diff --git a/awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js b/awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js index dfb434c831..512316a1ab 100644 --- a/awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js +++ b/awx/ui_next/src/api/models/WorkflowJobTemplateNodes.js @@ -51,6 +51,10 @@ class WorkflowJobTemplateNodes extends Base { disassociate: true, }); } + + readCredentials(id) { + return this.http.get(`${this.baseUrl}${id}/credentials/`); + } } export default WorkflowJobTemplateNodes; diff --git a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx index 8576e18bbe..2f6bfc227e 100644 --- a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx +++ b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx @@ -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 => diff --git a/awx/ui_next/src/components/CodeMirrorInput/VariablesDetail.jsx b/awx/ui_next/src/components/CodeMirrorInput/VariablesDetail.jsx index c2e3fe2b2c..2dae4512d5 100644 --- a/awx/ui_next/src/components/CodeMirrorInput/VariablesDetail.jsx +++ b/awx/ui_next/src/components/CodeMirrorInput/VariablesDetail.jsx @@ -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 = { diff --git a/awx/ui_next/src/components/ContentLoading/ContentLoading.jsx b/awx/ui_next/src/components/ContentLoading/ContentLoading.jsx index c737c96d44..b1c51a6b8f 100644 --- a/awx/ui_next/src/components/ContentLoading/ContentLoading.jsx +++ b/awx/ui_next/src/components/ContentLoading/ContentLoading.jsx @@ -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 }) => ( diff --git a/awx/ui_next/src/components/DetailList/Detail.jsx b/awx/ui_next/src/components/DetailList/Detail.jsx index 10be98d335..e97c20d896 100644 --- a/awx/ui_next/src/components/DetailList/Detail.jsx +++ b/awx/ui_next/src/components/DetailList/Detail.jsx @@ -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; diff --git a/awx/ui_next/src/components/SelectedList/SelectedList.jsx b/awx/ui_next/src/components/SelectedList/SelectedList.jsx index 2727fc67e6..c452c68657 100644 --- a/awx/ui_next/src/components/SelectedList/SelectedList.jsx +++ b/awx/ui_next/src/components/SelectedList/SelectedList.jsx @@ -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; `; diff --git a/awx/ui_next/src/components/Wizard/Wizard.jsx b/awx/ui_next/src/components/Wizard/Wizard.jsx new file mode 100644 index 0000000000..99e884baad --- /dev/null +++ b/awx/ui_next/src/components/Wizard/Wizard.jsx @@ -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; + } +`; diff --git a/awx/ui_next/src/components/Wizard/Wizard.test.jsx b/awx/ui_next/src/components/Wizard/Wizard.test.jsx new file mode 100644 index 0000000000..00fcbd4b51 --- /dev/null +++ b/awx/ui_next/src/components/Wizard/Wizard.test.jsx @@ -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(); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/awx/ui_next/src/components/Wizard/index.js b/awx/ui_next/src/components/Wizard/index.js new file mode 100644 index 0000000000..f07d6622b0 --- /dev/null +++ b/awx/ui_next/src/components/Wizard/index.js @@ -0,0 +1 @@ +export { default as Wizard } from './Wizard'; diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/ApprovalPreviewStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/ApprovalPreviewStep.jsx deleted file mode 100644 index b261008c25..0000000000 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/ApprovalPreviewStep.jsx +++ /dev/null @@ -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 ( -
- - {i18n._(t`Approval Node`)} - - - - - - - - -
- ); -} - -export default withI18n()(ApprovalPreviewStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/InventorySyncPreviewStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/InventorySyncPreviewStep.jsx deleted file mode 100644 index d385169117..0000000000 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/InventorySyncPreviewStep.jsx +++ /dev/null @@ -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 ( -
- - {i18n._(t`Inventory Sync Node`)} - - - - - - -
- ); -} - -export default withI18n()(InventorySyncPreviewStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/JobTemplatePreviewStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/JobTemplatePreviewStep.jsx deleted file mode 100644 index c04daa8e58..0000000000 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/JobTemplatePreviewStep.jsx +++ /dev/null @@ -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 ( -
- - {i18n._(t`Job Template Node`)} - - - - - - {/* - - {summary_fields.inventory ? ( - - ) : ( - !ask_inventory_on_launch && - renderMissingDataDetail(i18n._(t`Inventory`)) - )} - {summary_fields.project ? ( - - {summary_fields.project - ? summary_fields.project.name - : i18n._(t`Deleted`)} - - } - /> - ) : ( - renderMissingDataDetail(i18n._(t`Project`)) - )} - - - - - - {createdBy && ( - - )} - {modifiedBy && ( - - )} - - - {host_config_key && ( - - - - - )} - {renderOptionsField && ( - - )} - {summary_fields.credentials && - summary_fields.credentials.length > 0 && ( - - {summary_fields.credentials.map(c => ( - - ))} - - } - /> - )} - {summary_fields.labels && summary_fields.labels.results.length > 0 && ( - - {summary_fields.labels.results.map(l => ( - - {l.name} - - ))} - - } - /> - )} - {instanceGroups.length > 0 && ( - - {instanceGroups.map(ig => ( - - {ig.name} - - ))} - - } - /> - )} - {job_tags && job_tags.length > 0 && ( - - {job_tags.split(',').map(jobTag => ( - - {jobTag} - - ))} - - } - /> - )} - {skip_tags && skip_tags.length > 0 && ( - - {skip_tags.split(',').map(skipTag => ( - - {skipTag} - - ))} - - } - /> - )} */} - - -
- ); -} - -export default withI18n()(JobTemplatePreviewStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeApprovalStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeApprovalStep.jsx deleted file mode 100644 index b6905a077c..0000000000 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeApprovalStep.jsx +++ /dev/null @@ -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 ( -
- - {i18n._(t`Approval Node`)} - - - ( -
- - { - const isValid = - form && - (!form.touched[field.name] || !form.errors[field.name]); - - return ( - - { - updateName(value); - field.onChange(event); - }} - autoFocus - /> - - ); - }} - /> - - - ( - - { - updateDescription(value); - field.onChange(event); - }} - /> - - )} - /> - - - -
- ( - <> - { - if (!value || value === '') { - value = 0; - } - updateTimeout( - Number(value) * 60 + - Number(form.values.timeoutSeconds) - ); - field.onChange(event); - }} - /> - min - - )} - /> - ( - <> - { - if (!value || value === '') { - value = 0; - } - updateTimeout( - Number(value) + - Number(form.values.timeoutMinutes) * 60 - ); - field.onChange(event); - }} - /> - sec - - )} - /> -
-
-
-
- )} - /> -
- ); -} - -export default withI18n()(NodeApprovalStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeModal.jsx index 777e73d7fa..ed7bd6d546 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeModal.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeModal.jsx @@ -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: ( + + ), + 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: ( ), - enableNext: nodeType !== null, }, - ...(showResourceStep - ? [ - { - name: i18n._(t`Select Node Resource`), - key: 'node_resource', - enableNext: nodeResource !== null, - component: ( - - ), - }, - ] - : []), - ...(showApprovalStep - ? [ - { - name: i18n._(t`Configure Approval`), - key: 'approval', - component: ( - - ), - enableNext: approvalName !== '', - }, - ] - : []), - ...(showPreviewStep - ? [ - { - name: i18n._(t`Preview`), - key: 'preview', - component: ( - <> - {nodeType === 'approval' && ( - - )} - {nodeType === 'job_template' && ( - - )} - {nodeType === 'inventory_source_sync' && ( - - )} - {nodeType === 'project_sync' && ( - - )} - {nodeType === 'workflow_job_template' && ( - - )} - - ), - enableNext: true, - }, - ] - : []), ]; steps.forEach((step, n) => { @@ -272,20 +167,25 @@ function NodeModal({ i18n, title, onClose, onSave, node, askLinkType }) { const CustomFooter = ( - {({ activeStep, onNext, onBack, onClose }) => ( + {({ activeStep, onNext, onBack }) => ( <> setTriggerNext(triggerNext + 1)} + buttonText={ + activeStep.key === 'node_resource' + ? i18n._(t`Save`) + : i18n._(t`Next`) + } /> {activeStep && activeStep.id !== 1 && ( )} - @@ -294,17 +194,19 @@ function NodeModal({ i18n, title, onClose, onSave, node, askLinkType }) { ); + const wizardTitle = nodeResource ? `${title} | ${nodeResource.name}` : title; + return ( ); } -export default withI18n()(NodeModal); +export default withI18n()(withRouter(NodeModal)); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeNextButton.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeNextButton.jsx index 0d617ec821..a941cb33da 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeNextButton.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeNextButton.jsx @@ -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} ); } diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeResourceStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeResourceStep.jsx deleted file mode 100644 index 623dae669c..0000000000 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeResourceStep.jsx +++ /dev/null @@ -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 ( - - - {headerText} - -

{i18n._(t`Select a resource to be executed from the list below.`)}

- {nodeResource && ( - updateNodeResource(null)} - selected={[nodeResource]} - /> - )} - ( - updateNodeResource(item)} - onDeselect={() => updateNodeResource(null)} - isRadio={true} - /> - )} - renderToolbar={props => } - showPageSizeOptions={false} - /> -
- ); -} - -export default withI18n()(NodeTypeStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep.jsx deleted file mode 100644 index 6d0e84fd34..0000000000 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep.jsx +++ /dev/null @@ -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 ( -
- {askLinkType && ( - <> - - {i18n._(t`Run`)} - -

- {i18n._( - t`Specify the conditions under which this node should be executed` - )} -

- - updateLinkType('success')} - /> - updateLinkType('failure')} - /> - updateLinkType('always')} - /> - - - )} - - {i18n._(t`Node Type`)} - - - updateNodeType('job_template')} - /> - updateNodeType('workflow_job_template')} - /> - updateNodeType('project_sync')} - /> - updateNodeType('inventory_source_sync')} - /> - updateNodeType('approval')} - /> - -
- ); -} - -export default withI18n()(NodeTypeStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/InventorySourcesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/InventorySourcesList.jsx new file mode 100644 index 0000000000..7cb0db0347 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/InventorySourcesList.jsx @@ -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 ( + ( + updateNodeResource(item)} + onDeselect={() => updateNodeResource(null)} + isRadio={true} + /> + )} + renderToolbar={props => } + showPageSizeOptions={false} + /> + ); +} + +export default withI18n()(withRouter(InventorySourcesList)); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/JobTemplatesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/JobTemplatesList.jsx new file mode 100644 index 0000000000..01633cb1d5 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/JobTemplatesList.jsx @@ -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 ( + ( + updateNodeResource(item)} + onDeselect={() => updateNodeResource(null)} + isRadio={true} + /> + )} + renderToolbar={props => } + showPageSizeOptions={false} + /> + ); +} + +export default withI18n()(withRouter(JobTemplatesList)); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/NodeTypeStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/NodeTypeStep.jsx new file mode 100644 index 0000000000..8052571bc1 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/NodeTypeStep.jsx @@ -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 ( + <> +
+ {i18n._(t`Node Type`)} + +
+ { + updateNodeType(val); + }} + /> +
+
+ + {nodeType === 'job_template' && ( + + )} + {nodeType === 'project_sync' && ( + + )} + {nodeType === 'inventory_source_sync' && ( + + )} + {nodeType === 'workflow_job_template' && ( + + )} + {nodeType === 'approval' && ( + ( +
+ + { + const isValid = + form && + (!form.touched[field.name] || !form.errors[field.name]); + + return ( + + { + updateName(value); + field.onChange(event); + }} + autoFocus + /> + + ); + }} + /> + + + ( + + { + updateDescription(value); + field.onChange(event); + }} + /> + + )} + /> + + + +
+ ( + <> + { + if (!value || value === '') { + value = 0; + } + updateTimeout( + Number(value) * 60 + + Number(form.values.timeoutSeconds) + ); + field.onChange(event); + }} + /> + min + + )} + /> + ( + <> + { + if (!value || value === '') { + value = 0; + } + updateTimeout( + Number(value) + + Number(form.values.timeoutMinutes) * 60 + ); + field.onChange(event); + }} + /> + sec + + )} + /> +
+
+
+
+ )} + /> + )} + + ); +} + +export default withI18n()(NodeTypeStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/ProjectsList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/ProjectsList.jsx new file mode 100644 index 0000000000..5b428f98fe --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/ProjectsList.jsx @@ -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 ( + ( + updateNodeResource(item)} + onDeselect={() => updateNodeResource(null)} + isRadio={true} + /> + )} + renderToolbar={props => } + showPageSizeOptions={false} + /> + ); +} + +export default withI18n()(withRouter(ProjectsList)); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/WorkflowJobTemplatesList.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/WorkflowJobTemplatesList.jsx new file mode 100644 index 0000000000..c988dd34dd --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/NodeTypeStep/WorkflowJobTemplatesList.jsx @@ -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 ( + ( + updateNodeResource(item)} + onDeselect={() => updateNodeResource(null)} + isRadio={true} + /> + )} + renderToolbar={props => } + showPageSizeOptions={false} + /> + ); +} + +export default withI18n()(withRouter(WorkflowJobTemplatesList)); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/ProjectSyncPreviewStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/ProjectSyncPreviewStep.jsx deleted file mode 100644 index 596e6eb905..0000000000 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/ProjectSyncPreviewStep.jsx +++ /dev/null @@ -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 ( -
- - {i18n._(t`Project Sync Node`)} - - - - - - -
- ); -} - -export default withI18n()(ProjectPreviewStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/RunStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/RunStep.jsx new file mode 100644 index 0000000000..ce65f27cdd --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/RunStep.jsx @@ -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 ( + <> + + {i18n._(t`Run`)} + +

+ {i18n._( + t`Specify the conditions under which this node should be executed` + )} +

+ + updateLinkType('success')} + /> + updateLinkType('failure')} + /> + updateLinkType('always')} + /> + + + ); +} + +export default withI18n()(RunStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/WorkflowJobTemplatePreviewStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/WorkflowJobTemplatePreviewStep.jsx deleted file mode 100644 index d016248c62..0000000000 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModal/WorkflowJobTemplatePreviewStep.jsx +++ /dev/null @@ -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 ( -
- - {i18n._(t`Workflow Job Template Node`)} - - - - - - -
- ); -} - -export default withI18n()(WorkflowJobTemplatePreviewStep); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/ApprovalDetails.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/ApprovalDetails.jsx new file mode 100644 index 0000000000..a99413f471 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/ApprovalDetails.jsx @@ -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 ( + + + + + + + ); +} + +export default withI18n()(ApprovalDetails); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/InventorySourceSyncDetails.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/InventorySourceSyncDetails.jsx new file mode 100644 index 0000000000..510d9e591a --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/InventorySourceSyncDetails.jsx @@ -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 ; + } + + if (contentError) { + return ; + } + + if (noReadAccess) { + return ( + <> +

+ + Your account does not have read access to this inventory source so + the displayed details will be limited. + +

+
+ + + + + + + ); + } + + 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 ( + + + + + {summary_fields.inventory && ( + + )} + {summary_fields.credential && ( + + } + /> + )} + + + + {/* this should probably be tags built from OPTIONS*/} + + + {/* this should probably be tags built from OPTIONS */} + + + + + + + ); +} + +export default withI18n()(InventorySourceSyncDetails); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/JobTemplateDetails.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/JobTemplateDetails.jsx new file mode 100644 index 0000000000..f93b277b15 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/JobTemplateDetails.jsx @@ -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 ; + } + + if (contentError) { + return ; + } + + if (noReadAccess) { + return ( + <> +

+ + Your account does not have read access to this job template so the + displayed details will be limited. + +

+
+ + + + + + + ); + } + + 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 ( + <> + + + + + * {i18n._(t`Job Type`)} + ) : ( + i18n._(t`Job Type`) + ) + } + value={jobTypeOverridden ? nodeJobType : job_type} + /> + * {i18n._(t`Inventory`)} + ) : ( + i18n._(t`Inventory`) + ) + } + value={ + inventoryOverridden + ? nodeSummaryFields.inventory.name + : summary_fields.inventory.name + } + alwaysVisible={inventoryOverridden} + /> + {summary_fields.project && ( + + )} + * {i18n._(t`SCM Branch`)} + ) : ( + i18n._(t`SCM Branch`) + ) + } + value={scmBranchOverridden ? nodeScmBranch : scm_branch} + alwaysVisible={scmBranchOverridden} + /> + + + * {i18n._(t`Limit`)} + ) : ( + i18n._(t`Limit`) + ) + } + value={limitOverridden ? nodeLimit : limit} + alwaysVisible={limitOverridden} + /> + * {i18n._(t`Verbosity`)} + ) : ( + i18n._(t`Verbosity`) + ) + } + value={verbosityToShow} + /> + + * {i18n._(t`Show Changes`)} + ) : ( + i18n._(t`Show Changes`) + ) + } + value={ + (diffModeOverridden + ? nodeDiffMode + : diff_mode) + ? i18n._(t`On`) + : i18n._(t`Off`) + } + /> + + {host_config_key && ( + <> + + + + )} + * {i18n._(t`Credentials`)} + ) : ( + i18n._(t`Credentials`) + ) + } + value={ + credentialsToShow.length > 0 && ( + + {credentialsToShow.map(c => ( + + ))} + + ) + } + alwaysVisible={credentialOverridden} + /> + {summary_fields.labels && summary_fields.labels.results.length > 0 && ( + + {summary_fields.labels.results.map(l => ( + + {l.name} + + ))} + + } + /> + )} + {instanceGroups.length > 0 && ( + + {instanceGroups.map(ig => ( + + {ig.name} + + ))} + + } + /> + )} + * {i18n._(t`Job Tags`)} + ) : ( + i18n._(t`Job Tags`) + ) + } + value={ + jobTagsOverridden.length > 0 && ( + + {jobTagsToShow.split(',').map(jobTag => ( + + {jobTag} + + ))} + + ) + } + alwaysVisible={jobTagsOverridden} + /> + * {i18n._(t`Skip Tags`)} + ) : ( + i18n._(t`Skip Tags`) + ) + } + value={ + skipTagsToShow.length > 0 && ( + + {skipTagsToShow.split(',').map(skipTag => ( + + {skipTag} + + ))} + + ) + } + alwaysVisible={skipTagsOverridden} + /> + * {i18n._(t`Variables`)} + ) : ( + i18n._(t`Variables`) + ) + } + value={variablesToShow} + rows={4} + /> + + {(jobTypeOverridden || + limitOverridden || + scmBranchOverridden || + inventoryOverridden || + verbosityOverridden || + jobTagsOverridden || + skipTagsOverridden || + diffModeOverridden || + credentialOverridden || + variablesOverridden) && ( + <> +
+ + + + * Values for these fields differ from the job template's default + + + + + )} + + ); +} + +export default withI18n()(JobTemplateDetails); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/NodeViewModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/NodeViewModal.jsx new file mode 100644 index 0000000000..887740d804 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/NodeViewModal.jsx @@ -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 ( + + {(node.unifiedJobTemplate.type === 'job_template' || node.unifiedJobTemplate.unified_job_type === 'job') && ( + + )} + {(node.unifiedJobTemplate.type === 'workflow_approval_template' || node.unifiedJobTemplate.unified_job_type) === 'workflow_approval' && ( + + )} + {(node.unifiedJobTemplate.type === 'project' || node.unifiedJobTemplate.unified_job_type === 'project_update') && ( + + )} + {(node.unifiedJobTemplate.type === 'inventory_source' || node.unifiedJobTemplate.unified_job_type === 'inventory_update') && ( + + )} + {(node.unifiedJobTemplate.type === 'workflow_job_template' || node.unifiedJobTemplate.unified_job_type === 'workflow_job') && ( + + )} + + ); +} + +export default withI18n()(NodeViewModal); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/ProjectSyncDetails.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/ProjectSyncDetails.jsx new file mode 100644 index 0000000000..759c612940 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/ProjectSyncDetails.jsx @@ -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 ; + } + + if (contentError) { + return ; + } + + if (noReadAccess) { + return ( + <> +

+ + Your account does not have read access to this project so the + displayed details will be limited. + +

+
+ + + + + + + ); + } + + const { + custom_virtualenv, + description, + local_path, + name, + scm_branch, + scm_refspec, + scm_type, + scm_update_cache_timeout, + scm_url, + summary_fields, + } = project; + + return ( + + + + + {summary_fields.organization && ( + + )} + + + + + {summary_fields.credential && ( + + } + /> + )} + + + + {({ project_base_dir }) => ( + + )} + + + + ); +} + +export default withI18n()(ProjectSyncDetails); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/WorkflowJobTemplateDetails.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/WorkflowJobTemplateDetails.jsx new file mode 100644 index 0000000000..5142fd21d3 --- /dev/null +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeViewModal/WorkflowJobTemplateDetails.jsx @@ -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 ; + } + + if (contentError) { + return ; + } + + if (noReadAccess) { + return ( + <> +

+ + Your account does not have read access to this workflow job template + so the displayed details will be limited. + +

+
+ + + + + + + ); + } + + const { + description, + extra_vars, + limit, + name, + scm_branch, + summary_fields, + } = workflowJobTemplate; + + return ( + + + + + {summary_fields.organization && ( + + )} + {summary_fields.inventory && ( + + )} + + + {summary_fields.labels && summary_fields.labels.results.length > 0 && ( + + {summary_fields.labels.results.map(l => ( + + {l.name} + + ))} + + } + /> + )} + + + ); +} + +export default withI18n()(WorkflowJobTemplateDetails); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx index ef3041dbbf..0f2c222d08 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Visualizer.jsx @@ -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 && ( + setNodeToView(null)} /> + )} ); } diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx index b9eda4f24e..93a4457d15 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerGraph.jsx @@ -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 diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx index fa7f85d855..3ce9aaf22a 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/VisualizerNode.jsx @@ -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); + }} >