EmilStenstrom / django-components

Create simple reusable template components in Django.
MIT License
1.16k stars 76 forks source link

First class support for HTML fragments #635

Open JuroOravec opened 2 months ago

JuroOravec commented 2 months ago

NOTE: I'm jumping way ahead with this one, so really this one becomes relevant only once https://github.com/EmilStenstrom/django-components/issues/622 and similar have been implemented. But since I looked into this today, I wanted to document it.


Building on top of https://github.com/EmilStenstrom/django-components/issues/622#issuecomment-2324233043, I was thinking of what could be an intuitive use of HTML fragments with django_components.

For demonstration, imagine I have a component like so:

class MyComp(Component):
    def get_context_data(self):
        return {
            "my_var": "hello",
        }

    def css_data(self):
        return {
            "bg_color": "blue",
        }

    template = """
        <div class="mycomp">
            <p>
                First - {{ my_var }}
            </p>
            <p class="myfrag">
                Second - {{ my_var }}
            </p>
            <p class="myfrag">
                Third - {{ my_var }}
            </p>
        </div>
    """

    js = """
        const isFragmentA = $els[0].classlist.includes('myfrag');
        const isFragmentB = $els[0].classlist.includes('myfrag-b');

        if (isFragmentA) {
            doSomething();
        } else if (isFragmentB) {
            doSomethingElse();
        } else {
            processAsFullComponent();
        }
    """

    css = """
        me {
            font-size: 12px;
        }

        me > p {
            background: red;
        }

        .myfrag {
            background: var(--bg_color);
        }
    """

JS

  1. Allow to still run component JS, but allow to modify the logic depending on whether and which fragment it is.

    As show in the example, where there is isFragmentA and isFragmentB, a single component can be rendered as multiple fragments.

    In all 3 cases isFragmentA, isFragmentB, and else, the $els would point to different elements. In full render, it is the top-level root elements. In isFragmentA and isFragmentB, it would be the fragments' root elements.

    We can save users some work by figuring for them which fragment is the JS script running.

    For this, I suggest to declare the possible fragments and their names on the Component class:

    class MyComp(Component):
        class Fragments:
            header = "div > p"
            footer = "div:n-th(2) .row,div:n-th(3) .row"  # <-- Selector can point to multiple elems

    Thus, when rendering a component as a fragment, user would have to choose from pre-existing options:

    MyComp.fragment("header")
    # or
    MyComp.fragment("footer")

    We could then pass this info to the JS script and access it e.g. via $fragment:

    if ($fragment === "header") {
        doSomething();
    } else if ($fragment === "footer") {
        doSomethingElse();
    } else {
        processAsFullComponent();
    }
  2. Nested components that are fully present in the HTML should run as normal.

    Conversely, nested components whose ROOT elements are MISSING should NOT have their JS script ran, since we cannot guarantee correctness.

    This is easy to achieve, because how things work now is that we first load shared JS, then register the components, and only at the end actually call the components' JS one-by-one.

    One of the inputs for calling component's JS are the root elements, as identified by their data-comp-id ID.

    So if we have a fully rendered HTML like this:

    <main data-comp-id-555555 data-comp-css-0d0d0d> <---- Outer component
        <div class="mycomp" data-comp-id-3c0103 data-comp-css-1ab2c3> <---- Inner component
            <p>
                First - {{ my_var }}
            </p>
            <p class="myfrag">
                Second - {{ my_var }}
            </p>
            <p class="myfrag">
                Third - {{ my_var }}
            </p>
        </div>
    </main>

    The order of JS execution would be:

    1. data-comp-id-555555
    2. data-comp-id-3c0103

    Next, if our fragment selector was .mycomp, we would still have the inner component:

    <div class="mycomp" data-comp-id-3c0103 data-comp-css-1ab2c3> <---- Inner component
        <p>
            First - {{ my_var }}
        </p>
        <p class="myfrag">
            Second - {{ my_var }}
        </p>
        <p class="myfrag">
            Third - {{ my_var }}
        </p>
    </div>

    In this case above, we would first call the root component's JS with the fragment elements, and then the inner component:

    1. data-comp-id-555555 (as fragment)
    2. data-comp-id-3c0103

    But if our selector was myfrag, then data-comp-id-3c0103 would no longer be present, because it was neither the component where we called fragment(), nor its root present.

    So order of JS exec would be:

    1. data-comp-id-555555 (as fragment)

CSS

  1. Styles that apply to the fragment even after discarding the rest of the HTML should still be applied.

    So I take the original template, and assume that the selector for our fragment is .myfrag, then this should left us with:

    <p class="myfrag">
        Second - {{ my_var }}
    </p>
    <p class="myfrag">
        Third - {{ my_var }}
    </p>

    Looking at the CSS, ASSUMING we interpret me as the ORIGINAL ROOT, then the CSS looks like:

    [data-comp-id-1bcdef] {
        font-size: 12px;
    }
    
    [data-comp-id-1bcdef] > p {
        background: red;
    }
    
    .myfrag {
        background: var(--bg_color);
    }

    Then, given this, we'd expect .myfrag to be still applied.

    Notes:

    • For this to work, we will need to include all the same CSS files as if we rendered the component fully, so that the CSS takes the same effect.

    • Moreover, we will need to do the css-inline-scoping ourselves at server-side, so that the me is properly interpreted as the component root. Because if we inserted a CSS with the me selector inside the fragment, it would incorrect interpret the fragment root as me.

  2. Fragment CSS should still have access to the same CSS variables as original component.

    In the example, I used CSS var --bg_color. But given how the CSS variables are implemented, then child components should have access to the CSS variables from their parents.

    Hence, this behavior should be kept even for fragments.

    The access to CSS vars is via the data-comp-css-a1b2c3 HTML attributes.

    So if I have a rendered HTML like so:

    <main data-comp-id-555555 data-comp-css-0d0d0d> <---- Outer component
        <div class="mycomp" data-comp-id-3c0103 data-comp-css-1ab2c3> <---- Inner component
            <p>
                First - {{ my_var }}
            </p>
            <p class="myfrag">
                Second - {{ my_var }}
            </p>
            <p class="myfrag">
                Third - {{ my_var }}
            </p>
        </div>
    </main>

    Then the fragment HTML should still have access to the variables defined in data-comp-css-0d0d0d and data-comp-css-1ab2c3.

    There might be a smarter way about it, but one approach could be to look for all data-comp-css- attributes in the ancestor chain of the root fragment elements, and apply all those attributes to the fragment elements themselves.

    So we would get something like:

    <p class="myfrag" data-comp-css-0d0d0d data-comp-css-1ab2c3>
        Second - {{ my_var }}
    </p>
    <p class="myfrag" data-comp-css-0d0d0d data-comp-css-1ab2c3>
        Third - {{ my_var }}
    </p>

    Since we've already established in the previous point that we should include all the same CSS files as the fully rendered component, we WOULD have access to the variables under the IDs data-comp-css-1ab2c3 and data-comp-css-0d0d0d.

HTML

Not much to say about HTML. I think I've seen somewhere some lib(s) with special syntax for defining the fragments. But for simplicity I suggest using normal HTML syntax. So users could pick any attribute(s). E.g.:

class MyComp(Component):
    class Fragments:
        btn = '[role="button"]'

    template = """
    <div>
        <div role="button">
        </div>
    </div>
    """
EmilStenstrom commented 1 month ago

This looks like an elegant way to define fragments. But why would you need fragments? Is there a use-case you have in mind?

JuroOravec commented 1 month ago

Not for me, but seems like the combo of Django + HTMX + fragments keeps bouncing around Django forums / reddits.

dylanjcastillo commented 1 month ago

Hello! I haven't been around in a while, but just wanted to say this would be a great feature for HTMX users!

That, plus dynamic loading of JS/CSS, would make django-components by far the best way to build Django+HTMX projects.

Also, it's amazing how much this has grown so fast. Hats off to you both!

I hope to start contributing again in Q4.

EmilStenstrom commented 1 month ago

@dylanjcastillo Can you shed some light into why one would use fragements, and not whole components? I'm thinking it must be easier to reload the whole component compared to keeping track of minor fragments within a component?

dylanjcastillo commented 1 month ago

Here's an example (copied verbatim from this article from the creator of HTMX):

<html>
    <body>
        <div hx-target="this">
          #fragment archive-ui
            #if contact.archived
            <button hx-patch="/contacts/${contact.id}/unarchive">Unarchive</button>
            #else
            <button hx-delete="/contacts/${contact.id}">Archive</button>
            #end
          #end
        </div>
        <h3>Contact</h3>
        <p>${contact.email}</p>
    </body>
</html>

Based on that template, you can render the full component:

  Contact c = getContact();
  ChillTemplates.render("/contacts/detail.html", "contact", c);

Or the archive-ui fragment:

  Contact c = getContact();
  ChillTemplates.render("/contacts/detail.html#archive-ui", "contact", c);

The benefit would be all the logic could live in the same component/file, giving you a better locality of behavior.

Maybe with the latest changes to the library, there's a way to have something like this without fragments? If that's the case, documenting it would likely be enough.

EmilStenstrom commented 1 month ago

@dylanjcastillo Thanks for the example!

There's a link to six python/django packages at the end of that link, I'll to through them below:

  1. https://pypi.org/project/django-render-block/
    • Reuses block tags and adds a render_block_from_string tag that just picks up a block from the template and renders only that one.
    • Looks to me like a way of avoiding components altogether, and reusing the existing view templates and re-rendering parts of them.
  2. https://github.com/sponsfreixes/jinja2-fragments
    • "With jinja2, if you have a template block that you want to render by itself and as part of another page, you are forced to put that block on a separate file and then use the include tag (or Jinja Partials) on the wrapping template."
    • Same thing, avoid building component and just pick part of a full template to re-render
  3. and 4. https://github.com/mikeckennedy/jinja_partials - https://github.com/mikeckennedy/chameleon_partials
  4. https://github.com/basxsoftwareassociation/htmlgenerator
    • HTML Generator from nested python constructs. Has support for rendering parts of a that tree by specifying Fragment nodes with a name. Examples include the body tag, meaning that this is likely supposed to be used for rendering "parts of a page".
  5. https://pypi.org/project/django-template-partials/

All in all the reasoning from people is that fragments is an ALTERNATIVE to components. Instead of breaking things out into smaller components, just reuse your existing full-page templates, and pull in parts of them.

Based on that, I think the current way of rendering a component independently via HTTP will be enough to make django_components a great fit with HTMX devs.

JuroOravec commented 1 month ago

The way I would look at this feature is how easy / difficult it is to implement it for users.

HTML

When we consider only the HTML, I agree with @EmilStenstrom that fragments could be easily done as separate components. One would maybe need to reuse some logic in get_context_data in both the parent and child component, but this could be done by refactoring it out:

def shared_logic(abc, xyz): -> Dict:
    return {"abc": abc, "xyz": xyz}

class MyComp(Component):
    def get_context_data(self, abc, xyz):
        return shared_logic(abc, xyz)

class MySubComp(Component):
    def get_context_data(self, abc, xyz):
        return shared_logic(abc, xyz)

CSS

Let's say someone had template that used the same CSS variables in and outside of the fragment. Then I could achieve this by sharing the CSS variables similarly as above:

def shared_css(color): -> Dict:
    return {"background": color}

class MyComp(Component):
    def get_context_css(self, color):
        return shared_css(color)

class MySubComp(Component):
    def get_context_css(self, color):
        return shared_css(color)

A more complex case could be if I use {% component %} INSIDE the fragment's HTML, whereas the component referenced via {% component %} would expect to have CSS, maybe also CSS vars, that's shared in both the root component, and the fragment. In that case, we can still achieve this, but would already lead to some duplication:

def shared_css(color): -> Dict:
    return {"background": color}

shared_css: types.css = """
    .shared-class {
        background: var(--background);
     }
"""

class MyComp(Component):
    def get_context_css(self, color):
        return shared_css(color)

    css: types.css = f"""
        {shared_css}

        .my-class {
            text: blue;
            background: var(--background);
         }
    """

class MySubComp(Component):
    def get_context_css(self, color):
        return shared_css(color)

    css: types.css = f"""
        {shared_css}

        .my-frag {
            text: red;
            background: var(--background);
         }
    """

JS

If I imagine I have a list of items, where the items can be rendered as fragments, then I'd expect that it is the fragment's JS that registers the item into the list (so that the parent component could run some logic that applies to all list items).

On the other hand, if there was a single component, then it would need to know somehow whether the JS that we're running is for the whole component, or for a specific fragment. For that I suggested the $fragment variable in the component's JS in the original post:

if ($fragment === "header") {
    doSomething();
} else if ($fragment === "footer") {
    doSomethingElse();
} else {
    processAsFullComponent();
}

If there was a different component for the top-level component, and for the fragment, then I think that in terms of JS, it would be just a matter of splitting the JS script, and moving the relavant block to the fragment's component.

Media

There may also be shared CSS and JS scripts defined in Components.Media.js/css, I expect this would be 1-3 scripts at most, so could be shared

All combined

Actually, I think if there was a lot of shared logic, scripts and CSS, it would probably make more sense to define the fragments as subclasses, to avoid defining all the methods duplicately:

class MyComp(Component):
    class Media:
        js = ["script1.js", "script2.js"]
        css = ["styl1.css", "style2.css"]

    # CSS includes also fragment's styling
    css: types.css = """
        .shared-class {
            background: var(--background);
         }
        .my-class {
            text: blue;
              background: var(--background);
         }
        .my-frag {
            text: red;
            background: var(--background);
         }
    """

class MySubComp(MyComp):
    # JS / CSS media are the same. Same as:
    # Media = MyComp.Media

    # CSS is the same. Same as:
    # css = MyComp.css
    ...

Although subclassing might not work with component typing. Because if I define typing for the top-level component:

class MyCompKwargs(TypedDict):
    ...

class MyCompSlots(TypedDict):
    ...

class MyCompData(TypedDict):
    ...

class MyComp(Component[EmptyTuple, MyCompKwargs, MyCompSlots, MyCompData]):
    ...

Then I would want to inherit from MyComp, but that one already has the types set for args, kwargs, slots, data

class MySubCompKwargs(TypedDict):
    ...

# How to specify that `MySubomp` accepts `MySubCompKwargs` for kwargs?
class MySubComp(MyComp):
    ...

I can think of 2 ways to work around that:

  1. Use cast(SubComp, Component), e.g.

    class MySubCompKwargs(TypedDict):
        ...
    
    # How to specify that `MySubComp` accepts `MySubCompKwargs` for kwargs?
    # Like this:
    MySubCompType = Component[EmptyTuple, MySubCompKwargs, MyCompSlots, MyCompData]
    
    class MySubComp(cast(MySubCompType, MyComp)):
        ...

    But subclassing means that MySubComp might also inherit the same get, post, etc, methods. And if MySubComp has different inputs, I would assume that the get / post / etc methods would also differ.

  2. So my approach would be to define a "base class", that defines the shared JS, CSS, etc. So then the top-level component could use different get / post / etc methods:

    class _MyComp:
        class Media:
            js = ["script1.js", "script2.js"]
            css = ["styl1.css", "style2.css"]
    
        # CSS includes also fragment's styling
        css: types.css = """
            .shared-class {
                background: var(--background);
             }
            .my-class {
                text: blue;
                  background: var(--background);
             }
            .my-frag {
                text: red;
                background: var(--background);
             }
        """
    
    class MyComp(Component[EmptyTuple, MyCompKwargs, MyCompSlots, MyCompData], _MyComp):
        def get(self, request)
            return MyComp.render_to_response(kwargs={"background": request.GET['background'] })
        ...
    
    class MySubComp(Component[EmptyTuple, MySubCompKwargs, EmptyDict, MySubCompData], _MyComp):
        ...

    Note, though, that there might still be some issues with typing in _MyComp. So maybe it might actually subclass with types provided (Component[...]). In which case we would still need to do cast(), so the fragment's signature might look like so:

    MyCompType = Component[EmptyTuple, MyCompKwargs, MyCompSlots, MyCompData]
    
    # Base component, using the type of the top-level component
    class _MyComp(MyCompType):
        ...
    
    # Top-level component
    class MyComp(_MyComp):
        ...
    
    MySubCompType = Component[EmptyTuple, MySubCompKwargs, EmptyDict, MySubCompData]
    
    # Fragment component
    class MySubComp(MySubCompType, _MyComp):
        ...

So overall, it SHOULD be possible to use fragments also without explicitly supporting them, though there might be some workaround to make the typing work.

Using a Fragment nested class to define the fragments would definitely be cleaner, as for the typing we'd need to define types only for the top-level component:

class MyComp(Component[EmptyTuple, MySubCompKwargs, EmptyDict, MySubCompData]):
    class Fragments:
        sub_frag = "#frag-id"
    ...

But all of the above is just hypothetical, I haven't tried it in-code yet.

EmilStenstrom commented 1 month ago

@JuroOravec I like the idea of having "class Fragment" where you specify CSS selectors. It's much cleaner than targeting {% block %} or {% partial %} tags that you have to sprinkle around the templates. I also like the idea of being able to send in a fragment name when rendering, and only have that fragment rendered. The main question here is if this is something that people will use, or if the existing component tree is enough for most use-cases? Then we wouldn't need to complicate it further. This would also encourage larger components with fragments, instead of many smaller ones. What do we recommend?

As soon as we go past the python stuff, and start to mess with CSS or JS, we quickly end up with magic stuff that will be hard to follow. I'm least enthusiastic about those parts.

JuroOravec commented 1 month ago

As soon as we go past the python stuff, and start to mess with CSS or JS, we quickly end up with magic stuff that will be hard to follow. I'm least enthusiastic about those parts.

Oh, just wait till you see the code for the client-side dependency mgmt 😄

But jokes aside

The main question here is if this is something that people will use, or if the existing component tree is enough for most use-cases? Then we wouldn't need to complicate it further. This would also encourage larger components with fragments, instead of many smaller ones. What do we recommend?

Tbh difficult for me to say, @dylanjcastillo and others will have to advocate for this one. Personally, I'm not a big fan of the fragment pattern, and I share the concern about encouraging larger components vs smaller, but that's because, coming from Vue/React, I prefer to split the logic and presentation layers, to have finer control.

But at the same time, currently I have a harder time navigating when there's more than one component in a file, because python formatting requires a lot of empty lines. So if I knew I was rendering a component using .render() (that is, not via the component tag) personally I would probably go with a single component with extra fragment, over multiple components.

Lastly, I see no problem with adding support for it if there's people coming to Django from other areas, for whom the fragment pattern might be a sufficient / pragmatic approach.