Skip to content

Card-Based List Editing

examples/12_card_list.py · run it with uv run python examples/12_card_list.py

Card-Based List Editing

ModelGrid/EditGridWrapper render a list as a table with Add/Edit/Delete dialogs — great for dense data, less so on mobile or when each item needs its own layout. This example builds a mobile-friendly alternative from scratch: one card per item, each with its own autosaving ModelForm and a custom render_field() layout, backed by a JsonListAdapter.

There is no dedicated wrapper class for this — the card layout is inherently application-specific, so it's composed directly from ModelForm.from_adapter(), CollectionAdapter.create()/delete(), and NiceGUI's @ui.refreshable.

Each card's form uses autosave=True, so edits persist to the JSON file field-by-field (after validation, on blur/change) — no shared save button, no row selection. The raw JSON file is shown live below the cards.

Also shown: - a @model_validator whose error appears via form.render_nonfield_errors() (try a non-https URL with method ≠ GET) - confirm_dialog from niceview.util guarding the per-card delete button

Source
"""
# Card-Based List Editing

`ModelGrid`/`EditGridWrapper` render a list as a table with Add/Edit/Delete dialogs — great for
dense data, less so on mobile or when each item needs its own layout. This example builds a
mobile-friendly alternative from scratch: one card per item, each with its own autosaving
`ModelForm` and a custom `render_field()` layout, backed by a `JsonListAdapter`.

There is no dedicated wrapper class for this — the card layout is inherently
application-specific, so it's composed directly from `ModelForm.from_adapter()`,
`CollectionAdapter.create()`/`delete()`, and NiceGUI's `@ui.refreshable`.

Each card's form uses `autosave=True`, so edits persist to the JSON file field-by-field
(after validation, on blur/change) — no shared save button, no row selection. The raw JSON
file is shown live below the cards.

Also shown:
- a `@model_validator` whose error appears via `form.render_nonfield_errors()`
  (try a non-https URL with method ≠ GET)
- `confirm_dialog` from `niceview.util` guarding the per-card delete button
"""
import json
from pathlib import Path
from typing import Literal

import pydantic
from nicegui import ui

from niceview import JsonListAdapter, ModelForm
from niceview.util import confirm_dialog


class Webhook(pydantic.BaseModel):
    name: str = pydantic.Field(default='', min_length=1, max_length=40, pattern=r'^[a-zA-Z0-9_-]*$', title='Name')
    method: Literal['GET', 'POST', 'PUT', 'DELETE'] = pydantic.Field(default='POST', title='Method')
    url: str = pydantic.Field(default='https://', max_length=200, pattern=r'^https?://.*', title='URL')

    @pydantic.model_validator(mode='after')
    def _https_for_writes(self) -> 'Webhook':
        # Model-level rule spanning two fields — shown by render_nonfield_errors()
        if self.method != 'GET' and not self.url.startswith('https://'):
            raise ValueError('Webhooks with method != GET must use an https:// URL')
        return self


WEBHOOKS_PATH = Path('./example_webhooks.json')
adapter = JsonListAdapter(Webhook, WEBHOOKS_PATH)


@ui.refreshable
def render_cards() -> None:
    for key, item in adapter.items():
        form = ModelForm.from_adapter(Webhook, adapter, key, autosave=True)
        with ui.card().classes('w-full'):
            with ui.row().classes('w-full items-center'):
                form.render_field('name').classes('grow').props('outlined dense hide-bottom-space')
                ui.button(icon='delete').props('color=negative dense flat').on_click(
                    lambda _, it=item: delete_row(it)
                )
            with ui.row().classes('w-full'):
                form.render_field('method').classes('w-1/4').props('outlined dense hide-bottom-space')
                form.render_field('url').classes('grow').props('outlined dense hide-bottom-space')
            form.render_nonfield_errors()


def add_row() -> None:
    adapter.create(Webhook(name=f'hook_{len(list(adapter)) + 1}'))
    render_cards.refresh()


async def delete_row(item: Webhook) -> None:
    if not await confirm_dialog('Delete Webhook', f'Delete **{item.name or "this webhook"}**?',
                                ok_label='Delete', ok_role='delete'):
        return
    adapter.delete(adapter.key_from_item(item))
    render_cards.refresh()


def render_json_viewer() -> None:
    content = ui.code('', language='json').classes('w-full')

    def reload():
        try:
            content.set_content(json.dumps(json.loads(WEBHOOKS_PATH.read_text(encoding='utf-8')), indent=2))
        except FileNotFoundError:
            content.set_content('(file not yet created)')

    reload()
    ui.timer(1, reload)
    ui.label('Auto-refreshes every second').classes('text-caption')


@ui.page('/')
def page():
    ui.markdown(__doc__ or '')
    ui.separator()

    with ui.row().classes('w-full items-start gap-4'):
        with ui.column().classes('flex-1'):
            render_cards()
            ui.button('Add Webhook', icon='add', on_click=add_row).props('color=primary')

        with ui.card().classes('flex-1'):
            ui.label(f'JSON — {WEBHOOKS_PATH.name}').classes('text-h6')
            render_json_viewer()


ui.run(title='12 — Card List')