Initial commit

This commit is contained in:
Maurus Decimus
2026-04-20 15:02:57 +02:00
parent e72c9614f1
commit 4470616775
115 changed files with 26207 additions and 21 deletions
+15
View File
@@ -0,0 +1,15 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
let cached: string | undefined;
export function getBasePath(): string {
if (cached !== undefined) return cached;
const base = document.querySelector('base')?.getAttribute('href') ?? '/';
cached = base.replace(/\/+$/, '');
return cached;
}
+108
View File
@@ -0,0 +1,108 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
const isDev = import.meta.env.DEV;
function isEnabled(envVar: string | undefined): boolean {
if (!isDev || !envVar) return false;
const v = envVar.toLowerCase();
return v !== '' && v !== 'false' && v !== '0';
}
export const debugJmap = isEnabled(import.meta.env.VITE_DEBUG_JMAP);
export const debugForms = isEnabled(import.meta.env.VITE_DEBUG_FORMS);
type JmapCall = [string, Record<string, unknown>, string];
function summarizeArgs(args: Record<string, unknown>): string {
const parts: string[] = [];
if (typeof args.accountId === 'string') {
parts.push(`account=${truncate(args.accountId, 12)}`);
}
if (Array.isArray(args.ids)) {
parts.push(`ids=${args.ids.length}`);
} else if (args.ids === null) {
parts.push('ids=all');
}
if (args.create && typeof args.create === 'object') {
parts.push(`create=${Object.keys(args.create).length}`);
}
if (args.update && typeof args.update === 'object') {
parts.push(`update=${Object.keys(args.update).length}`);
}
if (Array.isArray(args.destroy)) {
parts.push(`destroy=${args.destroy.length}`);
}
if (Array.isArray(args.list)) {
parts.push(`list=${args.list.length}`);
}
if (typeof args.total === 'number') {
parts.push(`total=${args.total}`);
}
return parts.join(' ');
}
function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max - 1) + '…' : s;
}
function buildSummaryRows(calls: unknown): Array<Record<string, string>> {
if (!Array.isArray(calls)) return [];
return calls.map((call, idx) => {
if (!Array.isArray(call) || call.length < 3) {
return { '#': String(idx), method: '?', callId: '?', summary: '' };
}
const [method, args, callId] = call as JmapCall;
return {
'#': String(idx),
method,
callId,
summary: args && typeof args === 'object' ? summarizeArgs(args) : '',
};
});
}
export function logJmapExchange(methodCalls: unknown, methodResponses: unknown, durationMs?: number): void {
if (!debugJmap) return;
const hasError = Array.isArray(methodResponses) && methodResponses.some((r) => Array.isArray(r) && r[0] === 'error');
const methodNames = Array.isArray(methodCalls)
? methodCalls.map((c) => (Array.isArray(c) && typeof c[0] === 'string' ? c[0] : '?')).join(' · ')
: '?';
const durationLabel = typeof durationMs === 'number' ? ` (${Math.round(durationMs)}ms)` : '';
const headerColor = hasError ? '#dc2626' : '#2563eb';
console.groupCollapsed(
`%c[JMAP]%c ${methodNames}${durationLabel}${hasError ? ' (with errors)' : ''}`,
`color: ${headerColor}; font-weight: bold`,
'color: inherit',
);
console.groupCollapsed('%c→ request', 'color: #2563eb; font-weight: bold');
console.table(buildSummaryRows(methodCalls));
console.log(JSON.stringify(methodCalls, null, 2));
console.groupEnd();
console.groupCollapsed(
`%c← response${hasError ? ' (with errors)' : ''}`,
`color: ${hasError ? '#dc2626' : '#16a34a'}; font-weight: bold`,
);
console.table(buildSummaryRows(methodResponses));
console.log(JSON.stringify(methodResponses, null, 2));
console.groupEnd();
console.groupEnd();
}
export function logFormChange(label: string, data: unknown): void {
if (!debugForms) return;
console.groupCollapsed(`%c[Form]%c ${label}`, 'color: #9333ea; font-weight: bold', 'color: inherit');
console.log(JSON.stringify(data, null, 2));
console.groupEnd();
}
+229
View File
@@ -0,0 +1,229 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { describe, it, expect } from 'vitest';
import {
bytesToHuman,
humanToBytes,
formatSize,
msToHuman,
humanToMs,
formatDuration,
SIZE_UNITS,
DURATION_UNITS,
} from './durationFormat';
describe('SIZE_UNITS', () => {
it('should contain the expected units in order', () => {
expect(SIZE_UNITS).toEqual(['B', 'KB', 'MB', 'GB', 'TB']);
});
});
describe('DURATION_UNITS', () => {
it('should contain the expected units in order', () => {
expect(DURATION_UNITS).toEqual(['ms', 's', 'min', 'h', 'd']);
});
});
describe('bytesToHuman', () => {
it('should return 0 B for 0 bytes', () => {
expect(bytesToHuman(0)).toEqual({ value: 0, unit: 'B' });
});
it('should keep small values in bytes', () => {
expect(bytesToHuman(500)).toEqual({ value: 500, unit: 'B' });
});
it('should keep 512 bytes as B', () => {
expect(bytesToHuman(512)).toEqual({ value: 512, unit: 'B' });
});
it('should convert 1024 bytes to 1 KB', () => {
expect(bytesToHuman(1024)).toEqual({ value: 1, unit: 'KB' });
});
it('should convert 1536 bytes to 1.5 KB', () => {
expect(bytesToHuman(1536)).toEqual({ value: 1.5, unit: 'KB' });
});
it('should convert 1500 bytes to 1.46 KB', () => {
expect(bytesToHuman(1500)).toEqual({ value: 1.46, unit: 'KB' });
});
it('should convert 1048576 bytes to 1 MB', () => {
expect(bytesToHuman(1048576)).toEqual({ value: 1, unit: 'MB' });
});
it('should convert 10485760 bytes to 10 MB', () => {
expect(bytesToHuman(10485760)).toEqual({ value: 10, unit: 'MB' });
});
it('should convert 1073741824 bytes to 1 GB', () => {
expect(bytesToHuman(1073741824)).toEqual({ value: 1, unit: 'GB' });
});
it('should convert 1099511627776 bytes to 1 TB', () => {
expect(bytesToHuman(1099511627776)).toEqual({ value: 1, unit: 'TB' });
});
});
describe('humanToBytes', () => {
it('should convert 1 KB to 1024 bytes', () => {
expect(humanToBytes(1, 'KB')).toBe(1024);
});
it('should convert 10 MB to 10485760 bytes', () => {
expect(humanToBytes(10, 'MB')).toBe(10485760);
});
it('should convert 1.5 GB to 1610612736 bytes', () => {
expect(humanToBytes(1.5, 'GB')).toBe(1610612736);
});
it('should convert 0 B to 0', () => {
expect(humanToBytes(0, 'B')).toBe(0);
});
it('should return the raw value for an unknown unit', () => {
expect(humanToBytes(42, 'XYZ')).toBe(42);
});
});
describe('formatSize', () => {
it('should format 0 bytes as "0 B"', () => {
expect(formatSize(0)).toBe('0 B');
});
it('should format 1024 bytes as "1 KB"', () => {
expect(formatSize(1024)).toBe('1 KB');
});
it('should format 1572864 bytes as "1.5 MB"', () => {
expect(formatSize(1572864)).toBe('1.5 MB');
});
it('should format large values in TB', () => {
expect(formatSize(1099511627776)).toBe('1 TB');
});
});
describe('msToHuman', () => {
it('should return 0 ms for 0', () => {
expect(msToHuman(0)).toEqual({ value: 0, unit: 'ms' });
});
it('should keep 500 as ms', () => {
expect(msToHuman(500)).toEqual({ value: 500, unit: 'ms' });
});
it('should convert 1000 ms to 1 s', () => {
expect(msToHuman(1000)).toEqual({ value: 1, unit: 's' });
});
it('should convert 1500 ms to 1.5 s', () => {
expect(msToHuman(1500)).toEqual({ value: 1.5, unit: 's' });
});
it('should convert 60000 ms to 1 min', () => {
expect(msToHuman(60000)).toEqual({ value: 1, unit: 'min' });
});
it('should convert 300000 ms to 5 min', () => {
expect(msToHuman(300000)).toEqual({ value: 5, unit: 'min' });
});
it('should convert 90000 ms to 1.5 min', () => {
expect(msToHuman(90000)).toEqual({ value: 1.5, unit: 'min' });
});
it('should convert 3600000 ms to 1 h', () => {
expect(msToHuman(3600000)).toEqual({ value: 1, unit: 'h' });
});
it('should convert 86400000 ms to 1 d', () => {
expect(msToHuman(86400000)).toEqual({ value: 1, unit: 'd' });
});
});
describe('humanToMs', () => {
it('should convert 5 s to 5000 ms', () => {
expect(humanToMs(5, 's')).toBe(5000);
});
it('should convert 1 min to 60000 ms', () => {
expect(humanToMs(1, 'min')).toBe(60000);
});
it('should convert 2 h to 7200000 ms', () => {
expect(humanToMs(2, 'h')).toBe(7200000);
});
it('should convert 1 d to 86400000 ms', () => {
expect(humanToMs(1, 'd')).toBe(86400000);
});
it('should return the raw value for an unknown unit', () => {
expect(humanToMs(99, 'eons')).toBe(99);
});
});
describe('formatDuration', () => {
it('should format 0 as "0ms"', () => {
expect(formatDuration(0)).toBe('0ms');
});
it('should format 500 ms as "500ms"', () => {
expect(formatDuration(500)).toBe('500ms');
});
it('should format 5000 ms as "5s"', () => {
expect(formatDuration(5000)).toBe('5s');
});
it('should format 65000 ms as "1m 5s"', () => {
expect(formatDuration(65000)).toBe('1m 5s');
});
it('should format 3661000 ms as "1h 1m 1s"', () => {
expect(formatDuration(3661000)).toBe('1h 1m 1s');
});
it('should format 86400000 ms as "1d"', () => {
expect(formatDuration(86400000)).toBe('1d');
});
it('should format 90061500 ms as "1d 1h 1m 1s 500ms"', () => {
expect(formatDuration(90061500)).toBe('1d 1h 1m 1s 500ms');
});
});
describe('round-trip: bytes', () => {
it.each([1024, 1048576, 1073741824, 1099511627776])('bytesToHuman -> humanToBytes should recover %d', (original) => {
const { value, unit } = bytesToHuman(original);
expect(humanToBytes(value, unit)).toBe(original);
});
it('should be close for non-exact values', () => {
const original = 123456789;
const { value, unit } = bytesToHuman(original);
const recovered = humanToBytes(value, unit);
expect(Math.abs(recovered - original) / original).toBeLessThan(0.01);
});
});
describe('round-trip: duration', () => {
it.each([1000, 60000, 3600000, 86400000])('msToHuman -> humanToMs should recover %d', (original) => {
const { value, unit } = msToHuman(original);
expect(humanToMs(value, unit)).toBe(original);
});
it('should be close for non-exact values', () => {
const original = 123456;
const { value, unit } = msToHuman(original);
const recovered = humanToMs(value, unit);
expect(Math.abs(recovered - original) / original).toBeLessThan(0.01);
});
});
+96
View File
@@ -0,0 +1,96 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
export const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const;
const SIZE_FACTORS: Record<string, number> = {
B: 1,
KB: 1024,
MB: 1024 ** 2,
GB: 1024 ** 3,
TB: 1024 ** 4,
};
export function bytesToHuman(bytes: number): { value: number; unit: string } {
if (bytes === 0) return { value: 0, unit: 'B' };
for (let i = SIZE_UNITS.length - 1; i >= 0; i--) {
const unit = SIZE_UNITS[i];
const factor = SIZE_FACTORS[unit];
const v = bytes / factor;
if (v >= 1) {
const rounded = Math.round(v * 100) / 100;
return { value: rounded, unit };
}
}
return { value: bytes, unit: 'B' };
}
export function humanToBytes(value: number, unit: string): number {
const factor = SIZE_FACTORS[unit];
if (factor === undefined) return value;
return Math.round(value * factor);
}
export function formatSize(bytes: number): string {
const { value, unit } = bytesToHuman(bytes);
return `${value} ${unit}`;
}
export const DURATION_UNITS = ['ms', 's', 'min', 'h', 'd'] as const;
const DURATION_FACTORS: Record<string, number> = {
ms: 1,
s: 1_000,
min: 60_000,
h: 3_600_000,
d: 86_400_000,
};
export function msToHuman(ms: number): { value: number; unit: string } {
if (ms === 0) return { value: 0, unit: 'ms' };
for (let i = DURATION_UNITS.length - 1; i >= 0; i--) {
const unit = DURATION_UNITS[i];
const factor = DURATION_FACTORS[unit];
const v = ms / factor;
if (v >= 1) {
const rounded = Math.round(v * 100) / 100;
return { value: rounded, unit };
}
}
return { value: ms, unit: 'ms' };
}
export function humanToMs(value: number, unit: string): number {
const factor = DURATION_FACTORS[unit];
if (factor === undefined) return value;
return Math.round(value * factor);
}
export function formatDuration(ms: number): string {
if (ms === 0) return '0ms';
const d = Math.floor(ms / 86_400_000);
ms %= 86_400_000;
const h = Math.floor(ms / 3_600_000);
ms %= 3_600_000;
const m = Math.floor(ms / 60_000);
ms %= 60_000;
const s = Math.floor(ms / 1_000);
ms %= 1_000;
const parts: string[] = [];
if (d > 0) parts.push(`${d}d`);
if (h > 0) parts.push(`${h}h`);
if (m > 0) parts.push(`${m}m`);
if (s > 0) parts.push(`${s}s`);
if (ms > 0) parts.push(`${ms}ms`);
return parts.join(' ');
}
+48
View File
@@ -0,0 +1,48 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import type { JmapSetError } from '@/types/jmap';
import i18n from '@/i18n';
export function friendlySetError(err: JmapSetError): string {
if (err.description) return err.description;
switch (err.type) {
case 'forbidden':
return i18n.t('jmapErrors.forbidden', 'You do not have permission to perform this action.');
case 'notFound':
return i18n.t('jmapErrors.notFound', 'The item was not found. It may have been already deleted.');
case 'objectIsLinked':
return i18n.t('jmapErrors.objectIsLinked', 'This item is referenced by other items and cannot be deleted.');
case 'invalidForeignKey':
return i18n.t('jmapErrors.invalidForeignKey', 'This item references another item that does not exist.');
case 'primaryKeyViolation':
return i18n.t('jmapErrors.primaryKeyViolation', 'An item with the same key already exists.');
case 'alreadyExists':
return i18n.t('jmapErrors.alreadyExists', 'An item with the same identifier already exists.');
case 'overQuota':
return i18n.t('jmapErrors.overQuota', 'The operation exceeds your storage quota.');
case 'tooLarge':
return i18n.t('jmapErrors.tooLarge', 'The item is too large.');
case 'rateLimit':
return i18n.t('jmapErrors.rateLimit', 'Too many requests. Please try again later.');
case 'singleton':
return i18n.t('jmapErrors.singleton', 'This item is a singleton and cannot be deleted.');
case 'mailboxHasChild':
return i18n.t('jmapErrors.mailboxHasChild', 'This mailbox has child mailboxes. Delete them first.');
case 'mailboxHasEmail':
return i18n.t('jmapErrors.mailboxHasEmail', 'This mailbox contains messages. Remove them first.');
case 'calendarHasEvent':
return i18n.t('jmapErrors.calendarHasEvent', 'This calendar contains events. Remove them first.');
case 'addressBookHasContents':
return i18n.t('jmapErrors.addressBookHasContents', 'This address book has contacts. Remove them first.');
case 'nodeHasChildren':
return i18n.t('jmapErrors.nodeHasChildren', 'This item has children. Delete them first.');
case 'validationFailed':
return i18n.t('jmapErrors.validationFailed', 'The data did not pass validation.');
default:
return i18n.t('jmapErrors.unexpected', 'Unexpected error ({{type}}).', { type: err.type });
}
}
+513
View File
@@ -0,0 +1,513 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { describe, it, expect } from 'vitest';
import { calculateJmapPatch, escapeJsonPointerToken } from './jmapPatch';
describe('calculateJmapPatch', () => {
it('detects changed primitives', () => {
const original = { name: 'Alice', age: 30, active: true };
const modified = { name: 'Bob', age: 31, active: false };
expect(calculateJmapPatch(original, modified)).toEqual({
name: 'Bob',
age: 31,
active: false,
});
});
it('detects added fields', () => {
const original = { name: 'Alice' };
const modified = { name: 'Alice', email: 'alice@example.com' };
expect(calculateJmapPatch(original, modified)).toEqual({
email: 'alice@example.com',
});
});
it('detects removed fields (set to null)', () => {
const original = { name: 'Alice', email: 'alice@example.com' };
const modified = { name: 'Alice' };
expect(calculateJmapPatch(original, modified)).toEqual({
email: null,
});
});
it('returns empty object when nothing changed', () => {
const original = { name: 'Alice', age: 30, active: true };
const modified = { name: 'Alice', age: 30, active: true };
expect(calculateJmapPatch(original, modified)).toEqual({});
});
it('returns empty object for two empty objects', () => {
expect(calculateJmapPatch({}, {})).toEqual({});
});
it('produces JSON pointer paths for nested changes', () => {
const original = { address: { city: 'NYC', zip: '10001' } };
const modified = { address: { city: 'LA', zip: '10001' } };
expect(calculateJmapPatch(original, modified)).toEqual({
'address/city': 'LA',
});
});
it('handles deeply nested changes', () => {
const original = { a: { b: { c: { d: 1 } } } };
const modified = { a: { b: { c: { d: 2 } } } };
expect(calculateJmapPatch(original, modified)).toEqual({
'a/b/c/d': 2,
});
});
it('detects added nested fields', () => {
const original = { address: { city: 'NYC' } };
const modified = { address: { city: 'NYC', zip: '10001' } };
expect(calculateJmapPatch(original, modified)).toEqual({
'address/zip': '10001',
});
});
it('detects removed nested fields', () => {
const original = { address: { city: 'NYC', zip: '10001' } };
const modified = { address: { city: 'NYC' } };
expect(calculateJmapPatch(original, modified)).toEqual({
'address/zip': null,
});
});
it('detects set additions', () => {
const original = { keywords: { important: true } };
const modified = { keywords: { important: true, urgent: true } };
expect(calculateJmapPatch(original, modified)).toEqual({
'keywords/urgent': true,
});
});
it('detects set removals', () => {
const original = { keywords: { important: true, urgent: true } };
const modified = { keywords: { important: true } };
expect(calculateJmapPatch(original, modified)).toEqual({
'keywords/urgent': null,
});
});
it('handles mixed set changes', () => {
const original = { keywords: { important: true, draft: true } };
const modified = { keywords: { important: true, urgent: true } };
const patch = calculateJmapPatch(original, modified);
expect(patch).toEqual({
'keywords/draft': null,
'keywords/urgent': true,
});
});
it('detects objectList item additions', () => {
const original = {
emailAddresses: {
'0': { email: 'a@test.com', type: 'work' },
},
};
const modified = {
emailAddresses: {
'0': { email: 'a@test.com', type: 'work' },
'1': { email: 'b@test.com', type: 'home' },
},
};
expect(calculateJmapPatch(original, modified)).toEqual({
'emailAddresses/1': { email: 'b@test.com', type: 'home' },
});
});
it('detects objectList item removals', () => {
const original = {
emailAddresses: {
'0': { email: 'a@test.com', type: 'work' },
'1': { email: 'b@test.com', type: 'home' },
},
};
const modified = {
emailAddresses: {
'0': { email: 'a@test.com', type: 'work' },
},
};
expect(calculateJmapPatch(original, modified)).toEqual({
'emailAddresses/1': null,
});
});
it('detects objectList item field updates', () => {
const original = {
emailAddresses: {
'0': { email: 'a@test.com', type: 'work' },
},
};
const modified = {
emailAddresses: {
'0': { email: 'a@test.com', type: 'home' },
},
};
expect(calculateJmapPatch(original, modified)).toEqual({
'emailAddresses/0/type': 'home',
});
});
it('detects map key additions', () => {
const original = { mailboxIds: { inbox1: true } };
const modified = { mailboxIds: { inbox1: true, sent1: true } };
expect(calculateJmapPatch(original, modified)).toEqual({
'mailboxIds/sent1': true,
});
});
it('detects map key removals', () => {
const original = { mailboxIds: { inbox1: true, sent1: true } };
const modified = { mailboxIds: { inbox1: true } };
expect(calculateJmapPatch(original, modified)).toEqual({
'mailboxIds/sent1': null,
});
});
it('detects map value changes', () => {
const original = { settings: { theme: 'dark' } };
const modified = { settings: { theme: 'light' } };
expect(calculateJmapPatch(original, modified)).toEqual({
'settings/theme': 'light',
});
});
it('handles map with object values', () => {
const original = {
accounts: {
acc1: { name: 'Account 1', isActive: true },
},
};
const modified = {
accounts: {
acc1: { name: 'Account 1', isActive: false },
},
};
expect(calculateJmapPatch(original, modified)).toEqual({
'accounts/acc1/isActive': false,
});
});
it('handles map with nested object value additions', () => {
const original = { accounts: {} };
const modified = {
accounts: {
acc1: { name: 'Account 1', isActive: true },
},
};
expect(calculateJmapPatch(original, modified)).toEqual({
'accounts/acc1': { name: 'Account 1', isActive: true },
});
});
it('includes @type in patch', () => {
const original = { '@type': 'Mailbox', name: 'Inbox' };
const modified = { '@type': 'MailboxChanged', name: 'Inbox' };
expect(calculateJmapPatch(original, modified)).toEqual({
'@type': 'MailboxChanged',
});
});
it('does not include @type when unchanged', () => {
const original = { '@type': 'Mailbox', name: 'Inbox' };
const modified = { '@type': 'Mailbox', name: 'Sent' };
expect(calculateJmapPatch(original, modified)).toEqual({
name: 'Sent',
});
});
it('does not null out fields from the previous variant on top-level @type change', () => {
const original = {
'@type': 'MaxMind',
asnUrls: ['https://example.com/asn.csv'],
expires: 86400,
geoUrls: ['https://example.com/geo.csv'],
httpAuth: null,
httpHeaders: {},
maxSize: 104857600,
timeout: 30000,
};
const modified = { '@type': 'Disabled' };
expect(calculateJmapPatch(original, modified)).toEqual({
'@type': 'Disabled',
});
});
it("includes the new variant's defaults on top-level @type change", () => {
const original = {
'@type': 'VariantA',
onlyOnA: 'x',
shared: 1,
};
const modified = {
'@type': 'VariantB',
onlyOnB: 'y',
shared: 2,
};
expect(calculateJmapPatch(original, modified)).toEqual({
'@type': 'VariantB',
onlyOnB: 'y',
shared: 2,
});
});
it('replaces nested object atomically when its @type changes', () => {
const original = {
certificateManagement: { '@type': 'Manual' },
};
const modified = {
certificateManagement: {
'@type': 'Automatic',
acmeProviderId: 'letsencrypt',
subjectAlternativeNames: { example: true },
},
};
expect(calculateJmapPatch(original, modified)).toEqual({
certificateManagement: {
'@type': 'Automatic',
acmeProviderId: 'letsencrypt',
subjectAlternativeNames: { example: true },
},
});
});
it('does not produce nested/@type patch entries on variant change', () => {
const original = {
dnsManagement: { '@type': 'Manual' },
};
const modified = {
dnsManagement: {
'@type': 'Automatic',
publishRecords: { dkim: true, mx: true },
},
};
const patch = calculateJmapPatch(original, modified);
expect(Object.keys(patch)).toEqual(['dnsManagement']);
expect(patch['dnsManagement/@type']).toBeUndefined();
expect(patch['dnsManagement/publishRecords']).toBeUndefined();
});
it('patches nested object properties normally when @type is unchanged', () => {
const original = {
dnsManagement: {
'@type': 'Automatic',
publishRecords: { dkim: true, mx: false },
},
};
const modified = {
dnsManagement: {
'@type': 'Automatic',
publishRecords: { dkim: true, mx: true },
},
};
expect(calculateJmapPatch(original, modified)).toEqual({
'dnsManagement/publishRecords/mx': true,
});
});
it('replaces atomically when @type appears in modified but not original', () => {
const original = {
certificateManagement: {},
};
const modified = {
certificateManagement: { '@type': 'Automatic', acmeProviderId: 'le' },
};
expect(calculateJmapPatch(original, modified)).toEqual({
certificateManagement: { '@type': 'Automatic', acmeProviderId: 'le' },
});
});
it('replaces atomically when @type appears in original but not modified', () => {
const original = {
certificateManagement: { '@type': 'Manual' },
};
const modified = {
certificateManagement: {},
};
expect(calculateJmapPatch(original, modified)).toEqual({
certificateManagement: {},
});
});
it('handles deeply nested variant change (inside another nested object)', () => {
const original = {
model: {
outer: 'constant',
inner: { '@type': 'TypeA', a: 1 },
},
};
const modified = {
model: {
outer: 'constant',
inner: { '@type': 'TypeB', b: 2 },
},
};
expect(calculateJmapPatch(original, modified)).toEqual({
'model/inner': { '@type': 'TypeB', b: 2 },
});
});
it('only replaces the variant-changed sub-object, not siblings', () => {
const original = {
a: { '@type': 'X' },
b: { foo: 'bar' },
c: 1,
};
const modified = {
a: { '@type': 'Y', value: 42 },
b: { foo: 'baz' },
c: 2,
};
expect(calculateJmapPatch(original, modified)).toEqual({
a: { '@type': 'Y', value: 42 },
'b/foo': 'baz',
c: 2,
});
});
it('ignores id field', () => {
const original = { id: '123', name: 'Alice' };
const modified = { id: '456', name: 'Bob' };
expect(calculateJmapPatch(original, modified)).toEqual({
name: 'Bob',
});
});
it('ignores id field even when added', () => {
const original = { name: 'Alice' };
const modified = { id: '123', name: 'Alice' };
expect(calculateJmapPatch(original, modified)).toEqual({});
});
it('ignores id field even when removed', () => {
const original = { id: '123', name: 'Alice' };
const modified = { name: 'Alice' };
expect(calculateJmapPatch(original, modified)).toEqual({});
});
it('handles null to value', () => {
const original = { name: null };
const modified = { name: 'Alice' };
expect(calculateJmapPatch(original, modified)).toEqual({
name: 'Alice',
});
});
it('handles value to null', () => {
const original = { name: 'Alice' };
const modified = { name: null };
expect(calculateJmapPatch(original, modified)).toEqual({
name: null,
});
});
it('treats arrays as atomic', () => {
const original = { tags: ['a', 'b'] };
const modified = { tags: ['a', 'c'] };
expect(calculateJmapPatch(original, modified)).toEqual({
tags: ['a', 'c'],
});
});
it('returns empty for identical arrays', () => {
const original = { tags: ['a', 'b'] };
const modified = { tags: ['a', 'b'] };
expect(calculateJmapPatch(original, modified)).toEqual({});
});
it('handles empty objects', () => {
const original = { data: {} };
const modified = { data: {} };
expect(calculateJmapPatch(original, modified)).toEqual({});
});
it('handles transition from primitive to object', () => {
const original = { field: 'string' };
const modified = { field: { nested: true } };
expect(calculateJmapPatch(original, modified)).toEqual({
field: { nested: true },
});
});
it('handles transition from object to primitive', () => {
const original = { field: { nested: true } };
const modified = { field: 'string' };
expect(calculateJmapPatch(original, modified)).toEqual({
field: 'string',
});
});
it('handles transition from array to object', () => {
const original = { field: [1, 2, 3] };
const modified = { field: { key: 'value' } };
expect(calculateJmapPatch(original, modified)).toEqual({
field: { key: 'value' },
});
});
it('handles transition from object to array', () => {
const original = { field: { key: 'value' } };
const modified = { field: [1, 2, 3] };
expect(calculateJmapPatch(original, modified)).toEqual({
field: [1, 2, 3],
});
});
describe('escapeJsonPointerToken', () => {
it('escapes tilde', () => {
expect(escapeJsonPointerToken('a~b')).toBe('a~0b');
});
it('escapes slash', () => {
expect(escapeJsonPointerToken('a/b')).toBe('a~1b');
});
it('escapes both, tilde first', () => {
expect(escapeJsonPointerToken('a~/b')).toBe('a~0~1b');
});
it('leaves unrelated characters alone', () => {
expect(escapeJsonPointerToken('plain')).toBe('plain');
});
});
it('escapes user-supplied map keys containing slashes in patches', () => {
const original = { headers: { 'X-Plain': 'old' } };
const modified = { headers: { 'X-Plain': 'old', 'X/Slashed': 'value' } };
expect(calculateJmapPatch(original, modified)).toEqual({
'headers/X~1Slashed': 'value',
});
});
it('escapes user-supplied map keys containing tildes in patches', () => {
const original = { headers: {} };
const modified = { headers: { 'with~tilde': 'yes' } };
expect(calculateJmapPatch(original, modified)).toEqual({
'headers/with~0tilde': 'yes',
});
});
it('handles multiple changes at different nesting levels', () => {
const original = {
name: 'Test',
nested: {
a: 1,
b: { c: 2, d: 3 },
},
other: 'unchanged',
};
const modified = {
name: 'Updated',
nested: {
a: 1,
b: { c: 99, d: 3 },
},
other: 'unchanged',
};
expect(calculateJmapPatch(original, modified)).toEqual({
name: 'Updated',
'nested/b/c': 99,
});
});
});
+91
View File
@@ -0,0 +1,91 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
export function calculateJmapPatch(
original: Record<string, unknown>,
modified: Record<string, unknown>,
): Record<string, unknown> {
const patch: Record<string, unknown> = {};
const allKeys = new Set([...Object.keys(original), ...Object.keys(modified)]);
const variantChanged = '@type' in original && '@type' in modified && original['@type'] !== modified['@type'];
for (const key of allKeys) {
if (key === 'id') {
continue;
}
const origVal = original[key];
const modVal = modified[key];
if (!(key in original)) {
patch[key] = modVal;
continue;
}
if (!(key in modified)) {
if (variantChanged) continue;
patch[key] = null;
continue;
}
diffValues(key, origVal, modVal, patch);
}
return patch;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
export function escapeJsonPointerToken(key: string): string {
return key.replace(/~/g, '~0').replace(/\//g, '~1');
}
function diffValues(prefix: string, origVal: unknown, modVal: unknown, patch: Record<string, unknown>): void {
if (isPlainObject(origVal) && isPlainObject(modVal)) {
const origType = origVal['@type'];
const modType = modVal['@type'];
if (origType !== modType) {
patch[prefix] = modVal;
return;
}
const allSubKeys = new Set([...Object.keys(origVal), ...Object.keys(modVal)]);
for (const subKey of allSubKeys) {
const path = `${prefix}/${escapeJsonPointerToken(subKey)}`;
const origSub = origVal[subKey];
const modSub = modVal[subKey];
if (!(subKey in origVal)) {
patch[path] = modSub;
continue;
}
if (!(subKey in modVal)) {
patch[path] = null;
continue;
}
diffValues(path, origSub, modSub, patch);
}
return;
}
if (Array.isArray(origVal) && Array.isArray(modVal)) {
if (JSON.stringify(origVal) !== JSON.stringify(modVal)) {
patch[prefix] = modVal;
}
return;
}
if (origVal !== modVal) {
patch[prefix] = modVal;
}
}
+15
View File
@@ -0,0 +1,15 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
export function jmapMapToArray<T>(val: unknown): T[] {
if (Array.isArray(val)) return val as T[];
if (val && typeof val === 'object') {
return Object.entries(val as Record<string, T>)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([, v]) => v);
}
return [];
}
+195
View File
@@ -0,0 +1,195 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import type { Layout, LayoutItem, LayoutSubItem, Schema } from '@/types/schema';
import { resolveObject } from '@/lib/schemaResolver';
function findFirstSubLink(items: LayoutSubItem[]): string | null {
for (const item of items) {
if (item.type === 'link') return item.viewName;
if (item.type === 'container') {
const found = findFirstSubLink(item.items);
if (found) return found;
}
}
return null;
}
export function findFirstLinkInLayout(items: LayoutItem[] | Layout): string | null {
const list = Array.isArray(items) ? items : items.items;
for (const item of list) {
if ('link' in item) return item.link.viewName;
if ('container' in item) {
const found = findFirstSubLink(item.container.items);
if (found) return found;
}
}
return null;
}
export type CanGet = (permissionPrefix: string) => boolean;
export type HasPermission = (permission: string) => boolean;
interface SpecialLinkInfo {
visible: boolean;
enterprise: boolean;
}
function checkSpecialLink(
viewName: string,
edition: string,
hasPerm?: HasPermission,
canGet?: CanGet,
): SpecialLinkInfo | null {
if (viewName.startsWith('Dashboard/') || viewName === 'CustomComponent/Dashboard') {
if (edition === 'oss') return { visible: false, enterprise: true };
const hasLiveMetrics = hasPerm ? hasPerm('liveMetrics') : true;
const hasTraceGet = canGet ? canGet('sysTrace') : true;
return { visible: hasLiveMetrics && hasTraceGet, enterprise: true };
}
if (viewName === 'CustomComponent/LiveDelivery') {
const allowed = hasPerm ? hasPerm('liveDeliveryTest') : true;
return { visible: allowed, enterprise: false };
}
if (viewName === 'CustomComponent/LiveTracing') {
if (edition === 'oss') return { visible: false, enterprise: true };
const allowed = hasPerm ? hasPerm('liveTracing') : true;
return { visible: allowed, enterprise: true };
}
if (viewName.startsWith('CustomComponent/')) {
return { visible: true, enterprise: false };
}
return null;
}
export function isLinkVisible(
schema: Schema,
viewName: string,
edition: string,
canGet: CanGet,
hasPerm?: HasPermission,
): boolean {
const special = checkSpecialLink(viewName, edition, hasPerm, canGet);
if (special !== null) return special.visible;
const obj = schema.objects[viewName];
if (!obj) return false;
const resolved = resolveObject(schema, viewName);
if (!resolved) return false;
if (!canGet(resolved.permissionPrefix)) return false;
if (resolved.enterprise && edition === 'oss') return false;
return true;
}
export function isLinkEnterprise(schema: Schema, viewName: string, edition: string): boolean {
const special = checkSpecialLink(viewName, edition);
if (special !== null) return special.enterprise;
const obj = schema.objects[viewName];
if (!obj) return false;
if (obj.type === 'view') {
const parent = schema.objects[obj.objectName];
return parent?.type !== 'view' && parent?.enterprise === true;
}
return obj.enterprise === true;
}
function findFirstVisibleSubLink(
schema: Schema,
items: LayoutSubItem[],
edition: string,
canGet: CanGet,
hasPerm?: HasPermission,
): string | null {
for (const item of items) {
if (item.type === 'link') {
if (isLinkVisible(schema, item.viewName, edition, canGet, hasPerm)) return item.viewName;
} else if (item.type === 'container') {
const found = findFirstVisibleSubLink(schema, item.items, edition, canGet, hasPerm);
if (found) return found;
}
}
return null;
}
export function findFirstVisibleLinkInLayout(
schema: Schema,
layout: Layout,
edition: string,
canGet: CanGet,
hasPerm?: HasPermission,
): string | null {
for (const item of layout.items) {
if ('link' in item) {
if (isLinkVisible(schema, item.link.viewName, edition, canGet, hasPerm)) return item.link.viewName;
} else if ('container' in item) {
const found = findFirstVisibleSubLink(schema, item.container.items, edition, canGet, hasPerm);
if (found) return found;
}
}
return null;
}
export function isLinkAccessible(
schema: Schema,
viewName: string,
edition: string,
canGet: CanGet,
hasPerm?: HasPermission,
): boolean {
if (!isLinkVisible(schema, viewName, edition, canGet, hasPerm)) return false;
if (edition === 'community' && isLinkEnterprise(schema, viewName, edition)) return false;
return true;
}
function findFirstAccessibleSubLink(
schema: Schema,
items: LayoutSubItem[],
edition: string,
canGet: CanGet,
hasPerm?: HasPermission,
): string | null {
for (const item of items) {
if (item.type === 'link') {
if (isLinkAccessible(schema, item.viewName, edition, canGet, hasPerm)) return item.viewName;
} else if (item.type === 'container') {
const found = findFirstAccessibleSubLink(schema, item.items, edition, canGet, hasPerm);
if (found) return found;
}
}
return null;
}
export function findFirstAccessibleLinkInLayout(
schema: Schema,
layout: Layout,
edition: string,
canGet: CanGet,
hasPerm?: HasPermission,
): string | null {
for (const item of layout.items) {
if ('link' in item) {
if (isLinkAccessible(schema, item.link.viewName, edition, canGet, hasPerm)) return item.link.viewName;
} else if ('container' in item) {
const found = findFirstAccessibleSubLink(schema, item.container.items, edition, canGet, hasPerm);
if (found) return found;
}
}
return null;
}
export function visibleLayouts(schema: Schema, edition: string, canGet: CanGet, hasPerm?: HasPermission): Layout[] {
return schema.layouts.filter(
(layout) => findFirstVisibleLinkInLayout(schema, layout, edition, canGet, hasPerm) !== null,
);
}
+161
View File
@@ -0,0 +1,161 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { resolveObject, resolveList, getDisplayProperty } from '@/lib/schemaResolver';
import { jmapGetBatched, jmapQueryAllAndGet, getAccountId } from '@/services/jmap/client';
import { useAccountStore } from '@/stores/accountStore';
import { useCacheStore, type ObjectListEntry } from '@/stores/cacheStore';
import type { Schema } from '@/types/schema';
interface PendingBatch {
ids: Set<string>;
displayProp: string;
schema: Schema;
}
const pendingByType = new Map<string, PendingBatch>();
function requestObjectLabel(parentObjectName: string, viewOrObjectName: string, id: string, schema: Schema): void {
const existing = pendingByType.get(parentObjectName);
if (existing) {
existing.ids.add(id);
return;
}
const batch: PendingBatch = {
ids: new Set([id]),
displayProp: getDisplayProperty(schema, viewOrObjectName),
schema,
};
pendingByType.set(parentObjectName, batch);
queueMicrotask(() => {
const taken = pendingByType.get(parentObjectName);
if (taken !== batch) return;
pendingByType.delete(parentObjectName);
void executeBatch(parentObjectName, taken);
});
}
async function executeBatch(parentObjectName: string, batch: PendingBatch): Promise<void> {
const ids = Array.from(batch.ids);
const setDisplayNames = useCacheStore.getState().setDisplayNames;
try {
const accountId = getAccountId(parentObjectName);
const list = await jmapGetBatched(parentObjectName, accountId, ids, ['id', batch.displayProp]);
const entries: Record<string, string> = {};
for (const item of list) {
const itemId = item.id as string;
if (!itemId) continue;
entries[itemId] = (item[batch.displayProp] as string) ?? itemId;
}
for (const id of ids) {
if (!(id in entries)) entries[id] = id;
}
setDisplayNames(parentObjectName, entries);
} catch (err) {
console.error('Failed to batch-fetch labels for', parentObjectName, err);
const fallback: Record<string, string> = {};
for (const id of ids) fallback[id] = id;
setDisplayNames(parentObjectName, fallback);
}
}
export type ObjectOption = ObjectListEntry;
async function fetchObjectList(viewOrObjectName: string, schema: Schema): Promise<ObjectOption[]> {
const resolved = resolveObject(schema, viewOrObjectName);
const objectName = resolved?.objectName ?? viewOrObjectName;
const list = resolveList(schema, viewOrObjectName, objectName);
const filtersStatic = list?.filtersStatic;
const accountId = getAccountId(objectName);
const displayProp = getDisplayProperty(schema, viewOrObjectName);
let items: Array<Record<string, unknown>> = [];
try {
const result = await jmapQueryAllAndGet(
objectName,
accountId,
{ filter: filtersStatic && Object.keys(filtersStatic).length > 0 ? filtersStatic : undefined },
['id', displayProp],
);
items = result.list;
} catch (err) {
console.error('JMAP error fetching', viewOrObjectName, err);
return [];
}
return items.map((item) => ({
id: item.id as string,
label: (item[displayProp] as string) ?? (item.id as string),
}));
}
export function useObjectList(viewOrObjectName: string, schema: Schema) {
const cached = useCacheStore((s) => s.objectLists[viewOrObjectName]);
const setObjectList = useCacheStore((s) => s.setObjectList);
const [loading, setLoading] = useState(false);
const ensureLoaded = useCallback(async () => {
if (cached) return;
setLoading(true);
try {
const opts = await fetchObjectList(viewOrObjectName, schema);
setObjectList(viewOrObjectName, opts);
} catch (err) {
console.error('Failed to fetch objects for', viewOrObjectName, err);
} finally {
setLoading(false);
}
}, [cached, viewOrObjectName, schema, setObjectList]);
return { options: cached ?? [], loading, hasLoaded: cached != null, ensureLoaded };
}
export function useObjectLabel(
viewOrObjectName: string,
id: string | null | undefined,
schema: Schema,
): { label: string | null; loading: boolean } {
const resolved = resolveObject(schema, viewOrObjectName);
const parentObjectName = resolved?.objectName ?? viewOrObjectName;
const cachedLabel = useCacheStore((s) => (id ? s.displayNames[parentObjectName]?.[id] : undefined));
useEffect(() => {
if (!id) return;
if (cachedLabel != null) return;
requestObjectLabel(parentObjectName, viewOrObjectName, id, schema);
}, [id, cachedLabel, parentObjectName, viewOrObjectName, schema]);
return {
label: cachedLabel ?? null,
loading: !!id && cachedLabel == null,
};
}
export function objectSupportsSearch(schema: Schema, objectName: string): boolean {
const resolved = resolveObject(schema, objectName);
if (!resolved) return false;
const list = resolveList(schema, objectName, resolved.objectName);
return list?.filters?.some((f) => f.type === 'text') ?? false;
}
export function useNoPermissionMessage(schema: Schema, viewOrObjectName: string): string | null {
const { t } = useTranslation();
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
const resolved = resolveObject(schema, viewOrObjectName);
if (!resolved) return null;
const prefix = resolved.permissionPrefix;
if (hasObjectPermission(prefix, 'Get') && hasObjectPermission(prefix, 'Query')) return null;
const list = resolveList(schema, viewOrObjectName, resolved.objectName);
const name = list?.pluralName ?? resolved.objectName;
return t('field.noPermissionToView', 'You do not have permission to view {{name}}', { name });
}
+867
View File
@@ -0,0 +1,867 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { describe, it, expect } from 'vitest';
import type { Schema } from '@/types/schema';
import {
resolveObject,
resolveSchema,
resolveList,
resolveForm,
resolveVariantForm,
deepMerge,
buildCreateDefaults,
buildEmbeddedDefaults,
} from './schemaResolver';
import { getDisplayProperty } from './schemaResolver';
const schema: Schema = {
objects: {
'x:Domain': {
type: 'object',
description: 'Mail domain',
permissionPrefix: 'sysDomain',
},
'x:Auth': {
type: 'singleton',
description: 'Authentication settings',
permissionPrefix: 'sysAuth',
},
'x:Account': {
type: 'object',
description: 'Account',
permissionPrefix: 'sysAccount',
},
'x:Account/User': {
type: 'view',
objectName: 'x:Account',
},
'x:Account/Group': {
type: 'view',
objectName: 'x:Account',
},
'x:Credential': {
type: 'object',
description: 'Credential',
permissionPrefix: 'sysCredential',
},
'x:Credential/ApiKey': {
type: 'view',
objectName: 'x:Credential',
},
'x:Tenant': {
type: 'object',
description: 'Tenant',
permissionPrefix: 'sysTenant',
enterprise: true,
},
'x:Bad/ViewOfView': {
type: 'view',
objectName: 'x:Account/User',
},
},
schemas: {
'x:Domain': {
type: 'single',
schemaName: 'DomainFields',
},
'x:Auth': {
type: 'single',
schemaName: 'AuthFields',
},
'x:Account': {
type: 'multiple',
variants: [
{ name: 'User', label: 'User Account', schemaName: 'UserFields' },
{ name: 'Group', label: 'Group Account', schemaName: 'GroupFields' },
{ name: 'External', label: 'External Account' },
],
},
'x:Credential': {
type: 'single',
schemaName: 'CredentialFields',
},
'x:Tenant': {
type: 'single',
schemaName: 'TenantFields',
},
'x:Orphan': {
type: 'single',
schemaName: 'MissingFields',
},
},
fields: {
DomainFields: {
properties: {
domainName: {
description: 'Domain name',
type: { type: 'string', format: 'string' },
update: 'immutable',
},
},
defaults: { domainName: 'example.com', active: true },
},
AuthFields: {
properties: {
method: {
description: 'Auth method',
type: { type: 'string', format: 'string' },
update: 'mutable',
},
},
defaults: { method: 'password' },
},
UserFields: {
properties: {
email: {
description: 'Email',
type: { type: 'string', format: 'emailAddress' },
update: 'mutable',
},
role: {
description: 'Role',
type: { type: 'string', format: 'string' },
update: 'mutable',
},
settings: {
description: 'Settings',
type: { type: 'object', objectName: 'x:UserSettings' },
update: 'mutable',
},
},
defaults: { email: '', quota: 1000, settings: { lang: 'en' } },
},
GroupFields: {
properties: {
groupName: {
description: 'Group name',
type: { type: 'string', format: 'string' },
update: 'mutable',
},
},
defaults: { groupName: '' },
},
CredentialFields: {
properties: {
token: {
description: 'Token',
type: { type: 'string', format: 'secret' },
update: 'serverSet',
},
},
defaults: { expiresIn: 3600 },
},
TenantFields: {
properties: {
tenantName: {
description: 'Tenant name',
type: { type: 'string', format: 'string' },
update: 'mutable',
},
},
},
AccountBaseFields: {
properties: {},
},
},
forms: {
'x:Domain': {
title: 'Domain Form',
sections: [{ fields: [{ name: 'domainName', label: 'Domain' }] }],
},
'x:Account/User': {
title: 'User Form',
sections: [{ fields: [{ name: 'email', label: 'Email' }] }],
},
'x:Account': {
title: 'Account Form',
sections: [{ fields: [{ name: 'name', label: 'Name' }] }],
},
UserFields: {
title: 'User Schema Form',
sections: [{ fields: [{ name: 'email', label: 'Email' }] }],
},
GroupFields: {
title: 'Group Schema Form',
sections: [{ fields: [{ name: 'name', label: 'Name' }] }],
},
CredentialFields: {
title: 'Credential Schema Form',
sections: [{ fields: [{ name: 'token', label: 'Token' }] }],
},
},
lists: {
'x:Domain': {
title: 'Domains',
subtitle: 'All domains',
labelProperty: 'domainName',
singularName: 'domain',
pluralName: 'domains',
columns: [
{ name: 'domainName', label: 'Domain' },
{ name: 'active', label: 'Active' },
],
},
'x:Account/User': {
title: 'Users',
subtitle: 'User accounts',
singularName: 'user',
pluralName: 'users',
columns: [{ name: 'email', label: 'Email' }],
filtersStatic: { role: 'user', settings: { theme: 'dark', notifications: true } },
},
'x:Account': {
title: 'Accounts',
subtitle: 'All accounts',
singularName: 'account',
pluralName: 'accounts',
columns: [{ name: 'name', label: 'Name' }],
},
'x:Credential': {
title: 'Credentials',
subtitle: 'All credentials',
singularName: 'credential',
pluralName: 'credentials',
columns: [{ name: 'token', label: 'Token' }],
},
'x:NoLabel': {
title: 'No Label',
subtitle: '',
singularName: 'item',
pluralName: 'items',
columns: [{ name: 'title', label: 'Title' }],
},
'x:Empty': {
title: 'Empty',
subtitle: '',
singularName: 'thing',
pluralName: 'things',
columns: [],
},
},
enums: {},
dashboards: [],
layouts: [],
};
describe('resolveObject', () => {
it('resolves a regular object', () => {
const result = resolveObject(schema, 'x:Domain');
expect(result).not.toBeNull();
expect(result!.viewName).toBe('x:Domain');
expect(result!.objectName).toBe('x:Domain');
expect(result!.objectType.type).toBe('object');
expect(result!.permissionPrefix).toBe('sysDomain');
expect(result!.enterprise).toBe(false);
});
it('resolves a singleton', () => {
const result = resolveObject(schema, 'x:Auth');
expect(result).not.toBeNull();
expect(result!.viewName).toBe('x:Auth');
expect(result!.objectName).toBe('x:Auth');
expect(result!.objectType.type).toBe('singleton');
expect(result!.permissionPrefix).toBe('sysAuth');
expect(result!.enterprise).toBe(false);
});
it('resolves a view pointing to a parent object', () => {
const result = resolveObject(schema, 'x:Account/User');
expect(result).not.toBeNull();
expect(result!.viewName).toBe('x:Account/User');
expect(result!.objectName).toBe('x:Account');
expect(result!.objectType.type).toBe('object');
expect(result!.permissionPrefix).toBe('sysAccount');
expect(result!.enterprise).toBe(false);
});
it('returns null for unknown viewName', () => {
expect(resolveObject(schema, 'x:NonExistent')).toBeNull();
});
it('returns true for enterprise flag', () => {
const result = resolveObject(schema, 'x:Tenant');
expect(result!.enterprise).toBe(true);
});
it('returns null when a view points to another view', () => {
const result = resolveObject(schema, 'x:Bad/ViewOfView');
expect(result).toBeNull();
});
it('returns null when a view points to a missing parent', () => {
const badSchema: Schema = {
...schema,
objects: {
'x:Dangling': { type: 'view', objectName: 'x:Gone' },
},
};
expect(resolveObject(badSchema, 'x:Dangling')).toBeNull();
});
});
describe('resolveSchema', () => {
it('resolves a single schema', () => {
const result = resolveSchema(schema, 'x:Domain')!;
expect(result).not.toBeNull();
expect(result.type).toBe('single');
if (result.type === 'single') {
expect(result.schemaName).toBe('DomainFields');
expect(result.fields.properties).toHaveProperty('domainName');
}
});
it('resolves a multiple schema with variants', () => {
const result = resolveSchema(schema, 'x:Account')!;
expect(result).not.toBeNull();
expect(result.type).toBe('multiple');
if (result.type === 'multiple') {
expect(result.variants).toHaveLength(3);
expect(result.variants[0].name).toBe('User');
expect(result.variants[0].label).toBe('User Account');
expect(result.variants[0].fields).not.toBeNull();
expect(result.variants[0].fields!.properties).toHaveProperty('email');
}
});
it('sets fields to null for variant without schemaName', () => {
const result = resolveSchema(schema, 'x:Account')!;
if (result.type === 'multiple') {
const ext = result.variants.find((v) => v.name === 'External');
expect(ext).toBeDefined();
expect(ext!.fields).toBeNull();
expect(ext!.schemaName).toBeUndefined();
}
});
it('returns null for unknown objectName', () => {
expect(resolveSchema(schema, 'x:Unknown')).toBeNull();
});
it('returns null when single schema references missing fields', () => {
expect(resolveSchema(schema, 'x:Orphan')).toBeNull();
});
it('returns fields with defaults for single schema', () => {
const result = resolveSchema(schema, 'x:Domain')!;
if (result.type === 'single') {
expect(result.fields.defaults).toEqual({ domainName: 'example.com', active: true });
}
});
it('preserves schemaName on variant entries', () => {
const result = resolveSchema(schema, 'x:Account')!;
if (result.type === 'multiple') {
expect(result.variants[0].schemaName).toBe('UserFields');
expect(result.variants[1].schemaName).toBe('GroupFields');
}
});
});
describe('resolveList', () => {
it('returns view-specific list when it exists', () => {
const list = resolveList(schema, 'x:Account/User', 'x:Account');
expect(list).not.toBeNull();
expect(list!.title).toBe('Users');
});
it('falls back to parent objectName list', () => {
const list = resolveList(schema, 'x:Account/Group', 'x:Account');
expect(list).not.toBeNull();
expect(list!.title).toBe('Accounts');
});
it('returns null when neither view nor object list exists', () => {
const list = resolveList(schema, 'x:NoView', 'x:NoObject');
expect(list).toBeNull();
});
it('returns the list for a direct object', () => {
const list = resolveList(schema, 'x:Domain', 'x:Domain');
expect(list).not.toBeNull();
expect(list!.title).toBe('Domains');
});
it('prefers viewName over objectName', () => {
const list = resolveList(schema, 'x:Account/User', 'x:Account');
expect(list!.singularName).toBe('user');
});
});
describe('resolveForm', () => {
it('returns view-specific form', () => {
const form = resolveForm(schema, 'x:Account/User', 'x:Account', 'UserFields');
expect(form).not.toBeNull();
expect(form!.title).toBe('User Form');
});
it('falls back to objectName form', () => {
const form = resolveForm(schema, 'x:Account/Group', 'x:Account', 'GroupFields');
expect(form).not.toBeNull();
expect(form!.title).toBe('Account Form');
});
it('falls back to schemaName form', () => {
const smallSchema: Schema = {
...schema,
forms: {
CredentialFields: schema.forms['CredentialFields'],
},
};
const form = resolveForm(smallSchema, 'x:Credential/ApiKey', 'x:Credential', 'CredentialFields');
expect(form).not.toBeNull();
expect(form!.title).toBe('Credential Schema Form');
});
it('returns null when no form found at any level', () => {
const form = resolveForm(schema, 'x:NoView', 'x:NoObject', 'NoSchema');
expect(form).toBeNull();
});
it('uses viewName form even when objectName and schemaName also exist', () => {
const form = resolveForm(schema, 'x:Account/User', 'x:Account', 'UserFields');
expect(form!.title).toBe('User Form');
});
it('returns domain form for direct object', () => {
const form = resolveForm(schema, 'x:Domain', 'x:Domain', 'DomainFields');
expect(form!.title).toBe('Domain Form');
});
});
describe('resolveVariantForm', () => {
it('returns the variant-specific form by schemaName', () => {
const form = resolveVariantForm(schema, 'x:Account/User', 'x:Account', 'UserFields');
expect(form).not.toBeNull();
expect(form!.title).toBe('User Schema Form');
});
it('does not fall back to the parent form even if it exists', () => {
const form = resolveVariantForm(schema, 'x:Account/Group', 'x:Account', 'GroupFields');
expect(form).not.toBeNull();
expect(form!.title).toBe('Group Schema Form');
});
it('returns null when variantSchemaName is undefined', () => {
const form = resolveVariantForm(schema, 'x:NoView', 'x:NoObject');
expect(form).toBeNull();
});
it('returns null when variantSchemaName is provided but has no form', () => {
const form = resolveVariantForm(schema, 'x:NoView', 'x:NoObject', 'NonExistentSchema');
expect(form).toBeNull();
});
});
describe('deepMerge', () => {
it('merges flat objects', () => {
expect(deepMerge({ a: 1 }, { b: 2 })).toEqual({ a: 1, b: 2 });
});
it('source overrides target for same key', () => {
expect(deepMerge({ a: 1 }, { a: 2 })).toEqual({ a: 2 });
});
it('deeply merges nested objects', () => {
const target = { x: { a: 1, b: 2 } };
const source = { x: { b: 3, c: 4 } };
expect(deepMerge(target, source)).toEqual({ x: { a: 1, b: 3, c: 4 } });
});
it('replaces arrays instead of merging them', () => {
const target = { arr: [1, 2, 3] };
const source = { arr: [4, 5] };
expect(deepMerge(target, source)).toEqual({ arr: [4, 5] });
});
it('handles null source values (replaces target)', () => {
const target: Record<string, unknown> = { a: { nested: 1 } };
const source: Record<string, unknown> = { a: null };
expect(deepMerge(target, source)).toEqual({ a: null });
});
it('handles null target values (replaced by source object)', () => {
const target: Record<string, unknown> = { a: null };
const source: Record<string, unknown> = { a: { nested: 1 } };
expect(deepMerge(target, source)).toEqual({ a: { nested: 1 } });
});
it('handles empty source object', () => {
expect(deepMerge({ a: 1 }, {})).toEqual({ a: 1 });
});
it('handles empty target object', () => {
expect(deepMerge({}, { a: 1 })).toEqual({ a: 1 });
});
it('handles both empty', () => {
expect(deepMerge({}, {})).toEqual({});
});
it('deeply merges 3 levels', () => {
const target = { l1: { l2: { l3: 'original', keep: true } } };
const source = { l1: { l2: { l3: 'updated', added: 42 } } };
expect(deepMerge(target, source)).toEqual({
l1: { l2: { l3: 'updated', keep: true, added: 42 } },
});
});
it('does not mutate target', () => {
const target = { a: 1, nested: { b: 2 } };
const original = { ...target, nested: { ...target.nested } };
deepMerge(target, { a: 99, nested: { c: 3 } });
expect(target).toEqual(original);
});
it('does not mutate source', () => {
const source = { a: 1 };
const copy = { ...source };
deepMerge({}, source);
expect(source).toEqual(copy);
});
it('replaces string with object', () => {
const target: Record<string, unknown> = { a: 'hello' };
const source: Record<string, unknown> = { a: { nested: 1 } };
expect(deepMerge(target, source)).toEqual({ a: { nested: 1 } });
});
it('replaces object with string', () => {
const target: Record<string, unknown> = { a: { nested: 1 } };
const source: Record<string, unknown> = { a: 'hello' };
expect(deepMerge(target, source)).toEqual({ a: 'hello' });
});
});
const embeddedSchema: Schema = {
objects: {
'x:SpamClassifier': {
type: 'singleton',
description: 'Spam classifier',
permissionPrefix: 'sysSpam',
},
},
schemas: {
'x:Model': {
type: 'multiple',
variants: [
{ name: 'FtrlFh', label: 'FTRL FH', schemaName: 'x:FtrlFh' },
{ name: 'FtrlCcfh', label: 'FTRL CCFH', schemaName: 'x:FtrlCcfh' },
],
},
'x:FtrlParameters': {
type: 'single',
schemaName: 'x:FtrlParameters',
},
'x:CertManagement': {
type: 'multiple',
variants: [
{ name: 'Manual', label: 'Manual' },
{ name: 'Automatic', label: 'Automatic', schemaName: 'x:CertAuto' },
],
},
},
fields: {
'x:FtrlFh': {
properties: {
learningRate: {
description: '',
type: { type: 'number', format: 'float' },
update: 'mutable',
},
},
defaults: {
learningRate: 0.01,
},
},
'x:FtrlCcfh': {
properties: {
featureL2Normalize: {
description: '',
type: { type: 'boolean' },
update: 'mutable',
},
parameters: {
description: '',
type: { type: 'object', objectName: 'x:FtrlParameters' },
update: 'mutable',
},
indicatorParameters: {
description: '',
type: { type: 'object', objectName: 'x:FtrlParameters' },
update: 'mutable',
},
},
defaults: {
featureL2Normalize: true,
parameters: { numFeatures: '20' },
indicatorParameters: { numFeatures: '18' },
},
},
'x:FtrlParameters': {
properties: {
alpha: { description: '', type: { type: 'number', format: 'float' }, update: 'mutable' },
beta: { description: '', type: { type: 'number', format: 'float' }, update: 'mutable' },
l1Ratio: { description: '', type: { type: 'number', format: 'float' }, update: 'mutable' },
l2Ratio: { description: '', type: { type: 'number', format: 'float' }, update: 'mutable' },
numFeatures: { description: '', type: { type: 'string', format: 'string' }, update: 'mutable' },
},
defaults: {
alpha: 2,
beta: 1,
l1Ratio: 0.001,
l2Ratio: 0.0001,
numFeatures: '50',
},
},
'x:CertAuto': {
properties: {
acmeProviderId: { description: '', type: { type: 'string', format: 'string' }, update: 'mutable' },
},
defaults: {},
},
},
forms: {},
lists: {},
enums: {},
dashboards: [],
layouts: [],
};
describe('buildEmbeddedDefaults', () => {
it('returns child schema defaults for a single object', () => {
const result = buildEmbeddedDefaults(embeddedSchema, 'x:FtrlParameters');
expect(result).toEqual({
alpha: 2,
beta: 1,
l1Ratio: 0.001,
l2Ratio: 0.0001,
numFeatures: '50',
});
});
it('returns variant defaults with @type for a multi-variant object', () => {
const result = buildEmbeddedDefaults(embeddedSchema, 'x:Model', {}, 'FtrlFh');
expect(result).toEqual({
'@type': 'FtrlFh',
learningRate: 0.01,
});
});
it('recursively merges parent overrides into child defaults for embedded fields', () => {
const result = buildEmbeddedDefaults(embeddedSchema, 'x:Model', {}, 'FtrlCcfh');
expect(result).toEqual({
'@type': 'FtrlCcfh',
featureL2Normalize: true,
parameters: {
alpha: 2,
beta: 1,
l1Ratio: 0.001,
l2Ratio: 0.0001,
numFeatures: '20',
},
indicatorParameters: {
alpha: 2,
beta: 1,
l1Ratio: 0.001,
l2Ratio: 0.0001,
numFeatures: '18',
},
});
});
it('applies caller-supplied parent overrides on top of everything', () => {
const result = buildEmbeddedDefaults(embeddedSchema, 'x:Model', { featureL2Normalize: false }, 'FtrlCcfh');
expect(result.featureL2Normalize).toBe(false);
expect(result.parameters).toMatchObject({ alpha: 2, numFeatures: '20' });
});
it('handles a variant with no schemaName', () => {
const result = buildEmbeddedDefaults(embeddedSchema, 'x:CertManagement', {}, 'Manual');
expect(result).toEqual({ '@type': 'Manual' });
});
it('handles a variant with empty defaults', () => {
const result = buildEmbeddedDefaults(embeddedSchema, 'x:CertManagement', {}, 'Automatic');
expect(result).toEqual({ '@type': 'Automatic' });
});
it('returns parentOverrides when objectName is not in schema', () => {
const result = buildEmbeddedDefaults(embeddedSchema, 'x:Unknown', { foo: 'bar' });
expect(result).toEqual({ foo: 'bar' });
});
it('falls back to first variant when variantName is not provided', () => {
const result = buildEmbeddedDefaults(embeddedSchema, 'x:Model');
expect(result['@type']).toBe('FtrlFh');
expect(result.learningRate).toBe(0.01);
});
it('parent override only mentions some keys -- others come from child', () => {
const result = buildEmbeddedDefaults(embeddedSchema, 'x:Model', {}, 'FtrlCcfh');
const params = result.parameters as Record<string, unknown>;
expect(Object.keys(params).sort()).toEqual(['alpha', 'beta', 'l1Ratio', 'l2Ratio', 'numFeatures']);
});
it('parent overrides take precedence for matching nested keys', () => {
const result = buildEmbeddedDefaults(embeddedSchema, 'x:Model', {}, 'FtrlCcfh');
expect((result.parameters as Record<string, unknown>).numFeatures).toBe('20');
expect((result.indicatorParameters as Record<string, unknown>).numFeatures).toBe('18');
});
});
describe('buildCreateDefaults', () => {
it('returns single schema defaults', () => {
const ro = resolveObject(schema, 'x:Domain')!;
const rs = resolveSchema(schema, 'x:Domain')!;
const defaults = buildCreateDefaults(schema, ro, rs);
expect(defaults).toEqual({ domainName: 'example.com', active: true });
});
it('returns multi-variant defaults with @type', () => {
const ro = resolveObject(schema, 'x:Account/User')!;
const rs = resolveSchema(schema, 'x:Account')!;
const defaults = buildCreateDefaults(schema, ro, rs, 'User');
expect(defaults['@type']).toBe('User');
});
it('includes variant field defaults for multi-variant', () => {
const ro = resolveObject(schema, 'x:Account/User')!;
const rs = resolveSchema(schema, 'x:Account')!;
const defaults = buildCreateDefaults(schema, ro, rs, 'User');
expect(defaults).toHaveProperty('email', '');
expect(defaults).toHaveProperty('quota', 1000);
});
it('sets @type even when variant has no fields defaults', () => {
const ro = resolveObject(schema, 'x:Account/Group')!;
const rs = resolveSchema(schema, 'x:Account')!;
const defaults = buildCreateDefaults(schema, ro, rs, 'External');
expect(defaults['@type']).toBe('External');
});
it('applies parent schema defaults on top of variant defaults', () => {
const ro = resolveObject(schema, 'x:Credential/ApiKey')!;
const rs = resolveSchema(schema, 'x:Credential')!;
const defaults = buildCreateDefaults(schema, ro, rs);
expect(defaults).toHaveProperty('expiresIn', 3600);
});
it('applies filtersStatic field values on top of everything', () => {
const ro = resolveObject(schema, 'x:Account/User')!;
const rs = resolveSchema(schema, 'x:Account')!;
const list = resolveList(schema, 'x:Account/User', 'x:Account');
const defaults = buildCreateDefaults(schema, ro, rs, 'User', list?.filtersStatic);
expect(defaults).toHaveProperty('role', 'user');
});
it('deep-merges nested filtersStatic values with variant defaults', () => {
const ro = resolveObject(schema, 'x:Account/User')!;
const rs = resolveSchema(schema, 'x:Account')!;
const list = resolveList(schema, 'x:Account/User', 'x:Account');
const defaults = buildCreateDefaults(schema, ro, rs, 'User', list?.filtersStatic);
expect(defaults.settings).toEqual({
lang: 'en',
theme: 'dark',
notifications: true,
});
});
it('returns empty object when schema has no defaults', () => {
const ro = resolveObject(schema, 'x:Tenant')!;
const rs = resolveSchema(schema, 'x:Tenant')!;
const defaults = buildCreateDefaults(schema, ro, rs);
expect(defaults).toEqual({});
});
it('does not set @type for single schema', () => {
const ro = resolveObject(schema, 'x:Domain')!;
const rs = resolveSchema(schema, 'x:Domain')!;
const defaults = buildCreateDefaults(schema, ro, rs);
expect(defaults).not.toHaveProperty('@type');
});
it('does not set @type for multiple schema when variantName is not provided', () => {
const ro = resolveObject(schema, 'x:Account/User')!;
const rs = resolveSchema(schema, 'x:Account')!;
const defaults = buildCreateDefaults(schema, ro, rs);
expect(defaults).not.toHaveProperty('@type');
});
it('merge order: child defaults < parent defaults < filtersStatic', () => {
const custom: Schema = {
...schema,
objects: {
'x:Parent': {
type: 'object',
description: 'Parent',
permissionPrefix: 'p',
},
'x:Parent/Child': {
type: 'view',
objectName: 'x:Parent',
},
},
schemas: {
'x:Parent': {
type: 'single',
schemaName: 'ParentFields',
},
},
fields: {
ParentFields: {
properties: {
priority: { description: '', type: { type: 'string', format: 'string' }, update: 'mutable' },
fromParent: { description: '', type: { type: 'boolean' }, update: 'mutable' },
},
defaults: { priority: 'parent', fromParent: true },
},
},
lists: {
'x:Parent/Child': {
title: 'Children',
subtitle: '',
singularName: 'child',
pluralName: 'children',
columns: [],
filtersStatic: { priority: 'filter' },
},
},
};
const ro = resolveObject(custom, 'x:Parent/Child')!;
const rs = resolveSchema(custom, 'x:Parent')!;
const defaults = buildCreateDefaults(custom, ro, rs, undefined, custom.lists['x:Parent/Child'].filtersStatic);
expect(defaults.priority).toBe('filter');
expect(defaults.fromParent).toBe(true);
});
});
describe('getDisplayProperty', () => {
it('returns labelProperty when defined', () => {
expect(getDisplayProperty(schema, 'x:Domain')).toBe('domainName');
});
it('returns first column name when no labelProperty', () => {
expect(getDisplayProperty(schema, 'x:Account/User')).toBe('email');
});
it('falls back to "name" when no list exists', () => {
expect(getDisplayProperty(schema, 'x:NonExistent')).toBe('name');
});
it('falls back to "name" when columns are empty', () => {
expect(getDisplayProperty(schema, 'x:Empty')).toBe('name');
});
it('returns first column when labelProperty is absent but columns exist', () => {
expect(getDisplayProperty(schema, 'x:NoLabel')).toBe('title');
});
});
+268
View File
@@ -0,0 +1,268 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import type { Schema, ObjectTypeObject, ObjectTypeSingleton, Fields, Form, List } from '@/types/schema';
export interface ResolvedObject {
viewName: string;
objectName: string;
objectType: ObjectTypeObject | ObjectTypeSingleton;
permissionPrefix: string;
enterprise: boolean;
}
export function resolveObject(schema: Schema, viewName: string): ResolvedObject | null {
const entry = schema.objects[viewName];
if (!entry) return null;
if (entry.type === 'view') {
const parent = schema.objects[entry.objectName];
if (!parent || parent.type === 'view') return null;
return {
viewName,
objectName: entry.objectName,
objectType: parent,
permissionPrefix: parent.permissionPrefix,
enterprise: parent.enterprise ?? false,
};
}
return {
viewName,
objectName: viewName,
objectType: entry,
permissionPrefix: entry.permissionPrefix,
enterprise: entry.enterprise ?? false,
};
}
export interface ResolvedSingleSchema {
type: 'single';
schemaName: string;
fields: Fields;
}
export interface ResolvedMultipleSchema {
type: 'multiple';
variants: {
name: string;
label: string;
schemaName?: string;
fields: Fields | null;
}[];
}
export type ResolvedSchema = ResolvedSingleSchema | ResolvedMultipleSchema;
export function resolveSchema(schema: Schema, objectName: string): ResolvedSchema | null {
const schemaEntry = schema.schemas[objectName];
if (!schemaEntry) return null;
if (schemaEntry.type === 'single') {
const fields = schema.fields[schemaEntry.schemaName];
if (!fields) return null;
return { type: 'single', schemaName: schemaEntry.schemaName, fields };
}
return {
type: 'multiple',
variants: schemaEntry.variants.map((v) => ({
name: v.name,
label: v.label,
schemaName: v.schemaName,
fields: v.schemaName ? (schema.fields[v.schemaName] ?? null) : null,
})),
};
}
export function resolveList(schema: Schema, viewName: string, objectName: string): List | null {
return schema.lists[viewName] ?? schema.lists[objectName] ?? null;
}
export function resolveForm(schema: Schema, viewName: string, objectName: string, schemaName: string): Form | null {
return schema.forms[viewName] ?? schema.forms[objectName] ?? schema.forms[schemaName] ?? null;
}
export function resolveVariantForm(
schema: Schema,
_viewName: string,
_objectName: string,
variantSchemaName?: string,
): Form | null {
if (variantSchemaName && schema.forms[variantSchemaName]) {
return schema.forms[variantSchemaName];
}
return null;
}
export function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
const result = { ...target };
for (const key of Object.keys(source)) {
const sourceVal = source[key];
const targetVal = result[key];
if (
sourceVal &&
typeof sourceVal === 'object' &&
!Array.isArray(sourceVal) &&
targetVal &&
typeof targetVal === 'object' &&
!Array.isArray(targetVal)
) {
result[key] = deepMerge(targetVal as Record<string, unknown>, sourceVal as Record<string, unknown>);
} else {
result[key] = sourceVal;
}
}
return result;
}
export function buildCreateDefaults(
schema: Schema,
resolvedObject: ResolvedObject,
resolvedSchema: ResolvedSchema,
variantName?: string,
filtersStatic?: Record<string, unknown>,
): Record<string, unknown> {
let defaults: Record<string, unknown> = {};
let activeFields: Fields | null = null;
if (resolvedSchema.type === 'single') {
activeFields = resolvedSchema.fields;
const childDefaults = resolvedSchema.fields.defaults ?? {};
defaults = deepMerge(defaults, childDefaults);
} else if (resolvedSchema.type === 'multiple' && variantName) {
const variant = resolvedSchema.variants.find((v) => v.name === variantName);
activeFields = variant?.fields ?? null;
if (variant?.fields?.defaults) {
defaults = deepMerge(defaults, variant.fields.defaults);
}
defaults['@type'] = variantName;
}
const parentSchemaEntry = schema.schemas[resolvedObject.objectName];
if (parentSchemaEntry?.type === 'single') {
const parentFields = schema.fields[parentSchemaEntry.schemaName];
if (parentFields?.defaults) {
defaults = deepMerge(defaults, parentFields.defaults);
}
}
if (activeFields) {
for (const [propName, propDef] of Object.entries(activeFields.properties)) {
const t = propDef.type;
if (t.type !== 'object') continue;
if (defaults[propName] === null) continue;
const parentChildOverride =
defaults[propName] && typeof defaults[propName] === 'object' && !Array.isArray(defaults[propName])
? (defaults[propName] as Record<string, unknown>)
: {};
const nestedSchemaEntry = schema.schemas[t.objectName];
let nestedVariant: string | undefined;
if (nestedSchemaEntry?.type === 'multiple') {
nestedVariant = (parentChildOverride['@type'] as string | undefined) ?? nestedSchemaEntry.variants[0]?.name;
}
const nested = buildEmbeddedDefaults(schema, t.objectName, parentChildOverride, nestedVariant);
if (Object.keys(nested).length > 0) {
defaults[propName] = nested;
}
}
}
if (filtersStatic && Object.keys(filtersStatic).length > 0) {
const validKeys = new Set<string>(['@type']);
if (activeFields) {
for (const k of Object.keys(activeFields.properties)) validKeys.add(k);
}
if (resolvedSchema.type === 'multiple') {
const parentSchemaName =
parentSchemaEntry?.type === 'single' ? parentSchemaEntry.schemaName : resolvedObject.objectName;
const parentProps = schema.fields[parentSchemaName]?.properties;
if (parentProps) {
for (const k of Object.keys(parentProps)) validKeys.add(k);
}
}
const filtered: Record<string, unknown> = {};
for (const [k, v] of Object.entries(filtersStatic)) {
if (validKeys.has(k)) filtered[k] = v;
}
if (Object.keys(filtered).length > 0) {
defaults = deepMerge(defaults, filtered);
}
}
return defaults;
}
export function buildEmbeddedDefaults(
schema: Schema,
objectName: string,
parentOverrides: Record<string, unknown> = {},
variantName?: string,
): Record<string, unknown> {
const schemaEntry = schema.schemas[objectName];
if (!schemaEntry) return { ...parentOverrides };
let fields: Fields | null = null;
let result: Record<string, unknown> = {};
if (schemaEntry.type === 'single') {
fields = schema.fields[schemaEntry.schemaName] ?? null;
} else {
const variant = variantName ? schemaEntry.variants.find((v) => v.name === variantName) : schemaEntry.variants[0];
if (!variant) return { '@type': variantName ?? '', ...parentOverrides };
result['@type'] = variant.name;
if (variant.schemaName) {
fields = schema.fields[variant.schemaName] ?? null;
}
}
if (fields) {
if (fields.defaults) {
result = deepMerge(result, fields.defaults);
}
for (const [propName, propDef] of Object.entries(fields.properties)) {
const t = propDef.type;
if (t.type !== 'object') continue;
const parentChildOverride =
result[propName] && typeof result[propName] === 'object' && !Array.isArray(result[propName])
? (result[propName] as Record<string, unknown>)
: {};
const nestedSchemaEntry = schema.schemas[t.objectName];
let nestedVariant: string | undefined;
if (nestedSchemaEntry?.type === 'multiple') {
nestedVariant = (parentChildOverride['@type'] as string | undefined) ?? nestedSchemaEntry.variants[0]?.name;
}
const nestedDefaults = buildEmbeddedDefaults(schema, t.objectName, parentChildOverride, nestedVariant);
if (Object.keys(nestedDefaults).length > 0) {
result[propName] = nestedDefaults;
}
}
}
if (Object.keys(parentOverrides).length > 0) {
result = deepMerge(result, parentOverrides);
}
return result;
}
export function getDisplayProperty(schema: Schema, objectName: string): string {
const list = schema.lists[objectName];
if (list?.labelProperty) return list.labelProperty;
if (list?.columns?.[0]) return list.columns[0].name;
return 'name';
}
+12
View File
@@ -0,0 +1,12 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}