Ludic Logo
Edit

Edit Row

This example shows how to implement editable rows. In this example, we create a table with a row for each person in the database. Each row contains an edit button which allows inline editing of user's data.

Demo

#
NameEmailAction
Joe Smithjoe@smith.org
Angie MacDowellangie@macdowell.org
Fuqua Tarkentonfuqua@tarkenton.org
Kim Yeekim@yee.org

Implementation

#

In this example, we'll be creating three class-based endpoints:

  1. One for displaying the person's information in a row with the edit button;
  2. Another for displaying the form for editing the person's data and an action button to confirm the changes;
  3. Last one for displaying the whole table of people.

First, we create the attributes which define what kind of data we want to display:

 1   from typing import Annotated, NotRequired
 2   
 3   from ludic.attrs import Attrs
 4   from ludic.catalog.tables import ColumnMeta
 5   
 6   class PersonAttrs(Attrs):
 7       id: NotRequired[str]
 8       name: Annotated[str, ColumnMeta()]
 9       email: Annotated[str, ColumnMeta()]
10   
11   class PeopleAttrs(Attrs):
12       people: list[PersonAttrs]

Now we create the PersonRow endpoint for displaying the person's information in a row:

 1   from typing import Self, override
 2   
 3   from ludic.base import JavaScript
 4   from ludic.catalog.buttons import ButtonPrimary
 5   from ludic.catalog.tables import TableRow
 6   from ludic.web import Endpoint, LudicApp
 7   from ludic.web.parsers import Parser
 8   
 9   from your_app.attrs import PersonAttrs
10   from your_app.database import db
11   
12   app = LudicApp()
13   
14   @app.endpoint("/people/{id}")
15   class PersonRow(Endpoint[PersonAttrs]):
16       on_click_script: JavaScript = JavaScript(
17           """
18           let editing = document.querySelector('.editing')
19   
20           if (editing) {
21               alert('You are already editing a row')
22           } else {
23               htmx.trigger(this, 'edit')
24           }
25           """
26       )
27   
28       @classmethod
29       async def put(cls, id: str, data: Parser[PersonAttrs]) -> Self:
30           person = db.people.get(id)
31   
32           if person is None:
33               raise NotFoundError("Person not found")
34   
35           for attr, value in data.validate().items():
36               setattr(person, attr, value)
37   
38           return cls(**person.dict())
39   
40       @classmethod
41       async def get(cls, id: str) -> Self:
42           person = db.people.get(id)
43   
44           if person is None:
45               raise NotFoundError("Person not found")
46   
47           return cls(**person.dict())
48   
49       @override
50       def render(self) -> TableRow:
51           return TableRow(
52               self.attrs["name"],
53               self.attrs["email"],
54               ButtonPrimary(
55                   "Edit",
56                   hx_get=self.url_for(PersonForm),
57                   hx_trigger="edit",
58                   on_click=self.on_click_script,
59                   classes=["small"],
60               ),
61           )

We created a class-based endpoint, here is the explanation of its individual parts:

  • on_click_script – this script is executed when the edit button is clicked. We don't want to allow the user to edit multiple rows at once so we check if the user is already editing a row.
  • put – handles the PUT request which updates a person in the database. We return the updated person data as a row in the response.
  • get – handles the GET request which returns an instance of the PeopleRow of the requested person.
  • render – handles rendering of the row. Apart from the textual columns, we also render an action button to edit a row.

Now, we want to create an endpoint which renders the form we want to display when a row is being edited:

 1   from typing import Self, override
 2   
 3   from ludic.catalog.buttons import ButtonSecondary, ButtonSuccess
 4   from ludic.catalog.forms import InputField
 5   from ludic.catalog.tables import TableRow
 6   from ludic.web import Endpoint
 7   
 8   from your_app.attrs import PersonAttrs
 9   from your_app.database import db
10   
11   @app.endpoint("/people/{id}/form/")
12   class PersonForm(Endpoint[PersonAttrs]):
13       @classmethod
14       async def get(cls, id: str) -> Self:
15           person = db.people.get(id)
16   
17           if person is None:
18               raise NotFoundError("Person not found")
19   
20           return cls(**person.dict())
21   
22       @override
23       def render(self) -> TableRow:
24           return TableRow(
25               InputField(name="name", value=self.attrs["name"]),
26               InputField(name="email", value=self.attrs["email"]),
27               Cluster(
28                   ButtonSecondary(
29                       "Cancel",
30                       hx_get=self.url_for(PersonRow),
31                       classes=["small"],
32                   ),
33                   ButtonSuccess(
34                       "Save",
35                       hx_put=self.url_for(PersonRow),
36                       hx_include="closest tr",
37                       classes=["small"],
38                   ),
39                   classes=["cluster-small"],
40               ),
41               classes=["editing"],
42           )

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

  • get – handles the GET request which renders the requested person's row.
  • render – handles rendering of the person's edit row. Apart from the name and email columns, we are also rendering two action buttons, one canceling the action and one saving the changes.

The last class-based endpoint is for displaying the table of people:

 1   from typing import Self, override
 2   
 3   from ludic.catalog.tables import Table, TableHead
 4   from ludic.web import Endpoint
 5   
 6   from your_app.attrs import PeopleAttrs
 7   from your_app.database import db
 8   
 9   # ... PersonRow and PersonForm classes omitted along with the imports
10   
11   @app.endpoint("/people/")
12   class PeopleTable(Endpoint[PeopleAttrs]):
13       @classmethod
14       async def get(cls) -> Self:
15           return cls(people=[person.dict() for person in db.people.values()])
16   
17       @override
18       def render(self) -> Table[TableHead, PersonRow]:
19           return Table[TableHead, PersonRow](
20               TableHead("Name", "Email", "Action"),
21               *(PersonRow(**person) for person in self.attrs["people"]),
22               body_attrs=HtmxAttrs(
23                   hx_target="closest tr"
24               ),
25               classes=["text-align-center"],
26           )

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

  • get – handles the GET request which returns an instance of the PeopleTable filled with a list of people fetched from database.
  • render – handles rendering of the table of people. We use a special body_attrs attribute to configure HTMX operations on the tbody element.
Made with Ludic and HTMX and 🐍 • DiscordGitHub