Skip to content
Merged
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
22 changes: 20 additions & 2 deletions src/client/contentstack-client.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,11 @@
private _stack?: Stack;

constructor(
protected config: ContentstackConfig,

Check warning on line 57 in src/client/contentstack-client.service.ts

View workflow job for this annotation

GitHub Actions / Build & verify

Prefer using the inject() function over constructor parameter injection. Use Angular's migration schematic to automatically refactor: ng generate @angular/core:inject
protected transferState: TransferState,

Check warning on line 58 in src/client/contentstack-client.service.ts

View workflow job for this annotation

GitHub Actions / Build & verify

Prefer using the inject() function over constructor parameter injection. Use Angular's migration schematic to automatically refactor: ng generate @angular/core:inject
protected logger: LoggerService,

Check warning on line 59 in src/client/contentstack-client.service.ts

View workflow job for this annotation

GitHub Actions / Build & verify

Prefer using the inject() function over constructor parameter injection. Use Angular's migration schematic to automatically refactor: ng generate @angular/core:inject
protected restrictions: ContentstackRestrictionsService,

Check warning on line 60 in src/client/contentstack-client.service.ts

View workflow job for this annotation

GitHub Actions / Build & verify

Prefer using the inject() function over constructor parameter injection. Use Angular's migration schematic to automatically refactor: ng generate @angular/core:inject
@Inject(PLATFORM_ID) protected platformId: object,

Check warning on line 61 in src/client/contentstack-client.service.ts

View workflow job for this annotation

GitHub Actions / Build & verify

Prefer using the inject() function over constructor parameter injection. Use Angular's migration schematic to automatically refactor: ng generate @angular/core:inject
) {}

/** Lazily build (and memoize) the Delivery SDK stack from config. */
Expand Down Expand Up @@ -278,7 +278,10 @@
* `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');
}

Expand Down Expand Up @@ -313,6 +316,14 @@
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;
});
});
Expand Down Expand Up @@ -353,11 +364,18 @@
// 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
Expand Down
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 thread
Copilot marked this conversation as resolved.
},
ProductCarouselComponent: {
component: ContentstackEditableProductCarouselComponent,
},
},
} as CmsConfig),
],
})
export class ContentstackEditableComponentsModule {}
Original file line number Diff line number Diff line change
@@ -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<ComponentFixture<ContentstackEditableParagraphComponent>> {
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: '<p>Hello</p>',
$: { 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('<p>Hello</p>');
});

it('renders no data-cslp attribute when the entry is untagged (delivery)', async () => {
const fixture = await render({ content: '<p>Hello</p>' });
const el = fixture.nativeElement.querySelector('.cx-paragraph') as HTMLElement;
expect(el).toBeTruthy();
expect(el.hasAttribute('data-cslp')).toBe(false);
expect(el.innerHTML).toContain('<p>Hello</p>');
});
});
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 thread
vaishnavi-kedar-cstk marked this conversation as resolved.
`,
})
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,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<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 thread
Copilot marked this conversation as resolved.
.split(/\s+/)
.filter(Boolean)
.map((code) => this.productService.get(code, [ProductScope.LIST, ProductScope.STOCK])),
),
);
}
Loading
Loading