Merge pull request 'Apply saved settings on the server after a save' (#15) from feature/apply-saved-settings into main
This commit was merged in pull request #15.
This commit is contained in:
@@ -63,6 +63,7 @@ import { logFormChange } from '@/lib/debug';
|
|||||||
import { FieldWidget } from '@/components/forms/FieldWidget';
|
import { FieldWidget } from '@/components/forms/FieldWidget';
|
||||||
import { DnsConnectCard } from '@/features/dns/DnsConnectCard';
|
import { DnsConnectCard } from '@/features/dns/DnsConnectCard';
|
||||||
import { isSieveScriptField } from '@/lib/sievepad';
|
import { isSieveScriptField } from '@/lib/sievepad';
|
||||||
|
import { reloadActionFor } from '@/lib/settingsApply';
|
||||||
|
|
||||||
import type { Field, Fields, Form, FormField, Schema } from '@/types/schema';
|
import type { Field, Fields, Form, FormField, Schema } from '@/types/schema';
|
||||||
import type { JmapSetResponse, JmapSetError, JmapMethodCall } from '@/types/jmap';
|
import type { JmapSetResponse, JmapSetError, JmapMethodCall } from '@/types/jmap';
|
||||||
@@ -529,10 +530,13 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
|||||||
setCreatedObjectId(newId);
|
setCreatedObjectId(newId);
|
||||||
setServerCreatedProps(extraProps);
|
setServerCreatedProps(extraProps);
|
||||||
} else {
|
} else {
|
||||||
|
// inbuxa: a settings object says "Saved and applied" once the server has it.
|
||||||
|
if (!reloadActionFor(obj.objectName)) {
|
||||||
toast({
|
toast({
|
||||||
title: t('form.createdSuccess', 'Created successfully'),
|
title: t('form.createdSuccess', 'Created successfully'),
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
});
|
});
|
||||||
|
}
|
||||||
setOriginalData({ ...formData });
|
setOriginalData({ ...formData });
|
||||||
navigate(`/${section}/${viewName}`);
|
navigate(`/${section}/${viewName}`);
|
||||||
}
|
}
|
||||||
@@ -616,10 +620,13 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
|||||||
const setResult = setResponse[1] as unknown as JmapSetResponse;
|
const setResult = setResponse[1] as unknown as JmapSetResponse;
|
||||||
|
|
||||||
if (setResult.updated && updateId in setResult.updated) {
|
if (setResult.updated && updateId in setResult.updated) {
|
||||||
|
// inbuxa: a settings object says "Saved and applied" once the server has it.
|
||||||
|
if (!reloadActionFor(obj.objectName)) {
|
||||||
toast({
|
toast({
|
||||||
title: t('form.savedSuccess', 'Saved successfully'),
|
title: t('form.savedSuccess', 'Saved successfully'),
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (isSingleton) {
|
if (isSingleton) {
|
||||||
const getResponses = await jmapGet(obj.objectName, accountId, ['singleton'], fetchProperties);
|
const getResponses = await jmapGet(obj.objectName, accountId, ['singleton'], fetchProperties);
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* inbuxa: shown when saved settings are stored but the server couldn't apply
|
||||||
|
* them. It stays until they are applied or it is dismissed, names the object
|
||||||
|
* the server couldn't build and links to it, and offers to try again.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { AlertTriangle, Loader2, X } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { humanize } from '@/lib/humanize';
|
||||||
|
import { resolveList } from '@/lib/schemaResolver';
|
||||||
|
import { useSchemaStore } from '@/stores/schemaStore';
|
||||||
|
import { useSettingsApplyStore } from '@/stores/settingsApplyStore';
|
||||||
|
|
||||||
|
export function SettingsApplyBanner() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const failure = useSettingsApplyStore((s) => s.failure);
|
||||||
|
const applying = useSettingsApplyStore((s) => s.applying);
|
||||||
|
const applyNow = useSettingsApplyStore((s) => s.applyNow);
|
||||||
|
const dismiss = useSettingsApplyStore((s) => s.dismiss);
|
||||||
|
const schema = useSchemaStore((s) => s.schema);
|
||||||
|
const viewToSection = useSchemaStore((s) => s.viewToSection);
|
||||||
|
|
||||||
|
if (!failure) return null;
|
||||||
|
|
||||||
|
let objectLabel: string | null = null;
|
||||||
|
let objectLink: string | null = null;
|
||||||
|
if (failure.object) {
|
||||||
|
const objectName = `x:${failure.object.object}`;
|
||||||
|
const objectType = schema?.objects[objectName];
|
||||||
|
const list = schema ? resolveList(schema, objectName, objectName) : null;
|
||||||
|
objectLabel = list?.singularName ?? humanize(failure.object.object);
|
||||||
|
const section = viewToSection[objectName];
|
||||||
|
if (section) {
|
||||||
|
objectLink =
|
||||||
|
objectType?.type === 'singleton' || failure.object.id === 'singleton'
|
||||||
|
? `/${section}/${objectName}`
|
||||||
|
: `/${section}/${objectName}/${encodeURIComponent(failure.object.id)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="mb-4 flex flex-wrap items-start gap-x-3 gap-y-2 rounded-xl border border-destructive/40 bg-destructive/5 px-4 py-3 text-sm"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
||||||
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
|
<p className="font-medium">
|
||||||
|
{t('settingsApply.failed', "Saved, but the server couldn't apply the settings: {{reason}}", {
|
||||||
|
reason: failure.message,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{objectLabel && (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{t('settingsApply.failedObject', 'The problem is in {{object}}.', { object: objectLabel })}{' '}
|
||||||
|
{objectLink && (
|
||||||
|
<Link to={objectLink} className="font-medium text-primary hover:underline">
|
||||||
|
{t('settingsApply.openObject', 'Open it')}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
'settingsApply.stillRunning',
|
||||||
|
'The server keeps running on the settings it had. Your changes are saved and apply once this is fixed.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" onClick={() => void applyNow()} disabled={applying}>
|
||||||
|
{applying && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
|
||||||
|
{t('settingsApply.applyNow', 'Apply now')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={dismiss}
|
||||||
|
aria-label={t('settingsApply.dismiss', 'Dismiss')}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -345,6 +345,17 @@
|
|||||||
"periodValue": "Period value"
|
"periodValue": "Period value"
|
||||||
},
|
},
|
||||||
"sections": "Sections",
|
"sections": "Sections",
|
||||||
|
"settingsApply": {
|
||||||
|
"applied": "Saved and applied",
|
||||||
|
"applyNow": "Apply now",
|
||||||
|
"dismiss": "Dismiss",
|
||||||
|
"failed": "Saved, but the server couldn't apply the settings: {{reason}}",
|
||||||
|
"failedObject": "The problem is in {{object}}.",
|
||||||
|
"noAnswer": "The server did not answer.",
|
||||||
|
"notConfirmed": "The server did not confirm the reload.",
|
||||||
|
"openObject": "Open it",
|
||||||
|
"stillRunning": "The server keeps running on the settings it had. Your changes are saved and apply once this is fixed."
|
||||||
|
},
|
||||||
"sievepad": {
|
"sievepad": {
|
||||||
"debug": "Debug",
|
"debug": "Debug",
|
||||||
"defaultName": "Sieve script",
|
"defaultName": "Sieve script",
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
describeApplyFailure,
|
||||||
|
describeRequestFailure,
|
||||||
|
reloadActionFor,
|
||||||
|
reloadActionsFor,
|
||||||
|
writesRegistry,
|
||||||
|
writtenRegistryTypes,
|
||||||
|
} from './settingsApply';
|
||||||
|
import type { JmapMethodResponse } from '@/types/jmap';
|
||||||
|
|
||||||
|
describe('reloadActionFor', () => {
|
||||||
|
it('reloads settings objects', () => {
|
||||||
|
expect(reloadActionFor('x:MtaDeliverySchedule')).toBe('ReloadSettings');
|
||||||
|
expect(reloadActionFor('x:SpamPyzor')).toBe('ReloadSettings');
|
||||||
|
expect(reloadActionFor('x:NetworkListener')).toBe('ReloadSettings');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reloads a type it has never heard of', () => {
|
||||||
|
expect(reloadActionFor('x:SomethingNew')).toBe('ReloadSettings');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the narrower action where one exists', () => {
|
||||||
|
expect(reloadActionFor('x:Certificate')).toBe('ReloadTlsCertificates');
|
||||||
|
expect(reloadActionFor('x:StoreLookup')).toBe('ReloadLookupStores');
|
||||||
|
expect(reloadActionFor('x:MemoryLookupKeyValue')).toBe('ReloadLookupStores');
|
||||||
|
expect(reloadActionFor('x:BlockedIp')).toBe('ReloadBlockedIps');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips what the server reloads on write', () => {
|
||||||
|
expect(reloadActionFor('x:Directory')).toBeNull();
|
||||||
|
expect(reloadActionFor('x:Authentication')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips data read live, operations and stores', () => {
|
||||||
|
for (const type of ['Account', 'Domain', 'DkimSignature', 'Tenant', 'Action', 'QueuedMessage', 'DataStore']) {
|
||||||
|
expect(reloadActionFor(`x:${type}`)).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores anything outside the registry', () => {
|
||||||
|
expect(reloadActionFor('Email')).toBeNull();
|
||||||
|
expect(reloadActionFor('inbuxa:ProtocolPolicy')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reloadActionsFor', () => {
|
||||||
|
it('collapses duplicates and puts settings last', () => {
|
||||||
|
expect(reloadActionsFor(['x:MtaRoute', 'x:Certificate', 'x:MtaDeliverySchedule', 'x:Account'])).toEqual([
|
||||||
|
'ReloadTlsCertificates',
|
||||||
|
'ReloadSettings',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('registry writes', () => {
|
||||||
|
it('recognizes an x: set call among others', () => {
|
||||||
|
expect(writesRegistry([['x:MtaRoute/get', {}, '0']])).toBe(false);
|
||||||
|
expect(
|
||||||
|
writesRegistry([
|
||||||
|
['Blob/upload', {}, 'b'],
|
||||||
|
['x:MtaRoute/set', {}, '0'],
|
||||||
|
]),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports only the types a response changed', () => {
|
||||||
|
const responses: JmapMethodResponse[] = [
|
||||||
|
['Blob/upload', { created: { b: {} } }, 'b'],
|
||||||
|
['x:MtaRoute/set', { created: null, updated: { a: null }, destroyed: null }, '0'],
|
||||||
|
['x:SpamPyzor/set', { created: {}, updated: {}, destroyed: [], notUpdated: { singleton: {} } }, '1'],
|
||||||
|
['x:Certificate/set', { destroyed: ['c1'] }, '2'],
|
||||||
|
['error', { type: 'serverFail' }, '3'],
|
||||||
|
];
|
||||||
|
expect(writtenRegistryTypes(responses)).toEqual(['x:MtaRoute', 'x:Certificate']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('describeApplyFailure', () => {
|
||||||
|
it('keeps the server message and the object it named', () => {
|
||||||
|
expect(
|
||||||
|
describeApplyFailure({
|
||||||
|
type: 'validationFailed',
|
||||||
|
description: 'Failed to resolve Pyzor host',
|
||||||
|
objectId: { object: 'SpamPyzor', id: 'singleton' },
|
||||||
|
}),
|
||||||
|
).toEqual({ message: 'Failed to resolve Pyzor host', object: { object: 'SpamPyzor', id: 'singleton' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spells out validation errors when there is no description', () => {
|
||||||
|
const failure = describeApplyFailure({
|
||||||
|
type: 'validationFailed',
|
||||||
|
objectId: { object: 'MtaRoute', id: 'b' },
|
||||||
|
validationErrors: [{ type: 'Required', property: 'address' }],
|
||||||
|
});
|
||||||
|
expect(failure.message).toBe('address: This field is required.');
|
||||||
|
expect(failure.object).toEqual({ object: 'MtaRoute', id: 'b' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the error type', () => {
|
||||||
|
expect(describeApplyFailure({ type: 'forbidden' }).message).toBe(
|
||||||
|
'You do not have permission to perform this action.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('describeRequestFailure', () => {
|
||||||
|
it('reads thrown errors and method errors', () => {
|
||||||
|
expect(describeRequestFailure(new Error('Network down')).message).toBe('Network down');
|
||||||
|
expect(describeRequestFailure({ type: 'forbidden', description: 'No' }).message).toBe('No');
|
||||||
|
expect(describeRequestFailure(undefined).message).toBe('The server did not answer.');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* inbuxa: which saved registry objects the running server has to be told to
|
||||||
|
* apply, and how to read its answer when it can't.
|
||||||
|
*
|
||||||
|
* A write to the registry is stored at once, but most settings only take
|
||||||
|
* effect when the server rebuilds its configuration from the registry: the
|
||||||
|
* x:Action ReloadSettings action, which also carries the change to every
|
||||||
|
* node of a cluster. The server does that by itself on a write for a few
|
||||||
|
* types only (directories and the default authentication). Everything else
|
||||||
|
* waited for someone to open Management > Actions and reload by hand.
|
||||||
|
*
|
||||||
|
* A reload is all or nothing: the new configuration replaces the running one
|
||||||
|
* only when every settings object builds, so applying straight after a save
|
||||||
|
* can't leave the server half configured. When something doesn't build, the
|
||||||
|
* server keeps what it had and names the object and the problem.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import i18n from '@/i18n';
|
||||||
|
import { friendlySetError, validationErrorMessage } from '@/lib/jmapErrors';
|
||||||
|
import type { JmapMethodCall, JmapMethodResponse, JmapObjectRef, JmapSetError } from '@/types/jmap';
|
||||||
|
|
||||||
|
export type ReloadAction = 'ReloadSettings' | 'ReloadTlsCertificates' | 'ReloadLookupStores' | 'ReloadBlockedIps';
|
||||||
|
|
||||||
|
/** The order the actions run in when more than one is due. */
|
||||||
|
export const RELOAD_ORDER: readonly ReloadAction[] = [
|
||||||
|
'ReloadLookupStores',
|
||||||
|
'ReloadTlsCertificates',
|
||||||
|
'ReloadBlockedIps',
|
||||||
|
'ReloadSettings',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Types whose own reload action applies them; the server rebuilds nothing else for these. */
|
||||||
|
const OWN_ACTION: Record<string, ReloadAction> = {
|
||||||
|
Certificate: 'ReloadTlsCertificates',
|
||||||
|
StoreLookup: 'ReloadLookupStores',
|
||||||
|
HttpLookup: 'ReloadLookupStores',
|
||||||
|
MemoryLookupKey: 'ReloadLookupStores',
|
||||||
|
MemoryLookupKeyValue: 'ReloadLookupStores',
|
||||||
|
BlockedIp: 'ReloadBlockedIps',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Types that need no reload after a write. Anything not listed here is reloaded. */
|
||||||
|
const NOTHING_TO_APPLY = new Set<string>([
|
||||||
|
// The server reloads these itself on every write, here and on every node.
|
||||||
|
'Directory',
|
||||||
|
'Authentication',
|
||||||
|
// Read from the registry when used, or kept current by cache invalidation.
|
||||||
|
'Account',
|
||||||
|
'AccountPassword',
|
||||||
|
'AccountSettings',
|
||||||
|
'ApiKey',
|
||||||
|
'AppPassword',
|
||||||
|
'Domain',
|
||||||
|
'DkimSignature',
|
||||||
|
'MailingList',
|
||||||
|
'MaskedEmail',
|
||||||
|
'OAuthClient',
|
||||||
|
'PublicKey',
|
||||||
|
'Role',
|
||||||
|
'Tenant',
|
||||||
|
// Operations, records and telemetry rather than settings.
|
||||||
|
'Action',
|
||||||
|
'ArchivedItem',
|
||||||
|
'ArfExternalReport',
|
||||||
|
'Bootstrap',
|
||||||
|
'ClusterNode',
|
||||||
|
'DmarcExternalReport',
|
||||||
|
'DmarcInternalReport',
|
||||||
|
'Log',
|
||||||
|
'Metric',
|
||||||
|
'QueuedMessage',
|
||||||
|
'SpamTrainingSample',
|
||||||
|
'Task',
|
||||||
|
'TlsExternalReport',
|
||||||
|
'TlsInternalReport',
|
||||||
|
'Trace',
|
||||||
|
// Stores are opened once at startup and a reload keeps the ones it has, so
|
||||||
|
// saying "applied" would be untrue. They take effect on a restart.
|
||||||
|
'BlobStore',
|
||||||
|
'Coordinator',
|
||||||
|
'DataStore',
|
||||||
|
'InMemoryStore',
|
||||||
|
'SearchStore',
|
||||||
|
// Applications are unpacked by their own manager, which no reload reaches.
|
||||||
|
'Application',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The action that applies a write to `objectName` (an `x:` registry name), or
|
||||||
|
* null when the write needs none. Types this list doesn't know are reloaded:
|
||||||
|
* an unneeded reload costs a second, a missing one leaves a setting unapplied.
|
||||||
|
*/
|
||||||
|
export function reloadActionFor(objectName: string): ReloadAction | null {
|
||||||
|
if (!objectName.startsWith('x:')) return null;
|
||||||
|
const type = objectName.slice(2);
|
||||||
|
if (NOTHING_TO_APPLY.has(type)) return null;
|
||||||
|
return OWN_ACTION[type] ?? 'ReloadSettings';
|
||||||
|
}
|
||||||
|
|
||||||
|
const REGISTRY_SET = /^(x:[A-Za-z0-9]+)\/set$/;
|
||||||
|
|
||||||
|
/** Whether a request writes to the registry: any `x:<Type>/set` call. */
|
||||||
|
export function writesRegistry(methodCalls: JmapMethodCall[]): boolean {
|
||||||
|
return methodCalls.some(([name]) => REGISTRY_SET.test(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The registry types a response says were created, changed or destroyed. */
|
||||||
|
export function writtenRegistryTypes(methodResponses: JmapMethodResponse[]): string[] {
|
||||||
|
const types = new Set<string>();
|
||||||
|
for (const [name, result] of methodResponses) {
|
||||||
|
const match = REGISTRY_SET.exec(name);
|
||||||
|
if (!match || !result) continue;
|
||||||
|
const created = result.created as Record<string, unknown> | null | undefined;
|
||||||
|
const updated = result.updated as Record<string, unknown> | null | undefined;
|
||||||
|
const destroyed = result.destroyed as unknown[] | null | undefined;
|
||||||
|
if (
|
||||||
|
(created && Object.keys(created).length > 0) ||
|
||||||
|
(updated && Object.keys(updated).length > 0) ||
|
||||||
|
(destroyed && destroyed.length > 0)
|
||||||
|
) {
|
||||||
|
types.add(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...types];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The actions due for a set of written types, in the order they run. */
|
||||||
|
export function reloadActionsFor(objectNames: Iterable<string>): ReloadAction[] {
|
||||||
|
const due = new Set<ReloadAction>();
|
||||||
|
for (const name of objectNames) {
|
||||||
|
const action = reloadActionFor(name);
|
||||||
|
if (action) due.add(action);
|
||||||
|
}
|
||||||
|
return RELOAD_ORDER.filter((a) => due.has(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplyFailure {
|
||||||
|
/** What went wrong, in the server's words where it gave any. */
|
||||||
|
message: string;
|
||||||
|
/** The settings object that didn't build, when the server named one. */
|
||||||
|
object?: JmapObjectRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads a failed reload action into something to show an administrator. */
|
||||||
|
export function describeApplyFailure(err: JmapSetError): ApplyFailure {
|
||||||
|
let message = err.description?.trim() ?? '';
|
||||||
|
if (!message && err.validationErrors && err.validationErrors.length > 0) {
|
||||||
|
message = err.validationErrors
|
||||||
|
.map((ve) => (ve.property ? `${ve.property}: ${validationErrorMessage(ve)}` : validationErrorMessage(ve)))
|
||||||
|
.join('; ');
|
||||||
|
}
|
||||||
|
if (!message) message = friendlySetError(err);
|
||||||
|
const object = typeof err.objectId === 'object' && err.objectId?.object ? err.objectId : undefined;
|
||||||
|
return object ? { message, object } : { message };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A failure that came back as a method error or a failed request rather than a set error. */
|
||||||
|
export function describeRequestFailure(err: unknown): ApplyFailure {
|
||||||
|
if (err instanceof Error && err.message) return { message: err.message };
|
||||||
|
if (err && typeof err === 'object') {
|
||||||
|
const e = err as { type?: unknown; description?: unknown };
|
||||||
|
if (typeof e.description === 'string' && e.description) return { message: e.description };
|
||||||
|
if (typeof e.type === 'string' && e.type) {
|
||||||
|
return { message: i18n.t('jmapErrors.unexpected', 'Unexpected error ({{type}}).', { type: e.type }) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { message: i18n.t('settingsApply.noAnswer', 'The server did not answer.') };
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import { TopBar } from '@/components/layout/TopBar';
|
|||||||
import { Sidebar } from '@/components/layout/Sidebar';
|
import { Sidebar } from '@/components/layout/Sidebar';
|
||||||
import { SectionNav } from '@/components/layout/SectionNav';
|
import { SectionNav } from '@/components/layout/SectionNav';
|
||||||
import { MainContent } from '@/components/layout/MainContent';
|
import { MainContent } from '@/components/layout/MainContent';
|
||||||
|
import { SettingsApplyBanner } from '@/components/layout/SettingsApplyBanner';
|
||||||
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
|
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
|
||||||
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||||
import {
|
import {
|
||||||
@@ -320,6 +321,7 @@ export default function AdminPanel() {
|
|||||||
!useSectionNav && sidebarOpen && (sidebarCollapsed ? 'md:ml-[4.5rem]' : 'md:ml-64'),
|
!useSectionNav && sidebarOpen && (sidebarCollapsed ? 'md:ml-[4.5rem]' : 'md:ml-64'),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<SettingsApplyBanner />
|
||||||
<ErrorBoundary key={activeAccountId ?? 'none'}>
|
<ErrorBoundary key={activeAccountId ?? 'none'}>
|
||||||
<MainContent viewName={viewName} id={id} section={section} />
|
<MainContent viewName={viewName} id={id} section={section} />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { apiFetch } from '@/services/api';
|
|||||||
import { logJmapExchange } from '@/lib/debug';
|
import { logJmapExchange } from '@/lib/debug';
|
||||||
import type { JmapMethodCall, JmapMethodResponse, JmapQueryResponse, JmapResponse } from '@/types/jmap';
|
import type { JmapMethodCall, JmapMethodResponse, JmapQueryResponse, JmapResponse } from '@/types/jmap';
|
||||||
import type { Schema } from '@/types/schema';
|
import type { Schema } from '@/types/schema';
|
||||||
|
import { writesRegistry, writtenRegistryTypes } from '@/lib/settingsApply';
|
||||||
|
|
||||||
const JMAP_USING = [
|
const JMAP_USING = [
|
||||||
'urn:ietf:params:jmap:core',
|
'urn:ietf:params:jmap:core',
|
||||||
@@ -35,10 +36,44 @@ export function getAccountId(objectType: string): string {
|
|||||||
return activeAccountId;
|
return activeAccountId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* inbuxa: told about every registry write, so that saved settings can be
|
||||||
|
* applied on the server (settingsApplyStore). `started` comes before the
|
||||||
|
* request goes out and `finished` after it settles, with the types it changed.
|
||||||
|
*/
|
||||||
|
export interface RegistryWriteListener {
|
||||||
|
started(): void;
|
||||||
|
finished(objectNames: string[]): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let registryWriteListener: RegistryWriteListener | null = null;
|
||||||
|
|
||||||
|
export function setRegistryWriteListener(listener: RegistryWriteListener | null) {
|
||||||
|
registryWriteListener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
export async function jmapRequest(
|
export async function jmapRequest(
|
||||||
methodCalls: JmapMethodCall[],
|
methodCalls: JmapMethodCall[],
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
extraUsing: string[] = [],
|
extraUsing: string[] = [],
|
||||||
|
): Promise<JmapMethodResponse[]> {
|
||||||
|
const listener = writesRegistry(methodCalls) ? registryWriteListener : null;
|
||||||
|
if (!listener) return sendJmapRequest(methodCalls, signal, extraUsing);
|
||||||
|
listener.started();
|
||||||
|
let written: string[] = [];
|
||||||
|
try {
|
||||||
|
const responses = await sendJmapRequest(methodCalls, signal, extraUsing);
|
||||||
|
written = writtenRegistryTypes(responses);
|
||||||
|
return responses;
|
||||||
|
} finally {
|
||||||
|
listener.finished(written);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendJmapRequest(
|
||||||
|
methodCalls: JmapMethodCall[],
|
||||||
|
signal?: AbortSignal,
|
||||||
|
extraUsing: string[] = [],
|
||||||
): Promise<JmapMethodResponse[]> {
|
): Promise<JmapMethodResponse[]> {
|
||||||
const { apiUrl } = useAuthStore.getState();
|
const { apiUrl } = useAuthStore.getState();
|
||||||
let path = apiUrl || '/jmap';
|
let path = apiUrl || '/jmap';
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import type { RegistryWriteListener } from '@/services/jmap/client';
|
||||||
|
import type { JmapMethodCall, JmapMethodResponse } from '@/types/jmap';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
jmapRequest: vi.fn<(calls: JmapMethodCall[]) => Promise<JmapMethodResponse[]>>(),
|
||||||
|
listener: null as RegistryWriteListener | null,
|
||||||
|
toast: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/services/jmap/client', () => ({
|
||||||
|
getAccountId: () => 'admin',
|
||||||
|
jmapRequest: mocks.jmapRequest,
|
||||||
|
setRegistryWriteListener: (l: RegistryWriteListener | null) => {
|
||||||
|
mocks.listener = l;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/hooks/use-toast', () => ({ toast: mocks.toast }));
|
||||||
|
|
||||||
|
import { APPLY_DELAY_MS, resetSettingsApplyForTests, useSettingsApplyStore } from './settingsApplyStore';
|
||||||
|
|
||||||
|
function save(...types: string[]) {
|
||||||
|
mocks.listener!.started();
|
||||||
|
mocks.listener!.finished(types);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reloadCreates(call: JmapMethodCall): unknown[] {
|
||||||
|
expect(call[0]).toBe('x:Action/set');
|
||||||
|
return Object.values((call[1] as { create: Record<string, unknown> }).create);
|
||||||
|
}
|
||||||
|
|
||||||
|
function answer(created: string[], notCreated: Record<string, unknown> = {}): JmapMethodResponse[] {
|
||||||
|
return [['x:Action/set', { created: Object.fromEntries(created.map((k) => [k, { id: k }])), notCreated }, 'reload']];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('settingsApplyStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
mocks.jmapRequest.mockReset();
|
||||||
|
mocks.toast.mockReset();
|
||||||
|
resetSettingsApplyForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers with the JMAP client', () => {
|
||||||
|
expect(mocks.listener).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies a settings save once writes settle, and says so', async () => {
|
||||||
|
mocks.jmapRequest.mockResolvedValue(answer(['reload-0']));
|
||||||
|
save('x:MtaDeliverySchedule');
|
||||||
|
expect(mocks.jmapRequest).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
|
||||||
|
|
||||||
|
expect(mocks.jmapRequest).toHaveBeenCalledTimes(1);
|
||||||
|
expect(reloadCreates(mocks.jmapRequest.mock.calls[0][0][0])).toEqual([{ '@type': 'ReloadSettings' }]);
|
||||||
|
expect(mocks.toast).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ title: 'Saved and applied', variant: 'success' }),
|
||||||
|
);
|
||||||
|
expect(useSettingsApplyStore.getState()).toMatchObject({ pending: [], applying: false, failure: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends one reload for a burst of saves', async () => {
|
||||||
|
mocks.jmapRequest.mockResolvedValue(answer(['reload-0', 'reload-1']));
|
||||||
|
save('x:MtaRoute');
|
||||||
|
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS / 2);
|
||||||
|
save('x:MtaRoute');
|
||||||
|
// A save still in flight holds the reload back however long it takes.
|
||||||
|
mocks.listener!.started();
|
||||||
|
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS * 5);
|
||||||
|
expect(mocks.jmapRequest).not.toHaveBeenCalled();
|
||||||
|
mocks.listener!.finished(['x:Certificate']);
|
||||||
|
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
|
||||||
|
|
||||||
|
expect(mocks.jmapRequest).toHaveBeenCalledTimes(1);
|
||||||
|
expect(reloadCreates(mocks.jmapRequest.mock.calls[0][0][0])).toEqual([
|
||||||
|
{ '@type': 'ReloadTlsCertificates' },
|
||||||
|
{ '@type': 'ReloadSettings' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing for writes the server applies itself', async () => {
|
||||||
|
save('x:Directory', 'x:Account', 'x:Domain');
|
||||||
|
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS * 2);
|
||||||
|
expect(mocks.jmapRequest).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a failed reload with the object and message, and retries on Apply now', async () => {
|
||||||
|
mocks.jmapRequest.mockResolvedValueOnce(
|
||||||
|
answer([], {
|
||||||
|
'reload-0': {
|
||||||
|
type: 'validationFailed',
|
||||||
|
description: 'Failed to resolve Pyzor host',
|
||||||
|
objectId: { object: 'SpamPyzor', id: 'singleton' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
save('x:MtaDeliverySchedule');
|
||||||
|
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
|
||||||
|
|
||||||
|
expect(mocks.toast).not.toHaveBeenCalled();
|
||||||
|
expect(useSettingsApplyStore.getState()).toMatchObject({
|
||||||
|
pending: ['ReloadSettings'],
|
||||||
|
failure: { message: 'Failed to resolve Pyzor host', object: { object: 'SpamPyzor', id: 'singleton' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// An unrelated save doesn't rerun a reload that is known to fail.
|
||||||
|
save('x:Account');
|
||||||
|
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS * 2);
|
||||||
|
expect(mocks.jmapRequest).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
mocks.jmapRequest.mockResolvedValueOnce(answer(['reload-0']));
|
||||||
|
await useSettingsApplyStore.getState().applyNow();
|
||||||
|
expect(mocks.jmapRequest).toHaveBeenCalledTimes(2);
|
||||||
|
expect(useSettingsApplyStore.getState()).toMatchObject({ pending: [], failure: null });
|
||||||
|
expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ title: 'Saved and applied' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tries again by itself when a later save needs a reload', async () => {
|
||||||
|
mocks.jmapRequest.mockResolvedValueOnce(answer([], { 'reload-0': { type: 'validationFailed', description: 'x' } }));
|
||||||
|
save('x:SpamPyzor');
|
||||||
|
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
|
||||||
|
expect(useSettingsApplyStore.getState().failure).not.toBeNull();
|
||||||
|
|
||||||
|
mocks.jmapRequest.mockResolvedValueOnce(answer(['reload-0']));
|
||||||
|
save('x:SpamPyzor');
|
||||||
|
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
|
||||||
|
expect(mocks.jmapRequest).toHaveBeenCalledTimes(2);
|
||||||
|
expect(useSettingsApplyStore.getState().failure).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a request that fails outright', async () => {
|
||||||
|
mocks.jmapRequest.mockRejectedValueOnce(new Error('Network down'));
|
||||||
|
save('x:MtaRoute');
|
||||||
|
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
|
||||||
|
expect(useSettingsApplyStore.getState()).toMatchObject({
|
||||||
|
pending: ['ReloadSettings'],
|
||||||
|
failure: { message: 'Network down' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a method error', async () => {
|
||||||
|
mocks.jmapRequest.mockResolvedValueOnce([['error', { type: 'forbidden', description: 'Not allowed' }, 'reload']]);
|
||||||
|
save('x:MtaRoute');
|
||||||
|
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
|
||||||
|
expect(useSettingsApplyStore.getState().failure).toEqual({ message: 'Not allowed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismissing hides the failure but keeps the reload queued', async () => {
|
||||||
|
mocks.jmapRequest.mockResolvedValueOnce(answer([], { 'reload-0': { type: 'validationFailed', description: 'x' } }));
|
||||||
|
save('x:MtaRoute');
|
||||||
|
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
|
||||||
|
useSettingsApplyStore.getState().dismiss();
|
||||||
|
expect(useSettingsApplyStore.getState()).toMatchObject({ failure: null, pending: ['ReloadSettings'] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* inbuxa: applies saved settings on the running server.
|
||||||
|
*
|
||||||
|
* Every registry write that needs it queues a reload action (see
|
||||||
|
* lib/settingsApply). Once no write has been in flight for APPLY_DELAY_MS the
|
||||||
|
* queued actions go out together in one x:Action/set, so a bulk edit, or a
|
||||||
|
* page that saves several objects in a row, costs one reload rather than one
|
||||||
|
* per object. A reload that fails stays queued and shows in
|
||||||
|
* SettingsApplyBanner until it is applied, by "Apply now" or by the next save.
|
||||||
|
*
|
||||||
|
* If the server one day applies these writes by itself, the reload sent here
|
||||||
|
* is a second, harmless one: one per burst of saves, never one per object.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import i18n from '@/i18n';
|
||||||
|
import { toast } from '@/hooks/use-toast';
|
||||||
|
import { getAccountId, jmapRequest, setRegistryWriteListener } from '@/services/jmap/client';
|
||||||
|
import {
|
||||||
|
RELOAD_ORDER,
|
||||||
|
describeApplyFailure,
|
||||||
|
describeRequestFailure,
|
||||||
|
reloadActionsFor,
|
||||||
|
type ApplyFailure,
|
||||||
|
type ReloadAction,
|
||||||
|
} from '@/lib/settingsApply';
|
||||||
|
import type { JmapSetError } from '@/types/jmap';
|
||||||
|
|
||||||
|
export const APPLY_DELAY_MS = 600;
|
||||||
|
|
||||||
|
interface SettingsApplyState {
|
||||||
|
/** Actions waiting to be sent: queued by saves, or left over from a failed attempt. */
|
||||||
|
pending: ReloadAction[];
|
||||||
|
applying: boolean;
|
||||||
|
/** Why the last attempt didn't apply, until one does. */
|
||||||
|
failure: ApplyFailure | null;
|
||||||
|
/** Queues the actions the written types need, and applies them once writes settle. */
|
||||||
|
noteWrites: (objectNames: string[]) => void;
|
||||||
|
/** Sends whatever is queued now, without waiting. */
|
||||||
|
applyNow: () => Promise<void>;
|
||||||
|
/** Hides the failure. What failed stays queued for the next save. */
|
||||||
|
dismiss: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let writesInFlight = 0;
|
||||||
|
let applyAgain = false;
|
||||||
|
// Whether a save has queued something since the last attempt. After a failed
|
||||||
|
// reload, only a save that needs one tries again by itself; editing an
|
||||||
|
// account, say, doesn't rerun a reload that is known to fail.
|
||||||
|
let freshlyQueued = false;
|
||||||
|
|
||||||
|
function merge(a: ReloadAction[], b: ReloadAction[]): ReloadAction[] {
|
||||||
|
const all = new Set([...a, ...b]);
|
||||||
|
return RELOAD_ORDER.filter((x) => all.has(x));
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule() {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
const { pending, failure } = useSettingsApplyStore.getState();
|
||||||
|
if (pending.length === 0 || writesInFlight > 0) return;
|
||||||
|
if (failure && !freshlyQueued) return;
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
timer = null;
|
||||||
|
void useSettingsApplyStore.getState().applyNow();
|
||||||
|
}, APPLY_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSettingsApplyStore = create<SettingsApplyState>()((set, get) => ({
|
||||||
|
pending: [],
|
||||||
|
applying: false,
|
||||||
|
failure: null,
|
||||||
|
|
||||||
|
noteWrites: (objectNames) => {
|
||||||
|
const due = reloadActionsFor(objectNames);
|
||||||
|
if (due.length > 0) {
|
||||||
|
freshlyQueued = true;
|
||||||
|
set({ pending: merge(get().pending, due) });
|
||||||
|
}
|
||||||
|
schedule();
|
||||||
|
},
|
||||||
|
|
||||||
|
applyNow: async () => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
if (get().applying) {
|
||||||
|
// A save landed while the last reload was out: go again once it's back.
|
||||||
|
applyAgain = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const actions = get().pending;
|
||||||
|
if (actions.length === 0) return;
|
||||||
|
|
||||||
|
freshlyQueued = false;
|
||||||
|
set({ applying: true, pending: [] });
|
||||||
|
let notApplied: ReloadAction[] = [];
|
||||||
|
let failure: ApplyFailure | null = null;
|
||||||
|
try {
|
||||||
|
const create: Record<string, Record<string, unknown>> = {};
|
||||||
|
actions.forEach((action, i) => {
|
||||||
|
create[`reload-${i}`] = { '@type': action };
|
||||||
|
});
|
||||||
|
const responses = await jmapRequest([
|
||||||
|
['x:Action/set', { accountId: getAccountId('x:Action'), create }, 'reload'],
|
||||||
|
]);
|
||||||
|
const [name, result] = responses[responses.length - 1] ?? [];
|
||||||
|
if (name !== 'x:Action/set' || !result) {
|
||||||
|
notApplied = actions;
|
||||||
|
failure = describeRequestFailure(result);
|
||||||
|
} else {
|
||||||
|
const created = (result.created ?? {}) as Record<string, unknown>;
|
||||||
|
const notCreated = (result.notCreated ?? {}) as Record<string, JmapSetError>;
|
||||||
|
for (const [i, action] of actions.entries()) {
|
||||||
|
const key = `reload-${i}`;
|
||||||
|
if (key in created) continue;
|
||||||
|
notApplied.push(action);
|
||||||
|
failure ??= notCreated[key]
|
||||||
|
? describeApplyFailure(notCreated[key])
|
||||||
|
: { message: i18n.t('settingsApply.notConfirmed', 'The server did not confirm the reload.') };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
notApplied = actions;
|
||||||
|
failure = describeRequestFailure(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ applying: false, pending: merge(get().pending, notApplied), failure });
|
||||||
|
if (!failure) {
|
||||||
|
toast({ title: i18n.t('settingsApply.applied', 'Saved and applied'), variant: 'success' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (applyAgain) {
|
||||||
|
applyAgain = false;
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
dismiss: () => set({ failure: null }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
setRegistryWriteListener({
|
||||||
|
started() {
|
||||||
|
writesInFlight++;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
},
|
||||||
|
finished(objectNames) {
|
||||||
|
writesInFlight = Math.max(0, writesInFlight - 1);
|
||||||
|
useSettingsApplyStore.getState().noteWrites(objectNames);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Test hook: forget timers and counters between cases. */
|
||||||
|
export function resetSettingsApplyForTests() {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
writesInFlight = 0;
|
||||||
|
applyAgain = false;
|
||||||
|
freshlyQueued = false;
|
||||||
|
useSettingsApplyStore.setState({ pending: [], applying: false, failure: null });
|
||||||
|
}
|
||||||
+10
-1
@@ -1,7 +1,10 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface JmapRequest {
|
export interface JmapRequest {
|
||||||
@@ -52,11 +55,17 @@ export interface JmapSetError {
|
|||||||
description?: string;
|
description?: string;
|
||||||
properties?: string[];
|
properties?: string[];
|
||||||
existingId?: string;
|
existingId?: string;
|
||||||
objectId?: string;
|
objectId?: string | JmapObjectRef;
|
||||||
linkedObjects?: string[];
|
linkedObjects?: string[];
|
||||||
validationErrors?: ValidationError[];
|
validationErrors?: ValidationError[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A registry object as the server names it in an error: its type without `x:`, and its id. */
|
||||||
|
export interface JmapObjectRef {
|
||||||
|
object: string;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ValidationError {
|
export interface ValidationError {
|
||||||
type: 'Invalid' | 'Required' | 'MaxLength' | 'MinLength' | 'MaxValue' | 'MinValue';
|
type: 'Invalid' | 'Required' | 'MaxLength' | 'MinLength' | 'MaxValue' | 'MinValue';
|
||||||
property: string;
|
property: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user