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:
softwarefactory-project-zuul[bot] 2020-03-04 16:24:49 +00:00 committed by GitHub
commit 6c22ddf608
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1506 additions and 222 deletions

View File

@ -1,10 +1,9 @@
const InstanceGroupsMixin = parent =>
class extends parent {
readInstanceGroups(resourceId, params) {
return this.http.get(
`${this.baseUrl}${resourceId}/instance_groups/`,
params
);
return this.http.get(`${this.baseUrl}${resourceId}/instance_groups/`, {
params,
});
}
associateInstanceGroup(resourceId, instanceGroupId) {

View File

@ -6,6 +6,32 @@ class WorkflowJobTemplates extends Base {
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) {
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) {
return this.http.get(`${this.baseUrl}${id}/schedules/`, { params });
return this.http.get(`${this.baseUrl}${id}/schedules/`, {
params,
});
}
}

View File

@ -5,7 +5,7 @@ import { t } from '@lingui/macro';
import { useField } from 'formik';
import styled from 'styled-components';
import { Split, SplitItem } from '@patternfly/react-core';
import { CheckboxField } from '@components/FormField';
import { CheckboxField, FieldTooltip } from '@components/FormField';
import MultiButtonToggle from '@components/MultiButtonToggle';
import { yamlToJson, jsonToYaml, isJson } from '@util/yaml';
import CodeMirrorInput from './CodeMirrorInput';
@ -20,7 +20,15 @@ const StyledCheckboxField = styled(CheckboxField)`
--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 [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">
<span className="pf-c-form__label-text">{label}</span>
</label>
{tooltip && <FieldTooltip content={tooltip} />}
</SplitItem>
<SplitItem>
<MultiButtonToggle

View File

@ -69,6 +69,22 @@ describe('VariablesField', () => {
expect(field.prop('hasErrors')).toEqual(true);
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 () => {
const value = '---\nfoo: bar\n';

View File

@ -6,6 +6,7 @@ import { t } from '@lingui/macro';
import { CredentialsAPI } from '@api';
import { Credential } from '@types';
import { getQSConfig, parseQueryString, mergeParams } from '@util/qs';
import { FieldTooltip } from '@components/FormField';
import { FormGroup } from '@patternfly/react-core';
import Lookup from '@components/Lookup';
import OptionsList from './shared/OptionsList';
@ -28,6 +29,7 @@ function CredentialLookup({
value,
history,
i18n,
tooltip,
}) {
const [credentials, setCredentials] = useState([]);
const [count, setCount] = useState(0);
@ -60,6 +62,7 @@ function CredentialLookup({
label={label}
helperTextInvalid={helperTextInvalid}
>
{tooltip && <FieldTooltip content={tooltip} />}
<Lookup
id="credential"
header={label}
@ -97,6 +100,7 @@ function CredentialLookup({
},
]}
readOnly={!canDelete}
name="credential"
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
/>

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react';
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 JobTemplateForm from '../shared/JobTemplateForm';
import { JobTemplatesAPI } from '@api';
@ -61,15 +61,17 @@ function JobTemplateAdd() {
}
return (
<Card>
<CardBody>
<JobTemplateForm
handleCancel={handleCancel}
handleSubmit={handleSubmit}
submitError={formSubmitError}
/>
</CardBody>
</Card>
<PageSection>
<Card>
<CardBody>
<JobTemplateForm
handleCancel={handleCancel}
handleSubmit={handleSubmit}
submitError={formSubmitError}
/>
</CardBody>
</Card>
</PageSection>
);
}

View File

@ -164,87 +164,91 @@ class Template extends Component {
}
return (
<Card>
{cardHeader}
<Switch>
<Redirect
from="/templates/:templateType/:id"
to="/templates/:templateType/:id/details"
exact
/>
{template && (
<Route
key="details"
path="/templates/:templateType/:id/details"
render={() => (
<JobTemplateDetail
hasTemplateLoading={hasContentLoading}
template={template}
/>
)}
<PageSection>
<Card>
{cardHeader}
<Switch>
<Redirect
from="/templates/:templateType/:id"
to="/templates/:templateType/:id/details"
exact
/>
)}
{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
key="edit"
path="/templates/:templateType/:id/edit"
render={() => <JobTemplateEdit template={template} />}
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>
)
}
/>
)}
{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
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>
</Switch>
</Card>
</PageSection>
);
}
}

View File

@ -2,7 +2,7 @@ import React, { useEffect, useState, useCallback } from 'react';
import { useParams, useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Card } from '@patternfly/react-core';
import { Card, PageSection } from '@patternfly/react-core';
import {
JobTemplatesAPI,
@ -141,7 +141,7 @@ function TemplateList({ i18n }) {
);
return (
<>
<PageSection>
<Card>
<PaginatedDataList
contentError={contentError}
@ -245,7 +245,7 @@ function TemplateList({ i18n }) {
{i18n._(t`Failed to delete one or more templates.`)}
<ErrorDetail error={deletionError} />
</AlertModal>
</>
</PageSection>
);
}

View File

@ -2,7 +2,6 @@ import React, { Component } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Route, withRouter, Switch } from 'react-router-dom';
import { PageSection } from '@patternfly/react-core';
import { Config } from '@contexts/Config';
import Breadcrumbs from '@components/Breadcrumbs/Breadcrumbs';
@ -10,6 +9,7 @@ import { TemplateList } from './TemplateList';
import Template from './Template';
import WorkflowJobTemplate from './WorkflowJobTemplate';
import JobTemplateAdd from './JobTemplateAdd';
import WorkflowJobTemplateAdd from './WorkflowJobTemplateAdd';
class Templates extends Component {
constructor(props) {
@ -20,6 +20,9 @@ class Templates extends Component {
breadcrumbConfig: {
'/templates': i18n._(t`Templates`),
'/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 = {
'/templates': i18n._(t`Templates`),
'/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}/details`]: i18n._(
t`Details`
@ -56,47 +62,48 @@ class Templates extends Component {
return (
<>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
<PageSection>
<Switch>
<Route
path={`${match.path}/job_template/add`}
render={() => <JobTemplateAdd />}
/>
<Route
path={`${match.path}/job_template/:id`}
render={({ match: newRouteMatch }) => (
<Config>
{({ me }) => (
<Template
history={history}
location={location}
setBreadcrumb={this.setBreadCrumbConfig}
me={me || {}}
match={newRouteMatch}
/>
)}
</Config>
)}
/>
<Route
path={`${match.path}/workflow_job_template/:id`}
render={({ match: newRouteMatch }) => (
<Config>
{({ me }) => (
<WorkflowJobTemplate
history={history}
location={location}
setBreadcrumb={this.setBreadCrumbConfig}
me={me || {}}
match={newRouteMatch}
/>
)}
</Config>
)}
/>
<Route path={`${match.path}`} render={() => <TemplateList />} />
</Switch>
</PageSection>
<Switch>
<Route
path={`${match.path}/job_template/add`}
render={() => <JobTemplateAdd />}
/>
<Route
path={`${match.path}/workflow_job_template/add`}
render={() => <WorkflowJobTemplateAdd />}
/>
<Route
path={`${match.path}/job_template/:id`}
render={({ match: newRouteMatch }) => (
<Config>
{({ me }) => (
<Template
history={history}
location={location}
setBreadcrumb={this.setBreadCrumbConfig}
me={me || {}}
match={newRouteMatch}
/>
)}
</Config>
)}
/>
<Route
path={`${match.path}/workflow_job_template/:id`}
render={({ match: newRouteMatch }) => (
<Config>
{({ me }) => (
<WorkflowJobTemplate
location={location}
setBreadcrumb={this.setBreadCrumbConfig}
me={me || {}}
match={newRouteMatch}
/>
)}
</Config>
)}
/>
<Route path={`${match.path}`} render={() => <TemplateList />} />
</Switch>
</>
);
}

View File

@ -11,8 +11,10 @@ import FullPage from '@components/FullPage';
import JobList from '@components/JobList';
import RoutedTabs from '@components/RoutedTabs';
import ScheduleList from '@components/ScheduleList';
import ContentLoading from '@components/ContentLoading';
import { WorkflowJobTemplatesAPI, CredentialsAPI } from '@api';
import WorkflowJobTemplateDetail from './WorkflowJobTemplateDetail';
import WorkflowJobTemplateEdit from './WorkflowJobTemplateEdit';
import { Visualizer } from './WorkflowJobTemplateVisualizer';
class WorkflowJobTemplate extends Component {
@ -23,6 +25,7 @@ class WorkflowJobTemplate extends Component {
contentError: null,
hasContentLoading: true,
template: null,
webhook_key: null,
};
this.loadTemplate = this.loadTemplate.bind(this);
this.loadSchedules = this.loadSchedules.bind(this);
@ -50,19 +53,20 @@ class WorkflowJobTemplate extends Component {
const {
data: { webhook_key },
} = await WorkflowJobTemplatesAPI.readWebhookKey(id);
this.setState({ webHookKey: webhook_key });
this.setState({ webhook_key });
}
if (data?.summary_fields?.webhook_credential) {
const {
data: {
summary_fields: { credential_type: name },
summary_fields: {
credential_type: { name },
},
},
} = await CredentialsAPI.readDetail(
data.summary_fields.webhook_credential.id
);
data.summary_fields.webhook_credential.kind = name;
}
this.setState({ template: data });
setBreadcrumb(data);
} catch (err) {
@ -83,7 +87,7 @@ class WorkflowJobTemplate extends Component {
contentError,
hasContentLoading,
template,
webHookKey,
webhook_key,
} = this.state;
const tabsArray = [
@ -115,7 +119,9 @@ class WorkflowJobTemplate extends Component {
if (location.pathname.endsWith('edit')) {
cardHeader = null;
}
if (hasContentLoading) {
return <ContentLoading />;
}
if (!hasContentLoading && contentError) {
return (
<PageSection>
@ -134,73 +140,89 @@ class WorkflowJobTemplate extends Component {
}
return (
<Card>
{cardHeader}
<Switch>
<Redirect
from="/templates/workflow_job_template/:id"
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}
/>
)}
<PageSection>
<Card>
{cardHeader}
<Switch>
<Redirect
from="/templates/workflow_job_template/:id"
to="/templates/workflow_job_template/:id/details"
exact
/>
)}
{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,
}}
{template && (
<Route
key="wfjt-details"
path="/templates/workflow_job_template/:id/details"
render={() => (
<WorkflowJobTemplateDetail
template={template}
webhook_key={webhook_key}
/>
)}
/>
</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
path="/templates/:templateType/:id/schedules"
render={() => <ScheduleList loadSchedules={this.loadSchedules} />}
key="not-found"
path="*"
render={() =>
!hasContentLoading && (
<ContentError isNotFound>
{match.params.id && (
<Link
to={`/templates/workflow_job_template/${match.params.id}/details`}
>
{i18n._(`View Template Details`)}
</Link>
)}
</ContentError>
)
}
/>
)}
<Route
key="not-found"
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>
</Switch>
</Card>
</PageSection>
);
}
}

View File

@ -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;

View File

@ -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'
);
});
});

View File

@ -0,0 +1 @@
export { default } from './WorkflowJobTemplateAdd';

View File

@ -25,7 +25,7 @@ import LaunchButton from '@components/LaunchButton';
import Sparkline from '@components/Sparkline';
import { toTitleCase } from '@util/strings';
function WorkflowJobTemplateDetail({ template, i18n, webHookKey }) {
function WorkflowJobTemplateDetail({ template, i18n, webhook_key }) {
const {
id,
ask_inventory_on_launch,
@ -39,12 +39,15 @@ function WorkflowJobTemplateDetail({ template, i18n, webHookKey }) {
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;
template.allow_simultaneous || template.webhook_service;
const renderOptions = (
<TextList component={TextListVariants.ul}>
@ -55,7 +58,7 @@ function WorkflowJobTemplateDetail({ template, i18n, webHookKey }) {
)}
{template.webhook_service && (
<TextListItem component={TextListItemVariants.li}>
{i18n._(t`- Webhooks`)}
{i18n._(t`- Enable Webhook`)}
</TextListItem>
)}
</TextList>
@ -75,6 +78,7 @@ function WorkflowJobTemplateDetail({ template, i18n, webHookKey }) {
}
setHasContentLoading(false);
};
const inventoryValue = (kind, inventoryId) => {
const inventorykind = kind === 'smart' ? 'smart_inventory' : 'inventory';
@ -91,6 +95,7 @@ function WorkflowJobTemplateDetail({ template, i18n, webHookKey }) {
</Link>
);
};
const canLaunch = summary_fields?.user_capabilities?.start;
const recentPlaybookJobs = summary_fields.recent_jobs.map(job => ({
...job,
@ -143,7 +148,7 @@ function WorkflowJobTemplateDetail({ template, i18n, webHookKey }) {
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 && (
<Detail
fullWidth

View File

@ -40,6 +40,7 @@ describe('<WorkflowJobTemplateDetail/>', () => {
},
webhook_service: 'Github',
};
beforeEach(async () => {
history = createMemoryHistory({
initialEntries: ['/templates/workflow_job_template/1/details'],
@ -51,7 +52,7 @@ describe('<WorkflowJobTemplateDetail/>', () => {
component={() => (
<WorkflowJobTemplateDetail
template={template}
webHookKey="Foo webhook key"
webhook_key="Foo webhook key"
hasContentLoading={false}
onSetContentLoading={() => {}}
/>
@ -75,12 +76,15 @@ describe('<WorkflowJobTemplateDetail/>', () => {
);
});
});
afterEach(() => {
wrapper.unmount();
});
test('renders successfully', () => {
expect(wrapper.find(WorkflowJobTemplateDetail).length).toBe(1);
});
test('expect detail fields to render properly', () => {
const renderedValues = [
{
@ -147,6 +151,7 @@ describe('<WorkflowJobTemplateDetail/>', () => {
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

View File

@ -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;

View File

@ -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'
);
});
});

View File

@ -0,0 +1 @@
export { default } from './WorkflowJobTemplateEdit';

View File

@ -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`)}
&nbsp;
<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);

View File

@ -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();
});
});

View File

@ -1 +1,2 @@
export { default } from './JobTemplateForm';
export { default as JobTemplateForm } from './JobTemplateForm';
export { default as WorkflowJobTemplateForm } from './WorkflowJobTemplateForm';