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..1793eed8ba 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' ? '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 676758e12a..60a24054a2 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,
@@ -53,10 +55,48 @@ describe('LaunchButton', () => {
expect(JobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1);
await sleep(0);
expect(JobTemplatesAPI.launch).toHaveBeenCalledWith(1);
+ expect(history.location.pathname).toEqual('/jobs/9000/output');
+ });
+ 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..e68dbb3e17 100644
--- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx
+++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateDetail/WorkflowJobTemplateDetail.jsx
@@ -1,17 +1,238 @@
-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?.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');
+ });
+});