Skip to content

Commit

Permalink
feat(breadcrumb): Add the breadcrumb widget (#2451)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
marielaures authored and samouss committed Oct 18, 2017
1 parent 0c987c1 commit 11d78f0
Show file tree
Hide file tree
Showing 15 changed files with 1,209 additions and 37 deletions.
148 changes: 147 additions & 1 deletion dev/app/init-builtin-widgets.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<div id="hierarchicalMenu"></div>
<div id="breadcrumb"></div>
`;

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 = `
<div id="hierarchicalMenu"></div>
<div id="breadcrumb"></div>
`;

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 = `
<div id="breadcrumb"></div>
<div id="hierarchicalMenu"></div>
`;

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 = `
<div id="breadcrumb"></div>
<div id="hierarchicalMenu"></div>
`;

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 => {
Expand Down Expand Up @@ -719,7 +850,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',
],
})
);
})
Expand Down
82 changes: 82 additions & 0 deletions src/components/Breadcrumb/Breadcrumb.js
Original file line number Diff line number Diff line change
@@ -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
? <a className={`${cssClasses.disabledLabel} ${cssClasses.label}`}>
{item.name}
</a>
: <a
className={cssClasses.label}
href={createURL(item.value)}
onClick={e => {
e.preventDefault();
refine(item.value);
}}
>
{item.name}
</a>;

return [
<Template
key={item.name + idx}
rootProps={{ className: cssClasses.separator }}
templateKey="separator"
{...this.props.templateProps}
/>,
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 (
<div className={cssClasses.root}>
<a
className={homeClassNames.join(' ')}
href={homeUrl}
onClick={homeOnClickHandler}
>
<Template templateKey="home" {...this.props.templateProps} />
</a>
{breadcrumb}
</div>
);
}
}

export default autoHideContainerHOC(Breadcrumb);
19 changes: 10 additions & 9 deletions src/components/Template.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
}

Expand Down Expand Up @@ -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',
);
}

Expand All @@ -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),
Expand All @@ -70,7 +71,7 @@ PureTemplate.propTypes = {
PropTypes.shape({
o: PropTypes.string,
c: PropTypes.string,
})
}),
),
delimiters: PropTypes.string,
disableLambda: PropTypes.bool,
Expand Down Expand Up @@ -112,15 +113,15 @@ 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})`,
);
}

const dataType = typeof data;
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;
Expand All @@ -140,15 +141,15 @@ 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);
} else {
const transformedHelpers = transformHelpersToHogan(
helpers,
compileOptions,
data
data,
);
const preparedData = { ...data, helpers: transformedHelpers };
return hogan.compile(template, compileOptions).render(preparedData);
Expand All @@ -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);
})
}),
);
}

Expand Down
28 changes: 19 additions & 9 deletions src/components/__tests__/Template-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -208,15 +208,10 @@ describe('Template', () => {
it('forward rootProps to the first node', () => {
function fn() {}

const props = getProps({});
const tree = renderer
.create(
<PureTemplate
rootProps={{ className: 'hey', onClick: fn }}
{...props}
/>
)
.toJSON();
const props = getProps({
rootProps: { className: 'hey', onClick: fn },
});
const tree = renderer.create(<PureTemplate {...props} />).toJSON();
expect(tree).toMatchSnapshot();
});

Expand All @@ -229,6 +224,7 @@ describe('Template', () => {
container = document.createElement('div');
props = getProps({
data: { hello: 'mom' },
rootProps: { className: 'myCssClass' },
});
component = ReactDOM.render(<PureTemplate {...props} />, container);
sinon.spy(component, 'render');
Expand All @@ -251,12 +247,25 @@ describe('Template', () => {
ReactDOM.render(<PureTemplate {...props} />, container);
expect(component.render.called).toBe(true);
});

it('calls render when rootProps changes', () => {
props.rootProps = { className: 'myCssClass mySecondCssClass' };
ReactDOM.render(<PureTemplate {...props} />, container);
expect(component.render.called).toBe(true);
});

it('does not call render when rootProps remain unchanged', () => {
props.rootProps = { className: 'myCssClass' };
ReactDOM.render(<PureTemplate {...props} />, container);
expect(component.render.called).toBe(false);
});
});

function getProps({
templates = { test: '' },
data = {},
templateKey = 'test',
rootProps = {},
useCustomCompileOptions = {},
templatesConfig = { helper: {}, compileOptions: {} },
transformData = null,
Expand All @@ -265,6 +274,7 @@ describe('Template', () => {
templates,
data,
templateKey,
rootProps,
useCustomCompileOptions,
templatesConfig,
transformData,
Expand Down
Loading

0 comments on commit 11d78f0

Please sign in to comment.