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 thecreate_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 theListParser
. Next, we update our database and return the number of activated and deactivated people as aToast
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 thePersonAttrs
specification. We also need a button to submit the form and aToast
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)