@elytracms/studio 0.0.79 → 0.0.81

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 (107) hide show
  1. package/convex/contract-parity.test.ts +1 -1
  2. package/convex/functions.ts +13 -3
  3. package/convex/http-routes.test.ts +143 -0
  4. package/convex/http.ts +19 -9
  5. package/convex/schema.ts +0 -19
  6. package/convex/users.ts +1 -1
  7. package/dist/components/app-views/default-modules.d.ts +141 -44
  8. package/dist/components/app-views/default-modules.js +229 -65
  9. package/dist/components/app-views/default-modules.js.map +1 -1
  10. package/dist/components/app-views/module.d.ts +38 -0
  11. package/dist/components/app-views/module.js.map +1 -1
  12. package/dist/components/app-views/sidebar.d.ts +5 -2
  13. package/dist/components/app-views/sidebar.js +16 -5
  14. package/dist/components/app-views/sidebar.js.map +1 -1
  15. package/dist/components/app-views/studio-config.d.ts +27 -0
  16. package/dist/components/app-views/studio-config.js.map +1 -1
  17. package/dist/components/builder/canvas-render-context.js +7 -1
  18. package/dist/components/builder/canvas-render-context.js.map +1 -1
  19. package/dist/components/builder/reuse-context.js +7 -1
  20. package/dist/components/builder/reuse-context.js.map +1 -1
  21. package/dist/components/builder/reuse.d.ts +94 -16
  22. package/dist/components/builder/reuse.js +201 -19
  23. package/dist/components/builder/reuse.js.map +1 -1
  24. package/dist/components/cms/bulk-actions.js +3 -1
  25. package/dist/components/cms/bulk-actions.js.map +1 -1
  26. package/dist/components/cms/cell-edit.js +6 -2
  27. package/dist/components/cms/cell-edit.js.map +1 -1
  28. package/dist/components/cms/cms-store.d.ts +40 -13
  29. package/dist/components/cms/cms-store.js +50 -22
  30. package/dist/components/cms/cms-store.js.map +1 -1
  31. package/dist/components/cms/collection-table.js +2 -2
  32. package/dist/components/cms/collection-table.js.map +1 -1
  33. package/dist/components/cms/date-input-core.d.ts +21 -0
  34. package/dist/components/cms/date-input-core.js +20 -0
  35. package/dist/components/cms/date-input-core.js.map +1 -1
  36. package/dist/components/cms/date-input.d.ts +9 -1
  37. package/dist/components/cms/date-input.js +2 -2
  38. package/dist/components/cms/date-input.js.map +1 -1
  39. package/dist/components/cms/detail-editor.js +11 -5
  40. package/dist/components/cms/detail-editor.js.map +1 -1
  41. package/dist/components/cms/editable-cell.js +2 -1
  42. package/dist/components/cms/editable-cell.js.map +1 -1
  43. package/dist/components/cms/field-control.js +4 -2
  44. package/dist/components/cms/field-control.js.map +1 -1
  45. package/dist/components/cms/field-renderer.js +8 -1
  46. package/dist/components/cms/field-renderer.js.map +1 -1
  47. package/dist/components/cms/import-export.js +2 -1
  48. package/dist/components/cms/import-export.js.map +1 -1
  49. package/dist/components/cms/issues-pane.d.ts +4 -2
  50. package/dist/components/cms/issues-pane.js +6 -4
  51. package/dist/components/cms/issues-pane.js.map +1 -1
  52. package/dist/components/cms/scalar-value-editor.js +2 -1
  53. package/dist/components/cms/scalar-value-editor.js.map +1 -1
  54. package/dist/components/components/transfer-dialog.js +10 -12
  55. package/dist/components/components/transfer-dialog.js.map +1 -1
  56. package/dist/components/project-list.d.ts +9 -3
  57. package/dist/components/project-list.js +11 -5
  58. package/dist/components/project-list.js.map +1 -1
  59. package/dist/components/project-switcher.d.ts +3 -1
  60. package/dist/components/project-switcher.js +4 -2
  61. package/dist/components/project-switcher.js.map +1 -1
  62. package/dist/components/redirects/redirects-store.d.ts +21 -0
  63. package/dist/components/redirects/redirects-store.js +17 -4
  64. package/dist/components/redirects/redirects-store.js.map +1 -1
  65. package/dist/components/section-nav.js +102 -4
  66. package/dist/components/section-nav.js.map +1 -1
  67. package/dist/customization.d.ts +7 -0
  68. package/dist/customization.js.map +1 -1
  69. package/dist/lib/data/collection-content.d.ts +6 -1
  70. package/dist/lib/data/collection-content.js +50 -12
  71. package/dist/lib/data/collection-content.js.map +1 -1
  72. package/dist/lib/i18n/messages.de.js +132 -0
  73. package/dist/lib/i18n/messages.de.js.map +1 -1
  74. package/dist/lib/i18n/messages.en.d.ts +103 -0
  75. package/dist/lib/i18n/messages.en.js +146 -0
  76. package/dist/lib/i18n/messages.en.js.map +1 -1
  77. package/dist/lib/locale/binding-fallback.d.ts +8 -1
  78. package/dist/lib/locale/binding-fallback.js +8 -1
  79. package/dist/lib/locale/binding-fallback.js.map +1 -1
  80. package/dist/lib/locale/locale-store.d.ts +41 -9
  81. package/dist/lib/locale/locale-store.js +34 -15
  82. package/dist/lib/locale/locale-store.js.map +1 -1
  83. package/dist/lib/locale/locale-switcher.d.ts +3 -1
  84. package/dist/lib/locale/locale-switcher.js +4 -2
  85. package/dist/lib/locale/locale-switcher.js.map +1 -1
  86. package/dist/lib/locale/use-studio-locale.d.ts +9 -4
  87. package/dist/lib/locale/use-studio-locale.js.map +1 -1
  88. package/dist/lib/persistence/convex-adapter.d.ts +1 -3
  89. package/dist/lib/persistence/convex-adapter.js +0 -55
  90. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  91. package/dist/lib/persona/labels.d.ts +42 -1
  92. package/dist/lib/persona/labels.js +57 -0
  93. package/dist/lib/persona/labels.js.map +1 -1
  94. package/dist/lib/routes/route-issues.d.ts +9 -0
  95. package/dist/lib/routes/route-issues.js +117 -0
  96. package/dist/lib/routes/route-issues.js.map +1 -0
  97. package/dist/lib/workspace/config-issues.d.ts +54 -0
  98. package/dist/lib/workspace/config-issues.js +70 -0
  99. package/dist/lib/workspace/config-issues.js.map +1 -0
  100. package/dist/lib/workspace/index.d.ts +9 -0
  101. package/dist/lib/workspace/index.js +11 -0
  102. package/dist/lib/workspace/index.js.map +1 -1
  103. package/dist/lib/workspace/switcher-source.d.ts +20 -3
  104. package/dist/lib/workspace/switcher-source.js.map +1 -1
  105. package/package.json +2 -2
  106. package/convex/cliTokens.ts +0 -63
  107. package/convex/sync.ts +0 -297
@@ -2,21 +2,72 @@ import { manifestPropFields } from '@elytracms/core/component-registry';
2
2
  import { readDocumentRef, seedDefaults, titleFieldOf } from '@elytracms/core/cms-core';
3
3
  import { isStaticProp } from '@elytracms/core/project-graph';
4
4
  import { slugify } from '@elytracms/studio/lib/utils';
5
- /** The lone `relation` prop of a manifest (with its name), or null unless exactly one. */
6
- function soleRelationProp(manifest) {
7
- const relations = manifestPropFields(manifest).filter((f) => f.type === 'relation');
8
- const only = relations.length === 1 ? relations[0] : undefined;
9
- if (!only || only.type !== 'relation' || !only.target)
10
- return null;
11
- // EC-462: `form.hidden` present and not literally `false` — a predicate-hidden
12
- // relation is publish-stripped when hidden (EC-394), an always-hidden one is
13
- // never editor-pickable; neither can be a transclusion source.
14
- const hidden = only.form?.hidden;
15
- const conditional = hidden !== undefined && hidden !== false;
16
- return { name: only.name, target: only.target, multiple: only.multiple, conditional };
5
+ /** The customization point name stamped onto every reuse issue (EC-236 locator). */
6
+ const REUSE_POINT = 'reuse';
7
+ /** Every `relation` prop of a manifest, in declaration order. */
8
+ function relationPropsOf(manifest) {
9
+ const props = [];
10
+ for (const field of manifestPropFields(manifest)) {
11
+ if (field.type !== 'relation')
12
+ continue;
13
+ const hidden = field.form?.hidden;
14
+ props.push({
15
+ name: field.name,
16
+ target: field.target,
17
+ multiple: field.multiple === true,
18
+ conditional: hidden !== undefined && hidden !== false,
19
+ });
20
+ }
21
+ return props;
22
+ }
23
+ /**
24
+ * The relation props a DECLARED embed could plausibly transclude through: targeted
25
+ * and cardinality-one. A cardinality-`many` (or targetless) relation is excluded
26
+ * structurally, not heuristically — an embed transcludes exactly one document and
27
+ * {@link makeEmbedNode} writes exactly one `{collection,id}`, which a list-valued
28
+ * or target-less prop cannot hold.
29
+ */
30
+ function embeddableRelationProps(manifest) {
31
+ return relationPropsOf(manifest).filter((r) => r.target !== undefined && !r.multiple);
32
+ }
33
+ /** The collection's first composition field — what an embed of it transcludes. */
34
+ function blocksFieldOf(collection) {
35
+ const field = collection.fields.find((f) => f.type === 'blocks');
36
+ return field?.type === 'blocks' ? field : undefined;
17
37
  }
18
- /** Derive the project's reuse capability, or null when it is absent/ambiguous. */
19
- export function resolveReuseCapability(manifests, collections) {
38
+ /**
39
+ * The catalog keys for the reuse config issues (EC-478), in the `builder.reuse.config.*`
40
+ * namespace. Named here so the producer and the catalog-parity test agree on one list.
41
+ */
42
+ const REUSE_KEYS = {
43
+ unknownComponent: 'builder.reuse.config.unknownComponent',
44
+ noRelationProp: 'builder.reuse.config.noRelationProp',
45
+ unknownRelationProp: 'builder.reuse.config.unknownRelationProp',
46
+ ambiguousRelationProp: 'builder.reuse.config.ambiguousRelationProp',
47
+ unknownCollection: 'builder.reuse.config.unknownCollection',
48
+ noBlocksField: 'builder.reuse.config.noBlocksField',
49
+ ambiguousInference: 'builder.reuse.config.ambiguousInference',
50
+ };
51
+ function reuseIssue(code, severity, message, messageRef, id) {
52
+ return {
53
+ code,
54
+ severity,
55
+ message,
56
+ messageRef,
57
+ point: REUSE_POINT,
58
+ ...(id === undefined ? {} : { id }),
59
+ };
60
+ }
61
+ /** `"a", "b"` — a readable list of prop/component ids for an issue message. */
62
+ function quotedList(names) {
63
+ return names.map((name) => `"${name}"`).join(', ');
64
+ }
65
+ /**
66
+ * The components whose SHAPE makes them plausible reuse embeds (the zero-config
67
+ * heuristic + EC-462's disqualifiers), in manifest order. Exported for the tests
68
+ * and for issue copy; the resolution rule lives in {@link resolveReuse}.
69
+ */
70
+ export function inferredEmbedCandidates(manifests, collections) {
20
71
  const matches = [];
21
72
  for (const manifest of manifests) {
22
73
  if (manifest.slots.length > 0)
@@ -25,14 +76,20 @@ export function resolveReuseCapability(manifests, collections) {
25
76
  // relation prop is the filter source, never an embed pointer.
26
77
  if (manifest.listing)
27
78
  continue;
28
- const relation = soleRelationProp(manifest);
29
- if (!relation || relation.multiple === true || relation.conditional)
79
+ // The heuristic requires the relation to be UNAMBIGUOUS within the manifest:
80
+ // exactly ONE relation prop of any kind, which must then be targeted,
81
+ // cardinality-one and unconditional. (Counting all relation props — not just
82
+ // the embeddable ones — is deliberate: a component with a second, list-valued
83
+ // relation is not the single-purpose shape the heuristic is allowed to claim.)
84
+ const relations = relationPropsOf(manifest);
85
+ const relation = relations.length === 1 ? relations[0] : undefined;
86
+ if (!relation || relation.target === undefined || relation.multiple || relation.conditional)
30
87
  continue;
31
88
  const collection = collections.find((c) => c.id === relation.target);
32
89
  if (!collection)
33
90
  continue;
34
- const blocksField = collection.fields.find((f) => f.type === 'blocks');
35
- if (!blocksField || blocksField.type !== 'blocks')
91
+ const blocksField = blocksFieldOf(collection);
92
+ if (!blocksField)
36
93
  continue;
37
94
  matches.push({
38
95
  embedComponentId: manifest.id,
@@ -42,7 +99,132 @@ export function resolveReuseCapability(manifests, collections) {
42
99
  contentCardinality: blocksField.multiple === false ? 'one' : 'many',
43
100
  });
44
101
  }
45
- return matches.length === 1 ? matches[0] : null;
102
+ return matches;
103
+ }
104
+ /**
105
+ * Resolve the DECLARED embed (EC-467, AD-b). An explicit declaration is the
106
+ * developer's authority, so the inference disqualifiers are NOT applied here: a
107
+ * declared component may have slots, may declare `listing`, and its relation prop
108
+ * may be `form.hidden` — those exist to stop the engine GUESSING, not to overrule
109
+ * a stated intent. Only structural impossibilities are issues (see AD-c row 2):
110
+ * an unknown component id, no usable relation prop (or an ambiguous set with no
111
+ * `relationProp`), an undefined target collection, or a target with no `blocks`
112
+ * field — each of which would leave nothing coherent to transclude.
113
+ */
114
+ function resolveDeclaredReuse(declaration, manifests, collections) {
115
+ const { component, relationProp } = declaration;
116
+ const fail = (message, messageRef) => ({
117
+ capability: null,
118
+ issues: [reuseIssue('invalid-override', 'error', message, messageRef, component)],
119
+ });
120
+ const manifest = manifests.find((m) => m.id === component);
121
+ if (!manifest) {
122
+ return {
123
+ capability: null,
124
+ issues: [
125
+ reuseIssue('unknown-override-target', 'error', `Reusable elements: studio config declares reuse.component "${component}", which is not a registered component. Fix the id, or remove the reuse block to let the studio infer the embed.`, { key: REUSE_KEYS.unknownComponent, vars: { component } }, component),
126
+ ],
127
+ };
128
+ }
129
+ const relations = embeddableRelationProps(manifest);
130
+ if (relations.length === 0) {
131
+ return fail(`Reusable elements: the declared embed "${component}" has no single-document relation prop, so it cannot point at a reusable document. An embed needs one relation prop with cardinality one.`, { key: REUSE_KEYS.noRelationProp, vars: { component } });
132
+ }
133
+ const names = relations.map((r) => r.name);
134
+ let relation;
135
+ if (relationProp !== undefined) {
136
+ const named = relations.find((r) => r.name === relationProp);
137
+ if (!named) {
138
+ return fail(`Reusable elements: the declared embed "${component}" has no single-document relation prop named "${relationProp}". Its relation props are: ${quotedList(names)}.`, {
139
+ key: REUSE_KEYS.unknownRelationProp,
140
+ vars: { component, relationProp, props: quotedList(names) },
141
+ });
142
+ }
143
+ relation = named;
144
+ }
145
+ else if (relations.length === 1) {
146
+ relation = relations[0];
147
+ }
148
+ else {
149
+ return fail(`Reusable elements: the declared embed "${component}" has ${relations.length} single-document relation props (${quotedList(names)}) — add reuse.relationProp to say which one points at the reusable document.`, {
150
+ key: REUSE_KEYS.ambiguousRelationProp,
151
+ vars: { component, count: relations.length, props: quotedList(names) },
152
+ });
153
+ }
154
+ const collection = collections.find((c) => c.id === relation.target);
155
+ if (!collection) {
156
+ return fail(`Reusable elements: "${component}.${relation.name}" targets the collection "${relation.target}", which this project does not define.`, {
157
+ key: REUSE_KEYS.unknownCollection,
158
+ vars: { component, relationProp: relation.name, collection: relation.target },
159
+ });
160
+ }
161
+ const blocksField = blocksFieldOf(collection);
162
+ if (!blocksField) {
163
+ return fail(`Reusable elements: the collection "${collection.id}" behind "${component}.${relation.name}" has no blocks (composition) field, so there is nothing for the embed to transclude.`, {
164
+ key: REUSE_KEYS.noBlocksField,
165
+ vars: { component, relationProp: relation.name, collection: collection.id },
166
+ });
167
+ }
168
+ return {
169
+ capability: {
170
+ embedComponentId: manifest.id,
171
+ relationProp: relation.name,
172
+ collectionId: collection.id,
173
+ contentField: blocksField.name,
174
+ contentCardinality: blocksField.multiple === false ? 'one' : 'many',
175
+ },
176
+ issues: [],
177
+ };
178
+ }
179
+ /**
180
+ * Resolve the project's ONE reuse capability (AD-a) plus any config issues —
181
+ * EC-467's full matrix (AD-c):
182
+ *
183
+ * | Declaration | Candidates | Result |
184
+ * | --- | --- | --- |
185
+ * | present, resolves | any | the declared capability, no issue |
186
+ * | present, does not resolve | any | `null` + an ERROR issue (misconfiguration) |
187
+ * | absent | exactly 1 | that candidate, no issue (the zero-config default) |
188
+ * | absent | 2 or more | `null` + a WARNING issue naming every candidate |
189
+ * | absent | 0 | `null`, **no issue** — a project need not use reusable elements |
190
+ *
191
+ * Severity is the honest split: a declaration that cannot resolve is broken
192
+ * config the developer asked for (error); inference ambiguity is nothing broken —
193
+ * the engine declines to guess and asks for one line (warning). Both disable the
194
+ * gestures, and both render identically in the rail's issue list.
195
+ *
196
+ * The zero-candidate row is deliberately silent: crying wolf at every project
197
+ * that has no reusable elements would train developers to ignore the surface.
198
+ */
199
+ export function resolveReuse(manifests, collections, declaration) {
200
+ if (declaration)
201
+ return resolveDeclaredReuse(declaration, manifests, collections);
202
+ const candidates = inferredEmbedCandidates(manifests, collections);
203
+ if (candidates.length === 1)
204
+ return { capability: candidates[0], issues: [] };
205
+ if (candidates.length === 0)
206
+ return { capability: null, issues: [] };
207
+ return {
208
+ capability: null,
209
+ issues: [
210
+ reuseIssue('ambiguous-inference', 'warning', `Reusable elements: ${candidates.length} components have the reusable-embed shape (${quotedList(candidates.map((c) => c.embedComponentId))}). A project has exactly one reusable-element embed, so the studio will not guess — declare it in studio.config as reuse: { component: '<id>' }. Until then the reuse gestures are off.`, {
211
+ key: REUSE_KEYS.ambiguousInference,
212
+ vars: {
213
+ count: candidates.length,
214
+ components: quotedList(candidates.map((c) => c.embedComponentId)),
215
+ },
216
+ }),
217
+ ],
218
+ };
219
+ }
220
+ /**
221
+ * The project's reuse capability alone, or `null` when it is absent, ambiguous, or
222
+ * misdeclared — for callers that act on the capability and do not surface config
223
+ * issues themselves (the publish-integrity walk). The issues are surfaced ONCE, by
224
+ * the shell rail; see {@link resolveReuse}.
225
+ */
226
+ export function resolveReuseCapability(manifests, collections, declaration) {
227
+ return resolveReuse(manifests, collections, declaration).capability;
46
228
  }
47
229
  /** A slug for `name`, suffixed `-2`/`-3`/… until it is free among `taken`. */
48
230
  export function uniqueSlug(name, taken) {
@@ -1 +1 @@
1
- {"version":3,"file":"reuse.js","sourceRoot":"","sources":["../../../src/components/builder/reuse.ts"],"names":[],"mappings":"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","sourcesContent":["import { manifestPropFields } from '@elytracms/core/component-registry'\nimport type { ComponentManifest } from '@elytracms/core/component-registry'\nimport { readDocumentRef, seedDefaults, titleFieldOf } from '@elytracms/core/cms-core'\nimport type { CollectionDef, DocumentRef } from '@elytracms/core/cms-core'\nimport { isStaticProp } from '@elytracms/core/project-graph'\nimport type { ComponentNode } from '@elytracms/core/project-graph'\nimport { slugify } from '@elytracms/studio/lib/utils'\n\n/**\n * The reuse capability (EC-282): the engine-generic description of how a project\n * models reusable elements, DERIVED from config (manifests + collections) — no\n * new declaration, so a consumer that already has a transcluding embed gets the\n * in-canvas gestures for free (the EC-296 \"works automatically\" pattern).\n *\n * The derivation predicate for a \"reusable embed\" component: it contributes no\n * slots of its own and has exactly ONE `relation` prop (cardinality `one`) whose\n * target collection holds a `blocks` field — i.e. it embeds a single document and\n * renders that document's composition by transclusion (EC-254/EC-281). For\n * Konditorei that is uniquely `project.Reusable` → `element` → `reusable.content`.\n * If zero or more than one component matches, the capability is null and the\n * gestures stay hidden rather than guessing.\n *\n * EC-462 — the predicate additionally DISQUALIFIES a candidate whose own manifest\n * explicitly declares its relation prop to be something other than an embed\n * pointer. The shape test alone proved unsound the moment a relation prop's\n * target collection gains a `blocks` field of its own (a category/page with a\n * canvas — a completely natural content shape): the EC-276 sprint's ProductGrid\n * and the demo's OptionBox were both claimed as \"the embed\", so their ordinary\n * relation picks rendered the reusable-element placeholder, grew Detach/Make-\n * reusable gestures, and fed embed-worded publish-integrity issues. Two\n * explicit-declaration disqualifiers close the observed class:\n *\n * - a manifest with a `listing` declaration is a QUERY host (EC-255) — its\n * relation prop is the query's filter source (`listing.filter.fromProp`),\n * never a transclusion pointer;\n * - a relation prop with `form.hidden` (predicate or `true`) is conditional/\n * auxiliary — EC-394 strips a hidden relation's ref at publish, so it cannot\n * coherently be what the component transcludes.\n *\n * Both read only the CANDIDATE manifest's own declarations (never the target's\n * contents) and can only EXCLUDE, so a genuine pure embed (which declares\n * neither) is always kept. Residual ambiguity — a bare unconditional\n * single-relation display component (e.g. a page teaser) is at rest\n * indistinguishable from a genuine embed — is a data-model finding recorded in\n * `tasks/EC-462-relation-prop-reusable-embed-hijack.md` (resolution needs an\n * explicit manifest-level declaration, an owner decision).\n */\nexport interface ReuseCapability {\n /** The embed component, e.g. `project.Reusable`. */\n embedComponentId: string\n /** The embed's relation prop that points at the reusable document, e.g. `element`. */\n relationProp: string\n /** The collection reusable documents live in, e.g. `reusable`. */\n collectionId: string\n /** The collection's composition field the embed transcludes, e.g. `content`. */\n contentField: string\n /** Whether that field holds a single block or a list. */\n contentCardinality: 'one' | 'many'\n}\n\n/** The lone `relation` prop of a manifest (with its name), or null unless exactly one. */\nfunction soleRelationProp(\n manifest: ComponentManifest,\n): { name: string; target: string; multiple: boolean; conditional: boolean } | null {\n const relations = manifestPropFields(manifest).filter((f) => f.type === 'relation')\n const only = relations.length === 1 ? relations[0] : undefined\n if (!only || only.type !== 'relation' || !only.target) return null\n // EC-462: `form.hidden` present and not literally `false` — a predicate-hidden\n // relation is publish-stripped when hidden (EC-394), an always-hidden one is\n // never editor-pickable; neither can be a transclusion source.\n const hidden = only.form?.hidden\n const conditional = hidden !== undefined && hidden !== false\n return { name: only.name, target: only.target, multiple: only.multiple, conditional }\n}\n\n/** Derive the project's reuse capability, or null when it is absent/ambiguous. */\nexport function resolveReuseCapability(\n manifests: readonly ComponentManifest[],\n collections: readonly CollectionDef[],\n): ReuseCapability | null {\n const matches: ReuseCapability[] = []\n for (const manifest of manifests) {\n if (manifest.slots.length > 0) continue\n // EC-462: a `listing` manifest is a query host by its own declaration — its\n // relation prop is the filter source, never an embed pointer.\n if (manifest.listing) continue\n const relation = soleRelationProp(manifest)\n if (!relation || relation.multiple === true || relation.conditional) continue\n const collection = collections.find((c) => c.id === relation.target)\n if (!collection) continue\n const blocksField = collection.fields.find((f) => f.type === 'blocks')\n if (!blocksField || blocksField.type !== 'blocks') continue\n matches.push({\n embedComponentId: manifest.id,\n relationProp: relation.name,\n collectionId: collection.id,\n contentField: blocksField.name,\n contentCardinality: blocksField.multiple === false ? 'one' : 'many',\n })\n }\n return matches.length === 1 ? matches[0]! : null\n}\n\n/** A slug for `name`, suffixed `-2`/`-3`/… until it is free among `taken`. */\nexport function uniqueSlug(name: string, taken: ReadonlySet<string>): string {\n const base = slugify(name) || 'element'\n let id = base\n for (let n = 2; taken.has(id); n += 1) id = `${base}-${n}`\n return id\n}\n\n/**\n * The values for a new reusable document promoted from `node` (EC-282): seeded\n * defaults, the given `name` on the title field, the node on the content field,\n * and a collision-safe slug on any UNIQUE text field (e.g. Konditorei's\n * `internalId`) so the created document is publish-valid without hardcoding that\n * field's name. Other required fields, if any, are completed by the editor when\n * they open the new reusable.\n */\nexport function buildReusableValues(\n collection: CollectionDef,\n capability: ReuseCapability,\n name: string,\n node: ComponentNode,\n existing: readonly { values?: Record<string, unknown> }[],\n): Record<string, unknown> {\n const values: Record<string, unknown> = { ...seedDefaults(collection) }\n const titleField = titleFieldOf(collection)?.name\n if (titleField) values[titleField] = name\n for (const field of collection.fields) {\n if (\n field.type === 'text' &&\n field.validation?.unique === true &&\n field.name !== titleField &&\n field.name !== capability.contentField\n ) {\n const taken = new Set(\n existing\n .map((d) => d.values?.[field.name])\n .filter((v): v is string => typeof v === 'string'),\n )\n values[field.name] = uniqueSlug(name, taken)\n }\n }\n values[capability.contentField] = capability.contentCardinality === 'one' ? node : [node]\n return values\n}\n\n/**\n * The document a reuse-embed `node` targets (EC-300), or null when `node` is not the\n * embed component or has no static pick yet. Lets the canvas detect a HARD reference\n * whose target doesn't resolve (→ placeholder) without reaching into prop internals.\n */\nexport function reuseEmbedRef(node: ComponentNode, capability: ReuseCapability): DocumentRef | null {\n if (node.componentId !== capability.embedComponentId) return null\n const prop = node.props?.[capability.relationProp]\n const ref = isStaticProp(prop) ? readDocumentRef(prop.value) : undefined\n return ref ?? null\n}\n\n/**\n * Every reuse-embed target ref in a composition `blocks` value, at any depth\n * (EC-300) — the hard references a publish-integrity check walks to find\n * unpublished targets. Recurses slots; order is document order. Duplicates are\n * kept (the caller dedupes by key if needed).\n */\nexport function collectEmbedRefs(\n blocks: readonly ComponentNode[],\n capability: ReuseCapability,\n): DocumentRef[] {\n const refs: DocumentRef[] = []\n const visit = (node: ComponentNode): void => {\n const ref = reuseEmbedRef(node, capability)\n if (ref) refs.push(ref)\n for (const children of Object.values(node.slots ?? {})) for (const child of children) visit(child)\n }\n for (const node of blocks) visit(node)\n return refs\n}\n\n/** The `Reusable` embed node referencing the new document (id minted by the action). */\nexport function makeEmbedNode(capability: ReuseCapability, documentId: string): ComponentNode {\n return {\n id: 'reuse-embed',\n componentId: capability.embedComponentId,\n props: {\n [capability.relationProp]: {\n kind: 'static',\n value: { collection: capability.collectionId, id: documentId },\n },\n },\n }\n}\n"]}
1
+ {"version":3,"file":"reuse.js","sourceRoot":"","sources":["../../../src/components/builder/reuse.ts"],"names":[],"mappings":"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","sourcesContent":["import { manifestPropFields } from '@elytracms/core/component-registry'\nimport type { ComponentManifest } from '@elytracms/core/component-registry'\nimport { readDocumentRef, seedDefaults, titleFieldOf } from '@elytracms/core/cms-core'\nimport type { CollectionDef, DocumentRef } from '@elytracms/core/cms-core'\nimport { isStaticProp } from '@elytracms/core/project-graph'\nimport type { ComponentNode } from '@elytracms/core/project-graph'\nimport type { CustomizationIssue, CustomizationMessageRef } from '@elytracms/core/studio-core'\nimport { slugify } from '@elytracms/studio/lib/utils'\n\n/**\n * The reuse capability (EC-282): the engine-generic description of how a project\n * models reusable elements. **AD-a (EC-467): reusable elements are SINGULAR per\n * project** — exactly one embed component and exactly one reusable collection.\n * Multiple reuse collections are deliberately not supported, which is why this is\n * one value and never a list.\n *\n * The capability is normally DERIVED from config (manifests + collections) — no\n * declaration needed, so a consumer that already has a transcluding embed gets the\n * in-canvas gestures for free (the EC-296 \"works automatically\" pattern). The\n * derivation predicate for a \"reusable embed\" component: it contributes no slots of\n * its own and has exactly ONE `relation` prop (cardinality `one`) whose target\n * collection holds a `blocks` field — i.e. it embeds a single document and renders\n * that document's composition by transclusion (EC-254/EC-281). For Konditorei that\n * is uniquely `project.Reusable` → `element` → `reusable.content`.\n *\n * EC-462 — the predicate additionally DISQUALIFIES a candidate whose own manifest\n * explicitly declares its relation prop to be something other than an embed\n * pointer. The shape test alone proved unsound the moment a relation prop's\n * target collection gains a `blocks` field of its own (a category/page with a\n * canvas — a completely natural content shape): the EC-276 sprint's ProductGrid\n * and the demo's OptionBox were both claimed as \"the embed\", so their ordinary\n * relation picks rendered the reusable-element placeholder, grew Detach/Make-\n * reusable gestures, and fed embed-worded publish-integrity issues. Two\n * explicit-declaration disqualifiers close the observed class:\n *\n * - a manifest with a `listing` declaration is a QUERY host (EC-255) — its\n * relation prop is the query's filter source (`listing.filter.fromProp`),\n * never a transclusion pointer;\n * - a relation prop with `form.hidden` (predicate or `true`) is conditional/\n * auxiliary — EC-394 strips a hidden relation's ref at publish, so it cannot\n * coherently be what the component transcludes.\n *\n * Both read only the CANDIDATE manifest's own declarations (never the target's\n * contents) and can only EXCLUDE, so a genuine pure embed (which declares\n * neither) is always kept. EC-462's residual — a bare unconditional\n * single-relation display component (e.g. a page teaser) is at rest\n * indistinguishable from a genuine embed — is closed by EC-467's optional\n * {@link StudioReuseConfig} declaration: a project that hits ambiguity declares\n * its way out, and the ambiguity is LOUD instead of silently disabling the\n * gestures. See {@link resolveReuse}.\n */\nexport interface ReuseCapability {\n /** The embed component, e.g. `project.Reusable`. */\n embedComponentId: string\n /** The embed's relation prop that points at the reusable document, e.g. `element`. */\n relationProp: string\n /** The collection reusable documents live in, e.g. `reusable`. */\n collectionId: string\n /** The collection's composition field the embed transcludes, e.g. `content`. */\n contentField: string\n /** Whether that field holds a single block or a list. */\n contentCardinality: 'one' | 'many'\n}\n\n/**\n * EC-467 (AD-b) — the OPTIONAL studio-config declaration of the reuse embed:\n * `defineStudio({ reuse: { component: 'project.Reusable' } })`. See\n * {@link import('../app-views/studio-config').StudioConfig.reuse} for the\n * consumer-facing contract; this is the shape {@link resolveReuse} consumes.\n *\n * One identifier is enough because the rest DERIVES from it (its single-document\n * relation prop → that prop's target collection → that collection's `blocks`\n * field), and because reusable elements are singular per project (AD-a).\n */\nexport interface StudioReuseConfig {\n /** The embed component's registry id, e.g. `'project.Reusable'`. */\n component: string\n /**\n * Which relation prop points at the reusable document. Needed ONLY when the\n * declared component legitimately has more than one single-document relation\n * prop — otherwise the sole one is derived.\n */\n relationProp?: string\n}\n\n/**\n * EC-467 — the resolution of the project's reuse capability: the capability (or\n * `null`), plus the config issues a developer must SEE.\n *\n * The issues ride the EC-236 {@link CustomizationIssue} surface (`point: 'reuse'`),\n * exactly like the sidebar's and the field renderers' — so they render at the\n * studio rail foot on every surface, which is the studio's home for\n * project-CONFIGURATION problems (the document-scoped Issues pane is the wrong\n * home: this is not a content problem in one document).\n *\n * EC-478: each issue carries a `messageRef` (catalog key + identifier values)\n * alongside its prebuilt English, so the rail foot renders it in the active UI\n * locale. This module stays pure — it names a key, it never calls `translate`;\n * `lib/persona/labels.ts` resolves it at the render site. That is what makes the\n * whole list localize together, which is the mixed-language outcome EC-467\n * declined to create by localizing only its own half.\n */\nexport interface ReuseResolution {\n capability: ReuseCapability | null\n /** Empty whenever the capability is unambiguous — including \"this project has none\". */\n issues: CustomizationIssue[]\n}\n\n/** The customization point name stamped onto every reuse issue (EC-236 locator). */\nconst REUSE_POINT = 'reuse'\n\n/** A `relation` prop of a manifest, normalized for both resolution paths. */\ninterface RelationProp {\n name: string\n /** The target collection id; `undefined` when the manifest declares none. */\n target: string | undefined\n multiple: boolean\n /** EC-462: `form.hidden` present and not literally `false` (predicate or `true`). */\n conditional: boolean\n}\n\n/** Every `relation` prop of a manifest, in declaration order. */\nfunction relationPropsOf(manifest: ComponentManifest): RelationProp[] {\n const props: RelationProp[] = []\n for (const field of manifestPropFields(manifest)) {\n if (field.type !== 'relation') continue\n const hidden = field.form?.hidden\n props.push({\n name: field.name,\n target: field.target,\n multiple: field.multiple === true,\n conditional: hidden !== undefined && hidden !== false,\n })\n }\n return props\n}\n\n/**\n * The relation props a DECLARED embed could plausibly transclude through: targeted\n * and cardinality-one. A cardinality-`many` (or targetless) relation is excluded\n * structurally, not heuristically — an embed transcludes exactly one document and\n * {@link makeEmbedNode} writes exactly one `{collection,id}`, which a list-valued\n * or target-less prop cannot hold.\n */\nfunction embeddableRelationProps(manifest: ComponentManifest): TargetedRelationProp[] {\n return relationPropsOf(manifest).filter(\n (r): r is TargetedRelationProp => r.target !== undefined && !r.multiple,\n )\n}\ntype TargetedRelationProp = RelationProp & { target: string }\n\n/** The collection's first composition field — what an embed of it transcludes. */\nfunction blocksFieldOf(collection: CollectionDef) {\n const field = collection.fields.find((f) => f.type === 'blocks')\n return field?.type === 'blocks' ? field : undefined\n}\n\n/**\n * The catalog keys for the reuse config issues (EC-478), in the `builder.reuse.config.*`\n * namespace. Named here so the producer and the catalog-parity test agree on one list.\n */\nconst REUSE_KEYS = {\n unknownComponent: 'builder.reuse.config.unknownComponent',\n noRelationProp: 'builder.reuse.config.noRelationProp',\n unknownRelationProp: 'builder.reuse.config.unknownRelationProp',\n ambiguousRelationProp: 'builder.reuse.config.ambiguousRelationProp',\n unknownCollection: 'builder.reuse.config.unknownCollection',\n noBlocksField: 'builder.reuse.config.noBlocksField',\n ambiguousInference: 'builder.reuse.config.ambiguousInference',\n} as const\n\nfunction reuseIssue(\n code: CustomizationIssue['code'],\n severity: CustomizationIssue['severity'],\n message: string,\n messageRef: CustomizationMessageRef,\n id?: string,\n): CustomizationIssue {\n return {\n code,\n severity,\n message,\n messageRef,\n point: REUSE_POINT,\n ...(id === undefined ? {} : { id }),\n }\n}\n\n/** `\"a\", \"b\"` — a readable list of prop/component ids for an issue message. */\nfunction quotedList(names: readonly string[]): string {\n return names.map((name) => `\"${name}\"`).join(', ')\n}\n\n/**\n * The components whose SHAPE makes them plausible reuse embeds (the zero-config\n * heuristic + EC-462's disqualifiers), in manifest order. Exported for the tests\n * and for issue copy; the resolution rule lives in {@link resolveReuse}.\n */\nexport function inferredEmbedCandidates(\n manifests: readonly ComponentManifest[],\n collections: readonly CollectionDef[],\n): ReuseCapability[] {\n const matches: ReuseCapability[] = []\n for (const manifest of manifests) {\n if (manifest.slots.length > 0) continue\n // EC-462: a `listing` manifest is a query host by its own declaration — its\n // relation prop is the filter source, never an embed pointer.\n if (manifest.listing) continue\n // The heuristic requires the relation to be UNAMBIGUOUS within the manifest:\n // exactly ONE relation prop of any kind, which must then be targeted,\n // cardinality-one and unconditional. (Counting all relation props — not just\n // the embeddable ones — is deliberate: a component with a second, list-valued\n // relation is not the single-purpose shape the heuristic is allowed to claim.)\n const relations = relationPropsOf(manifest)\n const relation = relations.length === 1 ? relations[0]! : undefined\n if (!relation || relation.target === undefined || relation.multiple || relation.conditional)\n continue\n const collection = collections.find((c) => c.id === relation.target)\n if (!collection) continue\n const blocksField = blocksFieldOf(collection)\n if (!blocksField) continue\n matches.push({\n embedComponentId: manifest.id,\n relationProp: relation.name,\n collectionId: collection.id,\n contentField: blocksField.name,\n contentCardinality: blocksField.multiple === false ? 'one' : 'many',\n })\n }\n return matches\n}\n\n/**\n * Resolve the DECLARED embed (EC-467, AD-b). An explicit declaration is the\n * developer's authority, so the inference disqualifiers are NOT applied here: a\n * declared component may have slots, may declare `listing`, and its relation prop\n * may be `form.hidden` — those exist to stop the engine GUESSING, not to overrule\n * a stated intent. Only structural impossibilities are issues (see AD-c row 2):\n * an unknown component id, no usable relation prop (or an ambiguous set with no\n * `relationProp`), an undefined target collection, or a target with no `blocks`\n * field — each of which would leave nothing coherent to transclude.\n */\nfunction resolveDeclaredReuse(\n declaration: StudioReuseConfig,\n manifests: readonly ComponentManifest[],\n collections: readonly CollectionDef[],\n): ReuseResolution {\n const { component, relationProp } = declaration\n const fail = (message: string, messageRef: CustomizationMessageRef): ReuseResolution => ({\n capability: null,\n issues: [reuseIssue('invalid-override', 'error', message, messageRef, component)],\n })\n\n const manifest = manifests.find((m) => m.id === component)\n if (!manifest) {\n return {\n capability: null,\n issues: [\n reuseIssue(\n 'unknown-override-target',\n 'error',\n `Reusable elements: studio config declares reuse.component \"${component}\", which is not a registered component. Fix the id, or remove the reuse block to let the studio infer the embed.`,\n { key: REUSE_KEYS.unknownComponent, vars: { component } },\n component,\n ),\n ],\n }\n }\n\n const relations = embeddableRelationProps(manifest)\n if (relations.length === 0) {\n return fail(\n `Reusable elements: the declared embed \"${component}\" has no single-document relation prop, so it cannot point at a reusable document. An embed needs one relation prop with cardinality one.`,\n { key: REUSE_KEYS.noRelationProp, vars: { component } },\n )\n }\n const names = relations.map((r) => r.name)\n let relation: TargetedRelationProp\n if (relationProp !== undefined) {\n const named = relations.find((r) => r.name === relationProp)\n if (!named) {\n return fail(\n `Reusable elements: the declared embed \"${component}\" has no single-document relation prop named \"${relationProp}\". Its relation props are: ${quotedList(names)}.`,\n {\n key: REUSE_KEYS.unknownRelationProp,\n vars: { component, relationProp, props: quotedList(names) },\n },\n )\n }\n relation = named\n } else if (relations.length === 1) {\n relation = relations[0]!\n } else {\n return fail(\n `Reusable elements: the declared embed \"${component}\" has ${relations.length} single-document relation props (${quotedList(names)}) — add reuse.relationProp to say which one points at the reusable document.`,\n {\n key: REUSE_KEYS.ambiguousRelationProp,\n vars: { component, count: relations.length, props: quotedList(names) },\n },\n )\n }\n\n const collection = collections.find((c) => c.id === relation.target)\n if (!collection) {\n return fail(\n `Reusable elements: \"${component}.${relation.name}\" targets the collection \"${relation.target}\", which this project does not define.`,\n {\n key: REUSE_KEYS.unknownCollection,\n vars: { component, relationProp: relation.name, collection: relation.target },\n },\n )\n }\n const blocksField = blocksFieldOf(collection)\n if (!blocksField) {\n return fail(\n `Reusable elements: the collection \"${collection.id}\" behind \"${component}.${relation.name}\" has no blocks (composition) field, so there is nothing for the embed to transclude.`,\n {\n key: REUSE_KEYS.noBlocksField,\n vars: { component, relationProp: relation.name, collection: collection.id },\n },\n )\n }\n\n return {\n capability: {\n embedComponentId: manifest.id,\n relationProp: relation.name,\n collectionId: collection.id,\n contentField: blocksField.name,\n contentCardinality: blocksField.multiple === false ? 'one' : 'many',\n },\n issues: [],\n }\n}\n\n/**\n * Resolve the project's ONE reuse capability (AD-a) plus any config issues —\n * EC-467's full matrix (AD-c):\n *\n * | Declaration | Candidates | Result |\n * | --- | --- | --- |\n * | present, resolves | any | the declared capability, no issue |\n * | present, does not resolve | any | `null` + an ERROR issue (misconfiguration) |\n * | absent | exactly 1 | that candidate, no issue (the zero-config default) |\n * | absent | 2 or more | `null` + a WARNING issue naming every candidate |\n * | absent | 0 | `null`, **no issue** — a project need not use reusable elements |\n *\n * Severity is the honest split: a declaration that cannot resolve is broken\n * config the developer asked for (error); inference ambiguity is nothing broken —\n * the engine declines to guess and asks for one line (warning). Both disable the\n * gestures, and both render identically in the rail's issue list.\n *\n * The zero-candidate row is deliberately silent: crying wolf at every project\n * that has no reusable elements would train developers to ignore the surface.\n */\nexport function resolveReuse(\n manifests: readonly ComponentManifest[],\n collections: readonly CollectionDef[],\n declaration?: StudioReuseConfig,\n): ReuseResolution {\n if (declaration) return resolveDeclaredReuse(declaration, manifests, collections)\n\n const candidates = inferredEmbedCandidates(manifests, collections)\n if (candidates.length === 1) return { capability: candidates[0]!, issues: [] }\n if (candidates.length === 0) return { capability: null, issues: [] }\n return {\n capability: null,\n issues: [\n reuseIssue(\n 'ambiguous-inference',\n 'warning',\n `Reusable elements: ${candidates.length} components have the reusable-embed shape (${quotedList(\n candidates.map((c) => c.embedComponentId),\n )}). A project has exactly one reusable-element embed, so the studio will not guess — declare it in studio.config as reuse: { component: '<id>' }. Until then the reuse gestures are off.`,\n {\n key: REUSE_KEYS.ambiguousInference,\n vars: {\n count: candidates.length,\n components: quotedList(candidates.map((c) => c.embedComponentId)),\n },\n },\n ),\n ],\n }\n}\n\n/**\n * The project's reuse capability alone, or `null` when it is absent, ambiguous, or\n * misdeclared — for callers that act on the capability and do not surface config\n * issues themselves (the publish-integrity walk). The issues are surfaced ONCE, by\n * the shell rail; see {@link resolveReuse}.\n */\nexport function resolveReuseCapability(\n manifests: readonly ComponentManifest[],\n collections: readonly CollectionDef[],\n declaration?: StudioReuseConfig,\n): ReuseCapability | null {\n return resolveReuse(manifests, collections, declaration).capability\n}\n\n/** A slug for `name`, suffixed `-2`/`-3`/… until it is free among `taken`. */\nexport function uniqueSlug(name: string, taken: ReadonlySet<string>): string {\n const base = slugify(name) || 'element'\n let id = base\n for (let n = 2; taken.has(id); n += 1) id = `${base}-${n}`\n return id\n}\n\n/**\n * The values for a new reusable document promoted from `node` (EC-282): seeded\n * defaults, the given `name` on the title field, the node on the content field,\n * and a collision-safe slug on any UNIQUE text field (e.g. Konditorei's\n * `internalId`) so the created document is publish-valid without hardcoding that\n * field's name. Other required fields, if any, are completed by the editor when\n * they open the new reusable.\n */\nexport function buildReusableValues(\n collection: CollectionDef,\n capability: ReuseCapability,\n name: string,\n node: ComponentNode,\n existing: readonly { values?: Record<string, unknown> }[],\n): Record<string, unknown> {\n const values: Record<string, unknown> = { ...seedDefaults(collection) }\n const titleField = titleFieldOf(collection)?.name\n if (titleField) values[titleField] = name\n for (const field of collection.fields) {\n if (\n field.type === 'text' &&\n field.validation?.unique === true &&\n field.name !== titleField &&\n field.name !== capability.contentField\n ) {\n const taken = new Set(\n existing\n .map((d) => d.values?.[field.name])\n .filter((v): v is string => typeof v === 'string'),\n )\n values[field.name] = uniqueSlug(name, taken)\n }\n }\n values[capability.contentField] = capability.contentCardinality === 'one' ? node : [node]\n return values\n}\n\n/**\n * The document a reuse-embed `node` targets (EC-300), or null when `node` is not the\n * embed component or has no static pick yet. Lets the canvas detect a HARD reference\n * whose target doesn't resolve (→ placeholder) without reaching into prop internals.\n */\nexport function reuseEmbedRef(node: ComponentNode, capability: ReuseCapability): DocumentRef | null {\n if (node.componentId !== capability.embedComponentId) return null\n const prop = node.props?.[capability.relationProp]\n const ref = isStaticProp(prop) ? readDocumentRef(prop.value) : undefined\n return ref ?? null\n}\n\n/**\n * Every reuse-embed target ref in a composition `blocks` value, at any depth\n * (EC-300) — the hard references a publish-integrity check walks to find\n * unpublished targets. Recurses slots; order is document order. Duplicates are\n * kept (the caller dedupes by key if needed).\n */\nexport function collectEmbedRefs(\n blocks: readonly ComponentNode[],\n capability: ReuseCapability,\n): DocumentRef[] {\n const refs: DocumentRef[] = []\n const visit = (node: ComponentNode): void => {\n const ref = reuseEmbedRef(node, capability)\n if (ref) refs.push(ref)\n for (const children of Object.values(node.slots ?? {})) for (const child of children) visit(child)\n }\n for (const node of blocks) visit(node)\n return refs\n}\n\n/** The `Reusable` embed node referencing the new document (id minted by the action). */\nexport function makeEmbedNode(capability: ReuseCapability, documentId: string): ComponentNode {\n return {\n id: 'reuse-embed',\n componentId: capability.embedComponentId,\n props: {\n [capability.relationProp]: {\n kind: 'static',\n value: { collection: capability.collectionId, id: documentId },\n },\n },\n }\n}\n"]}
@@ -1,4 +1,5 @@
1
1
  import { matchesRef } from './document-ref-utils.js';
2
+ import { cmsValidationIssueMessage } from '@elytracms/studio/lib/persona/labels';
2
3
  export function bulkSummary(report) {
3
4
  let succeeded = 0;
4
5
  for (const result of report.results)
@@ -52,7 +53,8 @@ export async function runBulkAction(context, collectionId, ids, action, setField
52
53
  message: 'Document is not valid to publish.',
53
54
  issues: errors.map((issue) => ({
54
55
  code: issue.code,
55
- message: issue.message,
56
+ // EC-487 — resolved here, the last point that still has the structured issue.
57
+ message: cmsValidationIssueMessage(issue),
56
58
  path: [...issue.path],
57
59
  domain: 'cms',
58
60
  })),
@@ -1 +1 @@
1
- {"version":3,"file":"bulk-actions.js","sourceRoot":"","sources":["../../../src/components/cms/bulk-actions.ts"],"names":[],"mappings":"AAQA,OAAO,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAA;AA8CjD,MAAM,UAAU,WAAW,CAAC,MAAkB;IAC5C,IAAI,SAAS,GAAG,CAAC,CAAA;IACjB,KAAK,MAAM,MAAM,IAAI,MAAM,CAAC,OAAO;QAAE,IAAI,MAAM,CAAC,EAAE;YAAE,SAAS,IAAI,CAAC,CAAA;IAClE,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,CAAC,OAAO,CAAC,MAAM,GAAG,SAAS,EAAE,CAAA;AACjE,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,eAAe,CAAC,KAAqB;IACnD,IAAI,KAAK,CAAC,IAAI,KAAK,YAAY,IAAI,KAAK,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC3D,OAAO,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IAC/D,CAAC;IACD,OAAO,KAAK,CAAC,OAAO,CAAA;AACtB,CAAC;AAwCD,MAAM,YAAY,GAAsD;IACtE,OAAO,EAAE,WAAW;IACpB,SAAS,EAAE,OAAO;CACnB,CAAA;AAED;;;;;;;;;;GAUG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,OAAoB,EACpB,YAAoB,EACpB,GAAsB,EACtB,MAAkB,EAClB,QAAuB;IAEvB,MAAM,OAAO,GAAsB,EAAE,CAAA;IAErC,KAAK,MAAM,EAAE,IAAI,GAAG,EAAE,CAAC;QACrB,MAAM,GAAG,GAAgB,EAAE,UAAU,EAAE,YAAY,EAAE,EAAE,EAAE,CAAA;QACzD,MAAM,KAAK,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,CAAA;QAClC,MAAM,IAAI,GAAG,CAAC,KAAqB,EAAE,EAAE,CACrC,OAAO,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,KAAK,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,eAAe,CAAC,KAAK,CAAC,EAAE,KAAK,EAAE,CAAC,CAAA;QAEhF,MAAM,GAAG,GAAG,OAAO,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,CAAA;QAC7D,IAAI,CAAC,GAAG,EAAE,CAAC;YACT,IAAI,CAAC;gBACH,IAAI,EAAE,WAAW;gBACjB,OAAO,EAAE,uCAAuC;gBAChD,MAAM,EAAE,cAAc;gBACtB,GAAG,EAAE,GAAG,YAAY,IAAI,EAAE,EAAE;aAC7B,CAAC,CAAA;YACF,SAAQ;QACV,CAAC;QAED,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;YACzB,MAAM,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,KAAK,OAAO,CAAC,CAAA;YAClF,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;gBACtB,IAAI,CAAC;oBACH,IAAI,EAAE,YAAY;oBAClB,OAAO,EAAE,mCAAmC;oBAC5C,MAAM,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;wBAC7B,IAAI,EAAE,KAAK,CAAC,IAAI;wBAChB,OAAO,EAAE,KAAK,CAAC,OAAO;wBACtB,IAAI,EAAE,CAAC,GAAG,KAAK,CAAC,IAAI,CAAC;wBACrB,MAAM,EAAE,KAAK;qBACd,CAAC,CAAC;iBACJ,CAAC,CAAA;gBACF,SAAQ;YACV,CAAC;YACD,4EAA4E;YAC5E,0EAA0E;YAC1E,yEAAyE;YACzE,sEAAsE;YACtE,wEAAwE;YACxE,wEAAwE;YACxE,IAAI,OAAO,CAAC,eAAe,EAAE,CAAC;gBAC5B,MAAM,OAAO,GAAG,OAAO,CAAC,eAAe,CAAC,GAAG,CAAC,CAAA;gBAC5C,IAAI,OAAO,KAAK,GAAG,EAAE,CAAC;oBACpB,IAAI,CAAC;wBACH,MAAM,OAAO,CAAC,YAAY,EAAE,CAAC,GAAG,EAAE,OAAO,CAAC,CAAA;oBAC5C,CAAC;oBAAC,OAAO,KAAK,EAAE,CAAC;wBACf,IAAI,CAAC;4BACH,IAAI,EAAE,SAAS;4BACf,OAAO,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC;yBAChE,CAAC,CAAA;wBACF,SAAQ;oBACV,CAAC;gBACH,CAAC;YACH,CAAC;QACH,CAAC;QAED,IAAI,OAAO,CAAC,QAAQ,EAAE,CAAC;YACrB,IAAI,MAAgC,CAAA;YACpC,IAAI,CAAC;gBACH,MAAM,GAAG,MAAM,OAAO,CAAC,QAAQ,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;YAC9C,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,uEAAuE;gBACvE,6DAA6D;gBAC7D,MAAM,GAAG;oBACP,EAAE,EAAE,KAAK;oBACT,KAAK,EAAE;wBACL,IAAI,EAAE,SAAS;wBACf,OAAO,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC;qBAChE;iBACF,CAAA;YACH,CAAC;YACD,IAAI,CAAC,MAAM,CAAC,EAAE,EAAE,CAAC;gBACf,oEAAoE;gBACpE,gEAAgE;gBAChE,mEAAmE;gBACnE,yDAAyD;gBACzD,MAAM,gBAAgB,GAAG,MAAM,KAAK,QAAQ,IAAI,MAAM,CAAC,KAAK,CAAC,IAAI,KAAK,WAAW,CAAA;gBACjF,IAAI,CAAC,gBAAgB,EAAE,CAAC;oBACtB,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;oBAClB,SAAQ;gBACV,CAAC;YACH,CAAC;QACH,CAAC;QAED,IAAI,MAAM,KAAK,QAAQ;YAAE,OAAO,CAAC,WAAW,CAAC,GAAG,CAAC,CAAA;aAC5C,IAAI,MAAM,KAAK,WAAW,EAAE,CAAC;YAChC,IAAI,QAAQ;gBAAE,OAAO,CAAC,aAAa,EAAE,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAA;QACtD,CAAC;;YAAM,OAAO,CAAC,UAAU,CAAC,GAAG,EAAE,YAAY,CAAC,MAAM,CAAC,CAAC,CAAA;QACpD,OAAO,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,KAAK,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAA;IACxC,CAAC;IAED,OAAO,EAAE,MAAM,EAAE,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,CAAA;AAC/D,CAAC","sourcesContent":["import type {\n CmsDocument,\n CmsValidationIssue,\n DocumentRef,\n PublicationState,\n} from '@elytracms/core/cms-core'\nimport type { FilterScalar } from '@elytracms/core/content'\nimport type { OperationError, OperationResult } from '@elytracms/studio/lib/operations'\nimport { matchesRef } from './document-ref-utils'\n\n/**\n * Bulk actions over a table selection (EC-169) — the pure half.\n *\n * Each selected document gets its OWN operations command\n * (`documents.publish` / `documents.unpublish` / `documents.delete`, EC-224;\n * `documents.upsert` for a set-field, EC-247), awaited in selection order, and\n * its own per-item outcome. The report enumerates every failure with its\n * reason (validation gate, authorization denial, not-found, …) — a partial\n * failure is never silent and never collapsed into one toast. Session state is\n * only updated for items whose command succeeded.\n */\n\nexport type BulkAction = 'publish' | 'unpublish' | 'delete' | 'set-field'\n\n/**\n * The payload of a `set-field` bulk action (EC-247): assign one filterable\n * scalar field the same value across every selected document. The value is\n * folded into each document the locale-aware way (`applyCellValue`) by the\n * caller's `applySetField`/`dispatch`; this module only carries it.\n */\nexport interface BulkSetField {\n field: string\n value: FilterScalar\n}\n\n/** The outcome of one document's command within a bulk run. */\nexport interface BulkItemOutcome {\n ref: DocumentRef\n /** Display title at dispatch time (deleted docs still get named in the report). */\n title: string\n ok: boolean\n /** Human-readable failure reason — present exactly when `ok` is false. */\n reason?: string\n /** The structured error behind the reason (operation result / validation gate). */\n error?: OperationError\n}\n\nexport interface BulkReport {\n action: BulkAction\n /** Present for `set-field`: the field + value that was applied (report label). */\n setField?: BulkSetField\n results: BulkItemOutcome[]\n}\n\nexport function bulkSummary(report: BulkReport): { succeeded: number; failed: number } {\n let succeeded = 0\n for (const result of report.results) if (result.ok) succeeded += 1\n return { succeeded, failed: report.results.length - succeeded }\n}\n\n/** The failure reason a report row shows: validation issues beat the envelope. */\nexport function bulkErrorReason(error: OperationError): string {\n if (error.code === 'validation' && error.issues.length > 0) {\n return error.issues.map((issue) => issue.message).join(' · ')\n }\n return error.message\n}\n\nexport interface BulkContext {\n /** Current session documents (lookup + validation input). */\n documents: readonly CmsDocument[]\n titleOf(ref: DocumentRef): string\n /** Full document validation — gates publish: invalid documents never dispatch. */\n validate(doc: CmsDocument): CmsValidationIssue[]\n /** Apply one succeeded state transition to the session. */\n applyState(ref: DocumentRef, state: PublicationState): void\n /** Apply one succeeded delete to the session. */\n applyDelete(ref: DocumentRef): void\n /**\n * Apply one succeeded set-field to the session (locale-aware fold). Required\n * when the action is `set-field`; unused otherwise.\n */\n applySetField?(ref: DocumentRef, set: BulkSetField): void\n /**\n * EC-425: the publish-time hidden-strip (EC-273 clearHiddenValues + EC-394\n * clearHiddenProps, the SAME shared clean step the single-document `doPublish`\n * runs) — returns the document a publish should snapshot. Identity-preserving:\n * returns the input when nothing clears. Absent ⇒ publish the draft as stored\n * (fixture harnesses without a clean point).\n */\n cleanForPublish?(doc: CmsDocument): CmsDocument\n /**\n * EC-425: land a cleaned draft (session + persistence) BEFORE its publish\n * command dispatches, so the snapshot the publish pins — and the reference\n * index derived from the stored draft (EC-286) — are clean. Awaited; only\n * called when {@link cleanForPublish} actually changed the document\n * (save-if-changed, mirroring `doPublish`).\n */\n applyCleaned?(ref: DocumentRef, doc: CmsDocument): Promise<void> | void\n /**\n * Dispatch the ONE operations command for an item. Absent in fixture-only\n * sessions (no project scope) — items then mutate the session directly.\n */\n dispatch?: (action: BulkAction, ref: DocumentRef) => Promise<OperationResult<unknown>>\n}\n\nconst ACTION_STATE: Record<'publish' | 'unpublish', PublicationState> = {\n publish: 'published',\n unpublish: 'draft',\n}\n\n/**\n * Run one bulk action over a selection, item by item:\n * 1. publish gates on validation — an invalid document fails with its issues\n * and dispatches NOTHING (a set-field is a draft edit, EC-225 — never gated),\n * 2. publish joins the single-document clean point (EC-425): the hidden-strip\n * runs per item and a changed draft is saved (awaited) BEFORE the publish\n * command, so bulk-published snapshots go out as clean as editor publishes,\n * 3. each remaining item dispatches exactly one operations command, awaited,\n * 4. only succeeded items mutate the session,\n * 5. the report carries one honest outcome per item, in selection order.\n */\nexport async function runBulkAction(\n context: BulkContext,\n collectionId: string,\n ids: readonly string[],\n action: BulkAction,\n setField?: BulkSetField,\n): Promise<BulkReport> {\n const results: BulkItemOutcome[] = []\n\n for (const id of ids) {\n const ref: DocumentRef = { collection: collectionId, id }\n const title = context.titleOf(ref)\n const fail = (error: OperationError) =>\n results.push({ ref, title, ok: false, reason: bulkErrorReason(error), error })\n\n const doc = context.documents.find((d) => matchesRef(d, ref))\n if (!doc) {\n fail({\n code: 'not-found',\n message: 'Document is no longer in the session.',\n entity: 'cms-document',\n key: `${collectionId}:${id}`,\n })\n continue\n }\n\n if (action === 'publish') {\n const errors = context.validate(doc).filter((issue) => issue.severity === 'error')\n if (errors.length > 0) {\n fail({\n code: 'validation',\n message: 'Document is not valid to publish.',\n issues: errors.map((issue) => ({\n code: issue.code,\n message: issue.message,\n path: [...issue.path],\n domain: 'cms',\n })),\n })\n continue\n }\n // EC-425: the SAME clean-then-save sequence the single-document `doPublish`\n // runs, per item — validation gated on the live draft above (matching the\n // editor's pre-strip gate), then the hidden-strip; a changed draft lands\n // (awaited) before the publish command so the pinned snapshot and the\n // EC-286-derived reference index are clean. A failing save is an honest\n // per-item failure and the publish never dispatches on the dirty draft.\n if (context.cleanForPublish) {\n const cleaned = context.cleanForPublish(doc)\n if (cleaned !== doc) {\n try {\n await context.applyCleaned?.(ref, cleaned)\n } catch (cause) {\n fail({\n code: 'unknown',\n message: cause instanceof Error ? cause.message : String(cause),\n })\n continue\n }\n }\n }\n }\n\n if (context.dispatch) {\n let result: OperationResult<unknown>\n try {\n result = await context.dispatch(action, ref)\n } catch (cause) {\n // Operations never throw by contract; a broken transport still becomes\n // a visible per-item failure instead of an aborted bulk run.\n result = {\n ok: false,\n error: {\n code: 'unknown',\n message: cause instanceof Error ? cause.message : String(cause),\n },\n }\n }\n if (!result.ok) {\n // Idempotent delete: the document was never persisted (e.g. a draft\n // whose baseline upsert was rejected) or another editor already\n // deleted it. The desired end state — gone — holds, so removing it\n // from the session is the honest outcome, not a failure.\n const idempotentDelete = action === 'delete' && result.error.code === 'not-found'\n if (!idempotentDelete) {\n fail(result.error)\n continue\n }\n }\n }\n\n if (action === 'delete') context.applyDelete(ref)\n else if (action === 'set-field') {\n if (setField) context.applySetField?.(ref, setField)\n } else context.applyState(ref, ACTION_STATE[action])\n results.push({ ref, title, ok: true })\n }\n\n return { action, ...(setField ? { setField } : {}), results }\n}\n"]}
1
+ {"version":3,"file":"bulk-actions.js","sourceRoot":"","sources":["../../../src/components/cms/bulk-actions.ts"],"names":[],"mappings":"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","sourcesContent":["import type {\n CmsDocument,\n CmsValidationIssue,\n DocumentRef,\n PublicationState,\n} from '@elytracms/core/cms-core'\nimport type { FilterScalar } from '@elytracms/core/content'\nimport type { OperationError, OperationResult } from '@elytracms/studio/lib/operations'\nimport { matchesRef } from './document-ref-utils'\nimport { cmsValidationIssueMessage } from '@elytracms/studio/lib/persona/labels'\n\n/**\n * Bulk actions over a table selection (EC-169) — the pure half.\n *\n * Each selected document gets its OWN operations command\n * (`documents.publish` / `documents.unpublish` / `documents.delete`, EC-224;\n * `documents.upsert` for a set-field, EC-247), awaited in selection order, and\n * its own per-item outcome. The report enumerates every failure with its\n * reason (validation gate, authorization denial, not-found, …) — a partial\n * failure is never silent and never collapsed into one toast. Session state is\n * only updated for items whose command succeeded.\n */\n\nexport type BulkAction = 'publish' | 'unpublish' | 'delete' | 'set-field'\n\n/**\n * The payload of a `set-field` bulk action (EC-247): assign one filterable\n * scalar field the same value across every selected document. The value is\n * folded into each document the locale-aware way (`applyCellValue`) by the\n * caller's `applySetField`/`dispatch`; this module only carries it.\n */\nexport interface BulkSetField {\n field: string\n value: FilterScalar\n}\n\n/** The outcome of one document's command within a bulk run. */\nexport interface BulkItemOutcome {\n ref: DocumentRef\n /** Display title at dispatch time (deleted docs still get named in the report). */\n title: string\n ok: boolean\n /** Human-readable failure reason — present exactly when `ok` is false. */\n reason?: string\n /** The structured error behind the reason (operation result / validation gate). */\n error?: OperationError\n}\n\nexport interface BulkReport {\n action: BulkAction\n /** Present for `set-field`: the field + value that was applied (report label). */\n setField?: BulkSetField\n results: BulkItemOutcome[]\n}\n\nexport function bulkSummary(report: BulkReport): { succeeded: number; failed: number } {\n let succeeded = 0\n for (const result of report.results) if (result.ok) succeeded += 1\n return { succeeded, failed: report.results.length - succeeded }\n}\n\n/** The failure reason a report row shows: validation issues beat the envelope. */\nexport function bulkErrorReason(error: OperationError): string {\n if (error.code === 'validation' && error.issues.length > 0) {\n return error.issues.map((issue) => issue.message).join(' · ')\n }\n return error.message\n}\n\nexport interface BulkContext {\n /** Current session documents (lookup + validation input). */\n documents: readonly CmsDocument[]\n titleOf(ref: DocumentRef): string\n /** Full document validation — gates publish: invalid documents never dispatch. */\n validate(doc: CmsDocument): CmsValidationIssue[]\n /** Apply one succeeded state transition to the session. */\n applyState(ref: DocumentRef, state: PublicationState): void\n /** Apply one succeeded delete to the session. */\n applyDelete(ref: DocumentRef): void\n /**\n * Apply one succeeded set-field to the session (locale-aware fold). Required\n * when the action is `set-field`; unused otherwise.\n */\n applySetField?(ref: DocumentRef, set: BulkSetField): void\n /**\n * EC-425: the publish-time hidden-strip (EC-273 clearHiddenValues + EC-394\n * clearHiddenProps, the SAME shared clean step the single-document `doPublish`\n * runs) — returns the document a publish should snapshot. Identity-preserving:\n * returns the input when nothing clears. Absent ⇒ publish the draft as stored\n * (fixture harnesses without a clean point).\n */\n cleanForPublish?(doc: CmsDocument): CmsDocument\n /**\n * EC-425: land a cleaned draft (session + persistence) BEFORE its publish\n * command dispatches, so the snapshot the publish pins — and the reference\n * index derived from the stored draft (EC-286) — are clean. Awaited; only\n * called when {@link cleanForPublish} actually changed the document\n * (save-if-changed, mirroring `doPublish`).\n */\n applyCleaned?(ref: DocumentRef, doc: CmsDocument): Promise<void> | void\n /**\n * Dispatch the ONE operations command for an item. Absent in fixture-only\n * sessions (no project scope) — items then mutate the session directly.\n */\n dispatch?: (action: BulkAction, ref: DocumentRef) => Promise<OperationResult<unknown>>\n}\n\nconst ACTION_STATE: Record<'publish' | 'unpublish', PublicationState> = {\n publish: 'published',\n unpublish: 'draft',\n}\n\n/**\n * Run one bulk action over a selection, item by item:\n * 1. publish gates on validation — an invalid document fails with its issues\n * and dispatches NOTHING (a set-field is a draft edit, EC-225 — never gated),\n * 2. publish joins the single-document clean point (EC-425): the hidden-strip\n * runs per item and a changed draft is saved (awaited) BEFORE the publish\n * command, so bulk-published snapshots go out as clean as editor publishes,\n * 3. each remaining item dispatches exactly one operations command, awaited,\n * 4. only succeeded items mutate the session,\n * 5. the report carries one honest outcome per item, in selection order.\n */\nexport async function runBulkAction(\n context: BulkContext,\n collectionId: string,\n ids: readonly string[],\n action: BulkAction,\n setField?: BulkSetField,\n): Promise<BulkReport> {\n const results: BulkItemOutcome[] = []\n\n for (const id of ids) {\n const ref: DocumentRef = { collection: collectionId, id }\n const title = context.titleOf(ref)\n const fail = (error: OperationError) =>\n results.push({ ref, title, ok: false, reason: bulkErrorReason(error), error })\n\n const doc = context.documents.find((d) => matchesRef(d, ref))\n if (!doc) {\n fail({\n code: 'not-found',\n message: 'Document is no longer in the session.',\n entity: 'cms-document',\n key: `${collectionId}:${id}`,\n })\n continue\n }\n\n if (action === 'publish') {\n const errors = context.validate(doc).filter((issue) => issue.severity === 'error')\n if (errors.length > 0) {\n fail({\n code: 'validation',\n message: 'Document is not valid to publish.',\n issues: errors.map((issue) => ({\n code: issue.code,\n // EC-487 — resolved here, the last point that still has the structured issue.\n message: cmsValidationIssueMessage(issue),\n path: [...issue.path],\n domain: 'cms',\n })),\n })\n continue\n }\n // EC-425: the SAME clean-then-save sequence the single-document `doPublish`\n // runs, per item — validation gated on the live draft above (matching the\n // editor's pre-strip gate), then the hidden-strip; a changed draft lands\n // (awaited) before the publish command so the pinned snapshot and the\n // EC-286-derived reference index are clean. A failing save is an honest\n // per-item failure and the publish never dispatches on the dirty draft.\n if (context.cleanForPublish) {\n const cleaned = context.cleanForPublish(doc)\n if (cleaned !== doc) {\n try {\n await context.applyCleaned?.(ref, cleaned)\n } catch (cause) {\n fail({\n code: 'unknown',\n message: cause instanceof Error ? cause.message : String(cause),\n })\n continue\n }\n }\n }\n }\n\n if (context.dispatch) {\n let result: OperationResult<unknown>\n try {\n result = await context.dispatch(action, ref)\n } catch (cause) {\n // Operations never throw by contract; a broken transport still becomes\n // a visible per-item failure instead of an aborted bulk run.\n result = {\n ok: false,\n error: {\n code: 'unknown',\n message: cause instanceof Error ? cause.message : String(cause),\n },\n }\n }\n if (!result.ok) {\n // Idempotent delete: the document was never persisted (e.g. a draft\n // whose baseline upsert was rejected) or another editor already\n // deleted it. The desired end state — gone — holds, so removing it\n // from the session is the honest outcome, not a failure.\n const idempotentDelete = action === 'delete' && result.error.code === 'not-found'\n if (!idempotentDelete) {\n fail(result.error)\n continue\n }\n }\n }\n\n if (action === 'delete') context.applyDelete(ref)\n else if (action === 'set-field') {\n if (setField) context.applySetField?.(ref, setField)\n } else context.applyState(ref, ACTION_STATE[action])\n results.push({ ref, title, ok: true })\n }\n\n return { action, ...(setField ? { setField } : {}), results }\n}\n"]}
@@ -1,5 +1,6 @@
1
1
  import { createDocumentStore, isFieldLocalized, validateDocument } from '@elytracms/core/cms-core';
2
2
  import { replaceDoc } from './document-ref-utils.js';
3
+ import { cmsValidationIssueMessage } from '@elytracms/studio/lib/persona/labels';
3
4
  /** Stable key for a cell (state maps, DOM lookups). */
4
5
  export function cellKey(coord) {
5
6
  return `${coord.docId}::${coord.field}`;
@@ -105,10 +106,13 @@ export async function commitCellEdit(context, input) {
105
106
  ok: false,
106
107
  error: {
107
108
  code: 'validation',
108
- message: issues[0].message,
109
+ // EC-487: an `OperationIssue` carries no catalog reference (it crosses the
110
+ // operations boundary as plain text), so the message is resolved HERE, at the
111
+ // last point that still has the structured issue.
112
+ message: cmsValidationIssueMessage(issues[0]),
109
113
  issues: issues.map((issue) => ({
110
114
  code: issue.code,
111
- message: issue.message,
115
+ message: cmsValidationIssueMessage(issue),
112
116
  path: [...issue.path],
113
117
  domain: 'cms',
114
118
  })),
@@ -1 +1 @@
1
- {"version":3,"file":"cell-edit.js","sourceRoot":"","sources":["../../../src/components/cms/cell-edit.ts"],"names":[],"mappings":"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","sourcesContent":["import { createDocumentStore, isFieldLocalized, validateDocument } from '@elytracms/core/cms-core'\nimport type {\n CmsDocument,\n CmsValidationIssue,\n CollectionDef,\n CollectionRegistry,\n FieldDef,\n Locale,\n LocaleConfig,\n} from '@elytracms/core/cms-core'\nimport type { OperationError, OperationResult } from '@elytracms/studio/lib/operations'\nimport { replaceDoc } from './document-ref-utils'\n\n/**\n * Inline cell editing for the collection table (EC-168) — the pure half.\n *\n * Everything here is plain data + functions so the cell-edit state machine is\n * testable without a DOM: which cells are editable, how Tab/Enter advance,\n * how a draft value is folded into a document (active locale only), and how a\n * commit flows through exactly one `documents.upsert` operations command with\n * optimistic apply + rollback on rejection.\n */\n\n// --- Cell identity -----------------------------------------------------------\n\n/** A cell in the collection table: one document × one field column. */\nexport interface CellCoord {\n docId: string\n field: string\n}\n\n/** Stable key for a cell (state maps, DOM lookups). */\nexport function cellKey(coord: CellCoord): string {\n return `${coord.docId}::${coord.field}`\n}\n\n// --- Editability -------------------------------------------------------------\n\n/** How a cell edits: inline control, anchored picker dialog, or not at all. */\nexport type CellEditKind = 'inline' | 'picker' | 'none'\n\nconst INLINE_TYPES: ReadonlySet<FieldDef['type']> = new Set([\n 'text',\n 'number',\n 'boolean',\n 'select',\n 'date',\n])\n\n/**\n * Classify a field's in-table editing surface. Rich text is deliberately not\n * inline-editable (the detail editor owns it); read-only fields never edit.\n */\nexport function cellEditKind(field: FieldDef, readOnly: boolean): CellEditKind {\n if (readOnly) return 'none'\n if (field.type === 'relation' || field.type === 'asset') return 'picker'\n return INLINE_TYPES.has(field.type) ? 'inline' : 'none'\n}\n\n// --- Keyboard advance --------------------------------------------------------\n\n/** Where a committed edit moves next: Tab → next, Shift-Tab → prev, Enter → down. */\nexport type AdvanceDirection = 'next' | 'prev' | 'down'\n\n/**\n * The next editable cell in the table grid, or `null` at a boundary.\n * `docIds` is the rendered row order; `fields` the editable column order.\n * 'next'/'prev' walk row-major (wrapping across rows); 'down' stays in the\n * same column.\n */\nexport function nextEditableCell(\n docIds: readonly string[],\n fields: readonly string[],\n current: CellCoord,\n direction: AdvanceDirection,\n): CellCoord | null {\n const row = docIds.indexOf(current.docId)\n const col = fields.indexOf(current.field)\n if (row < 0 || col < 0 || fields.length === 0) return null\n if (direction === 'down') {\n const nextDoc = docIds[row + 1]\n return nextDoc === undefined ? null : { docId: nextDoc, field: current.field }\n }\n const flat = row * fields.length + col + (direction === 'next' ? 1 : -1)\n if (flat < 0 || flat >= docIds.length * fields.length) return null\n const docId = docIds[Math.floor(flat / fields.length)]!\n return { docId, field: fields[flat % fields.length]! }\n}\n\n// --- Applying a draft value ----------------------------------------------------\n\n/**\n * Fold one field value into a document. Localized fields write ONLY the given\n * locale's bag (other locale variants untouched, EC-018); non-localized fields\n * write the shared `values` bag. `undefined` clears the entry.\n */\nexport function applyCellValue(\n collection: CollectionDef,\n doc: CmsDocument,\n fieldName: string,\n locale: Locale,\n value: unknown,\n): CmsDocument {\n if (isFieldLocalized(collection, fieldName)) {\n const perLocale = { ...(doc.localized?.[locale] ?? {}) }\n if (value === undefined) delete perLocale[fieldName]\n else perLocale[fieldName] = value\n return { ...doc, localized: { ...doc.localized, [locale]: perLocale } }\n }\n const values = { ...doc.values }\n if (value === undefined) delete values[fieldName]\n else values[fieldName] = value\n return { ...doc, values }\n}\n\n// --- Validation gate -----------------------------------------------------------\n\n/**\n * Error-severity issues that the edited cell itself caused. Localized fields\n * only reject on issues for the edited locale — a missing translation in some\n * *other* locale must not block this commit.\n */\nexport function cellEditIssues(\n registry: CollectionRegistry,\n documents: readonly CmsDocument[],\n localeConfig: LocaleConfig,\n collection: CollectionDef,\n next: CmsDocument,\n fieldName: string,\n locale: Locale,\n): CmsValidationIssue[] {\n // Validate against the session with the edited document swapped in, so\n // self-relations and same-document targets resolve.\n const store = createDocumentStore(replaceDoc(documents, next))\n const issues = validateDocument(registry, next, { store, localeConfig })\n const localized = isFieldLocalized(collection, fieldName)\n return issues.filter((issue) => {\n if (issue.severity !== 'error') return false\n if (issue.meta?.field !== fieldName) return false\n if (!localized) return true\n // Localized field paths are ['documents', key, 'localized', <locale>, <field>, …].\n const at = issue.path.indexOf('localized')\n return at < 0 || issue.path[at + 1] === locale\n })\n}\n\n// --- Commit pipeline -------------------------------------------------------------\n\n/** Outcome of one cell commit: the new document, or a structured rejection. */\nexport type CellCommitOutcome =\n | { ok: true; doc: CmsDocument }\n | { ok: false; error: OperationError }\n\nexport interface CellCommitInput {\n collection: CollectionDef\n /** The document as currently rendered (the rollback target). */\n doc: CmsDocument\n field: FieldDef\n /** The active locale at the point of edit (table locale resolution). */\n locale: Locale\n /** The committed draft value (`undefined` clears the field). */\n value: unknown\n}\n\nexport interface CellCommitContext {\n registry: CollectionRegistry\n /** Current session documents (relation/asset target resolution). */\n documents: readonly CmsDocument[]\n localeConfig: LocaleConfig\n /** Optimistically apply the edited document to the session. */\n applySession: (next: CmsDocument) => void\n /** Restore the pre-edit document after a rejected dispatch. */\n rollbackSession: (previous: CmsDocument) => void\n /**\n * Dispatch the ONE operations command for this commit\n * (`documents.upsert`, EC-141). Absent in fixture-only sessions\n * (no project scope) — the commit is session-only then.\n */\n dispatch?: (next: CmsDocument) => Promise<OperationResult<unknown>>\n}\n\n/**\n * Commit one cell edit:\n * 1. fold the value into the document (active locale only),\n * 2. gate on field validation — rejected edits dispatch NOTHING and report a\n * structured validation error for the cell,\n * 3. apply optimistically, dispatch exactly one operations command,\n * 4. roll the session back when the operation result is a rejection.\n */\nexport async function commitCellEdit(\n context: CellCommitContext,\n input: CellCommitInput,\n): Promise<CellCommitOutcome> {\n const next = applyCellValue(input.collection, input.doc, input.field.name, input.locale, input.value)\n\n const issues = cellEditIssues(\n context.registry,\n context.documents,\n context.localeConfig,\n input.collection,\n next,\n input.field.name,\n input.locale,\n )\n if (issues.length > 0) {\n return {\n ok: false,\n error: {\n code: 'validation',\n message: issues[0]!.message,\n issues: issues.map((issue) => ({\n code: issue.code,\n message: issue.message,\n path: [...issue.path],\n domain: 'cms',\n })),\n },\n }\n }\n\n context.applySession(next)\n if (context.dispatch) {\n let result: OperationResult<unknown>\n try {\n result = await context.dispatch(next)\n } catch (cause) {\n // Operations never throw by contract; treat a broken transport the same\n // as a failed command so the cell still rolls back visibly.\n result = {\n ok: false,\n error: { code: 'unknown', message: cause instanceof Error ? cause.message : String(cause) },\n }\n }\n if (!result.ok) {\n context.rollbackSession(input.doc)\n return { ok: false, error: result.error }\n }\n }\n return { ok: true, doc: next }\n}\n\n/** The message the cell renders for a rejection: the first issue beats the envelope. */\nexport function cellErrorMessage(error: OperationError): string {\n if (error.code === 'validation' && error.issues.length > 0) return error.issues[0]!.message\n return error.message\n}\n"]}
1
+ {"version":3,"file":"cell-edit.js","sourceRoot":"","sources":["../../../src/components/cms/cell-edit.ts"],"names":[],"mappings":"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","sourcesContent":["import { createDocumentStore, isFieldLocalized, validateDocument } from '@elytracms/core/cms-core'\nimport type {\n CmsDocument,\n CmsValidationIssue,\n CollectionDef,\n CollectionRegistry,\n FieldDef,\n Locale,\n LocaleConfig,\n} from '@elytracms/core/cms-core'\nimport type { OperationError, OperationResult } from '@elytracms/studio/lib/operations'\nimport { replaceDoc } from './document-ref-utils'\nimport { cmsValidationIssueMessage } from '@elytracms/studio/lib/persona/labels'\n\n/**\n * Inline cell editing for the collection table (EC-168) — the pure half.\n *\n * Everything here is plain data + functions so the cell-edit state machine is\n * testable without a DOM: which cells are editable, how Tab/Enter advance,\n * how a draft value is folded into a document (active locale only), and how a\n * commit flows through exactly one `documents.upsert` operations command with\n * optimistic apply + rollback on rejection.\n */\n\n// --- Cell identity -----------------------------------------------------------\n\n/** A cell in the collection table: one document × one field column. */\nexport interface CellCoord {\n docId: string\n field: string\n}\n\n/** Stable key for a cell (state maps, DOM lookups). */\nexport function cellKey(coord: CellCoord): string {\n return `${coord.docId}::${coord.field}`\n}\n\n// --- Editability -------------------------------------------------------------\n\n/** How a cell edits: inline control, anchored picker dialog, or not at all. */\nexport type CellEditKind = 'inline' | 'picker' | 'none'\n\nconst INLINE_TYPES: ReadonlySet<FieldDef['type']> = new Set([\n 'text',\n 'number',\n 'boolean',\n 'select',\n 'date',\n])\n\n/**\n * Classify a field's in-table editing surface. Rich text is deliberately not\n * inline-editable (the detail editor owns it); read-only fields never edit.\n */\nexport function cellEditKind(field: FieldDef, readOnly: boolean): CellEditKind {\n if (readOnly) return 'none'\n if (field.type === 'relation' || field.type === 'asset') return 'picker'\n return INLINE_TYPES.has(field.type) ? 'inline' : 'none'\n}\n\n// --- Keyboard advance --------------------------------------------------------\n\n/** Where a committed edit moves next: Tab → next, Shift-Tab → prev, Enter → down. */\nexport type AdvanceDirection = 'next' | 'prev' | 'down'\n\n/**\n * The next editable cell in the table grid, or `null` at a boundary.\n * `docIds` is the rendered row order; `fields` the editable column order.\n * 'next'/'prev' walk row-major (wrapping across rows); 'down' stays in the\n * same column.\n */\nexport function nextEditableCell(\n docIds: readonly string[],\n fields: readonly string[],\n current: CellCoord,\n direction: AdvanceDirection,\n): CellCoord | null {\n const row = docIds.indexOf(current.docId)\n const col = fields.indexOf(current.field)\n if (row < 0 || col < 0 || fields.length === 0) return null\n if (direction === 'down') {\n const nextDoc = docIds[row + 1]\n return nextDoc === undefined ? null : { docId: nextDoc, field: current.field }\n }\n const flat = row * fields.length + col + (direction === 'next' ? 1 : -1)\n if (flat < 0 || flat >= docIds.length * fields.length) return null\n const docId = docIds[Math.floor(flat / fields.length)]!\n return { docId, field: fields[flat % fields.length]! }\n}\n\n// --- Applying a draft value ----------------------------------------------------\n\n/**\n * Fold one field value into a document. Localized fields write ONLY the given\n * locale's bag (other locale variants untouched, EC-018); non-localized fields\n * write the shared `values` bag. `undefined` clears the entry.\n */\nexport function applyCellValue(\n collection: CollectionDef,\n doc: CmsDocument,\n fieldName: string,\n locale: Locale,\n value: unknown,\n): CmsDocument {\n if (isFieldLocalized(collection, fieldName)) {\n const perLocale = { ...(doc.localized?.[locale] ?? {}) }\n if (value === undefined) delete perLocale[fieldName]\n else perLocale[fieldName] = value\n return { ...doc, localized: { ...doc.localized, [locale]: perLocale } }\n }\n const values = { ...doc.values }\n if (value === undefined) delete values[fieldName]\n else values[fieldName] = value\n return { ...doc, values }\n}\n\n// --- Validation gate -----------------------------------------------------------\n\n/**\n * Error-severity issues that the edited cell itself caused. Localized fields\n * only reject on issues for the edited locale — a missing translation in some\n * *other* locale must not block this commit.\n */\nexport function cellEditIssues(\n registry: CollectionRegistry,\n documents: readonly CmsDocument[],\n localeConfig: LocaleConfig,\n collection: CollectionDef,\n next: CmsDocument,\n fieldName: string,\n locale: Locale,\n): CmsValidationIssue[] {\n // Validate against the session with the edited document swapped in, so\n // self-relations and same-document targets resolve.\n const store = createDocumentStore(replaceDoc(documents, next))\n const issues = validateDocument(registry, next, { store, localeConfig })\n const localized = isFieldLocalized(collection, fieldName)\n return issues.filter((issue) => {\n if (issue.severity !== 'error') return false\n if (issue.meta?.field !== fieldName) return false\n if (!localized) return true\n // Localized field paths are ['documents', key, 'localized', <locale>, <field>, …].\n const at = issue.path.indexOf('localized')\n return at < 0 || issue.path[at + 1] === locale\n })\n}\n\n// --- Commit pipeline -------------------------------------------------------------\n\n/** Outcome of one cell commit: the new document, or a structured rejection. */\nexport type CellCommitOutcome =\n | { ok: true; doc: CmsDocument }\n | { ok: false; error: OperationError }\n\nexport interface CellCommitInput {\n collection: CollectionDef\n /** The document as currently rendered (the rollback target). */\n doc: CmsDocument\n field: FieldDef\n /** The active locale at the point of edit (table locale resolution). */\n locale: Locale\n /** The committed draft value (`undefined` clears the field). */\n value: unknown\n}\n\nexport interface CellCommitContext {\n registry: CollectionRegistry\n /** Current session documents (relation/asset target resolution). */\n documents: readonly CmsDocument[]\n localeConfig: LocaleConfig\n /** Optimistically apply the edited document to the session. */\n applySession: (next: CmsDocument) => void\n /** Restore the pre-edit document after a rejected dispatch. */\n rollbackSession: (previous: CmsDocument) => void\n /**\n * Dispatch the ONE operations command for this commit\n * (`documents.upsert`, EC-141). Absent in fixture-only sessions\n * (no project scope) — the commit is session-only then.\n */\n dispatch?: (next: CmsDocument) => Promise<OperationResult<unknown>>\n}\n\n/**\n * Commit one cell edit:\n * 1. fold the value into the document (active locale only),\n * 2. gate on field validation — rejected edits dispatch NOTHING and report a\n * structured validation error for the cell,\n * 3. apply optimistically, dispatch exactly one operations command,\n * 4. roll the session back when the operation result is a rejection.\n */\nexport async function commitCellEdit(\n context: CellCommitContext,\n input: CellCommitInput,\n): Promise<CellCommitOutcome> {\n const next = applyCellValue(input.collection, input.doc, input.field.name, input.locale, input.value)\n\n const issues = cellEditIssues(\n context.registry,\n context.documents,\n context.localeConfig,\n input.collection,\n next,\n input.field.name,\n input.locale,\n )\n if (issues.length > 0) {\n return {\n ok: false,\n error: {\n code: 'validation',\n // EC-487: an `OperationIssue` carries no catalog reference (it crosses the\n // operations boundary as plain text), so the message is resolved HERE, at the\n // last point that still has the structured issue.\n message: cmsValidationIssueMessage(issues[0]!),\n issues: issues.map((issue) => ({\n code: issue.code,\n message: cmsValidationIssueMessage(issue),\n path: [...issue.path],\n domain: 'cms',\n })),\n },\n }\n }\n\n context.applySession(next)\n if (context.dispatch) {\n let result: OperationResult<unknown>\n try {\n result = await context.dispatch(next)\n } catch (cause) {\n // Operations never throw by contract; treat a broken transport the same\n // as a failed command so the cell still rolls back visibly.\n result = {\n ok: false,\n error: { code: 'unknown', message: cause instanceof Error ? cause.message : String(cause) },\n }\n }\n if (!result.ok) {\n context.rollbackSession(input.doc)\n return { ok: false, error: result.error }\n }\n }\n return { ok: true, doc: next }\n}\n\n/** The message the cell renders for a rejection: the first issue beats the envelope. */\nexport function cellErrorMessage(error: OperationError): string {\n if (error.code === 'validation' && error.issues.length > 0) return error.issues[0]!.message\n return error.message\n}\n"]}
@@ -211,14 +211,7 @@ export declare function buildNewDocument(collectionId: string, registry: Collect
211
211
  * can never materialize a second singleton document.
212
212
  */
213
213
  export declare function hasDocumentFor(docs: readonly CmsDocument[], collectionId: string): boolean;
214
- /**
215
- * The CMS session store (EC-141): documents and collections are optimistic,
216
- * synchronous session state (live validation while typing); every mutation —
217
- * create/save/delete/state transitions — is mirrored onto the operations
218
- * layer (`documents.*` / `schema.*` commands) through {@link createCmsPersister}.
219
- * Failed/denied commands surface as {@link CmsStore.persistError}.
220
- */
221
- export declare function CmsProvider({ children, projectId, initialCollections, initialDocuments, initialPublishedKeys, initialTimestamps, initialPublishTimestamps, initialChangedKeys, initialLocaleConfig, seedReflectsWrites, }: {
214
+ export interface CmsProviderProps {
222
215
  children: ReactNode;
223
216
  /** Enables persistence through the shared operations client. */
224
217
  projectId?: string;
@@ -229,13 +222,23 @@ export declare function CmsProvider({ children, projectId, initialCollections, i
229
222
  * Omitted → the historical mode default (connected hydrates, playground may not).
230
223
  */
231
224
  seedReflectsWrites?: boolean;
232
- /** Seed collections (e.g. from an imported starter kit, EC-119); defaults to the samples. */
225
+ /**
226
+ * The session's collections — the project's own schema (hydration, a starter
227
+ * kit, or `elytra.config.ts`). Absent → **no collections** (EC-479): the store
228
+ * used to fall back to `cms-core`'s `sampleFilterableCollections`, so a studio
229
+ * whose config never arrived showed the engine's demo schema as if it were the
230
+ * project's. The absence is now reported at the rail (`projectConfigIssues`).
231
+ */
233
232
  initialCollections?: CollectionDef[];
233
+ /**
234
+ * The session's documents. Absent → **none** (EC-479; was `sampleDocuments`).
235
+ */
234
236
  initialDocuments?: CmsDocument[];
235
237
  /**
236
238
  * Published document keys from hydration (EC-224): the version pointer
237
- * (`publishedVersion` set). Absent (kit / fixture sessions) → the sample
238
- * fixtures' published set (`samplePublishedKeys`).
239
+ * (`publishedVersion` set). Absent (kit / fixture sessions) → none published
240
+ * (EC-479 retired the `samplePublishedKeys` fixture default, which only ever
241
+ * described the engine's own sample documents).
239
242
  */
240
243
  initialPublishedKeys?: readonly string[];
241
244
  /**
@@ -261,9 +264,33 @@ export declare function CmsProvider({ children, projectId, initialCollections, i
261
264
  * returned records. Absent (playground / kit) → empty set.
262
265
  */
263
266
  initialChangedKeys?: readonly string[];
264
- /** Locale config for the seeded content; defaults to the sample config. */
267
+ /**
268
+ * Locale config for this session — the PROJECT's own (config-declared locales,
269
+ * or its persisted settings; see `resolveCmsInitialLocaleConfig`). Absent → the
270
+ * active config project's locales are read directly, and failing that the
271
+ * session runs with no locale at all rather than the engine's `en/de`
272
+ * `sampleLocaleConfig` fixture (EC-479/EC-483: the default locale is the literal
273
+ * storage key for localized values, so inventing one is a future data migration).
274
+ */
265
275
  initialLocaleConfig?: LocaleConfig;
266
- }): import("react").JSX.Element;
276
+ }
277
+ /**
278
+ * The CMS session store (EC-141): documents and collections are optimistic,
279
+ * synchronous session state (live validation while typing); every mutation —
280
+ * create/save/delete/state transitions — is mirrored onto the operations
281
+ * layer (`documents.*` / `schema.*` commands) through {@link createCmsPersister}.
282
+ * Failed/denied commands surface as {@link CmsStore.persistError}.
283
+ *
284
+ * EC-479 — this outer component exists to make ONE property structural: a CMS
285
+ * session always runs in the project's own locale, or it does not run. The locale
286
+ * is resolved here (the caller's, else the active `elytra.config.ts` project's)
287
+ * and handed to {@link CmsSession} as a REQUIRED value, so no branch inside the
288
+ * session can reach for the engine's `sampleLocaleConfig` — the default locale is
289
+ * the storage key every localized value is written under (EC-483), and a session
290
+ * that guessed it would corrupt content rather than fail. With no locale at all
291
+ * the surface says so instead of editing in an invented one.
292
+ */
293
+ export declare function CmsProvider(props: CmsProviderProps): import("react").JSX.Element;
267
294
  export { useCms };
268
295
  export { primaryField };
269
296
  export type { Locale };