diff --git a/dev/app/init-builtin-widgets.js b/dev/app/init-builtin-widgets.js
index eb7f76e859..513aa95f38 100644
--- a/dev/app/init-builtin-widgets.js
+++ b/dev/app/init-builtin-widgets.js
@@ -5,6 +5,137 @@ import instantsearch from '../../index.js';
import wrapWithHits from './wrap-with-hits.js';
export default () => {
+ storiesOf('Breadcrumb')
+ .add(
+ 'default',
+ wrapWithHits(container => {
+ container.innerHTML = `
+
+
+ `;
+
+ window.search.addWidget(
+ instantsearch.widgets.breadcrumb({
+ container: '#breadcrumb',
+ attributes: [
+ 'hierarchicalCategories.lvl0',
+ 'hierarchicalCategories.lvl1',
+ 'hierarchicalCategories.lvl2',
+ ],
+ })
+ );
+
+ //Custom Widget to toggle refinement
+ window.search.addWidget({
+ init({ helper }) {
+ helper.toggleRefinement(
+ 'hierarchicalCategories.lvl0',
+ 'Cameras & Camcorders > Digital Cameras'
+ );
+ },
+ });
+ })
+ )
+ .add(
+ 'with custom home label',
+ wrapWithHits(container => {
+ container.innerHTML = `
+
+
+ `;
+
+ window.search.addWidget(
+ instantsearch.widgets.breadcrumb({
+ container: '#breadcrumb',
+ attributes: [
+ 'hierarchicalCategories.lvl0',
+ 'hierarchicalCategories.lvl1',
+ 'hierarchicalCategories.lvl2',
+ ],
+ templates: { home: 'Home Page' },
+ })
+ );
+
+ //Custom Widget to toggle refinement
+ window.search.addWidget({
+ init({ helper }) {
+ helper.toggleRefinement(
+ 'hierarchicalCategories.lvl0',
+ 'Cameras & Camcorders > Digital Cameras'
+ );
+ },
+ });
+ })
+ )
+ .add(
+ 'with default selected item',
+ wrapWithHits(container => {
+ container.innerHTML = `
+
+
+ `;
+
+ window.search.addWidget(
+ instantsearch.widgets.breadcrumb({
+ container: '#breadcrumb',
+ attributes: [
+ 'hierarchicalCategories.lvl0',
+ 'hierarchicalCategories.lvl1',
+ 'hierarchicalCategories.lvl2',
+ ],
+ rootPath: 'Cameras & Camcorders > Digital Cameras',
+ })
+ );
+
+ window.search.addWidget(
+ instantsearch.widgets.hierarchicalMenu({
+ showParentLevel: false,
+ container: '#hierarchicalMenu',
+ attributes: [
+ 'hierarchicalCategories.lvl0',
+ 'hierarchicalCategories.lvl1',
+ 'hierarchicalCategories.lvl2',
+ ],
+ rootPath: 'Cameras & Camcorders',
+ })
+ );
+ })
+ )
+ .add(
+ 'with hierarchical menu',
+ wrapWithHits(container => {
+ container.innerHTML = `
+
+
+ `;
+
+ window.search.addWidget(
+ instantsearch.widgets.breadcrumb({
+ container: '#breadcrumb',
+ separator: ' / ',
+ attributes: [
+ 'hierarchicalCategories.lvl0',
+ 'hierarchicalCategories.lvl1',
+ 'hierarchicalCategories.lvl2',
+ ],
+ })
+ );
+
+ window.search.addWidget(
+ instantsearch.widgets.hierarchicalMenu({
+ showParentLevel: false,
+ container: '#hierarchicalMenu',
+ attributes: [
+ 'hierarchicalCategories.lvl0',
+ 'hierarchicalCategories.lvl1',
+ 'hierarchicalCategories.lvl2',
+ ],
+ rootPath: 'Cameras & Camcorders',
+ })
+ );
+ })
+ );
+
storiesOf('Analytics').add(
'default',
wrapWithHits(container => {
@@ -699,7 +830,22 @@ export default () => {
'hierarchicalCategories.lvl1',
'hierarchicalCategories.lvl2',
],
- rootPath: 'Cameras & Camcorders',
+ })
+ );
+ })
+ )
+ .add(
+ 'only show current level',
+ wrapWithHits(container => {
+ window.search.addWidget(
+ instantsearch.widgets.hierarchicalMenu({
+ container,
+ showParentLevel: false,
+ attributes: [
+ 'hierarchicalCategories.lvl0',
+ 'hierarchicalCategories.lvl1',
+ 'hierarchicalCategories.lvl2',
+ ],
})
);
})
diff --git a/src/components/Breadcrumb/Breadcrumb.js b/src/components/Breadcrumb/Breadcrumb.js
new file mode 100644
index 0000000000..2a9d96856f
--- /dev/null
+++ b/src/components/Breadcrumb/Breadcrumb.js
@@ -0,0 +1,82 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import Template from '../Template.js';
+import autoHideContainerHOC from '../../decorators/autoHideContainer.js';
+
+const itemsPropType = PropTypes.arrayOf(
+ PropTypes.shape({
+ name: PropTypes.string,
+ value: PropTypes.string,
+ })
+);
+
+class Breadcrumb extends PureComponent {
+ static propTypes = {
+ createURL: PropTypes.func,
+ cssClasses: PropTypes.objectOf(PropTypes.string),
+ items: itemsPropType,
+ refine: PropTypes.func.isRequired,
+ separator: PropTypes.oneOfType([PropTypes.string, PropTypes.element]),
+ templateProps: PropTypes.object.isRequired,
+ translate: PropTypes.func,
+ };
+
+ render() {
+ const { createURL, items, refine, cssClasses } = this.props;
+
+ const breadcrumb = items.map((item, idx) => {
+ const isLast = idx === items.length - 1;
+ const label = isLast
+ ?
+ {item.name}
+
+ : {
+ e.preventDefault();
+ refine(item.value);
+ }}
+ >
+ {item.name}
+ ;
+
+ return [
+ ,
+ label,
+ ];
+ });
+
+ const homeClassNames =
+ items.length > 0
+ ? [cssClasses.home, cssClasses.label]
+ : [cssClasses.disabledLabel, cssClasses.home, cssClasses.label];
+
+ const homeOnClickHandler = e => {
+ e.preventDefault();
+ refine(null);
+ };
+
+ const homeUrl = createURL(null);
+
+ return (
+
+ );
+ }
+}
+
+export default autoHideContainerHOC(Breadcrumb);
diff --git a/src/components/Template.js b/src/components/Template.js
index 8d52e73b21..3f5cd4d2e1 100644
--- a/src/components/Template.js
+++ b/src/components/Template.js
@@ -13,7 +13,8 @@ export class PureTemplate extends React.Component {
shouldComponentUpdate(nextProps) {
return (
!isEqual(this.props.data, nextProps.data) ||
- this.props.templateKey !== nextProps.templateKey
+ this.props.templateKey !== nextProps.templateKey ||
+ !isEqual(this.props.rootProps, nextProps.rootProps)
);
}
@@ -41,7 +42,7 @@ export class PureTemplate extends React.Component {
if (isReactElement(content)) {
throw new Error(
- 'Support for templates as React elements has been removed, please use react-instantsearch'
+ 'Support for templates as React elements has been removed, please use react-instantsearch',
);
}
@@ -59,7 +60,7 @@ PureTemplate.propTypes = {
rootProps: PropTypes.object,
templateKey: PropTypes.string,
templates: PropTypes.objectOf(
- PropTypes.oneOfType([PropTypes.string, PropTypes.func])
+ PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
),
templatesConfig: PropTypes.shape({
helpers: PropTypes.objectOf(PropTypes.func),
@@ -70,7 +71,7 @@ PureTemplate.propTypes = {
PropTypes.shape({
o: PropTypes.string,
c: PropTypes.string,
- })
+ }),
),
delimiters: PropTypes.string,
disableLambda: PropTypes.bool,
@@ -112,7 +113,7 @@ function transformData(fn, templateKey, originalData) {
}
} else {
throw new Error(
- `transformData must be a function or an object, was ${typeFn} (key : ${templateKey})`
+ `transformData must be a function or an object, was ${typeFn} (key : ${templateKey})`,
);
}
@@ -120,7 +121,7 @@ function transformData(fn, templateKey, originalData) {
const expectedType = typeof originalData;
if (dataType !== expectedType) {
throw new Error(
- `\`transformData\` must return a \`${expectedType}\`, got \`${dataType}\`.`
+ `\`transformData\` must return a \`${expectedType}\`, got \`${dataType}\`.`,
);
}
return data;
@@ -140,7 +141,7 @@ function renderTemplate({
if (!isTemplateString && !isTemplateFunction) {
throw new Error(
- `Template must be 'string' or 'function', was '${templateType}' (key: ${templateKey})`
+ `Template must be 'string' or 'function', was '${templateType}' (key: ${templateKey})`,
);
} else if (isTemplateFunction) {
return template(data);
@@ -148,7 +149,7 @@ function renderTemplate({
const transformedHelpers = transformHelpersToHogan(
helpers,
compileOptions,
- data
+ data,
);
const preparedData = { ...data, helpers: transformedHelpers };
return hogan.compile(template, compileOptions).render(preparedData);
@@ -165,7 +166,7 @@ function transformHelpersToHogan(helpers, compileOptions, data) {
curry(function(text) {
const render = value => hogan.compile(value, compileOptions).render(this);
return method.call(data, text, render);
- })
+ }),
);
}
diff --git a/src/components/__tests__/Template-test.js b/src/components/__tests__/Template-test.js
index 615b27e754..74ad16bbe7 100644
--- a/src/components/__tests__/Template-test.js
+++ b/src/components/__tests__/Template-test.js
@@ -208,15 +208,10 @@ describe('Template', () => {
it('forward rootProps to the first node', () => {
function fn() {}
- const props = getProps({});
- const tree = renderer
- .create(
-
- )
- .toJSON();
+ const props = getProps({
+ rootProps: { className: 'hey', onClick: fn },
+ });
+ const tree = renderer.create().toJSON();
expect(tree).toMatchSnapshot();
});
@@ -229,6 +224,7 @@ describe('Template', () => {
container = document.createElement('div');
props = getProps({
data: { hello: 'mom' },
+ rootProps: { className: 'myCssClass' },
});
component = ReactDOM.render(, container);
sinon.spy(component, 'render');
@@ -251,12 +247,25 @@ describe('Template', () => {
ReactDOM.render(, container);
expect(component.render.called).toBe(true);
});
+
+ it('calls render when rootProps changes', () => {
+ props.rootProps = { className: 'myCssClass mySecondCssClass' };
+ ReactDOM.render(, container);
+ expect(component.render.called).toBe(true);
+ });
+
+ it('does not call render when rootProps remain unchanged', () => {
+ props.rootProps = { className: 'myCssClass' };
+ ReactDOM.render(, container);
+ expect(component.render.called).toBe(false);
+ });
});
function getProps({
templates = { test: '' },
data = {},
templateKey = 'test',
+ rootProps = {},
useCustomCompileOptions = {},
templatesConfig = { helper: {}, compileOptions: {} },
transformData = null,
@@ -265,6 +274,7 @@ describe('Template', () => {
templates,
data,
templateKey,
+ rootProps,
useCustomCompileOptions,
templatesConfig,
transformData,
diff --git a/src/connectors/breadcrumb/__tests__/connectBreadcrumb-test.js b/src/connectors/breadcrumb/__tests__/connectBreadcrumb-test.js
new file mode 100644
index 0000000000..a64026b179
--- /dev/null
+++ b/src/connectors/breadcrumb/__tests__/connectBreadcrumb-test.js
@@ -0,0 +1,470 @@
+import jsHelper from 'algoliasearch-helper';
+const SearchResults = jsHelper.SearchResults;
+
+import connectBreadcrumb from '../connectBreadcrumb.js';
+
+describe('connectBreadcrumb', () => {
+ it('Renders during init and render', () => {
+ const rendering = jest.fn();
+ const makeWidget = connectBreadcrumb(rendering);
+ const widget = makeWidget({ attributes: ['category', 'sub_category'] });
+
+ const config = widget.getConfiguration({});
+ expect(config).toEqual({
+ hierarchicalFacets: [
+ {
+ attributes: ['category', 'sub_category'],
+ name: 'category',
+ rootPath: null,
+ separator: ' > ',
+ },
+ ],
+ });
+
+ // Verify that the widget has not been rendered yet at this point
+ expect(rendering.mock.calls.length).toBe(0);
+
+ const helper = jsHelper({ addAlgoliaAgent: () => {} }, '', config);
+ helper.search = jest.fn();
+
+ widget.init({
+ helper,
+ state: helper.state,
+ createURL: () => '#',
+ });
+
+ // Verify that rendering has been called upon init with isFirstRendering = true
+ expect(rendering.mock.calls.length).toBe(1);
+ expect(rendering.mock.calls[0][0].widgetParams).toEqual({
+ attributes: ['category', 'sub_category'],
+ });
+ expect(rendering.mock.calls[0][1]).toBe(true);
+
+ const instantSearchInstance = { templatesConfig: undefined };
+ widget.render({
+ results: new SearchResults(helper.state, [
+ {
+ hits: [],
+ facets: {
+ category: {
+ Decoration: 880,
+ },
+ subCategory: {
+ 'Decoration > Candle holders & candles': 193,
+ 'Decoration > Frames & pictures': 173,
+ },
+ },
+ },
+ {
+ facets: {
+ category: {
+ Decoration: 880,
+ Outdoor: 47,
+ },
+ },
+ },
+ ]),
+ state: helper.state,
+ helper,
+ createURL: () => '#',
+ instantSearchInstance,
+ });
+
+ // Verify that rendering has been called upon render with isFirstRendering = false
+ expect(rendering.mock.calls.length).toBe(2);
+ expect(rendering.mock.calls[1][0].widgetParams).toEqual({
+ attributes: ['category', 'sub_category'],
+ });
+ expect(rendering.mock.calls[1][1]).toBe(false);
+ });
+
+ it('Does not duplicate configuration', () => {
+ const makeWidget = connectBreadcrumb(() => {});
+ const widget = makeWidget({ attributes: ['category', 'sub_category'] });
+
+ const partialConfiguration = widget.getConfiguration({
+ hierarchicalFacets: [
+ {
+ attributes: ['category', 'sub_category'],
+ name: 'category',
+ rootPath: null,
+ separator: ' > ',
+ showParentLevel: true,
+ },
+ ],
+ });
+
+ expect(partialConfiguration).toEqual({});
+ });
+
+ it('provides the correct facet values', () => {
+ const rendering = jest.fn();
+ const makeWidget = connectBreadcrumb(rendering);
+ const widget = makeWidget({ attributes: ['category', 'sub_category'] });
+
+ const config = widget.getConfiguration({});
+ const helper = jsHelper({ addAlgoliaAgent: () => {} }, '', config);
+ helper.search = jest.fn();
+
+ helper.toggleRefinement('category', 'Decoration');
+
+ widget.init({
+ helper,
+ state: helper.state,
+ createURL: () => '#',
+ });
+
+ const firstRenderingOptions = rendering.mock.calls[0][0];
+ expect(firstRenderingOptions.items).toEqual([]);
+
+ widget.render({
+ results: new SearchResults(helper.state, [
+ {
+ hits: [],
+ facets: {
+ category: {
+ Decoration: 880,
+ },
+ subCategory: {
+ 'Decoration > Candle holders & candles': 193,
+ 'Decoration > Frames & pictures': 173,
+ },
+ },
+ },
+ {
+ facets: {
+ category: {
+ Decoration: 880,
+ Outdoor: 47,
+ },
+ },
+ },
+ ]),
+ state: helper.state,
+ helper,
+ createURL: () => '#',
+ });
+
+ const secondRenderingOptions = rendering.mock.calls[1][0];
+ expect(secondRenderingOptions.items).toEqual([
+ { name: 'Decoration', value: null },
+ ]);
+ });
+
+ it('returns the correct URL', () => {
+ const rendering = jest.fn();
+ const makeWidget = connectBreadcrumb(rendering);
+ const widget = makeWidget({ attributes: ['category', 'sub_category'] });
+
+ const config = widget.getConfiguration({});
+ const helper = jsHelper({ addAlgoliaAgent: () => {} }, '', config);
+ helper.search = jest.fn();
+
+ widget.init({
+ helper,
+ state: helper.state,
+ createURL: state => state,
+ });
+
+ const firstRenderingOptions = rendering.mock.calls[0][0];
+ expect(firstRenderingOptions.items).toEqual([]);
+
+ widget.render({
+ results: new SearchResults(helper.state, [
+ {
+ hits: [],
+ facets: {
+ category: {
+ Decoration: 880,
+ },
+ subCategory: {
+ 'Decoration > Candle holders & candles': 193,
+ 'Decoration > Frames & pictures': 173,
+ },
+ },
+ },
+ {
+ facets: {
+ category: {
+ Decoration: 880,
+ Outdoor: 47,
+ },
+ },
+ },
+ ]),
+ state: helper.state,
+ helper,
+ createURL: state => state,
+ });
+ const createURL = rendering.mock.calls[1][0].createURL;
+ expect(helper.state.hierarchicalFacetsRefinements).toEqual({});
+ const stateForURL = createURL('Decoration > Candle holders & candles');
+ expect(stateForURL.hierarchicalFacetsRefinements).toEqual({
+ category: ['Decoration > Candle holders & candles'],
+ });
+ });
+
+ it('returns the correct URL version with 3 levels', () => {
+ const rendering = jest.fn();
+ const makeWidget = connectBreadcrumb(rendering);
+ const widget = makeWidget({
+ attributes: [
+ 'hierarchicalCategories.lvl0',
+ 'hierarchicalCategories.lvl1',
+ 'hierarchicalCategories.lvl2',
+ ],
+ });
+
+ const config = widget.getConfiguration({});
+ const helper = jsHelper({ addAlgoliaAgent: () => {} }, '', config);
+ helper.search = jest.fn();
+
+ widget.init({
+ helper,
+ state: helper.state,
+ createURL: state => state,
+ });
+
+ const firstRenderingOptions = rendering.mock.calls[0][0];
+ expect(firstRenderingOptions.items).toEqual([]);
+
+ helper.toggleFacetRefinement(
+ 'hierarchicalCategories.lvl0',
+ 'Cameras & Camcorders > Digital Cameras > Digital SLR Cameras'
+ );
+ widget.render({
+ results: new SearchResults(helper.state, [
+ {
+ hits: [],
+ page: 0,
+ nbPages: 57,
+ index: 'instant_search',
+ processingTimeMS: 1,
+ nbHits: 170,
+ query: '',
+ hitsPerPage: 3,
+ params:
+ 'query=&hitsPerPage=3&page=0&facets=%5B%22hierarchicalCategories.lvl0%22%2C%22hierarchicalCategories.lvl1%22%2C%22hierarchicalCategories.lvl2%22%5D&tagFilters=&facetFilters=%5B%5B%22hierarchicalCategories.lvl1%3ACameras%20%26%20Camcorders%20%3E%20Digital%20Cameras%22%5D%5D',
+ exhaustiveFacetsCount: true,
+ exhaustiveNbHits: true,
+ facets: {
+ 'hierarchicalCategories.lvl1': {
+ 'Cameras & Camcorders > Digital Cameras': 170,
+ },
+ 'hierarchicalCategories.lvl2': {
+ 'Cameras & Camcorders > Digital Cameras > Digital SLR Cameras': 44,
+ 'Cameras & Camcorders > Digital Cameras > Mirrorless Cameras': 29,
+ 'Cameras & Camcorders > Digital Cameras > Point & Shoot Cameras': 84,
+ },
+ 'hierarchicalCategories.lvl0': {
+ 'Cameras & Camcorders': 170,
+ },
+ },
+ },
+ {
+ exhaustiveFacetsCount: true,
+ params:
+ 'query=&hitsPerPage=1&page=0&attributesToRetrieve=%5B%5D&attributesToHighlight=%5B%5D&attributesToSnippet=%5B%5D&tagFilters=&facets=%5B%22hierarchicalCategories.lvl0%22%2C%22hierarchicalCategories.lvl1%22%5D&facetFilters=%5B%5B%22hierarchicalCategories.lvl0%3ACameras%20%26%20Camcorders%22%5D%5D',
+ facets: {
+ 'hierarchicalCategories.lvl0': {
+ 'Cameras & Camcorders': 1369,
+ },
+ 'hierarchicalCategories.lvl1': {
+ 'Cameras & Camcorders > Camcorders': 50,
+ 'Cameras & Camcorders > Memory Cards': 113,
+ 'Cameras & Camcorders > Trail Cameras': 5,
+ 'Cameras & Camcorders > Microscopes': 5,
+ 'Cameras & Camcorders > Spotting Scopes': 5,
+ 'Cameras & Camcorders > Telescopes': 15,
+ 'Cameras & Camcorders > Monoculars': 5,
+ 'Cameras & Camcorders > Digital Cameras': 170,
+ 'Cameras & Camcorders > P&S Adapters & Chargers': 1,
+ 'Cameras & Camcorders > Binoculars': 20,
+ 'Cameras & Camcorders > Camcorder Accessories': 173,
+ 'Cameras & Camcorders > Digital Camera Accessories': 804,
+ },
+ },
+ exhaustiveNbHits: true,
+ hitsPerPage: 1,
+ index: 'instant_search',
+ processingTimeMS: 1,
+ nbPages: 1000,
+ nbHits: 1369,
+ query: '',
+ page: 0,
+ hits: [],
+ },
+ {
+ params:
+ 'query=&hitsPerPage=1&page=0&attributesToRetrieve=%5B%5D&attributesToHighlight=%5B%5D&attributesToSnippet=%5B%5D&tagFilters=&facets=%5B%22hierarchicalCategories.lvl0%22%5D',
+ exhaustiveFacetsCount: true,
+ exhaustiveNbHits: true,
+ facets: {
+ 'hierarchicalCategories.lvl0': {
+ Audio: 1570,
+ 'Computers & Tablets': 3563,
+ 'Movies & Music': 18,
+ Paper: 65,
+ 'MP Pending': 3,
+ 'Cameras & Camcorders': 1369,
+ 'Cell Phones': 3291,
+ Appliances: 4306,
+ 'Custom Parts': 2,
+ 'Health, Fitness & Beauty': 923,
+ 'Video Games': 505,
+ 'Office & School Supplies': 617,
+ 'Entertainment Gift Cards': 46,
+ 'Musical Instruments': 312,
+ 'MP Exclusives': 1,
+ 'Toys, Games & Drones': 285,
+ 'Name Brands': 101,
+ 'Batteries & Power': 7,
+ 'Star Wars': 1,
+ 'Geek Squad': 2,
+ 'DC Comics': 1,
+ 'Scanners, Faxes & Copiers': 46,
+ 'Furniture & Decor': 91,
+ 'Household Essentials': 148,
+ 'Car Electronics & GPS': 1208,
+ 'Magnolia Home Theater': 33,
+ Housewares: 255,
+ 'Smart Home': 405,
+ 'Beverage & Wine Coolers': 1,
+ 'TV & Home Theater': 1201,
+ 'Avengers: Age of Ultron': 1,
+ Exclusives: 1,
+ 'Gift Ideas': 2,
+ 'Carfi Instore Only': 4,
+ 'Office Electronics': 328,
+ 'Office Furniture & Storage': 152,
+ 'Wearable Technology': 271,
+ 'In-Store Only': 2,
+ 'Telephones & Communication': 194,
+ },
+ },
+ hitsPerPage: 1,
+ nbPages: 1000,
+ processingTimeMS: 1,
+ index: 'instant_search',
+ query: '',
+ nbHits: 21469,
+ hits: [],
+ page: 0,
+ },
+ ]),
+ state: helper.state,
+ helper,
+ createURL: state => state,
+ });
+ const { createURL, items } = rendering.mock.calls[1][0];
+ const secondItemValue = items[1].value;
+
+ const stateForURL = createURL(secondItemValue);
+
+ expect(stateForURL.hierarchicalFacetsRefinements).toEqual({
+ 'hierarchicalCategories.lvl0': ['Cameras & Camcorders > Digital Cameras'],
+ });
+ const stateForHome = createURL(null);
+ expect(stateForHome.hierarchicalFacetsRefinements).toEqual({
+ 'hierarchicalCategories.lvl0': [],
+ });
+ });
+
+ it('toggles the refine function when passed the special value null', () => {
+ const rendering = jest.fn();
+ const makeWidget = connectBreadcrumb(rendering);
+ const widget = makeWidget({ attributes: ['category', 'sub_category'] });
+
+ const config = widget.getConfiguration({});
+ const helper = jsHelper({ addAlgoliaAgent: () => {} }, '', config);
+ helper.search = jest.fn();
+
+ widget.init({
+ helper,
+ state: helper.state,
+ createURL: () => '#',
+ });
+
+ const firstRenderingOptions = rendering.mock.calls[0][0];
+ expect(firstRenderingOptions.items).toEqual([]);
+
+ helper.toggleRefinement('category', 'Decoration');
+
+ widget.render({
+ results: new SearchResults(helper.state, [
+ {
+ hits: [],
+ facets: {
+ category: {
+ Decoration: 880,
+ },
+ subCategory: {
+ 'Decoration > Candle holders & candles': 193,
+ 'Decoration > Frames & pictures': 173,
+ },
+ },
+ },
+ {
+ facets: {
+ category: {
+ Decoration: 880,
+ Outdoor: 47,
+ },
+ },
+ },
+ ]),
+ state: helper.state,
+ helper,
+ createURL: () => '#',
+ });
+ const refine = rendering.mock.calls[1][0].refine;
+ expect(helper.getHierarchicalFacetBreadcrumb('category')).toEqual([
+ 'Decoration',
+ ]);
+ refine(null);
+ expect(helper.getHierarchicalFacetBreadcrumb('category')).toEqual([]);
+ });
+
+ it('Provides a configuration if none exists', () => {
+ const makeWidget = connectBreadcrumb(() => {});
+ const widget = makeWidget({ attributes: ['category', 'sub_category'] });
+
+ const partialConfiguration = widget.getConfiguration({});
+
+ expect(partialConfiguration).toEqual({
+ hierarchicalFacets: [
+ {
+ attributes: ['category', 'sub_category'],
+ name: 'category',
+ rootPath: null,
+ separator: ' > ',
+ },
+ ],
+ });
+ });
+ it('Provides an additional configuration if the existing one is different', () => {
+ const makeWidget = connectBreadcrumb(() => {});
+ const widget = makeWidget({ attributes: ['category', 'sub_category'] });
+
+ const partialConfiguration = widget.getConfiguration({
+ hierarchicalFacets: [
+ {
+ attributes: ['otherCategory', 'otherSub_category'],
+ name: 'otherCategory',
+ separator: ' > ',
+ },
+ ],
+ });
+
+ expect(partialConfiguration).toEqual({
+ hierarchicalFacets: [
+ {
+ attributes: ['category', 'sub_category'],
+ name: 'category',
+ rootPath: null,
+ separator: ' > ',
+ },
+ ],
+ });
+ });
+});
diff --git a/src/connectors/breadcrumb/connectBreadcrumb.js b/src/connectors/breadcrumb/connectBreadcrumb.js
new file mode 100644
index 0000000000..aea999b1bb
--- /dev/null
+++ b/src/connectors/breadcrumb/connectBreadcrumb.js
@@ -0,0 +1,188 @@
+import find from 'lodash/find';
+import isEqual from 'lodash/isEqual';
+import { checkRendering } from '../../lib/utils.js';
+
+const usage = `Usage:
+var customBreadcrumb = connectBreadcrumb(function renderFn(params, isFirstRendering) {
+ // params = {
+ // createURL,
+ // items,
+ // refine,
+ // instantSearchInstance,
+ // widgetParams,
+ // }
+});
+search.addWidget(
+ customBreadcrumb({
+ attributes,
+ [ rootPath = null ],
+ })
+);
+Full documentation available at https://community.algolia.com/instantsearch.js/v2/connectors/connectBreadcrumb.html
+`;
+
+/**
+ * @typedef {Object} CustomBreadcrumbItem
+ * @property {string} name Name of the category or subcategory.
+ * @property {string} value Value of breadcrumb item.
+ */
+
+/**
+ * @typedef {Object} CustomBreadcrumbWidgetOptions
+ * @property {string[]} attributes Attributes to use to generate the hierarchy of the breadcrumb.
+ * @property {string} [rootPath = null] Prefix path to use if the first level is not the root level.
+ *
+ * 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).
+ */
+
+/**
+ * @typedef {Object} BreadcrumbRenderingOptions
+ * @property {function(item.value): string} createURL Creates an url for the next state for a clicked item. The special value `null` is used for the `Home` (or root) item of the breadcrumb and will return an empty array.
+ * @property {BreadcrumbItem[]} items Values to be rendered.
+ * @property {function(item.value)} refine Sets the path of the hierarchical filter and triggers a new search.
+ * @property {Object} widgetParams All original `CustomBreadcrumbWidgetOptions` forwarded to the `renderFn`.
+ */
+
+/**
+ * **Breadcrumb** connector provides the logic to build a custom widget
+ * that will give the user the ability to see the current path in a hierarchical facet.
+ *
+ * This is commonly used in websites that have a large amount of content organized in a hierarchical manner (usually e-commerce websites).
+ * @type {Connector}
+ * @param {function(BreadcrumbRenderingOptions, boolean)} renderFn Rendering function for the custom **Breadcrumb* widget.
+ * @return {function(CustomBreadcrumbWidgetOptions)} Re-usable widget factory for a custom **Breadcrumb** widget.
+ */
+export default function connectBreadcrumb(renderFn) {
+ checkRendering(renderFn, usage);
+ return (widgetParams = {}) => {
+ const { attributes, separator = ' > ', rootPath = null } = widgetParams;
+ const [hierarchicalFacetName] = attributes;
+
+ if (!attributes || !Array.isArray(attributes) || attributes.length === 0) {
+ throw new Error(usage);
+ }
+
+ return {
+ getConfiguration: currentConfiguration => {
+ if (currentConfiguration.hierarchicalFacets) {
+ const isFacetSet = find(
+ currentConfiguration.hierarchicalFacets,
+ ({ name }) => name === hierarchicalFacetName
+ );
+ if (isFacetSet) {
+ if (
+ !isEqual(isFacetSet.attributes, attributes) ||
+ isFacetSet.separator !== separator
+ ) {
+ console.warn(
+ 'Using Breadcrumb & HierarchicalMenu on the same facet with different options. Adding that one will override the configuration of the HierarchicalMenu. Check your options.'
+ );
+ }
+ return {};
+ }
+ }
+
+ return {
+ hierarchicalFacets: [
+ {
+ attributes,
+ name: hierarchicalFacetName,
+ separator,
+ rootPath,
+ },
+ ],
+ };
+ },
+
+ init({ createURL, helper, instantSearchInstance }) {
+ this._createURL = facetValue => {
+ if (!facetValue) {
+ const breadcrumb = helper.getHierarchicalFacetBreadcrumb(
+ hierarchicalFacetName
+ );
+ if (breadcrumb.length > 0) {
+ return createURL(
+ helper.state.toggleRefinement(
+ hierarchicalFacetName,
+ breadcrumb[0]
+ )
+ );
+ }
+ }
+ return createURL(
+ helper.state.toggleRefinement(hierarchicalFacetName, facetValue)
+ );
+ };
+
+ this._refine = function(facetValue) {
+ if (!facetValue) {
+ const breadcrumb = helper.getHierarchicalFacetBreadcrumb(
+ hierarchicalFacetName
+ );
+ if (breadcrumb.length > 0) {
+ helper
+ .toggleRefinement(hierarchicalFacetName, breadcrumb[0])
+ .search();
+ }
+ } else {
+ helper.toggleRefinement(hierarchicalFacetName, facetValue).search();
+ }
+ };
+
+ renderFn(
+ {
+ createURL: this._createURL,
+ canRefine: false,
+ instantSearchInstance,
+ items: [],
+ refine: this._refine,
+ widgetParams,
+ },
+ true
+ );
+ },
+
+ render({ instantSearchInstance, results, state }) {
+ const [{ name: facetName }] = state.hierarchicalFacets;
+
+ const facetsValues = results.getFacetValues(facetName);
+ const items = shiftItemsValues(prepareItems(facetsValues));
+
+ renderFn(
+ {
+ canRefine: items.length > 0,
+ createURL: this._createURL,
+ instantSearchInstance,
+ items,
+ refine: this._refine,
+ widgetParams,
+ },
+ false
+ );
+ },
+ };
+ };
+}
+
+function prepareItems(obj) {
+ return obj.data.reduce((result, currentItem) => {
+ if (currentItem.isRefined) {
+ result.push({
+ name: currentItem.name,
+ value: currentItem.path,
+ });
+ if (Array.isArray(currentItem.data)) {
+ const children = prepareItems(currentItem);
+ result = result.concat(children);
+ }
+ }
+ return result;
+ }, []);
+}
+
+function shiftItemsValues(array) {
+ return array.map((x, idx) => ({
+ name: x.name,
+ value: idx + 1 === array.length ? null : array[idx + 1].value,
+ }));
+}
diff --git a/src/connectors/hierarchical-menu/connectHierarchicalMenu.js b/src/connectors/hierarchical-menu/connectHierarchicalMenu.js
index c9cffa726f..6c01b4ddd1 100644
--- a/src/connectors/hierarchical-menu/connectHierarchicalMenu.js
+++ b/src/connectors/hierarchical-menu/connectHierarchicalMenu.js
@@ -1,3 +1,6 @@
+import find from 'lodash/find';
+import isEqual from 'lodash/isEqual';
+
import { checkRendering } from '../../lib/utils.js';
const usage = `Usage:
@@ -89,25 +92,47 @@ export default function connectHierarchicalMenu(renderFn) {
const [hierarchicalFacetName] = attributes;
return {
- getConfiguration: currentConfiguration => ({
- hierarchicalFacets: [
- {
- name: hierarchicalFacetName,
- attributes,
- separator,
- rootPath,
- showParentLevel,
- },
- ],
- maxValuesPerFacet:
- currentConfiguration.maxValuesPerFacet !== undefined
- ? Math.max(currentConfiguration.maxValuesPerFacet, limit)
- : limit,
- }),
+ getConfiguration: currentConfiguration => {
+ if (currentConfiguration.hierarchicalFacets) {
+ let isFacetSet = find(
+ currentConfiguration.hierarchicalFacets,
+ ({ name }) => name === hierarchicalFacetName
+ );
+ if (
+ isFacetSet &&
+ !(
+ isEqual(isFacetSet.attributes, attributes) &&
+ isFacetSet.separator === separator
+ )
+ ) {
+ console.warn(
+ 'using Breadcrumb & HierarchicalMenu on the same facet with different options'
+ );
+ }
+ return;
+ }
+
+ return {
+ hierarchicalFacets: [
+ {
+ name: hierarchicalFacetName,
+ attributes,
+ separator,
+ rootPath,
+ showParentLevel,
+ },
+ ],
+ maxValuesPerFacet:
+ currentConfiguration.maxValuesPerFacet !== undefined
+ ? Math.max(currentConfiguration.maxValuesPerFacet, limit)
+ : limit,
+ };
+ },
init({ helper, createURL, instantSearchInstance }) {
- this._refine = facetValue =>
+ this._refine = function(facetValue) {
helper.toggleRefinement(hierarchicalFacetName, facetValue).search();
+ };
// Bind createURL to this specific attribute
function _createURL(facetValue) {
diff --git a/src/connectors/index.js b/src/connectors/index.js
index cb76fe62c9..96fc66fe1a 100644
--- a/src/connectors/index.js
+++ b/src/connectors/index.js
@@ -40,3 +40,6 @@ export {
} from './star-rating/connectStarRating.js';
export { default as connectStats } from './stats/connectStats.js';
export { default as connectToggle } from './toggle/connectToggle.js';
+export {
+ default as connectBreadcrumb,
+} from './breadcrumb/connectBreadcrumb.js';
diff --git a/src/css/default/_breadcrumb.scss b/src/css/default/_breadcrumb.scss
new file mode 100644
index 0000000000..14df6f1b5f
--- /dev/null
+++ b/src/css/default/_breadcrumb.scss
@@ -0,0 +1,16 @@
+.ais-breadcrumb--label,
+.ais-breadcrumb--separator,
+.ais-breadcrumb--home,
+{
+ display: inline;
+ color: #3369e7;
+}
+
+.ais-breadcrumb--item {
+ display: inline;
+}
+
+.ais-breadcrumb--disabledLabel {
+ color: rgb(68, 68, 68);
+ display: inline;
+}
\ No newline at end of file
diff --git a/src/css/instantsearch.scss b/src/css/instantsearch.scss
index 457e6cc852..122d1a80a3 100644
--- a/src/css/instantsearch.scss
+++ b/src/css/instantsearch.scss
@@ -1,5 +1,4 @@
@import "base";
-
@import "default/search-box";
@import "default/refinement-searchbox";
@import "default/stats";
@@ -12,7 +11,8 @@
@import "default/hierarchical-menu";
@import "default/range-slider";
@import "default/star-rating";
-@import "default/price-ranges" ;
+@import "default/price-ranges";
@import "default/clear-all";
@import "default/current-refined-values";
@import "default/header-footer";
+@import "default/breadcrumb";
\ No newline at end of file
diff --git a/src/css/theme/_base.scss b/src/css/theme/_base.scss
index 07998cf5bd..f30604e895 100644
--- a/src/css/theme/_base.scss
+++ b/src/css/theme/_base.scss
@@ -14,6 +14,7 @@
@import 'star-rating';
@import 'stats';
@import 'toggle';
+@import 'breadcrumb';
[class^=ais-] {
box-sizing: border-box;
diff --git a/src/css/theme/_breadcrumb.scss b/src/css/theme/_breadcrumb.scss
new file mode 100644
index 0000000000..c70253dbcd
--- /dev/null
+++ b/src/css/theme/_breadcrumb.scss
@@ -0,0 +1,31 @@
+.ais-breadcrumb--root {
+ .ais-breadcrumb--label,
+ .ais-breadcrumb--separator,
+ .ais-breadcrumb--home,
+ {
+ div {
+ display: inline;
+ }
+ display: inline;
+ color: #3369e7;
+ }
+ .ais-breadcrumb--disabledLabel {
+ color: rgb(68, 68, 68);
+ display: inline;
+ }
+ .ais-breadcrumb--separator {
+ position: relative;
+ display: inline-block;
+ height: 14px;
+ width: 14px;
+ &:after {
+ background: url("data:image/svg+xml;utf8,") no-repeat center center/contain;
+ content: ' ';
+ display: block;
+ position: absolute;
+ top: 2px;
+ height: 14px;
+ width: 14px;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/widgets/breadcrumb/breadcrumb.js b/src/widgets/breadcrumb/breadcrumb.js
new file mode 100644
index 0000000000..ce61a0158f
--- /dev/null
+++ b/src/widgets/breadcrumb/breadcrumb.js
@@ -0,0 +1,194 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import cx from 'classnames';
+
+import Breadcrumb from '../../components/Breadcrumb/Breadcrumb';
+import { connectBreadcrumb } from '../../connectors';
+import defaultTemplates from './defaultTemplates.js';
+
+import {
+ bemHelper,
+ getContainerNode,
+ prepareTemplateProps,
+} from '../../lib/utils';
+
+const bem = bemHelper('ais-breadcrumb');
+
+const renderer = ({
+ autoHideContainer,
+ containerNode,
+ cssClasses,
+ renderState,
+ separator,
+ templates,
+ transformData,
+}) => (
+ { canRefine, createURL, instantSearchInstance, items, refine },
+ isFirstRendering
+) => {
+ if (isFirstRendering) {
+ renderState.templateProps = prepareTemplateProps({
+ defaultTemplates,
+ templatesConfig: instantSearchInstance.templatesConfig,
+ templates,
+ transformData,
+ });
+ return;
+ }
+
+ const shouldAutoHideContainer = autoHideContainer && !canRefine;
+
+ ReactDOM.render(
+ ,
+ containerNode
+ );
+};
+
+const usage = `Usage:
+breadcrumb({
+ container,
+ attributes,
+ [ autoHideContainer=true ],
+ [ cssClasses.{disabledLabel, home, label, root, separator}={} ],
+ [ templates.{home, separator}]
+ [ transformData.{item} ],
+
+})`;
+
+/**
+ * @typedef {Object} BreadcrumbCSSClasses
+ * @property {string|string[]} [disabledLabel] CSS class to add to the last element of the breadcrumb (which is not clickable).
+ * @property {string|string[]} [home] CSS class to add to the first element of the breadcrumb.
+ * @property {string|string[]} [label] CSS class to add to the text part of each element of the breadcrumb.
+ * @property {string|string[]} [root] CSS class to add to the root element of the widget.
+ * @property {string|string[]} [separator] CSS class to add to the separator.
+ */
+
+/**
+ * @typedef {Object} BreadcrumbTemplates
+ * @property {string|function(object):string} [home='Home'] Label of the breadcrumb's first element.
+ * @property {string|function(object):string} [separator=''] Symbol used to separate the elements of the breadcrumb.
+ */
+
+/**
+ * @typedef {Object} BreadcrumbTransforms
+ * @property {function(object):object} [item] Method to change the object passed to the `item` template
+ */
+
+/**
+ * @typedef {Object} BreadcrumbWidgetOptions
+ * @property {string|HTMLElement} container CSS Selector or HTMLElement to insert the widget.
+ * @property {string[]} attributes Array of attributes to use to generate the breadcrumb.
+ *
+ * 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).
+ * @property {BreadcrumbTemplates} [templates] Templates to use for the widget.
+ * @property {BreadcrumbTransforms} [transformData] Set of functions to transform the data passed to the templates.
+ * @property {boolean} [autoHideContainer=true] Hides the container when there are no items in the breadcrumb.
+ * @property {BreadcrumbCSSClasses} [cssClasses] CSS classes to add to the wrapping elements.
+ */
+
+/**
+ * The breadcrumb widget is a secondary navigation scheme that allows the user to see where the current page is in relation to the facet's hierarchy.
+ *
+ * It reduces the number of actions a user needs to take in order to get to a higher-level page and improve the findability of the app or website's sections and pages.
+ * It is commonly used for websites with a large amount of data organized into categories with subcategories.
+ *
+ * All attributes (lvl0, lvl1 in this case) must be declared as [attributes for faceting](https://www.algolia.com/doc/guides/searching/faceting/#declaring-attributes-for-faceting) in your
+ * Algolia settings.
+ *
+ * @requirements
+ * Your objects must be formatted in a specific way to be
+ * able to display a breadcrumb. Here's an example:
+ *
+ * ```javascript
+ * {
+ * "objectID": "123",
+ * "name": "orange",
+ * "categories": {
+ * "lvl0": "fruits",
+ * "lvl1": "fruits > citrus"
+ * }
+ * }
+ * ```
+ *
+ * Each level must be specified entirely.
+ * It's also possible to have multiple values per level, for instance:
+ *
+ * ```javascript
+ * {
+ * "objectID": "123",
+ * "name": "orange",
+ * "categories": {
+ * "lvl0": ["fruits", "vitamins"],
+ * "lvl1": ["fruits > citrus", "vitamins > C"]
+ * }
+ * }
+ * ```
+ * @type {WidgetFactory}
+ * @category navigation
+ * @param {BreadcrumbWidgetOptions} $0 The Breadcrumb widget options.
+ * @return {Widget} A new Breadcrumb widget instance.
+ * @example
+ * search.addWidget(
+ * instantsearch.widgets.Breadcrumb({
+ * container: '#breadcrumb',
+ * attributes: ['hierarchicalCategories.lvl0', 'hierarchicalCategories.lvl1', 'hierarchicalCategories.lvl2'],
+ * templates: { home: 'Home Page' }
+ * rootPath: 'Cameras & Camcorders > Digital Cameras',
+ * })
+ * );
+ */
+
+export default function breadcrumb(
+ {
+ attributes,
+ autoHideContainer = false,
+ container,
+ cssClasses: userCssClasses = {},
+ rootPath = null,
+ separator = ' > ',
+ templates = defaultTemplates,
+ transformData,
+ } = {}
+) {
+ if (!container) {
+ throw new Error(usage);
+ }
+
+ const containerNode = getContainerNode(container);
+
+ const cssClasses = {
+ disabledLabel: cx(bem('disabledLabel'), userCssClasses.disabledLabel),
+ home: cx(bem('home'), userCssClasses.home),
+ item: cx(bem('item'), userCssClasses.item),
+ label: cx(bem('label'), userCssClasses.label),
+ root: cx(bem('root'), userCssClasses.root),
+ separator: cx(bem('separator'), userCssClasses.separator),
+ };
+
+ const specializedRenderer = renderer({
+ autoHideContainer,
+ containerNode,
+ cssClasses,
+ renderState: {},
+ separator,
+ templates,
+ transformData,
+ });
+
+ try {
+ const makeBreadcrumb = connectBreadcrumb(specializedRenderer);
+ return makeBreadcrumb({ attributes, rootPath });
+ } catch (e) {
+ throw new Error(usage);
+ }
+}
diff --git a/src/widgets/breadcrumb/defaultTemplates.js b/src/widgets/breadcrumb/defaultTemplates.js
new file mode 100644
index 0000000000..270fbf4d14
--- /dev/null
+++ b/src/widgets/breadcrumb/defaultTemplates.js
@@ -0,0 +1,4 @@
+export default {
+ home: 'Home',
+ separator: '',
+};
diff --git a/src/widgets/index.js b/src/widgets/index.js
index 680c825da6..efb8ceccfe 100644
--- a/src/widgets/index.js
+++ b/src/widgets/index.js
@@ -44,4 +44,5 @@ export { default as starRating } from '../widgets/star-rating/star-rating.js';
export { default as stats } from '../widgets/stats/stats.js';
export { default as toggle } from '../widgets/toggle/toggle.js';
export { default as analytics } from '../widgets/analytics/analytics.js';
+export { default as breadcrumb } from '../widgets/breadcrumb/breadcrumb.js';
export { default as menuSelect } from '../widgets/menu-select/menu-select.js';