Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions docs/content/docs/plugins/kanban.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,43 @@ 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.

<Callout type="warning">
These client checks control presentation and navigation only. Enforce the same authorization policy on your server endpoints; users can still invoke Kanban API mutations directly without the gated controls.
</Callout>

| 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"
<StackProvider
auth={{
getIdentity: () => getCurrentUser(),
can: async ({ resource, action, params, identity }) => {
if (!identity) return false
return authorizeKanban({ resource, action, params, userId: identity.id })
},
}}
// ...router and overrides
>
{children}
</StackProvider>
```

### 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.
Expand Down Expand Up @@ -486,6 +523,26 @@ When a task has an assignee:

When no assignee is set, the task card shows "Unassigned" with a placeholder icon.

<Callout type="info">
`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.
</Callout>

## 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
<StackProvider
i18n={{
translate: (key, defaultValue, params) =>
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`)
Expand Down Expand Up @@ -730,8 +787,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,
Expand All @@ -752,6 +812,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)

Expand Down
70 changes: 47 additions & 23 deletions e2e/tests/smoke.kanban.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]');
Expand Down Expand Up @@ -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) => {
Expand Down
Loading
Loading