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:
96
frontend/src/components/config/ConfigPageContainer.tsx
Normal file
96
frontend/src/components/config/ConfigPageContainer.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* ConfigPageContainer — orchestrates tab navigation and routing.
|
||||
*
|
||||
* Manages the TabList, routes tab selection events, and coordinates which
|
||||
* tab content is displayed. Delegates rendering of individual tabs to
|
||||
* specialized tab components.
|
||||
*
|
||||
* This component separates concerns: routing logic (useTabRouter) is isolated
|
||||
* from rendering, and each tab is a focused, domain-specific component.
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Tab, TabList, makeStyles, tokens } from "@fluentui/react-components";
|
||||
import { useTabRouter, type ConfigTabId } from "../../hooks/useTabRouter";
|
||||
import { ActionsTab } from "./ActionsTab";
|
||||
import { FiltersTab } from "./FiltersTab";
|
||||
import { JailsTab } from "./JailsTab";
|
||||
import { RegexTesterTab } from "./RegexTesterTab";
|
||||
import { ServerTab } from "./ServerTab";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
tabPanel: {
|
||||
display: "none",
|
||||
},
|
||||
tabPanelVisible: {
|
||||
display: "block",
|
||||
marginTop: tokens.spacingVerticalL,
|
||||
animationName: "fadeInUp",
|
||||
animationDuration: tokens.durationNormal,
|
||||
animationTimingFunction: tokens.curveDecelerateMid,
|
||||
animationFillMode: "both",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Container component for the configuration page.
|
||||
*
|
||||
* Renders the tab navigation bar and switches between tab content based on
|
||||
* the currently selected tab. Tab state is managed by useTabRouter and
|
||||
* synchronized with browser history.
|
||||
*
|
||||
* @returns JSX element with tab bar and active tab content.
|
||||
*/
|
||||
export function ConfigPageContainer(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { activeTab, activeItem, selectTab } = useTabRouter();
|
||||
|
||||
// Map tab IDs to their corresponding components.
|
||||
const tabComponents = useMemo(
|
||||
() => ({
|
||||
jails: <JailsTab initialJail={activeItem ?? undefined} />,
|
||||
filters: <FiltersTab />,
|
||||
actions: <ActionsTab />,
|
||||
server: <ServerTab />,
|
||||
regex: <RegexTesterTab />,
|
||||
}),
|
||||
[activeItem],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TabList
|
||||
selectedValue={activeTab}
|
||||
onTabSelect={(_e, d): void => {
|
||||
selectTab(d.value as ConfigTabId);
|
||||
}}
|
||||
>
|
||||
<Tab value="jails">Jails</Tab>
|
||||
<Tab value="filters">Filters</Tab>
|
||||
<Tab value="actions">Actions</Tab>
|
||||
<Tab value="server">Server</Tab>
|
||||
<Tab value="regex">Regex Tester</Tab>
|
||||
</TabList>
|
||||
|
||||
<div className={activeTab === "jails" ? styles.tabPanelVisible : styles.tabPanel}>
|
||||
{tabComponents.jails}
|
||||
</div>
|
||||
|
||||
<div className={activeTab === "filters" ? styles.tabPanelVisible : styles.tabPanel}>
|
||||
{tabComponents.filters}
|
||||
</div>
|
||||
|
||||
<div className={activeTab === "actions" ? styles.tabPanelVisible : styles.tabPanel}>
|
||||
{tabComponents.actions}
|
||||
</div>
|
||||
|
||||
<div className={activeTab === "server" ? styles.tabPanelVisible : styles.tabPanel}>
|
||||
{tabComponents.server}
|
||||
</div>
|
||||
|
||||
<div className={activeTab === "regex" ? styles.tabPanelVisible : styles.tabPanel}>
|
||||
{tabComponents.regex}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { ConfigPageContainer } from "../ConfigPageContainer";
|
||||
|
||||
// Mock all tab components to avoid deep render trees and API calls.
|
||||
vi.mock("../JailsTab", () => ({
|
||||
JailsTab: ({ initialJail }: { initialJail?: string }) => (
|
||||
<div data-testid="jails-tab" data-initial-jail={initialJail}>
|
||||
JailsTab
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../FiltersTab", () => ({
|
||||
FiltersTab: () => <div data-testid="filters-tab">FiltersTab</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../ActionsTab", () => ({
|
||||
ActionsTab: () => <div data-testid="actions-tab">ActionsTab</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../ServerTab", () => ({
|
||||
ServerTab: () => <div data-testid="server-tab">ServerTab</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../RegexTesterTab", () => ({
|
||||
RegexTesterTab: () => <div data-testid="regex-tab">RegexTesterTab</div>,
|
||||
}));
|
||||
|
||||
function renderContainer() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<ConfigPageContainer />
|
||||
</FluentProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ConfigPageContainer", () => {
|
||||
it("renders the tab list", () => {
|
||||
renderContainer();
|
||||
expect(screen.getByRole("tablist")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders all tab buttons", () => {
|
||||
renderContainer();
|
||||
expect(screen.getByRole("tab", { name: /jails/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /filters/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /actions/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /server/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /regex tester/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the Jails tab by default", () => {
|
||||
renderContainer();
|
||||
expect(screen.getByTestId("jails-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to Filters tab when clicked", () => {
|
||||
renderContainer();
|
||||
fireEvent.click(screen.getByRole("tab", { name: /filters/i }));
|
||||
expect(screen.getByTestId("filters-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to Actions tab when clicked", () => {
|
||||
renderContainer();
|
||||
fireEvent.click(screen.getByRole("tab", { name: /actions/i }));
|
||||
expect(screen.getByTestId("actions-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to Server tab when clicked", () => {
|
||||
renderContainer();
|
||||
fireEvent.click(screen.getByRole("tab", { name: /server/i }));
|
||||
expect(screen.getByTestId("server-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to Regex Tester tab when clicked", () => {
|
||||
renderContainer();
|
||||
fireEvent.click(screen.getByRole("tab", { name: /regex tester/i }));
|
||||
expect(screen.getByTestId("regex-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps all tab panels mounted (CSS display toggling)", () => {
|
||||
renderContainer();
|
||||
fireEvent.click(screen.getByRole("tab", { name: /filters/i }));
|
||||
expect(screen.getByTestId("jails-tab")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("filters-tab")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
* import { FiltersTab, RegexList } from "../components/config";
|
||||
*/
|
||||
|
||||
export { ConfigPageContainer } from "./ConfigPageContainer";
|
||||
export { ActionsTab } from "./ActionsTab";
|
||||
export { ActionForm } from "./ActionForm";
|
||||
export type { ActionFormProps } from "./ActionForm";
|
||||
|
||||
Reference in New Issue
Block a user