mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 14:00:09 +02:00
* fix(issues): stop kanban card snapping back on drag A cross-column drag on a non-position-sorted board left the card in its origin column for the whole request, then jumped to the target only when the mutation settled — the "snaps back, then moves" glitch. Root cause was three coupled choices in the optimistic path: - board-view never updated local columns on drop for sortBy != "position" (onDragOver is a no-op there), so the card relied on the settle refetch to move across. - useUpdateIssue invalidated the whole list on settle, replacing the column and re-landing the card even on success. - patchIssueInBuckets appended a moved card to the column tail instead of its position slot, so any later cache refresh teleported it to the end. Fixes: - board-view: optimistically move the card into the target column on drop for the non-position path (insertIdByPosition), and reconcile local columns from the cache on settle for both paths (revert on error now that the list is no longer refetched). - mutations: reconcile via onSuccess surgical patch of the returned entity; drop the list/detail invalidation from onSettled (aggregates still flush). - cache-helpers: patchIssueInBuckets inserts the moved/reordered card at its position slot; a plain field update still keeps its slot. Adds cache-helpers and drag-utils unit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(issues): patch My-Issues / Project board caches on move too The drag fix made the board reconcile local columns from its feeding cache on settle. The workspace board rides issueKeys.list (patched by onMutate), but the My-Issues and Project boards ride the myList cache, which the mutation did not patch — so a successful move snapped back on those boards. useUpdateIssue now patches/snapshots/rolls back every bucketed list cache (workspace list + myList), selected by the ListIssuesCache `byStatus` shape so grouped (assignee) and flat (gantt) caches are skipped. Adds renderHook regression tests covering both-cache optimistic move, both-cache rollback, and no-list-invalidation-on-settle. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(issues): drop redundant WS position->list invalidate onIssueUpdated already surgically patches the non-filtered workspace board via patchIssueInBuckets (cross-status move + same-column reorder). The extra `if (position) invalidateQueries(list)` re-pulled the whole board on top of that, re-introducing drag flicker through the echoed-back WS event. Removed. Filtered myAll lists still invalidate (membership can change there) — the client-side membership reconciliation for those is a separate follow-up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(issues): batch update patches myList + stops list refetch on settle - onMutate now patches both issueKeys.list and the filtered issueKeys.myAll bucketed caches, so a batch edit on a My-Issues / Project board is optimistic too. Previously only the workspace board was patched, so batch edits on those boards relied entirely on the settle refetch. - onSettled no longer invalidates issueKeys.list: the optimistic patch is a complete reconcile for these bucketed boards (batch changes status / priority / project, never a server-computed value), so a full-board refetch only re-introduced the flicker the single-issue path removed. Aggregate / grouped caches still refresh. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(issues): list view optimistically moves row on non-position drag The sortBy != "position" branch called onMoveIssue without moving the row in local columns, so the row sat in its origin group for the whole request and only jumped across on settle -- the same snap-back the board view had before its fix. Now mirrors board-view: setColumns(insertIdByPosition) on drop so the settle rebuild is a visual no-op. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(issues): keep My-Issues/Project boards in place on non-membership WS change onIssueUpdated now surgically patches the filtered myList (myAll) caches and only invalidates them when the change can actually move an issue in/out of the filter: an assignee change (covers My-Issues direct-assignee + the involves leg + actor panels) or a project change (Project board). A pure status / position / priority / label change reconciles in place -- no refetch -- removing the last drag flicker on filtered boards. Uses the assignee_changed flag the server already sends on issue:updated (surfaced on IssueUpdatedPayload + forwarded by the realtime dispatch); project change is diffed client-side against the cached value. No predicate replication, no backend change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(issues): add settle-lock to swimlane drag (no clobber mid-flight) The swimlane drag had no settle window: the resync useEffect (and the issueMap freeze) guarded only isDraggingRef, so a cache change landing after drop but before the move settled could rebuild localCells out from under the optimistic move. Adds isSettlingRef + settleVersion (mirroring board-view / list-view): the lock is held from drop until onMoveIssue settles, then released, forcing a single resync from the reconciled cache. onMoveIssue now accepts the same optional onSettled callback board/list already use; the parent handleMoveIssue supplies it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * refactor(issues): extract shared useDragSettle hook for board + list board-view and list-view carried byte-identical drag/settle scaffolding (the local columns mirror, the dragging/settling locks, the post-move animation-frame throttle, and the settle callback). That duplication is exactly what let list-view silently drift earlier (it had lost the optimistic-move half of the fix, and its position-branch settle callback omitted the settleVersion bump). Extract the primitive into useDragSettle so both surfaces share one implementation and can't drift again. Behavior-preserving for board-view. For list-view the one intended alignment: its position-branch failed move now reverts, gaining the settleVersion bump board-view already had. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
133 lines
4.3 KiB
TypeScript
133 lines
4.3 KiB
TypeScript
import type {
|
|
Issue,
|
|
IssueStatus,
|
|
IssueStatusBucket,
|
|
ListIssuesCache,
|
|
} from "../types";
|
|
import { PAGINATED_STATUSES } from "./queries";
|
|
|
|
const EMPTY_BUCKET: IssueStatusBucket = { issues: [], total: 0 };
|
|
|
|
export function getBucket(
|
|
resp: ListIssuesCache,
|
|
status: IssueStatus,
|
|
): IssueStatusBucket {
|
|
return resp.byStatus[status] ?? EMPTY_BUCKET;
|
|
}
|
|
|
|
export function setBucket(
|
|
resp: ListIssuesCache,
|
|
status: IssueStatus,
|
|
bucket: IssueStatusBucket,
|
|
): ListIssuesCache {
|
|
return { ...resp, byStatus: { ...resp.byStatus, [status]: bucket } };
|
|
}
|
|
|
|
/** Locate which status bucket holds `id`, if any. */
|
|
export function findIssueLocation(
|
|
resp: ListIssuesCache,
|
|
id: string,
|
|
): { status: IssueStatus; issue: Issue } | null {
|
|
for (const status of PAGINATED_STATUSES) {
|
|
const bucket = resp.byStatus[status];
|
|
const found = bucket?.issues.find((i) => i.id === id);
|
|
if (found) return { status, issue: found };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Add an issue to its status bucket (no-op if already present). */
|
|
export function addIssueToBuckets(
|
|
resp: ListIssuesCache,
|
|
issue: Issue,
|
|
): ListIssuesCache {
|
|
const bucket = getBucket(resp, issue.status);
|
|
if (bucket.issues.some((i) => i.id === issue.id)) return resp;
|
|
return setBucket(resp, issue.status, {
|
|
issues: [...bucket.issues, issue],
|
|
total: bucket.total + 1,
|
|
});
|
|
}
|
|
|
|
/** Remove an issue from whichever bucket contains it. */
|
|
export function removeIssueFromBuckets(
|
|
resp: ListIssuesCache,
|
|
id: string,
|
|
): ListIssuesCache {
|
|
const loc = findIssueLocation(resp, id);
|
|
if (!loc) return resp;
|
|
const bucket = getBucket(resp, loc.status);
|
|
return setBucket(resp, loc.status, {
|
|
issues: bucket.issues.filter((i) => i.id !== id),
|
|
total: Math.max(0, bucket.total - 1),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Insert `issue` into `issues` at the slot implied by `position ASC` — the same
|
|
* ordering the board renders (server `ORDER BY position ASC`). Returns a new
|
|
* array; the input is not mutated.
|
|
*
|
|
* Inserting at the right slot (instead of appending to the end) is what keeps an
|
|
* optimistic move from snapping: the card lands where it will be after the
|
|
* server confirms, so no later cache refresh teleports it to the column tail.
|
|
*/
|
|
export function insertByPosition(issues: Issue[], issue: Issue): Issue[] {
|
|
const idx = issues.findIndex((i) => i.position > issue.position);
|
|
if (idx === -1) return [...issues, issue];
|
|
return [...issues.slice(0, idx), issue, ...issues.slice(idx)];
|
|
}
|
|
|
|
/**
|
|
* Merge `patch` into the issue with `id`. If `patch.status` differs from the
|
|
* current bucket, the issue moves to the new bucket and both buckets' totals
|
|
* are adjusted. The moved card — and a same-column card whose `position`
|
|
* changed — is re-inserted at its `position`-sorted slot rather than appended,
|
|
* so the cache order stays consistent with what the board renders. A plain
|
|
* field update (no status/position change) keeps the card in place.
|
|
*/
|
|
export function patchIssueInBuckets(
|
|
resp: ListIssuesCache,
|
|
id: string,
|
|
patch: Partial<Issue>,
|
|
): ListIssuesCache {
|
|
const loc = findIssueLocation(resp, id);
|
|
if (!loc) return resp;
|
|
const merged: Issue = { ...loc.issue, ...patch };
|
|
const nextStatus = patch.status ?? loc.status;
|
|
|
|
if (nextStatus === loc.status) {
|
|
const bucket = getBucket(resp, loc.status);
|
|
const positionChanged =
|
|
patch.position !== undefined && patch.position !== loc.issue.position;
|
|
if (!positionChanged) {
|
|
// Plain field update (labels, metadata, title, …): keep the slot so a
|
|
// remote edit never reorders an otherwise-untouched column.
|
|
return setBucket(resp, loc.status, {
|
|
...bucket,
|
|
issues: bucket.issues.map((i) => (i.id === id ? merged : i)),
|
|
});
|
|
}
|
|
// Same-column reorder: lift the card out and re-insert at its new slot.
|
|
return setBucket(resp, loc.status, {
|
|
...bucket,
|
|
issues: insertByPosition(
|
|
bucket.issues.filter((i) => i.id !== id),
|
|
merged,
|
|
),
|
|
});
|
|
}
|
|
|
|
const fromBucket = getBucket(resp, loc.status);
|
|
const toBucket = getBucket(resp, nextStatus);
|
|
let next = setBucket(resp, loc.status, {
|
|
issues: fromBucket.issues.filter((i) => i.id !== id),
|
|
total: Math.max(0, fromBucket.total - 1),
|
|
});
|
|
next = setBucket(next, nextStatus, {
|
|
issues: insertByPosition(toBucket.issues, merged),
|
|
total: toBucket.total + 1,
|
|
});
|
|
return next;
|
|
}
|