EmilStenstrom / django-components

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

Single file components #183

Closed vb8448 closed 10 months ago

vb8448 commented 2 years ago

Have you ever thought to implement a single file component, similar to vuejs ?

Eg.:

@component.register("calendar")
class Calendar(component.Component):
    # This component takes one parameter, a date string to show in the template
    def get_context_data(self, date):
        return {
            "date": date,
        }

    template = """
        <div class="calendar-component">Today's date is <span>{{ date }}</span></div>
    """
    css = """
        .calendar-component { width: 200px; background: pink; }
        .calendar-component span { font-weight: bold; }
    """
    js = """
        (function(){
            if (document.querySelector(".calendar-component")) {
                document.querySelector(".calendar-component").onclick = function(){ alert("Clicked calendar!"); };
            }
        })()
    """
EmilStenstrom commented 2 years ago

This is a great idea. I have thought of ways of making this happen. I don't think I like having everything as strings, because you lose all syntax highlighting in editors. Is there a way to get syntax highlighting some other way?

vb8448 commented 2 years ago

you lose all syntax highlighting in editors. Is there a way to get syntax highlighting some other way?

Pycharm seems to support language injections and I guess also vscode does. Probably a specific extension will be needed.

EmilStenstrom commented 2 years ago

I did some reading on this, and found a couple of crazy solutions such as https://github.com/twidi/mixt/ which have solved this in a mix of crazy and fantastic. But there's really nothing good out there. Language injections works in pycharm, but not in vscode. This also bypasses Django's template loading system, which loses caching.

Overall, I think using strings like in your quick example would quickly break down for bigger components. So right now it's a "wontfix".

I do really like to have a solution for this, but I just don't see a solution I'd like to recommend to users. Losing syntax highlighting in editors seems like the major one right now.

aymericderbois commented 2 years ago

I love the idea of having the template directly in a component variable. This is especially useful when you want to make small components! Unfortunately for syntax highlighting, I think there is no solution. Even with Pycharm, the support of language injections is limited.

Nevertheless, as this is the kind of functionality I could use, I tried to make some tests and I made a quick development that allows to have "inline template".

Here is a "countdown" component, with the template directly in the component :

@component.register("countdown")
class Countdown(component.Component):
    template_string = """
        <span
          data-ended-at-timestamp="{{ until.timestamp }}"
          x-data="{
                interval: null,
                init() {
                    this.endTimestamp = parseInt($el.dataset.endedAtTimestamp);
                    this.processCountdown();
                    this.interval = setInterval(() => this.processCountdown(), 1000);
                },
                processCountdown() {
                    const currentTimestamp = Math.floor(Date.now() / 1000)
                    const remainingTime = this.endTimestamp - currentTimestamp;
                    if ($refs.days === undefined && this.interval !== null) {
                        clearInterval(this.interval)
                    } else {
                        $refs.days.innerHTML = Math.floor(remainingTime / 60 / 60 / 24)
                        $refs.hours.innerHTML = Math.floor(remainingTime / 60 / 60) % 24
                        $refs.minutes.innerHTML = Math.floor(remainingTime / 60) % 60
                        $refs.seconds.innerHTML = Math.floor(remainingTime) % 60
                    }
                }
            }"
        >
            <span x-ref="days"></span>j
            <span x-ref="hours"></span>h
            <span x-ref="minutes"></span>ms
            <span x-ref="seconds"></span>s
        </span>
    """

    def get_context_data(self, until: datetime):
        return {
            "until": until,
        }

The commit applying the modification is here : https://github.com/aymericderbois/django-components/commit/d650a9250812e708f441f4c4b488748a402f8a6c

The advantage of this implementation is to keep the use of the Django template system. I did this quickly, so I guess it could be greatly improved.

EmilStenstrom commented 2 years ago

Thanks for taking the time to show how it could look like.

I really don't like the developer ergonomics of having to write html, css, and js inside of python strings. If there was (when there is?) a way to enable syntax highlighting in some clever way, I would be OK with this change. But as it stands, I don't think we should encourage that kind of code, and instead only support having separate files in a directory.

Since all the changes are in the Component class, I think you could make your own component base, and inherit from that inside your project? That way, we wouldn't have to build this as part of the core django-components.

EmilStenstrom commented 1 year ago

Hmm. I keep thinking about how to get this done, because it would a lot simpler. Maybe we should write a custom VSCode extension that can highlight our custom file format?

EmilStenstrom commented 1 year ago

I have opened the following PR to try to get editors to highlight this code: https://github.com/microsoft/vscode/issues/169560

EmilStenstrom commented 1 year ago

The Python extension people with VSCode are currently thinking about adding support for this (!). All we need right now are more upvotes on this ticket: https://www.github.com/microsoft/pylance-release/issues/3874

Is this something that you, or your colleague, or maybe your grandmother would find useful? Please add a 👍 to that issue, and tell your friends! We're doing this!!

rtr1 commented 1 year ago

I was able to get syntax highlighting to work using https://github.com/samwillis/python-inline-source

image

EmilStenstrom commented 1 year ago

@rtr1 This is FANTASTIC news! This solved the major pain point I see with having inline components like this. I'm definitely open to allowing inline css/javascript now! Maybe also template code?

rtr1 commented 1 year ago

I got two Tailwind VS Code extensions to work inside Python strings.

Now with Tailwind, Alpine, and HTMX, I'm making Django components with only the .py file. I love it!

In VS Code's settings.json:

     "editor.quickSuggestions": {
        "strings": true
     },
     "tailwindCSS.includeLanguages": {
        "python": "html"
      },
      "[py]": {
        "tailwindCSS.experimental.classRegex": [
          "\\bclass=\"([^\"]*)\"",
        ]
    },
    "inlineFold.supportedLanguages": [
        "python"
    ]

And then using Tailwind CLI, in tailwind.config.js:

module.exports = {
  content: [
    './apps/**/templates/**/*.html',
    './components/**/*.html',
    './components/**/*.py'
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}
EmilStenstrom commented 1 year ago

@rtr1 Sounds great! Do you run a fork of django-components that supports inline strings? Would you mind submitting a PR?

rtr1 commented 1 year ago

@EmilStenstrom no I don't have a fork. Everything I've done so far has been vanilla django-components and third-party changes (VS Code extensions, python-inline-source library, Tailwind CLI).

What would be most helpful? Add demo/examples to docs, blog post, youtube video?

EmilStenstrom commented 1 year ago

@rtr1 I'm looking at your code example above and it specifies the CSS as a styles attribute on the Component class. That doesn't work automatically right? (I might be missing something obvious here :))

rtr1 commented 1 year ago

@EmilStenstrom ah I see the confusion. The image showing the CSS and JavaScript strings was only showing that syntax highlighting works with the python-inline-source library.

The template part works with a Python string as-is, and if I use Tailwind in the template string, then I don’t need a separate styles variable or .css file.

So the way I’m using it (with Tailwind and Alpine), I can get away with only using the template string inside the Python file, and no .html, .css, or .js file is required. Just one .py file.

EmilStenstrom commented 1 year ago

Ah, then I understand. Mind posting an example of how a real component looks with this way of writing it?

rtr1 commented 1 year ago

Here are some examples with some sample data.

A title component that sits inside a header component, and a location_menu component that's a dropdown select menu.

I think it should be possible to create a generic component library with this approach, like I'm working on a multi-select dropdown that's similar to the location_menu except it's using Alpine for the added functionality.

# title.py
from django_components import component
from django.template import Template
from sourcetypes import html

@component.register("title")
class Title (component.Component):
    template_name = ...

    def get_context_data(self, text): return {'text': text}

    def get_template(self, context, template_name: str | None = None):
        template: html = """<div class="ml-2"><h1>{{text}}</h1></div>"""
        return Template(template)
# header.py
from django.template import Template
from django_components import component
from sourcetypes import html

template: html = """
    {% load component_tags %}
    <nav class="flex items-center justify-between flex-wrap bg-teal-500 p-5 text-white font-semibold text-xl tracking-tight">
        {% component "title" text="Company Report" %}
    </nav>
"""

@component.register("header")
class Header (component.Component):
    template_name = ...

    def get_template(self, *args, **kwargs):
        return Template(template)
# location_menu.py
from django.template import Template
from django_components import component
from sourcetypes import html
from apps.company.models import Location

template: html = """
<div class="flex flex-col">

    <label for="location" class="block text-gray-600 text-sm font-bold m-2">Location</label>

    <select name="location" id="location" class="block w-full px-4 py-2 mb-1 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm">
        <option selected>Choose a location</option>
        {% for location in locations %}
            <option value="{{location.id}}">{{location.name}}</option>
        {% endfor %}

    </select>

</div>
"""

@component.register("location_menu")
class LocationMenu (component.Component):
    template_name = ...

    def get_context_data(self):
        return { 'locations': Location.objects.all().order_by("name") }

    def get_template(self, *args, **kwargs):
        return Template(template)

You can see the Tailwind code looks ugly when viewing the plain-text above, but in VS Code the components look pretty nice with Tailwind details collapsed.

image

And auto-completion works even inside the Python strings:

image

And it feels very much like working with React/Vue/etc, being able to build very small components and piece them together.

image

rtr1 commented 1 year ago

@EmilStenstrom is there currently a way to present a component as a view?

For instance, is it possible to pass the component to urlpatterns, perhaps something similar to how class-based views have the .as_view() method, or some concept like DefaultRouter from DRF that lets us map endpoints to components?

EmilStenstrom commented 1 year ago

@rtr1 Thank you for that example above, really inspiring. I think we should work on allowing single file components based on python-inline-source soon.

About "component as view": It's not possible to do this today, feel free to open a separate issue to track this. I think this is a really interesting track to explore, with the end goal to be able to build something like Phoenix LiveView in Django... It's not clear to me wether this should be part of this library or a separate one that depends on this one...

nerdoc commented 1 year ago

I dislike the idea of single file components, not because it is not convenient, but it has major drawbacks for devs. Even when syntax highlighting works, you loose things like auto suggestions, click-to-navigate to code, IDE's understanding of the code structure etc. There is another framework: Tetra, that solves this using the VSCode plugin. PyCharm does not works at all yet besides Syntax highlighting (manually). So -1 for this, efforts are better spent into other features IMHO...

rtr1 commented 1 year ago

I see at least two distinct scenarios.

  1. One is what @nerdoc brings up. I think if one has extensive JS or CSS in a component, this a very valid point, and becomes more valid as the complexity of those pieces of the puzzle increase.

  2. Two is using a “low/no JS” approach, like utilizing Tailwind and Alpine. This scenario can basically already be done with existing tooling, IDE extensions, etc.

So it may be the case that, the only practical application of SFC is one which is already possible, and no further changes would be needed. At least, it’s worth considering if this is the case or not.

EmilStenstrom commented 1 year ago

@nerdoc To be clear: The suggestion here is not to replace all existing multi-file components with single-file components. but to add single-file components as an option. If you have a complex component, that would likely be in multiple files, but simple ones could be in a single file. Again, this would be optional for you as a user, and I wouldn't want to break backwards compatibility by removing multi-file components.

nerdoc commented 1 year ago

Yeah, thats what I thought. As long as the "traditional" approach stays, a single file approach would reallly be a perfect addition.

dylanjcastillo commented 10 months ago

Would it be too crazy to have something in between? Not exactly a SFC, more like a single-file UI component + python component:

Something like this:

UI component:

<style>
    ...
</style>
<template>
    <div id="table-view-inner">
        <div class="flex flex-row justify-end">
            <p>
                Showing <span class="font-medium">{{ table_data|length }}</span> results
            </p>
        </div>
        <form class="flex flex-row align-baseline mb-4"
              hx-get="{% url 'table_view' %}"
              hx-target="#table-view-inner"
              hx-trigger="submit, change from:select[name='search_sorting']">
            ....
            <select id="search_sorting"
                    name="search_sorting"
                    class="w-1/5 bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
                <option value="most-recent"
                        {% if widget_state.search_sorting == 'most-recent' %}selected{% endif %}>Most recent</option>
                <option value="oldest"
                        {% if widget_state.search_sorting == 'oldest' %}selected{% endif %}>Oldest</option>
                <option value="most-cited"
                        {% if widget_state.search_sorting == 'most-cited' %}selected{% endif %}>Most cited</option>
                <option value="least-cited"
                        {% if widget_state.search_sorting == 'least-cited' %}selected{% endif %}>Least cited</option>
            </select>
        </form>
        {% for journal in table_data %}
            {% component 'table_row' journal=journal %}
        {% endfor %}
    </div>
</template>
<script>
    (function() {
        document.addEventListener("htmx:configRequest", function(event) {
            console.log(event.detail.triggeringEvent);
            if (event.detail.triggeringEvent.submitter) {
                event.detail.parameters["event_source"] =
                    event.detail.triggeringEvent.submitter.id;
            } else {
                event.detail.parameters["event_source"] =
                    event.detail.triggeringEvent.target.id;
            }
        });
    })();
</script>

Python Component:

from typing import Any, Dict
from django_components import component

@component.register("table_view")
class Calendar(component.Component, use_ui_component=True):
    template_name = "table_view/table_view.html"

    def get_context_data(self, table_data, widget_state) -> Dict[str, Any]:
        return {
            "table_data": table_data,
            "widget_state": widget_state,
        }

The UI component would be an HTML file, so you get syntax highlighting, etc for free but you also get more LOB on the UI side of things.

EmilStenstrom commented 10 months ago

@dylanjcastillo With HTMX and Tailwind, you are not as likely to need any style and js files at all. So I guess you can do that already...

I think the value here is having the python file contain everything you need. If it's two or four files matters less. The VSCode extension above solves inlines color highlighting nicesly.

So to me, this is just a matter of who will write the PR?

dylanjcastillo commented 10 months ago

Hey @EmilStenstrom, I agree that the single Python file is ideal, but you'd lose things like auto-formatting of the file (e.g., Prettier, djlint), which would reduce this to only very simple components.

OTOH, I'd like to get a bit more understanding of the codebase, and this could be a good starting point. So after I finish a project by the end of the month, I'll take a shot at this.

ekaj2 commented 10 months ago

1) Neovim has this plugin which works great for this concept: https://github.com/AndrewRadev/inline_edit.vim/ 2) I'd much rather have a single component in an html file than a python file as including Githubissues.

  • Githubissues is a development platform for aggregating issues.