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
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:
47
CHANGELOG.md
47
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
11
ROADMAP.md
11
ROADMAP.md
@@ -114,12 +114,13 @@ into the team's role catalog. Adopted packages reference the original via
|
|||||||
`AdoptTeamWorkflow` deprecated in favor of package-level adoption.
|
`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`**
|
||||||
|
|
||||||
|
|||||||
@@ -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"}]`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
347
server/forms/forms.go
Normal 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
322
server/forms/forms_test.go
Normal 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
63
server/handlers/forms.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 ────────────────────────────────
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
86
server/sandbox/forms_module.go
Normal file
86
server/sandbox/forms_module.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
116
server/sandbox/forms_module_test.go
Normal file
116
server/sandbox/forms_module_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
343
src/js/sw/sdk/forms.js
Normal 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, '&').replace(/"/g, '"').replace(/</g, '<');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 || {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user