├── py.typed ├── tests ├── __init__.py ├── benchmarks │ ├── __init__.py │ └── serializer │ │ └── __init__.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── startunicorn │ │ └── __init__.py ├── views │ ├── utils │ │ ├── __init__.py │ │ └── test_construct_model.py │ ├── action_parsers │ │ ├── __init__.py │ │ ├── utils │ │ │ └── __init__.py │ │ └── call_method │ │ │ └── __init__.py │ ├── test_unicorn_model.py │ ├── message │ │ ├── test_get_property_value.py │ │ ├── utils.py │ │ ├── test_sync_input.py │ │ ├── test_toggle.py │ │ └── test_calls.py │ ├── test_is_component_field_model_or_unicorn_field.py │ ├── test_unicorn_dict.py │ ├── test_unicorn_field.py │ ├── test_process_component_request.py │ ├── test_unicorn_view_init.py │ └── test_unicorn_set_property_value.py ├── templates │ ├── test_component.html │ ├── test_component_child.html │ ├── test_component_variable.html │ ├── test_component_args.html │ ├── test_component_model.html │ ├── test_component_kwargs_with_html_entity.html │ ├── test_component_kwargs.html │ ├── test_component_child_implicit.html │ ├── test_component_with_message.html │ ├── test_template.html │ ├── test_parent_template.html │ ├── test_parent_implicit_template.html │ ├── test_component_parent.html │ ├── test_component_parent_implicit.html │ ├── test_component_parent_with_value.html │ └── test_component_target.html ├── components │ ├── test_convert_to_dash_case.py │ ├── test_convert_to_snake_case.py │ ├── test_convert_to_pascal_case.py │ ├── test_create.py │ └── test_is_html_well_formed.py ├── js │ ├── unicorn │ │ ├── init.test.js │ │ ├── getComponent.test.js │ │ └── call.test.js │ ├── utils │ │ ├── contains.test.js │ │ ├── isEmpty.test.js │ │ ├── toKebabCase.test.js │ │ ├── toRegExp.test.js │ │ ├── args.test.js │ │ └── walk.test.js │ ├── element │ │ ├── poll.test.js │ │ ├── setValue.test.js │ │ ├── partial.test.js │ │ ├── init.test.js │ │ ├── visibility.test.js │ │ ├── getValue.test.js │ │ ├── model.test.js │ │ ├── errors.test.js │ │ └── loading.test.js │ └── component │ │ ├── init.test.js │ │ ├── callCalls.test.js │ │ ├── actions.test.js │ │ └── models.test.js ├── urls.py ├── templatetags │ ├── test_unicorn.py │ └── test_unicorn_scripts.py ├── serializer │ ├── test_exclude_field_attributes.py │ └── test_model_value.py ├── call_method_parser │ ├── test_parse_kwarg.py │ └── test_parse_call_method_name.py ├── test_settings.py └── test_model_lifecycle.py ├── .prettierignore ├── django_unicorn ├── __init__.py ├── templatetags │ └── __init__.py ├── views │ └── action_parsers │ │ ├── __init__.py │ │ └── sync_input.py ├── components │ ├── fields.py │ ├── mixins.py │ ├── __init__.py │ └── updaters.py ├── static │ └── unicorn │ │ └── js │ │ ├── .babelrc.json │ │ ├── store.js │ │ ├── morpher.js │ │ ├── morphers │ │ ├── alpine.js │ │ └── morphdom.js │ │ ├── delayers.js │ │ └── attribute.js ├── urls.py ├── templates │ └── unicorn │ │ ├── errors.html │ │ └── scripts.html ├── db.py ├── unicorn-ascii.txt ├── typing.py ├── errors.py └── decorators.py ├── example ├── apps │ ├── __init__.py │ └── main │ │ ├── __init__.py │ │ └── components │ │ └── sidebar_menu.py ├── project │ ├── __init__.py │ ├── wsgi.py │ └── urls.py ├── books │ ├── migrations │ │ ├── __init__.py │ │ ├── 0004_book_type.py │ │ ├── 0001_initial.py │ │ ├── 0002_author.py │ │ └── 0003_auto_20221110_0400.py │ ├── __init__.py │ ├── apps.py │ └── models.py ├── coffee │ ├── migrations │ │ ├── __init__.py │ │ ├── 0002_auto_20201205_1450.py │ │ ├── 0006_favorite.py │ │ ├── 0001_initial.py │ │ ├── 0004_origin_taste.py │ │ ├── 0005_auto_20221110_0400.py │ │ └── 0003_auto_20210128_0140.py │ ├── __init__.py │ ├── apps.py │ ├── admin.py │ ├── management │ │ └── commands │ │ │ └── import_flavors.py │ └── models.py ├── unicorn │ ├── components │ │ ├── nested │ │ │ ├── __init__.py │ │ │ ├── filter.py │ │ │ ├── row.py │ │ │ ├── favorite.py │ │ │ └── table.py │ │ ├── wizard │ │ │ ├── __init__.py │ │ │ ├── step1.py │ │ │ ├── step2.py │ │ │ └── wizard.py │ │ ├── direct_view.py │ │ ├── test_datetime.py │ │ ├── hello_world.py │ │ ├── text_inputs.py │ │ ├── validation.py │ │ ├── redirects.py │ │ ├── todo.py │ │ ├── polling.py │ │ ├── js.py │ │ ├── models.py │ │ ├── html_inputs.py │ │ └── objects.py │ ├── templates │ │ └── unicorn │ │ │ ├── nested │ │ │ ├── favorite.html │ │ │ ├── filter.html │ │ │ ├── row.html │ │ │ └── table.html │ │ │ ├── test-datetime.html │ │ │ ├── direct-view.html │ │ │ ├── wizard │ │ │ ├── step1.html │ │ │ ├── wizard.html │ │ │ └── step2.html │ │ │ ├── redirects.html │ │ │ ├── hello-world-test.html │ │ │ ├── polling.html │ │ │ ├── todo.html │ │ │ ├── validation.html │ │ │ ├── js.html │ │ │ └── models.html │ └── forms.py ├── static │ ├── js │ │ ├── alert.js │ │ └── console.js │ ├── css │ │ └── styles.css │ └── svg │ │ └── oval.svg ├── .watchmanconfig ├── www │ ├── templates │ │ └── www │ │ │ ├── objects.html │ │ │ ├── polling.html │ │ │ ├── models.html │ │ │ ├── js.html │ │ │ ├── wizard.html │ │ │ ├── test-datetime.html │ │ │ ├── validation.html │ │ │ ├── html-inputs.html │ │ │ ├── text-inputs.html │ │ │ ├── nested.html │ │ │ ├── index.html │ │ │ └── base.html │ ├── views.py │ └── urls.py └── manage.py ├── .babelrc ├── img └── unicorn-logo.png ├── .flake8 ├── setup.py ├── .editorconfig ├── .github ├── workflows │ ├── js.yml │ ├── coverage.yml │ ├── python.yml │ └── ci.yml ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── docs.yml │ ├── features.md │ └── bugs.yml ├── docs ├── Makefile ├── make.bat └── source │ ├── queue-requests.md │ ├── direct-view.md │ ├── custom-morphers.md │ ├── javascript.md │ ├── dirty-states.md │ ├── cli.md │ ├── troubleshooting.md │ ├── visibility.md │ ├── getting-started.md │ ├── partial-updates.md │ ├── messages.md │ ├── installation.md │ └── settings.md ├── .eslintrc.js ├── rollup.config.js ├── badges └── coverage.svg ├── LICENSE ├── SECURITY.md ├── package.json ├── DEVELOPING.md ├── .gitignore ├── CONTRIBUTING.md └── conftest.py /py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.html 2 | -------------------------------------------------------------------------------- /django_unicorn/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/apps/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/project/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/benchmarks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/apps/main/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/views/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/books/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/coffee/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/views/action_parsers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | presets: ["env"] 3 | } 4 | -------------------------------------------------------------------------------- /django_unicorn/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/benchmarks/serializer/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_unicorn/views/action_parsers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/unicorn/components/nested/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/unicorn/components/wizard/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/views/action_parsers/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/management/commands/startunicorn/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/views/action_parsers/call_method/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/templates/test_component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
-------------------------------------------------------------------------------- /example/static/js/alert.js: -------------------------------------------------------------------------------- 1 | alert("alert.js got called"); 2 | -------------------------------------------------------------------------------- /example/.watchmanconfig: -------------------------------------------------------------------------------- 1 | { 2 | "ignore_dirs": ["node_modules"] 3 | } -------------------------------------------------------------------------------- /example/books/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "books.apps.Config" 2 | -------------------------------------------------------------------------------- /example/coffee/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "coffee.apps.Config" 2 | -------------------------------------------------------------------------------- /example/static/js/console.js: -------------------------------------------------------------------------------- 1 | console.log("console.js got called"); 2 | -------------------------------------------------------------------------------- /tests/templates/test_component_child.html: -------------------------------------------------------------------------------- 1 |
2 | ==child== 3 |
-------------------------------------------------------------------------------- /tests/templates/test_component_variable.html: -------------------------------------------------------------------------------- 1 |
2 | {{ hello }} 3 |
-------------------------------------------------------------------------------- /tests/templates/test_component_args.html: -------------------------------------------------------------------------------- 1 |
2 | {{ hello }} 3 |
4 | -------------------------------------------------------------------------------- /tests/templates/test_component_model.html: -------------------------------------------------------------------------------- 1 |
2 | =={{ model_id }}== 3 |
-------------------------------------------------------------------------------- /tests/templates/test_component_kwargs_with_html_entity.html: -------------------------------------------------------------------------------- 1 |
2 | ->{{ hello }}<- 3 |
-------------------------------------------------------------------------------- /img/unicorn-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamghill/django-unicorn/HEAD/img/unicorn-logo.png -------------------------------------------------------------------------------- /tests/templates/test_component_kwargs.html: -------------------------------------------------------------------------------- 1 |
2 | {{ hello }} 3 | {{ request }} 4 |
-------------------------------------------------------------------------------- /example/static/css/styles.css: -------------------------------------------------------------------------------- 1 | div { 2 | margin-top: 40px; 3 | } 4 | 5 | label { 6 | font-weight: bold; 7 | } 8 | -------------------------------------------------------------------------------- /tests/templates/test_component_child_implicit.html: -------------------------------------------------------------------------------- 1 |
2 | ==child== 3 | has_parent:{{ has_parent }} 4 |
5 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | select = C,E,F,W,B,B950 4 | ignore = E501,W503,E231 5 | extend-ignore = E203,W503 -------------------------------------------------------------------------------- /tests/templates/test_component_with_message.html: -------------------------------------------------------------------------------- 1 |
2 | {% for m in messages %} 3 | {{ m }} 4 | {% endfor %} 5 |
-------------------------------------------------------------------------------- /example/books/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class Config(AppConfig): 5 | name = "example.books" 6 | -------------------------------------------------------------------------------- /example/coffee/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class Config(AppConfig): 5 | name = "example.coffee" 6 | -------------------------------------------------------------------------------- /tests/templates/test_template.html: -------------------------------------------------------------------------------- 1 | {% load unicorn %} 2 | 3 | {% unicorn 'tests.templatetags.test_unicorn_render.FakeComponentKwargs' %} -------------------------------------------------------------------------------- /example/unicorn/templates/unicorn/nested/favorite.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | -------------------------------------------------------------------------------- /tests/templates/test_parent_template.html: -------------------------------------------------------------------------------- 1 | {% load unicorn %} 2 | 3 | {% unicorn 'tests.templatetags.test_unicorn_render.FakeComponentParent' %} -------------------------------------------------------------------------------- /example/apps/main/components/sidebar_menu.py: -------------------------------------------------------------------------------- 1 | from django_unicorn.components import UnicornView 2 | 3 | 4 | class SidebarMenuView(UnicornView): 5 | pass 6 | -------------------------------------------------------------------------------- /tests/templates/test_parent_implicit_template.html: -------------------------------------------------------------------------------- 1 | {% load unicorn %} 2 | 3 | {% unicorn 'tests.templatetags.test_unicorn_render.FakeComponentParentImplicit' %} 4 | -------------------------------------------------------------------------------- /example/unicorn/templates/unicorn/nested/filter.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | Filter:
4 | Search: "{{ search }}" 5 |
6 | -------------------------------------------------------------------------------- /tests/templates/test_component_parent.html: -------------------------------------------------------------------------------- 1 | {% load unicorn %} 2 | 3 |
4 | --parent-- 5 | {% unicorn 'tests.templatetags.test_unicorn_render.FakeComponentChild' parent=view %} 6 |
-------------------------------------------------------------------------------- /tests/templates/test_component_parent_implicit.html: -------------------------------------------------------------------------------- 1 | {% load unicorn %} 2 | 3 |
4 | --parent-- 5 | {% unicorn 'tests.templatetags.test_unicorn_render.FakeComponentChildImplicit' %} 6 |
7 | -------------------------------------------------------------------------------- /example/unicorn/components/direct_view.py: -------------------------------------------------------------------------------- 1 | from django.utils.timezone import now 2 | 3 | from django_unicorn.components import UnicornView 4 | 5 | 6 | class DirectViewView(UnicornView): 7 | name = "test" 8 | -------------------------------------------------------------------------------- /example/coffee/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Flavor, Origin, Taste 4 | 5 | 6 | admin.site.register(Flavor) 7 | admin.site.register(Taste) 8 | admin.site.register(Origin) 9 | -------------------------------------------------------------------------------- /django_unicorn/components/fields.py: -------------------------------------------------------------------------------- 1 | class UnicornField: 2 | """ 3 | Base class to provide a way to serialize a component field quickly. 4 | """ 5 | 6 | def to_json(self): 7 | return self.__dict__ 8 | -------------------------------------------------------------------------------- /django_unicorn/static/unicorn/js/.babelrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/env", 5 | { 6 | "modules": false 7 | } 8 | ] 9 | ] 10 | } -------------------------------------------------------------------------------- /example/project/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.wsgi import get_wsgi_application 4 | 5 | 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings") 7 | 8 | application = get_wsgi_application() 9 | -------------------------------------------------------------------------------- /example/www/templates/www/objects.html: -------------------------------------------------------------------------------- 1 | {% extends "www/base.html" %} 2 | {% load static unicorn %} 3 | 4 | {% block content %} 5 | 6 |

Objects

7 | 8 | {% unicorn 'objects' %} 9 | 10 | {% endblock content %} -------------------------------------------------------------------------------- /example/www/templates/www/polling.html: -------------------------------------------------------------------------------- 1 | {% extends "www/base.html" %} 2 | {% load static unicorn %} 3 | 4 | {% block content %} 5 | 6 |

Polling

7 | 8 | {% unicorn 'polling' %} 9 | 10 | {% endblock content %} -------------------------------------------------------------------------------- /example/www/templates/www/models.html: -------------------------------------------------------------------------------- 1 | {% extends "www/base.html" %} 2 | {% load static unicorn %} 3 | 4 | {% block content %} 5 | 6 |

Models

7 | 8 | {% unicorn 'models' %} 9 | 10 | {% endblock content %} 11 | -------------------------------------------------------------------------------- /example/www/templates/www/js.html: -------------------------------------------------------------------------------- 1 | {% extends "www/base.html" %} 2 | {% load static unicorn %} 3 | 4 | {% block content %} 5 | 6 |

JavaScript integration

7 | 8 | {% unicorn 'js' %} 9 | 10 | {% endblock content %} 11 | -------------------------------------------------------------------------------- /example/www/templates/www/wizard.html: -------------------------------------------------------------------------------- 1 | {% extends "www/base.html" %} 2 | {% load static unicorn %} 3 | 4 | {% block content %} 5 | 6 |

Wizard

7 | 8 | {% unicorn 'wizard/wizard' %} 9 | 10 | {% endblock content %} 11 | -------------------------------------------------------------------------------- /tests/templates/test_component_parent_with_value.html: -------------------------------------------------------------------------------- 1 | {% load unicorn %} 2 | 3 |
4 | --parent-- 5 | {% unicorn 'tests.views.message.test_hash.FakeComponentChild' parent=view %} 6 | 7 | ||value:{{ value }}|| 8 |
9 | -------------------------------------------------------------------------------- /example/unicorn/templates/unicorn/test-datetime.html: -------------------------------------------------------------------------------- 1 | {% load unicorn %} 2 |
3 |
4 | {{ dt }}
5 | (click me) 6 |
7 |
8 | -------------------------------------------------------------------------------- /example/www/templates/www/test-datetime.html: -------------------------------------------------------------------------------- 1 | {% extends "www/base.html" %} 2 | {% load static unicorn %} 3 | 4 | {% block content %} 5 | 6 |

Test Datetime

7 | 8 | {% unicorn 'test-datetime' %} 9 | 10 | {% endblock content %} 11 | -------------------------------------------------------------------------------- /tests/templates/test_component_target.html: -------------------------------------------------------------------------------- 1 |
2 | ->{{ clicked }}<- 3 | 4 | 5 |
6 | 7 |
8 |
-------------------------------------------------------------------------------- /example/www/templates/www/validation.html: -------------------------------------------------------------------------------- 1 | {% extends "www/base.html" %} 2 | {% load static unicorn %} 3 | 4 | {% block content %} 5 | 6 |

Using Django forms for validation

7 | 8 | {% unicorn 'validation' hello="hello" %} 9 | 10 | {% endblock content %} -------------------------------------------------------------------------------- /example/unicorn/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | 4 | class ValidationForm(forms.Form): 5 | text = forms.CharField(min_length=3, max_length=10) 6 | date_time = forms.DateTimeField() 7 | number = forms.IntegerField() 8 | email = forms.EmailField() 9 | -------------------------------------------------------------------------------- /tests/components/test_convert_to_dash_case.py: -------------------------------------------------------------------------------- 1 | from django_unicorn.components.unicorn_view import convert_to_dash_case 2 | 3 | 4 | def test_convert_to_dash_case(): 5 | expected = "hello-world" 6 | actual = convert_to_dash_case("hello_world") 7 | 8 | assert expected == actual 9 | -------------------------------------------------------------------------------- /example/unicorn/templates/unicorn/direct-view.html: -------------------------------------------------------------------------------- 1 | {% extends "www/base.html" %} 2 | {% load unicorn %} 3 | 4 | {% block content %} 5 | 6 |
7 |

Direct View

8 | 9 | 10 | {{ name }} 11 |
12 | 13 | {% endblock content %} -------------------------------------------------------------------------------- /tests/components/test_convert_to_snake_case.py: -------------------------------------------------------------------------------- 1 | from django_unicorn.components.unicorn_view import convert_to_snake_case 2 | 3 | 4 | def test_convert_to_snake_case(): 5 | expected = "hello_world" 6 | actual = convert_to_snake_case("hello-world") 7 | 8 | assert expected == actual 9 | -------------------------------------------------------------------------------- /tests/components/test_convert_to_pascal_case.py: -------------------------------------------------------------------------------- 1 | from django_unicorn.components.unicorn_view import convert_to_pascal_case 2 | 3 | 4 | def test_convert_to_pascal_case(): 5 | expected = "HelloWorld" 6 | actual = convert_to_pascal_case("hello-world") 7 | 8 | assert expected == actual 9 | -------------------------------------------------------------------------------- /example/www/templates/www/html-inputs.html: -------------------------------------------------------------------------------- 1 | {% extends "www/base.html" %} 2 | {% load unicorn %} 3 | 4 | {% block content %} 5 | 6 |

HTML inputs

7 | 8 | {% unicorn 'html-inputs' %} 9 | 10 | {% unicorn 'todo' hello='world' %} 11 | 12 | {% unicorn 'todo' hello=example %} 13 | 14 | {% endblock content %} 15 | -------------------------------------------------------------------------------- /example/unicorn/components/wizard/step1.py: -------------------------------------------------------------------------------- 1 | from django_unicorn.components import UnicornView 2 | 3 | 4 | class Step1View(UnicornView): 5 | name: str 6 | email: str 7 | 8 | def mount(self): 9 | self.name = "Test" 10 | self.email = "test@example.com" 11 | 12 | def noop(self): 13 | print("no-op") 14 | -------------------------------------------------------------------------------- /example/unicorn/components/test_datetime.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from django.utils import timezone 4 | 5 | from django_unicorn.components import UnicornView 6 | 7 | 8 | class TestDatetimeView(UnicornView): 9 | dt: datetime = None 10 | 11 | def mount(self): 12 | self.dt = timezone.now() 13 | 14 | def foo(self): 15 | pass 16 | -------------------------------------------------------------------------------- /django_unicorn/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, re_path 2 | 3 | from django_unicorn import views 4 | 5 | app_name = "django_unicorn" 6 | 7 | 8 | urlpatterns = ( 9 | re_path(r"message/(?P[\w/\.-]+)", views.message, name="message"), 10 | path("message", views.message, name="message"), # Only here to build the correct url in scripts.html 11 | ) 12 | -------------------------------------------------------------------------------- /django_unicorn/static/unicorn/js/store.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Stores components. 3 | * 4 | * Key is the component id. 5 | * Value is the instantiated component. 6 | */ 7 | export const components = {}; 8 | 9 | /** 10 | * Stores lifecycle events to fire. 11 | * 12 | * Key is the event name. 13 | * Value is an array of callback functions. 14 | */ 15 | export const lifecycleEvents = {}; 16 | -------------------------------------------------------------------------------- /django_unicorn/templates/unicorn/errors.html: -------------------------------------------------------------------------------- 1 | {% if unicorn.errors.items %} 2 |
3 | 10 |
11 | {% endif %} -------------------------------------------------------------------------------- /django_unicorn/db.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from django.db.models import Model 4 | 5 | 6 | class DbModel: 7 | def __init__(self, name: str, model_class: Model, *, defaults: Optional[dict] = None): 8 | if defaults is None: 9 | defaults = {} 10 | self.name = name 11 | self.model_class = model_class 12 | self.defaults = defaults 13 | -------------------------------------------------------------------------------- /example/unicorn/components/wizard/step2.py: -------------------------------------------------------------------------------- 1 | from django_unicorn.components import UnicornView 2 | 3 | 4 | class Step2View(UnicornView): 5 | address: str 6 | city: str 7 | state: str 8 | zip_code: str 9 | 10 | def mount(self): 11 | self.address = "123 Main St" 12 | self.city = "Anytown" 13 | self.state = "CA" 14 | self.zip_code = "12345" 15 | -------------------------------------------------------------------------------- /example/unicorn/components/wizard/wizard.py: -------------------------------------------------------------------------------- 1 | from django_unicorn.components import UnicornView 2 | 3 | 4 | class WizardView(UnicornView): 5 | step: int = 1 6 | 7 | def next(self): 8 | self.step += 1 9 | 10 | def previous(self): 11 | self.step -= 1 12 | 13 | def finish(self): 14 | self.step = 3 15 | 16 | def start(self): 17 | self.step = 1 18 | -------------------------------------------------------------------------------- /example/www/templates/www/text-inputs.html: -------------------------------------------------------------------------------- 1 | {% extends "www/base.html" %} 2 | {% load static unicorn %} 3 | 4 | {% block content %} 5 | 6 |

Text inputs

7 | 8 | 9 | 10 | {% unicorn 'text-inputs' key="asdf" %} 11 | 12 | {% unicorn 'text-inputs' key="zxcv" %} 13 | 14 | {% endblock content %} -------------------------------------------------------------------------------- /example/unicorn/templates/unicorn/wizard/step1.html: -------------------------------------------------------------------------------- 1 |
2 |

Step 1

3 | 4 |
5 | 6 |
7 | 8 |
9 | Name: {{ name }} 10 |
11 | 12 | 13 | 14 | 15 | 16 |
17 | -------------------------------------------------------------------------------- /example/www/templates/www/nested.html: -------------------------------------------------------------------------------- 1 | {% extends "www/base.html" %} 2 | {% load static unicorn %} 3 | 4 | {% block content %} 5 | 6 | 14 | 15 |

Nested components (table)

16 | 17 | {% unicorn 'nested.table' %} 18 | 19 | {% endblock content %} 20 | -------------------------------------------------------------------------------- /django_unicorn/components/mixins.py: -------------------------------------------------------------------------------- 1 | from django_unicorn.serializer import model_value 2 | 3 | 4 | class ModelValueMixin: 5 | """ 6 | Adds a `value` method to a model similar to `QuerySet.values(*fields)` which serializes 7 | a model into a dictionary with the fields as specified in the `fields` argument. 8 | """ 9 | 10 | def value(self, *fields): 11 | return model_value(self, *fields) 12 | -------------------------------------------------------------------------------- /example/unicorn/components/hello_world.py: -------------------------------------------------------------------------------- 1 | from django.utils.timezone import now 2 | 3 | from django_unicorn.components import UnicornView 4 | 5 | 6 | class HelloWorldView(UnicornView): 7 | template_name = "unicorn/hello-world-test.html" 8 | 9 | name = "World" 10 | 11 | def set_name(self): 12 | self.name = "set_name method called" 13 | return "set_name called at " + now().strftime("%H:%M:%S.%f") 14 | -------------------------------------------------------------------------------- /tests/js/unicorn/init.test.js: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { init } from "../../../django_unicorn/static/unicorn/js/unicorn.js"; 3 | 4 | test("init unicorn", (t) => { 5 | const actual = init("unicorn/", "X-Unicorn", "unicorn", { NAME: "morphdom" }); 6 | 7 | t.true(actual.messageUrl === "unicorn/"); 8 | t.true(actual.csrfTokenHeaderName === "X-Unicorn"); 9 | t.true(actual.csrfTokenCookieName === "unicorn"); 10 | }); 11 | -------------------------------------------------------------------------------- /example/books/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Book(models.Model): 5 | TYPES = ((1, "Hardcover"), (2, "Softcover")) 6 | title = models.CharField(max_length=255) 7 | date_published = models.DateField() 8 | type = models.IntegerField(choices=TYPES, default=1) 9 | 10 | 11 | class Author(models.Model): 12 | name = models.CharField(max_length=1024) 13 | books = models.ManyToManyField(Book) 14 | -------------------------------------------------------------------------------- /example/unicorn/components/nested/filter.py: -------------------------------------------------------------------------------- 1 | from django_unicorn.components import UnicornView 2 | 3 | 4 | class FilterView(UnicornView): 5 | search = "" 6 | 7 | def updated_search(self, query): 8 | self.parent.load_table() 9 | 10 | if query: 11 | self.parent.flavors = list(filter(lambda f: query.lower() in f.name.lower(), self.parent.flavors)) 12 | 13 | self.parent.force_render = True 14 | -------------------------------------------------------------------------------- /example/project/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import include, path 3 | 4 | from unicorn.components.hello_world import HelloWorldView 5 | 6 | 7 | urlpatterns = [ 8 | path("admin/", admin.site.urls), 9 | path("", include("www.urls")), 10 | # Include django-unicorn urls 11 | path("unicorn/", include("django_unicorn.urls")), 12 | path("test", HelloWorldView.as_view(), name="test"), 13 | ] 14 | -------------------------------------------------------------------------------- /example/www/views.py: -------------------------------------------------------------------------------- 1 | from django.http.response import Http404 2 | from django.shortcuts import render 3 | from django.template.exceptions import TemplateDoesNotExist 4 | 5 | 6 | def index(request): 7 | return render(request, "www/index.html") 8 | 9 | 10 | def template(request, name): 11 | try: 12 | return render(request, f"www/{name}.html", context={"example": "test"}) 13 | except TemplateDoesNotExist: 14 | raise Http404 15 | -------------------------------------------------------------------------------- /example/unicorn/components/nested/row.py: -------------------------------------------------------------------------------- 1 | from django_unicorn.components import UnicornView 2 | from example.coffee.models import Flavor 3 | 4 | 5 | class RowView(UnicornView): 6 | model: Flavor = None 7 | is_editing = False 8 | is_updated_by_child = False 9 | 10 | def edit(self): 11 | self.is_editing = True 12 | 13 | def cancel(self): 14 | self.is_editing = False 15 | 16 | def save(self): 17 | self.model.save() 18 | self.is_editing = False 19 | -------------------------------------------------------------------------------- /example/unicorn/components/text_inputs.py: -------------------------------------------------------------------------------- 1 | from django.utils.timezone import now 2 | 3 | from django_unicorn.components import UnicornView 4 | 5 | 6 | class TextInputsView(UnicornView): 7 | name = "World" 8 | testing_xss = "Whatever " 9 | 10 | def set_name(self, name=None): 11 | if name: 12 | self.name = name 13 | else: 14 | self.name = "Universe" 15 | 16 | return f"{self.name} - {now().second}" 17 | -------------------------------------------------------------------------------- /tests/js/utils/contains.test.js: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { contains } from "../../../django_unicorn/static/unicorn/js/utils"; 3 | 4 | test("contains", (t) => { 5 | t.true(contains("abcdefg", "cde")); 6 | }); 7 | 8 | test("not contains", (t) => { 9 | t.false(contains("abcdefg", "xyz")); 10 | }); 11 | 12 | test("undefined contains", (t) => { 13 | t.false(contains(undefined, "xyz")); 14 | }); 15 | 16 | test("null contains", (t) => { 17 | t.false(contains(null, "xyz")); 18 | }); 19 | -------------------------------------------------------------------------------- /django_unicorn/components/__init__.py: -------------------------------------------------------------------------------- 1 | from django_unicorn.components.mixins import ModelValueMixin 2 | from django_unicorn.components.unicorn_view import UnicornField, UnicornView 3 | from django_unicorn.components.updaters import HashUpdate, LocationUpdate, PollUpdate 4 | from django_unicorn.typing import QuerySetType 5 | 6 | __all__ = [ 7 | "QuerySetType", 8 | "UnicornField", 9 | "UnicornView", 10 | "HashUpdate", 11 | "LocationUpdate", 12 | "PollUpdate", 13 | "ModelValueMixin", 14 | ] 15 | -------------------------------------------------------------------------------- /django_unicorn/unicorn-ascii.txt: -------------------------------------------------------------------------------- 1 | 2 | ,/ 3 | // 4 | ,// 5 | __ /| |// 6 | `__/\_ --(/|___/-/ 7 | \|\_-\___ __-_`- /-/ \. 8 | |\_-___,-\_____--/_)' ) \ 9 | \ -_ / __ \( `( __`\| 10 | `\__| |\) (/| 11 | ',--//-| \ | ' / 12 | /,---| \ / 13 | `_/ _,' | | 14 | __/'/ | | 15 | ___/ \ ( ) / 16 | \____/\ 17 | \ 18 | -------------------------------------------------------------------------------- /example/books/migrations/0004_book_type.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.5 on 2023-10-29 22:53 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("books", "0003_auto_20221110_0400"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="book", 14 | name="type", 15 | field=models.IntegerField(choices=[(1, "Hardcover"), (2, "Softcover")], default=1), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /example/unicorn/templates/unicorn/wizard/wizard.html: -------------------------------------------------------------------------------- 1 | {% load unicorn %} 2 |
3 | {% if step == 1 %} 4 | {% unicorn "wizard/step1" id="step1" %} 5 | {% elif step == 2 %} 6 | {% unicorn "wizard/step2" id="step2" %} 7 | {% elif step == 3 %} 8 |
9 | Last step! {{ step }} 10 |
11 | 12 |
13 | 14 | 15 |
16 | {% else %} 17 | UNKNOWN STEP! {{ step }} 18 | {% endif %} 19 |
20 | -------------------------------------------------------------------------------- /tests/js/utils/isEmpty.test.js: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { isEmpty } from "../../../django_unicorn/static/unicorn/js/utils"; 3 | 4 | test("undefined isEmpty", (t) => { 5 | t.true(isEmpty(undefined)); 6 | }); 7 | 8 | test("null isEmpty", (t) => { 9 | t.true(isEmpty(null)); 10 | }); 11 | 12 | test("{} isEmpty", (t) => { 13 | t.true(isEmpty({})); 14 | }); 15 | 16 | test("object isEmpty", (t) => { 17 | t.false(isEmpty({ test: 123 })); 18 | }); 19 | 20 | test("empty string isEmpty", (t) => { 21 | t.true(isEmpty("")); 22 | }); 23 | -------------------------------------------------------------------------------- /example/unicorn/templates/unicorn/wizard/step2.html: -------------------------------------------------------------------------------- 1 |
2 |
Step 2
3 | 4 |
5 | 6 | 7 | 8 | 9 |
10 | 11 |
12 | Address: {{ address }}
13 | City: {{ city }}
14 | State: {{ state }}
15 | Zip code: {{ zip_code }}
16 |
17 | 18 | 19 | 20 |
21 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # This is a temporary solution for the fact that `pip install -e` 4 | # fails when there is not a `setup.py`. 5 | 6 | import json 7 | from configparser import ConfigParser 8 | from distutils.core import setup 9 | 10 | 11 | def project_info(): 12 | config = ConfigParser() 13 | config.read("pyproject.toml") 14 | project = config["tool.poetry"] 15 | return { 16 | "name": json.loads(project["name"]), 17 | "version": json.loads(project["version"]), 18 | } 19 | 20 | 21 | setup(**project_info()) 22 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | # Helps keep Windows, Mac, Linux on the same page since they handle end of line differently 5 | end_of_line = lf 6 | charset = utf-8 7 | # Auto-trims trailing whitespace 8 | trim_trailing_whitespace = true 9 | # Auto-adds a blank newline to the end of a file 10 | insert_final_newline = true 11 | 12 | [*.scss] 13 | # More common to see 2 spaces in SCSS, HTML, and JS 14 | indent_size = 2 15 | 16 | [*.html] 17 | indent_size = 2 18 | 19 | [*.js] 20 | indent_size = 2 21 | 22 | [*.py] 23 | indent_style = space 24 | indent_size = 4 25 | -------------------------------------------------------------------------------- /example/unicorn/templates/unicorn/redirects.html: -------------------------------------------------------------------------------- 1 | {% extends "www/base.html" %} 2 | {% load unicorn %} 3 | 4 | {% block content %} 5 | 6 |
7 |

Redirects

8 | 9 |
10 | Update Location 11 |
12 | Current location count: {{ location_count }} 13 |
14 | 15 |
16 | Update Hash 17 |
18 | Current hash count: {{ hash_count }} 19 |
20 | 21 |
22 | Redirect 23 |
24 |
25 | 26 | {% endblock content %} -------------------------------------------------------------------------------- /example/unicorn/components/nested/favorite.py: -------------------------------------------------------------------------------- 1 | from django_unicorn.components import UnicornView 2 | from example.coffee.models import Favorite 3 | 4 | 5 | class FavoriteView(UnicornView): 6 | model: Favorite = None 7 | is_editing = False 8 | 9 | def updated(self, name, value): 10 | if not self.model: 11 | self.model = Favorite(flavor_id=self.parent.model.id, is_favorite=value) 12 | 13 | self.model.save() 14 | self.parent.is_updated_by_child = value 15 | self.parent.parent.favorite_count += 1 if value else -1 16 | 17 | self.parent.parent.force_render = True 18 | -------------------------------------------------------------------------------- /tests/js/utils/toKebabCase.test.js: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { toKebabCase } from "../../../django_unicorn/static/unicorn/js/utils"; 3 | 4 | test("Enter to kebab case", (t) => { 5 | t.is(toKebabCase("Enter"), "enter"); 6 | }); 7 | 8 | test("Escape to kebab case", (t) => { 9 | t.is(toKebabCase("Escape"), "escape"); 10 | }); 11 | 12 | test("Null to empty string", (t) => { 13 | t.is(toKebabCase(null), ""); 14 | }); 15 | 16 | test("Empty string to empty string", (t) => { 17 | t.is(toKebabCase(""), ""); 18 | }); 19 | 20 | test("One space string to empty string", (t) => { 21 | t.is(toKebabCase(" "), " "); 22 | }); 23 | -------------------------------------------------------------------------------- /.github/workflows/js.yml: -------------------------------------------------------------------------------- 1 | name: JavaScript 2 | on: 3 | push: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v4.1.1 12 | with: 13 | fetch-depth: 1 14 | 15 | - name: Set up node 16 | uses: actions/setup-node@v4.0.2 17 | 18 | - name: Install node packages 19 | run: npm install 20 | 21 | - name: See if unicorn.min.js is up-to-date 22 | run: | 23 | npm run build 24 | git diff --stat --exit-code 25 | 26 | - name: Test with ava 27 | run: npm run-script test 28 | -------------------------------------------------------------------------------- /example/www/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from example.unicorn.components.direct_view import DirectViewView 4 | from example.unicorn.components.redirects import RedirectsView 5 | from example.www import views 6 | 7 | 8 | app_name = "www" 9 | 10 | urlpatterns = [ 11 | path("", views.index, name="index"), 12 | path( 13 | "direct-view", 14 | DirectViewView.as_view(), 15 | name="direct-view", 16 | ), 17 | path( 18 | "redirects", 19 | RedirectsView.as_view(), 20 | name="redirects", 21 | ), 22 | path("", views.template, name="template"), 23 | ] 24 | -------------------------------------------------------------------------------- /example/www/templates/www/index.html: -------------------------------------------------------------------------------- 1 | {% extends "www/base.html" %} 2 | {% load static unicorn %} 3 | 4 | {% block content %} 5 | 6 |

Index

7 | 8 | 11 | 12 | 15 | 16 | 17 | {% unicorn 'unicorn.components.hello_world.HelloWorldView' name=abcdf %} 18 | 19 | {% endblock content %} 20 | -------------------------------------------------------------------------------- /example/unicorn/components/validation.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from django_unicorn.components import UnicornView 4 | 5 | from ..forms import ValidationForm 6 | 7 | 8 | class ValidationView(UnicornView): 9 | form_class = ValidationForm 10 | 11 | text = "hello" 12 | number = "" 13 | date_time = datetime(2020, 9, 13, 17, 45, 14) 14 | email = "" 15 | 16 | def set_text_no_validation(self): 17 | self.text = "no validation" 18 | 19 | def set_text_with_validation(self): 20 | self.text = "validation" 21 | self.validate() 22 | 23 | def set_number(self, number): 24 | self.number = number 25 | -------------------------------------------------------------------------------- /example/books/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.8 on 2020-08-22 20:55 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Book', 16 | fields=[ 17 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('title', models.CharField(max_length=255)), 19 | ('date_published', models.DateField()), 20 | ], 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /django_unicorn/typing.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, Iterator, TypeVar 2 | 3 | from django.db.models import Model, QuerySet 4 | 5 | M_co = TypeVar("M_co", bound=Model, covariant=True) 6 | 7 | 8 | class QuerySetType(Generic[M_co], QuerySet): 9 | """ 10 | Type for QuerySet that can be used for a typehint in components. 11 | """ 12 | 13 | # This is based on https://github.com/Vieolo/django-hint/blob/97e22bf/django_hint/typehint.py#L167, 14 | # although https://github.com/typeddjango/django-stubs/blob/2a732fd/django-stubs/db/models/manager.pyi#L28 15 | # might be a better long-term solution. 16 | 17 | def __iter__(self) -> Iterator[M_co]: 18 | ... 19 | -------------------------------------------------------------------------------- /example/books/migrations/0002_author.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-06-17 14:36 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('books', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='Author', 15 | fields=[ 16 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 17 | ('name', models.CharField(max_length=1024)), 18 | ('books', models.ManyToManyField(to='books.Book')), 19 | ], 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /example/coffee/migrations/0002_auto_20201205_1450.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-12-05 14:50 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('coffee', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='flavor', 15 | name='decimal_value', 16 | field=models.DecimalField(decimal_places=2, max_digits=10, null=True), 17 | ), 18 | migrations.AddField( 19 | model_name='flavor', 20 | name='float_value', 21 | field=models.FloatField(null=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /tests/js/utils/toRegExp.test.js: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { toRegExp } from "../../../django_unicorn/static/unicorn/js/utils"; 3 | 4 | test("To regexp 'test-*'", (t) => { 5 | t.deepEqual(toRegExp("test-*"), /(test-)[a-zA-Z0-9_:.\-]*/); 6 | }); 7 | 8 | test("To regexp '*-test'", (t) => { 9 | t.deepEqual(toRegExp("*-test"), /[a-zA-Z0-9_:.\-]*(-test)/); 10 | }); 11 | 12 | test("To regexp 'test-*-final'", (t) => { 13 | t.deepEqual(toRegExp("test-*-final"), /(test-)[a-zA-Z0-9_:.\-]*(-final)/); 14 | }); 15 | 16 | test("To regexp 'test-*-v*-final'", (t) => { 17 | t.deepEqual( 18 | toRegExp("test-*-v*-final"), 19 | /(test-)[a-zA-Z0-9_:.\-]*(-v)[a-zA-Z0-9_:.\-]*(-final)/ 20 | ); 21 | }); 22 | -------------------------------------------------------------------------------- /django_unicorn/static/unicorn/js/morpher.js: -------------------------------------------------------------------------------- 1 | import { MorphdomMorpher } from "./morphers/morphdom.js"; 2 | import { AlpineMorpher } from "./morphers/alpine.js"; 3 | import { isEmpty } from "./utils.js"; 4 | 5 | const MORPHER_CLASSES = { 6 | morphdom: MorphdomMorpher, 7 | alpine: AlpineMorpher, 8 | }; 9 | 10 | export function getMorpher(morpherSettings) { 11 | const morpherName = morpherSettings.NAME; 12 | 13 | if (isEmpty(morpherName)) { 14 | throw Error(" Missing morpher name"); 15 | } 16 | 17 | const MorpherClass = MORPHER_CLASSES[morpherName]; 18 | 19 | if (MorpherClass) { 20 | return new MorpherClass(morpherSettings); 21 | } 22 | 23 | throw Error(`Unknown morpher: ${morpherName}`); 24 | } 25 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings") 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == "__main__": 21 | main() 22 | -------------------------------------------------------------------------------- /example/static/svg/oval.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [adamghill] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /example/books/migrations/0003_auto_20221110_0400.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.15 on 2022-11-10 04:00 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('books', '0002_author'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='author', 15 | name='id', 16 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 17 | ), 18 | migrations.AlterField( 19 | model_name='book', 20 | name='id', 21 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /tests/js/element/poll.test.js: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { getElement } from "../utils.js"; 3 | 4 | test("poll", (t) => { 5 | const html = "
"; 6 | const element = getElement(html); 7 | 8 | t.is(element.poll.method, "test()"); 9 | t.is(element.poll.timing, 2000); 10 | }); 11 | 12 | test("poll-1000", (t) => { 13 | const html = "
"; 14 | const element = getElement(html); 15 | 16 | t.is(element.poll.timing, 1000); 17 | }); 18 | 19 | test("poll.disable", (t) => { 20 | const html = 21 | "
"; 22 | const element = getElement(html); 23 | 24 | t.is(element.poll.timing, 2000); 25 | t.is(element.poll.disableData, "disabled_poll"); 26 | }); 27 | -------------------------------------------------------------------------------- /example/coffee/migrations/0006_favorite.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.16 on 2023-02-14 00:02 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('coffee', '0005_auto_20221110_0400'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Favorite', 16 | fields=[ 17 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('is_favorite', models.BooleanField(default=False)), 19 | ('flavor', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='coffee.flavor')), 20 | ], 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /example/unicorn/components/redirects.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import redirect 2 | from django.utils.timezone import now 3 | 4 | from django_unicorn.components import HashUpdate, LocationUpdate, UnicornView 5 | 6 | 7 | class RedirectsView(UnicornView): 8 | location_count: int = 0 9 | hash_count: int = 0 10 | redirect_count: int = 0 11 | 12 | def update_location(self): 13 | self.location_count += 1 14 | 15 | return LocationUpdate( 16 | redirect(f"/redirects#{self.location_count}"), 17 | title=f"{self.location_count}", 18 | ) 19 | 20 | def update_hash(self): 21 | self.hash_count += 1 22 | 23 | return HashUpdate(f"#{self.hash_count}") 24 | 25 | def do_redirect(self): 26 | return redirect(f"/redirects#{now().timestamp()}") 27 | -------------------------------------------------------------------------------- /example/coffee/management/commands/import_flavors.py: -------------------------------------------------------------------------------- 1 | import csv 2 | 3 | from django.core.management.base import BaseCommand, CommandError 4 | 5 | from ...models import Favorite, Flavor 6 | 7 | 8 | class Command(BaseCommand): 9 | def handle(self, *args, **options): 10 | with open("example/coffee/management/commands/flavors.csv", "r") as f: 11 | csv_reader = csv.reader(f) 12 | 13 | for row in csv_reader: 14 | next(csv_reader) 15 | 16 | name = row[0] 17 | label = row[1] 18 | parent_name = row[2] 19 | 20 | parent = Flavor.objects.filter(name=parent_name).first() 21 | flavor = Flavor(name=name, label=label, parent=parent) 22 | flavor.save() 23 | Favorite.objects.create(flavor=flavor) 24 | -------------------------------------------------------------------------------- /example/coffee/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.8 on 2020-09-29 02:04 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='Flavor', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('name', models.CharField(max_length=255)), 20 | ('label', models.CharField(max_length=255)), 21 | ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='coffee.Flavor')), 22 | ], 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /tests/views/test_unicorn_model.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Model 2 | from django.db.models.fields import CharField 3 | 4 | from django_unicorn.components import UnicornView 5 | from django_unicorn.views.utils import set_property_from_data 6 | 7 | 8 | class FakeModel(Model): 9 | name = CharField(max_length=255) 10 | 11 | class Meta: 12 | app_label = "www" 13 | 14 | 15 | class ModelPropertyView(UnicornView): 16 | model = FakeModel(name="fake_model") 17 | 18 | 19 | def test_set_property_from_data_model(): 20 | component = ModelPropertyView(component_name="test", component_id="test_set_property_from_data_model") 21 | assert "fake_model" == component.model.name 22 | 23 | set_property_from_data(component, "model", {"name": "fake_model_updated"}) 24 | 25 | assert "fake_model_updated" == component.model.name 26 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | from django.urls import include, path 3 | from django.views.generic import TemplateView 4 | 5 | 6 | def parent_view(request): 7 | return render(request, "templates/test_parent_template.html") 8 | 9 | 10 | def parent_implicit_view(request): 11 | return render(request, "templates/test_parent_implicit_template.html") 12 | 13 | 14 | urlpatterns = ( 15 | path("test", TemplateView.as_view(template_name="templates/test_template.html")), 16 | path("test-parent", parent_view, name="test-parent"), 17 | path("test-parent-implicit", parent_implicit_view, name="test-parent-implicit"), 18 | path( 19 | "test-parent-template", 20 | TemplateView.as_view(template_name="templates/test_parent_template.html"), 21 | ), 22 | path("", include("django_unicorn.urls")), 23 | ) 24 | -------------------------------------------------------------------------------- /example/unicorn/templates/unicorn/hello-world-test.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 |
6 |
7 | 8 | 9 | 10 | 11 | Hello, {{ name|default:'World' }}! 12 | 13 |
14 | Request path context variable: '{{ request.path }}' 15 |
16 | 17 | 23 |
24 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parserOptions: { 4 | parser: "babel-eslint", 5 | }, 6 | env: { 7 | browser: true, 8 | }, 9 | extends: ["airbnb-base", "prettier"], 10 | rules: { 11 | "no-console": "warn", 12 | quotes: ["error", "double"], 13 | "max-len": ["error", { code: 140, ignoreStrings: true, ignoreUrls: true }], 14 | "import/no-unresolved": 0, 15 | "linebreak-style": 0, 16 | "comma-dangle": 0, 17 | "import/extensions": ["error", "always", { ignorePackages: true }], 18 | "import/prefer-default-export": 0, 19 | "no-unused-expressions": ["error", { allowTernary: true }], 20 | "no-underscore-dangle": 0, 21 | "no-param-reassign": 0, 22 | "object-curly-newline": ["error", { ObjectPattern: "never" }], 23 | "no-plusplus": ["error", { allowForLoopAfterthoughts: true }], 24 | "max-classes-per-file": 0, 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /tests/js/component/init.test.js: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { getComponent } from "../utils.js"; 3 | 4 | test("unicorn:id", (t) => { 5 | const component = getComponent(); 6 | 7 | t.true(component.root != null); 8 | t.is(component.id, "5jypjiyb"); 9 | }); 10 | 11 | test("unicorn:name", (t) => { 12 | const component = getComponent(); 13 | 14 | t.is(component.name, "text-inputs"); 15 | }); 16 | 17 | test("unicorn:checksum", (t) => { 18 | const component = getComponent(); 19 | 20 | t.is(component.checksum, "GXzew3Km"); 21 | }); 22 | 23 | test("component on non-div", (t) => { 24 | const html = ` 25 | 26 | `; 27 | const component = getComponent(html); 28 | 29 | t.is(component.root.attributes.length, 3); 30 | t.is(component.id, "5jypjiyb"); 31 | t.is(component.checksum, "GXzew3Km"); 32 | }); 33 | -------------------------------------------------------------------------------- /django_unicorn/errors.py: -------------------------------------------------------------------------------- 1 | class UnicornCacheError(Exception): 2 | pass 3 | 4 | 5 | class UnicornViewError(Exception): 6 | pass 7 | 8 | 9 | class ComponentLoadError(Exception): 10 | def __init__(self, *args, locations=None, **kwargs): 11 | super().__init__(*args, **kwargs) 12 | self.locations = locations 13 | 14 | 15 | class ComponentModuleLoadError(ComponentLoadError): 16 | pass 17 | 18 | 19 | class ComponentClassLoadError(ComponentLoadError): 20 | pass 21 | 22 | 23 | class RenderNotModifiedError(Exception): 24 | pass 25 | 26 | 27 | class MissingComponentElementError(Exception): 28 | pass 29 | 30 | 31 | class MissingComponentViewElementError(Exception): 32 | pass 33 | 34 | 35 | class NoRootComponentElementError(Exception): 36 | pass 37 | 38 | 39 | class MultipleRootComponentElementError(Exception): 40 | pass 41 | 42 | 43 | class ComponentNotValidError(Exception): 44 | pass 45 | -------------------------------------------------------------------------------- /tests/templatetags/test_unicorn.py: -------------------------------------------------------------------------------- 1 | from django.template.base import Parser, Token, TokenType 2 | 3 | from django_unicorn.templatetags.unicorn import unicorn 4 | 5 | 6 | def test_unicorn(): 7 | token = Token(TokenType.TEXT, "unicorn 'todo'") 8 | unicorn_node = unicorn(Parser([]), token) 9 | 10 | assert unicorn_node.component_name.resolve({}) == "todo" 11 | assert len(unicorn_node.kwargs) == 0 12 | 13 | 14 | def test_unicorn_kwargs(): 15 | token = Token(TokenType.TEXT, "unicorn 'todo' blob='blob'") 16 | unicorn_node = unicorn(Parser([]), token) 17 | 18 | assert unicorn_node.kwargs == {"blob": "blob"} 19 | 20 | 21 | def test_unicorn_args_and_kwargs(): 22 | # args after the component name get ignored 23 | token = Token(TokenType.TEXT, "unicorn 'todo' '1' 2 hello='world' test=3 '4'") 24 | unicorn_node = unicorn(Parser([]), token) 25 | 26 | assert unicorn_node.kwargs == {"hello": "world", "test": 3} 27 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import filesize from "rollup-plugin-filesize"; 2 | import { terser } from "rollup-plugin-terser"; 3 | import commonjs from "@rollup/plugin-commonjs"; 4 | import resolve from "@rollup/plugin-node-resolve"; 5 | import babel from "@rollup/plugin-babel"; 6 | import versionInjector from "rollup-plugin-version-injector"; 7 | 8 | export default { 9 | input: "django_unicorn/static/unicorn/js/unicorn.js", 10 | output: { 11 | file: "django_unicorn/static/unicorn/js/unicorn.min.js", 12 | format: "iife", 13 | name: "Unicorn", 14 | }, 15 | plugins: [ 16 | resolve(), 17 | terser({ 18 | mangle: true, 19 | }), 20 | versionInjector({ 21 | injectInComments: { 22 | fileRegexp: /\.js$/, 23 | tag: "Version: {version}", 24 | dateFormat: "mmmm d, yyyy HH:MM:ss", 25 | } 26 | }), 27 | commonjs({}), 28 | babel({ babelHelpers: "bundled" }), 29 | filesize(), 30 | ], 31 | }; 32 | -------------------------------------------------------------------------------- /tests/components/test_create.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django_unicorn.components.unicorn_view import UnicornView 4 | from django_unicorn.errors import ComponentModuleLoadError 5 | 6 | 7 | def test_no_component(): 8 | with pytest.raises(ComponentModuleLoadError) as e: 9 | UnicornView.create(component_id="create-no-component", component_name="create-no-component") 10 | 11 | assert ( 12 | e.exconly() 13 | == "django_unicorn.errors.ComponentModuleLoadError: The component module 'create_no_component' could not be loaded." # noqa: E501 14 | ) 15 | 16 | 17 | class FakeComponent(UnicornView): 18 | pass 19 | 20 | 21 | def test_components_settings(settings): 22 | settings.UNICORN["COMPONENTS"] = {"create-components-setting": FakeComponent} 23 | 24 | component = UnicornView.create( 25 | component_id="create-components-setting-id", component_name="create-components-setting" 26 | ) 27 | assert component 28 | -------------------------------------------------------------------------------- /example/unicorn/components/todo.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from django_unicorn.components import UnicornView 4 | 5 | 6 | class TodoForm(forms.Form): 7 | task = forms.CharField(min_length=3, max_length=10, required=True) 8 | 9 | 10 | class TodoView(UnicornView): 11 | form_class = TodoForm 12 | 13 | task = "" 14 | tasks = [] 15 | 16 | def __init__(self, *args, **kwargs): 17 | super().__init__(**kwargs) 18 | self.hello = kwargs.get("hello", "not available") 19 | print("__init__() self.request", self.request) 20 | print("__init__() self.hello", self.hello) 21 | 22 | def hydrate(self): 23 | print("hydrate() self.hello", self.hello) 24 | 25 | def mount(self): 26 | print("mount() self.hello", self.hello) 27 | 28 | def add(self): 29 | print("add() self.hello", self.hello) 30 | 31 | if self.is_valid(): 32 | self.tasks.append(self.task) 33 | self.task = "" 34 | -------------------------------------------------------------------------------- /django_unicorn/static/unicorn/js/morphers/alpine.js: -------------------------------------------------------------------------------- 1 | export class AlpineMorpher { 2 | constructor(options) { 3 | this.options = options; 4 | } 5 | 6 | morph(dom, htmlElement) { 7 | if (htmlElement) { 8 | // Check if window has Alpine and Alpine Morph 9 | if (!window.Alpine || !window.Alpine.morph) { 10 | throw Error(` 11 | Alpine.js and the Alpine morph plugin can not be found. 12 | See https://www.django-unicorn.com/docs/custom-morphers/#alpine for more information. 13 | `); 14 | } 15 | 16 | return window.Alpine.morph(dom, htmlElement, this.getOptions()); 17 | } 18 | } 19 | 20 | getOptions() { 21 | return { 22 | key(el) { 23 | if (el.attributes) { 24 | const key = 25 | el.getAttribute("unicorn:key") || el.getAttribute("u:key") || el.id; 26 | 27 | if (key) { 28 | return key; 29 | } 30 | } 31 | 32 | return el.id; 33 | }, 34 | }; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /example/unicorn/components/polling.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from django.contrib import messages 4 | from django.utils.timezone import now 5 | 6 | from django_unicorn.components import PollUpdate, UnicornView 7 | 8 | 9 | class PollingView(UnicornView): 10 | polling_disabled = False 11 | date_example = now() 12 | current_time = now() 13 | counter = 0 14 | 15 | def slow_update(self): 16 | self.counter += 1 17 | time.sleep(0.8) # Simulate slow request 18 | 19 | def get_date(self): 20 | self.current_time = now() 21 | self.date_example = now() 22 | 23 | messages.error(self.request, "get_date called :(") 24 | 25 | return PollUpdate(timing=2000, disable=False, method="get_date_2") 26 | 27 | def get_date_2(self): 28 | self.current_time = now() 29 | self.date_example = now() 30 | 31 | messages.success(self.request, "get_date2 called :)") 32 | 33 | return PollUpdate(timing=1000, disable=False, method="get_date") 34 | -------------------------------------------------------------------------------- /tests/views/message/test_get_property_value.py: -------------------------------------------------------------------------------- 1 | from tests.views.fake_components import FakeComponent 2 | 3 | from django_unicorn.views.action_parsers.call_method import _get_property_value 4 | 5 | 6 | def test_get_property_value(): 7 | component = FakeComponent(component_name="test", component_id="asdf") 8 | 9 | component.check = False 10 | check_value = _get_property_value(component, "check") 11 | assert check_value is False 12 | 13 | component.check = True 14 | check_value = _get_property_value(component, "check") 15 | 16 | assert check_value is True 17 | 18 | 19 | def test_get_property_value_nested(): 20 | component = FakeComponent(component_name="test", component_id="asdf") 21 | 22 | component.nested["check"] = False 23 | check_value = _get_property_value(component, "nested.check") 24 | assert check_value is False 25 | 26 | component.nested["check"] = True 27 | check_value = _get_property_value(component, "nested.check") 28 | 29 | assert check_value is True 30 | -------------------------------------------------------------------------------- /docs/source/queue-requests.md: -------------------------------------------------------------------------------- 1 | # Queue Requests 2 | 3 | This is an experimental feature of that queues up slow-processing component views to prevent race conditions. For simple components this should not be necessary. 4 | 5 | Serialization is turned off by default, but can be enabled in the [settings](settings.md#serial). 6 | 7 | ```{warning} 8 | This feature will be disabled automatically if the cache backend is set to ["django.core.cache.backends.dummy.DummyCache"](https://docs.djangoproject.com/en/stable/topics/cache/#dummy-caching-for-development). 9 | 10 | [Local memory caching](https://docs.djangoproject.com/en/3.1/topics/cache/#local-memory-caching) (the default if no `CACHES` setting is provided) will work fine if the web server only has one process. For more production use cases, consider using [`redis`](https://github.com/jazzband/django-redis), [`Memcache`](https://docs.djangoproject.com/en/stable/topics/cache/#memcached), or [database caching](https://docs.djangoproject.com/en/stable/topics/cache/#database-caching). 11 | ``` 12 | -------------------------------------------------------------------------------- /example/unicorn/templates/unicorn/polling.html: -------------------------------------------------------------------------------- 1 |
2 | {% if messages %} 3 |
    4 | {% for message in messages %} 5 | {{ message }} 6 | {% endfor %} 7 |
8 | {% endif %} 9 | 10 |
11 | poll-id: {{ current_time|date:"s" }} 12 |
13 | 14 | current_time: {{ current_time|date:"s" }} 15 | 16 |

17 | 18 | 19 |

20 | 21 | date_example: {{ date_example }} | {{ date_example|date:"s" }}
22 | 23 | 24 | 25 | 26 |
27 | -------------------------------------------------------------------------------- /tests/views/test_is_component_field_model_or_unicorn_field.py: -------------------------------------------------------------------------------- 1 | from django_unicorn.components import UnicornView 2 | from django_unicorn.views.utils import _is_component_field_model_or_unicorn_field 3 | from example.coffee.models import Flavor 4 | 5 | 6 | class TypeHintView(UnicornView): 7 | model: Flavor = None 8 | 9 | 10 | class ModelInstanceView(UnicornView): 11 | model = Flavor() 12 | 13 | 14 | def test_type_hint(): 15 | component = TypeHintView(component_name="asdf", component_id="test_type_hint") 16 | name = "model" 17 | actual = _is_component_field_model_or_unicorn_field(component, name) 18 | 19 | assert actual 20 | assert component.model is not None 21 | assert type(component.model) is Flavor 22 | 23 | 24 | def test_model_instance(): 25 | component = ModelInstanceView(component_name="asdf", component_id="test_model_instance") 26 | name = "model" 27 | actual = _is_component_field_model_or_unicorn_field(component, name) 28 | 29 | assert actual 30 | assert component.model is not None 31 | assert type(component.model) is Flavor 32 | -------------------------------------------------------------------------------- /django_unicorn/views/action_parsers/sync_input.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from django_unicorn.components import UnicornView 4 | from django_unicorn.views.action_parsers.utils import set_property_value 5 | from django_unicorn.views.objects import ComponentRequest 6 | 7 | 8 | def handle(component_request: ComponentRequest, component: UnicornView, payload: Dict): 9 | property_name = payload.get("name") 10 | property_value = payload.get("value") 11 | 12 | call_resolved_method = True 13 | 14 | # If there is more than one action then only call the resolved methods for the last action in the queue 15 | if len(component_request.action_queue) > 1: 16 | call_resolved_method = False 17 | last_action = component_request.action_queue[-1:][0] 18 | 19 | if last_action.payload.get("name") == property_name and last_action.payload.get("value") == property_value: 20 | call_resolved_method = True 21 | 22 | set_property_value( 23 | component, property_name, property_value, component_request.data, call_resolved_method=call_resolved_method 24 | ) 25 | -------------------------------------------------------------------------------- /tests/js/element/setValue.test.js: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { getElement } from "../utils.js"; 3 | 4 | test("setValue()", (t) => { 5 | const html = ""; 6 | const element = getElement(html); 7 | 8 | element.setValue("test"); 9 | t.is(element.getValue(), "test"); 10 | }); 11 | 12 | test("setValue() radio with value", (t) => { 13 | const html = ""; 14 | const element = getElement(html); 15 | 16 | element.setValue(true); 17 | t.is(element.getValue(), "test"); 18 | }); 19 | 20 | test("setValue() radio no value", (t) => { 21 | const html = ""; 22 | const element = getElement(html); 23 | 24 | element.setValue(true); 25 | t.is(element.getValue(), "on"); 26 | }); 27 | 28 | test("setValue() checkbox", (t) => { 29 | const html = ""; 30 | const element = getElement(html); 31 | 32 | t.false(element.getValue()); 33 | element.setValue(true); 34 | t.true(element.getValue()); 35 | }); 36 | -------------------------------------------------------------------------------- /example/coffee/migrations/0004_origin_taste.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-04-11 18:00 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('coffee', '0003_auto_20210128_0140'), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='Taste', 15 | fields=[ 16 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 17 | ('name', models.CharField(max_length=255)), 18 | ('flavor', models.ManyToManyField(to='coffee.Flavor')), 19 | ], 20 | ), 21 | migrations.CreateModel( 22 | name='Origin', 23 | fields=[ 24 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 25 | ('name', models.CharField(max_length=255)), 26 | ('flavor', models.ManyToManyField(related_name='origins', to='coffee.Flavor')), 27 | ], 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /badges/coverage.svg: -------------------------------------------------------------------------------- 1 | coverage: 92.94%coverage92.94% -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Adam Hill 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /example/unicorn/components/js.py: -------------------------------------------------------------------------------- 1 | from django.utils.timezone import now 2 | 3 | from django_unicorn.components import UnicornView 4 | 5 | 6 | class JsView(UnicornView): 7 | states = ( 8 | "Alabama", 9 | "Alaska", 10 | "Wisconsin", 11 | "Wyoming", 12 | ) 13 | selected_state = "" 14 | select2_datetime = now() 15 | scroll_counter = 0 16 | load_js = False 17 | 18 | def call_javascript(self): 19 | self.call("callAlert", "world") 20 | 21 | def call_javascript_module(self): 22 | self.call("HelloJs.hello", "world!") 23 | 24 | def get_now(self): 25 | self.select2_datetime = now() 26 | 27 | def change_states(self): 28 | self.states = ("Pennsylvania",) 29 | 30 | def select_state(self, val, idx): 31 | print("select_state called", val) 32 | print("select_state called idx", idx) 33 | self.selected_state = val 34 | 35 | def increase_counter(self): 36 | if self.scroll_counter >= 2: 37 | return False 38 | 39 | self.scroll_counter += 1 40 | 41 | class Meta: 42 | javascript_excludes = ("states",) 43 | -------------------------------------------------------------------------------- /tests/views/message/utils.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import shortuuid 4 | 5 | from django_unicorn.utils import generate_checksum 6 | 7 | 8 | def post_and_get_response( 9 | client, 10 | *, 11 | url="", 12 | data=None, 13 | action_queue=None, 14 | component_id=None, 15 | hash=None, # noqa: A002 16 | return_response=False, 17 | ): 18 | if not data: 19 | data = {} 20 | if not action_queue: 21 | action_queue = [] 22 | if not component_id: 23 | component_id = shortuuid.uuid()[:8] 24 | 25 | message = { 26 | "actionQueue": action_queue, 27 | "data": data, 28 | "checksum": generate_checksum(data), 29 | "id": component_id, 30 | "epoch": time.time(), 31 | "hash": hash, 32 | } 33 | 34 | response = client.post( 35 | url, 36 | message, 37 | content_type="application/json", 38 | ) 39 | 40 | if return_response: 41 | return response 42 | 43 | try: 44 | return response.json() 45 | except TypeError: 46 | # Return the regular response if no JSON for HttpResponseNotModified 47 | return response 48 | -------------------------------------------------------------------------------- /django_unicorn/decorators.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | 4 | from decorator import decorator 5 | from django.conf import settings 6 | 7 | 8 | @decorator 9 | def timed(func, *args, **kwargs): 10 | """ 11 | Decorator that prints out the timing of a function. 12 | 13 | Slightly altered version of https://gist.github.com/bradmontgomery/bd6288f09a24c06746bbe54afe4b8a82. 14 | """ 15 | if not settings.DEBUG: 16 | return func(*args, **kwargs) 17 | 18 | logger = logging.getLogger("profile") 19 | start = time.time() 20 | result = func(*args, **kwargs) 21 | end = time.time() 22 | 23 | function_name = func.__name__ 24 | arguments = "" 25 | 26 | if args: 27 | arguments = f"{args}, " 28 | 29 | for kwarg_key, kwarg_val in kwargs.items(): 30 | if isinstance(kwarg_val, str): 31 | kwarg_val = f"'{kwarg_val}'" # noqa: PLW2901 32 | 33 | arguments = f"{arguments}{kwarg_key}={kwarg_val}, " 34 | 35 | if arguments.endswith(", "): 36 | arguments = arguments[:-2] 37 | 38 | ms = round(end - start, 4) 39 | 40 | logger.debug(f"{function_name}({arguments}): {ms}ms") 41 | return result 42 | -------------------------------------------------------------------------------- /tests/js/unicorn/getComponent.test.js: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { getComponent } from "../../../django_unicorn/static/unicorn/js/unicorn.js"; 3 | import { components } from "../../../django_unicorn/static/unicorn/js/store.js"; 4 | import { getComponent as getComponentUtil } from "../utils.js"; 5 | 6 | test("getComponent by name", (t) => { 7 | const component = getComponentUtil(); 8 | component.name = "text-inputs-name"; 9 | components[component.id] = component; 10 | 11 | t.truthy(getComponent("text-inputs-name")); 12 | }); 13 | 14 | test("getComponent by key", (t) => { 15 | const component = getComponentUtil(); 16 | component.key = "text-inputs-key"; 17 | components[component.id] = component; 18 | 19 | t.truthy(getComponent("text-inputs-key")); 20 | }); 21 | 22 | test("getComponent missing", (t) => { 23 | const component = getComponentUtil(); 24 | component.name = "text-inputs-name"; 25 | components[component.id] = component; 26 | 27 | const error = t.throws( 28 | () => { 29 | getComponent("text-inputs-missing"); 30 | }, 31 | { instanceOf: Error } 32 | ); 33 | t.is(error.message, "No component found for: text-inputs-missing"); 34 | }); 35 | -------------------------------------------------------------------------------- /tests/js/element/partial.test.js: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { getElement } from "../utils.js"; 3 | 4 | test("partial target", (t) => { 5 | const html = 6 | ""; 7 | const element = getElement(html); 8 | 9 | t.is(element.partials[0].target, "test-target"); 10 | }); 11 | 12 | test("partial.id", (t) => { 13 | const html = 14 | ""; 15 | const element = getElement(html); 16 | 17 | t.is(element.partials[0].id, "test-id"); 18 | }); 19 | 20 | test("partial.key", (t) => { 21 | const html = 22 | ""; 23 | const element = getElement(html); 24 | 25 | t.is(element.partials[0].key, "test-key"); 26 | }); 27 | 28 | test("multiple partials", (t) => { 29 | const html = 30 | ""; 31 | const element = getElement(html); 32 | 33 | t.is(element.partials[0].id, "test-id"); 34 | t.is(element.partials[1].key, "test-key"); 35 | }); 36 | -------------------------------------------------------------------------------- /django_unicorn/templates/unicorn/scripts.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | {{ MORPHER|json_script:"unicorn:settings:morpher" }} 4 | 5 | {% if MINIFIED %} 6 | 7 | {% comment %} {% endcomment %} 8 | 9 | 16 | {% else %} 17 | 29 | {% endif %} 30 | -------------------------------------------------------------------------------- /example/unicorn/components/models.py: -------------------------------------------------------------------------------- 1 | from django_unicorn import QuerySetType, UnicornView 2 | from example.coffee.models import Flavor, Taste 3 | 4 | 5 | class ModelsView(UnicornView): 6 | flavor: Flavor = None 7 | flavors: QuerySetType[Flavor] = Flavor.objects.none() 8 | 9 | def mount(self): 10 | self.flavor = Flavor() 11 | self.refresh_flavors() 12 | 13 | def refresh_flavors(self): 14 | self.flavors = Flavor.objects.all().order_by("-id")[:2] 15 | 16 | def save_flavor(self): 17 | self.flavor.save() 18 | self.flavor = Flavor() 19 | self.refresh_flavors() 20 | 21 | def save(self, flavor_idx: int): 22 | flavor_data = self.flavors[flavor_idx] 23 | flavor_data.save() 24 | 25 | def delete(self, flavor_to_delete: Flavor): 26 | flavor_to_delete.delete() 27 | self.refresh_flavors() 28 | 29 | def available_flavors(self): 30 | return Flavor.objects.all() 31 | 32 | def available_tastes(self): 33 | return Taste.objects.all() 34 | 35 | def model_typehint(self, flavor: Flavor): 36 | print(flavor) 37 | 38 | class Meta: 39 | javascript_exclude = ("flavor.taste_set",) 40 | -------------------------------------------------------------------------------- /tests/js/unicorn/call.test.js: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { call } from "../../../django_unicorn/static/unicorn/js/unicorn.js"; 3 | import { components } from "../../../django_unicorn/static/unicorn/js/store.js"; 4 | import { getComponent } from "../utils.js"; 5 | 6 | test("call a method", (t) => { 7 | const component = getComponent(); 8 | components[component.id] = component; 9 | 10 | component.callMethod = (methodName) => { 11 | t.true(methodName === "testMethod"); 12 | }; 13 | 14 | call("text-inputs", "testMethod"); 15 | }); 16 | 17 | test("call a method with string argument", (t) => { 18 | const component = getComponent(); 19 | components[component.id] = component; 20 | 21 | component.callMethod = (methodName) => { 22 | t.true(methodName === "testMethod('test1')"); 23 | }; 24 | 25 | call("text-inputs", "testMethod", "test1"); 26 | }); 27 | 28 | test("call a method with string and int argument", (t) => { 29 | const component = getComponent(); 30 | components[component.id] = component; 31 | 32 | component.callMethod = (methodName) => { 33 | t.true(methodName === "testMethod('test1', 2)"); 34 | }; 35 | 36 | call("text-inputs", "testMethod", "test1", 2); 37 | }); 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/docs.yml: -------------------------------------------------------------------------------- 1 | name: 📑 Docs 2 | description: Propose changes and improvements to Django Unicorn Docs. 3 | labels: "📑 docs" 4 | title: "[📑 Docs]: " 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Thank you for contributing to Django Unicorn! 📑 👩🏻‍💻 We appreciate your feedback, ideas, and contributions. 11 | 12 | Before filing this issue, please make sure to check if there's already a similar issue opened. 13 | 14 | - type: textarea 15 | id: reason-context 16 | attributes: 17 | label: What Dev Docs changes are you proposing? 18 | description: Why do the Docs need this improvement? What is the motivation for this change? 19 | placeholder: "I would like to contribute to Django Unicorn 📑 by..." 20 | validations: 21 | required: true 22 | 23 | - type: checkboxes 24 | id: terms 25 | attributes: 26 | label: Code of Conduct 27 | description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/adamghill/django-unicorn/blob/main/CODE_OF_CONDUCT.md) 28 | options: 29 | - label: I agree to follow this project's Code of Conduct 30 | required: true -------------------------------------------------------------------------------- /example/unicorn/templates/unicorn/nested/row.html: -------------------------------------------------------------------------------- 1 | 2 | {% load unicorn %} 3 | 4 | 5 | {% if is_editing %} 6 | 7 | {% else %} 8 | {{ model.name }} 9 | {% endif %} 10 | 11 | 12 | {% if is_editing %} 13 | 14 | {% else %} 15 | {{ model.label }} 16 | {% endif %} 17 | 18 | 19 | {% if is_editing %} 20 | 21 | {% elif model.datetime %} 22 | {{ model.datetime }} 23 | {% else %} 24 | n/a 25 | {% endif %} 26 | 27 | 28 | {% unicorn 'nested.favorite' key=model.favorite.id model=model.favorite %} 29 | 30 | 31 | {% if is_editing %} 32 | 33 | 34 | {% else %} 35 | 36 | {% endif %} 37 | 38 | 39 | -------------------------------------------------------------------------------- /example/unicorn/templates/unicorn/nested/table.html: -------------------------------------------------------------------------------- 1 | {% load unicorn %} 2 |
3 |
4 | 5 | 6 | 7 | {% if is_editing %} 8 | 9 | 10 | 11 | {% else %} 12 |

{{ name }}

13 | Edit 14 | {% endif %} 15 |
16 | 17 | 18 | 19 |
Favorite count: {{ favorite_count }}
20 | 21 | 22 | 23 | {% if show_filter %} 24 | {% unicorn 'nested.filter' %} 25 | {% endif %} 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | {% for flavor in flavors %} 38 | {% unicorn 'nested.row' key=flavor.id model=flavor %} 39 | {% endfor %} 40 |
NameLabelDatetimeFavoriteAction
41 | 42 | 43 | 44 |
45 | -------------------------------------------------------------------------------- /tests/views/test_unicorn_dict.py: -------------------------------------------------------------------------------- 1 | from django_unicorn.components import UnicornView 2 | from django_unicorn.views.utils import set_property_from_data 3 | 4 | 5 | class DictPropertyView(UnicornView): 6 | dictionary = {"name": "dictionary"} # noqa: RUF012 7 | nested_dictionary = {"nested": {"name": "nested_dictionary"}} # noqa: RUF012 8 | 9 | 10 | def test_set_property_from_data_dict(): 11 | component = DictPropertyView(component_name="test", component_id="test_set_property_from_data_dict") 12 | assert "dictionary" == component.dictionary.get("name") 13 | 14 | set_property_from_data(component, "dictionary", {"name": "dictionary_updated"}) 15 | 16 | assert "dictionary_updated" == component.dictionary.get("name") 17 | 18 | 19 | def test_set_property_from_data_nested_dict(): 20 | component = DictPropertyView(component_name="test", component_id="test_set_property_from_data_nested_dict") 21 | assert "nested_dictionary" == component.nested_dictionary.get("nested").get("name") 22 | 23 | set_property_from_data( 24 | component, 25 | "nested_dictionary", 26 | {"nested": {"name": "nested_dictionary_updated"}}, 27 | ) 28 | 29 | assert "nested_dictionary_updated" == component.nested_dictionary.get("nested").get("name") 30 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | The following versions of Django Unicorn are currently supported for security updates: 6 | 7 | | Version | Supported | 8 | | ------- | ------------------ | 9 | | 0.61.x | ✅ Fully supported | 10 | 11 | Please ensure you are using the latest version to receive security updates. 12 | 13 | --- 14 | 15 | ## Reporting a Vulnerability 16 | 17 | We take security issues seriously and appreciate your efforts to responsibly disclose vulnerabilities. 18 | 19 | To report a security vulnerability: 20 | 21 | 1. **Use our GitHub Security Advisory**: 22 | - Navigate to the [Django Unicorn repository](https://github.com/adamghill/django-unicorn). 23 | - Go to the **Security** tab and click **Report a vulnerability**. 24 | 2. **Responsible Disclosure**: 25 | - Do not publicly disclose the vulnerability until we have had a chance to investigate and provide a fix. 26 | - We aim to respond to security reports within **48 hours** and provide a resolution within **7-14 days**. 27 | 28 | --- 29 | 30 | ## Acknowledgments 31 | 32 | We appreciate the contributions of security researchers and developers who help us make Django Unicorn secure. Thank you for your support in keeping this project safe and reliable. 33 | -------------------------------------------------------------------------------- /example/unicorn/templates/unicorn/todo.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 |
4 |
{{ unicorn.errors.task.0.message }}
5 | 6 |
7 |

8 |

9 | 10 |
11 |

12 |

13 | 14 |    15 | 16 | 17 | 18 | 19 | 20 | 21 |

22 |
23 | 24 |

25 | {% if tasks %} 26 |

    27 | {% for task in tasks %} 28 |
  • {{ task }}
  • 29 | {% endfor %} 30 |
31 | {% else %} 32 | No tasks 🎉 33 | {% endif %} 34 |

35 |
36 | -------------------------------------------------------------------------------- /tests/js/component/callCalls.test.js: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { getComponent } from "../utils.js"; 3 | 4 | test("callCalls with function name", (t) => { 5 | const functionName = "someFunction"; 6 | const component = getComponent(); 7 | 8 | component.window[functionName] = () => { 9 | return true; 10 | }; 11 | 12 | const actual = component.callCalls([{ fn: functionName }]); 13 | t.deepEqual(actual, [true]); 14 | }); 15 | 16 | test("callCalls with function name and arg", (t) => { 17 | const functionName = "someFunction"; 18 | const component = getComponent(); 19 | 20 | component.window[functionName] = (argOne) => { 21 | return `${argOne}!!`; 22 | }; 23 | 24 | const actual = component.callCalls([{ fn: functionName, args: ["great"] }]); 25 | t.deepEqual(actual, ["great!!"]); 26 | }); 27 | 28 | test("callCalls with module and arg", (t) => { 29 | const component = getComponent(); 30 | 31 | component.window.SomeModule = (() => { 32 | const self = {}; 33 | 34 | self.someFunction = (name) => { 35 | return `${name}!`; 36 | }; 37 | 38 | return self; 39 | })(); 40 | 41 | const actual = component.callCalls([ 42 | { fn: "SomeModule.someFunction", args: ["howdy"] }, 43 | ]); 44 | t.deepEqual(actual, ["howdy!"]); 45 | }); 46 | -------------------------------------------------------------------------------- /example/unicorn/components/nested/table.py: -------------------------------------------------------------------------------- 1 | from django_unicorn.components import UnicornView 2 | from example.coffee.models import Flavor 3 | 4 | 5 | class TableView(UnicornView): 6 | name = "Coffee Flavors" 7 | original_name = None 8 | flavors = Flavor.objects.none() 9 | is_editing = False 10 | favorite_count = 0 11 | show_filter = False 12 | 13 | def edit(self): 14 | self.is_editing = True 15 | self.original_name = self.name 16 | 17 | def save(self): 18 | self.is_editing = False 19 | 20 | def cancel(self): 21 | if self.original_name: 22 | self.name = self.original_name 23 | self.original_name = None 24 | 25 | self.is_editing = False 26 | 27 | def mount(self): 28 | self.load_table() 29 | 30 | def load_table(self): 31 | self.flavors = Flavor.objects.select_related("favorite").all()[10:20] 32 | self.favorite_count = sum([1 for f in self.flavors if hasattr(f, "favorite") and f.favorite.is_favorite]) 33 | 34 | def set_unedit(c): 35 | if hasattr(c, "is_editing"): 36 | c.is_editing = False 37 | for cc in c.children: 38 | set_unedit(cc) 39 | 40 | for child in self.children: 41 | set_unedit(child) 42 | -------------------------------------------------------------------------------- /tests/serializer/test_exclude_field_attributes.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django_unicorn.serializer import ( 4 | InvalidFieldAttributeError, 5 | InvalidFieldNameError, 6 | _exclude_field_attributes, 7 | ) 8 | 9 | 10 | def test_exclude_field_attributes(): 11 | expected = {"1": {"2": {}}} 12 | dict_data = {"1": {"2": {"3": "4"}}} 13 | _exclude_field_attributes(dict_data, ("1.2.3",)) 14 | 15 | assert dict_data == expected 16 | 17 | 18 | def test_exclude_field_attributes_none_value(): 19 | expected = {"1": None} 20 | dict_data = {"1": None} 21 | _exclude_field_attributes(dict_data, ("1.2",)) 22 | 23 | assert dict_data == expected 24 | 25 | 26 | def test_exclude_field_attributes_empty_value(): 27 | dict_data = {"1": {}} 28 | 29 | with pytest.raises(InvalidFieldAttributeError): 30 | _exclude_field_attributes(dict_data, ("1.2",)) 31 | 32 | 33 | def test_exclude_field_attributes_invalid_field_name(): 34 | dict_data = {"test": None} 35 | 36 | with pytest.raises(InvalidFieldNameError): 37 | _exclude_field_attributes(dict_data, ("1.2",)) 38 | 39 | 40 | def test_exclude_field_attributes_invalid_field_attribute(): 41 | dict_data = {"1": {"test": "more"}} 42 | 43 | with pytest.raises(InvalidFieldAttributeError): 44 | _exclude_field_attributes(dict_data, ("1.2",)) 45 | -------------------------------------------------------------------------------- /tests/js/element/init.test.js: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { getElement } from "../utils.js"; 3 | 4 | test("isUnicorn", (t) => { 5 | const html = ""; 6 | const element = getElement(html); 7 | 8 | t.true(element.isUnicorn); 9 | }); 10 | 11 | test("!isUnicorn", (t) => { 12 | const html = ""; 13 | const element = getElement(html); 14 | 15 | t.false(element.isUnicorn); 16 | }); 17 | 18 | test("key", (t) => { 19 | const html = ""; 20 | const element = getElement(html); 21 | 22 | t.is(element.key, "testKey"); 23 | }); 24 | 25 | test("unicorn:id is not an action", (t) => { 26 | const html = "
"; 27 | const element = getElement(html); 28 | 29 | t.true(element.isUnicorn); 30 | t.is(element.actions.length, 0); 31 | }); 32 | 33 | test("unicorn:key is not an action", (t) => { 34 | const html = "
"; 35 | const element = getElement(html); 36 | 37 | t.true(element.isUnicorn); 38 | t.is(element.actions.length, 0); 39 | }); 40 | 41 | test("unicorn:checksum is not an action", (t) => { 42 | const html = "
"; 43 | const element = getElement(html); 44 | 45 | t.true(element.isUnicorn); 46 | t.is(element.actions.length, 0); 47 | }); 48 | -------------------------------------------------------------------------------- /example/coffee/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.db import models 4 | 5 | 6 | class Flavor(models.Model): 7 | name = models.CharField(max_length=255) 8 | label = models.CharField(max_length=255) 9 | parent = models.ForeignKey("self", blank=True, null=True, on_delete=models.SET_NULL) 10 | float_value = models.FloatField(blank=True, null=True) 11 | decimal_value = models.DecimalField(blank=True, null=True, max_digits=10, decimal_places=2) 12 | uuid = models.UUIDField(default=uuid.uuid4) 13 | datetime = models.DateTimeField(blank=True, null=True) 14 | date = models.DateField(blank=True, null=True) 15 | time = models.TimeField(blank=True, null=True) 16 | duration = models.DurationField(blank=True, null=True) 17 | 18 | def __str__(self): 19 | return self.name 20 | 21 | 22 | class Favorite(models.Model): 23 | is_favorite = models.BooleanField(default=False) 24 | flavor = models.OneToOneField(Flavor, on_delete=models.CASCADE) 25 | 26 | 27 | class Taste(models.Model): 28 | name = models.CharField(max_length=255) 29 | flavor = models.ManyToManyField(Flavor) 30 | 31 | 32 | class Origin(models.Model): 33 | name = models.CharField(max_length=255) 34 | flavor = models.ManyToManyField(Flavor, related_name="origins") 35 | 36 | 37 | class NewFlavor(Flavor): 38 | new_name = models.CharField(max_length=255) 39 | -------------------------------------------------------------------------------- /docs/source/direct-view.md: -------------------------------------------------------------------------------- 1 | # Direct View 2 | 3 | Usually components will be included in a regular Django template, however a component can also be specified in a `urls.py` file in instances where having an additional template is not necessary. 4 | 5 | ## Template Requirements 6 | 7 | - there must be one (and only one) element that wraps around the portion of the template that should be handled by `Unicorn` 8 | - the wrapping element must include `unicorn:view` as an attribute 9 | - the template must included the `unicorn_scripts` and `csrf_token` template tags 10 | 11 | Similar to a class-based view, `Unicorn` components have a `as_view` function which is used in `urls.py`. 12 | 13 | ## Example 14 | 15 | ```python 16 | # book.py 17 | from django_unicorn.components import UnicornView 18 | 19 | class BookView(UnicornView): 20 | title = "" 21 | ``` 22 | 23 | ```html 24 | 25 | {% load unicorn %} 26 | 27 | 28 | 29 | {% unicorn_scripts %} 30 | 31 | 32 | {% csrf_token %} 33 |

Book

34 | 35 |
36 |
37 | {{ title }} 38 |
39 | 40 | 41 | ``` 42 | 43 | ```python 44 | # urls.py 45 | from django.urls import path 46 | from unicorn.components.book import BookView 47 | 48 | urlpatterns = [ 49 | path("book", BookView.as_view(), name="book"), 50 | ] 51 | ``` 52 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/features.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 💡 Feature Request 3 | about: Suggest a new idea/feature 4 | title: '[FEATURE] ' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | **Note: Please search to see if an issue already exists for the feature.** 12 | 13 | 14 | ### Why do we need this improvement? 15 | 16 | 17 | ### How will this change help? 18 | 19 | 20 | ### Please add screenshots if applicable 21 | 22 | 23 | ### How could it be implemented/designed? 24 | 25 | 26 | ### Will this be a breaking change? 27 | 28 | - [ ] Yes 29 | - [ ] No 30 | 31 | ### Have you read the Contributing guidelines? 32 | - [ ] I have read the [Contributing guidelines](https://github.com/adamghill/django-unicorn/blob/main/DEVELOPING.md) 33 | 34 | ### Are you willing to work on this issue? 35 | 36 | - [ ] Yes I am willing to submit a PR! 37 | - [ ] No, someone else can work on it. 38 | 39 | ### Anything else: 40 | 43 | 44 | ### References: 45 | This document was adapted from the open-source issue templates for Async API -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.62.0", 3 | "name": "django-unicorn", 4 | "scripts": { 5 | "build": "npx rollup -c", 6 | "test": "npx ava", 7 | "watch:test": "npx ava --watch" 8 | }, 9 | "devDependencies": { 10 | "@babel/core": "^7.11.6", 11 | "@babel/preset-env": "^7.11.5", 12 | "@rollup/plugin-alias": "^3.1.1", 13 | "@rollup/plugin-babel": "^5.2.1", 14 | "@rollup/plugin-commonjs": "^15.0.0", 15 | "@rollup/plugin-node-resolve": "^7.1.3", 16 | "ava": "^3.12.1", 17 | "eslint": "^7.2.0", 18 | "eslint-config-airbnb-base": "^14.2.0", 19 | "eslint-config-prettier": "^6.12.0", 20 | "eslint-plugin-import": "^2.22.0", 21 | "esm": "^3.2.25", 22 | "fetch-mock": "^9.11.0", 23 | "jsdom": "^16.4.0", 24 | "node-fetch": "^2.6.1", 25 | "prettier": "^2.1.2", 26 | "rollup": "^2.27.1", 27 | "rollup-plugin-babel": "^4.3.3", 28 | "rollup-plugin-commonjs": "^10.1.0", 29 | "rollup-plugin-filesize": "^6.2.1", 30 | "rollup-plugin-node-resolve": "^5.2.0", 31 | "rollup-plugin-terser": "^7.0.1", 32 | "rollup-plugin-version-injector": "^1.3.3" 33 | }, 34 | "ava": { 35 | "require": [ 36 | "esm" 37 | ], 38 | "files": [ 39 | "tests/js/**", 40 | "!tests/js/utils.js" 41 | ], 42 | "failFast": true 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /example/coffee/migrations/0005_auto_20221110_0400.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.15 on 2022-11-10 04:00 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('coffee', '0004_origin_taste'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='NewFlavor', 16 | fields=[ 17 | ('flavor_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='coffee.flavor')), 18 | ('new_name', models.CharField(max_length=255)), 19 | ], 20 | bases=('coffee.flavor',), 21 | ), 22 | migrations.AlterField( 23 | model_name='flavor', 24 | name='id', 25 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 26 | ), 27 | migrations.AlterField( 28 | model_name='origin', 29 | name='id', 30 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 31 | ), 32 | migrations.AlterField( 33 | model_name='taste', 34 | name='id', 35 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /tests/views/test_unicorn_field.py: -------------------------------------------------------------------------------- 1 | from django_unicorn.components import UnicornField, UnicornView 2 | from django_unicorn.views.utils import set_property_from_data 3 | 4 | 5 | class NestedPropertyOne(UnicornField): 6 | name = "nested_property_one" 7 | 8 | 9 | class PropertyOne(UnicornField): 10 | nested_property_one = NestedPropertyOne() 11 | name = "property_one" 12 | 13 | 14 | class NestedPropertyView(UnicornView): 15 | property_one = PropertyOne() 16 | name = "property_view" 17 | 18 | 19 | def test_set_property_from_data_unicorn_field(): 20 | component = NestedPropertyView(component_name="test", component_id="test_set_property_from_data_unicorn_field") 21 | assert "property_one" == component.property_one.name 22 | 23 | data = {"name": "property_one_updated"} 24 | set_property_from_data(component, "property_one", data) 25 | 26 | assert "property_one_updated" == component.property_one.name 27 | 28 | 29 | def test_set_property_from_data_nested_unicorn_field(): 30 | component = NestedPropertyView( 31 | component_name="test", component_id="test_set_property_from_data_nested_unicorn_field" 32 | ) 33 | assert "nested_property_one" == component.property_one.nested_property_one.name 34 | 35 | data = {"nested_property_one": {"name": "nested_property_one_updated"}} 36 | set_property_from_data(component, "property_one", data) 37 | 38 | assert "nested_property_one_updated" == component.property_one.nested_property_one.name 39 | -------------------------------------------------------------------------------- /tests/js/element/visibility.test.js: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { getComponent, getElement } from "../utils.js"; 3 | 4 | test("visible", (t) => { 5 | const html = ""; 6 | const element = getElement(html); 7 | 8 | const { visibility } = element; 9 | t.is(visibility.method, "test_function1"); 10 | t.is(visibility.threshold, 0); 11 | t.is(visibility.debounceTime, 0); 12 | }); 13 | 14 | test("visible threshold", (t) => { 15 | const html = ""; 16 | const element = getElement(html); 17 | 18 | const { visibility } = element; 19 | t.is(visibility.method, "test_function2"); 20 | t.is(visibility.threshold, 0.25); 21 | t.is(visibility.debounceTime, 0); 22 | }); 23 | 24 | test("visible debounce", (t) => { 25 | const html = ""; 26 | const element = getElement(html); 27 | 28 | const { visibility } = element; 29 | t.is(visibility.method, "test_function3"); 30 | t.is(visibility.threshold, 0); 31 | t.is(visibility.debounceTime, 1000); 32 | }); 33 | 34 | test("visible chained", (t) => { 35 | const html = 36 | ""; 37 | const element = getElement(html); 38 | 39 | const { visibility } = element; 40 | t.is(visibility.method, "test_function4"); 41 | t.is(visibility.threshold, 0.5); 42 | t.is(visibility.debounceTime, 2000); 43 | }); 44 | -------------------------------------------------------------------------------- /tests/js/element/getValue.test.js: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { getElement } from "../utils.js"; 3 | 4 | test("getValue()", (t) => { 5 | const html = ""; 6 | const element = getElement(html); 7 | 8 | t.is(element.getValue(), "test"); 9 | }); 10 | 11 | test("checkbox getValue() checked", (t) => { 12 | const html = ""; 13 | const element = getElement(html); 14 | 15 | t.true(element.getValue()); 16 | }); 17 | 18 | test("checkbox getValue() not checked", (t) => { 19 | const html = ""; 20 | const element = getElement(html); 21 | 22 | t.false(element.getValue()); 23 | }); 24 | 25 | test("checkbox getValue() select", (t) => { 26 | const html = ` 27 | 32 | `; 33 | const element = getElement(html); 34 | 35 | t.is(element.getValue(), "doggo"); 36 | }); 37 | 38 | test("checkbox getValue() select multiple", (t) => { 39 | const html = ` 40 | 45 | `; 46 | const element = getElement(html); 47 | 48 | t.deepEqual(element.getValue(), ["octopus", "alien"]); 49 | }); 50 | -------------------------------------------------------------------------------- /tests/js/element/model.test.js: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { getElement } from "../utils.js"; 3 | 4 | test("model", (t) => { 5 | const html = ""; 6 | const element = getElement(html); 7 | 8 | t.is(element.model.name, "name"); 9 | t.is(element.model.eventType, "input"); 10 | t.false(element.model.isLazy); 11 | t.false(element.model.isDefer); 12 | }); 13 | 14 | test("model.defer", (t) => { 15 | const html = ""; 16 | const element = getElement(html); 17 | 18 | t.true(element.model.isDefer); 19 | }); 20 | 21 | test("model.lazy", (t) => { 22 | const html = ""; 23 | const element = getElement(html); 24 | 25 | t.true(element.model.isLazy); 26 | t.is(element.model.eventType, "blur"); 27 | }); 28 | 29 | test("model.debounce", (t) => { 30 | const html = ""; 31 | const element = getElement(html); 32 | 33 | t.is(element.model.debounceTime, -1); 34 | }); 35 | 36 | test("model.debounce-1000", (t) => { 37 | const html = ""; 38 | const element = getElement(html); 39 | 40 | t.is(element.model.debounceTime, 1000); 41 | }); 42 | 43 | test("model.lazy.debounce-500", (t) => { 44 | const html = ""; 45 | const element = getElement(html); 46 | 47 | t.true(element.model.isLazy); 48 | t.is(element.model.debounceTime, 500); 49 | }); 50 | -------------------------------------------------------------------------------- /tests/js/element/errors.test.js: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { getElement } from "../utils.js"; 3 | 4 | test("unicorn:error:required", (t) => { 5 | const html = ""; 6 | const element = getElement(html); 7 | 8 | t.is(element.errors.length, 1); 9 | const error = element.errors[0]; 10 | t.is(error.code, "required"); 11 | t.is(error.message, "This field is required."); 12 | }); 13 | 14 | test("unicorn:error:invalid", (t) => { 15 | const html = ""; 16 | const element = getElement(html); 17 | 18 | t.is(element.errors.length, 1); 19 | const error = element.errors[0]; 20 | t.is(error.code, "invalid"); 21 | t.is(error.message, "Enter a whole number."); 22 | }); 23 | 24 | test("addError()", (t) => { 25 | const html = ""; 26 | const element = getElement(html); 27 | 28 | t.is(element.errors.length, 0); 29 | element.addError({ code: "invalid", message: "Enter a whole number." }); 30 | t.is(element.errors.length, 1); 31 | t.is(element.el.getAttribute("unicorn:error:invalid"), "Enter a whole number."); 32 | }); 33 | 34 | test("removeErrors()", (t) => { 35 | const html = ""; 36 | const element = getElement(html); 37 | 38 | t.is(element.errors.length, 1); 39 | element.removeErrors(); 40 | t.is(element.errors.length, 0); 41 | }); 42 | -------------------------------------------------------------------------------- /tests/views/message/test_sync_input.py: -------------------------------------------------------------------------------- 1 | from tests.views.message.utils import post_and_get_response 2 | 3 | 4 | def test_message_nested_sync_input(client): 5 | data = {"dictionary": {"name": "test"}} 6 | action_queue = [ 7 | { 8 | "payload": {"name": "dictionary.name", "value": "test1"}, 9 | "type": "syncInput", 10 | } 11 | ] 12 | response = post_and_get_response( 13 | client, 14 | url="/message/tests.views.fake_components.FakeComponent", 15 | data=data, 16 | action_queue=action_queue, 17 | ) 18 | 19 | assert not response["errors"] 20 | assert response["data"].get("dictionary") == {"name": "test1"} 21 | 22 | 23 | def test_message_sync_input_choices_with_select_widget(client): 24 | """ 25 | ModelForms with a Model that have a field with `choices` and the form's field uses a Select widget. 26 | Need to handle Select widget specifically otherwise `field.widget.format_value` will return a list 27 | that only contains one object. 28 | """ 29 | 30 | data = {"type": 1} 31 | action_queue = [ 32 | { 33 | "payload": {"name": "type", "value": 2}, 34 | "type": "syncInput", 35 | } 36 | ] 37 | response = post_and_get_response( 38 | client, 39 | url="/message/tests.views.fake_components.FakeModelFormComponent", 40 | data=data, 41 | action_queue=action_queue, 42 | ) 43 | 44 | assert not response["errors"] 45 | assert response["data"].get("type") == 2 46 | -------------------------------------------------------------------------------- /tests/js/component/actions.test.js: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { getComponent } from "../utils.js"; 3 | import { isEmpty } from "../../../django_unicorn/static/unicorn/js/utils.js"; 4 | 5 | test("action", (t) => { 6 | const component = getComponent(); 7 | 8 | t.is(component.attachedEventTypes.length, 1); 9 | t.false(isEmpty(component.actionEvents)); 10 | t.is(component.actionEvents.click.length, 1); 11 | }); 12 | 13 | test("multiple of same action eventType", (t) => { 14 | const html = ` 15 |
16 | 17 | 18 | 19 |
`; 20 | const component = getComponent(html); 21 | 22 | t.is(component.attachedEventTypes.length, 1); 23 | t.false(isEmpty(component.actionEvents)); 24 | t.is(component.actionEvents.click.length, 2); 25 | }); 26 | 27 | test("multiple action eventTypes", (t) => { 28 | const html = ` 29 |
30 | 31 | 32 | 33 |
`; 34 | const component = getComponent(html); 35 | 36 | t.is(component.attachedEventTypes.length, 2); 37 | t.false(isEmpty(component.actionEvents)); 38 | t.is(component.actionEvents.click.length, 1); 39 | t.is(component.actionEvents.keyup.length, 1); 40 | }); 41 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: Post coverage comment 2 | 3 | on: 4 | workflow_run: 5 | workflows: ["CI"] 6 | types: 7 | - completed 8 | 9 | jobs: 10 | test: 11 | name: Run tests & display coverage 12 | runs-on: ubuntu-latest 13 | if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' 14 | permissions: 15 | # Gives the action the necessary permissions for publishing new 16 | # comments in pull requests. 17 | pull-requests: write 18 | # Gives the action the necessary permissions for editing existing 19 | # comments (to avoid publishing multiple comments in the same PR) 20 | contents: write 21 | # Gives the action the necessary permissions for looking up the 22 | # workflow that launched this workflow, and download the related 23 | # artifact that contains the comment to be published 24 | actions: read 25 | steps: 26 | # DO NOT run actions/checkout here, for security reasons 27 | # For details, refer to https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ 28 | - name: Post comment 29 | uses: py-cov-action/python-coverage-comment-action@v3 30 | with: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | GITHUB_PR_RUN_ID: ${{ github.event.workflow_run.id }} 33 | # Update those if you changed the default values: 34 | # COMMENT_ARTIFACT_NAME: python-coverage-comment-action 35 | # COMMENT_FILENAME: python-coverage-comment-action.txt 36 | -------------------------------------------------------------------------------- /tests/views/message/test_toggle.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import orjson 4 | import shortuuid 5 | 6 | from django_unicorn.utils import generate_checksum 7 | 8 | 9 | def _post_message_and_get_body(client, message): 10 | response = client.post( 11 | "/message/tests.views.fake_components.FakeComponent", 12 | message, 13 | content_type="application/json", 14 | ) 15 | 16 | body = orjson.loads(response.content) 17 | return body 18 | 19 | 20 | def test_message_toggle(client): 21 | data = {"check": False} 22 | message = { 23 | "actionQueue": [ 24 | {"type": "callMethod", "payload": {"name": "$toggle('check')"}}, 25 | ], 26 | "data": data, 27 | "checksum": generate_checksum(str(data)), 28 | "id": shortuuid.uuid()[:8], 29 | "epoch": time.time(), 30 | } 31 | 32 | body = _post_message_and_get_body(client, message) 33 | 34 | assert not body["errors"] 35 | assert body["data"]["check"] is True 36 | 37 | 38 | def test_message_nested_toggle(client): 39 | data = {"nested": {"check": False}} 40 | message = { 41 | "actionQueue": [ 42 | {"type": "callMethod", "payload": {"name": "$toggle('nested.check')"}}, 43 | ], 44 | "data": data, 45 | "checksum": generate_checksum(str(data)), 46 | "id": shortuuid.uuid()[:8], 47 | "epoch": time.time(), 48 | } 49 | 50 | body = _post_message_and_get_body(client, message) 51 | 52 | assert not body["errors"] 53 | assert body["data"]["nested"]["check"] is True 54 | -------------------------------------------------------------------------------- /tests/templatetags/test_unicorn_scripts.py: -------------------------------------------------------------------------------- 1 | from django_unicorn.templatetags.unicorn import unicorn_scripts 2 | 3 | 4 | def test_unicorn_scripts(): 5 | actual = unicorn_scripts() 6 | 7 | assert actual["CSRF_HEADER_NAME"] == "X-CSRFTOKEN" 8 | assert actual["CSRF_COOKIE_NAME"] == "csrftoken" 9 | assert actual["MINIFIED"] is True 10 | 11 | 12 | def test_unicorn_scripts_debug(settings): 13 | settings.DEBUG = True 14 | actual = unicorn_scripts() 15 | 16 | assert actual["CSRF_HEADER_NAME"] == "X-CSRFTOKEN" 17 | assert actual["CSRF_COOKIE_NAME"] == "csrftoken" 18 | assert actual["MINIFIED"] is False 19 | 20 | 21 | def test_unicorn_scripts_minified_true(settings): 22 | settings.UNICORN = {"MINIFIED": True} 23 | actual = unicorn_scripts() 24 | 25 | assert actual["CSRF_HEADER_NAME"] == "X-CSRFTOKEN" 26 | assert actual["CSRF_COOKIE_NAME"] == "csrftoken" 27 | assert actual["MINIFIED"] is True 28 | 29 | 30 | def test_unicorn_scripts_minified_false(settings): 31 | settings.UNICORN = {"MINIFIED": False} 32 | actual = unicorn_scripts() 33 | 34 | assert actual["MINIFIED"] is False 35 | 36 | 37 | def test_unicorn_scripts_csrf_header_name(settings): 38 | settings.CSRF_HEADER_NAME = "HTTP_X_UNICORN" 39 | actual = unicorn_scripts() 40 | 41 | assert actual["CSRF_HEADER_NAME"] == "X-UNICORN" 42 | 43 | 44 | def test_unicorn_scripts_csrf_cookie_name(settings): 45 | settings.CSRF_COOKIE_NAME = "unicorn-csrftoken" 46 | actual = unicorn_scripts() 47 | 48 | assert actual["CSRF_COOKIE_NAME"] == "unicorn-csrftoken" 49 | -------------------------------------------------------------------------------- /tests/js/element/loading.test.js: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { getElement } from "../utils.js"; 3 | 4 | test("loading class", (t) => { 5 | const html = "
"; 6 | const element = getElement(html); 7 | 8 | t.is(element.loading.classes.length, 1); 9 | t.is(element.loading.classes[0], "loading"); 10 | }); 11 | 12 | test("loading multiple classes", (t) => { 13 | const html = 14 | "
"; 15 | const element = getElement(html); 16 | 17 | t.is(element.loading.classes.length, 2); 18 | t.is(element.loading.classes[0], "loading"); 19 | t.is(element.loading.classes[1], "another"); 20 | }); 21 | 22 | test("loading remove class", (t) => { 23 | const html = 24 | "
"; 25 | const element = getElement(html); 26 | 27 | t.is(element.loading.removeClasses.length, 1); 28 | t.is(element.loading.removeClasses[0], "unloading"); 29 | }); 30 | 31 | test("loading multiple remove classes", (t) => { 32 | const html = 33 | "
"; 34 | const element = getElement(html); 35 | 36 | t.is(element.loading.removeClasses.length, 2); 37 | t.is(element.loading.removeClasses[0], "unloading"); 38 | t.is(element.loading.removeClasses[1], "great"); 39 | }); 40 | 41 | test("loading attr", (t) => { 42 | const html = "
"; 43 | const element = getElement(html); 44 | 45 | t.is(element.loading.attr, "disabled"); 46 | }); 47 | -------------------------------------------------------------------------------- /docs/source/custom-morphers.md: -------------------------------------------------------------------------------- 1 | # Custom Morphers 2 | 3 | The morpher is a library used to update specific parts of the DOM element instead of replacing the entire element. This improves performance and maintains the state of unchanged DOM elements, such as the cursor position in an input. 4 | 5 | The default morpher used in Unicorn is [`morphdom`](https://github.com/patrick-steele-idem/morphdom). The only alternative morpher available is the [Alpine.js morph plugin](https://alpinejs.dev/plugins/morph). 6 | 7 | ## `Morphdom` 8 | 9 | `morphdom` is the default morpher so no extra settings or installation is required to use it. 10 | 11 | ## `Alpine` 12 | 13 | Components which use both `Unicorn` and `Alpine.js` should use the `Alpine.js` morpher to prevent losing state when it gets re-rendered. 14 | 15 | ## Django Settings 16 | 17 | ```python 18 | # settings.py 19 | 20 | UNICORN = { 21 | ... 22 | "MORPHER": { 23 | "NAME": "alpine", 24 | } 25 | ... 26 | } 27 | ``` 28 | 29 | ```{note} 30 | `MORPHER.RELOAD_SCRIPT_ELEMENTS` is not currently supported for the `Alpine.js` morpher. 31 | ``` 32 | 33 | ### JavaScript Installation 34 | 35 | `Alpine.js` is not included in `Unicorn` so you will need to manually include it. Make sure to include `Alpine.js` and the morpher plugin by adding the following line to your template before `{% unicorn_scripts %}`. 36 | 37 | ```html 38 | ... 39 | 40 | 41 | 42 | {% unicorn_scripts %} 43 | 44 | ... 45 | ``` 46 | -------------------------------------------------------------------------------- /example/coffee/migrations/0003_auto_20210128_0140.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.5 on 2021-01-28 01:40 2 | 3 | from django.db import migrations, models 4 | import uuid 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('coffee', '0002_auto_20201205_1450'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='flavor', 16 | name='date', 17 | field=models.DateField(blank=True, null=True), 18 | ), 19 | migrations.AddField( 20 | model_name='flavor', 21 | name='datetime', 22 | field=models.DateTimeField(blank=True, null=True), 23 | ), 24 | migrations.AddField( 25 | model_name='flavor', 26 | name='duration', 27 | field=models.DurationField(blank=True, null=True), 28 | ), 29 | migrations.AddField( 30 | model_name='flavor', 31 | name='time', 32 | field=models.TimeField(blank=True, null=True), 33 | ), 34 | migrations.AddField( 35 | model_name='flavor', 36 | name='uuid', 37 | field=models.UUIDField(default=uuid.uuid4), 38 | ), 39 | migrations.AlterField( 40 | model_name='flavor', 41 | name='decimal_value', 42 | field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True), 43 | ), 44 | migrations.AlterField( 45 | model_name='flavor', 46 | name='float_value', 47 | field=models.FloatField(blank=True, null=True), 48 | ), 49 | ] 50 | -------------------------------------------------------------------------------- /docs/source/javascript.md: -------------------------------------------------------------------------------- 1 | # JavaScript Integration 2 | 3 | ## Call JavaScript from View 4 | 5 | To integrate with other JavaScript functions, view methods can call an arbitrary JavaScript function after it gets rendered. 6 | 7 | ```html 8 | 9 |
10 | 15 | 16 | 17 | 18 |
19 | ``` 20 | 21 | ```python 22 | # call_javascript.py 23 | from django_unicorn.components import UnicornView 24 | 25 | class CallJavascriptView(UnicornView): 26 | name = "" 27 | 28 | def mount(self): 29 | self.call("hello", "world") 30 | 31 | def hello(self): 32 | self.call("hello", self.name) 33 | ``` 34 | 35 | ## Trigger Model Update 36 | 37 | Normally when a model element gets changed by a user it will trigger an event which `Unicorn` listens for (either `input` or `blur` depending on if it has a `lazy` modifier). However, when setting an element with JavaScript those events do not fire. `Unicorn.trigger()` provides a way to trigger that event from JavaScript manually. 38 | 39 | The first argument to `trigger` is the component name. The second argument is the value for the element's `unicorn:key`. 40 | 41 | ```html 42 | 43 | 49 | 50 | 54 | ``` 55 | -------------------------------------------------------------------------------- /docs/source/dirty-states.md: -------------------------------------------------------------------------------- 1 | # Dirty States 2 | 3 | `Unicorn` can provide context to the user that some data has been changed and will be updated. 4 | 5 | ## Toggling Attributes 6 | 7 | Elements can include an `unicorn:dirty` attribute with either an `attr` or `class` modifier. 8 | 9 | ### attr 10 | 11 | Set the specified attribute on the element that is changed. 12 | 13 | This example will set the input to be readonly when the model is changed. The attribute will be removed once the name is synced or if the input value is changed back to the original value. 14 | 15 | :::{code} html 16 | :force: true 17 | 18 | 19 |
20 | 21 |
22 | ::: 23 | 24 | ### class 25 | 26 | Add the specified class(es) to the model that is changed. 27 | 28 | This example will add _dirty_ and _changing_ classes to the input when the model is changed. The classes will be removed once the model is synced or if the input value is changed back to the original value. 29 | 30 | :::{code} html 31 | :force: true 32 | 33 | 34 |
35 | 36 |
37 | ::: 38 | 39 | ### class.remove 40 | 41 | Remove the specified class(es) from the model that is changed. 42 | 43 | This example will remove the _clean_ class from the input when the model is changed. The class will be added back once the model is synced or if the input value is changed back to the original value. 44 | 45 | :::{code} html 46 | :force: true 47 | 48 | 49 |
50 | 51 |
52 | ::: 53 | -------------------------------------------------------------------------------- /docs/source/cli.md: -------------------------------------------------------------------------------- 1 | # CLI 2 | 3 | `Unicorn` provides a Django management command to create new components. The first argument is the name of the Django app to create components in. Every argument after is the name of components that should be created. 4 | 5 | ```shell 6 | python manage.py startunicorn unicorn hello-world 7 | ``` 8 | 9 | This example would create a `unicorn` directory, and `templates` and `components` sub-directories if necessary. Underneath the `components` directory there will be a new module and subclass of `django_unicorn.components.UnicornView`. Underneath the `templates/unicorn` directory will be a example template. 10 | 11 | The following is an example folder structure. 12 | 13 | ``` 14 | unicorn/ 15 | components/ 16 | __init__.py 17 | hello_world.py 18 | templates/ 19 | unicorn/ 20 | hello-world.html 21 | ``` 22 | 23 | ```{note} 24 | If you have an existing Django app, you can use that instead of `unicorn` like the example above. The management command will create the the directories and files as needed. 25 | ``` 26 | 27 | ## Sub-folders 28 | 29 | `startunicorn` supports creating components in sub-folders. Separate each folder by a dot (similar to Python modules) to create a nested structure. 30 | 31 | ```shell 32 | python manage.py startunicorn unicorn hello.world 33 | ``` 34 | 35 | ``` 36 | unicorn/ 37 | components/ 38 | __init__.py 39 | hello/ 40 | __init__.py 41 | world.py 42 | templates/ 43 | unicorn/ 44 | hello/ 45 | world.html 46 | ``` 47 | 48 | The nested component would be included in a template like: 49 | 50 | ```html 51 | {% unicorn 'hello.world' %} 52 | ``` 53 | -------------------------------------------------------------------------------- /docs/source/troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | ## Disallowed MIME type error on Windows 4 | 5 | Apparently Windows system-wide MIME type configuration sometimes won't load up JavaScript modules in certain browsers. The errors would be something like `Loading module from “http://127.0.0.1:8000/static/js/unicorn.js” was blocked because of a disallowed MIME type (“text/plain”)` or `Failed to load module script: The server responded with a non-JavaScript MIME type of "text/plain".` 6 | 7 | One suggested solution is to add the following to the bottom of the settings file: 8 | 9 | ```python 10 | # settings.py 11 | 12 | if DEBUG: 13 | import mimetypes 14 | mimetypes.add_type("application/javascript", ".js", True) 15 | ``` 16 | 17 | See this [Windows MIME type detection pitfalls](https://www.taricorp.net/2020/windows-mime-pitfalls/) article, this [StackOverflow answer](https://stackoverflow.com/a/16355034), or [issue #201](https://github.com/adamghill/django-unicorn/issues/201) for more details. 18 | 19 | ## Missing CSRF token or 403 Forbidden errors 20 | 21 | `Unicorn` uses CSRF to protect its endpoint from malicious actors. The two parts that are required for CSRF are `"django.middleware.csrf.CsrfViewMiddleware"` in `MIDDLEWARE` and `{% csrf_token %}` in the template that includes any `Unicorn` components. 22 | 23 | ```python 24 | # settings.py 25 | 26 | ... 27 | MIDDLEWARE = [ 28 | ... 29 | "django.middleware.csrf.CsrfViewMiddleware", 30 | ... 31 | ] 32 | ``` 33 | 34 | ```html 35 | 36 | {% load unicorn %} 37 | 38 | 39 | 40 | {% unicorn_scripts %} 41 | 42 | 43 | {% csrf_token %} 44 | 45 | {% unicorn 'hello-world' %} 46 | 47 | 48 | ``` 49 | -------------------------------------------------------------------------------- /DEVELOPING.md: -------------------------------------------------------------------------------- 1 | # Developing 2 | 3 | ## Local development 4 | 5 | 1. Fork https://github.com/adamghill/django-unicorn` 6 | 1. `git clone` your forked repository 7 | 1. `cd django-unicorn` 8 | 1. `poetry install -E minify -E docs` 9 | 1. `poetry run python example/manage.py migrate` 10 | 1. `poetry run python example/manage.py runserver localhost:8000` 11 | 1. Go to `localhost:8000` in your browser 12 | 13 | ## To install in another project 14 | 15 | 1. Download the repo to your local 16 | 1. `pip install -e ../django-unicorn` from inside the other project's virtualenv _or_ add `django-unicorn = { path="../django-unicorn", develop=true }` to the other project's `pyproject.toml` 17 | 18 | ## Build Sphinx documentation 19 | 20 | 1. `poetry run sphinx-autobuild -W docs/source docs/build` 21 | 22 | ## Run unit tests on local environment 23 | 24 | 1. Python: `poetry run pytest` 25 | 1. JavaScript: `npm run test` 26 | 27 | ## Run Python/Django matrix unit tests 28 | 29 | 1. Install [`act`](https://nektosact.com) 30 | 1. `act -W .github/workflows/python.yml -j test` 31 | 32 | # Minify JavaScript 33 | 34 | 1. `npm install` 35 | 1. `npm run build` 36 | 37 | ## Bump version 38 | 39 | 1. Update changelog.md 40 | 1. Update package.json 41 | 1. `poetry version major|minor|patch` 42 | 1. Run all build processes: `poe build` 43 | 1. Commit/tag/push version bump 44 | 1. `poe publish` 45 | 1. Make sure test package can be installed as expected (https://test.pypi.org/project/django-unicorn/) 46 | 1. Make sure live package can be installed as expected (https://pypi.org/project/django-unicorn/) 47 | 1. [Create GitHub release](https://github.com/adamghill/django-unicorn/releases/new) and add changelog there 48 | 1. Update django-unicorn.com's version of `django-unicorn` 49 | -------------------------------------------------------------------------------- /tests/views/test_process_component_request.py: -------------------------------------------------------------------------------- 1 | from tests.views.message.utils import post_and_get_response 2 | 3 | from django_unicorn.components import UnicornView 4 | 5 | 6 | class FakeComponent(UnicornView): 7 | template_name = "templates/test_component_variable.html" 8 | 9 | hello = "" 10 | 11 | 12 | class FakeComponentSafe(UnicornView): 13 | template_name = "templates/test_component_variable.html" 14 | 15 | hello = "" 16 | 17 | class Meta: 18 | safe = ("hello",) 19 | 20 | 21 | def test_html_entities_encoded(client): 22 | data = {"hello": "test"} 23 | action_queue = [ 24 | { 25 | "payload": {"name": "hello", "value": "test1"}, 26 | "type": "syncInput", 27 | } 28 | ] 29 | response = post_and_get_response( 30 | client, 31 | url="/message/tests.views.test_process_component_request.FakeComponent", 32 | data=data, 33 | action_queue=action_queue, 34 | ) 35 | 36 | assert not response["errors"] 37 | assert response["data"].get("hello") == "test1" 38 | assert "<b>test1</b>" in response["dom"] 39 | 40 | 41 | def test_safe_html_entities_not_encoded(client): 42 | data = {"hello": "test"} 43 | action_queue = [ 44 | { 45 | "payload": {"name": "hello", "value": "test1"}, 46 | "type": "syncInput", 47 | } 48 | ] 49 | response = post_and_get_response( 50 | client, 51 | url="/message/tests.views.test_process_component_request.FakeComponentSafe", 52 | data=data, 53 | action_queue=action_queue, 54 | ) 55 | 56 | assert not response["errors"] 57 | assert response["data"].get("hello") == "test1" 58 | assert "test1" in response["dom"] 59 | -------------------------------------------------------------------------------- /tests/js/utils/args.test.js: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { args } from "../../../django_unicorn/static/unicorn/js/utils.js"; 3 | 4 | test("one arg", (t) => { 5 | const functionArgs = args("test($event.target.value)"); 6 | 7 | t.is(functionArgs.length, 1); 8 | t.is(functionArgs[0], "$event.target.value"); 9 | }); 10 | 11 | test("two args", (t) => { 12 | const functionArgs = args("test($event.target.value, 1)"); 13 | 14 | t.is(functionArgs.length, 2); 15 | t.is(functionArgs[0], "$event.target.value"); 16 | t.is(functionArgs[1], "1"); 17 | }); 18 | 19 | test("two args with array", (t) => { 20 | const functionArgs = args("test($event.target.value, [1, 2])"); 21 | 22 | t.is(functionArgs.length, 2); 23 | t.is(functionArgs[0], "$event.target.value"); 24 | t.is(functionArgs[1], "[1, 2]"); 25 | }); 26 | 27 | test("two args with object", (t) => { 28 | const functionArgs = args('test($event.target.value, {"1": 2})'); 29 | 30 | t.is(functionArgs.length, 2); 31 | t.is(functionArgs[0], "$event.target.value"); 32 | t.is(functionArgs[1], '{"1": 2}'); 33 | }); 34 | 35 | test("two args with comma in double quotes", (t) => { 36 | const functionArgs = args('test($event.target.value, "1,2")'); 37 | 38 | t.is(functionArgs.length, 2); 39 | t.is(functionArgs[0], "$event.target.value"); 40 | t.is(functionArgs[1], '"1,2"'); 41 | }); 42 | 43 | test("two args with comma in single quotes", (t) => { 44 | const functionArgs = args("test($event.target.value, '1,2')"); 45 | 46 | t.is(functionArgs.length, 2); 47 | t.is(functionArgs[0], "$event.target.value"); 48 | t.is(functionArgs[1], "'1,2'"); 49 | }); 50 | 51 | test("two args with missing parenthesis", (t) => { 52 | const functionArgs = args("test(4, $event.target.value"); 53 | 54 | // Error condition returns an empty array of args 55 | t.is(functionArgs.length, 0); 56 | }); 57 | -------------------------------------------------------------------------------- /tests/serializer/test_model_value.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.db import models 3 | 4 | from django_unicorn.components import ModelValueMixin 5 | from django_unicorn.serializer import model_value 6 | from example.coffee.models import Flavor 7 | 8 | 9 | def test_model_value_all_fields(): 10 | flavor = Flavor(name="flavor-1") 11 | 12 | expected = { 13 | "date": None, 14 | "datetime": None, 15 | "decimal_value": None, 16 | "duration": None, 17 | "float_value": None, 18 | "name": flavor.name, 19 | "label": "", 20 | "parent": None, 21 | "pk": None, 22 | "time": None, 23 | "uuid": str(flavor.uuid), 24 | "taste_set": [], 25 | "origins": [], 26 | } 27 | 28 | actual = model_value(flavor) 29 | 30 | assert expected == actual 31 | 32 | 33 | def test_model_value_one_field(): 34 | expected = {"name": "flavor-1"} 35 | 36 | flavor = Flavor(name="flavor-1") 37 | actual = model_value(flavor, "name") 38 | 39 | assert expected == actual 40 | 41 | 42 | @pytest.mark.django_db 43 | def test_model_value_multiple_field(): 44 | expected = { 45 | "pk": 77, 46 | "name": "flavor-1", 47 | } 48 | 49 | flavor = Flavor(name="flavor-1", id=77) 50 | actual = model_value(flavor, "pk", "name") 51 | 52 | assert expected == actual 53 | 54 | 55 | class FakeModel(ModelValueMixin, models.Model): 56 | name = models.CharField(max_length=255) 57 | 58 | class Meta: 59 | app_label = "tests" 60 | 61 | 62 | def test_model_value_mixin(): 63 | test_model = FakeModel(name="test-model") 64 | expected = {"name": test_model.name} 65 | 66 | actual = test_model.value("name") 67 | assert expected == actual 68 | 69 | actual = model_value(test_model, "name") 70 | assert expected == actual 71 | -------------------------------------------------------------------------------- /django_unicorn/static/unicorn/js/delayers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns a function, that, as long as it continues to be invoked, will not 3 | * be triggered. The function will be called after it stops being called for 4 | * N milliseconds. If `immediate` is passed, trigger the function on the 5 | * leading edge, instead of the trailing. 6 | 7 | * Derived from underscore.js's implementation in https://davidwalsh.name/javascript-debounce-function. 8 | */ 9 | export function debounce(func, wait, immediate) { 10 | let timeout; 11 | 12 | if (typeof immediate === "undefined") { 13 | immediate = true; 14 | } 15 | 16 | return (...args) => { 17 | const context = this; 18 | 19 | const later = () => { 20 | timeout = null; 21 | if (!immediate) { 22 | func.apply(context, args); 23 | } 24 | }; 25 | 26 | const callNow = immediate && !timeout; 27 | clearTimeout(timeout); 28 | timeout = setTimeout(later, wait); 29 | 30 | if (callNow) { 31 | func.apply(context, args); 32 | } 33 | }; 34 | } 35 | 36 | /** 37 | * The function is executed the number of times it is called, 38 | * but there is a fixed wait time before each execution. 39 | * From https://medium.com/ghostcoder/debounce-vs-throttle-vs-queue-execution-bcde259768. 40 | */ 41 | const funcQueue = []; 42 | export function queue(func, waitTime) { 43 | let isWaiting; 44 | 45 | const play = () => { 46 | let params; 47 | isWaiting = false; 48 | 49 | if (funcQueue.length) { 50 | params = funcQueue.shift(); 51 | executeFunc(params); 52 | } 53 | }; 54 | 55 | const executeFunc = (params) => { 56 | isWaiting = true; 57 | func(params); 58 | setTimeout(play, waitTime); 59 | }; 60 | 61 | return (params) => { 62 | if (isWaiting) { 63 | funcQueue.push(params); 64 | } else { 65 | executeFunc(params); 66 | } 67 | }; 68 | } 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv* 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # vscode 107 | .vscode/ 108 | .DS_Store 109 | pip-wheel-metadata 110 | TODO.md 111 | 112 | # pycharm 113 | .idea/ 114 | 115 | node_modules/ 116 | tags 117 | staticfiles/ -------------------------------------------------------------------------------- /django_unicorn/components/updaters.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Optional 3 | 4 | from django.http.response import HttpResponseRedirect 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class Update: 10 | """ 11 | Base class for updaters. 12 | """ 13 | 14 | def to_json(self): 15 | return self.__dict__ 16 | 17 | 18 | class HashUpdate(Update): 19 | """ 20 | Updates the current URL hash from an action method. 21 | """ 22 | 23 | def __init__(self, url_hash: str): 24 | """ 25 | Args: 26 | param url_hash: The URL hash to change. Example: `#model-123`. 27 | """ 28 | self.hash = url_hash 29 | 30 | 31 | class LocationUpdate(Update): 32 | """ 33 | Updates the current URL from an action method. 34 | """ 35 | 36 | def __init__(self, redirect: HttpResponseRedirect, title: Optional[str] = None): 37 | """ 38 | Args: 39 | param redirect: The redirect that contains the URL to redirect to. 40 | param title: The new title of the page. Optional. 41 | """ 42 | self.redirect = redirect 43 | self.title = title 44 | 45 | 46 | class PollUpdate(Update): 47 | """ 48 | Updates the current poll from an action method. 49 | """ 50 | 51 | def __init__(self, *, timing: Optional[int] = None, method: Optional[str] = None, disable: bool = False): 52 | """ 53 | Args: 54 | param timing: The timing that should be used for the poll. Optional. Defaults to `None` 55 | which keeps the existing timing. 56 | param method: The method that should be used for the poll. Optional. Defaults to `None` 57 | which keeps the existing method. 58 | param disable: Whether to disable the poll or not not. Optional. Defaults to `False`. 59 | """ 60 | self.timing = timing 61 | self.method = method 62 | self.disable = disable 63 | -------------------------------------------------------------------------------- /example/www/templates/www/base.html: -------------------------------------------------------------------------------- 1 | {% load static unicorn %} 2 | 3 | 4 | 5 | 6 | django-unicorn examples 7 | 8 | 9 | 25 | 26 | 27 | 28 | 29 | {% unicorn_scripts %} 30 | 31 | 32 | 33 |
34 | {% csrf_token %} 35 | 36 |

django-unicorn

37 | 38 | 52 | 53 | {% block content %}{% endblock content %} 54 | 55 |
56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /.github/workflows/python.yml: -------------------------------------------------------------------------------- 1 | name: Python 2 | 3 | on: 4 | push: 5 | workflow_dispatch: 6 | 7 | env: 8 | LINT_PYTHON_VERSION: 3.11 9 | 10 | jobs: 11 | lint: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 1 18 | 19 | - name: Install Poetry 20 | run: | 21 | pipx install poetry 22 | poetry config virtualenvs.path ~/.virtualenvs${{ env.LINT_PYTHON_VERSION }} 23 | 24 | - name: Set up Python ${{ env.LINT_PYTHON_VERSION }} 25 | uses: actions/setup-python@v3 26 | with: 27 | python-version: ${{ env.LINT_PYTHON_VERSION }} 28 | cache: "poetry" 29 | 30 | - name: Install dependencies 31 | run: | 32 | poetry install 33 | 34 | - name: ruff check 35 | run: poetry run ruff check . 36 | 37 | - name: mypy check 38 | run: poetry run mypy django_unicorn 39 | 40 | test: 41 | runs-on: ubuntu-latest 42 | strategy: 43 | matrix: 44 | python-version: ["3.10", "3.11", "3.12"] 45 | django-version: ["4.1", "4.2", "5.0"] 46 | 47 | steps: 48 | - uses: actions/checkout@v3 49 | with: 50 | fetch-depth: 1 51 | 52 | - name: Install Poetry 53 | run: | 54 | pipx install poetry 55 | poetry config virtualenvs.path ~/.virtualenvs${{ matrix.python-version }} 56 | 57 | - name: Set up Python ${{ matrix.python-version }} 58 | uses: actions/setup-python@v3 59 | with: 60 | python-version: ${{ matrix.python-version }} 61 | cache: "poetry" 62 | 63 | - name: Install dependencies 64 | run: | 65 | poetry env use ${{ matrix.python-version }} 66 | poetry add django==${{ matrix.django-version }} 67 | poetry install -E minify 68 | 69 | - name: Fast tests 70 | run: poetry run pytest -m 'not slow' 71 | 72 | - name: Slow tests 73 | run: poetry run pytest -m 'slow' 74 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - "main" 8 | 9 | env: 10 | PYTHON_VERSION: 3.11 11 | 12 | jobs: 13 | test: 14 | name: Run tests & display coverage 15 | runs-on: ubuntu-latest 16 | permissions: 17 | # Gives the action the necessary permissions for publishing new 18 | # comments in pull requests. 19 | pull-requests: write 20 | # Gives the action the necessary permissions for pushing data to the 21 | # python-coverage-comment-action branch, and for editing existing 22 | # comments (to avoid publishing multiple comments in the same PR) 23 | contents: write 24 | steps: 25 | - uses: actions/checkout@v4 26 | with: 27 | fetch-depth: 1 28 | 29 | - name: Install Poetry 30 | run: | 31 | pipx install poetry 32 | poetry config virtualenvs.path ~/.virtualenvs${{ env.PYTHON_VERSION }} 33 | 34 | - name: Set up Python ${{ env.PYTHON_VERSION }} 35 | uses: actions/setup-python@v3 36 | with: 37 | python-version: ${{ env.PYTHON_VERSION }} 38 | cache: "poetry" 39 | 40 | - name: Install dependencies 41 | run: | 42 | poetry install -E minify 43 | 44 | - name: Produce the .coverage file 45 | run: poetry run coverage run -m pytest 46 | 47 | - name: Coverage comment 48 | id: coverage_comment 49 | uses: py-cov-action/python-coverage-comment-action@v3 50 | with: 51 | GITHUB_TOKEN: ${{ github.token }} 52 | 53 | - name: Store Pull Request comment to be posted 54 | uses: actions/upload-artifact@v4 55 | if: steps.coverage_comment.outputs.COMMENT_FILE_WRITTEN == 'true' 56 | with: 57 | # If you use a different name, update COMMENT_ARTIFACT_NAME accordingly 58 | name: python-coverage-comment-action 59 | # If you use a different name, update COMMENT_FILENAME accordingly 60 | path: python-coverage-comment-action.txt 61 | -------------------------------------------------------------------------------- /tests/views/message/test_calls.py: -------------------------------------------------------------------------------- 1 | from tests.views.message.utils import post_and_get_response 2 | 3 | from django_unicorn.components import UnicornView 4 | 5 | 6 | class FakeCallsComponent(UnicornView): 7 | template_name = "templates/test_component.html" 8 | 9 | def test_call(self): 10 | self.call("testCall") 11 | 12 | def test_call2(self): 13 | self.call("testCall2") 14 | 15 | def test_call3(self): 16 | self.call("testCall3", "hello") 17 | 18 | 19 | FAKE_CALLS_COMPONENT_URL = "/message/tests.views.message.test_calls.FakeCallsComponent" 20 | 21 | 22 | def test_message_calls(client): 23 | action_queue = [ 24 | { 25 | "payload": {"name": "test_call"}, 26 | "type": "callMethod", 27 | "target": None, 28 | } 29 | ] 30 | 31 | response = post_and_get_response(client, url=FAKE_CALLS_COMPONENT_URL, action_queue=action_queue) 32 | 33 | assert response.get("calls") == [{"args": [], "fn": "testCall"}] 34 | 35 | 36 | def test_message_multiple_calls(client): 37 | action_queue = [ 38 | { 39 | "payload": {"name": "test_call"}, 40 | "type": "callMethod", 41 | "target": None, 42 | }, 43 | { 44 | "payload": {"name": "test_call2"}, 45 | "type": "callMethod", 46 | "target": None, 47 | }, 48 | ] 49 | response = post_and_get_response(client, url=FAKE_CALLS_COMPONENT_URL, action_queue=action_queue) 50 | 51 | assert response.get("calls") == [ 52 | {"args": [], "fn": "testCall"}, 53 | {"args": [], "fn": "testCall2"}, 54 | ] 55 | 56 | 57 | def test_message_calls_with_arg(client): 58 | action_queue = [ 59 | { 60 | "payload": {"name": "test_call3"}, 61 | "type": "callMethod", 62 | "target": None, 63 | } 64 | ] 65 | 66 | response = post_and_get_response(client, url=FAKE_CALLS_COMPONENT_URL, action_queue=action_queue) 67 | 68 | assert response.get("calls") == [{"args": ["hello"], "fn": "testCall3"}] 69 | -------------------------------------------------------------------------------- /docs/source/visibility.md: -------------------------------------------------------------------------------- 1 | # Visibility 2 | 3 | `unicorn:visible` can be added to any element to have it call the specified view method when it scrolls into view. 4 | 5 | ```python 6 | # visibility.py 7 | from django_unicorn.components import UnicornView 8 | 9 | class VisibilityView(UnicornView): 10 | visibility_count = 0 11 | 12 | def add_count(self): 13 | self.visibility_count += 1 14 | ``` 15 | 16 | ```html 17 | 18 |
19 |
20 | 21 |
22 |
23 | ``` 24 | 25 | ```{note} 26 | In some cases, the element with the `unicorn:visible` attribute will always be in the viewport, so the event will continue to fire and the method will continue to execute. However, this will not happen in the following instances: 27 | 28 | - the fields of component do not change, so the AJAX request will return a 304 status code 29 | - the method explicitly returns `False` 30 | ``` 31 | 32 | ## Modifiers 33 | 34 | There are a few modifiers for `unicorn:visible` and they are able to be chained if necessary. 35 | 36 | ### Debounce 37 | 38 | Similar to the debounce modifier on a [model](templates.md#debounce) or [actions](actions.md#debounce), wait the specified number of milliseconds before calling the specified method. 39 | 40 | :::{code} html 41 | :force: true 42 | 43 | 44 |
45 |
46 | 47 |
48 |
49 | ::: 50 | 51 | ### Threshold 52 | 53 | The percentage (as an integer) that should be visible before being triggered. For example, `0` means that as soon as 1 pixel of the element is visible it would be fired, `25` would be 25% of the target element is visible, `100` would require 100% of the element to be completely visible. 54 | 55 | :::{code} html 56 | :force: true 57 | 58 | 59 |
60 |
61 | 62 |
63 |
64 | ::: 65 | -------------------------------------------------------------------------------- /docs/source/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | ## Create a Django Project 4 | 5 | `Unicorn` requires a working Django project before it can be used. While it is recommended that you have some experience working with Python and Django, below is a list of steps you would need do to use `Unicorn`. 6 | 7 | ```{dropdown} Install the latest version of Python 8 | `Unicorn` works with Python 3.8 or greater. If you don't have Python installed, you will need to download and install it on your local machine. 9 | 10 | You can find the [latest version](https://www.python.org/downloads/) of Python at [python.org](https://www.python.org). 11 | 12 | Once you have installed Python, make sure that it has been added to your `PATH`. 13 | 14 | `python --version` 15 | ``` 16 | 17 | ````{dropdown} Create a virtual environment 18 | Before installing Django, you will need to create a new directory for your project. It is also recommended to create a [virtual environment](https://docs.python.org/3/library/venv.html) where you can install Django, `Unicorn`, and any other dependencies. Once you have created and navigated to the directory, type the following command to create a virtual environment. 19 | 20 | `python -m venv .venv` 21 | 22 | ```{note} 23 | Some package managers automatically create a virtual environment so this step might not be required. But, a virtual environment is suggested if directly using `pip` to install dependencies. 24 | ``` 25 | ```` 26 | 27 | ````{dropdown} Install Django and start a project 28 | You are now ready to install Django and start your project. 29 | 30 | ```sh 31 | python -m pip install Django 32 | django-admin startproject project-name . 33 | ``` 34 | 35 | If you have never created a Django project before, you may want to get acquainted with it first. There are several resources, including some [official tutorials](https://docs.djangoproject.com/en/stable/intro/), though the [Django Girls Tutorial](https://tutorial.djangogirls.org/en/django_start_project/) is also highly recommended. 36 | ```` 37 | 38 | Once you have a working Django project, you are ready to [install `Unicorn`](installation.md). -------------------------------------------------------------------------------- /tests/js/utils/walk.test.js: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { 3 | FilterAny, 4 | FilterSkipNested, 5 | walk, 6 | } from "../../../django_unicorn/static/unicorn/js/utils"; 7 | import { getEl, setBrowserMocks } from "../utils.js"; 8 | 9 | // makes a document and NodeFilter available from a fake DOM 10 | setBrowserMocks(); 11 | 12 | test("walk any", (t) => { 13 | const componentRootHtml = ` 14 |
15 | 16 |
22 | 23 |
24 | 25 |
26 | `; 27 | const componentRoot = getEl(componentRootHtml); 28 | const nodes = []; 29 | 30 | walk(componentRoot, (node) => nodes.push(node), FilterAny); 31 | 32 | t.is(nodes.length, 4); 33 | t.is(nodes[0].getAttribute("id"), "name"); 34 | t.is(nodes[1].getAttribute("unicorn:id"), "5jypjiyb:nested.filter"); 35 | t.is(nodes[2].getAttribute("id"), "search"); 36 | t.is(nodes[3].getAttribute("id"), "name2"); 37 | }); 38 | 39 | test("walk skip nested", (t) => { 40 | const componentRootHtml = ` 41 |
42 | 43 |
49 | 50 |
51 | 52 |
53 | `; 54 | const componentRoot = getEl(componentRootHtml); 55 | const nodes = []; 56 | 57 | walk(componentRoot, (node) => nodes.push(node), FilterSkipNested); 58 | 59 | t.is(nodes.length, 2); 60 | t.is(nodes[0].getAttribute("id"), "name"); 61 | t.is(nodes[1].getAttribute("id"), "name2"); 62 | }); 63 | -------------------------------------------------------------------------------- /tests/call_method_parser/test_parse_kwarg.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django_unicorn.call_method_parser import InvalidKwargError, parse_kwarg 4 | 5 | 6 | def test_kwargs_string(): 7 | expected = {"test": "1"} 8 | actual = parse_kwarg("test='1'") 9 | 10 | assert actual == expected 11 | assert isinstance(actual["test"], str) 12 | assert actual["test"] == "1" 13 | 14 | 15 | def test_kwargs_int(): 16 | expected = {"test": 2} 17 | actual = parse_kwarg("test=2") 18 | 19 | assert actual == expected 20 | assert isinstance(actual["test"], int) 21 | assert actual["test"] == 2 22 | 23 | 24 | def test_kwargs_invalid_startswith_doublequote(): 25 | with pytest.raises(InvalidKwargError) as e: 26 | parse_kwarg('"test"=2') 27 | 28 | assert e.type == InvalidKwargError 29 | 30 | 31 | def test_kwargs_invalid_startswith_singlequote(): 32 | with pytest.raises(InvalidKwargError) as e: 33 | parse_kwarg("'test'=2") 34 | 35 | assert e.type == InvalidKwargError 36 | 37 | 38 | def test_kwargs_invalid_no_equal_sign(): 39 | with pytest.raises(InvalidKwargError) as e: 40 | parse_kwarg("test") 41 | 42 | assert e.type == InvalidKwargError 43 | 44 | 45 | def test_kwargs_invalid_internal_doublequote(): 46 | with pytest.raises(InvalidKwargError) as e: 47 | parse_kwarg('te"st=1') 48 | 49 | assert e.type == InvalidKwargError 50 | 51 | 52 | def test_kwargs_invalid_internal_singlequote(): 53 | with pytest.raises(InvalidKwargError) as e: 54 | parse_kwarg("te'st=1") 55 | 56 | assert e.type == InvalidKwargError 57 | 58 | 59 | def test_kwargs_skip_unparseable_value(): 60 | expected = {"test": "some_context_variable"} 61 | actual = parse_kwarg("test=some_context_variable") 62 | 63 | assert actual == expected 64 | assert isinstance(actual["test"], str) 65 | assert actual["test"] == "some_context_variable" 66 | 67 | 68 | def test_kwargs_raise_unparseable_value(): 69 | with pytest.raises(ValueError) as e: 70 | parse_kwarg("test=some_context_variable", raise_if_unparseable=True) 71 | 72 | assert e.type is ValueError 73 | -------------------------------------------------------------------------------- /docs/source/partial-updates.md: -------------------------------------------------------------------------------- 1 | # Partial Updates 2 | 3 | Normally `Unicorn` will send the entire component's rendered HTML on every action to make sure that any changes to the context is reflected on the page. However, to reduce latency and minimize the amount of data that has to be sent over the network, `Unicorn` can only update a portion of the page by utilizing the `unicorn:partial` attribute. 4 | 5 | ```{note} 6 | By default, `unicorn:partial` will look in the current component's template for an `id` or `unicorn:key`. If an element can't be found with the specified target, the entire component will be morphed like usual. 7 | ``` 8 | 9 | ```python 10 | # partial_update.py 11 | from django_unicorn.components import UnicornView 12 | 13 | class PartialUpdateView(UnicornView): 14 | checked = False 15 | ``` 16 | 17 | ```html 18 | 19 |
20 | {{ checked }} 21 | 24 |
25 | ``` 26 | 27 | ## Target by id 28 | 29 | To only target an element `id` add the `id` modifier to `unicorn:partial`. 30 | 31 | :::{code} html 32 | :force: true 33 | 34 | 35 |
36 | {{ checked }} 37 | 40 |
41 | ::: 42 | 43 | ## Target by key 44 | 45 | To only target an element `unicorn:key` add the `key` modifier to `unicorn:partial`. 46 | 47 | :::{code} html 48 | :force: true 49 | 50 | 51 |
52 | {{ checked }} 53 | 56 |
57 | ::: 58 | 59 | ```{note} 60 | Multiple partials can be targetted by adding multiple attributes to the element. 61 | 62 | :::{code} html 63 | :force: true 64 | 65 | 19 | 20 | 21 | 22 | ::: 23 | 24 | ```python 25 | # messages.py 26 | from django.contrib import messages 27 | from django_unicorn.components import UnicornView 28 | 29 | class MessagesView(UnicornView): 30 | def update(self): 31 | messages.success(self.request, "update called") 32 | ``` 33 | 34 | ## Redirecting 35 | 36 | When the action returns a `redirect`, `Unicorn` will defer the messages so they do not get rendered in the component (since the user will never see the re-rendered component). Once the redirect has happened `messages` will be available for rendering by the template as expected. 37 | 38 | :::{code} html 39 | :force: true 40 | 41 | 42 |
43 | 44 |
45 | 46 | ::: 47 | 48 | :::{code} html 49 | :force: true 50 | 51 | 52 | 53 | {% if messages %} 54 | 55 |
    56 | {% for message in messages %} 57 | {{ message }} 58 | {% endfor %} 59 |
60 | {% endif %} 61 | 62 | ::: 63 | 64 | ```python 65 | # messages_when_redirecting.py 66 | from django.contrib import messages 67 | from django.shortcuts import redirect 68 | from django_unicorn.components import UnicornView 69 | 70 | class MessagesWhenRedirectingView(UnicornView): 71 | def update(self): 72 | messages.success(self.request, "update called") 73 | 74 | return redirect("new-url") 75 | ``` 76 | -------------------------------------------------------------------------------- /tests/call_method_parser/test_parse_call_method_name.py: -------------------------------------------------------------------------------- 1 | from django_unicorn.call_method_parser import parse_call_method_name 2 | 3 | 4 | def setup_function(): 5 | """Clears lru_cache before every test in the module.""" 6 | parse_call_method_name.cache_clear() 7 | 8 | 9 | def test_single_int_arg(): 10 | expected = ("set_name", (1,), {}) 11 | actual = parse_call_method_name("set_name(1)") 12 | 13 | assert actual == expected 14 | 15 | 16 | def test_single_dict_arg(): 17 | expected = ("set_name", ({"1": 2, "3": 4},), {}) 18 | actual = parse_call_method_name('set_name({"1": 2, "3": 4})') 19 | 20 | assert actual == expected 21 | 22 | 23 | def test_multiple_args(): 24 | expected = ("set_name", (0, {"1": 2}), {}) 25 | actual = parse_call_method_name('set_name(0, {"1": 2})') 26 | 27 | assert actual == expected 28 | 29 | 30 | def test_multiple_args_2(): 31 | expected = ("set_name", (1, 2), {}) 32 | actual = parse_call_method_name("set_name(1, 2)") 33 | 34 | assert actual == expected 35 | 36 | 37 | def test_var_with_curly_braces(): 38 | expected = ( 39 | "set_name", 40 | ("{}",), 41 | {}, 42 | ) 43 | actual = parse_call_method_name('set_name("{}")') 44 | 45 | assert actual == expected 46 | 47 | 48 | def test_one_arg(): 49 | expected = ( 50 | "set_name", 51 | ("1",), 52 | {}, 53 | ) 54 | actual = parse_call_method_name('set_name("1")') 55 | 56 | assert actual == expected 57 | 58 | 59 | def test_kwargs(): 60 | expected = ("set_name", (), {"kwarg1": "wow"}) 61 | actual = parse_call_method_name("set_name(kwarg1='wow')") 62 | 63 | assert actual == expected 64 | 65 | 66 | def test_args_and_kwargs(): 67 | expected = ("set_name", (8, 9), {"kwarg1": "wow"}) 68 | actual = parse_call_method_name("set_name(8, 9, kwarg1='wow')") 69 | 70 | assert actual == expected 71 | 72 | 73 | def test_special_method_without_parens(): 74 | expected = ("$reset", (), {}) 75 | actual = parse_call_method_name("$reset") 76 | 77 | assert actual == expected 78 | 79 | 80 | def test_special_method_with_parens(): 81 | expected = ("$reset", (), {}) 82 | actual = parse_call_method_name("$reset()") 83 | 84 | assert actual == expected 85 | -------------------------------------------------------------------------------- /example/unicorn/components/html_inputs.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from django_unicorn.components import UnicornView 4 | from example.coffee.models import Flavor 5 | 6 | 7 | class HtmlInputsView(UnicornView): 8 | is_checked = False 9 | another_check = True 10 | thing = "🐙" 11 | flavor: Optional[Flavor] = None 12 | flavors = Flavor.objects.none() 13 | things = [ 14 | "alien", 15 | ] 16 | pie = "cherry" 17 | paragraph = "" 18 | state = "" 19 | 20 | ALL_STATES = ( 21 | "Alabama", 22 | "Alaska", 23 | "Arizona", 24 | "Arkansas", 25 | "California", 26 | "Colorado", 27 | "Connecticut", 28 | "Delaware", 29 | "Florida", 30 | "Georgia", 31 | "Hawaii", 32 | "Idaho", 33 | "Illinois", 34 | "Indiana", 35 | "Iowa", 36 | "Kansas", 37 | "Kentucky", 38 | "Louisiana", 39 | "Maine", 40 | "Maryland", 41 | "Massachusetts", 42 | "Michigan", 43 | "Minnesota", 44 | "Mississippi", 45 | "Missouri", 46 | "Montana", 47 | "Nebraska", 48 | "Nevada", 49 | "New Hampshire", 50 | "New Jersey", 51 | "New Mexico", 52 | "New York", 53 | "North Carolina", 54 | "North Dakota", 55 | "Ohio", 56 | "Oklahoma", 57 | "Oregon", 58 | "Pennsylvania", 59 | "Rhode Island", 60 | "South Carolina", 61 | "South Dakota", 62 | "Tennessee", 63 | "Texas", 64 | "Utah", 65 | "Vermont", 66 | "Virginia", 67 | "Washington", 68 | "West Virginia", 69 | "Wisconsin", 70 | "Wyoming", 71 | ) 72 | 73 | def mount(self): 74 | self.flavors = Flavor.objects.all()[:3] 75 | 76 | def set_name(self, name=None): 77 | if name: 78 | self.name = name 79 | else: 80 | self.name = "Universe" 81 | 82 | def clear_states(self): 83 | self.state = "" 84 | 85 | def states(self): 86 | if not self.state: 87 | return [] 88 | 89 | return [s for s in self.ALL_STATES if s.lower().startswith(self.state.lower())] 90 | 91 | class Meta: 92 | exclude = ("ALL_STATES",) 93 | -------------------------------------------------------------------------------- /tests/views/test_unicorn_view_init.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.template.backends.django import Template 3 | 4 | from django_unicorn.components import UnicornView 5 | 6 | 7 | def test_init_no_component_name(): 8 | with pytest.raises(AssertionError) as e: 9 | UnicornView() 10 | 11 | assert e.exconly() == "AssertionError: Component name is required" 12 | 13 | 14 | def test_init_no_component_id(): 15 | with pytest.raises(AssertionError) as e: 16 | UnicornView(component_name="test") 17 | 18 | assert e.exconly() == "AssertionError: Component id is required" 19 | 20 | 21 | def test_init_none_component_id(): 22 | with pytest.raises(AssertionError) as e: 23 | UnicornView(component_name="test", component_id=None) 24 | 25 | assert e.exconly() == "AssertionError: Component id is required" 26 | 27 | 28 | def test_init_component_id(): 29 | component = UnicornView(component_name="test", component_id="test_init_component_id") 30 | assert component.component_id == "test_init_component_id" 31 | 32 | 33 | def test_init_component_name_valid_template_name(): 34 | component = UnicornView(component_id="test_init_component_name_valid_template_name", component_name="test") 35 | assert component.template_name == "unicorn/test.html" 36 | 37 | 38 | def test_init_kebab_component_name_valid_template_name(): 39 | component = UnicornView( 40 | component_id="test_init_kebab_component_name_valid_template_name", component_name="hello-world" 41 | ) 42 | assert component.template_name == "unicorn/hello-world.html" 43 | 44 | 45 | def test_init_snake_component_name_valid_template_name(): 46 | component = UnicornView( 47 | component_id="test_init_snake_component_name_valid_template_name", component_name="hello_world" 48 | ) 49 | assert component.template_name == "unicorn/hello_world.html" 50 | 51 | 52 | class TemplateHtmlView(UnicornView): 53 | template_html = "
test
" 54 | 55 | 56 | def test_init_template_html(): 57 | component = TemplateHtmlView(component_id="test_init_template_html", component_name="hello_world") 58 | assert isinstance(component.template_name, Template) 59 | 60 | 61 | def test_init_caches(): 62 | component = UnicornView(component_id="test_init_caches", component_name="hello_world") 63 | assert len(component._methods_cache) == 0 64 | assert len(component._attribute_names_cache) == 0 65 | -------------------------------------------------------------------------------- /example/unicorn/templates/unicorn/validation.html: -------------------------------------------------------------------------------- 1 | {% load unicorn %} 2 | 3 |
4 | 16 | 17 |
18 | {% unicorn_errors %} 19 | 20 |
21 | 22 | 23 | {% verbatim %}{{ text }}{% endverbatim %}: {{ text }} 24 |
25 | 26 |
27 | 28 | 29 | {% verbatim %}{{ number }}{% endverbatim %}: {{ number }}
30 | 31 |
32 | 33 |
34 | 35 | 36 | {{ unicorn.errors.date_time.0.message }} 37 | 38 |

39 | {% verbatim %}{{ date_time }}{% endverbatim %}: {{ date_time }} 40 |

41 | 42 |

43 | {% verbatim %}{{ date_time|date:"s" }}{% endverbatim %}: {{ date_time|date:"s" }} 44 |

45 |
46 | 47 |
48 | 49 | 50 | {% verbatim %}{{ email }}{% endverbatim %}: {{ email }} 51 |
52 | 53 |
54 | set_text_no_validation() 55 | 56 | 57 | 58 | 59 | 60 | 61 |
62 |
63 |
64 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bugs.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug Report 2 | description: Report bugs to fix and improve. 3 | title: "[Bug] :" 4 | labels: ["bug"] 5 | 6 | body: 7 | - type: textarea 8 | id: description 9 | attributes: 10 | label: Bug Description 11 | description: Please provide as much detail as possible. 12 | validations: 13 | required: true 14 | 15 | - type: textarea 16 | id: expected 17 | attributes: 18 | label: Expected behaviour 19 | description: A clear and concise description of what you expected to happen. 20 | validations: 21 | required: true 22 | 23 | - type: textarea 24 | id: screenshots 25 | attributes: 26 | label: Screenshots / Screenrecords 27 | description: Please add a screenshot/ short video of the bug you faced. 28 | validations: 29 | required: true 30 | 31 | - type: textarea 32 | id: steps 33 | attributes: 34 | label: Steps to reproduce 35 | placeholder: Add all steps/share a github gist that can help anyone reproduce the bug 36 | 37 | - type: dropdown 38 | id: browsers 39 | attributes: 40 | label: What browsers are you seeing the problem on? 41 | multiple: true 42 | options: 43 | - Firefox 44 | - Chrome 45 | - Safari 46 | - Microsoft Edge 47 | - Brave 48 | - Others 49 | 50 | - type: checkboxes 51 | id: no-duplicate-issues 52 | attributes: 53 | label: "👀 Have you checked for similar open issues?" 54 | options: 55 | - label: "I checked and didn't find similar issue" 56 | required: true 57 | 58 | - type: checkboxes 59 | id: terms 60 | attributes: 61 | label: Code of Conduct 62 | description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/adamghill/django-unicorn/blob/main/DEVELOPING.md). 63 | # the description can be changed to the contributing.md once it's available 64 | options: 65 | - label: I agree to follow this project's Code of Conduct 66 | required: true 67 | 68 | - type: dropdown 69 | attributes: 70 | label: Are you willing to work on this issue ? 71 | description: This is absolutely not required, but we are happy to guide you in the contribution process. 72 | options: 73 | - "Yes I am willing to submit a PR!" 74 | - "No, someone else can work on it" 75 | -------------------------------------------------------------------------------- /tests/views/utils/test_construct_model.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django_unicorn.typer import _construct_model 4 | from example.books.models import Author, Book 5 | from example.coffee.models import Flavor 6 | 7 | 8 | def test_construct_model_simple_model(): 9 | model_data = { 10 | "pk": 1, 11 | "name": "test-name", 12 | } 13 | 14 | actual = _construct_model(Flavor, model_data) 15 | 16 | assert actual.pk == 1 17 | assert actual.name == "test-name" 18 | 19 | 20 | @pytest.mark.django_db 21 | def test_construct_model_foreign_key(): 22 | flavor = Flavor(name="first-flavor") 23 | flavor.save() 24 | parent = Flavor(name="parent-flavor") 25 | parent.save() 26 | 27 | model_data = { 28 | "pk": flavor.id, 29 | "name": flavor.name, 30 | "parent": parent.id, 31 | } 32 | 33 | actual = _construct_model(Flavor, model_data) 34 | 35 | assert actual.pk == flavor.pk 36 | assert actual.name == flavor.name 37 | assert actual.parent.pk == parent.pk 38 | assert actual.parent.name == parent.name 39 | 40 | 41 | @pytest.mark.django_db 42 | @pytest.mark.skip("This test isn't all that helpful unless related models get serialized") 43 | def test_construct_model_recursive_foreign_key(): 44 | flavor = Flavor(name="first-flavor") 45 | flavor.save() 46 | parent = Flavor(name="parent-flavor") 47 | parent.save() 48 | 49 | model_data = { 50 | "pk": flavor.pk, 51 | "name": flavor.name, 52 | "parent": parent.pk, 53 | } 54 | 55 | actual = _construct_model(Flavor, model_data) 56 | 57 | assert actual.pk == flavor.pk 58 | assert actual.name == flavor.name 59 | assert actual.parent.pk == parent.pk 60 | assert actual.parent.name == parent.name 61 | 62 | 63 | @pytest.mark.django_db 64 | def test_construct_model_many_to_many(): 65 | author = Author(name="author 1") 66 | author.save() 67 | book = Book(title="book 1", date_published="2021-01-01") 68 | book.save() 69 | author.books.add(book) 70 | 71 | author_data = { 72 | "pk": author.pk, 73 | "name": author.name, 74 | "books": [ 75 | book.pk, 76 | ], 77 | } 78 | 79 | actual = _construct_model(Author, author_data) 80 | 81 | assert actual.pk == author.pk 82 | assert actual.name == author.name 83 | assert actual.books.count() == 1 84 | assert actual.books.all()[0].title == book.title 85 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django_unicorn.settings import ( 4 | get_cache_alias, 5 | get_minify_html_enabled, 6 | get_morpher_settings, 7 | get_script_location, 8 | get_serial_enabled, 9 | ) 10 | 11 | 12 | def test_settings_cache_alias(settings): 13 | settings.UNICORN["CACHE_ALIAS"] = "unicorn_cache" 14 | 15 | expected = "unicorn_cache" 16 | actual = get_cache_alias() 17 | assert expected == actual 18 | 19 | 20 | def test_settings_legacy(settings): 21 | settings.DJANGO_UNICORN = {} 22 | settings.DJANGO_UNICORN["CACHE_ALIAS"] = "unicorn_cache" 23 | 24 | expected = "unicorn_cache" 25 | actual = get_cache_alias() 26 | assert expected == actual 27 | 28 | 29 | def test_get_serial_enabled(settings): 30 | settings.UNICORN["SERIAL"]["ENABLED"] = False 31 | assert get_serial_enabled() is False 32 | 33 | settings.UNICORN["SERIAL"]["ENABLED"] = True 34 | assert get_serial_enabled() is True 35 | 36 | settings.UNICORN["SERIAL"]["ENABLED"] = True 37 | settings.CACHES["unicorn_cache"] = {} 38 | settings.CACHES["unicorn_cache"]["BACKEND"] = "django.core.cache.backends.dummy.DummyCache" 39 | settings.UNICORN["CACHE_ALIAS"] = "unicorn_cache" 40 | assert get_serial_enabled() is False 41 | 42 | 43 | def test_settings_minify_html_false(settings): 44 | settings.UNICORN["MINIFY_HTML"] = False 45 | 46 | assert get_minify_html_enabled() is False 47 | 48 | 49 | def test_settings_minify_html_true(settings): 50 | settings.UNICORN["MINIFY_HTML"] = True 51 | 52 | assert get_minify_html_enabled() is True 53 | 54 | settings.UNICORN["MINIFY_HTML"] = False 55 | 56 | 57 | def test_get_script_location(settings): 58 | assert get_script_location() == "after" 59 | 60 | settings.UNICORN["SCRIPT_LOCATION"] = "append" 61 | 62 | assert get_script_location() == "append" 63 | 64 | del settings.UNICORN["SCRIPT_LOCATION"] 65 | 66 | assert get_script_location() == "after" 67 | 68 | 69 | def test_get_morpher_settings(settings): 70 | assert get_morpher_settings() == {"NAME": "morphdom"} 71 | 72 | settings.UNICORN["MORPHER"] = {"NAME": "alpine"} 73 | assert get_morpher_settings()["NAME"] == "alpine" 74 | 75 | settings.UNICORN["MORPHER"] = {"NAME": "blob"} 76 | 77 | with pytest.raises(AssertionError) as e: 78 | get_morpher_settings() 79 | 80 | assert e.type is AssertionError 81 | assert e.exconly() == "AssertionError: Unknown morpher name: blob" 82 | 83 | del settings.UNICORN["MORPHER"] 84 | -------------------------------------------------------------------------------- /docs/source/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | ## Install `Unicorn` 4 | 5 | Install `Unicorn` the same as any other Python package (preferably into a [virtual environment](https://docs.python.org/3/tutorial/venv.html)). 6 | 7 | `````{tab-set} 8 | 9 | ````{tab-item} pip 10 | ```sh 11 | python -m pip install django-unicorn 12 | ``` 13 | ```` 14 | 15 | ````{tab-item} poetry 16 | ```sh 17 | poetry add django-unicorn 18 | ``` 19 | ```` 20 | 21 | ````{tab-item} pdm 22 | ```sh 23 | pdm add django-unicorn 24 | ``` 25 | ```` 26 | 27 | ````{tab-item} rye 28 | ```sh 29 | rye add django-unicorn 30 | ``` 31 | ```` 32 | 33 | ````{tab-item} pipenv 34 | ```sh 35 | pipenv install django-unicorn 36 | ``` 37 | ```` 38 | 39 | ````` 40 | 41 | ```{note} 42 | If attempting to install `django-unicorn` and `orjson` is preventing the installation from succeeding, check whether it is using 32-bit Python. Unfortunately, `orjson` is only supported on 64-bit Python. More details in [issue #105](https://github.com/adamghill/django-unicorn/issues/105). 43 | ``` 44 | 45 | ## Integrate `Unicorn` with Django 46 | 47 | 1\. Add `django_unicorn` to the `INSTALLED_APPS` list in the Django settings file (normally `settings.py`). 48 | 49 | ```python 50 | # settings.py 51 | INSTALLED_APPS = ( 52 | # other apps 53 | "django_unicorn", # required for Django to register urls and templatetags 54 | # other apps 55 | ) 56 | ``` 57 | 58 | 2\. Add `path("unicorn/", include("django_unicorn.urls")),`into the project's`urls.py`. 59 | 60 | ```python 61 | # urls.py 62 | urlpatterns = ( 63 | # other urls 64 | path("unicorn/", include("django_unicorn.urls")), 65 | ) 66 | ``` 67 | 68 | 3\. Add `{% load unicorn %}` to the top of the Django HTML template. 69 | 70 | ```{note} 71 | Generally, your Django HTML templates are typically created in the `myapp/templates/myapp` directory. You will need to add `{% load unicorn %}` at the top of each of the templates utilizing a `Unicorn` component. Alternatively, you can create one "base" template that is extended by other templates, in which case, you would only need to add `{% load unicorn %}` to the top of your base template. 72 | ``` 73 | 74 | 4\. Add `{% unicorn_scripts %}` into the Django HTML template and make sure there is a `{% csrf_token %}` in the template as well. 75 | 76 | ```html 77 | 78 | {% load unicorn %} 79 | 80 | 81 | {% unicorn_scripts %} 82 | 83 | 84 | {% csrf_token %} 85 | 86 | 87 | ``` 88 | 89 | Then, [create a component](components.md). 90 | -------------------------------------------------------------------------------- /tests/js/component/models.test.js: -------------------------------------------------------------------------------- 1 | import test from "ava"; 2 | import { getComponent } from "../utils.js"; 3 | 4 | class Event { 5 | constructor(type) { 6 | this.type = type; 7 | } 8 | } 9 | global.Event = Event; 10 | 11 | test("modelEls", (t) => { 12 | const component = getComponent(); 13 | 14 | t.is(component.modelEls.length, 1); 15 | }); 16 | 17 | test("abbreviated name", (t) => { 18 | const html = ` 19 |
20 | 21 |
`; 22 | const component = getComponent(html); 23 | 24 | t.is(component.modelEls.length, 1); 25 | }); 26 | 27 | test("model.lazy has input and blur events", (t) => { 28 | const html = ` 29 |
30 | 31 |
`; 32 | const component = getComponent(html); 33 | 34 | t.is(component.modelEls.length, 1); 35 | 36 | const element = component.modelEls[0]; 37 | 38 | t.is(element.events.length, 2); 39 | }); 40 | 41 | test("model trigger with invalid key", (t) => { 42 | t.plan(0); 43 | 44 | const html = ` 45 |
46 | 47 |
`; 48 | const component = getComponent(html); 49 | const element = component.modelEls[0]; 50 | 51 | element.el.dispatchEvent = () => { 52 | t.pass(); 53 | }; 54 | 55 | component.trigger("invalidNameKey"); 56 | }); 57 | 58 | test("model.lazy trigger", (t) => { 59 | t.plan(1); 60 | 61 | const html = ` 62 |
63 | 64 |
`; 65 | const component = getComponent(html); 66 | const element = component.modelEls[0]; 67 | 68 | element.el.dispatchEvent = (event) => { 69 | t.true(event.type === "blur"); 70 | }; 71 | 72 | component.trigger("nameKey"); 73 | }); 74 | 75 | test("model trigger", (t) => { 76 | t.plan(1); 77 | 78 | const html = ` 79 |
80 | 81 |
`; 82 | const component = getComponent(html); 83 | const element = component.modelEls[0]; 84 | 85 | element.el.dispatchEvent = (event) => { 86 | t.true(event.type === "input"); 87 | }; 88 | 89 | component.trigger("nameKey"); 90 | }); 91 | -------------------------------------------------------------------------------- /tests/views/test_unicorn_set_property_value.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | import pytest 4 | 5 | from django_unicorn.components import UnicornView 6 | from django_unicorn.views.action_parsers.utils import set_property_value 7 | from example.coffee.models import Flavor 8 | 9 | 10 | @dataclass 11 | class InventoryItem: 12 | """Class for keeping track of an item in inventory.""" 13 | 14 | name: str 15 | unit_price: float 16 | quantity_on_hand: int = 0 17 | 18 | 19 | class PropertyView(UnicornView): 20 | inventory = InventoryItem("Hammer", 20) 21 | 22 | 23 | class FakeComponent(UnicornView): 24 | flavors = [] # noqa: RUF012 25 | 26 | def __init__(self, **kwargs): 27 | self.flavors = list(Flavor.objects.all()) 28 | 29 | super().__init__(**kwargs) 30 | 31 | 32 | def test_set_property_value_dataclass(): 33 | component = PropertyView(component_name="test", component_id="test_set_property_value_dataclass") 34 | assert InventoryItem("Hammer", 20) == component.inventory 35 | 36 | set_property_value( 37 | component, 38 | "inventory", 39 | InventoryItem("Hammer", 20, 1), 40 | {"inventory": InventoryItem("Hammer", 20, 1)}, 41 | ) 42 | 43 | assert InventoryItem("Hammer", 20, 1) == component.inventory 44 | 45 | 46 | @pytest.mark.django_db 47 | def test_set_property_value_array(): 48 | flavor_one = Flavor(name="initial 1") 49 | flavor_one.save() 50 | flavor_two = Flavor(name="initial 2") 51 | flavor_two.save() 52 | component = FakeComponent(component_name="test", component_id="test_set_property_value_array") 53 | 54 | set_property_value( 55 | component, 56 | "flavors.0.name", 57 | "test 1", 58 | {"flavors": [{"name": "test"}, {"name": "something"}]}, 59 | ) 60 | 61 | assert component.flavors[0].name == "test 1" 62 | 63 | 64 | @pytest.mark.django_db 65 | def test_set_property_value_foreign_key(): 66 | flavor = Flavor(name="initial 1") 67 | flavor.save() 68 | parent = Flavor(name="initial 2") 69 | parent.save() 70 | component = FakeComponent(component_name="test", component_id="test_set_property_value_foreign_key") 71 | 72 | set_property_value( 73 | component, 74 | "flavors.0.parent", 75 | parent.pk, 76 | { 77 | "flavors": [ 78 | {"name": flavor.name, "parent": None}, 79 | ] 80 | }, 81 | ) 82 | 83 | assert component.flavors[0].parent_id == parent.pk 84 | 85 | # This fails for Django 2.2, but not 3.0 86 | # assert component.flavors[0].parent.pk == parent.pk 87 | -------------------------------------------------------------------------------- /docs/source/settings.md: -------------------------------------------------------------------------------- 1 | # Settings 2 | 3 | `Unicorn` stores all settings in a dictionary under the `UNICORN` attribute in the Django settings file. All settings are optional. 4 | 5 | ```python 6 | # settings.py 7 | UNICORN = { 8 | "APPS": ["unicorn",], 9 | "CACHE_ALIAS": "default", 10 | "MINIFY_HTML": False, 11 | "MINIFIED": True, 12 | "SERIAL": { 13 | "ENABLED": False, 14 | "TIMEOUT": 60, 15 | }, 16 | "SCRIPT_LOCATION": "after", 17 | "MORPHER": { 18 | "NAME": "morphdom", 19 | "RELOAD_SCRIPT_ELEMENTS": False, 20 | }, 21 | } 22 | ``` 23 | 24 | ## APPS 25 | 26 | Specify the modules to look for components. Defaults to `["unicorn",]`. 27 | 28 | ## CACHE_ALIAS 29 | 30 | The alias to use for caching. Only used by the experimental serialization of requests for now. Defaults to `"default"`. 31 | 32 | ## MINIFY_HTML 33 | 34 | Minify the HTML generated by `Unicorn` in the AJAX request. If set to `True` and [`htmlmin`](https://pypi.org/project/htmlmin/) is installed HTML will be minified. `htmlmin` can be installed with `Unicorn` via `poetry add django-unicorn[minify]` or `pip install django-unicorn[minify]`. Defaults to `False`. 35 | 36 | ## MINIFIED 37 | 38 | Provides a way to control if the minified version of the JavaScript bundle (i.e. `unicorn.min.js`) is used. Defaults to `!DEBUG`. 39 | 40 | ## SERIAL 41 | 42 | Settings for the experimental serialization of requests. Defaults to `{}`. 43 | 44 | ### ENABLED 45 | 46 | Whether slow requests to the same component should be queued or not. Defaults to `False`. 47 | 48 | ### TIMEOUT 49 | 50 | The number of seconds to wait for a request to finish for additional requests to queue behind it. Defaults to `60`. 51 | 52 | ## SCRIPT_LOCATION 53 | 54 | Where the initial JavaScript data is included on initial render. Two values are currently supported: `after` and `append`. 55 | 56 | **after** is the default and will render the JavaScript _outside_ of the HTML component, i.e. it will be output in the same hierarchy as the parent of the HTML component. 57 | 58 | **append** will render the JavaScript _inside_ of the HTML component. 59 | 60 | ## MORPHER 61 | 62 | Configures the library to use for diffing and merging the DOM. Defaults to `{}`. 63 | 64 | ### NAME 65 | 66 | The name of the morpher to use. Defaults to `"morphdom"`. Specify `"alpine"` to use the [Alpine.js Morph Plugin](https://alpinejs.dev/plugins/morph). See [Custom Morphers](custom-morphers.md) for more information. 67 | 68 | ### RELOAD_SCRIPT_ELEMENTS 69 | 70 | Whether script elements should be reloaded when a component is re-rendered. Defaults to `False`. Only available with the `"morphdom"` morpher. 71 | -------------------------------------------------------------------------------- /example/unicorn/templates/unicorn/js.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 |
4 | 5 | 6 | 7 | 8 | 23 | 24 |

Visibility

25 |
26 | 27 | Number of times this span was scrolled into view: {{ scroll_counter }} 28 | 29 |
30 | 31 |

Call JavaScript

32 | 33 | 34 | 35 |
36 |

37 | javascript_exclude states 38 |

39 | {% for state in states %} 40 | {{ state }} 41 | {% endfor %} 42 |
43 | 44 |

Select2

45 | 46 |
47 | 53 | 54 | States (in ignored div): {{ states }} 55 |
56 | 57 | selected_state: {{ selected_state }} 58 | 59 | 64 | 65 |
66 | States (not in ignored div): {{ states }}
67 | 68 |
69 | 70 |
71 | 72 | 73 |
74 | select2_datetime: {{ select2_datetime }} 75 |
76 |
77 | 78 |
79 | 80 |

load_js: {{ load_js }} 81 |

82 | {% if load_js %} 83 | 84 | 85 | {% endif %} 86 |
87 |
-------------------------------------------------------------------------------- /tests/components/test_is_html_well_formed.py: -------------------------------------------------------------------------------- 1 | from django_unicorn.components.unicorn_template_response import is_html_well_formed 2 | 3 | 4 | def test_is_html_well_formed(): 5 | html = """ 6 |
7 | something 8 |
9 | """ 10 | actual = is_html_well_formed(html) 11 | 12 | assert actual is True 13 | 14 | 15 | def test_is_html_well_formed_comment(): 16 | html = """ 17 | 18 | 19 | 20 |
21 | something 22 |
23 | """ 24 | actual = is_html_well_formed(html) 25 | 26 | assert actual is True 27 | 28 | 29 | def test_is_html_well_formed_p(): 30 | html = """ 31 | 32 |

33 | something 34 |
35 |

36 | """ 37 | actual = is_html_well_formed(html) 38 | 39 | assert actual is True 40 | 41 | 42 | def test_is_html_well_formed_missing_internal(): 43 | html = """ 44 |

45 |

46 | something 47 |
48 |

49 | """ 50 | actual = is_html_well_formed(html) 51 | 52 | assert actual is False 53 | 54 | 55 | def test_is_html_well_formed_multiple(): 56 | html = """ 57 |
58 |
something
59 |
60 | """ 61 | actual = is_html_well_formed(html) 62 | 63 | assert actual is True 64 | 65 | 66 | def test_is_html_well_formed_missing(): 67 | html = """ 68 |
69 | something 70 | 71 | """ 72 | actual = is_html_well_formed(html) 73 | 74 | assert actual is False 75 | 76 | 77 | def test_is_html_well_formed_invalid(): 78 | html = """ 79 |
80 | something 81 | 82 | 83 | """ 84 | actual = is_html_well_formed(html) 85 | 86 | assert actual is False 87 | 88 | 89 | def test_is_html_well_formed_no_slash(): 90 | html = """ 91 |
92 | something 93 |
94 | 95 | """ 96 | actual = is_html_well_formed(html) 97 | 98 | assert actual is False 99 | 100 | 101 | def test_is_well_formed_more(): 102 | html = """ 103 |
104 | 105 | 106 | 107 |
108 |
109 | 110 | 111 | 112 | 113 | Hello, {{ name|default:'World' }}! 114 | 115 |
116 | Request path context variable: '{{ request.path }}' 117 |
118 |
119 | """ 120 | 121 | actual = is_html_well_formed(html) 122 | 123 | assert actual is True 124 | -------------------------------------------------------------------------------- /django_unicorn/static/unicorn/js/morphers/morphdom.js: -------------------------------------------------------------------------------- 1 | import morphdom from "../morphdom/2.6.1/morphdom.js"; 2 | 3 | export class MorphdomMorpher { 4 | constructor(options) { 5 | this.options = options; 6 | } 7 | 8 | morph(dom, htmlElement) { 9 | return morphdom(dom, htmlElement, this.getOptions()); 10 | } 11 | 12 | getOptions() { 13 | const reloadScriptElements = this.options.RELOAD_SCRIPT_ELEMENTS || false; 14 | 15 | return { 16 | childrenOnly: false, 17 | // eslint-disable-next-line consistent-return 18 | getNodeKey(node) { 19 | // A node's unique identifier. Used to rearrange elements rather than 20 | // creating and destroying an element that already exists. 21 | if (node.attributes) { 22 | const key = 23 | node.getAttribute("unicorn:id") || 24 | node.getAttribute("unicorn:key") || 25 | node.id; 26 | 27 | if (key) { 28 | return key; 29 | } 30 | } 31 | }, 32 | // eslint-disable-next-line consistent-return 33 | onBeforeElUpdated(fromEl, toEl) { 34 | // Because morphdom also supports vDom nodes, it uses isSameNode to detect 35 | // sameness. When dealing with DOM nodes, we want isEqualNode, otherwise 36 | // isSameNode will ALWAYS return false. 37 | if (fromEl.isEqualNode(toEl)) { 38 | return false; 39 | } 40 | 41 | if (reloadScriptElements) { 42 | if (fromEl.nodeName === "SCRIPT" && toEl.nodeName === "SCRIPT") { 43 | // https://github.com/patrick-steele-idem/morphdom/issues/178#issuecomment-652562769 44 | const script = document.createElement("script"); 45 | // copy over the attributes 46 | [...toEl.attributes].forEach((attr) => { 47 | script.setAttribute(attr.nodeName, attr.nodeValue); 48 | }); 49 | 50 | script.innerHTML = toEl.innerHTML; 51 | fromEl.replaceWith(script); 52 | 53 | return false; 54 | } 55 | } 56 | 57 | return true; 58 | }, 59 | onNodeAdded(node) { 60 | if (reloadScriptElements) { 61 | if (node.nodeName === "SCRIPT") { 62 | // https://github.com/patrick-steele-idem/morphdom/issues/178#issuecomment-652562769 63 | const script = document.createElement("script"); 64 | // copy over the attributes 65 | [...node.attributes].forEach((attr) => { 66 | script.setAttribute(attr.nodeName, attr.nodeValue); 67 | }); 68 | 69 | script.innerHTML = node.innerHTML; 70 | node.replaceWith(script); 71 | } 72 | } 73 | }, 74 | }; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /example/unicorn/templates/unicorn/models.html: -------------------------------------------------------------------------------- 1 |
2 | 8 | 9 |
10 |

Using unicorn:model with Django models

11 | 12 |
13 |
14 | 15 | 16 |
17 | 18 |
19 | 20 | 21 |
22 | 23 | 24 | 25 | {% for flavor in flavors %} 26 |
27 |

{{ flavor.pk }}

28 | 29 | 30 | {{ flavor.name }} 31 |
32 | 33 | 34 | 35 | {{ flavor.label }} 36 |
37 | 38 | 39 | 40 | {{ flavor.float_value }} 41 |
42 | 43 | 44 | 45 | {{ flavor.decimal_value }} 46 |
47 | 48 | 49 | 55 | {{ flavor.parent.name }} 56 |
57 | 58 | 59 | 64 | 65 |
    66 | {% for taste in flavor.taste_set.all %} 67 |
  • {{ taste.name }}
  • 68 | {% endfor %} 69 |
70 |
71 | 72 | 73 | 74 | 75 |
76 | {% endfor %} 77 |
78 |
79 |
80 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Unicorn 2 | 3 | We love your input! We want to make contributing to this project as easy and transparent as possible. 4 | 5 | ## Contribution recogniton 6 | 7 | We use [All Contributors](https://allcontributors.org/docs/en/specification) specification to handle recognitions. For more details read [this](https://github.com/asyncapi/community/blob/master/recognize-contributors.md) document. 8 | 9 | ## Summary of the contribution flow 10 | 11 | The following is a summary of the ideal contribution flow. Please, note that Pull Requests can also be rejected by the maintainers when appropriate. 12 | 13 | ``` 14 | ┌───────────────────────┐ 15 | │ │ 16 | │ Open an issue │ 17 | │ (a bug report or a │ 18 | │ feature request) │ 19 | │ │ 20 | └───────────────────────┘ 21 | ⇩ 22 | ┌───────────────────────┐ 23 | │ │ 24 | │ Open a Pull Request │ 25 | │ (only after issue │ 26 | │ is approved) │ 27 | │ │ 28 | └───────────────────────┘ 29 | ⇩ 30 | ┌───────────────────────┐ 31 | │ │ 32 | │ Your changes will │ 33 | │ be merged and │ 34 | │ published on the next │ 35 | │ release │ 36 | │ │ 37 | └───────────────────────┘ 38 | ``` 39 | 40 | ## Code of Conduct 41 | 42 | Unicorn has adopted a Code of Conduct that we expect project participants to adhere to. Please [read the full text](./CODE_OF_CONDUCT.md) so that you can understand what sort of behavior is expected. 43 | 44 | ## Our Development Process 45 | 46 | We use GitHub to host code, to track issues and feature requests, as well as accept pull requests. 47 | 48 | See more details about developing `django-unicorn` locally in [DEVELOPING.md](developing.md). 49 | 50 | ## Issues 51 | 52 | [Open an issue](https://github.com/adamghill/django-unicorn/issues/new) **only** if you want to report a bug or a feature. Don't open issues for questions or support, instead create a [discussion](https://github.com/adamghill/django-unicorn/discussions/new) and ask there. 53 | 54 | ## Bug Reports and Feature Requests 55 | 56 | Please use our issues templates that provide you with hints on what information we need from you to help you out. 57 | 58 | ## Pull Requests 59 | 60 | Pull Requests are the best way to create a failing test case or sample code to replicate an issue. 61 | 62 | ## License 63 | 64 | When you submit changes, your submissions are understood to be under the same [MIT License](https://github.com/adamghill/django-unicorn/blob/master/LICENSE) that covers the project. 65 | 66 | ## References 67 | 68 | This document was adapted from the open-source contribution guidelines for [Async API](https://github.com/asyncapi/.github/blob/master/CONTRIBUTING.md). -------------------------------------------------------------------------------- /django_unicorn/static/unicorn/js/attribute.js: -------------------------------------------------------------------------------- 1 | import { contains } from "./utils.js"; 2 | 3 | /** 4 | * Encapsulate DOM element attribute for Unicorn-related information. 5 | */ 6 | export class Attribute { 7 | constructor(attribute) { 8 | this.attribute = attribute; 9 | this.name = this.attribute.name; 10 | this.value = this.attribute.value; 11 | this.isUnicorn = false; 12 | this.isModel = false; 13 | this.isPoll = false; 14 | this.isLoading = false; 15 | this.isTarget = false; 16 | this.isPartial = false; 17 | this.isDirty = false; 18 | this.isVisible = false; 19 | this.isKey = false; 20 | this.isError = false; 21 | this.modifiers = {}; 22 | this.eventType = null; 23 | 24 | this.init(); 25 | } 26 | 27 | /** 28 | * Init the attribute. 29 | */ 30 | init() { 31 | if (this.name.startsWith("unicorn:") || this.name.startsWith("u:")) { 32 | this.isUnicorn = true; 33 | 34 | // Use `contains` when there could be modifiers 35 | if (contains(this.name, ":model")) { 36 | this.isModel = true; 37 | } else if (contains(this.name, ":poll.disable")) { 38 | this.isPollDisable = true; 39 | } else if (contains(this.name, ":poll")) { 40 | this.isPoll = true; 41 | } else if (contains(this.name, ":loading")) { 42 | this.isLoading = true; 43 | } else if (contains(this.name, ":target")) { 44 | this.isTarget = true; 45 | } else if (contains(this.name, ":partial")) { 46 | this.isPartial = true; 47 | } else if (contains(this.name, ":dirty")) { 48 | this.isDirty = true; 49 | } else if (contains(this.name, ":visible")) { 50 | this.isVisible = true; 51 | } else if (this.name === "unicorn:key" || this.name === "u:key") { 52 | this.isKey = true; 53 | } else if (contains(this.name, ":error:")) { 54 | this.isError = true; 55 | } else { 56 | const actionEventType = this.name 57 | .replace("unicorn:", "") 58 | .replace("u:", ""); 59 | 60 | if ( 61 | actionEventType !== "id" && 62 | actionEventType !== "name" && 63 | actionEventType !== "checksum" 64 | ) { 65 | this.eventType = actionEventType; 66 | } 67 | } 68 | 69 | let potentialModifiers = this.name; 70 | 71 | if (this.eventType) { 72 | potentialModifiers = this.eventType; 73 | } 74 | 75 | // Find modifiers and any potential arguments 76 | potentialModifiers 77 | .split(".") 78 | .slice(1) 79 | .forEach((modifier) => { 80 | const modifierArgs = modifier.split("-"); 81 | this.modifiers[modifierArgs[0]] = 82 | modifierArgs.length > 1 ? modifierArgs[1] : true; 83 | 84 | // Remove any modifier from the event type 85 | if (this.eventType) { 86 | this.eventType = this.eventType.replace(`.${modifier}`, ""); 87 | } 88 | }); 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /example/unicorn/components/objects.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from decimal import Decimal as D 3 | from enum import Enum 4 | from typing import List, Optional 5 | 6 | from django.utils.timezone import now 7 | 8 | from pydantic import BaseModel 9 | 10 | from django_unicorn.components import UnicornField, UnicornView 11 | from example.books.models import Book 12 | 13 | 14 | class Color(Enum): 15 | RED = 1 16 | GREEN = 2 17 | BLUE = 3 18 | 19 | 20 | class PublishDateField(UnicornField): 21 | def __init__(self, year): 22 | self.year = year 23 | 24 | 25 | class BookField(UnicornField): 26 | def __init__(self): 27 | self.title = "Neverwhere" 28 | self.publish_date_field = PublishDateField(year=1996) 29 | self.publish_date = datetime(1996, 9, 16) 30 | 31 | 32 | class PydanticBook(BaseModel): 33 | title = "American Gods" 34 | publish_date: Optional[datetime] = datetime(1996, 9, 16) 35 | 36 | 37 | class ObjectsView(UnicornView): 38 | unicorn_field = None 39 | pydantic_field = None 40 | dictionary = None 41 | dictionary_2 = None 42 | book = None 43 | books = None 44 | date_example = now() 45 | date_example_with_typehint: datetime = now() 46 | dates_with_no_typehint = None 47 | dates_with_old_typehint: List[datetime] = None 48 | dates_with_new_typehint: list[datetime] = None 49 | dates_with_list_typehint: list = None 50 | float_example: float = 1.1 51 | decimal_example = D("1.1") 52 | int_example = 4 53 | color = Color.RED 54 | 55 | def mount(self): 56 | self.unicorn_field = BookField() 57 | self.pydantic_field = PydanticBook() 58 | self.dictionary = {"name": "dictionary", "nested": {"name": "nested dictionary"}} 59 | self.dictionary2 = {"5": "a", "9": "b"} 60 | self.book = Book(title="The Sandman") 61 | self.books = Book.objects.all() 62 | self.dates_with_no_typehint = [datetime(2021, 1, 1), datetime(2021, 1, 2)] 63 | self.dates_with_old_typehint = [datetime(2022, 2, 1), datetime(2022, 2, 2)] 64 | self.dates_with_new_typehint = [datetime(2023, 3, 1), datetime(2023, 3, 2)] 65 | self.dates_with_list_typehint = [datetime(2024, 4, 1), datetime(2024, 4, 2)] 66 | 67 | def get_date(self): 68 | self.date_example = now() 69 | 70 | def check_date(self, dt: datetime): 71 | assert type(dt) is datetime 72 | 73 | self.date_example = dt 74 | self.date_example_with_typehint = dt 75 | 76 | def add_hour(self): 77 | self.date_example_with_typehint = self.date_example_with_typehint + timedelta(hours=1) 78 | 79 | def set_dictionary(self, val): 80 | self.dictionary = val 81 | 82 | def set_dictionary_2(self): 83 | self.dictionary_2["1"] = "c" 84 | self.dictionary_2["6"] = "d" 85 | self.dictionary_2["11"] = "e" 86 | 87 | def add_one_to_float(self): 88 | self.float_example += 1 89 | 90 | def set_color(self, color: int): 91 | self.color = Color(color) 92 | -------------------------------------------------------------------------------- /tests/test_model_lifecycle.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django_unicorn.components import UnicornView 4 | from django_unicorn.serializer import dumps, loads 5 | from django_unicorn.typer import _construct_model 6 | from django_unicorn.views.utils import set_property_from_data 7 | from example.coffee.models import Flavor 8 | 9 | 10 | class FakeComponent(UnicornView): 11 | flavors = Flavor.objects.none() 12 | 13 | def __init__(self, **kwargs): 14 | self.flavors = Flavor.objects.none() 15 | super().__init__(**kwargs) 16 | 17 | 18 | @pytest.mark.django_db 19 | def test_model(): 20 | flavor = Flavor(name="first-flavor") 21 | flavor.save() 22 | 23 | str_data = dumps({"flavor": flavor}) 24 | data = loads(str_data) 25 | flavor_data = data["flavor"] 26 | 27 | actual = _construct_model(Flavor, flavor_data) 28 | 29 | assert actual.pk == flavor.id 30 | assert actual.name == flavor.name 31 | assert actual.parent is None 32 | 33 | 34 | @pytest.mark.django_db 35 | def test_model_foreign_key(): 36 | parent = Flavor(name="parent-flavor") 37 | parent.save() 38 | flavor = Flavor(name="first-flavor", parent=parent) 39 | flavor.save() 40 | 41 | str_data = dumps({"flavor": flavor}) 42 | data = loads(str_data) 43 | flavor_data = data["flavor"] 44 | 45 | actual = _construct_model(Flavor, flavor_data) 46 | 47 | assert actual.pk == flavor.id 48 | assert actual.name == flavor.name 49 | assert actual.parent.pk == parent.id 50 | assert actual.parent.name == parent.name 51 | 52 | 53 | @pytest.mark.django_db 54 | def test_queryset(): 55 | test_component = FakeComponent(component_name="test", component_id="model_lifecycle_test_queryset") 56 | assert test_component.flavors.count() == 0 57 | 58 | flavor = Flavor(name="qs-first-flavor") 59 | flavor.save() 60 | 61 | flavors = Flavor.objects.filter(name="qs-first-flavor") 62 | str_data = dumps({"flavors": flavors}) 63 | data = loads(str_data) 64 | flavors_data = data["flavors"] 65 | 66 | set_property_from_data(test_component, "flavors", flavors_data) 67 | 68 | assert test_component.flavors.count() == 1 69 | assert test_component.flavors[0].uuid == str(flavor.uuid) 70 | assert test_component.flavors[0].id == flavor.id 71 | 72 | 73 | @pytest.mark.django_db 74 | def test_queryset_values(): 75 | test_component = FakeComponent(component_name="test", component_id="model_lifecycle_test_queryset_values") 76 | assert test_component.flavors.count() == 0 77 | 78 | flavor = Flavor(name="values-first-flavor") 79 | flavor.save() 80 | 81 | flavors = Flavor.objects.filter(name="values-first-flavor").values("uuid") 82 | str_data = dumps({"flavors": flavors}) 83 | data = loads(str_data) 84 | flavors_data = data["flavors"] 85 | 86 | set_property_from_data(test_component, "flavors", flavors_data) 87 | 88 | assert test_component.flavors.count() == 1 89 | assert test_component.flavors[0].uuid == str(flavor.uuid) 90 | assert test_component.flavors[0].id is None 91 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.conf import settings 3 | 4 | 5 | def pytest_configure(): 6 | templates = [ 7 | { 8 | "BACKEND": "django.template.backends.django.DjangoTemplates", 9 | "DIRS": ["tests"], 10 | "OPTIONS": { 11 | "context_processors": [ 12 | "django.template.context_processors.request", 13 | "django.contrib.messages.context_processors.messages", 14 | ], 15 | }, 16 | } 17 | ] 18 | 19 | databases = { 20 | "default": { 21 | "ENGINE": "django.db.backends.sqlite3", 22 | } 23 | } 24 | 25 | installed_apps = [ 26 | "django.contrib.sessions", 27 | "django.contrib.messages", 28 | "django.contrib.contenttypes", 29 | "django.contrib.auth", 30 | "django_unicorn", 31 | "example.coffee.apps.Config", 32 | "example.books.apps.Config", 33 | ] 34 | 35 | unicorn_settings = { 36 | "SERIAL": {"ENABLED": True, "TIMEOUT": 5}, 37 | "CACHE_ALIAS": "default", 38 | "APPS": ("unicorn",), 39 | "MINIFY_HTML": False, 40 | "SCRIPT_LOCATION": "after", 41 | } 42 | 43 | caches = { 44 | "default": { 45 | "BACKEND": "django.core.cache.backends.locmem.LocMemCache", 46 | "LOCATION": "unique-snowflake", 47 | } 48 | } 49 | 50 | settings.configure( 51 | DEBUG=True, 52 | SECRET_KEY="this-is-a-secret", 53 | TEMPLATES=templates, 54 | MIDDLEWARE=[ 55 | "django.contrib.sessions.middleware.SessionMiddleware", 56 | "django.contrib.messages.middleware.MessageMiddleware", 57 | ], 58 | ROOT_URLCONF="tests.urls", 59 | DATABASES=databases, 60 | INSTALLED_APPS=installed_apps, 61 | UNIT_TEST=True, 62 | UNICORN=unicorn_settings, 63 | CACHES=caches, 64 | MESSAGE_STORAGE="django.contrib.messages.storage.session.SessionStorage", 65 | SESSION_ENGINE="django.contrib.sessions.backends.file", 66 | ) 67 | 68 | 69 | @pytest.fixture(autouse=True) 70 | def reset_settings(settings): 71 | """ 72 | This takes the original `UNICORN` settings before the test is run, runs the test, and then resets them afterwards. 73 | This is required because mutating nested dictionaries does not reset them as expected by `pytest-django`. 74 | More details in https://github.com/pytest-dev/pytest-django/issues/601#issuecomment-440676001. 75 | """ 76 | 77 | # Get original settings 78 | cache_settings = {**settings.CACHES} 79 | unicorn_settings = {**settings.UNICORN} 80 | django_unicorn_settings = {} 81 | 82 | if hasattr(settings, "DJANGO_UNICORN"): 83 | django_unicorn_settings = {**settings.DJANGO_UNICORN} 84 | 85 | # Run test 86 | yield 87 | 88 | # Re-set original settings 89 | settings.CACHES = cache_settings 90 | settings.UNICORN = unicorn_settings 91 | 92 | if django_unicorn_settings: 93 | settings.DJANGO_UNICORN = django_unicorn_settings 94 | --------------------------------------------------------------------------------