@elytracms/studio 0.0.41 → 0.0.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/assets/asset-detail.js +1 -1
- package/dist/components/assets/asset-detail.js.map +1 -1
- package/dist/components/assets/assets-module.js +19 -6
- package/dist/components/assets/assets-module.js.map +1 -1
- package/dist/components/assets/assets-store.d.ts +2 -1
- package/dist/components/assets/assets-store.js +4 -8
- package/dist/components/assets/assets-store.js.map +1 -1
- package/dist/components/assets/assets-view-preference.d.ts +0 -8
- package/dist/components/assets/assets-view-preference.js +12 -13
- package/dist/components/assets/assets-view-preference.js.map +1 -1
- package/dist/components/auth-context.d.ts +2 -1
- package/dist/components/auth-context.js +6 -8
- package/dist/components/auth-context.js.map +1 -1
- package/dist/components/builder/builder-store.d.ts +2 -2
- package/dist/components/builder/builder-store.js +4 -8
- package/dist/components/builder/builder-store.js.map +1 -1
- package/dist/components/builder/canvas.js +16 -2
- package/dist/components/builder/canvas.js.map +1 -1
- package/dist/components/cms/bulk-actions.js +2 -1
- package/dist/components/cms/bulk-actions.js.map +1 -1
- package/dist/components/cms/cell-edit.js +2 -1
- package/dist/components/cms/cell-edit.js.map +1 -1
- package/dist/components/cms/cms-composition-validators.d.ts +25 -0
- package/dist/components/cms/cms-composition-validators.js +75 -0
- package/dist/components/cms/cms-composition-validators.js.map +1 -0
- package/dist/components/cms/cms-labels.d.ts +15 -0
- package/dist/components/cms/cms-labels.js +27 -0
- package/dist/components/cms/cms-labels.js.map +1 -0
- package/dist/components/cms/cms-store.d.ts +23 -10
- package/dist/components/cms/cms-store.js +78 -154
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/collection-table.js +1 -1
- package/dist/components/cms/collection-table.js.map +1 -1
- package/dist/components/cms/detail-editor.js +67 -67
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/details-pane.d.ts +11 -21
- package/dist/components/cms/details-pane.js +14 -31
- package/dist/components/cms/details-pane.js.map +1 -1
- package/dist/components/cms/discard-draft.d.ts +18 -0
- package/dist/components/cms/discard-draft.js +18 -0
- package/dist/components/cms/discard-draft.js.map +1 -1
- package/dist/components/cms/document-editor-context.d.ts +2 -1
- package/dist/components/cms/document-editor-context.js +4 -9
- package/dist/components/cms/document-editor-context.js.map +1 -1
- package/dist/components/cms/document-ref-utils.d.ts +33 -0
- package/dist/components/cms/document-ref-utils.js +47 -0
- package/dist/components/cms/document-ref-utils.js.map +1 -0
- package/dist/components/cms/document-url-path.d.ts +39 -0
- package/dist/components/cms/document-url-path.js +60 -0
- package/dist/components/cms/document-url-path.js.map +1 -0
- package/dist/components/cms/field-control.js +19 -13
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/cms/issues-pane.js +1 -1
- package/dist/components/cms/issues-pane.js.map +1 -1
- package/dist/components/cms/rich-text/block-commands.d.ts +36 -0
- package/dist/components/cms/rich-text/block-commands.js +47 -0
- package/dist/components/cms/rich-text/block-commands.js.map +1 -0
- package/dist/components/cms/rich-text/slash-command.js +25 -8
- package/dist/components/cms/rich-text/slash-command.js.map +1 -1
- package/dist/components/cms/rich-text/toolbar.js +3 -2
- package/dist/components/cms/rich-text/toolbar.js.map +1 -1
- package/dist/components/cms/version-compare.js +1 -1
- package/dist/components/cms/version-compare.js.map +1 -1
- package/dist/components/cms/versions-model.d.ts +31 -0
- package/dist/components/cms/versions-model.js +30 -0
- package/dist/components/cms/versions-model.js.map +1 -1
- package/dist/components/cms/view-bar.js +9 -6
- package/dist/components/cms/view-bar.js.map +1 -1
- package/dist/components/collab/collab-context.d.ts +2 -1
- package/dist/components/collab/collab-context.js +4 -8
- package/dist/components/collab/collab-context.js.map +1 -1
- package/dist/components/collection-view/collection-view-context.d.ts +9 -1
- package/dist/components/collection-view/collection-view-context.js +4 -9
- package/dist/components/collection-view/collection-view-context.js.map +1 -1
- package/dist/components/components/component-detail.js +2 -5
- package/dist/components/components/component-detail.js.map +1 -1
- package/dist/components/components/component-preview.js +8 -4
- package/dist/components/components/component-preview.js.map +1 -1
- package/dist/components/components/registry-browser.js +24 -5
- package/dist/components/components/registry-browser.js.map +1 -1
- package/dist/components/components/transfer-dialog.d.ts +10 -0
- package/dist/components/components/transfer-dialog.js +24 -8
- package/dist/components/components/transfer-dialog.js.map +1 -1
- package/dist/components/delivery-health-banner.d.ts +12 -6
- package/dist/components/delivery-health-banner.js +27 -29
- package/dist/components/delivery-health-banner.js.map +1 -1
- package/dist/components/dev-login-badge.d.ts +0 -2
- package/dist/components/dev-login-badge.js +1 -3
- package/dist/components/dev-login-badge.js.map +1 -1
- package/dist/components/inspector/inspector-sidebar.js +20 -3
- package/dist/components/inspector/inspector-sidebar.js.map +1 -1
- package/dist/components/playground-badge.d.ts +0 -2
- package/dist/components/playground-badge.js +0 -2
- package/dist/components/playground-badge.js.map +1 -1
- package/dist/components/project-list.js +2 -6
- package/dist/components/project-list.js.map +1 -1
- package/dist/components/project-session.d.ts +6 -2
- package/dist/components/project-session.js +32 -83
- package/dist/components/project-session.js.map +1 -1
- package/dist/components/project-switcher.js +2 -6
- package/dist/components/project-switcher.js.map +1 -1
- package/dist/components/redirects/redirects-store.d.ts +2 -1
- package/dist/components/redirects/redirects-store.js +4 -8
- package/dist/components/redirects/redirects-store.js.map +1 -1
- package/dist/components/studio-context.d.ts +2 -1
- package/dist/components/studio-context.js +4 -8
- package/dist/components/studio-context.js.map +1 -1
- package/dist/components/studio-shell.js +1 -1
- package/dist/components/studio-shell.js.map +1 -1
- package/dist/components/theme-context.d.ts +2 -1
- package/dist/components/theme-context.js +4 -8
- package/dist/components/theme-context.js.map +1 -1
- package/dist/components/use-hydration-phase.d.ts +65 -0
- package/dist/components/use-hydration-phase.js +64 -0
- package/dist/components/use-hydration-phase.js.map +1 -0
- package/dist/components/users/users-store.d.ts +2 -1
- package/dist/components/users/users-store.js +4 -8
- package/dist/components/users/users-store.js.map +1 -1
- package/dist/components/workspace-projects.d.ts +14 -0
- package/dist/components/workspace-projects.js +21 -0
- package/dist/components/workspace-projects.js.map +1 -1
- package/dist/lib/data/data-store.d.ts +2 -1
- package/dist/lib/data/data-store.js +4 -8
- package/dist/lib/data/data-store.js.map +1 -1
- package/dist/lib/delivery/instant-publish.d.ts +9 -13
- package/dist/lib/delivery/instant-publish.js +0 -7
- package/dist/lib/delivery/instant-publish.js.map +1 -1
- package/dist/lib/diff-view-preference.js +4 -4
- package/dist/lib/diff-view-preference.js.map +1 -1
- package/dist/lib/i18n/messages.en.js +7 -5
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/local-preference.d.ts +37 -0
- package/dist/lib/local-preference.js +36 -0
- package/dist/lib/local-preference.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.d.ts +3 -1
- package/dist/lib/persistence/convex-adapter.js +20 -0
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/lib/rail.js +7 -6
- package/dist/lib/rail.js.map +1 -1
- package/dist/lib/required-context.d.ts +24 -0
- package/dist/lib/required-context.js +28 -0
- package/dist/lib/required-context.js.map +1 -0
- package/dist/lib/theme.js +11 -4
- package/dist/lib/theme.js.map +1 -1
- package/dist/ui/index.d.ts +1 -0
- package/dist/ui/index.js +1 -0
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/segmented-control.d.ts +46 -0
- package/dist/ui/segmented-control.js +13 -0
- package/dist/ui/segmented-control.js.map +1 -0
- package/package.json +2 -2
|
@@ -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'\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(\n documents.map((d) => (d.collection === next.collection && d.id === next.id ? next : d)),\n )\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'\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"]}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { BlocksFieldDef, CompositionIssue, RichTextFieldDef } from '@elytracms/core/cms-core';
|
|
2
|
+
/**
|
|
3
|
+
* Composition validator adapters (EC-326, extracted from `cms-store.tsx`) —
|
|
4
|
+
* injected into `validateDocument`'s `validateComposition`/`validateRichTextEmbeds`
|
|
5
|
+
* options so `@elytracms/core/cms-core` stays free of `@elytracms/core/project-graph`
|
|
6
|
+
* and `@elytracms/core/rich-text`.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* EC-188: validate a `blocks` composition field value against the field's `allow`
|
|
10
|
+
* vocabulary + the code-owned component registry (the leash), mapping graph
|
|
11
|
+
* issues into plain CMS findings. Never throws — a malformed (non-node)
|
|
12
|
+
* value degrades to a single finding, matching the "validation is first-class,
|
|
13
|
+
* never a crash" rule.
|
|
14
|
+
*/
|
|
15
|
+
export declare function validateComposition(value: unknown, field: BlocksFieldDef): CompositionIssue[];
|
|
16
|
+
/**
|
|
17
|
+
* EC-189: validate the composition embeds inside a `richText` field value. Each
|
|
18
|
+
* `componentEmbed` holds a single `ComponentNode` constrained by the field's
|
|
19
|
+
* `allow` vocabulary (the SAME leash a `blocks` field uses). Parse the value, walk
|
|
20
|
+
* its embeds at any depth, and validate each embedded node via
|
|
21
|
+
* `validateCompositionValue`; findings carry the rich-text node path joined with
|
|
22
|
+
* the in-embed graph path. Never throws — a malformed embed degrades to a
|
|
23
|
+
* single finding.
|
|
24
|
+
*/
|
|
25
|
+
export declare function validateRichTextEmbeds(value: unknown, field: RichTextFieldDef): CompositionIssue[];
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { validateCompositionValue } from '@elytracms/core/project-graph';
|
|
2
|
+
import { collectComponentEmbeds, parseRichTextValue } from '@elytracms/core/rich-text';
|
|
3
|
+
import { getStudioRegistry } from '@elytracms/studio/lib/registry';
|
|
4
|
+
/**
|
|
5
|
+
* Composition validator adapters (EC-326, extracted from `cms-store.tsx`) —
|
|
6
|
+
* injected into `validateDocument`'s `validateComposition`/`validateRichTextEmbeds`
|
|
7
|
+
* options so `@elytracms/core/cms-core` stays free of `@elytracms/core/project-graph`
|
|
8
|
+
* and `@elytracms/core/rich-text`.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* EC-188: validate a `blocks` composition field value against the field's `allow`
|
|
12
|
+
* vocabulary + the code-owned component registry (the leash), mapping graph
|
|
13
|
+
* issues into plain CMS findings. Never throws — a malformed (non-node)
|
|
14
|
+
* value degrades to a single finding, matching the "validation is first-class,
|
|
15
|
+
* never a crash" rule.
|
|
16
|
+
*/
|
|
17
|
+
export function validateComposition(value, field) {
|
|
18
|
+
if (value == null)
|
|
19
|
+
return [];
|
|
20
|
+
const roots = Array.isArray(value) ? value : [value];
|
|
21
|
+
if (!roots.every((r) => r != null && typeof r === 'object' && 'componentId' in r)) {
|
|
22
|
+
return [{ message: 'is not a valid composition (expected component nodes)', severity: 'error' }];
|
|
23
|
+
}
|
|
24
|
+
const components = getStudioRegistry().registry.lookup;
|
|
25
|
+
return validateCompositionValue(value, {
|
|
26
|
+
allow: field.allow,
|
|
27
|
+
components,
|
|
28
|
+
}).map((issue) => ({
|
|
29
|
+
message: issue.message,
|
|
30
|
+
severity: issue.severity,
|
|
31
|
+
nodePath: issue.path.join('/'),
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* EC-189: validate the composition embeds inside a `richText` field value. Each
|
|
36
|
+
* `componentEmbed` holds a single `ComponentNode` constrained by the field's
|
|
37
|
+
* `allow` vocabulary (the SAME leash a `blocks` field uses). Parse the value, walk
|
|
38
|
+
* its embeds at any depth, and validate each embedded node via
|
|
39
|
+
* `validateCompositionValue`; findings carry the rich-text node path joined with
|
|
40
|
+
* the in-embed graph path. Never throws — a malformed embed degrades to a
|
|
41
|
+
* single finding.
|
|
42
|
+
*/
|
|
43
|
+
export function validateRichTextEmbeds(value, field) {
|
|
44
|
+
if (value == null)
|
|
45
|
+
return [];
|
|
46
|
+
const parsed = parseRichTextValue(value);
|
|
47
|
+
// A plain-string / schema-invalid rich-text value carries no embeds to check
|
|
48
|
+
// here (scalar type validation owns reporting a malformed value).
|
|
49
|
+
if (!parsed.ok)
|
|
50
|
+
return [];
|
|
51
|
+
const components = getStudioRegistry().registry.lookup;
|
|
52
|
+
const out = [];
|
|
53
|
+
for (const occurrence of collectComponentEmbeds(parsed.value.doc)) {
|
|
54
|
+
if (!occurrence.embedded) {
|
|
55
|
+
out.push({
|
|
56
|
+
message: `embed at ${occurrence.nodePath} is missing a valid embedded component`,
|
|
57
|
+
severity: 'error',
|
|
58
|
+
nodePath: occurrence.nodePath,
|
|
59
|
+
});
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
for (const issue of validateCompositionValue(occurrence.embedded, {
|
|
63
|
+
allow: field.allow,
|
|
64
|
+
components,
|
|
65
|
+
})) {
|
|
66
|
+
out.push({
|
|
67
|
+
message: issue.message,
|
|
68
|
+
severity: issue.severity,
|
|
69
|
+
nodePath: `${occurrence.nodePath}/attrs/node/${issue.path.join('/')}`,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=cms-composition-validators.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cms-composition-validators.js","sourceRoot":"","sources":["../../../src/components/cms/cms-composition-validators.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,wBAAwB,EAAE,MAAM,+BAA+B,CAAA;AAExE,OAAO,EAAE,sBAAsB,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAA;AACtF,OAAO,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAA;AAElE;;;;;GAKG;AAEH;;;;;;GAMG;AACH,MAAM,UAAU,mBAAmB,CAAC,KAAc,EAAE,KAAqB;IACvE,IAAI,KAAK,IAAI,IAAI;QAAE,OAAO,EAAE,CAAA;IAC5B,MAAM,KAAK,GAAG,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAA;IACpD,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,IAAI,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,aAAa,IAAI,CAAC,CAAC,EAAE,CAAC;QAClF,OAAO,CAAC,EAAE,OAAO,EAAE,uDAAuD,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAC,CAAA;IAClG,CAAC;IACD,MAAM,UAAU,GAAG,iBAAiB,EAAE,CAAC,QAAQ,CAAC,MAAM,CAAA;IACtD,OAAO,wBAAwB,CAAC,KAAwC,EAAE;QACxE,KAAK,EAAE,KAAK,CAAC,KAAK;QAClB,UAAU;KACX,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;QACjB,OAAO,EAAE,KAAK,CAAC,OAAO;QACtB,QAAQ,EAAE,KAAK,CAAC,QAAQ;QACxB,QAAQ,EAAE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC;KAC/B,CAAC,CAAC,CAAA;AACL,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,sBAAsB,CAAC,KAAc,EAAE,KAAuB;IAC5E,IAAI,KAAK,IAAI,IAAI;QAAE,OAAO,EAAE,CAAA;IAC5B,MAAM,MAAM,GAAG,kBAAkB,CAAC,KAAK,CAAC,CAAA;IACxC,6EAA6E;IAC7E,kEAAkE;IAClE,IAAI,CAAC,MAAM,CAAC,EAAE;QAAE,OAAO,EAAE,CAAA;IACzB,MAAM,UAAU,GAAG,iBAAiB,EAAE,CAAC,QAAQ,CAAC,MAAM,CAAA;IACtD,MAAM,GAAG,GAAuB,EAAE,CAAA;IAClC,KAAK,MAAM,UAAU,IAAI,sBAAsB,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC;QAClE,IAAI,CAAC,UAAU,CAAC,QAAQ,EAAE,CAAC;YACzB,GAAG,CAAC,IAAI,CAAC;gBACP,OAAO,EAAE,YAAY,UAAU,CAAC,QAAQ,wCAAwC;gBAChF,QAAQ,EAAE,OAAO;gBACjB,QAAQ,EAAE,UAAU,CAAC,QAAQ;aAC9B,CAAC,CAAA;YACF,SAAQ;QACV,CAAC;QACD,KAAK,MAAM,KAAK,IAAI,wBAAwB,CAAC,UAAU,CAAC,QAAQ,EAAE;YAChE,KAAK,EAAE,KAAK,CAAC,KAAK;YAClB,UAAU;SACX,CAAC,EAAE,CAAC;YACH,GAAG,CAAC,IAAI,CAAC;gBACP,OAAO,EAAE,KAAK,CAAC,OAAO;gBACtB,QAAQ,EAAE,KAAK,CAAC,QAAQ;gBACxB,QAAQ,EAAE,GAAG,UAAU,CAAC,QAAQ,eAAe,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE;aACtE,CAAC,CAAA;QACJ,CAAC;IACH,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC","sourcesContent":["import type { BlocksFieldDef, CompositionIssue, RichTextFieldDef } from '@elytracms/core/cms-core'\nimport { validateCompositionValue } from '@elytracms/core/project-graph'\nimport type { ComponentNode } from '@elytracms/core/project-graph'\nimport { collectComponentEmbeds, parseRichTextValue } from '@elytracms/core/rich-text'\nimport { getStudioRegistry } from '@elytracms/studio/lib/registry'\n\n/**\n * Composition validator adapters (EC-326, extracted from `cms-store.tsx`) —\n * injected into `validateDocument`'s `validateComposition`/`validateRichTextEmbeds`\n * options so `@elytracms/core/cms-core` stays free of `@elytracms/core/project-graph`\n * and `@elytracms/core/rich-text`.\n */\n\n/**\n * EC-188: validate a `blocks` composition field value against the field's `allow`\n * vocabulary + the code-owned component registry (the leash), mapping graph\n * issues into plain CMS findings. Never throws — a malformed (non-node)\n * value degrades to a single finding, matching the \"validation is first-class,\n * never a crash\" rule.\n */\nexport function validateComposition(value: unknown, field: BlocksFieldDef): CompositionIssue[] {\n if (value == null) return []\n const roots = Array.isArray(value) ? value : [value]\n if (!roots.every((r) => r != null && typeof r === 'object' && 'componentId' in r)) {\n return [{ message: 'is not a valid composition (expected component nodes)', severity: 'error' }]\n }\n const components = getStudioRegistry().registry.lookup\n return validateCompositionValue(value as ComponentNode | ComponentNode[], {\n allow: field.allow,\n components,\n }).map((issue) => ({\n message: issue.message,\n severity: issue.severity,\n nodePath: issue.path.join('/'),\n }))\n}\n\n/**\n * EC-189: validate the composition embeds inside a `richText` field value. Each\n * `componentEmbed` holds a single `ComponentNode` constrained by the field's\n * `allow` vocabulary (the SAME leash a `blocks` field uses). Parse the value, walk\n * its embeds at any depth, and validate each embedded node via\n * `validateCompositionValue`; findings carry the rich-text node path joined with\n * the in-embed graph path. Never throws — a malformed embed degrades to a\n * single finding.\n */\nexport function validateRichTextEmbeds(value: unknown, field: RichTextFieldDef): CompositionIssue[] {\n if (value == null) return []\n const parsed = parseRichTextValue(value)\n // A plain-string / schema-invalid rich-text value carries no embeds to check\n // here (scalar type validation owns reporting a malformed value).\n if (!parsed.ok) return []\n const components = getStudioRegistry().registry.lookup\n const out: CompositionIssue[] = []\n for (const occurrence of collectComponentEmbeds(parsed.value.doc)) {\n if (!occurrence.embedded) {\n out.push({\n message: `embed at ${occurrence.nodePath} is missing a valid embedded component`,\n severity: 'error',\n nodePath: occurrence.nodePath,\n })\n continue\n }\n for (const issue of validateCompositionValue(occurrence.embedded, {\n allow: field.allow,\n components,\n })) {\n out.push({\n message: issue.message,\n severity: issue.severity,\n nodePath: `${occurrence.nodePath}/attrs/node/${issue.path.join('/')}`,\n })\n }\n }\n return out\n}\n"]}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { FieldDef } from '@elytracms/core/cms-core';
|
|
2
|
+
/**
|
|
3
|
+
* Pure label formatters (EC-326) — no `CmsProvider`/store coupling, so
|
|
4
|
+
* portable surfaces (AD-19: `collection-table.tsx`, `detail-editor.tsx`, and
|
|
5
|
+
* friends) can format a field label without statically depending on the
|
|
6
|
+
* 1000+ line `cms-store.tsx` module for a single formatter.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* A field's display label (EC-241): resolve its (possibly localized) `form.label`,
|
|
10
|
+
* falling back to the field name. **`locale` defaults to the active UI locale** —
|
|
11
|
+
* a bare `fieldLabel(field)` localizes a `{ en, de }` map against the browser locale
|
|
12
|
+
* (via the non-hook `localizedLabel`), so a German editor sees German labels. Pass an
|
|
13
|
+
* explicit `locale` only to force a specific one (e.g. a content-locale variant view).
|
|
14
|
+
*/
|
|
15
|
+
export declare function fieldLabel(field: FieldDef, locale?: string): string;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { resolveLabel } from '@elytracms/core/cms-core';
|
|
2
|
+
import { localizedLabel, resolveMsgLabel } from '@elytracms/studio/lib/i18n';
|
|
3
|
+
/**
|
|
4
|
+
* Pure label formatters (EC-326) — no `CmsProvider`/store coupling, so
|
|
5
|
+
* portable surfaces (AD-19: `collection-table.tsx`, `detail-editor.tsx`, and
|
|
6
|
+
* friends) can format a field label without statically depending on the
|
|
7
|
+
* 1000+ line `cms-store.tsx` module for a single formatter.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* A field's display label (EC-241): resolve its (possibly localized) `form.label`,
|
|
11
|
+
* falling back to the field name. **`locale` defaults to the active UI locale** —
|
|
12
|
+
* a bare `fieldLabel(field)` localizes a `{ en, de }` map against the browser locale
|
|
13
|
+
* (via the non-hook `localizedLabel`), so a German editor sees German labels. Pass an
|
|
14
|
+
* explicit `locale` only to force a specific one (e.g. a content-locale variant view).
|
|
15
|
+
*/
|
|
16
|
+
export function fieldLabel(field, locale) {
|
|
17
|
+
// A catalog-backed built-in-rail label (msgLabel) always resolves through the
|
|
18
|
+
// catalog against the active UI locale, ignoring a content-locale override — a rail
|
|
19
|
+
// is chrome, not localized content.
|
|
20
|
+
const chrome = resolveMsgLabel(field.form?.label);
|
|
21
|
+
if (chrome !== undefined)
|
|
22
|
+
return chrome;
|
|
23
|
+
if (locale !== undefined)
|
|
24
|
+
return resolveLabel(field.form?.label, locale) ?? field.name;
|
|
25
|
+
return localizedLabel(field.form?.label, field.name);
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=cms-labels.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cms-labels.js","sourceRoot":"","sources":["../../../src/components/cms/cms-labels.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAA;AACvD,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAA;AAE5E;;;;;GAKG;AAEH;;;;;;GAMG;AACH,MAAM,UAAU,UAAU,CAAC,KAAe,EAAE,MAAe;IACzD,8EAA8E;IAC9E,oFAAoF;IACpF,oCAAoC;IACpC,MAAM,MAAM,GAAG,eAAe,CAAC,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,CAAA;IACjD,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,MAAM,CAAA;IACvC,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,YAAY,CAAC,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE,MAAM,CAAC,IAAI,KAAK,CAAC,IAAI,CAAA;IACtF,OAAO,cAAc,CAAC,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE,KAAK,CAAC,IAAI,CAAC,CAAA;AACtD,CAAC","sourcesContent":["import type { FieldDef } from '@elytracms/core/cms-core'\nimport { resolveLabel } from '@elytracms/core/cms-core'\nimport { localizedLabel, resolveMsgLabel } from '@elytracms/studio/lib/i18n'\n\n/**\n * Pure label formatters (EC-326) — no `CmsProvider`/store coupling, so\n * portable surfaces (AD-19: `collection-table.tsx`, `detail-editor.tsx`, and\n * friends) can format a field label without statically depending on the\n * 1000+ line `cms-store.tsx` module for a single formatter.\n */\n\n/**\n * A field's display label (EC-241): resolve its (possibly localized) `form.label`,\n * falling back to the field name. **`locale` defaults to the active UI locale** —\n * a bare `fieldLabel(field)` localizes a `{ en, de }` map against the browser locale\n * (via the non-hook `localizedLabel`), so a German editor sees German labels. Pass an\n * explicit `locale` only to force a specific one (e.g. a content-locale variant view).\n */\nexport function fieldLabel(field: FieldDef, locale?: string): string {\n // A catalog-backed built-in-rail label (msgLabel) always resolves through the\n // catalog against the active UI locale, ignoring a content-locale override — a rail\n // is chrome, not localized content.\n const chrome = resolveMsgLabel(field.form?.label)\n if (chrome !== undefined) return chrome\n if (locale !== undefined) return resolveLabel(field.form?.label, locale) ?? field.name\n return localizedLabel(field.form?.label, field.name)\n}\n"]}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
-
import type { CmsDocument, CmsValidationIssue, CollectionDef, DocumentRef, FieldDef, Locale, LocaleConfig, RestoreScope } from '@elytracms/core/cms-core';
|
|
2
|
+
import type { CmsDocument, CmsValidationIssue, CollectionDef, CollectionRegistry, DocumentRef, FieldDef, Locale, LocaleConfig, RestoreScope } from '@elytracms/core/cms-core';
|
|
3
3
|
import type { DocumentVersionRecord } from '@elytracms/core/persistence';
|
|
4
4
|
import type { OperationError } from '@elytracms/studio/lib/operations';
|
|
5
5
|
import type { TableViewState } from '@elytracms/studio/lib/cms-views';
|
|
@@ -142,6 +142,7 @@ export interface CmsStore {
|
|
|
142
142
|
/** Await all queued persisted mutations (tests, publish flows). */
|
|
143
143
|
flushPersistence(): Promise<void>;
|
|
144
144
|
}
|
|
145
|
+
declare const useCms: () => CmsStore;
|
|
145
146
|
declare function primaryField(collection: CollectionDef): string;
|
|
146
147
|
/**
|
|
147
148
|
* EC-316 (bug fix) — mint a fresh, COLLISION-FREE document id for `+ New`.
|
|
@@ -158,6 +159,26 @@ declare function primaryField(collection: CollectionDef): string;
|
|
|
158
159
|
export declare function mintDocumentId(collectionId: string, seq: {
|
|
159
160
|
current: number;
|
|
160
161
|
}, existingIds: ReadonlySet<string>): string;
|
|
162
|
+
/**
|
|
163
|
+
* Build a fresh document for `collectionId`: a collision-free id (EC-316, via
|
|
164
|
+
* {@link mintDocumentId} against every id `existingDocs` already holds for
|
|
165
|
+
* this collection) and the collection's declared field defaults (EC-297, via
|
|
166
|
+
* `seedDefaults`). Pure — the sensitive part of the insert sequence shared by
|
|
167
|
+
* `createDocument` and `ensureSingletonDocument`'s empty branch (via the
|
|
168
|
+
* private `insertNewDocument` below), factored out so it's unit-testable
|
|
169
|
+
* without a `CmsProvider`.
|
|
170
|
+
*/
|
|
171
|
+
export declare function buildNewDocument(collectionId: string, registry: CollectionRegistry, seq: {
|
|
172
|
+
current: number;
|
|
173
|
+
}, existingDocs: readonly CmsDocument[]): CmsDocument;
|
|
174
|
+
/**
|
|
175
|
+
* Does `docs` already hold a document for `collectionId`? `ensureSingletonDocument`'s
|
|
176
|
+
* empty branch re-checks this INSIDE the `setDocuments` updater (against the
|
|
177
|
+
* latest `prev`, not a stale snapshot) so a concurrent invocation — StrictMode's
|
|
178
|
+
* double-invoked effect, or a second caller racing the same synchronous guard —
|
|
179
|
+
* can never materialize a second singleton document.
|
|
180
|
+
*/
|
|
181
|
+
export declare function hasDocumentFor(docs: readonly CmsDocument[], collectionId: string): boolean;
|
|
161
182
|
/**
|
|
162
183
|
* The CMS session store (EC-141): documents and collections are optimistic,
|
|
163
184
|
* synchronous session state (live validation while typing); every mutation —
|
|
@@ -181,14 +202,6 @@ export declare function CmsProvider({ children, projectId, initialCollections, i
|
|
|
181
202
|
/** Locale config for the seeded content; defaults to the sample config. */
|
|
182
203
|
initialLocaleConfig?: LocaleConfig;
|
|
183
204
|
}): import("react").JSX.Element;
|
|
184
|
-
export
|
|
185
|
-
/**
|
|
186
|
-
* A field's display label (EC-241): resolve its (possibly localized) `form.label`,
|
|
187
|
-
* falling back to the field name. **`locale` defaults to the active UI locale** —
|
|
188
|
-
* a bare `fieldLabel(field)` localizes a `{ en, de }` map against the browser locale
|
|
189
|
-
* (via the non-hook `localizedLabel`), so a German editor sees German labels. Pass an
|
|
190
|
-
* explicit `locale` only to force a specific one (e.g. a content-locale variant view).
|
|
191
|
-
*/
|
|
192
|
-
export declare function fieldLabel(field: FieldDef, locale?: string): string;
|
|
205
|
+
export { useCms };
|
|
193
206
|
export { primaryField };
|
|
194
207
|
export type { Locale };
|
|
@@ -1,86 +1,20 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
3
|
-
import { createCollectionRegistry, createDocumentStore, documentKey, documentSchema, resolveDocumentTitle, resolveField,
|
|
4
|
-
import {
|
|
5
|
-
import { collectComponentEmbeds, parseRichTextValue } from '@elytracms/core/rich-text';
|
|
6
|
-
import { localizedLabel, resolveMsgLabel, translate } from '@elytracms/studio/lib/i18n';
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { createCollectionRegistry, createDocumentStore, documentKey, documentSchema, resolveDocumentTitle, resolveField, sampleDocuments, seedDefaults, sampleFilterableCollections, sampleLocaleConfig, samplePublishedKeys, resolveSingletonDocument, titleFieldOf, validateDocument, } from '@elytracms/core/cms-core';
|
|
4
|
+
import { translate } from '@elytracms/studio/lib/i18n';
|
|
7
5
|
import { configureStudioLocales, useStudioLocale } from '@elytracms/studio/lib/locale';
|
|
8
|
-
import {
|
|
6
|
+
import { studioManifestLookup } from '@elytracms/studio/lib/registry';
|
|
9
7
|
import { resolveDeploymentCollections } from '@elytracms/studio/lib/workspace';
|
|
8
|
+
import { validateComposition, validateRichTextEmbeds } from './cms-composition-validators';
|
|
9
|
+
import { matchesRef, removeDoc, replaceDoc, upsertDoc } from './document-ref-utils';
|
|
10
|
+
import { createRequiredContext } from '@elytracms/studio/lib/required-context';
|
|
10
11
|
import { createCmsPersister, getStudioOperations } from '@elytracms/studio/lib/operations';
|
|
11
12
|
import { ALL_VIEW_ID, canonicalViewState, configViewsFor, defaultViewState, viewStatesEqual, } from '@elytracms/studio/lib/cms-views';
|
|
12
13
|
import { useOptionalAssets } from '../assets/assets-store';
|
|
13
14
|
import { stagedDocuments } from './document-staging';
|
|
14
15
|
import { applyCellValue, commitCellEdit } from './cell-edit';
|
|
15
16
|
import { runBulkAction } from './bulk-actions';
|
|
16
|
-
|
|
17
|
-
* EC-188: validate a `blocks` composition field value against the field's `allow`
|
|
18
|
-
* vocabulary + the code-owned component registry (the leash), mapping graph
|
|
19
|
-
* issues into plain CMS findings. Injected into `validateDocument` so cms-core
|
|
20
|
-
* stays free of `@elytracms/core/project-graph`. Never throws — a malformed (non-node)
|
|
21
|
-
* value degrades to a single finding, matching the "validation is first-class,
|
|
22
|
-
* never a crash" rule.
|
|
23
|
-
*/
|
|
24
|
-
function validateComposition(value, field) {
|
|
25
|
-
if (value == null)
|
|
26
|
-
return [];
|
|
27
|
-
const roots = Array.isArray(value) ? value : [value];
|
|
28
|
-
if (!roots.every((r) => r != null && typeof r === 'object' && 'componentId' in r)) {
|
|
29
|
-
return [{ message: 'is not a valid composition (expected component nodes)', severity: 'error' }];
|
|
30
|
-
}
|
|
31
|
-
const components = getStudioRegistry().registry.lookup;
|
|
32
|
-
return validateCompositionValue(value, {
|
|
33
|
-
allow: field.allow,
|
|
34
|
-
components,
|
|
35
|
-
}).map((issue) => ({
|
|
36
|
-
message: issue.message,
|
|
37
|
-
severity: issue.severity,
|
|
38
|
-
nodePath: issue.path.join('/'),
|
|
39
|
-
}));
|
|
40
|
-
}
|
|
41
|
-
/**
|
|
42
|
-
* EC-189: validate the composition embeds inside a `richText` field value. Each
|
|
43
|
-
* `componentEmbed` holds a single `ComponentNode` constrained by the field's
|
|
44
|
-
* `allow` vocabulary (the SAME leash a `blocks` field uses). Parse the value, walk
|
|
45
|
-
* its embeds at any depth, and validate each embedded node via
|
|
46
|
-
* `validateCompositionValue`; findings carry the rich-text node path joined with
|
|
47
|
-
* the in-embed graph path. Injected into `validateDocument` so cms-core depends on
|
|
48
|
-
* neither `@elytracms/core/project-graph` nor `@elytracms/core/rich-text`. Never throws — a
|
|
49
|
-
* malformed embed degrades to a single finding.
|
|
50
|
-
*/
|
|
51
|
-
function validateRichTextEmbeds(value, field) {
|
|
52
|
-
if (value == null)
|
|
53
|
-
return [];
|
|
54
|
-
const parsed = parseRichTextValue(value);
|
|
55
|
-
// A plain-string / schema-invalid rich-text value carries no embeds to check
|
|
56
|
-
// here (scalar type validation owns reporting a malformed value).
|
|
57
|
-
if (!parsed.ok)
|
|
58
|
-
return [];
|
|
59
|
-
const components = getStudioRegistry().registry.lookup;
|
|
60
|
-
const out = [];
|
|
61
|
-
for (const occurrence of collectComponentEmbeds(parsed.value.doc)) {
|
|
62
|
-
if (!occurrence.embedded) {
|
|
63
|
-
out.push({
|
|
64
|
-
message: `embed at ${occurrence.nodePath} is missing a valid embedded component`,
|
|
65
|
-
severity: 'error',
|
|
66
|
-
nodePath: occurrence.nodePath,
|
|
67
|
-
});
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
for (const issue of validateCompositionValue(occurrence.embedded, {
|
|
71
|
-
allow: field.allow,
|
|
72
|
-
components,
|
|
73
|
-
})) {
|
|
74
|
-
out.push({
|
|
75
|
-
message: issue.message,
|
|
76
|
-
severity: issue.severity,
|
|
77
|
-
nodePath: `${occurrence.nodePath}/attrs/node/${issue.path.join('/')}`,
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
return out;
|
|
82
|
-
}
|
|
83
|
-
const CmsContext = createContext(null);
|
|
17
|
+
const { Context: CmsContext, useRequired: useCms } = createRequiredContext('Cms');
|
|
84
18
|
function primaryField(collection) {
|
|
85
19
|
return titleFieldOf(collection)?.name ?? collection.fields[0]?.name ?? 'id';
|
|
86
20
|
}
|
|
@@ -104,6 +38,31 @@ export function mintDocumentId(collectionId, seq, existingIds) {
|
|
|
104
38
|
} while (existingIds.has(id));
|
|
105
39
|
return id;
|
|
106
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Build a fresh document for `collectionId`: a collision-free id (EC-316, via
|
|
43
|
+
* {@link mintDocumentId} against every id `existingDocs` already holds for
|
|
44
|
+
* this collection) and the collection's declared field defaults (EC-297, via
|
|
45
|
+
* `seedDefaults`). Pure — the sensitive part of the insert sequence shared by
|
|
46
|
+
* `createDocument` and `ensureSingletonDocument`'s empty branch (via the
|
|
47
|
+
* private `insertNewDocument` below), factored out so it's unit-testable
|
|
48
|
+
* without a `CmsProvider`.
|
|
49
|
+
*/
|
|
50
|
+
export function buildNewDocument(collectionId, registry, seq, existingDocs) {
|
|
51
|
+
const existingIds = new Set(existingDocs.filter((d) => d.collection === collectionId).map((d) => d.id));
|
|
52
|
+
const id = mintDocumentId(collectionId, seq, existingIds);
|
|
53
|
+
const collection = registry.get(collectionId);
|
|
54
|
+
return { collection: collectionId, id, values: collection ? seedDefaults(collection) : {} };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Does `docs` already hold a document for `collectionId`? `ensureSingletonDocument`'s
|
|
58
|
+
* empty branch re-checks this INSIDE the `setDocuments` updater (against the
|
|
59
|
+
* latest `prev`, not a stale snapshot) so a concurrent invocation — StrictMode's
|
|
60
|
+
* double-invoked effect, or a second caller racing the same synchronous guard —
|
|
61
|
+
* can never materialize a second singleton document.
|
|
62
|
+
*/
|
|
63
|
+
export function hasDocumentFor(docs, collectionId) {
|
|
64
|
+
return docs.some((d) => d.collection === collectionId);
|
|
65
|
+
}
|
|
107
66
|
/** The unnamed built-in view: no filters, no sort, every field visible. */
|
|
108
67
|
function defaultView(collection) {
|
|
109
68
|
return {
|
|
@@ -209,9 +168,9 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
|
|
|
209
168
|
configureStudioLocales(localeConfig);
|
|
210
169
|
}, [localeConfig]);
|
|
211
170
|
const getCollection = useCallback((id) => registry.get(id), [registry]);
|
|
212
|
-
const getDocument = useCallback((ref) => documents.find((d) => d
|
|
171
|
+
const getDocument = useCallback((ref) => documents.find((d) => matchesRef(d, ref)), [documents]);
|
|
213
172
|
const titleOf = useCallback((ref) => {
|
|
214
|
-
const doc = documents.find((d) => d
|
|
173
|
+
const doc = documents.find((d) => matchesRef(d, ref));
|
|
215
174
|
const collection = registry.get(ref.collection);
|
|
216
175
|
if (!doc || !collection)
|
|
217
176
|
return ref.id;
|
|
@@ -232,18 +191,15 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
|
|
|
232
191
|
return typeof value === 'string' && value ? value : ref.id;
|
|
233
192
|
}, [documents, registry, localeConfig, activeLocale]);
|
|
234
193
|
const saveDocument = useCallback((doc) => {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
return [...prev, doc];
|
|
239
|
-
const next = [...prev];
|
|
240
|
-
next[i] = doc;
|
|
241
|
-
return next;
|
|
242
|
-
});
|
|
194
|
+
// Replace-or-append (upsert): a brand-new document must still land even
|
|
195
|
+
// though nothing in the session matches its ref yet.
|
|
196
|
+
setDocuments((prev) => upsertDoc(prev, doc));
|
|
243
197
|
persisterRef.current?.upsertDocument(doc);
|
|
244
198
|
}, []);
|
|
245
199
|
const commitFieldEdit = useCallback((input) => {
|
|
246
|
-
|
|
200
|
+
// Pure map-replace: an edit always targets a document already open in
|
|
201
|
+
// the session, so a miss (already gone) is a no-op, never an append.
|
|
202
|
+
const replaceWith = (doc) => setDocuments((prev) => replaceDoc(prev, doc));
|
|
247
203
|
const persister = persisterRef.current;
|
|
248
204
|
return commitCellEdit({
|
|
249
205
|
registry,
|
|
@@ -264,22 +220,24 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
|
|
|
264
220
|
: {}),
|
|
265
221
|
}, input);
|
|
266
222
|
}, [registry, localeConfig, projectId]);
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
};
|
|
279
|
-
setDocuments((prev) => [...prev, doc]);
|
|
223
|
+
// Shared insert sequence (EC-326): existingIds/mint/seedDefaults (via
|
|
224
|
+
// `buildNewDocument`) + optimistic append + persist, consumed by both
|
|
225
|
+
// `createDocument` (always inserts) and `ensureSingletonDocument`'s empty
|
|
226
|
+
// branch (`guardExisting: true` — re-checks "no document for this collection
|
|
227
|
+
// yet" against the LATEST `prev` inside the `setDocuments` updater, not the
|
|
228
|
+
// `documentsRef.current` snapshot the caller already checked, so a
|
|
229
|
+
// concurrent invocation — StrictMode's double-invoked effect — can never
|
|
230
|
+
// materialize a second singleton document).
|
|
231
|
+
const insertNewDocument = useCallback((collectionId, options) => {
|
|
232
|
+
const doc = buildNewDocument(collectionId, registry, seq, documentsRef.current);
|
|
233
|
+
setDocuments((prev) => options?.guardExisting && hasDocumentFor(prev, collectionId) ? prev : [...prev, doc]);
|
|
280
234
|
persisterRef.current?.upsertDocument(doc);
|
|
281
|
-
return
|
|
235
|
+
return doc;
|
|
282
236
|
}, [registry]);
|
|
237
|
+
const createDocument = useCallback((collectionId) => {
|
|
238
|
+
const doc = insertNewDocument(collectionId);
|
|
239
|
+
return { collection: collectionId, id: doc.id };
|
|
240
|
+
}, [insertNewDocument]);
|
|
283
241
|
// EC-274 (re-scopes EC-217): a singleton is a UI lens, not a fixed id. Resolve
|
|
284
242
|
// the collection's lone document and return its ref; if none exists yet, create
|
|
285
243
|
// one with a NORMAL generated id (never the collection id — no code ever names a
|
|
@@ -308,22 +266,14 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
|
|
|
308
266
|
return { collection: collectionId, id: `new-${collectionId}-pending` };
|
|
309
267
|
}
|
|
310
268
|
ensuredSingletons.current.add(collectionId);
|
|
311
|
-
// EC-316: same collision-free mint as `createDocument` (
|
|
312
|
-
// the singleton-empty branch only
|
|
313
|
-
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
collection: collectionId,
|
|
318
|
-
id,
|
|
319
|
-
values: collection ? seedDefaults(collection) : {},
|
|
320
|
-
};
|
|
321
|
-
setDocuments((prev) => prev.some((d) => d.collection === collectionId) ? prev : [...prev, doc]);
|
|
322
|
-
persisterRef.current?.upsertDocument(doc);
|
|
323
|
-
return { collection: collectionId, id };
|
|
324
|
-
}, [registry]);
|
|
269
|
+
// EC-316: same collision-free mint as `createDocument` (via the shared
|
|
270
|
+
// `insertNewDocument` — defensive here, the singleton-empty branch only
|
|
271
|
+
// runs when no document exists yet).
|
|
272
|
+
const doc = insertNewDocument(collectionId, { guardExisting: true });
|
|
273
|
+
return { collection: collectionId, id: doc.id };
|
|
274
|
+
}, [registry, insertNewDocument]);
|
|
325
275
|
const deleteDocument = useCallback((ref) => {
|
|
326
|
-
setDocuments((prev) => prev
|
|
276
|
+
setDocuments((prev) => removeDoc(prev, ref));
|
|
327
277
|
persisterRef.current?.deleteDocument(ref);
|
|
328
278
|
}, []);
|
|
329
279
|
// EC-224: published-ness IS the version pointer (`publishedKeys`) — the SOLE
|
|
@@ -412,7 +362,9 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
|
|
|
412
362
|
return null;
|
|
413
363
|
}
|
|
414
364
|
const restored = result.value.document;
|
|
415
|
-
|
|
365
|
+
// Pure map-replace: a restore always targets a document already open in
|
|
366
|
+
// the session.
|
|
367
|
+
setDocuments((prev) => replaceDoc(prev, restored));
|
|
416
368
|
return restored;
|
|
417
369
|
}, [projectId]);
|
|
418
370
|
const validate = useCallback((doc) => validateDocument(registry, doc, {
|
|
@@ -462,17 +414,10 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
|
|
|
462
414
|
accepted.push(doc);
|
|
463
415
|
}
|
|
464
416
|
if (accepted.length > 0) {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
if (i < 0)
|
|
470
|
-
next.push(doc);
|
|
471
|
-
else
|
|
472
|
-
next[i] = doc;
|
|
473
|
-
}
|
|
474
|
-
return next;
|
|
475
|
-
});
|
|
417
|
+
// Replace-or-append (upsert): an imported row may be brand new to the
|
|
418
|
+
// session, so each accepted document must land even with no prior
|
|
419
|
+
// entry for its ref.
|
|
420
|
+
setDocuments((prev) => accepted.reduce((acc, doc) => upsertDoc(acc, doc), prev));
|
|
476
421
|
for (const doc of accepted)
|
|
477
422
|
persisterRef.current?.upsertDocument(doc);
|
|
478
423
|
// EC-224: imports land as the working DRAFT. Publish-ness is the version
|
|
@@ -556,7 +501,7 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
|
|
|
556
501
|
// set-field: upsert the document carrying the new value. The write
|
|
557
502
|
// path self-types it through the schema lens (EC-277); a scalar field
|
|
558
503
|
// stores its scalar as-is.
|
|
559
|
-
const current = documentsRef.current.find((d) => d
|
|
504
|
+
const current = documentsRef.current.find((d) => matchesRef(d, ref));
|
|
560
505
|
if (!current) {
|
|
561
506
|
return Promise.resolve({
|
|
562
507
|
ok: false,
|
|
@@ -590,10 +535,12 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
|
|
|
590
535
|
// `state` mirror to keep in sync (published-ness is derived from the
|
|
591
536
|
// pointer set).
|
|
592
537
|
applyState: (ref, state) => setPublished(ref, state === 'published'),
|
|
593
|
-
applyDelete: (ref) => setDocuments((prev) => prev
|
|
538
|
+
applyDelete: (ref) => setDocuments((prev) => removeDoc(prev, ref)),
|
|
594
539
|
// EC-247: fold the new value into the session document (same
|
|
595
|
-
// locale-aware path the dispatched upsert used).
|
|
596
|
-
|
|
540
|
+
// locale-aware path the dispatched upsert used). Not a plain
|
|
541
|
+
// `replaceDoc` — the replacement is computed FROM each matched
|
|
542
|
+
// document (`setFieldDoc(d)`), not a fixed value to swap in.
|
|
543
|
+
applySetField: (ref) => setDocuments((prev) => prev.map((d) => (matchesRef(d, ref) ? setFieldDoc(d) : d))),
|
|
597
544
|
...(dispatch ? { dispatch } : {}),
|
|
598
545
|
}, collectionId, ids, action, setField);
|
|
599
546
|
}, [projectId, titleOf, registry, localeConfig, activeLocale, setPublished, assetExists]);
|
|
@@ -682,29 +629,6 @@ export function CmsProvider({ children, projectId, initialCollections, initialDo
|
|
|
682
629
|
]);
|
|
683
630
|
return _jsx(CmsContext.Provider, { value: store$, children: children });
|
|
684
631
|
}
|
|
685
|
-
export
|
|
686
|
-
const store = useContext(CmsContext);
|
|
687
|
-
if (!store)
|
|
688
|
-
throw new Error('useCms must be used within a <CmsProvider>.');
|
|
689
|
-
return store;
|
|
690
|
-
}
|
|
691
|
-
/**
|
|
692
|
-
* A field's display label (EC-241): resolve its (possibly localized) `form.label`,
|
|
693
|
-
* falling back to the field name. **`locale` defaults to the active UI locale** —
|
|
694
|
-
* a bare `fieldLabel(field)` localizes a `{ en, de }` map against the browser locale
|
|
695
|
-
* (via the non-hook `localizedLabel`), so a German editor sees German labels. Pass an
|
|
696
|
-
* explicit `locale` only to force a specific one (e.g. a content-locale variant view).
|
|
697
|
-
*/
|
|
698
|
-
export function fieldLabel(field, locale) {
|
|
699
|
-
// A catalog-backed built-in-rail label (msgLabel) always resolves through the
|
|
700
|
-
// catalog against the active UI locale, ignoring a content-locale override — a rail
|
|
701
|
-
// is chrome, not localized content.
|
|
702
|
-
const chrome = resolveMsgLabel(field.form?.label);
|
|
703
|
-
if (chrome !== undefined)
|
|
704
|
-
return chrome;
|
|
705
|
-
if (locale !== undefined)
|
|
706
|
-
return resolveLabel(field.form?.label, locale) ?? field.name;
|
|
707
|
-
return localizedLabel(field.form?.label, field.name);
|
|
708
|
-
}
|
|
632
|
+
export { useCms };
|
|
709
633
|
export { primaryField };
|
|
710
634
|
//# sourceMappingURL=cms-store.js.map
|