Files
AFFiNE-Mirror/packages/frontend/admin/src/modules/settings/config-input-row.tsx
2026-02-17 00:56:03 +08:00

209 lines
5.1 KiB
TypeScript

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 { cn } from '@affine/admin/utils';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Textarea } from '../../components/ui/textarea';
export type ConfigInputProps = {
field: string;
desc: string;
defaultValue: any;
onChange: (field: string, value: any) => void;
error?: string;
onErrorChange?: (field: string, error?: string) => 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[];
error?: string;
onValidationChange?: (error?: string) => void;
}>
> = {
Boolean: function SwitchInput({ defaultValue, onChange }) {
const handleSwitchChange = (checked: boolean) => {
onChange(checked);
};
return (
<Switch
checked={Boolean(defaultValue)}
onCheckedChange={handleSwitchChange}
/>
);
},
String: function StringInput({ defaultValue, onChange }) {
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
};
return (
<Input
type="text"
minLength={1}
value={defaultValue ?? ''}
onChange={handleInputChange}
/>
);
},
Number: function NumberInput({ defaultValue, onChange }) {
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const next = e.target.value;
onChange(next === '' ? undefined : parseInt(next, 10));
};
return (
<Input
type="number"
value={defaultValue ?? ''}
onChange={handleInputChange}
/>
);
},
JSON: function ObjectInput({
defaultValue,
onChange,
error,
onValidationChange,
}) {
const fallbackText = useMemo(
() =>
typeof defaultValue === 'string'
? defaultValue
: JSON.stringify(defaultValue ?? null),
[defaultValue]
);
const [text, setText] = useState(fallbackText);
useEffect(() => {
setText(fallbackText);
}, [fallbackText]);
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const nextText = e.target.value;
setText(nextText);
try {
const value = JSON.parse(nextText);
onValidationChange?.(undefined);
onChange(value);
} catch {
onValidationChange?.('Invalid JSON format');
// Keep the draft "dirty" even when JSON is temporarily invalid
// so Save/Cancel state can reflect real editing progress.
onChange(nextText);
}
};
return (
<Textarea
value={text}
onChange={handleInputChange}
className={cn(
'w-full',
error
? 'border-red-500 hover:border-red-500 focus-visible:border-red-500 focus-visible:ring-red-500'
: undefined
)}
/>
);
},
Enum: function EnumInput({ defaultValue, onChange, options }) {
return (
<Select
value={typeof defaultValue === 'string' ? defaultValue : undefined}
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,
error,
onErrorChange,
...props
}: ConfigInputProps) => {
const Input = Inputs[type] ?? Inputs.JSON;
const [validationError, setValidationError] = useState<string>();
const onValueChange = useCallback(
(value?: any) => {
onChange(field, value);
},
[field, onChange]
);
const onValidationChange = useCallback((nextError?: string) => {
setValidationError(nextError);
}, []);
const mergedError = error ?? validationError;
useEffect(() => {
onErrorChange?.(field, mergedError);
return () => {
onErrorChange?.(field, undefined);
};
}, [field, mergedError, onErrorChange]);
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"
dangerouslySetInnerHTML={{ __html: desc }}
/>
<div className="flex flex-col items-end relative flex-1">
<Input
defaultValue={defaultValue}
onChange={onValueChange}
error={mergedError}
onValidationChange={onValidationChange}
{...props}
/>
{mergedError && (
<div className="mt-1 w-full text-sm break-words text-red-500">
{mergedError}
</div>
)}
</div>
</div>
);
};