Dynamic UI RFC #1445
Replies: 2 comments
|
There's a draft PR on the server side that this RFC needs to account for: What it is: a This replaces This is a Against the proposed build order1. Collections follow the same vocabulary: a 2. 3. 4. 5. 6/7. Cross-cutting behavioursAll first-class fields:
On the action modalThe RFC lists that consumer as blocked on a separate plugin-action RFC. On the
Two shipped actions already declare parameters ( SequencingLess open than it was. Steps 2–4 are where the old bag was most awkward — If anything in the DTO shape is awkward to render, now is the free time to change |
|
Follow-up to revam's comment above: two shape proposals from walking We took a live Current shape (as of PR
|
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Dynamic UI (schema-driven parameter/form generation, rendering & validation, and conventions) — RFC v2
Summary
Shoko already has a schema-driven UI mechanism: a plugin author decorates a C# configuration class with attributes, the server describes the resulting form, and the WebUI renders it — no hand-coded per-feature UI required. This exists end-to-end today, but coverage is thin: the WebUI implements only 2 of 9 documented element types, several cross-cutting behaviors the server can already express (visibility rules, badges, restart/env-var indicators) aren't rendered at all, and server-declared action buttons on a form are dropped entirely.
Since v1 was posted, the server side of this contract has itself started to change: a draft, unmerged
ShokoServerpull request (ShokoAnime/ShokoServer#1414) replaces the JSON-Schema-plus-extension-bag approach (x-uiDefinition) with a dedicated, render-readyUiDefinitiondocument, served alongside the schema rather than folded into it. It was opened specifically to gather feedback before merging, so this revision designs against it directly rather than against the bag it replaces — building the WebUI's rendering layer once, against the shape that's actually going to ship, instead of twice.This RFC proposes:
UiDefinitiondocument describes (section-container,boolean,integer,float,string,text-area,password,code-editor,enum,list,record,select, plusreferenceandunknownas structural/fallback cases) and the cross-cutting behaviors it carries on every element (size, visibility, badges, restart/env-var indicators, denied values).Throughout, the plugin developer controls the resulting UI by authoring the schema/attributes; the WebUI's job is to render it generically, not to hand-build a component per plugin.
Motivation
This is not a from-scratch design. A generic mechanism already exists and is already wired up end-to-end for one consumer:
GET Configuration/{id}/Schemareturns a JSON Schema, currently extended with anx-uiDefinitionproperty describing how each field should render — element type, size, visibility rules, badges, restart requirements, env var overrides, etc.GET Configuration/{id}returns the current values.src/components/Dynamic/{DynamicForm,DynamicSection,DynamicField,CodeEditor}.tsxconsume that contract generically (keyed by configurationid, not hardcoded to any one feature).src/pages/utilities/Renamer.tsx.The gap is coverage, not architecture: the WebUI's
elementTypeunion documents nine element types, but only two are actually rendered (auto, which itself branches internally on JSON type to cover booleans/numbers/strings, andcode-block) — everything else falls through to an "Unsupported element type" placeholder. Several cross-cutting behaviors the schema already models — visibility, badges, restart indicators, env-var overrides, denied values — aren't rendered at all yet.Why this revision targets a document that hasn't merged yet: two independent findings changed the picture since v1 was posted.
First, a real, reproducible defect in the current, live
x-uiDefinitiongenerator: for a plain scalar list (e.g.CoreSettings.Import.VideoExtensions, aList<string>), the field meant to carry the list's item element type is initialized to a display size value and is only conditionally overwritten — for a simple scalar list, it never is. A client reading that field to decide how to render list items would be reading a value that means something else entirely. The same construction exists forrecord. This isn't a hypothetical edge case; it's the exact mechanism the straightforward, most-common list shape goes through.Second, ShokoAnime/ShokoServer#1414 adds a purpose-built
UiDefinitiondocument (GET Configuration/{id}/UiDefinition) that fixes exactly this: a list or record's item element is a real, always-present child node, not something inferred from an overloaded field. It also addsGET Action/{actionID}/UiDefinitionand accepts parameters on action-invocation endpoints — giving the plugin-action-parameter consumer (this RFC's original motivating use case) a real contract for the first time. The PR is a draft, unmerged, and explicitly opened to collect feedback before it lands — this revision is a form of that feedback: a concrete rendering design built against the proposed shape, surfacing anything awkward to change now, while it's still free to change.This RFC's target consumers are:
Renamer.tsxalready uses to other plugins. Buildable today.GET Action/{actionID}/UiDefinition, plus parameter-accepting invocation endpoints) as of Render-ready UI definitions for configurations and actions, plus a Roslyn analyzer for unrenderable shapes ShokoServer#1414, though that contract's own server-side documentation still calls it a proof of concept — treat this consumer as designed-and-ready-to-build-against, not yet shovel-ready to ship, until the server side stabilizes.The design intent behind both: the plugin developer controls the UI, not the WebUI team — a plugin ships a description of its fields, and the WebUI renders it generically. This is distinct from
src/pages/settings/plugin/PluginPageEmbed.tsx, which already lets a plugin control its UI via a fully custom page embedded in an<iframe>— a heavier mechanism (arbitrary HTML/JS) than a declarative, server-described form. Whether this RFC treats the two as complementary (iframe for rich custom pages, schema-driven for simple structured settings/params) is worth stating explicitly in Design.Also distinct from both of the above: a configuration class can already declare UI action buttons on its own settings form (e.g. "Test Connection"), separate from the action-modal consumer described above. The WebUI doesn't render these at all today — easy to conflate with the "plugin action modal" concept since both are called "actions," but they're different mechanisms serving different purposes.
Design
Element type renderers
The new
UiDefinitiondocument uses a 14-member element-kind discriminator, with noautomember — the server always resolves authored intent down to one concrete kind before the document is served, so the client dispatches on that kind directly rather than inferring a render type from the underlying JSON type. Build order is still foundational-first:section-containerunblocks the complex variants oflist/record, so it goes first even though it isn't independently visible in the UI.What this actually looks like on the wire, so the shape below isn't described only in prose — this is the AniDB login area of
CoreSettings, trimmed, from a live server payload against the PR branch:Each
Kindvalue maps 1:1 to an element-type renderer, so dispatch becomes a switch onKind; resolving$refs becomes unnecessary since references are already resolved or hoisted intoDefinitions.That example doesn't cover
list,record, orselect— exactly the three shapes with open questions in Design below. Unlike the example above, the following aren't taken from a live payload; they're constructed directly from the type definitions to fill that gap, and should be verified against a real payload before anyone treats them as authoritative:The numbered build order below matches both examples:
section-container— a container whose children are exposed two ways: an ordered map of child elements keyed by the property name they're stored under, and (when interleaving with action buttons matters) an ordered list naming which of two maps — items or actions — each entry belongs to. Recursing into a nestedsection-containeris the same component the top level already uses, just called one level deeper.list— its layout is one of:auto(flat editable array — one field per index, add/remove/reorder controls per the container's own hide-add/hide-remove/sortable flags),enum-checkbox(a checkbox per member, when the item kind isenum), orcomplex-dropdown/complex-tab/complex-inline(each item is a full nestedsection-container, selected via dropdown / tabs / stacked inline respectively, keyed by a primary-key field the container declares). In every case, the item's own element description is a real, always-present child node — no inferring the item's kind from a separate, possibly-stale field. Real examples:OfflineImporter.AutoMatchRules/ParseRules(complex),CoreSettings.Import.VideoExtensions/Image.ImageTemplateUrls(simple scalar).record— same shape aslistbut keyed by a string/enum key instead of an index, and carries two child nodes instead of one: one describing the key, one describing the value — named so a renderer can treat a list entry's payload and a record entry's payload identically. No live schema example exists yet to validate the design against, particularly for an enum-keyed record — treat that specific case as provisional until one shows up.enum— a fixed list of named values, each carrying its own display title, optional description, wire value, and any alias names/values that collapsed onto it (so a value stays findable by every name it's known by, e.g. a legacy alias for a renamed option), plus a flag for whether the values are combinable bit flags. Flag enums likely need the same checkbox-list treatmentlist'senum-checkboxvariant uses — worth building that as one shared sub-component rather than two. Real examples:OfflineImporter.Mode,OfflineImporter.MatchType,CoreSettings.AniDb.MyList_StorageState,CoreSettings.AniDb.Calendar_UpdateFrequency.select— a selection component whose options are supplied by the server as part of the configuration value itself, not as part of the element's own description — the definition only carries the layout (auto/flat-list/checkbox-list) and whether multiple selection is allowed.flat-list→ existing select component;checkbox-list→ the same shared checkbox-list sub-component asenum/list's checkbox variants (a third reason to build that as one shared piece, not three). Multi-select beyond checkbox-list (a searchable/tag-style control) is a fast-follow, not v1 scope for this element. Worth building the value-binding layer around deliberately, not by extension of how every other element binds: the value this element points at (see the example above) serializes its own fields in lowercase —options,groups, and each option'svalue/label/selected/default/disabled/groupId— while the rest of the document, including this element's own definition, is cased like every other field shown above. A single generic "read the value at this element's key" binding helper would silently break on this one element kind if it assumes uniform casing. Real example:OfflineImporter.AutoMatchRule.LocationRules[].ManagedFolderSelector.password— trivial: a masked single-line text input, no new component needed. Real example:CoreSettings.AniDb.Password.text-area— needs one small new component (no multiline text input exists in the input library today) — a native<textarea>styled to match the existing single-line input. Real example:OfflineImporter.CustomParseRule.Regex.boolean/integer/float/string— what today's code handles as a singleautocase branching on JSON type becomes four concrete, independently-dispatched kinds. Functionally equivalent to what's already implemented for these (checkbox / number input / number input / text input) —integerandfloatboth carry their own min/max bounds directly on the element (aslong?/double?respectively), so neither needs a schema round-trip for validation range — just reached by matching the element kind directly rather than by reading the underlying schema type, which removes a layer of inference the current code needs and the new document doesn't.code-editor(the new name for what'scode-blocktoday) — same Monaco-based editor already implemented, with the language to highlight and whether to auto-format on load carried directly on this element instead of a general-purpose flag.reference— new: a pointer into a top-level table of hoisted element definitions, emitted wherever the element tree would otherwise recurse into itself (e.g. a self-referential structure). Needs a resolution step with no equivalent in the current renderer: look the pointed-to key up in that table and render it in place.unknown— new: the document's own "the server couldn't classify this" placeholder, carrying the raw schema type it failed to map. Replaces today's generic "Unsupported element type" fallback with one that reports what specifically wasn't understood.Cross-cutting behaviors
Every element — regardless of kind — carries the same set of cross-cutting fields, so these are handled once, generically, rather than per element type:
normal/small/large/full.smalluses a compact input in place of the normal one for string/number fields;large/fullwiden the field's container instead of swapping components.visible/hidden/read-only), anadvancedflag (hidden behind a section-level "Show advanced" toggle), and two independent reactive conditions, not quite the same shape as each other: the disable condition is just a path to compare, a value to compare against, and a flag to invert the comparison; the visibility-switch condition carries those same three plus which visibility state to switch to while it holds (so it isn't limited to toggling hidden/visible — it can switch to read-only too). Resolve the path against the in-memory configuration and re-evaluate on every render.smallsizing leaves no room for a subtitle.enum(whose options are part of the definition itself, so filtering them is a direct list operation).select's option list lives in the configuration value, not the definition — whether aselect's own denied values are meant to filter that server-provided list, or don't apply toselectin practice, isn't settled by anything read so far; worth confirming before assuming theenum-style filtering design carries over unchanged.Also newly available on every element, worth designing the renderer to lean on rather than cross-referencing the JSON Schema separately: a pre-resolved label, an optional longer description, a default value, and whether the element is required/nullable. A client built against this document shouldn't need to consult the JSON Schema for anything except the server-side-authoritative validation pass.
Config-level action buttons
A configuration's form can already declare its own action buttons (e.g. "Test Connection") attached to a section or to an individual field. Rendering: each action carries its own id, a title/description/theme/position/size, an optional icon, an optional attachment point (a specific field, to render next to it, or a named section, to float within it), a condition controlling whether it's shown and a separate one controlling whether it's disabled (each the plain path/value/invert shape — an action's shown-or-not condition, unlike an element's, has no target state to switch between beyond the implicit shown/hidden), and a flag to disable the button while the configuration is unmodified. Position resolves to one of three placements (auto/start/end); theme and size reuse the same mappings the cross-cutting behaviors above already establish.
On click: submit the current in-memory configuration to the server along with the button's own id and the field/section path it's attached to (empty for a section-level floating button). The response can carry a partial or full configuration to merge into local state, messages to toast, a location to navigate to, and validation errors to surface inline — respecting a flag for whether to keep any validation errors already showing. Out of scope for this design: a configuration can separately expose lifecycle hooks (fired automatically around save/load/validate rather than user-clickable) — whether the form should proactively call into those for configurations that declare them is a related but separate, still-open question.
Action-parameter modal
Distinct from the config-level action buttons above: invoking a plugin-registered action (of any scope) can now request its parameter form the same way a configuration requests its own — the same document shape describes both, so no special-casing is needed to render one versus the other. An action's parameter set doesn't vary by what it's invoked against, so one lookup covers a globally-scoped action and a series/episode/file/group-scoped one alike. A listing of available actions should be checked for whether an action takes parameters at all before offering to open this modal, to avoid an unnecessary round-trip for the (currently more common) parameterless case.
Invocation accepts an optional parameter body — omitting it still invokes exactly as a parameterless action always has, so this is additive to existing invocation, not breaking. A body that fails validation should surface as inline field errors on the modal, the same treatment config-level action buttons above already need for their own validation-error responses.
Open questions
selectelement type design above.record's design is provisional for the enum-keyed case specifically, pending a real live schema example to validate it against.SectionName+ flatStructureshown above with an explicit groupedSectionslist, so a client doesn't need to filter every element to render one tab.Descriptiontext as currently served can carry leftover documentation-comment whitespace and mid-sentence line wraps — worth trimming defensively on the client regardless of whether the server side also fixes it at the source.select(or similar server-populated) element has no way to declare what value it depends on or that it needs a refresh trigger before it can be usefully populated.All reactions