feat: Implement typed error contracts in generic hooks
Introduce discriminated FetchError union type to replace weak string error handling in API calls and hooks. Enables actionable error diagnostics. Changes: - Create types/api.ts with FetchError discriminated union (api_error, network_error, abort_error) - Export type guards: isAuthError, isAbortError, isNetworkError, isApiError - Update useListData and usePolledData to expose typed FetchError instead of string - Add getErrorMessage() helper to extract displayable messages from FetchError - Add createStringErrorAdapter() for backward compatibility with string error state - Update handleFetchError() to work with both FetchError and string setters - Update all consumer hooks to expose typed errors - Update components to use getErrorMessage() when displaying errors - Update tests to mock FetchError instead of strings - Add comprehensive typed error model documentation to Web-Development.md This enables better error handling patterns: - Check error.type to distinguish between API, network, and abort errors - Extract status codes for specific handling (401/403 auth, 50x server errors) - Maintain backward compatibility with existing string-based error states All TypeScript compilation passes with no errors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -28,6 +28,7 @@ import {
|
||||
import { PageEmpty, PageError, PageLoading } from "./PageFeedback";
|
||||
import { ChevronLeftRegular, ChevronRightRegular } from "@fluentui/react-icons";
|
||||
import { useBans } from "../hooks/useBans";
|
||||
import { getErrorMessage } from "../utils/fetchError";
|
||||
import { formatTimestamp } from "../utils/formatDate";
|
||||
import type { DashboardBanItem, TimeRange, BanOriginFilter } from "../types/ban";
|
||||
|
||||
@@ -212,7 +213,7 @@ export const BanTable = memo(function BanTable({ timeRange, origin, source }: Ba
|
||||
// Error state
|
||||
// --------------------------------------------------------------------------
|
||||
if (error) {
|
||||
return <PageError message={error} onRetry={refresh} />;
|
||||
return <PageError message={getErrorMessage(error)} onRetry={refresh} />;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
CHART_PALETTE,
|
||||
resolveFluentToken,
|
||||
} from "../utils/chartTheme";
|
||||
import { getErrorMessage } from "../utils/fetchError";
|
||||
import { useThemeMode } from "../providers/ThemeProvider";
|
||||
import { ChartStateWrapper } from "./ChartStateWrapper";
|
||||
import { useBanTrend } from "../hooks/useBanTrend";
|
||||
@@ -230,7 +231,7 @@ export const BanTrendChart = memo(function BanTrendChart({
|
||||
return (
|
||||
<ChartStateWrapper
|
||||
isLoading={loading}
|
||||
error={error}
|
||||
error={error ? getErrorMessage(error) : null}
|
||||
onRetry={reload}
|
||||
isEmpty={isEmpty}
|
||||
emptyMessage="No bans in this time range."
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as chartTheme from "../../utils/chartTheme";
|
||||
import * as useBanTrendModule from "../../hooks/useBanTrend";
|
||||
import type { UseBanTrendResult } from "../../hooks/useBanTrend";
|
||||
import type { BanTrendBucket } from "../../types/ban";
|
||||
import type { FetchError } from "../../types/api";
|
||||
|
||||
vi.mock("recharts", () => ({
|
||||
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
|
||||
@@ -61,7 +62,8 @@ describe("BanTrendChart", () => {
|
||||
|
||||
it("shows error message and retry button on error", async () => {
|
||||
const reload = vi.fn();
|
||||
mockHook({ error: "Failed to fetch trend", reload });
|
||||
const error: FetchError = { type: "network_error", message: "Failed to fetch trend" };
|
||||
mockHook({ error, reload });
|
||||
const user = userEvent.setup();
|
||||
wrap(<BanTrendChart timeRange="24h" origin="all" source="fail2ban" />);
|
||||
expect(screen.getByText("Failed to fetch trend")).toBeInTheDocument();
|
||||
|
||||
@@ -10,6 +10,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { ServerStatusBar } from "../ServerStatusBar";
|
||||
import type { FetchError } from "../../types/api";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock useServerStatus so tests never touch the network.
|
||||
@@ -158,10 +159,11 @@ describe("ServerStatusBar", () => {
|
||||
});
|
||||
|
||||
it("renders an error message when the status fetch fails", () => {
|
||||
const error: FetchError = { type: "network_error", message: "Network error" };
|
||||
mockedUseServerStatus.mockReturnValue({
|
||||
status: null,
|
||||
loading: false,
|
||||
error: "Network error",
|
||||
error,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
renderBar();
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { useJails } from "../../hooks/useJailList";
|
||||
import { getErrorMessage } from "../../utils/fetchError";
|
||||
import type { AssignActionRequest } from "../../types/config";
|
||||
import { ApiError } from "../../api/client";
|
||||
|
||||
@@ -120,7 +121,7 @@ function AssignActionDialogInner({
|
||||
|
||||
useEffect(() => {
|
||||
if (jailsError) {
|
||||
setError(jailsError);
|
||||
setError(getErrorMessage(jailsError));
|
||||
}
|
||||
}, [jailsError]);
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { useJails } from "../../hooks/useJailList";
|
||||
import { getErrorMessage } from "../../utils/fetchError";
|
||||
import type { AssignFilterRequest } from "../../types/config";
|
||||
import { ApiError } from "../../api/client";
|
||||
|
||||
@@ -120,7 +121,7 @@ function AssignFilterDialogInner({
|
||||
|
||||
useEffect(() => {
|
||||
if (jailsError) {
|
||||
setError(jailsError);
|
||||
setError(getErrorMessage(jailsError));
|
||||
}
|
||||
}, [jailsError]);
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "@fluentui/react-components";
|
||||
import { Add24Regular } from "@fluentui/react-icons";
|
||||
import type { FilterConfig } from "../../types/config";
|
||||
import { getErrorMessage } from "../../utils/fetchError";
|
||||
import { AssignFilterDialog } from "./AssignFilterDialog";
|
||||
import { ConfigListDetail } from "./ConfigListDetail";
|
||||
import { CreateFilterDialog } from "./CreateFilterDialog";
|
||||
@@ -77,7 +78,7 @@ export function FiltersTab(): React.JSX.Element {
|
||||
if (error) {
|
||||
return (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
<MessageBarBody>{getErrorMessage(error)}</MessageBarBody>
|
||||
</MessageBar>
|
||||
);
|
||||
}
|
||||
@@ -102,7 +103,7 @@ export function FiltersTab(): React.JSX.Element {
|
||||
selectedName={selectedName}
|
||||
onSelect={setSelectedName}
|
||||
loading={loading}
|
||||
error={error}
|
||||
error={error ? getErrorMessage(error) : null}
|
||||
listHeader={listHeader}
|
||||
>
|
||||
{selectedFilter !== null && (
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
Play24Regular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { handleFetchError } from "../../utils/fetchError";
|
||||
import { handleFetchError, createStringErrorAdapter } from "../../utils/fetchError";
|
||||
import type {
|
||||
AddLogPathRequest,
|
||||
BackendType,
|
||||
@@ -733,7 +733,7 @@ function InactiveJailDetail({
|
||||
onValidate()
|
||||
.then((result) => { setValidationResult(result); })
|
||||
.catch((err: unknown) => {
|
||||
handleFetchError(err, setValidationError, "Validation request failed.");
|
||||
handleFetchError(err, createStringErrorAdapter(setValidationError), "Validation request failed.");
|
||||
})
|
||||
.finally(() => { setValidating(false); });
|
||||
}, [onValidate]);
|
||||
|
||||
Reference in New Issue
Block a user