From 9d0f0116f3222874447a159f209924ec879870fc Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Fri, 28 Aug 2026 21:31:09 -0400 Subject: [PATCH] feat(client): group settings into five tabs The settings page rendered all 13 ConfigBox sections in one column, so finding anything meant scrolling past everything else. The sections are now grouped under five tabs by subject: General timezone, appearance, status page URL, about Monitoring thresholds, check retention, demo monitors Email email settings Integrations global proxy, PageSpeed API key Data import/export, monitor history, system reset No section changed. Every ConfigBox moved verbatim with its props, children and isAdmin guard intact; the only edit inside one is Prettier rewrapping an arrow function that shifted indentation level. Tabs are derived from a filtered array rather than conditional JSX, so indices stay correct when admin-only tabs drop out. Nine of the thirteen sections are admin-gated, which leaves a non-admin four sections in General alone -- the bar is hidden entirely in that case rather than showing four empty tabs. Panels stay mounted and toggle with `hidden`. Conditionally rendering them would unmount inputs belonging to the shared react-hook-form, and since Save is disabled on !isValid, a partial tree can compute a stale isValid and lock saving. The active tab lives in the URL as ?tab= so reloads and shared links land in the right place, following the useSearchParams pattern in StatusPage/Status. An unknown tab, or an admin tab requested by a non-admin, falls back to the first visible tab. Both confirmation dialogs and the sticky save bar stay outside the panels: the dialogs would unmount mid-request otherwise, and the save bar serves the whole form. Because the save bar lists validation errors by field name, an error could name a field on a hidden tab. onError now switches to the tab owning the first error before the alert renders. Two titles are corrected to sentence case while their sections move: "Global Thresholds" and "Monitor IP/URL on Status Page". Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- client/src/Pages/Settings/index.tsx | 870 ++++++++++++++++------------ client/src/locales/en.json | 11 +- 2 files changed, 497 insertions(+), 384 deletions(-) diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx index 70a8e1bb6..564cb3bda 100644 --- a/client/src/Pages/Settings/index.tsx +++ b/client/src/Pages/Settings/index.tsx @@ -1,4 +1,4 @@ -import { BasePage, ConfigBox, TextLink } from "@/Components/design-elements"; +import { BasePage, ConfigBox, TextLink, Tabs, Tab } from "@/Components/design-elements"; import { Autocomplete, Select, Dialog } from "@/Components/inputs"; import { logger } from "@/Utils/logger"; import { LAYOUT } from "@/Utils/Theme/constants"; @@ -46,6 +46,33 @@ import { FormNumberField } from "@/Components/inputs/forms/FormNumberField"; import { FormSelectField } from "@/Components/inputs/forms/FormSelectField"; import type { ProxyResponse } from "@/Types/Proxy"; import type { AppSettingsResponse } from "@/Types/Settings"; +import { useSearchParams } from "react-router-dom"; + +const SETTINGS_TABS = [ + { key: "general", labelKey: "pages.settings.tabs.general" }, + { key: "monitoring", labelKey: "pages.settings.tabs.monitoring", adminOnly: true }, + { key: "email", labelKey: "pages.settings.tabs.email", adminOnly: true }, + { key: "integrations", labelKey: "pages.settings.tabs.integrations", adminOnly: true }, + { key: "data", labelKey: "pages.settings.tabs.data", adminOnly: true }, +] as const; + +type SettingsTabKey = (typeof SETTINGS_TABS)[number]["key"]; + +// Maps a validation error back to the tab holding it, so a failed save can +// reveal the offending field instead of naming one on a hidden tab. +const FIELD_TAB: Record = { + checkTTL: "monitoring", + globalThresholds: "monitoring", + pagespeedApiKey: "integrations", + globalProxyEnabled: "integrations", + globalProxyId: "integrations", + showURL: "general", +}; + +const tabForField = (field: string): SettingsTabKey => + field.startsWith("systemEmail") + ? "email" + : (FIELD_TAB[field.split(".")[0]] ?? "general"); export const SettingsPage = () => { const theme = useTheme(); @@ -53,6 +80,28 @@ export const SettingsPage = () => { const dispatch = useDispatch(); const isAdmin = useIsAdmin(); const { toastError } = useToast(); + const [searchParams, setSearchParams] = useSearchParams(); + + const visibleTabs = useMemo( + () => SETTINGS_TABS.filter((tab) => !("adminOnly" in tab) || isAdmin), + [isAdmin] + ); + + // An unknown tab, or an admin tab requested by a non-admin, falls back to the + // first tab the current user can actually see. + const requestedTab = searchParams.get("tab"); + const activeTab: SettingsTabKey = + visibleTabs.find((tab) => tab.key === requestedTab)?.key ?? visibleTabs[0].key; + + const handleTabChange = (tab: SettingsTabKey) => { + const updated = new URLSearchParams(searchParams); + if (tab === visibleTabs[0].key) { + updated.delete("tab"); + } else { + updated.set("tab", tab); + } + setSearchParams(updated, { replace: true }); + }; // Local state for demo monitors dialog const [isDemoMonitorsDialogOpen, setIsDemoMonitorsDialogOpen] = useState(false); const { post: postDemoMonitors, loading: isPostingDemoMonitors } = usePost(); @@ -298,6 +347,15 @@ export const SettingsPage = () => { const onError = (errors: unknown) => { logger.debug("Form validation errors", errors); + // The save bar lists errors by field name, so reveal the tab owning the + // first one rather than naming a field the user cannot see. + const firstField = Object.keys(errors ?? {})[0]; + if (firstField) { + const target = tabForField(firstField); + if (visibleTabs.some((tab) => tab.key === target)) { + handleTabChange(target); + } + } }; const languages = Object.keys(i18n.options.resources || {}); @@ -324,7 +382,26 @@ export const SettingsPage = () => { component="form" onSubmit={form.handleSubmit(onSubmit, onError)} > - + {visibleTabs.length > 1 && ( + handleTabChange(value)} + > + {visibleTabs.map((tab) => ( + + ))} + + )} + + {/* general tab */} + } /> + {/* URL Settings */} + + } + /> + {/* About */} + + + {t("common.appName")} {__APP_VERSION__} + + + {t("pages.settings.form.about.developedBy")} + + + https://github.com/bluewave-labs/checkmate + + + } + /> + + + {/* monitoring tab */} + + } + /> + )} + {/* Check Retention */} + {isAdmin && ( + + value >= CHECK_TTL_SENTINEL + ? t("pages.settings.form.retention.option.days.unlimited") + : `${value}` + } + formatDisplayValue={(value: number) => + value >= CHECK_TTL_SENTINEL + ? t("pages.settings.form.retention.option.days.unlimited") + : `${value}` + } + /> + } + /> + )} + + {/* Demo Monitors - Admin Only */} + {isAdmin && ( + + + + } + /> + )} + + + {/* email tab */} + + } + rightContent={ + + {/* Email Host */} + + + {/* Email Port */} + + + {/* Email Address */} + + + {/* Email Display Name (Optional) */} + + + {/* Email User (Optional) */} + + + {/* Email Password with Reset Pattern */} + {isEmailPasswordSet && !emailPasswordHasBeenReset ? ( + + + {t("pages.settings.form.email.option.password.labelSet")} + + + + + + ) : ( + + )} + + {/* TLS Servername (Optional) */} + + + {/* Connection Host (Optional) */} + + + {/* Boolean Switches */} + + {emailSwitches.map(({ name, label }) => ( + + ))} + + + {/* Test Email Button */} + + + + + } + /> + )} + + + {/* integrations tab */} + - {/* URL Settings */} - - } - /> - + {/* data tab */} + + } + /> + )} {/* Clear All Stats */} {isAdmin && ( { /> )} - {/* Check Retention */} + {/* Remove All Monitors - Admin Only */} {isAdmin && ( - value >= CHECK_TTL_SENTINEL - ? t("pages.settings.form.retention.option.days.unlimited") - : `${value}` - } - formatDisplayValue={(value: number) => - value >= CHECK_TTL_SENTINEL - ? t("pages.settings.form.retention.option.days.unlimited") - : `${value}` - } - /> - } - /> - )} - - {/* Global Thresholds */} - {isAdmin && ( - - - - - - - + + + } /> )} - {/* Email Settings - Admin Only */} - {isAdmin && ( - - - {(() => { - const address = form.watch("systemEmailAddress") || ""; - const displayName = form.watch("systemEmailDisplayName")?.trim(); - return ( - <> - - - {JSON.stringify( - { - host: form.watch("systemEmailHost") || "", - port: form.watch("systemEmailPort") || "", - secure: form.watch("systemEmailSecure") ?? false, - auth: { - user: form.watch("systemEmailUser") || address, - pass: "", - }, - name: - form.watch("systemEmailConnectionHost") || "localhost", - pool: form.watch("systemEmailPool") ?? false, - tls: { - rejectUnauthorized: - form.watch("systemEmailRejectUnauthorized") ?? true, - ignoreTLS: form.watch("systemEmailIgnoreTLS") ?? false, - requireTLS: form.watch("systemEmailRequireTLS") ?? false, - servername: form.watch("systemEmailTLSServername") || "", - }, - }, - null, - 2 - )} - - - {address && ( - - - {`From: ${displayName ? `"${displayName}" <${address}>` : address}`} - - - )} - - ); - })()} - - } - rightContent={ - - {/* Email Host */} - - - {/* Email Port */} - - - {/* Email Address */} - - - {/* Email Display Name (Optional) */} - - - {/* Email User (Optional) */} - - - {/* Email Password with Reset Pattern */} - {isEmailPasswordSet && !emailPasswordHasBeenReset ? ( - - - {t("pages.settings.form.email.option.password.labelSet")} - - - - - - ) : ( - - )} - - {/* TLS Servername (Optional) */} - - - {/* Connection Host (Optional) */} - - - {/* Boolean Switches */} - - {emailSwitches.map(({ name, label }) => ( - - ))} - - - {/* Test Email Button */} - - - - - } - /> - )} - - {/* Demo Monitors - Admin Only */} - {isAdmin && ( - - - - } - /> - )} - - {/* Remove All Monitors - Admin Only */} - {isAdmin && ( - - - - } - /> - )} - - {/* Export Monitors - Admin Only */} - {isAdmin && ( - - - - - - } - /> - )} - - {/* About */} - - - {t("common.appName")} {__APP_VERSION__} - - - {t("pages.settings.form.about.developedBy")} - - - https://github.com/bluewave-labs/checkmate - - - } - /> - {/* Clear Stats Confirmation Dialog */}