mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-22 06:59:19 +02:00
Compare commits
2 Commits
agent/lamb
...
agent/j/ee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e8f4ffba5 | ||
|
|
888f85101c |
@@ -12,9 +12,12 @@ import { defaultStorage } from "../../platform/storage";
|
||||
export type ViewMode = "board" | "list" | "gantt" | "swimlane";
|
||||
export type GanttZoom = "day" | "week" | "month";
|
||||
export type IssueGrouping = "status" | "assignee";
|
||||
export type SwimlaneGrouping = "parent" | "project" | "assignee";
|
||||
export type SortField = "position" | "priority" | "start_date" | "due_date" | "created_at" | "title";
|
||||
export type SortDirection = "asc" | "desc";
|
||||
|
||||
export const SWIMLANE_GROUPINGS: SwimlaneGrouping[] = ["parent", "project", "assignee"];
|
||||
|
||||
export interface CardProperties {
|
||||
priority: boolean;
|
||||
description: boolean;
|
||||
@@ -79,8 +82,15 @@ export interface IssueViewState {
|
||||
listCollapsedStatuses: IssueStatus[];
|
||||
ganttZoom: GanttZoom;
|
||||
ganttShowCompleted: boolean;
|
||||
swimlaneOrder: string[];
|
||||
collapsedSwimlanes: string[];
|
||||
/** Active swimlane grouping dimension. */
|
||||
swimlaneGrouping: SwimlaneGrouping;
|
||||
/** Persisted lane order, keyed by grouping. Entries are raw lane ids
|
||||
* (parent issue id, project id, or `<assigneeType>:<assigneeId>`). */
|
||||
swimlaneOrders: Record<SwimlaneGrouping, string[]>;
|
||||
/** Persisted collapsed lanes, keyed by grouping. Same id space as
|
||||
* `swimlaneOrders`, plus the sentinel `"none"` for the pinned
|
||||
* no-X lane and `"__orphans__"` for the parent-grouping fallback. */
|
||||
collapsedSwimlanes: Record<SwimlaneGrouping, string[]>;
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
setGanttZoom: (zoom: GanttZoom) => void;
|
||||
toggleGanttShowCompleted: () => void;
|
||||
@@ -101,7 +111,10 @@ export interface IssueViewState {
|
||||
setSortDirection: (dir: SortDirection) => void;
|
||||
toggleCardProperty: (key: keyof CardProperties) => void;
|
||||
toggleListCollapsed: (status: IssueStatus) => void;
|
||||
setSwimlaneGrouping: (grouping: SwimlaneGrouping) => void;
|
||||
/** Update the lane order for the currently active swimlane grouping. */
|
||||
setSwimlaneOrder: (order: string[]) => void;
|
||||
/** Toggle a lane key in the currently active swimlane grouping. */
|
||||
toggleSwimlaneCollapsed: (key: string) => void;
|
||||
}
|
||||
|
||||
@@ -132,8 +145,9 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
|
||||
listCollapsedStatuses: [],
|
||||
ganttZoom: "week",
|
||||
ganttShowCompleted: false,
|
||||
swimlaneOrder: [],
|
||||
collapsedSwimlanes: [],
|
||||
swimlaneGrouping: "assignee",
|
||||
swimlaneOrders: { parent: [], project: [], assignee: [] },
|
||||
collapsedSwimlanes: { parent: [], project: [], assignee: [] },
|
||||
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
setGanttZoom: (zoom) => set({ ganttZoom: zoom }),
|
||||
@@ -239,13 +253,22 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
|
||||
? state.listCollapsedStatuses.filter((s) => s !== status)
|
||||
: [...state.listCollapsedStatuses, status],
|
||||
})),
|
||||
setSwimlaneOrder: (order) => set({ swimlaneOrder: order }),
|
||||
toggleSwimlaneCollapsed: (key) =>
|
||||
setSwimlaneGrouping: (grouping) => set({ swimlaneGrouping: grouping }),
|
||||
setSwimlaneOrder: (order) =>
|
||||
set((state) => ({
|
||||
collapsedSwimlanes: state.collapsedSwimlanes.includes(key)
|
||||
? state.collapsedSwimlanes.filter((k) => k !== key)
|
||||
: [...state.collapsedSwimlanes, key],
|
||||
swimlaneOrders: { ...state.swimlaneOrders, [state.swimlaneGrouping]: order },
|
||||
})),
|
||||
toggleSwimlaneCollapsed: (key) =>
|
||||
set((state) => {
|
||||
const grouping = state.swimlaneGrouping;
|
||||
const current = state.collapsedSwimlanes[grouping];
|
||||
const next = current.includes(key)
|
||||
? current.filter((k) => k !== key)
|
||||
: [...current, key];
|
||||
return {
|
||||
collapsedSwimlanes: { ...state.collapsedSwimlanes, [grouping]: next },
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
export const viewStorePersistOptions = (name: string) => ({
|
||||
@@ -272,7 +295,8 @@ export const viewStorePersistOptions = (name: string) => ({
|
||||
listCollapsedStatuses: state.listCollapsedStatuses,
|
||||
ganttZoom: state.ganttZoom,
|
||||
ganttShowCompleted: state.ganttShowCompleted,
|
||||
swimlaneOrder: state.swimlaneOrder,
|
||||
swimlaneGrouping: state.swimlaneGrouping,
|
||||
swimlaneOrders: state.swimlaneOrders,
|
||||
collapsedSwimlanes: state.collapsedSwimlanes,
|
||||
}),
|
||||
// Default Zustand merge is shallow, so a persisted `cardProperties` snapshot
|
||||
@@ -293,6 +317,13 @@ export function mergeViewStatePersisted<T extends IssueViewState>(
|
||||
current: T,
|
||||
): T {
|
||||
const p = (persisted ?? {}) as Partial<T>;
|
||||
// `collapsedSwimlanes` changed shape from `string[]` to
|
||||
// `Record<SwimlaneGrouping, string[]>`. A snapshot saved in the old
|
||||
// shape would otherwise overwrite the default record with an array
|
||||
// and crash on first read — fall back to the default when the
|
||||
// persisted value isn't a plain object.
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
v !== null && typeof v === "object" && !Array.isArray(v);
|
||||
return {
|
||||
...current,
|
||||
...p,
|
||||
@@ -300,6 +331,12 @@ export function mergeViewStatePersisted<T extends IssueViewState>(
|
||||
...current.cardProperties,
|
||||
...(p.cardProperties ?? {}),
|
||||
},
|
||||
swimlaneOrders: isRecord(p.swimlaneOrders)
|
||||
? { ...current.swimlaneOrders, ...p.swimlaneOrders }
|
||||
: current.swimlaneOrders,
|
||||
collapsedSwimlanes: isRecord(p.collapsedSwimlanes)
|
||||
? { ...current.collapsedSwimlanes, ...p.collapsedSwimlanes }
|
||||
: current.collapsedSwimlanes,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -59,11 +59,12 @@ import { LabelChip } from "../../labels/label-chip";
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
GROUPING_OPTIONS,
|
||||
SWIMLANE_GROUPINGS,
|
||||
CARD_PROPERTY_OPTIONS,
|
||||
type ActorFilterValue,
|
||||
} from "@multica/core/issues/stores/view-store";
|
||||
import { useViewStore, useViewStoreApi } from "@multica/core/issues/stores/view-store-context";
|
||||
import type { SortField, IssueGrouping, ViewMode } from "@multica/core/issues/stores/view-store";
|
||||
import type { SortField, IssueGrouping, SwimlaneGrouping, ViewMode } from "@multica/core/issues/stores/view-store";
|
||||
import {
|
||||
useIssuesScopeStore,
|
||||
type IssuesScope,
|
||||
@@ -602,6 +603,7 @@ export function IssueDisplayControls({
|
||||
const sortBy = useViewStore((s) => s.sortBy);
|
||||
const sortDirection = useViewStore((s) => s.sortDirection);
|
||||
const grouping = useViewStore((s) => s.grouping);
|
||||
const swimlaneGrouping = useViewStore((s) => s.swimlaneGrouping);
|
||||
const cardProperties = useViewStore((s) => s.cardProperties);
|
||||
const act = useViewStoreApi().getState();
|
||||
|
||||
@@ -631,6 +633,11 @@ export function IssueDisplayControls({
|
||||
status: "group_status",
|
||||
assignee: "group_assignee",
|
||||
};
|
||||
const SWIMLANE_GROUPING_LABEL_KEY: Record<SwimlaneGrouping, "group_parent" | "group_project" | "group_assignee"> = {
|
||||
parent: "group_parent",
|
||||
project: "group_project",
|
||||
assignee: "group_assignee",
|
||||
};
|
||||
const CARD_PROPERTY_LABEL_KEY: Record<typeof CARD_PROPERTY_OPTIONS[number]["key"], "card_priority" | "card_description" | "card_assignee" | "card_start_date" | "card_due_date" | "card_project" | "card_labels" | "card_child_progress"> = {
|
||||
priority: "card_priority",
|
||||
description: "card_description",
|
||||
@@ -643,6 +650,7 @@ export function IssueDisplayControls({
|
||||
};
|
||||
const sortLabel = t(($) => $.display[SORT_LABEL_KEY[sortBy]]);
|
||||
const groupingLabel = t(($) => $.display[GROUPING_LABEL_KEY[grouping]]);
|
||||
const swimlaneGroupingLabel = t(($) => $.display[SWIMLANE_GROUPING_LABEL_KEY[swimlaneGrouping]]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -911,6 +919,41 @@ export function IssueDisplayControls({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{viewMode === "swimlane" && (
|
||||
<div className="border-b px-3 py-2.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t(($) => $.display.grouping_section)}
|
||||
</span>
|
||||
<div className="mt-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-between text-xs"
|
||||
>
|
||||
{swimlaneGroupingLabel}
|
||||
<ChevronDown className="size-3 text-muted-foreground" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-auto">
|
||||
<DropdownMenuRadioGroup
|
||||
value={swimlaneGrouping}
|
||||
onValueChange={(v) => act.setSwimlaneGrouping(v as SwimlaneGrouping)}
|
||||
>
|
||||
{SWIMLANE_GROUPINGS.map((value) => (
|
||||
<DropdownMenuRadioItem key={value} value={value}>
|
||||
{t(($) => $.display[SWIMLANE_GROUPING_LABEL_KEY[value]])}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-b px-3 py-2.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
|
||||
@@ -27,6 +27,32 @@ vi.mock("@multica/core/paths", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Stub backend-bound queries that the swimlane invokes for project /
|
||||
// assignee groupings. The hook MUST return a stable reference each call
|
||||
// — production `useActorName` wraps its returns in `useMemo`, and the
|
||||
// swimlane feeds the result into a `useMemo(..., [getActorName, ...])`
|
||||
// that then drives a `useEffect(setLocalCells, [cells])` chain. A fresh
|
||||
// object per render therefore loops the effect indefinitely.
|
||||
vi.mock("@multica/core/projects/queries", () => ({
|
||||
projectListOptions: (_wsId: string) => ({
|
||||
queryKey: ["projects", _wsId, "list"],
|
||||
queryFn: () => Promise.resolve([]),
|
||||
}),
|
||||
}));
|
||||
const { mockActorNameResult } = vi.hoisted(() => ({
|
||||
mockActorNameResult: {
|
||||
getActorName: (_type: string, _id: string) => "Mock Actor",
|
||||
getActorInitials: () => "MA",
|
||||
getActorAvatarUrl: () => null,
|
||||
getMemberName: () => "Mock Member",
|
||||
getAgentName: () => "Mock Agent",
|
||||
getSquadName: () => "Mock Squad",
|
||||
},
|
||||
}));
|
||||
vi.mock("@multica/core/workspace/hooks", () => ({
|
||||
useActorName: () => mockActorNameResult,
|
||||
}));
|
||||
|
||||
// Mock @multica/core/auth
|
||||
const mockAuthUser = { id: "user-1", email: "test@test.com", name: "Test User" };
|
||||
vi.mock("@multica/core/auth", () => ({
|
||||
@@ -97,15 +123,22 @@ vi.mock("@multica/core/issues/mutations", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
// 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.
|
||||
type SwimlaneGroupingMock = "parent" | "project" | "assignee";
|
||||
|
||||
// Mock view store. The lane order and collapsed-lane fields are mutable
|
||||
// records on the captured object so tests can simulate persisted state
|
||||
// (per grouping) and assert that `setSwimlaneOrder` was called by drag-end
|
||||
// handlers. The store actions operate on `swimlaneGrouping` — tests that
|
||||
// flip grouping must set both `swimlaneGrouping` and the matching slice
|
||||
// in `swimlaneOrders` / `collapsedSwimlanes`.
|
||||
const mockViewState: {
|
||||
sortBy: "position";
|
||||
sortDirection: "asc";
|
||||
cardProperties: Record<string, boolean>;
|
||||
swimlaneOrder: string[];
|
||||
collapsedSwimlanes: string[];
|
||||
swimlaneGrouping: SwimlaneGroupingMock;
|
||||
swimlaneOrders: Record<SwimlaneGroupingMock, string[]>;
|
||||
collapsedSwimlanes: Record<SwimlaneGroupingMock, string[]>;
|
||||
setSwimlaneGrouping: (g: SwimlaneGroupingMock) => void;
|
||||
setSwimlaneOrder: (order: string[]) => void;
|
||||
toggleSwimlaneCollapsed: (key: string) => void;
|
||||
hideStatus: (s: string) => void;
|
||||
@@ -114,8 +147,10 @@ const mockViewState: {
|
||||
sortBy: "position",
|
||||
sortDirection: "asc",
|
||||
cardProperties: { priority: true, description: true, assignee: true, dueDate: true, project: true, childProgress: true, labels: true },
|
||||
swimlaneOrder: [],
|
||||
collapsedSwimlanes: [],
|
||||
swimlaneGrouping: "parent",
|
||||
swimlaneOrders: { parent: [], project: [], assignee: [] },
|
||||
collapsedSwimlanes: { parent: [], project: [], assignee: [] },
|
||||
setSwimlaneGrouping: vi.fn(),
|
||||
setSwimlaneOrder: vi.fn(),
|
||||
toggleSwimlaneCollapsed: vi.fn(),
|
||||
hideStatus: vi.fn(),
|
||||
@@ -269,8 +304,9 @@ function renderWithI18n(ui: React.ReactNode) {
|
||||
describe("SwimLaneView", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockViewState.swimlaneOrder = [];
|
||||
mockViewState.collapsedSwimlanes = [];
|
||||
mockViewState.swimlaneGrouping = "parent";
|
||||
mockViewState.swimlaneOrders = { parent: [], project: [], assignee: [] };
|
||||
mockViewState.collapsedSwimlanes = { parent: [], project: [], assignee: [] };
|
||||
useLoadMoreByStatusMock.mockImplementation(() => ({
|
||||
total: 0,
|
||||
loaded: 0,
|
||||
@@ -727,8 +763,8 @@ describe("SwimLaneView", () => {
|
||||
|
||||
act(() => {
|
||||
lastOnDragEnd({
|
||||
active: { id: "lane:parent-1" },
|
||||
over: { id: "lane:parent-2" },
|
||||
active: { id: "lane:parent:parent-1" },
|
||||
over: { id: "lane:parent:parent-2" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -736,7 +772,10 @@ describe("SwimLaneView", () => {
|
||||
});
|
||||
|
||||
it("appends newly-visible parents to the persisted order on first reorder", () => {
|
||||
mockViewState.swimlaneOrder = ["parent-1"];
|
||||
mockViewState.swimlaneOrders = {
|
||||
...mockViewState.swimlaneOrders,
|
||||
parent: ["parent-1"],
|
||||
};
|
||||
|
||||
renderWithI18n(
|
||||
<SwimLaneView issues={multiParentIssues} onMoveIssue={vi.fn()} />,
|
||||
@@ -744,8 +783,8 @@ describe("SwimLaneView", () => {
|
||||
|
||||
act(() => {
|
||||
lastOnDragEnd({
|
||||
active: { id: "lane:parent-1" },
|
||||
over: { id: "lane:parent-2" },
|
||||
active: { id: "lane:parent:parent-1" },
|
||||
over: { id: "lane:parent:parent-2" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -753,7 +792,10 @@ describe("SwimLaneView", () => {
|
||||
});
|
||||
|
||||
it("preserves persisted entries that aren't currently visible during a reorder", () => {
|
||||
mockViewState.swimlaneOrder = ["filtered-a", "parent-1", "filtered-b", "parent-2"];
|
||||
mockViewState.swimlaneOrders = {
|
||||
...mockViewState.swimlaneOrders,
|
||||
parent: ["filtered-a", "parent-1", "filtered-b", "parent-2"],
|
||||
};
|
||||
|
||||
renderWithI18n(
|
||||
<SwimLaneView issues={multiParentIssues} onMoveIssue={vi.fn()} />,
|
||||
@@ -761,8 +803,8 @@ describe("SwimLaneView", () => {
|
||||
|
||||
act(() => {
|
||||
lastOnDragEnd({
|
||||
active: { id: "lane:parent-1" },
|
||||
over: { id: "lane:parent-2" },
|
||||
active: { id: "lane:parent:parent-1" },
|
||||
over: { id: "lane:parent:parent-2" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -781,8 +823,8 @@ describe("SwimLaneView", () => {
|
||||
|
||||
act(() => {
|
||||
lastOnDragEnd({
|
||||
active: { id: "lane:parent-1" },
|
||||
over: { id: "lane:parent-1" },
|
||||
active: { id: "lane:parent:parent-1" },
|
||||
over: { id: "lane:parent:parent-1" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -797,8 +839,8 @@ describe("SwimLaneView", () => {
|
||||
|
||||
act(() => {
|
||||
lastOnDragEnd({
|
||||
active: { id: "lane:parent-1" },
|
||||
over: { id: "lane:parent-2" },
|
||||
active: { id: "lane:parent:parent-1" },
|
||||
over: { id: "lane:parent:parent-2" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -806,7 +848,10 @@ describe("SwimLaneView", () => {
|
||||
});
|
||||
|
||||
it("renders parent lanes in stored swimlaneOrder when set", () => {
|
||||
mockViewState.swimlaneOrder = ["parent-2", "parent-1"];
|
||||
mockViewState.swimlaneOrders = {
|
||||
...mockViewState.swimlaneOrders,
|
||||
parent: ["parent-2", "parent-1"],
|
||||
};
|
||||
|
||||
renderWithI18n(
|
||||
<SwimLaneView issues={multiParentIssues} onMoveIssue={vi.fn()} />,
|
||||
@@ -820,7 +865,10 @@ describe("SwimLaneView", () => {
|
||||
});
|
||||
|
||||
it("keeps 'No parent' lane pinned at top regardless of stored order", () => {
|
||||
mockViewState.swimlaneOrder = ["parent-2", "parent-1"];
|
||||
mockViewState.swimlaneOrders = {
|
||||
...mockViewState.swimlaneOrders,
|
||||
parent: ["parent-2", "parent-1"],
|
||||
};
|
||||
|
||||
renderWithI18n(
|
||||
<SwimLaneView issues={multiParentIssues} onMoveIssue={vi.fn()} />,
|
||||
@@ -836,7 +884,10 @@ describe("SwimLaneView", () => {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
it("collapses a parent lane when its id is in stored collapsedSwimlanes", () => {
|
||||
mockViewState.collapsedSwimlanes = ["parent-1"];
|
||||
mockViewState.collapsedSwimlanes = {
|
||||
...mockViewState.collapsedSwimlanes,
|
||||
parent: ["parent-1"],
|
||||
};
|
||||
|
||||
renderWithI18n(
|
||||
<SwimLaneView issues={multiParentIssues} onMoveIssue={vi.fn()} />,
|
||||
@@ -851,7 +902,10 @@ describe("SwimLaneView", () => {
|
||||
});
|
||||
|
||||
it("collapses the 'No parent' lane when 'none' is in stored collapsedSwimlanes", () => {
|
||||
mockViewState.collapsedSwimlanes = ["none"];
|
||||
mockViewState.collapsedSwimlanes = {
|
||||
...mockViewState.collapsedSwimlanes,
|
||||
parent: ["none"],
|
||||
};
|
||||
|
||||
renderWithI18n(
|
||||
<SwimLaneView issues={mockIssues} onMoveIssue={vi.fn()} />,
|
||||
@@ -887,4 +941,209 @@ describe("SwimLaneView", () => {
|
||||
|
||||
expect(mockToggleSwimlaneCollapsed).toHaveBeenCalledWith("none");
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Project grouping
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
const projectIssues: Issue[] = [
|
||||
{
|
||||
...mockIssues[0]!,
|
||||
id: "issue-a",
|
||||
identifier: "PROJ-100",
|
||||
title: "Issue A",
|
||||
project_id: "proj-1",
|
||||
parent_issue_id: null,
|
||||
status: "todo",
|
||||
},
|
||||
{
|
||||
...mockIssues[0]!,
|
||||
id: "issue-b",
|
||||
identifier: "PROJ-101",
|
||||
title: "Issue B",
|
||||
project_id: "proj-2",
|
||||
parent_issue_id: null,
|
||||
status: "in_progress",
|
||||
},
|
||||
{
|
||||
...mockIssues[0]!,
|
||||
id: "issue-c",
|
||||
identifier: "PROJ-102",
|
||||
title: "Issue C",
|
||||
project_id: null,
|
||||
parent_issue_id: null,
|
||||
status: "todo",
|
||||
},
|
||||
];
|
||||
|
||||
it("groups by project when swimlaneGrouping is 'project'", () => {
|
||||
mockViewState.swimlaneGrouping = "project";
|
||||
|
||||
renderWithI18n(
|
||||
<SwimLaneView issues={projectIssues} onMoveIssue={vi.fn()} />,
|
||||
);
|
||||
|
||||
// No-project pinned lane is always present.
|
||||
expect(screen.getAllByText("No project").length).toBeGreaterThanOrEqual(1);
|
||||
// Both issue cards from real projects render — production fetches
|
||||
// project titles from the API; in tests the mocked listProjects
|
||||
// returns [] so the lane headers fall back to an empty title and
|
||||
// we assert on card visibility, not lane title text.
|
||||
expect(screen.getByText("Issue A")).toBeInTheDocument();
|
||||
expect(screen.getByText("Issue B")).toBeInTheDocument();
|
||||
expect(screen.getByText("Issue C")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("emits project_id when a card is dropped into a project lane", () => {
|
||||
mockViewState.swimlaneGrouping = "project";
|
||||
const mockOnMoveIssue = vi.fn();
|
||||
|
||||
renderWithI18n(
|
||||
<SwimLaneView issues={projectIssues} onMoveIssue={mockOnMoveIssue} />,
|
||||
);
|
||||
|
||||
// Drop "issue-c" (no project) into proj-1's todo cell.
|
||||
const target = "swim:project:proj-1:todo";
|
||||
act(() => {
|
||||
lastOnDragOver({ active: { id: "issue-c" }, over: { id: target } });
|
||||
});
|
||||
act(() => {
|
||||
lastOnDragEnd({ active: { id: "issue-c" }, over: { id: target } });
|
||||
});
|
||||
|
||||
expect(mockOnMoveIssue).toHaveBeenCalledWith(
|
||||
"issue-c",
|
||||
expect.objectContaining({ project_id: "proj-1", status: "todo" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits null project_id when a card is dropped into the 'No project' lane", () => {
|
||||
mockViewState.swimlaneGrouping = "project";
|
||||
const mockOnMoveIssue = vi.fn();
|
||||
|
||||
renderWithI18n(
|
||||
<SwimLaneView issues={projectIssues} onMoveIssue={mockOnMoveIssue} />,
|
||||
);
|
||||
|
||||
const target = "swim:project:none:in_review";
|
||||
act(() => {
|
||||
lastOnDragOver({ active: { id: "issue-a" }, over: { id: target } });
|
||||
});
|
||||
act(() => {
|
||||
lastOnDragEnd({ active: { id: "issue-a" }, over: { id: target } });
|
||||
});
|
||||
|
||||
expect(mockOnMoveIssue).toHaveBeenCalledWith(
|
||||
"issue-a",
|
||||
expect.objectContaining({ project_id: null, status: "in_review" }),
|
||||
);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Assignee grouping
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
const assigneeIssues: Issue[] = [
|
||||
{
|
||||
...mockIssues[0]!,
|
||||
id: "issue-x",
|
||||
identifier: "PROJ-200",
|
||||
title: "Issue X",
|
||||
assignee_type: "member",
|
||||
assignee_id: "user-1",
|
||||
parent_issue_id: null,
|
||||
project_id: null,
|
||||
status: "todo",
|
||||
},
|
||||
{
|
||||
...mockIssues[0]!,
|
||||
id: "issue-y",
|
||||
identifier: "PROJ-201",
|
||||
title: "Issue Y",
|
||||
assignee_type: "agent",
|
||||
assignee_id: "agent-1",
|
||||
parent_issue_id: null,
|
||||
project_id: null,
|
||||
status: "in_progress",
|
||||
},
|
||||
{
|
||||
...mockIssues[0]!,
|
||||
id: "issue-z",
|
||||
identifier: "PROJ-202",
|
||||
title: "Issue Z",
|
||||
assignee_type: null,
|
||||
assignee_id: null,
|
||||
parent_issue_id: null,
|
||||
project_id: null,
|
||||
status: "todo",
|
||||
},
|
||||
];
|
||||
|
||||
it("groups by assignee when swimlaneGrouping is 'assignee'", () => {
|
||||
mockViewState.swimlaneGrouping = "assignee";
|
||||
|
||||
renderWithI18n(
|
||||
<SwimLaneView issues={assigneeIssues} onMoveIssue={vi.fn()} />,
|
||||
);
|
||||
|
||||
// Unassigned pinned lane is always rendered.
|
||||
expect(screen.getAllByText("Unassigned").length).toBeGreaterThanOrEqual(1);
|
||||
// Mock actor name fallback for both member and agent.
|
||||
expect(screen.getAllByText("Mock Actor").length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getByText("Issue X")).toBeInTheDocument();
|
||||
expect(screen.getByText("Issue Y")).toBeInTheDocument();
|
||||
expect(screen.getByText("Issue Z")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("emits assignee_type + assignee_id when a card is dropped into an actor lane", () => {
|
||||
mockViewState.swimlaneGrouping = "assignee";
|
||||
const mockOnMoveIssue = vi.fn();
|
||||
|
||||
renderWithI18n(
|
||||
<SwimLaneView issues={assigneeIssues} onMoveIssue={mockOnMoveIssue} />,
|
||||
);
|
||||
|
||||
const target = "swim:assignee:member:user-1:in_review";
|
||||
act(() => {
|
||||
lastOnDragOver({ active: { id: "issue-z" }, over: { id: target } });
|
||||
});
|
||||
act(() => {
|
||||
lastOnDragEnd({ active: { id: "issue-z" }, over: { id: target } });
|
||||
});
|
||||
|
||||
expect(mockOnMoveIssue).toHaveBeenCalledWith(
|
||||
"issue-z",
|
||||
expect.objectContaining({
|
||||
assignee_type: "member",
|
||||
assignee_id: "user-1",
|
||||
status: "in_review",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits null assignee when a card is dropped into the 'Unassigned' lane", () => {
|
||||
mockViewState.swimlaneGrouping = "assignee";
|
||||
const mockOnMoveIssue = vi.fn();
|
||||
|
||||
renderWithI18n(
|
||||
<SwimLaneView issues={assigneeIssues} onMoveIssue={mockOnMoveIssue} />,
|
||||
);
|
||||
|
||||
const target = "swim:assignee:none:done";
|
||||
act(() => {
|
||||
lastOnDragOver({ active: { id: "issue-x" }, over: { id: target } });
|
||||
});
|
||||
act(() => {
|
||||
lastOnDragEnd({ active: { id: "issue-x" }, over: { id: target } });
|
||||
});
|
||||
|
||||
expect(mockOnMoveIssue).toHaveBeenCalledWith(
|
||||
"issue-x",
|
||||
expect.objectContaining({
|
||||
assignee_type: null,
|
||||
assignee_id: null,
|
||||
status: "done",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -61,6 +61,8 @@
|
||||
"descending_title": "Descending",
|
||||
"group_status": "Status",
|
||||
"group_assignee": "Assignee",
|
||||
"group_parent": "Parent issue",
|
||||
"group_project": "Project",
|
||||
"sort_manual": "Manual",
|
||||
"sort_priority": "Priority",
|
||||
"sort_start_date": "Start date",
|
||||
@@ -127,6 +129,8 @@
|
||||
"swimlane": {
|
||||
"no_parent": "No parent",
|
||||
"other_parents": "Other parents",
|
||||
"no_project": "No project",
|
||||
"no_assignee": "Unassigned",
|
||||
"open_parent": "Open parent issue",
|
||||
"toggle_collapse": "Toggle lane"
|
||||
},
|
||||
|
||||
@@ -59,6 +59,8 @@
|
||||
"descending_title": "降序",
|
||||
"group_status": "状态",
|
||||
"group_assignee": "负责人",
|
||||
"group_parent": "父级 issue",
|
||||
"group_project": "项目",
|
||||
"sort_manual": "手动",
|
||||
"sort_priority": "优先级",
|
||||
"sort_start_date": "开始日期",
|
||||
@@ -125,6 +127,8 @@
|
||||
"swimlane": {
|
||||
"no_parent": "无父级",
|
||||
"other_parents": "其他父级",
|
||||
"no_project": "无项目",
|
||||
"no_assignee": "未指派",
|
||||
"open_parent": "打开父级 issue",
|
||||
"toggle_collapse": "切换泳道"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user