nicegui table updates

This commit is contained in:
John Lancaster
2026-08-30 01:01:50 -05:00
parent 3e2fc0ef25
commit 65669a2100
7 changed files with 274 additions and 108 deletions
@@ -0,0 +1,30 @@
from dataclasses import field
from datetime import date
from nicegui import binding
from nicegui import ui
@binding.bindable_dataclass
class ReportFilters:
start_on: date = field(default_factory=date.today)
page_size: int = 25
filters = ReportFilters()
ui.date().bind_value(
filters,
"start_on",
forward=date.fromisoformat, # control str -> model date
backward=date.isoformat, # model date -> control str
)
ui.label().bind_text_from(
filters,
"start_on",
backward=lambda value: f"Starting {value:%d %B %Y}",
)
if __name__ in {"__main__", "__mp_main__"}:
ui.run(port=8888, reload=True)
@@ -7,6 +7,7 @@
# ///
from dataclasses import dataclass
from dataclasses import field
import pandas as pd
from nicegui import binding
@@ -15,25 +16,49 @@ from nicegui import ui
STATUS_OPTIONS = ["draft", "active", "archived"]
EDITABLE_FIELDS = ("name", "quantity", "status")
TableValue = str | int
TableRow = dict[str, TableValue]
type TableValue = str | int
type TableRow = dict[str, TableValue]
@binding.bindable_dataclass(bindable_fields=EDITABLE_FIELDS)
@binding.bindable_dataclass
class EditableRow:
id: int
name: str
quantity: int
status: str
table_row: TableRow = field(init=False, repr=False)
touched: bool = False
def __post_init__(self) -> None:
self.table_row = {
"id": self.id,
"name": self.name,
"quantity": self.quantity,
"status": self.status,
}
for field_name in EDITABLE_FIELDS:
binding.bind_to(
self,
field_name,
self.table_row,
field_name,
other_strict=True,
)
@dataclass(slots=True)
class EditableTableState:
rows_by_id: dict[int, EditableRow]
table_rows_by_id: dict[int, TableRow]
def row(self, row_id: int) -> EditableRow | None:
return self.rows_by_id.get(row_id)
def table_rows(self) -> list[TableRow]:
return list(self.table_rows_by_id.values())
return [row.table_row for row in self.rows_by_id.values()]
def touched_rows(self) -> list[EditableRow]:
return [row for row in self.rows_by_id.values() if row.touched]
def dataframe_to_state(dataframe: pd.DataFrame) -> EditableTableState:
@@ -45,37 +70,20 @@ def dataframe_to_state(dataframe: pd.DataFrame) -> EditableTableState:
raise ValueError("The id column must contain unique row keys")
rows_by_id: dict[int, EditableRow] = {}
table_rows_by_id: dict[int, TableRow] = {}
for record in dataframe.to_dict(orient="records"):
row_state = EditableRow(
row = EditableRow(
id=int(record["id"]),
name=str(record["name"]),
quantity=int(record["quantity"]),
status=str(record["status"]),
)
if row_state.status not in STATUS_OPTIONS:
raise ValueError(f"Unknown status {row_state.status!r}")
if row_state.id in rows_by_id:
if row.status not in STATUS_OPTIONS:
raise ValueError(f"Unknown status {row.status!r}")
if row.id in rows_by_id:
raise ValueError("Row keys must remain unique after normalization")
rows_by_id[row.id] = row
table_row: TableRow = {
"id": row_state.id,
"name": row_state.name,
"quantity": row_state.quantity,
"status": row_state.status,
}
for field_name in EDITABLE_FIELDS:
binding.bind_to(
row_state,
field_name,
table_row,
field_name,
other_strict=True,
)
rows_by_id[row_state.id] = row_state
table_rows_by_id[row_state.id] = table_row
return EditableTableState(rows_by_id, table_rows_by_id)
return EditableTableState(rows_by_id)
def normalize_edit(field: str, raw_value: object) -> TableValue:
@@ -104,15 +112,6 @@ def normalize_edit(field: str, raw_value: object) -> TableValue:
raise ValueError(f"Field {field!r} is not editable")
def save_row(dataframe: pd.DataFrame, row_state: EditableRow) -> None:
matching_rows = dataframe["id"].eq(row_state.id)
if int(matching_rows.sum()) != 1:
raise ValueError("This row no longer exists")
dataframe.loc[matching_rows, "name"] = row_state.name
dataframe.loc[matching_rows, "quantity"] = row_state.quantity
dataframe.loc[matching_rows, "status"] = row_state.status
def render_table(dataframe: pd.DataFrame) -> EditableTableState:
state = dataframe_to_state(dataframe)
columns = [
@@ -125,33 +124,40 @@ def render_table(dataframe: pd.DataFrame) -> EditableTableState:
rows=state.table_rows(),
row_key="id",
selection="multiple",
).classes("w-full")
pagination=10,
).classes("w-120")
def apply_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)
row_state = state.rows_by_id.get(row_id)
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)
previous_value = getattr(row_state, field_name)
setattr(row_state, field_name, normalized_value)
try:
save_row(dataframe, row_state)
except Exception:
setattr(row_state, field_name, previous_value)
raise
row_state.touched = True
except (TypeError, ValueError) as error:
ui.notify(str(error), type="negative")
finally:
table.update_rows(state.table_rows(), clear_selection=False)
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}")
with table.add_slot("body-cell-name"), table.cell("name"):
ui.input().props(':model-value="props.value" dense borderless debounce=400').on(
"update:model-value",
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)",
)
@@ -167,9 +173,12 @@ def render_table(dataframe: pd.DataFrame) -> EditableTableState:
ui.select(STATUS_OPTIONS).props(':model-value="props.value" dense borderless options-dense').on(
"update:model-value",
handler=apply_edit,
js_handler="(value) => emit(props.row.id, props.col.name, value)",
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
@@ -178,6 +187,14 @@ if __name__ in {"__main__", "__mp_main__"}:
[
{"id": 101, "name": "Desk", "quantity": 4, "status": "active"},
{"id": 102, "name": "Lamp", "quantity": 12, "status": "draft"},
{"id": 103, "name": "Chair", "quantity": 8, "status": "active"},
{"id": 104, "name": "Shelf", "quantity": 3, "status": "draft"},
{"id": 105, "name": "Monitor", "quantity": 15, "status": "active"},
{"id": 106, "name": "Keyboard", "quantity": 20, "status": "active"},
{"id": 107, "name": "Mouse", "quantity": 24, "status": "active"},
{"id": 108, "name": "Dock", "quantity": 6, "status": "archived"},
{"id": 109, "name": "Cable", "quantity": 40, "status": "draft"},
{"id": 110, "name": "Stand", "quantity": 10, "status": "active"},
]
)
table_state = render_table(items)
@@ -0,0 +1,64 @@
#!/usr/bin/env -S uv run --script
# /// script
# dependencies = [
# "nicegui==3.16.0",
# ]
# ///
from datetime import UTC
from datetime import datetime
from nicegui import events
from nicegui import ui
OPTIONS = {
"python": "Python",
"typescript": "TypeScript",
"rust": "Rust",
}
with ui.card().classes("w-140 max-w-full"):
ui.label("Select event mechanics").classes("text-xl font-semibold")
event_log = ui.log(max_lines=12).classes("w-full h-64")
def record(event_name: str, payload: object) -> None:
timestamp = datetime.now(UTC).astimezone().strftime("%H:%M:%S")
event_log.push(f"{timestamp} {event_name}: {payload!r}")
def handle_change(event: events.ValueChangeEventArguments) -> None:
record("on_change event.value", event.value)
def handle_model_update(event: events.GenericEventArguments) -> None:
record("js_handler -> handler event.args", event.args)
language = (
ui.select(
options=OPTIONS,
value="python",
label="Language",
on_change=handle_change,
with_input=True,
clearable=True,
)
.props("outlined options-dense")
.classes("w-full text-h6")
)
language.on("popup-show", lambda: record("popup-show", None), args=[])
language.on("popup-hide", lambda: record("popup-hide", None), args=[])
# This only fires when the value is changed from the browser side (not from the button)
language.on(
"update:model-value",
handler=handle_model_update,
js_handler="(...args) => emit(...args)",
)
with ui.row().classes("w-full justify-end"):
ui.button("Set Rust", on_click=lambda: language.set_value("rust"))
ui.button("Clear log", on_click=event_log.clear).props("flat")
if __name__ in {"__main__", "__mp_main__"}:
ui.run(port=8888, reload=True)