Skip to content

Commit

Permalink
feat(ts): allow custom ui state and route state in routing (algolia#4816
Browse files Browse the repository at this point in the history
)

* feat(ts): allow custom ui state and route state in routing

While I couldn't find a way to make InstantSearch itself generic (this gets passed to many places, which then loses generic), using the routing middleware directly is possible like this now

```ts
import instantsearch from 'instantsearch.js/es'
import { history } from 'instantsearch.js/es/lib/routers';
import { createRouterMiddleware } from 'instantsearch.js/es/middlewares';
import { StateMapping, UiState } from 'instantsearch.js/es/types';

type SwagIndexUiState = { swag: boolean };
type SwagUiState = { [indexName: string]: SwagIndexUiState };

const stateMapping: StateMapping<UiState & SwagUiState, SwagUiState> = {
  stateToRoute(uiState) {
    return Object.keys(uiState).reduce(
      (state, indexId) => ({
        ...state,
        [indexId]: { swag: uiState[indexId].swag },
      }),
      {}
    );
  },

  routeToState(routeState = {}) {
    return Object.keys(routeState).reduce(
      (state, indexId) => ({
        ...state,
        [indexId]: routeState[indexId],
      }),
      {}
    );
  },
};

const search = instantsearch();

search.use(
  createRouterMiddleware<UiState & SwagUiState, SwagUiState>({
    router: history(),
    stateMapping,
  })
);
search.addWidgets([instantsearch.widgets.hits({ container })]);
```

* actually genericificate InstantSearch

* already inferred so no need

* Apply suggestions from code review

* address feedback

* better comment
  • Loading branch information
Haroenv authored Aug 4, 2021
1 parent 585e38f commit 5f8ba5d
Show file tree
Hide file tree
Showing 14 changed files with 199 additions and 149 deletions.
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 = Record<string, unknown>,
TRouteState = TUiState
>(
options: InstantSearchOptions<UiState & TUiState, TRouteState>
) => 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
44 changes: 24 additions & 20 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 @@ -35,32 +41,30 @@ const createFakeStateMapping = (
...args,
});

type Entry = Record<string, unknown>;

type HistoryState = {
type HistoryState<TEntry> = {
index: number;
entries: Entry[];
listeners: Array<(value: Entry) => void>;
entries: TEntry[];
listeners: Array<(value: TEntry) => void>;
};

const createFakeHistory = (
const createFakeHistory = <TEntry = Record<string, unknown>>(
{
index = -1,
entries = [],
listeners = [],
}: HistoryState = {} as HistoryState
}: HistoryState<TEntry> = {} as HistoryState<TEntry>
) => {
const state: HistoryState = {
const state: HistoryState<TEntry> = {
index,
entries,
listeners,
};

return {
subscribe(listener: (entry: Entry) => void) {
subscribe(listener: (entry: TEntry) => void) {
state.listeners.push(listener);
},
push(value: Entry) {
push(value: TEntry) {
state.entries.push(value);
state.index++;
},
Expand Down Expand Up @@ -379,7 +383,7 @@ describe('RoutingManager', () => {
test('should keep the UI state up to date on router.update', async () => {
const searchClient = createSearchClient();
const stateMapping = createFakeStateMapping({});
const history = createFakeHistory();
const history = createFakeHistory<UiState>();
const router = createFakeRouter({
onUpdate(fn) {
history.subscribe(state => {
Expand Down Expand Up @@ -471,7 +475,7 @@ describe('RoutingManager', () => {
return uiState;
},
});
const history = createFakeHistory();
const history = createFakeHistory<UiState>();
const router = createFakeRouter({
onUpdate(fn) {
history.subscribe(state => {
Expand Down Expand Up @@ -549,10 +553,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 +600,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 +609,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 +647,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 +656,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 = Record<string, unknown>,
TRouteState = TUiState
>(
options: InstantSearchOptions<UiState & 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
Loading

0 comments on commit 5f8ba5d

Please sign in to comment.