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