edit dialog
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
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:
|
||||
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")
|
||||
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,53 +226,45 @@ 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}")
|
||||
|
||||
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,
|
||||
js_handler="(value) => emit(props.row.id, props.col.name, value)",
|
||||
)
|
||||
row_editor = render_row_editor_dialog(state, refresh_table)
|
||||
|
||||
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,
|
||||
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,
|
||||
js_handler="(option) => emit(props.row.id, props.col.name, option.label)",
|
||||
_add_slots(
|
||||
table,
|
||||
apply_inline_edit,
|
||||
row_editor.open_for_row_id,
|
||||
)
|
||||
|
||||
with ui.row().classes("w-120 justify-end"):
|
||||
@@ -182,6 +273,51 @@ def render_table(dataframe: pd.DataFrame) -> EditableTableState:
|
||||
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_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_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_inline_edit,
|
||||
js_handler="(option) => emit(props.row.id, props.col.name, option.label)",
|
||||
)
|
||||
|
||||
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__"}:
|
||||
items = pd.DataFrame(
|
||||
[
|
||||
|
||||
@@ -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`.
|
||||
|
||||
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`.
|
||||
|
||||
Reference in New Issue
Block a user