Source code for ferrosoft.apps.ferrobase.frontend
"""Primitives for pushing real-time UI updates over WebSocket to the frontend.
Provides a hierarchy of serialisable element types (``Element``, ``UpdateSpec``,
etc.) and ``FrontendUIUpdater``, which broadcasts update specs to a Django
Channels channel group so connected clients can apply them without a full page
reload.
"""
import enum
from collections import namedtuple
from typing import Sequence, List
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from rest_framework import serializers
Position = namedtuple("Position", ["x", "y"])
Position.__doc__ = "A 2D coordinate used to position UI elements (x, y)."
[docs]
class Attr:
"""
Represents HTML element attribute.
"""
def __init__(self, name: str, value: str | bool = None):
self.name = name
self.value = value
[docs]
class Namespace(enum.StrEnum):
"""XML/HTML namespace identifiers used when serialising mixed-namespace elements."""
SVG = "http://www.w3.org/2000/svg"
HTML = "html"
[docs]
class Element:
"""
Element represents HTML elements or XML elements of certain namespace (i.e. SVG).
"""
def __init__(
self,
name: str,
*args,
**kwargs,
):
"""
Args:
name (str): Tag name
namespace (str): See Namespace enum for possible values. If no namespace is given,
the serialized representation does not include the field. This is fine if all
the elements are of uniform namespace. However, if for example SVG and HTML
elements are mixed together, a namespace must be specified for those elements
where their namespace differs from the factory namespace default.
text_content (str): Text content
args: Accepted are Element and Attr objects, allowing child elements and attributes to be declared.
"""
self.name = name
self.text_content = kwargs.pop("text_content", None)
self.namespace = kwargs.pop("namespace", None)
self.children = [item for item in args if isinstance(item, Element)]
self.attrs = {attr.name: attr.value for attr in args if isinstance(attr, Attr)}
[docs]
class ElementSerializer(serializers.Serializer):
"""DRF serialiser for ``Element`` that outputs camelCase JSON for the frontend."""
attrs = serializers.DictField()
children = serializers.SerializerMethodField()
name = serializers.CharField()
namespace = serializers.CharField()
text_content = serializers.CharField(allow_blank=True, allow_null=True)
[docs]
@staticmethod
def get_children(obj):
if not obj.children:
return []
return [ElementSerializer(child).data for child in obj.children]
[docs]
def to_representation(self, instance):
rep = super().to_representation(instance)
# Want camel case.
rep["textContent"] = rep["text_content"]
del rep["text_content"]
# Remove some fields if they are empty.
if rep["namespace"] is None:
del rep["namespace"]
if rep["textContent"] is None:
del rep["textContent"]
if len(rep["children"]) == 0:
del rep["children"]
if len(rep["attrs"]) == 0:
del rep["attrs"]
return rep
[docs]
class Transition:
"""A named UI transition with an optional detail payload."""
def __init__(self, transition: str, detail: dict = None):
self.transition = transition
self.detail = detail
[docs]
class TransitionSerializer(serializers.Serializer):
"""DRF serialiser for ``Transition``."""
transition = serializers.CharField()
detail = serializers.DictField()
[docs]
class AppendSpec:
"""Specification for appending new elements to a CSS-selector target."""
def __init__(self, selector: str, elements: Sequence[Element]):
self.selector = selector
self.elements = elements
[docs]
class AppendSpecSerializer(serializers.Serializer):
"""DRF serialiser for ``AppendSpec``."""
selector = serializers.CharField()
elements = ElementSerializer(many=True)
[docs]
class DeleteSpec:
"""Specification for removing all elements matching a CSS selector."""
def __init__(self, selector: str):
self.selector = selector
[docs]
class DeleteSpecSerializer(serializers.Serializer):
"""DRF serialiser for ``DeleteSpec``."""
selector = serializers.CharField()
[docs]
class UpdateSpec:
"""Aggregate specification describing a complete frontend UI update.
Combines element replacements, appends, and deletions into a single
payload that ``FrontendUIUpdater.update`` broadcasts to connected clients.
"""
def __init__(
self,
replace_children: List[Element] = None,
append_children: List[AppendSpec] = None,
delete_elements: List[DeleteSpec] = None,
):
self.replace_children = replace_children or []
self.append_children = append_children or []
self.delete_elements = delete_elements or []
[docs]
class UpdateSpecSerializer(serializers.Serializer):
"""DRF serialiser for ``UpdateSpec`` that outputs camelCase JSON."""
replace_children = serializers.SerializerMethodField()
append_children = serializers.SerializerMethodField()
delete_elements = serializers.SerializerMethodField()
[docs]
@staticmethod
def get_replace_children(obj):
if not obj.replace_children:
return []
return [ElementSerializer(child).data for child in obj.replace_children]
[docs]
@staticmethod
def get_append_children(obj):
if not obj.append_children:
return []
return [AppendSpecSerializer(spec).data for spec in obj.append_children]
[docs]
@staticmethod
def get_delete_elements(obj):
if not obj.delete_elements:
return []
return [DeleteSpecSerializer(spec).data for spec in obj.delete_elements]
[docs]
def to_representation(self, instance):
rep = super().to_representation(instance)
# want camel case for both fields
rep["replaceChildren"] = rep["replace_children"]
rep["appendChildren"] = rep["append_children"]
rep["deleteElements"] = rep["delete_elements"]
del rep["replace_children"]
del rep["append_children"]
del rep["delete_elements"]
return rep
[docs]
class FrontendUIUpdater:
"""
Update frontend UI with new or replacing elements.
"""
def __init__(self, *args, **kwargs):
self._group_name = None
@property
def group_name(self):
"""Django Channels group name this updater broadcasts to."""
if self._group_name is None:
raise Exception("_group_name property must be set")
return self._group_name
[docs]
def update(self, spec: UpdateSpec, transition: Transition | None = None):
"""Broadcast an update specification to all clients in the channel group.
Args:
spec: The UI update to apply. Elements targeted by
``replace_children`` must carry an ``id`` attribute.
transition: Optional named transition to accompany the update.
"""
channel_layer = get_channel_layer()
response = {
"type": "frontend_update",
"spec": UpdateSpecSerializer(spec).data,
}
if transition is not None:
response["transition"] = TransitionSerializer(transition).data
async_to_sync(channel_layer.group_send)(self.group_name, response)