mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-27 01:19:26 +02:00
Compare commits
1 Commits
codex/agen
...
fix/projec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28edfb5026 |
@@ -431,6 +431,26 @@ describe("useUpdateIssue — optimistic move keeps every bucketed board in sync"
|
||||
expect(invalidatedKeys).not.toContainEqual(issueKeys.list(WS_ID));
|
||||
expect(invalidatedKeys).not.toContainEqual(issueKeys.myAll(WS_ID));
|
||||
});
|
||||
|
||||
it("invalidates myAll on settle when project_id changes (drops the issue from the old project's list)", async () => {
|
||||
// A project move makes the issue leave the old project's filtered list. The
|
||||
// surgical patch is filter-blind (it never removes a card that no longer
|
||||
// matches the list filter), so onSettled must refetch myAll to drop it —
|
||||
// unlike a status-only move, which deliberately does not (MUL-3669 / #4548).
|
||||
updateIssue.mockResolvedValue(makeIssue(1, { project_id: "project-9" }));
|
||||
const invalidateSpy = vi.spyOn(qc, "invalidateQueries");
|
||||
|
||||
const { result } = renderHook(() => useUpdateIssue(), {
|
||||
wrapper: createWrapper(qc),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({ id: "issue-1", project_id: "project-9" });
|
||||
});
|
||||
|
||||
const invalidatedKeys = invalidateSpy.mock.calls.map((c) => c[0]?.queryKey);
|
||||
expect(invalidatedKeys).toContainEqual(issueKeys.myAll(WS_ID));
|
||||
});
|
||||
});
|
||||
|
||||
describe("useBatchUpdateIssues — optimistic patch covers filtered boards too", () => {
|
||||
@@ -541,6 +561,28 @@ describe("useBatchUpdateIssues — optimistic patch covers filtered boards too",
|
||||
const invalidatedKeys = invalidateSpy.mock.calls.map((c) => c[0]?.queryKey);
|
||||
expect(invalidatedKeys).not.toContainEqual(issueKeys.list(WS_ID));
|
||||
});
|
||||
|
||||
it("invalidates myAll on settle when project_id changes (drops moved issues from the old project's list)", async () => {
|
||||
// Mirrors useUpdateIssue: a batch that moves issues between projects must
|
||||
// refetch myAll so they leave the old project's filtered list, even though a
|
||||
// status-only batch deliberately does not (MUL-3669 / #4548).
|
||||
batchUpdateIssues.mockResolvedValue({ updated: 1 });
|
||||
const invalidateSpy = vi.spyOn(qc, "invalidateQueries");
|
||||
|
||||
const { result } = renderHook(() => useBatchUpdateIssues(), {
|
||||
wrapper: createWrapper(qc),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
ids: ["issue-1"],
|
||||
updates: { project_id: "project-9" },
|
||||
});
|
||||
});
|
||||
|
||||
const invalidatedKeys = invalidateSpy.mock.calls.map((c) => c[0]?.queryKey);
|
||||
expect(invalidatedKeys).toContainEqual(issueKeys.myAll(WS_ID));
|
||||
});
|
||||
});
|
||||
|
||||
describe("useResolveComment", () => {
|
||||
|
||||
@@ -333,6 +333,15 @@ export function useUpdateIssue() {
|
||||
) {
|
||||
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
|
||||
}
|
||||
// Local safety net for a project move. The WS echo now carries
|
||||
// project_changed, but a moved issue must also drop out of the OLD
|
||||
// project's filtered list here in case the echo is delayed or dropped. The
|
||||
// surgical onMutate patch is filter-blind — it never removes a card that no
|
||||
// longer matches the list's project filter — so reconcile by refetching
|
||||
// myAll whenever project_id was part of this update (MUL-3669 / #4548).
|
||||
if (Object.prototype.hasOwnProperty.call(vars, "project_id")) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
}
|
||||
// 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
|
||||
@@ -523,6 +532,11 @@ export function useBatchUpdateIssues() {
|
||||
) {
|
||||
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
|
||||
}
|
||||
// Local safety net mirroring useUpdateIssue: drop moved issues from the old
|
||||
// project's filtered list even if the WS echo is delayed (MUL-3669 / #4548).
|
||||
if (Object.prototype.hasOwnProperty.call(_vars.updates, "project_id")) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
}
|
||||
if (ctx?.affectedParentIds && ctx.affectedParentIds.size > 0) {
|
||||
for (const parentId of ctx.affectedParentIds) {
|
||||
qc.invalidateQueries({
|
||||
|
||||
@@ -317,11 +317,43 @@ describe("onIssueUpdated — position move is surgical, not a list refetch", ()
|
||||
it("invalidates myAll when the project changes (Project board membership)", () => {
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.myAll(WS_ID), makeListCache(issueA));
|
||||
|
||||
// issueA.project_id is null; moving it into a project shifts Project-board membership.
|
||||
// issueA.project_id is null; moving it into a project shifts Project-board
|
||||
// membership. No server flag here — this exercises the legacy cache-diff
|
||||
// fallback that keeps a new frontend working against an older backend.
|
||||
onIssueUpdated(qc, WS_ID, { ...issueA, project_id: "project-9" });
|
||||
|
||||
expectInvalidated(qc, issueKeys.myAll(WS_ID));
|
||||
});
|
||||
|
||||
it("invalidates myAll on a server project_changed flag even when the cached project_id already matches (local optimistic move)", () => {
|
||||
// Reproduces the post-optimistic-move state behind MUL-3669: onMutate has
|
||||
// already written the NEW project into detail + list, so a cache diff would
|
||||
// compute projectChanged=false and skip the refetch. The authoritative
|
||||
// server flag must still drive it.
|
||||
const moved: Issue = { ...issueA, project_id: "project-9" };
|
||||
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, moved.id), moved);
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.myAll(WS_ID), makeListCache(moved));
|
||||
|
||||
onIssueUpdated(qc, WS_ID, moved, { projectChanged: true });
|
||||
|
||||
expectInvalidated(qc, issueKeys.myAll(WS_ID));
|
||||
});
|
||||
|
||||
it("does NOT invalidate myAll when the server flag says project_changed=false (flag overrides the legacy diff)", () => {
|
||||
// No detail/list cache for the issue, so the legacy diff would resolve
|
||||
// oldProjectId=null and fire on the non-null incoming project_id. An explicit
|
||||
// false flag from the server is authoritative and must suppress that.
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.myAll(WS_ID), makeListCache(issueA));
|
||||
|
||||
onIssueUpdated(
|
||||
qc,
|
||||
WS_ID,
|
||||
{ ...issueA, project_id: "project-9" },
|
||||
{ projectChanged: false },
|
||||
);
|
||||
|
||||
expect(qc.getQueryState(issueKeys.myAll(WS_ID))?.isInvalidated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// A board column header shows `byStatus[status].total`. On a status change the
|
||||
|
||||
@@ -40,11 +40,16 @@ export function onIssueUpdated(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issue: Partial<Issue> & { id: string },
|
||||
// assigneeChanged / statusChanged come from the server's issue:updated flags.
|
||||
// assigneeChanged gates the filtered-list (myAll) invalidate so a
|
||||
// non-membership change keeps those lists in place instead of refetching.
|
||||
// statusChanged gates the off-screen count reconcile below.
|
||||
meta: { assigneeChanged?: boolean; statusChanged?: boolean } = {},
|
||||
// assigneeChanged / statusChanged / projectChanged come from the server's
|
||||
// issue:updated flags. assigneeChanged + projectChanged gate the filtered-list
|
||||
// (myAll) invalidate so a non-membership change keeps those lists in place
|
||||
// instead of refetching. statusChanged gates the off-screen count reconcile
|
||||
// below.
|
||||
meta: {
|
||||
assigneeChanged?: boolean;
|
||||
statusChanged?: boolean;
|
||||
projectChanged?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
// Look up the OLD parent before mutating list state, so we can keep
|
||||
// the parent's children cache in sync (powers the sub-issues list
|
||||
@@ -61,14 +66,21 @@ export function onIssueUpdated(
|
||||
const parentChanged =
|
||||
issue.parent_issue_id !== undefined && newParentId !== oldParentId;
|
||||
|
||||
// Project-board membership keys on project_id. There is no project_changed
|
||||
// flag on the wire, so diff the incoming project_id against the cached one.
|
||||
// Project board membership keys on project_id. Prefer the server's
|
||||
// project_changed flag (authoritative, set on the wire). Fall back to diffing
|
||||
// the incoming project_id against the cached one only when the flag is absent
|
||||
// (older backend): the diff is unreliable once a local optimistic move has
|
||||
// overwritten the cached project_id, but it still covers remote/agent moves
|
||||
// and keeps a new frontend on an old backend from regressing (MUL-3669 /
|
||||
// #4548). The local move itself is also covered by the onSettled safety net in
|
||||
// useUpdateIssue, which never depends on this flag.
|
||||
const oldProjectId =
|
||||
detailData?.project_id ??
|
||||
(firstListData ? findIssueLocation(firstListData, issue.id)?.issue.project_id : null) ??
|
||||
null;
|
||||
const projectChanged =
|
||||
issue.project_id !== undefined && (issue.project_id ?? null) !== oldProjectId;
|
||||
meta.projectChanged ??
|
||||
(issue.project_id !== undefined && (issue.project_id ?? null) !== oldProjectId);
|
||||
|
||||
// A status change shifts two bucket totals (the column header counts).
|
||||
// patchIssueInBuckets does that surgically, but only when it can find the card
|
||||
|
||||
@@ -589,6 +589,7 @@ export function useRealtimeSync(
|
||||
onIssueUpdated(qc, wsId, issue, {
|
||||
assigneeChanged: payload.assignee_changed,
|
||||
statusChanged: payload.status_changed,
|
||||
projectChanged: payload.project_changed,
|
||||
});
|
||||
if (issue.status) {
|
||||
onInboxIssueStatusChanged(qc, wsId, issue.id, issue.status);
|
||||
|
||||
@@ -98,10 +98,14 @@ export interface IssueUpdatedPayload {
|
||||
// (server/internal/handler/issue.go publish). assignee_changed lets the
|
||||
// realtime layer keep filtered myList caches in place on a non-membership
|
||||
// change instead of refetching; status_changed lets it reconcile board column
|
||||
// counts when a status change lands on an off-screen (unloaded) issue. Other
|
||||
// change flags are present on the wire too and can be surfaced here when needed.
|
||||
// counts when a status change lands on an off-screen (unloaded) issue;
|
||||
// project_changed lets it drop a moved issue from the old project's filtered
|
||||
// list (the client-side cache diff is unreliable after an optimistic local
|
||||
// move — MUL-3669 / #4548). Other change flags are present on the wire too and
|
||||
// can be surfaced here when needed.
|
||||
assignee_changed?: boolean;
|
||||
status_changed?: boolean;
|
||||
project_changed?: boolean;
|
||||
}
|
||||
|
||||
export interface IssueDeletedPayload {
|
||||
|
||||
@@ -2531,6 +2531,11 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
(prevIssue.AssigneeType.String != issue.AssigneeType.String || uuidToString(prevIssue.AssigneeID) != uuidToString(issue.AssigneeID))
|
||||
statusChanged := req.Status != nil && prevIssue.Status != issue.Status
|
||||
priorityChanged := req.Priority != nil && prevIssue.Priority != issue.Priority
|
||||
// project_changed gates the client's per-project issue-list refetch the way
|
||||
// status/assignee flags gate theirs. Without it the client must diff
|
||||
// project_id against its own cache, which breaks once an optimistic local
|
||||
// move has overwritten the cached value (MUL-3669 / #4548).
|
||||
projectChanged := req.ProjectID != nil && uuidToString(prevIssue.ProjectID) != uuidToString(issue.ProjectID)
|
||||
descriptionChanged := req.Description != nil && textToPtr(prevIssue.Description) != resp.Description
|
||||
titleChanged := req.Title != nil && prevIssue.Title != issue.Title
|
||||
prevStartDate := dateToPtr(prevIssue.StartDate)
|
||||
@@ -2548,6 +2553,7 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
"assignee_changed": assigneeChanged,
|
||||
"status_changed": statusChanged,
|
||||
"priority_changed": priorityChanged,
|
||||
"project_changed": projectChanged,
|
||||
"start_date_changed": startDateChanged,
|
||||
"due_date_changed": dueDateChanged,
|
||||
"description_changed": descriptionChanged,
|
||||
@@ -3053,12 +3059,14 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
|
||||
(prevIssue.AssigneeType.String != issue.AssigneeType.String || uuidToString(prevIssue.AssigneeID) != uuidToString(issue.AssigneeID))
|
||||
statusChanged := req.Updates.Status != nil && prevIssue.Status != issue.Status
|
||||
priorityChanged := req.Updates.Priority != nil && prevIssue.Priority != issue.Priority
|
||||
projectChanged := req.Updates.ProjectID != nil && uuidToString(prevIssue.ProjectID) != uuidToString(issue.ProjectID)
|
||||
|
||||
h.publish(protocol.EventIssueUpdated, workspaceID, actorType, actorID, map[string]any{
|
||||
"issue": resp,
|
||||
"assignee_changed": assigneeChanged,
|
||||
"status_changed": statusChanged,
|
||||
"priority_changed": priorityChanged,
|
||||
"project_changed": projectChanged,
|
||||
})
|
||||
|
||||
if assigneeChanged {
|
||||
|
||||
Reference in New Issue
Block a user