@byline/core 4.16.0 → 4.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/@types/admin-types.d.ts +86 -31
- package/dist/@types/admin-types.js +12 -0
- package/dist/@types/collection-types.d.ts +211 -29
- package/dist/@types/collection-types.js +68 -8
- package/dist/@types/db-types.d.ts +50 -0
- package/dist/@types/form-admin-config.test.node.d.ts +6 -0
- package/dist/@types/form-admin-config.test.node.js +26 -0
- package/dist/@types/search-types.d.ts +12 -0
- package/dist/@types/singleton-admin-config.test.node.js +66 -0
- package/dist/@types/singleton-definition.test.node.d.ts +8 -0
- package/dist/@types/singleton-definition.test.node.js +75 -0
- package/dist/@types/site-config.d.ts +9 -8
- package/dist/auth/apply-before-read.d.ts +2 -1
- package/dist/auth/apply-before-read.js +13 -3
- package/dist/auth/apply-before-read.test.node.js +44 -0
- package/dist/auth/assert-actor-can-perform.d.ts +4 -3
- package/dist/auth/assert-actor-can-perform.js +9 -7
- package/dist/auth/assert-actor-can-perform.test.node.js +32 -11
- package/dist/auth/filter-readable-collections.d.ts +6 -6
- package/dist/auth/filter-readable-collections.js +8 -8
- package/dist/auth/filter-readable-collections.test.node.js +26 -1
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.js +1 -1
- package/dist/auth/register-collection-abilities.d.ts +34 -30
- package/dist/auth/register-collection-abilities.js +81 -77
- package/dist/auth/register-collection-abilities.test.node.js +86 -8
- package/dist/codegen/fixtures/all-fields.expected.d.ts +3 -0
- package/dist/codegen/index.d.ts +1 -1
- package/dist/codegen/index.js +32 -16
- package/dist/codegen/index.test.node.js +35 -2
- package/dist/config/attach-hooks.d.ts +3 -3
- package/dist/config/attach-hooks.js +8 -0
- package/dist/config/attach-hooks.test.node.js +59 -0
- package/dist/config/config-hooks.test.node.js +34 -1
- package/dist/config/config.d.ts +2 -1
- package/dist/config/config.js +9 -1
- package/dist/config/group-collections.d.ts +10 -9
- package/dist/config/group-collections.js +4 -4
- package/dist/config/group-collections.test.node.js +14 -0
- package/dist/config/validate-admin-configs.d.ts +4 -4
- package/dist/config/validate-admin-configs.js +40 -7
- package/dist/config/validate-admin-configs.test.node.js +44 -0
- package/dist/config/validate-collections.js +43 -0
- package/dist/config/validate-collections.test.node.js +75 -0
- package/dist/core.d.ts +2 -3
- package/dist/core.js +3 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/lib/errors.d.ts +5 -3
- package/dist/lib/errors.js +1 -0
- package/dist/services/collection-bootstrap.js +12 -0
- package/dist/services/collection-bootstrap.test.node.js +63 -6
- package/dist/services/discover-counter-groups.test.node.js +6 -0
- package/dist/services/document-lifecycle/copy-to-locale.js +9 -17
- package/dist/services/document-lifecycle/create.js +6 -11
- package/dist/services/document-lifecycle/delete-locale.js +1 -1
- package/dist/services/document-lifecycle/delete.js +1 -1
- package/dist/services/document-lifecycle/duplicate.js +7 -18
- package/dist/services/document-lifecycle/persistence.d.ts +29 -0
- package/dist/services/document-lifecycle/persistence.js +42 -0
- package/dist/services/document-lifecycle/restore.js +9 -17
- package/dist/services/document-lifecycle/scheduled-publish.js +5 -5
- package/dist/services/document-lifecycle/status.js +3 -3
- package/dist/services/document-lifecycle/system-fields.js +1 -1
- package/dist/services/document-lifecycle/tree.js +2 -2
- package/dist/services/document-lifecycle/tree.test.node.js +6 -1
- package/dist/services/document-lifecycle/update.js +20 -36
- package/dist/services/document-lifecycle.test.node.js +35 -0
- package/dist/services/field-upload.d.ts +1 -1
- package/dist/services/field-upload.js +20 -6
- package/dist/services/field-upload.test.node.js +125 -9
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/services/populate.js +1 -1
- package/dist/services/populate.test.node.js +9 -0
- package/dist/services/richtext-populate.js +1 -1
- package/dist/services/singleton-lifecycle/copy-to-locale.d.ts +16 -0
- package/dist/services/singleton-lifecycle/copy-to-locale.js +100 -0
- package/dist/services/singleton-lifecycle/index.d.ts +14 -0
- package/dist/services/singleton-lifecycle/index.js +11 -0
- package/dist/services/singleton-lifecycle/internals.d.ts +49 -0
- package/dist/services/singleton-lifecycle/internals.js +118 -0
- package/dist/services/singleton-lifecycle/restore.d.ts +13 -0
- package/dist/services/singleton-lifecycle/restore.js +93 -0
- package/dist/services/singleton-lifecycle/update.d.ts +16 -0
- package/dist/services/singleton-lifecycle/update.js +107 -0
- package/dist/services/singleton-lifecycle.test.node.d.ts +8 -0
- package/dist/services/singleton-lifecycle.test.node.js +396 -0
- package/dist/storage/collection-fingerprint.js +18 -6
- package/dist/storage/collection-fingerprint.test.node.js +15 -0
- package/package.json +5 -5
- package/dist/config/resolve-item-view-columns.test.node.js +0 -30
- /package/dist/{config/resolve-item-view-columns.test.node.d.ts → @types/singleton-admin-config.test.node.d.ts} +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import type { CollectionDefinition, CollectionHooks, CollectionHooksLoader, FileField, ImageField, ServerHooksConfig, UploadHooks, UploadHooksLoader } from '../@types/index.js';
|
|
2
|
-
type
|
|
1
|
+
import type { CollectionDefinition, CollectionHooks, CollectionHooksLoader, FileField, ImageField, ServerHooksConfig, SingletonHooks, SingletonHooksLoader, UploadHooks, UploadHooksLoader } from '../@types/index.js';
|
|
2
|
+
type DocumentHookValue = CollectionHooks | CollectionHooksLoader | SingletonHooks | SingletonHooksLoader;
|
|
3
3
|
type UploadHookValue = UploadHooks | UploadHooksLoader;
|
|
4
4
|
type UploadField = ImageField | FileField;
|
|
5
5
|
export type HookAttachmentOperation = {
|
|
6
6
|
kind: 'collection';
|
|
7
7
|
target: CollectionDefinition;
|
|
8
|
-
hooks:
|
|
8
|
+
hooks: DocumentHookValue | undefined;
|
|
9
9
|
} | {
|
|
10
10
|
kind: 'upload';
|
|
11
11
|
target: UploadField;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertHooksMatchDefinition } from '../@types/collection-types.js';
|
|
1
2
|
import { formatDeclarationPath, walkFieldDeclarations } from '../paths/index.js';
|
|
2
3
|
const registryCollectionHooks = new WeakMap();
|
|
3
4
|
const registryUploadHooks = new WeakMap();
|
|
@@ -61,6 +62,8 @@ export function commitHookAttachment(operations) {
|
|
|
61
62
|
}
|
|
62
63
|
function planCollectionAttachment(collection, requested, operations) {
|
|
63
64
|
const owned = registryCollectionHooks.get(collection);
|
|
65
|
+
assertInlineHookFamily(collection, collection.hooks);
|
|
66
|
+
assertInlineHookFamily(collection, requested);
|
|
64
67
|
assertOwnershipIntact(`collection "${collection.path}"`, collection.hooks, owned);
|
|
65
68
|
if (requested === undefined) {
|
|
66
69
|
if (owned !== undefined) {
|
|
@@ -75,6 +78,11 @@ function planCollectionAttachment(collection, requested, operations) {
|
|
|
75
78
|
operations.push({ kind: 'collection', target: collection, hooks: requested });
|
|
76
79
|
}
|
|
77
80
|
}
|
|
81
|
+
function assertInlineHookFamily(definition, hooks) {
|
|
82
|
+
if (hooks !== undefined && typeof hooks !== 'function') {
|
|
83
|
+
assertHooksMatchDefinition(definition, hooks);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
78
86
|
function planUploadAttachment(path, field, requested, operations) {
|
|
79
87
|
const owned = registryUploadHooks.get(field);
|
|
80
88
|
assertOwnershipIntact(`upload field "${path}"`, field.upload?.hooks, owned);
|
|
@@ -9,6 +9,14 @@ function collection(path = 'documents') {
|
|
|
9
9
|
fields: [{ name: 'title', type: 'text' }],
|
|
10
10
|
};
|
|
11
11
|
}
|
|
12
|
+
function singleton(path = 'site-settings') {
|
|
13
|
+
return {
|
|
14
|
+
singleton: true,
|
|
15
|
+
path,
|
|
16
|
+
label: 'Site settings',
|
|
17
|
+
fields: [{ name: 'title', type: 'text' }],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
12
20
|
function nestedUploadCollection() {
|
|
13
21
|
const nested = { name: 'asset', type: 'file', upload: {} };
|
|
14
22
|
const arrayFile = { name: 'publicationFile', type: 'file', upload: {} };
|
|
@@ -37,6 +45,57 @@ function nestedUploadCollection() {
|
|
|
37
45
|
};
|
|
38
46
|
}
|
|
39
47
|
describe('server hook attachment', () => {
|
|
48
|
+
it('rejects inline hooks from the wrong resource family at startup', () => {
|
|
49
|
+
const singletonDefinition = singleton();
|
|
50
|
+
singletonDefinition.hooks = { beforeCreate: () => { } };
|
|
51
|
+
expect(() => prepareHookAttachment({ collections: [singletonDefinition] })).toThrow(/singleton.*beforeCreate/i);
|
|
52
|
+
const collectionDefinition = collection();
|
|
53
|
+
const singletonHooks = { beforeSave: () => { } };
|
|
54
|
+
expect(() => prepareHookAttachment({
|
|
55
|
+
collections: [collectionDefinition],
|
|
56
|
+
hooks: { collections: { documents: singletonHooks } },
|
|
57
|
+
})).toThrow(/collection.*beforeSave/i);
|
|
58
|
+
});
|
|
59
|
+
it('accepts well-formed inline singleton hooks and resolves them unchanged', async () => {
|
|
60
|
+
const definition = singleton();
|
|
61
|
+
const hooks = {
|
|
62
|
+
beforeSave: vi.fn((_context) => { }),
|
|
63
|
+
};
|
|
64
|
+
definition.hooks = hooks;
|
|
65
|
+
expect(prepareHookAttachment({ collections: [definition] })).toEqual([]);
|
|
66
|
+
expect(await resolveHooks(definition)).toBe(hooks);
|
|
67
|
+
});
|
|
68
|
+
it('defers loader-family validation until first resolution', async () => {
|
|
69
|
+
const definition = singleton();
|
|
70
|
+
const loader = vi.fn(() => Promise.resolve({ beforeCreate: () => { } }));
|
|
71
|
+
definition.hooks = loader;
|
|
72
|
+
expect(() => prepareHookAttachment({ collections: [definition] })).not.toThrow();
|
|
73
|
+
await expect(resolveHooks(definition)).rejects.toThrow(/singleton.*beforeCreate/i);
|
|
74
|
+
});
|
|
75
|
+
it('resolves and caches a well-formed singleton hook loader', async () => {
|
|
76
|
+
const definition = singleton();
|
|
77
|
+
const hooks = {
|
|
78
|
+
beforeSave: vi.fn((_context) => { }),
|
|
79
|
+
};
|
|
80
|
+
const loader = vi.fn(() => Promise.resolve(hooks));
|
|
81
|
+
definition.hooks = loader;
|
|
82
|
+
expect(await resolveHooks(definition)).toBe(hooks);
|
|
83
|
+
expect(await resolveHooks(definition)).toBe(hooks);
|
|
84
|
+
expect(loader).toHaveBeenCalledOnce();
|
|
85
|
+
});
|
|
86
|
+
it('validates a reused loader against the current definition on every resolution', async () => {
|
|
87
|
+
const hooks = {
|
|
88
|
+
beforeSave: vi.fn((_context) => { }),
|
|
89
|
+
};
|
|
90
|
+
const loader = vi.fn(() => Promise.resolve(hooks));
|
|
91
|
+
const singletonDefinition = singleton();
|
|
92
|
+
singletonDefinition.hooks = loader;
|
|
93
|
+
const collectionDefinition = collection();
|
|
94
|
+
collectionDefinition.hooks = loader;
|
|
95
|
+
expect(await resolveHooks(singletonDefinition)).toBe(hooks);
|
|
96
|
+
await expect(resolveHooks(collectionDefinition)).rejects.toThrow(/collection.*beforeSave/i);
|
|
97
|
+
expect(loader).toHaveBeenCalledOnce();
|
|
98
|
+
});
|
|
40
99
|
it('prepares without mutation, then attaches collection and canonical nested upload hooks', async () => {
|
|
41
100
|
const { definition, nested, arrayFile, blockFile } = nestedUploadCollection();
|
|
42
101
|
const collectionHooks = {};
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import { defineAdmin, defineSingletonAdmin } from '../@types/admin-types.js';
|
|
3
|
+
import { defineCollection, defineSingleton } from '../@types/collection-types.js';
|
|
4
|
+
import { defineAdminConfig, defineServerConfig, getCollectionAdminConfig, getCollectionDefinition, getSingletonAdminConfig, } from './config.js';
|
|
3
5
|
const SERVER_CONFIG = Symbol.for('__byline_server_config__');
|
|
6
|
+
const ADMIN_CONFIG = Symbol.for('__byline_admin_config__');
|
|
4
7
|
const CLIENT_CONFIG = Symbol.for('__byline_client_config__');
|
|
5
8
|
const previousServer = globalThis[SERVER_CONFIG];
|
|
9
|
+
const previousAdmin = globalThis[ADMIN_CONFIG];
|
|
6
10
|
const previousClient = globalThis[CLIENT_CONFIG];
|
|
7
11
|
afterEach(() => {
|
|
8
12
|
const globals = globalThis;
|
|
@@ -10,6 +14,10 @@ afterEach(() => {
|
|
|
10
14
|
delete globals[SERVER_CONFIG];
|
|
11
15
|
else
|
|
12
16
|
globals[SERVER_CONFIG] = previousServer;
|
|
17
|
+
if (previousAdmin === undefined)
|
|
18
|
+
delete globals[ADMIN_CONFIG];
|
|
19
|
+
else
|
|
20
|
+
globals[ADMIN_CONFIG] = previousAdmin;
|
|
13
21
|
if (previousClient === undefined)
|
|
14
22
|
delete globals[CLIENT_CONFIG];
|
|
15
23
|
else
|
|
@@ -53,3 +61,28 @@ describe('collection definition config preference', () => {
|
|
|
53
61
|
expect(getCollectionDefinition('docs')?.hooks).toBe(hooks);
|
|
54
62
|
});
|
|
55
63
|
});
|
|
64
|
+
describe('admin resource config lookup', () => {
|
|
65
|
+
it('returns only the admin config matching the requested resource kind', () => {
|
|
66
|
+
const pages = defineCollection({
|
|
67
|
+
path: 'pages',
|
|
68
|
+
labels: { singular: 'Page', plural: 'Pages' },
|
|
69
|
+
fields: [{ name: 'title', label: 'Title', type: 'text' }],
|
|
70
|
+
});
|
|
71
|
+
const settings = defineSingleton({
|
|
72
|
+
path: 'site-settings',
|
|
73
|
+
label: 'Site settings',
|
|
74
|
+
fields: [{ name: 'title', label: 'Title', type: 'text' }],
|
|
75
|
+
});
|
|
76
|
+
const pagesAdmin = defineAdmin(pages, {});
|
|
77
|
+
const settingsAdmin = defineSingletonAdmin(settings, {});
|
|
78
|
+
defineAdminConfig({
|
|
79
|
+
collections: [pages, settings],
|
|
80
|
+
admin: [pagesAdmin, settingsAdmin],
|
|
81
|
+
i18n,
|
|
82
|
+
});
|
|
83
|
+
expect(getCollectionAdminConfig(pages.path)).toBe(pagesAdmin);
|
|
84
|
+
expect(getCollectionAdminConfig(settings.path)).toBeNull();
|
|
85
|
+
expect(getSingletonAdminConfig(settings.path)).toBe(settingsAdmin);
|
|
86
|
+
expect(getSingletonAdminConfig(pages.path)).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
});
|
package/dist/config/config.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AdminConfig, CollectionAdminConfig, CollectionDefinition, ResolvedAdminConfig, ResolvedServerConfig, ServerConfig } from '../@types/index.js';
|
|
1
|
+
import type { AdminConfig, CollectionAdminConfig, CollectionDefinition, ResolvedAdminConfig, ResolvedServerConfig, ServerConfig, SingletonAdminConfig } from '../@types/index.js';
|
|
2
2
|
/**
|
|
3
3
|
* Resolve a collection definition by `path`. Returns `null` either when
|
|
4
4
|
* no config has been registered (e.g. unit tests, isolated tooling) or
|
|
@@ -12,6 +12,7 @@ import type { AdminConfig, CollectionAdminConfig, CollectionDefinition, Resolved
|
|
|
12
12
|
*/
|
|
13
13
|
export declare const getCollectionDefinition: (path: string) => CollectionDefinition | null;
|
|
14
14
|
export declare const getCollectionAdminConfig: (slug: string) => CollectionAdminConfig | null;
|
|
15
|
+
export declare const getSingletonAdminConfig: (slug: string) => SingletonAdminConfig | null;
|
|
15
16
|
export declare function defineAdminConfig(config: AdminConfig): ResolvedAdminConfig;
|
|
16
17
|
export declare function defineServerConfig<TAdminStore = unknown>(config: ServerConfig<TAdminStore>): ResolvedServerConfig<TAdminStore>;
|
|
17
18
|
/** Resolve and validate server config without replacing the registered singleton. */
|
package/dist/config/config.js
CHANGED
|
@@ -51,12 +51,20 @@ export const getCollectionDefinition = (path) => {
|
|
|
51
51
|
return null;
|
|
52
52
|
return config.collections.find((collection) => collection.path === path) ?? null;
|
|
53
53
|
};
|
|
54
|
-
|
|
54
|
+
const getAdminResourceConfig = (slug) => {
|
|
55
55
|
const adminConfig = getAdminConfigInstance();
|
|
56
56
|
if (adminConfig == null)
|
|
57
57
|
return null;
|
|
58
58
|
return adminConfig.admin?.find((admin) => admin.slug === slug) ?? null;
|
|
59
59
|
};
|
|
60
|
+
export const getCollectionAdminConfig = (slug) => {
|
|
61
|
+
const admin = getAdminResourceConfig(slug);
|
|
62
|
+
return admin?.singleton === true ? null : admin;
|
|
63
|
+
};
|
|
64
|
+
export const getSingletonAdminConfig = (slug) => {
|
|
65
|
+
const admin = getAdminResourceConfig(slug);
|
|
66
|
+
return admin?.singleton === true ? admin : null;
|
|
67
|
+
};
|
|
60
68
|
export function defineAdminConfig(config) {
|
|
61
69
|
validateCollections(config.collections);
|
|
62
70
|
validateAdminConfigs(config.admin, config.collections, config.collectionGroups);
|
|
@@ -5,30 +5,31 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
import type {
|
|
8
|
+
import type { AdminResourceConfig, CollectionGroupDefinition } from '../@types/admin-types.js';
|
|
9
9
|
import type { CollectionDefinition } from '../@types/collection-types.js';
|
|
10
10
|
/**
|
|
11
11
|
* One renderable section of the admin dashboard: a heading (or none) and the
|
|
12
|
-
*
|
|
12
|
+
* document resources beneath it. The public `collections` member name remains
|
|
13
|
+
* for backwards compatibility and may contain both resource kinds.
|
|
13
14
|
*/
|
|
14
|
-
export interface CollectionGroupBucket {
|
|
15
|
+
export interface CollectionGroupBucket<TDefinition extends CollectionDefinition = CollectionDefinition> {
|
|
15
16
|
/** Registry key, or `null` for the leading ungrouped band. */
|
|
16
17
|
name: string | null;
|
|
17
18
|
/** Heading text, or `null` when the band renders without a heading. */
|
|
18
19
|
label: string | null;
|
|
19
|
-
collections:
|
|
20
|
+
collections: TDefinition[];
|
|
20
21
|
}
|
|
21
22
|
/**
|
|
22
|
-
* Bucket collections into ordered dashboard sections.
|
|
23
|
+
* Bucket collections and singletons into ordered dashboard sections.
|
|
23
24
|
*
|
|
24
25
|
* Rules:
|
|
25
26
|
* - The ungrouped band is emitted first, and omitted entirely when empty.
|
|
26
27
|
* - Declared groups follow in `collectionGroups` order.
|
|
27
28
|
* - A declared group with no members is skipped, so no heading ever appears
|
|
28
29
|
* above an empty section.
|
|
29
|
-
* -
|
|
30
|
+
* - Resource declaration order is preserved within each bucket.
|
|
30
31
|
* - An absent or empty registry yields a single ungrouped bucket holding every
|
|
31
|
-
*
|
|
32
|
+
* resource — the flat grid Byline rendered before groups existed.
|
|
32
33
|
*
|
|
33
34
|
* This function is deliberately total: a `group` naming no declared entry is
|
|
34
35
|
* treated as ungrouped rather than throwing. `validateCollectionGroups` rejects
|
|
@@ -37,9 +38,9 @@ export interface CollectionGroupBucket {
|
|
|
37
38
|
* outcome.
|
|
38
39
|
*
|
|
39
40
|
* It takes no actor and knows nothing about abilities. Callers that need to
|
|
40
|
-
* hide
|
|
41
|
+
* hide resources filter the `collections` argument first — see
|
|
41
42
|
* `filterReadableCollections`. That ordering is what makes a group whose
|
|
42
43
|
* members are all hidden disappear along with its heading: it arrives here with
|
|
43
44
|
* no members and is skipped by the rule above.
|
|
44
45
|
*/
|
|
45
|
-
export declare function groupCollectionsForAdmin(collections: readonly
|
|
46
|
+
export declare function groupCollectionsForAdmin<TDefinition extends CollectionDefinition>(collections: readonly TDefinition[], admin: readonly AdminResourceConfig[] | undefined, collectionGroups: readonly CollectionGroupDefinition[] | undefined): CollectionGroupBucket<TDefinition>[];
|
|
@@ -6,16 +6,16 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
/**
|
|
9
|
-
* Bucket collections into ordered dashboard sections.
|
|
9
|
+
* Bucket collections and singletons into ordered dashboard sections.
|
|
10
10
|
*
|
|
11
11
|
* Rules:
|
|
12
12
|
* - The ungrouped band is emitted first, and omitted entirely when empty.
|
|
13
13
|
* - Declared groups follow in `collectionGroups` order.
|
|
14
14
|
* - A declared group with no members is skipped, so no heading ever appears
|
|
15
15
|
* above an empty section.
|
|
16
|
-
* -
|
|
16
|
+
* - Resource declaration order is preserved within each bucket.
|
|
17
17
|
* - An absent or empty registry yields a single ungrouped bucket holding every
|
|
18
|
-
*
|
|
18
|
+
* resource — the flat grid Byline rendered before groups existed.
|
|
19
19
|
*
|
|
20
20
|
* This function is deliberately total: a `group` naming no declared entry is
|
|
21
21
|
* treated as ungrouped rather than throwing. `validateCollectionGroups` rejects
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* outcome.
|
|
25
25
|
*
|
|
26
26
|
* It takes no actor and knows nothing about abilities. Callers that need to
|
|
27
|
-
* hide
|
|
27
|
+
* hide resources filter the `collections` argument first — see
|
|
28
28
|
* `filterReadableCollections`. That ordering is what makes a group whose
|
|
29
29
|
* members are all hidden disappear along with its heading: it arrives here with
|
|
30
30
|
* no members and is skipped by the rule above.
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
import { describe, expect, it } from 'vitest';
|
|
9
|
+
import { defineSingleton, } from '../@types/index.js';
|
|
9
10
|
import { groupCollectionsForAdmin } from './group-collections.js';
|
|
10
11
|
const define = (path) => ({
|
|
11
12
|
path,
|
|
@@ -17,6 +18,11 @@ const news = define('news');
|
|
|
17
18
|
const images = define('images');
|
|
18
19
|
const authors = define('authors');
|
|
19
20
|
const categories = define('categories');
|
|
21
|
+
const settings = defineSingleton({
|
|
22
|
+
path: 'site-settings',
|
|
23
|
+
label: 'Site settings',
|
|
24
|
+
fields: [{ name: 'title', label: 'Title', type: 'text' }],
|
|
25
|
+
});
|
|
20
26
|
const groups = [
|
|
21
27
|
{ name: 'media', label: 'Media' },
|
|
22
28
|
{ name: 'authorities', label: 'People & Organisations' },
|
|
@@ -64,6 +70,14 @@ describe('groupCollectionsForAdmin', () => {
|
|
|
64
70
|
const result = groupCollectionsForAdmin([more, images], [...admin, { slug: 'videos', group: 'media' }], groups);
|
|
65
71
|
expect(result[0]?.collections).toEqual([more, images]);
|
|
66
72
|
});
|
|
73
|
+
it('groups singleton and multi-collection resources together in declaration order', () => {
|
|
74
|
+
const resourceAdmin = [
|
|
75
|
+
...admin,
|
|
76
|
+
{ singleton: true, slug: settings.path, group: 'media' },
|
|
77
|
+
];
|
|
78
|
+
const result = groupCollectionsForAdmin([images, settings], resourceAdmin, groups);
|
|
79
|
+
expect(result[0]?.collections).toEqual([images, settings]);
|
|
80
|
+
});
|
|
67
81
|
it('places a collection with no admin config in the ungrouped band', () => {
|
|
68
82
|
const result = groupCollectionsForAdmin([pages, images], admin, groups);
|
|
69
83
|
expect(result[0]).toEqual({ name: null, label: null, collections: [pages] });
|
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
import type {
|
|
8
|
+
import type { AdminResourceConfig, BlockAdminConfig, CollectionDefinition, CollectionGroupDefinition } from '../@types/index.js';
|
|
9
9
|
/**
|
|
10
10
|
* Validate the dashboard collection-group registry and every reference to it.
|
|
11
11
|
*
|
|
12
12
|
* Enforced rules:
|
|
13
13
|
* 1. Each `collectionGroups` entry has a non-blank `name` and `label`.
|
|
14
14
|
* 2. No two entries share a `name`.
|
|
15
|
-
* 3. Every `
|
|
15
|
+
* 3. Every admin resource's `group` names a declared entry. This single
|
|
16
16
|
* rule covers both a typographical error and the case where `group` was set
|
|
17
17
|
* but the registry was never declared.
|
|
18
18
|
*
|
|
@@ -20,7 +20,7 @@ import type { BlockAdminConfig, CollectionAdminConfig, CollectionDefinition, Col
|
|
|
20
20
|
* configuration validation runs at startup, before the logger and error
|
|
21
21
|
* registry are necessarily wired up.
|
|
22
22
|
*/
|
|
23
|
-
export declare function validateCollectionGroups(collectionGroups: readonly CollectionGroupDefinition[] | undefined, admins: readonly
|
|
23
|
+
export declare function validateCollectionGroups(collectionGroups: readonly CollectionGroupDefinition[] | undefined, admins: readonly AdminResourceConfig[] | undefined): void;
|
|
24
24
|
/**
|
|
25
25
|
* Validate every admin config in a configuration.
|
|
26
26
|
*
|
|
@@ -53,7 +53,7 @@ export declare function validateCollectionGroups(collectionGroups: readonly Coll
|
|
|
53
53
|
* validation runs at startup, before the logger and error registry are
|
|
54
54
|
* necessarily wired up.
|
|
55
55
|
*/
|
|
56
|
-
export declare function validateAdminConfigs(admins: readonly
|
|
56
|
+
export declare function validateAdminConfigs(admins: readonly AdminResourceConfig[] | undefined, collections: readonly CollectionDefinition[], collectionGroups?: readonly CollectionGroupDefinition[]): void;
|
|
57
57
|
/**
|
|
58
58
|
* Validate every block admin config in a configuration.
|
|
59
59
|
*
|
|
@@ -5,7 +5,21 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
+
import { isSingleton } from '../@types/collection-types.js';
|
|
8
9
|
import { resolveDeclarationPath, walkFieldDeclarations, } from '../paths/index.js';
|
|
10
|
+
function completeCollectionOnlyAdminKeys(keys) {
|
|
11
|
+
return keys;
|
|
12
|
+
}
|
|
13
|
+
/** Runtime counterpart to `SingletonAdminConfig`'s collection-only `never` members. */
|
|
14
|
+
const COLLECTION_ONLY_ADMIN_KEYS = completeCollectionOnlyAdminKeys([
|
|
15
|
+
'columns',
|
|
16
|
+
'defaultSort',
|
|
17
|
+
'defaultColumns',
|
|
18
|
+
'itemView',
|
|
19
|
+
'itemViewSort',
|
|
20
|
+
'listView',
|
|
21
|
+
'listActions',
|
|
22
|
+
]);
|
|
9
23
|
/**
|
|
10
24
|
* Resolve a `fields{}` override key against a field set.
|
|
11
25
|
*
|
|
@@ -44,7 +58,7 @@ function validateFieldAdminKeys(keys, resolve, fail) {
|
|
|
44
58
|
* Enforced rules:
|
|
45
59
|
* 1. Each `collectionGroups` entry has a non-blank `name` and `label`.
|
|
46
60
|
* 2. No two entries share a `name`.
|
|
47
|
-
* 3. Every `
|
|
61
|
+
* 3. Every admin resource's `group` names a declared entry. This single
|
|
48
62
|
* rule covers both a typographical error and the case where `group` was set
|
|
49
63
|
* but the registry was never declared.
|
|
50
64
|
*
|
|
@@ -58,7 +72,7 @@ export function validateCollectionGroups(collectionGroups, admins) {
|
|
|
58
72
|
const name = typeof group.name === 'string' ? group.name.trim() : '';
|
|
59
73
|
const label = typeof group.label === 'string' ? group.label.trim() : '';
|
|
60
74
|
if (name === '') {
|
|
61
|
-
throw new Error('A `collectionGroups` entry has a blank `name`. Each entry needs a non-empty key for `
|
|
75
|
+
throw new Error('A `collectionGroups` entry has a blank `name`. Each entry needs a non-empty key for an admin resource `group` to reference.');
|
|
62
76
|
}
|
|
63
77
|
if (label === '') {
|
|
64
78
|
throw new Error(`Collection group "${name}" has a blank \`label\`. The label is the heading rendered above the group on the dashboard.`);
|
|
@@ -76,7 +90,7 @@ export function validateCollectionGroups(collectionGroups, admins) {
|
|
|
76
90
|
const known = declared.size === 0
|
|
77
91
|
? '`collectionGroups` was not declared, or is empty'
|
|
78
92
|
: `declared groups: ${[...declared].map((name) => `"${name}"`).join(', ')}`;
|
|
79
|
-
throw new Error(`
|
|
93
|
+
throw new Error(`Admin resource "${admin.slug}": \`group: '${admin.group}'\` does not name a declared collection group (${known}). Add it to \`AdminConfig.collectionGroups\`, or remove the \`group\` property.`);
|
|
80
94
|
}
|
|
81
95
|
}
|
|
82
96
|
/**
|
|
@@ -198,12 +212,29 @@ function validateOne(admin, collectionsByPath) {
|
|
|
198
212
|
// Rule 1 — slug pairing.
|
|
199
213
|
const collection = collectionsByPath.get(admin.slug);
|
|
200
214
|
if (collection == null) {
|
|
215
|
+
if (admin.singleton === true) {
|
|
216
|
+
throw new Error(`Singleton admin config "${admin.slug}" has no matching singleton definition (no singleton with \`path: '${admin.slug}'\` was registered).`);
|
|
217
|
+
}
|
|
201
218
|
throw new Error(`Admin config "${admin.slug}" has no matching collection (no collection with \`path: '${admin.slug}'\` was registered).`);
|
|
202
219
|
}
|
|
220
|
+
if (admin.singleton === true && !isSingleton(collection)) {
|
|
221
|
+
throw new Error(`Singleton admin config "${admin.slug}" targets a collection definition. Register it with \`defineAdmin\`, or point it at a singleton definition.`);
|
|
222
|
+
}
|
|
223
|
+
if (admin.singleton !== true && isSingleton(collection)) {
|
|
224
|
+
throw new Error(`Collection admin config "${admin.slug}" targets a singleton definition. Register it with \`defineSingletonAdmin\`, or point it at a multi-collection definition.`);
|
|
225
|
+
}
|
|
203
226
|
const slug = admin.slug;
|
|
204
227
|
const fail = (msg) => {
|
|
205
|
-
|
|
228
|
+
const resource = admin.singleton === true ? 'Singleton' : 'Collection';
|
|
229
|
+
throw new Error(`${resource} "${slug}": ${msg}`);
|
|
206
230
|
};
|
|
231
|
+
if (admin.singleton === true) {
|
|
232
|
+
for (const key of COLLECTION_ONLY_ADMIN_KEYS) {
|
|
233
|
+
if (Object.hasOwn(admin, key)) {
|
|
234
|
+
fail(`\`${key}\` is not allowed on a singleton admin config.`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
207
238
|
// Top-level schema field names — the population subject to placement
|
|
208
239
|
// bookkeeping. Fields nested inside group/array/blocks are rendered by
|
|
209
240
|
// their parent widget and are not addressable from layout.
|
|
@@ -221,7 +252,7 @@ function validateOne(admin, collectionsByPath) {
|
|
|
221
252
|
return;
|
|
222
253
|
const { field, direction } = spec;
|
|
223
254
|
const documentColumns = new Set(['createdAt', 'updatedAt', 'path']);
|
|
224
|
-
if (collection.orderable === true) {
|
|
255
|
+
if (!isSingleton(collection) && collection.orderable === true) {
|
|
225
256
|
fail(`${optionName} is not allowed on an orderable collection — manual ordering owns the sort (order_key asc).`);
|
|
226
257
|
}
|
|
227
258
|
if (typeof field !== 'string' || field.length === 0) {
|
|
@@ -234,8 +265,10 @@ function validateOne(admin, collectionsByPath) {
|
|
|
234
265
|
fail(`${optionName}.direction must be 'asc' or 'desc' (got "${String(direction)}").`);
|
|
235
266
|
}
|
|
236
267
|
};
|
|
237
|
-
|
|
238
|
-
|
|
268
|
+
if (admin.singleton !== true) {
|
|
269
|
+
validateSortSpec(admin.defaultSort, 'defaultSort');
|
|
270
|
+
validateSortSpec(admin.itemViewSort, 'itemViewSort');
|
|
271
|
+
}
|
|
239
272
|
// Build primitive lookup tables.
|
|
240
273
|
const tabSets = admin.tabSets ?? [];
|
|
241
274
|
const rows = admin.rows ?? [];
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
import { describe, expect, it } from 'vitest';
|
|
9
|
+
import { defineSingleton, defineSingletonAdmin, } from '../@types/index.js';
|
|
9
10
|
import { validateAdminConfigs, validateBlockAdminConfigs } from './validate-admin-configs.js';
|
|
10
11
|
const collection = {
|
|
11
12
|
path: 'news',
|
|
@@ -33,6 +34,19 @@ const baseAdmin = {
|
|
|
33
34
|
rows: [{ name: 'titles', fields: ['text1', 'text2'] }],
|
|
34
35
|
layout: { main: ['main'] },
|
|
35
36
|
};
|
|
37
|
+
const singleton = defineSingleton({
|
|
38
|
+
path: 'site-settings',
|
|
39
|
+
label: 'Site settings',
|
|
40
|
+
fields: [
|
|
41
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
42
|
+
{
|
|
43
|
+
name: 'seo',
|
|
44
|
+
label: 'SEO',
|
|
45
|
+
type: 'group',
|
|
46
|
+
fields: [{ name: 'description', label: 'Description', type: 'textArea' }],
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
});
|
|
36
50
|
describe('validateAdminConfigs', () => {
|
|
37
51
|
it('accepts a valid admin config (baseline)', () => {
|
|
38
52
|
expect(() => validateAdminConfigs([baseAdmin], [collection])).not.toThrow();
|
|
@@ -40,6 +54,36 @@ describe('validateAdminConfigs', () => {
|
|
|
40
54
|
it('is a no-op when admins is undefined', () => {
|
|
41
55
|
expect(() => validateAdminConfigs(undefined, [collection])).not.toThrow();
|
|
42
56
|
});
|
|
57
|
+
it('accepts a singleton admin config and dotted field override path', () => {
|
|
58
|
+
const admin = defineSingletonAdmin(singleton, {
|
|
59
|
+
fields: { 'seo.description': {} },
|
|
60
|
+
layout: { main: ['title', 'seo'] },
|
|
61
|
+
});
|
|
62
|
+
expect(() => validateAdminConfigs([admin], [collection, singleton])).not.toThrow();
|
|
63
|
+
});
|
|
64
|
+
it('rejects an unknown singleton field override path at startup', () => {
|
|
65
|
+
const admin = defineSingletonAdmin(singleton, { fields: { 'seo.missing': {} } });
|
|
66
|
+
expect(() => validateAdminConfigs([admin], [collection, singleton])).toThrow(/does not resolve to a field declaration/);
|
|
67
|
+
});
|
|
68
|
+
it('rejects list-only singleton options from untyped callers', () => {
|
|
69
|
+
const admin = {
|
|
70
|
+
...defineSingletonAdmin(singleton, {}),
|
|
71
|
+
columns: [{ fieldName: 'title', label: 'Title' }],
|
|
72
|
+
};
|
|
73
|
+
expect(() => validateAdminConfigs([admin], [collection, singleton])).toThrow(/columns.*not allowed on a singleton admin config/);
|
|
74
|
+
});
|
|
75
|
+
it('rejects a singleton admin config targeting a multi-collection', () => {
|
|
76
|
+
const admin = { ...defineSingletonAdmin(singleton, {}), slug: collection.path };
|
|
77
|
+
expect(() => validateAdminConfigs([admin], [collection, singleton])).toThrow(/targets a collection definition/);
|
|
78
|
+
});
|
|
79
|
+
it('rejects a singleton admin config with no matching definition', () => {
|
|
80
|
+
const admin = { ...defineSingletonAdmin(singleton, {}), slug: 'missing-settings' };
|
|
81
|
+
expect(() => validateAdminConfigs([admin], [collection, singleton])).toThrow(/no matching singleton definition/);
|
|
82
|
+
});
|
|
83
|
+
it('rejects a multi-collection admin config targeting a singleton', () => {
|
|
84
|
+
const admin = { slug: singleton.path };
|
|
85
|
+
expect(() => validateAdminConfigs([admin], [collection, singleton])).toThrow(/targets a singleton definition/);
|
|
86
|
+
});
|
|
43
87
|
// Rule 1 — slug pairing.
|
|
44
88
|
it('rejects an admin config whose slug has no matching collection', () => {
|
|
45
89
|
const orphan = { slug: 'nonexistent', layout: { main: [] } };
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
+
import { isSingleton } from '../@types/collection-types.js';
|
|
8
9
|
import { formatDeclarationPath, walkFieldDeclarations } from '../paths/index.js';
|
|
9
10
|
import { fieldTypeToStore } from '../storage/field-store-map.js';
|
|
10
11
|
/**
|
|
@@ -48,6 +49,23 @@ const USE_AS_PATH_SOURCE_TYPES = new Set([
|
|
|
48
49
|
'integer',
|
|
49
50
|
'counter',
|
|
50
51
|
]);
|
|
52
|
+
function completeCollectionOnlyOptions(keys) {
|
|
53
|
+
return keys;
|
|
54
|
+
}
|
|
55
|
+
/** Runtime counterpart to the singleton definition's collection-only `never` members. */
|
|
56
|
+
const COLLECTION_ONLY_OPTIONS = completeCollectionOnlyOptions([
|
|
57
|
+
'labels',
|
|
58
|
+
'orderable',
|
|
59
|
+
'tree',
|
|
60
|
+
'useAsPath',
|
|
61
|
+
'useAsTitle',
|
|
62
|
+
'search',
|
|
63
|
+
'listSearch',
|
|
64
|
+
'advertiseLocales',
|
|
65
|
+
'showStats',
|
|
66
|
+
'linksInEditor',
|
|
67
|
+
'buildDocumentPath',
|
|
68
|
+
]);
|
|
51
69
|
/**
|
|
52
70
|
* True when any field in the tree (at any nesting depth) is `localized`.
|
|
53
71
|
* Used to gate `advertiseLocales` — advertising content locales is only
|
|
@@ -239,13 +257,38 @@ function validateVirtualFields(collection) {
|
|
|
239
257
|
* necessarily wired up.
|
|
240
258
|
*/
|
|
241
259
|
export function validateCollections(collections) {
|
|
260
|
+
const definitionsByPath = new Map();
|
|
261
|
+
for (const collection of collections) {
|
|
262
|
+
if (definitionsByPath.has(collection.path)) {
|
|
263
|
+
throw new Error(`Two definitions are registered on path "${collection.path}". Collections and singletons share one path namespace, so a collection and a singleton cannot both be called "${collection.path}".`);
|
|
264
|
+
}
|
|
265
|
+
definitionsByPath.set(collection.path, collection);
|
|
266
|
+
}
|
|
242
267
|
for (const collection of collections) {
|
|
268
|
+
if (isSingleton(collection)) {
|
|
269
|
+
for (const option of COLLECTION_ONLY_OPTIONS) {
|
|
270
|
+
if (collection[option] !== undefined) {
|
|
271
|
+
throw new Error(`Singleton "${collection.path}" sets \`${option}\`, which is a multi-document collection option. A singleton holds at most one document, so there is no list to sort, search, or paginate and no public slug. Remove \`${option}\`.`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (typeof collection.label !== 'string') {
|
|
275
|
+
throw new Error(`Singleton "${collection.path}" must declare a \`label\`. Singletons have no plural form, so they use \`label\` rather than \`labels\`.`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
243
278
|
walkFields(collection.fields, (field) => {
|
|
244
279
|
if ('name' in field && RESERVED_FIELD_NAMES.has(field.name)) {
|
|
245
280
|
const hint = RESERVED_FIELD_HINTS[field.name] ?? '';
|
|
246
281
|
throw new Error(`Collection "${collection.path}" declares a field named "${field.name}", which is a reserved system attribute.${hint ? ` ${hint}` : ''}`);
|
|
247
282
|
}
|
|
248
283
|
});
|
|
284
|
+
walkFieldsWithPath(collection.fields, (field, fieldPath) => {
|
|
285
|
+
if (field.type !== 'relation')
|
|
286
|
+
return;
|
|
287
|
+
const target = definitionsByPath.get(field.targetCollection);
|
|
288
|
+
if (target == null || !isSingleton(target))
|
|
289
|
+
return;
|
|
290
|
+
throw new Error(`Field "${fieldPath}" on "${collection.path}" targets "${target.path}", which is a singleton. A singleton holds at most one document and is not a relation target in this release — reference its values directly instead.`);
|
|
291
|
+
});
|
|
249
292
|
if (collection.useAsPath != null) {
|
|
250
293
|
const source = collection.fields.find((f) => 'name' in f && f.name === collection.useAsPath);
|
|
251
294
|
if (source == null) {
|