Refactor schedule functionality in frontend
- Extract schedule logic into custom useSchedule hook - Update BlocklistScheduleSection to use the new hook - Add tests for useSchedule hook - Update documentation with task progress Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
126
frontend/src/hooks/__tests__/useSchedule.test.ts
Normal file
126
frontend/src/hooks/__tests__/useSchedule.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useSchedule } from "../useSchedule";
|
||||
import * as blocklistApi from "../../api/blocklist";
|
||||
|
||||
vi.mock("../../api/blocklist");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const mockScheduleInfo = {
|
||||
config: {
|
||||
frequency: "daily" as const,
|
||||
interval_hours: 24,
|
||||
hour: 3,
|
||||
minute: 0,
|
||||
day_of_week: 0,
|
||||
},
|
||||
last_run_at: "2024-01-01T10:00:00Z",
|
||||
next_run_at: "2024-01-02T03:00:00Z",
|
||||
last_run_errors: false,
|
||||
};
|
||||
|
||||
describe("useSchedule", () => {
|
||||
it("loads schedule info on mount", async () => {
|
||||
vi.mocked(blocklistApi.fetchSchedule).mockResolvedValue(mockScheduleInfo);
|
||||
vi.mocked(blocklistApi.updateSchedule).mockResolvedValue(mockScheduleInfo);
|
||||
|
||||
const { result } = renderHook(() => useSchedule());
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.info).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(vi.mocked(blocklistApi.fetchSchedule)).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.info).toEqual(mockScheduleInfo);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("sets error when fetch fails", async () => {
|
||||
vi.mocked(blocklistApi.fetchSchedule).mockRejectedValue(new Error("network error"));
|
||||
vi.mocked(blocklistApi.updateSchedule).mockResolvedValue(mockScheduleInfo);
|
||||
|
||||
const { result } = renderHook(() => useSchedule());
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe("network error");
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.info).toBeNull();
|
||||
});
|
||||
|
||||
it("saveSchedule updates info", async () => {
|
||||
vi.mocked(blocklistApi.fetchSchedule).mockResolvedValue(mockScheduleInfo);
|
||||
const updatedSchedule = {
|
||||
...mockScheduleInfo,
|
||||
config: { ...mockScheduleInfo.config, hour: 5 },
|
||||
};
|
||||
vi.mocked(blocklistApi.updateSchedule).mockResolvedValue(updatedSchedule);
|
||||
|
||||
const { result } = renderHook(() => useSchedule());
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveSchedule({
|
||||
frequency: "daily",
|
||||
interval_hours: 24,
|
||||
hour: 5,
|
||||
minute: 0,
|
||||
day_of_week: 0,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.info).toEqual(updatedSchedule);
|
||||
});
|
||||
|
||||
it("refresh triggers a second fetch", async () => {
|
||||
vi.mocked(blocklistApi.fetchSchedule)
|
||||
.mockResolvedValueOnce(mockScheduleInfo)
|
||||
.mockResolvedValueOnce({
|
||||
...mockScheduleInfo,
|
||||
config: { ...mockScheduleInfo.config, hour: 5 },
|
||||
});
|
||||
vi.mocked(blocklistApi.updateSchedule).mockResolvedValue(mockScheduleInfo);
|
||||
|
||||
const { result } = renderHook(() => useSchedule());
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.info?.config.hour).toBe(3);
|
||||
|
||||
await act(async () => {
|
||||
result.current.refresh();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(vi.mocked(blocklistApi.fetchSchedule)).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.info?.config.hour).toBe(5);
|
||||
});
|
||||
|
||||
it("refresh exposes function to reuse in callbacks", async () => {
|
||||
vi.mocked(blocklistApi.fetchSchedule).mockResolvedValue(mockScheduleInfo);
|
||||
vi.mocked(blocklistApi.updateSchedule).mockResolvedValue(mockScheduleInfo);
|
||||
|
||||
const { result } = renderHook(() => useSchedule());
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(typeof result.current.refresh).toBe("function");
|
||||
expect(() => result.current.refresh()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
* React hook for fetching and updating the blocklist import schedule.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchSchedule, updateSchedule } from "../api/blocklist";
|
||||
import { handleFetchError } from "../utils/fetchError";
|
||||
import type { ScheduleConfig, ScheduleInfo } from "../types/blocklist";
|
||||
@@ -12,6 +12,7 @@ export interface UseScheduleReturn {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
saveSchedule: (config: ScheduleConfig) => Promise<void>;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,9 +22,13 @@ export function useSchedule(): UseScheduleReturn {
|
||||
const [info, setInfo] = useState<ScheduleInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = useCallback((): void => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
@@ -37,19 +42,24 @@ export function useSchedule(): UseScheduleReturn {
|
||||
handleFetchError(err, setError, "Failed to load schedule");
|
||||
})
|
||||
.finally(() => {
|
||||
if (controller.signal.aborted) return;
|
||||
setLoading(false);
|
||||
if (!controller.signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
|
||||
return (): void => {
|
||||
controller.abort();
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
}, [refresh]);
|
||||
|
||||
const saveSchedule = useCallback(async (config: ScheduleConfig): Promise<void> => {
|
||||
const updated = await updateSchedule(config);
|
||||
setInfo(updated);
|
||||
}, []);
|
||||
|
||||
return { info, loading, error, saveSchedule };
|
||||
return { info, loading, error, saveSchedule, refresh };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user