mirror of
https://github.com/ansible/awx.git
synced 2026-02-26 23:46:05 -03:30
Merge pull request #5841 from AlexSCorey/5813-WorkflowJTForm
Adds WorkflowJobTemplate Add and Edit form and functionality. Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
@@ -1,10 +1,9 @@
|
|||||||
const InstanceGroupsMixin = parent =>
|
const InstanceGroupsMixin = parent =>
|
||||||
class extends parent {
|
class extends parent {
|
||||||
readInstanceGroups(resourceId, params) {
|
readInstanceGroups(resourceId, params) {
|
||||||
return this.http.get(
|
return this.http.get(`${this.baseUrl}${resourceId}/instance_groups/`, {
|
||||||
`${this.baseUrl}${resourceId}/instance_groups/`,
|
params,
|
||||||
params
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
associateInstanceGroup(resourceId, instanceGroupId) {
|
associateInstanceGroup(resourceId, instanceGroupId) {
|
||||||
|
|||||||
@@ -6,6 +6,32 @@ class WorkflowJobTemplates extends Base {
|
|||||||
this.baseUrl = '/api/v2/workflow_job_templates/';
|
this.baseUrl = '/api/v2/workflow_job_templates/';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readWebhookKey(id) {
|
||||||
|
return this.http.get(`${this.baseUrl}${id}/webhook_key/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateWebhookKey(id) {
|
||||||
|
return this.http.post(`${this.baseUrl}${id}/webhook_key/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
associateLabel(id, label, orgId) {
|
||||||
|
return this.http.post(`${this.baseUrl}${id}/labels/`, {
|
||||||
|
name: label.name,
|
||||||
|
organization: orgId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createNode(id, data) {
|
||||||
|
return this.http.post(`${this.baseUrl}${id}/workflow_nodes/`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
disassociateLabel(id, label) {
|
||||||
|
return this.http.post(`${this.baseUrl}${id}/labels/`, {
|
||||||
|
id: label.id,
|
||||||
|
disassociate: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
launch(id, data) {
|
launch(id, data) {
|
||||||
return this.http.post(`${this.baseUrl}${id}/launch/`, data);
|
return this.http.post(`${this.baseUrl}${id}/launch/`, data);
|
||||||
}
|
}
|
||||||
@@ -20,16 +46,10 @@ class WorkflowJobTemplates extends Base {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
readWebhookKey(id) {
|
|
||||||
return this.http.get(`${this.baseUrl}${id}/webhook_key/`);
|
|
||||||
}
|
|
||||||
|
|
||||||
createNode(id, data) {
|
|
||||||
return this.http.post(`${this.baseUrl}${id}/workflow_nodes/`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
readScheduleList(id, params) {
|
readScheduleList(id, params) {
|
||||||
return this.http.get(`${this.baseUrl}${id}/schedules/`, { params });
|
return this.http.get(`${this.baseUrl}${id}/schedules/`, {
|
||||||
|
params,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { t } from '@lingui/macro';
|
|||||||
import { useField } from 'formik';
|
import { useField } from 'formik';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { Split, SplitItem } from '@patternfly/react-core';
|
import { Split, SplitItem } from '@patternfly/react-core';
|
||||||
import { CheckboxField } from '@components/FormField';
|
import { CheckboxField, FieldTooltip } from '@components/FormField';
|
||||||
import MultiButtonToggle from '@components/MultiButtonToggle';
|
import MultiButtonToggle from '@components/MultiButtonToggle';
|
||||||
import { yamlToJson, jsonToYaml, isJson } from '@util/yaml';
|
import { yamlToJson, jsonToYaml, isJson } from '@util/yaml';
|
||||||
import CodeMirrorInput from './CodeMirrorInput';
|
import CodeMirrorInput from './CodeMirrorInput';
|
||||||
@@ -20,7 +20,15 @@ const StyledCheckboxField = styled(CheckboxField)`
|
|||||||
--pf-c-check__label--FontSize: var(--pf-c-form__label--FontSize);
|
--pf-c-check__label--FontSize: var(--pf-c-form__label--FontSize);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
function VariablesField({ i18n, id, name, label, readOnly, promptId }) {
|
function VariablesField({
|
||||||
|
i18n,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
readOnly,
|
||||||
|
promptId,
|
||||||
|
tooltip,
|
||||||
|
}) {
|
||||||
const [field, meta, helpers] = useField(name);
|
const [field, meta, helpers] = useField(name);
|
||||||
const [mode, setMode] = useState(isJson(field.value) ? JSON_MODE : YAML_MODE);
|
const [mode, setMode] = useState(isJson(field.value) ? JSON_MODE : YAML_MODE);
|
||||||
|
|
||||||
@@ -32,6 +40,7 @@ function VariablesField({ i18n, id, name, label, readOnly, promptId }) {
|
|||||||
<label htmlFor={id} className="pf-c-form__label">
|
<label htmlFor={id} className="pf-c-form__label">
|
||||||
<span className="pf-c-form__label-text">{label}</span>
|
<span className="pf-c-form__label-text">{label}</span>
|
||||||
</label>
|
</label>
|
||||||
|
{tooltip && <FieldTooltip content={tooltip} />}
|
||||||
</SplitItem>
|
</SplitItem>
|
||||||
<SplitItem>
|
<SplitItem>
|
||||||
<MultiButtonToggle
|
<MultiButtonToggle
|
||||||
|
|||||||
@@ -69,6 +69,22 @@ describe('VariablesField', () => {
|
|||||||
expect(field.prop('hasErrors')).toEqual(true);
|
expect(field.prop('hasErrors')).toEqual(true);
|
||||||
expect(wrapper.find('.pf-m-error')).toHaveLength(1);
|
expect(wrapper.find('.pf-m-error')).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
it('should render tooltip', () => {
|
||||||
|
const value = '---\n';
|
||||||
|
const wrapper = mount(
|
||||||
|
<Formik initialValues={{ variables: value }}>
|
||||||
|
{() => (
|
||||||
|
<VariablesField
|
||||||
|
id="the-field"
|
||||||
|
name="variables"
|
||||||
|
label="Variables"
|
||||||
|
tooltip="This is a tooltip"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
expect(wrapper.find('Tooltip').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('should submit value through Formik', async () => {
|
it('should submit value through Formik', async () => {
|
||||||
const value = '---\nfoo: bar\n';
|
const value = '---\nfoo: bar\n';
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { t } from '@lingui/macro';
|
|||||||
import { CredentialsAPI } from '@api';
|
import { CredentialsAPI } from '@api';
|
||||||
import { Credential } from '@types';
|
import { Credential } from '@types';
|
||||||
import { getQSConfig, parseQueryString, mergeParams } from '@util/qs';
|
import { getQSConfig, parseQueryString, mergeParams } from '@util/qs';
|
||||||
|
import { FieldTooltip } from '@components/FormField';
|
||||||
import { FormGroup } from '@patternfly/react-core';
|
import { FormGroup } from '@patternfly/react-core';
|
||||||
import Lookup from '@components/Lookup';
|
import Lookup from '@components/Lookup';
|
||||||
import OptionsList from './shared/OptionsList';
|
import OptionsList from './shared/OptionsList';
|
||||||
@@ -28,6 +29,7 @@ function CredentialLookup({
|
|||||||
value,
|
value,
|
||||||
history,
|
history,
|
||||||
i18n,
|
i18n,
|
||||||
|
tooltip,
|
||||||
}) {
|
}) {
|
||||||
const [credentials, setCredentials] = useState([]);
|
const [credentials, setCredentials] = useState([]);
|
||||||
const [count, setCount] = useState(0);
|
const [count, setCount] = useState(0);
|
||||||
@@ -60,6 +62,7 @@ function CredentialLookup({
|
|||||||
label={label}
|
label={label}
|
||||||
helperTextInvalid={helperTextInvalid}
|
helperTextInvalid={helperTextInvalid}
|
||||||
>
|
>
|
||||||
|
{tooltip && <FieldTooltip content={tooltip} />}
|
||||||
<Lookup
|
<Lookup
|
||||||
id="credential"
|
id="credential"
|
||||||
header={label}
|
header={label}
|
||||||
@@ -97,6 +100,7 @@ function CredentialLookup({
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
readOnly={!canDelete}
|
readOnly={!canDelete}
|
||||||
|
name="credential"
|
||||||
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
|
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
|
||||||
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
|
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import { Card } from '@patternfly/react-core';
|
import { Card, PageSection } from '@patternfly/react-core';
|
||||||
import { CardBody } from '@components/Card';
|
import { CardBody } from '@components/Card';
|
||||||
import JobTemplateForm from '../shared/JobTemplateForm';
|
import JobTemplateForm from '../shared/JobTemplateForm';
|
||||||
import { JobTemplatesAPI } from '@api';
|
import { JobTemplatesAPI } from '@api';
|
||||||
@@ -61,15 +61,17 @@ function JobTemplateAdd() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<PageSection>
|
||||||
<CardBody>
|
<Card>
|
||||||
<JobTemplateForm
|
<CardBody>
|
||||||
handleCancel={handleCancel}
|
<JobTemplateForm
|
||||||
handleSubmit={handleSubmit}
|
handleCancel={handleCancel}
|
||||||
submitError={formSubmitError}
|
handleSubmit={handleSubmit}
|
||||||
/>
|
submitError={formSubmitError}
|
||||||
</CardBody>
|
/>
|
||||||
</Card>
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</PageSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -164,87 +164,91 @@ class Template extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<PageSection>
|
||||||
{cardHeader}
|
<Card>
|
||||||
<Switch>
|
{cardHeader}
|
||||||
<Redirect
|
<Switch>
|
||||||
from="/templates/:templateType/:id"
|
<Redirect
|
||||||
to="/templates/:templateType/:id/details"
|
from="/templates/:templateType/:id"
|
||||||
exact
|
to="/templates/:templateType/:id/details"
|
||||||
/>
|
exact
|
||||||
{template && (
|
|
||||||
<Route
|
|
||||||
key="details"
|
|
||||||
path="/templates/:templateType/:id/details"
|
|
||||||
render={() => (
|
|
||||||
<JobTemplateDetail
|
|
||||||
hasTemplateLoading={hasContentLoading}
|
|
||||||
template={template}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
{template && (
|
||||||
{template && (
|
<Route
|
||||||
|
key="details"
|
||||||
|
path="/templates/:templateType/:id/details"
|
||||||
|
render={() => (
|
||||||
|
<JobTemplateDetail
|
||||||
|
hasTemplateLoading={hasContentLoading}
|
||||||
|
template={template}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{template && (
|
||||||
|
<Route
|
||||||
|
key="edit"
|
||||||
|
path="/templates/:templateType/:id/edit"
|
||||||
|
render={() => <JobTemplateEdit template={template} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{template && (
|
||||||
|
<Route
|
||||||
|
key="access"
|
||||||
|
path="/templates/:templateType/:id/access"
|
||||||
|
render={() => (
|
||||||
|
<ResourceAccessList
|
||||||
|
resource={template}
|
||||||
|
apiModel={JobTemplatesAPI}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canSeeNotificationsTab && (
|
||||||
|
<Route
|
||||||
|
path="/templates/:templateType/:id/notifications"
|
||||||
|
render={() => (
|
||||||
|
<NotificationList
|
||||||
|
id={Number(match.params.id)}
|
||||||
|
canToggleNotifications={isNotifAdmin}
|
||||||
|
apiModel={JobTemplatesAPI}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{template?.id && (
|
||||||
|
<Route path="/templates/:templateType/:id/completed_jobs">
|
||||||
|
<JobList defaultParams={{ job__job_template: template.id }} />
|
||||||
|
</Route>
|
||||||
|
)}
|
||||||
|
{template && (
|
||||||
|
<Route
|
||||||
|
path="/templates/:templateType/:id/schedules"
|
||||||
|
render={() => (
|
||||||
|
<ScheduleList loadSchedules={this.loadSchedules} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Route
|
<Route
|
||||||
key="edit"
|
key="not-found"
|
||||||
path="/templates/:templateType/:id/edit"
|
path="*"
|
||||||
render={() => <JobTemplateEdit template={template} />}
|
render={() =>
|
||||||
|
!hasContentLoading && (
|
||||||
|
<ContentError isNotFound>
|
||||||
|
{match.params.id && (
|
||||||
|
<Link
|
||||||
|
to={`/templates/${match.params.templateType}/${match.params.id}/details`}
|
||||||
|
>
|
||||||
|
{i18n._(`View Template Details`)}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</ContentError>
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
</Switch>
|
||||||
{template && (
|
</Card>
|
||||||
<Route
|
</PageSection>
|
||||||
key="access"
|
|
||||||
path="/templates/:templateType/:id/access"
|
|
||||||
render={() => (
|
|
||||||
<ResourceAccessList
|
|
||||||
resource={template}
|
|
||||||
apiModel={JobTemplatesAPI}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{canSeeNotificationsTab && (
|
|
||||||
<Route
|
|
||||||
path="/templates/:templateType/:id/notifications"
|
|
||||||
render={() => (
|
|
||||||
<NotificationList
|
|
||||||
id={Number(match.params.id)}
|
|
||||||
canToggleNotifications={isNotifAdmin}
|
|
||||||
apiModel={JobTemplatesAPI}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{template?.id && (
|
|
||||||
<Route path="/templates/:templateType/:id/completed_jobs">
|
|
||||||
<JobList defaultParams={{ job__job_template: template.id }} />
|
|
||||||
</Route>
|
|
||||||
)}
|
|
||||||
{template && (
|
|
||||||
<Route
|
|
||||||
path="/templates/:templateType/:id/schedules"
|
|
||||||
render={() => <ScheduleList loadSchedules={this.loadSchedules} />}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Route
|
|
||||||
key="not-found"
|
|
||||||
path="*"
|
|
||||||
render={() =>
|
|
||||||
!hasContentLoading && (
|
|
||||||
<ContentError isNotFound>
|
|
||||||
{match.params.id && (
|
|
||||||
<Link
|
|
||||||
to={`/templates/${match.params.templateType}/${match.params.id}/details`}
|
|
||||||
>
|
|
||||||
{i18n._(`View Template Details`)}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</ContentError>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Switch>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState, useCallback } from 'react';
|
|||||||
import { useParams, useLocation } from 'react-router-dom';
|
import { useParams, useLocation } from 'react-router-dom';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Card } from '@patternfly/react-core';
|
import { Card, PageSection } from '@patternfly/react-core';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
JobTemplatesAPI,
|
JobTemplatesAPI,
|
||||||
@@ -141,7 +141,7 @@ function TemplateList({ i18n }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<PageSection>
|
||||||
<Card>
|
<Card>
|
||||||
<PaginatedDataList
|
<PaginatedDataList
|
||||||
contentError={contentError}
|
contentError={contentError}
|
||||||
@@ -245,7 +245,7 @@ function TemplateList({ i18n }) {
|
|||||||
{i18n._(t`Failed to delete one or more templates.`)}
|
{i18n._(t`Failed to delete one or more templates.`)}
|
||||||
<ErrorDetail error={deletionError} />
|
<ErrorDetail error={deletionError} />
|
||||||
</AlertModal>
|
</AlertModal>
|
||||||
</>
|
</PageSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import React, { Component } from 'react';
|
|||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Route, withRouter, Switch } from 'react-router-dom';
|
import { Route, withRouter, Switch } from 'react-router-dom';
|
||||||
import { PageSection } from '@patternfly/react-core';
|
|
||||||
|
|
||||||
import { Config } from '@contexts/Config';
|
import { Config } from '@contexts/Config';
|
||||||
import Breadcrumbs from '@components/Breadcrumbs/Breadcrumbs';
|
import Breadcrumbs from '@components/Breadcrumbs/Breadcrumbs';
|
||||||
@@ -10,6 +9,7 @@ import { TemplateList } from './TemplateList';
|
|||||||
import Template from './Template';
|
import Template from './Template';
|
||||||
import WorkflowJobTemplate from './WorkflowJobTemplate';
|
import WorkflowJobTemplate from './WorkflowJobTemplate';
|
||||||
import JobTemplateAdd from './JobTemplateAdd';
|
import JobTemplateAdd from './JobTemplateAdd';
|
||||||
|
import WorkflowJobTemplateAdd from './WorkflowJobTemplateAdd';
|
||||||
|
|
||||||
class Templates extends Component {
|
class Templates extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -20,6 +20,9 @@ class Templates extends Component {
|
|||||||
breadcrumbConfig: {
|
breadcrumbConfig: {
|
||||||
'/templates': i18n._(t`Templates`),
|
'/templates': i18n._(t`Templates`),
|
||||||
'/templates/job_template/add': i18n._(t`Create New Job Template`),
|
'/templates/job_template/add': i18n._(t`Create New Job Template`),
|
||||||
|
'/templates/workflow_job_template/add': i18n._(
|
||||||
|
t`Create New Workflow Template`
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -32,6 +35,9 @@ class Templates extends Component {
|
|||||||
const breadcrumbConfig = {
|
const breadcrumbConfig = {
|
||||||
'/templates': i18n._(t`Templates`),
|
'/templates': i18n._(t`Templates`),
|
||||||
'/templates/job_template/add': i18n._(t`Create New Job Template`),
|
'/templates/job_template/add': i18n._(t`Create New Job Template`),
|
||||||
|
'/templates/workflow_job_template/add': i18n._(
|
||||||
|
t`Create New Workflow Template`
|
||||||
|
),
|
||||||
[`/templates/${template.type}/${template.id}`]: `${template.name}`,
|
[`/templates/${template.type}/${template.id}`]: `${template.name}`,
|
||||||
[`/templates/${template.type}/${template.id}/details`]: i18n._(
|
[`/templates/${template.type}/${template.id}/details`]: i18n._(
|
||||||
t`Details`
|
t`Details`
|
||||||
@@ -56,47 +62,48 @@ class Templates extends Component {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
|
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
|
||||||
<PageSection>
|
<Switch>
|
||||||
<Switch>
|
<Route
|
||||||
<Route
|
path={`${match.path}/job_template/add`}
|
||||||
path={`${match.path}/job_template/add`}
|
render={() => <JobTemplateAdd />}
|
||||||
render={() => <JobTemplateAdd />}
|
/>
|
||||||
/>
|
<Route
|
||||||
<Route
|
path={`${match.path}/workflow_job_template/add`}
|
||||||
path={`${match.path}/job_template/:id`}
|
render={() => <WorkflowJobTemplateAdd />}
|
||||||
render={({ match: newRouteMatch }) => (
|
/>
|
||||||
<Config>
|
<Route
|
||||||
{({ me }) => (
|
path={`${match.path}/job_template/:id`}
|
||||||
<Template
|
render={({ match: newRouteMatch }) => (
|
||||||
history={history}
|
<Config>
|
||||||
location={location}
|
{({ me }) => (
|
||||||
setBreadcrumb={this.setBreadCrumbConfig}
|
<Template
|
||||||
me={me || {}}
|
history={history}
|
||||||
match={newRouteMatch}
|
location={location}
|
||||||
/>
|
setBreadcrumb={this.setBreadCrumbConfig}
|
||||||
)}
|
me={me || {}}
|
||||||
</Config>
|
match={newRouteMatch}
|
||||||
)}
|
/>
|
||||||
/>
|
)}
|
||||||
<Route
|
</Config>
|
||||||
path={`${match.path}/workflow_job_template/:id`}
|
)}
|
||||||
render={({ match: newRouteMatch }) => (
|
/>
|
||||||
<Config>
|
<Route
|
||||||
{({ me }) => (
|
path={`${match.path}/workflow_job_template/:id`}
|
||||||
<WorkflowJobTemplate
|
render={({ match: newRouteMatch }) => (
|
||||||
history={history}
|
<Config>
|
||||||
location={location}
|
{({ me }) => (
|
||||||
setBreadcrumb={this.setBreadCrumbConfig}
|
<WorkflowJobTemplate
|
||||||
me={me || {}}
|
location={location}
|
||||||
match={newRouteMatch}
|
setBreadcrumb={this.setBreadCrumbConfig}
|
||||||
/>
|
me={me || {}}
|
||||||
)}
|
match={newRouteMatch}
|
||||||
</Config>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
</Config>
|
||||||
<Route path={`${match.path}`} render={() => <TemplateList />} />
|
)}
|
||||||
</Switch>
|
/>
|
||||||
</PageSection>
|
<Route path={`${match.path}`} render={() => <TemplateList />} />
|
||||||
|
</Switch>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import FullPage from '@components/FullPage';
|
|||||||
import JobList from '@components/JobList';
|
import JobList from '@components/JobList';
|
||||||
import RoutedTabs from '@components/RoutedTabs';
|
import RoutedTabs from '@components/RoutedTabs';
|
||||||
import ScheduleList from '@components/ScheduleList';
|
import ScheduleList from '@components/ScheduleList';
|
||||||
|
import ContentLoading from '@components/ContentLoading';
|
||||||
import { WorkflowJobTemplatesAPI, CredentialsAPI } from '@api';
|
import { WorkflowJobTemplatesAPI, CredentialsAPI } from '@api';
|
||||||
import WorkflowJobTemplateDetail from './WorkflowJobTemplateDetail';
|
import WorkflowJobTemplateDetail from './WorkflowJobTemplateDetail';
|
||||||
|
import WorkflowJobTemplateEdit from './WorkflowJobTemplateEdit';
|
||||||
import { Visualizer } from './WorkflowJobTemplateVisualizer';
|
import { Visualizer } from './WorkflowJobTemplateVisualizer';
|
||||||
|
|
||||||
class WorkflowJobTemplate extends Component {
|
class WorkflowJobTemplate extends Component {
|
||||||
@@ -23,6 +25,7 @@ class WorkflowJobTemplate extends Component {
|
|||||||
contentError: null,
|
contentError: null,
|
||||||
hasContentLoading: true,
|
hasContentLoading: true,
|
||||||
template: null,
|
template: null,
|
||||||
|
webhook_key: null,
|
||||||
};
|
};
|
||||||
this.loadTemplate = this.loadTemplate.bind(this);
|
this.loadTemplate = this.loadTemplate.bind(this);
|
||||||
this.loadSchedules = this.loadSchedules.bind(this);
|
this.loadSchedules = this.loadSchedules.bind(this);
|
||||||
@@ -50,19 +53,20 @@ class WorkflowJobTemplate extends Component {
|
|||||||
const {
|
const {
|
||||||
data: { webhook_key },
|
data: { webhook_key },
|
||||||
} = await WorkflowJobTemplatesAPI.readWebhookKey(id);
|
} = await WorkflowJobTemplatesAPI.readWebhookKey(id);
|
||||||
this.setState({ webHookKey: webhook_key });
|
this.setState({ webhook_key });
|
||||||
}
|
}
|
||||||
if (data?.summary_fields?.webhook_credential) {
|
if (data?.summary_fields?.webhook_credential) {
|
||||||
const {
|
const {
|
||||||
data: {
|
data: {
|
||||||
summary_fields: { credential_type: name },
|
summary_fields: {
|
||||||
|
credential_type: { name },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} = await CredentialsAPI.readDetail(
|
} = await CredentialsAPI.readDetail(
|
||||||
data.summary_fields.webhook_credential.id
|
data.summary_fields.webhook_credential.id
|
||||||
);
|
);
|
||||||
data.summary_fields.webhook_credential.kind = name;
|
data.summary_fields.webhook_credential.kind = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({ template: data });
|
this.setState({ template: data });
|
||||||
setBreadcrumb(data);
|
setBreadcrumb(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -83,7 +87,7 @@ class WorkflowJobTemplate extends Component {
|
|||||||
contentError,
|
contentError,
|
||||||
hasContentLoading,
|
hasContentLoading,
|
||||||
template,
|
template,
|
||||||
webHookKey,
|
webhook_key,
|
||||||
} = this.state;
|
} = this.state;
|
||||||
|
|
||||||
const tabsArray = [
|
const tabsArray = [
|
||||||
@@ -115,7 +119,9 @@ class WorkflowJobTemplate extends Component {
|
|||||||
if (location.pathname.endsWith('edit')) {
|
if (location.pathname.endsWith('edit')) {
|
||||||
cardHeader = null;
|
cardHeader = null;
|
||||||
}
|
}
|
||||||
|
if (hasContentLoading) {
|
||||||
|
return <ContentLoading />;
|
||||||
|
}
|
||||||
if (!hasContentLoading && contentError) {
|
if (!hasContentLoading && contentError) {
|
||||||
return (
|
return (
|
||||||
<PageSection>
|
<PageSection>
|
||||||
@@ -134,73 +140,89 @@ class WorkflowJobTemplate extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<PageSection>
|
||||||
{cardHeader}
|
<Card>
|
||||||
<Switch>
|
{cardHeader}
|
||||||
<Redirect
|
<Switch>
|
||||||
from="/templates/workflow_job_template/:id"
|
<Redirect
|
||||||
to="/templates/workflow_job_template/:id/details"
|
from="/templates/workflow_job_template/:id"
|
||||||
exact
|
to="/templates/workflow_job_template/:id/details"
|
||||||
/>
|
exact
|
||||||
{template && (
|
|
||||||
<Route
|
|
||||||
key="wfjt-details"
|
|
||||||
path="/templates/workflow_job_template/:id/details"
|
|
||||||
render={() => (
|
|
||||||
<WorkflowJobTemplateDetail
|
|
||||||
template={template}
|
|
||||||
webHookKey={webHookKey}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
{template && (
|
||||||
{template && (
|
<Route
|
||||||
<Route
|
key="wfjt-details"
|
||||||
key="wfjt-visualizer"
|
path="/templates/workflow_job_template/:id/details"
|
||||||
path="/templates/workflow_job_template/:id/visualizer"
|
render={() => (
|
||||||
render={() => (
|
<WorkflowJobTemplateDetail
|
||||||
<AppendBody>
|
template={template}
|
||||||
<FullPage>
|
webhook_key={webhook_key}
|
||||||
<Visualizer template={template} />
|
/>
|
||||||
</FullPage>
|
)}
|
||||||
</AppendBody>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{template?.id && (
|
|
||||||
<Route path="/templates/workflow_job_template/:id/completed_jobs">
|
|
||||||
<JobList
|
|
||||||
defaultParams={{
|
|
||||||
workflow_job__workflow_job_template: template.id,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Route>
|
)}
|
||||||
)}
|
{template && (
|
||||||
{template && (
|
<Route
|
||||||
|
key="wfjt-edit"
|
||||||
|
path="/templates/workflow_job_template/:id/edit"
|
||||||
|
render={() => (
|
||||||
|
<WorkflowJobTemplateEdit
|
||||||
|
template={template}
|
||||||
|
webhook_key={webhook_key}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{template && (
|
||||||
|
<Route
|
||||||
|
key="wfjt-visualizer"
|
||||||
|
path="/templates/workflow_job_template/:id/visualizer"
|
||||||
|
render={() => (
|
||||||
|
<AppendBody>
|
||||||
|
<FullPage>
|
||||||
|
<Visualizer template={template} />
|
||||||
|
</FullPage>
|
||||||
|
</AppendBody>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{template?.id && (
|
||||||
|
<Route path="/templates/workflow_job_template/:id/completed_jobs">
|
||||||
|
<JobList
|
||||||
|
defaultParams={{
|
||||||
|
workflow_job__workflow_job_template: template.id,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
)}
|
||||||
|
{template.id && (
|
||||||
|
<Route
|
||||||
|
path="/templates/:templateType/:id/schedules"
|
||||||
|
render={() => (
|
||||||
|
<ScheduleList loadSchedules={this.loadSchedules} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Route
|
<Route
|
||||||
path="/templates/:templateType/:id/schedules"
|
key="not-found"
|
||||||
render={() => <ScheduleList loadSchedules={this.loadSchedules} />}
|
path="*"
|
||||||
|
render={() =>
|
||||||
|
!hasContentLoading && (
|
||||||
|
<ContentError isNotFound>
|
||||||
|
{match.params.id && (
|
||||||
|
<Link
|
||||||
|
to={`/templates/workflow_job_template/${match.params.id}/details`}
|
||||||
|
>
|
||||||
|
{i18n._(`View Template Details`)}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</ContentError>
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
</Switch>
|
||||||
<Route
|
</Card>
|
||||||
key="not-found"
|
</PageSection>
|
||||||
path="*"
|
|
||||||
render={() =>
|
|
||||||
!hasContentLoading && (
|
|
||||||
<ContentError isNotFound>
|
|
||||||
{match.params.id && (
|
|
||||||
<Link
|
|
||||||
to={`/templates/workflow_job_template/${match.params.id}/details`}
|
|
||||||
>
|
|
||||||
{i18n._(`View Template Details`)}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</ContentError>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Switch>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { Card, PageSection } from '@patternfly/react-core';
|
||||||
|
import { CardBody } from '@components/Card';
|
||||||
|
|
||||||
|
import { WorkflowJobTemplatesAPI, OrganizationsAPI } from '@api';
|
||||||
|
import WorkflowJobTemplateForm from '../shared/WorkflowJobTemplateForm';
|
||||||
|
|
||||||
|
function WorkflowJobTemplateAdd() {
|
||||||
|
const history = useHistory();
|
||||||
|
const [formSubmitError, setFormSubmitError] = useState(null);
|
||||||
|
|
||||||
|
const handleSubmit = async values => {
|
||||||
|
const { labels, organizationId, ...remainingValues } = values;
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
data: { id },
|
||||||
|
} = await WorkflowJobTemplatesAPI.create(remainingValues);
|
||||||
|
await Promise.all(await submitLabels(id, labels, organizationId));
|
||||||
|
history.push(`/templates/workflow_job_template/${id}/details`);
|
||||||
|
} catch (err) {
|
||||||
|
setFormSubmitError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitLabels = async (templateId, labels = [], organizationId) => {
|
||||||
|
if (!organizationId) {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
data: { results },
|
||||||
|
} = await OrganizationsAPI.read();
|
||||||
|
organizationId = results[0].id;
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const associatePromises = labels.map(label =>
|
||||||
|
WorkflowJobTemplatesAPI.associateLabel(templateId, label, organizationId)
|
||||||
|
);
|
||||||
|
return [...associatePromises];
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
history.push(`/templates`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageSection>
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<WorkflowJobTemplateForm
|
||||||
|
handleCancel={handleCancel}
|
||||||
|
handleSubmit={handleSubmit}
|
||||||
|
submitError={formSubmitError}
|
||||||
|
/>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</PageSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WorkflowJobTemplateAdd;
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Route } from 'react-router-dom';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { WorkflowJobTemplatesAPI, OrganizationsAPI, LabelsAPI } from '@api';
|
||||||
|
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||||
|
import { createMemoryHistory } from 'history';
|
||||||
|
|
||||||
|
import WorkflowJobTemplateAdd from './WorkflowJobTemplateAdd';
|
||||||
|
|
||||||
|
jest.mock('@api/models/WorkflowJobTemplates');
|
||||||
|
jest.mock('@api/models/Organizations');
|
||||||
|
jest.mock('@api/models/Labels');
|
||||||
|
jest.mock('@api/models/Inventories');
|
||||||
|
|
||||||
|
describe('<WorkflowJobTemplateAdd/>', () => {
|
||||||
|
let wrapper;
|
||||||
|
let history;
|
||||||
|
beforeEach(async () => {
|
||||||
|
WorkflowJobTemplatesAPI.create.mockResolvedValue({ data: { id: 1 } });
|
||||||
|
OrganizationsAPI.read.mockResolvedValue({ results: [{ id: 1 }] });
|
||||||
|
LabelsAPI.read.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
results: [
|
||||||
|
{ name: 'Label 1', id: 1 },
|
||||||
|
{ name: 'Label 2', id: 2 },
|
||||||
|
{ name: 'Label 3', id: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
history = createMemoryHistory({
|
||||||
|
initialEntries: ['/templates/workflow_job_template/add'],
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = await mountWithContexts(
|
||||||
|
<Route
|
||||||
|
path="/templates/workflow_job_template/add"
|
||||||
|
component={() => <WorkflowJobTemplateAdd />}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
context: {
|
||||||
|
router: {
|
||||||
|
history,
|
||||||
|
route: {
|
||||||
|
location: history.location,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
wrapper.unmount();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initially renders successfully', async () => {
|
||||||
|
expect(wrapper.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calls workflowJobTemplatesAPI with correct information on submit', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
await wrapper.find('WorkflowJobTemplateForm').invoke('handleSubmit')({
|
||||||
|
name: 'Alex',
|
||||||
|
labels: [{ name: 'Foo', id: 1 }, { name: 'bar', id: 2 }],
|
||||||
|
organizationId: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(WorkflowJobTemplatesAPI.create).toHaveBeenCalledWith({
|
||||||
|
name: 'Alex',
|
||||||
|
});
|
||||||
|
expect(WorkflowJobTemplatesAPI.associateLabel).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleCancel navigates the user to the /templates', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
await wrapper.find('WorkflowJobTemplateForm').invoke('handleCancel')();
|
||||||
|
});
|
||||||
|
expect(history.location.pathname).toBe('/templates');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throwing error renders FormSubmitError component', async () => {
|
||||||
|
const error = {
|
||||||
|
response: {
|
||||||
|
config: {
|
||||||
|
method: 'post',
|
||||||
|
url: '/api/v2/workflow_job_templates/',
|
||||||
|
},
|
||||||
|
data: { detail: 'An error occurred' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
WorkflowJobTemplatesAPI.create.mockRejectedValue(error);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('WorkflowJobTemplateForm').invoke('handleSubmit')({
|
||||||
|
name: 'Foo',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(WorkflowJobTemplatesAPI.create).toHaveBeenCalled();
|
||||||
|
wrapper.update();
|
||||||
|
expect(wrapper.find('WorkflowJobTemplateForm').prop('submitError')).toEqual(
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throwing error prevents navigation away from form', async () => {
|
||||||
|
OrganizationsAPI.read.mockRejectedValue({
|
||||||
|
response: {
|
||||||
|
config: {
|
||||||
|
method: 'get',
|
||||||
|
url: '/api/v2/organizations/',
|
||||||
|
},
|
||||||
|
data: 'An error occurred',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
WorkflowJobTemplatesAPI.update.mockResolvedValue();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await wrapper.find('Button[aria-label="Save"]').simulate('click');
|
||||||
|
});
|
||||||
|
expect(wrapper.find('WorkflowJobTemplateForm').length).toBe(1);
|
||||||
|
expect(OrganizationsAPI.read).toBeCalled();
|
||||||
|
expect(history.location.pathname).toBe(
|
||||||
|
'/templates/workflow_job_template/add'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './WorkflowJobTemplateAdd';
|
||||||
@@ -25,7 +25,7 @@ import LaunchButton from '@components/LaunchButton';
|
|||||||
import Sparkline from '@components/Sparkline';
|
import Sparkline from '@components/Sparkline';
|
||||||
import { toTitleCase } from '@util/strings';
|
import { toTitleCase } from '@util/strings';
|
||||||
|
|
||||||
function WorkflowJobTemplateDetail({ template, i18n, webHookKey }) {
|
function WorkflowJobTemplateDetail({ template, i18n, webhook_key }) {
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
ask_inventory_on_launch,
|
ask_inventory_on_launch,
|
||||||
@@ -39,12 +39,15 @@ function WorkflowJobTemplateDetail({ template, i18n, webHookKey }) {
|
|||||||
related,
|
related,
|
||||||
webhook_credential,
|
webhook_credential,
|
||||||
} = template;
|
} = template;
|
||||||
|
|
||||||
const urlOrigin = window.location.origin;
|
const urlOrigin = window.location.origin;
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
const [deletionError, setDeletionError] = useState(null);
|
const [deletionError, setDeletionError] = useState(null);
|
||||||
const [hasContentLoading, setHasContentLoading] = useState(false);
|
const [hasContentLoading, setHasContentLoading] = useState(false);
|
||||||
|
|
||||||
const renderOptionsField =
|
const renderOptionsField =
|
||||||
template.allow_simultaneous || template.webhook_servicee;
|
template.allow_simultaneous || template.webhook_service;
|
||||||
|
|
||||||
const renderOptions = (
|
const renderOptions = (
|
||||||
<TextList component={TextListVariants.ul}>
|
<TextList component={TextListVariants.ul}>
|
||||||
@@ -55,7 +58,7 @@ function WorkflowJobTemplateDetail({ template, i18n, webHookKey }) {
|
|||||||
)}
|
)}
|
||||||
{template.webhook_service && (
|
{template.webhook_service && (
|
||||||
<TextListItem component={TextListItemVariants.li}>
|
<TextListItem component={TextListItemVariants.li}>
|
||||||
{i18n._(t`- Webhooks`)}
|
{i18n._(t`- Enable Webhook`)}
|
||||||
</TextListItem>
|
</TextListItem>
|
||||||
)}
|
)}
|
||||||
</TextList>
|
</TextList>
|
||||||
@@ -75,6 +78,7 @@ function WorkflowJobTemplateDetail({ template, i18n, webHookKey }) {
|
|||||||
}
|
}
|
||||||
setHasContentLoading(false);
|
setHasContentLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const inventoryValue = (kind, inventoryId) => {
|
const inventoryValue = (kind, inventoryId) => {
|
||||||
const inventorykind = kind === 'smart' ? 'smart_inventory' : 'inventory';
|
const inventorykind = kind === 'smart' ? 'smart_inventory' : 'inventory';
|
||||||
|
|
||||||
@@ -91,6 +95,7 @@ function WorkflowJobTemplateDetail({ template, i18n, webHookKey }) {
|
|||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const canLaunch = summary_fields?.user_capabilities?.start;
|
const canLaunch = summary_fields?.user_capabilities?.start;
|
||||||
const recentPlaybookJobs = summary_fields.recent_jobs.map(job => ({
|
const recentPlaybookJobs = summary_fields.recent_jobs.map(job => ({
|
||||||
...job,
|
...job,
|
||||||
@@ -143,7 +148,7 @@ function WorkflowJobTemplateDetail({ template, i18n, webHookKey }) {
|
|||||||
value={`${urlOrigin}${template.related.webhook_receiver}`}
|
value={`${urlOrigin}${template.related.webhook_receiver}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Detail label={i18n._(t`Webhook Key`)} value={webHookKey} />
|
<Detail label={i18n._(t`Webhook Key`)} value={webhook_key} />
|
||||||
{webhook_credential && (
|
{webhook_credential && (
|
||||||
<Detail
|
<Detail
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ describe('<WorkflowJobTemplateDetail/>', () => {
|
|||||||
},
|
},
|
||||||
webhook_service: 'Github',
|
webhook_service: 'Github',
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
history = createMemoryHistory({
|
history = createMemoryHistory({
|
||||||
initialEntries: ['/templates/workflow_job_template/1/details'],
|
initialEntries: ['/templates/workflow_job_template/1/details'],
|
||||||
@@ -51,7 +52,7 @@ describe('<WorkflowJobTemplateDetail/>', () => {
|
|||||||
component={() => (
|
component={() => (
|
||||||
<WorkflowJobTemplateDetail
|
<WorkflowJobTemplateDetail
|
||||||
template={template}
|
template={template}
|
||||||
webHookKey="Foo webhook key"
|
webhook_key="Foo webhook key"
|
||||||
hasContentLoading={false}
|
hasContentLoading={false}
|
||||||
onSetContentLoading={() => {}}
|
onSetContentLoading={() => {}}
|
||||||
/>
|
/>
|
||||||
@@ -75,12 +76,15 @@ describe('<WorkflowJobTemplateDetail/>', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renders successfully', () => {
|
test('renders successfully', () => {
|
||||||
expect(wrapper.find(WorkflowJobTemplateDetail).length).toBe(1);
|
expect(wrapper.find(WorkflowJobTemplateDetail).length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('expect detail fields to render properly', () => {
|
test('expect detail fields to render properly', () => {
|
||||||
const renderedValues = [
|
const renderedValues = [
|
||||||
{
|
{
|
||||||
@@ -147,6 +151,7 @@ describe('<WorkflowJobTemplateDetail/>', () => {
|
|||||||
|
|
||||||
renderedValues.map(value => assertValue(value));
|
renderedValues.map(value => assertValue(value));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('link out resource have the correct url', () => {
|
test('link out resource have the correct url', () => {
|
||||||
const inventory = wrapper.find('Detail[label="Inventory"]').find('Link');
|
const inventory = wrapper.find('Detail[label="Inventory"]').find('Link');
|
||||||
const organization = wrapper
|
const organization = wrapper
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { CardBody } from '@components/Card';
|
||||||
|
import { getAddedAndRemoved } from '@util/lists';
|
||||||
|
import { WorkflowJobTemplatesAPI, OrganizationsAPI } from '@api';
|
||||||
|
import { WorkflowJobTemplateForm } from '../shared';
|
||||||
|
|
||||||
|
function WorkflowJobTemplateEdit({ template, webhook_key }) {
|
||||||
|
const history = useHistory();
|
||||||
|
const [formSubmitError, setFormSubmitError] = useState(null);
|
||||||
|
|
||||||
|
const handleSubmit = async values => {
|
||||||
|
const { labels, ...remainingValues } = values;
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
await submitLabels(labels, values.organization, template.organization)
|
||||||
|
);
|
||||||
|
await WorkflowJobTemplatesAPI.update(template.id, remainingValues);
|
||||||
|
history.push(`/templates/workflow_job_template/${template.id}/details`);
|
||||||
|
} catch (err) {
|
||||||
|
setFormSubmitError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitLabels = async (labels = [], formOrgId, templateOrgId) => {
|
||||||
|
const { added, removed } = getAddedAndRemoved(
|
||||||
|
template.summary_fields.labels.results,
|
||||||
|
labels
|
||||||
|
);
|
||||||
|
let orgId = formOrgId || templateOrgId;
|
||||||
|
if (!orgId) {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
data: { results },
|
||||||
|
} = await OrganizationsAPI.read();
|
||||||
|
orgId = results[0].id;
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const disassociationPromises = await removed.map(label =>
|
||||||
|
WorkflowJobTemplatesAPI.disassociateLabel(template.id, label)
|
||||||
|
);
|
||||||
|
const associationPromises = await added.map(label =>
|
||||||
|
WorkflowJobTemplatesAPI.associateLabel(template.id, label, orgId)
|
||||||
|
);
|
||||||
|
const results = [...disassociationPromises, ...associationPromises];
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
history.push(`/templates/workflow_job_template/${template.id}/details`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardBody>
|
||||||
|
<WorkflowJobTemplateForm
|
||||||
|
handleSubmit={handleSubmit}
|
||||||
|
handleCancel={handleCancel}
|
||||||
|
template={template}
|
||||||
|
webhook_key={webhook_key}
|
||||||
|
submitError={formSubmitError}
|
||||||
|
/>
|
||||||
|
</CardBody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export default WorkflowJobTemplateEdit;
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Route } from 'react-router-dom';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { WorkflowJobTemplatesAPI, OrganizationsAPI, LabelsAPI } from '@api';
|
||||||
|
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||||
|
import { createMemoryHistory } from 'history';
|
||||||
|
import WorkflowJobTemplateEdit from './WorkflowJobTemplateEdit';
|
||||||
|
|
||||||
|
jest.mock('@api/models/WorkflowJobTemplates');
|
||||||
|
jest.mock('@api/models/Labels');
|
||||||
|
jest.mock('@api/models/Organizations');
|
||||||
|
jest.mock('@api/models/Inventories');
|
||||||
|
|
||||||
|
const mockTemplate = {
|
||||||
|
id: 6,
|
||||||
|
name: 'Foo',
|
||||||
|
description: 'Foo description',
|
||||||
|
summary_fields: {
|
||||||
|
inventory: { id: 1, name: 'Inventory 1' },
|
||||||
|
organization: { id: 1, name: 'Organization 1' },
|
||||||
|
labels: {
|
||||||
|
results: [{ name: 'Label 1', id: 1 }, { name: 'Label 2', id: 2 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scm_branch: 'devel',
|
||||||
|
limit: '5000',
|
||||||
|
variables: '---',
|
||||||
|
};
|
||||||
|
describe('<WorkflowJobTemplateEdit/>', () => {
|
||||||
|
let wrapper;
|
||||||
|
let history;
|
||||||
|
beforeEach(async () => {
|
||||||
|
LabelsAPI.read.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
results: [{ name: 'Label 1', id: 1 }, { name: 'Label 2', id: 2 }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
OrganizationsAPI.read.mockResolvedValue({ results: [{ id: 1 }] });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
history = createMemoryHistory({
|
||||||
|
initialEntries: ['/templates/workflow_job_template/6/edit'],
|
||||||
|
});
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<Route
|
||||||
|
path="/templates/workflow_job_template/:id/edit"
|
||||||
|
component={() => <WorkflowJobTemplateEdit template={mockTemplate} />}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
context: {
|
||||||
|
router: {
|
||||||
|
history,
|
||||||
|
route: {
|
||||||
|
location: history.location,
|
||||||
|
match: { params: { id: 6 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
wrapper.unmount();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders successfully', () => {
|
||||||
|
expect(wrapper.find('WorkflowJobTemplateEdit').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('api is called to properly to save the updated template.', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
await wrapper.find('WorkflowJobTemplateForm').invoke('handleSubmit')({
|
||||||
|
id: 6,
|
||||||
|
name: 'Alex',
|
||||||
|
description: 'Apollo and Athena',
|
||||||
|
inventory: 1,
|
||||||
|
organization: 1,
|
||||||
|
labels: [{ name: 'Label 2', id: 2 }, { name: 'Generated Label' }],
|
||||||
|
scm_branch: 'master',
|
||||||
|
limit: '5000',
|
||||||
|
variables: '---',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(WorkflowJobTemplatesAPI.update).toHaveBeenCalledWith(6, {
|
||||||
|
id: 6,
|
||||||
|
name: 'Alex',
|
||||||
|
description: 'Apollo and Athena',
|
||||||
|
inventory: 1,
|
||||||
|
organization: 1,
|
||||||
|
scm_branch: 'master',
|
||||||
|
limit: '5000',
|
||||||
|
variables: '---',
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
await expect(WorkflowJobTemplatesAPI.disassociateLabel).toBeCalledWith(6, {
|
||||||
|
name: 'Label 1',
|
||||||
|
id: 1,
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
await expect(WorkflowJobTemplatesAPI.associateLabel).toBeCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleCancel navigates the user to the /templates', () => {
|
||||||
|
act(() => {
|
||||||
|
wrapper.find('WorkflowJobTemplateForm').invoke('handleCancel')();
|
||||||
|
});
|
||||||
|
expect(history.location.pathname).toBe(
|
||||||
|
'/templates/workflow_job_template/6/details'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throwing error renders FormSubmitError component', async () => {
|
||||||
|
const error = {
|
||||||
|
response: {
|
||||||
|
config: {
|
||||||
|
method: 'patch',
|
||||||
|
url: '/api/v2/workflow_job_templates/',
|
||||||
|
},
|
||||||
|
data: { detail: 'An error occurred' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
WorkflowJobTemplatesAPI.update.mockRejectedValue(error);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Button[aria-label="Save"]').simulate('click');
|
||||||
|
});
|
||||||
|
expect(WorkflowJobTemplatesAPI.update).toHaveBeenCalled();
|
||||||
|
wrapper.update();
|
||||||
|
expect(wrapper.find('WorkflowJobTemplateForm').prop('submitError')).toEqual(
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throwing error prevents form submission', async () => {
|
||||||
|
const templateWithoutOrg = {
|
||||||
|
id: 6,
|
||||||
|
name: 'Foo',
|
||||||
|
description: 'Foo description',
|
||||||
|
summary_fields: {
|
||||||
|
inventory: { id: 1, name: 'Inventory 1' },
|
||||||
|
labels: {
|
||||||
|
results: [{ name: 'Label 1', id: 1 }, { name: 'Label 2', id: 2 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scm_branch: 'devel',
|
||||||
|
limit: '5000',
|
||||||
|
variables: '---',
|
||||||
|
};
|
||||||
|
|
||||||
|
let newWrapper;
|
||||||
|
await act(async () => {
|
||||||
|
newWrapper = await mountWithContexts(
|
||||||
|
<WorkflowJobTemplateEdit template={templateWithoutOrg} />,
|
||||||
|
{
|
||||||
|
context: {
|
||||||
|
router: {
|
||||||
|
history,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
OrganizationsAPI.read.mockRejectedValue({
|
||||||
|
response: {
|
||||||
|
config: {
|
||||||
|
method: 'get',
|
||||||
|
url: '/api/v2/organizations/',
|
||||||
|
},
|
||||||
|
data: { detail: 'An error occurred' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
WorkflowJobTemplatesAPI.update.mockResolvedValue();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await newWrapper.find('Button[aria-label="Save"]').simulate('click');
|
||||||
|
});
|
||||||
|
expect(newWrapper.find('WorkflowJobTemplateForm').length).toBe(1);
|
||||||
|
expect(OrganizationsAPI.read).toBeCalled();
|
||||||
|
expect(WorkflowJobTemplatesAPI.update).not.toBeCalled();
|
||||||
|
expect(history.location.pathname).toBe(
|
||||||
|
'/templates/workflow_job_template/6/edit'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './WorkflowJobTemplateEdit';
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { useRouteMatch, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { func, shape } from 'prop-types';
|
||||||
|
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { Formik, Field } from 'formik';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormGroup,
|
||||||
|
InputGroup,
|
||||||
|
Button,
|
||||||
|
TextInput,
|
||||||
|
Checkbox,
|
||||||
|
} from '@patternfly/react-core';
|
||||||
|
import { required } from '@util/validators';
|
||||||
|
import { SyncAltIcon } from '@patternfly/react-icons';
|
||||||
|
|
||||||
|
import AnsibleSelect from '@components/AnsibleSelect';
|
||||||
|
import { WorkflowJobTemplatesAPI, CredentialTypesAPI } from '@api';
|
||||||
|
|
||||||
|
import useRequest from '@util/useRequest';
|
||||||
|
import FormField, {
|
||||||
|
FieldTooltip,
|
||||||
|
FormSubmitError,
|
||||||
|
} from '@components/FormField';
|
||||||
|
import {
|
||||||
|
FormColumnLayout,
|
||||||
|
FormFullWidthLayout,
|
||||||
|
FormCheckboxLayout,
|
||||||
|
} from '@components/FormLayout';
|
||||||
|
import ContentLoading from '@components/ContentLoading';
|
||||||
|
import OrganizationLookup from '@components/Lookup/OrganizationLookup';
|
||||||
|
import CredentialLookup from '@components/Lookup/CredentialLookup';
|
||||||
|
import { InventoryLookup } from '@components/Lookup';
|
||||||
|
import { VariablesField } from '@components/CodeMirrorInput';
|
||||||
|
import FormActionGroup from '@components/FormActionGroup';
|
||||||
|
import ContentError from '@components/ContentError';
|
||||||
|
import CheckboxField from '@components/FormField/CheckboxField';
|
||||||
|
import LabelSelect from './LabelSelect';
|
||||||
|
|
||||||
|
function WorkflowJobTemplateForm({
|
||||||
|
handleSubmit,
|
||||||
|
handleCancel,
|
||||||
|
i18n,
|
||||||
|
template = {},
|
||||||
|
webhook_key,
|
||||||
|
submitError,
|
||||||
|
}) {
|
||||||
|
const urlOrigin = window.location.origin;
|
||||||
|
const { id } = useParams();
|
||||||
|
const wfjtAddMatch = useRouteMatch('/templates/workflow_job_template/add');
|
||||||
|
|
||||||
|
const [hasContentError, setContentError] = useState(null);
|
||||||
|
const [webhook_url, setWebhookUrl] = useState(
|
||||||
|
template?.related?.webhook_receiver
|
||||||
|
? `${urlOrigin}${template.related.webhook_receiver}`
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
const [inventory, setInventory] = useState(
|
||||||
|
template?.summary_fields?.inventory || null
|
||||||
|
);
|
||||||
|
const [organization, setOrganization] = useState(
|
||||||
|
template?.summary_fields?.organization || null
|
||||||
|
);
|
||||||
|
const [webhookCredential, setWebhookCredential] = useState(
|
||||||
|
template?.summary_fields?.webhook_credential || null
|
||||||
|
);
|
||||||
|
const [webhookKey, setWebHookKey] = useState(webhook_key);
|
||||||
|
const [webhookService, setWebHookService] = useState(
|
||||||
|
template.webhook_service || ''
|
||||||
|
);
|
||||||
|
const [hasWebhooks, setHasWebhooks] = useState(Boolean(webhookService));
|
||||||
|
|
||||||
|
const webhookServiceOptions = [
|
||||||
|
{
|
||||||
|
value: '',
|
||||||
|
key: '',
|
||||||
|
label: i18n._(t`Choose a Webhook Service`),
|
||||||
|
isDisabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'github',
|
||||||
|
key: 'github',
|
||||||
|
label: i18n._(t`GitHub`),
|
||||||
|
isDisabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'gitlab',
|
||||||
|
key: 'gitlab',
|
||||||
|
label: i18n._(t`GitLab`),
|
||||||
|
isDisabled: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const {
|
||||||
|
request: loadCredentialType,
|
||||||
|
error: contentError,
|
||||||
|
contentLoading,
|
||||||
|
result: credTypeId,
|
||||||
|
} = useRequest(
|
||||||
|
useCallback(async () => {
|
||||||
|
let results;
|
||||||
|
if (webhookService) {
|
||||||
|
results = await CredentialTypesAPI.read({
|
||||||
|
namespace: `${webhookService}_token`,
|
||||||
|
});
|
||||||
|
// TODO: Consider how to handle the situation where the results returns
|
||||||
|
// and empty array, or any of the other values is undefined or null (data, results, id)
|
||||||
|
}
|
||||||
|
return results?.data?.results[0]?.id;
|
||||||
|
}, [webhookService])
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCredentialType();
|
||||||
|
}, [loadCredentialType]);
|
||||||
|
|
||||||
|
// TODO: Convert this function below to useRequest. Might want to create a new
|
||||||
|
// webhookkey component that handles all of that api calls. Will also need
|
||||||
|
// to move this api call out of WorkflowJobTemplate.jsx and add it to workflowJobTemplateDetai.jsx
|
||||||
|
const changeWebhookKey = async () => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
data: { webhook_key: key },
|
||||||
|
} = await WorkflowJobTemplatesAPI.updateWebhookKey(id);
|
||||||
|
setWebHookKey(key);
|
||||||
|
} catch (err) {
|
||||||
|
setContentError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let initialWebhookKey = webhook_key;
|
||||||
|
const initialWebhookCredential = template?.summary_fields?.webhook_credential;
|
||||||
|
|
||||||
|
const storeWebhookValues = (form, webhookServiceValue) => {
|
||||||
|
if (
|
||||||
|
webhookServiceValue === form.initialValues.webhook_service ||
|
||||||
|
webhookServiceValue === ''
|
||||||
|
) {
|
||||||
|
form.setFieldValue(
|
||||||
|
'webhook_credential',
|
||||||
|
form.initialValues.webhook_credential
|
||||||
|
);
|
||||||
|
setWebhookCredential(initialWebhookCredential);
|
||||||
|
|
||||||
|
setWebhookUrl(
|
||||||
|
template?.related?.webhook_receiver
|
||||||
|
? `${urlOrigin}${template.related.webhook_receiver}`
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
form.setFieldValue('webhook_service', form.initialValues.webhook_service);
|
||||||
|
setWebHookService(form.initialValues.webhook_service);
|
||||||
|
|
||||||
|
setWebHookKey(initialWebhookKey);
|
||||||
|
} else {
|
||||||
|
form.setFieldValue('webhook_credential', null);
|
||||||
|
setWebhookCredential(null);
|
||||||
|
|
||||||
|
setWebhookUrl(
|
||||||
|
`${urlOrigin}/api/v2/workflow_job_templates/${template.id}/${webhookServiceValue}/`
|
||||||
|
);
|
||||||
|
|
||||||
|
setWebHookKey(
|
||||||
|
i18n._(t`a new webhook key will be generated on save.`).toUpperCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWebhookEnablement = (
|
||||||
|
form,
|
||||||
|
enabledWebhooks,
|
||||||
|
webhookServiceValue
|
||||||
|
) => {
|
||||||
|
if (!enabledWebhooks) {
|
||||||
|
initialWebhookKey = webhookKey;
|
||||||
|
form.setFieldValue('webhook_credential', null);
|
||||||
|
form.setFieldValue('webhook_service', '');
|
||||||
|
setWebhookUrl('');
|
||||||
|
setWebHookService('');
|
||||||
|
setWebHookKey('');
|
||||||
|
} else {
|
||||||
|
storeWebhookValues(form, webhookServiceValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasContentError || contentError) {
|
||||||
|
return <ContentError error={contentError || hasContentError} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentLoading) {
|
||||||
|
return <ContentLoading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
onSubmit={values => {
|
||||||
|
if (values.webhook_service === '') {
|
||||||
|
values.webhook_credential = '';
|
||||||
|
}
|
||||||
|
return handleSubmit(values);
|
||||||
|
}}
|
||||||
|
initialValues={{
|
||||||
|
name: template.name || '',
|
||||||
|
description: template.description || '',
|
||||||
|
inventory: template?.summary_fields?.inventory?.id || null,
|
||||||
|
organization: template?.summary_fields?.organization?.id || null,
|
||||||
|
labels: template.summary_fields?.labels?.results || [],
|
||||||
|
extra_vars: template.extra_vars || '---',
|
||||||
|
limit: template.limit || '',
|
||||||
|
scm_branch: template.scm_branch || '',
|
||||||
|
allow_simultaneous: template.allow_simultaneous || false,
|
||||||
|
webhook_credential:
|
||||||
|
template?.summary_fields?.webhook_credential?.id || null,
|
||||||
|
webhook_service: template.webhook_service || '',
|
||||||
|
ask_limit_on_launch: template.ask_limit_on_launch || false,
|
||||||
|
ask_inventory_on_launch: template.ask_inventory_on_launch || false,
|
||||||
|
ask_variables_on_launch: template.ask_variables_on_launch || false,
|
||||||
|
ask_scm_branch_on_launch: template.ask_scm_branch_on_launch || false,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formik => (
|
||||||
|
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||||
|
<FormColumnLayout>
|
||||||
|
<FormField
|
||||||
|
id="wfjt-name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
label={i18n._(t`Name`)}
|
||||||
|
validate={required(null, i18n)}
|
||||||
|
isRequired
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
id="wfjt-description"
|
||||||
|
name="description"
|
||||||
|
type="text"
|
||||||
|
label={i18n._(t`Description`)}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
id="wfjt-organization"
|
||||||
|
label={i18n._(t`Organization`)}
|
||||||
|
name="organization"
|
||||||
|
>
|
||||||
|
{({ form }) => (
|
||||||
|
<OrganizationLookup
|
||||||
|
helperTextInvalid={form.errors.organization}
|
||||||
|
onChange={value => {
|
||||||
|
form.setFieldValue('organization', value?.id || null);
|
||||||
|
setOrganization(value);
|
||||||
|
}}
|
||||||
|
value={organization}
|
||||||
|
isValid={!form.errors.organization}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field name="inventory">
|
||||||
|
{({ form }) => (
|
||||||
|
<FormGroup
|
||||||
|
label={i18n._(t`Inventory`)}
|
||||||
|
fieldId="wfjt-inventory"
|
||||||
|
>
|
||||||
|
<FieldTooltip
|
||||||
|
content={i18n._(
|
||||||
|
t`Select an inventory for the workflow. This inventory is applied to all job template nodes that prompt for an inventory.`
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<InventoryLookup
|
||||||
|
value={inventory}
|
||||||
|
isValid={!form.errors.inventory}
|
||||||
|
helperTextInvalid={form.errors.inventory}
|
||||||
|
onChange={value => {
|
||||||
|
form.setFieldValue('inventory', value?.id || null);
|
||||||
|
setInventory(value);
|
||||||
|
form.setFieldValue('organizationId', value?.organization);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<FormField
|
||||||
|
type="text"
|
||||||
|
name="limit"
|
||||||
|
id="wfjt-limit"
|
||||||
|
label={i18n._(t`Limit`)}
|
||||||
|
tooltip={i18n._(
|
||||||
|
t`Provide a host pattern to further constrain the list of hosts that will be managed or affected by the workflow. This limit is applied to all job template nodes that prompt for a limit. Refer to Ansible documentation for more information and examples on patterns.`
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
type="text"
|
||||||
|
label={i18n._(t`SCM Branch`)}
|
||||||
|
tooltip={i18n._(
|
||||||
|
t`Select a branch for the workflow. This branch is applied to all job template nodes that prompt for a branch.`
|
||||||
|
)}
|
||||||
|
id="wfjt-scm_branch"
|
||||||
|
name="scm_branch"
|
||||||
|
/>
|
||||||
|
</FormColumnLayout>
|
||||||
|
<FormFullWidthLayout>
|
||||||
|
<Field name="labels">
|
||||||
|
{({ form, field }) => (
|
||||||
|
<FormGroup
|
||||||
|
label={i18n._(t`Labels`)}
|
||||||
|
helperTextInvalid={form.errors.webhook_service}
|
||||||
|
isValid={!(form.touched.labels || form.errors.labels)}
|
||||||
|
name="wfjt-labels"
|
||||||
|
fieldId="wfjt-labels"
|
||||||
|
>
|
||||||
|
<FieldTooltip
|
||||||
|
content={i18n._(t`Optional labels that describe this job template,
|
||||||
|
such as 'dev' or 'test'. Labels can be used to group and filter
|
||||||
|
job templates and completed jobs.`)}
|
||||||
|
/>
|
||||||
|
<LabelSelect
|
||||||
|
value={field.value}
|
||||||
|
onChange={labels => form.setFieldValue('labels', labels)}
|
||||||
|
onError={err => setContentError(err)}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</FormFullWidthLayout>
|
||||||
|
<FormFullWidthLayout>
|
||||||
|
<VariablesField
|
||||||
|
id="wfjt-variables"
|
||||||
|
name="extra_vars"
|
||||||
|
label={i18n._(t`Variables`)}
|
||||||
|
tooltip={i18n._(
|
||||||
|
t`Pass extra command line variables to the playbook. This is the -e or --extra-vars command line parameter for ansible-playbook. Provide key/value pairs using either YAML or JSON. Refer to the Ansible Tower documentation for example syntax.`
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormFullWidthLayout>
|
||||||
|
<FormCheckboxLayout
|
||||||
|
fieldId="options"
|
||||||
|
isInline
|
||||||
|
label={i18n._(t`Options`)}
|
||||||
|
>
|
||||||
|
<Field id="wfjt-webhooks" name="hasWebhooks">
|
||||||
|
{({ form }) => (
|
||||||
|
<Checkbox
|
||||||
|
aria-label={i18n._(t`Enable Webhook`)}
|
||||||
|
label={
|
||||||
|
<span>
|
||||||
|
{i18n._(t`Enable Webhook`)}
|
||||||
|
|
||||||
|
<FieldTooltip
|
||||||
|
content={i18n._(
|
||||||
|
t`Enable webhook for this workflow job template.`
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
id="wfjt-enabled-webhooks"
|
||||||
|
isChecked={
|
||||||
|
Boolean(form.values.webhook_service) || hasWebhooks
|
||||||
|
}
|
||||||
|
onChange={checked => {
|
||||||
|
setHasWebhooks(checked);
|
||||||
|
handleWebhookEnablement(form, checked, webhookService);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<CheckboxField
|
||||||
|
name="allow_simultaneous"
|
||||||
|
id="allow_simultaneous"
|
||||||
|
tooltip={i18n._(
|
||||||
|
t`If enabled, simultaneous runs of this workflow job template will be allowed.`
|
||||||
|
)}
|
||||||
|
label={i18n._(t`Enable Concurrent Jobs`)}
|
||||||
|
/>
|
||||||
|
</FormCheckboxLayout>
|
||||||
|
{hasWebhooks && (
|
||||||
|
<FormColumnLayout>
|
||||||
|
<Field name="webhook_service">
|
||||||
|
{({ form, field }) => (
|
||||||
|
<FormGroup
|
||||||
|
name="webhook_service"
|
||||||
|
fieldId="webhook_service"
|
||||||
|
helperTextInvalid={form.errors.webhook_service}
|
||||||
|
isValid={
|
||||||
|
!(
|
||||||
|
form.touched.webhook_service ||
|
||||||
|
form.errors.webhook_service
|
||||||
|
)
|
||||||
|
}
|
||||||
|
label={i18n._(t`Webhook Service`)}
|
||||||
|
>
|
||||||
|
<FieldTooltip
|
||||||
|
content={i18n._(t`Select a webhook service`)}
|
||||||
|
/>
|
||||||
|
<AnsibleSelect
|
||||||
|
id="webhook_service"
|
||||||
|
data={webhookServiceOptions}
|
||||||
|
value={field.value}
|
||||||
|
onChange={(event, val) => {
|
||||||
|
setWebHookService(val);
|
||||||
|
storeWebhookValues(form, val);
|
||||||
|
|
||||||
|
form.setFieldValue('webhook_service', val);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
{!wfjtAddMatch && (
|
||||||
|
<>
|
||||||
|
<FormGroup
|
||||||
|
type="text"
|
||||||
|
fieldId="wfjt-webhookURL"
|
||||||
|
label={i18n._(t`Webhook URL`)}
|
||||||
|
id="wfjt-webhook-url"
|
||||||
|
name="webhook_url"
|
||||||
|
>
|
||||||
|
<FieldTooltip
|
||||||
|
content={i18n._(
|
||||||
|
t`Webhook services can launch jobs with this workflow job template by making a POST request to this URL.`
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
aria-label={i18n._(t`Webhook URL`)}
|
||||||
|
value={webhook_url}
|
||||||
|
isReadOnly
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
<Field>
|
||||||
|
{({ form }) => (
|
||||||
|
<FormGroup
|
||||||
|
fieldId="wfjt-webhook-key"
|
||||||
|
type="text"
|
||||||
|
id="wfjt-webhook-key"
|
||||||
|
name="webhook_key"
|
||||||
|
isValid={
|
||||||
|
!(form.touched.webhook_key || form.errors.webhook_key)
|
||||||
|
}
|
||||||
|
helperTextInvalid={form.errors.webhook_service}
|
||||||
|
label={i18n._(t`Webhook Key`)}
|
||||||
|
>
|
||||||
|
<FieldTooltip
|
||||||
|
content={i18n._(
|
||||||
|
t`Webhook services can use this as a shared secret.`
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<InputGroup>
|
||||||
|
<TextInput
|
||||||
|
isReadOnly
|
||||||
|
aria-label="wfjt-webhook-key"
|
||||||
|
value={webhookKey}
|
||||||
|
/>
|
||||||
|
<Button variant="tertiary" onClick={changeWebhookKey}>
|
||||||
|
<SyncAltIcon />
|
||||||
|
</Button>
|
||||||
|
</InputGroup>
|
||||||
|
</FormGroup>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{credTypeId && (
|
||||||
|
// TODO: Consider how to handle the situation where the results returns
|
||||||
|
// an empty array, or any of the other values is undefined or null
|
||||||
|
// (data, results, id)
|
||||||
|
<Field name="webhook_credential">
|
||||||
|
{({ form }) => (
|
||||||
|
<CredentialLookup
|
||||||
|
label={i18n._(t`Webhook Credential`)}
|
||||||
|
tooltip={i18n._(
|
||||||
|
t`Optionally select the credential to use to send status updates back to the webhook service.`
|
||||||
|
)}
|
||||||
|
credentialTypeId={credTypeId}
|
||||||
|
onChange={value => {
|
||||||
|
form.setFieldValue(
|
||||||
|
'webhook_credential',
|
||||||
|
value?.id || null
|
||||||
|
);
|
||||||
|
setWebhookCredential(value);
|
||||||
|
}}
|
||||||
|
isValid={!form.errors.webhook_credential}
|
||||||
|
helperTextInvalid={form.errors.webhook_credential}
|
||||||
|
value={webhookCredential}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</FormColumnLayout>
|
||||||
|
)}
|
||||||
|
{submitError && <FormSubmitError error={submitError} />}
|
||||||
|
<FormActionGroup
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onSubmit={formik.handleSubmit}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
WorkflowJobTemplateForm.propTypes = {
|
||||||
|
handleSubmit: func.isRequired,
|
||||||
|
handleCancel: func.isRequired,
|
||||||
|
submitError: shape({}),
|
||||||
|
};
|
||||||
|
|
||||||
|
WorkflowJobTemplateForm.defaultProps = {
|
||||||
|
submitError: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withI18n()(WorkflowJobTemplateForm);
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { Route } from 'react-router-dom';
|
||||||
|
import { createMemoryHistory } from 'history';
|
||||||
|
import { sleep } from '@testUtils/testUtils';
|
||||||
|
|
||||||
|
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||||
|
import WorkflowJobTemplateForm from './WorkflowJobTemplateForm';
|
||||||
|
import {
|
||||||
|
WorkflowJobTemplatesAPI,
|
||||||
|
LabelsAPI,
|
||||||
|
OrganizationsAPI,
|
||||||
|
InventoriesAPI,
|
||||||
|
} from '@api';
|
||||||
|
|
||||||
|
jest.mock('@api/models/WorkflowJobTemplates');
|
||||||
|
jest.mock('@api/models/Labels');
|
||||||
|
jest.mock('@api/models/Organizations');
|
||||||
|
jest.mock('@api/models/Inventories');
|
||||||
|
|
||||||
|
describe('<WorkflowJobTemplateForm/>', () => {
|
||||||
|
let wrapper;
|
||||||
|
let history;
|
||||||
|
const handleSubmit = jest.fn();
|
||||||
|
const handleCancel = jest.fn();
|
||||||
|
const mockTemplate = {
|
||||||
|
id: 6,
|
||||||
|
name: 'Foo',
|
||||||
|
description: 'Foo description',
|
||||||
|
summary_fields: {
|
||||||
|
inventory: { id: 1, name: 'Inventory 1' },
|
||||||
|
organization: { id: 1, name: 'Organization 1' },
|
||||||
|
labels: {
|
||||||
|
results: [{ name: 'Label 1', id: 1 }, { name: 'Label 2', id: 2 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scm_branch: 'devel',
|
||||||
|
limit: '5000',
|
||||||
|
variables: '---',
|
||||||
|
related: {
|
||||||
|
webhook_receiver: '/api/v2/workflow_job_templates/57/gitlab/',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
WorkflowJobTemplatesAPI.updateWebhookKey.mockResolvedValue({
|
||||||
|
data: { webhook_key: 'sdafdghjkl2345678ionbvcxz' },
|
||||||
|
});
|
||||||
|
LabelsAPI.read.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
results: [
|
||||||
|
{ name: 'Label 1', id: 1 },
|
||||||
|
{ name: 'Label 2', id: 2 },
|
||||||
|
{ name: 'Label 3', id: 3 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
OrganizationsAPI.read.mockResolvedValue({
|
||||||
|
results: [{ id: 1 }, { id: 2 }],
|
||||||
|
});
|
||||||
|
InventoriesAPI.read.mockResolvedValue({
|
||||||
|
results: [{ id: 1, name: 'Foo' }, { id: 2, name: 'Bar' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
history = createMemoryHistory({
|
||||||
|
initialEntries: ['/templates/workflow_job_template/6/edit'],
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = await mountWithContexts(
|
||||||
|
<Route
|
||||||
|
path="/templates/workflow_job_template/:id/edit"
|
||||||
|
component={() => (
|
||||||
|
<WorkflowJobTemplateForm
|
||||||
|
template={mockTemplate}
|
||||||
|
handleCancel={handleCancel}
|
||||||
|
handleSubmit={handleSubmit}
|
||||||
|
webhook_key="sdfghjklmnbvcdsew435678iokjhgfd"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
context: {
|
||||||
|
router: {
|
||||||
|
history,
|
||||||
|
route: {
|
||||||
|
location: history.location,
|
||||||
|
match: { params: { id: 6 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper.unmount();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders successfully', () => {
|
||||||
|
expect(wrapper.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all the fields render successfully', () => {
|
||||||
|
const fields = [
|
||||||
|
'FormField[name="name"]',
|
||||||
|
'FormField[name="description"]',
|
||||||
|
'Field[name="organization"]',
|
||||||
|
'Field[name="inventory"]',
|
||||||
|
'FormField[name="limit"]',
|
||||||
|
'FormField[name="scm_branch"]',
|
||||||
|
'Field[name="labels"]',
|
||||||
|
'VariablesField',
|
||||||
|
];
|
||||||
|
const assertField = field => {
|
||||||
|
expect(wrapper.find(`${field}`).length).toBe(1);
|
||||||
|
};
|
||||||
|
fields.map((field, index) => assertField(field, index));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changing inputs should update values', async () => {
|
||||||
|
const inputsToChange = [
|
||||||
|
{
|
||||||
|
element: 'wfjt-name',
|
||||||
|
value: { value: 'new foo', name: 'name' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: 'wfjt-description',
|
||||||
|
value: { value: 'new bar', name: 'description' },
|
||||||
|
},
|
||||||
|
{ element: 'wfjt-limit', value: { value: 1234567890, name: 'limit' } },
|
||||||
|
{
|
||||||
|
element: 'wfjt-scm_branch',
|
||||||
|
value: { value: 'new branch', name: 'scm_branch' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const changeInputs = async ({ element, value }) => {
|
||||||
|
wrapper.find(`input#${element}`).simulate('change', {
|
||||||
|
target: { value: `${value.value}`, name: `${value.name}` },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
inputsToChange.map(input => changeInputs(input));
|
||||||
|
|
||||||
|
wrapper.find('LabelSelect').invoke('onChange')([
|
||||||
|
{ name: 'Label 3', id: 3 },
|
||||||
|
{ name: 'Label 1', id: 1 },
|
||||||
|
{ name: 'Label 2', id: 2 },
|
||||||
|
]);
|
||||||
|
wrapper.find('InventoryLookup').invoke('onChange')({
|
||||||
|
id: 3,
|
||||||
|
name: 'inventory',
|
||||||
|
});
|
||||||
|
wrapper.find('OrganizationLookup').invoke('onChange')({
|
||||||
|
id: 3,
|
||||||
|
name: 'organization',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
|
const assertChanges = ({ element, value }) => {
|
||||||
|
expect(wrapper.find(`input#${element}`).prop('value')).toEqual(
|
||||||
|
`${value.value}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
inputsToChange.map(input => assertChanges(input));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('webhooks and enable concurrent jobs functions properly', async () => {
|
||||||
|
act(() => {
|
||||||
|
wrapper.find('Checkbox[aria-label="Enable Webhook"]').invoke('onChange')(
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
currentTarget: { value: true, type: 'change', checked: true },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(
|
||||||
|
wrapper.find('Checkbox[aria-label="Enable Webhook"]').prop('isChecked')
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
wrapper.find('input[aria-label="wfjt-webhook-key"]').prop('readOnly')
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
wrapper.find('input[aria-label="wfjt-webhook-key"]').prop('value')
|
||||||
|
).toBe('sdfghjklmnbvcdsew435678iokjhgfd');
|
||||||
|
await act(() =>
|
||||||
|
wrapper
|
||||||
|
.find('FormGroup[name="webhook_key"]')
|
||||||
|
.find('Button[variant="tertiary"]')
|
||||||
|
.prop('onClick')()
|
||||||
|
);
|
||||||
|
expect(WorkflowJobTemplatesAPI.updateWebhookKey).toBeCalledWith('6');
|
||||||
|
expect(
|
||||||
|
wrapper.find('TextInputBase[aria-label="Webhook URL"]').prop('value')
|
||||||
|
).toContain('/api/v2/workflow_job_templates/57/gitlab/');
|
||||||
|
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
|
expect(wrapper.find('Field[name="webhook_service"]').length).toBe(1);
|
||||||
|
|
||||||
|
act(() => wrapper.find('AnsibleSelect').prop('onChange')({}, 'gitlab'));
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
|
expect(wrapper.find('AnsibleSelect').prop('value')).toBe('gitlab');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleSubmit is called on submit button click', async () => {
|
||||||
|
act(() => {
|
||||||
|
wrapper.find('Formik').prop('onSubmit')({});
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
sleep(0);
|
||||||
|
expect(handleSubmit).toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleCancel is called on cancel button click', async () => {
|
||||||
|
act(() => {
|
||||||
|
wrapper.find('button[aria-label="Cancel"]').simulate('click');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handleCancel).toBeCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1 +1,2 @@
|
|||||||
export { default } from './JobTemplateForm';
|
export { default as JobTemplateForm } from './JobTemplateForm';
|
||||||
|
export { default as WorkflowJobTemplateForm } from './WorkflowJobTemplateForm';
|
||||||
|
|||||||
Reference in New Issue
Block a user