feat(FN-1238): add dashboard loading screen with animated loader component
- Create DashboardLoader component with pulsing Fusion logo and progress bar animation - Add CSS keyframe animations for smooth loading states - Gate initial App render with loader until dashboard data is ready - Add comprehensive tests for DashboardLoader component and App loading behavior - Update existing App tests to account for loader wrapper
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||||
import type { TaskDetail } from "@fusion/core";
|
import type { TaskDetail } from "@fusion/core";
|
||||||
import { Header, useViewportMode } from "./components/Header";
|
import { Header, useViewportMode } from "./components/Header";
|
||||||
import { Board } from "./components/Board";
|
import { Board } from "./components/Board";
|
||||||
@@ -8,10 +8,12 @@ import { AgentsView } from "./components/AgentsView";
|
|||||||
import { MissionManager } from "./components/MissionManager";
|
import { MissionManager } from "./components/MissionManager";
|
||||||
import { NodesView } from "./components/NodesView";
|
import { NodesView } from "./components/NodesView";
|
||||||
import { AppModals } from "./components/AppModals";
|
import { AppModals } from "./components/AppModals";
|
||||||
|
import { DashboardLoader, type DashboardLoaderStage } from "./components/DashboardLoader";
|
||||||
import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
|
import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
|
||||||
import { SessionNotificationBanner } from "./components/SessionNotificationBanner";
|
import { SessionNotificationBanner } from "./components/SessionNotificationBanner";
|
||||||
import { MobileNavBar } from "./components/MobileNavBar";
|
import { MobileNavBar } from "./components/MobileNavBar";
|
||||||
import { QuickChatFAB } from "./components/QuickChatFAB";
|
import { QuickChatFAB } from "./components/QuickChatFAB";
|
||||||
|
import { ToastContainer } from "./components/ToastContainer";
|
||||||
import { useBackgroundSessions } from "./hooks/useBackgroundSessions";
|
import { useBackgroundSessions } from "./hooks/useBackgroundSessions";
|
||||||
import { useTasks } from "./hooks/useTasks";
|
import { useTasks } from "./hooks/useTasks";
|
||||||
import { useProjects } from "./hooks/useProjects";
|
import { useProjects } from "./hooks/useProjects";
|
||||||
@@ -43,6 +45,32 @@ function AppInner() {
|
|||||||
currentProject ? { projectId: currentProject.id } : undefined
|
currentProject ? { projectId: currentProject.id } : undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
|
||||||
|
|
||||||
|
const loadingStage = useMemo<DashboardLoaderStage>(() => {
|
||||||
|
if (projectsLoading) return "projects";
|
||||||
|
if (currentProjectLoading) return "project";
|
||||||
|
return "tasks";
|
||||||
|
}, [projectsLoading, currentProjectLoading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialLoadComplete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projectsLoading || currentProjectLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settleTimer = window.setTimeout(() => {
|
||||||
|
setInitialLoadComplete(true);
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(settleTimer);
|
||||||
|
};
|
||||||
|
}, [initialLoadComplete, projectsLoading, currentProjectLoading]);
|
||||||
|
|
||||||
// Theme management
|
// Theme management
|
||||||
const { themeMode, colorTheme, setThemeMode, setColorTheme } = useTheme();
|
const { themeMode, colorTheme, setThemeMode, setColorTheme } = useTheme();
|
||||||
|
|
||||||
@@ -320,6 +348,15 @@ function AppInner() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!initialLoadComplete) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardLoader stage={loadingStage} />
|
||||||
|
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header
|
<Header
|
||||||
|
|||||||
161
packages/dashboard/app/components/DashboardLoader.test.tsx
Normal file
161
packages/dashboard/app/components/DashboardLoader.test.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { DashboardLoader } from "./DashboardLoader";
|
||||||
|
|
||||||
|
function getStep(label: string): HTMLElement {
|
||||||
|
const step = screen.getByText(label).closest("li");
|
||||||
|
if (!step) {
|
||||||
|
throw new Error(`Could not find step for label: ${label}`);
|
||||||
|
}
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DashboardLoader", () => {
|
||||||
|
it("renders projects stage with active first step and pending remaining steps", () => {
|
||||||
|
render(<DashboardLoader stage="projects" />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Fusion")).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(getStep("Loading projects").className).toContain("dashboard-loader__step--active");
|
||||||
|
expect(getStep("Selecting project").className).toContain("dashboard-loader__step--pending");
|
||||||
|
expect(getStep("Fetching tasks").className).toContain("dashboard-loader__step--pending");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks previous stages done when current stage is tasks", () => {
|
||||||
|
render(<DashboardLoader stage="tasks" />);
|
||||||
|
|
||||||
|
expect(getStep("Loading projects").className).toContain("dashboard-loader__step--done");
|
||||||
|
expect(getStep("Selecting project").className).toContain("dashboard-loader__step--done");
|
||||||
|
expect(getStep("Fetching tasks").className).toContain("dashboard-loader__step--active");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks all steps done when stage is ready", () => {
|
||||||
|
render(<DashboardLoader stage="ready" />);
|
||||||
|
|
||||||
|
expect(getStep("Loading projects").className).toContain("dashboard-loader__step--done");
|
||||||
|
expect(getStep("Selecting project").className).toContain("dashboard-loader__step--done");
|
||||||
|
expect(getStep("Fetching tasks").className).toContain("dashboard-loader__step--done");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("announces loading state for assistive technologies", () => {
|
||||||
|
render(<DashboardLoader stage="project" />);
|
||||||
|
|
||||||
|
const statusRegion = screen.getByRole("status", { name: "Loading Fusion dashboard" });
|
||||||
|
expect(statusRegion).toHaveAttribute("aria-live", "polite");
|
||||||
|
expect(screen.getByLabelText("Dashboard loading progress")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps stage visuals stable across all stages", () => {
|
||||||
|
const stages = ["projects", "project", "tasks", "ready"] as const;
|
||||||
|
|
||||||
|
const snapshots = stages.map((stage) => {
|
||||||
|
const { container, unmount } = render(<DashboardLoader stage={stage} />);
|
||||||
|
const steps = Array.from(container.querySelectorAll<HTMLElement>(".dashboard-loader__step")).map((step) => ({
|
||||||
|
className: step.className,
|
||||||
|
label: step.querySelector(".dashboard-loader__step-label")?.textContent,
|
||||||
|
iconText: step.querySelector(".dashboard-loader__step-icon")?.textContent?.trim() ?? "",
|
||||||
|
hasSpinner: Boolean(step.querySelector(".dashboard-loader__spinner")),
|
||||||
|
}));
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
return { stage, steps };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(snapshots).toMatchInlineSnapshot(`
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"stage": "projects",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"className": "dashboard-loader__step dashboard-loader__step--active",
|
||||||
|
"hasSpinner": true,
|
||||||
|
"iconText": "",
|
||||||
|
"label": "Loading projects",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"className": "dashboard-loader__step dashboard-loader__step--pending",
|
||||||
|
"hasSpinner": false,
|
||||||
|
"iconText": "•",
|
||||||
|
"label": "Selecting project",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"className": "dashboard-loader__step dashboard-loader__step--pending",
|
||||||
|
"hasSpinner": false,
|
||||||
|
"iconText": "•",
|
||||||
|
"label": "Fetching tasks",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "project",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"className": "dashboard-loader__step dashboard-loader__step--done",
|
||||||
|
"hasSpinner": false,
|
||||||
|
"iconText": "✓",
|
||||||
|
"label": "Loading projects",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"className": "dashboard-loader__step dashboard-loader__step--active",
|
||||||
|
"hasSpinner": true,
|
||||||
|
"iconText": "",
|
||||||
|
"label": "Selecting project",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"className": "dashboard-loader__step dashboard-loader__step--pending",
|
||||||
|
"hasSpinner": false,
|
||||||
|
"iconText": "•",
|
||||||
|
"label": "Fetching tasks",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "tasks",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"className": "dashboard-loader__step dashboard-loader__step--done",
|
||||||
|
"hasSpinner": false,
|
||||||
|
"iconText": "✓",
|
||||||
|
"label": "Loading projects",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"className": "dashboard-loader__step dashboard-loader__step--done",
|
||||||
|
"hasSpinner": false,
|
||||||
|
"iconText": "✓",
|
||||||
|
"label": "Selecting project",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"className": "dashboard-loader__step dashboard-loader__step--active",
|
||||||
|
"hasSpinner": true,
|
||||||
|
"iconText": "",
|
||||||
|
"label": "Fetching tasks",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stage": "ready",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"className": "dashboard-loader__step dashboard-loader__step--done",
|
||||||
|
"hasSpinner": false,
|
||||||
|
"iconText": "✓",
|
||||||
|
"label": "Loading projects",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"className": "dashboard-loader__step dashboard-loader__step--done",
|
||||||
|
"hasSpinner": false,
|
||||||
|
"iconText": "✓",
|
||||||
|
"label": "Selecting project",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"className": "dashboard-loader__step dashboard-loader__step--done",
|
||||||
|
"hasSpinner": false,
|
||||||
|
"iconText": "✓",
|
||||||
|
"label": "Fetching tasks",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
||||||
79
packages/dashboard/app/components/DashboardLoader.tsx
Normal file
79
packages/dashboard/app/components/DashboardLoader.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
export type DashboardLoaderStage = "projects" | "project" | "tasks" | "ready";
|
||||||
|
|
||||||
|
interface DashboardLoaderProps {
|
||||||
|
stage: DashboardLoaderStage;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoaderStep {
|
||||||
|
id: Exclude<DashboardLoaderStage, "ready">;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOADER_STEPS: LoaderStep[] = [
|
||||||
|
{ id: "projects", label: "Loading projects" },
|
||||||
|
{ id: "project", label: "Selecting project" },
|
||||||
|
{ id: "tasks", label: "Fetching tasks" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getStepState(stepId: LoaderStep["id"], stage: DashboardLoaderStage): "done" | "active" | "pending" {
|
||||||
|
if (stage === "ready") {
|
||||||
|
return "done";
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentStageIndex = LOADER_STEPS.findIndex((step) => step.id === stage);
|
||||||
|
const stepIndex = LOADER_STEPS.findIndex((step) => step.id === stepId);
|
||||||
|
|
||||||
|
if (stepIndex < currentStageIndex) {
|
||||||
|
return "done";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stepIndex === currentStageIndex) {
|
||||||
|
return "active";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardLoader({ stage }: DashboardLoaderProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="dashboard-loader"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label="Loading Fusion dashboard"
|
||||||
|
data-stage={stage}
|
||||||
|
>
|
||||||
|
<div className="dashboard-loader__content">
|
||||||
|
<h1 className="dashboard-loader__logo">Fusion</h1>
|
||||||
|
<p className="dashboard-loader__message">Initializing dashboard...</p>
|
||||||
|
|
||||||
|
<ol className="dashboard-loader__steps" aria-label="Dashboard loading progress">
|
||||||
|
{LOADER_STEPS.map((step) => {
|
||||||
|
const stepState = getStepState(step.id, stage);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={step.id}
|
||||||
|
className={`dashboard-loader__step dashboard-loader__step--${stepState}`}
|
||||||
|
data-testid={`dashboard-loader-step-${step.id}`}
|
||||||
|
>
|
||||||
|
<span className="dashboard-loader__step-icon" aria-hidden="true">
|
||||||
|
{stepState === "done" ? (
|
||||||
|
"✓"
|
||||||
|
) : stepState === "active" ? (
|
||||||
|
<Loader2 className="dashboard-loader__spinner animate-spin" size={14} />
|
||||||
|
) : (
|
||||||
|
"•"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="dashboard-loader__step-label">{step.label}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1330,10 +1330,11 @@ describe("App footer-safe project layout", () => {
|
|||||||
expect(fetchSettings).toHaveBeenCalled();
|
expect(fetchSettings).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wrapper should have project-content but NOT project-content--with-footer
|
await waitFor(() => {
|
||||||
const wrapper = document.querySelector(".project-content");
|
const wrapper = document.querySelector(".project-content");
|
||||||
expect(wrapper).toBeTruthy();
|
expect(wrapper).toBeTruthy();
|
||||||
expect(wrapper?.classList.contains("project-content--with-footer")).toBe(false);
|
expect(wrapper?.classList.contains("project-content--with-footer")).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds and removes footer class when switching between project and overview", async () => {
|
it("adds and removes footer class when switching between project and overview", async () => {
|
||||||
|
|||||||
@@ -2241,6 +2241,93 @@ body {
|
|||||||
.project-card--skeleton:nth-child(5) .project-skeleton--title { animation-delay: 0.4s; }
|
.project-card--skeleton:nth-child(5) .project-skeleton--title { animation-delay: 0.4s; }
|
||||||
.project-card--skeleton:nth-child(6) .project-skeleton--title { animation-delay: 0.5s; }
|
.project-card--skeleton:nth-child(6) .project-skeleton--title { animation-delay: 0.5s; }
|
||||||
|
|
||||||
|
/* --- Dashboard Loader --- */
|
||||||
|
@keyframes dashboard-loader-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-loader {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-xl);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-loader__content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-md);
|
||||||
|
min-width: min(100%, 320px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-loader__logo {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
color: var(--text);
|
||||||
|
animation: dashboard-loader-pulse 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-loader__message {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-loader__steps {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-loader__step {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-loader__step-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 16px;
|
||||||
|
min-width: 16px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-loader__step--done {
|
||||||
|
color: var(--success, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-loader__step--active {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-loader__step--pending {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-loader__spinner {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
/* === ActivityFeed Component === */
|
/* === ActivityFeed Component === */
|
||||||
.activity-feed {
|
.activity-feed {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user