Rewrite the actions page #128
No files matched your search
@@ -1,274 +1,132 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
/**
|
||||
* First-run setup: the server's `x:Bootstrap` form, one section per step,
|
||||
* saved in one update at the end. Shown instead of the console while the
|
||||
* signed-in account can see nothing but this.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ArrowLeft, ArrowRight, Check, Copy, Loader2, Rocket } from 'lucide-react';
|
||||
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import { DefaultLogo } from '@/components/common/Logo';
|
||||
import { FieldWidget } from '@/components/forms/FieldWidget';
|
||||
import { FormEditionContext } from '@/components/forms/FormEditionContext';
|
||||
import { DefaultLogo } from '@/components/common/Logo';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { resolveObject, resolveSchema, resolveForm, buildCreateDefaults, deepMerge } from '@/lib/schemaResolver';
|
||||
import { calculateJmapPatch } from '@/lib/jmapPatch';
|
||||
import { jmapGet, jmapSet, getAccountId } from '@/services/jmap/client';
|
||||
import { friendlySetError, validationErrorMessage } from '@/lib/jmapErrors';
|
||||
import { buildCreateDefaults, deepMerge, resolveForm, resolveObject, resolveSchema } from '@/lib/schemaResolver';
|
||||
import { getAccountId, jmapGet, jmapSet } from '@/services/jmap/client';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import {
|
||||
credentials,
|
||||
fieldLabels,
|
||||
firstMissing,
|
||||
missingOnPage,
|
||||
readSetResult,
|
||||
refusalByField,
|
||||
wizardPages,
|
||||
} from './wizard';
|
||||
|
||||
import type { Field, Fields, Form, FormField } from '@/types/schema';
|
||||
import type { JmapSetError, JmapSetResponse } from '@/types/jmap';
|
||||
const VIEW = 'x:Bootstrap';
|
||||
|
||||
const BOOTSTRAP_VIEW = 'x:Bootstrap';
|
||||
/** The singleton's id; there is only one bootstrap object. */
|
||||
const SINGLETON = 'singleton';
|
||||
|
||||
interface RenderableField {
|
||||
formField: FormField;
|
||||
field: Field;
|
||||
/** The bootstrap object, its fields and its form, once the schema is in. */
|
||||
function useBootstrapSchema() {
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
return useMemo(() => {
|
||||
const object = schema && resolveObject(schema, VIEW);
|
||||
const resolved = object && resolveSchema(schema, object.objectName);
|
||||
if (!schema || !object || resolved?.type !== 'single') return null;
|
||||
const form = resolveForm(schema, VIEW, object.objectName, resolved.schemaName);
|
||||
return { schema, object, resolved, form, pages: wizardPages(resolved.fields, form) };
|
||||
}, [schema]);
|
||||
}
|
||||
|
||||
export function BootstrapWizard() {
|
||||
const { t } = useTranslation();
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
const setup = useBootstrapSchema();
|
||||
|
||||
const resolved = useMemo(() => {
|
||||
if (!schema) return null;
|
||||
const obj = resolveObject(schema, BOOTSTRAP_VIEW);
|
||||
if (!obj) return null;
|
||||
const sch = resolveSchema(schema, obj.objectName);
|
||||
if (!sch || sch.type !== 'single') return null;
|
||||
const form = resolveForm(schema, BOOTSTRAP_VIEW, obj.objectName, sch.schemaName);
|
||||
return { obj, sch, fields: sch.fields, form };
|
||||
}, [schema]);
|
||||
|
||||
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
||||
const [originalData, setOriginalData] = useState<Record<string, unknown>>({});
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
const [step, setStep] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
const [generalError, setGeneralError] = useState<string | null>(null);
|
||||
const [successExtras, setSuccessExtras] = useState<Record<string, unknown> | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [generated, setGenerated] = useState<Record<string, unknown> | null>(null);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
const sections = useMemo((): { title?: string; subtitle?: string; fields: RenderableField[] }[] => {
|
||||
if (!resolved) return [];
|
||||
const { fields, form } = resolved;
|
||||
if (!form) return [];
|
||||
return form.sections
|
||||
.map((section) => {
|
||||
const renderableFields: RenderableField[] = [];
|
||||
for (const ff of section.fields) {
|
||||
const field = fields.properties[ff.name];
|
||||
if (!field) continue;
|
||||
if (field.update === 'serverSet') continue;
|
||||
if (field.enterprise) continue;
|
||||
renderableFields.push({ formField: ff, field });
|
||||
}
|
||||
return { title: section.title, fields: renderableFields };
|
||||
})
|
||||
.filter((s) => s.fields.length > 0);
|
||||
}, [resolved]);
|
||||
|
||||
const fetchProperties = useMemo((): string[] => {
|
||||
if (!resolved) return ['id'];
|
||||
const set = new Set<string>(['id']);
|
||||
for (const section of sections) {
|
||||
for (const rf of section.fields) set.add(rf.formField.name);
|
||||
}
|
||||
return Array.from(set);
|
||||
}, [resolved, sections]);
|
||||
|
||||
// Start from the schema's defaults, with whatever the server already has on top.
|
||||
useEffect(() => {
|
||||
if (!schema || !resolved) return;
|
||||
const { obj, sch } = resolved;
|
||||
|
||||
const ctrl = new AbortController();
|
||||
|
||||
(async () => {
|
||||
if (!setup) return;
|
||||
const { schema, object, resolved, pages } = setup;
|
||||
const properties = ['id', ...new Set(pages.flatMap((p) => p.fields.map((f) => f.formField.name)))];
|
||||
const abort = new AbortController();
|
||||
void (async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const accountId = getAccountId(obj.objectName);
|
||||
const responses = await jmapGet(obj.objectName, accountId, ['singleton'], fetchProperties, ctrl.signal);
|
||||
if (ctrl.signal.aborted) return;
|
||||
|
||||
const server = (responses[0]?.[1]?.list as Array<Record<string, unknown>> | undefined)?.[0] ?? {};
|
||||
const defaults = buildCreateDefaults(schema, obj, sch);
|
||||
const seeded = deepMerge(defaults, server);
|
||||
setFormData(seeded);
|
||||
setOriginalData({});
|
||||
} catch (err) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||
setGeneralError(
|
||||
err instanceof Error ? err.message : t('bootstrap.failedToLoad', 'Failed to load bootstrap state.'),
|
||||
const responses = await jmapGet(
|
||||
object.objectName,
|
||||
getAccountId(object.objectName),
|
||||
[SINGLETON],
|
||||
properties,
|
||||
abort.signal,
|
||||
);
|
||||
if (abort.signal.aborted) return;
|
||||
const current = (responses[0]?.[1]?.list as Record<string, unknown>[] | undefined)?.[0] ?? {};
|
||||
setValues(deepMerge(buildCreateDefaults(schema, object, resolved), current));
|
||||
} catch (err) {
|
||||
if (abort.signal.aborted || (err instanceof DOMException && err.name === 'AbortError')) return;
|
||||
setError(err instanceof Error ? err.message : t('bootstrap.failedToLoad', 'Failed to load bootstrap state.'));
|
||||
} finally {
|
||||
if (!ctrl.signal.aborted) setLoading(false);
|
||||
if (!abort.signal.aborted) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => abort.abort();
|
||||
}, [setup, t]);
|
||||
|
||||
return () => {
|
||||
ctrl.abort();
|
||||
};
|
||||
}, [schema, resolved, fetchProperties, t]);
|
||||
|
||||
const handleFieldChange = useCallback((fieldName: string, value: unknown) => {
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
const change = useCallback((name: string, value: unknown) => {
|
||||
setValues((prev) => ({ ...prev, [name]: value }));
|
||||
setFieldErrors((prev) => {
|
||||
if (!prev[fieldName]) return prev;
|
||||
const copy = { ...prev };
|
||||
delete copy[fieldName];
|
||||
return copy;
|
||||
if (!(name in prev)) return prev;
|
||||
const { [name]: _cleared, ...rest } = prev;
|
||||
void _cleared;
|
||||
return rest;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const validateSectionRequired = useCallback(
|
||||
(sectionFields: RenderableField[], data: Record<string, unknown>): Record<string, string> => {
|
||||
const errors: Record<string, string> = {};
|
||||
for (const { formField, field } of sectionFields) {
|
||||
if (field.update === 'serverSet' || field.update === 'immutable') continue;
|
||||
const fieldType = field.type;
|
||||
const eligible =
|
||||
fieldType.type === 'string' ||
|
||||
fieldType.type === 'number' ||
|
||||
fieldType.type === 'utcDateTime' ||
|
||||
fieldType.type === 'enum' ||
|
||||
fieldType.type === 'blobId' ||
|
||||
fieldType.type === 'objectId';
|
||||
if (!eligible) continue;
|
||||
if ('nullable' in fieldType && fieldType.nullable) continue;
|
||||
const value = data[formField.name];
|
||||
const isEmpty = value === undefined || value === null || (typeof value === 'string' && value === '');
|
||||
if (isEmpty) errors[formField.name] = t('form.required', 'This field is required.');
|
||||
}
|
||||
return errors;
|
||||
},
|
||||
[t],
|
||||
);
|
||||
const required = t('form.required', 'This field is required.');
|
||||
const correctBelow = () => t('form.correctErrorsBelow', 'Please correct the errors below.');
|
||||
|
||||
const applySetError = useCallback(
|
||||
(error: JmapSetError): number | null => {
|
||||
setGeneralError(friendlySetError(error));
|
||||
/** Shows errors and moves to the page that has the first one. */
|
||||
const showErrors = (errors: Record<string, string>, page: number | null) => {
|
||||
setFieldErrors(errors);
|
||||
if (page !== null) setStep(page);
|
||||
};
|
||||
|
||||
const fieldToPage = new Map<string, number>();
|
||||
sections.forEach((section, idx) => {
|
||||
for (const rf of section.fields) {
|
||||
if (!fieldToPage.has(rf.formField.name)) fieldToPage.set(rf.formField.name, idx);
|
||||
}
|
||||
});
|
||||
|
||||
const newErrors: Record<string, string> = {};
|
||||
let earliest: number | null = null;
|
||||
const record = (topLevel: string, msg: string) => {
|
||||
if (!fieldToPage.has(topLevel)) return;
|
||||
newErrors[topLevel] = msg;
|
||||
const idx = fieldToPage.get(topLevel)!;
|
||||
if (earliest === null || idx < earliest) earliest = idx;
|
||||
};
|
||||
|
||||
if (error.properties) {
|
||||
for (const prop of error.properties) {
|
||||
const top = prop.split('/')[0];
|
||||
record(top, error.description ?? t('form.invalidValue', 'Invalid value.'));
|
||||
}
|
||||
}
|
||||
if (error.validationErrors) {
|
||||
for (const ve of error.validationErrors) {
|
||||
const top = ve.property?.split('/')[0] ?? '';
|
||||
if (!top) continue;
|
||||
record(top, validationErrorMessage(ve));
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(newErrors).length > 0) setFieldErrors(newErrors);
|
||||
return earliest;
|
||||
},
|
||||
[sections, t],
|
||||
);
|
||||
|
||||
const canGoBack = currentPage > 0;
|
||||
const isLastPage = currentPage === sections.length - 1;
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
const current = sections[currentPage];
|
||||
if (!current) return;
|
||||
const errs = validateSectionRequired(current.fields, formData);
|
||||
if (Object.keys(errs).length > 0) {
|
||||
setFieldErrors(errs);
|
||||
setGeneralError(t('form.correctErrorsBelow', 'Please correct the errors below.'));
|
||||
return;
|
||||
}
|
||||
setGeneralError(null);
|
||||
const clearErrors = () => {
|
||||
setError(null);
|
||||
setFieldErrors({});
|
||||
setCurrentPage((p) => Math.min(p + 1, sections.length - 1));
|
||||
}, [currentPage, sections, formData, validateSectionRequired, t]);
|
||||
};
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
setGeneralError(null);
|
||||
setFieldErrors({});
|
||||
setCurrentPage((p) => Math.max(p - 1, 0));
|
||||
}, []);
|
||||
if (!setup) {
|
||||
return <Waiting>{t('form.loadingSchema', 'Loading schema...')}</Waiting>;
|
||||
}
|
||||
if (loading) {
|
||||
return <Waiting>{t('bootstrap.loadingSetup', 'Loading setup...')}</Waiting>;
|
||||
}
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!resolved) return;
|
||||
const { obj } = resolved;
|
||||
const { schema, object, resolved, form, pages } = setup;
|
||||
|
||||
let firstInvalid: number | null = null;
|
||||
const allErrors: Record<string, string> = {};
|
||||
sections.forEach((section, idx) => {
|
||||
const errs = validateSectionRequired(section.fields, formData);
|
||||
for (const [k, v] of Object.entries(errs)) {
|
||||
allErrors[k] = v;
|
||||
if (firstInvalid === null) firstInvalid = idx;
|
||||
}
|
||||
});
|
||||
if (firstInvalid !== null) {
|
||||
setFieldErrors(allErrors);
|
||||
setGeneralError(t('form.correctErrorsBelow', 'Please correct the errors below.'));
|
||||
setCurrentPage(firstInvalid);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setGeneralError(null);
|
||||
setFieldErrors({});
|
||||
|
||||
try {
|
||||
const patch = calculateJmapPatch(originalData, formData);
|
||||
|
||||
const accountId = getAccountId(obj.objectName);
|
||||
const responses = await jmapSet(obj.objectName, accountId, {
|
||||
update: { singleton: patch },
|
||||
});
|
||||
const setResult = responses[responses.length - 1]?.[1] as unknown as JmapSetResponse;
|
||||
|
||||
if (setResult.updated && 'singleton' in setResult.updated) {
|
||||
setSuccessExtras(setResult.updated.singleton ?? {});
|
||||
} else if (setResult.notUpdated && setResult.notUpdated.singleton) {
|
||||
const earliest = applySetError(setResult.notUpdated.singleton);
|
||||
if (earliest !== null) setCurrentPage(earliest);
|
||||
} else {
|
||||
setGeneralError(t('bootstrap.noConfirm', 'The server did not confirm the update.'));
|
||||
}
|
||||
} catch (err) {
|
||||
setGeneralError(
|
||||
err instanceof Error ? err.message : t('bootstrap.failedToComplete', 'Failed to complete setup.'),
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [resolved, sections, formData, originalData, validateSectionRequired, applySetError, t]);
|
||||
|
||||
const handleCopy = useCallback(
|
||||
async (key: string, value: string) => {
|
||||
if (generated !== null) {
|
||||
const copy = async (key: string, value: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(key);
|
||||
@@ -280,60 +138,86 @@ export function BootstrapWizard() {
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
if (!schema || !resolved) {
|
||||
};
|
||||
return (
|
||||
<WizardShell>
|
||||
<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>
|
||||
</WizardShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<WizardShell>
|
||||
<div className="flex items-center justify-center p-12 text-muted-foreground">
|
||||
<Loader2 className="h-6 w-6 animate-spin mr-2" />
|
||||
{t('bootstrap.loadingSetup', 'Loading setup...')}
|
||||
</div>
|
||||
</WizardShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (successExtras !== null) {
|
||||
return (
|
||||
<WizardShell>
|
||||
<SuccessScreen
|
||||
extras={successExtras}
|
||||
fields={resolved.fields}
|
||||
labelsByName={labelsByFieldName(resolved.form)}
|
||||
onCopy={handleCopy}
|
||||
<Shell>
|
||||
<Finished
|
||||
values={credentials(generated)}
|
||||
label={fieldLabels(form, resolved.fields)}
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
/>
|
||||
</WizardShell>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
if (pages.length === 0) {
|
||||
return (
|
||||
<WizardShell>
|
||||
<Shell>
|
||||
<div className="rounded-md border border-destructive/20 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{t('bootstrap.emptyForm', 'Setup form is empty. The server did not return any bootstrap fields.')}
|
||||
</div>
|
||||
</WizardShell>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
const current = sections[currentPage];
|
||||
const page = pages[step];
|
||||
const last = step === pages.length - 1;
|
||||
|
||||
const next = () => {
|
||||
const missing = missingOnPage(page, values, required);
|
||||
if (Object.keys(missing).length > 0) {
|
||||
setFieldErrors(missing);
|
||||
setError(correctBelow());
|
||||
return;
|
||||
}
|
||||
clearErrors();
|
||||
setStep(Math.min(step + 1, pages.length - 1));
|
||||
};
|
||||
|
||||
const back = () => {
|
||||
clearErrors();
|
||||
setStep(Math.max(step - 1, 0));
|
||||
};
|
||||
|
||||
const finish = async () => {
|
||||
const missing = firstMissing(pages, values, required);
|
||||
if (missing.page !== null) {
|
||||
setError(correctBelow());
|
||||
showErrors(missing.errors, missing.page);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
clearErrors();
|
||||
try {
|
||||
// Everything goes in the patch: setup starts from nothing on the server.
|
||||
const responses = await jmapSet(object.objectName, getAccountId(object.objectName), {
|
||||
update: { [SINGLETON]: calculateJmapPatch({}, values) },
|
||||
});
|
||||
const outcome = readSetResult(responses.at(-1)?.[1]);
|
||||
if (outcome.kind === 'updated') {
|
||||
setGenerated(outcome.extras);
|
||||
} else if (outcome.kind === 'refused') {
|
||||
setError(friendlySetError(outcome.error));
|
||||
const refusal = refusalByField(outcome.error, pages, {
|
||||
invalid: t('form.invalidValue', 'Invalid value.'),
|
||||
validation: validationErrorMessage,
|
||||
});
|
||||
if (Object.keys(refusal.errors).length > 0) showErrors(refusal.errors, refusal.page);
|
||||
} else {
|
||||
setError(t('bootstrap.noConfirm', 'The server did not confirm the update.'));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('bootstrap.failedToComplete', 'Failed to complete setup.'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stepOf = t('bootstrap.stepOf', 'Step {{current}} of {{total}}', { current: step + 1, total: pages.length });
|
||||
|
||||
return (
|
||||
<WizardShell>
|
||||
<Shell>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">{t('bootstrap.welcome', 'Welcome to inbuxa')}</h2>
|
||||
@@ -342,30 +226,38 @@ export function BootstrapWizard() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ProgressIndicator total={sections.length} current={currentPage} />
|
||||
<div className="flex items-center gap-2" aria-label={stepOf}>
|
||||
{pages.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-2 w-2 rounded-full transition-colors ${i <= step ? 'bg-primary' : 'bg-muted-foreground/20'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{generalError && (
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-4">
|
||||
<p className="text-sm text-destructive whitespace-pre-line">{generalError}</p>
|
||||
<p className="text-sm text-destructive whitespace-pre-line">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
{current.title && (
|
||||
{page.title && (
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{current.title}</CardTitle>
|
||||
<CardTitle className="text-base">{page.title}</CardTitle>
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent className={current.title ? '' : 'pt-6'}>
|
||||
<CardContent className={page.title ? '' : 'pt-6'}>
|
||||
<div className="space-y-6">
|
||||
{/* Enterprise-only options stay out of setup. */}
|
||||
<FormEditionContext.Provider value="oss">
|
||||
{current.fields.map(({ formField, field }) => (
|
||||
{page.fields.map(({ formField, field }) => (
|
||||
<FieldWidget
|
||||
key={formField.name}
|
||||
field={field}
|
||||
formField={formField}
|
||||
value={formData[formField.name]}
|
||||
onChange={(v) => handleFieldChange(formField.name, v)}
|
||||
value={values[formField.name]}
|
||||
onChange={(v) => change(formField.name, v)}
|
||||
readOnly={saving}
|
||||
error={fieldErrors[formField.name]}
|
||||
schema={schema}
|
||||
@@ -377,34 +269,30 @@ export function BootstrapWizard() {
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button type="button" variant="outline" onClick={handleBack} disabled={!canGoBack || saving}>
|
||||
<Button type="button" variant="outline" onClick={back} disabled={step === 0 || saving}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
{t('common.back', 'Back')}
|
||||
</Button>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('bootstrap.stepOf', 'Step {{current}} of {{total}}', {
|
||||
current: currentPage + 1,
|
||||
total: sections.length,
|
||||
})}
|
||||
</div>
|
||||
{isLastPage ? (
|
||||
<Button type="button" onClick={handleSubmit} disabled={saving}>
|
||||
<div className="text-xs text-muted-foreground">{stepOf}</div>
|
||||
{last ? (
|
||||
<Button type="button" onClick={finish} disabled={saving}>
|
||||
{saving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Rocket className="h-4 w-4 mr-2" />}
|
||||
{t('bootstrap.finishSetup', 'Finish setup')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" onClick={handleNext} disabled={saving}>
|
||||
<Button type="button" onClick={next} disabled={saving}>
|
||||
{t('common.next', 'Next')}
|
||||
<ArrowRight className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</WizardShell>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
function WizardShell({ children }: { children: React.ReactNode }) {
|
||||
/** The page around every step: the logo bar and a centered column. */
|
||||
function Shell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-content-background">
|
||||
<header className="flex items-center px-6 py-4 border-b bg-background">
|
||||
@@ -417,46 +305,27 @@ function WizardShell({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressIndicator({ total, current }: { total: number; current: number }) {
|
||||
const { t } = useTranslation();
|
||||
function Waiting({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
aria-label={t('bootstrap.stepOf', 'Step {{current}} of {{total}}', { current: current + 1, total })}
|
||||
>
|
||||
{Array.from({ length: total }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-2 w-2 rounded-full transition-colors ${
|
||||
i < current ? 'bg-primary' : i === current ? 'bg-primary' : 'bg-muted-foreground/20'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Shell>
|
||||
<div className="flex items-center justify-center p-12 text-muted-foreground">
|
||||
<Loader2 className="h-6 w-6 animate-spin mr-2" />
|
||||
{children}
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessScreen({
|
||||
extras,
|
||||
fields,
|
||||
labelsByName,
|
||||
onCopy,
|
||||
copied,
|
||||
}: {
|
||||
extras: Record<string, unknown>;
|
||||
fields: Fields;
|
||||
labelsByName: Map<string, string>;
|
||||
onCopy: (key: string, value: string) => void;
|
||||
interface FinishedProps {
|
||||
values: [string, string][];
|
||||
label: (name: string) => string;
|
||||
copied: string | null;
|
||||
}) {
|
||||
onCopy: (key: string, value: string) => void;
|
||||
}
|
||||
|
||||
/** Setup is done: what the server generated, to write down now, and the restart that comes next. */
|
||||
function Finished({ values, label, copied, onCopy }: FinishedProps) {
|
||||
const { t } = useTranslation();
|
||||
const entries = Object.entries(extras).filter(([k, v]) => {
|
||||
if (k === 'id' || k === 'blobId' || k === '@type') return false;
|
||||
return typeof v === 'string' && v.length > 0;
|
||||
}) as [string, string][];
|
||||
|
||||
const hasCredentials = entries.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
@@ -466,7 +335,7 @@ function SuccessScreen({
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">{t('bootstrap.complete', 'Setup complete')}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{hasCredentials
|
||||
{values.length > 0
|
||||
? t(
|
||||
'bootstrap.credentialsCreated',
|
||||
'Your administrator account has been created. Write these down now: the password will not be shown again.',
|
||||
@@ -476,24 +345,20 @@ function SuccessScreen({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasCredentials && (
|
||||
{values.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
{entries.map(([key, value]) => {
|
||||
const field = fields.properties[key];
|
||||
const label = labelsByName.get(key) ?? field?.description?.split('\n')[0] ?? key;
|
||||
return (
|
||||
<div key={key} className="space-y-1.5">
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
<div className="flex gap-2">
|
||||
<code className="flex-1 rounded bg-muted p-2 text-sm break-all select-all">{value}</code>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => onCopy(key, value)}>
|
||||
{copied === key ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{values.map(([key, value]) => (
|
||||
<div key={key} className="space-y-1.5">
|
||||
<div className="text-sm font-medium">{label(key)}</div>
|
||||
<div className="flex gap-2">
|
||||
<code className="flex-1 rounded bg-muted p-2 text-sm break-all select-all">{value}</code>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => onCopy(key, value)}>
|
||||
{copied === key ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -511,13 +376,4 @@ function SuccessScreen({
|
||||
);
|
||||
}
|
||||
|
||||
function labelsByFieldName(form: Form | null): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
if (!form) return map;
|
||||
for (const section of form.sections) {
|
||||
for (const ff of section.fields) map.set(ff.name, ff.label);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export default BootstrapWizard;
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Field, Fields, Form } from '@/types/schema';
|
||||
import {
|
||||
credentials,
|
||||
fieldLabels,
|
||||
firstMissing,
|
||||
missingOnPage,
|
||||
readSetResult,
|
||||
refusalByField,
|
||||
wizardPages,
|
||||
} from './wizard';
|
||||
|
||||
const field = (type: Record<string, unknown>, more: Partial<Field> = {}) =>
|
||||
({ description: 'Desc line\nmore', type, update: 'mutable', ...more }) as Field;
|
||||
|
||||
const fields: Fields = {
|
||||
properties: {
|
||||
host: field({ type: 'string' }),
|
||||
domain: field({ type: 'string' }),
|
||||
note: field({ type: 'string', nullable: true }),
|
||||
tls: field({ type: 'boolean' }),
|
||||
id: field({ type: 'string' }, { update: 'serverSet' }),
|
||||
paid: field({ type: 'string' }, { enterprise: true }),
|
||||
store: field({ type: 'object' }),
|
||||
kind: field({ type: 'enum' }),
|
||||
fixed: field({ type: 'string' }, { update: 'immutable' }),
|
||||
},
|
||||
};
|
||||
|
||||
const form: Form = {
|
||||
sections: [
|
||||
{
|
||||
title: 'Identity',
|
||||
fields: [
|
||||
{ name: 'host', label: 'Hostname' },
|
||||
{ name: 'domain', label: 'Domain' },
|
||||
{ name: 'note', label: 'Note' },
|
||||
{ name: 'tls', label: 'TLS' },
|
||||
{ name: 'ghost', label: 'Not in the schema' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Server only',
|
||||
fields: [
|
||||
{ name: 'id', label: 'Id' },
|
||||
{ name: 'paid', label: 'Paid' },
|
||||
],
|
||||
},
|
||||
{
|
||||
fields: [
|
||||
{ name: 'store', label: 'Store' },
|
||||
{ name: 'kind', label: 'Kind' },
|
||||
{ name: 'fixed', label: 'Fixed' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const pages = wizardPages(fields, form);
|
||||
|
||||
describe('wizardPages', () => {
|
||||
it('keeps the fields an admin fills in, and drops pages left empty', () => {
|
||||
expect(pages.map((p) => [p.title, p.fields.map((f) => f.formField.name)])).toEqual([
|
||||
['Identity', ['host', 'domain', 'note', 'tls']],
|
||||
[undefined, ['store', 'kind', 'fixed']],
|
||||
]);
|
||||
expect(pages[0].fields[0].field).toBe(fields.properties.host);
|
||||
});
|
||||
|
||||
it('has no pages without a form', () => {
|
||||
expect(wizardPages(fields, null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('required fields', () => {
|
||||
it('asks for plain values that are empty and not optional', () => {
|
||||
expect(missingOnPage(pages[0], { host: '', note: '', tls: undefined }, 'Required')).toEqual({
|
||||
host: 'Required',
|
||||
domain: 'Required',
|
||||
});
|
||||
expect(missingOnPage(pages[0], { host: 'mail.a.org', domain: 'a.org' }, 'Required')).toEqual({});
|
||||
});
|
||||
|
||||
it('leaves objects and fields that cannot change to their own checks', () => {
|
||||
expect(missingOnPage(pages[1], { store: null, kind: null, fixed: null }, 'Required')).toEqual({ kind: 'Required' });
|
||||
});
|
||||
|
||||
it('finds every missing field and the first page with one', () => {
|
||||
expect(firstMissing(pages, { host: 'h', domain: 'd' }, 'Required')).toEqual({
|
||||
errors: { kind: 'Required' },
|
||||
page: 1,
|
||||
});
|
||||
expect(firstMissing(pages, { kind: 'x' }, 'Required')).toEqual({
|
||||
errors: { host: 'Required', domain: 'Required' },
|
||||
page: 0,
|
||||
});
|
||||
expect(firstMissing(pages, { host: 'h', domain: 'd', kind: 'x' }, 'Required')).toEqual({ errors: {}, page: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('refusalByField', () => {
|
||||
const messages = { invalid: 'Invalid value.', validation: (ve: { type: string }) => `bad ${ve.type}` };
|
||||
|
||||
it('puts the server’s reasons on the fields they name, by their top-level name', () => {
|
||||
expect(
|
||||
refusalByField(
|
||||
{
|
||||
type: 'invalidProperties',
|
||||
description: 'Nope',
|
||||
properties: ['kind/value', 'unknown'],
|
||||
validationErrors: [{ type: 'MaxLength', property: 'domain', required: 3 }],
|
||||
},
|
||||
pages,
|
||||
messages,
|
||||
),
|
||||
).toEqual({ errors: { kind: 'Nope', domain: 'bad MaxLength' }, page: 0 });
|
||||
});
|
||||
|
||||
it('uses a plain message when the server gives none, and names no page when no field is shown', () => {
|
||||
expect(refusalByField({ type: 'invalidProperties', properties: ['store'] }, pages, messages)).toEqual({
|
||||
errors: { store: 'Invalid value.' },
|
||||
page: 1,
|
||||
});
|
||||
expect(refusalByField({ type: 'forbidden', properties: ['id'] }, pages, messages)).toEqual({
|
||||
errors: {},
|
||||
page: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('readSetResult', () => {
|
||||
it('reads an update, a refusal, or neither', () => {
|
||||
expect(readSetResult({ updated: { singleton: { adminPassword: 'x' } } })).toEqual({
|
||||
kind: 'updated',
|
||||
extras: { adminPassword: 'x' },
|
||||
});
|
||||
expect(readSetResult({ updated: { singleton: null } })).toEqual({ kind: 'updated', extras: {} });
|
||||
expect(readSetResult({ notUpdated: { singleton: { type: 'forbidden' } } })).toEqual({
|
||||
kind: 'refused',
|
||||
error: { type: 'forbidden' },
|
||||
});
|
||||
expect(readSetResult({ updated: null, notUpdated: null })).toEqual({ kind: 'unconfirmed' });
|
||||
expect(readSetResult(undefined)).toEqual({ kind: 'unconfirmed' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the finish screen', () => {
|
||||
it('shows the values the server generated, not ids or empty ones', () => {
|
||||
expect(
|
||||
credentials({ id: 's', blobId: 'b', '@type': 'X', adminUser: 'admin', empty: '', count: 3, pass: 'p' }),
|
||||
).toEqual([
|
||||
['adminUser', 'admin'],
|
||||
['pass', 'p'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('labels a value by its form label, else the first line of its description, else its name', () => {
|
||||
const labels = fieldLabels(form, fields);
|
||||
expect(labels('host')).toBe('Hostname');
|
||||
expect(labels('id')).toBe('Id');
|
||||
expect(fieldLabels(null, fields)('host')).toBe('Desc line');
|
||||
expect(labels('adminPassword')).toBe('adminPassword');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/** The setup wizard's rules, apart from the page: its pages, what each requires, and what the server answers. */
|
||||
import type { JmapSetError, ValidationError } from '@/types/jmap';
|
||||
import type { Field, Fields, Form, FormField } from '@/types/schema';
|
||||
|
||||
export interface WizardField {
|
||||
formField: FormField;
|
||||
field: Field;
|
||||
}
|
||||
|
||||
export interface WizardPage {
|
||||
title?: string;
|
||||
fields: WizardField[];
|
||||
}
|
||||
|
||||
/** Field errors by field name, and the first page that has one (null when none does). */
|
||||
export interface PageErrors {
|
||||
errors: Record<string, string>;
|
||||
page: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One page per form section, with the fields an admin fills in: not ones
|
||||
* the server sets, nor Enterprise ones. A section left with none is skipped.
|
||||
*/
|
||||
export function wizardPages(fields: Fields, form: Form | null): WizardPage[] {
|
||||
if (!form) return [];
|
||||
return form.sections
|
||||
.map((section) => ({
|
||||
title: section.title,
|
||||
fields: section.fields.flatMap((formField) => {
|
||||
const field = fields.properties[formField.name];
|
||||
return field && field.update !== 'serverSet' && !field.enterprise ? [{ formField, field }] : [];
|
||||
}),
|
||||
}))
|
||||
.filter((page) => page.fields.length > 0);
|
||||
}
|
||||
|
||||
/** Plain values can be checked for presence here; objects, lists and maps check themselves. */
|
||||
const PLAIN = new Set(['string', 'number', 'utcDateTime', 'enum', 'blobId', 'objectId']);
|
||||
|
||||
/** The page's required fields that are still empty. */
|
||||
export function missingOnPage(
|
||||
page: WizardPage,
|
||||
data: Record<string, unknown>,
|
||||
required: string,
|
||||
): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
for (const { formField, field } of page.fields) {
|
||||
if (field.update !== 'mutable' || !PLAIN.has(field.type.type)) continue;
|
||||
if ('nullable' in field.type && field.type.nullable) continue;
|
||||
const value = data[formField.name];
|
||||
if (value === undefined || value === null || value === '') errors[formField.name] = required;
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Every required field still empty, on any page. */
|
||||
export function firstMissing(pages: WizardPage[], data: Record<string, unknown>, required: string): PageErrors {
|
||||
const result: PageErrors = { errors: {}, page: null };
|
||||
pages.forEach((page, index) => {
|
||||
const missing = missingOnPage(page, data, required);
|
||||
if (Object.keys(missing).length === 0) return;
|
||||
Object.assign(result.errors, missing);
|
||||
result.page ??= index;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's refusal, on the fields it names. A property path like
|
||||
* `dataStore/path` belongs to the top-level field `dataStore`; fields
|
||||
* the wizard doesn't show are left to the general message.
|
||||
*/
|
||||
export function refusalByField(
|
||||
error: JmapSetError,
|
||||
pages: WizardPage[],
|
||||
messages: { invalid: string; validation: (ve: ValidationError) => string },
|
||||
): PageErrors {
|
||||
const pageOf = new Map<string, number>();
|
||||
pages.forEach((page, index) => {
|
||||
for (const { formField } of page.fields) if (!pageOf.has(formField.name)) pageOf.set(formField.name, index);
|
||||
});
|
||||
|
||||
const result: PageErrors = { errors: {}, page: null };
|
||||
const mark = (path: string | undefined, message: string) => {
|
||||
const name = path?.split('/')[0];
|
||||
const page = name ? pageOf.get(name) : undefined;
|
||||
if (!name || page === undefined) return;
|
||||
result.errors[name] = message;
|
||||
if (result.page === null || page < result.page) result.page = page;
|
||||
};
|
||||
for (const path of error.properties ?? []) mark(path, error.description ?? messages.invalid);
|
||||
for (const ve of error.validationErrors ?? []) mark(ve.property, messages.validation(ve));
|
||||
return result;
|
||||
}
|
||||
|
||||
export type SetOutcome =
|
||||
| { kind: 'updated'; extras: Record<string, unknown> }
|
||||
| { kind: 'refused'; error: JmapSetError }
|
||||
| { kind: 'unconfirmed' };
|
||||
|
||||
/** What `x:Bootstrap/set` said about the singleton: done (with anything it generated), refused, or nothing. */
|
||||
export function readSetResult(result: Record<string, unknown> | undefined): SetOutcome {
|
||||
const updated = result?.updated as Record<string, Record<string, unknown> | null> | null | undefined;
|
||||
const notUpdated = result?.notUpdated as Record<string, JmapSetError> | null | undefined;
|
||||
if (updated && 'singleton' in updated) return { kind: 'updated', extras: updated.singleton ?? {} };
|
||||
if (notUpdated?.singleton) return { kind: 'refused', error: notUpdated.singleton };
|
||||
return { kind: 'unconfirmed' };
|
||||
}
|
||||
|
||||
const NOT_SHOWN = new Set(['id', 'blobId', '@type']);
|
||||
|
||||
/** What setup generated and the admin must write down, such as the first account's password. */
|
||||
export function credentials(extras: Record<string, unknown>): [string, string][] {
|
||||
return Object.entries(extras).filter(
|
||||
(entry): entry is [string, string] => !NOT_SHOWN.has(entry[0]) && typeof entry[1] === 'string' && entry[1] !== '',
|
||||
);
|
||||
}
|
||||
|
||||
/** A field's name for people: its form label, else its description's first line, else the name itself. */
|
||||
export function fieldLabels(form: Form | null, fields: Fields): (name: string) => string {
|
||||
const labels = new Map<string, string>();
|
||||
for (const section of form?.sections ?? []) for (const ff of section.fields) labels.set(ff.name, ff.label);
|
||||
return (name) => labels.get(name) ?? fields.properties[name]?.description?.split('\n')[0] ?? name;
|
||||
}
|
||||
+219
-394
@@ -1,180 +1,105 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Zap, Loader2, ChevronRight, CircleCheck, CircleAlert } from 'lucide-react';
|
||||
import i18n from '@/i18n';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { ChevronRight, CircleAlert, CircleCheck, Loader2, Zap } from 'lucide-react';
|
||||
import { FieldWidget } from '@/components/forms/FieldWidget';
|
||||
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';
|
||||
import { ExplainButton } from '@/features/ai/explain/ExplainButton';
|
||||
import { spamVerdictSubject } from '@/features/ai/explain/explain';
|
||||
import { resolveObject, resolveSchema, resolveVariantForm } from '@/lib/schemaResolver';
|
||||
import { getAccountId, jmapSet } from '@/services/jmap/client';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import type { ObjectVariant, Schema } from '@/types/schema';
|
||||
import {
|
||||
actionPayload,
|
||||
CREATE_ID,
|
||||
groupActions,
|
||||
inputSections,
|
||||
missingInputs,
|
||||
readActionResult,
|
||||
splitLabel,
|
||||
} from './actions';
|
||||
|
||||
type ActionState =
|
||||
type Stage =
|
||||
| { kind: 'pick' }
|
||||
// inbuxa: an action without options, chosen from the command palette, waits to be confirmed.
|
||||
/** An action without options, opened from the command palette, waits for Run. */
|
||||
| { kind: 'confirm'; variant: ObjectVariant }
|
||||
| { 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 };
|
||||
| { kind: 'form'; variant: ObjectVariant; values: Record<string, unknown> }
|
||||
| { kind: 'running'; variant: ObjectVariant }
|
||||
| { kind: 'done'; variant: ObjectVariant; values: Record<string, unknown>; returned: Record<string, unknown> | null }
|
||||
| { kind: 'failed'; error: string };
|
||||
|
||||
interface ActionPageProps {
|
||||
viewName: string;
|
||||
}
|
||||
/** An action's options, starting from the schema's defaults. */
|
||||
const defaultsFor = (schema: Schema, variant: ObjectVariant) => ({
|
||||
...(variant.schemaName ? schema.fields[variant.schemaName]?.defaults : undefined),
|
||||
});
|
||||
|
||||
export function ActionPage({ viewName }: ActionPageProps) {
|
||||
export function ActionPage({ viewName }: { viewName: string }) {
|
||||
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 [stage, setStage] = useState<Stage>({ kind: 'pick' });
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const resolved = useMemo(() => {
|
||||
if (!schema) return null;
|
||||
return resolveObject(schema, viewName);
|
||||
}, [schema, viewName]);
|
||||
const object = useMemo(() => (schema ? resolveObject(schema, viewName) : null), [schema, viewName]);
|
||||
const variants = useMemo(() => {
|
||||
const resolved = schema && object ? resolveSchema(schema, object.objectName) : null;
|
||||
return resolved?.type === 'multiple' ? resolved.variants : null;
|
||||
}, [schema, object]);
|
||||
const canRun = object ? hasObjectPermission(object.permissionPrefix, 'Create') : false;
|
||||
const allowed = (name: string) => hasPermission(`action${name}`);
|
||||
|
||||
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);
|
||||
const run = async (variant: ObjectVariant, values: Record<string, unknown>) => {
|
||||
if (!schema || !object) return;
|
||||
setStage({ kind: 'running', variant });
|
||||
const fields = variant.schemaName ? (schema.fields[variant.schemaName] ?? null) : null;
|
||||
try {
|
||||
const responses = await jmapSet(object.objectName, getAccountId(object.objectName), {
|
||||
create: { [CREATE_ID]: actionPayload(variant.name, fields, values) },
|
||||
});
|
||||
const result = readActionResult(responses.at(-1)?.[1]);
|
||||
setStage(
|
||||
result.kind === 'done'
|
||||
? { kind: 'done', variant, values, returned: result.returned }
|
||||
: { kind: 'failed', error: result.reason ?? t('actions.actionFailed', 'Action failed.') },
|
||||
);
|
||||
} catch (e) {
|
||||
setStage({ kind: 'failed', error: e instanceof Error ? e.message : t('actions.actionFailed', 'Action failed.') });
|
||||
}
|
||||
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;
|
||||
/** A click on an action runs it at once when it has no options, else opens them. */
|
||||
const open = (variant: ObjectVariant) => {
|
||||
if (!schema) return;
|
||||
if (variant.schemaName) setStage({ kind: 'form', variant, values: defaultsFor(schema, variant) });
|
||||
else void run(variant, {});
|
||||
};
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
// inbuxa: `?action=<name>` from the command palette opens that action. One
|
||||
// with options shows its form; one without asks first, since here a click
|
||||
// on it runs it at once. Read while rendering, so it also works when the
|
||||
// palette is used on this page; the effect then takes it out of the address.
|
||||
// `?action=<name>` from the command palette opens that action, asking
|
||||
// first for one without options. It's read while rendering so it also
|
||||
// works when the palette is used on this page; the effect then takes it
|
||||
// out of the address.
|
||||
const requested = searchParams.get('action');
|
||||
const [openedFromPalette, setOpenedFromPalette] = useState<string | null>(null);
|
||||
if (requested !== openedFromPalette) {
|
||||
setOpenedFromPalette(requested);
|
||||
const variant = requested && sch?.type === 'multiple' ? sch.variants.find((v) => v.name === requested) : undefined;
|
||||
if (schema && variant && canCreate && hasPermission(`action${variant.name}`)) {
|
||||
if (variant.schemaName) {
|
||||
const defaults = schema.fields[variant.schemaName]?.defaults;
|
||||
setState({ kind: 'form', variant, formData: defaults ? { ...defaults } : {} });
|
||||
} else {
|
||||
setState({ kind: 'confirm', variant });
|
||||
}
|
||||
const [handled, setHandled] = useState<string | null>(null);
|
||||
if (requested !== handled) {
|
||||
setHandled(requested);
|
||||
const variant = requested ? variants?.find((v) => v.name === requested) : undefined;
|
||||
if (schema && variant && canRun && allowed(variant.name)) {
|
||||
setStage(
|
||||
variant.schemaName
|
||||
? { kind: 'form', variant, values: defaultsFor(schema, variant) }
|
||||
: { kind: 'confirm', variant },
|
||||
);
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
@@ -189,16 +114,55 @@ export function ActionPage({ viewName }: ActionPageProps) {
|
||||
);
|
||||
}, [requested, setSearchParams]);
|
||||
|
||||
if (!schema || !resolved || !sch) return null;
|
||||
if (!schema || !object || !variants) return null;
|
||||
const toList = () => setStage({ kind: 'pick' });
|
||||
|
||||
if (state.kind === 'confirm') {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl space-y-4 pt-8">
|
||||
<Card>
|
||||
switch (stage.kind) {
|
||||
case 'pick': {
|
||||
const list = schema.lists[viewName] ?? schema.lists[object.objectName];
|
||||
const subtitle = list?.subtitle ?? object.objectType.description;
|
||||
const other = t('actions.otherGroup', 'Other');
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{list?.title ?? t('actions.title', 'Actions')}</h1>
|
||||
{subtitle && <p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{[...groupActions(variants, allowed, other)].map(([group, members]) => (
|
||||
<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">
|
||||
{members.map((variant) => (
|
||||
<Button
|
||||
key={variant.name}
|
||||
variant="outline"
|
||||
className="justify-between h-auto py-3 px-4"
|
||||
disabled={!canRun}
|
||||
onClick={() => open(variant)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span>{splitLabel(variant.label, other).name}</span>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'confirm':
|
||||
return (
|
||||
<Narrow>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Zap className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
{state.variant.label}
|
||||
{stage.variant.label}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -206,236 +170,105 @@ export function ActionPage({ viewName }: ActionPageProps) {
|
||||
{t('actions.confirmRun', 'This runs on the server as soon as you choose Run. It has no options.')}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => handlePickVariant(state.variant)}>{t('actions.run', 'Run')}</Button>
|
||||
<Button variant="outline" onClick={() => setState({ kind: 'pick' })}>
|
||||
<Button onClick={() => open(stage.variant)}>{t('actions.run', 'Run')}</Button>
|
||||
<Button variant="outline" onClick={toList}>
|
||||
{t('actions.backToActions', 'Back to actions')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</Narrow>
|
||||
);
|
||||
|
||||
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>}
|
||||
case 'form':
|
||||
return (
|
||||
<OptionsForm
|
||||
schema={schema}
|
||||
variant={stage.variant}
|
||||
initial={stage.values}
|
||||
onRun={(values) => void run(stage.variant, values)}
|
||||
onBack={toList}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'running':
|
||||
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: stage.variant.label })}
|
||||
</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' })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'done':
|
||||
return (
|
||||
<ActionDone
|
||||
schema={schema}
|
||||
variant={stage.variant}
|
||||
values={stage.values}
|
||||
returned={stage.returned}
|
||||
onAnother={toList}
|
||||
/>
|
||||
);
|
||||
|
||||
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>
|
||||
case 'failed':
|
||||
return (
|
||||
<Narrow>
|
||||
<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>
|
||||
<p className="text-sm text-muted-foreground mt-1">{stage.error}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setState({ kind: 'pick' })}>
|
||||
<Button variant="outline" onClick={toList}>
|
||||
{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.'),
|
||||
});
|
||||
</Narrow>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function ActionFormView({
|
||||
schema,
|
||||
variant,
|
||||
formData: initialData,
|
||||
onSubmit,
|
||||
onBack,
|
||||
}: {
|
||||
function Narrow({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl space-y-4 pt-8">
|
||||
<Card>{children}</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface OptionsFormProps {
|
||||
schema: Schema;
|
||||
variant: ObjectVariant;
|
||||
formData: Record<string, unknown>;
|
||||
onSubmit: (data: Record<string, unknown>) => void;
|
||||
initial: Record<string, unknown>;
|
||||
onRun: (values: Record<string, unknown>) => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
}
|
||||
|
||||
/** An action's options, from its variant's form, with only the fields an admin sets. */
|
||||
function OptionsForm({ schema, variant, initial, onRun, onBack }: OptionsFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [formData, setFormData] = useState<Record<string, unknown>>(initialData);
|
||||
const [values, setValues] = useState(initial);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
if (!variant.schemaName) return null;
|
||||
const fields = schema.fields[variant.schemaName];
|
||||
const fields = variant.schemaName ? schema.fields[variant.schemaName] : undefined;
|
||||
const form = resolveVariantForm(schema, '', '', variant.schemaName);
|
||||
if (!fields || !form) return null;
|
||||
const sections = inputSections(fields, form);
|
||||
|
||||
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 change = (name: string, value: unknown) => {
|
||||
setValues((prev) => ({ ...prev, [name]: value }));
|
||||
setErrors(({ [name]: _cleared, ...rest }) => (void _cleared, rest));
|
||||
};
|
||||
|
||||
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);
|
||||
const submit = () => {
|
||||
const missing = missingInputs(sections, fields, values, t('form.required', 'This field is required.'));
|
||||
if (Object.keys(missing).length > 0) setErrors(missing);
|
||||
else onRun(values);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -447,8 +280,8 @@ function ActionFormView({
|
||||
<h1 className="text-xl font-semibold">{variant.label}</h1>
|
||||
</div>
|
||||
|
||||
{mutableSections.map((section, si) => (
|
||||
<Card key={si}>
|
||||
{sections.map((section, i) => (
|
||||
<Card key={i}>
|
||||
{section.title && (
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{section.title}</CardTitle>
|
||||
@@ -456,22 +289,18 @@ function ActionFormView({
|
||||
)}
|
||||
<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]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{section.fields.map((ff) => (
|
||||
<FieldWidget
|
||||
key={ff.name}
|
||||
formField={ff}
|
||||
field={fields.properties[ff.name]}
|
||||
value={values[ff.name]}
|
||||
onChange={(v) => change(ff.name, v)}
|
||||
readOnly={false}
|
||||
schema={schema}
|
||||
error={errors[ff.name]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -481,7 +310,7 @@ function ActionFormView({
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>
|
||||
<Button onClick={submit}>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
{t('actions.execute', 'Execute')}
|
||||
</Button>
|
||||
@@ -490,24 +319,20 @@ function ActionFormView({
|
||||
);
|
||||
}
|
||||
|
||||
function ActionResultView({
|
||||
schema,
|
||||
variant,
|
||||
props,
|
||||
inputData,
|
||||
onNewAction,
|
||||
}: {
|
||||
interface ActionDoneProps {
|
||||
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;
|
||||
values: Record<string, unknown>;
|
||||
returned: Record<string, unknown> | null;
|
||||
onAnother: () => void;
|
||||
}
|
||||
|
||||
const visibleFields = useMemo(() => new Set(Object.keys(mergedData)), [mergedData]);
|
||||
/** The action ran: its options and what the server sent back, shown through the variant's own view. */
|
||||
function ActionDone({ schema, variant, values, returned, onAnother }: ActionDoneProps) {
|
||||
const { t } = useTranslation();
|
||||
const data = useMemo(() => ({ ...values, ...returned }), [values, returned]);
|
||||
const shown = useMemo(() => new Set(Object.keys(data)), [data]);
|
||||
const hasData = !!variant.schemaName && shown.size > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pt-4">
|
||||
@@ -526,16 +351,16 @@ function ActionResultView({
|
||||
schema={schema}
|
||||
objectName={variant.schemaName}
|
||||
viewName={variant.schemaName}
|
||||
data={mergedData}
|
||||
visibleFields={visibleFields}
|
||||
data={data}
|
||||
visibleFields={shown}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* inbuxa: "Explain this" for a spam verdict (ai-explain EX-17) */}
|
||||
{variant.name === 'ClassifySpam' && props && (
|
||||
{/* "Explain this" for a spam verdict (ai-explain EX-17). */}
|
||||
{variant.name === 'ClassifySpam' && returned && (
|
||||
<ExplainButton
|
||||
subject={() => spamVerdictSubject(props)}
|
||||
title={t('explain.spamVerdictTitle', 'Spam verdict: {{result}}', { result: String(props.result ?? '') })}
|
||||
subject={() => spamVerdictSubject(returned)}
|
||||
title={t('explain.spamVerdictTitle', 'Spam verdict: {{result}}', { result: String(returned.result ?? '') })}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -547,7 +372,7 @@ function ActionResultView({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Button onClick={onNewAction}>
|
||||
<Button onClick={onAnother}>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
{t('actions.executeAnother', 'Execute another action')}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Field, Fields, Form, ObjectVariant } from '@/types/schema';
|
||||
import { actionPayload, groupActions, inputSections, missingInputs, readActionResult, splitLabel } from './actions';
|
||||
|
||||
const variants: ObjectVariant[] = [
|
||||
{ name: 'ReloadSettings', label: 'Reload: Server settings' },
|
||||
{ name: 'ReloadTls', label: 'Reload: TLS certificates' },
|
||||
{ name: 'ClassifySpam', label: 'Spam Filter: Classify a message', schemaName: 'x:SpamClassify' },
|
||||
{ name: 'Odd', label: ':starts with a colon' },
|
||||
{ name: 'Plain', label: 'No group' },
|
||||
{ name: 'Hidden', label: 'Reload: Hidden' },
|
||||
];
|
||||
|
||||
describe('splitLabel', () => {
|
||||
it('splits "Group: Action" at the first colon', () => {
|
||||
expect(splitLabel('Reload: Server settings', 'Other')).toEqual({ group: 'Reload', name: 'Server settings' });
|
||||
expect(splitLabel('A: b: c', 'Other')).toEqual({ group: 'A', name: 'b: c' });
|
||||
expect(splitLabel('No group', 'Other')).toEqual({ group: 'Other', name: 'No group' });
|
||||
expect(splitLabel(':x', 'Other')).toEqual({ group: 'Other', name: ':x' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupActions', () => {
|
||||
it('groups the allowed actions in schema order', () => {
|
||||
const groups = groupActions(variants, (name) => name !== 'Hidden', 'Other');
|
||||
expect([...groups].map(([g, list]) => [g, list.map((v) => v.name)])).toEqual([
|
||||
['Reload', ['ReloadSettings', 'ReloadTls']],
|
||||
['Spam Filter', ['ClassifySpam']],
|
||||
['Other', ['Odd', 'Plain']],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
const field = (type: Record<string, unknown>, update: Field['update'] = 'mutable') =>
|
||||
({ description: '', type, update }) as unknown as Field;
|
||||
|
||||
const fields: Fields = {
|
||||
properties: {
|
||||
message: field({ type: 'string', format: 'text' }),
|
||||
password: field({ type: 'string', format: 'secret' }),
|
||||
note: field({ type: 'string', format: 'secretText' }),
|
||||
ip: field({ type: 'string', format: 'ipAddress' }),
|
||||
optional: field({ type: 'string', nullable: true }),
|
||||
count: field({ type: 'number' }),
|
||||
level: field({ type: 'enum' }),
|
||||
tls: field({ type: 'boolean' }),
|
||||
when: field({ type: 'utcDateTime' }),
|
||||
score: field({ type: 'number' }, 'serverSet'),
|
||||
fixed: field({ type: 'string' }, 'immutable'),
|
||||
},
|
||||
};
|
||||
|
||||
describe('actionPayload', () => {
|
||||
it('sends the action type and the inputs the admin set, not server results or unchanged secrets', () => {
|
||||
expect(
|
||||
actionPayload('ClassifySpam', fields, {
|
||||
message: 'raw',
|
||||
password: '****',
|
||||
note: 'new secret',
|
||||
score: 5,
|
||||
tls: false,
|
||||
extra: 'not in schema',
|
||||
}),
|
||||
).toEqual({ '@type': 'ClassifySpam', message: 'raw', note: 'new secret', tls: false });
|
||||
});
|
||||
|
||||
it('is only the type for an action without options', () => {
|
||||
expect(actionPayload('ReloadSettings', null, { anything: 1 })).toEqual({ '@type': 'ReloadSettings' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('readActionResult', () => {
|
||||
it('returns what the server added, without ids', () => {
|
||||
expect(readActionResult({ created: { 'action-0': { id: 'x', blobId: 'b', score: 3, result: 'ham' } } })).toEqual({
|
||||
kind: 'done',
|
||||
returned: { score: 3, result: 'ham' },
|
||||
});
|
||||
expect(readActionResult({ created: { 'action-0': { id: 'x' } } })).toEqual({ kind: 'done', returned: null });
|
||||
});
|
||||
|
||||
it("returns the server's reason for a refusal, if it gave one", () => {
|
||||
expect(readActionResult({ notCreated: { 'action-0': { type: 'forbidden', description: 'Busy' } } })).toEqual({
|
||||
kind: 'refused',
|
||||
reason: 'Busy',
|
||||
});
|
||||
expect(readActionResult({ notCreated: { 'action-0': { type: 'forbidden' } } })).toEqual({
|
||||
kind: 'refused',
|
||||
reason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('counts an answer about neither as done', () => {
|
||||
expect(readActionResult({})).toEqual({ kind: 'done', returned: null });
|
||||
expect(readActionResult(undefined)).toEqual({ kind: 'done', returned: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the options form', () => {
|
||||
const form: Form = {
|
||||
sections: [
|
||||
{
|
||||
title: 'Input',
|
||||
fields: [
|
||||
{ name: 'message', label: 'Message' },
|
||||
{ name: 'optional', label: 'Optional' },
|
||||
{ name: 'count', label: 'Count' },
|
||||
{ name: 'level', label: 'Level' },
|
||||
{ name: 'tls', label: 'TLS' },
|
||||
{ name: 'when', label: 'When' },
|
||||
{ name: 'ghost', label: 'Not in the schema' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Results',
|
||||
fields: [
|
||||
{ name: 'score', label: 'Score' },
|
||||
{ name: 'fixed', label: 'Fixed' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const sections = inputSections(fields, form);
|
||||
|
||||
it('shows only the inputs, dropping sections of results', () => {
|
||||
expect(sections.map((s) => [s.title, s.fields.map((f) => f.name)])).toEqual([
|
||||
['Input', ['message', 'optional', 'count', 'level', 'tls', 'when']],
|
||||
]);
|
||||
});
|
||||
|
||||
it('requires text, numbers and choices that are not optional', () => {
|
||||
expect(missingInputs(sections, fields, { message: '', count: null, tls: undefined }, 'Required')).toEqual({
|
||||
message: 'Required',
|
||||
count: 'Required',
|
||||
level: 'Required',
|
||||
});
|
||||
expect(missingInputs(sections, fields, { message: 'm', count: 0, level: 'x' }, 'Required')).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* Server actions (`x:Action`): each variant is an action, run by creating
|
||||
* one. These are the page's rules apart from the page.
|
||||
*/
|
||||
import { SECRET_MASK } from '@/lib/jmapUtils';
|
||||
import type { JmapSetError } from '@/types/jmap';
|
||||
import type { Fields, Form, FormSection, ObjectVariant } from '@/types/schema';
|
||||
|
||||
/** The id an action is created under; the answer comes back under it. */
|
||||
export const CREATE_ID = 'action-0';
|
||||
|
||||
/** Labels read "Group: Action"; a label without a group goes under `other`. */
|
||||
export function splitLabel(label: string, other: string): { group: string; name: string } {
|
||||
const colon = label.indexOf(':');
|
||||
return colon > 0
|
||||
? { group: label.slice(0, colon).trim(), name: label.slice(colon + 1).trim() }
|
||||
: { group: other, name: label };
|
||||
}
|
||||
|
||||
/** The actions the admin may run, by group, in the order the schema lists them. */
|
||||
export function groupActions(
|
||||
variants: ObjectVariant[],
|
||||
allowed: (name: string) => boolean,
|
||||
other: string,
|
||||
): Map<string, ObjectVariant[]> {
|
||||
const groups = new Map<string, ObjectVariant[]>();
|
||||
for (const variant of variants) {
|
||||
if (!allowed(variant.name)) continue;
|
||||
const { group } = splitLabel(variant.label, other);
|
||||
groups.set(group, [...(groups.get(group) ?? []), variant]);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* What creating the action sends: its type, and each option the admin set.
|
||||
* Results the server fills in stay out, and so does a secret still showing
|
||||
* its mask, which means it wasn't changed.
|
||||
*/
|
||||
export function actionPayload(
|
||||
type: string,
|
||||
fields: Fields | null,
|
||||
values: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = { '@type': type };
|
||||
for (const [name, def] of Object.entries(fields?.properties ?? {})) {
|
||||
if (def.update === 'serverSet' || !(name in values)) continue;
|
||||
const secret = def.type.type === 'string' && (def.type.format === 'secret' || def.type.format === 'secretText');
|
||||
if (secret && values[name] === SECRET_MASK) continue;
|
||||
payload[name] = values[name];
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export type ActionResult =
|
||||
/** What the server sent back besides ids, or null when that was nothing. */
|
||||
{ kind: 'done'; returned: Record<string, unknown> | null } | { kind: 'refused'; reason: string | null };
|
||||
|
||||
const IDS = new Set(['id', 'blobId']);
|
||||
|
||||
/** Reads the answer to creating the action. An answer that mentions neither outcome counts as done. */
|
||||
export function readActionResult(result: Record<string, unknown> | undefined): ActionResult {
|
||||
const created = (result?.created as Record<string, Record<string, unknown>> | null | undefined)?.[CREATE_ID];
|
||||
const refused = (result?.notCreated as Record<string, JmapSetError> | null | undefined)?.[CREATE_ID];
|
||||
if (!created && refused) return { kind: 'refused', reason: refused.description ?? null };
|
||||
const returned = Object.fromEntries(Object.entries(created ?? {}).filter(([key]) => !IDS.has(key)));
|
||||
return { kind: 'done', returned: Object.keys(returned).length > 0 ? returned : null };
|
||||
}
|
||||
|
||||
/** The form's sections cut down to the options an admin sets; sections of results drop out. */
|
||||
export function inputSections(fields: Fields, form: Form): FormSection[] {
|
||||
return form.sections
|
||||
.map((section) => ({
|
||||
...section,
|
||||
fields: section.fields.filter((ff) => fields.properties[ff.name]?.update === 'mutable'),
|
||||
}))
|
||||
.filter((section) => section.fields.length > 0);
|
||||
}
|
||||
|
||||
/** Text, numbers, choices and object references must be filled in unless optional. */
|
||||
const CHECKED = new Set(['string', 'number', 'enum', 'objectId']);
|
||||
|
||||
export function missingInputs(
|
||||
sections: FormSection[],
|
||||
fields: Fields,
|
||||
values: Record<string, unknown>,
|
||||
required: string,
|
||||
): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
for (const ff of sections.flatMap((s) => s.fields)) {
|
||||
const type = fields.properties[ff.name]?.type;
|
||||
if (!type || !CHECKED.has(type.type) || ('nullable' in type && type.nullable)) continue;
|
||||
const value = values[ff.name];
|
||||
if (value === undefined || value === null || value === '') errors[ff.name] = required;
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
Reference in new issue
Block a user