@elytracms/studio 0.0.43 → 0.0.45

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 (104) hide show
  1. package/convex/cms.ts +59 -21
  2. package/convex/content.ts +6 -3
  3. package/convex/delivery.ts +8 -5
  4. package/convex/triggers.ts +8 -1
  5. package/dist/components/app-views/studio-config.d.ts +12 -0
  6. package/dist/components/app-views/studio-config.js.map +1 -1
  7. package/dist/components/assets/assets-collection-view-source.d.ts +13 -1
  8. package/dist/components/assets/assets-collection-view-source.js +42 -51
  9. package/dist/components/assets/assets-collection-view-source.js.map +1 -1
  10. package/dist/components/cms/cms-store.js +9 -11
  11. package/dist/components/cms/cms-store.js.map +1 -1
  12. package/dist/components/cms/detail-editor.js +110 -338
  13. package/dist/components/cms/detail-editor.js.map +1 -1
  14. package/dist/components/cms/document-staging.d.ts +8 -3
  15. package/dist/components/cms/document-staging.js +81 -21
  16. package/dist/components/cms/document-staging.js.map +1 -1
  17. package/dist/components/cms/use-compare-selection.d.ts +27 -0
  18. package/dist/components/cms/use-compare-selection.js +29 -0
  19. package/dist/components/cms/use-compare-selection.js.map +1 -0
  20. package/dist/components/cms/use-discard-controller.d.ts +44 -0
  21. package/dist/components/cms/use-discard-controller.js +51 -0
  22. package/dist/components/cms/use-discard-controller.js.map +1 -0
  23. package/dist/components/cms/use-embed-inspector-host.d.ts +12 -0
  24. package/dist/components/cms/use-embed-inspector-host.js +17 -0
  25. package/dist/components/cms/use-embed-inspector-host.js.map +1 -0
  26. package/dist/components/cms/use-field-focus.d.ts +38 -0
  27. package/dist/components/cms/use-field-focus.js +70 -0
  28. package/dist/components/cms/use-field-focus.js.map +1 -0
  29. package/dist/components/cms/use-live-url.d.ts +41 -0
  30. package/dist/components/cms/use-live-url.js +55 -0
  31. package/dist/components/cms/use-live-url.js.map +1 -0
  32. package/dist/components/cms/use-publish-controller.d.ts +49 -0
  33. package/dist/components/cms/use-publish-controller.js +102 -0
  34. package/dist/components/cms/use-publish-controller.js.map +1 -0
  35. package/dist/components/cms/use-published-perspective.d.ts +67 -0
  36. package/dist/components/cms/use-published-perspective.js +67 -0
  37. package/dist/components/cms/use-published-perspective.js.map +1 -0
  38. package/dist/components/cms/use-restore-version.d.ts +57 -0
  39. package/dist/components/cms/use-restore-version.js +66 -0
  40. package/dist/components/cms/use-restore-version.js.map +1 -0
  41. package/dist/components/cms/use-save-mode.d.ts +35 -0
  42. package/dist/components/cms/use-save-mode.js +54 -0
  43. package/dist/components/cms/use-save-mode.js.map +1 -0
  44. package/dist/components/cms/version-compare.js +13 -16
  45. package/dist/components/cms/version-compare.js.map +1 -1
  46. package/dist/components/cms/versions-pane.js +21 -19
  47. package/dist/components/cms/versions-pane.js.map +1 -1
  48. package/dist/components/components/transfer-dialog.d.ts +11 -6
  49. package/dist/components/components/transfer-dialog.js +98 -25
  50. package/dist/components/components/transfer-dialog.js.map +1 -1
  51. package/dist/components/components/transfer.d.ts +74 -8
  52. package/dist/components/components/transfer.js +87 -2
  53. package/dist/components/components/transfer.js.map +1 -1
  54. package/dist/components/rail-store-status.d.ts +7 -5
  55. package/dist/components/rail-store-status.js +12 -9
  56. package/dist/components/rail-store-status.js.map +1 -1
  57. package/dist/components/rail-view-source.d.ts +111 -0
  58. package/dist/components/rail-view-source.js +139 -0
  59. package/dist/components/rail-view-source.js.map +1 -0
  60. package/dist/components/redirects/redirects-collection-view-source.d.ts +13 -1
  61. package/dist/components/redirects/redirects-collection-view-source.js +42 -70
  62. package/dist/components/redirects/redirects-collection-view-source.js.map +1 -1
  63. package/dist/components/section-state.d.ts +12 -3
  64. package/dist/components/section-state.js +8 -4
  65. package/dist/components/section-state.js.map +1 -1
  66. package/dist/components/users/users-collection-view-source.d.ts +13 -1
  67. package/dist/components/users/users-collection-view-source.js +60 -79
  68. package/dist/components/users/users-collection-view-source.js.map +1 -1
  69. package/dist/lib/ai/anthropic-provider.d.ts +29 -0
  70. package/dist/lib/ai/anthropic-provider.js +59 -0
  71. package/dist/lib/ai/anthropic-provider.js.map +1 -0
  72. package/dist/lib/ai/apply-content.d.ts +9 -0
  73. package/dist/lib/ai/apply-content.js +170 -0
  74. package/dist/lib/ai/apply-content.js.map +1 -0
  75. package/dist/lib/ai/config.d.ts +58 -0
  76. package/dist/lib/ai/config.js +134 -0
  77. package/dist/lib/ai/config.js.map +1 -0
  78. package/dist/lib/ai/fake-provider.d.ts +34 -0
  79. package/dist/lib/ai/fake-provider.js +31 -0
  80. package/dist/lib/ai/fake-provider.js.map +1 -0
  81. package/dist/lib/ai/index.d.ts +22 -0
  82. package/dist/lib/ai/index.js +23 -0
  83. package/dist/lib/ai/index.js.map +1 -0
  84. package/dist/lib/ai/knowledge.d.ts +39 -0
  85. package/dist/lib/ai/knowledge.js +60 -0
  86. package/dist/lib/ai/knowledge.js.map +1 -0
  87. package/dist/lib/ai/provenance.d.ts +20 -0
  88. package/dist/lib/ai/provenance.js +15 -0
  89. package/dist/lib/ai/provenance.js.map +1 -0
  90. package/dist/lib/ai/route.d.ts +24 -0
  91. package/dist/lib/ai/route.js +90 -0
  92. package/dist/lib/ai/route.js.map +1 -0
  93. package/dist/lib/ai/shape.d.ts +35 -0
  94. package/dist/lib/ai/shape.js +113 -0
  95. package/dist/lib/ai/shape.js.map +1 -0
  96. package/dist/lib/ai/types.d.ts +227 -0
  97. package/dist/lib/ai/types.js +2 -0
  98. package/dist/lib/ai/types.js.map +1 -0
  99. package/dist/lib/i18n/messages.en.js +4 -2
  100. package/dist/lib/i18n/messages.en.js.map +1 -1
  101. package/dist/lib/workspace/index.d.ts +9 -0
  102. package/dist/lib/workspace/index.js +11 -0
  103. package/dist/lib/workspace/index.js.map +1 -1
  104. package/package.json +8 -2
@@ -1,26 +1,33 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useMemo } from 'react';
3
- import { FilePlus2 } from 'lucide-react';
2
+ import { useMemo, useState } from 'react';
3
+ import { FilePlus2, FileText, Loader2, Search } from 'lucide-react';
4
4
  import { STUDIO_PROJECT_ROUTE, studioPath, useNavigate } from '@elytracms/studio/router';
5
- import { Button, Code, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Row, Stack, Text, } from '@elytracms/studio/ui';
5
+ import { Button, Code, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Input, Row, Stack, Text, } from '@elytracms/studio/ui';
6
+ import { cn } from '@elytracms/studio/lib/utils';
6
7
  import { localizedLabel, translate } from '@elytracms/studio/lib/i18n';
7
8
  import { getInstantiatedKit } from '@elytracms/studio/lib/starters/kit-session';
8
9
  import { CmsSessionGate } from '../project-session';
9
10
  import { CmsProvider, useCms } from '../cms/cms-store';
10
11
  import { stageDocument } from '../cms/document-staging';
11
12
  import { resolveCmsInitialCollections } from '../app-views/default-modules';
12
- import { buildBlockDocument, collectionsWithBlocks } from './transfer';
13
- import { embedNodeFrom } from './component-preview';
13
+ import { appendToBlockDocument, buildBlockDocument, canAppendTo, collectionsWithBlocks, isComponentAllowed, } from './transfer';
14
+ /** Existing documents offered per target, beyond which the rest are hidden (filter to find them). */
15
+ const MAX_EXISTING_TARGETS = 8;
14
16
  /**
15
- * EC-320 — "Use in document…". Transfers the current component combination (the
16
- * live preview {@link ComponentNode}, edited props and all) into a NEW DRAFT
17
- * document whose blocks field holds it, then opens that document.
17
+ * EC-320 (+ 0.0.44 follow-up) — "Use in document…". Transfers the current
18
+ * component combination (the live preview {@link ComponentNode}, edited props
19
+ * and all) into a document's blocks field, then opens that document. Each
20
+ * eligible collection+field target offers a NEW draft AND its existing
21
+ * documents to append into.
18
22
  *
19
23
  * The dialog mounts a CMS session (so it reuses the store's create/save machinery,
20
24
  * collision-free ids and persistence) only while open. On confirm it builds the
21
- * draft (pure {@link buildBlockDocument}), persists it via `saveDocument`, STAGES
22
- * it for the next CMS mount (the playground handoff, see document-staging), and
23
- * navigates to the document — focused on its blocks field so it opens on the canvas.
25
+ * draft or the append (pure {@link buildBlockDocument} / {@link appendToBlockDocument}),
26
+ * saves it and AWAITS the persisted write landing (belt and braces alongside the
27
+ * STAGE for the next CMS mount, see `document-staging` — both matter: staging
28
+ * covers the very next mount regardless of mode, the await guards any later
29
+ * reload), then navigates to the document — focused on its blocks field so it
30
+ * opens on the canvas.
24
31
  */
25
32
  export function TransferToDocumentDialog({ projectId, manifest, node, open, onOpenChange, }) {
26
33
  return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "max-w-md", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: translate('components.transfer.title', { name: manifest.title ?? manifest.id }) }), _jsx(DialogDescription, { children: translate('components.transfer.description') })] }), open ? (_jsx(CmsSessionGate, { children: (cms) => {
@@ -36,6 +43,13 @@ export function TransferToDocumentDialog({ projectId, manifest, node, open, onOp
36
43
  return (_jsx(CmsProvider, { projectId: projectId, initialCollections: collections, initialDocuments: cms?.documents ?? kit?.documents, initialPublishedKeys: cms?.publishedKeys, children: _jsx(TransferPicker, { projectId: projectId, node: node, onClose: () => onOpenChange(false) }) }));
37
44
  } })) : null] }) }));
38
45
  }
46
+ /** A pending transfer's in-flight target key: `<collectionId>/new` or `<collectionId>/<docId>`. */
47
+ function newTargetKey(target) {
48
+ return `${target.collection.id}/new`;
49
+ }
50
+ function existingTargetKey(target, doc) {
51
+ return `${target.collection.id}/${doc.id}`;
52
+ }
39
53
  /**
40
54
  * Exported for the fresh-Convex-project regression test (bug fix,
41
55
  * transfer-dialog.test.tsx) — `TransferToDocumentDialog` is otherwise its only
@@ -44,27 +58,35 @@ export function TransferToDocumentDialog({ projectId, manifest, node, open, onOp
44
58
  export function TransferPicker({ projectId, node, onClose, }) {
45
59
  const cms = useCms();
46
60
  const navigate = useNavigate();
61
+ const [query, setQuery] = useState('');
62
+ // The in-flight target's key while its save is awaited (disables every row
63
+ // + shows a spinner on the clicked one) — `null` when idle.
64
+ const [pendingKey, setPendingKey] = useState(null);
47
65
  // Read the LIVE store schema (`cms.collections`) rather than re-deriving it
48
66
  // from the same `resolveCmsInitialCollections` result passed to `CmsProvider`
49
67
  // above — `cms.collections` is what the provider actually resolved to
50
68
  // (its OWN `initialCollections ?? sampleFilterableCollections` fallback,
51
69
  // EC-320), so the targets list can never drift from — or reintroduce the
52
70
  // `[]`-suppresses-the-fixture-fallback bug against — what the CMS/Assets
53
- // tabs show for this same session.
54
- const targets = useMemo(() => collectionsWithBlocks(cms.collections), [cms.collections]);
55
- function transfer(target) {
56
- const existingIds = new Set(cms.documentsOf(target.collection.id).map((d) => d.id));
57
- const doc = buildBlockDocument({
58
- collection: target.collection,
59
- blocksField: target.field,
60
- node: embedNodeFrom(node, 'node-1'),
61
- existingIds,
62
- seq: { current: 0 },
63
- });
64
- // Persist through the store (upsert + reference index) and stage for the next
65
- // CMS mount (playground handoff), then open the document on its canvas.
71
+ // tabs show for this same session. Allow-gated (0.0.44 follow-up): a target
72
+ // whose field's `allow` vocabulary rejects this component is never offered,
73
+ // new-document or existing-document alike — the validator would flag it.
74
+ const targets = useMemo(() => collectionsWithBlocks(cms.collections).filter((t) => isComponentAllowed(t.field, node.componentId)), [cms.collections, node.componentId]);
75
+ /** Persist (awaited) + stage + navigate — shared by both transfer paths. */
76
+ async function persistAndOpen(doc, target) {
77
+ // Optimistic session apply + queues the persisted upsert (EC-141's normal
78
+ // fire-and-forget autosave path — unchanged for every OTHER caller).
66
79
  cms.saveDocument(doc);
80
+ // Cross-mount handoff for the very next `CmsProvider` mount, in EVERY mode
81
+ // (see document-staging.ts) — covers the instant after this write, before
82
+ // any later hydration is guaranteed to observe it.
67
83
  stageDocument(projectId, doc);
84
+ // Belt and braces: AWAIT the queued persistence command landing (the same
85
+ // `flushPersistence` publish/unpublish already await) so a reload of the
86
+ // destination editor — not just its very next mount — also finds the
87
+ // document. `saveDocument` itself stays fire-and-forget for every other
88
+ // caller; only this explicit await makes the transfer's save synchronous.
89
+ await cms.flushPersistence();
68
90
  onClose();
69
91
  void navigate({
70
92
  to: STUDIO_PROJECT_ROUTE,
@@ -72,9 +94,60 @@ export function TransferPicker({ projectId, node, onClose, }) {
72
94
  search: { field: `values/${target.field.name}` },
73
95
  });
74
96
  }
97
+ async function transferNew(target) {
98
+ const key = newTargetKey(target);
99
+ setPendingKey(key);
100
+ try {
101
+ const existingIds = new Set(cms.documentsOf(target.collection.id).map((d) => d.id));
102
+ const doc = buildBlockDocument({
103
+ collection: target.collection,
104
+ blocksField: target.field,
105
+ node,
106
+ existingIds,
107
+ seq: { current: 0 },
108
+ });
109
+ await persistAndOpen(doc, target);
110
+ }
111
+ finally {
112
+ setPendingKey(null);
113
+ }
114
+ }
115
+ async function transferInto(target, existing) {
116
+ const key = existingTargetKey(target, existing);
117
+ setPendingKey(key);
118
+ try {
119
+ const doc = appendToBlockDocument({
120
+ collection: target.collection,
121
+ document: existing,
122
+ blocksField: target.field,
123
+ node,
124
+ locale: cms.activeLocale,
125
+ });
126
+ await persistAndOpen(doc, target);
127
+ }
128
+ finally {
129
+ setPendingKey(null);
130
+ }
131
+ }
75
132
  if (targets.length === 0) {
76
133
  return (_jsx(Text, { size: "sm", tone: "muted", "data-testid": "transfer-no-targets", children: translate('components.transfer.noTargets') }));
77
134
  }
78
- return (_jsxs(_Fragment, { children: [_jsx(Stack, { gap: "2", className: "max-h-[50vh] overflow-y-auto", children: targets.map((target) => (_jsxs("button", { type: "button", "data-testid": "transfer-target", onClick: () => transfer(target), className: "flex items-center gap-3 rounded-md border border-border p-3 text-left transition-colors hover:border-primary/40 hover:bg-muted", children: [_jsx("span", { className: "grid size-8 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground", children: _jsx(FilePlus2, { className: "size-4" }) }), _jsxs(Stack, { gap: "px", className: "min-w-0 flex-1", children: [_jsx(Text, { size: "sm", weight: "medium", className: "truncate", children: localizedLabel(target.collection.form?.label, target.collection.id) }), _jsxs(Row, { gap: "1", align: "center", children: [_jsx(Text, { size: "xs", tone: "muted", children: translate('components.transfer.intoField') }), _jsx(Code, { tone: "muted", children: target.field.name })] })] })] }, target.collection.id))) }), _jsx(DialogFooter, { children: _jsx(Button, { type: "button", variant: "ghost", onClick: onClose, children: translate('common.cancel') }) })] }));
135
+ const trimmedQuery = query.trim().toLowerCase();
136
+ const hasAnyExistingDocs = targets.some((t) => cms.documentsOf(t.collection.id).length > 0);
137
+ return (_jsxs(_Fragment, { children: [hasAnyExistingDocs ? (_jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { value: query, onChange: (e) => setQuery(e.target.value), placeholder: translate('common.searchPlaceholder'), className: "pl-8" })] })) : null, _jsx(Stack, { gap: "4", className: "max-h-[50vh] overflow-y-auto", children: targets.map((target) => {
138
+ const existing = cms
139
+ .documentsOf(target.collection.id)
140
+ .filter((doc) => canAppendTo({ collection: target.collection, document: doc, blocksField: target.field, locale: cms.activeLocale }))
141
+ .filter((doc) => trimmedQuery === '' ||
142
+ cms.titleOf({ collection: target.collection.id, id: doc.id }).toLowerCase().includes(trimmedQuery))
143
+ .slice(0, MAX_EXISTING_TARGETS);
144
+ return (_jsxs(Stack, { gap: "2", children: [_jsxs(Stack, { gap: "px", children: [_jsx(Text, { size: "sm", weight: "medium", className: "truncate", children: localizedLabel(target.collection.form?.label, target.collection.id) }), _jsxs(Row, { gap: "1", align: "center", children: [_jsx(Text, { size: "xs", tone: "muted", children: translate('components.transfer.intoField') }), _jsx(Code, { tone: "muted", children: target.field.name })] })] }), _jsxs(Stack, { gap: "1", children: [_jsx(TransferTargetRow, { testId: "transfer-target", icon: _jsx(FilePlus2, { className: "size-4" }), label: translate('components.transfer.newDocument', {
145
+ collection: localizedLabel(target.collection.form?.labelSingular, target.collection.id),
146
+ }), pending: pendingKey === newTargetKey(target), disabled: pendingKey !== null, onClick: () => void transferNew(target) }), existing.length > 0 ? (_jsx(Text, { size: "xs", tone: "muted", className: "px-1 pt-1", children: translate('components.transfer.orExisting') })) : null, existing.map((doc) => (_jsx(TransferTargetRow, { testId: "transfer-target-existing", icon: _jsx(FileText, { className: "size-4" }), label: cms.titleOf({ collection: target.collection.id, id: doc.id }), pending: pendingKey === existingTargetKey(target, doc), disabled: pendingKey !== null, onClick: () => void transferInto(target, doc) }, doc.id)))] })] }, target.collection.id));
147
+ }) }), _jsx(DialogFooter, { children: _jsx(Button, { type: "button", variant: "ghost", onClick: onClose, disabled: pendingKey !== null, children: translate('common.cancel') }) })] }));
148
+ }
149
+ /** One selectable transfer target row — a new draft, or an existing document to append into. */
150
+ function TransferTargetRow({ testId, icon, label, pending, disabled, onClick, }) {
151
+ return (_jsxs("button", { type: "button", "data-testid": testId, disabled: disabled, onClick: onClick, className: cn('flex items-center gap-3 rounded-md border border-border p-3 text-left transition-colors hover:border-primary/40 hover:bg-muted', disabled && 'cursor-not-allowed opacity-60'), children: [_jsx("span", { className: "grid size-8 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground", children: pending ? _jsx(Loader2, { className: "size-4 animate-spin" }) : icon }), _jsx(Text, { size: "sm", weight: "medium", className: "min-w-0 flex-1 truncate", children: label })] }));
79
152
  }
80
153
  //# sourceMappingURL=transfer-dialog.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"transfer-dialog.js","sourceRoot":"","sources":["../../../src/components/components/transfer-dialog.tsx"],"names":[],"mappings":"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","sourcesContent":["import { useMemo } from 'react'\nimport { FilePlus2 } from 'lucide-react'\nimport type { ComponentManifest } from '@elytracms/core/component-registry'\nimport type { ComponentNode } from '@elytracms/core/project-graph'\nimport { STUDIO_PROJECT_ROUTE, studioPath, useNavigate } from '@elytracms/studio/router'\nimport {\n Button,\n Code,\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n Row,\n Stack,\n Text,\n} from '@elytracms/studio/ui'\nimport { localizedLabel, translate } from '@elytracms/studio/lib/i18n'\nimport { getInstantiatedKit } from '@elytracms/studio/lib/starters/kit-session'\nimport { CmsSessionGate } from '../project-session'\nimport { CmsProvider, useCms } from '../cms/cms-store'\nimport { stageDocument } from '../cms/document-staging'\nimport { resolveCmsInitialCollections } from '../app-views/default-modules'\nimport { buildBlockDocument, collectionsWithBlocks } from './transfer'\nimport type { CollectionBlocksTarget } from './transfer'\nimport { embedNodeFrom } from './component-preview'\n\n/**\n * EC-320 — \"Use in document…\". Transfers the current component combination (the\n * live preview {@link ComponentNode}, edited props and all) into a NEW DRAFT\n * document whose blocks field holds it, then opens that document.\n *\n * The dialog mounts a CMS session (so it reuses the store's create/save machinery,\n * collision-free ids and persistence) only while open. On confirm it builds the\n * draft (pure {@link buildBlockDocument}), persists it via `saveDocument`, STAGES\n * it for the next CMS mount (the playground handoff, see document-staging), and\n * navigates to the document — focused on its blocks field so it opens on the canvas.\n */\nexport function TransferToDocumentDialog({\n projectId,\n manifest,\n node,\n open,\n onOpenChange,\n}: {\n projectId: string\n manifest: ComponentManifest\n node: ComponentNode\n open: boolean\n onOpenChange: (open: boolean) => void\n}) {\n return (\n <Dialog open={open} onOpenChange={onOpenChange}>\n <DialogContent className=\"max-w-md\">\n <DialogHeader>\n <DialogTitle>\n {translate('components.transfer.title', { name: manifest.title ?? manifest.id })}\n </DialogTitle>\n <DialogDescription>{translate('components.transfer.description')}</DialogDescription>\n </DialogHeader>\n {open ? (\n <CmsSessionGate>\n {(cms) => {\n const kit = getInstantiatedKit(projectId)\n // Bug fix: this used to reimplement `resolveCmsInitialCollections`'s\n // fallback chain inline with a final `[]` instead of `undefined` — `[]`\n // is \"present\" to `CmsProvider`'s `initialCollections ??\n // sampleFilterableCollections`, so it suppressed the fixture fallback a\n // fresh Convex-mode project (`cms === null`, no kit) needs. Delegating to\n // the shared, tested resolver (also used by the CMS/Assets tabs) keeps\n // this dialog's targets in sync with what those tabs show.\n const collections = resolveCmsInitialCollections(cms, kit)\n return (\n <CmsProvider\n projectId={projectId}\n initialCollections={collections}\n initialDocuments={cms?.documents ?? kit?.documents}\n initialPublishedKeys={cms?.publishedKeys}\n >\n <TransferPicker projectId={projectId} node={node} onClose={() => onOpenChange(false)} />\n </CmsProvider>\n )\n }}\n </CmsSessionGate>\n ) : null}\n </DialogContent>\n </Dialog>\n )\n}\n\n/**\n * Exported for the fresh-Convex-project regression test (bug fix,\n * transfer-dialog.test.tsx) — `TransferToDocumentDialog` is otherwise its only\n * caller.\n */\nexport function TransferPicker({\n projectId,\n node,\n onClose,\n}: {\n projectId: string\n node: ComponentNode\n onClose: () => void\n}) {\n const cms = useCms()\n const navigate = useNavigate()\n // Read the LIVE store schema (`cms.collections`) rather than re-deriving it\n // from the same `resolveCmsInitialCollections` result passed to `CmsProvider`\n // above — `cms.collections` is what the provider actually resolved to\n // (its OWN `initialCollections ?? sampleFilterableCollections` fallback,\n // EC-320), so the targets list can never drift from — or reintroduce the\n // `[]`-suppresses-the-fixture-fallback bug against — what the CMS/Assets\n // tabs show for this same session.\n const targets = useMemo(() => collectionsWithBlocks(cms.collections), [cms.collections])\n\n function transfer(target: CollectionBlocksTarget) {\n const existingIds = new Set(cms.documentsOf(target.collection.id).map((d) => d.id))\n const doc = buildBlockDocument({\n collection: target.collection,\n blocksField: target.field,\n node: embedNodeFrom(node, 'node-1'),\n existingIds,\n seq: { current: 0 },\n })\n // Persist through the store (upsert + reference index) and stage for the next\n // CMS mount (playground handoff), then open the document on its canvas.\n cms.saveDocument(doc)\n stageDocument(projectId, doc)\n onClose()\n void navigate({\n to: STUDIO_PROJECT_ROUTE,\n params: { projectId, _splat: studioPath.cmsDocument(target.collection.id, doc.id) },\n search: { field: `values/${target.field.name}` },\n })\n }\n\n if (targets.length === 0) {\n return (\n <Text size=\"sm\" tone=\"muted\" data-testid=\"transfer-no-targets\">\n {translate('components.transfer.noTargets')}\n </Text>\n )\n }\n\n return (\n <>\n <Stack gap=\"2\" className=\"max-h-[50vh] overflow-y-auto\">\n {targets.map((target) => (\n <button\n key={target.collection.id}\n type=\"button\"\n data-testid=\"transfer-target\"\n onClick={() => transfer(target)}\n className=\"flex items-center gap-3 rounded-md border border-border p-3 text-left transition-colors hover:border-primary/40 hover:bg-muted\"\n >\n <span className=\"grid size-8 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground\">\n <FilePlus2 className=\"size-4\" />\n </span>\n <Stack gap=\"px\" className=\"min-w-0 flex-1\">\n <Text size=\"sm\" weight=\"medium\" className=\"truncate\">\n {localizedLabel(target.collection.form?.label, target.collection.id)}\n </Text>\n <Row gap=\"1\" align=\"center\">\n <Text size=\"xs\" tone=\"muted\">\n {translate('components.transfer.intoField')}\n </Text>\n <Code tone=\"muted\">{target.field.name}</Code>\n </Row>\n </Stack>\n </button>\n ))}\n </Stack>\n <DialogFooter>\n <Button type=\"button\" variant=\"ghost\" onClick={onClose}>\n {translate('common.cancel')}\n </Button>\n </DialogFooter>\n </>\n )\n}\n"]}
1
+ {"version":3,"file":"transfer-dialog.js","sourceRoot":"","sources":["../../../src/components/components/transfer-dialog.tsx"],"names":[],"mappings":"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","sourcesContent":["import { useMemo, useState } from 'react'\nimport { FilePlus2, FileText, Loader2, Search } from 'lucide-react'\nimport type { ReactNode } from 'react'\nimport type { ComponentManifest } from '@elytracms/core/component-registry'\nimport type { CmsDocument } from '@elytracms/core/cms-core'\nimport type { ComponentNode } from '@elytracms/core/project-graph'\nimport { STUDIO_PROJECT_ROUTE, studioPath, useNavigate } from '@elytracms/studio/router'\nimport {\n Button,\n Code,\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n Input,\n Row,\n Stack,\n Text,\n} from '@elytracms/studio/ui'\nimport { cn } from '@elytracms/studio/lib/utils'\nimport { localizedLabel, translate } from '@elytracms/studio/lib/i18n'\nimport { getInstantiatedKit } from '@elytracms/studio/lib/starters/kit-session'\nimport { CmsSessionGate } from '../project-session'\nimport { CmsProvider, useCms } from '../cms/cms-store'\nimport { stageDocument } from '../cms/document-staging'\nimport { resolveCmsInitialCollections } from '../app-views/default-modules'\nimport {\n appendToBlockDocument,\n buildBlockDocument,\n canAppendTo,\n collectionsWithBlocks,\n isComponentAllowed,\n} from './transfer'\nimport type { CollectionBlocksTarget } from './transfer'\n\n/** Existing documents offered per target, beyond which the rest are hidden (filter to find them). */\nconst MAX_EXISTING_TARGETS = 8\n\n/**\n * EC-320 (+ 0.0.44 follow-up) — \"Use in document…\". Transfers the current\n * component combination (the live preview {@link ComponentNode}, edited props\n * and all) into a document's blocks field, then opens that document. Each\n * eligible collection+field target offers a NEW draft AND its existing\n * documents to append into.\n *\n * The dialog mounts a CMS session (so it reuses the store's create/save machinery,\n * collision-free ids and persistence) only while open. On confirm it builds the\n * draft or the append (pure {@link buildBlockDocument} / {@link appendToBlockDocument}),\n * saves it and AWAITS the persisted write landing (belt and braces alongside the\n * STAGE for the next CMS mount, see `document-staging` — both matter: staging\n * covers the very next mount regardless of mode, the await guards any later\n * reload), then navigates to the document — focused on its blocks field so it\n * opens on the canvas.\n */\nexport function TransferToDocumentDialog({\n projectId,\n manifest,\n node,\n open,\n onOpenChange,\n}: {\n projectId: string\n manifest: ComponentManifest\n node: ComponentNode\n open: boolean\n onOpenChange: (open: boolean) => void\n}) {\n return (\n <Dialog open={open} onOpenChange={onOpenChange}>\n <DialogContent className=\"max-w-md\">\n <DialogHeader>\n <DialogTitle>\n {translate('components.transfer.title', { name: manifest.title ?? manifest.id })}\n </DialogTitle>\n <DialogDescription>{translate('components.transfer.description')}</DialogDescription>\n </DialogHeader>\n {open ? (\n <CmsSessionGate>\n {(cms) => {\n const kit = getInstantiatedKit(projectId)\n // Bug fix: this used to reimplement `resolveCmsInitialCollections`'s\n // fallback chain inline with a final `[]` instead of `undefined` — `[]`\n // is \"present\" to `CmsProvider`'s `initialCollections ??\n // sampleFilterableCollections`, so it suppressed the fixture fallback a\n // fresh Convex-mode project (`cms === null`, no kit) needs. Delegating to\n // the shared, tested resolver (also used by the CMS/Assets tabs) keeps\n // this dialog's targets in sync with what those tabs show.\n const collections = resolveCmsInitialCollections(cms, kit)\n return (\n <CmsProvider\n projectId={projectId}\n initialCollections={collections}\n initialDocuments={cms?.documents ?? kit?.documents}\n initialPublishedKeys={cms?.publishedKeys}\n >\n <TransferPicker projectId={projectId} node={node} onClose={() => onOpenChange(false)} />\n </CmsProvider>\n )\n }}\n </CmsSessionGate>\n ) : null}\n </DialogContent>\n </Dialog>\n )\n}\n\n/** A pending transfer's in-flight target key: `<collectionId>/new` or `<collectionId>/<docId>`. */\nfunction newTargetKey(target: CollectionBlocksTarget): string {\n return `${target.collection.id}/new`\n}\nfunction existingTargetKey(target: CollectionBlocksTarget, doc: CmsDocument): string {\n return `${target.collection.id}/${doc.id}`\n}\n\n/**\n * Exported for the fresh-Convex-project regression test (bug fix,\n * transfer-dialog.test.tsx) — `TransferToDocumentDialog` is otherwise its only\n * caller.\n */\nexport function TransferPicker({\n projectId,\n node,\n onClose,\n}: {\n projectId: string\n node: ComponentNode\n onClose: () => void\n}) {\n const cms = useCms()\n const navigate = useNavigate()\n const [query, setQuery] = useState('')\n // The in-flight target's key while its save is awaited (disables every row\n // + shows a spinner on the clicked one) — `null` when idle.\n const [pendingKey, setPendingKey] = useState<string | null>(null)\n\n // Read the LIVE store schema (`cms.collections`) rather than re-deriving it\n // from the same `resolveCmsInitialCollections` result passed to `CmsProvider`\n // above — `cms.collections` is what the provider actually resolved to\n // (its OWN `initialCollections ?? sampleFilterableCollections` fallback,\n // EC-320), so the targets list can never drift from — or reintroduce the\n // `[]`-suppresses-the-fixture-fallback bug against — what the CMS/Assets\n // tabs show for this same session. Allow-gated (0.0.44 follow-up): a target\n // whose field's `allow` vocabulary rejects this component is never offered,\n // new-document or existing-document alike — the validator would flag it.\n const targets = useMemo(\n () => collectionsWithBlocks(cms.collections).filter((t) => isComponentAllowed(t.field, node.componentId)),\n [cms.collections, node.componentId],\n )\n\n /** Persist (awaited) + stage + navigate — shared by both transfer paths. */\n async function persistAndOpen(doc: CmsDocument, target: CollectionBlocksTarget) {\n // Optimistic session apply + queues the persisted upsert (EC-141's normal\n // fire-and-forget autosave path — unchanged for every OTHER caller).\n cms.saveDocument(doc)\n // Cross-mount handoff for the very next `CmsProvider` mount, in EVERY mode\n // (see document-staging.ts) — covers the instant after this write, before\n // any later hydration is guaranteed to observe it.\n stageDocument(projectId, doc)\n // Belt and braces: AWAIT the queued persistence command landing (the same\n // `flushPersistence` publish/unpublish already await) so a reload of the\n // destination editor — not just its very next mount — also finds the\n // document. `saveDocument` itself stays fire-and-forget for every other\n // caller; only this explicit await makes the transfer's save synchronous.\n await cms.flushPersistence()\n onClose()\n void navigate({\n to: STUDIO_PROJECT_ROUTE,\n params: { projectId, _splat: studioPath.cmsDocument(target.collection.id, doc.id) },\n search: { field: `values/${target.field.name}` },\n })\n }\n\n async function transferNew(target: CollectionBlocksTarget) {\n const key = newTargetKey(target)\n setPendingKey(key)\n try {\n const existingIds = new Set(cms.documentsOf(target.collection.id).map((d) => d.id))\n const doc = buildBlockDocument({\n collection: target.collection,\n blocksField: target.field,\n node,\n existingIds,\n seq: { current: 0 },\n })\n await persistAndOpen(doc, target)\n } finally {\n setPendingKey(null)\n }\n }\n\n async function transferInto(target: CollectionBlocksTarget, existing: CmsDocument) {\n const key = existingTargetKey(target, existing)\n setPendingKey(key)\n try {\n const doc = appendToBlockDocument({\n collection: target.collection,\n document: existing,\n blocksField: target.field,\n node,\n locale: cms.activeLocale,\n })\n await persistAndOpen(doc, target)\n } finally {\n setPendingKey(null)\n }\n }\n\n if (targets.length === 0) {\n return (\n <Text size=\"sm\" tone=\"muted\" data-testid=\"transfer-no-targets\">\n {translate('components.transfer.noTargets')}\n </Text>\n )\n }\n\n const trimmedQuery = query.trim().toLowerCase()\n const hasAnyExistingDocs = targets.some((t) => cms.documentsOf(t.collection.id).length > 0)\n\n return (\n <>\n {hasAnyExistingDocs ? (\n <div className=\"relative\">\n <Search className=\"pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground\" />\n <Input\n value={query}\n onChange={(e) => setQuery(e.target.value)}\n placeholder={translate('common.searchPlaceholder')}\n className=\"pl-8\"\n />\n </div>\n ) : null}\n <Stack gap=\"4\" className=\"max-h-[50vh] overflow-y-auto\">\n {targets.map((target) => {\n const existing = cms\n .documentsOf(target.collection.id)\n .filter((doc) => canAppendTo({ collection: target.collection, document: doc, blocksField: target.field, locale: cms.activeLocale }))\n .filter(\n (doc) =>\n trimmedQuery === '' ||\n cms.titleOf({ collection: target.collection.id, id: doc.id }).toLowerCase().includes(trimmedQuery),\n )\n .slice(0, MAX_EXISTING_TARGETS)\n\n return (\n <Stack key={target.collection.id} gap=\"2\">\n <Stack gap=\"px\">\n <Text size=\"sm\" weight=\"medium\" className=\"truncate\">\n {localizedLabel(target.collection.form?.label, target.collection.id)}\n </Text>\n <Row gap=\"1\" align=\"center\">\n <Text size=\"xs\" tone=\"muted\">\n {translate('components.transfer.intoField')}\n </Text>\n <Code tone=\"muted\">{target.field.name}</Code>\n </Row>\n </Stack>\n <Stack gap=\"1\">\n <TransferTargetRow\n testId=\"transfer-target\"\n icon={<FilePlus2 className=\"size-4\" />}\n label={translate('components.transfer.newDocument', {\n collection: localizedLabel(target.collection.form?.labelSingular, target.collection.id),\n })}\n pending={pendingKey === newTargetKey(target)}\n disabled={pendingKey !== null}\n onClick={() => void transferNew(target)}\n />\n {existing.length > 0 ? (\n <Text size=\"xs\" tone=\"muted\" className=\"px-1 pt-1\">\n {translate('components.transfer.orExisting')}\n </Text>\n ) : null}\n {existing.map((doc) => (\n <TransferTargetRow\n key={doc.id}\n testId=\"transfer-target-existing\"\n icon={<FileText className=\"size-4\" />}\n label={cms.titleOf({ collection: target.collection.id, id: doc.id })}\n pending={pendingKey === existingTargetKey(target, doc)}\n disabled={pendingKey !== null}\n onClick={() => void transferInto(target, doc)}\n />\n ))}\n </Stack>\n </Stack>\n )\n })}\n </Stack>\n <DialogFooter>\n <Button type=\"button\" variant=\"ghost\" onClick={onClose} disabled={pendingKey !== null}>\n {translate('common.cancel')}\n </Button>\n </DialogFooter>\n </>\n )\n}\n\n/** One selectable transfer target row — a new draft, or an existing document to append into. */\nfunction TransferTargetRow({\n testId,\n icon,\n label,\n pending,\n disabled,\n onClick,\n}: {\n testId: string\n icon: ReactNode\n label: string\n pending: boolean\n disabled: boolean\n onClick: () => void\n}) {\n return (\n <button\n type=\"button\"\n data-testid={testId}\n disabled={disabled}\n onClick={onClick}\n className={cn(\n 'flex items-center gap-3 rounded-md border border-border p-3 text-left transition-colors hover:border-primary/40 hover:bg-muted',\n disabled && 'cursor-not-allowed opacity-60',\n )}\n >\n <span className=\"grid size-8 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground\">\n {pending ? <Loader2 className=\"size-4 animate-spin\" /> : icon}\n </span>\n <Text size=\"sm\" weight=\"medium\" className=\"min-w-0 flex-1 truncate\">\n {label}\n </Text>\n </button>\n )\n}\n"]}
@@ -1,28 +1,44 @@
1
- import type { CmsDocument, CollectionDef, FieldDef } from '@elytracms/core/cms-core';
1
+ import type { BlocksFieldDef, CmsDocument, CollectionDef, Locale } from '@elytracms/core/cms-core';
2
2
  import type { ComponentNode } from '@elytracms/core/project-graph';
3
3
  /**
4
- * EC-320 — the "design guide → real content" bridge. A component combination the
5
- * user has arranged in the Components view (a manifest + edited prop values,
6
- * captured as a {@link ComponentNode}) is transferred into a NEW DRAFT document
7
- * whose blocks/composition field holds that node. Pure so the id-minting and
8
- * embedding are unit-testable; the React dialog owns the picker, persistence, and
4
+ * EC-320 (+ 0.0.44 follow-up) — the "design guide → real content" bridge. A
5
+ * component combination the user has arranged in the Components view (a
6
+ * manifest + edited prop values, captured as a {@link ComponentNode}) is
7
+ * transferred into a document's blocks/composition field — either a NEW DRAFT
8
+ * document created for it ({@link buildBlockDocument}) or an EXISTING
9
+ * document's field, appended alongside whatever it already holds
10
+ * ({@link appendToBlockDocument}). Pure so id-minting/embedding/appending are
11
+ * unit-testable; the React dialog owns the picker, persistence, and
9
12
  * navigation.
10
13
  */
11
14
  export interface CollectionBlocksTarget {
12
15
  collection: CollectionDef;
13
16
  /** The (first) blocks field the node lands in. */
14
- field: FieldDef;
17
+ field: BlocksFieldDef;
15
18
  }
16
19
  /**
17
20
  * Collections that can receive a transferred combination — those declaring a
18
21
  * `blocks` (composition) field. The first blocks field wins (a collection with a
19
22
  * single canvas is the norm; a second is an unusual edge we don't disambiguate).
23
+ * Callers additionally gate each target through {@link isComponentAllowed} —
24
+ * this only reports which collections HAVE a blocks field, not which ones
25
+ * accept a given component.
20
26
  */
21
27
  export declare function collectionsWithBlocks(collections: readonly CollectionDef[]): CollectionBlocksTarget[];
28
+ /**
29
+ * Is `componentId` permitted as a top-level block of `field`? Mirrors
30
+ * `validateCompositionValue`'s (`@elytracms/core/project-graph`) top-level
31
+ * `allow` rule EXACTLY: an absent or empty list permits any registered
32
+ * component; a non-empty list is the closed vocabulary. A target this
33
+ * predicate accepts can never fail that validator's `forbidden-component`
34
+ * check on save — the transfer dialog uses this to hide targets the
35
+ * component isn't allowed into (new AND existing document targets alike).
36
+ */
37
+ export declare function isComponentAllowed(field: BlocksFieldDef, componentId: string): boolean;
22
38
  export interface BuildBlockDocumentInput {
23
39
  collection: CollectionDef;
24
40
  /** The blocks field the node lands in (from {@link collectionsWithBlocks}). */
25
- blocksField: FieldDef;
41
+ blocksField: BlocksFieldDef;
26
42
  /** The component node — a manifest id + the edited static props (+ slots). */
27
43
  node: ComponentNode;
28
44
  /** Every doc id already in the target collection (collision guard, EC-316). */
@@ -41,3 +57,53 @@ export interface BuildBlockDocumentInput {
41
57
  * composition editor persists and `blockRootsOf` reads it back.
42
58
  */
43
59
  export declare function buildBlockDocument({ collection, blocksField, node, existingIds, seq, }: BuildBlockDocumentInput): CmsDocument;
60
+ export interface CanAppendToInput {
61
+ collection: CollectionDef;
62
+ document: CmsDocument;
63
+ blocksField: BlocksFieldDef;
64
+ /** The locale being edited (EC-176 ambient active locale) for a localized field. */
65
+ locale: Locale;
66
+ }
67
+ /**
68
+ * Can `node` be appended to `document`'s blocks field without destroying
69
+ * existing content? Always true for a `multiple` field (an ordered list has
70
+ * room for one more); a single-root (`multiple: false`) field only while it
71
+ * is empty — occupied, there is no second root to hold it without
72
+ * overwriting the existing block, so the transfer dialog excludes that
73
+ * document from its offered targets rather than risk data loss.
74
+ */
75
+ export declare function canAppendTo({ collection, document, blocksField, locale }: CanAppendToInput): boolean;
76
+ export interface AppendToBlockDocumentInput {
77
+ collection: CollectionDef;
78
+ /** The document to append into (the transfer dialog's "insert into existing document"). */
79
+ document: CmsDocument;
80
+ blocksField: BlocksFieldDef;
81
+ /** The component node to append — not yet re-ided (fresh id minted here). */
82
+ node: ComponentNode;
83
+ /** The locale being edited (EC-176 ambient active locale) for a localized field. */
84
+ locale: Locale;
85
+ }
86
+ /**
87
+ * Append `node` into an EXISTING document's blocks field (EC-320 follow-up,
88
+ * 0.0.44 — "insert into existing document"), leaving everything else about
89
+ * the document untouched: other fields, other locale bundles, and the
90
+ * field's own existing content, which the embed always lands AFTER.
91
+ *
92
+ * Reads the current roots via `blockRootsOf` (`@elytracms/core/project-graph`
93
+ * — the same reader the composition editor, table cell, and reuse-context
94
+ * use), so this reads the field back exactly like every other consumer,
95
+ * undefined-safe for a never-set field. Locale-aware writes go through
96
+ * {@link applyCellValue} (the same locale-write helper inline cell edits use,
97
+ * `cell-edit.ts`) so a localized blocks field's OTHER locale bundles are
98
+ * never touched, only the edited locale's.
99
+ *
100
+ * Node-id collision guard: `node` is re-embedded under a fresh id minted past
101
+ * every existing root's ids ({@link embedFreshNode}), never a fixed `node-1`
102
+ * — a document whose blocks already hold that id would otherwise collide.
103
+ *
104
+ * Throws if `blocksField` is a single-root (`multiple: false`) field that
105
+ * already holds a root — a defensive invariant guard, not a user-facing
106
+ * validation path: the caller must pre-filter candidates with
107
+ * {@link canAppendTo} so this is never reached in that state.
108
+ */
109
+ export declare function appendToBlockDocument({ collection, document, blocksField, node, locale, }: AppendToBlockDocumentInput): CmsDocument;
@@ -1,9 +1,16 @@
1
- import { isBlocksField, seedDefaults } from '@elytracms/core/cms-core';
1
+ import { isBlocksField, isFieldLocalized, seedDefaults } from '@elytracms/core/cms-core';
2
+ import { blockRootsOf, maxNodeIdSeq } from '@elytracms/core/project-graph';
3
+ import { makeNodeIdCounter } from '../builder/builder-store';
4
+ import { applyCellValue } from '../cms/cell-edit';
2
5
  import { mintDocumentId } from '../cms/cms-store';
6
+ import { embedNodeFrom } from './component-preview';
3
7
  /**
4
8
  * Collections that can receive a transferred combination — those declaring a
5
9
  * `blocks` (composition) field. The first blocks field wins (a collection with a
6
10
  * single canvas is the norm; a second is an unusual edge we don't disambiguate).
11
+ * Callers additionally gate each target through {@link isComponentAllowed} —
12
+ * this only reports which collections HAVE a blocks field, not which ones
13
+ * accept a given component.
7
14
  */
8
15
  export function collectionsWithBlocks(collections) {
9
16
  const targets = [];
@@ -14,6 +21,32 @@ export function collectionsWithBlocks(collections) {
14
21
  }
15
22
  return targets;
16
23
  }
24
+ /**
25
+ * Is `componentId` permitted as a top-level block of `field`? Mirrors
26
+ * `validateCompositionValue`'s (`@elytracms/core/project-graph`) top-level
27
+ * `allow` rule EXACTLY: an absent or empty list permits any registered
28
+ * component; a non-empty list is the closed vocabulary. A target this
29
+ * predicate accepts can never fail that validator's `forbidden-component`
30
+ * check on save — the transfer dialog uses this to hide targets the
31
+ * component isn't allowed into (new AND existing document targets alike).
32
+ */
33
+ export function isComponentAllowed(field, componentId) {
34
+ return !field.allow || field.allow.length === 0 || field.allow.includes(componentId);
35
+ }
36
+ /**
37
+ * Mint a fresh `node-<n>` id for `node`, guaranteed not to collide with any id
38
+ * already present anywhere in `existingRoots`. Reuses the SAME mint the
39
+ * canvas reducer and clipboard-paste path share (`maxNodeIdSeq` +
40
+ * `makeNodeIdCounter`, `builder-store.tsx`) rather than a fixed literal: a
41
+ * brand-new document (`existingRoots` empty) mints `node-1` — matching the
42
+ * transfer's prior behavior — but appending into a document whose blocks
43
+ * already hold `node-1..N` mints past that, never re-using an id already in
44
+ * the tree (the EC-320 follow-up collision hazard).
45
+ */
46
+ function embedFreshNode(node, existingRoots) {
47
+ const maxSeq = existingRoots.reduce((max, root) => Math.max(max, maxNodeIdSeq(root)), 0);
48
+ return embedNodeFrom(node, makeNodeIdCounter(maxSeq).genId());
49
+ }
17
50
  /**
18
51
  * Build the draft document that embeds `node` in the collection's blocks field.
19
52
  * Reuses the CMS create machinery: {@link mintDocumentId} for a collision-free id
@@ -25,7 +58,59 @@ export function collectionsWithBlocks(collections) {
25
58
  export function buildBlockDocument({ collection, blocksField, node, existingIds, seq, }) {
26
59
  const id = mintDocumentId(collection.id, seq, existingIds);
27
60
  const values = seedDefaults(collection);
28
- values[blocksField.name] = isBlocksField(blocksField) && blocksField.multiple ? [node] : node;
61
+ const embedded = embedFreshNode(node, []);
62
+ values[blocksField.name] = blocksField.multiple ? [embedded] : embedded;
29
63
  return { collection: collection.id, id, values };
30
64
  }
65
+ /** The blocks field's current value on `document`, respecting locale (EC-018). */
66
+ function readBlocksValue(collection, document, blocksField, locale) {
67
+ return isFieldLocalized(collection, blocksField.name)
68
+ ? document.localized?.[locale]?.[blocksField.name]
69
+ : document.values[blocksField.name];
70
+ }
71
+ /**
72
+ * Can `node` be appended to `document`'s blocks field without destroying
73
+ * existing content? Always true for a `multiple` field (an ordered list has
74
+ * room for one more); a single-root (`multiple: false`) field only while it
75
+ * is empty — occupied, there is no second root to hold it without
76
+ * overwriting the existing block, so the transfer dialog excludes that
77
+ * document from its offered targets rather than risk data loss.
78
+ */
79
+ export function canAppendTo({ collection, document, blocksField, locale }) {
80
+ if (blocksField.multiple)
81
+ return true;
82
+ return blockRootsOf(readBlocksValue(collection, document, blocksField, locale)).length === 0;
83
+ }
84
+ /**
85
+ * Append `node` into an EXISTING document's blocks field (EC-320 follow-up,
86
+ * 0.0.44 — "insert into existing document"), leaving everything else about
87
+ * the document untouched: other fields, other locale bundles, and the
88
+ * field's own existing content, which the embed always lands AFTER.
89
+ *
90
+ * Reads the current roots via `blockRootsOf` (`@elytracms/core/project-graph`
91
+ * — the same reader the composition editor, table cell, and reuse-context
92
+ * use), so this reads the field back exactly like every other consumer,
93
+ * undefined-safe for a never-set field. Locale-aware writes go through
94
+ * {@link applyCellValue} (the same locale-write helper inline cell edits use,
95
+ * `cell-edit.ts`) so a localized blocks field's OTHER locale bundles are
96
+ * never touched, only the edited locale's.
97
+ *
98
+ * Node-id collision guard: `node` is re-embedded under a fresh id minted past
99
+ * every existing root's ids ({@link embedFreshNode}), never a fixed `node-1`
100
+ * — a document whose blocks already hold that id would otherwise collide.
101
+ *
102
+ * Throws if `blocksField` is a single-root (`multiple: false`) field that
103
+ * already holds a root — a defensive invariant guard, not a user-facing
104
+ * validation path: the caller must pre-filter candidates with
105
+ * {@link canAppendTo} so this is never reached in that state.
106
+ */
107
+ export function appendToBlockDocument({ collection, document, blocksField, node, locale, }) {
108
+ const existingRoots = blockRootsOf(readBlocksValue(collection, document, blocksField, locale));
109
+ if (!blocksField.multiple && existingRoots.length > 0) {
110
+ throw new Error(`Cannot append to single-root blocks field "${blocksField.name}": already holds a component (caller must gate with canAppendTo).`);
111
+ }
112
+ const embedded = embedFreshNode(node, existingRoots);
113
+ const nextValue = blocksField.multiple ? [...existingRoots, embedded] : embedded;
114
+ return applyCellValue(collection, document, blocksField.name, locale, nextValue);
115
+ }
31
116
  //# sourceMappingURL=transfer.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"transfer.js","sourceRoot":"","sources":["../../../src/components/components/transfer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAA;AAGtE,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AAiBjD;;;;GAIG;AACH,MAAM,UAAU,qBAAqB,CACnC,WAAqC;IAErC,MAAM,OAAO,GAA6B,EAAE,CAAA;IAC5C,KAAK,MAAM,UAAU,IAAI,WAAW,EAAE,CAAC;QACrC,MAAM,KAAK,GAAG,UAAU,CAAC,MAAM,CAAC,IAAI,CAAC,aAAa,CAAC,CAAA;QACnD,IAAI,KAAK;YAAE,OAAO,CAAC,IAAI,CAAC,EAAE,UAAU,EAAE,KAAK,EAAE,CAAC,CAAA;IAChD,CAAC;IACD,OAAO,OAAO,CAAA;AAChB,CAAC;AAcD;;;;;;;GAOG;AACH,MAAM,UAAU,kBAAkB,CAAC,EACjC,UAAU,EACV,WAAW,EACX,IAAI,EACJ,WAAW,EACX,GAAG,GACqB;IACxB,MAAM,EAAE,GAAG,cAAc,CAAC,UAAU,CAAC,EAAE,EAAE,GAAG,EAAE,WAAW,CAAC,CAAA;IAC1D,MAAM,MAAM,GAAG,YAAY,CAAC,UAAU,CAAC,CAAA;IACvC,MAAM,CAAC,WAAW,CAAC,IAAI,CAAC,GAAG,aAAa,CAAC,WAAW,CAAC,IAAI,WAAW,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;IAC7F,OAAO,EAAE,UAAU,EAAE,UAAU,CAAC,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE,CAAA;AAClD,CAAC","sourcesContent":["import { isBlocksField, seedDefaults } from '@elytracms/core/cms-core'\nimport type { CmsDocument, CollectionDef, FieldDef } from '@elytracms/core/cms-core'\nimport type { ComponentNode } from '@elytracms/core/project-graph'\nimport { mintDocumentId } from '../cms/cms-store'\n\n/**\n * EC-320 — the \"design guide → real content\" bridge. A component combination the\n * user has arranged in the Components view (a manifest + edited prop values,\n * captured as a {@link ComponentNode}) is transferred into a NEW DRAFT document\n * whose blocks/composition field holds that node. Pure so the id-minting and\n * embedding are unit-testable; the React dialog owns the picker, persistence, and\n * navigation.\n */\n\nexport interface CollectionBlocksTarget {\n collection: CollectionDef\n /** The (first) blocks field the node lands in. */\n field: FieldDef\n}\n\n/**\n * Collections that can receive a transferred combination — those declaring a\n * `blocks` (composition) field. The first blocks field wins (a collection with a\n * single canvas is the norm; a second is an unusual edge we don't disambiguate).\n */\nexport function collectionsWithBlocks(\n collections: readonly CollectionDef[],\n): CollectionBlocksTarget[] {\n const targets: CollectionBlocksTarget[] = []\n for (const collection of collections) {\n const field = collection.fields.find(isBlocksField)\n if (field) targets.push({ collection, field })\n }\n return targets\n}\n\nexport interface BuildBlockDocumentInput {\n collection: CollectionDef\n /** The blocks field the node lands in (from {@link collectionsWithBlocks}). */\n blocksField: FieldDef\n /** The component node — a manifest id + the edited static props (+ slots). */\n node: ComponentNode\n /** Every doc id already in the target collection (collision guard, EC-316). */\n existingIds: ReadonlySet<string>\n /** The session id counter (mutated by {@link mintDocumentId}). */\n seq: { current: number }\n}\n\n/**\n * Build the draft document that embeds `node` in the collection's blocks field.\n * Reuses the CMS create machinery: {@link mintDocumentId} for a collision-free id\n * (EC-316) and {@link seedDefaults} so declared field defaults are present (as a\n * normal `+ New` would be). A `multiple` blocks field stores an array; a single\n * (`multiple: false`) blocks field stores the lone node — matching how the\n * composition editor persists and `blockRootsOf` reads it back.\n */\nexport function buildBlockDocument({\n collection,\n blocksField,\n node,\n existingIds,\n seq,\n}: BuildBlockDocumentInput): CmsDocument {\n const id = mintDocumentId(collection.id, seq, existingIds)\n const values = seedDefaults(collection)\n values[blocksField.name] = isBlocksField(blocksField) && blocksField.multiple ? [node] : node\n return { collection: collection.id, id, values }\n}\n"]}
1
+ {"version":3,"file":"transfer.js","sourceRoot":"","sources":["../../../src/components/components/transfer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAA;AAExF,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAA;AAE1E,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAA;AAC5D,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AACjD,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AACjD,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAA;AAoBnD;;;;;;;GAOG;AACH,MAAM,UAAU,qBAAqB,CACnC,WAAqC;IAErC,MAAM,OAAO,GAA6B,EAAE,CAAA;IAC5C,KAAK,MAAM,UAAU,IAAI,WAAW,EAAE,CAAC;QACrC,MAAM,KAAK,GAAG,UAAU,CAAC,MAAM,CAAC,IAAI,CAAC,aAAa,CAAC,CAAA;QACnD,IAAI,KAAK;YAAE,OAAO,CAAC,IAAI,CAAC,EAAE,UAAU,EAAE,KAAK,EAAE,CAAC,CAAA;IAChD,CAAC;IACD,OAAO,OAAO,CAAA;AAChB,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,kBAAkB,CAAC,KAAqB,EAAE,WAAmB;IAC3E,OAAO,CAAC,KAAK,CAAC,KAAK,IAAI,KAAK,CAAC,KAAK,CAAC,MAAM,KAAK,CAAC,IAAI,KAAK,CAAC,KAAK,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAA;AACtF,CAAC;AAED;;;;;;;;;GASG;AACH,SAAS,cAAc,CAAC,IAAmB,EAAE,aAAuC;IAClF,MAAM,MAAM,GAAG,aAAa,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,YAAY,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,CAAA;IACxF,OAAO,aAAa,CAAC,IAAI,EAAE,iBAAiB,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,CAAC,CAAA;AAC/D,CAAC;AAcD;;;;;;;GAOG;AACH,MAAM,UAAU,kBAAkB,CAAC,EACjC,UAAU,EACV,WAAW,EACX,IAAI,EACJ,WAAW,EACX,GAAG,GACqB;IACxB,MAAM,EAAE,GAAG,cAAc,CAAC,UAAU,CAAC,EAAE,EAAE,GAAG,EAAE,WAAW,CAAC,CAAA;IAC1D,MAAM,MAAM,GAAG,YAAY,CAAC,UAAU,CAAC,CAAA;IACvC,MAAM,QAAQ,GAAG,cAAc,CAAC,IAAI,EAAE,EAAE,CAAC,CAAA;IACzC,MAAM,CAAC,WAAW,CAAC,IAAI,CAAC,GAAG,WAAW,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAA;IACvE,OAAO,EAAE,UAAU,EAAE,UAAU,CAAC,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE,CAAA;AAClD,CAAC;AAED,kFAAkF;AAClF,SAAS,eAAe,CACtB,UAAyB,EACzB,QAAqB,EACrB,WAA2B,EAC3B,MAAc;IAEd,OAAO,gBAAgB,CAAC,UAAU,EAAE,WAAW,CAAC,IAAI,CAAC;QACnD,CAAC,CAAC,QAAQ,CAAC,SAAS,EAAE,CAAC,MAAM,CAAC,EAAE,CAAC,WAAW,CAAC,IAAI,CAAC;QAClD,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,WAAW,CAAC,IAAI,CAAC,CAAA;AACvC,CAAC;AAUD;;;;;;;GAOG;AACH,MAAM,UAAU,WAAW,CAAC,EAAE,UAAU,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,EAAoB;IACzF,IAAI,WAAW,CAAC,QAAQ;QAAE,OAAO,IAAI,CAAA;IACrC,OAAO,YAAY,CAAC,eAAe,CAAC,UAAU,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,CAAC,CAAC,CAAC,MAAM,KAAK,CAAC,CAAA;AAC9F,CAAC;AAaD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,UAAU,qBAAqB,CAAC,EACpC,UAAU,EACV,QAAQ,EACR,WAAW,EACX,IAAI,EACJ,MAAM,GACqB;IAC3B,MAAM,aAAa,GAAG,YAAY,CAAC,eAAe,CAAC,UAAU,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,CAAC,CAAC,CAAA;IAC9F,IAAI,CAAC,WAAW,CAAC,QAAQ,IAAI,aAAa,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACtD,MAAM,IAAI,KAAK,CACb,8CAA8C,WAAW,CAAC,IAAI,mEAAmE,CAClI,CAAA;IACH,CAAC;IACD,MAAM,QAAQ,GAAG,cAAc,CAAC,IAAI,EAAE,aAAa,CAAC,CAAA;IACpD,MAAM,SAAS,GAAG,WAAW,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,GAAG,aAAa,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAA;IAChF,OAAO,cAAc,CAAC,UAAU,EAAE,QAAQ,EAAE,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,CAAC,CAAA;AAClF,CAAC","sourcesContent":["import { isBlocksField, isFieldLocalized, seedDefaults } from '@elytracms/core/cms-core'\nimport type { BlocksFieldDef, CmsDocument, CollectionDef, Locale } from '@elytracms/core/cms-core'\nimport { blockRootsOf, maxNodeIdSeq } from '@elytracms/core/project-graph'\nimport type { ComponentNode } from '@elytracms/core/project-graph'\nimport { makeNodeIdCounter } from '../builder/builder-store'\nimport { applyCellValue } from '../cms/cell-edit'\nimport { mintDocumentId } from '../cms/cms-store'\nimport { embedNodeFrom } from './component-preview'\n\n/**\n * EC-320 (+ 0.0.44 follow-up) — the \"design guide → real content\" bridge. A\n * component combination the user has arranged in the Components view (a\n * manifest + edited prop values, captured as a {@link ComponentNode}) is\n * transferred into a document's blocks/composition field — either a NEW DRAFT\n * document created for it ({@link buildBlockDocument}) or an EXISTING\n * document's field, appended alongside whatever it already holds\n * ({@link appendToBlockDocument}). Pure so id-minting/embedding/appending are\n * unit-testable; the React dialog owns the picker, persistence, and\n * navigation.\n */\n\nexport interface CollectionBlocksTarget {\n collection: CollectionDef\n /** The (first) blocks field the node lands in. */\n field: BlocksFieldDef\n}\n\n/**\n * Collections that can receive a transferred combination — those declaring a\n * `blocks` (composition) field. The first blocks field wins (a collection with a\n * single canvas is the norm; a second is an unusual edge we don't disambiguate).\n * Callers additionally gate each target through {@link isComponentAllowed} —\n * this only reports which collections HAVE a blocks field, not which ones\n * accept a given component.\n */\nexport function collectionsWithBlocks(\n collections: readonly CollectionDef[],\n): CollectionBlocksTarget[] {\n const targets: CollectionBlocksTarget[] = []\n for (const collection of collections) {\n const field = collection.fields.find(isBlocksField)\n if (field) targets.push({ collection, field })\n }\n return targets\n}\n\n/**\n * Is `componentId` permitted as a top-level block of `field`? Mirrors\n * `validateCompositionValue`'s (`@elytracms/core/project-graph`) top-level\n * `allow` rule EXACTLY: an absent or empty list permits any registered\n * component; a non-empty list is the closed vocabulary. A target this\n * predicate accepts can never fail that validator's `forbidden-component`\n * check on save — the transfer dialog uses this to hide targets the\n * component isn't allowed into (new AND existing document targets alike).\n */\nexport function isComponentAllowed(field: BlocksFieldDef, componentId: string): boolean {\n return !field.allow || field.allow.length === 0 || field.allow.includes(componentId)\n}\n\n/**\n * Mint a fresh `node-<n>` id for `node`, guaranteed not to collide with any id\n * already present anywhere in `existingRoots`. Reuses the SAME mint the\n * canvas reducer and clipboard-paste path share (`maxNodeIdSeq` +\n * `makeNodeIdCounter`, `builder-store.tsx`) rather than a fixed literal: a\n * brand-new document (`existingRoots` empty) mints `node-1` — matching the\n * transfer's prior behavior — but appending into a document whose blocks\n * already hold `node-1..N` mints past that, never re-using an id already in\n * the tree (the EC-320 follow-up collision hazard).\n */\nfunction embedFreshNode(node: ComponentNode, existingRoots: readonly ComponentNode[]): ComponentNode {\n const maxSeq = existingRoots.reduce((max, root) => Math.max(max, maxNodeIdSeq(root)), 0)\n return embedNodeFrom(node, makeNodeIdCounter(maxSeq).genId())\n}\n\nexport interface BuildBlockDocumentInput {\n collection: CollectionDef\n /** The blocks field the node lands in (from {@link collectionsWithBlocks}). */\n blocksField: BlocksFieldDef\n /** The component node — a manifest id + the edited static props (+ slots). */\n node: ComponentNode\n /** Every doc id already in the target collection (collision guard, EC-316). */\n existingIds: ReadonlySet<string>\n /** The session id counter (mutated by {@link mintDocumentId}). */\n seq: { current: number }\n}\n\n/**\n * Build the draft document that embeds `node` in the collection's blocks field.\n * Reuses the CMS create machinery: {@link mintDocumentId} for a collision-free id\n * (EC-316) and {@link seedDefaults} so declared field defaults are present (as a\n * normal `+ New` would be). A `multiple` blocks field stores an array; a single\n * (`multiple: false`) blocks field stores the lone node — matching how the\n * composition editor persists and `blockRootsOf` reads it back.\n */\nexport function buildBlockDocument({\n collection,\n blocksField,\n node,\n existingIds,\n seq,\n}: BuildBlockDocumentInput): CmsDocument {\n const id = mintDocumentId(collection.id, seq, existingIds)\n const values = seedDefaults(collection)\n const embedded = embedFreshNode(node, [])\n values[blocksField.name] = blocksField.multiple ? [embedded] : embedded\n return { collection: collection.id, id, values }\n}\n\n/** The blocks field's current value on `document`, respecting locale (EC-018). */\nfunction readBlocksValue(\n collection: CollectionDef,\n document: CmsDocument,\n blocksField: BlocksFieldDef,\n locale: Locale,\n): unknown {\n return isFieldLocalized(collection, blocksField.name)\n ? document.localized?.[locale]?.[blocksField.name]\n : document.values[blocksField.name]\n}\n\nexport interface CanAppendToInput {\n collection: CollectionDef\n document: CmsDocument\n blocksField: BlocksFieldDef\n /** The locale being edited (EC-176 ambient active locale) for a localized field. */\n locale: Locale\n}\n\n/**\n * Can `node` be appended to `document`'s blocks field without destroying\n * existing content? Always true for a `multiple` field (an ordered list has\n * room for one more); a single-root (`multiple: false`) field only while it\n * is empty — occupied, there is no second root to hold it without\n * overwriting the existing block, so the transfer dialog excludes that\n * document from its offered targets rather than risk data loss.\n */\nexport function canAppendTo({ collection, document, blocksField, locale }: CanAppendToInput): boolean {\n if (blocksField.multiple) return true\n return blockRootsOf(readBlocksValue(collection, document, blocksField, locale)).length === 0\n}\n\nexport interface AppendToBlockDocumentInput {\n collection: CollectionDef\n /** The document to append into (the transfer dialog's \"insert into existing document\"). */\n document: CmsDocument\n blocksField: BlocksFieldDef\n /** The component node to append — not yet re-ided (fresh id minted here). */\n node: ComponentNode\n /** The locale being edited (EC-176 ambient active locale) for a localized field. */\n locale: Locale\n}\n\n/**\n * Append `node` into an EXISTING document's blocks field (EC-320 follow-up,\n * 0.0.44 — \"insert into existing document\"), leaving everything else about\n * the document untouched: other fields, other locale bundles, and the\n * field's own existing content, which the embed always lands AFTER.\n *\n * Reads the current roots via `blockRootsOf` (`@elytracms/core/project-graph`\n * — the same reader the composition editor, table cell, and reuse-context\n * use), so this reads the field back exactly like every other consumer,\n * undefined-safe for a never-set field. Locale-aware writes go through\n * {@link applyCellValue} (the same locale-write helper inline cell edits use,\n * `cell-edit.ts`) so a localized blocks field's OTHER locale bundles are\n * never touched, only the edited locale's.\n *\n * Node-id collision guard: `node` is re-embedded under a fresh id minted past\n * every existing root's ids ({@link embedFreshNode}), never a fixed `node-1`\n * — a document whose blocks already hold that id would otherwise collide.\n *\n * Throws if `blocksField` is a single-root (`multiple: false`) field that\n * already holds a root — a defensive invariant guard, not a user-facing\n * validation path: the caller must pre-filter candidates with\n * {@link canAppendTo} so this is never reached in that state.\n */\nexport function appendToBlockDocument({\n collection,\n document,\n blocksField,\n node,\n locale,\n}: AppendToBlockDocumentInput): CmsDocument {\n const existingRoots = blockRootsOf(readBlocksValue(collection, document, blocksField, locale))\n if (!blocksField.multiple && existingRoots.length > 0) {\n throw new Error(\n `Cannot append to single-root blocks field \"${blocksField.name}\": already holds a component (caller must gate with canAppendTo).`,\n )\n }\n const embedded = embedFreshNode(node, existingRoots)\n const nextValue = blocksField.multiple ? [...existingRoots, embedded] : embedded\n return applyCellValue(collection, document, blocksField.name, locale, nextValue)\n}\n"]}
@@ -1,10 +1,12 @@
1
1
  import type { ReactElement } from 'react';
2
2
  /**
3
- * The shared error/loading status gate for a rail module (EC-319/EC-323). The
4
- * assets, users, and redirects rails each read a list store with the same
5
- * `{ status, errorMessage, refresh }` shape; this renders the identical error
6
- * (heading + reason + retry) and loading states, so a module early-returns it
7
- * instead of re-inlining the same ~28 lines. Returns `null` for a `ready` store.
3
+ * The shared error/loading status gate for a rail module (EC-319/EC-323,
4
+ * repointed onto `SectionStateView` in EC-327). The assets, users, and
5
+ * redirects rails each read a list store with the same
6
+ * `{ status, errorMessage, refresh }` shape; this renders the shared
7
+ * error/loading section state — each rail's own translated `errorLabel`/
8
+ * `loadingLabel` copy, not a generic one — instead of a rail re-inlining its
9
+ * own AlertTriangle+retry panel. Returns `null` for a `ready` store.
8
10
  */
9
11
  export declare function RailStoreStatus({ store, errorLabel, loadingLabel, errorTestId, }: {
10
12
  store: {
@@ -1,20 +1,23 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { AlertTriangle, RotateCcw } from 'lucide-react';
3
- import { Button, Row, Stack, Text } from '@elytracms/studio/ui';
2
+ import { RotateCcw } from 'lucide-react';
3
+ import { Button } from '@elytracms/studio/ui';
4
4
  import { translate } from '@elytracms/studio/lib/i18n';
5
+ import { SectionStateView } from './section-state';
5
6
  /**
6
- * The shared error/loading status gate for a rail module (EC-319/EC-323). The
7
- * assets, users, and redirects rails each read a list store with the same
8
- * `{ status, errorMessage, refresh }` shape; this renders the identical error
9
- * (heading + reason + retry) and loading states, so a module early-returns it
10
- * instead of re-inlining the same ~28 lines. Returns `null` for a `ready` store.
7
+ * The shared error/loading status gate for a rail module (EC-319/EC-323,
8
+ * repointed onto `SectionStateView` in EC-327). The assets, users, and
9
+ * redirects rails each read a list store with the same
10
+ * `{ status, errorMessage, refresh }` shape; this renders the shared
11
+ * error/loading section state — each rail's own translated `errorLabel`/
12
+ * `loadingLabel` copy, not a generic one — instead of a rail re-inlining its
13
+ * own AlertTriangle+retry panel. Returns `null` for a `ready` store.
11
14
  */
12
15
  export function RailStoreStatus({ store, errorLabel, loadingLabel, errorTestId, }) {
13
16
  if (store.status === 'error') {
14
- return (_jsxs(Stack, { gap: "2", className: "p-6", "data-testid": errorTestId, children: [_jsxs(Row, { gap: "2", children: [_jsx(AlertTriangle, { className: "size-4 text-destructive" }), _jsx(Text, { weight: "semibold", children: errorLabel })] }), _jsx(Text, { size: "sm", tone: "muted", children: store.errorMessage ?? translate('common.backendUnreachable') }), _jsx(Row, { children: _jsxs(Button, { variant: "outline", size: "sm", onClick: () => void store.refresh(), children: [_jsx(RotateCcw, {}), " ", translate('common.retry')] }) })] }));
17
+ return (_jsx("div", { "data-testid": errorTestId, children: _jsx(SectionStateView, { state: "error", title: errorLabel, description: store.errorMessage ?? translate('common.backendUnreachable'), action: _jsxs(Button, { variant: "outline", size: "sm", onClick: () => void store.refresh(), children: [_jsx(RotateCcw, {}), " ", translate('common.retry')] }) }) }));
15
18
  }
16
19
  if (store.status === 'loading') {
17
- return (_jsx(Stack, { className: "h-full place-items-center p-8", align: "center", justify: "center", children: _jsx(Text, { size: "sm", tone: "muted", children: loadingLabel }) }));
20
+ return _jsx(SectionStateView, { state: "loading", title: loadingLabel });
18
21
  }
19
22
  return null;
20
23
  }
@@ -1 +1 @@
1
- {"version":3,"file":"rail-store-status.js","sourceRoot":"","sources":["../../src/components/rail-store-status.tsx"],"names":[],"mappings":";AACA,OAAO,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AACvD,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,sBAAsB,CAAA;AAC/D,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAA;AAEtD;;;;;;GAMG;AACH,MAAM,UAAU,eAAe,CAAC,EAC9B,KAAK,EACL,UAAU,EACV,YAAY,EACZ,WAAW,GAaZ;IACC,IAAI,KAAK,CAAC,MAAM,KAAK,OAAO,EAAE,CAAC;QAC7B,OAAO,CACL,MAAC,KAAK,IAAC,GAAG,EAAC,GAAG,EAAC,SAAS,EAAC,KAAK,iBAAc,WAAW,aACrD,MAAC,GAAG,IAAC,GAAG,EAAC,GAAG,aACV,KAAC,aAAa,IAAC,SAAS,EAAC,yBAAyB,GAAG,EACrD,KAAC,IAAI,IAAC,MAAM,EAAC,UAAU,YAAE,UAAU,GAAQ,IACvC,EACN,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,IAAI,EAAC,OAAO,YACzB,KAAK,CAAC,YAAY,IAAI,SAAS,CAAC,2BAA2B,CAAC,GACxD,EACP,KAAC,GAAG,cACF,MAAC,MAAM,IAAC,OAAO,EAAC,SAAS,EAAC,IAAI,EAAC,IAAI,EAAC,OAAO,EAAE,GAAG,EAAE,CAAC,KAAK,KAAK,CAAC,OAAO,EAAE,aACrE,KAAC,SAAS,KAAG,OAAE,SAAS,CAAC,cAAc,CAAC,IACjC,GACL,IACA,CACT,CAAA;IACH,CAAC;IACD,IAAI,KAAK,CAAC,MAAM,KAAK,SAAS,EAAE,CAAC;QAC/B,OAAO,CACL,KAAC,KAAK,IAAC,SAAS,EAAC,+BAA+B,EAAC,KAAK,EAAC,QAAQ,EAAC,OAAO,EAAC,QAAQ,YAC9E,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,IAAI,EAAC,OAAO,YACzB,YAAY,GACR,GACD,CACT,CAAA;IACH,CAAC;IACD,OAAO,IAAI,CAAA;AACb,CAAC","sourcesContent":["import type { ReactElement } from 'react'\nimport { AlertTriangle, RotateCcw } from 'lucide-react'\nimport { Button, Row, Stack, Text } from '@elytracms/studio/ui'\nimport { translate } from '@elytracms/studio/lib/i18n'\n\n/**\n * The shared error/loading status gate for a rail module (EC-319/EC-323). The\n * assets, users, and redirects rails each read a list store with the same\n * `{ status, errorMessage, refresh }` shape; this renders the identical error\n * (heading + reason + retry) and loading states, so a module early-returns it\n * instead of re-inlining the same ~28 lines. Returns `null` for a `ready` store.\n */\nexport function RailStoreStatus({\n store,\n errorLabel,\n loadingLabel,\n errorTestId,\n}: {\n store: {\n status: 'loading' | 'ready' | 'error'\n errorMessage?: string\n refresh: () => Promise<void>\n }\n /** Localized \"…failed to load\" heading for this rail. */\n errorLabel: string\n /** Localized \"Loading …\" text for this rail. */\n loadingLabel: string\n /** data-testid on the error container (per-rail, for tests). */\n errorTestId: string\n}): ReactElement | null {\n if (store.status === 'error') {\n return (\n <Stack gap=\"2\" className=\"p-6\" data-testid={errorTestId}>\n <Row gap=\"2\">\n <AlertTriangle className=\"size-4 text-destructive\" />\n <Text weight=\"semibold\">{errorLabel}</Text>\n </Row>\n <Text size=\"sm\" tone=\"muted\">\n {store.errorMessage ?? translate('common.backendUnreachable')}\n </Text>\n <Row>\n <Button variant=\"outline\" size=\"sm\" onClick={() => void store.refresh()}>\n <RotateCcw /> {translate('common.retry')}\n </Button>\n </Row>\n </Stack>\n )\n }\n if (store.status === 'loading') {\n return (\n <Stack className=\"h-full place-items-center p-8\" align=\"center\" justify=\"center\">\n <Text size=\"sm\" tone=\"muted\">\n {loadingLabel}\n </Text>\n </Stack>\n )\n }\n return null\n}\n"]}
1
+ {"version":3,"file":"rail-store-status.js","sourceRoot":"","sources":["../../src/components/rail-store-status.tsx"],"names":[],"mappings":";AACA,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AACxC,OAAO,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAA;AAC7C,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAA;AACtD,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAA;AAElD;;;;;;;;GAQG;AACH,MAAM,UAAU,eAAe,CAAC,EAC9B,KAAK,EACL,UAAU,EACV,YAAY,EACZ,WAAW,GAaZ;IACC,IAAI,KAAK,CAAC,MAAM,KAAK,OAAO,EAAE,CAAC;QAC7B,OAAO,CACL,6BAAkB,WAAW,YAC3B,KAAC,gBAAgB,IACf,KAAK,EAAC,OAAO,EACb,KAAK,EAAE,UAAU,EACjB,WAAW,EAAE,KAAK,CAAC,YAAY,IAAI,SAAS,CAAC,2BAA2B,CAAC,EACzE,MAAM,EACJ,MAAC,MAAM,IAAC,OAAO,EAAC,SAAS,EAAC,IAAI,EAAC,IAAI,EAAC,OAAO,EAAE,GAAG,EAAE,CAAC,KAAK,KAAK,CAAC,OAAO,EAAE,aACrE,KAAC,SAAS,KAAG,OAAE,SAAS,CAAC,cAAc,CAAC,IACjC,GAEX,GACE,CACP,CAAA;IACH,CAAC;IACD,IAAI,KAAK,CAAC,MAAM,KAAK,SAAS,EAAE,CAAC;QAC/B,OAAO,KAAC,gBAAgB,IAAC,KAAK,EAAC,SAAS,EAAC,KAAK,EAAE,YAAY,GAAI,CAAA;IAClE,CAAC;IACD,OAAO,IAAI,CAAA;AACb,CAAC","sourcesContent":["import type { ReactElement } from 'react'\nimport { RotateCcw } from 'lucide-react'\nimport { Button } from '@elytracms/studio/ui'\nimport { translate } from '@elytracms/studio/lib/i18n'\nimport { SectionStateView } from './section-state'\n\n/**\n * The shared error/loading status gate for a rail module (EC-319/EC-323,\n * repointed onto `SectionStateView` in EC-327). The assets, users, and\n * redirects rails each read a list store with the same\n * `{ status, errorMessage, refresh }` shape; this renders the shared\n * error/loading section state — each rail's own translated `errorLabel`/\n * `loadingLabel` copy, not a generic one — instead of a rail re-inlining its\n * own AlertTriangle+retry panel. Returns `null` for a `ready` store.\n */\nexport function RailStoreStatus({\n store,\n errorLabel,\n loadingLabel,\n errorTestId,\n}: {\n store: {\n status: 'loading' | 'ready' | 'error'\n errorMessage?: string\n refresh: () => Promise<void>\n }\n /** Localized \"…failed to load\" heading for this rail. */\n errorLabel: string\n /** Localized \"Loading …\" text for this rail. */\n loadingLabel: string\n /** data-testid on the error container (per-rail, for tests). */\n errorTestId: string\n}): ReactElement | null {\n if (store.status === 'error') {\n return (\n <div data-testid={errorTestId}>\n <SectionStateView\n state=\"error\"\n title={errorLabel}\n description={store.errorMessage ?? translate('common.backendUnreachable')}\n action={\n <Button variant=\"outline\" size=\"sm\" onClick={() => void store.refresh()}>\n <RotateCcw /> {translate('common.retry')}\n </Button>\n }\n />\n </div>\n )\n }\n if (store.status === 'loading') {\n return <SectionStateView state=\"loading\" title={loadingLabel} />\n }\n return null\n}\n"]}