From 65669a2100c0a12ec17d5561de7923510bcc56a0 Mon Sep 17 00:00:00 2001 From: John Lancaster <32917998+jsl12@users.noreply.github.com> Date: Sun, 30 Aug 2026 01:01:50 -0500 Subject: [PATCH] nicegui table updates --- src/personal_mcp/docs/skills/nicegui/SKILL.md | 7 +- .../skills/nicegui/examples/data_binding.py | 30 +++++ .../skills/nicegui/examples/editable_table.py | 113 ++++++++++-------- .../skills/nicegui/examples/select_events.py | 64 ++++++++++ .../nicegui/references/binding-dataclasses.md | 37 +++++- .../nicegui/references/component-mechanics.md | 49 ++++++++ .../docs/skills/nicegui/references/tables.md | 82 ++++--------- 7 files changed, 274 insertions(+), 108 deletions(-) create mode 100644 src/personal_mcp/docs/skills/nicegui/examples/data_binding.py create mode 100755 src/personal_mcp/docs/skills/nicegui/examples/select_events.py diff --git a/src/personal_mcp/docs/skills/nicegui/SKILL.md b/src/personal_mcp/docs/skills/nicegui/SKILL.md index f8dba44..33417d5 100644 --- a/src/personal_mcp/docs/skills/nicegui/SKILL.md +++ b/src/personal_mcp/docs/skills/nicegui/SKILL.md @@ -66,7 +66,8 @@ Load [component mechanics](./references/component-mechanics.md) for: - the NiceGUI Python wrapper, element bridge, Quasar component, and Vue runtime boundaries - 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 - source research against the installed NiceGUI and bundled Quasar versions - `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 - stable row identity across sorting, filtering, and pagination - 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` ### Bindable State @@ -88,7 +89,7 @@ Load [editable tables](./references/tables.md) for: Load [bindable dataclasses](./references/binding-dataclasses.md) for: - typed local UI state -- propagation and refresh behavior +- propagation, serializable projections, persistence, and rollback behavior - nested structures and strict bindings - mutable defaults, performance, and version notes diff --git a/src/personal_mcp/docs/skills/nicegui/examples/data_binding.py b/src/personal_mcp/docs/skills/nicegui/examples/data_binding.py new file mode 100644 index 0000000..5aaa202 --- /dev/null +++ b/src/personal_mcp/docs/skills/nicegui/examples/data_binding.py @@ -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) diff --git a/src/personal_mcp/docs/skills/nicegui/examples/editable_table.py b/src/personal_mcp/docs/skills/nicegui/examples/editable_table.py index 53b906a..cfb7450 100755 --- a/src/personal_mcp/docs/skills/nicegui/examples/editable_table.py +++ b/src/personal_mcp/docs/skills/nicegui/examples/editable_table.py @@ -7,6 +7,7 @@ # /// from dataclasses import dataclass +from dataclasses import field import pandas as pd from nicegui import binding @@ -15,25 +16,49 @@ from nicegui import ui STATUS_OPTIONS = ["draft", "active", "archived"] 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: id: int name: str quantity: int 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) class EditableTableState: 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]: - 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: @@ -45,37 +70,20 @@ def dataframe_to_state(dataframe: pd.DataFrame) -> EditableTableState: raise ValueError("The id column must contain unique row keys") rows_by_id: dict[int, EditableRow] = {} - table_rows_by_id: dict[int, TableRow] = {} for record in dataframe.to_dict(orient="records"): - row_state = EditableRow( + row = EditableRow( id=int(record["id"]), name=str(record["name"]), quantity=int(record["quantity"]), status=str(record["status"]), ) - if row_state.status not in STATUS_OPTIONS: - raise ValueError(f"Unknown status {row_state.status!r}") - if row_state.id in rows_by_id: + if row.status not in STATUS_OPTIONS: + raise ValueError(f"Unknown status {row.status!r}") + if row.id in rows_by_id: raise ValueError("Row keys must remain unique after normalization") + rows_by_id[row.id] = row - table_row: TableRow = { - "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) + return EditableTableState(rows_by_id) 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") -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: state = dataframe_to_state(dataframe) columns = [ @@ -125,33 +124,40 @@ def render_table(dataframe: pd.DataFrame) -> EditableTableState: rows=state.table_rows(), row_key="id", selection="multiple", - ).classes("w-full") + pagination=10, + ).classes("w-120") def apply_edit(event: events.GenericEventArguments) -> None: try: raw_row_id, raw_field, raw_value = event.args row_id = int(raw_row_id) 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: raise ValueError("This row no longer exists") normalized_value = normalize_edit(field_name, raw_value) - previous_value = getattr(row_state, field_name) setattr(row_state, field_name, normalized_value) - try: - save_row(dataframe, row_state) - except Exception: - setattr(row_state, field_name, previous_value) - raise + row_state.touched = True except (TypeError, ValueError) as error: ui.notify(str(error), type="negative") finally: 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"): - ui.input().props(':model-value="props.value" dense borderless debounce=400').on( - "update:model-value", + name_input = ui.input().props(remove="value") + name_input.props(':value="props.value" dense borderless debounce=400').on( + "update:value", handler=apply_edit, 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( "update:model-value", 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 @@ -178,6 +187,14 @@ if __name__ in {"__main__", "__mp_main__"}: [ {"id": 101, "name": "Desk", "quantity": 4, "status": "active"}, {"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) diff --git a/src/personal_mcp/docs/skills/nicegui/examples/select_events.py b/src/personal_mcp/docs/skills/nicegui/examples/select_events.py new file mode 100755 index 0000000..6847cbe --- /dev/null +++ b/src/personal_mcp/docs/skills/nicegui/examples/select_events.py @@ -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) diff --git a/src/personal_mcp/docs/skills/nicegui/references/binding-dataclasses.md b/src/personal_mcp/docs/skills/nicegui/references/binding-dataclasses.md index 9f773e2..bc6f66b 100644 --- a/src/personal_mcp/docs/skills/nicegui/references/binding-dataclasses.md +++ b/src/personal_mcp/docs/skills/nicegui/references/binding-dataclasses.md @@ -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. -### 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 | | --- | --- | --- | diff --git a/src/personal_mcp/docs/skills/nicegui/references/component-mechanics.md b/src/personal_mcp/docs/skills/nicegui/references/component-mechanics.md index 9d45f34..a58a834 100644 --- a/src/personal_mcp/docs/skills/nicegui/references/component-mechanics.md +++ b/src/personal_mcp/docs/skills/nicegui/references/component-mechanics.md @@ -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()`. +#### 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()` 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. +#### 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 Dot suffixes use Vue's [event and key modifier rules](https://vuejs.org/guide/essentials/event-handling.html#event-modifiers): diff --git a/src/personal_mcp/docs/skills/nicegui/references/tables.md b/src/personal_mcp/docs/skills/nicegui/references/tables.md index 6892d36..37b8bb4 100644 --- a/src/personal_mcp/docs/skills/nicegui/references/tables.md +++ b/src/personal_mcp/docs/skills/nicegui/references/tables.md @@ -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. -## 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). -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. 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 flowchart LR - A[Dataframe or repository] -->|render| B[Bindable row dataclasses] - B -->|field bindings| C[QTable row payloads] - C -->|props.value| D[NiceGUI editor] - D -->|row key, field, proposed value| E[Python handler] - E --> F{validate} - F -->|accept| B - B -->|persist| A - F -->|reject| G[notify] + A[Dataframe or repository] -->|render| B[EditableTableState] + B --> C[Bindable row: fields, payload, touched] + C -->|field bindings| D[QTable row payloads] + D -->|props.value| E[NiceGUI editor] + E -->|row key, field, proposed value| F[Python handler] + F --> G{validate} + G -->|accept and mark touched| C + 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 [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. @@ -52,35 +55,11 @@ The complete runnable source is available as [`editable_table.py`](../examples/e --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.`, `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. - -## 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. +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`. ## 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 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 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 @@ -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` 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 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. 2. Use an immutable, unique `row_key`; never persist by view index. 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. -6. Attach the event listener directly to the editor and emit only row identity, field, and proposed value. -7. Validate field access, types, ranges, permissions, and record existence in Python. -8. Assign the dataclass field, persist through the owning adapter or service, and roll back that assignment on failure. +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 and normalize proposals in Python before assigning them. +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. 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. \ No newline at end of file