Skip to content
ONIWC
iwc 0.1.0

Tutorial 5: Build a form

Objective: a contact form that stores enquiries in the database, validated on the server, with errors shown under each field.

Prerequisites: Tutorial 4.

In IWC a form is three pieces: a schema (the table), an API (the request contract and what it does) and a form element (the inputs). The finished project:

Project files
contact-form/
├── api/
│   └── contact.api.iwc
├── database/
│   └── enquiry.schema.iwc
├── styles/
│   └── main.css
├── tests/
│   └── contact.test.iwc
├── ui/
│   └── contact.ui.iwc
├── app.iwc
└── package.iwc

Step 1: The table

Create database/enquiry.schema.iwc:

database/enquiry.schema.iwc
{
  "name": "enquiry",
  "fields": {
    "name": { "type": "string", "required": true, "maxLength": 80 },
    "email": { "type": "email", "required": true },
    "topic": { "type": "enum", "values": ["order", "returns", "wholesale", "other"], "required": true },
    "message": { "type": "text", "required": true, "maxLength": 2000 },
    "newsletter": { "type": "boolean", "default": false }
  },
  "access": { "read": "none", "create": "public", "update": "none", "delete": "none" }
}
PartMeaning
fieldsColumns with their types and rules. email is validated and lower-cased; enum limits values; text is long text
accessWho may do what through generic APIs and data sources. Anyone may create (submit the form); nobody may read, update or delete enquiries through the API

IWC creates the table in .iwc/dev.db (SQLite) on the next compile; no migration command is needed in development.

Step 2: The API

Create api/contact.api.iwc:

api/contact.api.iwc
{
  "name": "contact",
  "method": "POST",
  "path": "/contact",
  "description": "Stores an enquiry from the contact form.",
  "operation": "db.create",
  "resource": "enquiry.schema.iwc",
  "request": {
    "payload": {
      "name": { "type": "string", "required": true, "minLength": 2, "maxLength": 80 },
      "email": { "type": "email", "required": true },
      "topic": { "type": "string", "required": true, "enum": ["order", "returns", "wholesale", "other"] },
      "message": { "type": "string", "required": true, "minLength": 10, "maxLength": 2000 },
      "newsletter": { "type": "boolean" }
    }
  },
  "response": { "status": 201, "fields": { "id": "string" } },
  "security": { "rateLimit": "5/1m" }
}
  • operation: "db.create" inserts the validated payload into resource.
  • request.payload is the contract: every field the API accepts, with its rules. Fields not listed are refused, so nobody can set a column the form doesn't show.
  • response.fields lists the only fields returned.
  • security.rateLimit allows 5 submissions per minute per client.

The API is mounted under /api: POST /api/contact.

Step 3: The page and the form

ui/contact.ui.iwc
{
  "name": "Contact",
  "route": "/",
  "title": "Contact us",
  "sections": [
    {
      "type": "section",
      "className": "contact",
      "layout": { "direction": "column", "gap": "16px" },
      "elements": [
        { "type": "text", "variant": "h1", "content": "Contact us" },
        { "type": "text", "content": "We reply within one working day." },
        {
          "type": "form",
          "inputs": [
            { "name": "name", "type": "text", "label": "Your name", "required": true, "autocomplete": "name" },
            { "name": "email", "type": "email", "label": "Email", "required": true, "autocomplete": "email" },
            {
              "name": "topic",
              "type": "select",
              "label": "Topic",
              "required": true,
              "options": [
                { "label": "An order", "value": "order" },
                { "label": "Returns", "value": "returns" },
                { "label": "Wholesale", "value": "wholesale" },
                { "label": "Something else", "value": "other" }
              ]
            },
            { "name": "message", "type": "textarea", "label": "Message", "required": true, "hint": "At least 10 characters." },
            { "name": "newsletter", "type": "checkbox", "label": "Send me the monthly newsletter" }
          ],
          "submit": { "api": "contact.api.iwc", "label": "Send message", "success": { "message": "Thanks — we'll be in touch soon.", "reset": true } }
        }
      ]
    }
  ]
}
Input propertyMeaning
nameThe payload key — it must match the API's field
typetext email select textarea checkbox … (all types)
requiredMarks the field in the browser. Enforcement is the API's required
optionsFor select and radio: strings, or {label, value}
hintHelp text under the field, read by screen readers
autocompleteBrowser autofill hints

submit.api posts the form as JSON to the API; success shows a message and clears the inputs on a 2xx answer. On a 422 each error appears under its field.

Step 4: Style it

styles/main.css
.contact { max-width: 560px; margin: 0 auto; padding: 64px 16px; }
.iwc-form { display: flex; flex-direction: column; gap: 14px; }
.iwc-field { display: flex; flex-direction: column; gap: 6px; }
.iwc-field input, .iwc-field select, .iwc-field textarea { padding: 10px 12px; border: 1px solid var(--color-border); border-radius: var(--radius); font: inherit; }
.iwc-field-error { color: var(--color-danger); font-size: 14px; }

.iwc-field-error and --color-danger style the error messages.

Step 5: Try the failure path

With iwc dev running, submit the form with A as the name and hi as the message. The terminal logs the reason:

text
11:28:14 api  POST   /api/contact                             422 0ms
      ↳ validation_failed: Some fields are invalid (message must be at least 10 characters; name must be at least 2 characters)

The response is always the same shape, which the form maps onto its fields:

json
{"error":{"code":"validation_failed","fields":{"message":"must be at least 10 characters","name":"must be at least 2 characters"},"message":"Some fields are invalid"}}

To call the API from a terminal or another server (no browser, no session), mark the request as a non-browser client. Browsers send CSRF tokens; a signed-out script sends X-IWC-Client: native instead:

Terminal
curl -s -X POST localhost:3000/api/contact \
  -H 'content-type: application/json' -H 'X-IWC-Client: native' \
  -d '{"name":"Asha","email":"asha@example.com","topic":"order","message":"Where is my order?"}'
# {"id":"cbea4553-8687-4bb4-98f4-1c976e0884aa"}

Step 6: Test it

tests/contact.test.iwc
{
  "name": "contact",
  "cases": [
    {
      "name": "the page shows the form",
      "request": { "path": "/" },
      "expect": { "status": 200, "contains": ["Contact us", "name=\"email\"", "Send message", "At least 10 characters."] }
    },
    {
      "name": "a valid enquiry is stored",
      "request": { "method": "POST", "path": "/api/contact", "body": { "name": "Asha", "email": "Asha@Example.com", "topic": "order", "message": "Where is order #1042?", "newsletter": true } },
      "expect": { "status": 201 }
    },
    {
      "name": "invalid fields come back with a message each",
      "request": { "method": "POST", "path": "/api/contact", "body": { "name": "A", "email": "not-an-email", "topic": "spam", "message": "short" } },
      "expect": { "status": 422, "contains": ["validation_failed", "email", "topic", "message"] }
    },
    {
      "name": "fields the API did not declare are refused",
      "request": { "method": "POST", "path": "/api/contact", "body": { "name": "Asha", "email": "a@example.com", "topic": "other", "message": "Hello there, team!", "status": "vip" } },
      "expect": { "status": 422, "contains": ["status"] }
    },
    {
      "name": "nobody can read the enquiries over the API",
      "request": { "path": "/api/contact" },
      "expect": { "status": 405 }
    }
  ]
}
Terminal
iwc test
Output
  ✓ contact › the page shows the form
  ✓ contact › a valid enquiry is stored
  ✓ contact › invalid fields come back with a message each
  ✓ contact › fields the API did not declare are refused
  ✓ contact › nobody can read the enquiries over the API

5 passed, 0 failed

Troubleshooting

MessageFix
form elements need "submit": {"api": ...}Every form posts to an API.
forms submit to POST/PUT/PATCH/DELETE apisThe API is GET; forms write.
… is not an accepted field (422)The input's name isn't declared in request.payload.
403 csrfThe page was open across a server restart; reload it. IWC forms send the CSRF token for you.

Next: Connect an API.

Source: apps/marketing/content/docs/tutorials/build-a-form.md