-
Notifications
You must be signed in to change notification settings - Fork 0
feat: opt-in editable paragraph component (fix Visual Builder 'Invalid CSLP tag') #18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
7eca4b7
feat: opt-in editable paragraph component for Visual Builder field ed…
vaishnavi-kedar-cstk e7d8548
feat: enable editable components by default via the feature module
vaishnavi-kedar-cstk 288c3de
feat: editable banner renderer (field-level media tag for Visual Buil…
vaishnavi-kedar-cstk 403679f
feat: editable product-carousel renderer (title tag, SAP hydration pr…
vaishnavi-kedar-cstk 60d2932
fix: put banner media edit tag on a standard element, not cx-media
vaishnavi-kedar-cstk c931ebe
fix(banner): tag url_link (text) instead of media (file) for Visual B…
vaishnavi-kedar-cstk 8d6ba7a
fix(vb): suppress entry-level host tag on editable renderers
vaishnavi-kedar-cstk 699efe1
fix(vb): valid field tag on section wrapper, no invalid 3-part host tag
vaishnavi-kedar-cstk 8342112
fix(vb): reset lingering live-preview inspector tooltip on navigation
vaishnavi-kedar-cstk bf6804c
fix(vb): address Copilot review — gate $ on preview, tag adapter fetc…
vaishnavi-kedar-cstk 8b74415
style: apply prettier formatting to fix format:check CI
vaishnavi-kedar-cstk 26d30b8
fix(vb): strip $ before field mapper so preview tags never leak for c…
vaishnavi-kedar-cstk File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
62 changes: 62 additions & 0 deletions
62
src/cms/components/contentstack-editable-banner.component.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
45
src/cms/components/contentstack-editable-components.module.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }, | ||
| ProductCarouselComponent: { | ||
| component: ContentstackEditableProductCarouselComponent, | ||
| }, | ||
| }, | ||
| } as CmsConfig), | ||
| ], | ||
| }) | ||
| export class ContentstackEditableComponentsModule {} | ||
50 changes: 50 additions & 0 deletions
50
src/cms/components/contentstack-editable-paragraph.component.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
59
src/cms/components/contentstack-editable-paragraph.component.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
|
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$; | ||
| } | ||
100 changes: 100 additions & 0 deletions
100
src/cms/components/contentstack-editable-product-carousel.component.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | ||
|
Copilot marked this conversation as resolved.
|
||
| .split(/\s+/) | ||
| .filter(Boolean) | ||
| .map((code) => this.productService.get(code, [ProductScope.LIST, ProductScope.STOCK])), | ||
| ), | ||
| ); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.