diff --git a/DeskThingServer/src/renderer/src/components/settings/SettingDynamic.tsx b/DeskThingServer/src/renderer/src/components/settings/SettingDynamic.tsx new file mode 100644 index 00000000..f6fa41fa --- /dev/null +++ b/DeskThingServer/src/renderer/src/components/settings/SettingDynamic.tsx @@ -0,0 +1,164 @@ +import React, { ReactNode, Reducer, useCallback, useEffect, useReducer } from 'react' +import SettingComponent from './SettingComponent' +import { SettingsDynamic, SettingsTypeWithoutDynamic, DynamicSettingsValue } from '@shared/types' +import { SETTINGS_COMPONENTS, SettingsProps } from '.' +import Button from '../Button' +import { IconTrash } from '@renderer/assets/icons' + +interface SettingsDynamicProps { + setting: SettingsDynamic + handleSettingChange: (value: DynamicSettingsValue) => void + className?: string +} + +interface SettingsPropsWithoutDynamic extends Omit { + setting: SettingsTypeWithoutDynamic +} + +type SettingsAction = + | { + type: 'UPDATE_ENTRY' + index: number + key: string + value: SettingsTypeWithoutDynamic['value'] + } + | { type: 'ADD_ENTRY'; options: SettingsTypeWithoutDynamic[] } + | { type: 'REMOVE_ENTRY'; index: number } + +const createEmptyEntry = ( + options: SettingsTypeWithoutDynamic[] +): Record => { + return options.reduce( + (acc, option) => ({ + ...acc, + [option.label]: option.value + }), + {} + ) +} + +const settingsReducer: Reducer = (state, action) => { + switch (action.type) { + case 'UPDATE_ENTRY': + return state.map((entry, i) => + i === action.index + ? { + ...entry, + [action.key]: action.value + } + : entry + ) + case 'ADD_ENTRY': + return [...state, createEmptyEntry(action.options)] + case 'REMOVE_ENTRY': + return state.filter((_, i) => i !== action.index) + default: + return state + } +} + +const settingRenderer = ({ + setting, + className, + handleSettingChange +}: SettingsPropsWithoutDynamic): ReactNode => { + const SettingComponent = SETTINGS_COMPONENTS[setting.type] as React.ComponentType<{ + setting: SettingsTypeWithoutDynamic + handleSettingChange: (value: SettingsTypeWithoutDynamic['value']) => void + className?: string + }> + + return SettingComponent ? ( + + ) : null +} + +const createInitialState = (setting: SettingsDynamic): DynamicSettingsValue => { + return setting.value || [createEmptyEntry(setting.options)] +} + +export const SettingsDynamicComponent: React.FC = ({ + className, + setting, + handleSettingChange +}: SettingsDynamicProps) => { + const [state, dispatch] = useReducer(settingsReducer, setting, createInitialState) + + useEffect(() => { + handleSettingChange(state) + }, [state, handleSettingChange]) + + const handleChange = useCallback( + (index: number, key: string, value: SettingsTypeWithoutDynamic['value']): void => { + dispatch({ + type: 'UPDATE_ENTRY', + index, + key, + value + }) + }, + [] + ) + + const handleAddEntry = useCallback(() => { + dispatch({ type: 'ADD_ENTRY', options: setting.options }) + }, [setting.options]) + + const handleRemoveEntry = useCallback((index: number) => { + dispatch({ type: 'REMOVE_ENTRY', index }) + }, []) + + const renderEntry = useCallback( + (index: number): ReturnType[] => { + return setting.options.map((option) => { + const currentValue = state[index]?.[option.label] + + return settingRenderer({ + setting: { + ...option, + value: currentValue ?? option.value + } as Extract, + className: 'flex flex-col', + handleSettingChange: (value) => + handleChange(index, option.label, value as SettingsTypeWithoutDynamic['value']) + }) + }) + }, + [setting.options, state, handleChange] + ) + + return ( + + {state.map((_, index) => ( +
+
+ {renderEntry(index)} + {state.length > 1 && ( + + )} +
+
+ ))} + + + + +
{JSON.stringify(state, null, 2)}
+
+
+ ) +} diff --git a/DeskThingServer/src/renderer/src/components/settings/SettingsBoolean.tsx b/DeskThingServer/src/renderer/src/components/settings/SettingsBoolean.tsx index 094ab6a6..a1275027 100644 --- a/DeskThingServer/src/renderer/src/components/settings/SettingsBoolean.tsx +++ b/DeskThingServer/src/renderer/src/components/settings/SettingsBoolean.tsx @@ -1,12 +1,12 @@ import React from 'react' import SettingComponent from './SettingComponent' -import { SettingsBoolean } from '@shared/types' +import { SettingsBoolean, SettingsOutputValue } from '@shared/types' import Button from '../Button' import { IconToggle } from '@renderer/assets/icons' interface SettingsBooleanProps { setting: SettingsBoolean - handleSettingChange: (value: number | boolean | string | string[]) => void + handleSettingChange: (value: SettingsOutputValue) => void className?: string } diff --git a/DeskThingServer/src/renderer/src/components/settings/SettingsColor.tsx b/DeskThingServer/src/renderer/src/components/settings/SettingsColor.tsx index 925466b6..885cdeae 100644 --- a/DeskThingServer/src/renderer/src/components/settings/SettingsColor.tsx +++ b/DeskThingServer/src/renderer/src/components/settings/SettingsColor.tsx @@ -1,10 +1,10 @@ import React, { useState, useEffect } from 'react' import SettingComponent from './SettingComponent' -import { SettingsColor } from '@shared/types' +import { SettingsColor, SettingsOutputValue } from '@shared/types' interface SettingsColorProps { setting: SettingsColor - handleSettingChange: (value: number | boolean | string | string[]) => void + handleSettingChange: (value: SettingsOutputValue) => void className?: string } diff --git a/DeskThingServer/src/renderer/src/components/settings/SettingsList.tsx b/DeskThingServer/src/renderer/src/components/settings/SettingsList.tsx index a795c6e2..d32675c0 100644 --- a/DeskThingServer/src/renderer/src/components/settings/SettingsList.tsx +++ b/DeskThingServer/src/renderer/src/components/settings/SettingsList.tsx @@ -1,11 +1,11 @@ import React from 'react' import SettingComponent from './SettingComponent' -import { SettingsList } from '@shared/types' +import { SettingsList, SettingsOutputValue } from '@shared/types' import TagList from '../TagList' interface SettingsListProps { setting: SettingsList - handleSettingChange: (value: number | boolean | string | string[]) => void + handleSettingChange: (value: SettingsOutputValue) => void className?: string } diff --git a/DeskThingServer/src/renderer/src/components/settings/SettingsMultiSelect.tsx b/DeskThingServer/src/renderer/src/components/settings/SettingsMultiSelect.tsx index dba2ba2f..78c10bad 100644 --- a/DeskThingServer/src/renderer/src/components/settings/SettingsMultiSelect.tsx +++ b/DeskThingServer/src/renderer/src/components/settings/SettingsMultiSelect.tsx @@ -1,12 +1,12 @@ import React from 'react' import SettingComponent from './SettingComponent' -import { SettingOption, SettingsMultiSelect } from '@shared/types' +import { SettingOption, SettingsMultiSelect, SettingsOutputValue } from '@shared/types' import Select from '../Select' import { MultiValue } from 'react-select' interface SettingsMultiSelectProps { setting: SettingsMultiSelect - handleSettingChange: (value: number | boolean | string | string[]) => void + handleSettingChange: (value: SettingsOutputValue) => void className?: string } diff --git a/DeskThingServer/src/renderer/src/components/settings/SettingsNumber.tsx b/DeskThingServer/src/renderer/src/components/settings/SettingsNumber.tsx index 03b89fc5..1a4c07db 100644 --- a/DeskThingServer/src/renderer/src/components/settings/SettingsNumber.tsx +++ b/DeskThingServer/src/renderer/src/components/settings/SettingsNumber.tsx @@ -1,10 +1,10 @@ import React from 'react' import SettingComponent from './SettingComponent' -import { SettingsNumber } from '@shared/types' +import { SettingsNumber, SettingsOutputValue } from '@shared/types' interface SettingsNumberProps { setting: SettingsNumber - handleSettingChange: (value: number | boolean | string | string[]) => void + handleSettingChange: (value: SettingsOutputValue) => void className?: string } diff --git a/DeskThingServer/src/renderer/src/components/settings/SettingsRange.tsx b/DeskThingServer/src/renderer/src/components/settings/SettingsRange.tsx index dd63d387..e8aad778 100644 --- a/DeskThingServer/src/renderer/src/components/settings/SettingsRange.tsx +++ b/DeskThingServer/src/renderer/src/components/settings/SettingsRange.tsx @@ -1,10 +1,10 @@ import React from 'react' import SettingComponent from './SettingComponent' -import { SettingsRange } from '@shared/types' +import { SettingsOutputValue, SettingsRange } from '@shared/types' interface SettingsRangeProps { setting: SettingsRange - handleSettingChange: (value: number | boolean | string | string[]) => void + handleSettingChange: (value: SettingsOutputValue) => void className?: string } diff --git a/DeskThingServer/src/renderer/src/components/settings/SettingsRanked.tsx b/DeskThingServer/src/renderer/src/components/settings/SettingsRanked.tsx index a3f982a4..64851420 100644 --- a/DeskThingServer/src/renderer/src/components/settings/SettingsRanked.tsx +++ b/DeskThingServer/src/renderer/src/components/settings/SettingsRanked.tsx @@ -1,11 +1,11 @@ import React from 'react' import SettingComponent from './SettingComponent' -import { SettingsRanked } from '@shared/types' +import { SettingsOutputValue, SettingsRanked } from '@shared/types' import RankableList from '../RankableList' interface SettingsRankedProps { setting: SettingsRanked - handleSettingChange: (value: number | boolean | string | string[]) => void + handleSettingChange: (value: SettingsOutputValue) => void className?: string } diff --git a/DeskThingServer/src/renderer/src/components/settings/SettingsSelect.tsx b/DeskThingServer/src/renderer/src/components/settings/SettingsSelect.tsx index 51e4e008..e6b3c315 100644 --- a/DeskThingServer/src/renderer/src/components/settings/SettingsSelect.tsx +++ b/DeskThingServer/src/renderer/src/components/settings/SettingsSelect.tsx @@ -1,12 +1,12 @@ import React from 'react' import SettingComponent from './SettingComponent' -import { SettingOption, SettingsSelect } from '@shared/types' +import { SettingOption, SettingsOutputValue, SettingsSelect } from '@shared/types' import { SingleValue } from 'react-select' import Select from '../Select' interface SettingsSelectProps { setting: SettingsSelect - handleSettingChange: (value: number | boolean | string | string[]) => void + handleSettingChange: (value: SettingsOutputValue) => void className?: string } diff --git a/DeskThingServer/src/renderer/src/components/settings/SettingsString.tsx b/DeskThingServer/src/renderer/src/components/settings/SettingsString.tsx index 8a12f362..e8227462 100644 --- a/DeskThingServer/src/renderer/src/components/settings/SettingsString.tsx +++ b/DeskThingServer/src/renderer/src/components/settings/SettingsString.tsx @@ -1,10 +1,10 @@ import React from 'react' import SettingComponent from './SettingComponent' -import { SettingsString } from '@shared/types' +import { SettingsOutputValue, SettingsString } from '@shared/types' interface SettingsStringProps { setting: SettingsString - handleSettingChange: (value: number | boolean | string | string[]) => void + handleSettingChange: (value: SettingsOutputValue) => void className?: string } @@ -21,7 +21,7 @@ export const SettingsStringComponent: React.FC = ({
handleSettingChange(e.target.value)} className={commonClasses + ' text-black w-96 max-w-s'} diff --git a/DeskThingServer/src/renderer/src/components/settings/index.tsx b/DeskThingServer/src/renderer/src/components/settings/index.tsx index 6ee09bfc..df398297 100644 --- a/DeskThingServer/src/renderer/src/components/settings/index.tsx +++ b/DeskThingServer/src/renderer/src/components/settings/index.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { SettingsType } from '@shared/types' +import { SettingsOutputValue, SettingsType } from '@shared/types' import { SettingsBooleanComponent } from './SettingsBoolean' import { SettingsListComponent } from './SettingsList' import { SettingsMultiSelectComponent } from './SettingsMultiSelect' @@ -9,17 +9,18 @@ import { SettingsRankedComponent } from './SettingsRanked' import { SettingsSelectComponent } from './SettingsSelect' import { SettingsStringComponent } from './SettingsString' import { SettingsColorComponent } from './SettingsColor' +import { SettingsDynamicComponent } from './SettingDynamic' export interface SettingsProps { setting: SettingsType - handleSettingChange: (value: number | boolean | string | string[]) => void + handleSettingChange: (value: SettingsOutputValue) => void className?: string } -const SETTINGS_COMPONENTS: { +export const SETTINGS_COMPONENTS: { [K in SettingsType['type']]: React.ComponentType<{ setting: SettingsType & { type: K } - handleSettingChange: (value: number | boolean | string | string[]) => void + handleSettingChange: (value: SettingsOutputValue) => void className?: string }> } = { @@ -31,13 +32,14 @@ const SETTINGS_COMPONENTS: { ranked: SettingsRankedComponent, select: SettingsSelectComponent, string: SettingsStringComponent, - color: SettingsColorComponent + color: SettingsColorComponent, + dynamic: SettingsDynamicComponent } as const export const Settings: React.FC = ({ setting, className, handleSettingChange }) => { const SettingComponent = SETTINGS_COMPONENTS[setting.type] as React.ComponentType<{ setting: SettingsType - handleSettingChange: (value: number | boolean | string | string[]) => void + handleSettingChange: (value: SettingsOutputValue) => void className?: string }> return SettingComponent ? ( diff --git a/DeskThingServer/src/renderer/src/overlays/apps/AppSettings.tsx b/DeskThingServer/src/renderer/src/overlays/apps/AppSettings.tsx index 03dcf1fb..d5ac6893 100644 --- a/DeskThingServer/src/renderer/src/overlays/apps/AppSettings.tsx +++ b/DeskThingServer/src/renderer/src/overlays/apps/AppSettings.tsx @@ -1,67 +1,103 @@ -import React, { useEffect, useState, useCallback, useMemo } from 'react' +import React, { useEffect, useReducer, useCallback } from 'react' import { useAppStore } from '@renderer/stores' -import { AppDataInterface } from '@shared/types' +import { AppDataInterface, SettingsOutputValue, SettingsType } from '@shared/types' import { AppSettingProps } from './AppsOverlay' import Button from '@renderer/components/Button' import { IconLoading, IconSave } from '@renderer/assets/icons' import Settings from '@renderer/components/settings' +type SettingsState = { + settings: Map + loaded: boolean + loading: boolean +} + +type SettingsAction = + | { type: 'INIT_SETTINGS'; payload: AppDataInterface | null } + | { type: 'UPDATE_SETTING'; payload: { key: string; value: SettingsOutputValue } } + | { type: 'SET_LOADING'; payload: boolean } + +function settingsReducer(state: SettingsState, action: SettingsAction): SettingsState { + switch (action.type) { + case 'INIT_SETTINGS': { + if (!action.payload) { + return { + ...state + } + } + + const settingsMap = new Map(Object.entries(action.payload.settings || {})) + return { + ...state, + settings: settingsMap, + loaded: true + } + } + case 'UPDATE_SETTING': { + const newSettings = new Map(state.settings) + const currentSetting = newSettings.get(action.payload.key) + if (currentSetting) { + newSettings.set(action.payload.key, { + ...currentSetting, + value: action.payload.value + } as Extract) + } + return { + ...state, + settings: newSettings + } + } + case 'SET_LOADING': + return { + ...state, + loading: action.payload + } + default: + return state + } +} + const AppSettings: React.FC = ({ app }) => { const getAppData = useAppStore((state) => state.getAppData) const saveAppData = useAppStore((state) => state.setAppData) - const [appData, setAppData] = useState(null) - const [loading, setLoading] = useState(false) + + const [state, dispatch] = useReducer(settingsReducer, { + settings: new Map(), + loaded: false, + loading: false + }) useEffect(() => { const fetchAppData = async (): Promise => { const data = await getAppData(app.name) - setAppData(data) + dispatch({ type: 'INIT_SETTINGS', payload: data }) } fetchAppData() }, [app.name, getAppData]) - const handleSettingChange = useCallback( - (key: string, value: string | number | boolean | string[] | boolean[]) => { - console.log('Setting changed:', key, value) - setAppData((prev) => - prev && prev.settings - ? { - ...prev, - settings: { - ...prev.settings, - [key]: { - ...prev.settings[key], - // It had to be this way... The way that the type expects a specific value for each type of object means that this can only be every type of value but only one at a time. We have no way of knowing which type of setting it is. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - value: value as any - } - } - } - : prev - ) - }, - [] - ) - - const settingsEntries = useMemo( - () => (appData?.settings ? Object.entries(appData.settings) : []), - [appData] - ) + const handleSettingChange = useCallback((key: string, value: SettingsOutputValue) => { + dispatch({ type: 'UPDATE_SETTING', payload: { key, value } }) + }, []) const onSaveClick = async (): Promise => { - if (!appData) return - setLoading(true) + dispatch({ type: 'SET_LOADING', payload: true }) try { - await saveAppData(app.name, appData) + const appData: AppDataInterface = { + ...(await getAppData(app.name)), + settings: Object.fromEntries(state.settings) + } + + saveAppData(app.name, appData) } catch (error) { console.error('Error saving app data:', error) } - - await setTimeout(() => { - setLoading(false) + setTimeout(() => { + dispatch({ type: 'SET_LOADING', payload: false }) }, 500) } + const settingsEntries = Array.from(state.settings.entries()) + return (
{settingsEntries.map(([key, setting]) => ( @@ -73,15 +109,16 @@ const AppSettings: React.FC = ({ app }) => { ))}
) } + export default AppSettings diff --git a/DeskThingServer/src/shared/types/app.ts b/DeskThingServer/src/shared/types/app.ts index bd99785c..91e6601e 100644 --- a/DeskThingServer/src/shared/types/app.ts +++ b/DeskThingServer/src/shared/types/app.ts @@ -103,6 +103,7 @@ interface SettingsBase { | 'select' | 'string' | 'color' + | 'dynamic' label: string description?: string } @@ -195,6 +196,22 @@ export interface SettingsColor extends SettingsBase { placeholder?: string } +export interface SettingsDynamic extends SettingsBase { + type: 'dynamic' + value: DynamicSettingsValue + options: SettingsTypeWithoutDynamic[] + label: string + description?: string +} + +export type DynamicSettingsValue = Record< + SettingsTypeWithoutDynamic['label'], + SettingsTypeWithoutDynamic['value'] +>[] + +// NOTE: Exclude dynamic setting as option as we don't want nested dynamic settings +export type SettingsTypeWithoutDynamic = Exclude + export type SettingsType = | SettingsNumber | SettingsBoolean @@ -205,6 +222,9 @@ export type SettingsType = | SettingsRanked | SettingsList | SettingsColor + | SettingsDynamic + +export type SettingsOutputValue = number | boolean | string | string[] | DynamicSettingsValue export interface AppSettings { [key: string]: SettingsType