Feat v0.9.5 typed forms sdk (#79)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m44s
CI/CD / test-sqlite (push) Successful in 3m6s
CI/CD / build-and-deploy (push) Successful in 29s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #79.
This commit is contained in:
2026-04-03 17:04:29 +00:00
committed by xcaliber
parent 6b9ce92103
commit 75d7abc089
18 changed files with 1451 additions and 324 deletions

View File

@@ -2,6 +2,53 @@
All notable changes to Armature are documented here. 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 ## v0.9.4 — Package Adoption + Roles
Packages can now declare `adoptable: true` in their manifest. When a team Packages can now declare `adoptable: true` in their manifest. When a team

View File

@@ -114,12 +114,13 @@ into the team's role catalog. Adopted packages reference the original via
`AdoptTeamWorkflow` deprecated in favor of package-level adoption. `AdoptTeamWorkflow` deprecated in favor of package-level adoption.
4 new endpoints, migration 017, 11 new tests. 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 Extracted `TypedFormTemplate`, `FormField`, `FormFieldset`, etc. from
`models/workflow.go` into a `forms` package. FE SDK: `sw.forms.render()` `models/workflow.go` into a standalone `forms` package. REST endpoint
and `sw.forms.validate()`. Starlark: `forms.validate()`. Any package `POST /api/v1/forms/validate`. Starlark `forms.validate()` module.
can declare forms, not just workflow stages. 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`** **v0.9.6 — Deprecate `stage_type`, Collapse `stage_mode`**

View File

@@ -1 +1 @@
0.9.3 0.9.5

View File

@@ -184,3 +184,30 @@ Kernel event prefixes: `user.*`, `team.*`, `workflow.*`, `notification.*`, `pres
| POST | `/presence/heartbeat` | Update presence status | | POST | `/presence/heartbeat` | Update presence status |
| GET | `/presence` | Query online users | | GET | `/presence` | Query online users |
| GET | `/users/search` | Search 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"}]`.

View File

@@ -248,6 +248,44 @@ Every surface uses one of three patterns:
The shell provides the home link, notification bell, and user menu The shell provides the home link, notification bell, and user menu
on every surface for free. 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 ## Extension CSS contract
Extensions must prefix all CSS classes with `.ext-{slug}-` to avoid Extensions must prefix all CSS classes with `.ext-{slug}-` to avoid

View File

@@ -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 | | `contributes` | Slot contributions this package injects into other surfaces |
| `capabilities` | Environment requirements: `{"required": [...], "optional": [...]}` | | `capabilities` | Environment requirements: `{"required": [...], "optional": [...]}` |
| `schema_version` | Integer for additive schema migrations | | `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 ## Multi-Surface Packages

View File

@@ -429,3 +429,30 @@ def on_run(ctx):
return {"advance": True, "data": {"needs_review": False}} 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`).

347
server/forms/forms.go Normal file
View File

@@ -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
}
}

322
server/forms/forms_test.go Normal file
View File

@@ -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)
}
}

63
server/handlers/forms.go Normal file
View File

@@ -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,
})
}

View File

@@ -5,6 +5,8 @@ import (
"fmt" "fmt"
"regexp" "regexp"
"strings" "strings"
"armature/forms"
) )
// validManifestID matches lowercase alphanumeric slugs with optional hyphens. // 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 GatePermission string // if set, checks this user permission before calling on_request
RequiresRoles []string // team roles needed to access this package (advisory, OR semantics) 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 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, // 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 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) info.SchemaVersion = ParseSchemaVersion(manifest)
// ── Type-specific constraints ──────────────────────────────── // ── Type-specific constraints ────────────────────────────────

View File

@@ -545,6 +545,10 @@ func main() {
protected.POST("/instances/:iid/signoffs", wfSignoffH.Submit) protected.POST("/instances/:iid/signoffs", wfSignoffH.Submit)
protected.GET("/instances/:iid/signoffs", wfSignoffH.List) protected.GET("/instances/:iid/signoffs", wfSignoffH.List)
// Forms validation
formsH := &handlers.FormsHandler{}
protected.POST("/forms/validate", formsH.Validate)
// Surface discovery // Surface discovery
pkgH := handlers.NewPackageHandler(stores) pkgH := handlers.NewPackageHandler(stores)
protected.GET("/surfaces", pkgH.ListEnabledSurfaces) protected.GET("/surfaces", pkgH.ListEnabledSurfaces)

View File

@@ -2,9 +2,6 @@ package models
import ( import (
"encoding/json" "encoding/json"
"fmt"
"regexp"
"strings"
"time" "time"
) )
@@ -118,320 +115,6 @@ var ValidAudiences = map[string]bool{
AudienceSystem: true, 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 ─────────────── // ── Instance Status Constants ───────────────
const ( const (

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -417,6 +417,9 @@ func (r *Runner) buildModulesWithLibCtx(ctx context.Context, packageID string, m
}) })
} }
case models.ExtPermFormValidate:
modules["forms"] = BuildFormsModule(ctx)
case models.ExtPermBatchExec: case models.ExtPermBatchExec:
hasBatchExec = true hasBatchExec = true
} }

343
src/js/sw/sdk/forms.js Normal file
View File

@@ -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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
}
// ── Client-side validation (mirrors Go forms.ValidateFormData) ──
function evaluateCondition(cond, data) {
const val = data[cond.when];
const exists = val !== undefined && val !== null;
const op = cond.op || 'eq';
switch (op) {
case 'exists': return exists;
case 'eq': return exists && String(val) === String(cond.value);
case 'neq': return !exists || String(val) !== String(cond.value);
case 'in': return exists && Array.isArray(cond.value) && cond.value.map(String).includes(String(val));
default: return true;
}
}
function validateField(f, val, present) {
const errs = [];
if (f.condition) return errs; // caller handles condition check
if (f.required && (!present || val === null || val === undefined || val === '')) {
errs.push({ key: f.key, message: (f.label || f.key) + ' is required' });
return errs;
}
if (!present || val === null || val === undefined) return errs;
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 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`<input type=${f.type} id=${id} value=${value || ''} placeholder=${f.placeholder || ''}
onInput=${e => 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`<input type="number" id=${id} value=${value ?? ''} placeholder=${f.placeholder || ''}
...${attrs} onInput=${e => onChange(f.key, e.target.value !== '' ? parseFloat(e.target.value) : null)} />`;
break;
}
case 'textarea':
input = html`<textarea id=${id} rows="4" placeholder=${f.placeholder || ''}
onInput=${e => onChange(f.key, e.target.value)}>${value || ''}</textarea>`;
break;
case 'select':
input = html`<select id=${id} value=${value || ''} onChange=${e => onChange(f.key, e.target.value)}>
<option value="">— Select —</option>
${(f.options || []).map(o => html`<option value=${o.value}>${o.label}</option>`)}
</select>`;
break;
case 'checkbox':
return html`<div class="sw-form-field" data-key=${f.key}>
<div class="sw-form-check">
<input type="checkbox" id=${id} checked=${!!value}
onChange=${e => onChange(f.key, e.target.checked)} />
<label for=${id}>${f.label}${f.required ? html`<span class="required">*</span>` : ''}</label>
</div>
${error ? html`<div class="sw-form-error">${error}</div>` : ''}
</div>`;
case 'file': {
const accept = f.validation?.accept || '';
input = html`<input type="file" id=${id} accept=${accept}
onChange=${e => onChange(f.key, e.target.files?.[0] || null)} />`;
break;
}
default:
input = html`<input type="text" id=${id} value=${value || ''} placeholder=${f.placeholder || ''}
onInput=${e => onChange(f.key, e.target.value)} />`;
}
return html`<div class="sw-form-field" data-key=${f.key}>
<label for=${id}>${f.label}${f.required ? html`<span class="required">*</span>` : ''}</label>
${input}
${error ? html`<div class="sw-form-error">${error}</div>` : ''}
</div>`;
}
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`<div class="sw-form sw-form-progressive">
<div class="sw-form-nav">
<span class="sw-form-step-label">${fs.label}</span>
<span class="sw-form-step-counter">Step ${step + 1} of ${template.fieldsets.length}</span>
</div>
${(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]} />`;
})}
<div class="sw-form-buttons">
${step > 0 ? html`<button type="button" class="sw-btn" onClick=${() => setStep(step - 1)}>Back</button>` : ''}
${!isLast ? html`<button type="button" class="sw-btn sw-btn-primary" onClick=${() => setStep(step + 1)}>Next</button>` : ''}
${isLast && opts.onSubmit ? html`<button type="button" class="sw-btn sw-btn-primary"
onClick=${() => opts.onSubmit(handleRef.current.getData())}>Submit</button>` : ''}
</div>
</div>`;
}
return html`<div class="sw-form">
${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`<div class="sw-form-buttons">
<button type="button" class="sw-btn sw-btn-primary"
onClick=${() => opts.onSubmit(handleRef.current.getData())}>Submit</button>
</div>` : ''}
</div>`;
}
// ── 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 || {},
});
},
};
}

View File

@@ -26,6 +26,7 @@ import { createRenderers } from './renderers.js';
import { createMarkdown } from './markdown.js'; import { createMarkdown } from './markdown.js';
import { createUsers } from './users.js'; import { createUsers } from './users.js';
import { createTesting } from './testing.js'; import { createTesting } from './testing.js';
import { createForms } from './forms.js';
import { confirm } from '../primitives/confirm.js'; import { confirm } from '../primitives/confirm.js';
import { prompt } from '../primitives/prompt.js'; import { prompt } from '../primitives/prompt.js';
@@ -125,6 +126,9 @@ export async function boot() {
// Testing — structured test framework for surface runners // Testing — structured test framework for surface runners
sw.testing = createTesting(events, restClient); 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 // Shell helpers — imperative confirm/prompt backed by primitives
sw.confirm = confirm; sw.confirm = confirm;
sw.prompt = prompt; sw.prompt = prompt;
@@ -235,7 +239,7 @@ export async function boot() {
} }
// Marker for idempotency // Marker for idempotency
sw._sdk = '0.9.1'; sw._sdk = '0.9.5';
// 8. Expose globally // 8. Expose globally
window.sw = sw; window.sw = sw;