From 1e80b2e2959226c5a4f6d48decd7cb05016ae7e1 Mon Sep 17 00:00:00 2001 From: mabashian Date: Fri, 25 Oct 2019 14:35:50 -0400 Subject: [PATCH 1/5] 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, +}); From 6acd3c98b7e1e7e3f4a37564ae1fda186ee36b68 Mon Sep 17 00:00:00 2001 From: mabashian Date: Fri, 25 Oct 2019 14:43:21 -0400 Subject: [PATCH 2/5] Updates stale copy pasta. Org -> Team --- awx/ui_next/src/screens/Team/TeamAdd/TeamAdd.test.jsx | 6 +++--- awx/ui_next/src/screens/Team/TeamDetail/TeamDetail.test.jsx | 6 +++--- awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.test.jsx | 6 +++--- awx/ui_next/src/screens/Team/shared/TeamForm.jsx | 4 ++-- awx/ui_next/src/screens/Team/shared/TeamForm.test.jsx | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/awx/ui_next/src/screens/Team/TeamAdd/TeamAdd.test.jsx b/awx/ui_next/src/screens/Team/TeamAdd/TeamAdd.test.jsx index a68aff00da..ca8c7caa56 100644 --- a/awx/ui_next/src/screens/Team/TeamAdd/TeamAdd.test.jsx +++ b/awx/ui_next/src/screens/Team/TeamAdd/TeamAdd.test.jsx @@ -9,12 +9,12 @@ jest.mock('@api'); describe('', () => { test('handleSubmit should post to api', () => { const wrapper = mountWithContexts(); - const updatedOrgData = { + const updatedTeamData = { name: 'new name', description: 'new description', }; - wrapper.find('TeamForm').prop('handleSubmit')(updatedOrgData); - expect(TeamsAPI.create).toHaveBeenCalledWith(updatedOrgData); + wrapper.find('TeamForm').prop('handleSubmit')(updatedTeamData); + expect(TeamsAPI.create).toHaveBeenCalledWith(updatedTeamData); }); test('should navigate to teams list when cancel is clicked', () => { diff --git a/awx/ui_next/src/screens/Team/TeamDetail/TeamDetail.test.jsx b/awx/ui_next/src/screens/Team/TeamDetail/TeamDetail.test.jsx index 7d0a620517..5b971c454c 100644 --- a/awx/ui_next/src/screens/Team/TeamDetail/TeamDetail.test.jsx +++ b/awx/ui_next/src/screens/Team/TeamDetail/TeamDetail.test.jsx @@ -54,9 +54,9 @@ describe('', () => { }); 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(); + 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/TeamEdit/TeamEdit.test.jsx b/awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.test.jsx index ac38acc3d2..7ea335361a 100644 --- a/awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.test.jsx +++ b/awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.test.jsx @@ -22,13 +22,13 @@ describe('', () => { test('handleSubmit should call api update', () => { const wrapper = mountWithContexts(); - const updatedOrgData = { + const updatedTeamData = { name: 'new name', description: 'new description', }; - wrapper.find('TeamForm').prop('handleSubmit')(updatedOrgData); + wrapper.find('TeamForm').prop('handleSubmit')(updatedTeamData); - expect(TeamsAPI.update).toHaveBeenCalledWith(1, updatedOrgData); + expect(TeamsAPI.update).toHaveBeenCalledWith(1, updatedTeamData); }); test('should navigate to team detail when cancel is clicked', () => { diff --git a/awx/ui_next/src/screens/Team/shared/TeamForm.jsx b/awx/ui_next/src/screens/Team/shared/TeamForm.jsx index 5e424471f1..9259b008bb 100644 --- a/awx/ui_next/src/screens/Team/shared/TeamForm.jsx +++ b/awx/ui_next/src/screens/Team/shared/TeamForm.jsx @@ -50,7 +50,7 @@ class TeamForm extends Component {
', () => { ); const form = wrapper.find('Formik'); - wrapper.find('input#org-name').simulate('change', { + wrapper.find('input#team-name').simulate('change', { target: { value: 'new foo', name: 'name' }, }); expect(form.state('values').name).toEqual('new foo'); - wrapper.find('input#org-description').simulate('change', { + wrapper.find('input#team-description').simulate('change', { target: { value: 'new bar', name: 'description' }, }); expect(form.state('values').description).toEqual('new bar'); From 84bce530dc25a06a8bed9d86e9159cf07a2109d7 Mon Sep 17 00:00:00 2001 From: mabashian Date: Fri, 1 Nov 2019 14:34:35 -0400 Subject: [PATCH 3/5] Adds organization field to team form. Adds edit button to team list items. --- .../src/screens/Team/TeamAdd/TeamAdd.test.jsx | 8 + .../src/screens/Team/TeamEdit/TeamEdit.jsx | 22 +-- .../screens/Team/TeamList/TeamListItem.jsx | 27 +++- .../Team/TeamList/TeamListItem.test.jsx | 51 +++++- .../src/screens/Team/shared/TeamForm.jsx | 153 +++++++++--------- .../src/screens/Team/shared/TeamForm.test.jsx | 51 +++--- 6 files changed, 185 insertions(+), 127 deletions(-) diff --git a/awx/ui_next/src/screens/Team/TeamAdd/TeamAdd.test.jsx b/awx/ui_next/src/screens/Team/TeamAdd/TeamAdd.test.jsx index ca8c7caa56..20eb762710 100644 --- a/awx/ui_next/src/screens/Team/TeamAdd/TeamAdd.test.jsx +++ b/awx/ui_next/src/screens/Team/TeamAdd/TeamAdd.test.jsx @@ -12,6 +12,7 @@ describe('', () => { const updatedTeamData = { name: 'new name', description: 'new description', + organization: 1, }; wrapper.find('TeamForm').prop('handleSubmit')(updatedTeamData); expect(TeamsAPI.create).toHaveBeenCalledWith(updatedTeamData); @@ -40,11 +41,18 @@ describe('', () => { 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(, { diff --git a/awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.jsx b/awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.jsx index a8c35e7039..a6580b4ce9 100644 --- a/awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.jsx +++ b/awx/ui_next/src/screens/Team/TeamEdit/TeamEdit.jsx @@ -13,7 +13,6 @@ class TeamEdit extends Component { 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); @@ -22,11 +21,10 @@ class TeamEdit extends Component { }; } - async handleSubmit(values, groupsToAssociate, groupsToDisassociate) { + async handleSubmit(values) { const { team } = this.props; try { await TeamsAPI.update(team.id, values); - await this.submitInstanceGroups(groupsToAssociate, groupsToDisassociate); this.handleSuccess(); } catch (err) { this.setState({ error: err }); @@ -49,24 +47,6 @@ class TeamEdit extends Component { 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; diff --git a/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx b/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx index c77ed7dd31..73ddbcad14 100644 --- a/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx +++ b/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx @@ -1,15 +1,20 @@ 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'; @@ -22,7 +27,7 @@ class TeamListItem extends React.Component { }; render() { - const { team, isSelected, onSelect, detailUrl } = this.props; + const { team, isSelected, onSelect, detailUrl, i18n } = this.props; const labelId = `check-action-${team.id}`; return ( @@ -48,7 +53,9 @@ class TeamListItem extends React.Component { {team.summary_fields.organization && ( - Organization + + {i18n._(t`Organization`)} + @@ -57,9 +64,19 @@ class TeamListItem extends React.Component { )} , - - edit button goes here - , + + {team.summary_fields.user_capabilities.edit && ( + + + + + + )} + , ]} /> diff --git a/awx/ui_next/src/screens/Team/TeamList/TeamListItem.test.jsx b/awx/ui_next/src/screens/Team/TeamList/TeamListItem.test.jsx index b4d10fa56e..73d9329c7e 100644 --- a/awx/ui_next/src/screens/Team/TeamList/TeamListItem.test.jsx +++ b/awx/ui_next/src/screens/Team/TeamList/TeamListItem.test.jsx @@ -16,9 +16,8 @@ describe('', () => { id: 1, name: 'Team 1', summary_fields: { - organization: { - id: 1, - name: 'Default', + user_capabilities: { + edit: true, }, }, }} @@ -30,4 +29,50 @@ describe('', () => { ); }); + 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/shared/TeamForm.jsx b/awx/ui_next/src/screens/Team/shared/TeamForm.jsx index 9259b008bb..65a3a12cbd 100644 --- a/awx/ui_next/src/screens/Team/shared/TeamForm.jsx +++ b/awx/ui_next/src/screens/Team/shared/TeamForm.jsx @@ -1,98 +1,91 @@ -import React, { Component } from 'react'; +import React, { useState } 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 { Formik, Field } from 'formik'; import { Form } from '@patternfly/react-core'; - -import FormRow from '@components/FormRow'; -import FormField from '@components/FormField'; 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'; -class TeamForm extends Component { - constructor(props) { - super(props); +function TeamForm(props) { + const { team, handleCancel, handleSubmit, i18n } = props; + const [organization, setOrganization] = useState( + team.summary_fields ? team.summary_fields.organization : null + ); - 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} - - )} - /> - ); - } + + ( + form.setFieldTouched('organization')} + onChange={value => { + form.setFieldValue('organization', value.id); + setOrganization(value); + }} + value={organization} + required + /> + )} + /> +
+ + + )} + /> + ); } -FormField.propTypes = { - label: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired, -}; - TeamForm.propTypes = { - team: PropTypes.shape(), - handleSubmit: PropTypes.func.isRequired, handleCancel: PropTypes.func.isRequired, + handleSubmit: PropTypes.func.isRequired, + team: PropTypes.shape({}), }; TeamForm.defaultProps = { - team: { - name: '', - description: '', - }, + team: {}, }; -export { TeamForm as _TeamForm }; -export default withI18n()(withRouter(TeamForm)); +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 index 1aaca85393..0d8a483417 100644 --- a/awx/ui_next/src/screens/Team/shared/TeamForm.test.jsx +++ b/awx/ui_next/src/screens/Team/shared/TeamForm.test.jsx @@ -1,6 +1,6 @@ import React from 'react'; - -import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import { sleep } from '@testUtils/testUtils'; import TeamForm from './TeamForm'; @@ -8,6 +8,7 @@ import TeamForm from './TeamForm'; jest.mock('@api'); describe('', () => { + let wrapper; const meConfig = { me: { is_superuser: false, @@ -17,14 +18,20 @@ describe('', () => { 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', () => { - const wrapper = mountWithContexts( + wrapper = mountWithContexts( ', () => { 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('calls handleSubmit when form submitted', async () => { + test('should call handleSubmit when Submit button is clicked', async () => { const handleSubmit = jest.fn(); - const wrapper = mountWithContexts( - - ); + 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).toHaveBeenCalledWith({ - name: 'Foo', - description: 'Bar', - }); + expect(handleSubmit).toBeCalled(); }); - test('calls "handleCancel" when Cancel button is clicked', () => { + test('calls handleCancel when Cancel button is clicked', () => { const handleCancel = jest.fn(); - const wrapper = mountWithContexts( + wrapper = mountWithContexts( Date: Fri, 8 Nov 2019 11:56:01 -0500 Subject: [PATCH 4/5] Updates a couple of team list checkbox tests to test the UI rather than state/props internals. Fixes bug where select all was selected on load. --- .../DataListCheck/DataListCheck.jsx | 1 + awx/ui_next/src/screens/Host/Host.test.jsx | 5 +- .../src/screens/Host/HostList/HostList.jsx | 2 +- .../src/screens/Inventory/Inventory.test.jsx | 5 +- .../Inventory/InventoryList/InventoryList.jsx | 2 +- .../screens/Inventory/SmartInventory.test.jsx | 5 +- .../Organization/Organization.test.jsx | 5 +- .../src/screens/Project/Project.test.jsx | 5 +- .../Project/ProjectList/ProjectList.jsx | 2 +- awx/ui_next/src/screens/Team/Team.jsx | 14 ++--- awx/ui_next/src/screens/Team/Team.test.jsx | 5 +- .../src/screens/Team/TeamList/TeamList.jsx | 2 +- .../screens/Team/TeamList/TeamList.test.jsx | 58 +++++++++++-------- .../screens/Team/TeamList/TeamListItem.jsx | 1 - .../src/screens/Template/Template.test.jsx | 3 +- 15 files changed, 58 insertions(+), 57 deletions(-) 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..3b6f6a9ea6 100644 --- a/awx/ui_next/src/screens/Host/HostList/HostList.jsx +++ b/awx/ui_next/src/screens/Host/HostList/HostList.jsx @@ -175,7 +175,7 @@ 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..aaef81ca21 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryList/InventoryList.jsx @@ -174,7 +174,7 @@ 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/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..b6c112fd0b 100644 --- a/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx +++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx @@ -141,7 +141,7 @@ 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 index abcae4913b..1ebbe5b7b9 100644 --- a/awx/ui_next/src/screens/Team/Team.jsx +++ b/awx/ui_next/src/screens/Team/Team.jsx @@ -15,6 +15,13 @@ 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); @@ -73,13 +80,6 @@ class Team extends Component { { 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 = ( ', () => { +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 done => { + test('should show content error when user attempts to navigate to erroneous route', async () => { const history = createMemoryHistory({ initialEntries: ['/teams/1/foobar'], }); @@ -63,6 +63,5 @@ describe.only('', () => { } ); await waitForElement(wrapper, 'ContentError', el => el.length === 1); - done(); }); }); diff --git a/awx/ui_next/src/screens/Team/TeamList/TeamList.jsx b/awx/ui_next/src/screens/Team/TeamList/TeamList.jsx index ac60f0c8ae..97b5c4fc15 100644 --- a/awx/ui_next/src/screens/Team/TeamList/TeamList.jsx +++ b/awx/ui_next/src/screens/Team/TeamList/TeamList.jsx @@ -139,7 +139,7 @@ class TeamsList extends Component { const canAdd = actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); - const isAllSelected = selected.length === teams.length; + const isAllSelected = selected.length > 0 && selected.length === teams.length; return ( diff --git a/awx/ui_next/src/screens/Team/TeamList/TeamList.test.jsx b/awx/ui_next/src/screens/Team/TeamList/TeamList.test.jsx index 92b2df06d0..b043e73740 100644 --- a/awx/ui_next/src/screens/Team/TeamList/TeamList.test.jsx +++ b/awx/ui_next/src/screens/Team/TeamList/TeamList.test.jsx @@ -53,10 +53,7 @@ describe('', () => { beforeEach(() => { TeamsAPI.read = () => Promise.resolve({ - data: { - count: 0, - results: [], - }, + data: mockAPITeamsList.data, }); TeamsAPI.readOptions = () => Promise.resolve({ @@ -73,32 +70,43 @@ describe('', () => { 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, - }); + 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.state('selected').length).toBe(0); - wrapper.instance().handleSelect(mockAPITeamsList.data.results.slice(0, 1)); - expect(wrapper.state('selected').length).toBe(1); + expect(wrapper.find('input[type="checkbox"]').findWhere(n => n.prop('checked') === true).length).toBe(1); }); - test('Puts all Teams in state when handleSelectAll is called.', () => { + test('Select all checkbox selects and unselects all rows', async () => { 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); + 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(list.state('selected').length).toEqual(list.state('teams').length); + 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.', () => { diff --git a/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx b/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx index 73ddbcad14..b024ed85c8 100644 --- a/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx +++ b/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx @@ -45,7 +45,6 @@ class TeamListItem extends React.Component { {team.name} 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('