From fd5ce6f63b2af1b1abd397ab7b38722335c3f2c0 Mon Sep 17 00:00:00 2001 From: John Lancaster <32917998+jsl12@users.noreply.github.com> Date: Sun, 30 Aug 2026 10:34:45 -0500 Subject: [PATCH] edit dialog --- src/personal_mcp/docs/skills/nicegui/SKILL.md | 4 +- .../skills/nicegui/examples/editable_table.py | 214 ++++++++++++++---- .../docs/skills/nicegui/references/tables.md | 2 + 3 files changed, 179 insertions(+), 41 deletions(-) diff --git a/src/personal_mcp/docs/skills/nicegui/SKILL.md b/src/personal_mcp/docs/skills/nicegui/SKILL.md index a70d500..d592e94 100644 --- a/src/personal_mcp/docs/skills/nicegui/SKILL.md +++ b/src/personal_mcp/docs/skills/nicegui/SKILL.md @@ -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. - [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 @@ -59,4 +59,4 @@ Load an example only when its exact mechanic matches the task: ## 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. \ No newline at end of file +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. 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 cfb7450..b3f507e 100755 --- a/src/personal_mcp/docs/skills/nicegui/examples/editable_table.py +++ b/src/personal_mcp/docs/skills/nicegui/examples/editable_table.py @@ -3,9 +3,11 @@ # dependencies = [ # "nicegui==3.16.0", # "pandas", +# "pydantic>=2", # ] # /// +from collections.abc import Callable from dataclasses import dataclass from dataclasses import field @@ -13,6 +15,9 @@ import pandas as pd from nicegui import binding from nicegui import events from nicegui import ui +from pydantic import BaseModel +from pydantic import ValidationError +from pydantic import field_validator STATUS_OPTIONS = ["draft", "active", "archived"] EDITABLE_FIELDS = ("name", "quantity", "status") @@ -21,6 +26,52 @@ type TableValue = str | int 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 class EditableRow: id: int @@ -46,6 +97,18 @@ class EditableRow: 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) class EditableTableState: @@ -86,30 +149,65 @@ def dataframe_to_state(dataframe: pd.DataFrame) -> EditableTableState: return EditableTableState(rows_by_id) -def normalize_edit(field: str, raw_value: object) -> TableValue: - match field: - case "name": - if not isinstance(raw_value, str) or not (name := raw_value.strip()): - raise ValueError("Name is required") - return name - case "quantity": - if isinstance(raw_value, bool) or not isinstance(raw_value, (int, float, str)): - raise TypeError("Quantity must be an integer") - if isinstance(raw_value, float) and not raw_value.is_integer(): - raise ValueError("Quantity must be an integer") - try: - quantity = int(raw_value) - except (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 - case "status": - if not isinstance(raw_value, str) or raw_value not in STATUS_OPTIONS: - raise ValueError("Unknown status") - return raw_value - case _: - raise ValueError(f"Field {field!r} is not editable") +def render_row_editor_dialog( + state: EditableTableState, + refresh_table: Callable[[], None], +) -> RowEditorDialog: + selected_row_id: int | None = None + + with ui.dialog() as edit_dialog, ui.card().classes("w-96"): + dialog_heading = ui.label("Edit row") + draft_name = ui.input("Name") + draft_quantity = ui.number("Quantity", min=0, max=1_000, precision=0) + draft_status = ui.select(STATUS_OPTIONS, label="Status") + with ui.row().classes("w-full justify-end"): + ui.button("Cancel", on_click=edit_dialog.close).props("flat") + + def save_dialog_edit() -> None: + nonlocal selected_row_id + try: + if selected_row_id is None: + raise ValueError("Select a row before saving") + + row_state = state.row(selected_row_id) + if row_state is None: + raise ValueError("This row no longer exists") + + 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: @@ -118,6 +216,7 @@ def render_table(dataframe: pd.DataFrame) -> EditableTableState: {"name": "name", "label": "Name", "field": "name", "align": "left"}, {"name": "quantity", "label": "Quantity", "field": "quantity", "align": "right"}, {"name": "status", "label": "Status", "field": "status", "align": "left"}, + {"name": "actions", "label": "Actions", "field": "id", "align": "center"}, ] table = ui.table( columns=columns, @@ -127,59 +226,96 @@ def render_table(dataframe: pd.DataFrame) -> EditableTableState: pagination=10, ).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: raw_row_id, raw_field, raw_value = event.args row_id = int(raw_row_id) 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) if row_state is None: raise ValueError("This row no longer exists") - normalized_value = normalize_edit(field_name, raw_value) - setattr(row_state, field_name, normalized_value) + draft = row_state.validate_update({field_name: raw_value}) + row_state.apply_draft(draft) row_state.touched = True + except ValidationError as error: + ui.notify(_validation_message(error), type="negative") except (TypeError, ValueError) as error: ui.notify(str(error), type="negative") finally: - table.update_rows(state.table_rows(), clear_selection=False) + refresh_table() 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}") + for row in changed_rows: + ui.notify(f"Changed row: {row.id}: {row.name}, quantity {row.quantity}, status {row.status}") + 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"): name_input = ui.input().props(remove="value") name_input.props(':value="props.value" dense borderless debounce=400').on( "update:value", - handler=apply_edit, + handler=apply_inline_edit, js_handler="(value) => emit(props.row.id, props.col.name, value)", ) 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( "update:model-value", - handler=apply_edit, + handler=apply_inline_edit, js_handler="(value) => emit(props.row.id, props.col.name, value)", ) with table.add_slot("body-cell-status"), table.cell("status"): ui.select(STATUS_OPTIONS).props(':model-value="props.value" dense borderless options-dense').on( "update:model-value", - handler=apply_edit, + handler=apply_inline_edit, 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 + with table.add_slot("body-cell-actions"), table.cell("actions"): + edit_button = ui.button(icon="edit") + edit_button.props('flat round dense color=primary aria-label="Edit row"') + 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__"}: diff --git a/src/personal_mcp/docs/skills/nicegui/references/tables.md b/src/personal_mcp/docs/skills/nicegui/references/tables.md index 37b8bb4..6496449 100644 --- a/src/personal_mcp/docs/skills/nicegui/references/tables.md +++ b/src/personal_mcp/docs/skills/nicegui/references/tables.md @@ -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.`, `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 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`.