Persist sidebar collapsed preference to localStorage
This commit is contained in:
@@ -415,7 +415,9 @@ const source = timeRange === "24h" ? "fail2ban" : "archive";
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### TASK-021 — Persist sidebar collapse preference to `localStorage`
|
### TASK-021 — Persist sidebar collapse preference to `localStorage` (done)
|
||||||
|
|
||||||
|
**Where fixed:** `frontend/src/layouts/MainLayout.tsx`
|
||||||
|
|
||||||
**Where found:** `frontend/src/layouts/MainLayout.tsx` — `useState(() => window.innerWidth < 640)` initialises collapsed state but never saves the user's toggle preference.
|
**Where found:** `frontend/src/layouts/MainLayout.tsx` — `useState(() => window.innerWidth < 640)` initialises collapsed state but never saves the user's toggle preference.
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { useBlocklistStatus } from "../hooks/useBlocklist";
|
|||||||
|
|
||||||
const SIDEBAR_FULL = "240px";
|
const SIDEBAR_FULL = "240px";
|
||||||
const SIDEBAR_COLLAPSED = "48px";
|
const SIDEBAR_COLLAPSED = "48px";
|
||||||
|
const SIDEBAR_COLLAPSED_STORAGE_KEY = "bangui_sidebar_collapsed";
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
root: {
|
root: {
|
||||||
@@ -220,23 +221,52 @@ export function MainLayout(): React.JSX.Element {
|
|||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const { logout } = useAuth();
|
const { logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
// Initialise collapsed based on screen width so narrow viewports start
|
|
||||||
// with the icon-only sidebar rather than the full-width one.
|
const readSavedCollapsed = (): boolean => {
|
||||||
const [collapsed, setCollapsed] = useState(() => window.innerWidth < 640);
|
try {
|
||||||
|
const savedValue = localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY);
|
||||||
|
if (savedValue === "true") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (savedValue === "false") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors and fall back to viewport heuristics.
|
||||||
|
}
|
||||||
|
return window.innerWidth < 640;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [collapsed, setCollapsed] = useState<boolean>(readSavedCollapsed);
|
||||||
const { status } = useServerStatus();
|
const { status } = useServerStatus();
|
||||||
const { hasErrors: blocklistHasErrors } = useBlocklistStatus();
|
const { hasErrors: blocklistHasErrors } = useBlocklistStatus();
|
||||||
|
|
||||||
/** True only after the first successful poll and fail2ban is unreachable. */
|
/** True only after the first successful poll and fail2ban is unreachable. */
|
||||||
const serverOffline = status !== null && !status.online;
|
const serverOffline = status !== null && !status.online;
|
||||||
|
|
||||||
// Auto-collapse / auto-expand when the viewport crosses the 640 px breakpoint.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SIDEBAR_COLLAPSED_STORAGE_KEY, String(collapsed));
|
||||||
|
} catch {
|
||||||
|
// Local storage may be unavailable in some environments.
|
||||||
|
}
|
||||||
|
}, [collapsed]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const savedValue = localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY);
|
||||||
|
if (savedValue !== null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const mq = window.matchMedia("(max-width: 639px)");
|
const mq = window.matchMedia("(max-width: 639px)");
|
||||||
const handler = (e: MediaQueryListEvent): void => {
|
const handler = (e: MediaQueryListEvent): void => {
|
||||||
setCollapsed(e.matches);
|
setCollapsed(e.matches);
|
||||||
};
|
};
|
||||||
|
|
||||||
mq.addEventListener("change", handler);
|
mq.addEventListener("change", handler);
|
||||||
return (): void => { mq.removeEventListener("change", handler); };
|
return (): void => {
|
||||||
|
mq.removeEventListener("change", handler);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleCollapse = useCallback(() => {
|
const toggleCollapse = useCallback(() => {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { MainLayout } from "../../layouts/MainLayout";
|
|||||||
// Mocks
|
// Mocks
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
vi.mock("../../providers/AuthProvider", () => ({
|
vi.mock("../../hooks/useAuth", () => ({
|
||||||
useAuth: () => ({ logout: vi.fn() }),
|
useAuth: () => ({ logout: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -56,6 +56,7 @@ function renderLayout(): void {
|
|||||||
describe("MainLayout", () => {
|
describe("MainLayout", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the navigation sidebar", () => {
|
it("renders the navigation sidebar", () => {
|
||||||
@@ -75,4 +76,17 @@ describe("MainLayout", () => {
|
|||||||
await userEvent.click(toggleButton);
|
await userEvent.click(toggleButton);
|
||||||
expect(screen.queryByText("BanGUI")).not.toBeInTheDocument();
|
expect(screen.queryByText("BanGUI")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("initialises sidebar collapsed state from localStorage", () => {
|
||||||
|
localStorage.setItem("bangui_sidebar_collapsed", "true");
|
||||||
|
renderLayout();
|
||||||
|
expect(screen.queryByText("BanGUI")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists sidebar collapsed state to localStorage when toggled", async () => {
|
||||||
|
renderLayout();
|
||||||
|
const toggleButton = screen.getByRole("button", { name: /collapse sidebar/i });
|
||||||
|
await userEvent.click(toggleButton);
|
||||||
|
expect(localStorage.getItem("bangui_sidebar_collapsed")).toBe("true");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user