mirror of
https://github.com/ansible/awx.git
synced 2026-02-26 15:36:04 -03:30
Merge pull request #4859 from mabashian/ui-next-projects
Add projects list and scaffolding for project details+tabs Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
@@ -7,11 +7,21 @@ class Projects extends LaunchUpdateMixin(Base) {
|
|||||||
this.baseUrl = '/api/v2/projects/';
|
this.baseUrl = '/api/v2/projects/';
|
||||||
|
|
||||||
this.readPlaybooks = this.readPlaybooks.bind(this);
|
this.readPlaybooks = this.readPlaybooks.bind(this);
|
||||||
|
this.readSync = this.readSync.bind(this);
|
||||||
|
this.sync = this.sync.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
readPlaybooks(id) {
|
readPlaybooks(id) {
|
||||||
return this.http.get(`${this.baseUrl}${id}/playbooks/`);
|
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;
|
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,
|
DataListItemRow,
|
||||||
DataListItemCells,
|
DataListItemCells,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Button as PFButton,
|
|
||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
import { RocketIcon } from '@patternfly/react-icons';
|
import { RocketIcon } from '@patternfly/react-icons';
|
||||||
import styled from 'styled-components';
|
|
||||||
import DataListCell from '@components/DataListCell';
|
import DataListCell from '@components/DataListCell';
|
||||||
import DataListCheck from '@components/DataListCheck';
|
import DataListCheck from '@components/DataListCheck';
|
||||||
import LaunchButton from '@components/LaunchButton';
|
import LaunchButton from '@components/LaunchButton';
|
||||||
|
import ListActionButton from '@components/ListActionButton';
|
||||||
import VerticalSeparator from '@components/VerticalSeparator';
|
import VerticalSeparator from '@components/VerticalSeparator';
|
||||||
import { toTitleCase } from '@util/strings';
|
import { toTitleCase } from '@util/strings';
|
||||||
import { JOB_TYPE_URL_SEGMENTS } from '../../../constants';
|
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 {
|
class JobListItem extends Component {
|
||||||
render() {
|
render() {
|
||||||
const { i18n, job, isSelected, onSelect } = this.props;
|
const { i18n, job, isSelected, onSelect } = this.props;
|
||||||
@@ -60,15 +50,15 @@ class JobListItem extends Component {
|
|||||||
<DataListCell lastcolumn="true" key="relaunch">
|
<DataListCell lastcolumn="true" key="relaunch">
|
||||||
{job.type !== 'system_job' &&
|
{job.type !== 'system_job' &&
|
||||||
job.summary_fields.user_capabilities.start && (
|
job.summary_fields.user_capabilities.start && (
|
||||||
<Tooltip content={i18n._(t`Relaunch`)} position="top">
|
<Tooltip content={i18n._(t`Relaunch Job`)} position="top">
|
||||||
<LaunchButton resource={job}>
|
<LaunchButton resource={job}>
|
||||||
{({ handleRelaunch }) => (
|
{({ handleRelaunch }) => (
|
||||||
<StyledButton
|
<ListActionButton
|
||||||
variant="plain"
|
variant="plain"
|
||||||
onClick={handleRelaunch}
|
onClick={handleRelaunch}
|
||||||
>
|
>
|
||||||
<RocketIcon />
|
<RocketIcon />
|
||||||
</StyledButton>
|
</ListActionButton>
|
||||||
)}
|
)}
|
||||||
</LaunchButton>
|
</LaunchButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import React from 'react';
|
|||||||
import { OrganizationsAPI } from '@api';
|
import { OrganizationsAPI } from '@api';
|
||||||
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
||||||
|
|
||||||
|
import mockOrganization from '@util/data.organization.json';
|
||||||
|
|
||||||
import Organization from './Organization';
|
import Organization from './Organization';
|
||||||
|
|
||||||
jest.mock('@api');
|
jest.mock('@api');
|
||||||
@@ -12,192 +14,17 @@ const mockMe = {
|
|||||||
is_system_auditor: false,
|
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) {
|
async function getOrganizations(params) {
|
||||||
let results = allOrganizations;
|
let results = [];
|
||||||
if (params && params.role_level) {
|
if (params && params.role_level) {
|
||||||
if (params.role_level === 'admin_role') {
|
if (params.role_level === 'admin_role') {
|
||||||
results = [adminOrganization];
|
results = [mockOrganization];
|
||||||
}
|
}
|
||||||
if (params.role_level === 'auditor_role') {
|
if (params.role_level === 'auditor_role') {
|
||||||
results = [auditorOrganization];
|
results = [mockOrganization];
|
||||||
}
|
}
|
||||||
if (params.role_level === 'notification_admin_role') {
|
if (params.role_level === 'notification_admin_role') {
|
||||||
results = [notificationAdminOrganization];
|
results = [mockOrganization];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -210,13 +37,13 @@ async function getOrganizations(params) {
|
|||||||
|
|
||||||
describe.only('<Organization />', () => {
|
describe.only('<Organization />', () => {
|
||||||
test('initially renders succesfully', () => {
|
test('initially renders succesfully', () => {
|
||||||
OrganizationsAPI.readDetail.mockResolvedValue(mockDetails);
|
OrganizationsAPI.readDetail.mockResolvedValue({ data: mockOrganization });
|
||||||
OrganizationsAPI.read.mockImplementation(getOrganizations);
|
OrganizationsAPI.read.mockImplementation(getOrganizations);
|
||||||
mountWithContexts(<Organization setBreadcrumb={() => {}} me={mockMe} />);
|
mountWithContexts(<Organization setBreadcrumb={() => {}} me={mockMe} />);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('notifications tab shown for admins', async done => {
|
test('notifications tab shown for admins', async done => {
|
||||||
OrganizationsAPI.readDetail.mockResolvedValue(mockDetails);
|
OrganizationsAPI.readDetail.mockResolvedValue({ data: mockOrganization });
|
||||||
OrganizationsAPI.read.mockImplementation(getOrganizations);
|
OrganizationsAPI.read.mockImplementation(getOrganizations);
|
||||||
|
|
||||||
const wrapper = mountWithContexts(
|
const wrapper = mountWithContexts(
|
||||||
@@ -232,8 +59,13 @@ describe.only('<Organization />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('notifications tab hidden with reduced permissions', async done => {
|
test('notifications tab hidden with reduced permissions', async done => {
|
||||||
OrganizationsAPI.readDetail.mockResolvedValue(mockDetails);
|
OrganizationsAPI.readDetail.mockResolvedValue({ data: mockOrganization });
|
||||||
OrganizationsAPI.read.mockResolvedValue(mockNoResults);
|
OrganizationsAPI.read.mockResolvedValue({
|
||||||
|
count: 0,
|
||||||
|
next: null,
|
||||||
|
previous: null,
|
||||||
|
data: { results: [] },
|
||||||
|
});
|
||||||
|
|
||||||
const wrapper = mountWithContexts(
|
const wrapper = mountWithContexts(
|
||||||
<Organization setBreadcrumb={() => {}} me={mockMe} />
|
<Organization setBreadcrumb={() => {}} me={mockMe} />
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import OrganizationListItem from './OrganizationListItem';
|
|||||||
|
|
||||||
const QS_CONFIG = getQSConfig('organization', {
|
const QS_CONFIG = getQSConfig('organization', {
|
||||||
page: 1,
|
page: 1,
|
||||||
page_size: 5,
|
page_size: 20,
|
||||||
order_by: 'name',
|
order_by: 'name',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
273
awx/ui_next/src/screens/Project/Project.jsx
Normal file
273
awx/ui_next/src/screens/Project/Project.jsx
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
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` },
|
||||||
|
{ name: i18n._(t`Access`), link: `${match.url}/access` },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (canSeeNotificationsTab) {
|
||||||
|
tabsArray.push({
|
||||||
|
name: i18n._(t`Notifications`),
|
||||||
|
link: `${match.url}/notifications`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tabsArray.push(
|
||||||
|
{
|
||||||
|
name: i18n._(t`Job Templates`),
|
||||||
|
link: `${match.url}/job_templates`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: i18n._(t`Schedules`),
|
||||||
|
link: `${match.url}/schedules`,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
tabsArray.forEach((tab, n) => {
|
||||||
|
tab.id = n;
|
||||||
|
});
|
||||||
|
|
||||||
|
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.at(2).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';
|
||||||
229
awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx
Normal file
229
awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
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: 20,
|
||||||
|
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.handleProjectDelete = this.handleProjectDelete.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 handleProjectDelete() {
|
||||||
|
const { selected } = this.state;
|
||||||
|
|
||||||
|
this.setState({ hasContentLoading: true });
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
selected.map(project => ProjectsAPI.destroy(project.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={i18n._(t`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.handleProjectDelete}
|
||||||
|
itemsToDelete={selected}
|
||||||
|
pluralizedItemName={i18n._(t`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 React, { Component, Fragment } from 'react';
|
||||||
|
import { Route, withRouter, Switch } from 'react-router-dom';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import {
|
|
||||||
PageSection,
|
import { Config } from '@contexts/Config';
|
||||||
PageSectionVariants,
|
import Breadcrumbs from '@components/Breadcrumbs/Breadcrumbs';
|
||||||
Title,
|
|
||||||
} from '@patternfly/react-core';
|
import ProjectsList from './ProjectList/ProjectList';
|
||||||
|
import ProjectAdd from './ProjectAdd/ProjectAdd';
|
||||||
|
import Project from './Project';
|
||||||
|
|
||||||
class Projects extends Component {
|
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 { 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 (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<PageSection variant={light} className="pf-m-condensed">
|
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
|
||||||
<Title size="2xl">{i18n._(t`Projects`)}</Title>
|
<Switch>
|
||||||
</PageSection>
|
<Route path={`${match.path}/add`} render={() => <ProjectAdd />} />
|
||||||
<PageSection />
|
<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>
|
</Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withI18n()(Projects);
|
export { Projects as _Projects };
|
||||||
|
export default withI18n()(withRouter(Projects));
|
||||||
|
|||||||
@@ -1,29 +1,33 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { createMemoryHistory } from 'history';
|
||||||
|
|
||||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||||
|
|
||||||
import Projects from './Projects';
|
import Projects from './Projects';
|
||||||
|
|
||||||
describe('<Projects />', () => {
|
describe('<Projects />', () => {
|
||||||
let pageWrapper;
|
test('initially renders succesfully', () => {
|
||||||
let pageSections;
|
mountWithContexts(<Projects />);
|
||||||
let title;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
pageWrapper = mountWithContexts(<Projects />);
|
|
||||||
pageSections = pageWrapper.find('PageSection');
|
|
||||||
title = pageWrapper.find('Title');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
test('should display a breadcrumb heading', () => {
|
||||||
pageWrapper.unmount();
|
const history = createMemoryHistory({
|
||||||
});
|
initialEntries: ['/projects'],
|
||||||
|
});
|
||||||
|
const match = { path: '/projects', url: '/projects', isExact: true };
|
||||||
|
|
||||||
test('initially renders without crashing', () => {
|
const wrapper = mountWithContexts(<Projects />, {
|
||||||
expect(pageWrapper.length).toBe(1);
|
context: {
|
||||||
expect(pageSections.length).toBe(2);
|
router: {
|
||||||
expect(title.length).toBe(1);
|
history,
|
||||||
expect(title.props().size).toBe('2xl');
|
route: {
|
||||||
expect(pageSections.first().props().variant).toBe('light');
|
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';
|
||||||
@@ -89,15 +89,14 @@ class Template extends Component {
|
|||||||
const canSeeNotificationsTab = me.is_system_auditor || isNotifAdmin;
|
const canSeeNotificationsTab = me.is_system_auditor || isNotifAdmin;
|
||||||
|
|
||||||
const tabsArray = [
|
const tabsArray = [
|
||||||
{ name: i18n._(t`Details`), link: `${match.url}/details`, id: 0 },
|
{ name: i18n._(t`Details`), link: `${match.url}/details` },
|
||||||
{ name: i18n._(t`Access`), link: '/home', id: 1 },
|
{ name: i18n._(t`Access`), link: '/home' },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (canSeeNotificationsTab) {
|
if (canSeeNotificationsTab) {
|
||||||
tabsArray.push({
|
tabsArray.push({
|
||||||
name: i18n._(t`Notifications`),
|
name: i18n._(t`Notifications`),
|
||||||
link: `${match.url}/notifications`,
|
link: `${match.url}/notifications`,
|
||||||
id: 2,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,20 +104,21 @@ class Template extends Component {
|
|||||||
{
|
{
|
||||||
name: i18n._(t`Schedules`),
|
name: i18n._(t`Schedules`),
|
||||||
link: '/home',
|
link: '/home',
|
||||||
id: canSeeNotificationsTab ? 3 : 2,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: i18n._(t`Completed Jobs`),
|
name: i18n._(t`Completed Jobs`),
|
||||||
link: '/home',
|
link: '/home',
|
||||||
id: canSeeNotificationsTab ? 4 : 3,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: i18n._(t`Survey`),
|
name: i18n._(t`Survey`),
|
||||||
link: '/home',
|
link: '/home',
|
||||||
id: canSeeNotificationsTab ? 5 : 4,
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
tabsArray.forEach((tab, n) => {
|
||||||
|
tab.id = n;
|
||||||
|
});
|
||||||
|
|
||||||
let cardHeader = hasContentLoading ? null : (
|
let cardHeader = hasContentLoading ? null : (
|
||||||
<CardHeader style={{ padding: 0 }}>
|
<CardHeader style={{ padding: 0 }}>
|
||||||
<RoutedTabs history={history} tabsArray={tabsArray} />
|
<RoutedTabs history={history} tabsArray={tabsArray} />
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import TemplateListItem from './TemplateListItem';
|
|||||||
// workflow_job_template so the params sent to the API match what the api expects.
|
// workflow_job_template so the params sent to the API match what the api expects.
|
||||||
const QS_CONFIG = getQSConfig('template', {
|
const QS_CONFIG = getQSConfig('template', {
|
||||||
page: 1,
|
page: 1,
|
||||||
page_size: 5,
|
page_size: 20,
|
||||||
order_by: 'name',
|
order_by: 'name',
|
||||||
type: 'job_template,workflow_job_template',
|
type: 'job_template,workflow_job_template',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,28 +5,21 @@ import {
|
|||||||
DataListItemRow,
|
DataListItemRow,
|
||||||
DataListItemCells as PFDataListItemCells,
|
DataListItemCells as PFDataListItemCells,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Button as PFButton,
|
|
||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { RocketIcon } from '@patternfly/react-icons';
|
import { RocketIcon } from '@patternfly/react-icons';
|
||||||
import styled from 'styled-components';
|
|
||||||
|
|
||||||
import DataListCell from '@components/DataListCell';
|
import DataListCell from '@components/DataListCell';
|
||||||
import DataListCheck from '@components/DataListCheck';
|
import DataListCheck from '@components/DataListCheck';
|
||||||
import LaunchButton from '@components/LaunchButton';
|
import LaunchButton from '@components/LaunchButton';
|
||||||
|
import ListActionButton from '@components/ListActionButton';
|
||||||
import VerticalSeparator from '@components/VerticalSeparator';
|
import VerticalSeparator from '@components/VerticalSeparator';
|
||||||
import { Sparkline } from '@components/Sparkline';
|
import { Sparkline } from '@components/Sparkline';
|
||||||
import { toTitleCase } from '@util/strings';
|
import { toTitleCase } from '@util/strings';
|
||||||
|
|
||||||
const StyledButton = styled(PFButton)`
|
import styled from 'styled-components';
|
||||||
padding: 5px 8px;
|
|
||||||
border: none;
|
|
||||||
&:hover {
|
|
||||||
background-color: #0066cc;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
const DataListItemCells = styled(PFDataListItemCells)`
|
const DataListItemCells = styled(PFDataListItemCells)`
|
||||||
display: flex;
|
display: flex;
|
||||||
@media screen and (max-width: 768px) {
|
@media screen and (max-width: 768px) {
|
||||||
@@ -101,12 +94,15 @@ class TemplateListItem extends Component {
|
|||||||
key="launch"
|
key="launch"
|
||||||
>
|
>
|
||||||
{canLaunch && template.type === 'job_template' && (
|
{canLaunch && template.type === 'job_template' && (
|
||||||
<Tooltip content={i18n._(t`Launch`)} position="top">
|
<Tooltip content={i18n._(t`Launch Template`)} position="top">
|
||||||
<LaunchButton resource={template}>
|
<LaunchButton resource={template}>
|
||||||
{({ handleLaunch }) => (
|
{({ handleLaunch }) => (
|
||||||
<StyledButton variant="plain" onClick={handleLaunch}>
|
<ListActionButton
|
||||||
|
variant="plain"
|
||||||
|
onClick={handleLaunch}
|
||||||
|
>
|
||||||
<RocketIcon />
|
<RocketIcon />
|
||||||
</StyledButton>
|
</ListActionButton>
|
||||||
)}
|
)}
|
||||||
</LaunchButton>
|
</LaunchButton>
|
||||||
</Tooltip>
|
</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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user