Ludic Logo
Edit

Bulk Update

This demo shows how to implement a common pattern where rows are selected and then bulk updated. For the purpose of this example, we create a table containing people. We also create a column where we can mark the individual person as active or inactive.

Demo

#

Implementation

#

The first thing we need to do is create the table. For this task, we use a class-based endpoint called PeopleTable which will contain the list of people. This list of people is going to be the endpoint's attributes, so we create them first:

from typing import Annotated

from ludic.attrs import Attrs
from ludic.catalog.forms import FieldMeta
from ludic.catalog.tables import ColumnMeta

class PersonAttrs(Attrs, total=False):
    id: Annotated[str, ColumnMeta(identifier=True)]
    name: Annotated[str, ColumnMeta()]
    email: Annotated[str, ColumnMeta()]
    active: Annotated[
        bool,
        ColumnMeta(kind=FieldMeta(kind="checkbox", label=None)),
    ]

class PeopleAttrs(Attrs):
    people: list[PersonAttrs]

Each field in the PersonAttrs definition is marked by theAnnotated class containing the ColumnMeta marker. This marker helps generating the table using the create_rows function as we'll see bellow.

Now we know what kind of data we need to store and what columns to create. Here is the endpoint implementation rendering the attributes as a table as well as handling the GET and POST HTTP methods:

from typing import Self, override

from ludic.catalog.layouts import Cluster
from ludic.catalog.tables import Table, create_rows
from ludic.catalog.forms import Form
from ludic.web import Endpoint, LudicApp
from ludic.web.exceptions import NotFoundError
from ludic.web.parsers import ListParser

from your_app.attrs import PeopleAttrs, PersonAttrs
from your_app.components import Toast
from your_app.database import db

app = LudicApp()

@app.endpoint("/people/")
class PeopleTable(Endpoint[PeopleAttrs]):
    @classmethod
    async def post(cls, data: ListParser[PersonAttrs]) -> Toast:
        items = {row["id"]: row for row in data.validate()}
        activations = {True: 0, False: 0}

        for person in db.people.values():
            active = items.get(person.id, {}).get("active", False)
            if person.active != active:
                person.active = active
                activations[active] += 1

        return Toast(
            f"Activated {activations[True]}, "
            f"deactivated {activations[False]}"
        )

    @classmethod
    async def get(cls) -> Self:
        return cls(people=[person.dict() for person in db.people.values()])

    @override
    def render(self) -> Form:
        return Form(
            Table(*create_rows(self.attrs["people"], spec=PersonAttrs)),
            Cluster(
                ButtonPrimary("Bulk Update", type="submit"),
                Toast(),
            ),
            hx_post=self.url_for(PeopleTable),
            hx_target=Toast.target,
            hx_swap="outerHTML settle:3s",
        )

We created a class-based endpoint with the following methods:

  • post – handles the POST request which contains form data. Since we used the create_rows method, it is possible to use a parser to automatically convert the form data in a dictionary. In fact, we are handling a list of people, so we use the ListParser. Next, we update our database and return the number of activated and deactivated people as a Toast component that we cover bellow.
  • get – handles the GET request which fetches data about people from the database, creates a list of dicts from that and returns the table containing these people.
  • render – handles rendering of the table. The table is wrapped in a form so that it is possible to issue a POST request updating the list of active or inactive people. The form is wrapping the table having the columns created according the the PersonAttrs specification. We also need a button to submit the form and a Toast component which displays a message about activated and deactivated people. The form is submitted via HTMX.

Here is the implementation of the Toast component displaying the small message next to the Bulk Update button:

from typing import override

from ludic.html import span, style

class Toast(span):
    id: str = "toast"
    target: str = f"#{id}"
    styles = style.use(
        lambda theme: {
            Toast.target: {
                "background": theme.colors.success,
                "padding": f"{theme.sizes.xxxxs} {theme.sizes.xxxs}",
                "font-size": theme.fonts.size * 0.9,
                "border-radius": "3px",
                "opacity": "0",
                "transition": "opacity 3s ease-out",
            },
            f"{Toast.target}.htmx-settling": {
                "opacity": "100",
            },
        }
    )

    @override
    def render(self) -> span:
        return span(*self.children, id=self.id)
Made with Ludic and HTMX and 🐍 • DiscordGitHub