diff --git a/envs/conda/dev.yaml b/envs/conda/dev.yaml index 35f273aac..697a3f6b2 100644 --- a/envs/conda/dev.yaml +++ b/envs/conda/dev.yaml @@ -30,7 +30,15 @@ dependencies: - django-crispy-forms >=1.13.0, <=1.14.0 - django-pandas >=0.6.6, <=0.6.6 - django-filter >=21.1, <=22.1 - - django_unicorn >=0.59.0, <=0.59.0 + # + # A fork of django-unicorn was made, which we now use + its deps + # - django_unicorn >=0.59.0, <=0.59.0 + - beautifulsoup4 >=4.8.0 + - orjson >=3.6.0 + - shortuuid >=1.0.1 + - cachetools >=4.1.1 + - decorator >=4.4.2 + # - django-simple-history >=3.3.0, <=3.3.0 - djangorestframework >=3.13.1, <3.15.2 - ipython >=8.22.2, <=8.29.0 diff --git a/pyproject.toml b/pyproject.toml index e8fe75e80..8f705f915 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,7 +72,6 @@ dependencies=[ "django-contrib-comments >=2.2.0, <=2.2.0", # for tracking user comments in web "django-crispy-forms >=1.13.0, <=1.14.0", # for formatting of online forms "django-pandas >=0.6.6, <=0.6.6", # for converting QuerySets to PandasDataFrames - "django-unicorn >=0.59.0, <=0.59.0", # for responsive web UI (AJAX calls) "dj-database-url >=0.5.0, <1.4.0", # for DigitalOcean URL conversion "django-simple-history >=3.3.0, <=3.3.0", # for tracking changes to data "djangorestframework >=3.13.1, <3.15.2", # for our REST API @@ -99,6 +98,14 @@ dependencies=[ # "rdkit", # cheminformatics library # BUG: install is broken on pypi # "ipython", # for rendering molecules in output # "umap-learn", # for chemspace mapping + # + # A fork of django-unicorn was made, which we now use + its deps + # "django-unicorn >=0.59.0, <=0.59.0", # for responsive web UI (AJAX calls) + "beautifulsoup4 >=4.8.0", + "orjson >=3.6.0", + "shortuuid >=1.0.1", + "cachetools >=4.1.1", + "decorator >=4.4.2", ] # optional dependencies that are not installed by default diff --git a/src/simmate/configuration/django/settings.py b/src/simmate/configuration/django/settings.py index b33f35791..388e377e8 100644 --- a/src/simmate/configuration/django/settings.py +++ b/src/simmate/configuration/django/settings.py @@ -60,7 +60,8 @@ # # Django unicorn acts as a frontend framework for making dyanmic webpages # (i.e. AJAX calls can be made to update the views) - "django_unicorn", + # "django_unicorn", + "simmate.website.configs.UnicornConfig", # fork of django_unicorn # # Django simple history lets you track history of changes (and who made # those changes) for a given model. This is important for models that users diff --git a/src/simmate/website/configs.py b/src/simmate/website/configs.py index 7d78395d9..bd3c7c4c6 100644 --- a/src/simmate/website/configs.py +++ b/src/simmate/website/configs.py @@ -11,6 +11,10 @@ class DataExplorerConfig(AppConfig): name = "simmate.website.data_explorer" +class UnicornConfig(AppConfig): + name = "simmate.website.unicorn" + + class UserTrackingConfig(AppConfig): name = "simmate.website.user_tracking" diff --git a/src/simmate/website/core/urls.py b/src/simmate/website/core/urls.py index b94abfb8a..3225a1e5b 100644 --- a/src/simmate/website/core/urls.py +++ b/src/simmate/website/core/urls.py @@ -108,7 +108,17 @@ def get_disabled_urls(): path(route="about/", view=views.about, name="about"), # # Django-unicorn urls - path("unicorn/", include("django_unicorn.urls")), + path( + route="unicorn/", + view=include( + ( + "simmate.website.unicorn.urls", + "simmate.website.unicorn", + ), + namespace="unicorn", + ), + name="unicorn", + ), # # Django-contrib-comments urls path("comments/", include("django_comments.urls")), diff --git a/src/simmate/website/core_components/components/base.py b/src/simmate/website/core_components/components/base.py index f3d89c0ad..59547ebcb 100644 --- a/src/simmate/website/core_components/components/base.py +++ b/src/simmate/website/core_components/components/base.py @@ -4,10 +4,10 @@ import urllib from django.shortcuts import redirect -from django_unicorn.components import UnicornView from simmate.database.base_data_types import DatabaseTable from simmate.toolkit import Molecule, Structure +from simmate.website.unicorn.components import UnicornView from simmate.website.utilities import parse_request_get @@ -442,7 +442,7 @@ def set_property( # attempt casting to correct type new_value = parse_value(new_value) # buggy - # from django_unicorn.typer import cast_attribute_value + # from simmate.website.unicorn.typer import cast_attribute_value # new_value = cast_attribute_value(self, property_name, new_value) # check if there is a special defined method for this property diff --git a/src/simmate/website/data_explorer/components/api_filter.py b/src/simmate/website/data_explorer/components/api_filter.py index fff688922..4ab47e14b 100644 --- a/src/simmate/website/data_explorer/components/api_filter.py +++ b/src/simmate/website/data_explorer/components/api_filter.py @@ -1,9 +1,9 @@ from django.shortcuts import redirect -from django_unicorn.components import UnicornView # from simmate.configuration import settings from simmate.database.base_data_types import DatabaseTable, FilteredScope, table_column from simmate.website.data_explorer.views import EXPLORABLE_TABLES +from simmate.website.unicorn.components import UnicornView from simmate.website.utilities import parse_request_get # TODO: move to util and combine with var used in views.py diff --git a/src/simmate/website/unicorn/__init__.py b/src/simmate/website/unicorn/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/simmate/website/unicorn/actions/__init__.py b/src/simmate/website/unicorn/actions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/simmate/website/unicorn/actions/backend/__init__.py b/src/simmate/website/unicorn/actions/backend/__init__.py new file mode 100644 index 000000000..bbc50c105 --- /dev/null +++ b/src/simmate/website/unicorn/actions/backend/__init__.py @@ -0,0 +1,9 @@ +# order of imports is important to prevent circular deps +from .base import BackendAction +from .call_method import CallMethod +from .refresh import Refresh +from .reset import Reset +from .set_attribute import SetAttribute +from .sync_input import SyncInput +from .toggle import Toggle +from .validate import Validate diff --git a/src/simmate/website/unicorn/actions/backend/base.py b/src/simmate/website/unicorn/actions/backend/base.py new file mode 100644 index 000000000..3a2892107 --- /dev/null +++ b/src/simmate/website/unicorn/actions/backend/base.py @@ -0,0 +1,120 @@ +from abc import ABC, abstractmethod + +from simmate.website.unicorn.actions.frontend import FrontendAction +from simmate.website.unicorn.components import Component + + +class BackendAction(ABC): + """ + Abstract base class for Unicorn Actions that get queued & applied to components + in the backend (via python). This base class also has helper methods for + dynamically loading various Action types. + + This class and its methods are typically handled by the FrontendAction class + """ + + # --- Abstract methods / attrs that must be set in the subclass --- + + action_type: str = None + """ + A name for this action that is used in JSON from the frontend + """ + + @abstractmethod + def apply( + self, + component: Component, + request, # : ComponentRequest, + ) -> tuple[Component, FrontendAction]: + """ + Applies the update to the component and returns the ActionResult if + there is one. Must be defined in all subclasses. + """ + raise NotImplementedError() + + # --- Built-in methods --- + + def __init__(self, payload: dict, partials: list): + self.payload = payload if payload is not None else {} + self.partials = partials if partials is not None else [] + + def __repr__(self): + return ( + f"BackEnd Action: {self.__class__.__name__}" + f"(action_type='{self.action_type}' payload={self.payload} " + f"partials={self.partials})" + ) + + def to_dict(self) -> dict: + """ + Converts the Action back to a dictionary match what the frontend gives + """ + return { + "partials": self.partials, + "payload": self.payload, + "type": self.action_type, + } + + @classmethod + def from_dict(cls, data: dict): + expected_type = data.get("type") + if expected_type != cls.action_type: + raise ValueError( + f"Action type mismatch. Type '{expected_type}' " + f"was provided, but class only accepts '{cls.action_type}'" + ) + return cls( + payload=data.get("payload", {}), + partials=data.get("partials", []), + ) + + # --- Utility methods that help interact with *all* Action subclasses --- + + @classmethod + def from_many_dicts(cls, data: list[dict]): + """ + Given a list of config dictionaries, this will create and return + a list Action objects in the proper Action subclass. + + This input is typically grabbed directly from `request.body.actionQueue` + """ + + mappings = cls.get_action_type_mappings() + + actions = [] + for config in data: + action_type = config["type"] + + if action_type not in mappings.keys(): + raise ValueError(f"Unknown Action type: '{action_type}'") + + action_class = mappings[action_type] + action = action_class.from_dict(config) + actions.append(action) + + return actions + + def get_action_type_mappings() -> dict: + """ + Gives a mapping of action_type to the Action subclass that should be + used. For example: {"callMethod": simmate.website.unicorn.actions.backend.CallMethod} + """ + # TODO: We assume only internal Actions for now, but we may want to + # support customer user Actions. + + # local import to prevent circular deps + from simmate.website.unicorn.actions.backend import CallMethod, SyncInput + + return { + action.action_type: action + for action in [ + CallMethod, + SyncInput, + # !!! These are special cases. + # See CallMethod.from_dict for details. + # Refresh, + # Reset, + # Toggle, + # Validate, + ] + } diff --git a/src/simmate/website/unicorn/actions/backend/call_method.py b/src/simmate/website/unicorn/actions/backend/call_method.py new file mode 100644 index 000000000..481b1d67f --- /dev/null +++ b/src/simmate/website/unicorn/actions/backend/call_method.py @@ -0,0 +1,297 @@ +from typing import Union + +from django.core.exceptions import NON_FIELD_ERRORS, ValidationError +from django.db.models import Model +from django.http.response import HttpResponseRedirect + +from simmate.website.unicorn.actions.frontend import FrontendAction +from simmate.website.unicorn.call_method_parser import parse_call_method_name +from simmate.website.unicorn.components import Component +from simmate.website.unicorn.typer import cast_value, get_type_hints +from simmate.website.unicorn.utils import get_method_arguments + +from .base import BackendAction + +try: + from typing import get_origin +except ImportError: + + def get_origin(type_hint): + if hasattr(type_hint, "__origin__"): + return type_hint.__origin__ + + +MIN_VALIDATION_ERROR_ARGS = 2 + + +class CallMethod(BackendAction): + + action_type = "callMethod" + + @classmethod + def from_dict(cls, data: dict): + + # Ideally, we could utilize the `Action.get_action_type_mappings` to + # determine all Action types. However, callMethod can lead to various + # subclasses like Refresh/Reset/Toggle. + # It'd be nice if these subclasses return a different action_type from + # the frontend but I'm not sure if that's easily achieved. + # If that's ever added, then this from_dict method can be removed. + # For now we need to create a CallMethod class and inspect it to + # decide whether to "punt" it to another Action type. + + # local import to prevent circular deps + from simmate.website.unicorn.actions.backend import ( + Refresh, + Reset, + SetAttribute, + Toggle, + Validate, + ) + + # This is the same as 'method_str' property above - but we don't have + # an obj yet. + method_str = data["payload"]["name"] + + # Note: all cases return a different Action subclass + # if "=" in method_name: --> kwargs give with method + # return SetAttr.from_dict(data) + if method_str == "$reset": + return Reset.from_dict(data) + elif method_str == "$refresh": + return Refresh.from_dict(data) + elif method_str == "$validate": + return Validate.from_dict(data) + elif method_str.startswith("$toggle"): + return Toggle.from_dict(data) + elif "=" in method_str and "(" not in method_str: + # e.g. 'some_attribute=123' + # but NOT something like 'some_method(key=123)' + return SetAttribute.from_dict(data) + else: + # then we indeed have a CallMethod action and can use the normal + # from_dict method to init + return super().from_dict(data) + + def apply( + self, + component: Component, + request, # : ComponentRequest, + ) -> tuple[Component, FrontendAction]: + + # Get all information needed for us to apply the method + component_with_method = self._get_component_with_method(component) + method_name = self.method_name + method_args = self.method_args + method_kwargs = self.method_kwargs + + # Now apply the method. + # We do this inside a try/except in case the method works but validation + # fails for the component afterwards. + try: + # call pre-method hook + component_with_method.calling(method_name, method_args) + + # call the method + method_return_value = self._call_method_name( + component_with_method, + method_name, + method_args, + method_kwargs, + ) + + # call post-method hook + component_with_method.called(method_name, method_args) + + # ------------- TODO: improve this section's refactor ------------- + # This should not be handled within callMethod but instead at a + # higher level. Or maybe `set_metadata` only needs to be called + # with CallMethod actions...? + + # if its not already a subclass object, then we wrap it in a MethodResult, + # which also subclasses FrontendAction + if not isinstance(method_return_value, FrontendAction): + + # local import to prevent circular deps + from simmate.website.unicorn.actions.frontend import ( + MethodResult, + Redirect, + ) + + # special case: redirect objects need are converted to Redirect + if isinstance(method_return_value, HttpResponseRedirect): + method_return_value = Redirect.from_django(method_return_value) + else: + method_return_value = MethodResult(value=method_return_value) + + # Unicorn frontend needs to know where this FrontendAction came from + method_return_value.set_metadata( + method_name, + method_args, + method_kwargs, + ) + # ----------------------------------------------------------------- + + return component, method_return_value + + except ValidationError as e: + self._apply_validation_error(component, e) + + # no FrontendAction needed + return component, None + + @property + def method_str(self): + return self.payload["name"] + + # OPTIMIZE: consider caching because it's used repeatedly + # Alternatively, just build this during init + @property + def method_config(self): + + # The "replace" handles the special case where + # "$parent.some_method" is given in the method_str, which we ignore for + # now (it is handled in _get_component_with_method) + method_str = self.method_str.replace("$parent.", "") + + # returns a tuple of (method_name, args, kwargs) + # !!! This is the only place this util is used... Consider refactor + # and pulling it in here. + return parse_call_method_name(method_str) + + def _get_component_with_method(self, component): + + if "$parent" in self.method_str: + parent_component = component.parent + if not parent_component: + raise Exception( + "$parent was requested but Component does not have a parent set" + ) + parent_component.force_render = True + return parent_component + + else: + return component + + @property + def method_name(self): + return self.method_config[0] + + @property + def method_args(self): + return self.method_config[1] + + @property + def method_kwargs(self): + return self.method_config[2] + + @staticmethod + def _apply_validation_error(self, component, e): + if len(enumerate().args) < MIN_VALIDATION_ERROR_ARGS or not e.args[1]: + raise AssertionError("Error code must be specified") from e + + if hasattr(e, "error_list"): + error_code = e.args[1] + + for error in e.error_list: + if NON_FIELD_ERRORS in component.errors: + component.errors[NON_FIELD_ERRORS].append( + {"code": error_code, "message": error.message} + ) + else: + component.errors[NON_FIELD_ERRORS] = [ + {"code": error_code, "message": error.message} + ] + elif hasattr(e, "message_dict"): + for field, message in e.message_dict.items(): + if not e.args[1]: + raise AssertionError("Error code must be specified") from e + + error_code = e.args[1] + + if field in component.errors: + component.errors[field].append( + {"code": error_code, "message": message} + ) + else: + component.errors[field] = [{"code": error_code, "message": message}] + + # TODO: refactor and consider moving to a method of Component + @staticmethod + def _call_method_name( + component: Component, + method_name: str, + args: tuple[any], + kwargs: dict[str, any], + ) -> any: + """ + Calls the method name with parameters. + + Args: + param component: Component to call method on. + param method_name: Method name to call. + param args: Tuple of arguments for the method. + param kwargs: Dictionary of kwargs for the method. + """ + + if method_name is not None and hasattr(component, method_name): + func = getattr(component, method_name) + + parsed_args = [] + parsed_kwargs = {} + arguments = get_method_arguments(func) + type_hints = get_type_hints(func) + + for argument in arguments: + if argument in type_hints: + type_hint = type_hints[argument] + + # Check that the type hint is a regular class or Union + # (which will also include Optional) + # TODO: Use types.UnionType to handle `|` for newer unions + if ( + not isinstance(type_hint, type) + and get_origin(type_hint) is not Union + ): + continue + + is_model = False + + try: + is_model = issubclass(type_hint, Model) + except TypeError: + pass + + if is_model: + DbModel = type_hint + key = "pk" + value = None + + if not kwargs: + value = args[len(parsed_args)] + parsed_args.append(DbModel.objects.get(**{key: value})) + else: + value = kwargs.get("pk") + parsed_kwargs[argument] = DbModel.objects.get( + **{key: value} + ) + + elif argument in kwargs: + parsed_kwargs[argument] = cast_value( + type_hint, kwargs[argument] + ) + elif len(args) > len(parsed_args): + parsed_args.append( + cast_value(type_hint, args[len(parsed_args)]) + ) + elif argument in kwargs: + parsed_kwargs[argument] = kwargs[argument] + else: + parsed_args.append(args[len(parsed_args)]) + + if parsed_args: + return func(*parsed_args, **parsed_kwargs) + elif parsed_kwargs: + return func(**parsed_kwargs) + else: + return func() diff --git a/src/simmate/website/unicorn/actions/backend/refresh.py b/src/simmate/website/unicorn/actions/backend/refresh.py new file mode 100644 index 000000000..c6304ccfb --- /dev/null +++ b/src/simmate/website/unicorn/actions/backend/refresh.py @@ -0,0 +1,35 @@ +from simmate.website.unicorn.actions.frontend import FrontendAction +from simmate.website.unicorn.components import Component +from simmate.website.unicorn.views.utils import set_property_from_data + +from .base import BackendAction + + +class Refresh(BackendAction): + + action_type = "callMethod" + method_name = "$refresh" + + def apply( + self, + component: Component, + request, # : ComponentRequest, + ) -> tuple[Component, FrontendAction]: + + # grab a clean object - can be from cache + updated_component = Component.get_or_create( + # we keep the original component's id and name + component_id=component.component_id, + component_name=component.component_name, + request=request.request, + use_cache=True, + ) + + # Set component properties based on request data + for property_name, property_value in request.data.items(): + set_property_from_data(updated_component, property_name, property_value) + + updated_component.hydrate() + + # no FrontendAction needed + return updated_component, None diff --git a/src/simmate/website/unicorn/actions/backend/reset.py b/src/simmate/website/unicorn/actions/backend/reset.py new file mode 100644 index 000000000..200a8791d --- /dev/null +++ b/src/simmate/website/unicorn/actions/backend/reset.py @@ -0,0 +1,30 @@ +from simmate.website.unicorn.actions.frontend import FrontendAction +from simmate.website.unicorn.components import Component + +from .base import BackendAction + + +class Reset(BackendAction): + + action_type = "callMethod" + method_name = "$reset" + + def apply( + self, + component: Component, + request, # : ComponentRequest, + ) -> tuple[Component, FrontendAction]: + + # create a clean object -- ignore cache + updated_component = Component.create( + # we keep the original component's id and name + component_id=component.component_id, + component_name=component.component_name, + request=request.request, + ) + + # Explicitly remove all errors and prevent validation from firing before render() + updated_component.errors = {} + + # no FrontendAction needed + return updated_component, None diff --git a/src/simmate/website/unicorn/actions/backend/set_attribute.py b/src/simmate/website/unicorn/actions/backend/set_attribute.py new file mode 100644 index 000000000..f682364d7 --- /dev/null +++ b/src/simmate/website/unicorn/actions/backend/set_attribute.py @@ -0,0 +1,55 @@ +from simmate.website.unicorn.actions.frontend import FrontendAction +from simmate.website.unicorn.components import Component + +from .base import BackendAction +from .utils import set_property_value + + +class SetAttribute(BackendAction): + + action_type = "callMethod" + method_name = "set_property" # TODO: add as method to Component class + + def apply( + self, + component: Component, + request, # : ComponentRequest, + ) -> tuple[Component, FrontendAction]: + + set_property_value( + component, + self.attribute_to_set, + self.new_attribute_value, + ) + + # TODO: build FrontendAction for this + # return_data = Return(property_name, [property_value]) + + return component, None + + @property + def attribute_to_set(self): + return self.attribute_config[0] + + @property + def new_attribute_value(self): + return self.attribute_config[1] + + # OPTIMIZE: consider caching because it's used repeatedly + # Alternatively, just build this during init + @property + def attribute_config(self): + # TODO: add support for '$parent.example=123' + + # it's under "name" because this normally is callMethod -> name of method + input_str = self.payload["name"] + + if input_str.count("=") != 1: + raise ValueError( + "Improper SetAttribute config. Expected something like " + f"'attribute=123' but recieved '{input_str}'" + ) + + attribute_name, new_value = self.payload["name"].split("=") + + return attribute_name, new_value diff --git a/src/simmate/website/unicorn/actions/backend/sync_input.py b/src/simmate/website/unicorn/actions/backend/sync_input.py new file mode 100644 index 000000000..28e7e9ae1 --- /dev/null +++ b/src/simmate/website/unicorn/actions/backend/sync_input.py @@ -0,0 +1,27 @@ +from simmate.website.unicorn.actions.backend.utils import set_property_value +from simmate.website.unicorn.actions.frontend import FrontendAction +from simmate.website.unicorn.components import Component + +from .base import BackendAction + + +class SyncInput(BackendAction): + + action_type = "syncInput" + + def apply( + self, + component: Component, + request, # : ComponentRequest, + ) -> tuple[Component, FrontendAction]: + + property_name = self.payload["name"] + property_value = self.payload["value"] + set_property_value( + component, + property_name, + property_value, + ) + + # no FrontendAction needed + return component, None diff --git a/src/simmate/website/unicorn/actions/backend/toggle.py b/src/simmate/website/unicorn/actions/backend/toggle.py new file mode 100644 index 000000000..0890f193c --- /dev/null +++ b/src/simmate/website/unicorn/actions/backend/toggle.py @@ -0,0 +1,39 @@ +from simmate.website.unicorn.actions.frontend import FrontendAction +from simmate.website.unicorn.call_method_parser import parse_call_method_name +from simmate.website.unicorn.components import Component + +from .base import BackendAction +from .utils import set_property_value + + +class Toggle(BackendAction): + + action_type = "callMethod" + method_name = "$toggle" + + def apply( + self, + component: Component, + request, # : ComponentRequest, + ) -> tuple[Component, FrontendAction]: + for property_name in self.properties_to_toggle: + property_value = component._get_property(property_name) + + if not isinstance(property_value, bool): + raise ValueError("You can only call '$toggle' on boolean attributes") + + # toggle/flip the boolean + property_value = not property_value + + # !!! consider making this util a method of Component + set_property_value(component, property_name, property_value) + + # no FrontendAction needed + return component, None + + @property + def properties_to_toggle(self): + # !!! Code is pretty much copy/pasted from CallMethod's method_config + # so I need to continue the refactor here. + method_str = self.payload["name"].replace("$parent.", "") + return parse_call_method_name(method_str)[1] # 2nd element in tuple is "args" diff --git a/src/simmate/website/unicorn/actions/backend/utils.py b/src/simmate/website/unicorn/actions/backend/utils.py new file mode 100644 index 000000000..49fa9b33f --- /dev/null +++ b/src/simmate/website/unicorn/actions/backend/utils.py @@ -0,0 +1,143 @@ +from typing import Any, Dict, Optional + +from django.db.models import QuerySet + +from simmate.website.unicorn.components import UnicornView +from simmate.website.unicorn.decorators import timed + +# BUG: request data should never be updated. Making it immutible +# lets us look backwards and see the original request data and run checks along +# the way. So I remove passing the 'data' kwarg in calls to this util +# (such as in SyncInput). I don't see any other areas this is used, but to be +# safe, I leave the data kwarg here for now - @jacksund + +# TODO: convert set_property_value util to a Component method + + +@timed +def set_property_value( + component: UnicornView, + property_name: str, + property_value: Any, + data: Optional[Dict] = None, +) -> None: + """ + Sets properties on the component. + Also updates the data dictionary which gets set back as part of the payload. + + Args: + param component: Component to set attributes on. + param property_name: Name of the property. + param property_value: Value to set on the property. + param data: Dictionary that gets sent back with the response. Defaults to {}. + """ + + if property_name is None: + raise AssertionError("Property name is required") + if property_value is None: + raise AssertionError("Property value is required") + + if not data: + data = {} + + component.updating(property_name, property_value) + + """ + Handles nested properties. For example, for the following component: + + class Author(UnicornField): + name = "Neil" + + class TestView(UnicornView): + author = Author() + + `payload` would be `{'name': 'author.name', 'value': 'Neil Gaiman'}` + + The following code updates UnicornView.author.name based the payload's `author.name`. + """ + property_name_parts = property_name.split(".") + component_or_field = component + data_or_dict = data # Could be an internal portion of data that gets set + + for idx, property_name_part in enumerate(property_name_parts): + if hasattr(component_or_field, property_name_part): + if idx == len(property_name_parts) - 1: + if hasattr(component_or_field, "_set_property"): + # Can assume that `component_or_field` is a component + component_or_field._set_property( + property_name_part, + property_value, + call_updating_method=False, + call_updated_method=True, + ) + else: + # Handle calling the updating/updated method for nested properties + property_name_snake_case = property_name.replace(".", "_") + updating_function_name = f"updating_{property_name_snake_case}" + updated_function_name = f"updated_{property_name_snake_case}" + + if hasattr(component, updating_function_name): + getattr(component, updating_function_name)(property_value) + + is_relation_field = False + + # Set the id property for ForeignKeys + # TODO: Move some of this to utility function + if hasattr(component_or_field, "_meta"): + for field in component_or_field._meta.get_fields(): + if field.is_relation and field.many_to_many: + related_name = field.name + + if field.auto_created: + related_name = ( + field.related_name or f"{field.name}_set" + ) + + if related_name == property_name_part: + related_descriptor = getattr( + component_or_field, related_name + ) + related_descriptor.set(property_value) + is_relation_field = True + break + elif field.name == property_name_part: + if field.is_relation: + setattr( + component_or_field, + field.attname, + property_value, + ) + is_relation_field = True + break + + if not is_relation_field: + setattr(component_or_field, property_name_part, property_value) + + if hasattr(component, updated_function_name): + getattr(component, updated_function_name)(property_value) + + data_or_dict[property_name_part] = property_value + else: + component_or_field = getattr(component_or_field, property_name_part) + data_or_dict = data_or_dict.get(property_name_part, {}) + elif isinstance(component_or_field, dict): + if idx == len(property_name_parts) - 1: + component_or_field[property_name_part] = property_value + data_or_dict[property_name_part] = property_value + else: + component_or_field = component_or_field[property_name_part] + data_or_dict = data_or_dict.get(property_name_part, {}) + elif isinstance(component_or_field, (QuerySet, list)): + # TODO: Check for iterable instead of list? `from collections.abc import Iterable` + property_name_part_int = int(property_name_part) + + if idx == len(property_name_parts) - 1: + component_or_field[property_name_part_int] = property_value + data_or_dict[property_name_part_int] = property_value + else: + component_or_field = component_or_field[property_name_part_int] + data_or_dict = data_or_dict[property_name_part_int] + else: + break + + component.updated(property_name, property_value) diff --git a/src/simmate/website/unicorn/actions/backend/validate.py b/src/simmate/website/unicorn/actions/backend/validate.py new file mode 100644 index 000000000..6acd7e63f --- /dev/null +++ b/src/simmate/website/unicorn/actions/backend/validate.py @@ -0,0 +1,19 @@ +from simmate.website.unicorn.actions.frontend import FrontendAction +from simmate.website.unicorn.components import Component + +from .base import BackendAction + + +class Validate(BackendAction): + + action_type = "callMethod" + method_name = "$validate" + + def apply( + self, + component: Component, + request, # : ComponentRequest, + ) -> tuple[Component, FrontendAction]: + # !!! This duplicates work done in ComponentResponse.from_context + component.validate() + return component, None diff --git a/src/simmate/website/unicorn/actions/frontend/__init__.py b/src/simmate/website/unicorn/actions/frontend/__init__.py new file mode 100644 index 000000000..063bac9eb --- /dev/null +++ b/src/simmate/website/unicorn/actions/frontend/__init__.py @@ -0,0 +1,7 @@ +# order of imports is important to prevent circular deps +from .base import FrontendAction +from .hash_update import HashUpdate +from .location_update import LocationUpdate +from .method_result import MethodResult +from .poll_update import PollUpdate +from .redirect import Redirect diff --git a/src/simmate/website/unicorn/actions/frontend/base.py b/src/simmate/website/unicorn/actions/frontend/base.py new file mode 100644 index 000000000..144acd5fb --- /dev/null +++ b/src/simmate/website/unicorn/actions/frontend/base.py @@ -0,0 +1,100 @@ +import logging +from abc import ABC, abstractmethod + +from simmate.website.unicorn.serializer import dumps, loads + +logger = logging.getLogger(__name__) + + +class FrontendAction(ABC): + """ + Any action or update to be performed on the frontend via client-side + javascript. Specifically, this defines the json response needed to call + actions on the fronted. + + Objects of this class and its methods are typically generated by + FrontendActions and then used to build a ComponentResponse. This whole + process is managed by a ComponentRequest. + """ + + method_name: str = None + """ + Name of the Component method that provided this FrontendAction + """ + + method_args: list[str] = None + """ + List of args that were used in creating this FrontendAction + """ + + method_kwargs: list[str] = None + """ + List of kwargs that were used in creating this FrontendAction + """ + + @property + def is_metadata_set(self) -> bool: + if None in (self.method_name, self.method_args, self.method_kwargs): + return False + else: + return True + + def set_metadata(self, method_name, args: list = None, kwargs: dict = None): + """ + The frontend often needs to know where this FrontendAction came from. + This is method is often used in + """ + self.method_name = method_name + self.method_args = args if args is not None else [] + self.method_kwargs = kwargs if kwargs is not None else {} + + @abstractmethod + def get_payload_value(self) -> any: + """ + Sets what should be returned with the 'value' key in the final dict + + This can be treated as an abstractmethod and overwritten in subclasses. + By default the __dict__ of the class will be returned + """ + return self.value + + def to_dict(self) -> dict: + """ + Converts this action to dictionary for the frontend to use. + All values in the {key: value} output must be json-serialized or as + basic python types (str, int, float, boolean). + """ + # bug-check + assert self.is_metadata_set + + try: + + value = self.get_payload_value() + + # json-serialize + serialized_value = loads(dumps(value)) + serialized_args = loads(dumps(self.method_args)) + serialized_kwargs = loads(dumps(self.method_kwargs)) + + return { + "method": self.method_name, + "args": serialized_args, + "kwargs": serialized_kwargs, + "value": serialized_value, + } + + except Exception as e: + # !!! Why do we fail silently here? - @jacksund + logger.exception(e) + + return {} + + def get_response_data(self): + """ + Builds on top of to_dict to format the ComponentResponse. This is needed + because sometimes the response data for this frontend action is needed + in multiple places (see PollUpdate as an example). + + By default it only updates the "return" value + """ + return {"return": self.to_dict()} diff --git a/src/simmate/website/unicorn/actions/frontend/hash_update.py b/src/simmate/website/unicorn/actions/frontend/hash_update.py new file mode 100644 index 000000000..3aa15cad0 --- /dev/null +++ b/src/simmate/website/unicorn/actions/frontend/hash_update.py @@ -0,0 +1,24 @@ +from .base import FrontendAction + + +class HashUpdate(FrontendAction): + """ + Updates the current URL hash from an action method. + """ + + def __init__(self, url_hash: str): + """ + Args: + param url_hash: The URL hash to change. Example: `#model-123`. + """ + self.url_hash = url_hash + + def get_payload_value(self): + return {"hash": self.url_hash} + + def get_response_data(self): + # The payload value is needed in two places + return { + "redirect": self.get_payload_value(), + "return": self.to_dict(), + } diff --git a/src/simmate/website/unicorn/actions/frontend/location_update.py b/src/simmate/website/unicorn/actions/frontend/location_update.py new file mode 100644 index 000000000..37d52d1ce --- /dev/null +++ b/src/simmate/website/unicorn/actions/frontend/location_update.py @@ -0,0 +1,32 @@ +from django.http import HttpResponseRedirect + +from .base import FrontendAction + + +class LocationUpdate(FrontendAction): + """ + Updates the current URL from an action method. + """ + + def __init__(self, redirect: HttpResponseRedirect, title: str | None = None): + """ + Args: + param redirect: The redirect that contains the URL to redirect to. + param title: The new title of the page. Optional. + """ + self.url = redirect.url + self.title = title + + def get_payload_value(self): + return { + "refresh": True, + "title": self.title, + "url": self.url, + } + + def get_response_data(self): + # The payload value is needed in two places + return { + "redirect": self.get_payload_value(), + "return": self.to_dict(), + } diff --git a/src/simmate/website/unicorn/actions/frontend/method_result.py b/src/simmate/website/unicorn/actions/frontend/method_result.py new file mode 100644 index 000000000..3a377bb93 --- /dev/null +++ b/src/simmate/website/unicorn/actions/frontend/method_result.py @@ -0,0 +1,12 @@ +from .base import FrontendAction + + +class MethodResult(FrontendAction): + + def __init__(self, value=None): + + # TODO: Support a tuple/list return_value which could contain multiple values + self.value = value or {} + + def get_payload_value(self): + return self.value diff --git a/src/simmate/website/unicorn/actions/frontend/poll_update.py b/src/simmate/website/unicorn/actions/frontend/poll_update.py new file mode 100644 index 000000000..f440c836b --- /dev/null +++ b/src/simmate/website/unicorn/actions/frontend/poll_update.py @@ -0,0 +1,40 @@ +from .base import FrontendAction + + +class PollUpdate(FrontendAction): + """ + Updates the current poll from an action method. + """ + + def __init__( + self, + *, + timing: int | None = None, + method: str | None = None, + disable: bool = False, + ): + """ + Args: + param timing: The timing that should be used for the poll. Optional. Defaults to `None` + which keeps the existing timing. + param method: The method that should be used for the poll. Optional. Defaults to `None` + which keeps the existing method. + param disable: Whether to disable the poll or not not. Optional. Defaults to `False`. + """ + self.timing = timing + self.method = method + self.disable = disable + + def get_payload_value(self): + return { + "timing": self.timing, + "method": self.method, + "disable": self.disable, + } + + def get_response_data(self): + # The payload value is needed in two places + return { + "poll": self.get_payload_value(), + "return": self.to_dict(), + } diff --git a/src/simmate/website/unicorn/actions/frontend/redirect.py b/src/simmate/website/unicorn/actions/frontend/redirect.py new file mode 100644 index 000000000..46050d635 --- /dev/null +++ b/src/simmate/website/unicorn/actions/frontend/redirect.py @@ -0,0 +1,23 @@ +from django.http.response import HttpResponseRedirect + +from .base import FrontendAction + + +class Redirect(FrontendAction): + + def __init__(self, *, url: str): + self.url = url + + @classmethod + def from_django(cls, redirect: HttpResponseRedirect): + return cls(url=redirect.url) + + def get_payload_value(self): + return {"url": self.url} + + def get_response_data(self): + # The payload value is needed in two places + return { + "redirect": self.get_payload_value(), + "return": self.to_dict(), + } diff --git a/src/simmate/website/unicorn/cacher.py b/src/simmate/website/unicorn/cacher.py new file mode 100644 index 000000000..bd60b7a3d --- /dev/null +++ b/src/simmate/website/unicorn/cacher.py @@ -0,0 +1,148 @@ +import logging +import pickle +from typing import List + +from django.core.cache import caches +from django.http import HttpRequest + +import simmate.website.unicorn as unicorn +from simmate.website.unicorn.errors import UnicornCacheError +from simmate.website.unicorn.settings import get_cache_alias + +logger = logging.getLogger(__name__) + + +class PointerUnicornView: + def __init__(self, component_cache_key): + self.component_cache_key = component_cache_key + self.parent = None + self.children = [] + + +class CacheableComponent: + """ + Updates a component into something that is cacheable/pickleable. Also set pointers to parents/children. + Use in a `with` statement or explicitly call `__enter__` `__exit__` to use. It will restore the original component + on exit. + """ + + def __init__(self, component): + self._state = {} + self.cacheable_component = component + + def __enter__(self): + components = [] + components.append(self.cacheable_component) + + while components: + component = components.pop() + + if component.component_id in self._state: + continue + + if hasattr(component, "extra_context"): + extra_context = component.extra_context + component.extra_context = None + else: + extra_context = None + + request = component.request + component.request = None + + self._state[component.component_id] = ( + component, + request, + extra_context, + component.parent, + component.children.copy(), + ) + + if component.parent: + components.append(component.parent) + component.parent = PointerUnicornView( + component.parent.component_cache_key + ) + + for index, child in enumerate(component.children): + components.append(child) + component.children[index] = PointerUnicornView( + child.component_cache_key + ) + + for component, *_ in self._state.values(): + try: + pickle.dumps(component) + except ( + TypeError, + AttributeError, + NotImplementedError, + pickle.PicklingError, + ) as e: + raise UnicornCacheError( + f"Cannot cache component '{type(component)}' because it is not picklable: {type(e)}: {e}" + ) from e + + return self + + def __exit__(self, *args): + for component, request, extra_context, parent, children in self._state.values(): + component.request = request + component.parent = parent + component.children = children + + if extra_context: + component.extra_context = extra_context + + def components(self): + return [component for component, *_ in self._state.values()] + + +def cache_full_tree(component): + root = component + + while root.parent: + root = root.parent + + cache = caches[get_cache_alias()] + + with CacheableComponent(root) as caching: + for component in caching.components(): + cache.set(component.component_cache_key, component) + + +def restore_from_cache(component_cache_key: str, request: HttpRequest = None): + """ + Gets a cached unicorn view by key, restoring and getting cached parents and children + and setting the request. + """ + + cache = caches[get_cache_alias()] + cached_component = cache.get(component_cache_key) + + if cached_component: + roots = {} + root = cached_component + roots[root.component_cache_key] = root + + while root.parent: + root = cache.get(root.parent.component_cache_key) + roots[root.component_cache_key] = root + + to_traverse = [] + to_traverse.append(root) + + while to_traverse: + current = to_traverse.pop() + current.setup(request) + current._validate_called = False + current.calls = [] + + for index, child in enumerate(current.children): + key = child.component_cache_key + cached_child = roots.pop(key, None) or cache.get(key) + + cached_child.parent = current + current.children[index] = cached_child + to_traverse.append(cached_child) + + return cached_component diff --git a/src/simmate/website/unicorn/call_method_parser.py b/src/simmate/website/unicorn/call_method_parser.py new file mode 100644 index 000000000..77e46df78 --- /dev/null +++ b/src/simmate/website/unicorn/call_method_parser.py @@ -0,0 +1,168 @@ +import ast +import logging +from functools import lru_cache +from types import MappingProxyType +from typing import Any, Dict, List, Mapping, Tuple + +from simmate.website.unicorn.typer import CASTERS + +logger = logging.getLogger(__name__) + + +class InvalidKwargError(Exception): + pass + + +def _get_expr_string(expr: ast.expr) -> str: + """ + Builds a string based on traversing `ast.Attribute` and `ast.Name` expressions. + + Args: + expr: Expression node of the the AST tree. Only handles `ast.Attribute` and `ast.Name` expressions. + + Returns: + String based on the expression nodes. + """ + + current_expr = expr + expr_str = "" + + while current_expr: + if isinstance(current_expr, ast.Name): + if not expr_str: + expr_str = current_expr.id + else: + expr_str = f"{current_expr.id}.{expr_str}" + + break + elif isinstance(current_expr, ast.Attribute): + if not expr_str: + expr_str = current_expr.attr + else: + expr_str = f"{current_expr.attr}.{expr_str}" + + current_expr = current_expr.value + else: + break + + return expr_str + + +def _cast_value(value): + """ + Try to cast a value based on a list of casters. + """ + + for caster in CASTERS.values(): + try: + casted_value = caster(value) + + if casted_value: + value = casted_value + break + except ValueError: + pass + + return value + + +@lru_cache(maxsize=128, typed=True) +def eval_value(value): + """ + Uses `ast.literal_eval` to parse strings into an appropriate Python primitive. + + Also returns an appropriate object for strings that look like they represent datetime, + date, time, duration, or UUID. + """ + + try: + value = ast.literal_eval(value) + except SyntaxError: + value = _cast_value(value) + + return value + + +@lru_cache(maxsize=128, typed=True) +def parse_kwarg(kwarg: str, *, raise_if_unparseable=False) -> Dict[str, Any]: + """ + Parses a potential kwarg as a string into a dictionary. + + Example: + `parse_kwarg("test='1'")` == `{"test": "1"}` + + Args: + kwarg: Potential kwarg as a string. e.g. "test='1'". + raise_if_unparseable: Raise an error if the `kwarg` cannot be parsed. Defaults to `False`. + + Returns: + Dictionary of key-value pairs. + """ + + try: + tree = ast.parse(kwarg, "eval") + + if tree.body and isinstance(tree.body[0], ast.Assign): + assign = tree.body[0] + + try: + target = assign.targets[0] + key = _get_expr_string(target) + + return {key: eval_value(assign.value)} + except ValueError: + if raise_if_unparseable: + raise + + # The value can be a template variable that will get set from the + # context when the templatetag is rendered, so just return the expr + # as a string. + value = _get_expr_string(assign.value) + return {target.id: value} + else: + raise InvalidKwargError(f"'{kwarg}' is invalid") + except SyntaxError as e: + raise InvalidKwargError(f"'{kwarg}' could not be parsed") from e + + +@lru_cache(maxsize=128, typed=True) +def parse_call_method_name( + call_method_name: str, +) -> Tuple[str, Tuple[Any], Mapping[str, Any]]: + """ + Parses the method name from the request payload into a set of parameters to pass to + a method. + + Args: + param call_method_name: String representation of a method name with parameters, + e.g. "set_name('Bob')" + + Returns: + Tuple of method_name, a list of arguments and a dict of keyword arguments + """ + + is_special_method = False + args: List[Any] = [] + kwargs: Dict[str, Any] = {} + method_name = call_method_name + + # Deal with special methods that start with a "$" + if method_name.startswith("$"): + is_special_method = True + method_name = method_name[1:] + + tree = ast.parse(method_name, "eval") + statement = tree.body[0].value + + if tree.body and isinstance(statement, ast.Call): + call = tree.body[0].value + method_name = call.func.id + args = [eval_value(arg) for arg in call.args] + kwargs = {kw.arg: eval_value(kw.value) for kw in call.keywords} + + # Add "$" back to special functions + if is_special_method: + method_name = f"${method_name}" + + # conversion to immutable types - tuple and MappingProxyType + return method_name, tuple(args), MappingProxyType(kwargs) diff --git a/src/simmate/website/unicorn/components/__init__.py b/src/simmate/website/unicorn/components/__init__.py new file mode 100644 index 000000000..392ee04c4 --- /dev/null +++ b/src/simmate/website/unicorn/components/__init__.py @@ -0,0 +1,24 @@ +# for depreciated imports. These classes are now in the 'actions.frontend' module +from simmate.website.unicorn.actions.frontend import ( + HashUpdate, + LocationUpdate, + PollUpdate, +) +from simmate.website.unicorn.components.mixins import ModelValueMixin +from simmate.website.unicorn.components.unicorn_view import ( + Component, + UnicornField, + UnicornView, +) +from simmate.website.unicorn.typing import QuerySetType + +__all__ = [ + "Component", + "QuerySetType", + "UnicornField", + "UnicornView", + "HashUpdate", + "LocationUpdate", + "PollUpdate", + "ModelValueMixin", +] diff --git a/src/simmate/website/unicorn/components/fields.py b/src/simmate/website/unicorn/components/fields.py new file mode 100644 index 000000000..579885d8c --- /dev/null +++ b/src/simmate/website/unicorn/components/fields.py @@ -0,0 +1,7 @@ +class UnicornField: + """ + Base class to provide a way to serialize a component field quickly. + """ + + def to_json(self): + return self.__dict__ diff --git a/src/simmate/website/unicorn/components/mixins.py b/src/simmate/website/unicorn/components/mixins.py new file mode 100644 index 000000000..d4ddf597c --- /dev/null +++ b/src/simmate/website/unicorn/components/mixins.py @@ -0,0 +1,11 @@ +from simmate.website.unicorn.serializer import model_value + + +class ModelValueMixin: + """ + Adds a `value` method to a model similar to `QuerySet.values(*fields)` which serializes + a model into a dictionary with the fields as specified in the `fields` argument. + """ + + def value(self, *fields): + return model_value(self, *fields) diff --git a/src/simmate/website/unicorn/components/unicorn_template_response.py b/src/simmate/website/unicorn/components/unicorn_template_response.py new file mode 100644 index 000000000..c6caabd8d --- /dev/null +++ b/src/simmate/website/unicorn/components/unicorn_template_response.py @@ -0,0 +1,282 @@ +import logging +import re +from collections import deque + +import orjson +from bs4 import BeautifulSoup +from bs4.dammit import EntitySubstitution +from bs4.element import Tag +from bs4.formatter import HTMLFormatter +from django.template.response import TemplateResponse + +from simmate.website.unicorn.decorators import timed +from simmate.website.unicorn.errors import ( + MissingComponentElementError, + MissingComponentViewElementError, + MultipleRootComponentElementError, + NoRootComponentElementError, +) +from simmate.website.unicorn.settings import ( + get_minify_html_enabled, + get_script_location, +) +from simmate.website.unicorn.utils import generate_checksum, sanitize_html + +logger = logging.getLogger(__name__) + + +# https://developer.mozilla.org/en-US/docs/Glossary/Empty_element +EMPTY_ELEMENTS = ( + "", + "", + "
", + "", + "", + "
", + "", + "", + "", + "", + "", + "", + "", + "", +) + + +def is_html_well_formed(html: str) -> bool: + """ + Whether the passed-in HTML is missing any closing elements which can cause issues when rendering. + """ + + tag_list = re.split("(<[^>!]*>)", html)[1::2] + stack = deque() + + for tag in tag_list: + if "/" not in tag: + cleaned_tag = re.sub(r"(<([\w\-]+)[^>!]*>)", r"<\2>", tag) + + if cleaned_tag not in EMPTY_ELEMENTS: + stack.append(cleaned_tag) + elif len(stack) > 0 and (tag.replace("/", "") == stack[len(stack) - 1]): + stack.pop() + + return len(stack) == 0 + + +def assert_has_single_wrapper_element(root_element: Tag, component_name: str) -> None: + """Assert that there is at least one child in the root element. And that there is only + one root element. + """ + + # Check that the root element has at least one child + try: + next(root_element.descendants) + except StopIteration: + raise NoRootComponentElementError( + f"The '{component_name}' component does not appear to have one root element." + ) from None + + if "unicorn:view" in root_element.attrs or "u:view" in root_element.attrs: + # If the root element is a direct view, skip the check + return + + # Check that there is not more than one root element + parent_element = root_element.parent + + tag_count = len([c for c in parent_element.children if isinstance(c, Tag)]) + + if tag_count > 1: + raise MultipleRootComponentElementError( + f"The '{component_name}' component appears to have multiple root elements." + ) from None + + +def _get_direct_view(tag: Tag): + return tag.find_next(attrs={"unicorn:view": True}) or tag.find_next( + attrs={"u:view": True} + ) + + +def get_root_element(soup: BeautifulSoup) -> Tag: + """Gets the first tag element for the component or the first element with a `unicorn:view` attribute for a direct + view. + + Returns: + BeautifulSoup tag element. + + Raises `Exception` if an element cannot be found. + """ + + for element in soup.contents: + if isinstance(element, Tag) and element.name: + if element.name == "html": + view_element = _get_direct_view(element) + + if not view_element: + raise MissingComponentViewElementError( + "An element with an `unicorn:view` attribute is required for a direct view" + ) + + return view_element + + return element + + raise MissingComponentElementError("No root element for the component was found") + + +class UnsortedAttributes(HTMLFormatter): + """ + Prevent beautifulsoup from re-ordering attributes. + """ + + def __init__(self): + super().__init__(entity_substitution=EntitySubstitution.substitute_html) + + def attributes(self, tag: Tag): + yield from tag.attrs.items() + + +class UnicornTemplateResponse(TemplateResponse): + def __init__( + self, + template, + request, + *, + context=None, + content_type=None, + status=None, + charset=None, + using=None, + component=None, + init_js=False, + **kwargs, # noqa: ARG002 + ): + super().__init__( + template=template, + request=request, + context=context, + content_type=content_type, + status=status, + charset=charset, + using=using, + ) + + self.component = component + self.init_js = init_js + + @timed + def render(self): + response = super().render() + + if not self.component or not self.component.component_id: + return response + + content = response.content.decode("utf-8") + + if not is_html_well_formed(content): + logger.warning( + f"The HTML in '{self.component.component_name}' appears to be missing a closing tag. That can \ +potentially cause errors in Unicorn." + ) + + frontend_context_variables = self.component.get_frontend_context_variables() + frontend_context_variables_dict = orjson.loads(frontend_context_variables) + checksum = generate_checksum(frontend_context_variables_dict) + + # Use `html.parser` and not `lxml` because in testing it was no faster even with `cchardet` + # despite https://thehftguy.com/2020/07/28/making-beautifulsoup-parsing-10-times-faster/ + soup = BeautifulSoup(content, features="html.parser") + root_element = get_root_element(soup) + + try: + assert_has_single_wrapper_element( + root_element, self.component.component_name + ) + except (NoRootComponentElementError, MultipleRootComponentElementError) as ex: + logger.warning(ex) + + root_element["unicorn:id"] = self.component.component_id + root_element["unicorn:name"] = self.component.component_name + root_element["unicorn:key"] = self.component.component_key + root_element["unicorn:checksum"] = checksum + root_element["unicorn:data"] = frontend_context_variables + root_element["unicorn:calls"] = orjson.dumps(self.component.calls).decode( + "utf-8" + ) + + # Generate the checksum based on the rendered content (without script tag) + content_hash = generate_checksum(UnicornTemplateResponse._desoupify(soup)) + + if self.init_js: + init = { + "id": self.component.component_id, + "name": self.component.component_name, + "key": self.component.component_key, + "data": orjson.loads(frontend_context_variables), + "calls": self.component.calls, + "hash": content_hash, + } + init = orjson.dumps(init).decode("utf-8") + json_element_id = f"unicorn:data:{self.component.component_id}" + init_script = f"Unicorn.componentInit(JSON.parse(document.getElementById('{json_element_id}').textContent));" + + json_tag = soup.new_tag("script") + json_tag["type"] = "application/json" + json_tag["id"] = json_element_id + json_tag.string = sanitize_html(init) + + if self.component.parent: + self.component._init_script = init_script + self.component._json_tag = json_tag + else: + json_tags = [json_tag] + + descendants = [] + descendants.append(self.component) + while descendants: + descendant = descendants.pop() + for child in descendant.children: + init_script = f"{init_script} {child._init_script}" + json_tags.append(child._json_tag) + descendants.append(child) + + script_tag = soup.new_tag("script") + script_tag["type"] = "module" + script_tag.string = f"if (typeof Unicorn === 'undefined') {{ console.error('Unicorn is missing. Do you \ +need {{% load unicorn %}} or {{% unicorn_scripts %}}?') }} else {{ {init_script} }}" + + if get_script_location() == "append": + root_element.append(script_tag) + else: + root_element.insert_after(script_tag) + + for t in json_tags: + if get_script_location() == "append": + root_element.append(t) + else: + root_element.insert_after(t) + + rendered_template = UnicornTemplateResponse._desoupify(soup) + + # !!! should rendered be called here instead of in the ComponentResponse + # class (see comment there) + self.component.rendered(rendered_template) + + response.content = rendered_template + + if get_minify_html_enabled(): + # Import here in case the minify extra was not installed + from htmlmin import minify + + minified_html = minify(response.content.decode()) + + if len(minified_html) < len(rendered_template): + response.content = minified_html + + return response + + @staticmethod + def _desoupify(soup): + soup.smooth() + return soup.encode(formatter=UnsortedAttributes()).decode("utf-8") diff --git a/src/simmate/website/unicorn/components/unicorn_view.py b/src/simmate/website/unicorn/components/unicorn_view.py new file mode 100644 index 000000000..91f15d095 --- /dev/null +++ b/src/simmate/website/unicorn/components/unicorn_view.py @@ -0,0 +1,907 @@ +import importlib +import logging +import pickle +import re +from functools import cache +from inspect import getmembers, isclass + +import orjson +import shortuuid +from django.apps import apps as django_apps_module +from django.db.models import Model +from django.forms.widgets import CheckboxInput, Select +from django.http import HttpRequest +from django.utils.decorators import classonlymethod +from django.utils.safestring import mark_safe +from django.views.generic.base import TemplateView + +from simmate.website.unicorn import serializer +from simmate.website.unicorn.cacher import cache_full_tree, restore_from_cache +from simmate.website.unicorn.components.fields import UnicornField +from simmate.website.unicorn.components.unicorn_template_response import ( + UnicornTemplateResponse, +) +from simmate.website.unicorn.decorators import timed +from simmate.website.unicorn.typer import cast_attribute_value, get_type_hints +from simmate.website.unicorn.utils import is_non_string_sequence + +try: + from cachetools.lru import LRUCache +except ImportError: + from cachetools import LRUCache + + +logger = logging.getLogger(__name__) + +LOCAL_COMPONENT_CACHE = LRUCache(maxsize=1_000) + + +class Component(TemplateView): + + component_key: str = "" + component_id: str = "" + component_args: list = None + component_kwargs: dict = None + + response_class = UnicornTemplateResponse + + request: HttpRequest = None + + parent: "Component" = None + + children: list["Component"] = None + + force_render: bool = False + + # JavaScript method calls + calls: list = None + + errors: dict = None + + _validate_called: bool = False + _init_script: str = "" + + component_args: list = None + component_kwargs: dict = None + + def __init__( + self, + component_args: list = [], + component_kwargs: dict = {}, + **kwargs, + ): + + # super().__init__(**kwargs) --> same as below + for key, value in kwargs.items(): + setattr(self, key, value) + + if kwargs.get("id"): + # Sometimes the component_id is initially in kwargs["id"] + self.component_id = kwargs["id"] + + if not self.component_id: + raise AssertionError("Component id is required") + + # init mutable sets with a fresh start + self.children = [] + self.calls = [] + self.errors = {} + + # set parent-child relationships + if self.parent and self not in self.parent.children: + self.parent.children.append(self) + + # !!! not sure when/where this is used + self.component_args = component_args + + # Revise the kwargs to only include custom kwargs since the + # standard kwargs are available as instance variables + custom_kwargs = set(component_kwargs.keys()) - { + # STANDARD_COMPONENT_KWARG_KEYS + "id", + "component_id", + "component_name", + "component_key", + "parent", + "request", + } + self.component_kwargs = {k: component_kwargs[k] for k in list(custom_kwargs)} + + # apply kwargs + for key, value in self.component_kwargs.items(): + setattr(self, key, value) + + # !!! should kwargs be mounted only on __init__ or should this move + # to the get_or_create method? + self.mount() + + # Make sure that there is always a request on the parent if needed + # if self.parent is not None and self.parent.request is None: + # self.parent.request = self.request + + # component.hydrate() + # component.complete() + # component._validate_called = False + + self.to_local_cache() + # component._set_request(request) + + @classmethod + @property + def component_name(cls): + # Switch from class name to unicorn convent (ExampleNameView --> example-name) + # adds a hyphen between each capital letter + # copied from https://stackoverflow.com/questions/199059/ + # -4 is to remove "View" at end + return re.sub(r"(\w)([A-Z])", r"\1-\2", cls.__name__[:-4]).lower() + + @classmethod + @property + def template_name(self) -> str: + """ + Sets a default template name based on component's name if necessary. + """ + # Convert component name with a dot to a folder structure + template_name = self.component_name.replace(".", "/") + self.template_name = f"unicorn/{template_name}.html" + + def call(self, function_name, *args): + """ + Add a JavaScript method name and arguments to be called after the component is rendered. + """ + self.calls.append({"fn": function_name, "args": args}) + + def mount(self): + """ + Hook that gets called when the component is first created. + """ + pass + + def hydrate(self): + """ + Hook that gets called when the component's data is hydrated. + """ + pass + + def complete(self): + """ + Hook that gets called after all component methods are executed. + """ + pass + + def rendered(self, html): + """ + Hook that gets called after the component has been rendered. + """ + pass + + def parent_rendered(self, html): + """ + Hook that gets called after the component's parent has been rendered. + """ + pass + + def updating(self, name, value): + """ + Hook that gets called when a component's data is about to get updated. + """ + pass + + def updated(self, name, value): + """ + Hook that gets called when a component's data is updated. + """ + pass + + def calling(self, name, args): + """ + Hook that gets called when a component's method is about to get called. + """ + pass + + def called(self, name, args): + """ + Hook that gets called when a component's method is called. + """ + pass + + # ------------------------------------------------------------------------- + + def reset(self): + for ( + attribute_name, + pickled_value, + ) in self._resettable_attributes.items(): + try: + attribute_value = pickle.loads(pickled_value) # noqa: S301 + self._set_property(attribute_name, attribute_value) + except TypeError: + logger.warn( + f"Resetting '{attribute_name}' attribute failed because it could not be constructed." + ) + pass + except pickle.PickleError: + logger.warn( + f"Resetting '{attribute_name}' attribute failed because it could not be de-pickled." + ) + pass + + @classonlymethod + def as_view(cls, **initkwargs): # noqa: N805 + if "component_id" not in initkwargs: + initkwargs["component_id"] = shortuuid.uuid()[:8] + + if "component_name" not in initkwargs: + module_name = cls.__module__ + module_parts = module_name.split(".") + component_name = module_parts[-1].replace("_", "-") + + initkwargs["component_name"] = component_name + + return super().as_view(**initkwargs) + + @timed + def render(self, *, init_js=False, extra_context=None, request=None) -> str: + """ + Renders a UnicornView component with the public properties available. Delegates to a + UnicornTemplateResponse to actually render a response. + + Args: + param init_js: Whether or not to include the Javascript required to initialize the component. + param extra_context: + param request: Set the `request` for rendering. Usually it will be in the context, + but it is missing when the component is re-rendered as a direct view, so it needs + to be set explicitly. + """ + + if extra_context is not None: + self.extra_context = extra_context + + if request: + self.request = request + + response = self.render_to_response( + context=self.get_context_data(), + component=self, + init_js=init_js, + ) + + # render_to_response() could only return a HttpResponse, so check for render() + if hasattr(response, "render"): + response.render() + + rendered_component = response.content.decode("utf-8") + + return rendered_component + + def dispatch(self, request, *args, **kwargs): # noqa: ARG002 + """ + Called by the `as_view` class method when utilizing a component directly as a view. + """ + + self.mount() + self.hydrate() + + return self.render_to_response( + context=self.get_context_data(), + component=self, + init_js=True, + ) + + # Ideally, this would be named get_frontend_context_json + def get_frontend_context_variables(self) -> str: + """ + Get publicly available properties and output them in a string-encoded JSON object. + """ + frontend_context_variables = {} + attributes = self._attributes + frontend_context_variables.update(attributes) + + exclude_field_attributes = [] + + # Remove any field in `javascript_exclude` from `frontend_context_variables` + if hasattr(self, "Meta") and hasattr(self.Meta, "javascript_exclude"): + if type(self.Meta.javascript_exclude) in (list, tuple): + for field_name in self.Meta.javascript_exclude: + if "." in field_name: + # Because the dictionary value could be an object, we can't just remove the attribute, so + # store field attributes for later to remove them from the serialized dictionary + exclude_field_attributes.append(field_name) + else: + if field_name not in frontend_context_variables: + raise serializer.InvalidFieldNameError( + field_name=field_name, data=frontend_context_variables + ) + + del frontend_context_variables[field_name] + + # Add cleaned values to `frontend_content_variables` based on the widget in form's fields + form = self._get_form(attributes) + + if form: + for key in attributes.keys(): + if key in form.fields: + field = form.fields[key] + + if key in form.cleaned_data: + cleaned_value = form.cleaned_data[key] + + if isinstance(field.widget, CheckboxInput) and isinstance( + cleaned_value, bool + ): + # Handle booleans for checkboxes explicitly because `format_value` + # returns `None` + value = cleaned_value + elif ( + isinstance(field.widget, Select) + and not field.widget.allow_multiple_selected + ): + # Handle value for Select widgets explicitly because `format_value` + # returns a list of stringified values + value = cleaned_value + else: + value = field.widget.format_value(cleaned_value) + + # Don't update the frontend variable if the only change is + # stripping off the whitespace from the field value + # https://docs.djangoproject.com/en/stable/ref/forms/fields/#django.forms.CharField.strip + if ( + not hasattr(frontend_context_variables[key], "strip") + or frontend_context_variables[key].strip() != value + ): + frontend_context_variables[key] = value + + encoded_frontend_context_variables = serializer.dumps( + frontend_context_variables, + exclude_field_attributes=tuple(exclude_field_attributes), + ) + + return encoded_frontend_context_variables + + @timed + def get_frontend_context(self) -> str: + """ + Get publicly available properties and output them to a python dict. + Note, special types (e.g. db models) are converted to dict as well + """ + # Re-load frontend context variables to deal with non-serializable properties. + # OPTMIZE: this converts to json and then immediately back to dict... + # !!! method easily confused with `get_frontend_context`, which is for template + return orjson.loads(self.get_frontend_context_variables()) + + @timed + def _get_form(self, data): + if hasattr(self, "form_class"): + try: + form = self.form_class(data=data) + form.is_valid() + + return form + except Exception as e: + logger.exception(e) + + @timed + def get_context_data(self, **kwargs): + """ + Overrides the standard `get_context_data` to add in publicly available + properties and methods. + """ + + context = super().get_context_data(**kwargs) + + attributes = self._attributes + context.update(attributes) + context.update(self._methods) + context.update( + { + "unicorn": { + "component_id": self.component_id, + "component_name": self.component_name, + "component_key": self.component_key, + "component": self, + "errors": self.errors, + } + } + ) + + return context + + def is_valid(self, model_names: list = None) -> bool: + return len(self.validate(model_names).keys()) == 0 + + def validate(self, model_names: list = None) -> dict: + """ + Validates the data using the `form_class` set on the component. + + Args: + model_names: Only include validation errors for specified fields. If none, validate everything. + """ + # TODO: Handle form.non_field_errors()? + + if self._validate_called: + return self.errors + + self._validate_called = True + + data = self._attributes + form = self._get_form(data) + + if form: + form_errors = form.errors.get_json_data(escape_html=True) + + # This code is confusing, but handles this use-case: + # the component has two models, one that starts with an error and one + # that is valid. Validating the valid one should not show an error for + # the invalid one. Only after the invalid field is updated, should the + # error show up and persist, even after updating the valid form. + if self.errors: + keys_to_remove = [] + + for key, value in self.errors.items(): + if key in form_errors: + self.errors[key] = value + else: + keys_to_remove.append(key) + + for key in keys_to_remove: + self.errors.pop(key) + + if model_names is not None: + for key, value in form_errors.items(): + if key in model_names: + self.errors[key] = value + else: + self.errors.update(form_errors) + + return self.errors + + # ------------------------------------------------------------------------- + + @classmethod + @property + @cache + def _methods(cls) -> dict[str, callable]: + """ + Get publicly available method names and their functions from the component. + Cached in `_methods_cache`. + """ + return { + name: getattr(cls, name) + for name in dir(cls) + # name="_methods" will cause recursion error + if cls._is_public(name) and callable(getattr(cls, name)) + } # dir() looks to be faster than inspect.getmembers + + @classmethod + @property + @cache + def _hook_methods(cls) -> list: + """ + Caches the updating/updated attribute function names defined on the component. + """ + hook_methods = [] + # BUG: using 'cls._attribute_names' causes recursion due to is_public method + for attribute_name in dir(cls): + for hook_name in ["updating", "updated"]: + function_name = f"{hook_name}_{attribute_name}" + if hasattr(cls, function_name): + hook_methods.append(function_name) + return hook_methods + + @classmethod + @property + @cache + def _attribute_names(cls) -> list[str]: + """ + Gets publicly available attribute names. + """ + attribute_names = [ + name + for name in dir(cls) + # name="_methods" will cause recursion error + if cls._is_public(name) and not callable(getattr(cls, name)) + ] # dir() looks to be faster than inspect.getmembers + + # Add type hints for the component to the attribute names since + # they won't be returned from `getmembers`/`dir` + for type_hint_attribute_name in get_type_hints(cls).keys(): + if cls._is_public(type_hint_attribute_name): + if type_hint_attribute_name not in attribute_names: + attribute_names.append(type_hint_attribute_name) + + return attribute_names + + @property + def _attributes(self) -> dict[str, any]: + """ + Get publicly available attributes and their values from the component. + """ + return { + attribute_name: getattr(self, attribute_name, None) + for attribute_name in self._attribute_names + } + + @property + def _resettable_attributes(self) -> dict: + """ + attributes that are "resettable" + a dictionary with key: attribute name; value: pickled attribute value + + Examples: + - `UnicornField` + - Django Models without a defined pk + """ + resettable_attributes = {} + for attribute_name, attribute_value in self._attributes.items(): + if isinstance(attribute_value, UnicornField): + resettable_attributes[attribute_name] = pickle.dumps(attribute_value) + elif isinstance(attribute_value, Model): + if not attribute_value.pk: + if attribute_name not in resettable_attributes: + try: + resettable_attributes[attribute_name] = pickle.dumps( + attribute_value + ) + except pickle.PickleError: + logger.warn( + f"Caching '{attribute_name}' failed because it could not be pickled." + ) + pass + return resettable_attributes + + # ------------------------------------------------------------------------- + + @timed + def _set_property( + self, + name: str, + value: any, + *, + call_updating_method: bool = False, + call_updated_method: bool = False, + ) -> None: + # Get the correct value type by using the form if it is available + data = self._attributes + + value = cast_attribute_value(self, name, value) + data[name] = value + + form = self._get_form(data) + + if form and name in form.fields and name in form.cleaned_data: + # The Django form CharField validator will remove whitespace + # from the field value. Ignore that update if it's the + # only thing different from the validator + # https://docs.djangoproject.com/en/stable/ref/forms/fields/#django.forms.CharField.strip + if not hasattr(value, "strip") or form.cleaned_data[name] != value.strip(): + value = form.cleaned_data[name] + + if call_updating_method: + updating_function_name = f"updating_{name}" + + if hasattr(self, updating_function_name): + getattr(self, updating_function_name)(value) + + try: + setattr(self, name, value) + + if call_updated_method: + updated_function_name = f"updated_{name}" + + if hasattr(self, updated_function_name): + getattr(self, updated_function_name)(value) + except AttributeError: + raise + + @timed + def _get_property(self, property_name: str) -> any: + """ + Gets property value from the component based on the property name. + Handles nested property names. + + Args: + param component: Component to get property values from. + param property_name: Property name. Can be "dot-notation" to get nested properties. + """ + + if property_name is None: + raise AssertionError("property_name name is required") + + # Handles nested properties + property_name_parts = property_name.split(".") + component_or_field = self + + for idx, property_name_part in enumerate(property_name_parts): + if hasattr(component_or_field, property_name_part): + if idx == len(property_name_parts) - 1: + return getattr(component_or_field, property_name_part) + else: + component_or_field = getattr(component_or_field, property_name_part) + elif isinstance(component_or_field, dict): + if idx == len(property_name_parts) - 1: + return component_or_field[property_name_part] + else: + component_or_field = component_or_field[property_name_part] + + # ------------------------------------------------------------------------- + + @classmethod + def _is_public(cls, name: str) -> bool: + """ + Determines if the name should be sent in the context. + """ + + # Ignore some standard attributes from TemplateView + protected_names = ( + "render", + "request", + "args", + "kwargs", + "content_type", + "extra_context", + "http_method_names", + "template_engine", + "template_name", + "dispatch", + "id", + "get", + "get_context_data", + "get_template_names", + "render_to_response", + "http_method_not_allowed", + "options", + "setup", + "fill", + "view_is_async", + # Component methods + "component_id", + "component_name", + "component_key", + "reset", + "mount", + "hydrate", + "updating", + "update", + "calling", + "called", + "complete", + "rendered", + "parent_rendered", + "validate", + "is_valid", + "get_frontend_context_variables", + "errors", + "updated", + "parent", + "children", + "call", + "calls", + "component_cache_key", + "component_kwargs", + "component_args", + "force_render", + ) + + excludes = [] + if hasattr(cls, "Meta") and hasattr(cls.Meta, "exclude"): + if not is_non_string_sequence(cls.Meta.exclude): + raise AssertionError("Meta.exclude should be a list, tuple, or set") + + for exclude in cls.Meta.exclude: + if not hasattr(cls, exclude): + raise serializer.InvalidFieldNameError( + field_name=exclude, data=cls._attributes() + ) + + excludes = cls.Meta.exclude + + return not ( + name.startswith("_") + or name in protected_names + or name in cls._hook_methods + or name in excludes + ) + + def _mark_safe_fields(self): + # Get set of attributes that should be marked as `safe` + safe_fields = [] + if hasattr(self, "Meta") and hasattr(self.Meta, "safe"): + breakpoint() + if isinstance(self.Meta.safe, Sequence): + for field_name in self.Meta.safe: + if field_name in self._attributes.keys(): + safe_fields.append(field_name) + + # Mark safe attributes as such before rendering + for field_name in safe_fields: + value = getattr(self, field_name) + if isinstance(value, str): + setattr(self, field_name, mark_safe(value)) + + # ------------------------------------------------------------------------- + + @classmethod + def from_request( + cls, + request, # takes ComponentRequest, not HttpRequest + apply_actions: bool = True, + ): + """ + Given a ComponentRequest object, this will create or load from cache + the proper UnicornView object and then (if requested) apply all actions + to the UnicornView object. + """ + component = cls.get_or_create( + component_id=request.id, + component_name=request.name, + request=request.request, # gives the HttpRequest obj + ) + + if apply_actions: + request.apply_to_component(component, inplace=True) + + return component + + @classmethod + def get_or_create( + cls, + component_id: str, + component_name: str, + **kwargs, + ) -> "UnicornView": + + component_cache_key = f"unicorn:component:{component_id}" + + # try local cache + cached_component = cls.from_local_cache(component_cache_key) + if cached_component: + return cached_component + + # try django cache (DISABLED FOR NOW) + # cached_component = cls.from_django_cache(component_cache_key) + # if cached_component: + # return cached_component + + # create new one + return cls.create( + component_id=component_id, + component_name=component_name, + **kwargs, + ) + + @staticmethod + def from_local_cache(component_cache_key: str) -> "UnicornView": + return LOCAL_COMPONENT_CACHE.get(component_cache_key) + + @staticmethod + def from_django_cache(component_cache_key: str) -> "UnicornView": + return restore_from_cache(component_cache_key) + + @staticmethod + def create(component_name: str, **kwargs) -> "UnicornView": + # note this fxn call is cached for speedup + component_class = get_all_component_classes()[component_name] + return component_class(**kwargs) + + # ------------------------------------------------------------------------- + + @property + def component_cache_key(self): + return f"unicorn:component:{self.component_id}" + # return ( + # f"{self.parent._component_cache_key}:{self.component_id}" + # if self.parent + # else f"unicorn:component:{self.component_id}" + # ) + + def update_caches(self): + self.to_local_cache() + # self.to_django_cache() # DISABLE FOR NOW + + def to_local_cache(self): + LOCAL_COMPONENT_CACHE[self.component_cache_key] = self + + def to_django_cache(self): + cache_full_tree(self) + + # ------------------------------------------------------------------------- + + +# modified from simmate get_all_workflows +@cache +def get_all_component_classes() -> dict: + + # note - app_config.name gives the python path + apps_to_search = [ + app_config.name for app_config in django_apps_module.get_app_configs() + ] + + all_components = {} + for app_name in apps_to_search: + + # check if there is a components module for this app and load it if so + components_path = get_app_submodule(app_name, "components") + if not components_path: + continue # skip to the next app + app_components_module = importlib.import_module(components_path) + + # iterate through each available object in the components file and find + # which ones are Component objects. This will be the file "components.py" + # or "components/__init__.py" + + # SETUP OPTION 1 + # If an __all__ value is set, then this will take priority when grabbing + # workflows from the module + if hasattr(app_components_module, "__all__"): + for component_class_name in app_components_module.__all__: + if not component_class_name.endswith("View"): + continue # !!! unicorn convention, I'd rather isinstance() + component_class = getattr(app_components_module, component_class_name) + if component_class_name not in all_components: + all_components[component_class_name] = component_class + + # SETUP OPTION 2 + # otherwise we load ALL class objects from the module -- assuming the + # user properly limited these to just Component objects. + else: + # a tuple is returned by getmembers so c[0] is the string name while + # c[1] is the python class object. + for component_class_name, component_class in getmembers( + app_components_module + ): + if not isclass(component_class): + continue + if not component_class_name.endswith("View"): + continue # !!! unicorn convention, I'd rather isinstance() + if component_class_name not in all_components: + all_components[component_class_name] = component_class + + # Switch from class name to unicorn convent (ExampleNameView --> example-name) + # adds a hyphen between each capital letter + # copied from https://stackoverflow.com/questions/199059/ + # -4 is to remove "View" at end + all_components_cleaned = {} + for component_class_name, component_class in all_components.items(): + component_name = re.sub( + r"(\w)([A-Z])", r"\1-\2", component_class_name[:-4] + ).lower() + all_components_cleaned[component_name] = component_class + + return all_components_cleaned + + +# util borrowed from simmate +def get_app_submodule( + app_path: str, + submodule_name: str, +) -> str: + """ + Checks if an app has a submodule present and returns the import path for it. + This is useful for checking if there are workflows or urls defined, which + are optional accross all apps. None is return if no app exists + """ + submodule_path = f"{app_path}.{submodule_name}" + + # check if there is a workflows module in the app, and if so, + # try loading the workflows. + # stackoverflow.com/questions/14050281 + has_submodule = importlib.util.find_spec(submodule_path) is not None + + return submodule_path if has_submodule else None + + +# util borrowed from simmate +def get_class(class_path: str): + """ + Given the import path for a python class (e.g. path.to.MyClass), this + utility will load the class given (MyClass). + """ + config_modulename = ".".join(class_path.split(".")[:-1]) + config_name = class_path.split(".")[-1] + config_module = importlib.import_module(config_modulename) + config = getattr(config_module, config_name) + return config + + +# to support deprec naming of class +UnicornView = Component diff --git a/src/simmate/website/unicorn/db.py b/src/simmate/website/unicorn/db.py new file mode 100644 index 000000000..0468c57f1 --- /dev/null +++ b/src/simmate/website/unicorn/db.py @@ -0,0 +1,14 @@ +from typing import Optional + +from django.db.models import Model + + +class DbModel: + def __init__( + self, name: str, model_class: Model, *, defaults: Optional[dict] = None + ): + if defaults is None: + defaults = {} + self.name = name + self.model_class = model_class + self.defaults = defaults diff --git a/src/simmate/website/unicorn/decorators.py b/src/simmate/website/unicorn/decorators.py new file mode 100644 index 000000000..514d13b82 --- /dev/null +++ b/src/simmate/website/unicorn/decorators.py @@ -0,0 +1,41 @@ +import logging +import time + +from decorator import decorator +from django.conf import settings + + +@decorator +def timed(func, *args, **kwargs): + """ + Decorator that prints out the timing of a function. + + Slightly altered version of https://gist.github.com/bradmontgomery/bd6288f09a24c06746bbe54afe4b8a82. + """ + if not settings.DEBUG: + return func(*args, **kwargs) + + logger = logging.getLogger("profile") + start = time.time() + result = func(*args, **kwargs) + end = time.time() + + function_name = func.__name__ + arguments = "" + + if args: + arguments = f"{args}, " + + for kwarg_key, kwarg_val in kwargs.items(): + if isinstance(kwarg_val, str): + kwarg_val = f"'{kwarg_val}'" # noqa: PLW2901 + + arguments = f"{arguments}{kwarg_key}={kwarg_val}, " + + if arguments.endswith(", "): + arguments = arguments[:-2] + + ms = round(end - start, 4) + + logger.debug(f"{function_name}({arguments}): {ms}ms") + return result diff --git a/src/simmate/website/unicorn/errors.py b/src/simmate/website/unicorn/errors.py new file mode 100644 index 000000000..1ff83f052 --- /dev/null +++ b/src/simmate/website/unicorn/errors.py @@ -0,0 +1,44 @@ +class UnicornCacheError(Exception): + pass + + +class UnicornViewError(Exception): + pass + + +class ComponentLoadError(Exception): + def __init__(self, *args, locations=None, **kwargs): + super().__init__(*args, **kwargs) + self.locations = locations + + +class ComponentModuleLoadError(ComponentLoadError): + pass + + +class ComponentClassLoadError(ComponentLoadError): + pass + + +class RenderNotModifiedError(Exception): + pass + + +class MissingComponentElementError(Exception): + pass + + +class MissingComponentViewElementError(Exception): + pass + + +class NoRootComponentElementError(Exception): + pass + + +class MultipleRootComponentElementError(Exception): + pass + + +class ComponentNotValidError(Exception): + pass diff --git a/src/simmate/website/unicorn/serializer.py b/src/simmate/website/unicorn/serializer.py new file mode 100644 index 000000000..fe0d49ee7 --- /dev/null +++ b/src/simmate/website/unicorn/serializer.py @@ -0,0 +1,472 @@ +import logging +from datetime import timedelta +from decimal import Decimal +from functools import lru_cache +from types import MappingProxyType +from typing import Any, Dict, List, Optional, Tuple + +import orjson +from django.core.serializers import serialize +from django.core.serializers.json import DjangoJSONEncoder +from django.db.models import ( + DateField, + DateTimeField, + DurationField, + Model, + QuerySet, + TimeField, +) +from django.utils.dateparse import ( + parse_date, + parse_datetime, + parse_duration, + parse_time, +) +from django.utils.duration import duration_string + +from simmate.website.unicorn.utils import is_int, is_non_string_sequence + +try: + from pydantic import BaseModel as PydanticBaseModel +except ImportError: + PydanticBaseModel = None + + +logger = logging.getLogger(__name__) + +django_json_encoder = DjangoJSONEncoder() + + +class JSONDecodeError(Exception): + pass + + +class InvalidFieldNameError(Exception): + def __init__(self, field_name: str, data: Optional[Dict] = None): + message = f"Cannot resolve '{field_name}'." + + if data: + available = ", ".join(data.keys()) + message = f"{message} Choices are: {available}" + + super().__init__(message) + + +class InvalidFieldAttributeError(Exception): + def __init__(self, field_name: str, field_attr: str, data: Optional[Dict] = None): + message = f"Cannot resolve '{field_attr}'." + + if data: + available = ", ".join(data[field_name].keys()) + message = f"{message} Choices on '{field_name}' are: {available}" + + super().__init__(message) + + +def _parse_field_values_from_string(model: Model) -> None: + """ + Convert the model fields' value to match the field type if appropriate. + + This is mostly to deal with field string values that will get saved as a date-related field. + """ + + for field in model._meta.fields: + val = getattr(model, field.attname) + + if not isinstance(val, str): + continue + + if isinstance(field, DateTimeField): + setattr(model, field.attname, parse_datetime(val)) + elif isinstance(field, TimeField): + setattr(model, field.attname, parse_time(val)) + elif isinstance(field, DateField): + setattr(model, field.attname, parse_date(val)) + elif isinstance(field, DurationField): + setattr(model, field.attname, parse_duration(val)) + + +def _get_many_to_many_field_related_names(model: Model) -> List[str]: + """ + Get the many-to-many fields for a particular model. Returns either the automatically + defined field name (i.e. something_set) or the related name. + """ + + # Use this internal method so that the fields can be cached + @lru_cache(maxsize=128, typed=True) + def _get_many_to_many_field_related_names_from_meta(meta): + names = [] + + for field in meta.get_fields(): + if field.is_relation and field.many_to_many: + related_name = field.name + + if field.auto_created: + related_name = field.related_name or f"{field.name}_set" + + names.append(related_name) + + return names + + return _get_many_to_many_field_related_names_from_meta(model._meta) + + +def _get_m2m_field_serialized(model: Model, field_name) -> List: + pks = [] + + try: + related_descriptor = getattr(model, field_name) + + # Get `pk` from `all` because it will re-use the cached data if the m-2-m field is prefetched + # Using `values_list("pk", flat=True)` or `only()` won't use the cached prefetched values + pks = [m.pk for m in related_descriptor.all()] + except ValueError: + # ValueError is thrown when the model doesn't have an id already set + pass + + return pks + + +def _handle_inherited_models(model: Model, model_json: Dict): + """ + Handle if the model has a parent (i.e. the model is a subclass of another model). + + Subclassed model's fields don't get serialized + (https://docs.djangoproject.com/en/stable/topics/serialization/#inherited-models) + so those fields need to be retrieved manually. + """ + + if model._meta.get_parent_list(): + for field in model._meta.get_fields(): + if ( + field.name not in model_json + and hasattr(field, "primary_key") + and not field.primary_key + ): + if field.is_relation: + # We already serialized the m2m fields above, so we can skip them, but need to handle FKs + if not field.many_to_many: + foreign_key_field = getattr(model, field.name) + foreign_key_field_pk = getattr( + foreign_key_field, + "pk", + getattr(foreign_key_field, "id", None), + ) + model_json[field.name] = foreign_key_field_pk + else: + value = getattr(model, field.name) + + # Explicitly handle `timedelta`, but use the DjangoJSONEncoder for everything else + if isinstance(value, timedelta): + value = duration_string(value) + else: + # Make sure the value is properly serialized + value = django_json_encoder.encode(value) + + # The DjangoJSONEncoder has extra double-quotes for strings so remove them + if ( + isinstance(value, str) + and value.startswith('"') + and value.endswith('"') + ): + value = value[1:-1] + + model_json[field.name] = value + + +def _get_model_dict(model: Model) -> dict: + """ + Serializes Django models. Uses the built-in Django JSON serializer, but moves the data around to + remove some unnecessary information and make the structure more compact. + """ + + _parse_field_values_from_string(model) + + # Django's `serialize` method always returns a string of an array, + # so remove the brackets from the resulting string + serialized_model = serialize("json", [model])[1:-1] + + # Convert the string into a dictionary and grab the `pk` + model_json = orjson.loads(serialized_model) + model_pk = model_json.get("pk") + + # Shuffle around the serialized pieces to condense the size of the payload + model_json = model_json.get("fields") + model_json["pk"] = model_pk + + # Set `pk` for models that subclass another model which only have `id` set + if not model_pk: + model_json["pk"] = model.pk or model.id + + # Add in m2m fields + m2m_field_names = _get_many_to_many_field_related_names(model) + + for m2m_field_name in m2m_field_names: + model_json[m2m_field_name] = _get_m2m_field_serialized(model, m2m_field_name) + + _handle_inherited_models(model, model_json) + + return model_json + + +def _json_serializer(obj): + """ + Handle the objects that the `orjson` deserializer can't handle automatically. + + The types handled by `orjson` by default: dataclass, datetime, enum, float, int, numpy, str, uuid. + The types handled in this class: Django Model, Django QuerySet, Decimal, or any object with `to_json` method. + + TODO: Investigate other ways to serialize objects automatically. + e.g. Using DRF serializer: https://www.django-rest-framework.org/api-guide/serializers/#serializing-objects + """ + from simmate.website.unicorn.components import UnicornView + + try: + if isinstance(obj, UnicornView): + return { + "name": obj.component_name, + "id": obj.component_id, + "key": obj.component_key, + } + elif isinstance(obj, Model): + return _get_model_dict(obj) + elif isinstance(obj, QuerySet): + queryset_json = [] + + for model in obj: + if obj.query.values_select and isinstance(model, dict): + # If the queryset was created with values it's already a dictionary + model_json = model + else: + model_json = _get_model_dict(model) + + queryset_json.append(model_json) + + return queryset_json + elif PydanticBaseModel and isinstance(obj, PydanticBaseModel): + return obj.dict() + elif isinstance(obj, Decimal): + return str(obj) + elif isinstance(obj, MappingProxyType): + # Return a regular dict for `mappingproxy` + return obj.copy() + elif hasattr(obj, "to_json"): + return obj.to_json() + except Exception as e: + # Log this because the `TypeError` and resulting stacktrace lacks context + logger.exception(e) + + raise TypeError + + +def _fix_floats( + current: Dict, data: Optional[Dict] = None, paths: Optional[List] = None +) -> None: + """ + Recursively change any Python floats to a string so that JavaScript + won't convert the float to an integer when deserializing. + + Params: + current: Dictionary in which to check for and fix floats. + """ + + if data is None: + data = current + + if paths is None: + paths = [] + + if isinstance(current, dict): + for key, val in current.items(): + paths.append(key) + _fix_floats(val, data, paths=paths) + paths.pop() + elif isinstance(current, list): + for idx, item in enumerate(current): + paths.append(idx) + _fix_floats(item, data, paths=paths) + paths.pop() + elif isinstance(current, float): + _piece = data + + for idx, path in enumerate(paths): + if idx == len(paths) - 1: + # `path` can be a dictionary key or list index, + # but in either instance it is set the same way + _piece[path] = str(current) + else: + _piece = _piece[path] + + +def _sort_dict(data: Dict) -> Dict: + """ + Recursively sort the dictionary keys so that JavaScript won't change the order + and change the generated checksum. + + Params: + data: Dictionary to sort. + """ + + if not isinstance(data, dict): + return data + + items = [ + [k, v] + for k, v in sorted( + data.items(), + key=lambda item: item[0] if not is_int(item[0]) else int(item[0]), + ) + ] + + for item in items: + if isinstance(item[1], dict): + item[1] = _sort_dict(item[1]) + + return dict(items) + + +def _exclude_field_attributes( + dict_data: Dict[Any, Any], exclude_field_attributes: Optional[Tuple[str]] = None +) -> None: + """ + Remove the field attribute from `dict_data`. Handles nested attributes with a dot. + + Example: + _exclude_field_attributes({"1": {"2": {"3": "4"}}}, ("1.2.3",)) == {"1": {"2": {}}} + """ + + if exclude_field_attributes: + for field in exclude_field_attributes: + field_splits = field.split(".") + nested_attribute_split_count = 2 + + if len(field_splits) > nested_attribute_split_count: + next_attribute_index = field.index(".") + 1 + remaining_field_attributes = field[next_attribute_index:] + remaining_dict_data = dict_data[field_splits[0]] + + return _exclude_field_attributes( + remaining_dict_data, (remaining_field_attributes,) + ) + elif len(field_splits) == nested_attribute_split_count: + (field_name, field_attr) = field_splits + + if field_name not in dict_data: + raise InvalidFieldNameError(field_name=field_name, data=dict_data) + + if dict_data[field_name] is not None: + if field_attr not in dict_data[field_name]: + raise InvalidFieldAttributeError( + field_name=field_name, field_attr=field_attr, data=dict_data + ) + + del dict_data[field_name][field_attr] + + +@lru_cache(maxsize=128, typed=True) +def _dumps( + serialized_data: bytes, + *, + fix_floats: bool = True, + exclude_field_attributes: Optional[Tuple[str]] = None, + sort_dict: bool = True, +) -> Dict: + """ + Dump serialized data with custom massaging. + + Features: + - fix floats + - remove specific keys as needed + - sort dictionary + """ + + data = orjson.loads(serialized_data) + + if fix_floats: + _fix_floats(data) + + if exclude_field_attributes: + # Excluding field attributes needs to de-serialize and then serialize again to + # handle complex objects + _exclude_field_attributes(data, exclude_field_attributes) + + if sort_dict: + # Sort dictionary manually because stringified integers don't get sorted + # correctly with `orjson.OPT_SORT_KEYS` and JavaScript will sort the keys + # as if they are integers + data = _sort_dict(data) + + return data + + +def dumps( + data: Dict, + *, + fix_floats: bool = True, + exclude_field_attributes: Optional[Tuple[str]] = None, + sort_dict: bool = True, +) -> str: + """ + Converts the passed-in dictionary to a string representation. + + Handles the following objects: dataclass, datetime, enum, float, int, numpy, str, uuid, + Django Model, Django QuerySet, Pydantic models (`PydanticBaseModel`), any object with `to_json` method. + + Args: + param fix_floats: Whether any floats should be converted to strings. Defaults to `True`, + but will be faster without it. + param exclude_field_attributes: Tuple of strings with field attributes to remove, i.e. "1.2" + to remove the key `2` from `{"1": {"2": "3"}}` + param sort_dict: Whether the `dict` should be sorted. Defaults to `True`, but + will be faster without it. + + Returns a `str` instead of `bytes` (which deviates from `orjson.dumps`), but seems more useful. + """ + + if exclude_field_attributes is not None and not is_non_string_sequence( + exclude_field_attributes + ): + raise AssertionError("exclude_field_attributes type needs to be a sequence") + + # Call `dumps` to make sure that complex objects are serialized correctly + serialized_data = orjson.dumps(data, default=_json_serializer) + + data = _dumps( + serialized_data, + fix_floats=fix_floats, + exclude_field_attributes=exclude_field_attributes, + sort_dict=sort_dict, + ) + + serialized_data = orjson.dumps(data) + + return serialized_data.decode("utf-8") + + +def loads(string: str) -> dict: + """ + Converts a string representation to dictionary. + """ + + try: + return orjson.loads(string) + except orjson.JSONDecodeError as e: + raise JSONDecodeError from e + + +def model_value(model: Model, *fields: str): + """ + Serializes a model into a dictionary with the fields as specified in the `fields` argument. + """ + + model_data = {} + model_dict = _get_model_dict(model) + + if not fields: + return model_dict + + for field in fields: + if field in model_dict: + model_data[field] = model_dict[field] + + return model_data diff --git a/src/simmate/website/unicorn/settings.py b/src/simmate/website/unicorn/settings.py new file mode 100644 index 000000000..c2413418e --- /dev/null +++ b/src/simmate/website/unicorn/settings.py @@ -0,0 +1,114 @@ +import logging +from warnings import warn + +from django.conf import settings + +logger = logging.getLogger(__name__) + + +SETTINGS_KEY = "UNICORN" +LEGACY_SETTINGS_KEY = f"DJANGO_{SETTINGS_KEY}" + +DEFAULT_MORPHER_NAME = "morphdom" +MORPHER_NAMES = ( + "morphdom", + "alpine", +) + + +def get_settings(): + unicorn_settings = {} + + if hasattr(settings, LEGACY_SETTINGS_KEY): + # TODO: Log deprecation message here + unicorn_settings = getattr(settings, LEGACY_SETTINGS_KEY) + elif hasattr(settings, SETTINGS_KEY): + unicorn_settings = getattr(settings, SETTINGS_KEY) + + return unicorn_settings + + +def get_setting(key, default=None): + unicorn_settings = get_settings() + + return unicorn_settings.get(key, default) + + +def get_serial_settings(): + return get_setting("SERIAL", {}) + + +def get_cache_alias(): + return get_setting("CACHE_ALIAS", "default") + + +def get_morpher_settings(): + options = get_setting("MORPHER", {"NAME": DEFAULT_MORPHER_NAME}) + + # Legacy `RELOAD_SCRIPT_ELEMENTS` setting that needs to go to `MORPHER.RELOAD_SCRIPT_ELEMENTS` + reload_script_elements = get_setting("RELOAD_SCRIPT_ELEMENTS") + + if reload_script_elements: + msg = 'The `RELOAD_SCRIPT_ELEMENTS` setting is deprecated. Use \ +`MORPHER["RELOAD_SCRIPT_ELEMENTS"]` instead.' + warn(msg, DeprecationWarning, stacklevel=1) + + options["RELOAD_SCRIPT_ELEMENTS"] = reload_script_elements + + if not options.get("NAME"): + options["NAME"] = DEFAULT_MORPHER_NAME + + morpher_name = options["NAME"] + + if not morpher_name or morpher_name not in MORPHER_NAMES: + raise AssertionError(f"Unknown morpher name: {morpher_name}") + + return options + + +def get_script_location(): + """ + Valid choices: "append", "after". Default is "after". + """ + + return get_setting("SCRIPT_LOCATION", "after") + + +def get_serial_enabled(): + """ + Default serial enabled is `False`. + """ + enabled = get_serial_settings().get("ENABLED", False) + + if enabled and settings.CACHES: + cache_alias = get_cache_alias() + cache_settings = settings.CACHES.get(cache_alias, {}) + cache_backend = cache_settings.get("BACKEND") + + if cache_backend == "django.core.cache.backends.dummy.DummyCache": + return False + + return enabled + + +def get_serial_timeout(): + """ + Default serial timeout is 60 seconds. + """ + return get_serial_settings().get("TIMEOUT", 60) + + +def get_minify_html_enabled(): + minify_html_enabled = get_setting("MINIFY_HTML", False) + + if minify_html_enabled: + try: + import htmlmin # noqa: F401 + except ModuleNotFoundError: + logger.error( + "MINIFY_HTML is `True`, but minify extra could not be imported. Install with `django-unicorn[minify]`." + ) + + return False + + return minify_html_enabled diff --git a/src/simmate/website/unicorn/static/unicorn/js/.babelrc.json b/src/simmate/website/unicorn/static/unicorn/js/.babelrc.json new file mode 100644 index 000000000..ee11ba5d8 --- /dev/null +++ b/src/simmate/website/unicorn/static/unicorn/js/.babelrc.json @@ -0,0 +1,10 @@ +{ + "presets": [ + [ + "@babel/env", + { + "modules": false + } + ] + ] +} \ No newline at end of file diff --git a/src/simmate/website/unicorn/static/unicorn/js/attribute.js b/src/simmate/website/unicorn/static/unicorn/js/attribute.js new file mode 100644 index 000000000..54ef2552e --- /dev/null +++ b/src/simmate/website/unicorn/static/unicorn/js/attribute.js @@ -0,0 +1,91 @@ +import { contains } from "./utils.js"; + +/** + * Encapsulate DOM element attribute for Unicorn-related information. + */ +export class Attribute { + constructor(attribute) { + this.attribute = attribute; + this.name = this.attribute.name; + this.value = this.attribute.value; + this.isUnicorn = false; + this.isModel = false; + this.isPoll = false; + this.isLoading = false; + this.isTarget = false; + this.isPartial = false; + this.isDirty = false; + this.isVisible = false; + this.isKey = false; + this.isError = false; + this.modifiers = {}; + this.eventType = null; + + this.init(); + } + + /** + * Init the attribute. + */ + init() { + if (this.name.startsWith("unicorn:") || this.name.startsWith("u:")) { + this.isUnicorn = true; + + // Use `contains` when there could be modifiers + if (contains(this.name, ":model")) { + this.isModel = true; + } else if (contains(this.name, ":poll.disable")) { + this.isPollDisable = true; + } else if (contains(this.name, ":poll")) { + this.isPoll = true; + } else if (contains(this.name, ":loading")) { + this.isLoading = true; + } else if (contains(this.name, ":target")) { + this.isTarget = true; + } else if (contains(this.name, ":partial")) { + this.isPartial = true; + } else if (contains(this.name, ":dirty")) { + this.isDirty = true; + } else if (contains(this.name, ":visible")) { + this.isVisible = true; + } else if (this.name === "unicorn:key" || this.name === "u:key") { + this.isKey = true; + } else if (contains(this.name, ":error:")) { + this.isError = true; + } else { + const actionEventType = this.name + .replace("unicorn:", "") + .replace("u:", ""); + + if ( + actionEventType !== "id" && + actionEventType !== "name" && + actionEventType !== "checksum" + ) { + this.eventType = actionEventType; + } + } + + let potentialModifiers = this.name; + + if (this.eventType) { + potentialModifiers = this.eventType; + } + + // Find modifiers and any potential arguments + potentialModifiers + .split(".") + .slice(1) + .forEach((modifier) => { + const modifierArgs = modifier.split("-"); + this.modifiers[modifierArgs[0]] = + modifierArgs.length > 1 ? modifierArgs[1] : true; + + // Remove any modifier from the event type + if (this.eventType) { + this.eventType = this.eventType.replace(`.${modifier}`, ""); + } + }); + } + } +} diff --git a/src/simmate/website/unicorn/static/unicorn/js/component.js b/src/simmate/website/unicorn/static/unicorn/js/component.js new file mode 100644 index 000000000..a3f001552 --- /dev/null +++ b/src/simmate/website/unicorn/static/unicorn/js/component.js @@ -0,0 +1,601 @@ +import { debounce } from "./delayers.js"; +import { Element } from "./element.js"; +import { + addActionEventListener, + addModelEventListener, +} from "./eventListeners.js"; +import { components, lifecycleEvents } from "./store.js"; +import { send } from "./messageSender.js"; +import { + $, + hasValue, + isEmpty, + isFunction, + walk, + FilterSkipNested, +} from "./utils.js"; + +/** + * Encapsulate component. + */ +export class Component { + constructor(args) { + this.id = args.id; + this.name = args.name; + this.key = args.key; + this.messageUrl = args.messageUrl; + this.csrfTokenHeaderName = args.csrfTokenHeaderName; + this.csrfTokenCookieName = args.csrfTokenCookieName; + this.hash = args.hash; + this.data = args.data || {}; + this.syncUrl = `${this.messageUrl}/${this.name}`; + + this.document = args.document || document; + this.walker = args.walker || walk; + this.window = args.window || window; + this.morpher = args.morpher; + + this.root = undefined; + this.modelEls = []; + this.loadingEls = []; + this.keyEls = []; + this.visibilityEls = []; + this.errors = {}; + this.return = {}; + this.poll = {}; + + this.actionQueue = []; + this.currentActionQueue = null; + this.lastTriggeringElements = []; + + this.actionEvents = {}; + this.attachedEventTypes = []; + this.attachedModelEvents = []; + + this.init(); + this.refreshEventListeners(); + this.initVisibility(); + this.initPolling(); + + this.callCalls(args.calls); + } + + /** + * Initializes the Component. + */ + init() { + this.root = $(`[unicorn\\:id="${this.id}"]`, this.document); + + if (!this.root) { + throw Error("No id found"); + } + + this.refreshChecksum(); + } + + /** + * Gets the children components of the current component. + */ + getChildrenComponents() { + const elements = []; + + this.walker(this.root, (el) => { + if (el.isSameNode(this.root)) { + // Skip the component root element + return; + } + + const componentId = el.getAttribute("unicorn:id"); + + if (componentId) { + const childComponent = components[componentId] || null; + + if (childComponent) { + elements.push(childComponent); + } + } + }); + + return elements; + } + + /** + * Gets the parent component of the current component. + * @param {int} parentComponentId Parent component id. + */ + getParentComponent(parentComponentId) { + if (typeof parentComponentId !== "undefined") { + return components[parentComponentId] || null; + } + + let currentEl = this.root; + let parentComponent = null; + + while (!parentComponent && currentEl.parentElement !== null) { + currentEl = currentEl.parentElement; + const componentId = currentEl.getAttribute("unicorn:id"); + + if (componentId) { + parentComponent = components[componentId] || null; + } + } + + return parentComponent; + } + + /** + * Call JavaScript functions on the `window`. + * @param {Array} calls A list of objects that specify the methods to call. + * + * `calls`: [{"fn": "someFunctionName"},] + * `calls`: [{"fn": "someFunctionName", args: ["world"]},] + * `calls`: [{"fn": "SomeModule.someFunctionName"},] + * `calls`: [{"fn": "SomeModule.someFunctionName", args: ["world", "universe"]},] + * + * Returns: + * Array of results for each method call. + */ + callCalls(calls) { + calls = calls || []; + const results = []; + + calls.forEach((call) => { + let functionName = call.fn; + let module = this.window; + + call.fn.split(".").forEach((obj, idx) => { + // only traverse down modules to the first dot, because the last portion is the function name + if (idx < call.fn.split(".").length - 1) { + module = module[obj]; + // account for the period when slicing + functionName = functionName.slice(obj.length + 1); + } + }); + + if (call.args) { + results.push(module[functionName](...call.args)); + } else { + results.push(module[functionName]()); + } + }); + + return results; + } + + /** + * Sets event listeners on unicorn elements. + */ + refreshEventListeners() { + this.actionEvents = {}; + this.modelEls = []; + this.loadingEls = []; + this.visibilityEls = []; + + try { + this.walker( + this.root, + (el) => { + if (el.isSameNode(this.root)) { + // Skip the component root element + return; + } + + const element = new Element(el); + + if (element.isUnicorn) { + if (hasValue(element.model)) { + if (!this.attachedModelEvents.some((e) => e.isSame(element))) { + this.attachedModelEvents.push(element); + addModelEventListener(this, element); + + // If a model is lazy, also add an event listener for input for dirty states + if (element.model.isLazy) { + // This input event for isLazy will be stopped after dirty is checked when the event fires + addModelEventListener(this, element, "input"); + } + } + + if (!this.modelEls.some((e) => e.isSame(element))) { + this.modelEls.push(element); + } + } else if (hasValue(element.loading)) { + this.loadingEls.push(element); + + // Hide loading elements that are shown when an action happens + if (element.loading.show) { + element.hide(); + } + } + + if (hasValue(element.key)) { + this.keyEls.push(element); + } + + if (hasValue(element.visibility)) { + this.visibilityEls.push(element); + } + + element.actions.forEach((action) => { + if (this.actionEvents[action.eventType]) { + this.actionEvents[action.eventType].push({ action, element }); + } else { + this.actionEvents[action.eventType] = [{ action, element }]; + + if ( + !this.attachedEventTypes.some((et) => et === action.eventType) + ) { + this.attachedEventTypes.push(action.eventType); + addActionEventListener(this, action.eventType); + element.events.push(action.eventType); + } + } + }); + } + }, + FilterSkipNested + ); + } catch (err) { + // nothing + } + } + + /** + * Calls the method for a particular component. + */ + callMethod(methodName, debounceTime, partials, errCallback) { + const action = { + type: "callMethod", + payload: { name: methodName }, + partials, + }; + this.actionQueue.push(action); + + // Debounce timeout defaults to 0 in element.js to remove any perceived lag, but can be overridden + this.queueMessage(debounceTime, (triggeringElements, _, err) => { + if (err && isFunction(errCallback)) { + errCallback(err); + } else if (err) { + console.error(err); + } else { + // Can hard-code `forceModelUpdate` to `true` since it is always required for + // `callMethod` actions + this.setModelValues(triggeringElements, true, true); + } + }); + } + + /** + * Initializes `visible` elements. + */ + initVisibility() { + if ( + typeof window !== "undefined" && + "IntersectionObserver" in window && + "IntersectionObserverEntry" in window && + "intersectionRatio" in window.IntersectionObserverEntry.prototype + ) { + this.visibilityEls.forEach((element) => { + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + + if (entry.isIntersecting) { + this.callMethod( + element.visibility.method, + element.visibility.debounceTime, + element.partials, + (err) => { + if (err) { + console.error(err); + } + } + ); + } + }, + { threshold: [element.visibility.threshold] } + ); + + observer.observe(element.el); + }); + } + } + + /** + * Handles poll errors. + * @param {Error} err Error. + */ + handlePollError(err) { + if (err) { + console.error(err); + } else if (this.poll.timer) { + clearInterval(this.poll.timer); + } + } + + /** + * Check to see if the poll is disabled. + */ + isPollEnabled() { + if (!this.poll.disable) { + if (hasValue(this.poll.disableData)) { + if (this.poll.disableData.startsWith("!")) { + // Manually negate this and re-negate it after the check + this.poll.disableData = this.poll.disableData.slice(1); + + if (this.data[this.poll.disableData]) { + this.poll.disableData = `!${this.poll.disableData}`; + + return true; + } + + this.poll.disableData = `!${this.poll.disableData}`; + } else if (!this.data[this.poll.disableData]) { + return true; + } + } else { + return true; + } + } + + return false; + } + + /** + * Sets up polling if it is defined on the component's root. + */ + initPolling() { + const rootElement = new Element(this.root); + + if (rootElement.isUnicorn && hasValue(rootElement.poll)) { + this.poll = rootElement.poll; + this.poll.timer = null; + + this.document.addEventListener( + "visibilitychange", + () => { + if (this.document.hidden) { + if (this.poll.timer) { + clearInterval(this.poll.timer); + } + } else { + // Call the poll method once the tab is visible again + this.startPolling(true); + } + }, + false + ); + + this.poll.partials = rootElement.partials; + + // Call the method once before the timer starts + this.startPolling(true); + } + } + + /** + * Starts polling and handles stopping the polling if there is an error. + */ + startPolling(fireImmediately) { + if (fireImmediately && this.isPollEnabled()) { + this.callMethod( + this.poll.method, + 0, + this.poll.partials, + this.handlePollError + ); + } + + this.poll.timer = setInterval(() => { + if (this.isPollEnabled()) { + this.callMethod( + this.poll.method, + 0, + this.poll.partials, + this.handlePollError + ); + } + }, this.poll.timing); + } + + /** + * Refresh the checksum. + */ + refreshChecksum() { + this.checksum = this.root.getAttribute("unicorn:checksum"); + } + + /** + * Sets the value of an element. Tries to deal with HTML weirdnesses. + * @param {Element} element Element to set value to (value retrieved from `component.data`). + */ + setValue(element) { + if (isEmpty(element.model)) { + // Don't try to set the value if there isn't a model on the element + return; + } + + this.setNestedValue(element, element.model.name, this.data); + } + + /** + * Sets an element value dealing with potential nested values + * @param {Element} element Element to set values on + * @param {String} name Name of the property + * @param {Object} data Data to get values from + */ + // eslint-disable-next-line class-methods-use-this + setNestedValue(element, name, data) { + const namePieces = name.split("."); + // Get local version of data in case have to traverse into a nested property + let _data = data; + + for (let i = 0; i < namePieces.length; i++) { + const namePiece = namePieces[i]; + + if (_data == null) { + return; + } + + if (Object.prototype.hasOwnProperty.call(_data, namePiece)) { + if (i === namePieces.length - 1) { + element.setValue(_data[namePiece]); + } else { + _data = _data[namePiece]; + } + } + } + } + + /** + * Sets all model values. + * @param {[Element]} triggeringElements The elements that triggered the event. + */ + setModelValues(triggeringElements, forceModelUpdates, updateParents) { + triggeringElements = triggeringElements || []; + forceModelUpdates = forceModelUpdates || false; + updateParents = updateParents || false; + + let lastTriggeringElement = null; + + // Focus on the last element which triggered the update. + // Prevents validation errors from stealing focus. + if (triggeringElements.length > 0) { + let elementFocused = false; + lastTriggeringElement = triggeringElements.slice(-1)[0]; + + if ( + hasValue(lastTriggeringElement) && + hasValue(lastTriggeringElement.model) && + !lastTriggeringElement.model.isLazy + ) { + ["id", "key"].forEach((attr) => { + this.modelEls.forEach((element) => { + if (!elementFocused) { + if ( + lastTriggeringElement[attr] && + lastTriggeringElement[attr] === element[attr] + ) { + element.focus(); + elementFocused = true; + } + } + }); + }); + } + } + + this.modelEls.forEach((element) => { + if ( + forceModelUpdates || + !lastTriggeringElement || + !lastTriggeringElement.isSame(element) + ) { + this.setValue(element); + } + }); + + // Re-set model values for all children + this.getChildrenComponents().forEach((childComponent) => { + childComponent.setModelValues( + triggeringElements, + forceModelUpdates, + false + ); + }); + + if (updateParents) { + const parent = this.getParentComponent(); + + if (parent) { + parent.setModelValues( + triggeringElements, + forceModelUpdates, + updateParents + ); + } + } + } + + /** + * Queues the `messageSender.send` call. + */ + queueMessage(debounceTime, callback) { + if (debounceTime === -1) { + debounce(send, 250, false)(this, callback); + } else { + debounce(send, debounceTime, false)(this, callback); + } + } + + /** + * Triggers the event's callback if it is defined. + * @param {String} eventName The event name to trigger. Current options: "updated". + */ + triggerLifecycleEvent(eventName) { + if (eventName in lifecycleEvents) { + lifecycleEvents[eventName].forEach((cb) => cb(this)); + } + } + + /** + * Manually trigger a model's `input` or `blur` event to force a component update. + * + * Useful when setting an element's value manually which won't trigger the correct event to fire. + * @param {String} key Key of the element. + */ + trigger(key) { + this.modelEls.forEach((element) => { + if (element.key === key) { + const eventType = element.model.isLazy ? "blur" : "input"; + element.el.dispatchEvent(new Event(eventType)); + } + }); + } + + /** + * Replace the target DOM with the rerendered component. + * + * The function updates the DOM, and updates the Unicorn component store by deleting + * components that were removed, and adding new components. + */ + morph(targetDom, rerenderedComponent) { + if (!rerenderedComponent) { + return; + } + + // Helper function that returns an array of nodes with an attribute unicorn:id + const findUnicorns = () => [ + ...targetDom.querySelectorAll("[unicorn\\:id]"), + ]; + + // Find component IDs before morphing + const componentIdsBeforeMorph = new Set( + findUnicorns().map((el) => el.getAttribute("unicorn:id")) + ); + + // Morph + this.morpher.morph(targetDom, rerenderedComponent); + + // Find all component IDs after morphing + const componentIdsAfterMorph = new Set( + findUnicorns().map((el) => el.getAttribute("unicorn:id")) + ); + + // Delete components that were removed + const removedComponentIds = [...componentIdsBeforeMorph].filter( + (id) => !componentIdsAfterMorph.has(id) + ); + removedComponentIds.forEach((id) => { + Unicorn.deleteComponent(id); + }); + + // Populate Unicorn with new components + findUnicorns().forEach((el) => { + Unicorn.insertComponentFromDom(el); + }); + } + + morphRoot(rerenderedComponent) { + this.morph(this.root, rerenderedComponent); + } +} diff --git a/src/simmate/website/unicorn/static/unicorn/js/delayers.js b/src/simmate/website/unicorn/static/unicorn/js/delayers.js new file mode 100644 index 000000000..0e4d8f3f1 --- /dev/null +++ b/src/simmate/website/unicorn/static/unicorn/js/delayers.js @@ -0,0 +1,68 @@ +/** + * Returns a function, that, as long as it continues to be invoked, will not + * be triggered. The function will be called after it stops being called for + * N milliseconds. If `immediate` is passed, trigger the function on the + * leading edge, instead of the trailing. + + * Derived from underscore.js's implementation in https://davidwalsh.name/javascript-debounce-function. + */ +export function debounce(func, wait, immediate) { + let timeout; + + if (typeof immediate === "undefined") { + immediate = true; + } + + return (...args) => { + const context = this; + + const later = () => { + timeout = null; + if (!immediate) { + func.apply(context, args); + } + }; + + const callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + + if (callNow) { + func.apply(context, args); + } + }; +} + +/** + * The function is executed the number of times it is called, + * but there is a fixed wait time before each execution. + * From https://medium.com/ghostcoder/debounce-vs-throttle-vs-queue-execution-bcde259768. + */ +const funcQueue = []; +export function queue(func, waitTime) { + let isWaiting; + + const play = () => { + let params; + isWaiting = false; + + if (funcQueue.length) { + params = funcQueue.shift(); + executeFunc(params); + } + }; + + const executeFunc = (params) => { + isWaiting = true; + func(params); + setTimeout(play, waitTime); + }; + + return (params) => { + if (isWaiting) { + funcQueue.push(params); + } else { + executeFunc(params); + } + }; +} diff --git a/src/simmate/website/unicorn/static/unicorn/js/element.js b/src/simmate/website/unicorn/static/unicorn/js/element.js new file mode 100644 index 000000000..43ab3d198 --- /dev/null +++ b/src/simmate/website/unicorn/static/unicorn/js/element.js @@ -0,0 +1,355 @@ +import { Attribute } from "./attribute.js"; +import { isEmpty, hasValue } from "./utils.js"; + +/** + * Encapsulate DOM element for Unicorn-related information. + */ +export class Element { + constructor(el) { + this.el = el; + this.init(); + } + + /** + * Init the element. + */ + init() { + this.id = this.el.id; + this.isUnicorn = false; + this.attributes = []; + this.value = this.getValue(); + this.parent = null; + + if (this.el.parentElement) { + this.parent = new Element(this.el.parentElement); + } + + this.model = {}; + this.poll = {}; + this.loading = {}; + this.dirty = {}; + this.actions = []; + this.partials = []; + this.target = null; + this.visibility = {}; + this.key = null; + this.events = []; + this.errors = []; + + if (!this.el.attributes) { + return; + } + + for (let i = 0; i < this.el.attributes.length; i++) { + const attribute = new Attribute(this.el.attributes[i]); + this.attributes.push(attribute); + + if (attribute.isUnicorn) { + this.isUnicorn = true; + } + + if (attribute.isModel) { + const key = "model"; + + this[key].name = attribute.value; + this[key].eventType = attribute.modifiers.lazy ? "blur" : "input"; + this[key].isLazy = !!attribute.modifiers.lazy; + this[key].isDefer = !!attribute.modifiers.defer; + this[key].debounceTime = attribute.modifiers.debounce + ? parseInt(attribute.modifiers.debounce, 10) || -1 + : -1; + } else if (attribute.isPoll) { + this.poll.method = attribute.value ? attribute.value : "refresh"; + this.poll.timing = 2000; + this.poll.disable = false; + + const pollArgs = attribute.name.split("-").slice(1); + + if (pollArgs.length > 0) { + this.poll.timing = parseInt(pollArgs[0], 10) || 2000; + } + } else if (attribute.isPollDisable) { + this.poll.disableData = attribute.value; + } else if (attribute.isLoading || attribute.isDirty) { + let key = "dirty"; + + if (attribute.isLoading) { + key = "loading"; + } + + if (attribute.modifiers.attr) { + this[key].attr = attribute.value; + } else if (attribute.modifiers.class && attribute.modifiers.remove) { + this[key].removeClasses = attribute.value.split(" "); + } else if (attribute.modifiers.class) { + this[key].classes = attribute.value.split(" "); + } else if (attribute.isLoading && attribute.modifiers.remove) { + this.loading.hide = true; + } else if (attribute.isLoading) { + this.loading.show = true; + } + } else if (attribute.isTarget) { + this.target = attribute.value; + } else if (attribute.isPartial) { + if (attribute.modifiers.id) { + this.partials.push({ id: attribute.value }); + } else if (attribute.modifiers.key) { + this.partials.push({ key: attribute.value }); + } else { + this.partials.push({ target: attribute.value }); + } + } else if (attribute.isVisible) { + let threshold = attribute.modifiers.threshold || 0; + + if (threshold > 1) { + // Convert the whole number into a percentage + threshold /= 100; + } + + this.visibility.method = attribute.value; + this.visibility.threshold = threshold; + this.visibility.debounceTime = attribute.modifiers.debounce + ? parseInt(attribute.modifiers.debounce, 10) || 0 + : 0; + } else if (attribute.eventType) { + const action = {}; + action.name = attribute.value; + action.eventType = attribute.eventType; + action.isPrevent = false; + action.isStop = false; + action.isDiscard = false; + action.debounceTime = 0; + + if (attribute.modifiers) { + Object.keys(attribute.modifiers).forEach((modifier) => { + if (modifier === "prevent") { + action.isPrevent = true; + } else if (modifier === "stop") { + action.isStop = true; + } else if (modifier === "discard") { + action.isDiscard = true; + } else if (modifier === "debounce") { + action.debounceTime = attribute.modifiers.debounce + ? parseInt(attribute.modifiers.debounce, 10) || 0 + : 0; + } else { + // Assume the modifier is a keycode + action.key = modifier; + } + }); + } + + this.actions.push(action); + } + + if (attribute.isKey) { + this.key = attribute.value; + } + + if (attribute.isError) { + const code = attribute.name.replace("unicorn:error:", ""); + this.errors.push({ code, message: attribute.value }); + } + } + } + + /** + * Focuses the element. + */ + focus() { + this.el.focus(); + } + + /** + * Hide the element. + */ + hide() { + this.el.hidden = "hidden"; + } + + /** + * Show the element. + */ + show() { + this.el.hidden = null; + } + + /** + * Get the element's next parent that is a unicorn element. + * + * Returns `null` if no unicorn element can be found before the root. + */ + getUnicornParent() { + let parentElement = this.parent; + + while (parentElement && !parentElement.isUnicorn) { + if (parentElement.isRoot()) { + return null; + } + + parentElement = parentElement.parent; + } + + return parentElement; + } + + /** + * Handle loading for the element. + * @param {bool} revert Whether or not the revert the loading class. + */ + handleLoading(revert) { + this.handleInterfacer("loading", revert); + } + + /** + * Handle dirty for the element. + * @param {bool} revert Whether or not the revert the dirty class. + */ + handleDirty(revert) { + this.handleInterfacer("dirty", revert); + } + + /** + * Handle interfacers for the element. + * @param {string} interfacerType The type of interfacer. Either "dirty" or "loading". + * @param {bool} revert Whether or not the revert the interfacer. + */ + handleInterfacer(interfacerType, revert) { + revert = revert || false; + + if (hasValue(this[interfacerType])) { + if (this[interfacerType].attr) { + if (revert) { + this.el.removeAttribute(this[interfacerType].attr); + } else { + this.el.setAttribute( + this[interfacerType].attr, + this[interfacerType].attr + ); + } + } + + if (this[interfacerType].classes) { + if (revert) { + this.el.classList.remove(...this[interfacerType].classes); + + // Remove the class attribute if it's empty so that morphdom sees the node as equal + if (this.el.classList.length === 0) { + this.el.removeAttribute("class"); + } + } else { + this.el.classList.add(...this[interfacerType].classes); + } + } + + if (this[interfacerType].removeClasses) { + if (revert) { + this.el.classList.add(...this[interfacerType].removeClasses); + } else { + this.el.classList.remove(...this[interfacerType].removeClasses); + } + } + } + } + + /** + * Check if another `Element` is the same as this `Element`. Uses `isSameNode` behind the scenes. + * @param {Element} other + */ + isSame(other) { + // Use isSameNode (not isEqualNode) because we want to check the nodes reference the same object + return this.isSameEl(other.el); + } + + /** + * Check if a DOM element is the same as this `Element`. Uses `isSameNode` behind the scenes. + * @param {El} DOM el + */ + isSameEl(el) { + // Use isSameNode (not isEqualNode) because we want to check the nodes reference the same object + return this.el.isSameNode(el); + } + + /** + * Check if another `Element` is the same as this `Element` by checking the key and id. + * @param {Element} other + */ + isSameId(other) { + return ( + (this.key && other.key && this.key === other.key) || + (this.id && other.id && this.id === other.id) + ); + } + + /** + * Gets the value from the element. + */ + getValue() { + let { value } = this.el; + + if (this.el.type) { + if (this.el.type.toLowerCase() === "checkbox") { + // Handle checkbox + value = this.el.checked; + } else if (this.el.type.toLowerCase() === "select-multiple") { + // Handle multiple select options + value = []; + for (let i = 0; i < this.el.selectedOptions.length; i++) { + value.push(this.el.selectedOptions[i].value); + } + } + } + + return value; + } + + /** + * Sets the value of an element. Tries to deal with HTML weirdnesses. + */ + setValue(val) { + if (isEmpty(this.el.type)) { + return; + } + + if (this.el.type.toLowerCase() === "radio") { + // Handle radio buttons + if (this.el.value === val) { + this.el.checked = true; + } + } else if (this.el.type.toLowerCase() === "checkbox") { + // Handle checkboxes + this.el.checked = val; + } else if (this.el.type.toLowerCase() === "select-one" && val == null) { + // Do not set null value for select elements because it clears the display + } else { + this.el.value = val; + } + } + + /** + * Add an error to the element. + */ + addError(error) { + this.errors.push(error); + this.el.setAttribute(`unicorn:error:${error.code}`, error.message); + } + + /** + * Remove all errors from the element. + */ + removeErrors() { + this.errors.forEach((error) => { + this.el.removeAttribute(`unicorn:error:${error.code}`); + }); + + this.errors = []; + } + + /** + * Whether the element is a root node or not. + */ + isRoot() { + // A litte hacky, but assume that an element with `unicorn:checksum` is a component root + return hasValue(this.el.getAttribute("unicorn:checksum")); + } +} diff --git a/src/simmate/website/unicorn/static/unicorn/js/eventListeners.js b/src/simmate/website/unicorn/static/unicorn/js/eventListeners.js new file mode 100644 index 000000000..8dce332dd --- /dev/null +++ b/src/simmate/website/unicorn/static/unicorn/js/eventListeners.js @@ -0,0 +1,320 @@ +import { $, args, hasValue, toKebabCase, toRegExp } from "./utils.js"; +import { Element } from "./element.js"; + +/** + * Handles loading elements in the component. + * @param {Component} component Component. + * @param {Element} targetElement Targetted element. + */ +function handleLoading(component, targetElement) { + targetElement.handleLoading(); + + // Look at all elements with a loading attribute + component.loadingEls.forEach((loadingElement) => { + if (loadingElement.target) { + // Get all ID matches + if (loadingElement.target.includes("*")) { + const targetRegex = toRegExp(loadingElement.target); + const targetedElArray = []; + const childrenCollection = component.root.getElementsByTagName("*"); + [...childrenCollection].forEach((child) => { + [...child.attributes].forEach((attr) => { + if ( + ["id", "unicorn:key", "u:key"].includes(attr.name) && + attr.value.match(targetRegex) + ) { + targetedElArray.push(child); + } + }); + }); + + targetedElArray.forEach((targetedEl) => { + if (targetElement.el.isSameNode(targetedEl)) { + loadingElement.handleLoading(); + if (loadingElement.loading.hide) { + loadingElement.hide(); + } else if (loadingElement.loading.show) { + loadingElement.show(); + } + } + }); + } else { + let targetedEl = $(`#${loadingElement.target}`, component.root); + + if (!targetedEl) { + component.keyEls.forEach((keyElement) => { + if (!targetedEl && keyElement.key === loadingElement.target) { + targetedEl = keyElement.el; + } + }); + } + + if (targetedEl) { + if (targetElement.el.isSameNode(targetedEl)) { + loadingElement.handleLoading(); + if (loadingElement.loading.hide) { + loadingElement.hide(); + } else if (loadingElement.loading.show) { + loadingElement.show(); + } + } + } + } + } else { + loadingElement.handleLoading(); + + if (loadingElement.loading.hide) { + loadingElement.hide(); + } else if (loadingElement.loading.show) { + loadingElement.show(); + } + } + }); +} + +/** + * Parse arguments and deal with nested data. + * + * // + * let data = {hello: " world "}; + * let output = parseEventArg(data, "$returnValue.hello.trim()"); + * // output is 'world' + * + * @param {Object} data The data that should be parsed. + * @param {String} arg The argument to the function. + * @param {String} specialArgName The special argument (starts with a $). + */ +function parseEventArg(data, arg, specialArgName) { + // Remove any extra whitespace, everything before and including "$event", and the ending paren + arg = arg + .trim() + .slice(arg.indexOf(specialArgName) + specialArgName.length) + .trim(); + + arg.split(".").forEach((piece) => { + piece = piece.trim(); + + if (piece) { + // TODO: Handle method calls with args + if (piece.endsWith("()")) { + // method call + const methodName = piece.slice(0, piece.length - 2); + data = data[methodName](); + } else if (hasValue(data[piece])) { + data = data[piece]; + } else { + throw Error(`'${piece}' could not be retrieved`); + } + } + }); + + if (typeof data === "string") { + // Wrap strings in quotes + data = `"${data}"`; + } + + return data; +} + +/** + * Adds an action event listener to the document for each type of event (e.g. click, keyup, etc). + * Added at the document level because validation errors would sometimes remove the + * events when attached directly to the element. + * @param {Component} component Component that contains the element. + * @param {string} eventType Event type to listen for. + */ +export function addActionEventListener(component, eventType) { + component.document.addEventListener(eventType, (event) => { + let targetElement = new Element(event.target); + + // Make sure that the target element is a unicorn element. + // Handles events fired from an element inside a unicorn element + // e.g. + if (targetElement && !targetElement.isUnicorn) { + targetElement = targetElement.getUnicornParent(); + } + + if ( + targetElement && + targetElement.isUnicorn && + targetElement.actions.length > 0 && + eventType in component.actionEvents + ) { + component.actionEvents[eventType].forEach((actionEvent) => { + const { action } = actionEvent; + const { element } = actionEvent; + + if (targetElement.isSame(element)) { + // Add the value of any child element of the target that is a lazy model to the action queue + // Handles situations similar to https://github.com/livewire/livewire/issues/528 + component.walker(element.el, (childEl) => { + const modelElsInTargetScope = component.modelEls.filter((e) => + e.isSameEl(childEl) + ); + + modelElsInTargetScope.forEach((modelElement) => { + if (hasValue(modelElement.model) && modelElement.model.isLazy) { + const actionForQueue = { + type: "syncInput", + payload: { + name: modelElement.model.name, + value: modelElement.getValue(), + }, + }; + component.actionQueue.push(actionForQueue); + } + }); + }); + + if (action.isPrevent) { + event.preventDefault(); + } + + if (action.isStop) { + event.stopPropagation(); + } + + if (action.isDiscard) { + // Remove all existing action events in the queue + component.actionQueue = []; + } + + let actionName = action.name; + // Handle special arguments (e.g. $event) + args(actionName).forEach((eventArg) => { + if (eventArg.startsWith("$event")) { + try { + const data = parseEventArg(event, eventArg, "$event"); + actionName = actionName.replace(eventArg, data); + } catch (err) { + // console.error(err); + actionName = actionName.replace(eventArg, ""); + } + } else if (eventArg.startsWith("$returnValue")) { + if ( + hasValue(component.return) && + hasValue(component.return.value) + ) { + try { + const data = parseEventArg( + component.return.value, + eventArg, + "$returnValue" + ); + actionName = actionName.replace(eventArg, data); + } catch (err) { + actionName = actionName.replace(eventArg, ""); + } + } else { + actionName = actionName.replace(eventArg, ""); + } + } + }); + + if (!action.key || action.key === toKebabCase(event.key)) { + handleLoading(component, targetElement); + component.callMethod( + actionName, + action.debounceTime, + targetElement.partials + ); + } + } + }); + } + }); +} + +/** + * Adds a model event listener to the element. + * @param {Component} component Component that contains the element. + * @param {Element} Element that will get the event attached. + * @param {string} eventType Event type to listen for. Optional; will use `model.eventType` by default. + */ +export function addModelEventListener(component, element, eventType) { + eventType = eventType || element.model.eventType; + element.events.push(eventType); + const { el } = element; + + el.addEventListener(eventType, (event) => { + let isDirty = false; + + if (component.data[element.model.name] !== element.getValue()) { + isDirty = true; + element.handleDirty(); + } else { + element.handleDirty(true); + } + + if (element.model.isLazy) { + // Lazy models fire an input and blur so that the dirty check above works as expected. + // This will prevent the input event from doing anything. + if (eventType === "input") { + return; + } + + // Lazy non-dirty elements can bail + if (!isDirty) { + return; + } + } + + const action = { + type: "syncInput", + payload: { + name: element.model.name, + value: element.getValue(), + }, + partials: element.partials, + }; + + if (!component.lastTriggeringElements.some((e) => e.isSame(element))) { + component.lastTriggeringElements.push(element); + } + + if (element.model.isDefer) { + let foundActionIdx = -1; + + // Update the existing action with the current value + component.actionQueue.forEach((a, idx) => { + if (a.payload.name === element.model.name) { + a.payload.value = element.getValue(); + foundActionIdx = idx; + } + }); + + // Add a new action + if (isDirty && foundActionIdx === -1) { + component.actionQueue.push(action); + } + + // Remove the found action that isn't dirty + if (!isDirty && foundActionIdx > -1) { + component.actionQueue.splice(foundActionIdx); + } + + return; + } + + component.actionQueue.push(action); + + component.queueMessage( + element.model.debounceTime, + (triggeringElements, forceModelUpdate, err) => { + if (err) { + console.error(err); + } else { + triggeringElements = triggeringElements || []; + + // Make sure that the current element is included in the triggeringElements + // if for some reason it is missing + if (!triggeringElements.some((e) => e.isSame(element))) { + triggeringElements.push(element); + } + + component.setModelValues(triggeringElements, forceModelUpdate, true); + } + } + ); + }); +} diff --git a/src/simmate/website/unicorn/static/unicorn/js/messageSender.js b/src/simmate/website/unicorn/static/unicorn/js/messageSender.js new file mode 100644 index 000000000..6d66e368e --- /dev/null +++ b/src/simmate/website/unicorn/static/unicorn/js/messageSender.js @@ -0,0 +1,303 @@ +import { $, getCsrfToken, hasValue, isFunction } from "./utils.js"; + +/** + * Calls the message endpoint and merges the results into the document. + */ +export function send(component, callback) { + // Prevent network call when there isn't an action + if (component.actionQueue.length === 0) { + return; + } + + // Prevent network call when the action queue gets repeated + if (component.currentActionQueue === component.actionQueue) { + return; + } + + // Since methods can change the data "behind the scenes", any queue with a callMethod + // action forces model elements to always be updated + const forceModelUpdate = component.actionQueue.some( + (a) => a.type === "callMethod" + ); + + // Set the current action queue and clear the action queue in case another event happens + component.currentActionQueue = component.actionQueue; + component.actionQueue = []; + + const body = { + id: component.id, + data: component.data, + checksum: component.checksum, + actionQueue: component.currentActionQueue, + epoch: Date.now(), + hash: component.hash, + }; + + const headers = { + Accept: "application/json", + "X-Requested-With": "XMLHttpRequest", + }; + headers[component.csrfTokenHeaderName] = getCsrfToken(component); + + fetch(component.syncUrl, { + method: "POST", + headers, + body: JSON.stringify(body), + }) + .then((response) => { + if (response.ok) { + return response.json(); + } + + // Revert targeted loading elements, loading states, + // and dirty states when the response is not ok (includes 304) + component.loadingEls.forEach((loadingElement) => { + if (loadingElement.loading.hide) { + loadingElement.show(); + } else if (loadingElement.loading.show) { + loadingElement.hide(); + } + + loadingElement.handleLoading(true); + loadingElement.handleDirty(true); + }); + + // HTTP status code of 304 is `Not Modified`. This null gets caught in the next promise + // and stops any more processing. + if (response.status === 304) { + return null; + } + + throw Error( + `Error when getting response: ${response.statusText} (${response.status})` + ); + }) + .then((responseJson) => { + if (!responseJson) { + return; + } + + if (responseJson.queued && responseJson.queued === true) { + return; + } + + if (responseJson.error) { + if (responseJson.error === "Checksum does not match") { + // Reset the models if the checksum doesn't match + if (isFunction(callback)) { + callback([], true, null); + } + } + + throw Error(responseJson.error); + } + + // Redirect to the specified url if it is set + // TODO: For turbolinks support look at https://github.com/livewire/livewire/blob/f2ba1977d73429911f81b3f6363ee8f8fea5abff/js/component/index.js#L330-L336 + if (responseJson.redirect) { + if (responseJson.redirect.url) { + if (responseJson.redirect.refresh) { + if (responseJson.redirect.title) { + component.window.document.title = responseJson.redirect.title; + } + + component.window.history.pushState( + {}, + "", + responseJson.redirect.url + ); + } else { + component.window.location.href = responseJson.redirect.url; + + // Prevent anything else from happening if there is a url redirect + return; + } + } else if (responseJson.redirect.hash) { + component.window.location.hash = responseJson.redirect.hash; + } + } + + // Remove any unicorn validation messages before trying to merge with morphdom + component.modelEls.forEach((element) => { + // Re-initialize element to make sure it is up to date + element.init(); + element.removeErrors(); + element.handleDirty(true); + }); + + // Merge the data from the response into the component's data + Object.keys(responseJson.data || {}).forEach((key) => { + component.data[key] = responseJson.data[key]; + }); + + component.errors = responseJson.errors || {}; + component.return = responseJson.return || {}; + component.hash = responseJson.hash; + + let parent = responseJson.parent || {}; + const rerenderedComponent = responseJson.dom || ""; + const partials = responseJson.partials || []; + const { checksum } = responseJson; + + // Handle poll + const poll = responseJson.poll || {}; + + if (hasValue(poll)) { + if (component.poll.timer) { + clearInterval(component.poll.timer); + } + + if (poll.timing) { + component.poll.timing = poll.timing; + } + if (poll.method) { + component.poll.method = poll.method; + } + + component.poll.disable = poll.disable || false; + component.startPolling(); + } + + // Refresh the parent component if there is one + while (hasValue(parent) && hasValue(parent.id)) { + const parentComponent = component.getParentComponent(parent.id); + + if (parentComponent && parentComponent.id === parent.id) { + // TODO: Handle parent errors? + + if (hasValue(parent.data)) { + parentComponent.data = parent.data; + } + + if (parent.dom) { + parentComponent.morphRoot(parent.dom); + + parentComponent.loadingEls.forEach((loadingElement) => { + if (loadingElement.loading.hide) { + loadingElement.show(); + } else if (loadingElement.loading.show) { + loadingElement.hide(); + } + + loadingElement.handleLoading(true); + loadingElement.handleDirty(true); + }); + } + + if (parent.checksum) { + parentComponent.root.setAttribute( + "unicorn:checksum", + parent.checksum + ); + + parentComponent.refreshChecksum(); + } + + // Set parent component hash + parentComponent.hash = parent.hash; + + parentComponent.refreshEventListeners(); + + // parentComponent.getChildrenComponents().forEach((child) => { + // child.init(); + // child.refreshEventListeners(); + // }); + } + parent = parent.parent || {}; + } + + if (partials.length > 0) { + for (let i = 0; i < partials.length; i++) { + const partial = partials[i]; + let targetDom = null; + + if (partial.key) { + targetDom = $(`[unicorn\\:key="${partial.key}"]`, component.root); + } else if (partial.id) { + targetDom = $(`#${partial.id}`, component.root); + } + + if (!targetDom && component.root.parentElement) { + // Go up one parent if the target can't be found + targetDom = $( + `[unicorn\\:key="${partial.key}"]`, + component.root.parentElement + ); + } + + if (targetDom) { + component.morph(targetDom, partial.dom); + } + } + + if (checksum) { + component.root.setAttribute("unicorn:checksum", checksum); + component.refreshChecksum(); + } + } else if (rerenderedComponent) { + component.morphRoot(rerenderedComponent); + } + + component.triggerLifecycleEvent("updated"); + + try { + // Re-init to refresh the root and checksum based on the new data + component.init(); + } catch (err) { + // No id found error will be thrown here for child components. + return; + } + + // Reset all event listeners + component.refreshEventListeners(); + + // Check for visibility elements if the last return value from the method wasn't false + let reInitVisbility = true; + + component.visibilityEls.forEach((el) => { + if ( + el.visibility.method === component.return.method && + component.return.value === false + ) { + reInitVisbility = false; + } + }); + + if (reInitVisbility) { + component.initVisibility(); + } + + // Re-add unicorn validation messages from errors + component.modelEls.forEach((element) => { + Object.keys(component.errors).forEach((modelName) => { + if (element.model.name === modelName) { + const error = component.errors[modelName][0]; + element.addError(error); + } + }); + }); + + // Call any JavaScript functions from the response + component.callCalls(responseJson.calls); + + const triggeringElements = component.lastTriggeringElements; + component.lastTriggeringElements = []; + + // Clear the current action queue + component.currentActionQueue = null; + + if (isFunction(callback)) { + callback(triggeringElements, forceModelUpdate, null); + } + }) + .catch((err) => { + // Make sure to clear the current queues in case of an error + component.actionQueue = []; + component.currentActionQueue = null; + component.lastTriggeringElements = []; + + if (isFunction(callback)) { + callback(null, null, err); + } + }); +} diff --git a/src/simmate/website/unicorn/static/unicorn/js/morphdom/2.6.1/morphdom-umd.min.js b/src/simmate/website/unicorn/static/unicorn/js/morphdom/2.6.1/morphdom-umd.min.js new file mode 100644 index 000000000..7cb875558 --- /dev/null +++ b/src/simmate/website/unicorn/static/unicorn/js/morphdom/2.6.1/morphdom-umd.min.js @@ -0,0 +1 @@ +(function(global,factory){typeof exports==="object"&&typeof module!=="undefined"?module.exports=factory():typeof define==="function"&&define.amd?define(factory):(global=global||self,global.morphdom=factory())})(this,function(){"use strict";var DOCUMENT_FRAGMENT_NODE=11;function morphAttrs(fromNode,toNode){var toNodeAttrs=toNode.attributes;var attr;var attrName;var attrNamespaceURI;var attrValue;var fromValue;if(toNode.nodeType===DOCUMENT_FRAGMENT_NODE||fromNode.nodeType===DOCUMENT_FRAGMENT_NODE){return}for(var i=toNodeAttrs.length-1;i>=0;i--){attr=toNodeAttrs[i];attrName=attr.name;attrNamespaceURI=attr.namespaceURI;attrValue=attr.value;if(attrNamespaceURI){attrName=attr.localName||attrName;fromValue=fromNode.getAttributeNS(attrNamespaceURI,attrName);if(fromValue!==attrValue){if(attr.prefix==="xmlns"){attrName=attr.name}fromNode.setAttributeNS(attrNamespaceURI,attrName,attrValue)}}else{fromValue=fromNode.getAttribute(attrName);if(fromValue!==attrValue){fromNode.setAttribute(attrName,attrValue)}}}var fromNodeAttrs=fromNode.attributes;for(var d=fromNodeAttrs.length-1;d>=0;d--){attr=fromNodeAttrs[d];attrName=attr.name;attrNamespaceURI=attr.namespaceURI;if(attrNamespaceURI){attrName=attr.localName||attrName;if(!toNode.hasAttributeNS(attrNamespaceURI,attrName)){fromNode.removeAttributeNS(attrNamespaceURI,attrName)}}else{if(!toNode.hasAttribute(attrName)){fromNode.removeAttribute(attrName)}}}}var range;var NS_XHTML="http://www.w3.org/1999/xhtml";var doc=typeof document==="undefined"?undefined:document;var HAS_TEMPLATE_SUPPORT=!!doc&&"content"in doc.createElement("template");var HAS_RANGE_SUPPORT=!!doc&&doc.createRange&&"createContextualFragment"in doc.createRange();function createFragmentFromTemplate(str){var template=doc.createElement("template");template.innerHTML=str;return template.content.childNodes[0]}function createFragmentFromRange(str){if(!range){range=doc.createRange();range.selectNode(doc.body)}var fragment=range.createContextualFragment(str);return fragment.childNodes[0]}function createFragmentFromWrap(str){var fragment=doc.createElement("body");fragment.innerHTML=str;return fragment.childNodes[0]}function toElement(str){str=str.trim();if(HAS_TEMPLATE_SUPPORT){return createFragmentFromTemplate(str)}else if(HAS_RANGE_SUPPORT){return createFragmentFromRange(str)}return createFragmentFromWrap(str)}function compareNodeNames(fromEl,toEl){var fromNodeName=fromEl.nodeName;var toNodeName=toEl.nodeName;var fromCodeStart,toCodeStart;if(fromNodeName===toNodeName){return true}fromCodeStart=fromNodeName.charCodeAt(0);toCodeStart=toNodeName.charCodeAt(0);if(fromCodeStart<=90&&toCodeStart>=97){return fromNodeName===toNodeName.toUpperCase()}else if(toCodeStart<=90&&fromCodeStart>=97){return toNodeName===fromNodeName.toUpperCase()}else{return false}}function createElementNS(name,namespaceURI){return!namespaceURI||namespaceURI===NS_XHTML?doc.createElement(name):doc.createElementNS(namespaceURI,name)}function moveChildren(fromEl,toEl){var curChild=fromEl.firstChild;while(curChild){var nextChild=curChild.nextSibling;toEl.appendChild(curChild);curChild=nextChild}return toEl}function syncBooleanAttrProp(fromEl,toEl,name){if(fromEl[name]!==toEl[name]){fromEl[name]=toEl[name];if(fromEl[name]){fromEl.setAttribute(name,"")}else{fromEl.removeAttribute(name)}}}var specialElHandlers={OPTION:function(fromEl,toEl){var parentNode=fromEl.parentNode;if(parentNode){var parentName=parentNode.nodeName.toUpperCase();if(parentName==="OPTGROUP"){parentNode=parentNode.parentNode;parentName=parentNode&&parentNode.nodeName.toUpperCase()}if(parentName==="SELECT"&&!parentNode.hasAttribute("multiple")){if(fromEl.hasAttribute("selected")&&!toEl.selected){fromEl.setAttribute("selected","selected");fromEl.removeAttribute("selected")}parentNode.selectedIndex=-1}}syncBooleanAttrProp(fromEl,toEl,"selected")},INPUT:function(fromEl,toEl){syncBooleanAttrProp(fromEl,toEl,"checked");syncBooleanAttrProp(fromEl,toEl,"disabled");if(fromEl.value!==toEl.value){fromEl.value=toEl.value}if(!toEl.hasAttribute("value")){fromEl.removeAttribute("value")}},TEXTAREA:function(fromEl,toEl){var newValue=toEl.value;if(fromEl.value!==newValue){fromEl.value=newValue}var firstChild=fromEl.firstChild;if(firstChild){var oldValue=firstChild.nodeValue;if(oldValue==newValue||!newValue&&oldValue==fromEl.placeholder){return}firstChild.nodeValue=newValue}},SELECT:function(fromEl,toEl){if(!toEl.hasAttribute("multiple")){var selectedIndex=-1;var i=0;var curChild=fromEl.firstChild;var optgroup;var nodeName;while(curChild){nodeName=curChild.nodeName&&curChild.nodeName.toUpperCase();if(nodeName==="OPTGROUP"){optgroup=curChild;curChild=optgroup.firstChild}else{if(nodeName==="OPTION"){if(curChild.hasAttribute("selected")){selectedIndex=i;break}i++}curChild=curChild.nextSibling;if(!curChild&&optgroup){curChild=optgroup.nextSibling;optgroup=null}}}fromEl.selectedIndex=selectedIndex}}};var ELEMENT_NODE=1;var DOCUMENT_FRAGMENT_NODE$1=11;var TEXT_NODE=3;var COMMENT_NODE=8;function noop(){}function defaultGetNodeKey(node){if(node){return node.getAttribute&&node.getAttribute("id")||node.id}}function morphdomFactory(morphAttrs){return function morphdom(fromNode,toNode,options){if(!options){options={}}if(typeof toNode==="string"){if(fromNode.nodeName==="#document"||fromNode.nodeName==="HTML"||fromNode.nodeName==="BODY"){var toNodeHtml=toNode;toNode=doc.createElement("html");toNode.innerHTML=toNodeHtml}else{toNode=toElement(toNode)}}var getNodeKey=options.getNodeKey||defaultGetNodeKey;var onBeforeNodeAdded=options.onBeforeNodeAdded||noop;var onNodeAdded=options.onNodeAdded||noop;var onBeforeElUpdated=options.onBeforeElUpdated||noop;var onElUpdated=options.onElUpdated||noop;var onBeforeNodeDiscarded=options.onBeforeNodeDiscarded||noop;var onNodeDiscarded=options.onNodeDiscarded||noop;var onBeforeElChildrenUpdated=options.onBeforeElChildrenUpdated||noop;var childrenOnly=options.childrenOnly===true;var fromNodesLookup=Object.create(null);var keyedRemovalList=[];function addKeyedRemoval(key){keyedRemovalList.push(key)}function walkDiscardedChildNodes(node,skipKeyedNodes){if(node.nodeType===ELEMENT_NODE){var curChild=node.firstChild;while(curChild){var key=undefined;if(skipKeyedNodes&&(key=getNodeKey(curChild))){addKeyedRemoval(key)}else{onNodeDiscarded(curChild);if(curChild.firstChild){walkDiscardedChildNodes(curChild,skipKeyedNodes)}}curChild=curChild.nextSibling}}}function removeNode(node,parentNode,skipKeyedNodes){if(onBeforeNodeDiscarded(node)===false){return}if(parentNode){parentNode.removeChild(node)}onNodeDiscarded(node);walkDiscardedChildNodes(node,skipKeyedNodes)}function indexTree(node){if(node.nodeType===ELEMENT_NODE||node.nodeType===DOCUMENT_FRAGMENT_NODE$1){var curChild=node.firstChild;while(curChild){var key=getNodeKey(curChild);if(key){fromNodesLookup[key]=curChild}indexTree(curChild);curChild=curChild.nextSibling}}}indexTree(fromNode);function handleNodeAdded(el){onNodeAdded(el);var curChild=el.firstChild;while(curChild){var nextSibling=curChild.nextSibling;var key=getNodeKey(curChild);if(key){var unmatchedFromEl=fromNodesLookup[key];if(unmatchedFromEl&&compareNodeNames(curChild,unmatchedFromEl)){curChild.parentNode.replaceChild(unmatchedFromEl,curChild);morphEl(unmatchedFromEl,curChild)}else{handleNodeAdded(curChild)}}else{handleNodeAdded(curChild)}curChild=nextSibling}}function cleanupFromEl(fromEl,curFromNodeChild,curFromNodeKey){while(curFromNodeChild){var fromNextSibling=curFromNodeChild.nextSibling;if(curFromNodeKey=getNodeKey(curFromNodeChild)){addKeyedRemoval(curFromNodeKey)}else{removeNode(curFromNodeChild,fromEl,true)}curFromNodeChild=fromNextSibling}}function morphEl(fromEl,toEl,childrenOnly){var toElKey=getNodeKey(toEl);if(toElKey){delete fromNodesLookup[toElKey]}if(!childrenOnly){if(onBeforeElUpdated(fromEl,toEl)===false){return}morphAttrs(fromEl,toEl);onElUpdated(fromEl);if(onBeforeElChildrenUpdated(fromEl,toEl)===false){return}}if(fromEl.nodeName!=="TEXTAREA"){morphChildren(fromEl,toEl)}else{specialElHandlers.TEXTAREA(fromEl,toEl)}}function morphChildren(fromEl,toEl){var curToNodeChild=toEl.firstChild;var curFromNodeChild=fromEl.firstChild;var curToNodeKey;var curFromNodeKey;var fromNextSibling;var toNextSibling;var matchingFromEl;outer:while(curToNodeChild){toNextSibling=curToNodeChild.nextSibling;curToNodeKey=getNodeKey(curToNodeChild);while(curFromNodeChild){fromNextSibling=curFromNodeChild.nextSibling;if(curToNodeChild.isSameNode&&curToNodeChild.isSameNode(curFromNodeChild)){curToNodeChild=toNextSibling;curFromNodeChild=fromNextSibling;continue outer}curFromNodeKey=getNodeKey(curFromNodeChild);var curFromNodeType=curFromNodeChild.nodeType;var isCompatible=undefined;if(curFromNodeType===curToNodeChild.nodeType){if(curFromNodeType===ELEMENT_NODE){if(curToNodeKey){if(curToNodeKey!==curFromNodeKey){if(matchingFromEl=fromNodesLookup[curToNodeKey]){if(fromNextSibling===matchingFromEl){isCompatible=false}else{fromEl.insertBefore(matchingFromEl,curFromNodeChild);if(curFromNodeKey){addKeyedRemoval(curFromNodeKey)}else{removeNode(curFromNodeChild,fromEl,true)}curFromNodeChild=matchingFromEl}}else{isCompatible=false}}}else if(curFromNodeKey){isCompatible=false}isCompatible=isCompatible!==false&&compareNodeNames(curFromNodeChild,curToNodeChild);if(isCompatible){morphEl(curFromNodeChild,curToNodeChild)}}else if(curFromNodeType===TEXT_NODE||curFromNodeType==COMMENT_NODE){isCompatible=true;if(curFromNodeChild.nodeValue!==curToNodeChild.nodeValue){curFromNodeChild.nodeValue=curToNodeChild.nodeValue}}}if(isCompatible){curToNodeChild=toNextSibling;curFromNodeChild=fromNextSibling;continue outer}if(curFromNodeKey){addKeyedRemoval(curFromNodeKey)}else{removeNode(curFromNodeChild,fromEl,true)}curFromNodeChild=fromNextSibling}if(curToNodeKey&&(matchingFromEl=fromNodesLookup[curToNodeKey])&&compareNodeNames(matchingFromEl,curToNodeChild)){fromEl.appendChild(matchingFromEl);morphEl(matchingFromEl,curToNodeChild)}else{var onBeforeNodeAddedResult=onBeforeNodeAdded(curToNodeChild);if(onBeforeNodeAddedResult!==false){if(onBeforeNodeAddedResult){curToNodeChild=onBeforeNodeAddedResult}if(curToNodeChild.actualize){curToNodeChild=curToNodeChild.actualize(fromEl.ownerDocument||doc)}fromEl.appendChild(curToNodeChild);handleNodeAdded(curToNodeChild)}}curToNodeChild=toNextSibling;curFromNodeChild=fromNextSibling}cleanupFromEl(fromEl,curFromNodeChild,curFromNodeKey);var specialElHandler=specialElHandlers[fromEl.nodeName];if(specialElHandler){specialElHandler(fromEl,toEl)}}var morphedNode=fromNode;var morphedNodeType=morphedNode.nodeType;var toNodeType=toNode.nodeType;if(!childrenOnly){if(morphedNodeType===ELEMENT_NODE){if(toNodeType===ELEMENT_NODE){if(!compareNodeNames(fromNode,toNode)){onNodeDiscarded(fromNode);morphedNode=moveChildren(fromNode,createElementNS(toNode.nodeName,toNode.namespaceURI))}}else{morphedNode=toNode}}else if(morphedNodeType===TEXT_NODE||morphedNodeType===COMMENT_NODE){if(toNodeType===morphedNodeType){if(morphedNode.nodeValue!==toNode.nodeValue){morphedNode.nodeValue=toNode.nodeValue}return morphedNode}else{morphedNode=toNode}}}if(morphedNode===toNode){onNodeDiscarded(fromNode)}else{if(toNode.isSameNode&&toNode.isSameNode(morphedNode)){return}morphEl(morphedNode,toNode,childrenOnly);if(keyedRemovalList){for(var i=0,len=keyedRemovalList.length;i= 0; i--) { + attr = toNodeAttrs[i]; + attrName = attr.name; + attrNamespaceURI = attr.namespaceURI; + attrValue = attr.value; + + if (attrNamespaceURI) { + attrName = attr.localName || attrName; + fromValue = fromNode.getAttributeNS(attrNamespaceURI, attrName); + + if (fromValue !== attrValue) { + if (attr.prefix === "xmlns") { + attrName = attr.name; // It's not allowed to set an attribute with the XMLNS namespace without specifying the `xmlns` prefix + } + fromNode.setAttributeNS(attrNamespaceURI, attrName, attrValue); + } + } else { + fromValue = fromNode.getAttribute(attrName); + + if (fromValue !== attrValue) { + fromNode.setAttribute(attrName, attrValue); + } + } + } + + // Remove any extra attributes found on the original DOM element that + // weren't found on the target element. + var fromNodeAttrs = fromNode.attributes; + + for (var d = fromNodeAttrs.length - 1; d >= 0; d--) { + attr = fromNodeAttrs[d]; + attrName = attr.name; + attrNamespaceURI = attr.namespaceURI; + + if (attrNamespaceURI) { + attrName = attr.localName || attrName; + + if (!toNode.hasAttributeNS(attrNamespaceURI, attrName)) { + fromNode.removeAttributeNS(attrNamespaceURI, attrName); + } + } else { + if (!toNode.hasAttribute(attrName)) { + fromNode.removeAttribute(attrName); + } + } + } +} + +var range; // Create a range object for efficently rendering strings to elements. +var NS_XHTML = "http://www.w3.org/1999/xhtml"; + +var doc = typeof document === "undefined" ? undefined : document; +var HAS_TEMPLATE_SUPPORT = !!doc && "content" in doc.createElement("template"); +var HAS_RANGE_SUPPORT = + !!doc && doc.createRange && "createContextualFragment" in doc.createRange(); + +function createFragmentFromTemplate(str) { + var template = doc.createElement("template"); + template.innerHTML = str; + return template.content.childNodes[0]; +} + +function createFragmentFromRange(str) { + if (!range) { + range = doc.createRange(); + range.selectNode(doc.body); + } + + var fragment = range.createContextualFragment(str); + return fragment.childNodes[0]; +} + +function createFragmentFromWrap(str) { + var fragment = doc.createElement("body"); + fragment.innerHTML = str; + return fragment.childNodes[0]; +} + +/** + * This is about the same + * var html = new DOMParser().parseFromString(str, 'text/html'); + * return html.body.firstChild; + * + * @method toElement + * @param {String} str + */ +function toElement(str) { + str = str.trim(); + if (HAS_TEMPLATE_SUPPORT) { + // avoid restrictions on content for things like `Hi` which + // createContextualFragment doesn't support + //