2346 lines
69 KiB
TypeScript
2346 lines
69 KiB
TypeScript
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
|
*/
|
|
|
|
import { useState, useEffect, type KeyboardEvent } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import ReactMarkdown from 'react-markdown';
|
|
|
|
import { Input } from '@/components/ui/input';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
import { Combobox, type ComboboxOption } from '@/components/ui/combobox';
|
|
|
|
import { Plus, X, Eye, EyeOff, Loader2, Search, Check, ChevronRight } from 'lucide-react';
|
|
|
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
|
|
|
import { ExpressionEditor } from '@/components/expression/ExpressionEditor';
|
|
import { OtpAuthField } from '@/components/forms/OtpAuthField';
|
|
|
|
import {
|
|
bytesToHuman,
|
|
humanToBytes,
|
|
msToHuman,
|
|
humanToMs,
|
|
formatSize,
|
|
formatDuration,
|
|
SIZE_UNITS,
|
|
DURATION_UNITS,
|
|
} from '@/lib/durationFormat';
|
|
import { resolveSchema, resolveVariantForm, resolveObject, buildEmbeddedDefaults } from '@/lib/schemaResolver';
|
|
import { useAccountStore } from '@/stores/accountStore';
|
|
import { useEffectiveEdition } from '@/components/forms/FormEditionContext';
|
|
import { useObjectList, useObjectLabel, useNoPermissionMessage, type ObjectOption } from '@/lib/objectOptions';
|
|
import { SECRET_MASK } from '@/lib/jmapUtils';
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
|
import { jmapGet, getAccountId } from '@/services/jmap/client';
|
|
|
|
import type { Field, FormField, Schema, ScalarType, MapValueType, StringFormat, NumberFormat } from '@/types/schema';
|
|
|
|
export interface FieldWidgetProps {
|
|
field: Field;
|
|
formField: FormField;
|
|
value: unknown;
|
|
onChange: (value: unknown) => void;
|
|
readOnly: boolean;
|
|
error?: string;
|
|
schema: Schema;
|
|
}
|
|
|
|
function getRequiredMarker(field: Field, readOnly: boolean): 'required' | 'optional' | null {
|
|
if (readOnly) return null;
|
|
if (field.update === 'serverSet') return null;
|
|
const t = field.type;
|
|
const eligible =
|
|
t.type === 'string' ||
|
|
t.type === 'number' ||
|
|
t.type === 'utcDateTime' ||
|
|
t.type === 'enum' ||
|
|
t.type === 'blobId' ||
|
|
t.type === 'objectId';
|
|
if (!eligible) return null;
|
|
if ('nullable' in t && t.nullable) return 'optional';
|
|
return 'required';
|
|
}
|
|
|
|
export function FieldWidget(props: FieldWidgetProps) {
|
|
const { t } = useTranslation();
|
|
const { field, formField, value, onChange, readOnly, error, schema } = props;
|
|
const ft = field.type;
|
|
const edition = useEffectiveEdition();
|
|
|
|
if (field.enterprise && edition === 'oss') return null;
|
|
|
|
const marker = getRequiredMarker(field, readOnly);
|
|
|
|
const isSelfContained = ft.type === 'object' && ft.objectName === 'x:OtpAuth';
|
|
|
|
const widget = (() => {
|
|
switch (ft.type) {
|
|
case 'string':
|
|
return (
|
|
<StringField
|
|
format={ft.format}
|
|
value={value}
|
|
onChange={onChange}
|
|
readOnly={readOnly}
|
|
placeholder={formField.placeholder}
|
|
minLength={ft.minLength}
|
|
maxLength={ft.maxLength}
|
|
nullable={ft.nullable}
|
|
/>
|
|
);
|
|
case 'number':
|
|
return (
|
|
<NumberField
|
|
format={ft.format}
|
|
value={value}
|
|
onChange={onChange}
|
|
readOnly={readOnly}
|
|
min={ft.min}
|
|
max={ft.max}
|
|
nullable={ft.nullable}
|
|
/>
|
|
);
|
|
case 'utcDateTime':
|
|
return <DateTimeField value={value} onChange={onChange} readOnly={readOnly} nullable={ft.nullable} />;
|
|
case 'boolean':
|
|
return <BooleanField value={value} onChange={onChange} readOnly={readOnly} />;
|
|
case 'enum':
|
|
return (
|
|
<EnumField
|
|
enumName={ft.enumName}
|
|
value={value}
|
|
onChange={onChange}
|
|
readOnly={readOnly}
|
|
schema={schema}
|
|
nullable={ft.nullable}
|
|
/>
|
|
);
|
|
case 'blobId':
|
|
return <BlobField value={value} onChange={onChange} readOnly={readOnly} />;
|
|
case 'objectId':
|
|
return (
|
|
<ObjectIdField
|
|
objectName={ft.objectName}
|
|
value={value}
|
|
onChange={onChange}
|
|
readOnly={readOnly}
|
|
schema={schema}
|
|
nullable={ft.nullable}
|
|
formField={formField}
|
|
/>
|
|
);
|
|
case 'object':
|
|
return (
|
|
<EmbeddedObjectField
|
|
objectName={ft.objectName}
|
|
value={value}
|
|
onChange={onChange}
|
|
readOnly={readOnly}
|
|
schema={schema}
|
|
formField={formField}
|
|
nullable={ft.nullable}
|
|
/>
|
|
);
|
|
case 'objectList':
|
|
return (
|
|
<ObjectListField
|
|
objectName={ft.objectName}
|
|
value={value}
|
|
onChange={onChange}
|
|
readOnly={readOnly}
|
|
schema={schema}
|
|
formField={formField}
|
|
minItems={ft.minItems}
|
|
maxItems={ft.maxItems}
|
|
/>
|
|
);
|
|
case 'set':
|
|
return (
|
|
<SetField
|
|
scalarType={ft.class}
|
|
value={value}
|
|
onChange={onChange}
|
|
readOnly={readOnly}
|
|
schema={schema}
|
|
minItems={ft.minItems}
|
|
maxItems={ft.maxItems}
|
|
/>
|
|
);
|
|
case 'map':
|
|
return (
|
|
<MapField
|
|
keyClass={ft.keyClass}
|
|
valueClass={ft.valueClass}
|
|
value={value}
|
|
onChange={onChange}
|
|
readOnly={readOnly}
|
|
schema={schema}
|
|
formField={formField}
|
|
minItems={ft.minItems}
|
|
maxItems={ft.maxItems}
|
|
/>
|
|
);
|
|
default: {
|
|
const _: never = ft;
|
|
void _;
|
|
return <p className="text-sm text-muted-foreground">Unsupported field type</p>;
|
|
}
|
|
}
|
|
})();
|
|
|
|
if (isSelfContained) {
|
|
return (
|
|
<div className="space-y-1.5">
|
|
{widget}
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center gap-2">
|
|
<Label className="text-sm font-medium">
|
|
{formField.label}
|
|
{marker === 'required' && (
|
|
<span className="ml-0.5 text-destructive" aria-label={t('field.required', 'required')}>
|
|
*
|
|
</span>
|
|
)}
|
|
{marker === 'optional' && (
|
|
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
|
|
{t('field.optional', '(optional)')}
|
|
</span>
|
|
)}
|
|
</Label>
|
|
</div>
|
|
{field.description && (
|
|
<div className="text-xs text-muted-foreground prose prose-sm max-w-none [&_p]:m-0">
|
|
<ReactMarkdown>{field.description.replace(/\\n/g, '\n')}</ReactMarkdown>
|
|
</div>
|
|
)}
|
|
{widget}
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface BufferedInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> {
|
|
value: string;
|
|
onCommit: (v: string) => void;
|
|
}
|
|
|
|
function BufferedInput({ value, onCommit, onBlur, onKeyDown, ...rest }: BufferedInputProps) {
|
|
const [local, setLocal] = useState(value);
|
|
|
|
useEffect(() => {
|
|
setLocal(value);
|
|
}, [value]);
|
|
|
|
const commit = () => {
|
|
if (local !== value) onCommit(local);
|
|
};
|
|
|
|
return (
|
|
<Input
|
|
{...rest}
|
|
value={local}
|
|
onChange={(e) => setLocal(e.target.value)}
|
|
onBlur={(e) => {
|
|
commit();
|
|
onBlur?.(e);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' && (rest.type ?? 'text') !== 'textarea') {
|
|
commit();
|
|
}
|
|
onKeyDown?.(e);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
interface BufferedTextareaProps extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange' | 'value'> {
|
|
value: string;
|
|
onCommit: (v: string) => void;
|
|
}
|
|
|
|
function BufferedTextarea({ value, onCommit, onBlur, ...rest }: BufferedTextareaProps) {
|
|
const [local, setLocal] = useState(value);
|
|
|
|
useEffect(() => {
|
|
setLocal(value);
|
|
}, [value]);
|
|
|
|
return (
|
|
<Textarea
|
|
{...rest}
|
|
value={local}
|
|
onChange={(e) => setLocal(e.target.value)}
|
|
onBlur={(e) => {
|
|
if (local !== value) onCommit(local);
|
|
onBlur?.(e);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
interface StringFieldProps {
|
|
format: StringFormat;
|
|
value: unknown;
|
|
onChange: (value: unknown) => void;
|
|
readOnly: boolean;
|
|
placeholder?: string;
|
|
minLength?: number;
|
|
maxLength?: number;
|
|
nullable?: boolean;
|
|
}
|
|
|
|
function StringField({
|
|
format,
|
|
value,
|
|
onChange,
|
|
readOnly,
|
|
placeholder,
|
|
minLength,
|
|
maxLength,
|
|
nullable,
|
|
}: StringFieldProps) {
|
|
const strValue = (value as string) ?? '';
|
|
|
|
const handleCommit = (v: string) => {
|
|
if (nullable && v === '') {
|
|
onChange(null);
|
|
} else {
|
|
onChange(v);
|
|
}
|
|
};
|
|
|
|
if (readOnly && format !== 'secret' && format !== 'secretText' && format !== 'color') {
|
|
if (!strValue) {
|
|
return <span className="text-sm text-muted-foreground italic">Not set</span>;
|
|
}
|
|
if (format === 'text' || format === 'html') {
|
|
return (
|
|
<pre className={`text-sm ${format === 'html' ? 'font-mono' : ''} whitespace-pre-wrap break-words`}>
|
|
{strValue}
|
|
</pre>
|
|
);
|
|
}
|
|
return <span className="text-sm break-words">{strValue}</span>;
|
|
}
|
|
|
|
switch (format) {
|
|
case 'secret':
|
|
return (
|
|
<SecretInput
|
|
value={strValue}
|
|
onChange={handleCommit}
|
|
readOnly={readOnly}
|
|
placeholder={placeholder}
|
|
minLength={minLength}
|
|
maxLength={maxLength}
|
|
multiline={false}
|
|
/>
|
|
);
|
|
case 'secretText':
|
|
return (
|
|
<SecretInput
|
|
value={strValue}
|
|
onChange={handleCommit}
|
|
readOnly={readOnly}
|
|
placeholder={placeholder}
|
|
minLength={minLength}
|
|
maxLength={maxLength}
|
|
multiline={true}
|
|
/>
|
|
);
|
|
case 'text':
|
|
return (
|
|
<BufferedTextarea
|
|
value={strValue}
|
|
onCommit={handleCommit}
|
|
disabled={readOnly}
|
|
placeholder={placeholder}
|
|
minLength={minLength}
|
|
maxLength={maxLength}
|
|
rows={4}
|
|
/>
|
|
);
|
|
case 'html':
|
|
return (
|
|
<div className="rounded-md border bg-muted/30">
|
|
<div className="flex items-center gap-2 border-b px-3 py-1.5 text-xs text-muted-foreground">
|
|
<span className="font-mono">HTML</span>
|
|
</div>
|
|
<BufferedTextarea
|
|
value={strValue}
|
|
onCommit={handleCommit}
|
|
disabled={readOnly}
|
|
placeholder={placeholder}
|
|
minLength={minLength}
|
|
maxLength={maxLength}
|
|
rows={10}
|
|
className="font-mono text-xs border-0 rounded-none rounded-b-md bg-transparent focus-visible:ring-0 resize-y"
|
|
/>
|
|
</div>
|
|
);
|
|
case 'color':
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="color"
|
|
value={strValue || '#000000'}
|
|
onChange={(e) => handleCommit(e.target.value)}
|
|
disabled={readOnly}
|
|
className="h-9 w-14 cursor-pointer rounded-md border border-input bg-transparent p-1"
|
|
/>
|
|
<BufferedInput
|
|
value={strValue}
|
|
onCommit={handleCommit}
|
|
disabled={readOnly}
|
|
placeholder="#000000"
|
|
className="max-w-[10rem]"
|
|
/>
|
|
</div>
|
|
);
|
|
default:
|
|
return (
|
|
<BufferedInput
|
|
type={format === 'emailAddress' ? 'email' : format === 'uri' ? 'url' : 'text'}
|
|
value={strValue}
|
|
onCommit={handleCommit}
|
|
disabled={readOnly}
|
|
placeholder={placeholder ?? formatPlaceholder(format)}
|
|
minLength={minLength}
|
|
maxLength={maxLength}
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
|
|
function formatPlaceholder(format: StringFormat): string | undefined {
|
|
switch (format) {
|
|
case 'ipAddress':
|
|
return '192.168.1.1';
|
|
case 'ipNetwork':
|
|
return '192.168.1.0/24';
|
|
case 'socketAddress':
|
|
return '127.0.0.1:8080';
|
|
case 'emailAddress':
|
|
return 'user@example.com';
|
|
case 'uri':
|
|
return 'https://example.com';
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
interface SecretInputProps {
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
readOnly: boolean;
|
|
placeholder?: string;
|
|
minLength?: number;
|
|
maxLength?: number;
|
|
multiline: boolean;
|
|
}
|
|
|
|
function SecretInput({ value, onChange, readOnly, placeholder, minLength, maxLength, multiline }: SecretInputProps) {
|
|
const [visible, setVisible] = useState(false);
|
|
const [localValue, setLocalValue] = useState(() => (value === SECRET_MASK || value === '' ? '' : value));
|
|
const [isMasked, setIsMasked] = useState(() => value === SECRET_MASK);
|
|
|
|
/* eslint-disable react-hooks/set-state-in-effect */
|
|
useEffect(() => {
|
|
if (value === SECRET_MASK || value === '') {
|
|
setLocalValue('');
|
|
setIsMasked(value === SECRET_MASK);
|
|
} else {
|
|
setLocalValue(value);
|
|
setIsMasked(false);
|
|
}
|
|
}, [value]);
|
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
|
|
const handleLocalChange = (v: string) => {
|
|
setLocalValue(v);
|
|
setIsMasked(false);
|
|
};
|
|
|
|
const commit = () => {
|
|
if (isMasked && localValue === '') return;
|
|
if (localValue !== value) onChange(localValue);
|
|
};
|
|
|
|
const displayValue = isMasked && !visible ? '' : localValue;
|
|
const displayPlaceholder = isMasked ? SECRET_MASK : placeholder;
|
|
|
|
const toggleBtn = (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 shrink-0"
|
|
onClick={() => setVisible((p) => !p)}
|
|
tabIndex={-1}
|
|
>
|
|
{visible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
</Button>
|
|
);
|
|
|
|
if (multiline) {
|
|
return (
|
|
<div className="relative">
|
|
<Textarea
|
|
value={displayValue}
|
|
onChange={(e) => handleLocalChange(e.target.value)}
|
|
onBlur={commit}
|
|
disabled={readOnly}
|
|
placeholder={displayPlaceholder}
|
|
minLength={minLength}
|
|
maxLength={maxLength}
|
|
rows={4}
|
|
className={visible ? '' : 'tracking-widest'}
|
|
style={visible ? undefined : ({ WebkitTextSecurity: 'disc' } as React.CSSProperties)}
|
|
/>
|
|
<div className="absolute right-1 top-1">{toggleBtn}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center gap-1">
|
|
<Input
|
|
type={visible ? 'text' : 'password'}
|
|
value={displayValue}
|
|
onChange={(e) => handleLocalChange(e.target.value)}
|
|
onBlur={commit}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') commit();
|
|
}}
|
|
disabled={readOnly}
|
|
placeholder={displayPlaceholder}
|
|
minLength={minLength}
|
|
maxLength={maxLength}
|
|
className="flex-1"
|
|
/>
|
|
{toggleBtn}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface NumberFieldProps {
|
|
format: NumberFormat;
|
|
value: unknown;
|
|
onChange: (value: unknown) => void;
|
|
readOnly: boolean;
|
|
min?: number;
|
|
max?: number;
|
|
nullable?: boolean;
|
|
}
|
|
|
|
function NumberField({ format, value, onChange, readOnly, min, max, nullable }: NumberFieldProps) {
|
|
if (readOnly && format !== 'size' && format !== 'duration') {
|
|
const numValue = value as number | null | undefined;
|
|
if (numValue == null) {
|
|
return <span className="text-sm text-muted-foreground italic">Not set</span>;
|
|
}
|
|
return <span className="text-sm">{numValue.toLocaleString()}</span>;
|
|
}
|
|
|
|
switch (format) {
|
|
case 'size':
|
|
return <SizeInput value={value} onChange={onChange} readOnly={readOnly} nullable={nullable} />;
|
|
case 'duration':
|
|
return <DurationInput value={value} onChange={onChange} readOnly={readOnly} nullable={nullable} />;
|
|
default: {
|
|
const numValue = value as number | null | undefined;
|
|
const step = format === 'float' ? 0.01 : 1;
|
|
const minVal = format === 'unsignedInteger' ? Math.max(min ?? 0, 0) : min;
|
|
|
|
return (
|
|
<BufferedNumberInput
|
|
value={numValue ?? null}
|
|
format={format}
|
|
step={step}
|
|
min={minVal}
|
|
max={max}
|
|
nullable={nullable}
|
|
onCommit={(v) => onChange(v)}
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
interface BufferedNumberInputProps {
|
|
value: number | null;
|
|
format: NumberFormat;
|
|
step: number;
|
|
min?: number;
|
|
max?: number;
|
|
nullable?: boolean;
|
|
disabled?: boolean;
|
|
onCommit: (value: number | null | undefined) => void;
|
|
}
|
|
|
|
function BufferedNumberInput({
|
|
value,
|
|
format,
|
|
step,
|
|
min,
|
|
max,
|
|
nullable,
|
|
disabled,
|
|
onCommit,
|
|
}: BufferedNumberInputProps) {
|
|
const [local, setLocal] = useState<string>(value != null ? String(value) : '');
|
|
|
|
/* eslint-disable react-hooks/set-state-in-effect */
|
|
useEffect(() => {
|
|
setLocal(value != null ? String(value) : '');
|
|
}, [value]);
|
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
|
|
const commit = () => {
|
|
if (local === '') {
|
|
const next = nullable ? null : undefined;
|
|
if (next !== value) onCommit(next);
|
|
return;
|
|
}
|
|
const n = format === 'float' ? parseFloat(local) : parseInt(local, 10);
|
|
if (!isNaN(n) && n !== value) onCommit(n);
|
|
};
|
|
|
|
return (
|
|
<Input
|
|
type="number"
|
|
value={local}
|
|
onChange={(e) => setLocal(e.target.value)}
|
|
onBlur={commit}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') commit();
|
|
}}
|
|
disabled={disabled}
|
|
step={step}
|
|
min={min}
|
|
max={max}
|
|
/>
|
|
);
|
|
}
|
|
|
|
interface SizeInputProps {
|
|
value: unknown;
|
|
onChange: (value: unknown) => void;
|
|
readOnly: boolean;
|
|
nullable?: boolean;
|
|
}
|
|
|
|
function SizeInput({ value, onChange, readOnly, nullable }: SizeInputProps) {
|
|
const { t } = useTranslation();
|
|
if (readOnly) {
|
|
if (value == null)
|
|
return <span className="text-sm text-muted-foreground italic">{t('field.notSet', 'Not set')}</span>;
|
|
return <span className="text-sm">{formatSize(value as number)}</span>;
|
|
}
|
|
return <SizeInputEditable value={value} onChange={onChange} nullable={nullable} />;
|
|
}
|
|
|
|
function SizeInputEditable({ value, onChange, nullable }: Omit<SizeInputProps, 'readOnly'>) {
|
|
const { t } = useTranslation();
|
|
const isNull = value == null;
|
|
const initHuman = bytesToHuman(typeof value === 'number' ? value : 0);
|
|
const [unit, setUnit] = useState(initHuman.unit);
|
|
const [localStr, setLocalStr] = useState<string>(isNull ? '' : String(initHuman.value));
|
|
|
|
/* eslint-disable react-hooks/set-state-in-effect */
|
|
useEffect(() => {
|
|
const h = bytesToHuman(typeof value === 'number' ? value : 0);
|
|
setUnit(h.unit);
|
|
setLocalStr(value == null ? '' : String(h.value));
|
|
}, [value]);
|
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
|
|
const commit = () => {
|
|
if (localStr === '') {
|
|
onChange(nullable ? null : 0);
|
|
return;
|
|
}
|
|
const n = parseFloat(localStr);
|
|
if (isNaN(n)) return;
|
|
const next = humanToBytes(n, unit);
|
|
if (next !== value) onChange(next);
|
|
};
|
|
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
type="number"
|
|
value={localStr}
|
|
placeholder={nullable ? t('field.notSet', 'Not set') : '0'}
|
|
onChange={(e) => setLocalStr(e.target.value)}
|
|
onBlur={commit}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') commit();
|
|
}}
|
|
min={0}
|
|
step={1}
|
|
className="flex-1"
|
|
/>
|
|
<Select
|
|
value={unit}
|
|
onValueChange={(u) => {
|
|
setUnit(u);
|
|
if (localStr !== '') {
|
|
const n = parseFloat(localStr);
|
|
if (!isNaN(n)) onChange(humanToBytes(n, u));
|
|
}
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-24">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{SIZE_UNITS.map((u) => (
|
|
<SelectItem key={u} value={u}>
|
|
{u}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{nullable && !isNull && (
|
|
<Button type="button" variant="ghost" size="icon" className="h-9 w-9 shrink-0" onClick={() => onChange(null)}>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface DurationInputProps {
|
|
value: unknown;
|
|
onChange: (value: unknown) => void;
|
|
readOnly: boolean;
|
|
nullable?: boolean;
|
|
}
|
|
|
|
function durationLabel(t: (key: string, fallback: string) => string, unit: string): string {
|
|
switch (unit) {
|
|
case 'ms':
|
|
return t('duration.milliseconds', 'Milliseconds');
|
|
case 's':
|
|
return t('duration.seconds', 'Seconds');
|
|
case 'min':
|
|
return t('duration.minutes', 'Minutes');
|
|
case 'h':
|
|
return t('duration.hours', 'Hours');
|
|
case 'd':
|
|
return t('duration.days', 'Days');
|
|
default:
|
|
return unit;
|
|
}
|
|
}
|
|
|
|
function DurationInput({ value, onChange, readOnly, nullable }: DurationInputProps) {
|
|
const { t } = useTranslation();
|
|
if (readOnly) {
|
|
if (value == null)
|
|
return <span className="text-sm text-muted-foreground italic">{t('field.notSet', 'Not set')}</span>;
|
|
return <span className="text-sm">{formatDuration(value as number)}</span>;
|
|
}
|
|
return <DurationInputEditable value={value} onChange={onChange} nullable={nullable} />;
|
|
}
|
|
|
|
function DurationInputEditable({ value, onChange, nullable }: Omit<DurationInputProps, 'readOnly'>) {
|
|
const { t } = useTranslation();
|
|
const isNull = value == null;
|
|
const initHuman = msToHuman(typeof value === 'number' ? value : 0);
|
|
const [unit, setUnit] = useState(initHuman.unit);
|
|
const [localStr, setLocalStr] = useState<string>(isNull ? '' : String(initHuman.value));
|
|
|
|
/* eslint-disable react-hooks/set-state-in-effect */
|
|
useEffect(() => {
|
|
const h = msToHuman(typeof value === 'number' ? value : 0);
|
|
setUnit(h.unit);
|
|
setLocalStr(value == null ? '' : String(h.value));
|
|
}, [value]);
|
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
|
|
const commit = () => {
|
|
if (localStr === '') {
|
|
onChange(nullable ? null : 0);
|
|
return;
|
|
}
|
|
const n = parseFloat(localStr);
|
|
if (isNaN(n)) return;
|
|
const next = humanToMs(n, unit);
|
|
if (next !== value) onChange(next);
|
|
};
|
|
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
type="number"
|
|
value={localStr}
|
|
placeholder={nullable ? t('field.notSet', 'Not set') : '0'}
|
|
onChange={(e) => setLocalStr(e.target.value)}
|
|
onBlur={commit}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') commit();
|
|
}}
|
|
min={0}
|
|
step={1}
|
|
className="flex-1"
|
|
/>
|
|
<Select
|
|
value={unit}
|
|
onValueChange={(u) => {
|
|
setUnit(u);
|
|
if (localStr !== '') {
|
|
const n = parseFloat(localStr);
|
|
if (!isNaN(n)) onChange(humanToMs(n, u));
|
|
}
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-32">
|
|
<SelectValue>{durationLabel(t, unit)}</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{DURATION_UNITS.map((u) => (
|
|
<SelectItem key={u} value={u}>
|
|
{durationLabel(t, u)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{nullable && !isNull && (
|
|
<Button type="button" variant="ghost" size="icon" className="h-9 w-9 shrink-0" onClick={() => onChange(null)}>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface DateTimeFieldProps {
|
|
value: unknown;
|
|
onChange: (value: unknown) => void;
|
|
readOnly: boolean;
|
|
nullable?: boolean;
|
|
}
|
|
|
|
function DateTimeField({ value, onChange, readOnly, nullable }: DateTimeFieldProps) {
|
|
const { t } = useTranslation();
|
|
const strValue = typeof value === 'string' ? value : '';
|
|
|
|
const toLocal = (iso: string): string => {
|
|
if (!iso) return '';
|
|
try {
|
|
const d = new Date(iso);
|
|
if (isNaN(d.getTime())) return '';
|
|
return d.toISOString().slice(0, 16);
|
|
} catch {
|
|
return '';
|
|
}
|
|
};
|
|
|
|
const toIso = (local: string): string | null => {
|
|
if (!local) return nullable ? null : '';
|
|
return new Date(local).toISOString();
|
|
};
|
|
|
|
const [local, setLocal] = useState(() => toLocal(strValue));
|
|
|
|
/* eslint-disable react-hooks/set-state-in-effect */
|
|
useEffect(() => {
|
|
setLocal(toLocal(strValue));
|
|
}, [strValue]);
|
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
|
|
const commit = () => {
|
|
const iso = toIso(local);
|
|
if (iso !== strValue) onChange(iso);
|
|
};
|
|
|
|
if (readOnly) {
|
|
if (!strValue) {
|
|
return <span className="text-sm text-muted-foreground italic">{t('field.notSet', 'Not set')}</span>;
|
|
}
|
|
let formatted = strValue;
|
|
try {
|
|
const d = new Date(strValue);
|
|
if (!isNaN(d.getTime())) {
|
|
formatted = new Intl.DateTimeFormat(undefined, {
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short',
|
|
}).format(d);
|
|
}
|
|
// eslint-disable-next-line no-empty
|
|
} catch {}
|
|
return <span className="text-sm">{formatted}</span>;
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
type="datetime-local"
|
|
value={local}
|
|
onChange={(e) => setLocal(e.target.value)}
|
|
onBlur={commit}
|
|
className="flex-1"
|
|
/>
|
|
{nullable && strValue && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-9 w-9 shrink-0"
|
|
onClick={() => onChange(null)}
|
|
aria-label={t('common.clear', 'Clear')}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface BooleanFieldProps {
|
|
value: unknown;
|
|
onChange: (value: unknown) => void;
|
|
readOnly: boolean;
|
|
}
|
|
|
|
function BooleanField({ value, onChange, readOnly }: BooleanFieldProps) {
|
|
const { t } = useTranslation();
|
|
if (readOnly) {
|
|
return value === true ? (
|
|
<Check className="h-4 w-4 text-green-600" aria-label={t('common.yes', 'Yes')} />
|
|
) : (
|
|
<X className="h-4 w-4 text-red-500" aria-label={t('common.no', 'No')} />
|
|
);
|
|
}
|
|
return <Switch checked={value === true} onCheckedChange={(checked) => onChange(checked)} />;
|
|
}
|
|
|
|
interface EnumFieldProps {
|
|
enumName: string;
|
|
value: unknown;
|
|
onChange: (value: unknown) => void;
|
|
readOnly: boolean;
|
|
schema: Schema;
|
|
nullable?: boolean;
|
|
}
|
|
|
|
function EnumField({ enumName, value, onChange, readOnly, schema, nullable }: EnumFieldProps) {
|
|
const { t } = useTranslation();
|
|
const variants = schema.enums[enumName] ?? [];
|
|
const strValue = (value as string) ?? '';
|
|
|
|
const filteredVariants =
|
|
enumName === 'Permission'
|
|
? variants.filter((v) => useAccountStore.getState().permissions.includes(v.name))
|
|
: variants;
|
|
|
|
if (readOnly) {
|
|
if (!strValue) return <span className="text-sm text-muted-foreground italic">{t('field.notSet', 'Not set')}</span>;
|
|
const variant = filteredVariants.find((v) => v.name === strValue);
|
|
if (!variant) return <span className="text-sm">{strValue}</span>;
|
|
return (
|
|
<Badge variant="secondary" style={variant.color ? { backgroundColor: variant.color, color: '#fff' } : undefined}>
|
|
{variant.label}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
const options: ComboboxOption[] = filteredVariants.map((v) => ({
|
|
value: v.name,
|
|
label: v.label,
|
|
description: v.explanation || undefined,
|
|
keywords: [v.name],
|
|
marker: v.color ? (
|
|
<span className="inline-block h-2.5 w-2.5 rounded-full shrink-0" style={{ backgroundColor: v.color }} />
|
|
) : undefined,
|
|
}));
|
|
|
|
return (
|
|
<Combobox
|
|
options={options}
|
|
value={strValue}
|
|
onValueChange={(v) => onChange(v === '' ? null : v)}
|
|
placeholder={t('field.selectEllipsis', 'Select...')}
|
|
nullable={nullable}
|
|
/>
|
|
);
|
|
}
|
|
|
|
interface BlobFieldProps {
|
|
value: unknown;
|
|
onChange: (value: unknown) => void;
|
|
readOnly: boolean;
|
|
}
|
|
|
|
function BlobField({ value, onChange, readOnly }: BlobFieldProps) {
|
|
const { t } = useTranslation();
|
|
const blobId = typeof value === 'string' ? value : null;
|
|
const [content, setContent] = useState<string>('');
|
|
const [loading, setLoading] = useState(false);
|
|
const [loaded, setLoaded] = useState(false);
|
|
const [modified, setModified] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!blobId || loaded) return;
|
|
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
|
|
(async () => {
|
|
try {
|
|
const accountId = getAccountId('x:Blob');
|
|
const responses = await jmapGet('Blob', accountId, [blobId], ['data:asText']);
|
|
if (cancelled) return;
|
|
const result = responses[0]?.[1];
|
|
const list = result?.list as Array<Record<string, unknown>> | undefined;
|
|
if (list?.[0]) {
|
|
const data = list[0]['data:asText'];
|
|
if (typeof data === 'string') {
|
|
setContent(data);
|
|
}
|
|
}
|
|
// eslint-disable-next-line no-empty
|
|
} catch {
|
|
} finally {
|
|
if (!cancelled) {
|
|
setLoading(false);
|
|
setLoaded(true);
|
|
}
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [blobId, loaded]);
|
|
|
|
const handleContentChange = (text: string) => {
|
|
setContent(text);
|
|
setModified(true);
|
|
onChange(text);
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
{t('field.loadingContent', 'Loading content...')}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-1">
|
|
<BufferedTextarea
|
|
value={content}
|
|
onCommit={handleContentChange}
|
|
disabled={readOnly}
|
|
rows={8}
|
|
className="font-mono text-xs"
|
|
/>
|
|
{modified && (
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('field.contentModified', 'Content modified (will be saved as a new blob)')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface ObjectIdFieldProps {
|
|
objectName: string;
|
|
value: unknown;
|
|
onChange: (value: unknown) => void;
|
|
readOnly: boolean;
|
|
schema: Schema;
|
|
nullable?: boolean;
|
|
formField: FormField;
|
|
}
|
|
|
|
function isLinkedObjectEnterpriseGated(schema: Schema, objectName: string): boolean {
|
|
const resolved = resolveObject(schema, objectName);
|
|
if (!resolved) return false;
|
|
if (!resolved.enterprise) return false;
|
|
const edition = useAccountStore.getState().edition;
|
|
return edition === 'community';
|
|
}
|
|
|
|
function ObjectIdField({
|
|
objectName,
|
|
value,
|
|
onChange,
|
|
readOnly,
|
|
schema,
|
|
nullable,
|
|
formField,
|
|
}: ObjectIdFieldProps & { formField?: FormField }) {
|
|
const { t } = useTranslation();
|
|
void formField;
|
|
const strValue = (value as string) ?? '';
|
|
|
|
const { label: cheapLabel, loading: labelLoading } = useObjectLabel(objectName, strValue || null, schema);
|
|
|
|
const list = useObjectList(objectName, schema);
|
|
const noPermissionMessage = useNoPermissionMessage(schema, objectName);
|
|
|
|
const enterpriseGated = isLinkedObjectEnterpriseGated(schema, objectName);
|
|
const effectiveReadOnly = readOnly || enterpriseGated;
|
|
|
|
const fromList = list.options.find((o) => o.id === strValue)?.label;
|
|
const displayLabel = cheapLabel ?? fromList ?? (strValue || null);
|
|
|
|
return (
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{displayLabel && (
|
|
<Badge variant="secondary" className="gap-1 pr-1.5 text-sm">
|
|
{labelLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : displayLabel}
|
|
{!effectiveReadOnly && nullable && (
|
|
<button
|
|
type="button"
|
|
onClick={() => onChange(null)}
|
|
className="ml-1 rounded-full hover:bg-muted-foreground/20 p-0.5"
|
|
aria-label={t('common.clear', 'Clear')}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
)}
|
|
</Badge>
|
|
)}
|
|
{!displayLabel && effectiveReadOnly && (
|
|
<span className="text-sm text-muted-foreground">{t('field.none', 'None')}</span>
|
|
)}
|
|
{!effectiveReadOnly && (
|
|
<ObjectIdSearchPopover
|
|
options={list.options}
|
|
loading={list.loading}
|
|
ensureLoaded={list.ensureLoaded}
|
|
onSelect={(id) => onChange(id)}
|
|
disabledMessage={noPermissionMessage}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ObjectIdSearchPopover({
|
|
options,
|
|
loading,
|
|
ensureLoaded,
|
|
onSelect,
|
|
disabledMessage,
|
|
}: {
|
|
options: ObjectOption[];
|
|
loading: boolean;
|
|
ensureLoaded: () => void | Promise<void>;
|
|
onSelect: (id: string) => void;
|
|
disabledMessage?: string | null;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [open, setOpen] = useState(false);
|
|
const [tooltipOpen, setTooltipOpen] = useState(false);
|
|
|
|
const handleOpenChange = (next: boolean) => {
|
|
setOpen(next);
|
|
if (next) {
|
|
void ensureLoaded();
|
|
}
|
|
};
|
|
|
|
if (disabledMessage) {
|
|
return (
|
|
<TooltipProvider>
|
|
<Tooltip open={tooltipOpen} onOpenChange={setTooltipOpen}>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 cursor-not-allowed opacity-60"
|
|
aria-label={t('common.search', 'Search')}
|
|
aria-disabled="true"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setTooltipOpen(true);
|
|
}}
|
|
>
|
|
<Search className="h-4 w-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{disabledMessage}</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={handleOpenChange}>
|
|
<PopoverTrigger asChild>
|
|
<Button type="button" variant="ghost" size="icon" className="h-7 w-7" aria-label={t('common.search', 'Search')}>
|
|
<Search className="h-4 w-4" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="p-0 w-72" align="start">
|
|
<Command>
|
|
<CommandInput placeholder={t('common.searchPlaceholder', 'Search...')} />
|
|
<CommandList>
|
|
{loading && (
|
|
<div className="flex items-center justify-center py-6 text-sm text-muted-foreground">
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
{t('common.loading', 'Loading...')}
|
|
</div>
|
|
)}
|
|
{!loading && <CommandEmpty>{t('common.noResultsDot', 'No results.')}</CommandEmpty>}
|
|
<CommandGroup>
|
|
{options.map((opt) => (
|
|
<CommandItem
|
|
key={opt.id}
|
|
value={opt.id}
|
|
keywords={[opt.label]}
|
|
onSelect={() => {
|
|
onSelect(opt.id);
|
|
setOpen(false);
|
|
}}
|
|
>
|
|
{opt.label}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
interface RateFieldProps {
|
|
value: unknown;
|
|
onChange: (value: unknown) => void;
|
|
readOnly: boolean;
|
|
nullable?: boolean;
|
|
}
|
|
|
|
function RateField({ value, onChange, readOnly, nullable }: RateFieldProps) {
|
|
const { t } = useTranslation();
|
|
const rateValue = value as { count?: number; period?: number } | null | undefined;
|
|
const isNull = rateValue == null;
|
|
|
|
const count = typeof rateValue?.count === 'number' ? rateValue.count : 0;
|
|
const periodMs = typeof rateValue?.period === 'number' ? rateValue.period : 0;
|
|
const human = msToHuman(periodMs);
|
|
|
|
const [localCount, setLocalCount] = useState(String(count));
|
|
const [localPeriod, setLocalPeriod] = useState(String(human.value));
|
|
|
|
useEffect(() => {
|
|
setLocalCount(String(count));
|
|
}, [count]);
|
|
useEffect(() => {
|
|
setLocalPeriod(String(human.value));
|
|
}, [human.value]);
|
|
|
|
const commitCount = () => {
|
|
const n = parseInt(localCount, 10);
|
|
const newCount = !localCount || isNaN(n) ? 0 : n;
|
|
if (newCount !== count) onChange({ count: newCount, period: periodMs });
|
|
};
|
|
const commitPeriod = () => {
|
|
const n = parseFloat(localPeriod);
|
|
const newVal = !localPeriod || isNaN(n) ? 0 : n;
|
|
if (newVal !== human.value) onChange({ count, period: humanToMs(newVal, human.unit) });
|
|
};
|
|
|
|
if (isNull && readOnly) {
|
|
return <span className="text-sm text-muted-foreground italic">{t('field.notSet', 'Not set')}</span>;
|
|
}
|
|
|
|
if (isNull) {
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-muted-foreground italic">{t('field.notSet', 'Not set')}</span>
|
|
<Button type="button" variant="outline" size="sm" onClick={() => onChange({ count: 1, period: 1000 })}>
|
|
<Plus className="h-4 w-4 mr-1" />
|
|
{t('field.enable', 'Enable')}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
step={1}
|
|
value={localCount}
|
|
disabled={readOnly}
|
|
className="w-24"
|
|
aria-label={t('rate.count', 'Count')}
|
|
onChange={(e) => setLocalCount(e.target.value)}
|
|
onBlur={commitCount}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') commitCount();
|
|
}}
|
|
/>
|
|
<span className="text-sm text-muted-foreground shrink-0">{t('rate.per', 'per')}</span>
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
step={1}
|
|
value={localPeriod}
|
|
disabled={readOnly}
|
|
className="w-24"
|
|
aria-label={t('rate.periodValue', 'Period value')}
|
|
onChange={(e) => setLocalPeriod(e.target.value)}
|
|
onBlur={commitPeriod}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') commitPeriod();
|
|
}}
|
|
/>
|
|
<Select
|
|
value={human.unit}
|
|
onValueChange={(u) => onChange({ count, period: humanToMs(human.value, u) })}
|
|
disabled={readOnly}
|
|
>
|
|
<SelectTrigger className="w-32">
|
|
<SelectValue>{durationLabel(t, human.unit)}</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{DURATION_UNITS.map((u) => (
|
|
<SelectItem key={u} value={u}>
|
|
{durationLabel(t, u)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{nullable && !readOnly && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-9 w-9 shrink-0"
|
|
onClick={() => onChange(null)}
|
|
aria-label={t('common.clear', 'Clear')}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface EmbeddedObjectFieldProps {
|
|
objectName: string;
|
|
value: unknown;
|
|
onChange: (value: unknown) => void;
|
|
readOnly: boolean;
|
|
schema: Schema;
|
|
formField: FormField;
|
|
nullable?: boolean;
|
|
}
|
|
|
|
function EmbeddedObjectField({
|
|
objectName,
|
|
value,
|
|
onChange,
|
|
readOnly,
|
|
schema,
|
|
formField,
|
|
nullable,
|
|
}: EmbeddedObjectFieldProps) {
|
|
const { t } = useTranslation();
|
|
const objValue = (value as Record<string, unknown>) ?? {};
|
|
|
|
if (objectName === 'x:Expression') {
|
|
return (
|
|
<ExpressionEditor
|
|
value={{
|
|
match: (objValue.match as Record<string, { if: string; then: string }>) ?? {},
|
|
else: (objValue.else as string) ?? '',
|
|
}}
|
|
onChange={(v) => onChange(v)}
|
|
readOnly={readOnly}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (objectName === 'x:Rate') {
|
|
return <RateField value={value} onChange={onChange} readOnly={readOnly} nullable={nullable} />;
|
|
}
|
|
|
|
if (objectName === 'x:OtpAuth') {
|
|
return <OtpAuthField value={value} onChange={onChange} readOnly={readOnly} />;
|
|
}
|
|
|
|
const resolvedSchema = resolveSchema(schema, objectName);
|
|
|
|
if (!resolvedSchema) {
|
|
return (
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('field.unknownObjectType', 'Unknown object type: {{name}}', { name: objectName })}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
const handleFieldChange = (fieldName: string, fieldValue: unknown) => {
|
|
onChange({ ...objValue, [fieldName]: fieldValue });
|
|
};
|
|
|
|
if (resolvedSchema.type === 'single') {
|
|
const fields = resolvedSchema.fields;
|
|
const form = resolveVariantForm(schema, objectName, objectName, resolvedSchema.schemaName);
|
|
const formFields = form?.sections.flatMap((s) => s.fields) ?? [];
|
|
|
|
return (
|
|
<div className="rounded-md border p-4 space-y-4">
|
|
{formFields.map((ff) => {
|
|
const fieldDef = fields.properties[ff.name];
|
|
if (!fieldDef) return null;
|
|
if (fieldDef.update === 'serverSet') return null;
|
|
return (
|
|
<FieldWidget
|
|
key={ff.name}
|
|
field={fieldDef}
|
|
formField={ff}
|
|
value={objValue[ff.name]}
|
|
onChange={(v) => handleFieldChange(ff.name, v)}
|
|
readOnly={readOnly}
|
|
schema={schema}
|
|
/>
|
|
);
|
|
})}
|
|
{formFields.length === 0 &&
|
|
Object.entries(fields.properties)
|
|
.filter(([, fieldDef]) => fieldDef.update !== 'serverSet')
|
|
.map(([name, fieldDef]) => (
|
|
<FieldWidget
|
|
key={name}
|
|
field={fieldDef}
|
|
formField={{ name, label: name }}
|
|
value={objValue[name]}
|
|
onChange={(v) => handleFieldChange(name, v)}
|
|
readOnly={readOnly}
|
|
schema={schema}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const currentType = (objValue['@type'] as string) ?? resolvedSchema.variants[0]?.name ?? '';
|
|
const currentVariant = resolvedSchema.variants.find((v) => v.name === currentType);
|
|
const variantFields = currentVariant?.fields;
|
|
const variantForm = resolveVariantForm(schema, objectName, objectName, currentVariant?.schemaName);
|
|
const variantFormFields = variantForm?.sections.flatMap((s) => s.fields) ?? [];
|
|
|
|
const handleVariantChange = (newType: string) => {
|
|
const newObj = buildEmbeddedDefaults(schema, objectName, {}, newType);
|
|
onChange(newObj);
|
|
};
|
|
|
|
return (
|
|
<div className="rounded-md border p-4 space-y-4">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm font-medium">{formField.label ?? t('field.type', 'Type')}</Label>
|
|
<Select value={currentType} onValueChange={handleVariantChange} disabled={readOnly}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder={t('form.selectType', 'Select type...')} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{resolvedSchema.variants.map((v) => (
|
|
<SelectItem key={v.name} value={v.name}>
|
|
{v.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{variantFields &&
|
|
variantFormFields.length > 0 &&
|
|
variantFormFields.map((ff) => {
|
|
const fieldDef = variantFields.properties[ff.name];
|
|
if (!fieldDef) return null;
|
|
if (fieldDef.update === 'serverSet') return null;
|
|
return (
|
|
<FieldWidget
|
|
key={ff.name}
|
|
field={fieldDef}
|
|
formField={ff}
|
|
value={objValue[ff.name]}
|
|
onChange={(v) => handleFieldChange(ff.name, v)}
|
|
readOnly={readOnly}
|
|
schema={schema}
|
|
/>
|
|
);
|
|
})}
|
|
{variantFields &&
|
|
variantFormFields.length === 0 &&
|
|
Object.entries(variantFields.properties)
|
|
.filter(([, fieldDef]) => fieldDef.update !== 'serverSet')
|
|
.map(([name, fieldDef]) => (
|
|
<FieldWidget
|
|
key={name}
|
|
field={fieldDef}
|
|
formField={{ name, label: name }}
|
|
value={objValue[name]}
|
|
onChange={(v) => handleFieldChange(name, v)}
|
|
readOnly={readOnly}
|
|
schema={schema}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface ObjectListFieldProps {
|
|
objectName: string;
|
|
value: unknown;
|
|
onChange: (value: unknown) => void;
|
|
readOnly: boolean;
|
|
schema: Schema;
|
|
formField: FormField;
|
|
minItems?: number;
|
|
maxItems?: number;
|
|
}
|
|
|
|
function ObjectListField({
|
|
objectName,
|
|
value,
|
|
onChange,
|
|
readOnly,
|
|
schema,
|
|
formField,
|
|
minItems,
|
|
maxItems,
|
|
}: ObjectListFieldProps) {
|
|
const { t } = useTranslation();
|
|
const mapValue = (value as Record<string, Record<string, unknown>>) ?? {};
|
|
const entries = Object.entries(mapValue).sort(([a], [b]) => parseInt(a) - parseInt(b));
|
|
|
|
const resolvedSchema = resolveSchema(schema, objectName);
|
|
if (!resolvedSchema) {
|
|
return (
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('field.unknownObjectType', 'Unknown object type: {{name}}', { name: objectName })}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
const addItem = () => {
|
|
const nextIndex = entries.length > 0 ? Math.max(...entries.map(([k]) => parseInt(k))) + 1 : 0;
|
|
let defaults: Record<string, unknown> = {};
|
|
if (resolvedSchema.type === 'single' && resolvedSchema.fields.defaults) {
|
|
defaults = { ...resolvedSchema.fields.defaults };
|
|
} else if (resolvedSchema.type === 'multiple' && resolvedSchema.variants[0]) {
|
|
defaults = { '@type': resolvedSchema.variants[0].name };
|
|
if (resolvedSchema.variants[0].fields?.defaults) {
|
|
defaults = { ...defaults, ...resolvedSchema.variants[0].fields.defaults };
|
|
}
|
|
}
|
|
onChange({ ...mapValue, [String(nextIndex)]: defaults });
|
|
};
|
|
|
|
const removeItem = (key: string) => {
|
|
const copy = { ...mapValue };
|
|
delete copy[key];
|
|
onChange(copy);
|
|
};
|
|
|
|
const updateItem = (key: string, itemValue: Record<string, unknown>) => {
|
|
onChange({ ...mapValue, [key]: itemValue });
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{entries.map(([key, item]) => (
|
|
<div key={key} className="rounded-md border p-4 space-y-3">
|
|
{!readOnly && (
|
|
<div className="flex justify-end">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={() => removeItem(key)}
|
|
disabled={minItems != null && entries.length <= minItems}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
<EmbeddedObjectField
|
|
objectName={objectName}
|
|
value={item}
|
|
onChange={(v) => updateItem(key, v as Record<string, unknown>)}
|
|
readOnly={readOnly}
|
|
schema={schema}
|
|
formField={formField}
|
|
/>
|
|
</div>
|
|
))}
|
|
{!readOnly && (maxItems == null || entries.length < maxItems) && (
|
|
<Button type="button" variant="outline" size="sm" onClick={addItem} className="gap-1">
|
|
<Plus className="h-4 w-4" />
|
|
{t('field.addItem', 'Add item')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function arrayToSetObject(items: string[]): Record<string, true> {
|
|
const obj: Record<string, true> = {};
|
|
for (const i of items) {
|
|
obj[i] = true;
|
|
}
|
|
return obj;
|
|
}
|
|
|
|
interface SetFieldProps {
|
|
scalarType: ScalarType;
|
|
value: unknown;
|
|
onChange: (value: unknown) => void;
|
|
readOnly: boolean;
|
|
schema: Schema;
|
|
minItems?: number;
|
|
maxItems?: number;
|
|
}
|
|
|
|
function SetField({ scalarType, value, onChange, readOnly, schema, minItems, maxItems }: SetFieldProps) {
|
|
const setObj = (value as Record<string, true>) ?? {};
|
|
const items = Object.keys(setObj);
|
|
|
|
switch (scalarType.type) {
|
|
case 'string':
|
|
return (
|
|
<TagInput
|
|
items={items}
|
|
onChange={(newItems) => onChange(arrayToSetObject(newItems))}
|
|
readOnly={readOnly}
|
|
minItems={minItems}
|
|
maxItems={maxItems}
|
|
placeholder={formatPlaceholder(scalarType.format)}
|
|
/>
|
|
);
|
|
case 'enum':
|
|
return (
|
|
<EnumMultiSelect
|
|
enumName={scalarType.enumName}
|
|
items={items}
|
|
onChange={(newItems) => onChange(arrayToSetObject(newItems))}
|
|
readOnly={readOnly}
|
|
schema={schema}
|
|
minItems={minItems}
|
|
/>
|
|
);
|
|
case 'objectId':
|
|
return (
|
|
<ObjectIdMultiSelect
|
|
objectName={scalarType.objectName}
|
|
items={items}
|
|
onChange={(newItems) => onChange(arrayToSetObject(newItems))}
|
|
readOnly={readOnly}
|
|
schema={schema}
|
|
minItems={minItems}
|
|
/>
|
|
);
|
|
default: {
|
|
const _: never = scalarType;
|
|
void _;
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
interface TagInputProps {
|
|
items: string[];
|
|
onChange: (items: string[]) => void;
|
|
readOnly: boolean;
|
|
minItems?: number;
|
|
maxItems?: number;
|
|
placeholder?: string;
|
|
}
|
|
|
|
function TagInput({ items, onChange, readOnly, minItems, maxItems, placeholder }: TagInputProps) {
|
|
const { t } = useTranslation();
|
|
const [inputValue, setInputValue] = useState('');
|
|
|
|
const addTag = () => {
|
|
const trimmed = inputValue.trim();
|
|
if (!trimmed || items.includes(trimmed)) return;
|
|
if (maxItems != null && items.length >= maxItems) return;
|
|
onChange([...items, trimmed]);
|
|
setInputValue('');
|
|
};
|
|
|
|
const removeTag = (tag: string) => {
|
|
onChange(items.filter((i) => i !== tag));
|
|
};
|
|
|
|
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
addTag();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{items.map((item) => (
|
|
<Badge key={item} variant="secondary" className="gap-1 pr-1">
|
|
{item}
|
|
{!readOnly && (
|
|
<button
|
|
type="button"
|
|
onClick={() => removeTag(item)}
|
|
disabled={minItems != null && items.length <= minItems}
|
|
className="ml-1 rounded-full hover:bg-muted-foreground/20 p-0.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
)}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
{!readOnly && (
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
value={inputValue}
|
|
onChange={(e) => setInputValue(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={placeholder ?? t('field.typeAndPressEnter', 'Type and press Enter...')}
|
|
className="flex-1"
|
|
/>
|
|
<Button type="button" variant="outline" size="sm" onClick={addTag} disabled={!inputValue.trim()}>
|
|
<Plus className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface EnumMultiSelectProps {
|
|
enumName: string;
|
|
items: string[];
|
|
onChange: (items: string[]) => void;
|
|
readOnly: boolean;
|
|
schema: Schema;
|
|
minItems?: number;
|
|
}
|
|
|
|
function EnumMultiSelect({ enumName, items, onChange, readOnly, schema, minItems }: EnumMultiSelectProps) {
|
|
const { t } = useTranslation();
|
|
const variants = schema.enums[enumName] ?? [];
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
|
|
const toggle = (name: string) => {
|
|
if (items.includes(name)) {
|
|
if (minItems != null && items.length <= minItems) return;
|
|
onChange(items.filter((i) => i !== name));
|
|
} else {
|
|
onChange([...items, name]);
|
|
}
|
|
};
|
|
|
|
const renderCheckboxItem = (v: (typeof variants)[number]) => (
|
|
<div key={v.name} className="flex items-center gap-2">
|
|
<Checkbox
|
|
id={`enum-${enumName}-${v.name}`}
|
|
checked={items.includes(v.name)}
|
|
onCheckedChange={() => toggle(v.name)}
|
|
disabled={readOnly}
|
|
/>
|
|
<Label htmlFor={`enum-${enumName}-${v.name}`} className="text-sm font-normal cursor-pointer">
|
|
<div className="flex items-center gap-2">
|
|
{v.color && (
|
|
<span className="inline-block h-2.5 w-2.5 rounded-full shrink-0" style={{ backgroundColor: v.color }} />
|
|
)}
|
|
{v.label}
|
|
</div>
|
|
</Label>
|
|
{v.explanation && (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="text-xs text-muted-foreground cursor-help">(?)</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p className="max-w-xs text-xs">{v.explanation}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
if (variants.length > 10) {
|
|
const filtered = searchQuery
|
|
? variants.filter(
|
|
(v) =>
|
|
v.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
v.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
|
)
|
|
: variants;
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
{items.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{items.map((name) => {
|
|
const variant = variants.find((v) => v.name === name);
|
|
return (
|
|
<Badge key={name} variant="secondary" className="gap-1 pr-1">
|
|
{variant?.color && (
|
|
<span
|
|
className="inline-block h-2 w-2 rounded-full shrink-0"
|
|
style={{ backgroundColor: variant.color }}
|
|
/>
|
|
)}
|
|
{variant?.label ?? name}
|
|
{!readOnly && (
|
|
<button
|
|
type="button"
|
|
onClick={() => toggle(name)}
|
|
disabled={minItems != null && items.length <= minItems}
|
|
className="ml-1 rounded-full hover:bg-muted-foreground/20 p-0.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
)}
|
|
</Badge>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
<Popover open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button type="button" variant="outline" size="sm" className="gap-1" disabled={readOnly}>
|
|
<Search className="h-4 w-4" />
|
|
{t('field.selectOptions', 'Select options...')}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-72 p-0" align="start">
|
|
<div className="p-2 border-b">
|
|
<Input
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder={t('common.searchPlaceholder', 'Search...')}
|
|
className="h-8"
|
|
/>
|
|
</div>
|
|
<div className="max-h-60 overflow-y-auto p-2 space-y-1">
|
|
{filtered.length === 0 && (
|
|
<p className="text-sm text-muted-foreground text-center py-2">{t('field.noMatches', 'No matches')}</p>
|
|
)}
|
|
{filtered.map((v) => (
|
|
<div key={v.name} className="flex items-center gap-2 py-1">
|
|
<Checkbox
|
|
id={`enum-dd-${enumName}-${v.name}`}
|
|
checked={items.includes(v.name)}
|
|
onCheckedChange={() => toggle(v.name)}
|
|
disabled={readOnly}
|
|
/>
|
|
<Label
|
|
htmlFor={`enum-dd-${enumName}-${v.name}`}
|
|
className="text-sm font-normal cursor-pointer flex-1"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
{v.color && (
|
|
<span
|
|
className="inline-block h-2.5 w-2.5 rounded-full shrink-0"
|
|
style={{ backgroundColor: v.color }}
|
|
/>
|
|
)}
|
|
{v.label}
|
|
</div>
|
|
</Label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (variants.length > 3) {
|
|
return <div className="grid grid-cols-2 gap-2">{variants.map(renderCheckboxItem)}</div>;
|
|
}
|
|
|
|
return <div className="space-y-2">{variants.map(renderCheckboxItem)}</div>;
|
|
}
|
|
|
|
interface ObjectIdMultiSelectProps {
|
|
objectName: string;
|
|
items: string[];
|
|
onChange: (items: string[]) => void;
|
|
readOnly: boolean;
|
|
schema: Schema;
|
|
minItems?: number;
|
|
}
|
|
|
|
function ObjectIdMultiSelect({ objectName, items, onChange, readOnly, schema, minItems }: ObjectIdMultiSelectProps) {
|
|
const { t } = useTranslation();
|
|
const list = useObjectList(objectName, schema);
|
|
const noPermissionMessage = useNoPermissionMessage(schema, objectName);
|
|
const available = list.options.filter((o) => !items.includes(o.id));
|
|
|
|
return (
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{items.map((id) => (
|
|
<ObjectIdMultiSelectPill
|
|
key={id}
|
|
id={id}
|
|
objectName={objectName}
|
|
schema={schema}
|
|
listOptions={list.options}
|
|
readOnly={readOnly}
|
|
canRemove={minItems == null || items.length > minItems}
|
|
onRemove={() => {
|
|
if (minItems != null && items.length <= minItems) return;
|
|
onChange(items.filter((i) => i !== id));
|
|
}}
|
|
/>
|
|
))}
|
|
{!readOnly && (
|
|
<ObjectIdSearchPopover
|
|
options={available}
|
|
loading={list.loading}
|
|
ensureLoaded={list.ensureLoaded}
|
|
onSelect={(id) => onChange([...items, id])}
|
|
disabledMessage={noPermissionMessage}
|
|
/>
|
|
)}
|
|
{items.length === 0 && readOnly && (
|
|
<span className="text-sm text-muted-foreground">{t('field.none', 'None')}</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ObjectIdMultiSelectPill({
|
|
id,
|
|
objectName,
|
|
schema,
|
|
listOptions,
|
|
readOnly,
|
|
canRemove,
|
|
onRemove,
|
|
}: {
|
|
id: string;
|
|
objectName: string;
|
|
schema: Schema;
|
|
listOptions: ObjectOption[];
|
|
readOnly: boolean;
|
|
canRemove: boolean;
|
|
onRemove: () => void;
|
|
}) {
|
|
const fromList = listOptions.find((o) => o.id === id)?.label;
|
|
const { label: cheapLabel, loading } = useObjectLabel(objectName, fromList ? null : id, schema);
|
|
const display = fromList ?? cheapLabel ?? id;
|
|
return (
|
|
<Badge variant="secondary" className="gap-1 pr-1.5 text-sm">
|
|
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : display}
|
|
{!readOnly && (
|
|
<button
|
|
type="button"
|
|
onClick={onRemove}
|
|
className="ml-1 rounded-full hover:bg-muted-foreground/20 p-0.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
disabled={!canRemove}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
)}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
function MapEntryKeyLabel({ keyClass, keyValue, schema }: { keyClass: ScalarType; keyValue: string; schema: Schema }) {
|
|
if (keyClass.type === 'enum') {
|
|
const variants = schema.enums[keyClass.enumName] ?? [];
|
|
const variant = variants.find((v) => v.name === keyValue);
|
|
return <>{variant?.label ?? keyValue}</>;
|
|
}
|
|
if (keyClass.type === 'objectId') {
|
|
return <ObjectIdKeyLabel objectName={keyClass.objectName} keyValue={keyValue} schema={schema} />;
|
|
}
|
|
return <>{keyValue}</>;
|
|
}
|
|
|
|
function ObjectIdKeyLabel({ objectName, keyValue, schema }: { objectName: string; keyValue: string; schema: Schema }) {
|
|
const list = useObjectList(objectName, schema);
|
|
const fromList = list.options.find((o) => o.id === keyValue)?.label;
|
|
const { label: cheapLabel, loading } = useObjectLabel(objectName, fromList ? null : keyValue, schema);
|
|
const display = fromList ?? cheapLabel;
|
|
if (loading && !display) {
|
|
return <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />;
|
|
}
|
|
return <>{display ?? keyValue}</>;
|
|
}
|
|
|
|
interface MapFieldProps {
|
|
keyClass: ScalarType;
|
|
valueClass: MapValueType;
|
|
value: unknown;
|
|
onChange: (value: unknown) => void;
|
|
readOnly: boolean;
|
|
schema: Schema;
|
|
formField: FormField;
|
|
minItems?: number;
|
|
maxItems?: number;
|
|
}
|
|
|
|
function MapField({ keyClass, valueClass, value, onChange, readOnly, schema, minItems, maxItems }: MapFieldProps) {
|
|
const { t } = useTranslation();
|
|
const mapValue = (value as Record<string, unknown>) ?? {};
|
|
const entries = Object.entries(mapValue);
|
|
|
|
const [newKey, setNewKey] = useState('');
|
|
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
|
|
|
|
const addEntry = () => {
|
|
const key = newKey.trim();
|
|
if (!key || key in mapValue) return;
|
|
if (maxItems != null && entries.length >= maxItems) return;
|
|
|
|
let defaultValue: unknown = '';
|
|
if (valueClass.type === 'number') {
|
|
defaultValue = 0;
|
|
} else if (valueClass.type === 'object') {
|
|
defaultValue = {};
|
|
}
|
|
|
|
onChange({ ...mapValue, [key]: defaultValue });
|
|
setExpandedKeys((prev) => new Set(prev).add(key));
|
|
setNewKey('');
|
|
};
|
|
|
|
const removeEntry = (key: string) => {
|
|
const copy = { ...mapValue };
|
|
delete copy[key];
|
|
onChange(copy);
|
|
setExpandedKeys((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(key);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const updateValue = (key: string, val: unknown) => {
|
|
onChange({ ...mapValue, [key]: val });
|
|
};
|
|
|
|
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
addEntry();
|
|
}
|
|
};
|
|
|
|
const existingKeys = new Set(Object.keys(mapValue));
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{entries.length > 0 && (
|
|
<div className="space-y-3">
|
|
{entries.map(([key, val]) => (
|
|
<Collapsible key={key} defaultOpen={expandedKeys.has(key)}>
|
|
<div className="rounded-md border">
|
|
<div className="flex items-center">
|
|
<CollapsibleTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="flex flex-1 items-center gap-2 p-3 text-sm font-medium hover:bg-accent/50 rounded-t-md transition-colors [&[data-state=closed]>svg]:rotate-0 [&[data-state=open]>svg]:rotate-90"
|
|
>
|
|
<ChevronRight className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
|
<MapEntryKeyLabel keyClass={keyClass} keyValue={key} schema={schema} />
|
|
</button>
|
|
</CollapsibleTrigger>
|
|
{!readOnly && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 mr-2 shrink-0"
|
|
onClick={() => removeEntry(key)}
|
|
disabled={minItems != null && entries.length <= minItems}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<CollapsibleContent>
|
|
<div className="px-4 pb-4 pt-1">
|
|
{valueClass.type === 'object' ? (
|
|
<EmbeddedObjectField
|
|
objectName={valueClass.objectName}
|
|
value={val}
|
|
onChange={(v) => updateValue(key, v)}
|
|
readOnly={readOnly}
|
|
schema={schema}
|
|
formField={{ name: '', label: '' }}
|
|
/>
|
|
) : (
|
|
<MapValueWidget
|
|
valueClass={valueClass}
|
|
value={val}
|
|
onChange={(v) => updateValue(key, v)}
|
|
readOnly={readOnly}
|
|
schema={schema}
|
|
/>
|
|
)}
|
|
</div>
|
|
</CollapsibleContent>
|
|
</div>
|
|
</Collapsible>
|
|
))}
|
|
</div>
|
|
)}
|
|
{!readOnly && (maxItems == null || entries.length < maxItems) && (
|
|
<div className="flex items-center gap-2">
|
|
<MapKeyInput
|
|
keyClass={keyClass}
|
|
value={newKey}
|
|
onChange={setNewKey}
|
|
onKeyDown={handleKeyDown}
|
|
schema={schema}
|
|
existingKeys={existingKeys}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={addEntry}
|
|
disabled={!newKey.trim() || newKey.trim() in mapValue}
|
|
className="gap-1 shrink-0"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
{t('field.add', 'Add')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MapKeyObjectIdInput({
|
|
objectName,
|
|
value,
|
|
onChange,
|
|
schema,
|
|
existingKeys,
|
|
}: {
|
|
objectName: string;
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
schema: Schema;
|
|
existingKeys?: Set<string>;
|
|
}) {
|
|
const list = useObjectList(objectName, schema);
|
|
const noPermissionMessage = useNoPermissionMessage(schema, objectName);
|
|
const filteredOptions = existingKeys ? list.options.filter((o) => !existingKeys.has(o.id)) : list.options;
|
|
const fromList = list.options.find((o) => o.id === value)?.label;
|
|
const { label: cheapLabel, loading: labelLoading } = useObjectLabel(
|
|
objectName,
|
|
fromList ? null : value || null,
|
|
schema,
|
|
);
|
|
const display = fromList ?? cheapLabel;
|
|
|
|
return (
|
|
<div className="flex items-center gap-2 flex-1">
|
|
{value &&
|
|
(labelLoading ? (
|
|
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
|
) : (
|
|
<span className="text-sm">{display ?? value}</span>
|
|
))}
|
|
<ObjectIdSearchPopover
|
|
options={filteredOptions}
|
|
loading={list.loading}
|
|
ensureLoaded={list.ensureLoaded}
|
|
onSelect={onChange}
|
|
disabledMessage={noPermissionMessage}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface MapKeyInputProps {
|
|
keyClass: ScalarType;
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
onKeyDown: (e: KeyboardEvent<HTMLInputElement>) => void;
|
|
schema: Schema;
|
|
existingKeys: Set<string>;
|
|
}
|
|
|
|
function MapKeyInput({ keyClass, value, onChange, onKeyDown, schema, existingKeys }: MapKeyInputProps) {
|
|
const { t } = useTranslation();
|
|
switch (keyClass.type) {
|
|
case 'string':
|
|
return (
|
|
<BufferedInput
|
|
value={value}
|
|
onCommit={onChange}
|
|
onKeyDown={onKeyDown}
|
|
placeholder={formatPlaceholder(keyClass.format) ?? t('field.key', 'Key...')}
|
|
className="flex-1"
|
|
/>
|
|
);
|
|
case 'enum': {
|
|
const variants = (schema.enums[keyClass.enumName] ?? []).filter((v) => !existingKeys.has(v.name));
|
|
return (
|
|
<Select value={value} onValueChange={onChange}>
|
|
<SelectTrigger className="flex-1">
|
|
<SelectValue placeholder={t('field.selectKey', 'Select key...')} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{variants.map((v) => (
|
|
<SelectItem key={v.name} value={v.name}>
|
|
{v.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
}
|
|
case 'objectId':
|
|
return (
|
|
<MapKeyObjectIdInput
|
|
objectName={keyClass.objectName}
|
|
value={value}
|
|
onChange={onChange}
|
|
schema={schema}
|
|
existingKeys={existingKeys}
|
|
/>
|
|
);
|
|
default: {
|
|
const _: never = keyClass;
|
|
void _;
|
|
return (
|
|
<BufferedInput
|
|
value={value}
|
|
onCommit={onChange}
|
|
onKeyDown={onKeyDown}
|
|
placeholder={t('field.key', 'Key...')}
|
|
className="flex-1"
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
interface MapValueWidgetProps {
|
|
valueClass: MapValueType;
|
|
value: unknown;
|
|
onChange: (value: unknown) => void;
|
|
readOnly: boolean;
|
|
schema: Schema;
|
|
}
|
|
|
|
function MapValueWidget({ valueClass, value, onChange, readOnly, schema }: MapValueWidgetProps) {
|
|
const { t } = useTranslation();
|
|
switch (valueClass.type) {
|
|
case 'string': {
|
|
const strVal = (value as string) ?? '';
|
|
if (valueClass.format === 'text' || valueClass.format === 'html') {
|
|
return (
|
|
<BufferedTextarea
|
|
value={strVal}
|
|
onCommit={(v) => onChange(v)}
|
|
disabled={readOnly}
|
|
rows={2}
|
|
className={valueClass.format === 'html' ? 'font-mono text-xs' : ''}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<BufferedInput
|
|
type="text"
|
|
value={strVal}
|
|
onCommit={(v) => onChange(v)}
|
|
disabled={readOnly}
|
|
placeholder={formatPlaceholder(valueClass.format)}
|
|
/>
|
|
);
|
|
}
|
|
case 'number': {
|
|
const numVal = value as number | null | undefined;
|
|
return (
|
|
<BufferedNumberInput
|
|
value={numVal ?? null}
|
|
format={valueClass.format}
|
|
step={valueClass.format === 'float' ? 0.01 : 1}
|
|
min={valueClass.min}
|
|
max={valueClass.max}
|
|
nullable={true}
|
|
disabled={readOnly}
|
|
onCommit={(v) => onChange(v)}
|
|
/>
|
|
);
|
|
}
|
|
case 'enum': {
|
|
const variants = schema.enums[valueClass.enumName] ?? [];
|
|
const strVal = (value as string) ?? '';
|
|
return (
|
|
<Select value={strVal} onValueChange={onChange} disabled={readOnly}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder={t('field.selectEllipsis', 'Select...')} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{variants.map((v) => (
|
|
<SelectItem key={v.name} value={v.name}>
|
|
{v.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
}
|
|
case 'object': {
|
|
return (
|
|
<EmbeddedObjectField
|
|
objectName={valueClass.objectName}
|
|
value={value}
|
|
onChange={onChange}
|
|
readOnly={readOnly}
|
|
schema={schema}
|
|
formField={{ name: '', label: '' }}
|
|
/>
|
|
);
|
|
}
|
|
default: {
|
|
const _: never = valueClass;
|
|
void _;
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
export default FieldWidget;
|