-
Notifications
You must be signed in to change notification settings - Fork 38
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feature proposal: Class-based components #60
Comments
@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 filesAs 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 Using similar interface as django-components does, and reusing the Where the parent components consists of: example_parent_v2.pyfrom 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.jsdocument.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.pyfrom 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
ImplementationExpand 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
2. Pass-through propsSlippers 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 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 Unknown props are defined as those props, that are not mentioned neither on With this, Alpine event handling becomes much cleaner: {% child name="John" @click="counter += 1" %} Comments
{% child name="John" @click="counter += 1" attrs=attrs %} ImplementationExpand 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)) |
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
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.
2. Child component logic (
example_child.py
)This logic is imported into
example_child.html
.Notice that:
ComponentABC
to define the Slipper component sections -types
,defaults
,events
, andsetup
.- We'll get to
ComponentABC
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.ExampleChild
are defined onExampleChildEvents
class.events.on_item_delete(item)
towards the end in thesetup
method.- Again, what really happens is that
events.on_item_delete(item)
returns a string that defines client-side (JS) event handling.3. Parent component (
example_parent.html
)Here we import the child component, and pass down event handlers via
events
prop.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
.4. Parent component logic (
example_parent.py
)This is where we plug into child's events to
5. components.yml
Register our components as Slippers components.
6.
ComponentABC
classKnowing how the
ComponentABC
looks like, we can now go back toExampleChild
component class. Notice that:events
is first defined as a class instead of instance, so that we can pass the class toprops.types
self.events
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.: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:Where
setup_component
would be a function automatically imported into the scope (like with thetyping
lib). andsetup_component
could look like this behind the scenes:The text was updated successfully, but these errors were encountered: