diff --git a/docs-ui/components/collapsible.stories.js b/docs-ui/components/collapsible.stories.js new file mode 100644 index 00000000000000..7152098d73e795 --- /dev/null +++ b/docs-ui/components/collapsible.stories.js @@ -0,0 +1,41 @@ +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={({onExpand, 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..71e041db76dcf5 --- /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 = { + onCollapse: () => void; +}; + +type ExpandButtonRenderProps = { + onExpand: () => 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, + }; + + handleCollapseToggle = () => { + this.setState(prevState => ({ + collapsed: !prevState.collapsed, + })); + }; + + renderCollapseButton() { + const {collapseButton} = this.props; + + if (typeof collapseButton === 'function') { + return collapseButton({onCollapse: this.handleCollapseToggle}); + } + + return ( + + ); + } + + renderExpandButton(numberOfCollapsedItems: number) { + const {expandButton} = this.props; + + if (typeof expandButton === 'function') { + return expandButton({ + onExpand: this.handleCollapseToggle, + 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} + + {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..c294e143f229c6 --- /dev/null +++ b/tests/js/spec/components/collapsible.spec.jsx @@ -0,0 +1,83 @@ +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[aria-label="Show 2 collapsed items"]').text()).toBe( + 'Show 2 collapsed items' + ); + expect(wrapper.find('button[aria-label="Collapse"]').exists()).toBeFalsy(); + }); + + it('expands items', function () { + const wrapper = mountWithTheme({items}); + + // expand + wrapper.find('button[aria-label="Show 2 collapsed items"]').simulate('click'); + + expect(wrapper.find('div').length).toBe(7); + + // collapse back + wrapper.find('button[aria-label="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').exists()).toBeFalsy(); + }); + + it('takes custom buttons', function () { + const wrapper = mountWithTheme( + ( + + )} + expandButton={({onExpand, numberOfCollapsedItems}) => ( + + )} + > + {items} + + ); + + expect(wrapper.find('button').length).toBe(1); + + // custom expand + wrapper.find('button[aria-label="Expand"]').simulate('click'); + + expect(wrapper.find('div').length).toBe(7); + + // custom collapse back + wrapper.find('button[aria-label="Custom Collapse"]').simulate('click'); + + expect(wrapper.find('div').length).toBe(5); + }); +});