@elytracms/studio 0.0.72 → 0.0.74
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/components/app-views/default-modules.js +1 -1
- package/dist/components/app-views/default-modules.js.map +1 -1
- package/dist/components/cms/cms-labels.d.ts +6 -0
- package/dist/components/cms/cms-labels.js +21 -1
- package/dist/components/cms/cms-labels.js.map +1 -1
- package/dist/components/cms/cms-store.d.ts +16 -1
- package/dist/components/cms/cms-store.js +114 -31
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/collection-table.js +71 -9
- package/dist/components/cms/collection-table.js.map +1 -1
- package/dist/components/cms/column-width.d.ts +8 -0
- package/dist/components/cms/column-width.js +18 -0
- package/dist/components/cms/column-width.js.map +1 -1
- package/dist/components/cms/versions-model.d.ts +13 -0
- package/dist/components/cms/versions-model.js +44 -0
- package/dist/components/cms/versions-model.js.map +1 -1
- package/dist/components/cms/versions-pane.js +27 -6
- package/dist/components/cms/versions-pane.js.map +1 -1
- package/dist/components/cms/view-bar.js +16 -6
- package/dist/components/cms/view-bar.js.map +1 -1
- package/dist/components/collection-view/cms-collection-view-source.js +7 -0
- package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
- package/dist/lib/cms-views/view-model.d.ts +114 -28
- package/dist/lib/cms-views/view-model.js +262 -71
- package/dist/lib/cms-views/view-model.js.map +1 -1
- package/dist/lib/collection-view/collection-view-source.d.ts +28 -0
- package/dist/lib/collection-view/collection-view-source.js +15 -0
- package/dist/lib/collection-view/collection-view-source.js.map +1 -1
- package/dist/lib/i18n/messages.de.js +5 -1
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +3 -0
- package/dist/lib/i18n/messages.en.js +5 -1
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/operations/session-hydration.d.ts +8 -0
- package/dist/lib/operations/session-hydration.js +3 -0
- package/dist/lib/operations/session-hydration.js.map +1 -1
- package/dist/ui/index.d.ts +1 -0
- package/dist/ui/index.js +1 -0
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/status-dot.d.ts +39 -0
- package/dist/ui/status-dot.js +36 -0
- package/dist/ui/status-dot.js.map +1 -0
- package/package.json +2 -2
|
@@ -55,6 +55,59 @@ export const tableViewStateSchema = z.object({
|
|
|
55
55
|
*/
|
|
56
56
|
export const STATE_FIELD = '$state';
|
|
57
57
|
const STATE_VALUES = ['draft', 'published'];
|
|
58
|
+
// --- Reserved pseudo-COLUMNS (EC-445) ------------------------------------------
|
|
59
|
+
/**
|
|
60
|
+
* Reserved pseudo-COLUMN keys — the columns-axis siblings of the filter-axis
|
|
61
|
+
* {@link STATE_FIELD}. `$`-prefixed so they can never collide with a schema field
|
|
62
|
+
* name; they participate in a view's `columns` list AND the Display picker exactly
|
|
63
|
+
* like a schema field (orderable / hideable / includable), but they are NOT drawn
|
|
64
|
+
* from `collection.fields` — the column UNIVERSE is `[...schemaFields, ...pseudoColumns]`.
|
|
65
|
+
*
|
|
66
|
+
* - `$state` — publication status (already a filter pseudo-field; now a column too).
|
|
67
|
+
* - `$url` — the derived delivery URL (routable collections only; NON-sortable).
|
|
68
|
+
* - `$updatedAt` — the record's last-write timestamp.
|
|
69
|
+
* - `$publishedAt` — when the live version was published (opt-in, HIDDEN by default).
|
|
70
|
+
* RESERVED: its data path (materialize `publishedAt`) is an EC-445 follow-up, so it
|
|
71
|
+
* is not yet emitted by {@link pseudoColumns}; the constant + sort support ship now.
|
|
72
|
+
*/
|
|
73
|
+
export const URL_FIELD = '$url';
|
|
74
|
+
export const PUBLISHED_AT_FIELD = '$publishedAt';
|
|
75
|
+
export const UPDATED_AT_FIELD = '$updatedAt';
|
|
76
|
+
/** Every reserved pseudo-column key (the columns-axis `$state` included). */
|
|
77
|
+
const PSEUDO_COLUMN_FIELDS = [
|
|
78
|
+
STATE_FIELD,
|
|
79
|
+
URL_FIELD,
|
|
80
|
+
PUBLISHED_AT_FIELD,
|
|
81
|
+
UPDATED_AT_FIELD,
|
|
82
|
+
];
|
|
83
|
+
/** Is `name` a reserved pseudo-column (a `$`-key, not a schema field)? */
|
|
84
|
+
export function isPseudoColumn(name) {
|
|
85
|
+
return PSEUDO_COLUMN_FIELDS.includes(name);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Pseudo-columns HIDDEN by default (opt-in): the extra publish timestamp. Everything
|
|
89
|
+
* else applicable (`$updatedAt`/`$state`/`$url`) is on by default (owner, 2026-07-17).
|
|
90
|
+
*/
|
|
91
|
+
const DEFAULT_HIDDEN_PSEUDO_COLUMNS = new Set([PUBLISHED_AT_FIELD]);
|
|
92
|
+
/**
|
|
93
|
+
* The reserved pseudo-columns that APPLY to a collection, in their default appended
|
|
94
|
+
* order (EC-445): `$updatedAt`, then `$state`, then `$url`. `$url` is gated on
|
|
95
|
+
* `routable` (a non-routable collection has no URL column, not an empty one), `$state`
|
|
96
|
+
* on `publishing` (a lifecycle-less rail shows no status), `$updatedAt` on `timestamps`.
|
|
97
|
+
* `$publishedAt` is reserved but not yet emitted (its data path is an EC-445 follow-up).
|
|
98
|
+
* Pure — a caller (`pseudoColumnsForSource`) supplies the signals from the source.
|
|
99
|
+
*/
|
|
100
|
+
export function pseudoColumns(collection, opts = {}) {
|
|
101
|
+
void collection; // reserved for future per-collection gating; kept for API symmetry
|
|
102
|
+
const cols = [];
|
|
103
|
+
if (opts.timestamps)
|
|
104
|
+
cols.push(UPDATED_AT_FIELD);
|
|
105
|
+
if (opts.publishing)
|
|
106
|
+
cols.push(STATE_FIELD);
|
|
107
|
+
if (opts.routable)
|
|
108
|
+
cols.push(URL_FIELD);
|
|
109
|
+
return cols;
|
|
110
|
+
}
|
|
58
111
|
export const STATE_OPTION = {
|
|
59
112
|
field: STATE_FIELD,
|
|
60
113
|
label: 'State',
|
|
@@ -267,9 +320,17 @@ function rawValueOf(collection, doc, field, localeConfig, isPublished) {
|
|
|
267
320
|
return resolveField(collection, doc, field, locale, localeConfig).value;
|
|
268
321
|
}
|
|
269
322
|
/** The scalar a document exposes for a SORT field, locale-resolved. */
|
|
270
|
-
function scalarOf(collection, doc, field, localeConfig, isPublished) {
|
|
323
|
+
function scalarOf(collection, doc, field, localeConfig, isPublished, meta = {}) {
|
|
271
324
|
if (field === STATE_FIELD)
|
|
272
325
|
return isPublished(doc) ? 'published' : 'draft';
|
|
326
|
+
// EC-445: the pseudo-DATE columns sort by the source's materialized ISO timestamp
|
|
327
|
+
// (ISO strings order lexicographically = chronologically); `$url` is derived → not sortable.
|
|
328
|
+
if (field === UPDATED_AT_FIELD)
|
|
329
|
+
return meta.updatedAt?.(doc) ?? null;
|
|
330
|
+
if (field === PUBLISHED_AT_FIELD)
|
|
331
|
+
return meta.publishedAt?.(doc) ?? null;
|
|
332
|
+
if (field === URL_FIELD)
|
|
333
|
+
return null;
|
|
273
334
|
if (!collection.fields.some((f) => f.name === field))
|
|
274
335
|
return null;
|
|
275
336
|
const locale = documentDefaultLocale(doc, localeConfig);
|
|
@@ -292,10 +353,13 @@ export function clauseMatches(collection, doc, localeConfig, clause, isPublished
|
|
|
292
353
|
* content accessors, the *table* may sort any rendered column — sorting is a
|
|
293
354
|
* presentation concern here; the `filterable` flag governs filters.
|
|
294
355
|
*/
|
|
295
|
-
export function sortViewDocuments(collection, docs, localeConfig, sort, isPublished = defaultIsPublished) {
|
|
356
|
+
export function sortViewDocuments(collection, docs, localeConfig, sort, isPublished = defaultIsPublished, meta = {}) {
|
|
296
357
|
if (sort === null)
|
|
297
358
|
return [...docs];
|
|
298
|
-
const positions = new Map(docs.map((doc) => [
|
|
359
|
+
const positions = new Map(docs.map((doc) => [
|
|
360
|
+
doc.id,
|
|
361
|
+
scalarOf(collection, doc, sort.field, localeConfig, isPublished, meta),
|
|
362
|
+
]));
|
|
299
363
|
return [...docs].sort((a, b) => compareListEntries({ id: a.id, sortValue: positions.get(a.id) ?? null }, { id: b.id, sortValue: positions.get(b.id) ?? null }, sort.dir));
|
|
300
364
|
}
|
|
301
365
|
/**
|
|
@@ -303,18 +367,18 @@ export function sortViewDocuments(collection, docs, localeConfig, sort, isPublis
|
|
|
303
367
|
* ones, sort. Invalid clauses never filter silently — they come back as
|
|
304
368
|
* issues for the UI to surface.
|
|
305
369
|
*/
|
|
306
|
-
export function applyViewState(collection, docs, localeConfig, state, isPublished = defaultIsPublished) {
|
|
370
|
+
export function applyViewState(collection, docs, localeConfig, state, isPublished = defaultIsPublished, meta = {}) {
|
|
307
371
|
const issues = validateClauses(collection, state.filters);
|
|
308
372
|
const invalid = new Set(issues.map((issue) => issue.index));
|
|
309
373
|
const active = state.filters.filter((_, index) => !invalid.has(index));
|
|
310
374
|
const matched = docs.filter((doc) => active.every((clause) => clauseMatches(collection, doc, localeConfig, clause, isPublished)));
|
|
311
375
|
return {
|
|
312
|
-
docs: sortViewDocuments(collection, matched, localeConfig, state.sort, isPublished),
|
|
376
|
+
docs: sortViewDocuments(collection, matched, localeConfig, state.sort, isPublished, meta),
|
|
313
377
|
issues,
|
|
314
378
|
};
|
|
315
379
|
}
|
|
316
380
|
// --- View state construction, equality, columns ----------------------------------
|
|
317
|
-
/** The id of the
|
|
381
|
+
/** The id of the synthesized built-in "All entries" baseline view (EC-444). */
|
|
318
382
|
export const ALL_VIEW_ID = 'all';
|
|
319
383
|
/** The default (unnamed) view: no filters, no sort, every field visible. */
|
|
320
384
|
/**
|
|
@@ -326,37 +390,83 @@ export const ALL_VIEW_ID = 'all';
|
|
|
326
390
|
*/
|
|
327
391
|
const NON_DEFAULT_COLUMN_TYPES = new Set(['blocks', 'richText', 'object']);
|
|
328
392
|
/**
|
|
329
|
-
* Field names OFF the built-in
|
|
330
|
-
* (blocks/richText/object) — always noise in a scalar list
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
393
|
+
* Field names OFF the built-in baseline lens by default: exactly the composite
|
|
394
|
+
* editing surfaces (blocks/richText/object) — always noise in a scalar list. The
|
|
395
|
+
* default lens is "all scalars"; there is no per-field column opt-out any more
|
|
396
|
+
* (EC-444 hard-removed `form.column`). A field stays in the picker one toggle away,
|
|
397
|
+
* and a view curates columns by declaring an explicit `columns` list (or the
|
|
398
|
+
* `id: 'default'` baseline override).
|
|
334
399
|
*/
|
|
335
|
-
function defaultHiddenColumns(collection) {
|
|
336
|
-
|
|
337
|
-
.filter((f) => NON_DEFAULT_COLUMN_TYPES.has(f.type)
|
|
400
|
+
function defaultHiddenColumns(collection, pseudo = []) {
|
|
401
|
+
const composites = collection.fields
|
|
402
|
+
.filter((f) => NON_DEFAULT_COLUMN_TYPES.has(f.type))
|
|
338
403
|
.map((f) => f.name);
|
|
404
|
+
// EC-445: applicable opt-in pseudo-columns ($publishedAt) start hidden; the rest
|
|
405
|
+
// ($updatedAt/$state/$url) are visible by default (owner: keep them on).
|
|
406
|
+
const hiddenPseudo = pseudo.filter((name) => DEFAULT_HIDDEN_PSEUDO_COLUMNS.has(name));
|
|
407
|
+
return [...composites, ...hiddenPseudo];
|
|
339
408
|
}
|
|
340
|
-
export function defaultViewState(collection) {
|
|
409
|
+
export function defaultViewState(collection, pseudo = []) {
|
|
341
410
|
return {
|
|
342
411
|
filters: [],
|
|
343
412
|
sort: null,
|
|
344
|
-
// Every field keeps a slot (order + picker), but the composite surfaces
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
columns: orderedColumnNames(collection),
|
|
348
|
-
hidden: defaultHiddenColumns(collection),
|
|
413
|
+
// Every field keeps a slot (order + picker), but the composite surfaces start
|
|
414
|
+
// hidden so the default lens is the collection's scalar columns (EC-246b/EC-444).
|
|
415
|
+
// EC-445: the applicable reserved pseudo-columns append after the data columns.
|
|
416
|
+
columns: orderedColumnNames(collection, pseudo),
|
|
417
|
+
hidden: defaultHiddenColumns(collection, pseudo),
|
|
349
418
|
};
|
|
350
419
|
}
|
|
351
420
|
/**
|
|
352
|
-
*
|
|
353
|
-
*
|
|
421
|
+
* Resolve an authored VISIBLE column list (EC-444) into the internal two-axis
|
|
422
|
+
* `{columns, hidden}` table state. The authored list is exactly the columns shown,
|
|
423
|
+
* in order; every OTHER schema field is appended (in schema order) and hidden — the
|
|
424
|
+
* same shape {@link defaultViewState} produces, so {@link resolveColumns} renders
|
|
425
|
+
* precisely the authored list, visible, in that order. An omitted (or empty) list
|
|
426
|
+
* inherits the collection's default lens (all scalars visible, composites hidden).
|
|
427
|
+
* Unknown names in the authored list (a field the schema no longer has) are dropped.
|
|
428
|
+
*/
|
|
429
|
+
export function resolveConfigColumns(collection, visible, pseudo = []) {
|
|
430
|
+
if (visible === undefined || visible.length === 0) {
|
|
431
|
+
const base = defaultViewState(collection, pseudo);
|
|
432
|
+
return { columns: base.columns, hidden: base.hidden };
|
|
433
|
+
}
|
|
434
|
+
const universe = orderedColumnNames(collection, pseudo);
|
|
435
|
+
const inUniverse = new Set(universe);
|
|
436
|
+
const seen = new Set();
|
|
437
|
+
const shown = [];
|
|
438
|
+
for (const name of visible) {
|
|
439
|
+
if (inUniverse.has(name) && !seen.has(name)) {
|
|
440
|
+
shown.push(name);
|
|
441
|
+
seen.add(name);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
const rest = universe.filter((name) => !seen.has(name));
|
|
445
|
+
// EC-445: a schema-ONLY authored list (mentioning no pseudo-column) keeps the
|
|
446
|
+
// applicable pseudo-columns at their DEFAULT visibility — so a consumer's
|
|
447
|
+
// pre-EC-445 `columns:[…schema…]` list still shows today's status + url (+ updatedAt).
|
|
448
|
+
// Mentioning ANY pseudo-column switches them to the visible-list rule (only listed shows).
|
|
449
|
+
const mentionsPseudo = visible.some(isPseudoColumn);
|
|
450
|
+
const keepVisible = mentionsPseudo
|
|
451
|
+
? new Set()
|
|
452
|
+
: new Set(pseudo.filter((name) => !DEFAULT_HIDDEN_PSEUDO_COLUMNS.has(name)));
|
|
453
|
+
const hidden = rest.filter((name) => !keepVisible.has(name));
|
|
454
|
+
return { columns: [...shown, ...rest], hidden };
|
|
455
|
+
}
|
|
456
|
+
/** The reserved `views[]` id whose entry OVERRIDES the synthesized baseline view (EC-444). */
|
|
457
|
+
export const DEFAULT_VIEW_OVERRIDE_ID = 'default';
|
|
458
|
+
/**
|
|
459
|
+
* A config-declared view (EC-246b/EC-444) — the flat authoring shape a collection
|
|
460
|
+
* declares in `cms/` config: `{ name, initial?, filters?, sort?, columns? }`.
|
|
354
461
|
* Filters/sort/columns sit at the top level (no nested `state` wrapper). The
|
|
355
462
|
* studio validates + interprets these; `cms-core` stores them opaquely (it
|
|
356
463
|
* can't reference the filter vocabulary without a layering cycle).
|
|
357
464
|
*/
|
|
358
465
|
export const configViewSchema = z.object({
|
|
359
|
-
/**
|
|
466
|
+
/**
|
|
467
|
+
* Optional stable id; defaults to a slug of `name`. The reserved value
|
|
468
|
+
* {@link DEFAULT_VIEW_OVERRIDE_ID} (`'default'`) marks the baseline-override entry.
|
|
469
|
+
*/
|
|
360
470
|
id: z.string().min(1).optional(),
|
|
361
471
|
/**
|
|
362
472
|
* View label; localizable (EC-343): a plain string or a `{ locale }` map, resolved
|
|
@@ -364,17 +474,26 @@ export const configViewSchema = z.object({
|
|
|
364
474
|
*/
|
|
365
475
|
name: localizedStringSchema,
|
|
366
476
|
/**
|
|
367
|
-
* When true this view seeds the collection's INITIAL active view (EC-343) —
|
|
368
|
-
* curated lens an editor lands on before picking one. At most one per collection;
|
|
369
|
-
* a duplicate is a visible validation issue ({@link configViewIssues}), never
|
|
370
|
-
*
|
|
477
|
+
* When true this view seeds the collection's INITIAL active view (EC-343/EC-444) —
|
|
478
|
+
* the curated lens an editor lands on before picking one. At most one per collection;
|
|
479
|
+
* a duplicate is a visible validation issue ({@link configViewIssues}), never silent.
|
|
480
|
+
* The baseline lens stays available and stays the fallback.
|
|
481
|
+
*/
|
|
482
|
+
initial: z.boolean().optional(),
|
|
483
|
+
/**
|
|
484
|
+
* @deprecated EC-444 — renamed to {@link ConfigView.initial}. Still read as an alias
|
|
485
|
+
* for one release (`initial ?? default`) with a console warning; removed next release.
|
|
486
|
+
* Distinct from a field's default VALUE and from the reserved `id: 'default'` override.
|
|
371
487
|
*/
|
|
372
488
|
default: z.boolean().optional(),
|
|
373
489
|
filters: z.array(filterClauseSchema).optional(),
|
|
374
490
|
sort: viewSortSchema.nullable().optional(),
|
|
375
|
-
/**
|
|
491
|
+
/**
|
|
492
|
+
* The single ordered list of VISIBLE columns (EC-444): what you list is what shows,
|
|
493
|
+
* in that order. Omit to inherit the collection's default columns (all scalars). The
|
|
494
|
+
* two-axis `{columns, hidden}` internal state is derived by {@link resolveConfigColumns}.
|
|
495
|
+
*/
|
|
376
496
|
columns: z.array(z.string().min(1)).optional(),
|
|
377
|
-
hidden: z.array(z.string().min(1)).optional(),
|
|
378
497
|
});
|
|
379
498
|
/**
|
|
380
499
|
* Slug used to derive a config view's stable id from its name. A localized name
|
|
@@ -397,11 +516,10 @@ function configViewId(name, index) {
|
|
|
397
516
|
* column. Config views are shared and change only in
|
|
398
517
|
* code — the runtime never mutates them.
|
|
399
518
|
*/
|
|
400
|
-
export function configViewsFor(collection) {
|
|
519
|
+
export function configViewsFor(collection, pseudo = []) {
|
|
401
520
|
const declared = collection.views;
|
|
402
521
|
if (!Array.isArray(declared))
|
|
403
522
|
return [];
|
|
404
|
-
const base = defaultViewState(collection);
|
|
405
523
|
const seen = new Set();
|
|
406
524
|
const resolved = [];
|
|
407
525
|
declared.forEach((raw, index) => {
|
|
@@ -413,46 +531,100 @@ export function configViewsFor(collection) {
|
|
|
413
531
|
if (seen.has(id))
|
|
414
532
|
return;
|
|
415
533
|
seen.add(id);
|
|
534
|
+
// EC-444: `initial` supersedes the deprecated `default` alias (one-release window).
|
|
535
|
+
const initial = view.initial ?? view.default;
|
|
536
|
+
const { columns, hidden } = resolveConfigColumns(collection, view.columns, pseudo);
|
|
416
537
|
resolved.push({
|
|
417
538
|
id,
|
|
418
539
|
name: view.name,
|
|
419
|
-
...(
|
|
540
|
+
...(initial ? { initial: true } : {}),
|
|
420
541
|
state: normalizeViewState({
|
|
421
542
|
filters: view.filters ?? [],
|
|
422
543
|
sort: view.sort ?? null,
|
|
423
|
-
columns
|
|
424
|
-
hidden
|
|
544
|
+
columns,
|
|
545
|
+
hidden,
|
|
425
546
|
}),
|
|
426
547
|
});
|
|
427
548
|
});
|
|
428
549
|
return resolved;
|
|
429
550
|
}
|
|
430
551
|
/**
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
|
|
435
|
-
|
|
552
|
+
* Does the collection declare a baseline-override view (a config view whose id is
|
|
553
|
+
* {@link DEFAULT_VIEW_OVERRIDE_ID})? When true the synthesized "All entries" lens is
|
|
554
|
+
* replaced by that entry verbatim (see {@link resolveCollectionViews}), EC-444.
|
|
555
|
+
*/
|
|
556
|
+
export function hasDefaultViewOverride(collection) {
|
|
557
|
+
return configViewsFor(collection).some((view) => view.id === DEFAULT_VIEW_OVERRIDE_ID);
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* The ordered view list a collection surfaces (EC-444): the baseline first, then the
|
|
561
|
+
* config-declared views in declaration order. The baseline is either an authored
|
|
562
|
+
* OVERRIDE (the config view whose id is {@link DEFAULT_VIEW_OVERRIDE_ID}, used
|
|
563
|
+
* VERBATIM and NOT repeated among the trailing views) or, absent one, the
|
|
564
|
+
* `synthesizedBaseline` the caller passes (the studio's translated "All entries"
|
|
565
|
+
* lens). Pure — the store supplies the synthesized baseline (i18n-bound `name`).
|
|
566
|
+
*/
|
|
567
|
+
export function resolveCollectionViews(collection, synthesizedBaseline, pseudo = []) {
|
|
568
|
+
const configViews = configViewsFor(collection, pseudo);
|
|
569
|
+
const overrideIndex = configViews.findIndex((view) => view.id === DEFAULT_VIEW_OVERRIDE_ID);
|
|
570
|
+
if (overrideIndex < 0)
|
|
571
|
+
return [synthesizedBaseline, ...configViews];
|
|
572
|
+
const override = configViews[overrideIndex];
|
|
573
|
+
const rest = configViews.filter((_, index) => index !== overrideIndex);
|
|
574
|
+
return [override, ...rest];
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* The ids of config views that still use the DEPRECATED `default` flag without the
|
|
578
|
+
* new `initial` (EC-444). Drives the one-release deprecation warning (mirrors the
|
|
579
|
+
* duplicate-default channel); empty once every view has migrated to `initial`.
|
|
580
|
+
*/
|
|
581
|
+
export function deprecatedDefaultFlagViewIds(collection) {
|
|
582
|
+
const declared = collection.views;
|
|
583
|
+
if (!Array.isArray(declared))
|
|
584
|
+
return [];
|
|
585
|
+
const seen = new Set();
|
|
586
|
+
const ids = [];
|
|
587
|
+
declared.forEach((raw, index) => {
|
|
588
|
+
const parsed = configViewSchema.safeParse(raw);
|
|
589
|
+
if (!parsed.success)
|
|
590
|
+
return;
|
|
591
|
+
const view = parsed.data;
|
|
592
|
+
const id = view.id ?? configViewId(view.name, index);
|
|
593
|
+
if (seen.has(id))
|
|
594
|
+
return;
|
|
595
|
+
seen.add(id);
|
|
596
|
+
if (view.default !== undefined && view.initial === undefined)
|
|
597
|
+
ids.push(id);
|
|
598
|
+
});
|
|
599
|
+
return ids;
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* The id of the config view flagged `initial: true` (EC-343/EC-444; `default` read
|
|
603
|
+
* as a deprecated alias) — the seed for a collection's INITIAL active view. When
|
|
604
|
+
* several are flagged (a misconfiguration, reported by {@link configViewIssues}) the
|
|
605
|
+
* FIRST in declaration order wins deterministically; when none is flagged the caller
|
|
606
|
+
* falls back to the baseline lens. `undefined` when the collection flags none.
|
|
436
607
|
*/
|
|
437
608
|
export function defaultConfigViewId(collection) {
|
|
438
|
-
return configViewsFor(collection).find((view) => view.
|
|
609
|
+
return configViewsFor(collection).find((view) => view.initial)?.id;
|
|
439
610
|
}
|
|
440
611
|
/**
|
|
441
|
-
* Validate a collection's config-declared views (EC-343): at most one may set
|
|
442
|
-
* `
|
|
443
|
-
* never silently swallowed — so the studio can
|
|
444
|
-
* wins deterministically for seeding, per
|
|
445
|
-
* schema-only; the DOM-free counterpart of the
|
|
612
|
+
* Validate a collection's config-declared views (EC-343/EC-444): at most one may set
|
|
613
|
+
* `initial: true` (`default` counted as its deprecated alias). A duplicate is returned
|
|
614
|
+
* as an explicit, structured issue — never silently swallowed — so the studio can
|
|
615
|
+
* surface it (the first still wins deterministically for seeding, per
|
|
616
|
+
* {@link defaultConfigViewId}). Pure and schema-only; the DOM-free counterpart of the
|
|
617
|
+
* "failures are first-class" contract.
|
|
446
618
|
*/
|
|
447
619
|
export function configViewIssues(collection) {
|
|
448
|
-
const
|
|
449
|
-
if (
|
|
620
|
+
const initials = configViewsFor(collection).filter((view) => view.initial);
|
|
621
|
+
if (initials.length <= 1)
|
|
450
622
|
return [];
|
|
451
|
-
const viewIds =
|
|
623
|
+
const viewIds = initials.map((view) => view.id);
|
|
452
624
|
return [
|
|
453
625
|
{
|
|
454
626
|
code: 'duplicate-default',
|
|
455
|
-
message: `Collection "${collection.id}" declares ${
|
|
627
|
+
message: `Collection "${collection.id}" declares ${initials.length} initial views (${viewIds.join(', ')}); at most one view may set \`initial: true\`. Seeding "${viewIds[0]}".`,
|
|
456
628
|
viewIds,
|
|
457
629
|
},
|
|
458
630
|
];
|
|
@@ -463,12 +635,15 @@ export function configViewIssues(collection) {
|
|
|
463
635
|
* selector, treating the first visible column as the title), then the
|
|
464
636
|
* remaining fields in schema order.
|
|
465
637
|
*/
|
|
466
|
-
function orderedColumnNames(collection) {
|
|
638
|
+
function orderedColumnNames(collection, pseudo = []) {
|
|
467
639
|
const names = collection.fields.map((field) => field.name);
|
|
468
640
|
const title = titleFieldOf(collection)?.name;
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
641
|
+
const schemaOrdered = title === undefined || !names.includes(title)
|
|
642
|
+
? names
|
|
643
|
+
: [title, ...names.filter((name) => name !== title)];
|
|
644
|
+
// EC-445: the column UNIVERSE = schema fields, then the applicable reserved
|
|
645
|
+
// pseudo-columns (they are NOT in `collection.fields`).
|
|
646
|
+
return [...schemaOrdered, ...pseudo];
|
|
472
647
|
}
|
|
473
648
|
/** Deep-cloned canonical form (stable key order via the Zod shape). */
|
|
474
649
|
export function normalizeViewState(state) {
|
|
@@ -486,26 +661,40 @@ export function viewStatesEqual(a, b) {
|
|
|
486
661
|
* or hidden" — order is preserved across hide/show because visibility lives in
|
|
487
662
|
* `hidden`, never in the order.
|
|
488
663
|
*/
|
|
489
|
-
export function resolveColumns(collection, state) {
|
|
664
|
+
export function resolveColumns(collection, state, pseudo = []) {
|
|
490
665
|
const schemaNames = collection.fields.map((field) => field.name);
|
|
491
|
-
|
|
666
|
+
// EC-445: the valid universe to KEEP from the saved order is schema fields PLUS the
|
|
667
|
+
// applicable pseudo-columns (which are not in `collection.fields`) — so a seeded
|
|
668
|
+
// `$state`/`$url`/`$updatedAt` is preserved + ordered, never dropped as "unknown".
|
|
669
|
+
const keep = new Set([...schemaNames, ...pseudo]);
|
|
492
670
|
const hidden = new Set(state.hidden);
|
|
493
|
-
|
|
671
|
+
const inState = new Set(state.columns);
|
|
672
|
+
// Keep the saved order, restricted to the live universe and de-duplicated (a
|
|
494
673
|
// corrupted persisted state must never render one field as two columns).
|
|
495
674
|
const ordered = [];
|
|
496
675
|
const seen = new Set();
|
|
497
676
|
for (const name of state.columns) {
|
|
498
|
-
if (
|
|
677
|
+
if (keep.has(name) && !seen.has(name)) {
|
|
499
678
|
ordered.push(name);
|
|
500
679
|
seen.add(name);
|
|
501
680
|
}
|
|
502
681
|
}
|
|
503
|
-
|
|
504
|
-
|
|
682
|
+
// Append universe members the saved order predates: new schema fields (EC-213
|
|
683
|
+
// discovery), then applicable pseudo-columns not yet placed (EC-445).
|
|
684
|
+
const appended = [
|
|
685
|
+
...schemaNames.filter((name) => !seen.has(name)),
|
|
686
|
+
...pseudo.filter((name) => !seen.has(name)),
|
|
687
|
+
];
|
|
688
|
+
return [...ordered, ...appended].map((field) => {
|
|
689
|
+
// A freshly-appended opt-in pseudo-column ($publishedAt) starts hidden; once it
|
|
690
|
+
// is in the saved order (an editor un-hid it) its visibility follows `hidden`.
|
|
691
|
+
const defaultHiddenAppend = DEFAULT_HIDDEN_PSEUDO_COLUMNS.has(field) && !inState.has(field);
|
|
692
|
+
return { field, visible: !hidden.has(field) && !defaultHiddenAppend };
|
|
693
|
+
});
|
|
505
694
|
}
|
|
506
695
|
/** The visible columns of a view, in render order (EC-213). */
|
|
507
|
-
export function visibleColumns(collection, state) {
|
|
508
|
-
return resolveColumns(collection, state)
|
|
696
|
+
export function visibleColumns(collection, state, pseudo = []) {
|
|
697
|
+
return resolveColumns(collection, state, pseudo)
|
|
509
698
|
.filter((column) => column.visible)
|
|
510
699
|
.map((column) => column.field);
|
|
511
700
|
}
|
|
@@ -518,9 +707,11 @@ export function visibleColumns(collection, state) {
|
|
|
518
707
|
* the first visible column, then the first field, then `id`, when no title field
|
|
519
708
|
* is resolvable (e.g. a function title with no backing `title` field).
|
|
520
709
|
*/
|
|
521
|
-
export function primaryColumn(collection, state) {
|
|
710
|
+
export function primaryColumn(collection, state, pseudo = []) {
|
|
522
711
|
return (titleFieldOf(collection)?.name ??
|
|
523
|
-
|
|
712
|
+
// EC-445: a pseudo-column is never the identity column (it has no title value) —
|
|
713
|
+
// fall back to the first visible SCHEMA column.
|
|
714
|
+
visibleColumns(collection, state, pseudo).find((name) => !isPseudoColumn(name)) ??
|
|
524
715
|
collection.fields[0]?.name ??
|
|
525
716
|
'id');
|
|
526
717
|
}
|
|
@@ -530,8 +721,8 @@ export function primaryColumn(collection, state) {
|
|
|
530
721
|
* `hidden` to fields the live schema still has (a column removed from the schema
|
|
531
722
|
* must not leave dead `hidden` cruft that trips dirty-detection / persists).
|
|
532
723
|
*/
|
|
533
|
-
function withResolvedOrder(collection, state) {
|
|
534
|
-
const columns = resolveColumns(collection, state).map((column) => column.field);
|
|
724
|
+
function withResolvedOrder(collection, state, pseudo = []) {
|
|
725
|
+
const columns = resolveColumns(collection, state, pseudo).map((column) => column.field);
|
|
535
726
|
const live = new Set(columns);
|
|
536
727
|
const hidden = state.hidden.filter((name) => live.has(name));
|
|
537
728
|
return { ...state, columns, hidden };
|
|
@@ -543,24 +734,24 @@ function withResolvedOrder(collection, state) {
|
|
|
543
734
|
* reconciliation or a stale `hidden` entry (only a real filter/sort/visibility/
|
|
544
735
|
* order change counts as "unsaved").
|
|
545
736
|
*/
|
|
546
|
-
export function canonicalViewState(collection, state) {
|
|
547
|
-
return normalizeViewState(withResolvedOrder(collection, state));
|
|
737
|
+
export function canonicalViewState(collection, state, pseudo = []) {
|
|
738
|
+
return normalizeViewState(withResolvedOrder(collection, state, pseudo));
|
|
548
739
|
}
|
|
549
740
|
/**
|
|
550
741
|
* Toggle a column's visibility — position is preserved (EC-213): visibility
|
|
551
742
|
* lives in `hidden`, the order in `columns` is untouched. Columns the saved
|
|
552
743
|
* order predates are pinned into `columns` first so their place is stable.
|
|
553
744
|
*/
|
|
554
|
-
export function toggleColumn(collection, state, field) {
|
|
555
|
-
const base = withResolvedOrder(collection, state);
|
|
745
|
+
export function toggleColumn(collection, state, field, pseudo = []) {
|
|
746
|
+
const base = withResolvedOrder(collection, state, pseudo);
|
|
556
747
|
const hidden = base.hidden.includes(field)
|
|
557
748
|
? base.hidden.filter((name) => name !== field)
|
|
558
749
|
: [...base.hidden, field];
|
|
559
750
|
return { ...base, hidden };
|
|
560
751
|
}
|
|
561
752
|
/** Move a column one position left (-1) or right (+1) in the full column order. */
|
|
562
|
-
export function moveColumn(collection, state, field, delta) {
|
|
563
|
-
const base = withResolvedOrder(collection, state);
|
|
753
|
+
export function moveColumn(collection, state, field, delta, pseudo = []) {
|
|
754
|
+
const base = withResolvedOrder(collection, state, pseudo);
|
|
564
755
|
const from = base.columns.indexOf(field);
|
|
565
756
|
const to = from + delta;
|
|
566
757
|
if (from < 0 || to < 0 || to >= base.columns.length)
|