@elytracms/studio 0.0.39 → 0.0.41

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 (49) hide show
  1. package/dist/components/cms/collection-table.js +13 -1
  2. package/dist/components/cms/collection-table.js.map +1 -1
  3. package/dist/components/cms/detail-editor.js +116 -72
  4. package/dist/components/cms/detail-editor.js.map +1 -1
  5. package/dist/components/cms/details-pane.d.ts +21 -8
  6. package/dist/components/cms/details-pane.js +31 -11
  7. package/dist/components/cms/details-pane.js.map +1 -1
  8. package/dist/components/cms/field-control.js +21 -12
  9. package/dist/components/cms/field-control.js.map +1 -1
  10. package/dist/components/cms/prop-fields.js +25 -1
  11. package/dist/components/cms/prop-fields.js.map +1 -1
  12. package/dist/components/cms/rich-text/component-embed.d.ts +7 -2
  13. package/dist/components/cms/rich-text/component-embed.js +60 -5
  14. package/dist/components/cms/rich-text/component-embed.js.map +1 -1
  15. package/dist/components/cms/rich-text/editor.js +24 -5
  16. package/dist/components/cms/rich-text/editor.js.map +1 -1
  17. package/dist/components/cms/rich-text/embed-inspector-context.d.ts +50 -0
  18. package/dist/components/cms/rich-text/embed-inspector-context.js +12 -0
  19. package/dist/components/cms/rich-text/embed-inspector-context.js.map +1 -0
  20. package/dist/components/cms/rich-text/embed-inspector.d.ts +25 -0
  21. package/dist/components/cms/rich-text/embed-inspector.js +48 -0
  22. package/dist/components/cms/rich-text/embed-inspector.js.map +1 -0
  23. package/dist/components/cms/rich-text/embed-picker.d.ts +0 -2
  24. package/dist/components/cms/rich-text/embed-picker.js +0 -4
  25. package/dist/components/cms/rich-text/embed-picker.js.map +1 -1
  26. package/dist/components/cms/rich-text/embed-props.d.ts +7 -0
  27. package/dist/components/cms/rich-text/embed-props.js +9 -0
  28. package/dist/components/cms/rich-text/embed-props.js.map +1 -1
  29. package/dist/components/cms/rich-text/insert.d.ts +8 -2
  30. package/dist/components/cms/rich-text/insert.js +32 -3
  31. package/dist/components/cms/rich-text/insert.js.map +1 -1
  32. package/dist/components/cms/rich-text/link-control.js +3 -1
  33. package/dist/components/cms/rich-text/link-control.js.map +1 -1
  34. package/dist/components/cms/rich-text/slash-command.d.ts +23 -6
  35. package/dist/components/cms/rich-text/slash-command.js +31 -23
  36. package/dist/components/cms/rich-text/slash-command.js.map +1 -1
  37. package/dist/components/cms/rich-text/toolbar-state.d.ts +45 -0
  38. package/dist/components/cms/rich-text/toolbar-state.js +66 -0
  39. package/dist/components/cms/rich-text/toolbar-state.js.map +1 -0
  40. package/dist/components/cms/rich-text/toolbar.js +7 -15
  41. package/dist/components/cms/rich-text/toolbar.js.map +1 -1
  42. package/dist/components/cms/version-compare.js +51 -22
  43. package/dist/components/cms/version-compare.js.map +1 -1
  44. package/dist/components/components/component-preview.d.ts +36 -0
  45. package/dist/components/components/component-preview.js +157 -5
  46. package/dist/components/components/component-preview.js.map +1 -1
  47. package/dist/lib/i18n/messages.en.js +18 -3
  48. package/dist/lib/i18n/messages.en.js.map +1 -1
  49. package/package.json +2 -2
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Link, STUDIO_PROJECT_ROUTE, studioPath, useNavigate } from '@elytracms/studio/router';
3
- import { Check, CircleAlert, CircleCheck, ExternalLink, Eye, EyeOff, GitCompareArrows, History, Loader2, Lock, MoreHorizontal, RotateCcw, Save, Tag, Trash2, X, } from 'lucide-react';
3
+ import { Check, ChevronDown, CircleAlert, CircleCheck, ExternalLink, Eye, EyeOff, GitCompareArrows, History, Loader2, Lock, MoreHorizontal, RotateCcw, Save, Tag, Trash2, X, } from 'lucide-react';
4
4
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
5
5
  import { FIELD_WIDTH_SPANS, SIDEBAR_FIELD_GROUP, clearHiddenValues, documentKey, groupOrder, groupSectionOf, hasAliases, hiddenGroupNames, isBlocksField, isFieldHidden, isFieldLocalized, isSingleton, resolveDocumentForLocale, resolveDocumentTitle, resolveField, sidebarFields, tabDescriptorOf, tabOrder, titleFieldOf, staticFieldHidden, urlForDocument, } from '@elytracms/core/cms-core';
6
6
  import { blockRootsOf } from '@elytracms/core/project-graph';
@@ -36,6 +36,10 @@ import { useDocumentVersionsQuery } from './use-document-versions';
36
36
  import { discardDraftStatus } from './discard-draft';
37
37
  import { IssuesPane } from './issues-pane';
38
38
  import { DetailsPane } from './details-pane';
39
+ // 0.0.41: selection-driven embed inspector. Both modules are deliberately TipTap-free
40
+ // so the editor runtime stays behind rich-text/lazy (EC-322) — see their doc comments.
41
+ import { EmbedInspectorProvider } from './rich-text/embed-inspector-context';
42
+ import { EmbedInspector } from './rich-text/embed-inspector';
39
43
  import { useSetBreadcrumb } from '../breadcrumb-context';
40
44
  import { resolveDeploymentFrontendUrl, resolveDeploymentRoutes } from '@elytracms/studio/lib/workspace';
41
45
  import { usePublishedDocuments } from '@elytracms/studio/lib/cms/published-documents';
@@ -291,6 +295,15 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
291
295
  // `sidebar`-group field can open the Details pane (where it renders). Undefined
292
296
  // = uncontrolled (the sidebar defaults to its first pane).
293
297
  const [inspectorPaneId, setInspectorPaneId] = useState();
298
+ // 0.0.41: the selected rich-text component embed, if any — a `componentEmbed` node
299
+ // selection swaps the inspector sidebar for the embed's prop form. Fed by the
300
+ // rich-text field through the EmbedInspector context; `clearActiveEmbed` keys on the
301
+ // node id so a fast A→B reselect can't let A's deselect wipe B (selection race).
302
+ const [activeEmbed, setActiveEmbed] = useState(null);
303
+ const embedInspectorApi = useMemo(() => ({
304
+ setActiveEmbed: (payload) => setActiveEmbed(payload),
305
+ clearActiveEmbed: (nodeId) => setActiveEmbed((prev) => (prev?.nodeId === nodeId ? null : prev)),
306
+ }), []);
294
307
  // EC-298: the open version comparison. The Compare tab is STICKY — opening it
295
308
  // sets the selection and focuses the tab; tabbing to the real fields keeps it
296
309
  // open (the selection persists) so an editor can flip between diff and fields;
@@ -435,19 +448,6 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
435
448
  function save() {
436
449
  source.save(draft);
437
450
  }
438
- // EC-316: "Save & close" — the SAME save path as everywhere else in this
439
- // header (autosave's debounce, the explicit-save rail's Save button, and the
440
- // unmount flush all call this exact `save()`), immediately, then navigate
441
- // back to the rail's own list route. Rail-owned (`listPath`, AD-19) so this
442
- // never hard-codes the CMS route; falls back to the project index for the
443
- // rare rail with no list surface at all (mirrors the delete flow below).
444
- function saveAndClose() {
445
- save();
446
- void navigate({
447
- to: STUDIO_PROJECT_ROUTE,
448
- params: { projectId, _splat: source.listPath ?? studioPath.index() },
449
- });
450
- }
451
451
  // EC-224: a version restore swaps the working draft for an older snapshot; the
452
452
  // open editor adopts it so the form/canvas re-render from the restored content
453
453
  // (the operation already persisted it, so it lands clean/non-dirty).
@@ -486,12 +486,25 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
486
486
  // memoized: its render closes over per-render edit state (draft, highlight,
487
487
  // field errors) via `renderFieldRow`, and the inspector re-renders with the rest
488
488
  // of the editor anyway, so a stable pane identity would buy nothing here.
489
- const detailsPane = hasAliases(collection) || visibleSidebarFields.length > 0
489
+ // 0.0.39: whether a deployment route addresses this collection — gates the header's
490
+ // "View live" AND the Details pane's document-level URL row (EC-315 hybrid split).
491
+ // A cheap synchronous accessor, resolved on every render (NOT frozen in a `useMemo`
492
+ // with a static dep — the same first-paint-empty-routes trap the SlugField fix
493
+ // removed: routes arrive after the workspace/config loads).
494
+ const routableCollection = resolveDeploymentRoutes().some((route) => route.document?.collection === collection.id);
495
+ const detailsPane = hasAliases(collection) || visibleSidebarFields.length > 0 || routableCollection
490
496
  ? {
491
497
  id: 'details',
492
498
  label: translate('cms.editor.detailsPane'),
493
499
  icon: _jsx(Tag, { className: "size-3.5" }),
494
- render: () => (_jsx(DetailsPane, { collection: collection, doc: draft, siblings: source.documents(), readOnly: viewReadOnly, fields: visibleSidebarFields, renderField: renderFieldRow, onAliasChange: (alias) => setDraft((d) => {
500
+ render: () => (_jsx(DetailsPane, { collection: collection, doc: draft, siblings: source.documents(), readOnly: viewReadOnly, fields: visibleSidebarFields, renderField: renderFieldRow, urlContext: routableCollection
501
+ ? {
502
+ projectId,
503
+ routes: resolveDeploymentRoutes(),
504
+ localeConfig: source.localeConfig,
505
+ locale,
506
+ }
507
+ : undefined, onAliasChange: (alias) => setDraft((d) => {
495
508
  const next = { ...d };
496
509
  if (alias === undefined)
497
510
  delete next.alias;
@@ -513,6 +526,10 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
513
526
  const [publishState, setPublishState] = useState('idle');
514
527
  // EC-300/AD-16: confirm-through guard when the page embeds unpublished reusables.
515
528
  const [publishConfirm, setPublishConfirm] = useState(false);
529
+ // 0.0.39: the split-button "Publish & close" intent — remembered across the
530
+ // confirm-through dialog so a successful publish returns to the list, matching the
531
+ // retired EC-316 "Save & close". Cleared on cancel or a publish failure (no navigate).
532
+ const closeAfterPublishRef = useRef(false);
516
533
  // Gate publish on the confirm-through dialog when there are unpublished hard
517
534
  // references (they'd render empty on the live site); schema errors already
518
535
  // disable the button, so they never reach here.
@@ -523,6 +540,13 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
523
540
  }
524
541
  void doPublish();
525
542
  }
543
+ // 0.0.39: publish, then return to the rail's list (rail-owned `listPath`, AD-19),
544
+ // the same navigation the removed "Save & close" did. Shares handlePublish's exact
545
+ // gating + confirm-through; the close happens only after the publish resolves.
546
+ function handlePublishAndClose() {
547
+ closeAfterPublishRef.current = true;
548
+ handlePublish();
549
+ }
526
550
  async function doPublish() {
527
551
  // EC-273: clean conditionally-hidden values from the draft AT PUBLISH TIME.
528
552
  // While editing, the draft keeps them so an accidental hide (e.g. flipping a
@@ -548,8 +572,18 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
548
572
  await source.publishing?.publish(ref);
549
573
  setPublishState('success');
550
574
  setTimeout(() => setPublishState('idle'), 1800);
575
+ // "Publish & close": return to the list once the publish landed (never on a
576
+ // failure). Rail-owned `listPath`, falling back to the project index (AD-19).
577
+ if (closeAfterPublishRef.current) {
578
+ closeAfterPublishRef.current = false;
579
+ void navigate({
580
+ to: STUDIO_PROJECT_ROUTE,
581
+ params: { projectId, _splat: source.listPath ?? studioPath.index() },
582
+ });
583
+ }
551
584
  }
552
585
  catch (error) {
586
+ closeAfterPublishRef.current = false;
553
587
  setPublishState('idle');
554
588
  toast.error(error instanceof Error
555
589
  ? translate('cms.editor.publishFailedReason', { error: error.message })
@@ -611,7 +645,6 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
611
645
  // URL visitors actually get; `undefined` until the doc is published (and the
612
646
  // pool has loaded). The collection being routable gates the disabled hint below.
613
647
  const publishedDocs = usePublishedDocuments(source.projectId, collection.id);
614
- const routableCollection = useMemo(() => resolveDeploymentRoutes().some((route) => route.document?.collection === collection.id), [collection.id]);
615
648
  const liveUrl = useMemo(() => {
616
649
  const published = publishedDocs.byId.get(doc.id);
617
650
  if (!published)
@@ -635,6 +668,18 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
635
668
  const hasFrameworkTabs = showReferences || compare !== null;
636
669
  const showContentTabs = tabs.length > 1 || hasFrameworkTabs;
637
670
  const showLensRow = showContentTabs || canLocales || canPublish;
671
+ // 0.0.39 split Publish button — the shared gating/label, computed once so the
672
+ // primary "Publish" and the "Publish & close" item can never diverge.
673
+ const publishDisabled = hasErrors || viewReadOnly || publishState === 'pending';
674
+ const publishTitle = viewReadOnly
675
+ ? translate('cms.editor.switchToDraftToPublish')
676
+ : hasErrors
677
+ ? translate(errorCount === 1 ? 'cms.editor.fixIssuesToPublish.one' : 'cms.editor.fixIssuesToPublish.other', { count: errorCount })
678
+ : undefined;
679
+ const publishButtonLabel = publishState === 'pending' ? (_jsxs(_Fragment, { children: [_jsx(Loader2, { className: "animate-spin" }), " ", translate('cms.editor.publishing')] })) : publishState === 'success' ? (_jsxs(_Fragment, { children: [_jsx(CircleCheck, {}), " ", translate('cms.editor.publishedState')] })) : (_jsxs(_Fragment, { children: [_jsx(Eye, {}), " ", translate('cms.editor.publish')] }));
680
+ // Offer "Publish & close" only when there IS a list to return to — the same gate
681
+ // the removed EC-316 "Save & close" used (never a singleton or a list-less rail).
682
+ const canPublishClose = !singleton && source.listPath !== undefined;
638
683
  return (_jsx(DocumentEditorProvider, { value: {
639
684
  projectId,
640
685
  collection,
@@ -651,61 +696,60 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
651
696
  setCompareBase,
652
697
  setCompareTarget,
653
698
  exitCompare,
654
- }, children: _jsxs("div", { className: cn('flex h-full min-h-0 flex-col', viewReadOnly && 'bg-info/[0.04]'), children: [_jsxs("div", { className: "shrink-0 border-b border-border px-4 py-3 sm:px-6", children: [_jsxs(Row, { gap: "3", children: [_jsxs(Row, { gap: "2", align: "center", className: "min-w-0", children: [_jsx(Heading, { level: 1, size: "h2", className: "truncate", children: singleton ? localizedLabel(collection.form?.label, collection.id) : title || doc.id }), singleton ? null : _jsx(FallbackChip, { fallback: titleFallback, testId: "doc-title-fallback" })] }), _jsx(Spacer, {}), _jsx(Text, { size: "xs", tone: "muted", className: "whitespace-nowrap", "data-testid": "autosave-status", children: source.lastError
655
- ? translate('cms.editor.saveFailed')
656
- : explicitSave
657
- ? dirty
658
- ? translate('cms.editor.unsavedChanges')
659
- : translate('cms.editor.saved')
660
- : dirty
661
- ? translate('cms.editor.saving')
662
- : translate('cms.editor.saved') }), canPublish ? _jsx(DocStateBadge, { published: published }) : null, routableCollection ? (liveUrl?.href ? (_jsx(Button, { asChild: true, variant: "outline", size: "sm", title: translate('cms.editor.openLiveUrl', { path: liveUrl.path }), children: _jsxs("a", { href: liveUrl.href, target: "_blank", rel: "noreferrer", "data-testid": "view-live", children: [_jsx(ExternalLink, {}), " ", translate('cms.editor.viewLive')] }) })) : (_jsxs(Button, { variant: "outline", size: "sm", disabled: true, "data-testid": "view-live-disabled", title: publishedDocs.status === 'loading'
663
- ? translate('cms.editor.loadingLiveUrl')
664
- : !published
665
- ? translate('cms.editor.publishToViewLive')
666
- : translate('cms.editor.setFrontendUrlHint'), children: [_jsx(ExternalLink, {}), " ", translate('cms.editor.viewLive')] }))) : null, canPublish ? (_jsx(Button, { size: "sm", onClick: handlePublish, disabled: hasErrors || viewReadOnly || publishState === 'pending', title: viewReadOnly
667
- ? translate('cms.editor.switchToDraftToPublish')
668
- : hasErrors
669
- ? translate(errorCount === 1
670
- ? 'cms.editor.fixIssuesToPublish.one'
671
- : 'cms.editor.fixIssuesToPublish.other', { count: errorCount })
672
- : undefined, children: publishState === 'pending' ? (_jsxs(_Fragment, { children: [_jsx(Loader2, { className: "animate-spin" }), " ", translate('cms.editor.publishing')] })) : publishState === 'success' ? (_jsxs(_Fragment, { children: [_jsx(CircleCheck, {}), " ", translate('cms.editor.publishedState')] })) : (_jsxs(_Fragment, { children: [_jsx(Eye, {}), " ", translate('cms.editor.publish')] })) })) : null, explicitSave ? (_jsxs(Button, { size: "sm", onClick: save, disabled: !dirty, "data-testid": "doc-save", children: [_jsx(Check, {}), " ", translate('common.save')] })) : null, !singleton && source.listPath !== undefined ? (_jsxs(Button, { variant: "outline", size: "sm", onClick: saveAndClose, "data-testid": "doc-save-and-close", children: [_jsx(Save, {}), " ", translate('cms.editor.saveAndClose')] })) : null, published || !singleton ? (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "outline", size: "icon-sm", "aria-label": translate('cms.editor.moreActions'), children: _jsx(MoreHorizontal, {}) }) }), _jsxs(DropdownMenuContent, { align: "end", children: [discardStatus !== 'hidden' ? (_jsxs(DropdownMenuItem, { "data-testid": "doc-discard-draft", disabled: discardStatus !== 'ready', title: discardDisabledReason, onClick: () => setDiscardConfirm(true), children: [_jsx(RotateCcw, {}), " ", translate('cms.editor.discardDraft')] })) : null, published ? (_jsxs(DropdownMenuItem, { onClick: () => setConfirmAction('unpublish'), children: [_jsx(EyeOff, {}), " ", translate('cms.editor.unpublish')] })) : null, singleton ? null : (_jsxs(DropdownMenuItem, { "data-testid": "doc-delete", className: "text-destructive", onClick: () => setConfirmAction('delete'), children: [_jsx(Trash2, {}), " ", translate('common.delete')] }))] })] })) : null] }), showLensRow ? (_jsxs(Row, { gap: "3", className: "mt-3", wrap: true, children: [_jsxs(Row, { gap: "1", align: "center", children: [showContentTabs ? tabs.map((tab) => renderContentTab(tab)) : null, showReferences ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "mx-1 h-4 w-px bg-border", "aria-hidden": true }), renderContentTab(REFERENCES_TAB)] })) : null, compare ? (_jsxs("span", { className: cn('inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-medium transition-colors', activeTab === COMPARE_TAB
673
- ? 'bg-accent text-accent-foreground'
674
- : 'text-muted-foreground hover:bg-muted'), children: [_jsxs("button", { type: "button", onClick: () => setActiveTab(COMPARE_TAB), "aria-pressed": activeTab === COMPARE_TAB, className: "inline-flex items-center gap-1", children: [_jsx(GitCompareArrows, { className: "size-3", "aria-hidden": true }), " ", translate('cms.version.compare')] }), _jsx("button", { type: "button", onClick: exitCompare, "aria-label": translate('cms.version.closeCompare'), className: "ml-0.5 rounded p-0.5 hover:bg-background/40", children: _jsx(X, { className: "size-3" }) })] })) : null] }), _jsx(Spacer, {}), canLocales ? _jsx(LocaleSwitcher, {}) : null, canPublish ? (_jsxs(Row, { gap: "px", className: "rounded-md bg-muted/60 p-0.5", children: [_jsx(PerspectiveButton, { label: translate('cms.editor.draftState'), active: perspective === 'draft', onClick: () => setPerspective('draft') }), _jsx(PerspectiveButton, { label: translate('cms.editor.publishedState'), active: perspective === 'published', accent: "info", disabled: !published, title: published ? undefined : translate('cms.editor.publishToViewPublished'), onClick: () => setPerspective('published') })] })) : null] })) : null] }), viewReadOnly ? (_jsxs(Row, { gap: "2", className: "shrink-0 border-b border-info/30 bg-info/10 px-4 py-1.5 text-info sm:px-6", "data-testid": "published-view-banner", children: [_jsx(Lock, { className: "size-3.5 shrink-0", "aria-hidden": true }), _jsx(Text, { size: "xs", className: "text-info", children: translate('cms.editor.publishedReadOnlyBanner') }), _jsx(Spacer, {}), _jsx("button", { type: "button", onClick: () => setPerspective('draft'), className: "text-xs font-medium underline-offset-2 hover:underline", children: translate('cms.editor.switchToDraft') })] })) : null, publishedPending ? (_jsx("div", { className: "grid min-h-0 flex-1 place-items-center p-8", children: _jsx(Text, { size: "sm", tone: "muted", children: publishedSnapshot.status === 'loading'
675
- ? translate('cms.editor.loadingPublishedVersion')
676
- : translate('cms.editor.noPublishedVersion') }) })) : activeTab === COMPARE_TAB && compare ? (_jsxs("div", { className: "flex min-h-0 flex-1 overflow-hidden", children: [_jsx("div", { className: "min-h-0 flex-1 overflow-auto", children: _jsx(VersionCompare, {}) }), _jsx(InspectorSidebar, { panes: trailingPanes, activeId: inspectorPaneId, onActiveChange: setInspectorPaneId, className: "w-[320px] shrink-0 border-l border-border" })] })) : canvasField ? (_jsx("div", { className: "min-h-0 flex-1 overflow-hidden", children: _jsx(CompositionFieldEditor, { field: canvasField, value: getValue(canvasField), onChange: (v) => setValue(canvasField, v), readOnly: viewReadOnly || source.isFieldReadOnly(canvasField), trailingPanes: trailingPanes }, `${canvasField.name}:${restoreNonce}:${perspective}`) })) : activeTab === REFERENCES_TAB ? (_jsx("div", { className: "flex min-h-0 flex-1 overflow-hidden", children: _jsx("div", { className: "min-h-0 flex-1 overflow-auto p-4 sm:p-6", children: _jsx("div", { className: "mx-auto w-full max-w-2xl", children: _jsx(ReferencesPanel, { projectId: projectId, source: { type: 'document', id: documentKey(ref) }, target: { type: 'document', id: documentKey(ref) } }) }) }) })) : (_jsxs("div", { className: "flex min-h-0 flex-1 overflow-hidden", children: [_jsx("div", { className: "min-h-0 flex-1 overflow-auto p-4 sm:p-6", children: _jsx("div", { className: "mx-auto w-full max-w-2xl", children: _jsx(Stack, { gap: "6", children: groups.map((group) => {
677
- // EC-306: a conditionally/statically hidden group section is skipped
678
- // entirely (its fields are also exempt from validation).
679
- if (hiddenGroups.has(group.name))
680
- return null;
681
- // EC-273/EC-240: drop a fieldset whose every field is hidden — whether
682
- // content-conditionally or for this principal — so the form never
683
- // shows an empty section header.
684
- const visible = group.fields.filter(isFieldShown);
685
- if (visible.length === 0)
686
- return null;
687
- const section = group.section;
688
- // EC-241: a section `title` is a LocalizedString ({ en, de } or a plain
689
- // string) — resolve it against the active UI locale before rendering.
690
- const sectionTitle = section?.title !== undefined ? localizedLabel(section.title, group.name) : undefined;
691
- return (_jsx(CollapsibleSection, { collapsible: section?.collapsible, defaultOpen: section?.defaultOpen, title: _jsx(Text, { size: "sm", weight: "semibold", className: sectionTitle || group.name === DEFAULT_FIELD_GROUP ? undefined : 'capitalize', children: sectionTitle ??
692
- (group.name === DEFAULT_FIELD_GROUP
693
- ? translate('cms.editor.defaultGroup')
694
- : group.name) }), children: _jsx("div", { className: "grid grid-cols-1 gap-4 p-4 md:grid-cols-12", children: visible.map((field) => (_jsx("div", { className: cn('col-span-1', fieldWidthColSpan(field.form?.width)), children: renderFieldRow(field) }, field.name))) }) }, group.name));
695
- }) }) }) }), _jsx(InspectorSidebar, { panes: trailingPanes, activeId: inspectorPaneId, onActiveChange: setInspectorPaneId, className: "w-[320px] shrink-0 border-l border-border" })] })), explicitSave ? (_jsx(UnsavedChangesGuard, { when: dirty, onSave: () => {
696
- save();
697
- return true;
698
- }, description: translate('cms.editor.unsavedChangesGuard') })) : null, confirmAction !== null ? (_jsx(ReferenceConfirmDialog, { projectId: projectId, action: confirmAction, targets: confirmTargets, open: true, onOpenChange: (open) => {
699
- if (!open)
700
- setConfirmAction(null);
701
- }, onConfirm: handleConfirmedAction })) : null, _jsx(Dialog, { open: discardConfirm, onOpenChange: (open) => { if (!open)
702
- setDiscardConfirm(false); }, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: translate('cms.editor.discardDraftTitle') }), _jsx(DialogDescription, { children: translate('cms.editor.discardDraftDescription') })] }), _jsxs(DialogFooter, { className: "mt-4", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: () => setDiscardConfirm(false), disabled: discardBusy, children: translate('common.cancel') }), _jsx(Button, { variant: "destructive", size: "sm", "data-testid": "discard-draft-confirm", disabled: discardBusy, onClick: () => void handleDiscardDraft(), children: discardBusy ? (_jsxs(_Fragment, { children: [_jsx(Loader2, { className: "animate-spin" }), " ", translate('cms.editor.discardingDraft')] })) : (_jsxs(_Fragment, { children: [_jsx(RotateCcw, {}), " ", translate('cms.editor.discardDraftConfirm')] })) })] })] }) }), _jsx(Dialog, { open: publishConfirm, onOpenChange: (open) => { if (!open)
703
- setPublishConfirm(false); }, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: translate('cms.editor.publishUnpublishedRefsTitle') }), _jsx(DialogDescription, { children: translate('cms.editor.publishUnpublishedRefsDescription') })] }), _jsxs(Stack, { gap: "1", children: [referenceIssues.map((issue, i) => (_jsxs(Text, { size: "xs", tone: "muted", children: ["\u2022 ", issue.message] }, `${issue.meta?.id ?? i}`))), schemaIssues.length > 0 ? (_jsx(Text, { size: "xs", tone: "muted", className: "mt-1", children: translate(schemaIssues.length === 1
704
- ? 'cms.editor.publishAlsoHasIssues.one'
705
- : 'cms.editor.publishAlsoHasIssues.other', { count: schemaIssues.length }) })) : null] }), _jsxs(DialogFooter, { className: "mt-4", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: () => setPublishConfirm(false), children: translate('common.cancel') }), _jsx(Button, { size: "sm", "data-testid": "publish-anyway", onClick: () => {
706
- setPublishConfirm(false);
707
- void doPublish();
708
- }, children: translate('cms.editor.publishAnyway') })] })] }) })] }) }));
699
+ }, children: _jsx(EmbedInspectorProvider, { value: embedInspectorApi, children: _jsxs("div", { className: cn('flex h-full min-h-0 flex-col', viewReadOnly && 'bg-info/[0.04]'), children: [_jsxs("div", { className: "shrink-0 border-b border-border px-4 py-3 sm:px-6", children: [_jsxs(Row, { gap: "3", children: [_jsxs(Row, { gap: "2", align: "center", className: "min-w-0", children: [_jsx(Heading, { level: 1, size: "h2", className: "truncate", children: singleton ? localizedLabel(collection.form?.label, collection.id) : title || doc.id }), singleton ? null : _jsx(FallbackChip, { fallback: titleFallback, testId: "doc-title-fallback" })] }), _jsx(Spacer, {}), _jsx(Text, { size: "xs", tone: "muted", className: "whitespace-nowrap", "data-testid": "autosave-status", children: source.lastError
700
+ ? translate('cms.editor.saveFailed')
701
+ : explicitSave
702
+ ? dirty
703
+ ? translate('cms.editor.unsavedChanges')
704
+ : translate('cms.editor.saved')
705
+ : dirty
706
+ ? translate('cms.editor.saving')
707
+ : translate('cms.editor.saved') }), canPublish ? _jsx(DocStateBadge, { published: published }) : null, routableCollection ? (liveUrl?.href ? (_jsx(Button, { asChild: true, variant: "outline", size: "sm", title: translate('cms.editor.openLiveUrl', { path: liveUrl.path }), children: _jsxs("a", { href: liveUrl.href, target: "_blank", rel: "noreferrer", "data-testid": "view-live", children: [_jsx(ExternalLink, {}), " ", translate('cms.editor.viewLive')] }) })) : (_jsxs(Button, { variant: "outline", size: "sm", disabled: true, "data-testid": "view-live-disabled", title: publishedDocs.status === 'loading'
708
+ ? translate('cms.editor.loadingLiveUrl')
709
+ : !published
710
+ ? translate('cms.editor.publishToViewLive')
711
+ : translate('cms.editor.setFrontendUrlHint'), children: [_jsx(ExternalLink, {}), " ", translate('cms.editor.viewLive')] }))) : null, canPublish ? (_jsxs(Row, { gap: "px", align: "center", children: [_jsx(Button, { size: "sm", onClick: handlePublish, disabled: publishDisabled, title: publishTitle, className: canPublishClose ? 'rounded-r-none' : undefined, "data-testid": "doc-publish", children: publishButtonLabel }), canPublishClose ? (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { size: "sm", disabled: publishDisabled, "aria-label": translate('cms.editor.publishActions'), "data-testid": "doc-publish-actions", className: "rounded-l-none border-l border-primary-foreground/25 px-1.5", children: _jsx(ChevronDown, { className: "size-3.5" }) }) }), _jsx(DropdownMenuContent, { align: "end", children: _jsxs(DropdownMenuItem, { onClick: handlePublishAndClose, "data-testid": "doc-publish-and-close", children: [_jsx(Save, {}), " ", translate('cms.editor.publishAndClose')] }) })] })) : null] })) : null, explicitSave ? (_jsxs(Button, { size: "sm", onClick: save, disabled: !dirty, "data-testid": "doc-save", children: [_jsx(Check, {}), " ", translate('common.save')] })) : null, published || !singleton ? (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "outline", size: "icon-sm", "aria-label": translate('cms.editor.moreActions'), children: _jsx(MoreHorizontal, {}) }) }), _jsxs(DropdownMenuContent, { align: "end", children: [discardStatus !== 'hidden' ? (_jsxs(DropdownMenuItem, { "data-testid": "doc-discard-draft", disabled: discardStatus !== 'ready', title: discardDisabledReason, onClick: () => setDiscardConfirm(true), children: [_jsx(RotateCcw, {}), " ", translate('cms.editor.discardDraft')] })) : null, published ? (_jsxs(DropdownMenuItem, { onClick: () => setConfirmAction('unpublish'), children: [_jsx(EyeOff, {}), " ", translate('cms.editor.unpublish')] })) : null, singleton ? null : (_jsxs(DropdownMenuItem, { "data-testid": "doc-delete", className: "text-destructive", onClick: () => setConfirmAction('delete'), children: [_jsx(Trash2, {}), " ", translate('common.delete')] }))] })] })) : null] }), showLensRow ? (_jsxs(Row, { gap: "3", className: "mt-3", wrap: true, children: [_jsxs(Row, { gap: "1", align: "center", children: [showContentTabs ? tabs.map((tab) => renderContentTab(tab)) : null, showReferences ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "mx-1 h-4 w-px bg-border", "aria-hidden": true }), renderContentTab(REFERENCES_TAB)] })) : null, compare ? (_jsxs("span", { className: cn('inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-medium transition-colors', activeTab === COMPARE_TAB
712
+ ? 'bg-accent text-accent-foreground'
713
+ : 'text-muted-foreground hover:bg-muted'), children: [_jsxs("button", { type: "button", onClick: () => setActiveTab(COMPARE_TAB), "aria-pressed": activeTab === COMPARE_TAB, className: "inline-flex items-center gap-1", children: [_jsx(GitCompareArrows, { className: "size-3", "aria-hidden": true }), " ", translate('cms.version.compare')] }), _jsx("button", { type: "button", onClick: exitCompare, "aria-label": translate('cms.version.closeCompare'), className: "ml-0.5 rounded p-0.5 hover:bg-background/40", children: _jsx(X, { className: "size-3" }) })] })) : null] }), _jsx(Spacer, {}), canLocales ? _jsx(LocaleSwitcher, {}) : null, canPublish ? (_jsxs(Row, { gap: "px", className: "rounded-md bg-muted/60 p-0.5", children: [_jsx(PerspectiveButton, { label: translate('cms.editor.draftState'), active: perspective === 'draft', onClick: () => setPerspective('draft') }), _jsx(PerspectiveButton, { label: translate('cms.editor.publishedState'), active: perspective === 'published', accent: "info", disabled: !published, title: published ? undefined : translate('cms.editor.publishToViewPublished'), onClick: () => setPerspective('published') })] })) : null] })) : null] }), viewReadOnly ? (_jsxs(Row, { gap: "2", className: "shrink-0 border-b border-info/30 bg-info/10 px-4 py-1.5 text-info sm:px-6", "data-testid": "published-view-banner", children: [_jsx(Lock, { className: "size-3.5 shrink-0", "aria-hidden": true }), _jsx(Text, { size: "xs", className: "text-info", children: translate('cms.editor.publishedReadOnlyBanner') }), _jsx(Spacer, {}), _jsx("button", { type: "button", onClick: () => setPerspective('draft'), className: "text-xs font-medium underline-offset-2 hover:underline", children: translate('cms.editor.switchToDraft') })] })) : null, publishedPending ? (_jsx("div", { className: "grid min-h-0 flex-1 place-items-center p-8", children: _jsx(Text, { size: "sm", tone: "muted", children: publishedSnapshot.status === 'loading'
714
+ ? translate('cms.editor.loadingPublishedVersion')
715
+ : translate('cms.editor.noPublishedVersion') }) })) : activeTab === COMPARE_TAB && compare ? (_jsxs("div", { className: "flex min-h-0 flex-1 overflow-hidden", children: [_jsx("div", { className: "min-h-0 flex-1 overflow-auto", children: _jsx(VersionCompare, {}) }), _jsx(InspectorSidebar, { panes: trailingPanes, activeId: inspectorPaneId, onActiveChange: setInspectorPaneId, className: "w-[320px] shrink-0 border-l border-border" })] })) : canvasField ? (_jsx("div", { className: "min-h-0 flex-1 overflow-hidden", children: _jsx(CompositionFieldEditor, { field: canvasField, value: getValue(canvasField), onChange: (v) => setValue(canvasField, v), readOnly: viewReadOnly || source.isFieldReadOnly(canvasField), trailingPanes: trailingPanes }, `${canvasField.name}:${restoreNonce}:${perspective}`) })) : activeTab === REFERENCES_TAB ? (_jsx("div", { className: "flex min-h-0 flex-1 overflow-hidden", children: _jsx("div", { className: "min-h-0 flex-1 overflow-auto p-4 sm:p-6", children: _jsx("div", { className: "mx-auto w-full max-w-2xl", children: _jsx(ReferencesPanel, { projectId: projectId, source: { type: 'document', id: documentKey(ref) }, target: { type: 'document', id: documentKey(ref) } }) }) }) })) : (_jsxs("div", { className: "flex min-h-0 flex-1 overflow-hidden", children: [_jsx("div", { className: "min-h-0 flex-1 overflow-auto p-4 sm:p-6", children: _jsx("div", { className: "mx-auto w-full max-w-2xl", children: _jsx(Stack, { gap: "6", children: groups.map((group) => {
716
+ // EC-306: a conditionally/statically hidden group section is skipped
717
+ // entirely (its fields are also exempt from validation).
718
+ if (hiddenGroups.has(group.name))
719
+ return null;
720
+ // EC-273/EC-240: drop a fieldset whose every field is hidden — whether
721
+ // content-conditionally or for this principal — so the form never
722
+ // shows an empty section header.
723
+ const visible = group.fields.filter(isFieldShown);
724
+ if (visible.length === 0)
725
+ return null;
726
+ const section = group.section;
727
+ // EC-241: a section `title` is a LocalizedString ({ en, de } or a plain
728
+ // string) — resolve it against the active UI locale before rendering.
729
+ const sectionTitle = section?.title !== undefined ? localizedLabel(section.title, group.name) : undefined;
730
+ return (_jsx(CollapsibleSection, { collapsible: section?.collapsible, defaultOpen: section?.defaultOpen, title: _jsx(Text, { size: "sm", weight: "semibold", className: sectionTitle || group.name === DEFAULT_FIELD_GROUP ? undefined : 'capitalize', children: sectionTitle ??
731
+ (group.name === DEFAULT_FIELD_GROUP
732
+ ? translate('cms.editor.defaultGroup')
733
+ : group.name) }), children: _jsx("div", { className: "grid grid-cols-1 gap-4 p-4 md:grid-cols-12", children: visible.map((field) => (_jsx("div", { className: cn('col-span-1', fieldWidthColSpan(field.form?.width)), children: renderFieldRow(field) }, field.name))) }) }, group.name));
734
+ }) }) }) }), activeEmbed ? (_jsx(EmbedInspector, { payload: activeEmbed, className: "w-[320px] shrink-0 border-l border-border" })) : (_jsx(InspectorSidebar, { panes: trailingPanes, activeId: inspectorPaneId, onActiveChange: setInspectorPaneId, className: "w-[320px] shrink-0 border-l border-border" }))] })), explicitSave ? (_jsx(UnsavedChangesGuard, { when: dirty, onSave: () => {
735
+ save();
736
+ return true;
737
+ }, description: translate('cms.editor.unsavedChangesGuard') })) : null, confirmAction !== null ? (_jsx(ReferenceConfirmDialog, { projectId: projectId, action: confirmAction, targets: confirmTargets, open: true, onOpenChange: (open) => {
738
+ if (!open)
739
+ setConfirmAction(null);
740
+ }, onConfirm: handleConfirmedAction })) : null, _jsx(Dialog, { open: discardConfirm, onOpenChange: (open) => { if (!open)
741
+ setDiscardConfirm(false); }, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: translate('cms.editor.discardDraftTitle') }), _jsx(DialogDescription, { children: translate('cms.editor.discardDraftDescription') })] }), _jsxs(DialogFooter, { className: "mt-4", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: () => setDiscardConfirm(false), disabled: discardBusy, children: translate('common.cancel') }), _jsx(Button, { variant: "destructive", size: "sm", "data-testid": "discard-draft-confirm", disabled: discardBusy, onClick: () => void handleDiscardDraft(), children: discardBusy ? (_jsxs(_Fragment, { children: [_jsx(Loader2, { className: "animate-spin" }), " ", translate('cms.editor.discardingDraft')] })) : (_jsxs(_Fragment, { children: [_jsx(RotateCcw, {}), " ", translate('cms.editor.discardDraftConfirm')] })) })] })] }) }), _jsx(Dialog, { open: publishConfirm, onOpenChange: (open) => { if (!open) {
742
+ setPublishConfirm(false);
743
+ closeAfterPublishRef.current = false;
744
+ } }, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: translate('cms.editor.publishUnpublishedRefsTitle') }), _jsx(DialogDescription, { children: translate('cms.editor.publishUnpublishedRefsDescription') })] }), _jsxs(Stack, { gap: "1", children: [referenceIssues.map((issue, i) => (_jsxs(Text, { size: "xs", tone: "muted", children: ["\u2022 ", issue.message] }, `${issue.meta?.id ?? i}`))), schemaIssues.length > 0 ? (_jsx(Text, { size: "xs", tone: "muted", className: "mt-1", children: translate(schemaIssues.length === 1
745
+ ? 'cms.editor.publishAlsoHasIssues.one'
746
+ : 'cms.editor.publishAlsoHasIssues.other', { count: schemaIssues.length }) })) : null] }), _jsxs(DialogFooter, { className: "mt-4", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: () => {
747
+ setPublishConfirm(false);
748
+ closeAfterPublishRef.current = false;
749
+ }, children: translate('common.cancel') }), _jsx(Button, { size: "sm", "data-testid": "publish-anyway", onClick: () => {
750
+ setPublishConfirm(false);
751
+ void doPublish();
752
+ }, children: translate('cms.editor.publishAnyway') })] })] }) })] }) }) }));
709
753
  }
710
754
  /**
711
755
  * EC-300/AD-16 — errors for composition embeds (hard references) whose target isn't