Ludic Logo
Edit

Click To Edit

The click to edit pattern provides a way to offer inline editing of all or part of a record without a page refresh. In this example, we create a customer which we'll be able to edit.

Demo

#

Implementation

#

Before we start implementing the endpoint rendering the HTML form, we also want to display the data of the customer. The task can be achieved by implementing the Contact endpoint which renders the data as a description list. The endpoint also requires the contact's attributes which we describe like this:

from typing import Annotated

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

class ContactAttrs(Attrs):
    id: NotRequired[str]
    first_name: Annotated[str, FieldMeta(label="First Name")]
    last_name: Annotated[str, FieldMeta(label="Last Name")]
    email: Annotated[
        str, FieldMeta(label="Email", type="email", parser=email_validator)
    ]

You may have noticed we used the Annotated marker. The reason for that is that we want to automatically create a form based on this specification later. There is also the email_validator parser which can parse and validate email of the customer. The parser could be as simple as this:

from ludic.web.parsers import ValidationError

def email_validator(email: str) -> str:
    if len(email.split("@")) != 2:
        raise ValidationError("Invalid email")
    return email

The contact endpoint handles rendering of the customer data as well as the GET and PUT HTTP methods:

from typing import Self, override

from ludic.catalog.forms import Form, create_fields
from ludic.catalog.layouts import Cluster, Stack
from ludic.catalog.items import Pairs
from ludic.web import Endpoint, LudicApp
from ludic.web.exceptions import NotFoundError
from ludic.web.parsers import Parser

from your_app.attrs import ContactAttrs
from your_app.database import db

app = LudicApp()

@app.endpoint("/contacts/{id}")
class Contact(Endpoint[ContactAttrs]):
    @classmethod
    async def get(cls, id: str) -> Self:
        contact = db.contacts.get(id)

        if contact is None:
            raise NotFoundError("Contact not found")

        return cls(**contact.dict())

    @classmethod
    async def put(cls, id: str, attrs: Parser[ContactAttrs]) -> Self:
        contact = db.contacts.get(id)

        if contact is None:
            raise NotFoundError("Contact not found")

        for key, value in attrs.validate().items():
            setattr(contact, key, value)

        return cls(**contact.dict())

    @override
    def render(self) -> Stack:
        return Stack(
            Pairs(items=self.attrs.items()),
            Cluster(
                Button(
                    "Click To Edit",
                    hx_get=self.url_for(ContactForm),
                ),
            ),
            hx_target="this",
        )

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

  • get – handles the GET request which fetches information about the contact from the database, and returns them as the Contact component to be rendered as HTML.
  • put – handles the PUT request which contains form data. Since we later use the create_rows method, it is possible to use a parser to automatically convert the form data into a dictionary. First, we check that we have the contact in our database, than we validate the data submitted by the user and update the contact in our database, and finally, we return the updated contact as the Contact component.
  • render – handles rendering of the contact data and a button to issue the HTMX swap operation replacing the content with an editable form.

The last remaining piece is the ContactForm component:

@app.endpoint("/contacts/{id}/form/")
class ContactForm(Endpoint[ContactAttrs]):
    @classmethod
    async def get(cls, id: str) -> Self:
        contact = db.contacts.get(id)

        if contact is None:
            raise NotFoundError("Contact not found")

        return cls(**contact.dict())

    @override
    def render(self) -> Form:
        return Form(
            *create_fields(self.attrs, spec=ContactAttrs),
            Cluster(
                ButtonPrimary("Submit"),
                ButtonDanger("Cancel", hx_get=self.url_for(Contact)),
            ),
            hx_put=self.url_for(Contact),
            hx_target="this",
        )

This component only implements two methods:

  • get – handles the GET request which renders the form containing the contact data stored in the database.
  • render – handles rendering of the contact form, a button to submit the form, and also a button to cancel the edit operation. We use the HTMX swap operation to replace the form with the Contact component on submit or cancel.
Made with Ludic and HTMX and 🐍 • DiscordGitHub