diff --git a/src/connectors/toggle/connectToggle.js b/src/connectors/toggle/connectToggle.js new file mode 100644 index 0000000000..cda9a0770a --- /dev/null +++ b/src/connectors/toggle/connectToggle.js @@ -0,0 +1,137 @@ +import { + bemHelper, + getContainerNode, +} from '../../lib/utils.js'; +import defaultTemplates from './defaultTemplates.js'; +import cx from 'classnames'; +import connectCurrent from './implementations/current.js'; +import connectLegacy from './implementations/legacy.js'; + +const bem = bemHelper('ais-toggle'); + +// we cannot use helper. because the facet is not yet declared in the helper +const hasFacetsRefinementsFor = (attributeName, searchParameters) => + searchParameters && + searchParameters.facetsRefinements && + searchParameters.facetsRefinements[attributeName] !== undefined; + +/** + * Instantiate the toggling of a boolean facet filter on and off. + * @function toggle + * @param {string|DOMElement} options.container CSS Selector or DOMElement to insert the widget + * @param {string} options.attributeName Name of the attribute for faceting (eg. "free_shipping") + * @param {string} options.label Human-readable name of the filter (eg. "Free Shipping") + * @param {Object} [options.values] Lets you define the values to filter on when toggling + * @param {string|number|boolean} [options.values.on=true] Value to filter on when checked + * @param {string|number|boolean} [options.values.off=undefined] Value to filter on when unchecked + * element (when using the default template). By default when switching to `off`, no refinement will be asked. So you + * will get both `true` and `false` results. If you set the off value to `false` then you will get only objects + * having `false` has a value for the selected attribute. + * @param {Object} [options.templates] Templates to use for the widget + * @param {string|Function} [options.templates.header] Header template + * @param {string|Function} [options.templates.item] Item template, provided with `name`, `count`, `isRefined`, `url` data properties + * count is always the number of hits that would be shown if you toggle the widget. We also provide + * `onFacetValue` and `offFacetValue` objects with according counts. + * @param {string|Function} [options.templates.footer] Footer template + * @param {Function} [options.transformData.item] Function to change the object passed to the `item` template + * @param {boolean} [options.autoHideContainer=true] Hide the container when there are no results + * @param {Object} [options.cssClasses] CSS classes to add + * @param {string|string[]} [options.cssClasses.root] CSS class to add to the root element + * @param {string|string[]} [options.cssClasses.header] CSS class to add to the header element + * @param {string|string[]} [options.cssClasses.body] CSS class to add to the body element + * @param {string|string[]} [options.cssClasses.footer] CSS class to add to the footer element + * @param {string|string[]} [options.cssClasses.list] CSS class to add to the list element + * @param {string|string[]} [options.cssClasses.item] CSS class to add to each item element + * @param {string|string[]} [options.cssClasses.active] CSS class to add to each active element + * @param {string|string[]} [options.cssClasses.label] CSS class to add to each + * label element (when using the default template) + * @param {string|string[]} [options.cssClasses.checkbox] CSS class to add to each + * checkbox element (when using the default template) + * @param {string|string[]} [options.cssClasses.count] CSS class to add to each count + * @param {object|boolean} [options.collapsible=false] Hide the widget body and footer when clicking on header + * @param {boolean} [options.collapsible.collapsed] Initial collapsed state of a collapsible widget + * @return {Object} + */ +const usage = `Usage: +toggle({ + container, + attributeName, + label, + [ values={on: true, off: undefined} ], + [ cssClasses.{root,header,body,footer,list,item,active,label,checkbox,count} ], + [ templates.{header,item,footer} ], + [ transformData.{item} ], + [ autoHideContainer=true ], + [ collapsible=false ] +})`; +function connectToggle(toggleRendering) { + const legacyToggle = connectLegacy(toggleRendering); + const currentToggle = connectCurrent(toggleRendering); + + return ({ + container, + attributeName, + label, + values: userValues = {on: true, off: undefined}, + templates = defaultTemplates, + collapsible = false, + cssClasses: userCssClasses = {}, + transformData, + autoHideContainer = true, + } = {}) => { + const containerNode = getContainerNode(container); + + if (!container || !attributeName || !label) { + throw new Error(usage); + } + + const hasAnOffValue = userValues.off !== undefined; + + const cssClasses = { + root: cx(bem(null), userCssClasses.root), + header: cx(bem('header'), userCssClasses.header), + body: cx(bem('body'), userCssClasses.body), + footer: cx(bem('footer'), userCssClasses.footer), + list: cx(bem('list'), userCssClasses.list), + item: cx(bem('item'), userCssClasses.item), + active: cx(bem('item', 'active'), userCssClasses.active), + label: cx(bem('label'), userCssClasses.label), + checkbox: cx(bem('checkbox'), userCssClasses.checkbox), + count: cx(bem('count'), userCssClasses.count), + }; + + // store the computed options for usage in the two toggle implementations + const implemOptions = { + attributeName, + label, + userValues, + templates, + collapsible, + transformData, + hasAnOffValue, + containerNode, + cssClasses, + autoHideContainer, + }; + + return { + getConfiguration(currentSearchParameters, searchParametersFromUrl) { + const useLegacyToggle = + hasFacetsRefinementsFor(attributeName, currentSearchParameters) || + hasFacetsRefinementsFor(attributeName, searchParametersFromUrl); + + const toggleImplementation = useLegacyToggle ? + legacyToggle(implemOptions) : + currentToggle(implemOptions); + + this.init = toggleImplementation.init.bind(toggleImplementation); + this.render = toggleImplementation.render.bind(toggleImplementation); + return toggleImplementation.getConfiguration(currentSearchParameters, searchParametersFromUrl); + }, + init() {}, + render() {}, + }; + }; +} + +export default connectToggle; diff --git a/src/widgets/toggle/defaultTemplates.js b/src/connectors/toggle/defaultTemplates.js similarity index 100% rename from src/widgets/toggle/defaultTemplates.js rename to src/connectors/toggle/defaultTemplates.js diff --git a/src/connectors/toggle/implementations/current.js b/src/connectors/toggle/implementations/current.js new file mode 100644 index 0000000000..e6074b99a2 --- /dev/null +++ b/src/connectors/toggle/implementations/current.js @@ -0,0 +1,132 @@ +import find from 'lodash/find'; +import defaultTemplates from '../defaultTemplates.js'; +import { + prepareTemplateProps, + escapeRefinement, + unescapeRefinement, +} from '../../../lib/utils.js'; + +const connectToggle = toggleRendering => ({ + attributeName, + label, + userValues, + templates, + collapsible, + transformData, + hasAnOffValue, + containerNode, + cssClasses, + autoHideContainer, +} = {}) => { + const on = userValues ? escapeRefinement(userValues.on) : undefined; + const off = userValues ? escapeRefinement(userValues.off) : undefined; + + return { + getConfiguration() { + return { + disjunctiveFacets: [attributeName], + }; + }, + toggleRefinement(helper, facetValue, isRefined) { + // Checking + if (!isRefined) { + if (hasAnOffValue) { + helper.removeDisjunctiveFacetRefinement(attributeName, off); + } + helper.addDisjunctiveFacetRefinement(attributeName, on); + } else { + // Unchecking + helper.removeDisjunctiveFacetRefinement(attributeName, on); + if (hasAnOffValue) { + helper.addDisjunctiveFacetRefinement(attributeName, off); + } + } + + helper.search(); + }, + init({state, helper, templatesConfig}) { + this._templateProps = prepareTemplateProps({ + transformData, + defaultTemplates, + templatesConfig, + templates, + }); + + this.toggleRefinement = this.toggleRefinement.bind(this, helper); + + // no need to refine anything at init if no custom off values + if (!hasAnOffValue) { + return; + } + + // Add filtering on the 'off' value if set + const isRefined = state.isDisjunctiveFacetRefined(attributeName, on); + if (!isRefined) { + helper.addDisjunctiveFacetRefinement(attributeName, off); + } + + toggleRendering({ + collapsible, + createURL: () => '', + cssClasses, + facetValues: [], + shouldAutoHideContainer: autoHideContainer, + templateProps: this._templateProps, + toggleRefinement: this.toggleRefinement, + containerNode, + }, false); + }, + render({helper, results, state, createURL}) { + const isRefined = helper.state.isDisjunctiveFacetRefined(attributeName, on); + const offValue = off === undefined ? false : off; + const allFacetValues = results.getFacetValues(attributeName); + const onData = find(allFacetValues, {name: unescapeRefinement(on)}); + const onFacetValue = { + name: label, + isRefined: onData !== undefined ? onData.isRefined : false, + count: onData === undefined ? null : onData.count, + }; + const offData = hasAnOffValue ? find(allFacetValues, {name: unescapeRefinement(offValue)}) : undefined; + const offFacetValue = { + name: label, + isRefined: offData !== undefined ? offData.isRefined : false, + count: offData === undefined ? results.nbHits : offData.count, + }; + + // what will we show by default, + // if checkbox is not checked, show: [ ] free shipping (countWhenChecked) + // if checkbox is checked, show: [x] free shipping (countWhenNotChecked) + const nextRefinement = isRefined ? offFacetValue : onFacetValue; + + const facetValue = { + name: label, + isRefined, + count: nextRefinement === undefined ? null : nextRefinement.count, + onFacetValue, + offFacetValue, + }; + + // Bind createURL to this specific attribute + function _createURL() { + return createURL( + state + .removeDisjunctiveFacetRefinement(attributeName, isRefined ? on : off) + .addDisjunctiveFacetRefinement(attributeName, isRefined ? off : on) + ); + } + + toggleRendering({ + collapsible, + createURL: _createURL, + cssClasses, + facetValues: [facetValue], + shouldAutoHideContainer: autoHideContainer && (facetValue.count === 0 || facetValue.count === null), + templateProps: this._templateProps, + toggleRefinement: this.toggleRefinement, + containerNode, + }, false); + }, + }; +}; + +export default connectToggle; diff --git a/src/connectors/toggle/implementations/legacy.js b/src/connectors/toggle/implementations/legacy.js new file mode 100644 index 0000000000..230fd4d794 --- /dev/null +++ b/src/connectors/toggle/implementations/legacy.js @@ -0,0 +1,111 @@ +import find from 'lodash/find'; +import defaultTemplates from '../defaultTemplates.js'; +import { + prepareTemplateProps, +} from '../../../lib/utils.js'; + +const connectToggle = toggleRendering => ({ + attributeName, + label, + userValues, + templates, + collapsible, + transformData, + hasAnOffValue, + autoHideContainer, + cssClasses, + containerNode, +} = {}) => { //eslint-disable-line + return { + getConfiguration() { + return { + facets: [attributeName], + }; + }, + toggleRefinement(helper, facetValue, isRefined) { + const on = userValues.on; + const off = userValues.off; + + // Checking + if (!isRefined) { + if (hasAnOffValue) { + helper.removeFacetRefinement(attributeName, off); + } + helper.addFacetRefinement(attributeName, on); + } else { + // Unchecking + helper.removeFacetRefinement(attributeName, on); + if (hasAnOffValue) { + helper.addFacetRefinement(attributeName, off); + } + } + + helper.search(); + }, + init({state, helper, templatesConfig}) { + this._templateProps = prepareTemplateProps({ + transformData, + defaultTemplates, + templatesConfig, + templates, + }); + this.toggleRefinement = this.toggleRefinement.bind(this, helper); + + // no need to refine anything at init if no custom off values + if (!hasAnOffValue) { + return; + } + // Add filtering on the 'off' value if set + const isRefined = state.isFacetRefined(attributeName, userValues.on); + if (!isRefined) { + helper.addFacetRefinement(attributeName, userValues.off); + } + + toggleRendering({ + collapsible, + createURL: () => '', + cssClasses, + facetValues: [], + shouldAutoHideContainer: autoHideContainer, + templateProps: this._templateProps, + toggleRefinement: this.toggleRefinement, + containerNode, + }, true); + }, + render({helper, results, state, createURL}) { + const isRefined = helper.state.isFacetRefined(attributeName, userValues.on); + const currentRefinement = isRefined ? userValues.on : userValues.off; + let count; + if (typeof currentRefinement === 'number') { + count = results.getFacetStats(attributeName).sum; + } else { + const facetData = find(results.getFacetValues(attributeName), {name: isRefined.toString()}); + count = facetData !== undefined ? facetData.count : null; + } + + const facetValue = { + name: label, + isRefined, + count, + }; + + // Bind createURL to this specific attribute + function _createURL() { + return createURL(state.toggleRefinement(attributeName, isRefined)); + } + + toggleRendering({ + collapsible, + createURL: _createURL, + cssClasses, + facetValues: [facetValue], + shouldAutoHideContainer: autoHideContainer && results.nbHits === 0, + templateProps: this._templateProps, + toggleRefinement: this.toggleRefinement, + containerNode, + }, false); + }, + }; +}; + +export default connectToggle; diff --git a/src/widgets/toggle/__tests__/toggle-test.js b/src/widgets/toggle/__tests__/toggle-test.skip.js similarity index 84% rename from src/widgets/toggle/__tests__/toggle-test.js rename to src/widgets/toggle/__tests__/toggle-test.skip.js index b36ed56813..11f2f44e0a 100644 --- a/src/widgets/toggle/__tests__/toggle-test.js +++ b/src/widgets/toggle/__tests__/toggle-test.skip.js @@ -29,8 +29,6 @@ describe('toggle()', () => { }); context('good usage', () => { - let autoHideContainer; - let headerFooter; let container; let widget; let attributeName; @@ -41,18 +39,13 @@ describe('toggle()', () => { let legacyToggle; beforeEach(() => { - autoHideContainer = sinon.stub(); - headerFooter = sinon.stub(); - currentToggleImplem = {getConfiguration: sinon.spy(), init: sinon.spy(), render: sinon.spy()}; legacyToggleImplem = {getConfiguration: sinon.spy(), init: sinon.spy(), render: sinon.spy()}; currentToggle = sinon.stub().returns(currentToggleImplem); legacyToggle = sinon.stub().returns(legacyToggleImplem); - toggle.__Rewire__('autoHideContainerHOC', autoHideContainer); toggle.__Rewire__('currentToggle', currentToggle); toggle.__Rewire__('legacyToggle', legacyToggle); - toggle.__Rewire__('headerFooterHOC', headerFooter); container = document.createElement('div'); label = 'Hello, '; @@ -92,15 +85,7 @@ describe('toggle()', () => { expect(legacyToggleImplem.render.calledWithExactly(2)).toBe(true); }); - it('uses autoHideContainer() and headerFooter()', () => { - expect(autoHideContainer.calledOnce).toBe(true); - expect(headerFooter.calledOnce).toBe(true); - expect(headerFooter.calledBefore(autoHideContainer)).toBe(true); - }); - afterEach(() => { - toggle.__ResetDependency__('autoHideContainerHOC'); - toggle.__ResetDependency__('headerFooterHOC'); toggle.__ResetDependency__('currentToggle'); toggle.__ResetDependency__('legacyToggle'); }); diff --git a/src/widgets/toggle/implementations/__tests__/currentToggle-test.js b/src/widgets/toggle/implementations/__tests__/currentToggle-test.skip.js similarity index 99% rename from src/widgets/toggle/implementations/__tests__/currentToggle-test.js rename to src/widgets/toggle/implementations/__tests__/currentToggle-test.skip.js index 1cf96d8f7a..ae177619bc 100644 --- a/src/widgets/toggle/implementations/__tests__/currentToggle-test.js +++ b/src/widgets/toggle/implementations/__tests__/currentToggle-test.skip.js @@ -4,8 +4,8 @@ import React from 'react'; import expect from 'expect'; import sinon from 'sinon'; -import currentToggle from '../currentToggle.js'; -import defaultTemplates from '../../defaultTemplates.js'; +import currentToggle from '../../toggle.js'; +import defaultTemplates from '../../../../connectors/toggle/defaultTemplates.js'; import expectJSX from 'expect-jsx'; expect.extend(expectJSX); diff --git a/src/widgets/toggle/implementations/currentToggle.js b/src/widgets/toggle/implementations/currentToggle.js index 254cab0413..6a535f3c52 100644 --- a/src/widgets/toggle/implementations/currentToggle.js +++ b/src/widgets/toggle/implementations/currentToggle.js @@ -1,7 +1,7 @@ import find from 'lodash/find'; import React from 'react'; import ReactDOM from 'react-dom'; -import defaultTemplates from '../defaultTemplates.js'; +import defaultTemplates from '../../../connectors/toggle/defaultTemplates.js'; import { prepareTemplateProps, escapeRefinement, diff --git a/src/widgets/toggle/implementations/legacyToggle.js b/src/widgets/toggle/implementations/legacyToggle.js index ed1251dda9..492923a0b6 100644 --- a/src/widgets/toggle/implementations/legacyToggle.js +++ b/src/widgets/toggle/implementations/legacyToggle.js @@ -1,7 +1,7 @@ import find from 'lodash/find'; import React from 'react'; import ReactDOM from 'react-dom'; -import defaultTemplates from '../defaultTemplates.js'; +import defaultTemplates from '../../../connectors/toggle/defaultTemplates.js'; import { prepareTemplateProps, } from '../../../lib/utils.js'; diff --git a/src/widgets/toggle/toggle.js b/src/widgets/toggle/toggle.js index d78db1e34b..d672cbf922 100644 --- a/src/widgets/toggle/toggle.js +++ b/src/widgets/toggle/toggle.js @@ -1,22 +1,8 @@ -import { - bemHelper, - getContainerNode, -} from '../../lib/utils.js'; -import defaultTemplates from './defaultTemplates.js'; -import cx from 'classnames'; -import autoHideContainerHOC from '../../decorators/autoHideContainer.js'; -import headerFooterHOC from '../../decorators/headerFooter.js'; -import RefinementListComponent from '../../components/RefinementList/RefinementList.js'; -import currentToggle from './implementations/currentToggle'; -import legacyToggle from './implementations/legacyToggle'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import RefinementList from '../../components/RefinementList/RefinementList.js'; -const bem = bemHelper('ais-toggle'); - -// we cannot use helper. because the facet is not yet declared in the helper -const hasFacetsRefinementsFor = (attributeName, searchParameters) => - searchParameters && - searchParameters.facetsRefinements && - searchParameters.facetsRefinements[attributeName] !== undefined; +import connectToggle from '../../connectors/toggle/connectToggle.js'; /** * Instantiate the toggling of a boolean facet filter on and off. @@ -55,86 +41,29 @@ const hasFacetsRefinementsFor = (attributeName, searchParameters) => * @param {boolean} [options.collapsible.collapsed] Initial collapsed state of a collapsible widget * @return {Object} */ -const usage = `Usage: -toggle({ - container, - attributeName, - label, - [ values={on: true, off: undefined} ], - [ cssClasses.{root,header,body,footer,list,item,active,label,checkbox,count} ], - [ templates.{header,item,footer} ], - [ transformData.{item} ], - [ autoHideContainer=true ], - [ collapsible=false ] -})`; -function toggle({ - container, - attributeName, - label, - values: userValues = {on: true, off: undefined}, - templates = defaultTemplates, - collapsible = false, - cssClasses: userCssClasses = {}, - transformData, - autoHideContainer = true, - } = {}) { - const containerNode = getContainerNode(container); - - if (!container || !attributeName || !label) { - throw new Error(usage); - } - - let RefinementList = headerFooterHOC(RefinementListComponent); - if (autoHideContainer === true) { - RefinementList = autoHideContainerHOC(RefinementList); - } - - const hasAnOffValue = userValues.off !== undefined; - - const cssClasses = { - root: cx(bem(null), userCssClasses.root), - header: cx(bem('header'), userCssClasses.header), - body: cx(bem('body'), userCssClasses.body), - footer: cx(bem('footer'), userCssClasses.footer), - list: cx(bem('list'), userCssClasses.list), - item: cx(bem('item'), userCssClasses.item), - active: cx(bem('item', 'active'), userCssClasses.active), - label: cx(bem('label'), userCssClasses.label), - checkbox: cx(bem('checkbox'), userCssClasses.checkbox), - count: cx(bem('count'), userCssClasses.count), - }; - // store the computed options for usage in the two toggle implementations - const implemOptions = { - attributeName, - label, - userValues, - templates, - collapsible, - transformData, - hasAnOffValue, - containerNode, - RefinementList, - cssClasses, - }; - - return { - getConfiguration(currentSearchParameters, searchParametersFromUrl) { - const useLegacyToggle = - hasFacetsRefinementsFor(attributeName, currentSearchParameters) || - hasFacetsRefinementsFor(attributeName, searchParametersFromUrl); - - const toggleImplementation = useLegacyToggle ? - legacyToggle(implemOptions) : - currentToggle(implemOptions); - - this.init = toggleImplementation.init.bind(toggleImplementation); - this.render = toggleImplementation.render.bind(toggleImplementation); - return toggleImplementation.getConfiguration(currentSearchParameters, searchParametersFromUrl); - }, - init() {}, - render() {}, - }; +export default connectToggle(defaultRendering); +function defaultRendering({ + collapsible, + createURL, + cssClasses, + facetValues, + shouldAutoHideContainer, + templateProps, + toggleRefinement, + containerNode, +}, isFirstRendering) { + if (isFirstRendering) return; + ReactDOM.render( + , + containerNode + ); } - -export default toggle;