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
@@ -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__"}: