@byline/core 4.13.1 → 4.14.1
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 +34 -1
- package/dist/@types/site-config.d.ts +15 -1
- package/dist/auth/filter-readable-collections.d.ts +37 -0
- package/dist/auth/filter-readable-collections.js +32 -0
- package/dist/auth/filter-readable-collections.test.node.d.ts +8 -0
- package/dist/auth/filter-readable-collections.test.node.js +72 -0
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.js +1 -0
- package/dist/config/config.js +1 -1
- package/dist/config/group-collections.d.ts +45 -0
- package/dist/config/group-collections.js +63 -0
- package/dist/config/group-collections.test.node.d.ts +8 -0
- package/dist/config/group-collections.test.node.js +81 -0
- package/dist/config/validate-admin-configs.d.ts +20 -2
- package/dist/config/validate-admin-configs.js +48 -1
- package/dist/config/validate-admin-configs.test.node.js +43 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/package.json +2 -2
|
@@ -167,6 +167,31 @@ export interface FieldAdminConfig {
|
|
|
167
167
|
*/
|
|
168
168
|
editor?: RichTextEditorComponent;
|
|
169
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* One labelled group of collections on the admin dashboard.
|
|
172
|
+
*
|
|
173
|
+
* Declared in display order on `AdminConfig.collectionGroups` and referenced by
|
|
174
|
+
* `name` from `CollectionAdminConfig.group`. Array order is the order headings
|
|
175
|
+
* appear on the dashboard; a group with no member collections is not rendered
|
|
176
|
+
* at all, so no heading ever sits above an empty section.
|
|
177
|
+
*/
|
|
178
|
+
export interface CollectionGroupDefinition {
|
|
179
|
+
/**
|
|
180
|
+
* Stable key referenced by `CollectionAdminConfig.group`. Boot-validated —
|
|
181
|
+
* a reference to an undeclared name throws at startup rather than silently
|
|
182
|
+
* producing an extra heading.
|
|
183
|
+
*/
|
|
184
|
+
name: string;
|
|
185
|
+
/**
|
|
186
|
+
* Heading text rendered above this group's collections.
|
|
187
|
+
*
|
|
188
|
+
* A plain string, deliberately not translated: `CollectionDefinition.labels`
|
|
189
|
+
* are themselves rendered untranslated on the dashboard, so translating group
|
|
190
|
+
* headings alone would put a localised heading above English card titles.
|
|
191
|
+
* Translated headings belong to a later, uniform collection-label i18n pass.
|
|
192
|
+
*/
|
|
193
|
+
label: string;
|
|
194
|
+
}
|
|
170
195
|
/**
|
|
171
196
|
* Minimal document shape passed to `CollectionAdminConfig.preview.url`.
|
|
172
197
|
*
|
|
@@ -193,7 +218,15 @@ export interface PreviewDocument<F = any> {
|
|
|
193
218
|
export interface CollectionAdminConfig<T = any> {
|
|
194
219
|
/** Must match the `path` of the corresponding `CollectionDefinition`. */
|
|
195
220
|
slug: string;
|
|
196
|
-
/**
|
|
221
|
+
/**
|
|
222
|
+
* Dashboard group this collection belongs to. Must name an entry in
|
|
223
|
+
* `AdminConfig.collectionGroups`; an unknown name throws at startup.
|
|
224
|
+
*
|
|
225
|
+
* Omit to place the collection in the leading ungrouped band, which renders
|
|
226
|
+
* above the first group heading with no heading of its own.
|
|
227
|
+
*
|
|
228
|
+
* @see CollectionGroupDefinition
|
|
229
|
+
*/
|
|
197
230
|
group?: string;
|
|
198
231
|
/** Column definitions for the collection list view. */
|
|
199
232
|
columns?: ColumnDefinition<T>[];
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import type { SessionProvider } from '@byline/auth';
|
|
9
9
|
import type { SlugifierFn } from '../utils/slugify.js';
|
|
10
10
|
import type { FilenameSlugifierFn } from '../utils/slugify-filename.js';
|
|
11
|
-
import type { BlockAdminConfig, CollectionAdminConfig } from './admin-types.js';
|
|
11
|
+
import type { BlockAdminConfig, CollectionAdminConfig, CollectionGroupDefinition } from './admin-types.js';
|
|
12
12
|
import type { CollectionDefinition, CollectionHooks, CollectionHooksLoader, UploadHooks, UploadHooksLoader } from './collection-types.js';
|
|
13
13
|
import type { IDbAdapter } from './db-types.js';
|
|
14
14
|
import type { RichTextEditorComponent, RichTextEmbedFn, RichTextPopulateFn, RichTextToMarkdownFn, RichTextToTextFn } from './field-types.js';
|
|
@@ -146,6 +146,20 @@ export type TranslationBundleShape = Readonly<{
|
|
|
146
146
|
export interface AdminConfig extends BaseConfig {
|
|
147
147
|
/** Admin UI configuration for collections (client-side only). */
|
|
148
148
|
admin?: CollectionAdminConfig[];
|
|
149
|
+
/**
|
|
150
|
+
* Ordered registry of dashboard collection groups. Array order is display
|
|
151
|
+
* order. A collection joins a group by setting `CollectionAdminConfig.group`
|
|
152
|
+
* to an entry's `name`.
|
|
153
|
+
*
|
|
154
|
+
* Omit entirely to keep the flat, ungrouped dashboard grid — this property is
|
|
155
|
+
* purely additive and changes nothing when absent.
|
|
156
|
+
*
|
|
157
|
+
* Boot-validated by `validateAdminConfigs`: duplicate names, blank names or
|
|
158
|
+
* labels, and references to undeclared names all throw.
|
|
159
|
+
*
|
|
160
|
+
* @see CollectionGroupDefinition
|
|
161
|
+
*/
|
|
162
|
+
collectionGroups?: CollectionGroupDefinition[];
|
|
149
163
|
/**
|
|
150
164
|
* Admin UI configuration for blocks, keyed by `blockType` — the block-scoped
|
|
151
165
|
* analogue of `admin`. Because blocks are shared across collections, an
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import type { CollectionDefinition } from '../@types/collection-types.js';
|
|
9
|
+
/**
|
|
10
|
+
* The ability facts a rendering surface needs about the current administrator.
|
|
11
|
+
* Mirrors the snapshot the admin route places on router context in
|
|
12
|
+
* `beforeLoad` — deliberately a plain data shape so this module stays
|
|
13
|
+
* React-free and transport-agnostic.
|
|
14
|
+
*/
|
|
15
|
+
export interface ActorAbilitySnapshot {
|
|
16
|
+
isSuperAdmin: boolean;
|
|
17
|
+
abilities: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Narrow a collection list to those the administrator can read.
|
|
21
|
+
*
|
|
22
|
+
* `read` is the gate because everything a dashboard card offers — the link to
|
|
23
|
+
* the list view, and the per-status counts — requires
|
|
24
|
+
* `collections.<path>.read` and is rejected server-side without it. An
|
|
25
|
+
* administrator who cannot read a collection would otherwise see a card whose
|
|
26
|
+
* status tiles all read zero, which is indistinguishable from a collection that
|
|
27
|
+
* is genuinely empty.
|
|
28
|
+
*
|
|
29
|
+
* **Cosmetic only.** This is an affordance, never a security boundary.
|
|
30
|
+
* `assertActorCanPerform` remains the enforcement point on every read and write
|
|
31
|
+
* path; hiding a card only stops the interface advertising something the server
|
|
32
|
+
* will refuse. Never rely on this function to keep data from anyone.
|
|
33
|
+
*
|
|
34
|
+
* Super-admin short-circuits, mirroring `AdminAuth.assertAbility` and the
|
|
35
|
+
* client-side `useAbilities` hook.
|
|
36
|
+
*/
|
|
37
|
+
export declare function filterReadableCollections(collections: readonly CollectionDefinition[], snapshot: ActorAbilitySnapshot): CollectionDefinition[];
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { collectionAbilityKey } from './register-collection-abilities.js';
|
|
9
|
+
/**
|
|
10
|
+
* Narrow a collection list to those the administrator can read.
|
|
11
|
+
*
|
|
12
|
+
* `read` is the gate because everything a dashboard card offers — the link to
|
|
13
|
+
* the list view, and the per-status counts — requires
|
|
14
|
+
* `collections.<path>.read` and is rejected server-side without it. An
|
|
15
|
+
* administrator who cannot read a collection would otherwise see a card whose
|
|
16
|
+
* status tiles all read zero, which is indistinguishable from a collection that
|
|
17
|
+
* is genuinely empty.
|
|
18
|
+
*
|
|
19
|
+
* **Cosmetic only.** This is an affordance, never a security boundary.
|
|
20
|
+
* `assertActorCanPerform` remains the enforcement point on every read and write
|
|
21
|
+
* path; hiding a card only stops the interface advertising something the server
|
|
22
|
+
* will refuse. Never rely on this function to keep data from anyone.
|
|
23
|
+
*
|
|
24
|
+
* Super-admin short-circuits, mirroring `AdminAuth.assertAbility` and the
|
|
25
|
+
* client-side `useAbilities` hook.
|
|
26
|
+
*/
|
|
27
|
+
export function filterReadableCollections(collections, snapshot) {
|
|
28
|
+
if (snapshot.isSuperAdmin)
|
|
29
|
+
return [...collections];
|
|
30
|
+
const held = new Set(snapshot.abilities);
|
|
31
|
+
return collections.filter((collection) => held.has(collectionAbilityKey(collection.path, 'read')));
|
|
32
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from 'vitest';
|
|
9
|
+
import { filterReadableCollections } from './filter-readable-collections.js';
|
|
10
|
+
const define = (path) => ({
|
|
11
|
+
path,
|
|
12
|
+
labels: { singular: path, plural: path },
|
|
13
|
+
fields: [{ name: 'title', label: 'Title', type: 'text' }],
|
|
14
|
+
});
|
|
15
|
+
const news = define('news');
|
|
16
|
+
const pages = define('pages');
|
|
17
|
+
const media = define('media');
|
|
18
|
+
const all = [news, pages, media];
|
|
19
|
+
describe('filterReadableCollections', () => {
|
|
20
|
+
it('returns every collection for a super admin, regardless of abilities', () => {
|
|
21
|
+
expect(filterReadableCollections(all, { isSuperAdmin: true, abilities: [] })).toEqual(all);
|
|
22
|
+
});
|
|
23
|
+
it('returns nothing when the actor holds no abilities', () => {
|
|
24
|
+
expect(filterReadableCollections(all, { isSuperAdmin: false, abilities: [] })).toEqual([]);
|
|
25
|
+
});
|
|
26
|
+
it('returns only the collections whose read ability is held', () => {
|
|
27
|
+
const result = filterReadableCollections(all, {
|
|
28
|
+
isSuperAdmin: false,
|
|
29
|
+
abilities: ['collections.news.read', 'collections.media.read'],
|
|
30
|
+
});
|
|
31
|
+
expect(result).toEqual([news, media]);
|
|
32
|
+
});
|
|
33
|
+
it('preserves declaration order', () => {
|
|
34
|
+
const result = filterReadableCollections(all, {
|
|
35
|
+
isSuperAdmin: false,
|
|
36
|
+
abilities: ['collections.media.read', 'collections.news.read'],
|
|
37
|
+
});
|
|
38
|
+
expect(result.map((c) => c.path)).toEqual(['news', 'media']);
|
|
39
|
+
});
|
|
40
|
+
it('does not treat a non-read verb as granting visibility', () => {
|
|
41
|
+
const result = filterReadableCollections(all, {
|
|
42
|
+
isSuperAdmin: false,
|
|
43
|
+
abilities: [
|
|
44
|
+
'collections.news.create',
|
|
45
|
+
'collections.news.update',
|
|
46
|
+
'collections.news.publish',
|
|
47
|
+
'collections.news.delete',
|
|
48
|
+
'collections.news.changeStatus',
|
|
49
|
+
'collections.news.reindex',
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
expect(result).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
it('does not match on a prefix of a collection path', () => {
|
|
55
|
+
const result = filterReadableCollections([define('news-categories')], {
|
|
56
|
+
isSuperAdmin: false,
|
|
57
|
+
abilities: ['collections.news.read'],
|
|
58
|
+
});
|
|
59
|
+
expect(result).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
it('ignores unrelated admin abilities', () => {
|
|
62
|
+
const result = filterReadableCollections(all, {
|
|
63
|
+
isSuperAdmin: false,
|
|
64
|
+
abilities: ['admin.users.read', 'admin.roles.read'],
|
|
65
|
+
});
|
|
66
|
+
expect(result).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
it('returns a new array rather than the input', () => {
|
|
69
|
+
const result = filterReadableCollections(all, { isSuperAdmin: true, abilities: [] });
|
|
70
|
+
expect(result).not.toBe(all);
|
|
71
|
+
});
|
|
72
|
+
});
|
package/dist/auth/index.d.ts
CHANGED
|
@@ -7,4 +7,5 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export { applyBeforeRead, bindReadContextAuthority, compileBeforeReadFilters, } from './apply-before-read.js';
|
|
9
9
|
export { assertActorCanPerform } from './assert-actor-can-perform.js';
|
|
10
|
+
export { type ActorAbilitySnapshot, filterReadableCollections, } from './filter-readable-collections.js';
|
|
10
11
|
export { COLLECTION_ABILITY_VERBS, type CollectionAbilityVerb, collectionAbilityKey, registerCollectionAbilities, } from './register-collection-abilities.js';
|
package/dist/auth/index.js
CHANGED
|
@@ -7,4 +7,5 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export { applyBeforeRead, bindReadContextAuthority, compileBeforeReadFilters, } from './apply-before-read.js';
|
|
9
9
|
export { assertActorCanPerform } from './assert-actor-can-perform.js';
|
|
10
|
+
export { filterReadableCollections, } from './filter-readable-collections.js';
|
|
10
11
|
export { COLLECTION_ABILITY_VERBS, collectionAbilityKey, registerCollectionAbilities, } from './register-collection-abilities.js';
|
package/dist/config/config.js
CHANGED
|
@@ -59,7 +59,7 @@ export const getCollectionAdminConfig = (slug) => {
|
|
|
59
59
|
};
|
|
60
60
|
export function defineAdminConfig(config) {
|
|
61
61
|
validateCollections(config.collections);
|
|
62
|
-
validateAdminConfigs(config.admin, config.collections);
|
|
62
|
+
validateAdminConfigs(config.admin, config.collections, config.collectionGroups);
|
|
63
63
|
validateBlockAdminConfigs(config.blockAdmin, config.collections);
|
|
64
64
|
const resolved = { ...config, routes: resolveRoutes(config.routes) };
|
|
65
65
|
setAdminConfigInstance(resolved);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import type { CollectionAdminConfig, CollectionGroupDefinition } from '../@types/admin-types.js';
|
|
9
|
+
import type { CollectionDefinition } from '../@types/collection-types.js';
|
|
10
|
+
/**
|
|
11
|
+
* One renderable section of the admin dashboard: a heading (or none) and the
|
|
12
|
+
* collections beneath it.
|
|
13
|
+
*/
|
|
14
|
+
export interface CollectionGroupBucket {
|
|
15
|
+
/** Registry key, or `null` for the leading ungrouped band. */
|
|
16
|
+
name: string | null;
|
|
17
|
+
/** Heading text, or `null` when the band renders without a heading. */
|
|
18
|
+
label: string | null;
|
|
19
|
+
collections: CollectionDefinition[];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Bucket collections into ordered dashboard sections.
|
|
23
|
+
*
|
|
24
|
+
* Rules:
|
|
25
|
+
* - The ungrouped band is emitted first, and omitted entirely when empty.
|
|
26
|
+
* - Declared groups follow in `collectionGroups` order.
|
|
27
|
+
* - A declared group with no members is skipped, so no heading ever appears
|
|
28
|
+
* above an empty section.
|
|
29
|
+
* - Collection declaration order is preserved within each bucket.
|
|
30
|
+
* - An absent or empty registry yields a single ungrouped bucket holding every
|
|
31
|
+
* collection — the flat grid Byline rendered before groups existed.
|
|
32
|
+
*
|
|
33
|
+
* This function is deliberately total: a `group` naming no declared entry is
|
|
34
|
+
* treated as ungrouped rather than throwing. `validateCollectionGroups` rejects
|
|
35
|
+
* that configuration at startup, so the fallback only ever covers a stale or
|
|
36
|
+
* hand-built config object, where crashing the dashboard would be the worse
|
|
37
|
+
* outcome.
|
|
38
|
+
*
|
|
39
|
+
* It takes no actor and knows nothing about abilities. Callers that need to
|
|
40
|
+
* hide collections filter the `collections` argument first — see
|
|
41
|
+
* `filterReadableCollections`. That ordering is what makes a group whose
|
|
42
|
+
* members are all hidden disappear along with its heading: it arrives here with
|
|
43
|
+
* no members and is skipped by the rule above.
|
|
44
|
+
*/
|
|
45
|
+
export declare function groupCollectionsForAdmin(collections: readonly CollectionDefinition[], admin: readonly CollectionAdminConfig[] | undefined, collectionGroups: readonly CollectionGroupDefinition[] | undefined): CollectionGroupBucket[];
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Bucket collections into ordered dashboard sections.
|
|
10
|
+
*
|
|
11
|
+
* Rules:
|
|
12
|
+
* - The ungrouped band is emitted first, and omitted entirely when empty.
|
|
13
|
+
* - Declared groups follow in `collectionGroups` order.
|
|
14
|
+
* - A declared group with no members is skipped, so no heading ever appears
|
|
15
|
+
* above an empty section.
|
|
16
|
+
* - Collection declaration order is preserved within each bucket.
|
|
17
|
+
* - An absent or empty registry yields a single ungrouped bucket holding every
|
|
18
|
+
* collection — the flat grid Byline rendered before groups existed.
|
|
19
|
+
*
|
|
20
|
+
* This function is deliberately total: a `group` naming no declared entry is
|
|
21
|
+
* treated as ungrouped rather than throwing. `validateCollectionGroups` rejects
|
|
22
|
+
* that configuration at startup, so the fallback only ever covers a stale or
|
|
23
|
+
* hand-built config object, where crashing the dashboard would be the worse
|
|
24
|
+
* outcome.
|
|
25
|
+
*
|
|
26
|
+
* It takes no actor and knows nothing about abilities. Callers that need to
|
|
27
|
+
* hide collections filter the `collections` argument first — see
|
|
28
|
+
* `filterReadableCollections`. That ordering is what makes a group whose
|
|
29
|
+
* members are all hidden disappear along with its heading: it arrives here with
|
|
30
|
+
* no members and is skipped by the rule above.
|
|
31
|
+
*/
|
|
32
|
+
export function groupCollectionsForAdmin(collections, admin, collectionGroups) {
|
|
33
|
+
const groupByCollectionPath = new Map();
|
|
34
|
+
for (const entry of admin ?? []) {
|
|
35
|
+
if (entry.group != null)
|
|
36
|
+
groupByCollectionPath.set(entry.slug, entry.group);
|
|
37
|
+
}
|
|
38
|
+
const membersByGroup = new Map();
|
|
39
|
+
for (const group of collectionGroups ?? []) {
|
|
40
|
+
membersByGroup.set(group.name, []);
|
|
41
|
+
}
|
|
42
|
+
const ungrouped = [];
|
|
43
|
+
for (const collection of collections) {
|
|
44
|
+
const groupName = groupByCollectionPath.get(collection.path);
|
|
45
|
+
const members = groupName == null ? undefined : membersByGroup.get(groupName);
|
|
46
|
+
if (members == null) {
|
|
47
|
+
ungrouped.push(collection);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
members.push(collection);
|
|
51
|
+
}
|
|
52
|
+
const buckets = [];
|
|
53
|
+
if (ungrouped.length > 0) {
|
|
54
|
+
buckets.push({ name: null, label: null, collections: ungrouped });
|
|
55
|
+
}
|
|
56
|
+
for (const group of collectionGroups ?? []) {
|
|
57
|
+
const members = membersByGroup.get(group.name) ?? [];
|
|
58
|
+
if (members.length === 0)
|
|
59
|
+
continue;
|
|
60
|
+
buckets.push({ name: group.name, label: group.label, collections: members });
|
|
61
|
+
}
|
|
62
|
+
return buckets;
|
|
63
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from 'vitest';
|
|
9
|
+
import { groupCollectionsForAdmin } from './group-collections.js';
|
|
10
|
+
const define = (path) => ({
|
|
11
|
+
path,
|
|
12
|
+
labels: { singular: path, plural: path },
|
|
13
|
+
fields: [{ name: 'title', label: 'Title', type: 'text' }],
|
|
14
|
+
});
|
|
15
|
+
const pages = define('pages');
|
|
16
|
+
const news = define('news');
|
|
17
|
+
const images = define('images');
|
|
18
|
+
const authors = define('authors');
|
|
19
|
+
const categories = define('categories');
|
|
20
|
+
const groups = [
|
|
21
|
+
{ name: 'media', label: 'Media' },
|
|
22
|
+
{ name: 'authorities', label: 'People & Organisations' },
|
|
23
|
+
{ name: 'taxonomy', label: 'Taxonomies' },
|
|
24
|
+
];
|
|
25
|
+
const admin = [
|
|
26
|
+
{ slug: 'images', group: 'media' },
|
|
27
|
+
{ slug: 'authors', group: 'authorities' },
|
|
28
|
+
{ slug: 'categories', group: 'taxonomy' },
|
|
29
|
+
];
|
|
30
|
+
describe('groupCollectionsForAdmin', () => {
|
|
31
|
+
it('returns one ungrouped bucket when no registry is declared', () => {
|
|
32
|
+
const result = groupCollectionsForAdmin([pages, news], admin, undefined);
|
|
33
|
+
expect(result).toEqual([{ name: null, label: null, collections: [pages, news] }]);
|
|
34
|
+
});
|
|
35
|
+
it('returns one ungrouped bucket when the registry is empty', () => {
|
|
36
|
+
const result = groupCollectionsForAdmin([pages, news], admin, []);
|
|
37
|
+
expect(result).toEqual([{ name: null, label: null, collections: [pages, news] }]);
|
|
38
|
+
});
|
|
39
|
+
it('emits the ungrouped band first, then groups in registry order', () => {
|
|
40
|
+
const result = groupCollectionsForAdmin([images, pages, categories, news, authors], admin, groups);
|
|
41
|
+
expect(result.map((b) => b.name)).toEqual([null, 'media', 'authorities', 'taxonomy']);
|
|
42
|
+
});
|
|
43
|
+
it('omits the ungrouped bucket entirely when every collection is grouped', () => {
|
|
44
|
+
const result = groupCollectionsForAdmin([images, authors, categories], admin, groups);
|
|
45
|
+
expect(result.map((b) => b.name)).toEqual(['media', 'authorities', 'taxonomy']);
|
|
46
|
+
});
|
|
47
|
+
it('skips a declared group that has no member collections', () => {
|
|
48
|
+
const result = groupCollectionsForAdmin([images, categories], admin, groups);
|
|
49
|
+
expect(result.map((b) => b.name)).toEqual(['media', 'taxonomy']);
|
|
50
|
+
});
|
|
51
|
+
it('returns an empty array when there are no collections at all', () => {
|
|
52
|
+
expect(groupCollectionsForAdmin([], admin, groups)).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
it('carries each group label through to its bucket', () => {
|
|
55
|
+
const result = groupCollectionsForAdmin([authors], admin, groups);
|
|
56
|
+
expect(result[0]).toEqual({
|
|
57
|
+
name: 'authorities',
|
|
58
|
+
label: 'People & Organisations',
|
|
59
|
+
collections: [authors],
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
it('preserves collection declaration order within a bucket', () => {
|
|
63
|
+
const more = define('videos');
|
|
64
|
+
const result = groupCollectionsForAdmin([more, images], [...admin, { slug: 'videos', group: 'media' }], groups);
|
|
65
|
+
expect(result[0]?.collections).toEqual([more, images]);
|
|
66
|
+
});
|
|
67
|
+
it('places a collection with no admin config in the ungrouped band', () => {
|
|
68
|
+
const result = groupCollectionsForAdmin([pages, images], admin, groups);
|
|
69
|
+
expect(result[0]).toEqual({ name: null, label: null, collections: [pages] });
|
|
70
|
+
});
|
|
71
|
+
it('treats an undeclared group name as ungrouped rather than throwing', () => {
|
|
72
|
+
// Boot validation rejects this configuration, but the function stays total
|
|
73
|
+
// so a renderer can never crash on a stale or hand-built config object.
|
|
74
|
+
const result = groupCollectionsForAdmin([pages], [{ slug: 'pages', group: 'ghost' }], groups);
|
|
75
|
+
expect(result).toEqual([{ name: null, label: null, collections: [pages] }]);
|
|
76
|
+
});
|
|
77
|
+
it('ignores admin configs whose collection is not registered', () => {
|
|
78
|
+
const result = groupCollectionsForAdmin([pages], admin, groups);
|
|
79
|
+
expect(result).toEqual([{ name: null, label: null, collections: [pages] }]);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -5,7 +5,22 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
import type { BlockAdminConfig, CollectionAdminConfig, CollectionDefinition } from '../@types/index.js';
|
|
8
|
+
import type { BlockAdminConfig, CollectionAdminConfig, CollectionDefinition, CollectionGroupDefinition } from '../@types/index.js';
|
|
9
|
+
/**
|
|
10
|
+
* Validate the dashboard collection-group registry and every reference to it.
|
|
11
|
+
*
|
|
12
|
+
* Enforced rules:
|
|
13
|
+
* 1. Each `collectionGroups` entry has a non-blank `name` and `label`.
|
|
14
|
+
* 2. No two entries share a `name`.
|
|
15
|
+
* 3. Every `CollectionAdminConfig.group` names a declared entry. This single
|
|
16
|
+
* rule covers both a typographical error and the case where `group` was set
|
|
17
|
+
* but the registry was never declared.
|
|
18
|
+
*
|
|
19
|
+
* Throws a plain `Error` for the same reason the rest of this module does —
|
|
20
|
+
* configuration validation runs at startup, before the logger and error
|
|
21
|
+
* registry are necessarily wired up.
|
|
22
|
+
*/
|
|
23
|
+
export declare function validateCollectionGroups(collectionGroups: readonly CollectionGroupDefinition[] | undefined, admins: readonly CollectionAdminConfig[] | undefined): void;
|
|
9
24
|
/**
|
|
10
25
|
* Validate every admin config in a configuration.
|
|
11
26
|
*
|
|
@@ -30,12 +45,15 @@ import type { BlockAdminConfig, CollectionAdminConfig, CollectionDefinition } fr
|
|
|
30
45
|
* direction (when given) is `asc` | `desc`, and the option is rejected
|
|
31
46
|
* on `orderable: true` collections (manual ordering owns their default
|
|
32
47
|
* sort and the drag-to-reorder canonical-view check assumes it).
|
|
48
|
+
* 8. Collection groups — the `collectionGroups` registry is well-formed
|
|
49
|
+
* (non-blank, unique names) and every `admin.group` names a declared
|
|
50
|
+
* entry. See `validateCollectionGroups`.
|
|
33
51
|
*
|
|
34
52
|
* Throws a plain `Error` (not a `BylineError`) because configuration
|
|
35
53
|
* validation runs at startup, before the logger and error registry are
|
|
36
54
|
* necessarily wired up.
|
|
37
55
|
*/
|
|
38
|
-
export declare function validateAdminConfigs(admins: readonly CollectionAdminConfig[] | undefined, collections: readonly CollectionDefinition[]): void;
|
|
56
|
+
export declare function validateAdminConfigs(admins: readonly CollectionAdminConfig[] | undefined, collections: readonly CollectionDefinition[], collectionGroups?: readonly CollectionGroupDefinition[]): void;
|
|
39
57
|
/**
|
|
40
58
|
* Validate every block admin config in a configuration.
|
|
41
59
|
*
|
|
@@ -38,6 +38,47 @@ function validateFieldAdminKeys(keys, resolve, fail) {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Validate the dashboard collection-group registry and every reference to it.
|
|
43
|
+
*
|
|
44
|
+
* Enforced rules:
|
|
45
|
+
* 1. Each `collectionGroups` entry has a non-blank `name` and `label`.
|
|
46
|
+
* 2. No two entries share a `name`.
|
|
47
|
+
* 3. Every `CollectionAdminConfig.group` names a declared entry. This single
|
|
48
|
+
* rule covers both a typographical error and the case where `group` was set
|
|
49
|
+
* but the registry was never declared.
|
|
50
|
+
*
|
|
51
|
+
* Throws a plain `Error` for the same reason the rest of this module does —
|
|
52
|
+
* configuration validation runs at startup, before the logger and error
|
|
53
|
+
* registry are necessarily wired up.
|
|
54
|
+
*/
|
|
55
|
+
export function validateCollectionGroups(collectionGroups, admins) {
|
|
56
|
+
const declared = new Set();
|
|
57
|
+
for (const group of collectionGroups ?? []) {
|
|
58
|
+
const name = typeof group.name === 'string' ? group.name.trim() : '';
|
|
59
|
+
const label = typeof group.label === 'string' ? group.label.trim() : '';
|
|
60
|
+
if (name === '') {
|
|
61
|
+
throw new Error('A `collectionGroups` entry has a blank `name`. Each entry needs a non-empty key for `CollectionAdminConfig.group` to reference.');
|
|
62
|
+
}
|
|
63
|
+
if (label === '') {
|
|
64
|
+
throw new Error(`Collection group "${name}" has a blank \`label\`. The label is the heading rendered above the group on the dashboard.`);
|
|
65
|
+
}
|
|
66
|
+
if (declared.has(name)) {
|
|
67
|
+
throw new Error(`Collection group "${name}" is declared more than once in \`collectionGroups\`. Group names must be unique.`);
|
|
68
|
+
}
|
|
69
|
+
declared.add(name);
|
|
70
|
+
}
|
|
71
|
+
for (const admin of admins ?? []) {
|
|
72
|
+
if (admin.group == null)
|
|
73
|
+
continue;
|
|
74
|
+
if (declared.has(admin.group))
|
|
75
|
+
continue;
|
|
76
|
+
const known = declared.size === 0
|
|
77
|
+
? '`collectionGroups` was not declared, or is empty'
|
|
78
|
+
: `declared groups: ${[...declared].map((name) => `"${name}"`).join(', ')}`;
|
|
79
|
+
throw new Error(`Collection "${admin.slug}": \`group: '${admin.group}'\` does not name a declared collection group (${known}). Add it to \`AdminConfig.collectionGroups\`, or remove the \`group\` property.`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
41
82
|
/**
|
|
42
83
|
* Validate every admin config in a configuration.
|
|
43
84
|
*
|
|
@@ -62,12 +103,18 @@ function validateFieldAdminKeys(keys, resolve, fail) {
|
|
|
62
103
|
* direction (when given) is `asc` | `desc`, and the option is rejected
|
|
63
104
|
* on `orderable: true` collections (manual ordering owns their default
|
|
64
105
|
* sort and the drag-to-reorder canonical-view check assumes it).
|
|
106
|
+
* 8. Collection groups — the `collectionGroups` registry is well-formed
|
|
107
|
+
* (non-blank, unique names) and every `admin.group` names a declared
|
|
108
|
+
* entry. See `validateCollectionGroups`.
|
|
65
109
|
*
|
|
66
110
|
* Throws a plain `Error` (not a `BylineError`) because configuration
|
|
67
111
|
* validation runs at startup, before the logger and error registry are
|
|
68
112
|
* necessarily wired up.
|
|
69
113
|
*/
|
|
70
|
-
export function validateAdminConfigs(admins, collections) {
|
|
114
|
+
export function validateAdminConfigs(admins, collections, collectionGroups) {
|
|
115
|
+
// Registry sanity runs before the early return below, so a malformed registry
|
|
116
|
+
// still fails fast in an installation that declares no admin configs.
|
|
117
|
+
validateCollectionGroups(collectionGroups, admins);
|
|
71
118
|
if (admins == null || admins.length === 0)
|
|
72
119
|
return;
|
|
73
120
|
const collectionsByPath = new Map();
|
|
@@ -461,3 +461,46 @@ describe('validateBlockAdminConfigs', () => {
|
|
|
461
461
|
expect(() => validateBlockAdminConfigs([{ blockType: 'quoteBlock', fields: { quoteText: {}, attribution: {} } }], [blockCollection, other])).not.toThrow();
|
|
462
462
|
});
|
|
463
463
|
});
|
|
464
|
+
describe('validateAdminConfigs — collection groups', () => {
|
|
465
|
+
const groups = [
|
|
466
|
+
{ name: 'media', label: 'Media' },
|
|
467
|
+
{ name: 'taxonomy', label: 'Taxonomies' },
|
|
468
|
+
];
|
|
469
|
+
it('accepts a valid registry and a valid reference', () => {
|
|
470
|
+
expect(() => validateAdminConfigs([{ slug: 'news', group: 'media' }], [collection], groups)).not.toThrow();
|
|
471
|
+
});
|
|
472
|
+
it('accepts a collection with no group when a registry is declared', () => {
|
|
473
|
+
expect(() => validateAdminConfigs([{ slug: 'news' }], [collection], groups)).not.toThrow();
|
|
474
|
+
});
|
|
475
|
+
it('is a no-op when no registry and no group references are present', () => {
|
|
476
|
+
expect(() => validateAdminConfigs([{ slug: 'news' }], [collection])).not.toThrow();
|
|
477
|
+
});
|
|
478
|
+
it('rejects a duplicate group name', () => {
|
|
479
|
+
expect(() => validateAdminConfigs([{ slug: 'news' }], [collection], [
|
|
480
|
+
{ name: 'media', label: 'Media' },
|
|
481
|
+
{ name: 'media', label: 'Media Library' },
|
|
482
|
+
])).toThrow(/declared more than once/);
|
|
483
|
+
});
|
|
484
|
+
it('rejects a blank group name', () => {
|
|
485
|
+
expect(() => validateAdminConfigs([{ slug: 'news' }], [collection], [{ name: ' ', label: 'Media' }])).toThrow(/blank `name`/);
|
|
486
|
+
});
|
|
487
|
+
it('rejects a blank group label', () => {
|
|
488
|
+
expect(() => validateAdminConfigs([{ slug: 'news' }], [collection], [{ name: 'media', label: '' }])).toThrow(/blank `label`/);
|
|
489
|
+
});
|
|
490
|
+
it('rejects a group reference that names no declared group', () => {
|
|
491
|
+
expect(() => validateAdminConfigs([{ slug: 'news', group: 'medai' }], [collection], groups)).toThrow(/does not name a declared collection group/);
|
|
492
|
+
});
|
|
493
|
+
it('names the declared groups in the unresolved-reference error', () => {
|
|
494
|
+
expect(() => validateAdminConfigs([{ slug: 'news', group: 'medai' }], [collection], groups)).toThrow(/"media", "taxonomy"/);
|
|
495
|
+
});
|
|
496
|
+
it('rejects a group reference when no registry was declared at all', () => {
|
|
497
|
+
expect(() => validateAdminConfigs([{ slug: 'news', group: 'media' }], [collection])).toThrow(/was not declared/);
|
|
498
|
+
});
|
|
499
|
+
// Registry sanity must not be skipped by the `admins` early return.
|
|
500
|
+
it('validates the registry even when there are no admin configs', () => {
|
|
501
|
+
expect(() => validateAdminConfigs([], [collection], [
|
|
502
|
+
{ name: 'media', label: 'Media' },
|
|
503
|
+
{ name: 'media', label: 'Media Library' },
|
|
504
|
+
])).toThrow(/declared more than once/);
|
|
505
|
+
});
|
|
506
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
export * from './@types/index.js';
|
|
2
|
-
export { applyBeforeRead, assertActorCanPerform, bindReadContextAuthority, COLLECTION_ABILITY_VERBS, type CollectionAbilityVerb, collectionAbilityKey, compileBeforeReadFilters, registerCollectionAbilities, } from './auth/index.js';
|
|
2
|
+
export { type ActorAbilitySnapshot, applyBeforeRead, assertActorCanPerform, bindReadContextAuthority, COLLECTION_ABILITY_VERBS, type CollectionAbilityVerb, collectionAbilityKey, compileBeforeReadFilters, filterReadableCollections, registerCollectionAbilities, } from './auth/index.js';
|
|
3
3
|
export { defineAdminConfig, defineServerConfig, getAdminConfig, getCollectionAdminConfig, getCollectionDefinition, getServerConfig, orderByContentLocale, } from './config/config.js';
|
|
4
|
+
export { type CollectionGroupBucket, groupCollectionsForAdmin, } from './config/group-collections.js';
|
|
4
5
|
export { resolveRoutes } from './config/routes.js';
|
|
5
|
-
export { validateAdminConfigs, validateBlockAdminConfigs, } from './config/validate-admin-configs.js';
|
|
6
|
+
export { validateAdminConfigs, validateBlockAdminConfigs, validateCollectionGroups, } from './config/validate-admin-configs.js';
|
|
6
7
|
export { RESERVED_FIELD_NAMES } from './config/validate-collections.js';
|
|
7
8
|
export { type BylineCore, getBylineCore, initBylineCore } from './core.js';
|
|
8
9
|
export * from './defaults/default-values.js';
|
package/dist/index.js
CHANGED
|
@@ -15,10 +15,11 @@
|
|
|
15
15
|
// through this main entry or `@byline/client`.
|
|
16
16
|
// ---------------------------------------------------------------------------
|
|
17
17
|
export * from './@types/index.js';
|
|
18
|
-
export { applyBeforeRead, assertActorCanPerform, bindReadContextAuthority, COLLECTION_ABILITY_VERBS, collectionAbilityKey, compileBeforeReadFilters, registerCollectionAbilities, } from './auth/index.js';
|
|
18
|
+
export { applyBeforeRead, assertActorCanPerform, bindReadContextAuthority, COLLECTION_ABILITY_VERBS, collectionAbilityKey, compileBeforeReadFilters, filterReadableCollections, registerCollectionAbilities, } from './auth/index.js';
|
|
19
19
|
export { defineAdminConfig, defineServerConfig, getAdminConfig, getCollectionAdminConfig, getCollectionDefinition, getServerConfig, orderByContentLocale, } from './config/config.js';
|
|
20
|
+
export { groupCollectionsForAdmin, } from './config/group-collections.js';
|
|
20
21
|
export { resolveRoutes } from './config/routes.js';
|
|
21
|
-
export { validateAdminConfigs, validateBlockAdminConfigs, } from './config/validate-admin-configs.js';
|
|
22
|
+
export { validateAdminConfigs, validateBlockAdminConfigs, validateCollectionGroups, } from './config/validate-admin-configs.js';
|
|
22
23
|
export { RESERVED_FIELD_NAMES } from './config/validate-collections.js';
|
|
23
24
|
export { getBylineCore, initBylineCore } from './core.js';
|
|
24
25
|
export * from './defaults/default-values.js';
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@byline/core",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "4.
|
|
5
|
+
"version": "4.14.1",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
"sharp": "^0.35.3",
|
|
83
83
|
"uuid": "^14.0.2",
|
|
84
84
|
"zod": "^4.4.3",
|
|
85
|
-
"@byline/auth": "4.
|
|
85
|
+
"@byline/auth": "4.14.1"
|
|
86
86
|
},
|
|
87
87
|
"devDependencies": {
|
|
88
88
|
"@biomejs/biome": "2.5.9",
|