Migrate dashboard dialogs to the shared movable and resizable FloatingWindow contract. - Move core, workflow, Git, planning, and automation modal surfaces to stable floating-window identities with persisted geometry. - Suspend geometry and floating controls for phone and short-viewport sheets, including Quick Chat. - Add accessibility wiring, tablet touch targets, migration tests, and operator documentation. Files changed: .changeset/fn-8606-floating-window-core-modals.md | 7 + docs/dashboard-guide.md | 4 + packages/dashboard/app/App.tsx | 8 +- .../dashboard/app/components/ActivityLogModal.tsx | 30 ++-- packages/dashboard/app/components/AddNodeModal.tsx | 8 +- .../dashboard/app/components/ChangesDiffModal.tsx | 35 +++-- .../dashboard/app/components/ConnectNodeModal.tsx | 9 +- .../dashboard/app/components/FloatingWindow.css | 71 ++++++++- .../dashboard/app/components/FloatingWindow.tsx | 27 +++- .../dashboard/app/components/GitManagerModal.tsx | 30 +++- .../dashboard/app/components/GroupTaskModal.tsx | 8 +- .../app/components/ModelOnboardingModal.tsx | 28 ++-- .../dashboard/app/components/NodeDetailModal.tsx | 9 +- .../dashboard/app/components/PlanningModeModal.css | 1 - .../dashboard/app/components/PlanningModeModal.tsx | 57 +++---- .../app/components/ScheduledTasksModal.tsx | 9 +- packages/dashboard/app/components/ScriptsModal.css | 1 - packages/dashboard/app/components/ScriptsModal.tsx | 32 ++-- .../dashboard/app/components/SettingsModal.css | 1 - .../dashboard/app/components/SettingsModal.tsx | 58 ++++--- .../app/components/WorkflowAddStepModal.css | 15 -- .../app/components/WorkflowAddStepModal.tsx | 46 +++--- .../components/__tests__/ActivityLogModal.test.tsx | 33 ++-- .../app/components/__tests__/AddNodeModal.test.tsx | 12 +- .../components/__tests__/ChangesDiffModal.test.tsx | 78 +++++----- .../components/__tests__/ConnectNodeModal.test.tsx | 7 + .../components/__tests__/FloatingWindow.test.tsx | 173 ++++++++++++++++++++- .../components/__tests__/GitManagerModal.test.tsx | 30 ++-- .../components/__tests__/GroupTaskModal.test.tsx | 9 ++ .../__tests__/ModelOnboardingModal.test.tsx | 18 ++- .../components/__tests__/NodeDetailModal.test.tsx | 9 ++ .../__tests__/PlanningModeModal.autosize.test.tsx | 30 ++-- .../__tests__/ScheduledTasksModal.test.tsx | 21 ++- .../app/components/__tests__/ScriptsModal.test.tsx | 15 +- .../__tests__/SettingsModal.mobileClose.test.tsx | 25 ++- .../__tests__/WorkflowAddStepModal.test.tsx | 7 + .../floatingWindowMigration.test-helpers.ts | 126 +++++++++++++++ 37 files changed, 828 insertions(+), 259 deletions(-) Fusion-Task-Id: FN-8606 Fusion-Task-Lineage: dab0df2d-73f4-4b0f-bec3-a45016310c91 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
226 lines
9.7 KiB
TypeScript
226 lines
9.7 KiB
TypeScript
import { act, render, screen, waitFor } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { GroupTaskModal } from "../GroupTaskModal";
|
|
import { assertModalGeometryRecoveryAndSheetContracts, assertRenderedModalTouchGeometry } from "./floatingWindowMigration.test-helpers";
|
|
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 {
|
|
...actual,
|
|
apiGetBranchGroup: vi.fn(),
|
|
apiPromoteBranchGroup: vi.fn(),
|
|
apiAbandonBranchGroup: vi.fn(),
|
|
};
|
|
});
|
|
|
|
vi.mock("../../hooks/useNavigationHistory", () => ({
|
|
useNavigationHistory: () => ({ canGoBack: false, goBack: vi.fn() }),
|
|
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);
|
|
|
|
const completeMembers = [
|
|
{ taskId: "FN-1", title: "First", column: "done", landed: true },
|
|
{ taskId: "FN-2", title: "Second", column: "done", landed: true },
|
|
];
|
|
|
|
function makeGroup(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: "BG-1",
|
|
branchName: "feature/shared",
|
|
status: "open",
|
|
autoMerge: false,
|
|
prState: "none",
|
|
prUrl: null,
|
|
prNumber: null,
|
|
completion: { landed: 1, total: 2, complete: false },
|
|
members: [
|
|
{ taskId: "FN-1", title: "First", column: "done", landed: true },
|
|
{ taskId: "FN-2", title: "Second", column: "todo", landed: false },
|
|
],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("GroupTaskModal", () => {
|
|
beforeEach(() => {
|
|
mockedPromote.mockReset();
|
|
mockedGet.mockReset();
|
|
mockedAbandon.mockReset();
|
|
sseSubscriptions.length = 0;
|
|
});
|
|
|
|
it("renders group summary and member open action", async () => {
|
|
mockedGet.mockResolvedValue({ group: makeGroup() } as Awaited<ReturnType<typeof apiGetBranchGroup>>);
|
|
const onOpenMemberTask = vi.fn();
|
|
|
|
render(<GroupTaskModal isOpen onClose={vi.fn()} groupId="BG-1" onOpenMemberTask={onOpenMemberTask} />);
|
|
|
|
expect(await screen.findByText("feature/shared")).toBeDefined();
|
|
expect(screen.getByText("1 of 2 members finished")).toBeDefined();
|
|
await userEvent.click(screen.getAllByRole("button", { name: "Open task" })[0]);
|
|
expect(onOpenMemberTask).toHaveBeenCalledWith("FN-1");
|
|
});
|
|
|
|
it("uses the production group header for touch drag and resize", async () => {
|
|
mockedGet.mockResolvedValue({ group: makeGroup() } as Awaited<ReturnType<typeof apiGetBranchGroup>>);
|
|
render(<GroupTaskModal isOpen onClose={vi.fn()} groupId="BG-1" onOpenMemberTask={vi.fn()} />);
|
|
await screen.findByText("feature/shared");
|
|
assertRenderedModalTouchGeometry("group-task", screen.getByRole("heading", { name: "Branch Group BG-1" }).closest(".modal-header") as HTMLElement);
|
|
assertModalGeometryRecoveryAndSheetContracts("group-task", () => render(<GroupTaskModal isOpen onClose={vi.fn()} groupId="BG-1" onOpenMemberTask={vi.fn()} />));
|
|
});
|
|
|
|
it("hides promote controls until complete", async () => {
|
|
mockedGet.mockResolvedValue({ group: makeGroup() } 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|merge group into main/i })).toBeNull();
|
|
});
|
|
|
|
it("promotes when complete and auto-merge is off", async () => {
|
|
mockedGet
|
|
.mockResolvedValueOnce({
|
|
group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: [
|
|
{ taskId: "FN-1", title: "First", column: "done", landed: true },
|
|
{ taskId: "FN-2", title: "Second", column: "done", landed: true },
|
|
] }),
|
|
} as Awaited<ReturnType<typeof apiGetBranchGroup>>)
|
|
.mockResolvedValueOnce({
|
|
group: makeGroup({ completion: { landed: 2, total: 2, complete: true } }),
|
|
} as Awaited<ReturnType<typeof apiGetBranchGroup>>);
|
|
|
|
mockedPromote.mockResolvedValue({ ok: true } as Awaited<ReturnType<typeof apiPromoteBranchGroup>>);
|
|
|
|
render(<GroupTaskModal isOpen onClose={vi.fn()} groupId="BG-1" onOpenMemberTask={vi.fn()} />);
|
|
|
|
const action = await screen.findByRole("button", { name: /open pr/i });
|
|
await userEvent.click(action);
|
|
await waitFor(() => expect(mockedPromote).toHaveBeenCalledWith("BG-1", undefined));
|
|
});
|
|
|
|
it("renders tracked pr info when present", async () => {
|
|
mockedGet.mockResolvedValue({
|
|
group: makeGroup({
|
|
completion: { landed: 2, total: 2, complete: true },
|
|
prState: "open",
|
|
prUrl: "https://github.com/org/repo/pull/1",
|
|
prNumber: 1,
|
|
}),
|
|
} as Awaited<ReturnType<typeof apiGetBranchGroup>>);
|
|
|
|
render(<GroupTaskModal isOpen onClose={vi.fn()} groupId="BG-1" onOpenMemberTask={vi.fn()} />);
|
|
|
|
const link = await screen.findByRole("link", { name: /PR #1/i });
|
|
expect(link.getAttribute("href")).toContain("/pull/1");
|
|
expect(link.textContent).toContain("open");
|
|
});
|
|
|
|
it("abandons an open group PR", async () => {
|
|
mockedGet.mockResolvedValue({
|
|
group: makeGroup({ completion: { landed: 2, total: 2, complete: true }, members: completeMembers, prState: "open", prNumber: 2, prUrl: "https://github.com/org/repo/pull/2" }),
|
|
} as Awaited<ReturnType<typeof apiGetBranchGroup>>);
|
|
mockedAbandon.mockResolvedValue({ groupId: "BG-1", group: makeGroup({ status: "abandoned", prState: "closed" }) } as Awaited<ReturnType<typeof apiAbandonBranchGroup>>);
|
|
|
|
render(<GroupTaskModal isOpen onClose={vi.fn()} groupId="BG-1" onOpenMemberTask={vi.fn()} />);
|
|
|
|
const action = await screen.findByRole("button", { name: /abandon group/i });
|
|
await userEvent.click(action);
|
|
await waitFor(() => expect(mockedAbandon).toHaveBeenCalledWith("BG-1", undefined));
|
|
});
|
|
|
|
it("keeps Abandon reachable but hides promote when completion reverts while PR is open", async () => {
|
|
// Regression: completion can flip back to false (a member moves
|
|
// in-progress → todo) while the group PR is still open. Abandon must remain
|
|
// available so the user can close the PR; promote stays gated on completion.
|
|
mockedGet.mockResolvedValue({
|
|
group: makeGroup({
|
|
completion: { landed: 1, total: 2, complete: false },
|
|
members: [
|
|
{ taskId: "FN-1", title: "First", column: "done", landed: true },
|
|
{ taskId: "FN-2", title: "Second", column: "in-progress", landed: false },
|
|
],
|
|
prState: "open",
|
|
prNumber: 4,
|
|
prUrl: "https://github.com/org/repo/pull/4",
|
|
}),
|
|
} as Awaited<ReturnType<typeof apiGetBranchGroup>>);
|
|
|
|
render(<GroupTaskModal isOpen onClose={vi.fn()} groupId="BG-1" onOpenMemberTask={vi.fn()} />);
|
|
|
|
expect(await screen.findByRole("button", { name: /abandon group/i })).toBeDefined();
|
|
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" }),
|
|
} as Awaited<ReturnType<typeof apiGetBranchGroup>>);
|
|
|
|
render(<GroupTaskModal isOpen onClose={vi.fn()} groupId="BG-1" onOpenMemberTask={vi.fn()} />);
|
|
|
|
expect(await screen.findByText("Group PR merged")).toBeDefined();
|
|
expect(screen.queryByRole("button", { name: /open pr|merge group into main|abandon group/i })).toBeNull();
|
|
});
|
|
});
|