Files
multica/packages/core/labels/mutations.test.tsx
Jiayuan Zhang 4d74db89cb feat(issues): richer sub-issue rows in issue detail (MUL-5098) (#5721)
* feat(issues): richer sub-issue rows in issue detail (MUL-5098)

The sub-issues panel showed only status, identifier, title and assignee —
priority, due dates, labels, live agent activity and nested breakdowns
were invisible without opening each child.

- SubIssueRow now shows priority (checkbox-slot swap like list rows),
  the agent-activity indicator, label chips (+n overflow), the child's
  own done/total progress ring, and an inline-editable due date with
  overdue emphasis (muted when the child is done/cancelled)
- Right-click opens the shared issue actions menu via a section-level
  IssueContextMenuProvider — parity with list/board surfaces
- ListChildIssues + ListChildrenByParents now bulk-load labels
  (same labelsByIssue pattern as the other list endpoints)
- patchIssueLabels patches per-parent children caches;
  invalidateIssueLabelDerivatives refetches the Map-shaped batched
  children caches so label changes stay live everywhere

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

* feat(issues): customizable property display for sub-issue rows (MUL-5098)

The enriched sub-issue rows were a fixed field set — no way to trim
them or surface workspace custom properties.

- New user-level persisted preference (useSubIssueDisplayStore):
  built-in field toggles (priority / labels / sub-issue progress /
  due date / assignee) + opted-in custom property ids. Defaults match
  the previous fixed layout, so existing users see no change.
- SubIssueDisplayPopover on the section header — same switch-row
  interaction as the main views' Display panel, reusing its card_*
  locale keys (no new translations needed).
- Rows render opted-in custom property chips (PropertyIcon +
  CustomPropertyValueDisplay, list-row parity) only when the child
  carries a value; ids resolve against live non-archived definitions,
  so foreign-workspace or archived ids are inert.

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

* fix(issues): reconcile sub-issue cache updates

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

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-22 22:43:34 +08:00

183 lines
5.2 KiB
TypeScript

/**
* @vitest-environment jsdom
*/
import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { setApiInstance } from "../api";
import type { ApiClient } from "../api/client";
import { issueKeys } from "../issues/queries";
import type {
Issue,
IssueLabelsResponse,
Label,
ListLabelsResponse,
} from "../types";
import { useAttachLabel, useDetachLabel } from "./mutations";
import { labelKeys } from "./queries";
vi.mock("../hooks", () => ({ useWorkspaceId: () => "ws-1" }));
const labelA: Label = {
id: "label-a",
workspace_id: "ws-1",
name: "bug",
color: "#ef4444",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
const labelB: Label = {
id: "label-b",
workspace_id: "ws-1",
name: "feature",
color: "#22c55e",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
const issue: Issue = {
id: "issue-1",
workspace_id: "ws-1",
number: 1,
identifier: "MUL-1",
title: "Child",
description: null,
status: "todo",
priority: "none",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "member-1",
parent_issue_id: "parent-1",
project_id: null,
position: 1,
stage: null,
start_date: null,
due_date: null,
labels: [labelA],
metadata: {},
properties: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
function wrapper(qc: QueryClient) {
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
};
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
function startStaleChildrenFetch(qc: QueryClient, labels: Label[]) {
const childKey = issueKeys.children("ws-1", "parent-1");
const response = deferred<Issue[]>();
const fetch = qc
.fetchQuery({
queryKey: childKey,
queryFn: () => response.promise,
})
.catch(() => undefined);
return { childKey, response, fetch, staleIssue: { ...issue, labels } };
}
describe("issue label mutations", () => {
afterEach(() => vi.restoreAllMocks());
it("prevents an older children fetch from overwriting an optimistic attach", async () => {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const childKey = issueKeys.children("ws-1", "parent-1");
qc.setQueryData<Issue[]>(childKey, [issue]);
qc.setQueryData<IssueLabelsResponse>(labelKeys.byIssue("ws-1", issue.id), {
labels: [labelA],
});
qc.setQueryData<ListLabelsResponse>(labelKeys.list("ws-1"), {
labels: [labelA, labelB],
total: 2,
});
const stale = startStaleChildrenFetch(qc, [labelA]);
await waitFor(() =>
expect(qc.getQueryState(childKey)?.fetchStatus).toBe("fetching"),
);
const write = deferred<IssueLabelsResponse>();
setApiInstance({
attachLabel: vi.fn(() => write.promise),
} as unknown as ApiClient);
const { result } = renderHook(() => useAttachLabel(issue.id), {
wrapper: wrapper(qc),
});
act(() => result.current.mutate(labelB.id));
await waitFor(() =>
expect(qc.getQueryData<Issue[]>(childKey)?.[0]?.labels).toEqual([
labelA,
labelB,
]),
);
stale.response.resolve([stale.staleIssue]);
await stale.fetch;
expect(qc.getQueryData<Issue[]>(childKey)?.[0]?.labels).toEqual([
labelA,
labelB,
]);
await act(async () => {
write.resolve({ labels: [labelA, labelB] });
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
qc.clear();
});
it("prevents an older children fetch from overwriting an optimistic detach", async () => {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const childKey = issueKeys.children("ws-1", "parent-1");
const labeledIssue = { ...issue, labels: [labelA, labelB] };
qc.setQueryData<Issue[]>(childKey, [labeledIssue]);
qc.setQueryData<IssueLabelsResponse>(labelKeys.byIssue("ws-1", issue.id), {
labels: [labelA, labelB],
});
const stale = startStaleChildrenFetch(qc, [labelA, labelB]);
await waitFor(() =>
expect(qc.getQueryState(childKey)?.fetchStatus).toBe("fetching"),
);
const write = deferred<IssueLabelsResponse>();
setApiInstance({
detachLabel: vi.fn(() => write.promise),
} as unknown as ApiClient);
const { result } = renderHook(() => useDetachLabel(issue.id), {
wrapper: wrapper(qc),
});
act(() => result.current.mutate(labelB.id));
await waitFor(() =>
expect(qc.getQueryData<Issue[]>(childKey)?.[0]?.labels).toEqual([labelA]),
);
stale.response.resolve([stale.staleIssue]);
await stale.fetch;
expect(qc.getQueryData<Issue[]>(childKey)?.[0]?.labels).toEqual([labelA]);
await act(async () => {
write.resolve({ labels: [labelA] });
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
qc.clear();
});
});