edit dialog

This commit is contained in:
John Lancaster
2026-08-30 10:34:45 -05:00
parent 783ecf421e
commit fd5ce6f63b
3 changed files with 179 additions and 41 deletions
@@ -44,7 +44,7 @@ Load an example only when its exact mechanic matches the task:
- [binding transforms](./examples/data_binding.py): `bindable_dataclass`, `ui.date`, and typed `forward`/`backward` conversion. - [binding transforms](./examples/data_binding.py): `bindable_dataclass`, `ui.date`, and typed `forward`/`backward` conversion.
- [select events](./examples/select_events.py): `on_change`, generic Quasar events, `update:model-value`, browser-to-Python payload forwarding, and programmatic value changes. - [select events](./examples/select_events.py): `on_change`, generic Quasar events, `update:model-value`, browser-to-Python payload forwarding, and programmatic value changes.
- [editable table](./examples/editable_table.py): dataframe-to-row state, named QTable cell slots, controlled editors, Python validation, touched rows, and canonical row refresh. - [editable table](./examples/editable_table.py): dataframe-to-row state, named QTable cell slots, controlled editors, dialog-based whole-row save/cancel edits, Python validation, touched rows, and canonical row refresh.
## Defaults That Span References ## Defaults That Span References
@@ -59,4 +59,4 @@ Load an example only when its exact mechanic matches the task:
## Completion Check ## Completion Check
Before finishing, distinguish target-repository facts from reference recommendations, cite the supporting page used for framework-specific claims, state unresolved assumptions, and report the focused behavior and viewport checks performed. Before finishing, distinguish target-repository facts from reference recommendations, cite the supporting page used for framework-specific claims, state unresolved assumptions, and report the focused behavior and viewport checks performed.
@@ -3,9 +3,11 @@
# dependencies = [ # dependencies = [
# "nicegui==3.16.0", # "nicegui==3.16.0",
# "pandas", # "pandas",
# "pydantic>=2",
# ] # ]
# /// # ///
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from dataclasses import field from dataclasses import field
@@ -13,6 +15,9 @@ import pandas as pd
from nicegui import binding from nicegui import binding
from nicegui import events from nicegui import events
from nicegui import ui from nicegui import ui
from pydantic import BaseModel
from pydantic import ValidationError
from pydantic import field_validator
STATUS_OPTIONS = ["draft", "active", "archived"] STATUS_OPTIONS = ["draft", "active", "archived"]
EDITABLE_FIELDS = ("name", "quantity", "status") EDITABLE_FIELDS = ("name", "quantity", "status")
@@ -21,6 +26,52 @@ type TableValue = str | int
type TableRow = dict[str, TableValue] type TableRow = dict[str, TableValue]
class RowEditDraft(BaseModel):
name: str
quantity: int
status: str
@field_validator("name")
@classmethod
def validate_name(cls, value: str) -> str:
if not (name := value.strip()):
raise ValueError("Name is required")
return name
@field_validator("quantity", mode="before")
@classmethod
def validate_quantity(cls, value: object) -> int:
if isinstance(value, bool) or value is None:
raise TypeError("Quantity must be an integer")
if not isinstance(value, (int, float, str)):
raise TypeError("Quantity must be an integer")
if isinstance(value, float) and not value.is_integer():
raise ValueError("Quantity must be an integer")
try:
quantity = int(value)
except (TypeError, ValueError, OverflowError) as error:
raise ValueError("Quantity must be an integer") from error
if not 0 <= quantity <= 1_000:
raise ValueError("Quantity must be between 0 and 1000")
return quantity
@field_validator("status")
@classmethod
def validate_status(cls, value: str) -> str:
if value not in STATUS_OPTIONS:
raise ValueError("Unknown status")
return value
@dataclass(slots=True)
class RowEditorDialog:
open_for_row_id: Callable[[int], None]
def _validation_message(error: ValidationError) -> str:
return str(error.errors()[0]["msg"])
@binding.bindable_dataclass @binding.bindable_dataclass
class EditableRow: class EditableRow:
id: int id: int
@@ -46,6 +97,18 @@ class EditableRow:
other_strict=True, other_strict=True,
) )
def to_draft(self) -> RowEditDraft:
return RowEditDraft(name=self.name, quantity=self.quantity, status=self.status)
def validate_update(self, updates: dict[str, object]) -> RowEditDraft:
base_values = self.to_draft().model_dump()
return RowEditDraft.model_validate({**base_values, **updates})
def apply_draft(self, draft: RowEditDraft) -> None:
self.name = draft.name
self.quantity = draft.quantity
self.status = draft.status
@dataclass(slots=True) @dataclass(slots=True)
class EditableTableState: class EditableTableState:
@@ -86,30 +149,65 @@ def dataframe_to_state(dataframe: pd.DataFrame) -> EditableTableState:
return EditableTableState(rows_by_id) return EditableTableState(rows_by_id)
def normalize_edit(field: str, raw_value: object) -> TableValue: def render_row_editor_dialog(
match field: state: EditableTableState,
case "name": refresh_table: Callable[[], None],
if not isinstance(raw_value, str) or not (name := raw_value.strip()): ) -> RowEditorDialog:
raise ValueError("Name is required") selected_row_id: int | None = None
return name
case "quantity": with ui.dialog() as edit_dialog, ui.card().classes("w-96"):
if isinstance(raw_value, bool) or not isinstance(raw_value, (int, float, str)): dialog_heading = ui.label("Edit row")
raise TypeError("Quantity must be an integer") draft_name = ui.input("Name")
if isinstance(raw_value, float) and not raw_value.is_integer(): draft_quantity = ui.number("Quantity", min=0, max=1_000, precision=0)
raise ValueError("Quantity must be an integer") draft_status = ui.select(STATUS_OPTIONS, label="Status")
try: with ui.row().classes("w-full justify-end"):
quantity = int(raw_value) ui.button("Cancel", on_click=edit_dialog.close).props("flat")
except (ValueError, OverflowError) as error:
raise ValueError("Quantity must be an integer") from error def save_dialog_edit() -> None:
if not 0 <= quantity <= 1_000: nonlocal selected_row_id
raise ValueError("Quantity must be between 0 and 1000") try:
return quantity if selected_row_id is None:
case "status": raise ValueError("Select a row before saving")
if not isinstance(raw_value, str) or raw_value not in STATUS_OPTIONS:
raise ValueError("Unknown status") row_state = state.row(selected_row_id)
return raw_value if row_state is None:
case _: raise ValueError("This row no longer exists")
raise ValueError(f"Field {field!r} is not editable")
draft = row_state.validate_update(
{
"name": draft_name.value,
"quantity": draft_quantity.value,
"status": draft_status.value,
},
)
row_state.apply_draft(draft)
row_state.touched = True
edit_dialog.close()
except ValidationError as error:
ui.notify(_validation_message(error), type="negative")
except ValueError as error:
ui.notify(str(error), type="negative")
finally:
refresh_table()
ui.button("Save", icon="save", on_click=save_dialog_edit)
def open_for_row_id(row_id: int) -> None:
nonlocal selected_row_id
row_state = state.row(row_id)
if row_state is None:
ui.notify("This row no longer exists", type="negative")
return
selected_row_id = row_id
draft = row_state.to_draft()
dialog_heading.set_text(f"Edit row {row_id}")
draft_name.set_value(draft.name)
draft_quantity.set_value(draft.quantity)
draft_status.set_value(draft.status)
edit_dialog.open()
return RowEditorDialog(open_for_row_id=open_for_row_id)
def render_table(dataframe: pd.DataFrame) -> EditableTableState: def render_table(dataframe: pd.DataFrame) -> EditableTableState:
@@ -118,6 +216,7 @@ def render_table(dataframe: pd.DataFrame) -> EditableTableState:
{"name": "name", "label": "Name", "field": "name", "align": "left"}, {"name": "name", "label": "Name", "field": "name", "align": "left"},
{"name": "quantity", "label": "Quantity", "field": "quantity", "align": "right"}, {"name": "quantity", "label": "Quantity", "field": "quantity", "align": "right"},
{"name": "status", "label": "Status", "field": "status", "align": "left"}, {"name": "status", "label": "Status", "field": "status", "align": "left"},
{"name": "actions", "label": "Actions", "field": "id", "align": "center"},
] ]
table = ui.table( table = ui.table(
columns=columns, columns=columns,
@@ -127,59 +226,96 @@ def render_table(dataframe: pd.DataFrame) -> EditableTableState:
pagination=10, pagination=10,
).classes("w-120") ).classes("w-120")
def apply_edit(event: events.GenericEventArguments) -> None: def refresh_table() -> None:
table.update_rows(state.table_rows(), clear_selection=False)
def apply_inline_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)
if field_name not in EDITABLE_FIELDS:
raise ValueError(f"Field {field_name!r} is not editable")
row_state = state.row(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) draft = row_state.validate_update({field_name: raw_value})
setattr(row_state, field_name, normalized_value) row_state.apply_draft(draft)
row_state.touched = True row_state.touched = True
except ValidationError as error:
ui.notify(_validation_message(error), type="negative")
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) refresh_table()
def show_changes() -> None: def show_changes() -> None:
changed_rows = state.touched_rows() changed_rows = state.touched_rows()
if not changed_rows: if not changed_rows:
ui.notify("No rows changed") ui.notify("No rows changed")
return return
summary = "; ".join( for row in changed_rows:
f"{row.id}: {row.name}, quantity {row.quantity}, status {row.status}" for row in changed_rows ui.notify(f"Changed row: {row.id}: {row.name}, quantity {row.quantity}, status {row.status}")
)
ui.notify(f"Changed rows: {summary}")
row_editor = render_row_editor_dialog(state, refresh_table)
_add_slots(
table,
apply_inline_edit,
row_editor.open_for_row_id,
)
with ui.row().classes("w-120 justify-end"):
ui.button("Show changes", icon="edit_note", on_click=show_changes)
return state
def open_dialog_for_row(open_editor: Callable[[int], None], event: events.GenericEventArguments) -> None:
try:
row_id = int(event.args)
open_editor(row_id)
except (TypeError, ValueError):
ui.notify("Invalid row key", type="negative")
def _add_slots(
table: ui.table,
apply_inline_edit: Callable[[events.GenericEventArguments], None],
open_editor: Callable[[int], None],
):
with table.add_slot("body-cell-name"), table.cell("name"): with table.add_slot("body-cell-name"), table.cell("name"):
name_input = ui.input().props(remove="value") name_input = ui.input().props(remove="value")
name_input.props(':value="props.value" dense borderless debounce=400').on( name_input.props(':value="props.value" dense borderless debounce=400').on(
"update:value", "update:value",
handler=apply_edit, handler=apply_inline_edit,
js_handler="(value) => emit(props.row.id, props.col.name, value)", js_handler="(value) => emit(props.row.id, props.col.name, value)",
) )
with table.add_slot("body-cell-quantity"), table.cell("quantity"): with table.add_slot("body-cell-quantity"), table.cell("quantity"):
ui.number(min=0, max=1_000).props(':model-value="props.value" dense borderless debounce=400').on( ui.number(min=0, max=1_000).props(':model-value="props.value" dense borderless debounce=400').on(
"update:model-value", "update:model-value",
handler=apply_edit, handler=apply_inline_edit,
js_handler="(value) => emit(props.row.id, props.col.name, value)", js_handler="(value) => emit(props.row.id, props.col.name, value)",
) )
with table.add_slot("body-cell-status"), table.cell("status"): with table.add_slot("body-cell-status"), table.cell("status"):
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_inline_edit,
js_handler="(option) => emit(props.row.id, props.col.name, option.label)", js_handler="(option) => emit(props.row.id, props.col.name, option.label)",
) )
with ui.row().classes("w-120 justify-end"): with table.add_slot("body-cell-actions"), table.cell("actions"):
ui.button("Show changes", icon="edit_note", on_click=show_changes) edit_button = ui.button(icon="edit")
edit_button.props('flat round dense color=primary aria-label="Edit row"')
return state edit_button.tooltip("Edit this row").on(
"click",
handler=lambda event: open_dialog_for_row(open_editor, event),
js_handler="() => emit(props.row.id)",
)
if __name__ in {"__main__", "__mp_main__"}: if __name__ in {"__main__", "__mp_main__"}:
@@ -57,6 +57,8 @@ The complete runnable source is available as [`editable_table.py`](../examples/e
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 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`.
When a row needs an explicit save/cancel workflow, add an actions cell (for example `body-cell-actions`) that emits only the immutable row key and opens one reusable `ui.dialog`. Keep dialog controls as local draft state rather than binding directly to the authoritative row model. On **Save**, re-resolve the row by key, validate and normalize every proposed field in Python (for example with a small Pydantic draft model), and then commit all assignments together so partial validation failure cannot leave mixed old/new values. On **Cancel** or dialog dismiss, close the dialog without mutating authoritative state. This keeps the table in named cell slots and avoids the full-row templating boundary required by `QPopupEdit`.
## Persistence And Row Refresh ## Persistence And Row Refresh
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`. 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`.