Render-ready UI definitions for configurations and actions, plus a Roslyn analyzer for unrenderable shapes - #1414
Render-ready UI definitions for configurations and actions, plus a Roslyn analyzer for unrenderable shapes#1414revam wants to merge 1 commit into
Conversation
… definition
A client that wanted to render a configuration form had to read two
documents and reconcile them: the JSON Schema for structure, and the
`x-uiDefinition` extension bag for presentation. JSON Schema is a validation
vocabulary, so presentation had to be inferred from it — `elementType: "auto"`
was literally an instruction to go and read `schema.type` — and references
were left unresolved, so the client dereferenced `$ref` itself while
carefully preserving the UI metadata a standards-compliant dereferencer would
discard. Executable actions had it worse: `ActionService` could deserialise a
parameter payload straight onto an action instance, but nothing described the
parameters and the API exposed no way to send them, so an action with
parameters could only be invoked in-process, on its defaults.
**Added `Shoko.Abstractions.UI.UiDefinition`**, a description of a form shaped
for rendering: an abstract `UiElement` with a concrete `Kind` discriminator
and 15 sealed element types, `$ref`s resolved, labels pre-resolved, and
constraints, nullability and requiredness on the element that needs them.
`auto` never reaches the wire.
- `GET /api/v3/Configuration/{configID}/UiDefinition` returns a
configuration's form, cached on `ConfigurationInfo`
- `GET /api/v3/Action/{actionID}/UiDefinition` returns an action's parameter
form, for actions of any scope
- `POST /api/v3/Action/{actionID}` and the four scoped variants accept an
optional parameter body, validated against the action's schema before
anything runs, with unknown properties rejected
- `ActionInfo.HasParameters` tells a listing whether an action has a form
worth opening
- A container's `Items` and `Actions` are maps keyed by what a `Structure`
entry carries, and enumerate in authored order
**Cut `x-uiDefinition` down to what the validator reads.** The bag existed to
carry presentation, which `UiDefinition` now does. What remains is the five
entries `ShokoJsonSchemaValidator` reads back during validation, and a
property carrying none of them gets no bag at all. `ServerSettings`' schema
dropped from 5567 to 2703 lines. This is breaking for any client rendering
off the bag.
**Added `Shoko.BuildTools.Analyzers`**, a Roslyn package that turns the shapes
the generator cannot describe into compiler errors (SHOKO0001–0005) at the
authoring site rather than exceptions at startup. It walks out from
`IConfiguration` and `IExecutableAction` roots only, so an identical shape
elsewhere is untouched. The runtime checks were kept, so a plugin can still
be built without referencing the package.
**Moved the shared authoring surface to `Shoko.Abstractions.UI`** —
presentational attributes, the `Display*` enums and `SelectComponent<>`.
`Config.Enums` was already being imported by the UI namespace, so the
dependency pointed the wrong way. Configuration-lifecycle attributes stayed
in `Config`.
Fixed along the way: inherited configuration properties emitted no UI
metadata at all, `visibility.toggle.inverseCondition` was never emitted
though it is read as non-optional, and `DisplayButtonPosition` aliased
`Top`/`Left` and `Bottom`/`Right` onto shared values with `[EnumMember]` only
on the canonical members, so `Position = Top` serialised as `"Left"`. The
aliases were removed; `Auto`/`Start`/`End` remain.
|
Can't give feedback when the DTO shape is not displayed anywhere. For other reviewers' reference, here's the current shape ( {
"ID": "19c7a5e2-…",
"Name": "Core Settings",
"Root": {
"Kind": "section-container", // concrete discriminator — no more "auto"
"SectionType": "tab",
"DefaultSectionName": "Misc.",
"AppendFloatingSectionsAtEnd": true,
"ShowSaveAction": true,
"Items": { // map keyed by document property name
"Username": {
"Kind": "string",
"Key": "Username",
"Label": "Username",
"Description": "AniDB username.",
"Size": "normal",
"Visibility": { "Default": "visible", "Advanced": false, "Toggle": null, "Disable": null },
"Badge": null,
"RequiresRestart": false,
"EnvironmentVariable": { "Name": "ANIDB_USER", "AllowOverride": false },
"SectionName": "Login", // ← grouping lives here today
"Default": null,
"IsRequired": false,
"IsNullable": true,
"DeniedValues": null
},
"Password": { "Kind": "password", "SectionName": "Login", "…": "…" },
"MaxRelationDepth": {
"Kind": "integer",
"Minimum": 0, "Maximum": 5, // constraints live ON the element now
"Size": "small",
"SectionName": "Download",
"…": "…"
},
"MyList_StorageState": {
"Kind": "enum",
"Values": [ // options pre-resolved — no schema round-trip
{ "Title": "Unknown", "Value": "Unknown" },
{ "Title": "HDD", "Value": "HDD" }, …
],
"SectionName": "MyList", "…": "…"
}
// nested objects inline as nested section-containers — no $refs to resolve
},
"Actions": {
"Test": {
"ID": "Test",
"Title": "Test",
"Theme": "primary",
"Position": "start",
"Size": "normal",
"SectionName": "Login",
"MemberName": "Test",
"DisableIfNoChanges": false
}
},
"Structure": [ // authored order, interleaving fields & buttons
{ "Name": "Username", "Kind": "item" },
{ "Name": "Password", "Kind": "item" },
{ "Name": "Test", "Kind": "action" },
{ "Name": "DownloadCharacters", "Kind": "item" },
…
]
},
"Definitions": {} // only populated to break reference cycles
}Each |
|
Proposal: group The problemRendering a tabbed configuration today requires the client to derive grouping on every render: const fields = Object.values(root.items).filter(e => e.sectionName === activeTab);Membership in a section is stated twice — once as Current shape{
"kind": "sectionContainer",
"sectionType": "tab",
"items": {
"Username": { "kind": "string", "sectionName": "Login", "…": "…" },
"Password": { "kind": "password", "sectionName": "Login", "…": "…" },
"DownloadCharacters": { "kind": "boolean", "sectionName": "Download", "…": "…" }
},
"actions": {
"Test": { "title": "Test", "sectionName": "Login", "position": "start" }
},
"structure": [
{ "name": "Username", "kind": "item" },
{ "name": "Password", "kind": "item" },
{ "name": "Test", "kind": "action" },
{ "name": "DownloadCharacters", "kind": "item" }
]
}To render one tab: scan every element, filter on Proposed shapeKeep {
"kind": "sectionContainer",
"sectionType": "tab",
"items": {
"Username": { "kind": "string", "…": "…" }, // sectionName gone from elements
"Password": { "kind": "password", "…": "…" },
"DownloadCharacters": { "kind": "boolean", "…": "…" }
},
"actions": {
"Test": { "title": "Test", "position": "start" }
},
"sections": [
{
"name": "Login",
"members": [
{ "name": "Username", "kind": "item" },
{ "name": "Password", "kind": "item" },
{ "name": "Test", "kind": "action" } // interleaving preserved
]
},
{
"name": "Download",
"members": [ { "name": "DownloadCharacters", "kind": "item" } ]
}
]
}Rendering a tab becomes one lookup plus enumeration: const members = root.sections.find(s => s.name === activeTab).members;
const nodes = members.map(m => m.kind === "item" ? root.items[m.name] : root.actions[m.name]);Why this works for non-tab containers tooThe shape is identical regardless of
Section ordering would be first-appearance order of authored members, with floating/default sections placed first or last per What this buys
Payload is roughly a wash: the section name moves off the elements into the section entries rather than being duplicated. |
|
Follow-up: renderer-perspective findings on actions, descriptions, and paths Tested the definition against a live 1. Actions: add the invocation
|
|
Examples are from a hypothetical The definition can't say an element is reactive
That leaves a client two options. Post the entire document on every event on every field, or public ReactiveEventType Reactive { get; set; } // 0 = not reactiveNothing can populate it, because the authoring surface has no way to say which members a [ConfigurationAction(ConfigurationActionType.LiveEdit, ReactiveEventType = ReactiveEventType.Unfocused)]
public ConfigurationActionResult OnEdit(ConfigurationActionContext<FfmpegTranscodeConfiguration> ctx) { … }That attaches to the type. The members it reacts to are known only to the body of the method. [ReactiveAction(nameof(FfmpegPath), Events = ReactiveEventType.Unfocused | ReactiveEventType.Edited)]makes it a fact the generator can read. Two questions the model doesn't currently answer, worth answering deliberately: whether more A third: whether a reactive request is best-effort or strict. A newly added list row exists
|
|
Proposal: a merge-friendly response model for Following up on @da3dsoul's reactivity points — that comment covers when a client should call LiveEdit; this is about what comes back, because the current response shape makes the client's merge step heuristic instead of mechanical. The problem
the client receives
Each of these currently resolves by per-endpoint folklore in every client. Proposed shape// POST /Configuration/{id}/LiveEdit?reactiveEventType=edited&path=Country&return=full
{
// ── merge envelope ──
"revision": 42, // monotonic per config per server session
"returnMode": "full", // echoes the ?return param — never inferred
"configuration": { … }, // returnMode=full: the ENTIRE document
// when ?return=patch instead:
// "patch": [
// { "op": "replace", "path": "/State/options", "value": [ … ] },
// { "op": "remove", "path": "/State/options/0/selected" }
// ],
// ── existing side-channels, unchanged ──
"validationErrors": null,
"messages": [],
"redirect": null,
"refresh": false,
"showSaveMessage": false
}Rationale per piece
What the client reduces toonSuccess: (res) => {
if (res.revision <= lastApplied.current) return; // drop stale
lastApplied.current = res.revision;
setDraft(res.returnMode === 'full'
? res.configuration // replace
: applyPatch(draftRef.current, res.patch)); // merge
if (res.validationErrors) setErrors(mapToElements(res.validationErrors));
res.messages.forEach(toast);
}One branch on Scope noteThis only reshapes the LiveEdit (and optionally action-result) response envelope — the request flow, reactive handler model, and |
I like this shape more than the object that we would have to loop over and build this shape internally anyways |



Opened as a draft to gather feedback before it merges rather than after. The
open question is at the bottom; it does not block, and I would rather change the
shape now than after something depends on it.
Breaking for the WebUI, and going out on
dev.x-uiDefinitionis cut downto the five entries the server's validator reads back, so the configuration form
and the renamer script editor need to move to
UiDefinition. The two sides do nothave to land together — the new shape is servable from this branch, so the client
work can be built against it while this is still open. Details under
What this changes.
The problem
A client that wants to render a configuration form today has to read two documents
and reconcile them: the JSON Schema for structure, and the
x-uiDefinitionextension bag for presentation. JSON Schema is a validation vocabulary, so
presentation has to be inferred from it —
elementType: "auto"is literally aninstruction to go and read
schema.type. References are unresolved, so the clientdereferences
$refitself while carefully preserving the UI metadata astandards-compliant dereferencer would discard.
Executable actions have it worse.
ActionServicecan deserialise a parameterpayload straight onto an action instance, and two shipped actions declare
parameters — but nothing describes them and the API exposes no way to send them,
so an action with parameters can only be invoked in-process, on its defaults.
What this adds
Shoko.Abstractions.UI.UiDefinition— a description of a form, shaped forrendering. An abstract
UiElementwith a concreteKinddiscriminator and 15sealed element types;
$refs resolved; labels pre-resolved; constraints,nullability and requiredness on the element that needs them.
autonever appearson the wire.
A container's
ItemsandActionsare maps rather than arrays, keyed by thedocument property name and the action ID respectively — which is exactly what a
Structureentry carries, and itsKindnames which map to look in (itemoraction), so resolving an entry is an index rather than a scan. Both mapsenumerate in authored order, so a client that renders the values in order gets the
layout without reading
Structureat all;Structureis what it reads tointerleave the two and place a button between two fields.
GET /api/v3/Configuration/{configID}/UiDefinitionGET /api/v3/Action/{actionID}/UiDefinitionPOST /api/v3/Action/{actionID}(and the four scoped variants)Action parameters are generated from the action's own settable properties — the
same ones the populator writes to — with the metadata surface (
Name,Category,Permission, …) excluded, and that exclusion is enforced by the contract resolverrather than by validation alone, so a payload cannot reach those members even if
validation is bypassed. Both configurations and actions go through the same
builders and emit the identical shape, so a renderer needs no special case.
An incoming parameter body is validated against the action's parameter schema
before anything is populated, and the objects in that schema are closed — the
populator ignores members it cannot map, so without this a mistyped parameter name
would silently do nothing and the action would run on its defaults. Nothing is
marked required: the instance is already constructed with its C# defaults, so an
omitted parameter means "leave it alone", and sending one parameter does not
oblige you to send all of them.
IConfigurationService.GenerateUiDefinition(Type)— mirrors the existingGenerateSchema(Type), with noIConfigurationconstraint, so a plugin candescribe any type it owns.
ConfigurationInfo.UiDefinitionis a lazy cache over it.Shoko.BuildTools.Analyzers— a Roslyn analyzer, packed underanalyzers/dotnet/cs/, turning configuration and action shapes the generatorcannot render into compiler errors. Five rules,
SHOKO0001–SHOKO0005, coveringevery
throwinShokoJsonSchemaGeneratorplus one silent metadata loss thatnever threw. It walks the property graph from
IConfigurationandIExecutableActionroots, so an identical shape elsewhere in the codebase isuntouched. The runtime checks remain — a plugin can be built without referencing
the package.
What this changes
The schema stays the validation contract, but
x-uiDefinitionshrinks to whatthe validator reads. The bag existed to carry presentation;
UiDefinitiondoesthat now. What remains is the five entries
ShokoJsonSchemaValidatorreads backduring validation —
elementType,requiresRestart,envVar,envVarOverridableandenumDefinitions(itself down tovalueandaliasValues) — and a property carrying none of them gets no bag at all. Theclass-level bag is gone entirely; nothing ever read it. Env-var splicing, env-lock
enforcement, enum alias resolution and restart-pending detection are unchanged.
ServerSettings' schema drops from 5567 to 2703 lines, and a golden-file testpins the result for every shipped configuration.
The generator's internals are typed.
Dictionary<string, object?>accumulators become typed builders, and a joiner walks the finished schema
alongside them — schema facts from the schema node, presentation from the builder.
Magic-string constants drop from 51 to 6, the six left being the bag's key and the
five inside it.
DisplayButtonPositionloses its aliases.Top/Leftshared a value withStart, andBottom/RightwithEnd, while[EnumMember]sat only on thecanonical members — so which name the runtime resolved for a duplicate was
unspecified, and every alias lost its attribute.
Position = Topwas serialisingas
"Left". NowAuto/Start/Endonly. Source-breaking on a pre-releasepackage, though nothing reads the wire value: the WebUI on
mastermodelsx-uiDefinitionwithout anactionsmember at all.The shared authoring surface moves to
Shoko.Abstractions.UI— presentationalattributes, the
Display*enums,SelectComponent<>.Config.Enumswas alreadybeing imported by the UI namespace, so the dependency pointed the wrong way.
Configuration-lifecycle attributes (
StorageLocation,ConfigurationAction,HideDefaultSaveAction,RequiresRestart,EnvironmentVariable) stay inConfig. Source-breaking.Defects this closes
metadata is filed under the reflected type and class metadata under the
generated type, so a configuration inheriting from a base loses labels,
visibility, sections — everything — for every inherited member. No shipped
configuration inherits, so it is dormant here and immediate for a plugin author.
List<List<T>>andDictionary<K,Dictionary<K,V>>collide on a single propertykey and the outer level is silently dropped;
List<Dictionary<K,V>>throws witha message naming the wrong type. All are now rejected with an actionable error,
at compile time and at runtime. A dictionary whose values are collections is
fine and stays supported.
property key, so the value element came back as an enum carrying the key's
members, and the key itself rendered as free text.
Measurements
ServerSettings, minified. Before this PR the schema carried the presentation bagand came to 112,762 B. After it the schema is 57,962 B and the definition is
116,885 B with nulls omitted, or 156,047 B as MVC emits it with nulls included.
So the definition is 2.0× the schema it now sits beside — but that is the wrong
comparison for a renderer, which used to pull the 112,762 B schema-plus-bag and now
pulls a 116,885 B definition instead. Transfer is roughly a wash; the saving is
in the client not reconciling two documents. 241 elements, no
auto, nounknown,no hoisted definitions.
Against the current renderer (
src/components/Dynamic/, 275 lines plus a 75-line$refhelper): the helper becomes unnecessary in full, andDynamicField'sautobranch — roughly 45 of its 134 lines, hand-dispatching on
propertySchema.typeandreading
minimum/maximumoff the schema node — collapses to a switch onKind.What I would like feedback on
The DTO shape itself. Nothing consumes it yet, so this is the free moment to
change it. If any element type is awkward to render, say so now.