Skip to content

Commit

Permalink
docs(headless commerce SSR): add parameter manager in SSR + nextjs sa…
Browse files Browse the repository at this point in the history
  • Loading branch information
fbeaudoincoveo authored Dec 16, 2024
1 parent f65d022 commit 208e054
Show file tree
Hide file tree
Showing 13 changed files with 203 additions and 32 deletions.
9 changes: 9 additions & 0 deletions packages/headless-react/src/ssr-commerce/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ export function buildProviderWithDefinition(looseDefinition: LooseDefinition) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const hydrateControllers: Record<string, any> = {};

if ('parameterManager' in controllers) {
hydrateControllers.parameterManager = {
initialState: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
parameters: (controllers as any).parameterManager.state.parameters,
},
};
}

if ('cart' in controllers) {
hydrateControllers.cart = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,15 @@ export function defineParameterManager<
throw loadReducerError;
}
return buildProductListing(engine).parameterManager({
initialState: props.state ? props.state : props.initialState,
...props,
excludeDefaultParameters: true,
});
} else {
if (!loadCommerceSearchParameterReducers(engine)) {
throw loadReducerError;
}
return buildSearch(engine).parameterManager({
initialState: props.state ? props.state : props.initialState,
...props,
excludeDefaultParameters: true,
});
}
Expand All @@ -77,9 +77,7 @@ export function defineParameterManager<
}

export interface SSRParameterManagerProps<T extends Parameters>
extends Omit<ParameterManagerProps<T>, 'excludeDefaultParameters'> {
state?: ParameterManagerState<T>;
}
extends Omit<ParameterManagerProps<T>, 'excludeDefaultParameters'> {}

type MappedParameterTypes<
TOptions extends ControllerDefinitionOption | undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ describe('pagination slice', () => {
);
});

it('does not restores principal pagination perPage when perPage parameter is undefined', () => {
it('does not restore principal pagination perPage when perPage parameter is undefined', () => {
const parameters = {
page: undefined,
perPage: undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ describe('commerceParameters slice', () => {
});

describe('when payload.slotId is undefined', () => {
it('when state.page is defined, decrements state.page', () => {
it('when state.page is greater than 1, decrements state.page', () => {
const state = {
...initialState,
page: 2,
Expand All @@ -106,6 +106,24 @@ describe('commerceParameters slice', () => {
expect(finalState).toEqual({...state, page: 1});
});

it('when state.page is 1, sets state.page to undefined', () => {
const state = {
...initialState,
page: 1,
};
const finalState = parametersReducer(state, previousPage());
expect(finalState).toEqual({...state, page: undefined});
});

it('when state.page is 0, sets state.page to undefined', () => {
const state = {
...initialState,
page: 0,
};
const finalState = parametersReducer(state, previousPage());
expect(finalState).toEqual({...state, page: undefined});
});

it('when state.page is undefined, set state.page to undefined', () => {
const finalState = parametersReducer(initialState, previousPage());
expect(finalState).toEqual({...initialState, page: undefined});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ const handlePreviousPage = (
return;
}

if (state.page !== undefined) {
if (state.page !== undefined && state.page > 1) {
state.page--;
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ export default function ProductsPerPage(props: IProductsPerPageProps) {

const [state, setState] = useState(controller.state);

const options = [5, 10, 20, 50, 0];

useEffect(() => {
controller.subscribe(() => setState(controller.state));
}, [controller]);

const options = [5, 10, 20, 50];
return (
<span className="ProductsPerPage">
<span className="ProductsPerPageLabel">Products per page:</span>
Expand All @@ -23,34 +24,22 @@ export default function ProductsPerPage(props: IProductsPerPageProps) {
return (
<span key={id}>
<input
checked={state.pageSize === pageSize}
checked={
state.pageSize === pageSize ||
(pageSize === 0 && !options.includes(state.pageSize))
}
id={id}
name={`pageSize-${pageSize}`}
onChange={() => controller.setPageSize(pageSize)}
type="radio"
value={pageSize}
/>
<label className="ProductsPerPageOption" htmlFor={id}>
{pageSize}
{pageSize === 0 ? 'Default' : pageSize}
</label>
</span>
);
})}
<span className="ProductsPerPageOptionOther">
<input
checked={state.pageSize === 0 || !options.includes(state.pageSize)}
id="page-size-other"
name="page"
onChange={() => controller.setPageSize(0)}
type="radio"
value={state.pageSize}
/>
<label htmlFor="page-size-other" key={0}>
{state.pageSize !== 0 && !options.includes(state.pageSize)
? `Other (${state.pageSize})`
: 'Default'}
</label>
</span>
</span>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import Cart from '@/components/cart';
import ContextDropdown from '@/components/context-dropdown';
import FacetGenerator from '@/components/facets/facet-generator';
import Pagination from '@/components/pagination';
import ParameterManager from '@/components/parameter-manager';
import ProductList from '@/components/product-list';
import ProductsPerPage from '@/components/products-per-page';
import {
ListingProvider,
RecommendationProvider,
Expand All @@ -20,6 +22,7 @@ import {
} from '@/lib/commerce-engine';
import {NextJsNavigatorContext} from '@/lib/navigatorContextProvider';
import {defaultContext} from '@/utils/context';
import {buildParameterSerializer} from '@coveo/headless-react/ssr-commerce';
import {headers} from 'next/headers';
import {notFound} from 'next/navigation';

Expand All @@ -30,7 +33,13 @@ const categoryList = ['surf-accessories', 'paddleboards', 'toys'];
*
* The Listing function is the entry point for server-side rendering (SSR).
*/
export default async function Listing({params}: {params: {category: string}}) {
export default async function Listing({
params,
searchParams,
}: {
params: {category: string};
searchParams: Promise<URLSearchParams>;
}) {
const {category} = params;

const matchedCategory = categoryList.find((c) => c === category);
Expand All @@ -43,6 +52,9 @@ export default async function Listing({params}: {params: {category: string}}) {
const navigatorContext = new NextJsNavigatorContext(headers());
listingEngineDefinition.setNavigatorContextProvider(() => navigatorContext);

const {deserialize} = buildParameterSerializer();
const parameters = deserialize(await searchParams);

// Fetches the cart items from an external service
const items = await externalCartAPI.getCart();

Expand All @@ -58,6 +70,7 @@ export default async function Listing({params}: {params: {category: string}}) {
url: `https://sports.barca.group/browse/promotions/${matchedCategory}`,
},
},
parameterManager: {initialState: {parameters}},
},
});

Expand All @@ -84,6 +97,7 @@ export default async function Listing({params}: {params: {category: string}}) {
staticState={staticState}
navigatorContext={navigatorContext.marshal}
>
<ParameterManager url={navigatorContext.location} />
<ContextDropdown useCase="listing" />
<div style={{display: 'flex', flexDirection: 'row'}}>
<div style={{flex: 1}}>
Expand All @@ -98,6 +112,7 @@ export default async function Listing({params}: {params: {category: string}}) {
<ProductList />
{/* The ShowMore and Pagination components showcase two frequent ways to implement pagination. */}
<Pagination />
<ProductsPerPage />
{/* <ShowMore
staticState={staticState.controllers.pagination.state}
controller={hydratedState?.controllers.pagination}
Expand Down
13 changes: 12 additions & 1 deletion packages/samples/headless-ssr-commerce/app/search/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as externalCartAPI from '@/actions/external-cart-api';
import BreadcrumbManager from '@/components/breadcrumb-manager';
import ContextDropdown from '@/components/context-dropdown';
import FacetGenerator from '@/components/facets/facet-generator';
import ParameterManager from '@/components/parameter-manager';
import ProductList from '@/components/product-list';
import {SearchProvider} from '@/components/providers/providers';
import SearchBox from '@/components/search-box';
Expand All @@ -11,13 +12,21 @@ import Triggers from '@/components/triggers/triggers';
import {searchEngineDefinition} from '@/lib/commerce-engine';
import {NextJsNavigatorContext} from '@/lib/navigatorContextProvider';
import {defaultContext} from '@/utils/context';
import {buildParameterSerializer} from '@coveo/headless-react/ssr-commerce';
import {headers} from 'next/headers';

export default async function Search() {
export default async function Search({
searchParams,
}: {
searchParams: Promise<URLSearchParams>;
}) {
// Sets the navigator context provider to use the newly created `navigatorContext` before fetching the app static state
const navigatorContext = new NextJsNavigatorContext(headers());
searchEngineDefinition.setNavigatorContextProvider(() => navigatorContext);

const {deserialize} = buildParameterSerializer();
const parameters = deserialize(await searchParams);

// Fetches the cart items from an external service
const items = await externalCartAPI.getCart();

Expand All @@ -33,6 +42,7 @@ export default async function Search() {
url: 'https://sports.barca.group/search',
},
},
parameterManager: {initialState: {parameters}},
},
});

Expand All @@ -41,6 +51,7 @@ export default async function Search() {
staticState={staticState}
navigatorContext={navigatorContext.marshal}
>
<ParameterManager url={navigatorContext.location} />
<ContextDropdown useCase="search" />
<div style={{display: 'flex', flexDirection: 'row'}}>
<div style={{flex: 1}}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export default function Pagination() {
value={page - 1}
checked={state.page === page - 1}
onChange={() => methods?.selectPage(page - 1)}
disabled={methods === undefined}
/>
{page}
</label>
Expand All @@ -30,15 +31,15 @@ export default function Pagination() {
</div>
<button
className="PreviousPage"
disabled={state.page === 0}
disabled={methods === undefined || state.page === 0}
onClick={methods?.previousPage}
>
{'<'}
</button>
{renderPageRadioButtons()}
<button
className="NextPage"
disabled={state.page === state.totalPages - 1}
disabled={methods === undefined || state.page === state.totalPages - 1}
onClick={methods?.nextPage}
>
{'>'}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
'use client';

import {useParameterManager} from '@/lib/commerce-engine';
import {buildParameterSerializer} from '@coveo/headless-react/ssr-commerce';
import {useSearchParams} from 'next/navigation';
import {useEffect, useMemo, useRef} from 'react';

export default function ParameterManager({url}: {url: string | null}) {
const {state, methods} = useParameterManager();

const {serialize, deserialize} = buildParameterSerializer();

const initialUrl = useMemo(() => new URL(url ?? location.href), [url]);
const previousUrl = useRef(initialUrl.href);
const searchParams = useSearchParams();

/**
* This flag serves to ensure that history navigation between pages does not clear commerce parameters and result in
* history state loss.
*
* When navigating to a new page, the ParameterManager controller is rebuilt with its initial state. Consequently, if
* we serialize the state parameters and push them to the browser history when navigating back to a page, any commerce
* parameters in the URL that were not part of the controller's initial state will be lost.
*
* By having a "guard" that prevents effect execution when the flag is set to true and sets the flag back to false,
* we are able to prevent this.
*
* For instance, suppose that a user initially navigates to /search?q=test. They then select the next page of results
* so that the URL becomes /search?q=test&page=1. Then, they navigate to a product page (e.g., /product/123). At this
* point, if they use their browser history to go back to the search page, the URL will be /search?q=test&page=1, but
* the ParameterManager controller's state will have been reset to only include the q=test parameter. Thanks to the
* flag, however, the navigation event will not cause the URL to be updated, but the useSearchParams hook will cause
* the controller to synchronize its state with the URL, thus preserving the page=1 parameter.
*/
const flag = useRef(true);

/**
* When the URL search parameters change, this effect deserializes them and synchronizes them into the
* ParameterManager controller's state.
*/
useEffect(() => {
if (methods === undefined) {
return;
}

if (flag.current) {
flag.current = false;
return;
}

const newCommerceParams = deserialize(searchParams);

const newUrl = serialize(newCommerceParams, new URL(previousUrl.current));

if (newUrl === previousUrl.current) {
return;
}

flag.current = true;
previousUrl.current = newUrl;
methods.synchronize(newCommerceParams);
}, [deserialize, methods, searchParams, serialize]);

/**
* When the ParameterManager controller's state changes, this effect serializes it into the URL and pushes the new URL
* to the browser history.
* */
useEffect(() => {
// Ensures that the effect only executes if the controller is hydrated, so that it plays well with the other effect.
if (methods === undefined) {
return;
}

if (flag.current) {
flag.current = false;
return;
}

const newUrl = serialize(state.parameters, new URL(previousUrl.current));

if (previousUrl.current === newUrl) {
return;
}

flag.current = true;
previousUrl.current = newUrl;
history.pushState(null, document.title, newUrl);
}, [methods, serialize, state.parameters]);

return null;
}
Loading

0 comments on commit 208e054

Please sign in to comment.