pl-fe: migrate config?

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2024-11-15 14:17:53 +01:00
parent 44a4116a75
commit ce2ac73fbe
7 changed files with 85 additions and 61 deletions

View file

@ -17,7 +17,7 @@ const CryptoAddressInput: StreamfieldComponent<CryptoAddress> = ({ value, onChan
const intl = useIntl(); const intl = useIntl();
const handleChange = (key: 'ticker' | 'address' | 'note'): React.ChangeEventHandler<HTMLInputElement> => e => { const handleChange = (key: 'ticker' | 'address' | 'note'): React.ChangeEventHandler<HTMLInputElement> => e => {
onChange(value.set(key, e.currentTarget.value)); onChange({ ...value, [key]: e.currentTarget.value });
}; };
return ( return (

View file

@ -16,7 +16,7 @@ const PromoPanelInput: StreamfieldComponent<FooterItem> = ({ value, onChange })
const intl = useIntl(); const intl = useIntl();
const handleChange = (key: 'title' | 'url'): React.ChangeEventHandler<HTMLInputElement> => e => { const handleChange = (key: 'title' | 'url'): React.ChangeEventHandler<HTMLInputElement> => e => {
onChange(value.set(key, e.currentTarget.value)); onChange({ ...value, [key]: e.currentTarget.value });
}; };
return ( return (

View file

@ -19,11 +19,11 @@ const PromoPanelInput: StreamfieldComponent<PromoPanelItem> = ({ value, onChange
const intl = useIntl(); const intl = useIntl();
const handleIconChange = (icon: string) => { const handleIconChange = (icon: string) => {
onChange(value.set('icon', icon)); onChange({ ...value, icon });
}; };
const handleChange = (key: 'text' | 'url'): React.ChangeEventHandler<HTMLInputElement> => e => { const handleChange = (key: 'text' | 'url'): React.ChangeEventHandler<HTMLInputElement> => e => {
onChange(value.set(key, e.currentTarget.value)); onChange({ ...value, [key]: e.currentTarget.value });
}; };
return ( return (

View file

@ -1,4 +1,4 @@
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; import { create } from 'mutative';
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import * as v from 'valibot'; import * as v from 'valibot';
@ -22,7 +22,7 @@ import ThemeSelector from 'pl-fe/features/ui/components/theme-selector';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch'; import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector'; import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { useFeatures } from 'pl-fe/hooks/use-features'; import { useFeatures } from 'pl-fe/hooks/use-features';
import { plFeConfigSchema } from 'pl-fe/normalizers/pl-fe/pl-fe-config'; import { cryptoAddressSchema, footerItemSchema, plFeConfigSchema, promoPanelItemSchema, type PlFeConfig } from 'pl-fe/normalizers/pl-fe/pl-fe-config';
import toast from 'pl-fe/toast'; import toast from 'pl-fe/toast';
import CryptoAddressInput from './components/crypto-address-input'; import CryptoAddressInput from './components/crypto-address-input';
@ -55,18 +55,11 @@ const messages = defineMessages({
sentryDsnHint: { id: 'plfe_config.sentry_dsn_hint', defaultMessage: 'DSN URL for error reporting. Works with Sentry and GlitchTip.' }, sentryDsnHint: { id: 'plfe_config.sentry_dsn_hint', defaultMessage: 'DSN URL for error reporting. Works with Sentry and GlitchTip.' },
}); });
type ValueGetter<T = Element> = (e: React.ChangeEvent<T>) => any; type ValueGetter<T1 = Element, T2 = any> = (e: React.ChangeEvent<T1>) => T2;
type Template = ImmutableMap<string, any>; type StreamItemConfigPath = ['promoPanel', 'items'] | ['navlinks', 'homeFooter'] | ['cryptoAddresses'];
type ConfigPath = Array<string | number>;
type ThemeChangeHandler = (theme: string) => void; type ThemeChangeHandler = (theme: string) => void;
const templates: Record<string, Template> = { const PlFeConfigEditor: React.FC = () => {
promoPanelItem: ImmutableMap({ icon: '', text: '', url: '' }),
footerItem: ImmutableMap({ title: '', url: '' }),
cryptoAddress: ImmutableMap({ ticker: '', address: '', note: '' }),
};
const PlFeConfig: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -75,26 +68,25 @@ const PlFeConfig: React.FC = () => {
const initialData = useAppSelector(state => state.plfe); const initialData = useAppSelector(state => state.plfe);
const [isLoading, setLoading] = useState(false); const [isLoading, setLoading] = useState(false);
const [data, setData] = useState(initialData); const [data, setData] = useState(v.parse(plFeConfigSchema, initialData));
const [jsonEditorExpanded, setJsonEditorExpanded] = useState(false); const [jsonEditorExpanded, setJsonEditorExpanded] = useState(false);
const [rawJSON, setRawJSON] = useState<string>(JSON.stringify(initialData, null, 2)); const [rawJSON, setRawJSON] = useState<string>(JSON.stringify(initialData, null, 2));
const [jsonValid, setJsonValid] = useState(true); const [jsonValid, setJsonValid] = useState(true);
const plFe = useMemo(() => v.parse(plFeConfigSchema, data), [data]); const plFe = useMemo(() => v.parse(plFeConfigSchema, data), [data]);
const setConfig = (path: ConfigPath, value: any) => { const setConfig = (newData: PlFeConfig) => {
const newData = data.setIn(path, value);
setData(newData); setData(newData);
setJsonValid(true); setJsonValid(true);
}; };
const putConfig = (newData: any) => { const putConfig = (newData: PlFeConfig) => {
setData(newData); setData(newData);
setJsonValid(true); setJsonValid(true);
}; };
const handleSubmit: React.FormEventHandler = (e) => { const handleSubmit: React.FormEventHandler = (e) => {
dispatch(updatePlFeConfig(data.toJS())).then(() => { dispatch(updatePlFeConfig(data)).then(() => {
setLoading(false); setLoading(false);
toast.success(intl.formatMessage(messages.saved)); toast.success(intl.formatMessage(messages.saved));
}).catch(() => { }).catch(() => {
@ -104,15 +96,20 @@ const PlFeConfig: React.FC = () => {
e.preventDefault(); e.preventDefault();
}; };
const handleChange = (path: ConfigPath, getValue: ValueGetter<any>): React.ChangeEventHandler => e => { const handleChange = (path: keyof PlFeConfig, getValue: ValueGetter<any, PlFeConfig[typeof path]>): React.ChangeEventHandler => e => {
setConfig(path, getValue(e)); const newData: PlFeConfig = { ...data, [path]: getValue(e) };
setConfig(newData);
}; };
const handleThemeChange = (path: ConfigPath): ThemeChangeHandler => theme => { const handleThemeChange: ThemeChangeHandler = (theme) => {
setConfig(path, theme); const newData = create(data, (draft) => {
if (!draft.defaultSettings) draft.defaultSettings = {};
draft.defaultSettings.themeMode = theme;
});
setConfig(newData);
}; };
const handleFileChange = (path: ConfigPath): React.ChangeEventHandler<HTMLInputElement> => e => { const handleFileChange = (path: keyof PlFeConfig): React.ChangeEventHandler<HTMLInputElement> => e => {
const file = e.target.files?.item(0); const file = e.target.files?.item(0);
if (file) { if (file) {
@ -122,19 +119,40 @@ const PlFeConfig: React.FC = () => {
} }
}; };
const handleStreamItemChange = (path: ConfigPath) => (values: any[]) => { const handleStreamItemChange = (path: StreamItemConfigPath) => (values: any[]) => {
setConfig(path, ImmutableList(values)); const newData = create(data, (draft) => {
if (path[0] === 'cryptoAddresses') {
draft.cryptoAddresses = values;
} else {
// @ts-ignore
draft[path[0]][path[1]] = values;
}
});
setConfig(newData);
}; };
const addStreamItem = (path: ConfigPath, template: Template) => () => { const addStreamItem = <T, >(path: StreamItemConfigPath, schema: v.BaseSchema<any, T, v.BaseIssue<unknown>>) => () => {
let items = data; const newData = create(data, (draft) => {
path.forEach(key => items = items?.[key] || []); if (path[0] === 'cryptoAddresses') {
setConfig(path, items.push(template)); draft.cryptoAddresses.push(v.parse(cryptoAddressSchema, {}));
} else {
// @ts-ignore
draft[path[0]][path[1]].push(v.parse(schema, {}));
}
});
setConfig(newData);
}; };
const deleteStreamItem = (path: ConfigPath) => (i: number) => { const deleteStreamItem = (path: StreamItemConfigPath) => (i: number) => {
const newData = data.deleteIn([...path, i]); const newData = create(data, (draft) => {
setData(newData); if (path[0] === 'cryptoAddresses') {
draft.cryptoAddresses = draft.cryptoAddresses.filter((_, index) => index !== i);
} else {
// @ts-ignore
draft[path[0]][path[1]] = draft[path[0]][path[1]].filter((_, index) => index !== i);
}
});
setConfig(newData);
}; };
const handleEditJSON: React.ChangeEventHandler<HTMLTextAreaElement> = e => { const handleEditJSON: React.ChangeEventHandler<HTMLTextAreaElement> = e => {
@ -144,7 +162,7 @@ const PlFeConfig: React.FC = () => {
const toggleJSONEditor = (expanded: boolean) => setJsonEditorExpanded(expanded); const toggleJSONEditor = (expanded: boolean) => setJsonEditorExpanded(expanded);
useEffect(() => { useEffect(() => {
putConfig(initialData); putConfig(v.parse(plFeConfigSchema, initialData));
}, [initialData]); }, [initialData]);
useEffect(() => { useEffect(() => {
@ -153,7 +171,7 @@ const PlFeConfig: React.FC = () => {
useEffect(() => { useEffect(() => {
try { try {
const data = fromJS(JSON.parse(rawJSON)); const data = v.parse(plFeConfigSchema, JSON.parse(rawJSON));
putConfig(data); putConfig(data);
} catch { } catch {
setJsonValid(false); setJsonValid(false);
@ -171,7 +189,7 @@ const PlFeConfig: React.FC = () => {
hintText={<FormattedMessage id='plfe_config.hints.logo' defaultMessage='SVG. At most 2 MB. Will be displayed to 50px height, maintaining aspect ratio' />} hintText={<FormattedMessage id='plfe_config.hints.logo' defaultMessage='SVG. At most 2 MB. Will be displayed to 50px height, maintaining aspect ratio' />}
> >
<FileInput <FileInput
onChange={handleFileChange(['logo'])} onChange={handleFileChange('logo')}
accept='image/svg+xml,image/png' accept='image/svg+xml,image/png'
/> />
</FormGroup> </FormGroup>
@ -183,8 +201,8 @@ const PlFeConfig: React.FC = () => {
<List> <List>
<ListItem label={<FormattedMessage id='plfe_config.fields.theme_label' defaultMessage='Default theme' />}> <ListItem label={<FormattedMessage id='plfe_config.fields.theme_label' defaultMessage='Default theme' />}>
<ThemeSelector <ThemeSelector
value={plFe.defaultSettings.get('themeMode')} value={plFe.defaultSettings?.themeMode}
onChange={handleThemeChange(['defaultSettings', 'themeMode'])} onChange={handleThemeChange}
/> />
</ListItem> </ListItem>
@ -202,14 +220,14 @@ const PlFeConfig: React.FC = () => {
<ListItem label={intl.formatMessage(messages.displayFqnLabel)}> <ListItem label={intl.formatMessage(messages.displayFqnLabel)}>
<Toggle <Toggle
checked={plFe.displayFqn === true} checked={plFe.displayFqn === true}
onChange={handleChange(['displayFqn'], (e) => e.target.checked)} onChange={handleChange('displayFqn', (e) => e.target.checked)}
/> />
</ListItem> </ListItem>
<ListItem label={intl.formatMessage(messages.greentextLabel)}> <ListItem label={intl.formatMessage(messages.greentextLabel)}>
<Toggle <Toggle
checked={plFe.greentext === true} checked={plFe.greentext === true}
onChange={handleChange(['greentext'], (e) => e.target.checked)} onChange={handleChange('greentext', (e) => e.target.checked)}
/> />
</ListItem> </ListItem>
@ -219,7 +237,7 @@ const PlFeConfig: React.FC = () => {
> >
<Toggle <Toggle
checked={plFe.feedInjection === true} checked={plFe.feedInjection === true}
onChange={handleChange(['feedInjection'], (e) => e.target.checked)} onChange={handleChange('feedInjection', (e) => e.target.checked)}
/> />
</ListItem> </ListItem>
@ -229,14 +247,14 @@ const PlFeConfig: React.FC = () => {
> >
<Toggle <Toggle
checked={plFe.mediaPreview === true} checked={plFe.mediaPreview === true}
onChange={handleChange(['mediaPreview'], (e) => e.target.checked)} onChange={handleChange('mediaPreview', (e) => e.target.checked)}
/> />
</ListItem> </ListItem>
<ListItem label={intl.formatMessage(messages.displayCtaLabel)}> <ListItem label={intl.formatMessage(messages.displayCtaLabel)}>
<Toggle <Toggle
checked={plFe.displayCta === true} checked={plFe.displayCta === true}
onChange={handleChange(['displayCta'], (e) => e.target.checked)} onChange={handleChange('displayCta', (e) => e.target.checked)}
/> />
</ListItem> </ListItem>
@ -246,7 +264,7 @@ const PlFeConfig: React.FC = () => {
> >
<Toggle <Toggle
checked={plFe.authenticatedProfile === true} checked={plFe.authenticatedProfile === true}
onChange={handleChange(['authenticatedProfile'], (e) => e.target.checked)} onChange={handleChange('authenticatedProfile', (e) => e.target.checked)}
/> />
</ListItem> </ListItem>
@ -258,7 +276,7 @@ const PlFeConfig: React.FC = () => {
type='text' type='text'
placeholder='/timeline/local' placeholder='/timeline/local'
value={String(data.redirectRootNoLogin || '')} value={String(data.redirectRootNoLogin || '')}
onChange={handleChange(['redirectRootNoLogin'], (e) => e.target.value)} onChange={handleChange('redirectRootNoLogin', (e) => e.target.value)}
/> />
</ListItem> </ListItem>
@ -270,7 +288,7 @@ const PlFeConfig: React.FC = () => {
type='text' type='text'
placeholder='https://01234abcdef@glitch.tip.tld/5678' placeholder='https://01234abcdef@glitch.tip.tld/5678'
value={String(data.sentryDsn || '')} value={String(data.sentryDsn || '')}
onChange={handleChange(['sentryDsn'], (e) => e.target.value)} onChange={handleChange('sentryDsn', (e) => e.target.value)}
/> />
</ListItem> </ListItem>
</List> </List>
@ -285,7 +303,7 @@ const PlFeConfig: React.FC = () => {
component={PromoPanelInput} component={PromoPanelInput}
values={plFe.promoPanel.items} values={plFe.promoPanel.items}
onChange={handleStreamItemChange(['promoPanel', 'items'])} onChange={handleStreamItemChange(['promoPanel', 'items'])}
onAddItem={addStreamItem(['promoPanel', 'items'], templates.promoPanel)} onAddItem={addStreamItem(['promoPanel', 'items'], promoPanelItemSchema)}
onRemoveItem={deleteStreamItem(['promoPanel', 'items'])} onRemoveItem={deleteStreamItem(['promoPanel', 'items'])}
draggable draggable
/> />
@ -296,7 +314,7 @@ const PlFeConfig: React.FC = () => {
component={FooterLinkInput} component={FooterLinkInput}
values={plFe.navlinks.homeFooter || []} values={plFe.navlinks.homeFooter || []}
onChange={handleStreamItemChange(['navlinks', 'homeFooter'])} onChange={handleStreamItemChange(['navlinks', 'homeFooter'])}
onAddItem={addStreamItem(['navlinks', 'homeFooter'], templates.footerItem)} onAddItem={addStreamItem(['navlinks', 'homeFooter'], footerItemSchema)}
onRemoveItem={deleteStreamItem(['navlinks', 'homeFooter'])} onRemoveItem={deleteStreamItem(['navlinks', 'homeFooter'])}
draggable draggable
/> />
@ -306,7 +324,7 @@ const PlFeConfig: React.FC = () => {
type='text' type='text'
placeholder={intl.formatMessage(messages.copyrightFooterLabel)} placeholder={intl.formatMessage(messages.copyrightFooterLabel)}
value={plFe.copyright} value={plFe.copyright}
onChange={handleChange(['copyright'], (e) => e.target.value)} onChange={handleChange('copyright', (e) => e.target.value)}
/> />
</FormGroup> </FormGroup>
@ -321,7 +339,7 @@ const PlFeConfig: React.FC = () => {
type='text' type='text'
placeholder={intl.formatMessage(messages.tileServerLabel)} placeholder={intl.formatMessage(messages.tileServerLabel)}
value={plFe.tileServer} value={plFe.tileServer}
onChange={handleChange(['tileServer'], (e) => e.target.value)} onChange={handleChange('tileServer', (e) => e.target.value)}
/> />
</FormGroup> </FormGroup>
@ -330,7 +348,7 @@ const PlFeConfig: React.FC = () => {
type='text' type='text'
placeholder={intl.formatMessage(messages.tileServerAttributionLabel)} placeholder={intl.formatMessage(messages.tileServerAttributionLabel)}
value={plFe.tileServerAttribution} value={plFe.tileServerAttribution}
onChange={handleChange(['tileServerAttribution'], (e) => e.target.value)} onChange={handleChange('tileServerAttribution', (e) => e.target.value)}
/> />
</FormGroup> </FormGroup>
</> </>
@ -346,7 +364,7 @@ const PlFeConfig: React.FC = () => {
component={CryptoAddressInput} component={CryptoAddressInput}
values={plFe.cryptoAddresses} values={plFe.cryptoAddresses}
onChange={handleStreamItemChange(['cryptoAddresses'])} onChange={handleStreamItemChange(['cryptoAddresses'])}
onAddItem={addStreamItem(['cryptoAddresses'], templates.cryptoAddress)} onAddItem={addStreamItem(['cryptoAddresses'], cryptoAddressSchema)}
onRemoveItem={deleteStreamItem(['cryptoAddresses'])} onRemoveItem={deleteStreamItem(['cryptoAddresses'])}
draggable draggable
/> />
@ -358,7 +376,7 @@ const PlFeConfig: React.FC = () => {
pattern='[0-9]+' pattern='[0-9]+'
placeholder={intl.formatMessage(messages.cryptoDonatePanelLimitLabel)} placeholder={intl.formatMessage(messages.cryptoDonatePanelLimitLabel)}
value={plFe.cryptoDonatePanel.limit} value={plFe.cryptoDonatePanel.limit}
onChange={handleChange(['cryptoDonatePanel', 'limit'], (e) => Number(e.target.value))} onChange={handleChange('cryptoDonatePanel', (e) => ({ limit: Number(e.target.value) }))}
/> />
</FormGroup> </FormGroup>
@ -395,4 +413,4 @@ const PlFeConfig: React.FC = () => {
); );
}; };
export { PlFeConfig as default }; export { PlFeConfigEditor as default };

View file

@ -61,6 +61,8 @@ const ThemeEditor: React.FC<IThemeEditor> = () => {
const fileInput = useRef<HTMLInputElement>(null); const fileInput = useRef<HTMLInputElement>(null);
const updateColors = (key: string) => (newColors: ColorGroup) => { const updateColors = (key: string) => (newColors: ColorGroup) => {
if (typeof colors[key] === 'string') return;
setColors({ setColors({
...colors, ...colors,
[key]: { [key]: {

View file

@ -51,7 +51,7 @@ type PromoPanel = v.InferOutput<typeof promoPanelSchema>;
const footerItemSchema = coerceObject({ const footerItemSchema = coerceObject({
title: v.fallback(v.string(), ''), title: v.fallback(v.string(), ''),
url: v.fallback(v.string(), ''), url: v.fallback(v.string(), ''),
titleLocales: v.fallback(v.record(v.string(), v.string()), {}) titleLocales: v.fallback(v.record(v.string(), v.string()), {}),
}); });
type FooterItem = v.InferOutput<typeof footerItemSchema>; type FooterItem = v.InferOutput<typeof footerItemSchema>;
@ -150,6 +150,9 @@ const plFeConfigSchema = v.pipe(coerceObject({
type PlFeConfig = v.InferOutput<typeof plFeConfigSchema>; type PlFeConfig = v.InferOutput<typeof plFeConfigSchema>;
export { export {
promoPanelItemSchema,
footerItemSchema,
cryptoAddressSchema,
plFeConfigSchema, plFeConfigSchema,
type PromoPanelItem, type PromoPanelItem,
type PromoPanel, type PromoPanel,

View file

@ -10,10 +10,11 @@ import {
} from '../actions/pl-fe'; } from '../actions/pl-fe';
import type { PleromaConfig } from 'pl-api'; import type { PleromaConfig } from 'pl-api';
import type { PlFeConfig } from 'pl-fe/normalizers/pl-fe/pl-fe-config';
const initialState: Record<string, any> = {}; const initialState: Partial<PlFeConfig> = {};
const fallbackState = { const fallbackState: Partial<PlFeConfig> = {
brandColor: '#d80482', brandColor: '#d80482',
}; };
@ -46,12 +47,12 @@ const persistPlFeConfig = (plFeConfig: Record<string, any>, host: string) => {
} }
}; };
const importPlFeConfig = (plFeConfig: Record<string, any>, host: string) => { const importPlFeConfig = (plFeConfig: PlFeConfig, host: string) => {
persistPlFeConfig(plFeConfig, host); persistPlFeConfig(plFeConfig, host);
return plFeConfig; return plFeConfig;
}; };
const plfe = (state = initialState, action: Record<string, any>): Record<string, any> => { const plfe = (state = initialState, action: Record<string, any>): Partial<PlFeConfig> => {
switch (action.type) { switch (action.type) {
case PLEROMA_PRELOAD_IMPORT: case PLEROMA_PRELOAD_IMPORT:
return preloadImport(state, action); return preloadImport(state, action);