Skip to content

Commit 12619ac

Browse files
feat(islands): add board.kanban with browser update actions (#46)
Add a full-bleed kanban island that groups rows by a column field and persists card moves through a manifest update action. Extend /api/action to support match+set updates (insert remains for form.entry). Dogfood on the operations example Incident board. Co-authored-by: Luka Isailovic <23192278+lukaisailovic@users.noreply.github.com>
1 parent d355ee1 commit 12619ac

25 files changed

Lines changed: 790 additions & 53 deletions

File tree

apps/docs/content/docs/data/actions.mdx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -144,11 +144,11 @@ talk an agent into a bad write still can't get past validation or escape `rollba
144144
## Surfacing an action to humans
145145

146146
An action isn't agent-only. Drop a [`form.entry`](/islands/content-and-layout#formentry) island on a
147-
page and point it at an action, and the runtime renders a form — one typed input per field, the
148-
action's types, enums, ranges, and defaults carried straight through — with a submit button that
149-
inserts a row. It runs the very same write path `runActions` does: validate, snapshot, insert, then
150-
the bound dataset's islands refresh live. The form is the human-facing mirror of the agent call,
151-
authored by reusing the action rather than re-declaring its fields.
147+
page and point it at an **insert** action, and the runtime renders a form — one typed input per field,
148+
the action's types, enums, ranges, and defaults carried straight through — with a submit button that
149+
inserts a row. Point a [`board.kanban`](/islands/content-and-layout#boardkanban) at an **update**
150+
action and dragging a card POSTs `match` + `set` the same way. Both share the write path
151+
`runActions` uses: validate, snapshot, write, then the bound dataset's islands refresh live.
152152

153153
See [MCP Server](/mcp) for Code Mode and the full `oi` API, and [Connectors](/data/connectors) for
154154
syncing a provider's data into `source` datasets on a schedule, through this same write path.

apps/docs/content/docs/islands/content-and-layout.mdx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,52 @@ runtime's file routes and fills the whole page, so it only means something insid
188188
is a full workspace bound to a `data/` tree with an editable CSV and the three virtual folders above.
189189
</Callout>
190190

191+
## `board.kanban`
192+
193+
**What it is.** A full-bleed kanban board that groups rows of a dataset into columns by a status or
194+
stage field. **When to use it:** work queues, incident triage, or any pipeline where cards move
195+
between columns. Pick it over [`table.grid`](/islands/tables-and-feeds#tablegrid) /
196+
[`status.grid`](/islands/metrics-and-gauges#statusgrid) when the main interaction is *moving* a row
197+
between stages, and over [`form.entry`](#formentry) when you're reassigning an existing row rather
198+
than inserting one.
199+
200+
Cards show `titleField` plus optional `details` fields. Declared `columns` always render (even when
201+
empty); values present in the data but missing from `columns` still appear so nothing is hidden.
202+
Moving a card — HTML5 drag-and-drop or the per-card column select — POSTs `match` + `set` through
203+
the bound update [action](/data/actions) (match on `key`, set `column`), then the dataset refreshes
204+
live. Without `action`, or with `readOnly: true`, the board is view-only. Renders full-bleed like
205+
[`content.editor`](#contenteditor) (no ordinary island card chrome).
206+
207+
```jsonc title="manifest.json"
208+
{
209+
"type": "board.kanban",
210+
"title": "Incidents",
211+
"dataset": "incidents",
212+
"key": "incident_id",
213+
"column": "status",
214+
"titleField": "title",
215+
"columns": ["investigating", "monitoring", "resolved"],
216+
"details": ["severity", "service"],
217+
"action": "move_incident",
218+
"span": 12
219+
}
220+
```
221+
222+
**Key options.**
223+
224+
- `dataset`, `key`, `column`, `titleField` — required. `key` is the row identity used in the update
225+
match; `column` is the field that places a card; `titleField` is the card headline.
226+
- `columns` (optional): ordered column values to always show, including empty ones.
227+
- `details` (optional): extra dataset columns shown under each card title.
228+
- `action` (optional): a manifest action with `mode: "update"` on the same dataset. Omit (or set
229+
`readOnly`) to disable moves.
230+
- `readOnly` (optional, default `false`): force a viewer.
231+
232+
<Callout type="info" title="Note">
233+
There's no live preview for `board.kanban` here: moves write through the action path. See the
234+
operations example's Incidents board for a working setup.
235+
</Callout>
236+
191237
## `form.entry`
192238

193239
**What it is.** A data-entry form card bound to a manifest [action](/data/actions). **When to use

apps/docs/content/docs/islands/overview.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ Each type links to its section with a live preview and full configuration.
8080
| [`content.editor`](/islands/content-and-layout#contenteditor) | (none) | A full-page workspace to browse and edit markdown files (`file` or `dir`); no data. |
8181
| [`layout.row`](/islands/content-and-layout#layoutrow) | `islands` | A full-width structural row holding other islands. |
8282
| [`form.entry`](/islands/content-and-layout#formentry) | `action` | A data-entry form bound to an action; one typed input per field inserts a row. No data binding of its own. |
83+
| [`board.kanban`](/islands/content-and-layout#boardkanban) | `dataset`, `key`, `column`, `titleField` | A full-bleed kanban board; drag/select moves persist via an update action. |
8384

8485
One layout construct isn't in the table because it isn't an island: a page can split into tabbed
8586
[`groups`](/islands/content-and-layout#tabs-groups). Groups are page structure, not a tile, so

apps/docs/content/docs/reference/manifest.mdx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ A `layout.row` is a structural wrapper: it holds other islands and forces them o
252252
their own full-width grid row. It carries no `span`, `title`, or data binding, and it
253253
cannot nest another `layout.row`.
254254

255-
**Built-in islands:** [`metric.kpi`](#metrickpi) · [`metric.scorecard`](#metricscorecard) · [`timeseries.line`](#timeseriesline) · [`category.bar`](#categorybar) · [`category.combo`](#categorycombo) · [`waterfall.bars`](#waterfallbars) · [`divergence.bars`](#divergencebars) · [`breakdown.treemap`](#breakdowntreemap) · [`distribution.heatmap`](#distributionheatmap) · [`activity.calendar`](#activitycalendar) · [`funnel.steps`](#funnelsteps) · [`rank.list`](#ranklist) · [`compare.radar`](#compareradar) · [`map.choropleth`](#mapchoropleth) · [`correlation.scatter`](#correlationscatter) · [`category.pie`](#categorypie) · [`table.grid`](#tablegrid) · [`timeline.feed`](#timelinefeed) · [`gauge.rings`](#gaugerings) · [`gauge.goal`](#gaugegoal) · [`gauge.meter`](#gaugemeter) · [`status.grid`](#statusgrid) · [`search.box`](#searchbox) · [`note.card`](#notecard) · [`source.doc`](#sourcedoc) · [`content.editor`](#contenteditor) · [`form.entry`](#formentry)
255+
**Built-in islands:** [`metric.kpi`](#metrickpi) · [`metric.scorecard`](#metricscorecard) · [`timeseries.line`](#timeseriesline) · [`category.bar`](#categorybar) · [`category.combo`](#categorycombo) · [`waterfall.bars`](#waterfallbars) · [`divergence.bars`](#divergencebars) · [`breakdown.treemap`](#breakdowntreemap) · [`distribution.heatmap`](#distributionheatmap) · [`activity.calendar`](#activitycalendar) · [`funnel.steps`](#funnelsteps) · [`rank.list`](#ranklist) · [`compare.radar`](#compareradar) · [`map.choropleth`](#mapchoropleth) · [`correlation.scatter`](#correlationscatter) · [`category.pie`](#categorypie) · [`table.grid`](#tablegrid) · [`timeline.feed`](#timelinefeed) · [`gauge.rings`](#gaugerings) · [`gauge.goal`](#gaugegoal) · [`gauge.meter`](#gaugemeter) · [`status.grid`](#statusgrid) · [`search.box`](#searchbox) · [`note.card`](#notecard) · [`source.doc`](#sourcedoc) · [`content.editor`](#contenteditor) · [`form.entry`](#formentry) · [`board.kanban`](#boardkanban)
256256

257257
### `metric.kpi`
258258

@@ -730,6 +730,26 @@ A data-entry form card bound to a manifest `action` — renders one typed input
730730
| `fields` | `array of string` | no | the action's columns to render as inputs, in this order; omit to show every insertable column. Each must be a column of the action's dataset |
731731
| `submitLabel` | `string` | no | text on the submit button; defaults to "Add" |
732732

733+
### `board.kanban`
734+
735+
A full-bleed kanban board grouping rows into columns by a status/stage field — use for work queues, incidents, or pipeline stages. Pick this over `table.grid` / `status.grid` when cards should move between columns, and over `form.entry` when the interaction is reassigning an existing row rather than inserting one. Moving a card POSTs match+set through the bound update action; without `action` or with `readOnly`, the board is view-only.
736+
737+
**Span:** min 6, recommended 12, max 12 (of 12). Omit `span` to render at the recommended width; below min or above max is a named error.
738+
739+
| Field | Type | Required | Description |
740+
| --- | --- | --- | --- |
741+
| `id` | `string` | no | |
742+
| `title` | `string` | no | |
743+
| `span` | `number` | no | |
744+
| `dataset` | `string` | yes | |
745+
| `key` | `string` | yes | field uniquely identifying each card — used as the match key when moving a card via the bound update action |
746+
| `column` | `string` | yes | field whose value places each row into a board column (e.g. status, stage) |
747+
| `titleField` | `string` | yes | field shown as the card title |
748+
| `columns` | `array of string` | no | ordered column values to always show (including empty ones); omit to derive columns from distinct values in the data, in first-seen order. Values present in the data but missing here still appear after the listed ones so nothing is hidden |
749+
| `details` | `array of string` | no | optional extra fields shown on each card under the title; each must be a column of the dataset |
750+
| `action` | `string` | no | name of a manifest `actions` entry with mode `update` that persists a column move (match on `key`, set `column`); omit or set `readOnly` to disable moves |
751+
| `readOnly` | `boolean` | no | disable drag/keyboard moves — a viewer only; default false when `action` is set |
752+
733753
## Custom islands
734754

735755
When no built-in fits, register a renderer in the user's project under

apps/examples/apps/operations/data/incidents.csv

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ incident_id,ts,service,title,severity,status,detected_by,duration_min,summary
22
INC-1042,2026-05-26 03:12,payments,Elevated decline rate on card charges,Sev-2,resolved,alert,142,Upstream processor timeouts spiked declines
33
INC-1043,2026-05-29 18:40,gateway,Edge latency regression after rollout,Sev-2,resolved,canary,76,Bad cache config shipped in gw build
44
INC-1044,2026-06-02 11:55,search,Stale results after index rebuild,Sev-3,resolved,customer,58,Index swap left replicas serving old shards
5-
INC-1045,2026-06-04 12:30,payments,Failed deploy left charges queued,Sev-1,resolved,oncall,63,Migration lock blocked the charge worker
5+
INC-1045,2026-06-04 12:30,payments,Failed deploy left charges queued,Sev-1,investigating,oncall,63,Migration lock blocked the charge worker
66
INC-1046,2026-06-08 09:25,gateway,Connection pool exhaustion at peak,Sev-1,resolved,alert,118,Rollout halved pool size under load
7-
INC-1047,2026-06-10 21:05,notifications,Push delivery backlog,Sev-3,resolved,alert,34,Provider rate limit throttled the sender
8-
INC-1048,2026-06-11 15:22,search,Query errors after schema change,Sev-2,resolved,canary,41,Renamed field broke the ranking query
7+
INC-1047,2026-06-10 21:05,notifications,Push delivery backlog,Sev-3,investigating,alert,34,Provider rate limit throttled the sender
8+
INC-1048,2026-06-11 15:22,search,Query errors after schema change,Sev-2,monitoring,canary,41,Renamed field broke the ranking query
99
INC-1049,2026-06-12 14:48,gateway,Intermittent 5xx on auth callbacks,Sev-2,monitoring,oncall,95,Retries masking a slow downstream auth path

apps/examples/apps/operations/manifest.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@
2626
"detected_by": { "enum": ["alert", "customer", "oncall", "canary"] }
2727
}
2828
},
29+
"move_incident": {
30+
"dataset": "incidents", "mode": "update",
31+
"description": "Move an incident between board columns (status)",
32+
"fields": {
33+
"status": { "enum": ["investigating", "monitoring", "resolved"] }
34+
}
35+
},
2936
"log_incident_update": {
3037
"dataset": "incident_updates", "mode": "insert",
3138
"description": "Append a status update to an incident; incident_id must match an existing incidents row",
@@ -146,6 +153,25 @@
146153
"details": [ { "field": "deploy_id", "label": "Deploy" } ] }
147154
]
148155
},
156+
{
157+
"id": "incidents-board",
158+
"title": "Incident board",
159+
"icon": "list-bullets",
160+
"islands": [
161+
{
162+
"type": "board.kanban",
163+
"title": "Incidents",
164+
"dataset": "incidents",
165+
"key": "incident_id",
166+
"column": "status",
167+
"titleField": "title",
168+
"columns": ["investigating", "monitoring", "resolved"],
169+
"details": ["severity", "service"],
170+
"action": "move_incident",
171+
"span": 12
172+
}
173+
]
174+
},
149175
{
150176
"id": "oncall",
151177
"title": "On-call & docs",

docs/data-app-model.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ or read `packages/schema/src/index.ts`.
135135
| `content.editor` || a full-page, data-free content workspace — browse and edit a tree of markdown files (and view CSVs) Obsidian-style; exactly one of `file` (one doc under `data/` or `docs/`) or `dir` (a directory, recursed) — both or neither is a named error; with `dir`: `include?` globs (default markdown), `csv: true` also surfaces `.csv` files as an editable table (cells + row add/delete; read-only under `readOnly`), `groups: [{id, label?, icon?, match}]` are virtual folders (globs relative to `dir`; unmatched files fall into Ungrouped); `readOnly?` makes it a viewer; edits save to the file, each save records a restorable version under `.openislands/editor.sqlite`; renders full-bleed and binds no dataset |
136136
| `layout.row` | `islands` | a full-width structural row holding other islands — children render on their own 12-column grid row; no `span`/`title`, no nesting, no data binding |
137137
| `form.entry` | `action` | a data-entry form bound to a manifest action; one typed input per field + a submit button that inserts a row (types/enums/defaults come from the action); optional `fields` subset/order and `submitLabel`; binds no dataset |
138+
| `board.kanban` | `dataset`, `key`, `column`, `titleField` | a full-bleed kanban board grouping rows by `column`; cards show `titleField` (+ optional `details`); `columns?` ordered values (empty columns still render); `action?` an update action (match on `key`, set `column`) so drag/select moves persist; `readOnly?` or no action disables moves |
138139

139140
### `content.editor` (the content workspace)
140141

packages/cli/src/scaffold.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@ export function islandSkeleton(type: string): Record<string, unknown> {
5252
"source.doc": { type, title: "Source", kind: "link", href: "https://" },
5353
"content.editor": { type, title: "Content", dir: "data/docs" },
5454
"form.entry": { type, title: "New form", action: "TODO" },
55+
"board.kanban": {
56+
type,
57+
title: "Board",
58+
dataset: "TODO",
59+
key: "TODO",
60+
column: "TODO",
61+
titleField: "TODO",
62+
},
5563
};
5664
return base[type] ?? { type, title: "New island" };
5765
}

packages/compiler/src/index.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -974,6 +974,14 @@ export function islandRequirements(island: Record<string, unknown>): { dataset:
974974
}
975975
break;
976976
}
977+
case "board.kanban": {
978+
add(island.key);
979+
add(island.column);
980+
add(island.titleField);
981+
const details = island.details;
982+
if (Array.isArray(details)) details.forEach(add);
983+
break;
984+
}
977985
case "status.grid":
978986
add(island.label);
979987
add(island.state);
@@ -1111,6 +1119,19 @@ export async function checkManifestContracts(
11111119
continue;
11121120
}
11131121

1122+
if (type === "board.kanban" && typeof config.action === "string" && config.action) {
1123+
const actionName = config.action;
1124+
const action = manifest.actions?.[actionName];
1125+
if (!action) {
1126+
errors.push({ kind: "island", page: page.id, index, type, field: "action", message: `unknown action '${actionName}' — declare it in manifest.actions` });
1127+
} else if (action.mode !== "update") {
1128+
errors.push({ kind: "island", page: page.id, index, type, field: "action", message: `action '${actionName}' must use mode 'update' (got '${action.mode}') so card moves can patch the column field` });
1129+
} else if (action.dataset !== config.dataset) {
1130+
errors.push({ kind: "island", page: page.id, index, type, field: "action", message: `action '${actionName}' targets dataset '${action.dataset}' but the board binds '${config.dataset as string}'` });
1131+
}
1132+
// fall through to binding checks on dataset columns
1133+
}
1134+
11141135
const req = islandRequirements(config);
11151136
if (!req.dataset) {
11161137
checks.push({ page: page.id, index, type, ok: true, missingFields: [] });

packages/compiler/test/index.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,18 @@ describe("islandRequirements", () => {
5353
it("binds content.editor to no dataset and no fields", () => {
5454
expect(islandRequirements({ type: "content.editor", dir: "data/x" })).toEqual({ dataset: null, fields: [] });
5555
});
56+
it("extracts board.kanban key, column, titleField, and details", () => {
57+
const r = islandRequirements({
58+
type: "board.kanban",
59+
dataset: "tasks",
60+
key: "id",
61+
column: "status",
62+
titleField: "title",
63+
details: ["owner", "priority"],
64+
});
65+
expect(r.dataset).toBe("tasks");
66+
expect(r.fields.toSorted()).toEqual(["id", "owner", "priority", "status", "title"]);
67+
});
5668
it("extracts detail fields for table.grid and timeline.feed", () => {
5769
const table = islandRequirements({
5870
type: "table.grid",
@@ -274,6 +286,54 @@ describe("compile (form.entry contract check)", () => {
274286
});
275287
});
276288

289+
const boardManifest = (island: Record<string, unknown>, actions: Record<string, unknown> = {}) => ({
290+
version: 1,
291+
title: "T",
292+
datasets: { tasks: { source: "data/tasks.csv" } },
293+
pages: [{ id: "p", islands: [{ type: "board.kanban", ...island }] }],
294+
actions,
295+
});
296+
const TASKS = "id,title,status,owner\na,Alpha,todo,Ada\n";
297+
298+
describe("compile (board.kanban contract check)", () => {
299+
it("passes with bound columns and an update action on the same dataset", async () => {
300+
const dir = project(
301+
boardManifest(
302+
{ dataset: "tasks", key: "id", column: "status", titleField: "title", action: "move_task" },
303+
{ move_task: { dataset: "tasks", mode: "update" } },
304+
),
305+
{ "data/tasks.csv": TASKS },
306+
);
307+
const report = await compile(dir);
308+
expect(report.ok, report.errors.join(" ")).toBe(true);
309+
});
310+
311+
it("fails when a binding field is missing from the dataset", async () => {
312+
const dir = project(
313+
boardManifest({ dataset: "tasks", key: "id", column: "stage", titleField: "title" }),
314+
{ "data/tasks.csv": TASKS },
315+
);
316+
const report = await compile(dir);
317+
expect(report.ok).toBe(false);
318+
expect(report.errors.join(" ")).toContain("stage");
319+
expect(report.errors.join(" ")).toContain("board.kanban");
320+
});
321+
322+
it("fails when the action is not mode update", async () => {
323+
const dir = project(
324+
boardManifest(
325+
{ dataset: "tasks", key: "id", column: "status", titleField: "title", action: "log_task" },
326+
{ log_task: { dataset: "tasks", mode: "insert" } },
327+
),
328+
{ "data/tasks.csv": TASKS },
329+
);
330+
const report = await compile(dir);
331+
expect(report.ok).toBe(false);
332+
expect(report.errors.join(" ")).toContain("mode 'update'");
333+
});
334+
});
335+
336+
277337
describe("compile (grouped page)", () => {
278338
const groupedManifest = {
279339
version: 1,

0 commit comments

Comments
 (0)