fix: remediate dashboard API integration review blockers (#459)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
ci/woodpecker/push/api Pipeline was successful

- Fix race condition: guard useEffect when workspaceId is null, prevent
  infinite loading state by setting isLoading=false on null workspace
- Fix TypeScript strict typing: @Workspace() returns string|undefined,
  controller now matches with BadRequestException guard
- Narrow details DTO type from unknown to Record<string, unknown>|null
- Add error state UI for API fetch failures
- Add error-path test with static mock import pattern

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 18:51:46 -06:00
parent 7c762e64c2
commit 3dab677524
7 changed files with 68 additions and 16 deletions

View File

@@ -1,8 +1,9 @@
import { Controller, Get, UseGuards } from "@nestjs/common";
import { Controller, Get, UseGuards, BadRequestException } from "@nestjs/common";
import { DashboardService } from "./dashboard.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
import { Workspace, Permission, RequirePermission } from "../common/decorators";
import type { DashboardSummaryDto } from "./dto";
/**
* Controller for dashboard endpoints.
@@ -25,7 +26,10 @@ export class DashboardController {
*/
@Get("summary")
@RequirePermission(Permission.WORKSPACE_ANY)
async getSummary(@Workspace() workspaceId: string) {
async getSummary(@Workspace() workspaceId: string | undefined): Promise<DashboardSummaryDto> {
if (!workspaceId) {
throw new BadRequestException("Workspace context required");
}
return this.dashboardService.getSummary(workspaceId);
}
}

View File

@@ -142,7 +142,7 @@ export class DashboardService {
action: row.action,
entityType: row.entityType,
entityId: row.entityId,
details: row.details,
details: row.details as Record<string, unknown> | null,
userId: row.userId,
createdAt: row.createdAt.toISOString(),
}));

View File

@@ -17,7 +17,7 @@ export class RecentActivityDto {
action!: string;
entityType!: string;
entityId!: string;
details!: unknown;
details!: Record<string, unknown> | null;
userId!: string;
createdAt!: string;
}

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import DashboardPage from "./page";
import { fetchDashboardSummary } from "@/lib/api/dashboard";
// Mock Phase 3 dashboard widgets
vi.mock("@/components/dashboard/DashboardMetrics", () => ({
@@ -51,6 +52,19 @@ vi.mock("@/lib/api/dashboard", () => ({
describe("DashboardPage", (): void => {
beforeEach((): void => {
vi.clearAllMocks();
vi.mocked(fetchDashboardSummary).mockResolvedValue({
metrics: {
activeAgents: 5,
tasksCompleted: 42,
totalTasks: 100,
tasksInProgress: 10,
activeProjects: 3,
errorRate: 0.5,
},
recentActivity: [],
activeJobs: [],
tokenBudget: [],
});
});
it("should render the DashboardMetrics widget", async (): Promise<void> => {
@@ -87,4 +101,12 @@ describe("DashboardPage", (): void => {
expect(screen.getByTestId("token-budget")).toBeInTheDocument();
});
});
it("should render error state when API fails", async (): Promise<void> => {
vi.mocked(fetchDashboardSummary).mockRejectedValueOnce(new Error("Network error"));
render(<DashboardPage />);
await waitFor((): void => {
expect(screen.getByText("Failed to load dashboard data")).toBeInTheDocument();
});
});
});

View File

@@ -15,19 +15,30 @@ export default function DashboardPage(): ReactElement {
const workspaceId = useWorkspaceId();
const [data, setData] = useState<DashboardSummaryResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!workspaceId) {
setIsLoading(false);
return;
}
const wsId = workspaceId;
let cancelled = false;
setError(null);
setIsLoading(true);
async function loadSummary(): Promise<void> {
try {
const summary = await fetchDashboardSummary(workspaceId ?? undefined);
const summary = await fetchDashboardSummary(wsId);
if (!cancelled) {
setData(summary);
}
} catch (err: unknown) {
// Log but do not crash; widgets will render with empty states
console.error("[Dashboard] Failed to fetch summary:", err);
if (!cancelled) {
setError("Failed to load dashboard data");
}
} finally {
if (!cancelled) {
setIsLoading(false);
@@ -62,6 +73,21 @@ export default function DashboardPage(): ReactElement {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{error && (
<div
style={{
padding: "12px 16px",
marginBottom: 16,
background: "rgba(229,72,77,0.1)",
border: "1px solid var(--border)",
borderRadius: "var(--r)",
color: "var(--text)",
fontSize: "0.85rem",
}}
>
{error}
</div>
)}
<DashboardMetrics metrics={data?.metrics} />
<div className="dash-grid">
<div style={{ display: "flex", flexDirection: "column", gap: 16, minWidth: 0 }}>

View File

@@ -23,7 +23,7 @@ export interface RecentActivity {
action: string;
entityType: string;
entityId: string;
details: unknown;
details: Record<string, unknown> | null;
userId: string;
createdAt: string;
}