@byline/core 4.13.0 → 4.14.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.
@@ -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
- /** Group name for organising collections in the admin sidebar. */
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
+ });
@@ -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';
@@ -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';
@@ -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.13.0",
5
+ "version": "4.14.0",
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.13.0"
85
+ "@byline/auth": "4.14.0"
86
86
  },
87
87
  "devDependencies": {
88
88
  "@biomejs/biome": "2.5.9",