From 01963b0ee7c8b4d61c6be9920b52f837d560aa2f Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 30 Oct 2019 15:54:33 -0400 Subject: [PATCH 1/3] Add host list, add/edit forms, and details --- awx/api/serializers.py | 1 + awx/ui_next/src/api/index.js | 3 + awx/ui_next/src/api/models/Hosts.js | 10 + .../CodeMirrorInput/VariablesField.jsx | 2 +- .../src/components/Sparkline/Sparkline.jsx | 3 +- awx/ui_next/src/index.jsx | 6 + awx/ui_next/src/screens/Host/Host.jsx | 192 ++++++++++++ awx/ui_next/src/screens/Host/Host.test.jsx | 46 +++ .../src/screens/Host/HostAdd/HostAdd.jsx | 73 +++++ .../src/screens/Host/HostAdd/HostAdd.test.jsx | 61 ++++ awx/ui_next/src/screens/Host/HostAdd/index.js | 1 + .../HostCompletedJobs/HostCompletedJobs.jsx | 10 + .../screens/Host/HostCompletedJobs/index.js | 1 + .../screens/Host/HostDetail/HostDetail.jsx | 108 +++++++ .../Host/HostDetail/HostDetail.test.jsx | 68 +++++ .../src/screens/Host/HostDetail/index.js | 1 + .../src/screens/Host/HostEdit/HostEdit.jsx | 77 +++++ .../screens/Host/HostEdit/HostEdit.test.jsx | 47 +++ .../src/screens/Host/HostEdit/index.js | 1 + .../src/screens/Host/HostFacts/HostFacts.jsx | 10 + .../src/screens/Host/HostFacts/index.js | 1 + .../screens/Host/HostGroups/HostGroups.jsx | 10 + .../src/screens/Host/HostGroups/index.js | 1 + .../src/screens/Host/HostList/HostList.jsx | 273 ++++++++++++++++++ .../screens/Host/HostList/HostList.test.jsx | 261 +++++++++++++++++ .../screens/Host/HostList/HostListItem.jsx | 119 ++++++++ .../Host/HostList/HostListItem.test.jsx | 95 ++++++ .../src/screens/Host/HostList/index.js | 0 awx/ui_next/src/screens/Host/Hosts.jsx | 80 +++++ awx/ui_next/src/screens/Host/Hosts.test.jsx | 33 +++ awx/ui_next/src/screens/Host/data.host.json | 98 +++++++ awx/ui_next/src/screens/Host/index.js | 1 + .../src/screens/Host/shared/HostForm.jsx | 147 ++++++++++ .../src/screens/Host/shared/HostForm.test.jsx | 90 ++++++ awx/ui_next/src/screens/Host/shared/index.js | 0 awx/ui_next/src/types.js | 29 ++ 36 files changed, 1957 insertions(+), 2 deletions(-) create mode 100644 awx/ui_next/src/api/models/Hosts.js create mode 100644 awx/ui_next/src/screens/Host/Host.jsx create mode 100644 awx/ui_next/src/screens/Host/Host.test.jsx create mode 100644 awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx create mode 100644 awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx create mode 100644 awx/ui_next/src/screens/Host/HostAdd/index.js create mode 100644 awx/ui_next/src/screens/Host/HostCompletedJobs/HostCompletedJobs.jsx create mode 100644 awx/ui_next/src/screens/Host/HostCompletedJobs/index.js create mode 100644 awx/ui_next/src/screens/Host/HostDetail/HostDetail.jsx create mode 100644 awx/ui_next/src/screens/Host/HostDetail/HostDetail.test.jsx create mode 100644 awx/ui_next/src/screens/Host/HostDetail/index.js create mode 100644 awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx create mode 100644 awx/ui_next/src/screens/Host/HostEdit/HostEdit.test.jsx create mode 100644 awx/ui_next/src/screens/Host/HostEdit/index.js create mode 100644 awx/ui_next/src/screens/Host/HostFacts/HostFacts.jsx create mode 100644 awx/ui_next/src/screens/Host/HostFacts/index.js create mode 100644 awx/ui_next/src/screens/Host/HostGroups/HostGroups.jsx create mode 100644 awx/ui_next/src/screens/Host/HostGroups/index.js create mode 100644 awx/ui_next/src/screens/Host/HostList/HostList.jsx create mode 100644 awx/ui_next/src/screens/Host/HostList/HostList.test.jsx create mode 100644 awx/ui_next/src/screens/Host/HostList/HostListItem.jsx create mode 100644 awx/ui_next/src/screens/Host/HostList/HostListItem.test.jsx create mode 100644 awx/ui_next/src/screens/Host/HostList/index.js create mode 100644 awx/ui_next/src/screens/Host/Hosts.jsx create mode 100644 awx/ui_next/src/screens/Host/Hosts.test.jsx create mode 100644 awx/ui_next/src/screens/Host/data.host.json create mode 100644 awx/ui_next/src/screens/Host/index.js create mode 100644 awx/ui_next/src/screens/Host/shared/HostForm.jsx create mode 100644 awx/ui_next/src/screens/Host/shared/HostForm.test.jsx create mode 100644 awx/ui_next/src/screens/Host/shared/index.js diff --git a/awx/api/serializers.py b/awx/api/serializers.py index a3d8d43306..15e5a808cb 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1701,6 +1701,7 @@ class HostSerializer(BaseSerializerWithVariables): 'name': j.job.job_template.name if j.job.job_template is not None else "", 'status': j.job.status, 'finished': j.job.finished, + 'type': j.job.get_real_instance_class()._meta.verbose_name.replace(' ', '_') } for j in obj.job_host_summaries.select_related('job__job_template').order_by('-created')[:5]]) return d diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js index 526cb7c35c..fe49a8247b 100644 --- a/awx/ui_next/src/api/index.js +++ b/awx/ui_next/src/api/index.js @@ -2,6 +2,7 @@ import AdHocCommands from './models/AdHocCommands'; import Config from './models/Config'; import CredentialTypes from './models/CredentialTypes'; import Credentials from './models/Credentials'; +import Hosts from './models/Hosts'; import InstanceGroups from './models/InstanceGroups'; import Inventories from './models/Inventories'; import InventorySources from './models/InventorySources'; @@ -27,6 +28,7 @@ const AdHocCommandsAPI = new AdHocCommands(); const ConfigAPI = new Config(); const CredentialsAPI = new Credentials(); const CredentialTypesAPI = new CredentialTypes(); +const HostsAPI = new Hosts(); const InstanceGroupsAPI = new InstanceGroups(); const InventoriesAPI = new Inventories(); const InventorySourcesAPI = new InventorySources(); @@ -53,6 +55,7 @@ export { ConfigAPI, CredentialsAPI, CredentialTypesAPI, + HostsAPI, InstanceGroupsAPI, InventoriesAPI, InventorySourcesAPI, diff --git a/awx/ui_next/src/api/models/Hosts.js b/awx/ui_next/src/api/models/Hosts.js new file mode 100644 index 0000000000..d36b5f15a3 --- /dev/null +++ b/awx/ui_next/src/api/models/Hosts.js @@ -0,0 +1,10 @@ +import Base from '../Base'; + +class Hosts extends Base { + constructor(http) { + super(http); + this.baseUrl = '/api/v2/hosts/'; + } +} + +export default Hosts; diff --git a/awx/ui_next/src/components/CodeMirrorInput/VariablesField.jsx b/awx/ui_next/src/components/CodeMirrorInput/VariablesField.jsx index f7452d915f..3c828c6308 100644 --- a/awx/ui_next/src/components/CodeMirrorInput/VariablesField.jsx +++ b/awx/ui_next/src/components/CodeMirrorInput/VariablesField.jsx @@ -26,7 +26,7 @@ function VariablesField({ id, name, label, readOnly }) { diff --git a/awx/ui_next/src/components/Sparkline/Sparkline.jsx b/awx/ui_next/src/components/Sparkline/Sparkline.jsx index 57029ce73a..dee54ac9c3 100644 --- a/awx/ui_next/src/components/Sparkline/Sparkline.jsx +++ b/awx/ui_next/src/components/Sparkline/Sparkline.jsx @@ -6,6 +6,7 @@ import { StatusIcon } from '@components/Sparkline'; import { Tooltip } from '@patternfly/react-core'; import styled from 'styled-components'; import { t } from '@lingui/macro'; +import { formatDateString } from '@util/dates'; import { JOB_TYPE_URL_SEGMENTS } from '../../constants'; /* eslint-disable react/jsx-pascal-case */ @@ -25,7 +26,7 @@ const Sparkline = ({ i18n, jobs }) => { {job.finished && (
- {i18n._(t`FINISHED:`)} {job.finished} + {i18n._(t`FINISHED:`)} {formatDateString(job.finished)}
)} diff --git a/awx/ui_next/src/index.jsx b/awx/ui_next/src/index.jsx index cd64f64e9d..9227e6e7e3 100644 --- a/awx/ui_next/src/index.jsx +++ b/awx/ui_next/src/index.jsx @@ -13,6 +13,7 @@ import Applications from '@screens/Application'; import Credentials from '@screens/Credential'; import CredentialTypes from '@screens/CredentialType'; import Dashboard from '@screens/Dashboard'; +import Hosts from '@screens/Host'; import InstanceGroups from '@screens/InstanceGroup'; import Inventories from '@screens/Inventory'; import InventoryScripts from '@screens/InventoryScript'; @@ -140,6 +141,11 @@ export function main(render) { path: '/inventories', component: Inventories, }, + { + title: i18n._(t`Hosts`), + path: '/hosts', + component: Hosts, + }, { title: i18n._(t`Inventory Scripts`), path: '/inventory_scripts', diff --git a/awx/ui_next/src/screens/Host/Host.jsx b/awx/ui_next/src/screens/Host/Host.jsx new file mode 100644 index 0000000000..6817e332b3 --- /dev/null +++ b/awx/ui_next/src/screens/Host/Host.jsx @@ -0,0 +1,192 @@ +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 HostFacts from './HostFacts'; +import HostDetail from './HostDetail'; +import HostEdit from './HostEdit'; +import HostGroups from './HostGroups'; +import HostCompletedJobs from './HostCompletedJobs'; +import { HostsAPI } from '@api'; + +class Host extends Component { + constructor(props) { + super(props); + + this.state = { + host: null, + hasContentLoading: true, + contentError: null, + isInitialized: false, + }; + this.loadHost = this.loadHost.bind(this); + } + + async componentDidMount() { + await this.loadHost(); + this.setState({ isInitialized: true }); + } + + async componentDidUpdate(prevProps) { + const { location, match } = this.props; + const url = `/hosts/${match.params.id}/`; + + if ( + prevProps.location.pathname.startsWith(url) && + prevProps.location !== location && + location.pathname === `${url}details` + ) { + await this.loadHost(); + } + } + + async loadHost() { + const { match, setBreadcrumb } = this.props; + const id = parseInt(match.params.id, 10); + + this.setState({ contentError: null, hasContentLoading: true }); + try { + const { data } = await HostsAPI.readDetail(id); + setBreadcrumb(data); + this.setState({ host: data }); + } catch (err) { + this.setState({ contentError: err }); + } finally { + this.setState({ hasContentLoading: false }); + } + } + + render() { + const { location, match, history, i18n } = this.props; + + const { host, contentError, hasContentLoading, isInitialized } = this.state; + + const tabsArray = [ + { name: i18n._(t`Details`), link: `${match.url}/details`, id: 0 }, + { name: i18n._(t`Facts`), link: `${match.url}/facts`, id: 1 }, + { name: i18n._(t`Groups`), link: `${match.url}/groups`, id: 2 }, + { + name: i18n._(t`Completed Jobs`), + link: `${match.url}/completed_jobs`, + id: 3, + }, + ]; + + 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._(`Host not found.`)}{' '} + {i18n._(`View all Hosts.`)} + + )} + + + + ); + } + + return ( + + + {cardHeader} + + + {host && ( + } + /> + )} + {host && ( + } + /> + )} + {host && ( + } + /> + )} + {host && ( + } + /> + )} + {host && ( + } + /> + )} + + !hasContentLoading && ( + + {match.params.id && ( + + {i18n._(`View Host Details`)} + + )} + + ) + } + /> + , + + + + ); + } +} + +export default withI18n()(withRouter(Host)); +export { Host as _Host }; diff --git a/awx/ui_next/src/screens/Host/Host.test.jsx b/awx/ui_next/src/screens/Host/Host.test.jsx new file mode 100644 index 0000000000..94b7dee895 --- /dev/null +++ b/awx/ui_next/src/screens/Host/Host.test.jsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { createMemoryHistory } from 'history'; +import { HostsAPI } from '@api'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import mockDetails from './data.host.json'; +import Host from './Host'; + +jest.mock('@api'); + +const mockMe = { + is_super_user: true, + is_system_auditor: false, +}; + +describe.only('', () => { + 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 => { + const history = createMemoryHistory({ + initialEntries: ['/hosts/1/foobar'], + }); + const wrapper = mountWithContexts( + {}} me={mockMe} />, + { + context: { + router: { + history, + route: { + location: history.location, + match: { + params: { id: 1 }, + url: '/hosts/1/foobar', + path: '/host/1/foobar', + }, + }, + }, + }, + } + ); + await waitForElement(wrapper, 'ContentError', el => el.length === 1); + done(); + }); +}); diff --git a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.jsx new file mode 100644 index 0000000000..8804b8ed91 --- /dev/null +++ b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.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 { HostsAPI } from '@api'; +import { Config } from '@contexts/Config'; +import CardCloseButton from '@components/CardCloseButton'; + +import HostForm from '../shared/HostForm'; + +class HostAdd 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 HostsAPI.create(values); + history.push(`/hosts/${response.id}`); + } catch (error) { + this.setState({ error }); + } + } + + handleCancel() { + const { history } = this.props; + history.push('/hosts'); + } + + render() { + const { error } = this.state; + const { i18n } = this.props; + + return ( + + + + + + + + + + {({ me }) => ( + + )} + + {error ?
error
: ''} +
+
+
+ ); + } +} + +export { HostAdd as _HostAdd }; +export default withI18n()(withRouter(HostAdd)); diff --git a/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx new file mode 100644 index 0000000000..ab1be4ed6f --- /dev/null +++ b/awx/ui_next/src/screens/Host/HostAdd/HostAdd.test.jsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { createMemoryHistory } from 'history'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import HostAdd from './HostAdd'; +import { HostsAPI } from '@api'; + +jest.mock('@api'); + +describe('', () => { + test('handleSubmit should post to api', () => { + const wrapper = mountWithContexts(); + const updatedHostData = { + name: 'new name', + description: 'new description', + inventory: 1, + variables: '---\nfoo: bar', + }; + wrapper.find('HostForm').prop('handleSubmit')(updatedHostData); + expect(HostsAPI.create).toHaveBeenCalledWith(updatedHostData); + }); + + test('should navigate to hosts 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('/hosts'); + }); + + test('should navigate to hosts 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('/hosts'); + }); + + test('successful form submission should trigger redirect', async () => { + const history = createMemoryHistory({}); + const hostData = { + name: 'new name', + description: 'new description', + inventory: 1, + variables: '---\nfoo: bar', + }; + HostsAPI.create.mockResolvedValueOnce({ + data: { + id: 5, + ...hostData, + }, + }); + const wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + await waitForElement(wrapper, 'button[aria-label="Save"]'); + await wrapper.find('HostForm').prop('handleSubmit')(hostData); + expect(history.location.pathname).toEqual('/hosts/5'); + }); +}); diff --git a/awx/ui_next/src/screens/Host/HostAdd/index.js b/awx/ui_next/src/screens/Host/HostAdd/index.js new file mode 100644 index 0000000000..98cccae21c --- /dev/null +++ b/awx/ui_next/src/screens/Host/HostAdd/index.js @@ -0,0 +1 @@ +export { default } from './HostAdd'; diff --git a/awx/ui_next/src/screens/Host/HostCompletedJobs/HostCompletedJobs.jsx b/awx/ui_next/src/screens/Host/HostCompletedJobs/HostCompletedJobs.jsx new file mode 100644 index 0000000000..63859254a6 --- /dev/null +++ b/awx/ui_next/src/screens/Host/HostCompletedJobs/HostCompletedJobs.jsx @@ -0,0 +1,10 @@ +import React, { Component } from 'react'; +import { CardBody } from '@patternfly/react-core'; + +class HostCompletedJobs extends Component { + render() { + return Coming soon :); + } +} + +export default HostCompletedJobs; diff --git a/awx/ui_next/src/screens/Host/HostCompletedJobs/index.js b/awx/ui_next/src/screens/Host/HostCompletedJobs/index.js new file mode 100644 index 0000000000..9dbe868803 --- /dev/null +++ b/awx/ui_next/src/screens/Host/HostCompletedJobs/index.js @@ -0,0 +1 @@ +export { default } from './HostCompletedJobs'; diff --git a/awx/ui_next/src/screens/Host/HostDetail/HostDetail.jsx b/awx/ui_next/src/screens/Host/HostDetail/HostDetail.jsx new file mode 100644 index 0000000000..479d28211f --- /dev/null +++ b/awx/ui_next/src/screens/Host/HostDetail/HostDetail.jsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { Link, withRouter } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import styled from 'styled-components'; +import { Host } from '@types'; +import { formatDateString } from '@util/dates'; +import { Button, CardBody } from '@patternfly/react-core'; +import { DetailList, Detail } from '@components/DetailList'; +import CodeMirrorInput from '@components/CodeMirrorInput'; + +const ActionButtonWrapper = styled.div` + display: flex; + justify-content: flex-end; + margin-top: 20px; + & > :not(:first-child) { + margin-left: 20px; + } +`; + +function HostDetail({ host, i18n }) { + const { created, description, id, modified, name, summary_fields } = host; + + let createdBy = ''; + if (created) { + if (summary_fields.created_by && summary_fields.created_by.username) { + createdBy = i18n._( + t`${formatDateString(created)} by ${summary_fields.created_by.username}` + ); + } else { + createdBy = formatDateString(created); + } + } + + let modifiedBy = ''; + if (modified) { + if (summary_fields.modified_by && summary_fields.modified_by.username) { + modifiedBy = i18n._( + t`${formatDateString(modified)} by ${ + summary_fields.modified_by.username + }` + ); + } else { + modifiedBy = formatDateString(modified); + } + } + + return ( + + + + + {summary_fields.inventory && ( + + {summary_fields.inventory.name} + + } + /> + )} + {/* TODO: Link to user in users */} + + {/* TODO: Link to user in users */} + + {}} + rows={6} + hasErrors={false} + /> + } + /> + + + {summary_fields.user_capabilities && + summary_fields.user_capabilities.edit && ( + + )} + + + ); +} + +HostDetail.propTypes = { + host: Host.isRequired, +}; + +export default withI18n()(withRouter(HostDetail)); diff --git a/awx/ui_next/src/screens/Host/HostDetail/HostDetail.test.jsx b/awx/ui_next/src/screens/Host/HostDetail/HostDetail.test.jsx new file mode 100644 index 0000000000..a748f9596d --- /dev/null +++ b/awx/ui_next/src/screens/Host/HostDetail/HostDetail.test.jsx @@ -0,0 +1,68 @@ +import React from 'react'; + +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; + +import HostDetail from './HostDetail'; + +jest.mock('@api'); + +describe('', () => { + const mockHost = { + id: 1, + name: 'Foo', + description: 'Bar', + inventory: 1, + created: '2015-07-07T17:21:26.429745Z', + modified: '2019-08-11T19:47:37.980466Z', + variables: '---', + summary_fields: { + inventory: { + id: 1, + name: 'test inventory', + }, + 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: 'Inventory', value: 'test inventory' }, + { 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, 'HostDetail Button'); + expect(editButton.text()).toEqual('Edit'); + expect(editButton.prop('to')).toBe('/hosts/1/edit'); + done(); + }); + + test('should hide edit button for users without edit permission', async done => { + const readOnlyHost = { ...mockHost }; + readOnlyHost.summary_fields.user_capabilities.edit = false; + const wrapper = mountWithContexts(); + await waitForElement(wrapper, 'HostDetail'); + expect(wrapper.find('HostDetail Button').length).toBe(0); + done(); + }); +}); diff --git a/awx/ui_next/src/screens/Host/HostDetail/index.js b/awx/ui_next/src/screens/Host/HostDetail/index.js new file mode 100644 index 0000000000..1ee5ffed11 --- /dev/null +++ b/awx/ui_next/src/screens/Host/HostDetail/index.js @@ -0,0 +1 @@ +export { default } from './HostDetail'; diff --git a/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx b/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx new file mode 100644 index 0000000000..e57f41baec --- /dev/null +++ b/awx/ui_next/src/screens/Host/HostEdit/HostEdit.jsx @@ -0,0 +1,77 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { withRouter } from 'react-router-dom'; +import { CardBody } from '@patternfly/react-core'; + +import { HostsAPI } from '@api'; +import { Config } from '@contexts/Config'; + +import HostForm from '../shared/HostForm'; + +class HostEdit extends Component { + constructor(props) { + super(props); + + this.handleSubmit = this.handleSubmit.bind(this); + this.handleCancel = this.handleCancel.bind(this); + this.handleSuccess = this.handleSuccess.bind(this); + + this.state = { + error: '', + }; + } + + async handleSubmit(values) { + const { host } = this.props; + try { + await HostsAPI.update(host.id, values); + this.handleSuccess(); + } catch (err) { + this.setState({ error: err }); + } + } + + handleCancel() { + const { + host: { id }, + history, + } = this.props; + history.push(`/hosts/${id}/details`); + } + + handleSuccess() { + const { + host: { id }, + history, + } = this.props; + history.push(`/hosts/${id}/details`); + } + + render() { + const { host } = this.props; + const { error } = this.state; + + return ( + + + {({ me }) => ( + + )} + + {error ?
error
: null} +
+ ); + } +} + +HostEdit.propTypes = { + host: PropTypes.shape().isRequired, +}; + +export { HostEdit as _HostEdit }; +export default withRouter(HostEdit); diff --git a/awx/ui_next/src/screens/Host/HostEdit/HostEdit.test.jsx b/awx/ui_next/src/screens/Host/HostEdit/HostEdit.test.jsx new file mode 100644 index 0000000000..fd097029b0 --- /dev/null +++ b/awx/ui_next/src/screens/Host/HostEdit/HostEdit.test.jsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { createMemoryHistory } from 'history'; +import { HostsAPI } from '@api'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import HostEdit from './HostEdit'; + +jest.mock('@api'); + +describe('', () => { + const mockData = { + id: 1, + name: 'Foo', + description: 'Bar', + inventory: 1, + variables: '---', + summary_fields: { + inventory: { + id: 1, + name: 'test inventory', + }, + }, + }; + + test('handleSubmit should call api update', () => { + const wrapper = mountWithContexts(); + + const updatedHostData = { + name: 'new name', + description: 'new description', + variables: '---\nfoo: bar', + }; + wrapper.find('HostForm').prop('handleSubmit')(updatedHostData); + + expect(HostsAPI.update).toHaveBeenCalledWith(1, updatedHostData); + }); + + test('should navigate to host 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('/hosts/1/details'); + }); +}); diff --git a/awx/ui_next/src/screens/Host/HostEdit/index.js b/awx/ui_next/src/screens/Host/HostEdit/index.js new file mode 100644 index 0000000000..3c0140c9c4 --- /dev/null +++ b/awx/ui_next/src/screens/Host/HostEdit/index.js @@ -0,0 +1 @@ +export { default } from './HostEdit'; diff --git a/awx/ui_next/src/screens/Host/HostFacts/HostFacts.jsx b/awx/ui_next/src/screens/Host/HostFacts/HostFacts.jsx new file mode 100644 index 0000000000..d86c2b7606 --- /dev/null +++ b/awx/ui_next/src/screens/Host/HostFacts/HostFacts.jsx @@ -0,0 +1,10 @@ +import React, { Component } from 'react'; +import { CardBody } from '@patternfly/react-core'; + +class HostFacts extends Component { + render() { + return Coming soon :); + } +} + +export default HostFacts; diff --git a/awx/ui_next/src/screens/Host/HostFacts/index.js b/awx/ui_next/src/screens/Host/HostFacts/index.js new file mode 100644 index 0000000000..59f799bd86 --- /dev/null +++ b/awx/ui_next/src/screens/Host/HostFacts/index.js @@ -0,0 +1 @@ +export { default } from './HostFacts'; diff --git a/awx/ui_next/src/screens/Host/HostGroups/HostGroups.jsx b/awx/ui_next/src/screens/Host/HostGroups/HostGroups.jsx new file mode 100644 index 0000000000..9757da9d4b --- /dev/null +++ b/awx/ui_next/src/screens/Host/HostGroups/HostGroups.jsx @@ -0,0 +1,10 @@ +import React, { Component } from 'react'; +import { CardBody } from '@patternfly/react-core'; + +class HostGroups extends Component { + render() { + return Coming soon :); + } +} + +export default HostGroups; diff --git a/awx/ui_next/src/screens/Host/HostGroups/index.js b/awx/ui_next/src/screens/Host/HostGroups/index.js new file mode 100644 index 0000000000..d37c1c9cb1 --- /dev/null +++ b/awx/ui_next/src/screens/Host/HostGroups/index.js @@ -0,0 +1 @@ +export { default } from './HostGroups'; diff --git a/awx/ui_next/src/screens/Host/HostList/HostList.jsx b/awx/ui_next/src/screens/Host/HostList/HostList.jsx new file mode 100644 index 0000000000..1610d57a5a --- /dev/null +++ b/awx/ui_next/src/screens/Host/HostList/HostList.jsx @@ -0,0 +1,273 @@ +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 { HostsAPI } 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 HostListItem from './HostListItem'; + +const QS_CONFIG = getQSConfig('host', { + page: 1, + page_size: 20, + order_by: 'name', +}); + +class HostsList extends Component { + constructor(props) { + super(props); + + this.state = { + hasContentLoading: true, + contentError: null, + deletionError: null, + hosts: [], + selected: [], + itemCount: 0, + actions: null, + toggleError: false, + toggleLoading: false, + }; + + this.handleSelectAll = this.handleSelectAll.bind(this); + this.handleSelect = this.handleSelect.bind(this); + this.handleHostDelete = this.handleHostDelete.bind(this); + this.handleDeleteErrorClose = this.handleDeleteErrorClose.bind(this); + this.loadHosts = this.loadHosts.bind(this); + this.handleHostToggle = this.handleHostToggle.bind(this); + this.handleHostToggleErrorClose = this.handleHostToggleErrorClose.bind( + this + ); + } + + componentDidMount() { + this.loadHosts(); + } + + componentDidUpdate(prevProps) { + const { location } = this.props; + if (location !== prevProps.location) { + this.loadHosts(); + } + } + + handleSelectAll(isSelected) { + const { hosts } = this.state; + + const selected = isSelected ? [...hosts] : []; + 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 }); + } + + handleHostToggleErrorClose() { + this.setState({ toggleError: false }); + } + + async handleHostDelete() { + const { selected } = this.state; + + this.setState({ hasContentLoading: true }); + try { + await Promise.all(selected.map(host => HostsAPI.destroy(host.id))); + } catch (err) { + this.setState({ deletionError: err }); + } finally { + await this.loadHosts(); + } + } + + async handleHostToggle(hostToToggle) { + const { hosts } = this.state; + this.setState({ toggleLoading: true }); + try { + const { data: updatedHost } = await HostsAPI.update(hostToToggle.id, { + enabled: !hostToToggle.enabled, + }); + this.setState({ + hosts: hosts.map(host => + host.id === updatedHost.id ? updatedHost : host + ), + }); + } catch (err) { + this.setState({ toggleError: true }); + } finally { + this.setState({ toggleLoading: false }); + } + } + + async loadHosts() { + 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 = HostsAPI.readOptions(); + } + + const promises = Promise.all([HostsAPI.read(params), optionsPromise]); + + this.setState({ contentError: null, hasContentLoading: true }); + try { + const [ + { + data: { count, results }, + }, + { + data: { actions }, + }, + ] = await promises; + this.setState({ + actions, + itemCount: count, + hosts: results, + selected: [], + }); + } catch (err) { + this.setState({ contentError: err }); + } finally { + this.setState({ hasContentLoading: false }); + } + } + + render() { + const { + actions, + itemCount, + contentError, + hasContentLoading, + deletionError, + selected, + hosts, + toggleLoading, + toggleError, + } = this.state; + const { match, i18n } = this.props; + + const canAdd = + actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); + const isAllSelected = selected.length === hosts.length; + + return ( + + + + ( + , + canAdd ? ( + + ) : null, + ]} + /> + )} + renderItem={o => ( + row.id === o.id)} + onSelect={() => this.handleSelect(o)} + toggleHost={this.handleHostToggle} + toggleLoading={toggleLoading} + /> + )} + emptyStateControls={ + canAdd ? ( + + ) : null + } + /> + + + {toggleError && !toggleLoading && ( + + {i18n._(t`Failed to toggle host.`)} + + + )} + {deletionError && ( + + {i18n._(t`Failed to delete one or more hosts.`)} + + + )} + + ); + } +} + +export { HostsList as _HostsList }; +export default withI18n()(withRouter(HostsList)); diff --git a/awx/ui_next/src/screens/Host/HostList/HostList.test.jsx b/awx/ui_next/src/screens/Host/HostList/HostList.test.jsx new file mode 100644 index 0000000000..e4b7060152 --- /dev/null +++ b/awx/ui_next/src/screens/Host/HostList/HostList.test.jsx @@ -0,0 +1,261 @@ +import React from 'react'; +import { HostsAPI } from '@api'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import { sleep } from '@testUtils/testUtils'; + +import HostsList, { _HostsList } from './HostList'; + +jest.mock('@api'); + +const mockHosts = [ + { + id: 1, + name: 'Host 1', + url: '/api/v2/hosts/1', + inventory: 1, + summary_fields: { + inventory: { + id: 1, + name: 'inv 1', + }, + user_capabilities: { + delete: true, + update: true, + }, + }, + }, + { + id: 2, + name: 'Host 2', + url: '/api/v2/hosts/2', + inventory: 1, + summary_fields: { + inventory: { + id: 1, + name: 'inv 1', + }, + user_capabilities: { + delete: true, + update: true, + }, + }, + }, + { + id: 3, + name: 'Host 3', + url: '/api/v2/hosts/3', + inventory: 1, + summary_fields: { + inventory: { + id: 1, + name: 'inv 1', + }, + user_capabilities: { + delete: false, + update: false, + }, + }, + }, +]; + +describe('', () => { + beforeEach(() => { + HostsAPI.read.mockResolvedValue({ + data: { + count: mockHosts.length, + results: mockHosts, + }, + }); + + HostsAPI.readOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('initially renders successfully', () => { + mountWithContexts( + + ); + }); + + test('Hosts are retrieved from the api and the components finishes loading', async done => { + const loadHosts = jest.spyOn(_HostsList.prototype, 'loadHosts'); + const wrapper = mountWithContexts(); + await waitForElement( + wrapper, + 'HostsList', + el => el.state('hasContentLoading') === true + ); + expect(loadHosts).toHaveBeenCalled(); + await waitForElement( + wrapper, + 'HostsList', + el => el.state('hasContentLoading') === false + ); + done(); + }); + + test('handleSelect is called when a host list item is selected', async done => { + const handleSelect = jest.spyOn(_HostsList.prototype, 'handleSelect'); + const wrapper = mountWithContexts(); + await waitForElement( + wrapper, + 'HostsList', + el => el.state('hasContentLoading') === false + ); + await wrapper + .find('input#select-host-1') + .closest('DataListCheck') + .props() + .onChange(); + expect(handleSelect).toBeCalled(); + await waitForElement( + wrapper, + 'HostsList', + el => el.state('selected').length === 1 + ); + done(); + }); + + test('handleSelectAll is called when select all checkbox is clicked', async done => { + const handleSelectAll = jest.spyOn(_HostsList.prototype, 'handleSelectAll'); + const wrapper = mountWithContexts(); + await waitForElement( + wrapper, + 'HostsList', + el => el.state('hasContentLoading') === false + ); + wrapper + .find('Checkbox#select-all') + .props() + .onChange(true); + expect(handleSelectAll).toBeCalled(); + await waitForElement( + wrapper, + 'HostsList', + el => el.state('selected').length === 3 + ); + done(); + }); + + test('delete button is disabled if user does not have delete capabilities on a selected host', async done => { + const wrapper = mountWithContexts(); + wrapper.find('HostsList').setState({ + hosts: mockHosts, + itemCount: 3, + isInitialized: true, + selected: mockHosts.slice(0, 1), + }); + await waitForElement( + wrapper, + 'ToolbarDeleteButton * button', + el => el.getDOMNode().disabled === false + ); + wrapper.find('HostsList').setState({ + selected: mockHosts, + }); + await waitForElement( + wrapper, + 'ToolbarDeleteButton * button', + el => el.getDOMNode().disabled === true + ); + done(); + }); + + test('api is called to delete hosts for each selected host.', () => { + HostsAPI.destroy = jest.fn(); + const wrapper = mountWithContexts(); + wrapper.find('HostsList').setState({ + hosts: mockHosts, + itemCount: 2, + isInitialized: true, + isModalOpen: true, + selected: mockHosts.slice(0, 2), + }); + wrapper.find('ToolbarDeleteButton').prop('onDelete')(); + expect(HostsAPI.destroy).toHaveBeenCalledTimes(2); + }); + + test('error is shown when host not successfully deleted from api', async done => { + HostsAPI.destroy.mockRejectedValue( + new Error({ + response: { + config: { + method: 'delete', + url: '/api/v2/hosts/1', + }, + data: 'An error occurred', + }, + }) + ); + const wrapper = mountWithContexts(); + wrapper.find('HostsList').setState({ + hosts: mockHosts, + itemCount: 1, + isInitialized: true, + isModalOpen: true, + selected: mockHosts.slice(0, 1), + }); + wrapper.find('ToolbarDeleteButton').prop('onDelete')(); + await sleep(0); + wrapper.update(); + 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 => { + const wrapper = mountWithContexts(); + await waitForElement( + wrapper, + 'HostsList', + el => el.state('hasContentLoading') === true + ); + await waitForElement( + wrapper, + 'HostsList', + 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 => { + HostsAPI.readOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + }, + }, + }); + const wrapper = mountWithContexts(); + await waitForElement( + wrapper, + 'HostsList', + el => el.state('hasContentLoading') === true + ); + await waitForElement( + wrapper, + 'HostsList', + el => el.state('hasContentLoading') === false + ); + expect(wrapper.find('ToolbarAddButton').length).toBe(0); + done(); + }); +}); diff --git a/awx/ui_next/src/screens/Host/HostList/HostListItem.jsx b/awx/ui_next/src/screens/Host/HostList/HostListItem.jsx new file mode 100644 index 0000000000..a316926045 --- /dev/null +++ b/awx/ui_next/src/screens/Host/HostList/HostListItem.jsx @@ -0,0 +1,119 @@ +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, + Switch, + 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 { Sparkline } from '@components/Sparkline'; +import VerticalSeparator from '@components/VerticalSeparator'; +import { Host } from '@types'; + +class HostListItem extends React.Component { + static propTypes = { + host: Host.isRequired, + detailUrl: string.isRequired, + isSelected: bool.isRequired, + onSelect: func.isRequired, + }; + + render() { + const { + host, + isSelected, + onSelect, + detailUrl, + toggleHost, + toggleLoading, + i18n, + } = this.props; + const labelId = `check-action-${host.id}`; + return ( + + + + + + + {host.name} + + , + + + , + + {host.summary_fields.inventory && ( + + + {i18n._(t`Inventory`)} + + + {host.summary_fields.inventory.name} + + + )} + , + + + toggleHost(host)} + aria-label={i18n._(t`Toggle host`)} + /> + + {host.summary_fields.user_capabilities.edit && ( + + + + + + )} + , + ]} + /> + + + ); + } +} +export default withI18n()(HostListItem); diff --git a/awx/ui_next/src/screens/Host/HostList/HostListItem.test.jsx b/awx/ui_next/src/screens/Host/HostList/HostListItem.test.jsx new file mode 100644 index 0000000000..d9e5987661 --- /dev/null +++ b/awx/ui_next/src/screens/Host/HostList/HostListItem.test.jsx @@ -0,0 +1,95 @@ +import React from 'react'; + +import { mountWithContexts } from '@testUtils/enzymeHelpers'; + +import HostsListItem from './HostListItem'; + +let toggleHost; + +const mockHost = { + id: 1, + name: 'Host 1', + url: '/api/v2/hosts/1', + inventory: 1, + summary_fields: { + inventory: { + id: 1, + name: 'Inv 1', + }, + user_capabilities: { + edit: true, + }, + }, +}; + +describe('', () => { + beforeEach(() => { + toggleHost = jest.fn(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('edit button shown to users with edit capabilities', () => { + const wrapper = mountWithContexts( + {}} + host={mockHost} + toggleHost={toggleHost} + /> + ); + expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy(); + }); + test('edit button hidden from users without edit capabilities', () => { + const copyMockHost = Object.assign({}, mockHost); + copyMockHost.summary_fields.user_capabilities.edit = false; + const wrapper = mountWithContexts( + {}} + host={copyMockHost} + toggleHost={toggleHost} + /> + ); + expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy(); + }); + test('handles toggle click when host is enabled', () => { + const wrapper = mountWithContexts( + {}} + host={mockHost} + toggleHost={toggleHost} + /> + ); + wrapper + .find('Switch') + .first() + .find('input') + .simulate('change'); + expect(toggleHost).toHaveBeenCalledWith(mockHost); + }); + + test('handles toggle click when host is disabled', () => { + const wrapper = mountWithContexts( + {}} + host={mockHost} + toggleHost={toggleHost} + /> + ); + wrapper + .find('Switch') + .first() + .find('input') + .simulate('change'); + expect(toggleHost).toHaveBeenCalledWith(mockHost); + }); +}); diff --git a/awx/ui_next/src/screens/Host/HostList/index.js b/awx/ui_next/src/screens/Host/HostList/index.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/awx/ui_next/src/screens/Host/Hosts.jsx b/awx/ui_next/src/screens/Host/Hosts.jsx new file mode 100644 index 0000000000..d647e56c3d --- /dev/null +++ b/awx/ui_next/src/screens/Host/Hosts.jsx @@ -0,0 +1,80 @@ +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 { Config } from '@contexts/Config'; +import Breadcrumbs from '@components/Breadcrumbs/Breadcrumbs'; + +import HostsList from './HostList/HostList'; +import HostAdd from './HostAdd/HostAdd'; +import Host from './Host'; + +class Hosts extends Component { + constructor(props) { + super(props); + + const { i18n } = props; + + this.state = { + breadcrumbConfig: { + '/hosts': i18n._(t`Hosts`), + '/hosts/add': i18n._(t`Create New Host`), + }, + }; + } + + setBreadcrumbConfig = host => { + const { i18n } = this.props; + + if (!host) { + return; + } + + const breadcrumbConfig = { + '/hosts': i18n._(t`Hosts`), + '/hosts/add': i18n._(t`Create New Host`), + [`/hosts/${host.id}`]: `${host.name}`, + [`/hosts/${host.id}/edit`]: i18n._(t`Edit Details`), + [`/hosts/${host.id}/details`]: i18n._(t`Details`), + [`/hosts/${host.id}/facts`]: i18n._(t`Facts`), + [`/hosts/${host.id}/groups`]: i18n._(t`Groups`), + [`/hosts/${host.id}/completed_jobs`]: i18n._(t`Completed Jobs`), + }; + + this.setState({ breadcrumbConfig }); + }; + + render() { + const { match, history, location } = this.props; + const { breadcrumbConfig } = this.state; + + return ( + + + + } /> + ( + + {({ me }) => ( + + )} + + )} + /> + } /> + + + ); + } +} + +export { Hosts as _Hosts }; +export default withI18n()(withRouter(Hosts)); diff --git a/awx/ui_next/src/screens/Host/Hosts.test.jsx b/awx/ui_next/src/screens/Host/Hosts.test.jsx new file mode 100644 index 0000000000..0581b3371e --- /dev/null +++ b/awx/ui_next/src/screens/Host/Hosts.test.jsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { createMemoryHistory } from 'history'; + +import { mountWithContexts } from '@testUtils/enzymeHelpers'; + +import Hosts from './Hosts'; + +describe('', () => { + test('initially renders succesfully', () => { + mountWithContexts(); + }); + + test('should display a breadcrumb heading', () => { + const history = createMemoryHistory({ + initialEntries: ['/hosts'], + }); + const match = { path: '/hosts', url: '/hosts', isExact: true }; + + const wrapper = mountWithContexts(, { + context: { + router: { + history, + route: { + location: history.location, + match, + }, + }, + }, + }); + expect(wrapper.find('BreadcrumbHeading').length).toBe(1); + wrapper.unmount(); + }); +}); diff --git a/awx/ui_next/src/screens/Host/data.host.json b/awx/ui_next/src/screens/Host/data.host.json new file mode 100644 index 0000000000..d2ef565610 --- /dev/null +++ b/awx/ui_next/src/screens/Host/data.host.json @@ -0,0 +1,98 @@ +{ + "id": 2, + "type": "host", + "url": "/api/v2/hosts/2/", + "related": { + "created_by": "/api/v2/users/1/", + "modified_by": "/api/v2/users/1/", + "variable_data": "/api/v2/hosts/2/variable_data/", + "groups": "/api/v2/hosts/2/groups/", + "all_groups": "/api/v2/hosts/2/all_groups/", + "job_events": "/api/v2/hosts/2/job_events/", + "job_host_summaries": "/api/v2/hosts/2/job_host_summaries/", + "activity_stream": "/api/v2/hosts/2/activity_stream/", + "inventory_sources": "/api/v2/hosts/2/inventory_sources/", + "smart_inventories": "/api/v2/hosts/2/smart_inventories/", + "ad_hoc_commands": "/api/v2/hosts/2/ad_hoc_commands/", + "ad_hoc_command_events": "/api/v2/hosts/2/ad_hoc_command_events/", + "insights": "/api/v2/hosts/2/insights/", + "ansible_facts": "/api/v2/hosts/2/ansible_facts/", + "inventory": "/api/v2/inventories/3/", + "last_job": "/api/v2/jobs/3/", + "last_job_host_summary": "/api/v2/job_host_summaries/1/" + }, + "summary_fields": { + "inventory": { + "id": 3, + "name": "Mikes Inventory", + "description": "", + "has_active_failures": false, + "total_hosts": 3, + "hosts_with_active_failures": 0, + "total_groups": 0, + "groups_with_active_failures": 0, + "has_inventory_sources": true, + "total_inventory_sources": 1, + "inventory_sources_with_failures": 0, + "organization_id": 3, + "kind": "" + }, + "last_job": { + "id": 3, + "name": "Ping", + "description": "", + "finished": "2019-10-28T21:29:08.880572Z", + "status": "successful", + "failed": false, + "job_template_id": 9, + "job_template_name": "Ping" + }, + "last_job_host_summary": { + "id": 1, + "failed": false + }, + "created_by": { + "id": 1, + "username": "admin", + "first_name": "", + "last_name": "" + }, + "modified_by": { + "id": 1, + "username": "admin", + "first_name": "", + "last_name": "" + }, + "user_capabilities": { + "edit": true, + "delete": true + }, + "groups": { + "count": 0, + "results": [] + }, + "recent_jobs": [ + { + "id": 3, + "name": "Ping", + "status": "successful", + "finished": "2019-10-28T21:29:08.880572Z", + "type": "job" + } + ] + }, + "created": "2019-10-28T21:26:54.508081Z", + "modified": "2019-10-29T20:18:41.915796Z", + "name": "localhost", + "description": "a good description", + "inventory": 3, + "enabled": true, + "instance_id": "", + "variables": "---\nansible_connection: local", + "has_active_failures": false, + "has_inventory_sources": false, + "last_job": 3, + "last_job_host_summary": 1, + "insights_system_id": null, + "ansible_facts_modified": null +} \ No newline at end of file diff --git a/awx/ui_next/src/screens/Host/index.js b/awx/ui_next/src/screens/Host/index.js new file mode 100644 index 0000000000..f501bcbcb9 --- /dev/null +++ b/awx/ui_next/src/screens/Host/index.js @@ -0,0 +1 @@ +export { default } from './Hosts'; diff --git a/awx/ui_next/src/screens/Host/shared/HostForm.jsx b/awx/ui_next/src/screens/Host/shared/HostForm.jsx new file mode 100644 index 0000000000..2cf42c54d2 --- /dev/null +++ b/awx/ui_next/src/screens/Host/shared/HostForm.jsx @@ -0,0 +1,147 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +import { withRouter } from 'react-router-dom'; +import { Formik, Field } 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 { VariablesField } from '@components/CodeMirrorInput'; +import { required } from '@util/validators'; +import { InventoryLookup } from '@components/Lookup'; + +class HostForm extends Component { + constructor(props) { + super(props); + + this.handleSubmit = this.handleSubmit.bind(this); + + this.state = { + formIsValid: true, + inventory: props.host.summary_fields.inventory, + }; + } + + handleSubmit(values) { + const { handleSubmit } = this.props; + + handleSubmit(values); + } + + render() { + const { host, handleCancel, i18n } = this.props; + const { formIsValid, inventory, error } = this.state; + + const initialValues = !host.id + ? { + name: host.name, + description: host.description, + inventory: host.inventory || '', + variables: host.variables, + } + : { + name: host.name, + description: host.description, + variables: host.variables, + }; + + return ( + ( +
+ + + + {!host.id && ( + ( + form.setFieldTouched('inventory')} + tooltip={i18n._( + t`Select the inventory that this host will belong to.` + )} + isValid={ + !form.touched.inventory || !form.errors.inventory + } + helperTextInvalid={form.errors.inventory} + onChange={value => { + form.setFieldValue('inventory', value.id); + this.setState({ inventory: value }); + }} + required + touched={form.touched.inventory} + error={form.errors.inventory} + /> + )} + /> + )} + + + + + + {error ?
error
: null} + + )} + /> + ); + } +} + +FormField.propTypes = { + label: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired, +}; + +HostForm.propTypes = { + host: PropTypes.shape(), + handleSubmit: PropTypes.func.isRequired, + handleCancel: PropTypes.func.isRequired, +}; + +HostForm.defaultProps = { + host: { + name: '', + description: '', + inventory: undefined, + variables: '---\n', + summary_fields: { + inventory: null, + }, + }, +}; + +export { HostForm as _HostForm }; +export default withI18n()(withRouter(HostForm)); diff --git a/awx/ui_next/src/screens/Host/shared/HostForm.test.jsx b/awx/ui_next/src/screens/Host/shared/HostForm.test.jsx new file mode 100644 index 0000000000..58078f1674 --- /dev/null +++ b/awx/ui_next/src/screens/Host/shared/HostForm.test.jsx @@ -0,0 +1,90 @@ +import React from 'react'; + +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import { sleep } from '@testUtils/testUtils'; + +import HostForm from './HostForm'; + +jest.mock('@api'); + +describe('', () => { + const meConfig = { + me: { + is_superuser: false, + }, + }; + const mockData = { + id: 1, + name: 'Foo', + description: 'Bar', + variables: '---', + inventory: 1, + summary_fields: { + inventory: { + id: 1, + name: 'Test Inv', + }, + }, + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('changing inputs should update form values', () => { + const wrapper = mountWithContexts( + + ); + + const form = wrapper.find('Formik'); + wrapper.find('input#host-name').simulate('change', { + target: { value: 'new foo', name: 'name' }, + }); + expect(form.state('values').name).toEqual('new foo'); + wrapper.find('input#host-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', + variables: '---', + }); + }); + + 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/Host/shared/index.js b/awx/ui_next/src/screens/Host/shared/index.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/awx/ui_next/src/types.js b/awx/ui_next/src/types.js index 61ca7ee41d..bf80f7631f 100644 --- a/awx/ui_next/src/types.js +++ b/awx/ui_next/src/types.js @@ -175,3 +175,32 @@ export const Job = shape({ extra_vars: string, artifacts: shape({}), }); + +export const Host = shape({ + id: number.isRequired, + type: oneOf(['host']), + url: string, + related: shape(), + summary_fields: shape({ + inventory: Inventory, + last_job: Job, + last_job_host_summary: shape({}), + created_by: shape({}), + modified_by: shape({}), + user_capabilities: objectOf(bool), + groups: shape({}), + recent_jobs: arrayOf(Job), + }), + created: string, + modified: string, + name: string.isRequired, + description: string, + inventory: number.isRequired, + enabled: bool, + instance_id: string, + variables: string, + has_active_failures: bool, + has_inventory_sources: bool, + last_job: number, + last_job_host_summary: number, +}); From d5e9716ceb88b6eead76f20c5891a8fa4863b040 Mon Sep 17 00:00:00 2001 From: mabashian Date: Mon, 4 Nov 2019 15:27:47 -0500 Subject: [PATCH 2/3] Move CardHeader styled component(s) outside of render functions. Refactors host options calls out to it's own function. --- awx/ui_next/src/screens/Host/Host.jsx | 14 +++++++------- .../src/screens/Host/HostList/HostList.jsx | 15 ++++++++++----- awx/ui_next/src/screens/Job/Job.jsx | 14 +++++++------- .../src/screens/Organization/Organization.jsx | 14 +++++++------- awx/ui_next/src/screens/Project/Project.jsx | 14 +++++++------- 5 files changed, 38 insertions(+), 33 deletions(-) diff --git a/awx/ui_next/src/screens/Host/Host.jsx b/awx/ui_next/src/screens/Host/Host.jsx index 6817e332b3..20cd4aad0c 100644 --- a/awx/ui_next/src/screens/Host/Host.jsx +++ b/awx/ui_next/src/screens/Host/Host.jsx @@ -18,6 +18,13 @@ import HostGroups from './HostGroups'; import HostCompletedJobs from './HostCompletedJobs'; import { HostsAPI } 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 Host extends Component { constructor(props) { super(props); @@ -81,13 +88,6 @@ class Host extends Component { }, ]; - 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 = ( diff --git a/awx/ui_next/src/screens/Organization/Organization.jsx b/awx/ui_next/src/screens/Organization/Organization.jsx index 8bbbdbc828..3e7dfaa0af 100644 --- a/awx/ui_next/src/screens/Organization/Organization.jsx +++ b/awx/ui_next/src/screens/Organization/Organization.jsx @@ -18,6 +18,13 @@ import OrganizationEdit from './OrganizationEdit'; import OrganizationTeams from './OrganizationTeams'; import { OrganizationsAPI } 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 Organization extends Component { constructor(props) { super(props); @@ -133,13 +140,6 @@ class Organization extends Component { }); } - 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 = ( Date: Mon, 4 Nov 2019 16:38:49 -0500 Subject: [PATCH 3/3] Removes changes to serializer that added type to host recent jobs. Addresses Switch styling issues on host list items. --- awx/api/serializers.py | 1 - .../src/screens/Host/HostList/HostListItem.jsx | 11 ++++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 15e5a808cb..a3d8d43306 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1701,7 +1701,6 @@ class HostSerializer(BaseSerializerWithVariables): 'name': j.job.job_template.name if j.job.job_template is not None else "", 'status': j.job.status, 'finished': j.job.finished, - 'type': j.job.get_real_instance_class()._meta.verbose_name.replace(' ', '_') } for j in obj.job_host_summaries.select_related('job__job_template').order_by('-created')[:5]]) return d diff --git a/awx/ui_next/src/screens/Host/HostList/HostListItem.jsx b/awx/ui_next/src/screens/Host/HostList/HostListItem.jsx index a316926045..4cd624da01 100644 --- a/awx/ui_next/src/screens/Host/HostList/HostListItem.jsx +++ b/awx/ui_next/src/screens/Host/HostList/HostListItem.jsx @@ -6,7 +6,7 @@ import { DataListItem, DataListItemRow, DataListItemCells, - Switch, + Switch as PFSwitch, Tooltip, } from '@patternfly/react-core'; import { Link } from 'react-router-dom'; @@ -20,6 +20,15 @@ import { Sparkline } from '@components/Sparkline'; import VerticalSeparator from '@components/VerticalSeparator'; import { Host } from '@types'; +import styled from 'styled-components'; + +const Switch = styled(PFSwitch)` + display: flex; + flex-wrap: no-wrap; + /* workaround PF bug; used in calculating switch width: */ + --pf-c-switch__toggle-icon--Offset: 0.125rem; +`; + class HostListItem extends React.Component { static propTypes = { host: Host.isRequired,