mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
* 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>
177 lines
4.8 KiB
TypeScript
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();
|
|
});
|
|
});
|