FN-5894: share project data-boundary and status helpers

Centralize project loading and status-state handling across dashboard surfaces.

- add a reusable DataBoundary component for loading, empty, error, and loaded states
- extract shared project status config helpers for labels, colors, icons, and initializing detection
- update project overview, cards, badges, and selector UI to use the shared helpers
- add regression coverage for data-boundary behavior, project overview empty/loading transitions, and status config fallbacks

Files changed:
 packages/dashboard/app/components/DataBoundary.tsx | 64 ++++++++++++++++++++++
 packages/dashboard/app/components/ProjectCard.tsx  | 48 ++--------------
 .../app/components/ProjectHealthBadge.tsx          | 45 +--------------
 .../dashboard/app/components/ProjectOverview.tsx   |  4 +-
 .../dashboard/app/components/ProjectSelector.tsx   | 27 +--------
 .../app/components/__tests__/DataBoundary.test.tsx | 62 +++++++++++++++++++++
 .../components/__tests__/ProjectOverview.test.tsx  | 25 +++++++++
 .../__tests__/ProjectThemeTokens.test.tsx          | 37 ++++---------
 .../utils/__tests__/projectStatusConfig.test.ts    | 53 ++++++++++++++++++
 .../dashboard/app/utils/projectStatusConfig.ts     | 50 +++++++++++++++++
 packages/dashboard/vitest.config.ts                |  2 +
 11 files changed, 282 insertions(+), 135 deletions(-)

Fusion-Task-Id: FN-5894

Fusion-Task-Lineage: 3626a4af-b62b-456d-82d7-cf47bbf55c21
This commit is contained in:
gsxdsm
2026-06-02 14:14:24 -07:00
parent 65ddb4d4f5
commit b95e7f7575
11 changed files with 281 additions and 134 deletions

View File

@@ -0,0 +1,64 @@
import { AlertCircle } from "lucide-react";
import type { ReactNode } from "react";
import { getErrorMessage } from "@fusion/core";
import { AgentEmptyState } from "./AgentEmptyState";
import { ProjectGridSkeleton } from "./ProjectGridSkeleton";
export interface DataBoundaryProps {
isEmpty: boolean;
isLoading?: boolean;
hasFetched?: boolean;
error?: unknown;
loadingFallback?: ReactNode;
emptyFallback?: ReactNode;
errorFallback?: ReactNode;
children: ReactNode;
}
function DefaultErrorFallback({ error }: { error: unknown }) {
return (
<div className="agent-empty" data-testid="data-boundary-error">
<AlertCircle className="agent-empty-state__icon" size={48} opacity={0.3} />
<p className="agent-empty-state__title">Unable to load data</p>
<p className="agent-empty-state__description text-secondary">
{getErrorMessage(error) || "Something went wrong while loading this view."}
</p>
</div>
);
}
export function DataBoundary({
isEmpty,
isLoading = false,
hasFetched = false,
error,
loadingFallback,
emptyFallback,
errorFallback,
children,
}: DataBoundaryProps) {
if (error) {
return <>{errorFallback ?? <DefaultErrorFallback error={error} />}</>;
}
const shouldShowLoading = isLoading || (!hasFetched && !error);
if (shouldShowLoading) {
return <>{loadingFallback ?? <ProjectGridSkeleton />}</>;
}
if (hasFetched && isEmpty) {
return (
<>
{emptyFallback ?? (
<AgentEmptyState
title="No data available"
description="There is nothing to show yet."
ctaLabel=""
/>
)}
</>
);
}
return <>{children}</>;
}

View File

@@ -1,8 +1,9 @@
import { memo, useCallback, useState } from "react"; import { memo, useCallback, useState } from "react";
import { Play, Pause, AlertCircle, Loader2, Trash2, Folder, ArrowRight } from "lucide-react"; import { Play, Pause, Trash2, Folder, ArrowRight } from "lucide-react";
import "./ProjectCard.css"; import "./ProjectCard.css";
import type { RegisteredProject, ProjectHealth, ProjectStatus } from "@fusion/core"; import type { RegisteredProject, ProjectHealth } from "@fusion/core";
import type { ProjectNodeAvailability } from "../api"; import type { ProjectNodeAvailability } from "../api";
import { getProjectStatusConfig, isInitializingStatus } from "../utils/projectStatusConfig";
export interface ProjectCardProps { export interface ProjectCardProps {
project: RegisteredProject; project: RegisteredProject;
@@ -15,45 +16,6 @@ export interface ProjectCardProps {
isLoading?: boolean; isLoading?: boolean;
} }
type StatusConfig = { label: string; color: string; icon: typeof Play };
const STATUS_CONFIG: Record<ProjectStatus, StatusConfig> = {
active: { label: "Active", color: "var(--color-success)", icon: Play },
paused: { label: "Paused", color: "var(--color-warning)", icon: Pause },
errored: { label: "Error", color: "var(--color-error)", icon: AlertCircle },
initializing: { label: "Initializing", color: "var(--color-warning)", icon: Loader2 },
};
const FALLBACK_STATUS_CONFIG: StatusConfig = {
label: "Unknown",
color: "var(--color-error)",
icon: AlertCircle,
};
function formatStatusLabel(status: string | null | undefined): string {
if (!status) {
return FALLBACK_STATUS_CONFIG.label;
}
return status
.split(/[-_\s]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function getStatusConfig(status: string | null | undefined): StatusConfig {
const config = STATUS_CONFIG[status as ProjectStatus];
if (config) {
return config;
}
return {
...FALLBACK_STATUS_CONFIG,
label: formatStatusLabel(status),
};
}
function formatRelativeTime(timestamp: string | undefined): string { function formatRelativeTime(timestamp: string | undefined): string {
if (!timestamp) return "Never"; if (!timestamp) return "Never";
@@ -127,7 +89,7 @@ function ProjectCardInner({
isLoading = false, isLoading = false,
}: ProjectCardProps) { }: ProjectCardProps) {
const [removeArmed, setRemoveArmed] = useState(false); const [removeArmed, setRemoveArmed] = useState(false);
const statusConfig = getStatusConfig(project.status); const statusConfig = getProjectStatusConfig(project.status);
const StatusIcon = statusConfig.icon; const StatusIcon = statusConfig.icon;
const handleSelect = useCallback(() => { const handleSelect = useCallback(() => {
@@ -157,7 +119,7 @@ function ProjectCardInner({
const isPaused = project.status === "paused"; const isPaused = project.status === "paused";
const isErrored = project.status === "errored"; const isErrored = project.status === "errored";
const isInitializing = project.status === "initializing"; const isInitializing = isInitializingStatus(project.status);
return ( return (
<div <div

View File

@@ -1,7 +1,7 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { Play, Pause, AlertCircle, Loader2 } from "lucide-react";
import type { ProjectStatus } from "@fusion/core"; import type { ProjectStatus } from "@fusion/core";
import type { ProjectHealth } from "../api"; import type { ProjectHealth } from "../api";
import { getProjectStatusConfig, isInitializingStatus } from "../utils/projectStatusConfig";
export interface ProjectHealthBadgeProps { export interface ProjectHealthBadgeProps {
status: ProjectStatus; status: ProjectStatus;
@@ -10,45 +10,6 @@ export interface ProjectHealthBadgeProps {
showTooltip?: boolean; showTooltip?: boolean;
} }
type StatusConfig = { label: string; color: string; icon: typeof Play };
const STATUS_CONFIG: Record<ProjectStatus, StatusConfig> = {
active: { label: "Active", color: "var(--success)", icon: Play },
paused: { label: "Paused", color: "var(--warning)", icon: Pause },
errored: { label: "Error", color: "var(--color-error)", icon: AlertCircle },
initializing: { label: "Initializing", color: "var(--info)", icon: Loader2 },
};
const FALLBACK_STATUS_CONFIG: StatusConfig = {
label: "Unknown",
color: "var(--color-error)",
icon: AlertCircle,
};
function formatStatusLabel(status: string | null | undefined): string {
if (!status) {
return FALLBACK_STATUS_CONFIG.label;
}
return status
.split(/[-_\s]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function getStatusConfig(status: string | null | undefined): StatusConfig {
const config = STATUS_CONFIG[status as ProjectStatus];
if (config) {
return config;
}
return {
...FALLBACK_STATUS_CONFIG,
label: formatStatusLabel(status),
};
}
/** /**
* ProjectHealthBadge - Color-coded badge showing project health status * ProjectHealthBadge - Color-coded badge showing project health status
* *
@@ -62,7 +23,7 @@ export function ProjectHealthBadge({
showTooltip = true, showTooltip = true,
}: ProjectHealthBadgeProps) { }: ProjectHealthBadgeProps) {
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const config = getStatusConfig(status); const config = getProjectStatusConfig(status);
const StatusIcon = config.icon; const StatusIcon = config.icon;
const handleMouseEnter = useCallback(() => { const handleMouseEnter = useCallback(() => {
@@ -81,7 +42,7 @@ export function ProjectHealthBadge({
lg: "project-health-badge--lg", lg: "project-health-badge--lg",
}; };
const isInitializing = status === "initializing"; const isInitializing = isInitializingStatus(status);
return ( return (
<div <div

View File

@@ -127,7 +127,9 @@ export function ProjectOverview({
paused: 2, paused: 2,
active: 3, active: 3,
}; };
comparison = statusOrder[a.project.status] - statusOrder[b.project.status]; const aOrder = statusOrder[a.project.status] ?? Number.MAX_SAFE_INTEGER;
const bOrder = statusOrder[b.project.status] ?? Number.MAX_SAFE_INTEGER;
comparison = aOrder - bOrder;
break; break;
} }
} }

View File

@@ -7,15 +7,12 @@ import {
Grid3X3, Grid3X3,
Search, Search,
Clock, Clock,
Play,
Pause,
AlertCircle,
Loader2,
X, X,
} from "lucide-react"; } from "lucide-react";
import type { ProjectInfo } from "../api"; import type { ProjectInfo } from "../api";
import type { ProjectStatus } from "@fusion/core"; import type { ProjectStatus } from "@fusion/core";
import { getTrailingPath } from "../utils/pathDisplay"; import { getTrailingPath } from "../utils/pathDisplay";
import { getProjectStatusConfig, isInitializingStatus } from "../utils/projectStatusConfig";
export interface ProjectSelectorProps { export interface ProjectSelectorProps {
projects: ProjectInfo[]; projects: ProjectInfo[];
@@ -25,24 +22,6 @@ export interface ProjectSelectorProps {
recentProjectIds?: string[]; recentProjectIds?: string[];
} }
type StatusConfig = { color: string; icon: typeof Play };
const STATUS_CONFIG: Record<ProjectStatus, StatusConfig> = {
active: { color: "var(--success)", icon: Play },
paused: { color: "var(--warning)", icon: Pause },
errored: { color: "var(--color-error)", icon: AlertCircle },
initializing: { color: "var(--info)", icon: Loader2 },
};
const FALLBACK_STATUS_CONFIG: StatusConfig = {
color: "var(--color-error)",
icon: AlertCircle,
};
function getStatusConfig(status: string | null | undefined): StatusConfig {
return STATUS_CONFIG[status as ProjectStatus] ?? FALLBACK_STATUS_CONFIG;
}
/** /**
* ProjectSelector - Project switcher dropdown with keyboard navigation * ProjectSelector - Project switcher dropdown with keyboard navigation
* *
@@ -234,13 +213,13 @@ export function ProjectSelector({
// Render status icon // Render status icon
const renderStatusIcon = (status: ProjectStatus) => { const renderStatusIcon = (status: ProjectStatus) => {
const config = getStatusConfig(status); const config = getProjectStatusConfig(status);
const Icon = config.icon; const Icon = config.icon;
return ( return (
<Icon <Icon
size={14} size={14}
style={{ color: config.color }} style={{ color: config.color }}
className={status === "initializing" ? "animate-spin" : ""} className={isInitializingStatus(status) ? "animate-spin" : ""}
/> />
); );
}; };

View File

@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { DataBoundary } from "../DataBoundary";
vi.mock("lucide-react", () => ({
AlertCircle: () => <span data-testid="alert-icon"></span>,
Bot: () => <span data-testid="bot-icon">🤖</span>,
Folder: () => <span data-testid="folder-icon">📁</span>,
Activity: () => <span data-testid="activity-icon">📊</span>,
CheckCircle: () => <span data-testid="check-icon"></span>,
}));
describe("DataBoundary", () => {
it("renders the loading fallback before the first fetch completes", () => {
render(
<DataBoundary
isEmpty={false}
hasFetched={false}
isLoading={false}
loadingFallback={<div data-testid="loading-fallback">Loading</div>}
>
<div>Loaded content</div>
</DataBoundary>
);
expect(screen.getByTestId("loading-fallback")).toBeDefined();
expect(screen.queryByText("Loaded content")).toBeNull();
});
it("renders an empty state after fetch completion instead of an infinite skeleton", () => {
render(
<DataBoundary isEmpty hasFetched isLoading={false}>
<div>Loaded content</div>
</DataBoundary>
);
expect(screen.getByText("No data available")).toBeDefined();
expect(screen.queryByText("Loaded content")).toBeNull();
});
it("renders an error state when error is set", () => {
render(
<DataBoundary isEmpty hasFetched={false} error={new Error("Boom")}>
<div>Loaded content</div>
</DataBoundary>
);
expect(screen.getByTestId("data-boundary-error")).toBeDefined();
expect(screen.getByText("Boom")).toBeDefined();
});
it("renders children when data is present", () => {
render(
<DataBoundary isEmpty={false} hasFetched>
<div>Loaded content</div>
</DataBoundary>
);
expect(screen.getByText("Loaded content")).toBeDefined();
expect(screen.queryByText("No data available")).toBeNull();
});
});

View File

@@ -328,6 +328,31 @@ describe("ProjectOverview", () => {
expect(screen.getByTestId("project-card-proj_1")).toBeDefined(); expect(screen.getByTestId("project-card-proj_1")).toBeDefined();
}); });
it("does not crash when sorting by status with unknown project statuses", () => {
expect(() => {
render(
<ProjectOverview
projects={[
makeProject({ id: "proj_active", name: "Active Project", status: "active" }),
makeProject({ id: "proj_unknown", name: "Unknown Project", status: "removing" as ProjectStatus }),
]}
onSelectProject={noop}
onAddProject={noop}
onPauseProject={noop}
onResumeProject={noop}
onRemoveProject={noop}
/>
);
}).not.toThrow();
fireEvent.change(screen.getByLabelText("Sort projects"), {
target: { value: "status-asc" },
});
expect(screen.getByTestId("project-card-proj_active")).toBeDefined();
expect(screen.getByTestId("project-card-proj_unknown")).toBeDefined();
});
it("shows loading skeleton when loading prop is true", () => { it("shows loading skeleton when loading prop is true", () => {
render( render(
<ProjectOverview <ProjectOverview

View File

@@ -115,33 +115,20 @@ describe("Project component CSS theme tokens", () => {
expect(html).not.toContain("var(--error)"); expect(html).not.toContain("var(--error)");
}); });
it("no STATUS_CONFIG in any project component uses bare var(--error)", async () => { it("shared project status config uses canonical error tokens only", async () => {
// This is a source-code-level regression check.
// Read the source files and verify no bare var(--error) remains.
const fs = await import("fs"); const fs = await import("fs");
const path = await import("path"); const path = await import("path");
const componentDir = path.resolve(__dirname, ".."); const source = fs.readFileSync(
const files = [ path.resolve(__dirname, "../../utils/projectStatusConfig.ts"),
"ProjectHealthBadge.tsx", "utf-8"
"ProjectSelector.tsx", );
"ProjectCard.tsx",
];
for (const file of files) { expect(source, "projectStatusConfig.ts should not contain var(--error)").not.toMatch(
const source = fs.readFileSync(path.join(componentDir, file), "utf-8"); /var\(--error\)(?!\w)/
);
// Should NOT contain the bare var(--error) token expect(source, "projectStatusConfig.ts should contain var(--color-error)").toContain(
expect( "var(--color-error)"
source, );
`${file} should not contain var(--error)`
).not.toMatch(/var\(--error\)(?!\w)/);
// Should contain the correct token for errored status
expect(
source,
`${file} should contain var(--color-error) for errored status`
).toContain('var(--color-error)');
}
}); });
}); });

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { AlertCircle, Loader2, Pause, Play } from "lucide-react";
import {
STATUS_CONFIG,
formatStatusLabel,
getProjectStatusConfig,
isInitializingStatus,
} from "../projectStatusConfig";
describe("projectStatusConfig", () => {
it("maps each known status to the expected config", () => {
expect(STATUS_CONFIG.active.label).toBe("Active");
expect(STATUS_CONFIG.active.color).toBe("var(--color-success)");
expect(STATUS_CONFIG.active.icon).toBe(Play);
expect(STATUS_CONFIG.paused.label).toBe("Paused");
expect(STATUS_CONFIG.paused.color).toBe("var(--color-warning)");
expect(STATUS_CONFIG.paused.icon).toBe(Pause);
expect(STATUS_CONFIG.errored.label).toBe("Error");
expect(STATUS_CONFIG.errored.color).toBe("var(--color-error)");
expect(STATUS_CONFIG.errored.icon).toBe(AlertCircle);
expect(STATUS_CONFIG.initializing.label).toBe("Initializing");
expect(STATUS_CONFIG.initializing.color).toBe("var(--color-info)");
expect(STATUS_CONFIG.initializing.icon).toBe(Loader2);
});
it("formats unknown statuses into readable labels", () => {
expect(formatStatusLabel("removing_now")).toBe("Removing Now");
expect(getProjectStatusConfig("removing")).toMatchObject({
label: "Removing",
color: "var(--color-error)",
});
expect(getProjectStatusConfig("removing").icon).toBe(AlertCircle);
});
it("returns Unknown fallback labels for undefined, null, or empty statuses", () => {
for (const status of [undefined, null, ""]) {
const config = getProjectStatusConfig(status);
expect(config.label).toBe("Unknown");
expect(config.color).toBe("var(--color-error)");
expect(config.icon).toBe(AlertCircle);
expect(config.icon).toBeDefined();
}
});
it("only treats literal initializing as the spinner state", () => {
expect(isInitializingStatus("initializing")).toBe(true);
expect(isInitializingStatus("INITIALIZING")).toBe(false);
expect(isInitializingStatus(undefined)).toBe(false);
});
});

View File

@@ -0,0 +1,50 @@
import { AlertCircle, Loader2, Pause, Play } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { ProjectStatus } from "@fusion/core";
export interface ProjectStatusConfig {
label: string;
color: string;
icon: LucideIcon;
}
export const STATUS_CONFIG: Record<ProjectStatus, ProjectStatusConfig> = {
active: { label: "Active", color: "var(--color-success)", icon: Play },
paused: { label: "Paused", color: "var(--color-warning)", icon: Pause },
errored: { label: "Error", color: "var(--color-error)", icon: AlertCircle },
initializing: { label: "Initializing", color: "var(--color-info)", icon: Loader2 },
};
const FALLBACK_STATUS_CONFIG: ProjectStatusConfig = {
label: "Unknown",
color: "var(--color-error)",
icon: AlertCircle,
};
export function formatStatusLabel(status: string | null | undefined): string {
if (!status) {
return FALLBACK_STATUS_CONFIG.label;
}
return status
.split(/[-_\s]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
export function getProjectStatusConfig(status: string | null | undefined): ProjectStatusConfig {
const config = STATUS_CONFIG[status as ProjectStatus];
if (config) {
return config;
}
return {
...FALLBACK_STATUS_CONFIG,
label: formatStatusLabel(status),
};
}
export function isInitializingStatus(status: string | null | undefined): boolean {
return status === "initializing";
}

View File

@@ -108,6 +108,7 @@ const qualityAppComponentTests = [
"Column", "Column",
"ConfirmDialog", "ConfirmDialog",
"ConversationHistory", "ConversationHistory",
"DataBoundary",
"DashboardLoader", "DashboardLoader",
"DevServerView.mobile", "DevServerView.mobile",
"DirectoryPicker", "DirectoryPicker",
@@ -135,6 +136,7 @@ const qualityAppComponentTests = [
"PrCreateModal", "PrCreateModal",
"PrCreateModal.layout", "PrCreateModal.layout",
"ProjectCard", "ProjectCard",
"ProjectHealthBadge",
"ProjectSelector", "ProjectSelector",
"ProviderIcon", "ProviderIcon",
"PrPanel", "PrPanel",