diff --git a/apps/roam/src/components/settings/components/BlockPropSettingPanels.tsx b/apps/roam/src/components/settings/components/BlockPropSettingPanels.tsx new file mode 100644 index 000000000..396e9943d --- /dev/null +++ b/apps/roam/src/components/settings/components/BlockPropSettingPanels.tsx @@ -0,0 +1,401 @@ +import React, { type ChangeEvent, useState } from "react"; +import { + Checkbox, + InputGroup, + Label, + NumericInput, + HTMLSelect, + Button, + Tag, +} from "@blueprintjs/core"; +import Description from "roamjs-components/components/Description"; +import idToTitle from "roamjs-components/util/idToTitle"; +import { + getGlobalSetting, + setGlobalSetting, + getPersonalSetting, + setPersonalSetting, + getFeatureFlag, + setFeatureFlag, +} from "~/components/settings/utils/accessors"; +import type { FeatureFlags } from "~/components/settings/utils/zodSchema"; + +type TextGetter = (keys: string[]) => string | undefined; +type TextSetter = (keys: string[], value: string) => void; + +type FlagGetter = (keys: string[]) => boolean | undefined; +type FlagSetter = (keys: string[], value: boolean) => void; + +type NumberGetter = (keys: string[]) => number | undefined; +type NumberSetter = (keys: string[], value: number) => void; + +type MultiTextGetter = (keys: string[]) => string[] | undefined; +type MultiTextSetter = (keys: string[], value: string[]) => void; + +type BaseTextPanelProps = { + title: string; + description: string; + settingKeys: string[]; + getter: TextGetter; + setter: TextSetter; + defaultValue?: string; + placeholder?: string; +}; + +type BaseFlagPanelProps = { + title: string; + description: string; + settingKeys: string[]; + getter: FlagGetter; + setter: FlagSetter; + defaultValue?: boolean; + disabled?: boolean; + onBeforeChange?: (checked: boolean) => Promise; + onChange?: (checked: boolean) => void; +}; + +type BaseNumberPanelProps = { + title: string; + description: string; + settingKeys: string[]; + getter: NumberGetter; + setter: NumberSetter; + defaultValue?: number; + min?: number; + max?: number; +}; + +type BaseSelectPanelProps = { + title: string; + description: string; + settingKeys: string[]; + getter: TextGetter; + setter: TextSetter; + options: string[]; + defaultValue?: string; +}; + +type BaseMultiTextPanelProps = { + title: string; + description: string; + settingKeys: string[]; + getter: MultiTextGetter; + setter: MultiTextSetter; + defaultValue?: string[]; +}; + + +const BaseTextPanel = ({ + title, + description, + settingKeys, + getter, + setter, + defaultValue = "", + placeholder, +}: BaseTextPanelProps) => { + const [value, setValue] = useState(() => getter(settingKeys) ?? defaultValue); + + const handleChange = (e: ChangeEvent) => { + const newValue = e.target.value; + setValue(newValue); + setter(settingKeys, newValue); + }; + + return ( + + ); +}; + +const BaseFlagPanel = ({ + title, + description, + settingKeys, + getter, + setter, + defaultValue = false, + disabled = false, + onBeforeChange, + onChange, +}: BaseFlagPanelProps) => { + const [value, setValue] = useState(() => getter(settingKeys) ?? defaultValue); + + const handleChange = async (e: React.FormEvent) => { + const { checked } = e.target as HTMLInputElement; + + if (onBeforeChange) { + const shouldProceed = await onBeforeChange(checked); + if (!shouldProceed) return; + } + + setValue(checked); + setter(settingKeys, checked); + onChange?.(checked); + }; + + return ( + void handleChange(e)} + disabled={disabled} + labelElement={ + <> + {idToTitle(title)} + + + } + /> + ); +}; + +const BaseNumberPanel = ({ + title, + description, + settingKeys, + getter, + setter, + defaultValue = 0, + min, + max, +}: BaseNumberPanelProps) => { + const [value, setValue] = useState(() => getter(settingKeys) ?? defaultValue); + + const handleChange = (valueAsNumber: number) => { + if (Number.isNaN(valueAsNumber)) return; + setValue(valueAsNumber); + setter(settingKeys, valueAsNumber); + }; + + return ( + + ); +}; + +const BaseSelectPanel = ({ + title, + description, + settingKeys, + getter, + setter, + options, + defaultValue, +}: BaseSelectPanelProps) => { + const [value, setValue] = useState( + () => getter(settingKeys) ?? defaultValue ?? options[0], + ); + + const handleChange = (e: ChangeEvent) => { + const newValue = e.target.value; + setValue(newValue); + setter(settingKeys, newValue); + }; + + return ( + + ); +}; + +const BaseMultiTextPanel = ({ + title, + description, + settingKeys, + getter, + setter, + defaultValue = [], +}: BaseMultiTextPanelProps) => { + const [values, setValues] = useState( + () => getter(settingKeys) ?? defaultValue, + ); + const [inputValue, setInputValue] = useState(""); + + const handleAdd = () => { + if (inputValue.trim() && !values.includes(inputValue.trim())) { + const newValues = [...values, inputValue.trim()]; + setValues(newValues); + setter(settingKeys, newValues); + setInputValue(""); + } + }; + + const handleRemove = (index: number) => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const newValues = values.filter((_, i) => i !== index); + setValues(newValues); + setter(settingKeys, newValues); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAdd(); + } + }; + + return ( + + ); +}; + +type TextWrapperProps = Omit; +type FlagWrapperProps = Omit; +type NumberWrapperProps = Omit; +type SelectWrapperProps = Omit; +type MultiTextWrapperProps = Omit; + +const featureFlagGetter: FlagGetter = (keys) => { + const key = keys[0]; + if (!key) return undefined; + return getFeatureFlag(key as keyof FeatureFlags); +}; + +const featureFlagSetter: FlagSetter = (keys, value) => { + const key = keys[0]; + if (!key) return; + setFeatureFlag(key as keyof FeatureFlags, value); +}; + +type Getter = (keys: string[]) => T | undefined; +type Setter = (keys: string[], value: T) => void; +type Accessors = { getter: Getter; setter: Setter }; + +const createAccessors = ( + getFn: (keys: string[]) => U | undefined, + setFn: (keys: string[], value: T) => void, +): Accessors => ({ + getter: (keys) => getFn(keys), + setter: setFn, +}); + +const globalAccessors = { + text: createAccessors(getGlobalSetting, setGlobalSetting), + flag: createAccessors(getGlobalSetting, setGlobalSetting), + number: createAccessors(getGlobalSetting, setGlobalSetting), + multiText: createAccessors(getGlobalSetting, setGlobalSetting), +}; + +const personalAccessors = { + text: createAccessors(getPersonalSetting, setPersonalSetting), + flag: createAccessors(getPersonalSetting, setPersonalSetting), + number: createAccessors(getPersonalSetting, setPersonalSetting), + multiText: createAccessors(getPersonalSetting, setPersonalSetting), +}; + +export const FeatureFlagPanel = ({ + title, + description, + featureKey, + onBeforeEnable, + onAfterChange, +}: { + title: string; + description: string; + featureKey: keyof FeatureFlags; + onBeforeEnable?: () => Promise; + onAfterChange?: (checked: boolean) => void; +}) => { + const handleBeforeChange: ((checked: boolean) => Promise) | undefined = + onBeforeEnable + ? async (checked) => { + if (checked) { + return onBeforeEnable(); + } + return true; + } + : undefined; + + return ( + + ); +}; + +export const GlobalTextPanel = (props: TextWrapperProps) => ( + +); + +export const GlobalFlagPanel = (props: FlagWrapperProps) => ( + +); + +export const GlobalNumberPanel = (props: NumberWrapperProps) => ( + +); + +export const GlobalSelectPanel = (props: SelectWrapperProps) => ( + +); + +export const GlobalMultiTextPanel = (props: MultiTextWrapperProps) => ( + +); + +export const PersonalTextPanel = (props: TextWrapperProps) => ( + +); + +export const PersonalFlagPanel = (props: FlagWrapperProps) => ( + +); + +export const PersonalNumberPanel = (props: NumberWrapperProps) => ( + +); + +export const PersonalSelectPanel = (props: SelectWrapperProps) => ( + +); + +export const PersonalMultiTextPanel = (props: MultiTextWrapperProps) => ( + +);