diff --git a/src/components/PoweredBy/PoweredBy.js b/src/components/PoweredBy/PoweredBy.js deleted file mode 100644 index f881d8396d..0000000000 --- a/src/components/PoweredBy/PoweredBy.js +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react'; - -class PoweredBy extends React.Component { - shouldComponentUpdate() { - return false; - } - - render() { - return ( -
- Search by - Algolia -
- ); - } -} - -PoweredBy.propTypes = { - cssClasses: React.PropTypes.shape({ - root: React.PropTypes.string, - link: React.PropTypes.string - }), - link: React.PropTypes.string.isRequired -}; - -export default PoweredBy; diff --git a/src/components/PoweredBy/__tests__/PoweredBy-test.js b/src/components/PoweredBy/__tests__/PoweredBy-test.js deleted file mode 100644 index 69ae33cb5c..0000000000 --- a/src/components/PoweredBy/__tests__/PoweredBy-test.js +++ /dev/null @@ -1,45 +0,0 @@ -/* eslint-env mocha */ - -import React from 'react'; -import expect from 'expect'; -import TestUtils from 'react-addons-test-utils'; -import PoweredBy from '../PoweredBy'; - -import expectJSX from 'expect-jsx'; -expect.extend(expectJSX); - -describe('PoweredBy', () => { - let renderer; - - beforeEach(() => { - let {createRenderer} = TestUtils; - renderer = createRenderer(); - }); - - it('should render ', () => { - let out = render({ - cssClasses: { - root: 'pb-root', - link: 'pb-link' - }, - link: 'www.google.com' - }); - expect(out).toEqualJSX( -
- Search by - Algolia -
); - }); - - function render(extraProps = {}) { - renderer.render(); - return renderer.getRenderOutput(); - } - - function getProps(extraProps = {}) { - return { - cssClasses: {}, - ...extraProps - }; - } -}); diff --git a/src/widgets/search-box/__tests__/search-box-test.js b/src/widgets/search-box/__tests__/search-box-test.js index 78abe75059..d0bf59b255 100644 --- a/src/widgets/search-box/__tests__/search-box-test.js +++ b/src/widgets/search-box/__tests__/search-box-test.js @@ -182,25 +182,205 @@ describe('searchBox()', () => { }); }); - context('adds a PoweredBy', () => { + context('poweredBy', () => { + let defaultInitOptions; + let defaultWidgetOptions; + let $; + beforeEach(() => { container = document.createElement('div'); + $ = container.querySelectorAll.bind(container); + defaultWidgetOptions = {container}; + defaultInitOptions = {state, helper, onHistoryChange}; }); - it('do not add the poweredBy if not specified', () => { - widget = searchBox({container}); - widget.init({state, helper, onHistoryChange}); - expect(container.querySelector('.ais-search-box--powered-by')).toBe(null); + it('should not add the element with default options', () => { + // Given + widget = searchBox(defaultWidgetOptions); + + // When + widget.init(defaultInitOptions); + + // Then + expect($('.ais-search-box--powered-by').length).toEqual(0); }); - it('adds the poweredBy if specified', () => { - widget = searchBox({container, poweredBy: true}); - widget.init({state, helper, onHistoryChange}); - const poweredBy = container.querySelector('.ais-search-box--powered-by'); - const poweredByLink = poweredBy.querySelector('a'); - const expectedLink = `https://www.algolia.com/?utm_source=instantsearch.js&utm_medium=website&utm_content=${location.hostname}&utm_campaign=poweredby`; - expect(poweredBy).toNotBe(null); - expect(poweredByLink.getAttribute('href')).toBe(expectedLink); + it('should not add the element with poweredBy: false', () => { + // Given + widget = searchBox({ + ...defaultWidgetOptions, + poweredBy: false + }); + + // When + widget.init(defaultInitOptions); + + // Then + expect($('.ais-search-box--powered-by').length).toEqual(0); + }); + + it('should add the element with poweredBy: true', () => { + // Given + widget = searchBox({ + ...defaultWidgetOptions, + poweredBy: true + }); + + // When + widget.init(defaultInitOptions); + + // Then + expect($('.ais-search-box--powered-by').length).toEqual(1); + }); + + it('should contain a link to Algolia with poweredBy: true', () => { + // Given + widget = searchBox({ + ...defaultWidgetOptions, + poweredBy: true + }); + + // When + widget.init(defaultInitOptions); + + // Then + let actual = $('.ais-search-box--powered-by-link'); + let url = `https://www.algolia.com/?utm_source=instantsearch.js&utm_medium=website&utm_content=${location.hostname}&utm_campaign=poweredby`; + expect(actual.length).toEqual(1); + expect(actual[0].tagName).toEqual('A'); + expect(actual[0].innerHTML).toEqual('Algolia'); + expect(actual[0].getAttribute('href')).toEqual(url); + }); + + it('should let user add its own CSS classes with poweredBy.cssClasses', () => { + // Given + widget = searchBox({ + ...defaultWidgetOptions, + poweredBy: { + cssClasses: { + root: 'myroot', + link: 'mylink' + } + } + }); + + // When + widget.init(defaultInitOptions); + + // Then + let root = $('.myroot'); + let link = $('.mylink'); + expect(root.length).toEqual(1); + expect(link.length).toEqual(1); + expect(link[0].tagName).toEqual('A'); + expect(link[0].innerHTML).toEqual('Algolia'); + }); + + it('should still apply default CSS classes even if user provides its own', () => { + // Given + widget = searchBox({ + ...defaultWidgetOptions, + poweredBy: { + cssClasses: { + root: 'myroot', + link: 'mylink' + } + } + }); + + // When + widget.init(defaultInitOptions); + + // Then + let root = $('.ais-search-box--powered-by'); + let link = $('.ais-search-box--powered-by-link'); + expect(root.length).toEqual(1); + expect(link.length).toEqual(1); + }); + + it('should let the user define its own string template', () => { + // Given + widget = searchBox({ + ...defaultWidgetOptions, + poweredBy: { + template: '
Foobar
' + } + }); + + // When + widget.init(defaultInitOptions); + + // Then + expect(container.innerHTML).toContain('Foobar'); + }); + + it('should let the user define its own Hogan template', () => { + // Given + widget = searchBox({ + ...defaultWidgetOptions, + poweredBy: { + template: '
Foobar--{{url}}
' + } + }); + + // When + widget.init(defaultInitOptions); + + // Then + expect(container.innerHTML).toContain('Foobar--https://www.algolia.com/'); + }); + + it('should let the user define its own function template', () => { + // Given + widget = searchBox({ + ...defaultWidgetOptions, + poweredBy: { + template: (data) => { + return `
Foobar--${data.url}
`; + } + } + }); + + // When + widget.init(defaultInitOptions); + + // Then + expect(container.innerHTML).toContain('Foobar--https://www.algolia.com/'); + }); + + it('should gracefully handle templates with leading spaces', () => { + // Given + widget = searchBox({ + ...defaultWidgetOptions, + poweredBy: { + template: ` + +
Foobar
` + } + }); + + // When + widget.init(defaultInitOptions); + + // Then + expect(container.innerHTML).toContain('Foobar'); + }); + + it('should handle templates not wrapped in a node', () => { + // Given + widget = searchBox({ + ...defaultWidgetOptions, + poweredBy: { + template: 'Foobar ' + } + }); + + // When + widget.init(defaultInitOptions); + + // Then + expect(container.innerHTML).toContain('Foobar'); + expect($('.should-be-found').length).toEqual(1); }); }); @@ -363,7 +543,6 @@ describe('searchBox()', () => { expect(container.value).toBe('iphone'); }); - it('handles external updates', () => { container = document.body.appendChild(document.createElement('input')); container.value = 'initial'; diff --git a/src/widgets/search-box/defaultTemplates.js b/src/widgets/search-box/defaultTemplates.js new file mode 100644 index 0000000000..bcbe288535 --- /dev/null +++ b/src/widgets/search-box/defaultTemplates.js @@ -0,0 +1,7 @@ +export default { + poweredBy: ` +
+ Search by + Algolia +
` +}; diff --git a/src/widgets/search-box/search-box.js b/src/widgets/search-box/search-box.js index 705ec69153..f82ebfdf17 100644 --- a/src/widgets/search-box/search-box.js +++ b/src/widgets/search-box/search-box.js @@ -1,12 +1,13 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; import { bemHelper, getContainerNode } from '../../lib/utils.js'; import forEach from 'lodash/collection/forEach'; +import isString from 'lodash/lang/isString'; +import isFunction from 'lodash/lang/isFunction'; import cx from 'classnames'; -import PoweredBy from '../../components/PoweredBy/PoweredBy.js'; +import Hogan from 'hogan.js'; +import defaultTemplates from './defaultTemplates.js'; let bem = bemHelper('ais-search-box'); const KEY_ENTER = 13; @@ -17,7 +18,11 @@ const KEY_SUPPRESS = 8; * @function searchBox * @param {string|DOMElement} options.container CSS Selector or DOMElement to insert the widget * @param {string} [options.placeholder] Input's placeholder - * @param {boolean} [options.poweredBy=false] Show a powered by Algolia link below the input + * @param {boolean|Object} [options.poweredBy=false] Define if a "powered by Algolia" link should be added near the input + * @param {function|string} [options.poweredBy.template] Template used for displaying the link. Can accept a function or a Hogan string. + * @param {number} [options.poweredBy.cssClasses] CSS classes to add + * @param {string|string[]} [options.poweredBy.cssClasses.root] CSS class to add to the root element + * @param {string|string[]} [options.poweredBy.cssClasses.link] CSS class to add to the link element * @param {boolean} [options.wrapInput=true] Wrap the input in a `div.ais-search-box` * @param {boolean|string} [autofocus='auto'] autofocus on the input * @param {boolean} [options.searchOnEnterKeyPressOnly=false] If set, trigger the search @@ -26,18 +31,18 @@ const KEY_SUPPRESS = 8; * @param {string|string[]} [options.cssClasses.root] CSS class to add to the * wrapping div (if `wrapInput` set to `true`) * @param {string|string[]} [options.cssClasses.input] CSS class to add to the input - * @param {string|string[]} [options.cssClasses.poweredBy] CSS class to add to the poweredBy element * @param {function} [options.queryHook] A function that will be called everytime a new search would be done. You * will get the query as first parameter and a search(query) function to call as the second parameter. * This queryHook can be used to debounce the number of searches done from the searchBox. * @return {Object} */ + const usage = `Usage: searchBox({ container, [ placeholder ], [ cssClasses.{input,poweredBy} ], - [ poweredBy ], + [ poweredBy=false || poweredBy.{template, cssClasses.{root,link}} ], [ wrapInput ], [ autofocus ], [ searchOnEnterKeyPressOnly ], @@ -72,6 +77,11 @@ function searchBox({ autofocus = 'auto'; } + // Convert to object if only set to true + if (poweredBy === true) { + poweredBy = {}; + } + return { getInput: function() { // Returns reference to targeted input if present, or create a new one @@ -113,25 +123,46 @@ function searchBox({ CSSClassesToAdd.forEach(cssClass => input.classList.add(cssClass)); }, addPoweredBy: function(input) { - let poweredByContainer = document.createElement('div'); - input.parentNode.insertBefore(poweredByContainer, input.nextSibling); - let poweredByCssClasses = { - root: cx(bem('powered-by'), cssClasses.poweredBy), - link: bem('powered-by-link') + // Default values + poweredBy = { + cssClasses: {}, + template: defaultTemplates.poweredBy, + ...poweredBy }; - let link = 'https://www.algolia.com/?' + + + let poweredByCSSClasses = { + root: cx(bem('powered-by'), poweredBy.cssClasses.root), + link: cx(bem('powered-by-link'), poweredBy.cssClasses.link) + }; + + let url = 'https://www.algolia.com/?' + 'utm_source=instantsearch.js&' + 'utm_medium=website&' + `utm_content=${location.hostname}&` + 'utm_campaign=poweredby'; - ReactDOM.render( - , - poweredByContainer - ); + let templateData = { + cssClasses: poweredByCSSClasses, + url + }; + + let template = poweredBy.template; + let stringNode; + + if (isString(template)) { + stringNode = Hogan.compile(template).render(templateData); + } + if (isFunction(template)) { + stringNode = template(templateData); + } + + // Crossbrowser way to create a DOM node from a string. We wrap in + // a `span` to make sure we have one and only one node. + let tmpNode = document.createElement('div'); + tmpNode.innerHTML = `${stringNode.trim()}`; + let htmlNode = tmpNode.firstChild; + + input.parentNode.insertBefore(htmlNode, input.nextSibling); }, init: function({state, helper, onHistoryChange}) { let isInputTargeted = container.tagName === 'INPUT';