feat(admin): adapt new config system (#11360)

feat(server): add test mail api

feat(admin): adapt new config system
This commit is contained in:
forehalo
2025-04-01 15:00:10 +00:00
parent 8427293d36
commit dad858014f
29 changed files with 718 additions and 400 deletions

View File

@@ -85,8 +85,8 @@ export const router = _createBrowserRouter(
lazy: () => import('./modules/ai'),
},
{
path: 'config',
lazy: () => import('./modules/config'),
path: 'about',
lazy: () => import('./modules/about'),
},
{
path: 'settings',

View File

@@ -39,8 +39,8 @@ export function Layout({ children }: PropsWithChildren) {
const leftPanelRef = useRef<ImperativePanelHandle>(null);
const [activeTab, setActiveTab] = useState('');
const [activeSubTab, setActiveSubTab] = useState('auth');
const [currentModule, setCurrentModule] = useState('auth');
const [activeSubTab, setActiveSubTab] = useState('server');
const [currentModule, setCurrentModule] = useState('server');
const handleLeftExpand = useCallback(() => {
if (leftPanelRef.current?.getSize() === 0) {

View File

@@ -1,62 +1,25 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@affine/admin/components/ui/accordion';
import { useCallback } from 'react';
import { NavLink } from 'react-router-dom';
import { buttonVariants } from '../../components/ui/button';
import { cn } from '../../utils';
export const CollapsibleItem = ({
title,
changeModule,
}: {
title: string;
changeModule?: (module: string) => void;
}) => {
const handleClick = useCallback(() => {
changeModule?.(title);
}, [changeModule, title]);
return (
<Accordion type="multiple" className="w-full">
<AccordionItem value="item-1" className="border-b-0 ml-7">
<NavLink
to={`/admin/settings/${title}`}
className={({ isActive }) => {
return isActive
? 'w-full bg-zinc-100 inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50'
: '';
}}
>
<AccordionTrigger
onClick={handleClick}
className="py-2 px-2 rounded [&[data-state=closed]>svg]:rotate-270 [&[data-state=open]>svg]:rotate-360"
>
{title}
</AccordionTrigger>
</NavLink>
</AccordionItem>
</Accordion>
);
};
export const NormalSubItem = ({
module,
title,
changeModule,
}: {
module: string;
title: string;
changeModule?: (module: string) => void;
}) => {
const handleClick = useCallback(() => {
changeModule?.(title);
}, [changeModule, title]);
changeModule?.(module);
}, [changeModule, module]);
return (
<div className="w-full flex">
<NavLink
to={`/admin/settings/${title}`}
to={`/admin/settings/${module}`}
onClick={handleClick}
className={({ isActive }) => {
return cn(
@@ -72,30 +35,3 @@ export const NormalSubItem = ({
</div>
);
};
export const OtherModules = ({
moduleList,
changeModule,
}: {
moduleList: string[];
changeModule?: (module: string) => void;
}) => {
return (
<Accordion type="multiple" className="w-full">
<AccordionItem value="item-1" className="border-b-0">
<AccordionTrigger className="ml-8 py-2 px-2 rounded [&[data-state=closed]>svg]:rotate-270 [&[data-state=open]>svg]:rotate-360">
Other
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-1 py-1">
{moduleList.map(module => (
<NormalSubItem
key={module}
title={module}
changeModule={changeModule}
/>
))}
</AccordionContent>
</AccordionItem>
</Accordion>
);
};

View File

@@ -98,9 +98,9 @@ export function Nav({ isCollapsed = false }: NavProps) {
/>
<SettingsItem isCollapsed={isCollapsed} />
<NavItem
to="/admin/config"
to="/admin/about"
icon={<SelfhostIcon fontSize={20} />}
label="Server"
label="About"
isCollapsed={isCollapsed}
/>
</nav>

View File

@@ -12,15 +12,10 @@ import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cssVarV2 } from '@toeverything/theme/v2';
import { NavLink } from 'react-router-dom';
import { ALL_CONFIGURABLE_MODULES } from '../settings/config';
import { NormalSubItem, OtherModules } from './collapsible-item';
import { KNOWN_CONFIG_GROUPS, UNKNOWN_CONFIG_GROUPS } from '../settings/config';
import { NormalSubItem } from './collapsible-item';
import { useNav } from './context';
const authModule = ALL_CONFIGURABLE_MODULES.find(module => module === 'auth');
const otherModules = ALL_CONFIGURABLE_MODULES.filter(
module => module !== 'auth'
);
export const SettingsItem = ({ isCollapsed }: { isCollapsed: boolean }) => {
const { setCurrentModule } = useNav();
@@ -59,10 +54,10 @@ export const SettingsItem = ({ isCollapsed }: { isCollapsed: boolean }) => {
borderColor: cssVarV2('layer/insideBorder/blackBorder'),
}}
>
{authModule ? (
<li key={authModule} className="flex">
{KNOWN_CONFIG_GROUPS.map(group => (
<li key={group.module} className="flex">
<NavLink
to={`/admin/settings/${authModule}`}
to={`/admin/settings/${group.module}`}
className={cn(
buttonVariants({
variant: 'ghost',
@@ -75,16 +70,16 @@ export const SettingsItem = ({ isCollapsed }: { isCollapsed: boolean }) => {
? cssVarV2('selfhost/button/sidebarButton/bg/select')
: undefined,
})}
onClick={() => setCurrentModule?.(authModule)}
onClick={() => setCurrentModule?.(group.module)}
>
{authModule}
{group.name}
</NavLink>
</li>
) : null}
{otherModules.map(module => (
<li key={module} className="flex">
))}
{UNKNOWN_CONFIG_GROUPS.map(group => (
<li key={group.module} className="flex">
<NavLink
to={`/admin/settings/${module}`}
to={`/admin/settings/${group.module}`}
className={cn(
buttonVariants({
variant: 'ghost',
@@ -97,9 +92,9 @@ export const SettingsItem = ({ isCollapsed }: { isCollapsed: boolean }) => {
? cssVarV2('selfhost/button/sidebarButton/bg/select')
: undefined,
})}
onClick={() => setCurrentModule?.(module)}
onClick={() => setCurrentModule?.(group.module)}
>
{module}
{group.name}
</NavLink>
</li>
))}
@@ -151,18 +146,31 @@ export const SettingsItem = ({ isCollapsed }: { isCollapsed: boolean }) => {
className={cn('relative overflow-hidden w-full h-full')}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit] [&>div]:!block">
{authModule && (
{KNOWN_CONFIG_GROUPS.map(group => (
<NormalSubItem
title={authModule}
key={group.module}
module={group.module}
title={group.name}
changeModule={setCurrentModule}
/>
)}
{otherModules.length > 0 && (
<OtherModules
moduleList={otherModules}
changeModule={setCurrentModule}
/>
)}
))}
<Accordion type="multiple" className="w-full">
<AccordionItem value="item-1" className="border-b-0">
<AccordionTrigger className="ml-8 py-2 px-2 rounded [&[data-state=closed]>svg]:rotate-270 [&[data-state=open]>svg]:rotate-360">
Experimental
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-1 py-1">
{UNKNOWN_CONFIG_GROUPS.map(group => (
<NormalSubItem
key={group.module}
module={group.module}
title={group.name}
changeModule={setCurrentModule}
/>
))}
</AccordionContent>
</AccordionItem>
</Accordion>
</ScrollAreaPrimitive.Viewport>
<ScrollAreaPrimitive.ScrollAreaScrollbar
className={cn(

View File

@@ -0,0 +1,170 @@
import { Input } from '@affine/admin/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@affine/admin/components/ui/select';
import { Switch } from '@affine/admin/components/ui/switch';
import { useCallback, useState } from 'react';
import { Textarea } from '../../components/ui/textarea';
import { isEqual } from './utils';
export type ConfigInputProps = {
field: string;
desc: string;
defaultValue: any;
onChange: (key: string, value: any) => void;
} & (
| {
type: 'String' | 'Number' | 'Boolean' | 'JSON';
}
| {
type: 'Enum';
options: string[];
}
);
const Inputs: Record<
ConfigInputProps['type'],
React.ComponentType<{
defaultValue: any;
onChange: (value?: any) => void;
options?: string[];
}>
> = {
Boolean: function SwitchInput({ defaultValue, onChange }) {
const handleSwitchChange = (checked: boolean) => {
onChange(checked);
};
return (
<Switch
defaultChecked={defaultValue}
onCheckedChange={handleSwitchChange}
/>
);
},
String: function StringInput({ defaultValue, onChange }) {
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
};
return (
<Input
type="text"
minLength={1}
defaultValue={defaultValue}
onChange={handleInputChange}
/>
);
},
Number: function NumberInput({ defaultValue, onChange }) {
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(parseInt(e.target.value));
};
return (
<Input
type="number"
defaultValue={defaultValue}
onChange={handleInputChange}
/>
);
},
JSON: function ObjectInput({ defaultValue, onChange }) {
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
try {
const value = JSON.parse(e.target.value);
onChange(value);
} catch {}
};
return (
<Textarea
defaultValue={JSON.stringify(defaultValue)}
onChange={handleInputChange}
className="w-full"
/>
);
},
Enum: function EnumInput({ defaultValue, onChange, options }) {
return (
<Select defaultValue={defaultValue} onValueChange={onChange}>
<SelectTrigger>
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
{options?.map(option => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
);
},
};
export const ConfigRow = ({
field,
desc,
type,
defaultValue,
onChange,
...props
}: ConfigInputProps) => {
const [value, setValue] = useState(defaultValue);
const isValueChanged = !isEqual(value, defaultValue);
const onValueChange = useCallback(
(value?: any) => {
onChange(field, value);
setValue(value);
},
[field, onChange]
);
const Input = Inputs[type] ?? Inputs.JSON;
return (
<div
className={`flex justify-between flex-grow space-y-[10px]
${type === 'Boolean' ? 'flex-row' : 'flex-col'}`}
>
<div className="text-base font-bold flex-3">{desc}</div>
<div className="flex flex-col items-end relative flex-1">
<Input
defaultValue={defaultValue}
onChange={onValueChange}
{...props}
/>
{isValueChanged && (
<div className="absolute bottom-[-25px] text-sm right-0 break-words">
<span
className="line-through"
style={{
color: 'rgba(198, 34, 34, 1)',
backgroundColor: 'rgba(254, 213, 213, 1)',
}}
>
{JSON.stringify(defaultValue)}
</span>{' '}
=&gt;{' '}
<span
style={{
color: 'rgba(20, 147, 67, 1)',
backgroundColor: 'rgba(225, 250, 177, 1)',
}}
>
{JSON.stringify(value)}
</span>
</div>
)}
</div>
</div>
);
};

View File

@@ -1,132 +0,0 @@
import { Input } from '@affine/admin/components/ui/input';
import { Switch } from '@affine/admin/components/ui/switch';
import { useCallback, useState } from 'react';
import { Textarea } from '../../components/ui/textarea';
import { isEqual } from './utils';
interface ConfigInputProps {
module: string;
field: string;
type: string;
defaultValue: any;
onChange: (module: string, field: string, value: any) => void;
}
const Inputs: Record<
string,
React.ComponentType<{
defaultValue: any;
onChange: (value?: any) => void;
}>
> = {
Boolean: function SwitchInput({ defaultValue, onChange }) {
const handleSwitchChange = (checked: boolean) => {
onChange(checked);
};
return (
<Switch
defaultChecked={defaultValue}
onCheckedChange={handleSwitchChange}
/>
);
},
String: function StringInput({ defaultValue, onChange }) {
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
};
return (
<Input
type="text"
minLength={1}
defaultValue={defaultValue}
onChange={handleInputChange}
/>
);
},
Number: function NumberInput({ defaultValue, onChange }) {
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(parseInt(e.target.value));
};
return (
<Input
type="number"
defaultValue={defaultValue}
onChange={handleInputChange}
/>
);
},
JSON: function ObjectInput({ defaultValue, onChange }) {
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
try {
const value = JSON.parse(e.target.value);
onChange(value);
} catch {}
};
return (
<Textarea
defaultValue={JSON.stringify(defaultValue)}
onChange={handleInputChange}
className="w-full"
/>
);
},
};
export const ConfigInput = ({
module,
field,
type,
defaultValue,
onChange,
}: ConfigInputProps) => {
const [value, setValue] = useState(defaultValue);
const onValueChange = useCallback(
(value?: any) => {
onChange(module, field, value);
setValue(value);
},
[module, field, onChange]
);
const Input = Inputs[type] ?? Inputs.JSON;
const isValueEqual = isEqual(value, defaultValue);
return (
<div className="flex flex-col items-end gap-2 w-full">
<Input defaultValue={defaultValue} onChange={onValueChange} />
<div
className="w-full break-words"
style={{
opacity: isValueEqual ? 0 : 1,
height: isValueEqual ? 0 : 'auto',
}}
>
<span
className="line-through"
style={{
color: 'rgba(198, 34, 34, 1)',
backgroundColor: 'rgba(254, 213, 213, 1)',
}}
>
{JSON.stringify(defaultValue)}
</span>{' '}
=&gt;{' '}
<span
style={{
color: 'rgba(20, 147, 67, 1)',
backgroundColor: 'rgba(225, 250, 177, 1)',
}}
>
{JSON.stringify(value)}
</span>
</div>
</div>
);
};

View File

@@ -1,23 +1,43 @@
import CONFIG from '../../config.json';
import { upperFirst } from 'lodash-es';
import type { ComponentType } from 'react';
export type ConfigDescriptor = {
import CONFIG_DESCRIPTORS from '../../config.json';
import type { ConfigInputProps } from './config-input-row';
import { SendTestEmail } from './operations/send-test-email';
export type ConfigType = 'String' | 'Number' | 'Boolean' | 'JSON' | 'Enum';
type ConfigDescriptor = {
desc: string;
type: 'String' | 'Number' | 'Boolean' | 'Array' | 'Object';
type: ConfigType;
env?: string;
link?: string;
};
export type AppConfig = typeof CONFIG;
export type AvailableConfig = {
[K in keyof AppConfig]: {
module: K;
fields: Array<keyof AppConfig[K]>;
};
}[keyof AppConfig];
export type AppConfig = Record<string, Record<string, any>>;
type AppConfigDescriptors = typeof CONFIG_DESCRIPTORS;
type AppConfigModule = keyof AppConfigDescriptors;
type ModuleConfigDescriptors<M extends AppConfigModule> =
AppConfigDescriptors[M];
type ConfigGroup<T extends AppConfigModule> = {
name: string;
module: T;
fields: Array<
| keyof ModuleConfigDescriptors<T>
| ({
key: keyof ModuleConfigDescriptors<T>;
sub?: string;
desc?: string;
} & Partial<ConfigInputProps>)
>;
operations?: ComponentType<{
appConfig: AppConfig;
}>[];
};
const IGNORED_MODULES: (keyof AppConfig)[] = [
'db',
'redis',
'graphql',
'copilot', // not ready
];
@@ -25,7 +45,117 @@ if (!environment.isSelfHosted) {
IGNORED_MODULES.push('payment');
}
export { CONFIG as ALL_CONFIG };
export const ALL_CONFIGURABLE_MODULES = Object.keys(CONFIG).filter(
const ALL_CONFIGURABLE_MODULES = Object.keys(CONFIG_DESCRIPTORS).filter(
key => !IGNORED_MODULES.includes(key as keyof AppConfig)
) as (keyof AppConfig)[];
);
export const KNOWN_CONFIG_GROUPS = [
{
name: 'Server',
module: 'server',
fields: ['externalUrl', 'name'],
} as ConfigGroup<'server'>,
{
name: 'Auth',
module: 'auth',
fields: [
'allowSignup',
// nested json object
{
key: 'passwordRequirements',
sub: 'min',
type: 'Number',
desc: 'Minimum length requirement of password',
},
{
key: 'passwordRequirements',
sub: 'max',
type: 'Number',
desc: 'Maximum length requirement of password',
},
],
} as ConfigGroup<'auth'>,
{
name: 'Notification',
module: 'mailer',
fields: [
'enabled',
'SMTP.host',
'SMTP.port',
'SMTP.username',
'SMTP.password',
'SMTP.ignoreTLS',
'SMTP.sender',
],
operations: [SendTestEmail],
} as ConfigGroup<'mailer'>,
{
name: 'Storage',
module: 'storages',
fields: [
{
key: 'blob.storage',
desc: 'The storage provider for user uploaded blobs',
sub: 'provider',
type: 'Enum',
options: ['fs', 'aws-s3', 'cloudflare-r2'],
},
{
key: 'blob.storage',
sub: 'bucket',
type: 'String',
desc: 'The bucket name for user uploaded blobs storage',
},
{
key: 'blob.storage',
sub: 'config',
type: 'JSON',
desc: 'The config passed directly to the storage provider(e.g. aws-sdk)',
},
{
key: 'avatar.storage',
desc: 'The storage provider for user avatars',
sub: 'provider',
type: 'Enum',
options: ['fs', 'aws-s3', 'cloudflare-r2'],
},
{
key: 'avatar.storage',
sub: 'bucket',
type: 'String',
desc: 'The bucket name for user avatars storage',
},
{
key: 'avatar.storage',
sub: 'config',
type: 'JSON',
desc: 'The config passed directly to the storage provider(e.g. aws-sdk)',
},
],
} as ConfigGroup<'storages'>,
{
name: 'OAuth',
module: 'oauth',
fields: ['providers.google', 'providers.github', 'providers.oidc'],
} as ConfigGroup<'oauth'>,
];
export const UNKNOWN_CONFIG_GROUPS = ALL_CONFIGURABLE_MODULES.filter(
module => !KNOWN_CONFIG_GROUPS.some(group => group.module === module)
).map(module => ({
name: upperFirst(module),
module,
// @ts-expect-error allow
fields: Object.keys(CONFIG_DESCRIPTORS[module]),
operations: undefined,
}));
export const ALL_SETTING_GROUPS = [
...KNOWN_CONFIG_GROUPS,
...UNKNOWN_CONFIG_GROUPS,
];
export const ALL_CONFIG_DESCRIPTORS = CONFIG_DESCRIPTORS as Record<
string,
Record<string, ConfigDescriptor>
>;

View File

@@ -9,13 +9,15 @@ import {
} from '@affine/admin/components/ui/dialog';
import { useCallback } from 'react';
import type { AppConfig } from './config';
export const ConfirmChanges = ({
updates,
open,
onOpenChange,
onConfirm,
}: {
updates: Record<string, { from: any; to: any }>;
updates: AppConfig;
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;

View File

@@ -1,6 +1,5 @@
import { Button } from '@affine/admin/components/ui/button';
import { ScrollArea } from '@affine/admin/components/ui/scroll-area';
import { Separator } from '@affine/admin/components/ui/separator';
import { get } from 'lodash-es';
import { CheckIcon } from 'lucide-react';
import { useCallback, useState } from 'react';
@@ -8,17 +7,16 @@ import { useCallback, useState } from 'react';
import { Header } from '../header';
import { useNav } from '../nav/context';
import {
ALL_CONFIG,
ALL_CONFIGURABLE_MODULES,
type ConfigDescriptor,
ALL_CONFIG_DESCRIPTORS,
ALL_SETTING_GROUPS,
type AppConfig,
} from './config';
import { ConfigInput } from './config-input';
import { type ConfigInputProps, ConfigRow } from './config-input-row';
import { ConfirmChanges } from './confirm-changes';
import { RuntimeSettingRow } from './runtime-setting-row';
import { useAppConfig } from './use-app-config';
export function SettingsPage() {
const { appConfig, update, save, updates } = useAppConfig();
const { appConfig, update, save, patchedAppConfig, updates } = useAppConfig();
const [open, setOpen] = useState(false);
const onOpen = useCallback(() => setOpen(true), [setOpen]);
@@ -28,22 +26,12 @@ export function SettingsPage() {
if (disableSave) {
return;
}
save(
Object.entries(updates).map(([key, { to }]) => {
const splitAt = key.indexOf('.');
const [module, field] = [key.slice(0, splitAt), key.slice(splitAt + 1)];
return {
module,
key: field,
value: to,
};
})
);
save();
setOpen(false);
}, [save, disableSave, updates]);
}, [save, disableSave]);
return (
<div className=" h-screen flex-1 flex-col flex">
<div className="h-screen flex-1 flex-col flex">
<Header
title="Settings"
endFix={
@@ -59,7 +47,11 @@ export function SettingsPage() {
</Button>
}
/>
<AdminPanel onUpdate={update} appConfig={appConfig} />
<AdminPanel
onUpdate={update}
appConfig={appConfig}
patchedAppConfig={patchedAppConfig}
/>
<ConfirmChanges
updates={updates}
open={open}
@@ -72,58 +64,66 @@ export function SettingsPage() {
export const AdminPanel = ({
appConfig,
patchedAppConfig,
onUpdate,
}: {
appConfig: Record<string, any>;
onUpdate: (module: string, field: string, value: any) => void;
appConfig: AppConfig;
patchedAppConfig: AppConfig;
onUpdate: (path: string, value: any) => void;
}) => {
const { currentModule } = useNav();
const group = ALL_SETTING_GROUPS.find(
group => group.module === currentModule
);
if (!group) {
return null;
}
const { name, module, fields, operations } = group;
return (
<ScrollArea>
<div className="flex flex-col h-full gap-3 py-5 px-6 w-full max-w-[800px] mx-auto">
{ALL_CONFIGURABLE_MODULES.filter(
module => module === currentModule
).map(module => {
const fields = Object.keys(ALL_CONFIG[module]);
return (
<div
className="flex flex-col gap-5"
id={`config-module-${module}`}
key={module}
>
<div className="text-xl font-semibold">{module}</div>
{fields.map((field, index) => {
// @ts-expect-error allow
const { desc, type } = ALL_CONFIG[module][
field
] as ConfigDescriptor;
<ScrollArea className="h-full">
<div className="flex flex-col h-full gap-5 py-5 px-6 w-full max-w-[800px] mx-auto">
<div className="text-2xl font-semibold">{name}</div>
<div className="flex flex-col gap-10" id={`config-module-${module}`}>
{fields.map(field => {
let desc: string;
let props: ConfigInputProps;
if (typeof field === 'string') {
const descriptor = ALL_CONFIG_DESCRIPTORS[module][field];
desc = descriptor.desc;
props = {
type: descriptor.type,
defaultValue: get(appConfig[module], field),
field: `${module}/${field}`,
desc,
onChange: onUpdate,
options: [],
};
} else {
const descriptor = ALL_CONFIG_DESCRIPTORS[module][field.key];
return (
<div key={field} className="flex flex-col gap-10">
{index !== 0 && <Separator />}
<RuntimeSettingRow
key={field}
id={field}
description={desc}
orientation={
type === 'Boolean' ? 'horizontal' : 'vertical'
}
>
<ConfigInput
module={module}
field={field}
type={type}
defaultValue={get(appConfig[module], field)}
onChange={onUpdate}
/>
</RuntimeSettingRow>
</div>
);
})}
</div>
);
})}
props = {
desc: field.desc ?? descriptor.desc,
type: field.type ?? descriptor.type,
// @ts-expect-error for enum type
options: field.options,
defaultValue: get(
appConfig[module],
field.key + (field.sub ? '.' + field.sub : '')
),
field: `${module}/${field.key}${field.sub ? `/${field.sub}` : ''}`,
onChange: onUpdate,
};
}
return <ConfigRow key={props.field} {...props} />;
})}
{operations?.map(Operation => (
<Operation key={Operation.name} appConfig={patchedAppConfig} />
))}
</div>
</div>
</ScrollArea>
);

View File

@@ -0,0 +1,32 @@
import { Button } from '@affine/admin/components/ui/button';
import { useMutation } from '@affine/admin/use-mutation';
import { notify } from '@affine/component';
import type { UserFriendlyError } from '@affine/error';
import { sendTestEmailMutation } from '@affine/graphql';
import { useCallback } from 'react';
import type { AppConfig } from '../config';
export function SendTestEmail({ appConfig }: { appConfig: AppConfig }) {
const { trigger } = useMutation({
mutation: sendTestEmailMutation,
});
const onClick = useCallback(() => {
trigger(appConfig.mailer.SMTP)
.then(() => {
notify.success({
title: 'Test email sent',
message: 'The test email has been successfully sent.',
});
})
.catch((err: UserFriendlyError) => {
notify.error({
title: 'Failed to send test email',
message: err.message,
});
});
}, [appConfig, trigger]);
return <Button onClick={onClick}>Send Test Email</Button>;
}

View File

@@ -1,30 +0,0 @@
import { type ReactNode } from 'react';
export const RuntimeSettingRow = ({
id,
description,
orientation = 'horizontal',
children,
}: {
id: string;
description: string;
orientation?: 'horizontal' | 'vertical';
children: ReactNode;
}) => {
return (
<div
className={`flex justify-between flex-grow space-y-[10px] gap-5 ${orientation === 'vertical' ? 'flex-col' : 'flex-row'}`}
id={id}
>
<div className="flex flex-col gap-1">
<div className="text-base font-bold">{description}</div>
<div className="">
<code className="text-xs bg-zinc-100 text-gray-500 px-[4px] py-[2px] rounded">
{id}
</code>
</div>
</div>
<div className="flex flex-col items-end gap-2 mr-1">{children}</div>
</div>
);
};

View File

@@ -8,11 +8,15 @@ import {
type UpdateAppConfigInput,
updateAppConfigMutation,
} from '@affine/graphql';
import { get, merge } from 'lodash-es';
import { cloneDeep, get, merge, set } from 'lodash-es';
import { useCallback, useState } from 'react';
import type { AppConfig } from './config';
export { type UpdateAppConfigInput };
export type AppConfigUpdates = Record<string, { from: any; to: any }>;
export const useAppConfig = () => {
const {
data: { appConfig },
@@ -25,49 +29,81 @@ export const useAppConfig = () => {
mutation: updateAppConfigMutation,
});
const [updates, setUpdates] = useState<
Record<string, { from: any; to: any }>
>({});
const save = useAsyncCallback(
async (updates: UpdateAppConfigInput[]) => {
try {
const savedUpdates = await trigger({
updates,
});
await mutate({ appConfig: merge({}, appConfig, savedUpdates) });
setUpdates({});
notify.success({
title: 'Saved successfully',
message: 'Runtime configurations have been saved successfully.',
});
} catch (e) {
const error = UserFriendlyError.fromAny(e);
notify.error({
title: 'Failed to save',
message: error.message,
});
console.error(e);
}
},
[appConfig, mutate, trigger]
const [updates, setUpdates] = useState<AppConfigUpdates>({});
const [patchedAppConfig, setPatchedAppConfig] = useState<AppConfig>(() =>
cloneDeep(appConfig)
);
const save = useAsyncCallback(async () => {
const updateInputs: UpdateAppConfigInput[] = Object.entries(updates).map(
([key, value]) => {
const splitIndex = key.indexOf('.');
const module = key.slice(0, splitIndex);
const field = key.slice(splitIndex + 1);
return {
module,
key: field,
value: value.to,
};
}
);
try {
const savedUpdates = await trigger({
updates: updateInputs,
});
await mutate(prev => {
return { appConfig: merge({}, prev, savedUpdates) };
});
setUpdates({});
notify.success({
title: 'Saved',
message: 'Settings have been saved successfully.',
});
} catch (e) {
const error = UserFriendlyError.fromAny(e);
notify.error({
title: 'Failed to save',
message: error.message,
});
console.error(e);
}
}, [updates, mutate, trigger]);
const update = useCallback(
(module: string, field: string, value: any) => {
setUpdates(prev => ({
...prev,
[`${module}.${field}`]: {
from: get(appConfig, `${module}.${field}`),
to: value,
},
}));
(path: string, value: any) => {
const [module, field, subField] = path.split('/');
const key = `${module}.${field}`;
const from = get(appConfig, key);
setUpdates(prev => {
const to = subField
? set(prev[key]?.to ?? { ...from }, subField, value)
: value;
return {
...prev,
[key]: {
from,
to,
},
};
});
setPatchedAppConfig(prev => {
return set(
prev,
`${module}.${field}${subField ? `.${subField}` : ''}`,
value
);
});
},
[appConfig]
);
return {
appConfig,
appConfig: appConfig as AppConfig,
patchedAppConfig,
update,
save,
updates,