1118 lines
38 KiB
TypeScript
1118 lines
38 KiB
TypeScript
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
import { useNavigate, useBlocker } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from '@/components/ui/alert-dialog';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
} from '@/components/ui/dialog';
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
import { ArrowLeft, Save, Trash2, Loader2 } from 'lucide-react';
|
|
|
|
import { useSchemaStore } from '@/stores/schemaStore';
|
|
import { useAccountStore } from '@/stores/accountStore';
|
|
import { usePermissions } from '@/hooks/usePermissions';
|
|
import {
|
|
resolveObject,
|
|
resolveSchema,
|
|
resolveList,
|
|
resolveForm,
|
|
resolveVariantForm,
|
|
buildCreateDefaults,
|
|
buildEmbeddedDefaults,
|
|
} from '@/lib/schemaResolver';
|
|
import { jmapGet, jmapSet, jmapRequest, getAccountId } from '@/services/jmap/client';
|
|
import { calculateJmapPatch } from '@/lib/jmapPatch';
|
|
import { friendlySetError, validationErrorMessage } from '@/lib/jmapErrors';
|
|
import { coerceLabel } from '@/lib/objectOptions';
|
|
import { SECRET_MASK } from '@/lib/jmapUtils';
|
|
import { toast } from '@/hooks/use-toast';
|
|
import { logFormChange } from '@/lib/debug';
|
|
import { FieldWidget } from '@/components/forms/FieldWidget';
|
|
|
|
import type { Field, Fields, Form, FormField, Schema } from '@/types/schema';
|
|
import type { JmapSetResponse, JmapSetError, JmapMethodCall } from '@/types/jmap';
|
|
|
|
interface DynamicFormProps {
|
|
viewName: string;
|
|
objectId: string | null;
|
|
}
|
|
|
|
export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const schema = useSchemaStore((s) => s.schema);
|
|
const viewToSection = useSchemaStore((s) => s.viewToSection);
|
|
const edition = useAccountStore((s) => s.edition);
|
|
const { canUpdateObject, canDestroyObject } = usePermissions();
|
|
|
|
const resolved = useMemo(() => {
|
|
if (!schema) return null;
|
|
const obj = resolveObject(schema, viewName);
|
|
if (!obj) return null;
|
|
const sch = resolveSchema(schema, obj.objectName);
|
|
if (!sch) return null;
|
|
const list = resolveList(schema, viewName, obj.objectName);
|
|
return { obj, sch, list };
|
|
}, [schema, viewName]);
|
|
|
|
const isCreate = objectId === null;
|
|
const isSingleton = resolved?.obj.objectType.type === 'singleton';
|
|
|
|
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
|
const [originalData, setOriginalData] = useState<Record<string, unknown>>({});
|
|
const [loading, setLoading] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [generalError, setGeneralError] = useState<string | null>(null);
|
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
|
|
|
const [selectedVariant, setSelectedVariant] = useState<string>('');
|
|
|
|
const [serverCreatedProps, setServerCreatedProps] = useState<Record<string, unknown> | null>(null);
|
|
const [createdObjectId, setCreatedObjectId] = useState<string | null>(null);
|
|
|
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
|
|
|
const currentFields = useMemo((): Fields | null => {
|
|
if (!resolved) return null;
|
|
const { sch } = resolved;
|
|
let fields: Fields | null;
|
|
if (sch.type === 'single') {
|
|
fields = sch.fields;
|
|
} else {
|
|
if (!selectedVariant) return null;
|
|
const variant = sch.variants.find((v) => v.name === selectedVariant);
|
|
fields = variant?.fields ?? null;
|
|
}
|
|
if (!fields || !schema) return fields;
|
|
const canGet = (prefix: string) => useAccountStore.getState().hasObjectPermission(prefix, 'Get');
|
|
const filtered: Record<string, Field> = {};
|
|
for (const [name, field] of Object.entries(fields.properties)) {
|
|
if (fieldReferencesInaccessibleObject(field, schema, canGet)) continue;
|
|
filtered[name] = field;
|
|
}
|
|
return { ...fields, properties: filtered };
|
|
}, [resolved, selectedVariant, schema]);
|
|
|
|
const currentForm = useMemo((): Form | null => {
|
|
if (!schema || !resolved) return null;
|
|
const { obj, sch } = resolved;
|
|
if (sch.type === 'single') {
|
|
return resolveForm(schema, viewName, obj.objectName, sch.schemaName);
|
|
}
|
|
if (!selectedVariant) return null;
|
|
const variant = sch.variants.find((v) => v.name === selectedVariant);
|
|
return resolveVariantForm(schema, viewName, obj.objectName, variant?.schemaName);
|
|
}, [schema, resolved, viewName, selectedVariant]);
|
|
|
|
const parentForm = useMemo((): Form | null => {
|
|
if (!schema || !resolved) return null;
|
|
const { obj, sch } = resolved;
|
|
if (sch.type !== 'multiple') return null;
|
|
return schema.forms[viewName] ?? schema.forms[obj.objectName] ?? null;
|
|
}, [schema, resolved, viewName]);
|
|
|
|
const fetchProperties = useMemo((): string[] => {
|
|
if (!schema || !resolved) return ['id'];
|
|
const { obj, sch } = resolved;
|
|
const set = new Set<string>(['id']);
|
|
|
|
const collectFromForm = (form: { sections: { fields: { name: string }[] }[] } | null | undefined) => {
|
|
if (!form) return;
|
|
for (const section of form.sections) {
|
|
for (const ff of section.fields) {
|
|
if (ff.name && ff.name !== '@type') set.add(ff.name);
|
|
}
|
|
}
|
|
};
|
|
|
|
if (sch.type === 'multiple') {
|
|
set.add('@type');
|
|
collectFromForm(schema.forms[viewName] ?? schema.forms[obj.objectName]);
|
|
for (const variant of sch.variants) {
|
|
if (variant.schemaName) collectFromForm(schema.forms[variant.schemaName]);
|
|
}
|
|
} else {
|
|
const singleForm = resolveForm(schema, viewName, obj.objectName, sch.schemaName);
|
|
if (singleForm) {
|
|
collectFromForm(singleForm);
|
|
} else {
|
|
for (const name of Object.keys(sch.fields.properties)) set.add(name);
|
|
}
|
|
}
|
|
return Array.from(set);
|
|
}, [schema, resolved, viewName]);
|
|
|
|
useEffect(() => {
|
|
if (!schema || !resolved) return;
|
|
const { obj, sch } = resolved;
|
|
|
|
if (isCreate) {
|
|
const staticFilters = resolved.list?.filtersStatic;
|
|
if (sch.type === 'multiple') {
|
|
const variantFromFilter = typeof staticFilters?.['@type'] === 'string' ? staticFilters['@type'] : undefined;
|
|
const initialVariant = variantFromFilter ?? sch.variants[0]?.name ?? '';
|
|
setSelectedVariant(initialVariant);
|
|
const defaults = buildCreateDefaults(schema, obj, sch, initialVariant, staticFilters);
|
|
setFormData(defaults);
|
|
setOriginalData(defaults);
|
|
} else {
|
|
const defaults = buildCreateDefaults(schema, obj, sch, undefined, staticFilters);
|
|
setFormData(defaults);
|
|
setOriginalData(defaults);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const ctrl = new AbortController();
|
|
setLoading(true);
|
|
|
|
(async () => {
|
|
try {
|
|
const accountId = getAccountId(obj.objectName);
|
|
const ids = isSingleton ? ['singleton'] : [objectId];
|
|
const responses = await jmapGet(obj.objectName, accountId, ids, fetchProperties, ctrl.signal);
|
|
if (ctrl.signal.aborted) return;
|
|
|
|
const result = responses[0]?.[1];
|
|
const list = result?.list as Array<Record<string, unknown>> | undefined;
|
|
if (list?.[0]) {
|
|
const data = list[0];
|
|
setFormData({ ...data });
|
|
setOriginalData({ ...data });
|
|
|
|
if (sch.type === 'multiple') {
|
|
const variantType = (data['@type'] as string) ?? sch.variants[0]?.name ?? '';
|
|
setSelectedVariant(variantType);
|
|
}
|
|
} else {
|
|
setGeneralError(t('form.objectNotFound', 'Object not found.'));
|
|
}
|
|
} catch (err) {
|
|
if (ctrl.signal.aborted) return;
|
|
if (err instanceof DOMException && err.name === 'AbortError') return;
|
|
setGeneralError(err instanceof Error ? err.message : t('form.failedToLoadData', 'Failed to load data.'));
|
|
} finally {
|
|
if (!ctrl.signal.aborted) setLoading(false);
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
ctrl.abort();
|
|
};
|
|
}, [schema, resolved, objectId, isCreate, isSingleton, fetchProperties, t]);
|
|
|
|
useEffect(() => {
|
|
logFormChange(`${viewName}${objectId ? ` (${objectId})` : ' (new)'}`, formData);
|
|
}, [formData, viewName, objectId]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
setGeneralError(null);
|
|
setFieldErrors({});
|
|
};
|
|
}, [viewName, objectId]);
|
|
|
|
const isDirty = useMemo(() => {
|
|
return JSON.stringify(formData) !== JSON.stringify(originalData);
|
|
}, [formData, originalData]);
|
|
|
|
useEffect(() => {
|
|
if (!isDirty) return;
|
|
const handler = (e: BeforeUnloadEvent) => {
|
|
e.preventDefault();
|
|
};
|
|
window.addEventListener('beforeunload', handler);
|
|
return () => window.removeEventListener('beforeunload', handler);
|
|
}, [isDirty]);
|
|
|
|
const blocker = useBlocker(isDirty && !saving);
|
|
|
|
const [pendingNavAfterCreate, setPendingNavAfterCreate] = useState(false);
|
|
useEffect(() => {
|
|
if (!pendingNavAfterCreate) return;
|
|
setPendingNavAfterCreate(false);
|
|
const section = viewToSection[viewName] ?? '';
|
|
navigate(`/${section}/${viewName}`);
|
|
}, [pendingNavAfterCreate, viewName, viewToSection, navigate]);
|
|
|
|
const handleFieldChange = useCallback((fieldName: string, value: unknown) => {
|
|
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
|
setFieldErrors((prev) => {
|
|
if (prev[fieldName]) {
|
|
const copy = { ...prev };
|
|
delete copy[fieldName];
|
|
return copy;
|
|
}
|
|
return prev;
|
|
});
|
|
}, []);
|
|
|
|
const handleVariantChange = useCallback(
|
|
(newVariant: string) => {
|
|
if (!schema || !resolved) return;
|
|
const { obj, sch } = resolved;
|
|
if (sch.type !== 'multiple') return;
|
|
|
|
setSelectedVariant(newVariant);
|
|
|
|
const newData = buildEmbeddedDefaults(schema, obj.objectName, {}, newVariant);
|
|
setFormData(newData);
|
|
},
|
|
[schema, resolved],
|
|
);
|
|
|
|
const collectBlobUploads = useCallback(
|
|
(
|
|
payload: Record<string, unknown>,
|
|
fields: Fields | null,
|
|
original?: Record<string, unknown>,
|
|
): { blobMethodCalls: JmapMethodCall[]; patchedPayload: Record<string, unknown> } => {
|
|
if (!fields) return { blobMethodCalls: [], patchedPayload: payload };
|
|
|
|
const blobMethodCalls: JmapMethodCall[] = [];
|
|
const patchedPayload = { ...payload };
|
|
let blobIndex = 0;
|
|
|
|
for (const [fieldName, fieldDef] of Object.entries(fields.properties)) {
|
|
if (fieldDef.type.type !== 'blobId') continue;
|
|
const value = payload[fieldName];
|
|
if (typeof value !== 'string' || !value) continue;
|
|
|
|
if (original && original[fieldName] === value) continue;
|
|
|
|
const tag = `temp-blob-${blobIndex}`;
|
|
const accountId = getAccountId(resolved!.obj.objectName);
|
|
blobMethodCalls.push([
|
|
'Blob/upload',
|
|
{
|
|
accountId,
|
|
create: {
|
|
[tag]: {
|
|
data: [{ 'data:asText': value }],
|
|
type: 'application/octet-stream',
|
|
},
|
|
},
|
|
},
|
|
`blob-${blobIndex}`,
|
|
]);
|
|
patchedPayload[fieldName] = `#${tag}`;
|
|
blobIndex++;
|
|
}
|
|
|
|
return { blobMethodCalls, patchedPayload };
|
|
},
|
|
[resolved],
|
|
);
|
|
|
|
const applySetError = useCallback(
|
|
(error: JmapSetError) => {
|
|
setGeneralError(friendlySetError(error));
|
|
|
|
const newFieldErrors: Record<string, string> = {};
|
|
const errorMsg = error.description ?? t('form.invalidValue', 'Invalid value.');
|
|
|
|
if (error.properties && error.properties.length > 0) {
|
|
for (const prop of error.properties) {
|
|
const topLevel = prop.split('/')[0];
|
|
if (topLevel && currentFields?.properties[topLevel]) {
|
|
newFieldErrors[topLevel] = errorMsg;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (error.validationErrors && error.validationErrors.length > 0) {
|
|
for (const ve of error.validationErrors) {
|
|
if (ve.property && currentFields?.properties[ve.property]) {
|
|
newFieldErrors[ve.property] = validationErrorMessage(ve);
|
|
} else if (ve.property) {
|
|
const detail = ve.value && ve.value.length > 0 ? ve.value : ve.type;
|
|
setGeneralError((prev) =>
|
|
prev ? `${prev}\n${ve.property}: ${detail}` : `${ve.property}: ${detail}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (Object.keys(newFieldErrors).length > 0) {
|
|
setFieldErrors(newFieldErrors);
|
|
}
|
|
},
|
|
[currentFields, t],
|
|
);
|
|
|
|
const validateRequired = useCallback(
|
|
(data: Record<string, unknown>, fields: Fields, visibleFields?: Set<string>): Record<string, string> => {
|
|
const errors: Record<string, string> = {};
|
|
|
|
for (const [fieldName, fieldDef] of Object.entries(fields.properties)) {
|
|
if (visibleFields && !visibleFields.has(fieldName)) continue;
|
|
if (fieldDef.update === 'serverSet') continue;
|
|
if (!isCreate && fieldDef.update === 'immutable') continue;
|
|
|
|
const fieldType = fieldDef.type;
|
|
const requiredEligible =
|
|
fieldType.type === 'string' ||
|
|
fieldType.type === 'number' ||
|
|
fieldType.type === 'utcDateTime' ||
|
|
fieldType.type === 'enum' ||
|
|
fieldType.type === 'blobId' ||
|
|
fieldType.type === 'objectId';
|
|
if (!requiredEligible) continue;
|
|
if ('nullable' in fieldType && fieldType.nullable) continue;
|
|
|
|
const value = data[fieldName];
|
|
const isEmpty = value === undefined || value === null || (typeof value === 'string' && value === '');
|
|
if (isEmpty) {
|
|
errors[fieldName] = t('form.required', 'This field is required.');
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
},
|
|
[isCreate, t],
|
|
);
|
|
|
|
const handleSave = useCallback(async () => {
|
|
if (!resolved) return;
|
|
const { obj, sch } = resolved;
|
|
|
|
setSaving(true);
|
|
setGeneralError(null);
|
|
setFieldErrors({});
|
|
|
|
if (currentFields) {
|
|
const visibleFields = new Set<string>();
|
|
for (const section of parentForm?.sections ?? []) {
|
|
for (const ff of section.fields) visibleFields.add(ff.name);
|
|
}
|
|
for (const section of currentForm?.sections ?? []) {
|
|
for (const ff of section.fields) visibleFields.add(ff.name);
|
|
}
|
|
const validationErrors = validateRequired(
|
|
formData,
|
|
currentFields,
|
|
visibleFields.size > 0 ? visibleFields : undefined,
|
|
);
|
|
if (Object.keys(validationErrors).length > 0) {
|
|
setFieldErrors(validationErrors);
|
|
setGeneralError(t('form.correctErrorsBelow', 'Please correct the errors below.'));
|
|
setSaving(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!isOtpAuthValid(formData)) {
|
|
setGeneralError(t('form.otpCodeRequired', 'Enter your current authenticator code to authorise this change.'));
|
|
setSaving(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const accountId = getAccountId(obj.objectName);
|
|
const section = viewToSection[viewName] ?? '';
|
|
|
|
if (isCreate) {
|
|
const createPayload: Record<string, unknown> = {};
|
|
|
|
const staticFilters = resolved.list?.filtersStatic;
|
|
if (staticFilters) {
|
|
const validKeys = new Set<string>(['@type']);
|
|
if (currentFields) {
|
|
for (const k of Object.keys(currentFields.properties)) validKeys.add(k);
|
|
}
|
|
for (const [k, v] of Object.entries(staticFilters)) {
|
|
if (validKeys.has(k)) createPayload[k] = v;
|
|
}
|
|
}
|
|
|
|
if (sch.type === 'multiple' && selectedVariant) {
|
|
createPayload['@type'] = selectedVariant;
|
|
}
|
|
|
|
if (currentFields) {
|
|
for (const [fieldName, fieldDef] of Object.entries(currentFields.properties)) {
|
|
if (fieldDef.update === 'serverSet') continue;
|
|
if (fieldName in formData) {
|
|
const isSecret =
|
|
fieldDef.type.type === 'string' &&
|
|
(fieldDef.type.format === 'secret' || fieldDef.type.format === 'secretText');
|
|
if (isSecret && formData[fieldName] === SECRET_MASK) continue;
|
|
createPayload[fieldName] = formData[fieldName];
|
|
}
|
|
}
|
|
}
|
|
|
|
const { blobMethodCalls, patchedPayload } = collectBlobUploads(createPayload, currentFields);
|
|
|
|
let responses;
|
|
if (blobMethodCalls.length > 0) {
|
|
const setCall: JmapMethodCall = [
|
|
`${obj.objectName}/set`,
|
|
{ accountId, create: { 'new-0': patchedPayload } },
|
|
'set-0',
|
|
];
|
|
responses = await jmapRequest([...blobMethodCalls, setCall]);
|
|
} else {
|
|
responses = await jmapSet(obj.objectName, accountId, {
|
|
create: { 'new-0': patchedPayload },
|
|
});
|
|
}
|
|
|
|
const setResponse = responses[responses.length - 1];
|
|
const setResult = setResponse[1] as unknown as JmapSetResponse;
|
|
|
|
if (setResult.created && setResult.created['new-0']) {
|
|
const createdData = setResult.created['new-0'];
|
|
const newId = createdData.id as string;
|
|
|
|
const noisyKeys = new Set(['id', 'blobId']);
|
|
const extraKeys = Object.keys(createdData).filter((k) => !noisyKeys.has(k));
|
|
if (extraKeys.length > 0) {
|
|
const extraProps: Record<string, unknown> = {};
|
|
for (const key of extraKeys) {
|
|
extraProps[key] = createdData[key];
|
|
}
|
|
setOriginalData({ ...formData });
|
|
setCreatedObjectId(newId);
|
|
setServerCreatedProps(extraProps);
|
|
} else {
|
|
toast({
|
|
title: t('form.createdSuccess', 'Created successfully'),
|
|
variant: 'success',
|
|
});
|
|
setOriginalData({ ...formData });
|
|
navigate(`/${section}/${viewName}`);
|
|
}
|
|
} else if (setResult.notCreated && setResult.notCreated['new-0']) {
|
|
applySetError(setResult.notCreated['new-0']);
|
|
}
|
|
} else {
|
|
const rawPatch = calculateJmapPatch(originalData, formData);
|
|
|
|
const patch: Record<string, unknown> = {};
|
|
for (const [patchKey, patchValue] of Object.entries(rawPatch)) {
|
|
const topLevelField = patchKey.split('/')[0];
|
|
const fieldDef = currentFields?.properties[topLevelField];
|
|
if (fieldDef && (fieldDef.update === 'immutable' || fieldDef.update === 'serverSet')) {
|
|
continue;
|
|
}
|
|
if (
|
|
fieldDef?.type.type === 'string' &&
|
|
(fieldDef.type.format === 'secret' || fieldDef.type.format === 'secretText') &&
|
|
patchValue === SECRET_MASK
|
|
) {
|
|
continue;
|
|
}
|
|
patch[patchKey] = patchValue;
|
|
}
|
|
|
|
if (Object.keys(patch).length === 0) {
|
|
toast({
|
|
title: t('form.noChangesToSave', 'No changes to save'),
|
|
variant: 'default',
|
|
});
|
|
return;
|
|
}
|
|
|
|
const blobMethodCalls: JmapMethodCall[] = [];
|
|
let blobIndex = 0;
|
|
const finalPatch = { ...patch };
|
|
|
|
for (const [patchKey, patchValue] of Object.entries(patch)) {
|
|
const topLevelField = patchKey.split('/')[0];
|
|
const fieldDef = currentFields?.properties[topLevelField];
|
|
if (fieldDef?.type.type !== 'blobId') continue;
|
|
if (typeof patchValue !== 'string') continue;
|
|
|
|
const tag = `temp-blob-${blobIndex}`;
|
|
const accountId = getAccountId(obj.objectName);
|
|
blobMethodCalls.push([
|
|
'Blob/upload',
|
|
{
|
|
accountId,
|
|
create: {
|
|
[tag]: {
|
|
data: [{ 'data:asText': patchValue }],
|
|
type: 'application/octet-stream',
|
|
},
|
|
},
|
|
},
|
|
`blob-${blobIndex}`,
|
|
]);
|
|
finalPatch[patchKey] = `#${tag}`;
|
|
blobIndex++;
|
|
}
|
|
|
|
const updateId = isSingleton ? 'singleton' : objectId!;
|
|
let responses;
|
|
|
|
if (blobMethodCalls.length > 0) {
|
|
const setCall: JmapMethodCall = [
|
|
`${obj.objectName}/set`,
|
|
{ accountId, update: { [updateId]: finalPatch } },
|
|
'set-0',
|
|
];
|
|
responses = await jmapRequest([...blobMethodCalls, setCall]);
|
|
} else {
|
|
responses = await jmapSet(obj.objectName, accountId, {
|
|
update: { [updateId]: finalPatch },
|
|
});
|
|
}
|
|
|
|
const setResponse = responses[responses.length - 1];
|
|
const setResult = setResponse[1] as unknown as JmapSetResponse;
|
|
|
|
if (setResult.updated && updateId in setResult.updated) {
|
|
toast({
|
|
title: t('form.savedSuccess', 'Saved successfully'),
|
|
variant: 'success',
|
|
});
|
|
|
|
if (isSingleton) {
|
|
const getResponses = await jmapGet(obj.objectName, accountId, ['singleton'], fetchProperties);
|
|
const getResult = getResponses[0]?.[1];
|
|
const list = getResult?.list as Array<Record<string, unknown>> | undefined;
|
|
if (list?.[0]) {
|
|
setFormData({ ...list[0] });
|
|
setOriginalData({ ...list[0] });
|
|
}
|
|
} else {
|
|
setOriginalData({ ...formData });
|
|
navigate(`/${section}/${viewName}`);
|
|
}
|
|
} else if (setResult.notUpdated && setResult.notUpdated[updateId]) {
|
|
applySetError(setResult.notUpdated[updateId]);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
setGeneralError(err instanceof Error ? err.message : t('form.failedToSave', 'Failed to save.'));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}, [
|
|
resolved,
|
|
currentFields,
|
|
currentForm,
|
|
parentForm,
|
|
isCreate,
|
|
viewName,
|
|
objectId,
|
|
formData,
|
|
originalData,
|
|
selectedVariant,
|
|
viewToSection,
|
|
isSingleton,
|
|
navigate,
|
|
collectBlobUploads,
|
|
applySetError,
|
|
validateRequired,
|
|
fetchProperties,
|
|
t,
|
|
]);
|
|
|
|
const handleDelete = useCallback(async () => {
|
|
if (!resolved || !objectId) return;
|
|
|
|
setSaving(true);
|
|
setGeneralError(null);
|
|
|
|
try {
|
|
const { obj } = resolved;
|
|
const accountId = getAccountId(obj.objectName);
|
|
const responses = await jmapSet(obj.objectName, accountId, { destroy: [objectId] });
|
|
const setResponse = responses[responses.length - 1];
|
|
const setResult = setResponse[1] as unknown as JmapSetResponse;
|
|
|
|
if (setResult.destroyed && setResult.destroyed.includes(objectId)) {
|
|
const list = resolveList(schema!, viewName, obj.objectName);
|
|
const label = list?.singularName ?? obj.objectType.description;
|
|
toast({
|
|
title: t('form.deletedSuccess', '{{name}} deleted successfully', {
|
|
name: label.charAt(0).toUpperCase() + label.slice(1),
|
|
}),
|
|
variant: 'success',
|
|
});
|
|
setOriginalData({ ...formData });
|
|
const section = viewToSection[viewName] ?? '';
|
|
navigate(`/${section}/${viewName}`);
|
|
} else if (setResult.notDestroyed && setResult.notDestroyed[objectId]) {
|
|
const error = setResult.notDestroyed[objectId];
|
|
setGeneralError(friendlySetError(error));
|
|
}
|
|
} catch (err) {
|
|
setGeneralError(err instanceof Error ? err.message : t('form.failedToDelete', 'Failed to delete.'));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}, [resolved, viewName, objectId, viewToSection, navigate, schema, formData, t]);
|
|
|
|
const canEdit = isCreate || canUpdateObject(viewName);
|
|
const canDelete = !isCreate && !isSingleton && canDestroyObject(viewName);
|
|
|
|
const titleForm = parentForm ?? currentForm;
|
|
|
|
const formTitle = useMemo(() => {
|
|
if (!resolved || !schema) return '';
|
|
const { obj } = resolved;
|
|
|
|
if (isSingleton) return titleForm?.title ?? obj.objectType.description;
|
|
|
|
const list = resolveList(schema, viewName, obj.objectName);
|
|
const name = list?.singularName ?? obj.objectType.description;
|
|
|
|
if (isCreate) return t('form.createTitle', 'Create {{name}}', { name });
|
|
|
|
const labelProp = list?.labelProperty;
|
|
if (labelProp) {
|
|
const value = coerceLabel(formData[labelProp], '');
|
|
if (value.length > 0) {
|
|
return t('form.editTitleWithValue', 'Edit {{name}}: {{value}}', { name, value });
|
|
}
|
|
}
|
|
return t('form.editTitle', 'Edit {{name}}', { name });
|
|
}, [resolved, schema, isCreate, isSingleton, formData, viewName, titleForm, t]);
|
|
|
|
const formSubtitle = useMemo(() => {
|
|
return titleForm?.subtitle;
|
|
}, [titleForm]);
|
|
|
|
if (!schema || !resolved) {
|
|
return (
|
|
<div className="flex items-center justify-center p-12 text-muted-foreground">
|
|
<Loader2 className="h-6 w-6 animate-spin mr-2" />
|
|
{t('form.loadingSchema', 'Loading schema...')}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center p-12 text-muted-foreground">
|
|
<Loader2 className="h-6 w-6 animate-spin mr-2" />
|
|
{t('form.loadingData', 'Loading data...')}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const { sch } = resolved;
|
|
const readOnly = !canEdit;
|
|
|
|
const combinedForm: Form | null = (() => {
|
|
if (sch.type !== 'multiple') return currentForm;
|
|
const parentSections = parentForm?.sections ?? [];
|
|
const variantSections = currentForm?.sections ?? [];
|
|
if (parentSections.length === 0 && variantSections.length === 0) return null;
|
|
return {
|
|
title: parentForm?.title ?? currentForm?.title,
|
|
subtitle: parentForm?.subtitle ?? currentForm?.subtitle,
|
|
sections: [...parentSections, ...variantSections],
|
|
};
|
|
})();
|
|
|
|
const sectionsToRender = buildSections(combinedForm, currentFields, isCreate, edition);
|
|
|
|
return (
|
|
<div className="space-y-6 max-w-4xl">
|
|
<div className="flex items-center gap-4">
|
|
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
|
<ArrowLeft className="h-5 w-5" />
|
|
</Button>
|
|
<div className="flex-1">
|
|
<h1 className="text-2xl font-semibold tracking-tight">{formTitle}</h1>
|
|
{formSubtitle && <p className="text-sm text-muted-foreground mt-1">{formSubtitle}</p>}
|
|
</div>
|
|
</div>
|
|
|
|
{generalError && (
|
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-4">
|
|
<p className="text-sm text-destructive">{generalError}</p>
|
|
</div>
|
|
)}
|
|
|
|
{sectionsToRender.map((section, sectionIdx) => (
|
|
<Card key={sectionIdx}>
|
|
{section.title && (
|
|
<CardHeader>
|
|
<CardTitle className="text-base">{section.title}</CardTitle>
|
|
</CardHeader>
|
|
)}
|
|
<CardContent className={section.title ? '' : 'pt-6'}>
|
|
<div className="space-y-6">
|
|
{section.fields.map((sf) => {
|
|
const { formField, field, visible, enterpriseDisabled } = sf;
|
|
if (!visible) return null;
|
|
|
|
if (formField.name === '@type' && sch.type === 'multiple') {
|
|
return (
|
|
<div key="@type" className="space-y-1.5">
|
|
<Label className="text-sm font-medium">{formField.label}</Label>
|
|
<Select value={selectedVariant} onValueChange={handleVariantChange} disabled={readOnly}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder={t('form.selectType', 'Select type...')} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{sch.variants.map((v) => (
|
|
<SelectItem key={v.name} value={v.name}>
|
|
{v.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const fieldValue = formData[formField.name];
|
|
const fieldError = fieldErrors[formField.name];
|
|
const fieldReadOnly =
|
|
readOnly ||
|
|
(field.update === 'immutable' && !isCreate) ||
|
|
field.update === 'serverSet' ||
|
|
enterpriseDisabled;
|
|
|
|
const widget = (
|
|
<FieldWidget
|
|
key={formField.name}
|
|
field={field}
|
|
formField={formField}
|
|
value={fieldValue}
|
|
onChange={(v) => handleFieldChange(formField.name, v)}
|
|
readOnly={fieldReadOnly}
|
|
error={fieldError}
|
|
schema={schema}
|
|
/>
|
|
);
|
|
|
|
if (enterpriseDisabled) {
|
|
return (
|
|
<TooltipProvider key={formField.name}>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div className="opacity-60">{widget}</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{t('enterprise.featureDisabled', 'This feature requires an Enterprise license.')}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
);
|
|
}
|
|
|
|
return widget;
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
|
|
<div className="flex items-center justify-between pt-2 pb-8">
|
|
<div>
|
|
{canDelete && (
|
|
<Button type="button" variant="destructive" disabled={saving} onClick={() => setDeleteConfirmOpen(true)}>
|
|
<Trash2 className="h-4 w-4 mr-2" />
|
|
{t('common.delete', 'Delete')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{isDirty && (
|
|
<span className="text-xs text-muted-foreground">{t('form.unsavedChangesLabel', 'Unsaved changes')}</span>
|
|
)}
|
|
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={saving}>
|
|
{t('common.cancel', 'Cancel')}
|
|
</Button>
|
|
{!readOnly && (
|
|
<Button type="button" onClick={handleSave} disabled={saving || (!isCreate && !isDirty)}>
|
|
{saving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
|
|
{isCreate ? t('common.create', 'Create') : t('common.save', 'Save')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<AlertDialog
|
|
open={blocker.state === 'blocked'}
|
|
onOpenChange={(open) => {
|
|
if (!open && blocker.state === 'blocked') blocker.reset?.();
|
|
}}
|
|
>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{t('form.unsavedChangesTitle', 'Unsaved changes')}</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t('form.unsavedChanges', 'You have unsaved changes. Are you sure you want to leave?')}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel onClick={() => blocker.reset?.()}>{t('form.stay', 'Stay')}</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={() => {
|
|
setOriginalData({ ...formData });
|
|
blocker.proceed?.();
|
|
}}
|
|
>
|
|
{t('form.leave', 'Leave')}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
<AlertDialog
|
|
open={deleteConfirmOpen}
|
|
onOpenChange={(open) => {
|
|
if (!open) setDeleteConfirmOpen(false);
|
|
}}
|
|
>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
{(() => {
|
|
const list = resolved ? resolveList(schema, viewName, resolved.obj.objectName) : null;
|
|
const label = (
|
|
list?.singularName ??
|
|
resolved?.obj.objectType.description ??
|
|
t('form.item', 'item')
|
|
).toLowerCase();
|
|
const titleLabel = label.charAt(0).toUpperCase() + label.slice(1);
|
|
return (
|
|
<>
|
|
<AlertDialogTitle>{t('form.deleteTitle', 'Delete {{name}}?', { name: titleLabel })}</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t(
|
|
'form.deleteDescription',
|
|
'This action cannot be undone. This will permanently delete this {{name}}.',
|
|
{ name: label },
|
|
)}
|
|
</AlertDialogDescription>
|
|
</>
|
|
);
|
|
})()}
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>{t('common.cancel', 'Cancel')}</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={() => {
|
|
setDeleteConfirmOpen(false);
|
|
handleDelete();
|
|
}}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
{t('common.delete', 'Delete')}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
<Dialog
|
|
open={serverCreatedProps !== null && createdObjectId !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setOriginalData({ ...formData });
|
|
setServerCreatedProps(null);
|
|
setPendingNavAfterCreate(true);
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{t('form.createdSuccess', 'Created successfully')}</DialogTitle>
|
|
<DialogDescription>
|
|
{t(
|
|
'form.createdServerGenerated',
|
|
'The following values were generated by the server. Please save them now, they may not be retrievable later.',
|
|
)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3 py-2">
|
|
{serverCreatedProps &&
|
|
(() => {
|
|
const labelByName = new Map<string, string>();
|
|
for (const section of parentForm?.sections ?? []) {
|
|
for (const ff of section.fields) labelByName.set(ff.name, ff.label);
|
|
}
|
|
for (const section of currentForm?.sections ?? []) {
|
|
for (const ff of section.fields) labelByName.set(ff.name, ff.label);
|
|
}
|
|
return Object.entries(serverCreatedProps).map(([key, value]) => (
|
|
<div key={key} className="flex flex-col gap-1">
|
|
<span className="text-sm font-medium">{labelByName.get(key) ?? key}</span>
|
|
<code className="rounded bg-muted p-2 text-sm break-all select-all">{String(value)}</code>
|
|
</div>
|
|
));
|
|
})()}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
onClick={() => {
|
|
setOriginalData({ ...formData });
|
|
setServerCreatedProps(null);
|
|
setPendingNavAfterCreate(true);
|
|
}}
|
|
>
|
|
{t('common.continue', 'Continue')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface RenderableField {
|
|
formField: FormField;
|
|
field: Field;
|
|
visible: boolean;
|
|
enterpriseDisabled: boolean;
|
|
}
|
|
|
|
interface RenderableSection {
|
|
title?: string;
|
|
fields: RenderableField[];
|
|
}
|
|
|
|
function buildSections(
|
|
form: Form | null,
|
|
fields: Fields | null,
|
|
isCreate: boolean,
|
|
edition: string,
|
|
): RenderableSection[] {
|
|
if (!form && !fields) return [];
|
|
|
|
if (!form) {
|
|
const allFields = Object.entries(fields!.properties)
|
|
.map(([name, field]) => buildRenderableField({ name, label: name }, field, isCreate, edition))
|
|
.filter((f): f is RenderableField => f !== null);
|
|
return [{ fields: allFields }];
|
|
}
|
|
|
|
return form.sections
|
|
.map((section) => {
|
|
const renderedFields = section.fields
|
|
.map((ff) => {
|
|
if (ff.name === '@type') {
|
|
const syntheticField: Field = {
|
|
description: '',
|
|
type: { type: 'string', format: 'string' },
|
|
update: 'mutable',
|
|
};
|
|
return {
|
|
formField: ff,
|
|
field: syntheticField,
|
|
visible: true,
|
|
enterpriseDisabled: false,
|
|
};
|
|
}
|
|
const field = fields?.properties[ff.name];
|
|
if (!field) return null;
|
|
return buildRenderableField(ff, field, isCreate, edition);
|
|
})
|
|
.filter((f): f is RenderableField => f !== null);
|
|
|
|
return {
|
|
title: section.title,
|
|
fields: renderedFields,
|
|
};
|
|
})
|
|
.filter((section) => section.fields.some((f) => f.visible));
|
|
}
|
|
|
|
function fieldReferencesInaccessibleObject(
|
|
field: Field,
|
|
schema: Schema,
|
|
canGet: (permissionPrefix: string) => boolean,
|
|
): boolean {
|
|
const isObjectAccessible = (objectName: string): boolean => {
|
|
const resolved = resolveObject(schema, objectName);
|
|
if (!resolved) return true;
|
|
return canGet(resolved.permissionPrefix);
|
|
};
|
|
|
|
const t = field.type;
|
|
switch (t.type) {
|
|
case 'objectId':
|
|
return !isObjectAccessible(t.objectName);
|
|
case 'object':
|
|
case 'objectList':
|
|
return !isObjectAccessible(t.objectName);
|
|
case 'set':
|
|
if (t.class.type === 'objectId') return !isObjectAccessible(t.class.objectName);
|
|
return false;
|
|
case 'map':
|
|
if (t.keyClass.type === 'objectId' && !isObjectAccessible(t.keyClass.objectName)) return true;
|
|
if (t.valueClass.type === 'object' && !isObjectAccessible(t.valueClass.objectName)) return true;
|
|
return false;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function buildRenderableField(
|
|
formField: FormField,
|
|
field: Field,
|
|
isCreate: boolean,
|
|
edition: string,
|
|
): RenderableField | null {
|
|
let visible = true;
|
|
let enterpriseDisabled = false;
|
|
|
|
if (isCreate && field.update === 'serverSet') {
|
|
visible = false;
|
|
}
|
|
|
|
if (field.enterprise) {
|
|
if (edition === 'oss') {
|
|
visible = false;
|
|
} else if (edition === 'community') {
|
|
enterpriseDisabled = true;
|
|
}
|
|
}
|
|
|
|
return { formField, field, visible, enterpriseDisabled };
|
|
}
|
|
|
|
function isOtpAuthValid(data: unknown): boolean {
|
|
if (data == null || typeof data !== 'object' || Array.isArray(data)) return true;
|
|
const obj = data as Record<string, unknown>;
|
|
|
|
for (const key of Object.keys(obj)) {
|
|
if (!isOtpAuthValid(obj[key])) return false;
|
|
}
|
|
|
|
const hasOtpUrl = 'otpUrl' in obj;
|
|
const hasOtpCode = 'otpCode' in obj;
|
|
if (!hasOtpUrl || !hasOtpCode) return true;
|
|
|
|
const otpUrl = obj.otpUrl;
|
|
const otpCode = obj.otpCode;
|
|
if (otpUrl == null || otpUrl === '') return true;
|
|
if (otpCode == null || otpCode === '' || otpCode === SECRET_MASK) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export default DynamicForm;
|