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)
|
||||
}
|
||||
|
||||
// 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{
|
||||
Title: spec.Title,
|
||||
Schema: spec.Schema,
|
||||
|
|
|
|||
|
|
@ -576,6 +576,84 @@ func ListHistory(abs string) ([]HistoryEntry, error) {
|
|||
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 ----
|
||||
|
||||
func sha8(data []byte) string {
|
||||
|
|
|
|||
|
|
@ -32,11 +32,25 @@ type Schema struct {
|
|||
Maximum *float64 `yaml:"maximum" json:"maximum,omitempty"`
|
||||
MinLength *int `yaml:"minLength" json:"minLength,omitempty"`
|
||||
MaxLength *int `yaml:"maxLength" json:"maxLength,omitempty"`
|
||||
Pattern string `yaml:"pattern,omitempty" json:"pattern,omitempty"`
|
||||
Format string `yaml:"format" json:"format,omitempty"`
|
||||
AdditionalProperties any `yaml:"additionalProperties" json:"additionalProperties,omitempty"`
|
||||
Title string `yaml:"title" json:"title,omitempty"`
|
||||
Description string `yaml:"description" json:"description,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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue