Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions docs-ui/components/collapsible.stories.js
Original file line number Diff line number Diff line change
@@ -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 (
<Collapsible maxVisibleItems={number('Max visible items', 5)}>
{[1, 2, 3, 4, 5, 6, 7].map(item => (
<div key={item}>Item {item}</div>
))}
</Collapsible>
);
});

export const CustomButtons = () => {
return (
<Collapsible
maxVisibleItems={number('Max visible items', 5)}
collapseButton={({onCollapse}) => <Button onClick={onCollapse}>Collapse</Button>}
expandButton={({onExpand, numberOfCollapsedItems}) => (
<Button onClick={onExpand}>
{tn('Expand %s item', 'Expand %s items', numberOfCollapsedItems)}
</Button>
)}
>
{[1, 2, 3, 4, 5, 6, 7].map(item => (
<div key={item}>Item {item}</div>
))}
</Collapsible>
);
};
96 changes: 96 additions & 0 deletions src/sentry/static/sentry/app/components/collapsible.tsx
Original file line number Diff line number Diff line change
@@ -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<Props, State> {
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 (
<Button priority="link" onClick={this.handleCollapseToggle}>
{t('Collapse')}
</Button>
);
}

renderExpandButton(numberOfCollapsedItems: number) {
const {expandButton} = this.props;

if (typeof expandButton === 'function') {
return expandButton({
onExpand: this.handleCollapseToggle,
numberOfCollapsedItems,
});
}

return (
<Button priority="link" onClick={this.handleCollapseToggle}>
{tn('Show %s collapsed item', 'Show %s collapsed items', numberOfCollapsedItems)}
</Button>
);
}

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 (
<React.Fragment>
{itemsToRender}

{numberOfCollapsedItems > 0 && this.renderExpandButton(numberOfCollapsedItems)}

{numberOfCollapsedItems === 0 && canExpand && this.renderCollapseButton()}
</React.Fragment>
);
}
}

export default Collapsible;
83 changes: 83 additions & 0 deletions tests/js/spec/components/collapsible.spec.jsx
Original file line number Diff line number Diff line change
@@ -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 => <div key={i}>Item {i}</div>);

describe('Collapsible', function () {
it('collapses items', function () {
const wrapper = mountWithTheme(<Collapsible>{items}</Collapsible>);

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(<Collapsible>{items}</Collapsible>);

// 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(
<Collapsible maxVisibleItems={2}>{items}</Collapsible>
);

expect(wrapper.find('div').length).toBe(2);
});

it('does not collapse items below threshold', function () {
const wrapper = mountWithTheme(
<Collapsible maxVisibleItems={100}>{items}</Collapsible>
);

expect(wrapper.find('div').length).toBe(7);

expect(wrapper.find('button').exists()).toBeFalsy();
});

it('takes custom buttons', function () {
const wrapper = mountWithTheme(
<Collapsible
collapseButton={({onCollapse}) => (
<Button onClick={onCollapse}>Custom Collapse</Button>
)}
expandButton={({onExpand, numberOfCollapsedItems}) => (
<Button onClick={onExpand} aria-label="Expand">
Custom Expand {numberOfCollapsedItems}
</Button>
)}
>
{items}
</Collapsible>
);

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);
});
});