EmilStenstrom / django-components

Create simple reusable template components in Django.
MIT License
1.1k stars 74 forks source link

Proposals to make it possible to keep HTML attributes inside the HTML file #539

Closed JuroOravec closed 2 weeks ago

JuroOravec commented 2 months ago

Problem

I'm updating one of my projects from v0.73 to v0.82. Features like the html_attrs tag, prop:key=value syntax and provide/inject cleaned up the code really nicely.

However, there are still a few use cases where I need to define HTML attributes in Python (instead of keeping them in the HTML), to work around the limitations of the library. This adds unnecesary mental overhead as I need to trace where the HTML attributes belong.

Consider the example below, where:

class TabItem(NamedTuple):
    content: str
    disabled: bool = False

@component.register("tabs")
class Tabs(Component):
    template = """
        <div>
            {% for tab in tabs %}
                {% component "tab"
                    content=tab.content
                    disabled=tab.disabled
                    attrs=attrs
                %}{% endcomponent %}

                {% component "input_form"
                    editable=tab.editable
                %}{% endcomponent %}
            {% endfor %}
        </div>
    """

    def get_context_data(
        self,
        *_args,
        tabs: list[TabItem],
        attrs: dict | None = None,
    ):
        final_attrs = {
            **(attrs or {}),
            "@click": "(evt) => alert(evt.target.data)"
        }

        final_tabs = []
        for tab in tabs:
            final_tabs.append({
                "content": tab.content,
                "disabled": tab.disabled,
                "editable": not tab.disabled,
            })

        return {
            "tabs": final_tabs,
            "attrs": final_attrs,
        }

Solution

❌ Custom filters

While custom filters could be enough for the negation:

editable=tab.disabled|not

It's already insufficient for ternary (if/else). Django has the built-in yesno filter, but the filter works only with strings. I cannot use yesno to decide between two objects.

This works:

editable=tab.disabled|yesno:"yes,no"

But I cannot achieve this:

editable=tab.disabled|yesno:this_if_true,that_if_false

One custom filter I can think of that could work is if I defined a filter that runs a function:

@register.filter("call")
def call_fn(value, fn):
    return fn(value)

But here the limitation is that I could pass only a single value to it.

So while it could work with predefined True/False values:

def this_that_ternary(predicate):
    return this_if_true if predicate else that_if_false
editable=tab.disabled|call:this_that_ternary

I couldn't pass the True/False values on the spot:

editable=tab.disabled|call:ternary(this_if_true, that_if_false)

✅ Custom tags

The upside of tags is that you can pass in an arbirary number of arguments, and you can capture the output with as var syntax:

@register.simple_tag
def ternary(predicate, val_if_true, val_if_false):
    return val_if_true if predicate else val_if_false
{% ternary tab.disabled this_if_true that_if_false as tab_editable %}

{% component "input_form"
    editable=tab_editable
%}{% endcomponent %}

And this could be also used for merging of the HTML attributes inside the template:

@register.simple_tag
def merge_dicts(*dicts, **kwargs):
    merged = {}
    for d in dicts:
        merged.update(d)
    merged.update(kwargs)
    return merged
{%  merge_dicts
    attrs
    @click="(evt) => alert(evt.target.data)"
as tab_attrs %}

{% component "tab"
    attrs=tab_attrs
%}{% endcomponent %}

❓Inlined custom tags

In the Custom tags examples, the tag still had to be defined on a separate line. I wonder if we could have a feature similar to React or Vue, where you could directly put logic as the value of the prop.

So what in React looks like:

<MyComp value={myVal ? thisIfTrue : thatIfFalse} />

Could possibly be achieved Django something like this:

{% component "my_comp"
    value=`{% ternary tab.disabled this_if_true that_if_false %}`
%}{% endcomponent %}

Where the value wrapped in `{% ... %}` would mean "interpret the content as template tag".

Implementation notes:

❓Spread operator

One last limitation when working with the components currently is that it doesn't have a spread operator. Again this is mostly useful when I want to combine my inputs with inputs given from outside.

For context, in React, it looks like this:

const person = { name: 'John', lastName: 'Smith'}

<Person age={29} {...person} />
// Which is same as
// <Person age={29} name={person.name} lastName={person.lastName} />

And Vue:

<Person :age="29" v-bind="person" />
// Same as
// <Person :age="29" :name="person.name" :last-name="person.lastName" />

In Django it could look like this:

{% component "person" age=29 ...person %}{% endcomponent %}

We already allow dots (.) in kwarg names for components. So we would give special treatment to kwargs that start with ..., and put the dict entries in it's place.

EmilStenstrom commented 2 months ago

Hi! I like the custom tags version just like you do, because it feels like the simplest way to make this work.

A fifth option would be to actually make editable=not disabled work like you would expect it to. That is, allow arbitrary boolean operators just like if we where executing an if block like this editable=editable and not disabled.

About merge_attrs, I wonder if we can expand how tags can be sent to components again?

{% component "tab"
    attrs=attrs  # Default dict sent to the component
    attrs:@click="(evt) => alert(evt.target.data)"
%}{% endcomponent %}
JuroOravec commented 1 month ago

Today I also looked a bit more into the spread operator, as it feels like the last crucial step (after the self-closing tags and the dynamic expressions), to have the templating syntax similarly powerful as React or Vue. For the UI templating library, migrating components from JSX to Django components is currently the biggest bottleneck, because things have to be done differently in the latter. That's why I'd like get these features first.

Anyway, notes:

EmilStenstrom commented 1 month ago

I think keeping things simple is likely the best way forward here. I'm +1 adding support for just ...dict in the template.