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+DrillDownWrapperas separate components: A newModelList(Quasar list) andDrillDownWrapperare added alongsideModelGrid/EditGridWrapperrather 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. DrillDownWrapperis embeddable, not page-owning (no.register(base_path), no@ui.pageof 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-renderedEditFormWrapperper 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 aui.refreshable_method-driven list/detail body with a slide animation (same technique as manual multi-page-in-one-page navigation elsewhere) plusrender_list_item/render_detailoverride hooks, dropping the split-panel layout and page registration entirely.render_detail'sset_keycallback instead of a return value: The first design hadrender_detailreturn the new key after a rename. That only works for synchronous renames — a "Name" input'sblurhandler fires well afterrender_detailhas 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/ModelListstill takeitem_typeexplicitly alongsideadapter, even though concrete adapters (ListAdapter,JsonListAdapter, ...) already know their item type internally. Kept for consistency withModelForm.from_adapter,ModelGrid.from_adapter, andEditFormWrapper.from_adapter, which all take the same(item_type, adapter, ...)shape — and becauseCollectionAdapterdoesn't guarantee item-type introspection (DirectoryAdapter's items are alwaysFileEntry,FilteredAdapterjust wraps another adapter). Deriving it from the adapter would need a new required Protocol member across every adapter implementation (including the optionalSqlModelAdapter) 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_methodtreatment to both, which gave the title row a matching slide-in animation but meant any styling applied towrapper.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 inrender()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/EditFormWrapperfactories: early versions rendered inside thefrom_*factory. This broke the create-then-render symmetry withModelForm/ModelGrid/ModelList/DrillDownWrapper(whose custom-layout APIs likerender_field()require a non-rendered construction phase), made construction impossible outside a UI context, and prevented configuration before rendering. Since factories return the instance andrender()returns it again, the fluent one-linerX.from_list(...).render()costs callers a single call. - Strict keyword arguments: unknown kwargs raise
TypeErrorin all component constructors and factories instead of being silently ignored — a silently droppedclasses='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 acceptingclasses=/props=factory kwargs. A kwarg would duplicate NiceGUI's fluent API and be ambiguous about which element it targets. - Multi-level tree navigation:
DrillDownWrappercovers the common 2-level (list → detail) case per instance, though nesting one inside another'srender_detail(seeexamples/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.pageroutes with a central URL factory class (R) and a sharedpage_header(title, back_url)helper — seeexamples/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_changeand exposeis_dirtyproperty; (b) use a JSbeforeunloadguard (requires NiceGUIui.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_itemopen 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 theScreenfixture (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: MakingModelFormgeneric would allowform.itemto returnTinstead ofBaseModel, eliminating casts in callers. The machinery is non-trivial:@classmethod+TypeVargenerics are awkward pre-3.12, and internal fields (_current_item,_validated_item) would need careful typing. Deferred — the benefit is modest since callers rarely accessform.itemdirectly andmodel_copy()/model_validate()stay untyped internally anyway. The action events inherit the same limitation now that they are exported for annotating a handler:e.itemis aBaseModel(BaseModel | Noneon a grid), soe.item.hostneeds a cast under mypy. Typing itAny— asTableItemEventArgumentsdoes — would let the attribute through, but it would also hide theNonea 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 aModelFormmode: rendering a single widget from a hand-builtFieldInfois a strictly smaller job thanModelForm— no item, no adapter, no change events, no validation state — and callers that need it (interpreters for schemas that must never become classes viacreate_model()) specifically want not to instantiate a form. Making it a function onniceview/widgets.py, withModelFormcalling 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 thatwidget_typeandfield_type, whichModelFormderives from the annotation, must be given explicitly, and that'editgrid'/'modelselect'stayModelForm-only (they need a model type and a repository) — both raiseValueErrorrather than being silently skipped. - Field metadata mirrors NiceGUI, validation is layered, the item is written whole: three rules that belong together. (1)
FieldInfois NiceGUI's widget vocabulary plus a named set of niceview extensions — every constructor argument of every supported element is aFieldInfoattribute of the same name, set by niceview itself, or deliberately left toprops=, declared inWIDGET_OPTIONSand enforced bytests/test_widget_option_coverage.pyso a NiceGUI upgrade cannot widen the gap unnoticed. (2) Validation runs in layers:required, thenfield_info.validation(NiceGUI's own contract, and the only two layersrender_field()has), then the value conversion, then —ModelFormonly — the whole item against the Pydantic model. Model validation is an additional feature ofModelForm, 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.itemis the statesave()would persist and keeps its identity so NiceGUI bindings survive, whileform.draftexposes what the widgets currently hold. The alternative — committing each field as soon as that field alone is valid — leftform.itemin statessave()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
FieldInfowith a'button'widget type — WTForms'SubmitFieldand Angular Formly's'button'type do exactly that. It would put something intoFieldsthat 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 andFieldsstays a mapping of model fields. The name is a token rather than aFormActionobject 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 aMeta.profilesentry stays pure data — putting callbacks into the layout tree would hang behaviour off the model class. The callback lives in a separateactionstable, which is also why actions are a kwarg only and never read fromMeta.requires_validis the single piece of state niceview takes over, becausehas_validation_errorsis something it already knows;visible/enabledcallables were deliberately left out — the application has the button and can do that itself. The wrapper'schrome_actions=is a separate name from the form'sactions=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 sameFormAction, but the callback cannot always be handed a form — a grid has a selection instead of an item, and aDrillDownWrapper's detail view may be arender_detailof the application's own. Rather than pass a form that is sometimesNone, 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, soon_clickis 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'schrome_actionscannot drift apart. Two things do not travel with it.requires_validneeds 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. DrillDownWrappersplitschrome_actionsintolist_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.DrillDownWrapperhas 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_actionsbefore Add,detail_actionsbefore Delete), fixes that without touching the other wrappers.chrome_actionsstays as an accepted alias ofdetail_actions(the latter wins if both are given, same pattern astitle_field/item_title_field) so existing callers are unaffected.list_actionsgets its own event type,DrillDownListActionEventArguments— nokey/item, since the list view is about no single item — and always rejectsrequires_valid, since there is no form there to ask.- Which options are Meta-sourceable: not "all of them", one rule per option:
ModelFormreads more of its options fromMetathanModelGrid/ModelListdo, which reads more than an unrelated set of options forEditFormWrapper/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_infosdescribe 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).layoutstaysModelForm-only becauseModelGrid/ModelListhave no layout concept to feed it into (a grid's columns are a flat list, not rows/cards); it already reachesEditFormWrappertransitively, since that wrapper forwards unknown kwargs straight into theModelFormit 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_classesstayModelForm-only because a read-only grid/list row has no editable widget to apply them to (ChromeStyle's ownPlacetype already says Quasar props do not reach AG Grid cells).description_asstaysModelForm-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_tzis now Meta-sourceable inModelGrid/ModelListtoo, same pattern (see the next bullet); numeric formatting (number_format/prefix/suffix) is the one still open, tracked in TODO.md. ModelGrid/ModelListreuseto_widget_value()for date/time-family values, rather than a grid/list-specific formatter: the value conversionlocal_tzneeds —datetime/date/time/timedeltato a plain ISO string, tz-aware fordatetime— already existed forModelForm's widgets inniceview.widgets.to_widget_value(). Routing aModelGridrow's orModelListrow'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 rawtimedeltaobject crashedModelGridentirely (orjson, NiceGUI's JSON encoder, has notimedeltasupport), andto_widget_value()already turns it into the canonical ISO-8601 duration stringModelFormshows. The same audit foundModelList's value-to-text step resolved amodelselectfield's label but nothing else: a choice field showed its raw stored value instead of the label fromoptions/literal_options, and a list-valued field showed Python'srepr()("['a', 'b']") instead of a joined string, matching whatModelGridalready did for both. Both were fixed alongsidelocal_tzin the same pass, since they are the same class of problem — the display value not going through the same resolutionModelForm's widget already does.ModelGrid's numeric formatting is avalueFormatter, 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()), butModelGridcannot: AG Grid renders client-side, and writing a formatted string like'$4.50'straight intorowData— the same move that works fortimedelta/datetime— would silently break the column's own sort andagNumberColumnFilter, which expect a real number, not text that merely looks like one. AG Grid'svalueFormatterkeeps 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:propNameconvention (a column-def key prefixed with:is evaluated into a real function at grid-init time; seenicegui/static/utils/dynamic_properties.js).number_formatis 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 toModelFormas before), rather than trying to reimplement Python's%-formatting mini-language in JS.- A
boolfield gets AG Grid's owncellDataType: 'boolean', not a custom checkbox renderer:ui.aggriddeprecated 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 readingui.aggrid's own migration shim, which rewrites the old form and warns. SettingcellDataType: 'boolean'is therefore the one line that givesModelGrida non-interactive checkbox look andModelGridInlineEdit(whosedefaultColDefalready turns oneditable) a clickable one, for free and withoutniceviewowning any button/icon rendering itself — consistent with how little of AG Grid's own behaviorniceviewreimplements elsewhere. It also means the boolean column must not fall into theagTextColumnFilterdefault in_collect_aggrid_cols: AG Grid's own boolean filter, whichcellDataTypealready brings, would otherwise be overridden by an unrelated text filter.ModelListhas no AG Grid to defer to, so its checkbox equivalent is the plain Unicode'✓'/'✗'pair — visually the closest aui.label's text content can get to a checkbox without turning_display_value()from a string builder into something that can also place aui.icon. Meta.default_profiledegrades softly, an explicitprofile=does not: an explicitprofile='typo'is a request that cannot be honoured and should say so loudly —Fieldsalready raised for it.Meta.default_profileis 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 absentdefault_profilesilently 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 withmeta_option's own rule (kwargs win overMeta): explicitprofile=> explicitlayout=>Meta.default_profile> every field.DrillDownWrapper'ssearch=filters via a long-livedFilteredAdapter, not a fresh one per render: the list view is rebuilt on every navigation and on everysearchkeystroke (_body.refresh()), so filtering had to avoid re-wrapping the adapter each time —FilteredAdapter.__init__itself registers anon_changeforwarder 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 forModelList's own reactive registration. OneFilteredAdapteris built once (only whensearch=True), with a predicate that reads the current search text fromself._statedynamically rather than capturing it at construction time. The search box itself lives inDrillDownWrapper's title row, not onModelList: only the title row can place it immediately left oflist_actionsas asked, sinceModelList'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 viaset_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 fromFieldInfoor pydantic'stitleand therefore already belongs to the application, which localizes it where it defines it. The price is that niceview ships no.pofiles 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.