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.
|
- [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`.
|
||||||
|
|||||||
Reference in New Issue
Block a user