mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 20:45:37 +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>
208 lines
8.2 KiB
TypeScript
208 lines
8.2 KiB
TypeScript
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { api } from "../api";
|
|
import { propertyKeys } from "./queries";
|
|
import { useWorkspaceId } from "../hooks";
|
|
import { issueKeys } from "../issues/queries";
|
|
import {
|
|
invalidatePropertyWindowQueries,
|
|
onIssuePropertiesChanged,
|
|
patchIssueProperties,
|
|
} from "../issues/ws-updaters";
|
|
import { findIssueLocation } from "../issues/cache-helpers";
|
|
import type { IssueFlatCache } from "../issues/cache-coordinator";
|
|
import type {
|
|
CreatePropertyRequest,
|
|
UpdatePropertyRequest,
|
|
Issue,
|
|
IssuePropertyValue,
|
|
IssuePropertyValues,
|
|
ListIssuesCache,
|
|
} from "../types";
|
|
|
|
export function useCreateProperty() {
|
|
const qc = useQueryClient();
|
|
const wsId = useWorkspaceId();
|
|
return useMutation({
|
|
mutationFn: (data: CreatePropertyRequest) => api.createProperty(data),
|
|
onSettled: () => {
|
|
qc.invalidateQueries({ queryKey: propertyKeys.all(wsId) });
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Definition updates (rename, options, archive). No optimistic patch: edits
|
|
* happen in the settings dialog where a round-trip is acceptable, and config
|
|
* canonicalization (option id assignment) is server-side anyway.
|
|
*/
|
|
export function useUpdateProperty() {
|
|
const qc = useQueryClient();
|
|
const wsId = useWorkspaceId();
|
|
return useMutation({
|
|
mutationFn: ({ id, ...data }: { id: string } & UpdatePropertyRequest) =>
|
|
api.updateProperty(id, data),
|
|
onSettled: () => {
|
|
qc.invalidateQueries({ queryKey: propertyKeys.all(wsId) });
|
|
// Issue caches embed the value bag; a definition change (rename,
|
|
// option edits) changes how values render, and rows referencing an
|
|
// archived definition must drop out of "+ Add property" menus.
|
|
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Read the issue's current property bag from whichever cache holds it: the
|
|
* detail cache first, then any list cache bucket. Board/list surfaces never
|
|
* populate the detail cache, so stopping there would make the optimistic
|
|
* merge below overwrite the whole bag with a single key.
|
|
*/
|
|
function readIssueProperties(qc: ReturnType<typeof useQueryClient>, wsId: string, issueId: string): IssuePropertyValues | undefined {
|
|
const detail = qc.getQueryData<Issue>(issueKeys.detail(wsId, issueId));
|
|
if (detail) return detail.properties ?? {};
|
|
for (const [, data] of qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) })) {
|
|
if (!data) continue;
|
|
const location = findIssueLocation(data, issueId);
|
|
if (location) return location.issue.properties ?? {};
|
|
}
|
|
for (const [, data] of qc.getQueriesData<IssueFlatCache>({
|
|
queryKey: issueKeys.flatAll(wsId),
|
|
})) {
|
|
for (const page of data?.pages ?? []) {
|
|
const issue = page.issues.find((candidate) => candidate.id === issueId);
|
|
if (issue) return issue.properties ?? {};
|
|
}
|
|
}
|
|
for (const [, data] of qc.getQueriesData<Issue[]>({
|
|
queryKey: issueKeys.childrenAll(wsId),
|
|
})) {
|
|
const issue = data?.find((candidate) => candidate.id === issueId);
|
|
if (issue) return issue.properties ?? {};
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
async function cancelIssuePropertyMutationQueries(
|
|
qc: ReturnType<typeof useQueryClient>,
|
|
wsId: string,
|
|
issueId: string,
|
|
) {
|
|
await Promise.all([
|
|
qc.cancelQueries({ queryKey: issueKeys.detail(wsId, issueId) }),
|
|
qc.cancelQueries({ queryKey: issueKeys.list(wsId) }),
|
|
qc.cancelQueries({ queryKey: issueKeys.flatAll(wsId) }),
|
|
qc.cancelQueries({ queryKey: issueKeys.childrenAll(wsId) }),
|
|
qc.cancelQueries({ queryKey: issueKeys.childrenByParentsAll(wsId) }),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Optimistic single-property write on an issue.
|
|
*
|
|
* Concurrency contract (MUL-4463 review round 1/2):
|
|
* - Mutations for the SAME issue serialize via TanStack's mutation
|
|
* `scope`, so full-bag responses cannot land out of order and rapid
|
|
* multi-select toggles cannot interleave.
|
|
* - The optimistic patch merges into the bag read from ANY cache
|
|
* (detail or list) — never replaces it — so board-only surfaces keep
|
|
* the issue's other property values.
|
|
* - Failure restores the snapshot; if no snapshot existed, it falls back
|
|
* to invalidation.
|
|
* - The LAST settled mutation for the issue does an authoritative
|
|
* invalidate of the detail cache plus the definition catalog (usage
|
|
* counts), reconciling any raced WS snapshots under staleTime:Infinity.
|
|
*/
|
|
export function useSetIssueProperty() {
|
|
const qc = useQueryClient();
|
|
const wsId = useWorkspaceId();
|
|
return useMutation({
|
|
mutationFn: ({ issueId, propertyId, value }: { issueId: string; propertyId: string; value: IssuePropertyValue }) =>
|
|
api.setIssueProperty(issueId, propertyId, value),
|
|
scope: { id: `issue-properties:${wsId}` },
|
|
mutationKey: ["issue-properties", wsId],
|
|
onMutate: async ({ issueId, propertyId, value }) => {
|
|
// A response snapshotted before this write must not land after the
|
|
// optimistic patch and revert any denormalized issue projection.
|
|
await cancelIssuePropertyMutationQueries(qc, wsId, issueId);
|
|
const prev = readIssueProperties(qc, wsId, issueId);
|
|
patchIssueProperties(qc, wsId, issueId, { ...(prev ?? {}), [propertyId]: value });
|
|
return { prevValue: prev?.[propertyId], hadBag: prev !== undefined, issueId, propertyId };
|
|
},
|
|
onError: (_err, _vars, ctx) => {
|
|
if (!ctx) return;
|
|
rollbackSingleKey(qc, wsId, ctx);
|
|
},
|
|
onSuccess: (data, { issueId }) => {
|
|
onIssuePropertiesChanged(qc, wsId, issueId, data.properties ?? {});
|
|
},
|
|
onSettled: (_data, _err, { issueId }) => {
|
|
settleIssuePropertyCaches(qc, wsId, issueId);
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useUnsetIssueProperty() {
|
|
const qc = useQueryClient();
|
|
const wsId = useWorkspaceId();
|
|
return useMutation({
|
|
mutationFn: ({ issueId, propertyId }: { issueId: string; propertyId: string }) =>
|
|
api.unsetIssueProperty(issueId, propertyId),
|
|
scope: { id: `issue-properties:${wsId}` },
|
|
mutationKey: ["issue-properties", wsId],
|
|
onMutate: async ({ issueId, propertyId }) => {
|
|
await cancelIssuePropertyMutationQueries(qc, wsId, issueId);
|
|
const prev = readIssueProperties(qc, wsId, issueId);
|
|
if (prev) {
|
|
const next = { ...prev };
|
|
delete next[propertyId];
|
|
patchIssueProperties(qc, wsId, issueId, next);
|
|
}
|
|
return { prevValue: prev?.[propertyId], hadBag: prev !== undefined, issueId, propertyId };
|
|
},
|
|
onError: (_err, _vars, ctx) => {
|
|
if (!ctx) return;
|
|
rollbackSingleKey(qc, wsId, ctx);
|
|
},
|
|
onSuccess: (data, { issueId }) => {
|
|
onIssuePropertiesChanged(qc, wsId, issueId, data.properties ?? {});
|
|
},
|
|
onSettled: (_data, _err, { issueId }) => {
|
|
settleIssuePropertyCaches(qc, wsId, issueId);
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Roll back exactly the key this mutation touched, against the CURRENT bag.
|
|
* Restoring the whole onMutate snapshot would erase concurrent remote writes
|
|
* to other keys that landed via WS while this request was in flight
|
|
* (clean-room review F2 interleave B).
|
|
*/
|
|
function rollbackSingleKey(
|
|
qc: ReturnType<typeof useQueryClient>,
|
|
wsId: string,
|
|
ctx: { prevValue: IssuePropertyValue | undefined; hadBag: boolean; issueId: string; propertyId: string },
|
|
) {
|
|
if (!ctx.hadBag) {
|
|
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, ctx.issueId) });
|
|
return;
|
|
}
|
|
const current = readIssueProperties(qc, wsId, ctx.issueId) ?? {};
|
|
const next = { ...current };
|
|
if (ctx.prevValue === undefined) delete next[ctx.propertyId];
|
|
else next[ctx.propertyId] = ctx.prevValue;
|
|
patchIssueProperties(qc, wsId, ctx.issueId, next);
|
|
}
|
|
|
|
/** Authoritative reconcile once the LAST in-flight property write settles. */
|
|
function settleIssuePropertyCaches(qc: ReturnType<typeof useQueryClient>, wsId: string, issueId: string) {
|
|
if (qc.isMutating({ mutationKey: ["issue-properties", wsId] }) > 1) return;
|
|
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, issueId) });
|
|
qc.invalidateQueries({ queryKey: propertyKeys.all(wsId) });
|
|
// Server-filtered/-sorted windows must refetch: the write may have moved
|
|
// this issue across pages or reordered it (onIssuePropertiesChanged
|
|
// already fires this for the WS path; local mutations need it on settle
|
|
// because the self-emitted WS event may be suppressed or raced).
|
|
invalidatePropertyWindowQueries(qc, wsId);
|
|
}
|