Files
multica/packages/core/properties/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

177 lines
4.8 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 type { Issue, IssuePropertiesResponse } from "../types";
import { issueKeys } from "../issues/queries";
import { useSetIssueProperty } from "./mutations";
vi.mock("../hooks", () => ({ useWorkspaceId: () => "ws-1" }));
const issue: Issue = {
id: "issue-1",
workspace_id: "ws-1",
number: 1,
identifier: "MUL-1",
title: "Estimate",
description: null,
status: "todo",
priority: "none",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "member-1",
parent_issue_id: null,
project_id: null,
position: 1,
stage: null,
start_date: null,
due_date: null,
labels: [],
metadata: {},
properties: { estimate: 1 },
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 };
}
describe("useSetIssueProperty", () => {
afterEach(() => vi.restoreAllMocks());
it("keeps an optimistic children-cache patch when an older fetch resolves late", async () => {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const parentId = "parent-1";
const childKey = issueKeys.children("ws-1", parentId);
const child = {
...issue,
parent_issue_id: parentId,
properties: { estimate: 1, environment: "staging" },
};
qc.setQueryData<Issue[]>(childKey, [child]);
const staleChildren = deferred<Issue[]>();
const staleFetch = qc
.fetchQuery({
queryKey: childKey,
queryFn: () => staleChildren.promise,
})
.catch(() => undefined);
await waitFor(() =>
expect(qc.getQueryState(childKey)?.fetchStatus).toBe("fetching"),
);
const write = deferred<IssuePropertiesResponse>();
setApiInstance({
setIssueProperty: vi.fn(() => write.promise),
} as unknown as ApiClient);
const { result } = renderHook(() => useSetIssueProperty(), {
wrapper: wrapper(qc),
});
act(() => {
result.current.mutate({
issueId: issue.id,
propertyId: "estimate",
value: 2,
});
});
await waitFor(() =>
expect(qc.getQueryData<Issue[]>(childKey)?.[0]?.properties).toEqual({
estimate: 2,
environment: "staging",
}),
);
// Resolve the response captured before the mutation. cancelQueries must
// keep it from restoring estimate=1 while the write is still pending.
staleChildren.resolve([child]);
await staleFetch;
expect(qc.getQueryData<Issue[]>(childKey)?.[0]?.properties).toEqual({
estimate: 2,
environment: "staging",
});
await act(async () => {
write.resolve({
properties: { estimate: 2, environment: "staging" },
});
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
qc.clear();
});
it("does not refetch a property window before the mutation commits", async () => {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const flatKey = issueKeys.flat(
"ws-1",
"workspace:all",
{},
{ sort_by: "property:estimate", properties: { estimate: ["2"] } },
);
qc.setQueryData(flatKey, {
pages: [{ issues: [issue], total: 1 }],
pageParams: [0],
});
let resolveWrite!: (value: IssuePropertiesResponse) => void;
const setIssueProperty = vi.fn(
() =>
new Promise<IssuePropertiesResponse>((resolve) => {
resolveWrite = resolve;
}),
);
setApiInstance({ setIssueProperty } as unknown as ApiClient);
const { result } = renderHook(() => useSetIssueProperty(), {
wrapper: wrapper(qc),
});
act(() => {
result.current.mutate({
issueId: issue.id,
propertyId: "estimate",
value: 2,
});
});
await waitFor(() =>
expect(
qc.getQueryData<{ pages: { issues: Issue[] }[] }>(flatKey)?.pages[0]
?.issues[0]?.properties.estimate,
).toBe(2),
);
expect(qc.getQueryState(flatKey)?.isInvalidated).toBe(false);
await act(async () => {
resolveWrite({ properties: { estimate: 2 } });
});
await waitFor(() =>
expect(qc.getQueryState(flatKey)?.isInvalidated).toBe(true),
);
qc.clear();
});
});