From 4b0f2a7b275c3b493f3e328babafcedc328a5bcf Mon Sep 17 00:00:00 2001
From: olliethedev <5933733+olliethedev@users.noreply.github.com>
Date: Wed, 19 Aug 2026 22:16:59 +0000
Subject: [PATCH 1/3] feat(kanban): complete phase-2 primitive sweep
---
docs/content/docs/plugins/kanban.mdx | 79 +++
e2e/tests/smoke.kanban.spec.ts | 70 ++-
packages/stack/registry/btst-kanban.json | 36 +-
.../src/__tests__/kanban-query-keys.test.ts | 51 ++
.../src/__tests__/resource-factory.test.tsx | 2 +
.../src/plugins/client/resource/internal.ts | 4 +-
.../kanban/__tests__/client-sweep.test.tsx | 259 +++++++++
.../client/components/forms/board-form.tsx | 131 +++--
.../client/components/forms/column-form.tsx | 103 ++--
.../client/components/forms/task-form.tsx | 387 ++++++++-----
.../client/components/pages/404-page.tsx | 21 +-
.../components/pages/board-page.internal.tsx | 277 +++++++---
.../client/components/pages/board-page.tsx | 5 +
.../pages/boards-list-page.internal.tsx | 62 ++-
.../components/pages/boards-list-page.tsx | 1 +
.../pages/new-board-page.internal.tsx | 30 +-
.../components/pages/new-board-page.tsx | 1 +
.../components/shared/column-content.tsx | 92 +++-
.../components/shared/default-error.tsx | 17 +-
.../client/components/shared/kanban-board.tsx | 9 +
.../client/components/shared/task-card.tsx | 50 +-
.../client/components/shared/user-avatar.tsx | 14 +-
.../src/plugins/kanban/client/hooks/index.tsx | 3 +
.../kanban/client/hooks/kanban-hooks.tsx | 511 +++---------------
.../kanban/client/hooks/kanban-resource.ts | 13 +
.../client/localization/kanban-common.ts | 24 +
.../client/localization/kanban-forms.ts | 11 +
.../kanban/client/localization/kanban-list.ts | 25 +
.../stack/src/plugins/kanban/query-keys.ts | 382 ++++++++-----
29 files changed, 1716 insertions(+), 954 deletions(-)
create mode 100644 packages/stack/src/__tests__/kanban-query-keys.test.ts
create mode 100644 packages/stack/src/plugins/kanban/__tests__/client-sweep.test.tsx
create mode 100644 packages/stack/src/plugins/kanban/client/hooks/kanban-resource.ts
diff --git a/docs/content/docs/plugins/kanban.mdx b/docs/content/docs/plugins/kanban.mdx
index f531db82..0244021a 100644
--- a/docs/content/docs/plugins/kanban.mdx
+++ b/docs/content/docs/plugins/kanban.mdx
@@ -311,6 +311,39 @@ The kanban plugin automatically creates the following pages (mounted at your con
- `/kanban/new` - Create new board page
- `/kanban/:boardId` - Board detail page with kanban view
+### Client permissions
+
+Pass an [`auth` provider](/auth) to `StackProvider` to gate Kanban routes and controls. Without an auth provider (or without `auth.can`), every check is allowed, preserving the default behavior.
+
+| UI | Resource | Action | Params |
+|---|---|---|---|
+| Boards route | `kanban:board` | `read` | — |
+| New board route and buttons | `kanban:board` | `create` | — |
+| Board route | `kanban:board` | `read` | `{ id: boardId }` |
+| Edit/delete board | `kanban:board` | `update` / `delete` | `{ id: boardId }` |
+| Create column | `kanban:column` | `create` | `{ boardId }` |
+| Edit/delete column | `kanban:column` | `update` / `delete` | `{ id, boardId }` |
+| Create task | `kanban:task` | `create` | `{ boardId, columnId }` |
+| Edit/delete task | `kanban:task` | `update` / `delete` | `{ id, boardId, columnId }` |
+| Drag columns/tasks | `kanban:column` / `kanban:task` | `update` | `{ boardId }` |
+
+Denied task cards stay visible, but cannot be opened for editing. A denied board-level update check disables the corresponding drag handle.
+
+```tsx title="app/pages/[[...all]]/layout.tsx"
+ getCurrentUser(),
+ can: async ({ resource, action, params, identity }) => {
+ if (!identity) return false
+ return authorizeKanban({ resource, action, params, userId: identity.id })
+ },
+ }}
+ // ...router and overrides
+>
+ {children}
+
+```
+
### Page Component Overrides
You can replace any built-in page with your own React component using the optional `pageComponents` field in `kanbanClientPlugin(config)`. The built-in component is used as the fallback whenever an override is not provided. Overrides for parameterized routes receive the route context (`{ params }`) as props.
@@ -486,6 +519,26 @@ When a task has an assignee:
When no assignee is set, the task card shows "Unassigned" with a placeholder icon.
+
+`useResolveUser` and `useSearchUsers` intentionally remain callback-backed hooks. Assignees come from your application's user directory rather than a Kanban HTTP endpoint, so these hooks do not use the resource factory's HTTP-backed `useSelect` helper.
+
+
+## Localization
+
+All built-in Kanban component copy is routed through the `i18n` provider on `StackProvider`. Translation keys use the `kanban.*` namespace, for example `kanban.list.kanbanBoards`, `kanban.forms.createTask`, and `kanban.common.unassigned`.
+
+```tsx
+
+ i18next.t(key, { defaultValue, ...params }),
+ }}
+ // ...
+/>
+```
+
+The existing camel-case `overrides.kanban.localization` fields remain supported and take precedence over `i18n`, so current consumers can migrate incrementally.
+
## API Reference
### Backend (`@btst/stack/plugins/kanban/api`)
@@ -730,8 +783,11 @@ Import hooks from `@btst/stack/plugins/kanban/client/hooks` to use in your compo
import {
useBoards,
useBoard,
+ useBoardForm,
useBoardMutations,
+ useColumnForm,
useColumnMutations,
+ useTaskForm,
useTaskMutations,
useResolveUser,
useSearchUsers,
@@ -752,6 +808,29 @@ const { createColumn, updateColumn, deleteColumn } = useColumnMutations()
// Task mutations (includes assigneeId support)
const { createTask, updateTask, deleteTask, moveTask } = useTaskMutations()
+// Resource form lifecycles choose create/update, await cache invalidation,
+// and expose normalized server validation issues through fieldErrors.
+const boardForm = useBoardForm({
+ action: board ? "edit" : "create",
+ record: board ?? null,
+ toCreateVars: (values) => values,
+ toUpdateVars: (values) => ({ id: board.id, data: values }),
+})
+
+const columnForm = useColumnForm({
+ action: column ? "edit" : "create",
+ record: column ?? null,
+ toCreateVars: (values) => ({ ...values, boardId }),
+ toUpdateVars: (values) => ({ id: column.id, data: values }),
+})
+
+const taskForm = useTaskForm({
+ action: task ? "edit" : "create",
+ record: task ?? null,
+ toCreateVars: (values) => values,
+ toUpdateVars: (values) => ({ id: task.id, data: values }),
+})
+
// Resolve user info (with caching)
const { data: user, isLoading } = useResolveUser(assigneeId)
diff --git a/e2e/tests/smoke.kanban.spec.ts b/e2e/tests/smoke.kanban.spec.ts
index 9d489b38..86b6fe23 100644
--- a/e2e/tests/smoke.kanban.spec.ts
+++ b/e2e/tests/smoke.kanban.spec.ts
@@ -69,32 +69,11 @@ test("create task in column", async ({ page, request }) => {
await page.goto(`/pages/kanban/${board.id}`, { waitUntil: "networkidle" });
await expect(page.locator('[data-testid="board-page"]')).toBeVisible();
- // Find the "To Do" column and click "Add Task" button within it
- // The "Add Task" button should be in the column dropdown menu or as a direct action
+ // Find the "To Do" column and click its direct "Add Task" action.
const toDoColumn = page.locator('[data-slot="kanban-column"]').first();
await expect(toDoColumn).toBeVisible();
- // Click the column options and select "Add Task"
- const columnMenuButton = toDoColumn
- .locator("button")
- .filter({ has: page.locator("svg") })
- .first();
- await columnMenuButton.click();
-
- // Look for Add Task in menu or click directly if there's an add button
- const addTaskButton = page.getByRole("menuitem", { name: /add task/i });
- const addTaskVisible = await addTaskButton.isVisible().catch(() => false);
-
- if (addTaskVisible) {
- await addTaskButton.click();
- } else {
- // Close the menu and look for an alternative add task button
- await page.keyboard.press("Escape");
- const directAddButton = toDoColumn.getByRole("button", {
- name: /add task/i,
- });
- await directAddButton.click();
- }
+ await toDoColumn.getByRole("button", { name: /add task/i }).click();
// Wait for the task form dialog to appear
const dialog = page.locator('div[role="dialog"][data-slot="dialog-content"]');
@@ -184,6 +163,51 @@ test("edit task", async ({ page, request }) => {
expect(errors, `Console errors detected: \n${errors.join("\n")}`).toEqual([]);
});
+test("edit task into another column", async ({ page, request }) => {
+ const errors: string[] = [];
+ page.on("console", (msg) => {
+ if (msg.type() === "error") errors.push(msg.text());
+ });
+
+ const board = await createBoard(request, {
+ name: "Move Task Test Board",
+ description: "Board for moving a task through the edit form",
+ });
+ const sourceColumn = board.columns[0];
+ const targetColumn = board.columns[1];
+
+ await createTask(request, {
+ title: "Task to Move in Form",
+ priority: "MEDIUM",
+ columnId: sourceColumn.id,
+ });
+
+ await page.goto(`/pages/kanban/${board.id}`, { waitUntil: "networkidle" });
+ await expect(page.locator('[data-testid="board-page"]')).toBeVisible();
+ await page.getByText("Task to Move in Form").click();
+
+ const dialog = page.locator('div[role="dialog"][data-slot="dialog-content"]');
+ await expect(dialog).toBeVisible({ timeout: 5000 });
+
+ // Priority is the first select and Column is the second.
+ await dialog.locator('[data-slot="select-trigger"]').nth(1).click();
+ await page
+ .getByRole("option", { name: targetColumn.title, exact: true })
+ .click();
+ await page.getByRole("button", { name: /update task/i }).click();
+
+ await expect(dialog).not.toBeVisible({ timeout: 5000 });
+ const columns = page.locator('[data-slot="kanban-column"]');
+ await expect(
+ columns.nth(1).getByText("Task to Move in Form", { exact: true }),
+ ).toBeVisible({ timeout: 5000 });
+ await expect(
+ columns.nth(0).getByText("Task to Move in Form", { exact: true }),
+ ).toHaveCount(0);
+
+ expect(errors, `Console errors detected: \n${errors.join("\n")}`).toEqual([]);
+});
+
test("delete task", async ({ page, request }) => {
const errors: string[] = [];
page.on("console", (msg) => {
diff --git a/packages/stack/registry/btst-kanban.json b/packages/stack/registry/btst-kanban.json
index fa67c9ec..68916588 100644
--- a/packages/stack/registry/btst-kanban.json
+++ b/packages/stack/registry/btst-kanban.json
@@ -49,19 +49,19 @@
{
"path": "btst/kanban/client/components/forms/board-form.tsx",
"type": "registry:component",
- "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { Label } from \"@/components/ui/label\";\nimport { useBoardMutations } from \"@btst/stack/plugins/kanban/client/hooks\";\nimport type { SerializedBoard } from \"../../../types\";\n\ninterface BoardFormProps {\n\tboard?: SerializedBoard;\n\tonClose: () => void;\n\tonSuccess: (boardId: string) => void;\n}\n\nexport function BoardForm({ board, onClose, onSuccess }: BoardFormProps) {\n\tconst isEditing = !!board;\n\tconst { createBoard, updateBoard, isCreating, isUpdating } =\n\t\tuseBoardMutations();\n\n\tconst [name, setName] = useState(board?.name || \"\");\n\tconst [description, setDescription] = useState(board?.description || \"\");\n\tconst [error, setError] = useState(null);\n\n\tconst isPending = isCreating || isUpdating;\n\n\tconst handleSubmit = async (e: React.FormEvent) => {\n\t\te.preventDefault();\n\t\tsetError(null);\n\n\t\tif (!name.trim()) {\n\t\t\tsetError(\"Name is required\");\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tif (isEditing && board) {\n\t\t\t\tawait updateBoard(board.id, { name, description });\n\t\t\t\tonSuccess(board.id);\n\t\t\t} else {\n\t\t\t\tconst newBoard = await createBoard({ name, description });\n\t\t\t\tif (newBoard?.id) {\n\t\t\t\t\tonSuccess(newBoard.id);\n\t\t\t\t}\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tsetError(err instanceof Error ? err.message : \"An error occurred\");\n\t\t}\n\t};\n\n\treturn (\n\t\t\n\t);\n}\n",
+ "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { Label } from \"@/components/ui/label\";\nimport { usePluginOverrides, useTranslate } from \"@btst/stack/context\";\nimport { useBoardForm } from \"@btst/stack/plugins/kanban/client/hooks\";\nimport type { KanbanPluginOverrides } from \"../../overrides\";\nimport type { SerializedBoard } from \"../../../types\";\n\ninterface BoardFormProps {\n\tboard?: SerializedBoard;\n\tonClose: () => void;\n\tonSuccess: (boardId: string) => void;\n}\n\ninterface BoardFormValues {\n\tname: string;\n\tdescription: string;\n}\n\nfunction firstError(error: string | string[] | undefined): string | undefined {\n\treturn Array.isArray(error) ? error[0] : error;\n}\n\nexport function BoardForm({ board, onClose, onSuccess }: BoardFormProps) {\n\tconst t = useTranslate();\n\tconst { localization } = usePluginOverrides(\"kanban\");\n\tconst isEditing = !!board;\n\n\tconst [name, setName] = useState(board?.name || \"\");\n\tconst [description, setDescription] = useState(board?.description || \"\");\n\tconst [nameError, setNameError] = useState(null);\n\n\tconst resourceForm = useBoardForm({\n\t\taction: isEditing ? \"edit\" : \"create\",\n\t\trecord: board ?? null,\n\t\ttoCreateVars: (values) => values,\n\t\ttoUpdateVars: (values) => ({ id: board?.id ?? \"\", data: values }),\n\t\tonSuccess: (savedBoard) => onSuccess(savedBoard.id),\n\t});\n\n\tconst serverNameError = firstError(resourceForm.fieldErrors.name);\n\tconst serverDescriptionError = firstError(\n\t\tresourceForm.fieldErrors.description,\n\t);\n\tconst topLevelError =\n\t\tresourceForm.error && Object.keys(resourceForm.fieldErrors).length === 0\n\t\t\t? resourceForm.error.message\n\t\t\t: null;\n\n\tconst handleSubmit = async (event: React.FormEvent) => {\n\t\tevent.preventDefault();\n\t\tresourceForm.clearErrors();\n\t\tsetNameError(null);\n\n\t\tif (!name.trim()) {\n\t\t\tsetNameError(\n\t\t\t\tlocalization?.nameRequired ??\n\t\t\t\t\tt(\"kanban.forms.nameRequired\", \"Name is required\"),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tawait resourceForm.submit({ name, description });\n\t};\n\n\treturn (\n\t\t\n\t);\n}\n",
"target": "src/components/btst/kanban/client/components/forms/board-form.tsx"
},
{
"path": "btst/kanban/client/components/forms/column-form.tsx",
"type": "registry:component",
- "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { useColumnMutations } from \"@btst/stack/plugins/kanban/client/hooks\";\nimport type { SerializedColumn } from \"../../../types\";\n\ninterface ColumnFormProps {\n\tboardId: string;\n\tcolumnId?: string;\n\tcolumn?: SerializedColumn;\n\tonClose: () => void;\n\tonSuccess: () => void;\n}\n\nexport function ColumnForm({\n\tboardId,\n\tcolumnId,\n\tcolumn,\n\tonClose,\n\tonSuccess,\n}: ColumnFormProps) {\n\tconst isEditing = !!columnId;\n\tconst { createColumn, updateColumn, isCreating, isUpdating } =\n\t\tuseColumnMutations();\n\n\tconst [title, setTitle] = useState(column?.title || \"\");\n\tconst [error, setError] = useState(null);\n\n\tconst isPending = isCreating || isUpdating;\n\n\tconst handleSubmit = async (e: React.FormEvent) => {\n\t\te.preventDefault();\n\t\tsetError(null);\n\n\t\tif (!title.trim()) {\n\t\t\tsetError(\"Title is required\");\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tif (isEditing && columnId) {\n\t\t\t\tawait updateColumn(columnId, { title });\n\t\t\t} else {\n\t\t\t\tawait createColumn({ title, boardId });\n\t\t\t}\n\t\t\tonSuccess();\n\t\t} catch (err) {\n\t\t\tsetError(err instanceof Error ? err.message : \"An error occurred\");\n\t\t}\n\t};\n\n\treturn (\n\t\t\n\t);\n}\n",
+ "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport { usePluginOverrides, useTranslate } from \"@btst/stack/context\";\nimport { useColumnForm } from \"@btst/stack/plugins/kanban/client/hooks\";\nimport type { KanbanPluginOverrides } from \"../../overrides\";\nimport type { SerializedColumn } from \"../../../types\";\n\ninterface ColumnFormProps {\n\tboardId: string;\n\tcolumnId?: string;\n\tcolumn?: SerializedColumn;\n\tonClose: () => void;\n\tonSuccess: () => void;\n}\n\ninterface ColumnFormValues {\n\ttitle: string;\n}\n\nfunction firstError(error: string | string[] | undefined): string | undefined {\n\treturn Array.isArray(error) ? error[0] : error;\n}\n\nexport function ColumnForm({\n\tboardId,\n\tcolumnId,\n\tcolumn,\n\tonClose,\n\tonSuccess,\n}: ColumnFormProps) {\n\tconst t = useTranslate();\n\tconst { localization } = usePluginOverrides(\"kanban\");\n\tconst isEditing = !!columnId;\n\tconst [title, setTitle] = useState(column?.title || \"\");\n\tconst [titleError, setTitleError] = useState(null);\n\n\tconst resourceForm = useColumnForm({\n\t\taction: isEditing ? \"edit\" : \"create\",\n\t\trecord: column ?? null,\n\t\ttoCreateVars: (values) => ({ ...values, boardId }),\n\t\ttoUpdateVars: (values) => ({ id: columnId ?? \"\", data: values }),\n\t\tonSuccess,\n\t});\n\n\tconst serverTitleError = firstError(resourceForm.fieldErrors.title);\n\tconst topLevelError =\n\t\tresourceForm.error && Object.keys(resourceForm.fieldErrors).length === 0\n\t\t\t? resourceForm.error.message\n\t\t\t: null;\n\n\tconst handleSubmit = async (event: React.FormEvent) => {\n\t\tevent.preventDefault();\n\t\tresourceForm.clearErrors();\n\t\tsetTitleError(null);\n\n\t\tif (!title.trim()) {\n\t\t\tsetTitleError(\n\t\t\t\tlocalization?.titleRequired ??\n\t\t\t\t\tt(\"kanban.forms.titleRequired\", \"Title is required\"),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tawait resourceForm.submit({ title });\n\t};\n\n\treturn (\n\t\t\n\t);\n}\n",
"target": "src/components/btst/kanban/client/components/forms/column-form.tsx"
},
{
"path": "btst/kanban/client/components/forms/task-form.tsx",
"type": "registry:component",
- "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Trash2 } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n\tSelect,\n\tSelectContent,\n\tSelectItem,\n\tSelectTrigger,\n\tSelectValue,\n} from \"@/components/ui/select\";\nimport { MinimalTiptapEditor } from \"@/components/ui/minimal-tiptap\";\nimport SearchSelect from \"@/components/ui/search-select\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport { useTaskMutations, useSearchUsers } from \"@btst/stack/plugins/kanban/client/hooks\";\nimport type { KanbanPluginOverrides } from \"../../overrides\";\nimport { PRIORITY_OPTIONS } from \"../../../utils\";\nimport type {\n\tSerializedColumn,\n\tSerializedTask,\n\tPriority,\n} from \"../../../types\";\n\ninterface TaskFormProps {\n\tcolumnId: string;\n\tboardId: string;\n\ttaskId?: string;\n\ttask?: SerializedTask;\n\tcolumns: SerializedColumn[];\n\tonClose: () => void;\n\tonSuccess: () => void;\n\tonDelete?: () => void;\n}\n\nexport function TaskForm({\n\tcolumnId,\n\tboardId,\n\ttaskId,\n\ttask,\n\tcolumns,\n\tonClose,\n\tonSuccess,\n\tonDelete,\n}: TaskFormProps) {\n\tconst isEditing = !!taskId;\n\tconst { uploadImage, imagePicker: imagePickerTrigger } =\n\t\tusePluginOverrides(\"kanban\");\n\tconst {\n\t\tcreateTask,\n\t\tupdateTask,\n\t\tmoveTask,\n\t\tisCreating,\n\t\tisUpdating,\n\t\tisDeleting,\n\t\tisMoving,\n\t} = useTaskMutations();\n\n\tconst [title, setTitle] = useState(task?.title || \"\");\n\tconst [description, setDescription] = useState(task?.description || \"\");\n\tconst [priority, setPriority] = useState(\n\t\ttask?.priority || \"MEDIUM\",\n\t);\n\tconst [selectedColumnId, setSelectedColumnId] = useState(\n\t\ttask?.columnId || columnId,\n\t);\n\tconst [assigneeId, setAssigneeId] = useState(task?.assigneeId || \"\");\n\tconst [error, setError] = useState(null);\n\n\t// Fetch available users for assignment\n\tconst { data: users = [] } = useSearchUsers(\"\", boardId);\n\tconst userOptions = [\n\t\t{ value: \"\", label: \"Unassigned\" },\n\t\t...users.map((user) => ({ value: user.id, label: user.name })),\n\t];\n\n\tconst isPending = isCreating || isUpdating || isDeleting || isMoving;\n\n\tconst handleSubmit = async (e: React.FormEvent) => {\n\t\te.preventDefault();\n\t\tsetError(null);\n\n\t\tif (!title.trim()) {\n\t\t\tsetError(\"Title is required\");\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tif (isEditing && taskId) {\n\t\t\t\tconst isColumnChanging =\n\t\t\t\t\ttask?.columnId && selectedColumnId !== task.columnId;\n\n\t\t\t\tif (isColumnChanging) {\n\t\t\t\t\t// When changing columns, we need two operations:\n\t\t\t\t\t// 1. Update task properties (title, description, priority, assigneeId)\n\t\t\t\t\t// 2. Move task to new column with proper order calculation\n\t\t\t\t\t//\n\t\t\t\t\t// To avoid partial failure confusion, we attempt both operations\n\t\t\t\t\t// but provide clear messaging if one succeeds and the other fails.\n\n\t\t\t\t\t// First update the task properties (title, description, priority, assigneeId)\n\t\t\t\t\t// If this fails, nothing is saved and the outer catch handles it\n\t\t\t\t\tawait updateTask(taskId, {\n\t\t\t\t\t\ttitle,\n\t\t\t\t\t\tdescription,\n\t\t\t\t\t\tpriority,\n\t\t\t\t\t\tassigneeId: assigneeId || null,\n\t\t\t\t\t});\n\n\t\t\t\t\t// Then move the task to the new column with calculated order\n\t\t\t\t\t// Place at the end of the destination column\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst targetColumn = columns.find((c) => c.id === selectedColumnId);\n\t\t\t\t\t\tconst targetTasks = targetColumn?.tasks || [];\n\t\t\t\t\t\tconst targetOrder =\n\t\t\t\t\t\t\ttargetTasks.length > 0\n\t\t\t\t\t\t\t\t? Math.max(...targetTasks.map((t) => t.order)) + 1\n\t\t\t\t\t\t\t\t: 0;\n\n\t\t\t\t\t\tawait moveTask(taskId, selectedColumnId, targetOrder);\n\t\t\t\t\t} catch (moveErr) {\n\t\t\t\t\t\t// Properties were saved but column move failed\n\t\t\t\t\t\t// Provide specific error message about partial success\n\t\t\t\t\t\tconst moveErrorMsg =\n\t\t\t\t\t\t\tmoveErr instanceof Error ? moveErr.message : \"Unknown error\";\n\t\t\t\t\t\tsetError(\n\t\t\t\t\t\t\t`Task properties were saved, but moving to the new column failed: ${moveErrorMsg}. ` +\n\t\t\t\t\t\t\t\t`You can try dragging the task to the desired column.`,\n\t\t\t\t\t\t);\n\t\t\t\t\t\t// Don't call onSuccess since the operation wasn't fully completed\n\t\t\t\t\t\t// but also don't throw - we want to show the specific error\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// Same column - just update the task properties\n\t\t\t\t\tawait updateTask(taskId, {\n\t\t\t\t\t\ttitle,\n\t\t\t\t\t\tdescription,\n\t\t\t\t\t\tpriority,\n\t\t\t\t\t\tcolumnId: selectedColumnId,\n\t\t\t\t\t\tassigneeId: assigneeId || null,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tawait createTask({\n\t\t\t\t\ttitle,\n\t\t\t\t\tdescription,\n\t\t\t\t\tpriority,\n\t\t\t\t\tcolumnId: selectedColumnId,\n\t\t\t\t\tassigneeId: assigneeId || undefined,\n\t\t\t\t});\n\t\t\t}\n\t\t\tonSuccess();\n\t\t} catch (err) {\n\t\t\tsetError(err instanceof Error ? err.message : \"An error occurred\");\n\t\t}\n\t};\n\n\treturn (\n\t\t\n\t);\n}\n",
+ "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Trash2 } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n\tSelect,\n\tSelectContent,\n\tSelectItem,\n\tSelectTrigger,\n\tSelectValue,\n} from \"@/components/ui/select\";\nimport { MinimalTiptapEditor } from \"@/components/ui/minimal-tiptap\";\nimport SearchSelect from \"@/components/ui/search-select\";\nimport {\n\tCanAccess,\n\tusePluginOverrides,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport {\n\tuseTaskForm,\n\tuseTaskMutations,\n\tuseSearchUsers,\n} from \"@btst/stack/plugins/kanban/client/hooks\";\nimport type { KanbanPluginOverrides } from \"../../overrides\";\nimport type {\n\tSerializedColumn,\n\tSerializedTask,\n\tPriority,\n} from \"../../../types\";\n\ninterface TaskFormProps {\n\tcolumnId: string;\n\tboardId: string;\n\ttaskId?: string;\n\ttask?: SerializedTask;\n\tcolumns: SerializedColumn[];\n\tonClose: () => void;\n\tonSuccess: () => void;\n\tonDelete?: () => void | Promise;\n}\n\ninterface TaskFormValues {\n\ttitle: string;\n\tdescription: string;\n\tpriority: Priority;\n\tcolumnId: string;\n\tassigneeId: string;\n}\n\nfunction firstError(error: string | string[] | undefined): string | undefined {\n\treturn Array.isArray(error) ? error[0] : error;\n}\n\nexport function TaskForm({\n\tcolumnId,\n\tboardId,\n\ttaskId,\n\ttask,\n\tcolumns,\n\tonClose,\n\tonSuccess,\n\tonDelete,\n}: TaskFormProps) {\n\tconst t = useTranslate();\n\tconst {\n\t\tuploadImage,\n\t\timagePicker: imagePickerTrigger,\n\t\tlocalization,\n\t} = usePluginOverrides(\"kanban\");\n\tconst isEditing = !!taskId;\n\tconst { moveTask, isMoving } = useTaskMutations();\n\n\tconst [title, setTitle] = useState(task?.title || \"\");\n\tconst [description, setDescription] = useState(task?.description || \"\");\n\tconst [priority, setPriority] = useState(\n\t\ttask?.priority || \"MEDIUM\",\n\t);\n\tconst [selectedColumnId, setSelectedColumnId] = useState(\n\t\ttask?.columnId || columnId,\n\t);\n\tconst [assigneeId, setAssigneeId] = useState(task?.assigneeId || \"\");\n\tconst [titleError, setTitleError] = useState(null);\n\tconst [isDeleting, setIsDeleting] = useState(false);\n\n\tconst resourceForm = useTaskForm({\n\t\taction: isEditing ? \"edit\" : \"create\",\n\t\trecord: task ?? null,\n\t\ttoCreateVars: (values) => ({\n\t\t\ttitle: values.title,\n\t\t\tdescription: values.description,\n\t\t\tpriority: values.priority,\n\t\t\tcolumnId: values.columnId,\n\t\t\tassigneeId: values.assigneeId || undefined,\n\t\t}),\n\t\ttoUpdateVars: (values) => ({\n\t\t\tid: taskId ?? \"\",\n\t\t\tdata: {\n\t\t\t\ttitle: values.title,\n\t\t\t\tdescription: values.description,\n\t\t\t\tpriority: values.priority,\n\t\t\t\t...(values.columnId === task?.columnId\n\t\t\t\t\t? { columnId: values.columnId }\n\t\t\t\t\t: {}),\n\t\t\t\tassigneeId: values.assigneeId || null,\n\t\t\t},\n\t\t}),\n\t\tonSuccess: async () => {\n\t\t\tif (isEditing && taskId && selectedColumnId !== task?.columnId) {\n\t\t\t\tconst targetTasks =\n\t\t\t\t\tcolumns.find((column) => column.id === selectedColumnId)?.tasks ?? [];\n\t\t\t\tconst targetOrder =\n\t\t\t\t\ttargetTasks.length > 0\n\t\t\t\t\t\t? Math.max(...targetTasks.map((targetTask) => targetTask.order)) + 1\n\t\t\t\t\t\t: 0;\n\n\t\t\t\ttry {\n\t\t\t\t\tawait moveTask(taskId, selectedColumnId, targetOrder);\n\t\t\t\t} catch (error) {\n\t\t\t\t\tconst message =\n\t\t\t\t\t\terror instanceof Error\n\t\t\t\t\t\t\t? error.message\n\t\t\t\t\t\t\t: (localization?.errorGeneric ??\n\t\t\t\t\t\t\t\tt(\"kanban.common.errorGeneric\", \"Something went wrong\"));\n\t\t\t\t\tconst partialErrorTemplate = localization?.taskMovePartialError;\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\tpartialErrorTemplate\n\t\t\t\t\t\t\t? partialErrorTemplate.replaceAll(\"{{message}}\", message)\n\t\t\t\t\t\t\t: t(\n\t\t\t\t\t\t\t\t\t\"kanban.forms.taskMovePartialError\",\n\t\t\t\t\t\t\t\t\t\"Task properties were saved, but moving to the new column failed: {{message}}. You can try dragging the task to the desired column.\",\n\t\t\t\t\t\t\t\t\t{ message },\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tonSuccess();\n\t\t},\n\t});\n\n\tconst { data: users = [] } = useSearchUsers(\"\", boardId);\n\tconst unassigned =\n\t\tlocalization?.unassigned ?? t(\"kanban.common.unassigned\", \"Unassigned\");\n\tconst userOptions = [\n\t\t{ value: \"\", label: unassigned },\n\t\t...users.map((user) => ({ value: user.id, label: user.name })),\n\t];\n\tconst priorityOptions: Array<{ value: Priority; label: string }> = [\n\t\t{\n\t\t\tvalue: \"LOW\",\n\t\t\tlabel: localization?.priorityLow ?? t(\"kanban.common.priorityLow\", \"Low\"),\n\t\t},\n\t\t{\n\t\t\tvalue: \"MEDIUM\",\n\t\t\tlabel:\n\t\t\t\tlocalization?.priorityMedium ??\n\t\t\t\tt(\"kanban.common.priorityMedium\", \"Medium\"),\n\t\t},\n\t\t{\n\t\t\tvalue: \"HIGH\",\n\t\t\tlabel:\n\t\t\t\tlocalization?.priorityHigh ?? t(\"kanban.common.priorityHigh\", \"High\"),\n\t\t},\n\t\t{\n\t\t\tvalue: \"URGENT\",\n\t\t\tlabel:\n\t\t\t\tlocalization?.priorityUrgent ??\n\t\t\t\tt(\"kanban.common.priorityUrgent\", \"Urgent\"),\n\t\t},\n\t];\n\n\tconst isPending = resourceForm.isSubmitting || isMoving || isDeleting;\n\tconst serverTitleError = firstError(resourceForm.fieldErrors.title);\n\tconst serverDescriptionError = firstError(\n\t\tresourceForm.fieldErrors.description,\n\t);\n\tconst serverPriorityError = firstError(resourceForm.fieldErrors.priority);\n\tconst serverColumnError = firstError(resourceForm.fieldErrors.columnId);\n\tconst serverAssigneeError = firstError(resourceForm.fieldErrors.assigneeId);\n\tconst topLevelError =\n\t\tresourceForm.error && Object.keys(resourceForm.fieldErrors).length === 0\n\t\t\t? resourceForm.error.message\n\t\t\t: null;\n\n\tconst handleSubmit = async (event: React.FormEvent) => {\n\t\tevent.preventDefault();\n\t\tresourceForm.clearErrors();\n\t\tsetTitleError(null);\n\n\t\tif (!title.trim()) {\n\t\t\tsetTitleError(\n\t\t\t\tlocalization?.titleRequired ??\n\t\t\t\t\tt(\"kanban.forms.titleRequired\", \"Title is required\"),\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\t\tawait resourceForm.submit({\n\t\t\ttitle,\n\t\t\tdescription,\n\t\t\tpriority,\n\t\t\tcolumnId: selectedColumnId,\n\t\t\tassigneeId,\n\t\t});\n\t};\n\n\tconst handleDelete = async () => {\n\t\tif (!onDelete) return;\n\t\tsetIsDeleting(true);\n\t\ttry {\n\t\t\tawait onDelete();\n\t\t} finally {\n\t\t\tsetIsDeleting(false);\n\t\t}\n\t};\n\n\treturn (\n\t\t\n\t);\n}\n",
"target": "src/components/btst/kanban/client/components/forms/task-form.tsx"
},
{
@@ -85,55 +85,55 @@
{
"path": "btst/kanban/client/components/pages/404-page.tsx",
"type": "registry:page",
- "content": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { KanbanPluginOverrides } from \"../../overrides\";\n\nexport function NotFoundPage() {\n\tconst { navigate: overrideNavigate } =\n\t\tusePluginOverrides(\"kanban\");\n\tconst navigate =\n\t\toverrideNavigate ||\n\t\t((path: string) => {\n\t\t\twindow.location.href = path;\n\t\t});\n\n\treturn (\n\t\t
\n\t\t\t
Page Not Found
\n\t\t\t
\n\t\t\t\tThe page you're looking for doesn't exist.\n\t\t\t
\n\t\t\t\n\t\t
\n\t);\n}\n",
+ "content": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { usePluginOverrides, useTranslate } from \"@btst/stack/context\";\nimport type { KanbanPluginOverrides } from \"../../overrides\";\n\nexport function NotFoundPage() {\n\tconst t = useTranslate();\n\tconst { navigate: overrideNavigate, localization } =\n\t\tusePluginOverrides(\"kanban\");\n\tconst navigate =\n\t\toverrideNavigate ||\n\t\t((path: string) => {\n\t\t\twindow.location.href = path;\n\t\t});\n\n\treturn (\n\t\t
\n\t\t\t
\n\t\t\t\t{localization?.pageNotFound ??\n\t\t\t\t\tt(\"kanban.common.pageNotFound\", \"Page Not Found\")}\n\t\t\t
\n\t\t\t
\n\t\t\t\t{localization?.pageNotFoundDescription ??\n\t\t\t\t\tt(\n\t\t\t\t\t\t\"kanban.common.pageNotFoundDescription\",\n\t\t\t\t\t\t\"The page you're looking for doesn't exist.\",\n\t\t\t\t\t)}\n\t\t\t
\n\t\t\t\n\t\t
\n\t);\n}\n",
"target": "src/components/btst/kanban/client/components/pages/404-page.tsx"
},
{
"path": "btst/kanban/client/components/pages/board-page.internal.tsx",
"type": "registry:component",
- "content": "\"use client\";\n\nimport { useState, useCallback, useMemo, useEffect } from \"react\";\nimport { ArrowLeft, Plus, Settings, Trash2, Pencil } from \"lucide-react\";\nimport { toast } from \"sonner\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tDropdownMenu,\n\tDropdownMenuContent,\n\tDropdownMenuItem,\n\tDropdownMenuSeparator,\n\tDropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n\tDialog,\n\tDialogContent,\n\tDialogDescription,\n\tDialogHeader,\n\tDialogTitle,\n} from \"@/components/ui/dialog\";\nimport {\n\tAlertDialog,\n\tAlertDialogAction,\n\tAlertDialogCancel,\n\tAlertDialogContent,\n\tAlertDialogDescription,\n\tAlertDialogFooter,\n\tAlertDialogHeader,\n\tAlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport {\n\tuseSuspenseBoard,\n\tuseBoardMutations,\n\tuseColumnMutations,\n\tuseTaskMutations,\n} from \"@btst/stack/plugins/kanban/client/hooks\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { KanbanPluginOverrides } from \"../../overrides\";\nimport { KanbanBoard } from \"../shared/kanban-board\";\nimport { ColumnForm } from \"../forms/column-form\";\nimport { BoardForm } from \"../forms/board-form\";\nimport { TaskForm } from \"../forms/task-form\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport type { SerializedTask, SerializedColumn } from \"../../../types\";\n\ninterface BoardPageProps {\n\tboardId: string;\n}\n\ntype ModalState =\n\t| { type: \"none\" }\n\t| { type: \"addColumn\" }\n\t| { type: \"editColumn\"; columnId: string }\n\t| { type: \"deleteColumn\"; columnId: string }\n\t| { type: \"editBoard\" }\n\t| { type: \"deleteBoard\" }\n\t| { type: \"addTask\"; columnId: string }\n\t| { type: \"editTask\"; columnId: string; taskId: string };\n\nexport function BoardPage({ boardId }: BoardPageProps) {\n\tconst { data: board, error, refetch, isFetching } = useSuspenseBoard(boardId);\n\n\t// Suspense hooks only throw on initial fetch, not refetch failures\n\tif (error && !isFetching) {\n\t\tthrow error;\n\t}\n\n\tconst {\n\t\tLink: OverrideLink,\n\t\tnavigate: overrideNavigate,\n\t\ttaskDetailBottomSlot,\n\t} = usePluginOverrides(\"kanban\");\n\tconst navigate =\n\t\toverrideNavigate ||\n\t\t((path: string) => {\n\t\t\twindow.location.href = path;\n\t\t});\n\tconst Link = OverrideLink || \"a\";\n\n\tconst { deleteBoard, isDeleting } = useBoardMutations();\n\tconst { deleteColumn, reorderColumns } = useColumnMutations();\n\tconst { deleteTask, moveTask, reorderTasks } = useTaskMutations();\n\n\tconst [modalState, setModalState] = useState({ type: \"none\" });\n\n\t// Helper function to convert board columns to kanban state format\n\tconst computeKanbanData = useCallback(\n\t\t(\n\t\t\tcolumns: SerializedColumn[] | undefined,\n\t\t): Record => {\n\t\t\tif (!columns) return {};\n\t\t\treturn columns.reduce(\n\t\t\t\t(acc, column) => {\n\t\t\t\t\tacc[column.id] = column.tasks || [];\n\t\t\t\t\treturn acc;\n\t\t\t\t},\n\t\t\t\t{} as Record,\n\t\t\t);\n\t\t},\n\t\t[],\n\t);\n\n\t// Initialize kanbanState with data from board to avoid flash of empty state\n\t// Using lazy initializer ensures we have the correct state on first render\n\tconst [kanbanState, setKanbanState] = useState<\n\t\tRecord\n\t>(() => computeKanbanData(board?.columns));\n\n\t// Keep kanbanState in sync when server data changes (e.g., after refetch)\n\tconst serverKanbanData = useMemo(\n\t\t() => computeKanbanData(board?.columns),\n\t\t[board?.columns, computeKanbanData],\n\t);\n\n\tuseEffect(() => {\n\t\tsetKanbanState(serverKanbanData);\n\t}, [serverKanbanData]);\n\n\tconst closeModal = useCallback(() => {\n\t\tsetModalState({ type: \"none\" });\n\t}, []);\n\n\tconst handleDeleteBoard = useCallback(async () => {\n\t\ttry {\n\t\t\tawait deleteBoard(boardId);\n\t\t\tcloseModal();\n\t\t\t// Use both navigate and a fallback to ensure navigation works\n\t\t\t// Some frameworks may have issues with router.push after mutations\n\t\t\tnavigate(\"/pages/kanban\");\n\t\t\t// Fallback: if navigate doesn't work, use window.location\n\t\t\tif (typeof window !== \"undefined\") {\n\t\t\t\tsetTimeout(() => {\n\t\t\t\t\t// Only redirect if we're still on the same page after 100ms\n\t\t\t\t\tif (window.location.pathname.includes(boardId)) {\n\t\t\t\t\t\twindow.location.href = \"/pages/kanban\";\n\t\t\t\t\t}\n\t\t\t\t}, 100);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconst message =\n\t\t\t\terror instanceof Error ? error.message : \"Failed to delete board\";\n\t\t\ttoast.error(message);\n\t\t}\n\t}, [deleteBoard, boardId, navigate, closeModal]);\n\n\tconst handleKanbanChange = useCallback(\n\t\tasync (newData: Record) => {\n\t\t\tif (!board) return;\n\n\t\t\t// Capture current state for change detection\n\t\t\t// Note: We use a functional update to get the actual current state,\n\t\t\t// avoiding stale closure issues with rapid successive operations\n\t\t\tlet previousState: Record = {};\n\t\t\tsetKanbanState((current) => {\n\t\t\t\tpreviousState = current;\n\t\t\t\treturn newData;\n\t\t\t});\n\n\t\t\ttry {\n\t\t\t\t// Detect column reorder\n\t\t\t\tconst oldKeys = Object.keys(previousState);\n\t\t\t\tconst newKeys = Object.keys(newData);\n\t\t\t\tconst isColumnMove =\n\t\t\t\t\toldKeys.length === newKeys.length &&\n\t\t\t\t\toldKeys.join(\"\") !== newKeys.join(\"\");\n\n\t\t\t\tif (isColumnMove) {\n\t\t\t\t\t// Column reorder - use atomic batch endpoint with transaction support\n\t\t\t\t\tawait reorderColumns(board.id, newKeys);\n\t\t\t\t} else {\n\t\t\t\t\t// Task changes - detect cross-column moves and within-column reorders\n\t\t\t\t\tconst crossColumnMoves: Array<{\n\t\t\t\t\t\ttaskId: string;\n\t\t\t\t\t\ttargetColumnId: string;\n\t\t\t\t\t\ttargetOrder: number;\n\t\t\t\t\t}> = [];\n\t\t\t\t\tconst columnsToReorder: Map = new Map();\n\t\t\t\t\tconst targetColumnsOfCrossMove = new Set();\n\n\t\t\t\t\tfor (const [columnId, tasks] of Object.entries(newData)) {\n\t\t\t\t\t\tconst oldTasks = previousState[columnId] || [];\n\t\t\t\t\t\tlet hasOrderChanges = false;\n\n\t\t\t\t\t\tfor (let i = 0; i < tasks.length; i++) {\n\t\t\t\t\t\t\tconst task = tasks[i];\n\t\t\t\t\t\t\tif (!task) continue;\n\n\t\t\t\t\t\t\tif (task.columnId !== columnId) {\n\t\t\t\t\t\t\t\t// Task moved from another column - needs cross-column move\n\t\t\t\t\t\t\t\tcrossColumnMoves.push({\n\t\t\t\t\t\t\t\t\ttaskId: task.id,\n\t\t\t\t\t\t\t\t\ttargetColumnId: columnId,\n\t\t\t\t\t\t\t\t\ttargetOrder: i,\n\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\ttargetColumnsOfCrossMove.add(columnId);\n\t\t\t\t\t\t\t} else if (task.order !== i) {\n\t\t\t\t\t\t\t\t// Task order changed within same column\n\t\t\t\t\t\t\t\thasOrderChanges = true;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Check if tasks were removed from this column (moved elsewhere)\n\t\t\t\t\t\tconst newTaskIds = new Set(tasks.map((t) => t.id));\n\t\t\t\t\t\tconst tasksRemoved = oldTasks.some((t) => !newTaskIds.has(t.id));\n\n\t\t\t\t\t\t// If order changes within column (not a target of cross-column move),\n\t\t\t\t\t\t// use atomic reorder\n\t\t\t\t\t\tif (\n\t\t\t\t\t\t\thasOrderChanges &&\n\t\t\t\t\t\t\t!targetColumnsOfCrossMove.has(columnId) &&\n\t\t\t\t\t\t\t!tasksRemoved\n\t\t\t\t\t\t) {\n\t\t\t\t\t\t\tcolumnsToReorder.set(\n\t\t\t\t\t\t\t\tcolumnId,\n\t\t\t\t\t\t\t\ttasks.map((t) => t.id),\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\t// Handle cross-column moves first (these need individual moveTask calls)\n\t\t\t\t\tfor (const move of crossColumnMoves) {\n\t\t\t\t\t\tawait moveTask(move.taskId, move.targetColumnId, move.targetOrder);\n\t\t\t\t\t}\n\n\t\t\t\t\t// Then handle within-column reorders atomically\n\t\t\t\t\tfor (const [columnId, taskIds] of columnsToReorder) {\n\t\t\t\t\t\tawait reorderTasks(columnId, taskIds);\n\t\t\t\t\t}\n\n\t\t\t\t\t// Reorder target columns of cross-column moves to fix order collisions\n\t\t\t\t\t// The moveTask only sets the moved task's order, so other tasks need reordering\n\t\t\t\t\tfor (const targetColumnId of targetColumnsOfCrossMove) {\n\t\t\t\t\t\tconst tasks = newData[targetColumnId];\n\t\t\t\t\t\tif (tasks) {\n\t\t\t\t\t\t\tawait reorderTasks(\n\t\t\t\t\t\t\t\ttargetColumnId,\n\t\t\t\t\t\t\t\ttasks.map((t) => t.id),\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// Sync with server after successful mutations\n\t\t\t\trefetch();\n\t\t\t} catch (error) {\n\t\t\t\t// On error, refetch from server to get the authoritative state.\n\t\t\t\t// We avoid manual rollback to previousState because with rapid successive\n\t\t\t\t// operations, the captured previousState may be stale - a later operation\n\t\t\t\t// may have already updated the state, and reverting would incorrectly\n\t\t\t\t// undo that operation too. The server is the source of truth.\n\t\t\t\trefetch();\n\t\t\t\t// Re-throw so error boundaries or toast handlers can catch it\n\t\t\t\tthrow error;\n\t\t\t}\n\t\t},\n\t\t[board, reorderColumns, moveTask, reorderTasks, refetch],\n\t);\n\n\tconst orderedColumns = useMemo(() => {\n\t\tif (!board?.columns) return [];\n\t\tconst columnMap = new Map(board.columns.map((c) => [c.id, c]));\n\t\treturn Object.keys(kanbanState)\n\t\t\t.map((columnId) => {\n\t\t\t\tconst column = columnMap.get(columnId);\n\t\t\t\tif (!column) return null;\n\t\t\t\treturn {\n\t\t\t\t\t...column,\n\t\t\t\t\ttasks: kanbanState[columnId] || [],\n\t\t\t\t};\n\t\t\t})\n\t\t\t.filter(\n\t\t\t\t(c): c is SerializedColumn & { tasks: SerializedTask[] } => c !== null,\n\t\t\t);\n\t}, [board?.columns, kanbanState]);\n\n\t// Board not found - only shown after data has loaded (not during loading)\n\tif (!board) {\n\t\treturn (\n\t\t\t navigate(\"/pages/kanban\")}>\n\t\t\t\t\t\t\n\t\t\t\t\t\tBack to Boards\n\t\t\t\t\t\n\t\t\t\t}\n\t\t\t/>\n\t\t);\n\t}\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t) : (\n\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t)}\n\t\t\n\t);\n}\n",
"target": "src/components/btst/kanban/client/components/pages/boards-list-page.internal.tsx"
},
{
"path": "btst/kanban/client/components/pages/boards-list-page.tsx",
"type": "registry:page",
- "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { DefaultError } from \"../shared/default-error\";\nimport { BoardsListSkeleton } from \"../loading/boards-list-skeleton\";\nimport { NotFoundPage } from \"./404-page\";\n\nconst BoardsListPage = lazy(() =>\n\timport(\"./boards-list-page.internal\").then((m) => ({\n\t\tdefault: m.BoardsListPage,\n\t})),\n);\n\nexport function BoardsListPageComponent() {\n\treturn (\n\t\t console.error(\"BoardsListPage error:\", error)}\n\t\t/>\n\t);\n}\n",
+ "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { DefaultError } from \"../shared/default-error\";\nimport { BoardsListSkeleton } from \"../loading/boards-list-skeleton\";\nimport { NotFoundPage } from \"./404-page\";\n\nconst BoardsListPage = lazy(() =>\n\timport(\"./boards-list-page.internal\").then((m) => ({\n\t\tdefault: m.BoardsListPage,\n\t})),\n);\n\nexport function BoardsListPageComponent() {\n\treturn (\n\t\t console.error(\"BoardsListPage error:\", error)}\n\t\t/>\n\t);\n}\n",
"target": "src/components/btst/kanban/client/components/pages/boards-list-page.tsx"
},
{
"path": "btst/kanban/client/components/pages/new-board-page.internal.tsx",
"type": "registry:component",
- "content": "\"use client\";\n\nimport { ArrowLeft } from \"lucide-react\";\nimport {\n\tCard,\n\tCardContent,\n\tCardDescription,\n\tCardHeader,\n\tCardTitle,\n} from \"@/components/ui/card\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { KanbanPluginOverrides } from \"../../overrides\";\nimport { BoardForm } from \"../forms/board-form\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\n\nexport function NewBoardPage() {\n\tconst { Link: OverrideLink, navigate: overrideNavigate } =\n\t\tusePluginOverrides(\"kanban\");\n\tconst navigate =\n\t\toverrideNavigate ||\n\t\t((path: string) => {\n\t\t\twindow.location.href = path;\n\t\t});\n\tconst Link = OverrideLink || \"a\";\n\n\tconst handleSuccess = (boardId: string) => {\n\t\tnavigate(`/pages/kanban/${boardId}`);\n\t};\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\tCreate New Board\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\tSet up a new kanban board for your project\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t
\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\tBoard Details\n\t\t\t\t\t\n\t\t\t\t\t\tEnter the details for your new kanban board.\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t navigate(\"/pages/kanban\")}\n\t\t\t\t\t\tonSuccess={handleSuccess}\n\t\t\t\t\t/>\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n",
+ "content": "\"use client\";\n\nimport { ArrowLeft } from \"lucide-react\";\nimport {\n\tCard,\n\tCardContent,\n\tCardDescription,\n\tCardHeader,\n\tCardTitle,\n} from \"@/components/ui/card\";\nimport { usePluginOverrides, useTranslate } from \"@btst/stack/context\";\nimport type { KanbanPluginOverrides } from \"../../overrides\";\nimport { BoardForm } from \"../forms/board-form\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\n\nexport function NewBoardPage() {\n\tconst t = useTranslate();\n\tconst {\n\t\tLink: OverrideLink,\n\t\tnavigate: overrideNavigate,\n\t\tlocalization,\n\t} = usePluginOverrides(\"kanban\");\n\tconst navigate =\n\t\toverrideNavigate ||\n\t\t((path: string) => {\n\t\t\twindow.location.href = path;\n\t\t});\n\tconst Link = OverrideLink || \"a\";\n\n\tconst handleSuccess = (boardId: string) => {\n\t\tnavigate(`/pages/kanban/${boardId}`);\n\t};\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t{localization?.createNewBoard ??\n\t\t\t\t\t\t\tt(\"kanban.list.createNewBoard\", \"Create New Board\")}\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t\t{localization?.createNewBoardDescription ??\n\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\"kanban.list.createNewBoardDescription\",\n\t\t\t\t\t\t\t\t\"Set up a new kanban board for your project\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t
\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{localization?.boardDetails ??\n\t\t\t\t\t\t\tt(\"kanban.list.boardDetails\", \"Board Details\")}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{localization?.boardDetailsDescription ??\n\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\"kanban.list.boardDetailsDescription\",\n\t\t\t\t\t\t\t\t\"Enter the details for your new kanban board.\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t navigate(\"/pages/kanban\")}\n\t\t\t\t\t\tonSuccess={handleSuccess}\n\t\t\t\t\t/>\n\t\t\t\t\n\t\t\t\n\t\t\n\t);\n}\n",
"target": "src/components/btst/kanban/client/components/pages/new-board-page.internal.tsx"
},
{
"path": "btst/kanban/client/components/pages/new-board-page.tsx",
"type": "registry:page",
- "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { DefaultError } from \"../shared/default-error\";\nimport { BoardsListSkeleton } from \"../loading/boards-list-skeleton\";\nimport { NotFoundPage } from \"./404-page\";\n\nconst NewBoardPage = lazy(() =>\n\timport(\"./new-board-page.internal\").then((m) => ({\n\t\tdefault: m.NewBoardPage,\n\t})),\n);\n\nexport function NewBoardPageComponent() {\n\treturn (\n\t\t console.error(\"NewBoardPage error:\", error)}\n\t\t/>\n\t);\n}\n",
+ "content": "\"use client\";\n\nimport { lazy } from \"react\";\nimport { ComposedRoute } from \"@btst/stack/client/components\";\nimport { DefaultError } from \"../shared/default-error\";\nimport { BoardsListSkeleton } from \"../loading/boards-list-skeleton\";\nimport { NotFoundPage } from \"./404-page\";\n\nconst NewBoardPage = lazy(() =>\n\timport(\"./new-board-page.internal\").then((m) => ({\n\t\tdefault: m.NewBoardPage,\n\t})),\n);\n\nexport function NewBoardPageComponent() {\n\treturn (\n\t\t console.error(\"NewBoardPage error:\", error)}\n\t\t/>\n\t);\n}\n",
"target": "src/components/btst/kanban/client/components/pages/new-board-page.tsx"
},
{
"path": "btst/kanban/client/components/shared/column-content.tsx",
"type": "registry:component",
- "content": "\"use client\";\n\nimport { memo } from \"react\";\nimport { GripVertical, MoreVertical, Pencil, Plus, Trash2 } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport * as Kanban from \"@/components/ui/kanban\";\nimport {\n\tDropdownMenu,\n\tDropdownMenuContent,\n\tDropdownMenuItem,\n\tDropdownMenuSeparator,\n\tDropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { TaskCard } from \"./task-card\";\nimport type { SerializedColumn, SerializedTask } from \"../../../types\";\n\ninterface ColumnContentProps {\n\tcolumn: SerializedColumn & { tasks: SerializedTask[] };\n\tonAddTask: () => void;\n\tonEditTask: (taskId: string) => void;\n\tonEditColumn: () => void;\n\tonDeleteColumn: () => void;\n}\n\nfunction ColumnContentComponent({\n\tcolumn,\n\tonAddTask,\n\tonEditTask,\n\tonEditColumn,\n\tonDeleteColumn,\n}: ColumnContentProps) {\n\tconst hasTasks = column.tasks && column.tasks.length > 0;\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t\t\t\t\t{localization?.noTasksDescription ??\n\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\"kanban.list.noTasksDescription\",\n\t\t\t\t\t\t\t\t\t\t\"Add a task to get started\",\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t
- Create New Board
+ {localization?.createNewBoard ??
+ t("kanban.list.createNewBoard", "Create New Board")}
- Set up a new kanban board for your project
+ {localization?.createNewBoardDescription ??
+ t(
+ "kanban.list.createNewBoardDescription",
+ "Set up a new kanban board for your project",
+ )}
- Board Details
+
+ {localization?.boardDetails ??
+ t("kanban.list.boardDetails", "Board Details")}
+
- Enter the details for your new kanban board.
+ {localization?.boardDetailsDescription ??
+ t(
+ "kanban.list.boardDetailsDescription",
+ "Enter the details for your new kanban board.",
+ )}
diff --git a/packages/stack/src/plugins/kanban/client/components/pages/new-board-page.tsx b/packages/stack/src/plugins/kanban/client/components/pages/new-board-page.tsx
index 73f2654e..99b12826 100644
--- a/packages/stack/src/plugins/kanban/client/components/pages/new-board-page.tsx
+++ b/packages/stack/src/plugins/kanban/client/components/pages/new-board-page.tsx
@@ -16,6 +16,7 @@ export function NewBoardPageComponent() {
return (
void;
onEditTask: (taskId: string) => void;
onEditColumn: () => void;
@@ -24,18 +33,23 @@ interface ColumnContentProps {
}
function ColumnContentComponent({
+ boardId,
column,
+ canMoveColumn,
+ canMoveTasks,
onAddTask,
onEditTask,
onEditColumn,
onDeleteColumn,
}: ColumnContentProps) {
+ const t = useTranslate();
+ const { localization } = usePluginOverrides("kanban");
const hasTasks = column.tasks && column.tasks.length > 0;
return (
{hasTasks ? (
From 9c175348ed601b30384c9731056a5b8f24fdfe83 Mon Sep 17 00:00:00 2001
From: olliethedev <5933733+olliethedev@users.noreply.github.com>
Date: Wed, 19 Aug 2026 22:26:54 +0000
Subject: [PATCH 3/3] docs(kanban): clarify server authorization
---
docs/content/docs/plugins/kanban.mdx | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/docs/content/docs/plugins/kanban.mdx b/docs/content/docs/plugins/kanban.mdx
index 0244021a..539ac813 100644
--- a/docs/content/docs/plugins/kanban.mdx
+++ b/docs/content/docs/plugins/kanban.mdx
@@ -315,6 +315,10 @@ The kanban plugin automatically creates the following pages (mounted at your con
Pass an [`auth` provider](/auth) to `StackProvider` to gate Kanban routes and controls. Without an auth provider (or without `auth.can`), every check is allowed, preserving the default behavior.
+
+These client checks control presentation and navigation only. Enforce the same authorization policy on your server endpoints; users can still invoke Kanban API mutations directly without the gated controls.
+
+
| UI | Resource | Action | Params |
|---|---|---|---|
| Boards route | `kanban:board` | `read` | — |