Skip to content

Grids and lists

The collection side: a table for the desktop, a list with drill-down for the phone, and the chrome wrapper that adds create, edit and delete to either. See Components for the guide.

ModelGrid

Renders a Pydantic model collection as an ag-Grid table.

Create via factory methods: ModelGrid.from_list(Type, items) — in-memory list ModelGrid.from_json(Type, path) — JSON file ModelGrid.from_adapter(Type, adapter) — any CollectionAdapter

After render(), the NiceGUI ag-Grid element is available as grid.widget. Apply classes/style/props via grid.widget after render: grid.render() grid.widget.classes('w-full') Call update_rows() to refresh the displayed data from the adapter.

Source code in niceview/modelgrid.py
class ModelGrid:
    """
    Renders a Pydantic model collection as an ag-Grid table.

    Create via factory methods:
      ModelGrid.from_list(Type, items)         — in-memory list
      ModelGrid.from_json(Type, path)          — JSON file
      ModelGrid.from_adapter(Type, adapter)    — any CollectionAdapter

    After render(), the NiceGUI ag-Grid element is available as grid.widget.
    Apply classes/style/props via grid.widget after render:
      grid.render()
      grid.widget.classes('w-full')
    Call update_rows() to refresh the displayed data from the adapter.
    """
    _fields: Fields
    _data: CollectionAdapter
    _selection_handlers: list[Handler[TableItemSelectEventArguments]]
    _auto_update_registered: bool
    _rows: list[dict[str, Any]]
    widget: ui.aggrid | None
    _theme: str
    _auto_size_columns: bool | None
    _defaultColDef: dict
    _rowSelection: Literal[None, 'single', 'multiple']
    _cell_renderers: dict[str, Callable[[Any], Any]]
    _html_fields: list[str]
    _local_tz: str | None
    _model_repositories: dict[type[BaseModel] | str, CollectionAdapter]

    def __init__(self, item_type: type[T], adapter: CollectionAdapter, **kwargs: Unpack[_ModelGridOptionInputs]) -> None:
        """
        Create a ModelGrid for the given Pydantic model type and adapter.
        Prefer the factory methods (from_list, from_json, from_adapter) over the constructor.
        """
        if not isinstance(item_type, type) or not issubclass(item_type, BaseModel):
            raise TypeError(f"item_type must be a subclass of BaseModel, got {type(item_type)}")

        # include/exclude/field_infos fall back to the model's Meta (like ModelForm); profile
        # stays kwargs-only -- Fields() itself resolves Meta.default_profile as its fallback.
        include = meta_option(item_type, kwargs, 'include', '__all__')
        exclude = meta_option(item_type, kwargs, 'exclude', '')
        field_infos = meta_option(item_type, kwargs, 'field_infos', {})
        self._fields = Fields(item_type, include, exclude, field_infos,
                              profile=kwargs.pop('profile', None))
        self._local_tz = meta_option(item_type, kwargs, 'local_tz', None)
        self._data = adapter
        self._selection_handlers = []
        self._auto_update_registered = False
        self._rows = []
        self.widget = None
        self._theme = kwargs.pop('theme', '')
        self._auto_size_columns = kwargs.pop('auto_size_columns', None)
        self._defaultColDef = kwargs.pop('defaultColDef', {}).copy()
        self._rowSelection = kwargs.pop('rowSelection', None)
        self._cell_renderers = kwargs.pop('cell_renderers', {}).copy()
        self._html_fields = kwargs.pop('html_fields', []).copy()
        self._model_repositories = {}
        if kwargs:
            raise TypeError(f"Unexpected keyword arguments for {type(self).__name__}: {', '.join(kwargs.keys())}")

    # --- factory methods ---------------------------------------------------

    @classmethod
    def from_list(cls, item_type: type[T], items: list[T], **kwargs: Unpack[_ModelGridOptionInputs]) -> Self:
        """
        Create a grid from an in-memory list.

        Pass a plain list for manual control: the grid updates only when update_rows()
        is called explicitly (e.g. via the EditGridWrapper Refresh button).

        Pass an ObservableList for automatic updates: the grid re-renders whenever
        the list is mutated structurally (append, delete, replace) without any
        explicit update_rows() call.

        Return type is Self so subclasses (e.g. ModelGridInlineEdit) are returned as their own type.
        """
        return cls(item_type, ListAdapter(item_type, items), **kwargs)  # type: ignore[arg-type]

    @classmethod
    def from_adapter(cls, item_type: type[T], adapter: CollectionAdapter, **kwargs: Unpack[_ModelGridOptionInputs]) -> Self:
        """
        Create an instance from any CollectionAdapter.
        Equivalent to the constructor — provided for API symmetry with ModelForm.from_adapter().
        Return type is Self so subclasses (e.g. ModelGridInlineEdit) are returned as their own type.
        """
        return cls(item_type, adapter, **kwargs)  # type: ignore[arg-type]

    @classmethod
    def from_json(cls, item_type: type[T], path_name: Path, *, create_if_not_exist: bool = True, **kwargs: Unpack[_ModelGridOptionInputs]) -> Self:
        """
        Create an instance backed by a JSON file via JsonListAdapter.
        The file is created with an empty list if it does not exist.
        Call grid.adapter.reload() + grid.update_rows() to refresh from disk.
        Return type is Self so subclasses (e.g. ModelGridInlineEdit) are returned as their own type.
        """
        adapter = JsonListAdapter(item_type, path_name, create_if_not_exist=create_if_not_exist)
        return cls(item_type, adapter, **kwargs)  # type: ignore[arg-type]

    @property
    def adapter(self) -> CollectionAdapter:
        """The backing data adapter."""
        return self._data

    def with_repositories(self, repositories: 'dict[type[BaseModel] | str, CollectionAdapter]') -> Self:
        """Register repositories for modelselect fields, so the grid shows their labels (and,
        when inline-editable, offers a select of the related items). Keys are a field name
        (preferred) or the related model type. May be called after render() — the columns and
        rows are refreshed in place."""
        self._model_repositories = {**self._model_repositories, **repositories}  # merge; new wins
        if self.widget is not None:
            self.widget.options['columnDefs'] = _collect_aggrid_cols(self._fields, self._model_repositories)
            self.update_rows()
            self.widget.update()
        return self

    # --- event handler configuration --------------------------------------

    def on_select(self, callback: Handler[TableItemSelectEventArguments]) -> Self:
        """
        Add a callback invoked when the row selection changes.
        The event carries row_key and item (both None when the selection was cleared),
        mirroring ModelList.on_select. Only meaningful when rowSelection='single'.
        """
        if not callable(callback):
            raise TypeError(f"callback must be callable, got {type(callback)}")
        if self._rowSelection != 'single':
            log.warning(f"on_select is only supported for single row selection, but rowSelection is '{self._rowSelection}'")
        self._selection_handlers.append(callback)
        return self

    # --- data and rendering -----------------------------------------------

    def update_rows(self) -> Self:
        """Refresh the displayed rows from the adapter."""
        # _rows is mutated in-place (clear + re-append) so that widget.options['rowData'],
        # which holds the same list reference, stays in sync via NiceGUI's data binding —
        # the browser update happens automatically without an explicit widget.update() call.
        # self.widget.update()
        self._rows.clear()
        for item in self._data:
            row: dict[str, Any] = {'__ui_row_key': self._data.key_from_item(item)}
            for field_name in self._fields:
                field_info = self._fields[field_name]
                if field_info.hidden or field_info.table_hidden:
                    continue
                value = getattr(item, field_name)
                if field_name in self._cell_renderers:
                    row[field_name] = self._cell_renderers[field_name](value)
                elif field_info.widget_type in ('datetime', 'date', 'time', 'timedelta'):
                    # Also fixes a crash: orjson (NiceGUI's JSON encoder) has no timedelta
                    # support, and to_widget_value() already turns all four into plain,
                    # JSON-safe ISO strings for ModelForm -- local_tz-aware for datetime.
                    row[field_name] = to_widget_value(field_info, value, local_tz=self._local_tz)
                elif isinstance(value, list):
                    row[field_name] = ', '.join(str(v) for v in value)
                elif isinstance(value, BaseModel):
                    # A modelselect/relationship: with a repository, store the key so the
                    # refData column maps it to its label; otherwise fall back to str().
                    repo = resolve_repository(self._model_repositories, field_name, field_info.item_type)
                    row[field_name] = repo.key_from_item(value) if repo else str(value)
                else:
                    row[field_name] = value
            self._rows.append(row)
        if self.widget:
            self.widget.options['rowData'] = self._rows
        return self

    def render(self) -> Self:
        """Render the ag-Grid widget into the current NiceGUI context."""
        cols = _collect_aggrid_cols(self._fields, self._model_repositories)
        self.update_rows()

        aggrid_kwargs: dict[str, Any] = {}
        if self._theme:
            aggrid_kwargs['theme'] = self._theme
        if self._auto_size_columns is not None:
            aggrid_kwargs['auto_size_columns'] = self._auto_size_columns
        if self._html_fields:
            aggrid_kwargs['html_columns'] = [i for i, c in enumerate(cols) if c['field'] in self._html_fields]
        config: dict[str, Any] = {
            'columnDefs': cols,
            'rowData': self._rows,
            'stopEditingWhenCellsLoseFocus': True,
        }
        if self._defaultColDef:
            config['defaultColDef'] = self._defaultColDef
        if self._rowSelection:
            config['rowSelection'] = self._rowSelection

        self.widget = ui.aggrid(config, **aggrid_kwargs)
        self.widget.on('selectionChanged', self._handle_selection_changed)

        if not self._auto_update_registered and isinstance(self._data, ReactiveAdapter):
            def _refresh() -> None:
                self.update_rows()
            self._data.on_change(_refresh)
            self._auto_update_registered = True

        return self

    async def _handle_selection_changed(self, event) -> None:
        if not self.widget:
            return
        row = await self.widget.get_selected_row()
        row_key: str | None = row['__ui_row_key'] if row else None
        item: Any = None
        if row_key is not None:
            try:
                item = self._data.read(row_key)
            except (KeyError, ValueError):
                log.warning(f"on_select: selected row key {row_key!r} not found in adapter")
        args = TableItemSelectEventArguments(sender=event.sender, client=event.client,
                                             grid=self, row_key=row_key, item=item)
        for handler in self._selection_handlers:
            handle_event(handler, args)

adapter property

The backing data adapter.

from_list classmethod

from_list(
    item_type: type[T],
    items: list[T],
    **kwargs: Unpack[_ModelGridOptionInputs],
) -> Self

Create a grid from an in-memory list.

Pass a plain list for manual control: the grid updates only when update_rows() is called explicitly (e.g. via the EditGridWrapper Refresh button).

Pass an ObservableList for automatic updates: the grid re-renders whenever the list is mutated structurally (append, delete, replace) without any explicit update_rows() call.

Return type is Self so subclasses (e.g. ModelGridInlineEdit) are returned as their own type.

Source code in niceview/modelgrid.py
@classmethod
def from_list(cls, item_type: type[T], items: list[T], **kwargs: Unpack[_ModelGridOptionInputs]) -> Self:
    """
    Create a grid from an in-memory list.

    Pass a plain list for manual control: the grid updates only when update_rows()
    is called explicitly (e.g. via the EditGridWrapper Refresh button).

    Pass an ObservableList for automatic updates: the grid re-renders whenever
    the list is mutated structurally (append, delete, replace) without any
    explicit update_rows() call.

    Return type is Self so subclasses (e.g. ModelGridInlineEdit) are returned as their own type.
    """
    return cls(item_type, ListAdapter(item_type, items), **kwargs)  # type: ignore[arg-type]

from_adapter classmethod

from_adapter(
    item_type: type[T],
    adapter: CollectionAdapter,
    **kwargs: Unpack[_ModelGridOptionInputs],
) -> Self

Create an instance from any CollectionAdapter. Equivalent to the constructor — provided for API symmetry with ModelForm.from_adapter(). Return type is Self so subclasses (e.g. ModelGridInlineEdit) are returned as their own type.

Source code in niceview/modelgrid.py
@classmethod
def from_adapter(cls, item_type: type[T], adapter: CollectionAdapter, **kwargs: Unpack[_ModelGridOptionInputs]) -> Self:
    """
    Create an instance from any CollectionAdapter.
    Equivalent to the constructor — provided for API symmetry with ModelForm.from_adapter().
    Return type is Self so subclasses (e.g. ModelGridInlineEdit) are returned as their own type.
    """
    return cls(item_type, adapter, **kwargs)  # type: ignore[arg-type]

from_json classmethod

from_json(
    item_type: type[T],
    path_name: Path,
    *,
    create_if_not_exist: bool = True,
    **kwargs: Unpack[_ModelGridOptionInputs],
) -> Self

Create an instance backed by a JSON file via JsonListAdapter. The file is created with an empty list if it does not exist. Call grid.adapter.reload() + grid.update_rows() to refresh from disk. Return type is Self so subclasses (e.g. ModelGridInlineEdit) are returned as their own type.

Source code in niceview/modelgrid.py
@classmethod
def from_json(cls, item_type: type[T], path_name: Path, *, create_if_not_exist: bool = True, **kwargs: Unpack[_ModelGridOptionInputs]) -> Self:
    """
    Create an instance backed by a JSON file via JsonListAdapter.
    The file is created with an empty list if it does not exist.
    Call grid.adapter.reload() + grid.update_rows() to refresh from disk.
    Return type is Self so subclasses (e.g. ModelGridInlineEdit) are returned as their own type.
    """
    adapter = JsonListAdapter(item_type, path_name, create_if_not_exist=create_if_not_exist)
    return cls(item_type, adapter, **kwargs)  # type: ignore[arg-type]

with_repositories

with_repositories(
    repositories: dict[
        type[BaseModel] | str, CollectionAdapter
    ],
) -> Self

Register repositories for modelselect fields, so the grid shows their labels (and, when inline-editable, offers a select of the related items). Keys are a field name (preferred) or the related model type. May be called after render() — the columns and rows are refreshed in place.

Source code in niceview/modelgrid.py
def with_repositories(self, repositories: 'dict[type[BaseModel] | str, CollectionAdapter]') -> Self:
    """Register repositories for modelselect fields, so the grid shows their labels (and,
    when inline-editable, offers a select of the related items). Keys are a field name
    (preferred) or the related model type. May be called after render() — the columns and
    rows are refreshed in place."""
    self._model_repositories = {**self._model_repositories, **repositories}  # merge; new wins
    if self.widget is not None:
        self.widget.options['columnDefs'] = _collect_aggrid_cols(self._fields, self._model_repositories)
        self.update_rows()
        self.widget.update()
    return self

on_select

on_select(
    callback: Handler[TableItemSelectEventArguments],
) -> Self

Add a callback invoked when the row selection changes. The event carries row_key and item (both None when the selection was cleared), mirroring ModelList.on_select. Only meaningful when rowSelection='single'.

Source code in niceview/modelgrid.py
def on_select(self, callback: Handler[TableItemSelectEventArguments]) -> Self:
    """
    Add a callback invoked when the row selection changes.
    The event carries row_key and item (both None when the selection was cleared),
    mirroring ModelList.on_select. Only meaningful when rowSelection='single'.
    """
    if not callable(callback):
        raise TypeError(f"callback must be callable, got {type(callback)}")
    if self._rowSelection != 'single':
        log.warning(f"on_select is only supported for single row selection, but rowSelection is '{self._rowSelection}'")
    self._selection_handlers.append(callback)
    return self

update_rows

update_rows() -> Self

Refresh the displayed rows from the adapter.

Source code in niceview/modelgrid.py
def update_rows(self) -> Self:
    """Refresh the displayed rows from the adapter."""
    # _rows is mutated in-place (clear + re-append) so that widget.options['rowData'],
    # which holds the same list reference, stays in sync via NiceGUI's data binding —
    # the browser update happens automatically without an explicit widget.update() call.
    # self.widget.update()
    self._rows.clear()
    for item in self._data:
        row: dict[str, Any] = {'__ui_row_key': self._data.key_from_item(item)}
        for field_name in self._fields:
            field_info = self._fields[field_name]
            if field_info.hidden or field_info.table_hidden:
                continue
            value = getattr(item, field_name)
            if field_name in self._cell_renderers:
                row[field_name] = self._cell_renderers[field_name](value)
            elif field_info.widget_type in ('datetime', 'date', 'time', 'timedelta'):
                # Also fixes a crash: orjson (NiceGUI's JSON encoder) has no timedelta
                # support, and to_widget_value() already turns all four into plain,
                # JSON-safe ISO strings for ModelForm -- local_tz-aware for datetime.
                row[field_name] = to_widget_value(field_info, value, local_tz=self._local_tz)
            elif isinstance(value, list):
                row[field_name] = ', '.join(str(v) for v in value)
            elif isinstance(value, BaseModel):
                # A modelselect/relationship: with a repository, store the key so the
                # refData column maps it to its label; otherwise fall back to str().
                repo = resolve_repository(self._model_repositories, field_name, field_info.item_type)
                row[field_name] = repo.key_from_item(value) if repo else str(value)
            else:
                row[field_name] = value
        self._rows.append(row)
    if self.widget:
        self.widget.options['rowData'] = self._rows
    return self

render

render() -> Self

Render the ag-Grid widget into the current NiceGUI context.

Source code in niceview/modelgrid.py
def render(self) -> Self:
    """Render the ag-Grid widget into the current NiceGUI context."""
    cols = _collect_aggrid_cols(self._fields, self._model_repositories)
    self.update_rows()

    aggrid_kwargs: dict[str, Any] = {}
    if self._theme:
        aggrid_kwargs['theme'] = self._theme
    if self._auto_size_columns is not None:
        aggrid_kwargs['auto_size_columns'] = self._auto_size_columns
    if self._html_fields:
        aggrid_kwargs['html_columns'] = [i for i, c in enumerate(cols) if c['field'] in self._html_fields]
    config: dict[str, Any] = {
        'columnDefs': cols,
        'rowData': self._rows,
        'stopEditingWhenCellsLoseFocus': True,
    }
    if self._defaultColDef:
        config['defaultColDef'] = self._defaultColDef
    if self._rowSelection:
        config['rowSelection'] = self._rowSelection

    self.widget = ui.aggrid(config, **aggrid_kwargs)
    self.widget.on('selectionChanged', self._handle_selection_changed)

    if not self._auto_update_registered and isinstance(self._data, ReactiveAdapter):
        def _refresh() -> None:
            self.update_rows()
        self._data.on_change(_refresh)
        self._auto_update_registered = True

    return self

ModelGridInlineEdit

Bases: ModelGrid

Extends ModelGrid with inline cell editing. Each cell edit is validated against the Pydantic model and persisted via the adapter. Register on_change() callbacks to react to successful or failed cell edits.

Source code in niceview/modelgrid.py
class ModelGridInlineEdit(ModelGrid):
    """
    Extends ModelGrid with inline cell editing.
    Each cell edit is validated against the Pydantic model and persisted via the adapter.
    Register on_change() callbacks to react to successful or failed cell edits.
    """
    _change_handlers: list[Handler[TableItemFieldEventArguments]]
    cell_readers: dict[str, Callable[[str], Any]]

    def __init__(self, item_type: type[T], adapter: CollectionAdapter, **kwargs: Unpack[_InlineEditableModelGridOptionInputs]) -> None:
        self.cell_readers = kwargs.pop('cell_readers', {})
        super().__init__(item_type, adapter, **kwargs)  # type: ignore[arg-type, misc]
        self._defaultColDef.update({'editable': True})
        self._change_handlers = []

    def on_change(self, callback: Handler[TableItemFieldEventArguments]) -> Self:
        """Add a callback invoked on each inline cell edit (success or validation failure)."""
        if not callable(callback):
            raise TypeError(f"callback must be callable, got {type(callback)}")
        self._change_handlers.append(callback)
        return self

    def render(self) -> Self:
        """Render the grid with inline editing enabled."""
        super().render()
        if self.widget:
            self.widget.on('cellValueChanged', self._handle_cell_value_changed)
        return self

    def _handle_cell_value_changed(self, event) -> None:
        row_key = event.args['data']['__ui_row_key']
        field_name = event.args['colId']
        old_value = event.args['oldValue']
        new_value = event.args['newValue']

        if field_name in self.cell_readers:
            old_value = self.cell_readers[field_name](old_value)
            new_value = self.cell_readers[field_name](new_value)

        try:
            item = self._data.read(row_key)
        except Exception:
            _notify(get_chrome_text().row_not_found, key=row_key)
            return

        if not isinstance(item, self._fields._item_type):
            log.error(f"Expected {self._fields._item_type.__name__}, got {type(item).__name__} for row {row_key}")
            return
        if not hasattr(item, field_name):
            log.error(f"Field '{field_name}' not found in {type(item).__name__}")
            return

        dumped = item.model_dump()
        dumped[field_name] = new_value
        errors = self._fields.validation_error_list(dumped)

        if not errors:
            setattr(item, field_name, new_value)
            try:
                self._data.update(item)
            except ConflictError:
                _notify(get_chrome_text().conflict)
                self.update_rows()
                return
            except Exception as e:
                log.error(f'Error persisting cell edit for row {row_key}: {e}')
                _notify(get_chrome_text().save_error, error=e)
                self.update_rows()
                return
        else:
            _notify(get_chrome_text().invalid_value, value=new_value, errors=errors)
            self.update_rows()

        tife = TableItemFieldEventArguments(
            sender=event.sender, client=event.client,
            grid=self,
            row_key=row_key,
            item=item,
            field_name=field_name,
            new_value=new_value,
        )
        for handler in self._change_handlers:
            handle_event(handler, tife)

on_change

on_change(
    callback: Handler[TableItemFieldEventArguments],
) -> Self

Add a callback invoked on each inline cell edit (success or validation failure).

Source code in niceview/modelgrid.py
def on_change(self, callback: Handler[TableItemFieldEventArguments]) -> Self:
    """Add a callback invoked on each inline cell edit (success or validation failure)."""
    if not callable(callback):
        raise TypeError(f"callback must be callable, got {type(callback)}")
    self._change_handlers.append(callback)
    return self

render

render() -> Self

Render the grid with inline editing enabled.

Source code in niceview/modelgrid.py
def render(self) -> Self:
    """Render the grid with inline editing enabled."""
    super().render()
    if self.widget:
        self.widget.on('cellValueChanged', self._handle_cell_value_changed)
    return self

TableItemEventArguments dataclass

Bases: ClickEventArguments

Fired by EditGridWrapper.on_change after a successful create, update, or delete.

Source code in niceview/modelgrid.py
@dataclass(kw_only=True, slots=True)
class TableItemEventArguments(ClickEventArguments):
    """Fired by EditGridWrapper.on_change after a successful create, update, or delete."""
    grid: ModelGrid
    """The grid the item belongs to."""
    row_key: str
    """Key of the affected row."""
    item: Any
    """The affected item."""

grid instance-attribute

grid: ModelGrid

The grid the item belongs to.

row_key instance-attribute

row_key: str

Key of the affected row.

item instance-attribute

item: Any

The affected item.

EditGridWrapper

Chrome wrapper for ModelGrid: renders title, description, and CRUD buttons (add, edit, delete, refresh) above the grid.

Title semantics: omitted or None → auto-generated title '{ItemType} List'; '' → no title; any other string → that title. Button semantics ('' and None differ from the title!): '' → icon-only button (the default), a string → labeled button, None → button hidden.

on_add replaces the default Add action (create item_type() and open the create dialog) with a handler of your own; the other CRUD buttons keep their built-in behaviour.

chrome_actions adds the application's own buttons to that row — the same FormAction a form places between its fields, here left of niceview's own so those keep the right edge they have everywhere. Their on_click receives a GridActionEventArguments, with the selected row rather than a form's item.

search=True adds a free-text search box to the title row, filtering rows across all columns as the user types — ag-grid's client-side quick filter, not a server round-trip.

After render(), the NiceGUI elements are exposed for further styling: wrapper.title → ui.label | None wrapper.description → ui.markdown | None wrapper.title_row → ui.row | None wrapper.search_input → ui.input | None wrapper.add_button → ui.button | None wrapper.edit_button → ui.button | None wrapper.delete_button → ui.button | None wrapper.refresh_button → ui.button | None wrapper.action_buttons → dict[str, ui.button] — from chrome_actions=

Source code in niceview/editwrapper.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
class EditGridWrapper():
    """
    Chrome wrapper for ModelGrid: renders title, description, and CRUD buttons
    (add, edit, delete, refresh) above the grid.

    Title semantics: omitted or None → auto-generated title '{ItemType} List';
    '' → no title; any other string → that title.
    Button semantics ('' and None differ from the title!): '' → icon-only
    button (the default), a string → labeled button, None → button hidden.

    `on_add` replaces the default Add action (create item_type() and open the create dialog)
    with a handler of your own; the other CRUD buttons keep their built-in behaviour.

    `chrome_actions` adds the application's own buttons to that row — the same `FormAction` a
    form places between its fields, here left of niceview's own so those keep the right edge
    they have everywhere. Their `on_click` receives a `GridActionEventArguments`, with the
    selected row rather than a form's item.

    `search=True` adds a free-text search box to the title row, filtering rows across all
    columns as the user types — ag-grid's client-side quick filter, not a server round-trip.

    After render(), the NiceGUI elements are exposed for further styling:
        wrapper.title          → ui.label | None
        wrapper.description    → ui.markdown | None
        wrapper.title_row      → ui.row | None
        wrapper.search_input   → ui.input | None
        wrapper.add_button     → ui.button | None
        wrapper.edit_button    → ui.button | None
        wrapper.delete_button  → ui.button | None
        wrapper.refresh_button → ui.button | None
        wrapper.action_buttons → dict[str, ui.button] — from chrome_actions=
    """
    grid: ModelGrid

    # private config
    _rendered: bool
    _title: str | None
    _description: str | None
    _on_add: ActionHandler | None
    _delete_button: str | None
    _add_button: str | None
    _edit_button: str | None
    _refresh_button: str | None
    _chrome_actions: dict[str, FormAction]
    _chrome_style: ChromeStyle | None
    _chrome_text: ChromeText | None
    _place: Place

    # Exposed NiceGUI elements (populated by render())
    title: ui.label | None
    description: ui.markdown | None
    title_row: ui.row | None
    search_input: ui.input | None
    delete_button: ui.button | None
    add_button: ui.button | None
    edit_button: ui.button | None
    refresh_button: ui.button | None
    action_buttons: dict[str, ui.button]

    _change_handlers: list[Handler[TableItemEventArguments]]
    _model_repositories: dict[type[BaseModel] | str, CollectionAdapter]

    def __init__(self, grid: ModelGrid, **kwargs: Unpack[_EditGridWrapperInputs]) -> None:
        self.grid = grid
        if self.grid._rowSelection and self.grid._rowSelection != 'single':
            raise ValueError(f"EditGridWrapper only supports single row selection, got '{self.grid._rowSelection}'")
        self.grid._rowSelection = 'single'

        default_edit = None if isinstance(self.grid, ModelGridInlineEdit) else ''
        item_type = self.grid._fields._item_type
        title = meta_option(item_type, kwargs, 'title', None, meta_key='title_plural')
        self._title = f'{item_type.__name__} List' if title is None else (title or None)
        self._description = meta_option(item_type, kwargs, 'description', None)
        self._on_add = kwargs.pop('on_add', None)
        self._delete_button = kwargs.pop('delete_button', '')
        self._add_button = kwargs.pop('add_button', '')
        self._edit_button = kwargs.pop('edit_button', default_edit)
        self._refresh_button = kwargs.pop('refresh_button', '')
        self._chrome_actions = ModelForm._checked_actions(kwargs.pop('chrome_actions', {}),
                                                          no_form="a grid's title row has none")
        self._chrome_style = kwargs.pop('chrome_style', None)
        self._chrome_text = kwargs.pop('chrome_text', None)
        self._place = kwargs.pop('place', 'toolbar')
        self._search = kwargs.pop('search', False)

        self._rendered = False
        self.title = None
        self.description = None
        self.title_row = None
        self.search_input = None
        self.delete_button = None
        self.add_button = None
        self.edit_button = None
        self.refresh_button = None
        self.action_buttons = {}

        self._change_handlers = []
        self._model_repositories = {}

        if kwargs:
            raise TypeError(f"Unexpected keyword arguments for EditGridWrapper: {', '.join(kwargs.keys())}")

    @property
    def _style(self) -> ChromeStyle:
        return self._chrome_style or get_chrome_style()

    @property
    def _text(self) -> ChromeText:
        return self._chrome_text or get_chrome_text()

    def _notify(self, template: Any, kind: 'NotifyKind', **params: Any) -> None:
        """One of niceview's notifications: text from ChromeText, delivery from ChromeStyle."""
        chrome_notify(text_of(template, **params), kind, self._style)

    # --- factory methods ---------------------------------------------------

    @classmethod
    def _split_kwargs(cls, kwargs: Mapping[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
        """Split factory kwargs into (wrapper chrome options, ModelGrid options)."""
        grid_kwargs = dict(kwargs)
        wrapper_kwargs = {k: grid_kwargs.pop(k) for k in list(grid_kwargs) if k in _GRID_WRAPPER_INPUT_KEYS}
        return wrapper_kwargs, grid_kwargs

    @classmethod
    def from_list(cls, item_type: type[T], items: list[T], *, inline_edit: bool = False, **kwargs: Unpack[_EditGridWrapperFactoryInputs]) -> Self:
        """Create an EditGridWrapper backed by an in-memory list. Call render() (fluent: from_list(...).render()) to draw it."""
        wrapper_kwargs, grid_kwargs = cls._split_kwargs(kwargs)
        grid_cls = ModelGridInlineEdit if inline_edit else ModelGrid
        grid = grid_cls.from_list(item_type, items, **grid_kwargs)
        return cls(grid, **wrapper_kwargs)

    @classmethod
    def from_json(cls, item_type: type[T], path_name: Path, *, create_if_not_exist: bool = True, inline_edit: bool = False, **kwargs: Unpack[_EditGridWrapperFactoryInputs]) -> Self:
        """Create an EditGridWrapper backed by a JSON file. Call render() to draw it."""
        wrapper_kwargs, grid_kwargs = cls._split_kwargs(kwargs)
        grid_cls = ModelGridInlineEdit if inline_edit else ModelGrid
        grid = grid_cls.from_json(item_type, path_name, create_if_not_exist=create_if_not_exist, **grid_kwargs)
        return cls(grid, **wrapper_kwargs)

    @classmethod
    def from_adapter(cls, item_type: type[T], adapter: CollectionAdapter, *, inline_edit: bool = False, **kwargs: Unpack[_EditGridWrapperFactoryInputs]) -> Self:
        """Create an EditGridWrapper backed by any CollectionAdapter. Call render() to draw it."""
        wrapper_kwargs, grid_kwargs = cls._split_kwargs(kwargs)
        grid_cls = ModelGridInlineEdit if inline_edit else ModelGrid
        grid = grid_cls.from_adapter(item_type, adapter, **grid_kwargs)
        return cls(grid, **wrapper_kwargs)

    # --- configuration -----------------------------------------------------

    def with_repositories(self, repositories: 'dict') -> Self:
        """Set repositories for modelselect fields — the create/edit dialogs and the grid itself,
        so modelselect columns show their labels (and offer a select where applicable). Keys are
        a field name (preferred) or the related model type. Additive, and merged into the grid's
        own registrations rather than replacing them."""
        self._model_repositories = {**self._model_repositories, **repositories}  # merge; new wins
        self.grid.with_repositories(self._model_repositories)  # merge the full view into the grid
        return self

    def on_change(self, callback: Handler[TableItemEventArguments]) -> Self:
        """Add a callback invoked after each successful create, update, or delete."""
        if not callable(callback):
            raise TypeError(f"callback must be callable, got {type(callback)}")
        self._change_handlers.append(callback)
        return self

    def _notify_change_handlers(self, row_key: str, item: BaseModel | None) -> None:
        """Fire change handlers. Requires the grid to be rendered (widget must not be None)."""
        if not self._change_handlers:
            return
        widget = self.grid.widget
        if widget is None:
            return
        tce = TableItemEventArguments(
            sender=widget,  # type: ignore[arg-type]
            client=widget.client,  # type: ignore[attr-defined]
            grid=self.grid,
            row_key=row_key,
            item=item,
        )
        for handler in self._change_handlers:
            handle_event(handler, tce)

    async def _get_selected_row_key(self) -> str | None:
        """Return the row key of the currently selected row, or None if no row is selected."""
        if not self.grid.widget:
            return None
        selected_row = await self.grid.widget.get_selected_row()
        return selected_row['__ui_row_key'] if selected_row else None

    def _error_msg_from_exception(self, e: Exception) -> str:
        """Return a user-facing error message extracted from an exception."""
        if isinstance(e, HTTPException) and hasattr(e, 'detail'):
            return e.detail
        return str(e)

    # --- render ------------------------------------------------------------

    def render(self) -> Self:
        """Render title, description, CRUD buttons, and the grid into the current NiceGUI context."""
        if self._rendered:
            return self
        self.title = None
        self.description = None
        self.title_row = None
        self.search_input = None
        self.delete_button = None
        self.add_button = None
        self.edit_button = None
        self.refresh_button = None
        self.action_buttons = {}

        style, text, place = self._style, self._text, self._place
        button_count = sum(b is not None for b in [self._refresh_button, self._delete_button, self._add_button, self._edit_button]) + len(self._chrome_actions)
        has_chrome = bool(self._title) or button_count > 0 or self._search
        if has_chrome:
            with chrome_row(style) as self.title_row:
                if self._title:
                    self.title = chrome_title(self._title, style)
                elif self._search or button_count:
                    ui.space()
                if self._search:
                    self.search_input = ui.input(placeholder=text_of(text.search_placeholder)) \
                        .props('type=search outlined dense clearable').classes('w-48') \
                        .on_value_change(self._on_search_changed)
                    with self.search_input.add_slot('append'):
                        ui.icon('search')
                if button_count:
                    with chrome_buttons(style, button_count):
                        for name, action in self._chrome_actions.items():
                            self.action_buttons[name] = render_action_button(
                                action, style, place, None,
                                lambda event, n=name, a=action: self._handle_chrome_action(n, a, event))
                        if self._refresh_button is not None:
                            self.refresh_button = chrome_button('refresh', self._refresh_button, 'refresh', text_of(text.refresh_tooltip), style, self._on_refresh_clicked, place)
                        if self._delete_button is not None:
                            self.delete_button = chrome_button('delete', self._delete_button, 'delete', text_of(text.delete_tooltip), style, self._on_delete_clicked, place)
                        if self._add_button is not None:
                            self.add_button = chrome_button('add', self._add_button, 'add', text_of(text.add_tooltip), style, self._on_create_clicked, place)
                        if self._edit_button is not None:
                            self.edit_button = chrome_button('edit', self._edit_button, 'edit', text_of(text.edit_tooltip), style, self._on_update_clicked, place)

        if self._description:
            self.description = ui.markdown(self._description)

        self.grid.render()
        self._rendered = True
        return self

    # --- CRUD actions ------------------------------------------------------

    def refresh(self) -> None:
        """Reload from the adapter and re-render the grid."""
        if isinstance(self.grid.adapter, ReloadableAdapter):
            self.grid.adapter.reload()
        self.grid.update_rows()

    def _on_refresh_clicked(self, event: ClickEventArguments) -> None:
        self.refresh()

    def _on_search_changed(self, event: ValueChangeEventArguments) -> None:
        """Drive ag-grid's quick filter from the search box — client-side, no adapter reload."""
        if self.grid.widget is not None:
            self.grid.widget.run_grid_method('setGridOption', 'quickFilterText', event.value or '')

    def _apply_create(self, item: BaseModel) -> BaseModel:
        """Persist a new item via the adapter. Raises on type mismatch or adapter error."""
        return self.grid.adapter.create(item)

    def _apply_update(self, new_item: BaseModel, row_key: str) -> BaseModel:
        """Persist an updated item via the adapter. Raises on not-found or optimistic-lock conflict."""
        original = self.grid.adapter.read(row_key)
        for field, value in new_item.model_dump().items():
            setattr(original, field, value)
        return self.grid.adapter.update(original)

    def _apply_delete(self, row_key: str) -> None:
        """Delete an item via the adapter. Raises if the key does not exist."""
        self.grid.adapter.delete(row_key)

    async def create_item(self) -> None:
        """Open the create dialog and, on confirmation, persist the new item."""
        from niceview.dataadapter import FilteredAdapter
        item = self.grid._fields._item_type()
        # Pre-apply FK defaults so the dialog form starts with a valid item
        # (e.g. author_id is set before Pydantic validates the new Book).
        if isinstance(self.grid.adapter, FilteredAdapter):
            for field, value in self.grid.adapter._defaults.items():
                setattr(item, field, value)
        success = await self.default_edit_create_handler(item, True)
        if success:
            try:
                item = self._apply_create(item)
                self._notify(self._text.item_created, 'positive')
                self.grid.update_rows()
                self._notify_change_handlers(self.grid.adapter.key_from_item(item), item)
            except Exception as e:
                log.error(f'Error creating item: {e}')
                self._notify(self._text.create_error, 'negative', error=self._error_msg_from_exception(e))
        else:
            self._notify(self._text.create_cancelled, 'negative')

    async def _on_create_clicked(self, event: ClickEventArguments) -> None:
        if self._on_add is not None:
            await maybe_await(self._on_add())
            return
        await self.create_item()

    async def update_item(self) -> None:
        """Open the edit dialog for the selected row and, on confirmation, persist changes."""
        row_key = await self._get_selected_row_key()
        if not row_key:
            self._notify(self._text.select_row_first, 'negative')
            return

        item = self.grid.adapter.read(row_key)
        if not item:
            self._notify(self._text.item_not_found, 'negative', key=row_key)
            return

        item = item.model_copy(deep=True)
        success = await self.default_edit_create_handler(item, False)
        if not success:
            self._notify(self._text.update_cancelled, 'negative')
            return

        try:
            item = self._apply_update(item, row_key)
            self._notify(self._text.item_updated, 'positive')
            self.grid.update_rows()
            self._notify_change_handlers(self.grid.adapter.key_from_item(item), item)
        except ConflictError as e:
            log.warning(f'Optimistic lock conflict updating item {row_key}: {e}')
            self._notify(self._text.conflict, 'negative')
            self.grid.update_rows()
        except StorageError as e:
            log.error(f'Storage error updating item {row_key}: {e}')
            self._notify(str(e), 'negative')  # the adapter's own message, not one of ours
            self.grid.update_rows()
        except Exception as e:
            log.error(f'Error updating item: {e}')
            self._notify(self._text.update_error, 'negative', error=self._error_msg_from_exception(e))
            self.grid.update_rows()  # refresh to revert the UI to the current adapter state

    async def _on_update_clicked(self, event: ClickEventArguments) -> None:
        await self.update_item()

    async def delete_item(self) -> None:
        """Ask for confirmation and delete the selected row."""
        row_key = await self._get_selected_row_key()
        if not row_key:
            self._notify(self._text.select_row_to_delete, 'negative')
            return

        text = self._text
        confirm = await confirm_dialog(text_of(text.delete_selected_title),
                                       text_of(text.delete_selected_message, key=row_key),
                                       ok_label=text_of(text.delete_label), ok_role='delete',
                                       chrome_style=self._chrome_style, chrome_text=self._chrome_text)
        if not confirm:
            self._notify(text.delete_cancelled, 'negative')
            return

        try:
            self._apply_delete(row_key)
            self._notify(text.item_deleted, 'positive')
            self.grid.update_rows()
            self._notify_change_handlers(row_key, None)
        except Exception as e:
            log.error(f'Error deleting item {row_key}: {e}')
            self._notify(text.delete_error, 'negative', key=row_key, error=self._error_msg_from_exception(e))
            self.grid.update_rows()  # refresh to revert the UI to the current adapter state

    async def _on_delete_clicked(self, event: ClickEventArguments) -> None:
        await self.delete_item()

    async def _handle_chrome_action(self, name: str, action: FormAction, event: ClickEventArguments) -> None:
        """Call one of the application's own title-row actions with the current selection."""
        if action.on_click is None:
            return
        row_key = await self._get_selected_row_key()  # async: the selection lives in the browser
        item: BaseModel | None = None
        if row_key is not None:
            try:
                item = self.grid.adapter.read(row_key)
            except (KeyError, ValueError):
                item = None  # deleted between the click and the answer — the action sees no item
        handle_event(cast('Handler[GridActionEventArguments]', action.on_click),
                     GridActionEventArguments(sender=event.sender, client=event.client,
                                              wrapper=self, name=name, action=action,
                                              row_key=row_key, item=item))

    async def default_edit_create_handler(self, item: BaseModel, do_create: bool) -> bool:
        """
        Show a modal dialog to create or edit an item. Returns True if the user confirmed.

        The dialog renders a ModelForm for the item and presents Cancel / Create-or-Ok buttons.
        On confirm, pending widget values are flushed into the validated item before the dialog
        closes — this guards against the edge case where a blur event arrives after the click
        over WebSocket (browsers fire blur before click, but message ordering is not guaranteed).
        """
        style, text = self._style, self._text
        form = ModelForm.from_item(item, chrome_style=self._chrome_style, chrome_text=self._chrome_text)
        if self._model_repositories:
            form.with_repositories(self._model_repositories)

        def confirm():
            if form.has_validation_errors:
                self._notify(text.validation_errors, 'negative')
                return

            # Flush any pending widget values into the validated item.
            if form._current_item is not None and form._validated_item is not None:
                for field_name in form._fields:
                    fi = form._fields[field_name]
                    if not fi or fi.widget_type in ('editgrid', None):
                        continue
                    if form._validation_error_messages.get(field_name):
                        continue
                    cur = getattr(form._current_item, field_name)
                    if cur != getattr(form._validated_item, field_name):
                        setattr(form._validated_item, field_name, cur)
            dialog.submit('confirm')

        with chrome_dialog(style) as dialog:
            form.render()
            with ui.card_section().classes('w-full'):
                # Same button row as niceview.util's dialogs: cancel first, confirm last,
                # aligned to the right edge.
                with chrome_dialog_buttons(style):
                    chrome_button('cancel', text_of(text.cancel_label), None, '', style,
                                  lambda: dialog.submit('cancel'), place='dialog')
                    chrome_button('ok', text_of(text.create_label if do_create else text.ok_label),
                                  None, '', style, confirm, place='dialog')

        success = ('confirm' == await dialog)
        dialog.clear()
        return success

from_list classmethod

from_list(
    item_type: type[T],
    items: list[T],
    *,
    inline_edit: bool = False,
    **kwargs: Unpack[_EditGridWrapperFactoryInputs],
) -> Self

Create an EditGridWrapper backed by an in-memory list. Call render() (fluent: from_list(...).render()) to draw it.

Source code in niceview/editwrapper.py
@classmethod
def from_list(cls, item_type: type[T], items: list[T], *, inline_edit: bool = False, **kwargs: Unpack[_EditGridWrapperFactoryInputs]) -> Self:
    """Create an EditGridWrapper backed by an in-memory list. Call render() (fluent: from_list(...).render()) to draw it."""
    wrapper_kwargs, grid_kwargs = cls._split_kwargs(kwargs)
    grid_cls = ModelGridInlineEdit if inline_edit else ModelGrid
    grid = grid_cls.from_list(item_type, items, **grid_kwargs)
    return cls(grid, **wrapper_kwargs)

from_json classmethod

from_json(
    item_type: type[T],
    path_name: Path,
    *,
    create_if_not_exist: bool = True,
    inline_edit: bool = False,
    **kwargs: Unpack[_EditGridWrapperFactoryInputs],
) -> Self

Create an EditGridWrapper backed by a JSON file. Call render() to draw it.

Source code in niceview/editwrapper.py
@classmethod
def from_json(cls, item_type: type[T], path_name: Path, *, create_if_not_exist: bool = True, inline_edit: bool = False, **kwargs: Unpack[_EditGridWrapperFactoryInputs]) -> Self:
    """Create an EditGridWrapper backed by a JSON file. Call render() to draw it."""
    wrapper_kwargs, grid_kwargs = cls._split_kwargs(kwargs)
    grid_cls = ModelGridInlineEdit if inline_edit else ModelGrid
    grid = grid_cls.from_json(item_type, path_name, create_if_not_exist=create_if_not_exist, **grid_kwargs)
    return cls(grid, **wrapper_kwargs)

from_adapter classmethod

from_adapter(
    item_type: type[T],
    adapter: CollectionAdapter,
    *,
    inline_edit: bool = False,
    **kwargs: Unpack[_EditGridWrapperFactoryInputs],
) -> Self

Create an EditGridWrapper backed by any CollectionAdapter. Call render() to draw it.

Source code in niceview/editwrapper.py
@classmethod
def from_adapter(cls, item_type: type[T], adapter: CollectionAdapter, *, inline_edit: bool = False, **kwargs: Unpack[_EditGridWrapperFactoryInputs]) -> Self:
    """Create an EditGridWrapper backed by any CollectionAdapter. Call render() to draw it."""
    wrapper_kwargs, grid_kwargs = cls._split_kwargs(kwargs)
    grid_cls = ModelGridInlineEdit if inline_edit else ModelGrid
    grid = grid_cls.from_adapter(item_type, adapter, **grid_kwargs)
    return cls(grid, **wrapper_kwargs)

with_repositories

with_repositories(repositories: dict) -> Self

Set repositories for modelselect fields — the create/edit dialogs and the grid itself, so modelselect columns show their labels (and offer a select where applicable). Keys are a field name (preferred) or the related model type. Additive, and merged into the grid's own registrations rather than replacing them.

Source code in niceview/editwrapper.py
def with_repositories(self, repositories: 'dict') -> Self:
    """Set repositories for modelselect fields — the create/edit dialogs and the grid itself,
    so modelselect columns show their labels (and offer a select where applicable). Keys are
    a field name (preferred) or the related model type. Additive, and merged into the grid's
    own registrations rather than replacing them."""
    self._model_repositories = {**self._model_repositories, **repositories}  # merge; new wins
    self.grid.with_repositories(self._model_repositories)  # merge the full view into the grid
    return self

on_change

on_change(
    callback: Handler[TableItemEventArguments],
) -> Self

Add a callback invoked after each successful create, update, or delete.

Source code in niceview/editwrapper.py
def on_change(self, callback: Handler[TableItemEventArguments]) -> Self:
    """Add a callback invoked after each successful create, update, or delete."""
    if not callable(callback):
        raise TypeError(f"callback must be callable, got {type(callback)}")
    self._change_handlers.append(callback)
    return self

render

render() -> Self

Render title, description, CRUD buttons, and the grid into the current NiceGUI context.

Source code in niceview/editwrapper.py
def render(self) -> Self:
    """Render title, description, CRUD buttons, and the grid into the current NiceGUI context."""
    if self._rendered:
        return self
    self.title = None
    self.description = None
    self.title_row = None
    self.search_input = None
    self.delete_button = None
    self.add_button = None
    self.edit_button = None
    self.refresh_button = None
    self.action_buttons = {}

    style, text, place = self._style, self._text, self._place
    button_count = sum(b is not None for b in [self._refresh_button, self._delete_button, self._add_button, self._edit_button]) + len(self._chrome_actions)
    has_chrome = bool(self._title) or button_count > 0 or self._search
    if has_chrome:
        with chrome_row(style) as self.title_row:
            if self._title:
                self.title = chrome_title(self._title, style)
            elif self._search or button_count:
                ui.space()
            if self._search:
                self.search_input = ui.input(placeholder=text_of(text.search_placeholder)) \
                    .props('type=search outlined dense clearable').classes('w-48') \
                    .on_value_change(self._on_search_changed)
                with self.search_input.add_slot('append'):
                    ui.icon('search')
            if button_count:
                with chrome_buttons(style, button_count):
                    for name, action in self._chrome_actions.items():
                        self.action_buttons[name] = render_action_button(
                            action, style, place, None,
                            lambda event, n=name, a=action: self._handle_chrome_action(n, a, event))
                    if self._refresh_button is not None:
                        self.refresh_button = chrome_button('refresh', self._refresh_button, 'refresh', text_of(text.refresh_tooltip), style, self._on_refresh_clicked, place)
                    if self._delete_button is not None:
                        self.delete_button = chrome_button('delete', self._delete_button, 'delete', text_of(text.delete_tooltip), style, self._on_delete_clicked, place)
                    if self._add_button is not None:
                        self.add_button = chrome_button('add', self._add_button, 'add', text_of(text.add_tooltip), style, self._on_create_clicked, place)
                    if self._edit_button is not None:
                        self.edit_button = chrome_button('edit', self._edit_button, 'edit', text_of(text.edit_tooltip), style, self._on_update_clicked, place)

    if self._description:
        self.description = ui.markdown(self._description)

    self.grid.render()
    self._rendered = True
    return self

refresh

refresh() -> None

Reload from the adapter and re-render the grid.

Source code in niceview/editwrapper.py
def refresh(self) -> None:
    """Reload from the adapter and re-render the grid."""
    if isinstance(self.grid.adapter, ReloadableAdapter):
        self.grid.adapter.reload()
    self.grid.update_rows()

create_item async

create_item() -> None

Open the create dialog and, on confirmation, persist the new item.

Source code in niceview/editwrapper.py
async def create_item(self) -> None:
    """Open the create dialog and, on confirmation, persist the new item."""
    from niceview.dataadapter import FilteredAdapter
    item = self.grid._fields._item_type()
    # Pre-apply FK defaults so the dialog form starts with a valid item
    # (e.g. author_id is set before Pydantic validates the new Book).
    if isinstance(self.grid.adapter, FilteredAdapter):
        for field, value in self.grid.adapter._defaults.items():
            setattr(item, field, value)
    success = await self.default_edit_create_handler(item, True)
    if success:
        try:
            item = self._apply_create(item)
            self._notify(self._text.item_created, 'positive')
            self.grid.update_rows()
            self._notify_change_handlers(self.grid.adapter.key_from_item(item), item)
        except Exception as e:
            log.error(f'Error creating item: {e}')
            self._notify(self._text.create_error, 'negative', error=self._error_msg_from_exception(e))
    else:
        self._notify(self._text.create_cancelled, 'negative')

update_item async

update_item() -> None

Open the edit dialog for the selected row and, on confirmation, persist changes.

Source code in niceview/editwrapper.py
async def update_item(self) -> None:
    """Open the edit dialog for the selected row and, on confirmation, persist changes."""
    row_key = await self._get_selected_row_key()
    if not row_key:
        self._notify(self._text.select_row_first, 'negative')
        return

    item = self.grid.adapter.read(row_key)
    if not item:
        self._notify(self._text.item_not_found, 'negative', key=row_key)
        return

    item = item.model_copy(deep=True)
    success = await self.default_edit_create_handler(item, False)
    if not success:
        self._notify(self._text.update_cancelled, 'negative')
        return

    try:
        item = self._apply_update(item, row_key)
        self._notify(self._text.item_updated, 'positive')
        self.grid.update_rows()
        self._notify_change_handlers(self.grid.adapter.key_from_item(item), item)
    except ConflictError as e:
        log.warning(f'Optimistic lock conflict updating item {row_key}: {e}')
        self._notify(self._text.conflict, 'negative')
        self.grid.update_rows()
    except StorageError as e:
        log.error(f'Storage error updating item {row_key}: {e}')
        self._notify(str(e), 'negative')  # the adapter's own message, not one of ours
        self.grid.update_rows()
    except Exception as e:
        log.error(f'Error updating item: {e}')
        self._notify(self._text.update_error, 'negative', error=self._error_msg_from_exception(e))
        self.grid.update_rows()  # refresh to revert the UI to the current adapter state

delete_item async

delete_item() -> None

Ask for confirmation and delete the selected row.

Source code in niceview/editwrapper.py
async def delete_item(self) -> None:
    """Ask for confirmation and delete the selected row."""
    row_key = await self._get_selected_row_key()
    if not row_key:
        self._notify(self._text.select_row_to_delete, 'negative')
        return

    text = self._text
    confirm = await confirm_dialog(text_of(text.delete_selected_title),
                                   text_of(text.delete_selected_message, key=row_key),
                                   ok_label=text_of(text.delete_label), ok_role='delete',
                                   chrome_style=self._chrome_style, chrome_text=self._chrome_text)
    if not confirm:
        self._notify(text.delete_cancelled, 'negative')
        return

    try:
        self._apply_delete(row_key)
        self._notify(text.item_deleted, 'positive')
        self.grid.update_rows()
        self._notify_change_handlers(row_key, None)
    except Exception as e:
        log.error(f'Error deleting item {row_key}: {e}')
        self._notify(text.delete_error, 'negative', key=row_key, error=self._error_msg_from_exception(e))
        self.grid.update_rows()  # refresh to revert the UI to the current adapter state

default_edit_create_handler async

default_edit_create_handler(
    item: BaseModel, do_create: bool
) -> bool

Show a modal dialog to create or edit an item. Returns True if the user confirmed.

The dialog renders a ModelForm for the item and presents Cancel / Create-or-Ok buttons. On confirm, pending widget values are flushed into the validated item before the dialog closes — this guards against the edge case where a blur event arrives after the click over WebSocket (browsers fire blur before click, but message ordering is not guaranteed).

Source code in niceview/editwrapper.py
async def default_edit_create_handler(self, item: BaseModel, do_create: bool) -> bool:
    """
    Show a modal dialog to create or edit an item. Returns True if the user confirmed.

    The dialog renders a ModelForm for the item and presents Cancel / Create-or-Ok buttons.
    On confirm, pending widget values are flushed into the validated item before the dialog
    closes — this guards against the edge case where a blur event arrives after the click
    over WebSocket (browsers fire blur before click, but message ordering is not guaranteed).
    """
    style, text = self._style, self._text
    form = ModelForm.from_item(item, chrome_style=self._chrome_style, chrome_text=self._chrome_text)
    if self._model_repositories:
        form.with_repositories(self._model_repositories)

    def confirm():
        if form.has_validation_errors:
            self._notify(text.validation_errors, 'negative')
            return

        # Flush any pending widget values into the validated item.
        if form._current_item is not None and form._validated_item is not None:
            for field_name in form._fields:
                fi = form._fields[field_name]
                if not fi or fi.widget_type in ('editgrid', None):
                    continue
                if form._validation_error_messages.get(field_name):
                    continue
                cur = getattr(form._current_item, field_name)
                if cur != getattr(form._validated_item, field_name):
                    setattr(form._validated_item, field_name, cur)
        dialog.submit('confirm')

    with chrome_dialog(style) as dialog:
        form.render()
        with ui.card_section().classes('w-full'):
            # Same button row as niceview.util's dialogs: cancel first, confirm last,
            # aligned to the right edge.
            with chrome_dialog_buttons(style):
                chrome_button('cancel', text_of(text.cancel_label), None, '', style,
                              lambda: dialog.submit('cancel'), place='dialog')
                chrome_button('ok', text_of(text.create_label if do_create else text.ok_label),
                              None, '', style, confirm, place='dialog')

    success = ('confirm' == await dialog)
    dialog.clear()
    return success

GridActionEventArguments dataclass

Bases: UiEventArguments

What an action's on_click receives in an EditGridWrapper's title row.

A grid has no item of its own, it has a selection: row_key and item are the selected row, or None when nothing is selected — which an action that works on one has to check, the same way Edit and Delete do.

Source code in niceview/editwrapper.py
@dataclass(kw_only=True, slots=True)
class GridActionEventArguments(UiEventArguments):
    """
    What an action's `on_click` receives in an EditGridWrapper's title row.

    A grid has no item of its own, it has a selection: `row_key` and `item` are the selected
    row, or None when nothing is selected — which an action that works on one has to check,
    the same way Edit and Delete do.
    """
    wrapper: 'EditGridWrapper'
    """The wrapper the action belongs to."""
    name: str
    """The action's name, as declared in chrome_actions."""
    action: FormAction
    """The FormAction itself (label, icon, on_click, ...)."""
    row_key: str | None
    """Key of the selected row, or None when nothing is selected."""
    item: BaseModel | None
    """The selected row's item, or None when nothing is selected."""

wrapper instance-attribute

wrapper: EditGridWrapper

The wrapper the action belongs to.

name instance-attribute

name: str

The action's name, as declared in chrome_actions.

action instance-attribute

action: FormAction

The FormAction itself (label, icon, on_click, ...).

row_key instance-attribute

row_key: str | None

Key of the selected row, or None when nothing is selected.

item instance-attribute

item: BaseModel | None

The selected row's item, or None when nothing is selected.

ModelList

Renders a Pydantic model collection as a Quasar list (ui.list / ui.item). Each item shows a title line and an optional subtitle, with a chevron indicating drill-down.

The first visible field is used as the title; the next two as subtitle by default. Override with title_field= and subtitle_fields=.

Create via factory methods: ModelList.from_list(Type, items) — in-memory list ModelList.from_json(Type, path) — JSON file ModelList.from_adapter(Type, adapter) — any CollectionAdapter

After render(), the NiceGUI list element is available as .widget. Call update_rows() to refresh from the adapter.

The look of the list and its rows comes from the chrome style — application-wide via niceview.style.set_chrome_style(), or for this list alone via chrome_style=. Styling .widget directly is not enough: update_rows() rebuilds every row inside it.

Source code in niceview/modellist.py
class ModelList:
    """
    Renders a Pydantic model collection as a Quasar list (ui.list / ui.item).
    Each item shows a title line and an optional subtitle, with a chevron indicating drill-down.

    The first visible field is used as the title; the next two as subtitle by default.
    Override with title_field= and subtitle_fields=.

    Create via factory methods:
      ModelList.from_list(Type, items)       — in-memory list
      ModelList.from_json(Type, path)        — JSON file
      ModelList.from_adapter(Type, adapter)  — any CollectionAdapter

    After render(), the NiceGUI list element is available as .widget.
    Call update_rows() to refresh from the adapter.

    The look of the list and its rows comes from the chrome style — application-wide via
    niceview.style.set_chrome_style(), or for this list alone via chrome_style=. Styling
    .widget directly is not enough: update_rows() rebuilds every row inside it.
    """
    _fields: Fields
    _data: CollectionAdapter
    _title_field: str | None
    _subtitle_fields: list[str]
    _select_handlers: list[Handler[ListItemSelectEventArguments]]
    _auto_update_registered: bool
    _chrome_style: ChromeStyle | None
    _model_repositories: dict[type[BaseModel] | str, CollectionAdapter]
    _local_tz: str | None
    widget: ui.list | None

    def __init__(self, item_type: type[T], adapter: CollectionAdapter, **kwargs: Unpack[_ModelListOptionInputs]) -> None:
        if not isinstance(item_type, type) or not issubclass(item_type, BaseModel):
            raise TypeError(f"item_type must be a subclass of BaseModel, got {type(item_type)}")

        # include/exclude/field_infos fall back to the model's Meta (like ModelForm); profile
        # stays kwargs-only -- Fields() itself resolves Meta.default_profile as its fallback.
        include = meta_option(item_type, kwargs, 'include', '__all__')
        exclude = meta_option(item_type, kwargs, 'exclude', '')
        field_infos = meta_option(item_type, kwargs, 'field_infos', {})
        self._fields = Fields(item_type, include, exclude, field_infos,
                              profile=kwargs.pop('profile', None))
        self._local_tz = meta_option(item_type, kwargs, 'local_tz', None)
        self._data = adapter
        self._select_handlers = []
        self._auto_update_registered = False
        self._chrome_style = kwargs.pop('chrome_style', None)
        self._model_repositories = {}
        self.widget = None

        visible = [n for n in self._fields if not self._fields[n].hidden]
        title_field = kwargs.pop('title_field', None)
        subtitle_fields = kwargs.pop('subtitle_fields', None)
        self._title_field = title_field if title_field is not None else (visible[0] if visible else None)
        self._subtitle_fields = subtitle_fields if subtitle_fields is not None else visible[1:3]
        if kwargs:
            raise TypeError(f"Unexpected keyword arguments for ModelList: {', '.join(kwargs.keys())}")

    # --- factory methods ---------------------------------------------------

    @classmethod
    def from_list(cls, item_type: type[T], items: list[T], **kwargs: Unpack[_ModelListOptionInputs]) -> Self:
        """Create a ModelList backed by an in-memory list."""
        return cls(item_type, ListAdapter(item_type, items), **kwargs)  # type: ignore[arg-type]

    @classmethod
    def from_adapter(cls, item_type: type[T], adapter: CollectionAdapter, **kwargs: Unpack[_ModelListOptionInputs]) -> Self:
        """Create a ModelList from any CollectionAdapter."""
        return cls(item_type, adapter, **kwargs)  # type: ignore[arg-type]

    @classmethod
    def from_json(cls, item_type: type[T], path_name: Path, *, create_if_not_exist: bool = True, **kwargs: Unpack[_ModelListOptionInputs]) -> Self:
        """Create a ModelList backed by a JSON file."""
        adapter = JsonListAdapter(item_type, path_name, create_if_not_exist=create_if_not_exist)
        return cls(item_type, adapter, **kwargs)  # type: ignore[arg-type]

    @property
    def adapter(self) -> CollectionAdapter:
        """The backing data adapter."""
        return self._data

    # --- event handler configuration --------------------------------------

    def on_select(self, callback: Handler[ListItemSelectEventArguments]) -> Self:
        """Add a callback invoked when the user taps an item."""
        if not callable(callback):
            raise TypeError(f"callback must be callable, got {type(callback)}")
        self._select_handlers.append(callback)
        return self

    def _handle_select(self, row_key: str, item: Any) -> None:
        widget = self.widget
        lse = ListItemSelectEventArguments(
            sender=widget,  # type: ignore[arg-type]
            client=widget.client if widget else None,  # type: ignore[arg-type]
            row_key=row_key,
            item=item,
        )
        for handler in self._select_handlers:
            handle_event(handler, lse)

    # --- data and rendering -----------------------------------------------

    def _display_value(self, field_name: str, value: Any) -> str:
        """The text shown for a field value: a modelselect key resolves through its repository
        to the referenced item's label; a choice field's stored value resolves to its label
        (static options/literal_options only — an async options= callable can't be awaited
        here); a date/time-family value goes through the same conversion as ModelForm's widgets
        (local_tz included); a checkbox/switch field shows '✓'/'✗'; a ui.number field with any
        of precision/number_format/prefix/suffix set is formatted the same way ui.number itself
        would; a list joins its items with ', ', each resolved the same way; everything else is
        shown via str()."""
        if value is None:
            return ''
        fi = self._fields.get(field_name)
        if fi is None:
            return str(value)
        if fi.widget_type == 'modelselect' and not isinstance(value, BaseModel):
            repo = resolve_repository(self._model_repositories, field_name, fi.item_type)
            if repo is not None:
                try:
                    return str(repo.read(str(value)))
                except (KeyError, ValueError):
                    return str(value)  # stale key — show it rather than nothing
            return str(value)
        if fi.widget_type in ('datetime', 'date', 'time', 'timedelta'):
            return str(to_widget_value(fi, value, local_tz=self._local_tz))
        if fi.widget_type in ('ui.checkbox', 'ui.switch'):
            return '✓' if value else '✗'
        if fi.widget_type == 'ui.number' and (fi.precision is not None or fi.number_format
                                              or fi.prefix or fi.suffix):
            return format_number(fi, value)
        options = fi.options or fi.literal_options
        labels = {str(k): str(v) for k, v in options.items()} if isinstance(options, dict) else None
        if isinstance(value, list):
            return ', '.join(labels.get(str(v), str(v)) if labels else str(v) for v in value)
        if labels is not None:
            return labels.get(str(value), str(value))
        return str(value)

    def _item_title(self, item: Any) -> str:
        if not self._title_field:
            return str(item)
        return self._display_value(self._title_field, getattr(item, self._title_field, None))

    def _item_subtitle(self, item: Any) -> str:
        parts = []
        for field_name in self._subtitle_fields:
            parts.append(self._display_value(field_name, getattr(item, field_name, None)))
        return ' · '.join(parts)

    def with_repositories(self, repositories: 'dict') -> Self:
        """Register repositories for modelselect fields shown as title/subtitle, so a stored key
        displays the referenced item's label. Keys are a field name (preferred) or the related
        model type — the same form the other components accept."""
        self._model_repositories = {**self._model_repositories, **repositories}  # merge; new wins
        if self.widget is not None:
            self.update_rows()
        return self

    def _render_items(self) -> None:
        style = self._chrome_style or get_chrome_style()
        for item in self._data:
            key = self._data.key_from_item(item)
            subtitle = self._item_subtitle(item)
            with ui.item(on_click=lambda k=key, i=item: self._handle_select(k, i)).classes(style.list_item_classes):
                with ui.item_section():
                    ui.item_label(self._item_title(item)).props(style.list_title_props)
                    if subtitle:
                        ui.item_label(subtitle).props(style.list_subtitle_props)
                if style.list_chevron_icon is not None:
                    with ui.item_section().props('side'):
                        ui.icon(style.list_chevron_icon).classes(style.list_chevron_classes)

    def update_rows(self) -> Self:
        """Refresh the displayed list from the adapter."""
        if self.widget is None:
            return self
        self.widget.clear()
        with self.widget:
            self._render_items()
        return self

    def render(self) -> Self:
        """Render the list widget into the current NiceGUI context."""
        style = self._chrome_style or get_chrome_style()
        with ui.list().props(style.list_props).classes('w-full') as self.widget:
            self._render_items()

        if not self._auto_update_registered and isinstance(self._data, ReactiveAdapter):
            def _refresh() -> None:
                self.update_rows()
            self._data.on_change(_refresh)
            self._auto_update_registered = True

        return self

adapter property

The backing data adapter.

from_list classmethod

from_list(
    item_type: type[T],
    items: list[T],
    **kwargs: Unpack[_ModelListOptionInputs],
) -> Self

Create a ModelList backed by an in-memory list.

Source code in niceview/modellist.py
@classmethod
def from_list(cls, item_type: type[T], items: list[T], **kwargs: Unpack[_ModelListOptionInputs]) -> Self:
    """Create a ModelList backed by an in-memory list."""
    return cls(item_type, ListAdapter(item_type, items), **kwargs)  # type: ignore[arg-type]

from_adapter classmethod

from_adapter(
    item_type: type[T],
    adapter: CollectionAdapter,
    **kwargs: Unpack[_ModelListOptionInputs],
) -> Self

Create a ModelList from any CollectionAdapter.

Source code in niceview/modellist.py
@classmethod
def from_adapter(cls, item_type: type[T], adapter: CollectionAdapter, **kwargs: Unpack[_ModelListOptionInputs]) -> Self:
    """Create a ModelList from any CollectionAdapter."""
    return cls(item_type, adapter, **kwargs)  # type: ignore[arg-type]

from_json classmethod

from_json(
    item_type: type[T],
    path_name: Path,
    *,
    create_if_not_exist: bool = True,
    **kwargs: Unpack[_ModelListOptionInputs],
) -> Self

Create a ModelList backed by a JSON file.

Source code in niceview/modellist.py
@classmethod
def from_json(cls, item_type: type[T], path_name: Path, *, create_if_not_exist: bool = True, **kwargs: Unpack[_ModelListOptionInputs]) -> Self:
    """Create a ModelList backed by a JSON file."""
    adapter = JsonListAdapter(item_type, path_name, create_if_not_exist=create_if_not_exist)
    return cls(item_type, adapter, **kwargs)  # type: ignore[arg-type]

on_select

on_select(
    callback: Handler[ListItemSelectEventArguments],
) -> Self

Add a callback invoked when the user taps an item.

Source code in niceview/modellist.py
def on_select(self, callback: Handler[ListItemSelectEventArguments]) -> Self:
    """Add a callback invoked when the user taps an item."""
    if not callable(callback):
        raise TypeError(f"callback must be callable, got {type(callback)}")
    self._select_handlers.append(callback)
    return self

with_repositories

with_repositories(repositories: dict) -> Self

Register repositories for modelselect fields shown as title/subtitle, so a stored key displays the referenced item's label. Keys are a field name (preferred) or the related model type — the same form the other components accept.

Source code in niceview/modellist.py
def with_repositories(self, repositories: 'dict') -> Self:
    """Register repositories for modelselect fields shown as title/subtitle, so a stored key
    displays the referenced item's label. Keys are a field name (preferred) or the related
    model type — the same form the other components accept."""
    self._model_repositories = {**self._model_repositories, **repositories}  # merge; new wins
    if self.widget is not None:
        self.update_rows()
    return self

update_rows

update_rows() -> Self

Refresh the displayed list from the adapter.

Source code in niceview/modellist.py
def update_rows(self) -> Self:
    """Refresh the displayed list from the adapter."""
    if self.widget is None:
        return self
    self.widget.clear()
    with self.widget:
        self._render_items()
    return self

render

render() -> Self

Render the list widget into the current NiceGUI context.

Source code in niceview/modellist.py
def render(self) -> Self:
    """Render the list widget into the current NiceGUI context."""
    style = self._chrome_style or get_chrome_style()
    with ui.list().props(style.list_props).classes('w-full') as self.widget:
        self._render_items()

    if not self._auto_update_registered and isinstance(self._data, ReactiveAdapter):
        def _refresh() -> None:
            self.update_rows()
        self._data.on_change(_refresh)
        self._auto_update_registered = True

    return self

DrillDownWrapper

Embeddable list <-> detail navigation. render() draws a title row (Add in list view; Back + item title + Delete in detail view) plus a body that swaps between a list view and a per-item detail view, with a slide animation on every swap. No NiceGUI page/route of its own — call it inside your own ui.page / ui.card / ui.column, same as any other niceview widget.

Default rendering (both overridable): - list view: ModelList-style rows (title/subtitle from field values) - detail view: an autosaving ModelForm.from_adapter(item_type, adapter, key)

list_actions and detail_actions add the application's own buttons to the title row — the same FormAction a form places between its fields — right-aligned just left of niceview's own button of that view (Add, Delete), so niceview's own button keeps the right edge it has everywhere. list_actions sit in the list view and are hidden in the detail view, and vice versa: each view shows only its own actions, since an action about no single item and one about the item on screen are different things. list_actions get a DrillDownListActionEventArguments (no key/item — there is no single item in the list view); detail_actions get a DrillDownActionEventArguments naming the item on screen. chrome_actions is accepted as an alias of detail_actions.

Override render_list_item / render_detail for custom layout, heterogeneous item types (resolve the concrete pydantic type per item inside render_detail), or non-form content — e.g. rendering a nested DrillDownWrapper for a DirectoryAdapter's files. See README.

After render(), the title row elements are exposed for further styling -- built once and updated (text/visibility only) rather than recreated on every list<->detail navigation, so styling applied here is not lost: wrapper.title_row → ui.row | None wrapper.title → ui.label | None (list title, or the current item's title in detail view) wrapper.description → ui.markdown | None (None entirely if description= is unset) wrapper.back_button → ui.button | None (visible in detail always; in list only if on_back= is set) wrapper.search_input → ui.input | None (visible in list view; None entirely unless search=True) wrapper.add_button → ui.button | None (visible in list view; None entirely if add_button=None) wrapper.delete_button → ui.button | None (visible in detail view; None entirely if delete_button=None) wrapper.list_action_buttons → dict[str, ui.button] (visible in list view; from list_actions=) wrapper.action_buttons → dict[str, ui.button] (visible in detail view; from detail_actions= / chrome_actions=) For the shared look of the title row rather than a single instance of it, see niceview.style.set_chrome_style() and the chrome_style= option. The body (list/detail content) is not exposed: unlike the title row, it is genuinely torn down and rebuilt on every navigation (list and detail are structurally different content, and the swap is where the slide animation lives), so any styling applied to it would be silently lost on the next navigation.

Usage: wrapper = DrillDownWrapper.from_list(User, items, title='Users') wrapper.render()

Source code in niceview/drilldown.py
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
class DrillDownWrapper:
    """
    Embeddable list <-> detail navigation. render() draws a title row (Add in
    list view; Back + item title + Delete in detail view) plus a body that
    swaps between a list view and a per-item detail view, with a slide
    animation on every swap. No NiceGUI page/route of its own — call it inside
    your own ui.page / ui.card / ui.column, same as any other niceview widget.

    Default rendering (both overridable):
      - list view:   ModelList-style rows (title/subtitle from field values)
      - detail view: an autosaving ModelForm.from_adapter(item_type, adapter, key)

    `list_actions` and `detail_actions` add the application's own buttons to the title row — the
    same `FormAction` a form places between its fields — right-aligned just left of niceview's
    own button of that view (Add, Delete), so niceview's own button keeps the right edge it has
    everywhere. `list_actions` sit in the list view and are hidden in the detail view, and vice
    versa: each view shows only its own actions, since an action about no single item and one
    about the item on screen are different things. `list_actions` get a
    `DrillDownListActionEventArguments` (no key/item — there is no single item in the list
    view); `detail_actions` get a `DrillDownActionEventArguments` naming the item on screen.
    `chrome_actions` is accepted as an alias of `detail_actions`.

    Override render_list_item / render_detail for custom layout, heterogeneous
    item types (resolve the concrete pydantic type per item inside
    render_detail), or non-form content — e.g. rendering a nested
    DrillDownWrapper for a DirectoryAdapter's files. See README.

    After render(), the title row elements are exposed for further styling --
    built once and updated (text/visibility only) rather than recreated on
    every list<->detail navigation, so styling applied here is not lost:
        wrapper.title_row     → ui.row | None
        wrapper.title         → ui.label | None (list title, or the current item's title in detail view)
        wrapper.description   → ui.markdown | None (None entirely if description= is unset)
        wrapper.back_button   → ui.button | None (visible in detail always; in list only if on_back= is set)
        wrapper.search_input  → ui.input | None (visible in list view; None entirely unless search=True)
        wrapper.add_button    → ui.button | None (visible in list view; None entirely if add_button=None)
        wrapper.delete_button → ui.button | None (visible in detail view; None entirely if delete_button=None)
        wrapper.list_action_buttons → dict[str, ui.button] (visible in list view; from list_actions=)
        wrapper.action_buttons → dict[str, ui.button] (visible in detail view; from detail_actions= / chrome_actions=)
    For the shared look of the title row rather than a single instance of it, see
    niceview.style.set_chrome_style() and the chrome_style= option.
    The body (list/detail content) is not exposed: unlike the title row, it is genuinely
    torn down and rebuilt on every navigation (list and detail are structurally different
    content, and the swap is where the slide animation lives), so any styling applied to it
    would be silently lost on the next navigation.

    Usage:
        wrapper = DrillDownWrapper.from_list(User, items, title='Users')
        wrapper.render()
    """
    _item_type: type[BaseModel]
    _adapter: CollectionAdapter
    _title: str | None
    _description: str | None
    _item_title_field: str | None
    _item_subtitle_fields: list[str] | None
    _render_list_item: ListItemRenderer | None
    _render_list_container: ListContainerRenderer | None
    _render_detail: DetailRenderer | None
    _on_add: ActionHandler | None
    _on_back: ActionHandler | None
    _add_button: str | None
    _delete_button: str | None
    _back_button: str | None
    _search: bool
    _search_field_names: list[str]
    _search_adapter: 'FilteredAdapter | None'
    _list_actions: dict[str, FormAction]
    _detail_actions: dict[str, FormAction]
    _chrome_style: ChromeStyle | None
    _chrome_text: ChromeText | None
    _place: Place
    _list_kwargs: dict[str, Any]
    _state: dict[str, Any]
    _auto_update_registered: bool

    # Exposed NiceGUI elements: built once in render() and updated (not recreated) on every
    # list<->detail navigation, so styling applied after render() stays put. The body itself
    # (list/detail content) is not exposed -- see _body()'s docstring comment.
    title_row: ui.row | None
    title: ui.label | None
    description: ui.markdown | None
    back_button: ui.button | None
    search_input: ui.input | None
    add_button: ui.button | None
    delete_button: ui.button | None
    list_action_buttons: dict[str, ui.button]
    action_buttons: dict[str, ui.button]

    def __init__(self, item_type: type[BaseModel], adapter: CollectionAdapter, **kwargs: Unpack[_DrillDownWrapperOptionInputs]) -> None:
        if not isinstance(item_type, type) or not issubclass(item_type, BaseModel):
            raise TypeError(f"item_type must be a subclass of BaseModel, got {type(item_type)}")
        self._item_type = item_type
        self._adapter = adapter
        title = meta_option(item_type, kwargs, 'title', None, meta_key='title_plural')
        self._title = (item_type.__name__ + ' List') if title is None else (title or None)
        self._description = meta_option(item_type, kwargs, 'description', None)
        # title_field / subtitle_fields are accepted as aliases of item_title_field /
        # item_subtitle_fields (the names ModelList uses), so switching ModelList <-> DrillDown
        # needs no rename. The item_* form wins if both are given.
        title_field = kwargs.pop('item_title_field', None)
        if title_field is None:
            title_field = kwargs.pop('title_field', None)
        else:
            kwargs.pop('title_field', None)
        subtitle_fields = kwargs.pop('item_subtitle_fields', None)
        if subtitle_fields is None:
            subtitle_fields = kwargs.pop('subtitle_fields', None)
        else:
            kwargs.pop('subtitle_fields', None)
        self._item_title_field = title_field
        self._item_subtitle_fields = subtitle_fields
        self._render_list_item = kwargs.pop('render_list_item', None)
        self._render_list_container = kwargs.pop('render_list_container', None)
        self._render_detail = kwargs.pop('render_detail', None)
        self._on_add = kwargs.pop('on_add', None)
        self._on_back = kwargs.pop('on_back', None)
        self._add_button = kwargs.pop('add_button', '')
        self._delete_button = kwargs.pop('delete_button', '')
        self._back_button = kwargs.pop('back_button', '')
        self._search = kwargs.pop('search', False)
        # requires_valid can only be answered by the form the wrapper builds itself: a
        # render_detail of its own may put anything into the detail view, a form or not.
        detail_actions = kwargs.pop('detail_actions', None)
        if detail_actions is None:
            detail_actions = kwargs.pop('chrome_actions', {})
        else:
            kwargs.pop('chrome_actions', None)  # detail_actions wins if both are given
        self._detail_actions = ModelForm._checked_actions(
            detail_actions,
            no_form='' if self._render_detail is None else 'a render_detail of your own owns the detail view')
        # The list view is never backed by a single form -- requires_valid always needs one.
        self._list_actions = ModelForm._checked_actions(
            kwargs.pop('list_actions', {}), no_form='the list view has no single item to validate')
        self._chrome_style = kwargs.pop('chrome_style', None)
        self._chrome_text = kwargs.pop('chrome_text', None)
        self._place = kwargs.pop('place', 'toolbar')
        self._model_repositories: dict = {}
        self._list_kwargs = dict(kwargs)  # remainder forwarded to ModelList (include, exclude, ...) when render_list_item is unset
        allowed_list_keys = {'include', 'exclude', 'field_infos', 'profile'}
        if unknown := set(self._list_kwargs) - allowed_list_keys:
            raise TypeError(f"Unexpected keyword arguments for DrillDownWrapper: {', '.join(sorted(unknown))}")
        # on_add/on_back may be async, the renderers may not: they run inside the refreshable
        # body, which builds its elements synchronously. Say so here rather than let the
        # coroutine be dropped at render time, where an empty body is all you would see.
        for option in ('render_list_item', 'render_list_container', 'render_detail'):
            if helpers.is_coroutine_function(getattr(self, f'_{option}')):
                raise TypeError(f"DrillDownWrapper's {option} must be synchronous; "
                                f"load data before render() or fill a placeholder from a task.")
        self._state = {'view': 'list', 'key': None, 'direction': 'right', 'animate': True, 'search': ''}
        self._auto_update_registered = False

        self.title_row = None
        self.title = None
        self.description = None
        self.back_button = None
        self.search_input = None
        self.add_button = None
        self.delete_button = None
        self.list_action_buttons = {}
        self.action_buttons = {}

        # Resolve the display title field once so the detail title row is consistent; search
        # needs every visible field's name too, so it runs the same resolution either way.
        self._search_field_names = []
        if self._item_title_field is None or self._search:
            fields = Fields(
                item_type,
                self._list_kwargs.get('include', '__all__'),
                self._list_kwargs.get('exclude', ''),
                self._list_kwargs.get('field_infos', {}),
                profile=self._list_kwargs.get('profile', None),
            )
            visible = [name for name in fields if not fields[name].hidden]
            if self._item_title_field is None and visible:
                self._item_title_field = visible[0]
            if self._search:
                self._search_field_names = visible
        # One instance for the wrapper's lifetime, not one per render: the predicate reads
        # self._state['search'] dynamically, and a fresh FilteredAdapter on every body refresh
        # would register a fresh on_change forwarder on self._adapter each time -- a growing
        # leak, the same one ModelList's own dedup (see _render_list_view) already guards against.
        self._search_adapter = FilteredAdapter(self._adapter, self._matches_search) if self._search else None

    # --- factory methods ---------------------------------------------------

    @classmethod
    def from_list(cls, item_type: type[T], items: list[T], **kwargs: Unpack[_DrillDownWrapperOptionInputs]) -> Self:
        """Create a DrillDownWrapper backed by an in-memory list."""
        return cls(item_type, ListAdapter(item_type, items), **kwargs)  # type: ignore[arg-type]

    @classmethod
    def from_adapter(cls, item_type: type[T], adapter: CollectionAdapter, **kwargs: Unpack[_DrillDownWrapperOptionInputs]) -> Self:
        """Create a DrillDownWrapper from any CollectionAdapter."""
        return cls(item_type, adapter, **kwargs)  # type: ignore[arg-type]

    @classmethod
    def from_json(cls, item_type: type[T], path_name: Path, *, create_if_not_exist: bool = True, **kwargs: Unpack[_DrillDownWrapperOptionInputs]) -> Self:
        """Create a DrillDownWrapper backed by a JSON file."""
        adapter = JsonListAdapter(item_type, path_name, create_if_not_exist=create_if_not_exist)
        return cls(item_type, adapter, **kwargs)  # type: ignore[arg-type]

    @property
    def adapter(self) -> CollectionAdapter:
        """The backing data adapter."""
        return self._adapter

    def with_repositories(self, repositories: 'dict') -> Self:
        """Register repositories for modelselect fields, forwarded to both views: the list rows
        (a key field shows the referenced label) and the default detail form (its selects).
        Keys are a field name (preferred) or the related model type. Call before render().
        Additive: a later call adds to (and overrides) earlier registrations."""
        self._model_repositories = {**self._model_repositories, **repositories}  # merge; new wins
        return self

    # --- navigation ----------------------------------------------------------

    def open(self, key: str) -> Self:
        """Navigate to the detail view for key — e.g. from a custom on_add handler."""
        self._state.update(view='detail', key=key, direction='right', animate=True)
        self._update_title_row()
        self._body.refresh()
        return self

    def _back(self) -> None:
        self._state.update(view='list', key=None, direction='left', animate=True)
        self._update_title_row()
        self._body.refresh()

    def _select(self, key: str) -> None:
        self.open(key)

    def _make_select(self, key: str) -> Callable[[], None]:
        return lambda: self._select(key)

    def _on_adapter_change(self) -> None:
        # Data changed under us (e.g. an autosaving form's own field edit) -- rebuild the body
        # to reflect it, but this is not a list<->detail navigation, so don't replay the slide.
        self._state['animate'] = False
        self._body.refresh()

    # --- title row -----------------------------------------------------------

    def _item_title(self, item: Any) -> str:
        return str(getattr(item, self._item_title_field, '')) if self._item_title_field else str(item)

    def _detail_title(self) -> str:
        key = self._state['key']
        if key is None:
            return ''
        try:
            return self._item_title(self._adapter.read(key))
        except (KeyError, ValueError):
            return key

    def _matches_search(self, item: Any) -> bool:
        query = self._state['search'].strip().lower()
        if not query:
            return True
        return any(query in str(getattr(item, name, '')).lower() for name in self._search_field_names)

    def _on_search_changed(self, event: ValueChangeEventArguments) -> None:
        self._state['search'] = event.value or ''
        self._state['animate'] = False
        self._body.refresh()

    async def _handle_back_click(self) -> None:
        if self._state['view'] == 'detail':
            self._back()
        elif self._on_back is not None:
            await maybe_await(self._on_back())

    @property
    def _style(self) -> ChromeStyle:
        return self._chrome_style or get_chrome_style()

    @property
    def _text(self) -> ChromeText:
        return self._chrome_text or get_chrome_text()

    def _notify(self, template: Any, kind: NotifyKind, **params: Any) -> None:
        """One of niceview's notifications: text from ChromeText, delivery from ChromeStyle."""
        chrome_notify(text_of(template, **params), kind, self._style)

    def _build_title_row(self) -> None:
        # Built once (unlike _body) and updated in place by _update_title_row(): its structure
        # barely changes between list/detail -- just text and which buttons are visible -- so
        # keeping it persistent lets callers style it once after render() instead of every
        # element being wiped out on each list<->detail navigation.
        style, text, place = self._style, self._text, self._place
        with chrome_row(style) as self.title_row:
            # Back sits left of the title: it navigates, it is not one of the actions on the
            # item, which are grouped at the right edge like in the other wrappers.
            if self._back_button is not None:
                self.back_button = chrome_button('back', self._back_button, 'arrow_back', text_of(text.back_tooltip), style, self._handle_back_click, place)
            self.title = chrome_title('', style)
            if self._search:
                self.search_input = ui.input(placeholder=text_of(text.search_placeholder)) \
                    .props('type=search outlined dense clearable').classes('w-48') \
                    .on_value_change(self._on_search_changed)
                with self.search_input.add_slot('append'):
                    ui.icon('search')
            if (self._list_actions or self._add_button is not None
                    or self._detail_actions or self._delete_button is not None):
                # list_actions + Add belong to the list view, detail_actions + Delete to the
                # detail view, so the count is whichever side is wider at once — never the sum.
                list_count = len(self._list_actions) + (1 if self._add_button is not None else 0)
                detail_count = len(self._detail_actions) + (1 if self._delete_button is not None else 0)
                with chrome_buttons(style, max(list_count, detail_count)):
                    # detail_actions are built first (kept from chrome_actions' original spot)
                    # so an existing wrapper without list_actions keeps its exact button order;
                    # each view only ever shows its own contiguous run (the other is hidden), so
                    # visually list_actions still sits right before Add, detail_actions right
                    # before Delete.
                    for name, action in self._detail_actions.items():
                        self.action_buttons[name] = render_action_button(
                            action, style, place, None,
                            lambda event, n=name, a=action: self._handle_detail_action(n, a, event))
                    for name, action in self._list_actions.items():
                        self.list_action_buttons[name] = render_action_button(
                            action, style, place, None,
                            lambda event, n=name, a=action: self._handle_list_action(n, a, event))
                    if self._add_button is not None:
                        self.add_button = chrome_button('add', self._add_button, 'add', text_of(text.add_tooltip), style, self._handle_add, place)
                    if self._delete_button is not None:
                        self.delete_button = chrome_button('delete', self._delete_button, 'delete', text_of(text.delete_item_tooltip), style, self._handle_delete, place)

    def _update_title_row(self) -> None:
        assert self.title is not None
        is_detail = self._state['view'] == 'detail'
        if self.back_button is not None:
            self.back_button.set_visibility(is_detail or self._on_back is not None)
        self.title.set_text(self._detail_title() if is_detail else (self._title or ''))
        if self.search_input is not None:
            self.search_input.set_visibility(not is_detail)
        for button in self.list_action_buttons.values():
            button.set_visibility(not is_detail)
        if self.add_button is not None:
            self.add_button.set_visibility(not is_detail)
        for button in self.action_buttons.values():
            button.set_visibility(is_detail)
        if self.delete_button is not None:
            self.delete_button.set_visibility(is_detail)

    # --- body ------------------------------------------------------------------

    @ui.refreshable_method
    def _body(self) -> None:
        # Not exposed for styling: unlike title_row, this container is genuinely torn down
        # and rebuilt on every navigation (list and detail are structurally different content),
        # so any styling applied to it would be silently lost on the next swap.
        classes = 'w-full gap-2'
        if self._state['animate']:
            classes += f' {_slide_class(self._state["direction"])}'
        with ui.column().classes(classes):
            if self._state['view'] == 'detail' and self._state['key'] is not None:
                self._render_detail_view(self._state['key'])
            else:
                self._render_list_view()

    def _render_list_view(self) -> None:
        adapter = self._search_adapter or self._adapter
        if self._render_list_item is not None:
            items = list(adapter.items())
            if not items:
                ui.label(text_of(self._text.no_items)).classes('italic')
                return
            render_list_item = self._render_list_item

            def render_rows() -> None:
                for key, item in items:
                    render_list_item(key, item, self._make_select(key))

            if self._render_list_container is not None:
                self._render_list_container(render_rows)
            else:
                render_rows()
            return
        model_list = ModelList(
            self._item_type, adapter,
            title_field=self._item_title_field,
            subtitle_fields=self._item_subtitle_fields,
            chrome_style=self._chrome_style,  # a style set on the wrapper styles its rows too
            **self._list_kwargs,
        )
        # _render_list_view() runs again on every DrillDownWrapper._body refresh, creating a
        # fresh ModelList each time. Skip ModelList's own reactive on_change registration --
        # our own registration in render() already re-renders the whole body on adapter changes,
        # and letting each throwaway ModelList instance register too would leak a growing chain
        # of on_change handlers pointing at stale, already-deleted widgets.
        model_list._auto_update_registered = True
        if self._model_repositories:
            model_list.with_repositories(self._model_repositories)
        model_list.on_select(lambda e: self.open(e.row_key))
        model_list.render()

    def _default_render_detail(self, adapter: CollectionAdapter, key: str, set_key: Callable[[str], None]) -> None:
        form = ModelForm.from_adapter(self._item_type, adapter, key, autosave=True,
                                      chrome_style=self._chrome_style, chrome_text=self._chrome_text)
        if self._model_repositories:
            form.with_repositories(self._model_repositories)
        form.render()  # render() already places the non-field error label
        # The title row outlives the form -- every navigation builds a new one -- so a
        # requires_valid action is handed to whichever form is currently below it.
        for name, action in self._detail_actions.items():
            if action.requires_valid and (button := self.action_buttons.get(name)) is not None:
                form._gate_on_validity(button)

    def _set_detail_key(self, new_key: str) -> None:
        if new_key != self._state['key']:
            self._state.update(key=new_key, animate=False)
            self._update_title_row()
            self._body.refresh()

    def _render_detail_view(self, key: str) -> None:
        try:
            self._adapter.read(key)
        except (KeyError, ValueError):
            ui.label(text_of(self._text.detail_not_found, key=key)).classes('text-negative')
            return
        renderer = self._render_detail or self._default_render_detail
        renderer(self._adapter, key, self._set_detail_key)

    # --- CRUD actions ------------------------------------------------------

    async def _handle_add(self) -> None:
        if self._on_add is not None:
            await maybe_await(self._on_add())
            return
        item = self._adapter.create(self._item_type())
        self.open(self._adapter.key_from_item(item))

    def _handle_detail_action(self, name: str, action: FormAction, event: ClickEventArguments) -> None:
        """Call one of the application's own detail-view title-row actions with the item on screen."""
        if action.on_click is None:
            return
        key = self._state['key']
        if key is None:
            return  # only reachable in the detail view, where a key is what the view is about
        try:
            item = self._adapter.read(key)
        except (KeyError, ValueError):
            log.warning(f"Action '{name}': item {key!r} is gone")
            return
        handle_event(cast('Handler[DrillDownActionEventArguments]', action.on_click),
                     DrillDownActionEventArguments(sender=event.sender, client=event.client,
                                                   wrapper=self, name=name, action=action,
                                                   key=key, item=item))

    def _handle_list_action(self, name: str, action: FormAction, event: ClickEventArguments) -> None:
        """Call one of the application's own list-view title-row actions."""
        if action.on_click is None:
            return
        handle_event(cast('Handler[DrillDownListActionEventArguments]', action.on_click),
                     DrillDownListActionEventArguments(sender=event.sender, client=event.client,
                                                       wrapper=self, name=name, action=action))

    async def _handle_delete(self) -> None:
        key = self._state['key']
        if key is None:
            return
        text = self._text
        if not await confirm_dialog(text_of(text.delete_item_title), text_of(text.delete_item_message),
                                    ok_label=text_of(text.delete_label), ok_role='delete',
                                    chrome_style=self._chrome_style, chrome_text=self._chrome_text):
            return
        try:
            self._adapter.delete(key)
        except Exception as e:
            log.error(f'Error deleting item {key!r}: {e}')
            self._notify(text.delete_failed, 'negative', error=e)
            return
        self._notify(text.item_deleted, 'positive')
        self._back()

    # --- render --------------------------------------------------------------

    def render(self) -> Self:
        """Render the title row and list/detail body into the current NiceGUI context."""
        self._build_title_row()
        self._update_title_row()
        if self._description:
            self.description = ui.markdown(self._description)
        self._body()
        if not self._auto_update_registered and isinstance(self._adapter, ReactiveAdapter):
            self._adapter.on_change(self._on_adapter_change)
            self._auto_update_registered = True
        return self

adapter property

The backing data adapter.

from_list classmethod

from_list(
    item_type: type[T],
    items: list[T],
    **kwargs: Unpack[_DrillDownWrapperOptionInputs],
) -> Self

Create a DrillDownWrapper backed by an in-memory list.

Source code in niceview/drilldown.py
@classmethod
def from_list(cls, item_type: type[T], items: list[T], **kwargs: Unpack[_DrillDownWrapperOptionInputs]) -> Self:
    """Create a DrillDownWrapper backed by an in-memory list."""
    return cls(item_type, ListAdapter(item_type, items), **kwargs)  # type: ignore[arg-type]

from_adapter classmethod

from_adapter(
    item_type: type[T],
    adapter: CollectionAdapter,
    **kwargs: Unpack[_DrillDownWrapperOptionInputs],
) -> Self

Create a DrillDownWrapper from any CollectionAdapter.

Source code in niceview/drilldown.py
@classmethod
def from_adapter(cls, item_type: type[T], adapter: CollectionAdapter, **kwargs: Unpack[_DrillDownWrapperOptionInputs]) -> Self:
    """Create a DrillDownWrapper from any CollectionAdapter."""
    return cls(item_type, adapter, **kwargs)  # type: ignore[arg-type]

from_json classmethod

from_json(
    item_type: type[T],
    path_name: Path,
    *,
    create_if_not_exist: bool = True,
    **kwargs: Unpack[_DrillDownWrapperOptionInputs],
) -> Self

Create a DrillDownWrapper backed by a JSON file.

Source code in niceview/drilldown.py
@classmethod
def from_json(cls, item_type: type[T], path_name: Path, *, create_if_not_exist: bool = True, **kwargs: Unpack[_DrillDownWrapperOptionInputs]) -> Self:
    """Create a DrillDownWrapper backed by a JSON file."""
    adapter = JsonListAdapter(item_type, path_name, create_if_not_exist=create_if_not_exist)
    return cls(item_type, adapter, **kwargs)  # type: ignore[arg-type]

with_repositories

with_repositories(repositories: dict) -> Self

Register repositories for modelselect fields, forwarded to both views: the list rows (a key field shows the referenced label) and the default detail form (its selects). Keys are a field name (preferred) or the related model type. Call before render(). Additive: a later call adds to (and overrides) earlier registrations.

Source code in niceview/drilldown.py
def with_repositories(self, repositories: 'dict') -> Self:
    """Register repositories for modelselect fields, forwarded to both views: the list rows
    (a key field shows the referenced label) and the default detail form (its selects).
    Keys are a field name (preferred) or the related model type. Call before render().
    Additive: a later call adds to (and overrides) earlier registrations."""
    self._model_repositories = {**self._model_repositories, **repositories}  # merge; new wins
    return self

open

open(key: str) -> Self

Navigate to the detail view for key — e.g. from a custom on_add handler.

Source code in niceview/drilldown.py
def open(self, key: str) -> Self:
    """Navigate to the detail view for key — e.g. from a custom on_add handler."""
    self._state.update(view='detail', key=key, direction='right', animate=True)
    self._update_title_row()
    self._body.refresh()
    return self

render

render() -> Self

Render the title row and list/detail body into the current NiceGUI context.

Source code in niceview/drilldown.py
def render(self) -> Self:
    """Render the title row and list/detail body into the current NiceGUI context."""
    self._build_title_row()
    self._update_title_row()
    if self._description:
        self.description = ui.markdown(self._description)
    self._body()
    if not self._auto_update_registered and isinstance(self._adapter, ReactiveAdapter):
        self._adapter.on_change(self._on_adapter_change)
        self._auto_update_registered = True
    return self

DrillDownActionEventArguments dataclass

Bases: UiEventArguments

What a detail_actions action's on_click receives in a DrillDownWrapper's title row.

Those buttons belong to the detail view — key and item are the item on screen, never None, because there is nothing to click them on in the list view. See DrillDownListActionEventArguments for list_actions, which has neither.

Source code in niceview/drilldown.py
@dataclass(kw_only=True, slots=True)
class DrillDownActionEventArguments(UiEventArguments):
    """
    What a `detail_actions` action's `on_click` receives in a DrillDownWrapper's title row.

    Those buttons belong to the detail view — `key` and `item` are the item on screen, never
    None, because there is nothing to click them on in the list view. See
    DrillDownListActionEventArguments for `list_actions`, which has neither.
    """
    wrapper: 'DrillDownWrapper'
    """The wrapper the action belongs to."""
    name: str
    """The action's name, as declared in detail_actions."""
    action: FormAction
    """The FormAction itself (label, icon, on_click, ...)."""
    key: str
    """Key of the item on screen."""
    item: BaseModel
    """The item on screen."""

wrapper instance-attribute

The wrapper the action belongs to.

name instance-attribute

name: str

The action's name, as declared in detail_actions.

action instance-attribute

action: FormAction

The FormAction itself (label, icon, on_click, ...).

key instance-attribute

key: str

Key of the item on screen.

item instance-attribute

item: BaseModel

The item on screen.