Extract generic useListData hook for shared list fetching
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
79
frontend/src/hooks/__tests__/useListData.test.ts
Normal file
79
frontend/src/hooks/__tests__/useListData.test.ts
Normal 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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> => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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> => {
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
70
frontend/src/hooks/useListData.ts
Normal file
70
frontend/src/hooks/useListData.ts
Normal 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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user