From e7ec1c6ef80bafa08a0d06360ed807828a09416e Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Tue, 7 May 2019 09:51:50 -0400 Subject: [PATCH] convert OrganizationList to use PaginatedDataList (#192) * convert Org list to use PaginatedDataList * add ToolbarAddButton, ToolbarDeleteButton * pass full org into OrganizationListItem --- __tests__/components/DataListToolbar.test.jsx | 45 +-- .../DeleteToolbarButton.test.jsx | 76 ++++ .../PaginatedDataList.test.jsx | 6 +- .../ToolbarAddButton.test.jsx | 25 ++ .../DeleteToolbarButton.test.jsx.snap | 168 +++++++++ .../components/OrganizationListItem.test.jsx | 14 +- .../OrganizationNotifications.test.jsx.snap | 30 +- .../screens/OrganizationsList.test.jsx | 110 ++---- .../DataListToolbar/DataListToolbar.jsx | 92 +---- src/components/DataListToolbar/styles.scss | 18 +- .../PaginatedDataList/PaginatedDataList.jsx | 21 +- .../PaginatedDataList/ToolbarAddButton.jsx | 53 +++ .../PaginatedDataList/ToolbarDeleteButton.jsx | 162 ++++++++ src/components/PaginatedDataList/index.js | 2 + .../components/OrganizationListItem.jsx | 26 +- .../Organization/OrganizationAccess.jsx | 23 +- .../Organization/OrganizationTeams.jsx | 2 +- .../screens/OrganizationsList.jsx | 345 +++++------------- 18 files changed, 693 insertions(+), 525 deletions(-) create mode 100644 __tests__/components/PaginatedDataList/DeleteToolbarButton.test.jsx rename __tests__/components/{ => PaginatedDataList}/PaginatedDataList.test.jsx (93%) create mode 100644 __tests__/components/PaginatedDataList/ToolbarAddButton.test.jsx create mode 100644 __tests__/components/PaginatedDataList/__snapshots__/DeleteToolbarButton.test.jsx.snap create mode 100644 src/components/PaginatedDataList/ToolbarAddButton.jsx create mode 100644 src/components/PaginatedDataList/ToolbarDeleteButton.jsx diff --git a/__tests__/components/DataListToolbar.test.jsx b/__tests__/components/DataListToolbar.test.jsx index 51dabba0db..8bf673d693 100644 --- a/__tests__/components/DataListToolbar.test.jsx +++ b/__tests__/components/DataListToolbar.test.jsx @@ -179,47 +179,24 @@ describe('', () => { expect(upAlphaIcon.length).toBe(1); }); - test('trash can button triggers correct function', () => { + test('should render additionalControls', () => { const columns = [{ name: 'Name', key: 'name', isSortable: true }]; - const onOpenDeleteModal = jest.fn(); - const openDeleteModalButton = 'button[aria-label="Delete"]'; + const onSearch = jest.fn(); + const onSort = jest.fn(); + const onSelectAll = jest.fn(); toolbar = mountWithContexts( - ); - toolbar.find(openDeleteModalButton).simulate('click'); - expect(onOpenDeleteModal).toHaveBeenCalled(); - }); - - test('Tooltip says "Select a row to delete" when trash can icon is disabled', () => { - toolbar = mountWithContexts( - click]} /> ); - const toolTip = toolbar.find('.pf-c-tooltip__content'); - toolTip.simulate('mouseover'); - expect(toolTip.text()).toBe('Select a row to delete'); - }); - - test('Delete Org tooltip says "Delete" when trash can icon is enabled', () => { - toolbar = mountWithContexts( - - ); - const toolTip = toolbar.find('.pf-c-tooltip__content'); - toolTip.simulate('mouseover'); - expect(toolTip.text()).toBe('Delete'); + const button = toolbar.find('#test'); + expect(button).toHaveLength(1); + expect(button.text()).toEqual('click'); }); }); diff --git a/__tests__/components/PaginatedDataList/DeleteToolbarButton.test.jsx b/__tests__/components/PaginatedDataList/DeleteToolbarButton.test.jsx new file mode 100644 index 0000000000..1a512de332 --- /dev/null +++ b/__tests__/components/PaginatedDataList/DeleteToolbarButton.test.jsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { mountWithContexts } from '../../enzymeHelpers'; +import { ToolbarDeleteButton } from '../../../src/components/PaginatedDataList'; + +const itemA = { + id: 1, + name: 'Foo', + summary_fields: { user_capabilities: { delete: true } }, +}; +const itemB = { + id: 1, + name: 'Foo', + summary_fields: { user_capabilities: { delete: false } }, +}; + +describe('', () => { + test('should render button', () => { + const wrapper = mountWithContexts( + {}} + itemsToDelete={[]} + /> + ); + expect(wrapper.find('button')).toHaveLength(1); + expect(wrapper.find('ToolbarDeleteButton')).toMatchSnapshot(); + }); + + test('should open confirmation modal', () => { + const wrapper = mountWithContexts( + {}} + itemsToDelete={[itemA]} + /> + ); + wrapper.find('button').simulate('click'); + expect(wrapper.find('ToolbarDeleteButton').state('isModalOpen')) + .toBe(true); + wrapper.update(); + expect(wrapper.find('Modal')).toHaveLength(1); + }); + + test('should invoke onDelete prop', () => { + const onDelete = jest.fn(); + const wrapper = mountWithContexts( + + ); + wrapper.find('ToolbarDeleteButton').setState({ isModalOpen: true }); + wrapper.find('button.pf-m-danger').simulate('click'); + expect(onDelete).toHaveBeenCalled(); + expect(wrapper.find('ToolbarDeleteButton').state('isModalOpen')).toBe(false); + }); + + test('should disable button when no delete permissions', () => { + const wrapper = mountWithContexts( + {}} + itemsToDelete={[itemB]} + /> + ); + expect(wrapper.find('button[disabled]')).toHaveLength(1); + }); + + test('should render tooltip', () => { + const wrapper = mountWithContexts( + {}} + itemsToDelete={[itemA]} + /> + ); + expect(wrapper.find('Tooltip')).toHaveLength(1); + expect(wrapper.find('Tooltip').prop('content')).toEqual('Delete'); + }); +}); diff --git a/__tests__/components/PaginatedDataList.test.jsx b/__tests__/components/PaginatedDataList/PaginatedDataList.test.jsx similarity index 93% rename from __tests__/components/PaginatedDataList.test.jsx rename to __tests__/components/PaginatedDataList/PaginatedDataList.test.jsx index 2196ae512b..f148aa3ac0 100644 --- a/__tests__/components/PaginatedDataList.test.jsx +++ b/__tests__/components/PaginatedDataList/PaginatedDataList.test.jsx @@ -1,8 +1,8 @@ import React from 'react'; import { createMemoryHistory } from 'history'; -import { mountWithContexts } from '../enzymeHelpers'; -import { sleep } from '../testUtils'; -import PaginatedDataList from '../../src/components/PaginatedDataList'; +import { mountWithContexts } from '../../enzymeHelpers'; +import { sleep } from '../../testUtils'; +import PaginatedDataList from '../../../src/components/PaginatedDataList'; const mockData = [ { id: 1, name: 'one', url: '/org/team/1' }, diff --git a/__tests__/components/PaginatedDataList/ToolbarAddButton.test.jsx b/__tests__/components/PaginatedDataList/ToolbarAddButton.test.jsx new file mode 100644 index 0000000000..9178584067 --- /dev/null +++ b/__tests__/components/PaginatedDataList/ToolbarAddButton.test.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { mountWithContexts } from '../../enzymeHelpers'; +import { ToolbarAddButton } from '../../../src/components/PaginatedDataList'; + +describe('', () => { + test('should render button', () => { + const onClick = jest.fn(); + const wrapper = mountWithContexts( + + ); + const button = wrapper.find('button'); + expect(button).toHaveLength(1); + button.simulate('click'); + expect(onClick).toHaveBeenCalled(); + }); + + test('should render link', () => { + const wrapper = mountWithContexts( + + ); + const link = wrapper.find('Link'); + expect(link).toHaveLength(1); + expect(link.prop('to')).toBe('/foo'); + }); +}); diff --git a/__tests__/components/PaginatedDataList/__snapshots__/DeleteToolbarButton.test.jsx.snap b/__tests__/components/PaginatedDataList/__snapshots__/DeleteToolbarButton.test.jsx.snap new file mode 100644 index 0000000000..3a181290d8 --- /dev/null +++ b/__tests__/components/PaginatedDataList/__snapshots__/DeleteToolbarButton.test.jsx.snap @@ -0,0 +1,168 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render button 1`] = ` + + + + + + + Select a row to delete + + + } + delay={ + Array [ + 500, + 500, + ] + } + distance={15} + flip={true} + lazy={true} + maxWidth="18.75rem" + onCreate={[Function]} + performance={true} + placement="left" + popperOptions={ + Object { + "modifiers": Object { + "hide": Object { + "enabled": true, + }, + "preventOverflow": Object { + "enabled": true, + }, + }, + } + } + theme="pf-tippy" + trigger="mouseenter focus" + zIndex={9999} + > + + + + + } + > +
+ +
+ + +
+ Select a row to delete +
+
+
+ + + + + +`; diff --git a/__tests__/pages/Organizations/components/OrganizationListItem.test.jsx b/__tests__/pages/Organizations/components/OrganizationListItem.test.jsx index 210979646b..e82e1ae9dd 100644 --- a/__tests__/pages/Organizations/components/OrganizationListItem.test.jsx +++ b/__tests__/pages/Organizations/components/OrganizationListItem.test.jsx @@ -9,7 +9,19 @@ describe('', () => { mountWithContexts( - + {}} + /> ); diff --git a/__tests__/pages/Organizations/screens/Organization/__snapshots__/OrganizationNotifications.test.jsx.snap b/__tests__/pages/Organizations/screens/Organization/__snapshots__/OrganizationNotifications.test.jsx.snap index 560cc21984..e7d882aac1 100644 --- a/__tests__/pages/Organizations/screens/Organization/__snapshots__/OrganizationNotifications.test.jsx.snap +++ b/__tests__/pages/Organizations/screens/Organization/__snapshots__/OrganizationNotifications.test.jsx.snap @@ -109,8 +109,9 @@ exports[` initially renders succesfully 1`] = ` > initially renders succesfully 1`] = ` "url": "", } } + onSelectAll={null} queryParams={ Object { "order_by": "name", @@ -154,6 +156,7 @@ exports[` initially renders succesfully 1`] = ` } } renderItem={[Function]} + showSelectAll={false} toolbarColumns={ Array [ Object { @@ -181,9 +184,7 @@ exports[` initially renders succesfully 1`] = ` withHash={true} > initially renders succesfully 1`] = ` }, ] } - deleteTooltip="Delete" - disableDelete={true} isAllSelected={false} isCompact={false} noLeftMargin={false} onCompact={null} onExpand={null} - onOpenDeleteModal={null} onSearch={[Function]} onSelectAll={null} onSort={[Function]} - showAdd={false} - showDelete={false} showSelectAll={false} sortOrder="ascending" sortedColumnKey="name" @@ -919,20 +915,8 @@ exports[` initially renders succesfully 1`] = `
- -
+ +
diff --git a/__tests__/pages/Organizations/screens/OrganizationsList.test.jsx b/__tests__/pages/Organizations/screens/OrganizationsList.test.jsx index 1262c36db8..3f6b3d74cd 100644 --- a/__tests__/pages/Organizations/screens/OrganizationsList.test.jsx +++ b/__tests__/pages/Organizations/screens/OrganizationsList.test.jsx @@ -5,9 +5,11 @@ import OrganizationsList, { _OrganizationsList } from '../../../../src/pages/Org const mockAPIOrgsList = { data: { + count: 3, results: [{ name: 'Organization 0', id: 1, + url: '/organizations/1', summary_fields: { related_field_counts: { teams: 3, @@ -21,6 +23,7 @@ const mockAPIOrgsList = { { name: 'Organization 1', id: 2, + url: '/organizations/2', summary_fields: { related_field_counts: { teams: 2, @@ -34,6 +37,7 @@ const mockAPIOrgsList = { { name: 'Organization 2', id: 3, + url: '/organizations/3', summary_fields: { related_field_counts: { teams: 5, @@ -50,7 +54,7 @@ const mockAPIOrgsList = { warningMsg: 'message' }; -describe('<_OrganizationsList />', () => { +describe('', () => { let wrapper; let api; @@ -67,75 +71,42 @@ describe('<_OrganizationsList />', () => { ); }); - test('Puts 1 selected Org in state when onSelect is called.', () => { + test('Puts 1 selected Org in state when handleSelect is called.', () => { wrapper = mountWithContexts( ).find('OrganizationsList'); wrapper.setState({ - results: mockAPIOrgsList.data.results + organizations: mockAPIOrgsList.data.results, + itemCount: 3, + isInitialized: true }); wrapper.update(); expect(wrapper.state('selected').length).toBe(0); - wrapper.instance().onSelect(mockAPIOrgsList.data.results.slice(0, 1)); + wrapper.instance().handleSelect(mockAPIOrgsList.data.results.slice(0, 1)); expect(wrapper.state('selected').length).toBe(1); }); - test('Puts all Orgs in state when onSelectAll is called.', () => { - wrapper = mountWithContexts( - - ).find('OrganizationsList'); - wrapper.setState( - mockAPIOrgsList.data - ); - expect(wrapper.state('selected').length).toBe(0); - wrapper.instance().onSelectAll(true); - expect(wrapper.find('OrganizationsList').state().selected.length).toEqual(wrapper.state().results.length); - }); - - test('selected is > 0 when close modal button is clicked.', () => { + test('Puts all Orgs in state when handleSelectAll is called.', () => { wrapper = mountWithContexts( ); - wrapper.find('OrganizationsList').setState({ - results: mockAPIOrgsList.data.results, - isModalOpen: mockAPIOrgsList.isModalOpen, - selected: mockAPIOrgsList.data.results + const list = wrapper.find('OrganizationsList'); + list.setState({ + organizations: mockAPIOrgsList.data.results, + itemCount: 3, + isInitialized: true }); - const component = wrapper.find('OrganizationsList'); - wrapper.find('DataListToolbar').prop('onOpenDeleteModal')(); + expect(list.state('selected').length).toBe(0); + list.instance().handleSelectAll(true); wrapper.update(); - const button = wrapper.find('ModalBoxCloseButton'); - button.prop('onClose')(); - wrapper.update(); - expect(component.state('isModalOpen')).toBe(false); - expect(component.state('selected').length).toBeGreaterThan(0); - wrapper.unmount(); - }); - - test('selected is > 0 when cancel modal button is clicked.', () => { - wrapper = mountWithContexts( - - ); - wrapper.find('OrganizationsList').setState({ - results: mockAPIOrgsList.data.results, - isModalOpen: mockAPIOrgsList.isModalOpen, - selected: mockAPIOrgsList.data.results - }); - const component = wrapper.find('OrganizationsList'); - wrapper.find('DataListToolbar').prop('onOpenDeleteModal')(); - wrapper.update(); - const button = wrapper.find('ModalBoxFooter').find('button').at(1); - button.prop('onClick')(); - wrapper.update(); - expect(component.state('isModalOpen')).toBe(false); - expect(component.state('selected').length).toBeGreaterThan(0); - wrapper.unmount(); + expect(list.state('selected').length) + .toEqual(list.state('organizations').length); }); test('api is called to delete Orgs for each org in selected.', () => { const fetchOrganizations = jest.fn(() => wrapper.find('OrganizationsList').setState({ - results: [] + organizations: [] })); wrapper = mountWithContexts( ', () => { ); const component = wrapper.find('OrganizationsList'); wrapper.find('OrganizationsList').setState({ - results: mockAPIOrgsList.data.results, + organizations: mockAPIOrgsList.data.results, + itemCount: 3, + isInitialized: true, isModalOpen: mockAPIOrgsList.isModalOpen, selected: mockAPIOrgsList.data.results }); - wrapper.find('DataListToolbar').prop('onOpenDeleteModal')(); - wrapper.update(); - const button = wrapper.find('ModalBoxFooter').find('button').at(0); - button.simulate('click'); - wrapper.update(); + wrapper.find('ToolbarDeleteButton').prop('onDelete')(); expect(api.destroyOrganization).toHaveBeenCalledTimes(component.state('selected').length); }); @@ -167,7 +136,9 @@ describe('<_OrganizationsList />', () => { } ); wrapper.find('OrganizationsList').setState({ - results: mockAPIOrgsList.data.results, + organizations: mockAPIOrgsList.data.results, + itemCount: 3, + isInitialized: true, selected: mockAPIOrgsList.data.results.slice(0, 1) }); const component = wrapper.find('OrganizationsList'); @@ -193,27 +164,6 @@ describe('<_OrganizationsList />', () => { expect(history.location.search).toBe('?order_by=modified&page=1&page_size=5'); }); - test('onSort sends the correct information to fetchOrganizations', () => { - const history = createMemoryHistory({ - initialEntries: ['organizations?order_by=name&page=1&page_size=5'], - }); - const fetchOrganizations = jest.spyOn(_OrganizationsList.prototype, 'fetchOrganizations'); - wrapper = mountWithContexts( - , { - context: { - router: { history } - } - } - ); - const component = wrapper.find('OrganizationsList'); - component.instance().onSort('modified', 'ascending'); - expect(fetchOrganizations).toBeCalledWith({ - page: 1, - page_size: 5, - order_by: 'modified' - }); - }); - test('error is thrown when org not successfully deleted from api', async () => { const history = createMemoryHistory({ initialEntries: ['organizations?order_by=name&page=1&page_size=5'], @@ -227,7 +177,9 @@ describe('<_OrganizationsList />', () => { } ); await wrapper.setState({ - results: mockAPIOrgsList.data.results, + organizations: mockAPIOrgsList.data.results, + itemCount: 3, + isInitialized: true, selected: [...mockAPIOrgsList.data.results].push({ name: 'Organization 6', id: 'a', diff --git a/src/components/DataListToolbar/DataListToolbar.jsx b/src/components/DataListToolbar/DataListToolbar.jsx index 1028af9e48..4616370f76 100644 --- a/src/components/DataListToolbar/DataListToolbar.jsx +++ b/src/components/DataListToolbar/DataListToolbar.jsx @@ -1,24 +1,15 @@ import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; -import { I18n, i18nMark } from '@lingui/react'; +import { I18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { - Button, Checkbox, Level, LevelItem, Toolbar, ToolbarGroup, ToolbarItem, - Tooltip, } from '@patternfly/react-core'; -import { - TrashAltIcon, - PlusIcon, -} from '@patternfly/react-icons'; -import { - Link -} from 'react-router-dom'; import ExpandCollapse from '../ExpandCollapse'; import Search from '../Search'; @@ -28,12 +19,8 @@ import VerticalSeparator from '../VerticalSeparator'; class DataListToolbar extends React.Component { render () { const { - add, - addBtnToolTipContent, - addUrl, columns, - deleteTooltip, - disableDelete, + showSelectAll, isAllSelected, isCompact, noLeftMargin, @@ -41,18 +28,12 @@ class DataListToolbar extends React.Component { onSearch, onCompact, onExpand, - onOpenDeleteModal, onSelectAll, - showAdd, - showDelete, - showSelectAll, sortOrder, sortedColumnKey, + additionalControls, } = this.props; - const deleteIconStyling = disableDelete ? 'awx-ToolBarBtn awx-ToolBarBtn--disabled' - : 'awx-ToolBarBtn'; - const showExpandCollapse = (onCompact && onExpand); return ( @@ -96,9 +77,9 @@ class DataListToolbar extends React.Component { /> - { (showExpandCollapse || showDelete || addUrl || add) && ( + { (showExpandCollapse || additionalControls.length) ? ( - )} + ) : null} {showExpandCollapse && ( @@ -108,50 +89,15 @@ class DataListToolbar extends React.Component { onExpand={onExpand} /> - { (showDelete || addUrl || add) && ( + { additionalControls && ( )} )}
- - {showDelete && ( - -
- -
-
- )} - {showAdd && addUrl && ( - - - - - - )} - {showAdd && add && ( - {add} - )} + + {additionalControls}
@@ -162,48 +108,34 @@ class DataListToolbar extends React.Component { } DataListToolbar.propTypes = { - add: PropTypes.node, - addBtnToolTipContent: PropTypes.string, - addUrl: PropTypes.string, columns: PropTypes.arrayOf(PropTypes.object).isRequired, - deleteTooltip: PropTypes.node, - disableDelete: PropTypes.bool, + showSelectAll: PropTypes.bool, isAllSelected: PropTypes.bool, isCompact: PropTypes.bool, noLeftMargin: PropTypes.bool, onCompact: PropTypes.func, onExpand: PropTypes.func, - onOpenDeleteModal: PropTypes.func, onSearch: PropTypes.func, onSelectAll: PropTypes.func, onSort: PropTypes.func, - showAdd: PropTypes.bool, - showDelete: PropTypes.bool, - showSelectAll: PropTypes.bool, sortOrder: PropTypes.string, sortedColumnKey: PropTypes.string, + additionalControls: PropTypes.arrayOf(PropTypes.node), }; DataListToolbar.defaultProps = { - add: null, - addBtnToolTipContent: i18nMark('Add'), - addUrl: null, - deleteTooltip: i18nMark('Delete'), - disableDelete: true, + showSelectAll: false, isAllSelected: false, isCompact: false, noLeftMargin: false, onCompact: null, onExpand: null, - onOpenDeleteModal: null, onSearch: null, onSelectAll: null, onSort: null, - showAdd: false, - showDelete: false, - showSelectAll: false, sortOrder: 'ascending', sortedColumnKey: 'name', + additionalControls: [], }; export default DataListToolbar; diff --git a/src/components/DataListToolbar/styles.scss b/src/components/DataListToolbar/styles.scss index 865ec3af88..8909439caa 100644 --- a/src/components/DataListToolbar/styles.scss +++ b/src/components/DataListToolbar/styles.scss @@ -81,32 +81,34 @@ font-size: 18px; } -.awx-ToolBarBtn{ +.awx-ToolBarBtn { width: 30px; display: flex; justify-content: center; margin-right: 20px; border-radius: 3px; + + &[disabled] { + cursor: not-allowed; + } } -.awx-ToolBarBtn:hover{ +.awx-ToolBarBtn:hover { .awx-ToolBarTrashCanIcon { - color:white; + color: white; } background-color:#d9534f; } -.awx-ToolBarBtn--disabled:hover{ +.awx-ToolBarBtn[disabled]:hover { .awx-ToolBarTrashCanIcon { color: #d2d2d2; } - background-color:white; - cursor: not-allowed; + background-color: white; } -.pf-l-toolbar >div{ +.pf-l-toolbar > div { &:last-child{ display:none; } } - diff --git a/src/components/PaginatedDataList/PaginatedDataList.jsx b/src/components/PaginatedDataList/PaginatedDataList.jsx index 36fd9a2bc6..a90fa6e91e 100644 --- a/src/components/PaginatedDataList/PaginatedDataList.jsx +++ b/src/components/PaginatedDataList/PaginatedDataList.jsx @@ -1,5 +1,5 @@ import React, { Fragment } from 'react'; -import PropTypes, { arrayOf, shape, string, bool } from 'prop-types'; +import PropTypes, { arrayOf, shape, string, bool, node } from 'prop-types'; import { DataList, DataListItem, @@ -99,6 +99,9 @@ class PaginatedDataList extends React.Component { additionalControls, itemName, itemNamePlural, + showSelectAll, + isAllSelected, + onSelectAll, } = this.props; const { error } = this.state; const [orderBy, sortOrder] = this.getSortOrder(); @@ -146,8 +149,10 @@ class PaginatedDataList extends React.Component { columns={toolbarColumns} onSearch={() => { }} onSort={this.handleSort} - showAdd={!!additionalControls} - add={additionalControls} + showSelectAll={showSelectAll} + isAllSelected={isAllSelected} + onSelectAll={onSelectAll} + additionalControls={additionalControls} /> {items.map(item => (renderItem ? renderItem(item) : ( @@ -216,7 +221,10 @@ PaginatedDataList.propTypes = { key: string.isRequired, isSortable: bool, })), - additionalControls: PropTypes.node, + additionalControls: arrayOf(node), + showSelectAll: PropTypes.bool, + isAllSelected: PropTypes.bool, + onSelectAll: PropTypes.func, }; PaginatedDataList.defaultProps = { @@ -224,9 +232,12 @@ PaginatedDataList.defaultProps = { toolbarColumns: [ { name: i18nMark('Name'), key: 'name', isSortable: true }, ], - additionalControls: null, + additionalControls: [], itemName: 'item', itemNamePlural: '', + showSelectAll: false, + isAllSelected: false, + onSelectAll: null, }; export { PaginatedDataList as _PaginatedDataList }; diff --git a/src/components/PaginatedDataList/ToolbarAddButton.jsx b/src/components/PaginatedDataList/ToolbarAddButton.jsx new file mode 100644 index 0000000000..72c5ba1398 --- /dev/null +++ b/src/components/PaginatedDataList/ToolbarAddButton.jsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { string, func } from 'prop-types'; +import { Link } from 'react-router-dom'; +import { Button } from '@patternfly/react-core'; +import { PlusIcon } from '@patternfly/react-icons'; +import { I18n } from '@lingui/react'; +import { t } from '@lingui/macro'; + +function ToolbarAddButton ({ linkTo, onClick }) { + if (!linkTo && !onClick) { + throw new Error('ToolbarAddButton requires either `linkTo` or `onClick` prop'); + } + if (linkTo) { + // TODO: This should only be a (no + + )} +
+ ); + } + return ( + + {({ i18n }) => ( + + )} + + ); +} +ToolbarAddButton.propTypes = { + linkTo: string, + onClick: func, +}; +ToolbarAddButton.defaultProps = { + linkTo: null, + onClick: null +}; + +export default ToolbarAddButton; diff --git a/src/components/PaginatedDataList/ToolbarDeleteButton.jsx b/src/components/PaginatedDataList/ToolbarDeleteButton.jsx new file mode 100644 index 0000000000..7c54057f70 --- /dev/null +++ b/src/components/PaginatedDataList/ToolbarDeleteButton.jsx @@ -0,0 +1,162 @@ +import React, { Fragment } from 'react'; +import { func, bool, number, string, arrayOf, shape } from 'prop-types'; +import { Button, Tooltip } from '@patternfly/react-core'; +import { TrashAltIcon } from '@patternfly/react-icons'; +import { I18n, i18nMark } from '@lingui/react'; +import { Trans, t } from '@lingui/macro'; +import AlertModal from '../AlertModal'; +import { pluralize } from '../../util/strings'; + +const ItemToDelete = shape({ + id: number.isRequired, + name: string.isRequired, + summary_fields: shape({ + user_capabilities: shape({ + delete: bool.isRequired, + }).isRequired, + }).isRequired, +}); + +function cannotDelete (item) { + return !item.summary_fields.user_capabilities.delete; +} + +class ToolbarDeleteButton extends React.Component { + static propTypes = { + onDelete: func.isRequired, + itemsToDelete: arrayOf(ItemToDelete).isRequired, + itemName: string, + }; + + static defaultProps = { + itemName: 'item', + }; + + constructor (props) { + super(props); + + this.state = { + isModalOpen: false, + }; + + this.handleConfirmDelete = this.handleConfirmDelete.bind(this); + this.handleCancelDelete = this.handleCancelDelete.bind(this); + this.handleDelete = this.handleDelete.bind(this); + } + + handleConfirmDelete () { + this.setState({ isModalOpen: true }); + } + + handleCancelDelete () { + this.setState({ isModalOpen: false, }); + } + + handleDelete () { + const { onDelete } = this.props; + onDelete(); + this.setState({ isModalOpen: false }); + } + + renderTooltip () { + const { itemsToDelete, itemName } = this.props; + if (itemsToDelete.some(cannotDelete)) { + return ( +
+ + You dont have permission to delete the following + {' '} + {pluralize(itemName)} + : + + {itemsToDelete + .filter(cannotDelete) + .map(item => ( +
+ {item.name} +
+ )) + } +
+ ); + } + if (itemsToDelete.length) { + return i18nMark('Delete'); + } + return i18nMark('Select a row to delete'); + } + + render () { + const { itemsToDelete, itemName } = this.props; + const { isModalOpen } = this.state; + + const isDisabled = itemsToDelete.length === 0 + || itemsToDelete.some(cannotDelete); + + return ( + + {({ i18n }) => ( + + + + + { isModalOpen && ( + + {i18n._(t`Delete`)} + , + + ]} + > + {i18n._(t`Are you sure you want to delete:`)} +
+ {itemsToDelete.map((item) => ( + + + {item.name} + +
+
+ ))} +
+
+ )} +
+ )} +
+ ); + } +} + +export default ToolbarDeleteButton; diff --git a/src/components/PaginatedDataList/index.js b/src/components/PaginatedDataList/index.js index bfa3fd9d6a..55cf1e8134 100644 --- a/src/components/PaginatedDataList/index.js +++ b/src/components/PaginatedDataList/index.js @@ -1,3 +1,5 @@ import PaginatedDataList from './PaginatedDataList'; export default PaginatedDataList; +export { default as ToolbarDeleteButton } from './ToolbarDeleteButton'; +export { default as ToolbarAddButton } from './ToolbarAddButton'; diff --git a/src/pages/Organizations/components/OrganizationListItem.jsx b/src/pages/Organizations/components/OrganizationListItem.jsx index f1e7447fdf..f250f5ce75 100644 --- a/src/pages/Organizations/components/OrganizationListItem.jsx +++ b/src/pages/Organizations/components/OrganizationListItem.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { string, bool, func } from 'prop-types'; import { Trans } from '@lingui/macro'; import { Badge, @@ -13,24 +14,29 @@ import { } from 'react-router-dom'; import VerticalSeparator from '../../../components/VerticalSeparator'; +import { Organization } from '../../../types'; class OrganizationListItem extends React.Component { + static propTypes = { + organization: Organization.isRequired, + detailUrl: string.isRequired, + isSelected: bool.isRequired, + onSelect: func.isRequired + } + render () { const { - itemId, - name, - memberCount, - teamCount, + organization, isSelected, onSelect, detailUrl, } = this.props; - const labelId = `check-action-${itemId}`; + const labelId = `check-action-${organization.id}`; return ( - + - {name} + {organization.name} , @@ -52,13 +58,13 @@ class OrganizationListItem extends React.Component { Members - {memberCount} + {organization.summary_fields.related_field_counts.users} Teams - {teamCount} + {organization.summary_fields.related_field_counts.teams} diff --git a/src/pages/Organizations/screens/Organization/OrganizationAccess.jsx b/src/pages/Organizations/screens/Organization/OrganizationAccess.jsx index 86954098c0..a6e9077610 100644 --- a/src/pages/Organizations/screens/Organization/OrganizationAccess.jsx +++ b/src/pages/Organizations/screens/Organization/OrganizationAccess.jsx @@ -1,10 +1,7 @@ import React, { Fragment } from 'react'; import { withRouter } from 'react-router-dom'; -import { I18n, i18nMark } from '@lingui/react'; -import { t } from '@lingui/macro'; -import { Button } from '@patternfly/react-core'; -import { PlusIcon } from '@patternfly/react-icons'; -import PaginatedDataList from '../../../../components/PaginatedDataList'; +import { i18nMark } from '@lingui/react'; +import PaginatedDataList, { ToolbarAddButton } from '../../../../components/PaginatedDataList'; import OrganizationAccessItem from '../../components/OrganizationAccessItem'; import DeleteRoleConfirmationModal from '../../components/DeleteRoleConfirmationModal'; import AddResourceRole from '../../../../components/AddRole/AddResourceRole'; @@ -184,19 +181,9 @@ class OrganizationAccess extends React.Component { { name: i18nMark('Username'), key: 'username', isSortable: true }, { name: i18nMark('Last Name'), key: 'last_name', isSortable: true }, ]} - additionalControls={canEdit ? ( - - {({ i18n }) => ( - - )} - - ) : null} + additionalControls={canEdit ? [ + + ] : null} renderItem={accessRecord => ( s.id === row.id); - - if (isSelected) { + if (selected.some(s => s.id === row.id)) { this.setState({ selected: selected.filter(s => s.id !== row.id) }); } else { this.setState({ selected: selected.concat(row) }); } } - getQueryParams (overrides = {}) { + getQueryParams () { const { location } = this.props; - const { search } = location; + const searchParams = parseQueryString(location.search.substring(1)); - const searchParams = parseQueryString(search.substring(1)); - - return Object.assign({}, DEFAULT_PARAMS, searchParams, overrides); - } - - handleClearOrgDeleteModal () { - this.setState({ - isModalOpen: false, - }); - } - - handleOpenOrgDeleteModal () { - const { selected } = this.state; - const warningTitle = selected.length > 1 ? i18nMark('Delete Organization') : i18nMark('Delete Organizations'); - const warningMsg = i18nMark('Are you sure you want to delete:'); - this.setState({ - isModalOpen: true, - warningTitle, - warningMsg, - loading: false - }); + return { + ...DEFAULT_QUERY_PARAMS, + ...searchParams, + }; } async handleOrgDelete () { @@ -169,7 +95,6 @@ class OrganizationsList extends Component { try { await Promise.all(selected.map((org) => api.destroyOrganization(org.id))); this.setState({ - isModalOpen: false, selected: [] }); } catch (err) { @@ -192,50 +117,27 @@ class OrganizationsList extends Component { } } - async fetchOrganizations (queryParams) { + async fetchOrganizations () { const { api, handleHttpError } = this.props; - const { page, page_size, order_by } = queryParams; + const params = this.getQueryParams(); - let sortOrder = 'ascending'; - let sortedColumnKey = order_by; - - if (order_by.startsWith('-')) { - sortOrder = 'descending'; - sortedColumnKey = order_by.substring(1); - } - - this.setState({ error: false, loading: true }); + this.setState({ error: false, isLoading: true }); try { - const { data } = await api.getOrganizations(queryParams); + const { data } = await api.getOrganizations(params); const { count, results } = data; - const pageCount = Math.ceil(count / page_size); - const stateToUpdate = { - count, - page, - pageCount, - page_size, - sortOrder, - sortedColumnKey, - results, + itemCount: count, + organizations: results, selected: [], - loading: false + isLoading: false, + isInitialized: true, }; - // This is in place to track whether or not the initial request - // return any results. If it did not, we show the empty state. - // This will become problematic once search is in play because - // the first load may have query params (think bookmarked search) - if (typeof noInitialResults === 'undefined') { - stateToUpdate.noInitialResults = results.length === 0; - } - this.setState(stateToUpdate); - this.updateUrl(queryParams); } catch (err) { - handleHttpError(err) || this.setState({ error: true, loading: false }); + handleHttpError(err) || this.setState({ error: true, isLoading: false }); } } @@ -254,7 +156,7 @@ class OrganizationsList extends Component { } catch (err) { this.setState({ error: true }); } finally { - this.setState({ loading: false }); + this.setState({ isLoading: false }); } } @@ -264,139 +166,56 @@ class OrganizationsList extends Component { } = PageSectionVariants; const { canAdd, - count, + itemCount, error, - loading, - noInitialResults, - page, - pageCount, - page_size, + isLoading, + isInitialized, selected, - sortedColumnKey, - sortOrder, - results, - isModalOpen, - warningTitle, - warningMsg, + organizations, } = this.state; const { match } = this.props; - let deleteToolTipContent; - - if (selected.some(row => !row.summary_fields.user_capabilities.delete)) { - deleteToolTipContent = ( -
- - You dont have permission to delete the following Organizations: - - {selected - .filter(row => !row.summary_fields.user_capabilities.delete) - .map(row => ( -
- {row.name} -
- )) - } -
- ); - } else if (selected.length === 0) { - deleteToolTipContent = i18nMark('Select a row to delete'); - } else { - deleteToolTipContent = i18nMark('Delete'); - } - - const disableDelete = ( - selected.length === 0 - || selected.some(row => !row.summary_fields.user_capabilities.delete) - ); + const isAllSelected = selected.length === organizations.length; return ( - - {({ i18n }) => ( - - - { isModalOpen && ( - {i18n._(t`Delete`)}, - - ]} - > - {warningMsg} -
- {selected.map((org) => ( - - - {org.name} - -
-
- ))} -
-
+ + + {isInitialized && ( + , + canAdd + ? + : null, + ]} + renderItem={(o) => ( + row.id === o.id)} + onSelect={() => this.handleSelect(o)} + /> )} - {noInitialResults && ( - - - - <Trans>No Organizations Found</Trans> - - - Please add an organization to populate this list - - - ) || ( - - -
    - { results.map(o => ( - row.id === o.id)} - onSelect={() => this.onSelect(o)} - /> - ))} -
- - { loading ?
loading...
: '' } - { error ?
error
: '' } -
- )} -
-
- )} -
+ /> + )} + { isLoading ?
loading...
: '' } + { error ?
error
: '' } + + ); } }