| title | Plugin Kanban |
|---|
import { InteractiveDemo } from '@/app/components/InteractiveDemo'; import { PluginLoader } from '@/app/components/PluginLoader';
Kanban board component with drag-and-drop powered by @dnd-kit.
npm install @object-ui/plugin-kanbanThis package publishes a stylesheet. Import it after the base sheets, or the board renders unstyled — the themed utilities it uses have no other source in a published app (#4929):
/* src/index.css */
@import "tailwindcss";
@import "@object-ui/components/style.css";
@import "@object-ui/fields/style.css";
@import "@object-ui/plugin-kanban/style.css";<PluginLoader plugins={['kanban']}>
// Import once in your app entry point
import '@object-ui/plugin-kanban'
import type { ObjectKanbanSchema } from '@object-ui/types'
// Use in schemas
const schema: ObjectKanbanSchema = {
type: 'object-kanban',
groupBy: 'status',
data: [],
columns: [
{
id: 'todo',
title: 'To Do',
cards: [
{ id: '1', title: 'Task 1', description: 'Do something' }
]
},
{
id: 'done',
title: 'Done',
cards: []
}
]
}
// `onCardMove` is NOT a document key — JSON has no function value. The host
// supplies it as a React prop, separate from `schema` above.
const onCardMove = (cardId: string, fromCol: string, toCol: string, index: number) => {
console.log(`Card ${cardId} moved`)
}- Drag and drop cards between columns — on an object-bound board
(
object-kanban), only for a caller thekanbanCardMoverow of the affordance-to-grant map allows (the object's policy, the effective API operation set and the caller's update grant); without it the cards are not movable. With no permission provider mounted it reads open. - Column limits (WIP limits)
- Column totals: a kanban view's
summarizeFieldsums that field over each column's loaded cards in the column header, beside the count (with the count's+when the fetch window is full) - Card badges for status/priority
- Keyboard navigation
- Lazy-loaded (~100-150 KB loads only when rendered)
onCardMove is NOT a document key: it is a React prop the host supplies (JSON
has no function value), never a member of the schema object below —
packages/plugin-kanban/README.md documents this the same way.
Neither is onCardClick, and since objectui#7804 the object-kanban validator
says so by name: it is declared as an objectui#6124 runtime slot, so an
authored onCardClick: { "action": "toast" } is now refused with a message
pointing at the node-type spelling, where before it parsed green and was handed
to a call site expecting a function. A React host still supplies the function
through the TypeScript interface or as a React prop; only the JSON face refuses
it.
⭐ onQuickAdd is a tombstone on object-kanban since objectui#11234, not a
runtime slot. A supplied one used to reach the board and was never called,
because its partner quickAdd is retired there. ObjectKanban now renders an
internal board that takes the Quick Add pair only as explicit props, and it
supplies neither half. So the TypeScript twin is ?: never and the validator
refuses the key by name. The pair lives on KanbanRenderer — see "Quick Add is
a React-host capability" below.
⭐ onCardMove is declared too, since objectui#9342 — but as a tombstone,
not a runtime slot. An authored one used to be accepted and silently dropped,
because an object-bound board substitutes its own mover and ObjectKanban
declares no onCardMove React prop; the key reached nothing, so the
TypeScript twin is ?: never rather than callable and the validator refuses the
key by name. The mover is a prop on KanbanRenderer, a sibling of its
schema — which is where a host that mounts the board component directly
supplies it, and the move that let the arm carry the tombstone at all.
columns are the board's swimlanes, not a field projection — the fields drawn
on a card are cardFields. It is one of two array shapes, the pair
@objectstack/spec declares: an array of { id, title } lanes (one per groupBy
value), or an array of bare value strings. ⛔ Not a mix of the two — the
renderer decides which shape it has by looking at the first element only, so a
mixed array produces a blank lane and mis-bucketed cards. cards is optional
on a lane — an object-bound board buckets its records into the lane by groupBy,
and only a static board writes a lane's cards itself. Both faces of
@object-ui/types declare this since objectui#8913, so a lane and its cards are
validated: a card with no title is refused.
Since objectui#8990 made groupBy optional (matching @objectstack/spec), a
lane-less board is a valid authoring and this arm is live on it: the lanes are
drawn, titled by the raw strings — where a grouped board titles its lanes with
the picklist labels. groupBy and write the { id, title } array.
{
type: 'object-kanban',
groupBy: 'status',
data: [],
columns?: string[] | KanbanLane[], // one shape or the other, never mixed
className?: string
}
// `KanbanLane` is a name for this page only — the shape is declared inline on
// `ObjectKanbanSchema` and is not importable.
interface KanbanLane {
id: string // matched against the groupBy value
title: string
cards?: KanbanCard[] // a STATIC board's lane only
limit?: number // Max cards allowed (WIP limit)
className?: string
collapsed?: boolean // lane starts collapsed; the viewer can reopen it
}
interface KanbanCard {
id: string
title: string
description?: string
badges?: Array<{
label: string
variant?: 'default' | 'secondary' | 'destructive' | 'outline'
}>
}
| Property | Type | Description |
|---|---|---|
columns |
string[] | KanbanLane[] |
Swimlane definitions — an array of { id, title } lanes (one per groupBy value), or an array of bare value strings; never a mix. NOT a field projection. The bare-string array is accepted for spec parity but is ignored on this block (see the note above). |
filter |
ViewFilterRule[] | Query filter for an object-driven board, lowered to $filter |
limit |
number | Rows fetched by an object-driven board (default 100) |
navigation |
ViewNavigationConfig |
What a card click opens — the spec's NavigationConfig by reference, the type ObjectGridSchema.navigation uses: mode (page, drawer, modal, split, popover, new_window or none) with size, openNewTab and preventNavigation. With the key absent a click opens the record in a drawer, and a click handler from a parent view outranks the whole key. page — and a block written without mode, which takes the spec's page default — opens the record page through the record navigator the host publishes (objectui#11293); the console publishes one on its custom pages, record pages and list views, and under a host that publishes none the click opens nothing. |
swimlaneField |
string | Record field that splits an object-driven board into horizontal swimlanes, across the groupBy columns. @objectstack/spec declares it on object-kanban, and both faces of @object-ui/types declare it since objectui#11355. With the key absent the board falls back to grouping.fields[0].field |
grouping |
GroupingConfig |
The fallback for swimlaneField: with that key absent, the board splits into swimlanes by grouping.fields[0].field. Nothing else in the block is read on this board. The type is @objectstack/spec's GroupingConfig, { fields: [{ field, order?, collapsed? }] }, which both faces of @object-ui/types take by reference since objectui#11216: at least one entry, no undeclared key, and a field without leading or trailing spaces |
className |
string | Additional Tailwind CSS classes |
An undeclared lane key is accepted and dropped, not refused.
| Property | Type | Description |
|---|---|---|
id |
string | Lane identifier, matched against the groupBy value. Declare a string; since objectui#8993 a non-string id is matched by its string spelling (a lane 1 takes the group '1') instead of rendering every such card twice |
title |
string | Lane title, localized against the groupBy picklist's option labels |
cards |
KanbanCard[] | Cards this lane carries — optional, and a static board's option; an object-bound board's cards arrive from the record source |
limit |
number | Max cards allowed (WIP limit). Never reaches the query — the fetch window is the board's limit |
className |
string | Additional Tailwind CSS classes |
collapsed |
boolean | Whether the lane renders collapsed — narrowed to a title spine with its cards withheld, and its heading a disclosure the viewer can open. The authored value is the lane's INITIAL state: once a viewer toggles the lane, their choice holds for the session, including across data refreshes |
| Property | Type | Description |
|---|---|---|
id |
string | Unique card identifier |
title |
string | Card title |
description |
string | Card description |
badges |
Badge[] | Status/priority badges |
When a board is bound to an object (an object-kanban, or a kanban view inside
ListView / ObjectView) instead of being given static columns, the fields
rendered on each card resolve in priority order:
- View-level
cardFields— the fields the view configures (a kanban view'scolumns, forwarded ascardFields). An explicit choice always wins. - The object's
highlightFields— the object's ADR-0085 semantic role (its curated "most important fields"), the same list the Grid, List and Detail surfaces already default to. Used when the view configures no card fields, so a board over an object with no per-view config still surfaces meaningful fields. Entries that reference a field the object no longer declares are ignored. - A legacy semantic-field heuristic — a best-effort guess (amount, owner, priority, …) used only when neither of the above is available.
Defaulting to highlightFields keeps a card's contents consistent with the
object's other views without every kanban view having to re-declare its fields.
An object-driven board fetches at most limit records, defaulting to 100. A
board renders every fetched record into a lane and offers no pagination control,
so this is the author's window on the object — one fetch batch — rather than a
page size. The default does not follow the display page size @objectstack/spec
declares for pagination.pageSize:
import type { ObjectKanbanSchema } from '@object-ui/types'
const board: ObjectKanbanSchema = {
type: 'object-kanban',
objectName: 'opportunity',
groupBy: 'stage',
limit: 250, // rows fetched; omit for the default 100
}The dataSource binding sets it too, and the two do not rank the same way: the
binding's OWN limit wins outright, while the pagination.pageSize of a view it
names fills the cap only when the board leaves limit unset (see
Data source).
import type { ObjectKanbanSchema } from '@object-ui/types'
const taskBoard: ObjectKanbanSchema = {
type: 'object-kanban',
groupBy: 'status',
data: [],
columns: [
{
id: 'backlog',
title: 'Backlog',
cards: [
{
id: 'task-1',
title: 'Design new homepage',
description: 'Create mockups for the new landing page',
badges: [
{ label: 'Design', variant: 'default' },
{ label: 'High Priority', variant: 'destructive' }
]
}
]
},
{
id: 'in-progress',
title: 'In Progress',
limit: 3, // WIP limit
cards: [
{
id: 'task-2',
title: 'Implement authentication',
description: 'Add OAuth2 login flow',
badges: [
{ label: 'Backend', variant: 'secondary' }
]
}
]
},
{
id: 'review',
title: 'Code Review',
cards: []
},
{
id: 'done',
title: 'Done',
cards: []
}
]
}
// `onCardMove` is NOT a document key — the host supplies it as a React prop,
// separate from `taskBoard` above.
const onCardMove = (cardId: string, fromCol: string, toCol: string, index: number) => {
// Update backend/state
console.log(`Moved ${cardId} from ${fromCol} to ${toCol}`)
}import type { ObjectKanbanSchema } from '@object-ui/types'
const ticketBoard: ObjectKanbanSchema = {
type: 'object-kanban',
groupBy: 'status',
data: [],
columns: [
{
id: 'new',
title: 'New Tickets',
cards: [
{
id: 'ticket-1',
title: 'Login not working',
description: 'User cannot log in with Google',
badges: [
{ label: 'Bug', variant: 'destructive' },
{ label: 'P1', variant: 'destructive' }
]
}
]
},
{
id: 'assigned',
title: 'Assigned',
limit: 5,
cards: []
},
{
id: 'resolved',
title: 'Resolved',
cards: []
}
],
className: 'min-h-[600px]'
}import type { ObjectKanbanSchema } from '@object-ui/types'
const salesPipeline: ObjectKanbanSchema = {
type: 'object-kanban',
groupBy: 'status',
data: [],
columns: [
{
id: 'leads',
title: 'Leads',
cards: [
{
id: 'lead-1',
title: 'Acme Corp',
description: '$50,000 - Enterprise plan',
badges: [
{ label: 'Hot Lead', variant: 'destructive' }
]
}
]
},
{
id: 'qualified',
title: 'Qualified',
cards: []
},
{
id: 'proposal',
title: 'Proposal Sent',
cards: []
},
{
id: 'won',
title: 'Won',
cards: []
}
]
}Use badges to show card status, priority, or categories:
import type { KanbanCard } from '@object-ui/plugin-kanban'
const card: KanbanCard = {
id: 'task-1',
title: 'Important Task',
badges: [
{ label: 'Frontend', variant: 'default' },
{ label: 'Urgent', variant: 'destructive' },
{ label: 'Reviewed', variant: 'secondary' },
{ label: 'Blocked', variant: 'outline' }
]
}default- Blue badgesecondary- Gray badgedestructive- Red badgeoutline- Outlined badge
Set maximum cards per column to enforce work-in-progress limits:
import type { KanbanColumn } from '@object-ui/plugin-kanban'
const column: KanbanColumn = {
id: 'in-progress',
title: 'In Progress',
limit: 3, // Max 3 cards
cards: [
{ id: 'task-2', title: 'Implement authentication' }
]
}When the limit is reached, the column shows visual feedback.
Handle card movements to update your backend or state:
import { useState } from 'react'
import type { KanbanColumn } from '@object-ui/plugin-kanban'
import type { ObjectKanbanSchema } from '@object-ui/types'
declare function updateCardColumn(cardId: string, toColumnId: string, newIndex: number): Promise<void>
declare function moveCard(columns: KanbanColumn[], cardId: string, toColumnId: string, newIndex: number): KanbanColumn[]
export function useBoard(initialColumns: KanbanColumn[]) {
const [columns, setColumns] = useState<KanbanColumn[]>(initialColumns)
const schema: ObjectKanbanSchema = {
type: 'object-kanban',
groupBy: 'status',
data: [],
columns,
}
// `onCardMove` is NOT a document key — the host supplies it as a React prop,
// separate from `schema` above. It returns void, so the callback cannot be
// awaited by the board: start the write and update local state without
// blocking the drop.
const onCardMove = (cardId: string, fromColumnId: string, toColumnId: string, newIndex: number) => {
// Update database
void updateCardColumn(cardId, toColumnId, newIndex)
// Update local state
setColumns((prev) => moveCard(prev, cardId, toColumnId, newIndex))
}
return { schema, onCardMove }
}The Quick Add button at the bottom of a lane is gated on both halves of a
pair: quickAdd, and an onQuickAdd handler. onQuickAdd is a function, so it
can only be supplied by a React host that mounts the board component directly:
KanbanRenderer, exported from @object-ui/plugin-kanban, reads both halves off
its schema and forwards them by identity.
kanban-ui registration
retired with objectui#8257, so a document naming that tag is now an
unknown-component error rather than a board with a working pair. Import the
component instead.
An object-driven board (object-kanban, or a kanban view inside ListView /
ObjectView) has no Quick Add control, and both halves of the pair are
retired on it: quickAdd since objectui#8285, and onQuickAdd since
objectui#11234. The board does not create records inline. Both
ObjectKanbanSchema faces in @object-ui/types refuse both keys by name, and
@objectstack/spec refuses quickAdd. ObjectKanban renders an internal board
that takes the pair only as explicit props, and it supplies neither — so even a
host that hands the board both halves of the pair gets no control. A page
written as constrained JSX draws the ordinary unknown-prop warning for
quickAdd (validateTree in @object-ui/sdui-parser), since the block has no
such prop. Delete both keys; to offer Quick Add, mount KanbanRenderer as above.
The plugin uses lazy loading to optimize bundle size:
- Initial load: ~0.2 KB (entry point)
- Lazy chunk: ~100-150 KB (loaded when kanban is rendered)
- Includes @dnd-kit for drag-and-drop functionality
The kanban board includes:
- Keyboard navigation - Move cards with keyboard
- Screen reader support - ARIA labels for all interactions
- Focus management - Clear focus indicators
ObjectKanbanSchema is published by @object-ui/types (and its Zod mirror by
@object-ui/types/zod); @object-ui/plugin-kanban publishes the card and column
shapes. Importing all three from the plugin does not resolve — its barrel never
exported the schema type.
import type { ObjectKanbanSchema } from '@object-ui/types'
import type { KanbanCard, KanbanColumn } from '@object-ui/plugin-kanban'
const column: KanbanColumn = {
id: 'todo',
title: 'To Do',
cards: [],
limit: 5
}
const kanbanSchema: ObjectKanbanSchema = {
type: 'object-kanban',
groupBy: 'status',
data: [],
columns: [column]
}