/* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ import { useState, useMemo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Zap, Loader2, ChevronRight, CircleCheck, CircleAlert } from 'lucide-react'; import i18n from '@/i18n'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { useSchemaStore } from '@/stores/schemaStore'; import { useAccountStore } from '@/stores/accountStore'; import { resolveObject, resolveSchema, resolveVariantForm } from '@/lib/schemaResolver'; import { SECRET_MASK } from '@/lib/jmapUtils'; import { jmapSet, getAccountId } from '@/services/jmap/client'; import { FieldWidget } from '@/components/forms/FieldWidget'; import { DynamicView } from '@/components/views/DynamicView'; import type { Schema, ObjectVariant } from '@/types/schema'; import type { JmapSetResponse } from '@/types/jmap'; type ActionState = | { kind: 'pick' } | { kind: 'form'; variant: ObjectVariant; formData: Record } | { kind: 'submitting'; variant: ObjectVariant } | { kind: 'result'; variant: ObjectVariant; props: Record | null; inputData: Record; } | { kind: 'error'; variant: ObjectVariant; error: string }; interface ActionPageProps { viewName: string; } export function ActionPage({ viewName }: ActionPageProps) { const { t } = useTranslation(); const schema = useSchemaStore((s) => s.schema); const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission); const hasPermission = useAccountStore((s) => s.hasPermission); const [state, setState] = useState({ kind: 'pick' }); const resolved = useMemo(() => { if (!schema) return null; return resolveObject(schema, viewName); }, [schema, viewName]); const sch = useMemo(() => { if (!schema || !resolved) return null; return resolveSchema(schema, resolved.objectName); }, [schema, resolved]); const canCreate = resolved ? hasObjectPermission(resolved.permissionPrefix, 'Create') : false; const groups = useMemo(() => { const variants = sch?.type === 'multiple' ? sch.variants : []; const map = new Map(); const otherLabel = t('actions.otherGroup', 'Other'); for (const v of variants) { if (!hasPermission(`action${v.name}`)) continue; const colonIdx = v.label.indexOf(':'); const group = colonIdx > 0 ? v.label.slice(0, colonIdx).trim() : otherLabel; let list = map.get(group); if (!list) { list = []; map.set(group, list); } list.push(v); } return map; }, [sch, hasPermission, t]); const list = schema?.lists[viewName] ?? schema?.lists[resolved?.objectName ?? '']; const title = list?.title ?? t('actions.title', 'Actions'); const subtitle = list?.subtitle ?? resolved?.objectType?.description; const handlePickVariant = useCallback( (variant: ObjectVariant) => { if (!schema || !resolved) return; if (!variant.schemaName) { executeAction(schema, resolved.objectName, variant.name, {}, setState); } else { const fieldsDef = schema.fields[variant.schemaName!]; const defaults = fieldsDef?.defaults ? { ...fieldsDef.defaults } : {}; setState({ kind: 'form', variant, formData: defaults }); } }, [schema, resolved], ); const handleSubmitForm = useCallback( async (variant: ObjectVariant, formData: Record) => { if (!schema || !resolved) return; setState({ kind: 'submitting', variant }); const fields = variant.schemaName ? schema.fields[variant.schemaName] : null; const payload: Record = { '@type': variant.name }; if (fields) { for (const [name, def] of Object.entries(fields.properties)) { if (def.update === 'serverSet') continue; if (name in formData) { const isSecret = def.type.type === 'string' && (def.type.format === 'secret' || def.type.format === 'secretText'); if (isSecret && formData[name] === SECRET_MASK) continue; payload[name] = formData[name]; } } } try { const accountId = getAccountId(resolved.objectName); const responses = await jmapSet(resolved.objectName, accountId, { create: { 'action-0': payload }, }); const setResponse = responses[responses.length - 1]; const setResult = setResponse[1] as unknown as JmapSetResponse; if (setResult.created && setResult.created['action-0']) { const created = setResult.created['action-0']; const noisyKeys = new Set(['id', 'blobId']); const extraKeys = Object.keys(created).filter((k) => !noisyKeys.has(k)); if (extraKeys.length > 0) { const extra: Record = {}; for (const k of extraKeys) extra[k] = created[k]; setState({ kind: 'result', variant, props: extra, inputData: formData }); } else { setState({ kind: 'result', variant, props: null, inputData: formData }); } } else if (setResult.notCreated && setResult.notCreated['action-0']) { const err = setResult.notCreated['action-0']; setState({ kind: 'error', variant, error: (err as { description?: string }).description ?? t('actions.actionFailed', 'Action failed.'), }); } else { setState({ kind: 'result', variant, props: null, inputData: formData }); } } catch (e) { setState({ kind: 'error', variant, error: e instanceof Error ? e.message : t('actions.actionFailed', 'Action failed.'), }); } }, [schema, resolved, t], ); if (!schema || !resolved || !sch) return null; if (state.kind === 'pick') { return (

{title}

{subtitle &&

{subtitle}

}
{Array.from(groups.entries()).map(([group, groupVariants]) => (

{group}

{groupVariants.map((v) => { const colonIdx = v.label.indexOf(':'); const actionName = colonIdx > 0 ? v.label.slice(colonIdx + 1).trim() : v.label; return ( ); })}
))}
); } if (state.kind === 'form') { return ( handleSubmitForm(state.variant, data)} onBack={() => setState({ kind: 'pick' })} /> ); } if (state.kind === 'submitting') { return (

{t('actions.executing', 'Executing {{label}}...', { label: state.variant.label })}

); } if (state.kind === 'result') { return ( setState({ kind: 'pick' })} /> ); } if (state.kind === 'error') { return (

{t('actions.failedTitle', 'Action failed')}

{state.error}

); } return null; } async function executeAction( schema: Schema, objectName: string, variantName: string, formData: Record, setState: React.Dispatch>, ) { const variant = { name: variantName, label: variantName } as ObjectVariant; setState({ kind: 'submitting', variant }); try { const accountId = getAccountId(objectName); const payload: Record = { '@type': variantName, ...formData }; const responses = await jmapSet(objectName, accountId, { create: { 'action-0': payload }, }); const setResponse = responses[responses.length - 1]; const setResult = setResponse[1] as unknown as JmapSetResponse; const sch = resolveSchema(schema, objectName); const realVariant = sch?.type === 'multiple' ? (sch.variants.find((v) => v.name === variantName) ?? variant) : variant; if (setResult.created && setResult.created['action-0']) { const created = setResult.created['action-0']; const noisyKeys = new Set(['id', 'blobId']); const extraKeys = Object.keys(created).filter((k) => !noisyKeys.has(k)); if (extraKeys.length > 0) { const extra: Record = {}; for (const k of extraKeys) extra[k] = created[k]; setState({ kind: 'result', variant: realVariant, props: extra, inputData: formData }); } else { setState({ kind: 'result', variant: realVariant, props: null, inputData: formData }); } } else if (setResult.notCreated && setResult.notCreated['action-0']) { const err = setResult.notCreated['action-0']; setState({ kind: 'error', variant: realVariant, error: (err as { description?: string }).description ?? i18n.t('actions.actionFailed', 'Action failed.'), }); } else { setState({ kind: 'result', variant: realVariant, props: null, inputData: formData }); } } catch (e) { setState({ kind: 'error', variant, error: e instanceof Error ? e.message : i18n.t('actions.actionFailed', 'Action failed.'), }); } } function ActionFormView({ schema, variant, formData: initialData, onSubmit, onBack, }: { schema: Schema; variant: ObjectVariant; formData: Record; onSubmit: (data: Record) => void; onBack: () => void; }) { const { t } = useTranslation(); const [formData, setFormData] = useState>(initialData); const [errors, setErrors] = useState>({}); if (!variant.schemaName) return null; const fields = schema.fields[variant.schemaName]; const form = resolveVariantForm(schema, '', '', variant.schemaName); if (!fields || !form) return null; const mutableSections = form.sections .map((section) => ({ ...section, fields: section.fields.filter((ff) => { const def = fields.properties[ff.name]; return def && def.update === 'mutable'; }), })) .filter((s) => s.fields.length > 0); const handleChange = (fieldName: string, value: unknown) => { setFormData((prev) => ({ ...prev, [fieldName]: value })); setErrors((prev) => { const copy = { ...prev }; delete copy[fieldName]; return copy; }); }; const handleSubmit = () => { const newErrors: Record = {}; for (const section of mutableSections) { for (const ff of section.fields) { const def = fields.properties[ff.name]; if (!def) continue; const isRequired = (def.type.type === 'string' || def.type.type === 'number' || def.type.type === 'enum' || def.type.type === 'objectId') && !('nullable' in def.type && def.type.nullable); if (isRequired) { const val = formData[ff.name]; if (val === undefined || val === null || val === '') { newErrors[ff.name] = t('form.required', 'This field is required.'); } } } } if (Object.keys(newErrors).length > 0) { setErrors(newErrors); return; } onSubmit(formData); }; return (

{variant.label}

{mutableSections.map((section, si) => ( {section.title && ( {section.title} )}
{section.fields.map((ff) => { const def = fields.properties[ff.name]; if (!def) return null; return ( handleChange(ff.name, v)} readOnly={false} schema={schema} error={errors[ff.name]} /> ); })}
))}
); } function ActionResultView({ schema, variant, props, inputData, onNewAction, }: { schema: Schema; variant: ObjectVariant; props: Record | null; inputData: Record; onNewAction: () => void; }) { const { t } = useTranslation(); const mergedData = useMemo(() => ({ ...inputData, ...(props ?? {}) }), [inputData, props]); const hasData = variant.schemaName && Object.keys(mergedData).length > 0; const visibleFields = useMemo(() => new Set(Object.keys(mergedData)), [mergedData]); return (

{variant.label}

{t('actions.executedSuccess', 'Action executed successfully.')}

{hasData && variant.schemaName && ( )} {!hasData && ( {t('actions.noAdditionalData', 'No additional data returned.')} )}
); }