EmilStenstrom / django-components

Create simple reusable template components in Django.
MIT License
1.15k stars 75 forks source link

feat: React-like render functions (or functional components) and CachedTemplate #613

Closed JuroOravec closed 1 month ago

JuroOravec commented 1 month ago

Background

Another inspo from Vuetify (the UI component library) is that, altho it's built on Vue, it uses JSX for rendering components. Their reason for that was likely that it allows for multiple components to be defined in a single file, while still having proper syntax support for the rendered template.

This would also make my work a bit easier, as the Django templates could look more similar to the original.

Because currently, if I there is a template like this (VDivider):

const VDivider = (
  vm,
  { hrProps, wrapperProps },
  { slots },
) => {
  const divider = (
    <hr class="v-divider" { ...hrProps.value } />
  );

  if (!slots.default) return divider;

  return (
    <div class="v-divider__wrapper" { ...wrapperProps.value }>
      { divider }

      <div class="v-divider__content">
        { slots.default() }
      </div>

      { divider }
    </div>
  );
};

Then I cannot succintly express if (!slots.default) return divider;. The only way I can do it is by putting the entirety of the content in a single if / else:

{% if component_vars.is_filled.default %}
    {% component "adivider_wrapper" attrs=attrs %}
        {% fill "divider" %}
            {% component "adivider_ruler" / %}
        {% endfill %}
        {% fill "default" %}
            {% component "_slot" %}
                {% slot "default" default / %}
            {% endcomponent %}
        {% endfill %}
    {% endcomponent %}

{% else %}
    {% component "adivider_ruler" attrs=attrs / %}
{% endif %}

Moreover, django-web-components also supports a React-style render function:

from django_web_components import component
from django_web_components.template import CachedTemplate

@component.register
def alert(context):
    return CachedTemplate(
        """
        <div class="alert alert-primary" role="alert">
            {% render_slot slots.inner_block %}
        </div>
        """,
        name="alert",
    ).render(context)

So I'd like to have something similar (I already have the demo).


API

The example from django-web-components doesn't include parameters, so I don't know if they allow that. But IMO I'd go with React style, where the component props are declared as function args/kwargs.

function App(props) {
  return (
    <div>
      <Welcome name="props.name" />
    </div>
  );
}

Input

However, there's two difference for Django:

  1. In Django, there's the Context object that's passed around at render time. So IMO it should be available inside the render function too.
  2. The way this functional component is implemented is that, behind the scenes, the decorator creates a subclass of Component, and this function gets called inside get_context_data(). So it could be also useful for people to be able to access the component itself.

So taking these into the consideration, the input to the functional components could look like this:

@component.register
def alert(component: Component, context: Context, name: str, *args, klass: str = ""):
    ...

So when it comes to the parameters, after the first two, the rest would be the same as defining parameters for get_context_data.

Output

Here I'm considering between two approaches:

  1. Return already-rendered content

    In this case, the body and output would be the same as in django-web-components. So it would be up to the user to create and render a template. They would be expected to return str | SafeString.

    @component.register
    def alert(component: Component, context: Context, name: str, *args, klass: str = ""):
        return CachedTemplate(
            """
            <div class="alert alert-primary" role="alert">
                {% render_slot slots.inner_block %}
            </div>
            """,
            name="alert",
        ).render(context)

    However, I don't like how it is then up to the user to manage template caching.

  2. Return Template or template string.

    On the other hand, we could ask users to return only the raw template or string thereof. And template caching would be automatically handled by django_components:

    @component.register
    def alert(component: Component, context: Context, name: str, *args, klass: str = ""):
        return """
            <div class="alert alert-primary" role="alert">
                {{ name }}   {# <------- How to render name?? #}
            </div>
        """

    However, the issue with this latter approach is that if the user would want to add some variables to the context, there would have to be some workaround for that, e.g.:

    @component.register
    def alert(component: Component, context: Context, name: str, *args, klass: str = ""):
        context["name"] = name
    
        return """
            <div class="alert alert-primary" role="alert">
                {{ name }}
            </div>
        """

Personally I like the 2nd approach more, I find it simpler / cleaner.

CachedTemplate

Idea for CachedTemplate comes from django-web-components. Basically it's a stand-in for a regular Template class, with the distinction that those Django templates that are defined through CachedTemplates are - you guessed it - cached! In the same cache as configured by the template_cache_size settings.

EmilStenstrom commented 1 month ago

A whole new API for defining components a slightly different way? I see where it comes from, but I don't like having two ways of doing the same thing. It adds mental load for people writing components, "which style do I use?" similar too how things work in the React community. React is an especially bad offender, with a community that's split between different standards, so code examples you find only can be in way too many different "styles".

PEP 20: There should be one-- and preferably only one --obvious way to do it.

JuroOravec commented 1 month ago

Fair enough, my motivation was mostly because I thought I could trim a couple of lines per component, but actually not really. And this is like 20 lines of code, so I can use it just in my project.

There are a few points I want to discuss on the template caching and template-related API, but I'll continue that over at https://github.com/EmilStenstrom/django-components/issues/589.

JuroOravec commented 1 month ago

Btw, just for completeness, if anyone comes across this thread and wants to implement a similar feature in their project, here's how I did it:

from typing import Any, Type, Protocol, Union

from django.template import Context, Template
from django_components import Component, ArgsType, KwargsType, SlotsType, DataType, SlotResult, register

class ComponentFn(Protocol):
    def __call__(
        self,
        context: Context,
        *args: Any,
        **kwargs: Any,
    ) -> Union[SlotResult, Template]: ...

def component_func(fn: ComponentFn) -> Type[Component[ArgsType, KwargsType, SlotsType, DataType]]:
    def get_context_data(self: Component, *args: Any, **kwargs: Any) -> Any:
        self.template = lambda ctx: fn(ctx, *args, **kwargs)

    # Dynamically create a Component subclass
    # See https://stackoverflow.com/a/3915434/9788634
    subclass = type(
        fn.__name__,  # type: ignore[attr-defined]
        (Component, object),
        {"get_context_data": get_context_data}
    )

    return subclass

# Example usage
@register("my_comp")
@component_func
def my_comp(context: Context, one: int, two: str, three: int = 1) -> Template:
    # NOTE:
    # - IF returns str or SafeString, assume it's rendered
    # - IF returns Template or CachedTemplate, render it with given context

    # VARIANT A: Let the rendering happing in the background
    context["abc"] = one
    return Template("""
        {{ one }}
    """)

    # VARIANT B: Explicit rendering
    context["abc"] = 123
    return Template("""
        {{ one }}
    """).render(context)