From a40518940c9bc124d9a9dc316fbf1abd801c8e5a Mon Sep 17 00:00:00 2001 From: Matej Minar Date: Mon, 14 Dec 2020 17:11:34 +0100 Subject: [PATCH 1/3] feat(ui): Add new Collapsible component --- docs-ui/components/collapsible.stories.js | 43 +++++++++ .../sentry/app/components/collapsible.tsx | 96 +++++++++++++++++++ tests/js/spec/components/collapsible.spec.jsx | 91 ++++++++++++++++++ 3 files changed, 230 insertions(+) create mode 100644 docs-ui/components/collapsible.stories.js create mode 100644 src/sentry/static/sentry/app/components/collapsible.tsx create mode 100644 tests/js/spec/components/collapsible.spec.jsx diff --git a/docs-ui/components/collapsible.stories.js b/docs-ui/components/collapsible.stories.js new file mode 100644 index 00000000000000..dd9417cb39ce92 --- /dev/null +++ b/docs-ui/components/collapsible.stories.js @@ -0,0 +1,43 @@ +import React from 'react'; +import {withInfo} from '@storybook/addon-info'; +import {number} from '@storybook/addon-knobs'; + +import Button from 'app/components/button'; +import Collapsible from 'app/components/collapsible'; +import {tn} from 'app/locale'; + +export default { + title: 'Utilities/Collapsible', +}; + +export const Default = withInfo( + 'This component is used to show first X items and collapse the rest' +)(() => { + return ( + + {[1, 2, 3, 4, 5, 6, 7].map(item => ( +
Item {item}
+ ))} +
+ ); +}); + +export const CustomButtons = () => { + return ( + ( + + )} + expandButton={({handleExpand, numberOfCollapsedItems}) => ( + + )} + > + {[1, 2, 3, 4, 5, 6, 7].map(item => ( +
Item {item}
+ ))} +
+ ); +}; diff --git a/src/sentry/static/sentry/app/components/collapsible.tsx b/src/sentry/static/sentry/app/components/collapsible.tsx new file mode 100644 index 00000000000000..e1161eb98c8551 --- /dev/null +++ b/src/sentry/static/sentry/app/components/collapsible.tsx @@ -0,0 +1,96 @@ +import React from 'react'; + +import Button from 'app/components/button'; +import {t, tn} from 'app/locale'; + +type CollapseButtonRenderProps = { + handleCollapse: () => void; +}; + +type ExpandButtonRenderProps = { + handleExpand: () => void; + numberOfCollapsedItems: number; +}; + +type DefaultProps = { + maxVisibleItems: number; +}; + +type Props = { + collapseButton?: (props: CollapseButtonRenderProps) => React.ReactNode; + expandButton?: (props: ExpandButtonRenderProps) => React.ReactNode; +} & DefaultProps; + +type State = { + collapsed: boolean; +}; + +class Collapsible extends React.Component { + static defaultProps: DefaultProps = { + maxVisibleItems: 5, + }; + + state: State = { + collapsed: true, + }; + + onCollapseToggle = () => { + this.setState(prevState => ({ + collapsed: !prevState.collapsed, + })); + }; + + renderCollapseButton() { + const {collapseButton} = this.props; + + if (typeof collapseButton === 'function') { + return collapseButton({handleCollapse: this.onCollapseToggle}); + } + + return ( + + ); + } + + renderExpandButton(numberOfCollapsedItems: number) { + const {expandButton} = this.props; + + if (typeof expandButton === 'function') { + return expandButton({ + handleExpand: this.onCollapseToggle, + numberOfCollapsedItems, + }); + } + + return ( + + ); + } + + render() { + const {maxVisibleItems, children} = this.props; + const {collapsed} = this.state; + + const items = React.Children.toArray(children); + const canExpand = items.length > maxVisibleItems; + const itemsToRender = + collapsed && canExpand ? items.slice(0, maxVisibleItems) : items; + const numberOfCollapsedItems = items.length - itemsToRender.length; + + return ( + + {itemsToRender.map(item => item)} + + {numberOfCollapsedItems > 0 && this.renderExpandButton(numberOfCollapsedItems)} + + {numberOfCollapsedItems === 0 && canExpand && this.renderCollapseButton()} + + ); + } +} + +export default Collapsible; diff --git a/tests/js/spec/components/collapsible.spec.jsx b/tests/js/spec/components/collapsible.spec.jsx new file mode 100644 index 00000000000000..9716fc35015269 --- /dev/null +++ b/tests/js/spec/components/collapsible.spec.jsx @@ -0,0 +1,91 @@ +import React from 'react'; + +import {mountWithTheme} from 'sentry-test/enzyme'; + +import Button from 'app/components/button'; +import Collapsible from 'app/components/collapsible'; + +const items = [1, 2, 3, 4, 5, 6, 7].map(i =>
Item {i}
); + +describe('Collapsible', function () { + it('collapses items', function () { + const wrapper = mountWithTheme({items}); + + expect(wrapper.find('div').length).toBe(5); + expect(wrapper.find('div').at(2).text()).toBe('Item 3'); + + expect(wrapper.find('Button[data-test-id="expand"]').text()).toBe( + 'Show 2 collapsed items' + ); + expect(wrapper.find('Button[data-test-id="collapse"]').exists()).toBeFalsy(); + }); + + it('expands items', function () { + const wrapper = mountWithTheme({items}); + + // expand + wrapper.find('Button[data-test-id="expand"]').simulate('click'); + + expect(wrapper.find('div').length).toBe(7); + + // collapse back + wrapper.find('Button[data-test-id="collapse"]').simulate('click'); + + expect(wrapper.find('div').length).toBe(5); + }); + + it('respects maxVisibleItems prop', function () { + const wrapper = mountWithTheme( + {items} + ); + + expect(wrapper.find('div').length).toBe(2); + }); + + it('does not collapse items below threshold', function () { + const wrapper = mountWithTheme( + {items} + ); + + expect(wrapper.find('div').length).toBe(7); + + expect(wrapper.find('Button[data-test-id="expand"]').exists()).toBeFalsy(); + expect(wrapper.find('Button[data-test-id="collapse"]').exists()).toBeFalsy(); + }); + + it('takes custom buttons', function () { + const wrapper = mountWithTheme( + ( + + )} + expandButton={({handleExpand, numberOfCollapsedItems}) => ( + + )} + > + {items} + + ); + + expect(wrapper.find('Button[data-test-id="expand"]').exists()).toBeFalsy(); + expect(wrapper.find('Button[data-test-id="collapse"]').exists()).toBeFalsy(); + + expect(wrapper.find('Button[data-test-id="custom-expand"]').text()).toBe( + 'Custom Expand 2' + ); + + // custom expand + wrapper.find('Button[data-test-id="custom-expand"]').simulate('click'); + + expect(wrapper.find('div').length).toBe(7); + + // custom collapse back + wrapper.find('Button[data-test-id="custom-collapse"]').simulate('click'); + + expect(wrapper.find('div').length).toBe(5); + }); +}); From 120bdd53326e70b5e2bcccef86da42f934f38f3d Mon Sep 17 00:00:00 2001 From: Matej Minar Date: Tue, 15 Dec 2020 11:18:24 +0100 Subject: [PATCH 2/3] ref: Rename handlers, use aria-label instead of data-test-id --- docs-ui/components/collapsible.stories.js | 8 ++--- .../sentry/app/components/collapsible.tsx | 14 ++++---- tests/js/spec/components/collapsible.spec.jsx | 32 +++++++------------ 3 files changed, 22 insertions(+), 32 deletions(-) diff --git a/docs-ui/components/collapsible.stories.js b/docs-ui/components/collapsible.stories.js index dd9417cb39ce92..7152098d73e795 100644 --- a/docs-ui/components/collapsible.stories.js +++ b/docs-ui/components/collapsible.stories.js @@ -26,11 +26,9 @@ export const CustomButtons = () => { return ( ( - - )} - expandButton={({handleExpand, numberOfCollapsedItems}) => ( - } + expandButton={({onExpand, numberOfCollapsedItems}) => ( + )} diff --git a/src/sentry/static/sentry/app/components/collapsible.tsx b/src/sentry/static/sentry/app/components/collapsible.tsx index e1161eb98c8551..00c26ec5a2c223 100644 --- a/src/sentry/static/sentry/app/components/collapsible.tsx +++ b/src/sentry/static/sentry/app/components/collapsible.tsx @@ -4,11 +4,11 @@ import Button from 'app/components/button'; import {t, tn} from 'app/locale'; type CollapseButtonRenderProps = { - handleCollapse: () => void; + onCollapse: () => void; }; type ExpandButtonRenderProps = { - handleExpand: () => void; + onExpand: () => void; numberOfCollapsedItems: number; }; @@ -34,7 +34,7 @@ class Collapsible extends React.Component { collapsed: true, }; - onCollapseToggle = () => { + handleCollapseToggle = () => { this.setState(prevState => ({ collapsed: !prevState.collapsed, })); @@ -44,11 +44,11 @@ class Collapsible extends React.Component { const {collapseButton} = this.props; if (typeof collapseButton === 'function') { - return collapseButton({handleCollapse: this.onCollapseToggle}); + return collapseButton({onCollapse: this.handleCollapseToggle}); } return ( - ); @@ -59,13 +59,13 @@ class Collapsible extends React.Component { if (typeof expandButton === 'function') { return expandButton({ - handleExpand: this.onCollapseToggle, + onExpand: this.handleCollapseToggle, numberOfCollapsedItems, }); } return ( - ); diff --git a/tests/js/spec/components/collapsible.spec.jsx b/tests/js/spec/components/collapsible.spec.jsx index 9716fc35015269..c294e143f229c6 100644 --- a/tests/js/spec/components/collapsible.spec.jsx +++ b/tests/js/spec/components/collapsible.spec.jsx @@ -14,22 +14,22 @@ describe('Collapsible', function () { expect(wrapper.find('div').length).toBe(5); expect(wrapper.find('div').at(2).text()).toBe('Item 3'); - expect(wrapper.find('Button[data-test-id="expand"]').text()).toBe( + expect(wrapper.find('button[aria-label="Show 2 collapsed items"]').text()).toBe( 'Show 2 collapsed items' ); - expect(wrapper.find('Button[data-test-id="collapse"]').exists()).toBeFalsy(); + expect(wrapper.find('button[aria-label="Collapse"]').exists()).toBeFalsy(); }); it('expands items', function () { const wrapper = mountWithTheme({items}); // expand - wrapper.find('Button[data-test-id="expand"]').simulate('click'); + wrapper.find('button[aria-label="Show 2 collapsed items"]').simulate('click'); expect(wrapper.find('div').length).toBe(7); // collapse back - wrapper.find('Button[data-test-id="collapse"]').simulate('click'); + wrapper.find('button[aria-label="Collapse"]').simulate('click'); expect(wrapper.find('div').length).toBe(5); }); @@ -49,20 +49,17 @@ describe('Collapsible', function () { expect(wrapper.find('div').length).toBe(7); - expect(wrapper.find('Button[data-test-id="expand"]').exists()).toBeFalsy(); - expect(wrapper.find('Button[data-test-id="collapse"]').exists()).toBeFalsy(); + expect(wrapper.find('button').exists()).toBeFalsy(); }); it('takes custom buttons', function () { const wrapper = mountWithTheme( ( - + collapseButton={({onCollapse}) => ( + )} - expandButton={({handleExpand, numberOfCollapsedItems}) => ( - )} @@ -71,20 +68,15 @@ describe('Collapsible', function () { ); - expect(wrapper.find('Button[data-test-id="expand"]').exists()).toBeFalsy(); - expect(wrapper.find('Button[data-test-id="collapse"]').exists()).toBeFalsy(); - - expect(wrapper.find('Button[data-test-id="custom-expand"]').text()).toBe( - 'Custom Expand 2' - ); + expect(wrapper.find('button').length).toBe(1); // custom expand - wrapper.find('Button[data-test-id="custom-expand"]').simulate('click'); + wrapper.find('button[aria-label="Expand"]').simulate('click'); expect(wrapper.find('div').length).toBe(7); // custom collapse back - wrapper.find('Button[data-test-id="custom-collapse"]').simulate('click'); + wrapper.find('button[aria-label="Custom Collapse"]').simulate('click'); expect(wrapper.find('div').length).toBe(5); }); From b8e18802a59c6188103e60331ba3896daddbab10 Mon Sep 17 00:00:00 2001 From: Matej Minar Date: Tue, 15 Dec 2020 17:21:38 +0100 Subject: [PATCH 3/3] ref: remove map --- src/sentry/static/sentry/app/components/collapsible.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/static/sentry/app/components/collapsible.tsx b/src/sentry/static/sentry/app/components/collapsible.tsx index 00c26ec5a2c223..71e041db76dcf5 100644 --- a/src/sentry/static/sentry/app/components/collapsible.tsx +++ b/src/sentry/static/sentry/app/components/collapsible.tsx @@ -83,7 +83,7 @@ class Collapsible extends React.Component { return ( - {itemsToRender.map(item => item)} + {itemsToRender} {numberOfCollapsedItems > 0 && this.renderExpandButton(numberOfCollapsedItems)}