feat(forms): augment served schema with cascade field_codes + locks
Two extension fields added to jsonschema.Schema so server-injected constraints survive the YAML→Schema→JSON round-trip: - Pattern: regex hint for the form renderer (server-side validation for field_codes already runs via WriteWithHistory). - ReadOnly: surfaces locked / audit fields as disabled in the UI. - Labels: x-labels extension carrying human-readable display strings paired with enum keys (e.g. ACM → "Acme Inc"), so dropdowns can show "ACM — Acme Inc" rather than bare codes. serveFormRender now calls augmentSchemaFromCascade after loading the spec: per-field, it injects enum (from field_codes:codes), pattern (from field_codes:pattern), readOnly (from records:locked), and default (from records:field_defaults). The augmentation is per-request and never touches the on-disk *.form.yaml — operators who declare their own enum/pattern in the spec take precedence (injection is "if absent"). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d35809cfd8
commit
d947f616d1
3 changed files with 99 additions and 0 deletions
|
|
@ -330,6 +330,13 @@ func serveFormRender(cfg config.Config, req *FormRequest, w http.ResponseWriter,
|
||||||
data = normalizeYAMLForJSON(data)
|
data = normalizeYAMLForJSON(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Augment the schema with cascade-resolved field_codes (enum /
|
||||||
|
// pattern / labels) and any records: rule that applies in this
|
||||||
|
// folder (readOnly for locked fields, default for field_defaults).
|
||||||
|
// The augmentation is per-request and never mutates the on-disk
|
||||||
|
// spec — it's purely additive context the form renderer needs.
|
||||||
|
augmentSchemaFromCascade(spec.Schema, chain, gateDir)
|
||||||
|
|
||||||
ctx := formContext{
|
ctx := formContext{
|
||||||
Title: spec.Title,
|
Title: spec.Title,
|
||||||
Schema: spec.Schema,
|
Schema: spec.Schema,
|
||||||
|
|
|
||||||
|
|
@ -576,6 +576,84 @@ func ListHistory(abs string) ([]HistoryEntry, error) {
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// augmentSchemaFromCascade mutates schema in place to inject
|
||||||
|
// cascade-resolved field_codes and records:-rule constraints. For
|
||||||
|
// every property whose name matches a field-code key, the relevant
|
||||||
|
// enum/pattern/labels are injected. For every record-rule's locked
|
||||||
|
// field, the corresponding property is marked readOnly. For every
|
||||||
|
// field_default, the corresponding property's Default is set if
|
||||||
|
// absent.
|
||||||
|
//
|
||||||
|
// gateDir is the directory the cascade was resolved at — needed
|
||||||
|
// only to pick the right records: rule when multiple patterns
|
||||||
|
// could match. The current cascade interface gives us the chain
|
||||||
|
// already; we pull a single "*.yaml" representative rule (matching
|
||||||
|
// the create-time behaviour in serveFormCreateRollup).
|
||||||
|
//
|
||||||
|
// Mutates the input schema. No-op when schema is nil.
|
||||||
|
func augmentSchemaFromCascade(schema *jsonschema.Schema, chain zddc.PolicyChain, gateDir string) {
|
||||||
|
if schema == nil || schema.Properties == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
codes := chain.EffectiveFieldCodes()
|
||||||
|
for name, prop := range schema.Properties {
|
||||||
|
if code, ok := codes[name]; ok {
|
||||||
|
switch code.Kind {
|
||||||
|
case zddc.FieldCodeEnum:
|
||||||
|
// Populate Enum with the code keys (sorted for
|
||||||
|
// deterministic order). Labels carries the
|
||||||
|
// human-readable display strings.
|
||||||
|
keys := make([]string, 0, len(code.Codes))
|
||||||
|
for k := range code.Codes {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
if len(prop.Enum) == 0 {
|
||||||
|
prop.Enum = make([]any, len(keys))
|
||||||
|
for i, k := range keys {
|
||||||
|
prop.Enum[i] = k
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if prop.Labels == nil && len(code.Codes) > 0 {
|
||||||
|
prop.Labels = make(map[string]string, len(code.Codes))
|
||||||
|
for k, v := range code.Codes {
|
||||||
|
prop.Labels[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case zddc.FieldCodePattern:
|
||||||
|
if prop.Pattern == "" {
|
||||||
|
prop.Pattern = code.Pattern
|
||||||
|
}
|
||||||
|
case zddc.FieldCodeFree:
|
||||||
|
// No constraint to inject; description is the
|
||||||
|
// only field and the operator can author it
|
||||||
|
// directly in the form spec.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the matched records:-rule's readOnly + default to
|
||||||
|
// matching properties. We probe with "*.yaml" — the records
|
||||||
|
// entries shipped in the embedded defaults all match that
|
||||||
|
// glob; operator schemas with literal-keyed rules would still
|
||||||
|
// be honoured by serveFormCreateRollup but won't be reflected
|
||||||
|
// in the form-render augmentation here.
|
||||||
|
if _, rule, ok := chain.EffectiveRecordRule("placeholder.yaml"); ok {
|
||||||
|
for _, name := range rule.Locked {
|
||||||
|
if prop, present := schema.Properties[name]; present {
|
||||||
|
prop.ReadOnly = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name, val := range rule.FieldDefaults {
|
||||||
|
if prop, present := schema.Properties[name]; present {
|
||||||
|
if prop.Default == nil {
|
||||||
|
prop.Default = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- small helpers ----
|
// ---- small helpers ----
|
||||||
|
|
||||||
func sha8(data []byte) string {
|
func sha8(data []byte) string {
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,25 @@ type Schema struct {
|
||||||
Maximum *float64 `yaml:"maximum" json:"maximum,omitempty"`
|
Maximum *float64 `yaml:"maximum" json:"maximum,omitempty"`
|
||||||
MinLength *int `yaml:"minLength" json:"minLength,omitempty"`
|
MinLength *int `yaml:"minLength" json:"minLength,omitempty"`
|
||||||
MaxLength *int `yaml:"maxLength" json:"maxLength,omitempty"`
|
MaxLength *int `yaml:"maxLength" json:"maxLength,omitempty"`
|
||||||
|
Pattern string `yaml:"pattern,omitempty" json:"pattern,omitempty"`
|
||||||
Format string `yaml:"format" json:"format,omitempty"`
|
Format string `yaml:"format" json:"format,omitempty"`
|
||||||
AdditionalProperties any `yaml:"additionalProperties" json:"additionalProperties,omitempty"`
|
AdditionalProperties any `yaml:"additionalProperties" json:"additionalProperties,omitempty"`
|
||||||
Title string `yaml:"title" json:"title,omitempty"`
|
Title string `yaml:"title" json:"title,omitempty"`
|
||||||
Description string `yaml:"description" json:"description,omitempty"`
|
Description string `yaml:"description" json:"description,omitempty"`
|
||||||
Default any `yaml:"default" json:"default,omitempty"`
|
Default any `yaml:"default" json:"default,omitempty"`
|
||||||
|
|
||||||
|
// ReadOnly: if true, clients render this property as disabled and
|
||||||
|
// suppress edit affordances. Not enforced by the validator (a
|
||||||
|
// rejected write is the wrong UX for a read-only field — the
|
||||||
|
// server strips the value instead). Surface to clients via JSON.
|
||||||
|
ReadOnly bool `yaml:"readOnly,omitempty" json:"readOnly,omitempty"`
|
||||||
|
|
||||||
|
// Labels is an extension used by the form renderer to display a
|
||||||
|
// human label next to a code in enum-with-labels dropdowns. The
|
||||||
|
// key matches an entry in Enum; the value is the display label
|
||||||
|
// (e.g. {"ACM": "Acme Inc"}). Server-injected from the cascade's
|
||||||
|
// field_codes:codes map; not enforced by the validator.
|
||||||
|
Labels map[string]string `yaml:"x-labels,omitempty" json:"x-labels,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error reports a single validation failure. Path is a JSON Pointer (RFC 6901)
|
// Error reports a single validation failure. Path is a JSON Pointer (RFC 6901)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue