From 32e017bd03a488f1c17ca4a0887452e9332a7678 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Mon, 17 Feb 2020 14:47:35 -0500 Subject: [PATCH 1/2] Adds WFJT Details view Adds Launch Functinality for WFJT. --- .../src/api/models/WorkflowJobTemplates.js | 16 +- .../src/components/DetailList/DetailList.jsx | 2 +- .../components/LaunchButton/LaunchButton.jsx | 22 +- .../LaunchButton/LaunchButton.test.jsx | 51 +++- .../ResourceAccessListItem.test.jsx.snap | 20 +- .../screens/Template/WorkflowJobTemplate.jsx | 30 ++- .../Template/WorkflowJobTemplate.test.jsx | 96 +++++++ .../WorkflowJobTemplateDetail.jsx | 246 +++++++++++++++++- .../WorkflowJobTemplateDetail.test.jsx | 158 +++++++++++ 9 files changed, 601 insertions(+), 40 deletions(-) create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplate.test.jsx create mode 100644 awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.test.jsx diff --git a/awx/ui_next/src/api/models/WorkflowJobTemplates.js b/awx/ui_next/src/api/models/WorkflowJobTemplates.js index bb0e53f7d5..45b6f6539f 100644 --- a/awx/ui_next/src/api/models/WorkflowJobTemplates.js +++ b/awx/ui_next/src/api/models/WorkflowJobTemplates.js @@ -6,8 +6,22 @@ class WorkflowJobTemplates extends Base { this.baseUrl = '/api/v2/workflow_job_templates/'; } + launch(id, data) { + return this.http.post(`${this.baseUrl}${id}/launch/`, data); + } + + readLaunch(id) { + return this.http.get(`${this.baseUrl}${id}/launch/`); + } + readNodes(id, params) { - return this.http.get(`${this.baseUrl}${id}/workflow_nodes/`, { params }); + return this.http.get(`${this.baseUrl}${id}/workflow_nodes/`, { + params, + }); + } + + readWebhookKey(id) { + return this.http.get(`${this.baseUrl}${id}/webhook_key/`); } createNode(id, data) { diff --git a/awx/ui_next/src/components/DetailList/DetailList.jsx b/awx/ui_next/src/components/DetailList/DetailList.jsx index f47cfa89bb..59185b0931 100644 --- a/awx/ui_next/src/components/DetailList/DetailList.jsx +++ b/awx/ui_next/src/components/DetailList/DetailList.jsx @@ -11,7 +11,7 @@ const DetailList = ({ children, stacked, ...props }) => ( export default styled(DetailList)` display: grid; grid-gap: 20px; - align-items: flex-start; + align-items: center; ${props => props.stacked ? ` diff --git a/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx b/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx index 75c1277420..b18bc1233a 100644 --- a/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx +++ b/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx @@ -13,6 +13,7 @@ import { JobTemplatesAPI, ProjectsAPI, WorkflowJobsAPI, + WorkflowJobTemplatesAPI, } from '@api'; class LaunchButton extends React.Component { @@ -46,13 +47,24 @@ class LaunchButton extends React.Component { async handleLaunch() { const { history, resource } = this.props; + const readLaunch = + resource.type === 'workflow_job_template' + ? WorkflowJobTemplatesAPI.readLaunch(resource.id) + : JobTemplatesAPI.readLaunch(resource.id); + const launchJob = + resource.type === 'workflow_job_template' + ? WorkflowJobTemplatesAPI.launch(resource.id) + : JobTemplatesAPI.launch(resource.id); try { - const { data: launchConfig } = await JobTemplatesAPI.readLaunch( - resource.id - ); + const { data: launchConfig } = await readLaunch; + if (launchConfig.can_start_without_user_input) { - const { data: job } = await JobTemplatesAPI.launch(resource.id); - history.push(`/jobs/${job.id}`); + const { data: job } = await launchJob; + history.push( + `/${ + resource.type === 'workflow_job_template' ? 'workflow' : 'jobs' + }/${job.id}` + ); } else { this.setState({ promptError: true }); } diff --git a/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx b/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx index 676758e12a..7b292809a1 100644 --- a/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx +++ b/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx @@ -4,9 +4,10 @@ import { mountWithContexts } from '@testUtils/enzymeHelpers'; import { sleep } from '@testUtils/testUtils'; import LaunchButton from './LaunchButton'; -import { JobTemplatesAPI } from '@api'; +import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '@api'; -jest.mock('@api'); +jest.mock('@api/models/WorkflowJobTemplates'); +jest.mock('@api/models/JobTemplates'); describe('LaunchButton', () => { JobTemplatesAPI.readLaunch.mockResolvedValue({ @@ -23,7 +24,7 @@ describe('LaunchButton', () => { id: 1, type: 'job_template', }; - + afterEach(() => jest.clearAllMocks()); test('renders the expected content', () => { const wrapper = mountWithContexts( {children} @@ -35,6 +36,7 @@ describe('LaunchButton', () => { const history = createMemoryHistory({ initialEntries: ['/jobs/9000'], }); + JobTemplatesAPI.launch.mockResolvedValue({ data: { id: 9000, @@ -55,8 +57,46 @@ describe('LaunchButton', () => { expect(JobTemplatesAPI.launch).toHaveBeenCalledWith(1); expect(history.location.pathname).toEqual('/jobs/9000'); }); - + test('should launch the correct job type', async () => { + WorkflowJobTemplatesAPI.readLaunch.mockResolvedValue({ + data: { + can_start_without_user_input: true, + }, + }); + const history = createMemoryHistory({ + initialEntries: ['/jobs/9000'], + }); + JobTemplatesAPI.launch.mockResolvedValue({ + data: { + id: 9000, + }, + }); + const wrapper = mountWithContexts( + + {children} + , + { + context: { + router: { history }, + }, + } + ); + const button = wrapper.find('button'); + button.prop('onClick')(); + expect(WorkflowJobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1); + await sleep(0); + expect(WorkflowJobTemplatesAPI.launch).toHaveBeenCalledWith(1); + expect(history.location.pathname).toEqual('/jobs/9000'); + }); test('displays error modal after unsuccessful launch', async () => { + const wrapper = mountWithContexts( + {children} + ); JobTemplatesAPI.launch.mockRejectedValue( new Error({ response: { @@ -69,9 +109,6 @@ describe('LaunchButton', () => { }, }) ); - const wrapper = mountWithContexts( - {children} - ); expect(wrapper.find('Modal').length).toBe(0); wrapper.find('button').prop('onClick')(); await sleep(0); diff --git a/awx/ui_next/src/components/ResourceAccessList/__snapshots__/ResourceAccessListItem.test.jsx.snap b/awx/ui_next/src/components/ResourceAccessList/__snapshots__/ResourceAccessListItem.test.jsx.snap index 42bcf01688..f877f31997 100644 --- a/awx/ui_next/src/components/ResourceAccessList/__snapshots__/ResourceAccessListItem.test.jsx.snap +++ b/awx/ui_next/src/components/ResourceAccessList/__snapshots__/ResourceAccessListItem.test.jsx.snap @@ -374,9 +374,9 @@ exports[` initially renders succesfully 1`] = ` "componentStyle": ComponentStyle { "componentId": "DetailList-sc-12g7m4-0", "isStatic": false, - "lastClassName": "eYaZBv", + "lastClassName": "iAtits", "rules": Array [ - "display:grid;grid-gap:20px;align-items:flex-start;", + "display:grid;grid-gap:20px;align-items:center;", [Function], ], }, @@ -394,15 +394,15 @@ exports[` initially renders succesfully 1`] = ` stacked={true} >
initially renders succesfully 1`] = ` "componentStyle": ComponentStyle { "componentId": "DetailList-sc-12g7m4-0", "isStatic": false, - "lastClassName": "eYaZBv", + "lastClassName": "iAtits", "rules": Array [ - "display:grid;grid-gap:20px;align-items:flex-start;", + "display:grid;grid-gap:20px;align-items:center;", [Function], ], }, @@ -568,15 +568,15 @@ exports[` initially renders succesfully 1`] = ` stacked={true} >
( )} /> diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplate.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplate.test.jsx new file mode 100644 index 0000000000..8c0ed91df0 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplate.test.jsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { Route } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; +import { act } from 'react-dom/test-utils'; + +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import WorkflowJobTemplate from './WorkflowJobTemplate'; +import { sleep } from '@testUtils/testUtils'; +import { WorkflowJobTemplatesAPI, CredentialsAPI } from '@api'; + +jest.mock('@api/models/WorkflowJobTemplates'); +jest.mock('@api/models/Credentials'); + +describe('', () => { + const mockMe = { + is_super_user: true, + is_system_auditor: false, + }; + beforeAll(() => { + WorkflowJobTemplatesAPI.readDetail.mockResolvedValue({ + data: { + id: 1, + name: 'Foo', + description: 'Bar', + created: '2015-07-07T17:21:26.429745Z', + modified: '2019-08-11T19:47:37.980466Z', + extra_vars: '', + summary_fields: { + webhook_credential: { id: 1234567, name: 'Foo Webhook Credential' }, + created_by: { id: 1, username: 'Athena' }, + modified_by: { id: 1, username: 'Apollo' }, + recent_jobs: [ + { id: 1, status: 'run' }, + { id: 2, status: 'run' }, + { id: 3, status: 'run' }, + ], + labels: { + results: [ + { name: 'Label 1', id: 1 }, + { name: 'Label 2', id: 2 }, + { name: 'Label 3', id: 3 }, + ], + }, + }, + related: { + webhook_key: '/api/v2/workflow_job_templates/57/webhook_key/', + }, + }, + }); + WorkflowJobTemplatesAPI.readWebhookKey.mockResolvedValue({ + data: { webhook_key: 'WebHook Key' }, + }); + CredentialsAPI.readDetail.mockResolvedValue({ + data: { + summary_fields: { + credential_type: { name: 'Github Personal Access Token', id: 1 }, + }, + }, + }); + }); + test('calls api to get workflow job template data', async () => { + const history = createMemoryHistory({ + initialEntries: ['/templates/workflow_job_template/1'], + }); + let wrapper; + act(() => { + wrapper = mountWithContexts( + ( + {}} me={mockMe} /> + )} + />, + { + context: { + router: { + history, + route: { + location: history.location, + match: { + params: { id: 1 }, + }, + }, + }, + }, + } + ); + }); + expect(wrapper.find('WorkflowJobTemplate').length).toBe(1); + expect(WorkflowJobTemplatesAPI.readDetail).toBeCalledWith('1'); + wrapper.update(); + await sleep(0); + expect(WorkflowJobTemplatesAPI.readWebhookKey).toBeCalledWith('1'); + expect(CredentialsAPI.readDetail).toBeCalledWith(1234567); + }); +}); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx index fbd62f5253..360f65b3d9 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx @@ -1,17 +1,239 @@ -import React, { Component } from 'react'; -import { withRouter } from 'react-router-dom'; +import React, { useState } from 'react'; +import { Link, useHistory } from 'react-router-dom'; import { withI18n } from '@lingui/react'; -import { CardBody } from '@components/Card'; -import { DetailList } from '@components/DetailList'; +import { t } from '@lingui/macro'; +import { + Chip, + ChipGroup, + Button, + TextList, + TextListItem, + TextListVariants, + TextListItemVariants, + Label, +} from '@patternfly/react-core'; -class WorkflowJobTemplateDetail extends Component { - render() { - return ( - - - - ); +import { CardBody, CardActionsRow } from '@components/Card'; +import ContentLoading from '@components/ContentLoading'; +import { WorkflowJobTemplatesAPI } from '@api'; +import AlertModal from '@components/AlertModal'; +import ErrorDetail from '@components/ErrorDetail'; +import { DetailList, Detail, UserDateDetail } from '@components/DetailList'; +import { VariablesDetail } from '@components/CodeMirrorInput'; +import LaunchButton from '@components/LaunchButton'; +import DeleteButton from '@components/DeleteButton'; +import { toTitleCase } from '@util/strings'; +import { Sparkline } from '@components/Sparkline'; + +function WorkflowJobTemplateDetail({ template, i18n, webHookKey }) { + const { + id, + ask_inventory_on_launch, + name, + description, + type, + extra_vars, + created, + modified, + summary_fields, + related, + webhook_credential, + } = template; + const urlOrigin = window.location.origin; + const history = useHistory(); + const [deletionError, setDeletionError] = useState(null); + const [hasContentLoading, setHasContentLoading] = useState(false); + const renderOptionsField = + template.allow_simultaneous || template.webhook_servicee; + + const renderOptions = ( + + {template.allow_simultaneous && ( + + {i18n._(t`- Enable Concurrent Jobs`)} + + )} + {template.webhook_service && ( + + {i18n._(t`- Webhooks`)} + + )} + + ); + + if (hasContentLoading) { + return ; } + + const handleDelete = async () => { + setHasContentLoading(true); + try { + await WorkflowJobTemplatesAPI.destroy(id); + history.push(`/templates`); + } catch (error) { + setDeletionError(error); + } + setHasContentLoading(false); + }; + const inventoryValue = (kind, inventoryId) => { + const inventorykind = kind === 'smart' ? 'smart_inventory' : 'inventory'; + + return ask_inventory_on_launch ? ( + <> + + + + {i18n._(t`(Prompt on Launch)`)} + + ) : ( + + + + ); + }; + const canLaunch = + summary_fields.user_capabilities && summary_fields.user_capabilities.start; + const recentPlaybookJobs = summary_fields.recent_jobs.map(job => ({ + ...job, + type: 'job', + })); + + return ( + + + + + {summary_fields.recent_jobs?.length > 0 && ( + } + label={i18n._(t`Activity`)} + /> + )} + {summary_fields.organization && ( + + + + } + /> + )} + + {summary_fields.inventory && ( + + )} + {renderOptionsField && ( + + )} + + {related.webhook_receiver && ( + + )} + + {webhook_credential && ( + + + + } + /> + )} + {summary_fields.labels?.results?.length > 0 && ( + + {summary_fields.labels.results.map(l => ( + + {l.name} + + ))} + + } + /> + )} + + + + + + {summary_fields.user_capabilities && + summary_fields.user_capabilities.edit && ( + + )} + {canLaunch && ( + + {({ handleLaunch }) => ( + + )} + + )} + {summary_fields.user_capabilities && + summary_fields.user_capabilities.delete && ( + + {i18n._(t`Delete`)} + + )} + + {deletionError && ( + setDeletionError(null)} + > + {i18n._(t`Failed to delete workflow job template.`)} + + + )} + + ); } export { WorkflowJobTemplateDetail as _WorkflowJobTemplateDetail }; -export default withI18n()(withRouter(WorkflowJobTemplateDetail)); +export default withI18n()(WorkflowJobTemplateDetail); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.test.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.test.jsx new file mode 100644 index 0000000000..7d137deb3d --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.test.jsx @@ -0,0 +1,158 @@ +import React from 'react'; +import { Route } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; +import { act } from 'react-dom/test-utils'; + +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import WorkflowJobTemplateDetail from './WorkflowJobTemplateDetail'; + +describe('', () => { + let wrapper; + let history; + const template = { + id: 1, + name: 'WFJT Template', + description: 'It is a wfjt template, yo!', + type: 'workflow_job_template', + extra_vars: '1: 2', + created: '2015-07-07T17:21:26.429745Z', + modified: '2019-08-11T19:47:37.980466Z', + related: { webhook_receiver: '/api/v2/workflow_job_templates/45/github/' }, + summary_fields: { + created_by: { id: 1, username: 'Athena' }, + modified_by: { id: 1, username: 'Apollo' }, + organization: { id: 1, name: 'Org' }, + inventory: { kind: 'Foo', id: 1, name: 'Bar' }, + labels: { + results: [ + { name: 'Label 1', id: 1 }, + { name: 'Label 2', id: 2 }, + { name: 'Label 3', id: 3 }, + ], + }, + recent_jobs: [ + { id: 1, status: 'run' }, + { id: 2, status: 'run' }, + { id: 3, status: 'run' }, + ], + webhook_credential: { id: '1', name: 'Credentaial', kind: 'machine' }, + user_capabilities: { edit: true, delete: true }, + }, + webhook_service: 'Github', + }; + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/templates/workflow_job_template/1/details'], + }); + await act(async () => { + wrapper = await mountWithContexts( + ( + {}} + /> + )} + />, + { + context: { + router: { + history, + route: { + location: history.location, + match: { + params: { id: 1 }, + path: '/templates/workflow_job_template/1/details', + url: '/templates/workflow_job_template/1/details', + }, + }, + }, + }, + } + ); + }); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('renders successfully', () => { + expect(wrapper.find(WorkflowJobTemplateDetail).length).toBe(1); + }); + test('expect detail fields to render properly', () => { + const renderedValues = [ + { + element: 'UserDateDetail[label="Created"]', + prop: 'date', + value: '2015-07-07T17:21:26.429745Z', + }, + { + element: 'UserDateDetail[label="Modified"]', + prop: 'date', + value: '2019-08-11T19:47:37.980466Z', + }, + { + element: 'Detail[label="Webhook URL"]', + prop: 'value', + value: 'http://127.0.0.1:3001/api/v2/workflow_job_templates/45/github/', + }, + { + element: "Detail[label='Webhook Service']", + prop: 'value', + value: 'Github', + }, + { + element: 'Detail[label="Webhook Key"]', + prop: 'value', + value: 'Foo webhook key', + }, + { + element: 'Detail[label="Name"]', + value: 'WFJT Template', + prop: 'value', + }, + { + element: 'Detail[label="Description"]', + prop: 'value', + value: 'It is a wfjt template, yo!', + }, + { + element: 'Detail[label="Job Type"]', + prop: 'value', + value: 'Workflow Job Template', + }, + ]; + + const organization = wrapper + .find('Detail[label="Organization"]') + .find('span'); + const inventory = wrapper.find('Detail[label="Inventory"]').find('a'); + const labels = wrapper + .find('Detail[label="Labels"]') + .find('Chip[component="li"]'); + const sparkline = wrapper.find('Sparkline__Link'); + + expect(organization.text()).toBe('Org'); + expect(inventory.text()).toEqual('Bar'); + expect(labels.length).toBe(3); + expect(sparkline.length).toBe(3); + + const assertValue = value => { + expect(wrapper.find(`${value.element}`).prop(`${value.prop}`)).toEqual( + `${value.value}` + ); + }; + + renderedValues.map(value => assertValue(value)); + }); + test('link out resource have the correct url', () => { + const inventory = wrapper.find('Detail[label="Inventory"]').find('Link'); + const organization = wrapper + .find('Detail[label="Organization"]') + .find('Link'); + expect(inventory.prop('to')).toEqual('/inventories/inventory/1/details'); + expect(organization.prop('to')).toEqual('/organizations/1/details'); + }); +}); From 8f6b679c856dfa51a352a7ee4b9b38ac9bb5fe15 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Wed, 19 Feb 2020 09:36:07 -0500 Subject: [PATCH 2/2] Fixes navigation issue and address ps issues. --- awx/ui_next/src/components/LaunchButton/LaunchButton.jsx | 4 ++-- awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx | 2 +- .../WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx | 3 +-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx b/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx index b18bc1233a..1793eed8ba 100644 --- a/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx +++ b/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx @@ -62,8 +62,8 @@ class LaunchButton extends React.Component { const { data: job } = await launchJob; history.push( `/${ - resource.type === 'workflow_job_template' ? 'workflow' : 'jobs' - }/${job.id}` + resource.type === 'workflow_job_template' ? 'jobs/workflow' : 'jobs' + }/${job.id}/output` ); } else { this.setState({ promptError: true }); diff --git a/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx b/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx index 7b292809a1..60a24054a2 100644 --- a/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx +++ b/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx @@ -55,7 +55,7 @@ describe('LaunchButton', () => { expect(JobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1); await sleep(0); expect(JobTemplatesAPI.launch).toHaveBeenCalledWith(1); - expect(history.location.pathname).toEqual('/jobs/9000'); + expect(history.location.pathname).toEqual('/jobs/9000/output'); }); test('should launch the correct job type', async () => { WorkflowJobTemplatesAPI.readLaunch.mockResolvedValue({ diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx index 360f65b3d9..e68dbb3e17 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx @@ -91,8 +91,7 @@ function WorkflowJobTemplateDetail({ template, i18n, webHookKey }) { ); }; - const canLaunch = - summary_fields.user_capabilities && summary_fields.user_capabilities.start; + const canLaunch = summary_fields?.user_capabilities?.start; const recentPlaybookJobs = summary_fields.recent_jobs.map(job => ({ ...job, type: 'job',