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\t\t
\n\t\t\t\t\n\t\t\t\t) =>\n\t\t\t\t\t\tsetName(e.target.value)\n\t\t\t\t\t}\n\t\t\t\t\tplaceholder=\"e.g., Project Alpha\"\n\t\t\t\t\tdisabled={isPending}\n\t\t\t\t/>\n\t\t\t
\n\n\t\t\t
\n\t\t\t\t\n\t\t\t\t) =>\n\t\t\t\t\t\tsetDescription(e.target.value)\n\t\t\t\t\t}\n\t\t\t\t\tplaceholder=\"Describe your board...\"\n\t\t\t\t\tdisabled={isPending}\n\t\t\t\t\trows={3}\n\t\t\t\t/>\n\t\t\t
\n\n\t\t\t{error && (\n\t\t\t\t
\n\t\t\t\t\t{error}\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\tCancel\n\t\t\t\t\n\t\t\t
\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\t\t
\n\t\t\t\t\n\t\t\t\t) => {\n\t\t\t\t\t\tsetName(event.target.value);\n\t\t\t\t\t\tsetNameError(null);\n\t\t\t\t\t}}\n\t\t\t\t\tplaceholder={\n\t\t\t\t\t\tlocalization?.boardNamePlaceholder ??\n\t\t\t\t\t\tt(\"kanban.forms.boardNamePlaceholder\", \"e.g., Project Alpha\")\n\t\t\t\t\t}\n\t\t\t\t\tdisabled={resourceForm.isSubmitting}\n\t\t\t\t\taria-invalid={!!(nameError || serverNameError)}\n\t\t\t\t/>\n\t\t\t\t{(nameError || serverNameError) && (\n\t\t\t\t\t

\n\t\t\t\t\t\t{nameError || serverNameError}\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) =>\n\t\t\t\t\t\tsetDescription(event.target.value)\n\t\t\t\t\t}\n\t\t\t\t\tplaceholder={\n\t\t\t\t\t\tlocalization?.boardDescriptionPlaceholder ??\n\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\"kanban.forms.boardDescriptionPlaceholder\",\n\t\t\t\t\t\t\t\"Describe your board...\",\n\t\t\t\t\t\t)\n\t\t\t\t\t}\n\t\t\t\t\tdisabled={resourceForm.isSubmitting}\n\t\t\t\t\trows={3}\n\t\t\t\t\taria-invalid={!!serverDescriptionError}\n\t\t\t\t/>\n\t\t\t\t{serverDescriptionError && (\n\t\t\t\t\t

{serverDescriptionError}

\n\t\t\t\t)}\n\t\t\t
\n\n\t\t\t{topLevelError && (\n\t\t\t\t
\n\t\t\t\t\t{topLevelError}\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t{localization?.cancel ?? t(\"kanban.common.cancel\", \"Cancel\")}\n\t\t\t\t\n\t\t\t
\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\t\t
\n\t\t\t\t\n\t\t\t\t) =>\n\t\t\t\t\t\tsetTitle(e.target.value)\n\t\t\t\t\t}\n\t\t\t\t\tplaceholder=\"e.g., To Do\"\n\t\t\t\t\tdisabled={isPending}\n\t\t\t\t/>\n\t\t\t
\n\n\t\t\t{error && (\n\t\t\t\t
\n\t\t\t\t\t{error}\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\tCancel\n\t\t\t\t\n\t\t\t
\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\t\t
\n\t\t\t\t\n\t\t\t\t) => {\n\t\t\t\t\t\tsetTitle(event.target.value);\n\t\t\t\t\t\tsetTitleError(null);\n\t\t\t\t\t}}\n\t\t\t\t\tplaceholder={\n\t\t\t\t\t\tlocalization?.columnTitlePlaceholder ??\n\t\t\t\t\t\tt(\"kanban.forms.columnTitlePlaceholder\", \"e.g., To Do\")\n\t\t\t\t\t}\n\t\t\t\t\tdisabled={resourceForm.isSubmitting}\n\t\t\t\t\taria-invalid={!!(titleError || serverTitleError)}\n\t\t\t\t/>\n\t\t\t\t{(titleError || serverTitleError) && (\n\t\t\t\t\t

\n\t\t\t\t\t\t{titleError || serverTitleError}\n\t\t\t\t\t

\n\t\t\t\t)}\n\t\t\t
\n\n\t\t\t{topLevelError && (\n\t\t\t\t
\n\t\t\t\t\t{topLevelError}\n\t\t\t\t
\n\t\t\t)}\n\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t{localization?.cancel ?? t(\"kanban.common.cancel\", \"Cancel\")}\n\t\t\t\t\n\t\t\t
\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\t\t
\n\t\t\t\t\n\t\t\t\t) =>\n\t\t\t\t\t\tsetTitle(e.target.value)\n\t\t\t\t\t}\n\t\t\t\t\tplaceholder=\"e.g., Fix login bug\"\n\t\t\t\t\tdisabled={isPending}\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 setPriority(v as Priority)}\n\t\t\t\t\t>\n\t\t\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\t\n\t\t\t\t\t\t\t{PRIORITY_OPTIONS.map((option) => (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{option.label}\n\t\t\t\t\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\n\t\t\t\t
\n\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\n\t\t\t
\n\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\tsetDescription(typeof value === \"string\" ? value : \"\")\n\t\t\t\t\t}\n\t\t\t\t\toutput=\"markdown\"\n\t\t\t\t\tplaceholder=\"Describe the task...\"\n\t\t\t\t\tclassName=\"min-h-[150px]\"\n\t\t\t\t\tuploader={uploadImage}\n\t\t\t\t\timagePickerTrigger={imagePickerTrigger}\n\t\t\t\t/>\n\t\t\t
\n\n\t\t\t{error && (\n\t\t\t\t
\n\t\t\t\t\t{error}\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\n\t\t\t\t\t\tCancel\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\t{isEditing && onDelete && (\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\tDelete\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 { 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\t\t
\n\t\t\t\t\n\t\t\t\t) => {\n\t\t\t\t\t\tsetTitle(event.target.value);\n\t\t\t\t\t\tsetTitleError(null);\n\t\t\t\t\t}}\n\t\t\t\t\tplaceholder={\n\t\t\t\t\t\tlocalization?.taskTitlePlaceholder ??\n\t\t\t\t\t\tt(\"kanban.forms.taskTitlePlaceholder\", \"e.g., Fix login bug\")\n\t\t\t\t\t}\n\t\t\t\t\tdisabled={isPending}\n\t\t\t\t\taria-invalid={!!(titleError || serverTitleError)}\n\t\t\t\t/>\n\t\t\t\t{(titleError || serverTitleError) && (\n\t\t\t\t\t

\n\t\t\t\t\t\t{titleError || serverTitleError}\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 setPriority(value as Priority)}\n\t\t\t\t\t\tdisabled={isPending}\n\t\t\t\t\t>\n\t\t\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\t\n\t\t\t\t\t\t\t{priorityOptions.map((option) => (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{option.label}\n\t\t\t\t\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\t{serverPriorityError && (\n\t\t\t\t\t\t

{serverPriorityError}

\n\t\t\t\t\t)}\n\t\t\t\t
\n\n\t\t\t\t
\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\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\t\n\t\t\t\t\t\t\t{columns.map((column) => (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{column.title}\n\t\t\t\t\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\t{serverColumnError && (\n\t\t\t\t\t\t

{serverColumnError}

\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\n\t\t\t\t{serverAssigneeError && (\n\t\t\t\t\t

{serverAssigneeError}

\n\t\t\t\t)}\n\t\t\t
\n\n\t\t\t
\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\tsetDescription(typeof value === \"string\" ? value : \"\")\n\t\t\t\t\t}\n\t\t\t\t\toutput=\"markdown\"\n\t\t\t\t\tplaceholder={\n\t\t\t\t\t\tlocalization?.taskDescriptionPlaceholder ??\n\t\t\t\t\t\tt(\"kanban.forms.taskDescriptionPlaceholder\", \"Describe the task...\")\n\t\t\t\t\t}\n\t\t\t\t\tclassName=\"min-h-[150px]\"\n\t\t\t\t\tuploader={uploadImage}\n\t\t\t\t\timagePickerTrigger={imagePickerTrigger}\n\t\t\t\t/>\n\t\t\t\t{serverDescriptionError && (\n\t\t\t\t\t

{serverDescriptionError}

\n\t\t\t\t)}\n\t\t\t
\n\n\t\t\t{topLevelError && (\n\t\t\t\t
\n\t\t\t\t\t{topLevelError}\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\n\t\t\t\t\t\t{localization?.cancel ?? t(\"kanban.common.cancel\", \"Cancel\")}\n\t\t\t\t\t\n\t\t\t\t
\n\t\t\t\t{isEditing && onDelete && taskId && (\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t{isDeleting\n\t\t\t\t\t\t\t\t? (localization?.deleting ??\n\t\t\t\t\t\t\t\t\tt(\"kanban.common.deleting\", \"Deleting...\"))\n\t\t\t\t\t\t\t\t: (localization?.delete ?? t(\"kanban.common.delete\", \"Delete\"))}\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/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\t
\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{board.name}\n\t\t\t\t\t\t

\n\t\t\t\t\t\t{board.description && (\n\t\t\t\t\t\t\t

{board.description}

\n\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\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t setModalState({ type: \"addColumn\" })}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tAdd Column\n\t\t\t\t\t\t\n\t\t\t\t\t\t setModalState({ type: \"editBoard\" })}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tEdit Board\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t setModalState({ type: \"deleteBoard\" })}\n\t\t\t\t\t\t\tclassName=\"text-red-600 focus:text-red-600\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tDelete Board\n\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{orderedColumns.length > 0 ? (\n\t\t\t\t setModalState({ type: \"addTask\", columnId })}\n\t\t\t\t\tonEditTask={(columnId, taskId) =>\n\t\t\t\t\t\tsetModalState({ type: \"editTask\", columnId, taskId })\n\t\t\t\t\t}\n\t\t\t\t\tonEditColumn={(columnId) =>\n\t\t\t\t\t\tsetModalState({ type: \"editColumn\", columnId })\n\t\t\t\t\t}\n\t\t\t\t\tonDeleteColumn={(columnId) =>\n\t\t\t\t\t\tsetModalState({ type: \"deleteColumn\", columnId })\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t) : (\n\t\t\t\t setModalState({ type: \"addColumn\" })}>\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tAdd Column\n\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{/* Add Column Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tAdd Column\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tAdd a new column to this board.\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t {\n\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\trefetch();\n\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{/* Edit Column Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tEdit Column\n\t\t\t\t\t\tUpdate the column details.\n\t\t\t\t\t\n\t\t\t\t\t{modalState.type === \"editColumn\" && (\n\t\t\t\t\t\t c.id === modalState.columnId)}\n\t\t\t\t\t\t\tonClose={closeModal}\n\t\t\t\t\t\t\tonSuccess={() => {\n\t\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\t\trefetch();\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\n\t\t\t{/* Delete Column Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tDelete Column\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tAre you sure you want to delete this column? All tasks in this\n\t\t\t\t\t\t\tcolumn will be permanently removed.\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tCancel\n\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\tif (modalState.type === \"deleteColumn\") {\n\t\t\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\t\t\tawait deleteColumn(modalState.columnId);\n\t\t\t\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\t\t\t\trefetch();\n\t\t\t\t\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\t\t\t\t\tconst message =\n\t\t\t\t\t\t\t\t\t\t\terror instanceof Error\n\t\t\t\t\t\t\t\t\t\t\t\t? error.message\n\t\t\t\t\t\t\t\t\t\t\t\t: \"Failed to delete column\";\n\t\t\t\t\t\t\t\t\t\ttoast.error(message);\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tclassName=\"bg-red-600 hover:bg-red-700\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tDelete\n\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{/* Edit Board Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tEdit Board\n\t\t\t\t\t\tUpdate board details.\n\t\t\t\t\t\n\t\t\t\t\t {\n\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\trefetch();\n\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{/* Delete Board Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tDelete Board\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tAre you sure you want to delete this board? This action cannot be\n\t\t\t\t\t\t\tundone. All columns and tasks will be permanently removed.\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tCancel\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{isDeleting ? \"Deleting...\" : \"Delete\"}\n\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{/* Add Task Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tAdd Task\n\t\t\t\t\t\tCreate a new task.\n\t\t\t\t\t\n\t\t\t\t\t{modalState.type === \"addTask\" && (\n\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\t\trefetch();\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\n\t\t\t{/* Edit Task Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tEdit Task\n\t\t\t\t\t\tUpdate task details.\n\t\t\t\t\t\n\t\t\t\t\t{modalState.type === \"editTask\" && (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t c.id === modalState.columnId)\n\t\t\t\t\t\t\t\t\t?.tasks?.find((t) => t.id === modalState.taskId)}\n\t\t\t\t\t\t\t\tcolumns={board.columns || []}\n\t\t\t\t\t\t\t\tonClose={closeModal}\n\t\t\t\t\t\t\t\tonSuccess={() => {\n\t\t\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\t\t\trefetch();\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\tonDelete={async () => {\n\t\t\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\t\t\tawait deleteTask(modalState.taskId);\n\t\t\t\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\t\t\t\trefetch();\n\t\t\t\t\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\t\t\t\t\tconst message =\n\t\t\t\t\t\t\t\t\t\t\terror instanceof Error\n\t\t\t\t\t\t\t\t\t\t\t\t? error.message\n\t\t\t\t\t\t\t\t\t\t\t\t: \"Failed to delete task\";\n\t\t\t\t\t\t\t\t\t\ttoast.error(message);\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t{taskDetailBottomSlot &&\n\t\t\t\t\t\t\t\t(() => {\n\t\t\t\t\t\t\t\t\tconst task = board.columns\n\t\t\t\t\t\t\t\t\t\t?.find((c) => c.id === modalState.columnId)\n\t\t\t\t\t\t\t\t\t\t?.tasks?.find((t) => t.id === modalState.taskId);\n\t\t\t\t\t\t\t\t\treturn task ? (\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{taskDetailBottomSlot(task)}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t) : null;\n\t\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", + "content": "\"use client\";\n\nimport { useState, useCallback, useMemo, useEffect } from \"react\";\nimport { ArrowLeft, Plus, Settings, Trash2, Pencil } from \"lucide-react\";\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 {\n\tCanAccess,\n\tuseCan,\n\tuseNotify,\n\tusePluginOverrides,\n\tuseTranslate,\n} 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 t = useTranslate();\n\tconst notify = useNotify();\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\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 { deleteBoard, isDeleting } = useBoardMutations();\n\tconst { deleteColumn, reorderColumns } = useColumnMutations();\n\tconst { deleteTask, moveTask, reorderTasks } = useTaskMutations();\n\tconst { can: canMoveColumns, isPending: isCheckingColumnMove } = useCan({\n\t\tresource: \"kanban:column\",\n\t\taction: \"update\",\n\t\tparams: { boardId },\n\t});\n\tconst { can: canMoveTasks, isPending: isCheckingTaskMove } = useCan({\n\t\tresource: \"kanban:task\",\n\t\taction: \"update\",\n\t\tparams: { boardId },\n\t});\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\n\t\t\t\t\t? error.message\n\t\t\t\t\t: (localization?.deleteBoardError ??\n\t\t\t\t\t\tt(\"kanban.common.deleteBoardError\", \"Failed to delete board\"));\n\t\t\tnotify.error(message);\n\t\t}\n\t}, [\n\t\tdeleteBoard,\n\t\tboardId,\n\t\tnavigate,\n\t\tcloseModal,\n\t\tlocalization?.deleteBoardError,\n\t\tnotify,\n\t\tt,\n\t]);\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 the caller's error handling can surface 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\t{localization?.backToBoards ??\n\t\t\t\t\t\t\tt(\"kanban.common.backToBoards\", \"Back 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\t
\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t\t

\n\t\t\t\t\t\t\t{board.name}\n\t\t\t\t\t\t

\n\t\t\t\t\t\t{board.description && (\n\t\t\t\t\t\t\t

{board.description}

\n\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\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t setModalState({ type: \"addColumn\" })}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization?.addColumn ??\n\t\t\t\t\t\t\t\t\tt(\"kanban.list.addColumn\", \"Add Column\")}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t setModalState({ type: \"editBoard\" })}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization?.editBoard ??\n\t\t\t\t\t\t\t\t\tt(\"kanban.list.editBoard\", \"Edit Board\")}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t setModalState({ type: \"deleteBoard\" })}\n\t\t\t\t\t\t\t\tclassName=\"text-red-600 focus:text-red-600\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization?.deleteBoard ??\n\t\t\t\t\t\t\t\t\tt(\"kanban.forms.deleteBoard\", \"Delete Board\")}\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\n\t\t\t{orderedColumns.length > 0 ? (\n\t\t\t\t setModalState({ type: \"addTask\", columnId })}\n\t\t\t\t\tonEditTask={(columnId, taskId) =>\n\t\t\t\t\t\tsetModalState({ type: \"editTask\", columnId, taskId })\n\t\t\t\t\t}\n\t\t\t\t\tonEditColumn={(columnId) =>\n\t\t\t\t\t\tsetModalState({ type: \"editColumn\", columnId })\n\t\t\t\t\t}\n\t\t\t\t\tonDeleteColumn={(columnId) =>\n\t\t\t\t\t\tsetModalState({ type: \"deleteColumn\", columnId })\n\t\t\t\t\t}\n\t\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\n\t\t\t{/* Add Column Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.addColumn ??\n\t\t\t\t\t\t\t\tt(\"kanban.list.addColumn\", \"Add Column\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.addColumnDescription ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"kanban.list.addColumnDescription\",\n\t\t\t\t\t\t\t\t\t\"Add a new column to this board.\",\n\t\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\t\n\t\t\t\t\n\t\t\t\n\n\t\t\t{/* Edit Column Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.editColumn ??\n\t\t\t\t\t\t\t\tt(\"kanban.list.editColumn\", \"Edit Column\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.editColumnDescription ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"kanban.list.editColumnDescription\",\n\t\t\t\t\t\t\t\t\t\"Update the column details.\",\n\t\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\t{modalState.type === \"editColumn\" && (\n\t\t\t\t\t\t c.id === modalState.columnId)}\n\t\t\t\t\t\t\tonClose={closeModal}\n\t\t\t\t\t\t\tonSuccess={closeModal}\n\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{/* Delete Column Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.deleteColumn ??\n\t\t\t\t\t\t\t\tt(\"kanban.forms.deleteColumn\", \"Delete Column\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.deleteColumnConfirm ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"kanban.forms.deleteColumnConfirm\",\n\t\t\t\t\t\t\t\t\t\"Are you sure you want to delete this column? All tasks in this column will be permanently removed.\",\n\t\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\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.cancel ?? t(\"kanban.common.cancel\", \"Cancel\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t {\n\t\t\t\t\t\t\t\tif (modalState.type === \"deleteColumn\") {\n\t\t\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\t\t\tawait deleteColumn(modalState.columnId);\n\t\t\t\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\t\t\t\t\tconst message =\n\t\t\t\t\t\t\t\t\t\t\terror instanceof Error\n\t\t\t\t\t\t\t\t\t\t\t\t? error.message\n\t\t\t\t\t\t\t\t\t\t\t\t: (localization?.deleteColumnError ??\n\t\t\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"kanban.common.deleteColumnError\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"Failed to delete column\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t));\n\t\t\t\t\t\t\t\t\t\tnotify.error(message);\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\tclassName=\"bg-red-600 hover:bg-red-700\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{localization?.delete ?? t(\"kanban.common.delete\", \"Delete\")}\n\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{/* Edit Board Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.editBoard ??\n\t\t\t\t\t\t\t\tt(\"kanban.list.editBoard\", \"Edit Board\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.editBoardDescription ??\n\t\t\t\t\t\t\t\tt(\"kanban.list.editBoardDescription\", \"Update board details.\")}\n\t\t\t\t\t\t\n\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{/* Delete Board Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.deleteBoard ??\n\t\t\t\t\t\t\t\tt(\"kanban.forms.deleteBoard\", \"Delete Board\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.deleteBoardConfirm ??\n\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\"kanban.forms.deleteBoardConfirm\",\n\t\t\t\t\t\t\t\t\t\"Are you sure you want to delete this board? This action cannot be undone. All columns and tasks will be permanently removed.\",\n\t\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\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.cancel ?? t(\"kanban.common.cancel\", \"Cancel\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{isDeleting\n\t\t\t\t\t\t\t\t? (localization?.deleting ??\n\t\t\t\t\t\t\t\t\tt(\"kanban.common.deleting\", \"Deleting...\"))\n\t\t\t\t\t\t\t\t: (localization?.delete ?? t(\"kanban.common.delete\", \"Delete\"))}\n\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{/* Add Task Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.addTask ?? t(\"kanban.list.addTask\", \"Add Task\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.addTaskDescription ??\n\t\t\t\t\t\t\t\tt(\"kanban.list.addTaskDescription\", \"Create a new task.\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t{modalState.type === \"addTask\" && (\n\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{/* Edit Task Modal */}\n\t\t\t !open && closeModal()}\n\t\t\t>\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.editTask ?? t(\"kanban.list.editTask\", \"Edit Task\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{localization?.editTaskDescription ??\n\t\t\t\t\t\t\t\tt(\"kanban.list.editTaskDescription\", \"Update task details.\")}\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t{modalState.type === \"editTask\" && (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t c.id === modalState.columnId)\n\t\t\t\t\t\t\t\t\t?.tasks?.find((t) => t.id === modalState.taskId)}\n\t\t\t\t\t\t\t\tcolumns={board.columns || []}\n\t\t\t\t\t\t\t\tonClose={closeModal}\n\t\t\t\t\t\t\t\tonSuccess={closeModal}\n\t\t\t\t\t\t\t\tonDelete={async () => {\n\t\t\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\t\t\tawait deleteTask(modalState.taskId);\n\t\t\t\t\t\t\t\t\t\tcloseModal();\n\t\t\t\t\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\t\t\t\t\tconst message =\n\t\t\t\t\t\t\t\t\t\t\terror instanceof Error\n\t\t\t\t\t\t\t\t\t\t\t\t? error.message\n\t\t\t\t\t\t\t\t\t\t\t\t: (localization?.deleteTaskError ??\n\t\t\t\t\t\t\t\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"kanban.common.deleteTaskError\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"Failed to delete task\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t));\n\t\t\t\t\t\t\t\t\t\tnotify.error(message);\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t{taskDetailBottomSlot &&\n\t\t\t\t\t\t\t\t(() => {\n\t\t\t\t\t\t\t\t\tconst task = board.columns\n\t\t\t\t\t\t\t\t\t\t?.find((c) => c.id === modalState.columnId)\n\t\t\t\t\t\t\t\t\t\t?.tasks?.find((t) => t.id === modalState.taskId);\n\t\t\t\t\t\t\t\t\treturn task ? (\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{taskDetailBottomSlot(task)}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t) : null;\n\t\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/board-page.internal.tsx" }, { "path": "btst/kanban/client/components/pages/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 { BoardSkeleton } from \"../loading/board-skeleton\";\nimport { NotFoundPage } from \"./404-page\";\n\nconst BoardPage = lazy(() =>\n\timport(\"./board-page.internal\").then((m) => ({\n\t\tdefault: m.BoardPage,\n\t})),\n);\n\ninterface BoardPageComponentProps {\n\tboardId: string;\n}\n\nexport function BoardPageComponent({ boardId }: BoardPageComponentProps) {\n\treturn (\n\t\t console.error(\"BoardPage 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 { BoardSkeleton } from \"../loading/board-skeleton\";\nimport { NotFoundPage } from \"./404-page\";\n\nconst BoardPage = lazy(() =>\n\timport(\"./board-page.internal\").then((m) => ({\n\t\tdefault: m.BoardPage,\n\t})),\n);\n\ninterface BoardPageComponentProps {\n\tboardId: string;\n}\n\nexport function BoardPageComponent({ boardId }: BoardPageComponentProps) {\n\treturn (\n\t\t console.error(\"BoardPage error:\", error)}\n\t\t/>\n\t);\n}\n", "target": "src/components/btst/kanban/client/components/pages/board-page.tsx" }, { "path": "btst/kanban/client/components/pages/boards-list-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { Plus } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tCard,\n\tCardContent,\n\tCardDescription,\n\tCardHeader,\n\tCardTitle,\n} from \"@/components/ui/card\";\nimport { useSuspenseBoards } from \"@btst/stack/plugins/kanban/client/hooks\";\nimport { usePluginOverrides } from \"@btst/stack/context\";\nimport type { KanbanPluginOverrides } from \"../../overrides\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { format } from \"date-fns\";\n\nexport function BoardsListPage() {\n\tconst { data: boards, error, isFetching } = useSuspenseBoards();\n\n\t// Suspense hooks only throw on initial fetch, not refetch failures\n\tif (error && !isFetching) {\n\t\tthrow error;\n\t}\n\tconst { Link: OverrideLink, navigate: overrideNavigate } =\n\t\tusePluginOverrides(\"kanban\");\n\tconst Link = OverrideLink || \"a\";\n\tconst navigate =\n\t\toverrideNavigate ||\n\t\t((path: string) => {\n\t\t\twindow.location.href = path;\n\t\t});\n\n\tconst handleNewBoard = () => {\n\t\tnavigate(\"/pages/kanban/new\");\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\t\tKanban Boards\n\t\t\t\t\t

\n\t\t\t\t\t

\n\t\t\t\t\t\tManage your projects and tasks\n\t\t\t\t\t

\n\t\t\t\t
\n\t\t\t\t\n\t\t\t
\n\n\t\t\t{boards.length > 0 ? (\n\t\t\t\t
\n\t\t\t\t\t{boards.map((board) => (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{board.name}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{board.description && (\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{board.description}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t{board.columns?.length || 0} columns\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{format(new Date(board.createdAt), \"MMM d, yyyy\")}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\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\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tCreate Board\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", + "content": "\"use client\";\n\nimport { Plus } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n\tCard,\n\tCardContent,\n\tCardDescription,\n\tCardHeader,\n\tCardTitle,\n} from \"@/components/ui/card\";\nimport { useSuspenseBoards } from \"@btst/stack/plugins/kanban/client/hooks\";\nimport {\n\tCanAccess,\n\tusePluginOverrides,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport type { KanbanPluginOverrides } from \"../../overrides\";\nimport { EmptyState } from \"../shared/empty-state\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport { format } from \"date-fns\";\n\nexport function BoardsListPage() {\n\tconst t = useTranslate();\n\tconst { data: boards, error, isFetching } = useSuspenseBoards();\n\n\t// Suspense hooks only throw on initial fetch, not refetch failures\n\tif (error && !isFetching) {\n\t\tthrow error;\n\t}\n\tconst {\n\t\tLink: OverrideLink,\n\t\tnavigate: overrideNavigate,\n\t\tlocalization,\n\t} = usePluginOverrides(\"kanban\");\n\tconst Link = OverrideLink || \"a\";\n\tconst navigate =\n\t\toverrideNavigate ||\n\t\t((path: string) => {\n\t\t\twindow.location.href = path;\n\t\t});\n\n\tconst handleNewBoard = () => {\n\t\tnavigate(\"/pages/kanban/new\");\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\t\t{localization?.kanbanBoards ??\n\t\t\t\t\t\t\tt(\"kanban.list.kanbanBoards\", \"Kanban Boards\")}\n\t\t\t\t\t

\n\t\t\t\t\t

\n\t\t\t\t\t\t{localization?.manageProjects ??\n\t\t\t\t\t\t\tt(\"kanban.list.manageProjects\", \"Manage your projects and tasks\")}\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\n\t\t\t
\n\n\t\t\t{boards.length > 0 ? (\n\t\t\t\t
\n\t\t\t\t\t{boards.map((board) => (\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t{board.name}\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{board.description && (\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{board.description}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{localization?.columnsCount\n\t\t\t\t\t\t\t\t\t\t\t\t? `${board.columns?.length || 0} ${localization.columnsCount}`\n\t\t\t\t\t\t\t\t\t\t\t\t: t(\"kanban.list.columnsCount\", \"{{count}} columns\", {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tcount: board.columns?.length || 0,\n\t\t\t\t\t\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t{format(new Date(board.createdAt), \"MMM d, yyyy\")}\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t
\n\t\t\t\t\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\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\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{column.title}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{column.tasks?.length || 0}\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\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tEdit Column\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tAdd Task\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tDelete Column\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\t
\n\t\t\t\t{hasTasks ? (\n\t\t\t\t\tcolumn.tasks.map((task) => (\n\t\t\t\t\t\t onEditTask(task.id)}\n\t\t\t\t\t\t/>\n\t\t\t\t\t))\n\t\t\t\t) : (\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t

No tasks yet

\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\tAdd a task to get started\n\t\t\t\t\t\t\t

\n\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\nexport const ColumnContent = memo(ColumnContentComponent);\n", + "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\tCanAccess,\n\tusePluginOverrides,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport type { KanbanPluginOverrides } from \"../../overrides\";\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\tboardId: string;\n\tcolumn: SerializedColumn & { tasks: SerializedTask[] };\n\tcanMoveColumn: boolean;\n\tcanMoveTasks: boolean;\n\tonAddTask: () => void;\n\tonEditTask: (taskId: string) => void;\n\tonEditColumn: () => void;\n\tonDeleteColumn: () => void;\n}\n\nfunction ColumnContentComponent({\n\tboardId,\n\tcolumn,\n\tcanMoveColumn,\n\tcanMoveTasks,\n\tonAddTask,\n\tonEditTask,\n\tonEditColumn,\n\tonDeleteColumn,\n}: ColumnContentProps) {\n\tconst t = useTranslate();\n\tconst { localization } = usePluginOverrides(\"kanban\");\n\tconst hasTasks = column.tasks && column.tasks.length > 0;\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{column.title}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{column.tasks?.length || 0}\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\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization?.editColumn ??\n\t\t\t\t\t\t\t\t\tt(\"kanban.list.editColumn\", \"Edit Column\")}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization?.addTask ?? t(\"kanban.list.addTask\", \"Add Task\")}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{localization?.deleteColumn ??\n\t\t\t\t\t\t\t\t\tt(\"kanban.forms.deleteColumn\", \"Delete Column\")}\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\t
\n\t\t\t\t{hasTasks ? (\n\t\t\t\t\tcolumn.tasks.map((task) => (\n\t\t\t\t\t\t onEditTask(task.id)}\n\t\t\t\t\t\t/>\n\t\t\t\t\t))\n\t\t\t\t) : (\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t{localization?.noTasks ??\n\t\t\t\t\t\t\t\t\tt(\"kanban.common.noTasks\", \"No tasks yet\")}\n\t\t\t\t\t\t\t

\n\t\t\t\t\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

\n\t\t\t\t\t\t
\n\t\t\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\nexport const ColumnContent = memo(ColumnContentComponent);\n", "target": "src/components/btst/kanban/client/components/shared/column-content.tsx" }, { "path": "btst/kanban/client/components/shared/default-error.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { AlertCircle, RefreshCw } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\n\ninterface DefaultErrorProps {\n\terror?: unknown;\n\treset?: () => void;\n}\n\nexport function DefaultError({ error, reset }: DefaultErrorProps) {\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t\t

Something went wrong

\n\t\t\t

\n\t\t\t\t{(error instanceof Error ? error.message : undefined) ||\n\t\t\t\t\t\"An unexpected error occurred. Please try again.\"}\n\t\t\t

\n\t\t\t{reset && (\n\t\t\t\t\n\t\t\t)}\n\t\t\n\t);\n}\n", + "content": "\"use client\";\n\nimport { AlertCircle, RefreshCw } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { usePluginOverrides, useTranslate } from \"@btst/stack/context\";\nimport type { KanbanPluginOverrides } from \"../../overrides\";\n\ninterface DefaultErrorProps {\n\terror?: unknown;\n\treset?: () => void;\n}\n\nexport function DefaultError({ error, reset }: DefaultErrorProps) {\n\tconst t = useTranslate();\n\tconst { localization } = usePluginOverrides(\"kanban\");\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t\n\t\t\t
\n\t\t\t

\n\t\t\t\t{localization?.errorGeneric ??\n\t\t\t\t\tt(\"kanban.common.errorGeneric\", \"Something went wrong\")}\n\t\t\t

\n\t\t\t

\n\t\t\t\t{(error instanceof Error ? error.message : undefined) ||\n\t\t\t\t\t(localization?.unexpectedError ??\n\t\t\t\t\t\tt(\n\t\t\t\t\t\t\t\"kanban.common.unexpectedError\",\n\t\t\t\t\t\t\t\"An unexpected error occurred. Please try again.\",\n\t\t\t\t\t\t))}\n\t\t\t

\n\t\t\t{reset && (\n\t\t\t\t\n\t\t\t)}\n\t\t\n\t);\n}\n", "target": "src/components/btst/kanban/client/components/shared/default-error.tsx" }, { @@ -145,7 +145,7 @@ { "path": "btst/kanban/client/components/shared/kanban-board.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { memo, useMemo } from \"react\";\nimport * as Kanban from \"@/components/ui/kanban\";\nimport { cn } from \"@/lib/utils\";\nimport { ColumnContent } from \"./column-content\";\nimport type { SerializedColumn, SerializedTask } from \"../../../types\";\n\ninterface KanbanBoardProps {\n\tcolumns: (SerializedColumn & { tasks: SerializedTask[] })[];\n\tkanbanState: Record;\n\tonKanbanChange: (newData: Record) => void;\n\tonAddTask: (columnId: string) => void;\n\tonEditTask: (columnId: string, taskId: string) => void;\n\tonEditColumn: (columnId: string) => void;\n\tonDeleteColumn: (columnId: string) => void;\n}\n\nfunction KanbanBoardComponent({\n\tcolumns,\n\tkanbanState,\n\tonKanbanChange,\n\tonAddTask,\n\tonEditTask,\n\tonEditColumn,\n\tonDeleteColumn,\n}: KanbanBoardProps) {\n\tconst orderedColumns = useMemo(() => {\n\t\tconst columnMap = new Map(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}, [columns, kanbanState]);\n\n\tconst mdClass = useMemo(() => {\n\t\tconst gridClassMap: Record = {\n\t\t\t1: \"md:grid-cols-1\",\n\t\t\t2: \"md:grid-cols-2\",\n\t\t\t3: \"md:grid-cols-3\",\n\t\t\t4: \"md:grid-cols-4\",\n\t\t\t5: \"md:grid-cols-5\",\n\t\t\t6: \"md:grid-cols-6\",\n\t\t};\n\t\treturn gridClassMap[orderedColumns.length] || \"md:grid-cols-6\";\n\t}, [orderedColumns.length]);\n\n\treturn (\n\t\t item.id}\n\t\t>\n\t\t\t\n\t\t\t\t{orderedColumns.map((column) => (\n\t\t\t\t\t onAddTask(column.id)}\n\t\t\t\t\t\tonEditTask={(taskId) => onEditTask(column.id, taskId)}\n\t\t\t\t\t\tonEditColumn={() => onEditColumn(column.id)}\n\t\t\t\t\t\tonDeleteColumn={() => onDeleteColumn(column.id)}\n\t\t\t\t\t/>\n\t\t\t\t))}\n\t\t\t\n\t\t\t\n\t\t\t\t
\n\t\t\t\n\t\t\n\t);\n}\n\nexport const KanbanBoard = memo(KanbanBoardComponent);\n", + "content": "\"use client\";\n\nimport { memo, useMemo } from \"react\";\nimport * as Kanban from \"@/components/ui/kanban\";\nimport { cn } from \"@/lib/utils\";\nimport { ColumnContent } from \"./column-content\";\nimport type { SerializedColumn, SerializedTask } from \"../../../types\";\n\ninterface KanbanBoardProps {\n\tboardId: string;\n\tcolumns: (SerializedColumn & { tasks: SerializedTask[] })[];\n\tkanbanState: Record;\n\tcanMoveColumns: boolean;\n\tcanMoveTasks: boolean;\n\tonKanbanChange: (newData: Record) => void;\n\tonAddTask: (columnId: string) => void;\n\tonEditTask: (columnId: string, taskId: string) => void;\n\tonEditColumn: (columnId: string) => void;\n\tonDeleteColumn: (columnId: string) => void;\n}\n\nfunction KanbanBoardComponent({\n\tboardId,\n\tcolumns,\n\tkanbanState,\n\tcanMoveColumns,\n\tcanMoveTasks,\n\tonKanbanChange,\n\tonAddTask,\n\tonEditTask,\n\tonEditColumn,\n\tonDeleteColumn,\n}: KanbanBoardProps) {\n\tconst orderedColumns = useMemo(() => {\n\t\tconst columnMap = new Map(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}, [columns, kanbanState]);\n\n\tconst mdClass = useMemo(() => {\n\t\tconst gridClassMap: Record = {\n\t\t\t1: \"md:grid-cols-1\",\n\t\t\t2: \"md:grid-cols-2\",\n\t\t\t3: \"md:grid-cols-3\",\n\t\t\t4: \"md:grid-cols-4\",\n\t\t\t5: \"md:grid-cols-5\",\n\t\t\t6: \"md:grid-cols-6\",\n\t\t};\n\t\treturn gridClassMap[orderedColumns.length] || \"md:grid-cols-6\";\n\t}, [orderedColumns.length]);\n\n\treturn (\n\t\t item.id}\n\t\t>\n\t\t\t\n\t\t\t\t{orderedColumns.map((column) => (\n\t\t\t\t\t onAddTask(column.id)}\n\t\t\t\t\t\tonEditTask={(taskId) => onEditTask(column.id, taskId)}\n\t\t\t\t\t\tonEditColumn={() => onEditColumn(column.id)}\n\t\t\t\t\t\tonDeleteColumn={() => onDeleteColumn(column.id)}\n\t\t\t\t\t/>\n\t\t\t\t))}\n\t\t\t\n\t\t\t\n\t\t\t\t
\n\t\t\t\n\t\t\n\t);\n}\n\nexport const KanbanBoard = memo(KanbanBoardComponent);\n", "target": "src/components/btst/kanban/client/components/shared/kanban-board.tsx" }, { @@ -157,13 +157,13 @@ { "path": "btst/kanban/client/components/shared/task-card.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { memo } from \"react\";\nimport { GripVertical } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport * as Kanban from \"@/components/ui/kanban\";\nimport { format } from \"date-fns\";\nimport type { SerializedTask } from \"../../../types\";\nimport { getPriorityConfig } from \"../../../utils\";\nimport { useResolveUser } from \"@btst/stack/plugins/kanban/client/hooks\";\nimport { UserAvatar } from \"./user-avatar\";\n\ninterface TaskCardProps {\n\ttask: SerializedTask;\n\tonClick: () => void;\n}\n\nfunction TaskCardComponent({ task, onClick }: TaskCardProps) {\n\tconst priorityConfig = getPriorityConfig(task.priority);\n\tconst { data: assignee } = useResolveUser(task.assigneeId);\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\t\t\n\t\t\t\t\t\t\t e.stopPropagation()}\n\t\t\t\t\t\t\t>\n\t\t\t\t\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\t\n\t\t\t\t\t\t\t{task.title}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{priorityConfig.label}\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\n\t\t\t\t\t
\n\t\t\t\t\t\t{task.assigneeId ? (\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{assignee?.name || \"Assigned\"}\n\t\t\t\t\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\t\t
\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tUnassigned\n\t\t\t\t\t\t\t
\n\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\nexport const TaskCard = memo(TaskCardComponent);\n", + "content": "\"use client\";\n\nimport { memo } from \"react\";\nimport { GripVertical } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport * as Kanban from \"@/components/ui/kanban\";\nimport { format } from \"date-fns\";\nimport type { SerializedTask } from \"../../../types\";\nimport { getPriorityConfig } from \"../../../utils\";\nimport { useResolveUser } from \"@btst/stack/plugins/kanban/client/hooks\";\nimport { UserAvatar } from \"./user-avatar\";\nimport { useCan, usePluginOverrides, useTranslate } from \"@btst/stack/context\";\nimport type { KanbanPluginOverrides } from \"../../overrides\";\n\ninterface TaskCardProps {\n\tboardId: string;\n\tcolumnId: string;\n\ttask: SerializedTask;\n\tcanMove: boolean;\n\tonClick: () => void;\n}\n\nfunction TaskCardComponent({\n\tboardId,\n\tcolumnId,\n\ttask,\n\tcanMove,\n\tonClick,\n}: TaskCardProps) {\n\tconst t = useTranslate();\n\tconst { localization } = usePluginOverrides(\"kanban\");\n\tconst { can: canEdit, isPending: isCheckingEdit } = useCan({\n\t\tresource: \"kanban:task\",\n\t\taction: \"update\",\n\t\tparams: { id: task.id, boardId, columnId },\n\t});\n\tconst priorityConfig = getPriorityConfig(task.priority);\n\tconst { data: assignee } = useResolveUser(task.assigneeId);\n\tconst priorityLabels = {\n\t\tLOW: localization?.priorityLow ?? t(\"kanban.common.priorityLow\", \"Low\"),\n\t\tMEDIUM:\n\t\t\tlocalization?.priorityMedium ??\n\t\t\tt(\"kanban.common.priorityMedium\", \"Medium\"),\n\t\tHIGH: localization?.priorityHigh ?? t(\"kanban.common.priorityHigh\", \"High\"),\n\t\tURGENT:\n\t\t\tlocalization?.priorityUrgent ??\n\t\t\tt(\"kanban.common.priorityUrgent\", \"Urgent\"),\n\t};\n\tconst editable = !isCheckingEdit && canEdit;\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\t\t\n\t\t\t\t\t\t\t e.stopPropagation()}\n\t\t\t\t\t\t\t>\n\t\t\t\t\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\t\n\t\t\t\t\t\t\t{task.title}\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t{priorityLabels[task.priority] ?? priorityConfig.label}\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\n\t\t\t\t\t
\n\t\t\t\t\t\t{task.assigneeId ? (\n\t\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{assignee?.name ||\n\t\t\t\t\t\t\t\t\t\t(localization?.assigned ??\n\t\t\t\t\t\t\t\t\t\t\tt(\"kanban.common.assigned\", \"Assigned\"))}\n\t\t\t\t\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\t\t
\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{localization?.unassigned ??\n\t\t\t\t\t\t\t\t\t\tt(\"kanban.common.unassigned\", \"Unassigned\")}\n\t\t\t\t\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\t\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t
\n\t\t\n\t);\n}\n\nexport const TaskCard = memo(TaskCardComponent);\n", "target": "src/components/btst/kanban/client/components/shared/task-card.tsx" }, { "path": "btst/kanban/client/components/shared/user-avatar.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport { memo } from \"react\";\nimport { User } from \"lucide-react\";\nimport {\n\tAvatar,\n\tAvatarImage,\n\tAvatarFallback,\n} from \"@/components/ui/avatar\";\nimport type { KanbanUser } from \"../../overrides\";\n\ninterface UserAvatarProps {\n\tuser: KanbanUser | null;\n\tsize?: \"sm\" | \"default\" | \"lg\";\n\tclassName?: string;\n}\n\n/**\n * Get initials from a user's name\n */\nfunction getInitials(name: string): string {\n\tconst parts = name.trim().split(/\\s+/);\n\tif (parts.length === 1) {\n\t\treturn parts[0]?.charAt(0).toUpperCase() || \"\";\n\t}\n\treturn (\n\t\t(parts[0]?.charAt(0) || \"\") + (parts[parts.length - 1]?.charAt(0) || \"\")\n\t).toUpperCase();\n}\n\n/**\n * UserAvatar component displays a user's avatar or initials fallback\n * Uses the shadcn Avatar component with Radix primitives\n */\nfunction UserAvatarComponent({\n\tuser,\n\tsize = \"sm\",\n\tclassName,\n}: UserAvatarProps) {\n\t// No user - show placeholder icon\n\tif (!user) {\n\t\treturn (\n\t\t\t\n\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\tconst initials = getInitials(user.name);\n\n\treturn (\n\t\t\n\t\t\t{user.avatarUrl && }\n\t\t\t\n\t\t\t\t{initials}\n\t\t\t\n\t\t\n\t);\n}\n\nexport const UserAvatar = memo(UserAvatarComponent);\n", + "content": "\"use client\";\n\nimport { memo } from \"react\";\nimport { User } from \"lucide-react\";\nimport {\n\tAvatar,\n\tAvatarImage,\n\tAvatarFallback,\n} from \"@/components/ui/avatar\";\nimport { usePluginOverrides, useTranslate } from \"@btst/stack/context\";\nimport type { KanbanPluginOverrides, KanbanUser } from \"../../overrides\";\n\ninterface UserAvatarProps {\n\tuser: KanbanUser | null;\n\tsize?: \"sm\" | \"default\" | \"lg\";\n\tclassName?: string;\n}\n\n/**\n * Get initials from a user's name\n */\nfunction getInitials(name: string): string {\n\tconst parts = name.trim().split(/\\s+/);\n\tif (parts.length === 1) {\n\t\treturn parts[0]?.charAt(0).toUpperCase() || \"\";\n\t}\n\treturn (\n\t\t(parts[0]?.charAt(0) || \"\") + (parts[parts.length - 1]?.charAt(0) || \"\")\n\t).toUpperCase();\n}\n\n/**\n * UserAvatar component displays a user's avatar or initials fallback\n * Uses the shadcn Avatar component with Radix primitives\n */\nfunction UserAvatarComponent({\n\tuser,\n\tsize = \"sm\",\n\tclassName,\n}: UserAvatarProps) {\n\tconst t = useTranslate();\n\tconst { localization } = usePluginOverrides(\"kanban\");\n\t// No user - show placeholder icon\n\tif (!user) {\n\t\treturn (\n\t\t\t\n\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\tconst initials = getInitials(user.name);\n\n\treturn (\n\t\t\n\t\t\t{user.avatarUrl && }\n\t\t\t\n\t\t\t\t{initials}\n\t\t\t\n\t\t\n\t);\n}\n\nexport const UserAvatar = memo(UserAvatarComponent);\n", "target": "src/components/btst/kanban/client/components/shared/user-avatar.tsx" }, { @@ -175,19 +175,19 @@ { "path": "btst/kanban/client/localization/kanban-common.ts", "type": "registry:lib", - "content": "export interface KanbanCommonLocalization {\n\t// Navigation\n\tbackToBoards: string;\n\t// Actions\n\tactions: string;\n\tcreate: string;\n\tedit: string;\n\tdelete: string;\n\tcancel: string;\n\tsave: string;\n\t// Status\n\tloading: string;\n\tsaving: string;\n\tdeleting: string;\n\t// Labels\n\tboard: string;\n\tboards: string;\n\tcolumn: string;\n\tcolumns: string;\n\ttask: string;\n\ttasks: string;\n\t// Priorities\n\tpriorityLow: string;\n\tpriorityMedium: string;\n\tpriorityHigh: string;\n\tpriorityUrgent: string;\n\t// Empty states\n\tnoBoards: string;\n\tnoColumns: string;\n\tnoTasks: string;\n\t// Errors\n\terrorGeneric: string;\n\terrorNotFound: string;\n}\n\nexport const defaultKanbanCommonLocalization: KanbanCommonLocalization = {\n\t// Navigation\n\tbackToBoards: \"Back to Boards\",\n\t// Actions\n\tactions: \"Actions\",\n\tcreate: \"Create\",\n\tedit: \"Edit\",\n\tdelete: \"Delete\",\n\tcancel: \"Cancel\",\n\tsave: \"Save\",\n\t// Status\n\tloading: \"Loading...\",\n\tsaving: \"Saving...\",\n\tdeleting: \"Deleting...\",\n\t// Labels\n\tboard: \"Board\",\n\tboards: \"Boards\",\n\tcolumn: \"Column\",\n\tcolumns: \"Columns\",\n\ttask: \"Task\",\n\ttasks: \"Tasks\",\n\t// Priorities\n\tpriorityLow: \"Low\",\n\tpriorityMedium: \"Medium\",\n\tpriorityHigh: \"High\",\n\tpriorityUrgent: \"Urgent\",\n\t// Empty states\n\tnoBoards: \"No boards yet\",\n\tnoColumns: \"No columns yet\",\n\tnoTasks: \"No tasks yet\",\n\t// Errors\n\terrorGeneric: \"Something went wrong\",\n\terrorNotFound: \"Not found\",\n};\n", + "content": "export interface KanbanCommonLocalization {\n\t// Navigation\n\tbackToBoards: string;\n\t// Actions\n\tactions: string;\n\tcreate: string;\n\tedit: string;\n\tdelete: string;\n\tcancel: string;\n\tsave: string;\n\t// Status\n\tloading: string;\n\tsaving: string;\n\tcreating: string;\n\tupdating: string;\n\tdeleting: string;\n\t// Labels\n\tboard: string;\n\tboards: string;\n\tcolumn: string;\n\tcolumns: string;\n\ttask: string;\n\ttasks: string;\n\t// Priorities\n\tpriorityLow: string;\n\tpriorityMedium: string;\n\tpriorityHigh: string;\n\tpriorityUrgent: string;\n\t// Empty states\n\tnoBoards: string;\n\tnoColumns: string;\n\tnoTasks: string;\n\tassigned: string;\n\tunassigned: string;\n\t// Generic UI\n\tpageNotFound: string;\n\tpageNotFoundDescription: string;\n\tunexpectedError: string;\n\ttryAgain: string;\n\t// Errors\n\terrorGeneric: string;\n\terrorNotFound: string;\n\tdeleteBoardError: string;\n\tdeleteColumnError: string;\n\tdeleteTaskError: string;\n}\n\nexport const defaultKanbanCommonLocalization: KanbanCommonLocalization = {\n\t// Navigation\n\tbackToBoards: \"Back to Boards\",\n\t// Actions\n\tactions: \"Actions\",\n\tcreate: \"Create\",\n\tedit: \"Edit\",\n\tdelete: \"Delete\",\n\tcancel: \"Cancel\",\n\tsave: \"Save\",\n\t// Status\n\tloading: \"Loading...\",\n\tsaving: \"Saving...\",\n\tcreating: \"Creating...\",\n\tupdating: \"Updating...\",\n\tdeleting: \"Deleting...\",\n\t// Labels\n\tboard: \"Board\",\n\tboards: \"Boards\",\n\tcolumn: \"Column\",\n\tcolumns: \"Columns\",\n\ttask: \"Task\",\n\ttasks: \"Tasks\",\n\t// Priorities\n\tpriorityLow: \"Low\",\n\tpriorityMedium: \"Medium\",\n\tpriorityHigh: \"High\",\n\tpriorityUrgent: \"Urgent\",\n\t// Empty states\n\tnoBoards: \"No boards yet\",\n\tnoColumns: \"No columns yet\",\n\tnoTasks: \"No tasks yet\",\n\tassigned: \"Assigned\",\n\tunassigned: \"Unassigned\",\n\t// Generic UI\n\tpageNotFound: \"Page Not Found\",\n\tpageNotFoundDescription: \"The page you're looking for doesn't exist.\",\n\tunexpectedError: \"An unexpected error occurred. Please try again.\",\n\ttryAgain: \"Try Again\",\n\t// Errors\n\terrorGeneric: \"Something went wrong\",\n\terrorNotFound: \"Not found\",\n\tdeleteBoardError: \"Failed to delete board\",\n\tdeleteColumnError: \"Failed to delete column\",\n\tdeleteTaskError: \"Failed to delete task\",\n};\n", "target": "src/components/btst/kanban/client/localization/kanban-common.ts" }, { "path": "btst/kanban/client/localization/kanban-forms.ts", "type": "registry:lib", - "content": "export interface KanbanFormsLocalization {\n\t// Board form\n\tboardName: string;\n\tboardNamePlaceholder: string;\n\tboardDescription: string;\n\tboardDescriptionPlaceholder: string;\n\tcreateBoard: string;\n\tupdateBoard: string;\n\tdeleteBoard: string;\n\tdeleteBoardConfirm: string;\n\t// Column form\n\tcolumnTitle: string;\n\tcolumnTitlePlaceholder: string;\n\tcreateColumn: string;\n\tupdateColumn: string;\n\tdeleteColumn: string;\n\tdeleteColumnConfirm: string;\n\t// Task form\n\ttaskTitle: string;\n\ttaskTitlePlaceholder: string;\n\ttaskDescription: string;\n\ttaskDescriptionPlaceholder: string;\n\ttaskPriority: string;\n\ttaskColumn: string;\n\ttaskAssignee: string;\n\tcreateTask: string;\n\tupdateTask: string;\n\tdeleteTask: string;\n\tdeleteTaskConfirm: string;\n\t// Validation\n\tnameRequired: string;\n\ttitleRequired: string;\n}\n\nexport const defaultKanbanFormsLocalization: KanbanFormsLocalization = {\n\t// Board form\n\tboardName: \"Name\",\n\tboardNamePlaceholder: \"e.g., Project Alpha\",\n\tboardDescription: \"Description\",\n\tboardDescriptionPlaceholder: \"Describe your board...\",\n\tcreateBoard: \"Create Board\",\n\tupdateBoard: \"Update Board\",\n\tdeleteBoard: \"Delete Board\",\n\tdeleteBoardConfirm:\n\t\t\"Are you sure you want to delete this board? This action cannot be undone. All columns and tasks will be permanently removed.\",\n\t// Column form\n\tcolumnTitle: \"Title\",\n\tcolumnTitlePlaceholder: \"e.g., To Do\",\n\tcreateColumn: \"Create Column\",\n\tupdateColumn: \"Update Column\",\n\tdeleteColumn: \"Delete Column\",\n\tdeleteColumnConfirm:\n\t\t\"Are you sure you want to delete this column? All tasks in this column will be permanently removed.\",\n\t// Task form\n\ttaskTitle: \"Title\",\n\ttaskTitlePlaceholder: \"e.g., Fix login bug\",\n\ttaskDescription: \"Description\",\n\ttaskDescriptionPlaceholder: \"Describe the task...\",\n\ttaskPriority: \"Priority\",\n\ttaskColumn: \"Column\",\n\ttaskAssignee: \"Assignee\",\n\tcreateTask: \"Create Task\",\n\tupdateTask: \"Update Task\",\n\tdeleteTask: \"Delete Task\",\n\tdeleteTaskConfirm:\n\t\t\"Are you sure you want to delete this task? This action cannot be undone.\",\n\t// Validation\n\tnameRequired: \"Name is required\",\n\ttitleRequired: \"Title is required\",\n};\n", + "content": "export interface KanbanFormsLocalization {\n\t// Board form\n\tboardName: string;\n\tboardNamePlaceholder: string;\n\tboardDescription: string;\n\tboardDescriptionPlaceholder: string;\n\tcreateBoard: string;\n\tupdateBoard: string;\n\tdeleteBoard: string;\n\tdeleteBoardConfirm: string;\n\t// Column form\n\tcolumnTitle: string;\n\tcolumnTitlePlaceholder: string;\n\tcreateColumn: string;\n\tupdateColumn: string;\n\tdeleteColumn: string;\n\tdeleteColumnConfirm: string;\n\t// Task form\n\ttaskTitle: string;\n\ttaskTitlePlaceholder: string;\n\ttaskDescription: string;\n\ttaskDescriptionPlaceholder: string;\n\ttaskPriority: string;\n\ttaskColumn: string;\n\ttaskAssignee: string;\n\tselectPriority: string;\n\tselectColumn: string;\n\tselectAssignee: string;\n\tnoUsersFound: string;\n\ttaskMovePartialError: string;\n\tcreateTask: string;\n\tupdateTask: string;\n\tdeleteTask: string;\n\tdeleteTaskConfirm: string;\n\t// Validation\n\tnameRequired: string;\n\ttitleRequired: string;\n}\n\nexport const defaultKanbanFormsLocalization: KanbanFormsLocalization = {\n\t// Board form\n\tboardName: \"Name\",\n\tboardNamePlaceholder: \"e.g., Project Alpha\",\n\tboardDescription: \"Description\",\n\tboardDescriptionPlaceholder: \"Describe your board...\",\n\tcreateBoard: \"Create Board\",\n\tupdateBoard: \"Update Board\",\n\tdeleteBoard: \"Delete Board\",\n\tdeleteBoardConfirm:\n\t\t\"Are you sure you want to delete this board? This action cannot be undone. All columns and tasks will be permanently removed.\",\n\t// Column form\n\tcolumnTitle: \"Title\",\n\tcolumnTitlePlaceholder: \"e.g., To Do\",\n\tcreateColumn: \"Create Column\",\n\tupdateColumn: \"Update Column\",\n\tdeleteColumn: \"Delete Column\",\n\tdeleteColumnConfirm:\n\t\t\"Are you sure you want to delete this column? All tasks in this column will be permanently removed.\",\n\t// Task form\n\ttaskTitle: \"Title\",\n\ttaskTitlePlaceholder: \"e.g., Fix login bug\",\n\ttaskDescription: \"Description\",\n\ttaskDescriptionPlaceholder: \"Describe the task...\",\n\ttaskPriority: \"Priority\",\n\ttaskColumn: \"Column\",\n\ttaskAssignee: \"Assignee\",\n\tselectPriority: \"Select priority\",\n\tselectColumn: \"Select column\",\n\tselectAssignee: \"Select assignee\",\n\tnoUsersFound: \"No users found\",\n\ttaskMovePartialError:\n\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\tcreateTask: \"Create Task\",\n\tupdateTask: \"Update Task\",\n\tdeleteTask: \"Delete Task\",\n\tdeleteTaskConfirm:\n\t\t\"Are you sure you want to delete this task? This action cannot be undone.\",\n\t// Validation\n\tnameRequired: \"Name is required\",\n\ttitleRequired: \"Title is required\",\n};\n", "target": "src/components/btst/kanban/client/localization/kanban-forms.ts" }, { "path": "btst/kanban/client/localization/kanban-list.ts", "type": "registry:lib", - "content": "export interface KanbanListLocalization {\n\t// Page titles\n\tkanbanBoards: string;\n\tmanageProjects: string;\n\tcreateNewBoard: string;\n\tboardDetails: string;\n\t// Actions\n\tnewBoard: string;\n\taddColumn: string;\n\taddTask: string;\n\t// List\n\tcolumnsCount: string;\n\t// Empty states\n\tnoBoardsDescription: string;\n\tnoColumnsDescription: string;\n\tnoTasksDescription: string;\n}\n\nexport const defaultKanbanListLocalization: KanbanListLocalization = {\n\t// Page titles\n\tkanbanBoards: \"Kanban Boards\",\n\tmanageProjects: \"Manage your projects and tasks\",\n\tcreateNewBoard: \"Create New Board\",\n\tboardDetails: \"Board Details\",\n\t// Actions\n\tnewBoard: \"New Board\",\n\taddColumn: \"Add Column\",\n\taddTask: \"Add Task\",\n\t// List\n\tcolumnsCount: \"columns\",\n\t// Empty states\n\tnoBoardsDescription:\n\t\t\"Create your first kanban board to start organizing your tasks.\",\n\tnoColumnsDescription: \"Create your first column to start organizing tasks.\",\n\tnoTasksDescription: \"Add a task to get started\",\n};\n", + "content": "export interface KanbanListLocalization {\n\t// Page titles\n\tkanbanBoards: string;\n\tmanageProjects: string;\n\tcreateNewBoard: string;\n\tcreateNewBoardDescription: string;\n\tboardDetails: string;\n\tboardDetailsDescription: string;\n\t// Actions\n\tnewBoard: string;\n\taddColumn: string;\n\taddTask: string;\n\teditBoard: string;\n\teditColumn: string;\n\teditTask: string;\n\t// List\n\tcolumnsCount: string;\n\t// Empty states\n\tnoBoardsDescription: string;\n\tnoColumnsDescription: string;\n\tnoTasksDescription: string;\n\tboardNotFound: string;\n\tboardNotFoundDescription: string;\n\taddColumnDescription: string;\n\teditColumnDescription: string;\n\teditBoardDescription: string;\n\taddTaskDescription: string;\n\teditTaskDescription: string;\n}\n\nexport const defaultKanbanListLocalization: KanbanListLocalization = {\n\t// Page titles\n\tkanbanBoards: \"Kanban Boards\",\n\tmanageProjects: \"Manage your projects and tasks\",\n\tcreateNewBoard: \"Create New Board\",\n\tcreateNewBoardDescription: \"Set up a new kanban board for your project\",\n\tboardDetails: \"Board Details\",\n\tboardDetailsDescription: \"Enter the details for your new kanban board.\",\n\t// Actions\n\tnewBoard: \"New Board\",\n\taddColumn: \"Add Column\",\n\taddTask: \"Add Task\",\n\teditBoard: \"Edit Board\",\n\teditColumn: \"Edit Column\",\n\teditTask: \"Edit Task\",\n\t// List\n\tcolumnsCount: \"columns\",\n\t// Empty states\n\tnoBoardsDescription:\n\t\t\"Create your first kanban board to start organizing your tasks.\",\n\tnoColumnsDescription: \"Create your first column to start organizing tasks.\",\n\tnoTasksDescription: \"Add a task to get started\",\n\tboardNotFound: \"Board not found\",\n\tboardNotFoundDescription:\n\t\t\"The board you're looking for doesn't exist or you don't have access to it.\",\n\taddColumnDescription: \"Add a new column to this board.\",\n\teditColumnDescription: \"Update the column details.\",\n\teditBoardDescription: \"Update board details.\",\n\taddTaskDescription: \"Create a new task.\",\n\teditTaskDescription: \"Update task details.\",\n};\n", "target": "src/components/btst/kanban/client/localization/kanban-list.ts" }, { diff --git a/packages/stack/src/__tests__/kanban-query-keys.test.ts b/packages/stack/src/__tests__/kanban-query-keys.test.ts new file mode 100644 index 00000000..75f18d5e --- /dev/null +++ b/packages/stack/src/__tests__/kanban-query-keys.test.ts @@ -0,0 +1,51 @@ +/** + * SSG guard: factory-generated Kanban query keys must stay deep-equal to the + * builders used by `prefetchForRoute`. Key drift silently breaks hydration. + */ +import { describe, expect, it, vi } from "vitest"; +import { KANBAN_QUERY_KEYS } from "../plugins/kanban/api/query-key-defs"; +import { createKanbanQueryKeys } from "../plugins/kanban/query-keys"; + +const client = vi.fn() as any; + +describe("kanban query keys match SSG prefetch keys", () => { + const queries = createKanbanQueryKeys(client); + + it("board list keys match for default params", () => { + expect([...queries.boards.list().queryKey]).toEqual([ + ...KANBAN_QUERY_KEYS.boardsList(), + ]); + }); + + it("board list keys match for every supported filter", () => { + const params = { + slug: "roadmap", + ownerId: "user-1", + organizationId: "org-1", + limit: 10, + offset: 20, + }; + expect([...queries.boards.list(params).queryKey]).toEqual([ + ...KANBAN_QUERY_KEYS.boardsList(params), + ]); + }); + + it("board detail keys match", () => { + expect([...queries.boards.detail("board-1").queryKey]).toEqual([ + ...KANBAN_QUERY_KEYS.boardDetail("board-1"), + ]); + }); + + it("preserves the previous query-key factory prefixes", () => { + expect([...queries.boards._def]).toEqual(["boards"]); + expect([...queries.boards.list._def]).toEqual(["boards", "list"]); + expect([...queries.boards.detail._def]).toEqual(["boards", "detail"]); + expect([...queries.boards.bySlug._def]).toEqual(["boards", "bySlug"]); + expect([...queries.boards.bySlug("roadmap").queryKey]).toEqual([ + "boards", + "bySlug", + "slug", + "roadmap", + ]); + }); +}); diff --git a/packages/stack/src/__tests__/resource-factory.test.tsx b/packages/stack/src/__tests__/resource-factory.test.tsx index ae49506f..21ad9b87 100644 --- a/packages/stack/src/__tests__/resource-factory.test.tsx +++ b/packages/stack/src/__tests__/resource-factory.test.tsx @@ -313,6 +313,7 @@ describe("createResource hooks", () => { "test-plugin": { apiBaseURL: "http://test.local", apiBasePath: "/api/data", + headers: { "x-resource-test": "forwarded" }, refresh, }, }} @@ -491,6 +492,7 @@ describe("createResource hooks", () => { const [url, init] = fetchMock.mock.calls[0] as [unknown, RequestInit]; expect(String(url)).toContain("/api/data/items"); expect(init.method).toBe("POST"); + expect(new Headers(init.headers).get("x-resource-test")).toBe("forwarded"); // Detail cache seeded from the result expect(queryClient.getQueryData(["items", "detail", "42"])).toEqual( diff --git a/packages/stack/src/plugins/client/resource/internal.ts b/packages/stack/src/plugins/client/resource/internal.ts index 7b4bb2ae..94c04c7d 100644 --- a/packages/stack/src/plugins/client/resource/internal.ts +++ b/packages/stack/src/plugins/client/resource/internal.ts @@ -66,7 +66,7 @@ export function useResourceMutationForDef( def: ResourceMutationDef | undefined, ): UseMutationResult { const queryClient = useQueryClient(); - const { client, refresh } = context; + const { client, headers, refresh } = context; return useMutation({ mutationKey: [resourceName, mutationName], @@ -76,7 +76,7 @@ export function useResourceMutationForDef( `Resource "${resourceName}" has no "${mutationName}" mutation declared`, ); } - return runResourceMutation(client, def, vars); + return runResourceMutation(client, def, vars, headers); }, onSuccess: async (result) => { if (!def) return; diff --git a/packages/stack/src/plugins/kanban/__tests__/client-sweep.test.tsx b/packages/stack/src/plugins/kanban/__tests__/client-sweep.test.tsx new file mode 100644 index 00000000..515ed578 --- /dev/null +++ b/packages/stack/src/plugins/kanban/__tests__/client-sweep.test.tsx @@ -0,0 +1,259 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + StackProvider, + type StackAuthProvider, + type StackI18nProvider, +} from "@btst/stack/context"; +import { BoardForm } from "../client/components/forms/board-form"; +import { ColumnForm } from "../client/components/forms/column-form"; +import { TaskForm } from "../client/components/forms/task-form"; +import { BoardsListPage } from "../client/components/pages/boards-list-page.internal"; +import type { + SerializedBoard, + SerializedColumn, + SerializedTask, +} from "../types"; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const hooks = vi.hoisted(() => ({ + useSuspenseBoards: vi.fn(), + useBoardForm: vi.fn(), + useColumnForm: vi.fn(), + useTaskForm: vi.fn(), + useTaskMutations: vi.fn(), + useSearchUsers: vi.fn(), +})); + +vi.mock("../client/hooks/kanban-hooks", () => hooks); + +vi.mock("@workspace/ui/components/minimal-tiptap", () => ({ + MinimalTiptapEditor: () =>
, +})); + +vi.mock("@workspace/ui/components/search-select", () => ({ + default: () =>
, +})); + +const board: SerializedBoard = { + id: "board-1", + name: "Roadmap", + slug: "roadmap", + description: "Team roadmap", + createdAt: new Date("2024-01-01").toISOString(), + updatedAt: new Date("2024-01-01").toISOString(), +}; + +const column: SerializedColumn = { + id: "column-1", + boardId: board.id, + title: "To Do", + order: 0, + tasks: [], + createdAt: new Date("2024-01-01").toISOString(), + updatedAt: new Date("2024-01-01").toISOString(), +}; + +const task: SerializedTask = { + id: "task-1", + columnId: column.id, + title: "Ship it", + description: "", + priority: "MEDIUM", + order: 0, + isArchived: false, + createdAt: new Date("2024-01-01").toISOString(), + updatedAt: new Date("2024-01-01").toISOString(), +}; + +let container: HTMLDivElement; +let root: Root; + +const formResult = { + action: "create", + record: null, + isLoadingRecord: false, + recordError: null, + defaultValues: undefined, + submit: vi.fn(), + isSubmitting: false, + error: null, + fieldErrors: {}, + clearErrors: vi.fn(), +}; + +const kanbanOverrides = { + navigate: vi.fn(), + apiBaseURL: "http://test.local", + apiBasePath: "/api/data", + resolveUser: vi.fn(), + searchUsers: vi.fn().mockResolvedValue([]), +}; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + hooks.useSuspenseBoards.mockReturnValue({ + data: [], + error: null, + isFetching: false, + }); + hooks.useBoardForm.mockReturnValue({ ...formResult }); + hooks.useColumnForm.mockReturnValue({ ...formResult }); + hooks.useTaskForm.mockReturnValue({ ...formResult }); + hooks.useTaskMutations.mockReturnValue({ + moveTask: vi.fn().mockResolvedValue(task), + isMoving: false, + }); + hooks.useSearchUsers.mockReturnValue({ data: [] }); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); +}); + +async function render( + ui: React.ReactElement, + options: { + auth?: StackAuthProvider; + i18n?: StackI18nProvider; + localization?: Record; + } = {}, +) { + await act(async () => { + root.render( + + {ui} + , + ); + }); +} + +async function waitFor(check: () => boolean, timeout = 3000) { + const start = Date.now(); + while (!check()) { + if (Date.now() - start > timeout) throw new Error("waitFor timed out"); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + } +} + +function texts(): string { + return document.body.textContent ?? ""; +} + +describe("Kanban permissions", () => { + it("shows create-board controls when auth is not configured", async () => { + await render(); + expect(texts()).toContain("Create Board"); + }); + + it("hides create-board controls when permission is denied", async () => { + const can = vi.fn( + ({ resource, action }: { resource: string; action: string }) => + !(resource === "kanban:board" && action === "create"), + ); + const auth: StackAuthProvider = { + getIdentity: () => ({ id: "user-1" }), + can, + }; + + await render(, { auth }); + await waitFor(() => !texts().includes("Create Board")); + + expect(texts()).toContain("No boards yet"); + expect(can).toHaveBeenCalledWith( + expect.objectContaining({ + resource: "kanban:board", + action: "create", + }), + ); + }); +}); + +describe("Kanban resource forms", () => { + it("renders normalized server field errors inline", async () => { + hooks.useBoardForm.mockReturnValue({ + ...formResult, + error: new Error("Validation failed"), + fieldErrors: { name: "Server says the name is invalid" }, + }); + + await render( {}} onSuccess={() => {}} />); + + expect(texts()).toContain("Server says the name is invalid"); + }); + + it("wires all three forms to their public resource form hooks", async () => { + await render( + <> + {}} onSuccess={() => {}} /> + {}} + onSuccess={() => {}} + /> + {}} + onSuccess={() => {}} + /> + , + ); + + expect(hooks.useBoardForm).toHaveBeenCalled(); + expect(hooks.useColumnForm).toHaveBeenCalled(); + expect(hooks.useTaskForm).toHaveBeenCalled(); + }); +}); + +describe("Kanban i18n precedence", () => { + it("routes UI strings through the i18n provider", async () => { + const i18n: StackI18nProvider = { + translate: (key, defaultValue) => + key === "kanban.common.noBoards" ? "Noch keine Boards" : defaultValue, + }; + + await render(, { i18n }); + expect(texts()).toContain("Noch keine Boards"); + }); + + it("lets the existing localization override win", async () => { + const translate = vi.fn((key: string) => `translated:${key}`); + + await render(, { + i18n: { translate }, + localization: { noBoards: "Custom empty board copy" }, + }); + + expect(texts()).toContain("Custom empty board copy"); + expect(texts()).not.toContain("translated:kanban.common.noBoards"); + }); +}); diff --git a/packages/stack/src/plugins/kanban/client/components/forms/board-form.tsx b/packages/stack/src/plugins/kanban/client/components/forms/board-form.tsx index 1105d3bc..95159788 100644 --- a/packages/stack/src/plugins/kanban/client/components/forms/board-form.tsx +++ b/packages/stack/src/plugins/kanban/client/components/forms/board-form.tsx @@ -5,7 +5,9 @@ import { Button } from "@workspace/ui/components/button"; import { Input } from "@workspace/ui/components/input"; import { Textarea } from "@workspace/ui/components/textarea"; import { Label } from "@workspace/ui/components/label"; -import { useBoardMutations } from "../../hooks/kanban-hooks"; +import { usePluginOverrides, useTranslate } from "@btst/stack/context"; +import { useBoardForm } from "../../hooks/kanban-hooks"; +import type { KanbanPluginOverrides } from "../../overrides"; import type { SerializedBoard } from "../../../types"; interface BoardFormProps { @@ -14,93 +16,138 @@ interface BoardFormProps { onSuccess: (boardId: string) => void; } +interface BoardFormValues { + name: string; + description: string; +} + +function firstError(error: string | string[] | undefined): string | undefined { + return Array.isArray(error) ? error[0] : error; +} + export function BoardForm({ board, onClose, onSuccess }: BoardFormProps) { + const t = useTranslate(); + const { localization } = usePluginOverrides("kanban"); const isEditing = !!board; - const { createBoard, updateBoard, isCreating, isUpdating } = - useBoardMutations(); const [name, setName] = useState(board?.name || ""); const [description, setDescription] = useState(board?.description || ""); - const [error, setError] = useState(null); + const [nameError, setNameError] = useState(null); - const isPending = isCreating || isUpdating; + const resourceForm = useBoardForm({ + action: isEditing ? "edit" : "create", + record: board ?? null, + toCreateVars: (values) => values, + toUpdateVars: (values) => ({ id: board?.id ?? "", data: values }), + onSuccess: (savedBoard) => onSuccess(savedBoard.id), + }); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(null); + const serverNameError = firstError(resourceForm.fieldErrors.name); + const serverDescriptionError = firstError( + resourceForm.fieldErrors.description, + ); + const topLevelError = + resourceForm.error && Object.keys(resourceForm.fieldErrors).length === 0 + ? resourceForm.error.message + : null; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + resourceForm.clearErrors(); + setNameError(null); if (!name.trim()) { - setError("Name is required"); + setNameError( + localization?.nameRequired ?? + t("kanban.forms.nameRequired", "Name is required"), + ); return; } - try { - if (isEditing && board) { - await updateBoard(board.id, { name, description }); - onSuccess(board.id); - } else { - const newBoard = await createBoard({ name, description }); - if (newBoard?.id) { - onSuccess(newBoard.id); - } - } - } catch (err) { - setError(err instanceof Error ? err.message : "An error occurred"); - } + await resourceForm.submit({ name, description }); }; return (
- + ) => - setName(e.target.value) + onChange={(event: React.ChangeEvent) => { + setName(event.target.value); + setNameError(null); + }} + placeholder={ + localization?.boardNamePlaceholder ?? + t("kanban.forms.boardNamePlaceholder", "e.g., Project Alpha") } - placeholder="e.g., Project Alpha" - disabled={isPending} + disabled={resourceForm.isSubmitting} + aria-invalid={!!(nameError || serverNameError)} /> + {(nameError || serverNameError) && ( +

+ {nameError || serverNameError} +

+ )}
- +