Add projects list and scaffolding for project details+tabs

This commit is contained in:
mabashian 2019-09-30 18:30:24 -04:00
parent 8d3ecf708b
commit b8fe3f648e
35 changed files with 1573 additions and 239 deletions

View File

@ -7,11 +7,21 @@ class Projects extends LaunchUpdateMixin(Base) {
this.baseUrl = '/api/v2/projects/';
this.readPlaybooks = this.readPlaybooks.bind(this);
this.readSync = this.readSync.bind(this);
this.sync = this.sync.bind(this);
}
readPlaybooks(id) {
return this.http.get(`${this.baseUrl}${id}/playbooks/`);
}
readSync(id) {
return this.http.get(`${this.baseUrl}${id}/update/`);
}
sync(id) {
return this.http.post(`${this.baseUrl}${id}/update/`);
}
}
export default Projects;

View File

@ -0,0 +1,11 @@
import { Button } from '@patternfly/react-core';
import styled from 'styled-components';
export default styled(Button)`
padding: 5px 8px;
border: none;
&:hover {
background-color: #0066cc;
color: white;
}
`;

View File

@ -0,0 +1,10 @@
import React from 'react';
import { mount } from 'enzyme';
import ListActionButton from './ListActionButton';
describe('ListActionButton', () => {
test('renders the expected content', () => {
const wrapper = mount(<ListActionButton />);
expect(wrapper).toHaveLength(1);
});
});

View File

@ -0,0 +1 @@
export { default } from './ListActionButton';

View File

@ -7,26 +7,16 @@ import {
DataListItemRow,
DataListItemCells,
Tooltip,
Button as PFButton,
} from '@patternfly/react-core';
import { RocketIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
import DataListCell from '@components/DataListCell';
import DataListCheck from '@components/DataListCheck';
import LaunchButton from '@components/LaunchButton';
import ListActionButton from '@components/ListActionButton';
import VerticalSeparator from '@components/VerticalSeparator';
import { toTitleCase } from '@util/strings';
import { JOB_TYPE_URL_SEGMENTS } from '../../../constants';
const StyledButton = styled(PFButton)`
padding: 5px 8px;
border: none;
&:hover {
background-color: #0066cc;
color: white;
}
`;
class JobListItem extends Component {
render() {
const { i18n, job, isSelected, onSelect } = this.props;
@ -60,15 +50,15 @@ class JobListItem extends Component {
<DataListCell lastcolumn="true" key="relaunch">
{job.type !== 'system_job' &&
job.summary_fields.user_capabilities.start && (
<Tooltip content={i18n._(t`Relaunch`)} position="top">
<Tooltip content={i18n._(t`Relaunch Job`)} position="top">
<LaunchButton resource={job}>
{({ handleRelaunch }) => (
<StyledButton
<ListActionButton
variant="plain"
onClick={handleRelaunch}
>
<RocketIcon />
</StyledButton>
</ListActionButton>
)}
</LaunchButton>
</Tooltip>

View File

@ -3,6 +3,8 @@ import React from 'react';
import { OrganizationsAPI } from '@api';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import mockOrganization from '@util/data.organization.json';
import Organization from './Organization';
jest.mock('@api');
@ -12,192 +14,17 @@ const mockMe = {
is_system_auditor: false,
};
const mockNoResults = {
count: 0,
next: null,
previous: null,
data: { results: [] },
};
const mockDetails = {
data: {
id: 1,
type: 'organization',
url: '/api/v2/organizations/1/',
related: {
notification_templates: '/api/v2/organizations/1/notification_templates/',
notification_templates_any:
'/api/v2/organizations/1/notification_templates_any/',
notification_templates_success:
'/api/v2/organizations/1/notification_templates_success/',
notification_templates_error:
'/api/v2/organizations/1/notification_templates_error/',
object_roles: '/api/v2/organizations/1/object_roles/',
access_list: '/api/v2/organizations/1/access_list/',
instance_groups: '/api/v2/organizations/1/instance_groups/',
},
summary_fields: {
created_by: {
id: 1,
username: 'admin',
first_name: 'Super',
last_name: 'User',
},
modified_by: {
id: 1,
username: 'admin',
first_name: 'Super',
last_name: 'User',
},
object_roles: {
admin_role: {
description: 'Can manage all aspects of the organization',
name: 'Admin',
id: 42,
},
notification_admin_role: {
description: 'Can manage all notifications of the organization',
name: 'Notification Admin',
id: 1683,
},
auditor_role: {
description: 'Can view all aspects of the organization',
name: 'Auditor',
id: 41,
},
},
user_capabilities: {
edit: true,
delete: true,
},
related_field_counts: {
users: 51,
admins: 19,
inventories: 23,
teams: 12,
projects: 33,
job_templates: 30,
},
},
created: '2015-07-07T17:21:26.429745Z',
modified: '2017-09-05T19:23:15.418808Z',
name: 'Sarif Industries',
description: '',
max_hosts: 0,
custom_virtualenv: null,
},
};
const adminOrganization = {
id: 1,
type: 'organization',
url: '/api/v2/organizations/1/',
related: {
instance_groups: '/api/v2/organizations/1/instance_groups/',
object_roles: '/api/v2/organizations/1/object_roles/',
access_list: '/api/v2/organizations/1/access_list/',
},
summary_fields: {
created_by: {
id: 1,
username: 'admin',
first_name: 'Super',
last_name: 'User',
},
modified_by: {
id: 1,
username: 'admin',
first_name: 'Super',
last_name: 'User',
},
},
created: '2015-07-07T17:21:26.429745Z',
modified: '2017-09-05T19:23:15.418808Z',
name: 'Sarif Industries',
description: '',
max_hosts: 0,
custom_virtualenv: null,
};
const auditorOrganization = {
id: 2,
type: 'organization',
url: '/api/v2/organizations/2/',
related: {
instance_groups: '/api/v2/organizations/2/instance_groups/',
object_roles: '/api/v2/organizations/2/object_roles/',
access_list: '/api/v2/organizations/2/access_list/',
},
summary_fields: {
created_by: {
id: 2,
username: 'admin',
first_name: 'Super',
last_name: 'User',
},
modified_by: {
id: 2,
username: 'admin',
first_name: 'Super',
last_name: 'User',
},
},
created: '2015-07-07T17:21:26.429745Z',
modified: '2017-09-05T19:23:15.418808Z',
name: 'Autobots',
description: '',
max_hosts: 0,
custom_virtualenv: null,
};
const notificationAdminOrganization = {
id: 3,
type: 'organization',
url: '/api/v2/organizations/3/',
related: {
instance_groups: '/api/v2/organizations/3/instance_groups/',
object_roles: '/api/v2/organizations/3/object_roles/',
access_list: '/api/v2/organizations/3/access_list/',
},
summary_fields: {
created_by: {
id: 1,
username: 'admin',
first_name: 'Super',
last_name: 'User',
},
modified_by: {
id: 1,
username: 'admin',
first_name: 'Super',
last_name: 'User',
},
},
created: '2015-07-07T17:21:26.429745Z',
modified: '2017-09-05T19:23:15.418808Z',
name: 'Decepticons',
description: '',
max_hosts: 0,
custom_virtualenv: null,
};
const allOrganizations = [
adminOrganization,
auditorOrganization,
notificationAdminOrganization,
];
async function getOrganizations(params) {
let results = allOrganizations;
let results = [];
if (params && params.role_level) {
if (params.role_level === 'admin_role') {
results = [adminOrganization];
results = [mockOrganization];
}
if (params.role_level === 'auditor_role') {
results = [auditorOrganization];
results = [mockOrganization];
}
if (params.role_level === 'notification_admin_role') {
results = [notificationAdminOrganization];
results = [mockOrganization];
}
}
return {
@ -210,13 +37,13 @@ async function getOrganizations(params) {
describe.only('<Organization />', () => {
test('initially renders succesfully', () => {
OrganizationsAPI.readDetail.mockResolvedValue(mockDetails);
OrganizationsAPI.readDetail.mockResolvedValue({ data: mockOrganization });
OrganizationsAPI.read.mockImplementation(getOrganizations);
mountWithContexts(<Organization setBreadcrumb={() => {}} me={mockMe} />);
});
test('notifications tab shown for admins', async done => {
OrganizationsAPI.readDetail.mockResolvedValue(mockDetails);
OrganizationsAPI.readDetail.mockResolvedValue({ data: mockOrganization });
OrganizationsAPI.read.mockImplementation(getOrganizations);
const wrapper = mountWithContexts(
@ -232,8 +59,13 @@ describe.only('<Organization />', () => {
});
test('notifications tab hidden with reduced permissions', async done => {
OrganizationsAPI.readDetail.mockResolvedValue(mockDetails);
OrganizationsAPI.read.mockResolvedValue(mockNoResults);
OrganizationsAPI.readDetail.mockResolvedValue({ data: mockOrganization });
OrganizationsAPI.read.mockResolvedValue({
count: 0,
next: null,
previous: null,
data: { results: [] },
});
const wrapper = mountWithContexts(
<Organization setBreadcrumb={() => {}} me={mockMe} />

View File

@ -0,0 +1,265 @@
import React, { Component } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Switch, Route, withRouter, Redirect, Link } from 'react-router-dom';
import {
Card,
CardHeader as PFCardHeader,
PageSection,
} from '@patternfly/react-core';
import styled from 'styled-components';
import CardCloseButton from '@components/CardCloseButton';
import RoutedTabs from '@components/RoutedTabs';
import ContentError from '@components/ContentError';
import ProjectAccess from './ProjectAccess';
import ProjectDetail from './ProjectDetail';
import ProjectEdit from './ProjectEdit';
import ProjectJobTemplates from './ProjectJobTemplates';
import ProjectNotifications from './ProjectNotifications';
import ProjectSchedules from './ProjectSchedules';
import { OrganizationsAPI, ProjectsAPI } from '@api';
class Project extends Component {
constructor(props) {
super(props);
this.state = {
project: null,
hasContentLoading: true,
contentError: null,
isInitialized: false,
isNotifAdmin: false,
isAuditorOfThisOrg: false,
isAdminOfThisOrg: false,
};
this.loadProject = this.loadProject.bind(this);
this.loadProjectAndRoles = this.loadProjectAndRoles.bind(this);
}
async componentDidMount() {
await this.loadProjectAndRoles();
this.setState({ isInitialized: true });
}
async componentDidUpdate(prevProps) {
const { location, match } = this.props;
const url = `/projects/${match.params.id}/`;
if (
prevProps.location.pathname.startsWith(url) &&
prevProps.location !== location &&
location.pathname === `${url}details`
) {
await this.loadProject();
}
}
async loadProjectAndRoles() {
const { match, setBreadcrumb } = this.props;
const id = parseInt(match.params.id, 10);
this.setState({ contentError: null, hasContentLoading: true });
try {
const [{ data }, notifAdminRes] = await Promise.all([
ProjectsAPI.readDetail(id),
OrganizationsAPI.read({
page_size: 1,
role_level: 'notification_admin_role',
}),
]);
const [auditorRes, adminRes] = await Promise.all([
OrganizationsAPI.read({
id: data.organization,
role_level: 'auditor_role',
}),
OrganizationsAPI.read({
id: data.organization,
role_level: 'admin_role',
}),
]);
setBreadcrumb(data);
this.setState({
project: data,
isNotifAdmin: notifAdminRes.data.results.length > 0,
isAuditorOfThisOrg: auditorRes.data.results.length > 0,
isAdminOfThisOrg: adminRes.data.results.length > 0,
});
} catch (err) {
this.setState({ contentError: err });
} finally {
this.setState({ hasContentLoading: false });
}
}
async loadProject() {
const { match, setBreadcrumb } = this.props;
const id = parseInt(match.params.id, 10);
this.setState({ contentError: null, hasContentLoading: true });
try {
const { data } = await ProjectsAPI.readDetail(id);
setBreadcrumb(data);
this.setState({ project: data });
} catch (err) {
this.setState({ contentError: err });
} finally {
this.setState({ hasContentLoading: false });
}
}
render() {
const { location, match, me, history, i18n } = this.props;
const {
project,
contentError,
hasContentLoading,
isInitialized,
isNotifAdmin,
isAuditorOfThisOrg,
isAdminOfThisOrg,
} = this.state;
const canSeeNotificationsTab =
me.is_system_auditor || isNotifAdmin || isAuditorOfThisOrg;
const canToggleNotifications =
isNotifAdmin &&
(me.is_system_auditor || isAuditorOfThisOrg || isAdminOfThisOrg);
const tabsArray = [
{ name: i18n._(t`Details`), link: `${match.url}/details`, id: 0 },
{ name: i18n._(t`Access`), link: `${match.url}/access`, id: 1 },
{ name: i18n._(t`Schedules`), link: `${match.url}/schedules`, id: 2 },
{
name: i18n._(t`Job Templates`),
link: `${match.url}/job_templates`,
id: 3,
},
];
if (canSeeNotificationsTab) {
tabsArray.push({
name: i18n._(t`Notifications`),
link: `${match.url}/notifications`,
id: 4,
});
}
const CardHeader = styled(PFCardHeader)`
--pf-c-card--first-child--PaddingTop: 0;
--pf-c-card--child--PaddingLeft: 0;
--pf-c-card--child--PaddingRight: 0;
position: relative;
`;
let cardHeader = (
<CardHeader style={{ padding: 0 }}>
<RoutedTabs
match={match}
history={history}
labeltext={i18n._(t`Project detail tabs`)}
tabsArray={tabsArray}
/>
<CardCloseButton linkTo="/projects" />
</CardHeader>
);
if (!isInitialized) {
cardHeader = null;
}
if (!match) {
cardHeader = null;
}
if (location.pathname.endsWith('edit')) {
cardHeader = null;
}
if (!hasContentLoading && contentError) {
return (
<PageSection>
<Card className="awx-c-card">
<ContentError error={contentError}>
{contentError.response.status === 404 && (
<span>
{i18n._(`Project not found.`)}{' '}
<Link to="/projects">{i18n._(`View all Projects.`)}</Link>
</span>
)}
</ContentError>
</Card>
</PageSection>
);
}
return (
<PageSection>
<Card className="awx-c-card">
{cardHeader}
<Switch>
<Redirect from="/projects/:id" to="/projects/:id/details" exact />
{project && (
<Route
path="/projects/:id/edit"
render={() => <ProjectEdit match={match} project={project} />}
/>
)}
{project && (
<Route
path="/projects/:id/details"
render={() => <ProjectDetail match={match} project={project} />}
/>
)}
{project && (
<Route
path="/projects/:id/access"
render={() => <ProjectAccess project={project} />}
/>
)}
{canSeeNotificationsTab && (
<Route
path="/projects/:id/notifications"
render={() => (
<ProjectNotifications
id={Number(match.params.id)}
canToggleNotifications={canToggleNotifications}
/>
)}
/>
)}
<Route
path="/projects/:id/job_templates"
render={() => (
<ProjectJobTemplates id={Number(match.params.id)} />
)}
/>
<Route
path="/projects/:id/schedules"
render={() => <ProjectSchedules id={Number(match.params.id)} />}
/>
<Route
key="not-found"
path="*"
render={() =>
!hasContentLoading && (
<ContentError isNotFound>
{match.params.id && (
<Link to={`/projects/${match.params.id}/details`}>
{i18n._(`View Project Details`)}
</Link>
)}
</ContentError>
)
}
/>
,
</Switch>
</Card>
</PageSection>
);
}
}
export default withI18n()(withRouter(Project));
export { Project as _Project };

View File

@ -0,0 +1,72 @@
import React from 'react';
import { OrganizationsAPI, ProjectsAPI } from '@api';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import mockOrganization from '@util/data.organization.json';
import mockDetails from './data.project.json';
import Project from './Project';
jest.mock('@api');
const mockMe = {
is_super_user: true,
is_system_auditor: false,
};
async function getOrganizations() {
return {
count: 1,
next: null,
previous: null,
data: {
results: [mockOrganization],
},
};
}
describe.only('<Project />', () => {
test('initially renders succesfully', () => {
ProjectsAPI.readDetail.mockResolvedValue({ data: mockDetails });
OrganizationsAPI.read.mockImplementation(getOrganizations);
mountWithContexts(<Project setBreadcrumb={() => {}} me={mockMe} />);
});
test('notifications tab shown for admins', async done => {
ProjectsAPI.readDetail.mockResolvedValue({ data: mockDetails });
OrganizationsAPI.read.mockImplementation(getOrganizations);
const wrapper = mountWithContexts(
<Project setBreadcrumb={() => {}} me={mockMe} />
);
const tabs = await waitForElement(
wrapper,
'.pf-c-tabs__item',
el => el.length === 5
);
expect(tabs.last().text()).toEqual('Notifications');
done();
});
test('notifications tab hidden with reduced permissions', async done => {
ProjectsAPI.readDetail.mockResolvedValue({ data: mockDetails });
OrganizationsAPI.read.mockResolvedValue({
count: 0,
next: null,
previous: null,
data: { results: [] },
});
const wrapper = mountWithContexts(
<Project setBreadcrumb={() => {}} me={mockMe} />
);
const tabs = await waitForElement(
wrapper,
'.pf-c-tabs__item',
el => el.length === 4
);
tabs.forEach(tab => expect(tab.text()).not.toEqual('Notifications'));
done();
});
});

View File

@ -0,0 +1,10 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
class ProjectAccess extends Component {
render() {
return <CardBody>Coming soon :)</CardBody>;
}
}
export default ProjectAccess;

View File

@ -0,0 +1 @@
export { default } from './ProjectAccess';

View File

@ -0,0 +1,10 @@
import React, { Component } from 'react';
import { PageSection } from '@patternfly/react-core';
class ProjectAdd extends Component {
render() {
return <PageSection>Coming soon :)</PageSection>;
}
}
export default ProjectAdd;

View File

@ -0,0 +1 @@
export { default } from './ProjectAdd';

View File

@ -0,0 +1,10 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
class ProjectDetail extends Component {
render() {
return <CardBody>Coming soon :)</CardBody>;
}
}
export default ProjectDetail;

View File

@ -0,0 +1 @@
export { default } from './ProjectDetail';

View File

@ -0,0 +1,10 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
class ProjectEdit extends Component {
render() {
return <CardBody>Coming soon :)</CardBody>;
}
}
export default ProjectEdit;

View File

@ -0,0 +1 @@
export { default } from './ProjectEdit';

View File

@ -0,0 +1,10 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
class ProjectJobTemplates extends Component {
render() {
return <CardBody>Coming soon :)</CardBody>;
}
}
export default ProjectJobTemplates;

View File

@ -0,0 +1 @@
export { default } from './ProjectJobTemplates';

View File

@ -0,0 +1,227 @@
import React, { Component, Fragment } from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Card, PageSection } from '@patternfly/react-core';
import { ProjectsAPI } from '@api';
import AlertModal from '@components/AlertModal';
import DataListToolbar from '@components/DataListToolbar';
import ErrorDetail from '@components/ErrorDetail';
import PaginatedDataList, {
ToolbarAddButton,
ToolbarDeleteButton,
} from '@components/PaginatedDataList';
import { getQSConfig, parseQueryString } from '@util/qs';
import ProjectListItem from './ProjectListItem';
const QS_CONFIG = getQSConfig('project', {
page: 1,
page_size: 5,
order_by: 'name',
});
class ProjectsList extends Component {
constructor(props) {
super(props);
this.state = {
hasContentLoading: true,
contentError: null,
deletionError: null,
projects: [],
selected: [],
itemCount: 0,
actions: null,
};
this.handleSelectAll = this.handleSelectAll.bind(this);
this.handleSelect = this.handleSelect.bind(this);
this.handleOrgDelete = this.handleOrgDelete.bind(this);
this.handleDeleteErrorClose = this.handleDeleteErrorClose.bind(this);
this.loadProjects = this.loadProjects.bind(this);
}
componentDidMount() {
this.loadProjects();
}
componentDidUpdate(prevProps) {
const { location } = this.props;
if (location !== prevProps.location) {
this.loadProjects();
}
}
handleSelectAll(isSelected) {
const { projects } = this.state;
const selected = isSelected ? [...projects] : [];
this.setState({ selected });
}
handleSelect(row) {
const { selected } = this.state;
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) });
}
}
handleDeleteErrorClose() {
this.setState({ deletionError: null });
}
async handleOrgDelete() {
const { selected } = this.state;
this.setState({ hasContentLoading: true });
try {
await Promise.all(selected.map(org => ProjectsAPI.destroy(org.id)));
} catch (err) {
this.setState({ deletionError: err });
} finally {
await this.loadProjects();
}
}
async loadProjects() {
const { location } = this.props;
const { actions: cachedActions } = this.state;
const params = parseQueryString(QS_CONFIG, location.search);
let optionsPromise;
if (cachedActions) {
optionsPromise = Promise.resolve({ data: { actions: cachedActions } });
} else {
optionsPromise = ProjectsAPI.readOptions();
}
const promises = Promise.all([ProjectsAPI.read(params), optionsPromise]);
this.setState({ contentError: null, hasContentLoading: true });
try {
const [
{
data: { count, results },
},
{
data: { actions },
},
] = await promises;
this.setState({
actions,
itemCount: count,
projects: results,
selected: [],
});
} catch (err) {
this.setState({ contentError: err });
} finally {
this.setState({ hasContentLoading: false });
}
}
render() {
const {
actions,
itemCount,
contentError,
hasContentLoading,
deletionError,
selected,
projects,
} = this.state;
const { match, i18n } = this.props;
const canAdd =
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
const isAllSelected = selected.length === projects.length;
return (
<Fragment>
<PageSection>
<Card>
<PaginatedDataList
contentError={contentError}
hasContentLoading={hasContentLoading}
items={projects}
itemCount={itemCount}
pluralizedItemName="Projects"
qsConfig={QS_CONFIG}
toolbarColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isSortable: true,
isSearchable: true,
},
{
name: i18n._(t`Modified`),
key: 'modified',
isSortable: true,
isNumeric: true,
},
{
name: i18n._(t`Created`),
key: 'created',
isSortable: true,
isNumeric: true,
},
]}
renderToolbar={props => (
<DataListToolbar
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={this.handleSelectAll}
qsConfig={QS_CONFIG}
additionalControls={[
<ToolbarDeleteButton
key="delete"
onDelete={this.handleOrgDelete}
itemsToDelete={selected}
pluralizedItemName="Projects"
/>,
canAdd ? (
<ToolbarAddButton key="add" linkTo={`${match.url}/add`} />
) : null,
]}
/>
)}
renderItem={o => (
<ProjectListItem
key={o.id}
project={o}
detailUrl={`${match.url}/${o.id}`}
isSelected={selected.some(row => row.id === o.id)}
onSelect={() => this.handleSelect(o)}
/>
)}
emptyStateControls={
canAdd ? (
<ToolbarAddButton key="add" linkTo={`${match.url}/add`} />
) : null
}
/>
</Card>
</PageSection>
<AlertModal
isOpen={deletionError}
variant="danger"
title={i18n._(t`Error!`)}
onClose={this.handleDeleteErrorClose}
>
{i18n._(t`Failed to delete one or more projects.`)}
<ErrorDetail error={deletionError} />
</AlertModal>
</Fragment>
);
}
}
export { ProjectsList as _ProjectsList };
export default withI18n()(withRouter(ProjectsList));

View File

@ -0,0 +1,222 @@
import React from 'react';
import { ProjectsAPI } from '@api';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import ProjectsList, { _ProjectsList } from './ProjectList';
jest.mock('@api');
const mockProjects = [
{
id: 1,
name: 'Project 1',
url: '/api/v2/projects/1',
type: 'project',
scm_type: 'git',
summary_fields: {
last_job: {
id: 9000,
status: 'successful',
},
user_capabilities: {
delete: true,
update: true,
},
},
},
{
id: 2,
name: 'Project 2',
url: '/api/v2/projects/2',
type: 'project',
scm_type: 'svn',
summary_fields: {
last_job: {
id: 9002,
status: 'successful',
},
user_capabilities: {
delete: true,
update: true,
},
},
},
{
id: 3,
name: 'Project 3',
url: '/api/v2/projects/3',
type: 'project',
scm_type: 'insights',
summary_fields: {
last_job: {
id: 9003,
status: 'successful',
},
user_capabilities: {
delete: false,
update: false,
},
},
},
];
describe('<ProjectsList />', () => {
beforeEach(() => {
ProjectsAPI.read.mockResolvedValue({
data: {
count: mockProjects.length,
results: mockProjects,
},
});
ProjectsAPI.readOptions.mockResolvedValue({
data: {
actions: [],
},
});
});
afterEach(() => {
jest.clearAllMocks();
});
test('initially renders successfully', () => {
mountWithContexts(
<ProjectsList
match={{ path: '/projects', url: '/projects' }}
location={{ search: '', pathname: '/projects' }}
/>
);
});
test('Projects are retrieved from the api and the components finishes loading', async done => {
const loadProjects = jest.spyOn(_ProjectsList.prototype, 'loadProjects');
const wrapper = mountWithContexts(<ProjectsList />);
await waitForElement(
wrapper,
'ProjectsList',
el => el.state('hasContentLoading') === true
);
expect(loadProjects).toHaveBeenCalled();
await waitForElement(
wrapper,
'ProjectsList',
el => el.state('hasContentLoading') === false
);
done();
});
test('handleSelect is called when a project list item is selected', async done => {
const handleSelect = jest.spyOn(_ProjectsList.prototype, 'handleSelect');
const wrapper = mountWithContexts(<ProjectsList />);
await waitForElement(
wrapper,
'ProjectsList',
el => el.state('hasContentLoading') === false
);
await wrapper
.find('input#select-project-1')
.closest('DataListCheck')
.props()
.onChange();
expect(handleSelect).toBeCalled();
await waitForElement(
wrapper,
'ProjectsList',
el => el.state('selected').length === 1
);
done();
});
test('handleSelectAll is called when select all checkbox is clicked', async done => {
const handleSelectAll = jest.spyOn(
_ProjectsList.prototype,
'handleSelectAll'
);
const wrapper = mountWithContexts(<ProjectsList />);
await waitForElement(
wrapper,
'ProjectsList',
el => el.state('hasContentLoading') === false
);
wrapper
.find('Checkbox#select-all')
.props()
.onChange(true);
expect(handleSelectAll).toBeCalled();
await waitForElement(
wrapper,
'ProjectsList',
el => el.state('selected').length === 3
);
done();
});
test('delete button is disabled if user does not have delete capabilities on a selected project', async done => {
const wrapper = mountWithContexts(<ProjectsList />);
wrapper.find('ProjectsList').setState({
projects: mockProjects,
itemCount: 3,
isInitialized: true,
selected: mockProjects.slice(0, 1),
});
await waitForElement(
wrapper,
'ToolbarDeleteButton * button',
el => el.getDOMNode().disabled === false
);
wrapper.find('ProjectsList').setState({
selected: mockProjects,
});
await waitForElement(
wrapper,
'ToolbarDeleteButton * button',
el => el.getDOMNode().disabled === true
);
done();
});
test('api is called to delete projects for each selected project.', () => {
ProjectsAPI.destroy = jest.fn();
const wrapper = mountWithContexts(<ProjectsList />);
wrapper.find('ProjectsList').setState({
projects: mockProjects,
itemCount: 2,
isInitialized: true,
isModalOpen: true,
selected: mockProjects.slice(0, 2),
});
wrapper.find('ToolbarDeleteButton').prop('onDelete')();
expect(ProjectsAPI.destroy).toHaveBeenCalledTimes(2);
});
test('error is shown when project not successfully deleted from api', async done => {
ProjectsAPI.destroy.mockRejectedValue(
new Error({
response: {
config: {
method: 'delete',
url: '/api/v2/projects/1',
},
data: 'An error occurred',
},
})
);
const wrapper = mountWithContexts(<ProjectsList />);
wrapper.find('ProjectsList').setState({
projects: mockProjects,
itemCount: 1,
isInitialized: true,
isModalOpen: true,
selected: mockProjects.slice(0, 1),
});
wrapper.find('ToolbarDeleteButton').prop('onDelete')();
await waitForElement(
wrapper,
'Modal',
el => el.props().isOpen === true && el.props().title === 'Error!'
);
done();
});
});

View File

@ -0,0 +1,125 @@
import React, { Fragment } from 'react';
import { string, bool, func } from 'prop-types';
import { withI18n } from '@lingui/react';
import {
DataListItem,
DataListItemRow,
DataListItemCells,
Tooltip,
} from '@patternfly/react-core';
import { t } from '@lingui/macro';
import { Link as _Link } from 'react-router-dom';
import { SyncIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
import DataListCell from '@components/DataListCell';
import DataListCheck from '@components/DataListCheck';
import ListActionButton from '@components/ListActionButton';
import ProjectSyncButton from '../shared/ProjectSyncButton';
import { StatusIcon } from '@components/Sparkline';
import VerticalSeparator from '@components/VerticalSeparator';
import { Project } from '@types';
/* eslint-disable react/jsx-pascal-case */
const Link = styled(props => <_Link {...props} />)`
margin-right: 10px;
`;
/* eslint-enable react/jsx-pascal-case */
class ProjectListItem extends React.Component {
static propTypes = {
project: Project.isRequired,
detailUrl: string.isRequired,
isSelected: bool.isRequired,
onSelect: func.isRequired,
};
constructor(props) {
super(props);
this.generateLastJobTooltip = this.generateLastJobTooltip.bind(this);
}
generateLastJobTooltip = job => {
const { i18n } = this.props;
return (
<Fragment>
<div>{i18n._(t`MOST RECENT SYNC`)}</div>
<div>
{i18n._(t`JOB ID:`)} {job.id}
</div>
<div>
{i18n._(t`STATUS:`)} {job.status.toUpperCase()}
</div>
{job.finished && (
<div>
{i18n._(t`FINISHED:`)} {job.finished}
</div>
)}
</Fragment>
);
};
render() {
const { project, isSelected, onSelect, detailUrl, i18n } = this.props;
const labelId = `check-action-${project.id}`;
return (
<DataListItem key={project.id} aria-labelledby={labelId}>
<DataListItemRow>
<DataListCheck
id={`select-project-${project.id}`}
checked={isSelected}
onChange={onSelect}
aria-labelledby={labelId}
/>
<DataListItemCells
dataListCells={[
<DataListCell key="divider">
<VerticalSeparator />
{project.summary_fields.last_job && (
<Tooltip
position="top"
content={this.generateLastJobTooltip(
project.summary_fields.last_job
)}
key={project.summary_fields.last_job.id}
>
<Link
to={`/jobs/project/${project.summary_fields.last_job.id}`}
>
<StatusIcon
status={project.summary_fields.last_job.status}
/>
</Link>
</Tooltip>
)}
<span id={labelId}>
<Link to={`${detailUrl}`}>
<b>{project.name}</b>
</Link>
</span>
</DataListCell>,
<DataListCell key="type">
{project.scm_type.toUpperCase()}
</DataListCell>,
<DataListCell lastcolumn="true" key="action">
{project.summary_fields.user_capabilities.start && (
<Tooltip content={i18n._(t`Sync Project`)} position="top">
<ProjectSyncButton projectId={project.id}>
{handleSync => (
<ListActionButton variant="plain" onClick={handleSync}>
<SyncIcon />
</ListActionButton>
)}
</ProjectSyncButton>
</Tooltip>
)}
</DataListCell>,
]}
/>
</DataListItemRow>
</DataListItem>
);
}
}
export default withI18n()(ProjectListItem);

View File

@ -0,0 +1,60 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import ProjectsListItem from './ProjectListItem';
describe('<ProjectsListItem />', () => {
test('launch button shown to users with start capabilities', () => {
const wrapper = mountWithContexts(
<ProjectsListItem
isSelected={false}
detailUrl="/project/1"
onSelect={() => {}}
project={{
id: 1,
name: 'Project 1',
url: '/api/v2/projects/1',
type: 'project',
scm_type: 'git',
summary_fields: {
last_job: {
id: 9000,
status: 'successful',
},
user_capabilities: {
start: true,
},
},
}}
/>
);
expect(wrapper.find('ProjectSyncButton').exists()).toBeTruthy();
});
test('launch button hidden from users without start capabilities', () => {
const wrapper = mountWithContexts(
<ProjectsListItem
isSelected={false}
detailUrl="/project/1"
onSelect={() => {}}
project={{
id: 1,
name: 'Project 1',
url: '/api/v2/projects/1',
type: 'project',
scm_type: 'git',
summary_fields: {
last_job: {
id: 9000,
status: 'successful',
},
user_capabilities: {
start: false,
},
},
}}
/>
);
expect(wrapper.find('ProjectSyncButton').exists()).toBeFalsy();
});
});

View File

@ -0,0 +1,2 @@
export { default as ProjectList } from './ProjectList';
export { default as ProjectListItem } from './ProjectListItem';

View File

@ -0,0 +1,10 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
class ProjectNotifications extends Component {
render() {
return <CardBody>Coming soon :)</CardBody>;
}
}
export default ProjectNotifications;

View File

@ -0,0 +1 @@
export { default } from './ProjectNotifications';

View File

@ -0,0 +1,10 @@
import React, { Component } from 'react';
import { CardBody } from '@patternfly/react-core';
class ProjectSchedules extends Component {
render() {
return <CardBody>Coming soon :)</CardBody>;
}
}
export default ProjectSchedules;

View File

@ -0,0 +1 @@
export { default } from './ProjectSchedules';

View File

@ -1,26 +1,81 @@
import React, { Component, Fragment } from 'react';
import { Route, withRouter, Switch } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
PageSection,
PageSectionVariants,
Title,
} from '@patternfly/react-core';
import { Config } from '@contexts/Config';
import Breadcrumbs from '@components/Breadcrumbs/Breadcrumbs';
import ProjectsList from './ProjectList/ProjectList';
import ProjectAdd from './ProjectAdd/ProjectAdd';
import Project from './Project';
class Projects extends Component {
render() {
constructor(props) {
super(props);
const { i18n } = props;
this.state = {
breadcrumbConfig: {
'/projects': i18n._(t`Projects`),
'/projects/add': i18n._(t`Create New Project`),
},
};
}
setBreadcrumbConfig = project => {
const { i18n } = this.props;
const { light } = PageSectionVariants;
if (!project) {
return;
}
const breadcrumbConfig = {
'/projects': i18n._(t`Projects`),
'/projects/add': i18n._(t`Create New Project`),
[`/projects/${project.id}`]: `${project.name}`,
[`/projects/${project.id}/edit`]: i18n._(t`Edit Details`),
[`/projects/${project.id}/details`]: i18n._(t`Details`),
[`/projects/${project.id}/access`]: i18n._(t`Access`),
[`/projects/${project.id}/notifications`]: i18n._(t`Notifications`),
[`/projects/${project.id}/job_templates`]: i18n._(t`Job Templates`),
[`/projects/${project.id}/schedules`]: i18n._(t`Schedules`),
};
this.setState({ breadcrumbConfig });
};
render() {
const { match, history, location } = this.props;
const { breadcrumbConfig } = this.state;
return (
<Fragment>
<PageSection variant={light} className="pf-m-condensed">
<Title size="2xl">{i18n._(t`Projects`)}</Title>
</PageSection>
<PageSection />
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
<Switch>
<Route path={`${match.path}/add`} render={() => <ProjectAdd />} />
<Route
path={`${match.path}/:id`}
render={() => (
<Config>
{({ me }) => (
<Project
history={history}
location={location}
setBreadcrumb={this.setBreadcrumbConfig}
me={me || {}}
/>
)}
</Config>
)}
/>
<Route path={`${match.path}`} render={() => <ProjectsList />} />
</Switch>
</Fragment>
);
}
}
export default withI18n()(Projects);
export { Projects as _Projects };
export default withI18n()(withRouter(Projects));

View File

@ -1,29 +1,33 @@
import React from 'react';
import { createMemoryHistory } from 'history';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import Projects from './Projects';
describe('<Projects />', () => {
let pageWrapper;
let pageSections;
let title;
beforeEach(() => {
pageWrapper = mountWithContexts(<Projects />);
pageSections = pageWrapper.find('PageSection');
title = pageWrapper.find('Title');
test('initially renders succesfully', () => {
mountWithContexts(<Projects />);
});
afterEach(() => {
pageWrapper.unmount();
});
test('should display a breadcrumb heading', () => {
const history = createMemoryHistory({
initialEntries: ['/projects'],
});
const match = { path: '/projects', url: '/projects', isExact: true };
test('initially renders without crashing', () => {
expect(pageWrapper.length).toBe(1);
expect(pageSections.length).toBe(2);
expect(title.length).toBe(1);
expect(title.props().size).toBe('2xl');
expect(pageSections.first().props().variant).toBe('light');
const wrapper = mountWithContexts(<Projects />, {
context: {
router: {
history,
route: {
location: history.location,
match,
},
},
},
});
expect(wrapper.find('BreadcrumbHeading').length).toBe(1);
wrapper.unmount();
});
});

View File

@ -0,0 +1,115 @@
{
"id": 6,
"type": "project",
"url": "/api/v2/projects/6/",
"related": {
"named_url": "/api/v2/projects/Mike's Project++Default/",
"created_by": "/api/v2/users/1/",
"modified_by": "/api/v2/users/1/",
"last_job": "/api/v2/project_updates/8/",
"teams": "/api/v2/projects/6/teams/",
"playbooks": "/api/v2/projects/6/playbooks/",
"inventory_files": "/api/v2/projects/6/inventories/",
"update": "/api/v2/projects/6/update/",
"project_updates": "/api/v2/projects/6/project_updates/",
"scm_inventory_sources": "/api/v2/projects/6/scm_inventory_sources/",
"schedules": "/api/v2/projects/6/schedules/",
"activity_stream": "/api/v2/projects/6/activity_stream/",
"notification_templates_started": "/api/v2/projects/6/notification_templates_started/",
"notification_templates_success": "/api/v2/projects/6/notification_templates_success/",
"notification_templates_error": "/api/v2/projects/6/notification_templates_error/",
"access_list": "/api/v2/projects/6/access_list/",
"object_roles": "/api/v2/projects/6/object_roles/",
"copy": "/api/v2/projects/6/copy/",
"organization": "/api/v2/organizations/1/",
"last_update": "/api/v2/project_updates/8/"
},
"summary_fields": {
"organization": {
"id": 1,
"name": "Default",
"description": ""
},
"last_job": {
"id": 8,
"name": "Mike's Project",
"description": "",
"finished": "2019-09-30T18:06:34.713654Z",
"status": "successful",
"failed": false
},
"last_update": {
"id": 8,
"name": "Mike's Project",
"description": "",
"status": "successful",
"failed": false
},
"created_by": {
"id": 1,
"username": "admin",
"first_name": "",
"last_name": ""
},
"modified_by": {
"id": 1,
"username": "admin",
"first_name": "",
"last_name": ""
},
"object_roles": {
"admin_role": {
"description": "Can manage all aspects of the project",
"name": "Admin",
"id": 20
},
"use_role": {
"description": "Can use the project in a job template",
"name": "Use",
"id": 21
},
"update_role": {
"description": "May update the project",
"name": "Update",
"id": 22
},
"read_role": {
"description": "May view settings for the project",
"name": "Read",
"id": 23
}
},
"user_capabilities": {
"edit": true,
"delete": true,
"start": true,
"schedule": true,
"copy": true
}
},
"created": "2019-09-30T16:17:37.956673Z",
"modified": "2019-09-30T16:17:37.956705Z",
"name": "Mike's Project",
"description": "",
"local_path": "_6__mikes_project",
"scm_type": "git",
"scm_url": "https://github.com/ansible/test-playbooks",
"scm_branch": "",
"scm_refspec": "",
"scm_clean": false,
"scm_delete_on_update": false,
"credential": null,
"timeout": 0,
"scm_revision": "f5de82382e756b87143f3511c7c6c006d941830d",
"last_job_run": "2019-09-30T18:06:34.713654Z",
"last_job_failed": false,
"next_job_run": null,
"status": "successful",
"organization": 1,
"scm_update_on_launch": false,
"scm_update_cache_timeout": 0,
"allow_override": false,
"custom_virtualenv": null,
"last_update_failed": false,
"last_updated": "2019-09-30T18:06:34.713654Z"
}

View File

@ -0,0 +1,70 @@
import React, { Fragment } from 'react';
import { number } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import AlertModal from '@components/AlertModal';
import ErrorDetail from '@components/ErrorDetail';
import { ProjectsAPI } from '@api';
class ProjectSyncButton extends React.Component {
static propTypes = {
projectId: number.isRequired,
};
constructor(props) {
super(props);
this.state = {
syncError: null,
};
this.handleSync = this.handleSync.bind(this);
this.handleSyncErrorClose = this.handleSyncErrorClose.bind(this);
}
handleSyncErrorClose() {
this.setState({ syncError: null });
}
async handleSync() {
const { i18n, projectId } = this.props;
try {
const { data: syncConfig } = await ProjectsAPI.readSync(projectId);
if (syncConfig.can_update) {
await ProjectsAPI.sync(projectId);
} else {
this.setState({
syncError: i18n._(
t`You don't have the necessary permissions to sync this project.`
),
});
}
} catch (err) {
this.setState({ syncError: err });
}
}
render() {
const { syncError } = this.state;
const { i18n, children } = this.props;
return (
<Fragment>
{children(this.handleSync)}
{syncError && (
<AlertModal
isOpen={syncError}
variant="danger"
title={i18n._(t`Error!`)}
onClose={this.handleSyncErrorClose}
>
{i18n._(t`Failed to sync job.`)}
<ErrorDetail error={syncError} />
</AlertModal>
)}
</Fragment>
);
}
}
export default withI18n()(ProjectSyncButton);

View File

@ -0,0 +1,70 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { sleep } from '@testUtils/testUtils';
import ProjectSyncButton from './ProjectSyncButton';
import { ProjectsAPI } from '@api';
jest.mock('@api');
describe('ProjectSyncButton', () => {
ProjectsAPI.readSync.mockResolvedValue({
data: {
can_update: true,
},
});
const children = handleSync => (
<button type="submit" onClick={() => handleSync()} />
);
test('renders the expected content', () => {
const wrapper = mountWithContexts(
<ProjectSyncButton projectId={1}>{children}</ProjectSyncButton>
);
expect(wrapper).toHaveLength(1);
});
test('correct api calls are made on sync', async done => {
ProjectsAPI.sync.mockResolvedValue({
data: {
id: 9000,
},
});
const wrapper = mountWithContexts(
<ProjectSyncButton projectId={1}>{children}</ProjectSyncButton>
);
const button = wrapper.find('button');
button.prop('onClick')();
expect(ProjectsAPI.readSync).toHaveBeenCalledWith(1);
await sleep(0);
expect(ProjectsAPI.sync).toHaveBeenCalledWith(1);
done();
});
test('displays error modal after unsuccessful sync', async done => {
ProjectsAPI.sync.mockRejectedValue(
new Error({
response: {
config: {
method: 'post',
url: '/api/v2/projects/1/update',
},
data: 'An error occurred',
status: 403,
},
})
);
const wrapper = mountWithContexts(
<ProjectSyncButton projectId={1}>{children}</ProjectSyncButton>
);
expect(wrapper.find('Modal').length).toBe(0);
wrapper.find('button').prop('onClick')();
await sleep(0);
wrapper.update();
expect(wrapper.find('Modal').length).toBe(1);
wrapper.find('ModalBoxCloseButton').simulate('click');
await sleep(0);
wrapper.update();
expect(wrapper.find('Modal').length).toBe(0);
done();
});
});

View File

@ -0,0 +1 @@
export { default } from './ProjectSyncButton';

View File

@ -5,28 +5,19 @@ import {
DataListItemRow,
DataListItemCells as PFDataListItemCells,
Tooltip,
Button as PFButton,
} from '@patternfly/react-core';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
import { RocketIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
import DataListCell from '@components/DataListCell';
import DataListCheck from '@components/DataListCheck';
import LaunchButton from '@components/LaunchButton';
import ListActionButton from '@components/ListActionButton';
import VerticalSeparator from '@components/VerticalSeparator';
import { Sparkline } from '@components/Sparkline';
import { toTitleCase } from '@util/strings';
const StyledButton = styled(PFButton)`
padding: 5px 8px;
border: none;
&:hover {
background-color: #0066cc;
color: white;
}
`;
const DataListItemCells = styled(PFDataListItemCells)`
display: flex;
@media screen and (max-width: 768px) {
@ -101,12 +92,15 @@ class TemplateListItem extends Component {
key="launch"
>
{canLaunch && template.type === 'job_template' && (
<Tooltip content={i18n._(t`Launch`)} position="top">
<Tooltip content={i18n._(t`Launch Template`)} position="top">
<LaunchButton resource={template}>
{({ handleLaunch }) => (
<StyledButton variant="plain" onClick={handleLaunch}>
<ListActionButton
variant="plain"
onClick={handleLaunch}
>
<RocketIcon />
</StyledButton>
</ListActionButton>
)}
</LaunchButton>
</Tooltip>

View File

@ -0,0 +1,121 @@
{
"id": 1,
"type": "organization",
"url": "/api/v2/organizations/1/",
"related": {
"named_url": "/api/v2/organizations/Default/",
"created_by": "/api/v2/users/1/",
"modified_by": "/api/v2/users/1/",
"projects": "/api/v2/organizations/1/projects/",
"inventories": "/api/v2/organizations/1/inventories/",
"workflow_job_templates": "/api/v2/organizations/1/workflow_job_templates/",
"users": "/api/v2/organizations/1/users/",
"admins": "/api/v2/organizations/1/admins/",
"teams": "/api/v2/organizations/1/teams/",
"credentials": "/api/v2/organizations/1/credentials/",
"applications": "/api/v2/organizations/1/applications/",
"activity_stream": "/api/v2/organizations/1/activity_stream/",
"notification_templates": "/api/v2/organizations/1/notification_templates/",
"notification_templates_started": "/api/v2/organizations/1/notification_templates_started/",
"notification_templates_success": "/api/v2/organizations/1/notification_templates_success/",
"notification_templates_error": "/api/v2/organizations/1/notification_templates_error/",
"notification_templates_approvals": "/api/v2/organizations/1/notification_templates_approvals/",
"object_roles": "/api/v2/organizations/1/object_roles/",
"access_list": "/api/v2/organizations/1/access_list/",
"instance_groups": "/api/v2/organizations/1/instance_groups/"
},
"summary_fields": {
"created_by": {
"id": 1,
"username": "admin",
"first_name": "",
"last_name": ""
},
"modified_by": {
"id": 1,
"username": "admin",
"first_name": "",
"last_name": ""
},
"object_roles": {
"admin_role": {
"description": "Can manage all aspects of the organization",
"name": "Admin",
"id": 2
},
"execute_role": {
"description": "May run any executable resources in the organization",
"name": "Execute",
"id": 3
},
"project_admin_role": {
"description": "Can manage all projects of the organization",
"name": "Project Admin",
"id": 4
},
"inventory_admin_role": {
"description": "Can manage all inventories of the organization",
"name": "Inventory Admin",
"id": 5
},
"credential_admin_role": {
"description": "Can manage all credentials of the organization",
"name": "Credential Admin",
"id": 6
},
"workflow_admin_role": {
"description": "Can manage all workflows of the organization",
"name": "Workflow Admin",
"id": 7
},
"notification_admin_role": {
"description": "Can manage all notifications of the organization",
"name": "Notification Admin",
"id": 8
},
"job_template_admin_role": {
"description": "Can manage all job templates of the organization",
"name": "Job Template Admin",
"id": 9
},
"auditor_role": {
"description": "Can view all aspects of the organization",
"name": "Auditor",
"id": 10
},
"member_role": {
"description": "User is a member of the organization",
"name": "Member",
"id": 11
},
"read_role": {
"description": "May view settings for the organization",
"name": "Read",
"id": 12
},
"approval_role": {
"description": "Can approve or deny a workflow approval node",
"name": "Approve",
"id": 13
}
},
"user_capabilities": {
"edit": true,
"delete": true
},
"related_field_counts": {
"users": 2,
"admins": 1,
"inventories": 1,
"teams": 0,
"projects": 1,
"job_templates": 2
}
},
"created": "2019-09-30T16:16:45.952981Z",
"modified": "2019-09-30T16:16:45.953010Z",
"name": "Default",
"description": "",
"max_hosts": 0,
"custom_virtualenv": null
}