diff --git a/src/client/contentstack-client.service.ts b/src/client/contentstack-client.service.ts index 83d6056..b6c9940 100644 --- a/src/client/contentstack-client.service.ts +++ b/src/client/contentstack-client.service.ts @@ -278,7 +278,10 @@ export class ContentstackClientService { * `data-cslp` attributes. Only invoked when `delivery.livePreview` is on, so * pure delivery builds are untouched. */ - protected tagForLivePreview(entry: ContentstackCmsPageEntry, contentTypeUid: string): void { + protected tagForLivePreview( + entry: ContentstackCmsPageEntry | ContentstackEntry, + contentTypeUid: string, + ): void { tagEntryTree(entry, contentTypeUid, entry.locale ?? 'en-us'); } @@ -313,6 +316,14 @@ export class ContentstackClientService { access.gateRoot, ); } + // Tag standalone/adapter-loaded entries too (unresolved references, + // per-uid reloads on a language switch), mirroring the page path — so a + // component reaching an editable renderer through this fetch still + // carries its `$` field-tag map and stays inline-editable in Visual + // Builder. Only when live preview is on, so delivery builds are untouched. + if (result && this.config.contentstack?.delivery?.livePreview) { + this.tagForLivePreview(result, contentTypeUid); + } return result; }); }); @@ -353,11 +364,18 @@ export class ContentstackClientService { // Filter gated content BEFORE it is persisted to TransferState: // each restricted entry is redacted to a tags-only stub so the adapter // still counts it as "found" (no OCC refetch) without shipping content. - return access + const sanitized = access ? list.map((entry) => this.restrictions.sanitizeForTransfer(entry, access.permissions, access.gateRoot), ) : list; + // Tag adapter-loaded entries in preview so multi-uid reloads (e.g. a + // language switch) keep their `$` field-tag map and stay editable in + // Visual Builder — mirrors getEntryByUid and the page path. + if (this.config.contentstack?.delivery?.livePreview) { + sanitized.forEach((entry) => this.tagForLivePreview(entry, contentTypeUid)); + } + return sanitized; }); }, // Failure fallback: an empty array, never `undefined` — the return type is diff --git a/src/cms/components/contentstack-editable-banner.component.ts b/src/cms/components/contentstack-editable-banner.component.ts new file mode 100644 index 0000000..00101ea --- /dev/null +++ b/src/cms/components/contentstack-editable-banner.component.ts @@ -0,0 +1,62 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { CmsBannerComponent } from '@spartacus/core'; +import { CmsComponentData, GenericLinkModule, MediaModule } from '@spartacus/storefront'; +import { CsEditableDirective } from '../../live-preview/cs-editable.directive'; + +/** + * The `data` shape read from `CmsComponentData` for a banner. `media` (the + * responsive breakpoint set) and `urlLink` are what the stock Spartacus + * `BannerComponent` reads; `$` is the Live Preview field-tag map preserved by + * {@link ContentstackCmsComponentNormalizer} (present only on preview builds). + */ +export interface ContentstackEditableBannerData extends CmsBannerComponent { + urlLink?: string; + $?: Record; +} + +/** + * Connector-provided replacement for Spartacus's stock banner renderer that adds + * a **field-level** `data-cslp` tag, so Contentstack's Visual Builder selects the + * banner as an editable field — instead of the component only carrying the coarse + * entry-level tag it flags as an invalid / incorrectly-generated CSLP tag. + * + * The tag is placed on the banner's **`url_link`** (a text field), NOT on the + * `media` image. Visual Builder's inline `data-cslp` editing only supports + * text-type fields; a tag pointing at a file/asset field (`media`) is rejected as + * "invalid or incorrectly generated" (unlike the paragraph `content` and carousel + * `title` text fields, which tag cleanly). The banner **image** is still fully + * editable in Visual Builder — via the entry's form panel, which is the Contentstack + * pattern for asset fields — just not by clicking the rendered image inline. + * + * Renders the same building blocks as the stock banner — Spartacus's `cx-media` + * (responsive image) inside `cx-generic-link` (SPA-aware link) — so the visual + * output and routing behavior are unchanged; the only addition is the edit tag, + * which is inert outside preview builds (no `$` ⇒ CsEditableDirective removes the + * attribute). Registered by {@link ContentstackEditableComponentsModule} for the + * banner typeCodes, on by default via {@link ContentstackCmsFeatureModule}. + */ +@Component({ + selector: 'cs-editable-banner', + standalone: true, + imports: [CommonModule, MediaModule, GenericLinkModule, CsEditableDirective], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ + + + + + +
+ `, +}) +export class ContentstackEditableBannerComponent { + protected readonly componentData: CmsComponentData = + inject(CmsComponentData); + + /** Banner data stream from Contentstack (via the CMS store). */ + readonly data$: Observable = this.componentData.data$; +} diff --git a/src/cms/components/contentstack-editable-components.module.ts b/src/cms/components/contentstack-editable-components.module.ts new file mode 100644 index 0000000..c0d23f0 --- /dev/null +++ b/src/cms/components/contentstack-editable-components.module.ts @@ -0,0 +1,45 @@ +import { NgModule } from '@angular/core'; +import { CmsConfig, provideConfig } from '@spartacus/core'; +import { ContentstackEditableParagraphComponent } from './contentstack-editable-paragraph.component'; +import { ContentstackEditableBannerComponent } from './contentstack-editable-banner.component'; +import { ContentstackEditableProductCarouselComponent } from './contentstack-editable-product-carousel.component'; + +/** + * Swaps Spartacus's stock renderers for connector-owned ones that emit + * **field-level** `data-cslp` tags, so seeded Contentstack component types become + * inline-editable in the Visual Builder (instead of only carrying the coarse + * entry-level tag that Visual Builder flags as an invalid / incorrectly-generated + * CSLP tag). + * + * Imported by {@link ContentstackCmsFeatureModule} by default, so consuming apps + * get this automatically with no extra wiring. It is safe on by default: the + * editable renderers render identically to the stock Spartacus components, and + * the edit tags are inert outside preview builds (no `entry.$` ⇒ the directive + * removes the attribute), so normal delivery/production rendering is unchanged. + * + * Covers `CMSParagraphComponent`, the banner typeCodes (`SimpleBannerComponent`, + * `SimpleResponsiveBannerComponent`), and `ProductCarouselComponent` (whose + * editable renderer keeps the live SAP product hydration and only adds an + * editable tag on the carousel title). + */ +@NgModule({ + providers: [ + provideConfig({ + cmsComponents: { + CMSParagraphComponent: { + component: ContentstackEditableParagraphComponent, + }, + SimpleBannerComponent: { + component: ContentstackEditableBannerComponent, + }, + SimpleResponsiveBannerComponent: { + component: ContentstackEditableBannerComponent, + }, + ProductCarouselComponent: { + component: ContentstackEditableProductCarouselComponent, + }, + }, + } as CmsConfig), + ], +}) +export class ContentstackEditableComponentsModule {} diff --git a/src/cms/components/contentstack-editable-paragraph.component.spec.ts b/src/cms/components/contentstack-editable-paragraph.component.spec.ts new file mode 100644 index 0000000..30450a0 --- /dev/null +++ b/src/cms/components/contentstack-editable-paragraph.component.spec.ts @@ -0,0 +1,50 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { CmsComponentData } from '@spartacus/storefront'; +import { + ContentstackEditableParagraphComponent, + ContentstackEditableParagraphData, +} from './contentstack-editable-paragraph.component'; + +/** + * Component-level coverage for the core Visual Builder fix: the rendered + * paragraph must carry a field-level `data-cslp` when the entry was tagged + * (preview), and NONE when it was not (delivery). The normalizer specs only + * assert data propagation, so this guards the template binding + directive. + */ +describe('ContentstackEditableParagraphComponent', () => { + const TAG = 'cms_paragraph_component.blt1.en-us.content'; + + async function render( + data: ContentstackEditableParagraphData, + ): Promise> { + await TestBed.configureTestingModule({ + imports: [ContentstackEditableParagraphComponent], + providers: [{ provide: CmsComponentData, useValue: { uid: 'blt1', data$: of(data) } }], + }).compileComponents(); + const fixture = TestBed.createComponent(ContentstackEditableParagraphComponent); + fixture.detectChanges(); + return fixture; + } + + afterEach(() => TestBed.resetTestingModule()); + + it('emits the 4-part field-level data-cslp and renders the content when tagged (preview)', async () => { + const fixture = await render({ + content: '

Hello

', + $: { content: { 'data-cslp': TAG } }, + }); + const el = fixture.nativeElement.querySelector('.cx-paragraph') as HTMLElement; + expect(el).toBeTruthy(); + expect(el.getAttribute('data-cslp')).toBe(TAG); + expect(el.innerHTML).toContain('

Hello

'); + }); + + it('renders no data-cslp attribute when the entry is untagged (delivery)', async () => { + const fixture = await render({ content: '

Hello

' }); + const el = fixture.nativeElement.querySelector('.cx-paragraph') as HTMLElement; + expect(el).toBeTruthy(); + expect(el.hasAttribute('data-cslp')).toBe(false); + expect(el.innerHTML).toContain('

Hello

'); + }); +}); diff --git a/src/cms/components/contentstack-editable-paragraph.component.ts b/src/cms/components/contentstack-editable-paragraph.component.ts new file mode 100644 index 0000000..4d287b3 --- /dev/null +++ b/src/cms/components/contentstack-editable-paragraph.component.ts @@ -0,0 +1,59 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { CmsComponent } from '@spartacus/core'; +import { CmsComponentData } from '@spartacus/storefront'; +import { CsEditableDirective } from '../../live-preview/cs-editable.directive'; + +/** + * The `data` shape this component reads out of `CmsComponentData` for a + * `CMSParagraphComponent`. `content` is the rendered rich text (same field the + * stock Spartacus `ParagraphComponent` reads); `$` is the Live Preview field-tag + * map preserved by {@link ContentstackCmsComponentNormalizer} (present only on + * preview builds). + */ +export interface ContentstackEditableParagraphData extends CmsComponent { + content?: string; + $?: Record; +} + +/** + * A drop-in, connector-provided replacement for Spartacus's stock paragraph + * renderer that additionally emits a **field-level** `data-cslp` tag on the + * content, so Contentstack's Visual Builder can inline-edit seeded + * `cms_paragraph_component` content. + * + * Why this exists: the stock `ParagraphComponent` is owned by Spartacus, so the + * connector can only stamp a coarse **entry-level** tag on its wrapper (via the + * ComponentDecorator). Visual Builder needs a field-level tag + * (`{content_type}.{entry}.{locale}.content`) to edit a field, and reports an + * "Invalid CSLP tag" for the entry-level-only tag. Rendering the paragraph here + * — with `[csEditable]` bound to `data.$.content` — produces that field tag. + * + * Registered by {@link ContentstackEditableComponentsModule} (on by default via + * {@link ContentstackCmsFeatureModule}). Renders + * identically to the stock paragraph (same `cx-paragraph` host class + innerHTML + * content); the only addition is the edit tag, which is inert outside preview + * builds (no `$` ⇒ CsEditableDirective removes the attribute). + */ +@Component({ + selector: 'cs-editable-paragraph', + standalone: true, + imports: [CommonModule, CsEditableDirective], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ `, +}) +export class ContentstackEditableParagraphComponent { + protected readonly componentData: CmsComponentData = + inject(CmsComponentData); + + /** Paragraph content stream from Contentstack (via the CMS store). */ + readonly data$: Observable = this.componentData.data$; +} diff --git a/src/cms/components/contentstack-editable-product-carousel.component.ts b/src/cms/components/contentstack-editable-product-carousel.component.ts new file mode 100644 index 0000000..70cca9b --- /dev/null +++ b/src/cms/components/contentstack-editable-product-carousel.component.ts @@ -0,0 +1,100 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { + CmsProductCarouselComponent, + Product, + ProductScope, + ProductService, +} from '@spartacus/core'; +import { CarouselModule, CmsComponentData, ProductCarouselModule } from '@spartacus/storefront'; +import { CsEditableDirective } from '../../live-preview/cs-editable.directive'; + +/** + * The `data` shape read from `CmsComponentData` for a product carousel. `title` + * and `productCodes` (space-separated SKUs) are produced by the connector's + * field mapper + carousel normalizer; `$` is the Live Preview field-tag map + * preserved by {@link ContentstackCmsComponentNormalizer} (preview builds only). + */ +export interface ContentstackEditableProductCarouselData extends CmsProductCarouselComponent { + title?: string; + productCodes?: string; + $?: Record; +} + +/** + * Connector-provided replacement for Spartacus's stock product-carousel renderer + * that makes the carousel **title** inline-editable in Contentstack's Visual + * Builder, while **preserving the live SAP product hydration**. + * + * The stock `ProductCarouselComponent` renders its title *inside* `cx-carousel` + * (via the `[title]` input), so it can't be field-tagged there. This component + * instead wraps the whole section in a `
` carrying the `title` field's VALID + * 4-part `data-cslp` (`[csEditable]` → `title`), renders its own `

` heading, + * and passes an empty title to `cx-carousel` to avoid a duplicate heading. + * + * The section-wrapper tag (not the coarse entry-level host tag, which the + * ComponentDecorator deliberately skips for `` hosts) is what + * makes the section selectable/openable in Visual Builder WITHOUT the "Invalid + * CSLP tag" error — a single valid field tag, no conflicting bare 3-part tag. + * + * The products themselves are unchanged: each SKU in `productCodes` is hydrated + * live from SAP via Spartacus's `ProductService` (same `LIST`+`STOCK` scopes as + * the stock component) and rendered with the stock `cx-product-carousel-item`, so + * price/stock/name/image and add-to-cart all keep coming from SAP OCC. + * + * Registered by {@link ContentstackEditableComponentsModule} (on by default via + * {@link ContentstackCmsFeatureModule}). The edit tag is inert outside preview + * builds (no `$` ⇒ CsEditableDirective removes the attribute). + */ +@Component({ + selector: 'cs-editable-product-carousel', + standalone: true, + imports: [CommonModule, CarouselModule, ProductCarouselModule, CsEditableDirective], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + + + + + `, +}) +export class ContentstackEditableProductCarouselComponent { + protected readonly componentData: CmsComponentData = + inject(CmsComponentData); + protected readonly productService = inject(ProductService); + + readonly data$: Observable = this.componentData.data$; + + /** + * One live SAP product stream per SKU — preserves the connector's hydration: + * `productCodes` (space-separated) → `ProductService.get(code, [LIST, STOCK])`, + * exactly the scopes the stock ProductCarouselComponent uses. + */ + readonly items$: Observable[]> = this.data$.pipe( + map((data) => + (data.productCodes ?? '') + .trim() + .split(/\s+/) + .filter(Boolean) + .map((code) => this.productService.get(code, [ProductScope.LIST, ProductScope.STOCK])), + ), + ); +} diff --git a/src/cms/converters/contentstack-cms-component.normalizer.spec.ts b/src/cms/converters/contentstack-cms-component.normalizer.spec.ts index 74f2827..414eca4 100644 --- a/src/cms/converters/contentstack-cms-component.normalizer.spec.ts +++ b/src/cms/converters/contentstack-cms-component.normalizer.spec.ts @@ -4,14 +4,23 @@ import { ContentstackCmsNavigationComponentNormalizer } from './components/conte import { ContentstackCmsProductCarouselComponentNormalizer } from './components/contentstack-cms-product-carousel-component.normalizer'; import { ContentstackFieldMapper } from './contentstack-field-mapper'; import { ContentstackEntry } from '../model/contentstack.model'; +import { ContentstackConfig } from '../../config/contentstack-config'; -describe('ContentstackCmsComponentNormalizer', () => { - const normalizer = new ContentstackCmsComponentNormalizer( +/** Build a normalizer whose config reports live preview on/off. */ +function makeNormalizer(livePreview: boolean): ContentstackCmsComponentNormalizer { + return new ContentstackCmsComponentNormalizer( new ContentstackCmsBannerComponentNormalizer(), new ContentstackCmsNavigationComponentNormalizer(), new ContentstackCmsProductCarouselComponentNormalizer(), new ContentstackFieldMapper(), + { contentstack: { delivery: { livePreview } } } as ContentstackConfig, ); +} + +describe('ContentstackCmsComponentNormalizer', () => { + // Default instance runs with live preview ON, matching the preview builds + // where the `$` field-tag map is produced and consumed. + const normalizer = makeNormalizer(true); it('maps a standalone entry to a CmsComponent with typeCode from content type', () => { const entry: ContentstackEntry = { @@ -52,6 +61,69 @@ describe('ContentstackCmsComponentNormalizer', () => { expect(component.modifiedTime).toBeUndefined(); }); + it('preserves the Live Preview field-tag map ($) so editable components can bind data-cslp', () => { + const component = normalizer.convert({ + uid: 'blt1', + _content_type_uid: 'cms_paragraph_component', + content: '

Hi

', + $: { content: { 'data-cslp': 'cms_paragraph_component.blt1.en-us.content' } }, + } as any); + expect((component as any).$?.content?.['data-cslp']).toBe( + 'cms_paragraph_component.blt1.en-us.content', + ); + }); + + it('omits $ entirely when the entry was not tagged (non-preview builds unchanged)', () => { + const component = normalizer.convert({ + uid: 'blt1', + _content_type_uid: 'cms_paragraph_component', + content: '

Hi

', + }); + expect('$' in (component as any)).toBe(false); + }); + + it('does NOT propagate $ when live preview is off, even if the entry carries tags', () => { + // Defense-in-depth: a stray `$` must never leak into a production delivery + // build (would emit `data-cslp` on the live site otherwise). + const deliveryNormalizer = makeNormalizer(false); + const component = deliveryNormalizer.convert({ + uid: 'blt1', + _content_type_uid: 'cms_paragraph_component', + content: '

Hi

', + $: { content: { 'data-cslp': 'cms_paragraph_component.blt1.en-us.content' } }, + } as any); + expect('$' in (component as any)).toBe(false); + }); + + it('does NOT leak $ through the passthrough mapper for a custom/unmapped type (preview off)', () => { + // The default field mapper returns ALL remaining fields, so `$` must be + // stripped BEFORE mapping — otherwise it would ride through for custom types + // even when live preview is off (the gate on the explicit spread alone would + // not catch it). + const deliveryNormalizer = makeNormalizer(false); + const component = deliveryNormalizer.convert({ + uid: 'blt_widget', + _content_type_uid: 'my_custom_widget', + headline: 'Hi', + $: { headline: { 'data-cslp': 'my_custom_widget.blt_widget.en-us.headline' } }, + } as any); + expect(component.typeCode).toBe('my_custom_widget'); + expect((component as any).headline).toBe('Hi'); + expect('$' in (component as any)).toBe(false); + }); + + it('keeps $ for a custom/unmapped type when live preview is on (mapper does not clobber it)', () => { + const component = normalizer.convert({ + uid: 'blt_widget', + _content_type_uid: 'my_custom_widget', + headline: 'Hi', + $: { headline: { 'data-cslp': 'my_custom_widget.blt_widget.en-us.headline' } }, + } as any); + expect((component as any).$?.headline?.['data-cslp']).toBe( + 'my_custom_widget.blt_widget.en-us.headline', + ); + }); + it('merges onto a provided target rather than replacing it', () => { const target = { container: true } as any; const component = normalizer.convert( diff --git a/src/cms/converters/contentstack-cms-component.normalizer.ts b/src/cms/converters/contentstack-cms-component.normalizer.ts index c5d63e7..ba03bd4 100644 --- a/src/cms/converters/contentstack-cms-component.normalizer.ts +++ b/src/cms/converters/contentstack-cms-component.normalizer.ts @@ -1,5 +1,6 @@ import { Injectable } from '@angular/core'; import { CmsComponent, Converter } from '@spartacus/core'; +import { ContentstackConfig } from '../../config/contentstack-config'; import { ContentstackEntry } from '../model/contentstack.model'; import { toTypeCode } from '../model/slot-maps'; import { ContentstackFieldMapper } from './contentstack-field-mapper'; @@ -45,6 +46,7 @@ export class ContentstackCmsComponentNormalizer implements Converter< protected navigationNormalizer: ContentstackCmsNavigationComponentNormalizer, protected productCarouselNormalizer: ContentstackCmsProductCarouselComponentNormalizer, protected fieldMapper: ContentstackFieldMapper, + protected config: ContentstackConfig, ) {} convert(source: ContentstackEntry, target: CmsComponent = {}): CmsComponent { @@ -56,8 +58,14 @@ export class ContentstackCmsComponentNormalizer implements Converter< updated_at, publish_details, locale, + // Pull the Live Preview field-tag map OUT of `fields` so it can never ride + // through the field mapper. The default (passthrough) mapper returns all + // remaining fields, so leaving `$` in `fields` would leak it into custom / + // unmapped components even in a non-preview build — bypassing the gate + // below. We add it back explicitly (and only when live preview is on). + $: previewTags, ...fields - } = source; + } = source as ContentstackEntry & { $?: Record }; const typeCode = toTypeCode(_content_type_uid); const component = { @@ -70,6 +78,13 @@ export class ContentstackCmsComponentNormalizer implements Converter< // Contentstack field uids must be lowercase, so this mapping is required // — a raw passthrough leaves e.g. links without a visible label. ...this.fieldMapper.map(typeCode, fields), + // Re-add the Live Preview field-tag map (`entry.$`, from tagEntryTree) so + // connector-provided editable components can bind a per-field `data-cslp` + // via CsEditableDirective. Gated on the live-preview flag as + // defense-in-depth: even if a `$` map is attached outside a preview build, + // it is NOT propagated here, so a normal production delivery emits no + // `data-cslp` and is byte-for-byte unaffected. + ...(this.config.contentstack?.delivery?.livePreview && previewTags ? { $: previewTags } : {}), } as CmsComponent; if (BANNER_TYPE_CODES.has(typeCode)) { diff --git a/src/cms/converters/contentstack-cms-page.normalizer.spec.ts b/src/cms/converters/contentstack-cms-page.normalizer.spec.ts index 3b5c7b8..eb1e5be 100644 --- a/src/cms/converters/contentstack-cms-page.normalizer.spec.ts +++ b/src/cms/converters/contentstack-cms-page.normalizer.spec.ts @@ -27,6 +27,8 @@ describe('ContentstackCmsPageNormalizer', () => { new ContentstackCmsNavigationComponentNormalizer(), new ContentstackCmsProductCarouselComponentNormalizer(), new ContentstackFieldMapper(), + // Live preview ON: the page path is the preview path that produces `$`. + { contentstack: { delivery: { livePreview: true } } } as typeof config, ); const typeRegistry = new ContentstackComponentTypeRegistry(); const normalizer = new ContentstackCmsPageNormalizer( diff --git a/src/contentstack-cms-feature.module.ts b/src/contentstack-cms-feature.module.ts index 3913378..085c1ee 100644 --- a/src/contentstack-cms-feature.module.ts +++ b/src/contentstack-cms-feature.module.ts @@ -4,6 +4,7 @@ import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { ContentstackCmsModule } from './cms/contentstack-cms.module'; import { ContentstackLivePreviewModule } from './live-preview/contentstack-live-preview.module'; +import { ContentstackEditableComponentsModule } from './cms/components/contentstack-editable-components.module'; import { ContentstackConfig } from './config/contentstack-config'; import { defaultContentstackConfig } from './config/default-contentstack-config'; import { @@ -29,8 +30,14 @@ import { * it stays inert on normal delivery builds and activates on preview builds. * 3. Registers {@link defaultContentstackConfig} as default config, so the app * only needs to supply credentials via its own `provideConfig(...)`. + * 4. Imports {@link ContentstackEditableComponentsModule} — registers + * connector-owned editable renderers for seeded starter-pack component types + * (currently `CMSParagraphComponent`) that emit a **field-level** `data-cslp` + * tag so Contentstack Visual Builder can inline-edit them. They render + * identically to the stock Spartacus components and the edit tags are inert + * outside preview builds, so this is transparent on normal delivery builds. * - * (Both submodules were previously behind a lazy `CmsConfig.featureModules` + * (Both CMS/Live-Preview submodules were previously behind a lazy `CmsConfig.featureModules` * entry — the standard Spartacus code-splitting convention — but that gate only * fires when a `cmsComponents` component tagged with the feature renders, which * this connector never registers. So the entry never loaded and neither the CMS @@ -40,8 +47,10 @@ import { * - It does not import Spartacus's `SmartEditRootModule`. That omission is the * primary SmartEdit bypass (no handshake APP_INITIALIZER, no CmsTicket * interceptor) — see `guards/contentstack-smartedit-bypass.ts`. - * - It does not register any `cmsComponents` mappings. Mapping Contentstack - * block/content types to Angular components is app-specific; follow the + * - It registers `cmsComponents` mappings only for the connector-owned editable + * renderers of seeded starter-pack types (via + * {@link ContentstackEditableComponentsModule}). Mapping your OWN + * block/content types to Angular components stays app-specific; follow the * pattern in the `examples/hero-banner` module. * * IMPORTANT — import ordering: import `ContentstackCmsFeatureModule` *after* the @@ -50,7 +59,11 @@ import { * `SpartacusFeaturesModule` (or after `StorefrontModule`) satisfies this. */ @NgModule({ - imports: [ContentstackCmsModule, ContentstackLivePreviewModule], + imports: [ + ContentstackCmsModule, + ContentstackLivePreviewModule, + ContentstackEditableComponentsModule, + ], providers: [ provideDefaultConfig(defaultContentstackConfig), // Bind the typed config accessor to Spartacus's merged global Config, so diff --git a/src/live-preview/contentstack-component.decorator.ts b/src/live-preview/contentstack-component.decorator.ts index 6633892..8ed4fa9 100644 --- a/src/live-preview/contentstack-component.decorator.ts +++ b/src/live-preview/contentstack-component.decorator.ts @@ -20,8 +20,23 @@ export class ContentstackComponentDecorator extends ComponentDecorator { if (!component) { return; } + // Do NOT stamp the coarse tag on our own editable renderers (``). + // That coarse tag is a bare 3-part `content_type.entry.locale` string, which is + // NOT a valid Contentstack CSLP tag — the SDK only ever emits field-scoped + // (4-part) tags. On an editable renderer, Visual Builder therefore reports + // "Invalid CSLP tag" for the host. These renderers instead carry a VALID + // field-level tag on their section wrapper (via CsEditableDirective), which + // both clears the error and provides the open-the-entry affordance. + if (this.isEditableRenderer(element)) { + return; + } if (!this.contentstackLivePreviewService.hasEditableTags(element)) { this.contentstackLivePreviewService.addInspectorModeTags(element, renderer, component); } } + + /** True for the connector's editable renderer hosts (``). */ + protected isEditableRenderer(element: Element): boolean { + return element.tagName.toLowerCase().startsWith('cs-editable-'); + } } diff --git a/src/live-preview/contentstack-live-preview.service.ts b/src/live-preview/contentstack-live-preview.service.ts index 03569d6..7da0f3e 100644 --- a/src/live-preview/contentstack-live-preview.service.ts +++ b/src/live-preview/contentstack-live-preview.service.ts @@ -53,6 +53,15 @@ export class ContentstackLivePreviewService { if (!this.config.contentstack?.delivery?.livePreview) { return; } + // Contentstack's Live Preview SDK injects one global inspector tooltip + // (`#cslp-tooltip`) and does NOT reset it on Angular's client-side route + // changes. After visiting a page with editable content, that tooltip can + // linger on the next page — still pointing at the now-unmounted entry — even + // on an OCC-only page with no Contentstack islands (e.g. a category/PLP + // page), showing a stray "Edit" button that opens the wrong entry. Hide it + // on every navigation so it only reappears when the shopper actually hovers + // a tagged element on the current page. + this.routingService.getPageContext().subscribe(() => this.resetInspectorTooltip()); // Track the active language for the whole session. The Visual Builder SDK // is initialized ONCE (it does not re-init cleanly, so the VB *session* is // single-locale), but the storefront's on-page edit tags must still follow @@ -131,6 +140,24 @@ export class ContentstackLivePreviewService { }); } + /** + * Hide Contentstack's global Live Preview inspector tooltip (`#cslp-tooltip`) + * by parking it offscreen. The SDK repositions it via inline `top`/`left` when + * the shopper hovers a genuinely tagged element on the current page, so this + * only clears a stale target carried over from a previous route — it does not + * disable editing. Browser-only; no-op under SSR. + */ + protected resetInspectorTooltip(): void { + if (typeof document === 'undefined') { + return; + } + const tooltip = document.getElementById('cslp-tooltip'); + if (tooltip) { + tooltip.style.top = '-9999px'; + tooltip.style.left = '-9999px'; + } + } + /** * Override the default "re-fetch current page → dispatch components" behavior * with a custom handler (rarely needed). Contentstack's `onEntryChange` has diff --git a/src/public-api.ts b/src/public-api.ts index 10fb196..2038790 100644 --- a/src/public-api.ts +++ b/src/public-api.ts @@ -35,6 +35,12 @@ export * from './cms/model/slot-maps'; export * from './cms/access/contentstack-current-user'; export * from './cms/access/contentstack-restrictions.service'; +// Editable renderers (field-level Live Preview / Visual Builder tags) +export * from './cms/components/contentstack-editable-paragraph.component'; +export * from './cms/components/contentstack-editable-banner.component'; +export * from './cms/components/contentstack-editable-product-carousel.component'; +export * from './cms/components/contentstack-editable-components.module'; + // SmartEdit bypass export * from './guards/contentstack-smartedit-bypass'; diff --git a/typings/spartacus.d.ts b/typings/spartacus.d.ts index ca7fc38..4dd05ae 100644 --- a/typings/spartacus.d.ts +++ b/typings/spartacus.d.ts @@ -272,6 +272,18 @@ declare module '@spartacus/storefront' { uid: string; data$: Observable; } + + // Rendering building blocks reused by the connector's editable banner + // renderer (real exports of @spartacus/storefront; declared here only so the + // offline typecheck resolves the imports — tsc does not check the templates + // that actually use `cx-media` / `cx-generic-link`). + export class MediaModule {} + export class GenericLinkModule {} + export class MediaComponent {} + export class CarouselModule {} + export class CarouselComponent {} + export class ProductCarouselModule {} + export class ProductCarouselItemComponent {} } declare module '@spartacus/cart/base/root' {