Skip to content

Commit

Permalink
feat(headless): added defineSearchParametersManager SSR controller (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
btaillon-coveo authored Sep 1, 2023
1 parent 90765dc commit 99cca68
Show file tree
Hide file tree
Showing 10 changed files with 439 additions and 11 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {SearchEngine} from '../../../app/search-engine/search-engine';
import {ControllerDefinitionWithProps} from '../../../app/ssr-engine/types/common';
import {
SearchParameterManager,
SearchParameterManagerInitialState,
buildSearchParameterManager,
} from '../../search-parameter-manager/headless-search-parameter-manager';

export type {
SearchParameterManagerInitialState,
SearchParameterManagerState,
SearchParameterManager,
} from '../../search-parameter-manager/headless-search-parameter-manager';

/**
* @internal
*/
export interface SearchParameterManagerBuildProps {
initialState: SearchParameterManagerInitialState;
}

/**
* @internal
*/
export const defineSearchParameterManager = (): ControllerDefinitionWithProps<
SearchEngine,
SearchParameterManager,
SearchParameterManagerBuildProps
> => ({
buildWithProps: (engine, props) =>
buildSearchParameterManager(engine, {initialState: props.initialState}),
});
8 changes: 8 additions & 0 deletions packages/headless/src/ssr.index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,11 @@ export type {
ResultListState,
} from './controllers/ssr/result-list/headless-ssr-result-list';
export {defineResultList} from './controllers/ssr/result-list/headless-ssr-result-list';

export type {
SearchParameterManager,
SearchParameterManagerInitialState,
SearchParameterManagerBuildProps,
SearchParameterManagerState,
} from './controllers/ssr/search-parameter-manager/headless-ssr-search-parameter-manager';
export {defineSearchParameterManager} from './controllers/ssr/search-parameter-manager/headless-ssr-search-parameter-manager';
7 changes: 7 additions & 0 deletions packages/samples/headless-ssr/cypress/e2e/ssr-e2e-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,10 @@ export function spyOnConsole() {
export function waitForHydration() {
cy.get('#hydrated-indicator').should('be.checked');
}

export const getResultTitles = () =>
(
cy.get('.result-list li').invoke('map', function (this: HTMLElement) {
return this.innerText;
}) as Cypress.Chainable<JQuery<string>>
).invoke('toArray');
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import {
ConsoleAliases,
getResultTitles,
spyOnConsole,
waitForHydration,
} from './ssr-e2e-utils';

const searchStateKey = 'search-state';

describe('headless ssr with search parameter manager example', () => {
const route = '/generic';
describe('when loading a page without search parameters, after hydration', () => {
beforeEach(() => {
spyOnConsole();
cy.visit(route);
waitForHydration();
getResultTitles()
.should('have.length.greaterThan', 0)
.as('initial-results');
});

it('should not update the search parameters', () => {
cy.url().should((href) =>
expect(new URL(href).searchParams.size).to.equal(0)
);
});

describe('after submitting a search', () => {
const query = 'abc';
beforeEach(() =>
cy.get('.search-box input').focus().type(`${query}{enter}`)
);

describe('after the url was updated', () => {
beforeEach(() => {
cy.url().should((href) =>
expect(new URL(href).searchParams.has(searchStateKey)).to.equal(
true
)
);
cy.get<string>('@initial-results').then((initialResults) => {
getResultTitles().should('not.deep.equal', initialResults);
});
});

it('should have the correct search parameters', () => {
cy.url().should((href) => {
const searchState = new URL(href).searchParams.get(searchStateKey);
expect(searchState && JSON.parse(searchState)).to.deep.equal({
q: query,
});
});
});

it('has only two history states', () => {
cy.go('back');
cy.go('back');
cy.url().should('eq', 'about:blank');
});

describe('after pressing the back button', () => {
beforeEach(() => cy.go('back'));

it('should remove the search parameters', () => {
cy.url().should((href) =>
expect(new URL(href).searchParams.size).to.be.equal(0)
);
});

it('should update the page', () => {
cy.get('.search-box input').should('have.value', '');
cy.get<string>('@initial-results').then((initialResults) => {
getResultTitles().should('deep.equal', initialResults);
});
});

it('should not log an error nor warning', () => {
cy.get(ConsoleAliases.error).should('not.be.called');
cy.get(ConsoleAliases.warn).should('not.be.called');
});
});
});
});
});

describe('when loading a page with search parameters', () => {
const query = 'def';
function getInitialUrl() {
const searchParams = new URLSearchParams({
[searchStateKey]: JSON.stringify({q: query}),
});
return `${route}?${searchParams.toString()}`;
}

it('renders page in SSR as expected', () => {
cy.intercept('/**', (req) => {
req.continue((resp) => {
const dom = new DOMParser().parseFromString(resp.body, 'text/html');
expect(dom.querySelector('.search-box input')).to.have.value(query);
});
});
cy.visit(getInitialUrl());
waitForHydration();
});

describe('after hydration', () => {
beforeEach(() => {
cy.visit(getInitialUrl());
waitForHydration();
});

it("doesn't update the page", () => {
cy.wait(1000);
cy.get('.search-box input').should('have.value', query);
});

it('should not update the parameters', () => {
cy.wait(1000);
cy.url().should((href) => {
expect(href.endsWith(getInitialUrl())).to.equal(true);
});
});

it('has only one history state', () => {
cy.go('back');
cy.url().should('eq', 'about:blank');
});
});
});

describe('when loading a page with invalid search parameters', () => {
function getInitialUrl() {
const searchParams = new URLSearchParams({
[searchStateKey]: JSON.stringify({q: ''}),
});
return `${route}?${searchParams.toString()}`;
}

it('renders page in SSR as expected', () => {
cy.intercept('/**', (req) => {
req.continue((resp) => {
const dom = new DOMParser().parseFromString(resp.body, 'text/html');
expect(dom.querySelector('.search-box input')).to.have.value('');
});
});
cy.visit(getInitialUrl());
waitForHydration();
});

describe('after hydration', () => {
beforeEach(() => {
cy.visit(getInitialUrl());
waitForHydration();
});

it("doesn't update the page", () => {
cy.wait(1000);
cy.get('.search-box input').should('have.value', '');
});

it('should correct the parameters', () => {
cy.url().should((href) => {
expect(new URL(href).searchParams.size).to.equal(0);
});
});

it('has only one history state', () => {
cy.go('back');
cy.url().should('eq', 'about:blank');
});
});
});
});
14 changes: 6 additions & 8 deletions packages/samples/headless-ssr/cypress/e2e/ssr.cy.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import 'cypress-web-vitals';
import {ConsoleAliases, spyOnConsole, waitForHydration} from './ssr-e2e-utils';

const getResultTitles = () =>
(
cy.get('.result-list li').invoke('map', function (this: HTMLElement) {
return this.innerText;
}) as Cypress.Chainable<JQuery<string>>
).invoke('toArray');
import {
ConsoleAliases,
getResultTitles,
spyOnConsole,
waitForHydration,
} from './ssr-e2e-utils';

describe('headless ssr example', () => {
const route = '/generic';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
InferSSRState,
InferCSRState,
defineSearchBox,
defineSearchParameterManager,
} from '@coveo/headless/ssr';

const engineDefinition = defineSearchEngine({
Expand All @@ -15,6 +16,7 @@ const engineDefinition = defineSearchEngine({
controllers: {
searchBox: defineSearchBox(),
resultList: defineResultList(),
searchParameters: defineSearchParameterManager(),
},
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import {SearchParameters} from '@coveo/headless';
import {ReadonlyURLSearchParams} from 'next/navigation';

export type NextJSServerSideSearchParams = Record<
string,
string | string[] | undefined
>;

const searchStateKey = 'search-state';

export class CoveoNextJsSearchParametersSerializer {
public static fromServerSideUrlSearchParams(
serverSideUrlSearchParams: NextJSServerSideSearchParams
): CoveoNextJsSearchParametersSerializer {
if (!(searchStateKey in serverSideUrlSearchParams)) {
return new CoveoNextJsSearchParametersSerializer({});
}
const stringifiedSearchParameters =
serverSideUrlSearchParams[searchStateKey];
if (
!stringifiedSearchParameters ||
Array.isArray(stringifiedSearchParameters)
) {
return new CoveoNextJsSearchParametersSerializer({});
}
return new CoveoNextJsSearchParametersSerializer(
JSON.parse(stringifiedSearchParameters)
);
}

public static fromClientSideUrlSearchParams(
clientSideUrlSearchParams: URLSearchParams | ReadonlyURLSearchParams
) {
if (clientSideUrlSearchParams.getAll(searchStateKey).length !== 1) {
return new CoveoNextJsSearchParametersSerializer({});
}
return new CoveoNextJsSearchParametersSerializer(
JSON.parse(clientSideUrlSearchParams.get(searchStateKey)!)
);
}

public static fromCoveoSearchParameters(
coveoSearchParameters: SearchParameters
) {
return new CoveoNextJsSearchParametersSerializer(coveoSearchParameters);
}

private constructor(
public readonly coveoSearchParameters: SearchParameters
) {}

public applyToUrlSearchParams(urlSearchParams: URLSearchParams) {
if (!Object.keys(this.coveoSearchParameters).length) {
urlSearchParams.delete(searchStateKey);
return;
}
urlSearchParams.set(
searchStateKey,
JSON.stringify(this.coveoSearchParameters)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
hydrateInitialState,
} from '@/src/app/generic/common/engine';
import {useEffect, useState} from 'react';
import {useSyncSearchParameters} from '../hooks/search-parameters';
import {HydrationMetadata} from './hydration-metadata';
import {ResultList} from './result-list';
import {SearchBox} from './search-box';
Expand All @@ -16,10 +17,23 @@ export default function SearchPage({ssrState}: {ssrState: SearchSSRState}) {
);

useEffect(() => {
hydrateInitialState(ssrState).then(({engine, controllers}) => {
hydrateInitialState({
searchFulfilledAction: ssrState.searchFulfilledAction,
controllers: {
searchParameters: {
initialState: ssrState.controllers.searchParameters.state,
},
},
}).then(({engine, controllers}) => {
setCSRResult({engine, controllers});
});
}, [ssrState]);

useSyncSearchParameters({
ssrState: ssrState.controllers.searchParameters.state,
controller: csrResult?.controllers.searchParameters,
});

return (
<>
<HydrationMetadata ssrState={ssrState} csrResult={csrResult} />
Expand Down
Loading

0 comments on commit 99cca68

Please sign in to comment.