Refactor useHistory hook: replace HistoryQuery with explicit parameters and add documentation

- Split useHistory interface to accept explicit parameters (page, pageSize, range, origin, jail, ip, source) instead of HistoryQuery object
- Add comprehensive JSDoc for useHistory function
- Update HistoryPage and tests to use new parameter structure
- Move TaskList documentation from Tasks.md to Web-Development.md
- Improve type safety with explicit TimeRange and BanOriginFilter types

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-23 09:51:16 +02:00
parent 8904e180d1
commit 9c5757eeb0
5 changed files with 119 additions and 93 deletions

View File

@@ -6,7 +6,7 @@
* Rows with repeatedly-banned IPs are highlighted in amber.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Badge,
Button,
@@ -32,8 +32,7 @@ import {
import { DashboardFilterBar } from "../components/DashboardFilterBar";
import { useHistory } from "../hooks/useHistory";
import { IpDetailView } from "./history/IpDetailView";
import { areHistoryQueriesEqual } from "../utils/queryUtils";
import type { HistoryBanItem, HistoryQuery, TimeRange } from "../types/history";
import type { HistoryBanItem, TimeRange } from "../types/history";
import type { BanOriginFilter } from "../types/ban";
// ---------------------------------------------------------------------------
@@ -199,20 +198,21 @@ export function HistoryPage(): React.JSX.Element {
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
const [jailFilter, setJailFilter] = useState("");
const [ipFilter, setIpFilter] = useState("");
const defaultQuery: HistoryQuery = {
range: "7d",
source: "archive",
page_size: PAGE_SIZE,
page: 1,
};
const [appliedQuery, setAppliedQuery] = useState<HistoryQuery>(defaultQuery);
const appliedQueryRef = useRef<HistoryQuery>(defaultQuery);
const [page, setPage] = useState(1);
// Per-IP detail navigation
const [selectedIp, setSelectedIp] = useState<string | null>(null);
const { items, total, page, loading, error, setPage, refresh } =
useHistory(appliedQuery);
const { items, total, page: currentPage, loading, error, setPage: setCurrentPage, refresh } =
useHistory(
page,
PAGE_SIZE,
range,
originFilter !== "all" ? originFilter : undefined,
jailFilter.trim() || undefined,
ipFilter.trim() || undefined,
"archive",
);
const handleIpClick = useCallback((ip: string): void => {
setSelectedIp(ip);
@@ -223,25 +223,10 @@ export function HistoryPage(): React.JSX.Element {
[handleIpClick, styles],
);
// Reset to page 1 when filters change
useEffect((): void => {
const nextQuery: HistoryQuery = {
range,
origin: originFilter !== "all" ? originFilter : undefined,
jail: jailFilter.trim() || undefined,
ip: ipFilter.trim() || undefined,
source: "archive",
page: 1,
page_size: PAGE_SIZE,
};
if (areHistoryQueriesEqual(nextQuery, appliedQueryRef.current)) {
return;
}
setPage(1);
setAppliedQuery(nextQuery);
appliedQueryRef.current = nextQuery;
}, [range, originFilter, jailFilter, ipFilter, setPage]);
}, [range, originFilter, jailFilter, ipFilter]);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
@@ -310,7 +295,7 @@ export function HistoryPage(): React.JSX.Element {
{!loading && !error && (
<Text size={300} style={{ color: tokens.colorNeutralForeground3 }}>
{String(total)} record{total !== 1 ? "s" : ""} found ·
Page {String(page)} of {String(totalPages)} ·
Page {String(currentPage)} of {String(totalPages)} ·
Rows highlighted in yellow have {String(HIGH_BAN_THRESHOLD)}+ repeat bans
</Text>
)}
@@ -362,21 +347,21 @@ export function HistoryPage(): React.JSX.Element {
icon={<ChevronLeftRegular />}
appearance="subtle"
size="small"
disabled={page <= 1}
disabled={currentPage <= 1}
onClick={(): void => {
setPage(page - 1);
setCurrentPage(currentPage - 1);
}}
/>
<Text size={200}>
Page {String(page)} / {String(totalPages)}
Page {String(currentPage)} / {String(totalPages)}
</Text>
<Button
icon={<ChevronRightRegular />}
appearance="subtle"
size="small"
disabled={page >= totalPages}
disabled={currentPage >= totalPages}
onClick={(): void => {
setPage(page + 1);
setCurrentPage(currentPage + 1);
}}
/>
</div>

View File

@@ -3,10 +3,10 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
let lastQuery: Record<string, unknown> | null = null;
const mockUseHistory = vi.fn((query: Record<string, unknown>) => {
console.log("mockUseHistory called", query);
lastQuery = query;
let lastCallArgs: unknown[] | null = null;
const mockUseHistory = vi.fn((...args: unknown[]) => {
console.log("mockUseHistory called with args:", args);
lastCallArgs = args;
return {
items: [],
total: 0,
@@ -19,7 +19,7 @@ const mockUseHistory = vi.fn((query: Record<string, unknown>) => {
});
vi.mock("../../hooks/useHistory", () => ({
useHistory: (query: Record<string, unknown>) => mockUseHistory(query),
useHistory: (...args: unknown[]) => mockUseHistory(...args),
useIpHistory: () => ({ detail: null, loading: false, error: null, refresh: vi.fn() }),
}));
@@ -47,17 +47,18 @@ describe("HistoryPage", () => {
</FluentProvider>,
);
// Initial load should include the auto-applied default query.
// Initial load should have default parameters: page, pageSize, range, origin, jail, ip, source
// Arguments: (page: 1, pageSize: 50, range: "7d", origin: undefined, jail: undefined, ip: undefined, source: "archive")
await waitFor(() => {
expect(lastQuery).toEqual({
range: "7d",
source: "archive",
origin: undefined,
jail: undefined,
ip: undefined,
page: 1,
page_size: 50,
});
expect(lastCallArgs).toEqual([
1, // page
50, // pageSize
"7d", // range
undefined, // origin
undefined, // jail
undefined, // ip
"archive", // source
]);
});
expect(screen.queryByRole("button", { name: /apply/i })).toBeNull();
@@ -66,12 +67,12 @@ describe("HistoryPage", () => {
// Time-range and origin updates should be applied automatically.
await user.click(screen.getByRole("button", { name: /Last 7 days/i }));
await waitFor(() => {
expect(lastQuery).toMatchObject({ range: "7d" });
expect(lastCallArgs?.[2]).toBe("7d"); // range is at index 2
});
await user.click(screen.getByRole("button", { name: /Blocklist/i }));
await waitFor(() => {
expect(lastQuery).toMatchObject({ origin: "blocklist" });
expect(lastCallArgs?.[3]).toBe("blocklist"); // origin is at index 3
});
});
@@ -83,9 +84,9 @@ describe("HistoryPage", () => {
);
await waitFor(() => {
expect(lastQuery?.range).toBe("7d");
expect(lastCallArgs?.[2]).toBe("7d"); // range is at index 2
});
expect(mockUseHistory.mock.calls.every((call) => call[0].range === "7d")).toBe(true);
expect(mockUseHistory.mock.calls.every((call) => call[2] === "7d")).toBe(true);
});
});