nicegui table updates
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user