Skip to content

Commit 37bbd01

Browse files
shortcutsHaroenv
andauthoredMar 19, 2021
feat(ts): convert stats, connectStats (#4681)
* feat(ts): convert stats, connectStats * Add type for `Relevant Sort` response Co-authored-by: Haroen Viaene <hello@haroen.me>
1 parent 8bc463b commit 37bbd01

File tree

15 files changed

+552
-529
lines changed

15 files changed

+552
-529
lines changed
 

‎src/components/Stats/Stats.js

-62
This file was deleted.

‎src/components/Stats/Stats.tsx

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/** @jsx h */
2+
3+
import { h } from 'preact';
4+
import cx from 'classnames';
5+
import { StatsCSSClasses, StatsTemplates } from '../../widgets/stats/stats';
6+
import Template from '../Template/Template';
7+
8+
type StatsProps = {
9+
cssClasses: StatsCSSClasses;
10+
templateProps: {
11+
[key: string]: any;
12+
templates: StatsTemplates;
13+
};
14+
hitsPerPage: number | undefined;
15+
nbHits: number;
16+
nbSortedHits: number | undefined;
17+
areHitsSorted: boolean;
18+
nbPages: number;
19+
page: number;
20+
processingTimeMS: number;
21+
query: string;
22+
};
23+
24+
const Stats = ({
25+
nbHits,
26+
nbSortedHits,
27+
cssClasses,
28+
templateProps,
29+
...rest
30+
}: StatsProps) => (
31+
<div className={cx(cssClasses.root)}>
32+
<Template
33+
{...templateProps}
34+
templateKey="text"
35+
rootTagName="span"
36+
rootProps={{ className: cssClasses.text }}
37+
data={{
38+
hasManySortedResults: nbSortedHits && nbSortedHits > 1,
39+
hasNoSortedResults: nbSortedHits === 0,
40+
hasOneSortedResults: nbSortedHits === 1,
41+
hasManyResults: nbHits > 1,
42+
hasNoResults: nbHits === 0,
43+
hasOneResult: nbHits === 1,
44+
nbHits,
45+
nbSortedHits,
46+
cssClasses,
47+
...rest,
48+
}}
49+
/>
50+
</div>
51+
);
52+
53+
export default Stats;

‎src/components/Stats/__tests__/Stats-test.js ‎src/components/Stats/__tests__/Stats-test.tsx

+55-34
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { mount } from 'enzyme';
55
import Stats from '../Stats';
66
import defaultTemplates from '../../../widgets/stats/defaultTemplates';
77
import createHelpers from '../../../lib/createHelpers';
8+
import { render } from '@testing-library/preact';
9+
import { ReactElementLike } from 'prop-types';
810

911
describe('Stats', () => {
1012
const cssClasses = {
@@ -14,7 +16,12 @@ describe('Stats', () => {
1416

1517
it('should render <Template data= />', () => {
1618
const wrapper = mount(
17-
<Stats {...getProps()} templateProps={{ templates: defaultTemplates }} />
19+
(
20+
<Stats
21+
{...getProps()}
22+
templateProps={{ templates: defaultTemplates }}
23+
/>
24+
) as ReactElementLike
1825
);
1926

2027
const defaultProps = {
@@ -25,25 +32,46 @@ describe('Stats', () => {
2532
};
2633

2734
expect(wrapper.find('Template').props().data).toMatchObject(defaultProps);
28-
expect(wrapper).toMatchSnapshot();
35+
expect(wrapper).toMatchInlineSnapshot(`
36+
Array [
37+
<div
38+
className="root"
39+
>
40+
Array [
41+
<span
42+
className="text"
43+
dangerouslySetInnerHTML={
44+
Object {
45+
"__html": "results found in 42ms",
46+
}
47+
}
48+
/>,
49+
]
50+
</div>,
51+
]
52+
`);
2953
});
3054

3155
it('should render <Stats /> with custom CSS classes', () => {
32-
const wrapper = mount(
56+
const { container } = render(
3357
<Stats
58+
{...getProps()}
3459
templateProps={{
3560
templates: defaultTemplates,
3661
}}
37-
nbHits={1}
38-
cssClasses={cssClasses}
62+
cssClasses={{
63+
root: 'customRoot',
64+
text: 'customText',
65+
}}
3966
/>
4067
);
4168

42-
expect(wrapper).toMatchSnapshot();
69+
expect(container.querySelector('.customRoot')).toBeInTheDocument();
70+
expect(container.querySelector('.customText')).toBeInTheDocument();
4371
});
4472

4573
it('should render sorted hits', () => {
46-
const wrapper = mount(
74+
const { container } = render(
4775
<Stats
4876
{...getProps({ nbSortedHits: 150, areHitsSorted: true })}
4977
templateProps={{
@@ -54,20 +82,17 @@ describe('Stats', () => {
5482
}}
5583
/>
5684
);
57-
expect(wrapper.find('.text')).toMatchInlineSnapshot(`
85+
expect(container.querySelector('.text')).toMatchInlineSnapshot(`
5886
<span
59-
className="text"
60-
dangerouslySetInnerHTML={
61-
Object {
62-
"__html": "150 relevant results sorted out of 1,234 found in 42ms",
63-
}
64-
}
65-
/>
87+
class="text"
88+
>
89+
150 relevant results sorted out of 1,234 found in 42ms
90+
</span>
6691
`);
6792
});
6893

6994
it('should render 1 sorted hit', () => {
70-
const wrapper = mount(
95+
const { container } = render(
7196
<Stats
7297
{...getProps({ nbSortedHits: 1, areHitsSorted: true })}
7398
templateProps={{
@@ -78,22 +103,19 @@ describe('Stats', () => {
78103
}}
79104
/>
80105
);
81-
expect(wrapper.find('.text')).toMatchInlineSnapshot(`
106+
expect(container.querySelector('.text')).toMatchInlineSnapshot(`
82107
<span
83-
className="text"
84-
dangerouslySetInnerHTML={
85-
Object {
86-
"__html": "1 relevant result sorted out of 1,234 found in 42ms",
87-
}
88-
}
89-
/>
108+
class="text"
109+
>
110+
1 relevant result sorted out of 1,234 found in 42ms
111+
</span>
90112
`);
91113
});
92114

93115
it('should render 0 sorted hit', () => {
94-
const wrapper = mount(
116+
const { container } = render(
95117
<Stats
96-
{...getProps({ nbSortedHits: 0, areHitsSorted: true })}
118+
{...getProps({ areHitsSorted: true })}
97119
templateProps={{
98120
templates: defaultTemplates,
99121
templatesConfig: {
@@ -102,21 +124,20 @@ describe('Stats', () => {
102124
}}
103125
/>
104126
);
105-
expect(wrapper.find('.text')).toMatchInlineSnapshot(`
127+
expect(container.querySelector('.text')).toMatchInlineSnapshot(`
106128
<span
107-
className="text"
108-
dangerouslySetInnerHTML={
109-
Object {
110-
"__html": "No relevant results sorted out of 1,234 found in 42ms",
111-
}
112-
}
113-
/>
129+
class="text"
130+
>
131+
No relevant results sorted out of 1,234 found in 42ms
132+
</span>
114133
`);
115134
});
116135

117136
function getProps(extraProps = {}) {
118137
return {
119138
cssClasses,
139+
areHitsSorted: false,
140+
nbSortedHits: 0,
120141
hitsPerPage: 10,
121142
nbHits: 1234,
122143
nbPages: 124,

‎src/components/Stats/__tests__/__snapshots__/Stats-test.js.snap

-39
This file was deleted.

‎src/connectors/stats/__tests__/connectStats-test.js ‎src/connectors/stats/__tests__/connectStats-test.ts

+47-33
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,20 @@ describe('connectStats', () => {
2121
index: 'indexName',
2222
});
2323

24-
widget.init(
24+
widget.init!(
2525
createInitOptions({
2626
helper,
2727
state: helper.state,
2828
})
2929
);
3030

31-
return [widget, helper];
31+
return [widget, helper] as const;
3232
};
3333

3434
describe('Usage', () => {
3535
it('throws without render function', () => {
3636
expect(() => {
37+
// @ts-expect-error
3738
connectStats()({});
3839
}).toThrowErrorMatchingInlineSnapshot(`
3940
"The render function is not valid (received type Undefined).
@@ -42,6 +43,15 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/stats/js/#c
4243
`);
4344
});
4445

46+
it('accepts not being passed widgetParams', () => {
47+
const render = jest.fn();
48+
const unmount = jest.fn();
49+
50+
const customStats = connectStats(render, unmount);
51+
// @ts-expect-error
52+
expect(() => customStats()).not.toThrow();
53+
});
54+
4555
it('is a widget', () => {
4656
const render = jest.fn();
4757
const unmount = jest.fn();
@@ -65,13 +75,13 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/stats/js/#c
6575
const renderFn = jest.fn();
6676
const unmountFn = jest.fn();
6777
const createStats = connectStats(renderFn, unmountFn);
68-
const stats = createStats();
78+
const stats = createStats({});
6979
const helper = jsHelper(createSearchClient(), 'indexName', {
7080
index: 'indexName',
7181
});
7282

7383
const renderState = stats.getRenderState(
74-
{ stats: {} },
84+
{},
7585
createInitOptions({ helper })
7686
);
7787

@@ -92,7 +102,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/stats/js/#c
92102
const [stats, helper] = getInitializedWidget();
93103

94104
const renderState = stats.getRenderState(
95-
{ stats: {} },
105+
{},
96106
createRenderOptions({
97107
helper,
98108
state: helper.state,
@@ -119,7 +129,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/stats/js/#c
119129
const [stats, helper] = getInitializedWidget();
120130

121131
const renderState = stats.getRenderState(
122-
{ stats: {} },
132+
{},
123133
createRenderOptions({
124134
helper,
125135
state: helper.state,
@@ -158,7 +168,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/stats/js/#c
158168
const renderFn = jest.fn();
159169
const unmountFn = jest.fn();
160170
const createStats = connectStats(renderFn, unmountFn);
161-
const stats = createStats();
171+
const stats = createStats({});
162172
const helper = jsHelper(createSearchClient(), 'indexName', {
163173
index: 'indexName',
164174
});
@@ -332,14 +342,10 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/stats/js/#c
332342
foo: 'bar', // dummy param to test `widgetParams`
333343
});
334344

335-
const helper = jsHelper({});
345+
const helper = jsHelper(createSearchClient(), '');
336346
helper.search = jest.fn();
337347

338-
widget.init({
339-
helper,
340-
state: helper.state,
341-
createURL: () => '#',
342-
});
348+
widget.init!(createInitOptions());
343349

344350
{
345351
// should call the rendering once with isFirstRendering to true
@@ -367,22 +373,28 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/stats/js/#c
367373
expect(widgetParams).toEqual({ foo: 'bar' });
368374
}
369375

370-
widget.render({
371-
results: new SearchResults(helper.state, [
372-
{
373-
hits: [{ One: 'record' }],
374-
nbPages: 1,
375-
nbHits: 1,
376-
hitsPerPage: helper.state.hitsPerPage,
377-
page: helper.state.page,
378-
query: '',
379-
processingTimeMS: 12,
380-
},
381-
]),
382-
state: helper.state,
383-
helper,
384-
createURL: () => '#',
385-
});
376+
widget.render!(
377+
createRenderOptions({
378+
createURL: () => '#',
379+
state: helper.state,
380+
helper,
381+
results: new SearchResults(helper.state, [
382+
createSingleSearchResponse({
383+
hits: [
384+
{
385+
objectID: 'string',
386+
},
387+
],
388+
nbPages: 1,
389+
nbHits: 1,
390+
hitsPerPage: helper.state.hitsPerPage,
391+
page: helper.state.page,
392+
query: '',
393+
processingTimeMS: 12,
394+
}),
395+
]),
396+
})
397+
);
386398

387399
{
388400
// Should call the rendering a second time, with isFirstRendering to false
@@ -400,10 +412,10 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/stats/js/#c
400412
processingTimeMS,
401413
query,
402414
} = rendering.mock.calls[rendering.mock.calls.length - 1][0];
403-
expect(hitsPerPage).toBe(helper.state.hitsPerPage);
415+
expect(hitsPerPage).toBe(20);
404416
expect(nbHits).toBe(1);
405417
expect(nbPages).toBe(1);
406-
expect(page).toBe(helper.state.page);
418+
expect(page).toBe(0);
407419
expect(processingTimeMS).toBe(12);
408420
expect(query).toBe('');
409421
}
@@ -413,7 +425,9 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/stats/js/#c
413425
const rendering = () => {};
414426
const makeWidget = connectStats(rendering);
415427
const widget = makeWidget({});
416-
const helper = jsHelper({});
417-
expect(() => widget.dispose({ helper, state: helper.state })).not.toThrow();
428+
const helper = jsHelper(createSearchClient(), '');
429+
expect(() =>
430+
widget.dispose!({ helper, state: helper.state })
431+
).not.toThrow();
418432
});
419433
});

‎src/connectors/stats/connectStats.js ‎src/connectors/stats/connectStats.ts

+52-41
Original file line numberDiff line numberDiff line change
@@ -3,58 +3,67 @@ import {
33
createDocumentationMessageGenerator,
44
noop,
55
} from '../../lib/utils';
6+
import { Connector } from '../../types';
67

78
const withUsage = createDocumentationMessageGenerator({
89
name: 'stats',
910
connector: true,
1011
});
1112

12-
/**
13-
* @typedef {Object} StatsRenderingOptions
14-
* @property {number} hitsPerPage The maximum number of hits per page returned by Algolia.
15-
* @property {number} nbHits The number of hits in the result set.
16-
* @property {number} nbPages The number of pages computed for the result set.
17-
* @property {number} page The current page.
18-
* @property {number} processingTimeMS The time taken to compute the results inside the Algolia engine.
19-
* @property {string} query The query used for the current search.
20-
* @property {object} widgetParams All original `CustomStatsWidgetParams` forwarded to the `renderFn`.
21-
*/
22-
23-
/**
24-
* @typedef {Object} CustomStatsWidgetParams
25-
*/
26-
2713
/**
2814
* **Stats** connector provides the logic to build a custom widget that will displays
2915
* search statistics (hits number and processing time).
30-
*
31-
* @type {Connector}
32-
* @param {function(StatsRenderingOptions, boolean)} renderFn Rendering function for the custom **Stats** widget.
33-
* @param {function} unmountFn Unmount function called when the widget is disposed.
34-
* @return {function(CustomStatsWidgetParams)} Re-usable widget factory for a custom **Stats** widget.
35-
* @example
36-
* // custom `renderFn` to render the custom Stats widget
37-
* function renderFn(StatsRenderingOptions, isFirstRendering) {
38-
* if (isFirstRendering) return;
39-
*
40-
* StatsRenderingOptions.widgetParams.containerNode
41-
* .html(StatsRenderingOptions.nbHits + ' results found in ' + StatsRenderingOptions.processingTimeMS);
42-
* }
43-
*
44-
* // connect `renderFn` to Stats logic
45-
* var customStatsWidget = instantsearch.connectors.connectStats(renderFn);
46-
*
47-
* // mount widget on the page
48-
* search.addWidgets([
49-
* customStatsWidget({
50-
* containerNode: $('#custom-stats-container'),
51-
* })
52-
* ]);
5316
*/
54-
export default function connectStats(renderFn, unmountFn = noop) {
17+
18+
export type StatsRendererOptions = {
19+
/**
20+
* The maximum number of hits per page returned by Algolia.
21+
*/
22+
hitsPerPage?: number;
23+
/**
24+
* The number of hits in the result set.
25+
*/
26+
nbHits: number;
27+
/**
28+
* The number of sorted hits in the result set (when using Relevant sort).
29+
*/
30+
nbSortedHits?: number;
31+
/**
32+
* Indicates whether the index is currently using Relevant sort and is displaying only sorted hits.
33+
*/
34+
areHitsSorted: boolean;
35+
/**
36+
* The number of pages computed for the result set.
37+
*/
38+
nbPages: number;
39+
/**
40+
* The current page.
41+
*/
42+
page: number;
43+
/**
44+
* The time taken to compute the results inside the Algolia engine.
45+
*/
46+
processingTimeMS: number;
47+
/**
48+
* The query used for the current search.
49+
*/
50+
query: string;
51+
};
52+
53+
export type StatsConnectorParams = Record<string, unknown>;
54+
55+
export type StatsConnector = Connector<
56+
StatsRendererOptions,
57+
StatsConnectorParams
58+
>;
59+
60+
const connectStats: StatsConnector = function connectStats(
61+
renderFn,
62+
unmountFn = noop
63+
) {
5564
checkRendering(renderFn, withUsage());
5665

57-
return (widgetParams = {}) => ({
66+
return widgetParams => ({
5867
$$type: 'ais.stats',
5968

6069
init(initOptions) {
@@ -123,4 +132,6 @@ export default function connectStats(renderFn, unmountFn = noop) {
123132
};
124133
},
125134
});
126-
}
135+
};
136+
137+
export default connectStats;

‎src/types/algoliasearch.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ type SearchForFacetValuesResponseV3 = AlgoliaSearch.SearchForFacetValues.Respons
1717
/** @ts-ignore */
1818
type SearchForFacetValuesResponseV4 = ClientSearch.SearchForFacetValuesResponse;
1919

20+
type RelevantSortResponse = {
21+
appliedRelevancyStrictness?: number;
22+
nbSortedHits?: number;
23+
};
24+
2025
type DummySearchClientV4 = {
2126
readonly transporter: any;
2227
};
@@ -38,7 +43,7 @@ export type SearchResponse<
3843
THit
3944
> = DefaultSearchClient extends DummySearchClientV4
4045
? SearchResponseV4<THit>
41-
: SearchResponseV3<THit> & { appliedRelevancyStrictness?: number };
46+
: SearchResponseV3<THit> & RelevantSortResponse;
4247

4348
export type SearchForFacetValuesResponse = DefaultSearchClient extends DummySearchClientV4
4449
? SearchForFacetValuesResponseV4

‎src/types/widget.ts

+5
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ import {
8383
RefinementListRendererOptions,
8484
RefinementListConnectorParams,
8585
} from '../connectors/refinement-list/connectRefinementList';
86+
import {
87+
StatsConnectorParams,
88+
StatsRendererOptions,
89+
} from '../connectors/stats/connectStats';
8690

8791
export type ScopedResult = {
8892
indexId: string;
@@ -353,6 +357,7 @@ export type IndexRenderState = Partial<{
353357
RelevantSortRendererOptions,
354358
RelevantSortConnectorParams
355359
>;
360+
stats: WidgetRenderState<StatsRendererOptions, StatsConnectorParams>;
356361
}>;
357362

358363
export type WidgetRenderState<

‎src/widgets/stats/__tests__/__snapshots__/stats-test.js.snap

-77
This file was deleted.

‎src/widgets/stats/__tests__/stats-test.js

-86
This file was deleted.
+162
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { render as preactRender } from 'preact';
2+
import stats from '../stats';
3+
import { castToJestMock } from '../../../../test/utils/castToJestMock';
4+
5+
const render = castToJestMock(preactRender);
6+
jest.mock('preact', () => {
7+
const module = jest.requireActual('preact');
8+
module.render = jest.fn();
9+
return module;
10+
});
11+
12+
const instantSearchInstance = { templatesConfig: undefined };
13+
14+
describe('Usage', () => {
15+
it('throws without container', () => {
16+
expect(() => {
17+
// @ts-expect-error
18+
stats({ container: undefined });
19+
}).toThrowErrorMatchingInlineSnapshot(`
20+
"The \`container\` option is required.
21+
22+
See documentation: https://www.algolia.com/doc/api-reference/widgets/stats/js/"
23+
`);
24+
});
25+
});
26+
27+
describe('stats()', () => {
28+
let container;
29+
let widget;
30+
31+
beforeEach(() => {
32+
container = document.createElement('div');
33+
widget = stats({ container, cssClasses: { text: ['text', 'cx'] } });
34+
35+
widget.init({
36+
helper: { state: {} },
37+
instantSearchInstance,
38+
});
39+
40+
render.mockClear();
41+
});
42+
43+
it('calls twice render(<Stats props />, container)', () => {
44+
const results = {
45+
hits: [{}, {}],
46+
nbHits: 20,
47+
page: 0,
48+
nbPages: 10,
49+
hitsPerPage: 2,
50+
processingTimeMS: 42,
51+
query: 'a query',
52+
};
53+
widget.render({ results, instantSearchInstance });
54+
widget.render({ results, instantSearchInstance });
55+
56+
const [firstRender, secondRender] = render.mock.calls;
57+
58+
expect(render).toHaveBeenCalledTimes(2);
59+
// @ts-expect-error
60+
expect(firstRender[0].props).toMatchInlineSnapshot(`
61+
Object {
62+
"areHitsSorted": false,
63+
"cssClasses": Object {
64+
"root": "ais-Stats",
65+
"text": "ais-Stats-text text cx",
66+
},
67+
"hitsPerPage": 2,
68+
"nbHits": 20,
69+
"nbPages": 10,
70+
"nbSortedHits": undefined,
71+
"page": 0,
72+
"processingTimeMS": 42,
73+
"query": "a query",
74+
"templateProps": Object {
75+
"templates": Object {
76+
"text": "
77+
{{#areHitsSorted}}
78+
{{#hasNoSortedResults}}No relevant results{{/hasNoSortedResults}}
79+
{{#hasOneSortedResults}}1 relevant result{{/hasOneSortedResults}}
80+
{{#hasManySortedResults}}{{#helpers.formatNumber}}{{nbSortedHits}}{{/helpers.formatNumber}} relevant results{{/hasManySortedResults}}
81+
sorted out of {{#helpers.formatNumber}}{{nbHits}}{{/helpers.formatNumber}}
82+
{{/areHitsSorted}}
83+
{{^areHitsSorted}}
84+
{{#hasNoResults}}No results{{/hasNoResults}}
85+
{{#hasOneResult}}1 result{{/hasOneResult}}
86+
{{#hasManyResults}}{{#helpers.formatNumber}}{{nbHits}}{{/helpers.formatNumber}} results{{/hasManyResults}}
87+
{{/areHitsSorted}}
88+
found in {{processingTimeMS}}ms",
89+
},
90+
"templatesConfig": undefined,
91+
"useCustomCompileOptions": Object {
92+
"text": false,
93+
},
94+
},
95+
}
96+
`);
97+
expect(firstRender[1]).toEqual(container);
98+
// @ts-expect-error
99+
expect(secondRender[0].props).toMatchInlineSnapshot(`
100+
Object {
101+
"areHitsSorted": false,
102+
"cssClasses": Object {
103+
"root": "ais-Stats",
104+
"text": "ais-Stats-text text cx",
105+
},
106+
"hitsPerPage": 2,
107+
"nbHits": 20,
108+
"nbPages": 10,
109+
"nbSortedHits": undefined,
110+
"page": 0,
111+
"processingTimeMS": 42,
112+
"query": "a query",
113+
"templateProps": Object {
114+
"templates": Object {
115+
"text": "
116+
{{#areHitsSorted}}
117+
{{#hasNoSortedResults}}No relevant results{{/hasNoSortedResults}}
118+
{{#hasOneSortedResults}}1 relevant result{{/hasOneSortedResults}}
119+
{{#hasManySortedResults}}{{#helpers.formatNumber}}{{nbSortedHits}}{{/helpers.formatNumber}} relevant results{{/hasManySortedResults}}
120+
sorted out of {{#helpers.formatNumber}}{{nbHits}}{{/helpers.formatNumber}}
121+
{{/areHitsSorted}}
122+
{{^areHitsSorted}}
123+
{{#hasNoResults}}No results{{/hasNoResults}}
124+
{{#hasOneResult}}1 result{{/hasOneResult}}
125+
{{#hasManyResults}}{{#helpers.formatNumber}}{{nbHits}}{{/helpers.formatNumber}} results{{/hasManyResults}}
126+
{{/areHitsSorted}}
127+
found in {{processingTimeMS}}ms",
128+
},
129+
"templatesConfig": undefined,
130+
"useCustomCompileOptions": Object {
131+
"text": false,
132+
},
133+
},
134+
}
135+
`);
136+
expect(secondRender[1]).toEqual(container);
137+
});
138+
139+
it('renders sorted hits', () => {
140+
const results = {
141+
hits: [{}, {}],
142+
nbHits: 20,
143+
nbSortedHits: 16,
144+
appliedRelevancyStrictness: 20,
145+
page: 0,
146+
nbPages: 10,
147+
hitsPerPage: 2,
148+
processingTimeMS: 42,
149+
query: 'second query',
150+
};
151+
widget.render({ results, instantSearchInstance });
152+
153+
const [firstRender] = render.mock.calls;
154+
// @ts-expect-error
155+
expect(firstRender[0].props).toEqual(
156+
expect.objectContaining({
157+
areHitsSorted: true,
158+
nbSortedHits: 16,
159+
})
160+
);
161+
});
162+
});

‎src/widgets/stats/defaultTemplates.js

-15
This file was deleted.

‎src/widgets/stats/defaultTemplates.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { defaultTemplates as default } from './stats';

‎src/widgets/stats/stats.js

-141
This file was deleted.

‎src/widgets/stats/stats.tsx

+171
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/** @jsx h */
2+
3+
import { h, render } from 'preact';
4+
import cx from 'classnames';
5+
import Stats from '../../components/Stats/Stats';
6+
import connectStats, {
7+
StatsConnectorParams,
8+
StatsRendererOptions,
9+
} from '../../connectors/stats/connectStats';
10+
import {
11+
prepareTemplateProps,
12+
getContainerNode,
13+
createDocumentationMessageGenerator,
14+
} from '../../lib/utils';
15+
import { component } from '../../lib/suit';
16+
import { Renderer, Template, WidgetFactory } from '../../types';
17+
18+
const withUsage = createDocumentationMessageGenerator({ name: 'stats' });
19+
const suit = component('Stats');
20+
21+
export type StatsCSSClasses = {
22+
/**
23+
* CSS class to add to the root element.
24+
*/
25+
root: string | string[];
26+
27+
/**
28+
* CSS class to add to the text span element.
29+
*/
30+
text: string | string[];
31+
};
32+
33+
export type StatsTemplates = {
34+
/**
35+
* Text template, provided with `hasManyResults`, `hasNoResults`, `hasOneResult`, `hitsPerPage`, `nbHits`, `nbSortedHits`, `nbPages`, `areHitsSorted`, `page`, `processingTimeMS`, `query`.
36+
*/
37+
text: Template<
38+
{
39+
hasManyResults: boolean;
40+
hasNoResults: boolean;
41+
hasOneResult: boolean;
42+
} & StatsRendererOptions
43+
>;
44+
};
45+
46+
export type StatsWidgetParams = {
47+
/**
48+
* CSS Selector or HTMLElement to insert the widget.
49+
*/
50+
container: string | HTMLElement;
51+
52+
/**
53+
* Templates to use for the widget.
54+
*/
55+
templates?: Partial<StatsTemplates>;
56+
57+
/**
58+
* CSS classes to add.
59+
*/
60+
cssClasses?: Partial<StatsCSSClasses>;
61+
};
62+
63+
export type StatsWidget = WidgetFactory<
64+
StatsRendererOptions,
65+
StatsConnectorParams,
66+
StatsWidgetParams
67+
>;
68+
69+
export const defaultTemplates: StatsTemplates = {
70+
text: `
71+
{{#areHitsSorted}}
72+
{{#hasNoSortedResults}}No relevant results{{/hasNoSortedResults}}
73+
{{#hasOneSortedResults}}1 relevant result{{/hasOneSortedResults}}
74+
{{#hasManySortedResults}}{{#helpers.formatNumber}}{{nbSortedHits}}{{/helpers.formatNumber}} relevant results{{/hasManySortedResults}}
75+
sorted out of {{#helpers.formatNumber}}{{nbHits}}{{/helpers.formatNumber}}
76+
{{/areHitsSorted}}
77+
{{^areHitsSorted}}
78+
{{#hasNoResults}}No results{{/hasNoResults}}
79+
{{#hasOneResult}}1 result{{/hasOneResult}}
80+
{{#hasManyResults}}{{#helpers.formatNumber}}{{nbHits}}{{/helpers.formatNumber}} results{{/hasManyResults}}
81+
{{/areHitsSorted}}
82+
found in {{processingTimeMS}}ms`,
83+
};
84+
85+
const renderer = ({
86+
renderState,
87+
cssClasses,
88+
containerNode,
89+
templates,
90+
}): Renderer<StatsRendererOptions, Partial<StatsWidgetParams>> => (
91+
{
92+
hitsPerPage,
93+
nbHits,
94+
nbSortedHits,
95+
areHitsSorted,
96+
nbPages,
97+
page,
98+
processingTimeMS,
99+
query,
100+
instantSearchInstance,
101+
},
102+
isFirstRendering
103+
) => {
104+
if (isFirstRendering) {
105+
renderState.templateProps = prepareTemplateProps({
106+
defaultTemplates,
107+
templatesConfig: instantSearchInstance.templatesConfig,
108+
templates,
109+
});
110+
111+
return;
112+
}
113+
114+
render(
115+
<Stats
116+
cssClasses={cssClasses}
117+
hitsPerPage={hitsPerPage}
118+
nbHits={nbHits}
119+
nbSortedHits={nbSortedHits}
120+
areHitsSorted={areHitsSorted}
121+
nbPages={nbPages}
122+
page={page}
123+
processingTimeMS={processingTimeMS}
124+
query={query}
125+
templateProps={renderState.templateProps}
126+
/>,
127+
containerNode
128+
);
129+
};
130+
131+
/**
132+
* The `stats` widget is used to display useful insights about the current results.
133+
*
134+
* By default, it will display the **number of hits** and the time taken to compute the
135+
* results inside the engine.
136+
*/
137+
const stats: StatsWidget = widgetParams => {
138+
const {
139+
container,
140+
cssClasses: userCssClasses = {} as StatsCSSClasses,
141+
templates = defaultTemplates,
142+
} = widgetParams || {};
143+
if (!container) {
144+
throw new Error(withUsage('The `container` option is required.'));
145+
}
146+
147+
const containerNode = getContainerNode(container);
148+
149+
const cssClasses: StatsCSSClasses = {
150+
root: cx(suit(), userCssClasses.root),
151+
text: cx(suit({ descendantName: 'text' }), userCssClasses.text),
152+
};
153+
154+
const specializedRenderer = renderer({
155+
containerNode,
156+
cssClasses,
157+
templates,
158+
renderState: {},
159+
});
160+
161+
const makeWidget = connectStats(specializedRenderer, () =>
162+
render(null, containerNode)
163+
);
164+
165+
return {
166+
...makeWidget({}),
167+
$$widgetType: 'ais.stats',
168+
};
169+
};
170+
171+
export default stats;

0 commit comments

Comments
 (0)
Please sign in to comment.