Merge pull request #5979 from AlexSCorey/5814-WFJTDetailsView

Adds WFJT Details view

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2020-02-19 16:22:51 +00:00 committed by GitHub
commit 09d6da117a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 600 additions and 40 deletions

View File

@ -6,8 +6,22 @@ class WorkflowJobTemplates extends Base {
this.baseUrl = '/api/v2/workflow_job_templates/';
}
launch(id, data) {
return this.http.post(`${this.baseUrl}${id}/launch/`, data);
}
readLaunch(id) {
return this.http.get(`${this.baseUrl}${id}/launch/`);
}
readNodes(id, params) {
return this.http.get(`${this.baseUrl}${id}/workflow_nodes/`, { params });
return this.http.get(`${this.baseUrl}${id}/workflow_nodes/`, {
params,
});
}
readWebhookKey(id) {
return this.http.get(`${this.baseUrl}${id}/webhook_key/`);
}
createNode(id, data) {

View File

@ -11,7 +11,7 @@ const DetailList = ({ children, stacked, ...props }) => (
export default styled(DetailList)`
display: grid;
grid-gap: 20px;
align-items: flex-start;
align-items: center;
${props =>
props.stacked
? `

View File

@ -13,6 +13,7 @@ import {
JobTemplatesAPI,
ProjectsAPI,
WorkflowJobsAPI,
WorkflowJobTemplatesAPI,
} from '@api';
class LaunchButton extends React.Component {
@ -46,13 +47,24 @@ class LaunchButton extends React.Component {
async handleLaunch() {
const { history, resource } = this.props;
const readLaunch =
resource.type === 'workflow_job_template'
? WorkflowJobTemplatesAPI.readLaunch(resource.id)
: JobTemplatesAPI.readLaunch(resource.id);
const launchJob =
resource.type === 'workflow_job_template'
? WorkflowJobTemplatesAPI.launch(resource.id)
: JobTemplatesAPI.launch(resource.id);
try {
const { data: launchConfig } = await JobTemplatesAPI.readLaunch(
resource.id
);
const { data: launchConfig } = await readLaunch;
if (launchConfig.can_start_without_user_input) {
const { data: job } = await JobTemplatesAPI.launch(resource.id);
history.push(`/jobs/${job.id}`);
const { data: job } = await launchJob;
history.push(
`/${
resource.type === 'workflow_job_template' ? 'jobs/workflow' : 'jobs'
}/${job.id}/output`
);
} else {
this.setState({ promptError: true });
}

View File

@ -4,9 +4,10 @@ import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { sleep } from '@testUtils/testUtils';
import LaunchButton from './LaunchButton';
import { JobTemplatesAPI } from '@api';
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '@api';
jest.mock('@api');
jest.mock('@api/models/WorkflowJobTemplates');
jest.mock('@api/models/JobTemplates');
describe('LaunchButton', () => {
JobTemplatesAPI.readLaunch.mockResolvedValue({
@ -23,7 +24,7 @@ describe('LaunchButton', () => {
id: 1,
type: 'job_template',
};
afterEach(() => jest.clearAllMocks());
test('renders the expected content', () => {
const wrapper = mountWithContexts(
<LaunchButton resource={resource}>{children}</LaunchButton>
@ -35,6 +36,7 @@ describe('LaunchButton', () => {
const history = createMemoryHistory({
initialEntries: ['/jobs/9000'],
});
JobTemplatesAPI.launch.mockResolvedValue({
data: {
id: 9000,
@ -53,10 +55,48 @@ describe('LaunchButton', () => {
expect(JobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1);
await sleep(0);
expect(JobTemplatesAPI.launch).toHaveBeenCalledWith(1);
expect(history.location.pathname).toEqual('/jobs/9000/output');
});
test('should launch the correct job type', async () => {
WorkflowJobTemplatesAPI.readLaunch.mockResolvedValue({
data: {
can_start_without_user_input: true,
},
});
const history = createMemoryHistory({
initialEntries: ['/jobs/9000'],
});
JobTemplatesAPI.launch.mockResolvedValue({
data: {
id: 9000,
},
});
const wrapper = mountWithContexts(
<LaunchButton
resource={{
id: 1,
type: 'workflow_job_template',
}}
>
{children}
</LaunchButton>,
{
context: {
router: { history },
},
}
);
const button = wrapper.find('button');
button.prop('onClick')();
expect(WorkflowJobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1);
await sleep(0);
expect(WorkflowJobTemplatesAPI.launch).toHaveBeenCalledWith(1);
expect(history.location.pathname).toEqual('/jobs/9000');
});
test('displays error modal after unsuccessful launch', async () => {
const wrapper = mountWithContexts(
<LaunchButton resource={resource}>{children}</LaunchButton>
);
JobTemplatesAPI.launch.mockRejectedValue(
new Error({
response: {
@ -69,9 +109,6 @@ describe('LaunchButton', () => {
},
})
);
const wrapper = mountWithContexts(
<LaunchButton resource={resource}>{children}</LaunchButton>
);
expect(wrapper.find('Modal').length).toBe(0);
wrapper.find('button').prop('onClick')();
await sleep(0);

View File

@ -374,9 +374,9 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
"componentStyle": ComponentStyle {
"componentId": "DetailList-sc-12g7m4-0",
"isStatic": false,
"lastClassName": "eYaZBv",
"lastClassName": "iAtits",
"rules": Array [
"display:grid;grid-gap:20px;align-items:flex-start;",
"display:grid;grid-gap:20px;align-items:center;",
[Function],
],
},
@ -394,15 +394,15 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
stacked={true}
>
<DetailList
className="DetailList-sc-12g7m4-0 eYaZBv"
className="DetailList-sc-12g7m4-0 iAtits"
stacked={true}
>
<TextList
className="DetailList-sc-12g7m4-0 eYaZBv"
className="DetailList-sc-12g7m4-0 iAtits"
component="dl"
>
<dl
className="DetailList-sc-12g7m4-0 eYaZBv"
className="DetailList-sc-12g7m4-0 iAtits"
data-pf-content={true}
>
<Detail
@ -548,9 +548,9 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
"componentStyle": ComponentStyle {
"componentId": "DetailList-sc-12g7m4-0",
"isStatic": false,
"lastClassName": "eYaZBv",
"lastClassName": "iAtits",
"rules": Array [
"display:grid;grid-gap:20px;align-items:flex-start;",
"display:grid;grid-gap:20px;align-items:center;",
[Function],
],
},
@ -568,15 +568,15 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
stacked={true}
>
<DetailList
className="DetailList-sc-12g7m4-0 eYaZBv"
className="DetailList-sc-12g7m4-0 iAtits"
stacked={true}
>
<TextList
className="DetailList-sc-12g7m4-0 eYaZBv"
className="DetailList-sc-12g7m4-0 iAtits"
component="dl"
>
<dl
className="DetailList-sc-12g7m4-0 eYaZBv"
className="DetailList-sc-12g7m4-0 iAtits"
data-pf-content={true}
>
<Detail

View File

@ -9,7 +9,7 @@ import CardCloseButton from '@components/CardCloseButton';
import ContentError from '@components/ContentError';
import FullPage from '@components/FullPage';
import RoutedTabs from '@components/RoutedTabs';
import { WorkflowJobTemplatesAPI } from '@api';
import { WorkflowJobTemplatesAPI, CredentialsAPI } from '@api';
import WorkflowJobTemplateDetail from './WorkflowJobTemplateDetail';
import { Visualizer } from './WorkflowJobTemplateVisualizer';
@ -43,8 +43,25 @@ class WorkflowJobTemplate extends Component {
this.setState({ contentError: null, hasContentLoading: true });
try {
const { data } = await WorkflowJobTemplatesAPI.readDetail(id);
setBreadcrumb(data);
if (data?.related?.webhook_key) {
const {
data: { webhook_key },
} = await WorkflowJobTemplatesAPI.readWebhookKey(id);
this.setState({ webHookKey: webhook_key });
}
if (data?.summary_fields?.webhook_credential) {
const {
data: {
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) {
this.setState({ contentError: err });
} finally {
@ -54,7 +71,12 @@ class WorkflowJobTemplate extends Component {
render() {
const { i18n, location, match } = this.props;
const { contentError, hasContentLoading, template } = this.state;
const {
contentError,
hasContentLoading,
template,
webHookKey,
} = this.state;
const tabsArray = [
{ name: i18n._(t`Details`), link: `${match.url}/details` },
@ -108,8 +130,8 @@ class WorkflowJobTemplate extends Component {
path="/templates/workflow_job_template/:id/details"
render={() => (
<WorkflowJobTemplateDetail
hasTemplateLoading={hasContentLoading}
template={template}
webHookKey={webHookKey}
/>
)}
/>

View File

@ -0,0 +1,96 @@
import React from 'react';
import { Route } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import WorkflowJobTemplate from './WorkflowJobTemplate';
import { sleep } from '@testUtils/testUtils';
import { WorkflowJobTemplatesAPI, CredentialsAPI } from '@api';
jest.mock('@api/models/WorkflowJobTemplates');
jest.mock('@api/models/Credentials');
describe('<WorkflowJobTemplate/>', () => {
const mockMe = {
is_super_user: true,
is_system_auditor: false,
};
beforeAll(() => {
WorkflowJobTemplatesAPI.readDetail.mockResolvedValue({
data: {
id: 1,
name: 'Foo',
description: 'Bar',
created: '2015-07-07T17:21:26.429745Z',
modified: '2019-08-11T19:47:37.980466Z',
extra_vars: '',
summary_fields: {
webhook_credential: { id: 1234567, name: 'Foo Webhook Credential' },
created_by: { id: 1, username: 'Athena' },
modified_by: { id: 1, username: 'Apollo' },
recent_jobs: [
{ id: 1, status: 'run' },
{ id: 2, status: 'run' },
{ id: 3, status: 'run' },
],
labels: {
results: [
{ name: 'Label 1', id: 1 },
{ name: 'Label 2', id: 2 },
{ name: 'Label 3', id: 3 },
],
},
},
related: {
webhook_key: '/api/v2/workflow_job_templates/57/webhook_key/',
},
},
});
WorkflowJobTemplatesAPI.readWebhookKey.mockResolvedValue({
data: { webhook_key: 'WebHook Key' },
});
CredentialsAPI.readDetail.mockResolvedValue({
data: {
summary_fields: {
credential_type: { name: 'Github Personal Access Token', id: 1 },
},
},
});
});
test('calls api to get workflow job template data', async () => {
const history = createMemoryHistory({
initialEntries: ['/templates/workflow_job_template/1'],
});
let wrapper;
act(() => {
wrapper = mountWithContexts(
<Route
path="/templates/workflow_job_template/:id"
component={() => (
<WorkflowJobTemplate setBreadcrumb={() => {}} me={mockMe} />
)}
/>,
{
context: {
router: {
history,
route: {
location: history.location,
match: {
params: { id: 1 },
},
},
},
},
}
);
});
expect(wrapper.find('WorkflowJobTemplate').length).toBe(1);
expect(WorkflowJobTemplatesAPI.readDetail).toBeCalledWith('1');
wrapper.update();
await sleep(0);
expect(WorkflowJobTemplatesAPI.readWebhookKey).toBeCalledWith('1');
expect(CredentialsAPI.readDetail).toBeCalledWith(1234567);
});
});

View File

@ -1,17 +1,238 @@
import React, { Component } from 'react';
import { withRouter } from 'react-router-dom';
import React, { useState } from 'react';
import { Link, useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { CardBody } from '@components/Card';
import { DetailList } from '@components/DetailList';
import { t } from '@lingui/macro';
import {
Chip,
ChipGroup,
Button,
TextList,
TextListItem,
TextListVariants,
TextListItemVariants,
Label,
} from '@patternfly/react-core';
class WorkflowJobTemplateDetail extends Component {
render() {
return (
<CardBody>
<DetailList gutter="sm" />
</CardBody>
);
import { CardBody, CardActionsRow } from '@components/Card';
import ContentLoading from '@components/ContentLoading';
import { WorkflowJobTemplatesAPI } from '@api';
import AlertModal from '@components/AlertModal';
import ErrorDetail from '@components/ErrorDetail';
import { DetailList, Detail, UserDateDetail } from '@components/DetailList';
import { VariablesDetail } from '@components/CodeMirrorInput';
import LaunchButton from '@components/LaunchButton';
import DeleteButton from '@components/DeleteButton';
import { toTitleCase } from '@util/strings';
import { Sparkline } from '@components/Sparkline';
function WorkflowJobTemplateDetail({ template, i18n, webHookKey }) {
const {
id,
ask_inventory_on_launch,
name,
description,
type,
extra_vars,
created,
modified,
summary_fields,
related,
webhook_credential,
} = template;
const urlOrigin = window.location.origin;
const history = useHistory();
const [deletionError, setDeletionError] = useState(null);
const [hasContentLoading, setHasContentLoading] = useState(false);
const renderOptionsField =
template.allow_simultaneous || template.webhook_servicee;
const renderOptions = (
<TextList component={TextListVariants.ul}>
{template.allow_simultaneous && (
<TextListItem component={TextListItemVariants.li}>
{i18n._(t`- Enable Concurrent Jobs`)}
</TextListItem>
)}
{template.webhook_service && (
<TextListItem component={TextListItemVariants.li}>
{i18n._(t`- Webhooks`)}
</TextListItem>
)}
</TextList>
);
if (hasContentLoading) {
return <ContentLoading />;
}
const handleDelete = async () => {
setHasContentLoading(true);
try {
await WorkflowJobTemplatesAPI.destroy(id);
history.push(`/templates`);
} catch (error) {
setDeletionError(error);
}
setHasContentLoading(false);
};
const inventoryValue = (kind, inventoryId) => {
const inventorykind = kind === 'smart' ? 'smart_inventory' : 'inventory';
return ask_inventory_on_launch ? (
<>
<Link to={`/inventories/${inventorykind}/${inventoryId}/details`}>
<Label>{summary_fields.inventory.name}</Label>
</Link>
<span> {i18n._(t`(Prompt on Launch)`)}</span>
</>
) : (
<Link to={`/inventories/${inventorykind}/${inventoryId}/details`}>
<Label>{summary_fields.inventory.name}</Label>
</Link>
);
};
const canLaunch = summary_fields?.user_capabilities?.start;
const recentPlaybookJobs = summary_fields.recent_jobs.map(job => ({
...job,
type: 'job',
}));
return (
<CardBody>
<DetailList gutter="sm">
<Detail label={i18n._(t`Name`)} value={name} dataCy="jt-detail-name" />
<Detail label={i18n._(t`Description`)} value={description} />
{summary_fields.recent_jobs?.length > 0 && (
<Detail
css="display: flex; flex: 1;"
value={<Sparkline jobs={recentPlaybookJobs} />}
label={i18n._(t`Activity`)}
/>
)}
{summary_fields.organization && (
<Detail
label={i18n._(t`Organization`)}
value={
<Link
to={`/organizations/${summary_fields.organization.id}/details`}
>
<Label>{summary_fields.organization.name}</Label>
</Link>
}
/>
)}
<Detail label={i18n._(t`Job Type`)} value={toTitleCase(type)} />
{summary_fields.inventory && (
<Detail
label={i18n._(t`Inventory`)}
value={inventoryValue(
summary_fields.inventory.kind,
summary_fields.inventory.id
)}
/>
)}
{renderOptionsField && (
<Detail label={i18n._(t`Options`)} value={renderOptions} />
)}
<Detail
label={i18n._(t`Webhook Service`)}
value={toTitleCase(template.webhook_service)}
/>
{related.webhook_receiver && (
<Detail
label={i18n._(t`Webhook URL`)}
value={`${urlOrigin}${template.related.webhook_receiver}`}
/>
)}
<Detail label={i18n._(t`Webhook Key`)} value={webHookKey} />
{webhook_credential && (
<Detail
fullWidth
label={i18n._(t`Webhook Credentials`)}
value={
<Link
to={`/credentials/${summary_fields.webhook_credential.id}/details`}
>
<Label>{summary_fields.webhook_credential.name}</Label>
</Link>
}
/>
)}
{summary_fields.labels?.results?.length > 0 && (
<Detail
fullWidth
label={i18n._(t`Labels`)}
value={
<ChipGroup>
{summary_fields.labels.results.map(l => (
<Chip key={l.id} isReadOnly>
{l.name}
</Chip>
))}
</ChipGroup>
}
/>
)}
<VariablesDetail
label={i18n._(t`Variables`)}
value={extra_vars}
rows={4}
/>
<UserDateDetail
label={i18n._(t`Created`)}
date={created}
user={summary_fields.created_by}
/>
<UserDateDetail
label={i18n._(t`Modified`)}
date={modified}
user={summary_fields.modified_by}
/>
</DetailList>
<CardActionsRow>
{summary_fields.user_capabilities &&
summary_fields.user_capabilities.edit && (
<Button
component={Link}
to={`/templates/workflow_job_template/${id}/edit`}
aria-label={i18n._(t`Edit`)}
>
{i18n._(t`Edit`)}
</Button>
)}
{canLaunch && (
<LaunchButton resource={template} aria-label={i18n._(t`Launch`)}>
{({ handleLaunch }) => (
<Button variant="secondary" type="submit" onClick={handleLaunch}>
{i18n._(t`Launch`)}
</Button>
)}
</LaunchButton>
)}
{summary_fields.user_capabilities &&
summary_fields.user_capabilities.delete && (
<DeleteButton
name={name}
modalTitle={i18n._(t`Delete Workflow Job Template`)}
onConfirm={handleDelete}
>
{i18n._(t`Delete`)}
</DeleteButton>
)}
</CardActionsRow>
{deletionError && (
<AlertModal
isOpen={deletionError}
variant="danger"
title={i18n._(t`Error!`)}
onClose={() => setDeletionError(null)}
>
{i18n._(t`Failed to delete workflow job template.`)}
<ErrorDetail error={deletionError} />
</AlertModal>
)}
</CardBody>
);
}
export { WorkflowJobTemplateDetail as _WorkflowJobTemplateDetail };
export default withI18n()(withRouter(WorkflowJobTemplateDetail));
export default withI18n()(WorkflowJobTemplateDetail);

View File

@ -0,0 +1,158 @@
import React from 'react';
import { Route } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import WorkflowJobTemplateDetail from './WorkflowJobTemplateDetail';
describe('<WorkflowJobTemplateDetail/>', () => {
let wrapper;
let history;
const template = {
id: 1,
name: 'WFJT Template',
description: 'It is a wfjt template, yo!',
type: 'workflow_job_template',
extra_vars: '1: 2',
created: '2015-07-07T17:21:26.429745Z',
modified: '2019-08-11T19:47:37.980466Z',
related: { webhook_receiver: '/api/v2/workflow_job_templates/45/github/' },
summary_fields: {
created_by: { id: 1, username: 'Athena' },
modified_by: { id: 1, username: 'Apollo' },
organization: { id: 1, name: 'Org' },
inventory: { kind: 'Foo', id: 1, name: 'Bar' },
labels: {
results: [
{ name: 'Label 1', id: 1 },
{ name: 'Label 2', id: 2 },
{ name: 'Label 3', id: 3 },
],
},
recent_jobs: [
{ id: 1, status: 'run' },
{ id: 2, status: 'run' },
{ id: 3, status: 'run' },
],
webhook_credential: { id: '1', name: 'Credentaial', kind: 'machine' },
user_capabilities: { edit: true, delete: true },
},
webhook_service: 'Github',
};
beforeEach(async () => {
history = createMemoryHistory({
initialEntries: ['/templates/workflow_job_template/1/details'],
});
await act(async () => {
wrapper = await mountWithContexts(
<Route
path="/templates/workflow_job_template/:id/details"
component={() => (
<WorkflowJobTemplateDetail
template={template}
webHookKey="Foo webhook key"
hasContentLoading={false}
onSetContentLoading={() => {}}
/>
)}
/>,
{
context: {
router: {
history,
route: {
location: history.location,
match: {
params: { id: 1 },
path: '/templates/workflow_job_template/1/details',
url: '/templates/workflow_job_template/1/details',
},
},
},
},
}
);
});
});
afterEach(() => {
wrapper.unmount();
});
test('renders successfully', () => {
expect(wrapper.find(WorkflowJobTemplateDetail).length).toBe(1);
});
test('expect detail fields to render properly', () => {
const renderedValues = [
{
element: 'UserDateDetail[label="Created"]',
prop: 'date',
value: '2015-07-07T17:21:26.429745Z',
},
{
element: 'UserDateDetail[label="Modified"]',
prop: 'date',
value: '2019-08-11T19:47:37.980466Z',
},
{
element: 'Detail[label="Webhook URL"]',
prop: 'value',
value: 'http://127.0.0.1:3001/api/v2/workflow_job_templates/45/github/',
},
{
element: "Detail[label='Webhook Service']",
prop: 'value',
value: 'Github',
},
{
element: 'Detail[label="Webhook Key"]',
prop: 'value',
value: 'Foo webhook key',
},
{
element: 'Detail[label="Name"]',
value: 'WFJT Template',
prop: 'value',
},
{
element: 'Detail[label="Description"]',
prop: 'value',
value: 'It is a wfjt template, yo!',
},
{
element: 'Detail[label="Job Type"]',
prop: 'value',
value: 'Workflow Job Template',
},
];
const organization = wrapper
.find('Detail[label="Organization"]')
.find('span');
const inventory = wrapper.find('Detail[label="Inventory"]').find('a');
const labels = wrapper
.find('Detail[label="Labels"]')
.find('Chip[component="li"]');
const sparkline = wrapper.find('Sparkline__Link');
expect(organization.text()).toBe('Org');
expect(inventory.text()).toEqual('Bar');
expect(labels.length).toBe(3);
expect(sparkline.length).toBe(3);
const assertValue = value => {
expect(wrapper.find(`${value.element}`).prop(`${value.prop}`)).toEqual(
`${value.value}`
);
};
renderedValues.map(value => assertValue(value));
});
test('link out resource have the correct url', () => {
const inventory = wrapper.find('Detail[label="Inventory"]').find('Link');
const organization = wrapper
.find('Detail[label="Organization"]')
.find('Link');
expect(inventory.prop('to')).toEqual('/inventories/inventory/1/details');
expect(organization.prop('to')).toEqual('/organizations/1/details');
});
});