diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index db32155c11..33a6089a8d 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -152,7 +152,8 @@ export { withSitecore, useSitecore, withEditorChromes, - withPlaceholder, + withAppPlaceholder, + withClientPlaceholder, withDatasourceCheck, ImageSizeParameters, WithSitecoreOptions, diff --git a/packages/react/api/content-sdk-react.api.md b/packages/react/api/content-sdk-react.api.md index a37b4d7f49..7785127c95 100644 --- a/packages/react/api/content-sdk-react.api.md +++ b/packages/react/api/content-sdk-react.api.md @@ -59,17 +59,14 @@ import { SitePathService } from '@sitecore-content-sdk/content/site'; import { SitePathServiceConfig } from '@sitecore-content-sdk/content/site'; // @public -export const AppPlaceholder: (props: AppPlaceholderProps) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | React_2.JSX.Element | (string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | React_2.JSX.Element | null | undefined)[] | null | undefined; +export const AppPlaceholder: (props: AppPlaceholderProps) => React_2.JSX.Element; // Warning: (ae-forgotten-export) The symbol "BasePlaceholderProps" needs to be exported by the entry point api-surface.d.ts // // @public -export interface AppPlaceholderProps extends BasePlaceholderProps { - componentMap: ComponentMap; - // Warning: (ae-forgotten-export) The symbol "AppComponentProps" needs to be exported by the entry point api-surface.d.ts - modifyComponentProps?: (componentProps: AppComponentProps) => AppComponentProps; +export type AppPlaceholderProps = Omit & Required> & { render?: (components: React.ReactNode[], data: ComponentRendering[], props: AppPlaceholderProps) => React.ReactNode; -} +}; // @public export class BYOCComponent extends React_2.Component { @@ -179,7 +176,7 @@ export { DictionaryPhrases } export { DictionaryService } // @public -export const EditingScripts: () => JSX_2.Element; +export const EditingScripts: () => React_2.JSX.Element; export { EditMode } @@ -375,17 +372,12 @@ export { Page } export { PageMode } // @public -export const Placeholder: (props: EnhancedOmit) => React_2.JSX.Element; +export const Placeholder: (props: PlaceholderProps) => React_2.JSX.Element; // @public -interface PlaceholderProps extends BasePlaceholderProps { - // (undocumented) - [key: string]: unknown; - componentMap?: ComponentMap; - // Warning: (ae-forgotten-export) The symbol "ComponentProps" needs to be exported by the entry point api-surface.d.ts - modifyComponentProps?: (componentProps: ComponentProps) => ComponentProps; +type PlaceholderProps = BasePlaceholderProps & { render?: (components: React.ReactNode[], data: ComponentRendering[], props: PlaceholderProps) => React.ReactNode; -} +}; export { PlaceholderProps as PlaceholderComponentProps } export { PlaceholderProps } @@ -430,19 +422,11 @@ export { RouteData } // @public export type SearchStatus = 'idle' | 'loading' | 'success' | 'error'; -// Warning: (ae-forgotten-export) The symbol "SitecoreProviderProps" needs to be exported by the entry point api-surface.d.ts -// // @public -export class SitecoreProvider extends React_2.Component { - constructor(props: SitecoreProviderProps); - // (undocumented) - componentDidUpdate(prevProps: SitecoreProviderProps): void; - // (undocumented) - static displayName: string; - // (undocumented) - render(): React_2.JSX.Element; - setPage: (value: Page) => void; -} +export const SitecoreProvider: { + (props: SitecoreProviderProps): React_2.JSX.Element; + displayName: string; +}; // @public export const SitecoreProviderReactContext: React_2.Context; @@ -451,7 +435,7 @@ export const SitecoreProviderReactContext: React_2.Context void; + setPage?: (value: Page) => void; } export { SitePathService } @@ -470,6 +454,9 @@ export interface TextField extends FieldMetadata { value?: string | number; } +// @public +export function useComponentMap(): ComponentMap; + // @public export const useInfiniteSearch: (options: UseInfiniteSearchOptions) => UseInfiniteSearchState; @@ -519,8 +506,17 @@ export type UseSearchState = Omit(Component: ComponentType) => (props: W) => React_2.JSX.Element; + +// Warning: (ae-forgotten-export) The symbol "ComponentProps_2" needs to be exported by the entry point api-surface.d.ts +// Warning: (ae-forgotten-export) The symbol "WrapperProps_2" needs to be exported by the entry point api-surface.d.ts +// +// @public +export const withClientPlaceholder: (Component: ComponentType) => (props: W) => React_2.JSX.Element; // Warning: (ae-forgotten-export) The symbol "WithDatasourceCheckOptions" needs to be exported by the entry point api-surface.d.ts // Warning: (ae-forgotten-export) The symbol "WithDatasourceCheckProps" needs to be exported by the entry point api-surface.d.ts @@ -529,7 +525,10 @@ export function useSitecore(options?: WithSitecoreOptions): WithSitecoreProps; export function withDatasourceCheck(options?: WithDatasourceCheckOptions): (Component: React_2.ComponentType) => (props: ComponentProps) => JSX_2.Element | null; // @public -export const withEditorChromes: (WrappedComponent: React_2.ComponentClass | React_2.FC) => React_2.ComponentClass; +export const withEditorChromes: (WrappedComponent: React_2.ComponentClass | React_2.FC) => { + (props: Record): React_2.JSX.Element; + displayName: string; +}; // Warning: (ae-forgotten-export) The symbol "WithEmptyFieldEditingComponentProps" needs to be exported by the entry point api-surface.d.ts // Warning: (ae-forgotten-export) The symbol "WithEmptyFieldEditingComponentOptions" needs to be exported by the entry point api-surface.d.ts @@ -542,33 +541,10 @@ export function withEmptyFieldEditingComponent(FieldComponent: ComponentType, isForwardRef?: boolean): React_2.ForwardRefExoticComponent & React_2.RefAttributes> | ((props: FieldComponentProps) => React_2.JSX.Element); -// Warning: (ae-forgotten-export) The symbol "WithPlaceholderSpec" needs to be exported by the entry point api-surface.d.ts -// Warning: (ae-forgotten-export) The symbol "WithPlaceholderOptions" needs to be exported by the entry point api-surface.d.ts -// -// @public -export function withPlaceholder(placeholders: WithPlaceholderSpec, options?: WithPlaceholderOptions): (WrappedComponent: React_2.ComponentClass | React_2.FunctionComponent) => (props: EnhancedOmit) => React_2.JSX.Element; - -// @public (undocumented) -export function withSitecore(options?: WithSitecoreOptions): (Component: React_2.ComponentType) => (props: WithSitecoreHocProps) => React_2.JSX.Element; - -// @public -export type WithSitecoreHocProps = EnhancedOmit; - -// @public -export interface WithSitecoreOptions { - updatable?: boolean; -} - -// @public -export interface WithSitecoreProps { - api?: SitecoreProviderState['api']; - page: Page; - updatePage?: ((value: Page) => void) | false; -} - // Warnings were encountered during analysis: // // src/components/FEaaS/models.ts:96:3 - (ae-forgotten-export) The symbol "RevisionType" needs to be exported by the entry point api-surface.d.ts +// src/components/SitecoreProvider.tsx:88:30 - (ae-forgotten-export) The symbol "SitecoreProviderProps" needs to be exported by the entry point api-surface.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/react/src/components/Date.tsx b/packages/react/src/components/Date.tsx index 588800cf76..b86307c39a 100644 --- a/packages/react/src/components/Date.tsx +++ b/packages/react/src/components/Date.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React from 'react'; import { withFieldMetadata } from '../enhancers/withFieldMetadata'; import { withEmptyFieldEditingComponent } from '../enhancers/withEmptyFieldEditingComponent'; import { DefaultEmptyFieldEditingComponentText } from './DefaultEmptyFieldEditingComponents'; @@ -51,7 +51,8 @@ export const DateField: React.FC = withFieldMetadata{children}; } else { return {children}; } diff --git a/packages/react/src/components/DefaultEmptyFieldEditingComponents.tsx b/packages/react/src/components/DefaultEmptyFieldEditingComponents.tsx index dd63315cea..72b9557c2c 100644 --- a/packages/react/src/components/DefaultEmptyFieldEditingComponents.tsx +++ b/packages/react/src/components/DefaultEmptyFieldEditingComponents.tsx @@ -9,10 +9,11 @@ export const DefaultEmptyFieldEditingComponentText: React.FC<{ [key: string]: unknown; tag?: string; }> = (props) => { - return React.createElement( - props.tag || 'span', - { ...props, suppressHydrationWarning: true }, - '[No text in field]' + const Tag = (props.tag || 'span') as React.ElementType; + return ( + + [No text in field] + ); }; diff --git a/packages/react/src/components/DesignLibrary/DesignLibrary.tsx b/packages/react/src/components/DesignLibrary/DesignLibrary.tsx index 526f2db9f7..9f42986368 100644 --- a/packages/react/src/components/DesignLibrary/DesignLibrary.tsx +++ b/packages/react/src/components/DesignLibrary/DesignLibrary.tsx @@ -13,11 +13,10 @@ import { } from '@sitecore-content-sdk/content/editing'; import * as codegen from '@sitecore-content-sdk/content/codegen'; import * as editing from '@sitecore-content-sdk/content/editing'; -import { useSitecore } from '../../enhancers/withSitecore'; +import { useSitecore, useLoadImportMap } from '../../components/SitecoreProvider'; import { Placeholder, PlaceholderMetadata } from '../Placeholder'; import { DesignLibraryErrorBoundary } from './DesignLibraryErrorBoundary'; import { DynamicComponent } from './models'; -import { useLoadImportMap } from '../../enhancers/withLoadImportMap'; import { ErrorComponent } from '../ErrorBoundary'; let { @@ -45,8 +44,6 @@ export const __mockDependencies = (mocks: any) => { * when generation is enabled (`page.mode.designLibrary.isVariantGeneration === true`), * wires the **variant generation** handshake so the parent (DL Studio) can send * generated code to preview and iterate on. - * @param {DesignLibraryProps} props - * @param {() => Promise} [props.loadImportMap] Optional async loader that resolves to the import-map used to resolve the generated component’s imports. Required when `isVariantGeneration` is true. * @returns {JSX.Element} The preview surface, or `null` when not in Design Library mode. * @public */ @@ -156,7 +153,7 @@ export const DesignLibrary = () => { cancelled = true; unsubscribe && unsubscribe(); }; - }, [isVariantGeneration, uid]); + }, [isDesignLibrary, isVariantGeneration, uid, loadImportMap, propsState]); return (
diff --git a/packages/react/src/components/EditingScripts.tsx b/packages/react/src/components/EditingScripts.tsx index 5d2c38cab1..a13a075bf2 100644 --- a/packages/react/src/components/EditingScripts.tsx +++ b/packages/react/src/components/EditingScripts.tsx @@ -1,7 +1,10 @@ 'use client'; -import React, { JSX } from 'react'; -import { useSitecore } from '../enhancers/withSitecore'; -import { getContentSdkPagesClientData, getDesignLibraryScriptLink } from '@sitecore-content-sdk/content/editing'; +import React from 'react'; +import { useSitecore } from './SitecoreProvider'; +import { + getContentSdkPagesClientData, + getDesignLibraryScriptLink, +} from '@sitecore-content-sdk/content/editing'; /** * Renders client scripts and data for editing/preview mode for Pages. @@ -9,7 +12,7 @@ import { getContentSdkPagesClientData, getDesignLibraryScriptLink } from '@sitec * @returns A JSX element containing the editing scripts or an empty fragment if not in editing/preview mode. * @public */ -export const EditingScripts = (): JSX.Element => { +export const EditingScripts = () => { const { page: { mode, layout }, api, diff --git a/packages/react/src/components/ErrorBoundary.tsx b/packages/react/src/components/ErrorBoundary.tsx index 730e130e76..19ed522c8d 100644 --- a/packages/react/src/components/ErrorBoundary.tsx +++ b/packages/react/src/components/ErrorBoundary.tsx @@ -1,4 +1,4 @@ -'use client'; +'use client'; import React, { ReactNode, Suspense } from 'react'; import { Page } from '@sitecore-content-sdk/content/client'; import { ComponentRendering } from '@sitecore-content-sdk/content/layout'; diff --git a/packages/react/src/components/File.tsx b/packages/react/src/components/File.tsx index fd5491b138..dcb892d994 100644 --- a/packages/react/src/components/File.tsx +++ b/packages/react/src/components/File.tsx @@ -1,4 +1,4 @@ -import { isFieldValueEmpty } from '@sitecore-content-sdk/content/layout'; +import { isFieldValueEmpty } from '@sitecore-content-sdk/content/layout'; import React from 'react'; export interface FileFieldValue { @@ -48,7 +48,13 @@ export const File: React.FC = ({ field, children, ...otherProps }) => const anchorAttrs = { href: file.src, }; - return React.createElement('a', { ...anchorAttrs, ...otherProps }, linkText, children); + + return ( + + {linkText} + {children} + + ); }; File.displayName = 'File'; diff --git a/packages/react/src/components/Form.tsx b/packages/react/src/components/Form.tsx index dd028e7062..63a705963d 100644 --- a/packages/react/src/components/Form.tsx +++ b/packages/react/src/components/Form.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { ComponentRendering } from '@sitecore-content-sdk/content/layout'; import { form } from '@sitecore-content-sdk/content'; -import { useSitecore } from '../enhancers/withSitecore'; +import { useSitecore } from './SitecoreProvider'; import { ErrorComponent } from './ErrorBoundary'; let { executeScriptElements, loadForm, subscribeToFormSubmitEvent } = form; @@ -86,7 +86,14 @@ export const Form = ({ params, rendering }: FormProps) => { executeScriptElements(formRef.current); } - }, [content]); + }, [ + content, + isEditing, + params.FormId, + rendering.uid, + context.api?.edge?.clientContextId, + context.api?.edge?.edgeUrl, + ]); if (isEditing && error) { return ; diff --git a/packages/react/src/components/Link.tsx b/packages/react/src/components/Link.tsx index 2171644787..695cc0f770 100644 --- a/packages/react/src/components/Link.tsx +++ b/packages/react/src/components/Link.tsx @@ -1,4 +1,4 @@ -'use client'; +'use client'; import React, { RefAttributes, forwardRef } from 'react'; import { FieldMetadata, isFieldValueEmpty } from '@sitecore-content-sdk/content/layout'; import { withFieldMetadata } from '../enhancers/withFieldMetadata'; @@ -91,14 +91,12 @@ export const Link: React.FC = withFieldMetadata + {linkText} + {children} + ); - - return {element}; } ), { defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentText, isForwardRef: true } diff --git a/packages/react/src/components/Placeholder/AppPlaceholder.test.tsx b/packages/react/src/components/Placeholder/AppPlaceholder.test.tsx index 33822f5281..e32bc4472f 100644 --- a/packages/react/src/components/Placeholder/AppPlaceholder.test.tsx +++ b/packages/react/src/components/Placeholder/AppPlaceholder.test.tsx @@ -1308,6 +1308,52 @@ describe('App Placeholder logic', () => { // 4 placeholders in total, 8 code blocks expect(wrapper?.container.querySelectorAll('.scpm').length).to.equal(8); }); + + it('should use renderEach for each child in the placeholder when page editing is enabled', () => { + const page = getPage(); + const components = new Map(); + + page.mode.isEditing = true; + + components.set('Child', () => 'Child'); + + const route = { + name: 'Render Each Test', + placeholders: { + main: [ + { + componentName: 'Child', + }, + { + componentName: 'Child', + }, + ], + }, + }; + page.layout = { + sitecore: { + context: {}, + route, + }, + }; + const phKey = 'main'; + + const renderedComponent = render( + +
{children}
} + renderEach={(child) =>
{child}
} + /> +
+ ); + + expect(renderedComponent.container.querySelectorAll('.parentWrapper').length).to.equal(1); + expect(renderedComponent.container.querySelectorAll('.wrapper').length).to.equal(2); + }); }); }); diff --git a/packages/react/src/components/Placeholder/AppPlaceholder.tsx b/packages/react/src/components/Placeholder/AppPlaceholder.tsx index 079dde57a0..f1d539d018 100644 --- a/packages/react/src/components/Placeholder/AppPlaceholder.tsx +++ b/packages/react/src/components/Placeholder/AppPlaceholder.tsx @@ -1,13 +1,10 @@ -import { AppPlaceholderProps } from './models'; +import { AppPlaceholderProps, ChildComponentProps, ComponentForRendering } from './models'; import { - getAppComponentProps, - getComponentForRendering, + drawPlaceholderComponents, getPlaceholderRenderings, renderEmptyPlaceholder, } from './placeholder-utils'; import React from 'react'; -import { PlaceholderMetadata } from './PlaceholderMetadata'; -import { ComponentRendering } from '@sitecore-content-sdk/content/layout'; import ErrorBoundary from '../ErrorBoundary'; import { ClientComponentWrapper } from './ClientComponentWrapper'; import { rsc } from '#rsc-env'; @@ -21,124 +18,65 @@ import { rsc } from '#rsc-env'; * @public */ export const AppPlaceholder = (props: AppPlaceholderProps) => { - const { rendering: parentRendering, componentMap, page } = props; - const placeholderRenderings = getPlaceholderRenderings( - parentRendering, - props.name, - page.mode.isEditing - ); - - const components = placeholderRenderings - .map((rendering, index) => { - const { - component: Component, - isEmpty, - componentType, - dynamic, - } = getComponentForRendering( - rendering, - props.name, - componentMap, - props.hiddenRenderingComponent, - props.missingComponentComponent - ); - const isClient = componentType === 'client'; - const key = rendering.uid || `component-${index}`; - - // Use rsc context to determine the current runtime - const componentRuntime = rsc ? 'server' : 'client'; - - const renderedProps = getAppComponentProps(props, rendering); - - const finalRenderedProps = props.modifyComponentProps - ? props.modifyComponentProps(renderedProps) - : renderedProps; - - // Client wrapper is required only when component crosses boundary from server to client. - // It happens when component is marker as client and rendered in RSC context. - // Also, it is not required when component is hidden or empty, as it will be rendered whthout boundary crossing. - const useClientWrapper = isClient && rsc && !isEmpty; - let rendered = useClientWrapper ? ( - - ) : ( - - ); + const renderingData = props.rendering; + const isEditing = props.page.mode.isEditing; + const placeholderRenderings = getPlaceholderRenderings(renderingData, props.name, isEditing); - if (!isEmpty) { - const errorBoundaryKey = rendered.type + '-' + index; - const disableSuspense = props.disableSuspense || false; - rendered = ( - - {rendered} - - ); - } + const drawAppPlaceholderChildComponent = ( + componentForRendering: ComponentForRendering, + renderedProps: ChildComponentProps, + key?: string + ) => { + // Client wrapper is required only when component crosses boundary from server to client. + // It happens when component is marker as client and rendered in RSC context. + // Also, it is not required when component is hidden or empty, as it will be rendered whthout boundary crossing. + const useClientWrapper = + componentForRendering.componentType === 'client' && rsc && !componentForRendering.isEmpty; + return useClientWrapper ? ( + + ) : ( + + ); + }; - // if in edit mode then emit shallow chromes for hydration in Pages - if (page.mode.isEditing) { - const key = (rendering.uid as string) || `component-${index}`; - return ( - - {rendered} - - ); - } - return rendered; - }) - .filter((element) => element); + const applyConditionalTransform = (renderedComponents: React.JSX.Element[]) => { + const isEmpty = !placeholderRenderings.length; - const finalRendering = page.mode.isEditing - ? [ - - {components} - , - ] - : components; + if (isEmpty) { + const rendered = props.renderEmpty + ? props.renderEmpty(renderedComponents) + : renderedComponents; - const placeholderEmpty = !placeholderRenderings.length; + return props.page.mode.isEditing ? renderEmptyPlaceholder(rendered) : rendered; + } else if (props.render) { + return props.render(renderedComponents, placeholderRenderings, props); + } else { + return renderedComponents; + } + }; - if (placeholderEmpty) { - const rendered = props.renderEmpty ? props.renderEmpty(finalRendering) : finalRendering; - - return page.mode.isEditing ? renderEmptyPlaceholder(rendered) : rendered; - } - - if (props.render) { - return props.render(components, placeholderRenderings, props); - } else if (props.renderEach) { - const renderEach = props.renderEach; - - return finalRendering.map((component, index) => { - if (component && component.props && component.props.type === 'text/sitecore') { - return component; - } + const componentRuntime = rsc ? 'server' : 'client'; + const components = drawPlaceholderComponents( + props, + placeholderRenderings, + drawAppPlaceholderChildComponent, + componentRuntime, + isEditing + ); - return renderEach(component, index); - }); - } else { - return finalRendering; - } + const finalOutput = applyConditionalTransform(components); + // Using error boundary for errors that may happen within Placeholder itself + return {finalOutput}; }; + diff --git a/packages/react/src/components/Placeholder/ClientComponentWrapper.tsx b/packages/react/src/components/Placeholder/ClientComponentWrapper.tsx index 2879d77724..05c3786005 100644 --- a/packages/react/src/components/Placeholder/ClientComponentWrapper.tsx +++ b/packages/react/src/components/Placeholder/ClientComponentWrapper.tsx @@ -1,15 +1,14 @@ 'use client'; import { ComponentRendering } from '@sitecore-content-sdk/content/layout'; -import { ComponentMapReactContext } from '../SitecoreProvider'; +import { ComponentMapReactContext, useSitecore } from '../SitecoreProvider'; import { useContext } from 'react'; import React from 'react'; -import { useSitecore } from '../../enhancers/withSitecore'; -import { AppComponentProps } from './models'; +import { ChildComponentProps } from './models'; import { getComponentForRendering } from './placeholder-utils'; export interface ClientComponentWrapperProps { rendering: ComponentRendering; - componentProps: AppComponentProps; + componentProps: ChildComponentProps; placeholderName: string; } diff --git a/packages/react/src/components/Placeholder/Placeholder.test.tsx b/packages/react/src/components/Placeholder/Placeholder.test.tsx index fe8167bd8c..286e90c268 100644 --- a/packages/react/src/components/Placeholder/Placeholder.test.tsx +++ b/packages/react/src/components/Placeholder/Placeholder.test.tsx @@ -1,4 +1,4 @@ -/* eslint-disable no-unused-vars */ +/* eslint-disable no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable no-unused-expressions */ /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -34,7 +34,7 @@ import * as HiddenRendering from '../HiddenRendering'; import * as ErrorBoundary from '../ErrorBoundary'; import { MissingComponent, MissingComponentProps } from '../MissingComponent'; import { Placeholder } from './Placeholder'; -import { ComponentProps } from './models'; +import { ChildComponentProps } from './models'; import { SitecoreProvider } from '../SitecoreProvider'; import { Page, PageMode } from '@sitecore-content-sdk/content/client'; @@ -86,7 +86,7 @@ const DownloadCallout: React.FC<{ extraDiv?: boolean; }> = (props) => (
- {props.fields.message ? props.fields.message.value : ''} + {props.fields?.message ? props.fields.message.value : ''} {props.extraDiv ?
extra!
: null}
); @@ -151,7 +151,7 @@ describe('', () => { ).to.equal(1); }); - it('should render components based on the rendereach function', () => { + it('should render components based on the renderEach function', () => { const page = getPage(); page.layout = dataSet.data; const component = dataSet.data.sitecore.route as RouteData; @@ -170,6 +170,51 @@ describe('', () => { expect(renderedComponent.container.querySelectorAll('.wrapper').length).to.equal(1); }); + it('should use renderEach for each child in the placeholder when page editing is enabled', () => { + const page = getPage(); + const components = new Map(); + + page.mode.isEditing = true; + + components.set('Child', () => 'Child'); + + const route = { + name: 'Render Each Test', + placeholders: { + main: [ + { + componentName: 'Child', + }, + { + componentName: 'Child', + }, + ], + }, + }; + page.layout = { + sitecore: { + context: {}, + route, + }, + }; + const phKey = 'main'; + + const renderedComponent = render( + +
{children}
} + renderEach={(child) =>
{child}
} + /> +
+ ); + + expect(renderedComponent.container.querySelectorAll('.parentWrapper').length).to.equal(1); + expect(renderedComponent.container.querySelectorAll('.wrapper').length).to.equal(2); + }); + it('should render components based on the render function', () => { const page = getPage(); page.layout = dataSet.data; @@ -261,40 +306,35 @@ describe('', () => { ).to.be.true; }); - it('should apply modifyComponentProps to the final props', () => { + it('should pass passThroughComponentProps to rendered components', () => { const page = getPage(); - page.layout = dataSet.data; - const component = dataSet.data.sitecore.route as any; - const phKey = 'main'; - const expectedMessage = (component.placeholders.main as any[]).find((c) => c.componentName) - .fields.message; - - const modifyComponentProps = (props: ComponentProps) => { - if (props.rendering?.componentName === 'DownloadCallout') { - return { - ...props, - extraDiv: true, - }; - } - - return props; + // Create a simple test component directly without nesting + const testRendering: RouteData = { + placeholders: { + 'test-placeholder': [ + { + componentName: 'DownloadCallout', + uid: 'download-uid', + fields: { + message: { value: 'Test message' }, + }, + }, + ], + }, }; + page.layout = { sitecore: { context: {}, route: testRendering } }; const renderedComponent = render( ); - expect( - renderedComponent.container - .querySelector('.download-callout-mock') - ?.innerHTML.indexOf(expectedMessage.value) !== -1 - ).to.be.true; + expect(renderedComponent.container.querySelector('.download-callout-mock')).to.not.be.null; expect(renderedComponent.container.querySelectorAll('div.extra').length).to.equal(1); }); }); diff --git a/packages/react/src/components/Placeholder/Placeholder.tsx b/packages/react/src/components/Placeholder/Placeholder.tsx index 5578e80605..37b087a2f0 100644 --- a/packages/react/src/components/Placeholder/Placeholder.tsx +++ b/packages/react/src/components/Placeholder/Placeholder.tsx @@ -1,172 +1,83 @@ -'use client'; -import React from 'react'; -import { PlaceholderProps } from './models'; -import { withComponentMap } from '../../enhancers/withComponentMap'; +'use client'; +import React, { useEffect } from 'react'; +import { ChildComponentProps, ComponentForRendering, PlaceholderProps } from './models'; import { PagesEditor } from '@sitecore-content-sdk/content/editing'; -import { withSitecore } from '../../enhancers/withSitecore'; import { - getComponentForRendering, getPlaceholderRenderings, - getRenderedComponentProps, + drawPlaceholderComponents, renderEmptyPlaceholder, } from './placeholder-utils'; -import { ComponentRendering } from '@sitecore-content-sdk/content/layout'; -import { PlaceholderMetadata } from './PlaceholderMetadata'; -import ErrorBoundary, { ErrorComponent } from '../ErrorBoundary'; - -export class PlaceholderComponent extends React.Component { - isEmpty = false; - state: Readonly<{ error?: Error }>; - - constructor(props: PlaceholderProps) { - super(props); - this.state = {}; - } - - /** - * Renders the components for the placeholder based on the provided rendering data. - * @param {PlaceholderProps} props placeholder component props - * @param {ComponentRendering[]} placeholderRenderings renderings within placeholder - * @returns {React.ReactNode | React.ReactElement[]} rendered components - */ - static getRenderedComponents = ( - props: PlaceholderProps, - placeholderRenderings: ComponentRendering[] - ) => { - const { name, missingComponentComponent, hiddenRenderingComponent } = props; - - const transformedComponents = placeholderRenderings - .map((componentRendering: ComponentRendering, index: number) => { - const key = componentRendering.uid || `component-${index}`; - - const renderedProps = getRenderedComponentProps(props, componentRendering, key); - - const component = getComponentForRendering( - componentRendering, - name, - props.componentMap, - hiddenRenderingComponent, - missingComponentComponent - ); - - let rendered = React.createElement<{ [attr: string]: unknown }>( - component.component as React.ComponentType, - props.modifyComponentProps ? props.modifyComponentProps(renderedProps) : renderedProps - ); - - if (!component.isEmpty) { - const errorBoundaryKey = rendered.type + '-' + index; - - const disableSuspense = props.disableSuspense || false; - rendered = ( - - {rendered} - - ); - } - - // if in edit mode then emit shallow chromes for hydration in Pages - if (props.page.mode.isEditing) { - return ( - - {rendered} - - ); - } - - return rendered; - }) - .filter((element) => element); // remove nulls - - if (props.page.mode.isEditing) { - return [ - - {transformedComponents} - , - ]; - } - - return transformedComponents; - }; - - componentDidMount() { - if (this.isEmpty && PagesEditor.isActive()) { +import ErrorBoundary from '../ErrorBoundary'; +import { useComponentMap, useSitecore } from '../SitecoreProvider'; + +const PlaceholderComponent = (props: PlaceholderProps) => { + const renderingData = props.rendering; + let { page } = useSitecore(); + let componentMap = useComponentMap(); + page = props.page ?? page; + componentMap = props.componentMap || componentMap || undefined; + const modProps = { ...props, page, componentMap }; + const isEditing = page.mode.isEditing; + const placeholderRenderings = getPlaceholderRenderings(renderingData, modProps.name, isEditing); + const isEmpty = !placeholderRenderings.length; + + useEffect(() => { + if (isEmpty && PagesEditor.isActive()) { PagesEditor.resetChromes(); } - } - - componentDidCatch(error: Error) { - this.setState({ error }); - } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Empty array so it runs only once on mount - render() { - const childProps: PlaceholderProps = { ...this.props }; - - delete childProps.componentMap; - - if (this.state.error) { - if (childProps.errorComponent) { - return ; - } - - return ( - - ); - } - - const renderingData = childProps.rendering; - - const placeholderRenderings = getPlaceholderRenderings( - renderingData, - this.props.name, - this.props.page.mode.isEditing - ); - - this.isEmpty = !placeholderRenderings.length; - - const components = PlaceholderComponent.getRenderedComponents( - this.props, - placeholderRenderings + const drawPlaceholderChildComponent = ( + componentForRendering: ComponentForRendering, + renderedProps: ChildComponentProps, + key?: string + ) => { + return ( + ); + }; - if (this.isEmpty) { - const rendered = this.props.renderEmpty ? this.props.renderEmpty(components) : components; - - return this.props.page.mode.isEditing ? renderEmptyPlaceholder(rendered) : rendered; - } else if (this.props.render) { - return this.props.render(components, placeholderRenderings, childProps); - } else if (this.props.renderEach) { - const renderEach = this.props.renderEach; + const applyConditionalTransform = (renderedComponents: React.JSX.Element[]) => { + const childProps = { ...props }; + delete childProps.componentMap; + const isEmpty = !placeholderRenderings.length; - return components.map((component, index) => { - if (component && component.props && component.props.type === 'text/sitecore') { - return component; - } + if (isEmpty) { + const rendered = props.renderEmpty + ? props.renderEmpty(renderedComponents) + : renderedComponents; - return renderEach(component, index); - }); + return isEditing ? renderEmptyPlaceholder(rendered) : rendered; + } else if (props.render) { + return props.render(renderedComponents, placeholderRenderings, childProps); } else { - return components; + return renderedComponents; } - } -} + }; -const PlaceholderWithComponentMap = withComponentMap(PlaceholderComponent); + const components = drawPlaceholderComponents( + modProps, + placeholderRenderings, + drawPlaceholderChildComponent, + undefined, + isEditing + ); + + const finalOutput = applyConditionalTransform(components); + // Using error boundary for errors that may happen within Placeholder itself + return {finalOutput}; +}; /** * The Placeholder component. * @public */ -export const Placeholder = withSitecore()(PlaceholderWithComponentMap); +export const Placeholder = PlaceholderComponent; + diff --git a/packages/react/src/components/Placeholder/index.ts b/packages/react/src/components/Placeholder/index.ts index be2656c94a..29db9d6f36 100644 --- a/packages/react/src/components/Placeholder/index.ts +++ b/packages/react/src/components/Placeholder/index.ts @@ -1,4 +1,4 @@ -export { Placeholder, PlaceholderComponent } from './Placeholder'; +export { Placeholder } from './Placeholder'; export { PlaceholderMetadata } from './PlaceholderMetadata'; export { PlaceholderProps, AppPlaceholderProps } from './models'; export { AppPlaceholder } from './AppPlaceholder'; diff --git a/packages/react/src/components/Placeholder/models.ts b/packages/react/src/components/Placeholder/models.ts index 4f26188689..cbc855933d 100644 --- a/packages/react/src/components/Placeholder/models.ts +++ b/packages/react/src/components/Placeholder/models.ts @@ -10,22 +10,6 @@ type ErrorComponentProps = { [prop: string]: unknown; }; -/** Provided for the component which represents rendering data */ -export type ComponentProps = { - [key: string]: unknown; - rendering: ComponentRendering; -}; - -export interface AppComponentProps { - fields: { - [name: string]: Field | Item | Item[]; - }; - params: { - [name: string]: string; - }; - rendering: ComponentRendering; -} - export interface BasePlaceholderProps { /** Name of the placeholder to render. */ name: string; @@ -66,7 +50,7 @@ export interface BasePlaceholderProps { * Page data. * This data is passed by the SitecoreProvider. */ - page: Page; + page?: Page; /** * The message that gets displayed while component is loading */ @@ -86,59 +70,37 @@ export interface BasePlaceholderProps { * Mutually exclusive with `render`. */ renderEach?: (component: React.ReactNode, index: number) => React.ReactNode; -} - -/** - * The interface for the Placeholder component props. - * @public - */ -export interface PlaceholderProps extends BasePlaceholderProps { - [key: string]: unknown; - /** - * Component Map will be used to map Sitecore component names to app implementation - * When rendered within a component, defaults to the context componentMap. - * When rendered as a server placeholder, this prop must be provided. This prop is not used in AppPlaceholder. - */ - componentMap?: ComponentMap; /** * Modify final props of component (before render) provided by rendering data. * Can be used in case when you need to insert additional data into the component. - * @param {ComponentProps} componentProps component props to be modified - * @returns {ComponentProps} modified or initial props + * @param {ChildComponentProps} componentProps component props to be modified + * @returns {ChildComponentProps} modified or initial props */ - modifyComponentProps?: (componentProps: ComponentProps) => ComponentProps; + modifyComponentProps?: (componentProps: ChildComponentProps) => ChildComponentProps; /** - * Render props function that enables control over the rendering of the components in the placeholder. - * Useful for techniques like wrapping each child in a wrapper component. + * An alternative to `modifyComponentProps` that allows passing additional props to the component without modifying the CSDK Placeholder props from Sitecore. + * These props will be merged into the result of modifyComponentProps if you use both + * Make sure to not include non-serializable props here in RSC server context https://react.dev/reference/rsc/use-server#serializable-parameters-and-return-values */ - render?: ( - components: React.ReactNode[], - data: ComponentRendering[], - props: PlaceholderProps - ) => React.ReactNode; -} + passThroughComponentProps?: { + [key: string]: unknown; + }; -/** - * The interface for the AppPlaceholder component props. - * @public - */ -export interface AppPlaceholderProps extends BasePlaceholderProps { /** * Component Map will be used to map Sitecore component names to app implementation * When rendered within a component, defaults to the context componentMap. * When rendered as a server placeholder, this prop must be provided. This prop is not used in AppPlaceholder. */ - componentMap: ComponentMap; - /** - * Modify final props of component (before render) provided by rendering data. - * Can be used in case when you need to insert additional data into the component. - * @param {AppComponentProps} componentProps component props to be modified - * @returns {AppComponentProps} modified or initial props - */ - modifyComponentProps?: (componentProps: AppComponentProps) => AppComponentProps; + componentMap?: ComponentMap; +} +/** + * The interface for the client Placeholder component props. + * @public + */ +export type PlaceholderProps = BasePlaceholderProps & { /** * Render props function that enables control over the rendering of the components in the placeholder. * Useful for techniques like wrapping each child in a wrapper component. @@ -146,17 +108,41 @@ export interface AppPlaceholderProps extends BasePlaceholderProps { render?: ( components: React.ReactNode[], data: ComponentRendering[], - props: AppPlaceholderProps + props: PlaceholderProps ) => React.ReactNode; -} +}; + +/** + * The interface for the AppPlaceholder component props. + * @public + */ +export type AppPlaceholderProps = Omit & + Required> & { + /** + * Render props function that enables control over the rendering of the components in the placeholder. + * Useful for techniques like wrapping each child in a wrapper component. + */ + render?: ( + components: React.ReactNode[], + data: ComponentRendering[], + props: AppPlaceholderProps + ) => React.ReactNode; + }; -export type RenderedProps = Omit & { +export type RenderedProps = ChildComponentProps & { key: string; - fields: { [field: string]: unknown }; - params: { [param: string]: unknown }; - rendering: ComponentRendering; }; +export interface ChildComponentProps { + fields: { + [name: string]: Field | Item | Item[]; + }; + params: { + [name: string]: string; + }; + rendering: ComponentRendering; +} + export interface ComponentForRendering { component: React.ComponentType; isEmpty: boolean; @@ -179,3 +165,4 @@ export const nonSerializedPlaceholderProps = [ 'missingComponentComponent', 'hiddenRenderingComponent', ] as const satisfies (keyof PlaceholderProps)[]; + diff --git a/packages/react/src/components/Placeholder/placeholder-utils.test.tsx b/packages/react/src/components/Placeholder/placeholder-utils.test.tsx index ab8dec3abe..0b44218f18 100644 --- a/packages/react/src/components/Placeholder/placeholder-utils.test.tsx +++ b/packages/react/src/components/Placeholder/placeholder-utils.test.tsx @@ -7,7 +7,7 @@ import { createSandbox } from 'sinon'; import { getPlaceholderRenderings, getSXAParams, - getRenderedComponentProps, + getChildComponentProps, getComponentForRendering, } from './placeholder-utils'; import { ComponentRendering } from '@sitecore-content-sdk/content/layout'; @@ -183,7 +183,7 @@ describe('placeholder-utils', () => { }); }); - describe('getRenderedComponentProps', () => { + describe('getChildComponentProps', () => { it('should merge placeholder and rendering fields', () => { const placeholderProps: PlaceholderProps = { name: 'test-placeholder', @@ -215,13 +215,14 @@ describe('placeholder-utils', () => { }, }; - const result = getRenderedComponentProps(placeholderProps, componentRendering, 'test-key'); + const result = getChildComponentProps(placeholderProps, componentRendering); expect(result.fields).to.deep.equal({ placeholderField: { value: 'placeholder-value' }, renderingField: { value: 'rendering-value' }, sharedField: { value: 'rendering-shared-value' }, // rendering should override placeholder }); + expect(result.rendering).to.equal(componentRendering); }); it('should merge placeholder and rendering params', () => { @@ -257,7 +258,7 @@ describe('placeholder-utils', () => { }, }; - const result = getRenderedComponentProps(placeholderProps, componentRendering, 'test-key'); + const result = getChildComponentProps(placeholderProps, componentRendering); expect(result.params).to.deep.equal({ placeholderParam: 'placeholder-param-value', @@ -267,9 +268,10 @@ describe('placeholder-utils', () => { Styles: 'custom-class', styles: 'col-lg-6 custom-class', // SXA styles should be added }); + expect(result.rendering).to.equal(componentRendering); }); - it('should return composite props object', () => { + it('should return minimal child component props object', () => { const placeholderProps: PlaceholderProps = { name: 'test-placeholder', rendering: { componentName: 'Test', uid: 'test-uid' }, @@ -302,24 +304,24 @@ describe('placeholder-utils', () => { }, }; - const result = getRenderedComponentProps(placeholderProps, componentRendering, 'test-key'); + const result = getChildComponentProps(placeholderProps, componentRendering); - expect(result.key).to.equal('test-key'); + // getChildComponentProps returns only fields, params, and rendering expect(result.rendering).to.equal(componentRendering); - expect(result.customProp).to.equal('custom-value'); - expect(result.componentMap).to.equal(placeholderProps.componentMap); - - // These props should be removed from the result - expect(result.missingComponentComponent).to.be.undefined; - expect(result.hiddenRenderingComponent).to.be.undefined; - expect(result.name).to.be.undefined; - expect(result.fields).to.deep.equal({ testField: { value: 'test-value' }, }); expect(result.params).to.deep.equal({ testParam: 'test-param', }); + + // getChildComponentProps does not include these props + expect((result as any).key).to.be.undefined; + expect((result as any).customProp).to.be.undefined; + expect((result as any).componentMap).to.be.undefined; + expect((result as any).missingComponentComponent).to.be.undefined; + expect((result as any).hiddenRenderingComponent).to.be.undefined; + expect((result as any).name).to.be.undefined; }); }); diff --git a/packages/react/src/components/Placeholder/placeholder-utils.tsx b/packages/react/src/components/Placeholder/placeholder-utils.tsx index 9c454a9dc7..b223ac711d 100644 --- a/packages/react/src/components/Placeholder/placeholder-utils.tsx +++ b/packages/react/src/components/Placeholder/placeholder-utils.tsx @@ -20,15 +20,17 @@ import { FEAAS_WRAPPER_RENDERING_NAME, } from '../FEaaS'; import { - AppComponentProps, + ChildComponentProps, BasePlaceholderProps, ComponentForRendering, PlaceholderProps, - RenderedProps, + AppPlaceholderProps, } from './models'; +import { PlaceholderMetadata } from './PlaceholderMetadata'; +import ErrorBoundary from '../ErrorBoundary'; /** - * Get the renderings for the specified placeholder from the rendering data. + * Get the renderings for the specified placeholder from the rendering layout data. * @param {ComponentRendering | RouteData } rendering rendering data * @param {string} name placeholder name * @param {boolean} isEditing whether components should be rendered in editing mode @@ -110,43 +112,15 @@ export const renderEmptyPlaceholder = (node: React.ReactNode | React.ReactElemen }; /** - * Get component props to be passed to the rendered component. - * @param {PlaceholderProps} placeholderProps current placeholder props - * @param {ComponentRendering} componentRendering rendering to be rendered - * @param {string} renderingKey unique key to pass over to rendering props - * @returns {RenderedProps} props to be passed to the rendered component - */ -export const getRenderedComponentProps = ( - placeholderProps: PlaceholderProps, - componentRendering: ComponentRendering, - renderingKey: string -): RenderedProps => { - // eslint-disable-next-line no-unused-vars - const { fields, params: placeholderParams, ...passThroughProps } = placeholderProps; - delete passThroughProps.missingComponentComponent; - delete passThroughProps.hiddenRenderingComponent; - delete (passThroughProps as { name?: string }).name; - - const mergedContentProps = getAppComponentProps(placeholderProps, componentRendering); - - return { - key: renderingKey, - ...passThroughProps, - ...mergedContentProps, - rendering: componentRendering, - }; -}; - -/** - * Merge placeholder and component field and params content props. + * Merge specific placeholder props with component field and params content props. * @param {BasePlaceholderProps} placeholderProps placeholder props * @param {ComponentRendering} componentRendering component rendering * @returns {ComponentProps} merged props */ -export function getAppComponentProps( +export function getChildComponentProps( placeholderProps: T, componentRendering: ComponentRendering -): AppComponentProps { +): ChildComponentProps { const fields = { ...(placeholderProps.fields || {}), ...(componentRendering.fields || {}) }; const params = { ...(placeholderProps.params || {}), ...(componentRendering.params || {}) }; return { @@ -230,7 +204,7 @@ export const getComponentForRendering = ( componentType: 'universal', }; } else if (renderingDefinition.componentName === BYOC_WRAPPER_RENDERING_NAME) { - // wrapping with error boundary could cause problems in case where parent component uses withPlaceholder HOC and tries to access its children props + // wrapping with error boundary could cause problems in case where parent component uses withPlaceholder HOCs and tries to access its children props // that's why we need to mark BYOC wrapper dynamic return { component: BYOCWrapper, @@ -282,3 +256,95 @@ export const getComponentForRendering = ( isEmpty: false, }; }; + +export const drawPlaceholderComponents = ( + props: PlaceholderProps | AppPlaceholderProps, + placeholderRenderings: ComponentRendering[], + drawPlaceholderChildComponent: ( + componentForRendering: ComponentForRendering, + renderedProps: ChildComponentProps, + key?: string + ) => React.JSX.Element, + componentRuntime?: 'server' | 'client' | undefined, + isEditing?: boolean +) => { + const { name, missingComponentComponent, hiddenRenderingComponent } = props; + + const transformedComponents = placeholderRenderings + .map((componentRendering: ComponentRendering, index: number) => { + const component = getComponentForRendering( + componentRendering, + name, + props.componentMap, + hiddenRenderingComponent, + missingComponentComponent + ); + const key = componentRendering.uid || `component-${index}`; + + const renderedProps = props.modifyComponentProps + ? props.modifyComponentProps(getChildComponentProps(props, componentRendering)) + : getChildComponentProps(props, componentRendering); + + let rendered = drawPlaceholderChildComponent( + component, + { + ...renderedProps, + ...props.passThroughComponentProps, + }, + key + ); + + if (props.renderEach) { + rendered = props.renderEach(rendered, index) as React.ReactElement<{ + [attr: string]: unknown; + }>; + } + + if (!component.isEmpty) { + const errorBoundaryKey = rendered.type + '-' + index; + + const disableSuspense = props.disableSuspense || false; + rendered = ( + + {rendered} + + ); + } + // if in edit mode then emit shallow chromes for hydration in Pages + return isEditing ? ( + + {rendered} + + ) : ( + rendered + ); + }) + .filter((element) => element); // remove nulls + + if (!isEditing) { + return transformedComponents; + } + + return [ + + {transformedComponents} + , + ]; +}; + diff --git a/packages/react/src/components/RichText.tsx b/packages/react/src/components/RichText.tsx index 787d19fc1b..3e4390cfdd 100644 --- a/packages/react/src/components/RichText.tsx +++ b/packages/react/src/components/RichText.tsx @@ -1,4 +1,4 @@ -'use client'; +'use client'; import React, { ForwardedRef, forwardRef } from 'react'; import { FieldMetadata, isFieldValueEmpty } from '@sitecore-content-sdk/content/layout'; import { withFieldMetadata } from '../enhancers/withFieldMetadata'; @@ -55,7 +55,8 @@ export const RichText: React.FC = withFieldMetadata; } ), { defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentText, isForwardRef: true } diff --git a/packages/react/src/components/SitecoreProvider.test.tsx b/packages/react/src/components/SitecoreProvider.test.tsx index 3a7f2822d5..1527ccaea6 100644 --- a/packages/react/src/components/SitecoreProvider.test.tsx +++ b/packages/react/src/components/SitecoreProvider.test.tsx @@ -2,8 +2,8 @@ import React, { FC } from 'react'; import { expect } from 'chai'; import { Page } from '@sitecore-content-sdk/content/client'; -import { SitecoreProvider } from './SitecoreProvider'; -import { WithSitecoreProps, withSitecore, useSitecore } from '../enhancers/withSitecore'; +import { SitecoreProvider, useSitecore } from './SitecoreProvider'; +import { WithSitecoreProps, withSitecore } from '../enhancers/withSitecore'; import { LayoutServiceData, LayoutServicePageState } from '../index'; import { render } from '@testing-library/react'; diff --git a/packages/react/src/components/SitecoreProvider.tsx b/packages/react/src/components/SitecoreProvider.tsx index 83008a0c05..46a9f87f1b 100644 --- a/packages/react/src/components/SitecoreProvider.tsx +++ b/packages/react/src/components/SitecoreProvider.tsx @@ -1,5 +1,5 @@ 'use client'; -import React from 'react'; +import React, { useState, useEffect, useCallback, useMemo, useContext } from 'react'; import fastDeepEqual from 'fast-deep-equal/es6/react'; import { Page } from '@sitecore-content-sdk/content/client'; import { SitecoreConfig } from '@sitecore-content-sdk/content/config'; @@ -38,7 +38,7 @@ export interface SitecoreProviderState { * @param {Page} value New page value. * @returns {void} */ - setPage: (value: Page) => void; + setPage?: (value: Page) => void; /** * The current page. */ @@ -49,6 +49,17 @@ export interface SitecoreProviderState { api?: SitecoreProviderProps['api']; } +/** + * The options for the useSitecore hook. + * @public + */ +export interface UseSitecoreOptions { + /** + * If set to true, the `updateContext` method will be injected into the component props. + */ + updatable?: boolean; +} + /** * The context for the SitecoreProvider component. * @public @@ -65,68 +76,108 @@ export const ImportMapReactContext = React.createContext< /** * The SitecoreProvider component. + * @param {SitecoreProviderProps} props - The props for the SitecoreProvider component. + * @param {SitecoreProviderProps['api']} props.api - The API configuration. + * @param {SitecoreProviderProps['page']} props.page - The page data. + * @param {SitecoreProviderProps['componentMap']} props.componentMap - The component map. + * @param {SitecoreProviderProps['loadImportMap']} props.loadImportMap - The function to load the import map. + * @param {React.ReactNode} props.children - The children to render. + * @returns {React.ReactNode} The SitecoreProvider component. * @public */ -export class SitecoreProvider extends React.Component< - SitecoreProviderProps, - SitecoreProviderState -> { - static displayName = 'SitecoreProvider'; +export const SitecoreProvider = (props: SitecoreProviderProps) => { + const { api: propsApi, page: propsPage, componentMap, loadImportMap, children } = props; - constructor(props: SitecoreProviderProps) { - super(props); - - // If any Edge ID is present but no edgeUrl, apply the default - let api = props.api; + // Apply default edgeUrl if any Edge ID is present but no edgeUrl + const api = useMemo(() => { if ( - (props.api?.edge?.contextId || props.api?.edge?.clientContextId) && - !props.api?.edge?.edgeUrl + (propsApi?.edge?.contextId || propsApi?.edge?.clientContextId) && + !propsApi?.edge?.edgeUrl ) { - api = { - ...props.api, + return { + ...propsApi, edge: { - ...props.api.edge, + ...propsApi.edge, edgeUrl: constants.SITECORE_EDGE_URL_DEFAULT, }, }; } + return propsApi; + }, [propsApi]); - this.state = { - page: props.page, - setPage: this.setPage, - api, - }; - } + const [page, setPageInternal] = useState(propsPage); - componentDidUpdate(prevProps: SitecoreProviderProps) { - // In case if somebody will manage SitecoreProvider state by passing fresh `page` prop - // instead of using `updateContext` - if (!fastDeepEqual(prevProps.page, this.props.page)) { - this.setPage(this.props.page); + // Memoize setPage callback + const setPage = useCallback((value: Page) => { + setPageInternal(value); + }, []); - return; + // Handle page prop changes using useEffect instead of componentDidUpdate + useEffect(() => { + if (!fastDeepEqual(propsPage, page)) { + setPage(propsPage); } - } + }, [propsPage, page, setPage]); - /** - * Update page state. - * @param {Page} value New page value - */ - setPage = (value: Page) => { - this.setState({ - page: value, - }); + // Memoize the context value to prevent unnecessary re-renders + const contextValue = useMemo( + () => ({ + page, + setPage, + api, + }), + [page, setPage, api] + ); + + return ( + + + + {children} + + + + ); +}; + +SitecoreProvider.displayName = 'SitecoreProvider'; + +/** + * This hook grants acсess to the current Sitecore page and api. + * @param {UseSitecoreOptions} [options] hook options + * @example + * const EditMode = () => { + * const { page } = useSitecore(); + * return Edit Mode is {page.mode.isEditing ? 'active' : 'inactive'} + * } + * @returns {SitecoreProviderState} The current Sitecore context, including the page and api. + * @public + */ +export function useSitecore(options?: UseSitecoreOptions): SitecoreProviderState { + const scContext = useContext(SitecoreProviderReactContext); + const updatable = options?.updatable; + + return { + ...scContext, + setPage: updatable ? scContext.setPage : undefined, }; +} + +/** + * Hook that retrieves the loadImportMap function from context. + * @returns {() => Promise | undefined} The loadImportMap function from context, or undefined if not available. + * @public + */ +export function useLoadImportMap(): (() => Promise) | undefined { + return useContext(ImportMapReactContext); +} - render() { - return ( - - - - {this.props.children} - - - - ); - } +/** + * Hook to access the component map in client context. + * @returns {ComponentMap} The component map from the SitecoreProvider + * @public + */ +export function useComponentMap(): ComponentMap { + const componentMap = useContext(ComponentMapReactContext); + return componentMap; } diff --git a/packages/react/src/components/Text.tsx b/packages/react/src/components/Text.tsx index 8afffebbde..dadc0c58ba 100644 --- a/packages/react/src/components/Text.tsx +++ b/packages/react/src/components/Text.tsx @@ -1,4 +1,4 @@ -'use client'; +'use client'; import React, { ReactElement } from 'react'; import { FieldMetadata, isFieldValueEmpty } from '@sitecore-content-sdk/content/layout'; import { withFieldMetadata } from '../enhancers/withFieldMetadata'; @@ -83,14 +83,16 @@ export const Text: React.FC = withFieldMetadata( children = output; } + const Tag = (tag || 'span') as React.ElementType; + if (field.metadata) { - return React.createElement( - tag || 'span', - { ...htmlProps, suppressHydrationWarning: true }, - children + return ( + + {children} + ); } else if (tag || !encode) { - return React.createElement(tag || 'span', htmlProps, children); + return {children}; } else { return {children}; } diff --git a/packages/react/src/enhancers/withAppPlaceholder.test.tsx b/packages/react/src/enhancers/withAppPlaceholder.test.tsx index 0a4212f4f8..1e5c64b109 100644 --- a/packages/react/src/enhancers/withAppPlaceholder.test.tsx +++ b/packages/react/src/enhancers/withAppPlaceholder.test.tsx @@ -188,13 +188,12 @@ describe('withAppPlaceholder HOC', () => { ); }; - const phKeys = ['page-header', 'page-content']; const props: WrapperProps = { rendering: cleanComponent, page: getPage(), componentMap, }; - const Element = withAppPlaceholder(MultiKeyTestComponent, phKeys); + const Element = withAppPlaceholder(MultiKeyTestComponent); const renderedComponent = render( diff --git a/packages/react/src/enhancers/withAppPlaceholder.tsx b/packages/react/src/enhancers/withAppPlaceholder.tsx index 4fcefe81bb..ce69402283 100644 --- a/packages/react/src/enhancers/withAppPlaceholder.tsx +++ b/packages/react/src/enhancers/withAppPlaceholder.tsx @@ -16,6 +16,12 @@ export type WrapperProps = { componentMap: ComponentMap; }; +/** + * Provides a slot-like functionality by wrapping a component and rendering placeholders defined in the layout data. + * @param {ComponentType} Component - The component to be wrapped around placeholders. + * @returns {React.ReactNode} A new component that renders the original component with placeholders. + * @public + */ export const withAppPlaceholder = ( Component: ComponentType ) => { diff --git a/packages/react/src/enhancers/withClientPlaceholder.test.tsx b/packages/react/src/enhancers/withClientPlaceholder.test.tsx new file mode 100644 index 0000000000..ba757c9964 --- /dev/null +++ b/packages/react/src/enhancers/withClientPlaceholder.test.tsx @@ -0,0 +1,418 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable no-unused-expressions */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React, { ReactElement, ReactNode } from 'react'; +import { expect } from 'chai'; +import { render } from '@testing-library/react'; +import { Page } from '@sitecore-content-sdk/content/client'; +import { LayoutServicePageState } from '@sitecore-content-sdk/content/layout'; +import { convertedDevData as normalModeDevData } from '../test-data/normal-mode-data'; +import * as metadataData from '../test-data/metadata-data'; +import { withClientPlaceholder, ComponentProps, WrapperProps } from './withClientPlaceholder'; +import { SitecoreProvider } from '../components/SitecoreProvider'; +import { ComponentRendering, RouteData } from '@sitecore-content-sdk/content/layout'; +import { Placeholder } from '../components/Placeholder'; + +type CalloutProps = ComponentProps & { + [prop: string]: unknown; + fields: { message: { value?: string } }; + subProp?: ReactElement; +}; + +type HomeProps = ComponentProps & { + [prop: string]: unknown; + rendering?: RouteData | ComponentRendering; + subProp?: ReactElement; +}; + +const DownloadCallout: React.FC = (props) => ( +
+ {props.fields?.message ? props.fields.message.value : ''} +
+); + +const Home: React.FC = ({ placeholders, subProp, ...otherProps }: HomeProps) => { + if (subProp && !otherProps.reset) { + return
{subProp}
; + } else { + // For withClientPlaceholder, placeholders are provided as props, so we access them + const placeholderContent = + Object.keys(placeholders).length > 0 + ? placeholders['page-content'] || placeholders.main || placeholders['page-header'] + : null; + return
{placeholderContent as ReactNode}
; + } +}; + +const delay = (timeout: number, promise?: any) => { + return new Promise((resolve) => { + setTimeout(resolve, timeout); + }).then(() => promise); +}; + +const componentMap = new Map>(); + +componentMap.set('DownloadCallout', DownloadCallout); +componentMap.set('Jumbotron', () =>
); +componentMap.set('BrokenComponent', () => { + throw new Error('BrokenComponent error'); +}); +componentMap.set( + 'DynamicComponent', + React.lazy(() => + delay(500, () => { + throw new Error('DynamicComponent error'); + }) + ) +); + +describe('withClientPlaceholder HOC', () => { + const api = { + edge: { + contextId: 'id', + edgeUrl: 'url', + clientContextId: 'clientId', + }, + local: { + apiKey: 'apiKey', + apiHost: 'apiHost', + path: 'path', + }, + }; + + const getPage = (): Page => ({ + layout: normalModeDevData, + locale: 'en', + mode: { + name: LayoutServicePageState.Normal, + isNormal: true, + isPreview: false, + isEditing: false, + isDesignLibrary: false, + designLibrary: { + isVariantGeneration: false, + }, + }, + }); + + // Basic functionality tests + it('should render without placeholders', () => { + const cleanComponent: ComponentRendering = { + componentName: 'TestComponent', + uid: 'clean-test-123', + fields: { + title: { value: 'Test Title' }, + }, + }; + + const props: WrapperProps = { + rendering: cleanComponent, + page: getPage(), + componentMap, + }; + const Element = withClientPlaceholder(Home); + const renderedComponent = render( + + + + ); + + expect(renderedComponent.container.querySelectorAll('.home-mock').length).to.equal(1); + expect(renderedComponent.container.querySelector('.home-mock')?.children.length).to.equal(0); + }); + + it('should render a single placeholder correctly', () => { + const cleanComponent: ComponentRendering = { + componentName: 'TestComponent', + uid: 'clean-test-123', + placeholders: { + 'page-content': [ + { + componentName: 'DownloadCallout', + uid: 'download-123', + fields: { linkText: { value: 'Download' } }, + }, + ], + }, + }; + + const props: WrapperProps = { + rendering: cleanComponent, + page: getPage(), + componentMap, + }; + const Element = withClientPlaceholder(Home); + const renderedComponent = render( + + + + ); + + expect(renderedComponent.container.querySelectorAll('.home-mock').length).to.equal(1); + expect(renderedComponent.container.querySelectorAll('.download-callout-mock').length).to.equal( + 1 + ); + }); + + it('should render multiple placeholders correctly', () => { + // Create a simple component with clean data (no broken components) + const cleanComponent: ComponentRendering = { + componentName: 'TestComponent', + uid: 'clean-test-123', + placeholders: { + 'page-header': [ + { + componentName: 'Jumbotron', + uid: 'jumbotron-123', + params: { shade: 'dark', titleSize: '1' }, + fields: { titleText: { value: 'Test Title' } }, + }, + ], + 'page-content': [ + { + componentName: 'DownloadCallout', + uid: 'download-123', + fields: { linkText: { value: 'Download' } }, + }, + ], + }, + }; + + // Test component that uses both placeholders + const MultiKeyTestComponent: React.FC = ({ placeholders }) => { + return ( +
+
{placeholders['page-header']}
+
{placeholders['page-content']}
+
+ ); + }; + + const props: WrapperProps = { + rendering: cleanComponent, + page: getPage(), + componentMap, + }; + const Element = withClientPlaceholder(MultiKeyTestComponent); + const renderedComponent = render( + + + + ); + + // Should render the test component + expect(renderedComponent.container.querySelectorAll('.multi-key-test').length).to.equal(1); + // Should render jumbotron from page-header placeholder + expect(renderedComponent.container.querySelectorAll('.jumbotron-mock').length).to.equal(1); + // Should render download callout from page-content placeholder + expect(renderedComponent.container.querySelectorAll('.download-callout-mock').length).to.equal( + 1 + ); + }); + + it('should pass correct props to Placeholder components', () => { + const cleanComponent: ComponentRendering = { + componentName: 'TestComponent', + uid: 'clean-test-123', + placeholders: { + 'page-content': [ + { + componentName: 'DownloadCallout', + uid: 'download-123', + fields: { linkText: { value: 'Download' } }, + }, + ], + }, + }; + const phKey = 'page-content'; + const page = getPage(); + + // Test component that captures placeholder props + let capturedPlaceholderProps: any = null; + const TestComponent: React.FC = ({ placeholders }) => { + const placeholder = placeholders[phKey]; + if (React.isValidElement(placeholder)) { + capturedPlaceholderProps = placeholder.props; + } + return
{placeholder}
; + }; + + const props: WrapperProps = { + rendering: cleanComponent, + page, + componentMap, + }; + + const Element = withClientPlaceholder(TestComponent); + render( + + + + ); + + // Verify Placeholder received correct props + expect(capturedPlaceholderProps).to.not.be.null; + expect(capturedPlaceholderProps.name).to.equal(phKey); + expect(capturedPlaceholderProps.rendering).to.equal(cleanComponent); + // Note: page and componentMap come from SitecoreProvider context in client mode + }); + + describe('Metadata Mode', () => { + const defaultPage = getPage(); + const editModePage: Page = { + ...defaultPage, + mode: { + ...defaultPage.mode, + name: LayoutServicePageState.Edit, + isEditing: true, + }, + }; + + const { + layoutData, + layoutDataWithEmptyPlaceholder, + layoutDataForNestedDynamicPlaceholder, + layoutDataWithUnknownComponent, + } = metadataData; + + const metadataComponentMap = new Map(); + + metadataComponentMap.set('Header', () => ( +
+ +
+ )); + metadataComponentMap.set('Logo', () =>
); + + it('should render a placeholder with given key', () => { + const component = layoutData.sitecore.route; + const props: WrapperProps = { + rendering: component as ComponentRendering, + page: editModePage, + componentMap: metadataComponentMap, + }; + const Element = withClientPlaceholder(Home); + const renderedComponent = render( + + + + ); + + // Check that it renders the basic structure + expect(renderedComponent.container.querySelectorAll('.home-mock').length).to.equal(1); + expect(renderedComponent.container.querySelectorAll('.header-wrapper').length).to.equal(1); + expect(renderedComponent.container.querySelectorAll('.Logo-mock').length).to.equal(1); + }); + + it('should render a placeholder with given key and multiple placeholders', () => { + const component = layoutData.sitecore.route; + + const MultiPlaceholderMetadataComponent: React.FC = ({ placeholders }) => { + return ( +
+
{placeholders.main}
+
+ {placeholders.secondary || Empty secondary} +
+
+ ); + }; + + const props: WrapperProps = { + rendering: component as ComponentRendering, + page: editModePage, + componentMap: metadataComponentMap, + }; + const Element = withClientPlaceholder(MultiPlaceholderMetadataComponent); + const renderedComponent = render( + + + + ); + + expect(renderedComponent.container.querySelectorAll('.metadata-multi-mock').length).to.equal( + 1 + ); + expect(renderedComponent.container.querySelectorAll('.header-wrapper').length).to.equal(1); + expect(renderedComponent.container.querySelectorAll('.Logo-mock').length).to.equal(1); + }); + + it('should render code blocks even if placeholder is empty', () => { + const component = layoutDataWithEmptyPlaceholder.sitecore.route; + const props: WrapperProps = { + rendering: component as ComponentRendering, + page: editModePage, + componentMap: metadataComponentMap, + }; + const Element = withClientPlaceholder(Home); + const renderedComponent = render( + + + + ); + + expect(renderedComponent.container.querySelectorAll('.home-mock').length).to.equal(1); + // Placeholder should handle empty placeholders in edit mode + }); + + it('should render missing component with code blocks if component is not registered', () => { + const component = layoutDataWithUnknownComponent.sitecore.route; + const props: WrapperProps = { + rendering: component as ComponentRendering, + page: editModePage, + componentMap: metadataComponentMap, + }; + const Element = withClientPlaceholder(Home); + const renderedComponent = render( + + + + ); + + expect(renderedComponent.container.querySelectorAll('.home-mock').length).to.equal(1); + // Should render unknown component placeholder + expect(renderedComponent.container.innerHTML).to.include('Unknown'); + }); + + it('should render dynamic placeholder', () => { + const layoutData = layoutDataForNestedDynamicPlaceholder('container-{*}'); + const component = layoutData.sitecore.route; + const props: WrapperProps = { + rendering: component as ComponentRendering, + page: editModePage, + componentMap: metadataComponentMap, + }; + const Element = withClientPlaceholder(Home); + const renderedComponent = render( + + + + ); + + expect(renderedComponent.container.querySelectorAll('.home-mock').length).to.equal(1); + // Placeholder should handle dynamic placeholders + }); + + it('should render double digit dynamic placeholder', () => { + const layoutData = layoutDataForNestedDynamicPlaceholder('container-1-{*}'); + const component = layoutData.sitecore.route; + const props: WrapperProps = { + rendering: component as ComponentRendering, + page: editModePage, + componentMap: metadataComponentMap, + }; + const Element = withClientPlaceholder(Home); + const renderedComponent = render( + + + + ); + + expect(renderedComponent.container.querySelectorAll('.home-mock').length).to.equal(1); + // Placeholder should handle double digit dynamic placeholders + }); + }); +}); diff --git a/packages/react/src/enhancers/withClientPlaceholder.tsx b/packages/react/src/enhancers/withClientPlaceholder.tsx new file mode 100644 index 0000000000..ca32d9a79f --- /dev/null +++ b/packages/react/src/enhancers/withClientPlaceholder.tsx @@ -0,0 +1,42 @@ +'use client'; +import React from 'react'; +import { ComponentType } from 'react'; +import { ComponentRendering } from '@sitecore-content-sdk/content/layout'; +import { Page } from '@sitecore-content-sdk/content/client'; +import { ComponentMap } from '../components/sharedTypes'; +import { Placeholder } from '../components/Placeholder'; + +export type ComponentProps = { + rendering: ComponentRendering; + placeholders: Record; +}; + +export type WrapperProps = { + rendering: ComponentRendering; + page: Page; + componentMap: ComponentMap; +}; + +/** + * Provides a slot-like functionality by wrapping a component and rendering placeholders defined in the layout data. + * @param {ComponentType} Component - The component to be wrapped around placeholders. + * @returns {React.ReactNode} A new component that renders the original component with placeholders. + * @public + */ +export const withClientPlaceholder = ( + Component: ComponentType +) => { + return (props: W) => { + const placeholders = props.rendering.placeholders || {}; + const phProps: Record = {}; + + for (const placeholder of Object.keys(placeholders)) { + phProps[placeholder] = ; + } + + const displayName = Component.displayName || Component.name || 'Component'; + const propsCopy: T = { ...props, displayName }; + + return ; + }; +}; diff --git a/packages/react/src/enhancers/withComponentMap.test.tsx b/packages/react/src/enhancers/withComponentMap.test.tsx deleted file mode 100644 index 9f82a3deef..0000000000 --- a/packages/react/src/enhancers/withComponentMap.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -/* eslint-disable react-hooks/rules-of-hooks */ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable no-unused-expressions, @typescript-eslint/no-unused-expressions */ -import React from 'react'; -import { expect, use } from 'chai'; -import { spy } from 'sinon'; -import sinonChai from 'sinon-chai'; -import { render } from '@testing-library/react'; -import { withComponentMap } from './withComponentMap'; -import { ComponentMapReactContext } from '../components/SitecoreProvider'; - -use(sinonChai); - -describe('withComponentMap', () => { - it('should render component and pass componentMap from HOC props', () => { - const TestComponent = spy(({ componentMap }: { componentMap: any }) => ( -
{JSON.stringify(componentMap)}
- )); - const WrappedComponent = withComponentMap(TestComponent); - - const componentMapProp = { key: 'value' }; - const { getByText } = render(); - - expect(TestComponent).to.have.been.calledWithMatch({ componentMap: componentMapProp }); - expect(getByText(JSON.stringify(componentMapProp))).to.exist; - }); - - it('should render component and use componentMap from context as fallback', () => { - const TestComponent = spy(({ componentMap }: { componentMap: any }) => ( -
{JSON.stringify(componentMap)}
- )); - const WrappedComponent = withComponentMap(TestComponent); - - const contextValue = { fallbackKey: 'fallbackValue' }; - const { getByText } = render( - - - - ); - - expect(TestComponent).to.have.been.calledWithMatch({ componentMap: contextValue }); - expect(getByText(JSON.stringify(contextValue))).to.exist; - }); -}); diff --git a/packages/react/src/enhancers/withComponentMap.tsx b/packages/react/src/enhancers/withComponentMap.tsx deleted file mode 100644 index e466ca1330..0000000000 --- a/packages/react/src/enhancers/withComponentMap.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React, { JSX } from 'react'; -import { ComponentMapReactContext } from '../components/SitecoreProvider'; -import { useContext } from 'react'; -import { ComponentMap } from '../components/sharedTypes'; - -export interface WithComponentMapProps { - componentMap?: ComponentMap; -} - -/** - * @param {React.ComponentClass | React.FC} Component - */ -export function withComponentMap( - Component: React.ComponentClass | React.FC -) { - /** - * @param {T} props - props to pass to the wrapped component - * @returns {JSX.Element} - the rendered component - */ - function WithComponentMap(props: T): JSX.Element { - const context = useContext(ComponentMapReactContext); - - return ; - } - - WithComponentMap.displayName = `withComponentMap(${ - Component.displayName || Component.name || 'Anonymous' - })`; - - return WithComponentMap; -} diff --git a/packages/react/src/enhancers/withDatasourceCheck.tsx b/packages/react/src/enhancers/withDatasourceCheck.tsx index 68c34c2f78..aa10469e07 100644 --- a/packages/react/src/enhancers/withDatasourceCheck.tsx +++ b/packages/react/src/enhancers/withDatasourceCheck.tsx @@ -1,6 +1,7 @@ -import React, { JSX } from 'react'; +'use client'; +import React, { JSX } from 'react'; import { ComponentRendering } from '@sitecore-content-sdk/content/layout'; -import { useSitecore } from './withSitecore'; +import { useSitecore } from './../components/SitecoreProvider'; export const DefaultEditingError = (): JSX.Element => (
@@ -32,7 +33,7 @@ export function withDatasourceCheck(options?: WithDatasourceCheckOptions) { return function withDatasourceCheckHoc( Component: React.ComponentType ) { - return function WithDatasourceCheck(props: ComponentProps) { + return (props: ComponentProps) => { const { page } = useSitecore(); const EditingError = options?.editingErrorComponent ?? DefaultEditingError; diff --git a/packages/react/src/enhancers/withEditorChromes.tsx b/packages/react/src/enhancers/withEditorChromes.tsx index 31eb107224..af30951b14 100644 --- a/packages/react/src/enhancers/withEditorChromes.tsx +++ b/packages/react/src/enhancers/withEditorChromes.tsx @@ -1,4 +1,4 @@ -import React, { ComponentType } from 'react'; +import React, { ComponentType, useEffect, useRef } from 'react'; import { resetEditorChromes } from '..'; /** @@ -9,18 +9,24 @@ import { resetEditorChromes } from '..'; export const withEditorChromes = ( WrappedComponent: React.ComponentClass | React.FC ) => { - class Enhancer extends React.Component { - displayName: string = - (WrappedComponent as ComponentType).displayName || WrappedComponent.name || 'Component'; - - componentDidUpdate() { + const Enhancer = (props: Record) => { + const isFirstRender = useRef(true); + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + // only reset chromes on subsequent re-renders resetEditorChromes(); - } + }); + + return ; + }; - render() { - return ; - } - } + Enhancer.displayName = + (WrappedComponent as ComponentType).displayName || + (WrappedComponent as ComponentType).name || + 'Component'; - return Enhancer as React.ComponentClass; + return Enhancer; }; diff --git a/packages/react/src/enhancers/withLoadImportMap.test.tsx b/packages/react/src/enhancers/withLoadImportMap.test.tsx deleted file mode 100644 index c3d205db10..0000000000 --- a/packages/react/src/enhancers/withLoadImportMap.test.tsx +++ /dev/null @@ -1,160 +0,0 @@ -/* eslint-disable react-hooks/rules-of-hooks */ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable no-unused-expressions, @typescript-eslint/no-unused-expressions */ -import React from 'react'; -import { expect, use } from 'chai'; -import { spy } from 'sinon'; -import sinonChai from 'sinon-chai'; -import { render } from '@testing-library/react'; -import { withLoadImportMap, WithLoadImportMapProps, useLoadImportMap } from './withLoadImportMap'; -import { ImportMapReactContext } from '../components/SitecoreProvider'; -import { ImportMapImport } from '../components/DesignLibrary/models'; - -use(sinonChai); - -describe('withLoadImportMap', () => { - const mockImportMapData: ImportMapImport = { - default: [ - { - module: 'react', - exports: [ - { name: 'default', value: {} }, - { name: 'useState', value: {} }, - ], - }, - ], - }; - - const TestComponent = spy(({ loadImportMap }: WithLoadImportMapProps) => ( -
- {loadImportMap ? 'loadImportMap provided' : 'no loadImportMap'} -
- )); - - it('should render component and pass loadImportMap from HOC props', async () => { - const loadImportMapProp = spy(() => Promise.resolve(mockImportMapData)); - - const WrappedComponent = withLoadImportMap(TestComponent); - - const { getByTestId } = render(); - - expect(TestComponent).to.have.been.calledWithMatch({ loadImportMap: loadImportMapProp }); - expect(getByTestId('test-component')).to.exist; - expect(getByTestId('test-component').textContent).to.equal('loadImportMap provided'); - }); - - it('should render component and use loadImportMap from context as fallback', async () => { - const contextLoadImportMap = spy(() => Promise.resolve(mockImportMapData)); - - const WrappedComponent = withLoadImportMap(TestComponent); - - const { getByTestId } = render( - - - - ); - - expect(TestComponent).to.have.been.calledWithMatch({ loadImportMap: contextLoadImportMap }); - expect(getByTestId('test-component')).to.exist; - expect(getByTestId('test-component').textContent).to.equal('loadImportMap provided'); - }); - - it('should prioritize prop loadImportMap over context loadImportMap', async () => { - const propLoadImportMap = spy(() => Promise.resolve(mockImportMapData)); - const contextLoadImportMap = spy(() => - Promise.resolve({ default: [{ module: 'context-module', exports: [] }] }) - ); - - const WrappedComponent = withLoadImportMap(TestComponent); - - render( - - - - ); - - expect(TestComponent).to.have.been.calledWithMatch({ loadImportMap: propLoadImportMap }); - expect(TestComponent).to.not.have.been.calledWithMatch({ - loadImportMap: contextLoadImportMap, - }); - }); - - it('should render without loadImportMap when neither prop nor context provides it', () => { - const WrappedComponent = withLoadImportMap(TestComponent); - - const { getByTestId } = render(); - - expect(TestComponent).to.have.been.calledWithMatch({ loadImportMap: undefined }); - expect(getByTestId('test-component').textContent).to.equal('no loadImportMap'); - }); - - it('should handle loadImportMap function being called', async () => { - const loadImportMapProp = spy(() => Promise.resolve(mockImportMapData)); - - const TestComponent = ({ loadImportMap }: WithLoadImportMapProps) => { - const [data, setData] = React.useState('loading'); - - React.useEffect(() => { - if (loadImportMap) { - loadImportMap().then(() => setData('loaded')); - } - }, [loadImportMap]); - - return
{data}
; - }; - - const WrappedComponent = withLoadImportMap(TestComponent); - - const { getByTestId, findByText } = render( - - ); - - expect(getByTestId('test-component').textContent).to.equal('loading'); - - await findByText('loaded'); - - expect(loadImportMapProp).to.have.been.calledOnce; - expect(getByTestId('test-component').textContent).to.equal('loaded'); - }); -}); - -describe('useLoadImportMap', () => { - const mockImportMapData: ImportMapImport = { - default: [ - { - module: 'react', - exports: [ - { name: 'default', value: {} }, - { name: 'useState', value: {} }, - ], - }, - ], - }; - - const TestComponent = () => { - const loadImportMap = useLoadImportMap(); - return ( -
- {loadImportMap ? 'loadImportMap available' : 'no loadImportMap'} -
- ); - }; - - it('should return loadImportMap function from context', () => { - const contextLoadImportMap = spy(() => Promise.resolve(mockImportMapData)); - - const { getByTestId } = render( - - - - ); - - expect(getByTestId('test-hook').textContent).to.equal('loadImportMap available'); - }); - - it('should return undefined when context is not provided', () => { - const { getByTestId } = render(); - - expect(getByTestId('test-hook').textContent).to.equal('no loadImportMap'); - }); -}); diff --git a/packages/react/src/enhancers/withLoadImportMap.tsx b/packages/react/src/enhancers/withLoadImportMap.tsx deleted file mode 100644 index 910d15f181..0000000000 --- a/packages/react/src/enhancers/withLoadImportMap.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React, { useContext, JSX } from 'react'; -import { ImportMapReactContext } from '../components/SitecoreProvider'; -import { ImportMapImport } from '../components/DesignLibrary/models'; - -/** - * Props that include the loadImportMap function from context. - */ -export interface WithLoadImportMapProps { - /** - * The dynamic import for import map to be used in variant generation mode. - */ - loadImportMap?: () => Promise; -} - -/** - * Hook that retrieves the loadImportMap function from context. - * @returns {() => Promise | undefined} The loadImportMap function from context, or undefined if not available. - * @public - */ -export function useLoadImportMap(): (() => Promise) | undefined { - return useContext(ImportMapReactContext); -} - -/** - * Higher-order component that injects the loadImportMap function from context into component props. - * If the component already receives loadImportMap via props, the prop value takes precedence. - * @param {React.ComponentClass | React.FC} Component - The component to enhance. - * @returns {React.ComponentClass | React.FC} The enhanced component with loadImportMap injected. - */ -export function withLoadImportMap( - Component: React.ComponentClass | React.FC -) { - const WithLoadImportMap = (props: T): JSX.Element => { - const loadImportMapContext = useLoadImportMap(); - const loadClientImportMap = props.loadImportMap || loadImportMapContext; - return ; - }; - - WithLoadImportMap.displayName = `withLoadImportMap(${ - Component.displayName || Component.name || 'Component' - })`; - - return WithLoadImportMap; -} diff --git a/packages/react/src/enhancers/withPlaceholder.test.tsx b/packages/react/src/enhancers/withPlaceholder.test.tsx deleted file mode 100644 index 9473b5bf5d..0000000000 --- a/packages/react/src/enhancers/withPlaceholder.test.tsx +++ /dev/null @@ -1,494 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -/* eslint-disable no-unused-expressions */ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import React, { ReactElement, ReactNode } from 'react'; -import { expect } from 'chai'; -import { render } from '@testing-library/react'; -import { Page } from '@sitecore-content-sdk/content/client'; -import { LayoutServicePageState } from '@sitecore-content-sdk/content/layout'; -import { convertedDevData as normalModeDevData } from '../test-data/normal-mode-data'; -import * as metadataData from '../test-data/metadata-data'; -import { withPlaceholder } from '../enhancers/withPlaceholder'; -import { SitecoreProvider } from '../components/SitecoreProvider'; -import { PlaceholderProps } from '../components/PlaceholderCommon'; -import { ComponentRendering, RouteData } from '@sitecore-content-sdk/content/layout'; -import { Placeholder } from '../components/Placeholder'; -import { EnhancedOmit } from '@sitecore-content-sdk/core/tools'; - -type CalloutProps = PlaceholderProps & { - [prop: string]: unknown; - fields: { message: { value?: string } }; - subProp?: ReactElement; -}; - -type HomeProps = PlaceholderProps & { - [prop: string]: unknown; - rendering?: RouteData | ComponentRendering; - subProp?: ReactElement; -}; - -const DownloadCallout: React.FC = (props) => ( -
- {props.fields?.message ? props.fields.message.value : ''} -
-); - -const Home: React.FC = ({ name, subProp, ...otherProps }: HomeProps) => { - if (subProp && !otherProps.reset) { - return
{subProp}
; - } else { - return
{otherProps[name] as ReactNode}
; - } -}; - -const ErrorComponent: React.FC = () => { - throw 'Error!'; -}; - -const ErrorMessageComponent: React.FC = () => ( -
Your error has been... dealt with.
-); - -const delay = (timeout, promise?) => { - return new Promise((resolve) => { - setTimeout(resolve, timeout); - }).then(() => promise); -}; - -const componentMap = new Map>(); - -componentMap.set('DownloadCallout', DownloadCallout); -componentMap.set('Jumbotron', () =>
); -componentMap.set('BrokenComponent', () => { - throw new Error('BrokenComponent error'); -}); -componentMap.set( - 'DynamicComponent', - React.lazy(() => - delay(500, () => { - throw new Error('DynamicComponent error'); - }) - ) -); - -const testData = [{ label: 'Dev data', data: normalModeDevData }]; - -describe('withPlaceholder HOC', () => { - const api = { - edge: { - contextId: 'id', - edgeUrl: 'url', - clientContextId: 'clientId', - }, - local: { - apiKey: 'apiKey', - apiHost: 'apiHost', - path: 'path', - }, - }; - - const getPage = (): Page => ({ - layout: normalModeDevData, - locale: 'en', - mode: { - name: LayoutServicePageState.Normal, - isNormal: true, - isPreview: false, - isEditing: false, - isDesignLibrary: false, - designLibrary: { - isVariantGeneration: false, - }, - }, - }); - - describe('Error handling', () => { - before(() => { - // Set to development mode to show error details - process.env.NODE_ENV = 'development'; - }); - - it('should render default error component on wrapped component error', () => { - const phKey = 'page-content'; - const props: EnhancedOmit = { - name: phKey, - rendering: null as unknown as ComponentRendering, - }; - const Element = withPlaceholder(phKey)(ErrorComponent); - const renderedComponent = render( - - - - ); - expect( - renderedComponent.container.querySelectorAll('.sc-content-sdk-placeholder-error').length - ).to.equal(1); - }); - - it('should render custom component error on wrapped component error, when provided', () => { - const phKey = 'page-content'; - const props: EnhancedOmit = { - name: phKey, - rendering: null as unknown as ComponentRendering, - errorComponent: ErrorMessageComponent, - }; - const Element = withPlaceholder(phKey)(ErrorComponent); - const renderedComponent = render( - - - - ); - expect(renderedComponent.container.querySelectorAll('.error-handled').length).to.equal(1); - }); - - it('should render nested broken component', () => { - const component = ( - normalModeDevData.sitecore.route?.placeholders.main as (ComponentRendering | RouteData)[] - ).find((c) => (c as ComponentRendering).componentName) as ComponentRendering; - const phKey = 'page-content'; - const props: EnhancedOmit = { - name: phKey, - rendering: component, - }; - const Element = withPlaceholder(phKey)(Home); - const renderedComponent = render( - - - - ); - - expect( - renderedComponent.container.querySelectorAll('.download-callout-mock').length - ).to.equal(1); - expect( - renderedComponent.container.querySelectorAll('.sc-content-sdk-placeholder-error').length - ).to.equal(1); - expect(renderedComponent.container.querySelectorAll('h4').length).to.equal(1); - expect(renderedComponent.container.querySelector('h4')?.outerHTML).to.equal( - '

Loading component...

' - ); - }); - - it('should render nested components using custom error component', () => { - const component = ( - normalModeDevData.sitecore.route?.placeholders.main as (ComponentRendering | RouteData)[] - ).find((c) => (c as ComponentRendering).componentName) as ComponentRendering; - const phKey = 'page-content'; - const props: EnhancedOmit = { - name: phKey, - rendering: component, - errorComponent: ErrorMessageComponent, - componentLoadingMessage: 'Custom loading message...', - }; - const Element = withPlaceholder(phKey)(Home); - const renderedComponent = render( - - - - ); - - expect( - renderedComponent.container.querySelectorAll('.download-callout-mock').length - ).to.equal(1); - expect(renderedComponent.container.querySelectorAll('.error-handled').length).to.equal(1); - expect(renderedComponent.container.querySelectorAll('h4').length).to.equal(1); - expect(renderedComponent.container.querySelector('h4')?.outerHTML).to.equal( - '

Custom loading message...

' - ); - }); - }); - - testData.forEach((dataSet) => { - describe(`with ${dataSet.label}`, () => { - it('should render a placeholder with given key', () => { - const component = ( - dataSet.data.sitecore.route?.placeholders.main as (ComponentRendering | RouteData)[] - ).find((c) => (c as ComponentRendering).componentName) as ComponentRendering; - const phKey = 'page-content'; - const props: EnhancedOmit = { - name: phKey, - rendering: component, - }; - const Element = withPlaceholder(phKey)(Home); - const renderedComponent = render( - - - - ); - expect( - renderedComponent.container.querySelectorAll('.download-callout-mock').length - ).to.equal(1); - }); - - it('should render a placeholder with given key and prop', () => { - const component = ( - dataSet.data.sitecore.route?.placeholders.main as (ComponentRendering | RouteData)[] - ).find((c) => (c as ComponentRendering).componentName) as ComponentRendering; - const phKeyAndProp = { - placeholder: 'page-header', - prop: 'subProp', - }; - const props: EnhancedOmit = { - name: 'page-header', - rendering: component, - }; - const Element = withPlaceholder(phKeyAndProp)(Home); - const renderedComponent = render( - - - - ); - expect( - renderedComponent.container.querySelectorAll('.home-mock-with-prop').length - ).to.not.equal(0); - expect(renderedComponent.container.querySelectorAll('.jumbotron-mock').length).to.equal(1); - }); - - it('should use propsTransformer method when provided', () => { - const component = ( - dataSet.data.sitecore.route?.placeholders.main as (ComponentRendering | RouteData)[] - ).find((c) => (c as ComponentRendering).componentName) as ComponentRendering; - const phKeyAndProp = { - placeholder: 'page-header', - prop: 'subProp', - }; - const phOptions = { - propsTransformer: (props) => { - return { ...props, reset: true }; - }, - }; - const props: EnhancedOmit = { - name: 'page-header', - rendering: component, - }; - const Element = withPlaceholder(phKeyAndProp, phOptions)(Home); - const renderedComponent = render( - - - - ); - expect( - renderedComponent.container.querySelectorAll('.home-mock-with-prop').length - ).to.equal(0); - expect(renderedComponent.container.querySelectorAll('.home-mock').length).to.not.equal(0); - }); - }); - }); - - describe('Metadata Mode', () => { - const defaultPage = getPage(); - const editModePage: Page = { - ...defaultPage, - mode: { - ...defaultPage.mode, - name: LayoutServicePageState.Edit, - isEditing: true, - }, - }; - - const { - layoutData, - layoutDataWithEmptyPlaceholder, - layoutDataForNestedDynamicPlaceholder, - layoutDataWithUnknownComponent, - } = metadataData; - - const componentMap = new Map(); - - componentMap.set('Header', () => ( -
- -
- )); - componentMap.set('Logo', () =>
); - - it('should render a placeholder with given key', () => { - const component = layoutData.sitecore.route; - const phKey = 'main'; - const props: EnhancedOmit = { - name: phKey, - rendering: component, - }; - const Element = withPlaceholder(phKey)(Home); - const renderedComponent = render( - - - - ); - expect(renderedComponent?.container.innerHTML).to.equal( - [ - '
', - '', - '', - '
', - '', - '', - '
', - '', - '', - '
', - '', - '', - '
', - ].join('') - ); - }); - - it('should render a placeholder with given key and prop', () => { - const component = layoutData.sitecore.route; - const phKey = 'main'; - const phKeyAndProp = { - placeholder: phKey, - prop: 'subProp', - }; - const props: EnhancedOmit = { - name: phKey, - rendering: component, - }; - const Element = withPlaceholder(phKeyAndProp)(Home); - const renderedComponent = render( - - - - ); - - expect(renderedComponent?.container.innerHTML).to.equal( - [ - '
', - '', - '', - '
', - '', - '', - '
', - '', - '', - '
', - '', - '', - '
', - ].join('') - ); - }); - - it('should render code blocks even if placeholder is empty', () => { - const component = layoutDataWithEmptyPlaceholder.sitecore.route; - const phKey = 'main'; - const props: EnhancedOmit = { - name: phKey, - rendering: component, - }; - const Element = withPlaceholder(phKey)(Home); - const renderedComponent = render( - - - - ); - - expect(renderedComponent?.container.innerHTML).to.equal( - [ - '
', - '', - '', - '
', - ].join('') - ); - }); - - it('should render missing component with code blocks if component is not registered', () => { - const component = layoutDataWithUnknownComponent.sitecore.route; - const phKey = 'main'; - const props: EnhancedOmit = { - name: phKey, - rendering: component, - }; - const Element = withPlaceholder(phKey)(Home); - const renderedComponent = render( - - - - ); - - expect(renderedComponent?.container.innerHTML).to.equal( - [ - '
', - '', - '', - '

Unknown

Content SDK component is missing React implementation. See the developer console for more information.

', - '', - '', - '
', - ].join('') - ); - }); - - it('should render dynamic placeholder', () => { - const phKey = 'container-1'; - const layoutData = layoutDataForNestedDynamicPlaceholder('container-{*}'); - const component = layoutData.sitecore.route; - const props: EnhancedOmit = { - name: phKey, - rendering: component, - }; - const Element = withPlaceholder(phKey)(Home); - const renderedComponent = render( - - - - ); - - expect(renderedComponent?.container.innerHTML).to.equal( - [ - '
', - '', - '', - '
', - '', - '', - '
', - '', - '
', - '', - '', - '
', - ].join('') - ); - }); - - it('should render double digit dynamic placeholder', () => { - const phKey = 'container-1-2'; - const layoutData = layoutDataForNestedDynamicPlaceholder('container-1-{*}'); - const component = layoutData.sitecore.route; - const props: EnhancedOmit = { - name: phKey, - rendering: component, - }; - const Element = withPlaceholder(phKey)(Home); - const renderedComponent = render( - - - - ); - - expect(renderedComponent?.container.innerHTML).to.equal( - [ - '
', - '', - '', - '
', - '', - '', - '
', - '', - '
', - '', - '', - '
', - ].join('') - ); - }); - }); -}); diff --git a/packages/react/src/enhancers/withPlaceholder.tsx b/packages/react/src/enhancers/withPlaceholder.tsx deleted file mode 100644 index 1dc4e8ff6d..0000000000 --- a/packages/react/src/enhancers/withPlaceholder.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import React from 'react'; -import { ComponentRendering, RouteData } from '@sitecore-content-sdk/content/layout'; -import { withComponentMap } from './withComponentMap'; -import { withSitecore } from './withSitecore'; -import { - PlaceholderComponent, - PlaceholderProps, - getPlaceholderRenderings, -} from '../components/Placeholder'; -import { ErrorComponent } from '../components/ErrorBoundary'; - -export interface WithPlaceholderOptions { - /** - * Function to map incoming placeholder props into rendering data to use for the placeholder data. - * Normally in a Content SDK component, props.rendering is passed the component data, and that is the default. - * However, if your component data is in a different prop, like say 'route' in a sample app, - * this lets you map that. - */ - resolvePlaceholderDataFromProps?: (props: unknown) => ComponentRendering | RouteData; - /** - * Function to alter the placeholder props from within the HOC. Enables the props to be - * transformed before being used by the placeholder/HOC, for example to customize the - * error or missing component display - */ - propsTransformer?: (props: PlaceholderProps) => PlaceholderProps; -} - -export interface PlaceholderToPropMapping { - /** - * The name of the placeholder this component will expose - */ - placeholder: string; - /** - * The name of the prop on your wrapped component that you would like the placeholder data injected on - */ - prop: string; -} - -export type WithPlaceholderSpec = - | (string | PlaceholderToPropMapping) - | (string | PlaceholderToPropMapping)[]; - -/** - * HOC to provide client-side placeholder functionality to a component. - * @param {WithPlaceholderSpec} placeholders - * @param {WithPlaceholderOptions} [options] - * @public - */ -export function withPlaceholder( - placeholders: WithPlaceholderSpec, - options?: WithPlaceholderOptions -) { - return ( - WrappedComponent: - | React.ComponentClass - | React.FunctionComponent - ) => { - class WithPlaceholder extends PlaceholderComponent { - constructor(props: PlaceholderProps) { - super(props); - } - - render() { - let childProps: PlaceholderProps = { ...this.props }; - - delete childProps.componentMap; - - if (options && options.propsTransformer) { - childProps = options.propsTransformer(childProps); - } - - if (this.state.error) { - if (childProps.errorComponent) { - return ; - } - - return ( - - ); - } - - const renderingData = - options && options.resolvePlaceholderDataFromProps - ? options.resolvePlaceholderDataFromProps(childProps) - : childProps.rendering; - - const definitelyArrayPlacholders = !Array.isArray(placeholders) - ? [placeholders] - : placeholders; - - definitelyArrayPlacholders.forEach((placeholder: string | PlaceholderToPropMapping) => { - let placeholderData: ComponentRendering[]; - - if (typeof placeholder !== 'string' && placeholder.placeholder && placeholder.prop) { - placeholderData = getPlaceholderRenderings( - renderingData, - placeholder.placeholder, - childProps.page.mode.isEditing - ); - if (placeholderData) { - (childProps as PlaceholderProps & Record)[placeholder.prop] = - PlaceholderComponent.getRenderedComponents(this.props, placeholderData); - } - } else { - placeholderData = getPlaceholderRenderings( - renderingData, - placeholder as string, - childProps.page.mode.isEditing - ); - if (placeholderData) { - (childProps as PlaceholderProps & Record)[placeholder as string] = - PlaceholderComponent.getRenderedComponents(this.props, placeholderData); - } - } - }); - - return ; - } - } - - return withSitecore()(withComponentMap(WithPlaceholder)); - }; -} diff --git a/packages/react/src/enhancers/withSitecore.test.tsx b/packages/react/src/enhancers/withSitecore.test.tsx index 1a979b7a86..dc79354299 100644 --- a/packages/react/src/enhancers/withSitecore.test.tsx +++ b/packages/react/src/enhancers/withSitecore.test.tsx @@ -5,11 +5,11 @@ import { expect, use } from 'chai'; import { fireEvent, render } from '@testing-library/react'; import { spy } from 'sinon'; import sinonChai from 'sinon-chai'; - -import { useSitecore, withSitecore, WithSitecoreProps } from '../enhancers/withSitecore'; +import { withSitecore, WithSitecoreProps } from '../enhancers/withSitecore'; import { SitecoreProviderReactContext, SitecoreProviderState, + useSitecore, } from '../components/SitecoreProvider'; import { LayoutServicePageState } from '@sitecore-content-sdk/content/layout'; @@ -60,7 +60,7 @@ describe('withSitecore', () => { it('withSitecore()', () => { const TestComponent: React.FC = (props: WithSitecoreProps & { customProp: string }) => ( <> -
+
{props.page.locale} {props.customProp}
@@ -109,7 +109,7 @@ describe('withSitecore', () => { return ( <> -
+
{page.locale} {props.customProp}
@@ -142,7 +142,7 @@ describe('withSitecore', () => { const context = reactContext.page; return ( -
+
{context.locale} {props.customProp}
diff --git a/packages/react/src/enhancers/withSitecore.tsx b/packages/react/src/enhancers/withSitecore.tsx index 99a5981176..4772148d06 100644 --- a/packages/react/src/enhancers/withSitecore.tsx +++ b/packages/react/src/enhancers/withSitecore.tsx @@ -1,23 +1,13 @@ -'use client'; +'use client'; import React from 'react'; import { EnhancedOmit } from '@sitecore-content-sdk/core/tools'; import { - SitecoreProviderReactContext, SitecoreProviderState, + useSitecore, + UseSitecoreOptions, } from '../components/SitecoreProvider'; import { Page } from '@sitecore-content-sdk/content/client'; -/** - * The options for the withSitecore HOC. - * @public - */ -export interface WithSitecoreOptions { - /** - * If set to true, the `updateContext` method will be injected into the component props. - */ - updatable?: boolean; -} - /** * The props that HOC will inject. * @public @@ -36,7 +26,7 @@ export interface WithSitecoreProps { * @param {Page} value New page value. * @returns {void} */ - updatePage?: ((value: Page) => void) | false; + setPage?: ((value: Page) => void) | false; } /** @@ -52,51 +42,20 @@ export type WithSitecoreHocProps = EnhancedOmit< * @param {WithSitecoreProviderOptions} [options] * @public */ -export function withSitecore(options?: WithSitecoreOptions) { +export function withSitecore(options?: UseSitecoreOptions) { return function withSitecoreProviderHoc( Component: React.ComponentType ) { return function WithSitecoreProvider(props: WithSitecoreHocProps) { + const scContext = useSitecore(options); return ( - - {(value) => ( - - )} - + ); }; }; } - -/** - * This hook grants acсess to the current Sitecore page and api. - * @param {WithSitecoreOptions} [options] hook options - * @example - * const EditMode = () => { - * const { page } = useSitecore(); - * return Edit Mode is {page.mode.isEditing ? 'active' : 'inactive'} - * } - * @example - * const EditMode = () => { - * const { page, updatePage } = useSitecore({ updatable: true }); - * const onClick = () => updatePage({ itemId: '123' }); - * return Item id is {page.itemId} - * } - * @returns {object} { api, page, updatePage } - * @public - */ -export function useSitecore(options?: WithSitecoreOptions): WithSitecoreProps { - const reactContext = React.useContext(SitecoreProviderReactContext); - const updatable = options?.updatable; - - return { - api: reactContext.api, - page: reactContext.page, - updatePage: updatable ? reactContext.setPage : undefined, - }; -} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 916070b820..0948ea0538 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -87,18 +87,13 @@ export { SitecoreProviderState, SitecoreProviderReactContext, } from './components/SitecoreProvider'; -export { - withSitecore, - useSitecore, - WithSitecoreOptions, - WithSitecoreProps, - WithSitecoreHocProps, -} from './enhancers/withSitecore'; export { withEditorChromes } from './enhancers/withEditorChromes'; -export { withPlaceholder } from './enhancers/withPlaceholder'; export { withDatasourceCheck } from './enhancers/withDatasourceCheck'; export { withFieldMetadata } from './enhancers/withFieldMetadata'; export { withEmptyFieldEditingComponent } from './enhancers/withEmptyFieldEditingComponent'; +export { useComponentMap } from './components/SitecoreProvider'; +export { withAppPlaceholder } from './enhancers/withAppPlaceholder'; +export { withClientPlaceholder } from './enhancers/withClientPlaceholder'; export { EditingScripts } from './components/EditingScripts'; export { DefaultEmptyFieldEditingComponentText, diff --git a/packages/react/src/search/utils.test.tsx b/packages/react/src/search/utils.test.tsx index b46fa09575..9647732b4a 100644 --- a/packages/react/src/search/utils.test.tsx +++ b/packages/react/src/search/utils.test.tsx @@ -7,8 +7,8 @@ import { render, waitFor } from '@testing-library/react'; import { SitecoreProviderReactContext, SitecoreProviderState, + useSitecore, } from '../components/SitecoreProvider'; -import { useSitecore } from '../enhancers/withSitecore'; import { getOffset, useSearchService } from './utils'; describe('search utils', () => { diff --git a/packages/react/src/search/utils.ts b/packages/react/src/search/utils.ts index be47470b36..28c81864f1 100644 --- a/packages/react/src/search/utils.ts +++ b/packages/react/src/search/utils.ts @@ -1,6 +1,6 @@ import { SearchService } from '@sitecore-content-sdk/search'; import { useMemo } from 'react'; -import { useSitecore } from '../enhancers/withSitecore'; +import { useSitecore } from '../components/SitecoreProvider'; export const DEFAULT_PAGE_SIZE = 10;