diff --git a/zddc/internal/handler/default-rsk.form.yaml b/zddc/internal/handler/default-rsk.form.yaml index 901ad0f..78e8fbe 100644 --- a/zddc/internal/handler/default-rsk.form.yaml +++ b/zddc/internal/handler/default-rsk.form.yaml @@ -106,6 +106,12 @@ schema: type: string title: Category 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: type: string title: Description diff --git a/zddc/internal/handler/default-rsk.table.yaml b/zddc/internal/handler/default-rsk.table.yaml index a935d52..3d7c2c9 100644 --- a/zddc/internal/handler/default-rsk.table.yaml +++ b/zddc/internal/handler/default-rsk.table.yaml @@ -24,6 +24,14 @@ columns: - field: category title: Category width: 10em + - field: package + title: Package + width: 12em + # Dropdown sourced from a live registry: `options_source: ` fills + # this column's choices from the entries under //. 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 title: L width: 4em diff --git a/zddc/internal/handler/tablehandler.go b/zddc/internal/handler/tablehandler.go index ab76d43..0e07cf2 100644 --- a/zddc/internal/handler/tablehandler.go +++ b/zddc/internal/handler/tablehandler.go @@ -33,6 +33,7 @@ import ( "net/http" "os" "path/filepath" + "sort" "strings" "gopkg.in/yaml.v3" @@ -460,6 +461,105 @@ func injectTableContextObj(template []byte, ctx interface{}) ([]byte, error) { return bytesReplace(template, needle, replacement), nil } +// resolveDynamicEnums fills in `enum` for any column that declares +// `options_source: ` (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 // — 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 // (e.g. the token page) that render a server-injected collection through it. func EmbeddedTablesHTML() []byte { return embeddedTablesHTML } @@ -495,6 +595,11 @@ func ServeTable(cfg config.Config, req *TableRequest, w http.ResponseWriter, r * body := embeddedTablesHTML tableYAML := LoadViewSpec(cfg.Root, req.Dir, "table.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 { body = injected } diff --git a/zddc/internal/handler/tablehandler_test.go b/zddc/internal/handler/tablehandler_test.go index e369acb..32c89b2 100644 --- a/zddc/internal/handler/tablehandler_test.go +++ b/zddc/internal/handler/tablehandler_test.go @@ -10,6 +10,8 @@ import ( "strings" "testing" + "gopkg.in/yaml.v3" + "codeberg.org/VARASYS/ZDDC/zddc/internal/config" "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") } } + +// TestResolveDynamicEnums verifies that a column declaring +// `options_source: ` is filled with the live registry entries from +// // — the configurable-source feature (e.g. the risk +// register's package column sourced from ssr). +func TestResolveDynamicEnums(t *testing.T) { + root := t.TempDir() + // /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") + } +}