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:
parent
b11f165b26
commit
8b690b782f
4 changed files with 166 additions and 0 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue