refactor(frontend): decompose ConfigPage into dedicated config components

- Extract tab components: JailsTab, ActionsTab, FiltersTab, JailFilesTab,
  GlobalTab, ServerTab, ConfFilesTab, RegexTesterTab, MapTab, ExportTab
- Add form components: JailFileForm, ActionForm, FilterForm
- Add AutoSaveIndicator, RegexList, configStyles, and barrel index
- ConfigPage now composes these components; greatly reduces file size
- Add tests: ConfigPage.test.tsx, useAutoSave.test.ts
This commit is contained in:
2026-03-13 13:48:09 +01:00
parent a0e8566ff8
commit 9b73f6719d
23 changed files with 4275 additions and 1828 deletions

View File

@@ -0,0 +1,113 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { ActionForm } from "../ActionForm";
import type { ActionConfig } from "../../../types/config";
// Mock the useActionConfig hook so tests don't make real API calls.
vi.mock("../../../hooks/useActionConfig");
import { useActionConfig } from "../../../hooks/useActionConfig";
const mockUseActionConfig = vi.mocked(useActionConfig);
const mockConfig: ActionConfig = {
name: "iptables",
filename: "iptables.conf",
before: null,
after: null,
actionstart: "iptables -N fail2ban-<name>",
actionstop: "iptables -F fail2ban-<name>",
actioncheck: null,
actionban: "iptables -I INPUT -s <ip> -j DROP",
actionunban: "iptables -D INPUT -s <ip> -j DROP",
actionflush: null,
definition_vars: {},
init_vars: {},
};
function renderForm(name: string) {
return render(
<FluentProvider theme={webLightTheme}>
<ActionForm name={name} />
</FluentProvider>,
);
}
describe("ActionForm", () => {
it("shows skeleton while loading", () => {
mockUseActionConfig.mockReturnValue({
config: null,
loading: true,
error: null,
saving: false,
saveError: null,
refresh: vi.fn(),
save: vi.fn(),
});
renderForm("iptables");
expect(screen.getByLabelText(/loading iptables/i)).toBeInTheDocument();
});
it("shows error message when loading fails", () => {
mockUseActionConfig.mockReturnValue({
config: null,
loading: false,
error: "Timeout",
saving: false,
saveError: null,
refresh: vi.fn(),
save: vi.fn(),
});
renderForm("iptables");
expect(screen.getByText(/timeout/i)).toBeInTheDocument();
});
it("shows fallback error when config is null with no error message", () => {
mockUseActionConfig.mockReturnValue({
config: null,
loading: false,
error: null,
saving: false,
saveError: null,
refresh: vi.fn(),
save: vi.fn(),
});
renderForm("iptables");
expect(screen.getByText(/failed to load action config/i)).toBeInTheDocument();
});
it("renders form accordion sections when config is loaded", () => {
mockUseActionConfig.mockReturnValue({
config: mockConfig,
loading: false,
error: null,
saving: false,
saveError: null,
refresh: vi.fn(),
save: vi.fn(),
});
renderForm("iptables");
expect(screen.getByText(/includes/i)).toBeInTheDocument();
expect(screen.getByText(/definition/i)).toBeInTheDocument();
});
it("passes the action name to useActionConfig", () => {
mockUseActionConfig.mockReturnValue({
config: null,
loading: true,
error: null,
saving: false,
saveError: null,
refresh: vi.fn(),
save: vi.fn(),
});
renderForm("iptables-multiport");
expect(mockUseActionConfig).toHaveBeenCalledWith("iptables-multiport");
});
});

View File

@@ -0,0 +1,51 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { AutoSaveIndicator } from "../AutoSaveIndicator";
function renderIndicator(props: Parameters<typeof AutoSaveIndicator>[0]) {
return render(
<FluentProvider theme={webLightTheme}>
<AutoSaveIndicator {...props} />
</FluentProvider>,
);
}
describe("AutoSaveIndicator", () => {
it("renders aria-live region when idle with no visible text", () => {
renderIndicator({ status: "idle" });
const region = screen.getByRole("status");
expect(region).toBeInTheDocument();
expect(region).toHaveAttribute("aria-live", "polite");
// No visible text content for idle
expect(region.textContent).toBe("");
});
it("shows spinner and Saving text when saving", () => {
renderIndicator({ status: "saving" });
expect(screen.getByText(/saving/i)).toBeInTheDocument();
});
it("shows Saved badge when saved", () => {
renderIndicator({ status: "saved" });
expect(screen.getByText(/saved/i)).toBeInTheDocument();
});
it("shows error text when status is error", () => {
renderIndicator({ status: "error", errorText: "Network error" });
expect(screen.getByText(/network error/i)).toBeInTheDocument();
});
it("shows fallback error text when errorText is null", () => {
renderIndicator({ status: "error", errorText: null });
expect(screen.getByText(/save failed/i)).toBeInTheDocument();
});
it("calls onRetry when retry button is clicked", () => {
const onRetry = vi.fn();
renderIndicator({ status: "error", errorText: "Oops", onRetry });
const retryBtn = screen.getByRole("button", { name: /retry/i });
fireEvent.click(retryBtn);
expect(onRetry).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,114 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { FilterForm } from "../FilterForm";
import type { FilterConfig } from "../../../types/config";
// Mock the useFilterConfig hook so tests don't make real API calls.
vi.mock("../../../hooks/useFilterConfig");
import { useFilterConfig } from "../../../hooks/useFilterConfig";
const mockUseFilterConfig = vi.mocked(useFilterConfig);
const mockConfig: FilterConfig = {
name: "sshd",
filename: "sshd.conf",
before: null,
after: null,
variables: {},
prefregex: null,
failregex: ["^<HOST> port \\d+"],
ignoreregex: [],
maxlines: null,
datepattern: null,
journalmatch: null,
};
function renderForm(name: string) {
return render(
<FluentProvider theme={webLightTheme}>
<FilterForm name={name} />
</FluentProvider>,
);
}
describe("FilterForm", () => {
it("shows skeleton while loading", () => {
mockUseFilterConfig.mockReturnValue({
config: null,
loading: true,
error: null,
saving: false,
saveError: null,
refresh: vi.fn(),
save: vi.fn(),
});
renderForm("sshd");
// aria-label on Skeleton container
expect(screen.getByLabelText(/loading sshd/i)).toBeInTheDocument();
});
it("shows error message when loading fails", () => {
mockUseFilterConfig.mockReturnValue({
config: null,
loading: false,
error: "Network error",
saving: false,
saveError: null,
refresh: vi.fn(),
save: vi.fn(),
});
renderForm("sshd");
expect(screen.getByText(/network error/i)).toBeInTheDocument();
});
it("shows fallback error when config is null with no error message", () => {
mockUseFilterConfig.mockReturnValue({
config: null,
loading: false,
error: null,
saving: false,
saveError: null,
refresh: vi.fn(),
save: vi.fn(),
});
renderForm("sshd");
expect(screen.getByText(/failed to load filter config/i)).toBeInTheDocument();
});
it("renders form accordion sections when config is loaded", () => {
mockUseFilterConfig.mockReturnValue({
config: mockConfig,
loading: false,
error: null,
saving: false,
saveError: null,
refresh: vi.fn(),
save: vi.fn(),
});
renderForm("sshd");
// Accordion headers should be visible
expect(screen.getByText(/includes/i)).toBeInTheDocument();
expect(screen.getByText(/definition/i)).toBeInTheDocument();
});
it("passes the filter name to useFilterConfig", () => {
mockUseFilterConfig.mockReturnValue({
config: null,
loading: true,
error: null,
saving: false,
saveError: null,
refresh: vi.fn(),
save: vi.fn(),
});
renderForm("nginx");
expect(mockUseFilterConfig).toHaveBeenCalledWith("nginx");
});
});