mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-31 00:40:46 +02:00
* feat(server): custom issue properties — definitions, typed values, CLI (MUL-4463)
Workspace-level property definitions (issue_property table; 7 types:
text/number/select/multi_select/date/checkbox/url) plus a typed value bag
on each issue (issue.properties JSONB keyed by definition UUID, mirroring
the metadata machinery: single-key atomic writes, 16KB cap, GIN index).
- Definitions: owner/admin only; agent actors rejected (agents propose,
humans confirm). 20 active per workspace, 50 options per select,
reserved built-in names blocked, archive instead of delete.
- Values: any member or agent; per-type validation with self-correcting
error messages that enumerate legal option ids.
- API: /api/properties CRUD + PUT/DELETE /api/issues/{id}/properties/{propertyId};
issue responses always emit the properties bag.
- CLI: multica property list/get/create/update/archive/unarchive and
multica issue property list/set/unset with name→id translation.
- Events: property:created/updated, issue_properties:changed.
Co-authored-by: multica-agent <github@multica.ai>
* feat(web): custom properties settings tab + issue sidebar editors (MUL-4463)
- Settings → Properties: definition management mirroring the Labels tab
(list with type badges/option chips/usage counts, create/edit dialog
with option editor, archive/restore, 20-cap indicator). Admin-gated;
members see a read-only catalog.
- Issue detail sidebar: custom properties join the built-in optional
props' progressive disclosure — set values render as rows with
type-appropriate editors (select/multi-select pickers, calendar,
yes/no, inline input for text/number/url), unset ones live in the
same '+ Add property' menu behind a separator. Archived definitions
render read-only until cleared.
- Core: property types, zod schemas (lenient type strings for forward
compat), api client methods, React Query hooks with optimistic
single-key value writes, ws-updaters + realtime wiring for
property:created/updated and issue_properties:changed.
- Locales: en/zh-Hans/ja/ko strings; Issue fixtures gain properties: {}.
Co-authored-by: multica-agent <github@multica.ai>
* fix(properties): address MUL-4463 review round 1 — mobile CI, option guard, mutation safety, schema tolerance
- mobile: EMPTY_ISSUE_FALLBACK gains the required properties field (mobile
typecheck was the red CI check).
- server: PATCH /api/properties/{id} rejects config updates that remove
select options still referenced by issues (409 with a per-option usage
census via jsonb ?); renames keep ids and pass. Integration test included.
- core: property value mutations are serialized per workspace via mutation
scope, snapshot the bag from detail OR list caches (board surfaces have no
detail cache — the old path overwrote whole bags with one key), roll back
to the snapshot or invalidate on error, and the last settled mutation does
an authoritative detail+catalog invalidate (usage counts reconcile).
- schemas: unknown-shaped property values (future server types) are dropped
per-entry in a preprocess step instead of failing the whole IssueSchema
and blanking lists through parseWithFallback; test updated to lock the
tolerant behavior.
- realtime: reconnect invalidation covers the property catalog; every
issue_properties:changed event also refreshes catalog usage counts.
- ui: number editor accepts decimals (step=any); settings usage count
pluralizes (issue/issues) with CJK-safe plural keys.
Co-authored-by: multica-agent <github@multica.ai>
* fix(migrations): renumber issue properties to 179 and build the GIN index concurrently
main's migration sequence advanced twice under this PR (167 collision, then
an upstream renumber wave that claimed 178), so issue properties now sits at
179 — verified against main's current tip by the prefix-uniqueness lint.
The properties GIN index moves to its own single-statement migration (180)
using CREATE INDEX CONCURRENTLY — a plain CREATE INDEX on the hot issue
table would block writes for the duration of the build. Mirrors the
119_user_created_at_index pattern; full-chain dry-run on a fresh database
passes through 180.
Co-authored-by: multica-agent <github@multica.ai>
* feat(web): custom-property list surfaces — filter, cards, sort, board grouping (MUL-4463 M2)
Brings custom properties to the issue list surfaces on top of the M1
definitions/values core:
- Filter: per-definition sections in the Filter dropdown (select /
multi_select options with color dots and counts; checkbox as Yes/No
pseudo-options). OR within a definition, AND across definitions;
client-side in applyIssueFilters, mirrored into filterAssigneeGroups
for the assignee-grouped board. Included in active-filter count and
Clear all.
- Cards: per-property Display toggles (cardPropertyIds) render value
chips on board cards and list rows via CustomPropertyValueDisplay.
- Sort: SortField gains property:<id> for number/date definitions.
Server keeps position order (fixed sort enum); the surface controller
re-sorts client-side, swimlane/gantt reuse the same comparator.
Date-only strings compare lexically; missing values sort last.
- Board grouping: IssueGrouping gains property:<id> for select
definitions — one column per option (definition order) plus a
trailing No-value column, option-colored headings. Drag-drop moves
position via UpdateIssue and applies the value through
useSetIssueProperty/useUnsetIssueProperty (properties are not part
of UpdateIssueRequest). Stale persisted property groupings fall back
to status columns.
View-store: propertyFilters + cardPropertyIds persisted via the
partialize allowlist; clearFilters resets property filters; new fields
deep-merge cleanly into pre-existing persisted snapshots.
Co-authored-by: multica-agent <github@multica.ai>
* fix(properties): address MUL-4463 review round 2 — desc sort, option bucketing, archived-state reconciliation
- sort: direction now applies to value comparison only; issues without a
value sort last in BOTH directions (the whole-array reverse flipped them
to the front on desc). Test covers the desc+missing case.
- board: values referencing an option removed from the definition bucket
into the No-value column instead of vanishing (unmatched column ids
dropped the issue entirely). Defense-in-depth behind the new server-side
in-use guard; drag-utils test locks both behaviors.
- controller: persisted propertyFilters keyed by archived/deleted
definitions are stripped before reaching the filter predicates, and a
persisted property sort on a non-active definition degrades to manual
order — previously both kept silently applying while the header claimed
otherwise. The filter badge counts only active-definition filters.
Co-authored-by: multica-agent <github@multica.ai>
* feat(properties): server-side property filtering and sorting on list endpoints
Property filter/sort now execute in the database, so results are correct
across the full issue set — not just the loaded 50-per-status window
(closes MUL-4493 item 1's filter/sort half; requested on MUL-4463).
- New `properties` query param on ListIssues and ListGroupedIssues:
JSON {definitionId: [values]} compiled to an AND-of-ORs containment
check (double NOT EXISTS over jsonb_array_elements). One value expands
to every storage shape it could match — string (select), array element
(multi_select), boolean (checkbox) — so the handler stays type-agnostic.
Guarded at 20 definitions / 50 values.
- `sort=property:<definitionId>` resolves the definition and orders by a
typed expression (numeric CASE cast for number, NULLIF text for
date/text/url); missing values sort last in both directions. Malformed
ids 400; unknown/archived definitions degrade to position order instead
of breaking stale clients.
- Frontend: the property filter and property sort ride the IssueSortParam
window bag, so every surface (workspace + my-issues variants), query
key, and per-status load-more page carries them automatically. The
client-side re-sort layer is gone; applyIssueFilters keeps its property
predicate as an optimistic-update backstop.
- Regression test seeds 55 issues and proves a match at position 55 is
returned by a filtered 50-row page, plus sort order/missing-last,
AND-across-definitions, and the 400/fallback sort paths.
Co-authored-by: multica-agent <github@multica.ai>
* fix(properties): address review round 3 — cache reconcile, merged-scope order, GIN-indexable filter, pool loader
- Cache reconciliation: property value writes (mutation settle + WS event)
now invalidate every issue window whose server-side shape depends on
property values — queries filtered by `properties` or sorted by
`property:<id>` (detected via query-key predicate), covering flat lists,
assignee groups, and my-issues variants. Windows without property params
keep the cheap in-place patch. Fixes stale ordering/membership/counts
under staleTime:Infinity.
- My Issues "All" scope: merged assigned/created/involves results are
re-sorted with a comparator mirroring the server ORDER BY semantics
(including property sorts and missing-last, created_at DESC tiebreak) in
both the flat and assignee-grouped merge paths — relation concatenation
no longer overrides the user's sort.
- Filter predicate rebuilt as plain bind-parameter containment ORs
(AND across definitions): EXPLAIN now shows BitmapOr over
idx_issue_properties_gin (the correlated jsonb_array_elements form
defeated the index). Alternatives capped at 256 bind params.
- Property-grouped board gains a pool loader strip: one sentinel per
status that still has server rows, keeping every issue reachable until
per-column pagination lands (MUL-4493).
- Windowing regression test hardened: explicit positions + an assertion
that the unfiltered first page excludes the target (the old fixture tied
at position 0 and the created_at DESC tiebreak put the target on page
one, proving nothing).
- Rollback safety: /api/properties 404 (old server) degrades to an empty
catalog instead of a query error, which also keeps property params from
ever being sent to pre-property servers; migration 179's CHECK
constraints switch to NOT VALID + VALIDATE so the exclusive lock is
instantaneous.
Co-authored-by: multica-agent <github@multica.ai>
* fix(properties): harden concurrency and cache coordination from clean-room review
Backend (MUL-4762 F1/F4/F5):
- withPropertyLock: pg_advisory_xact_lock helper; definition create/update
and value writes now serialize config-vs-value and cap-vs-insert races
(workspace-level 'props:' lock + per-definition 'prop:' lock, ordered).
- propertySortExpr degrades archived definitions to position sort.
Frontend (F2/F3/F6):
- onIssuePropertiesChanged invalidates plain assignee-group caches too.
- Property value mutations cancel list refetches in onMutate and roll back
only the touched key against the current bag (concurrent WS writes to
other keys survive a failed write).
- useUpdateIssue reconcile drops the stale properties bag from the server
snapshot; the property pipeline owns that field.
- Surface controller passes persisted property filters/sorts through
until the catalog query settles (cold cache no longer strips them).
Co-authored-by: multica-agent <github@multica.ai>
* fix(properties): open_only branch honors the properties filter
ListOpenIssues takes the parsed AND-of-ORs containment groups as a single
jsonb properties_filter param and unrolls them with a static double
NOT EXISTS; previously the open_only path parsed the properties param and
silently dropped it (clean-room review F7a).
Co-authored-by: multica-agent <github@multica.ai>
* fix(properties): toast on failed board drag to a property column
Property-column drags rolled the card back silently on failure; mirror
the status/assignee drag path (use-issue-surface-actions) so the
snap-back is explained (clean-room review F3, drag half).
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
1034 lines
39 KiB
TypeScript
1034 lines
39 KiB
TypeScript
import { useState, useCallback } from "react";
|
|
import { hashKey, useMutation, useQueryClient, type QueryKey } from "@tanstack/react-query";
|
|
import { api } from "../api";
|
|
import {
|
|
issueKeys,
|
|
ISSUE_PAGE_SIZE,
|
|
type AssigneeGroupedIssuesFilter,
|
|
type IssueSortParam,
|
|
type MyIssuesFilter,
|
|
} from "./queries";
|
|
import { projectKeys } from "../projects/queries";
|
|
import { inboxKeys } from "../inbox/queries";
|
|
import {
|
|
applyIssueChange,
|
|
invalidateIssueDerivatives,
|
|
invalidateStaleListKeys,
|
|
rollbackIssueChange,
|
|
} from "./cache-coordinator";
|
|
import { issueChangedDims } from "./surface/membership";
|
|
import {
|
|
addIssueToBuckets,
|
|
getBucket,
|
|
setBucket,
|
|
} from "./cache-helpers";
|
|
import {
|
|
cleanupDeletedIssueCaches,
|
|
collectDeletedIssueCacheMetadata,
|
|
invalidateDeletedIssueDependentCaches,
|
|
invalidateDeletedIssueParentCaches,
|
|
invalidateIssueScopedCaches,
|
|
pruneDeletedIssueFromListCaches,
|
|
pruneDeletedIssueFromParentChildrenCaches,
|
|
} from "./delete-cache";
|
|
import { useWorkspaceId } from "../hooks";
|
|
import { useRecentContextStore } from "../chat/recent-context-store";
|
|
import { useRecentIssuesStore } from "./stores";
|
|
import type { GroupedIssuesResponse, InboxItem, Issue, IssueAssigneeGroup, IssueReaction, IssueStatus } from "../types";
|
|
import type {
|
|
CreateIssueRequest,
|
|
UpdateIssueRequest,
|
|
ListIssuesCache,
|
|
} from "../types";
|
|
import type { TimelineEntry, IssueSubscriber, Reaction } from "../types";
|
|
import { sortTimelineEntriesAsc } from "./timeline-sort";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared mutation variable types — used by both mutation hooks and
|
|
// useMutationState consumers to keep the type assertion in sync.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type ToggleCommentReactionVars = {
|
|
commentId: string;
|
|
emoji: string;
|
|
existing: Reaction | undefined;
|
|
};
|
|
|
|
export type ToggleIssueReactionVars = {
|
|
emoji: string;
|
|
existing: IssueReaction | undefined;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Per-status pagination
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Paginate one status column into the cache. Works for both the workspace
|
|
* issue list and per-scope My Issues lists (pass `myIssues` to target the
|
|
* latter).
|
|
*
|
|
* `sort` must match the sort the consuming `useQuery` was called with —
|
|
* the query key embeds it (see `listSorted` / `myListSorted`), so a load-more
|
|
* with the wrong sort would patch a stale cache entry that nobody is
|
|
* subscribed to. It is also threaded into the API request so the appended
|
|
* page lines up with the server-side ordering of the existing items.
|
|
*/
|
|
export function useLoadMoreByStatus(
|
|
status: IssueStatus,
|
|
myIssues?: { scope: string; filter: MyIssuesFilter },
|
|
sort?: IssueSortParam,
|
|
) {
|
|
const qc = useQueryClient();
|
|
const wsId = useWorkspaceId();
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
const activeKey = myIssues
|
|
? issueKeys.myListSorted(wsId, myIssues.scope, myIssues.filter, sort)
|
|
: issueKeys.listSorted(wsId, sort);
|
|
const cache = qc.getQueryData<ListIssuesCache>(activeKey);
|
|
const bucket = cache?.byStatus[status];
|
|
const loaded = bucket?.issues.length ?? 0;
|
|
const total = bucket?.total ?? 0;
|
|
const hasMore = loaded < total;
|
|
|
|
const loadMore = useCallback(async () => {
|
|
if (isLoading || !hasMore) return;
|
|
setIsLoading(true);
|
|
try {
|
|
const res = await api.listIssues({
|
|
status,
|
|
limit: ISSUE_PAGE_SIZE,
|
|
offset: loaded,
|
|
...sort,
|
|
...myIssues?.filter,
|
|
});
|
|
qc.setQueryData<ListIssuesCache>(activeKey, (old) => {
|
|
if (!old) return old;
|
|
const prev = getBucket(old, status);
|
|
const existingIds = new Set(prev.issues.map((i) => i.id));
|
|
const appended = res.issues.filter((i) => !existingIds.has(i.id));
|
|
return setBucket(old, status, {
|
|
issues: [...prev.issues, ...appended],
|
|
total: res.total,
|
|
});
|
|
});
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [qc, activeKey, status, loaded, hasMore, isLoading, myIssues?.filter, sort]);
|
|
|
|
return { loadMore, hasMore, isLoading, total };
|
|
}
|
|
|
|
/**
|
|
* Paginate one assignee-grouped board column into the cache. `queryKey`
|
|
* already pins the active cache entry (it's the same object the consuming
|
|
* `useQuery` registered), so the cache lookup and `setQueryData` target the
|
|
* right row. `sort` is threaded into the API request so the appended page
|
|
* lines up with the server-side ordering of the existing items.
|
|
*/
|
|
export function useLoadMoreByAssigneeGroup(
|
|
group: Pick<IssueAssigneeGroup, "id" | "assignee_type" | "assignee_id">,
|
|
queryKey: QueryKey,
|
|
filter: AssigneeGroupedIssuesFilter,
|
|
sort?: IssueSortParam,
|
|
) {
|
|
const qc = useQueryClient();
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
const cache = qc.getQueryData<GroupedIssuesResponse>(queryKey);
|
|
const cachedGroup = cache?.groups.find((g) => g.id === group.id);
|
|
const loaded = cachedGroup?.issues.length ?? 0;
|
|
const total = cachedGroup?.total ?? 0;
|
|
const hasMore = loaded < total;
|
|
|
|
const loadMore = useCallback(async () => {
|
|
if (isLoading || !hasMore) return;
|
|
setIsLoading(true);
|
|
try {
|
|
const res = await api.listGroupedIssues({
|
|
group_by: "assignee",
|
|
limit: ISSUE_PAGE_SIZE,
|
|
offset: loaded,
|
|
...sort,
|
|
...filter,
|
|
group_assignee_type: group.assignee_type ?? "none",
|
|
group_assignee_id: group.assignee_id ?? undefined,
|
|
});
|
|
const nextGroup = res.groups[0];
|
|
if (!nextGroup) return;
|
|
|
|
qc.setQueryData<GroupedIssuesResponse>(queryKey, (old) => {
|
|
if (!old) return old;
|
|
return {
|
|
groups: old.groups.map((existing) => {
|
|
if (existing.id !== nextGroup.id) return existing;
|
|
const existingIds = new Set(existing.issues.map((issue) => issue.id));
|
|
const appended = nextGroup.issues.filter((issue) => !existingIds.has(issue.id));
|
|
return {
|
|
...existing,
|
|
issues: [...existing.issues, ...appended],
|
|
total: nextGroup.total,
|
|
};
|
|
}),
|
|
};
|
|
});
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [filter, group.assignee_id, group.assignee_type, hasMore, isLoading, loaded, qc, queryKey, sort]);
|
|
|
|
return { loadMore, hasMore, isLoading, total };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Issue CRUD
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function useCreateIssue() {
|
|
const qc = useQueryClient();
|
|
const wsId = useWorkspaceId();
|
|
return useMutation({
|
|
mutationFn: (data: CreateIssueRequest) => api.createIssue(data),
|
|
onSuccess: (newIssue) => {
|
|
for (const [key, data] of qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) })) {
|
|
if (data) qc.setQueryData<ListIssuesCache>(key, addIssueToBuckets(data, newIssue));
|
|
}
|
|
// Surface the just-created issue in cmd+k's Recent list without
|
|
// requiring the user to open it first.
|
|
useRecentIssuesStore.getState().recordVisit(wsId, newIssue.id);
|
|
// Invalidate parent's children query so sub-issues list updates immediately
|
|
if (newIssue.parent_issue_id) {
|
|
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, newIssue.parent_issue_id) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
|
}
|
|
},
|
|
onSettled: () => {
|
|
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.projectGanttAll(wsId) });
|
|
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useUpdateIssue() {
|
|
const qc = useQueryClient();
|
|
const wsId = useWorkspaceId();
|
|
return useMutation({
|
|
mutationFn: ({ id, ...data }: { id: string } & UpdateIssueRequest) =>
|
|
api.updateIssue(id, data),
|
|
onMutate: ({ id, ...data }) => {
|
|
// suppress_run / handoff_note are write-time control fields, not Issue
|
|
// columns — they steer enqueue/injection on the server and must never be
|
|
// written into the query cache (MUL-3375). Strip them from the patch; the
|
|
// mutationFn above still sends the full payload to the API.
|
|
const { suppress_run: _suppressRun, handoff_note: _handoffNote, ...patch } = data;
|
|
// Fire-and-forget cancelQueries — keeps onMutate synchronous so the
|
|
// cache update happens in the same tick as mutate(). Awaiting would
|
|
// yield to the event loop, letting @dnd-kit reset its visual state
|
|
// before the optimistic update lands.
|
|
qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
|
qc.cancelQueries({ queryKey: issueKeys.myAll(wsId) });
|
|
if (patch.status !== undefined) {
|
|
qc.cancelQueries({ queryKey: inboxKeys.list(wsId) });
|
|
}
|
|
const prevDetail = qc.getQueryData<Issue>(issueKeys.detail(wsId, id));
|
|
// The coordinator owns the cross-cache rules: surgical patch/rebucket
|
|
// where the card is loaded and still belongs, surgical REMOVE where the
|
|
// change moves it off a filtered surface, stale-key bookkeeping where
|
|
// the server result may have drifted (invalidated on settle, not here —
|
|
// a mid-flight refetch would stomp the optimistic state).
|
|
const change = applyIssueChange(qc, wsId, id, patch as Partial<Issue>, {
|
|
changed: issueChangedDims(patch, prevDetail),
|
|
baseIssue: prevDetail,
|
|
});
|
|
|
|
// Resolve parent_issue_id from the freshest source so we can keep the
|
|
// parent's children cache in sync (used by the parent issue's
|
|
// sub-issues list). Falls back to scanning loaded children caches —
|
|
// when the user navigates straight to a parent's detail page, the
|
|
// child may live only there, not in detail/list.
|
|
let parentId: string | null =
|
|
prevDetail?.parent_issue_id ??
|
|
change.prevIssue?.parent_issue_id ??
|
|
null;
|
|
if (!parentId) {
|
|
const childrenCaches = qc.getQueriesData<Issue[]>({
|
|
queryKey: [...issueKeys.all(wsId), "children"],
|
|
});
|
|
for (const [key, data] of childrenCaches) {
|
|
if (!data?.some((c) => c.id === id)) continue;
|
|
const candidate = key[key.length - 1];
|
|
if (typeof candidate === "string") {
|
|
parentId = candidate;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
const prevChildren = parentId
|
|
? qc.getQueryData<Issue[]>(issueKeys.children(wsId, parentId))
|
|
: undefined;
|
|
|
|
if (parentId) {
|
|
// When the write re-parents this issue away from `parentId` (detach
|
|
// to standalone, or move under a different parent), prune it from the
|
|
// old parent's children cache. The parent's sub-issues list renders
|
|
// that array directly, so a bare patch to parent_issue_id: null would
|
|
// leave an orphaned row in the list until the settle refetch lands.
|
|
// onError restores prevChildren, so the prune rolls back on failure.
|
|
const detachedFromParent =
|
|
Object.prototype.hasOwnProperty.call(patch, "parent_issue_id") &&
|
|
patch.parent_issue_id !== parentId;
|
|
qc.setQueryData<Issue[]>(
|
|
issueKeys.children(wsId, parentId),
|
|
(old) =>
|
|
detachedFromParent
|
|
? old?.filter((c) => c.id !== id)
|
|
: old?.map((c) => (c.id === id ? { ...c, ...patch } : c)),
|
|
);
|
|
}
|
|
return { change, prevChildren, parentId, id };
|
|
},
|
|
onError: (_err, _vars, ctx) => {
|
|
if (ctx) {
|
|
rollbackIssueChange(qc, wsId, ctx.id, ctx.change);
|
|
}
|
|
if (ctx?.parentId && ctx.prevChildren !== undefined) {
|
|
qc.setQueryData(
|
|
issueKeys.children(wsId, ctx.parentId),
|
|
ctx.prevChildren,
|
|
);
|
|
}
|
|
},
|
|
onSuccess: (serverIssue, vars) => {
|
|
// Reconcile with the authoritative server entity by patching the one card
|
|
// in place — NOT by invalidating + refetching the list. The list refetch
|
|
// is what made a successful move flicker: the optimistic card was already
|
|
// in the right place, then the refetch replaced the whole column and the
|
|
// card re-landed. updateIssue returns the full issue and a position update
|
|
// touches only that row, so a surgical patch is the authoritative
|
|
// reconcile and is a visual no-op when the optimistic value matched.
|
|
//
|
|
// baseIssue = serverIssue: membership moves were already handled
|
|
// optimistically; against the post-write entity the changed dims come
|
|
// out false unless the server coerced a different value, so this pass
|
|
// is the plain surgical patch it always was.
|
|
const { suppress_run: _suppressRun, handoff_note: _handoffNote, id: _id, ...intent } = vars;
|
|
// Drop `properties` from the reconcile payload: the bag is owned by the
|
|
// property mutation pipeline (single-key atomic writes + its own
|
|
// optimistic state). An UpdateIssue snapshot taken before a concurrent
|
|
// property write resolves would otherwise overwrite the newer bag
|
|
// (clean-room review F3 response-ordering race).
|
|
const { properties: _staleBag, ...reconcilable } = serverIssue;
|
|
const reconcile = applyIssueChange(qc, wsId, serverIssue.id, reconcilable as typeof serverIssue, {
|
|
changed: issueChangedDims(intent, serverIssue),
|
|
baseIssue: serverIssue,
|
|
});
|
|
// The server has committed — safe to flush any drift it reported now.
|
|
invalidateStaleListKeys(qc, reconcile.staleKeys);
|
|
},
|
|
onSettled: (_data, _err, vars, ctx) => {
|
|
// The issue's own list + detail caches are reconciled surgically in
|
|
// onSuccess / onError, so they are deliberately NOT invalidated here — a
|
|
// full-list refetch on settle is what made drags flicker. Only aggregate
|
|
// caches that cannot be patched from a single issue are refreshed, plus
|
|
// the specific list keys the coordinator flagged as drifted (unknown
|
|
// membership, enter/leave beyond the loaded window, bucket-count drift).
|
|
// Those stale keys are the surgical replacement for the old blanket
|
|
// "invalidate myAll on project move" safety net (MUL-3669 / #4548): the
|
|
// old project's loaded list already had the card removed in onMutate,
|
|
// and only genuinely undecidable lists refetch here.
|
|
invalidateIssueDerivatives(qc, wsId, {
|
|
statusOrProjectChanged:
|
|
vars.status !== undefined ||
|
|
Object.prototype.hasOwnProperty.call(vars, "project_id"),
|
|
});
|
|
if (ctx) {
|
|
invalidateStaleListKeys(qc, ctx.change.staleKeys);
|
|
}
|
|
// Refresh the issue's attachments cache when the description editor
|
|
// bound new uploads — the description editor reads `issueAttachments`
|
|
// to resolve text-preview Eye gates, and unlike other mutations this
|
|
// payload mutates the attachment join table.
|
|
if (vars.attachment_ids?.length) {
|
|
qc.invalidateQueries({ queryKey: issueKeys.attachments(vars.id) });
|
|
}
|
|
// Invalidate old parent's children cache
|
|
if (ctx?.parentId) {
|
|
qc.invalidateQueries({
|
|
queryKey: issueKeys.children(wsId, ctx.parentId),
|
|
});
|
|
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
|
}
|
|
// Invalidate new parent's children cache when parent_issue_id changed
|
|
const newParentId = vars.parent_issue_id;
|
|
if (newParentId && newParentId !== ctx?.parentId) {
|
|
qc.invalidateQueries({
|
|
queryKey: issueKeys.children(wsId, newParentId),
|
|
});
|
|
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
|
}
|
|
// Invalidate the batched-children cache only when the parent link
|
|
// actually changed. The WS path (ws-updaters.ts) invalidates
|
|
// unconditionally because it doesn't know what the server change
|
|
// touched; here onMutate already patched issueKeys.children(parent)
|
|
// optimistically, so we only need to flush when the parent relation
|
|
// itself moved.
|
|
if (ctx?.parentId || newParentId) {
|
|
qc.invalidateQueries({ queryKey: issueKeys.childrenByParentsAll(wsId) });
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useDeleteIssue() {
|
|
const qc = useQueryClient();
|
|
const wsId = useWorkspaceId();
|
|
return useMutation({
|
|
mutationFn: (id: string) => api.deleteIssue(id),
|
|
onMutate: async (id) => {
|
|
await Promise.all([
|
|
qc.cancelQueries({ queryKey: issueKeys.list(wsId) }),
|
|
qc.cancelQueries({ queryKey: issueKeys.myAll(wsId) }),
|
|
]);
|
|
const metadata = collectDeletedIssueCacheMetadata(qc, wsId, id);
|
|
await Promise.all(
|
|
metadata.parentIssueIds.map((parentId) =>
|
|
qc.cancelQueries({ queryKey: issueKeys.children(wsId, parentId) }),
|
|
),
|
|
);
|
|
const prevLists = qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) });
|
|
const prevMyLists = qc.getQueriesData<ListIssuesCache>({
|
|
queryKey: issueKeys.myAll(wsId),
|
|
});
|
|
const prevDetail = qc.getQueryData<Issue>(issueKeys.detail(wsId, id));
|
|
const prevChildren = new Map<string, Issue[] | undefined>();
|
|
for (const parentId of metadata.parentIssueIds) {
|
|
prevChildren.set(
|
|
parentId,
|
|
qc.getQueryData<Issue[]>(issueKeys.children(wsId, parentId)),
|
|
);
|
|
}
|
|
|
|
pruneDeletedIssueFromListCaches(qc, wsId, id);
|
|
pruneDeletedIssueFromParentChildrenCaches(qc, wsId, id, metadata);
|
|
qc.removeQueries({ queryKey: issueKeys.detail(wsId, id) });
|
|
return { id, metadata, prevLists, prevMyLists, prevDetail, prevChildren };
|
|
},
|
|
onError: (_err, _id, ctx) => {
|
|
if (ctx?.prevLists) {
|
|
for (const [key, snapshot] of ctx.prevLists) {
|
|
qc.setQueryData(key, snapshot);
|
|
}
|
|
}
|
|
if (ctx?.prevMyLists) {
|
|
for (const [key, snapshot] of ctx.prevMyLists) {
|
|
qc.setQueryData(key, snapshot);
|
|
}
|
|
}
|
|
if (ctx?.prevDetail) {
|
|
qc.setQueryData(issueKeys.detail(wsId, ctx.id), ctx.prevDetail);
|
|
}
|
|
if (ctx?.prevChildren) {
|
|
for (const [parentId, snapshot] of ctx.prevChildren) {
|
|
qc.setQueryData(issueKeys.children(wsId, parentId), snapshot);
|
|
}
|
|
}
|
|
},
|
|
onSuccess: (_data, id, ctx) => {
|
|
useRecentContextStore.getState().forgetContext(wsId, { type: "issue", id });
|
|
cleanupDeletedIssueCaches(qc, wsId, id, ctx?.metadata);
|
|
},
|
|
onSettled: (_data, _err, _id, ctx) => {
|
|
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.projectGanttAll(wsId) });
|
|
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
|
|
if (ctx?.metadata) invalidateDeletedIssueParentCaches(qc, wsId, ctx.metadata);
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useBatchUpdateIssues() {
|
|
const qc = useQueryClient();
|
|
const wsId = useWorkspaceId();
|
|
return useMutation({
|
|
mutationFn: ({
|
|
ids,
|
|
updates,
|
|
}: {
|
|
ids: string[];
|
|
updates: UpdateIssueRequest;
|
|
}) => api.batchUpdateIssues(ids, updates),
|
|
onMutate: async ({ ids, updates }) => {
|
|
// Control fields steer the server; they are not Issue columns and must
|
|
// not enter the cache (MUL-3375). mutationFn still sends them.
|
|
const { suppress_run: _suppressRun, handoff_note: _handoffNote, ...patch } = updates;
|
|
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
|
await qc.cancelQueries({ queryKey: issueKeys.myAll(wsId) });
|
|
if (patch.status !== undefined) {
|
|
await qc.cancelQueries({ queryKey: inboxKeys.list(wsId) });
|
|
}
|
|
|
|
// Run every issue through the coordinator — the same rules table the
|
|
// single-issue update uses, so a batch edit patches/removes across the
|
|
// workspace board AND every filtered myList surface identically.
|
|
// Snapshots are first-wins per cache key: after the first issue's
|
|
// application a cache already carries partial patches, so only the
|
|
// first snapshot per key is pristine for rollback.
|
|
const prevListByHash = new Map<string, [QueryKey, ListIssuesCache]>();
|
|
const prevDetailById = new Map<string, Issue>();
|
|
let prevInboxList: InboxItem[] | undefined;
|
|
const staleKeys: QueryKey[] = [];
|
|
for (const id of ids) {
|
|
const base = qc.getQueryData<Issue>(issueKeys.detail(wsId, id));
|
|
const change = applyIssueChange(qc, wsId, id, patch as Partial<Issue>, {
|
|
changed: issueChangedDims(patch, base),
|
|
baseIssue: base,
|
|
});
|
|
for (const [key, snapshot] of change.prevLists) {
|
|
const hash = hashKey(key);
|
|
if (!prevListByHash.has(hash)) prevListByHash.set(hash, [key, snapshot]);
|
|
}
|
|
if (change.prevDetail) prevDetailById.set(id, change.prevDetail);
|
|
if (prevInboxList === undefined && change.prevInboxList !== undefined) {
|
|
prevInboxList = change.prevInboxList;
|
|
}
|
|
staleKeys.push(...change.staleKeys);
|
|
}
|
|
|
|
// Mirror the optimistic patch into any loaded children cache so
|
|
// sub-issue rows on a parent's detail page reflect the change too.
|
|
const idSet = new Set(ids);
|
|
const childrenCaches = qc.getQueriesData<Issue[]>({
|
|
queryKey: [...issueKeys.all(wsId), "children"],
|
|
});
|
|
const prevChildren = new Map<string, Issue[] | undefined>();
|
|
const affectedParentIds = new Set<string>();
|
|
for (const [key, data] of childrenCaches) {
|
|
if (!data?.some((c) => idSet.has(c.id))) continue;
|
|
const parentId = key[key.length - 1];
|
|
if (typeof parentId !== "string") continue;
|
|
affectedParentIds.add(parentId);
|
|
prevChildren.set(parentId, data);
|
|
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
|
|
old?.map((c) => (idSet.has(c.id) ? { ...c, ...patch } : c)),
|
|
);
|
|
}
|
|
|
|
return {
|
|
prevLists: [...prevListByHash.values()],
|
|
prevDetailById,
|
|
prevInboxList,
|
|
staleKeys,
|
|
prevChildren,
|
|
affectedParentIds,
|
|
};
|
|
},
|
|
onError: (_err, _vars, ctx) => {
|
|
if (ctx?.prevLists) {
|
|
for (const [key, snapshot] of ctx.prevLists) {
|
|
qc.setQueryData(key, snapshot);
|
|
}
|
|
}
|
|
if (ctx?.prevDetailById) {
|
|
for (const [id, snapshot] of ctx.prevDetailById) {
|
|
qc.setQueryData(issueKeys.detail(wsId, id), snapshot);
|
|
}
|
|
}
|
|
if (ctx?.prevInboxList !== undefined) {
|
|
qc.setQueryData(inboxKeys.list(wsId), ctx.prevInboxList);
|
|
}
|
|
if (ctx?.prevChildren) {
|
|
for (const [parentId, snapshot] of ctx.prevChildren) {
|
|
qc.setQueryData(issueKeys.children(wsId, parentId), snapshot);
|
|
}
|
|
}
|
|
},
|
|
onSettled: (_data, _err, _vars, ctx) => {
|
|
// Deliberately NOT invalidating issueKeys.list / myAll here: the onMutate
|
|
// pass above is a complete surgical reconcile for the loaded bucketed
|
|
// boards, so a full-board refetch on settle would only re-introduce the
|
|
// flicker the single-issue update already removed. Aggregate / grouped
|
|
// caches that cannot be recomputed from a single-issue patch are
|
|
// refreshed below, plus the specific keys the coordinator flagged as
|
|
// drifted — the surgical replacement for the old blanket "invalidate
|
|
// myAll on project move" safety net (MUL-3669 / #4548).
|
|
invalidateIssueDerivatives(qc, wsId, {
|
|
statusOrProjectChanged:
|
|
_vars.updates.status !== undefined ||
|
|
Object.prototype.hasOwnProperty.call(_vars.updates, "project_id"),
|
|
});
|
|
if (ctx) {
|
|
invalidateStaleListKeys(qc, ctx.staleKeys);
|
|
}
|
|
if (ctx?.affectedParentIds && ctx.affectedParentIds.size > 0) {
|
|
for (const parentId of ctx.affectedParentIds) {
|
|
qc.invalidateQueries({
|
|
queryKey: issueKeys.children(wsId, parentId),
|
|
});
|
|
}
|
|
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useBatchDeleteIssues() {
|
|
const qc = useQueryClient();
|
|
const wsId = useWorkspaceId();
|
|
return useMutation({
|
|
mutationFn: (ids: string[]) => api.batchDeleteIssues(ids),
|
|
onMutate: async (ids) => {
|
|
await Promise.all([
|
|
qc.cancelQueries({ queryKey: issueKeys.list(wsId) }),
|
|
qc.cancelQueries({ queryKey: issueKeys.myAll(wsId) }),
|
|
]);
|
|
const metadataById = new Map(
|
|
ids.map((id) => [
|
|
id,
|
|
collectDeletedIssueCacheMetadata(qc, wsId, id),
|
|
]),
|
|
);
|
|
const parentIssueIds = new Set<string>();
|
|
for (const metadata of metadataById.values()) {
|
|
for (const parentId of metadata.parentIssueIds) {
|
|
parentIssueIds.add(parentId);
|
|
}
|
|
}
|
|
await Promise.all(
|
|
Array.from(parentIssueIds).map((parentId) =>
|
|
qc.cancelQueries({ queryKey: issueKeys.children(wsId, parentId) }),
|
|
),
|
|
);
|
|
const prevLists = qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) });
|
|
const prevMyLists = qc.getQueriesData<ListIssuesCache>({
|
|
queryKey: issueKeys.myAll(wsId),
|
|
});
|
|
const prevChildren = new Map<string, Issue[] | undefined>();
|
|
for (const parentId of parentIssueIds) {
|
|
prevChildren.set(
|
|
parentId,
|
|
qc.getQueryData<Issue[]>(issueKeys.children(wsId, parentId)),
|
|
);
|
|
}
|
|
|
|
for (const id of ids) {
|
|
const metadata = metadataById.get(id);
|
|
pruneDeletedIssueFromListCaches(qc, wsId, id);
|
|
if (metadata) {
|
|
pruneDeletedIssueFromParentChildrenCaches(qc, wsId, id, metadata);
|
|
}
|
|
}
|
|
return { prevLists, prevMyLists, prevChildren, parentIssueIds, metadataById };
|
|
},
|
|
onError: (_err, _ids, ctx) => {
|
|
if (ctx?.prevLists) {
|
|
for (const [key, snapshot] of ctx.prevLists) {
|
|
qc.setQueryData(key, snapshot);
|
|
}
|
|
}
|
|
if (ctx?.prevMyLists) {
|
|
for (const [key, snapshot] of ctx.prevMyLists) {
|
|
qc.setQueryData(key, snapshot);
|
|
}
|
|
}
|
|
if (ctx?.prevChildren) {
|
|
for (const [parentId, snapshot] of ctx.prevChildren) {
|
|
qc.setQueryData(issueKeys.children(wsId, parentId), snapshot);
|
|
}
|
|
}
|
|
},
|
|
onSuccess: (data, ids, ctx) => {
|
|
if (data.deleted === ids.length) {
|
|
const { forgetContext } = useRecentContextStore.getState();
|
|
for (const id of ids) {
|
|
forgetContext(wsId, { type: "issue", id });
|
|
cleanupDeletedIssueCaches(qc, wsId, id, ctx?.metadataById.get(id));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (ctx?.prevLists) {
|
|
for (const [key, snapshot] of ctx.prevLists) {
|
|
qc.setQueryData(key, snapshot);
|
|
}
|
|
}
|
|
if (ctx?.prevMyLists) {
|
|
for (const [key, snapshot] of ctx.prevMyLists) {
|
|
qc.setQueryData(key, snapshot);
|
|
}
|
|
}
|
|
if (ctx?.prevChildren) {
|
|
for (const [parentId, snapshot] of ctx.prevChildren) {
|
|
qc.setQueryData(issueKeys.children(wsId, parentId), snapshot);
|
|
}
|
|
}
|
|
for (const id of ids) {
|
|
invalidateIssueScopedCaches(qc, wsId, id);
|
|
}
|
|
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
|
|
invalidateDeletedIssueDependentCaches(qc, wsId);
|
|
},
|
|
onSettled: (_data, _err, _ids, ctx) => {
|
|
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.projectGanttAll(wsId) });
|
|
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
|
|
if (ctx?.parentIssueIds && ctx.parentIssueIds.size > 0) {
|
|
invalidateDeletedIssueParentCaches(qc, wsId, {
|
|
parentIssueIds: Array.from(ctx.parentIssueIds),
|
|
});
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Comments / Timeline
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type TimelineCache = TimelineEntry[];
|
|
|
|
export function useCreateComment(issueId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: ({
|
|
content,
|
|
type,
|
|
parentId,
|
|
attachmentIds,
|
|
suppressAgentIds,
|
|
}: {
|
|
content: string;
|
|
type?: string;
|
|
parentId?: string;
|
|
attachmentIds?: string[];
|
|
suppressAgentIds?: string[];
|
|
}) => api.createComment(issueId, content, type, parentId, attachmentIds, suppressAgentIds),
|
|
onSuccess: (comment) => {
|
|
const entry: TimelineEntry = {
|
|
type: "comment",
|
|
id: comment.id,
|
|
actor_type: comment.author_type,
|
|
actor_id: comment.author_id,
|
|
content: comment.content,
|
|
parent_id: comment.parent_id,
|
|
comment_type: comment.type,
|
|
reactions: comment.reactions ?? [],
|
|
attachments: comment.attachments ?? [],
|
|
created_at: comment.created_at,
|
|
updated_at: comment.updated_at,
|
|
};
|
|
// Dedupe by id: the `comment:created` WS event may have already added
|
|
// this entry from the broadcast path before this onSuccess fires. Skip
|
|
// the append if the entry is already in the cache.
|
|
qc.setQueryData<TimelineCache>(issueKeys.timeline(issueId), (old) => {
|
|
if (!old) return [entry];
|
|
if (old.some((e) => e.id === entry.id)) return old;
|
|
return sortTimelineEntriesAsc([...old, entry]);
|
|
});
|
|
// Posting a comment changes the trigger answer itself (the enqueued
|
|
// task now dedupes follow-up triggers), so cached previews for this
|
|
// issue are stale the moment the create lands.
|
|
qc.invalidateQueries({ queryKey: issueKeys.commentTriggerPreview(issueId) });
|
|
},
|
|
// No onSettled invalidate. The `comment:created` WS broadcast keeps
|
|
// the timeline cache fresh after a successful create, and reconnect
|
|
// recovery in useIssueTimeline already invalidates if the connection
|
|
// dropped. Re-fetching on every submit replaces every entry's
|
|
// reference, which forces every memoized CommentCard subtree to
|
|
// re-render (visible as a flash across sibling threads during AI
|
|
// streaming).
|
|
});
|
|
}
|
|
|
|
export function useUpdateComment(issueId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: ({
|
|
commentId,
|
|
content,
|
|
attachmentIds,
|
|
suppressAgentIds,
|
|
}: {
|
|
commentId: string;
|
|
content: string;
|
|
attachmentIds: string[];
|
|
suppressAgentIds?: string[];
|
|
}) => api.updateComment(commentId, content, attachmentIds, suppressAgentIds),
|
|
onMutate: async ({ commentId, content, attachmentIds }) => {
|
|
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
|
|
const prev = qc.getQueryData<TimelineCache>(issueKeys.timeline(issueId));
|
|
const kept = new Set(attachmentIds);
|
|
qc.setQueryData<TimelineCache>(issueKeys.timeline(issueId), (old) =>
|
|
old?.map((e) =>
|
|
e.id === commentId
|
|
? { ...e, content, attachments: e.attachments?.filter((a) => kept.has(a.id)) }
|
|
: e,
|
|
),
|
|
);
|
|
return { prev };
|
|
},
|
|
onError: (_err, _vars, ctx) => {
|
|
if (ctx?.prev !== undefined) {
|
|
qc.setQueryData(issueKeys.timeline(issueId), ctx.prev);
|
|
}
|
|
},
|
|
onSettled: () => {
|
|
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useDeleteComment(issueId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (commentId: string) => api.deleteComment(commentId),
|
|
onMutate: async (commentId) => {
|
|
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
|
|
const prev = qc.getQueryData<TimelineCache>(issueKeys.timeline(issueId));
|
|
|
|
// Cascade: collect all descendants of the deleted comment.
|
|
const toRemove = new Set<string>([commentId]);
|
|
if (prev) {
|
|
let changed = true;
|
|
while (changed) {
|
|
changed = false;
|
|
for (const e of prev) {
|
|
if (
|
|
e.parent_id &&
|
|
toRemove.has(e.parent_id) &&
|
|
!toRemove.has(e.id)
|
|
) {
|
|
toRemove.add(e.id);
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
qc.setQueryData<TimelineCache>(issueKeys.timeline(issueId), (old) =>
|
|
old?.filter((e) => !toRemove.has(e.id)),
|
|
);
|
|
return { prev };
|
|
},
|
|
onError: (_err, _id, ctx) => {
|
|
if (ctx?.prev !== undefined) {
|
|
qc.setQueryData(issueKeys.timeline(issueId), ctx.prev);
|
|
}
|
|
},
|
|
onSettled: () => {
|
|
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
|
|
},
|
|
});
|
|
}
|
|
|
|
// Every comment id in the same thread as `commentId` — the thread root plus
|
|
// every descendant. Mirrors the server's thread walk in
|
|
// ClearOtherThreadResolutions so the resolve optimistic update can clear sibling
|
|
// resolutions exactly as the backend will, instead of briefly showing two
|
|
// resolutions until the refetch settles.
|
|
function collectThreadCommentIds(
|
|
entries: TimelineCache,
|
|
commentId: string,
|
|
): Set<string> {
|
|
const byId = new Map<string, TimelineEntry>();
|
|
for (const e of entries) {
|
|
if (e.type === "comment") byId.set(e.id, e);
|
|
}
|
|
// Walk up to the thread root (cycle-guarded against malformed parent chains).
|
|
let rootId = commentId;
|
|
const guard = new Set<string>();
|
|
let cur = byId.get(commentId);
|
|
while (cur?.parent_id && byId.has(cur.parent_id) && !guard.has(cur.id)) {
|
|
guard.add(cur.id);
|
|
rootId = cur.parent_id;
|
|
cur = byId.get(cur.parent_id);
|
|
}
|
|
// Expand back down over the whole subtree.
|
|
const childrenByParent = new Map<string, string[]>();
|
|
for (const e of byId.values()) {
|
|
if (e.parent_id) {
|
|
const list = childrenByParent.get(e.parent_id) ?? [];
|
|
list.push(e.id);
|
|
childrenByParent.set(e.parent_id, list);
|
|
}
|
|
}
|
|
const ids = new Set<string>([rootId]);
|
|
const stack = [rootId];
|
|
while (stack.length > 0) {
|
|
const id = stack.pop()!;
|
|
for (const child of childrenByParent.get(id) ?? []) {
|
|
if (!ids.has(child)) {
|
|
ids.add(child);
|
|
stack.push(child);
|
|
}
|
|
}
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
export function useResolveComment(issueId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: ({ commentId, resolved }: { commentId: string; resolved: boolean }) =>
|
|
resolved ? api.resolveComment(commentId) : api.unresolveComment(commentId),
|
|
onMutate: async ({ commentId, resolved }) => {
|
|
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
|
|
const prev = qc.getQueryData<TimelineCache>(issueKeys.timeline(issueId));
|
|
qc.setQueryData<TimelineCache>(issueKeys.timeline(issueId), (old) => {
|
|
if (!old) return old;
|
|
// Resolving makes this comment the sole resolution in its thread, so
|
|
// mirror the server (ClearOtherThreadResolutions) and clear every other
|
|
// resolution in the same thread. Without this the cache shows two
|
|
// resolutions until the settle refetch, which is exactly the flash the
|
|
// single-resolution fix removes. Unresolve only clears its own row.
|
|
const threadIds = resolved ? collectThreadCommentIds(old, commentId) : null;
|
|
return old.map((e) => {
|
|
if (e.id === commentId) {
|
|
return {
|
|
...e,
|
|
resolved_at: resolved ? new Date().toISOString() : null,
|
|
resolved_by_type: resolved ? e.resolved_by_type ?? null : null,
|
|
resolved_by_id: resolved ? e.resolved_by_id ?? null : null,
|
|
};
|
|
}
|
|
if (resolved && e.resolved_at && threadIds?.has(e.id)) {
|
|
return { ...e, resolved_at: null, resolved_by_type: null, resolved_by_id: null };
|
|
}
|
|
return e;
|
|
});
|
|
});
|
|
return { prev };
|
|
},
|
|
onError: (_err, _vars, ctx) => {
|
|
if (ctx?.prev !== undefined) {
|
|
qc.setQueryData(issueKeys.timeline(issueId), ctx.prev);
|
|
}
|
|
},
|
|
onSettled: () => {
|
|
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useToggleCommentReaction(issueId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationKey: ["toggleCommentReaction", issueId] as const,
|
|
mutationFn: async ({
|
|
commentId,
|
|
emoji,
|
|
existing,
|
|
}: ToggleCommentReactionVars) => {
|
|
if (existing) {
|
|
await api.removeReaction(commentId, emoji);
|
|
return null;
|
|
}
|
|
return api.addReaction(commentId, emoji);
|
|
},
|
|
onSettled: () => {
|
|
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
|
|
},
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Issue-level Reactions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function useToggleIssueReaction(issueId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationKey: ["toggleIssueReaction", issueId] as const,
|
|
mutationFn: async ({
|
|
emoji,
|
|
existing,
|
|
}: ToggleIssueReactionVars) => {
|
|
if (existing) {
|
|
await api.removeIssueReaction(issueId, emoji);
|
|
return null;
|
|
}
|
|
return api.addIssueReaction(issueId, emoji);
|
|
},
|
|
onSettled: () => {
|
|
qc.invalidateQueries({ queryKey: issueKeys.reactions(issueId) });
|
|
},
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Issue Subscribers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function useToggleIssueSubscriber(issueId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async ({
|
|
userId,
|
|
userType,
|
|
subscribed,
|
|
}: {
|
|
userId: string;
|
|
userType: "member" | "agent";
|
|
subscribed: boolean;
|
|
}) => {
|
|
if (subscribed) {
|
|
await api.unsubscribeFromIssue(issueId, userId, userType);
|
|
} else {
|
|
await api.subscribeToIssue(issueId, userId, userType);
|
|
}
|
|
},
|
|
onMutate: async ({ userId, userType, subscribed }) => {
|
|
await qc.cancelQueries({ queryKey: issueKeys.subscribers(issueId) });
|
|
const prev = qc.getQueryData<IssueSubscriber[]>(
|
|
issueKeys.subscribers(issueId),
|
|
);
|
|
|
|
if (subscribed) {
|
|
qc.setQueryData<IssueSubscriber[]>(
|
|
issueKeys.subscribers(issueId),
|
|
(old) =>
|
|
old?.filter(
|
|
(s) => !(s.user_id === userId && s.user_type === userType),
|
|
),
|
|
);
|
|
} else {
|
|
const temp: IssueSubscriber = {
|
|
issue_id: issueId,
|
|
user_type: userType,
|
|
user_id: userId,
|
|
reason: "manual",
|
|
created_at: new Date().toISOString(),
|
|
};
|
|
qc.setQueryData<IssueSubscriber[]>(
|
|
issueKeys.subscribers(issueId),
|
|
(old) => {
|
|
if (
|
|
old?.some(
|
|
(s) => s.user_id === userId && s.user_type === userType,
|
|
)
|
|
)
|
|
return old;
|
|
return [...(old ?? []), temp];
|
|
},
|
|
);
|
|
}
|
|
return { prev };
|
|
},
|
|
onError: (_err, _vars, ctx) => {
|
|
if (ctx?.prev)
|
|
qc.setQueryData(issueKeys.subscribers(issueId), ctx.prev);
|
|
},
|
|
onSettled: () => {
|
|
qc.invalidateQueries({ queryKey: issueKeys.subscribers(issueId) });
|
|
},
|
|
});
|
|
}
|