Files
multica/packages/views/issues/components/create-renderers.test.tsx
Naiyuan Qing ade6b34e5f MUL-3903: Extract shared issue surfaces (#4774)
* MUL-3903 refactor project issue surface state

Co-authored-by: multica-agent <github@multica.ai>

* Refactor project issue surface ownership

Co-authored-by: multica-agent <github@multica.ai>

* Extract shared issue surface entrypoints

Co-authored-by: multica-agent <github@multica.ai>

* Fix issue surface create defaults and selection reset

Co-authored-by: multica-agent <github@multica.ai>

* test(editor): add missing AbortSignal to suggestion items() calls

The suggestion items() contract gained a required signal param; the
mention/slash test call sites were never updated, breaking pnpm typecheck
for @multica/views.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(issues): server-side assignee_types filter on ListIssues

ListGroupedIssues has taken assignee_types since squads shipped, but
ListIssues never did — so the workspace Members/Agents tabs had to fetch
the unfiltered workspace list and post-filter loaded pages client-side,
which made column totals and load-more pagination reflect the unfiltered
counts.

Add the same parse + WHERE clause to ListIssues (count query shares the
WHERE, so totals agree), thread the param through the TS client, and
widen MyIssuesFilter so scoped list caches can carry it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(issues): route issue cache writes through a membership-aware coordinator

useUpdateIssue, useBatchUpdateIssues, and the WS issue:updated handler
each maintained their own similar-but-diverging patch/invalidate rules.
Consolidate them into cache-coordinator.ts (applyIssueChange /
rollbackIssueChange / invalidateIssueDerivatives) so local writes and
remote echoes follow one rules table by construction.

The coordinator is membership-aware via surface/membership.ts
(true | false | unknown against each list cache's own filter contract):

- a change that moves an issue off a filtered surface removes the card
  surgically (bucket total decremented) — fixes assignee changes leaving
  stale cards on My Assigned with no local safety net (previously only
  the WS echo recovered it), and replaces the blanket invalidate-myAll
  net for project moves (MUL-3669) with per-key precision
- possible entry into a loaded list marks that key stale — never
  hard-insert; page/slot is server knowledge
- stale keys flush on settle for mutations (a mid-flight refetch would
  stomp the optimistic state) and immediately for WS
- batch updates now patch detail + inbox like single updates; the
  off-screen bucket-count recovery previously exclusive to the WS path
  now covers local mutations too

Preserved invariants: synchronous optimistic patches (dnd-kit), MUL-3375
control-field stripping, and no refetch of surgically reconciled lists
(the drag-flicker fix).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(issues): resolve surfaces via core query plan/repository with window-keyed remount

Read-path convergence and the loading/empty semantics that fall out of
it:

- scope -> API params moves from scope.ts helpers into
  surface/query-plan.ts; workspace members/agents become server-filtered
  scoped plans (assignee_types) and the client postFilter machinery is
  deleted — tab counts and load-more are now exact
- query selection moves behind surface/repository.ts; the views data
  hook no longer branches on workspace-vs-scoped plumbing
- IssueSurfaceContent remounts on data-window change (wsId + scope):
  keepPreviousData placeholders keep sort/filter changes flicker-free
  within one window but must never let project A's (or workspace A's)
  cards impersonate B's with no loading state — cold window shows the
  skeleton, warm window hits cache instantly
- isEmpty is only asserted from full-window data; the gantt
  scheduled-only projection can't prove the window is empty, so GanttView's
  own "no scheduled issues" empty state renders instead of the generic
  create-issue one
- per-card project lookups hoist into a surface-level projectMap (drops
  a per-card useQuery), create-defaults typing tightens to
  IssueCreateDefaults

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(issues): count-only arithmetic for off-window status/membership changes

An issue beyond a list's loaded page window used to force a full
first-page refetch just to fix two column counts. When the change is
CERTAIN (base entity known, membership definitive) the coordinator now
does the arithmetic locally:

- stayed a member + status changed: move one unit of total between the
  two buckets (loaded arrays untouched; hasMore stays consistent)
- left the list (reassigned / re-projected): old status bucket total -1
- member-to-member reassignment: counts unaffected, not even a stale key

Entering a list and any uncertainty (no base, unknown membership) still
refetch — the right page/slot is server knowledge. Branches on membership
OUTCOMES, not on which field changed, so future dimensions (team) join
automatically. Biggest win is the WS path: agents flipping off-screen
statuses no longer trigger refetch storms.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(issues): deferred view-refresh indicator during placeholder revalidation

Sort/date changes (and any grouped-board filter change) revalidate behind
the previous snapshot — correct, but on a slow network the click felt
dead: content stays put and isLoading never fires. Surface the state as
isRefreshing (isPlaceholderData of the active query) and render a shared
ViewRefreshIndicator in every issues header: a fixed-width slot (zero
layout shift) whose spinner fades in after 300ms, so sub-second responses
show nothing (NN/g) while slow ones get a working signal.

Bound to the revalidation STATE, not to any particular control — any
current or future server-side view change lights it automatically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 14:11:10 +08:00

152 lines
4.1 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { BoardColumn } from "./board-column";
import { ListView } from "./list-view";
const openModal = vi.hoisted(() => vi.fn());
const hideStatus = vi.hoisted(() => vi.fn());
const showStatus = vi.hoisted(() => vi.fn());
const select = vi.hoisted(() => vi.fn());
const deselect = vi.hoisted(() => vi.fn());
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
vi.mock("@multica/core/modals", () => ({
useModalStore: {
getState: () => ({ open: openModal }),
},
}));
vi.mock("@multica/core/issues/stores/view-store-context", () => ({
useViewStore: (selector?: any) => {
const state = {
grouping: "status",
sortBy: "position",
listCollapsedStatuses: [],
toggleListCollapsed: vi.fn(),
};
return selector ? selector(state) : state;
},
useViewStoreApi: () => ({
getState: () => ({ hideStatus, showStatus }),
}),
}));
vi.mock("@multica/core/issues/mutations", () => ({
useLoadMoreByStatus: () => ({
total: 0,
loaded: 0,
hasMore: false,
isLoading: false,
loadMore: vi.fn(),
}),
}));
vi.mock("@multica/core/workspace/hooks", () => ({
useActorName: () => ({
getActorName: (_type: string, id: string) => id,
}),
}));
vi.mock("../surface/selection-context", () => ({
useIssueSurfaceSelection: () => ({
selectedIds: new Set<string>(),
select,
deselect,
toggle: vi.fn(),
clear: vi.fn(),
}),
}));
vi.mock("../../i18n", () => ({
useT: () => ({ t: () => "translated" }),
}));
vi.mock("@dnd-kit/core", () => ({
DndContext: ({ children }: { children: React.ReactNode }) => children,
DragOverlay: () => null,
PointerSensor: class {},
useSensor: () => ({}),
useSensors: () => [],
useDroppable: () => ({ setNodeRef: vi.fn(), isOver: false }),
}));
vi.mock("@dnd-kit/sortable", () => ({
SortableContext: ({ children }: { children: React.ReactNode }) => children,
verticalListSortingStrategy: {},
arrayMove: <T,>(items: T[]) => items,
}));
vi.mock("@base-ui/react/accordion", () => ({
Accordion: {
Root: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Item: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Header: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
Trigger: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<button type="button" className={className}>
{children}
</button>
),
Panel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
},
}));
beforeEach(() => {
openModal.mockClear();
hideStatus.mockClear();
showStatus.mockClear();
select.mockClear();
deselect.mockClear();
});
describe("issue renderer create entrypoints", () => {
it("routes board column create through the surface callback with local defaults", () => {
const onCreateIssue = vi.fn();
render(
<BoardColumn
group={{ id: "todo", title: "todo", status: "todo", createData: { status: "todo" } }}
issueIds={[]}
issueMap={new Map()}
projectId="project-1"
onCreateIssue={onCreateIssue}
/>,
);
const buttons = screen.getAllByRole("button");
fireEvent.click(buttons[buttons.length - 1]!);
expect(onCreateIssue).toHaveBeenCalledWith({
status: "todo",
project_id: "project-1",
});
expect(openModal).not.toHaveBeenCalled();
});
it("routes list status create through the surface callback with local defaults", () => {
const onCreateIssue = vi.fn();
render(
<ListView
issues={[]}
visibleStatuses={["todo"]}
projectId="project-1"
onCreateIssue={onCreateIssue}
/>,
);
const buttons = screen.getAllByRole("button");
fireEvent.click(buttons[buttons.length - 1]!);
expect(onCreateIssue).toHaveBeenCalledWith({
status: "todo",
project_id: "project-1",
});
expect(openModal).not.toHaveBeenCalled();
});
});