Skip to content

Latest commit

 

History

History
560 lines (466 loc) · 20.2 KB

File metadata and controls

560 lines (466 loc) · 20.2 KB
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.

Installation

npm install @object-ui/plugin-kanban

This 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']}>

Interactive Examples

Usage

Basic Usage

// 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`)
}

Features

  • Drag and drop cards between columns — on an object-bound board (object-kanban), only for a caller the kanbanCardMove row 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 summarizeField sums 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)

Schema API

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.

**The bare-string array applies only to a board with no `groupBy`.** It is declared so this package does not refuse an authoring `@objectstack/spec` allows. The renderer only reads a bare-string lane list when a board has **no** `groupBy`, so on a board that declares one the strings are ignored and the lanes come from the group field's picklist options or from the data.

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. ⚠️ A lane-less board holds no cards: with no lane key the records are never distributed into lanes, and dragging a card writes nothing back. It is lane headings, not a populated board. To control the lanes of a working board, declare 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'
  }>
}

Properties

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

Lane Properties

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

Card Properties

Property Type Description
id string Unique card identifier
title string Card title
description string Card description
badges Badge[] Status/priority badges

Card fields (object-driven boards)

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:

  1. View-level cardFields — the fields the view configures (a kanban view's columns, forwarded as cardFields). An explicit choice always wins.
  2. 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.
  3. 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.

Fetch batch (object-driven boards)

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).

This is the **board's** `limit`, not a column's. `limit` on a *column* is that lane's WIP limit — the card count at which the lane warns — and has no effect on the query.

Examples

Project Task Board

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}`)
}

Support Ticket Board

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]'
}

Sales Pipeline

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: []
    }
  ]
}

Card Badges

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' }
  ]
}

Badge Variants

  • default - Blue badge
  • secondary - Gray badge
  • destructive - Red badge
  • outline - Outlined badge

Column Limits (WIP Limits)

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.

Event Handling

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 }
}

Quick Add is a React-host capability, not a document one

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.

⚠️ There is no node type key that reaches it. The 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.

Bundle Size

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

Accessibility

The kanban board includes:

  • Keyboard navigation - Move cards with keyboard
  • Screen reader support - ARIA labels for all interactions
  • Focus management - Clear focus indicators

TypeScript Support

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]
}

Related Documentation