diff --git a/CHANGELOG.md b/CHANGELOG.md index e179bbf896..0efb60825e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ Our versioning strategy is as follows: > ⚠️ **JSS 23 supports Sitecore XP 10.5 only. Sitecore AI is not supported - use Sitecore Content SDK for that scenario.** +### 🎉 New Features & Improvements + +* `[sitecore-jss]` `[sitecore-jss-react]` Treat Layout Service `isContentResolved` as datasource validity in `withDatasourceCheck()` ([#2219](https://github.com/Sitecore/jss/pull/2219)) + ### 🐛 Bug Fixes * `[sitecore-jss-react]` Suppress hydration mismatch warnings in Experience Editor by adding `suppressHydrationWarning` to SDK chrome paths (ErrorBoundary, placeholders, field components)([#2218](https://github.com/Sitecore/jss/pull/2218)) diff --git a/packages/sitecore-jss-react/src/enhancers/withDatasourceCheck.test.tsx b/packages/sitecore-jss-react/src/enhancers/withDatasourceCheck.test.tsx index f2d81852c5..296d1675ba 100644 --- a/packages/sitecore-jss-react/src/enhancers/withDatasourceCheck.test.tsx +++ b/packages/sitecore-jss-react/src/enhancers/withDatasourceCheck.test.tsx @@ -146,4 +146,217 @@ describe('withDatasourceCheck', () => { expect(wrapper.container.innerHTML).to.contain(props.rendering.componentName); expect(wrapper.container.innerHTML).to.contain(props.rendering.dataSource); }); + + it('should return null if no datasource is configured on the rendering', () => { + const TestComponentWithDatasourceCheck = withDatasourceCheck()(TestComponent); + const props = { + rendering: { + componentName: 'TestComponent', + }, + }; + + const wrapper = render( + + + + ); + + expect(wrapper.container.innerHTML).to.be.empty; + }); + + it('should return wrapped component when isContentResolved is true', () => { + const TestComponentWithDatasourceCheck = withDatasourceCheck()(TestComponent); + const props = { + rendering: { + componentName: 'TestComponent', + dataSource: '{CACDB205-2386-4271-9F05-AE20AAC2A39E}', + isContentResolved: true, + }, + }; + + const wrapper = render( + + + + ); + + expect(wrapper.container.innerHTML).to.contain(props.rendering.componentName); + expect(wrapper.container.innerHTML).to.contain(props.rendering.dataSource); + }); + + it('should return wrapped component when isContentResolved is true in editing mode', () => { + const TestComponentWithDatasourceCheck = withDatasourceCheck()(TestComponent); + const props = { + rendering: { + componentName: 'TestComponent', + dataSource: '{CACDB205-2386-4271-9F05-AE20AAC2A39E}', + isContentResolved: true, + }, + }; + + const wrapper = render( + + + + ); + + expect(wrapper.container.innerHTML).to.contain(props.rendering.componentName); + expect(wrapper.container.innerHTML).to.contain(props.rendering.dataSource); + }); + + it('should return null when isContentResolved is false in normal mode', () => { + const TestComponentWithDatasourceCheck = withDatasourceCheck()(TestComponent); + const props = { + rendering: { + componentName: 'TestComponent', + dataSource: '{CACDB205-2386-4271-9F05-AE20AAC2A39E}', + isContentResolved: false, + }, + }; + + const wrapper = render( + + + + ); + + expect(wrapper.container.innerHTML).to.be.empty; + }); + + it('should return default error component when isContentResolved is false in editing mode', () => { + const TestComponentWithDatasourceCheck = withDatasourceCheck()(TestComponent); + const props = { + rendering: { + componentName: 'TestComponent', + dataSource: '{CACDB205-2386-4271-9F05-AE20AAC2A39E}', + isContentResolved: false, + }, + }; + + const wrapper = render( + + + + ); + + expect(wrapper.container.querySelectorAll('div.sc-jss-editing-error')).to.have.length(1); + }); + + it('should not render when the datasource item was deleted and isContentResolved is false', () => { + const TestComponentWithDatasourceCheck = withDatasourceCheck()(TestComponent); + const props = { + rendering: { + componentName: 'TestComponent', + dataSource: '{DELETED-DATASOURCE-ID}', + isContentResolved: false, + }, + }; + + const wrapper = render( + + + + ); + + expect(wrapper.container.innerHTML).to.be.empty; + }); + + it('should not render when the datasource item was archived and isContentResolved is false', () => { + const TestComponentWithDatasourceCheck = withDatasourceCheck()(TestComponent); + const props = { + rendering: { + componentName: 'TestComponent', + dataSource: '{ARCHIVED-DATASOURCE-ID}', + isContentResolved: false, + }, + }; + + const wrapper = render( + + + + ); + + expect(wrapper.container.innerHTML).to.be.empty; + }); + + it('should preserve existing missing-datasource behavior when isContentResolved is true', () => { + const TestComponentWithDatasourceCheck = withDatasourceCheck()(TestComponent); + const props = { + rendering: { + componentName: 'TestComponent', + dataSource: '', + isContentResolved: true, + }, + }; + + const wrapper = render( + + + + ); + + expect(wrapper.container.innerHTML).to.be.empty; + }); + + it('should preserve existing behavior when isContentResolved is absent', () => { + const TestComponentWithDatasourceCheck = withDatasourceCheck()(TestComponent); + const propsWithDatasource = { + rendering: { + componentName: 'TestComponent', + dataSource: '{CACDB205-2386-4271-9F05-AE20AAC2A39E}', + }, + }; + const propsWithoutDatasource = { + rendering: { + componentName: 'TestComponent', + dataSource: '', + }, + }; + + const rendered = render( + + + + ); + const hidden = render( + + + + ); + + expect(rendered.container.innerHTML).to.contain(propsWithDatasource.rendering.componentName); + expect(hidden.container.innerHTML).to.be.empty; + }); + + it('should evaluate nested placeholder renderings independently', () => { + const TestComponentWithDatasourceCheck = withDatasourceCheck()(TestComponent); + const nestedChild = { + componentName: 'ChildComponent', + dataSource: '{CHILD-DATASOURCE-ID}', + isContentResolved: false, + }; + const parentRendering = { + componentName: 'ParentComponent', + dataSource: '{PARENT-DATASOURCE-ID}', + isContentResolved: true, + placeholders: { + nested: [nestedChild], + }, + }; + + const parentWrapper = render( + + + + ); + const childWrapper = render( + + + + ); + + expect(parentWrapper.container.innerHTML).to.contain(parentRendering.componentName); + expect(childWrapper.container.innerHTML).to.be.empty; + }); }); diff --git a/packages/sitecore-jss-react/src/enhancers/withDatasourceCheck.tsx b/packages/sitecore-jss-react/src/enhancers/withDatasourceCheck.tsx index 390ecaf91c..6430f9ce65 100644 --- a/packages/sitecore-jss-react/src/enhancers/withDatasourceCheck.tsx +++ b/packages/sitecore-jss-react/src/enhancers/withDatasourceCheck.tsx @@ -14,18 +14,40 @@ export interface WithDatasourceCheckProps { export interface WithDatasourceCheckOptions { /** - * A component that is rendered when a datasource is missing during editing. + * A component that is rendered when a datasource is missing or failed to resolve during editing. * If unspecified, a default component with message is displayed. */ editingErrorComponent?: React.ComponentClass | React.FC; } /** - * Checks whether a Sitecore datasource is present and renders appropriately depending on page mode (normal vs editing). + * Returns true when the rendering has a datasource and Layout Service did not report unresolved content. + * @param {ComponentRendering} [rendering] rendering data from Layout Service + * @returns {boolean} whether the datasource is present and valid + */ +function hasValidDatasource(rendering?: ComponentRendering): boolean { + if (!rendering?.dataSource) { + return false; + } + + return rendering.isContentResolved !== false; +} + +/** + * Checks whether a Sitecore datasource is present and valid, then renders appropriately depending on page mode (normal vs editing). + * `isContentResolved: false` is treated the same as a missing datasource. If the property is omitted, the original presence check is used. * @param {WithDatasourceCheckOptions} [options] * @returns - * The wrapped component, if a datasource is present. - * A null component (in normal mode) or an error component (in editing mode), if a datasource is not present. + * The wrapped component, if a datasource is present and valid. + * A null component (in normal mode) or an error component (in editing mode), if a datasource is missing or failed to resolve. + * @example + * // Wrap once. Deleted/archived datasources (isContentResolved: false) use the same + * // fallback as a missing datasource: hide in normal mode, show an editing error in editing mode. + * const ContentBlock = (props) =>
{props.fields.heading}
; + * export default withDatasourceCheck()(ContentBlock); + * + * // Layout Service: { componentName: 'ContentBlock', dataSource: '{id}', isContentResolved: false } + * // → ContentBlock is not rendered; no extra app-level check is required. */ export function withDatasourceCheck(options?: WithDatasourceCheckOptions) { return function withDatasourceCheckHoc( @@ -35,7 +57,7 @@ export function withDatasourceCheck(options?: WithDatasourceCheckOptions) { const { sitecoreContext } = useSitecoreContext(); const EditingError = options?.editingErrorComponent ?? DefaultEditingError; - return props.rendering?.dataSource ? ( + return hasValidDatasource(props.rendering) ? ( ) : sitecoreContext.pageEditing ? ( diff --git a/packages/sitecore-jss/src/layout/models.ts b/packages/sitecore-jss/src/layout/models.ts index a5d70b304f..d38c90c8a1 100644 --- a/packages/sitecore-jss/src/layout/models.ts +++ b/packages/sitecore-jss/src/layout/models.ts @@ -85,6 +85,12 @@ export interface ComponentParams { export interface ComponentRendering { componentName: string; dataSource?: string; + /** + * `true` when Layout Service resolved this rendering's datasource content. + * `false` when resolution failed (for example because the item was deleted or archived). + * Omitted by older Layout Service versions; absence preserves existing behavior. + */ + isContentResolved?: boolean; uid?: string; placeholders?: PlaceholdersData; fields?: T;