Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(commerce-ssr): add parameter manager #4626

Open
wants to merge 11 commits into
base: KIT-3681-2
Choose a base branch
from
33 changes: 32 additions & 1 deletion packages/headless-react/src/ssr-commerce/client-utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
'use client';

import {DependencyList, useEffect, useReducer, useRef} from 'react';
import {
DependencyList,
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
} from 'react';

/**
* Subscriber is a function that takes a single argument, which is another function `listener` that returns `void`. The Subscriber function itself returns another function that can be used to unsubscribe the `listener`.
Expand Down Expand Up @@ -63,3 +70,27 @@ export function useSyncMemoizedStore<T>(

return snapshot.current;
}

function getUrl() {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In https://docs.coveo.com/en/headless/latest/usage/headless-server-side-rendering/implement-search-parameter-support/#implement-a-history-router-hook, we explain how to implement this.

I thought we might as well create and export a hook for that to reduce boilerplating.

if (typeof window === 'undefined') {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this despite the use client?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

window is undefined in 'use client' components when they are first rendered on the server. I am not sure how this works in the context of a hook 🤔 .

Copy link
Contributor Author

@fbeaudoincoveo fbeaudoincoveo Nov 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I remove the check, the server responds with 500 (ReferenceError: document is not defined) upon the initial GET request.

return null;
}
return new URL(document.location.href);
}

export function useAppHistoryRouter() {
const [url, updateUrl] = useReducer(() => getUrl(), getUrl());
useEffect(() => {
window.addEventListener('popstate', updateUrl);
return () => window.removeEventListener('popstate', updateUrl);
}, []);
const replace = useCallback(
(href: string) => window.history.replaceState(null, document.title, href),
[]
);
const push = useCallback(
(href: string) => window.history.pushState(null, document.title, href),
[]
);
return useMemo(() => ({url, replace, push}), [url, replace, push]);
}
1 change: 1 addition & 0 deletions packages/headless-react/src/ssr-commerce/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export {defineCommerceEngine} from './commerce-engine.js';
export type {ReactCommerceEngineDefinition} from './commerce-engine.js';
export {MissingEngineProviderError} from './common.js';
export * from '@coveo/headless/ssr-commerce';
export {useAppHistoryRouter} from './client-utils.js';
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,9 @@ export function defineParameterManager<
}
},
} as SubControllerDefinitionWithProps<
ParameterManager<MappedParameterTypes<typeof options>>,
ParameterManager<MappedParameterTypes<TOptions>>,
TOptions,
ParameterManagerProps<MappedParameterTypes<typeof options>>
ParameterManagerProps<MappedParameterTypes<TOptions>>
>;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
import {CommerceSearchParameters} from '../search-parameters/search-parameters-actions.js';
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import {SortBy, SortDirection} from '../sort/sort.js';
import {buildSSRCommerceSearchParameterSerializer} from './parameters-serializer.ssr.js';

const someSpecialCharactersThatNeedsEncoding = [
'&',
',',
'=',
'[',
']',
'#',
'?',
'/',
'>',
];

describe('buildSSRCommerceSearchParameterSerializer', () => {
const {toCommerceSearchParameters} =
buildSSRCommerceSearchParameterSerializer();

describe('#toSearchParameters', () => {
it('should convert URLSearchParams to SearchParameters', () => {
const urlSearchParams = new URLSearchParams();
urlSearchParams.append('q', 'query');
urlSearchParams.append('f-color', 'red');
urlSearchParams.append('f-shape', 'square');

const result = toCommerceSearchParameters(urlSearchParams);

expect(result).toEqual({
q: 'query',
f: {color: ['red'], shape: ['square']},
});
});

it('should convert Record<string, SearchParamValue> to SearchParameters', () => {
const searchParams = {
q: 'query',
'f-color': 'red',
'f-shape': 'square',
};

const result = toCommerceSearchParameters(searchParams);

expect(result).toEqual({
q: 'query',
f: {color: ['red'], shape: ['square']},
});
});

it('should convert Record<string, SearchParamValue> with special characters to SearchParameters', () => {
someSpecialCharactersThatNeedsEncoding.forEach((char) => {
const searchParams = {
q: `query${char}`,
'f-color': 'red',
'f-shape': 'square',
};

const result = toCommerceSearchParameters(searchParams);

expect(result).toEqual({
q: `query${char}`,
f: {color: ['red'], shape: ['square']},
});
});
});

it('should not convert empty-string URLSearchParams to SearchParameters', () => {
const urlSearchParams = new URLSearchParams();
urlSearchParams.append('q', '');
urlSearchParams.append('f-color', 'red');
urlSearchParams.append('f-shape', 'square');

const result = toCommerceSearchParameters(urlSearchParams);

expect(result).toEqual({
f: {color: ['red'], shape: ['square']},
});
});

it('should cast the URLSearchParams to appropriate SearchParameter type', () => {
const urlSearchParams = new URLSearchParams();
urlSearchParams.append('perPage', '10');
urlSearchParams.append('q', 'test');
urlSearchParams.append('sortCriteria', 'ec_price%20asc');

const result = toCommerceSearchParameters(urlSearchParams);

expect(result).toEqual({
q: 'test',
perPage: 10,
sortCriteria: {
by: SortBy.Fields,
fields: [{name: 'ec_price', direction: SortDirection.Ascending}],
},
});
});

describe('when the search parameter have multiple values', () => {
it('should convert repeated URL search parameters to a SearchParameters object', () => {
const urlSearchParams = new URLSearchParams();
urlSearchParams.append('q', 'query');
urlSearchParams.append('f-color', 'red');
urlSearchParams.append('f-color', 'green');
urlSearchParams.append('f-shape', 'square');

const result = toCommerceSearchParameters(urlSearchParams);

expect(result).toEqual({
q: 'query',
f: {color: ['red', 'green'], shape: ['square']},
});
});

it('should convert Record<string, SearchParamValue> with repeated values to a SearchParameters object', () => {
const searchParams = {
q: 'query',
'f-color': ['red', 'green'],
'f-shape': 'square',
};

const result = toCommerceSearchParameters(searchParams);

expect(result).toEqual({
q: 'query',
f: {color: ['red', 'green'], shape: ['square']},
});
});

it('should convert a URL search parameter with a single key and string value with special characters', () => {
someSpecialCharactersThatNeedsEncoding.forEach((char) => {
const {searchParams} = new URL(
`https://example.com?q=hello${encodeURIComponent(char)}`
);
const result = toCommerceSearchParameters(searchParams);
expect(result).toEqual({q: `hello${char}`});
});
});

it('should convert two facets correctly with special characters', () => {
someSpecialCharactersThatNeedsEncoding.forEach((char) => {
const {searchParams} = new URL(
`https://example.com?f-color=${encodeURIComponent(
char
)}&f-color=green&f-shape=square`
);

const result = toCommerceSearchParameters(searchParams);

expect(result).toEqual({
f: {
color: [char, 'green'],
shape: ['square'],
},
});
});
});
});
});

describe('serialize', () => {
const {serialize} = buildSSRCommerceSearchParameterSerializer();

it('should serializes special characters in records', () => {
const initialUrl = new URL('https://example.com');
someSpecialCharactersThatNeedsEncoding.forEach((specialChar) => {
const result = serialize({q: `hello${specialChar}`}, initialUrl);
expect(result).toBe(
`https://example.com/?q=hello${encodeURIComponent(specialChar)}`
);
});
});

it('serializes special characters in the #f parameter correctly', () => {
const initialUrl = new URL('https://example.com');
someSpecialCharactersThatNeedsEncoding.forEach((specialChar) => {
const f = {author: ['a', specialChar]};
const result = serialize({f}, initialUrl);
expect(result).toEqual(
`https://example.com/?f-author=a&f-author=${encodeURIComponent(
specialChar
)}`
);
});
});

it('should serialize search parameters to URL', () => {
const searchParameters: CommerceSearchParameters = {
q: 'query',
f: {color: ['red', 'green'], shape: ['square']},
};
const initialUrl = new URL('https://example.com');

const result = serialize(searchParameters, initialUrl);

expect(result).toBe(
'https://example.com/?q=query&f-color=red&f-color=green&f-shape=square'
);
});

it('should serialize search parameters to URL regardless of their types', () => {
const searchParameters: CommerceSearchParameters = {
perPage: 10,
q: 'test',
sortCriteria: {
by: SortBy.Fields,
fields: [{name: 'ec_price', direction: SortDirection.Ascending}],
},
};
const initialUrl = new URL('https://example.com');

const result = serialize(searchParameters, initialUrl);

expect(result).toBe(
'https://example.com/?perPage=10&q=test&sortCriteria=ec_price%2520asc'
);
});

it('should not alter url host and route', () => {
const searchParameters: CommerceSearchParameters = {
q: 'query',
f: {color: ['red', 'green'], shape: ['square']},
};
const initialUrl = new URL('https://example.com/custom/route');

const result = serialize(searchParameters, initialUrl);

expect(result).toBe(
'https://example.com/custom/route?q=query&f-color=red&f-color=green&f-shape=square'
);
});

it('should not overwrite existing (non-coveo) search parameters in URL', () => {
const searchParameters: CommerceSearchParameters = {
f: {color: ['red', 'green'], shape: ['square']},
};
const initialUrl = new URL('https://example.com?foo=bar');

const result = serialize(searchParameters, initialUrl);

expect(result).toBe(
'https://example.com/?foo=bar&f-color=red&f-color=green&f-shape=square'
);
});

it('should overwrite existing coveo search parameters in URL', () => {
const searchParameters = {
f: {color: ['red', 'green'], shape: ['square']},
};
const initialUrl = new URL(
'https://example.com/?f-color=blue&f-shape=oval'
);

const result = serialize(searchParameters, initialUrl);

expect(result).toBe(
'https://example.com/?f-color=red&f-color=green&f-shape=square'
);
});

it('should handle empty search parameters', () => {
const searchParameters = {};
const initialUrl = new URL('https://example.com/?param1=value1');
const result = serialize(searchParameters, initialUrl);

expect(result).toBe('https://example.com/?param1=value1');
});
});
});
Loading
Loading