diff --git a/awx/ui_next/src/api/models/Projects.js b/awx/ui_next/src/api/models/Projects.js index 14a6048b0a..55ab141df6 100644 --- a/awx/ui_next/src/api/models/Projects.js +++ b/awx/ui_next/src/api/models/Projects.js @@ -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; diff --git a/awx/ui_next/src/components/ListActionButton/ListActionButton.jsx b/awx/ui_next/src/components/ListActionButton/ListActionButton.jsx new file mode 100644 index 0000000000..3081b4637f --- /dev/null +++ b/awx/ui_next/src/components/ListActionButton/ListActionButton.jsx @@ -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; + } +`; diff --git a/awx/ui_next/src/components/ListActionButton/ListActionButton.test.jsx b/awx/ui_next/src/components/ListActionButton/ListActionButton.test.jsx new file mode 100644 index 0000000000..cab3534a69 --- /dev/null +++ b/awx/ui_next/src/components/ListActionButton/ListActionButton.test.jsx @@ -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(); + expect(wrapper).toHaveLength(1); + }); +}); diff --git a/awx/ui_next/src/components/ListActionButton/index.js b/awx/ui_next/src/components/ListActionButton/index.js new file mode 100644 index 0000000000..de229f6c76 --- /dev/null +++ b/awx/ui_next/src/components/ListActionButton/index.js @@ -0,0 +1 @@ +export { default } from './ListActionButton'; diff --git a/awx/ui_next/src/screens/Job/JobList/JobListItem.jsx b/awx/ui_next/src/screens/Job/JobList/JobListItem.jsx index 495edc0d0b..0c61b1ee7a 100644 --- a/awx/ui_next/src/screens/Job/JobList/JobListItem.jsx +++ b/awx/ui_next/src/screens/Job/JobList/JobListItem.jsx @@ -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 { {job.type !== 'system_job' && job.summary_fields.user_capabilities.start && ( - + {({ handleRelaunch }) => ( - - + )} diff --git a/awx/ui_next/src/screens/Organization/Organization.test.jsx b/awx/ui_next/src/screens/Organization/Organization.test.jsx index 17d8e30298..8c13bb78a1 100644 --- a/awx/ui_next/src/screens/Organization/Organization.test.jsx +++ b/awx/ui_next/src/screens/Organization/Organization.test.jsx @@ -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('', () => { test('initially renders succesfully', () => { - OrganizationsAPI.readDetail.mockResolvedValue(mockDetails); + OrganizationsAPI.readDetail.mockResolvedValue({ data: mockOrganization }); OrganizationsAPI.read.mockImplementation(getOrganizations); mountWithContexts( {}} 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('', () => { }); 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( {}} me={mockMe} /> diff --git a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx index 06e83022b8..d0031cb30e 100644 --- a/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx +++ b/awx/ui_next/src/screens/Organization/OrganizationList/OrganizationList.jsx @@ -18,7 +18,7 @@ import OrganizationListItem from './OrganizationListItem'; const QS_CONFIG = getQSConfig('organization', { page: 1, - page_size: 5, + page_size: 20, order_by: 'name', }); diff --git a/awx/ui_next/src/screens/Project/Project.jsx b/awx/ui_next/src/screens/Project/Project.jsx new file mode 100644 index 0000000000..03f0fae85e --- /dev/null +++ b/awx/ui_next/src/screens/Project/Project.jsx @@ -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 = ( + + + + + ); + + if (!isInitialized) { + cardHeader = null; + } + + if (!match) { + cardHeader = null; + } + + if (location.pathname.endsWith('edit')) { + cardHeader = null; + } + + if (!hasContentLoading && contentError) { + return ( + + + + {contentError.response.status === 404 && ( + + {i18n._(`Project not found.`)}{' '} + {i18n._(`View all Projects.`)} + + )} + + + + ); + } + + return ( + + + {cardHeader} + + + {project && ( + } + /> + )} + {project && ( + } + /> + )} + {project && ( + } + /> + )} + {canSeeNotificationsTab && ( + ( + + )} + /> + )} + ( + + )} + /> + } + /> + + !hasContentLoading && ( + + {match.params.id && ( + + {i18n._(`View Project Details`)} + + )} + + ) + } + /> + , + + + + ); + } +} + +export default withI18n()(withRouter(Project)); +export { Project as _Project }; diff --git a/awx/ui_next/src/screens/Project/Project.test.jsx b/awx/ui_next/src/screens/Project/Project.test.jsx new file mode 100644 index 0000000000..5205276ac3 --- /dev/null +++ b/awx/ui_next/src/screens/Project/Project.test.jsx @@ -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('', () => { + test('initially renders succesfully', () => { + ProjectsAPI.readDetail.mockResolvedValue({ data: mockDetails }); + OrganizationsAPI.read.mockImplementation(getOrganizations); + mountWithContexts( {}} me={mockMe} />); + }); + + test('notifications tab shown for admins', async done => { + ProjectsAPI.readDetail.mockResolvedValue({ data: mockDetails }); + OrganizationsAPI.read.mockImplementation(getOrganizations); + + const wrapper = mountWithContexts( + {}} 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( + {}} 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(); + }); +}); diff --git a/awx/ui_next/src/screens/Project/ProjectAccess/ProjectAccess.jsx b/awx/ui_next/src/screens/Project/ProjectAccess/ProjectAccess.jsx new file mode 100644 index 0000000000..18365a9bf5 --- /dev/null +++ b/awx/ui_next/src/screens/Project/ProjectAccess/ProjectAccess.jsx @@ -0,0 +1,10 @@ +import React, { Component } from 'react'; +import { CardBody } from '@patternfly/react-core'; + +class ProjectAccess extends Component { + render() { + return Coming soon :); + } +} + +export default ProjectAccess; diff --git a/awx/ui_next/src/screens/Project/ProjectAccess/index.js b/awx/ui_next/src/screens/Project/ProjectAccess/index.js new file mode 100644 index 0000000000..ebfe78aa2d --- /dev/null +++ b/awx/ui_next/src/screens/Project/ProjectAccess/index.js @@ -0,0 +1 @@ +export { default } from './ProjectAccess'; diff --git a/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.jsx b/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.jsx new file mode 100644 index 0000000000..36df6994d3 --- /dev/null +++ b/awx/ui_next/src/screens/Project/ProjectAdd/ProjectAdd.jsx @@ -0,0 +1,10 @@ +import React, { Component } from 'react'; +import { PageSection } from '@patternfly/react-core'; + +class ProjectAdd extends Component { + render() { + return Coming soon :); + } +} + +export default ProjectAdd; diff --git a/awx/ui_next/src/screens/Project/ProjectAdd/index.js b/awx/ui_next/src/screens/Project/ProjectAdd/index.js new file mode 100644 index 0000000000..b182f153ac --- /dev/null +++ b/awx/ui_next/src/screens/Project/ProjectAdd/index.js @@ -0,0 +1 @@ +export { default } from './ProjectAdd'; diff --git a/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx b/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx new file mode 100644 index 0000000000..6cc799902c --- /dev/null +++ b/awx/ui_next/src/screens/Project/ProjectDetail/ProjectDetail.jsx @@ -0,0 +1,10 @@ +import React, { Component } from 'react'; +import { CardBody } from '@patternfly/react-core'; + +class ProjectDetail extends Component { + render() { + return Coming soon :); + } +} + +export default ProjectDetail; diff --git a/awx/ui_next/src/screens/Project/ProjectDetail/index.js b/awx/ui_next/src/screens/Project/ProjectDetail/index.js new file mode 100644 index 0000000000..e0b5e229b1 --- /dev/null +++ b/awx/ui_next/src/screens/Project/ProjectDetail/index.js @@ -0,0 +1 @@ +export { default } from './ProjectDetail'; diff --git a/awx/ui_next/src/screens/Project/ProjectEdit/ProjectEdit.jsx b/awx/ui_next/src/screens/Project/ProjectEdit/ProjectEdit.jsx new file mode 100644 index 0000000000..1127e5e8ba --- /dev/null +++ b/awx/ui_next/src/screens/Project/ProjectEdit/ProjectEdit.jsx @@ -0,0 +1,10 @@ +import React, { Component } from 'react'; +import { CardBody } from '@patternfly/react-core'; + +class ProjectEdit extends Component { + render() { + return Coming soon :); + } +} + +export default ProjectEdit; diff --git a/awx/ui_next/src/screens/Project/ProjectEdit/index.js b/awx/ui_next/src/screens/Project/ProjectEdit/index.js new file mode 100644 index 0000000000..559b86bfc2 --- /dev/null +++ b/awx/ui_next/src/screens/Project/ProjectEdit/index.js @@ -0,0 +1 @@ +export { default } from './ProjectEdit'; diff --git a/awx/ui_next/src/screens/Project/ProjectJobTemplates/ProjectJobTemplates.jsx b/awx/ui_next/src/screens/Project/ProjectJobTemplates/ProjectJobTemplates.jsx new file mode 100644 index 0000000000..b09167f495 --- /dev/null +++ b/awx/ui_next/src/screens/Project/ProjectJobTemplates/ProjectJobTemplates.jsx @@ -0,0 +1,10 @@ +import React, { Component } from 'react'; +import { CardBody } from '@patternfly/react-core'; + +class ProjectJobTemplates extends Component { + render() { + return Coming soon :); + } +} + +export default ProjectJobTemplates; diff --git a/awx/ui_next/src/screens/Project/ProjectJobTemplates/index.js b/awx/ui_next/src/screens/Project/ProjectJobTemplates/index.js new file mode 100644 index 0000000000..7652ab295d --- /dev/null +++ b/awx/ui_next/src/screens/Project/ProjectJobTemplates/index.js @@ -0,0 +1 @@ +export { default } from './ProjectJobTemplates'; diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx new file mode 100644 index 0000000000..1e46ce6760 --- /dev/null +++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx @@ -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 ( + + + + ( + , + canAdd ? ( + + ) : null, + ]} + /> + )} + renderItem={o => ( + row.id === o.id)} + onSelect={() => this.handleSelect(o)} + /> + )} + emptyStateControls={ + canAdd ? ( + + ) : null + } + /> + + + + {i18n._(t`Failed to delete one or more projects.`)} + + + + ); + } +} + +export { ProjectsList as _ProjectsList }; +export default withI18n()(withRouter(ProjectsList)); diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectList.test.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectList.test.jsx new file mode 100644 index 0000000000..d37eaca50e --- /dev/null +++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectList.test.jsx @@ -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('', () => { + beforeEach(() => { + ProjectsAPI.read.mockResolvedValue({ + data: { + count: mockProjects.length, + results: mockProjects, + }, + }); + + ProjectsAPI.readOptions.mockResolvedValue({ + data: { + actions: [], + }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('initially renders successfully', () => { + mountWithContexts( + + ); + }); + + test('Projects are retrieved from the api and the components finishes loading', async done => { + const loadProjects = jest.spyOn(_ProjectsList.prototype, 'loadProjects'); + const wrapper = mountWithContexts(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx new file mode 100644 index 0000000000..ed81638f45 --- /dev/null +++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx @@ -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 ( + +
{i18n._(t`MOST RECENT SYNC`)}
+
+ {i18n._(t`JOB ID:`)} {job.id} +
+
+ {i18n._(t`STATUS:`)} {job.status.toUpperCase()} +
+ {job.finished && ( +
+ {i18n._(t`FINISHED:`)} {job.finished} +
+ )} +
+ ); + }; + + render() { + const { project, isSelected, onSelect, detailUrl, i18n } = this.props; + const labelId = `check-action-${project.id}`; + return ( + + + + + + {project.summary_fields.last_job && ( + + + + + + )} + + + {project.name} + + + , + + {project.scm_type.toUpperCase()} + , + + {project.summary_fields.user_capabilities.start && ( + + + {handleSync => ( + + + + )} + + + )} + , + ]} + /> + + + ); + } +} +export default withI18n()(ProjectListItem); diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.test.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.test.jsx new file mode 100644 index 0000000000..1573efac80 --- /dev/null +++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.test.jsx @@ -0,0 +1,60 @@ +import React from 'react'; + +import { mountWithContexts } from '@testUtils/enzymeHelpers'; + +import ProjectsListItem from './ProjectListItem'; + +describe('', () => { + test('launch button shown to users with start capabilities', () => { + const wrapper = mountWithContexts( + {}} + 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( + {}} + 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(); + }); +}); diff --git a/awx/ui_next/src/screens/Project/ProjectList/index.js b/awx/ui_next/src/screens/Project/ProjectList/index.js new file mode 100644 index 0000000000..8c7a942a72 --- /dev/null +++ b/awx/ui_next/src/screens/Project/ProjectList/index.js @@ -0,0 +1,2 @@ +export { default as ProjectList } from './ProjectList'; +export { default as ProjectListItem } from './ProjectListItem'; diff --git a/awx/ui_next/src/screens/Project/ProjectNotifications/ProjectNotifications.jsx b/awx/ui_next/src/screens/Project/ProjectNotifications/ProjectNotifications.jsx new file mode 100644 index 0000000000..32312cc920 --- /dev/null +++ b/awx/ui_next/src/screens/Project/ProjectNotifications/ProjectNotifications.jsx @@ -0,0 +1,10 @@ +import React, { Component } from 'react'; +import { CardBody } from '@patternfly/react-core'; + +class ProjectNotifications extends Component { + render() { + return Coming soon :); + } +} + +export default ProjectNotifications; diff --git a/awx/ui_next/src/screens/Project/ProjectNotifications/index.js b/awx/ui_next/src/screens/Project/ProjectNotifications/index.js new file mode 100644 index 0000000000..61e2ce96bf --- /dev/null +++ b/awx/ui_next/src/screens/Project/ProjectNotifications/index.js @@ -0,0 +1 @@ +export { default } from './ProjectNotifications'; diff --git a/awx/ui_next/src/screens/Project/ProjectSchedules/ProjectSchedules.jsx b/awx/ui_next/src/screens/Project/ProjectSchedules/ProjectSchedules.jsx new file mode 100644 index 0000000000..6550406190 --- /dev/null +++ b/awx/ui_next/src/screens/Project/ProjectSchedules/ProjectSchedules.jsx @@ -0,0 +1,10 @@ +import React, { Component } from 'react'; +import { CardBody } from '@patternfly/react-core'; + +class ProjectSchedules extends Component { + render() { + return Coming soon :); + } +} + +export default ProjectSchedules; diff --git a/awx/ui_next/src/screens/Project/ProjectSchedules/index.js b/awx/ui_next/src/screens/Project/ProjectSchedules/index.js new file mode 100644 index 0000000000..b07f7a9a5a --- /dev/null +++ b/awx/ui_next/src/screens/Project/ProjectSchedules/index.js @@ -0,0 +1 @@ +export { default } from './ProjectSchedules'; diff --git a/awx/ui_next/src/screens/Project/Projects.jsx b/awx/ui_next/src/screens/Project/Projects.jsx index bbbf51725c..b3c7a06d6c 100644 --- a/awx/ui_next/src/screens/Project/Projects.jsx +++ b/awx/ui_next/src/screens/Project/Projects.jsx @@ -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 ( - - {i18n._(t`Projects`)} - - + + + } /> + ( + + {({ me }) => ( + + )} + + )} + /> + } /> + ); } } -export default withI18n()(Projects); +export { Projects as _Projects }; +export default withI18n()(withRouter(Projects)); diff --git a/awx/ui_next/src/screens/Project/Projects.test.jsx b/awx/ui_next/src/screens/Project/Projects.test.jsx index cc5c6fea70..c901fc5290 100644 --- a/awx/ui_next/src/screens/Project/Projects.test.jsx +++ b/awx/ui_next/src/screens/Project/Projects.test.jsx @@ -1,29 +1,33 @@ import React from 'react'; +import { createMemoryHistory } from 'history'; import { mountWithContexts } from '@testUtils/enzymeHelpers'; import Projects from './Projects'; describe('', () => { - let pageWrapper; - let pageSections; - let title; - - beforeEach(() => { - pageWrapper = mountWithContexts(); - pageSections = pageWrapper.find('PageSection'); - title = pageWrapper.find('Title'); + test('initially renders succesfully', () => { + mountWithContexts(); }); - 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(, { + context: { + router: { + history, + route: { + location: history.location, + match, + }, + }, + }, + }); + expect(wrapper.find('BreadcrumbHeading').length).toBe(1); + wrapper.unmount(); }); }); diff --git a/awx/ui_next/src/screens/Project/data.project.json b/awx/ui_next/src/screens/Project/data.project.json new file mode 100644 index 0000000000..c0e9ccaef9 --- /dev/null +++ b/awx/ui_next/src/screens/Project/data.project.json @@ -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" +} \ No newline at end of file diff --git a/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.jsx b/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.jsx new file mode 100644 index 0000000000..bc3e40d022 --- /dev/null +++ b/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.jsx @@ -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 ( + + {children(this.handleSync)} + {syncError && ( + + {i18n._(t`Failed to sync job.`)} + + + )} + + ); + } +} + +export default withI18n()(ProjectSyncButton); diff --git a/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.test.jsx b/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.test.jsx new file mode 100644 index 0000000000..f162430fde --- /dev/null +++ b/awx/ui_next/src/screens/Project/shared/ProjectSyncButton.test.jsx @@ -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 => ( +