Merge pull request #121 from ansible/org-access-list

Organization Access List
This commit is contained in:
kialam
2019-03-05 18:56:29 -05:00
committed by GitHub
12 changed files with 683 additions and 7 deletions

View File

@@ -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('<AccessList />', () => {
test('initially renders succesfully', () => {
mount(
<I18nProvider>
<MemoryRouter>
<AccessList
match={{ path: '/organizations/:id', url: '/organizations/1', params: { id: '1' } }}
location={{ search: '', pathname: '/organizations/1/access' }}
getAccessList={() => {}}
/>
</MemoryRouter>
</I18nProvider>
);
});
test('api response data passed to component gets set to state properly', (done) => {
const wrapper = mount(
<I18nProvider>
<MemoryRouter>
<AccessList
match={{ path: '/organizations/:id', url: '/organizations/1', params: { id: '0' } }}
location={{ search: '', pathname: '/organizations/1/access' }}
getAccessList={() => ({ data: { count: 1, results: mockResults } })}
/>
</MemoryRouter>
</I18nProvider>
).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(
<I18nProvider>
<MemoryRouter>
<AccessList
match={{ path: '/organizations/:id', url: '/organizations/1', params: { id: '0' } }}
location={{ search: '', pathname: '/organizations/1/access' }}
getAccessList={() => ({ data: { count: 1, results: mockResults } })}
/>
</MemoryRouter>
</I18nProvider>
).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(
<I18nProvider>
<MemoryRouter>
<AccessList
match={{ path: '/organizations/:id', url: '/organizations/1', params: { id: '0' } }}
location={{ search: '', pathname: '/organizations/1/access' }}
getAccessList={() => ({ data: { count: 1, results: mockResults } })}
/>
</MemoryRouter>
</I18nProvider>
).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(
<I18nProvider>
<MemoryRouter>
<AccessList
match={{ path: '/organizations/:id', url: '/organizations/1', params: { id: '0' } }}
location={{ search: '', pathname: '/organizations/1/access' }}
getAccessList={() => ({ data: { count: 1, results: mockData } })}
/>
</MemoryRouter>
</I18nProvider>
).find('AccessList');
setImmediate(() => {
const { results } = wrapper.state();
results.forEach(result => {
expect(result.teamRoles).toEqual([]);
});
done();
});
});
});

View File

@@ -196,6 +196,7 @@ describe('<Lookup />', () => {
lookup_header="Foo Bar"
onLookupSave={() => { }}
value={mockData}
selected={[]}
columns={mockColumns}
sortedColumnKey="name"
getItems={() => { }}
@@ -215,6 +216,7 @@ describe('<Lookup />', () => {
lookup_header="Foo Bar"
onLookupSave={() => { }}
value={mockData}
selected={[]}
columns={mockColumns}
sortedColumnKey="name"
getItems={() => { }}

View File

@@ -12,6 +12,11 @@ describe('<Notifications />', () => {
<Notifications
match={{ path: '/organizations/:id/?tab=notifications', url: '/organizations/:id/?tab=notifications' }}
location={{ search: '', pathname: '/organizations/:id/?tab=notifications' }}
getError={jest.fn()}
getNotifications={jest.fn()}
getSuccess={jest.fn()}
postError={jest.fn()}
postSuccess={jest.fn()}
/>
</I18nProvider>
</MemoryRouter>
@@ -25,6 +30,11 @@ describe('<Notifications />', () => {
<Notifications
match={{ path: '/organizations/:id/?tab=notifications', url: '/organizations/:id/?tab=notifications' }}
location={{ search: '', pathname: '/organizations/:id/?tab=notifications' }}
getError={jest.fn()}
getNotifications={jest.fn()}
getSuccess={jest.fn()}
postError={jest.fn()}
postSuccess={jest.fn()}
/>
</I18nProvider>
</MemoryRouter>
@@ -39,6 +49,11 @@ describe('<Notifications />', () => {
<Notifications
match={{ path: '/organizations/:id/?tab=notifications', url: '/organizations/:id/?tab=notifications' }}
location={{ search: '', pathname: '/organizations/:id/?tab=notifications' }}
getError={jest.fn()}
getNotifications={jest.fn()}
getSuccess={jest.fn()}
postError={jest.fn()}
postSuccess={jest.fn()}
/>
</I18nProvider>
</MemoryRouter>
@@ -54,6 +69,10 @@ describe('<Notifications />', () => {
<Notifications
match={{ path: '/organizations/:id/?tab=notifications', url: '/organizations/:id/?tab=notifications', params: { id: 1 } }}
location={{ search: '', pathname: '/organizations/:id/?tab=notifications' }}
getError={jest.fn()}
getNotifications={jest.fn()}
getSuccess={jest.fn()}
postError={jest.fn()}
postSuccess={postSuccessFn}
/>
</I18nProvider>
@@ -75,6 +94,11 @@ describe('<Notifications />', () => {
<Notifications
match={{ path: '/organizations/:id/?tab=notifications', url: '/organizations/:id/?tab=notifications' }}
location={{ search: '', pathname: '/organizations/:id/?tab=notifications' }}
getError={jest.fn()}
getNotifications={jest.fn()}
getSuccess={jest.fn()}
postError={jest.fn()}
postSuccess={jest.fn()}
/>
</I18nProvider>
</MemoryRouter>
@@ -90,7 +114,11 @@ describe('<Notifications />', () => {
<Notifications
match={{ path: '/organizations/:id/?tab=notifications', url: '/organizations/:id/?tab=notifications', params: { id: 1 } }}
location={{ search: '', pathname: '/organizations/:id/?tab=notifications' }}
getError={jest.fn()}
getNotifications={jest.fn()}
getSuccess={jest.fn()}
postError={postErrorFn}
postSuccess={jest.fn()}
/>
</I18nProvider>
</MemoryRouter>
@@ -141,6 +169,8 @@ describe('<Notifications />', () => {
getNotifications={getNotificationsFn}
getSuccess={getSuccessFn}
getError={getErrorFn}
postError={jest.fn()}
postSuccess={jest.fn()}
/>
</I18nProvider>
</MemoryRouter>

View File

@@ -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('<OrganizationAccess />', () => {
test('initially renders succesfully', () => {
mount(
<MemoryRouter initialEntries={['/organizations/1']} initialIndex={0}>
<OrganizationAccess
match={{ path: '/organizations/:id/access', url: '/organizations/1/access', params: { id: 1 } }}
location={{ search: '', pathname: '/organizations/1/access' }}
params={{}}
api={{
getOrganzationAccessList: jest.fn(),
}}
/>
</MemoryRouter>
);
});
test('passed methods as props are called appropriately', async () => {
const wrapper = mount(
<MemoryRouter initialEntries={['/organizations/1']} initialIndex={0}>
<OrganizationAccess
match={{ path: '/organizations/:id/access', url: '/organizations/1/access', params: { id: 1 } }}
location={{ search: '', pathname: '/organizations/1/access' }}
params={{}}
api={{
getOrganzationAccessList: mockGetOrganzationAccessList,
}}
/>
</MemoryRouter>
).find('OrganizationAccess');
const accessList = await wrapper.instance().getOrgAccessList();
expect(accessList).toEqual(mockAPIAccessList);
});
});

View File

@@ -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}/`;

View File

@@ -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 = (
<TextContent style={{ ...detailWrapperStyle, ...customStyles }}>
{url ? (
<Link to={{ pathname: url }}>
<Text component={TextVariants.h6} style={detailLabelStyle}>{label}</Text>
</Link>) : (<Text component={TextVariants.h6} style={detailLabelStyle}>{label}</Text>
)}
<Text component={TextVariants.p} style={detailValueStyle}>{value}</Text>
</TextContent>
);
}
return detail;
};
const UserName = ({ value, url }) => {
let username = null;
if (value) {
username = (
<TextContent style={detailWrapperStyle}>
{url ? (
<Link to={{ pathname: url }}>
<Text component={TextVariants.h6} style={detailLabelStyle}>{value}</Text>
</Link>) : (<Text component={TextVariants.h6} style={detailLabelStyle}>{value}</Text>
)}
</TextContent>
);
}
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 (
<Fragment>
{!error && !results && (
<h1>Loading...</h1> // TODO: replace with proper loading state
)}
{error && !results && (
<Fragment>
<div>{error.message}</div>
{error.response && (
<div>{error.response.data.detail}</div>
)}
</Fragment> // TODO: replace with proper error handling
)}
{results && (
<Fragment>
<DataListToolbar
sortedColumnKey={sortedColumnKey}
sortOrder={sortOrder}
columns={this.columns}
onSearch={() => { }}
onSort={this.onSort}
onCompact={this.onCompact}
onExpand={this.onExpand}
isCompact={isCompact}
showExpandCollapse
/>
<Fragment>
<I18n>
{({ i18n }) => (
<DataList aria-label={i18n._(t`Access List`)}>
{results.map(result => (
<DataListItem aria-labelledby={i18n._(t`access-list-item`)} key={result.id}>
<DataListCell>
<UserName
value={result.username}
url={result.url}
/>
{result.first_name || result.last_name ? (
<Detail
label={i18n._(t`Name`)}
value={`${result.first_name} ${result.last_name}`}
url={null}
customStyles={isCompact ? hiddenStyle : null}
/>
) : (
null
)}
</DataListCell>
<DataListCell>
<Detail
label=" "
value=" "
url={null}
customStyles={isCompact ? hiddenStyle : null}
/>
{result.userRoles.length > 0 && (
<ul style={isCompact
? { ...userRolesWrapperStyle, ...hiddenStyle }
: userRolesWrapperStyle}
>
<Text component={TextVariants.h6} style={detailLabelStyle}>{i18n._(t`User Roles`)}</Text>
{result.userRoles.map(role => (
<BasicChip
key={role.id}
text={role.name}
/>
))}
</ul>
)}
{result.teamRoles.length > 0 && (
<ul style={isCompact
? { ...userRolesWrapperStyle, ...hiddenStyle }
: userRolesWrapperStyle}
>
<Text component={TextVariants.h6} style={detailLabelStyle}>{i18n._(t`Team Roles`)}</Text>
{result.teamRoles.map(role => (
<BasicChip
key={role.id}
text={role.name}
/>
))}
</ul>
)}
</DataListCell>
</DataListItem>
))}
</DataList>
)}
</I18n>
</Fragment>
<Pagination
count={count}
page={page}
pageCount={pageCount}
page_size={page_size}
onSetPage={this.onSetPage}
/>
</Fragment>
)}
</Fragment>
);
}
}
AccessList.propTypes = {
getAccessList: PropTypes.func.isRequired,
};
export default AccessList;

View File

@@ -0,0 +1,3 @@
import AccessList from './Access.list';
export default AccessList;

View File

@@ -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 {
<ToolbarItem>
<Button
variant="plain"
aria-label={i18n._(t`Expand`)}
aria-label={i18n._(t`Collapse`)}
onClick={this.onCompact}
style={isCompact ? ToolbarActiveStyle : null}
>
<BarsIcon />
</Button>
@@ -253,7 +274,9 @@ class DataListToolbar extends React.Component {
<ToolbarItem>
<Button
variant="plain"
aria-label={i18n._(t`Collapse`)}
aria-label={i18n._(t`Expand`)}
onClick={this.onExpand}
style={!isCompact ? ToolbarActiveStyle : null}
>
<EqualsIcon />
</Button>
@@ -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',

View File

@@ -335,7 +335,7 @@ class Notifications extends Component {
}
}
Notifications.propType = {
Notifications.propTypes = {
getError: PropTypes.func.isRequired,
getNotifications: PropTypes.func.isRequired,
getSuccess: PropTypes.func.isRequired,

View File

@@ -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 (
<span
style={{ position: 'relative' }}
@@ -57,7 +65,7 @@ class Tooltip extends React.Component {
className={`pf-c-tooltip pf-m-${position}`}
>
<div className="pf-c-tooltip__arrow" />
<div className="pf-c-tooltip__content">
<div className="pf-c-tooltip__content" style={toolTipContent}>
{ message }
</div>
</div>

View File

@@ -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 {
)}
<Route
path="/organizations/:id/access"
render={() => <CardBody><h1><Trans>Access</Trans></h1></CardBody>}
render={() => (
<OrganizationAccess
api={api}
match={match}
location={location}
history={history}
/>
)}
/>
<Route
path="/organizations/:id/teams"

View File

@@ -0,0 +1,34 @@
import React from 'react';
import AccessList from '../../../../components/AccessList';
class OrganizationAccess extends React.Component {
constructor (props) {
super(props);
this.getOrgAccessList = this.getOrgAccessList.bind(this);
}
getOrgAccessList (id, params) {
const { api } = this.props;
return api.getOrganzationAccessList(id, params);
}
render () {
const {
location,
match,
history,
} = this.props;
return (
<AccessList
getAccessList={this.getOrgAccessList}
match={match}
location={location}
history={history}
/>
);
}
}
export default OrganizationAccess;