Initial commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(' ');
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user