@byline/core 3.20.2 → 3.20.4

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.
@@ -54,6 +54,22 @@ export interface ColumnDefinition<T = any> {
54
54
  className?: string;
55
55
  formatter?: ColumnFormatter<T>;
56
56
  }
57
+ /**
58
+ * Default sort for a collection's list view, applied when the URL carries
59
+ * no explicit `order`/`desc` search params. An explicit param always wins
60
+ * (a shared link opens exactly as sent).
61
+ *
62
+ * `field` is a top-level schema field name (e.g. `'publicationDate'`) or a
63
+ * document-level column (`'createdAt'`, `'updatedAt'`, `'path'`). Validated
64
+ * at boot by `validateAdminConfigs`. Not allowed on `orderable: true`
65
+ * collections — manual ordering already owns their default sort
66
+ * (`order_key asc`).
67
+ */
68
+ export interface ListDefaultSort<T = any> {
69
+ field: keyof T | 'createdAt' | 'updatedAt' | 'path';
70
+ /** Defaults to `'asc'`. */
71
+ direction?: 'asc' | 'desc';
72
+ }
57
73
  /**
58
74
  * A single tab inside a tab set.
59
75
  */
@@ -181,6 +197,21 @@ export interface CollectionAdminConfig<T = any> {
181
197
  group?: string;
182
198
  /** Column definitions for the collection list view. */
183
199
  columns?: ColumnDefinition<T>[];
200
+ /**
201
+ * Default sort for the list view when the URL carries no explicit
202
+ * `order`/`desc` search params — e.g. a publications library that should
203
+ * open newest-publication-first:
204
+ *
205
+ * ```ts
206
+ * defaultSort: { field: 'publicationDate', direction: 'desc' },
207
+ * ```
208
+ *
209
+ * Precedence: explicit URL params → this default → `created_at desc`.
210
+ * Boot-validated: `field` must be a top-level schema field or a document
211
+ * column (`createdAt` / `updatedAt` / `path`), and the option is rejected
212
+ * on `orderable: true` collections (manual ordering owns their default).
213
+ */
214
+ defaultSort?: ListDefaultSort<T>;
184
215
  /**
185
216
  * Column definitions for rendering this collection as a compact **item
186
217
  * row / tile** wherever a single document is shown outside its own list —
@@ -993,9 +993,7 @@ export interface CollectionDefinition {
993
993
  * Text fields contribute their value; `richText` fields are extracted to
994
994
  * plain text via the registered `fields.richText.toText` seam. Each entry
995
995
  * is a field path, or `{ field, boost }` to weight it for scoring
996
- * providers that support `capabilities.weighting`. Drives the admin
997
- * list-view search box (its `store_text` subset). Falls back to the
998
- * identity field (`useAsTitle`) when omitted.
996
+ * providers that support `capabilities.weighting`.
999
997
  * - `facets` — relation field paths to controlled-vocabulary collections.
1000
998
  * Core resolves each target's `counter` field (the stable aggregation id)
1001
999
  * and its `useAsTitle` (the term, folded into searchable text). `{ field,
@@ -1015,6 +1013,27 @@ export interface CollectionDefinition {
1015
1013
  filters?: string[];
1016
1014
  zones?: string[];
1017
1015
  };
1016
+ /**
1017
+ * Admin list-view quick-search fields — the top-level text-store field
1018
+ * names (`text` / `textArea` / `select`) the list route's search box
1019
+ * matches with substring (`ILIKE`) queries against `store_text`.
1020
+ *
1021
+ * Deliberately separate from `search`, which configures provider indexing
1022
+ * for site search: the two answer different questions ("find the row I
1023
+ * mean" vs. "rank relevant published content") and need not name the same
1024
+ * fields — a collection with a six-field weighted `search.body` typically
1025
+ * wants only its identity field (plus maybe a serial/code field) here.
1026
+ * Declaring one without the other is equally valid: `listSearch` with no
1027
+ * `search` keeps the list box working on an unindexed collection.
1028
+ *
1029
+ * Falls back to the identity field (`useAsTitle`, else the first declared
1030
+ * text field) when omitted — most collections need no declaration.
1031
+ *
1032
+ * Lives on the schema (not admin config) for the same reason as
1033
+ * `useAsTitle`: the query layer (`findDocuments` in the db adapter)
1034
+ * resolves it server-side from the `CollectionDefinition`.
1035
+ */
1036
+ listSearch?: string[];
1018
1037
  /**
1019
1038
  * The field that represents this document's identity — used anywhere a
1020
1039
  * single-line label for the document is needed: form headings, relation
@@ -24,6 +24,11 @@ import type { CollectionAdminConfig, CollectionDefinition } from '../@types/inde
24
24
  * schema field names; groups exclude tabSets and nested groups.
25
25
  * 6. `fields` map sanity — every key in `admin.fields` matches a top-level
26
26
  * schema field name.
27
+ * 7. `defaultSort` sanity — `field` resolves to a top-level schema field
28
+ * or a document-level column (`createdAt` / `updatedAt` / `path`), the
29
+ * direction (when given) is `asc` | `desc`, and the option is rejected
30
+ * on `orderable: true` collections (manual ordering owns their default
31
+ * sort and the drag-to-reorder canonical-view check assumes it).
27
32
  *
28
33
  * Throws a plain `Error` (not a `BylineError`) because configuration
29
34
  * validation runs at startup, before the logger and error registry are
@@ -23,6 +23,11 @@
23
23
  * schema field names; groups exclude tabSets and nested groups.
24
24
  * 6. `fields` map sanity — every key in `admin.fields` matches a top-level
25
25
  * schema field name.
26
+ * 7. `defaultSort` sanity — `field` resolves to a top-level schema field
27
+ * or a document-level column (`createdAt` / `updatedAt` / `path`), the
28
+ * direction (when given) is `asc` | `desc`, and the option is rejected
29
+ * on `orderable: true` collections (manual ordering owns their default
30
+ * sort and the drag-to-reorder canonical-view check assumes it).
26
31
  *
27
32
  * Throws a plain `Error` (not a `BylineError`) because configuration
28
33
  * validation runs at startup, before the logger and error registry are
@@ -57,6 +62,26 @@ function validateOne(admin, collectionsByPath) {
57
62
  if ('name' in field)
58
63
  topLevelFieldNames.add(field.name);
59
64
  }
65
+ // Rule 7 — defaultSort sanity. The list read applies this spec verbatim
66
+ // (host list server fn → parseSort), so a bad field name would silently
67
+ // fall back to `created_at desc` at request time — fail loudly at boot
68
+ // instead.
69
+ if (admin.defaultSort != null) {
70
+ const { field, direction } = admin.defaultSort;
71
+ const documentColumns = new Set(['createdAt', 'updatedAt', 'path']);
72
+ if (collection.orderable === true) {
73
+ fail(`defaultSort is not allowed on an orderable collection — manual ordering owns the default sort (order_key asc).`);
74
+ }
75
+ if (typeof field !== 'string' || field.length === 0) {
76
+ fail(`defaultSort.field must be a non-empty string.`);
77
+ }
78
+ if (!topLevelFieldNames.has(field) && !documentColumns.has(field)) {
79
+ fail(`defaultSort.field "${String(field)}" is not a top-level schema field or a document column (createdAt, updatedAt, path).`);
80
+ }
81
+ if (direction != null && direction !== 'asc' && direction !== 'desc') {
82
+ fail(`defaultSort.direction must be 'asc' or 'desc' (got "${String(direction)}").`);
83
+ }
84
+ }
60
85
  // Build primitive lookup tables.
61
86
  const tabSets = admin.tabSets ?? [];
62
87
  const rows = admin.rows ?? [];
@@ -201,6 +201,43 @@ describe('validateAdminConfigs', () => {
201
201
  };
202
202
  expect(() => validateAdminConfigs([admin], [collection])).toThrow(/not a top-level schema field/);
203
203
  });
204
+ // Rule 7 — defaultSort sanity.
205
+ it('accepts a defaultSort on a schema field', () => {
206
+ const admin = {
207
+ ...baseAdmin,
208
+ defaultSort: { field: 'title', direction: 'desc' },
209
+ };
210
+ expect(() => validateAdminConfigs([admin], [collection])).not.toThrow();
211
+ });
212
+ it('accepts a defaultSort on a document column', () => {
213
+ const admin = {
214
+ ...baseAdmin,
215
+ defaultSort: { field: 'updatedAt' },
216
+ };
217
+ expect(() => validateAdminConfigs([admin], [collection])).not.toThrow();
218
+ });
219
+ it('rejects a defaultSort field that resolves to nothing', () => {
220
+ const admin = {
221
+ ...baseAdmin,
222
+ defaultSort: { field: 'nonexistent' },
223
+ };
224
+ expect(() => validateAdminConfigs([admin], [collection])).toThrow(/defaultSort\.field "nonexistent"/);
225
+ });
226
+ it('rejects a defaultSort direction outside asc/desc', () => {
227
+ const admin = {
228
+ ...baseAdmin,
229
+ defaultSort: { field: 'title', direction: 'sideways' },
230
+ };
231
+ expect(() => validateAdminConfigs([admin], [collection])).toThrow(/defaultSort\.direction must be 'asc' or 'desc'/);
232
+ });
233
+ it('rejects a defaultSort on an orderable collection', () => {
234
+ const orderableCollection = { ...collection, orderable: true };
235
+ const admin = {
236
+ ...baseAdmin,
237
+ defaultSort: { field: 'title' },
238
+ };
239
+ expect(() => validateAdminConfigs([admin], [orderableCollection])).toThrow(/not allowed on an orderable collection/);
240
+ });
204
241
  // Layout omitted — bookkeeping skipped, primitive declarations still checked.
205
242
  it('skips placement bookkeeping when layout is omitted', () => {
206
243
  const admin = {
@@ -33,6 +33,9 @@ export declare const RESERVED_FIELD_NAMES: ReadonlySet<string>;
33
33
  * - A collection may not set both `tree: true` and `orderable: true`. A
34
34
  * document-tree owns ordering per-parent on the tree edge, so
35
35
  * `byline_documents.order_key` is inert for it.
36
+ * - Each `listSearch` entry must name an existing top-level field whose
37
+ * type is persisted to the text store (the admin list-view search box
38
+ * is an `ILIKE` over `store_text`), and the field may not be virtual.
36
39
  * - `virtual` fields must satisfy the constraints in
37
40
  * {@link validateVirtualFields} (optional-or-default, no counters, no
38
41
  * upload fields, not referenced by useAsTitle / useAsPath / search).
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
+ import { fieldTypeToStore } from '../storage/field-store-map.js';
8
9
  /**
9
10
  * Field names that cannot be declared in a collection schema because they
10
11
  * collide with system-managed attributes on `documentVersions`. Exported
@@ -21,6 +22,15 @@ const RESERVED_FIELD_HINTS = {
21
22
  path: "Use `useAsPath: '<sourceField>'` on the collection definition instead.",
22
23
  availableLocales: 'Use `advertiseLocales: true` on the collection definition instead.',
23
24
  };
25
+ /**
26
+ * Field types `listSearch` may name — the types persisted to the text
27
+ * store, since the admin list-view search box is an `ILIKE` over
28
+ * `store_text`. Derived from the canonical field→store mapping so the two
29
+ * can't drift.
30
+ */
31
+ const LIST_SEARCH_SOURCE_TYPES = new Set(Object.entries(fieldTypeToStore)
32
+ .filter(([, mapping]) => mapping?.storeType === 'text')
33
+ .map(([type]) => type));
24
34
  const USE_AS_PATH_SOURCE_TYPES = new Set([
25
35
  'text',
26
36
  'textArea',
@@ -180,6 +190,9 @@ function validateVirtualFields(collection) {
180
190
  * - A collection may not set both `tree: true` and `orderable: true`. A
181
191
  * document-tree owns ordering per-parent on the tree edge, so
182
192
  * `byline_documents.order_key` is inert for it.
193
+ * - Each `listSearch` entry must name an existing top-level field whose
194
+ * type is persisted to the text store (the admin list-view search box
195
+ * is an `ILIKE` over `store_text`), and the field may not be virtual.
183
196
  * - `virtual` fields must satisfy the constraints in
184
197
  * {@link validateVirtualFields} (optional-or-default, no counters, no
185
198
  * upload fields, not referenced by useAsTitle / useAsPath / search).
@@ -205,6 +218,18 @@ export function validateCollections(collections) {
205
218
  throw new Error(`Collection "${collection.path}" sets \`useAsPath: '${collection.useAsPath}'\` but field "${collection.useAsPath}" has type "${source.type}". Supported source types: ${[...USE_AS_PATH_SOURCE_TYPES].join(', ')}.`);
206
219
  }
207
220
  }
221
+ for (const name of collection.listSearch ?? []) {
222
+ const source = collection.fields.find((f) => 'name' in f && f.name === name);
223
+ if (source == null) {
224
+ throw new Error(`Collection "${collection.path}" names '${name}' in \`listSearch\` but no top-level field with that name exists.`);
225
+ }
226
+ if (!LIST_SEARCH_SOURCE_TYPES.has(source.type)) {
227
+ throw new Error(`Collection "${collection.path}" names '${name}' in \`listSearch\` but field "${name}" has type "${source.type}". The list-view search box matches text-store fields only (${[...LIST_SEARCH_SOURCE_TYPES].join(', ')}).`);
228
+ }
229
+ if (source.virtual === true) {
230
+ throw new Error(`Collection "${collection.path}" names virtual field '${name}' in \`listSearch\` — list-view search reads persisted values, which virtual fields never have.`);
231
+ }
232
+ }
208
233
  if (collection.advertiseLocales === true && !hasLocalizedField(collection.fields)) {
209
234
  throw new Error(`Collection "${collection.path}" sets \`advertiseLocales: true\` but has no localized fields. The available-locales control advertises content locales, which is only meaningful when at least one field is \`localized\`.`);
210
235
  }
@@ -410,4 +410,46 @@ describe('validateCollections', () => {
410
410
  };
411
411
  expect(() => validateCollections([collection])).not.toThrow();
412
412
  });
413
+ it('accepts listSearch naming text-store fields', () => {
414
+ const collection = {
415
+ ...baseCollection,
416
+ listSearch: ['title', 'summary', 'kind'],
417
+ fields: [
418
+ { name: 'title', label: 'Title', type: 'text' },
419
+ { name: 'summary', label: 'Summary', type: 'textArea' },
420
+ {
421
+ name: 'kind',
422
+ label: 'Kind',
423
+ type: 'select',
424
+ options: [{ label: 'A', value: 'a' }],
425
+ },
426
+ ],
427
+ };
428
+ expect(() => validateCollections([collection])).not.toThrow();
429
+ });
430
+ it('rejects a listSearch entry referencing a missing field', () => {
431
+ expect(() => validateCollections([{ ...baseCollection, listSearch: ['nonexistent'] }])).toThrow(/listSearch.*no top-level field/s);
432
+ });
433
+ it('rejects a listSearch entry referencing a non-text-store field', () => {
434
+ const collection = {
435
+ ...baseCollection,
436
+ listSearch: ['count'],
437
+ fields: [
438
+ { name: 'title', label: 'Title', type: 'text' },
439
+ { name: 'count', label: 'Count', type: 'integer' },
440
+ ],
441
+ };
442
+ expect(() => validateCollections([collection])).toThrow(/text-store fields only/);
443
+ });
444
+ it('rejects a listSearch entry referencing a virtual field', () => {
445
+ const collection = {
446
+ ...baseCollection,
447
+ listSearch: ['ephemeral'],
448
+ fields: [
449
+ { name: 'title', label: 'Title', type: 'text' },
450
+ { name: 'ephemeral', label: 'Ephemeral', type: 'text', virtual: true, optional: true },
451
+ ],
452
+ };
453
+ expect(() => validateCollections([collection])).toThrow(/listSearch.*virtual/s);
454
+ });
413
455
  });
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": "3.20.2",
5
+ "version": "3.20.4",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -76,7 +76,7 @@
76
76
  "pino": "^10.3.1",
77
77
  "sharp": "^0.35.3",
78
78
  "zod": "^4.4.3",
79
- "@byline/auth": "3.20.2"
79
+ "@byline/auth": "3.20.4"
80
80
  },
81
81
  "devDependencies": {
82
82
  "@biomejs/biome": "2.5.2",