EmilStenstrom / django-components

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

Hook into the render process to allow to modify the rendered content #630

Closed JuroOravec closed 2 months ago

JuroOravec commented 2 months ago

This relates to the declarative tabs component that I've made and shared in https://github.com/EmilStenstrom/django-components/discussions/540

{% component "tabs" attrs:class="p-6 h-full" %}
  {% component "tab_item" header="Tab 1" %}
    <p>
      hello from tab 1
    </p>
    {% component "button" %}
      Click me!
    {% endcomponent %}
  {% endcomponent %}

  {% component "tab_item" header="Tab 2" %}
    Hello this is tab 2
  {% endcomponent %}
{% endcomponent %}

So, to implement this, I had to do a bit of magic:

  1. Inside tabs, I provided an array
  2. Each tab_item injects said array, and, after the item's content is rendered, it inserts the rendered content into the array.
  3. After all children of tabs are rendered, the tabs content doesn't return it's actual rendered content (which consists only of tab_items. But instead it renders its own HTML, iterating over the array of tab_item contents.

Now, to do that, I had to introduce hooks - optional functions that can be defined to be run specific position of the rendering process.

Practically, it could be done with one:

def on_render_after(self, context: Context, template: Template, content: str) -> str | SafeString | None:
    ...

The hook receives contextual info like the context, and the Template used for rendering, as well as the rendered output (content).

If the hook returns something, we use that something instead of the original output. This way, the hook can override what's returned from the component. If it doesn't return anything, the original output is used.

This is how I used it:

Tabs:

def on_render_after(self, context, template, content):
    # By the time we get here, all child TabItem components should have been
    # rendered, and they should've populated the tabs list.
    tabs: list[TabEntry] = context["tabs"]
    return _TabsImpl.render(
        kwargs={
            "tabs": tabs,
            "name": context["name"],
            "attrs": context["attrs"],
            "header_attrs": context["header_attrs"],
            "content_attrs": context["content_attrs"],
        },
    )

TabItem:

def on_render_after(self, context, template, content):
    parent_tabs: list[dict] = context["parent_tabs"]
    parent_tabs.append({
        "header": context["header"],
        "disabled": context["disabled"],
        "content": mark_safe(content.strip()),
    })

And for completeness, I would add also on_render_before, as:

def on_render_before(self, context: Context, template: Template) -> None:
    ...

Now, the reason something like this is needed is because it's currently not that easy to intercept the rendering process. One might naively try to override render, but that's just the public API, which would be bypassed when the component is rendered inside a template. One would actually have to intercept Template.render to be able to mimic on_render_before and on_render_after.

So IMO it's better to explicitly offer these hooks.

EmilStenstrom commented 2 months ago

Thanks for including the example, makes sense to me!