From 9a601245b65b0cd0fd062f9c66ed985c3158fb8e Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Fri, 4 Sep 2026 02:33:05 +0100 Subject: [PATCH] Refactor Settings Component and Introduce New Settings Schema - Removed unused imports and streamlined component structure for better readability. - Added a new settings schema to manage user and app settings, enhancing configurability. - Updated state management for settings, including draft and saved settings handling. - Improved appearance application logic to dynamically adjust theme and density based on user preferences. - Introduced utility functions for validating and picking settings, ensuring robust settings management. --- .../Dashboard/Management/Settings.jsx | 478 ++++++++---------- .../Dashboard/context/ApiServerContext.jsx | 6 +- src/database/Settings.js | 306 +++++++++++ 3 files changed, 523 insertions(+), 267 deletions(-) create mode 100644 src/database/Settings.js diff --git a/src/components/Dashboard/Management/Settings.jsx b/src/components/Dashboard/Management/Settings.jsx index a521cc3b..4b594525 100644 --- a/src/components/Dashboard/Management/Settings.jsx +++ b/src/components/Dashboard/Management/Settings.jsx @@ -1,10 +1,8 @@ import { useContext, useEffect, useMemo, useState } from 'react' -import { Descriptions, Flex, Select, Space, Spin, Typography } from 'antd' -import { LoadingOutlined, SettingOutlined } from '@ant-design/icons' +import { Flex, Space } from 'antd' import { useThemeContext } from '../context/ThemeContext' import { ApiServerContext } from '../context/ApiServerContext' import { ElectronContext } from '../context/ElectronContext' -import { AuthContext } from '../context/AuthContext' import { normalizeAppUpdateEngine, useAppUpdateContext @@ -12,20 +10,29 @@ import { import { useMessageContext } from '../context/MessageContext' import useCollapseState from '../hooks/useCollapseState' import InfoCollapse from '../common/InfoCollapse' +import ObjectInfo from '../common/ObjectInfo' import ViewButton from '../common/ViewButton' import EditButtons from '../common/EditButtons' +import ScrollBox from '../common/ScrollBox' +import settingsSchema, { + APP_SETTING_NAMES, + USER_SETTING_NAMES, + areSettingsValid, + getEffectiveAppearance, + getSettingsDefaults, + getVisibleSettingsSections, + pickSettings +} from '../../../database/Settings' -const { Text } = Typography -const { Option } = Select const DEFAULT_UPDATE_BRANCH = 'main' const DEFAULT_UPDATE_ENGINE = 'native' - -const engineLabel = (engine) => { - const normalized = normalizeAppUpdateEngine(engine) - if (normalized === 'chromium') return 'Chromium' - if (normalized === 'native') return 'Native' - return 'Not configured' -} +const LEGACY_ELECTRON_USER_KEYS = [ + 'theme', + 'density', + 'showNavigationLabels', + 'dateTimeFormat', + 'timezone' +] const Settings = () => { const { @@ -37,145 +44,183 @@ const Settings = () => { showNavigationLabels, setShowNavigationLabels } = useThemeContext() - const { fetchAppUpdateBranches } = useContext(ApiServerContext) + const { + connected, + fetchAppUpdateBranches, + updateUserSettings, + userSettings, + userSettingsLoaded + } = useContext(ApiServerContext) const { isElectron, getAppSettings, setAppSettings, getAppEngine } = useContext(ElectronContext) const { recheckForUpdates } = useAppUpdateContext() - const { userProfile, setUserProfile } = useContext(AuthContext) const { showSuccess, showError } = useMessageContext() - const [collapseState, updateCollapseState] = useCollapseState('Settings', { - appearance: true, - appUpdates: true - }) + const visibleSections = useMemo( + () => getVisibleSettingsSections({ isElectron }), + [isElectron] + ) + const [collapseState, updateCollapseState] = useCollapseState( + 'Settings', + Object.fromEntries( + settingsSchema.sections.map((section) => [section.key, true]) + ) + ) const [isEditing, setIsEditing] = useState(false) const [settingsLoading, setSettingsLoading] = useState(true) const [saving, setSaving] = useState(false) - const [appSettings, setAppSettingsState] = useState({}) + const [savedSettings, setSavedSettings] = useState({}) const [draftSettings, setDraftSettings] = useState({}) + const [electronSettings, setElectronSettings] = useState({}) const [branches, setBranches] = useState([]) - const [branchLoading, setBranchLoading] = useState(false) - const [runningEngine, setRunningEngine] = useState(DEFAULT_UPDATE_ENGINE) + + const currentThemeValue = isSystem ? 'system' : isDarkMode ? 'dark' : 'light' + const currentDensityValue = isCompact ? 'compact' : 'comfortable' + const userAppearance = userSettings?.appearance || {} + + const resolvedSettings = useMemo(() => { + const defaults = getSettingsDefaults({ isElectron }) + const stored = isEditing ? draftSettings : savedSettings + const defaultBranch = branches.includes(DEFAULT_UPDATE_BRANCH) + ? DEFAULT_UPDATE_BRANCH + : branches[0] + + return { + ...defaults, + theme: currentThemeValue, + density: currentDensityValue, + showNavigationLabels: showNavigationLabels ?? false, + ...(isElectron + ? { + appTheme: stored.appTheme || currentThemeValue, + appShowNavigationLabels: + stored.appShowNavigationLabels ?? showNavigationLabels ?? false, + appUpdateBranch: defaultBranch, + appUpdateEngine: + normalizeAppUpdateEngine(stored.appUpdateEngine) || + DEFAULT_UPDATE_ENGINE + } + : {}), + ...stored + } + }, [ + branches, + currentDensityValue, + currentThemeValue, + draftSettings, + isEditing, + isElectron, + savedSettings, + showNavigationLabels + ]) + + const applyAppearance = (nextSettings, nextElectronSettings = electronSettings) => { + const effective = getEffectiveAppearance({ + isElectron, + userAppearance: nextSettings, + electronSettings: { + ...nextElectronSettings, + appTheme: nextSettings.appTheme, + appShowNavigationLabels: nextSettings.appShowNavigationLabels, + overrideTheme: nextSettings.theme === 'app' + } + }) + if (effective.theme) setThemeMode(effective.theme) + if (effective.density) setDensityMode(effective.density) + if (effective.showNavigationLabels !== undefined) { + setShowNavigationLabels(effective.showNavigationLabels) + } + } useEffect(() => { + if (!userSettingsLoaded || isEditing) return + const loadSettings = async () => { setSettingsLoading(true) - const [storedSettings, detectedEngine] = await Promise.all([ - isElectron ? getAppSettings() : Promise.resolve(userProfile?.settings || {}), + const [storedElectronSettings, detectedEngine] = await Promise.all([ + isElectron ? getAppSettings() : Promise.resolve({}), isElectron ? getAppEngine() : Promise.resolve(DEFAULT_UPDATE_ENGINE) ]) const nextEngine = normalizeAppUpdateEngine(detectedEngine) || DEFAULT_UPDATE_ENGINE - setRunningEngine(nextEngine) - - const nextSettings = { ...(storedSettings || {}) } - if (isElectron && !normalizeAppUpdateEngine(nextSettings.appUpdateEngine)) { - nextSettings.appUpdateEngine = nextEngine + const nextElectronSettings = { ...(storedElectronSettings || {}) } + if ( + isElectron && + !normalizeAppUpdateEngine(nextElectronSettings.appUpdateEngine) + ) { + nextElectronSettings.appUpdateEngine = nextEngine } - setAppSettingsState(nextSettings) + const nextSettings = { + ...getSettingsDefaults({ isElectron }), + ...pickSettings(userAppearance, USER_SETTING_NAMES), + ...(isElectron + ? { + ...pickSettings(nextElectronSettings, APP_SETTING_NAMES), + appTheme: + nextElectronSettings.appTheme || nextElectronSettings.theme, + appShowNavigationLabels: + nextElectronSettings.appShowNavigationLabels ?? + nextElectronSettings.showNavigationLabels, + theme: nextElectronSettings.overrideTheme + ? 'app' + : userAppearance.theme + } + : {}) + } + + setElectronSettings(nextElectronSettings) + setSavedSettings(nextSettings) setDraftSettings(nextSettings) + applyAppearance(nextSettings, nextElectronSettings) setSettingsLoading(false) } loadSettings() - }, [getAppEngine, getAppSettings, isElectron, userProfile?.settings]) - - useEffect(() => { - if (settingsLoading || isEditing) return - if (appSettings.theme) setThemeMode(appSettings.theme) - if (appSettings.density) setDensityMode(appSettings.density) - if (appSettings.showNavigationLabels !== undefined) { - setShowNavigationLabels(appSettings.showNavigationLabels) - } + // eslint-disable-next-line react-hooks/exhaustive-deps -- reload when stored appearance/app settings change }, [ - appSettings.density, - appSettings.showNavigationLabels, - appSettings.theme, + getAppEngine, + getAppSettings, isEditing, - setDensityMode, - setShowNavigationLabels, - setThemeMode, - settingsLoading + isElectron, + userAppearance.dateTimeFormat, + userAppearance.density, + userAppearance.showNavigationLabels, + userAppearance.theme, + userAppearance.timezone, + userSettingsLoaded ]) useEffect(() => { if (!isElectron) { setBranches([]) - setBranchLoading(false) return } const loadBranches = async () => { - setBranchLoading(true) const availableBranches = await fetchAppUpdateBranches() setBranches(availableBranches) setDraftSettings((previous) => { if (previous.appUpdateBranch) return previous - const defaultBranch = availableBranches.includes(DEFAULT_UPDATE_BRANCH) ? DEFAULT_UPDATE_BRANCH : availableBranches[0] - return defaultBranch ? { ...previous, appUpdateBranch: defaultBranch } : previous }) - - setBranchLoading(false) } loadBranches() }, [fetchAppUpdateBranches, isElectron]) - const branchOptions = useMemo( - () => - branches.map((branch) => ( - - )), - [branches] - ) - const viewItems = [ - { key: 'appearance', label: 'Appearance Settings' }, - ...(isElectron ? [{ key: 'appUpdates', label: 'App Update Settings' }] : []) - ] - - const getCurrentThemeValue = () => { - if (isSystem) return 'system' - return isDarkMode ? 'dark' : 'light' - } - - const currentThemeValue = getCurrentThemeValue() - const currentDensityValue = isCompact ? 'compact' : 'comfortable' - const currentShowNavigationLabels = - appSettings.showNavigationLabels ?? showNavigationLabels ?? false - const currentBranch = - appSettings.appUpdateBranch || - (branches.includes(DEFAULT_UPDATE_BRANCH) ? DEFAULT_UPDATE_BRANCH : null) || - branches[0] || - 'Not configured' - const currentEngine = - normalizeAppUpdateEngine(appSettings.appUpdateEngine) || - normalizeAppUpdateEngine(runningEngine) || - DEFAULT_UPDATE_ENGINE - const startEditing = () => { - setDraftSettings({ - ...appSettings, - appUpdateBranch: - currentBranch === 'Not configured' ? undefined : currentBranch, - appUpdateEngine: currentEngine, - theme: currentThemeValue, - density: currentDensityValue, - showNavigationLabels: currentShowNavigationLabels - }) + setDraftSettings(resolvedSettings) setIsEditing(true) } const cancelEditing = () => { - setDraftSettings(appSettings) + setDraftSettings(savedSettings) setIsEditing(false) } @@ -183,55 +228,67 @@ const Settings = () => { setSaving(true) try { + const useAppTheme = isElectron && draftSettings.theme === 'app' const nextEngine = normalizeAppUpdateEngine(draftSettings.appUpdateEngine) || - currentEngine + DEFAULT_UPDATE_ENGINE const nextSettings = { - ...appSettings, - theme: draftSettings.theme, - density: draftSettings.density, + ...getSettingsDefaults({ isElectron }), + ...savedSettings, + ...draftSettings, showNavigationLabels: Boolean(draftSettings.showNavigationLabels), ...(isElectron ? { + appShowNavigationLabels: Boolean( + draftSettings.appShowNavigationLabels + ), appUpdateBranch: draftSettings.appUpdateBranch, appUpdateEngine: nextEngine } : {}) } - const saved = isElectron - ? await setAppSettings(nextSettings) - : Boolean(userProfile) - if (!saved) { + const userUpdates = USER_SETTING_NAMES.filter( + (name) => !(name === 'theme' && useAppTheme) + ).map((name) => updateUserSettings('appearance', name, nextSettings[name])) + + const userResults = await Promise.all(userUpdates) + if (connected && userResults.some((result) => result == null)) { showError('Unable to save settings.') return } - if (!isElectron) { - setUserProfile((previous) => ({ - ...previous, - settings: { - ...(previous?.settings || {}), - theme: draftSettings.theme, - density: draftSettings.density, - showNavigationLabels: Boolean(draftSettings.showNavigationLabels) - } - })) + let nextElectronSettings = electronSettings + if (isElectron) { + nextElectronSettings = { ...electronSettings } + for (const key of LEGACY_ELECTRON_USER_KEYS) { + delete nextElectronSettings[key] + } + nextElectronSettings = { + ...nextElectronSettings, + ...pickSettings(nextSettings, APP_SETTING_NAMES), + overrideTheme: useAppTheme + } + + const saved = await setAppSettings(nextElectronSettings) + if (!saved) { + showError('Unable to save settings.') + return + } } - setThemeMode(draftSettings.theme) - setDensityMode(draftSettings.density) - setShowNavigationLabels(draftSettings.showNavigationLabels) - setAppSettingsState(nextSettings) + applyAppearance(nextSettings, nextElectronSettings) + setElectronSettings(nextElectronSettings) + setSavedSettings(nextSettings) setDraftSettings(nextSettings) setIsEditing(false) showSuccess('Settings saved.') const appUpdateSettingsChanged = isElectron && - (nextSettings.appUpdateBranch !== appSettings.appUpdateBranch || + (nextSettings.appUpdateBranch !== savedSettings.appUpdateBranch || nextSettings.appUpdateEngine !== - normalizeAppUpdateEngine(appSettings.appUpdateEngine)) + normalizeAppUpdateEngine(savedSettings.appUpdateEngine)) if (appUpdateSettingsChanged) { void recheckForUpdates() @@ -247,7 +304,10 @@ const Settings = () => { ({ + key: section.key, + label: section.name + }))} visibleState={collapseState} updateVisibleState={updateCollapseState} /> @@ -257,163 +317,51 @@ const Settings = () => { handleUpdate={handleSave} cancelEditing={cancelEditing} startEditing={startEditing} - formValid={ - Boolean(draftSettings.theme && draftSettings.density) && - (!isElectron || - (Boolean(draftSettings.appUpdateBranch) && - Boolean(normalizeAppUpdateEngine(draftSettings.appUpdateEngine)))) - } - disabled={settingsLoading || (!isElectron && !userProfile)} + formValid={areSettingsValid(draftSettings, { isElectron })} + disabled={settingsLoading || !userSettingsLoaded} loading={saving} /> -
- }> - - } - active={collapseState.appearance} - onToggle={(expanded) => - updateCollapseState('appearance', expanded) - } - collapseKey='appearance' - > - - - {isEditing ? ( - - ) : ( - {currentThemeValue} - )} - - - {isEditing ? ( - - ) : ( - {isCompact ? 'Compact' : 'Comfortable'} - )} - - - {isEditing ? ( - - ) : ( - - {currentShowNavigationLabels ? 'Show' : 'Hide'} - - )} - - - - {isElectron && ( + + + {visibleSections.map((section) => { + const Icon = section.icon + return ( } - active={collapseState.appUpdates} + key={section.key} + title={section.name} + icon={} + active={collapseState[section.key]} onToggle={(expanded) => - updateCollapseState('appUpdates', expanded) + updateCollapseState(section.key, expanded) } - collapseKey='appUpdates' + collapseKey={section.key} > - - - {isEditing ? ( - - ) : ( - {currentBranch} - )} - - - {isEditing ? ( - - ) : ( - {engineLabel(currentEngine)} - )} - - + : undefined + } + {...(section.column ? { column: section.column } : {})} + /> - )} - - -
+ ) + })} + + ) } diff --git a/src/components/Dashboard/context/ApiServerContext.jsx b/src/components/Dashboard/context/ApiServerContext.jsx index 102d7cb6..b7bd183a 100644 --- a/src/components/Dashboard/context/ApiServerContext.jsx +++ b/src/components/Dashboard/context/ApiServerContext.jsx @@ -38,7 +38,8 @@ const createEmptyUserSettings = () => ({ sortSidebarVisibility: {}, columnVisibility: {}, collapseState: {}, - pageLayout: {} + pageLayout: {}, + appearance: {} }) const normalizeUserSettingsCategory = (category) => @@ -76,7 +77,8 @@ const normalizeUserSettings = (settings = {}) => ({ ), columnVisibility: normalizeUserSettingsCategory(settings?.columnVisibility), collapseState: normalizeUserSettingsCategory(settings?.collapseState), - pageLayout: normalizeUserSettingsCategory(settings?.pageLayout) + pageLayout: normalizeUserSettingsCategory(settings?.pageLayout), + appearance: normalizeUserSettingsCategory(settings?.appearance) }) const emitWithAcknowledgement = (socket, eventName, data) => diff --git a/src/database/Settings.js b/src/database/Settings.js new file mode 100644 index 00000000..0eff8601 --- /dev/null +++ b/src/database/Settings.js @@ -0,0 +1,306 @@ +import PersonIcon from '../components/Icons/PersonIcon' +import OpenAppIcon from '../components/Icons/OpenAppIcon' +import SoftwareUpdateIcon from '../components/Icons/SoftwareUpdateIcon' + +export const DEFAULT_DATE_TIME_FORMAT = 'MM/DD/YYYY HH:mm:ss' + +const DATE_FORMAT_TOKENS = [ + 'YYYY', + 'YY', + 'MMMM', + 'MMM', + 'MM', + 'M', + 'dddd', + 'ddd', + 'dd', + 'd', + 'DD', + 'Do', + 'D', + 'WW', + 'W', + 'ww', + 'w', + 'Q' +] +const TIME_FORMAT_TOKENS = [ + 'HH', + 'H', + 'hh', + 'h', + 'mm', + 'm', + 'ss', + 's', + 'SSS', + 'SS', + 'S', + 'A', + 'a', + 'ZZ', + 'Z', + 'X', + 'x' +] +const FORMAT_TOKEN_PATTERN = new RegExp( + `\\[[^\\]]*\\]|${[...DATE_FORMAT_TOKENS, ...TIME_FORMAT_TOKENS] + .sort((left, right) => right.length - left.length) + .join('|')}`, + 'g' +) +const DATE_FORMAT_TOKEN_SET = new Set(DATE_FORMAT_TOKENS) +const TIME_FORMAT_TOKEN_SET = new Set(TIME_FORMAT_TOKENS) + +const lastPartIndex = (parts, type) => { + for (let index = parts.length - 1; index >= 0; index -= 1) { + if (parts[index].type === type) return index + } + return -1 +} + +const extractFormatPart = (parts, type) => { + const first = parts.findIndex((part) => part.type === type) + const last = lastPartIndex(parts, type) + if (first === -1) return '' + return parts + .slice(first, last + 1) + .map((part) => part.value) + .join('') + .trim() +} + +export const splitDateTimeFormat = ( + format = DEFAULT_DATE_TIME_FORMAT +) => { + const source = + typeof format === 'string' && format.trim() + ? format + : DEFAULT_DATE_TIME_FORMAT + const parts = [] + let cursor = 0 + + for (const match of source.matchAll(FORMAT_TOKEN_PATTERN)) { + if (match.index > cursor) { + parts.push({ type: 'sep', value: source.slice(cursor, match.index) }) + } + + const token = match[0] + if (token.startsWith('[')) { + parts.push({ type: 'sep', value: token }) + } else if (DATE_FORMAT_TOKEN_SET.has(token)) { + parts.push({ type: 'date', value: token }) + } else if (TIME_FORMAT_TOKEN_SET.has(token)) { + parts.push({ type: 'time', value: token }) + } else { + parts.push({ type: 'sep', value: token }) + } + + cursor = match.index + token.length + } + + if (cursor < source.length) { + parts.push({ type: 'sep', value: source.slice(cursor) }) + } + + return { + dateFormat: extractFormatPart(parts, 'date') || 'MM/DD/YYYY', + timeFormat: extractFormatPart(parts, 'time') || 'HH:mm:ss' + } +} + +export const USER_SETTING_NAMES = [ + 'theme', + 'density', + 'showNavigationLabels', + 'dateTimeFormat', + 'timezone' +] + +export const APP_SETTING_NAMES = [ + 'appTheme', + 'appShowNavigationLabels', + 'appUpdateBranch', + 'appUpdateEngine' +] + +const themeOptions = [ + { label: 'Light', value: 'light' }, + { label: 'Dark', value: 'dark' }, + { label: 'System', value: 'system' } +] + +const settings = { + sections: [ + { + key: 'appearance', + name: 'User Settings', + visible: true, + icon: PersonIcon, + properties: [ + { + name: 'theme', + label: 'Theme', + type: 'select', + required: true, + options: (_objectData, parentData) => [ + ...themeOptions, + ...(parentData?.isElectron ? [{ label: 'App', value: 'app' }] : []) + ] + }, + { + name: 'density', + label: 'UI Density', + type: 'select', + required: true, + options: [ + { label: 'Comfortable', value: 'comfortable' }, + { label: 'Compact', value: 'compact' } + ] + }, + { + name: 'showNavigationLabels', + label: 'Navigation Labels', + type: 'select', + required: true, + options: [ + { label: 'Show', value: true }, + { label: 'Hide', value: false } + ] + }, + { + name: 'dateTimeFormat', + label: 'Date and Time Format', + type: 'text', + required: true, + defaultValue: DEFAULT_DATE_TIME_FORMAT + }, + { + name: 'timezone', + label: 'Timezone', + type: 'text', + required: true, + defaultValue: 'UTC' + } + ] + }, + { + key: 'app', + name: 'App Settings', + visible: ({ isElectron } = {}) => Boolean(isElectron), + icon: OpenAppIcon, + properties: [ + { + name: 'appTheme', + label: 'Theme', + type: 'select', + required: true, + defaultValue: 'system', + options: themeOptions + }, + { + name: 'appShowNavigationLabels', + label: 'Navigation Labels', + type: 'select', + required: true, + defaultValue: false, + options: [ + { label: 'Show', value: true }, + { label: 'Hide', value: false } + ] + } + ] + }, + { + key: 'appUpdates', + name: 'App Update Settings', + visible: ({ isElectron } = {}) => Boolean(isElectron), + icon: SoftwareUpdateIcon, + column: 1, + properties: [ + { + name: 'appUpdateBranch', + label: 'Branch', + type: 'select', + required: true, + defaultValue: 'main', + options: (_objectData, parentData) => + (parentData?.branches || []).map((branch) => ({ + label: branch, + value: branch + })) + }, + { + name: 'appUpdateEngine', + label: 'Engine', + type: 'select', + required: true, + defaultValue: 'native', + options: [ + { label: 'Native', value: 'native' }, + { label: 'Chromium', value: 'chromium' } + ] + } + ] + } + ] +} + +export const getVisibleSettingsSections = (context = {}) => + settings.sections.filter((section) => { + if (typeof section.visible === 'function') { + return section.visible(context) + } + return section.visible !== false + }) + +export const getSettingsDefaults = (context = {}) => { + const defaults = {} + for (const section of getVisibleSettingsSections(context)) { + for (const property of section.properties || []) { + if (property.defaultValue !== undefined) { + defaults[property.name] = property.defaultValue + } + } + } + return defaults +} + +export const areSettingsValid = (values = {}, context = {}) => + getVisibleSettingsSections(context).every((section) => + (section.properties || []).every((property) => { + if (!property.required) return true + const value = values[property.name] + return value !== undefined && value !== null && value !== '' + }) + ) + +export const pickSettings = (values = {}, names = []) => + names.reduce((picked, name) => { + if (values[name] !== undefined) { + picked[name] = values[name] + } + return picked + }, {}) + +export const getEffectiveAppearance = ({ + isElectron = false, + userAppearance = {}, + electronSettings = {} +} = {}) => { + const overrideTheme = Boolean(isElectron && electronSettings.overrideTheme) + const appTheme = electronSettings.appTheme || electronSettings.theme + const appShowNavigationLabels = + electronSettings.appShowNavigationLabels ?? + electronSettings.showNavigationLabels + + return { + theme: overrideTheme ? appTheme : userAppearance.theme, + showNavigationLabels: isElectron + ? (appShowNavigationLabels ?? userAppearance.showNavigationLabels) + : userAppearance.showNavigationLabels, + density: userAppearance.density + } +} + +export default settings