Extract generic useListData hook for shared list fetching

This commit is contained in:
2026-04-21 17:53:58 +02:00
parent e683108965
commit e244a85291
9 changed files with 232 additions and 169 deletions

View File

@@ -304,7 +304,9 @@ Issues are grouped by category and ordered roughly by severity. Each entry descr
--- ---
### TASK-016 — Extract generic `useListData` hook to eliminate duplicated fetch-list pattern ### TASK-016 — Extract generic `useListData` hook to eliminate duplicated fetch-list pattern (done)
**Where fixed:** `frontend/src/hooks/useListData.ts`, `frontend/src/hooks/useActionList.ts`, `frontend/src/hooks/useFilterList.ts`, `frontend/src/hooks/useJailConfigs.ts`, `frontend/src/hooks/useBlocklists.ts`, `frontend/src/api/config.ts`, `frontend/src/api/blocklist.ts`, `frontend/src/hooks/__tests__/useListData.test.ts`
**Where found:** `frontend/src/hooks/useActionList.ts`, `useFilterList.ts`, `useJailConfigs.ts`, and `useBlocklists.ts` each contain ~40 lines of nearly identical code: `useState` for data/loading/error, a `useRef<AbortController>`, a `refresh` callback with abort-create-set-loading-fetch-set logic, and a `useEffect` that calls `refresh` on mount. **Where found:** `frontend/src/hooks/useActionList.ts`, `useFilterList.ts`, `useJailConfigs.ts`, and `useBlocklists.ts` each contain ~40 lines of nearly identical code: `useState` for data/loading/error, a `useRef<AbortController>`, a `refresh` callback with abort-create-set-loading-fetch-set logic, and a `useEffect` that calls `refresh` on mount.

View File

@@ -21,8 +21,8 @@ import type {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Fetch all configured blocklist sources. */ /** Fetch all configured blocklist sources. */
export async function fetchBlocklists(): Promise<BlocklistListResponse> { export async function fetchBlocklists(signal?: AbortSignal): Promise<BlocklistListResponse> {
return get<BlocklistListResponse>(ENDPOINTS.blocklists); return get<BlocklistListResponse>(ENDPOINTS.blocklists, signal);
} }
/** Create a new blocklist source. */ /** Create a new blocklist source. */

View File

@@ -51,8 +51,9 @@ import type {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export async function fetchJailConfigs( export async function fetchJailConfigs(
signal?: AbortSignal,
): Promise<JailConfigListResponse> { ): Promise<JailConfigListResponse> {
return get<JailConfigListResponse>(ENDPOINTS.configJails); return get<JailConfigListResponse>(ENDPOINTS.configJails, signal);
} }
export async function fetchJailConfig( export async function fetchJailConfig(
@@ -344,8 +345,8 @@ export async function assignFilterToJail(
* *
* @returns FilterListResponse with all discovered filters and status. * @returns FilterListResponse with all discovered filters and status.
*/ */
export async function fetchFilters(): Promise<FilterListResponse> { export async function fetchFilters(signal?: AbortSignal): Promise<FilterListResponse> {
return get<FilterListResponse>(ENDPOINTS.configFilters); return get<FilterListResponse>(ENDPOINTS.configFilters, signal);
} }
/** /**
@@ -385,8 +386,8 @@ export async function updateParsedAction(
* *
* @returns ActionListResponse with all discovered actions and status. * @returns ActionListResponse with all discovered actions and status.
*/ */
export async function fetchActions(): Promise<ActionListResponse> { export async function fetchActions(signal?: AbortSignal): Promise<ActionListResponse> {
return get<ActionListResponse>(ENDPOINTS.configActions); return get<ActionListResponse>(ENDPOINTS.configActions, signal);
} }
/** /**

View File

@@ -0,0 +1,79 @@
import { describe, it, expect, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useListData } from "../useListData";
describe("useListData", () => {
it("loads items and updates loading", async () => {
const fetcher = vi.fn().mockResolvedValue({ items: ["one", "two"] });
const selector = vi.fn((response: { items: string[] }) => response.items);
const { result } = renderHook(() =>
useListData({
fetcher,
selector,
errorMessage: "Failed to load",
})
);
expect(result.current.loading).toBe(true);
expect(result.current.items).toEqual([]);
await act(async () => {
await Promise.resolve();
});
expect(fetcher).toHaveBeenCalledTimes(1);
expect(selector).toHaveBeenCalledWith({ items: ["one", "two"] });
expect(result.current.items).toEqual(["one", "two"]);
expect(result.current.loading).toBe(false);
});
it("sets error when the fetcher rejects", async () => {
const fetcher = vi.fn().mockRejectedValue(new Error("network"));
const selector = vi.fn();
const { result } = renderHook(() =>
useListData({
fetcher,
selector,
errorMessage: "Failed to load",
})
);
await act(async () => {
await Promise.resolve();
});
expect(result.current.error).toBe("network");
expect(result.current.loading).toBe(false);
expect(result.current.items).toEqual([]);
});
it("refresh triggers a second fetch", async () => {
const fetcher = vi.fn()
.mockResolvedValueOnce({ items: ["first"] })
.mockResolvedValueOnce({ items: ["second"] });
const selector = vi.fn((response: { items: string[] }) => response.items);
const { result } = renderHook(() =>
useListData({
fetcher,
selector,
errorMessage: "Failed to load",
})
);
await act(async () => {
await Promise.resolve();
});
expect(result.current.items).toEqual(["first"]);
await act(async () => {
result.current.refresh();
await Promise.resolve();
});
expect(fetcher).toHaveBeenCalledTimes(2);
expect(result.current.items).toEqual(["second"]);
});
});

View File

@@ -1,10 +1,10 @@
/** /**
* React hook for loading action metadata used by the actions tab. * React hook for loading action metadata used by the actions tab.
*/ */
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback } from "react";
import { fetchActions, removeActionFromJail, createAction, assignActionToJail } from "../api/config"; import { fetchActions, removeActionFromJail, createAction, assignActionToJail } from "../api/config";
import { handleFetchError } from "../utils/fetchError"; import { useListData } from "./useListData";
import type { ActionConfig, ActionCreateRequest } from "../types/config"; import type { ActionConfig, ActionCreateRequest, ActionListResponse } from "../types/config";
export interface UseActionListResult { export interface UseActionListResult {
actions: ActionConfig[]; actions: ActionConfig[];
@@ -20,43 +20,18 @@ export interface UseActionListResult {
* Load the action inventory and expose related action operations. * Load the action inventory and expose related action operations.
*/ */
export function useActionList(): UseActionListResult { export function useActionList(): UseActionListResult {
const [actions, setActions] = useState<ActionConfig[]>([]); const fetcher = useCallback(
const [loading, setLoading] = useState(true); (signal: AbortSignal) => fetchActions(signal),
const [error, setError] = useState<string | null>(null); [],
const abortRef = useRef<AbortController | null>(null); );
const refresh = useCallback((): void => { const selector = useCallback((response: ActionListResponse) => response.actions, []);
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true); const { items: actions, loading, error, refresh } = useListData<ActionListResponse, ActionConfig>({
setError(null); fetcher,
selector,
fetchActions() errorMessage: "Failed to load actions",
.then((resp) => { });
if (!controller.signal.aborted) {
setActions(resp.actions);
}
})
.catch((err: unknown) => {
if (!controller.signal.aborted) {
handleFetchError(err, setError, "Failed to load actions");
}
})
.finally(() => {
if (!controller.signal.aborted) {
setLoading(false);
}
});
}, []);
useEffect(() => {
refresh();
return (): void => {
abortRef.current?.abort();
};
}, [refresh]);
const handleRemoveActionFromJail = useCallback( const handleRemoveActionFromJail = useCallback(
async (jailName: string, actionName: string): Promise<void> => { async (jailName: string, actionName: string): Promise<void> => {

View File

@@ -2,7 +2,7 @@
* React hook for listing and mutating blocklist sources. * React hook for listing and mutating blocklist sources.
*/ */
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback } from "react";
import { import {
createBlocklist, createBlocklist,
deleteBlocklist, deleteBlocklist,
@@ -10,11 +10,12 @@ import {
previewBlocklist, previewBlocklist,
updateBlocklist, updateBlocklist,
} from "../api/blocklist"; } from "../api/blocklist";
import { handleFetchError } from "../utils/fetchError"; import { useListData } from "./useListData";
import type { import type {
BlocklistSource, BlocklistSource,
BlocklistSourceCreate, BlocklistSourceCreate,
BlocklistSourceUpdate, BlocklistSourceUpdate,
BlocklistListResponse,
PreviewResponse, PreviewResponse,
} from "../types/blocklist"; } from "../types/blocklist";
@@ -33,63 +34,44 @@ export interface UseBlocklistsReturn {
* Load all blocklist sources and expose CRUD operations. * Load all blocklist sources and expose CRUD operations.
*/ */
export function useBlocklists(): UseBlocklistsReturn { export function useBlocklists(): UseBlocklistsReturn {
const [sources, setSources] = useState<BlocklistSource[]>([]); const fetcher = useCallback(
const [loading, setLoading] = useState(true); (signal: AbortSignal) => fetchBlocklists(signal),
const [error, setError] = useState<string | null>(null); [],
const abortRef = useRef<AbortController | null>(null); );
const load = useCallback((): void => { const selector = useCallback((response: BlocklistListResponse) => response.sources, []);
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoading(true); const { items: sources, loading, error, refresh } = useListData<BlocklistListResponse, BlocklistSource>({
setError(null); fetcher,
selector,
fetchBlocklists() errorMessage: "Failed to load blocklists",
.then((data) => { });
if (!ctrl.signal.aborted) {
setSources(data.sources);
setLoading(false);
}
})
.catch((err: unknown) => {
if (!ctrl.signal.aborted) {
handleFetchError(err, setError, "Failed to load blocklists");
setLoading(false);
}
});
}, []);
useEffect(() => {
load();
return (): void => {
abortRef.current?.abort();
};
}, [load]);
const createSource = useCallback( const createSource = useCallback(
async (payload: BlocklistSourceCreate): Promise<BlocklistSource> => { async (payload: BlocklistSourceCreate): Promise<BlocklistSource> => {
const created = await createBlocklist(payload); const created = await createBlocklist(payload);
setSources((prev) => [...prev, created]); refresh();
return created; return created;
}, },
[], [refresh],
); );
const updateSource = useCallback( const updateSource = useCallback(
async (id: number, payload: BlocklistSourceUpdate): Promise<BlocklistSource> => { async (id: number, payload: BlocklistSourceUpdate): Promise<BlocklistSource> => {
const updated = await updateBlocklist(id, payload); const updated = await updateBlocklist(id, payload);
setSources((prev) => prev.map((s) => (s.id === id ? updated : s))); refresh();
return updated; return updated;
}, },
[], [refresh],
); );
const removeSource = useCallback(async (id: number): Promise<void> => { const removeSource = useCallback(
await deleteBlocklist(id); async (id: number): Promise<void> => {
setSources((prev) => prev.filter((s) => s.id !== id)); await deleteBlocklist(id);
}, []); refresh();
},
[refresh],
);
const previewSource = useCallback(async (id: number): Promise<PreviewResponse> => { const previewSource = useCallback(async (id: number): Promise<PreviewResponse> => {
return previewBlocklist(id); return previewBlocklist(id);
@@ -99,7 +81,7 @@ export function useBlocklists(): UseBlocklistsReturn {
sources, sources,
loading, loading,
error, error,
refresh: load, refresh,
createSource, createSource,
updateSource, updateSource,
removeSource, removeSource,

View File

@@ -1,10 +1,10 @@
/** /**
* React hook for loading filter config metadata used by the filter tab. * React hook for loading filter config metadata used by the filter tab.
*/ */
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback } from "react";
import { fetchFilters, createFilter, assignFilterToJail } from "../api/config"; import { fetchFilters, createFilter, assignFilterToJail } from "../api/config";
import { handleFetchError } from "../utils/fetchError"; import { useListData } from "./useListData";
import type { FilterConfig, FilterCreateRequest } from "../types/config"; import type { FilterConfig, FilterCreateRequest, FilterListResponse } from "../types/config";
export interface UseFilterListResult { export interface UseFilterListResult {
filters: FilterConfig[]; filters: FilterConfig[];
@@ -19,43 +19,18 @@ export interface UseFilterListResult {
* Load the filter inventory and expose refresh semantics. * Load the filter inventory and expose refresh semantics.
*/ */
export function useFilterList(): UseFilterListResult { export function useFilterList(): UseFilterListResult {
const [filters, setFilters] = useState<FilterConfig[]>([]); const fetcher = useCallback(
const [loading, setLoading] = useState(true); (signal: AbortSignal) => fetchFilters(signal),
const [error, setError] = useState<string | null>(null); [],
const abortRef = useRef<AbortController | null>(null); );
const refresh = useCallback((): void => { const selector = useCallback((response: FilterListResponse) => response.filters, []);
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true); const { items: filters, loading, error, refresh } = useListData<FilterListResponse, FilterConfig>({
setError(null); fetcher,
selector,
fetchFilters() errorMessage: "Failed to load filters",
.then((resp) => { });
if (!controller.signal.aborted) {
setFilters(resp.filters);
}
})
.catch((err: unknown) => {
if (!controller.signal.aborted) {
handleFetchError(err, setError, "Failed to load filters");
}
})
.finally(() => {
if (!controller.signal.aborted) {
setLoading(false);
}
});
}, []);
useEffect(() => {
refresh();
return (): void => {
abortRef.current?.abort();
};
}, [refresh]);
const handleCreateFilter = useCallback( const handleCreateFilter = useCallback(
async (payload: FilterCreateRequest): Promise<FilterConfig> => { async (payload: FilterCreateRequest): Promise<FilterConfig> => {

View File

@@ -2,10 +2,10 @@
* React hook for loading the jail config inventory. * React hook for loading the jail config inventory.
*/ */
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useState } from "react";
import { fetchJailConfigs, reloadConfig, updateJailConfig } from "../api/config"; import { fetchJailConfigs, reloadConfig, updateJailConfig } from "../api/config";
import { handleFetchError } from "../utils/fetchError"; import { useListData } from "./useListData";
import type { JailConfig, JailConfigUpdate } from "../types/config"; import type { JailConfig, JailConfigUpdate, JailConfigListResponse } from "../types/config";
export interface UseJailConfigsResult { export interface UseJailConfigsResult {
jails: JailConfig[]; jails: JailConfig[];
@@ -21,57 +21,36 @@ export interface UseJailConfigsResult {
* Load all jail configs and expose update controls. * Load all jail configs and expose update controls.
*/ */
export function useJailConfigs(): UseJailConfigsResult { export function useJailConfigs(): UseJailConfigsResult {
const [jails, setJails] = useState<JailConfig[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const load = useCallback((): void => { const fetcher = useCallback(
abortRef.current?.abort(); (signal: AbortSignal) => fetchJailConfigs(signal),
const ctrl = new AbortController(); [],
abortRef.current = ctrl; );
setLoading(true);
setError(null);
fetchJailConfigs() const selector = useCallback((response: JailConfigListResponse) => response.jails, []);
.then((resp) => {
if (!ctrl.signal.aborted) {
setJails(resp.jails);
setTotal(resp.total);
}
})
.catch((err: unknown) => {
if (!ctrl.signal.aborted) {
handleFetchError(err, setError, "Failed to fetch jail configs");
}
})
.finally(() => {
if (!abortRef.current?.signal.aborted) {
setLoading(false);
}
});
}, []);
useEffect(() => { const { items: jails, loading, error, refresh } = useListData<JailConfigListResponse, JailConfig>({
load(); fetcher,
return (): void => { selector,
abortRef.current?.abort(); errorMessage: "Failed to fetch jail configs",
}; onSuccess: (response) => {
}, [load]); setTotal(response.total);
},
});
const updateJail = useCallback( const updateJail = useCallback(
async (name: string, update: JailConfigUpdate): Promise<void> => { async (name: string, update: JailConfigUpdate): Promise<void> => {
await updateJailConfig(name, update); await updateJailConfig(name, update);
load(); refresh();
}, },
[load], [refresh],
); );
const reloadAll = useCallback(async (): Promise<void> => { const reloadAll = useCallback(async (): Promise<void> => {
await reloadConfig(); await reloadConfig();
load(); refresh();
}, [load]); }, [refresh]);
return { jails, total, loading, error, refresh: load, updateJail, reloadAll }; return { jails, total, loading, error, refresh, updateJail, reloadAll };
} }

View File

@@ -0,0 +1,70 @@
/**
* Generic hook for loading list data from an API endpoint.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { handleFetchError } from "../utils/fetchError";
export interface UseListDataOptions<TResponse, TItem> {
fetcher: (signal: AbortSignal) => Promise<TResponse>;
selector: (response: TResponse) => TItem[];
errorMessage: string;
onSuccess?: (response: TResponse) => void;
initialItems?: TItem[];
}
export interface UseListDataResult<TItem> {
items: TItem[];
loading: boolean;
error: string | null;
refresh: () => void;
}
/**
* Load a list response and expose refresh semantics with abort support.
*/
export function useListData<TResponse, TItem>(
options: UseListDataOptions<TResponse, TItem>,
): UseListDataResult<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 abortRef = useRef<AbortController | null>(null);
const refresh = useCallback((): void => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
setError(null);
fetcher(controller.signal)
.then((response) => {
if (controller.signal.aborted) return;
setItems(selector(response));
if (onSuccess) {
onSuccess(response);
}
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
handleFetchError(err, setError, errorMessage);
})
.finally(() => {
if (!controller.signal.aborted) {
setLoading(false);
}
});
}, [fetcher, selector, errorMessage, onSuccess]);
useEffect(() => {
refresh();
return (): void => {
abortRef.current?.abort();
};
}, [refresh]);
return { items, loading, error, refresh };
}