diff --git a/__tests__/components/AccessList.test.jsx b/__tests__/components/AccessList.test.jsx new file mode 100644 index 0000000000..d7ab1862fb --- /dev/null +++ b/__tests__/components/AccessList.test.jsx @@ -0,0 +1,160 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { MemoryRouter } from 'react-router-dom'; +import { I18nProvider } from '@lingui/react'; + +import AccessList from '../../src/components/AccessList'; + +const mockResults = [ + { + id: 1, + username: 'boo', + url: '/foo/bar/', + first_name: 'john', + last_name: 'smith', + summary_fields: { + foo: [ + { + role: { + name: 'foo', + id: 2, + } + } + ], + } + } +]; + +describe('', () => { + test('initially renders succesfully', () => { + mount( + + + {}} + /> + + + ); + }); + + test('api response data passed to component gets set to state properly', (done) => { + const wrapper = mount( + + + ({ data: { count: 1, results: mockResults } })} + /> + + + ).find('AccessList'); + + setImmediate(() => { + expect(wrapper.state().results).toEqual(mockResults); + done(); + }); + }); + + test('onExpand and onCompact methods called when user clicks on Expand and Compact icons respectively', async (done) => { + const onExpand = jest.spyOn(AccessList.prototype, 'onExpand'); + const onCompact = jest.spyOn(AccessList.prototype, 'onCompact'); + const wrapper = mount( + + + ({ data: { count: 1, results: mockResults } })} + /> + + + ).find('AccessList'); + expect(onExpand).not.toHaveBeenCalled(); + expect(onCompact).not.toHaveBeenCalled(); + + setImmediate(() => { + const rendered = wrapper.update(); + rendered.find('button[aria-label="Expand"]').simulate('click'); + rendered.find('button[aria-label="Collapse"]').simulate('click'); + expect(onExpand).toHaveBeenCalled(); + expect(onCompact).toHaveBeenCalled(); + done(); + }); + }); + + test('onSort being passed properly to DataListToolbar component', async (done) => { + const onSort = jest.spyOn(AccessList.prototype, 'onSort'); + const wrapper = mount( + + + ({ data: { count: 1, results: mockResults } })} + /> + + + ).find('AccessList'); + expect(onSort).not.toHaveBeenCalled(); + + setImmediate(() => { + const rendered = wrapper.update(); + rendered.find('button[aria-label="Sort"]').simulate('click'); + expect(onSort).toHaveBeenCalled(); + done(); + }); + }); + + test('getTeamRoles returns empty array if dataset is missing team_id attribute', (done) => { + const mockData = [ + { + id: 1, + username: 'boo', + url: '/foo/bar/', + first_name: 'john', + last_name: 'smith', + summary_fields: { + foo: [ + { + role: { + name: 'foo', + id: 2, + } + } + ], + direct_access: [ + { + role: { + name: 'team user', + id: 3, + } + } + ] + } + } + ]; + const wrapper = mount( + + + ({ data: { count: 1, results: mockData } })} + /> + + + ).find('AccessList'); + + setImmediate(() => { + const { results } = wrapper.state(); + results.forEach(result => { + expect(result.teamRoles).toEqual([]); + }); + done(); + }); + }); +}); diff --git a/__tests__/components/Lookup.test.jsx b/__tests__/components/Lookup.test.jsx index 84d454fb3a..2e990ec1b9 100644 --- a/__tests__/components/Lookup.test.jsx +++ b/__tests__/components/Lookup.test.jsx @@ -196,6 +196,7 @@ describe('', () => { lookup_header="Foo Bar" onLookupSave={() => { }} value={mockData} + selected={[]} columns={mockColumns} sortedColumnKey="name" getItems={() => { }} @@ -215,6 +216,7 @@ describe('', () => { lookup_header="Foo Bar" onLookupSave={() => { }} value={mockData} + selected={[]} columns={mockColumns} sortedColumnKey="name" getItems={() => { }} diff --git a/__tests__/components/NotificationList.test.jsx b/__tests__/components/NotificationList.test.jsx index 0b53f3c7e9..c08de77350 100644 --- a/__tests__/components/NotificationList.test.jsx +++ b/__tests__/components/NotificationList.test.jsx @@ -12,6 +12,11 @@ describe('', () => { @@ -25,6 +30,11 @@ describe('', () => { @@ -39,6 +49,11 @@ describe('', () => { @@ -54,6 +69,10 @@ describe('', () => { @@ -75,6 +94,11 @@ describe('', () => { @@ -90,7 +114,11 @@ describe('', () => { @@ -141,6 +169,8 @@ describe('', () => { getNotifications={getNotificationsFn} getSuccess={getSuccessFn} getError={getErrorFn} + postError={jest.fn()} + postSuccess={jest.fn()} /> diff --git a/__tests__/pages/Organizations/screens/Organization/OrganizationAccess.test.jsx b/__tests__/pages/Organizations/screens/Organization/OrganizationAccess.test.jsx new file mode 100644 index 0000000000..4a42274379 --- /dev/null +++ b/__tests__/pages/Organizations/screens/Organization/OrganizationAccess.test.jsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { MemoryRouter } from 'react-router-dom'; + +import OrganizationAccess from '../../../../../src/pages/Organizations/screens/Organization/OrganizationAccess'; + +const mockAPIAccessList = { + foo: 'bar', +}; + +const mockGetOrganzationAccessList = jest.fn(() => ( + Promise.resolve(mockAPIAccessList) +)); + +describe('', () => { + test('initially renders succesfully', () => { + mount( + + + + ); + }); + + test('passed methods as props are called appropriately', async () => { + const wrapper = mount( + + + + ).find('OrganizationAccess'); + const accessList = await wrapper.instance().getOrgAccessList(); + expect(accessList).toEqual(mockAPIAccessList); + }); +}); diff --git a/src/api.js b/src/api.js index 1dc6d5a74b..41cdb7525d 100644 --- a/src/api.js +++ b/src/api.js @@ -64,6 +64,12 @@ class APIClient { return this.http.post(API_ORGANIZATIONS, data); } + getOrganzationAccessList (id, params = {}) { + const endpoint = `${API_ORGANIZATIONS}${id}/access_list/`; + + return this.http.get(endpoint, { params }); + } + getOrganizationDetails (id) { const endpoint = `${API_ORGANIZATIONS}${id}/`; diff --git a/src/components/AccessList/Access.list.jsx b/src/components/AccessList/Access.list.jsx new file mode 100644 index 0000000000..c8d3aaec35 --- /dev/null +++ b/src/components/AccessList/Access.list.jsx @@ -0,0 +1,353 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; + +import { + DataList, DataListItem, DataListCell, Text, + TextContent, TextVariants +} from '@patternfly/react-core'; + +import { I18n, i18nMark } from '@lingui/react'; +import { t } from '@lingui/macro'; + +import { + Link +} from 'react-router-dom'; + +import BasicChip from '../BasicChip/BasicChip'; +import Pagination from '../Pagination'; +import DataListToolbar from '../DataListToolbar'; + +import { + parseQueryString, +} from '../../qs'; + +const userRolesWrapperStyle = { + display: 'flex', + flexWrap: 'wrap', +}; + +const detailWrapperStyle = { + display: 'grid', + gridTemplateColumns: 'minmax(70px, max-content) minmax(60px, max-content)', +}; + +const detailLabelStyle = { + fontWeight: '700', + lineHeight: '24px', + marginRight: '20px', +}; + +const detailValueStyle = { + lineHeight: '28px', + overflow: 'visible', +}; + +const hiddenStyle = { + display: 'none', +}; + +const Detail = ({ label, value, url, customStyles }) => { + let detail = null; + if (value) { + detail = ( + + {url ? ( + + {label} + ) : ({label} + )} + {value} + + ); + } + return detail; +}; + +const UserName = ({ value, url }) => { + let username = null; + if (value) { + username = ( + + {url ? ( + + {value} + ) : ({value} + )} + + ); + } + return username; +}; + +class AccessList extends React.Component { + columns = [ + { name: i18nMark('Name'), key: 'first_name', isSortable: true }, + { name: i18nMark('Username'), key: 'username', isSortable: true }, + { name: i18nMark('Last Name'), key: 'last_name', isSortable: true }, + ]; + + defaultParams = { + page: 1, + page_size: 5, + order_by: 'first_name', + }; + + constructor (props) { + super(props); + + const { page, page_size } = this.getQueryParams(); + + this.state = { + page, + page_size, + count: null, + sortOrder: 'ascending', + sortedColumnKey: 'username', + isCompact: false, + }; + + this.fetchOrgAccessList = this.fetchOrgAccessList.bind(this); + this.onSetPage = this.onSetPage.bind(this); + this.onExpand = this.onExpand.bind(this); + this.onCompact = this.onCompact.bind(this); + this.onSort = this.onSort.bind(this); + this.getQueryParams = this.getQueryParams.bind(this); + this.getTeamRoles = this.getTeamRoles.bind(this); + } + + componentDidMount () { + const queryParams = this.getQueryParams(); + try { + this.fetchOrgAccessList(queryParams); + } catch (error) { + this.setState({ error }); + } + } + + onExpand () { + this.setState({ isCompact: false }); + } + + onCompact () { + this.setState({ isCompact: true }); + } + + onSetPage (pageNumber, pageSize) { + const { sortOrder, sortedColumnKey } = this.state; + const page = parseInt(pageNumber, 10); + const page_size = parseInt(pageSize, 10); + let order_by = sortedColumnKey; + + // Preserve sort order when paginating + if (sortOrder === 'descending') { + order_by = `-${order_by}`; + } + + const queryParams = this.getQueryParams({ page, page_size, order_by }); + + this.fetchOrgAccessList(queryParams); + } + + onSort (sortedColumnKey, sortOrder) { + const { page_size } = this.state; + + let order_by = sortedColumnKey; + + if (sortOrder === 'descending') { + order_by = `-${order_by}`; + } + + const queryParams = this.getQueryParams({ order_by, page_size }); + + this.fetchOrgAccessList(queryParams); + } + + getQueryParams (overrides = {}) { + const { location } = this.props; + const { search } = location; + + const searchParams = parseQueryString(search.substring(1)); + + return Object.assign({}, this.defaultParams, searchParams, overrides); + } + + getRoles = roles => Object.values(roles) + .reduce((val, role) => { + if (role.length > 0) { + val.push(role[0].role); + } + return val; + }, []); + + getTeamRoles = roles => roles + .reduce((val, item) => { + if (item.role.team_id) { + const { role } = item; + val.push(role); + } + return val; + }, []); + + async fetchOrgAccessList (queryParams) { + const { match, getAccessList } = this.props; + + const { page, page_size, order_by } = queryParams; + + let sortOrder = 'ascending'; + let sortedColumnKey = order_by; + + if (order_by.startsWith('-')) { + sortOrder = 'descending'; + sortedColumnKey = order_by.substring(1); + } + + try { + const { data: + { count = null, results = null } + } = await getAccessList(match.params.id, queryParams); + const pageCount = Math.ceil(count / page_size); + + const stateToUpdate = { + count, + page, + pageCount, + page_size, + sortOrder, + sortedColumnKey, + results, + }; + results.forEach((result) => { + if (result.summary_fields.direct_access) { + result.teamRoles = this.getTeamRoles(result.summary_fields.direct_access); + } else { + result.teamRoles = []; + } + result.userRoles = this.getRoles(result.summary_fields) || []; + }); + this.setState(stateToUpdate); + } catch (error) { + this.setState({ error }); + } + } + + render () { + const { + results, + error, + count, + page_size, + pageCount, + page, + sortedColumnKey, + sortOrder, + isCompact, + } = this.state; + return ( + + {!error && !results && ( + Loading... // TODO: replace with proper loading state + )} + {error && !results && ( + + {error.message} + {error.response && ( + {error.response.data.detail} + )} + // TODO: replace with proper error handling + )} + {results && ( + + { }} + onSort={this.onSort} + onCompact={this.onCompact} + onExpand={this.onExpand} + isCompact={isCompact} + showExpandCollapse + /> + + + {({ i18n }) => ( + + {results.map(result => ( + + + + {result.first_name || result.last_name ? ( + + ) : ( + null + )} + + + + {result.userRoles.length > 0 && ( + + {i18n._(t`User Roles`)} + {result.userRoles.map(role => ( + + ))} + + )} + {result.teamRoles.length > 0 && ( + + {i18n._(t`Team Roles`)} + {result.teamRoles.map(role => ( + + ))} + + )} + + + ))} + + )} + + + + + )} + + ); + } +} + +AccessList.propTypes = { + getAccessList: PropTypes.func.isRequired, +}; + +export default AccessList; diff --git a/src/components/AccessList/index.js b/src/components/AccessList/index.js new file mode 100644 index 0000000000..f435e8bb1f --- /dev/null +++ b/src/components/AccessList/index.js @@ -0,0 +1,3 @@ +import AccessList from './Access.list'; + +export default AccessList; diff --git a/src/components/DataListToolbar/DataListToolbar.jsx b/src/components/DataListToolbar/DataListToolbar.jsx index a84f086a00..2810a52435 100644 --- a/src/components/DataListToolbar/DataListToolbar.jsx +++ b/src/components/DataListToolbar/DataListToolbar.jsx @@ -24,7 +24,7 @@ import { SortNumericDownIcon, SortNumericUpIcon, TrashAltIcon, - PlusIcon + PlusIcon, } from '@patternfly/react-icons'; import { Link @@ -37,6 +37,12 @@ const flexGrowStyling = { flexGrow: '1' }; +const ToolbarActiveStyle = { + backgroundColor: '#007bba', + color: 'white', + padding: '0 5px', +}; + class DataListToolbar extends React.Component { constructor (props) { super(props); @@ -56,6 +62,18 @@ class DataListToolbar extends React.Component { this.onSearchDropdownSelect = this.onSearchDropdownSelect.bind(this); this.onSearch = this.onSearch.bind(this); this.onSort = this.onSort.bind(this); + this.onExpand = this.onExpand.bind(this); + this.onCompact = this.onCompact.bind(this); + } + + onExpand () { + const { onExpand } = this.props; + onExpand(); + } + + onCompact () { + const { onCompact } = this.props; + onCompact(); } onSortDropdownToggle (isSortDropdownOpen) { @@ -114,7 +132,8 @@ class DataListToolbar extends React.Component { showExpandCollapse, showDelete, showSelectAll, - isLookup + isLookup, + isCompact, } = this.props; const { isSearchDropdownOpen, @@ -245,7 +264,9 @@ class DataListToolbar extends React.Component { @@ -253,7 +274,9 @@ class DataListToolbar extends React.Component { @@ -306,6 +329,7 @@ DataListToolbar.propTypes = { onSelectAll: PropTypes.func, onSort: PropTypes.func, showDelete: PropTypes.bool, + showExpandCollapse: PropTypes.bool, showSelectAll: PropTypes.bool, sortOrder: PropTypes.string, sortedColumnKey: PropTypes.string, @@ -317,6 +341,7 @@ DataListToolbar.defaultProps = { onSelectAll: null, onSort: null, showDelete: false, + showExpandCollapse: false, showSelectAll: false, sortOrder: 'ascending', sortedColumnKey: 'name', diff --git a/src/components/NotificationsList/Notifications.list.jsx b/src/components/NotificationsList/Notifications.list.jsx index 38f37044ce..1fe385823d 100644 --- a/src/components/NotificationsList/Notifications.list.jsx +++ b/src/components/NotificationsList/Notifications.list.jsx @@ -335,7 +335,7 @@ class Notifications extends Component { } } -Notifications.propType = { +Notifications.propTypes = { getError: PropTypes.func.isRequired, getNotifications: PropTypes.func.isRequired, getSuccess: PropTypes.func.isRequired, diff --git a/src/components/Tooltip/Tooltip.jsx b/src/components/Tooltip/Tooltip.jsx index 2ecb1ce832..c2309b443d 100644 --- a/src/components/Tooltip/Tooltip.jsx +++ b/src/components/Tooltip/Tooltip.jsx @@ -1,6 +1,11 @@ import React from 'react'; import PropTypes from 'prop-types'; +const toolTipContent = { + padding: '10px', + minWidth: '100px', +}; + class Tooltip extends React.Component { transforms = { top: { @@ -43,6 +48,9 @@ class Tooltip extends React.Component { isDisplayed } = this.state; + if (message === '') { + return null; + } return ( - + { message } diff --git a/src/pages/Organizations/screens/Organization/Organization.jsx b/src/pages/Organizations/screens/Organization/Organization.jsx index 248b93c6d3..dde9a44621 100644 --- a/src/pages/Organizations/screens/Organization/Organization.jsx +++ b/src/pages/Organizations/screens/Organization/Organization.jsx @@ -14,6 +14,7 @@ import { PageSection } from '@patternfly/react-core'; +import OrganizationAccess from './OrganizationAccess'; import OrganizationDetail from './OrganizationDetail'; import OrganizationEdit from './OrganizationEdit'; import OrganizationNotifications from './OrganizationNotifications'; @@ -145,7 +146,14 @@ class Organization extends Component { )} Access} + render={() => ( + + )} /> + ); + } +} + +export default OrganizationAccess;