mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-29 02:19:19 +02:00
Compare commits
1 Commits
codex/agen
...
agent/j/92
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9035bc2644 |
314
packages/core/issues/mutations.test.tsx
Normal file
314
packages/core/issues/mutations.test.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { setApiInstance } from "../api";
|
||||
import type { ApiClient } from "../api/client";
|
||||
import { useLoadMoreByAssigneeGroup, useLoadMoreByStatus } from "./mutations";
|
||||
import {
|
||||
issueKeys,
|
||||
type IssueSortParam,
|
||||
} from "./queries";
|
||||
import type {
|
||||
GroupedIssuesResponse,
|
||||
Issue,
|
||||
ListIssuesCache,
|
||||
ListIssuesParams,
|
||||
ListGroupedIssuesParams,
|
||||
ListIssuesResponse,
|
||||
} from "../types";
|
||||
|
||||
vi.mock("../hooks", () => ({
|
||||
useWorkspaceId: () => "ws-1",
|
||||
}));
|
||||
|
||||
const WS_ID = "ws-1";
|
||||
|
||||
function makeIssue(idx: number, overrides: Partial<Issue> = {}): Issue {
|
||||
return {
|
||||
id: `issue-${idx}`,
|
||||
workspace_id: WS_ID,
|
||||
number: idx,
|
||||
identifier: `MUL-${idx}`,
|
||||
title: `Issue ${idx}`,
|
||||
description: null,
|
||||
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: idx,
|
||||
start_date: null,
|
||||
due_date: null,
|
||||
labels: [],
|
||||
metadata: {},
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createWrapper(qc: QueryClient) {
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
};
|
||||
}
|
||||
|
||||
describe("useLoadMoreByStatus", () => {
|
||||
let qc: QueryClient;
|
||||
let listIssues: ReturnType<typeof vi.fn<(p?: ListIssuesParams) => Promise<ListIssuesResponse>>>;
|
||||
|
||||
beforeEach(() => {
|
||||
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
listIssues = vi.fn();
|
||||
setApiInstance({ listIssues } as unknown as ApiClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
qc.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("targets the sorted cache key and forwards sort to the API", async () => {
|
||||
const sort: IssueSortParam = { sort_by: "priority", sort_direction: "desc" };
|
||||
const activeKey = issueKeys.listSorted(WS_ID, sort);
|
||||
const seed: ListIssuesCache = {
|
||||
byStatus: {
|
||||
todo: { issues: [makeIssue(1)], total: 3 },
|
||||
},
|
||||
};
|
||||
qc.setQueryData<ListIssuesCache>(activeKey, seed);
|
||||
|
||||
listIssues.mockResolvedValue({
|
||||
issues: [makeIssue(2), makeIssue(3)],
|
||||
total: 3,
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useLoadMoreByStatus("todo", undefined, sort),
|
||||
{ wrapper: createWrapper(qc) },
|
||||
);
|
||||
|
||||
expect(result.current.hasMore).toBe(true);
|
||||
expect(result.current.total).toBe(3);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadMore();
|
||||
});
|
||||
|
||||
expect(listIssues).toHaveBeenCalledWith({
|
||||
status: "todo",
|
||||
limit: 50,
|
||||
offset: 1,
|
||||
sort_by: "priority",
|
||||
sort_direction: "desc",
|
||||
});
|
||||
|
||||
const updated = qc.getQueryData<ListIssuesCache>(activeKey);
|
||||
expect(updated?.byStatus.todo?.issues).toHaveLength(3);
|
||||
expect(updated?.byStatus.todo?.issues.map((i) => i.id)).toEqual([
|
||||
"issue-1",
|
||||
"issue-2",
|
||||
"issue-3",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores a stale cache entry under a different sort", async () => {
|
||||
// Stale entry from a previous sort lingers (kept by gcTime / keepPreviousData).
|
||||
const staleSort: IssueSortParam = { sort_by: "priority", sort_direction: "desc" };
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.listSorted(WS_ID, staleSort), {
|
||||
byStatus: { todo: { issues: [makeIssue(99)], total: 99 } },
|
||||
});
|
||||
|
||||
// The active sort cache has its own bucket — load-more must target THIS one.
|
||||
const activeSort: IssueSortParam = { sort_by: "position", sort_direction: undefined };
|
||||
const activeKey = issueKeys.listSorted(WS_ID, activeSort);
|
||||
qc.setQueryData<ListIssuesCache>(activeKey, {
|
||||
byStatus: { todo: { issues: [makeIssue(1)], total: 2 } },
|
||||
});
|
||||
|
||||
listIssues.mockResolvedValue({
|
||||
issues: [makeIssue(2)],
|
||||
total: 2,
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useLoadMoreByStatus("todo", undefined, activeSort),
|
||||
{ wrapper: createWrapper(qc) },
|
||||
);
|
||||
|
||||
// total derives from the active key, not the stale one.
|
||||
expect(result.current.total).toBe(2);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadMore();
|
||||
});
|
||||
|
||||
expect(listIssues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ offset: 1, sort_by: "position" }),
|
||||
);
|
||||
|
||||
const active = qc.getQueryData<ListIssuesCache>(activeKey);
|
||||
expect(active?.byStatus.todo?.issues.map((i) => i.id)).toEqual([
|
||||
"issue-1",
|
||||
"issue-2",
|
||||
]);
|
||||
|
||||
// Stale cache is untouched.
|
||||
const stale = qc.getQueryData<ListIssuesCache>(issueKeys.listSorted(WS_ID, staleSort));
|
||||
expect(stale?.byStatus.todo?.issues.map((i) => i.id)).toEqual(["issue-99"]);
|
||||
});
|
||||
|
||||
it("targets the myList scoped cache when myIssues is provided", async () => {
|
||||
const sort: IssueSortParam = { sort_by: "title", sort_direction: "asc" };
|
||||
const myIssues = { scope: "assigned", filter: { assignee_id: "user-1" } };
|
||||
const activeKey = issueKeys.myListSorted(WS_ID, myIssues.scope, myIssues.filter, sort);
|
||||
qc.setQueryData<ListIssuesCache>(activeKey, {
|
||||
byStatus: { in_progress: { issues: [makeIssue(1, { status: "in_progress" })], total: 2 } },
|
||||
});
|
||||
|
||||
listIssues.mockResolvedValue({
|
||||
issues: [makeIssue(2, { status: "in_progress" })],
|
||||
total: 2,
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useLoadMoreByStatus("in_progress", myIssues, sort),
|
||||
{ wrapper: createWrapper(qc) },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadMore();
|
||||
});
|
||||
|
||||
expect(listIssues).toHaveBeenCalledWith({
|
||||
status: "in_progress",
|
||||
limit: 50,
|
||||
offset: 1,
|
||||
sort_by: "title",
|
||||
sort_direction: "asc",
|
||||
assignee_id: "user-1",
|
||||
});
|
||||
|
||||
const updated = qc.getQueryData<ListIssuesCache>(activeKey);
|
||||
expect(updated?.byStatus.in_progress?.issues).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("works with no sort (matches the {} key used by sort-less callers)", async () => {
|
||||
const myIssues = { scope: "actor", filter: { assignee_id: "user-2" } };
|
||||
const activeKey = issueKeys.myListSorted(WS_ID, myIssues.scope, myIssues.filter, undefined);
|
||||
qc.setQueryData<ListIssuesCache>(activeKey, {
|
||||
byStatus: { todo: { issues: [makeIssue(1)], total: 2 } },
|
||||
});
|
||||
|
||||
listIssues.mockResolvedValue({ issues: [makeIssue(2)], total: 2 });
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useLoadMoreByStatus("todo", myIssues),
|
||||
{ wrapper: createWrapper(qc) },
|
||||
);
|
||||
|
||||
expect(result.current.total).toBe(2);
|
||||
expect(result.current.hasMore).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadMore();
|
||||
});
|
||||
|
||||
const updated = qc.getQueryData<ListIssuesCache>(activeKey);
|
||||
expect(updated?.byStatus.todo?.issues).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useLoadMoreByAssigneeGroup", () => {
|
||||
let qc: QueryClient;
|
||||
let listGroupedIssues: ReturnType<
|
||||
typeof vi.fn<(p: ListGroupedIssuesParams) => Promise<GroupedIssuesResponse>>
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
listGroupedIssues = vi.fn();
|
||||
setApiInstance({ listGroupedIssues } as unknown as ApiClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
qc.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("forwards sort to the grouped API and appends into the right group", async () => {
|
||||
const sort: IssueSortParam = { sort_by: "priority", sort_direction: "desc" };
|
||||
const queryKey = ["custom", "assignee-groups", "ws-1"] as const;
|
||||
const seed: GroupedIssuesResponse = {
|
||||
groups: [
|
||||
{
|
||||
id: "assignee:member:user-1",
|
||||
assignee_type: "member",
|
||||
assignee_id: "user-1",
|
||||
issues: [makeIssue(1, { assignee_type: "member", assignee_id: "user-1" })],
|
||||
total: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
qc.setQueryData<GroupedIssuesResponse>(queryKey, seed);
|
||||
|
||||
listGroupedIssues.mockResolvedValue({
|
||||
groups: [
|
||||
{
|
||||
id: "assignee:member:user-1",
|
||||
assignee_type: "member",
|
||||
assignee_id: "user-1",
|
||||
issues: [makeIssue(2, { assignee_type: "member", assignee_id: "user-1" })],
|
||||
total: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useLoadMoreByAssigneeGroup(
|
||||
{
|
||||
id: "assignee:member:user-1",
|
||||
assignee_type: "member",
|
||||
assignee_id: "user-1",
|
||||
},
|
||||
queryKey,
|
||||
{ statuses: ["todo"] },
|
||||
sort,
|
||||
),
|
||||
{ wrapper: createWrapper(qc) },
|
||||
);
|
||||
|
||||
expect(result.current.hasMore).toBe(true);
|
||||
expect(result.current.total).toBe(2);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadMore();
|
||||
});
|
||||
|
||||
expect(listGroupedIssues).toHaveBeenCalledWith({
|
||||
group_by: "assignee",
|
||||
limit: 50,
|
||||
offset: 1,
|
||||
sort_by: "priority",
|
||||
sort_direction: "desc",
|
||||
statuses: ["todo"],
|
||||
group_assignee_type: "member",
|
||||
group_assignee_id: "user-1",
|
||||
});
|
||||
|
||||
const updated = qc.getQueryData<GroupedIssuesResponse>(queryKey);
|
||||
expect(updated?.groups[0]?.issues.map((i) => i.id)).toEqual([
|
||||
"issue-1",
|
||||
"issue-2",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
issueKeys,
|
||||
ISSUE_PAGE_SIZE,
|
||||
type AssigneeGroupedIssuesFilter,
|
||||
type IssueSortParam,
|
||||
type MyIssuesFilter,
|
||||
} from "./queries";
|
||||
import {
|
||||
@@ -58,33 +59,40 @@ export type ToggleIssueReactionVars = {
|
||||
* Paginate one status column into the cache. Works for both the workspace
|
||||
* issue list and per-scope My Issues lists (pass `myIssues` to target the
|
||||
* latter).
|
||||
*
|
||||
* `sort` must match the sort the consuming `useQuery` was called with —
|
||||
* the query key embeds it (see `listSorted` / `myListSorted`), so a load-more
|
||||
* with the wrong sort would patch a stale cache entry that nobody is
|
||||
* subscribed to. It is also threaded into the API request so the appended
|
||||
* page lines up with the server-side ordering of the existing items.
|
||||
*/
|
||||
export function useLoadMoreByStatus(
|
||||
status: IssueStatus,
|
||||
myIssues?: { scope: string; filter: MyIssuesFilter },
|
||||
sort?: IssueSortParam,
|
||||
) {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const prefixKey = myIssues
|
||||
? issueKeys.myList(wsId, myIssues.scope, myIssues.filter)
|
||||
: issueKeys.list(wsId);
|
||||
const queries = qc.getQueriesData<ListIssuesCache>({ queryKey: prefixKey });
|
||||
const [activeKey, cache] = queries[0] ?? [undefined, undefined];
|
||||
const activeKey = myIssues
|
||||
? issueKeys.myListSorted(wsId, myIssues.scope, myIssues.filter, sort)
|
||||
: issueKeys.listSorted(wsId, sort);
|
||||
const cache = qc.getQueryData<ListIssuesCache>(activeKey);
|
||||
const bucket = cache?.byStatus[status];
|
||||
const loaded = bucket?.issues.length ?? 0;
|
||||
const total = bucket?.total ?? 0;
|
||||
const hasMore = loaded < total;
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (isLoading || !hasMore || !activeKey) return;
|
||||
if (isLoading || !hasMore) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await api.listIssues({
|
||||
status,
|
||||
limit: ISSUE_PAGE_SIZE,
|
||||
offset: loaded,
|
||||
...sort,
|
||||
...myIssues?.filter,
|
||||
});
|
||||
qc.setQueryData<ListIssuesCache>(activeKey, (old) => {
|
||||
@@ -100,15 +108,23 @@ export function useLoadMoreByStatus(
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [qc, activeKey, status, loaded, hasMore, isLoading, myIssues?.filter]);
|
||||
}, [qc, activeKey, status, loaded, hasMore, isLoading, myIssues?.filter, sort]);
|
||||
|
||||
return { loadMore, hasMore, isLoading, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginate one assignee-grouped board column into the cache. `queryKey`
|
||||
* already pins the active cache entry (it's the same object the consuming
|
||||
* `useQuery` registered), so the cache lookup and `setQueryData` target the
|
||||
* right row. `sort` is threaded into the API request so the appended page
|
||||
* lines up with the server-side ordering of the existing items.
|
||||
*/
|
||||
export function useLoadMoreByAssigneeGroup(
|
||||
group: Pick<IssueAssigneeGroup, "id" | "assignee_type" | "assignee_id">,
|
||||
queryKey: QueryKey,
|
||||
filter: AssigneeGroupedIssuesFilter,
|
||||
sort?: IssueSortParam,
|
||||
) {
|
||||
const qc = useQueryClient();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -127,6 +143,7 @@ export function useLoadMoreByAssigneeGroup(
|
||||
group_by: "assignee",
|
||||
limit: ISSUE_PAGE_SIZE,
|
||||
offset: loaded,
|
||||
...sort,
|
||||
...filter,
|
||||
group_assignee_type: group.assignee_type ?? "none",
|
||||
group_assignee_id: group.assignee_id ?? undefined,
|
||||
@@ -152,7 +169,7 @@ export function useLoadMoreByAssigneeGroup(
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [filter, group.assignee_id, group.assignee_type, hasMore, isLoading, loaded, qc, queryKey]);
|
||||
}, [filter, group.assignee_id, group.assignee_type, hasMore, isLoading, loaded, qc, queryKey, sort]);
|
||||
|
||||
return { loadMore, hasMore, isLoading, total };
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { QueryKey } from "@tanstack/react-query";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
import type { Issue, IssueAssigneeGroup, IssueAssigneeType, IssueStatus, UpdateIssueRequest } from "@multica/core/types";
|
||||
import { useLoadMoreByAssigneeGroup, useLoadMoreByStatus } from "@multica/core/issues/mutations";
|
||||
import type { AssigneeGroupedIssuesFilter, MyIssuesFilter } from "@multica/core/issues/queries";
|
||||
import type { AssigneeGroupedIssuesFilter, IssueSortParam, MyIssuesFilter } from "@multica/core/issues/queries";
|
||||
import { useViewStore } from "@multica/core/issues/stores/view-store-context";
|
||||
import type { IssueGrouping } from "@multica/core/issues/stores/view-store";
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
@@ -205,6 +205,7 @@ export function BoardView({
|
||||
childProgressMap = EMPTY_PROGRESS_MAP,
|
||||
myIssuesScope,
|
||||
myIssuesFilter,
|
||||
sort,
|
||||
projectId,
|
||||
}: {
|
||||
issues: Issue[];
|
||||
@@ -218,6 +219,8 @@ export function BoardView({
|
||||
/** When set, per-status load-more targets the scoped cache instead of the workspace one. */
|
||||
myIssuesScope?: string;
|
||||
myIssuesFilter?: MyIssuesFilter;
|
||||
/** Must match the sort the page queried with — embedded in the cache key. */
|
||||
sort?: IssueSortParam;
|
||||
/** When set, the per-column "+" pre-fills the project on the create form. */
|
||||
projectId?: string;
|
||||
}) {
|
||||
@@ -493,6 +496,7 @@ export function BoardView({
|
||||
issueMap={issueMapRef.current}
|
||||
childProgressMap={childProgressMap}
|
||||
myIssuesOpts={myIssuesOpts}
|
||||
sort={sort}
|
||||
projectId={projectId}
|
||||
sortLabel={sortLabel}
|
||||
/>
|
||||
@@ -506,6 +510,7 @@ export function BoardView({
|
||||
childProgressMap={childProgressMap}
|
||||
queryKey={assigneeGroupQueryKey}
|
||||
filter={assigneeGroupFilter}
|
||||
sort={sort}
|
||||
projectId={projectId}
|
||||
sortLabel={sortLabel}
|
||||
/>
|
||||
@@ -529,6 +534,7 @@ export function BoardView({
|
||||
<BoardHiddenColumnsPanel
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
myIssuesOpts={myIssuesOpts}
|
||||
sort={sort}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -551,6 +557,7 @@ const PaginatedAssigneeBoardColumn = memo(function PaginatedAssigneeBoardColumn(
|
||||
childProgressMap,
|
||||
queryKey,
|
||||
filter,
|
||||
sort,
|
||||
projectId,
|
||||
sortLabel,
|
||||
}: {
|
||||
@@ -560,6 +567,7 @@ const PaginatedAssigneeBoardColumn = memo(function PaginatedAssigneeBoardColumn(
|
||||
childProgressMap?: Map<string, ChildProgress>;
|
||||
queryKey: QueryKey;
|
||||
filter: AssigneeGroupedIssuesFilter;
|
||||
sort?: IssueSortParam;
|
||||
projectId?: string;
|
||||
sortLabel?: string | null;
|
||||
}) {
|
||||
@@ -571,6 +579,7 @@ const PaginatedAssigneeBoardColumn = memo(function PaginatedAssigneeBoardColumn(
|
||||
},
|
||||
queryKey,
|
||||
filter,
|
||||
sort,
|
||||
);
|
||||
return (
|
||||
<BoardColumn
|
||||
@@ -596,6 +605,7 @@ const PaginatedBoardColumn = memo(function PaginatedBoardColumn({
|
||||
issueMap,
|
||||
childProgressMap,
|
||||
myIssuesOpts,
|
||||
sort,
|
||||
projectId,
|
||||
sortLabel,
|
||||
}: {
|
||||
@@ -604,12 +614,14 @@ const PaginatedBoardColumn = memo(function PaginatedBoardColumn({
|
||||
issueMap: Map<string, Issue>;
|
||||
childProgressMap?: Map<string, ChildProgress>;
|
||||
myIssuesOpts?: { scope: string; filter: MyIssuesFilter };
|
||||
sort?: IssueSortParam;
|
||||
projectId?: string;
|
||||
sortLabel?: string | null;
|
||||
}) {
|
||||
const { loadMore, hasMore, isLoading, total } = useLoadMoreByStatus(
|
||||
group.status,
|
||||
myIssuesOpts,
|
||||
sort,
|
||||
);
|
||||
return (
|
||||
<BoardColumn
|
||||
@@ -639,20 +651,24 @@ const PaginatedBoardColumn = memo(function PaginatedBoardColumn({
|
||||
function BoardHiddenColumnRow({
|
||||
status,
|
||||
myIssuesOpts,
|
||||
sort,
|
||||
}: {
|
||||
status: IssueStatus;
|
||||
myIssuesOpts?: { scope: string; filter: MyIssuesFilter };
|
||||
sort?: IssueSortParam;
|
||||
}) {
|
||||
const { total } = useLoadMoreByStatus(status, myIssuesOpts);
|
||||
const { total } = useLoadMoreByStatus(status, myIssuesOpts, sort);
|
||||
return <HiddenColumnRow status={status} total={total} />;
|
||||
}
|
||||
|
||||
function BoardHiddenColumnsPanel({
|
||||
hiddenStatuses,
|
||||
myIssuesOpts,
|
||||
sort,
|
||||
}: {
|
||||
hiddenStatuses: IssueStatus[];
|
||||
myIssuesOpts?: { scope: string; filter: MyIssuesFilter };
|
||||
sort?: IssueSortParam;
|
||||
}) {
|
||||
return (
|
||||
<HiddenColumnsPanel
|
||||
@@ -662,6 +678,7 @@ function BoardHiddenColumnsPanel({
|
||||
key={status}
|
||||
status={status}
|
||||
myIssuesOpts={myIssuesOpts}
|
||||
sort={sort}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -223,6 +223,7 @@ export function IssuesPage() {
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
onMoveIssue={handleMoveIssue}
|
||||
childProgressMap={childProgressMap}
|
||||
sort={sort}
|
||||
/>
|
||||
) : viewMode === "swimlane" ? (
|
||||
<SwimLaneView
|
||||
@@ -232,9 +233,10 @@ export function IssuesPage() {
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
onMoveIssue={handleMoveIssue}
|
||||
childProgressMap={childProgressMap}
|
||||
sort={sort}
|
||||
/>
|
||||
) : (
|
||||
<ListView issues={issues} visibleStatuses={visibleStatuses} childProgressMap={childProgressMap} />
|
||||
<ListView issues={issues} visibleStatuses={visibleStatuses} childProgressMap={childProgressMap} sort={sort} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import type { Issue, IssueStatus } from "@multica/core/types";
|
||||
import { useLoadMoreByStatus } from "@multica/core/issues/mutations";
|
||||
import type { MyIssuesFilter } from "@multica/core/issues/queries";
|
||||
import type { IssueSortParam, MyIssuesFilter } from "@multica/core/issues/queries";
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
import { useViewStore } from "@multica/core/issues/stores/view-store-context";
|
||||
import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-store";
|
||||
@@ -24,6 +24,7 @@ export function ListView({
|
||||
childProgressMap = EMPTY_PROGRESS_MAP,
|
||||
myIssuesScope,
|
||||
myIssuesFilter,
|
||||
sort,
|
||||
projectId,
|
||||
}: {
|
||||
issues: Issue[];
|
||||
@@ -32,6 +33,8 @@ export function ListView({
|
||||
/** When set, per-status load-more targets the scoped cache instead of the workspace one. */
|
||||
myIssuesScope?: string;
|
||||
myIssuesFilter?: MyIssuesFilter;
|
||||
/** Must match the sort the page queried with — embedded in the cache key. */
|
||||
sort?: IssueSortParam;
|
||||
/** When set, the per-section "+" pre-fills the project on the create form. */
|
||||
projectId?: string;
|
||||
}) {
|
||||
@@ -86,6 +89,7 @@ export function ListView({
|
||||
issues={issuesByStatus.get(status) ?? []}
|
||||
childProgressMap={childProgressMap}
|
||||
myIssuesOpts={myIssuesOpts}
|
||||
sort={sort}
|
||||
projectId={projectId}
|
||||
/>
|
||||
))}
|
||||
@@ -99,12 +103,14 @@ function StatusAccordionItem({
|
||||
issues,
|
||||
childProgressMap,
|
||||
myIssuesOpts,
|
||||
sort,
|
||||
projectId,
|
||||
}: {
|
||||
status: IssueStatus;
|
||||
issues: Issue[];
|
||||
childProgressMap: Map<string, ChildProgress>;
|
||||
myIssuesOpts?: { scope: string; filter: MyIssuesFilter };
|
||||
sort?: IssueSortParam;
|
||||
projectId?: string;
|
||||
}) {
|
||||
const { t } = useT("issues");
|
||||
@@ -114,6 +120,7 @@ function StatusAccordionItem({
|
||||
const { loadMore, hasMore, isLoading, total } = useLoadMoreByStatus(
|
||||
status,
|
||||
myIssuesOpts,
|
||||
sort,
|
||||
);
|
||||
|
||||
const issueIds = issues.map((i) => i.id);
|
||||
|
||||
@@ -80,7 +80,7 @@ vi.mock("@multica/core/issues/config", () => ({
|
||||
// as no-op divs and don't pull IntersectionObserver into JSDOM.
|
||||
const mockLoadMore = vi.fn();
|
||||
const useLoadMoreByStatusMock = vi.fn(
|
||||
(_status: string, _opts?: unknown) => ({
|
||||
(_status: string, _opts?: unknown, _sort?: unknown) => ({
|
||||
total: 0,
|
||||
loaded: 0,
|
||||
hasMore: false,
|
||||
@@ -92,8 +92,8 @@ vi.mock("@multica/core/issues/mutations", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@multica/core/issues/mutations")>();
|
||||
return {
|
||||
...actual,
|
||||
useLoadMoreByStatus: (status: string, opts?: unknown) =>
|
||||
useLoadMoreByStatusMock(status, opts),
|
||||
useLoadMoreByStatus: (status: string, opts?: unknown, sort?: unknown) =>
|
||||
useLoadMoreByStatusMock(status, opts, sort),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import type { UpdateIssueRequest } from "@multica/core/types";
|
||||
import { useViewStore, useViewStoreApi } from "@multica/core/issues/stores/view-store-context";
|
||||
import { useWorkspacePaths } from "@multica/core/paths";
|
||||
import { useLoadMoreByStatus } from "@multica/core/issues/mutations";
|
||||
import type { MyIssuesFilter } from "@multica/core/issues/queries";
|
||||
import type { IssueSortParam, MyIssuesFilter } from "@multica/core/issues/queries";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
@@ -161,6 +161,7 @@ export function SwimLaneView({
|
||||
childProgressMap = EMPTY_PROGRESS_MAP,
|
||||
myIssuesScope,
|
||||
myIssuesFilter,
|
||||
sort,
|
||||
projectId,
|
||||
}: {
|
||||
issues: Issue[];
|
||||
@@ -179,6 +180,8 @@ export function SwimLaneView({
|
||||
childProgressMap?: Map<string, ChildProgress>;
|
||||
myIssuesScope?: string;
|
||||
myIssuesFilter?: MyIssuesFilter;
|
||||
/** Must match the sort the page queried with — embedded in the cache key. */
|
||||
sort?: IssueSortParam;
|
||||
/** Pre-fills `project_id` on the create form for the in-cell "+" button. */
|
||||
projectId?: string;
|
||||
}) {
|
||||
@@ -748,6 +751,7 @@ export function SwimLaneView({
|
||||
sortedStatuses={sortedStatuses}
|
||||
gridStyle={gridStyle}
|
||||
myIssuesOpts={myIssuesOpts}
|
||||
sort={sort}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1031,10 +1035,12 @@ function SwimLaneLoadMoreRow({
|
||||
sortedStatuses,
|
||||
gridStyle,
|
||||
myIssuesOpts,
|
||||
sort,
|
||||
}: {
|
||||
sortedStatuses: IssueStatus[];
|
||||
gridStyle: React.CSSProperties;
|
||||
myIssuesOpts?: { scope: string; filter: MyIssuesFilter };
|
||||
sort?: IssueSortParam;
|
||||
}) {
|
||||
return (
|
||||
<div style={gridStyle}>
|
||||
@@ -1043,6 +1049,7 @@ function SwimLaneLoadMoreRow({
|
||||
key={status}
|
||||
status={status}
|
||||
myIssuesOpts={myIssuesOpts}
|
||||
sort={sort}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1052,11 +1059,13 @@ function SwimLaneLoadMoreRow({
|
||||
function SwimLaneLoadMoreCell({
|
||||
status,
|
||||
myIssuesOpts,
|
||||
sort,
|
||||
}: {
|
||||
status: IssueStatus;
|
||||
myIssuesOpts?: { scope: string; filter: MyIssuesFilter };
|
||||
sort?: IssueSortParam;
|
||||
}) {
|
||||
const { loadMore, hasMore, isLoading } = useLoadMoreByStatus(status, myIssuesOpts);
|
||||
const { loadMore, hasMore, isLoading } = useLoadMoreByStatus(status, myIssuesOpts, sort);
|
||||
if (!hasMore) return <div />;
|
||||
return <InfiniteScrollSentinel onVisible={loadMore} loading={isLoading} />;
|
||||
}
|
||||
|
||||
@@ -273,6 +273,7 @@ export function MyIssuesPage() {
|
||||
childProgressMap={childProgressMap}
|
||||
myIssuesScope={scope}
|
||||
myIssuesFilter={filter}
|
||||
sort={sort}
|
||||
/>
|
||||
) : viewMode === "swimlane" ? (
|
||||
<SwimLaneView
|
||||
@@ -284,6 +285,7 @@ export function MyIssuesPage() {
|
||||
childProgressMap={childProgressMap}
|
||||
myIssuesScope={scope}
|
||||
myIssuesFilter={filter}
|
||||
sort={sort}
|
||||
/>
|
||||
) : (
|
||||
<ListView
|
||||
@@ -292,6 +294,7 @@ export function MyIssuesPage() {
|
||||
childProgressMap={childProgressMap}
|
||||
myIssuesScope={scope}
|
||||
myIssuesFilter={filter}
|
||||
sort={sort}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
projectGanttIssuesOptions,
|
||||
childIssueProgressOptions,
|
||||
type AssigneeGroupedIssuesFilter,
|
||||
type IssueSortParam,
|
||||
type MyIssuesFilter,
|
||||
} from "@multica/core/issues/queries";
|
||||
import { useUpdateIssue } from "@multica/core/issues/mutations";
|
||||
@@ -116,6 +117,7 @@ function ProjectIssuesContent({
|
||||
assigneeGroupFilter,
|
||||
scope,
|
||||
filter,
|
||||
sort,
|
||||
ganttIssues,
|
||||
}: {
|
||||
projectId: string;
|
||||
@@ -125,6 +127,7 @@ function ProjectIssuesContent({
|
||||
assigneeGroupFilter?: AssigneeGroupedIssuesFilter;
|
||||
scope: string;
|
||||
filter: MyIssuesFilter;
|
||||
sort?: IssueSortParam;
|
||||
ganttIssues: Issue[];
|
||||
}) {
|
||||
const { t } = useT("projects");
|
||||
@@ -228,6 +231,7 @@ function ProjectIssuesContent({
|
||||
childProgressMap={childProgressMap}
|
||||
myIssuesScope={scope}
|
||||
myIssuesFilter={filter}
|
||||
sort={sort}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
@@ -238,6 +242,7 @@ function ProjectIssuesContent({
|
||||
childProgressMap={childProgressMap}
|
||||
myIssuesScope={scope}
|
||||
myIssuesFilter={filter}
|
||||
sort={sort}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
@@ -252,6 +257,7 @@ function ProjectIssuesContent({
|
||||
childProgressMap={childProgressMap}
|
||||
myIssuesScope={scope}
|
||||
myIssuesFilter={filter}
|
||||
sort={sort}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
@@ -351,6 +357,7 @@ function ProjectIssuesSurface({
|
||||
assigneeGroupFilter={usesAssigneeBoard ? assigneeGroupFilter : undefined}
|
||||
scope={scope}
|
||||
filter={filter}
|
||||
sort={sort}
|
||||
ganttIssues={ganttIssues}
|
||||
/>
|
||||
<BatchActionToolbar />
|
||||
|
||||
Reference in New Issue
Block a user