@elytracms/studio 0.0.73 → 0.0.75

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.
Files changed (50) hide show
  1. package/convex/cms.ts +67 -3
  2. package/convex/schema.ts +9 -0
  3. package/dist/components/app-views/default-modules.js +1 -1
  4. package/dist/components/app-views/default-modules.js.map +1 -1
  5. package/dist/components/cms/cms-labels.d.ts +6 -0
  6. package/dist/components/cms/cms-labels.js +21 -1
  7. package/dist/components/cms/cms-labels.js.map +1 -1
  8. package/dist/components/cms/cms-store.d.ts +33 -1
  9. package/dist/components/cms/cms-store.js +115 -18
  10. package/dist/components/cms/cms-store.js.map +1 -1
  11. package/dist/components/cms/collection-table.js +89 -11
  12. package/dist/components/cms/collection-table.js.map +1 -1
  13. package/dist/components/cms/column-width.d.ts +16 -0
  14. package/dist/components/cms/column-width.js +26 -0
  15. package/dist/components/cms/column-width.js.map +1 -1
  16. package/dist/components/cms/doc-state.d.ts +46 -0
  17. package/dist/components/cms/doc-state.js +47 -1
  18. package/dist/components/cms/doc-state.js.map +1 -1
  19. package/dist/components/cms/versions-model.d.ts +13 -0
  20. package/dist/components/cms/versions-model.js +44 -0
  21. package/dist/components/cms/versions-model.js.map +1 -1
  22. package/dist/components/cms/versions-pane.js +27 -6
  23. package/dist/components/cms/versions-pane.js.map +1 -1
  24. package/dist/components/cms/view-bar.js +16 -6
  25. package/dist/components/cms/view-bar.js.map +1 -1
  26. package/dist/components/collection-view/cms-collection-view-source.js +8 -0
  27. package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
  28. package/dist/lib/cms-views/view-model.d.ts +80 -12
  29. package/dist/lib/cms-views/view-model.js +170 -44
  30. package/dist/lib/cms-views/view-model.js.map +1 -1
  31. package/dist/lib/collection-view/collection-view-source.d.ts +29 -0
  32. package/dist/lib/collection-view/collection-view-source.js +15 -0
  33. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  34. package/dist/lib/i18n/messages.de.js +8 -0
  35. package/dist/lib/i18n/messages.de.js.map +1 -1
  36. package/dist/lib/i18n/messages.en.d.ts +5 -0
  37. package/dist/lib/i18n/messages.en.js +11 -0
  38. package/dist/lib/i18n/messages.en.js.map +1 -1
  39. package/dist/lib/operations/session-hydration.d.ts +19 -0
  40. package/dist/lib/operations/session-hydration.js +9 -0
  41. package/dist/lib/operations/session-hydration.js.map +1 -1
  42. package/dist/lib/persistence/convex-adapter.js +9 -1
  43. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  44. package/dist/ui/index.d.ts +1 -0
  45. package/dist/ui/index.js +1 -0
  46. package/dist/ui/index.js.map +1 -1
  47. package/dist/ui/status-dot.d.ts +50 -0
  48. package/dist/ui/status-dot.js +38 -0
  49. package/dist/ui/status-dot.js.map +1 -0
  50. package/package.json +2 -2
@@ -4,12 +4,12 @@ import { createCollectionRegistry, createDocumentStore, documentKey, documentSch
4
4
  import { translate } from '@elytracms/studio/lib/i18n';
5
5
  import { configureStudioLocales, useStudioLocale } from '@elytracms/studio/lib/locale';
6
6
  import { studioManifestLookup } from '@elytracms/studio/lib/registry';
7
- import { resolveDeploymentCollections } from '@elytracms/studio/lib/workspace';
7
+ import { resolveDeploymentCollections, resolveDeploymentRoutes } from '@elytracms/studio/lib/workspace';
8
8
  import { validateComposition, validateRichTextEmbeds } from './cms-composition-validators.js';
9
9
  import { matchesRef, removeDoc, replaceDoc, upsertDoc } from './document-ref-utils.js';
10
10
  import { createRequiredContext } from '@elytracms/studio/lib/required-context';
11
11
  import { createCmsPersister, getStudioOperations } from '@elytracms/studio/lib/operations';
12
- import { ALL_VIEW_ID, DEFAULT_VIEW_OVERRIDE_ID, canonicalViewState, configViewIssues, defaultConfigViewId, defaultViewState, deprecatedDefaultFlagViewIds, hasDefaultViewOverride, resolveCollectionViews, viewStatesEqual, } from '@elytracms/studio/lib/cms-views';
12
+ import { ALL_VIEW_ID, DEFAULT_VIEW_OVERRIDE_ID, canonicalViewState, configViewIssues, defaultConfigViewId, defaultViewState, deprecatedDefaultFlagViewIds, hasDefaultViewOverride, pseudoColumns, resolveCollectionViews, viewStatesEqual, } from '@elytracms/studio/lib/cms-views';
13
13
  import { useOptionalAssets } from '../assets/assets-store.js';
14
14
  import { consumeStagedDocuments } from './document-staging.js';
15
15
  import { applyCellValue, commitCellEdit } from './cell-edit.js';
@@ -101,12 +101,12 @@ function warnDeprecatedDefaultFlag(collection) {
101
101
  }
102
102
  }
103
103
  /** The unnamed built-in view: no filters, no sort, every field visible. */
104
- function defaultView(collection) {
104
+ function defaultView(collection, pseudo = []) {
105
105
  return {
106
106
  id: ALL_VIEW_ID,
107
107
  name: translate('cms.list.view.all'),
108
108
  state: collection
109
- ? defaultViewState(collection)
109
+ ? defaultViewState(collection, pseudo)
110
110
  : { filters: [], sort: null, columns: [], hidden: [] },
111
111
  };
112
112
  }
@@ -117,7 +117,7 @@ function defaultView(collection) {
117
117
  * layer (`documents.*` / `schema.*` commands) through {@link createCmsPersister}.
118
118
  * Failed/denied commands surface as {@link CmsStore.persistError}.
119
119
  */
120
- export function CmsProvider({ children, projectId, initialCollections, initialDocuments, initialPublishedKeys, initialLocaleConfig, }) {
120
+ export function CmsProvider({ children, projectId, initialCollections, initialDocuments, initialPublishedKeys, initialTimestamps, initialPublishTimestamps, initialLocaleConfig, }) {
121
121
  const [documents, setDocuments] = useState(() => {
122
122
  const seeded = (initialDocuments ?? sampleDocuments).map((d) => structuredClone(d));
123
123
  // EC-320 (0.0.44 follow-up): merge any documents STAGED by the
@@ -139,6 +139,68 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
139
139
  return new Set(initialPublishedKeys);
140
140
  return new Set(initialDocuments ? [] : samplePublishedKeys);
141
141
  });
142
+ // EC-445: per-document last-write timestamps behind the `$updatedAt` column.
143
+ // Seeded from hydration (connected: the persisted `cmsDocuments.updatedAt`), then
144
+ // re-stamped on the common edit paths. A seeded document with no hydrated timestamp
145
+ // (the playground fixtures / a kit) gets a session-start stamp so the column is
146
+ // never a wall of blanks; a fresh draft is stamped when it is created.
147
+ const [updatedAtByKey, setUpdatedAtByKey] = useState(() => {
148
+ const now = new Date().toISOString();
149
+ const map = new Map();
150
+ if (initialTimestamps) {
151
+ for (const [key, value] of Object.entries(initialTimestamps))
152
+ map.set(key, value);
153
+ }
154
+ for (const doc of initialDocuments ?? sampleDocuments) {
155
+ const key = documentKey(doc);
156
+ if (!map.has(key))
157
+ map.set(key, now);
158
+ }
159
+ return map;
160
+ });
161
+ const stampUpdatedAt = useCallback((ref, iso = new Date().toISOString()) => {
162
+ setUpdatedAtByKey((prev) => {
163
+ const next = new Map(prev);
164
+ next.set(documentKey(ref), iso);
165
+ return next;
166
+ });
167
+ }, []);
168
+ const documentUpdatedAt = useCallback((ref) => updatedAtByKey.get(documentKey(ref)), [updatedAtByKey]);
169
+ // EC-447: per-document FIRST-publish instants behind the `$publishedAt` column.
170
+ // Seeded from hydration (the persisted `cmsDocuments.publishedAt`); UNLIKE
171
+ // `$updatedAt` there is NO session-start fallback — an absent entry is a real "no
172
+ // publish date" (EmDash). The store re-stamps/clears it authoritatively on
173
+ // publish/unpublish (the set-once + clear semantics live in the persistence spine).
174
+ const [publishedAtByKey, setPublishedAtByKey] = useState(() => {
175
+ const map = new Map();
176
+ if (initialPublishTimestamps) {
177
+ for (const [key, value] of Object.entries(initialPublishTimestamps))
178
+ map.set(key, value);
179
+ }
180
+ return map;
181
+ });
182
+ // Adopt the AUTHORITATIVE publish instant from the reconciled record: an ISO value
183
+ // stamps it (set-once is enforced server-side, so this only ever adopts the pinned
184
+ // first-publish value); `undefined` CLEARS it (unpublish). Mirrors `stampUpdatedAt`
185
+ // but supports clearing — the column shows a date iff the doc is currently published.
186
+ const stampPublishedAt = useCallback((ref, iso) => {
187
+ setPublishedAtByKey((prev) => {
188
+ const key = documentKey(ref);
189
+ if (iso === undefined) {
190
+ if (!prev.has(key))
191
+ return prev;
192
+ const next = new Map(prev);
193
+ next.delete(key);
194
+ return next;
195
+ }
196
+ if (prev.get(key) === iso)
197
+ return prev;
198
+ const next = new Map(prev);
199
+ next.set(key, iso);
200
+ return next;
201
+ });
202
+ }, []);
203
+ const documentPublishedAt = useCallback((ref) => publishedAtByKey.get(documentKey(ref)), [publishedAtByKey]);
142
204
  const [activeViews, setActiveViews] = useState({});
143
205
  // The live table state per collection where it diverges from the active
144
206
  // view; absent key = exactly the active view's state (not dirty).
@@ -229,8 +291,9 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
229
291
  // Replace-or-append (upsert): a brand-new document must still land even
230
292
  // though nothing in the session matches its ref yet.
231
293
  setDocuments((prev) => upsertDoc(prev, doc));
294
+ stampUpdatedAt(doc); // EC-445: a save bumps the $updatedAt column
232
295
  persisterRef.current?.upsertDocument(doc);
233
- }, []);
296
+ }, [stampUpdatedAt]);
234
297
  const commitFieldEdit = useCallback((input) => {
235
298
  // Pure map-replace: an edit always targets a document already open in
236
299
  // the session, so a miss (already gone) is a no-op, never an append.
@@ -240,7 +303,12 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
240
303
  registry,
241
304
  documents: documentsRef.current,
242
305
  localeConfig,
243
- applySession: replaceWith,
306
+ // EC-445: the optimistic apply bumps the $updatedAt column (a rollback
307
+ // leaves it — it self-corrects on the next hydration).
308
+ applySession: (doc) => {
309
+ replaceWith(doc);
310
+ stampUpdatedAt(doc);
311
+ },
244
312
  rollbackSession: replaceWith,
245
313
  ...(persister && projectId !== undefined
246
314
  ? {
@@ -254,7 +322,7 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
254
322
  }
255
323
  : {}),
256
324
  }, input);
257
- }, [registry, localeConfig, projectId]);
325
+ }, [registry, localeConfig, projectId, stampUpdatedAt]);
258
326
  // Shared insert sequence (EC-326): existingIds/mint/seedDefaults (via
259
327
  // `buildNewDocument`) + optimistic append + persist, consumed by both
260
328
  // `createDocument` (always inserts) and `ensureSingletonDocument`'s empty
@@ -266,9 +334,10 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
266
334
  const insertNewDocument = useCallback((collectionId, options) => {
267
335
  const doc = buildNewDocument(collectionId, registry, seq, documentsRef.current);
268
336
  setDocuments((prev) => options?.guardExisting && hasDocumentFor(prev, collectionId) ? prev : [...prev, doc]);
337
+ stampUpdatedAt(doc); // EC-445: a fresh draft's $updatedAt starts at creation time
269
338
  persisterRef.current?.upsertDocument(doc);
270
339
  return doc;
271
- }, [registry]);
340
+ }, [registry, stampUpdatedAt]);
272
341
  const createDocument = useCallback((collectionId) => {
273
342
  const doc = insertNewDocument(collectionId);
274
343
  return { collection: collectionId, id: doc.id };
@@ -339,9 +408,18 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
339
408
  return;
340
409
  const { operations } = await getStudioOperations();
341
410
  const record = await operations.documents.get({ projectId, ref });
342
- if (record.ok)
411
+ if (record.ok) {
343
412
  setPublished(ref, record.value.publishedVersion !== undefined);
344
- }, [projectId, setPublished]);
413
+ // EC-445: publish/unpublish patches `updatedAt` — adopt the authoritative
414
+ // value so the $updatedAt column reflects the actual write, not a guess.
415
+ if (record.value.updatedAt)
416
+ stampUpdatedAt(ref, record.value.updatedAt);
417
+ // EC-447: adopt the authoritative `publishedAt` — set-once on the first
418
+ // publish, absent after unpublish. Passing `undefined` CLEARS the column, so
419
+ // an unpublish reverts $publishedAt to EmDash and a republish records fresh.
420
+ stampPublishedAt(ref, record.value.publishedAt);
421
+ }
422
+ }, [projectId, setPublished, stampUpdatedAt, stampPublishedAt]);
345
423
  const publishDocument = useCallback(async (ref) => {
346
424
  setPublished(ref, true); // optimistic
347
425
  // EC-273: no clearing here — the draft is already free of conditionally-hidden
@@ -465,6 +543,17 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
465
543
  }, [registry, documents, localeConfig, assetExists]);
466
544
  const isFieldReadOnly = useCallback((field) => field.form?.readOnly === true, []);
467
545
  // --- Views (EC-246b: config-as-code) --------------------------------------
546
+ // EC-445: the reserved pseudo-columns a CMS collection's list surfaces. Every
547
+ // `cmsDocuments` collection has a publish lifecycle + materialized timestamps
548
+ // ($state + $updatedAt always apply); $url applies only where the deployment
549
+ // resolves a delivery route for it (mirrors the live-URL column's own guard).
550
+ const pseudoColumnsFor = useCallback((collectionId) => {
551
+ const collection = registry.get(collectionId);
552
+ if (!collection)
553
+ return [];
554
+ const routable = resolveDeploymentRoutes().some((route) => route.document?.collection === collectionId);
555
+ return pseudoColumns(collection, { publishing: true, timestamps: true, routable });
556
+ }, [registry]);
468
557
  const viewsOf = useCallback((collectionId) => {
469
558
  // The baseline view plus the collection's config-declared views (EC-246b/EC-444).
470
559
  // Views are PRESENTATION, read live from elytra.config.ts (AD-11) — not the
@@ -476,12 +565,13 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
476
565
  // EC-444: a config view with the reserved `id: 'default'` REPLACES the
477
566
  // synthesized "All entries" baseline verbatim (first tab); absent one, the
478
567
  // synthesized baseline leads. `resolveCollectionViews` owns that selection.
479
- const baseline = defaultView(registry.get(collectionId));
568
+ const pseudo = pseudoColumnsFor(collectionId);
569
+ const baseline = defaultView(registry.get(collectionId), pseudo);
480
570
  const configCollection = resolveDeploymentCollections().find((c) => c.id === collectionId);
481
571
  if (!configCollection)
482
572
  return [baseline];
483
- return resolveCollectionViews(configCollection, baseline);
484
- }, [registry]);
573
+ return resolveCollectionViews(configCollection, baseline, pseudo);
574
+ }, [registry, pseudoColumnsFor]);
485
575
  // The INITIAL active view for a collection (EC-343/EC-444): a config view flagged
486
576
  // `initial: true` seeds the first-load lens; else the baseline — the `id: 'default'`
487
577
  // override when present, else the synthesized "All entries" (`ALL_VIEW_ID`). Only
@@ -502,8 +592,8 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
502
592
  const activeViewOf = useCallback((collectionId) => {
503
593
  const id = activeViews[collectionId] ?? initialViewId(collectionId);
504
594
  return (viewsOf(collectionId).find((view) => view.id === id) ??
505
- defaultView(registry.get(collectionId)));
506
- }, [activeViews, initialViewId, viewsOf, registry]);
595
+ defaultView(registry.get(collectionId), pseudoColumnsFor(collectionId)));
596
+ }, [activeViews, initialViewId, viewsOf, registry, pseudoColumnsFor]);
507
597
  const tableStateOf = useCallback((collectionId) => tableStates[collectionId] ?? activeViewOf(collectionId).state, [tableStates, activeViewOf]);
508
598
  const dropTableState = useCallback((collectionId) => {
509
599
  setTableStates((prev) => {
@@ -633,6 +723,8 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
633
723
  ensureSingletonDocument,
634
724
  deleteDocument,
635
725
  isPublished,
726
+ documentUpdatedAt,
727
+ documentPublishedAt,
636
728
  publishDocument,
637
729
  unpublishDocument,
638
730
  loadDocumentVersions,
@@ -658,8 +750,10 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
658
750
  if (collection === undefined)
659
751
  return !viewStatesEqual(working, active);
660
752
  // Compare schema-resolved forms (EC-213): a column-order reconciliation
661
- // or a stale `hidden` entry must not read as an unsaved edit.
662
- return !viewStatesEqual(canonicalViewState(collection, working), canonicalViewState(collection, active));
753
+ // or a stale `hidden` entry must not read as an unsaved edit. EC-445: the
754
+ // pseudo-column universe must match so a reserved column never reads as a diff.
755
+ const pseudo = pseudoColumnsFor(collectionId);
756
+ return !viewStatesEqual(canonicalViewState(collection, working, pseudo), canonicalViewState(collection, active, pseudo));
663
757
  },
664
758
  resetTableState: dropTableState,
665
759
  runBulk,
@@ -683,6 +777,8 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
683
777
  ensureSingletonDocument,
684
778
  deleteDocument,
685
779
  isPublished,
780
+ documentUpdatedAt,
781
+ documentPublishedAt,
686
782
  publishDocument,
687
783
  unpublishDocument,
688
784
  loadDocumentVersions,
@@ -700,6 +796,7 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
700
796
  dropTableState,
701
797
  runBulk,
702
798
  registry,
799
+ pseudoColumnsFor,
703
800
  persistError,
704
801
  ]);
705
802
  return _jsx(CmsContext.Provider, { value: store$, children: children });