diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 7dac8ed..6c92218 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -4,7 +4,7 @@ This document breaks the entire BanGUI project into development stages, ordered --- -## Config View Redesign — List/Detail Layout with Active Status and Raw Export +## Config View Redesign — List/Detail Layout with Active Status and Raw Export ✅ COMPLETE ### Overview diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts index fabb850..e271b4c 100644 --- a/frontend/src/api/config.ts +++ b/frontend/src/api/config.ts @@ -182,6 +182,13 @@ export async function fetchJailConfigFileContent( return get(ENDPOINTS.configJailFile(filename)); } +export async function updateJailConfigFile( + filename: string, + req: ConfFileUpdateRequest +): Promise { + await put(ENDPOINTS.configJailFile(filename), req); +} + export async function setJailConfigFileEnabled( filename: string, update: JailConfigFileEnabledUpdate diff --git a/frontend/src/components/__tests__/ConfigPageLogPath.test.tsx b/frontend/src/components/__tests__/ConfigPageLogPath.test.tsx index 849935c..b18ebbe 100644 --- a/frontend/src/components/__tests__/ConfigPageLogPath.test.tsx +++ b/frontend/src/components/__tests__/ConfigPageLogPath.test.tsx @@ -36,6 +36,7 @@ const { mockUpdateServerSettings, mockFlushLogs, mockSetJailConfigFileEnabled, + mockUpdateJailConfigFile, } = vi.hoisted(() => ({ mockAddLogPath: vi.fn<() => Promise>().mockResolvedValue(undefined), mockDeleteLogPath: vi.fn<() => Promise>().mockResolvedValue(undefined), @@ -73,6 +74,7 @@ const { mockUpdateServerSettings: vi.fn<() => Promise>().mockResolvedValue(undefined), mockFlushLogs: vi.fn().mockResolvedValue({ message: "ok" }), mockSetJailConfigFileEnabled: vi.fn<() => Promise>().mockResolvedValue(undefined), + mockUpdateJailConfigFile: vi.fn<() => Promise>().mockResolvedValue(undefined), })); vi.mock("../../api/config", () => ({ @@ -91,6 +93,7 @@ vi.mock("../../api/config", () => ({ updateMapColorThresholds: mockUpdateMapColorThresholds, fetchJailConfigFiles: mockFetchJailConfigFiles, fetchJailConfigFileContent: vi.fn(), + updateJailConfigFile: mockUpdateJailConfigFile, setJailConfigFileEnabled: mockSetJailConfigFileEnabled, fetchFilterFiles: mockFetchFilterFiles, fetchFilterFile: vi.fn(), @@ -104,6 +107,10 @@ vi.mock("../../api/config", () => ({ testRegex: vi.fn(), })); +vi.mock("../../api/jails", () => ({ + fetchJails: vi.fn().mockResolvedValue({ jails: [], total: 0 }), +})); + /** Minimal jail fixture used across tests. */ const MOCK_JAIL: JailConfig = { name: "sshd", @@ -134,10 +141,10 @@ function renderConfigPage() { ); } -/** Waits for the sshd accordion button to appear and clicks it open. */ +/** Waits for the sshd list item to appear and clicks it to open the detail pane. */ async function openSshdAccordion(user: ReturnType) { - const accordionBtn = await screen.findByRole("button", { name: /sshd/i }); - await user.click(accordionBtn); + const listItem = await screen.findByRole("option", { name: /sshd/i }); + await user.click(listItem); } // --------------------------------------------------------------------------- diff --git a/frontend/src/components/config/configStyles.ts b/frontend/src/components/config/configStyles.ts index ac167a4..aa2f86d 100644 --- a/frontend/src/components/config/configStyles.ts +++ b/frontend/src/components/config/configStyles.ts @@ -12,6 +12,71 @@ export const useConfigStyles = makeStyles({ padding: tokens.spacingVerticalXXL, maxWidth: "1100px", }, + // ------------------------------------------------------------------------- + // List/Detail layout (ConfigListDetail component) + // ------------------------------------------------------------------------- + /** Root flex-row container for the two-pane list/detail layout. */ + listDetailRoot: { + display: "flex", + flexDirection: "row", + gap: tokens.spacingHorizontalM, + minHeight: "400px", + "@media (max-width: 900px)": { + flexDirection: "column", + }, + }, + /** Fixed-width left pane with scrollable item list. */ + listPane: { + width: "280px", + flexShrink: "0", + overflowY: "auto", + borderRight: `1px solid ${tokens.colorNeutralStroke2}`, + paddingRight: tokens.spacingHorizontalS, + "@media (max-width: 900px)": { + width: "100%", + borderRight: "none", + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + paddingRight: "0", + paddingBottom: tokens.spacingVerticalS, + overflowY: "visible", + }, + }, + /** A single clickable item in the left pane. */ + listItem: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`, + borderRadius: tokens.borderRadiusMedium, + cursor: "pointer", + userSelect: "none", + overflow: "hidden", + ":hover": { + backgroundColor: tokens.colorNeutralBackground2, + }, + ":focus-visible": { + outline: `2px solid ${tokens.colorBrandBackground}`, + outlineOffset: "2px", + }, + }, + /** Additional styles applied to the currently selected list item. */ + listItemSelected: { + backgroundColor: tokens.colorNeutralBackground1Selected, + borderLeft: `3px solid ${tokens.colorBrandBackground}`, + paddingLeft: `calc(${tokens.spacingHorizontalM} - 3px)`, + ":hover": { + backgroundColor: tokens.colorNeutralBackground1Selected, + }, + }, + /** Right pane that shows detail content for the selected item. */ + detailPane: { + flex: "1", + overflowY: "auto", + paddingLeft: tokens.spacingHorizontalM, + "@media (max-width: 900px)": { + paddingLeft: "0", + }, + }, header: { marginBottom: tokens.spacingVerticalL, }, @@ -176,6 +241,11 @@ export function injectGlobalStyles(): void { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } } + /* ConfigListDetail responsive: show dropdown on narrow screens */ + @media (max-width: 900px) { + .bangui-list-dropdown { display: block !important; } + .bangui-list-items { display: none !important; } + } `; document.head.appendChild(style); } diff --git a/frontend/src/components/config/index.ts b/frontend/src/components/config/index.ts index 11e0c65..0ec8e4d 100644 --- a/frontend/src/components/config/index.ts +++ b/frontend/src/components/config/index.ts @@ -12,6 +12,8 @@ export { AutoSaveIndicator } from "./AutoSaveIndicator"; export type { AutoSaveStatus, AutoSaveIndicatorProps } from "./AutoSaveIndicator"; export { ConfFilesTab } from "./ConfFilesTab"; export type { ConfFilesTabProps } from "./ConfFilesTab"; +export { ConfigListDetail } from "./ConfigListDetail"; +export type { ConfigListDetailProps } from "./ConfigListDetail"; export { ExportTab } from "./ExportTab"; export { FilterForm } from "./FilterForm"; export type { FilterFormProps } from "./FilterForm"; @@ -21,6 +23,8 @@ export { JailFilesTab } from "./JailFilesTab"; export { JailFileForm } from "./JailFileForm"; export { JailsTab } from "./JailsTab"; export { MapTab } from "./MapTab"; +export { RawConfigSection } from "./RawConfigSection"; +export type { RawConfigSectionProps } from "./RawConfigSection"; export { RegexList } from "./RegexList"; export type { RegexListProps } from "./RegexList"; export { RegexTesterTab } from "./RegexTesterTab"; diff --git a/frontend/src/setupTests.ts b/frontend/src/setupTests.ts index 9f7cf7e..328e169 100644 --- a/frontend/src/setupTests.ts +++ b/frontend/src/setupTests.ts @@ -17,6 +17,9 @@ class ResizeObserverStub { globalThis.ResizeObserver = ResizeObserverStub; +// jsdom does not implement scrollIntoView. +Element.prototype.scrollIntoView = () => {}; + // Fluent UI animations rely on matchMedia. Object.defineProperty(window, "matchMedia", { writable: true,