From 1e80b2e2959226c5a4f6d48decd7cb05016ae7e1 Mon Sep 17 00:00:00 2001 From: mabashian Date: Fri, 25 Oct 2019 14:35:50 -0400 Subject: [PATCH] Adds basic teams list and add/edit forms. The edit button on the list rows and the org lookup in the form are both missing and will be added in a later commit. --- .../OrganizationAdd/OrganizationAdd.test.jsx | 9 +- awx/ui_next/src/screens/Team/Team.jsx | 178 ++++++++++++++ awx/ui_next/src/screens/Team/Team.test.jsx | 68 ++++++ .../src/screens/Team/TeamAdd/TeamAdd.jsx | 73 ++++++ .../src/screens/Team/TeamAdd/TeamAdd.test.jsx | 57 +++++ awx/ui_next/src/screens/Team/TeamAdd/index.js | 1 + .../screens/Team/TeamDetail/TeamDetail.jsx | 57 +++++ .../Team/TeamDetail/TeamDetail.test.jsx | 64 +++++ .../src/screens/Team/TeamDetail/index.js | 1 + .../src/screens/Team/TeamEdit/TeamEdit.jsx | 101 ++++++++ .../screens/Team/TeamEdit/TeamEdit.test.jsx | 44 ++++ .../src/screens/Team/TeamEdit/index.js | 1 + .../src/screens/Team/TeamList/TeamList.jsx | 227 ++++++++++++++++++ .../screens/Team/TeamList/TeamList.test.jsx | 203 ++++++++++++++++ .../screens/Team/TeamList/TeamListItem.jsx | 70 ++++++ .../Team/TeamList/TeamListItem.test.jsx | 33 +++ .../src/screens/Team/TeamList/index.js | 2 + awx/ui_next/src/screens/Team/Teams.jsx | 77 +++++- awx/ui_next/src/screens/Team/Teams.test.jsx | 31 +-- .../src/screens/Team/shared/TeamForm.jsx | 98 ++++++++ .../src/screens/Team/shared/TeamForm.test.jsx | 81 +++++++ awx/ui_next/src/screens/Team/shared/index.js | 2 + awx/ui_next/src/types.js | 6 + 23 files changed, 1444 insertions(+), 40 deletions(-) create mode 100644 awx/ui_next/src/screens/Team/Team.jsx create mode 100644 awx/ui_next/src/screens/Team/Team.test.jsx create mode 100644 awx/ui_next/src/screens/Team/TeamAdd/TeamAdd.jsx create mode 100644 awx/ui_next/src/screens/Team/TeamAdd/TeamAdd.test.jsx create mode 100644 awx/ui_next/src/screens/Team/TeamAdd/index.js create mode 100644 awx/ui_next/src/screens/Team/TeamDetail/TeamDetail.jsx create mode 100644 awx/ui_next/src/screens/Team/TeamDetail/TeamDetail.test.jsx create mode 100644 awx/ui_next/src/screens/Team/TeamDetail/index.js create mode 100644 awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.jsx create mode 100644 awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.test.jsx create mode 100644 awx/ui_next/src/screens/Team/TeamEdit/index.js create mode 100644 awx/ui_next/src/screens/Team/TeamList/TeamList.jsx create mode 100644 awx/ui_next/src/screens/Team/TeamList/TeamList.test.jsx create mode 100644 awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx create mode 100644 awx/ui_next/src/screens/Team/TeamList/TeamListItem.test.jsx create mode 100644 awx/ui_next/src/screens/Team/TeamList/index.js create mode 100644 awx/ui_next/src/screens/Team/shared/TeamForm.jsx create mode 100644 awx/ui_next/src/screens/Team/shared/TeamForm.test.jsx create mode 100644 awx/ui_next/src/screens/Team/shared/index.js 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/Team/Team.jsx b/awx/ui_next/src/screens/Team/Team.jsx new file mode 100644 index 0000000000..abcae4913b --- /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'; + +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 }, + ]; + + 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._(`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..4d4a14dd23 --- /dev/null +++ b/awx/ui_next/src/screens/Team/Team.test.jsx @@ -0,0 +1,68 @@ +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.only('', () => { + 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 done => { + 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); + done(); + }); +}); 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..a68aff00da --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamAdd/TeamAdd.test.jsx @@ -0,0 +1,57 @@ +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 updatedOrgData = { + name: 'new name', + description: 'new description', + }; + wrapper.find('TeamForm').prop('handleSubmit')(updatedOrgData); + expect(TeamsAPI.create).toHaveBeenCalledWith(updatedOrgData); + }); + + 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', + }; + TeamsAPI.create.mockResolvedValueOnce({ + data: { + id: 5, + ...teamData, + }, + }); + 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..7d0a620517 --- /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 readOnlyOrg = { ...mockTeam }; + readOnlyOrg.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..a8c35e7039 --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.jsx @@ -0,0 +1,101 @@ +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.submitInstanceGroups = this.submitInstanceGroups.bind(this); + this.handleCancel = this.handleCancel.bind(this); + this.handleSuccess = this.handleSuccess.bind(this); + + this.state = { + error: '', + }; + } + + async handleSubmit(values, groupsToAssociate, groupsToDisassociate) { + const { team } = this.props; + try { + await TeamsAPI.update(team.id, values); + await this.submitInstanceGroups(groupsToAssociate, groupsToDisassociate); + 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`); + } + + async submitInstanceGroups(groupsToAssociate, groupsToDisassociate) { + const { team } = this.props; + try { + await Promise.all( + groupsToAssociate.map(id => + TeamsAPI.associateInstanceGroup(team.id, id) + ) + ); + await Promise.all( + groupsToDisassociate.map(id => + TeamsAPI.disassociateInstanceGroup(team.id, id) + ) + ); + } catch (err) { + this.setState({ error: err }); + } + } + + 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..ac38acc3d2 --- /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 updatedOrgData = { + name: 'new name', + description: 'new description', + }; + wrapper.find('TeamForm').prop('handleSubmit')(updatedOrgData); + + expect(TeamsAPI.update).toHaveBeenCalledWith(1, updatedOrgData); + }); + + 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..ac60f0c8ae --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamList/TeamList.jsx @@ -0,0 +1,227 @@ +import React, { Component, Fragment } from 'react'; +import { withRouter } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Card, PageSection } from '@patternfly/react-core'; + +import { 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 === 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..92b2df06d0 --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamList/TeamList.test.jsx @@ -0,0 +1,203 @@ +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: { + count: 0, + results: [], + }, + }); + TeamsAPI.readOptions = () => + Promise.resolve({ + data: { + actions: { + GET: {}, + POST: {}, + }, + }, + }); + }); + + test('initially renders succesfully', () => { + mountWithContexts(); + }); + + test('Puts 1 selected Team in state when handleSelect is called.', () => { + wrapper = mountWithContexts().find('TeamsList'); + + wrapper.setState({ + teams: mockAPITeamsList.data.results, + itemCount: 3, + isInitialized: true, + }); + wrapper.update(); + expect(wrapper.state('selected').length).toBe(0); + wrapper.instance().handleSelect(mockAPITeamsList.data.results.slice(0, 1)); + expect(wrapper.state('selected').length).toBe(1); + }); + + test('Puts all Teams in state when handleSelectAll is called.', () => { + wrapper = mountWithContexts(); + const list = wrapper.find('TeamsList'); + list.setState({ + teams: mockAPITeamsList.data.results, + itemCount: 3, + isInitialized: true, + }); + expect(list.state('selected').length).toBe(0); + list.instance().handleSelectAll(true); + wrapper.update(); + expect(list.state('selected').length).toEqual(list.state('teams').length); + }); + + 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..c77ed7dd31 --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx @@ -0,0 +1,70 @@ +import React, { Fragment } from 'react'; +import { string, bool, func } from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { + DataListItem, + DataListItemRow, + DataListItemCells, +} from '@patternfly/react-core'; +import { Link } from 'react-router-dom'; + +import DataListCell from '@components/DataListCell'; +import DataListCheck from '@components/DataListCheck'; +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 } = this.props; + const labelId = `check-action-${team.id}`; + return ( + + + + + + + {team.name} + + , + + {team.summary_fields.organization && ( + + Organization + + {team.summary_fields.organization.name} + + + )} + , + + edit button goes here + , + ]} + /> + + + ); + } +} +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..b4d10fa56e --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamList/TeamListItem.test.jsx @@ -0,0 +1,33 @@ +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( + + + {}} + /> + + + ); + }); +}); 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..5e424471f1 --- /dev/null +++ b/awx/ui_next/src/screens/Team/shared/TeamForm.jsx @@ -0,0 +1,98 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +import { withRouter } from 'react-router-dom'; +import { Formik } from 'formik'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; + +import { Form } from '@patternfly/react-core'; + +import FormRow from '@components/FormRow'; +import FormField from '@components/FormField'; +import FormActionGroup from '@components/FormActionGroup/FormActionGroup'; +import { required } from '@util/validators'; + +class TeamForm extends Component { + constructor(props) { + super(props); + + this.handleSubmit = this.handleSubmit.bind(this); + + this.state = { + formIsValid: true, + }; + } + + isEditingNewTeam() { + const { team } = this.props; + return !team.id; + } + + handleSubmit(values) { + const { handleSubmit } = this.props; + + handleSubmit(values); + } + + render() { + const { team, handleCancel, i18n } = this.props; + const { formIsValid, error } = this.state; + + return ( + ( +
+ + + + + + {error ?
error
: null} + + )} + /> + ); + } +} + +FormField.propTypes = { + label: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired, +}; + +TeamForm.propTypes = { + team: PropTypes.shape(), + handleSubmit: PropTypes.func.isRequired, + handleCancel: PropTypes.func.isRequired, +}; + +TeamForm.defaultProps = { + team: { + name: '', + description: '', + }, +}; + +export { TeamForm as _TeamForm }; +export default withI18n()(withRouter(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..5594692598 --- /dev/null +++ b/awx/ui_next/src/screens/Team/shared/TeamForm.test.jsx @@ -0,0 +1,81 @@ +import React from 'react'; + +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import { sleep } from '@testUtils/testUtils'; + +import TeamForm from './TeamForm'; + +jest.mock('@api'); + +describe('', () => { + const meConfig = { + me: { + is_superuser: false, + }, + }; + const mockData = { + id: 1, + name: 'Foo', + description: 'Bar', + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('changing inputs should update form values', () => { + const wrapper = mountWithContexts( + + ); + + const form = wrapper.find('Formik'); + wrapper.find('input#org-name').simulate('change', { + target: { value: 'new foo', name: 'name' }, + }); + expect(form.state('values').name).toEqual('new foo'); + wrapper.find('input#org-description').simulate('change', { + target: { value: 'new bar', name: 'description' }, + }); + expect(form.state('values').description).toEqual('new bar'); + }); + + test('calls handleSubmit when form submitted', async () => { + const handleSubmit = jest.fn(); + const wrapper = mountWithContexts( + + ); + expect(handleSubmit).not.toHaveBeenCalled(); + wrapper.find('button[aria-label="Save"]').simulate('click'); + await sleep(1); + expect(handleSubmit).toHaveBeenCalledWith({ + name: 'Foo', + description: 'Bar', + }); + }); + + test('calls "handleCancel" when Cancel button is clicked', () => { + const handleCancel = jest.fn(); + + const 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/types.js b/awx/ui_next/src/types.js index bf80f7631f..6675854c55 100644 --- a/awx/ui_next/src/types.js +++ b/awx/ui_next/src/types.js @@ -204,3 +204,9 @@ export const Host = shape({ last_job: number, last_job_host_summary: number, }); + +export const Team = shape({ + id: number.isRequired, + name: string.isRequired, + organization: number, +});