FN-7321: refresh branch group details from task events
Refresh branch group detail views when task lifecycle events change member state. - Add shared branch group SSE helpers for task lifecycle refresh events and project filtering. - Subscribe branch group cards and modals to created, moved, updated, deleted, merged, and reconnect refreshes without resetting local view state. - Cover live refresh behavior and add a patch changeset for the published CLI bundle. Files changed: .changeset/refresh-branch-group-detail-live.md | 7 ++ .../dashboard/app/components/BranchGroupCard.tsx | 33 +++-- .../dashboard/app/components/GroupTaskModal.tsx | 40 +++--- .../components/__tests__/BranchGroupCard.test.tsx | 137 ++++++++++++++++++++- .../components/__tests__/GroupTaskModal.test.tsx | 58 ++++++++- packages/dashboard/app/utils/branchGroupSse.ts | 19 +++ 6 files changed, 262 insertions(+), 32 deletions(-) Fusion-Task-Id: FN-7321 Fusion-Task-Lineage: 015ba35b-c449-4e5c-a398-83a442ad360d Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/refresh-branch-group-detail-live.md
Normal file
7
.changeset/refresh-branch-group-detail-live.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Refresh branch group task-detail completion live as member tasks change.
|
||||
category: fix
|
||||
dev: Refetches branch group summaries on task lifecycle SSE events and reconnect.
|
||||
@@ -1,10 +1,11 @@
|
||||
import "./BranchGroupCard.css";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, ChevronDown, ChevronRight, CircleDashed, ExternalLink, GitBranch, GitPullRequest, Loader2 } from "lucide-react";
|
||||
import type { BranchGroupSummary } from "../api";
|
||||
import { apiAbandonBranchGroup, apiGetBranchGroup, apiPromoteBranchGroup } from "../api";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
import { BRANCH_GROUP_REFRESH_TASK_EVENTS, shouldRefreshBranchGroupForTaskEvent } from "../utils/branchGroupSse";
|
||||
|
||||
interface BranchGroupCardProps {
|
||||
groupId: string;
|
||||
@@ -37,25 +38,35 @@ export function BranchGroupCard({ groupId, projectId }: BranchGroupCardProps) {
|
||||
}
|
||||
}, [groupId, projectId, t]);
|
||||
|
||||
const loadGroupRef = useRef(loadGroup);
|
||||
|
||||
useEffect(() => {
|
||||
loadGroupRef.current = loadGroup;
|
||||
}, [loadGroup]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
void loadGroup();
|
||||
}, [loadGroup]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||
const refreshFromCurrentGroup = (event?: MessageEvent) => {
|
||||
if (event && !shouldRefreshBranchGroupForTaskEvent(event, projectId)) {
|
||||
return;
|
||||
}
|
||||
void loadGroupRef.current();
|
||||
};
|
||||
/*
|
||||
FNXC:BranchGroupDetails 2026-06-30-18:04:
|
||||
Task-detail branch group refreshes use the current loader callback so live SSE updates do not reset the local collapsed/expanded state.
|
||||
*/
|
||||
const events = Object.fromEntries(BRANCH_GROUP_REFRESH_TASK_EVENTS.map((eventName) => [eventName, refreshFromCurrentGroup]));
|
||||
return subscribeSse(`/api/events${query}`, {
|
||||
events: {
|
||||
"task:updated": () => {
|
||||
void loadGroup();
|
||||
},
|
||||
},
|
||||
onReconnect: () => {
|
||||
void loadGroup();
|
||||
},
|
||||
events,
|
||||
onReconnect: () => refreshFromCurrentGroup(),
|
||||
});
|
||||
}, [loadGroup, projectId]);
|
||||
}, [projectId]);
|
||||
|
||||
const completionText = useMemo(() => {
|
||||
if (!group) return "";
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import "./GroupTaskModal.css";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, CircleDashed, ExternalLink, Loader2, X } from "lucide-react";
|
||||
import { apiAbandonBranchGroup, apiGetBranchGroup, apiPromoteBranchGroup, type BranchGroupSummary } from "../api";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
import { BRANCH_GROUP_REFRESH_TASK_EVENTS, shouldRefreshBranchGroupForTaskEvent } from "../utils/branchGroupSse";
|
||||
|
||||
interface GroupTaskModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -35,6 +36,12 @@ export function GroupTaskModal({ isOpen, onClose, groupId, projectId, onOpenMemb
|
||||
}
|
||||
}, [groupId, projectId]);
|
||||
|
||||
const loadGroupRef = useRef(loadGroup);
|
||||
|
||||
useEffect(() => {
|
||||
loadGroupRef.current = loadGroup;
|
||||
}, [loadGroup]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !groupId) return;
|
||||
void loadGroup();
|
||||
@@ -43,25 +50,22 @@ export function GroupTaskModal({ isOpen, onClose, groupId, projectId, onOpenMemb
|
||||
useEffect(() => {
|
||||
if (!isOpen || !groupId) return;
|
||||
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||
const refreshFromCurrentGroup = (event?: MessageEvent) => {
|
||||
if (event && !shouldRefreshBranchGroupForTaskEvent(event, projectId)) {
|
||||
return;
|
||||
}
|
||||
void loadGroupRef.current();
|
||||
};
|
||||
/*
|
||||
FNXC:BranchGroupDetails 2026-06-30-18:04:
|
||||
The full branch group modal shares task-derived completion state with task details, so it listens to the same lifecycle SSE set while preserving its open/closed guard.
|
||||
*/
|
||||
const events = Object.fromEntries(BRANCH_GROUP_REFRESH_TASK_EVENTS.map((eventName) => [eventName, refreshFromCurrentGroup]));
|
||||
return subscribeSse(`/api/events${query}`, {
|
||||
events: {
|
||||
"task:updated": (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as { projectId?: string };
|
||||
if (projectId && payload.projectId && payload.projectId !== projectId) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
void loadGroup();
|
||||
},
|
||||
},
|
||||
onReconnect: () => {
|
||||
void loadGroup();
|
||||
},
|
||||
events,
|
||||
onReconnect: () => refreshFromCurrentGroup(),
|
||||
});
|
||||
}, [groupId, isOpen, loadGroup, projectId]);
|
||||
}, [groupId, isOpen, projectId]);
|
||||
|
||||
const completionText = useMemo(() => {
|
||||
if (!group) return "";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { BranchGroupCard } from "../BranchGroupCard";
|
||||
import { loadAllAppCssBaseOnly } from "../../test/cssFixture";
|
||||
|
||||
@@ -8,6 +8,16 @@ const apiGetBranchGroup = vi.fn();
|
||||
const apiPromoteBranchGroup = vi.fn();
|
||||
const apiAbandonBranchGroup = vi.fn();
|
||||
|
||||
type SseSubscription = {
|
||||
url: string;
|
||||
options: {
|
||||
events?: Record<string, (event: MessageEvent) => void>;
|
||||
onReconnect?: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
const sseSubscriptions: SseSubscription[] = [];
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
apiGetBranchGroup: (...args: unknown[]) => apiGetBranchGroup(...args),
|
||||
apiPromoteBranchGroup: (...args: unknown[]) => apiPromoteBranchGroup(...args),
|
||||
@@ -15,7 +25,10 @@ vi.mock("../../api", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../sse-bus", () => ({
|
||||
subscribeSse: () => () => {},
|
||||
subscribeSse: (url: string, options: SseSubscription["options"]) => {
|
||||
sseSubscriptions.push({ url, options });
|
||||
return () => {};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
@@ -61,6 +74,7 @@ describe("BranchGroupCard", () => {
|
||||
apiGetBranchGroup.mockReset();
|
||||
apiPromoteBranchGroup.mockReset();
|
||||
apiAbandonBranchGroup.mockReset();
|
||||
sseSubscriptions.length = 0;
|
||||
});
|
||||
|
||||
it("hides promote control while incomplete", async () => {
|
||||
@@ -193,6 +207,125 @@ describe("BranchGroupCard", () => {
|
||||
expect(screen.getByRole("button", { name: /open pr/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
it("refetches on task:moved and updates completion, progress, member landed state, and completion-gated actions", async () => {
|
||||
apiGetBranchGroup
|
||||
.mockResolvedValueOnce({ group: makeGroup() })
|
||||
.mockResolvedValueOnce({
|
||||
group: makeGroup({
|
||||
completion: { landed: 2, total: 2, complete: true },
|
||||
members: [
|
||||
{ taskId: "FN-1", title: "one", column: "done", landed: true },
|
||||
{ taskId: "FN-2", title: "two", column: "done", landed: true },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
render(<BranchGroupCard groupId="BG-1" />);
|
||||
await screen.findByText("1 of 2 members finished");
|
||||
await expandBranchGroup();
|
||||
|
||||
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "1");
|
||||
expect(screen.queryByRole("button", { name: /open pr/i })).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
sseSubscriptions.at(-1)?.options.events?.["task:moved"]?.(new MessageEvent("task:moved", { data: JSON.stringify({ task: { id: "FN-2" } }) }));
|
||||
});
|
||||
|
||||
expect(await screen.findByText("2 of 2 members finished")).toBeInTheDocument();
|
||||
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "2");
|
||||
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuemax", "2");
|
||||
expect(screen.getByText("FN-2 · two").closest("li")?.querySelector(".status-dot")).toHaveClass("status-dot--online");
|
||||
expect(screen.getByRole("button", { name: /open pr/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refetches on membership lifecycle events and updates member rows without remounting", async () => {
|
||||
apiGetBranchGroup
|
||||
.mockResolvedValueOnce({ group: makeGroup() })
|
||||
.mockResolvedValueOnce({
|
||||
group: makeGroup({
|
||||
completion: { landed: 1, total: 1, complete: true },
|
||||
members: [{ taskId: "FN-1", title: "one", column: "done", landed: true }],
|
||||
}),
|
||||
});
|
||||
|
||||
render(<BranchGroupCard groupId="BG-1" projectId="proj-a" />);
|
||||
await screen.findByText("1 of 2 members finished");
|
||||
await expandBranchGroup();
|
||||
expect(screen.getByText("FN-2 · two")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
sseSubscriptions.at(-1)?.options.events?.["task:deleted"]?.(new MessageEvent("task:deleted", { data: JSON.stringify({ id: "FN-2", projectId: "proj-a" }) }));
|
||||
});
|
||||
|
||||
expect(await screen.findByText("1 of 1 members finished")).toBeInTheDocument();
|
||||
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuemax", "1");
|
||||
expect(screen.queryByText("FN-2 · two")).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores task lifecycle events for another project when payloads include a project id", async () => {
|
||||
apiGetBranchGroup.mockResolvedValue({ group: makeGroup() });
|
||||
|
||||
render(<BranchGroupCard groupId="BG-1" projectId="proj-a" />);
|
||||
await screen.findByText("1 of 2 members finished");
|
||||
|
||||
await act(async () => {
|
||||
sseSubscriptions.at(-1)?.options.events?.["task:updated"]?.(new MessageEvent("task:updated", { data: JSON.stringify({ id: "FN-2", projectId: "proj-b" }) }));
|
||||
});
|
||||
|
||||
expect(apiGetBranchGroup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refetches on reconnect", async () => {
|
||||
apiGetBranchGroup
|
||||
.mockResolvedValueOnce({ group: makeGroup() })
|
||||
.mockResolvedValueOnce({ group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: completeMembers }) });
|
||||
|
||||
render(<BranchGroupCard groupId="BG-1" />);
|
||||
await screen.findByText("1 of 2 members finished");
|
||||
|
||||
await act(async () => {
|
||||
sseSubscriptions.at(-1)?.options.onReconnect?.();
|
||||
});
|
||||
|
||||
expect(await screen.findByText("2 of 2 members finished")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("preserves user expansion state across live refreshes", async () => {
|
||||
apiGetBranchGroup
|
||||
.mockResolvedValueOnce({ group: makeGroup() })
|
||||
.mockResolvedValueOnce({ group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: completeMembers }) })
|
||||
.mockResolvedValueOnce({ group: makeGroup() })
|
||||
.mockResolvedValueOnce({ group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: completeMembers }) });
|
||||
|
||||
const { unmount } = render(<BranchGroupCard groupId="BG-1" />);
|
||||
await screen.findByText("1 of 2 members finished");
|
||||
await expandBranchGroup();
|
||||
|
||||
await act(async () => {
|
||||
sseSubscriptions.at(-1)?.options.events?.["task:updated"]?.(new MessageEvent("task:updated", { data: "{}" }));
|
||||
});
|
||||
|
||||
expect(await screen.findByText("2 of 2 members finished")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /collapse branch group/i })).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByText("FN-1 · one")).toBeInTheDocument();
|
||||
|
||||
unmount();
|
||||
sseSubscriptions.length = 0;
|
||||
|
||||
render(<BranchGroupCard groupId="BG-1" />);
|
||||
await screen.findByText("1 of 2 members finished");
|
||||
expect(screen.getByRole("button", { name: /expand branch group/i })).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
await act(async () => {
|
||||
sseSubscriptions.at(-1)?.options.events?.["task:moved"]?.(new MessageEvent("task:moved", { data: "{}" }));
|
||||
});
|
||||
|
||||
expect(await screen.findByText("2 of 2 members finished")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /expand branch group/i })).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByText("FN-1 · one")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps collapsed branch-group styling tokenized and compact", () => {
|
||||
const css = loadAllAppCssBaseOnly();
|
||||
const collapsedBlock = css.match(/\.branch-group-card--collapsed\s*\{(?<block>[^}]*)\}/)?.groups?.block ?? "";
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { GroupTaskModal } from "../GroupTaskModal";
|
||||
import { apiGetBranchGroup, apiPromoteBranchGroup, apiAbandonBranchGroup } from "../../api";
|
||||
|
||||
type SseSubscription = {
|
||||
url: string;
|
||||
options: {
|
||||
events?: Record<string, (event: MessageEvent) => void>;
|
||||
onReconnect?: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
const sseSubscriptions: SseSubscription[] = [];
|
||||
|
||||
vi.mock("../../api", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../api")>("../../api");
|
||||
return {
|
||||
@@ -18,6 +28,13 @@ vi.mock("../../hooks/useNavigationHistory", () => ({
|
||||
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("../../sse-bus", () => ({
|
||||
subscribeSse: (url: string, options: SseSubscription["options"]) => {
|
||||
sseSubscriptions.push({ url, options });
|
||||
return () => {};
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedGet = vi.mocked(apiGetBranchGroup);
|
||||
const mockedPromote = vi.mocked(apiPromoteBranchGroup);
|
||||
const mockedAbandon = vi.mocked(apiAbandonBranchGroup);
|
||||
@@ -50,6 +67,7 @@ describe("GroupTaskModal", () => {
|
||||
mockedPromote.mockReset();
|
||||
mockedGet.mockReset();
|
||||
mockedAbandon.mockReset();
|
||||
sseSubscriptions.length = 0;
|
||||
});
|
||||
|
||||
it("renders group summary and member open action", async () => {
|
||||
@@ -147,6 +165,44 @@ describe("GroupTaskModal", () => {
|
||||
expect(screen.queryByRole("button", { name: /open pr|merge group into main/i })).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
it("refetches on task:moved and updates completion-gated actions in place", async () => {
|
||||
mockedGet
|
||||
.mockResolvedValueOnce({ group: makeGroup() } as Awaited<ReturnType<typeof apiGetBranchGroup>>)
|
||||
.mockResolvedValueOnce({
|
||||
group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: completeMembers }),
|
||||
} as Awaited<ReturnType<typeof apiGetBranchGroup>>);
|
||||
|
||||
render(<GroupTaskModal isOpen onClose={vi.fn()} groupId="BG-1" onOpenMemberTask={vi.fn()} />);
|
||||
await screen.findByText("1 of 2 members finished");
|
||||
expect(screen.queryByRole("button", { name: /open pr/i })).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
sseSubscriptions.at(-1)?.options.events?.["task:moved"]?.(new MessageEvent("task:moved", { data: JSON.stringify({ task: { id: "FN-2" } }) }));
|
||||
});
|
||||
|
||||
expect(await screen.findByText("2 of 2 members finished")).toBeDefined();
|
||||
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "2");
|
||||
expect(screen.getByRole("button", { name: /open pr/i })).toBeDefined();
|
||||
});
|
||||
|
||||
it("refetches the open group modal on reconnect", async () => {
|
||||
mockedGet
|
||||
.mockResolvedValueOnce({ group: makeGroup() } as Awaited<ReturnType<typeof apiGetBranchGroup>>)
|
||||
.mockResolvedValueOnce({
|
||||
group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: completeMembers }),
|
||||
} as Awaited<ReturnType<typeof apiGetBranchGroup>>);
|
||||
|
||||
render(<GroupTaskModal isOpen onClose={vi.fn()} groupId="BG-1" onOpenMemberTask={vi.fn()} />);
|
||||
await screen.findByText("1 of 2 members finished");
|
||||
|
||||
await act(async () => {
|
||||
sseSubscriptions.at(-1)?.options.onReconnect?.();
|
||||
});
|
||||
|
||||
expect(await screen.findByText("2 of 2 members finished")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows terminal state and hides controls when merged", async () => {
|
||||
mockedGet.mockResolvedValue({
|
||||
group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: completeMembers, prState: "merged", prNumber: 3, prUrl: "https://github.com/org/repo/pull/3" }),
|
||||
|
||||
19
packages/dashboard/app/utils/branchGroupSse.ts
Normal file
19
packages/dashboard/app/utils/branchGroupSse.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
FNXC:BranchGroupDetails 2026-06-30-18:04:
|
||||
Branch group summaries derive membership and landed completion from tasks, so dashboard subscribers refetch on each task lifecycle SSE event that can change those values.
|
||||
*/
|
||||
export const BRANCH_GROUP_REFRESH_TASK_EVENTS = ["task:created", "task:moved", "task:updated", "task:deleted", "task:merged"] as const;
|
||||
|
||||
export type BranchGroupRefreshTaskEvent = typeof BRANCH_GROUP_REFRESH_TASK_EVENTS[number];
|
||||
|
||||
export function shouldRefreshBranchGroupForTaskEvent(event: MessageEvent, projectId?: string): boolean {
|
||||
if (!projectId) return true;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as { projectId?: string; task?: { projectId?: string } };
|
||||
const payloadProjectId = payload.projectId ?? payload.task?.projectId;
|
||||
return !payloadProjectId || payloadProjectId === projectId;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user