Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/components/Panel/Panel.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import React from 'preact-compat';
import PropTypes from 'prop-types';
import cx from 'classnames';
import Template from '../Template/Template';

const Panel = ({ cssClasses, hidden, templateProps, data, onRef }) => (
<div
className={cx(cssClasses.root, {
[cssClasses.noRefinementRoot]: hidden,
})}
hidden={hidden}
>
{templateProps.templates.header && (
<Template
{...templateProps}
templateKey="header"
rootProps={{
className: cssClasses.header,
}}
data={data}
/>
)}

<div className={cssClasses.body} ref={onRef} />

{templateProps.templates.footer && (
<Template
{...templateProps}
templateKey="footer"
rootProps={{
className: cssClasses.footer,
}}
data={data}
/>
)}
</div>
);

Panel.propTypes = {
// Prop to get the panel body reference to insert the widget
onRef: PropTypes.func,
cssClasses: PropTypes.shape({
root: PropTypes.string.isRequired,
noRefinementRoot: PropTypes.string.isRequired,
body: PropTypes.string.isRequired,
header: PropTypes.string.isRequired,
footer: PropTypes.string.isRequired,
}).isRequired,
templateProps: PropTypes.shape({
templates: PropTypes.object.isRequired,
}).isRequired,
hidden: PropTypes.bool.isRequired,
data: PropTypes.object.isRequired,
};

export default Panel;
66 changes: 66 additions & 0 deletions src/components/Panel/__tests__/Panel-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import React from 'react';
import { mount } from 'enzyme';
import Panel from '../Panel';

describe('Panel', () => {
test('should render component with default props', () => {
const props = {
cssClasses: {
root: 'root',
noRefinementRoot: 'noRefinementRoot',
body: 'body',
header: 'header',
footer: 'footer',
},
hidden: false,
data: {},
templateProps: {
templates: {
header: 'Header',
footer: 'Footer',
},
},
};

const wrapper = mount(<Panel {...props} />);

expect(wrapper.find('.root')).toHaveLength(1);
expect(wrapper.find('.noRefinementRoot')).toHaveLength(0);
expect(wrapper.find('.body')).toHaveLength(1);
expect(wrapper.find('.header')).toHaveLength(1);
expect(wrapper.find('.footer')).toHaveLength(1);
expect(wrapper.find('.header').text()).toBe('Header');
expect(wrapper.find('.footer').text()).toBe('Footer');
expect(wrapper).toMatchSnapshot();
});

test('should render component with `hidden` prop', () => {
const props = {
cssClasses: {
root: 'root',
noRefinementRoot: 'noRefinementRoot',
body: 'body',
header: 'header',
footer: 'footer',
},
hidden: true,
data: {},
templateProps: {
templates: {
header: 'Header',
footer: 'Footer',
},
},
};

const wrapper = mount(<Panel {...props} />);

expect(wrapper.find('.root')).toHaveLength(1);
expect(wrapper.find('.noRefinementRoot')).toHaveLength(1);
expect(wrapper.find('.body')).toHaveLength(1);
expect(wrapper.find('.header')).toHaveLength(1);
expect(wrapper.find('.footer')).toHaveLength(1);
expect(wrapper.props().hidden).toBe(true);
expect(wrapper).toMatchSnapshot();
});
});
55 changes: 55 additions & 0 deletions src/components/Panel/__tests__/__snapshots__/Panel-test.js.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Panel should render component with \`hidden\` prop 1`] = `
<div
className="root noRefinementRoot"
hidden={true}
>
<div
className="header"
dangerouslySetInnerHTML={
Object {
"__html": "Header",
}
}
/>
<div
className="body"
/>
<div
className="footer"
dangerouslySetInnerHTML={
Object {
"__html": "Footer",
}
}
/>
</div>
`;

exports[`Panel should render component with default props 1`] = `
<div
className="root"
hidden={false}
>
<div
className="header"
dangerouslySetInnerHTML={
Object {
"__html": "Header",
}
}
/>
<div
className="body"
/>
<div
className="footer"
dangerouslySetInnerHTML={
Object {
"__html": "Footer",
}
}
/>
</div>
`;
1 change: 1 addition & 0 deletions src/widgets/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,4 @@ export { default as analytics } from './analytics/analytics.js';
export { default as breadcrumb } from './breadcrumb/breadcrumb.js';
export { default as menuSelect } from './menu-select/menu-select.js';
export { default as poweredBy } from './powered-by/powered-by.js';
export { default as panel } from './panel/panel.js';
46 changes: 46 additions & 0 deletions src/widgets/panel/__tests__/panel-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import panel from '../panel';

describe('panel call', () => {
test('without arguments does not throw', () => {
expect(() => panel()).not.toThrow();
});

test('with templates does not throw', () => {
expect(() =>
panel({
templates: { header: 'header' },
})
).not.toThrow();
});

test('with `hidden` as function does not throw', () => {
expect(() =>
panel({
hidden: () => true,
})
).not.toThrow();
});

test('with `hidden` as boolean warns', () => {
const warn = jest.spyOn(global.console, 'warn');
warn.mockImplementation(() => {});

panel({
hidden: true,
});

expect(warn).toHaveBeenCalledWith(
'[InstantSearch.js]: The `hidden` option in the "panel" widget expects a function returning a boolean (received "boolean" type).'
);

warn.mockRestore();
});

test('with a widget without `container` throws', () => {
const fakeWidget = () => {};

expect(() => panel()(fakeWidget)({})).toThrowErrorMatchingInlineSnapshot(
`"[InstantSearch.js] The \`container\` option is required in the widget within the panel."`
);
});
});
113 changes: 113 additions & 0 deletions src/widgets/panel/panel.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import React, { render, unmountComponentAtNode } from 'preact-compat';
import cx from 'classnames';
import { getContainerNode, prepareTemplateProps, warn } from '../../lib/utils';
import { component } from '../../lib/suit';
import Panel from '../../components/Panel/Panel';

const suit = component('Panel');

const renderer = ({ containerNode, cssClasses, templateProps }) => ({
options,
hidden,
}) => {
let bodyRef = null;

render(
<Panel
cssClasses={cssClasses}
hidden={hidden}
templateProps={templateProps}
data={options}
onRef={ref => (bodyRef = ref)}
/>,
containerNode
);

return { bodyRef };
};

const usage = `Usage:
const widgetWithHeaderFooter = panel({
[ templates.{header, footer} ],
[ hidden ],
[ cssClasses.{root, noRefinementRoot, body, header, footer} ],
})(widget);

const myWidget = widgetWithHeaderFooter(widgetOptions)`;

export default function panel({
templates = {},
hidden = () => false,
cssClasses: userCssClasses = {},
} = {}) {
if (typeof hidden !== 'function') {
warn(
`The \`hidden\` option in the "panel" widget expects a function returning a boolean (received "${typeof hidden}" type).`
);
}

const cssClasses = {
root: cx(suit(), userCssClasses.root),
noRefinementRoot: cx(
suit({ modifierName: 'noRefinement' }),
userCssClasses.noRefinementRoot
),
body: cx(suit({ descendantName: 'body' }), userCssClasses.body),
header: cx(suit({ descendantName: 'header' }), userCssClasses.header),
footer: cx(suit({ descendantName: 'footer' }), userCssClasses.footer),
};

return widgetFactory => (widgetOptions = {}) => {
const { container } = widgetOptions;

if (!container) {
throw new Error(
`[InstantSearch.js] The \`container\` option is required in the widget within the panel.`
);
}

const defaultTemplates = { header: '', footer: '' };
const templateProps = prepareTemplateProps({ defaultTemplates, templates });

const renderPanel = renderer({
containerNode: getContainerNode(container),
cssClasses,
templateProps,
});

try {
const { bodyRef } = renderPanel({
options: {},
hidden: true,
});

const widget = widgetFactory({
...widgetOptions,
container: getContainerNode(bodyRef),
});

return {
...widget,
dispose() {
unmountComponentAtNode(getContainerNode(container));

if (typeof widget.dispose === 'function') {
widget.dispose();
}
},
render(options) {
renderPanel({
options,
hidden: Boolean(hidden(options)),
});

if (typeof widget.render === 'function') {
widget.render(options);
}
},
};
} catch (error) {
throw new Error(usage);
}
};
}
2 changes: 2 additions & 0 deletions storybook/app/builtin/init-stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import initStatsStories from './stories/stats.stories';
import initToggleStories from './stories/toggleRefinement.stories';
import initConfigureStories from './stories/configure.stories';
import initPoweredByStories from './stories/powered-by.stories';
import initPanelStories from './stories/panel.stories';

export default () => {
initAnalyticsStories();
Expand Down Expand Up @@ -50,4 +51,5 @@ export default () => {
initToggleStories();
initConfigureStories();
initPoweredByStories();
initPanelStories();
};
28 changes: 28 additions & 0 deletions storybook/app/builtin/stories/panel.stories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/* eslint-disable import/default */

import { storiesOf } from 'dev-novel';
import instantsearch from '../../../../index';
import { wrapWithHits } from '../../utils/wrap-with-hits.js';

const stories = storiesOf('Panel');

export default () => {
stories.add(
'with default',
wrapWithHits(container => {
window.search.addWidget(
instantsearch.widgets.panel({
templates: {
header: ({ results }) =>
`Header ${results ? `| ${results.nbHits} results` : ''}`,
footer: 'Footer',
},
hidden: ({ results }) => results.nbHits === 0,
})(instantsearch.widgets.refinementList)({
container,
attribute: 'brand',
})
);
})
);
};