diff --git a/packages/views/issues/components/board-view.test.tsx b/packages/views/issues/components/board-view.test.tsx new file mode 100644 index 000000000..ee03c5b08 --- /dev/null +++ b/packages/views/issues/components/board-view.test.tsx @@ -0,0 +1,253 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, within, act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { I18nProvider } from "@multica/core/i18n/react"; +import type { Issue } from "@multica/core/types"; +import enCommon from "../../locales/en/common.json"; +import enIssues from "../../locales/en/issues.json"; +import { BoardView } from "./board-view"; + +const TEST_RESOURCES = { en: { common: enCommon, issues: enIssues } }; + +// PRIORITY_ORDER drives sortIssues' "priority" ranking. +vi.mock("@multica/core/issues/config", () => ({ + PRIORITY_ORDER: ["urgent", "high", "medium", "low", "none"], +})); + +// MUST be a stable reference: production `useActorName` memoizes its return, +// and board-view feeds `getActorName` into a `useMemo` that drives the +// columns-rebuild effect. A fresh object per render loops it forever. +const { mockActorName } = vi.hoisted(() => ({ + mockActorName: { + getActorName: () => "Mock Actor", + getActorInitials: () => "MA", + getActorAvatarUrl: () => null, + }, +})); +vi.mock("@multica/core/workspace/hooks", () => ({ + useActorName: () => mockActorName, +})); + +// View store — mutable so each test sets the sort it needs. +const mockViewState = { + grouping: "status" as "status" | "assignee", + sortBy: "position" as + | "position" + | "priority" + | "start_date" + | "due_date" + | "created_at" + | "title", + sortDirection: "asc" as "asc" | "desc", +}; +vi.mock("@multica/core/issues/stores/view-store-context", () => ({ + ViewStoreProvider: ({ children }: { children: React.ReactNode }) => children, + useViewStore: (selector?: (s: typeof mockViewState) => unknown) => + selector ? selector(mockViewState) : mockViewState, + useViewStoreApi: () => ({ + getState: () => mockViewState, + setState: vi.fn(), + subscribe: vi.fn(), + }), +})); + +const useLoadMoreByStatusMock = vi.fn(() => ({ + total: 0, + loaded: 0, + hasMore: false, + isLoading: false, + loadMore: vi.fn(), +})); +vi.mock("@multica/core/issues/mutations", () => ({ + useLoadMoreByStatus: () => useLoadMoreByStatusMock(), + useLoadMoreByAssigneeGroup: () => useLoadMoreByStatusMock(), +})); + +// Capture the drag handlers so tests can drive drag-end directly. +let lastOnDragEnd: ((event: unknown) => void) | null = null; +vi.mock("@dnd-kit/core", () => ({ + DndContext: ({ + children, + onDragEnd, + }: { + children: React.ReactNode; + onDragEnd: (event: unknown) => void; + }) => { + lastOnDragEnd = onDragEnd; + return children; + }, + DragOverlay: () => null, + PointerSensor: class {}, + useSensor: () => ({}), + useSensors: () => [], + pointerWithin: vi.fn(), + closestCenter: vi.fn(), +})); + +// Replace heavy column/card rendering with a thin probe that exposes the +// per-column ordered issue ids — exactly what BoardView is responsible for. +vi.mock("./board-column", () => ({ + BOARD_CARD_WIDTH: 200, + BoardColumn: ({ + group, + issueIds, + }: { + group: { id: string }; + issueIds: string[]; + }) => ( +
+ {issueIds.map((id) => ( + + {id} + + ))} +
+ ), +})); +vi.mock("./board-card", () => ({ BoardCardContent: () => null })); +vi.mock("./hidden-columns-panel", () => ({ + HiddenColumnsPanel: () => null, + HiddenColumnRow: () => null, +})); +vi.mock("./infinite-scroll-sentinel", () => ({ + InfiniteScrollSentinel: () => null, +})); + +function makeIssue(over: Partial & { id: string }): Issue { + return { + workspace_id: "ws-1", + number: 1, + identifier: "MUL-1", + title: over.id, + description: "", + status: "todo", + priority: "none", + assignee_type: null, + assignee_id: null, + creator_type: "member", + creator_id: "user-1", + parent_issue_id: null, + project_id: null, + position: 0, + start_date: null, + due_date: null, + metadata: {}, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + ...over, + }; +} + +function renderBoard(ui: React.ReactNode) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + return render( + + + {ui} + + , + ); +} + +const VISIBLE = ["todo", "in_progress"] as const; + +describe("BoardView non-manual ordering", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockViewState.grouping = "status"; + mockViewState.sortBy = "position"; + mockViewState.sortDirection = "asc"; + lastOnDragEnd = null; + }); + + it("orders cards within a column by the active non-position sort", () => { + mockViewState.sortBy = "priority"; + // Out of priority order on purpose; cache/array order is low, urgent, medium. + const issues = [ + makeIssue({ id: "low", status: "todo", priority: "low" }), + makeIssue({ id: "urgent", status: "todo", priority: "urgent" }), + makeIssue({ id: "medium", status: "todo", priority: "medium" }), + ]; + + const { getByTestId } = renderBoard( + , + ); + + const cards = within(getByTestId("col-status:todo")).getAllByTestId("card"); + expect(cards.map((c) => c.textContent)).toEqual(["urgent", "medium", "low"]); + }); + + it("places an optimistically-moved card into its sorted slot without waiting for settle", () => { + mockViewState.sortBy = "priority"; + const onMoveIssue = vi.fn(); + const mover = makeIssue({ id: "mover", status: "todo", priority: "high" }); + const initial = [ + mover, + makeIssue({ id: "stay-urgent", status: "in_progress", priority: "urgent" }), + makeIssue({ id: "stay-low", status: "in_progress", priority: "low" }), + ]; + + const { getByTestId, rerender } = renderBoard( + , + ); + + // Drag "mover" from todo into the in_progress column. + act(() => { + lastOnDragEnd?.({ + active: { id: "mover" }, + over: { id: "status:in_progress" }, + }); + }); + + // The move is requested with no settle callback — the gate is gone. + expect(onMoveIssue).toHaveBeenCalledTimes(1); + const [movedId, updates, settleCb] = onMoveIssue.mock.calls[0]!; + expect(movedId).toBe("mover"); + expect(updates).toMatchObject({ status: "in_progress" }); + expect(settleCb).toBeUndefined(); + + // Simulate the optimistic cache patch (status flipped) WITHOUT firing any + // settle callback. The card must already sit in its sorted slot — between + // urgent and low — in the in_progress column. + const patched = [ + makeIssue({ id: "mover", status: "in_progress", priority: "high" }), + makeIssue({ id: "stay-urgent", status: "in_progress", priority: "urgent" }), + makeIssue({ id: "stay-low", status: "in_progress", priority: "low" }), + ]; + rerender( + + + + + , + ); + + const todo = within(getByTestId("col-status:todo")).queryAllByTestId("card"); + expect(todo.map((c) => c.textContent)).toEqual([]); + const inProgress = within(getByTestId("col-status:in_progress")).getAllByTestId( + "card", + ); + expect(inProgress.map((c) => c.textContent)).toEqual([ + "stay-urgent", + "mover", + "stay-low", + ]); + }); +}); diff --git a/packages/views/issues/components/board-view.tsx b/packages/views/issues/components/board-view.tsx index c9584e931..669e0dfb2 100644 --- a/packages/views/issues/components/board-view.tsx +++ b/packages/views/issues/components/board-view.tsx @@ -25,6 +25,7 @@ import { HiddenColumnsPanel, HiddenColumnRow } from "./hidden-columns-panel"; import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel"; import type { ChildProgress } from "./list-row"; import { useT } from "../../i18n"; +import { sortIssues } from "../utils/sort"; import { type DragMoveUpdates, makeKanbanCollision, @@ -141,6 +142,7 @@ export function BoardView({ const { t } = useT("issues"); const grouping = useViewStore((s) => s.grouping); const sortBy = useViewStore((s) => s.sortBy); + const sortDirection = useViewStore((s) => s.sortDirection); const sortFieldKey = sortBy === "created_at" ? "created" : sortBy; const sortLabel = sortBy !== "position" ? t(($) => $.board.ordered_by, { field: t(($) => $.display[`sort_${sortFieldKey}` as keyof typeof $.display]) }) @@ -156,6 +158,15 @@ export function BoardView({ : issues, [assigneeGroups, grouping, issues], ); + // Client-side sort before building columns, mirroring swimlane/gantt. The + // optimistic move appends the issue to its target bucket's tail in the cache; + // sorting here drops it straight into its correct slot so non-manual sorts + // (priority/date/title) don't jump on settle. For manual sort this is a no-op + // ordering (sortIssues "position" branch == the manual order). + const sortedGroupedIssues = useMemo( + () => sortIssues(groupedIssues, sortBy, sortDirection), + [groupedIssues, sortBy, sortDirection], + ); const hydratedAssigneeGroups = useMemo(() => { if (grouping !== "assignee" || !assigneeGroups) return undefined; const order: Record = { @@ -215,22 +226,21 @@ export function BoardView({ const [activeIssue, setActiveIssue] = useState(null); const isDraggingRef = useRef(false); const isSettlingRef = useRef(false); - const [settleVersion, setSettleVersion] = useState(0); // --- Local columns state --- // Between drags: follows TQ via useEffect. // During drag: local-only, driven by onDragOver/onDragEnd. const [columns, setColumns] = useState>(() => - buildColumns(groupedIssues, groups, grouping), + buildColumns(sortedGroupedIssues, groups, grouping), ); const columnsRef = useRef(columns); columnsRef.current = columns; useEffect(() => { if (!isDraggingRef.current && !isSettlingRef.current) { - setColumns(buildColumns(groupedIssues, groups, grouping)); + setColumns(buildColumns(sortedGroupedIssues, groups, grouping)); } - }, [groupedIssues, groups, grouping, settleVersion]); + }, [sortedGroupedIssues, groups, grouping]); // After a cross-column move, lock for one animation frame so dnd-kit's // collision detection can stabilize before processing the next move. @@ -306,7 +316,7 @@ export function BoardView({ setActiveIssue(null); const resetColumns = () => - setColumns(buildColumns(groupedIssues, groups, grouping)); + setColumns(buildColumns(sortedGroupedIssues, groups, grouping)); if (!over) { resetColumns(); @@ -359,11 +369,12 @@ export function BoardView({ resetColumns(); return; } - isSettlingRef.current = true; - onMoveIssue(activeId, getMoveUpdates(finalGroup, currentIssue.position), () => { - isSettlingRef.current = false; - setSettleVersion((v) => v + 1); - }); + // No settle gate here: the optimistic cache patch flows straight through + // the (now sorted) buildColumns effect, so the card lands in its correct + // sorted slot in the target column in one frame — no "stuck in source + // column then jump" lag. The settle gate is only needed for manual + // (position) reorders, which keep a hand-built local order below. + onMoveIssue(activeId, getMoveUpdates(finalGroup, currentIssue.position)); return; } @@ -384,7 +395,7 @@ export function BoardView({ isSettlingRef.current = false; }); }, - [groupedIssues, groups, grouping, onMoveIssue, groupIds, groupMap, sortBy], + [sortedGroupedIssues, groups, grouping, onMoveIssue, groupIds, groupMap, sortBy], ); return (