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.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..a6acb27 --- /dev/null +++ b/src/cms/components/contentstack-editable-product-carousel.component.ts @@ -0,0 +1,96 @@ +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..c8cc2f3 100644 --- a/src/cms/converters/contentstack-cms-component.normalizer.spec.ts +++ b/src/cms/converters/contentstack-cms-component.normalizer.spec.ts @@ -52,6 +52,27 @@ 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('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..3472722 100644 --- a/src/cms/converters/contentstack-cms-component.normalizer.ts +++ b/src/cms/converters/contentstack-cms-component.normalizer.ts @@ -70,6 +70,12 @@ 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), + // Preserve the Live Preview field-tag map (`entry.$`, added by + // tagEntryTree when livePreview is on) so connector-provided editable + // components can bind a per-field `data-cslp` via CsEditableDirective. + // Only present on preview builds; absent (and omitted) otherwise, so stock + // components and production delivery are byte-for-byte unaffected. + ...(source['$'] ? { $: source['$'] } : {}), } as CmsComponent; if (BANNER_TYPE_CODES.has(typeCode)) { 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/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' {