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(ts): allow custom ui state and route state in routing #4816

Merged
merged 6 commits into from
Aug 4, 2021
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/index.es.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { InstantSearchOptions } from './types';
import { InstantSearchOptions, UiState } from './types';
import InstantSearch from './lib/InstantSearch';
import version from './lib/version';
import {
Expand All @@ -11,8 +11,12 @@ import {
} from './helpers';
import { createInfiniteHitsSessionStorageCache } from './lib/infiniteHitsCache';

const instantsearch = (options: InstantSearchOptions): InstantSearch =>
new InstantSearch(options);
const instantsearch = <
TUiState extends UiState = UiState,
TRouteState = TUiState
>(
options: InstantSearchOptions<TUiState, TRouteState>
): InstantSearch => new InstantSearch(options);

instantsearch.version = version;
instantsearch.snippet = snippet;
Expand Down
14 changes: 10 additions & 4 deletions src/lib/InstantSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ function defaultCreateURL() {
/**
* Global options for an InstantSearch instance.
*/
export type InstantSearchOptions = {
export type InstantSearchOptions<
TUiState extends UiState = UiState,
TRouteState = TUiState
> = {
/**
* The name of the main index
*/
Expand Down Expand Up @@ -120,7 +123,7 @@ export type InstantSearchOptions = {
* Router configuration used to save the UI State into the URL or any other
* client side persistence. Passing `true` will use the default URL options.
*/
routing?: RouterProps | boolean;
routing?: RouterProps<TUiState, TRouteState> | boolean;

/**
* the instance of search-insights to use for sending insights events inside
Expand All @@ -136,7 +139,10 @@ export type InstantSearchOptions = {
* created using the `instantsearch` factory function.
* It emits the 'render' event every time a search is done
*/
class InstantSearch extends EventEmitter {
class InstantSearch<
TUiState extends UiState = UiState,
TRouteState = TUiState
> extends EventEmitter {
public client: InstantSearchOptions['searchClient'];
public indexName: string;
public insightsClient: AlgoliaInsightsClient | null;
Expand All @@ -160,7 +166,7 @@ class InstantSearch extends EventEmitter {
}> = [];
public sendEventToInsights: (event: InsightsEvent) => void;

public constructor(options: InstantSearchOptions) {
public constructor(options: InstantSearchOptions<TUiState, TRouteState>) {
super();

const {
Expand Down
26 changes: 16 additions & 10 deletions src/lib/__tests__/RoutingManager-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import qs from 'qs';
import { createSearchClient } from '../../../test/mock/createSearchClient';
import { createWidget } from '../../../test/mock/createWidget';
import { runAllMicroTasks } from '../../../test/utils/runAllMicroTasks';
import { Router, Widget, UiState, StateMapping, RouteState } from '../../types';
import {
Router,
Widget,
UiState,
StateMapping,
IndexUiState,
} from '../../types';
import historyRouter from '../routers/history';
import instantsearch from '../main';

Expand Down Expand Up @@ -383,7 +389,7 @@ describe('RoutingManager', () => {
const router = createFakeRouter({
onUpdate(fn) {
history.subscribe(state => {
fn(state);
fn(state as UiState);
});
},
write: jest.fn(state => {
Expand Down Expand Up @@ -475,7 +481,7 @@ describe('RoutingManager', () => {
const router = createFakeRouter({
onUpdate(fn) {
history.subscribe(state => {
fn(state);
fn(state as UiState);
});
},
write: jest.fn(state => {
Expand Down Expand Up @@ -549,10 +555,10 @@ describe('RoutingManager', () => {
const searchClient = createSearchClient();
const stateMapping = createFakeStateMapping({});
const router = historyRouter({
windowTitle(routeState: RouteState) {
windowTitle(routeState) {
return `Searching for "${routeState.query}"`;
},
} as any);
});

const search = instantsearch({
indexName: 'instant_search',
Expand Down Expand Up @@ -596,7 +602,7 @@ describe('RoutingManager', () => {
url: createFakeUrlWithRefinements({ length: 22 }),
});

const router = historyRouter();
const router = historyRouter<IndexUiState>();
// @ts-expect-error: This method is considered private but we still use it
// in the test after the TypeScript migration.
// In a next refactor, we can consider changing this test implementation.
Expand All @@ -605,7 +611,7 @@ describe('RoutingManager', () => {
location: window.location,
});

expect(parsedUrl.refinementList.brand).toBeInstanceOf(Array);
expect(parsedUrl.refinementList!.brand).toBeInstanceOf(Array);
expect(parsedUrl).toMatchInlineSnapshot(`
Object {
"refinementList": Object {
Expand Down Expand Up @@ -643,7 +649,7 @@ describe('RoutingManager', () => {
url: createFakeUrlWithRefinements({ length: 100 }),
});

const router = historyRouter();
const router = historyRouter<IndexUiState>();
// @ts-expect-error: This method is considered private but we still use it
// in the test after the TypeScript migration.
// In a next refactor, we can consider changing this test implementation.
Expand All @@ -652,13 +658,13 @@ describe('RoutingManager', () => {
location: window.location,
});

expect(parsedUrl.refinementList.brand).toBeInstanceOf(Array);
expect(parsedUrl.refinementList!.brand).toBeInstanceOf(Array);
});
});

describe('createURL', () => {
it('returns an URL for a `routeState` with refinements', () => {
const router = historyRouter();
const router = historyRouter<IndexUiState>();
const actual = router.createURL({
query: 'iPhone',
page: 5,
Expand Down
10 changes: 7 additions & 3 deletions src/lib/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import * as middlewares from '../middlewares/index';
import * as routers from './routers/index';
import * as stateMappings from './stateMappings/index';
import { createInfiniteHitsSessionStorageCache } from './infiniteHitsCache/index';
import { InstantSearchOptions } from '../types';
import { InstantSearchOptions, UiState } from '../types';

/**
* InstantSearch is the main component of InstantSearch.js. This object
Expand All @@ -28,8 +28,12 @@ import { InstantSearchOptions } from '../types';
* @function instantsearch
* @param {InstantSearchOptions} options The options
*/
const instantsearch = (options: InstantSearchOptions) =>
new InstantSearch(options);
const instantsearch = <
TUiState extends UiState = UiState,
TRouteState = TUiState
>(
options: InstantSearchOptions<TUiState, TRouteState>
) => new InstantSearch(options);

instantsearch.routers = routers;
instantsearch.stateMappings = stateMappings;
Expand Down
2 changes: 1 addition & 1 deletion src/lib/routers/__tests__/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ describe('life cycle', () => {
it('writes after timeout is done', async () => {
const pushState = jest.spyOn(window.history, 'pushState');

const router = historyRouter({
const router = historyRouter<{ some: string }>({
writeDelay: 0,
});

Expand Down
133 changes: 68 additions & 65 deletions src/lib/routers/history.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,22 @@
import qs from 'qs';
import { Router, RouteState } from '../../types';
import { Router, UiState } from '../../types';

type CreateURL = ({
qsModule,
routeState,
location,
}: {
type CreateURL<TRouteState> = (args: {
qsModule: typeof qs;
routeState: RouteState;
routeState: TRouteState;
location: Location;
}) => string;

type ParseURL = ({
qsModule,
location,
}: {
type ParseURL<TRouteState> = (args: {
qsModule: typeof qs;
location: Location;
}) => RouteState;
}) => TRouteState;

type BrowserHistoryArgs = {
windowTitle?: (routeState: RouteState) => string;
writeDelay?: number;
createURL?: CreateURL;
parseURL?: ParseURL;
};

const defaultCreateURL: CreateURL = ({ qsModule, routeState, location }) => {
const { protocol, hostname, port = '', pathname, hash } = location;
const queryString = qsModule.stringify(routeState);
const portWithPrefix = port === '' ? '' : `:${port}`;

// IE <= 11 has no proper `location.origin` so we cannot rely on it.
if (!queryString) {
return `${protocol}//${hostname}${portWithPrefix}${pathname}${hash}`;
}

return `${protocol}//${hostname}${portWithPrefix}${pathname}?${queryString}${hash}`;
};

const defaultParseURL: ParseURL = ({ qsModule, location }) => {
// `qs` by default converts arrays with more than 20 items to an object.
// We want to avoid this because the data structure manipulated can therefore vary.
// Setting the limit to `100` seems a good number because the engine's default is 100
// (it can go up to 1000 but it is very unlikely to select more than 100 items in the UI).
//
// Using an `arrayLimit` of `n` allows `n + 1` items.
//
// See:
// - https://github.com/ljharb/qs#parsing-arrays
// - https://www.algolia.com/doc/api-reference/api-parameters/maxValuesPerFacet/
return qsModule.parse(location.search.slice(1), { arrayLimit: 99 });
type BrowserHistoryArgs<TRouteState> = {
windowTitle?: (routeState: TRouteState) => string;
writeDelay: number;
createURL: CreateURL<TRouteState>;
parseURL: ParseURL<TRouteState>;
};

const setWindowTitle = (title?: string): void => {
Expand All @@ -59,29 +25,35 @@ const setWindowTitle = (title?: string): void => {
}
};

class BrowserHistory implements Router {
class BrowserHistory<TRouteState> implements Router<TRouteState> {
/**
* Transforms a UI state into a title for the page.
*/
private readonly windowTitle?: BrowserHistoryArgs['windowTitle'];
private readonly windowTitle?: BrowserHistoryArgs<TRouteState>['windowTitle'];
/**
* Time in milliseconds before performing a write in the history.
* It prevents from adding too many entries in the history and
* makes the back button more usable.
*
* @default 400
*/
private readonly writeDelay: Required<BrowserHistoryArgs>['writeDelay'];
private readonly writeDelay: Required<
BrowserHistoryArgs<TRouteState>
>['writeDelay'];
/**
* Creates a full URL based on the route state.
* The storage adaptor maps all syncable keys to the query string of the URL.
*/
private readonly _createURL: Required<BrowserHistoryArgs>['createURL'];
private readonly _createURL: Required<
BrowserHistoryArgs<TRouteState>
>['createURL'];
/**
* Parses the URL into a route state.
* It should be symetrical to `createURL`.
* It should be symmetrical to `createURL`.
*/
private readonly parseURL: Required<BrowserHistoryArgs>['parseURL'];
private readonly parseURL: Required<
BrowserHistoryArgs<TRouteState>
>['parseURL'];

private writeTimer?: number;
private _onPopState?(event: PopStateEvent): void;
Expand All @@ -90,14 +62,12 @@ class BrowserHistory implements Router {
* Initializes a new storage provider that syncs the search state to the URL
* using web APIs (`window.location.pushState` and `onpopstate` event).
*/
public constructor(
{
windowTitle,
writeDelay = 400,
createURL = defaultCreateURL,
parseURL = defaultParseURL,
}: BrowserHistoryArgs = {} as BrowserHistoryArgs
) {
public constructor({
windowTitle,
writeDelay = 400,
createURL,
parseURL,
}: BrowserHistoryArgs<TRouteState>) {
this.windowTitle = windowTitle;
this.writeTimer = undefined;
this.writeDelay = writeDelay;
Expand All @@ -112,14 +82,14 @@ class BrowserHistory implements Router {
/**
* Reads the URL and returns a syncable UI search state.
*/
public read(): RouteState {
public read(): TRouteState {
return this.parseURL({ qsModule: qs, location: window.location });
}

/**
* Pushes a search state into the URL.
*/
public write(routeState: RouteState): void {
public write(routeState: TRouteState): void {
const url = this.createURL(routeState);
const title = this.windowTitle && this.windowTitle(routeState);

Expand All @@ -138,7 +108,7 @@ class BrowserHistory implements Router {
* Sets a callback on the `onpopstate` event of the history API of the current page.
* It enables the URL sync to keep track of the changes.
*/
public onUpdate(callback: (routeState: RouteState) => void): void {
public onUpdate(callback: (routeState: TRouteState) => void): void {
this._onPopState = event => {
if (this.writeTimer) {
window.clearTimeout(this.writeTimer);
Expand Down Expand Up @@ -167,7 +137,7 @@ class BrowserHistory implements Router {
* This allows to handle cases like using a <base href>.
* See: https://github.com/algolia/instantsearch.js/issues/790
*/
public createURL(routeState: RouteState): string {
public createURL(routeState: TRouteState): string {
return this._createURL({
qsModule: qs,
routeState,
Expand All @@ -187,10 +157,43 @@ class BrowserHistory implements Router {
window.clearTimeout(this.writeTimer);
}

this.write({});
this.write({} as TRouteState);
}
}

export default function(props?: BrowserHistoryArgs): BrowserHistory {
return new BrowserHistory(props);
export default function<TRouteState = UiState>({
createURL = ({ qsModule, routeState, location }) => {
const { protocol, hostname, port = '', pathname, hash } = location;
const queryString = qsModule.stringify(routeState);
const portWithPrefix = port === '' ? '' : `:${port}`;

// IE <= 11 has no proper `location.origin` so we cannot rely on it.
if (!queryString) {
return `${protocol}//${hostname}${portWithPrefix}${pathname}${hash}`;
}

return `${protocol}//${hostname}${portWithPrefix}${pathname}?${queryString}${hash}`;
},
parseURL = ({ qsModule, location }) => {
// `qs` by default converts arrays with more than 20 items to an object.
// We want to avoid this because the data structure manipulated can therefore vary.
// Setting the limit to `100` seems a good number because the engine's default is 100
// (it can go up to 1000 but it is very unlikely to select more than 100 items in the UI).
//
// Using an `arrayLimit` of `n` allows `n + 1` items.
//
// See:
// - https://github.com/ljharb/qs#parsing-arrays
// - https://www.algolia.com/doc/api-reference/api-parameters/maxValuesPerFacet/
return qsModule.parse(location.search.slice(1), { arrayLimit: 99 });
},
writeDelay = 400,
windowTitle,
}: Partial<BrowserHistoryArgs<TRouteState>> = {}): BrowserHistory<TRouteState> {
return new BrowserHistory({
createURL,
parseURL,
writeDelay,
windowTitle,
});
}
Loading