Skip to content

Commit 11d78f0

Browse files
marielauressamouss
authored andcommitted
feat(breadcrumb): Add the breadcrumb widget (#2451)
* feat(breadcrumb): kick-off connectBreadcrumb * chore(dev): add breadcrumb into storybook * feat(connectBreadcrumb): remove view logic * feat(widgets): export breadcrumb widget * feat(widget): breadcrumb * feat(component): kick-off breadcrumb component * feat(Breadcrumb): WIP * feat(Breadcrumb): modified separator * feat(Breadcrumb): display breadcrumb (without any links) * feat(Breadcrumb): WIP * feat(Breadcrumb): add css classes * feat(Breadcrumb): working on styling * feat(Breadcrumb): modified onClick prop * feat(Breadcrumb): Add headerFooter + autoHideContainer * feat(Breadcrumb): documentation * feat(Breadcrumb): remove cssClass root * enable use as a standalone widget * add template to widget * add custom label story * first series of tests * add logic for the onclick on "Home" * add templates for home and separator * warn the user if there's already a Breadcrumb used with the same facet * add cssClasses * add lifecycle test * modify template so it re renders when rootProps are modified * modify css classes applied to the home label * change the separator in the theme * more tests for connectBreadcrumb * add documentation * add createUrl + some refactoring * add default selected item story * WIP * WIP - fixing response data for hierarchical values * add createUrl + rootPath * add default selected item story * WIP * chore(test): test on second value * feat(Breadcrumb): update API of the connector * fix(dep): use same casing for isEqual * chore(doc): fix misc. errors * chore(breadcrumb): add more docs and validation * various code improvements Review fixes. Fix #2299
1 parent 0c987c1 commit 11d78f0

File tree

15 files changed

+1209
-37
lines changed

15 files changed

+1209
-37
lines changed

dev/app/init-builtin-widgets.js

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,137 @@ import instantsearch from '../../index.js';
55
import wrapWithHits from './wrap-with-hits.js';
66

77
export default () => {
8+
storiesOf('Breadcrumb')
9+
.add(
10+
'default',
11+
wrapWithHits(container => {
12+
container.innerHTML = `
13+
<div id="hierarchicalMenu"></div>
14+
<div id="breadcrumb"></div>
15+
`;
16+
17+
window.search.addWidget(
18+
instantsearch.widgets.breadcrumb({
19+
container: '#breadcrumb',
20+
attributes: [
21+
'hierarchicalCategories.lvl0',
22+
'hierarchicalCategories.lvl1',
23+
'hierarchicalCategories.lvl2',
24+
],
25+
})
26+
);
27+
28+
//Custom Widget to toggle refinement
29+
window.search.addWidget({
30+
init({ helper }) {
31+
helper.toggleRefinement(
32+
'hierarchicalCategories.lvl0',
33+
'Cameras & Camcorders > Digital Cameras'
34+
);
35+
},
36+
});
37+
})
38+
)
39+
.add(
40+
'with custom home label',
41+
wrapWithHits(container => {
42+
container.innerHTML = `
43+
<div id="hierarchicalMenu"></div>
44+
<div id="breadcrumb"></div>
45+
`;
46+
47+
window.search.addWidget(
48+
instantsearch.widgets.breadcrumb({
49+
container: '#breadcrumb',
50+
attributes: [
51+
'hierarchicalCategories.lvl0',
52+
'hierarchicalCategories.lvl1',
53+
'hierarchicalCategories.lvl2',
54+
],
55+
templates: { home: 'Home Page' },
56+
})
57+
);
58+
59+
//Custom Widget to toggle refinement
60+
window.search.addWidget({
61+
init({ helper }) {
62+
helper.toggleRefinement(
63+
'hierarchicalCategories.lvl0',
64+
'Cameras & Camcorders > Digital Cameras'
65+
);
66+
},
67+
});
68+
})
69+
)
70+
.add(
71+
'with default selected item',
72+
wrapWithHits(container => {
73+
container.innerHTML = `
74+
<div id="breadcrumb"></div>
75+
<div id="hierarchicalMenu"></div>
76+
`;
77+
78+
window.search.addWidget(
79+
instantsearch.widgets.breadcrumb({
80+
container: '#breadcrumb',
81+
attributes: [
82+
'hierarchicalCategories.lvl0',
83+
'hierarchicalCategories.lvl1',
84+
'hierarchicalCategories.lvl2',
85+
],
86+
rootPath: 'Cameras & Camcorders > Digital Cameras',
87+
})
88+
);
89+
90+
window.search.addWidget(
91+
instantsearch.widgets.hierarchicalMenu({
92+
showParentLevel: false,
93+
container: '#hierarchicalMenu',
94+
attributes: [
95+
'hierarchicalCategories.lvl0',
96+
'hierarchicalCategories.lvl1',
97+
'hierarchicalCategories.lvl2',
98+
],
99+
rootPath: 'Cameras & Camcorders',
100+
})
101+
);
102+
})
103+
)
104+
.add(
105+
'with hierarchical menu',
106+
wrapWithHits(container => {
107+
container.innerHTML = `
108+
<div id="breadcrumb"></div>
109+
<div id="hierarchicalMenu"></div>
110+
`;
111+
112+
window.search.addWidget(
113+
instantsearch.widgets.breadcrumb({
114+
container: '#breadcrumb',
115+
separator: ' / ',
116+
attributes: [
117+
'hierarchicalCategories.lvl0',
118+
'hierarchicalCategories.lvl1',
119+
'hierarchicalCategories.lvl2',
120+
],
121+
})
122+
);
123+
124+
window.search.addWidget(
125+
instantsearch.widgets.hierarchicalMenu({
126+
showParentLevel: false,
127+
container: '#hierarchicalMenu',
128+
attributes: [
129+
'hierarchicalCategories.lvl0',
130+
'hierarchicalCategories.lvl1',
131+
'hierarchicalCategories.lvl2',
132+
],
133+
rootPath: 'Cameras & Camcorders',
134+
})
135+
);
136+
})
137+
);
138+
8139
storiesOf('Analytics').add(
9140
'default',
10141
wrapWithHits(container => {
@@ -719,7 +850,22 @@ export default () => {
719850
'hierarchicalCategories.lvl1',
720851
'hierarchicalCategories.lvl2',
721852
],
722-
rootPath: 'Cameras & Camcorders',
853+
})
854+
);
855+
})
856+
)
857+
.add(
858+
'only show current level',
859+
wrapWithHits(container => {
860+
window.search.addWidget(
861+
instantsearch.widgets.hierarchicalMenu({
862+
container,
863+
showParentLevel: false,
864+
attributes: [
865+
'hierarchicalCategories.lvl0',
866+
'hierarchicalCategories.lvl1',
867+
'hierarchicalCategories.lvl2',
868+
],
723869
})
724870
);
725871
})
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import React, { PureComponent } from 'react';
2+
import PropTypes from 'prop-types';
3+
import Template from '../Template.js';
4+
import autoHideContainerHOC from '../../decorators/autoHideContainer.js';
5+
6+
const itemsPropType = PropTypes.arrayOf(
7+
PropTypes.shape({
8+
name: PropTypes.string,
9+
value: PropTypes.string,
10+
})
11+
);
12+
13+
class Breadcrumb extends PureComponent {
14+
static propTypes = {
15+
createURL: PropTypes.func,
16+
cssClasses: PropTypes.objectOf(PropTypes.string),
17+
items: itemsPropType,
18+
refine: PropTypes.func.isRequired,
19+
separator: PropTypes.oneOfType([PropTypes.string, PropTypes.element]),
20+
templateProps: PropTypes.object.isRequired,
21+
translate: PropTypes.func,
22+
};
23+
24+
render() {
25+
const { createURL, items, refine, cssClasses } = this.props;
26+
27+
const breadcrumb = items.map((item, idx) => {
28+
const isLast = idx === items.length - 1;
29+
const label = isLast
30+
? <a className={`${cssClasses.disabledLabel} ${cssClasses.label}`}>
31+
{item.name}
32+
</a>
33+
: <a
34+
className={cssClasses.label}
35+
href={createURL(item.value)}
36+
onClick={e => {
37+
e.preventDefault();
38+
refine(item.value);
39+
}}
40+
>
41+
{item.name}
42+
</a>;
43+
44+
return [
45+
<Template
46+
key={item.name + idx}
47+
rootProps={{ className: cssClasses.separator }}
48+
templateKey="separator"
49+
{...this.props.templateProps}
50+
/>,
51+
label,
52+
];
53+
});
54+
55+
const homeClassNames =
56+
items.length > 0
57+
? [cssClasses.home, cssClasses.label]
58+
: [cssClasses.disabledLabel, cssClasses.home, cssClasses.label];
59+
60+
const homeOnClickHandler = e => {
61+
e.preventDefault();
62+
refine(null);
63+
};
64+
65+
const homeUrl = createURL(null);
66+
67+
return (
68+
<div className={cssClasses.root}>
69+
<a
70+
className={homeClassNames.join(' ')}
71+
href={homeUrl}
72+
onClick={homeOnClickHandler}
73+
>
74+
<Template templateKey="home" {...this.props.templateProps} />
75+
</a>
76+
{breadcrumb}
77+
</div>
78+
);
79+
}
80+
}
81+
82+
export default autoHideContainerHOC(Breadcrumb);

src/components/Template.js

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ export class PureTemplate extends React.Component {
1313
shouldComponentUpdate(nextProps) {
1414
return (
1515
!isEqual(this.props.data, nextProps.data) ||
16-
this.props.templateKey !== nextProps.templateKey
16+
this.props.templateKey !== nextProps.templateKey ||
17+
!isEqual(this.props.rootProps, nextProps.rootProps)
1718
);
1819
}
1920

@@ -41,7 +42,7 @@ export class PureTemplate extends React.Component {
4142

4243
if (isReactElement(content)) {
4344
throw new Error(
44-
'Support for templates as React elements has been removed, please use react-instantsearch'
45+
'Support for templates as React elements has been removed, please use react-instantsearch',
4546
);
4647
}
4748

@@ -59,7 +60,7 @@ PureTemplate.propTypes = {
5960
rootProps: PropTypes.object,
6061
templateKey: PropTypes.string,
6162
templates: PropTypes.objectOf(
62-
PropTypes.oneOfType([PropTypes.string, PropTypes.func])
63+
PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
6364
),
6465
templatesConfig: PropTypes.shape({
6566
helpers: PropTypes.objectOf(PropTypes.func),
@@ -70,7 +71,7 @@ PureTemplate.propTypes = {
7071
PropTypes.shape({
7172
o: PropTypes.string,
7273
c: PropTypes.string,
73-
})
74+
}),
7475
),
7576
delimiters: PropTypes.string,
7677
disableLambda: PropTypes.bool,
@@ -112,15 +113,15 @@ function transformData(fn, templateKey, originalData) {
112113
}
113114
} else {
114115
throw new Error(
115-
`transformData must be a function or an object, was ${typeFn} (key : ${templateKey})`
116+
`transformData must be a function or an object, was ${typeFn} (key : ${templateKey})`,
116117
);
117118
}
118119

119120
const dataType = typeof data;
120121
const expectedType = typeof originalData;
121122
if (dataType !== expectedType) {
122123
throw new Error(
123-
`\`transformData\` must return a \`${expectedType}\`, got \`${dataType}\`.`
124+
`\`transformData\` must return a \`${expectedType}\`, got \`${dataType}\`.`,
124125
);
125126
}
126127
return data;
@@ -140,15 +141,15 @@ function renderTemplate({
140141

141142
if (!isTemplateString && !isTemplateFunction) {
142143
throw new Error(
143-
`Template must be 'string' or 'function', was '${templateType}' (key: ${templateKey})`
144+
`Template must be 'string' or 'function', was '${templateType}' (key: ${templateKey})`,
144145
);
145146
} else if (isTemplateFunction) {
146147
return template(data);
147148
} else {
148149
const transformedHelpers = transformHelpersToHogan(
149150
helpers,
150151
compileOptions,
151-
data
152+
data,
152153
);
153154
const preparedData = { ...data, helpers: transformedHelpers };
154155
return hogan.compile(template, compileOptions).render(preparedData);
@@ -165,7 +166,7 @@ function transformHelpersToHogan(helpers, compileOptions, data) {
165166
curry(function(text) {
166167
const render = value => hogan.compile(value, compileOptions).render(this);
167168
return method.call(data, text, render);
168-
})
169+
}),
169170
);
170171
}
171172

src/components/__tests__/Template-test.js

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -208,15 +208,10 @@ describe('Template', () => {
208208
it('forward rootProps to the first node', () => {
209209
function fn() {}
210210

211-
const props = getProps({});
212-
const tree = renderer
213-
.create(
214-
<PureTemplate
215-
rootProps={{ className: 'hey', onClick: fn }}
216-
{...props}
217-
/>
218-
)
219-
.toJSON();
211+
const props = getProps({
212+
rootProps: { className: 'hey', onClick: fn },
213+
});
214+
const tree = renderer.create(<PureTemplate {...props} />).toJSON();
220215
expect(tree).toMatchSnapshot();
221216
});
222217

@@ -229,6 +224,7 @@ describe('Template', () => {
229224
container = document.createElement('div');
230225
props = getProps({
231226
data: { hello: 'mom' },
227+
rootProps: { className: 'myCssClass' },
232228
});
233229
component = ReactDOM.render(<PureTemplate {...props} />, container);
234230
sinon.spy(component, 'render');
@@ -251,12 +247,25 @@ describe('Template', () => {
251247
ReactDOM.render(<PureTemplate {...props} />, container);
252248
expect(component.render.called).toBe(true);
253249
});
250+
251+
it('calls render when rootProps changes', () => {
252+
props.rootProps = { className: 'myCssClass mySecondCssClass' };
253+
ReactDOM.render(<PureTemplate {...props} />, container);
254+
expect(component.render.called).toBe(true);
255+
});
256+
257+
it('does not call render when rootProps remain unchanged', () => {
258+
props.rootProps = { className: 'myCssClass' };
259+
ReactDOM.render(<PureTemplate {...props} />, container);
260+
expect(component.render.called).toBe(false);
261+
});
254262
});
255263

256264
function getProps({
257265
templates = { test: '' },
258266
data = {},
259267
templateKey = 'test',
268+
rootProps = {},
260269
useCustomCompileOptions = {},
261270
templatesConfig = { helper: {}, compileOptions: {} },
262271
transformData = null,
@@ -265,6 +274,7 @@ describe('Template', () => {
265274
templates,
266275
data,
267276
templateKey,
277+
rootProps,
268278
useCustomCompileOptions,
269279
templatesConfig,
270280
transformData,

0 commit comments

Comments
 (0)