diff --git a/awx/ui_next/src/components/DataListCheck/DataListCheck.jsx b/awx/ui_next/src/components/DataListCheck/DataListCheck.jsx index 511cc8d9a8..30817989cf 100644 --- a/awx/ui_next/src/components/DataListCheck/DataListCheck.jsx +++ b/awx/ui_next/src/components/DataListCheck/DataListCheck.jsx @@ -1,6 +1,7 @@ import { DataListCheck as PFDataListCheck } from '@patternfly/react-core'; import styled from 'styled-components'; +PFDataListCheck.displayName = 'PFDataListCheck'; export default styled(PFDataListCheck)` padding-top: 18px; @media screen and (min-width: 768px) { diff --git a/awx/ui_next/src/screens/Host/Host.test.jsx b/awx/ui_next/src/screens/Host/Host.test.jsx index 94b7dee895..ca7cb6fe97 100644 --- a/awx/ui_next/src/screens/Host/Host.test.jsx +++ b/awx/ui_next/src/screens/Host/Host.test.jsx @@ -12,13 +12,13 @@ const mockMe = { is_system_auditor: false, }; -describe.only('', () => { +describe('', () => { test('initially renders succesfully', () => { HostsAPI.readDetail.mockResolvedValue({ data: mockDetails }); mountWithContexts( {}} me={mockMe} />); }); - test('should show content error when user attempts to navigate to erroneous route', async done => { + test('should show content error when user attempts to navigate to erroneous route', async () => { const history = createMemoryHistory({ initialEntries: ['/hosts/1/foobar'], }); @@ -41,6 +41,5 @@ describe.only('', () => { } ); await waitForElement(wrapper, 'ContentError', el => el.length === 1); - done(); }); }); diff --git a/awx/ui_next/src/screens/Host/HostList/HostList.jsx b/awx/ui_next/src/screens/Host/HostList/HostList.jsx index 4886cfc899..36da7a3535 100644 --- a/awx/ui_next/src/screens/Host/HostList/HostList.jsx +++ b/awx/ui_next/src/screens/Host/HostList/HostList.jsx @@ -175,7 +175,8 @@ class HostsList extends Component { const canAdd = actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); - const isAllSelected = selected.length === hosts.length; + const isAllSelected = + selected.length > 0 && selected.length === hosts.length; return ( diff --git a/awx/ui_next/src/screens/Inventory/Inventory.test.jsx b/awx/ui_next/src/screens/Inventory/Inventory.test.jsx index b1b7abd112..cff57dfc11 100644 --- a/awx/ui_next/src/screens/Inventory/Inventory.test.jsx +++ b/awx/ui_next/src/screens/Inventory/Inventory.test.jsx @@ -11,7 +11,7 @@ InventoriesAPI.readDetail.mockResolvedValue({ data: mockInventory, }); -describe.only('', () => { +describe('', () => { test('initially renders succesfully', async done => { const wrapper = mountWithContexts( {}} match={{ params: { id: 1 } }} /> @@ -29,7 +29,7 @@ describe.only('', () => { await waitForElement(wrapper, '.pf-c-tabs__item', el => el.length === 6); done(); }); - test('should show content error when user attempts to navigate to erroneous route', async done => { + test('should show content error when user attempts to navigate to erroneous route', async () => { const history = createMemoryHistory({ initialEntries: ['/inventories/inventory/1/foobar'], }); @@ -49,6 +49,5 @@ describe.only('', () => { }, }); await waitForElement(wrapper, 'ContentError', el => el.length === 1); - done(); }); }); diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx index 35d33741b4..c949b39656 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx @@ -174,7 +174,8 @@ class InventoriesList extends Component { const { match, i18n } = this.props; const canAdd = actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); - const isAllSelected = selected.length === inventories.length; + const isAllSelected = + selected.length > 0 && selected.length === inventories.length; return ( diff --git a/awx/ui_next/src/screens/Inventory/SmartInventory.test.jsx b/awx/ui_next/src/screens/Inventory/SmartInventory.test.jsx index 1f81d26ebf..33a4035b3c 100644 --- a/awx/ui_next/src/screens/Inventory/SmartInventory.test.jsx +++ b/awx/ui_next/src/screens/Inventory/SmartInventory.test.jsx @@ -11,7 +11,7 @@ InventoriesAPI.readDetail.mockResolvedValue({ data: mockSmartInventory, }); -describe.only('', () => { +describe('', () => { test('initially renders succesfully', async done => { const wrapper = mountWithContexts( {}} match={{ params: { id: 1 } }} /> @@ -29,7 +29,7 @@ describe.only('', () => { await waitForElement(wrapper, '.pf-c-tabs__item', el => el.length === 4); done(); }); - test('should show content error when user attempts to navigate to erroneous route', async done => { + test('should show content error when user attempts to navigate to erroneous route', async () => { const history = createMemoryHistory({ initialEntries: ['/inventories/smart_inventory/1/foobar'], }); @@ -52,6 +52,5 @@ describe.only('', () => { } ); await waitForElement(wrapper, 'ContentError', el => el.length === 1); - done(); }); }); diff --git a/awx/ui_next/src/screens/Organization/Organization.test.jsx b/awx/ui_next/src/screens/Organization/Organization.test.jsx index 4c78bf94ed..4fda6bcffc 100644 --- a/awx/ui_next/src/screens/Organization/Organization.test.jsx +++ b/awx/ui_next/src/screens/Organization/Organization.test.jsx @@ -33,7 +33,7 @@ async function getOrganizations(params) { }; } -describe.only('', () => { +describe('', () => { test('initially renders succesfully', () => { OrganizationsAPI.readDetail.mockResolvedValue({ data: mockOrganization }); OrganizationsAPI.read.mockImplementation(getOrganizations); @@ -77,7 +77,7 @@ describe.only('', () => { done(); }); - test('should show content error when user attempts to navigate to erroneous route', async done => { + test('should show content error when user attempts to navigate to erroneous route', async () => { const history = createMemoryHistory({ initialEntries: ['/organizations/1/foobar'], }); @@ -100,6 +100,5 @@ describe.only('', () => { } ); await waitForElement(wrapper, 'ContentError', el => el.length === 1); - done(); }); }); diff --git a/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.test.jsx b/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.test.jsx index 2a6ffb2b28..7213632290 100644 --- a/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.test.jsx +++ b/awx/ui_next/src/screens/Organization/OrganizationAdd/OrganizationAdd.test.jsx @@ -1,13 +1,10 @@ import React from 'react'; import { createMemoryHistory } from 'history'; -import { - mountWithContexts, - waitForElement, -} from '../../../../testUtils/enzymeHelpers'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import OrganizationAdd from './OrganizationAdd'; -import { OrganizationsAPI } from '../../../api'; +import { OrganizationsAPI } from '@api'; -jest.mock('../../../api'); +jest.mock('@api'); describe('', () => { test('handleSubmit should post to api', () => { diff --git a/awx/ui_next/src/screens/Project/Project.test.jsx b/awx/ui_next/src/screens/Project/Project.test.jsx index 2e21629923..264e111dfa 100644 --- a/awx/ui_next/src/screens/Project/Project.test.jsx +++ b/awx/ui_next/src/screens/Project/Project.test.jsx @@ -24,7 +24,7 @@ async function getOrganizations() { }; } -describe.only('', () => { +describe('', () => { test('initially renders succesfully', () => { ProjectsAPI.readDetail.mockResolvedValue({ data: mockDetails }); OrganizationsAPI.read.mockImplementation(getOrganizations); @@ -68,7 +68,7 @@ describe.only('', () => { done(); }); - test('should show content error when user attempts to navigate to erroneous route', async done => { + test('should show content error when user attempts to navigate to erroneous route', async () => { const history = createMemoryHistory({ initialEntries: ['/projects/1/foobar'], }); @@ -91,6 +91,5 @@ describe.only('', () => { } ); await waitForElement(wrapper, 'ContentError', el => el.length === 1); - done(); }); }); diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx index 1e46ce6760..8067f48dd4 100644 --- a/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx +++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx @@ -141,7 +141,8 @@ class ProjectsList extends Component { const canAdd = actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); - const isAllSelected = selected.length === projects.length; + const isAllSelected = + selected.length > 0 && selected.length === projects.length; return ( diff --git a/awx/ui_next/src/screens/Team/Team.jsx b/awx/ui_next/src/screens/Team/Team.jsx new file mode 100644 index 0000000000..9aabb7b1dd --- /dev/null +++ b/awx/ui_next/src/screens/Team/Team.jsx @@ -0,0 +1,178 @@ +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 TeamDetail from './TeamDetail'; +import TeamEdit from './TeamEdit'; +import { TeamsAPI } from '@api'; + +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; +`; + +class Team extends Component { + constructor(props) { + super(props); + + this.state = { + team: null, + hasContentLoading: true, + contentError: null, + isInitialized: false, + }; + this.loadTeam = this.loadTeam.bind(this); + } + + async componentDidMount() { + await this.loadTeam(); + this.setState({ isInitialized: true }); + } + + async componentDidUpdate(prevProps) { + const { location, match } = this.props; + const url = `/teams/${match.params.id}/`; + + if ( + prevProps.location.pathname.startsWith(url) && + prevProps.location !== location && + location.pathname === `${url}details` + ) { + await this.loadTeam(); + } + } + + async loadTeam() { + const { match, setBreadcrumb } = this.props; + const id = parseInt(match.params.id, 10); + + this.setState({ contentError: null, hasContentLoading: true }); + try { + const { data } = await TeamsAPI.readDetail(id); + setBreadcrumb(data); + this.setState({ team: data }); + } catch (err) { + this.setState({ contentError: err }); + } finally { + this.setState({ hasContentLoading: false }); + } + } + + render() { + const { location, match, history, i18n } = this.props; + + const { team, contentError, hasContentLoading, isInitialized } = this.state; + + const tabsArray = [ + { name: i18n._(t`Details`), link: `${match.url}/details`, id: 0 }, + { name: i18n._(t`Users`), link: `${match.url}/users`, id: 1 }, + { name: i18n._(t`Access`), link: `${match.url}/access`, id: 2 }, + ]; + + 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._(`Team not found.`)}{' '} + {i18n._(`View all Teams.`)} + + )} + + + + ); + } + + return ( + + + {cardHeader} + + + {team && ( + } + /> + )} + {team && ( + } + /> + )} + {team && ( + Coming soon :)} + /> + )} + {team && ( + Coming soon :)} + /> + )} + + !hasContentLoading && ( + + {match.params.id && ( + + {i18n._(`View Team Details`)} + + )} + + ) + } + /> + , + + + + ); + } +} + +export default withI18n()(withRouter(Team)); +export { Team as _Team }; diff --git a/awx/ui_next/src/screens/Team/Team.test.jsx b/awx/ui_next/src/screens/Team/Team.test.jsx new file mode 100644 index 0000000000..f6b8ac5b04 --- /dev/null +++ b/awx/ui_next/src/screens/Team/Team.test.jsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { createMemoryHistory } from 'history'; +import { TeamsAPI } from '@api'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import Team from './Team'; + +jest.mock('@api'); + +const mockMe = { + is_super_user: true, + is_system_auditor: false, +}; + +const mockTeam = { + id: 1, + name: 'Test Team', + summary_fields: { + organization: { + id: 1, + name: 'Default', + }, + }, +}; + +async function getTeams() { + return { + count: 1, + next: null, + previous: null, + data: { + results: [mockTeam], + }, + }; +} + +describe('', () => { + test('initially renders succesfully', () => { + TeamsAPI.readDetail.mockResolvedValue({ data: mockTeam }); + TeamsAPI.read.mockImplementation(getTeams); + mountWithContexts( {}} me={mockMe} />); + }); + + test('should show content error when user attempts to navigate to erroneous route', async () => { + const history = createMemoryHistory({ + initialEntries: ['/teams/1/foobar'], + }); + const wrapper = mountWithContexts( + {}} me={mockMe} />, + { + context: { + router: { + history, + route: { + location: history.location, + match: { + params: { id: 1 }, + url: '/teams/1/foobar', + path: '/teams/1/foobar', + }, + }, + }, + }, + } + ); + await waitForElement(wrapper, 'ContentError', el => el.length === 1); + }); +}); diff --git a/awx/ui_next/src/screens/Team/TeamAdd/TeamAdd.jsx b/awx/ui_next/src/screens/Team/TeamAdd/TeamAdd.jsx new file mode 100644 index 0000000000..ff220e9be7 --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamAdd/TeamAdd.jsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { withRouter } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { + PageSection, + Card, + CardHeader, + CardBody, + Tooltip, +} from '@patternfly/react-core'; + +import { TeamsAPI } from '@api'; +import { Config } from '@contexts/Config'; +import CardCloseButton from '@components/CardCloseButton'; + +import TeamForm from '../shared/TeamForm'; + +class TeamAdd extends React.Component { + constructor(props) { + super(props); + this.handleSubmit = this.handleSubmit.bind(this); + this.handleCancel = this.handleCancel.bind(this); + this.state = { error: '' }; + } + + async handleSubmit(values) { + const { history } = this.props; + try { + const { data: response } = await TeamsAPI.create(values); + history.push(`/teams/${response.id}`); + } catch (error) { + this.setState({ error }); + } + } + + handleCancel() { + const { history } = this.props; + history.push('/teams'); + } + + render() { + const { error } = this.state; + const { i18n } = this.props; + + return ( + + + + + + + + + + {({ me }) => ( + + )} + + {error ?
error
: ''} +
+
+
+ ); + } +} + +export { TeamAdd as _TeamAdd }; +export default withI18n()(withRouter(TeamAdd)); diff --git a/awx/ui_next/src/screens/Team/TeamAdd/TeamAdd.test.jsx b/awx/ui_next/src/screens/Team/TeamAdd/TeamAdd.test.jsx new file mode 100644 index 0000000000..20eb762710 --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamAdd/TeamAdd.test.jsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { createMemoryHistory } from 'history'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import TeamAdd from './TeamAdd'; +import { TeamsAPI } from '@api'; + +jest.mock('@api'); + +describe('', () => { + test('handleSubmit should post to api', () => { + const wrapper = mountWithContexts(); + const updatedTeamData = { + name: 'new name', + description: 'new description', + organization: 1, + }; + wrapper.find('TeamForm').prop('handleSubmit')(updatedTeamData); + expect(TeamsAPI.create).toHaveBeenCalledWith(updatedTeamData); + }); + + test('should navigate to teams list when cancel is clicked', () => { + const history = createMemoryHistory({}); + const wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + expect(history.location.pathname).toEqual('/teams'); + }); + + test('should navigate to teams list when close (x) is clicked', () => { + const history = createMemoryHistory({}); + const wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + wrapper.find('button[aria-label="Close"]').prop('onClick')(); + expect(history.location.pathname).toEqual('/teams'); + }); + + test('successful form submission should trigger redirect', async () => { + const history = createMemoryHistory({}); + const teamData = { + name: 'new name', + description: 'new description', + organization: 1, + }; + TeamsAPI.create.mockResolvedValueOnce({ + data: { + id: 5, + ...teamData, + summary_fields: { + organization: { + id: 1, + name: 'Default', + }, + }, + }, + }); + const wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + await waitForElement(wrapper, 'button[aria-label="Save"]'); + await wrapper.find('TeamForm').prop('handleSubmit')(teamData); + expect(history.location.pathname).toEqual('/teams/5'); + }); +}); diff --git a/awx/ui_next/src/screens/Team/TeamAdd/index.js b/awx/ui_next/src/screens/Team/TeamAdd/index.js new file mode 100644 index 0000000000..1e42040965 --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamAdd/index.js @@ -0,0 +1 @@ +export { default } from './TeamAdd'; diff --git a/awx/ui_next/src/screens/Team/TeamDetail/TeamDetail.jsx b/awx/ui_next/src/screens/Team/TeamDetail/TeamDetail.jsx new file mode 100644 index 0000000000..9c71916a24 --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamDetail/TeamDetail.jsx @@ -0,0 +1,57 @@ +import React, { Component } from 'react'; +import { Link, withRouter } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { CardBody as PFCardBody, Button } from '@patternfly/react-core'; +import styled from 'styled-components'; + +import { DetailList, Detail } from '@components/DetailList'; +import { formatDateString } from '@util/dates'; + +const CardBody = styled(PFCardBody)` + padding-top: 20px; +`; + +class TeamDetail extends Component { + render() { + const { + team: { name, description, created, modified, summary_fields }, + match, + i18n, + } = this.props; + + return ( + + + + + + {summary_fields.organization.name} + + } + /> + + + + {summary_fields.user_capabilities.edit && ( +
+ +
+ )} +
+ ); + } +} + +export default withI18n()(withRouter(TeamDetail)); diff --git a/awx/ui_next/src/screens/Team/TeamDetail/TeamDetail.test.jsx b/awx/ui_next/src/screens/Team/TeamDetail/TeamDetail.test.jsx new file mode 100644 index 0000000000..5b971c454c --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamDetail/TeamDetail.test.jsx @@ -0,0 +1,64 @@ +import React from 'react'; + +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; + +import TeamDetail from './TeamDetail'; + +jest.mock('@api'); + +describe('', () => { + const mockTeam = { + name: 'Foo', + description: 'Bar', + created: '2015-07-07T17:21:26.429745Z', + modified: '2019-08-11T19:47:37.980466Z', + summary_fields: { + organization: { + id: 1, + name: 'Default', + }, + user_capabilities: { + edit: true, + }, + }, + }; + test('initially renders succesfully', () => { + mountWithContexts(); + }); + + test('should render Details', async done => { + const wrapper = mountWithContexts(); + const testParams = [ + { label: 'Name', value: 'Foo' }, + { label: 'Description', value: 'Bar' }, + { label: 'Organization', value: 'Default' }, + { label: 'Created', value: '7/7/2015, 5:21:26 PM' }, + { label: 'Last Modified', value: '8/11/2019, 7:47:37 PM' }, + ]; + // eslint-disable-next-line no-restricted-syntax + for (const { label, value } of testParams) { + // eslint-disable-next-line no-await-in-loop + const detail = await waitForElement(wrapper, `Detail[label="${label}"]`); + expect(detail.find('dt').text()).toBe(label); + expect(detail.find('dd').text()).toBe(value); + } + done(); + }); + + test('should show edit button for users with edit permission', async done => { + const wrapper = mountWithContexts(); + const editButton = await waitForElement(wrapper, 'TeamDetail Button'); + expect(editButton.text()).toEqual('Edit'); + expect(editButton.prop('to')).toBe('/teams/undefined/edit'); + done(); + }); + + test('should hide edit button for users without edit permission', async done => { + const readOnlyTeam = { ...mockTeam }; + readOnlyTeam.summary_fields.user_capabilities.edit = false; + const wrapper = mountWithContexts(); + await waitForElement(wrapper, 'TeamDetail'); + expect(wrapper.find('TeamDetail Button').length).toBe(0); + done(); + }); +}); diff --git a/awx/ui_next/src/screens/Team/TeamDetail/index.js b/awx/ui_next/src/screens/Team/TeamDetail/index.js new file mode 100644 index 0000000000..7c06d30dac --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamDetail/index.js @@ -0,0 +1 @@ +export { default } from './TeamDetail'; diff --git a/awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.jsx b/awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.jsx new file mode 100644 index 0000000000..a6580b4ce9 --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.jsx @@ -0,0 +1,81 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { withRouter } from 'react-router-dom'; +import { CardBody } from '@patternfly/react-core'; + +import { TeamsAPI } from '@api'; +import { Config } from '@contexts/Config'; + +import TeamForm from '../shared/TeamForm'; + +class TeamEdit extends Component { + constructor(props) { + super(props); + + this.handleSubmit = this.handleSubmit.bind(this); + this.handleCancel = this.handleCancel.bind(this); + this.handleSuccess = this.handleSuccess.bind(this); + + this.state = { + error: '', + }; + } + + async handleSubmit(values) { + const { team } = this.props; + try { + await TeamsAPI.update(team.id, values); + this.handleSuccess(); + } catch (err) { + this.setState({ error: err }); + } + } + + handleCancel() { + const { + team: { id }, + history, + } = this.props; + history.push(`/teams/${id}/details`); + } + + handleSuccess() { + const { + team: { id }, + history, + } = this.props; + history.push(`/teams/${id}/details`); + } + + render() { + const { team } = this.props; + const { error } = this.state; + + return ( + + + {({ me }) => ( + + )} + + {error ?
error
: null} +
+ ); + } +} + +TeamEdit.propTypes = { + team: PropTypes.shape().isRequired, +}; + +TeamEdit.contextTypes = { + custom_virtualenvs: PropTypes.arrayOf(PropTypes.string), +}; + +export { TeamEdit as _TeamEdit }; +export default withRouter(TeamEdit); diff --git a/awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.test.jsx b/awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.test.jsx new file mode 100644 index 0000000000..7ea335361a --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.test.jsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { createMemoryHistory } from 'history'; +import { TeamsAPI } from '@api'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import TeamEdit from './TeamEdit'; + +jest.mock('@api'); + +describe('', () => { + const mockData = { + name: 'Foo', + description: 'Bar', + id: 1, + summary_fields: { + organization: { + id: 1, + name: 'Default', + }, + }, + }; + + test('handleSubmit should call api update', () => { + const wrapper = mountWithContexts(); + + const updatedTeamData = { + name: 'new name', + description: 'new description', + }; + wrapper.find('TeamForm').prop('handleSubmit')(updatedTeamData); + + expect(TeamsAPI.update).toHaveBeenCalledWith(1, updatedTeamData); + }); + + test('should navigate to team detail when cancel is clicked', () => { + const history = createMemoryHistory({}); + const wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + + wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + + expect(history.location.pathname).toEqual('/teams/1/details'); + }); +}); diff --git a/awx/ui_next/src/screens/Team/TeamEdit/index.js b/awx/ui_next/src/screens/Team/TeamEdit/index.js new file mode 100644 index 0000000000..417d983965 --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamEdit/index.js @@ -0,0 +1 @@ +export { default } from './TeamEdit'; diff --git a/awx/ui_next/src/screens/Team/TeamList/TeamList.jsx b/awx/ui_next/src/screens/Team/TeamList/TeamList.jsx new file mode 100644 index 0000000000..5369dbd7ed --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamList/TeamList.jsx @@ -0,0 +1,228 @@ +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 { TeamsAPI } 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 TeamListItem from './TeamListItem'; + +const QS_CONFIG = getQSConfig('team', { + page: 1, + page_size: 20, + order_by: 'name', +}); + +class TeamsList extends Component { + constructor(props) { + super(props); + + this.state = { + hasContentLoading: true, + contentError: null, + deletionError: null, + teams: [], + selected: [], + itemCount: 0, + actions: null, + }; + + this.handleSelectAll = this.handleSelectAll.bind(this); + this.handleSelect = this.handleSelect.bind(this); + this.handleTeamDelete = this.handleTeamDelete.bind(this); + this.handleDeleteErrorClose = this.handleDeleteErrorClose.bind(this); + this.loadTeams = this.loadTeams.bind(this); + } + + componentDidMount() { + this.loadTeams(); + } + + componentDidUpdate(prevProps) { + const { location } = this.props; + if (location !== prevProps.location) { + this.loadTeams(); + } + } + + handleSelectAll(isSelected) { + const { teams } = this.state; + + const selected = isSelected ? [...teams] : []; + 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 handleTeamDelete() { + const { selected } = this.state; + + this.setState({ hasContentLoading: true }); + try { + await Promise.all(selected.map(team => TeamsAPI.destroy(team.id))); + } catch (err) { + this.setState({ deletionError: err }); + } finally { + await this.loadTeams(); + } + } + + async loadTeams() { + 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 = TeamsAPI.readOptions(); + } + + const promises = Promise.all([TeamsAPI.read(params), optionsPromise]); + + this.setState({ contentError: null, hasContentLoading: true }); + try { + const [ + { + data: { count, results }, + }, + { + data: { actions }, + }, + ] = await promises; + this.setState({ + actions, + itemCount: count, + teams: results, + selected: [], + }); + } catch (err) { + this.setState({ contentError: err }); + } finally { + this.setState({ hasContentLoading: false }); + } + } + + render() { + const { + actions, + itemCount, + contentError, + hasContentLoading, + deletionError, + selected, + teams, + } = this.state; + const { match, i18n } = this.props; + + const canAdd = + actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); + const isAllSelected = + selected.length > 0 && selected.length === teams.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 teams.`)} + + + + ); + } +} + +export { TeamsList as _TeamsList }; +export default withI18n()(withRouter(TeamsList)); diff --git a/awx/ui_next/src/screens/Team/TeamList/TeamList.test.jsx b/awx/ui_next/src/screens/Team/TeamList/TeamList.test.jsx new file mode 100644 index 0000000000..2eb816a5f6 --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamList/TeamList.test.jsx @@ -0,0 +1,232 @@ +import React from 'react'; +import { TeamsAPI } from '@api'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; + +import TeamsList, { _TeamsList } from './TeamList'; + +jest.mock('@api'); + +const mockAPITeamsList = { + data: { + count: 3, + results: [ + { + name: 'Team 0', + id: 1, + url: '/teams/1', + summary_fields: { + user_capabilities: { + delete: true, + }, + }, + }, + { + name: 'Team 1', + id: 2, + url: '/teams/2', + summary_fields: { + user_capabilities: { + delete: true, + }, + }, + }, + { + name: 'Team 2', + id: 3, + url: '/teams/3', + summary_fields: { + user_capabilities: { + delete: true, + }, + }, + }, + ], + }, + isModalOpen: false, + warningTitle: 'title', + warningMsg: 'message', +}; + +describe('', () => { + let wrapper; + + beforeEach(() => { + TeamsAPI.read = () => + Promise.resolve({ + data: mockAPITeamsList.data, + }); + TeamsAPI.readOptions = () => + Promise.resolve({ + data: { + actions: { + GET: {}, + POST: {}, + }, + }, + }); + }); + + test('initially renders succesfully', () => { + mountWithContexts(); + }); + + test('Selects one team when row is checked', async () => { + wrapper = mountWithContexts(); + await waitForElement( + wrapper, + 'TeamsList', + el => el.state('hasContentLoading') === false + ); + expect( + wrapper + .find('input[type="checkbox"]') + .findWhere(n => n.prop('checked') === true).length + ).toBe(0); + wrapper + .find('TeamListItem') + .at(0) + .find('DataListCheck') + .props() + .onChange(true); + wrapper.update(); + expect( + wrapper + .find('input[type="checkbox"]') + .findWhere(n => n.prop('checked') === true).length + ).toBe(1); + }); + + test('Select all checkbox selects and unselects all rows', async () => { + wrapper = mountWithContexts(); + await waitForElement( + wrapper, + 'TeamsList', + el => el.state('hasContentLoading') === false + ); + expect( + wrapper + .find('input[type="checkbox"]') + .findWhere(n => n.prop('checked') === true).length + ).toBe(0); + wrapper + .find('Checkbox#select-all') + .props() + .onChange(true); + wrapper.update(); + expect( + wrapper + .find('input[type="checkbox"]') + .findWhere(n => n.prop('checked') === true).length + ).toBe(4); + wrapper + .find('Checkbox#select-all') + .props() + .onChange(false); + wrapper.update(); + expect( + wrapper + .find('input[type="checkbox"]') + .findWhere(n => n.prop('checked') === true).length + ).toBe(0); + }); + + test('api is called to delete Teams for each team in selected.', () => { + wrapper = mountWithContexts(); + const component = wrapper.find('TeamsList'); + wrapper.find('TeamsList').setState({ + teams: mockAPITeamsList.data.results, + itemCount: 3, + isInitialized: true, + isModalOpen: mockAPITeamsList.isModalOpen, + selected: mockAPITeamsList.data.results, + }); + wrapper.find('ToolbarDeleteButton').prop('onDelete')(); + expect(TeamsAPI.destroy).toHaveBeenCalledTimes( + component.state('selected').length + ); + }); + + test('call loadTeams after team(s) have been deleted', () => { + const fetchTeams = jest.spyOn(_TeamsList.prototype, 'loadTeams'); + const event = { preventDefault: () => {} }; + wrapper = mountWithContexts(); + wrapper.find('TeamsList').setState({ + teams: mockAPITeamsList.data.results, + itemCount: 3, + isInitialized: true, + selected: mockAPITeamsList.data.results.slice(0, 1), + }); + const component = wrapper.find('TeamsList'); + component.instance().handleTeamDelete(event); + expect(fetchTeams).toBeCalled(); + }); + + test('error is shown when team not successfully deleted from api', async done => { + TeamsAPI.destroy.mockRejectedValue( + new Error({ + response: { + config: { + method: 'delete', + url: '/api/v2/teams/1', + }, + data: 'An error occurred', + }, + }) + ); + + wrapper = mountWithContexts(); + wrapper.find('TeamsList').setState({ + teams: mockAPITeamsList.data.results, + itemCount: 3, + isInitialized: true, + selected: mockAPITeamsList.data.results.slice(0, 1), + }); + wrapper.find('ToolbarDeleteButton').prop('onDelete')(); + await waitForElement( + wrapper, + 'Modal', + el => el.props().isOpen === true && el.props().title === 'Error!' + ); + done(); + }); + + test('Add button shown for users without ability to POST', async done => { + wrapper = mountWithContexts(); + await waitForElement( + wrapper, + 'TeamsList', + el => el.state('hasContentLoading') === true + ); + await waitForElement( + wrapper, + 'TeamsList', + el => el.state('hasContentLoading') === false + ); + expect(wrapper.find('ToolbarAddButton').length).toBe(1); + done(); + }); + + test('Add button hidden for users without ability to POST', async done => { + TeamsAPI.readOptions = () => + Promise.resolve({ + data: { + actions: { + GET: {}, + }, + }, + }); + wrapper = mountWithContexts(); + await waitForElement( + wrapper, + 'TeamsList', + el => el.state('hasContentLoading') === true + ); + await waitForElement( + wrapper, + 'TeamsList', + el => el.state('hasContentLoading') === false + ); + expect(wrapper.find('ToolbarAddButton').length).toBe(0); + done(); + }); +}); diff --git a/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx b/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx new file mode 100644 index 0000000000..cd6244153b --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx @@ -0,0 +1,83 @@ +import React, { Fragment } from 'react'; +import { string, bool, func } from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { + DataListItem, + DataListItemRow, + DataListItemCells, + Tooltip, +} from '@patternfly/react-core'; +import { Link } from 'react-router-dom'; +import { PencilAltIcon } from '@patternfly/react-icons'; + +import ActionButtonCell from '@components/ActionButtonCell'; +import DataListCell from '@components/DataListCell'; +import DataListCheck from '@components/DataListCheck'; +import ListActionButton from '@components/ListActionButton'; +import VerticalSeparator from '@components/VerticalSeparator'; +import { Team } from '@types'; + +class TeamListItem extends React.Component { + static propTypes = { + team: Team.isRequired, + detailUrl: string.isRequired, + isSelected: bool.isRequired, + onSelect: func.isRequired, + }; + + render() { + const { team, isSelected, onSelect, detailUrl, i18n } = this.props; + const labelId = `check-action-${team.id}`; + return ( + + + + + + + {team.name} + + , + + {team.summary_fields.organization && ( + + + {i18n._(t`Organization`)} + + + {team.summary_fields.organization.name} + + + )} + , + + {team.summary_fields.user_capabilities.edit && ( + + + + + + )} + , + ]} + /> + + + ); + } +} +export default withI18n()(TeamListItem); diff --git a/awx/ui_next/src/screens/Team/TeamList/TeamListItem.test.jsx b/awx/ui_next/src/screens/Team/TeamList/TeamListItem.test.jsx new file mode 100644 index 0000000000..73d9329c7e --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamList/TeamListItem.test.jsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { I18nProvider } from '@lingui/react'; + +import { mountWithContexts } from '@testUtils/enzymeHelpers'; + +import TeamListItem from './TeamListItem'; + +describe('', () => { + test('initially renders succesfully', () => { + mountWithContexts( + + + {}} + /> + + + ); + }); + test('edit button shown to users with edit capabilities', () => { + const wrapper = mountWithContexts( + + + {}} + /> + + + ); + expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy(); + }); + test('edit button hidden from users without edit capabilities', () => { + const wrapper = mountWithContexts( + + + {}} + /> + + + ); + expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy(); + }); +}); diff --git a/awx/ui_next/src/screens/Team/TeamList/index.js b/awx/ui_next/src/screens/Team/TeamList/index.js new file mode 100644 index 0000000000..7f52a34617 --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamList/index.js @@ -0,0 +1,2 @@ +export { default as TeamList } from './TeamList'; +export { default as TeamListItem } from './TeamListItem'; diff --git a/awx/ui_next/src/screens/Team/Teams.jsx b/awx/ui_next/src/screens/Team/Teams.jsx index 72fc0073ab..8634a058ae 100644 --- a/awx/ui_next/src/screens/Team/Teams.jsx +++ b/awx/ui_next/src/screens/Team/Teams.jsx @@ -1,26 +1,79 @@ 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 TeamsList from './TeamList/TeamList'; +import TeamAdd from './TeamAdd/TeamAdd'; +import Team from './Team'; class Teams extends Component { - render() { + constructor(props) { + super(props); + + const { i18n } = props; + + this.state = { + breadcrumbConfig: { + '/teams': i18n._(t`Teams`), + '/teams/add': i18n._(t`Create New Team`), + }, + }; + } + + setBreadcrumbConfig = team => { const { i18n } = this.props; - const { light } = PageSectionVariants; + + if (!team) { + return; + } + + const breadcrumbConfig = { + '/teams': i18n._(t`Teams`), + '/teams/add': i18n._(t`Create New Team`), + [`/teams/${team.id}`]: `${team.name}`, + [`/teams/${team.id}/edit`]: i18n._(t`Edit Details`), + [`/teams/${team.id}/details`]: i18n._(t`Details`), + [`/teams/${team.id}/users`]: i18n._(t`Users`), + [`/teams/${team.id}/access`]: i18n._(t`Access`), + }; + + this.setState({ breadcrumbConfig }); + }; + + render() { + const { match, history, location } = this.props; + const { breadcrumbConfig } = this.state; return ( - - {i18n._(t`Teams`)} - - + + + } /> + ( + + {({ me }) => ( + + )} + + )} + /> + } /> + ); } } -export default withI18n()(Teams); +export { Teams as _Teams }; +export default withI18n()(withRouter(Teams)); diff --git a/awx/ui_next/src/screens/Team/Teams.test.jsx b/awx/ui_next/src/screens/Team/Teams.test.jsx index c051a92bfe..5a444e6977 100644 --- a/awx/ui_next/src/screens/Team/Teams.test.jsx +++ b/awx/ui_next/src/screens/Team/Teams.test.jsx @@ -1,29 +1,16 @@ import React from 'react'; - import { mountWithContexts } from '@testUtils/enzymeHelpers'; - import Teams from './Teams'; +jest.mock('@api'); + describe('', () => { - let pageWrapper; - let pageSections; - let title; - - beforeEach(() => { - pageWrapper = mountWithContexts(); - pageSections = pageWrapper.find('PageSection'); - title = pageWrapper.find('Title'); - }); - - afterEach(() => { - pageWrapper.unmount(); - }); - - 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'); + test('initially renders succesfully', () => { + mountWithContexts( + + ); }); }); diff --git a/awx/ui_next/src/screens/Team/shared/TeamForm.jsx b/awx/ui_next/src/screens/Team/shared/TeamForm.jsx new file mode 100644 index 0000000000..65a3a12cbd --- /dev/null +++ b/awx/ui_next/src/screens/Team/shared/TeamForm.jsx @@ -0,0 +1,91 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Formik, Field } from 'formik'; +import { Form } from '@patternfly/react-core'; +import FormActionGroup from '@components/FormActionGroup/FormActionGroup'; +import FormField from '@components/FormField'; +import FormRow from '@components/FormRow'; +import OrganizationLookup from '@components/Lookup/OrganizationLookup'; +import { required } from '@util/validators'; + +function TeamForm(props) { + const { team, handleCancel, handleSubmit, i18n } = props; + const [organization, setOrganization] = useState( + team.summary_fields ? team.summary_fields.organization : null + ); + + return ( + ( +
+ + + + ( + form.setFieldTouched('organization')} + onChange={value => { + form.setFieldValue('organization', value.id); + setOrganization(value); + }} + value={organization} + required + /> + )} + /> + + + + )} + /> + ); +} + +TeamForm.propTypes = { + handleCancel: PropTypes.func.isRequired, + handleSubmit: PropTypes.func.isRequired, + team: PropTypes.shape({}), +}; + +TeamForm.defaultProps = { + team: {}, +}; + +export default withI18n()(TeamForm); diff --git a/awx/ui_next/src/screens/Team/shared/TeamForm.test.jsx b/awx/ui_next/src/screens/Team/shared/TeamForm.test.jsx new file mode 100644 index 0000000000..0d8a483417 --- /dev/null +++ b/awx/ui_next/src/screens/Team/shared/TeamForm.test.jsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import { sleep } from '@testUtils/testUtils'; + +import TeamForm from './TeamForm'; + +jest.mock('@api'); + +describe('', () => { + let wrapper; + const meConfig = { + me: { + is_superuser: false, + }, + }; + const mockData = { + id: 1, + name: 'Foo', + description: 'Bar', + organization: 1, + summary_fields: { + id: 1, + name: 'Default', + }, + }; + + afterEach(() => { + wrapper.unmount(); + jest.clearAllMocks(); + }); + + test('changing inputs should update form values', () => { + wrapper = mountWithContexts( + + ); + + const form = wrapper.find('Formik'); + wrapper.find('input#team-name').simulate('change', { + target: { value: 'new foo', name: 'name' }, + }); + expect(form.state('values').name).toEqual('new foo'); + wrapper.find('input#team-description').simulate('change', { + target: { value: 'new bar', name: 'description' }, + }); + expect(form.state('values').description).toEqual('new bar'); + act(() => { + wrapper.find('OrganizationLookup').invoke('onBlur')(); + wrapper.find('OrganizationLookup').invoke('onChange')({ + id: 2, + name: 'Other Org', + }); + }); + expect(form.state('values').organization).toEqual(2); + }); + + test('should call handleSubmit when Submit button is clicked', async () => { + const handleSubmit = jest.fn(); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(handleSubmit).not.toHaveBeenCalled(); + wrapper.find('button[aria-label="Save"]').simulate('click'); + await sleep(1); + expect(handleSubmit).toBeCalled(); + }); + + test('calls handleCancel when Cancel button is clicked', () => { + const handleCancel = jest.fn(); + + wrapper = mountWithContexts( + + ); + expect(handleCancel).not.toHaveBeenCalled(); + wrapper.find('button[aria-label="Cancel"]').prop('onClick')(); + expect(handleCancel).toBeCalled(); + }); +}); diff --git a/awx/ui_next/src/screens/Team/shared/index.js b/awx/ui_next/src/screens/Team/shared/index.js new file mode 100644 index 0000000000..2e8b2306fd --- /dev/null +++ b/awx/ui_next/src/screens/Team/shared/index.js @@ -0,0 +1,2 @@ +/* eslint-disable-next-line import/prefer-default-export */ +export { default as TeamForm } from './TeamForm'; diff --git a/awx/ui_next/src/screens/Template/Template.test.jsx b/awx/ui_next/src/screens/Template/Template.test.jsx index a76309ccb9..c4f0a36af5 100644 --- a/awx/ui_next/src/screens/Template/Template.test.jsx +++ b/awx/ui_next/src/screens/Template/Template.test.jsx @@ -88,7 +88,7 @@ describe('