Reusable partial design component for SitecoreAI
Previously, Sitecore Experience Accelerator (SXA) supported Snippets, which allowed content made up of multiple renderings to be reused across a site.
SitecoreAI has a similar concept called partial designs. Components can be
composed and reused across pages, but they take over the placeholder they live
in. For example, if you create a header partial design and place it in the
headless-header placeholder for a page design, individual pages cannot put
additional components within that placeholder. This limits partial designs to
fixed placeholders instead of letting you use them anywhere.
Instead, I wanted to use partial designs like components: drop one into any placeholder, around any other component. After some research, I found I could fetch the contents of a partial design and create my own component.
Warning
This is not the intended usage of partial designs and may not be supported. Use at your own risk.
Partial design component
The component uses a Datasource Location that matches where partial designs are created by default:
query:$site/*[@@name='Presentation']/*[@@templatename='Partial Designs']|query:$sharedSites/*[@@name='Presentation']/*[@@templatename='Partial Designs']
Then set the Datasource Template to Partial Design:
/sitecore/templates/Foundation/JSS Experience Accelerator/Presentation/Partial Design
Code
// PartialDesignComponent.tsx
import { useContext } from 'react';
import {
type ComponentParams,
ComponentPropsContext,
ComponentPropsReactContext,
type ComponentRendering,
type GetComponentServerProps,
type LayoutServiceData,
Placeholder,
type PlaceholdersData,
} from '@sitecore-content-sdk/nextjs';
import componentMap from '@/../.sitecore/component-map';
import { getGraphQlClient } from '@/utils/graphql-client';
// Placeholder where components are added within the partial design.
const MAIN_PLACEHOLDER = 'headless-main';
// GraphQL query to fetch the partial design "page".
const GET_PARTIAL_DESIGN_QUERY = `
query GetPartialDesign($datasource: String!, $language: String!) {
item(path: $datasource, language: $language) {
rendered
}
}
`;
type ComponentProps = {
item?: {
rendered: {
sitecore: {
route: {
name: string;
placeholders: {
[MAIN_PLACEHOLDER]: ComponentRendering[];
};
};
};
};
};
params: ComponentParams;
dataSource?: string;
/** Merged getComponentServerProps results for each child rendering (keyed by uid) */
componentProps?: Record<string, unknown>;
};
type PartialDesignResponse = {
item: ComponentProps['item'];
};
type ComponentMapEntry = {
getComponentServerProps?: GetComponentServerProps;
dynamicModule?: () => Promise<unknown>;
};
/** Recursively flatten all renderings from placeholders */
function flattenRenderings(placeholders: PlaceholdersData | undefined): ComponentRendering[] {
if (!placeholders) return [];
const list: ComponentRendering[] = [];
for (const renderings of Object.values(placeholders)) {
for (const r of renderings) {
list.push(r);
if (r.placeholders) {
list.push(...flattenRenderings(r.placeholders));
}
}
}
return list;
}
/** Resolve component module (handles dynamicModule) */
async function getResolvedModule(
components: Map<string, ComponentMapEntry>,
componentName: string,
): Promise<{ getComponentServerProps?: GetComponentServerProps } | null> {
const component = components.get(componentName);
if (!component) return null;
const resolved = component.dynamicModule == null ? component : await component.dynamicModule();
return resolved as { getComponentServerProps?: GetComponentServerProps };
}
export const Default = ({ item, params, componentProps: partialChildProps }: ComponentProps) => {
const componentPropsContext = useContext(ComponentPropsReactContext);
if (!item?.rendered?.sitecore?.route) {
return null;
}
const { name: routeName, placeholders } = item.rendered.sitecore.route;
const { DynamicPlaceholderId, styles } = params;
const placeholderName = `partial-design-component-${DynamicPlaceholderId}`;
const rendering = {
name: routeName,
placeholders: {
[placeholderName]: placeholders[MAIN_PLACEHOLDER],
},
};
// Merge our child getComponentServerProps results into context so Placeholder lookups find them
const mergedComponentProps = {
...componentPropsContext,
...partialChildProps,
};
return (
<div className={`component ${styles}`}>
<ComponentPropsContext value={mergedComponentProps}>
<Placeholder name={placeholderName} rendering={rendering} />
</ComponentPropsContext>
</div>
);
};
export const getComponentServerProps: GetComponentServerProps = async (
rendering,
layoutData,
context,
) => {
if (!rendering.dataSource) {
return {};
}
const graphQLClient = getGraphQlClient();
const result = await graphQLClient.request<PartialDesignResponse>(GET_PARTIAL_DESIGN_QUERY, {
datasource: rendering.dataSource,
language: layoutData?.sitecore?.context?.language,
});
// Return an empty object if the item is missing.
if (!result?.item) {
return {};
}
const placeholders = result.item?.rendered?.sitecore?.route?.placeholders;
const childRenderings = flattenRenderings(placeholders);
const componentProps: Record<string, unknown> = {};
// Get the results from every component that uses `getComponentServerProps`.
await Promise.all(
childRenderings.map(async (childRendering) => {
const uid = childRendering.uid;
if (!uid) return;
const resolvedModule = await getResolvedModule(
componentMap as Map<string, ComponentMapEntry>,
childRendering.componentName,
);
if (!resolvedModule?.getComponentServerProps) return;
const childProps = await resolvedModule.getComponentServerProps(
childRendering,
(layoutData ?? { sitecore: { route: null } }) as LayoutServiceData,
context,
);
componentProps[uid] = childProps ?? {};
}),
);
return {
...result,
dataSource: rendering.dataSource,
componentProps,
};
};