diff --git a/docs/components/Layout/index.jsx b/docs/components/Layout/index.jsx index b6ffb43b7..df8a95aa2 100644 --- a/docs/components/Layout/index.jsx +++ b/docs/components/Layout/index.jsx @@ -39,6 +39,7 @@ import SvgSymbolExample from '../../examples/SvgSymbolExample'; import SvgSymbolCircleExample from '../../examples/SvgSymbolCircleExample'; import TileGridExample from '../../examples/TileGridExample'; import AccordionExample from '../../examples/AccordionExample'; +import AccordionPanelExample from '../../examples/AccordionPanelExample'; import CarouselExample from '../../examples/CarouselExample'; import ConfirmModalExample from '../../examples/ConfirmModalExample'; import HelpIconPopoverExample from '../../examples/HelpIconPopoverExample'; @@ -227,6 +228,7 @@ class PageLayout extends React.Component { + diff --git a/docs/components/Navigation/index.jsx b/docs/components/Navigation/index.jsx index cd53e1cb1..20ef1efd9 100644 --- a/docs/components/Navigation/index.jsx +++ b/docs/components/Navigation/index.jsx @@ -14,35 +14,29 @@ const contentFactory = navigateTo => componentName => ( ); -const panelFactory = (navigateTo, currentOpenPanel) => (section, sectionName) => ({ +const panelFactory = navigateTo => (section, sectionName) => ({ id: sectionName, title: _.startCase(sectionName), content:
    {_.map(section, contentFactory(navigateTo))}
, - isCollapsed: sectionName !== currentOpenPanel, }); class Navigation extends React.Component { - constructor(props) { - super(props); - this.state = { - currentOpenPanel: initialOpenPanel, - }; - this.togglePanel = this.togglePanel.bind(this); - } - - togglePanel(panelId) { + togglePanel = panelId => { const nextPanel = panelId === this.state.currentOpenPanel ? '' : panelId; this.setState({ currentOpenPanel: nextPanel }); - } + }; render() { - const panels = _.map( - this.props.componentsBySection, - panelFactory(this.props.navigateTo, this.state.currentOpenPanel) - ); + const panels = _.map(this.props.componentsBySection, panelFactory(this.props.navigateTo)); return (
- + + {_.map(panels, panel => ( + + {panel.content} + + ))} +
); } diff --git a/docs/examples/AccordionExample.jsx b/docs/examples/AccordionExample.jsx index c1e1565bf..9114e4ac3 100644 --- a/docs/examples/AccordionExample.jsx +++ b/docs/examples/AccordionExample.jsx @@ -1,67 +1,68 @@ import React from 'react'; -import _ from 'lodash'; -import Immutable from 'seamless-immutable'; import Example from '../components/Example'; -import { Accordion, Checkbox } from '../../src'; - -const initialState = { - accordionPanels: [ - { - id: '1', - icon: { href: './docs/assets/svg-symbols.svg#list' }, - title: 'Filter by region', - isCollapsed: true, - content: ( -
    -
  • - -
  • -
  • - -
  • -
- ), - }, - { - id: '2', - icon: { href: './docs/assets/svg-symbols.svg#list' }, - title: 'Filter by device', - isCollapsed: false, - content: ( -
    -
  • - -
  • -
  • - -
  • -
  • - -
  • -
- ), - }, - ], -}; +import { Accordion, Checkbox, Panel } from '../../src'; class AccordionExample extends React.Component { - constructor() { - super(); - this.state = initialState; - this.toggleAccordionPanel = this.toggleAccordionPanel.bind(this); - } + state = { + clickHistory: [], + }; - toggleAccordionPanel(panelId) { - const nextPanels = Immutable.from(this.state.accordionPanels).asMutable({ - deep: true, + onPanelClick = panelId => { + this.setState({ + clickHistory: [...this.state.clickHistory, `onPanelClick triggered on ${panelId}`], }); - const panelToToggle = _.find(nextPanels, { id: panelId }); - panelToToggle.isCollapsed = !panelToToggle.isCollapsed; - this.setState({ accordionPanels: nextPanels }); - } + }; render() { - return ; + return ( +
+
+ + +
    +
  • + +
  • +
  • + +
  • +
+
+ +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
test
+ + Filter 1 + + + Filter 2 + + + Filter 3 + + + Filter 4 + +
+
+
+ {this.state.clickHistory.map((text, index) => ( +
{text}
+ ))} +
+
+ ); } } @@ -73,7 +74,31 @@ const exampleProps = { areas of the list.

), - exampleCodeSnippet: '', + exampleCodeSnippet: ` + +
    +
  • + +
  • +
  • + +
  • +
+
+ +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
`, propTypeSectionArray: [ { propTypes: [ @@ -83,16 +108,35 @@ const exampleProps = { note: 'render `data-test-selector` onto the component. It can be useful for testing.', }, { - propType: 'panels', - type: ( + propType: 'onPanelClick', + type: 'func', + note: ( - arrayOf Panels +
onPanelClick(panelId)
+
+ takes in a single parameter which is the id of the clicked panel.
), }, { - propType: 'onPanelClick', - type: 'func', + propType: 'defaultActivePanelIds', + type: 'arrayOf(string)', + }, + { + propType: 'maxExpand', + type: "oneOfType(number, string('max'))", + defaultValue: 'max', + note: + 'determine how many Panels can be expanded, accepted value is a positive number, or `max` to have no restriction', + }, + { + propType: 'children', + type: 'node', + note: ( + + Panel(s) to be rendered inside the Accordion. See Accordion.Panels + + ), }, ], }, diff --git a/docs/examples/AccordionPanelExample.jsx b/docs/examples/AccordionPanelExample.jsx new file mode 100644 index 000000000..091d13c32 --- /dev/null +++ b/docs/examples/AccordionPanelExample.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import Example from '../components/Example'; +import { Accordion } from '../../src'; + +class AccordionPanelExample extends React.Component { + state = { + isCollapsed: false, + }; + + onPanelClick = () => this.setState({ isCollapsed: !this.state.isCollapsed }); + + render() { + return ( + + Panel content + + ); + } +} + +const exampleProps = { + componentName: 'Accordion.Panel', + designNotes: ( +

+ See Panel +

+ ), + exampleCodeSnippet: ` + Panel content + `, + propTypeSectionArray: [], +}; + +export default () => ( + + + +); diff --git a/docs/examples/PanelExample.jsx b/docs/examples/PanelExample.jsx index 7c6a7bd7e..2051cff2e 100644 --- a/docs/examples/PanelExample.jsx +++ b/docs/examples/PanelExample.jsx @@ -94,7 +94,6 @@ const exampleProps = { { propType: 'isCollapsed', type: 'boolean', - defaultValue: 'false', note: '', }, { diff --git a/docs/examples/styles.scss b/docs/examples/styles.scss index d00f03d7d..ceed43da8 100644 --- a/docs/examples/styles.scss +++ b/docs/examples/styles.scss @@ -28,7 +28,7 @@ } } - &.accordion-example, + &.accordion-panel-example, &.card-example { .adslot-ui-example { width: 240px; diff --git a/src/components/adslot-ui/Accordion/AccordionPanel/index.jsx b/src/components/adslot-ui/Accordion/AccordionPanel/index.jsx new file mode 100644 index 000000000..8764f2ec9 --- /dev/null +++ b/src/components/adslot-ui/Accordion/AccordionPanel/index.jsx @@ -0,0 +1,7 @@ +import Panel from '../../Panel'; + +class AccordionPanel extends Panel { + static displayName = 'Accordion.Panel'; +} + +export default AccordionPanel; diff --git a/src/components/adslot-ui/Accordion/index.jsx b/src/components/adslot-ui/Accordion/index.jsx index 0f35e4bdb..6688e35ea 100644 --- a/src/components/adslot-ui/Accordion/index.jsx +++ b/src/components/adslot-ui/Accordion/index.jsx @@ -1,35 +1,91 @@ import _ from 'lodash'; import React from 'react'; import PropTypes from 'prop-types'; -import Panel from 'adslot-ui/Panel'; import Card from 'alexandria/Card'; +import AccordionPanel from './AccordionPanel'; -const AccordionComponent = ({ dts, panels, onPanelClick }) => ( - - - {_.map(panels, panel => { - const panelDts = dts ? `panel-${panel.id}` : undefined; - const panelProps = { - key: panel.id, - onClick: onPanelClick, - dts: panelDts, - ..._.omit(panel, ['content', 'onClick']), - }; - - return {panel.content}; - })} - - -); - -const accordionPanelPropTypes = _.pick(Panel.propTypes, ['id', 'icon', 'title', 'isCollapsed']); - -accordionPanelPropTypes.content = PropTypes.node; - -AccordionComponent.propTypes = { - dts: PropTypes.string, - panels: PropTypes.arrayOf(PropTypes.shape(accordionPanelPropTypes)).isRequired, - onPanelClick: PropTypes.func.isRequired, -}; +class AccordionComponent extends React.PureComponent { + static propTypes = { + dts: PropTypes.string, + onPanelClick: PropTypes.func, + children: PropTypes.node, + defaultActivePanelIds: PropTypes.arrayOf(PropTypes.string), + maxExpand: PropTypes.oneOfType([PropTypes.number, PropTypes.oneOf(['max'])]), + }; + + static defaultProps = { + maxExpand: 'max', + }; + + state = { + activePanelIds: + this.props.maxExpand === 'max' + ? this.props.defaultActivePanelIds + : _.slice(this.props.defaultActivePanelIds, 0, this.props.maxExpand), + }; + + onPanelClick = panelId => { + const { maxExpand } = this.props; + + if (_.includes(this.state.activePanelIds, panelId)) { + // remove panelId out of the active list + this.setState({ activePanelIds: _.without(this.state.activePanelIds, panelId) }); + } else { + // drop panels from the beginning if max opened panels count is reached + let newActivePanelIds = [...this.state.activePanelIds, panelId]; + if (maxExpand !== 'max' && newActivePanelIds.length > maxExpand) { + newActivePanelIds = _.drop(newActivePanelIds, newActivePanelIds.length - maxExpand); + } + + this.setState({ activePanelIds: newActivePanelIds }); + } + + if (this.props.onPanelClick) { + this.props.onPanelClick(panelId); + } + }; + + validateProps() { + const { maxExpand } = this.props; + + // validate maxExpand value + switch (true) { + case _.isNumber(maxExpand) && maxExpand <= 0: + case _.isString(maxExpand) && maxExpand !== 'max': + throw new Error("maxExpand must be a positive number or 'max'"); + default: + break; + } + } + + renderPanelFromChildren = child => { + const { id, isCollapsed } = child.props; + + // prevent rendering if child is not an instance of Accordion.Panel + if (child.type.displayName !== AccordionPanel.displayName) { + return null; + } + + // respects child.props.isCollapsed for controlled behaviour + return React.cloneElement(child, { + ...child.props, + onClick: this.onPanelClick, + isCollapsed: _.isNil(isCollapsed) ? !_.includes(this.state.activePanelIds, id) : isCollapsed, + }); + }; + + render() { + const { children, dts } = this.props; + this.validateProps(); + + return ( + + {React.Children.map(children, this.renderPanelFromChildren)} + + ); + } +} + +AccordionComponent.Panel = AccordionPanel; export default AccordionComponent; diff --git a/src/components/adslot-ui/Accordion/index.spec.jsx b/src/components/adslot-ui/Accordion/index.spec.jsx index 135b205a3..6b967598a 100644 --- a/src/components/adslot-ui/Accordion/index.spec.jsx +++ b/src/components/adslot-ui/Accordion/index.spec.jsx @@ -1,29 +1,49 @@ import _ from 'lodash'; import React from 'react'; import sinon from 'sinon'; -import { shallow, mount } from 'enzyme'; -import { Accordion, Panel } from 'adslot-ui'; +import { mount, shallow } from 'enzyme'; +import { Accordion } from 'adslot-ui'; import Card from 'alexandria/Card'; import PanelMocks from 'adslot-ui/Panel/mocks'; describe('AccordionComponent', () => { const { panel1, panel2, panel3 } = PanelMocks; + const makeProps = override => + _.merge( + { + dts: 'my-accordion', + onPanelClick: sinon.stub(), + defaultActivePanelIds: [], + maxExpand: 'max', + }, + override + ); + it('should render with defaults', () => { - const wrapper = shallow(); + const wrapper = shallow( + + {panel1.content} + + ); const cardElement = wrapper.find(Card.Content); expect(cardElement).to.have.length(1); - expect(cardElement.children()).to.have.length(0); + expect(cardElement.children()).to.have.length(1); }); it('should render with props', () => { - const panels = [panel1, panel2, panel3]; - const wrapper = shallow(); + const wrapper = shallow( + + {panel1.content} + {panel2.content} + {panel3.content} + + ); const cardElement = wrapper.find(Card.Content); expect(cardElement).to.have.length(1); - const panelElements = cardElement.find(Panel); + const panelElements = cardElement.find(Accordion.Panel); expect(panelElements).to.have.length(3); const panelElement1 = panelElements.at(0); @@ -41,48 +61,120 @@ describe('AccordionComponent', () => { }); it('should pass onPanelClick down to panels', () => { - const callback = sinon.spy(); - const panels = [panel1, panel2, panel3]; - const wrapper = mount(); - const panelElements = wrapper.find(Panel); + const props = makeProps(); + const wrapper = mount( + + {panel1.content} + {panel2.content} + {panel3.content} + + ); + const panelElements = wrapper.find(Accordion.Panel); panelElements .at(0) - .childAt(0) - .childAt(0) + .find('.panel-component-header') .simulate('click'); panelElements .at(1) - .childAt(0) - .childAt(0) + .find('.panel-component-header') .simulate('click'); panelElements .at(2) - .childAt(0) - .childAt(0) + .find('.panel-component-header') .simulate('click'); - expect(callback.callCount).to.equal(3); - expect(callback.firstCall.calledWith('1')).to.equal(true); - expect(callback.secondCall.calledWith('2')).to.equal(true); - expect(callback.thirdCall.calledWith('3')).to.equal(true); + expect(props.onPanelClick.callCount).to.equal(3); + expect(props.onPanelClick.firstCall.calledWith('1')).to.equal(true); + expect(props.onPanelClick.secondCall.calledWith('2')).to.equal(true); + expect(props.onPanelClick.thirdCall.calledWith('3')).to.equal(true); }); - it('should pass custom props down to panels', () => { - const panels = [panel1, panel2, { ...panel3, className: 'test-class', randomProp: 'random-prop-value' }]; - const wrapper = mount(); + it('should remove active panel id from active list when clicking on an expanded panel', () => { + const wrapper = mount( + + {panel1.content} + {panel2.content} + {panel3.content} + + ); + wrapper + .find(Accordion.Panel) + .at(0) + .find('.panel-component-header') + .simulate('click'); + + expect(wrapper.state()).to.eql({ activePanelIds: [] }); + }); + + it('should replace active panel id when props.maxExpand is less than current opened Panels count', () => { + const wrapper = mount( + + {panel1.content} + {panel2.content} + {panel3.content} + + ); + wrapper + .find(Accordion.Panel) + .at(1) + .find('.panel-component-header') + .simulate('click'); + + expect(wrapper.state()).to.eql({ activePanelIds: ['2'] }); + }); + + it('should respect isCollapsed in Panel children', () => { + const props = makeProps(); + delete props.onPanelClick; + + const wrapper = mount( + + + {panel1.content} + + {panel2.content} + {panel3.content} + + ); + wrapper + .find(Accordion.Panel) + .at(0) + .find('.panel-component-header') + .simulate('click'); expect( wrapper - .find(Panel) - .at(2) - .prop('className') - ).to.equal('test-class'); - expect( - wrapper - .find(Panel) - .at(2) - .prop('randomProp') - ).to.equal('random-prop-value'); + .find(Accordion.Panel) + .at(0) + .prop('isCollapsed') + ).to.equal(true); + }); + + it('should throw error if props.maxExpand has invalid value', () => { + const wrapper = shallow( + + {panel1.content} + + ); + + try { + wrapper.setProps({ maxExpand: -1 }); + wrapper.instance().validateProps(); + throw new Error('should not reach'); + } catch (err) { + expect(err.message).to.equal("maxExpand must be a positive number or 'max'"); + } + }); + + it('should ignore children that are not an instance of Accordion.Panel', () => { + const wrapper = mount( + +
test
+ {panel1.content} +
+ ); + + expect(wrapper.find('.should-not-render')).to.have.length(0); }); }); diff --git a/src/components/adslot-ui/Panel/index.jsx b/src/components/adslot-ui/Panel/index.jsx index c4c7a4f2e..c16cfc240 100644 --- a/src/components/adslot-ui/Panel/index.jsx +++ b/src/components/adslot-ui/Panel/index.jsx @@ -2,38 +2,36 @@ import React from 'react'; import PropTypes from 'prop-types'; import SvgSymbol from 'alexandria/SvgSymbol'; import classnames from 'classnames'; +import './styles.scss'; -require('./styles.scss'); +class PanelComponent extends React.PureComponent { + static propTypes = { + id: PropTypes.string.isRequired, + className: PropTypes.string, + dts: PropTypes.string, + icon: PropTypes.shape(SvgSymbol.propTypes), + title: PropTypes.node.isRequired, + isCollapsed: PropTypes.bool, + onClick: PropTypes.func, + children: PropTypes.node, + }; -const PanelComponent = ({ id, className, dts, icon, title, isCollapsed, onClick, children }) => { - const classesCombined = classnames(['panel-component', className, { collapsed: isCollapsed }]); + onHeaderClick = () => this.props.onClick(this.props.id); - const onHeaderClick = () => onClick(id); + render() { + const { className, children, dts, icon, isCollapsed, title } = this.props; + const classesCombined = classnames(['panel-component', className, { collapsed: isCollapsed }]); - return ( -
-
- {icon ? : null} - {title} + return ( +
+
+ {icon ? : null} + {title} +
+
{children}
-
{children}
-
- ); -}; - -PanelComponent.propTypes = { - id: PropTypes.string.isRequired, - className: PropTypes.string, - dts: PropTypes.string, - icon: PropTypes.shape(SvgSymbol.propTypes), - title: PropTypes.node.isRequired, - isCollapsed: PropTypes.bool, - onClick: PropTypes.func.isRequired, - children: PropTypes.node, -}; - -PanelComponent.defaultProps = { - isCollapsed: false, -}; + ); + } +} export default PanelComponent; diff --git a/src/components/adslot-ui/Panel/mocks.js b/src/components/adslot-ui/Panel/mocks.js index 5b0d8947d..2313608a8 100644 --- a/src/components/adslot-ui/Panel/mocks.js +++ b/src/components/adslot-ui/Panel/mocks.js @@ -4,6 +4,7 @@ import _ from 'lodash'; const panel1 = { id: '1', title: 'Panel 1', + dts: 'panel-1', onClick: _.noop, }; @@ -24,6 +25,7 @@ const panel3 = { className: 'test-class-1 test-class-2', onClick: _.noop, content: 'Panel 3 content', + dts: 'panel-3', }; const PanelMocks = immutable({