Usage
Integration recipe
1. Pin the repository
With npins:
npins add --name nix-module-form forgejo https://git.fediversity.eu/ fediversity nix-module-form --branch main
Or as a flake input:
inputs.nix-module-form.url = "git+https://git.fediversity.eu/fediversity/nix-module-form";
2. Build the bundle with your own entry
package.nix takes an entry argument: a single .ts file that imports this
library and adds your host-specific glue.
pkgs.callPackage "${sources.nix-module-form}/package.nix" {
entry = ./my-adapter.ts;
}
The result is a directory containing two files:
forms-island.js-- an ES module, the whole bundle (Vue, Vuetify, JSONForms and the renderers are all inlined; there is nothing else to serve).forms-island.css-- all the styles, including Vuetify's.
With entry = null (the default) you get the standalone library bundle built
from src/index.ts, which exports the API below but mounts nothing. That is
what nix build .#packages.x86_64-linux.default produces.
3. Serve the two files
<link rel="stylesheet" href="/static/forms-island.css" />
<script type="module" src="/static/forms-island.js"></script>
Both must be served over HTTP. A browser refuses to load
<script type="module"> from a file:// origin.
4. Mount the form from your entry
Your entry file is copied into src/ before the build, so it imports the
library by relative path:
import { mountDeploymentForm } from "./index";
const handle = mountDeploymentForm(document.getElementById("form")!, {
schema,
value,
baseline,
onChange: (data) => {
/* stash the wire value */
},
onDiffChange: (hasDiff) => {
deployButton.disabled = !hasDiff;
},
});
demo/demo-entry.ts in this repository is a complete, commented worked example
of exactly this -- see Demo.
API reference
Everything below is exported from src/index.ts, which is the entire public
surface.
mountDeploymentForm(el, opts)
Mounts the form into el and returns a handle. opts is a MountOptions:
| field | type | meaning |
|---|---|---|
schema |
JsonSchema |
Required. The JSON Schema to render. |
uischema |
UISchemaElement? |
Optional JSONForms UI schema. Omitted in practice -- the renderers key off the data schema, so field order follows the schema. |
value |
unknown |
Required. The configuration to load into the form. |
baseline |
unknown? |
The last deployed configuration, which each field is highlighted against. Omitted means the deployment was never deployed: the baseline is the empty config, so every set field reads as a change. |
schemaChanges |
SchemaChangeMap? |
Per-path record of which options were added, removed, renamed or retyped between the schema the config was last deployed under and the current one. Omit when the module source is unchanged (the common case). |
onChange |
(data: unknown) => void |
Called after every edit with the stripped wire value. Not guaranteed to fire at mount -- it only fires when the form's data actually changes, which for an already-complete value need not happen. |
onDiffChange |
(hasDiff: boolean) => void |
Called at mount and after every edit with whether the form now differs from the baseline. Use it to gate a Deploy button. |
The returned handle:
| member | type | meaning |
|---|---|---|
app |
App |
The underlying Vue application, for unmounting. |
validate(value?) |
=> { path, message }[] |
Runs full AJV validation against the schema. Empty means valid. A non-empty result also reveals the errors inline (the form starts in "validate on submit" mode). value defaults to the live form value. |
attemptSubmit(value?) |
=> string[] |
validate projected to the failing paths. The Deploy gate: a non-empty result means do not submit. |
recomputeDiffAgainst(value) |
=> void |
Re-runs the diff (and so onDiffChange) against an externally supplied value. Only needed when something outside JSONForms writes the canonical value. |
validateConfig(schema, data, validator?)
The pure validation core behind handle.validate, with no DOM and no mounted
form. Returns { path, message }[]. Exported so a host can validate a value it
is not currently rendering.
resolveTheme() and setTheme(theme)
resolveTheme(): "light" | "dark" reads the host page's <html data-theme>,
falling back to the OS prefers-color-scheme when the attribute is absent.
It is called once at mount.
setTheme("light" | "dark") switches the mounted form's theme. The library does
not watch for theme changes -- the embedder calls this from whatever event its
page emits.
migrateValue(value, renames)
Applies a structured rename table to a stored value before it reaches the form,
so an operator sees their data under the current field names. Returns
{ value, unmigrated, applied }, where applied lists the concrete
{ from, to } pairs whose data actually moved for this value.
A Rename is { from: Segment[], to: Segment[] }, where a Segment is a
literal key or null -- a wildcard for an attrsOf/listOf level, expanded
across whichever concrete keys the value has.
This is migrate-on-read; the stored value is untouched.
computeSchemaChanges(oldSchema, newSchema, renames)
Returns a SchemaChangeMap (Map<string, SchemaChange>) describing what
changed between the schema the config was last deployed under and the current
one. Each entry has a kind of added, removed, renamed or changed,
keyed by the current data path -- except removed, which has no current path
and is keyed by its old one.
restrictRenamesToApplied(changes, appliedToPaths)
Narrows a SchemaChangeMap to the renames that this particular value was
actually migrated across, so a field is not annotated "renamed from X" for a
rename that never touched it. Pass the to paths from migrateValue's
applied. Returns a new map.
The three compose into the schema-change workflow:
const { value: migrated, applied } = migrateValue(stored, renames);
const changes = restrictRenamesToApplied(
computeSchemaChanges(deployedSchema, currentSchema, renames),
new Set(applied.map((r) => r.to.join("."))),
);
mountDeploymentForm(el, {
schema: currentSchema,
value: migrated,
baseline,
schemaChanges: changes,
});
Schema shapes
This is the contract with the schema generator: which JSON Schema shapes get a dedicated renderer, and how they are spelled. Anything not listed falls through to the stock JSONForms Vuetify renderers.
nullOr
Recognized in four spellings, because the generator has emitted different ones over time and a legacy clan-core fork emitted a fourth. All four render as a "Configure / use the default" toggle with the inner control beneath it:
- Merged scalar
typearray --nullOr str:{ "type": ["null", "string"], "default": null } - Merged object
typearray --nullOr (submodule ...), with thepropertiesco-located on the node:{ "type": ["null", "object"], "properties": {}, "default": null } anyOftwo-branch --nullOr (enum [...]), a node with notype:{ "anyOf": [{ "type": "null" }, { "enum": ["a", "b"] }], "default": null }oneOftwo-branch -- the legacy clan fork's spelling:{ "oneOf": [{ "type": "null" }, { "type": "string" }] }
attrTag (tagged unions)
A oneOf of two or more object branches, each with exactly one required
property that is also its only property -- the property name being the tag. None
of the branches may be { "type": "null" }; that shape is a nullOr instead.
Two spellings are accepted. Each branch may state its own type: "object", or
the union node may hoist the shared type up -- which is what
fediversity/module-schema does, since additionalProperties cannot follow it
up. A branch with no type is accepted as long as the union node supplies
type: "object".
{
"type": "object",
"oneOf": [
{
"properties": { "ssh-host": { "$ref": "#/$defs/SshHost" } },
"required": ["ssh-host"],
"additionalProperties": false
},
{
"properties": { "local": { "$ref": "#/$defs/Local" } },
"required": ["local"],
"additionalProperties": false
}
]
}
Renders as a native <select> over the tags with only the active branch's
fields below it. Switching tags carries over the settings both branches declare
identically -- deep-equal schema nodes, so the carried value is guaranteed to
still validate -- and remembers the rest for as long as the form is mounted. A branch
property is usually a $ref into $defs, which is dereferenced before
dispatch (internal #/$defs/... refs only).
attrsOf
An object with additionalProperties and, usually, a propertyNames
constraint naming the Nix attrName key type:
{
"type": "object",
"additionalProperties": { "type": "string" },
"propertyNames": { "$ref": "#/$defs/attrName" }
}
Renders as a map with a new-key input. See the AJV gotcha below -- this shape is the reason the build carries a vendored patch.
The rest
listOf renders as the stock array control, submodule as a nested object,
enum as a <select>, and scalars (string, integer, number, boolean)
as their Vuetify controls -- booleans as a switch rather than a checkbox. An
option's description becomes the field's help text and its default becomes a
greyed-out placeholder.
Gotchas
The entry file is copied into src/. package.nix does
cp ${entry} src/${baseNameOf entry} before the build, so your entry must
import the library by relative path (./index, ./migrate), not by package
name. Your editor will flag those imports as unresolved, since the file only
lives in src/ inside the build sandbox. That is expected.
The library is host-free by design. It does not auto-mount, define window
globals, fetch anything, or render a Deploy button. Everything host-specific
lives in your entry. That is a real cost: the fediversity panel's adapter is
around 630 lines, nearly all of it API calls, auth tokens and hidden-input
plumbing. The demo entry is the same contract with all of that
stripped away, at roughly 50 lines of code.
Defaults are not filled in. JSONForms' useDefaults is deliberately off. A
Nix default is a fallback, not an operator choice -- baking it into the form
data would store today's default verbatim and pin the deployment to it if the
default later changes. So an absent key in the wire value means "follow the
current default". The default is still shown, as a greyed-out placeholder.
Validate the canonical value, not the form state. If anything other than
JSONForms can write the value your host submits -- a hidden input written by
tests, devtools, or a "load template" button -- pass that value explicitly:
handle.validate(value) / handle.attemptSubmit(value). Otherwise the gate
checks form state that may lag behind what is actually submitted.
recomputeDiffAgainst(value) exists for the same reason, on the diff side.
Read the wire value from onChange, not the DOM. When schemaChanges marks
an option as changed or removed, the form injects synthetic read-only
<key>__fviOld fields showing the last-deployed value. They are stripped from
everything that leaves the form, but they are in the rendered schema and data.
onChange's argument is already stripped; scraping the form is not.
Theme changes need setTheme(). resolveTheme() runs once, at mount.
Setting <html data-theme> afterwards restyles your page but not the mounted
form until you also call setTheme().
Re-mounting requires clearing the host element. mountDeploymentForm mounts
a fresh Vue app into the element you hand it. Mount twice without emptying it
first and you get two forms.
Styling hooks. The form's own classes are namespaced fvi-* and are
stable enough to style from the host page: .fvi-diff-leaf and
.fvi-diff-added / .fvi-diff-changed / .fvi-diff-removed for the per-field
value diff; .fvi-schema-added / .fvi-schema-changed / .fvi-schema-old-field
for the schema-change layer; .fvi-diff-legend-item and
.fvi-schema-legend-item for legend swatches carrying the same colors. Note
that forms-island.css sets .v-application to background: transparent on
purpose -- the host page owns the surface the form sits on, so give it one.
The bundle carries a vendored AJV patch. @jsonforms/vue-vuetify's
additional-properties renderer mounts a nested form for a map's new-key input
and gets it wrong twice: it passes ajv: undefined (so the nested form builds a
default AJV with unicodeRegExp: true, which rejects the Nix attrName
pattern as a Lone quantifier brackets syntax error), and it spreads
propertyNames verbatim without resolving a $ref into the root's $defs.
Either one makes an attrsOf field throw at mount. package.nix patches both
at build time. If you build outside package.nix, apply
patches/vue-vuetify-additional-properties-ajv.nix yourself or your maps will
not render. Both bugs are filed upstream.