mirror of
https://github.com/ansible/awx.git
synced 2026-01-13 11:00:03 -03:30
Add projects list and scaffolding for project details+tabs
This commit is contained in:
parent
8d3ecf708b
commit
b8fe3f648e
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
`;
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
1
awx/ui_next/src/components/ListActionButton/index.js
Normal file
1
awx/ui_next/src/components/ListActionButton/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './ListActionButton';
|
||||
@ -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>
|
||||
|
||||
@ -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} />
|
||||
|
||||
265
awx/ui_next/src/screens/Project/Project.jsx
Normal file
265
awx/ui_next/src/screens/Project/Project.jsx
Normal 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 };
|
||||
72
awx/ui_next/src/screens/Project/Project.test.jsx
Normal file
72
awx/ui_next/src/screens/Project/Project.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
1
awx/ui_next/src/screens/Project/ProjectAccess/index.js
Normal file
1
awx/ui_next/src/screens/Project/ProjectAccess/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './ProjectAccess';
|
||||
10
awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.jsx
Normal file
10
awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.jsx
Normal 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;
|
||||
1
awx/ui_next/src/screens/Project/ProjectAdd/index.js
Normal file
1
awx/ui_next/src/screens/Project/ProjectAdd/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './ProjectAdd';
|
||||
@ -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;
|
||||
1
awx/ui_next/src/screens/Project/ProjectDetail/index.js
Normal file
1
awx/ui_next/src/screens/Project/ProjectDetail/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './ProjectDetail';
|
||||
10
awx/ui_next/src/screens/Project/ProjectEdit/ProjectEdit.jsx
Normal file
10
awx/ui_next/src/screens/Project/ProjectEdit/ProjectEdit.jsx
Normal 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;
|
||||
1
awx/ui_next/src/screens/Project/ProjectEdit/index.js
Normal file
1
awx/ui_next/src/screens/Project/ProjectEdit/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './ProjectEdit';
|
||||
@ -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;
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './ProjectJobTemplates';
|
||||
227
awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx
Normal file
227
awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx
Normal 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));
|
||||
222
awx/ui_next/src/screens/Project/ProjectList/ProjectList.test.jsx
Normal file
222
awx/ui_next/src/screens/Project/ProjectList/ProjectList.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
125
awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx
Normal file
125
awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx
Normal 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);
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
2
awx/ui_next/src/screens/Project/ProjectList/index.js
Normal file
2
awx/ui_next/src/screens/Project/ProjectList/index.js
Normal file
@ -0,0 +1,2 @@
|
||||
export { default as ProjectList } from './ProjectList';
|
||||
export { default as ProjectListItem } from './ProjectListItem';
|
||||
@ -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;
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './ProjectNotifications';
|
||||
@ -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;
|
||||
@ -0,0 +1 @@
|
||||
export { default } from './ProjectSchedules';
|
||||
@ -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));
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
115
awx/ui_next/src/screens/Project/data.project.json
Normal file
115
awx/ui_next/src/screens/Project/data.project.json
Normal 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"
|
||||
}
|
||||
70
awx/ui_next/src/screens/Project/shared/ProjectSyncButton.jsx
Normal file
70
awx/ui_next/src/screens/Project/shared/ProjectSyncButton.jsx
Normal 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);
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
1
awx/ui_next/src/screens/Project/shared/index.js
Normal file
1
awx/ui_next/src/screens/Project/shared/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './ProjectSyncButton';
|
||||
@ -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>
|
||||
|
||||
121
awx/ui_next/src/util/data.organization.json
Normal file
121
awx/ui_next/src/util/data.organization.json
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user