@startsimpli/ui 0.4.40 → 0.4.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.40",
3
+ "version": "0.4.43",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
7
7
  "exports": {
8
8
  ".": "./src/index.ts",
9
+ "./catalog": "./src/catalog/index.ts",
9
10
  "./table": "./src/components/unified-table/index.ts",
10
11
  "./gantt": "./src/components/gantt/index.ts",
11
12
  "./gantt/styles": "./src/components/gantt/gantt.css",
@@ -34,7 +35,7 @@
34
35
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
35
36
  "react": "^18.0.0 || ^19.0.0",
36
37
  "react-dom": "^18.0.0 || ^19.0.0",
37
- "@startsimpli/api": "0.5.39",
38
+ "@startsimpli/api": "0.5.42",
38
39
  "@startsimpli/auth": "0.4.29"
39
40
  },
40
41
  "peerDependenciesMeta": {
@@ -0,0 +1,101 @@
1
+ import {
2
+ UI_CATALOG,
3
+ getCatalogEntry,
4
+ catalogByCategory,
5
+ builtInWidgets,
6
+ renderCatalogForPrompt,
7
+ renderDashboardConfig,
8
+ widgetsNeedingScaffold,
9
+ } from '../index';
10
+
11
+ describe('UI_CATALOG', () => {
12
+ it('has unique ids', () => {
13
+ const ids = UI_CATALOG.map((e) => e.id);
14
+ expect(new Set(ids).size).toBe(ids.length);
15
+ });
16
+
17
+ it('gives every entry the fields the AI builder + scaffolder need', () => {
18
+ for (const e of UI_CATALOG) {
19
+ expect(e.id).toMatch(/^[a-z][a-z0-9-]*$/);
20
+ expect(['widget', 'view', 'component']).toContain(e.category);
21
+ expect(e.title.length).toBeGreaterThan(0);
22
+ expect(e.description.length).toBeGreaterThan(0);
23
+ expect(e.dataNeeds.length).toBeGreaterThan(0);
24
+ expect(e.source.export.length).toBeGreaterThan(0);
25
+ expect(e.source.from.startsWith('@startsimpli/ui')).toBe(true);
26
+ }
27
+ });
28
+
29
+ it('exposes the five template built-in widgets as config-only widgets', () => {
30
+ const builtIns = builtInWidgets().map((e) => e.id).sort();
31
+ expect(builtIns).toEqual(['metrics', 'pipeline', 'quick-create', 'recent', 'type-grid']);
32
+ for (const e of builtInWidgets()) expect(e.category).toBe('widget');
33
+ });
34
+
35
+ it('built-in widgets ship an example config whose kind matches their id', () => {
36
+ for (const e of builtInWidgets()) {
37
+ expect(e.configExample).toBeDefined();
38
+ expect(e.configExample!.kind).toBe(e.id);
39
+ }
40
+ });
41
+
42
+ it('flags heavy blocks so the AI can avoid them by default', () => {
43
+ // The bundle-costly ones (dnd/xlsx/calendar/gantt) must be marked heavy.
44
+ for (const id of ['kanban-board', 'data-table', 'calendar', 'gantt']) {
45
+ expect(getCatalogEntry(id)?.heavy).toBe(true);
46
+ }
47
+ // The lightweight defaults must NOT be heavy.
48
+ for (const id of ['metrics', 'quick-create', 'recent', 'type-grid']) {
49
+ expect(getCatalogEntry(id)?.heavy).toBeFalsy();
50
+ }
51
+ });
52
+
53
+ it('renderCatalogForPrompt lists every block with its heavy/built-in flags', () => {
54
+ const text = renderCatalogForPrompt();
55
+ for (const e of UI_CATALOG) expect(text).toContain(e.id);
56
+ expect(text).toContain('⚠ heavy');
57
+ expect(text).toContain('built-in');
58
+ expect(text).toContain('scaffolded');
59
+ });
60
+
61
+ it('catalogByCategory partitions the catalog', () => {
62
+ const total =
63
+ catalogByCategory('widget').length +
64
+ catalogByCategory('view').length +
65
+ catalogByCategory('component').length;
66
+ expect(total).toBe(UI_CATALOG.length);
67
+ });
68
+ });
69
+
70
+ describe('renderDashboardConfig', () => {
71
+ it('renders a valid foundry.dashboard.ts for known widgets', () => {
72
+ const r = renderDashboardConfig([
73
+ { kind: 'metrics', title: 'Overview' },
74
+ { kind: 'pipeline', type: 'topic', title: 'Pipeline' },
75
+ { kind: 'recent', limit: 8 },
76
+ ]);
77
+ expect(r.ok).toBe(true);
78
+ expect(r.errors).toEqual([]);
79
+ expect(r.file?.path).toBe('src/foundry.dashboard.ts');
80
+ expect(r.file?.content).toContain('export const dashboard: DashboardConfig = [');
81
+ expect(r.file?.content).toContain('"kind":"metrics"');
82
+ expect(r.file?.content).toContain('"type":"topic"');
83
+ expect(r.file?.content).toContain("import type { DashboardConfig } from '@/components/dashboard/config'");
84
+ });
85
+
86
+ it('rejects an unknown widget kind (never writes a config the app cannot render)', () => {
87
+ const r = renderDashboardConfig([{ kind: 'metrics' }, { kind: 'teleporter' }]);
88
+ expect(r.ok).toBe(false);
89
+ expect(r.file).toBeUndefined();
90
+ expect(r.errors.join(' ')).toContain('teleporter');
91
+ });
92
+
93
+ it('rejects an empty layout', () => {
94
+ expect(renderDashboardConfig([]).ok).toBe(false);
95
+ });
96
+
97
+ it('flags chosen widgets that are not built into the template', () => {
98
+ expect(widgetsNeedingScaffold([{ kind: 'metrics' }, { kind: 'kanban-board' }])).toEqual(['kanban-board']);
99
+ expect(widgetsNeedingScaffold([{ kind: 'metrics' }, { kind: 'recent' }])).toEqual([]);
100
+ });
101
+ });
@@ -0,0 +1,138 @@
1
+ import type { CatalogEntry } from './types';
2
+
3
+ /**
4
+ * The curated Foundry app-UI catalog. Order is roughly "reach for these first" →
5
+ * heavier/nicher last. Keep it small and honest: every entry must be genuinely
6
+ * useful in a data-driven tenant app and cheap to explain to a non-technical
7
+ * owner. `builtIn` blocks ship in the template today; the rest are scaffolded in
8
+ * on demand so the base template stays thin.
9
+ */
10
+ export const UI_CATALOG: CatalogEntry[] = [
11
+ {
12
+ id: 'metrics',
13
+ category: 'widget',
14
+ title: 'Metrics row',
15
+ description:
16
+ 'A row of big-number tiles, one per entity type, each linking to its section. The default at-a-glance overview — good on almost every app.',
17
+ dataNeeds: 'Any types (uses record counts).',
18
+ builtIn: true,
19
+ source: { export: 'MetricCard', from: '@startsimpli/ui' },
20
+ configExample: { kind: 'metrics', title: 'Overview' },
21
+ },
22
+ {
23
+ id: 'quick-create',
24
+ category: 'widget',
25
+ title: 'Quick add',
26
+ description:
27
+ 'One-click "New <type>" buttons that open the create form in a dialog. The fast path from an empty app to its first records.',
28
+ dataNeeds: 'Any types.',
29
+ builtIn: true,
30
+ source: { export: 'BaseDialog', from: '@startsimpli/ui' },
31
+ configExample: { kind: 'quick-create', title: 'Quick add' },
32
+ },
33
+ {
34
+ id: 'pipeline',
35
+ category: 'widget',
36
+ title: 'Pipeline funnel',
37
+ description:
38
+ 'A funnel of one type\'s records across the stages of its status field, with per-stage counts and conversion. Best for a clear forward workflow (e.g. lead → won). Reads oddly for branch statuses like "rejected".',
39
+ dataNeeds: 'A type with a status/enum field whose stages flow forward.',
40
+ builtIn: true,
41
+ source: { export: 'PipelineFunnel', from: '@startsimpli/ui' },
42
+ configExample: { kind: 'pipeline', type: '<typeKey>', title: 'Pipeline' },
43
+ },
44
+ {
45
+ id: 'recent',
46
+ category: 'widget',
47
+ title: 'Recent activity',
48
+ description:
49
+ 'A live feed of the most recently created records — across all types or one — with type badges and relative timestamps.',
50
+ dataNeeds: 'Any types (uses created_at).',
51
+ builtIn: true,
52
+ source: { export: 'Card', from: '@startsimpli/ui' },
53
+ configExample: { kind: 'recent', limit: 8, title: 'Recent activity' },
54
+ },
55
+ {
56
+ id: 'type-grid',
57
+ category: 'widget',
58
+ title: 'Section cards',
59
+ description:
60
+ 'The classic per-type card grid: count + status breakdown, each card links into its section. A compact index of the whole app.',
61
+ dataNeeds: 'Any types.',
62
+ builtIn: true,
63
+ source: { export: 'Card', from: '@startsimpli/ui' },
64
+ configExample: { kind: 'type-grid', title: 'All sections' },
65
+ },
66
+ {
67
+ id: 'kanban-board',
68
+ category: 'widget',
69
+ title: 'Kanban board preview',
70
+ description:
71
+ 'A drag-to-move board that lays a status type\'s records into lanes. Use as a home widget for the primary workflow type, or as the full section for it.',
72
+ dataNeeds: 'A type with a status/enum field.',
73
+ heavy: true,
74
+ source: { export: 'KanbanBoard', from: '@startsimpli/ui' },
75
+ configExample: { kind: 'kanban-board', type: '<typeKey>', title: 'Board' },
76
+ },
77
+ {
78
+ id: 'data-table',
79
+ category: 'view',
80
+ title: 'Data table',
81
+ description:
82
+ 'An enterprise table for one type — sortable columns, column picker, server pagination, Excel export. Use for dense list types the owner scans and exports.',
83
+ dataNeeds: 'Any type (renders its fields as columns).',
84
+ heavy: true,
85
+ source: { export: 'UnifiedTable', from: '@startsimpli/ui/table' },
86
+ configExample: { kind: 'data-table', type: '<typeKey>', title: 'Records' },
87
+ },
88
+ {
89
+ id: 'calendar',
90
+ category: 'view',
91
+ title: 'Calendar',
92
+ description:
93
+ 'A month/week calendar of a type\'s records placed by one of its date fields. Use for anything time-anchored — events, posts, deadlines.',
94
+ dataNeeds: 'A type with a date field.',
95
+ heavy: true,
96
+ source: { export: 'CalendarView', from: '@startsimpli/ui' },
97
+ configExample: { kind: 'calendar', type: '<typeKey>', dateField: '<fieldName>', title: 'Calendar' },
98
+ },
99
+ {
100
+ id: 'gantt',
101
+ category: 'view',
102
+ title: 'Gantt timeline',
103
+ description:
104
+ 'A timeline of records with start/end dates as bars, with board and timeline modes. Use for scheduling/project types spanning ranges.',
105
+ dataNeeds: 'A type with a start-date and an end-date field.',
106
+ heavy: true,
107
+ source: { export: 'GanttChart', from: '@startsimpli/ui/gantt' },
108
+ configExample: { kind: 'gantt', type: '<typeKey>', startField: '<start>', endField: '<end>', title: 'Timeline' },
109
+ },
110
+ {
111
+ id: 'metric-trend',
112
+ category: 'widget',
113
+ title: 'Trend metric',
114
+ description:
115
+ 'A single metric tile with a sparkline and up/down trend. Use to headline one number that matters (this week\'s new records, a running total).',
116
+ dataNeeds: 'Any type, or a numeric field tracked over time.',
117
+ source: { export: 'MetricCard', from: '@startsimpli/ui' },
118
+ configExample: { kind: 'metric-trend', type: '<typeKey>', title: 'This week' },
119
+ },
120
+ {
121
+ id: 'command-palette',
122
+ category: 'component',
123
+ title: 'Command palette',
124
+ description:
125
+ 'A ⌘K global search/jump over the app\'s sections and records. An app-shell add-on, not a home widget — enable it once for the whole app.',
126
+ dataNeeds: 'Any types.',
127
+ source: { export: 'CommandPalette', from: '@startsimpli/ui' },
128
+ },
129
+ {
130
+ id: 'chat',
131
+ category: 'component',
132
+ title: 'Chat thread',
133
+ description:
134
+ 'A threaded message view with a composer and attachments. Use only when the app genuinely models conversations (a messages type with a thread). Niche — do not add by default.',
135
+ dataNeeds: 'A messages/thread type.',
136
+ source: { export: 'ChatThread', from: '@startsimpli/ui' },
137
+ },
138
+ ];
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Foundry app-UI catalog — public surface.
3
+ *
4
+ * Import from `@startsimpli/ui/catalog` (a pure-data subpath; no component code).
5
+ * The AI app builder uses `renderCatalogForPrompt()` to "see" what it can build;
6
+ * `getCatalogEntry`/`UI_CATALOG` back the scaffolder and any component picker UI.
7
+ */
8
+ export { UI_CATALOG } from './catalog';
9
+ export type { CatalogEntry, CatalogCategory, ScaffoldFile } from './types';
10
+ export {
11
+ renderDashboardConfig,
12
+ widgetsNeedingScaffold,
13
+ type DashboardWidgetConfig,
14
+ type RenderResult,
15
+ } from './scaffold';
16
+
17
+ import { UI_CATALOG } from './catalog';
18
+ import type { CatalogCategory, CatalogEntry } from './types';
19
+
20
+ /** Look up one block by id (the foundry.dashboard.ts `kind` for widgets). */
21
+ export function getCatalogEntry(id: string): CatalogEntry | undefined {
22
+ return UI_CATALOG.find((e) => e.id === id);
23
+ }
24
+
25
+ /** All blocks in a category. */
26
+ export function catalogByCategory(category: CatalogCategory): CatalogEntry[] {
27
+ return UI_CATALOG.filter((e) => e.category === category);
28
+ }
29
+
30
+ /** The blocks the base template already ships (config-only, no scaffolding). */
31
+ export function builtInWidgets(): CatalogEntry[] {
32
+ return UI_CATALOG.filter((e) => e.builtIn);
33
+ }
34
+
35
+ /**
36
+ * A compact, token-frugal rendering of the catalog for an LLM system prompt: one
37
+ * line per block — id, category, a ⚠ heavy marker, what it needs, and the gist.
38
+ * Deterministic (catalog order) so prompts stay cache-stable.
39
+ */
40
+ export function renderCatalogForPrompt(): string {
41
+ const line = (e: CatalogEntry) => {
42
+ const flags = [e.builtIn ? 'built-in' : 'scaffolded', e.heavy ? '⚠ heavy' : null]
43
+ .filter(Boolean)
44
+ .join(', ');
45
+ return `- ${e.id} (${e.category}${flags ? `; ${flags}` : ''}) — ${e.title}: ${e.description} Needs: ${e.dataNeeds}`;
46
+ };
47
+ return UI_CATALOG.map(line).join('\n');
48
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Fork-authoring helpers: turn a chosen set of catalog blocks into the concrete
3
+ * files that go into a tenant's forked app repo. The AI app builder calls these
4
+ * to produce a reviewable change (applied as a PR), so the base template stays
5
+ * thin — only what's chosen lands in a fork.
6
+ *
7
+ * Today: `renderDashboardConfig` writes the home layout (foundry.dashboard.ts)
8
+ * from built-in widgets. Per-component scaffolding of heavier blocks (calendar,
9
+ * gantt, …) layers on next, emitting additional ScaffoldFiles.
10
+ */
11
+ import { UI_CATALOG } from './catalog';
12
+ import type { ScaffoldFile } from './types';
13
+
14
+ /** One home widget: a catalog widget id + its per-widget params (type, title…). */
15
+ export interface DashboardWidgetConfig {
16
+ kind: string;
17
+ [param: string]: unknown;
18
+ }
19
+
20
+ const WIDGET_IDS = new Set(UI_CATALOG.filter((e) => e.category === 'widget').map((e) => e.id));
21
+
22
+ /** Which of the chosen widgets are NOT built into the base template (need scaffolding). */
23
+ export function widgetsNeedingScaffold(widgets: DashboardWidgetConfig[]): string[] {
24
+ const builtIn = new Set(UI_CATALOG.filter((e) => e.builtIn).map((e) => e.id));
25
+ return [...new Set(widgets.map((w) => w.kind))].filter((k) => WIDGET_IDS.has(k) && !builtIn.has(k));
26
+ }
27
+
28
+ export interface RenderResult {
29
+ ok: boolean;
30
+ /** Unknown/invalid widget kinds — the AI should fix these before applying. */
31
+ errors: string[];
32
+ file?: ScaffoldFile;
33
+ }
34
+
35
+ /**
36
+ * Render `src/foundry.dashboard.ts` for the given home layout. Validates each
37
+ * widget `kind` against the catalog's widget blocks; an unknown kind fails the
38
+ * whole render (so we never write a config referencing a widget the app can't
39
+ * render). Serializes params as a plain literal — valid TS the owner can hand-edit.
40
+ */
41
+ export function renderDashboardConfig(widgets: DashboardWidgetConfig[]): RenderResult {
42
+ const errors: string[] = [];
43
+ if (widgets.length === 0) errors.push('layout is empty — choose at least one widget');
44
+ for (const w of widgets) {
45
+ if (!w.kind) errors.push('a widget is missing its "kind"');
46
+ else if (!WIDGET_IDS.has(w.kind)) errors.push(`unknown widget kind "${w.kind}"`);
47
+ }
48
+ if (errors.length > 0) return { ok: false, errors };
49
+
50
+ const body = widgets.map((w) => ` ${JSON.stringify(w)},`).join('\n');
51
+ const content = `/**
52
+ * Your app's home, as data. Generated by the Foundry app builder — safe to
53
+ * hand-edit. Each entry is a widget (see components/dashboard for the registry);
54
+ * add/remove/reorder them, or set this to 'auto' to derive a layout from your
55
+ * schema. Widget shapes live in components/dashboard/config.
56
+ */
57
+ import type { DashboardConfig } from '@/components/dashboard/config';
58
+
59
+ export const dashboard: DashboardConfig = [
60
+ ${body}
61
+ ];
62
+ `;
63
+ return { ok: true, errors: [], file: { path: 'src/foundry.dashboard.ts', content } };
64
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The Foundry app-UI component catalog (startsim app-builder).
3
+ *
4
+ * A curated, machine-readable index of the @startsimpli/ui building blocks a
5
+ * Foundry tenant app can use on its home / sections — the surface the AI app
6
+ * builder reads to decide what to compose, and the source of truth for how each
7
+ * gets scaffolded into a tenant's forked repo.
8
+ *
9
+ * This module is PURE DATA (no React imports) so it's cheap to pull into an LLM
10
+ * route or a Node scaffolder without dragging component code. The heavy, curated
11
+ * catalog is deliberately SMALL — the ~dozen blocks that make sense in a
12
+ * data-driven tenant app, not the ~300 exports of the kit. The AI is told to
13
+ * reach past it only when asked.
14
+ */
15
+
16
+ /**
17
+ * How a block is used in a tenant app:
18
+ * - `widget`: a home-dashboard block, referenced by `kind` in foundry.dashboard.ts.
19
+ * - `view`: a full section/page for one entity type (board, table, calendar…).
20
+ * - `component`: an app-shell/embeddable primitive (command palette, chat…).
21
+ */
22
+ export type CatalogCategory = 'widget' | 'view' | 'component';
23
+
24
+ export interface CatalogEntry {
25
+ /** Stable id. For widgets this IS the foundry.dashboard.ts `kind`. */
26
+ id: string;
27
+ category: CatalogCategory;
28
+ /** Human title shown to the owner. */
29
+ title: string;
30
+ /** What it is + when to reach for it — written for the AI builder to reason over. */
31
+ description: string;
32
+ /**
33
+ * The schema shape this block wants, in one plain phrase (e.g. "a type with a
34
+ * status/enum field", "a type with a date field"). The AI only offers a block
35
+ * when the tenant's live schema satisfies this.
36
+ */
37
+ dataNeeds: string;
38
+ /**
39
+ * Pulls a big transitive dependency (calendar, kanban dnd, xlsx, gantt). The AI
40
+ * is instructed to avoid heavy blocks unless the user explicitly asks — this is
41
+ * how "don't just throw every component in" is enforced at the catalog level.
42
+ */
43
+ heavy?: boolean;
44
+ /**
45
+ * Already shipped in the base template's widget registry — configuring it needs
46
+ * only a foundry.dashboard.ts entry (no new files). Absent = must be scaffolded
47
+ * into the fork on demand (keeps the base template thin).
48
+ */
49
+ builtIn?: boolean;
50
+ /** Where the underlying component comes from, for a scaffolded wrapper's import. */
51
+ source: { export: string; from: string };
52
+ /**
53
+ * An example foundry.dashboard.ts entry for this block, so the AI emits valid
54
+ * config. `kind` matches `id` for widgets.
55
+ */
56
+ configExample?: Record<string, unknown>;
57
+ }
58
+
59
+ /** A block plus the concrete files to add to a fork to enable it (scaffolding). */
60
+ export interface ScaffoldFile {
61
+ /** Repo-relative path within the tenant fork, e.g. src/components/dashboard/calendar-widget.tsx */
62
+ path: string;
63
+ /** Full file contents. */
64
+ content: string;
65
+ }
@@ -1,6 +1,12 @@
1
1
  'use client'
2
2
 
3
3
  import * as React from 'react'
4
+ import {
5
+ DragDropContext,
6
+ Droppable,
7
+ Draggable,
8
+ type DropResult,
9
+ } from '@hello-pangea/dnd'
4
10
  import { cn } from '../../lib/utils'
5
11
 
6
12
  export interface KanbanColumnConfig {
@@ -9,6 +15,36 @@ export interface KanbanColumnConfig {
9
15
  color?: string
10
16
  }
11
17
 
18
+ /** A resolved card move derived from a drag, in board terms (not DnD terms). */
19
+ export interface KanbanMove {
20
+ cardId: string
21
+ fromColumnId: string
22
+ toColumnId: string
23
+ toIndex: number
24
+ }
25
+
26
+ /**
27
+ * Pure resolver: turn a @hello-pangea/dnd DropResult into a KanbanMove, or null
28
+ * when the drag is a no-op (dropped outside any column, or back onto the same slot).
29
+ * Exported so consumers and tests can reason about a move without a DOM.
30
+ */
31
+ export function resolveKanbanMove(result: DropResult): KanbanMove | null {
32
+ const { draggableId, source, destination } = result
33
+ if (!destination) return null
34
+ if (
35
+ source.droppableId === destination.droppableId &&
36
+ source.index === destination.index
37
+ ) {
38
+ return null
39
+ }
40
+ return {
41
+ cardId: draggableId,
42
+ fromColumnId: source.droppableId,
43
+ toColumnId: destination.droppableId,
44
+ toIndex: destination.index,
45
+ }
46
+ }
47
+
12
48
  export interface KanbanBoardProps<T> {
13
49
  columns: KanbanColumnConfig[]
14
50
  items: Record<string, T[]>
@@ -20,6 +56,15 @@ export interface KanbanBoardProps<T> {
20
56
  columnRef?: (columnId: string) => React.Ref<HTMLDivElement> | undefined
21
57
  isColumnOver?: (columnId: string) => boolean
22
58
  className?: string
59
+ /**
60
+ * DnD (opt-in). Provide BOTH `getCardId` and `onCardMove` to make cards draggable
61
+ * between columns via @hello-pangea/dnd — all drag mechanics live here in
62
+ * @startsimpli/ui; consumers stay dnd-agnostic and just persist the move in
63
+ * `onCardMove`. Omit either and the board renders exactly as before (no DnD;
64
+ * `columnRef`/`isColumnOver` honored for a parent's own manual wiring).
65
+ */
66
+ getCardId?: (item: T) => string
67
+ onCardMove?: (move: KanbanMove) => void
23
68
  }
24
69
 
25
70
  export function KanbanBoard<T>({
@@ -33,71 +78,135 @@ export function KanbanBoard<T>({
33
78
  columnRef,
34
79
  isColumnOver,
35
80
  className,
81
+ getCardId,
82
+ onCardMove,
36
83
  }: KanbanBoardProps<T>) {
37
- return (
38
- <div className={cn('flex gap-4 overflow-x-auto pb-4', className)}>
39
- {columns.map((column) => {
40
- const columnItems = items[column.id] ?? []
41
- const ref = columnRef?.(column.id)
42
- const isOver = isColumnOver?.(column.id) ?? false
84
+ const dndEnabled = !!(getCardId && onCardMove)
43
85
 
44
- return (
45
- <div
46
- key={column.id}
47
- ref={ref}
48
- style={{ minWidth: columnWidth, maxWidth: columnWidth }}
49
- className={cn(
50
- 'flex flex-col rounded-lg border bg-muted/50 transition-colors',
51
- isOver && 'border-primary ring-2 ring-primary/20',
52
- )}
53
- >
54
- {/* Column Header */}
55
- {renderColumnHeader ? (
56
- renderColumnHeader(column, columnItems)
57
- ) : (
58
- <div className="flex items-center justify-between px-3 py-2 border-b">
59
- <div className="flex items-center gap-2">
60
- {column.color && (
61
- <span
62
- className="w-2.5 h-2.5 rounded-full shrink-0"
63
- style={{ backgroundColor: column.color }}
64
- aria-hidden="true"
65
- />
66
- )}
67
- <h3 className="text-sm font-semibold text-foreground truncate">
68
- {column.label}
69
- </h3>
70
- </div>
71
- <span className="inline-flex items-center justify-center rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
72
- {columnItems.length}
73
- </span>
74
- </div>
75
- )}
86
+ const handleDragEnd = React.useCallback(
87
+ (result: DropResult) => {
88
+ const move = resolveKanbanMove(result)
89
+ if (move) onCardMove?.(move)
90
+ },
91
+ [onCardMove],
92
+ )
93
+
94
+ const columnHeader = (column: KanbanColumnConfig, columnItems: T[]) =>
95
+ renderColumnHeader ? (
96
+ renderColumnHeader(column, columnItems)
97
+ ) : (
98
+ <div className="flex items-center justify-between px-3 py-2 border-b">
99
+ <div className="flex items-center gap-2">
100
+ {column.color && (
101
+ <span
102
+ className="w-2.5 h-2.5 rounded-full shrink-0"
103
+ style={{ backgroundColor: column.color }}
104
+ aria-hidden="true"
105
+ />
106
+ )}
107
+ <h3 className="text-sm font-semibold text-foreground truncate">
108
+ {column.label}
109
+ </h3>
110
+ </div>
111
+ <span className="inline-flex items-center justify-center rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
112
+ {columnItems.length}
113
+ </span>
114
+ </div>
115
+ )
116
+
117
+ const emptyState = (
118
+ <p className="text-xs text-muted-foreground text-center py-8">
119
+ {emptyColumnMessage}
120
+ </p>
121
+ )
76
122
 
77
- {/* Column Body */}
78
- <div className="flex-1 overflow-y-auto p-2 space-y-2">
79
- {columnItems.length === 0 ? (
80
- <p className="text-xs text-muted-foreground text-center py-8">
81
- {emptyColumnMessage}
82
- </p>
83
- ) : (
84
- columnItems.map((item, index) => (
85
- <React.Fragment key={index}>
86
- {renderCard(item)}
87
- </React.Fragment>
88
- ))
89
- )}
90
- </div>
123
+ const renderColumn = (column: KanbanColumnConfig) => {
124
+ const columnItems = items[column.id] ?? []
125
+ const footer = renderColumnFooter && (
126
+ <div className="border-t px-3 py-2">{renderColumnFooter(column)}</div>
127
+ )
91
128
 
92
- {/* Column Footer */}
93
- {renderColumnFooter && (
94
- <div className="border-t px-3 py-2">
95
- {renderColumnFooter(column)}
129
+ if (dndEnabled) {
130
+ return (
131
+ <div
132
+ key={column.id}
133
+ style={{ minWidth: columnWidth, maxWidth: columnWidth }}
134
+ className="flex flex-col rounded-lg border bg-muted/50 transition-colors"
135
+ >
136
+ {columnHeader(column, columnItems)}
137
+ <Droppable droppableId={column.id}>
138
+ {(provided, snapshot) => (
139
+ <div
140
+ ref={provided.innerRef}
141
+ {...provided.droppableProps}
142
+ className={cn(
143
+ 'flex-1 overflow-y-auto p-2 space-y-2 transition-colors',
144
+ snapshot.isDraggingOver && 'bg-primary/5',
145
+ )}
146
+ >
147
+ {columnItems.length === 0 && !snapshot.isDraggingOver
148
+ ? emptyState
149
+ : columnItems.map((item, index) => {
150
+ const id = getCardId!(item)
151
+ return (
152
+ <Draggable key={id} draggableId={id} index={index}>
153
+ {(dp, ds) => (
154
+ <div
155
+ ref={dp.innerRef}
156
+ {...dp.draggableProps}
157
+ {...dp.dragHandleProps}
158
+ className={cn(ds.isDragging && 'opacity-80')}
159
+ >
160
+ {renderCard(item)}
161
+ </div>
162
+ )}
163
+ </Draggable>
164
+ )
165
+ })}
166
+ {provided.placeholder}
96
167
  </div>
97
168
  )}
98
- </div>
99
- )
100
- })}
169
+ </Droppable>
170
+ {footer}
171
+ </div>
172
+ )
173
+ }
174
+
175
+ // Non-DnD (backward compatible): honor columnRef/isColumnOver for a parent's own
176
+ // manual drag wiring, render cards keyed by index as before.
177
+ const ref = columnRef?.(column.id)
178
+ const isOver = isColumnOver?.(column.id) ?? false
179
+ return (
180
+ <div
181
+ key={column.id}
182
+ ref={ref}
183
+ style={{ minWidth: columnWidth, maxWidth: columnWidth }}
184
+ className={cn(
185
+ 'flex flex-col rounded-lg border bg-muted/50 transition-colors',
186
+ isOver && 'border-primary ring-2 ring-primary/20',
187
+ )}
188
+ >
189
+ {columnHeader(column, columnItems)}
190
+ <div className="flex-1 overflow-y-auto p-2 space-y-2">
191
+ {columnItems.length === 0
192
+ ? emptyState
193
+ : columnItems.map((item, index) => (
194
+ <React.Fragment key={index}>{renderCard(item)}</React.Fragment>
195
+ ))}
196
+ </div>
197
+ {footer}
198
+ </div>
199
+ )
200
+ }
201
+
202
+ const board = (
203
+ <div className={cn('flex gap-4 overflow-x-auto pb-4', className)}>
204
+ {columns.map(renderColumn)}
101
205
  </div>
102
206
  )
207
+
208
+ if (dndEnabled) {
209
+ return <DragDropContext onDragEnd={handleDragEnd}>{board}</DragDropContext>
210
+ }
211
+ return board
103
212
  }
@@ -1,5 +1,6 @@
1
1
  import { render, screen } from '@testing-library/react'
2
- import { KanbanBoard } from '../KanbanBoard'
2
+ import type { DropResult } from '@hello-pangea/dnd'
3
+ import { KanbanBoard, resolveKanbanMove } from '../KanbanBoard'
3
4
 
4
5
  interface Card {
5
6
  id: string
@@ -154,4 +155,66 @@ describe('KanbanBoard', () => {
154
155
  const firstCol = container.querySelector('[style*="320"]')
155
156
  expect(firstCol).toHaveClass('ring-2')
156
157
  })
158
+
159
+ it('renders draggable cards when getCardId + onCardMove are provided (DnD enabled)', () => {
160
+ const onCardMove = jest.fn()
161
+ render(
162
+ <KanbanBoard
163
+ columns={columns}
164
+ items={items}
165
+ renderCard={renderCard}
166
+ getCardId={(c) => c.id}
167
+ onCardMove={onCardMove}
168
+ />
169
+ )
170
+ // cards still render through the @hello-pangea/dnd path without crashing
171
+ expect(screen.getByTestId('card-1')).toBeInTheDocument()
172
+ expect(screen.getByTestId('card-3')).toBeInTheDocument()
173
+ expect(screen.getByText('To Do')).toBeInTheDocument()
174
+ })
175
+ })
176
+
177
+ describe('resolveKanbanMove', () => {
178
+ const drop = (
179
+ draggableId: string,
180
+ from: string,
181
+ fromIndex: number,
182
+ to?: string,
183
+ toIndex?: number,
184
+ ): DropResult =>
185
+ ({
186
+ draggableId,
187
+ type: 'DEFAULT',
188
+ source: { droppableId: from, index: fromIndex },
189
+ destination: to === undefined ? null : { droppableId: to, index: toIndex ?? 0 },
190
+ reason: 'DROP',
191
+ mode: 'FLUID',
192
+ combine: null,
193
+ } as unknown as DropResult)
194
+
195
+ it('resolves a cross-column move', () => {
196
+ expect(resolveKanbanMove(drop('c1', 'new', 0, 'surfaced', 2))).toEqual({
197
+ cardId: 'c1',
198
+ fromColumnId: 'new',
199
+ toColumnId: 'surfaced',
200
+ toIndex: 2,
201
+ })
202
+ })
203
+
204
+ it('returns null when dropped outside any column', () => {
205
+ expect(resolveKanbanMove(drop('c1', 'new', 0))).toBeNull()
206
+ })
207
+
208
+ it('returns null when dropped back onto the same slot', () => {
209
+ expect(resolveKanbanMove(drop('c1', 'new', 1, 'new', 1))).toBeNull()
210
+ })
211
+
212
+ it('resolves a within-column reorder', () => {
213
+ expect(resolveKanbanMove(drop('c1', 'new', 0, 'new', 3))).toEqual({
214
+ cardId: 'c1',
215
+ fromColumnId: 'new',
216
+ toColumnId: 'new',
217
+ toIndex: 3,
218
+ })
219
+ })
157
220
  })
@@ -1,2 +1,2 @@
1
- export { KanbanBoard } from './KanbanBoard'
2
- export type { KanbanBoardProps, KanbanColumnConfig } from './KanbanBoard'
1
+ export { KanbanBoard, resolveKanbanMove } from './KanbanBoard'
2
+ export type { KanbanBoardProps, KanbanColumnConfig, KanbanMove } from './KanbanBoard'