diff --git a/packages/core/issues/stores/view-store.ts b/packages/core/issues/stores/view-store.ts index a94117d8d9..cc9263b32b 100644 --- a/packages/core/issues/stores/view-store.ts +++ b/packages/core/issues/stores/view-store.ts @@ -9,7 +9,7 @@ import { ALL_STATUSES } from "../config"; import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage"; import { defaultStorage } from "../../platform/storage"; -export type ViewMode = "board" | "list" | "gantt"; +export type ViewMode = "board" | "list" | "gantt" | "swimlane"; export type GanttZoom = "day" | "week" | "month"; export type IssueGrouping = "status" | "assignee"; export type SortField = "position" | "priority" | "start_date" | "due_date" | "created_at" | "title"; @@ -79,6 +79,8 @@ export interface IssueViewState { listCollapsedStatuses: IssueStatus[]; ganttZoom: GanttZoom; ganttShowCompleted: boolean; + swimlaneOrder: string[]; + collapsedSwimlanes: string[]; setViewMode: (mode: ViewMode) => void; setGanttZoom: (zoom: GanttZoom) => void; toggleGanttShowCompleted: () => void; @@ -99,6 +101,8 @@ export interface IssueViewState { setSortDirection: (dir: SortDirection) => void; toggleCardProperty: (key: keyof CardProperties) => void; toggleListCollapsed: (status: IssueStatus) => void; + setSwimlaneOrder: (order: string[]) => void; + toggleSwimlaneCollapsed: (key: string) => void; } export const viewStoreSlice = (set: StoreApi["setState"]): IssueViewState => ({ @@ -128,6 +132,8 @@ export const viewStoreSlice = (set: StoreApi["setState"]): Issue listCollapsedStatuses: [], ganttZoom: "week", ganttShowCompleted: false, + swimlaneOrder: [], + collapsedSwimlanes: [], setViewMode: (mode) => set({ viewMode: mode }), setGanttZoom: (zoom) => set({ ganttZoom: zoom }), @@ -233,6 +239,13 @@ export const viewStoreSlice = (set: StoreApi["setState"]): Issue ? state.listCollapsedStatuses.filter((s) => s !== status) : [...state.listCollapsedStatuses, status], })), + setSwimlaneOrder: (order) => set({ swimlaneOrder: order }), + toggleSwimlaneCollapsed: (key) => + set((state) => ({ + collapsedSwimlanes: state.collapsedSwimlanes.includes(key) + ? state.collapsedSwimlanes.filter((k) => k !== key) + : [...state.collapsedSwimlanes, key], + })), }); export const viewStorePersistOptions = (name: string) => ({ @@ -259,6 +272,8 @@ export const viewStorePersistOptions = (name: string) => ({ listCollapsedStatuses: state.listCollapsedStatuses, ganttZoom: state.ganttZoom, ganttShowCompleted: state.ganttShowCompleted, + swimlaneOrder: state.swimlaneOrder, + collapsedSwimlanes: state.collapsedSwimlanes, }), // Default Zustand merge is shallow, so a persisted `cardProperties` snapshot // saved before a new toggle was introduced wins entirely and the new key is diff --git a/packages/views/issues/components/board-view.tsx b/packages/views/issues/components/board-view.tsx index f25334fcda..e196e88c1d 100644 --- a/packages/views/issues/components/board-view.tsx +++ b/packages/views/issues/components/board-view.tsx @@ -16,24 +16,16 @@ import { } from "@dnd-kit/core"; import type { QueryKey } from "@tanstack/react-query"; import { arrayMove } from "@dnd-kit/sortable"; -import { Eye, MoreHorizontal } from "lucide-react"; import type { Issue, IssueAssigneeGroup, IssueAssigneeType, IssueStatus, UpdateIssueRequest } from "@multica/core/types"; -import { Button } from "@multica/ui/components/ui/button"; import { useLoadMoreByAssigneeGroup, useLoadMoreByStatus } from "@multica/core/issues/mutations"; import type { AssigneeGroupedIssuesFilter, MyIssuesFilter } from "@multica/core/issues/queries"; -import { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, -} from "@multica/ui/components/ui/dropdown-menu"; -import { useViewStoreApi, useViewStore } from "@multica/core/issues/stores/view-store-context"; +import { useViewStore } from "@multica/core/issues/stores/view-store-context"; import type { IssueGrouping, SortField, SortDirection } from "@multica/core/issues/stores/view-store"; import { useActorName } from "@multica/core/workspace/hooks"; import { sortIssues } from "../utils/sort"; -import { StatusIcon } from "./status-icon"; import { BoardColumn, type BoardColumnGroup } from "./board-column"; import { BoardCardContent } from "./board-card"; +import { HiddenColumnsPanel, HiddenColumnRow } from "./hidden-columns-panel"; import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel"; import type { ChildProgress } from "./list-row"; import { useT } from "../../i18n"; @@ -512,7 +504,7 @@ export function BoardView({ )} {grouping === "status" && hiddenStatuses.length > 0 && ( - @@ -609,74 +601,41 @@ function PaginatedBoardColumn({ ); } -function HiddenColumnsPanel({ - hiddenStatuses, +/** + * Board-view-specific row that pulls the server-aggregated total from + * `useLoadMoreByStatus` and hands it to the shared {@link HiddenColumnRow}. + * Lives here (not in `hidden-columns-panel.tsx`) so the shared panel stays + * free of `useLoadMoreByStatus` / `myIssuesOpts` coupling — the swimlane + * uses an in-memory total instead. + */ +function BoardHiddenColumnRow({ + status, myIssuesOpts, }: { - hiddenStatuses: IssueStatus[]; + status: IssueStatus; myIssuesOpts?: { scope: string; filter: MyIssuesFilter }; }) { - const { t } = useT("issues"); - return ( -
-
- - {t(($) => $.board.hidden_columns_label)} - -
-
- {hiddenStatuses.map((status) => ( - - ))} -
-
- ); + const { total } = useLoadMoreByStatus(status, myIssuesOpts); + return ; } -function HiddenColumnRow({ - status, +function BoardHiddenColumnsPanel({ + hiddenStatuses, myIssuesOpts, }: { - status: IssueStatus; + hiddenStatuses: IssueStatus[]; myIssuesOpts?: { scope: string; filter: MyIssuesFilter }; }) { - const { t } = useT("issues"); - const viewStoreApi = useViewStoreApi(); - const { total } = useLoadMoreByStatus(status, myIssuesOpts); return ( -
-
- - {t(($) => $.status[status])} -
-
- {total} - - - - - } - /> - - viewStoreApi.getState().showStatus(status)} - > - - {t(($) => $.board.show_column)} - - - -
-
+ ( + + )} + /> ); } diff --git a/packages/views/issues/components/hidden-columns-panel.tsx b/packages/views/issues/components/hidden-columns-panel.tsx new file mode 100644 index 0000000000..0352e42367 --- /dev/null +++ b/packages/views/issues/components/hidden-columns-panel.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { Eye, MoreHorizontal } from "lucide-react"; +import type { IssueStatus } from "@multica/core/types"; +import { Button } from "@multica/ui/components/ui/button"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from "@multica/ui/components/ui/dropdown-menu"; +import { useViewStoreApi } from "@multica/core/issues/stores/view-store-context"; +import { StatusIcon } from "./status-icon"; +import { useT } from "../../i18n"; + +/** + * Single source of truth for the "Hidden columns" side panel rendered by + * the kanban-style views (board and swimlane). + * + * Each consumer renders its own per-row count via the {@link renderRow} slot — + * the board uses `useLoadMoreByStatus` to fetch the workspace-wide aggregate, + * while the swimlane uses an in-memory total derived from already-loaded + * issues. Centralising the chrome here keeps a future view (calendar / + * timeline / etc.) from forking yet another copy. + */ +export function HiddenColumnsPanel({ + hiddenStatuses, + renderRow, +}: { + hiddenStatuses: IssueStatus[]; + renderRow: (status: IssueStatus) => React.ReactNode; +}) { + const { t } = useT("issues"); + return ( +
+
+ + {t(($) => $.board.hidden_columns_label)} + +
+
+ {hiddenStatuses.map((status) => renderRow(status))} +
+
+ ); +} + +/** + * One row inside {@link HiddenColumnsPanel}. Pure presentational — the caller + * computes `total` however it likes (cached query vs. in-memory aggregate). + */ +export function HiddenColumnRow({ + status, + total, +}: { + status: IssueStatus; + total: number; +}) { + const { t } = useT("issues"); + const viewStoreApi = useViewStoreApi(); + return ( +
+
+ + {t(($) => $.status[status])} +
+
+ {total} + + $.board.show_column)} + className="rounded-full text-muted-foreground" + > + + + } + /> + + viewStoreApi.getState().showStatus(status)} + > + + {t(($) => $.board.show_column)} + + + +
+
+ ); +} diff --git a/packages/views/issues/components/issues-header.tsx b/packages/views/issues/components/issues-header.tsx index 440ad4a0b7..136fe1740e 100644 --- a/packages/views/issues/components/issues-header.tsx +++ b/packages/views/issues/components/issues-header.tsx @@ -19,6 +19,7 @@ import { User, UserMinus, UserPen, + Waves, } from "lucide-react"; import { Button } from "@multica/ui/components/ui/button"; import { @@ -969,6 +970,8 @@ export function IssueDisplayControls({ ) : viewMode === "gantt" && allowGantt ? ( + ) : viewMode === "swimlane" ? ( + ) : ( )} @@ -982,6 +985,8 @@ export function IssueDisplayControls({ ? t(($) => $.view.tooltip_board) : viewMode === "gantt" && allowGantt ? t(($) => $.view.tooltip_gantt) + : viewMode === "swimlane" + ? t(($) => $.view.tooltip_swimlane) : t(($) => $.view.tooltip_list)} @@ -1002,6 +1007,10 @@ export function IssueDisplayControls({ {t(($) => $.view.gantt)} )} + act.setViewMode("swimlane")}> + + {t(($) => $.view.swimlane)} + diff --git a/packages/views/issues/components/issues-page.tsx b/packages/views/issues/components/issues-page.tsx index 93adacc983..d8572ab961 100644 --- a/packages/views/issues/components/issues-page.tsx +++ b/packages/views/issues/components/issues-page.tsx @@ -22,6 +22,7 @@ import { PageHeader } from "../../layout/page-header"; import { IssuesHeader } from "./issues-header"; import { BoardView } from "./board-view"; import { ListView } from "./list-view"; +import { SwimLaneView } from "./swimlane-view"; import { BatchActionToolbar } from "./batch-action-toolbar"; import { useT } from "../../i18n"; @@ -135,7 +136,7 @@ export function IssuesPage() { const updateIssueMutation = useUpdateIssue(); const handleMoveIssue = useCallback( - (issueId: string, updates: Pick) => { + (issueId: string, updates: Pick) => { updateIssueMutation.mutate( { id: issueId, ...updates }, { @@ -227,6 +228,14 @@ export function IssuesPage() { onMoveIssue={handleMoveIssue} childProgressMap={childProgressMap} /> + ) : viewMode === "swimlane" ? ( + ) : ( )} diff --git a/packages/views/issues/components/swimlane-view.test.tsx b/packages/views/issues/components/swimlane-view.test.tsx new file mode 100644 index 0000000000..1c77d805b9 --- /dev/null +++ b/packages/views/issues/components/swimlane-view.test.tsx @@ -0,0 +1,724 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { SwimLaneView } from "./swimlane-view"; +import type { Issue } from "@multica/core/types"; +import { I18nProvider } from "@multica/core/i18n/react"; +import enCommon from "../../locales/en/common.json"; +import enIssues from "../../locales/en/issues.json"; + +const TEST_RESOURCES = { en: { common: enCommon, issues: enIssues } }; + +// Mock hooks +vi.mock("@multica/core/hooks", () => ({ + useWorkspaceId: () => "ws-1", +})); + +// Mock paths +vi.mock("@multica/core/paths", async () => { + const actual = await vi.importActual( + "@multica/core/paths", + ); + return { + ...actual, + useWorkspaceSlug: () => "acme", + useRequiredWorkspaceSlug: () => "acme", + useWorkspacePaths: () => actual.paths.workspace("acme"), + }; +}); + +// Mock @multica/core/auth +const mockAuthUser = { id: "user-1", email: "test@test.com", name: "Test User" }; +vi.mock("@multica/core/auth", () => ({ + useAuthStore: Object.assign( + (selector?: any) => { + const state = { user: mockAuthUser, isAuthenticated: true }; + return selector ? selector(state) : state; + }, + { getState: () => ({ user: mockAuthUser, isAuthenticated: true }) }, + ), + registerAuthStore: vi.fn(), + createAuthStore: vi.fn(), +})); + +// Mock navigation +vi.mock("../../navigation", () => ({ + AppLink: ({ children, href, ...props }: any) => ( + + {children} + + ), + useNavigation: () => ({ push: vi.fn(), pathname: "/issues" }), + NavigationProvider: ({ children }: { children: React.ReactNode }) => children, +})); + +// Mock issue config +vi.mock("@multica/core/issues/config", () => ({ + ALL_STATUSES: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"], + BOARD_STATUSES: ["backlog", "todo", "in_progress", "in_review", "done", "blocked"], + STATUS_ORDER: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"], + STATUS_CONFIG: { + backlog: { label: "Backlog", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" }, + todo: { label: "Todo", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" }, + in_progress: { label: "In Progress", iconColor: "text-warning", hoverBg: "hover:bg-warning/10" }, + in_review: { label: "In Review", iconColor: "text-success", hoverBg: "hover:bg-success/10" }, + done: { label: "Done", iconColor: "text-info", hoverBg: "hover:bg-info/10" }, + blocked: { label: "Blocked", iconColor: "text-destructive", hoverBg: "hover:bg-destructive/10" }, + cancelled: { label: "Cancelled", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" }, + }, + PRIORITY_ORDER: ["urgent", "high", "medium", "low", "none"], + PRIORITY_CONFIG: { + urgent: { label: "Urgent", bars: 4, color: "text-destructive" }, + high: { label: "High", bars: 3, color: "text-warning" }, + medium: { label: "Medium", bars: 2, color: "text-warning" }, + low: { label: "Low", bars: 1, color: "text-info" }, + none: { label: "No priority", bars: 0, color: "text-muted-foreground" }, + }, +})); + +// Mock @multica/core/issues/mutations for hidden column status counts +vi.mock("@multica/core/issues/mutations", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useLoadMoreByStatus: (status: string) => { + if (status === "backlog") return { total: 1, loaded: 1, hasMore: false, isLoading: false, loadMore: vi.fn() }; + if (status === "blocked") return { total: 0, loaded: 0, hasMore: false, isLoading: false, loadMore: vi.fn() }; + return { total: 0, loaded: 0, hasMore: false, isLoading: false, loadMore: vi.fn() }; + }, + }; +}); + +// Mock view store. `swimlaneOrder` is mutable on the captured object so +// tests can simulate persisted lane order and assert that +// `setSwimlaneOrder` was called by drag-end handlers. +const mockViewState: { + sortBy: "position"; + sortDirection: "asc"; + cardProperties: Record; + swimlaneOrder: string[]; + collapsedSwimlanes: string[]; + setSwimlaneOrder: (order: string[]) => void; + toggleSwimlaneCollapsed: (key: string) => void; + hideStatus: (s: string) => void; + showStatus: (s: string) => void; +} = { + sortBy: "position", + sortDirection: "asc", + cardProperties: { priority: true, description: true, assignee: true, dueDate: true, project: true, childProgress: true, labels: true }, + swimlaneOrder: [], + collapsedSwimlanes: [], + setSwimlaneOrder: vi.fn(), + toggleSwimlaneCollapsed: vi.fn(), + hideStatus: vi.fn(), + showStatus: vi.fn(), +}; +const mockSetSwimlaneOrder = mockViewState.setSwimlaneOrder as ReturnType; +const mockToggleSwimlaneCollapsed = mockViewState.toggleSwimlaneCollapsed as ReturnType; + +vi.mock("@multica/core/issues/stores/view-store-context", () => ({ + ViewStoreProvider: ({ children }: { children: React.ReactNode }) => children, + useViewStore: (selector?: any) => (selector ? selector(mockViewState) : mockViewState), + useViewStoreApi: () => ({ getState: () => mockViewState, setState: vi.fn(), subscribe: vi.fn() }), +})); + +// Mock modal store +const mockOpenModal = vi.fn(); +vi.mock("@multica/core/modals", () => ({ + useModalStore: Object.assign( + () => ({ open: mockOpenModal }), + { getState: () => ({ open: mockOpenModal }) }, + ), +})); + +// Mock dnd-kit +let lastOnDragEnd: any = null; +let lastOnDragOver: any = null; + +vi.mock("@dnd-kit/core", () => ({ + DndContext: ({ children, onDragEnd, onDragOver }: any) => { + lastOnDragEnd = onDragEnd; + lastOnDragOver = onDragOver; + return children; + }, + DragOverlay: () => null, + PointerSensor: class {}, + useSensor: () => ({}), + useSensors: () => [], + useDroppable: () => ({ setNodeRef: vi.fn(), isOver: false }), + pointerWithin: vi.fn(), + closestCenter: vi.fn(), +})); + +vi.mock("@dnd-kit/sortable", () => ({ + SortableContext: ({ children }: any) => children, + verticalListSortingStrategy: {}, + // Real arrayMove implementation — the production code uses this both for + // card reordering and lane reordering, so returning undefined would break + // every reorder assertion. + arrayMove: (arr: T[], from: number, to: number): T[] => { + const copy = arr.slice(); + const [item] = copy.splice(from, 1); + copy.splice(to, 0, item!); + return copy; + }, + useSortable: () => ({ + attributes: {}, + listeners: {}, + setNodeRef: vi.fn(), + transform: null, + transition: null, + isDragging: false, + }), +})); + +vi.mock("@dnd-kit/utilities", () => ({ + CSS: { Transform: { toString: () => undefined } }, +})); + +const mockIssues: Issue[] = [ + { + id: "parent-1", + workspace_id: "ws-1", + number: 1, + identifier: "PROJ-1", + title: "Parent Issue 1", + description: "Parent description", + status: "todo", + priority: "high", + assignee_type: null, + assignee_id: null, + creator_type: "member", + creator_id: "user-1", + parent_issue_id: null, + project_id: null, + position: 100, + start_date: null, + due_date: null, + metadata: {}, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }, + { + id: "child-1", + workspace_id: "ws-1", + number: 2, + identifier: "PROJ-2", + title: "Child Issue 1", + description: "Child description", + status: "in_progress", + priority: "medium", + assignee_type: "member", + assignee_id: "user-1", + creator_type: "member", + creator_id: "user-1", + parent_issue_id: "parent-1", + project_id: null, + position: 200, + start_date: null, + due_date: null, + metadata: {}, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }, + { + id: "orphan-1", + workspace_id: "ws-1", + number: 3, + identifier: "PROJ-3", + title: "Orphan Issue 1", + description: "No parent", + status: "backlog", + priority: "low", + assignee_type: null, + assignee_id: null, + creator_type: "member", + creator_id: "user-1", + parent_issue_id: null, + project_id: null, + position: 300, + start_date: null, + due_date: null, + metadata: {}, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }, +]; + +function renderWithI18n(ui: React.ReactNode) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + return render( + + + {ui} + + , + ); +} + +describe("SwimLaneView", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset mutable mock state between tests so stored lane order / + // collapsed state from one test doesn't leak into the next. + mockViewState.swimlaneOrder = []; + mockViewState.collapsedSwimlanes = []; + }); + + it("renders status columns as headers", () => { + renderWithI18n( + , + ); + + expect(screen.getByText("Backlog")).toBeInTheDocument(); + expect(screen.getByText("Todo")).toBeInTheDocument(); + expect(screen.getByText("In Progress")).toBeInTheDocument(); + }); + + it("renders parent swimlanes and orphans section", () => { + renderWithI18n( + , + ); + + // No parent (orphan swimlane) + expect(screen.getAllByText("No parent").length).toBeGreaterThanOrEqual(1); + + // Parent Issue 1 swimlane + expect(screen.getAllByText("Parent Issue 1").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("PROJ-1").length).toBeGreaterThanOrEqual(1); + }); + + it("renders cards in their corresponding cell", () => { + renderWithI18n( + , + ); + + // Orphan Issue 1 is in "No parent" + Backlog + expect(screen.getByText("Orphan Issue 1")).toBeInTheDocument(); + + // Parent Issue 1 is in "No parent" + Todo + expect(screen.getAllByText("Parent Issue 1").length).toBeGreaterThanOrEqual(1); + + // Child Issue 1 is in "Parent Issue 1" + In Progress + expect(screen.getByText("Child Issue 1")).toBeInTheDocument(); + }); + + it("triggers modal open when add button is clicked", () => { + renderWithI18n( + , + ); + + // Find the per-cell "+" button by its aria-label (set to the + // `board.add_issue_tooltip` translation). + const addButtons = screen.getAllByRole("button", { name: /add issue/i }); + expect(addButtons.length).toBeGreaterThan(0); + + fireEvent.click(addButtons[0]!); + expect(mockOpenModal).toHaveBeenCalledWith("create-issue", expect.any(Object)); + }); + + it("renders an open-parent link for lanes with a real parent", () => { + renderWithI18n( + , + ); + + // The pencil link uses aria-label "Open parent issue" and href to /issues/. + // Only the "Parent Issue 1" lane (parent-1) has a parent issue id; the + // orphan lane ("No parent") must not render this link. + const links = screen.getAllByRole("link", { name: "Open parent issue" }); + expect(links).toHaveLength(1); + expect(links[0]).toHaveAttribute("href", expect.stringContaining("parent-1")); + }); + + it("renders HiddenColumnsPanel only when hiddenStatuses is non-empty", () => { + // Case 1: no hidden statuses → panel is absent. + const { unmount } = renderWithI18n( + , + ); + expect(screen.queryByText("Hidden columns")).not.toBeInTheDocument(); + unmount(); + + // Case 2: hide a status → panel shows the localized status name + count. + // "cancelled" is excluded from BOARD_STATUSES, so we pass "blocked" as hidden. + renderWithI18n( + , + ); + expect(screen.getByText("Hidden columns")).toBeInTheDocument(); + expect(screen.getByText("Blocked")).toBeInTheDocument(); + }); + + it("calls onMoveIssue on drag-and-drop end", () => { + const mockOnMoveIssue = vi.fn(); + renderWithI18n( + , + ); + + expect(lastOnDragOver).toBeDefined(); + expect(lastOnDragEnd).toBeDefined(); + + // Drag orphan-1 (status: backlog, parent: null, in "No parent" lane) + // to the "in_progress" column of the same "No parent" lane. + const targetCellId = "swim:parent:none:in_progress"; + + act(() => { + lastOnDragOver({ + active: { id: "orphan-1" }, + over: { id: targetCellId }, + }); + }); + + act(() => { + lastOnDragEnd({ + active: { id: "orphan-1" }, + over: { id: targetCellId }, + }); + }); + + expect(mockOnMoveIssue).toHaveBeenCalledWith("orphan-1", { + parent_issue_id: null, + status: "in_progress", + position: 300, // maintains its position since it's the only item in target + }); + }); + + it("does not call onMoveIssue when drop target equals source cell (no-op)", () => { + // Drop "parent-1" (status: todo, parent_issue_id: null) onto its own + // current cell. The handler must detect the no-op and skip the mutation + // so the network and the optimistic cache aren't churned. + const mockOnMoveIssue = vi.fn(); + renderWithI18n( + , + ); + + act(() => { + lastOnDragEnd({ + active: { id: "parent-1" }, + over: { id: "swim:parent:none:todo" }, + }); + }); + + expect(mockOnMoveIssue).not.toHaveBeenCalled(); + }); + + it("emits parent_issue_id when dragging from orphan into a parent lane", () => { + // Drag "orphan-1" (parent_issue_id: null, status: backlog) into the + // "Parent Issue 1" lane's `todo` column. The contract: payload must + // carry the new parent_issue_id (parent-1) and the new status (todo). + const mockOnMoveIssue = vi.fn(); + renderWithI18n( + , + ); + + const target = "swim:parent:parent-1:todo"; + act(() => { + lastOnDragOver({ + active: { id: "orphan-1" }, + over: { id: target }, + }); + }); + act(() => { + lastOnDragEnd({ + active: { id: "orphan-1" }, + over: { id: target }, + }); + }); + + expect(mockOnMoveIssue).toHaveBeenCalledWith( + "orphan-1", + expect.objectContaining({ + parent_issue_id: "parent-1", + status: "todo", + }), + ); + }); + + it("renders count for hidden statuses based on in-memory issues", () => { + // statusTotals must count every issue's status, including statuses that + // are not currently rendered as columns. mockIssues has 1 `backlog` and + // 0 `blocked`. Hide both and assert the panel shows the right counts. + renderWithI18n( + , + ); + + const panel = screen.getByText("Hidden columns").parentElement!.parentElement!; + // Backlog row should show "1"; Blocked row should show "0". + expect(panel).toHaveTextContent("Backlog"); + expect(panel).toHaveTextContent("Blocked"); + expect(panel).toHaveTextContent("1"); + expect(panel).toHaveTextContent("0"); + }); + + // ------------------------------------------------------------------ + // Lane reordering via drag-and-drop + // + // Two parent fixtures (parent-1, parent-2) so we can test cross-lane + // reordering. Each needs a child issue so a swimlane is actually created + // (parentGroups skips parents with no children loaded). + // ------------------------------------------------------------------ + const multiParentIssues: Issue[] = [ + { + id: "parent-1", + workspace_id: "ws-1", + number: 1, + identifier: "PROJ-1", + title: "Parent A", + description: null, + status: "todo", + priority: "high", + assignee_type: null, + assignee_id: null, + creator_type: "member", + creator_id: "user-1", + parent_issue_id: null, + project_id: null, + position: 100, + start_date: null, + due_date: null, + metadata: {}, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }, + { + id: "parent-2", + workspace_id: "ws-1", + number: 2, + identifier: "PROJ-10", + title: "Parent B", + description: null, + status: "todo", + priority: "high", + assignee_type: null, + assignee_id: null, + creator_type: "member", + creator_id: "user-1", + parent_issue_id: null, + project_id: null, + position: 200, + start_date: null, + due_date: null, + metadata: {}, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }, + { + id: "child-of-1", + workspace_id: "ws-1", + number: 3, + identifier: "PROJ-2", + title: "Child of A", + description: null, + status: "in_progress", + priority: "medium", + assignee_type: null, + assignee_id: null, + creator_type: "member", + creator_id: "user-1", + parent_issue_id: "parent-1", + project_id: null, + position: 300, + start_date: null, + due_date: null, + metadata: {}, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }, + { + id: "child-of-2", + workspace_id: "ws-1", + number: 4, + identifier: "PROJ-11", + title: "Child of B", + description: null, + status: "in_progress", + priority: "medium", + assignee_type: null, + assignee_id: null, + creator_type: "member", + creator_id: "user-1", + parent_issue_id: "parent-2", + project_id: null, + position: 400, + start_date: null, + due_date: null, + metadata: {}, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }, + ]; + + it("persists lane order via setSwimlaneOrder when a lane is dragged onto another", () => { + renderWithI18n( + , + ); + + // Drag lane parent-1 onto lane parent-2 — expect order to become + // [parent-2, parent-1]. + act(() => { + lastOnDragEnd({ + active: { id: "lane:parent-1" }, + over: { id: "lane:parent-2" }, + }); + }); + + expect(mockSetSwimlaneOrder).toHaveBeenCalledWith(["parent-2", "parent-1"]); + }); + + it("does not call setSwimlaneOrder when a lane is dropped onto itself", () => { + renderWithI18n( + , + ); + + act(() => { + lastOnDragEnd({ + active: { id: "lane:parent-1" }, + over: { id: "lane:parent-1" }, + }); + }); + + expect(mockSetSwimlaneOrder).not.toHaveBeenCalled(); + }); + + it("does not call onMoveIssue when a lane drag ends (no card mutation)", () => { + // Lane drags must not accidentally trigger card-position mutations. + const mockOnMoveIssue = vi.fn(); + renderWithI18n( + , + ); + + act(() => { + lastOnDragEnd({ + active: { id: "lane:parent-1" }, + over: { id: "lane:parent-2" }, + }); + }); + + expect(mockOnMoveIssue).not.toHaveBeenCalled(); + }); + + it("renders parent lanes in stored swimlaneOrder when set", () => { + // Set persisted order to put parent-2 first, then parent-1. + mockViewState.swimlaneOrder = ["parent-2", "parent-1"]; + + renderWithI18n( + , + ); + + const parentA = screen.getByText("Parent A"); + const parentB = screen.getByText("Parent B"); + // DOM order: "Parent B" must precede "Parent A". + // compareDocumentPosition: bitmask, DOCUMENT_POSITION_FOLLOWING = 4 + expect(parentB.compareDocumentPosition(parentA) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it("keeps 'No parent' lane pinned at top regardless of stored order", () => { + // Even if the user could somehow include a synthetic "no parent" key, + // the No-parent lane is rendered outside the SortableContext and + // always appears first. + mockViewState.swimlaneOrder = ["parent-2", "parent-1"]; + + renderWithI18n( + , + ); + + const noParent = screen.getByText("No parent"); + const parentB = screen.getByText("Parent B"); + expect(noParent.compareDocumentPosition(parentB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + // ------------------------------------------------------------------ + // Persisted collapsed-lane state + // ------------------------------------------------------------------ + + it("collapses a parent lane when its id is in stored collapsedSwimlanes", () => { + // Pre-seed the store as if the lane had been collapsed in a prior + // session. Child cards inside that lane must not render. + mockViewState.collapsedSwimlanes = ["parent-1"]; + + renderWithI18n( + , + ); + + // The lane HEADER for Parent A is still visible… + expect(screen.getByText("Parent A")).toBeInTheDocument(); + // …but its child card is not. + expect(screen.queryByText("Child of A")).not.toBeInTheDocument(); + // Parent B (not collapsed) still shows its child. + expect(screen.getByText("Child of B")).toBeInTheDocument(); + }); + + it("collapses the 'No parent' lane when 'none' is in stored collapsedSwimlanes", () => { + // Sentinel key "none" represents the No-parent lane. mockIssues + // contains the orphan "Orphan Issue 1" — when collapsed, its card + // must be absent from the DOM. + mockViewState.collapsedSwimlanes = ["none"]; + + renderWithI18n( + , + ); + + expect(screen.getByText("No parent")).toBeInTheDocument(); + expect(screen.queryByText("Orphan Issue 1")).not.toBeInTheDocument(); + }); + + it("calls toggleSwimlaneCollapsed with the raw parent id when a lane header is clicked", () => { + renderWithI18n( + , + ); + + // The Parent A lane header is the + } + /> + + viewStoreApi.getState().hideStatus(status)} + > + + {t(($) => $.board.hide_column)} + + + + + ); + })} + + + + {/* Parent rows. "No parent" is pinned at top and non-draggable; + the rest are wrapped in a SortableContext so users can reorder + lanes by dragging the grip handle. */} +
+ {parentGroups + .filter((p) => p.parentIssueId === null) + .map((parent) => ( + toggleLane(parent.key)} + localCells={localCells} + sortedStatuses={sortedStatuses} + issueMap={issueMapRef.current} + childProgressMap={childProgressMap} + gridStyle={gridStyle} + paths={paths} + /> + ))} + p.parentIssueId !== null) + .map((p) => laneId(p.parentIssueId!))} + strategy={verticalListSortingStrategy} + > + {parentGroups + .filter((p) => p.parentIssueId !== null) + .map((parent) => ( + toggleLane(parent.key)} + localCells={localCells} + sortedStatuses={sortedStatuses} + issueMap={issueMapRef.current} + childProgressMap={childProgressMap} + gridStyle={gridStyle} + paths={paths} + /> + ))} + +
+ + + {hiddenStatuses.length > 0 && ( + + )} + + + + {activeIssue ? ( +
+ +
+ ) : null} +
+ + ); +} + +/** + * Renders a single swimlane (lane header + cells row). + * + * Lanes with a real parent are made draggable via `useSortable` so users can + * reorder them. The "No parent" lane passes through with `disabled: true` + * so it stays pinned and unclickable for drag — useSortable must still be + * called unconditionally to satisfy the rules of hooks. + * + * Click vs drag: PointerSensor has `activationConstraint: { distance: 5 }`, + * so taps on the header still toggle collapse while a >=5px drag starts the + * sortable interaction. The "Open parent" pencil link stops pointer events + * so users can click it without inadvertently starting a drag. + */ +function DraggableSwimLane({ + parent, + isCollapsed, + onToggleCollapse, + localCells, + sortedStatuses, + issueMap, + childProgressMap, + gridStyle, + paths, +}: { + parent: ParentGroup; + isCollapsed: boolean; + onToggleCollapse: () => void; + localCells: Record>; + sortedStatuses: IssueStatus[]; + issueMap: Map; + childProgressMap: Map; + gridStyle: React.CSSProperties; + paths: ReturnType; +}) { + const { t } = useT("issues"); + const isNoParent = parent.parentIssueId === null; + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + // Always provide a valid id (rules of hooks) — the "No parent" lane is + // disabled, so its id is never used as a sortable target. + id: isNoParent ? "lane:__no_parent__" : laneId(parent.parentIssueId!), + disabled: isNoParent, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + const laneTotal = sortedStatuses.reduce( + (sum, s) => sum + (localCells[parent.key]?.[s]?.length ?? 0), + 0, + ); + + // Stop pointer events from bubbling into the sortable's pointer listener + // so clicking interactive elements (open-parent link) does not start a drag. + const stopPointer = (e: React.SyntheticEvent) => e.stopPropagation(); + + return ( +
+ {/* Lane header — full width above the row. Spread `listeners` so the + header itself acts as the drag affordance; the leading GripVertical + icon is a visual hint, not a separate handle. */} + + {/* Cells row — each cell mirrors a BoardColumn body */} + {!isCollapsed && ( +
+ {sortedStatuses.map((status) => { + const cId = cellId(parent.key, status); + const issueIds = localCells[parent.key]?.[status] ?? []; + return ( + + ); + })} +
+ )} +
+ ); +} + +function SwimLaneCell({ + cellId: cId, + issueIds, + issueMap, + childProgressMap, + status, + parentGroup, +}: { + cellId: string; + issueIds: string[]; + issueMap: Map; + childProgressMap: Map; + status: IssueStatus; + parentGroup: ParentGroup; +}) { + const { setNodeRef, isOver } = useDroppable({ id: cId }); + const { t } = useT("issues"); + const cfg = STATUS_CONFIG[status]; + + const resolvedIssues = useMemo( + () => + issueIds.flatMap((id) => { + const issue = issueMap.get(id); + return issue ? [issue] : []; + }), + [issueIds, issueMap], + ); + + const handleAdd = useCallback(() => { + const data: Record = { status }; + if (parentGroup.parentIssueId) { + data.parent_issue_id = parentGroup.parentIssueId; + } + useModalStore.getState().open("create-issue", data); + }, [status, parentGroup]); + + return ( +
+
+ + {resolvedIssues.map((issue) => ( + + ))} + + {issueIds.length === 0 && ( +

+ — +

+ )} +
+ + $.board.add_issue_tooltip)} + className="mt-1 w-full rounded-md text-muted-foreground hover:text-foreground" + onClick={handleAdd} + > + + + } + /> + {t(($) => $.board.add_issue_tooltip)} + +
+ ); +} + +function SwimLaneHiddenColumnRow({ + status, + myIssuesOpts, +}: { + status: IssueStatus; + myIssuesOpts?: { scope: string; filter: MyIssuesFilter }; +}) { + const { total } = useLoadMoreByStatus(status, myIssuesOpts); + return ; +} + +/** + * Swimlane-specific wrapper around the shared {@link HiddenColumnsPanel}. + * Uses `useLoadMoreByStatus` to get accurate cached/live status counts from + * the React Query cache, mirroring the board's hidden column behavior. + */ +function SwimLaneHiddenColumnsPanel({ + hiddenStatuses, + myIssuesOpts, +}: { + hiddenStatuses: IssueStatus[]; + myIssuesOpts?: { scope: string; filter: MyIssuesFilter }; +}) { + return ( + ( + + )} + /> + ); +} diff --git a/packages/views/locales/en/issues.json b/packages/views/locales/en/issues.json index 4dcbef4b93..840e5c0aca 100644 --- a/packages/views/locales/en/issues.json +++ b/packages/views/locales/en/issues.json @@ -78,10 +78,12 @@ "tooltip_board": "Board view", "tooltip_list": "List view", "tooltip_gantt": "Gantt view", + "tooltip_swimlane": "Swimlane view", "section": "View", "board": "Board", "list": "List", - "gantt": "Gantt" + "gantt": "Gantt", + "swimlane": "Swimlane" }, "gantt": { "header_issue": "Issue", @@ -120,6 +122,11 @@ "empty_status": "No issues", "add_issue_tooltip": "Add issue" }, + "swimlane": { + "parent_column": "Parent", + "no_parent": "No parent", + "open_parent": "Open parent issue" + }, "board": { "hidden_columns_label": "Hidden columns", "hide_column": "Hide column", diff --git a/packages/views/locales/en/my-issues.json b/packages/views/locales/en/my-issues.json index 7abc2300da..67c414e8d3 100644 --- a/packages/views/locales/en/my-issues.json +++ b/packages/views/locales/en/my-issues.json @@ -32,9 +32,11 @@ "card_properties": "Card properties", "view_board": "Board view", "view_list": "List view", + "view_swimlane": "Swimlane view", "view_label": "View", "view_board_short": "Board", "view_list_short": "List", + "view_swimlane_short": "Swimlane", "sort_manual": "Manual" }, "errors": { diff --git a/packages/views/locales/zh-Hans/issues.json b/packages/views/locales/zh-Hans/issues.json index 8756b6716c..d212efa54a 100644 --- a/packages/views/locales/zh-Hans/issues.json +++ b/packages/views/locales/zh-Hans/issues.json @@ -77,10 +77,12 @@ "tooltip_board": "看板视图", "tooltip_list": "列表视图", "tooltip_gantt": "甘特图", + "tooltip_swimlane": "泳道视图", "section": "视图", "board": "看板", "list": "列表", - "gantt": "甘特图" + "gantt": "甘特图", + "swimlane": "泳道" }, "gantt": { "header_issue": "Issue", @@ -119,6 +121,11 @@ "empty_status": "无 issue", "add_issue_tooltip": "新建 issue" }, + "swimlane": { + "parent_column": "父级", + "no_parent": "无父级", + "open_parent": "打开父级 issue" + }, "board": { "hidden_columns_label": "隐藏的列", "hide_column": "隐藏列", diff --git a/packages/views/locales/zh-Hans/my-issues.json b/packages/views/locales/zh-Hans/my-issues.json index 8303259ab7..7c00eece30 100644 --- a/packages/views/locales/zh-Hans/my-issues.json +++ b/packages/views/locales/zh-Hans/my-issues.json @@ -31,9 +31,11 @@ "card_properties": "卡片属性", "view_board": "看板视图", "view_list": "列表视图", + "view_swimlane": "泳道视图", "view_label": "视图", "view_board_short": "看板", "view_list_short": "列表", + "view_swimlane_short": "泳道", "sort_manual": "手动" }, "errors": { diff --git a/packages/views/my-issues/components/my-issues-header.tsx b/packages/views/my-issues/components/my-issues-header.tsx index 8c3e011d00..aff9535abc 100644 --- a/packages/views/my-issues/components/my-issues-header.tsx +++ b/packages/views/my-issues/components/my-issues-header.tsx @@ -13,6 +13,7 @@ import { List, SignalHigh, SlidersHorizontal, + Waves, } from "lucide-react"; import { Button } from "@multica/ui/components/ui/button"; import { @@ -425,6 +426,8 @@ export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) {