mixxorz / slippers

A UI component framework for Django. Built on top of Django Template Language.

Home Page:https://mitchel.me/slippers/

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Feature proposal: Class-based components

JuroOravec opened this issue · comments

TLDR: Using Slippers and the component frontmatter, I was able to make a setup that mimics event handling - where parent component can decide how to handle child's events.

Event handling in Django templates sounds like a strange concept, because event handing happens on client-side, while the templates are rendered server-side.

However, in our case we're using Alpine.js, so we inline JS event handlers into the HTML. Hence, with this Slippers event handling feature, we can write components that handle client-side events in a decoupled manner similar to the likes of React or Vue.

Demo

In this demo, the event handler (the JS snippet that opens the alert) was defined in the parent component. Parent's event handler was able to access the child's event argument (the item value).

Screen.Recording.2023-12-01.at.19.11.04.mov
Screenshot 2023-12-01 at 19 11 53

How it works

At the end of the day it's just dependency injection - child delegates the rendering of some part of the component to an "Events" class provided by the parent.

However, normally, parent can pass down only static data. Because we can use Python in the frontmatter, we can pass down an object with methods, and the child is able to call and pass arbitrary data to those methods!

Proof of concept

Consists of 6 parts:

1. Child component (example_child.html)

It doesn't do much, just renders items. However, notice the menu_item.item_attrs in the middle, becuase this is where we pass in the event handling.

Note that I've used Slippers frontmatter to define logic that is run with each rendering of the component. I've split HTML and Python for readability.

---
from myapp.templates.components.example_child import ExampleChild

ExampleChild(props)
---

<ul class="{{ class }}" {{ attrs|safe }}>
  {% for item, menu_items in render_data %}
    <li>
      {{ item }}
      <ul style="padding-left: 40px;">
        {% for menu_item in menu_items %}
          <li {{ menu_item.item_attrs|default:""|safe }}>
            {{ menu_item.value }}
          <li>
        {% endfor %}
      </ul>
    </li>
  {% endfor %}
</ul>

2. Child component logic (example_child.py)

This logic is imported into example_child.html.

Notice that:

  1. I use a class inheriting from ComponentABC to define the Slipper component sections - types, defaults, events, and setup.
    - We'll get to ComponentABC later.
    - types and defaults are from Slippers' Props class available in the frontmatter.
    - setup is a callback called at the end of the frontmatter after all the rest has been prepared.
    - Hence, types, defaults and setup are just sugar on top of Slippers frontmatter. Only events is really a new thing here.
  2. The events used by ExampleChild are defined on ExampleChildEvents class.
  3. We "emit" an event by calling events.on_item_delete(item) towards the end in the setup method.
    - Again, what really happens is that events.on_item_delete(item) returns a string that defines client-side (JS) event handling.
class ExampleChildEvents():
    def on_item_delete(self, item) -> str:
        return ''

  
class ExampleChild(ComponentABC[ExampleChildEvents]):
    types = {
        'items': list,
        'class': Optional[str],
        'attrs': Optional[str],
    }
    defaults = {
        'class': '',
        'attrs': '',
    }
    events = ExampleChildEvents

    def setup(self, props, events):
        items = props['items']
        menu_items_per_item = [
            [
                MenuItem(value="Edit", link="#"),
                MenuItem(value="Duplicate"),
                MenuItem(value="Delete", item_attrs=events.on_item_delete(item)),
            ]
            for item in items
        ]

        props['render_data'] = list(zip(items, menu_items_per_item))

3. Parent component (example_parent.html)

Here we import the child component, and pass down event handlers via events prop.

  • You may have noticed we pass down the events prop, but we didn't define it on ExampleChild.types. This is because events prop is automatically generated and populated from the ExampleChild.events attribute because it inherits from ComponentABC.
---
from myapp.templates.components.example_parent import ExampleParent

ExampleParent(props)
---

{% example_child items=items events=child_events %}

4. Parent component logic (example_parent.py)

This is where we plug into child's events to

  • Print to console
  • Define custom client-side event handler logic using event's data
from myapp.templates.components.example_child import ExampleChildEvents

class ChildEvents(ExampleChildEvents):
    def on_item_delete(self, row_id):
        print('Hello from parent component!')
        return f"""
        onClick="(() => {{ 
            alert('Deleting item {row_id}!');
            return false;
        }})();"
        """


class ExampleParent(ComponentABC):
    def setup(self, props, events):
        props['items'] = ['Hello', 1, 'automaschine']
        props['child_events'] = ChildEvents()

5. components.yml

Register our components as Slippers components.

components:
  example_parent: "components/example_parent/example_parent.html"
  example_child: "components/example_child/example_child.html"

6. ComponentABC class

from typing import Any, TypeVar, Generic
from abc import ABC, abstractmethod

from slippers.props import Props

class BaseEventHandler(ABC):
  """Base class for the component event handlers"""
  pass


T = TypeVar('T', bound="BaseEventHandler")


def apply_component_defaults(props_obj: Props):
  """Apply defaults also for empty strings, not just None"""
  for key, val in props_obj.items():
      # Ignore if no default is defined for this key
      if key not in props_obj.defaults:
          continue
      if val is None or val == "":
          props_obj[key] = props_obj.defaults[key]


class ComponentABC(ABC, Generic[T]):
  types: dict[str, type[Any]] = None
  defaults: dict[str, Any] = None
  events: type[T] | None = None

  def __init__(self, props: Props[T]) -> None:
      super().__init__()
  	# Apply defaults
      self.types = self.types or {}
      self.defaults = self.defaults or {}

      # Populate props object
      props.types = self.types
      props.defaults = self.defaults

      props.types['events'] = self.events
      props.defaults['events'] = self.events() if self.events else None

      apply_component_defaults(props)

      # Replace class with instance, so in `setup`, we can do `self.events.callback()`
      self.events = props['events']

      self.setup(props, self.events)

  @abstractmethod
  def setup(self, props: Props[T], events: T) -> None:
      ...

Knowing how the ComponentABC looks like, we can now go back to ExampleChild component class. Notice that:

  1. events is first defined as a class instead of instance, so that we can pass the class to props.types
  2. At instantiation, the Events class is instiantiated too, and assigned to self.events
  3. Also at instantiation, user can provide their own instance of Events class via events prop (props['events']). This is how user can plug into the child's events.

Next steps

I'd like to hear your feedback for this feature. The ideal outcome for me would be to get the ComponentABC (together with the "events" feature) into Slippers package, along with proper documentation. I'm happy to work on those.

Further thoughts

Furthermore, you can see that in the components' frontmatter, I'm explicitly passing the Props object to the component class, e.g.:

ExampleChild(props)

This has to be done because I'm interacting with Slippers from the outside. If the ComponentABC was integrated, it could possibly have a different interface, e.g. in the frontmatter, we could do:

setup_component(ExampleChild)

Where setup_component would be a function automatically imported into the scope (like with the typing lib). and setup_component could look like this behind the scenes:

def setup_component(comp_class: type[ComponentABC]):
    comp_class(props)

@mixxorz What do you think?

!! UPDATE: Renamed feature to "Class-based components" !!

Further refining the ideas as I keep working with Slippers components:

1. Split HTML file into Python, JavaScript, CSS and HTML files

As I started adding event handling logic, Slippers components quickly became difficult work with, because if I have Python, JS, CSS, and HTML code in a single file, then I don't get intellisense / language support for 3 of 4 languages.

At this point, I will probably eventually migrate to django-components to have support for separate files.

However, since we've started with Slippers, I want to make the transition as smooth as possible.

As I discussed a Component class in the issue description, the natural extension of that is to do something similar to django-components and allow to split JS and CSS into separate files.

Using similar interface as django-components does, and reusing the ExampleParent example I mentioned in the issue description, we can get a component definition like this:

Screenshot 2023-12-09 at 11 15 00

Where the parent components consists of:

example_parent_v2.py

from myapp.helpers.components import Component
from myapp.templates.components.example_child_v2 import ExampleChildV2Events

class ChildEvents(ExampleChildV2Events):
    def on_item_delete(self, row_id):
        items = self.component.props['items']
        print(
            "Hello from parent component!"
            f"\nData from child - row_id: {row_id}\n"
            f"\nData from component - items: {items}\n"
        )
        return '@click="onItemDelete"'

class ExampleParentV2(Component):
    def setup(self, props, events):
        props["items"] = ["Hello", 1, "automaschine"]
        props["child_events"] = ChildEvents(self)

    class Media:
        js = "components/example_parent_v2/example_parent_v2.js"

example_parent_v2.html

---
from myapp.templates.components.example_parent_v2 import ExampleParentV2

ExampleParentV2(props)
---
{{ media_js }}

{% example_child_v2 items=items events=child_events %}

example_parent_v2.js

document.addEventListener('alpine:init', () => {
  Alpine.data('example_parent_v2', () => ({
    onItemDelete(event) {
      alert(`Deleting item ${event.details.rowId}!`);
      return false;
    }
  }));
});

And the child component consists of:

example_child_v2.py

from myapp.helpers.components import Component, EventHandler
from myapp.components.menu import MenuItem

class ExampleChildV2Events(EventHandler):
    def on_item_delete(self, item):
        pass

class ExampleChildV2(Component[ExampleChildV2Events]):
    types = {
        "items": list,
        "class": str | None,
    }
    defaults = {
        "class": "",
    }

    Events = ExampleChildV2Events

    class Media:
        css = "components/example_child_v2/example_child_v2.css"

    def setup(self, props, events):
        items = props["items"]
        menu_items_per_item = [
            [
                MenuItem(value="Edit", link="#"),
                MenuItem(value="Duplicate"),
                MenuItem(value="Delete", item_attrs=events.on_item_delete(item)),
            ]
            for item in items
        ]

        props["render_data"] = list(zip(items, menu_items_per_item))

example_child_v2.html

---
from myapp.templates.components.example_child_v2 import ExampleChildV2

ExampleChildV2(props)
---
{{ media_css }}

<ul class="{{ class }}" {{ attrs|safe }}>
  {% for item, menu_items in render_data %}
    <li>
      {{ item }}
      <ul class="example-child-v2__children">
        {% for menu_item in menu_items %}
          <li {{ menu_item.item_attrs|default:""|safe }}>
            {{ menu_item.value }}
          <li>
        {% endfor %}
      </ul>
    </li>
  {% endfor %}
</ul>

example_child_v2.css

.example-child-v2__children {
  padding-left: 40px;
}

Comments

  • Used AlpineJS for client-side event handling

  • The JS is assigned to a media_js prop, so we still can/have to define where we shall put the JS snippet.

    • media_js renders a <script> tag
  • Same applies to CSS, which is assigned to media_css prop

    • media_css renders a <style> tag
  • In this example, a component's "entrypoint" is the Django template file. That's why we need to import the component in the front matter.

  • Note this example is a bit contrived, e.g. instead of defining the on_item_delete server-side "event", we could just handle it all on client-side with Alpine. But still, it show 2 ways of managing child component's events - 1) server-side (at render time), and 2) client-side (in-browser).

Implementation

Expand to see the definition of `Component` and `EventHandler`
from typing import Any, TypeVar, Generic
from abc import ABC, abstractmethod

from slippers.props import Props
from django.utils.safestring import mark_safe

class EventHandler(ABC):
    """Base class for the component event handlers"""

    component: "Component"

    def __init__(self, component):
        self.component = component

T = TypeVar("T", bound=EventHandler)

class Component(ABC, Generic[T]):
    types: dict[str, type[Any]] = None
    defaults: dict[str, Any] = None
    events: T
    props: Props

    class Events(EventHandler):
        pass

    class Media:
        js: str | None = None
        css: str | None = None

    def __init__(self, props: Props[T]) -> None:
        super().__init__()
        self.types = self.types or {}
        self.defaults = self.defaults or {}
        self.props = props

        # Populate props object
        props.types = self.types
        props.defaults = self.defaults

        props.types["events"] = self.Events
        props.defaults["events"] = self.Events(self)

        prepare_props(props)

        self.events = props["events"]
        self.load_media()

        self.setup(props, self.events)

    def load_media(self):
        from django.template.loader import render_to_string

        props = self.props
        if hasattr(self.Media, 'js') and self.Media.js:
            props["media_js"] = render_to_string(self.Media.js, props.__dict__)
            props["media_js"] = f"<script>{props['media_js']}</script>"
        else:
            props["media_js"] = ""
        props["media_js"] = mark_safe(props["media_js"])

        if hasattr(self.Media, 'css') and self.Media.css:
            props["media_css"] = render_to_string(self.Media.css, props.__dict__)
            props["media_css"] = f"<style>{props['media_css']}</script>"
        else:
            props["media_css"] = ""
        props["media_css"] = mark_safe(props["media_css"])

    @abstractmethod
    def setup(self, props: Props[T], events: T) -> None:
        ...

Further thoughts

  • The component registration could be refactored to define component either via it's template file (like it's originally), or via it's component class. In the case of the latter, the template file would need to be specified similarly as is done in django-components, e.g. using a template_name class field.

2. Pass-through props

Slippers was cumbersome when used with event handling.

From Vue and the likes, I'm used that I can attach event handlers to components the same way as I attach them to vanilla HTML. E.g.:

<MyComponent @click="doSomething" />
<div @click="doAnotherThing">
  Click me!
</div>

Because of this, I was defining class and attrs props for all my Slippers components. E.g. in the example below, the parent defines an Alpine event listener via attrs prop:

child.html

<div class="text-red {{ class }}" attrs="{{ attrs|safe }}">
  Hello there, {{ name }}!
</div>

parent.html

{% child name="John" attrs='@click="counter += 1"' %}

But this approach is clunky to type and easy to make mistakes in. Because if I'm defining attributes, I need to use both single and double quotes - Double quotes for attribute values, and single quote to wrap it all as a string.

So instead, I implemented behavior similar to Vue, where unknown props can be "passed through" - AKA all unknown props are collected in the attrs prop.

Unknown props are defined as those props, that are not mentioned neither on props.types nor props.defaults.

With this, Alpine event handling becomes much cleaner:

{% child name="John" @click="counter += 1" %}

Comments

  • One thing that I'm still missing about this setup is the possibility to "spread" props. E.g. if parent components wants to pass all it's props to a child component, it should be possible with something like **props, e.g. :

     ---
     props.types = {
         name: str,
     }
     ---
     {% child **props %}

    Should unpack to

     {% child name=props.name %}

    This would be extremely convenient together with the pass-through props.

  • As an alternative to unpacking, I made it so that I can define both pass-through props, AND attrs prop. And during component initialization, the two are merged. This is done by the prepare_props function.

    That way, in a component, I can define my own pass-through props AND I can still allow component user to provide their own additional props:

{% child name="John" @click="counter += 1" attrs=attrs %}

Implementation

Expand to see the definition of `prepare_props`
import json

from slippers.props import Props
from django.utils.safestring import mark_safe


def prepare_props(props_obj: Props):
    """
    !! Use this function ONLY inside the Slipper components front matter !!

    Given a Slippers `props` object available in the front matter code of
    Slippers components, this function:

    1. Merges given props with defaults, treating empty strings as `None`.
      - NOTE: This is required because props passed through multiple components
              get coerced to strings, so we must treat empty strings as None.
      - See https://github.com/mixxorz/slippers/issues/59

    2. Collects all undeclared props to `attrs` prop.

    Example usage:

    ```twig
    ---
    from theme.helpers.components import prepare_props

    props.types = {
        'my_prop': Optional[int],
    }
    props.defaults = {
        'my_prop': 12,
    }
    prepare_props(props)
    ---
    <div>
    ...
    </div>
    ```
    """

    # Slippers doesn't support spreading props (e.g. like `v-bind` in Vue).
    # So if we want to pass down dynamically defined attributes, we still need
    # some prop to assign them to. For this we use the `attrs` prop.
    #
    # Since all components that use this function will populate `attrs` prop,
    # the component's user shouldn't need to define it. But if they don't define
    # it, then the `attrs` prop we define here would be misinterpreted as a "pass-through"
    # prop.
    #
    # That's why we add `attrs` to component's props here.
    if "attrs" not in props_obj.types:
        props_obj.types["attrs"] = str

    # Next, go prop by prop, and assign defaults to None and empty strings,
    # because Slippers does it only for None.
    for key, val in props_obj.items():
        # Ignore if no default is defined for this key
        if key not in props_obj.defaults:
            continue
        if val is None or val == "":
            props_obj[key] = props_obj.defaults[key]

    # Next, check which props we've been given that have not been declared by
    # the component and collect them all into the `attrs` prop.
    def format_value(val):
        if isinstance(val, str):
            return json.dumps(val)
        return val

    all_prop_names = set([*props_obj.types, *props_obj.defaults])
    passthrough_props = [
        f"{k}={format_value(props_obj[k])}"
        for k in props_obj
        if k not in all_prop_names
    ]

    orig_attrs = props_obj.get("attrs", "") or ""
    props_obj["attrs"] = mark_safe(orig_attrs + " " + " ".join(passthrough_props))