Skip to main content

Editor metadata and selections

Vox exposes an editor schema for every action. The schema is presentation metadata rather than a dependency on the built-in Admin UI, so another client can render the same labels, descriptions, nested fields, validation and choices.

Labels and editor hints​

Use the standard data-annotation attributes where they already describe presentation:

public class ProductEditorial : VoxFieldGroup
{
[Display(
Name = "Editorial description",
Description = "Shown on the product detail page.",
Prompt = "Write a short description",
Order = 10)]
[UIHint("markdown")]
[StringLength(2_000)]
public virtual string Description { get; set; } = "";
}

Display is supported on entity types, custom action classes, action fields, entity properties and nested object properties. Its name, description, prompt, order and group are returned by /api/entity-types. UIHint currently recognizes multiline, markdown, html, code, json and color; an unknown hint safely falls back to the normal type editor. Standard DataType values select email, URL, telephone and password inputs. VoxUnit, VoxAdvanced and VoxConfirmation cover units, advanced fields and action confirmation prompts.

The schema is recursive. Nested objects and lists of objects are rendered as structured forms rather than generic JSON text areas. JSON remains available explicitly through [UIHint("json")] and as a fallback for unsupported shapes.

Dynamic selections​

A selection factory separates the value stored in an action property from the label an editor sees:

public class CitySelection : IVoxSelectionFactory<string>
{
public Task<VoxSelectionPage<string>> SearchAsync(
VoxSelectionContext context,
VoxSelectionQuery query,
CancellationToken cancellationToken)
{
IReadOnlyList<VoxSelectionOption<string>> options =
[
new("STO", "Stockholm"),
new("GOT", "Gothenburg"),
];
return Task.FromResult(new VoxSelectionPage<string>(options));
}

public Task<IReadOnlyList<VoxSelectionOption<string>>> ResolveAsync(
VoxSelectionContext context,
IReadOnlyCollection<string> values,
CancellationToken cancellationToken)
{
// Resolve existing stored keys even when they aren't on the current search page.
throw new NotImplementedException();
}
}

public class Store : IVoxEntity
{
// Built-in properties omitted.

[Display(Name = "City", Prompt = "Choose a city")]
[VoxSelection<CitySelection>]
public required virtual string City { get; set; }
}

Factories are discovered from the annotation, registered as scoped dependencies and may use constructor injection. They support scalar and collection properties whose element is a string, number, GUID or enum. SearchAsync supports search text, cursor pagination and a requested page size. ResolveAsync gives the UI labels for values already stored on an entity without requiring those values to occur on the first search page.

VoxSelectionContext contains the entity type, action, field path, current entity ID, complete draft command and the selected contexts. A factory can therefore implement dependent choices such as states filtered by the selected country. Options are requested lazily from a field-specific API endpoint; factory type names are never exposed.

By default a selection controls the editor but does not make its current options a domain constraint. This matters for imports and external option sources that may be temporarily unavailable. Use AllowCustomValues = true for a combo box that also accepts typed values. Use ValidateValue = true when options are authoritative: Vox resolves submitted action values through the factory and rejects missing or disabled choices. Static authoritative sets can continue to use [AllowedValues] or enums.

Vox validates selection annotations at startup. A factory must be a concrete DI-constructible class, implement exactly one IVoxSelectionFactory<TValue>, and supply the scalar property type or the element type of a collection.

Environment-specific editor overrides​

Administrators with the vox:EditorSchema permission can override presentation metadata from the Editor schema screen. Overrides are stored in the Vox database, take effect without restarting the application, and are shared with other application instances. They can change labels, descriptions, prompts, groups, ordering, editor hints, formats, units, advanced-field status, confirmation text, and enum labels. Validation, permissions, field types, selection factories, and selection validation remain controlled by code.

Each value has separate Save, Clear, and Reset to code operations. Reset removes the database override; Clear intentionally suppresses an inherited text. The schema is sparse and versioned, with its change history retained. If an entity, action, or field disappears, its overrides are retained and shown as conflicts rather than being silently discarded.

The Sync to code section generates the C# annotations represented by the active overrides. Dynamic-schema code generation includes the same annotations on generated entity types and properties. At startup and after model changes, Vox compares every stored value with the code-defined value. Values that now match code are removed from the current database schema individually, while the reconciliation remains visible in history.

Editor-schema entries already carry an invariant culture slot. The current UI only edits invariant values; this leaves the storage and resolution model ready for culture-specific text without changing today's API semantics.