EmilStenstrom / django-components

Create simple reusable template components in Django.
MIT License
973 stars 62 forks source link

Components as views #349

Closed dylanjcastillo closed 4 months ago

dylanjcastillo commented 5 months ago

While working with this library and HTMX, I've found myself creating templates that only contain the component and a thin view on top of it. The idea is just to regenerate the component with the new data.

I've seen this pop up in other issues. So I wanted to understand your view on this @EmilStenstrom.

This is something I might be able to work on in January if you feel it'd be a useful addition to the library.

EmilStenstrom commented 5 months ago

Thanks for also making this an issue. I think this would be a good addition to the library.

Idea:

A couple of open questions:

burakyilmaz321 commented 4 months ago

I think this would be a good thing to have in django-components. Rendering a single component and sending it over the HTTP will be a growing need.

In my projects, I have this simple utility function for this need.

However, I cannot find a way of handling components with slots.

def render_component_to_string(component_name: str, *args, **kwargs) -> str:
    component_cls: Type[Component] = component_registry.get(component_name)
    component_instance: Component = component_cls()
    component_context: Dict[str, Any] = component_instance.get_context_data(*args, **kwargs)
    rendered_component: str = component_instance.render(Context(component_context))
    return rendered_component

This limitation also surfaced when I tried integrating django-components with Storybook (see the repo here) for creating component stories. The handling of slots remains a consistent obstacle.

rtr1 commented 4 months ago

I've used components as views by modifying components.py in this way:

# components.py
from django.http import HttpResponse
from django.utils.decorators import classonlymethod
# ...
class Component(metaclass=SimplifiedInterfaceMediaDefiningClass):
# ...
    def get_view_context (self, request, *args, **kwargs):
        return Context(self.get_context_data(*args, **kwargs))

    @classonlymethod
    def as_view (cls, **initkwargs):
        def view (request, *args, **kwargs):
            self = cls(**initkwargs)
            context = self.get_view_context(request, *args, **kwargs)
            rendered_component = self.render(context)
            return HttpResponse(rendered_component)
        return view

Then in the component we can override the get_view_context method, since we may want to pull the context from an HTTP request.

class Employee (component.Component):
    template_name = ...

    def get_context_data(self, text=None):
        return {'text': text or 'TITLE'}

    # Pull context from HTTP request, if needed
    def get_view_context (self, request, *args, **kwargs):
        text = request.GET.get('text')
        return Context(self.get_context_data(text))
    # ...

I also found I had to handle component registration separately, instead of using the @component.register decorator. I place a registration.py file in my components/ folder with something like this:

# registration.py
from django_components.component import register
# ...
register("employee")(EmployeeComponent)
register("sales_goal")(SalesGoalComponent)
# etc...

Because in my urls.py file I'm using the class directly and it would give an error about the component being already registered if I used the decorator.

# urls.py
urlpatterns = [
    # ...
    path('employee/', EmployeeComponent.as_view(), ...),
]

This was only my quick-and-dirty way to get it to work how I needed. I expect there are other details that need to be handled for general use.

EmilStenstrom commented 4 months ago

@rtr1 Thanks for sharing!

dylanjcastillo commented 4 months ago

@EmilStenstrom @rtr1

I tried something similar to @rtr1's approach in this branch of my fork: https://github.com/EmilStenstrom/django-components/compare/master...dylanjcastillo:django-components:render-components-as-views

I subclassed View, did some small changes to get_context_data, and removed the double registration check.

It's still missing proper tests for this functionality, but I did some quick testing on the sampleproject and ran the current tests, and it's looking good.

What I like about subclassing View is that you can easily define different logic for other HTTP methods without having to redefine as_view().

Also, about the urlpatterns approach, maybe that's more of a nice to have that could be worked on a different issue? I'm not sure if most people want to render all of their components as views. In my case, it's just a few, and feels OK to add those manually to urlpatterns.

Let me know what you think.

EmilStenstrom commented 4 months ago

Hmm... subclassing View is very powerful, but saying that components ARE views doesn't feel right somehow. The mental model for Components has always been that they are a collection of python, html, css and js. Now they are also class based views, and can be queried with HTTP OPTIONS? Maybe, but feels hard to explain.

Some loose thoughts on this area: My greater vision for this library is to slowly move towards something that works like Phoenix LiveView, and have the server be able to push updates to components on all clients. They do this via an open websocket, that streams changes to a js router, and that js routers knows about all components, and how to render each one (from component definition, the js is very light). Everything is first rendered from the serverside, and then patched with js to be responsive. So no SEO or accessibility hits. Thinking more about this, it probably makes sense to have one async view that all clients subscribe to if they want updates. Instead of each component individually, so maybe this is a separate thing altogether.

Another thing to consider is HTMX: If the component is available over HTTP, the client can simply request an update to it by calling that view, and rerendering. If all components were available automatically, rerendering a component would be as simple as setting some HTMX-attributes in the template. I really like that.

dylanjcastillo commented 4 months ago

Hmm... subclassing View is very powerful, but saying that components ARE views doesn't feel right somehow. The mental model for Components has always been that they are a collection of python, html, css and js. Now they are also class based views, and can be queried with HTTP OPTIONS? Maybe, but feels hard to explain.

Agreed. One option could be reducing the available HTTP methods using http_method_names to just the relevant ones: get, post, put, patch, and delete. But it will still be a CBV, so it might still feel too broad for the Component mental model.

Another option could be importing components into view.py, and rendering them there. It will remove the need to have a template.html, but still add the need to add a view + URL, even for simple components. I guess this is possible now, but probably the code looks a bit ugly. It also has the upside of being FBV/CBV-agnostic.

Some loose thoughts on this area: My greater vision for this library is to slowly move towards something that works like Phoenix LiveView, and have the server be able to push updates to components on all clients. They do this via an open websocket, that streams changes to a js router, and that js routers knows about all components, and how to render each one (from component definition, the js is very light). Everything is first rendered from the serverside, and then patched with js to be responsive. So no SEO or accessibility hits. Thinking more about this, it probably makes sense to have one async view that all clients subscribe to if they want updates. Instead of each component individually, so maybe this is a separate thing altogether.

This sounds great. I don't get some of the details of how is this supposed to work, but I'm happy to contribute if possible. I'm not sure if the current approach would clash with this, but I feel that it's more like an intermediate step.

Another thing to consider is HTMX: If the component is available over HTTP, the client can simply request an update to it by calling that view, and rerendering. If all components were available automatically, rerendering a component would be as simple as setting some HTMX-attributes in the template. I really like that.

But you'd have some sort of parameter to specify which components should be rendered as views and which don't? I wonder if it's always the case that you want to render all of your components as views.

burakyilmaz321 commented 4 months ago

@dylanjcastillo do you have any idea how to handle filled slots?

For example, in your implementation, how would you render a component like this one:

<div id="greeting">Hello, {{ name }}!</div>
{% slot "message" %}{% endslot %}

Which should be rendered using component_block like:

{% component_block "greeting" name="Joe" %}
    {% fill "message" %}
        Howdy?
    {% endfill %}
{% endcomponent_block %}
EmilStenstrom commented 4 months ago

@dylanjcastillo I have thought about this. I think it makes sense to make the base component also be a View like you suggested. It works well with the thought of "A component can be rendered independently and added to urls.py". I agree that making a generic url where you can get all components can be a separate issue. If you want to take a stab at this, feel free!

EmilStenstrom commented 4 months ago

@burakyilmaz321 Hm, good question. You would need a way to pass in the slot contents if you want to render the component independently. Or, I guess you could make a new component without slots, that fills in the slots like you want them to. Thoughts?

dylanjcastillo commented 4 months ago

@EmilStenstrom @burakyilmaz321 I got something working including filling in the slots. Feels a bit hacky, so would love to hear your thoughts.

I made a draft PR for it: https://github.com/EmilStenstrom/django-components/pull/366