Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/cms/components/contentstack-editable-banner.component.ts
Original file line number Diff line number Diff line change
@@ -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<string, { 'data-cslp'?: string } | undefined>;
}

/**
* 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: `
<div *ngIf="data$ | async as data" [csEditable]="data.$?.['url_link']">
<cx-generic-link *ngIf="data.urlLink; else plain" [url]="data.urlLink">
<cx-media [container]="$any(data.media)" [elementType]="'picture'"></cx-media>
</cx-generic-link>
<ng-template #plain>
<cx-media [container]="$any(data.media)" [elementType]="'picture'"></cx-media>
</ng-template>
</div>
`,
})
export class ContentstackEditableBannerComponent {
protected readonly componentData: CmsComponentData<ContentstackEditableBannerData> =
inject(CmsComponentData);

/** Banner data stream from Contentstack (via the CMS store). */
readonly data$: Observable<ContentstackEditableBannerData> = this.componentData.data$;
}
45 changes: 45 additions & 0 deletions src/cms/components/contentstack-editable-components.module.ts
Original file line number Diff line number Diff line change
@@ -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,
Comment on lines +32 to +36
},
ProductCarouselComponent: {
component: ContentstackEditableProductCarouselComponent,
},
},
} as CmsConfig),
],
})
export class ContentstackEditableComponentsModule {}
59 changes: 59 additions & 0 deletions src/cms/components/contentstack-editable-paragraph.component.ts
Original file line number Diff line number Diff line change
@@ -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<string, { 'data-cslp'?: string } | undefined>;
}

/**
* 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: `
<div
*ngIf="data$ | async as data"
class="cx-paragraph"
[csEditable]="data.$?.['content']"
[innerHTML]="data.content"
></div>
Comment on lines +44 to +50
`,
})
export class ContentstackEditableParagraphComponent {
protected readonly componentData: CmsComponentData<ContentstackEditableParagraphData> =
inject(CmsComponentData);

/** Paragraph content stream from Contentstack (via the CMS store). */
readonly data$: Observable<ContentstackEditableParagraphData> = this.componentData.data$;
}
Original file line number Diff line number Diff line change
@@ -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<string, { 'data-cslp'?: string } | undefined>;
}

/**
* 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 `<div>` carrying the `title` field's VALID
* 4-part `data-cslp` (`[csEditable]` → `title`), renders its own `<h3>` 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 `<cs-editable-*>` 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: `
<div *ngIf="data$ | async as data" class="cs-editable-carousel" [csEditable]="data.$?.['title']">
<h3 *ngIf="data.title" class="cs-editable-carousel-title">
{{ data.title }}
</h3>
<cx-carousel
*ngIf="items$ | async as items"
[items]="items"
[template]="carouselItem"
itemWidth="285px"
[title]="''"
></cx-carousel>
</div>

<ng-template #carouselItem let-item="item" let-itemIndex="itemIndex">
<cx-product-carousel-item [item]="item" [itemIndex]="itemIndex"></cx-product-carousel-item>
</ng-template>
`,
})
export class ContentstackEditableProductCarouselComponent {
protected readonly componentData: CmsComponentData<ContentstackEditableProductCarouselData> =
inject(CmsComponentData);
protected readonly productService = inject(ProductService);

readonly data$: Observable<ContentstackEditableProductCarouselData> = 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<Observable<Product | undefined>[]> = this.data$.pipe(
map((data) =>
(data.productCodes ?? '')
.trim()
Comment on lines +87 to +90
.split(/\s+/)
.filter(Boolean)
.map((code) => this.productService.get(code, [ProductScope.LIST, ProductScope.STOCK])),
),
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<p>Hi</p>',
$: { 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: '<p>Hi</p>',
});
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(
Expand Down
6 changes: 6 additions & 0 deletions src/cms/converters/contentstack-cms-component.normalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
21 changes: 17 additions & 4 deletions src/contentstack-cms-feature.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
15 changes: 15 additions & 0 deletions src/live-preview/contentstack-component.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,23 @@ export class ContentstackComponentDecorator extends ComponentDecorator {
if (!component) {
return;
}
// Do NOT stamp the coarse tag on our own editable renderers (`<cs-editable-*>`).
// 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 (`<cs-editable-*>`). */
protected isEditableRenderer(element: Element): boolean {
return element.tagName.toLowerCase().startsWith('cs-editable-');
}
}
6 changes: 6 additions & 0 deletions src/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Loading
Loading