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:
113
frontend/src/components/config/__tests__/ActionForm.test.tsx
Normal file
113
frontend/src/components/config/__tests__/ActionForm.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
114
frontend/src/components/config/__tests__/FilterForm.test.tsx
Normal file
114
frontend/src/components/config/__tests__/FilterForm.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user