Skip to content
ONIWC
iwc 0.1.0

Tutorial 8: Create a CRUD application

Objective: a to-do app where each member creates, reads, updates and deletes their own tasks, with an edit form and live updates.

Prerequisites: Tutorial 7. This project reuses its user schema, sign-in APIs and screens.

Project files
tasks/
├── api/
│   ├── login.api.iwc
│   ├── me.api.iwc
│   ├── register.api.iwc
│   ├── task-create.api.iwc
│   ├── task-delete.api.iwc
│   ├── task-list.api.iwc
│   └── task-update.api.iwc
├── components/
│   ├── header.component.iwc
│   └── stat.component.iwc
├── database/
│   ├── task.schema.iwc
│   └── user.schema.iwc
├── layouts/
│   ├── admin.layout.iwc
│   └── main.layout.iwc
├── styles/
│   └── main.css
├── tests/
│   ├── admin.test.iwc
│   └── tasks.test.iwc
├── ui/
│   ├── admin.ui.iwc
│   ├── home.ui.iwc
│   ├── login.ui.iwc
│   ├── not-found.ui.iwc
│   ├── register.ui.iwc
│   ├── task-edit.ui.iwc
│   └── tasks.ui.iwc
├── app.iwc
└── package.iwc

Step 1: The table, with ownership

database/task.schema.iwc
{
  "name": "task",
  "fields": {
    "owner": { "type": "relation", "to": "user.schema.iwc", "required": true, "onDelete": "cascade" },
    "title": { "type": "string", "required": true, "minLength": 2, "maxLength": 120 },
    "notes": { "type": "text", "maxLength": 2000 },
    "priority": { "type": "enum", "values": ["low", "normal", "high"], "default": "normal", "required": true },
    "due": { "type": "date" },
    "done": { "type": "boolean", "default": false, "required": true, "index": true }
  },
  "owner": "owner",
  "access": { "read": ["admin", "owner"], "create": "auth", "update": "owner", "delete": "owner" },
  "seed": [
    { "id": "t-1", "owner": "u-member", "title": "Book the venue", "priority": "high", "due": "2026-11-20", "done": false },
    { "id": "t-2", "owner": "u-member", "title": "Send invitations", "priority": "normal", "done": true },
    { "id": "t-3", "owner": "u-admin", "title": "Review the budget", "priority": "low", "done": false }
  ]
}
  • "owner": "owner" names the field that holds the creating user. IWC sets it on create — the client cannot choose it.
  • access: admins and the owner can read; any signed-in user can create; only the owner can update or delete. Someone else's task looks exactly like a missing one (404), so ids can't be probed.
  • "index": true on done speeds up "open tasks" queries.

The user schema is the one from Tutorial 7; app.iwc adds one key, database, explained in Tutorial 9:

app.iwc
{
  "name": "Tasks",
  "iwc": 1,
  "theme": {
    "tokens": {
      "color-primary": "#5b4cf5",
      "color-on-primary": "#ffffff",
      "color-muted": "#5d6073",
      "color-surface": "#f6f4ff",
      "color-border": "#e6e6ef",
      "color-danger": "#c0262d",
      "radius": "12px",
      "font-body": "system-ui, sans-serif"
    }
  },
  "styles": ["styles/main.css"],
  "head": { "title": "%s · Tasks" },
  "auth": { "user": "user.schema.iwc", "identity": "email", "defaultRole": "member", "loginPage": "/login" },
  "database": { "url": { "env": "TASKS_DATABASE_URL" } },
  "notFound": "not-found.ui.iwc"
}

Step 2: The four operations

Create

api/task-create.api.iwc
{
  "name": "createTask",
  "method": "POST",
  "path": "/tasks",
  "operation": "db.create",
  "resource": "task.schema.iwc",
  "request": {
    "payload": {
      "title": { "type": "string", "required": true, "minLength": 2, "maxLength": 120 },
      "notes": { "type": "string", "maxLength": 2000 },
      "priority": { "type": "string", "enum": ["low", "normal", "high"] },
      "due": { "type": "date" }
    }
  },
  "response": { "status": 201, "fields": { "id": "string", "title": "string" } },
  "security": { "auth": true, "rateLimit": "60/1m" }
}

Read (for API clients — pages read the table directly)

api/task-list.api.iwc
{
  "name": "listTasks",
  "method": "GET",
  "path": "/tasks",
  "description": "The signed-in user's tasks (admins see everyone's).",
  "operation": "db.list",
  "resource": "task.schema.iwc",
  "request": { "query": { "done": { "type": "boolean" }, "priority": { "type": "string", "enum": ["low", "normal", "high"] }, "page": { "type": "integer", "min": 1 } } },
  "response": { "type": "list", "fields": { "id": "string", "title": "string", "priority": "string", "due": "string", "done": "boolean" } },
  "security": { "auth": true }
}

db.list understands ?page, ?limit, ?sort=-createdAt, ?q= and filters for declared query fields such as ?done=false.

Update

api/task-update.api.iwc
{
  "name": "updateTask",
  "method": "PATCH",
  "path": "/tasks/:id",
  "operation": "db.update",
  "resource": "task.schema.iwc",
  "request": {
    "payload": {
      "title": { "type": "string", "minLength": 2, "maxLength": 120 },
      "notes": { "type": "string", "maxLength": 2000 },
      "priority": { "type": "string", "enum": ["low", "normal", "high"] },
      "due": { "type": "date" },
      "done": { "type": "boolean" }
    }
  },
  "response": { "fields": { "id": "string", "title": "string", "done": "boolean" } },
  "security": { "auth": true }
}

Only the fields in payload can change. Every one is optional, so a client can send just {"done": true}.

Delete

api/task-delete.api.iwc
{
  "name": "deleteTask",
  "method": "DELETE",
  "path": "/tasks/:id",
  "operation": "db.delete",
  "resource": "task.schema.iwc",
  "security": { "auth": true }
}

Step 3: The task list

ui/tasks.ui.iwc
{
  "name": "My tasks",
  "route": "/tasks",
  "layout": "main.layout.iwc",
  "auth": "required",
  "title": "My tasks",
  "sections": [
    {
      "type": "section",
      "className": "page",
      "layout": { "direction": "column", "gap": "24px" },
      "elements": [
        { "type": "text", "variant": "h1", "bind": "user.name", "prefix": "Tasks for " },
        {
          "type": "column",
          "className": "panel",
          "elements": [
            { "type": "text", "variant": "h3", "content": "New task" },
            {
              "type": "form",
              "inputs": [
                { "name": "title", "type": "text", "label": "Title", "required": true },
                { "name": "priority", "type": "select", "label": "Priority", "value": "normal", "options": [{ "label": "Low", "value": "low" }, { "label": "Normal", "value": "normal" }, { "label": "High", "value": "high" }] },
                { "name": "due", "type": "date", "label": "Due" }
              ],
              "submit": { "api": "task-create.api.iwc", "label": "Add task", "success": { "reset": true, "reload": true } }
            }
          ]
        },
        {
          "type": "stack",
          "className": "task-list",
          "layout": { "direction": "column", "gap": "8px" },
          "data": {
            "source": "DATABASE.tasks",
            "columns": ["title", "priority", "due", "done", "owner"],
            "filter": { "owner": { "user": "id" } },
            "sort": { "field": "createdAt", "order": "desc" },
            "live": true
          },
          "item": {
            "type": "row",
            "className": "task",
            "layout": { "align": "center", "gap": "12px" },
            "attributes": { "data-done": "=done ? 'yes' : 'no'" },
            "elements": [
              { "type": "column", "className": "grow", "elements": [
                { "type": "text", "variant": "label", "bind": "title" },
                { "type": "text", "variant": "caption", "content": "=concat(priority, ' priority', due ? concat(' · due ', format(due, 'date-short')) : '')" }
              ] },
              { "type": "button", "label": "Done", "variant": "secondary", "action": "api", "api": "task-update.api.iwc", "value": { "done": true }, "success": { "reload": true }, "visible": "=!done" },
              { "type": "button", "label": "Reopen", "variant": "ghost", "action": "api", "api": "task-update.api.iwc", "value": { "done": false }, "success": { "reload": true }, "visible": "=done" },
              { "type": "button", "label": "Edit", "variant": "ghost", "action": "navigate", "target": "/tasks/:id" },
              { "type": "button", "label": "Delete", "variant": "ghost", "action": "api", "api": "task-delete.api.iwc", "success": { "reload": true } }
            ]
          },
          "empty": { "type": "text", "className": "muted", "content": "No tasks yet — add your first one above." }
        }
      ]
    }
  ]
}
  • The form creates a task, then resets and reloads the list in place.
  • The stack reads DATABASE.tasks, only the columns it needs, filtered to the signed-in user ({"user": "id"}), newest first. live: true re-renders it in every open tab when the table changes.
  • Inside item, the Done / Reopen buttons call the update API with a JSON value. The API's :id is filled from the current record, so each button acts on its own row.
  • visible expressions show either Done or Reopen; attributes sets data-done for the strike-through style.

Step 4: The edit page

ui/task-edit.ui.iwc
{
  "name": "Edit task",
  "route": "/tasks/:id",
  "layout": "main.layout.iwc",
  "auth": "required",
  "title": "Edit task",
  "sections": [
    {
      "type": "section",
      "className": "page narrow",
      "data": { "source": "DATABASE.tasks", "mode": "single", "columns": ["title", "notes", "priority", "due", "done"], "filter": { "id": { "param": "id" } } },
      "layout": { "direction": "column", "gap": "16px" },
      "elements": [
        { "type": "link", "label": "← All tasks", "target": "/tasks" },
        { "type": "text", "variant": "h1", "content": "Edit task" },
        {
          "type": "form",
          "inputs": [
            { "name": "title", "type": "text", "label": "Title", "required": true, "value": "item.title" },
            { "name": "notes", "type": "textarea", "label": "Notes", "value": "item.notes" },
            { "name": "priority", "type": "select", "label": "Priority", "value": "item.priority", "options": [{ "label": "Low", "value": "low" }, { "label": "Normal", "value": "normal" }, { "label": "High", "value": "high" }] },
            { "name": "due", "type": "date", "label": "Due", "value": "item.due" },
            { "name": "done", "type": "checkbox", "label": "Done", "value": "item.done" }
          ],
          "submit": { "api": "task-update.api.iwc", "label": "Save", "success": { "navigate": "/tasks" } }
        }
      ],
      "empty": { "type": "text", "content": "That task doesn't exist, or isn't yours." }
    }
  ]
}

"mode": "single" with "filter": {"id": {"param": "id"}} loads the task from the URL. Each input's value prefills it (item.title); the form posts to the update API at /api/tasks/:id. If the task isn't yours, the empty element is shown.

Step 5: Header, layout, styles

components/header.component.iwc
{
  "name": "Header",
  "root": {
    "type": "navbar",
    "className": "site-header",
    "elements": [
      { "type": "link", "label": "Tasks", "target": "/", "className": "brand" },
      {
        "type": "row",
        "className": "account-actions",
        "layout": { "align": "center", "gap": "12px" },
        "elements": [
          { "type": "link", "label": "Sign in", "target": "/login", "visible": { "auth": "signed-out" } },
          { "type": "link", "label": "My tasks", "target": "/tasks", "visible": { "auth": "signed-in" } },
          { "type": "link", "label": "Admin", "target": "/admin", "visible": { "roles": ["admin"] } },
          { "type": "button", "label": "Sign out", "variant": "ghost", "action": "logout", "target": "/", "visible": { "auth": "signed-in" } }
        ]
      }
    ]
  }
}
layouts/main.layout.iwc
{
  "name": "MainLayout",
  "slots": { "header": "header.component.iwc", "content": "PAGE_CONTENT" },
  "structure": { "header": { "position": "sticky" } }
}

Step 6: Test the whole cycle

tests/tasks.test.iwc
{
  "name": "tasks",
  "cases": [
    { "name": "the task list needs sign-in", "request": { "path": "/tasks" }, "expect": { "status": 303 } },
    { "name": "a member sees only their own tasks", "as": { "email": "max@example.com", "password": "member-pass-123" }, "request": { "path": "/tasks" }, "expect": { "status": 200, "contains": ["Book the venue", "Send invitations"], "excludes": ["Review the budget"] } },
    { "name": "creating a task", "as": { "email": "max@example.com", "password": "member-pass-123" }, "request": { "method": "POST", "path": "/api/tasks", "body": { "title": "Order flowers", "priority": "high", "due": "2026-11-25" } }, "expect": { "status": 201, "body": { "title": "Order flowers" } }, "save": { "taskId": "id" } },
    { "name": "it appears in the API listing", "as": { "email": "max@example.com", "password": "member-pass-123" }, "request": { "path": "/api/tasks?done=false" }, "expect": { "status": 200, "contains": ["Order flowers", "Book the venue"], "excludes": ["Send invitations"] } },
    { "name": "marking it done", "as": { "email": "max@example.com", "password": "member-pass-123" }, "request": { "method": "PATCH", "path": "/api/tasks/{{taskId}}", "body": { "done": true } }, "expect": { "status": 200, "body": { "done": true } } },
    { "name": "another member cannot change it", "as": { "email": "admin@example.com", "password": "admin-pass-123" }, "request": { "method": "PATCH", "path": "/api/tasks/{{taskId}}", "body": { "title": "Hacked" } }, "expect": { "status": 404 } },
    { "name": "nor delete it", "as": { "email": "admin@example.com", "password": "admin-pass-123" }, "request": { "method": "DELETE", "path": "/api/tasks/{{taskId}}" }, "expect": { "status": 404 } },
    { "name": "the edit page is prefilled", "as": { "email": "max@example.com", "password": "member-pass-123" }, "request": { "path": "/tasks/{{taskId}}" }, "expect": { "status": 200, "contains": ["value=\"Order flowers\"", "2026-11-25"] } },
    { "name": "the owner deletes it", "as": { "email": "max@example.com", "password": "member-pass-123" }, "request": { "method": "DELETE", "path": "/api/tasks/{{taskId}}" }, "expect": { "status": 200 } },
    { "name": "titles are validated", "as": { "email": "max@example.com", "password": "member-pass-123" }, "request": { "method": "POST", "path": "/api/tasks", "body": { "title": "x" } }, "expect": { "status": 422 } },
    { "name": "the owner cannot be chosen by the client", "as": { "email": "max@example.com", "password": "member-pass-123" }, "request": { "method": "POST", "path": "/api/tasks", "body": { "title": "Sneaky", "owner": "u-admin" } }, "expect": { "status": 422 } }
  ]
}
Terminal
iwc test
Output
  ✓ tasks › the task list needs sign-in
  ✓ tasks › a member sees only their own tasks
  ✓ tasks › creating a task
  ✓ tasks › it appears in the API listing
  ✓ tasks › marking it done
  ✓ tasks › another member cannot change it
  ✓ tasks › nor delete it
  ✓ tasks › the edit page is prefilled
  ✓ tasks › the owner deletes it
  ✓ tasks › titles are validated
  ✓ tasks › the owner cannot be chosen by the client

save stores the new task's id, and later cases use it as {{taskId}}.

Troubleshooting

ProblemFix
A task you can see in the database answers 404The table's access rules hide it from this user, as intended.
iwc check reports a binding to a field the data source doesn't loadAdd the field to the data source's columns (only listed columns are selected).
Delete buttons delete the wrong rowPut the API button inside the item, so :id comes from that record.

Next: Connect a database.

Source: apps/marketing/content/docs/tutorials/crud.md