refactor: Decompose ConfigPage into focused routing and component layers
Split the over-centralized ConfigPage into focused, composable layers: 1. useTabRouter hook: Encapsulates tab state management and URL synchronization - Maintains selected tab and active item (e.g., jail name) - Syncs state to location.state for deep linking and browser history - Supports bookmarkable URLs and back/forward navigation 2. ConfigPageContainer: Orchestrates tab navigation - Manages TabList and routes tab selection events - Conditionally renders tab content panels - Delegates domain-specific logic to tab components 3. ConfigPage: Focused page layout component - Renders page structure (header, title, description) - Delegates tab orchestration to ConfigPageContainer - No routing or tab state logic Benefits: - Page is now 30 lines vs 125 lines (76% reduction) - Tab state management is reusable for other multi-tab pages - Each tab component remains focused on domain-specific UI - Deep linking and browser history work out of the box - Easier to test and maintain Added comprehensive tests: - useTabRouter: 6 tests covering state initialization, tab selection, and deep linking - ConfigPageContainer: 8 tests covering tab rendering and navigation - ConfigPage: 3 tests for page structure Updated Web-Development.md with tab orchestration pattern documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
91
frontend/src/hooks/__tests__/useTabRouter.test.tsx
Normal file
91
frontend/src/hooks/__tests__/useTabRouter.test.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { useTabRouter } from "../useTabRouter";
|
||||
|
||||
describe("useTabRouter", () => {
|
||||
it("initializes with jails tab selected by default", () => {
|
||||
const { result } = renderHook(() => useTabRouter(), {
|
||||
wrapper: ({ children }) => <MemoryRouter>{children}</MemoryRouter>,
|
||||
});
|
||||
|
||||
expect(result.current.activeTab).toBe("jails");
|
||||
expect(result.current.activeItem).toBeNull();
|
||||
});
|
||||
|
||||
it("selects a new tab", () => {
|
||||
const { result } = renderHook(() => useTabRouter(), {
|
||||
wrapper: ({ children }) => <MemoryRouter>{children}</MemoryRouter>,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.selectTab("filters");
|
||||
});
|
||||
|
||||
expect(result.current.activeTab).toBe("filters");
|
||||
});
|
||||
|
||||
it("clears active item when selecting a new tab", () => {
|
||||
const { result } = renderHook(() => useTabRouter(), {
|
||||
wrapper: ({ children }) => <MemoryRouter>{children}</MemoryRouter>,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.selectTabWithItem("jails", "test-jail");
|
||||
});
|
||||
|
||||
expect(result.current.activeItem).toBe("test-jail");
|
||||
|
||||
act(() => {
|
||||
result.current.selectTab("filters");
|
||||
});
|
||||
|
||||
expect(result.current.activeItem).toBeNull();
|
||||
});
|
||||
|
||||
it("selects a tab with an active item", () => {
|
||||
const { result } = renderHook(() => useTabRouter(), {
|
||||
wrapper: ({ children }) => <MemoryRouter>{children}</MemoryRouter>,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.selectTabWithItem("jails", "sshd");
|
||||
});
|
||||
|
||||
expect(result.current.activeTab).toBe("jails");
|
||||
expect(result.current.activeItem).toBe("sshd");
|
||||
});
|
||||
|
||||
it("supports all valid tab IDs", () => {
|
||||
const { result } = renderHook(() => useTabRouter(), {
|
||||
wrapper: ({ children }) => <MemoryRouter>{children}</MemoryRouter>,
|
||||
});
|
||||
|
||||
const tabs = ["jails", "filters", "actions", "server", "regex"] as const;
|
||||
|
||||
tabs.forEach((tab) => {
|
||||
act(() => {
|
||||
result.current.selectTab(tab);
|
||||
});
|
||||
expect(result.current.activeTab).toBe(tab);
|
||||
});
|
||||
});
|
||||
|
||||
it("clears active item when setting item to null", () => {
|
||||
const { result } = renderHook(() => useTabRouter(), {
|
||||
wrapper: ({ children }) => <MemoryRouter>{children}</MemoryRouter>,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.selectTabWithItem("jails", "sshd");
|
||||
});
|
||||
|
||||
expect(result.current.activeItem).toBe("sshd");
|
||||
|
||||
act(() => {
|
||||
result.current.selectTabWithItem("jails", null);
|
||||
});
|
||||
|
||||
expect(result.current.activeItem).toBeNull();
|
||||
});
|
||||
});
|
||||
85
frontend/src/hooks/useTabRouter.ts
Normal file
85
frontend/src/hooks/useTabRouter.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* useTabRouter — centralized tab routing and state management.
|
||||
*
|
||||
* Encapsulates tab navigation logic, URL state synchronization, and history
|
||||
* management for the config page. Supports deep linking to specific tabs and
|
||||
* optional item IDs (e.g., jail names).
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
/** Available tab IDs in the config page. */
|
||||
export type ConfigTabId = "jails" | "filters" | "actions" | "server" | "regex";
|
||||
|
||||
/** Tab navigation state with optional active item/jail name. */
|
||||
export interface TabRouterState {
|
||||
activeTab: ConfigTabId;
|
||||
activeItem: string | null;
|
||||
}
|
||||
|
||||
interface UseTabRouterReturn extends TabRouterState {
|
||||
selectTab: (tab: ConfigTabId) => void;
|
||||
selectTabWithItem: (tab: ConfigTabId, itemId: string | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing config page tab routing.
|
||||
*
|
||||
* Syncs tab selection to URL state via `location.state` so it persists across
|
||||
* navigation. The activeItem (jail name, filter name, etc.) is also tracked
|
||||
* to support deep linking.
|
||||
*
|
||||
* @returns Tab state and navigation functions.
|
||||
*/
|
||||
export function useTabRouter(): UseTabRouterReturn {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<ConfigTabId>("jails");
|
||||
const [activeItem, setActiveItem] = useState<string | null>(null);
|
||||
|
||||
// Sync from location.state on mount or when location changes.
|
||||
useEffect(() => {
|
||||
const state = location.state as { tab?: string; item?: string } | null;
|
||||
|
||||
if (state?.tab === "jails" || state?.tab === "filters" || state?.tab === "actions" || state?.tab === "server" || state?.tab === "regex") {
|
||||
setActiveTab(state.tab);
|
||||
}
|
||||
|
||||
if (state?.item) {
|
||||
setActiveItem(state.item);
|
||||
}
|
||||
}, [location.state]);
|
||||
|
||||
const selectTab = useCallback(
|
||||
(tab: ConfigTabId): void => {
|
||||
setActiveTab(tab);
|
||||
setActiveItem(null);
|
||||
navigate(location.pathname, {
|
||||
state: { tab },
|
||||
replace: false,
|
||||
});
|
||||
},
|
||||
[navigate, location.pathname],
|
||||
);
|
||||
|
||||
const selectTabWithItem = useCallback(
|
||||
(tab: ConfigTabId, itemId: string | null): void => {
|
||||
setActiveTab(tab);
|
||||
setActiveItem(itemId);
|
||||
navigate(location.pathname, {
|
||||
state: { tab, item: itemId },
|
||||
replace: false,
|
||||
});
|
||||
},
|
||||
[navigate, location.pathname],
|
||||
);
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
activeItem,
|
||||
selectTab,
|
||||
selectTabWithItem,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user