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](https://private-user-images.githubusercontent.com/25986782/287352389-c01ec3e0-e386-4703-b966-1e890e4bdcf1.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MjI3OTcwOTcsIm5iZiI6MTcyMjc5Njc5NywicGF0aCI6Ii8yNTk4Njc4Mi8yODczNTIzODktYzAxZWMzZTAtZTM4Ni00NzAzLWI5NjYtMWU4OTBlNGJkY2YxLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNDA4MDQlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjQwODA0VDE4Mzk1N1omWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTg1NzAxYjYzZDA4MWViNTcyMzliMjk2NDA5MTE4YjM0OGZhMjYyZWQ1NzkxYTkzYzBlZDgwOTgyMmNjZjAxNGQmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JmFjdG9yX2lkPTAma2V5X2lkPTAmcmVwb19pZD0wIn0.icvsMZDJgVC0raRmcVqHVr80RRhYTee-VeFn4dL4Z-s)
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:
- I use a class inheriting from
ComponentABC
to define the Slipper component sections -types
,defaults
,events
, andsetup
.
- We'll get toComponentABC
later.
-types
anddefaults
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
andsetup
are just sugar on top of Slippers frontmatter. Onlyevents
is really a new thing here. - The events used by
ExampleChild
are defined onExampleChildEvents
class. - We "emit" an event by calling
events.on_item_delete(item)
towards the end in thesetup
method.
- Again, what really happens is thatevents.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 onExampleChild.types
. This is becauseevents
prop is automatically generated and populated from theExampleChild.events
attribute because it inherits fromComponentABC
.
---
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:
events
is first defined as a class instead of instance, so that we can pass the class toprops.types
- At instantiation, the Events class is instiantiated too, and assigned to
self.events
- 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](https://private-user-images.githubusercontent.com/25986782/289264808-751c7b5d-4dcd-4cf7-89f0-b0f3403f216e.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MjI3OTcwOTcsIm5iZiI6MTcyMjc5Njc5NywicGF0aCI6Ii8yNTk4Njc4Mi8yODkyNjQ4MDgtNzUxYzdiNWQtNGRjZC00Y2Y3LTg5ZjAtYjBmMzQwM2YyMTZlLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNDA4MDQlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjQwODA0VDE4Mzk1N1omWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTg0NTA1MWRiNTRkNTJkNGU5MjQ1NjRlMGNjNjY0ZWY0MTk5ODdhNzhjN2U1ZmIxNDJkYWQwZmY3ZjgzN2VjZTImWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JmFjdG9yX2lkPTAma2V5X2lkPTAmcmVwb19pZD0wIn0.H92oyVPyLlrJFxx-pZLQu4QJH0qWFlC7YYzSgYH1TU0)
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
propmedia_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 theprepare_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))