Halfway implemented node details. Still need to handle cases where the user has edited the node and cases where the node is brand new.

This commit is contained in:
mabashian 2020-01-16 15:37:35 -05:00
parent de55af6ae6
commit 50ba4f9759
34 changed files with 1822 additions and 930 deletions

View File

@ -51,6 +51,10 @@ class WorkflowJobTemplateNodes extends Base {
disassociate: true,
});
}
readCredentials(id) {
return this.http.get(`${this.baseUrl}${id}/credentials/`);
}
}
export default WorkflowJobTemplateNodes;

View File

@ -2,10 +2,10 @@ import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Wizard } from '@patternfly/react-core';
import SelectResourceStep from './SelectResourceStep';
import SelectRoleStep from './SelectRoleStep';
import { SelectableCard } from '@components/SelectableCard';
import { Wizard } from '@components/Wizard';
import { TeamsAPI, UsersAPI } from '../../api';
const readUsers = async queryParams =>

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { string, number } from 'prop-types';
import { string, node, number } from 'prop-types';
import { Split, SplitItem, TextListItemVariants } from '@patternfly/react-core';
import { DetailName, DetailValue } from '@components/DetailList';
import { yamlToJson, jsonToYaml, isJson } from '@util/yaml';
@ -21,7 +21,7 @@ function getValueAsMode(value, mode) {
return mode === YAML_MODE ? jsonToYaml(value) : yamlToJson(value);
}
function VariablesDetail({ value, label, rows }) {
function VariablesDetail({ value = '---', label, rows }) {
const [mode, setMode] = useState(isJson(value) ? JSON_MODE : YAML_MODE);
const [currentValue, setCurrentValue] = useState(value || '---');
const [error, setError] = useState(null);
@ -90,7 +90,7 @@ function VariablesDetail({ value, label, rows }) {
}
VariablesDetail.propTypes = {
value: string.isRequired,
label: string.isRequired,
label: node.isRequired,
rows: number,
};
VariablesDetail.defaultProps = {

View File

@ -1,7 +1,15 @@
import React from 'react';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
import { EmptyState, EmptyStateBody } from '@patternfly/react-core';
import styled from 'styled-components';
import {
EmptyState as PFEmptyState,
EmptyStateBody,
} from '@patternfly/react-core';
const EmptyState = styled(PFEmptyState)`
--pf-c-empty-state--m-lg--MaxWidth: none;
`;
// TODO: Better loading state - skeleton lines / spinner, etc.
const ContentLoading = ({ className, i18n }) => (

View File

@ -25,8 +25,15 @@ const DetailValue = styled(({ fullWidth, ...props }) => (
`}
`;
const Detail = ({ label, value, fullWidth, className, dataCy }) => {
if (!value && typeof value !== 'number') {
const Detail = ({
label,
value,
fullWidth,
className,
dataCy,
alwaysVisible,
}) => {
if (!value && typeof value !== 'number' && !alwaysVisible) {
return null;
}
@ -58,10 +65,12 @@ Detail.propTypes = {
label: node.isRequired,
value: node,
fullWidth: bool,
alwaysVisible: bool,
};
Detail.defaultProps = {
value: null,
fullWidth: false,
alwaysVisible: false,
};
export default Detail;

View File

@ -10,14 +10,11 @@ import styled from 'styled-components';
import VerticalSeparator from '../VerticalSeparator';
const Split = styled(PFSplit)`
padding-top: 15px;
padding-bottom: 5px;
border-bottom: #ebebeb var(--pf-global--BorderWidth--sm) solid;
margin: 20px 0px;
align-items: baseline;
`;
const SplitLabelItem = styled(SplitItem)`
font-size: 14px;
font-weight: bold;
word-break: initial;
`;

View File

@ -0,0 +1,9 @@
import { Wizard } from '@patternfly/react-core';
import styled from 'styled-components';
Wizard.displayName = 'PFWizard';
export default styled(Wizard)`
.pf-c-data-toolbar__content {
padding: 0 !important;
}
`;

View File

@ -0,0 +1,10 @@
import React from 'react';
import { mount } from 'enzyme';
import Wizard from './Wizard';
describe('Wizard', () => {
test('renders the expected content', () => {
const wrapper = mount(<Wizard />);
expect(wrapper).toMatchSnapshot();
});
});

View File

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

View File

@ -1,49 +0,0 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Title } from '@patternfly/react-core';
import { DetailList, Detail } from '@components/DetailList';
import HorizontalSeparator from '@components/HorizontalSeparator';
function ApprovalPreviewStep({ i18n, name, description, timeout, linkType }) {
let linkTypeValue;
switch (linkType) {
case 'on_success':
linkTypeValue = i18n._(t`On Success`);
break;
case 'on_failure':
linkTypeValue = i18n._(t`On Failure`);
break;
case 'always':
linkTypeValue = i18n._(t`Always`);
break;
default:
break;
}
let timeoutValue = i18n._(t`None`);
if (timeout) {
const minutes = Math.floor(timeout / 60);
const seconds = timeout - minutes * 60;
timeoutValue = i18n._(t`${minutes}min ${seconds}sec`);
}
return (
<div>
<Title headingLevel="h1" size="xl">
{i18n._(t`Approval Node`)}
</Title>
<HorizontalSeparator />
<DetailList>
<Detail label={i18n._(t`Name`)} value={name} />
<Detail label={i18n._(t`Description`)} value={description} />
<Detail label={i18n._(t`Timeout`)} value={timeoutValue} />
<Detail label={i18n._(t`Run`)} value={linkTypeValue} />
</DetailList>
</div>
);
}
export default withI18n()(ApprovalPreviewStep);

View File

@ -1,39 +0,0 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Title } from '@patternfly/react-core';
import { DetailList, Detail } from '@components/DetailList';
import HorizontalSeparator from '@components/HorizontalSeparator';
function InventorySyncPreviewStep({ i18n, inventorySource, linkType }) {
let linkTypeValue;
switch (linkType) {
case 'success':
linkTypeValue = i18n._(t`On Success`);
break;
case 'failure':
linkTypeValue = i18n._(t`On Failure`);
break;
case 'always':
linkTypeValue = i18n._(t`Always`);
break;
default:
break;
}
return (
<div>
<Title headingLevel="h1" size="xl">
{i18n._(t`Inventory Sync Node`)}
</Title>
<HorizontalSeparator />
<DetailList gutter="sm">
<Detail label={i18n._(t`Name`)} value={inventorySource.name} />
<Detail label={i18n._(t`Run`)} value={linkTypeValue} />
</DetailList>
</div>
);
}
export default withI18n()(InventorySyncPreviewStep);

View File

@ -1,185 +0,0 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Title } from '@patternfly/react-core';
import { DetailList, Detail } from '@components/DetailList';
import HorizontalSeparator from '@components/HorizontalSeparator';
function JobTemplatePreviewStep({ i18n, jobTemplate, linkType }) {
let linkTypeValue;
switch (linkType) {
case 'success':
linkTypeValue = i18n._(t`On Success`);
break;
case 'failure':
linkTypeValue = i18n._(t`On Failure`);
break;
case 'always':
linkTypeValue = i18n._(t`Always`);
break;
default:
break;
}
return (
<div>
<Title headingLevel="h1" size="xl">
{i18n._(t`Job Template Node`)}
</Title>
<HorizontalSeparator />
<DetailList gutter="sm">
<Detail label={i18n._(t`Name`)} value={jobTemplate.name} />
<Detail
label={i18n._(t`Description`)}
value={jobTemplate.description}
/>
{/* <Detail label={i18n._(t`Job Type`)} value={job_type} />
{summary_fields.inventory ? (
<Detail
label={i18n._(t`Inventory`)}
value={inventoryValue(
summary_fields.inventory.kind,
summary_fields.inventory.id
)}
/>
) : (
!ask_inventory_on_launch &&
renderMissingDataDetail(i18n._(t`Inventory`))
)}
{summary_fields.project ? (
<Detail
label={i18n._(t`Project`)}
value={
<Link to={`/projects/${summary_fields.project.id}/details`}>
{summary_fields.project
? summary_fields.project.name
: i18n._(t`Deleted`)}
</Link>
}
/>
) : (
renderMissingDataDetail(i18n._(t`Project`))
)}
<Detail label={i18n._(t`Playbook`)} value={playbook} />
<Detail label={i18n._(t`Forks`)} value={forks || '0'} />
<Detail label={i18n._(t`Limit`)} value={limit} />
<Detail
label={i18n._(t`Verbosity`)}
value={verbosityDetails[0].details}
/>
<Detail label={i18n._(t`Timeout`)} value={timeout || '0'} />
{createdBy && (
<Detail
label={i18n._(t`Created`)}
value={createdBy} // TODO: link to user in users
/>
)}
{modifiedBy && (
<Detail
label={i18n._(t`Last Modified`)}
value={modifiedBy} // TODO: link to user in users
/>
)}
<Detail
label={i18n._(t`Show Changes`)}
value={diff_mode ? 'On' : 'Off'}
/>
<Detail label={i18n._(t` Job Slicing`)} value={job_slice_count} />
{host_config_key && (
<React.Fragment>
<Detail
label={i18n._(t`Host Config Key`)}
value={host_config_key}
/>
<Detail
label={i18n._(t`Provisioning Callback URL`)}
value={generateCallBackUrl}
/>
</React.Fragment>
)}
{renderOptionsField && (
<Detail label={i18n._(t`Options`)} value={renderOptions} />
)}
{summary_fields.credentials &&
summary_fields.credentials.length > 0 && (
<Detail
fullWidth
label={i18n._(t`Credentials`)}
value={
<ChipGroup numChips={5}>
{summary_fields.credentials.map(c => (
<CredentialChip key={c.id} credential={c} isReadOnly />
))}
</ChipGroup>
}
/>
)}
{summary_fields.labels && summary_fields.labels.results.length > 0 && (
<Detail
fullWidth
label={i18n._(t`Labels`)}
value={
<ChipGroup numChips={5}>
{summary_fields.labels.results.map(l => (
<Chip key={l.id} isReadOnly>
{l.name}
</Chip>
))}
</ChipGroup>
}
/>
)}
{instanceGroups.length > 0 && (
<Detail
fullWidth
label={i18n._(t`Instance Groups`)}
value={
<ChipGroup numChips={5}>
{instanceGroups.map(ig => (
<Chip key={ig.id} isReadOnly>
{ig.name}
</Chip>
))}
</ChipGroup>
}
/>
)}
{job_tags && job_tags.length > 0 && (
<Detail
fullWidth
label={i18n._(t`Job tags`)}
value={
<ChipGroup numChips={5}>
{job_tags.split(',').map(jobTag => (
<Chip key={jobTag} isReadOnly>
{jobTag}
</Chip>
))}
</ChipGroup>
}
/>
)}
{skip_tags && skip_tags.length > 0 && (
<Detail
fullWidth
label={i18n._(t`Skip tags`)}
value={
<ChipGroup numChips={5}>
{skip_tags.split(',').map(skipTag => (
<Chip key={skipTag} isReadOnly>
{skipTag}
</Chip>
))}
</ChipGroup>
}
/>
)} */}
<Detail label={i18n._(t`Run`)} value={linkTypeValue} />
</DetailList>
</div>
);
}
export default withI18n()(JobTemplatePreviewStep);

View File

@ -1,161 +0,0 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import { Formik, Field } from 'formik';
import { Form, FormGroup, TextInput, Title } from '@patternfly/react-core';
import FormRow from '@components/FormRow';
import HorizontalSeparator from '@components/HorizontalSeparator';
const TimeoutInput = styled(TextInput)`
width: 200px;
:not(:first-of-type) {
margin-left: 20px;
}
`;
const TimeoutLabel = styled.p`
margin-left: 10px;
`;
function NodeApprovalStep({
i18n,
name,
updateName,
description,
updateDescription,
timeout = 0,
updateTimeout,
}) {
return (
<div>
<Title headingLevel="h1" size="xl">
{i18n._(t`Approval Node`)}
</Title>
<HorizontalSeparator />
<Formik
initialValues={{
name: name || '',
description: description || '',
timeoutMinutes: Math.floor(timeout / 60),
timeoutSeconds: timeout - Math.floor(timeout / 60) * 60,
}}
render={() => (
<Form>
<FormRow>
<Field
name="name"
render={({ field, form }) => {
const isValid =
form &&
(!form.touched[field.name] || !form.errors[field.name]);
return (
<FormGroup
fieldId="approval-name"
isRequired={true}
isValid={isValid}
label={i18n._(t`Name`)}
>
<TextInput
id="approval-name"
isRequired={true}
isValid={isValid}
type="text"
{...field}
onChange={(value, event) => {
updateName(value);
field.onChange(event);
}}
autoFocus
/>
</FormGroup>
);
}}
/>
</FormRow>
<FormRow>
<Field
name="description"
render={({ field }) => (
<FormGroup
fieldId="approval-description"
label={i18n._(t`Description`)}
>
<TextInput
id="approval-description"
type="text"
{...field}
onChange={value => {
updateDescription(value);
field.onChange(event);
}}
/>
</FormGroup>
)}
/>
</FormRow>
<FormRow>
<FormGroup label={i18n._(t`Timeout`)} fieldId="approval-timeout">
<div css="display: flex;align-items: center;">
<Field
name="timeoutMinutes"
render={({ field, form }) => (
<>
<TimeoutInput
id="approval-timeout-minutes"
type="number"
min="0"
step="1"
{...field}
onChange={value => {
if (!value || value === '') {
value = 0;
}
updateTimeout(
Number(value) * 60 +
Number(form.values.timeoutSeconds)
);
field.onChange(event);
}}
/>
<TimeoutLabel>min</TimeoutLabel>
</>
)}
/>
<Field
name="timeoutSeconds"
render={({ field, form }) => (
<>
<TimeoutInput
id="approval-timeout-seconds"
type="number"
min="0"
step="1"
{...field}
onChange={value => {
if (!value || value === '') {
value = 0;
}
updateTimeout(
Number(value) +
Number(form.values.timeoutMinutes) * 60
);
field.onChange(event);
}}
/>
<TimeoutLabel>sec</TimeoutLabel>
</>
)}
/>
</div>
</FormGroup>
</FormRow>
</Form>
)}
/>
</div>
);
}
export default withI18n()(NodeApprovalStep);

View File

@ -1,38 +1,26 @@
import React, { useState } from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
Button,
Wizard,
WizardContextConsumer,
WizardFooter,
} from '@patternfly/react-core';
import NodeResourceStep from './NodeResourceStep';
import NodeTypeStep from './NodeTypeStep';
import NodeTypeStep from './NodeTypeStep/NodeTypeStep';
import RunStep from './RunStep';
import NodeNextButton from './NodeNextButton';
import NodeApprovalStep from './NodeApprovalStep';
import ApprovalPreviewStep from './ApprovalPreviewStep';
import JobTemplatePreviewStep from './JobTemplatePreviewStep';
import InventorySyncPreviewStep from './InventorySyncPreviewStep';
import ProjectSyncPreviewStep from './ProjectSyncPreviewStep';
import WorkflowJobTemplatePreviewStep from './WorkflowJobTemplatePreviewStep';
import { Wizard } from '@components/Wizard';
import {
JobTemplatesAPI,
ProjectsAPI,
InventorySourcesAPI,
WorkflowJobTemplatesAPI,
} from '@api';
const readInventorySources = async queryParams =>
InventorySourcesAPI.read(queryParams);
const readJobTemplates = async queryParams =>
JobTemplatesAPI.read(queryParams, { role_level: 'execute_role' });
const readProjects = async queryParams => ProjectsAPI.read(queryParams);
const readWorkflowJobTemplates = async queryParams =>
WorkflowJobTemplatesAPI.read(queryParams, { role_level: 'execute_role' });
function NodeModal({ i18n, title, onClose, onSave, node, askLinkType }) {
function NodeModal({
history,
i18n,
title,
onClose,
onSave,
node,
askLinkType,
}) {
let defaultNodeType = 'job_template';
let defaultNodeResource = null;
let defaultApprovalName = '';
@ -82,15 +70,6 @@ function NodeModal({ i18n, title, onClose, onSave, node, askLinkType }) {
const [nodeType, setNodeType] = useState(defaultNodeType);
const [linkType, setLinkType] = useState('success');
const [nodeResource, setNodeResource] = useState(defaultNodeResource);
const [showApprovalStep, setShowApprovalStep] = useState(
defaultNodeType === 'approval'
);
const [showResourceStep, setShowResourceStep] = useState(
defaultNodeResource ? true : false
);
const [showPreviewStep, setShowPreviewStep] = useState(
defaultNodeType === 'approval' || defaultNodeResource ? true : false
);
const [triggerNext, setTriggerNext] = useState(0);
const [approvalName, setApprovalName] = useState(defaultApprovalName);
const [approvalDescription, setApprovalDescription] = useState(
@ -100,7 +79,19 @@ function NodeModal({ i18n, title, onClose, onSave, node, askLinkType }) {
defaultApprovalTimeout
);
const clearQueryParams = () => {
const parts = history.location.search.replace(/^\?/, '').split('&');
const otherParts = parts.filter(param =>
/^!(job_templates\.|projects\.|inventory_sources\.|workflow_job_templates\.)/.test(
param
)
);
history.push(`${history.location.pathname}?${otherParts.join('&')}`);
};
const handleSaveNode = () => {
clearQueryParams();
const resource =
nodeType === 'approval'
? {
@ -120,47 +111,13 @@ function NodeModal({ i18n, title, onClose, onSave, node, askLinkType }) {
});
};
const resourceSearch = queryParams => {
switch (nodeType) {
case 'inventory_source_sync':
return readInventorySources(queryParams);
case 'job_template':
return readJobTemplates(queryParams);
case 'project_sync':
return readProjects(queryParams);
case 'workflow_job_template':
return readWorkflowJobTemplates(queryParams);
default:
throw new Error(i18n._(t`Missing node type`));
}
};
const handleNextClick = activeStep => {
if (activeStep.key === 'node_type') {
if (
[
'inventory_source_sync',
'job_template',
'project_sync',
'workflow_job_template',
].includes(nodeType)
) {
setShowApprovalStep(false);
setShowResourceStep(true);
} else if (nodeType === 'approval') {
setShowResourceStep(false);
setShowApprovalStep(true);
}
setShowPreviewStep(true);
}
setTriggerNext(triggerNext + 1);
const handleCancel = () => {
clearQueryParams();
onClose();
};
const handleNodeTypeChange = newNodeType => {
setNodeType(newNodeType);
setShowResourceStep(false);
setShowApprovalStep(false);
setShowPreviewStep(false);
setNodeResource(null);
setApprovalName('');
setApprovalDescription('');
@ -168,101 +125,39 @@ function NodeModal({ i18n, title, onClose, onSave, node, askLinkType }) {
};
const steps = [
...(askLinkType
? [
{
name: i18n._(t`Run Type`),
key: 'run_type',
component: (
<RunStep linkType={linkType} updateLinkType={setLinkType} />
),
enableNext: linkType !== null,
},
]
: []),
{
name: node ? i18n._(t`Node Type`) : i18n._(t`Run/Node Type`),
key: 'node_type',
name: i18n._(t`Node Type`),
key: 'node_resource',
enableNext:
(nodeType !== 'approval' && nodeResource !== null) ||
(nodeType === 'approval' && approvalName !== ''),
component: (
<NodeTypeStep
nodeType={nodeType}
updateNodeType={handleNodeTypeChange}
askLinkType={askLinkType}
linkType={linkType}
updateLinkType={setLinkType}
nodeResource={nodeResource}
updateNodeResource={setNodeResource}
name={approvalName}
updateName={setApprovalName}
description={approvalDescription}
updateDescription={setApprovalDescription}
timeout={approvalTimeout}
updateTimeout={setApprovalTimeout}
/>
),
enableNext: nodeType !== null,
},
...(showResourceStep
? [
{
name: i18n._(t`Select Node Resource`),
key: 'node_resource',
enableNext: nodeResource !== null,
component: (
<NodeResourceStep
nodeType={nodeType}
search={resourceSearch}
nodeResource={nodeResource}
updateNodeResource={setNodeResource}
/>
),
},
]
: []),
...(showApprovalStep
? [
{
name: i18n._(t`Configure Approval`),
key: 'approval',
component: (
<NodeApprovalStep
name={approvalName}
updateName={setApprovalName}
description={approvalDescription}
updateDescription={setApprovalDescription}
timeout={approvalTimeout}
updateTimeout={setApprovalTimeout}
/>
),
enableNext: approvalName !== '',
},
]
: []),
...(showPreviewStep
? [
{
name: i18n._(t`Preview`),
key: 'preview',
component: (
<>
{nodeType === 'approval' && (
<ApprovalPreviewStep
name={approvalName}
description={approvalDescription}
timeout={approvalTimeout}
linkType={linkType}
/>
)}
{nodeType === 'job_template' && (
<JobTemplatePreviewStep
jobTemplate={nodeResource}
linkType={linkType}
/>
)}
{nodeType === 'inventory_source_sync' && (
<InventorySyncPreviewStep
inventorySource={nodeResource}
linkType={linkType}
/>
)}
{nodeType === 'project_sync' && (
<ProjectSyncPreviewStep
project={nodeResource}
linkType={linkType}
/>
)}
{nodeType === 'workflow_job_template' && (
<WorkflowJobTemplatePreviewStep
workflowJobTemplate={nodeResource}
linkType={linkType}
/>
)}
</>
),
enableNext: true,
},
]
: []),
];
steps.forEach((step, n) => {
@ -272,20 +167,25 @@ function NodeModal({ i18n, title, onClose, onSave, node, askLinkType }) {
const CustomFooter = (
<WizardFooter>
<WizardContextConsumer>
{({ activeStep, onNext, onBack, onClose }) => (
{({ activeStep, onNext, onBack }) => (
<>
<NodeNextButton
triggerNext={triggerNext}
activeStep={activeStep}
onNext={onNext}
onClick={handleNextClick}
onClick={() => setTriggerNext(triggerNext + 1)}
buttonText={
activeStep.key === 'node_resource'
? i18n._(t`Save`)
: i18n._(t`Next`)
}
/>
{activeStep && activeStep.id !== 1 && (
<Button variant="secondary" onClick={onBack}>
{i18n._(t`Back`)}
</Button>
)}
<Button variant="link" onClick={onClose}>
<Button variant="link" onClick={handleCancel}>
{i18n._(t`Cancel`)}
</Button>
</>
@ -294,17 +194,19 @@ function NodeModal({ i18n, title, onClose, onSave, node, askLinkType }) {
</WizardFooter>
);
const wizardTitle = nodeResource ? `${title} | ${nodeResource.name}` : title;
return (
<Wizard
style={{ overflow: 'scroll' }}
isOpen
steps={steps}
title={title}
onClose={onClose}
title={wizardTitle}
onClose={handleCancel}
onSave={handleSaveNode}
footer={CustomFooter}
/>
);
}
export default withI18n()(NodeModal);
export default withI18n()(withRouter(NodeModal));

View File

@ -3,7 +3,14 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core';
function NodeNextButton({ i18n, activeStep, onNext, triggerNext, onClick }) {
function NodeNextButton({
i18n,
activeStep,
onNext,
triggerNext,
onClick,
buttonText,
}) {
useEffect(() => {
if (!triggerNext) {
return;
@ -18,7 +25,7 @@ function NodeNextButton({ i18n, activeStep, onNext, triggerNext, onClick }) {
onClick={() => onClick(activeStep)}
isDisabled={!activeStep.enableNext}
>
{activeStep.key === 'preview' ? i18n._(t`Save`) : i18n._(t`Next`)}
{buttonText}
</Button>
);
}

View File

@ -1,120 +0,0 @@
import React, { Fragment, useEffect, useState } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { getQSConfig, parseQueryString } from '@util/qs';
import { Title } from '@patternfly/react-core';
import PaginatedDataList from '@components/PaginatedDataList';
import DataListToolbar from '@components/DataListToolbar';
import CheckboxListItem from '@components/CheckboxListItem';
import SelectedList from '@components/SelectedList';
const QS_CONFIG = getQSConfig('node_resource', {
page: 1,
page_size: 5,
order_by: 'name',
});
function NodeTypeStep({
i18n,
search,
nodeType,
nodeResource,
updateNodeResource,
}) {
const [contentError, setContentError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [rowCount, setRowCount] = useState(0);
const [rows, setRows] = useState([]);
let headerText = '';
switch (nodeType) {
case 'inventory_source_sync':
headerText = i18n._(t`Inventory Sources`);
break;
case 'job_template':
headerText = i18n._(t`Job Templates`);
break;
case 'project_sync':
headerText = i18n._(t`Projects`);
break;
case 'workflow_job_template':
headerText = i18n._(t`Workflow Job Templates`);
break;
default:
break;
}
const fetchRows = queryString => {
const params = parseQueryString(QS_CONFIG, queryString);
return search(params);
};
useEffect(() => {
async function fetchData() {
try {
const {
data: { count, results },
} = await fetchRows(location.node_resource);
setRows(results);
setRowCount(count);
} catch (error) {
setContentError(error);
} finally {
setIsLoading(false);
}
}
fetchData();
}, [location]);
return (
<Fragment>
<Title headingLevel="h1" size="xl">
{headerText}
</Title>
<p>{i18n._(t`Select a resource to be executed from the list below.`)}</p>
{nodeResource && (
<SelectedList
displayKey="name"
label={i18n._(t`Selected`)}
onRemove={() => updateNodeResource(null)}
selected={[nodeResource]}
/>
)}
<PaginatedDataList
contentError={contentError}
hasContentLoading={isLoading}
items={rows}
itemCount={rowCount}
qsConfig={QS_CONFIG}
toolbarColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isSortable: true,
isSearchable: true,
},
]}
renderItem={item => (
<CheckboxListItem
isSelected={
nodeResource && nodeResource.id === item.id ? true : false
}
itemId={item.id}
key={item.id}
name={item.name}
label={item.name}
onSelect={() => updateNodeResource(item)}
onDeselect={() => updateNodeResource(null)}
isRadio={true}
/>
)}
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
showPageSizeOptions={false}
/>
</Fragment>
);
}
export default withI18n()(NodeTypeStep);

View File

@ -1,105 +0,0 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import { Title } from '@patternfly/react-core';
import { SelectableCard } from '@components/SelectableCard';
const Grid = styled.div`
display: grid;
grid-template-columns: 33% 33% 33%;
grid-gap: 20px;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
grid-auto-rows: 100px;
width: 100%;
margin: 20px 0px;
`;
function NodeTypeStep({
i18n,
nodeType,
updateNodeType,
linkType,
updateLinkType,
askLinkType,
}) {
return (
<div>
{askLinkType && (
<>
<Title headingLevel="h1" size="xl">
{i18n._(t`Run`)}
</Title>
<p>
{i18n._(
t`Specify the conditions under which this node should be executed`
)}
</p>
<Grid>
<SelectableCard
isSelected={linkType === 'success'}
label={i18n._(t`On Success`)}
description={i18n._(
t`Execute when the parent node results in a successful state.`
)}
onClick={() => updateLinkType('success')}
/>
<SelectableCard
isSelected={linkType === 'failure'}
label={i18n._(t`On Failure`)}
description={i18n._(
t`Execute when the parent node results in a failure state.`
)}
onClick={() => updateLinkType('failure')}
/>
<SelectableCard
isSelected={linkType === 'always'}
label={i18n._(t`Always`)}
description={i18n._(
t`Execute regardless of the parent node's final state.`
)}
onClick={() => updateLinkType('always')}
/>
</Grid>
</>
)}
<Title headingLevel="h1" size="xl">
{i18n._(t`Node Type`)}
</Title>
<Grid>
<SelectableCard
isSelected={nodeType === 'job_template'}
label={i18n._(t`Job Template`)}
description={i18n._(t`Execute a job template.`)}
onClick={() => updateNodeType('job_template')}
/>
<SelectableCard
isSelected={nodeType === 'workflow_job_template'}
label={i18n._(t`Workflow Job Template`)}
description={i18n._(t`Execute a workflow job template.`)}
onClick={() => updateNodeType('workflow_job_template')}
/>
<SelectableCard
isSelected={nodeType === 'project_sync'}
label={i18n._(t`Project Sync`)}
description={i18n._(t`Execute a project sync.`)}
onClick={() => updateNodeType('project_sync')}
/>
<SelectableCard
isSelected={nodeType === 'inventory_source_sync'}
label={i18n._(t`Inventory Source Sync`)}
description={i18n._(t`Execute an inventory source sync.`)}
onClick={() => updateNodeType('inventory_source_sync')}
/>
<SelectableCard
isSelected={nodeType === 'approval'}
label={i18n._(t`Approval`)}
description={i18n._(t`Pause the workflow and wait for approval.`)}
onClick={() => updateNodeType('approval')}
/>
</Grid>
</div>
);
}
export default withI18n()(NodeTypeStep);

View File

@ -0,0 +1,81 @@
import React, { useState, useEffect } from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { InventorySourcesAPI } from '@api';
import { getQSConfig, parseQueryString } from '@util/qs';
import PaginatedDataList from '@components/PaginatedDataList';
import DataListToolbar from '@components/DataListToolbar';
import CheckboxListItem from '@components/CheckboxListItem';
const QS_CONFIG = getQSConfig('inventory_sources', {
page: 1,
page_size: 5,
order_by: 'name',
});
function InventorySourcesList({
i18n,
history,
nodeResource,
updateNodeResource,
}) {
const [inventorySources, setInventorySources] = useState([]);
const [count, setCount] = useState(0);
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
(async () => {
setIsLoading(true);
setInventorySources([]);
setCount(0);
const params = parseQueryString(QS_CONFIG, history.location.search);
try {
const { data } = await InventorySourcesAPI.read(params);
setInventorySources(data.results);
setCount(data.count);
} catch (err) {
setError(err);
} finally {
setIsLoading(false);
}
})();
}, [history.location]);
return (
<PaginatedDataList
contentError={error}
hasContentLoading={isLoading}
items={inventorySources}
itemCount={count}
qsConfig={QS_CONFIG}
toolbarColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isSortable: true,
isSearchable: true,
},
]}
renderItem={item => (
<CheckboxListItem
isSelected={
nodeResource && nodeResource.id === item.id ? true : false
}
itemId={item.id}
key={item.id}
name={item.name}
label={item.name}
onSelect={() => updateNodeResource(item)}
onDeselect={() => updateNodeResource(null)}
isRadio={true}
/>
)}
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
showPageSizeOptions={false}
/>
);
}
export default withI18n()(withRouter(InventorySourcesList));

View File

@ -0,0 +1,78 @@
import React, { useState, useEffect } from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { JobTemplatesAPI } from '@api';
import { getQSConfig, parseQueryString } from '@util/qs';
import PaginatedDataList from '@components/PaginatedDataList';
import DataListToolbar from '@components/DataListToolbar';
import CheckboxListItem from '@components/CheckboxListItem';
const QS_CONFIG = getQSConfig('job_templates', {
page: 1,
page_size: 5,
order_by: 'name',
});
function JobTemplatesList({ i18n, history, nodeResource, updateNodeResource }) {
const [jobTemplates, setJobTemplates] = useState([]);
const [count, setCount] = useState(0);
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
(async () => {
setIsLoading(true);
setJobTemplates([]);
setCount(0);
const params = parseQueryString(QS_CONFIG, history.location.search);
try {
const { data } = await JobTemplatesAPI.read(params, {
role_level: 'execute_role',
});
setJobTemplates(data.results);
setCount(data.count);
} catch (err) {
setError(err);
} finally {
setIsLoading(false);
}
})();
}, [history.location]);
return (
<PaginatedDataList
contentError={error}
hasContentLoading={isLoading}
items={jobTemplates}
itemCount={count}
qsConfig={QS_CONFIG}
toolbarColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isSortable: true,
isSearchable: true,
},
]}
renderItem={item => (
<CheckboxListItem
isSelected={
nodeResource && nodeResource.id === item.id ? true : false
}
itemId={item.id}
key={item.id}
name={item.name}
label={item.name}
onSelect={() => updateNodeResource(item)}
onDeselect={() => updateNodeResource(null)}
isRadio={true}
/>
)}
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
showPageSizeOptions={false}
/>
);
}
export default withI18n()(withRouter(JobTemplatesList));

View File

@ -0,0 +1,244 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import { Formik, Field } from 'formik';
import { Form, FormGroup, TextInput } from '@patternfly/react-core';
import { Divider } from '@patternfly/react-core/dist/esm/experimental';
import FormRow from '@components/FormRow';
import AnsibleSelect from '@components/AnsibleSelect';
import VerticalSeperator from '@components/VerticalSeparator';
import InventorySourcesList from './InventorySourcesList';
import JobTemplatesList from './JobTemplatesList';
import ProjectsList from './ProjectsList';
import WorkflowJobTemplatesList from './WorkflowJobTemplatesList';
const TimeoutInput = styled(TextInput)`
width: 200px;
:not(:first-of-type) {
margin-left: 20px;
}
`;
const TimeoutLabel = styled.p`
margin-left: 10px;
`;
function NodeTypeStep({
i18n,
nodeType = 'job_template',
updateNodeType,
nodeResource,
updateNodeResource,
name,
updateName,
description,
updateDescription,
timeout = 0,
updateTimeout,
}) {
return (
<>
<div css=" display: flex; align-items: center; margin-bottom: 20px;">
<b>{i18n._(t`Node Type`)}</b>
<VerticalSeperator />
<div>
<AnsibleSelect
id="nodeResource-select"
label={i18n._(t`Select a Node Type`)}
data={[
{
key: 'approval',
value: 'approval',
label: i18n._(t`Approval`),
isDisabled: false,
},
{
key: 'inventory_source_sync',
value: 'inventory_source_sync',
label: i18n._(t`Inventory Source Sync`),
isDisabled: false,
},
{
key: 'job_template',
value: 'job_template',
label: i18n._(t`Job Template`),
isDisabled: false,
},
{
key: 'project_sync',
value: 'project_sync',
label: i18n._(t`Project Sync`),
isDisabled: false,
},
{
key: 'workflow_job_template',
value: 'workflow_job_template',
label: i18n._(t`Workflow Job Template`),
isDisabled: false,
},
]}
value={nodeType}
onChange={(e, val) => {
updateNodeType(val);
}}
/>
</div>
</div>
<Divider component="div" />
{nodeType === 'job_template' && (
<JobTemplatesList
nodeResource={nodeResource}
updateNodeResource={updateNodeResource}
/>
)}
{nodeType === 'project_sync' && (
<ProjectsList
nodeResource={nodeResource}
updateNodeResource={updateNodeResource}
/>
)}
{nodeType === 'inventory_source_sync' && (
<InventorySourcesList
nodeResource={nodeResource}
updateNodeResource={updateNodeResource}
/>
)}
{nodeType === 'workflow_job_template' && (
<WorkflowJobTemplatesList
nodeResource={nodeResource}
updateNodeResource={updateNodeResource}
/>
)}
{nodeType === 'approval' && (
<Formik
initialValues={{
name: name || '',
description: description || '',
timeoutMinutes: Math.floor(timeout / 60),
timeoutSeconds: timeout - Math.floor(timeout / 60) * 60,
}}
render={() => (
<Form css="margin-top: 20px;">
<FormRow>
<Field
name="name"
render={({ field, form }) => {
const isValid =
form &&
(!form.touched[field.name] || !form.errors[field.name]);
return (
<FormGroup
fieldId="approval-name"
isRequired={true}
isValid={isValid}
label={i18n._(t`Name`)}
>
<TextInput
id="approval-name"
isRequired={true}
isValid={isValid}
type="text"
{...field}
onChange={(value, event) => {
updateName(value);
field.onChange(event);
}}
autoFocus
/>
</FormGroup>
);
}}
/>
</FormRow>
<FormRow>
<Field
name="description"
render={({ field }) => (
<FormGroup
fieldId="approval-description"
label={i18n._(t`Description`)}
>
<TextInput
id="approval-description"
type="text"
{...field}
onChange={value => {
updateDescription(value);
field.onChange(event);
}}
/>
</FormGroup>
)}
/>
</FormRow>
<FormRow>
<FormGroup
label={i18n._(t`Timeout`)}
fieldId="approval-timeout"
>
<div css="display: flex;align-items: center;">
<Field
name="timeoutMinutes"
render={({ field, form }) => (
<>
<TimeoutInput
id="approval-timeout-minutes"
type="number"
min="0"
step="1"
{...field}
onChange={value => {
if (!value || value === '') {
value = 0;
}
updateTimeout(
Number(value) * 60 +
Number(form.values.timeoutSeconds)
);
field.onChange(event);
}}
/>
<TimeoutLabel>min</TimeoutLabel>
</>
)}
/>
<Field
name="timeoutSeconds"
render={({ field, form }) => (
<>
<TimeoutInput
id="approval-timeout-seconds"
type="number"
min="0"
step="1"
{...field}
onChange={value => {
if (!value || value === '') {
value = 0;
}
updateTimeout(
Number(value) +
Number(form.values.timeoutMinutes) * 60
);
field.onChange(event);
}}
/>
<TimeoutLabel>sec</TimeoutLabel>
</>
)}
/>
</div>
</FormGroup>
</FormRow>
</Form>
)}
/>
)}
</>
);
}
export default withI18n()(NodeTypeStep);

View File

@ -0,0 +1,76 @@
import React, { useState, useEffect } from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { ProjectsAPI } from '@api';
import { getQSConfig, parseQueryString } from '@util/qs';
import PaginatedDataList from '@components/PaginatedDataList';
import DataListToolbar from '@components/DataListToolbar';
import CheckboxListItem from '@components/CheckboxListItem';
const QS_CONFIG = getQSConfig('projects', {
page: 1,
page_size: 5,
order_by: 'name',
});
function ProjectsList({ i18n, history, nodeResource, updateNodeResource }) {
const [projects, setProjects] = useState([]);
const [count, setCount] = useState(0);
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
(async () => {
setIsLoading(true);
setProjects([]);
setCount(0);
const params = parseQueryString(QS_CONFIG, history.location.search);
try {
const { data } = await ProjectsAPI.read(params);
setProjects(data.results);
setCount(data.count);
} catch (err) {
setError(err);
} finally {
setIsLoading(false);
}
})();
}, [history.location]);
return (
<PaginatedDataList
contentError={error}
hasContentLoading={isLoading}
items={projects}
itemCount={count}
qsConfig={QS_CONFIG}
toolbarColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isSortable: true,
isSearchable: true,
},
]}
renderItem={item => (
<CheckboxListItem
isSelected={
nodeResource && nodeResource.id === item.id ? true : false
}
itemId={item.id}
key={item.id}
name={item.name}
label={item.name}
onSelect={() => updateNodeResource(item)}
onDeselect={() => updateNodeResource(null)}
isRadio={true}
/>
)}
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
showPageSizeOptions={false}
/>
);
}
export default withI18n()(withRouter(ProjectsList));

View File

@ -0,0 +1,83 @@
import React, { useState, useEffect } from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { WorkflowJobTemplatesAPI } from '@api';
import { getQSConfig, parseQueryString } from '@util/qs';
import PaginatedDataList from '@components/PaginatedDataList';
import DataListToolbar from '@components/DataListToolbar';
import CheckboxListItem from '@components/CheckboxListItem';
const QS_CONFIG = getQSConfig('workflow_job_templates', {
page: 1,
page_size: 5,
order_by: 'name',
});
function WorkflowJobTemplatesList({
i18n,
history,
nodeResource,
updateNodeResource,
}) {
const [workflowJobTemplates, setWorkflowJobTemplates] = useState([]);
const [count, setCount] = useState(0);
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
(async () => {
setIsLoading(true);
setWorkflowJobTemplates([]);
setCount(0);
const params = parseQueryString(QS_CONFIG, history.location.search);
try {
const { data } = await WorkflowJobTemplatesAPI.read(params, {
role_level: 'execute_role',
});
setWorkflowJobTemplates(data.results);
setCount(data.count);
} catch (err) {
setError(err);
} finally {
setIsLoading(false);
}
})();
}, [history.location]);
return (
<PaginatedDataList
contentError={error}
hasContentLoading={isLoading}
items={workflowJobTemplates}
itemCount={count}
qsConfig={QS_CONFIG}
toolbarColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isSortable: true,
isSearchable: true,
},
]}
renderItem={item => (
<CheckboxListItem
isSelected={
nodeResource && nodeResource.id === item.id ? true : false
}
itemId={item.id}
key={item.id}
name={item.name}
label={item.name}
onSelect={() => updateNodeResource(item)}
onDeselect={() => updateNodeResource(null)}
isRadio={true}
/>
)}
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
showPageSizeOptions={false}
/>
);
}
export default withI18n()(withRouter(WorkflowJobTemplatesList));

View File

@ -1,39 +0,0 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Title } from '@patternfly/react-core';
import { DetailList, Detail } from '@components/DetailList';
import HorizontalSeparator from '@components/HorizontalSeparator';
function ProjectPreviewStep({ i18n, project, linkType }) {
let linkTypeValue;
switch (linkType) {
case 'success':
linkTypeValue = i18n._(t`On Success`);
break;
case 'failure':
linkTypeValue = i18n._(t`On Failure`);
break;
case 'always':
linkTypeValue = i18n._(t`Always`);
break;
default:
break;
}
return (
<div>
<Title headingLevel="h1" size="xl">
{i18n._(t`Project Sync Node`)}
</Title>
<HorizontalSeparator />
<DetailList gutter="sm">
<Detail label={i18n._(t`Name`)} value={project.name} />
<Detail label={i18n._(t`Run`)} value={linkTypeValue} />
</DetailList>
</div>
);
}
export default withI18n()(ProjectPreviewStep);

View File

@ -0,0 +1,59 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import { Title } from '@patternfly/react-core';
import { SelectableCard } from '@components/SelectableCard';
const Grid = styled.div`
display: grid;
grid-template-columns: 33% 33% 33%;
grid-gap: 20px;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
grid-auto-rows: 100px;
width: 100%;
margin: 20px 0px;
`;
function RunStep({ i18n, linkType, updateLinkType }) {
return (
<>
<Title headingLevel="h1" size="xl">
{i18n._(t`Run`)}
</Title>
<p>
{i18n._(
t`Specify the conditions under which this node should be executed`
)}
</p>
<Grid>
<SelectableCard
isSelected={linkType === 'success'}
label={i18n._(t`On Success`)}
description={i18n._(
t`Execute when the parent node results in a successful state.`
)}
onClick={() => updateLinkType('success')}
/>
<SelectableCard
isSelected={linkType === 'failure'}
label={i18n._(t`On Failure`)}
description={i18n._(
t`Execute when the parent node results in a failure state.`
)}
onClick={() => updateLinkType('failure')}
/>
<SelectableCard
isSelected={linkType === 'always'}
label={i18n._(t`Always`)}
description={i18n._(
t`Execute regardless of the parent node's final state.`
)}
onClick={() => updateLinkType('always')}
/>
</Grid>
</>
);
}
export default withI18n()(RunStep);

View File

@ -1,43 +0,0 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Title } from '@patternfly/react-core';
import { DetailList, Detail } from '@components/DetailList';
import HorizontalSeparator from '@components/HorizontalSeparator';
function WorkflowJobTemplatePreviewStep({
i18n,
workflowJobTemplate,
linkType,
}) {
let linkTypeValue;
switch (linkType) {
case 'success':
linkTypeValue = i18n._(t`On Success`);
break;
case 'failure':
linkTypeValue = i18n._(t`On Failure`);
break;
case 'always':
linkTypeValue = i18n._(t`Always`);
break;
default:
break;
}
return (
<div>
<Title headingLevel="h1" size="xl">
{i18n._(t`Workflow Job Template Node`)}
</Title>
<HorizontalSeparator />
<DetailList gutter="sm">
<Detail label={i18n._(t`Name`)} value={workflowJobTemplate.name} />
<Detail label={i18n._(t`Run`)} value={linkTypeValue} />
</DetailList>
</div>
);
}
export default withI18n()(WorkflowJobTemplatePreviewStep);

View File

@ -0,0 +1,26 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { DetailList, Detail } from '@components/DetailList';
function ApprovalDetails({ i18n, node }) {
const { name, description, timeout } = node.unifiedJobTemplate;
let timeoutValue = i18n._(t`None`);
if (timeout) {
const minutes = Math.floor(timeout / 60);
const seconds = timeout - minutes * 60;
timeoutValue = i18n._(t`${minutes}min ${seconds}sec`);
}
return (
<DetailList gutter="sm">
<Detail label={i18n._(t`Node Type`)} value={i18n._(t`Approval`)} />
<Detail label={i18n._(t`Name`)} value={name} />
<Detail label={i18n._(t`Description`)} value={description} />
<Detail label={i18n._(t`Timeout`)} value={timeoutValue} />
</DetailList>
);
}
export default withI18n()(ApprovalDetails);

View File

@ -0,0 +1,165 @@
import React, { useEffect, useState } from 'react';
import { withI18n } from '@lingui/react';
import { Trans } from '@lingui/macro';
import { t } from '@lingui/macro';
import { InventorySourcesAPI } from '@api';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import { DetailList, Detail } from '@components/DetailList';
import { VariablesDetail } from '@components/CodeMirrorInput';
import { CredentialChip } from '@components/Chip';
function InventorySourceSyncDetails({ i18n, node }) {
const [inventorySource, setInventorySource] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [noReadAccess, setNoReadAccess] = useState(false);
const [contentError, setContentError] = useState(null);
const [optionsActions, setOptionsActions] = useState(null);
useEffect(() => {
async function fetchInventorySource() {
try {
const [
{ data },
{
data: { actions },
},
] = await Promise.all([
InventorySourcesAPI.readDetail(node.unifiedJobTemplate.id),
InventorySourcesAPI.readOptions(),
]);
setInventorySource(data);
setOptionsActions(actions);
} catch (err) {
if (err.response.status === 403) {
setNoReadAccess(true);
} else {
setContentError(err);
}
} finally {
setIsLoading(false);
}
}
fetchInventorySource();
}, []);
if (isLoading) {
return <ContentLoading />;
}
if (contentError) {
return <ContentError error={contentError} />;
}
if (noReadAccess) {
return (
<>
<p>
<Trans>
Your account does not have read access to this inventory source so
the displayed details will be limited.
</Trans>
</p>
<br />
<DetailList gutter="sm">
<Detail
label={i18n._(t`Node Type`)}
value={i18n._(t`Inventory Source Sync`)}
/>
<Detail
label={i18n._(t`Name`)}
value={node.unifiedJobTemplate.name}
/>
<Detail
label={i18n._(t`Description`)}
value={node.unifiedJobTemplate.description}
/>
</DetailList>
</>
);
}
const {
custom_virtualenv,
description,
group_by,
instance_filters,
name,
source,
source_path,
source_regions,
source_script,
source_vars,
summary_fields,
timeout,
verbosity,
} = inventorySource;
let sourceValue = '';
let verbosityValue = '';
optionsActions.GET.source.choices.forEach(choice => {
if (choice[0] === source) {
sourceValue = choice[1];
}
});
optionsActions.GET.verbosity.choices.forEach(choice => {
if (choice[0] === verbosity) {
verbosityValue = choice[1];
}
});
return (
<DetailList gutter="sm">
<Detail
label={i18n._(t`Node Type`)}
value={i18n._(t`Inventory Source Sync`)}
/>
<Detail label={i18n._(t`Name`)} value={name} />
<Detail label={i18n._(t`Description`)} value={description} />
{summary_fields.inventory && (
<Detail
label={i18n._(t`Inventory`)}
value={summary_fields.inventory.name}
/>
)}
{summary_fields.credential && (
<Detail
label={i18n._(t`Credential`)}
value={
<CredentialChip
key={summary_fields.credential.id}
credential={summary_fields.credential}
isReadOnly
/>
}
/>
)}
<Detail label={i18n._(t`Source`)} value={sourceValue} />
<Detail label={i18n._(t`Source Path`)} value={source_path} />
<Detail label={i18n._(t`Source Script`)} value={source_script} />
{/* this should probably be tags built from OPTIONS*/}
<Detail label={i18n._(t`Source Regions`)} value={source_regions} />
<Detail label={i18n._(t`Instance Filters`)} value={instance_filters} />
{/* this should probably be tags built from OPTIONS */}
<Detail label={i18n._(t`Only Group By`)} value={group_by} />
<Detail
label={i18n._(t`Timeout`)}
value={`${timeout} ${i18n._(t`Seconds`)}`}
/>
<Detail
label={i18n._(t`Ansible Environment`)}
value={custom_virtualenv}
/>
<Detail label={i18n._(t`Verbosity`)} value={verbosityValue} />
<VariablesDetail
label={i18n._(t`Variables`)}
value={source_vars}
rows={4}
/>
</DetailList>
);
}
export default withI18n()(InventorySourceSyncDetails);

View File

@ -0,0 +1,564 @@
import React, { useEffect, useState } from 'react';
import { withI18n } from '@lingui/react';
import { Trans } from '@lingui/macro';
import { t } from '@lingui/macro';
import jsyaml from 'js-yaml';
import styled from 'styled-components';
import { JobTemplatesAPI, WorkflowJobTemplateNodesAPI } from '@api';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import { DetailList, Detail } from '@components/DetailList';
import { ChipGroup, Chip, CredentialChip } from '@components/Chip';
import { VariablesDetail } from '@components/CodeMirrorInput';
const Overridden = styled.div`
color: var(--pf-global--warning-color--100);
`;
function JobTemplateDetails({ i18n, node }) {
const [jobTemplate, setJobTemplate] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [noReadAccess, setNoReadAccess] = useState(false);
const [contentError, setContentError] = useState(null);
const [optionsActions, setOptionsActions] = useState(null);
const [instanceGroups, setInstanceGroups] = useState([]);
const [nodeCredentials, setNodeCredentials] = useState([]);
const [launchConf, setLaunchConf] = useState(null);
useEffect(() => {
async function fetchJobTemplate() {
try {
const [
{ data },
{
data: { results: instanceGroups },
},
{ data: launchConf },
{
data: { actions },
},
{
data: { results: nodeCredentials },
},
] = await Promise.all([
JobTemplatesAPI.readDetail(node.unifiedJobTemplate.id),
JobTemplatesAPI.readInstanceGroups(node.unifiedJobTemplate.id),
JobTemplatesAPI.readLaunch(node.unifiedJobTemplate.id),
JobTemplatesAPI.readOptions(),
WorkflowJobTemplateNodesAPI.readCredentials(
node.originalNodeObject.id
),
]);
setJobTemplate(data);
setInstanceGroups(instanceGroups);
setLaunchConf(launchConf);
setOptionsActions(actions);
setNodeCredentials(nodeCredentials);
} catch (err) {
if (err.response.status === 403) {
setNoReadAccess(true);
} else {
setContentError(err);
}
} finally {
setIsLoading(false);
}
}
fetchJobTemplate();
}, []);
if (isLoading) {
return <ContentLoading />;
}
if (contentError) {
return <ContentError error={contentError} />;
}
if (noReadAccess) {
return (
<>
<p>
<Trans>
Your account does not have read access to this job template so the
displayed details will be limited.
</Trans>
</p>
<br />
<DetailList gutter="sm">
<Detail
label={i18n._(t`Node Type`)}
value={i18n._(t`Job Template`)}
/>
<Detail
label={i18n._(t`Name`)}
value={node.unifiedJobTemplate.name}
/>
<Detail
label={i18n._(t`Description`)}
value={node.unifiedJobTemplate.description}
/>
</DetailList>
</>
);
}
const {
job_type: nodeJobType,
limit: nodeLimit,
scm_branch: nodeScmBranch,
inventory: nodeInventory,
verbosity: nodeVerbosity,
job_tags: nodeJobTags,
skip_tags: nodeSkipTags,
diff_mode: nodeDiffMode,
extra_data: nodeExtraData,
summary_fields: nodeSummaryFields,
} = node.originalNodeObject;
let {
ask_job_type_on_launch,
ask_limit_on_launch,
ask_scm_branch_on_launch,
ask_inventory_on_launch,
ask_verbosity_on_launch,
ask_tags_on_launch,
ask_skip_tags_on_launch,
ask_diff_mode_on_launch,
ask_credential_on_launch,
ask_variables_on_launch,
description,
diff_mode,
extra_vars,
forks,
host_config_key,
job_slice_count,
job_tags,
job_type,
name,
limit,
playbook,
skip_tags,
timeout,
summary_fields,
verbosity,
scm_branch,
inventory,
} = jobTemplate;
const jobTypeOverridden =
ask_job_type_on_launch && nodeJobType !== null && job_type !== nodeJobType;
const limitOverridden =
ask_limit_on_launch && nodeLimit !== null && limit !== nodeLimit;
const scmBranchOverridden =
ask_scm_branch_on_launch &&
nodeScmBranch !== null &&
scm_branch !== nodeScmBranch;
const inventoryOverridden =
ask_inventory_on_launch &&
nodeInventory !== null &&
inventory !== nodeInventory;
const verbosityOverridden =
ask_verbosity_on_launch &&
nodeVerbosity !== null &&
verbosity !== nodeVerbosity;
const jobTagsOverridden =
ask_tags_on_launch && nodeJobTags !== null && job_tags !== nodeJobTags;
const skipTagsOverridden =
ask_skip_tags_on_launch &&
nodeSkipTags !== null &&
skip_tags !== nodeSkipTags;
const diffModeOverridden =
ask_diff_mode_on_launch &&
nodeDiffMode !== null &&
diff_mode !== nodeDiffMode;
const credentialOverridden =
ask_credential_on_launch && nodeCredentials.length > 0;
let variablesOverridden = false;
let variablesToShow = extra_vars;
const deepObjectMatch = (obj1, obj2) => {
if (obj1 === obj2) {
return true;
}
if (
obj1 === null ||
obj2 === null ||
typeof obj1 !== 'object' ||
typeof obj2 !== 'object'
) {
return false;
}
const obj1Keys = Object.keys(obj1);
const obj2Keys = Object.keys(obj2);
if (obj1Keys.length !== obj2Keys.length) {
return false;
}
for (let key of obj1Keys) {
if (!obj2Keys.includes(key) || !deepObjectMatch(obj1[key], obj2[key])) {
return false;
}
}
return true;
};
if (ask_variables_on_launch || launchConf.survey_enabled) {
// we need to check to see if the extra vars are different from the defaults
// but we'll need to do some normalization. Convert both to JSON objects
// and then compare.
let jsonifiedExtraVars = {};
let jsonifiedExtraData = {};
// extra_vars has to be a string
if (typeof extra_vars === 'string') {
if (
extra_vars === '{}' ||
extra_vars === 'null' ||
extra_vars === '' ||
extra_vars === '""'
) {
jsonifiedExtraVars = {};
} else {
try {
// try to turn the string into json
jsonifiedExtraVars = JSON.parse(extra_vars);
} catch (jsonParseError) {
try {
// do safeLoad, which well error if not valid yaml
jsonifiedExtraVars = jsyaml.safeLoad(extra_vars);
} catch (yamlLoadError) {
setContentError(yamlLoadError);
}
}
}
} else {
setContentError(
Error(i18n._(t`Error parsing extra variables from the job template`))
);
}
// extra_data on a node can be either a string or an object...
if (typeof nodeExtraData === 'string') {
if (
nodeExtraData === '{}' ||
nodeExtraData === 'null' ||
nodeExtraData === '' ||
nodeExtraData === '""'
) {
jsonifiedExtraData = {};
} else {
try {
// try to turn the string into json
jsonifiedExtraData = JSON.parse(nodeExtraData);
} catch (error) {
try {
// do safeLoad, which well error if not valid yaml
jsonifiedExtraData = jsyaml.safeLoad(nodeExtraData);
} catch (yamlLoadError) {
setContentError(yamlLoadError);
}
}
}
} else if (typeof nodeExtraData === 'object') {
jsonifiedExtraData = nodeExtraData;
} else {
setContentError(
Error(i18n._(t`Error parsing extra variables from the node`))
);
}
if (!deepObjectMatch(jsonifiedExtraVars, jsonifiedExtraData)) {
variablesOverridden = true;
variablesToShow = jsyaml.safeDump(
Object.assign(jsonifiedExtraVars, jsonifiedExtraData)
);
}
}
let credentialsToShow = summary_fields.credentials;
if (credentialOverridden) {
credentialsToShow = [...nodeCredentials];
// adds vault_id to the credentials we get back from
// fetching the JT
launchConf.defaults.credentials.forEach(launchCred => {
if (launchCred.vault_id) {
summary_fields.credentials[
summary_fields.credentials.findIndex(
defaultCred => defaultCred.id === launchCred.id
)
].vault_id = launchCred.vault_id;
}
});
summary_fields.credentials.forEach(defaultCred => {
if (
!nodeCredentials.some(
overrideCredential =>
(defaultCred.kind === overrideCredential.kind &&
(!defaultCred.vault_id && !overrideCredential.inputs.vault_id)) ||
(defaultCred.vault_id &&
overrideCredential.inputs.vault_id &&
defaultCred.vault_id === overrideCredential.inputs.vault_id)
)
) {
credentialsToShow.push(defaultCred);
}
});
}
let verbosityToShow = '';
optionsActions.GET.verbosity.choices.forEach(choice => {
if (
verbosityOverridden
? choice[0] === nodeVerbosity
: choice[0] === verbosity
) {
verbosityToShow = choice[1];
}
});
const jobTagsToShow = jobTagsOverridden ? nodeJobTags : job_tags;
const skipTagsToShow = skipTagsOverridden ? nodeSkipTags : skip_tags;
return (
<>
<DetailList gutter="sm">
<Detail label={i18n._(t`Node Type`)} value={i18n._(t`Job Template`)} />
<Detail label={i18n._(t`Name`)} value={name} />
<Detail label={i18n._(t`Description`)} value={description} />
<Detail
label={
jobTypeOverridden ? (
<Overridden>* {i18n._(t`Job Type`)}</Overridden>
) : (
i18n._(t`Job Type`)
)
}
value={jobTypeOverridden ? nodeJobType : job_type}
/>
<Detail
label={
inventoryOverridden ? (
<Overridden>* {i18n._(t`Inventory`)}</Overridden>
) : (
i18n._(t`Inventory`)
)
}
value={
inventoryOverridden
? nodeSummaryFields.inventory.name
: summary_fields.inventory.name
}
alwaysVisible={inventoryOverridden}
/>
{summary_fields.project && (
<Detail
label={i18n._(t`Project`)}
value={summary_fields.project.name}
/>
)}
<Detail
label={
scmBranchOverridden ? (
<Overridden>* {i18n._(t`SCM Branch`)}</Overridden>
) : (
i18n._(t`SCM Branch`)
)
}
value={scmBranchOverridden ? nodeScmBranch : scm_branch}
alwaysVisible={scmBranchOverridden}
/>
<Detail label={i18n._(t`Playbook`)} value={playbook} />
<Detail label={i18n._(t`Forks`)} value={forks || '0'} />
<Detail
label={
limitOverridden ? (
<Overridden>* {i18n._(t`Limit`)}</Overridden>
) : (
i18n._(t`Limit`)
)
}
value={limitOverridden ? nodeLimit : limit}
alwaysVisible={limitOverridden}
/>
<Detail
label={
verbosityOverridden ? (
<Overridden>* {i18n._(t`Verbosity`)}</Overridden>
) : (
i18n._(t`Verbosity`)
)
}
value={verbosityToShow}
/>
<Detail label={i18n._(t`Timeout`)} value={timeout || '0'} />
<Detail
label={
diffModeOverridden ? (
<Overridden>* {i18n._(t`Show Changes`)}</Overridden>
) : (
i18n._(t`Show Changes`)
)
}
value={
(diffModeOverridden
? nodeDiffMode
: diff_mode)
? i18n._(t`On`)
: i18n._(t`Off`)
}
/>
<Detail label={i18n._(t` Job Slicing`)} value={job_slice_count} />
{host_config_key && (
<>
<Detail
label={i18n._(t`Host Config Key`)}
value={host_config_key}
/>
<Detail
label={i18n._(t`Provisioning Callback URL`)}
value={generateCallBackUrl}
/>
</>
)}
<Detail
fullWidth
label={
credentialOverridden ? (
<Overridden>* {i18n._(t`Credentials`)}</Overridden>
) : (
i18n._(t`Credentials`)
)
}
value={
credentialsToShow.length > 0 && (
<ChipGroup numChips={5}>
{credentialsToShow.map(c => (
<CredentialChip key={c.id} credential={c} isReadOnly />
))}
</ChipGroup>
)
}
alwaysVisible={credentialOverridden}
/>
{summary_fields.labels && summary_fields.labels.results.length > 0 && (
<Detail
fullWidth
label={i18n._(t`Labels`)}
value={
<ChipGroup numChips={5}>
{summary_fields.labels.results.map(l => (
<Chip key={l.id} isReadOnly>
{l.name}
</Chip>
))}
</ChipGroup>
}
/>
)}
{instanceGroups.length > 0 && (
<Detail
fullWidth
label={i18n._(t`Instance Groups`)}
value={
<ChipGroup numChips={5}>
{instanceGroups.map(ig => (
<Chip key={ig.id} isReadOnly>
{ig.name}
</Chip>
))}
</ChipGroup>
}
/>
)}
<Detail
fullWidth
label={
jobTagsOverridden ? (
<Overridden>* {i18n._(t`Job Tags`)}</Overridden>
) : (
i18n._(t`Job Tags`)
)
}
value={
jobTagsOverridden.length > 0 && (
<ChipGroup numChips={5}>
{jobTagsToShow.split(',').map(jobTag => (
<Chip key={jobTag} isReadOnly>
{jobTag}
</Chip>
))}
</ChipGroup>
)
}
alwaysVisible={jobTagsOverridden}
/>
<Detail
fullWidth
label={
skipTagsOverridden ? (
<Overridden>* {i18n._(t`Skip Tags`)}</Overridden>
) : (
i18n._(t`Skip Tags`)
)
}
value={
skipTagsToShow.length > 0 && (
<ChipGroup numChips={5}>
{skipTagsToShow.split(',').map(skipTag => (
<Chip key={skipTag} isReadOnly>
{skipTag}
</Chip>
))}
</ChipGroup>
)
}
alwaysVisible={skipTagsOverridden}
/>
<VariablesDetail
label={
variablesOverridden ? (
<Overridden>* {i18n._(t`Variables`)}</Overridden>
) : (
i18n._(t`Variables`)
)
}
value={variablesToShow}
rows={4}
/>
</DetailList>
{(jobTypeOverridden ||
limitOverridden ||
scmBranchOverridden ||
inventoryOverridden ||
verbosityOverridden ||
jobTagsOverridden ||
skipTagsOverridden ||
diffModeOverridden ||
credentialOverridden ||
variablesOverridden) && (
<>
<br />
<Overridden>
<b>
<Trans>
* Values for these fields differ from the job template's default
</Trans>
</b>
</Overridden>
</>
)}
</>
);
}
export default withI18n()(JobTemplateDetails);

View File

@ -0,0 +1,38 @@
import React from 'react';
import { Modal } from '@patternfly/react-core';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import ApprovalDetails from './ApprovalDetails';
import InventorySourceSyncDetails from './InventorySourceSyncDetails';
import JobTemplateDetails from './JobTemplateDetails';
import ProjectSyncDetails from './ProjectSyncDetails';
import WorkflowJobTemplateDetails from './WorkflowJobTemplateDetails';
function NodeViewModal({ i18n, onClose, node }) {
return (
<Modal
isLarge
isOpen={true}
title={i18n._(t`Node Details | ${node.unifiedJobTemplate.name}`)}
onClose={onClose}
>
{(node.unifiedJobTemplate.type === 'job_template' || node.unifiedJobTemplate.unified_job_type === 'job') && (
<JobTemplateDetails node={node} />
)}
{(node.unifiedJobTemplate.type === 'workflow_approval_template' || node.unifiedJobTemplate.unified_job_type) === 'workflow_approval' && (
<ApprovalDetails node={node} />
)}
{(node.unifiedJobTemplate.type === 'project' || node.unifiedJobTemplate.unified_job_type === 'project_update') && (
<ProjectSyncDetails node={node} />
)}
{(node.unifiedJobTemplate.type === 'inventory_source' || node.unifiedJobTemplate.unified_job_type === 'inventory_update') && (
<InventorySourceSyncDetails node={node} />
)}
{(node.unifiedJobTemplate.type === 'workflow_job_template' || node.unifiedJobTemplate.unified_job_type === 'workflow_job') && (
<WorkflowJobTemplateDetails node={node} />
)}
</Modal>
);
}
export default withI18n()(NodeViewModal);

View File

@ -0,0 +1,141 @@
import React, { useEffect, useState } from 'react';
import { withI18n } from '@lingui/react';
import { Trans } from '@lingui/macro';
import { t } from '@lingui/macro';
import { ProjectsAPI } from '@api';
import { Config } from '@contexts/Config';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import { DetailList, Detail } from '@components/DetailList';
import { CredentialChip } from '@components/Chip';
import { toTitleCase } from '@util/strings';
function ProjectSyncDetails({ i18n, node }) {
const [project, setProject] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [noReadAccess, setNoReadAccess] = useState(false);
const [contentError, setContentError] = useState(null);
useEffect(() => {
async function fetchProject() {
try {
const { data } = await ProjectsAPI.readDetail(
node.unifiedJobTemplate.id
);
setProject(data);
} catch (err) {
if (err.response.status === 403) {
setNoReadAccess(true);
} else {
setContentError(err);
}
} finally {
setIsLoading(false);
}
}
fetchProject();
}, []);
if (isLoading) {
return <ContentLoading />;
}
if (contentError) {
return <ContentError error={contentError} />;
}
if (noReadAccess) {
return (
<>
<p>
<Trans>
Your account does not have read access to this project so the
displayed details will be limited.
</Trans>
</p>
<br />
<DetailList gutter="sm">
<Detail
label={i18n._(t`Node Type`)}
value={i18n._(t`Project Sync`)}
/>
<Detail
label={i18n._(t`Name`)}
value={node.unifiedJobTemplate.name}
/>
<Detail
label={i18n._(t`Description`)}
value={node.unifiedJobTemplate.description}
/>
</DetailList>
</>
);
}
const {
custom_virtualenv,
description,
local_path,
name,
scm_branch,
scm_refspec,
scm_type,
scm_update_cache_timeout,
scm_url,
summary_fields,
} = project;
return (
<DetailList gutter="sm">
<Detail label={i18n._(t`Node Type`)} value={i18n._(t`Project Sync`)} />
<Detail label={i18n._(t`Name`)} value={name} />
<Detail label={i18n._(t`Description`)} value={description} />
{summary_fields.organization && (
<Detail
label={i18n._(t`Organization`)}
value={summary_fields.organization.name}
/>
)}
<Detail
label={i18n._(t`SCM Type`)}
value={
scm_type === '' ? i18n._(t`Manual`) : toTitleCase(project.scm_type)
}
/>
<Detail label={i18n._(t`SCM URL`)} value={scm_url} />
<Detail label={i18n._(t`SCM Branch`)} value={scm_branch} />
<Detail label={i18n._(t`SCM Refspec`)} value={scm_refspec} />
{summary_fields.credential && (
<Detail
label={i18n._(t`SCM Credential`)}
value={
<CredentialChip
key={summary_fields.credential.id}
credential={summary_fields.credential}
isReadOnly
/>
}
/>
)}
<Detail
label={i18n._(t`Cache Timeout`)}
value={`${scm_update_cache_timeout} ${i18n._(t`Seconds`)}`}
/>
<Detail
label={i18n._(t`Ansible Environment`)}
value={custom_virtualenv}
/>
<Config>
{({ project_base_dir }) => (
<Detail
label={i18n._(t`Project Base Path`)}
value={project_base_dir}
/>
)}
</Config>
<Detail label={i18n._(t`Playbook Directory`)} value={local_path} />
</DetailList>
);
}
export default withI18n()(ProjectSyncDetails);

View File

@ -0,0 +1,129 @@
import React, { useEffect, useState } from 'react';
import { withI18n } from '@lingui/react';
import { Trans } from '@lingui/macro';
import { t } from '@lingui/macro';
import { WorkflowJobTemplatesAPI } from '@api';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import { DetailList, Detail } from '@components/DetailList';
import { ChipGroup, Chip } from '@components/Chip';
import { VariablesDetail } from '@components/CodeMirrorInput';
function WorkflowJobTemplateDetails({ i18n, node }) {
const [workflowJobTemplate, setWorkflowJobTemplate] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [noReadAccess, setNoReadAccess] = useState(false);
const [contentError, setContentError] = useState(null);
useEffect(() => {
async function fetchWorkflowJobTemplate() {
try {
const { data } = await WorkflowJobTemplatesAPI.readDetail(
node.unifiedJobTemplate.id
);
setWorkflowJobTemplate(data);
} catch (err) {
if (err.response.status === 403) {
setNoReadAccess(true);
} else {
setContentError(err);
}
} finally {
setIsLoading(false);
}
}
fetchWorkflowJobTemplate();
}, []);
if (isLoading) {
return <ContentLoading />;
}
if (contentError) {
return <ContentError error={contentError} />;
}
if (noReadAccess) {
return (
<>
<p>
<Trans>
Your account does not have read access to this workflow job template
so the displayed details will be limited.
</Trans>
</p>
<br />
<DetailList gutter="sm">
<Detail
label={i18n._(t`Node Type`)}
value={i18n._(t`Workflow Job Template`)}
/>
<Detail
label={i18n._(t`Name`)}
value={node.unifiedJobTemplate.name}
/>
<Detail
label={i18n._(t`Description`)}
value={node.unifiedJobTemplate.description}
/>
</DetailList>
</>
);
}
const {
description,
extra_vars,
limit,
name,
scm_branch,
summary_fields,
} = workflowJobTemplate;
return (
<DetailList gutter="sm">
<Detail
label={i18n._(t`Node Type`)}
value={i18n._(t`Workflow Job Template`)}
/>
<Detail label={i18n._(t`Name`)} value={name} />
<Detail label={i18n._(t`Description`)} value={description} />
{summary_fields.organization && (
<Detail
label={i18n._(t`Organization`)}
value={summary_fields.organization.name}
/>
)}
{summary_fields.inventory && (
<Detail
label={i18n._(t`Inventory`)}
value={summary_fields.inventory.name}
/>
)}
<Detail label={i18n._(t`Limit`)} value={limit} />
<Detail label={i18n._(t`SCM Branch`)} value={scm_branch} />
{summary_fields.labels && summary_fields.labels.results.length > 0 && (
<Detail
fullWidth
label={i18n._(t`Labels`)}
value={
<ChipGroup numChips={5}>
{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}
/>
</DetailList>
);
}
export default withI18n()(WorkflowJobTemplateDetails);

View File

@ -16,6 +16,7 @@ import VisualizerGraph from './VisualizerGraph';
import VisualizerStartScreen from './VisualizerStartScreen';
import VisualizerToolbar from './VisualizerToolbar';
import UnsavedChangesModal from './Modals/UnsavedChangesModal';
import NodeViewModal from './Modals/NodeViewModal/NodeViewModal';
import {
WorkflowApprovalTemplatesAPI,
WorkflowJobTemplatesAPI,
@ -69,6 +70,7 @@ function Visualizer({ history, template, i18n }) {
const [nodePositions, setNodePositions] = useState(null);
const [nodeToDelete, setNodeToDelete] = useState(null);
const [nodeToEdit, setNodeToEdit] = useState(null);
const [nodeToView, setNodeToView] = useState(null);
const [addingLink, setAddingLink] = useState(false);
const [addLinkSourceNode, setAddLinkSourceNode] = useState(null);
const [addLinkTargetNode, setAddLinkTargetNode] = useState(null);
@ -825,6 +827,7 @@ function Visualizer({ history, template, i18n }) {
onStartAddLinkClick={selectSourceNodeForLinking}
onConfirmAddLinkClick={selectTargetNodeForLinking}
onCancelAddLinkClick={cancelNodeLink}
onViewNodeClick={setNodeToView}
addingLink={addingLink}
addLinkSourceNode={addLinkSourceNode}
showKey={showKey}
@ -905,6 +908,9 @@ function Visualizer({ history, template, i18n }) {
onConfirm={() => deleteAllNodes()}
/>
)}
{nodeToView && (
<NodeViewModal node={nodeToView} onClose={() => setNodeToView(null)} />
)}
</Fragment>
);
}

View File

@ -31,18 +31,6 @@ const WorkflowSVG = styled.svg`
background-color: #f6f6f6;
`;
// const KeyWrapper = styled.div`
// position: absolute;
// right: 20px;
// top: 76px;
// `;
// const ToolsWrapper = styled.div`
// position: absolute;
// right: 200px;
// top: 76px;
// `;
function VisualizerGraph({
links,
nodes,
@ -56,6 +44,7 @@ function VisualizerGraph({
onStartAddLinkClick,
onConfirmAddLinkClick,
onCancelAddLinkClick,
onViewNodeClick,
addingLink,
addLinkSourceNode,
showKey,
@ -310,6 +299,7 @@ function VisualizerGraph({
onDeleteNodeClick={onDeleteNodeClick}
onStartAddLinkClick={onStartAddLinkClick}
onConfirmAddLinkClick={onConfirmAddLinkClick}
onViewNodeClick={onViewNodeClick}
addingLink={addingLink}
isAddLinkSourceNode={
addLinkSourceNode && addLinkSourceNode.id === node.id

View File

@ -51,6 +51,7 @@ function VisualizerNode({
isAddLinkSourceNode,
onAddNodeClick,
onEditNodeClick,
onViewNodeClick,
}) {
const [hovering, setHovering] = useState(false);
@ -89,6 +90,11 @@ function VisualizerNode({
key="details"
onMouseEnter={() => updateHelpText(i18n._(t`View node details`))}
onMouseLeave={() => updateHelpText(null)}
onClick={() => {
updateHelpText(null);
setHovering(false);
onViewNodeClick(node);
}}
>
<InfoIcon />
</WorkflowActionTooltipItem>