nicolium: add pleroma config management
Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
|
||||
import Checkbox from './checkbox';
|
||||
|
||||
@@ -78,7 +78,7 @@ const FormGroup: React.FC<IFormGroup> = (props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='⁂-form-group'>
|
||||
{labelText && (
|
||||
<label
|
||||
htmlFor={formFieldId}
|
||||
@@ -114,4 +114,6 @@ const FormGroup: React.FC<IFormGroup> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export { FormGroup as default };
|
||||
const MemoizedFormGroup = memo(FormGroup);
|
||||
|
||||
export { MemoizedFormGroup as default };
|
||||
|
||||
@@ -195,6 +195,26 @@
|
||||
"admin.links.pending_reports": "{count, plural, one {{formattedCount} pending report} other {{formattedCount} pending reports}}",
|
||||
"admin.links.pending_users": "{count, plural, one {{formattedCount} pending user} other {{formattedCount} pending users}}",
|
||||
"admin.moderation_log.empty_message": "You have not performed any moderation actions yet. When you do, a history will be shown here.",
|
||||
"admin.pleroma_config.complex_hint": "Edit this value as JSON.",
|
||||
"admin.pleroma_config.empty": "No configuration options available.",
|
||||
"admin.pleroma_config.empty_search": "No settings match the current filters.",
|
||||
"admin.pleroma_config.json_invalid": "Invalid JSON",
|
||||
"admin.pleroma_config.need_reboot": "Some configuration changes require a server restart to take effect.",
|
||||
"admin.pleroma_config.no_changes": "No changes yet",
|
||||
"admin.pleroma_config.placeholder_value": "value",
|
||||
"admin.pleroma_config.placeholder_value.kv_key": "key",
|
||||
"admin.pleroma_config.placeholder_value.kv_value": "value",
|
||||
"admin.pleroma_config.placeholder_value.left": "left",
|
||||
"admin.pleroma_config.placeholder_value.right": "right",
|
||||
"admin.pleroma_config.reset": "Reset",
|
||||
"admin.pleroma_config.save": "Save",
|
||||
"admin.pleroma_config.save_failed": "Failed to save configuration",
|
||||
"admin.pleroma_config.saved": "Configuration saved",
|
||||
"admin.pleroma_config.search": "Search settings",
|
||||
"admin.pleroma_config.search_placeholder": "Search by label, key, group or description",
|
||||
"admin.pleroma_config.suggestions": "Suggestions",
|
||||
"admin.pleroma_config.tuple_hint": "Values are parsed as JSON when possible, otherwise kept as strings.",
|
||||
"admin.pleroma_config.value_type": "Value type",
|
||||
"admin.relays.add.fail": "Failed to follow the instance relay",
|
||||
"admin.relays.add.success": "Instance relay followed",
|
||||
"admin.relays.deleted": "Relay unfollowed",
|
||||
@@ -454,6 +474,7 @@
|
||||
"column.admin.edit_domain": "Edit domain",
|
||||
"column.admin.edit_rule": "Edit rule",
|
||||
"column.admin.moderation_log": "Moderation log",
|
||||
"column.admin.pleroma_config": "Pleroma configuration",
|
||||
"column.admin.relays": "Instance relays",
|
||||
"column.admin.reports": "Reports",
|
||||
"column.admin.reports.clear_filter": "Clear filter",
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import React, { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import Accordion from '@/components/ui/accordion';
|
||||
import Form from '@/components/ui/form';
|
||||
import FormActions from '@/components/ui/form-actions';
|
||||
|
||||
import { ConfigValueEditor } from './config-value-editor';
|
||||
import { getDescriptionValue, getDescriptionValueSignature } from './utils';
|
||||
|
||||
import type { PleromaConfigDescription } from 'pl-api';
|
||||
|
||||
interface IConfigGroupEditor {
|
||||
description: PleromaConfigDescription;
|
||||
currentValue: unknown;
|
||||
currentValueSignature: string;
|
||||
onSave: (description: PleromaConfigDescription, value: unknown) => void;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
const ConfigGroupEditor = memo(
|
||||
({
|
||||
description,
|
||||
currentValue,
|
||||
currentValueSignature: _currentValueSignature,
|
||||
onSave,
|
||||
isPending,
|
||||
}: IConfigGroupEditor) => {
|
||||
const [draftValue, setDraftValue] = useState(currentValue);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftValue(currentValue);
|
||||
setHasError(false);
|
||||
}, [currentValue]);
|
||||
|
||||
const isDirty = useMemo(
|
||||
() => JSON.stringify(draftValue) !== JSON.stringify(currentValue),
|
||||
[currentValue, draftValue],
|
||||
);
|
||||
|
||||
if (!description.group) return null;
|
||||
|
||||
return (
|
||||
<Form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onSave(description, draftValue);
|
||||
}}
|
||||
>
|
||||
<fieldset className='⁂-admin-config__fieldset' disabled={isPending}>
|
||||
<ConfigValueEditor
|
||||
node={description}
|
||||
value={draftValue}
|
||||
onChange={setDraftValue}
|
||||
onValidityChange={setHasError}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<FormActions>
|
||||
{!isDirty ? (
|
||||
<p className='⁂-admin-config__feedback'>
|
||||
<FormattedMessage
|
||||
id='admin.pleroma_config.no_changes'
|
||||
defaultMessage='No changes yet'
|
||||
/>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type='button'
|
||||
disabled={isPending || !isDirty}
|
||||
className='⁂-admin-config__reset-button'
|
||||
onClick={() => {
|
||||
setDraftValue(currentValue);
|
||||
setHasError(false);
|
||||
}}
|
||||
>
|
||||
<FormattedMessage id='admin.pleroma_config.reset' defaultMessage='Reset' />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type='submit'
|
||||
disabled={isPending || hasError || !isDirty}
|
||||
className='⁂-admin-config__submit-button'
|
||||
>
|
||||
<FormattedMessage id='admin.pleroma_config.save' defaultMessage='Save' />
|
||||
</button>
|
||||
</FormActions>
|
||||
</Form>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.description === nextProps.description &&
|
||||
prevProps.currentValueSignature === nextProps.currentValueSignature &&
|
||||
prevProps.onSave === nextProps.onSave &&
|
||||
prevProps.isPending === nextProps.isPending,
|
||||
);
|
||||
|
||||
ConfigGroupEditor.displayName = 'ConfigGroupEditor';
|
||||
|
||||
interface IConfigSection {
|
||||
group: string;
|
||||
descriptions: PleromaConfigDescription[];
|
||||
configValueMap: Map<string, unknown>;
|
||||
onSave: (description: PleromaConfigDescription, value: unknown) => void;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
const ConfigSection = memo(
|
||||
({ group, descriptions, configValueMap, onSave, isPending }: IConfigSection) => {
|
||||
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<section className='⁂-admin-config__section'>
|
||||
<div className='⁂-admin-config__section-header'>
|
||||
<h2 className='⁂-admin-config__section-title'>{group}</h2>
|
||||
</div>
|
||||
|
||||
<div className='⁂-admin-config__accordion-list'>
|
||||
{descriptions.map((description) => {
|
||||
const accordionKey =
|
||||
description.key ?? description.label ?? `${group}-${description.type}`;
|
||||
const currentValue = getDescriptionValue(description, configValueMap);
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
key={accordionKey}
|
||||
expanded={expandedKey === accordionKey}
|
||||
onToggle={(open) => setExpandedKey(open ? accordionKey : null)}
|
||||
headline={
|
||||
<div className='⁂-admin-config__accordion-headline'>
|
||||
<p className='⁂-admin-config__headline-title'>
|
||||
{description.label ?? description.key}
|
||||
</p>
|
||||
|
||||
{description.description ? (
|
||||
<p className='⁂-admin-config__meta'>{description.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{expandedKey === accordionKey && (
|
||||
<ConfigGroupEditor
|
||||
description={description}
|
||||
currentValue={currentValue}
|
||||
currentValueSignature={getDescriptionValueSignature(
|
||||
description,
|
||||
configValueMap,
|
||||
)}
|
||||
onSave={onSave}
|
||||
isPending={isPending}
|
||||
/>
|
||||
)}
|
||||
</Accordion>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
if (
|
||||
prevProps.group !== nextProps.group ||
|
||||
prevProps.descriptions !== nextProps.descriptions ||
|
||||
prevProps.onSave !== nextProps.onSave ||
|
||||
prevProps.isPending !== nextProps.isPending
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return prevProps.descriptions.every((description) => {
|
||||
return (
|
||||
getDescriptionValueSignature(description, prevProps.configValueMap) ===
|
||||
getDescriptionValueSignature(description, nextProps.configValueMap)
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
ConfigSection.displayName = 'ConfigSection';
|
||||
|
||||
export { ConfigSection };
|
||||
@@ -0,0 +1,770 @@
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import React, { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import FormGroup from '@/components/ui/form-group';
|
||||
import Input from '@/components/ui/input';
|
||||
import Select from '@/components/ui/select';
|
||||
import Streamfield from '@/components/ui/streamfield';
|
||||
import Textarea from '@/components/ui/textarea';
|
||||
import Toggle from '@/components/ui/toggle';
|
||||
|
||||
import {
|
||||
createDynamicEntry,
|
||||
createTupleEntry,
|
||||
getDefaultValueForType,
|
||||
getDynamicEntriesValue,
|
||||
getPrimitiveTypeName,
|
||||
getSuggestionValues,
|
||||
getTupleSuggestions,
|
||||
getTypeLabel,
|
||||
getUnionOptions,
|
||||
isContainerDescriptor,
|
||||
isStringOrImageType,
|
||||
isValueCompatibleWithType,
|
||||
mapToTupleArray,
|
||||
normalizeDynamicEntries,
|
||||
normalizePrimitiveListEntries,
|
||||
normalizeTupleEntries,
|
||||
numericTypes,
|
||||
parsePrimitiveListEntries,
|
||||
parseLooseValue,
|
||||
stringifyValue,
|
||||
textualTypes,
|
||||
tupleArrayToMap,
|
||||
} from './utils';
|
||||
|
||||
import type { ConfigDescriptionNode, ConfigType, DynamicEntry, TupleEntry } from './utils';
|
||||
import type { StreamfieldComponent } from '@/components/ui/streamfield';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholderValue: {
|
||||
id: 'admin.pleroma_config.placeholder_value',
|
||||
defaultMessage: 'value',
|
||||
},
|
||||
placeholderValueLeft: {
|
||||
id: 'admin.pleroma_config.placeholder_value.left',
|
||||
defaultMessage: 'left',
|
||||
},
|
||||
placeholderValueRight: {
|
||||
id: 'admin.pleroma_config.placeholder_value.right',
|
||||
defaultMessage: 'right',
|
||||
},
|
||||
placeholderValueKVKey: {
|
||||
id: 'admin.pleroma_config.placeholder_value.kv_key',
|
||||
defaultMessage: 'key',
|
||||
},
|
||||
placeholderValueKVValue: {
|
||||
id: 'admin.pleroma_config.placeholder_value.kv_value',
|
||||
defaultMessage: 'value',
|
||||
},
|
||||
});
|
||||
|
||||
interface ISuggestions {
|
||||
suggestions: unknown[];
|
||||
onSelect: (value: unknown) => void;
|
||||
}
|
||||
|
||||
interface IConfigValueEditor {
|
||||
node: ConfigDescriptionNode;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
onValidityChange?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const Suggestions = memo(({ suggestions, onSelect }: ISuggestions) => {
|
||||
if (!suggestions.length) return null;
|
||||
|
||||
return (
|
||||
<div className='⁂-admin-config__suggestions'>
|
||||
<p className='⁂-admin-config__suggestions-label'>
|
||||
<FormattedMessage id='admin.pleroma_config.suggestions' defaultMessage='Suggestions' />
|
||||
</p>
|
||||
{suggestions.map((suggestion) => (
|
||||
<button
|
||||
key={`${typeof suggestion}-${stringifyValue(suggestion)}`}
|
||||
type='button'
|
||||
className='⁂-admin-config__suggestion-button'
|
||||
onClick={() => onSelect(suggestion)}
|
||||
>
|
||||
{typeof suggestion === 'string' ? suggestion : stringifyValue(suggestion)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Suggestions.displayName = 'Suggestions';
|
||||
|
||||
const PrimitiveValueEditor = memo(({ node, value, onChange }: IConfigValueEditor) => {
|
||||
const typeName = getPrimitiveTypeName(node.type) ?? 'string';
|
||||
const suggestions = getSuggestionValues(node);
|
||||
const [textValue, setTextValue] = useState(
|
||||
typeof value === 'string' || typeof value === 'number' ? String(value) : '',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeName === 'boolean') return;
|
||||
setTextValue(typeof value === 'string' || typeof value === 'number' ? String(value) : '');
|
||||
}, [typeName, value]);
|
||||
|
||||
if (typeName === 'boolean') {
|
||||
return (
|
||||
<div className='⁂-admin-config__toggle-field'>
|
||||
<Toggle checked={value === true} onChange={(event) => onChange(event.target.checked)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (numericTypes.has(typeName)) {
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
type='number'
|
||||
value={textValue}
|
||||
onChange={(event) => {
|
||||
setTextValue(event.target.value);
|
||||
|
||||
if (!event.target.value.length) {
|
||||
onChange(getDefaultValueForType(typeName));
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = Number(event.target.value);
|
||||
if (!Number.isNaN(parsed)) onChange(parsed);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Suggestions
|
||||
suggestions={suggestions}
|
||||
onSelect={(suggestion) => {
|
||||
const nextValue =
|
||||
typeof suggestion === 'number' ? suggestion : Number.parseFloat(String(suggestion));
|
||||
if (Number.isNaN(nextValue)) return;
|
||||
|
||||
setTextValue(String(nextValue));
|
||||
onChange(nextValue);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
type='text'
|
||||
value={textValue}
|
||||
placeholder={typeName === 'atom' ? ':value' : undefined}
|
||||
onChange={(event) => {
|
||||
setTextValue(event.target.value);
|
||||
onChange(event.target.value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Suggestions
|
||||
suggestions={suggestions}
|
||||
onSelect={(suggestion) => {
|
||||
const nextValue = String(suggestion);
|
||||
setTextValue(nextValue);
|
||||
onChange(nextValue);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
PrimitiveValueEditor.displayName = 'PrimitiveValueEditor';
|
||||
|
||||
const JsonValueEditor = memo(({ value, onChange, onValidityChange }: IConfigValueEditor) => {
|
||||
const [jsonText, setJsonText] = useState(stringifyValue(value));
|
||||
const [jsonError, setJsonError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setJsonText(stringifyValue(value));
|
||||
setJsonError(false);
|
||||
onValidityChange?.(false);
|
||||
}, [onValidityChange, value]);
|
||||
|
||||
return (
|
||||
<div className='⁂-admin-config__editor-stack'>
|
||||
<Textarea
|
||||
isCodeEditor
|
||||
value={jsonText}
|
||||
hasError={jsonError}
|
||||
onChange={(event) => {
|
||||
const nextText = event.target.value;
|
||||
setJsonText(nextText);
|
||||
|
||||
if (!nextText.trim().length) {
|
||||
setJsonError(false);
|
||||
onValidityChange?.(false);
|
||||
onChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
onChange(JSON.parse(nextText));
|
||||
setJsonError(false);
|
||||
onValidityChange?.(false);
|
||||
} catch {
|
||||
setJsonError(true);
|
||||
onValidityChange?.(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{jsonError ? (
|
||||
<p className='⁂-admin-config__feedback ⁂-admin-config__feedback--danger'>
|
||||
<FormattedMessage id='admin.pleroma_config.json_invalid' defaultMessage='Invalid JSON' />
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
JsonValueEditor.displayName = 'JsonValueEditor';
|
||||
|
||||
const PrimitiveListStreamfieldInput: StreamfieldComponent<string> = memo(({ value, onChange }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<Input
|
||||
type='text'
|
||||
outerClassName='⁂-admin-config__streamfield-row__input'
|
||||
value={value}
|
||||
placeholder={intl.formatMessage(messages.placeholderValue)}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
PrimitiveListStreamfieldInput.displayName = 'PrimitiveListStreamfieldInput';
|
||||
|
||||
const PrimitiveListEditor = memo(
|
||||
({ node, itemType, value, onChange }: IConfigValueEditor & { itemType: ConfigType }) => {
|
||||
const suggestions = getSuggestionValues(node);
|
||||
const typeName = typeof itemType === 'string' ? itemType : 'string';
|
||||
const [entries, setEntries] = useState<string[]>(() => normalizePrimitiveListEntries(value));
|
||||
|
||||
useEffect(() => {
|
||||
if (isEqual(parsePrimitiveListEntries(entries, typeName), value)) return;
|
||||
|
||||
setEntries(normalizePrimitiveListEntries(value));
|
||||
}, [typeName, value]);
|
||||
|
||||
const syncEntries = (nextEntries: string[]) => {
|
||||
setEntries(nextEntries);
|
||||
|
||||
const nextValue = parsePrimitiveListEntries(nextEntries, typeName);
|
||||
if (isEqual(nextValue, value)) return;
|
||||
|
||||
onChange(nextValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='⁂-admin-config__editor-stack'>
|
||||
<Streamfield
|
||||
values={entries}
|
||||
onChange={syncEntries}
|
||||
onAddItem={() => syncEntries([...entries, ''])}
|
||||
onRemoveItem={(index) =>
|
||||
syncEntries(entries.filter((_, currentIndex) => currentIndex !== index))
|
||||
}
|
||||
component={PrimitiveListStreamfieldInput}
|
||||
/>
|
||||
|
||||
<Suggestions
|
||||
suggestions={suggestions}
|
||||
onSelect={(suggestion) => {
|
||||
syncEntries([...entries, stringifyValue(suggestion)]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
PrimitiveListEditor.displayName = 'PrimitiveListEditor';
|
||||
|
||||
const DropdownValueEditor = memo(({ node, value, onChange }: IConfigValueEditor) => {
|
||||
const suggestions = getSuggestionValues(node).map(String);
|
||||
const currentValue = typeof value === 'string' ? value : String(value ?? suggestions[0] ?? '');
|
||||
const options = suggestions.includes(currentValue) ? suggestions : [currentValue, ...suggestions];
|
||||
|
||||
return (
|
||||
<Select value={currentValue} onChange={(event) => onChange(event.target.value)}>
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
});
|
||||
|
||||
DropdownValueEditor.displayName = 'DropdownValueEditor';
|
||||
|
||||
const TupleStreamfieldInput: StreamfieldComponent<TupleEntry> = memo(({ value, onChange }) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<div className='⁂-admin-config__streamfield-row'>
|
||||
<Input
|
||||
type='text'
|
||||
outerClassName='⁂-admin-config__streamfield-row__input'
|
||||
value={value.left}
|
||||
placeholder={intl.formatMessage(messages.placeholderValueLeft)}
|
||||
onChange={(event) => onChange({ ...value, left: event.target.value })}
|
||||
/>
|
||||
<Input
|
||||
type='text'
|
||||
outerClassName='⁂-admin-config__streamfield-row__input'
|
||||
value={value.right}
|
||||
placeholder={intl.formatMessage(messages.placeholderValueRight)}
|
||||
onChange={(event) => onChange({ ...value, right: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
TupleStreamfieldInput.displayName = 'TupleStreamfieldInput';
|
||||
|
||||
const DynamicStreamfieldInput: StreamfieldComponent<DynamicEntry> = memo(({ value, onChange }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div className='⁂-admin-config__streamfield-row ⁂-admin-config__streamfield-row--dynamic'>
|
||||
<Input
|
||||
type='text'
|
||||
outerClassName='⁂-admin-config__streamfield-row__input'
|
||||
value={value.key}
|
||||
placeholder={intl.formatMessage(messages.placeholderValueKVKey)}
|
||||
onChange={(event) => onChange({ ...value, key: event.target.value })}
|
||||
/>
|
||||
<Textarea
|
||||
isCodeEditor
|
||||
rows={1}
|
||||
autoGrow
|
||||
value={value.value}
|
||||
placeholder={intl.formatMessage(messages.placeholderValueKVValue)}
|
||||
onChange={(event) => onChange({ ...value, value: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
DynamicStreamfieldInput.displayName = 'DynamicStreamfieldInput';
|
||||
|
||||
const TupleEditor = memo(
|
||||
({ node, value, onChange, isList }: IConfigValueEditor & { isList: boolean }) => {
|
||||
const [entries, setEntries] = useState<TupleEntry[]>(() =>
|
||||
normalizeTupleEntries(value, isList),
|
||||
);
|
||||
const suggestions = getTupleSuggestions(node);
|
||||
|
||||
useEffect(() => {
|
||||
setEntries(normalizeTupleEntries(value, isList));
|
||||
}, [isList, value]);
|
||||
|
||||
const syncEntries = (nextEntries: TupleEntry[]) => {
|
||||
setEntries(nextEntries);
|
||||
|
||||
const tuples = nextEntries.map((entry) => ({
|
||||
tuple: [parseLooseValue(entry.left), parseLooseValue(entry.right)] as [unknown, unknown],
|
||||
}));
|
||||
|
||||
onChange(isList ? tuples : (tuples[0] ?? { tuple: ['', ''] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='⁂-admin-config__editor-stack'>
|
||||
<p className='⁂-admin-config__feedback'>
|
||||
<FormattedMessage
|
||||
id='admin.pleroma_config.tuple_hint'
|
||||
defaultMessage='Values are parsed as JSON when possible, otherwise kept as strings.'
|
||||
/>
|
||||
</p>
|
||||
|
||||
<Streamfield
|
||||
values={entries}
|
||||
onChange={syncEntries}
|
||||
onAddItem={() => syncEntries([...entries, createTupleEntry()])}
|
||||
onRemoveItem={(index) => {
|
||||
const nextEntries = entries.filter((_, currentIndex) => currentIndex !== index);
|
||||
syncEntries(nextEntries.length ? nextEntries : [createTupleEntry()]);
|
||||
}}
|
||||
component={TupleStreamfieldInput}
|
||||
/>
|
||||
|
||||
<Suggestions
|
||||
suggestions={suggestions}
|
||||
onSelect={(suggestion) => {
|
||||
if (!Array.isArray(suggestion) || suggestion.length !== 2) return;
|
||||
|
||||
const nextEntry = createTupleEntry(
|
||||
stringifyValue(suggestion[0]),
|
||||
stringifyValue(suggestion[1]),
|
||||
);
|
||||
syncEntries(isList ? [...entries, nextEntry] : [nextEntry]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
TupleEditor.displayName = 'TupleEditor';
|
||||
|
||||
const DynamicEntriesEditor = memo(
|
||||
({ node, value, onChange, isKeyword }: IConfigValueEditor & { isKeyword: boolean }) => {
|
||||
const [entries, setEntries] = useState<DynamicEntry[]>(() =>
|
||||
normalizeDynamicEntries(value, isKeyword),
|
||||
);
|
||||
const suggestions = getSuggestionValues(node);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEqual(getDynamicEntriesValue(entries, isKeyword), value)) return;
|
||||
|
||||
setEntries(normalizeDynamicEntries(value, isKeyword));
|
||||
}, [isKeyword, value]);
|
||||
|
||||
const syncEntries = (nextEntries: DynamicEntry[]) => {
|
||||
setEntries(nextEntries);
|
||||
const nextValue = getDynamicEntriesValue(nextEntries, isKeyword);
|
||||
if (isEqual(nextValue, value)) return;
|
||||
|
||||
onChange(nextValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='⁂-admin-config__editor-stack'>
|
||||
<Streamfield
|
||||
values={entries}
|
||||
onChange={syncEntries}
|
||||
onAddItem={() => syncEntries([...entries, createDynamicEntry()])}
|
||||
onRemoveItem={(index) => {
|
||||
const nextEntries = entries.filter((_, currentIndex) => currentIndex !== index);
|
||||
syncEntries(nextEntries.length ? nextEntries : [createDynamicEntry()]);
|
||||
}}
|
||||
component={DynamicStreamfieldInput}
|
||||
/>
|
||||
|
||||
<Suggestions
|
||||
suggestions={suggestions}
|
||||
onSelect={(suggestion) => {
|
||||
if (!Array.isArray(suggestion) || suggestion.length !== 2) return;
|
||||
|
||||
syncEntries([
|
||||
...entries,
|
||||
createDynamicEntry(String(suggestion[0]), stringifyValue(suggestion[1])),
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
DynamicEntriesEditor.displayName = 'DynamicEntriesEditor';
|
||||
|
||||
const GroupChildrenEditor = memo(
|
||||
({ node, value, onChange, onValidityChange }: IConfigValueEditor) => {
|
||||
const childValues = useMemo(() => tupleArrayToMap(value), [value]);
|
||||
const [invalidChildren, setInvalidChildren] = useState<Record<string, boolean>>({});
|
||||
const hasInvalidChildren = useMemo(
|
||||
() => Object.values(invalidChildren).some(Boolean),
|
||||
[invalidChildren],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setInvalidChildren({});
|
||||
}, [node, value]);
|
||||
|
||||
useEffect(() => {
|
||||
onValidityChange?.(hasInvalidChildren);
|
||||
}, [hasInvalidChildren, onValidityChange]);
|
||||
|
||||
if (!node.children?.length) {
|
||||
return (
|
||||
<JsonValueEditor
|
||||
node={node}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onValidityChange={onValidityChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='⁂-admin-config__editor-stack'>
|
||||
{node.children.map((child) => {
|
||||
if (!child.key) return null;
|
||||
const childKey = child.key;
|
||||
|
||||
return (
|
||||
<FormGroup
|
||||
key={[child.group].flat().join('|') + '|' + childKey}
|
||||
labelText={child.label ?? childKey}
|
||||
hintText={child.description}
|
||||
>
|
||||
<ConfigValueEditor
|
||||
node={child}
|
||||
value={childValues[childKey]}
|
||||
onChange={(nextValue) =>
|
||||
onChange(
|
||||
mapToTupleArray({
|
||||
...childValues,
|
||||
[childKey]: nextValue,
|
||||
}),
|
||||
)
|
||||
}
|
||||
onValidityChange={(isInvalid) =>
|
||||
setInvalidChildren((current) =>
|
||||
current[childKey] === isInvalid
|
||||
? current
|
||||
: { ...current, [childKey]: isInvalid },
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
GroupChildrenEditor.displayName = 'GroupChildrenEditor';
|
||||
|
||||
const UnionValueEditor = memo(
|
||||
({
|
||||
node,
|
||||
value,
|
||||
onChange,
|
||||
onValidityChange,
|
||||
options,
|
||||
}: IConfigValueEditor & { options: ConfigType[] }) => {
|
||||
const intl = useIntl();
|
||||
const [selectedType, setSelectedType] = useState<ConfigType>(() => {
|
||||
return options.find((option) => isValueCompatibleWithType(value, option)) ?? options[0];
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedType(
|
||||
options.find((option) => isValueCompatibleWithType(value, option)) ?? options[0],
|
||||
);
|
||||
}, [options, value]);
|
||||
|
||||
return (
|
||||
<div className='⁂-admin-config__editor-stack'>
|
||||
<FormGroup
|
||||
labelText={intl.formatMessage({
|
||||
id: 'admin.pleroma_config.value_type',
|
||||
defaultMessage: 'Value type',
|
||||
})}
|
||||
>
|
||||
<Select
|
||||
value={JSON.stringify(selectedType)}
|
||||
onChange={(event) => {
|
||||
const nextType =
|
||||
options.find((option) => JSON.stringify(option) === event.target.value) ??
|
||||
options[0];
|
||||
|
||||
setSelectedType(nextType);
|
||||
if (!isValueCompatibleWithType(value, nextType)) {
|
||||
onChange(getDefaultValueForType(nextType, getSuggestionValues(node)));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={JSON.stringify(option)} value={JSON.stringify(option)}>
|
||||
{getTypeLabel(option)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</FormGroup>
|
||||
|
||||
<ConfigValueEditor
|
||||
node={{ ...node, type: selectedType }}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onValidityChange={onValidityChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
UnionValueEditor.displayName = 'UnionValueEditor';
|
||||
|
||||
const ConfigValueEditor = memo(
|
||||
({ node, value, onChange, onValidityChange }: IConfigValueEditor) => {
|
||||
const unionOptions = getUnionOptions(node.type);
|
||||
if (unionOptions) {
|
||||
return (
|
||||
<UnionValueEditor
|
||||
node={node}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onValidityChange={onValidityChange}
|
||||
options={unionOptions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (node.type === 'group') {
|
||||
return (
|
||||
<GroupChildrenEditor
|
||||
node={node}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onValidityChange={onValidityChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof node.type === 'string' || isStringOrImageType(node.type)) {
|
||||
if (node.type === 'tuple') {
|
||||
return (
|
||||
<TupleEditor
|
||||
node={node}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onValidityChange={onValidityChange}
|
||||
isList={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (node.type === 'keyword') {
|
||||
return (
|
||||
<DynamicEntriesEditor
|
||||
node={node}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onValidityChange={onValidityChange}
|
||||
isKeyword
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (node.type === 'map') {
|
||||
return (
|
||||
<DynamicEntriesEditor
|
||||
node={node}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onValidityChange={onValidityChange}
|
||||
isKeyword={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PrimitiveValueEditor
|
||||
node={node}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onValidityChange={onValidityChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isContainerDescriptor(node.type)) {
|
||||
return (
|
||||
<div className='⁂-admin-config__editor-stack'>
|
||||
<p className='⁂-admin-config__feedback'>
|
||||
<FormattedMessage
|
||||
id='admin.pleroma_config.complex_hint'
|
||||
defaultMessage='Edit this value as JSON.'
|
||||
/>
|
||||
</p>
|
||||
<JsonValueEditor
|
||||
node={node}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onValidityChange={onValidityChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const [container, subType] = node.type;
|
||||
if (container === 'dropdown') {
|
||||
return (
|
||||
<DropdownValueEditor
|
||||
node={node}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onValidityChange={onValidityChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (container === 'list') {
|
||||
if (subType === 'tuple') {
|
||||
return (
|
||||
<TupleEditor
|
||||
node={node}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onValidityChange={onValidityChange}
|
||||
isList
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof subType === 'string' &&
|
||||
(textualTypes.has(subType) || numericTypes.has(subType) || subType === 'boolean')
|
||||
) {
|
||||
return (
|
||||
<PrimitiveListEditor
|
||||
node={node}
|
||||
itemType={subType}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onValidityChange={onValidityChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<JsonValueEditor
|
||||
node={node}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onValidityChange={onValidityChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (container === 'keyword') {
|
||||
return (
|
||||
<DynamicEntriesEditor
|
||||
node={node}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onValidityChange={onValidityChange}
|
||||
isKeyword
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DynamicEntriesEditor
|
||||
node={node}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onValidityChange={onValidityChange}
|
||||
isKeyword={false}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ConfigValueEditor.displayName = 'ConfigValueEditor';
|
||||
|
||||
export { ConfigValueEditor };
|
||||
|
||||
export type { IConfigValueEditor };
|
||||
@@ -0,0 +1,431 @@
|
||||
import type { PleromaConfigDescription, PleromaConfigDescriptionChild } from 'pl-api';
|
||||
|
||||
type ConfigDescriptionNode = PleromaConfigDescription | PleromaConfigDescriptionChild;
|
||||
type ConfigType = ConfigDescriptionNode['type'];
|
||||
type PrimitiveValue = boolean | number | string;
|
||||
type TupleValue = { tuple: [unknown, unknown] };
|
||||
type TupleEntry = { id: string; left: string; right: string };
|
||||
type DynamicEntry = { id: string; key: string; value: string };
|
||||
type ConfigEntry = { group: string; key: string; value: unknown };
|
||||
|
||||
const containerTypes = new Set(['list', 'dropdown', 'keyword', 'map']);
|
||||
const textualTypes = new Set(['string', 'atom', 'module', 'charlist', 'image', 'image/png']);
|
||||
const numericTypes = new Set(['integer', 'float']);
|
||||
const annotationTypes = new Set(['image', 'image/png']);
|
||||
|
||||
const stringifyValue = (value: unknown): string => {
|
||||
if (typeof value === 'undefined') return '';
|
||||
if (typeof value === 'string') return value;
|
||||
|
||||
const json = JSON.stringify(value, null, 2);
|
||||
return json ?? '';
|
||||
};
|
||||
|
||||
const isTupleObject = (value: unknown): value is TupleValue =>
|
||||
!!value &&
|
||||
typeof value === 'object' &&
|
||||
'tuple' in value &&
|
||||
Array.isArray((value as { tuple?: unknown }).tuple) &&
|
||||
(value as { tuple: unknown[] }).tuple.length === 2;
|
||||
|
||||
const tupleArrayToMap = (value: unknown): Record<string, unknown> => {
|
||||
if (!Array.isArray(value)) return {};
|
||||
|
||||
return value.reduce<Record<string, unknown>>((result, item) => {
|
||||
if (isTupleObject(item) && typeof item.tuple[0] === 'string') {
|
||||
result[item.tuple[0]] = item.tuple[1];
|
||||
}
|
||||
|
||||
return result;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const mapToTupleArray = (value: Record<string, unknown>): TupleValue[] =>
|
||||
Object.entries(value).map(([key, itemValue]) => ({
|
||||
tuple: [key, itemValue],
|
||||
}));
|
||||
|
||||
const parseLooseValue = (text: string): unknown => {
|
||||
const trimmed = text.trim();
|
||||
|
||||
if (!trimmed.length) return '';
|
||||
if (trimmed === 'true') return true;
|
||||
if (trimmed === 'false') return false;
|
||||
if (trimmed === 'null') return null;
|
||||
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
|
||||
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
const isContainerDescriptor = (type: ConfigType): type is Array<string | Array<string>> => {
|
||||
if (!Array.isArray(type)) return false;
|
||||
if (type.length !== 2) return false;
|
||||
|
||||
return typeof type[0] === 'string' && containerTypes.has(type[0]);
|
||||
};
|
||||
|
||||
const getUnionOptions = (type: ConfigType): ConfigType[] | null => {
|
||||
if (!Array.isArray(type) || !type.length) return null;
|
||||
if (isContainerDescriptor(type)) return null;
|
||||
|
||||
const options = [...type].filter(
|
||||
(item) => typeof item === 'string' || Array.isArray(item),
|
||||
) as ConfigType[];
|
||||
if (options.length <= 1) return null;
|
||||
|
||||
if (
|
||||
options.length === 2 &&
|
||||
options.some((item) => item === 'string') &&
|
||||
options.some((item) => typeof item === 'string' && annotationTypes.has(item))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
const getPrimitiveTypeName = (type: ConfigType): string | null => {
|
||||
if (typeof type === 'string') return type;
|
||||
|
||||
if (
|
||||
Array.isArray(type) &&
|
||||
type.length === 2 &&
|
||||
type[0] === 'dropdown' &&
|
||||
typeof type[1] === 'string'
|
||||
) {
|
||||
return type[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const isValueCompatibleWithType = (value: unknown, type: ConfigType): boolean => {
|
||||
const unionOptions = getUnionOptions(type);
|
||||
if (unionOptions) {
|
||||
return unionOptions.some((option) => isValueCompatibleWithType(value, option));
|
||||
}
|
||||
|
||||
if (typeof type === 'string') {
|
||||
if (type === 'boolean') return typeof value === 'boolean';
|
||||
if (numericTypes.has(type)) return typeof value === 'number';
|
||||
if (textualTypes.has(type)) return typeof value === 'string';
|
||||
if (type === 'tuple') return isTupleObject(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isContainerDescriptor(type)) return true;
|
||||
|
||||
const [container, subType] = type;
|
||||
if (container === 'dropdown') return isValueCompatibleWithType(value, subType);
|
||||
if (container === 'list') return Array.isArray(value);
|
||||
if (container === 'keyword')
|
||||
return Array.isArray(value) && value.every((item) => isTupleObject(item));
|
||||
if (container === 'map')
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const isStringOrImageType: (type: Exclude<ConfigType, string>) => boolean = (type) => {
|
||||
return type.length === 2 && type[0] === 'string' && type[1] === 'image';
|
||||
};
|
||||
|
||||
const getSuggestionValues = (node: ConfigDescriptionNode): unknown[] => {
|
||||
const suggestions = (node as Record<string, unknown>).suggestions;
|
||||
return Array.isArray(suggestions) ? suggestions : [];
|
||||
};
|
||||
|
||||
const getDefaultValueForType = (type: ConfigType, suggestions: unknown[] = []): unknown => {
|
||||
const unionOptions = getUnionOptions(type);
|
||||
if (unionOptions) {
|
||||
return getDefaultValueForType(unionOptions[0], suggestions);
|
||||
}
|
||||
|
||||
if (typeof type === 'string') {
|
||||
if (type === 'boolean') return false;
|
||||
if (numericTypes.has(type)) return 0;
|
||||
if (textualTypes.has(type)) return typeof suggestions[0] === 'string' ? suggestions[0] : '';
|
||||
if (type === 'tuple') return { tuple: ['', ''] };
|
||||
return typeof suggestions[0] !== 'undefined' ? suggestions[0] : null;
|
||||
}
|
||||
|
||||
if (!isContainerDescriptor(type)) return null;
|
||||
|
||||
const [container, subType] = type;
|
||||
if (container === 'dropdown') {
|
||||
return typeof suggestions[0] !== 'undefined'
|
||||
? suggestions[0]
|
||||
: getDefaultValueForType(subType, suggestions);
|
||||
}
|
||||
|
||||
if (container === 'list' || container === 'keyword') return [];
|
||||
if (container === 'map') return {};
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getTypeLabel = (type: ConfigType): string => {
|
||||
const unionOptions = getUnionOptions(type);
|
||||
if (unionOptions) return unionOptions.map(getTypeLabel).join(' / ');
|
||||
|
||||
if (typeof type === 'string') {
|
||||
switch (type) {
|
||||
case 'image':
|
||||
case 'image/png':
|
||||
return 'image path';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isContainerDescriptor(type)) return JSON.stringify(type);
|
||||
|
||||
const [container, subType] = type;
|
||||
if (container === 'dropdown') return `dropdown (${getTypeLabel(subType)})`;
|
||||
if (container === 'list') return `list of ${getTypeLabel(subType)}`;
|
||||
if (container === 'keyword') return `keyword of ${getTypeLabel(subType)}`;
|
||||
if (container === 'map') return `map of ${getTypeLabel(subType)}`;
|
||||
|
||||
return JSON.stringify(type);
|
||||
};
|
||||
|
||||
const getValueSignature = (value: unknown): string => stringifyValue(value);
|
||||
|
||||
const getTupleSuggestions = (node: ConfigDescriptionNode): Array<[unknown, unknown]> =>
|
||||
getSuggestionValues(node)
|
||||
.map((value) => {
|
||||
if (Array.isArray(value) && value.length === 2)
|
||||
return [value[0], value[1]] as [unknown, unknown];
|
||||
if (isTupleObject(value)) return value.tuple;
|
||||
return null;
|
||||
})
|
||||
.filter((value): value is [unknown, unknown] => value !== null);
|
||||
|
||||
const createTupleEntry = (left = '', right = ''): TupleEntry => ({
|
||||
id: crypto.randomUUID(),
|
||||
left,
|
||||
right,
|
||||
});
|
||||
|
||||
const createDynamicEntry = (key = '', value = ''): DynamicEntry => ({
|
||||
id: crypto.randomUUID(),
|
||||
key,
|
||||
value,
|
||||
});
|
||||
|
||||
const normalizeTupleEntries = (value: unknown, isList: boolean): TupleEntry[] => {
|
||||
console.log('Normalizing tuple entries', { value, isList });
|
||||
if (isList) {
|
||||
if (!Array.isArray(value)) return [createTupleEntry()];
|
||||
|
||||
const entries = value
|
||||
.map((item) => {
|
||||
if (!isTupleObject(item)) return null;
|
||||
return createTupleEntry(stringifyValue(item.tuple[0]), stringifyValue(item.tuple[1]));
|
||||
})
|
||||
.filter((item): item is TupleEntry => item !== null);
|
||||
|
||||
return entries.length ? entries : [createTupleEntry()];
|
||||
}
|
||||
|
||||
if (isTupleObject(value)) {
|
||||
return [createTupleEntry(stringifyValue(value.tuple[0]), stringifyValue(value.tuple[1]))];
|
||||
}
|
||||
|
||||
return [createTupleEntry()];
|
||||
};
|
||||
|
||||
const normalizeDynamicEntries = (value: unknown, isKeyword: boolean): DynamicEntry[] => {
|
||||
console.log('Normalizing dynamic entries', { value, isKeyword });
|
||||
if (isKeyword) {
|
||||
if (!Array.isArray(value)) return [createDynamicEntry()];
|
||||
|
||||
const entries = value
|
||||
.map((item) => {
|
||||
if (!isTupleObject(item)) return null;
|
||||
return createDynamicEntry(stringifyValue(item.tuple[0]), stringifyValue(item.tuple[1]));
|
||||
})
|
||||
.filter((item): item is DynamicEntry => item !== null);
|
||||
|
||||
return entries.length ? entries : [createDynamicEntry()];
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return [createDynamicEntry()];
|
||||
}
|
||||
|
||||
const entries = Object.entries(value).map(([key, itemValue]) =>
|
||||
createDynamicEntry(key, stringifyValue(itemValue)),
|
||||
);
|
||||
|
||||
return entries.length ? entries : [createDynamicEntry()];
|
||||
};
|
||||
|
||||
const normalizePrimitiveListEntries = (value: unknown): string[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((item) => stringifyValue(item));
|
||||
};
|
||||
|
||||
const parsePrimitiveListEntries = (entries: string[], typeName: string): PrimitiveValue[] => {
|
||||
return entries
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.map((entry): PrimitiveValue => {
|
||||
if (numericTypes.has(typeName)) return Number(entry);
|
||||
if (typeName === 'boolean') return entry === 'true';
|
||||
return entry;
|
||||
});
|
||||
};
|
||||
|
||||
const getDynamicEntriesValue = (entries: DynamicEntry[], isKeyword: boolean): unknown => {
|
||||
const activeEntries = entries.filter((entry) => entry.key.trim().length);
|
||||
|
||||
if (isKeyword) {
|
||||
return activeEntries.map((entry) => ({
|
||||
tuple: [entry.key, parseLooseValue(entry.value)] as [unknown, unknown],
|
||||
}));
|
||||
}
|
||||
|
||||
return activeEntries.reduce<Record<string, unknown>>((result, entry) => {
|
||||
result[entry.key] = parseLooseValue(entry.value);
|
||||
return result;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const getConfigEntryId = (group: string, key: string): string => `${group}\u0000${key}`;
|
||||
|
||||
const getConfigValueMap = (configs: ConfigEntry[]): Map<string, unknown> => {
|
||||
return new Map(configs.map((entry) => [getConfigEntryId(entry.group, entry.key), entry.value]));
|
||||
};
|
||||
|
||||
const getNodeGroup = (group: ConfigDescriptionNode['group']): string | null => {
|
||||
if (typeof group === 'string') return group;
|
||||
if (Array.isArray(group)) return group[0] ?? null;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getFlatGroupValue = (
|
||||
description: PleromaConfigDescription,
|
||||
configValueMap: Map<string, unknown>,
|
||||
): TupleValue[] => {
|
||||
const childValues = description.children.reduce<Record<string, unknown>>((result, child) => {
|
||||
if (!child.key) return result;
|
||||
|
||||
const childGroup = getNodeGroup(child.group) ?? description.group;
|
||||
if (!childGroup) return result;
|
||||
|
||||
const childValue = configValueMap.get(getConfigEntryId(childGroup, child.key));
|
||||
if (typeof childValue !== 'undefined') {
|
||||
result[child.key] = childValue;
|
||||
}
|
||||
|
||||
return result;
|
||||
}, {});
|
||||
|
||||
return mapToTupleArray(childValues);
|
||||
};
|
||||
|
||||
const getDescriptionValue = (
|
||||
description: PleromaConfigDescription,
|
||||
configValueMap: Map<string, unknown>,
|
||||
): unknown => {
|
||||
if (!description.group) return undefined;
|
||||
|
||||
if (description.key) {
|
||||
return configValueMap.get(getConfigEntryId(description.group, description.key));
|
||||
}
|
||||
|
||||
if (description.type === 'group') {
|
||||
return getFlatGroupValue(description, configValueMap);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getDescriptionValueSignature = (
|
||||
description: PleromaConfigDescription,
|
||||
configValueMap: Map<string, unknown>,
|
||||
): string => getValueSignature(getDescriptionValue(description, configValueMap));
|
||||
|
||||
const getDescriptionUpdates = (
|
||||
description: PleromaConfigDescription,
|
||||
value: unknown,
|
||||
): ConfigEntry[] => {
|
||||
if (!description.group) return [];
|
||||
|
||||
if (description.key) {
|
||||
return [{ group: description.group, key: description.key, value }];
|
||||
}
|
||||
|
||||
if (description.type !== 'group') return [];
|
||||
|
||||
const childValues = tupleArrayToMap(value);
|
||||
|
||||
return description.children.flatMap((child) => {
|
||||
if (!child.key || !Object.hasOwn(childValues, child.key)) return [];
|
||||
|
||||
const childGroup = getNodeGroup(child.group) ?? description.group;
|
||||
if (!childGroup) return [];
|
||||
|
||||
return [{ group: childGroup, key: child.key, value: childValues[child.key] }];
|
||||
});
|
||||
};
|
||||
|
||||
const getDescriptionSearchText = (node: ConfigDescriptionNode): string => {
|
||||
return [
|
||||
getNodeGroup(node.group),
|
||||
node.key,
|
||||
node.label,
|
||||
node.description,
|
||||
...(node.children?.map(getDescriptionSearchText) ?? []),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
};
|
||||
|
||||
export {
|
||||
createDynamicEntry,
|
||||
createTupleEntry,
|
||||
getConfigValueMap,
|
||||
getDefaultValueForType,
|
||||
getDescriptionSearchText,
|
||||
getDescriptionUpdates,
|
||||
getDescriptionValue,
|
||||
getDescriptionValueSignature,
|
||||
getDynamicEntriesValue,
|
||||
getPrimitiveTypeName,
|
||||
getSuggestionValues,
|
||||
getTupleSuggestions,
|
||||
getTypeLabel,
|
||||
getUnionOptions,
|
||||
isContainerDescriptor,
|
||||
isStringOrImageType,
|
||||
isTupleObject,
|
||||
isValueCompatibleWithType,
|
||||
mapToTupleArray,
|
||||
normalizeDynamicEntries,
|
||||
normalizePrimitiveListEntries,
|
||||
normalizeTupleEntries,
|
||||
numericTypes,
|
||||
parsePrimitiveListEntries,
|
||||
parseLooseValue,
|
||||
stringifyValue,
|
||||
textualTypes,
|
||||
tupleArrayToMap,
|
||||
};
|
||||
|
||||
export type {
|
||||
ConfigDescriptionNode,
|
||||
ConfigEntry,
|
||||
ConfigType,
|
||||
DynamicEntry,
|
||||
PrimitiveValue,
|
||||
TupleEntry,
|
||||
TupleValue,
|
||||
};
|
||||
@@ -307,6 +307,18 @@ const Dashboard: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{features.pleromaAdminConfig && (
|
||||
<ListItem
|
||||
to='/nicolium/admin/pleroma-config'
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='column.admin.pleroma_config'
|
||||
defaultMessage='Pleroma configuration'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{features.domains && (
|
||||
<ListItem
|
||||
to='/nicolium/admin/domains'
|
||||
|
||||
209
packages/nicolium/src/pages/dashboard/pleroma-config.tsx
Normal file
209
packages/nicolium/src/pages/dashboard/pleroma-config.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import React, { useCallback, useDeferredValue, useMemo, useState } from 'react';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { EmptyMessage } from '@/components/empty-message';
|
||||
import Column from '@/components/ui/column';
|
||||
import FormGroup from '@/components/ui/form-group';
|
||||
import Input from '@/components/ui/input';
|
||||
import Spinner from '@/components/ui/spinner';
|
||||
import Warning from '@/features/compose/components/warning';
|
||||
import { ConfigSection } from '@/pages/dashboard/components/pleroma-config/config-section';
|
||||
import {
|
||||
getConfigValueMap,
|
||||
getDescriptionSearchText,
|
||||
getDescriptionUpdates,
|
||||
} from '@/pages/dashboard/components/pleroma-config/utils';
|
||||
import {
|
||||
useAdminConfig,
|
||||
useAdminConfigDescriptions,
|
||||
useUpdateAdminConfig,
|
||||
} from '@/queries/admin/use-config';
|
||||
import toast from '@/toast';
|
||||
|
||||
import type { PleromaConfigDescription } from 'pl-api';
|
||||
|
||||
type IndexedDescription = {
|
||||
description: PleromaConfigDescription;
|
||||
searchText: string;
|
||||
};
|
||||
|
||||
const PleromaConfigPage: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { data: descriptions, isLoading: descriptionsLoading } = useAdminConfigDescriptions();
|
||||
const { data: currentConfig, isLoading: currentConfigLoading } = useAdminConfig();
|
||||
const { mutate: updateConfig, isPending } = useUpdateAdminConfig();
|
||||
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const deferredSearchInput = useDeferredValue(searchInput);
|
||||
const normalizedSearchInput = deferredSearchInput.trim().toLowerCase();
|
||||
|
||||
const indexedDescriptions = useMemo<IndexedDescription[]>(() => {
|
||||
if (!descriptions) return [];
|
||||
|
||||
return descriptions.map((description) => ({
|
||||
description,
|
||||
searchText: getDescriptionSearchText(description),
|
||||
}));
|
||||
}, [descriptions]);
|
||||
|
||||
const filteredDescriptions = useMemo(() => {
|
||||
if (!indexedDescriptions.length) return [];
|
||||
if (!normalizedSearchInput.length) {
|
||||
return indexedDescriptions.map((entry) => entry.description);
|
||||
}
|
||||
|
||||
return indexedDescriptions
|
||||
.filter((entry) => entry.searchText.includes(normalizedSearchInput))
|
||||
.map((entry) => entry.description);
|
||||
}, [indexedDescriptions, normalizedSearchInput]);
|
||||
|
||||
const groupedEntries = useMemo(() => {
|
||||
const grouped = filteredDescriptions.reduce<Record<string, PleromaConfigDescription[]>>(
|
||||
(result, description) => {
|
||||
if (!description.group) return result;
|
||||
|
||||
if (!result[description.group]) {
|
||||
result[description.group] = [];
|
||||
}
|
||||
|
||||
result[description.group].push(description);
|
||||
return result;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return Object.entries(grouped)
|
||||
.map(
|
||||
([group, groupDescriptions]) =>
|
||||
[
|
||||
group,
|
||||
[...groupDescriptions].sort((left, right) =>
|
||||
(left.label ?? left.key ?? '').localeCompare(right.label ?? right.key ?? ''),
|
||||
),
|
||||
] as const,
|
||||
)
|
||||
.sort(([left], [right]) => left.localeCompare(right));
|
||||
}, [filteredDescriptions]);
|
||||
|
||||
const configValueMap = useMemo(
|
||||
() => getConfigValueMap(currentConfig?.configs ?? []),
|
||||
[currentConfig?.configs],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(
|
||||
(description: PleromaConfigDescription, value: unknown) => {
|
||||
const updates = getDescriptionUpdates(description, value);
|
||||
if (!updates.length) return;
|
||||
|
||||
updateConfig(updates, {
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
intl.formatMessage({
|
||||
id: 'admin.pleroma_config.saved',
|
||||
defaultMessage: 'Configuration saved',
|
||||
}),
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(
|
||||
intl.formatMessage({
|
||||
id: 'admin.pleroma_config.save_failed',
|
||||
defaultMessage: 'Failed to save configuration',
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
[intl, updateConfig],
|
||||
);
|
||||
|
||||
const handleSearchChange = useCallback<React.ChangeEventHandler<HTMLInputElement>>((event) => {
|
||||
setSearchInput(event.target.value);
|
||||
}, []);
|
||||
|
||||
const isEmpty = !descriptionsLoading && !descriptions?.length;
|
||||
const isFilteredEmpty =
|
||||
!descriptionsLoading && !!descriptions?.length && !filteredDescriptions.length;
|
||||
|
||||
return (
|
||||
<Column
|
||||
label={intl.formatMessage({
|
||||
id: 'column.admin.pleroma_config',
|
||||
defaultMessage: 'Pleroma configuration',
|
||||
})}
|
||||
>
|
||||
{descriptionsLoading || currentConfigLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<div className='⁂-admin-config'>
|
||||
{currentConfig?.need_reboot ? (
|
||||
<Warning
|
||||
message={
|
||||
<FormattedMessage
|
||||
id='admin.pleroma_config.need_reboot'
|
||||
defaultMessage='Some configuration changes require a server restart to take effect.'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className='⁂-admin-config__filters'>
|
||||
<FormGroup
|
||||
labelText={intl.formatMessage({
|
||||
id: 'admin.pleroma_config.search',
|
||||
defaultMessage: 'Search settings',
|
||||
})}
|
||||
>
|
||||
<Input
|
||||
type='text'
|
||||
value={searchInput}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'admin.pleroma_config.search_placeholder',
|
||||
defaultMessage: 'Search by label, key, group or description',
|
||||
})}
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
|
||||
<div className='⁂-admin-config__sections'>
|
||||
{groupedEntries.map(([group, groupDescriptions]) => (
|
||||
<ConfigSection
|
||||
key={group}
|
||||
group={group}
|
||||
descriptions={groupDescriptions}
|
||||
configValueMap={configValueMap}
|
||||
onSave={handleSave}
|
||||
isPending={isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isEmpty ? (
|
||||
<EmptyMessage
|
||||
text={
|
||||
<FormattedMessage
|
||||
id='admin.pleroma_config.empty'
|
||||
defaultMessage='No configuration options available.'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isFilteredEmpty ? (
|
||||
<EmptyMessage
|
||||
text={
|
||||
<FormattedMessage
|
||||
id='admin.pleroma_config.empty_search'
|
||||
defaultMessage='No settings match the current filters.'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export { PleromaConfigPage as default };
|
||||
@@ -1041,6 +1041,15 @@ export const adminRulesRoute = createRoute({
|
||||
}),
|
||||
});
|
||||
|
||||
export const adminPleromaConfigRoute = createRoute({
|
||||
getParentRoute: () => layouts.admin,
|
||||
path: '/nicolium/admin/pleroma-config',
|
||||
component: lazy(() => import('@/pages/dashboard/pleroma-config')),
|
||||
beforeLoad: requireAuthMiddleware(({ context: { features, isAdmin } }) => {
|
||||
if (!isAdmin || !features.pleromaAdminConfig) throw notFound();
|
||||
}),
|
||||
});
|
||||
|
||||
// Info and other routes
|
||||
export const serverInfoRoute = createRoute({
|
||||
getParentRoute: () => layouts.empty,
|
||||
@@ -1333,6 +1342,7 @@ const routeTree = rootRoute.addChildren([
|
||||
adminAnnouncementsRoute,
|
||||
adminDomainsRoute,
|
||||
adminRulesRoute,
|
||||
adminPleromaConfigRoute,
|
||||
]),
|
||||
layouts.chats.addChildren([
|
||||
chatsRoute.addChildren([
|
||||
|
||||
@@ -59,3 +59,202 @@
|
||||
@include mixins.text($align: center);
|
||||
}
|
||||
}
|
||||
|
||||
.⁂-admin-config {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
|
||||
&__filters {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
&__sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
&__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
.⁂-form__actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
|
||||
margin: -0.5rem -1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
|
||||
background-color: #fffe;
|
||||
backdrop-filter: blur(12px) saturate(2);
|
||||
|
||||
.dark & {
|
||||
background-color: rgb(var(--color-primary-900) / 0.9);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
@media (max-width: variables.$breakpoint-lg) {
|
||||
bottom: 53px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__section-header {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__section-title,
|
||||
&__headline-title {
|
||||
@include mixins.text($weight: semibold);
|
||||
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
@include mixins.text($size: lg, $weight: semibold);
|
||||
}
|
||||
|
||||
.dark & .⁂-accordion {
|
||||
background-color: rgb(var(--color-primary-900));
|
||||
}
|
||||
|
||||
&__feedback {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
p.⁂-admin-config__meta,
|
||||
p.⁂-admin-config__feedback {
|
||||
@include mixins.text($size: sm, $theme: muted);
|
||||
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p.⁂-admin-config__feedback--danger {
|
||||
@include mixins.text($size: sm, $theme: danger);
|
||||
}
|
||||
|
||||
&__accordion-list,
|
||||
&__editor-stack,
|
||||
&__streamfield-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
&__accordion-headline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
align-items: flex-start;
|
||||
|
||||
width: 100%;
|
||||
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&__fieldset {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__editor-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__toggle-field {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__streamfield-row {
|
||||
display: grid;
|
||||
flex-grow: 1;
|
||||
gap: 0.75rem;
|
||||
align-items: start;
|
||||
|
||||
&__input {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@media (min-width: variables.$breakpoint-md) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
&__streamfield-row--dynamic {
|
||||
|
||||
@media (min-width: variables.$breakpoint-md) {
|
||||
grid-template-columns: minmax(10rem, 0.75fr) minmax(0, 1.25fr);
|
||||
}
|
||||
}
|
||||
|
||||
&__suggestions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
|
||||
.⁂-form-group__content > & {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
p.⁂-admin-config__suggestions-label {
|
||||
@include mixins.text($size: xs, $theme: muted);
|
||||
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__suggestion-button {
|
||||
@include mixins.button($theme: secondary, $size: sm);
|
||||
|
||||
border-radius: 0.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&__submit-button {
|
||||
@include mixins.button($theme: primary);
|
||||
}
|
||||
|
||||
&__reset-button {
|
||||
@include mixins.button($theme: secondary);
|
||||
}
|
||||
|
||||
.⁂-form-group:has(> .⁂-form-group__content > .⁂-admin-config__toggle-field) {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'label toggle'
|
||||
'hint toggle';
|
||||
|
||||
.⁂-form-group__label {
|
||||
grid-area: label;
|
||||
}
|
||||
|
||||
.⁂-form-group__hint {
|
||||
grid-area: hint;
|
||||
}
|
||||
|
||||
.⁂-admin-config__toggle-field {
|
||||
display: flex;
|
||||
grid-area: toggle;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.⁂-form-group__content {
|
||||
display: contents;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,7 +430,7 @@ a.⁂-list-item,
|
||||
border-color: rgb(var(--color-gray-800));
|
||||
color: rgb(var(--color-gray-100));
|
||||
background-color: rgb(var(--color-gray-900));
|
||||
outline: 2px solid rgb(var(--color-gray-800));
|
||||
outline: 1px solid rgb(var(--color-gray-800));
|
||||
}
|
||||
|
||||
.dark &:focus {
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
border-color: rgb(var(--color-gray-800));
|
||||
color: rgb(var(--color-gray-100));
|
||||
background-color: rgb(var(--color-gray-900));
|
||||
outline: 2px solid rgb(var(--color-gray-800));
|
||||
outline: 1px solid rgb(var(--color-gray-800));
|
||||
}
|
||||
|
||||
.dark &:focus {
|
||||
|
||||
@@ -637,7 +637,7 @@
|
||||
position: sticky;
|
||||
bottom: 0.5rem;
|
||||
|
||||
@media (max-width: variables.$breakpoint-md) {
|
||||
@media (max-width: variables.$breakpoint-lg) {
|
||||
bottom: 4rem;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user