Skip to content

Design Decisions and Accepted Technical Debt

This document records design decisions and consciously accepted technical debt. User-facing documentation lives in the overview; open work items in TODO.md.

  • Mobile navigation: ModelList + DrillDownWrapper as separate components: A new ModelList (Quasar list) and DrillDownWrapper are added alongside ModelGrid / EditGridWrapper rather than making the existing desktop components responsive. Motivation: the UX patterns are fundamentally different — card list with drill-down vs data table with dialogs — and a single component handling both would need too many conditional paths.
  • DrillDownWrapper is embeddable, not page-owning (no .register(base_path), no @ui.page of its own): An earlier version registered two NiceGUI pages (list + {key} detail) with a CSS-only desktop split-panel. In practice this was rarely useful on its own — real editors need custom detail layout and often heterogeneous item types in one collection, which a single auto-rendered EditFormWrapper per item can't express, and most call sites wanted the widget embedded inside an existing page/card rather than owning a URL. It was replaced by a ui.refreshable_method-driven list/detail body with a slide animation (same technique as manual multi-page-in-one-page navigation elsewhere) plus render_list_item/render_detail override hooks, dropping the split-panel layout and page registration entirely.
  • render_detail's set_key callback instead of a return value: The first design had render_detail return the new key after a rename. That only works for synchronous renames — a "Name" input's blur handler fires well after render_detail has already returned, so the wrapper would never learn about the change. set_key(new_key), callable at any time, fixes this at the cost of one extra parameter.
  • DrillDownWrapper/ModelList still take item_type explicitly alongside adapter, even though concrete adapters (ListAdapter, JsonListAdapter, ...) already know their item type internally. Kept for consistency with ModelForm.from_adapter, ModelGrid.from_adapter, and EditFormWrapper.from_adapter, which all take the same (item_type, adapter, ...) shape — and because CollectionAdapter doesn't guarantee item-type introspection (DirectoryAdapter's items are always FileEntry, FilteredAdapter just wraps another adapter). Deriving it from the adapter would need a new required Protocol member across every adapter implementation (including the optional SqlModelAdapter) for a parameter that's one extra argument, not a real pain point.
  • DrillDownWrapper's title row is built once, not recreated on every navigation like the body: the first version applied the same "tear down and rebuild" ui.refreshable_method treatment to both, which gave the title row a matching slide-in animation but meant any styling applied to wrapper.title/wrapper.add_button/etc. was silently wiped on the very next list<->detail swap. Since the title row's structure barely changes between views (same slots, just text and visibility), it's now built once in render() and updated in place (.set_text()/.set_visibility()), at the cost of dropping its own slide animation — the body still animates, since its content is genuinely rebuilt every time regardless.
  • No auto-render in EditGridWrapper/EditFormWrapper factories: early versions rendered inside the from_* factory. This broke the create-then-render symmetry with ModelForm/ModelGrid/ModelList/DrillDownWrapper (whose custom-layout APIs like render_field() require a non-rendered construction phase), made construction impossible outside a UI context, and prevented configuration before rendering. Since factories return the instance and render() returns it again, the fluent one-liner X.from_list(...).render() costs callers a single call.
  • Strict keyword arguments: unknown kwargs raise TypeError in all component constructors and factories instead of being silently ignored — a silently dropped classes='w-full' cost more debugging time than the strictness costs in flexibility.
  • Styling via exposed elements, not kwargs: components expose their NiceGUI elements after render() (grid.widget, wrapper.title, form.w(name), ...) instead of accepting classes=/props= factory kwargs. A kwarg would duplicate NiceGUI's fluent API and be ambiguous about which element it targets.
  • Multi-level tree navigation: DrillDownWrapper covers the common 2-level (list → detail) case per instance, though nesting one inside another's render_detail (see examples/13_directory_drilldown.py's directory-of-files → per-file editor pattern) composes further levels. For URL-addressable deep trees (card grid → edit form → sub-list → sub-detail, etc.) the recommended pattern is still explicit @ui.page routes with a central URL factory class (R) and a shared page_header(title, back_url) helper — see examples/11_tree_navigation.py. This keeps each page self-contained, makes URL changes a one-place edit, and ensures the back button is always explicit and visible (not relying on browser history).
  • Form navigation / dirty state: No detection when the user leaves an unsaved form. Options: (a) track dirty state via on_change and expose is_dirty property; (b) use a JS beforeunload guard (requires NiceGUI ui.run_javascript). Neither covers in-app navigation — NiceGUI has no built-in route guard.
  • NiceGUI element lifecycle: When are elements instantiated, active, deleted? render() must be called inside a NiceGUI page context; elements created outside a client context silently fail. No lifecycle hooks for cleanup.
  • Tests for async dialog flows: create_item / update_item / delete_item open a NiceGUI dialog (await dialog) and cannot be tested without a browser. The CRUD data operations are covered via _apply_create, _apply_update, _apply_delete (unit tests) and the render/button presence via acceptance tests. Full dialog flow testing would require the Screen fixture (Playwright-based).
  • Design decision date/time/datetime: Use Python data types and HTML native widgets instead of NiceGUI/Quasar widgets with strings.
  • ModelForm[T] generics: Making ModelForm generic would allow form.item to return T instead of BaseModel, eliminating casts in callers. The machinery is non-trivial: @classmethod + TypeVar generics are awkward pre-3.12, and internal fields (_current_item, _validated_item) would need careful typing. Deferred — the benefit is modest since callers rarely access form.item directly and model_copy()/model_validate() stay untyped internally anyway. The action events inherit the same limitation now that they are exported for annotating a handler: e.item is a BaseModel (BaseModel | None on a grid), so e.item.host needs a cast under mypy. Typing it Any — as TableItemEventArguments does — would let the attribute through, but it would also hide the None a grid's empty selection makes real, and that case is the one an application must not forget. The honest type stays; the ergonomics wait for the same generics.
  • Model-free render_field() as a module-level function, not a ModelForm mode: rendering a single widget from a hand-built FieldInfo is a strictly smaller job than ModelForm — no item, no adapter, no change events, no validation state — and callers that need it (interpreters for schemas that must never become classes via create_model()) specifically want not to instantiate a form. Making it a function on niceview/widgets.py, with ModelForm calling the same _create_widget() with a value getter of its own, keeps the widget switch in exactly one place: a new widget type or a changed styling convention reaches both paths at once. The price is that widget_type and field_type, which ModelForm derives from the annotation, must be given explicitly, and that 'editgrid'/'modelselect' stay ModelForm-only (they need a model type and a repository) — both raise ValueError rather than being silently skipped.
  • Field metadata mirrors NiceGUI, validation is layered, the item is written whole: three rules that belong together. (1) FieldInfo is NiceGUI's widget vocabulary plus a named set of niceview extensions — every constructor argument of every supported element is a FieldInfo attribute of the same name, set by niceview itself, or deliberately left to props=, declared in WIDGET_OPTIONS and enforced by tests/test_widget_option_coverage.py so a NiceGUI upgrade cannot widen the gap unnoticed. (2) Validation runs in layers: required, then field_info.validation (NiceGUI's own contract, and the only two layers render_field() has), then the value conversion, then — ModelForm only — the whole item against the Pydantic model. Model validation is an additional feature of ModelForm, not the base mechanism, and a value rejected by an earlier layer never reaches a later one. (3) The item is written only when it validates as a whole, and in place: form.item is the state save() would persist and keeps its identity so NiceGUI bindings survive, while form.draft exposes what the widgets currently hold. The alternative — committing each field as soon as that field alone is valid — left form.item in states save() would refuse, which is the bug this replaced. The price is that one interaction can emit several change events, and that an edit blocked by a cross-field error is reported only when the error clears.
  • A form action is a layout element, not a pseudo-field: a button without a model field ("Test connection" next to the host) could have been a FieldInfo with a 'button' widget type — WTForms' SubmitField and Angular Formly's 'button' type do exactly that. It would put something into Fields that has no value, no validation and no origin in the model, so every path that walks the fields — pushing values into widgets, converting them back, _styled(), the validation loop — would need an exception for it. The layout language already carries non-model content ('# Title', '## Title' are layout, not fields), so '@name' joins it there and Fields stays a mapping of model fields. The name is a token rather than a FormAction object in the tree for two reasons: it composes with everything the notation can do ('@test:w-1/4' takes classes like a field name, rows and columns treat it like any child), and a Meta.profiles entry stays pure data — putting callbacks into the layout tree would hang behaviour off the model class. The callback lives in a separate actions table, which is also why actions are a kwarg only and never read from Meta. requires_valid is the single piece of state niceview takes over, because has_validation_errors is something it already knows; visible/enabled callables were deliberately left out — the application has the button and can do that itself. The wrapper's chrome_actions= is a separate name from the form's actions= because a factory call configures both at once and one name could not tell them apart.
  • One FormAction, one event type per place: every wrapper's title row takes the same FormAction, but the callback cannot always be handed a form — a grid has a selection instead of an item, and a DrillDownWrapper's detail view may be a render_detail of the application's own. Rather than pass a form that is sometimes None, each place sends the event arguments of what it is about: FormActionEventArguments, GridActionEventArguments, DrillDownActionEventArguments. The alternative — an action type per wrapper — would have duplicated label, icon, tooltip, props and classes three times over for the one field that differs, so on_click is typed as the union of the three handlers instead, and the button itself is built in one place (render_action_button()) so that a form's '@name' and a wrapper's chrome_actions cannot drift apart. Two things do not travel with it. requires_valid needs a form to ask, so the places without one reject it at construction rather than render a button that stays enabled without a word. An empty grid selection is left to the application — the case Edit and Delete already answer with Select a row first — because a niceview that disabled the button would have to poll the selection to keep it current, and one that swallowed the click would hide an action that may not need a row at all.
  • DrillDownWrapper splits chrome_actions into list_actions/detail_actions: every other wrapper has one view and therefore one title row, so one action table (chrome_actions) says all there is to say. DrillDownWrapper has two — list and detail — and a single table could not place a button in one without also building it, hidden, into the other's DOM position; an application wanting an action in the list view (e.g. "Export") had no name for it. Two tables, one per view, each rendered immediately left of that view's own button (list_actions before Add, detail_actions before Delete), fixes that without touching the other wrappers. chrome_actions stays as an accepted alias of detail_actions (the latter wins if both are given, same pattern as title_field/item_title_field) so existing callers are unaffected. list_actions gets its own event type, DrillDownListActionEventArguments — no key/item, since the list view is about no single item — and always rejects requires_valid, since there is no form there to ask.
  • Which options are Meta-sourceable: not "all of them", one rule per option: ModelForm reads more of its options from Meta than ModelGrid/ModelList do, which reads more than an unrelated set of options for EditFormWrapper/EditGridWrapper/DrillDownWrapper's own title row — deliberately, not an oversight. The rule is whether the option is answerable outside a form at all: include/exclude/field_infos describe the model itself, so all three field-oriented components now read them the same way (meta_option, ModelForm's own resolver folded into it — one function, not three copies). layout stays ModelForm-only because ModelGrid/ModelList have no layout concept to feed it into (a grid's columns are a flat list, not rows/cards); it already reaches EditFormWrapper transitively, since that wrapper forwards unknown kwargs straight into the ModelForm it builds; the same forwarding is why the wrappers never needed their own Meta-resolution for any of this in the first place. autosave, required_marker/required_message, base_props/default_classes stay ModelForm-only because a read-only grid/list row has no editable widget to apply them to (ChromeStyle's own Place type already says Quasar props do not reach AG Grid cells). description_as stays ModelForm-only on purpose: it decides between a hint and a tooltip, and a grid column has neither — a header tooltip was considered and rejected, since it would sit in the way of the header's existing sort/filter affordances for something niceview does not currently judge important enough to earn that space. local_tz is now Meta-sourceable in ModelGrid/ModelList too, same pattern (see the next bullet); numeric formatting (number_format/prefix/suffix) is the one still open, tracked in TODO.md.
  • ModelGrid/ModelList reuse to_widget_value() for date/time-family values, rather than a grid/list-specific formatter: the value conversion local_tz needs — datetime/date/time/timedelta to a plain ISO string, tz-aware for datetime — already existed for ModelForm's widgets in niceview.widgets.to_widget_value(). Routing a ModelGrid row's or ModelList row's date/time-family field through the same function, rather than writing a second formatter, means the three components can never drift on what a given value looks like, and it fixed a real bug for free: a raw timedelta object crashed ModelGrid entirely (orjson, NiceGUI's JSON encoder, has no timedelta support), and to_widget_value() already turns it into the canonical ISO-8601 duration string ModelForm shows. The same audit found ModelList's value-to-text step resolved a modelselect field's label but nothing else: a choice field showed its raw stored value instead of the label from options/literal_options, and a list-valued field showed Python's repr() ("['a', 'b']") instead of a joined string, matching what ModelGrid already did for both. Both were fixed alongside local_tz in the same pass, since they are the same class of problem — the display value not going through the same resolution ModelForm's widget already does.
  • ModelGrid's numeric formatting is a valueFormatter, not a display string, unlike everything else in this list: ModelList's number formatting reuses the same "compute the display text in Python" idea as the date/time fixes above (widgets.format_number()), but ModelGrid cannot: AG Grid renders client-side, and writing a formatted string like '$4.50' straight into rowData — the same move that works for timedelta/datetime — would silently break the column's own sort and agNumberColumnFilter, which expect a real number, not text that merely looks like one. AG Grid's valueFormatter keeps the cell's value numeric and only changes what is displayed, but it runs client-side, so it has to be a JS expression, not a Python callable — the one place in this codebase a small JS snippet is generated, via NiceGUI's :propName convention (a column-def key prefixed with : is evaluated into a real function at grid-init time; see nicegui/static/utils/dynamic_properties.js). number_format is only honoured when it is a plain '%.<n>f' pattern the precision can be extracted from — other patterns fall back to no formatting in the grid (still applying to ModelForm as before), rather than trying to reimplement Python's %-formatting mini-language in JS.
  • A bool field gets AG Grid's own cellDataType: 'boolean', not a custom checkbox renderer: ui.aggrid deprecated a manual 'checkboxRenderer' string in favor of declaring the column's data type and letting AG Grid pick its own native renderer/editor — confirmed by reading ui.aggrid's own migration shim, which rewrites the old form and warns. Setting cellDataType: 'boolean' is therefore the one line that gives ModelGrid a non-interactive checkbox look and ModelGridInlineEdit (whose defaultColDef already turns on editable) a clickable one, for free and without niceview owning any button/icon rendering itself — consistent with how little of AG Grid's own behavior niceview reimplements elsewhere. It also means the boolean column must not fall into the agTextColumnFilter default in _collect_aggrid_cols: AG Grid's own boolean filter, which cellDataType already brings, would otherwise be overridden by an unrelated text filter. ModelList has no AG Grid to defer to, so its checkbox equivalent is the plain Unicode '✓'/'✗' pair — visually the closest a ui.label's text content can get to a checkbox without turning _display_value() from a string builder into something that can also place a ui.icon.
  • Meta.default_profile degrades softly, an explicit profile= does not: an explicit profile='typo' is a request that cannot be honoured and should say so loudly — Fields already raised for it. Meta.default_profile is a different kind of value: a hint the model class carries about its own preferred view, and a hint that no longer resolves (the profile was renamed or removed) should not make the model unusable everywhere it is rendered. So a stale or absent default_profile silently falls back to no profile, resolved in exactly one place — Fields.__init__, the funnel every field-oriented component already goes through — so it applies uniformly without any wrapper needing to know about it. The precedence stays consistent with meta_option's own rule (kwargs win over Meta): explicit profile= > explicit layout= > Meta.default_profile > every field.
  • DrillDownWrapper's search= filters via a long-lived FilteredAdapter, not a fresh one per render: the list view is rebuilt on every navigation and on every search keystroke (_body.refresh()), so filtering had to avoid re-wrapping the adapter each time — FilteredAdapter.__init__ itself registers an on_change forwarder on the adapter it wraps, and a fresh one per render would leak a growing chain of them, the same failure mode already guarded against for ModelList's own reactive registration. One FilteredAdapter is built once (only when search=True), with a predicate that reads the current search text from self._state dynamically rather than capturing it at construction time. The search box itself lives in DrillDownWrapper's title row, not on ModelList: only the title row can place it immediately left of list_actions as asked, since ModelList's own rendering happens entirely inside the body, a structurally separate region that is torn down and rebuilt on every swap.
  • No gettext, no translation files — a replaceable string table instead: every text niceview shows lives in ChromeText (niceview/text.py), replaceable as a whole via set_chrome_text(). Two reasons not to ship an i18n stack. (1) A library must not pick the application's: Django can prescribe gettext because Django is the framework, niceview is not, and an application that already uses Babel, Fluent or a dict of its own would have to bridge two systems. (2) Decisive: a NiceGUI locale is per client, gettext's locale is per process — a server rendering two sessions in two languages at once cannot be served by a module-level translation. Hence every slot accepts a callable as well as a string, resolved when the text is rendered rather than when it is configured, so an application can plug its own per-client lookup in without niceview knowing what it is. Model texts stay out entirely: a field's label comes from FieldInfo or pydantic's title and therefore already belongs to the application, which localizes it where it defines it. The price is that niceview ships no .po files and no plural forms — a plural belongs to a language's grammar, and resolving it is exactly the job of the stack the application chose.