Skip to content

Render-ready UI definitions for configurations and actions, plus a Roslyn analyzer for unrenderable shapes - #1414

Draft
revam wants to merge 1 commit into
masterfrom
feat/gen-ui-dto
Draft

Render-ready UI definitions for configurations and actions, plus a Roslyn analyzer for unrenderable shapes#1414
revam wants to merge 1 commit into
masterfrom
feat/gen-ui-dto

Conversation

@revam

@revam revam commented Aug 23, 2026

Copy link
Copy Markdown
Member

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-uiDefinition is cut down
to 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 not
have 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-uiDefinition
extension bag for presentation. JSON Schema is a validation vocabulary, so
presentation has to be inferred from it — elementType: "auto" is literally an
instruction to go and read schema.type. References are unresolved, so the client
dereferences $ref itself while carefully preserving the UI metadata a
standards-compliant dereferencer would discard.

Executable actions have it worse. ActionService can deserialise a parameter
payload 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 for
rendering. An abstract UiElement with a concrete Kind discriminator and 15
sealed element types; $refs resolved; labels pre-resolved; constraints,
nullability and requiredness on the element that needs them. auto never appears
on the wire.

A container's Items and Actions are maps rather than arrays, keyed by the
document property name and the action ID respectively — which is exactly what a
Structure entry carries, and its Kind names which map to look in (item or
action), so resolving an entry is an index rather than a scan. Both maps
enumerate in authored order, so a client that renders the values in order gets the
layout without reading Structure at all; Structure is what it reads to
interleave the two and place a button between two fields.

GET /api/v3/Configuration/{configID}/UiDefinition a configuration's form
GET /api/v3/Action/{actionID}/UiDefinition an action's parameter form, any scope
POST /api/v3/Action/{actionID} (and the four scoped variants) now accepts an optional parameter body

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 resolver
rather 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 existing
GenerateSchema(Type), with no IConfiguration constraint, so a plugin can
describe any type it owns. ConfigurationInfo.UiDefinition is a lazy cache over it.

Shoko.BuildTools.Analyzers — a Roslyn analyzer, packed under
analyzers/dotnet/cs/, turning configuration and action shapes the generator
cannot render into compiler errors. Five rules, SHOKO0001SHOKO0005, covering
every throw in ShokoJsonSchemaGenerator plus one silent metadata loss that
never threw. It walks the property graph from IConfiguration and
IExecutableAction roots, so an identical shape elsewhere in the codebase is
untouched. The runtime checks remain — a plugin can be built without referencing
the package.

What this changes

The schema stays the validation contract, but x-uiDefinition shrinks to what
the validator reads.
The bag existed to carry presentation; UiDefinition does
that now. What remains is the five entries ShokoJsonSchemaValidator reads back
during validation — elementType, requiresRestart, envVar,
envVarOverridable and enumDefinitions (itself down to value and
aliasValues) — and a property carrying none of them gets no bag at all. The
class-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 test
pins 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.

DisplayButtonPosition loses its aliases. Top/Left shared a value with
Start, and Bottom/Right with End, while [EnumMember] sat only on the
canonical members — so which name the runtime resolved for a duplicate was
unspecified, and every alias lost its attribute. Position = Top was serialising
as "Left". Now Auto/Start/End only. Source-breaking on a pre-release
package, though nothing reads the wire value: the WebUI on master models
x-uiDefinition without an actions member at all.

The shared authoring surface moves to Shoko.Abstractions.UI — presentational
attributes, the Display* enums, SelectComponent<>. Config.Enums was already
being imported by the UI namespace, so the dependency pointed the wrong way.
Configuration-lifecycle attributes (StorageLocation, ConfigurationAction,
HideDefaultSaveAction, RequiresRestart, EnvironmentVariable) stay in
Config. Source-breaking.

Defects this closes

  • Inherited configuration properties emit no UI metadata at all. Property
    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.
  • A collection inside a collection fails three different ways.
    List<List<T>> and Dictionary<K,Dictionary<K,V>> collide on a single property
    key and the outer level is silently dropped; List<Dictionary<K,V>> throws with
    a 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.
  • Enum-keyed dictionaries describe the wrong thing. Key and value share a
    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 bag
and 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, no unknown,
no hoisted definitions.

Against the current renderer (src/components/Dynamic/, 275 lines plus a 75-line
$ref helper): the helper becomes unnecessary in full, and DynamicField's auto
branch — roughly 45 of its 134 lines, hand-dispatching on propertySchema.type and
reading minimum/maximum off the schema node — collapses to a switch on Kind.

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.

… 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.
@sonarqubecloud

Copy link
Copy Markdown

@harshithmohan

harshithmohan commented Aug 23, 2026

Copy link
Copy Markdown
Member

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.

Can't give feedback when the DTO shape is not displayed anywhere. For other reviewers' reference, here's the current shape (553db752) — the AniDB login area of CoreSettings, from a live payload of GET /api/v3/Configuration/{configID}/UiDefinition (trimmed):

{
  "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 Kind value maps 1:1 to an element-type renderer, and $refs are already resolved or hoisted into Definitions, so consumers don't need schema round-trips.

@harshithmohan

Copy link
Copy Markdown
Member

Proposal: group Structure by section instead of a flat list

The problem

Rendering 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 sectionName on every element and action, and once implicitly through authored order in structure — so the client reconciles the two by filtering all elements each time, for every container depth.

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 sectionName, then use structure only to re-insert buttons in order.

Proposed shape

Keep items/actions as flat maps keyed by property name / action ID (value binding stays O(1)), but make grouping explicit:

{
  "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 too

The shape is identical regardless of sectionType — that stays a pure rendering concern:

  • field-set / minimal / checkbox: enumerate sections, draw each group per type. A container with no [SectionName] anywhere just gets one synthesized section named after defaultSectionName, so something like AVDumpSettings renders as a single unboxed group with zero special-casing.
  • Nested containers: a nested sectionContainer element carries its own sections, so the same render rule composes recursively.

Section ordering would be first-appearance order of authored members, with floating/default sections placed first or last per appendFloatingSectionsAtEnd — the same rule the current flat structure implies, just made explicit per group.

What this buys

  1. Single source of truth for membership — grouping moves from N per-element attributes to one authoritative list; no way for sectionName and structure order to disagree.
  2. No per-render filtering, at any container depth.
  3. Smaller elementsSectionName disappears from both elements and actions.
  4. Interleaving preserved — buttons still sit between fields inside members, which is why structure exists at all.

Payload is roughly a wash: the section name moves off the elements into the section entries rather than being duplicated.

@harshithmohan

harshithmohan commented Aug 23, 2026

Copy link
Copy Markdown
Member

Follow-up: renderer-perspective findings on actions, descriptions, and paths

Tested the definition against a live ServerSettings payload and walked the full render/bind/invoke flow. Two concrete requests and two open questions.

1. Actions: add the invocation path to the action, and rename IDName

Invoking a config action today requires three inputs:

POST /api/v3/Configuration/{configID}/PerformAction?actionName=Test&path=AniDb
  • configID — available from the definition's own ID
  • actionName — available as the action's ID ("Test") ✅
  • pathnot in the definition

The client has to reconstruct path ("dotted keys of ancestor containers from the config root") from its own navigation position. That works only if the renderer diligently tracks the container chain it descended through, and nothing documents that this is what path means. A generic renderer that renders a nested action without tracking ancestry sends path="" and gets a silent failure.

Since the builder knows the ancestor chain at generation time, putting the resolved path directly on the action makes the definition self-sufficient for invocation — consistent with the definition's goal that a client renders (and now invokes) from it alone.

Related nit: config actions use string method names as ID, while standalone executable actions use GUIDs for their IDs. Same field name, two disjoint identifier domains — a generic client keeping one action type can't tell which contract an ID belongs to. Since the config-action value is really a name (it's sent as actionName), renaming IDName would remove the overlap.

2. Descriptions leak XML-doc whitespace and source-wrapping newlines

From the live payload:

"Description": "The image source.\n            "
"Description": "Path where the images are stored. If set to `null` then it will use\nthe default location."

Two distinct problems from the same root cause:

  1. Trailing whitespace — untrimmed <summary> blocks leave a newline + indentation on the end.
  2. Mid-sentence newlines — authors wrap <summary> text across lines for source readability, and those hard wraps are emitted verbatim: "…it will use\nthe default location." breaks mid-sentence at whatever column the author happened to wrap at. These newlines carry no meaning and render as broken text in tooltips.

Both need handling at emit time: trim the ends, and collapse/unwrap the interior single newlines (they're wrapping artifacts, not intentional formatting — a deliberate paragraph break can't currently be distinguished from one anyway). Every consumer otherwise re-implements the same cleanup forever; golden files would need regenerating once.

Open question: condition path semantics

UiCondition.Path is specified as "relative to the nearest enclosing object," which leaves three cases undefined:

  1. Inside list items — when an element within a list item carries a condition, does Path resolve per-item-instance (each item evaluates against itself), or against the enclosing configuration object? Per-item seems like the only useful reading, but it should be stated.
  2. Edited vs saved state — conditions presumably evaluate against the client's current unsaved edit state (toggling FileLockChecking instantly disables its dependents), not the last-saved document. Worth confirming.
  3. Upward traversal — paths can only descend into the current container. If cross-container conditions (a nested field conditioning on a root-level value) are meant to be unsupported, fine — but then it's worth stating explicitly so plugin authors don't design around it.

All three produce silently-wrong UI rather than errors if a client guesses differently than the server intends, and all three are exercised by shipped configs today (FileLockWaitTimeMS's disable condition, etc.).

Open question: flag enum serialization

UiEnumElement.IsFlag says multiple values may be combined, but nothing specifies how a combination serializes back into the document. With the current enum converters it's comma-separated names ("Source, Destination"), which is a write-path contract a renderer must get exactly right — no fallback, silent mis-parse on a wrong guess. Nothing shipped uses flags yet, so this is purely about specifying the convention before someone depends on guessing it.

@da3dsoul

da3dsoul commented Aug 23, 2026

Copy link
Copy Markdown
Member

Examples are from a hypothetical FfmpegTranscodeConfiguration, the transform config the
plugin-stream-pipeline README names but never writes.

The definition can't say an element is reactive

LiveEdit exists and works. What the definition says about it is
ConfigurationInfo.HasLiveEdit, one bool for the whole configuration.

That leaves a client two options. Post the entire document on every event on every field, or
never call LiveEdit. ServerSettings is ~112 KB, so it's the second, and the mechanism
goes unused.

public ReactiveEventType Reactive { get; set; }   // 0 = not reactive

Nothing can populate it, because the authoring surface has no way to say which members a
handler watches:

[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.
Something like:

[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
than one handler can respond to the same event, and whether a handler on a parent type
observes edits to a nested one.

A third: whether a reactive request is best-effort or strict. A newly added list row exists
client-side before the posted document has it, so a path that doesn't resolve is normal
rather than an error. An explicitly invoked action is the opposite case.

ReactiveEventType conflates two roles

A request says what happened. That's always exactly one thing.

A handler says what it cares about. That's naturally a set — Unfocused or Edited.

Both use the same single-valued enum, so All has to mean "unspecified" in one direction and
"anything" in the other. A handler declaring Unfocused is unreachable from a request that
didn't name an event, since All is the default on both sides and only matches handlers that
also declared All.

Separating them — handler declares a set, request declares one event — is also what makes a
per-element Reactive expressible at all.

A select can't say where its options come from

{ "kind": "select", "key": "VideoEncoder", "label": "Video Encoder",
  "selectType": "auto", "multipleItems": false }

Nothing says this is server-populated, that it derives from FfmpegPath, or that leaving
that field should trigger anything. On the wire it's a static enum.

The reason it can't is that options live in the value. SelectComponent<T> serialises
Options, and SelectedValue/SelectedValues are [JsonIgnore], derived from IsSelected.
Two consequences:

  • The user's choice has no independent representation. Storing "picked hevc_nvenc" means
    storing every encoder the probe found.
  • Probe output becomes configuration. settings-server.json holds a snapshot of whatever
    ffmpeg reported at the last probe, reloaded next boot on a machine where the binary may
    have changed.

From the definition's side a select is a value shaped like a list, which is why
UiSelectElement carries nothing but SelectType and MultipleItems.

The other split is the value holding the selection and the definition holding the option
source — inline when static, reactive when not. Either way the element needs to name what it
depends on:

public IReadOnlyList<string>? DependsOn { get; init; }

so a client can disable and say "enter the ffmpeg path first" rather than showing an empty
dropdown.

Conditions are one path, one value, one equality

[Visibility(DisableWhenMemberIsSet = nameof(FfmpegPath), DisableWhenSetTo = null)]
public SelectComponent<string> VideoEncoder { get; set; } = new();

Correct until someone focuses the path field and clears it. The value is then "" and the
encoder dropdown re-enables against a blank path. There's no emptiness test.

[Visibility(DisplayVisibility.Hidden,
    ToggleWhenMemberIsSet = nameof(HardwareAcceleration),
    ToggleWhenSetTo = HwAccelKind.Vaapi,
    ToggleVisibilityTo = DisplayVisibility.Visible)]
public SelectComponent<string> RenderDevice { get; set; } = new();

This also needs to show for Qsv. With one equality per element the workaround is a separate
property per acceleration mode.

public UiConditionOperator Operator { get; init; }   // equals, notEquals, contains, isEmpty, greaterThan, …

with Toggle/Disable taking a list plus all/any. Flat operators rather than JSON
Forms' schema-fragment approach, since ToggleWhenMemberIsSet/ToggleWhenSetTo can't emit a
fragment without getting unpleasant to author.

No element for a server-enumerated keyed set

[Visibility(DisplayVisibility.ReadOnly)] public Dictionary<Guid, bool> TransformEnabled { get; set; } = [];
[Visibility(DisplayVisibility.ReadOnly)] public List<Guid> TransformPriority { get; set; } = [];
[Visibility(DisplayVisibility.ReadOnly)] public Dictionary<Guid, bool> ObserverEnabled { get; set; } = [];

ReadOnly not because they're immutable — VideoStreamController mutates them through its
own endpoints — but because a record of raw GUIDs with checkboxes is unusable. What they
are is a reorderable enable-list over a set only the server can enumerate. Same gap as the
select, in core rather than a plugin.

@harshithmohan

harshithmohan commented Aug 23, 2026

Copy link
Copy Markdown
Member

Proposal: a merge-friendly response model for LiveEdit

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

ConfigurationActionResult.Configuration is documented as "partially or fully sent back to the client depending on what the result is for." Concretely, after

POST /Configuration/{id}/LiveEdit?reactiveEventType=edited&path=Country

the client receives "configuration": { "State": { … } } and has to answer three questions with no data to answer them from:

  1. Is this the whole document or a subtree? Missing keys could mean "unchanged" or "this was a full doc without them" — and since the serializer omits default-valued members, absence is already overloaded.
  2. How do I express removals? When a handler clears a selection or empties a list, an object delta can't distinguish "removed" from "unchanged" from "set to null."
  3. Which response is newest? Debounced edits race; two in-flight LiveEdits can resolve out of order, and the older response's delta silently overwrites newer state.

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

  • Caller-chosen return mode, echoed as returnMode. Small plugin configs ask for full — merging becomes setDraft(response.configuration), no patch machinery needed. Large configs (ServerSettings) ask for patch. The server never picks; the client never guesses.
  • RFC-6902 ops for patch mode. remove finally means remove (vs. absent/null/false soup against an omit-defaults serializer). JSON pointers are exactly the dotted element-key paths already used for value binding (/AniDb/UDPPingFrequency), so no new path convention. And the API already speaks this dialect — PATCH /Configuration/{id} takes a JsonPatchDocument — so it's the same vocabulary server-side.
  • Monotonic revision. The client keeps lastApplied and drops anything ≤ it before touching state. Without this, no debounce strategy is safe under concurrent mutations.
  • (Deliberately no request-correlation echo.) In a synchronous request–response the client already knows which call a response belongs to; correlation fields would only matter if LiveEdit ever moved to a decoupled transport (e.g. SignalR), at which point they'd be added there.

What the client reduces to

onSuccess: (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 returnMode, one guard on revision, zero endpoint-specific knowledge.

Scope note

This only reshapes the LiveEdit (and optionally action-result) response envelope — the request flow, reactive handler model, and UiDefinition are untouched, so it composes cleanly with the per-element reactivity proposal above: that one decides when clients call, this one makes what returns trivially applicable.

@hidden4003

Copy link
Copy Markdown
Member

Each Kind value maps 1:1 to an element-type renderer, and $refs are already resolved or hoisted into Definitions, so consumers don't need schema round-trips.

I like this shape more than the object that we would have to loop over and build this shape internally anyways

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants