Skip to content

Commit 870e2f7

Browse files
Yannick CroissantHaroenv
Yannick Croissant
andauthoredApr 8, 2021
feat(ts): convert hierarchical-menu to TypeScript (#4711)
* feat(ts): convert rating-menu to TypeScript * feat(ts): convert RefinementList to TypeScript * fixup! feat(ts): convert RefinementList to TypeScript * fixup! feat(ts): convert RefinementList to TypeScript * fixup! fixup! feat(ts): convert RefinementList to TypeScript * Update src/components/RefinementList/RefinementList.tsx Co-authored-by: Haroen Viaene <hello@haroen.me> * fixup! feat(ts): convert RefinementList to TypeScript * fixup! feat(ts): convert RefinementList to TypeScript * feat(ts): convert hierarchical-menu to TypeScript * Apply suggestions from code review Co-authored-by: Haroen Viaene <hello@haroen.me> * fixup! feat(ts): convert hierarchical-menu to TypeScript * avoid casting * deduplicate Co-authored-by: Haroen Viaene <hello@haroen.me>
1 parent 40b27b6 commit 870e2f7

File tree

10 files changed

+913
-521
lines changed

10 files changed

+913
-521
lines changed
 

‎src/components/RefinementList/RefinementList.tsx

+23-6
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import Template from '../Template/Template';
88
import RefinementListItem from './RefinementListItem';
99
import SearchBox from '../SearchBox/SearchBox';
1010
import { RefinementListItem as TRefinementListItem } from '../../connectors/refinement-list/connectRefinementList';
11+
import { HierarchicalMenuItem } from '../../connectors/hierarchical-menu/connectHierarchicalMenu';
1112
import {
1213
SearchBoxRendererCSSClasses,
1314
SearchBoxTemplates,
@@ -19,14 +20,17 @@ type CSSClasses = {
1920
[key: string]: any;
2021
};
2122

23+
type FacetValue = TRefinementListItem | HierarchicalMenuItem;
24+
type FacetValues = TRefinementListItem[] | HierarchicalMenuItem[];
25+
2226
export type RefinementListProps<
2327
TTemplates extends Templates,
2428
TCSSClasses extends CSSClasses
2529
> = {
2630
createURL: CreateURL<string>;
2731
cssClasses: TCSSClasses;
2832
depth?: number;
29-
facetValues?: TRefinementListItem[];
33+
facetValues?: FacetValues;
3034
attribute?: string;
3135
templateProps?: PreparedTemplateProps<TTemplates>;
3236
searchBoxTemplateProps?: PreparedTemplateProps<SearchBoxTemplates>;
@@ -58,13 +62,19 @@ type RefinementListPropsWithDefaultProps<
5862
type RefinementListItemTemplateData<
5963
TTemplates extends Templates,
6064
TCSSClasses extends CSSClasses
61-
> = TRefinementListItem & {
65+
> = FacetValue & {
6266
url: string;
6367
} & Pick<
6468
RefinementListProps<TTemplates, TCSSClasses>,
6569
'attribute' | 'cssClasses' | 'isFromSearch'
6670
>;
6771

72+
function isHierarchicalMenuItem(
73+
facetValue: FacetValue
74+
): facetValue is HierarchicalMenuItem {
75+
return (facetValue as HierarchicalMenuItem).data !== undefined;
76+
}
77+
6878
class RefinementList<
6979
TTemplates extends Templates,
7080
TCSSClasses extends CSSClasses
@@ -97,9 +107,13 @@ class RefinementList<
97107
this.props.toggleRefinement(facetValueToRefine);
98108
}
99109

100-
private _generateFacetItem(facetValue: TRefinementListItem) {
110+
private _generateFacetItem(facetValue: FacetValue) {
101111
let subItems;
102-
if (facetValue.data && facetValue.data.length > 0) {
112+
if (
113+
isHierarchicalMenuItem(facetValue) &&
114+
Array.isArray(facetValue.data) &&
115+
facetValue.data.length > 0
116+
) {
103117
const { root, ...cssClasses } = this.props.cssClasses;
104118
subItems = (
105119
<RefinementList
@@ -138,7 +152,9 @@ class RefinementList<
138152
[this.props.cssClasses.selectedItem]: facetValue.isRefined,
139153
[this.props.cssClasses.disabledItem]: !facetValue.count,
140154
[this.props.cssClasses.parentItem]:
141-
facetValue.data && facetValue.data.length > 0,
155+
isHierarchicalMenuItem(facetValue) &&
156+
Array.isArray(facetValue.data) &&
157+
facetValue.data.length > 0,
142158
});
143159

144160
return (
@@ -303,7 +319,8 @@ class RefinementList<
303319
const facetValues = this.props.facetValues &&
304320
this.props.facetValues.length > 0 && (
305321
<ul className={this.props.cssClasses.list}>
306-
{this.props.facetValues.map(this._generateFacetItem, this)}
322+
{// @ts-expect-error until TS > 4.2.3 is used https://github.com/microsoft/TypeScript/commit/b217f22e798c781f55d17da72ed099a9dee5c650
323+
this.props.facetValues.map(this._generateFacetItem, this)}
307324
</ul>
308325
);
309326

‎src/components/RefinementList/__tests__/RefinementList-test.tsx

+28-4
Original file line numberDiff line numberDiff line change
@@ -340,8 +340,20 @@ describe('RefinementList', () => {
340340
label: 'foo',
341341
count: 1,
342342
data: [
343-
{ value: 'bar', label: 'bar', count: 2, isRefined: false },
344-
{ value: 'baz', label: 'baz', count: 3, isRefined: false },
343+
{
344+
value: 'bar',
345+
label: 'bar',
346+
count: 2,
347+
isRefined: false,
348+
data: null,
349+
},
350+
{
351+
value: 'baz',
352+
label: 'baz',
353+
count: 3,
354+
isRefined: false,
355+
data: null,
356+
},
345357
],
346358
isRefined: false,
347359
},
@@ -382,8 +394,20 @@ describe('RefinementList', () => {
382394
label: 'foo',
383395
count: 1,
384396
data: [
385-
{ value: 'bar', label: 'bar', count: 2, isRefined: false },
386-
{ value: 'baz', label: 'baz', count: 3, isRefined: false },
397+
{
398+
value: 'bar',
399+
label: 'bar',
400+
count: 2,
401+
isRefined: false,
402+
data: null,
403+
},
404+
{
405+
value: 'baz',
406+
label: 'baz',
407+
count: 3,
408+
isRefined: false,
409+
data: null,
410+
},
387411
],
388412
isRefined: false,
389413
},

‎src/connectors/hierarchical-menu/__tests__/connectHierarchicalMenu-test.js ‎src/connectors/hierarchical-menu/__tests__/connectHierarchicalMenu-test.ts

+156-110
Large diffs are not rendered by default.

‎src/connectors/hierarchical-menu/connectHierarchicalMenu.js ‎src/connectors/hierarchical-menu/connectHierarchicalMenu.ts

+191-86
Original file line numberDiff line numberDiff line change
@@ -5,45 +5,122 @@ import {
55
createSendEventForFacet,
66
isEqual,
77
noop,
8+
SendEventForFacet,
89
} from '../../lib/utils';
10+
import { SearchResults } from 'algoliasearch-helper';
11+
import {
12+
Connector,
13+
CreateURL,
14+
TransformItems,
15+
RenderOptions,
16+
Widget,
17+
SortBy,
18+
} from '../../types';
919

1020
const withUsage = createDocumentationMessageGenerator({
1121
name: 'hierarchical-menu',
1222
connector: true,
1323
});
1424

15-
/**
16-
* @typedef {Object} HierarchicalMenuItem
17-
* @property {string} value Value of the menu item.
18-
* @property {string} label Human-readable value of the menu item.
19-
* @property {number} count Number of matched results after refinement is applied.
20-
* @property {isRefined} boolean Indicates if the refinement is applied.
21-
* @property {Object} [data = undefined] n+1 level of items, same structure HierarchicalMenuItem (default: `undefined`).
22-
*/
23-
24-
/**
25-
* @typedef {Object} CustomHierarchicalMenuWidgetParams
26-
* @property {string[]} attributes Attributes to use to generate the hierarchy of the menu.
27-
* @property {string} [separator = '>'] Separator used in the attributes to separate level values.
28-
* @property {string} [rootPath = null] Prefix path to use if the first level is not the root level.
29-
* @property {boolean} [showParentLevel=false] Show the siblings of the selected parent levels of the current refined value. This
30-
* does not impact the root level.
31-
* @property {number} [limit = 10] Max number of values to display.
32-
* @property {boolean} [showMore = false] Whether to display the "show more" button.
33-
* @property {number} [showMoreLimit = 20] Max number of values to display when showing more.
34-
* @property {string[]|function} [sortBy = ['name:asc']] How to sort refinements. Possible values: `count|isRefined|name:asc|name:desc`.
35-
*
36-
* You can also use a sort function that behaves like the standard Javascript [compareFunction](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#Syntax).
37-
* @property {function(object[]):object[]} [transformItems] Function to transform the items passed to the templates.
38-
*/
39-
40-
/**
41-
* @typedef {Object} HierarchicalMenuRenderingOptions
42-
* @property {function(item.value): string} createURL Creates an url for the next state for a clicked item.
43-
* @property {HierarchicalMenuItem[]} items Values to be rendered.
44-
* @property {function(item.value)} refine Sets the path of the hierarchical filter and triggers a new search.
45-
* @property {Object} widgetParams All original `CustomHierarchicalMenuWidgetParams` forwarded to the `renderFn`.
46-
*/
25+
export type HierarchicalMenuItem = {
26+
/**
27+
* Value of the menu item.
28+
*/
29+
value: string;
30+
/**
31+
* Human-readable value of the menu item.
32+
*/
33+
label: string;
34+
/**
35+
* Number of matched results after refinement is applied.
36+
*/
37+
count: number;
38+
/**
39+
* Indicates if the refinement is applied.
40+
*/
41+
isRefined: boolean;
42+
/**
43+
* n+1 level of items, same structure HierarchicalMenuItem
44+
*/
45+
data: HierarchicalMenuItem[] | null;
46+
};
47+
48+
export type HierarchicalMenuConnectorParams = {
49+
/**
50+
* Attributes to use to generate the hierarchy of the menu.
51+
*/
52+
attributes: string[];
53+
/**
54+
* Separator used in the attributes to separate level values.
55+
*/
56+
separator?: string;
57+
/**
58+
* Prefix path to use if the first level is not the root level.
59+
*/
60+
rootPath?: string | null;
61+
/**
62+
* Show the siblings of the selected parent levels of the current refined value. This
63+
* does not impact the root level.
64+
*/
65+
showParentLevel?: boolean;
66+
/**
67+
* Max number of values to display.
68+
*/
69+
limit?: number;
70+
/**
71+
* Whether to display the "show more" button.
72+
*/
73+
showMore?: boolean;
74+
/**
75+
* Max number of values to display when showing more.
76+
*/
77+
showMoreLimit?: number;
78+
/**
79+
* How to sort refinements. Possible values: `count|isRefined|name:asc|name:desc`.
80+
* You can also use a sort function that behaves like the standard Javascript [compareFunction](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#Syntax).
81+
*/
82+
sortBy?: SortBy<HierarchicalMenuItem>;
83+
/**
84+
* Function to transform the items passed to the templates.
85+
*/
86+
transformItems?: TransformItems<HierarchicalMenuItem>;
87+
};
88+
89+
export type HierarchicalMenuRendererOptions = {
90+
/**
91+
* Creates an url for the next state for a clicked item.
92+
*/
93+
createURL: CreateURL<string>;
94+
/**
95+
* Values to be rendered.
96+
*/
97+
items: HierarchicalMenuItem[];
98+
/**
99+
* Sets the path of the hierarchical filter and triggers a new search.
100+
*/
101+
refine: (value: string) => void;
102+
/**
103+
* Indicates if search state can be refined.
104+
*/
105+
canRefine: boolean;
106+
/**
107+
* True if the menu is displaying all the menu items.
108+
*/
109+
isShowingMore: boolean;
110+
/**
111+
* Toggles the number of values displayed between `limit` and `showMoreLimit`.
112+
*/
113+
toggleShowMore: () => void;
114+
/**
115+
* `true` if the toggleShowMore button can be activated (enough items to display more or
116+
* already displaying more than `limit` items)
117+
*/
118+
canToggleShowMore: boolean;
119+
/**
120+
* Send event to insights middleware
121+
*/
122+
sendEvent: SendEventForFacet;
123+
};
47124

48125
/**
49126
* **HierarchicalMenu** connector provides the logic to build a custom widget
@@ -58,10 +135,18 @@ const withUsage = createDocumentationMessageGenerator({
58135
* @param {function} unmountFn Unmount function called when the widget is disposed.
59136
* @return {function(CustomHierarchicalMenuWidgetParams)} Re-usable widget factory for a custom **HierarchicalMenu** widget.
60137
*/
61-
export default function connectHierarchicalMenu(renderFn, unmountFn = noop) {
138+
export type ConnectHierarchicalMenu = Connector<
139+
HierarchicalMenuRendererOptions,
140+
HierarchicalMenuConnectorParams
141+
>;
142+
143+
const connectHierarchicalMenu: ConnectHierarchicalMenu = function connectHierarchicalMenu(
144+
renderFn,
145+
unmountFn = noop
146+
) {
62147
checkRendering(renderFn, withUsage());
63148

64-
return (widgetParams = {}) => {
149+
return widgetParams => {
65150
const {
66151
attributes,
67152
separator = ' > ',
@@ -71,8 +156,8 @@ export default function connectHierarchicalMenu(renderFn, unmountFn = noop) {
71156
showMore = false,
72157
showMoreLimit = 20,
73158
sortBy = ['name:asc'],
74-
transformItems = items => items,
75-
} = widgetParams;
159+
transformItems = (items => items) as TransformItems<HierarchicalMenuItem>,
160+
} = widgetParams || {};
76161

77162
if (!attributes || !Array.isArray(attributes) || attributes.length === 0) {
78163
throw new Error(
@@ -90,7 +175,7 @@ export default function connectHierarchicalMenu(renderFn, unmountFn = noop) {
90175
// so that we can always map $hierarchicalFacetName => real attributes
91176
// we use the first attribute name
92177
const [hierarchicalFacetName] = attributes;
93-
let sendEvent;
178+
let sendEvent: HierarchicalMenuRendererOptions['sendEvent'];
94179

95180
// Provide the same function to the `renderFn` so that way the user
96181
// has to only bind it once when `isFirstRendering` for instance
@@ -99,21 +184,45 @@ export default function connectHierarchicalMenu(renderFn, unmountFn = noop) {
99184
toggleShowMore();
100185
}
101186

102-
return {
103-
$$type: 'ais.hierarchicalMenu',
187+
let _refine: HierarchicalMenuRendererOptions['refine'] | undefined;
104188

105-
isShowingMore: false,
189+
let isShowingMore = false;
106190

107-
createToggleShowMore(renderOptions) {
108-
return () => {
109-
this.isShowingMore = !this.isShowingMore;
110-
this.render(renderOptions);
111-
};
112-
},
191+
function createToggleShowMore(
192+
renderOptions: RenderOptions,
193+
widget: Widget
194+
) {
195+
return () => {
196+
isShowingMore = !isShowingMore;
197+
widget.render!(renderOptions);
198+
};
199+
}
113200

114-
getLimit() {
115-
return this.isShowingMore ? showMoreLimit : limit;
116-
},
201+
function getLimit() {
202+
return isShowingMore ? showMoreLimit : limit;
203+
}
204+
205+
function _prepareFacetValues(
206+
facetValues: SearchResults.HierarchicalFacet[]
207+
): HierarchicalMenuItem[] {
208+
return facetValues
209+
.slice(0, getLimit())
210+
.map(({ name: label, path: value, data, ...subValue }) => {
211+
const item: HierarchicalMenuItem = {
212+
...subValue,
213+
label,
214+
value,
215+
data: null,
216+
};
217+
if (Array.isArray(data)) {
218+
item.data = _prepareFacetValues(data);
219+
}
220+
return item;
221+
});
222+
}
223+
224+
return {
225+
$$type: 'ais.hierarchicalMenu',
117226

118227
init(initOptions) {
119228
const { instantSearchInstance } = initOptions;
@@ -127,21 +236,10 @@ export default function connectHierarchicalMenu(renderFn, unmountFn = noop) {
127236
);
128237
},
129238

130-
_prepareFacetValues(facetValues) {
131-
return facetValues
132-
.slice(0, this.getLimit())
133-
.map(({ name: label, path: value, ...subValue }) => {
134-
if (Array.isArray(subValue.data)) {
135-
subValue.data = this._prepareFacetValues(subValue.data);
136-
}
137-
return { ...subValue, label, value };
138-
});
139-
},
140-
141239
render(renderOptions) {
142240
const { instantSearchInstance } = renderOptions;
143241

144-
toggleShowMore = this.createToggleShowMore(renderOptions);
242+
toggleShowMore = createToggleShowMore(renderOptions, this);
145243

146244
renderFn(
147245
{
@@ -182,8 +280,11 @@ export default function connectHierarchicalMenu(renderFn, unmountFn = noop) {
182280
instantSearchInstance,
183281
helper,
184282
}) {
283+
let items: HierarchicalMenuRendererOptions['items'] = [];
284+
let canToggleShowMore = false;
285+
185286
// Bind createURL to this specific attribute
186-
function _createURL(facetValue) {
287+
function _createURL(facetValue: string) {
187288
return createURL(
188289
state
189290
.resetPage()
@@ -196,54 +297,55 @@ export default function connectHierarchicalMenu(renderFn, unmountFn = noop) {
196297
instantSearchInstance,
197298
helper,
198299
attribute: hierarchicalFacetName,
199-
widgetType: this.$$type,
300+
widgetType: this.$$type!,
200301
});
201302
}
202303

203-
if (!this._refine) {
204-
this._refine = function(facetValue) {
304+
if (!_refine) {
305+
_refine = function(facetValue) {
205306
sendEvent('click', facetValue);
206307
helper
207308
.toggleFacetRefinement(hierarchicalFacetName, facetValue)
208309
.search();
209310
};
210311
}
211312

212-
const facetValues = results
213-
? results.getFacetValues(hierarchicalFacetName, { sortBy }).data || []
214-
: [];
215-
const items = transformItems(
216-
results ? this._prepareFacetValues(facetValues) : []
217-
);
218-
219-
const getHasExhaustiveItems = () => {
220-
if (!results) {
221-
return false;
222-
}
313+
if (results) {
314+
const facetValues = results.getFacetValues(hierarchicalFacetName, {
315+
sortBy,
316+
});
317+
const facetItems =
318+
facetValues && !Array.isArray(facetValues) && facetValues.data
319+
? facetValues.data
320+
: [];
223321

224-
const currentLimit = this.getLimit();
225322
// If the limit is the max number of facet retrieved it is impossible to know
226323
// if the facets are exhaustive. The only moment we are sure it is exhaustive
227324
// is when it is strictly under the number requested unless we know that another
228325
// widget has requested more values (maxValuesPerFacet > getLimit()).
229326
// Because this is used for making the search of facets unable or not, it is important
230327
// to be conservative here.
231-
return state.maxValuesPerFacet > currentLimit
232-
? facetValues.length <= currentLimit
233-
: facetValues.length < currentLimit;
234-
};
328+
const hasExhaustiveItems =
329+
(state.maxValuesPerFacet || 0) > getLimit()
330+
? facetItems.length <= getLimit()
331+
: facetItems.length < getLimit();
332+
333+
canToggleShowMore =
334+
showMore && (isShowingMore || !hasExhaustiveItems);
335+
336+
items = transformItems(_prepareFacetValues(facetItems));
337+
}
235338

236339
return {
237340
items,
238-
refine: this._refine,
341+
refine: _refine,
239342
canRefine: items.length > 0,
240343
createURL: _createURL,
241344
sendEvent,
242345
widgetParams,
243-
isShowingMore: this.isShowingMore,
346+
isShowingMore,
244347
toggleShowMore: cachedToggleShowMore,
245-
canToggleShowMore:
246-
showMore && (this.isShowingMore || !getHasExhaustiveItems()),
348+
canToggleShowMore,
247349
};
248350
},
249351

@@ -290,6 +392,7 @@ export default function connectHierarchicalMenu(renderFn, unmountFn = noop) {
290392
attributes,
291393
separator,
292394
rootPath,
395+
// @ts-ignore `showParentLevel` is missing in the SearchParameters.HierarchicalFacet declaration
293396
showParentLevel,
294397
});
295398

@@ -322,4 +425,6 @@ export default function connectHierarchicalMenu(renderFn, unmountFn = noop) {
322425
},
323426
};
324427
};
325-
}
428+
};
429+
430+
export default connectHierarchicalMenu;

‎src/types/widget.ts

+6-20
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
PlainSearchParameters,
77
} from 'algoliasearch-helper';
88
import { InstantSearch, Hit, GeoLoc } from './instantsearch';
9-
import { TransformItems } from './connector';
109
import { BindEventForHits, SendEventForHits } from '../lib/utils';
1110
import {
1211
AutocompleteRendererOptions,
@@ -82,6 +81,10 @@ import {
8281
MenuConnectorParams,
8382
MenuRendererOptions,
8483
} from '../connectors/menu/connectMenu';
84+
import {
85+
HierarchicalMenuConnectorParams,
86+
HierarchicalMenuRendererOptions,
87+
} from '../connectors/hierarchical-menu/connectHierarchicalMenu';
8588
import {
8689
RefinementListRendererOptions,
8790
RefinementListConnectorParams,
@@ -256,25 +259,8 @@ export type IndexRenderState = Partial<{
256259
};
257260
hierarchicalMenu: {
258261
[attribute: string]: WidgetRenderState<
259-
{
260-
items: any[];
261-
refine(facetValue: any): void;
262-
createURL(facetValue: any): string;
263-
isShowingMore: boolean;
264-
toggleShowMore(): void;
265-
canToggleShowMore: boolean;
266-
},
267-
{
268-
attributes: string[];
269-
separator: string;
270-
rootPath: string | null;
271-
showParentLevel: boolean;
272-
limit: number;
273-
showMore: boolean;
274-
showMoreLimit: number;
275-
sortBy: any;
276-
transformItems: TransformItems<any>;
277-
}
262+
HierarchicalMenuRendererOptions,
263+
HierarchicalMenuConnectorParams
278264
>;
279265
};
280266
hits: WidgetRenderState<HitsRendererOptions, HitsConnectorParams>;

‎src/widgets/hierarchical-menu/__tests__/__snapshots__/hierarchical-menu-test.js.snap ‎src/widgets/hierarchical-menu/__tests__/__snapshots__/hierarchical-menu-test.ts.snap

+32-8
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,18 @@ Object {
2121
"depth": 0,
2222
"facetValues": Array [
2323
Object {
24+
"count": 1,
25+
"data": Array [],
26+
"isRefined": false,
2427
"label": "foo",
25-
"value": undefined,
28+
"value": "",
2629
},
2730
Object {
31+
"count": 2,
32+
"data": Array [],
33+
"isRefined": false,
2834
"label": "bar",
29-
"value": undefined,
35+
"value": "",
3036
},
3137
],
3238
"isShowingMore": false,
@@ -75,12 +81,18 @@ Object {
7581
"depth": 0,
7682
"facetValues": Array [
7783
Object {
84+
"count": 1,
85+
"data": Array [],
86+
"isRefined": false,
7887
"label": "foo",
79-
"value": undefined,
88+
"value": "",
8089
},
8190
Object {
91+
"count": 2,
92+
"data": Array [],
93+
"isRefined": false,
8294
"label": "bar",
83-
"value": undefined,
95+
"value": "",
8496
},
8597
],
8698
"isShowingMore": false,
@@ -129,14 +141,20 @@ Object {
129141
"depth": 0,
130142
"facetValues": Array [
131143
Object {
144+
"count": 1,
145+
"data": Array [],
146+
"isRefined": false,
132147
"label": "foo",
133148
"transformed": true,
134-
"value": undefined,
149+
"value": "",
135150
},
136151
Object {
152+
"count": 2,
153+
"data": Array [],
154+
"isRefined": false,
137155
"label": "bar",
138156
"transformed": true,
139-
"value": undefined,
157+
"value": "",
140158
},
141159
],
142160
"isShowingMore": false,
@@ -230,12 +248,18 @@ Object {
230248
"depth": 0,
231249
"facetValues": Array [
232250
Object {
251+
"count": 1,
252+
"data": Array [],
253+
"isRefined": false,
233254
"label": "foo",
234-
"value": undefined,
255+
"value": "",
235256
},
236257
Object {
258+
"count": 2,
259+
"data": Array [],
260+
"isRefined": false,
237261
"label": "bar",
238-
"value": undefined,
262+
"value": "",
239263
},
240264
],
241265
"isShowingMore": false,

‎src/widgets/hierarchical-menu/__tests__/hierarchical-menu-test.js

-221
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
import { render } from 'preact';
2+
import algoliasearchHelper, {
3+
AlgoliaSearchHelper,
4+
SearchParameters,
5+
SearchResults,
6+
} from 'algoliasearch-helper';
7+
import hierarchicalMenu, {
8+
HierarchicalMenuWidgetParams,
9+
} from '../hierarchical-menu';
10+
import {
11+
HierarchicalMenuConnectorParams,
12+
HierarchicalMenuRendererOptions,
13+
} from '../../../connectors/hierarchical-menu/connectHierarchicalMenu';
14+
import {
15+
createInitOptions,
16+
createRenderOptions,
17+
} from '../../../../test/mock/createWidget';
18+
import { createSearchClient } from '../../../../test/mock/createSearchClient';
19+
import { createSingleSearchResponse } from '../../../../test/mock/createAPIResponse';
20+
import { Widget, WidgetRenderState } from '../../../types';
21+
22+
const mockedRender = render as jest.Mock;
23+
24+
jest.mock('preact', () => {
25+
const module = jest.requireActual('preact');
26+
27+
module.render = jest.fn();
28+
29+
return module;
30+
});
31+
32+
describe('hierarchicalMenu()', () => {
33+
let container: HTMLDivElement;
34+
let attributes: string[];
35+
let options: HierarchicalMenuConnectorParams & HierarchicalMenuWidgetParams;
36+
let widget: Widget<{
37+
renderState: WidgetRenderState<
38+
HierarchicalMenuRendererOptions,
39+
HierarchicalMenuConnectorParams
40+
>;
41+
}>;
42+
43+
beforeEach(() => {
44+
container = document.createElement('div');
45+
attributes = ['hello', 'world'];
46+
options = { container, attributes };
47+
48+
mockedRender.mockClear();
49+
});
50+
51+
describe('Usage', () => {
52+
it('throws without container', () => {
53+
// @ts-expect-error
54+
options = { container: undefined, attributes };
55+
expect(() => hierarchicalMenu(options))
56+
.toThrowErrorMatchingInlineSnapshot(`
57+
"The \`container\` option is required.
58+
59+
See documentation: https://www.algolia.com/doc/api-reference/widgets/hierarchical-menu/js/"
60+
`);
61+
});
62+
});
63+
64+
describe('render', () => {
65+
let results: SearchResults<any>;
66+
let data: SearchResults.HierarchicalFacet;
67+
let helper: AlgoliaSearchHelper;
68+
let state: SearchParameters;
69+
70+
beforeEach(() => {
71+
data = {
72+
name: 'baz',
73+
count: 3,
74+
path: '',
75+
isRefined: false,
76+
data: [
77+
{ name: 'foo', count: 1, path: '', isRefined: false, data: [] },
78+
{ name: 'bar', count: 2, path: '', isRefined: false, data: [] },
79+
],
80+
};
81+
helper = algoliasearchHelper(createSearchClient(), '');
82+
helper.toggleFacetRefinement = jest.fn().mockReturnThis();
83+
helper.search = jest.fn();
84+
results = new SearchResults(helper.state, [
85+
createSingleSearchResponse({}),
86+
]);
87+
results.getFacetValues = jest.fn(() => data);
88+
state = new SearchParameters();
89+
options = { container, attributes };
90+
});
91+
92+
it('understand provided cssClasses', () => {
93+
const userCssClasses = {
94+
root: 'root',
95+
noRefinementRoot: 'noRefinementRoot',
96+
searchBox: 'searchBox',
97+
list: 'list',
98+
childList: 'childList',
99+
item: 'item',
100+
selectedItem: 'selectedItem',
101+
parentItem: 'parentItem',
102+
link: 'link',
103+
label: 'label',
104+
count: 'count',
105+
noResults: 'noResults',
106+
showMore: 'showMore',
107+
disabledShowMore: 'disabledShowMore',
108+
};
109+
widget = hierarchicalMenu({ ...options, cssClasses: userCssClasses });
110+
111+
widget.init!(createInitOptions({ helper }));
112+
widget.render!(createRenderOptions({ results, state }));
113+
114+
const [firstRender] = mockedRender.mock.calls;
115+
116+
expect(firstRender[0].props).toMatchSnapshot();
117+
});
118+
119+
it('calls render', () => {
120+
widget = hierarchicalMenu(options);
121+
122+
widget.init!(createInitOptions({ helper }));
123+
widget.render!(createRenderOptions({ results, state }));
124+
125+
const [firstRender] = mockedRender.mock.calls;
126+
127+
expect(mockedRender).toHaveBeenCalledTimes(1);
128+
expect(firstRender[0].props).toMatchSnapshot();
129+
});
130+
131+
it('asks for results.getFacetValues', () => {
132+
widget = hierarchicalMenu(options);
133+
134+
widget.init!(createInitOptions({ helper }));
135+
widget.render!(createRenderOptions({ results, state }));
136+
137+
expect(results.getFacetValues).toHaveBeenCalledTimes(1);
138+
expect(results.getFacetValues).toHaveBeenCalledWith('hello', {
139+
sortBy: ['name:asc'],
140+
});
141+
});
142+
143+
it('has a sortBy option', () => {
144+
widget = hierarchicalMenu({ ...options, sortBy: ['name:asc'] });
145+
146+
widget.init!(createInitOptions({ helper }));
147+
widget.render!(createRenderOptions({ results, state }));
148+
149+
expect(results.getFacetValues).toHaveBeenCalledTimes(1);
150+
expect(results.getFacetValues).toHaveBeenCalledWith('hello', {
151+
sortBy: ['name:asc'],
152+
});
153+
});
154+
155+
it('has a templates option', () => {
156+
widget = hierarchicalMenu({
157+
...options,
158+
templates: {
159+
item: 'item2',
160+
},
161+
});
162+
163+
widget.init!(createInitOptions({ helper }));
164+
widget.render!(createRenderOptions({ results, state }));
165+
166+
const [firstRender] = mockedRender.mock.calls;
167+
168+
expect(firstRender[0].props).toMatchSnapshot();
169+
});
170+
171+
it('has a transformItems options', () => {
172+
widget = hierarchicalMenu({
173+
...options,
174+
transformItems: items =>
175+
items.map(item => ({ ...item, transformed: true })),
176+
});
177+
178+
widget.init!(createInitOptions({ helper }));
179+
widget.render!(createRenderOptions({ results, state }));
180+
181+
const [firstRender] = mockedRender.mock.calls;
182+
183+
expect(firstRender[0].props).toMatchSnapshot();
184+
});
185+
186+
it('sets facetValues to empty array when no results', () => {
187+
data = {
188+
name: 'baz',
189+
count: 0,
190+
path: '',
191+
isRefined: false,
192+
data: [],
193+
};
194+
widget = hierarchicalMenu(options);
195+
196+
widget.init!(createInitOptions({ helper }));
197+
widget.render!(createRenderOptions({ results, state }));
198+
199+
const [firstRender] = mockedRender.mock.calls;
200+
201+
expect(firstRender[0].props).toMatchSnapshot();
202+
});
203+
204+
it('has a toggleRefinement method', () => {
205+
widget = hierarchicalMenu(options);
206+
207+
widget.init!(createInitOptions({ helper }));
208+
widget.render!(createRenderOptions({ results, state }));
209+
210+
const [firstRender] = mockedRender.mock.calls;
211+
212+
const elementToggleRefinement = firstRender[0].props.toggleRefinement;
213+
elementToggleRefinement('mom');
214+
215+
expect(helper.toggleFacetRefinement).toHaveBeenCalledTimes(1);
216+
expect(helper.toggleFacetRefinement).toHaveBeenCalledWith('hello', 'mom');
217+
expect(helper.search).toHaveBeenCalledTimes(1);
218+
});
219+
220+
it('has a limit option', () => {
221+
const secondLevel: SearchResults.HierarchicalFacet[] = [
222+
{ name: 'six', path: 'six', count: 6, isRefined: false, data: [] },
223+
{ name: 'seven', path: 'seven', count: 7, isRefined: false, data: [] },
224+
{ name: 'eight', path: 'eight', count: 8, isRefined: false, data: [] },
225+
{ name: 'nine', path: 'nine', count: 9, isRefined: false, data: [] },
226+
];
227+
const firstLevel: SearchResults.HierarchicalFacet[] = [
228+
{ name: 'one', path: 'one', count: 1, isRefined: false, data: [] },
229+
{
230+
name: 'two',
231+
path: 'two',
232+
count: 2,
233+
isRefined: false,
234+
data: secondLevel,
235+
},
236+
{ name: 'three', path: 'three', count: 3, isRefined: false, data: [] },
237+
{ name: 'four', path: 'four', count: 4, isRefined: false, data: [] },
238+
{ name: 'five', path: 'five', count: 5, isRefined: false, data: [] },
239+
];
240+
data = {
241+
name: 'zero',
242+
path: 'zero',
243+
count: 0,
244+
isRefined: false,
245+
data: firstLevel,
246+
};
247+
const expectedFacetValues = [
248+
{ label: 'one', value: 'one', count: 1, isRefined: false, data: [] },
249+
{
250+
label: 'two',
251+
value: 'two',
252+
count: 2,
253+
isRefined: false,
254+
data: [
255+
{
256+
label: 'six',
257+
value: 'six',
258+
count: 6,
259+
isRefined: false,
260+
data: [],
261+
},
262+
{
263+
label: 'seven',
264+
value: 'seven',
265+
count: 7,
266+
isRefined: false,
267+
data: [],
268+
},
269+
{
270+
label: 'eight',
271+
value: 'eight',
272+
count: 8,
273+
isRefined: false,
274+
data: [],
275+
},
276+
],
277+
},
278+
{
279+
label: 'three',
280+
value: 'three',
281+
count: 3,
282+
isRefined: false,
283+
data: [],
284+
},
285+
];
286+
widget = hierarchicalMenu({ ...options, limit: 3 });
287+
288+
widget.init!(createInitOptions({ helper }));
289+
widget.render!(createRenderOptions({ results, state }));
290+
291+
const [firstRender] = mockedRender.mock.calls;
292+
293+
const actualFacetValues = firstRender[0].props.facetValues;
294+
expect(actualFacetValues).toEqual(expectedFacetValues);
295+
});
296+
});
297+
});

‎src/widgets/hierarchical-menu/hierarchical-menu.js ‎src/widgets/hierarchical-menu/hierarchical-menu.tsx

+180-66
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,190 @@
33
import { h, render } from 'preact';
44
import cx from 'classnames';
55
import RefinementList from '../../components/RefinementList/RefinementList';
6-
import connectHierarchicalMenu from '../../connectors/hierarchical-menu/connectHierarchicalMenu';
6+
import connectHierarchicalMenu, {
7+
HierarchicalMenuItem,
8+
HierarchicalMenuConnectorParams,
9+
HierarchicalMenuRendererOptions,
10+
} from '../../connectors/hierarchical-menu/connectHierarchicalMenu';
711
import defaultTemplates from './defaultTemplates';
12+
import { PreparedTemplateProps } from '../../lib/utils/prepareTemplateProps';
813
import {
914
prepareTemplateProps,
1015
getContainerNode,
1116
createDocumentationMessageGenerator,
1217
} from '../../lib/utils';
18+
import {
19+
TransformItems,
20+
Template,
21+
WidgetFactory,
22+
RendererOptions,
23+
SortBy,
24+
} from '../../types';
1325
import { component } from '../../lib/suit';
1426

1527
const withUsage = createDocumentationMessageGenerator({
1628
name: 'hierarchical-menu',
1729
});
1830
const suit = component('HierarchicalMenu');
1931

32+
type HierarchicalMenuTemplates = {
33+
/**
34+
* Item template, provided with `name`, `count`, `isRefined`, `url` data properties.
35+
*/
36+
item: Template<{
37+
name: string;
38+
count: number;
39+
isRefined: boolean;
40+
url: string;
41+
}>;
42+
/**
43+
* Template used for the show more text, provided with `isShowingMore` data property.
44+
*/
45+
showMoreText: Template<{ isShowingMore: boolean }>;
46+
};
47+
48+
export type HierarchicalMenuCSSClasses = {
49+
/**
50+
* CSS class to add to the root element.
51+
*/
52+
root: string | string[];
53+
/**
54+
* CSS class to add to the root element when no refinements.
55+
*/
56+
noRefinementRoot: string | string[];
57+
/**
58+
* CSS class to add to the list element.
59+
*/
60+
list: string | string[];
61+
/**
62+
* CSS class to add to the child list element.
63+
*/
64+
childList: string | string[];
65+
/**
66+
* CSS class to add to each item element.
67+
*/
68+
item: string | string[];
69+
/**
70+
* CSS class to add to each selected item element.
71+
*/
72+
selectedItem: string | string[];
73+
/**
74+
* CSS class to add to each parent item element.
75+
*/
76+
parentItem: string | string[];
77+
/**
78+
* CSS class to add to each link (when using the default template).
79+
*/
80+
link: string | string[];
81+
/**
82+
* CSS class to add to each label (when using the default template).
83+
*/
84+
label: string | string[];
85+
/**
86+
* CSS class to add to each count element (when using the default template).
87+
*/
88+
count: string | string[];
89+
/**
90+
* CSS class to add to the show more element.
91+
*/
92+
showMore: string | string[];
93+
/**
94+
* CSS class to add to the disabled show more element.
95+
*/
96+
disabledShowMore: string | string[];
97+
};
98+
99+
type HierarchicalMenuRendererCSSClasses = Required<
100+
{
101+
[key in keyof HierarchicalMenuCSSClasses]: string;
102+
}
103+
>;
104+
105+
export type HierarchicalMenuWidgetParams = {
106+
/**
107+
* CSS Selector or HTMLElement to insert the widget.
108+
*/
109+
container: string | HTMLElement;
110+
/**
111+
* Array of attributes to use to generate the hierarchy of the menu.
112+
*/
113+
attributes: string[];
114+
/**
115+
* Separator used in the attributes to separate level values.
116+
*/
117+
separator?: string;
118+
/**
119+
* Prefix path to use if the first level is not the root level.
120+
*/
121+
rootPath?: string;
122+
/**
123+
* Show the siblings of the selected parent level of the current refined value.
124+
*
125+
* With `showParentLevel` set to `true` (default):
126+
* - Parent lvl0
127+
* - **lvl1**
128+
* - **lvl2**
129+
* - lvl2
130+
* - lvl2
131+
* - lvl 1
132+
* - lvl 1
133+
* - Parent lvl0
134+
* - Parent lvl0
135+
*
136+
* With `showParentLevel` set to `false`:
137+
* - Parent lvl0
138+
* - **lvl1**
139+
* - **lvl2**
140+
* - Parent lvl0
141+
* - Parent lvl0
142+
*/
143+
showParentLevel?: boolean;
144+
/**
145+
* Max number of values to display.
146+
*/
147+
limit?: number;
148+
/**
149+
* Whether to display the "show more" button.
150+
*/
151+
showMore?: boolean;
152+
/**
153+
* Max number of values to display when showing more.
154+
* does not impact the root level.
155+
*/
156+
showMoreLimit?: number;
157+
/**
158+
* How to sort refinements. Possible values: `count|isRefined|name:asc|name:desc`.
159+
* You can also use a sort function that behaves like the standard Javascript [compareFunction](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#Syntax).
160+
*/
161+
sortBy?: SortBy<HierarchicalMenuItem>;
162+
/**
163+
* Function to transform the items passed to the templates.
164+
*/
165+
transformItems?: TransformItems<HierarchicalMenuItem>;
166+
/**
167+
* Templates to use for the widget.
168+
*/
169+
templates?: Partial<HierarchicalMenuTemplates>;
170+
/**
171+
* CSS classes to add to the wrapping elements.
172+
*/
173+
cssClasses?: Partial<HierarchicalMenuCSSClasses>;
174+
};
175+
20176
const renderer = ({
21177
cssClasses,
22178
containerNode,
23179
showMore,
24180
templates,
25181
renderState,
182+
}: {
183+
cssClasses: HierarchicalMenuRendererCSSClasses;
184+
containerNode: HTMLElement;
185+
showMore: boolean;
186+
templates: Partial<HierarchicalMenuTemplates>;
187+
renderState: {
188+
templateProps?: PreparedTemplateProps<HierarchicalMenuTemplates>;
189+
};
26190
}) => (
27191
{
28192
createURL,
@@ -32,8 +196,9 @@ const renderer = ({
32196
isShowingMore,
33197
toggleShowMore,
34198
canToggleShowMore,
35-
},
36-
isFirstRendering
199+
}: HierarchicalMenuRendererOptions &
200+
RendererOptions<HierarchicalMenuConnectorParams>,
201+
isFirstRendering: boolean
37202
) => {
38203
if (isFirstRendering) {
39204
renderState.templateProps = prepareTemplateProps({
@@ -60,67 +225,6 @@ const renderer = ({
60225
);
61226
};
62227

63-
/**
64-
* @typedef {Object} HierarchicalMenuCSSClasses
65-
* @property {string|string[]} [root] CSS class to add to the root element.
66-
* @property {string|string[]} [noRefinementRoot] CSS class to add to the root element when no refinements.
67-
* @property {string|string[]} [list] CSS class to add to the list element.
68-
* @property {string|string[]} [childList] CSS class to add to the child list element.
69-
* @property {string|string[]} [item] CSS class to add to each item element.
70-
* @property {string|string[]} [selectedItem] CSS class to add to each selected item element.
71-
* @property {string|string[]} [parentItem] CSS class to add to each parent item element.
72-
* @property {string|string[]} [link] CSS class to add to each link (when using the default template).
73-
* @property {string|string[]} [label] CSS class to add to each label (when using the default template).
74-
* @property {string|string[]} [count] CSS class to add to each count element (when using the default template).
75-
* @property {string|string[]} [showMore] CSS class to add to the show more element.
76-
* @property {string|string[]} [disabledShowMore] CSS class to add to the disabled show more element.
77-
*/
78-
79-
/**
80-
* @typedef {Object} HierarchicalMenuTemplates
81-
* @property {string|function(object):string} [item] Item template, provided with `name`, `count`, `isRefined`, `url` data properties.
82-
* @property {string|function} [showMoreText] Template used for the show more text, provided with `isShowingMore` data property.
83-
*/
84-
85-
/**
86-
* @typedef {Object} HierarchicalMenuWidgetParams
87-
* @property {string|HTMLElement} container CSS Selector or HTMLElement to insert the widget.
88-
* @property {string[]} attributes Array of attributes to use to generate the hierarchy of the menu.
89-
* @property {string} [separator = " > "] Separator used in the attributes to separate level values.
90-
* @property {string} [rootPath] Prefix path to use if the first level is not the root level.
91-
* @property {boolean} [showParentLevel = true] Show the siblings of the selected parent level of the current refined value. This
92-
* @property {number} [limit = 10] Max number of values to display.
93-
* @property {boolean} [showMore = false] Whether to display the "show more" button.
94-
* @property {number} [showMoreLimit = 20] Max number of values to display when showing more.
95-
* does not impact the root level.
96-
*
97-
* The hierarchical menu is able to show or hide the siblings with `showParentLevel`.
98-
*
99-
* With `showParentLevel` set to `true` (default):
100-
* - Parent lvl0
101-
* - **lvl1**
102-
* - **lvl2**
103-
* - lvl2
104-
* - lvl2
105-
* - lvl 1
106-
* - lvl 1
107-
* - Parent lvl0
108-
* - Parent lvl0
109-
*
110-
* With `showParentLevel` set to `false`:
111-
* - Parent lvl0
112-
* - **lvl1**
113-
* - **lvl2**
114-
* - Parent lvl0
115-
* - Parent lvl0
116-
* @property {string[]|function} [sortBy = ['name:asc']] How to sort refinements. Possible values: `count|isRefined|name:asc|name:desc`.
117-
*
118-
* You can also use a sort function that behaves like the standard Javascript [compareFunction](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#Syntax).
119-
* @property {function(object[]):object[]} [transformItems] Function to transform the items passed to the templates.
120-
* @property {HierarchicalMenuTemplates} [templates] Templates to use for the widget.
121-
* @property {HierarchicalMenuCSSClasses} [cssClasses] CSS classes to add to the wrapping elements.
122-
*/
123-
124228
/**
125229
* The hierarchical menu widget is used to create a navigation based on a hierarchy of facet attributes.
126230
*
@@ -172,7 +276,15 @@ const renderer = ({
172276
* })
173277
* ]);
174278
*/
175-
export default function hierarchicalMenu(widgetParams) {
279+
export type HierarchicalMenuWidget = WidgetFactory<
280+
HierarchicalMenuRendererOptions,
281+
HierarchicalMenuConnectorParams,
282+
HierarchicalMenuWidgetParams
283+
>;
284+
285+
const hierarchicalMenu: HierarchicalMenuWidget = function hierarchicalMenu(
286+
widgetParams
287+
) {
176288
const {
177289
container,
178290
attributes,
@@ -250,4 +362,6 @@ export default function hierarchicalMenu(widgetParams) {
250362
}),
251363
$$widgetType: 'ais.hierarchicalMenu',
252364
};
253-
}
365+
};
366+
367+
export default hierarchicalMenu;

0 commit comments

Comments
 (0)
Please sign in to comment.