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:

 1   from typing import Annotated
 2   
 3   from ludic.attrs import Attrs
 4   from ludic.catalog.forms import FieldMeta
 5   from ludic.catalog.tables import ColumnMeta
 6   
 7   class PersonAttrs(Attrs, total=False):
 8       id: Annotated[str, ColumnMeta(identifier=True)]
 9       name: Annotated[str, ColumnMeta()]
10       email: Annotated[str, ColumnMeta()]
11       active: Annotated[
12           bool,
13           ColumnMeta(kind=FieldMeta(kind="checkbox", label=None)),
14       ]
15   
16   class PeopleAttrs(Attrs):
17       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:

 1   from typing import Self, override
 2   
 3   from ludic.catalog.layouts import Cluster
 4   from ludic.catalog.tables import Table, create_rows
 5   from ludic.catalog.forms import Form
 6   from ludic.web import Endpoint, LudicApp
 7   from ludic.web.exceptions import NotFoundError
 8   from ludic.web.parsers import ListParser
 9   
10   from your_app.attrs import PeopleAttrs, PersonAttrs
11   from your_app.components import Toast
12   from your_app.database import db
13   
14   app = LudicApp()
15   
16   @app.endpoint("/people/")
17   class PeopleTable(Endpoint[PeopleAttrs]):
18       @classmethod
19       async def post(cls, data: ListParser[PersonAttrs]) -> Toast:
20           items = {row["id"]: row for row in data.validate()}
21           activations = {True: 0, False: 0}
22   
23           for person in db.people.values():
24               active = items.get(person.id, {}).get("active", False)
25               if person.active != active:
26                   person.active = active
27                   activations[active] += 1
28   
29           return Toast(
30               f"Activated {activations[True]}, "
31               f"deactivated {activations[False]}"
32           )
33   
34       @classmethod
35       async def get(cls) -> Self:
36           return cls(people=[person.dict() for person in db.people.values()])
37   
38       @override
39       def render(self) -> Form:
40           return Form(
41               Table(*create_rows(self.attrs["people"], spec=PersonAttrs)),
42               Cluster(
43                   ButtonPrimary("Bulk Update", type="submit"),
44                   Toast(),
45               ),
46               hx_post=self.url_for(PeopleTable),
47               hx_target=Toast.target,
48               hx_swap="outerHTML settle:3s",
49           )

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:

 1   from typing import override
 2   
 3   from ludic.html import span, style
 4   
 5   class Toast(span):
 6       id: str = "toast"
 7       target: str = f"#{id}"
 8       styles = style.use(
 9           lambda theme: {
10               Toast.target: {
11                   "background": theme.colors.success,
12                   "padding": f"{theme.sizes.xxxxs} {theme.sizes.xxxs}",
13                   "font-size": theme.fonts.size * 0.9,
14                   "border-radius": "3px",
15                   "opacity": "0",
16                   "transition": "opacity 3s ease-out",
17               },
18               f"{Toast.target}.htmx-settling": {
19                   "opacity": "100",
20               },
21           }
22       )
23   
24       @override
25       def render(self) -> span:
26           return span(*self.children, id=self.id)
Made with Ludic and HTMX and 🐍 • DiscordGitHub