feat(cli): add --schema flag to pad collection create (TASK-1334) (#482)

* feat(cli): add --schema flag to pad collection create (TASK-1334)

The existing --fields DSL (key:type[:options]) had no syntax for
terminal_options, default, required, computed, suffix, or relation
collection — every CLI-created collection lost those FieldDef
properties even though the model already supports them. Symptom from
BUG-1284: dashboard "active" counts treat published/archived items as
in-progress because the persisted schema has no terminal_options.

Adds a new --schema flag that accepts the full CollectionSchema JSON,
which captures every current and future FieldDef property automatically.
Three input modes:

  --schema '<json>'      inline literal (agent-natural; CLI is agent-first)
  --schema @./path.json  file path
  --schema -             stdin

--fields and --schema are mutually exclusive; --fields keeps working
unchanged for backward compat (no deprecation).

Refactors the inline parser into three testable helpers in main.go:
collectionSchemaJSONFromFlags (orchestrator), readSchemaInputBytes
(input resolver), and parseFieldsDSL (legacy DSL parser preserving the
"first status select gets required+default" heuristic).

Tests: 9 table-style cases in collection_create_schema_test.go covering
all three input modes, the mutually-exclusive guard, malformed JSON,
missing file, fallthrough-to-DSL, both-empty, and a regression test
that verifies terminal_options + computed + suffix + relation.collection
all round-trip through --schema.

Parent: PLAN-1333.

* fix(cli): backfill missing labels in --schema fields per Codex review (round 2)

Codex flagged that the --schema example omitted "label", which the
parser preserved as label:"" — agents constructing JSON could create
collections that render blank field headers in the web UI.

Fix: after unmarshaling --schema input, backfill any FieldDef with an
empty Label using the same Title-Case-of-key heuristic the legacy
--fields DSL applies (e.g. "due_date" → "Due Date"). Explicit labels
are preserved.

Also updated the help-text example to include "label" on the status
field so the canonical shape is visible, plus a tip line documenting
the auto-fill behavior so users know it's safe to omit labels.

Test: TestCollectionSchemaJSONFromFlags_BackfillsMissingLabels covers
auto-fill, multi-word key normalization, and the explicit-label-not-
clobbered case.

Parent: PLAN-1333 / TASK-1334.
This commit is contained in:
xarmian
2026-05-10 21:21:43 -04:00
committed by GitHub
parent e01c1581d0
commit 25a21184a2
2 changed files with 399 additions and 37 deletions
+260
View File
@@ -0,0 +1,260 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// TestCollectionSchemaJSONFromFlags_Inline verifies the inline JSON path:
// schemaInput is the literal JSON, gets unmarshaled then re-marshaled
// through the CollectionSchema shape (which normalizes / validates it).
func TestCollectionSchemaJSONFromFlags_Inline(t *testing.T) {
in := `{"fields":[{"key":"status","label":"Status","type":"select","options":["new","done"],"terminal_options":["done"],"default":"new","required":true}]}`
out, err := collectionSchemaJSONFromFlags(in, "", strings.NewReader(""))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got models.CollectionSchema
if err := json.Unmarshal([]byte(out), &got); err != nil {
t.Fatalf("re-unmarshal: %v", err)
}
if len(got.Fields) != 1 {
t.Fatalf("expected 1 field, got %d", len(got.Fields))
}
f := got.Fields[0]
if f.Key != "status" || f.Type != "select" {
t.Fatalf("unexpected key/type: %+v", f)
}
if len(f.TerminalOptions) != 1 || f.TerminalOptions[0] != "done" {
t.Fatalf("expected terminal_options=[done], got %v", f.TerminalOptions)
}
if !f.Required || f.Default != "new" {
t.Fatalf("expected required=true default=new, got required=%v default=%v", f.Required, f.Default)
}
}
// TestCollectionSchemaJSONFromFlags_File verifies the @<path> file path resolver.
func TestCollectionSchemaJSONFromFlags_File(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "schema.json")
payload := `{"fields":[{"key":"status","type":"select","options":["a","b"],"terminal_options":["b"]}]}`
if err := os.WriteFile(path, []byte(payload), 0o600); err != nil {
t.Fatalf("write tmpfile: %v", err)
}
out, err := collectionSchemaJSONFromFlags("@"+path, "", strings.NewReader(""))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got models.CollectionSchema
if err := json.Unmarshal([]byte(out), &got); err != nil {
t.Fatalf("re-unmarshal: %v", err)
}
if len(got.Fields) != 1 || got.Fields[0].TerminalOptions[0] != "b" {
t.Fatalf("expected terminal_options=[b], got %+v", got.Fields)
}
}
// TestCollectionSchemaJSONFromFlags_Stdin verifies the "-" stdin resolver.
func TestCollectionSchemaJSONFromFlags_Stdin(t *testing.T) {
stdin := strings.NewReader(`{"fields":[{"key":"priority","type":"select","options":["lo","hi"]}]}`)
out, err := collectionSchemaJSONFromFlags("-", "", stdin)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got models.CollectionSchema
if err := json.Unmarshal([]byte(out), &got); err != nil {
t.Fatalf("re-unmarshal: %v", err)
}
if len(got.Fields) != 1 || got.Fields[0].Key != "priority" {
t.Fatalf("unexpected schema: %+v", got)
}
}
// TestCollectionSchemaJSONFromFlags_BothFlagsError checks the mutually-exclusive guard.
func TestCollectionSchemaJSONFromFlags_BothFlagsError(t *testing.T) {
_, err := collectionSchemaJSONFromFlags(`{"fields":[]}`, "status:select:open,done", strings.NewReader(""))
if err == nil {
t.Fatal("expected error when both --fields and --schema set, got nil")
}
if !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("expected mutually-exclusive error, got: %v", err)
}
}
// TestCollectionSchemaJSONFromFlags_MalformedJSON ensures the unmarshal error
// is wrapped with the "invalid --schema JSON" prefix so users can see which
// flag caused the problem.
func TestCollectionSchemaJSONFromFlags_MalformedJSON(t *testing.T) {
_, err := collectionSchemaJSONFromFlags(`{this is not json`, "", strings.NewReader(""))
if err == nil {
t.Fatal("expected error for malformed JSON, got nil")
}
if !strings.Contains(err.Error(), "invalid --schema JSON") {
t.Fatalf("expected 'invalid --schema JSON' in error, got: %v", err)
}
}
// TestCollectionSchemaJSONFromFlags_MissingFile ensures a clear error when
// the @path target doesn't exist.
func TestCollectionSchemaJSONFromFlags_MissingFile(t *testing.T) {
_, err := collectionSchemaJSONFromFlags("@/nonexistent/path/schema.json", "", strings.NewReader(""))
if err == nil {
t.Fatal("expected error for missing file, got nil")
}
if !strings.Contains(err.Error(), "read --schema file") {
t.Fatalf("expected 'read --schema file' in error, got: %v", err)
}
}
// TestCollectionSchemaJSONFromFlags_EmptyFallsThroughToFields verifies that an
// empty --schema falls through to the --fields DSL parser (backward compat).
func TestCollectionSchemaJSONFromFlags_EmptyFallsThroughToFields(t *testing.T) {
out, err := collectionSchemaJSONFromFlags("", "status:select:open,done", strings.NewReader(""))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got models.CollectionSchema
if err := json.Unmarshal([]byte(out), &got); err != nil {
t.Fatalf("re-unmarshal: %v", err)
}
if len(got.Fields) != 1 {
t.Fatalf("expected 1 field, got %d", len(got.Fields))
}
f := got.Fields[0]
if f.Key != "status" || f.Type != "select" {
t.Fatalf("unexpected key/type: %+v", f)
}
// DSL preserves the legacy "first status select gets required+default" heuristic.
if !f.Required || f.Default != "open" {
t.Fatalf("expected legacy DSL heuristic (required=true, default=open), got required=%v default=%v", f.Required, f.Default)
}
}
// TestCollectionSchemaJSONFromFlags_BothEmptyReturnsEmptySchema verifies the
// no-flags case yields an empty schema (and no error).
func TestCollectionSchemaJSONFromFlags_BothEmptyReturnsEmptySchema(t *testing.T) {
out, err := collectionSchemaJSONFromFlags("", "", strings.NewReader(""))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out != `{"fields":null}` && out != `{"fields":[]}` && out != `{}` {
// We accept any JSON shape that round-trips to a Fields-less schema —
// the wire format here is opaque to callers.
var got models.CollectionSchema
if jsonErr := json.Unmarshal([]byte(out), &got); jsonErr != nil {
t.Fatalf("unparseable empty-schema output %q: %v", out, jsonErr)
}
if len(got.Fields) != 0 {
t.Fatalf("expected empty Fields, got %d", len(got.Fields))
}
}
}
// TestCollectionSchemaJSONFromFlags_BackfillsMissingLabels verifies that a
// schema field with no `label` gets one auto-filled from its `key` using
// Title-Case, matching the legacy --fields DSL behavior. Without this,
// agents constructing JSON that omits `label` would create collections
// that render blank field headers in the web UI.
func TestCollectionSchemaJSONFromFlags_BackfillsMissingLabels(t *testing.T) {
in := `{"fields":[
{"key":"status","type":"select","options":["open","done"]},
{"key":"due_date","type":"date"},
{"key":"already_labeled","label":"Custom Label","type":"text"}
]}`
out, err := collectionSchemaJSONFromFlags(in, "", strings.NewReader(""))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got models.CollectionSchema
if err := json.Unmarshal([]byte(out), &got); err != nil {
t.Fatalf("re-unmarshal: %v", err)
}
if len(got.Fields) != 3 {
t.Fatalf("expected 3 fields, got %d", len(got.Fields))
}
if got.Fields[0].Label != "Status" {
t.Errorf("expected label 'Status' for key 'status', got %q", got.Fields[0].Label)
}
if got.Fields[1].Label != "Due Date" {
t.Errorf("expected label 'Due Date' for key 'due_date', got %q", got.Fields[1].Label)
}
if got.Fields[2].Label != "Custom Label" {
t.Errorf("explicit label clobbered: got %q want 'Custom Label'", got.Fields[2].Label)
}
}
// TestCollectionSchemaJSONFromFlags_PreservesAllFieldDefProperties is the
// regression test for BUG-1284: confirms that every FieldDef property — not
// just the DSL-expressible subset — round-trips through --schema unchanged.
func TestCollectionSchemaJSONFromFlags_PreservesAllFieldDefProperties(t *testing.T) {
in := `{
"fields": [
{
"key": "status",
"label": "Status",
"type": "select",
"options": ["idea","drafting","review","approved","scheduled","published","archived"],
"terminal_options": ["published","archived"],
"default": "idea",
"required": true
},
{
"key": "progress",
"label": "Progress",
"type": "number",
"computed": true,
"suffix": "%"
},
{
"key": "parent_plan",
"label": "Parent Plan",
"type": "relation",
"collection": "plans"
}
]
}`
out, err := collectionSchemaJSONFromFlags(in, "", strings.NewReader(""))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got models.CollectionSchema
if err := json.Unmarshal([]byte(out), &got); err != nil {
t.Fatalf("re-unmarshal: %v", err)
}
if len(got.Fields) != 3 {
t.Fatalf("expected 3 fields, got %d", len(got.Fields))
}
status := got.Fields[0]
if len(status.TerminalOptions) != 2 || status.TerminalOptions[0] != "published" || status.TerminalOptions[1] != "archived" {
t.Errorf("status.terminal_options not preserved: %v", status.TerminalOptions)
}
if status.Default != "idea" {
t.Errorf("status.default not preserved: %v", status.Default)
}
if !status.Required {
t.Errorf("status.required not preserved")
}
progress := got.Fields[1]
if !progress.Computed {
t.Errorf("progress.computed not preserved")
}
if progress.Suffix != "%" {
t.Errorf("progress.suffix not preserved: %q", progress.Suffix)
}
parent := got.Fields[2]
if parent.Collection != "plans" {
t.Errorf("relation.collection not preserved: %q", parent.Collection)
}
}
+139 -37
View File
@@ -4820,11 +4820,123 @@ func collectionsCmd() *cobra.Command {
}
}
// collectionSchemaJSONFromFlags resolves the --schema and --fields flags into
// a marshaled CollectionSchema JSON string.
//
// Exactly one of schemaInput or fieldsDSL may be non-empty. When both are
// empty, returns "{}" — an empty schema with no fields.
//
// schemaInput input modes:
// - "" — fall through to fieldsDSL (or empty schema if that is also empty)
// - "-" — read full JSON from stdin
// - "@<path>" — read full JSON from the file at <path>
// - anything else — treat the value itself as an inline JSON literal
func collectionSchemaJSONFromFlags(schemaInput, fieldsDSL string, stdin io.Reader) (string, error) {
if schemaInput != "" && fieldsDSL != "" {
return "", fmt.Errorf("--fields and --schema are mutually exclusive")
}
if schemaInput != "" {
data, err := readSchemaInputBytes(schemaInput, stdin)
if err != nil {
return "", err
}
var schema models.CollectionSchema
if err := json.Unmarshal(data, &schema); err != nil {
return "", fmt.Errorf("invalid --schema JSON: %w", err)
}
// Backfill missing labels from keys using the same Title-Case-of-key
// heuristic the legacy --fields DSL applies. Without this, schemas
// that omit `label` render blank field headers in the web UI — easy
// for an agent constructing JSON to forget.
for i := range schema.Fields {
if schema.Fields[i].Label == "" && schema.Fields[i].Key != "" {
schema.Fields[i].Label = cases.Title(language.English).String(strings.ReplaceAll(schema.Fields[i].Key, "_", " "))
}
}
out, err := json.Marshal(schema)
if err != nil {
return "", fmt.Errorf("re-marshal schema: %w", err)
}
return string(out), nil
}
schema, err := parseFieldsDSL(fieldsDSL)
if err != nil {
return "", err
}
out, err := json.Marshal(schema)
if err != nil {
return "", fmt.Errorf("marshal schema from --fields: %w", err)
}
return string(out), nil
}
// readSchemaInputBytes resolves the --schema flag value into raw JSON bytes,
// honoring the "-" (stdin), "@path" (file), and inline-literal modes.
func readSchemaInputBytes(input string, stdin io.Reader) ([]byte, error) {
switch {
case input == "-":
data, err := io.ReadAll(stdin)
if err != nil {
return nil, fmt.Errorf("read --schema from stdin: %w", err)
}
return data, nil
case strings.HasPrefix(input, "@"):
path := input[1:]
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read --schema file %q: %w", path, err)
}
return data, nil
default:
return []byte(input), nil
}
}
// parseFieldsDSL parses the legacy --fields DSL (key:type[:options];...) into
// a CollectionSchema. Empty input returns an empty schema with no error.
func parseFieldsDSL(fieldsDSL string) (models.CollectionSchema, error) {
schema := models.CollectionSchema{}
if fieldsDSL == "" {
return schema, nil
}
for _, f := range strings.Split(fieldsDSL, ";") {
f = strings.TrimSpace(f)
if f == "" {
continue
}
parts := strings.SplitN(f, ":", 3)
if len(parts) < 2 {
return schema, fmt.Errorf("invalid field definition: %q (expected key:type[:options])", f)
}
fd := models.FieldDef{
Key: parts[0],
Label: cases.Title(language.English).String(strings.ReplaceAll(parts[0], "_", " ")),
Type: parts[1],
}
if len(parts) == 3 && parts[2] != "" {
fd.Options = strings.Split(parts[2], ",")
}
// First select field gets required+default — preserved for
// backward compat with the pre-existing DSL behavior.
if fd.Type == "select" && fd.Key == "status" {
fd.Required = true
if len(fd.Options) > 0 {
fd.Default = fd.Options[0]
}
}
schema.Fields = append(schema.Fields, fd)
}
return schema, nil
}
func collectionsCreateCmd() *cobra.Command {
var (
icon string
description string
fieldsDSL string
schemaInput string
layout string
defaultView string
boardGroup string
@@ -4835,12 +4947,30 @@ func collectionsCreateCmd() *cobra.Command {
Short: "Create a custom collection",
Long: `Create a new collection with custom fields.
Fields DSL format: key:type[:option1,option2,...]
Separate multiple fields with newlines or semicolons.
Two ways to define the schema:
--fields Compact DSL for the simple case: key:type[:option1,option2,...]
Separate multiple fields with semicolons. Does not support
terminal_options, custom defaults, computed fields, suffixes,
or relation collections.
--schema Full CollectionSchema JSON for everything else. Accepts:
inline JSON: --schema '{"fields":[...]}'
file path: --schema @./schema.json
stdin: --schema -
--fields and --schema are mutually exclusive.
Examples:
pad collection create "Bugs" --fields "status:select:new,triaged,fixing,resolved;severity:select:low,medium,high,critical;component:text"
pad collection create "Decisions" --icon "⚖️" --fields "status:select:proposed,accepted,rejected;impact:select:low,medium,high"`,
pad collection create "Decisions" --icon "⚖️" --fields "status:select:proposed,accepted,rejected;impact:select:low,medium,high"
pad collection create "Marketing" --schema '{"fields":[{"key":"status","label":"Status","type":"select","options":["idea","drafting","review","published","archived"],"terminal_options":["published","archived"],"default":"idea","required":true}]}'
pad collection create "Marketing" --schema @./marketing-schema.json
cat schema.json | pad collection create "Marketing" --schema -
Tip: if you omit "label" on a --schema field, the CLI auto-fills it from
the key using Title Case (e.g. "due_date" "Due Date") matching what
the --fields DSL does. Set "label" explicitly when you want a custom display name.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, _ := getClient()
@@ -4848,40 +4978,11 @@ Examples:
name := args[0]
// Parse fields DSL into schema JSON
schema := models.CollectionSchema{}
if fieldsDSL != "" {
fields := strings.Split(fieldsDSL, ";")
for _, f := range fields {
f = strings.TrimSpace(f)
if f == "" {
continue
}
parts := strings.SplitN(f, ":", 3)
if len(parts) < 2 {
return fmt.Errorf("invalid field definition: %q (expected key:type[:options])", f)
}
fd := models.FieldDef{
Key: parts[0],
Label: cases.Title(language.English).String(strings.ReplaceAll(parts[0], "_", " ")),
Type: parts[1],
}
if len(parts) == 3 && parts[2] != "" {
fd.Options = strings.Split(parts[2], ",")
}
// First select field gets required+default
if fd.Type == "select" && fd.Key == "status" {
fd.Required = true
if len(fd.Options) > 0 {
fd.Default = fd.Options[0]
}
}
schema.Fields = append(schema.Fields, fd)
}
schemaJSON, err := collectionSchemaJSONFromFlags(schemaInput, fieldsDSL, os.Stdin)
if err != nil {
return err
}
schemaJSON, _ := json.Marshal(schema)
// Build settings
settings := models.CollectionSettings{
Layout: layout,
@@ -4900,7 +5001,7 @@ Examples:
Name: name,
Icon: icon,
Description: description,
Schema: string(schemaJSON),
Schema: schemaJSON,
Settings: string(settingsJSON),
}
@@ -4924,7 +5025,8 @@ Examples:
cmd.Flags().StringVar(&icon, "icon", "", "collection emoji icon")
cmd.Flags().StringVar(&description, "description", "", "collection description")
cmd.Flags().StringVar(&fieldsDSL, "fields", "", "field definitions (key:type[:options]; ...)")
cmd.Flags().StringVar(&fieldsDSL, "fields", "", "field definitions DSL (key:type[:options]; ...); use --schema for terminal_options, computed, defaults, etc.")
cmd.Flags().StringVar(&schemaInput, "schema", "", "full CollectionSchema JSON: inline, @path, or - for stdin; mutually exclusive with --fields")
cmd.Flags().StringVar(&layout, "layout", "fields-primary", "item detail layout: fields-primary, content-primary, balanced")
cmd.Flags().StringVar(&defaultView, "default-view", "list", "default view type: list, board, table")
cmd.Flags().StringVar(&boardGroup, "board-group-by", "status", "field to group by in board view")