Skip to content

Commit

Permalink
feat(typescript): allow generics for the Hit type in all hit-displayi…
Browse files Browse the repository at this point in the history
…ng connectors and widgets (algolia#6218)

* POC: make hits connector and widget (js) generic

to work around, `satisfies` operator gets confused with the generic type of widgetParams being different to the ones passed to the connector itself. Maybe there's a smart way to pass the generics around though

* refactor: simplify and not allow generic for params twice

this ensures no extra params are allowed unless declared in the connector in advance

* use in examples

* chore: remove comment

* fix: allow WidgetRenderState generic by retyping

* chore: change type things
  • Loading branch information
Haroenv authored May 31, 2024
1 parent a0eb9c9 commit 89dd7ae
Show file tree
Hide file tree
Showing 54 changed files with 1,851 additions and 1,143 deletions.
6 changes: 3 additions & 3 deletions bundlesize.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,19 @@
},
{
"path": "./packages/instantsearch.js/dist/instantsearch.production.min.js",
"maxSize": "81.5 kB"
"maxSize": "83 kB"
},
{
"path": "./packages/instantsearch.js/dist/instantsearch.development.js",
"maxSize": "177.5 kB"
"maxSize": "180 kB"
},
{
"path": "packages/react-instantsearch-core/dist/umd/ReactInstantSearchCore.min.js",
"maxSize": "50.5 kB"
},
{
"path": "packages/react-instantsearch/dist/umd/ReactInstantSearch.min.js",
"maxSize": "63.25 kB"
"maxSize": "64 kB"
},
{
"path": "packages/vue-instantsearch/vue2/umd/index.js",
Expand Down
11 changes: 10 additions & 1 deletion examples/js/e-commerce-umd/src/widgets/Products.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
const { hits } = window.instantsearch.widgets;

export const products = hits({
type Hit = {
name: string;
image: string;
categories: string[];
description: string;
price: number;
rating: number;
};

export const products = hits<Hit>({
container: '[data-widget="hits"]',
templates: {
item(hit, { html, components }) {
Expand Down
11 changes: 10 additions & 1 deletion examples/js/e-commerce/src/widgets/Products.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import { hits } from 'instantsearch.js/es/widgets';

export const products = hits({
type Hit = {
name: string;
image: string;
categories: string[];
description: string;
price: number;
rating: number;
};

export const products = hits<Hit>({
container: '[data-widget="hits"]',
templates: {
item(hit, { html, components }) {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
"@babel/preset-react": "7.14.5",
"@babel/preset-typescript": "7.15.0",
"@googlemaps/jest-mocks": "2.7.5",
"@microsoft/api-extractor": "7.18.0",
"@microsoft/api-extractor": "7.45.1",
"@storybook/addon-actions": "5.3.9",
"@storybook/addon-knobs": "5.3.9",
"@storybook/addon-options": "5.3.9",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { InstantSearch, Widget } from '../../src/types';
import { IndexWidget } from '../../src/widgets/index/index';
import { InstantSearch, Widget, IndexWidget } from '../../src/types';

const setDisabledState = (element: HTMLButtonElement, state: boolean) => {
element.disabled = state;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@

"ae-missing-release-tag": {
"logLevel": "none"
},

"ae-wrong-input-file-type": {
// This may be returned falsely, to investigate!
"logLevel": "warning"
}
},

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import { h } from 'preact';
import { prepareTemplateProps } from '../../../lib/templating';
import InfiniteHits from '../InfiniteHits';

import type { Hit } from '../../../types';
import type { InfiniteHitsProps } from '../InfiniteHits';
import type { Hit } from 'instantsearch.js';

beforeEach(() => {
document.body.innerHTML = '';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,21 @@ describe('connectFrequentlyBoughtTogether', () => {
);
});

it('accepts custom parameters', () => {
const render = jest.fn();
const unmount = jest.fn();

const customFrequentlyBoughtTogether = connectFrequentlyBoughtTogether<{
container: string;
}>(render, unmount);
const widget = customFrequentlyBoughtTogether({
container: '#container',
objectIDs: ['1'],
});

expect(widget.$$type).toBe('ais.frequentlyBoughtTogether');
});

it('Renders during init and render', () => {
const renderFn = jest.fn();
const makeWidget = connectFrequentlyBoughtTogether(renderFn);
Expand All @@ -51,7 +66,7 @@ describe('connectFrequentlyBoughtTogether', () => {
const helper = algoliasearchHelper(createSearchClient(), '', {});
helper.search = jest.fn();

widget.init!(
widget.init(
createInitOptions({
helper,
state: helper.state,
Expand All @@ -68,7 +83,7 @@ describe('connectFrequentlyBoughtTogether', () => {
helper,
});

widget.render!(renderOptions);
widget.render(renderOptions);

expect(renderFn).toHaveBeenCalledTimes(2);
expect(renderFn).toHaveBeenLastCalledWith(
Expand All @@ -90,7 +105,7 @@ describe('connectFrequentlyBoughtTogether', () => {
});

// @ts-expect-error
const actual = widget.getWidgetParameters!(new RecommendParameters(), {
const actual = widget.getWidgetParameters(new RecommendParameters(), {
uiState: {},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,15 @@ import {
TAG_PLACEHOLDER,
} from '../../lib/utils';

import type { Connector, TransformItems, Hit, BaseHit } from '../../types';
import type {
Connector,
TransformItems,
Hit,
BaseHit,
Renderer,
Unmounter,
UnknownWidgetParams,
} from '../../types';
import type {
PlainSearchParameters,
RecommendResultItem,
Expand All @@ -18,7 +26,7 @@ const withUsage = createDocumentationMessageGenerator({
});

export type FrequentlyBoughtTogetherRenderState<
THit extends BaseHit = BaseHit
THit extends NonNullable<object> = BaseHit
> = {
/**
* The matched recommendations from Algolia API.
Expand All @@ -27,7 +35,7 @@ export type FrequentlyBoughtTogetherRenderState<
};

export type FrequentlyBoughtTogetherConnectorParams<
THit extends BaseHit = BaseHit
THit extends NonNullable<object> = BaseHit
> = {
/**
* The objectIDs of the items to get the frequently bought together items for.
Expand Down Expand Up @@ -66,108 +74,116 @@ export type FrequentlyBoughtTogetherConnectorParams<
};

export type FrequentlyBoughtTogetherWidgetDescription<
THit extends BaseHit = BaseHit
THit extends NonNullable<object> = BaseHit
> = {
$$type: 'ais.frequentlyBoughtTogether';
renderState: FrequentlyBoughtTogetherRenderState<THit>;
};

export type FrequentlyBoughtTogetherConnector<THit extends BaseHit = BaseHit> =
Connector<
FrequentlyBoughtTogetherWidgetDescription<THit>,
FrequentlyBoughtTogetherConnectorParams<THit>
>;

const connectFrequentlyBoughtTogether: FrequentlyBoughtTogetherConnector =
function connectFrequentlyBoughtTogether(renderFn, unmountFn = noop) {
checkRendering(renderFn, withUsage());

return (widgetParams) => {
const {
// @MAJOR: this can default to false
escapeHTML = true,
transformItems = ((items) => items) as NonNullable<
FrequentlyBoughtTogetherConnectorParams['transformItems']
>,
objectIDs,
limit,
threshold,
queryParameters,
} = widgetParams || {};

if (!objectIDs || objectIDs.length === 0) {
throw new Error(withUsage('The `objectIDs` option is required.'));
}

return {
dependsOn: 'recommend',
$$type: 'ais.frequentlyBoughtTogether',

init(initOptions) {
renderFn(
{
...this.getWidgetRenderState(initOptions),
instantSearchInstance: initOptions.instantSearchInstance,
},
true
);
},

render(renderOptions) {
const renderState = this.getWidgetRenderState(renderOptions);

renderFn(
{
...renderState,
instantSearchInstance: renderOptions.instantSearchInstance,
},
false
);
},

getRenderState(renderState) {
return renderState;
},

getWidgetRenderState({ results }) {
if (results === null || results === undefined) {
return { items: [], widgetParams };
}

if (escapeHTML && results.hits.length > 0) {
results.hits = escapeHits(results.hits);
}

const transformedItems = transformItems(results.hits, {
results: results as RecommendResultItem,
});

return { items: transformedItems, widgetParams };
},

dispose({ recommendState }) {
unmountFn();
return recommendState.removeParams(this.$$id!);
},

getWidgetParameters(state) {
return objectIDs.reduce(
(acc, objectID) =>
acc.addFrequentlyBoughtTogether({
objectID,
threshold,
maxRecommendations: limit,
queryParameters: {
...queryParameters,
...(escapeHTML ? TAG_PLACEHOLDER : {}),
},
$$id: this.$$id!,
}),
state.removeParams(this.$$id!)
);
},
};
export type FrequentlyBoughtTogetherConnector<
THit extends NonNullable<object> = BaseHit
> = Connector<
FrequentlyBoughtTogetherWidgetDescription<THit>,
FrequentlyBoughtTogetherConnectorParams<THit>
>;

export default (function connectFrequentlyBoughtTogether<
TWidgetParams extends UnknownWidgetParams
>(
renderFn: Renderer<
FrequentlyBoughtTogetherRenderState,
TWidgetParams & FrequentlyBoughtTogetherConnectorParams
>,
unmountFn: Unmounter = noop
) {
checkRendering(renderFn, withUsage());

return <THit extends NonNullable<object> = BaseHit>(
widgetParams: TWidgetParams & FrequentlyBoughtTogetherConnectorParams<THit>
) => {
const {
// @MAJOR: this can default to false
escapeHTML = true,
transformItems = ((items) => items) as NonNullable<
FrequentlyBoughtTogetherConnectorParams<THit>['transformItems']
>,
objectIDs,
limit,
threshold,
queryParameters,
} = widgetParams || {};

if (!objectIDs || objectIDs.length === 0) {
throw new Error(withUsage('The `objectIDs` option is required.'));
}

return {
dependsOn: 'recommend',
$$type: 'ais.frequentlyBoughtTogether',

init(initOptions) {
renderFn(
{
...this.getWidgetRenderState(initOptions),
instantSearchInstance: initOptions.instantSearchInstance,
},
true
);
},

render(renderOptions) {
const renderState = this.getWidgetRenderState(renderOptions);

renderFn(
{
...renderState,
instantSearchInstance: renderOptions.instantSearchInstance,
},
false
);
},

getRenderState(renderState) {
return renderState;
},

getWidgetRenderState({ results }) {
if (results === null || results === undefined) {
return { items: [], widgetParams };
}

if (escapeHTML && results.hits.length > 0) {
results.hits = escapeHits(results.hits);
}

const transformedItems = transformItems(results.hits, {
results: results as RecommendResultItem,
});

return { items: transformedItems, widgetParams };
},

dispose({ recommendState }) {
unmountFn();
return recommendState.removeParams(this.$$id!);
},

getWidgetParameters(state) {
return objectIDs.reduce(
(acc, objectID) =>
acc.addFrequentlyBoughtTogether({
objectID,
threshold,
maxRecommendations: limit,
queryParameters: {
...queryParameters,
...(escapeHTML ? TAG_PLACEHOLDER : {}),
},
$$id: this.$$id!,
}),
state.removeParams(this.$$id!)
);
},
};
};

export default connectFrequentlyBoughtTogether;
} satisfies FrequentlyBoughtTogetherConnector);
Loading

0 comments on commit 89dd7ae

Please sign in to comment.