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 { Header, useViewportMode } from "./components/Header";
|
||||
import { Board } from "./components/Board";
|
||||
@@ -8,10 +8,12 @@ import { AgentsView } from "./components/AgentsView";
|
||||
import { MissionManager } from "./components/MissionManager";
|
||||
import { NodesView } from "./components/NodesView";
|
||||
import { AppModals } from "./components/AppModals";
|
||||
import { DashboardLoader, type DashboardLoaderStage } from "./components/DashboardLoader";
|
||||
import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
|
||||
import { SessionNotificationBanner } from "./components/SessionNotificationBanner";
|
||||
import { MobileNavBar } from "./components/MobileNavBar";
|
||||
import { QuickChatFAB } from "./components/QuickChatFAB";
|
||||
import { ToastContainer } from "./components/ToastContainer";
|
||||
import { useBackgroundSessions } from "./hooks/useBackgroundSessions";
|
||||
import { useTasks } from "./hooks/useTasks";
|
||||
import { useProjects } from "./hooks/useProjects";
|
||||
@@ -43,6 +45,32 @@ function AppInner() {
|
||||
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
|
||||
const { themeMode, colorTheme, setThemeMode, setColorTheme } = useTheme();
|
||||
|
||||
@@ -320,6 +348,15 @@ function AppInner() {
|
||||
);
|
||||
};
|
||||
|
||||
if (!initialLoadComplete) {
|
||||
return (
|
||||
<>
|
||||
<DashboardLoader stage={loadingStage} />
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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();
|
||||
});
|
||||
|
||||
// Wrapper should have project-content but NOT project-content--with-footer
|
||||
const wrapper = document.querySelector(".project-content");
|
||||
expect(wrapper).toBeTruthy();
|
||||
expect(wrapper?.classList.contains("project-content--with-footer")).toBe(false);
|
||||
await waitFor(() => {
|
||||
const wrapper = document.querySelector(".project-content");
|
||||
expect(wrapper).toBeTruthy();
|
||||
expect(wrapper?.classList.contains("project-content--with-footer")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
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(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 === */
|
||||
.activity-feed {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user