Move Reliability from standalone dashboard navigation into Command Center while preserving legacy entry points. - Remove the top-level Reliability view from desktop and mobile navigation. - Add Reliability as a Command Center subtab backed by the existing ReliabilityView. - Migrate persisted and linked legacy reliability view values to Command Center. - Update docs and regression coverage for the new navigation surface. Files changed: AGENTS.md | 3 +- docs/dashboard-guide.md | 9 ++-- packages/dashboard/app/App.tsx | 12 ----- .../app/__tests__/lazy-loaded-views-docs.test.ts | 11 +++-- .../mobile-feature-access-regression.test.tsx | 17 ++----- packages/dashboard/app/components/Header.tsx | 14 +----- packages/dashboard/app/components/MobileNavBar.tsx | 11 ----- .../components/command-center/CommandCenter.tsx | 8 ++++ .../__tests__/CommandCenter.test.tsx | 54 +++++++++++++++++++++- .../app/hooks/__tests__/useViewState.test.ts | 44 ++++++++++++++++++ packages/dashboard/app/hooks/useViewState.ts | 23 +++++++-- packages/i18n/locales/en/app.json | 1 + 12 files changed, 141 insertions(+), 66 deletions(-) Fusion-Task-Id: FN-6702 Fusion-Task-Lineage: 2d1ca07c-9619-438d-b09e-73fe124a5ffb
581 lines
17 KiB
TypeScript
581 lines
17 KiB
TypeScript
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { useViewState } from "../useViewState";
|
|
import * as pluginViewRegistry from "../../plugins/pluginViewRegistry";
|
|
import type { ProjectInfo } from "../../api";
|
|
import type { ThemeMode } from "@fusion/core";
|
|
|
|
const PROJECT: ProjectInfo = {
|
|
id: "proj_123",
|
|
name: "Demo Project",
|
|
path: "/demo",
|
|
status: "active",
|
|
isolationMode: "in-process",
|
|
createdAt: "",
|
|
updatedAt: "",
|
|
};
|
|
|
|
function createOptions(overrides: Partial<Parameters<typeof useViewState>[0]> = {}): Parameters<typeof useViewState>[0] {
|
|
return {
|
|
projectsLoading: false,
|
|
projectsError: null,
|
|
currentProjectLoading: false,
|
|
currentProject: null,
|
|
projectsLength: 1,
|
|
setupWizardOpen: false,
|
|
openSetupWizard: vi.fn(),
|
|
themeMode: "dark",
|
|
setThemeMode: vi.fn(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("useViewState", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
localStorage.clear();
|
|
vi.spyOn(pluginViewRegistry, "isPluginViewRegistered").mockImplementation(() => false);
|
|
});
|
|
|
|
it("returns default viewMode and taskView when no localStorage exists", async () => {
|
|
const { result } = renderHook(() => useViewState(createOptions()));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.viewMode).toBe("overview");
|
|
expect(result.current.taskView).toBe("board");
|
|
});
|
|
});
|
|
|
|
it("reads saved viewMode from localStorage on init", async () => {
|
|
localStorage.setItem("kb-dashboard-view-mode", "project");
|
|
|
|
const { result } = renderHook(() => useViewState(createOptions()));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.viewMode).toBe("project");
|
|
});
|
|
});
|
|
|
|
it("reads saved taskView from localStorage on init", async () => {
|
|
localStorage.setItem("kb-dashboard-task-view", "list");
|
|
|
|
const { result } = renderHook(() => useViewState(createOptions()));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.taskView).toBe("list");
|
|
});
|
|
});
|
|
|
|
it("migrates legacy reliability taskView from localStorage to Command Center", async () => {
|
|
localStorage.setItem("kb-dashboard-task-view", "reliability");
|
|
|
|
const { result } = renderHook(() => useViewState(createOptions()));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.taskView).toBe("command-center");
|
|
});
|
|
expect(localStorage.getItem("kb-dashboard-task-view")).toBe("command-center");
|
|
});
|
|
|
|
it("migrates legacy reliability URL param to Command Center", async () => {
|
|
const originalUrl = `${window.location.pathname}${window.location.search}`;
|
|
window.history.replaceState({}, "", "?view=reliability");
|
|
|
|
try {
|
|
const { result } = renderHook(() => useViewState(createOptions()));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.taskView).toBe("command-center");
|
|
});
|
|
expect(localStorage.getItem("kb-dashboard-task-view")).toBe("command-center");
|
|
} finally {
|
|
window.history.replaceState({}, "", originalUrl || "/");
|
|
}
|
|
});
|
|
|
|
it("migrates legacy roadmaps state to plugin view when registered", async () => {
|
|
vi.spyOn(pluginViewRegistry, "isPluginViewRegistered").mockReturnValue(true);
|
|
localStorage.setItem("kb-dashboard-task-view", "roadmaps");
|
|
|
|
const { result } = renderHook(() => useViewState(createOptions()));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.taskView).toBe("plugin:fusion-plugin-roadmap:roadmaps");
|
|
});
|
|
});
|
|
|
|
it("falls back to board for legacy roadmaps state when plugin is unavailable", async () => {
|
|
vi.spyOn(pluginViewRegistry, "isPluginViewRegistered").mockReturnValue(false);
|
|
localStorage.setItem("kb-dashboard-task-view", "roadmaps");
|
|
|
|
const { result } = renderHook(() => useViewState(createOptions()));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.taskView).toBe("board");
|
|
});
|
|
});
|
|
|
|
it("persists viewMode changes to localStorage", async () => {
|
|
const { result } = renderHook(() => useViewState(createOptions()));
|
|
|
|
await act(async () => {
|
|
result.current.setViewMode("project");
|
|
});
|
|
|
|
expect(localStorage.getItem("kb-dashboard-view-mode")).toBe("project");
|
|
});
|
|
|
|
it("persists taskView changes to localStorage", async () => {
|
|
const { result } = renderHook(() => useViewState(createOptions()));
|
|
|
|
await act(async () => {
|
|
result.current.setTaskView("list");
|
|
});
|
|
|
|
expect(localStorage.getItem("kb-dashboard-task-view")).toBe("list");
|
|
});
|
|
|
|
it("handleChangeTaskView updates taskView state", async () => {
|
|
const { result } = renderHook(() => useViewState(createOptions()));
|
|
|
|
await act(async () => {
|
|
result.current.handleChangeTaskView("agents");
|
|
});
|
|
|
|
expect(result.current.taskView).toBe("agents");
|
|
});
|
|
|
|
it("handleToggleTheme cycles dark → light → system → dark", async () => {
|
|
let themeMode: ThemeMode = "dark";
|
|
const setThemeMode = vi.fn((mode: ThemeMode) => {
|
|
themeMode = mode;
|
|
});
|
|
|
|
const { result, rerender } = renderHook(() =>
|
|
useViewState(
|
|
createOptions({
|
|
themeMode,
|
|
setThemeMode,
|
|
}),
|
|
),
|
|
);
|
|
|
|
await act(async () => {
|
|
result.current.handleToggleTheme();
|
|
});
|
|
expect(setThemeMode).toHaveBeenLastCalledWith("light");
|
|
|
|
rerender();
|
|
await act(async () => {
|
|
result.current.handleToggleTheme();
|
|
});
|
|
expect(setThemeMode).toHaveBeenLastCalledWith("system");
|
|
|
|
rerender();
|
|
await act(async () => {
|
|
result.current.handleToggleTheme();
|
|
});
|
|
expect(setThemeMode).toHaveBeenLastCalledWith("dark");
|
|
});
|
|
|
|
it("syncs viewMode to project when currentProject is restored after loading", async () => {
|
|
localStorage.setItem("kb-dashboard-view-mode", "overview");
|
|
|
|
const { result } = renderHook(() =>
|
|
useViewState(
|
|
createOptions({
|
|
currentProject: PROJECT,
|
|
projectsLength: 1,
|
|
}),
|
|
),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.viewMode).toBe("project");
|
|
});
|
|
});
|
|
|
|
it("calls openSetupWizard when no projects and no current project after loading", async () => {
|
|
vi.useFakeTimers();
|
|
const openSetupWizard = vi.fn();
|
|
|
|
renderHook(() =>
|
|
useViewState(
|
|
createOptions({
|
|
projectsLength: 0,
|
|
currentProject: null,
|
|
openSetupWizard,
|
|
}),
|
|
),
|
|
);
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(500);
|
|
});
|
|
|
|
expect(openSetupWizard).toHaveBeenCalledTimes(1);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("does NOT call openSetupWizard when projects exist even if no current project selected", async () => {
|
|
vi.useFakeTimers();
|
|
const openSetupWizard = vi.fn();
|
|
|
|
renderHook(() =>
|
|
useViewState(
|
|
createOptions({
|
|
projectsLength: 3, // Projects exist
|
|
currentProject: null, // But none selected yet
|
|
openSetupWizard,
|
|
}),
|
|
),
|
|
);
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(500);
|
|
});
|
|
|
|
// Should NOT open setup wizard when projects already exist
|
|
// The dashboard should show overview mode to let user pick a project
|
|
expect(openSetupWizard).not.toHaveBeenCalled();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("does NOT call openSetupWizard when the initial projects fetch failed", async () => {
|
|
vi.useFakeTimers();
|
|
const openSetupWizard = vi.fn();
|
|
|
|
renderHook(() =>
|
|
useViewState(
|
|
createOptions({
|
|
projectsLength: 0,
|
|
currentProject: null,
|
|
projectsError: "Failed to fetch projects",
|
|
openSetupWizard,
|
|
}),
|
|
),
|
|
);
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(500);
|
|
});
|
|
|
|
expect(openSetupWizard).not.toHaveBeenCalled();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
// ── Insights view persistence ─────────────────────────────────────
|
|
|
|
it("reads saved insights taskView from scoped localStorage on init", async () => {
|
|
// Set up scoped storage for project
|
|
localStorage.setItem("kb:proj_123:kb-dashboard-task-view", "insights");
|
|
|
|
const { result } = renderHook(() =>
|
|
useViewState(
|
|
createOptions({
|
|
currentProject: PROJECT,
|
|
}),
|
|
),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.taskView).toBe("insights");
|
|
});
|
|
});
|
|
|
|
it("reads saved research taskView from scoped localStorage on init", async () => {
|
|
localStorage.setItem("kb:proj_123:kb-dashboard-task-view", "research");
|
|
|
|
const { result } = renderHook(() =>
|
|
useViewState(
|
|
createOptions({
|
|
currentProject: PROJECT,
|
|
}),
|
|
),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.taskView).toBe("research");
|
|
});
|
|
});
|
|
|
|
it("migrates legacy reliability taskView from scoped storage to Command Center", async () => {
|
|
localStorage.setItem("kb:proj_123:kb-dashboard-task-view", "reliability");
|
|
|
|
const { result } = renderHook(() =>
|
|
useViewState(
|
|
createOptions({
|
|
currentProject: PROJECT,
|
|
}),
|
|
),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.taskView).toBe("command-center");
|
|
});
|
|
expect(localStorage.getItem("kb:proj_123:kb-dashboard-task-view")).toBe("command-center");
|
|
});
|
|
|
|
it("persists insights taskView changes to scoped localStorage", async () => {
|
|
const { result } = renderHook(() =>
|
|
useViewState(
|
|
createOptions({
|
|
currentProject: PROJECT,
|
|
}),
|
|
),
|
|
);
|
|
|
|
await act(async () => {
|
|
result.current.setTaskView("insights");
|
|
});
|
|
|
|
expect(localStorage.getItem("kb:proj_123:kb-dashboard-task-view")).toBe("insights");
|
|
});
|
|
|
|
it("persists research taskView changes to scoped localStorage", async () => {
|
|
const { result } = renderHook(() =>
|
|
useViewState(
|
|
createOptions({
|
|
currentProject: PROJECT,
|
|
}),
|
|
),
|
|
);
|
|
|
|
await act(async () => {
|
|
result.current.setTaskView("research");
|
|
});
|
|
|
|
expect(localStorage.getItem("kb:proj_123:kb-dashboard-task-view")).toBe("research");
|
|
});
|
|
|
|
it("restores dev-server task view and normalizes legacy devserver values", async () => {
|
|
localStorage.setItem("kb:proj_123:kb-dashboard-task-view", "dev-server");
|
|
|
|
const { result, rerender } = renderHook(
|
|
({ project }) => useViewState(createOptions({ currentProject: project })),
|
|
{ initialProps: { project: PROJECT } },
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.taskView).toBe("dev-server");
|
|
});
|
|
|
|
await act(async () => {
|
|
result.current.setTaskView("dev-server");
|
|
});
|
|
|
|
expect(localStorage.getItem("kb:proj_123:kb-dashboard-task-view")).toBe("dev-server");
|
|
|
|
localStorage.setItem("kb:proj_123:kb-dashboard-task-view", "devserver");
|
|
rerender({ project: { ...PROJECT, id: "proj_legacy", name: "Legacy" } });
|
|
rerender({ project: PROJECT });
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.taskView).toBe("dev-server");
|
|
});
|
|
});
|
|
|
|
it("restores and persists graph taskView using scoped storage", async () => {
|
|
localStorage.setItem("kb:proj_123:kb-dashboard-task-view", "graph");
|
|
|
|
const { result } = renderHook(() =>
|
|
useViewState(
|
|
createOptions({
|
|
currentProject: PROJECT,
|
|
}),
|
|
),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.taskView).toBe("graph");
|
|
});
|
|
|
|
await act(async () => {
|
|
result.current.setTaskView("graph");
|
|
});
|
|
|
|
expect(localStorage.getItem("kb:proj_123:kb-dashboard-task-view")).toBe("graph");
|
|
});
|
|
|
|
it("hydrates secrets from ?view and persists scoped round-trip", async () => {
|
|
const originalSearch = window.location.search;
|
|
window.history.replaceState({}, "", "?view=secrets");
|
|
|
|
const { result } = renderHook(() =>
|
|
useViewState(
|
|
createOptions({
|
|
currentProject: PROJECT,
|
|
}),
|
|
),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.taskView).toBe("secrets");
|
|
});
|
|
|
|
expect(localStorage.getItem("kb:proj_123:kb-dashboard-task-view")).toBe("secrets");
|
|
window.history.replaceState({}, "", originalSearch ? `?${originalSearch.replace(/^\?/, "")}` : "/");
|
|
});
|
|
|
|
it("restores and persists plugin task views using the canonical composite key", async () => {
|
|
localStorage.setItem("kb:proj_123:kb-dashboard-task-view", "plugin:fusion-plugin-dependency-graph:graph");
|
|
|
|
const { result } = renderHook(() =>
|
|
useViewState(
|
|
createOptions({
|
|
currentProject: PROJECT,
|
|
}),
|
|
),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.taskView).toBe("plugin:fusion-plugin-dependency-graph:graph");
|
|
});
|
|
|
|
await act(async () => {
|
|
result.current.setTaskView("plugin:fusion-plugin-dependency-graph:graph");
|
|
});
|
|
|
|
expect(localStorage.getItem("kb:proj_123:kb-dashboard-task-view")).toBe("plugin:fusion-plugin-dependency-graph:graph");
|
|
});
|
|
|
|
it("rejects invalid plugin view IDs and falls back to board", async () => {
|
|
localStorage.setItem("kb:proj_123:kb-dashboard-task-view", "plugin:only-one-segment");
|
|
|
|
const { result } = renderHook(() =>
|
|
useViewState(
|
|
createOptions({
|
|
currentProject: PROJECT,
|
|
}),
|
|
),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.taskView).toBe("board");
|
|
});
|
|
});
|
|
|
|
it("round-trips between built-in and plugin task views", async () => {
|
|
localStorage.setItem("kb:proj_123:kb-dashboard-task-view", "board");
|
|
|
|
const { result } = renderHook(() =>
|
|
useViewState(
|
|
createOptions({
|
|
currentProject: PROJECT,
|
|
}),
|
|
),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.taskView).toBe("board");
|
|
});
|
|
|
|
await act(async () => {
|
|
result.current.handleChangeTaskView("plugin:fusion-plugin-dependency-graph:graph");
|
|
});
|
|
expect(result.current.taskView).toBe("plugin:fusion-plugin-dependency-graph:graph");
|
|
expect(localStorage.getItem("kb:proj_123:kb-dashboard-task-view")).toBe("plugin:fusion-plugin-dependency-graph:graph");
|
|
|
|
await act(async () => {
|
|
result.current.handleChangeTaskView("board");
|
|
});
|
|
expect(result.current.taskView).toBe("board");
|
|
expect(localStorage.getItem("kb:proj_123:kb-dashboard-task-view")).toBe("board");
|
|
});
|
|
|
|
it("restores legacy views (board/list/agents/missions/chat) from scoped storage", async () => {
|
|
const legacyViews = ["board", "list", "agents", "missions", "chat"] as const;
|
|
|
|
for (const view of legacyViews) {
|
|
localStorage.clear();
|
|
localStorage.setItem(`kb:proj_123:kb-dashboard-task-view`, view);
|
|
|
|
const { result } = renderHook(() =>
|
|
useViewState(
|
|
createOptions({
|
|
currentProject: PROJECT,
|
|
}),
|
|
),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.taskView).toBe(view);
|
|
});
|
|
}
|
|
});
|
|
|
|
// ── Project-switch scoped rehydration ─────────────────────────────
|
|
|
|
it("project A reads its own scoped task-view and project B reads its own", async () => {
|
|
const projectA: ProjectInfo = { ...PROJECT, id: "proj_a", name: "Project A" };
|
|
const projectB: ProjectInfo = { ...PROJECT, id: "proj_b", name: "Project B" };
|
|
|
|
// Set different views for each project
|
|
localStorage.setItem("kb:proj_a:kb-dashboard-task-view", "insights");
|
|
localStorage.setItem("kb:proj_b:kb-dashboard-task-view", "agents");
|
|
|
|
// Start with project A
|
|
const { result, rerender } = renderHook(
|
|
({ project }) => useViewState(createOptions({ currentProject: project })),
|
|
{ initialProps: { project: projectA } },
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.taskView).toBe("insights");
|
|
});
|
|
|
|
// Switch to project B
|
|
rerender({ project: projectB });
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.taskView).toBe("agents");
|
|
});
|
|
|
|
// Switch back to project A - should restore A's view
|
|
rerender({ project: projectA });
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.taskView).toBe("insights");
|
|
});
|
|
});
|
|
|
|
it("no cross-project bleed when switching projects", async () => {
|
|
const projectA: ProjectInfo = { ...PROJECT, id: "proj_a", name: "Project A" };
|
|
const projectB: ProjectInfo = { ...PROJECT, id: "proj_b", name: "Project B" };
|
|
|
|
// Only set view for project A, project B has no saved view
|
|
localStorage.setItem("kb:proj_a:kb-dashboard-task-view", "insights");
|
|
// Ensure project B has no scoped storage
|
|
localStorage.removeItem("kb:proj_b:kb-dashboard-task-view");
|
|
|
|
// Load project A
|
|
const { result: resultA } = renderHook(() =>
|
|
useViewState(
|
|
createOptions({
|
|
currentProject: projectA,
|
|
}),
|
|
),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(resultA.current.taskView).toBe("insights");
|
|
});
|
|
|
|
// Load project B (no saved view - should default to board)
|
|
const { result: resultB } = renderHook(() =>
|
|
useViewState(
|
|
createOptions({
|
|
currentProject: projectB,
|
|
}),
|
|
),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(resultB.current.taskView).toBe("board");
|
|
});
|
|
|
|
// Project A's view should still be insights (not affected by project B load)
|
|
expect(resultA.current.taskView).toBe("insights");
|
|
});
|
|
});
|