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

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