diff --git a/CHANGELOG.md b/CHANGELOG.md index 6259e47..fd20ded 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,53 @@ All notable changes to Armature are documented here. +## v0.9.5 — Typed Forms → SDK Primitive + +Promotes the typed form system from a workflow-only model to a reusable +SDK primitive. Any package can now declare and validate forms — not just +workflow stages. + +**Backend — `server/forms/` package** + +- Extracted `TypedFormTemplate`, `FormField`, `FormFieldset`, `FormOption`, + `FormValidation`, `FieldCondition`, `FormHooks`, `FieldError` from + `models/workflow.go` into standalone `forms` package. +- `ParseTypedFormTemplate()`, `ValidateFormData()`, `EvaluateFieldCondition()` + exported for use by any consumer. +- ~317 lines removed from `models/workflow.go` (no external imports existed). + +**REST endpoint** + +- `POST /api/v1/forms/validate` — authenticated endpoint. Accepts + `{template, data}`, returns `{valid: bool, errors: [{key, message}]}`. + +**Starlark module** + +- `forms.validate(template, data)` — gated by `forms.validate` permission. + Returns dict with `valid` (bool) and `errors` (list of dicts). + +**Frontend SDK** + +- `sw.forms.render(container, template, opts)` — Preact component rendering + flat and progressive (fieldset) forms. Returns `{getData, setErrors, destroy}`. +- `sw.forms.validate(template, data)` — client-side validation (mirrors Go logic). +- `sw.forms.validateRemote(template, data)` — server-side validation via REST. +- SDK version bumped to `0.9.5`. + +**Manifest validation** + +- `form_template` accepted at package level. Validated via + `forms.ParseTypedFormTemplate()` at install time. +- `ManifestInfo.HasFormTemplate` flag added. + +**Tests** + +- 16 new tests: 12 forms package unit tests (parse, validate by type, + conditions, fieldsets), 4 Starlark module tests (valid, required, + type errors, conditions). + +--- + ## v0.9.4 — Package Adoption + Roles Packages can now declare `adoptable: true` in their manifest. When a team diff --git a/ROADMAP.md b/ROADMAP.md index 5e65408..b0b69aa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -114,12 +114,13 @@ into the team's role catalog. Adopted packages reference the original via `AdoptTeamWorkflow` deprecated in favor of package-level adoption. 4 new endpoints, migration 017, 11 new tests. -**v0.9.5 — Typed Forms → SDK Primitive** +**v0.9.5 — Typed Forms → SDK Primitive** *(completed)* -Extract `TypedFormTemplate`, `FormField`, `FormFieldset`, etc. from -`models/workflow.go` into a `forms` package. FE SDK: `sw.forms.render()` -and `sw.forms.validate()`. Starlark: `forms.validate()`. Any package -can declare forms, not just workflow stages. +Extracted `TypedFormTemplate`, `FormField`, `FormFieldset`, etc. from +`models/workflow.go` into a standalone `forms` package. REST endpoint +`POST /api/v1/forms/validate`. Starlark `forms.validate()` module. +FE SDK: `sw.forms.render()`, `sw.forms.validate()`, `sw.forms.validateRemote()`. +Manifest `form_template` accepted at package level. 16 new tests. **v0.9.6 — Deprecate `stage_type`, Collapse `stage_mode`** diff --git a/VERSION b/VERSION index 965065d..b0bb878 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.3 +0.9.5 diff --git a/docs/API-REFERENCE.md b/docs/API-REFERENCE.md index d8dd2c7..67772bc 100644 --- a/docs/API-REFERENCE.md +++ b/docs/API-REFERENCE.md @@ -184,3 +184,30 @@ Kernel event prefixes: `user.*`, `team.*`, `workflow.*`, `notification.*`, `pres | POST | `/presence/heartbeat` | Update presence status | | GET | `/presence` | Query online users | | GET | `/users/search` | Search users | + +### Forms (v0.9.5) + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/forms/validate` | Validate form data against a typed template | + +**Request body:** + +```json +{ + "template": { + "fields": [ + {"key": "name", "type": "text", "label": "Name", "required": true} + ] + }, + "data": {"name": "Alice"} +} +``` + +**Response:** + +```json +{"valid": true, "errors": []} +``` + +On validation failure, `errors` contains `[{"key": "name", "message": "Name is required"}]`. diff --git a/docs/FRONTEND-JS-GUIDE.md b/docs/FRONTEND-JS-GUIDE.md index f7a280f..3465075 100644 --- a/docs/FRONTEND-JS-GUIDE.md +++ b/docs/FRONTEND-JS-GUIDE.md @@ -248,6 +248,44 @@ Every surface uses one of three patterns: The shell provides the home link, notification bell, and user menu on every surface for free. +## `sw.forms` — Typed Forms (v0.9.5) + +Any extension can render and validate typed forms using the `sw.forms` module. + +### `sw.forms.render(container, template, opts)` + +Renders a typed form into a DOM container. Supports flat forms and progressive +multi-step forms (fieldsets). Returns a control handle. + +```js +const handle = sw.forms.render(document.getElementById('my-form'), template, { + values: { name: 'prefilled' }, + onSubmit: (data) => { console.log('submitted', data); }, +}); + +// Programmatic access +const data = handle.getData(); +handle.setErrors([{ key: 'name', message: 'Name is taken' }]); +handle.destroy(); +``` + +### `sw.forms.validate(template, data)` + +Client-side validation (no network call). Returns `{ valid, errors }`. + +```js +const { valid, errors } = sw.forms.validate(template, { name: '' }); +// valid === false, errors === [{ key: 'name', message: 'Name is required' }] +``` + +### `sw.forms.validateRemote(template, data)` + +Server-side validation via `POST /api/v1/forms/validate`. Returns a Promise. + +```js +const result = await sw.forms.validateRemote(template, data); +``` + ## Extension CSS contract Extensions must prefix all CSS classes with `.ext-{slug}-` to avoid diff --git a/docs/PACKAGE-FORMAT.md b/docs/PACKAGE-FORMAT.md index d6c6fd9..98bd6d2 100644 --- a/docs/PACKAGE-FORMAT.md +++ b/docs/PACKAGE-FORMAT.md @@ -75,6 +75,7 @@ Only `manifest.json` is required. All other directories are optional and include | `contributes` | Slot contributions this package injects into other surfaces | | `capabilities` | Environment requirements: `{"required": [...], "optional": [...]}` | | `schema_version` | Integer for additive schema migrations | +| `form_template` | Typed form template (v0.9.5). Fields array or fieldsets for progressive forms. Validated at install. | ## Multi-Surface Packages diff --git a/docs/STARLARK-REFERENCE.md b/docs/STARLARK-REFERENCE.md index f3abf68..5624aea 100644 --- a/docs/STARLARK-REFERENCE.md +++ b/docs/STARLARK-REFERENCE.md @@ -429,3 +429,30 @@ def on_run(ctx): return {"advance": True, "data": {"needs_review": False}} ``` + +--- + +## `forms` Module (v0.9.5) + +**Permission:** `forms.validate` + +Validates form data against a typed form template. + +### `forms.validate(template, data)` + +Validates `data` (a dict) against a `template` (a dict matching the TypedFormTemplate schema). + +Returns a dict: `{"valid": True/False, "errors": [{"key": "...", "message": "..."}]}`. + +```python +result = forms.validate( + {"fields": [{"key": "name", "type": "text", "label": "Name", "required": True}]}, + {"name": "Alice"}, +) +# result["valid"] == True +# result["errors"] == [] +``` + +Field types: `text`, `email`, `select`, `number`, `date`, `textarea`, `checkbox`, `file`. + +Supports: required checks, min/max length, pattern (regex), number range, date range, select option whitelist, conditional visibility (`condition.when`/`op`/`value`). diff --git a/server/forms/forms.go b/server/forms/forms.go new file mode 100644 index 0000000..bb52b33 --- /dev/null +++ b/server/forms/forms.go @@ -0,0 +1,347 @@ +package forms + +// forms.go +// +// Standalone typed form system extracted from models/workflow.go (v0.9.5). +// Any package can declare and validate forms — not just workflow stages. +// +// Schema: +// TypedFormTemplate → []FormField | []FormFieldset +// FormField → key, type, label, validation, condition, … +// FormValidation → type-specific rules (length, pattern, range, date, file) +// FieldCondition → conditional visibility (eq, neq, in, exists) +// FormHooks → Starlark validate / on_submit entry points +// +// Functions: +// ParseTypedFormTemplate(raw) → *TypedFormTemplate +// ValidateFormData(tpl, data) → []FieldError + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + "time" +) + +// ── Types ──────────────────────────────────── + +// TypedFormTemplate is the structured form_template schema. +type TypedFormTemplate struct { + Fields []FormField `json:"fields"` + Fieldsets []FormFieldset `json:"fieldsets,omitempty"` + Hooks *FormHooks `json:"hooks,omitempty"` +} + +// FormFieldset groups fields into a labelled step for progressive forms. +// When fieldsets is present, top-level fields is ignored. +type FormFieldset struct { + Label string `json:"label"` + Fields []FormField `json:"fields"` +} + +// FieldCondition controls conditional visibility of a form field. +// When set, the field is shown/validated only if the condition is met. +type FieldCondition struct { + When string `json:"when"` // key of the field to check + Op string `json:"op,omitempty"` // eq (default) | neq | in | exists + Value any `json:"value,omitempty"` // value to compare against +} + +// FormField is a single field in a typed form template. +type FormField struct { + Key string `json:"key"` + Type string `json:"type"` // text | email | select | number | date | textarea | checkbox | file + Label string `json:"label"` + Placeholder string `json:"placeholder,omitempty"` + Required bool `json:"required,omitempty"` + Default interface{} `json:"default,omitempty"` + Options []FormOption `json:"options,omitempty"` + Validation *FormValidation `json:"validation,omitempty"` + Condition *FieldCondition `json:"condition,omitempty"` +} + +// FormOption is a choice for select fields. +type FormOption struct { + Value string `json:"value"` + Label string `json:"label"` +} + +// FormValidation holds type-specific validation rules. +type FormValidation struct { + MinLength *int `json:"min_length,omitempty"` // text, textarea, email + MaxLength *int `json:"max_length,omitempty"` // text, textarea, email + Pattern string `json:"pattern,omitempty"` // text, email (regex) + Min *float64 `json:"min,omitempty"` // number + Max *float64 `json:"max,omitempty"` // number + Step *float64 `json:"step,omitempty"` // number + MinDate string `json:"min_date,omitempty"` // date (YYYY-MM-DD) + MaxDate string `json:"max_date,omitempty"` // date (YYYY-MM-DD) + Accept string `json:"accept,omitempty"` // file (MIME types) + MaxSize *int64 `json:"max_size_bytes,omitempty"` // file +} + +// FormHooks wires Starlark validation/submission hooks for a form. +type FormHooks struct { + PackageID string `json:"package_id"` + Validate string `json:"validate,omitempty"` // entry point name + OnSubmit string `json:"on_submit,omitempty"` // entry point name +} + +// ValidFormFieldTypes is the set of recognized field types. +var ValidFormFieldTypes = map[string]bool{ + "text": true, "email": true, "select": true, "number": true, + "date": true, "textarea": true, "checkbox": true, "file": true, +} + +// ── Parsing ────────────────────────────────── + +// ParseTypedFormTemplate parses a raw JSON form_template into the typed schema. +// Returns nil if the template is empty, legacy format, or not a typed template. +func ParseTypedFormTemplate(raw json.RawMessage) *TypedFormTemplate { + if len(raw) == 0 || string(raw) == "{}" || string(raw) == "null" { + return nil + } + var tpl TypedFormTemplate + if err := json.Unmarshal(raw, &tpl); err != nil { + return nil + } + + if len(tpl.Fieldsets) > 0 { + var allFields []FormField + for _, fs := range tpl.Fieldsets { + allFields = append(allFields, fs.Fields...) + } + if len(allFields) > 0 { + tpl.Fields = allFields + return &tpl + } + } + + if len(tpl.Fields) == 0 { + return nil + } + // Verify this is actually the typed format (first field must have key+type) + if tpl.Fields[0].Key == "" || tpl.Fields[0].Type == "" { + return nil + } + return &tpl +} + +// AllFields returns all fields from the template, whether from top-level fields or fieldsets. +func (t *TypedFormTemplate) AllFields() []FormField { + return t.Fields // ParseTypedFormTemplate already flattens fieldsets into Fields +} + +// ── Validation ─────────────────────────────── + +// FieldError is a validation error for a specific form field. +type FieldError struct { + Key string `json:"key"` + Message string `json:"message"` +} + +// ValidateFormData validates submitted data against a typed form template. +func ValidateFormData(tpl *TypedFormTemplate, data map[string]interface{}) []FieldError { + var errs []FieldError + for _, f := range tpl.Fields { + if f.Condition != nil && !evaluateFieldCondition(f.Condition, data) { + continue + } + + val, present := data[f.Key] + + // Required check + if f.Required && (!present || val == nil || val == "") { + errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " is required"}) + continue + } + if !present || val == nil { + continue + } + + switch f.Type { + case "text", "textarea": + errs = append(errs, validateString(f, val)...) + case "email": + errs = append(errs, validateEmail(f, val)...) + case "number": + errs = append(errs, validateNumber(f, val)...) + case "date": + errs = append(errs, validateDate(f, val)...) + case "select": + errs = append(errs, validateSelect(f, val)...) + case "checkbox": + if _, ok := val.(bool); !ok { + errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " must be true or false"}) + } + } + } + return errs +} + +func validateString(f FormField, val interface{}) []FieldError { + s, ok := val.(string) + if !ok { + return []FieldError{{Key: f.Key, Message: f.Label + " must be text"}} + } + var errs []FieldError + v := f.Validation + if v == nil { + return nil + } + if v.MinLength != nil && len(s) < *v.MinLength { + errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at least %d characters", f.Label, *v.MinLength)}) + } + if v.MaxLength != nil && len(s) > *v.MaxLength { + errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at most %d characters", f.Label, *v.MaxLength)}) + } + if v.Pattern != "" { + if re, err := regexp.Compile(v.Pattern); err == nil && !re.MatchString(s) { + errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " does not match the required pattern"}) + } + } + return errs +} + +var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`) + +func validateEmail(f FormField, val interface{}) []FieldError { + s, ok := val.(string) + if !ok { + return []FieldError{{Key: f.Key, Message: f.Label + " must be text"}} + } + if !emailRe.MatchString(s) { + return []FieldError{{Key: f.Key, Message: f.Label + " must be a valid email address"}} + } + var errs []FieldError + if f.Validation != nil { + if f.Validation.Pattern != "" { + if re, err := regexp.Compile(f.Validation.Pattern); err == nil && !re.MatchString(s) { + errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " does not match the required pattern"}) + } + } + } + return errs +} + +func validateNumber(f FormField, val interface{}) []FieldError { + var n float64 + switch v := val.(type) { + case float64: + n = v + case int: + n = float64(v) + case json.Number: + var err error + n, err = v.Float64() + if err != nil { + return []FieldError{{Key: f.Key, Message: f.Label + " must be a number"}} + } + case string: + return []FieldError{{Key: f.Key, Message: f.Label + " must be a number"}} + default: + return []FieldError{{Key: f.Key, Message: f.Label + " must be a number"}} + } + var errs []FieldError + if f.Validation != nil { + if f.Validation.Min != nil && n < *f.Validation.Min { + errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at least %g", f.Label, *f.Validation.Min)}) + } + if f.Validation.Max != nil && n > *f.Validation.Max { + errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at most %g", f.Label, *f.Validation.Max)}) + } + } + return errs +} + +func validateDate(f FormField, val interface{}) []FieldError { + s, ok := val.(string) + if !ok { + return []FieldError{{Key: f.Key, Message: f.Label + " must be a date string (YYYY-MM-DD)"}} + } + _, err := time.Parse("2006-01-02", s) + if err != nil { + return []FieldError{{Key: f.Key, Message: f.Label + " must be a valid date (YYYY-MM-DD)"}} + } + if f.Validation != nil { + if f.Validation.MinDate != "" { + if minD, e := time.Parse("2006-01-02", f.Validation.MinDate); e == nil { + if d, _ := time.Parse("2006-01-02", s); d.Before(minD) { + return []FieldError{{Key: f.Key, Message: fmt.Sprintf("%s must be on or after %s", f.Label, f.Validation.MinDate)}} + } + } + } + if f.Validation.MaxDate != "" { + if maxD, e := time.Parse("2006-01-02", f.Validation.MaxDate); e == nil { + if d, _ := time.Parse("2006-01-02", s); d.After(maxD) { + return []FieldError{{Key: f.Key, Message: fmt.Sprintf("%s must be on or before %s", f.Label, f.Validation.MaxDate)}} + } + } + } + } + return nil +} + +func validateSelect(f FormField, val interface{}) []FieldError { + s, ok := val.(string) + if !ok { + return []FieldError{{Key: f.Key, Message: f.Label + " must be a string"}} + } + if len(f.Options) == 0 { + return nil + } + for _, o := range f.Options { + if strings.EqualFold(o.Value, s) { + return nil + } + } + return []FieldError{{Key: f.Key, Message: f.Label + " is not a valid option"}} +} + +// ── Field Condition Evaluator ───── + +// EvaluateFieldCondition checks if a conditional field should be visible/validated. +func EvaluateFieldCondition(cond *FieldCondition, data map[string]interface{}) bool { + return evaluateFieldCondition(cond, data) +} + +func evaluateFieldCondition(cond *FieldCondition, data map[string]interface{}) bool { + val, exists := data[cond.When] + op := cond.Op + if op == "" { + op = "eq" + } + + switch op { + case "exists": + return exists + case "eq": + if !exists { + return false + } + return fmt.Sprintf("%v", val) == fmt.Sprintf("%v", cond.Value) + case "neq": + if !exists { + return true + } + return fmt.Sprintf("%v", val) != fmt.Sprintf("%v", cond.Value) + case "in": + if !exists { + return false + } + arr, ok := cond.Value.([]interface{}) + if !ok { + return false + } + vs := fmt.Sprintf("%v", val) + for _, item := range arr { + if fmt.Sprintf("%v", item) == vs { + return true + } + } + return false + default: + return true + } +} diff --git a/server/forms/forms_test.go b/server/forms/forms_test.go new file mode 100644 index 0000000..115aaea --- /dev/null +++ b/server/forms/forms_test.go @@ -0,0 +1,322 @@ +package forms + +import ( + "encoding/json" + "testing" +) + +// ── Parse Tests ────────────────────────────── + +func TestParseTypedFormTemplate_Empty(t *testing.T) { + for _, input := range []string{"", "{}", "null"} { + if tpl := ParseTypedFormTemplate(json.RawMessage(input)); tpl != nil { + t.Errorf("expected nil for %q, got %+v", input, tpl) + } + } +} + +func TestParseTypedFormTemplate_InvalidJSON(t *testing.T) { + if tpl := ParseTypedFormTemplate(json.RawMessage(`{bad`)); tpl != nil { + t.Fatal("expected nil for invalid JSON") + } +} + +func TestParseTypedFormTemplate_FlatFields(t *testing.T) { + raw := json.RawMessage(`{ + "fields": [ + {"key": "name", "type": "text", "label": "Name"}, + {"key": "email", "type": "email", "label": "Email"} + ] + }`) + tpl := ParseTypedFormTemplate(raw) + if tpl == nil { + t.Fatal("expected non-nil template") + } + if len(tpl.Fields) != 2 { + t.Fatalf("expected 2 fields, got %d", len(tpl.Fields)) + } +} + +func TestParseTypedFormTemplate_Fieldsets(t *testing.T) { + raw := json.RawMessage(`{ + "fieldsets": [ + {"label": "Step 1", "fields": [{"key": "a", "type": "text", "label": "A"}]}, + {"label": "Step 2", "fields": [{"key": "b", "type": "number", "label": "B"}]} + ] + }`) + tpl := ParseTypedFormTemplate(raw) + if tpl == nil { + t.Fatal("expected non-nil template") + } + if len(tpl.Fields) != 2 { + t.Fatalf("expected 2 flattened fields, got %d", len(tpl.Fields)) + } + if len(tpl.Fieldsets) != 2 { + t.Fatalf("expected 2 fieldsets, got %d", len(tpl.Fieldsets)) + } +} + +func TestParseTypedFormTemplate_LegacyFormat(t *testing.T) { + // Legacy format: fields present but missing key/type → returns nil + raw := json.RawMessage(`{"fields": [{"label": "Name"}]}`) + if tpl := ParseTypedFormTemplate(raw); tpl != nil { + t.Fatal("expected nil for legacy format") + } +} + +// ── Validation Tests ───────────────────────── + +func tpl(fields ...FormField) *TypedFormTemplate { + return &TypedFormTemplate{Fields: fields} +} + +func intPtr(n int) *int { return &n } +func floatPtr(n float64) *float64 { return &n } + +func TestValidateFormData_Required(t *testing.T) { + template := tpl(FormField{Key: "name", Type: "text", Label: "Name", Required: true}) + + // Missing + errs := ValidateFormData(template, map[string]interface{}{}) + if len(errs) != 1 || errs[0].Key != "name" { + t.Fatalf("expected 1 error for missing required field, got %+v", errs) + } + + // Present + errs = ValidateFormData(template, map[string]interface{}{"name": "Alice"}) + if len(errs) != 0 { + t.Fatalf("expected 0 errors, got %+v", errs) + } + + // Empty string counts as missing + errs = ValidateFormData(template, map[string]interface{}{"name": ""}) + if len(errs) != 1 { + t.Fatalf("expected 1 error for empty required field, got %+v", errs) + } +} + +func TestValidateFormData_Text(t *testing.T) { + template := tpl(FormField{ + Key: "bio", Type: "text", Label: "Bio", + Validation: &FormValidation{MinLength: intPtr(3), MaxLength: intPtr(10), Pattern: `^[a-z]+$`}, + }) + + // Too short + errs := ValidateFormData(template, map[string]interface{}{"bio": "ab"}) + if len(errs) != 1 { + t.Fatalf("expected min_length error, got %+v", errs) + } + + // Too long + errs = ValidateFormData(template, map[string]interface{}{"bio": "abcdefghijk"}) + if len(errs) != 1 { + t.Fatalf("expected max_length error, got %+v", errs) + } + + // Pattern mismatch + errs = ValidateFormData(template, map[string]interface{}{"bio": "ABC"}) + if len(errs) != 1 { + t.Fatalf("expected pattern error, got %+v", errs) + } + + // Valid + errs = ValidateFormData(template, map[string]interface{}{"bio": "hello"}) + if len(errs) != 0 { + t.Fatalf("expected 0 errors, got %+v", errs) + } +} + +func TestValidateFormData_Email(t *testing.T) { + template := tpl(FormField{Key: "email", Type: "email", Label: "Email"}) + + errs := ValidateFormData(template, map[string]interface{}{"email": "bad"}) + if len(errs) != 1 { + t.Fatalf("expected email error, got %+v", errs) + } + + errs = ValidateFormData(template, map[string]interface{}{"email": "a@b.c"}) + if len(errs) != 0 { + t.Fatalf("expected 0 errors, got %+v", errs) + } +} + +func TestValidateFormData_Number(t *testing.T) { + template := tpl(FormField{ + Key: "age", Type: "number", Label: "Age", + Validation: &FormValidation{Min: floatPtr(0), Max: floatPtr(150)}, + }) + + // Below min + errs := ValidateFormData(template, map[string]interface{}{"age": float64(-1)}) + if len(errs) != 1 { + t.Fatalf("expected min error, got %+v", errs) + } + + // Above max + errs = ValidateFormData(template, map[string]interface{}{"age": float64(200)}) + if len(errs) != 1 { + t.Fatalf("expected max error, got %+v", errs) + } + + // Non-number + errs = ValidateFormData(template, map[string]interface{}{"age": "thirty"}) + if len(errs) != 1 { + t.Fatalf("expected type error, got %+v", errs) + } + + // Valid + errs = ValidateFormData(template, map[string]interface{}{"age": float64(25)}) + if len(errs) != 0 { + t.Fatalf("expected 0 errors, got %+v", errs) + } +} + +func TestValidateFormData_Date(t *testing.T) { + template := tpl(FormField{ + Key: "start", Type: "date", Label: "Start", + Validation: &FormValidation{MinDate: "2024-01-01", MaxDate: "2024-12-31"}, + }) + + // Invalid format + errs := ValidateFormData(template, map[string]interface{}{"start": "Jan 1"}) + if len(errs) != 1 { + t.Fatalf("expected format error, got %+v", errs) + } + + // Before min + errs = ValidateFormData(template, map[string]interface{}{"start": "2023-12-31"}) + if len(errs) != 1 { + t.Fatalf("expected min_date error, got %+v", errs) + } + + // After max + errs = ValidateFormData(template, map[string]interface{}{"start": "2025-01-01"}) + if len(errs) != 1 { + t.Fatalf("expected max_date error, got %+v", errs) + } + + // Valid + errs = ValidateFormData(template, map[string]interface{}{"start": "2024-06-15"}) + if len(errs) != 0 { + t.Fatalf("expected 0 errors, got %+v", errs) + } +} + +func TestValidateFormData_Select(t *testing.T) { + template := tpl(FormField{ + Key: "color", Type: "select", Label: "Color", + Options: []FormOption{{Value: "red", Label: "Red"}, {Value: "blue", Label: "Blue"}}, + }) + + errs := ValidateFormData(template, map[string]interface{}{"color": "green"}) + if len(errs) != 1 { + t.Fatalf("expected invalid option error, got %+v", errs) + } + + // Case-insensitive match + errs = ValidateFormData(template, map[string]interface{}{"color": "Red"}) + if len(errs) != 0 { + t.Fatalf("expected 0 errors (case-insensitive), got %+v", errs) + } +} + +func TestValidateFormData_Checkbox(t *testing.T) { + template := tpl(FormField{Key: "agree", Type: "checkbox", Label: "Agree"}) + + errs := ValidateFormData(template, map[string]interface{}{"agree": "yes"}) + if len(errs) != 1 { + t.Fatalf("expected type error, got %+v", errs) + } + + errs = ValidateFormData(template, map[string]interface{}{"agree": true}) + if len(errs) != 0 { + t.Fatalf("expected 0 errors, got %+v", errs) + } +} + +func TestValidateFormData_Condition(t *testing.T) { + template := tpl( + FormField{Key: "type", Type: "select", Label: "Type", Options: []FormOption{{Value: "normal", Label: "Normal"}, {Value: "other", Label: "Other"}}}, + FormField{ + Key: "details", Type: "text", Label: "Details", Required: true, + Condition: &FieldCondition{When: "type", Op: "eq", Value: "other"}, + }, + ) + + // Condition not met → required field skipped + errs := ValidateFormData(template, map[string]interface{}{"type": "normal"}) + if len(errs) != 0 { + t.Fatalf("expected 0 errors (condition not met), got %+v", errs) + } + + // Condition met → required enforced + errs = ValidateFormData(template, map[string]interface{}{"type": "other"}) + if len(errs) != 1 || errs[0].Key != "details" { + t.Fatalf("expected 1 error for conditional required field, got %+v", errs) + } + + // Condition met + value provided + errs = ValidateFormData(template, map[string]interface{}{"type": "other", "details": "More info"}) + if len(errs) != 0 { + t.Fatalf("expected 0 errors, got %+v", errs) + } +} + +func TestEvaluateFieldCondition_Operators(t *testing.T) { + data := map[string]interface{}{"status": "active", "role": "admin"} + + tests := []struct { + name string + cond FieldCondition + expect bool + }{ + {"eq match", FieldCondition{When: "status", Op: "eq", Value: "active"}, true}, + {"eq mismatch", FieldCondition{When: "status", Op: "eq", Value: "inactive"}, false}, + {"neq match", FieldCondition{When: "status", Op: "neq", Value: "inactive"}, true}, + {"neq mismatch", FieldCondition{When: "status", Op: "neq", Value: "active"}, false}, + {"exists present", FieldCondition{When: "status", Op: "exists"}, true}, + {"exists absent", FieldCondition{When: "missing", Op: "exists"}, false}, + {"in match", FieldCondition{When: "role", Op: "in", Value: []interface{}{"admin", "mod"}}, true}, + {"in mismatch", FieldCondition{When: "role", Op: "in", Value: []interface{}{"user", "guest"}}, false}, + {"default op is eq", FieldCondition{When: "status", Value: "active"}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := EvaluateFieldCondition(&tt.cond, data) + if got != tt.expect { + t.Errorf("expected %v, got %v", tt.expect, got) + } + }) + } +} + +func TestAllFields(t *testing.T) { + raw := json.RawMessage(`{ + "fieldsets": [ + {"label": "A", "fields": [{"key": "x", "type": "text", "label": "X"}]}, + {"label": "B", "fields": [{"key": "y", "type": "text", "label": "Y"}, {"key": "z", "type": "text", "label": "Z"}]} + ] + }`) + tpl := ParseTypedFormTemplate(raw) + if tpl == nil { + t.Fatal("expected non-nil template") + } + all := tpl.AllFields() + if len(all) != 3 { + t.Fatalf("expected 3 fields, got %d", len(all)) + } +} + +func TestValidateFormData_OptionalSkipped(t *testing.T) { + template := tpl(FormField{ + Key: "note", Type: "text", Label: "Note", + Validation: &FormValidation{MinLength: intPtr(5)}, + }) + + // Not present and not required → no error + errs := ValidateFormData(template, map[string]interface{}{}) + if len(errs) != 0 { + t.Fatalf("expected 0 errors for absent optional field, got %+v", errs) + } +} diff --git a/server/handlers/forms.go b/server/handlers/forms.go new file mode 100644 index 0000000..1ced812 --- /dev/null +++ b/server/handlers/forms.go @@ -0,0 +1,63 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/gin-gonic/gin" + + "armature/forms" +) + +// FormsHandler provides REST endpoints for the standalone forms system. +type FormsHandler struct{} + +// formsValidateRequest is the request body for POST /api/v1/forms/validate. +type formsValidateRequest struct { + Template json.RawMessage `json:"template"` + Data map[string]interface{} `json:"data"` +} + +// formsValidateResponse is the response body for POST /api/v1/forms/validate. +type formsValidateResponse struct { + Valid bool `json:"valid"` + Errors []forms.FieldError `json:"errors"` +} + +// Validate validates form data against a typed form template. +// +// POST /api/v1/forms/validate +// Body: { "template": {...}, "data": {...} } +// Response: { "valid": true/false, "errors": [...] } +func (h *FormsHandler) Validate(c *gin.Context) { + var req formsValidateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + if len(req.Template) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "template is required"}) + return + } + + tpl := forms.ParseTypedFormTemplate(req.Template) + if tpl == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "template is invalid or empty"}) + return + } + + if req.Data == nil { + req.Data = make(map[string]interface{}) + } + + errs := forms.ValidateFormData(tpl, req.Data) + if errs == nil { + errs = []forms.FieldError{} + } + + c.JSON(http.StatusOK, formsValidateResponse{ + Valid: len(errs) == 0, + Errors: errs, + }) +} diff --git a/server/handlers/package_validate.go b/server/handlers/package_validate.go index 53b9772..30559b8 100644 --- a/server/handlers/package_validate.go +++ b/server/handlers/package_validate.go @@ -5,6 +5,8 @@ import ( "fmt" "regexp" "strings" + + "armature/forms" ) // validManifestID matches lowercase alphanumeric slugs with optional hyphens. @@ -35,6 +37,7 @@ type ManifestInfo struct { GatePermission string // if set, checks this user permission before calling on_request RequiresRoles []string // team roles needed to access this package (advisory, OR semantics) Adoptable bool // if true, teams can adopt this package to get a team-scoped copy + HasFormTemplate bool // if true, package declares a form_template at the top level } // ValidateManifest parses a manifest map and validates all required fields, @@ -184,6 +187,18 @@ func ValidateManifest(manifest map[string]any) (*ManifestInfo, error) { info.Adoptable = v } + // v0.9.5: form_template — any package can declare a typed form template + if manifest["form_template"] != nil { + raw, err := json.Marshal(manifest["form_template"]) + if err != nil { + return nil, fmt.Errorf("form_template is invalid JSON") + } + if tpl := forms.ParseTypedFormTemplate(raw); tpl == nil { + return nil, fmt.Errorf("form_template is invalid or empty") + } + info.HasFormTemplate = true + } + info.SchemaVersion = ParseSchemaVersion(manifest) // ── Type-specific constraints ──────────────────────────────── diff --git a/server/main.go b/server/main.go index 2f8c1a4..fb3b08c 100644 --- a/server/main.go +++ b/server/main.go @@ -545,6 +545,10 @@ func main() { protected.POST("/instances/:iid/signoffs", wfSignoffH.Submit) protected.GET("/instances/:iid/signoffs", wfSignoffH.List) + // Forms validation + formsH := &handlers.FormsHandler{} + protected.POST("/forms/validate", formsH.Validate) + // Surface discovery pkgH := handlers.NewPackageHandler(stores) protected.GET("/surfaces", pkgH.ListEnabledSurfaces) diff --git a/server/models/workflow.go b/server/models/workflow.go index f4b4413..95c297d 100644 --- a/server/models/workflow.go +++ b/server/models/workflow.go @@ -2,9 +2,6 @@ package models import ( "encoding/json" - "fmt" - "regexp" - "strings" "time" ) @@ -118,320 +115,6 @@ var ValidAudiences = map[string]bool{ AudienceSystem: true, } -// ── Typed Form Template ───────────────────── - -// TypedFormTemplate is the structured form_template schema. -type TypedFormTemplate struct { - Fields []FormField `json:"fields"` - Fieldsets []FormFieldset `json:"fieldsets,omitempty"` - Hooks *FormHooks `json:"hooks,omitempty"` -} - -// FormFieldset groups fields into a labelled step for progressive forms. -// When fieldsets is present, top-level fields is ignored. -type FormFieldset struct { - Label string `json:"label"` - Fields []FormField `json:"fields"` -} - -// FieldCondition controls conditional visibility of a form field. -// When set, the field is shown/validated only if the condition is met. -type FieldCondition struct { - When string `json:"when"` // key of the field to check - Op string `json:"op,omitempty"` // eq (default) | neq | in | exists - Value any `json:"value,omitempty"` // value to compare against -} - -// FormField is a single field in a typed form template. -type FormField struct { - Key string `json:"key"` - Type string `json:"type"` // text | email | select | number | date | textarea | checkbox | file - Label string `json:"label"` - Placeholder string `json:"placeholder,omitempty"` - Required bool `json:"required,omitempty"` - Default interface{} `json:"default,omitempty"` - Options []FormOption `json:"options,omitempty"` - Validation *FormValidation `json:"validation,omitempty"` - Condition *FieldCondition `json:"condition,omitempty"` -} - -// FormOption is a choice for select fields. -type FormOption struct { - Value string `json:"value"` - Label string `json:"label"` -} - -// FormValidation holds type-specific validation rules. -type FormValidation struct { - MinLength *int `json:"min_length,omitempty"` // text, textarea, email - MaxLength *int `json:"max_length,omitempty"` // text, textarea, email - Pattern string `json:"pattern,omitempty"` // text, email (regex) - Min *float64 `json:"min,omitempty"` // number - Max *float64 `json:"max,omitempty"` // number - Step *float64 `json:"step,omitempty"` // number - MinDate string `json:"min_date,omitempty"` // date (YYYY-MM-DD) - MaxDate string `json:"max_date,omitempty"` // date (YYYY-MM-DD) - Accept string `json:"accept,omitempty"` // file (MIME types) - MaxSize *int64 `json:"max_size_bytes,omitempty"` // file -} - -// FormHooks wires Starlark validation/submission hooks for a stage's form. -type FormHooks struct { - PackageID string `json:"package_id"` - Validate string `json:"validate,omitempty"` // entry point name - OnSubmit string `json:"on_submit,omitempty"` // entry point name -} - -// ValidFormFieldTypes is the set of recognized field types. -var ValidFormFieldTypes = map[string]bool{ - "text": true, "email": true, "select": true, "number": true, - "date": true, "textarea": true, "checkbox": true, "file": true, -} - -// ParseTypedFormTemplate parses a raw JSON form_template into the typed schema. -// Returns nil if the template is empty, legacy format, or not a typed template. -func ParseTypedFormTemplate(raw json.RawMessage) *TypedFormTemplate { - if len(raw) == 0 || string(raw) == "{}" || string(raw) == "null" { - return nil - } - var tpl TypedFormTemplate - if err := json.Unmarshal(raw, &tpl); err != nil { - return nil - } - - if len(tpl.Fieldsets) > 0 { - var allFields []FormField - for _, fs := range tpl.Fieldsets { - allFields = append(allFields, fs.Fields...) - } - if len(allFields) > 0 { - tpl.Fields = allFields - return &tpl - } - } - - if len(tpl.Fields) == 0 { - return nil - } - // Verify this is actually the typed format (first field must have key+type) - if tpl.Fields[0].Key == "" || tpl.Fields[0].Type == "" { - return nil - } - return &tpl -} - -// AllFields returns all fields from the template, whether from top-level fields or fieldsets. -func (t *TypedFormTemplate) AllFields() []FormField { - return t.Fields // ParseTypedFormTemplate already flattens fieldsets into Fields -} - -// FieldError is a validation error for a specific form field. -type FieldError struct { - Key string `json:"key"` - Message string `json:"message"` -} - -// ValidateFormData validates submitted data against a typed form template. -func ValidateFormData(tpl *TypedFormTemplate, data map[string]interface{}) []FieldError { - var errs []FieldError - for _, f := range tpl.Fields { - if f.Condition != nil && !evaluateFieldCondition(f.Condition, data) { - continue - } - - val, present := data[f.Key] - - // Required check - if f.Required && (!present || val == nil || val == "") { - errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " is required"}) - continue - } - if !present || val == nil { - continue - } - - switch f.Type { - case "text", "textarea": - errs = append(errs, validateString(f, val)...) - case "email": - errs = append(errs, validateEmail(f, val)...) - case "number": - errs = append(errs, validateNumber(f, val)...) - case "date": - errs = append(errs, validateDate(f, val)...) - case "select": - errs = append(errs, validateSelect(f, val)...) - case "checkbox": - if _, ok := val.(bool); !ok { - errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " must be true or false"}) - } - } - } - return errs -} - -func validateString(f FormField, val interface{}) []FieldError { - s, ok := val.(string) - if !ok { - return []FieldError{{Key: f.Key, Message: f.Label + " must be text"}} - } - var errs []FieldError - v := f.Validation - if v == nil { - return nil - } - if v.MinLength != nil && len(s) < *v.MinLength { - errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at least %d characters", f.Label, *v.MinLength)}) - } - if v.MaxLength != nil && len(s) > *v.MaxLength { - errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at most %d characters", f.Label, *v.MaxLength)}) - } - if v.Pattern != "" { - if re, err := regexp.Compile(v.Pattern); err == nil && !re.MatchString(s) { - errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " does not match the required pattern"}) - } - } - return errs -} - -var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`) - -func validateEmail(f FormField, val interface{}) []FieldError { - s, ok := val.(string) - if !ok { - return []FieldError{{Key: f.Key, Message: f.Label + " must be text"}} - } - if !emailRe.MatchString(s) { - return []FieldError{{Key: f.Key, Message: f.Label + " must be a valid email address"}} - } - var errs []FieldError - if f.Validation != nil { - if f.Validation.Pattern != "" { - if re, err := regexp.Compile(f.Validation.Pattern); err == nil && !re.MatchString(s) { - errs = append(errs, FieldError{Key: f.Key, Message: f.Label + " does not match the required pattern"}) - } - } - } - return errs -} - -func validateNumber(f FormField, val interface{}) []FieldError { - var n float64 - switch v := val.(type) { - case float64: - n = v - case int: - n = float64(v) - case json.Number: - var err error - n, err = v.Float64() - if err != nil { - return []FieldError{{Key: f.Key, Message: f.Label + " must be a number"}} - } - case string: - return []FieldError{{Key: f.Key, Message: f.Label + " must be a number"}} - default: - return []FieldError{{Key: f.Key, Message: f.Label + " must be a number"}} - } - var errs []FieldError - if f.Validation != nil { - if f.Validation.Min != nil && n < *f.Validation.Min { - errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at least %g", f.Label, *f.Validation.Min)}) - } - if f.Validation.Max != nil && n > *f.Validation.Max { - errs = append(errs, FieldError{Key: f.Key, Message: fmt.Sprintf("%s must be at most %g", f.Label, *f.Validation.Max)}) - } - } - return errs -} - -func validateDate(f FormField, val interface{}) []FieldError { - s, ok := val.(string) - if !ok { - return []FieldError{{Key: f.Key, Message: f.Label + " must be a date string (YYYY-MM-DD)"}} - } - _, err := time.Parse("2006-01-02", s) - if err != nil { - return []FieldError{{Key: f.Key, Message: f.Label + " must be a valid date (YYYY-MM-DD)"}} - } - if f.Validation != nil { - if f.Validation.MinDate != "" { - if minD, e := time.Parse("2006-01-02", f.Validation.MinDate); e == nil { - if d, _ := time.Parse("2006-01-02", s); d.Before(minD) { - return []FieldError{{Key: f.Key, Message: fmt.Sprintf("%s must be on or after %s", f.Label, f.Validation.MinDate)}} - } - } - } - if f.Validation.MaxDate != "" { - if maxD, e := time.Parse("2006-01-02", f.Validation.MaxDate); e == nil { - if d, _ := time.Parse("2006-01-02", s); d.After(maxD) { - return []FieldError{{Key: f.Key, Message: fmt.Sprintf("%s must be on or before %s", f.Label, f.Validation.MaxDate)}} - } - } - } - } - return nil -} - -func validateSelect(f FormField, val interface{}) []FieldError { - s, ok := val.(string) - if !ok { - return []FieldError{{Key: f.Key, Message: f.Label + " must be a string"}} - } - if len(f.Options) == 0 { - return nil - } - for _, o := range f.Options { - if strings.EqualFold(o.Value, s) { - return nil - } - } - return []FieldError{{Key: f.Key, Message: f.Label + " is not a valid option"}} -} - -// ── Field Condition Evaluator ───── - -// evaluateFieldCondition checks if a conditional field should be visible/validated. -func evaluateFieldCondition(cond *FieldCondition, data map[string]interface{}) bool { - val, exists := data[cond.When] - op := cond.Op - if op == "" { - op = "eq" - } - - switch op { - case "exists": - return exists - case "eq": - if !exists { - return false - } - return fmt.Sprintf("%v", val) == fmt.Sprintf("%v", cond.Value) - case "neq": - if !exists { - return true - } - return fmt.Sprintf("%v", val) != fmt.Sprintf("%v", cond.Value) - case "in": - if !exists { - return false - } - arr, ok := cond.Value.([]interface{}) - if !ok { - return false - } - vs := fmt.Sprintf("%v", val) - for _, item := range arr { - if fmt.Sprintf("%v", item) == vs { - return true - } - } - return false - default: - return true - } -} - // ── Instance Status Constants ─────────────── const ( diff --git a/server/sandbox/forms_module.go b/server/sandbox/forms_module.go new file mode 100644 index 0000000..cb6de69 --- /dev/null +++ b/server/sandbox/forms_module.go @@ -0,0 +1,86 @@ +package sandbox + +// forms_module.go +// +// Starlark forms module — validates form data against typed form templates. +// Gated by the forms.validate extension permission. +// +// Starlark API: +// result = forms.validate(template, data) +// # result["valid"] → True/False +// # result["errors"] → [{"key": "...", "message": "..."}, ...] + +import ( + "context" + "encoding/json" + "fmt" + + "go.starlark.net/starlark" + "go.starlark.net/starlarkstruct" + + "armature/forms" +) + +// BuildFormsModule creates the "forms" Starlark module. +// Requires the forms.validate permission. +func BuildFormsModule(_ context.Context) *starlarkstruct.Module { + return MakeModule("forms", starlark.StringDict{ + "validate": starlark.NewBuiltin("forms.validate", formsValidateBuiltin()), + }) +} + +func formsValidateBuiltin() func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) { + return func(_ *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + var templateVal, dataVal starlark.Value + if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 2, &templateVal, &dataVal); err != nil { + return nil, err + } + + // Convert template dict → Go map → JSON → TypedFormTemplate + templateDict, ok := templateVal.(*starlark.Dict) + if !ok { + return nil, fmt.Errorf("forms.validate: template must be a dict, got %s", templateVal.Type()) + } + templateMap := DictToMap(templateDict) + templateJSON, err := json.Marshal(templateMap) + if err != nil { + return nil, fmt.Errorf("forms.validate: failed to marshal template: %w", err) + } + + tpl := forms.ParseTypedFormTemplate(json.RawMessage(templateJSON)) + if tpl == nil { + return nil, fmt.Errorf("forms.validate: template is invalid or empty") + } + + // Convert data dict → Go map + dataDict, ok := dataVal.(*starlark.Dict) + if !ok { + return nil, fmt.Errorf("forms.validate: data must be a dict, got %s", dataVal.Type()) + } + dataMap := DictToMap(dataDict) + + // Coerce to map[string]interface{} + data := make(map[string]interface{}, len(dataMap)) + for k, v := range dataMap { + data[k] = v + } + + // Validate + errs := forms.ValidateFormData(tpl, data) + + // Build result dict + errList := make([]starlark.Value, len(errs)) + for i, e := range errs { + d := starlark.NewDict(2) + _ = d.SetKey(starlark.String("key"), starlark.String(e.Key)) + _ = d.SetKey(starlark.String("message"), starlark.String(e.Message)) + errList[i] = d + } + + result := starlark.NewDict(2) + _ = result.SetKey(starlark.String("valid"), starlark.Bool(len(errs) == 0)) + _ = result.SetKey(starlark.String("errors"), starlark.NewList(errList)) + + return result, nil + } +} diff --git a/server/sandbox/forms_module_test.go b/server/sandbox/forms_module_test.go new file mode 100644 index 0000000..b15e183 --- /dev/null +++ b/server/sandbox/forms_module_test.go @@ -0,0 +1,116 @@ +package sandbox + +import ( + "context" + "testing" + + "go.starlark.net/starlark" +) + +func runFormsScript(t *testing.T, script string) starlark.StringDict { + t.Helper() + mod := BuildFormsModule(context.Background()) + predeclared := starlark.StringDict{"forms": mod} + globals, err := starlark.ExecFile(&starlark.Thread{Name: "test"}, "test.star", script, predeclared) + if err != nil { + t.Fatal(err) + } + return globals +} + +func TestFormsModule_ValidateValid(t *testing.T) { + globals := runFormsScript(t, ` +tpl = { + "fields": [ + {"key": "name", "type": "text", "label": "Name", "required": True}, + {"key": "email", "type": "email", "label": "Email"}, + ] +} +result = forms.validate(tpl, {"name": "Alice", "email": "alice@example.com"}) +`) + result := globals["result"].(*starlark.Dict) + valid, _, _ := result.Get(starlark.String("valid")) + if valid != starlark.True { + t.Fatalf("expected valid=True, got %v", valid) + } + errors, _, _ := result.Get(starlark.String("errors")) + errList := errors.(*starlark.List) + if errList.Len() != 0 { + t.Fatalf("expected 0 errors, got %d", errList.Len()) + } +} + +func TestFormsModule_ValidateRequired(t *testing.T) { + globals := runFormsScript(t, ` +tpl = { + "fields": [ + {"key": "name", "type": "text", "label": "Name", "required": True}, + ] +} +result = forms.validate(tpl, {}) +`) + result := globals["result"].(*starlark.Dict) + valid, _, _ := result.Get(starlark.String("valid")) + if valid != starlark.False { + t.Fatalf("expected valid=False, got %v", valid) + } + errors, _, _ := result.Get(starlark.String("errors")) + errList := errors.(*starlark.List) + if errList.Len() != 1 { + t.Fatalf("expected 1 error, got %d", errList.Len()) + } + errDict := errList.Index(0).(*starlark.Dict) + key, _, _ := errDict.Get(starlark.String("key")) + if key.(starlark.String).GoString() != "name" { + t.Fatalf("expected error key 'name', got %v", key) + } +} + +func TestFormsModule_ValidateTypeErrors(t *testing.T) { + globals := runFormsScript(t, ` +tpl = { + "fields": [ + {"key": "age", "type": "number", "label": "Age", "validation": {"min": 0, "max": 150}}, + {"key": "email", "type": "email", "label": "Email"}, + ] +} +result = forms.validate(tpl, {"age": -5, "email": "bad"}) +`) + result := globals["result"].(*starlark.Dict) + valid, _, _ := result.Get(starlark.String("valid")) + if valid != starlark.False { + t.Fatalf("expected valid=False, got %v", valid) + } + errors, _, _ := result.Get(starlark.String("errors")) + errList := errors.(*starlark.List) + if errList.Len() != 2 { + t.Fatalf("expected 2 errors, got %d", errList.Len()) + } +} + +func TestFormsModule_ValidateCondition(t *testing.T) { + globals := runFormsScript(t, ` +tpl = { + "fields": [ + {"key": "type", "type": "text", "label": "Type"}, + {"key": "details", "type": "text", "label": "Details", "required": True, + "condition": {"when": "type", "op": "eq", "value": "other"}}, + ] +} +# Condition not met — details not required +result1 = forms.validate(tpl, {"type": "normal"}) +# Condition met — details required +result2 = forms.validate(tpl, {"type": "other"}) +`) + result1 := globals["result1"].(*starlark.Dict) + valid1, _, _ := result1.Get(starlark.String("valid")) + if valid1 != starlark.True { + t.Fatalf("result1: expected valid=True, got %v", valid1) + } + + result2 := globals["result2"].(*starlark.Dict) + valid2, _, _ := result2.Get(starlark.String("valid")) + if valid2 != starlark.False { + t.Fatalf("result2: expected valid=False, got %v", valid2) + } +} diff --git a/server/sandbox/runner.go b/server/sandbox/runner.go index e596455..e44b18d 100644 --- a/server/sandbox/runner.go +++ b/server/sandbox/runner.go @@ -417,6 +417,9 @@ func (r *Runner) buildModulesWithLibCtx(ctx context.Context, packageID string, m }) } + case models.ExtPermFormValidate: + modules["forms"] = BuildFormsModule(ctx) + case models.ExtPermBatchExec: hasBatchExec = true } diff --git a/src/js/sw/sdk/forms.js b/src/js/sw/sdk/forms.js new file mode 100644 index 0000000..1bc4409 --- /dev/null +++ b/src/js/sw/sdk/forms.js @@ -0,0 +1,343 @@ +// ========================================== +// Armature — SDK Forms Module (v0.9.5) +// ========================================== +// Reusable typed form system. Any package can +// declare and render forms, not just workflows. +// +// Usage: +// const handle = sw.forms.render(container, template, opts); +// const { valid, errors } = sw.forms.validate(template, data); +// const result = await sw.forms.validateRemote(template, data); +// ========================================== + +const { html } = window; +const { h, render: preactRender } = preact; +const { useState, useEffect, useRef } = hooks; + +// ── Field type → HTML input mapping ───────── + +const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; + +function escAttr(s) { + return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/ v.max_length) errs.push({ key: f.key, message: `${f.label} must be at most ${v.max_length} characters` }); + if (v.pattern) { try { if (!new RegExp(v.pattern).test(val)) errs.push({ key: f.key, message: f.label + ' does not match the required pattern' }); } catch (_) {} } + break; + } + case 'email': { + if (typeof val !== 'string') { errs.push({ key: f.key, message: f.label + ' must be text' }); break; } + if (!EMAIL_RE.test(val)) errs.push({ key: f.key, message: f.label + ' must be a valid email address' }); + if (v.pattern) { try { if (!new RegExp(v.pattern).test(val)) errs.push({ key: f.key, message: f.label + ' does not match the required pattern' }); } catch (_) {} } + break; + } + case 'number': { + const n = typeof val === 'number' ? val : parseFloat(val); + if (isNaN(n)) { errs.push({ key: f.key, message: f.label + ' must be a number' }); break; } + if (v.min != null && n < v.min) errs.push({ key: f.key, message: `${f.label} must be at least ${v.min}` }); + if (v.max != null && n > v.max) errs.push({ key: f.key, message: `${f.label} must be at most ${v.max}` }); + break; + } + case 'date': { + if (typeof val !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(val)) { errs.push({ key: f.key, message: f.label + ' must be a valid date (YYYY-MM-DD)' }); break; } + if (v.min_date && val < v.min_date) errs.push({ key: f.key, message: `${f.label} must be on or after ${v.min_date}` }); + if (v.max_date && val > v.max_date) errs.push({ key: f.key, message: `${f.label} must be on or before ${v.max_date}` }); + break; + } + case 'select': { + if (f.options && f.options.length > 0) { + const valid = f.options.some(o => o.value.toLowerCase() === String(val).toLowerCase()); + if (!valid) errs.push({ key: f.key, message: f.label + ' is not a valid option' }); + } + break; + } + case 'checkbox': { + if (typeof val !== 'boolean') errs.push({ key: f.key, message: f.label + ' must be true or false' }); + break; + } + } + return errs; +} + +function validateAll(template, data) { + const fields = getAllFields(template); + const errs = []; + for (const f of fields) { + if (f.condition && !evaluateCondition(f.condition, data)) continue; + const val = data[f.key]; + const present = f.key in data; + + if (f.required && (!present || val === null || val === undefined || val === '')) { + errs.push({ key: f.key, message: (f.label || f.key) + ' is required' }); + continue; + } + if (!present || val === null || val === undefined) continue; + + const v = f.validation || {}; + switch (f.type) { + case 'text': case 'textarea': { + if (typeof val !== 'string') { errs.push({ key: f.key, message: f.label + ' must be text' }); break; } + if (v.min_length != null && val.length < v.min_length) errs.push({ key: f.key, message: `${f.label} must be at least ${v.min_length} characters` }); + if (v.max_length != null && val.length > v.max_length) errs.push({ key: f.key, message: `${f.label} must be at most ${v.max_length} characters` }); + if (v.pattern) { try { if (!new RegExp(v.pattern).test(val)) errs.push({ key: f.key, message: f.label + ' does not match the required pattern' }); } catch (_) {} } + break; + } + case 'email': { + if (typeof val !== 'string') { errs.push({ key: f.key, message: f.label + ' must be text' }); break; } + if (!EMAIL_RE.test(val)) errs.push({ key: f.key, message: f.label + ' must be a valid email address' }); + if (v.pattern) { try { if (!new RegExp(v.pattern).test(val)) errs.push({ key: f.key, message: f.label + ' does not match the required pattern' }); } catch (_) {} } + break; + } + case 'number': { + const n = typeof val === 'number' ? val : parseFloat(val); + if (isNaN(n)) { errs.push({ key: f.key, message: f.label + ' must be a number' }); break; } + if (v.min != null && n < v.min) errs.push({ key: f.key, message: `${f.label} must be at least ${v.min}` }); + if (v.max != null && n > v.max) errs.push({ key: f.key, message: `${f.label} must be at most ${v.max}` }); + break; + } + case 'date': { + if (typeof val !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(val)) { errs.push({ key: f.key, message: f.label + ' must be a valid date (YYYY-MM-DD)' }); break; } + if (v.min_date && val < v.min_date) errs.push({ key: f.key, message: `${f.label} must be on or after ${v.min_date}` }); + if (v.max_date && val > v.max_date) errs.push({ key: f.key, message: `${f.label} must be on or before ${v.max_date}` }); + break; + } + case 'select': { + if (f.options && f.options.length > 0) { + const match = f.options.some(o => o.value.toLowerCase() === String(val).toLowerCase()); + if (!match) errs.push({ key: f.key, message: f.label + ' is not a valid option' }); + } + break; + } + case 'checkbox': { + if (typeof val !== 'boolean') errs.push({ key: f.key, message: f.label + ' must be true or false' }); + break; + } + } + } + return { valid: errs.length === 0, errors: errs }; +} + +function getAllFields(template) { + if (template.fieldsets && template.fieldsets.length > 0) { + return template.fieldsets.flatMap(fs => fs.fields || []); + } + return template.fields || []; +} + +// ── Preact Form Components ────────────────── + +function FormFieldInput({ field, value, onChange, error }) { + const f = field; + const id = 'sw-ff-' + f.key; + + let input; + switch (f.type) { + case 'text': case 'email': case 'date': + input = html` onChange(f.key, e.target.value)} />`; + break; + case 'number': { + const attrs = {}; + if (f.validation?.min != null) attrs.min = f.validation.min; + if (f.validation?.max != null) attrs.max = f.validation.max; + if (f.validation?.step != null) attrs.step = f.validation.step; + input = html` onChange(f.key, e.target.value !== '' ? parseFloat(e.target.value) : null)} />`; + break; + } + case 'textarea': + input = html``; + break; + case 'select': + input = html``; + break; + case 'checkbox': + return html`
+
+ onChange(f.key, e.target.checked)} /> + +
+ ${error ? html`
${error}
` : ''} +
`; + case 'file': { + const accept = f.validation?.accept || ''; + input = html` onChange(f.key, e.target.files?.[0] || null)} />`; + break; + } + default: + input = html` onChange(f.key, e.target.value)} />`; + } + + return html`
+ + ${input} + ${error ? html`
${error}
` : ''} +
`; +} + +function FormRenderer({ template, opts }) { + const allFields = getAllFields(template); + const [data, setData] = useState(() => { + const init = { ...(opts.values || {}) }; + for (const f of allFields) { + if (!(f.key in init) && f.default != null) init[f.key] = f.default; + } + return init; + }); + const [errors, setErrors] = useState({}); + const [step, setStep] = useState(0); + const handleRef = useRef(null); + + const onChange = (key, value) => { + setData(prev => ({ ...prev, [key]: value })); + setErrors(prev => { const next = { ...prev }; delete next[key]; return next; }); + }; + + // Expose control handle + useEffect(() => { + if (handleRef.current) { + handleRef.current.getData = () => { + const out = {}; + for (const f of allFields) { + if (f.condition && !evaluateCondition(f.condition, data)) continue; + if (f.key in data) out[f.key] = data[f.key]; + } + return out; + }; + handleRef.current.setErrors = (errArr) => { + const map = {}; + for (const e of errArr) map[e.key] = e.message; + setErrors(map); + }; + } + }, [data]); + + const hasFieldsets = template.fieldsets && template.fieldsets.length > 0; + + if (hasFieldsets) { + const fs = template.fieldsets[step]; + const isLast = step === template.fieldsets.length - 1; + return html`
+
+ ${fs.label} + Step ${step + 1} of ${template.fieldsets.length} +
+ ${(fs.fields || []).map(f => { + if (f.condition && !evaluateCondition(f.condition, data)) return ''; + return html`<${FormFieldInput} field=${f} value=${data[f.key]} onChange=${onChange} + error=${errors[f.key]} />`; + })} +
+ ${step > 0 ? html`` : ''} + ${!isLast ? html`` : ''} + ${isLast && opts.onSubmit ? html`` : ''} +
+
`; + } + + return html`
+ ${allFields.map(f => { + if (f.condition && !evaluateCondition(f.condition, data)) return ''; + return html`<${FormFieldInput} field=${f} value=${data[f.key]} onChange=${onChange} + error=${errors[f.key]} />`; + })} + ${opts.onSubmit ? html`
+ +
` : ''} +
`; +} + +// ── Public API ────────────────────────────── + +/** + * Creates the sw.forms SDK module. + * @param {object} restClient — the SDK REST client + */ +export function createForms(restClient) { + return { + /** + * Render a typed form into a container element. + * @param {HTMLElement} container + * @param {object} template — TypedFormTemplate JSON + * @param {object} [opts] — { onSubmit, values, readOnly } + * @returns {{ getData, setErrors, destroy }} + */ + render(container, template, opts = {}) { + const handle = { getData: () => ({}), setErrors: () => {}, destroy: () => {} }; + + const ConnectedForm = () => { + const ref = useRef(handle); + return html`<${FormRenderer} template=${template} opts=${{ ...opts }} />`; + }; + + preactRender(html`<${ConnectedForm} />`, container); + handle.destroy = () => { preactRender(null, container); }; + + return handle; + }, + + /** + * Client-side validate form data against a template. + * @param {object} template — TypedFormTemplate JSON + * @param {object} data — field values + * @returns {{ valid: boolean, errors: Array<{key: string, message: string}> }} + */ + validate(template, data) { + return validateAll(template, data || {}); + }, + + /** + * Server-side validate via REST endpoint. + * @param {object} template — TypedFormTemplate JSON + * @param {object} data — field values + * @returns {Promise<{ valid: boolean, errors: Array<{key: string, message: string}> }>} + */ + async validateRemote(template, data) { + return restClient.post('/api/v1/forms/validate', { + template, + data: data || {}, + }); + }, + }; +} diff --git a/src/js/sw/sdk/index.js b/src/js/sw/sdk/index.js index aabbd1c..c89c432 100644 --- a/src/js/sw/sdk/index.js +++ b/src/js/sw/sdk/index.js @@ -26,6 +26,7 @@ import { createRenderers } from './renderers.js'; import { createMarkdown } from './markdown.js'; import { createUsers } from './users.js'; import { createTesting } from './testing.js'; +import { createForms } from './forms.js'; import { confirm } from '../primitives/confirm.js'; import { prompt } from '../primitives/prompt.js'; @@ -125,6 +126,9 @@ export async function boot() { // Testing — structured test framework for surface runners sw.testing = createTesting(events, restClient); + // Forms — typed form rendering + validation (v0.9.5) + sw.forms = createForms(restClient); + // Shell helpers — imperative confirm/prompt backed by primitives sw.confirm = confirm; sw.prompt = prompt; @@ -235,7 +239,7 @@ export async function boot() { } // Marker for idempotency - sw._sdk = '0.9.1'; + sw._sdk = '0.9.5'; // 8. Expose globally window.sw = sw;