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.
This commit is contained in:
Tom Butcher 2026-09-04 02:33:05 +01:00
parent da8fe8de2d
commit 9a601245b6
3 changed files with 523 additions and 267 deletions

View File

@ -1,10 +1,8 @@
import { useContext, useEffect, useMemo, useState } from 'react' import { useContext, useEffect, useMemo, useState } from 'react'
import { Descriptions, Flex, Select, Space, Spin, Typography } from 'antd' import { Flex, Space } from 'antd'
import { LoadingOutlined, SettingOutlined } from '@ant-design/icons'
import { useThemeContext } from '../context/ThemeContext' import { useThemeContext } from '../context/ThemeContext'
import { ApiServerContext } from '../context/ApiServerContext' import { ApiServerContext } from '../context/ApiServerContext'
import { ElectronContext } from '../context/ElectronContext' import { ElectronContext } from '../context/ElectronContext'
import { AuthContext } from '../context/AuthContext'
import { import {
normalizeAppUpdateEngine, normalizeAppUpdateEngine,
useAppUpdateContext useAppUpdateContext
@ -12,20 +10,29 @@ import {
import { useMessageContext } from '../context/MessageContext' import { useMessageContext } from '../context/MessageContext'
import useCollapseState from '../hooks/useCollapseState' import useCollapseState from '../hooks/useCollapseState'
import InfoCollapse from '../common/InfoCollapse' import InfoCollapse from '../common/InfoCollapse'
import ObjectInfo from '../common/ObjectInfo'
import ViewButton from '../common/ViewButton' import ViewButton from '../common/ViewButton'
import EditButtons from '../common/EditButtons' 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_BRANCH = 'main'
const DEFAULT_UPDATE_ENGINE = 'native' const DEFAULT_UPDATE_ENGINE = 'native'
const LEGACY_ELECTRON_USER_KEYS = [
const engineLabel = (engine) => { 'theme',
const normalized = normalizeAppUpdateEngine(engine) 'density',
if (normalized === 'chromium') return 'Chromium' 'showNavigationLabels',
if (normalized === 'native') return 'Native' 'dateTimeFormat',
return 'Not configured' 'timezone'
} ]
const Settings = () => { const Settings = () => {
const { const {
@ -37,145 +44,183 @@ const Settings = () => {
showNavigationLabels, showNavigationLabels,
setShowNavigationLabels setShowNavigationLabels
} = useThemeContext() } = useThemeContext()
const { fetchAppUpdateBranches } = useContext(ApiServerContext) const {
connected,
fetchAppUpdateBranches,
updateUserSettings,
userSettings,
userSettingsLoaded
} = useContext(ApiServerContext)
const { isElectron, getAppSettings, setAppSettings, getAppEngine } = const { isElectron, getAppSettings, setAppSettings, getAppEngine } =
useContext(ElectronContext) useContext(ElectronContext)
const { recheckForUpdates } = useAppUpdateContext() const { recheckForUpdates } = useAppUpdateContext()
const { userProfile, setUserProfile } = useContext(AuthContext)
const { showSuccess, showError } = useMessageContext() const { showSuccess, showError } = useMessageContext()
const [collapseState, updateCollapseState] = useCollapseState('Settings', { const visibleSections = useMemo(
appearance: true, () => getVisibleSettingsSections({ isElectron }),
appUpdates: true [isElectron]
}) )
const [collapseState, updateCollapseState] = useCollapseState(
'Settings',
Object.fromEntries(
settingsSchema.sections.map((section) => [section.key, true])
)
)
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [settingsLoading, setSettingsLoading] = useState(true) const [settingsLoading, setSettingsLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [appSettings, setAppSettingsState] = useState({}) const [savedSettings, setSavedSettings] = useState({})
const [draftSettings, setDraftSettings] = useState({}) const [draftSettings, setDraftSettings] = useState({})
const [electronSettings, setElectronSettings] = useState({})
const [branches, setBranches] = 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(() => { useEffect(() => {
if (!userSettingsLoaded || isEditing) return
const loadSettings = async () => { const loadSettings = async () => {
setSettingsLoading(true) setSettingsLoading(true)
const [storedSettings, detectedEngine] = await Promise.all([ const [storedElectronSettings, detectedEngine] = await Promise.all([
isElectron ? getAppSettings() : Promise.resolve(userProfile?.settings || {}), isElectron ? getAppSettings() : Promise.resolve({}),
isElectron ? getAppEngine() : Promise.resolve(DEFAULT_UPDATE_ENGINE) isElectron ? getAppEngine() : Promise.resolve(DEFAULT_UPDATE_ENGINE)
]) ])
const nextEngine = const nextEngine =
normalizeAppUpdateEngine(detectedEngine) || DEFAULT_UPDATE_ENGINE normalizeAppUpdateEngine(detectedEngine) || DEFAULT_UPDATE_ENGINE
setRunningEngine(nextEngine) const nextElectronSettings = { ...(storedElectronSettings || {}) }
if (
const nextSettings = { ...(storedSettings || {}) } isElectron &&
if (isElectron && !normalizeAppUpdateEngine(nextSettings.appUpdateEngine)) { !normalizeAppUpdateEngine(nextElectronSettings.appUpdateEngine)
nextSettings.appUpdateEngine = nextEngine ) {
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) setDraftSettings(nextSettings)
applyAppearance(nextSettings, nextElectronSettings)
setSettingsLoading(false) setSettingsLoading(false)
} }
loadSettings() loadSettings()
}, [getAppEngine, getAppSettings, isElectron, userProfile?.settings]) // eslint-disable-next-line react-hooks/exhaustive-deps -- reload when stored appearance/app settings change
useEffect(() => {
if (settingsLoading || isEditing) return
if (appSettings.theme) setThemeMode(appSettings.theme)
if (appSettings.density) setDensityMode(appSettings.density)
if (appSettings.showNavigationLabels !== undefined) {
setShowNavigationLabels(appSettings.showNavigationLabels)
}
}, [ }, [
appSettings.density, getAppEngine,
appSettings.showNavigationLabels, getAppSettings,
appSettings.theme,
isEditing, isEditing,
setDensityMode, isElectron,
setShowNavigationLabels, userAppearance.dateTimeFormat,
setThemeMode, userAppearance.density,
settingsLoading userAppearance.showNavigationLabels,
userAppearance.theme,
userAppearance.timezone,
userSettingsLoaded
]) ])
useEffect(() => { useEffect(() => {
if (!isElectron) { if (!isElectron) {
setBranches([]) setBranches([])
setBranchLoading(false)
return return
} }
const loadBranches = async () => { const loadBranches = async () => {
setBranchLoading(true)
const availableBranches = await fetchAppUpdateBranches() const availableBranches = await fetchAppUpdateBranches()
setBranches(availableBranches) setBranches(availableBranches)
setDraftSettings((previous) => { setDraftSettings((previous) => {
if (previous.appUpdateBranch) return previous if (previous.appUpdateBranch) return previous
const defaultBranch = availableBranches.includes(DEFAULT_UPDATE_BRANCH) const defaultBranch = availableBranches.includes(DEFAULT_UPDATE_BRANCH)
? DEFAULT_UPDATE_BRANCH ? DEFAULT_UPDATE_BRANCH
: availableBranches[0] : availableBranches[0]
return defaultBranch return defaultBranch
? { ...previous, appUpdateBranch: defaultBranch } ? { ...previous, appUpdateBranch: defaultBranch }
: previous : previous
}) })
setBranchLoading(false)
} }
loadBranches() loadBranches()
}, [fetchAppUpdateBranches, isElectron]) }, [fetchAppUpdateBranches, isElectron])
const branchOptions = useMemo(
() =>
branches.map((branch) => (
<Option key={branch} value={branch}>
{branch}
</Option>
)),
[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 = () => { const startEditing = () => {
setDraftSettings({ setDraftSettings(resolvedSettings)
...appSettings,
appUpdateBranch:
currentBranch === 'Not configured' ? undefined : currentBranch,
appUpdateEngine: currentEngine,
theme: currentThemeValue,
density: currentDensityValue,
showNavigationLabels: currentShowNavigationLabels
})
setIsEditing(true) setIsEditing(true)
} }
const cancelEditing = () => { const cancelEditing = () => {
setDraftSettings(appSettings) setDraftSettings(savedSettings)
setIsEditing(false) setIsEditing(false)
} }
@ -183,55 +228,67 @@ const Settings = () => {
setSaving(true) setSaving(true)
try { try {
const useAppTheme = isElectron && draftSettings.theme === 'app'
const nextEngine = const nextEngine =
normalizeAppUpdateEngine(draftSettings.appUpdateEngine) || normalizeAppUpdateEngine(draftSettings.appUpdateEngine) ||
currentEngine DEFAULT_UPDATE_ENGINE
const nextSettings = { const nextSettings = {
...appSettings, ...getSettingsDefaults({ isElectron }),
theme: draftSettings.theme, ...savedSettings,
density: draftSettings.density, ...draftSettings,
showNavigationLabels: Boolean(draftSettings.showNavigationLabels), showNavigationLabels: Boolean(draftSettings.showNavigationLabels),
...(isElectron ...(isElectron
? { ? {
appShowNavigationLabels: Boolean(
draftSettings.appShowNavigationLabels
),
appUpdateBranch: draftSettings.appUpdateBranch, appUpdateBranch: draftSettings.appUpdateBranch,
appUpdateEngine: nextEngine 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.') showError('Unable to save settings.')
return return
} }
if (!isElectron) { let nextElectronSettings = electronSettings
setUserProfile((previous) => ({ if (isElectron) {
...previous, nextElectronSettings = { ...electronSettings }
settings: { for (const key of LEGACY_ELECTRON_USER_KEYS) {
...(previous?.settings || {}), delete nextElectronSettings[key]
theme: draftSettings.theme,
density: draftSettings.density,
showNavigationLabels: Boolean(draftSettings.showNavigationLabels)
} }
})) nextElectronSettings = {
...nextElectronSettings,
...pickSettings(nextSettings, APP_SETTING_NAMES),
overrideTheme: useAppTheme
} }
setThemeMode(draftSettings.theme) const saved = await setAppSettings(nextElectronSettings)
setDensityMode(draftSettings.density) if (!saved) {
setShowNavigationLabels(draftSettings.showNavigationLabels) showError('Unable to save settings.')
setAppSettingsState(nextSettings) return
}
}
applyAppearance(nextSettings, nextElectronSettings)
setElectronSettings(nextElectronSettings)
setSavedSettings(nextSettings)
setDraftSettings(nextSettings) setDraftSettings(nextSettings)
setIsEditing(false) setIsEditing(false)
showSuccess('Settings saved.') showSuccess('Settings saved.')
const appUpdateSettingsChanged = const appUpdateSettingsChanged =
isElectron && isElectron &&
(nextSettings.appUpdateBranch !== appSettings.appUpdateBranch || (nextSettings.appUpdateBranch !== savedSettings.appUpdateBranch ||
nextSettings.appUpdateEngine !== nextSettings.appUpdateEngine !==
normalizeAppUpdateEngine(appSettings.appUpdateEngine)) normalizeAppUpdateEngine(savedSettings.appUpdateEngine))
if (appUpdateSettingsChanged) { if (appUpdateSettingsChanged) {
void recheckForUpdates() void recheckForUpdates()
@ -247,7 +304,10 @@ const Settings = () => {
<Space size='small'> <Space size='small'>
<ViewButton <ViewButton
disabled={settingsLoading} disabled={settingsLoading}
items={viewItems} items={visibleSections.map((section) => ({
key: section.key,
label: section.name
}))}
visibleState={collapseState} visibleState={collapseState}
updateVisibleState={updateCollapseState} updateVisibleState={updateCollapseState}
/> />
@ -257,163 +317,51 @@ const Settings = () => {
handleUpdate={handleSave} handleUpdate={handleSave}
cancelEditing={cancelEditing} cancelEditing={cancelEditing}
startEditing={startEditing} startEditing={startEditing}
formValid={ formValid={areSettingsValid(draftSettings, { isElectron })}
Boolean(draftSettings.theme && draftSettings.density) && disabled={settingsLoading || !userSettingsLoaded}
(!isElectron ||
(Boolean(draftSettings.appUpdateBranch) &&
Boolean(normalizeAppUpdateEngine(draftSettings.appUpdateEngine))))
}
disabled={settingsLoading || (!isElectron && !userProfile)}
loading={saving} loading={saving}
/> />
</Flex> </Flex>
<div style={{ height: '100%', minHeight: 0, overflowY: 'auto' }}> <ScrollBox>
<Spin spinning={settingsLoading} indicator={<LoadingOutlined />}>
<Flex vertical gap='large'> <Flex vertical gap='large'>
{visibleSections.map((section) => {
const Icon = section.icon
return (
<InfoCollapse <InfoCollapse
title='Appearance Settings' key={section.key}
icon={<SettingOutlined />} title={section.name}
active={collapseState.appearance} icon={<Icon />}
active={collapseState[section.key]}
onToggle={(expanded) => onToggle={(expanded) =>
updateCollapseState('appearance', expanded) updateCollapseState(section.key, expanded)
} }
collapseKey='appearance' collapseKey={section.key}
> >
<Descriptions <ObjectInfo
bordered loading={settingsLoading}
column={{ isEditing={isEditing}
xs: 1, propertyDefinitions={section.properties}
sm: 1, objectData={resolvedSettings}
md: 1, parentData={{ branches, isElectron }}
lg: 2, onPropertyChange={
xl: 2, isEditing
xxl: 2 ? (name, value) =>
}}
>
<Descriptions.Item label='Theme'>
{isEditing ? (
<Select
value={draftSettings.theme}
onChange={(value) =>
setDraftSettings((previous) => ({ setDraftSettings((previous) => ({
...previous, ...previous,
theme: value [name]:
value?.target && typeof value.target === 'object'
? value.target.value
: value
})) }))
: undefined
} }
style={{ width: '100%' }} {...(section.column ? { column: section.column } : {})}
> />
<Option value='light'>Light</Option>
<Option value='dark'>Dark</Option>
<Option value='system'>System</Option>
</Select>
) : (
<Text>{currentThemeValue}</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='UI Density'>
{isEditing ? (
<Select
value={draftSettings.density}
onChange={(value) =>
setDraftSettings((previous) => ({
...previous,
density: value
}))
}
style={{ width: '100%' }}
>
<Option value='comfortable'>Comfortable</Option>
<Option value='compact'>Compact</Option>
</Select>
) : (
<Text>{isCompact ? 'Compact' : 'Comfortable'}</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Navigation Labels'>
{isEditing ? (
<Select
value={
draftSettings.showNavigationLabels ? 'show' : 'hide'
}
onChange={(value) =>
setDraftSettings((previous) => ({
...previous,
showNavigationLabels: value === 'show'
}))
}
style={{ width: '100%' }}
>
<Option value='show'>Show</Option>
<Option value='hide'>Hide</Option>
</Select>
) : (
<Text>
{currentShowNavigationLabels ? 'Show' : 'Hide'}
</Text>
)}
</Descriptions.Item>
</Descriptions>
</InfoCollapse> </InfoCollapse>
{isElectron && ( )
<InfoCollapse })}
title='App Update Settings'
icon={<SettingOutlined />}
active={collapseState.appUpdates}
onToggle={(expanded) =>
updateCollapseState('appUpdates', expanded)
}
collapseKey='appUpdates'
>
<Descriptions bordered column={1}>
<Descriptions.Item label='Branch'>
{isEditing ? (
<Select
value={draftSettings.appUpdateBranch}
onChange={(value) =>
setDraftSettings((previous) => ({
...previous,
appUpdateBranch: value
}))
}
style={{ width: '100%' }}
loading={branchLoading}
placeholder='Select a branch'
>
{branchOptions}
</Select>
) : (
<Text>{currentBranch}</Text>
)}
</Descriptions.Item>
<Descriptions.Item label='Engine'>
{isEditing ? (
<Select
value={
normalizeAppUpdateEngine(draftSettings.appUpdateEngine) ||
currentEngine
}
onChange={(value) =>
setDraftSettings((previous) => ({
...previous,
appUpdateEngine: value
}))
}
style={{ width: '100%' }}
placeholder='Select an engine'
>
<Option value='native'>Native</Option>
<Option value='chromium'>Chromium</Option>
</Select>
) : (
<Text>{engineLabel(currentEngine)}</Text>
)}
</Descriptions.Item>
</Descriptions>
</InfoCollapse>
)}
</Flex> </Flex>
</Spin> </ScrollBox>
</div>
</Flex> </Flex>
) )
} }

View File

@ -38,7 +38,8 @@ const createEmptyUserSettings = () => ({
sortSidebarVisibility: {}, sortSidebarVisibility: {},
columnVisibility: {}, columnVisibility: {},
collapseState: {}, collapseState: {},
pageLayout: {} pageLayout: {},
appearance: {}
}) })
const normalizeUserSettingsCategory = (category) => const normalizeUserSettingsCategory = (category) =>
@ -76,7 +77,8 @@ const normalizeUserSettings = (settings = {}) => ({
), ),
columnVisibility: normalizeUserSettingsCategory(settings?.columnVisibility), columnVisibility: normalizeUserSettingsCategory(settings?.columnVisibility),
collapseState: normalizeUserSettingsCategory(settings?.collapseState), collapseState: normalizeUserSettingsCategory(settings?.collapseState),
pageLayout: normalizeUserSettingsCategory(settings?.pageLayout) pageLayout: normalizeUserSettingsCategory(settings?.pageLayout),
appearance: normalizeUserSettingsCategory(settings?.appearance)
}) })
const emitWithAcknowledgement = (socket, eventName, data) => const emitWithAcknowledgement = (socket, eventName, data) =>

306
src/database/Settings.js Normal file
View File

@ -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