485 lines
16 KiB
TypeScript
485 lines
16 KiB
TypeScript
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
|
*
|
|
* 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<string, unknown> }
|
|
| { kind: 'submitting'; variant: ObjectVariant }
|
|
| {
|
|
kind: 'result';
|
|
variant: ObjectVariant;
|
|
props: Record<string, unknown> | null;
|
|
inputData: Record<string, unknown>;
|
|
}
|
|
| { 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<ActionState>({ 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<string, ObjectVariant[]>();
|
|
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<string, unknown>) => {
|
|
if (!schema || !resolved) return;
|
|
setState({ kind: 'submitting', variant });
|
|
|
|
const fields = variant.schemaName ? schema.fields[variant.schemaName] : null;
|
|
const payload: Record<string, unknown> = { '@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<string, unknown> = {};
|
|
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 (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h1 className="text-xl font-semibold">{title}</h1>
|
|
{subtitle && <p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>}
|
|
</div>
|
|
<div className="space-y-6">
|
|
{Array.from(groups.entries()).map(([group, groupVariants]) => (
|
|
<div key={group}>
|
|
<h3 className="text-sm font-medium text-muted-foreground mb-2">{group}</h3>
|
|
<div className="grid gap-2 sm:grid-cols-2">
|
|
{groupVariants.map((v) => {
|
|
const colonIdx = v.label.indexOf(':');
|
|
const actionName = colonIdx > 0 ? v.label.slice(colonIdx + 1).trim() : v.label;
|
|
return (
|
|
<Button
|
|
key={v.name}
|
|
variant="outline"
|
|
className="justify-between h-auto py-3 px-4"
|
|
disabled={!canCreate}
|
|
onClick={() => handlePickVariant(v)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Zap className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
<span>{actionName}</span>
|
|
</div>
|
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
|
</Button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (state.kind === 'form') {
|
|
return (
|
|
<ActionFormView
|
|
schema={schema}
|
|
variant={state.variant}
|
|
formData={state.formData}
|
|
onSubmit={(data) => handleSubmitForm(state.variant, data)}
|
|
onBack={() => setState({ kind: 'pick' })}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (state.kind === 'submitting') {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center gap-3 py-16">
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
<p className="text-muted-foreground">
|
|
{t('actions.executing', 'Executing {{label}}...', { label: state.variant.label })}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (state.kind === 'result') {
|
|
return (
|
|
<ActionResultView
|
|
schema={schema}
|
|
variant={state.variant}
|
|
props={state.props}
|
|
inputData={state.inputData}
|
|
onNewAction={() => setState({ kind: 'pick' })}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (state.kind === 'error') {
|
|
return (
|
|
<div className="mx-auto max-w-xl space-y-4 pt-8">
|
|
<Card>
|
|
<CardContent className="p-6 space-y-4">
|
|
<div className="flex items-center gap-3">
|
|
<CircleAlert className="h-5 w-5 text-destructive shrink-0" />
|
|
<div>
|
|
<p className="font-medium">{t('actions.failedTitle', 'Action failed')}</p>
|
|
<p className="text-sm text-muted-foreground mt-1">{state.error}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={() => setState({ kind: 'pick' })}>
|
|
{t('actions.backToActions', 'Back to actions')}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function executeAction(
|
|
schema: Schema,
|
|
objectName: string,
|
|
variantName: string,
|
|
formData: Record<string, unknown>,
|
|
setState: React.Dispatch<React.SetStateAction<ActionState>>,
|
|
) {
|
|
const variant = { name: variantName, label: variantName } as ObjectVariant;
|
|
setState({ kind: 'submitting', variant });
|
|
|
|
try {
|
|
const accountId = getAccountId(objectName);
|
|
const payload: Record<string, unknown> = { '@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<string, unknown> = {};
|
|
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<string, unknown>;
|
|
onSubmit: (data: Record<string, unknown>) => void;
|
|
onBack: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [formData, setFormData] = useState<Record<string, unknown>>(initialData);
|
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
|
|
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<string, string> = {};
|
|
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 (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" size="sm" onClick={onBack}>
|
|
{t('common.back', 'Back')}
|
|
</Button>
|
|
<h1 className="text-xl font-semibold">{variant.label}</h1>
|
|
</div>
|
|
|
|
{mutableSections.map((section, si) => (
|
|
<Card key={si}>
|
|
{section.title && (
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-base">{section.title}</CardTitle>
|
|
</CardHeader>
|
|
)}
|
|
<CardContent className={section.title ? 'pt-0' : ''}>
|
|
<div className="space-y-4">
|
|
{section.fields.map((ff) => {
|
|
const def = fields.properties[ff.name];
|
|
if (!def) return null;
|
|
return (
|
|
<FieldWidget
|
|
key={ff.name}
|
|
formField={ff}
|
|
field={def}
|
|
value={formData[ff.name]}
|
|
onChange={(v) => handleChange(ff.name, v)}
|
|
readOnly={false}
|
|
schema={schema}
|
|
error={errors[ff.name]}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="outline" onClick={onBack}>
|
|
{t('common.cancel', 'Cancel')}
|
|
</Button>
|
|
<Button onClick={handleSubmit}>
|
|
<Zap className="mr-2 h-4 w-4" />
|
|
{t('actions.execute', 'Execute')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ActionResultView({
|
|
schema,
|
|
variant,
|
|
props,
|
|
inputData,
|
|
onNewAction,
|
|
}: {
|
|
schema: Schema;
|
|
variant: ObjectVariant;
|
|
props: Record<string, unknown> | null;
|
|
inputData: Record<string, unknown>;
|
|
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 (
|
|
<div className="space-y-6 pt-4">
|
|
<div className="flex items-center gap-3">
|
|
<CircleCheck className="h-5 w-5 text-emerald-500 shrink-0" />
|
|
<div>
|
|
<p className="font-medium">{variant.label}</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('actions.executedSuccess', 'Action executed successfully.')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{hasData && variant.schemaName && (
|
|
<DynamicView
|
|
schema={schema}
|
|
objectName={variant.schemaName}
|
|
viewName={variant.schemaName}
|
|
data={mergedData}
|
|
visibleFields={visibleFields}
|
|
/>
|
|
)}
|
|
|
|
{!hasData && (
|
|
<Card>
|
|
<CardContent className="p-6 text-sm text-muted-foreground">
|
|
{t('actions.noAdditionalData', 'No additional data returned.')}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<Button onClick={onNewAction}>
|
|
<Zap className="mr-2 h-4 w-4" />
|
|
{t('actions.executeAnother', 'Execute another action')}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|