November 16, 2024
5 min read

Tables, tables and more tables

One of the features I really wanted to provide for developers is easy table editing. This has turned out to be quite the journey as it turns out it is not possible to have a form element inside of a table/tbody/tr. Remember that a form elements is just a collection of HTML inputs that are sent together when the form is submitted. This means that it is not possible to just define the form and the associated inputs.

A workaround exists in HTML5. It allows forms to be defined outside the table, with inputs specifying which form they are associated with. However, this feels like a clunky solution to a limitation of the HTML spec.

Another approach is to define a form that encloses the entire table. The downside of this method is that the entire table, with all rows, is submitted during the request. This might work if the user's intent is to perform a bulk update, but it’s not ideal for updating a single row. Again, this solution feels suboptimal.

The next option I considered was linking to a different page altogether to render a traditional form for editing. However, this approach felt like an over-engineered hack for something as simple as editing a table row.

It turns out there’s a better solution when using HTMX. The hx-includeattribute can effectively act as a form element in tables! By specifying the selector, you can target the inputs within a row. We just have to specify the selector. A row in HTML is defined inside a tr element. So we can use the selector hx-include="closest tr". Interestingly, this solution is well-documented in the HTMX documentation.

What surprised me most was discovering that the HTML spec explicitly disallows form elements inside certain tags. However, with HTMX resolving the issue of collecting inputs into a single request, I could focus on improving the developer experience.

A Simple Idea: Using Pydantic Models

In Ui-Wizard I want to create a simple solution. One that does not require the developer to define all of the inputs and how it should be handled. If they want something custom they can always create that.

So I started with a simple idea that a pydantic model should serve my component with all I need. Lets start with a model.

class TableData(BaseModel):
    # Hide the id input field
    id: Annotated[str, UiAnno(ui.hiddenInput)]
    input: str = Field(min_length=3)
    title: str
    des: str

I should be able to take a list of TableData instances and generate a table.

data = [
    TableData(
        id="1",
        input="This is input",
        title="Some title",
        des="Description"
    ),
]
ui.table(data)

And it should produce something like this.

idinputtitledes
1This is inputSome titleDescription


I want to enable developers to easily update change a row. To enable this three endpoints are needed. One endpoint to return the row with inputs that that a user can use to change the values. I want the developer to just define the endpoint.

@app.ui("/edit/row/{id}")
async def endpoint_edit_row(id: str):
    Table.render_edit_row(
        data[id]
    )

ui.table(data).edit_row(edit_row, "id")

The above code will create an endpoint, which takes a parameter we call id and the method edit_row takes the function endpoint as the first parameter and the second parameter is the attribute name of the model. The method will use the value of the instance model as the parameter for the endpoint.

The new table should now display an edit button.

idinputtitledes
1This is inputSome titleDescription


As the model is defined with python type hinting it is possible to infer some of the input fields.

Now lets define the save endpoint and the display row endpoint and extend the existing endpoint with reference to these new endpoints.

@app.ui("/save/row/")
async def save_row(model: TableData):
    data[model.id] = model # Save the data somewhere
    Table.render_row(data[model.id], edit_row, "id")

@app.ui("/display/row/{id}")
async def display_row(id: str):
    Table.render_row(data[id], edit_row, "id")

@app.ui("/edit/row/{id}")
async def edit_row(id: str):
    Table.render_edit_row(
        data[id],
        "id",
        save_row,
        display_row,
    )

ui.table(data).edit_row(edit_row, "id")

It is now possible to have a page with a table that we can edit as we want.

idinputtitledes
1This is inputSome titleDescription

The minimal amount of code needed to get started is impressive. Developers only need to define a Pydantic model, three endpoints, and use three table methods. This simple yet powerful API makes creating editable tables a breeze!