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:
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useListData } from "../useListData";
|
||||
import type { FetchError } from "../../types/api";
|
||||
|
||||
describe("useListData", () => {
|
||||
it("loads items and updates loading", async () => {
|
||||
@@ -26,9 +27,10 @@ describe("useListData", () => {
|
||||
expect(selector).toHaveBeenCalledWith({ items: ["one", "two"] });
|
||||
expect(result.current.items).toEqual(["one", "two"]);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("sets error when the fetcher rejects", async () => {
|
||||
it("sets typed error when the fetcher rejects", async () => {
|
||||
const fetcher = vi.fn().mockRejectedValue(new Error("network"));
|
||||
const selector = vi.fn();
|
||||
|
||||
@@ -44,7 +46,10 @@ describe("useListData", () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe("network");
|
||||
const error = result.current.error as FetchError | null;
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.type).toBe("network_error");
|
||||
expect(error?.message).toBe("network");
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.items).toEqual([]);
|
||||
});
|
||||
@@ -109,4 +114,58 @@ describe("useListData", () => {
|
||||
// Items should not be updated after abort
|
||||
expect(result.current.items).toEqual([]);
|
||||
});
|
||||
|
||||
it("silently handles abort errors", async () => {
|
||||
const fetcher = vi.fn().mockRejectedValue(new DOMException("Aborted", "AbortError"));
|
||||
const selector = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useListData({
|
||||
fetcher,
|
||||
selector,
|
||||
errorMessage: "Failed to load",
|
||||
})
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Abort errors should not set error state
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("exposes typed API error information", async () => {
|
||||
// Mock an API error response
|
||||
const fetcher = vi.fn().mockImplementation(() => {
|
||||
return Promise.reject((() => {
|
||||
const error = new Error("API error 500: Server error");
|
||||
(error as any).name = "ApiError";
|
||||
(error as any).status = 500;
|
||||
(error as any).body = "Server error";
|
||||
return error;
|
||||
})());
|
||||
});
|
||||
const selector = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useListData({
|
||||
fetcher,
|
||||
selector,
|
||||
errorMessage: "Failed to load",
|
||||
})
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const error = result.current.error as FetchError | null;
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.type).toBe("api_error");
|
||||
if (error?.type === "api_error") {
|
||||
expect(error.status).toBe(500);
|
||||
expect(error.body).toBe("Server error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,11 +5,12 @@ import { useCallback } from "react";
|
||||
import { fetchActions, removeActionFromJail, createAction, assignActionToJail } from "../api/config";
|
||||
import { useListData } from "./useListData";
|
||||
import type { ActionConfig, ActionCreateRequest, ActionListResponse } from "../types/config";
|
||||
import type { FetchError } from "../types/api";
|
||||
|
||||
export interface UseActionListResult {
|
||||
actions: ActionConfig[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
error: FetchError | null;
|
||||
refresh: () => void;
|
||||
removeActionFromJail: (jailName: string, actionName: string) => Promise<void>;
|
||||
createAction: (payload: ActionCreateRequest) => Promise<ActionConfig>;
|
||||
@@ -18,6 +19,9 @@ export interface UseActionListResult {
|
||||
|
||||
/**
|
||||
* Load the action inventory and expose related action operations.
|
||||
*
|
||||
* Exposes typed errors via the `error` property, which is either `null` or a discriminated
|
||||
* `FetchError` union. Check the error's `type` field to determine the failure mode.
|
||||
*/
|
||||
export function useActionList(): UseActionListResult {
|
||||
const fetcher = useCallback(
|
||||
|
||||
@@ -6,12 +6,13 @@ import { useCallback } from "react";
|
||||
import { banIp, fetchActiveBans, unbanAllBans, unbanIp } from "../api/jails";
|
||||
import { useListData } from "./useListData";
|
||||
import type { ActiveBan, UnbanAllResponse, ActiveBanListResponse } from "../types/jail";
|
||||
import type { FetchError } from "../types/api";
|
||||
|
||||
export interface UseActiveBansResult {
|
||||
bans: ActiveBan[];
|
||||
total: number;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
error: FetchError | null;
|
||||
refresh: () => void;
|
||||
banIp: (jail: string, ip: string) => Promise<void>;
|
||||
unbanIp: (ip: string, jail?: string) => Promise<void>;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useCallback, useState } from "react";
|
||||
import { fetchBanTrend } from "../api/dashboard";
|
||||
import { useListData } from "./useListData";
|
||||
import type { BanTrendBucket, BanOriginFilter, TimeRange, BanTrendResponse } from "../types/ban";
|
||||
import type { FetchError } from "../types/api";
|
||||
|
||||
/** Return value shape for {@link useBanTrend}. */
|
||||
export interface UseBanTrendResult {
|
||||
@@ -19,7 +20,7 @@ export interface UseBanTrendResult {
|
||||
/** True while a fetch is in flight. */
|
||||
loading: boolean;
|
||||
/** Error message or `null`. */
|
||||
error: string | null;
|
||||
error: FetchError | null;
|
||||
/** Re-fetch the data immediately. */
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { fetchBans } from "../api/dashboard";
|
||||
import { useListData } from "./useListData";
|
||||
import { BAN_PAGE_SIZE } from "../utils/constants";
|
||||
import type { DashboardBanItem, TimeRange, BanOriginFilter, DashboardBanListResponse } from "../types/ban";
|
||||
import type { FetchError } from "../types/api";
|
||||
|
||||
/** Return value shape for {@link useBans}. */
|
||||
export interface UseBansResult {
|
||||
@@ -24,7 +25,7 @@ export interface UseBansResult {
|
||||
/** Whether a fetch is currently in flight. */
|
||||
loading: boolean;
|
||||
/** Error message if the last fetch failed, otherwise `null`. */
|
||||
error: string | null;
|
||||
error: FetchError | null;
|
||||
/** Imperatively re-fetch the current page. */
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchBansByCountry } from "../api/map";
|
||||
import { handleFetchError } from "../utils/fetchError";
|
||||
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
|
||||
import type { BanOriginFilter, TimeRange } from "../types/ban";
|
||||
import type { BansByCountryResponse, MapBanItem } from "../types/map";
|
||||
|
||||
@@ -80,7 +80,7 @@ export function useBansByCountry(
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
handleFetchError(err, setError, "Failed to fetch ban-by-country data");
|
||||
handleFetchError(err, createStringErrorAdapter(setError), "Failed to fetch ban-by-country data");
|
||||
}
|
||||
})
|
||||
.finally((): void => {
|
||||
|
||||
@@ -18,11 +18,12 @@ import type {
|
||||
BlocklistListResponse,
|
||||
PreviewResponse,
|
||||
} from "../types/blocklist";
|
||||
import type { FetchError } from "../types/api";
|
||||
|
||||
export interface UseBlocklistsReturn {
|
||||
sources: BlocklistSource[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
error: FetchError | null;
|
||||
refresh: () => void;
|
||||
createSource: (payload: BlocklistSourceCreate) => Promise<BlocklistSource>;
|
||||
updateSource: (id: number, payload: BlocklistSourceUpdate) => Promise<BlocklistSource>;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchJails } from "../api/jails";
|
||||
import { fetchJailConfigs } from "../api/config";
|
||||
import { handleFetchError } from "../utils/fetchError";
|
||||
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
|
||||
import type { JailConfig } from "../types/config";
|
||||
import type { JailSummary } from "../types/jail";
|
||||
|
||||
@@ -111,7 +111,7 @@ export function useConfigActiveStatus(): UseConfigActiveStatusResult {
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
handleFetchError(err, setError, "Failed to load active status.");
|
||||
handleFetchError(err, createStringErrorAdapter(setError), "Failed to load active status.");
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Generic config hook for loading and saving a single entity.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { handleFetchError } from "../utils/fetchError";
|
||||
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
|
||||
import { isAuthError } from "../api/client";
|
||||
|
||||
export interface UseConfigItemResult<T, U> {
|
||||
@@ -48,7 +48,7 @@ export function useConfigItem<T, U>(
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
handleFetchError(err, setError, "Failed to load data");
|
||||
handleFetchError(err, createStringErrorAdapter(setError), "Failed to load data");
|
||||
setLoading(false);
|
||||
});
|
||||
}, [fetchFn]);
|
||||
|
||||
@@ -5,11 +5,12 @@ import { useCallback } from "react";
|
||||
import { fetchFilters, createFilter, assignFilterToJail } from "../api/config";
|
||||
import { useListData } from "./useListData";
|
||||
import type { FilterConfig, FilterCreateRequest, FilterListResponse } from "../types/config";
|
||||
import type { FetchError } from "../types/api";
|
||||
|
||||
export interface UseFilterListResult {
|
||||
filters: FilterConfig[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
error: FetchError | null;
|
||||
refresh: () => void;
|
||||
createFilter: (payload: FilterCreateRequest) => Promise<FilterConfig>;
|
||||
assignFilterToJail: (jailName: string, payload: { filter_name: string }, reload: boolean) => Promise<void>;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchGlobalConfig, updateGlobalConfig } from "../api/config";
|
||||
import { handleFetchError } from "../utils/fetchError";
|
||||
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
|
||||
import type { GlobalConfig, GlobalConfigUpdate } from "../types/config";
|
||||
|
||||
export interface UseGlobalConfigResult {
|
||||
@@ -39,7 +39,7 @@ export function useGlobalConfig(): UseGlobalConfigResult {
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!ctrl.signal.aborted) {
|
||||
handleFetchError(err, setError, "Failed to fetch global config");
|
||||
handleFetchError(err, createStringErrorAdapter(setError), "Failed to fetch global config");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
|
||||
@@ -4,17 +4,18 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchHistory, fetchIpHistory } from "../api/history";
|
||||
import { handleFetchError } from "../utils/fetchError";
|
||||
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
|
||||
import { useListData } from "./useListData";
|
||||
import type { HistoryBanItem, IpDetailResponse, HistoryListResponse } from "../types/history";
|
||||
import type { BanOriginFilter, TimeRange } from "../types/ban";
|
||||
import type { FetchError } from "../types/api";
|
||||
|
||||
export interface UseHistoryResult {
|
||||
items: HistoryBanItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
error: FetchError | null;
|
||||
setPage: (page: number) => void;
|
||||
refresh: () => void;
|
||||
}
|
||||
@@ -121,7 +122,7 @@ export function useIpHistory(ip: string): UseIpHistoryResult {
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!abortRef.current?.signal.aborted) {
|
||||
handleFetchError(err, setError, "Failed to fetch IP history");
|
||||
handleFetchError(err, createStringErrorAdapter(setError), "Failed to fetch IP history");
|
||||
}
|
||||
})
|
||||
.finally((): void => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchImportLog } from "../api/blocklist";
|
||||
import { handleFetchError } from "../utils/fetchError";
|
||||
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
|
||||
import type { ImportLogListResponse } from "../types/blocklist";
|
||||
|
||||
export interface UseImportLogReturn {
|
||||
@@ -46,7 +46,7 @@ export function useImportLog(
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!ctrl.signal.aborted) {
|
||||
handleFetchError(err, setError, "Failed to load import log");
|
||||
handleFetchError(err, createStringErrorAdapter(setError), "Failed to load import log");
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { handleFetchError } from "../utils/fetchError";
|
||||
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
|
||||
import { lookupIp } from "../api/jails";
|
||||
import type { IpLookupResponse } from "../types/jail";
|
||||
|
||||
@@ -41,7 +41,7 @@ export function useIpLookup(): UseIpLookupResult {
|
||||
setResult(res);
|
||||
} catch (err: unknown) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
handleFetchError(err, setError, "Failed to lookup IP");
|
||||
handleFetchError(err, createStringErrorAdapter(setError), "Failed to lookup IP");
|
||||
} finally {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setLoading(false);
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
validateJailConfig,
|
||||
createJailConfigFile,
|
||||
} from "../api/config";
|
||||
import { handleFetchError } from "../utils/fetchError";
|
||||
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
|
||||
import type {
|
||||
ActivateJailRequest,
|
||||
ConfFileCreateRequest,
|
||||
@@ -57,7 +57,7 @@ export function useJailAdmin(): UseJailAdminResult {
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!ctrl.signal.aborted) {
|
||||
handleFetchError(err, setInactiveError, "Failed to load inactive jails");
|
||||
handleFetchError(err, createStringErrorAdapter(setInactiveError), "Failed to load inactive jails");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchJailBannedIps, unbanIp } from "../api/jails";
|
||||
import { handleFetchError } from "../utils/fetchError";
|
||||
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
|
||||
import type { ActiveBan } from "../types/jail";
|
||||
|
||||
export interface UseJailBannedIpsResult {
|
||||
@@ -69,7 +69,7 @@ export function useJailBannedIps(jailName: string): UseJailBannedIpsResult {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
handleFetchError(err, setError, "Failed to fetch jailed IPs");
|
||||
handleFetchError(err, createStringErrorAdapter(setError), "Failed to fetch jailed IPs");
|
||||
} finally {
|
||||
if (!signal.aborted) {
|
||||
setLoading(false);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { addLogPath, fetchJailConfig, updateJailConfig } from "../api/config";
|
||||
import { handleFetchError } from "../utils/fetchError";
|
||||
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
|
||||
import type { AddLogPathRequest, JailConfig, JailConfigUpdate } from "../types/config";
|
||||
|
||||
export interface UseJailConfigDetailResult {
|
||||
@@ -40,7 +40,7 @@ export function useJailConfigDetail(name: string): UseJailConfigDetailResult {
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!ctrl.signal.aborted) {
|
||||
handleFetchError(err, setError, "Failed to fetch jail config");
|
||||
handleFetchError(err, createStringErrorAdapter(setError), "Failed to fetch jail config");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
|
||||
@@ -6,12 +6,13 @@ import { useCallback, useState } from "react";
|
||||
import { fetchJailConfigs, reloadConfig, updateJailConfig } from "../api/config";
|
||||
import { useListData } from "./useListData";
|
||||
import type { JailConfig, JailConfigUpdate, JailConfigListResponse } from "../types/config";
|
||||
import type { FetchError } from "../types/api";
|
||||
|
||||
export interface UseJailConfigsResult {
|
||||
jails: JailConfig[];
|
||||
total: number;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
error: FetchError | null;
|
||||
refresh: () => void;
|
||||
updateJail: (name: string, update: JailConfigUpdate) => Promise<void>;
|
||||
reloadAll: () => Promise<void>;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchJail } from "../api/jails";
|
||||
import { handleFetchError } from "../utils/fetchError";
|
||||
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
|
||||
import type { Jail } from "../types/jail";
|
||||
|
||||
export interface UseJailDataResult {
|
||||
@@ -50,7 +50,7 @@ export function useJailData(name: string): UseJailDataResult {
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!ctrl.signal.aborted) {
|
||||
handleFetchError(err, setError, "Failed to fetch jail detail");
|
||||
handleFetchError(err, createStringErrorAdapter(setError), "Failed to fetch jail detail");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchBansByJail } from "../api/dashboard";
|
||||
import { handleFetchError } from "../utils/fetchError";
|
||||
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
|
||||
import type { BanOriginFilter, JailBanCount, TimeRange } from "../types/ban";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -66,7 +66,7 @@ export function useJailDistribution(
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
handleFetchError(err, setError, "Failed to fetch jail distribution");
|
||||
handleFetchError(err, createStringErrorAdapter(setError), "Failed to fetch jail distribution");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
|
||||
@@ -13,12 +13,13 @@ import {
|
||||
} from "../api/jails";
|
||||
import { useListData } from "./useListData";
|
||||
import type { JailSummary, JailListResponse } from "../types/jail";
|
||||
import type { FetchError } from "../types/api";
|
||||
|
||||
export interface UseJailsResult {
|
||||
jails: JailSummary[];
|
||||
total: number;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
error: FetchError | null;
|
||||
refresh: () => void;
|
||||
startJail: (name: string) => Promise<void>;
|
||||
stopJail: (name: string) => Promise<void>;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { handleFetchError } from "../utils/fetchError";
|
||||
import type { FetchError } from "../types/api";
|
||||
|
||||
export interface UseListDataOptions<TResponse, TItem> {
|
||||
fetcher: (signal: AbortSignal) => Promise<TResponse>;
|
||||
@@ -15,12 +16,23 @@ export interface UseListDataOptions<TResponse, TItem> {
|
||||
export interface UseListDataResult<TItem> {
|
||||
items: TItem[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
error: FetchError | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a list response and expose refresh semantics with abort support.
|
||||
*
|
||||
* Provides typed error handling through the `error` property, which is either
|
||||
* `null` or a discriminated `FetchError` union. Use the error's `type` field
|
||||
* to determine how to handle it:
|
||||
*
|
||||
* - `"api_error"`: Server returned HTTP error (check `status` for 401/403/50x)
|
||||
* - `"network_error"`: Network, DNS, or JSON parse failure
|
||||
* - `"abort_error"`: Request was cancelled (typically silently ignored by hook)
|
||||
*
|
||||
* @param options - Configuration options
|
||||
* @returns Data, loading state, typed error, and refresh callback
|
||||
*/
|
||||
export function useListData<TResponse, TItem>(
|
||||
options: UseListDataOptions<TResponse, TItem>,
|
||||
@@ -28,7 +40,7 @@ export function useListData<TResponse, TItem>(
|
||||
const { fetcher, selector, errorMessage, onSuccess, initialItems } = options;
|
||||
const [items, setItems] = useState<TItem[]>(initialItems ?? []);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<FetchError | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const refresh = useCallback((): void => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { previewLog } from "../api/config";
|
||||
import { handleFetchError } from "../utils/fetchError";
|
||||
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
|
||||
import type { LogPreviewRequest, LogPreviewResponse } from "../types/config";
|
||||
|
||||
export interface UseLogPreviewResult {
|
||||
@@ -29,7 +29,7 @@ export function useLogPreview(): UseLogPreviewResult {
|
||||
setPreview(resp);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
handleFetchError(err, setError, "Log preview failed");
|
||||
handleFetchError(err, createStringErrorAdapter(setError), "Log preview failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { fetchMapColorThresholds, updateMapColorThresholds } from "../api/config";
|
||||
import { handleFetchError } from "../utils/fetchError";
|
||||
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
|
||||
import type {
|
||||
MapColorThresholdsResponse,
|
||||
MapColorThresholdsUpdate,
|
||||
@@ -29,7 +29,7 @@ export function useMapColorThresholds(): UseMapColorThresholdsResult {
|
||||
setThresholds(data);
|
||||
} catch (err: unknown) {
|
||||
if (signal?.aborted) return;
|
||||
handleFetchError(err, setError, "Failed to fetch map color thresholds");
|
||||
handleFetchError(err, createStringErrorAdapter(setError), "Failed to fetch map color thresholds");
|
||||
} finally {
|
||||
if (!signal?.aborted) {
|
||||
setLoading(false);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { handleFetchError } from "../utils/fetchError";
|
||||
import type { FetchError } from "../types/api";
|
||||
|
||||
export interface UsePolledDataOptions<TResponse, TData> {
|
||||
fetcher: (signal: AbortSignal) => Promise<TResponse>;
|
||||
@@ -20,15 +21,23 @@ export interface UsePolledDataOptions<TResponse, TData> {
|
||||
export interface UsePolledDataResult<TData> {
|
||||
data: TData | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
error: FetchError | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a single-item response and expose refresh semantics with polling support.
|
||||
*
|
||||
* Provides typed error handling through the `error` property, which is either
|
||||
* `null` or a discriminated `FetchError` union. Use the error's `type` field
|
||||
* to determine how to handle it:
|
||||
*
|
||||
* - `"api_error"`: Server returned HTTP error (check `status` for 401/403/50x)
|
||||
* - `"network_error"`: Network, DNS, or JSON parse failure
|
||||
* - `"abort_error"`: Request was cancelled (typically silently ignored by hook)
|
||||
*
|
||||
* @param options - Configuration options
|
||||
* @returns Data, loading state, error, and refresh callback
|
||||
* @returns Data, loading state, typed error, and refresh callback
|
||||
*/
|
||||
export function usePolledData<TResponse, TData>(
|
||||
options: UsePolledDataOptions<TResponse, TData>,
|
||||
@@ -45,7 +54,7 @@ export function usePolledData<TResponse, TData>(
|
||||
|
||||
const [data, setData] = useState<TData | null>(initialData ?? null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<FetchError | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const fetchRef = useRef<() => void>((): void => undefined);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { testRegex } from "../api/config";
|
||||
import { handleFetchError } from "../utils/fetchError";
|
||||
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
|
||||
import type { RegexTestRequest, RegexTestResponse } from "../types/config";
|
||||
|
||||
export interface UseRegexTesterResult {
|
||||
@@ -29,7 +29,7 @@ export function useRegexTester(): UseRegexTesterResult {
|
||||
setResult(resp);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
handleFetchError(err, setError, "Regex test failed");
|
||||
handleFetchError(err, createStringErrorAdapter(setError), "Regex test failed");
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { runImportNow } from "../api/blocklist";
|
||||
import { handleFetchError } from "../utils/fetchError";
|
||||
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
|
||||
import type { ImportRunResult } from "../types/blocklist";
|
||||
|
||||
export interface UseRunImportReturn {
|
||||
@@ -29,7 +29,7 @@ export function useRunImport(): UseRunImportReturn {
|
||||
const result = await runImportNow();
|
||||
setLastResult(result);
|
||||
} catch (err: unknown) {
|
||||
handleFetchError(err, setError, "Import failed");
|
||||
handleFetchError(err, createStringErrorAdapter(setError), "Import failed");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchSchedule, updateSchedule } from "../api/blocklist";
|
||||
import { handleFetchError } from "../utils/fetchError";
|
||||
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
|
||||
import type { ScheduleConfig, ScheduleInfo } from "../types/blocklist";
|
||||
|
||||
export interface UseScheduleReturn {
|
||||
@@ -39,7 +39,7 @@ export function useSchedule(): UseScheduleReturn {
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
handleFetchError(err, setError, "Failed to load schedule");
|
||||
handleFetchError(err, createStringErrorAdapter(setError), "Failed to load schedule");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchServerSettings, flushLogs, updateServerSettings } from "../api/server";
|
||||
import { reloadConfig, restartFail2Ban } from "../api/config";
|
||||
import { handleFetchError } from "../utils/fetchError";
|
||||
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
|
||||
import type { ServerSettings, ServerSettingsUpdate } from "../types/config";
|
||||
|
||||
export interface UseServerSettingsResult {
|
||||
@@ -43,7 +43,7 @@ export function useServerSettings(): UseServerSettingsResult {
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!ctrl.signal.aborted) {
|
||||
handleFetchError(err, setError, "Failed to fetch server settings");
|
||||
handleFetchError(err, createStringErrorAdapter(setError), "Failed to fetch server settings");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useCallback } from "react";
|
||||
import { fetchServerStatus } from "../api/dashboard";
|
||||
import { usePolledData } from "./usePolledData";
|
||||
import type { ServerStatus, ServerStatusResponse } from "../types/server";
|
||||
import type { FetchError } from "../types/api";
|
||||
|
||||
/** How often to poll the status endpoint (milliseconds). */
|
||||
const POLL_INTERVAL_MS = 30_000;
|
||||
@@ -21,7 +22,7 @@ export interface UseServerStatusResult {
|
||||
/** Whether a fetch is currently in flight. */
|
||||
loading: boolean;
|
||||
/** Error message string when the last fetch failed, otherwise `null`. */
|
||||
error: string | null;
|
||||
error: FetchError | null;
|
||||
/** Manually trigger a refresh immediately. */
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { fetchTimezone } from "../api/setup";
|
||||
import { handleFetchError } from "../utils/fetchError";
|
||||
import { handleFetchError, createStringErrorAdapter } from "../utils/fetchError";
|
||||
|
||||
export interface UseTimezoneDataResult {
|
||||
timezone: string;
|
||||
@@ -24,7 +24,7 @@ export function useTimezoneData(): UseTimezoneDataResult {
|
||||
setTimezone(resp.timezone);
|
||||
} catch (err: unknown) {
|
||||
if (signal?.aborted) return;
|
||||
handleFetchError(err, setError, "Failed to fetch timezone");
|
||||
handleFetchError(err, createStringErrorAdapter(setError), "Failed to fetch timezone");
|
||||
setTimezone("UTC");
|
||||
} finally {
|
||||
if (!signal?.aborted) {
|
||||
|
||||
Reference in New Issue
Block a user