nicegui table updates

This commit is contained in:
John Lancaster
2026-08-30 01:01:50 -05:00
parent 3e2fc0ef25
commit 65669a2100
7 changed files with 274 additions and 108 deletions
@@ -66,7 +66,8 @@ Load [component mechanics](./references/component-mechanics.md) for:
- the NiceGUI Python wrapper, element bridge, Quasar component, and Vue runtime boundaries - the NiceGUI Python wrapper, element bridge, Quasar component, and Vue runtime boundaries
- deciding between constructors, bindings, Quasar props, events, slots, and frontend methods - deciding between constructors, bindings, Quasar props, events, slots, and frontend methods
- server-client state and event flow - controlled values, model events, transformed payloads, and server-authoritative edit proposals
- server-client state and event flow, validation timing, and commit policy
- detached content and external icon assets - detached content and external icon assets
- source research against the installed NiceGUI and bundled Quasar versions - source research against the installed NiceGUI and bundled Quasar versions
- `ui.select` and `ui.icon` mechanics and caveats - `ui.select` and `ui.icon` mechanics and caveats
@@ -80,7 +81,7 @@ Load [editable tables](./references/tables.md) for:
- rendering dataframe records into row-scoped bindable dataclasses - rendering dataframe records into row-scoped bindable dataclasses
- stable row identity across sorting, filtering, and pagination - stable row identity across sorting, filtering, and pagination
- NiceGUI editors in Quasar `body-cell-*` scoped slots - NiceGUI editors in Quasar `body-cell-*` scoped slots
- validation, persistence, rejection, and canonical row refresh - QTable row refresh and selection preservation after edits
- the full `body` slot required when escalating to `QPopupEdit` - the full `body` slot required when escalating to `QPopupEdit`
### Bindable State ### Bindable State
@@ -88,7 +89,7 @@ Load [editable tables](./references/tables.md) for:
Load [bindable dataclasses](./references/binding-dataclasses.md) for: Load [bindable dataclasses](./references/binding-dataclasses.md) for:
- typed local UI state - typed local UI state
- propagation and refresh behavior - propagation, serializable projections, persistence, and rollback behavior
- nested structures and strict bindings - nested structures and strict bindings
- mutable defaults, performance, and version notes - mutable defaults, performance, and version notes
@@ -0,0 +1,30 @@
from dataclasses import field
from datetime import date
from nicegui import binding
from nicegui import ui
@binding.bindable_dataclass
class ReportFilters:
start_on: date = field(default_factory=date.today)
page_size: int = 25
filters = ReportFilters()
ui.date().bind_value(
filters,
"start_on",
forward=date.fromisoformat, # control str -> model date
backward=date.isoformat, # model date -> control str
)
ui.label().bind_text_from(
filters,
"start_on",
backward=lambda value: f"Starting {value:%d %B %Y}",
)
if __name__ in {"__main__", "__mp_main__"}:
ui.run(port=8888, reload=True)
@@ -7,6 +7,7 @@
# /// # ///
from dataclasses import dataclass from dataclasses import dataclass
from dataclasses import field
import pandas as pd import pandas as pd
from nicegui import binding from nicegui import binding
@@ -15,25 +16,49 @@ from nicegui import ui
STATUS_OPTIONS = ["draft", "active", "archived"] STATUS_OPTIONS = ["draft", "active", "archived"]
EDITABLE_FIELDS = ("name", "quantity", "status") EDITABLE_FIELDS = ("name", "quantity", "status")
TableValue = str | int
TableRow = dict[str, TableValue] type TableValue = str | int
type TableRow = dict[str, TableValue]
@binding.bindable_dataclass(bindable_fields=EDITABLE_FIELDS) @binding.bindable_dataclass
class EditableRow: class EditableRow:
id: int id: int
name: str name: str
quantity: int quantity: int
status: str status: str
table_row: TableRow = field(init=False, repr=False)
touched: bool = False
def __post_init__(self) -> None:
self.table_row = {
"id": self.id,
"name": self.name,
"quantity": self.quantity,
"status": self.status,
}
for field_name in EDITABLE_FIELDS:
binding.bind_to(
self,
field_name,
self.table_row,
field_name,
other_strict=True,
)
@dataclass(slots=True) @dataclass(slots=True)
class EditableTableState: class EditableTableState:
rows_by_id: dict[int, EditableRow] rows_by_id: dict[int, EditableRow]
table_rows_by_id: dict[int, TableRow]
def row(self, row_id: int) -> EditableRow | None:
return self.rows_by_id.get(row_id)
def table_rows(self) -> list[TableRow]: def table_rows(self) -> list[TableRow]:
return list(self.table_rows_by_id.values()) return [row.table_row for row in self.rows_by_id.values()]
def touched_rows(self) -> list[EditableRow]:
return [row for row in self.rows_by_id.values() if row.touched]
def dataframe_to_state(dataframe: pd.DataFrame) -> EditableTableState: def dataframe_to_state(dataframe: pd.DataFrame) -> EditableTableState:
@@ -45,37 +70,20 @@ def dataframe_to_state(dataframe: pd.DataFrame) -> EditableTableState:
raise ValueError("The id column must contain unique row keys") raise ValueError("The id column must contain unique row keys")
rows_by_id: dict[int, EditableRow] = {} rows_by_id: dict[int, EditableRow] = {}
table_rows_by_id: dict[int, TableRow] = {}
for record in dataframe.to_dict(orient="records"): for record in dataframe.to_dict(orient="records"):
row_state = EditableRow( row = EditableRow(
id=int(record["id"]), id=int(record["id"]),
name=str(record["name"]), name=str(record["name"]),
quantity=int(record["quantity"]), quantity=int(record["quantity"]),
status=str(record["status"]), status=str(record["status"]),
) )
if row_state.status not in STATUS_OPTIONS: if row.status not in STATUS_OPTIONS:
raise ValueError(f"Unknown status {row_state.status!r}") raise ValueError(f"Unknown status {row.status!r}")
if row_state.id in rows_by_id: if row.id in rows_by_id:
raise ValueError("Row keys must remain unique after normalization") raise ValueError("Row keys must remain unique after normalization")
rows_by_id[row.id] = row
table_row: TableRow = { return EditableTableState(rows_by_id)
"id": row_state.id,
"name": row_state.name,
"quantity": row_state.quantity,
"status": row_state.status,
}
for field_name in EDITABLE_FIELDS:
binding.bind_to(
row_state,
field_name,
table_row,
field_name,
other_strict=True,
)
rows_by_id[row_state.id] = row_state
table_rows_by_id[row_state.id] = table_row
return EditableTableState(rows_by_id, table_rows_by_id)
def normalize_edit(field: str, raw_value: object) -> TableValue: def normalize_edit(field: str, raw_value: object) -> TableValue:
@@ -104,15 +112,6 @@ def normalize_edit(field: str, raw_value: object) -> TableValue:
raise ValueError(f"Field {field!r} is not editable") raise ValueError(f"Field {field!r} is not editable")
def save_row(dataframe: pd.DataFrame, row_state: EditableRow) -> None:
matching_rows = dataframe["id"].eq(row_state.id)
if int(matching_rows.sum()) != 1:
raise ValueError("This row no longer exists")
dataframe.loc[matching_rows, "name"] = row_state.name
dataframe.loc[matching_rows, "quantity"] = row_state.quantity
dataframe.loc[matching_rows, "status"] = row_state.status
def render_table(dataframe: pd.DataFrame) -> EditableTableState: def render_table(dataframe: pd.DataFrame) -> EditableTableState:
state = dataframe_to_state(dataframe) state = dataframe_to_state(dataframe)
columns = [ columns = [
@@ -125,33 +124,40 @@ def render_table(dataframe: pd.DataFrame) -> EditableTableState:
rows=state.table_rows(), rows=state.table_rows(),
row_key="id", row_key="id",
selection="multiple", selection="multiple",
).classes("w-full") pagination=10,
).classes("w-120")
def apply_edit(event: events.GenericEventArguments) -> None: def apply_edit(event: events.GenericEventArguments) -> None:
try: try:
raw_row_id, raw_field, raw_value = event.args raw_row_id, raw_field, raw_value = event.args
row_id = int(raw_row_id) row_id = int(raw_row_id)
field_name = str(raw_field) field_name = str(raw_field)
row_state = state.rows_by_id.get(row_id) row_state = state.row(row_id)
if row_state is None: if row_state is None:
raise ValueError("This row no longer exists") raise ValueError("This row no longer exists")
normalized_value = normalize_edit(field_name, raw_value) normalized_value = normalize_edit(field_name, raw_value)
previous_value = getattr(row_state, field_name)
setattr(row_state, field_name, normalized_value) setattr(row_state, field_name, normalized_value)
try: row_state.touched = True
save_row(dataframe, row_state)
except Exception:
setattr(row_state, field_name, previous_value)
raise
except (TypeError, ValueError) as error: except (TypeError, ValueError) as error:
ui.notify(str(error), type="negative") ui.notify(str(error), type="negative")
finally: finally:
table.update_rows(state.table_rows(), clear_selection=False) table.update_rows(state.table_rows(), clear_selection=False)
def show_changes() -> None:
changed_rows = state.touched_rows()
if not changed_rows:
ui.notify("No rows changed")
return
summary = "; ".join(
f"{row.id}: {row.name}, quantity {row.quantity}, status {row.status}" for row in changed_rows
)
ui.notify(f"Changed rows: {summary}")
with table.add_slot("body-cell-name"), table.cell("name"): with table.add_slot("body-cell-name"), table.cell("name"):
ui.input().props(':model-value="props.value" dense borderless debounce=400').on( name_input = ui.input().props(remove="value")
"update:model-value", name_input.props(':value="props.value" dense borderless debounce=400').on(
"update:value",
handler=apply_edit, handler=apply_edit,
js_handler="(value) => emit(props.row.id, props.col.name, value)", js_handler="(value) => emit(props.row.id, props.col.name, value)",
) )
@@ -167,9 +173,12 @@ def render_table(dataframe: pd.DataFrame) -> EditableTableState:
ui.select(STATUS_OPTIONS).props(':model-value="props.value" dense borderless options-dense').on( ui.select(STATUS_OPTIONS).props(':model-value="props.value" dense borderless options-dense').on(
"update:model-value", "update:model-value",
handler=apply_edit, handler=apply_edit,
js_handler="(value) => emit(props.row.id, props.col.name, value)", js_handler="(option) => emit(props.row.id, props.col.name, option.label)",
) )
with ui.row().classes("w-120 justify-end"):
ui.button("Show changes", icon="edit_note", on_click=show_changes)
return state return state
@@ -178,6 +187,14 @@ if __name__ in {"__main__", "__mp_main__"}:
[ [
{"id": 101, "name": "Desk", "quantity": 4, "status": "active"}, {"id": 101, "name": "Desk", "quantity": 4, "status": "active"},
{"id": 102, "name": "Lamp", "quantity": 12, "status": "draft"}, {"id": 102, "name": "Lamp", "quantity": 12, "status": "draft"},
{"id": 103, "name": "Chair", "quantity": 8, "status": "active"},
{"id": 104, "name": "Shelf", "quantity": 3, "status": "draft"},
{"id": 105, "name": "Monitor", "quantity": 15, "status": "active"},
{"id": 106, "name": "Keyboard", "quantity": 20, "status": "active"},
{"id": 107, "name": "Mouse", "quantity": 24, "status": "active"},
{"id": 108, "name": "Dock", "quantity": 6, "status": "archived"},
{"id": 109, "name": "Cable", "quantity": 40, "status": "draft"},
{"id": 110, "name": "Stand", "quantity": 10, "status": "active"},
] ]
) )
table_state = render_table(items) table_state = render_table(items)
@@ -0,0 +1,64 @@
#!/usr/bin/env -S uv run --script
# /// script
# dependencies = [
# "nicegui==3.16.0",
# ]
# ///
from datetime import UTC
from datetime import datetime
from nicegui import events
from nicegui import ui
OPTIONS = {
"python": "Python",
"typescript": "TypeScript",
"rust": "Rust",
}
with ui.card().classes("w-140 max-w-full"):
ui.label("Select event mechanics").classes("text-xl font-semibold")
event_log = ui.log(max_lines=12).classes("w-full h-64")
def record(event_name: str, payload: object) -> None:
timestamp = datetime.now(UTC).astimezone().strftime("%H:%M:%S")
event_log.push(f"{timestamp} {event_name}: {payload!r}")
def handle_change(event: events.ValueChangeEventArguments) -> None:
record("on_change event.value", event.value)
def handle_model_update(event: events.GenericEventArguments) -> None:
record("js_handler -> handler event.args", event.args)
language = (
ui.select(
options=OPTIONS,
value="python",
label="Language",
on_change=handle_change,
with_input=True,
clearable=True,
)
.props("outlined options-dense")
.classes("w-full text-h6")
)
language.on("popup-show", lambda: record("popup-show", None), args=[])
language.on("popup-hide", lambda: record("popup-hide", None), args=[])
# This only fires when the value is changed from the browser side (not from the button)
language.on(
"update:model-value",
handler=handle_model_update,
js_handler="(...args) => emit(...args)",
)
with ui.row().classes("w-full justify-end"):
ui.button("Set Rust", on_click=lambda: language.set_value("rust"))
ui.button("Clear log", on_click=event_log.clear).props("flat")
if __name__ in {"__main__", "__mp_main__"}:
ui.run(port=8888, reload=True)
@@ -66,7 +66,42 @@ After registration, propagation follows these rules:
Since NiceGUI `2.16.0`, this depth-first walk updates each affected node once per pass. Transform functions must not depend on call count or traversal order. Since NiceGUI `2.16.0`, this depth-first walk updates each affected node once per pass. Transform functions must not depend on call count or traversal order.
### Bindable Properties Versus Active Links ## Authoritative Models And Projections
A bindable dataclass can own canonical page state while plain dictionaries or component properties act as serializable projections. Use a one-way binding from each model field to its projection when browser rendering requires a different container shape:
```python
from nicegui import binding
projection = {"name": profile.name}
binding.bind_to(
profile,
"name",
projection,
"name",
other_strict=True,
)
```
Assigning `profile.name` then propagates immediately to `projection["name"]`. The projection is transport state, not a second business model; application code should locate and mutate the owning dataclass rather than treating browser-visible dictionaries as authoritative. This distinction is especially useful when one client-side scoped template renders many records and therefore cannot bind to one fixed Python object. The [editable-table pattern](./tables.md) applies it to one row dataclass and one QTable payload per stable row identity.
Browser-originated values still require Python validation before model assignment. Keep editable fields explicit, normalize into domain types, verify permissions and record existence, and only then assign the bindable field. For the client event path that carries such proposals, see [server-authoritative edit proposals](./component-mechanics.md#server-authoritative-edit-proposals).
### Persistence And Rollback
Treat a dataframe, service, or repository as the persistence boundary around the canonical bindable model:
1. validate and normalize the proposed value
2. remember the previous model value
3. assign the normalized value so bound projections update
4. persist the model through the owning adapter, service, or repository
5. if persistence fails, restore the previous model value before reporting or re-raising the error
6. refresh the affected component from the resulting projection on both acceptance and rejection
For asynchronous persistence, await the transaction and refresh only after it commits or rolls back. Catch expected validation, conflict, and persistence exceptions separately so the interface can report actionable failures without hiding programming errors. Component-specific refresh APIs and identity rules remain the responsibility of the consuming pattern; for QTable, see [persistence and row refresh](./tables.md#persistence-and-row-refresh).
## Bindable Properties Versus Active Links
| Source | Change detection | Update timing | | Source | Change detection | Update timing |
| --- | --- | --- | | --- | --- | --- |
@@ -89,6 +89,42 @@ The right-hand side is JavaScript, not Python. It may read browser globals, call
There is one important render-function distinction. Vue template syntax allows argument-less `v-bind="object"` to spread every key in an object. A literal `.props("v-bind=someObject")` token is not compiled as a directive by NiceGUI's render-function path and does not spread the object. Use a raw `add_slot(..., template=...)` Vue template when a slot contract requires whole-object binding, or bind the documented fields individually. Vue's [render-function reference](https://vuejs.org/guide/extras/render-function.html#creating-vnodes) defines the equivalent programmatic form as passing or spreading those keys in the object supplied to `h()`. There is one important render-function distinction. Vue template syntax allows argument-less `v-bind="object"` to spread every key in an object. A literal `.props("v-bind=someObject")` token is not compiled as a directive by NiceGUI's render-function path and does not spread the object. Use a raw `add_slot(..., template=...)` Vue template when a slot contract requires whole-object binding, or bind the documented fields individually. Vue's [render-function reference](https://vuejs.org/guide/extras/render-function.html#creating-vnodes) defines the equivalent programmatic form as passing or spreading those keys in the object supplied to `h()`.
#### Controlled Values And Model Events
Vue component `v-model` expands to a value prop plus an update listener. For the common `modelValue` contract, that means `modelValue` and `update:modelValue`, as defined by the [Vue component `v-model` guide](https://vuejs.org/guide/components/v-model.html) and its tagged [compiler transform](https://github.com/vuejs/core/blob/v3.5.22/packages/compiler-core/src/transforms/vModel.ts).
NiceGUI can express a deliberately one-way controlled value with a dynamic prop and handle the corresponding proposal separately. For example, `ui.number` follows QInput's common `modelValue` contract:
```python
number_editor = ui.number()
number_editor.props(':model-value="props.value"').on(
"update:model-value",
handler=apply_proposal,
js_handler="(value) => emit(value)",
)
```
This pattern is useful inside a scoped slot or whenever Python must authorize a change before reasserting component state. The dynamic prop displays the current client-side projection; the listener sends an edit proposal to Python instead of assigning into the source object in JavaScript. Use an ordinary NiceGUI value binding when the wrapper's two-way value model already matches the requirement.
##### `ui.input` Wrapper Exception
In NiceGUI `3.16.0`, `ui.input` is a NiceGUI client wrapper around QInput rather than a direct QInput element. The tagged [`input.js` component](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/elements/input.js) defines its controlled prop and event as `value` and `update:value`, not `model-value` and `update:model-value`. It also adds a static empty `value` prop. Remove that prop before adding a row-scoped dynamic value:
```python
name_input = ui.input().props(remove="value")
name_input.props(':value="props.value"').on(
"update:value",
handler=apply_proposal,
js_handler="(value) => emit(value)",
)
```
Using `:model-value="props.value"` leaves this wrapper's own `value` unchanged, so repeated text inputs in a scoped table slot render blank. `ui.number` is a direct QInput specialization and therefore uses `model-value` and `update:model-value` as shown above. Check each NiceGUI wrapper's `VALUE_PROP` and client component before assuming the underlying Quasar model contract is exposed unchanged.
An `update:model-value` listener receives the component's emitted model value, whose shape is component-specific. A custom listener also bypasses normalization that a wrapper's built-in value handler may perform. For example, the Quasar input beneath `ui.number` can emit numeric text, so the Python proposal handler must perform authoritative numeric conversion.
NiceGUI serializes `ui.select` options into QSelect objects shaped like `{value: index, label: option_label}` and normally maps the selected object back to the corresponding Python option. A custom `js_handler` receives that object before NiceGUI's Python-side conversion. When list values and labels are intentionally identical, forward `option.label`; otherwise emit the index and resolve it against the authoritative Python options rather than trusting a browser-supplied label.
### Classes And Styles ### Classes And Styles
`.classes()` adds class names to the rendered element: `.classes()` adds class names to the rendered element:
@@ -174,6 +210,19 @@ Omit the Python handler for a client-only action, or omit `js_handler` to use Ni
Events that pass imperative JavaScript callbacks require special care. For example, QSelect's `filter` event emits an input string plus `doneFn` and `abortFn` functions. Those functions cannot be serialized for later use by Python. Use NiceGUI's wrapper-supported filtering API, or consume such callbacks synchronously in browser-side JavaScript; do not treat them as ordinary server payloads. Events that pass imperative JavaScript callbacks require special care. For example, QSelect's `filter` event emits an input string plus `doneFn` and `abortFn` functions. Those functions cannot be serialized for later use by Python. Use NiceGUI's wrapper-supported filtering API, or consume such callbacks synchronously in browser-side JavaScript; do not treat them as ordinary server payloads.
#### Server-Authoritative Edit Proposals
Treat values received from the browser as proposals, even when Quasar validation or input constraints have already run. Attach the listener to the component that emits the event, use `js_handler` to send only the identity and serializable values Python needs, and validate the field allowlist, types, ranges, permissions, record existence, and persistence constraints in Python. The browser may keep temporary editor state, but it is not the source of truth.
Choose when proposals cross the client-server boundary according to the interaction:
- Use `update:model-value` for discrete editors such as selects, switches, and checkboxes.
- For text and numeric inputs accepted during typing, use the component's documented `debounce` prop to avoid a server round trip for every keystroke.
- For an explicit save/cancel workflow, keep a local draft in a dialog or popup and emit one proposal on save.
- During asynchronous persistence, disable the editor or expose a busy state. Add an entity version or another optimistic-concurrency check when multiple clients can edit the same record.
After validation, pass accepted values to the authoritative model and persistence boundary. See [bindable dataclasses](./binding-dataclasses.md#authoritative-models-and-projections) for projection, rollback, and refresh mechanics, and [editable tables](./tables.md) for the QTable-specific form of this pattern.
#### Modifiers And High-Frequency Events #### Modifiers And High-Frequency Events
Dot suffixes use Vue's [event and key modifier rules](https://vuejs.org/guide/essentials/event-handling.html#event-modifiers): Dot suffixes use Vue's [event and key modifier rules](https://vuejs.org/guide/essentials/event-handling.html#event-modifiers):
@@ -14,35 +14,38 @@ This reference was verified against the latest released NiceGUI stack at the tim
Recheck the dependency manifest and tagged sources when the target application uses another NiceGUI release. Do not infer the Quasar or Vue version from their latest independent releases; use the versions bundled by NiceGUI. Recheck the dependency manifest and tagged sources when the target application uses another NiceGUI release. Do not infer the Quasar or Vue version from their latest independent releases; use the versions bundled by NiceGUI.
## Ownership Model ## Table Ownership Model
Treat an edit as a proposal, not a browser-side state mutation: This pattern combines [server-authoritative component events](./component-mechanics.md#server-authoritative-edit-proposals) with [bindable model projections](./binding-dataclasses.md#authoritative-models-and-projections):
1. A render function converts dataframe records into row-scoped [bindable dataclasses](./binding-dataclasses.md). 1. A render function converts dataframe records into row-scoped [bindable dataclasses](./binding-dataclasses.md).
2. Each editable dataclass field is bound to the corresponding serializable QTable row field. 2. Each bindable row owns its serializable QTable projection and whether an accepted edit has touched it.
3. A NiceGUI editor displays that projection through `props.value` in a QTable scoped slot. 3. A NiceGUI editor displays that projection through `props.value` in a QTable scoped slot.
4. The editor emits stable row identity, the field name, and the proposed value. 4. The editor emits stable row identity, the field name, and the proposed value.
5. Python locates the row dataclass, validates and assigns the value, persists the row to the dataframe or repository, and sends the resulting projection back with `table.update_rows(...)`. 5. Python locates the row, validates and assigns the value, marks it as touched, and sends the resulting projection back with `table.update_rows(...)`.
```mermaid ```mermaid
flowchart LR flowchart LR
A[Dataframe or repository] -->|render| B[Bindable row dataclasses] A[Dataframe or repository] -->|render| B[EditableTableState]
B -->|field bindings| C[QTable row payloads] B --> C[Bindable row: fields, payload, touched]
C -->|props.value| D[NiceGUI editor] C -->|field bindings| D[QTable row payloads]
D -->|row key, field, proposed value| E[Python handler] D -->|props.value| E[NiceGUI editor]
E --> F{validate} E -->|row key, field, proposed value| F[Python handler]
F -->|accept| B F --> G{validate}
B -->|persist| A G -->|accept and mark touched| C
F -->|reject| G[notify] C -->|persist touched rows later| A
G -->|reject| H[notify]
``` ```
The bindable dataclasses are the canonical page state in Python. The dataframe is the load and persistence boundary in this example; a production application can replace it with a service or repository. The browser may hold temporary editor state, but it is never the source of truth. Do not mutate `props.row` and mistake Vue reactivity for persistence. Do not use a visual row index as identity: sorting, filtering, and pagination can all change it. Set `row_key` to an immutable, unique field and send that value with every edit proposal. Do not use a visual row index as identity: sorting, filtering, and pagination can all change it. Set `row_key` to an immutable, unique field and send that value with every edit proposal. Do not mutate `props.row`; locate the row model by its stable key and let Python update the bound QTable projection.
## Recommended Cell-Slot Pattern ## Recommended Cell-Slot Pattern
[NiceGUI `ui.table`](https://nicegui.io/documentation/table) supports NiceGUI elements in scoped slots since `3.5.0`. The tagged [`Table.cell` implementation](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/elements/table.py) creates the corresponding Quasar `QTd`, while the tagged [table client component](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/elements/table.js) forwards QTable's scoped slot props. [NiceGUI `ui.table`](https://nicegui.io/documentation/table) supports NiceGUI elements in scoped slots since `3.5.0`. The tagged [`Table.cell` implementation](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/elements/table.py) creates the corresponding Quasar `QTd`, while the tagged [table client component](https://github.com/zauberzeug/nicegui/blob/v3.16.0/nicegui/elements/table.js) forwards QTable's scoped slot props.
The following example uses a render function to transform a dataframe into an `EditableTableState`. That state owns one `EditableRow` per stable identifier and one serializable QTable payload per row. NiceGUI's `binding.bind_to` links each bindable dataclass field to its corresponding payload field, so assigning `row_state.name`, `row_state.quantity`, or `row_state.status` updates the Python-side table projection immediately. The following example uses a render function to transform a dataframe into an `EditableTableState`. Its `rows_by_id` container owns one `EditableRow` per stable identifier. Each row keeps its serializable QTable payload and touched flag with the editable fields, while the container provides identity lookup and ordered projections. The detailed `binding.bind_to` propagation behavior is covered by [bindable dataclasses](./binding-dataclasses.md#authoritative-models-and-projections).
`state.touched_rows()` returns touched `EditableRow` instances that remain in `rows_by_id`, in table order. The example marks a row after an edit validates and leaves persistence to the caller, which can persist the returned dataclasses in one batch. Its **Show changes** button uses the same method to report each changed row's current ID, name, quantity, and status. Removing or replacing a row in the container automatically excludes the former object.
A QTable scoped slot is one client-side template reused for every matching cell. It cannot use `bind_value(row_state, "name")` because there is no single Python `row_state` for that template. Instead, the slot reads the bound payload through `props.value` and sends the stable key back to Python, where the handler selects and assigns the corresponding dataclass. A QTable scoped slot is one client-side template reused for every matching cell. It cannot use `bind_value(row_state, "name")` because there is no single Python `row_state` for that template. Instead, the slot reads the bound payload through `props.value` and sends the stable key back to Python, where the handler selects and assigns the corresponding dataclass.
@@ -52,35 +55,11 @@ The complete runnable source is available as [`editable_table.py`](../examples/e
--8<-- "docs/skills/nicegui/examples/editable_table.py" --8<-- "docs/skills/nicegui/examples/editable_table.py"
``` ```
This uses the same transformed-event path documented by [NiceGUI's table selection example](https://nicegui.io/documentation/table): `.on("update:model-value", ...)` attaches directly to the editor, and `js_handler` emits only the serializable values Python needs. Vue component events [do not bubble](https://vuejs.org/guide/components/events.html), so listening on the table or cell instead of the editor will not capture the editor's model update. The editor path uses the transformed-event pattern from [controlled values and model events](./component-mechanics.md#controlled-values-and-model-events). Attach the listener directly to each cell editor because Vue component events do not bubble from the editor to the cell or table. Read the QTable cell value from `props.value`, emit `props.row.<row_key>`, `props.col.name`, and the proposed value, then resolve the row in Python. NiceGUI's text-input wrapper uses `value` and `update:value`, while the number and select editors use `model-value` and `update:model-value`. Remove the text input's static `value` prop before adding its scoped `:value` binding. The select editor emits a NiceGUI-normalized option object, so this example forwards `option.label`, which is also the canonical value in `STATUS_OPTIONS`.
The `update:model-value` callback receives the emitted model value itself. Forward it with `(value) => emit(..., value)`; do not read `value.value`. For `ui.number`, the underlying Quasar input emits numeric text and NiceGUI normally performs the float conversion in its built-in value handler. Because this custom handler forwards the event, `normalize_edit` accepts numeric strings and performs the authoritative integer conversion in Python. ## Persistence And Row Refresh
The `:model-value="props.value"` prop is deliberately one-way at the client boundary. In Vue, component `v-model` expands to a `modelValue` prop plus an `update:modelValue` listener, as shown in the [Vue component `v-model` guide](https://vuejs.org/guide/components/v-model.html) and its tagged [compiler transform](https://github.com/vuejs/core/blob/v3.5.22/packages/compiler-core/src/transforms/vModel.ts). Here the update listener sends an intent to Python rather than assigning into `props.row`; Python assignment to the selected bindable dataclass then updates the corresponding table-row payload. Keep `table.rows` as the serializable projection described in [bindable dataclasses](./binding-dataclasses.md#persistence-and-rollback), not the business model. After every accepted or rejected proposal, call `table.update_rows(state.table_rows(), clear_selection=False)` so the canonical projection replaces any temporary editor display. Preserve selection only while the selected row identities remain valid; otherwise use the default `clear_selection=True`.
## Commit Policy
Choose when edits cross the client-server boundary according to the editor:
- Use `update:model-value` for discrete editors such as `ui.select`, switches, and checkboxes.
- For text and numeric inputs, use Quasar's documented `debounce` prop when accepting edits during typing. A trailing delay avoids one server round trip per keystroke.
- When the user must explicitly save or cancel a multi-field draft, keep the draft in a dialog or popup and emit one proposal on save. Python must still validate and reassert the canonical row.
- For asynchronous persistence, disable or mark the affected editor busy while saving. Add an entity version or other optimistic concurrency check when multiple clients can edit the same record.
Do not rely on browser validation alone. Quasar editor constraints improve feedback, but the event payload is still untrusted input. The Python handler must enforce the editable-field allowlist, types, ranges, permissions, record existence, and persistence constraints.
## Persistence And Refresh
Keep `table.rows` as a projection, not the business model. The row-scoped bindable dataclasses are the page model, and the dataframe or repository is its persistence boundary. On acceptance:
1. validate and coerce into domain types
2. assign the normalized value to the matching bindable dataclass field
3. persist that dataclass through the dataframe adapter, service, or repository
4. call `table.update_rows(state.table_rows(), clear_selection=False)`
On validation rejection, leave the dataclass unchanged. On persistence failure, restore its previous value before re-raising or reporting the error. Perform step 4 in either case so the field binding and canonical Python state overwrite any temporary editor display. Preserve selection only when the selected row identities remain valid; otherwise use the default `clear_selection=True`.
For database-backed applications, make the handler `async`, await the service transaction, and refresh only after it commits. Catch the application's expected validation, conflict, and persistence exceptions separately so the user receives actionable feedback without hiding programming errors.
## QTable And QPopupEdit Escalation ## QTable And QPopupEdit Escalation
@@ -92,7 +71,7 @@ That restriction changes the implementation boundary: a full `body` slot must re
1. confirm an ordinary NiceGUI editor or dialog cannot meet the interaction requirement 1. confirm an ordinary NiceGUI editor or dialog cannot meet the interaction requirement
2. copy the row structure from the matching Quasar `2.18.5` QTable documentation, not another version 2. copy the row structure from the matching Quasar `2.18.5` QTable documentation, not another version
3. keep popup draft state local rather than assigning into `props.row` 3. keep popup draft state local rather than assigning into `props.row`, following the [explicit save/cancel proposal pattern](./component-mechanics.md#server-authoritative-edit-proposals)
4. emit the stable row key, field, and saved proposal to Python 4. emit the stable row key, field, and saved proposal to Python
5. validate, persist, and replace the table rows from Python exactly as in the cell-slot pattern 5. validate, persist, and replace the table rows from Python exactly as in the cell-slot pattern
6. test keyboard focus, save, cancel, validation failure, sorting, filtering, pagination, and selection 6. test keyboard focus, save, cancel, validation failure, sorting, filtering, pagination, and selection
@@ -117,15 +96,6 @@ Replacing the full row template has a larger maintenance and accessibility surfa
- [`QPopupEdit` source](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/ui/src/components/popup-edit/QPopupEdit.js) - [`QPopupEdit` source](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/ui/src/components/popup-edit/QPopupEdit.js)
- [`QPopupEdit` API definition](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/ui/src/components/popup-edit/QPopupEdit.json) - [`QPopupEdit` API definition](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/ui/src/components/popup-edit/QPopupEdit.json)
### Vue `3.5.22`
- [Component `v-model`](https://vuejs.org/guide/components/v-model.html)
- [Component events](https://vuejs.org/guide/components/events.html)
- [Scoped slots](https://vuejs.org/guide/components/slots.html#scoped-slots)
- [`v-model` compiler transform](https://github.com/vuejs/core/blob/v3.5.22/packages/compiler-core/src/transforms/vModel.ts)
- [Component event runtime](https://github.com/vuejs/core/blob/v3.5.22/packages/runtime-core/src/componentEmits.ts)
- [Native `v-model` directives](https://github.com/vuejs/core/blob/v3.5.22/packages/runtime-dom/src/directives/vModel.ts)
## Completion Check ## Completion Check
Before accepting an editable table: Before accepting an editable table:
@@ -133,12 +103,12 @@ Before accepting an editable table:
1. Pin the NiceGUI release and verify its bundled Quasar and Vue versions. 1. Pin the NiceGUI release and verify its bundled Quasar and Vue versions.
2. Use an immutable, unique `row_key`; never persist by view index. 2. Use an immutable, unique `row_key`; never persist by view index.
3. Transform dataframe records into row-scoped bindable dataclasses during rendering. 3. Transform dataframe records into row-scoped bindable dataclasses during rendering.
4. Bind each editable dataclass field to its corresponding serializable QTable row field. 4. Keep each dataclass, serializable QTable row, and touched flag together on one bindable row.
5. Display the projected value from QTable scoped props; do not bind one shared slot template to one Python row object. 5. Display the projected value from QTable scoped props; do not bind one shared slot template to one Python row object.
6. Attach the event listener directly to the editor and emit only row identity, field, and proposed value. 6. Apply the [controlled-value event proposal](./component-mechanics.md#controlled-values-and-model-events) directly to each editor and emit only row identity, field, and proposed value.
7. Validate field access, types, ranges, permissions, and record existence in Python. 7. Validate and normalize proposals in Python before assigning them.
8. Assign the dataclass field, persist through the owning adapter or service, and roll back that assignment on failure. 8. Mark accepted rows as touched and derive touched dataclasses from the rows still held by the container.
9. Reassert canonical rows after accepted and rejected proposals. 9. Reassert canonical rows after accepted and rejected proposals.
10. Test editing after sort, filter, pagination, and selection changes. 10. Test editing after sort, filter, pagination, and selection changes.
11. Test stale rows, invalid input, persistence failure, and concurrent edits. 11. Test stale rows, invalid input, persistence failure, concurrent edits, and removal of touched bindings.
12. Use a full `body` slot for `QPopupEdit`, never a `body-cell-*` slot. 12. Use a full `body` slot for `QPopupEdit`, never a `body-cell-*` slot.