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:
64
packages/dashboard/app/components/DataBoundary.tsx
Normal file
64
packages/dashboard/app/components/DataBoundary.tsx
Normal 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}</>;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" : ""}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
50
packages/dashboard/app/utils/projectStatusConfig.ts
Normal file
50
packages/dashboard/app/utils/projectStatusConfig.ts
Normal 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";
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user