-
Notifications
You must be signed in to change notification settings - Fork 34
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
base: KIT-3681-2
Are you sure you want to change the base?
Changes from 6 commits
8886515
bd64354
8992c30
a1d7ce4
9f8a1d2
e656536
61ffded
81d4e86
c0d38fe
6d1028c
64e3cba
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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`. | ||
|
@@ -63,3 +70,27 @@ export function useSyncMemoizedStore<T>( | |
|
||
return snapshot.current; | ||
} | ||
|
||
function getUrl() { | ||
if (typeof window === 'undefined') { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need this despite the There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 🤔 . There was a problem hiding this comment. Choose a reason for hiding this commentThe 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]); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,269 @@ | ||
import {CommerceSearchParameters} from '../search-parameters/search-parameters-actions.js'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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'); | ||
}); | ||
}); | ||
}); |
There was a problem hiding this comment.
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.