Feat v0.9.5 typed forms SDK primitive
Extract typed form system from models/workflow.go into standalone server/forms/ package. Any package can now declare and validate forms, not just workflow stages. - New server/forms/ package (types + validation + condition evaluator) - REST POST /api/v1/forms/validate endpoint - Starlark forms.validate(template, data) module - Frontend sw.forms.render(), .validate(), .validateRemote() - Manifest form_template accepted at package level - 16 new tests (12 forms + 4 Starlark module) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user