mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
* feat(issues): live-resort board/list on updated_at drift (MUL-5016) An open Kanban board / list sorted by "Updated date" did not re-sort in real time when a card's updated_at advanced — it only picked up the new order on the next fetch (navigation, invalidation, etc.). Two triggers now re-sort live: - comment:created: a new comment bumps the parent issue's updated_at server-side (MUL-5009), but the WS handler only invalidated the per-issue timeline cache. It now also invalidates loaded updated_at- sorted issue-list/board/flat keys via invalidateUpdatedAtSortedIssue Lists, so the commented card re-sorts (and an off-window card can surface). Only updated_at-sorted keys are touched. - same-status field edit: applyIssueChange already reconciled flat windows on updated_at-sort drift but the bucketed board only marked stale on membership/status change. It now marks an updated_at-sorted bucketed key stale whenever the patch advances updated_at, mirroring the flat-window rule. Deferred to onSettle for mutations (WS flushes immediately), respecting the existing timing contract. Extracted patchChangesAnyIssueField shared helper. Tests cover the coordinator drift/targeting and the comment:created wiring. Co-authored-by: multica-agent <github@multica.ai> * fix(issues): cover assignee-grouped boards and property/metadata events in updated_at re-sort (MUL-5016) Addresses Elon's review of #5671. Must-fix 1: invalidateUpdatedAtSortedIssueLists missed assignee-grouped boards. They fold the sort into their filter bag (issueAssigneeGroupsOptions does `{ ...filter, ...sort }`) rather than a standalone sort key, so the old bucketed+flat enumeration skipped them. Replace it with a single predicate invalidateQueries over issueKeys.all(wsId) that matches any query key with an object part carrying sort_by: "updated_at" — covering status boards, flat tables, and assignee-grouped boards (workspace + My Issues) with one rule. Must-fix 2: custom-property and metadata edits also bump issue.updated_at server-side (SetIssuePropertyValue / DeleteIssuePropertyValue / SetIssueMetadataKey / DeleteIssueMetadataKey) but flow through issue_properties:changed / issue_metadata:changed, not applyIssueChange. Their handlers patched cards in place and only invalidated property/My-Issues/ grouped windows, so an updated_at-sorted workspace board or flat table stayed in the old order. Call invalidateUpdatedAtSortedIssueLists from both onIssuePropertiesChanged and onIssueMetadataChanged. Both are committed-only paths (WS event + mutation onSuccess); the optimistic leg still uses patchIssueProperties, so no premature refetch. Tests: grouped-board targeting in cache-coordinator; updated_at vs position re-sort for onIssueMetadataChanged / onIssuePropertiesChanged (incl. the optimistic leg not refetching) in ws-updaters. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai>
119 lines
3.7 KiB
TypeScript
119 lines
3.7 KiB
TypeScript
/**
|
|
* @vitest-environment jsdom
|
|
*/
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { renderHook } from "@testing-library/react";
|
|
import type { ReactNode } from "react";
|
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
import type { WSClient } from "../api/ws-client";
|
|
import { issueKeys, type IssueSortParam } from "../issues/queries";
|
|
import type { ListIssuesCache } from "../types";
|
|
import { useRealtimeSync, type RealtimeSyncStores } from "./use-realtime-sync";
|
|
|
|
vi.mock("../platform/workspace-storage", () => ({
|
|
getCurrentWsId: () => "ws-1",
|
|
getCurrentSlug: () => "test-ws",
|
|
}));
|
|
|
|
vi.mock("../paths", () => ({
|
|
useHasOnboarded: () => true,
|
|
resolvePostAuthDestination: () => "/",
|
|
}));
|
|
|
|
// Records every ws.on handler by event name so a test can fire one directly.
|
|
function createRecordingWs(): {
|
|
ws: WSClient;
|
|
handlers: Record<string, (p: unknown) => void>;
|
|
} {
|
|
const handlers: Record<string, (p: unknown) => void> = {};
|
|
const ws = {
|
|
on: vi.fn((event: string, handler: (p: unknown) => void) => {
|
|
handlers[event] = handler;
|
|
return () => {};
|
|
}),
|
|
onAny: vi.fn(() => () => {}),
|
|
onReconnect: vi.fn(() => () => {}),
|
|
} as unknown as WSClient;
|
|
return { ws, handlers };
|
|
}
|
|
|
|
function createStores(): RealtimeSyncStores {
|
|
return {
|
|
authStore: Object.assign(() => ({}), {
|
|
getState: () => ({ user: { id: "u1" } }),
|
|
subscribe: () => () => {},
|
|
setState: () => {},
|
|
destroy: () => {},
|
|
}),
|
|
} as unknown as RealtimeSyncStores;
|
|
}
|
|
|
|
function createWrapper(qc: QueryClient) {
|
|
return function Wrapper({ children }: { children: ReactNode }) {
|
|
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
|
};
|
|
}
|
|
|
|
const updatedSort: IssueSortParam = {
|
|
sort_by: "updated_at",
|
|
sort_direction: "desc",
|
|
};
|
|
const positionSort: IssueSortParam = { sort_by: "position" };
|
|
const updatedBoardKey = issueKeys.listSorted("ws-1", updatedSort);
|
|
const positionBoardKey = issueKeys.listSorted("ws-1", positionSort);
|
|
|
|
function bucketed(): ListIssuesCache {
|
|
return { byStatus: { todo: { issues: [], total: 1 } } };
|
|
}
|
|
|
|
describe("useRealtimeSync — comment:created re-sorts Updated date lists", () => {
|
|
let qc: QueryClient;
|
|
|
|
beforeEach(() => {
|
|
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
});
|
|
|
|
afterEach(() => {
|
|
qc.clear();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("invalidates the updated_at-sorted board but leaves the position board", () => {
|
|
qc.setQueryData<ListIssuesCache>(updatedBoardKey, bucketed());
|
|
qc.setQueryData<ListIssuesCache>(positionBoardKey, bucketed());
|
|
qc.setQueryData(issueKeys.timeline("issue-1"), []);
|
|
|
|
const { ws, handlers } = createRecordingWs();
|
|
renderHook(() => useRealtimeSync(ws, createStores()), {
|
|
wrapper: createWrapper(qc),
|
|
});
|
|
|
|
handlers["comment:created"]?.({
|
|
comment: { id: "c1", issue_id: "issue-1" },
|
|
});
|
|
|
|
expect(qc.getQueryState(updatedBoardKey)?.isInvalidated).toBe(true);
|
|
expect(qc.getQueryState(positionBoardKey)?.isInvalidated).toBe(false);
|
|
// The per-issue timeline is still invalidated as before.
|
|
expect(
|
|
qc.getQueryState(issueKeys.timeline("issue-1"))?.isInvalidated,
|
|
).toBe(true);
|
|
});
|
|
|
|
it("ignores a comment event with no issue_id", () => {
|
|
qc.setQueryData<ListIssuesCache>(updatedBoardKey, bucketed());
|
|
|
|
const { handlers } = (() => {
|
|
const rec = createRecordingWs();
|
|
renderHook(() => useRealtimeSync(rec.ws, createStores()), {
|
|
wrapper: createWrapper(qc),
|
|
});
|
|
return rec;
|
|
})();
|
|
|
|
handlers["comment:created"]?.({ comment: { id: "c1" } });
|
|
|
|
expect(qc.getQueryState(updatedBoardKey)?.isInvalidated).toBe(false);
|
|
});
|
|
});
|