From 737eac2279256afe50fda6dc65f1057dda0f918f Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 18 Oct 2026 19:52:51 -0700 Subject: [PATCH 1/2] Rewrite the setup wizard Its rules are now plain, tested code in wizard.ts: which fields each step shows, which are required, where the server's refusal lands, and what the finish screen lists. The wizard itself is rewritten on top of them, with the same steps, look and update. --- src/components/bootstrap/BootstrapWizard.tsx | 550 +++++++------------ src/components/bootstrap/wizard.test.ts | 170 ++++++ src/components/bootstrap/wizard.ts | 131 +++++ 3 files changed, 504 insertions(+), 347 deletions(-) create mode 100644 src/components/bootstrap/wizard.test.ts create mode 100644 src/components/bootstrap/wizard.ts diff --git a/src/components/bootstrap/BootstrapWizard.tsx b/src/components/bootstrap/BootstrapWizard.tsx index 86a0775..094643b 100644 --- a/src/components/bootstrap/BootstrapWizard.tsx +++ b/src/components/bootstrap/BootstrapWizard.tsx @@ -1,274 +1,132 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * 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>({}); - const [originalData, setOriginalData] = useState>({}); - const [currentPage, setCurrentPage] = useState(0); + const [values, setValues] = useState>({}); + const [step, setStep] = useState(0); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [fieldErrors, setFieldErrors] = useState>({}); - const [generalError, setGeneralError] = useState(null); - const [successExtras, setSuccessExtras] = useState | null>(null); + const [error, setError] = useState(null); + const [generated, setGenerated] = useState | null>(null); const [copied, setCopied] = useState(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(['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> | 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[] | 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): Record => { - const errors: Record = {}; - 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, page: number | null) => { + setFieldErrors(errors); + if (page !== null) setStep(page); + }; - const fieldToPage = new Map(); - 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 = {}; - 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 {t('form.loadingSchema', 'Loading schema...')}; + } + if (loading) { + return {t('bootstrap.loadingSetup', 'Loading setup...')}; + } - 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 = {}; - 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 ( - -
- - {t('form.loadingSchema', 'Loading schema...')} -
-
- ); - } - - if (loading) { - return ( - -
- - {t('bootstrap.loadingSetup', 'Loading setup...')} -
-
- ); - } - - if (successExtras !== null) { - return ( - - + - + ); } - if (sections.length === 0) { + if (pages.length === 0) { return ( - +
{t('bootstrap.emptyForm', 'Setup form is empty. The server did not return any bootstrap fields.')}
-
+ ); } - 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 ( - +

{t('bootstrap.welcome', 'Welcome to inbuxa')}

@@ -342,30 +226,38 @@ export function BootstrapWizard() {

- +
+ {pages.map((_, i) => ( +
+ ))} +
- {generalError && ( + {error && (
-

{generalError}

+

{error}

)} - {current.title && ( + {page.title && ( - {current.title} + {page.title} )} - +
+ {/* Enterprise-only options stay out of setup. */} - {current.fields.map(({ formField, field }) => ( + {page.fields.map(({ formField, field }) => ( 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() {
- -
- {t('bootstrap.stepOf', 'Step {{current}} of {{total}}', { - current: currentPage + 1, - total: sections.length, - })} -
- {isLastPage ? ( - ) : ( - )}
- + ); } -function WizardShell({ children }: { children: React.ReactNode }) { +/** The page around every step: the logo bar and a centered column. */ +function Shell({ children }: { children: ReactNode }) { return (
@@ -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 ( -
- {Array.from({ length: total }, (_, i) => ( -
- ))} -
+ +
+ + {children} +
+
); } -function SuccessScreen({ - extras, - fields, - labelsByName, - onCopy, - copied, -}: { - extras: Record; - fields: Fields; - labelsByName: Map; - 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 (
@@ -466,7 +335,7 @@ function SuccessScreen({

{t('bootstrap.complete', 'Setup complete')}

- {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({

- {hasCredentials && ( + {values.length > 0 && ( - {entries.map(([key, value]) => { - const field = fields.properties[key]; - const label = labelsByName.get(key) ?? field?.description?.split('\n')[0] ?? key; - return ( -
-
{label}
-
- {value} - -
+ {values.map(([key, value]) => ( +
+
{label(key)}
+
+ {value} +
- ); - })} +
+ ))} )} @@ -511,13 +376,4 @@ function SuccessScreen({ ); } -function labelsByFieldName(form: Form | null): Map { - const map = new Map(); - 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; diff --git a/src/components/bootstrap/wizard.test.ts b/src/components/bootstrap/wizard.test.ts new file mode 100644 index 0000000..389f05b --- /dev/null +++ b/src/components/bootstrap/wizard.test.ts @@ -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, more: Partial = {}) => + ({ 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'); + }); +}); diff --git a/src/components/bootstrap/wizard.ts b/src/components/bootstrap/wizard.ts new file mode 100644 index 0000000..af8e2de --- /dev/null +++ b/src/components/bootstrap/wizard.ts @@ -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; + 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, + required: string, +): Record { + const errors: Record = {}; + 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, 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(); + 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 } + | { 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 | undefined): SetOutcome { + const updated = result?.updated as Record | null> | null | undefined; + const notUpdated = result?.notUpdated as Record | 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, 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(); + 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; +} -- 2.54.0 From 39d9062ccff84d2333d1198b4a8285bfe3a88816 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 18 Oct 2026 20:01:48 -0700 Subject: [PATCH 2/2] Rewrite the actions page Its rules are now plain, tested code in actions.ts: grouping actions by label, what creating one sends (no server results, no untouched secrets), reading the answer, and which options are required. The page is rewritten on top of them, with the same look and flow. An action without options now shows its label while it runs, not its internal name. --- src/features/actions/ActionPage.tsx | 613 ++++++++++----------------- src/features/actions/actions.test.ts | 144 +++++++ src/features/actions/actions.ts | 103 +++++ 3 files changed, 466 insertions(+), 394 deletions(-) create mode 100644 src/features/actions/actions.test.ts create mode 100644 src/features/actions/actions.ts diff --git a/src/features/actions/ActionPage.tsx b/src/features/actions/ActionPage.tsx index 7d7e24c..ce237aa 100644 --- a/src/features/actions/ActionPage.tsx +++ b/src/features/actions/ActionPage.tsx @@ -1,180 +1,105 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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 } - | { kind: 'submitting'; variant: ObjectVariant } - | { - kind: 'result'; - variant: ObjectVariant; - props: Record | null; - inputData: Record; - } - | { kind: 'error'; variant: ObjectVariant; error: string }; + | { kind: 'form'; variant: ObjectVariant; values: Record } + | { kind: 'running'; variant: ObjectVariant } + | { kind: 'done'; variant: ObjectVariant; values: Record; returned: Record | 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({ kind: 'pick' }); + const [stage, setStage] = useState({ 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(); - 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) => { + 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) => { - if (!schema || !resolved) return; - setState({ kind: 'submitting', variant }); - - const fields = variant.schemaName ? schema.fields[variant.schemaName] : null; - const payload: Record = { '@type': variant.name }; - - if (fields) { - for (const [name, def] of Object.entries(fields.properties)) { - if (def.update === 'serverSet') continue; - if (name in formData) { - const isSecret = - def.type.type === 'string' && (def.type.format === 'secret' || def.type.format === 'secretText'); - if (isSecret && formData[name] === SECRET_MASK) continue; - payload[name] = formData[name]; - } - } - } - - try { - const accountId = getAccountId(resolved.objectName); - const responses = await jmapSet(resolved.objectName, accountId, { - create: { 'action-0': payload }, - }); - const setResponse = responses[responses.length - 1]; - const setResult = setResponse[1] as unknown as JmapSetResponse; - - if (setResult.created && setResult.created['action-0']) { - const created = setResult.created['action-0']; - const noisyKeys = new Set(['id', 'blobId']); - const extraKeys = Object.keys(created).filter((k) => !noisyKeys.has(k)); - if (extraKeys.length > 0) { - const extra: Record = {}; - for (const k of extraKeys) extra[k] = created[k]; - setState({ kind: 'result', variant, props: extra, inputData: formData }); - } else { - setState({ kind: 'result', variant, props: null, inputData: formData }); - } - } else if (setResult.notCreated && setResult.notCreated['action-0']) { - const err = setResult.notCreated['action-0']; - setState({ - kind: 'error', - variant, - error: (err as { description?: string }).description ?? t('actions.actionFailed', 'Action failed.'), - }); - } else { - setState({ kind: 'result', variant, props: null, inputData: formData }); - } - } catch (e) { - setState({ - kind: 'error', - variant, - error: e instanceof Error ? e.message : t('actions.actionFailed', 'Action failed.'), - }); - } - }, - [schema, resolved, t], - ); - - // inbuxa: `?action=` 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=` 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(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(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 ( -
- + 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 ( +
+
+

{list?.title ?? t('actions.title', 'Actions')}

+ {subtitle &&

{subtitle}

} +
+
+ {[...groupActions(variants, allowed, other)].map(([group, members]) => ( +
+

{group}

+
+ {members.map((variant) => ( + + ))} +
+
+ ))} +
+
+ ); + } + + case 'confirm': + return ( + - {state.variant.label} + {stage.variant.label} @@ -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.')}

- - +
-
-
- ); - } + + ); - if (state.kind === 'pick') { - return ( -
-
-

{title}

- {subtitle &&

{subtitle}

} + case 'form': + return ( + void run(stage.variant, values)} + onBack={toList} + /> + ); + + case 'running': + return ( +
+ +

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

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

{group}

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

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

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

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

-

{state.error}

+

{stage.error}

-
-
-
- ); - } - - return null; -} - -async function executeAction( - schema: Schema, - objectName: string, - variantName: string, - formData: Record, - setState: React.Dispatch>, -) { - const variant = { name: variantName, label: variantName } as ObjectVariant; - setState({ kind: 'submitting', variant }); - - try { - const accountId = getAccountId(objectName); - const payload: Record = { '@type': variantName, ...formData }; - const responses = await jmapSet(objectName, accountId, { - create: { 'action-0': payload }, - }); - const setResponse = responses[responses.length - 1]; - const setResult = setResponse[1] as unknown as JmapSetResponse; - - const sch = resolveSchema(schema, objectName); - const realVariant = - sch?.type === 'multiple' ? (sch.variants.find((v) => v.name === variantName) ?? variant) : variant; - - if (setResult.created && setResult.created['action-0']) { - const created = setResult.created['action-0']; - const noisyKeys = new Set(['id', 'blobId']); - const extraKeys = Object.keys(created).filter((k) => !noisyKeys.has(k)); - if (extraKeys.length > 0) { - const extra: Record = {}; - for (const k of extraKeys) extra[k] = created[k]; - setState({ kind: 'result', variant: realVariant, props: extra, inputData: formData }); - } else { - setState({ kind: 'result', variant: realVariant, props: null, inputData: formData }); - } - } else if (setResult.notCreated && setResult.notCreated['action-0']) { - const err = setResult.notCreated['action-0']; - setState({ - kind: 'error', - variant: realVariant, - error: (err as { description?: string }).description ?? i18n.t('actions.actionFailed', 'Action failed.'), - }); - } else { - setState({ kind: 'result', variant: realVariant, props: null, inputData: formData }); - } - } catch (e) { - setState({ - kind: 'error', - variant, - error: e instanceof Error ? e.message : i18n.t('actions.actionFailed', 'Action failed.'), - }); + + ); } } -function ActionFormView({ - schema, - variant, - formData: initialData, - onSubmit, - onBack, -}: { +function Narrow({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +interface OptionsFormProps { schema: Schema; variant: ObjectVariant; - formData: Record; - onSubmit: (data: Record) => void; + initial: Record; + onRun: (values: Record) => 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>(initialData); + const [values, setValues] = useState(initial); const [errors, setErrors] = useState>({}); - 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 = {}; - 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({

{variant.label}

- {mutableSections.map((section, si) => ( - + {sections.map((section, i) => ( + {section.title && ( {section.title} @@ -456,22 +289,18 @@ function ActionFormView({ )}
- {section.fields.map((ff) => { - const def = fields.properties[ff.name]; - if (!def) return null; - return ( - handleChange(ff.name, v)} - readOnly={false} - schema={schema} - error={errors[ff.name]} - /> - ); - })} + {section.fields.map((ff) => ( + change(ff.name, v)} + readOnly={false} + schema={schema} + error={errors[ff.name]} + /> + ))}
@@ -481,7 +310,7 @@ function ActionFormView({ - @@ -490,24 +319,20 @@ function ActionFormView({ ); } -function ActionResultView({ - schema, - variant, - props, - inputData, - onNewAction, -}: { +interface ActionDoneProps { schema: Schema; variant: ObjectVariant; - props: Record | null; - inputData: Record; - onNewAction: () => void; -}) { - const { t } = useTranslation(); - const mergedData = useMemo(() => ({ ...inputData, ...(props ?? {}) }), [inputData, props]); - const hasData = variant.schemaName && Object.keys(mergedData).length > 0; + values: Record; + returned: Record | 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 (
@@ -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 && ( 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({ )} - diff --git a/src/features/actions/actions.test.ts b/src/features/actions/actions.test.ts new file mode 100644 index 0000000..b5c3367 --- /dev/null +++ b/src/features/actions/actions.test.ts @@ -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, 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({}); + }); +}); diff --git a/src/features/actions/actions.ts b/src/features/actions/actions.ts new file mode 100644 index 0000000..1633056 --- /dev/null +++ b/src/features/actions/actions.ts @@ -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 { + const groups = new Map(); + 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, +): Record { + const payload: Record = { '@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 | 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 | undefined): ActionResult { + const created = (result?.created as Record> | null | undefined)?.[CREATE_ID]; + const refused = (result?.notCreated as Record | 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, + required: string, +): Record { + const errors: Record = {}; + 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; +} -- 2.54.0