feat(tables): configurable column option source — dropdown from a live registry

A table column can declare `options_source: <peer>` and the server fills its
`enum` from the live entries under <project>/<peer>/ — so the row editor renders
a dropdown of the current registry instead of free text. Generic + configurable
in the spec; no hardcoding.

- Server (tablehandler.go): resolveDynamicEnums + registryEntries resolve the
  peer directory (its *.yaml basenames + subfolders, sorted, dot/spec entries
  skipped) into the column enum at ServeTable time, before the context inject.
- Default risk register: add a `package` column with `options_source: ssr`
  (dropdown of the project's SSR packages) + the matching form property. The
  spec comment documents the key so operators can source other registries.
- Test covering the resolver (entries, skips, untouched columns).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-08 15:36:42 -05:00
parent b11f165b26
commit 8b690b782f
4 changed files with 166 additions and 0 deletions

View file

@ -106,6 +106,12 @@ schema:
type: string type: string
title: Category title: Category
description: Free-form grouping (schedule, cost, technical, regulatory, ...). description: Free-form grouping (schedule, cost, technical, regulatory, ...).
package:
type: string
title: Package
description: The SSR package this risk belongs to. The table view sources
the column's dropdown from the project's ssr/ registry
(table.yaml column options_source).
description: description:
type: string type: string
title: Description title: Description

View file

@ -24,6 +24,14 @@ columns:
- field: category - field: category
title: Category title: Category
width: 10em width: 10em
- field: package
title: Package
width: 12em
# Dropdown sourced from a live registry: `options_source: <peer>` fills
# this column's choices from the entries under <project>/<peer>/. Here it
# lists the project's SSR packages. Change `ssr` to source a different
# registry, or delete the key to make the column free text.
options_source: ssr
- field: likelihood - field: likelihood
title: L title: L
width: 4em width: 4em

View file

@ -33,6 +33,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@ -460,6 +461,105 @@ func injectTableContextObj(template []byte, ctx interface{}) ([]byte, error) {
return bytesReplace(template, needle, replacement), nil return bytesReplace(template, needle, replacement), nil
} }
// resolveDynamicEnums fills in `enum` for any column that declares
// `options_source: <peer>` (a configurable column key) from that peer
// registry's live entries under the project root. Example: the risk
// register's `package` column with `options_source: ssr` gets a dropdown of
// the project's registered SSR packages. Columns without the key are
// untouched; tableYAML is returned unchanged when there's nothing to resolve.
func resolveDynamicEnums(fsRoot, dir string, tableYAML []byte) []byte {
if len(tableYAML) == 0 {
return tableYAML
}
var spec map[string]interface{}
if err := yaml.Unmarshal(tableYAML, &spec); err != nil {
return tableYAML
}
cols, ok := spec["columns"].([]interface{})
if !ok {
return tableYAML
}
changed := false
for _, c := range cols {
col, ok := c.(map[string]interface{})
if !ok {
continue
}
src, _ := col["options_source"].(string)
if src == "" {
continue
}
entries := registryEntries(fsRoot, dir, src)
if entries == nil {
continue
}
arr := make([]interface{}, len(entries))
for i, e := range entries {
arr[i] = e
}
col["enum"] = arr
changed = true
}
if !changed {
return tableYAML
}
out, err := yaml.Marshal(spec)
if err != nil {
return tableYAML
}
return out
}
// registryEntries lists the names registered under <project>/<peer>/ — the
// .yaml row files (basename sans extension) and any subdirectories — sorted
// and de-duped, skipping dot/underscore names and the table/form specs. The
// project is the first path segment of dir under fsRoot. Returns nil when the
// peer directory doesn't exist.
func registryEntries(fsRoot, dir, peer string) []string {
rel, err := filepath.Rel(fsRoot, dir)
if err != nil || strings.HasPrefix(rel, "..") {
return nil
}
rel = filepath.ToSlash(rel)
project := rel
if i := strings.IndexByte(rel, '/'); i >= 0 {
project = rel[:i]
}
if project == "" || project == "." {
return nil
}
ents, err := os.ReadDir(filepath.Join(fsRoot, project, peer))
if err != nil {
return nil
}
seen := map[string]bool{}
var names []string
for _, e := range ents {
name := e.Name()
if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") {
continue
}
if name == "table.yaml" || name == "form.yaml" {
continue
}
base := name
if !e.IsDir() {
low := strings.ToLower(name)
if !strings.HasSuffix(low, ".yaml") && !strings.HasSuffix(low, ".yml") {
continue
}
base = strings.TrimSuffix(strings.TrimSuffix(name, ".yml"), ".yaml")
}
if base == "" || seen[base] {
continue
}
seen[base] = true
names = append(names, base)
}
sort.Strings(names)
return names
}
// EmbeddedTablesHTML exposes the embedded tables renderer to sibling handlers // EmbeddedTablesHTML exposes the embedded tables renderer to sibling handlers
// (e.g. the token page) that render a server-injected collection through it. // (e.g. the token page) that render a server-injected collection through it.
func EmbeddedTablesHTML() []byte { return embeddedTablesHTML } func EmbeddedTablesHTML() []byte { return embeddedTablesHTML }
@ -495,6 +595,11 @@ func ServeTable(cfg config.Config, req *TableRequest, w http.ResponseWriter, r *
body := embeddedTablesHTML body := embeddedTablesHTML
tableYAML := LoadViewSpec(cfg.Root, req.Dir, "table.yaml") tableYAML := LoadViewSpec(cfg.Root, req.Dir, "table.yaml")
formYAML := LoadViewSpec(cfg.Root, req.Dir, "form.yaml") formYAML := LoadViewSpec(cfg.Root, req.Dir, "form.yaml")
// Resolve any column whose options come from a live registry
// (column key `options_source`, e.g. the risk register's `package`
// column sourced from the project's `ssr` packages) into a concrete
// enum, so the row editor renders a dropdown of the current entries.
tableYAML = resolveDynamicEnums(cfg.Root, req.Dir, tableYAML)
if injected, ierr := injectTableContext(embeddedTablesHTML, tableYAML, formYAML); ierr == nil { if injected, ierr := injectTableContext(embeddedTablesHTML, tableYAML, formYAML); ierr == nil {
body = injected body = injected
} }

View file

@ -10,6 +10,8 @@ import (
"strings" "strings"
"testing" "testing"
"gopkg.in/yaml.v3"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
) )
@ -446,3 +448,48 @@ func TestIsDefaultSpec_ProjectLevel_OperatorOverrides(t *testing.T) {
t.Errorf("operator file should win at /Project/ssr/table.yaml") t.Errorf("operator file should win at /Project/ssr/table.yaml")
} }
} }
// TestResolveDynamicEnums verifies that a column declaring
// `options_source: <peer>` is filled with the live registry entries from
// <project>/<peer>/ — the configurable-source feature (e.g. the risk
// register's package column sourced from ssr).
func TestResolveDynamicEnums(t *testing.T) {
root := t.TempDir()
// <root>/Proj/ssr/{Acme,Globex}.yaml + a Beta/ folder; specs + dotfiles
// must be skipped.
ssr := filepath.Join(root, "Proj", "ssr")
if err := os.MkdirAll(filepath.Join(ssr, "Beta"), 0o755); err != nil {
t.Fatal(err)
}
for _, n := range []string{"Acme.yaml", "Globex.yaml", "table.yaml", ".hidden.yaml", "notes.txt"} {
if err := os.WriteFile(filepath.Join(ssr, n), []byte("x: 1\n"), 0o644); err != nil {
t.Fatal(err)
}
}
rskDir := filepath.Join(root, "Proj", "rsk", "Acme")
spec := []byte("columns:\n - field: package\n options_source: ssr\n - field: title\n")
out := resolveDynamicEnums(root, rskDir, spec)
var parsed map[string]interface{}
if err := yaml.Unmarshal(out, &parsed); err != nil {
t.Fatalf("re-parse: %v", err)
}
cols := parsed["columns"].([]interface{})
pkg := cols[0].(map[string]interface{})
enum, ok := pkg["enum"].([]interface{})
if !ok {
t.Fatalf("package column got no enum: %+v", pkg)
}
got := make([]string, len(enum))
for i, e := range enum {
got[i] = e.(string)
}
want := "Acme,Beta,Globex" // sorted; specs + dot/non-yaml skipped
if strings.Join(got, ",") != want {
t.Errorf("enum = %v, want %s", got, want)
}
// The non-sourced column is untouched.
if _, has := cols[1].(map[string]interface{})["enum"]; has {
t.Errorf("title column should not get an enum")
}
}