Compare commits

...

2 Commits

Author SHA1 Message Date
J
7e8f4ffba5 feat(views): default swimlane grouping to assignee
Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 22:21:53 +08:00
J
888f85101c feat(views): swimlane supports parent / project / assignee grouping (MUL-2711)
The swimlane view was hard-coded to group by parent issue. This adds a
display dropdown so users can pick parent (default), project, or
assignee — analogous to how the board view exposes its grouping option.

- Generalise the lane builder in swimlane-view.tsx behind a `LaneGroup`
  abstraction (matcher + per-grouping `moveUpdates` payload) so the
  drag-end handler no longer branches on grouping. Cell ids gain a
  `<grouping>:<rawId>` prefix and lane sortable ids include the
  grouping so dnd-kit cannot collide entries from different groupings.
- Extend the view store with `swimlaneGrouping`, `swimlaneOrders` (one
  saved order per grouping), and a grouping-keyed `collapsedSwimlanes`.
  The persist `merge` defends against the old `string[]` shape so a
  pre-upgrade snapshot doesn't crash on first read.
- Wire `setSwimlaneGrouping` into the issues display popover next to
  the existing board grouping control. Add en / zh-Hans copy for the
  three swimlane buckets (Parent issue / Project / Assignee) and the
  two new pinned lanes (No project / Unassigned).
- Expand swimlane tests with parent / project / assignee smoke cases
  and update existing mocks to the new lane-id format. Add stable
  `useActorName` / `projectListOptions` mocks to avoid the
  set-state-in-effect loop that an unstable `getActorName` would
  trigger via the cells-rebuild memo.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 21:44:47 +08:00
6 changed files with 908 additions and 293 deletions

View File

@@ -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,
};
}

View File

@@ -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">

View File

@@ -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

View File

@@ -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"
},

View File

@@ -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": "切换泳道"
},