From 0a00a3104af546294e87cde18ae6a47b19497397 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Thu, 2 Apr 2020 13:55:30 -0700 Subject: [PATCH 1/9] add CredentialTypesAPI.loadAllTypes helper function --- awx/ui_next/src/api/models/CredentialTypes.js | 22 +++++++ .../src/api/models/CredentialTypes.test.js | 65 +++++++++++++++++++ .../Lookup/MultiCredentialsLookup.jsx | 23 +------ .../Lookup/MultiCredentialsLookup.test.jsx | 32 ++++----- 4 files changed, 102 insertions(+), 40 deletions(-) create mode 100644 awx/ui_next/src/api/models/CredentialTypes.test.js diff --git a/awx/ui_next/src/api/models/CredentialTypes.js b/awx/ui_next/src/api/models/CredentialTypes.js index 65906cdcbd..d2d993091c 100644 --- a/awx/ui_next/src/api/models/CredentialTypes.js +++ b/awx/ui_next/src/api/models/CredentialTypes.js @@ -5,6 +5,28 @@ class CredentialTypes extends Base { super(http); this.baseUrl = '/api/v2/credential_types/'; } + + async loadAllTypes( + acceptableKinds = ['machine', 'cloud', 'net', 'ssh', 'vault'] + ) { + const pageSize = 200; + // The number of credential types a user can have is unlimited. In practice, it is unlikely for + // users to have more than a page at the maximum request size. + const { + data: { next, results }, + } = await this.read({ page_size: pageSize }); + let nextResults = []; + if (next) { + const { data } = await this.read({ + page_size: pageSize, + page: 2, + }); + nextResults = data.results; + } + return results + .concat(nextResults) + .filter(type => acceptableKinds.includes(type.kind)); + } } export default CredentialTypes; diff --git a/awx/ui_next/src/api/models/CredentialTypes.test.js b/awx/ui_next/src/api/models/CredentialTypes.test.js new file mode 100644 index 0000000000..5cb038912e --- /dev/null +++ b/awx/ui_next/src/api/models/CredentialTypes.test.js @@ -0,0 +1,65 @@ +import CredentialTypes from './CredentialTypes'; + +const typesData = [{ id: 1, kind: 'machine' }, { id: 2, kind: 'cloud' }]; + +describe('CredentialTypesAPI', () => { + test('should load all types', async () => { + const getPromise = () => + Promise.resolve({ + data: { + results: typesData, + }, + }); + const mockHttp = { get: jest.fn(getPromise) }; + const CredentialTypesAPI = new CredentialTypes(mockHttp); + + const types = await CredentialTypesAPI.loadAllTypes(); + + expect(mockHttp.get).toHaveBeenCalledTimes(1); + expect(mockHttp.get.mock.calls[0]).toEqual([ + `/api/v2/credential_types/`, + { params: { page_size: 200 } }, + ]); + expect(types).toEqual(typesData); + }); + + test('should load all types (2 pages)', async () => { + const getPromise = () => + Promise.resolve({ + data: { + results: typesData, + next: 2, + }, + }); + const mockHttp = { get: jest.fn(getPromise) }; + const CredentialTypesAPI = new CredentialTypes(mockHttp); + + const types = await CredentialTypesAPI.loadAllTypes(); + + expect(mockHttp.get).toHaveBeenCalledTimes(2); + expect(mockHttp.get.mock.calls[0]).toEqual([ + `/api/v2/credential_types/`, + { params: { page_size: 200 } }, + ]); + expect(mockHttp.get.mock.calls[1]).toEqual([ + `/api/v2/credential_types/`, + { params: { page_size: 200, page: 2 } }, + ]); + expect(types).toHaveLength(4); + }); + + test('should filter by acceptable kinds', async () => { + const getPromise = () => + Promise.resolve({ + data: { + results: typesData, + }, + }); + const mockHttp = { get: jest.fn(getPromise) }; + const CredentialTypesAPI = new CredentialTypes(mockHttp); + + const types = await CredentialTypesAPI.loadAllTypes(['machine']); + + expect(types).toEqual([typesData[0]]); + }); +}); diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx index 58bdf359cb..c153b1d610 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx @@ -17,27 +17,6 @@ const QS_CONFIG = getQSConfig('credentials', { order_by: 'name', }); -async function loadCredentialTypes() { - const pageSize = 200; - const acceptableKinds = ['machine', 'cloud', 'net', 'ssh', 'vault']; - // The number of credential types a user can have is unlimited. In practice, it is unlikely for - // users to have more than a page at the maximum request size. - const { - data: { next, results }, - } = await CredentialTypesAPI.read({ page_size: pageSize }); - let nextResults = []; - if (next) { - const { data } = await CredentialTypesAPI.read({ - page_size: pageSize, - page: 2, - }); - nextResults = data.results; - } - return results - .concat(nextResults) - .filter(type => acceptableKinds.includes(type.kind)); -} - async function loadCredentials(params, selectedCredentialTypeId) { params.credential_type = selectedCredentialTypeId || 1; const { data } = await CredentialsAPI.read(params); @@ -54,7 +33,7 @@ function MultiCredentialsLookup(props) { useEffect(() => { (async () => { try { - const types = await loadCredentialTypes(); + const types = await CredentialTypesAPI.loadAllTypes(); setCredentialTypes(types); const match = types.find(type => type.kind === 'ssh') || types[0]; setSelectedType(match); diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx index fa8eb5a15f..08fcf87b18 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.test.jsx @@ -18,21 +18,16 @@ describe('', () => { ]; beforeEach(() => { - CredentialTypesAPI.read.mockResolvedValueOnce({ - data: { - results: [ - { - id: 400, - kind: 'ssh', - namespace: 'biz', - name: 'Amazon Web Services', - }, - { id: 500, kind: 'vault', namespace: 'buzz', name: 'Vault' }, - { id: 600, kind: 'machine', namespace: 'fuzz', name: 'Machine' }, - ], - count: 2, + CredentialTypesAPI.loadAllTypes.mockResolvedValueOnce([ + { + id: 400, + kind: 'ssh', + namespace: 'biz', + name: 'Amazon Web Services', }, - }); + { id: 500, kind: 'vault', namespace: 'buzz', name: 'Vault' }, + { id: 600, kind: 'machine', namespace: 'fuzz', name: 'Machine' }, + ]); CredentialsAPI.read.mockResolvedValueOnce({ data: { results: [ @@ -52,7 +47,7 @@ describe('', () => { wrapper.unmount(); }); - test('MultiCredentialsLookup renders properly', async () => { + test('should load credential types', async () => { const onChange = jest.fn(); await act(async () => { wrapper = mountWithContexts( @@ -64,8 +59,9 @@ describe('', () => { /> ); }); + wrapper.update(); expect(wrapper.find('MultiCredentialsLookup')).toHaveLength(1); - expect(CredentialTypesAPI.read).toHaveBeenCalled(); + expect(CredentialTypesAPI.loadAllTypes).toHaveBeenCalled(); }); test('onChange is called when you click to remove a credential from input', async () => { @@ -118,12 +114,12 @@ describe('', () => { count: 1, }, }); - expect(CredentialsAPI.read).toHaveBeenCalledTimes(2); + expect(CredentialsAPI.read).toHaveBeenCalledTimes(1); await act(async () => { select.invoke('onChange')({}, 500); }); wrapper.update(); - expect(CredentialsAPI.read).toHaveBeenCalledTimes(3); + expect(CredentialsAPI.read).toHaveBeenCalledTimes(2); expect(wrapper.find('OptionsList').prop('options')).toEqual([ { id: 1, kind: 'cloud', name: 'New Cred', url: 'www.google.com' }, ]); From 0b9c5c410a4a0b81d24c4eff5e75b8ec4190fc2a Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Thu, 2 Apr 2020 16:29:40 -0700 Subject: [PATCH 2/9] add credential select list to launch CredentialsStep --- .../LaunchPrompt/CredentialsStep.jsx | 165 +++++++++++++++++- .../components/LaunchPrompt/InventoryStep.jsx | 2 +- 2 files changed, 162 insertions(+), 5 deletions(-) diff --git a/awx/ui_next/src/components/LaunchPrompt/CredentialsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/CredentialsStep.jsx index 10872e311c..8d68995197 100644 --- a/awx/ui_next/src/components/LaunchPrompt/CredentialsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/CredentialsStep.jsx @@ -1,7 +1,164 @@ -import React from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { useField } from 'formik'; +import { ToolbarItem } from '@patternfly/react-core'; +import { CredentialsAPI, CredentialTypesAPI } from '@api'; +import AnsibleSelect from '@components/AnsibleSelect'; +import OptionsList from '@components/OptionsList'; +import ContentLoading from '@components/ContentLoading'; +import CredentialChip from '@components/CredentialChip'; +import { getQSConfig, parseQueryString } from '@util/qs'; +import useRequest from '@util/useRequest'; -function CredentialsStep() { - return
; +const QS_CONFIG = getQSConfig('inventory', { + page: 1, + page_size: 5, + order_by: 'name', +}); + +function CredentialsStep({ i18n }) { + const [field, , helpers] = useField('credentials'); + const [selectedType, setSelectedType] = useState(null); + const [selectedItems, setSelectedItems] = useState([]); + const history = useHistory(); + + const isTypeSelected = !!selectedType; + const { + result: types, + error: typesError, + isLoading: isTypesLoading, + request: fetchTypes, + } = useRequest( + useCallback(async () => { + const loadedTypes = await CredentialTypesAPI.loadAllTypes(); + if (!isTypeSelected && loadedTypes.length) { + const match = + loadedTypes.find(type => type.kind === 'ssh') || loadedTypes[0]; + setSelectedType(match); + } + return loadedTypes; + }, [isTypeSelected]), + [] + ); + + useEffect(() => { + fetchTypes(); + }, [fetchTypes]); + + const { + result: { credentials, count }, + error: credentialsError, + isLoading: isCredentialsLoading, + request: fetchCredentials, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, history.location.search); + const { data } = await CredentialsAPI.read({ + ...params, + credential_type: selectedType.id || 1, + }); + return { + credentials: data.results, + count: data.count, + }; + }, [selectedType, history.location.search]), + { credentials: [], count: 0 } + ); + + useEffect(() => { + fetchCredentials(); + }, [fetchCredentials]); + + if (isTypesLoading) { + return ; + } + + const isVault = selectedType?.kind === 'vault'; + + const renderChip = ({ item, removeItem, canDelete }) => ( + removeItem(item)} + isReadOnly={!canDelete} + credential={item} + /> + ); + + return ( + <> + {types && types.length > 0 && ( + +
+ {i18n._(t`Selected Category`)} +
+ ({ + key: type.id, + value: type.id, + label: type.name, + isDisabled: false, + }))} + value={selectedType && selectedType.id} + onChange={(e, id) => { + setSelectedType(types.find(o => o.id === parseInt(id, 10))); + }} + /> +
+ )} + { + const hasSameVaultID = val => + val?.inputs?.vault_id !== undefined && + val?.inputs?.vault_id === item?.inputs?.vault_id; + const hasSameKind = val => val.kind === item.kind; + const newItems = selectedItems.filter(i => + isVault ? !hasSameVaultID(i) : !hasSameKind(i) + ); + newItems.push(item); + setSelectedItems(newItems); + }} + deselectItem={item => { + setSelectedItems(selectedItems.filter(i => i.id !== item.id)); + }} + renderItemChip={renderChip} + /> + ) + + ); } -export default CredentialsStep; +export default withI18n()(CredentialsStep); diff --git a/awx/ui_next/src/components/LaunchPrompt/InventoryStep.jsx b/awx/ui_next/src/components/LaunchPrompt/InventoryStep.jsx index 696dc445bc..b35a9f4090 100644 --- a/awx/ui_next/src/components/LaunchPrompt/InventoryStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/InventoryStep.jsx @@ -16,8 +16,8 @@ const QS_CONFIG = getQSConfig('inventory', { }); function InventoryStep({ i18n }) { - const history = useHistory(); const [field, , helpers] = useField('inventory'); + const history = useHistory(); const { isLoading, From 8baa9d8458ca7242d1157d8bbc93644f0d3bc34a Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Fri, 3 Apr 2020 11:47:06 -0700 Subject: [PATCH 3/9] clean up launch prompt credentials, display errors --- .../components/ContentError/ContentError.jsx | 1 + .../LaunchPrompt/CredentialsStep.jsx | 106 ++++++++++-------- .../components/LaunchPrompt/InventoryStep.jsx | 6 +- .../components/LaunchPrompt/LaunchPrompt.jsx | 3 + 4 files changed, 66 insertions(+), 50 deletions(-) diff --git a/awx/ui_next/src/components/ContentError/ContentError.jsx b/awx/ui_next/src/components/ContentError/ContentError.jsx index 7f766f7e91..f6fb2d825c 100644 --- a/awx/ui_next/src/components/ContentError/ContentError.jsx +++ b/awx/ui_next/src/components/ContentError/ContentError.jsx @@ -25,6 +25,7 @@ function ContentError({ error, children, isNotFound, i18n }) { return null; } } + console.error(error); const is404 = isNotFound || (error && error.response && error.response.status === 404); const is401 = error && error.response && error.response.status === 401; diff --git a/awx/ui_next/src/components/LaunchPrompt/CredentialsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/CredentialsStep.jsx index 8d68995197..f86513a550 100644 --- a/awx/ui_next/src/components/LaunchPrompt/CredentialsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/CredentialsStep.jsx @@ -9,6 +9,7 @@ import AnsibleSelect from '@components/AnsibleSelect'; import OptionsList from '@components/OptionsList'; import ContentLoading from '@components/ContentLoading'; import CredentialChip from '@components/CredentialChip'; +import ContentError from '@components/ContentError'; import { getQSConfig, parseQueryString } from '@util/qs'; import useRequest from '@util/useRequest'; @@ -21,7 +22,6 @@ const QS_CONFIG = getQSConfig('inventory', { function CredentialsStep({ i18n }) { const [field, , helpers] = useField('credentials'); const [selectedType, setSelectedType] = useState(null); - const [selectedItems, setSelectedItems] = useState([]); const history = useHistory(); const isTypeSelected = !!selectedType; @@ -54,10 +54,13 @@ function CredentialsStep({ i18n }) { request: fetchCredentials, } = useRequest( useCallback(async () => { + if (!selectedType) { + return { credentials: [], count: 0 }; + } const params = parseQueryString(QS_CONFIG, history.location.search); const { data } = await CredentialsAPI.read({ ...params, - credential_type: selectedType.id || 1, + credential_type: selectedType.id, }); return { credentials: data.results, @@ -75,6 +78,10 @@ function CredentialsStep({ i18n }) { return ; } + if (typesError || credentialsError) { + return ; + } + const isVault = selectedType?.kind === 'vault'; const renderChip = ({ item, removeItem, canDelete }) => ( @@ -110,53 +117,54 @@ function CredentialsStep({ i18n }) { /> )} - { - const hasSameVaultID = val => - val?.inputs?.vault_id !== undefined && - val?.inputs?.vault_id === item?.inputs?.vault_id; - const hasSameKind = val => val.kind === item.kind; - const newItems = selectedItems.filter(i => - isVault ? !hasSameVaultID(i) : !hasSameKind(i) - ); - newItems.push(item); - setSelectedItems(newItems); - }} - deselectItem={item => { - setSelectedItems(selectedItems.filter(i => i.id !== item.id)); - }} - renderItemChip={renderChip} - /> - ) + {!isCredentialsLoading && ( + { + const hasSameVaultID = val => + val?.inputs?.vault_id !== undefined && + val?.inputs?.vault_id === item?.inputs?.vault_id; + const hasSameKind = val => val.kind === item.kind; + const newItems = field.value.filter(i => + isVault ? !hasSameVaultID(i) : !hasSameKind(i) + ); + newItems.push(item); + helpers.setValue(newItems); + }} + deselectItem={item => { + helpers.setValue(field.value.filter(i => i.id !== item.id)); + }} + renderItemChip={renderChip} + /> + )} ); } diff --git a/awx/ui_next/src/components/LaunchPrompt/InventoryStep.jsx b/awx/ui_next/src/components/LaunchPrompt/InventoryStep.jsx index b35a9f4090..e34dc40664 100644 --- a/awx/ui_next/src/components/LaunchPrompt/InventoryStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/InventoryStep.jsx @@ -8,6 +8,7 @@ import { getQSConfig, parseQueryString } from '@util/qs'; import useRequest from '@util/useRequest'; import OptionsList from '@components/OptionsList'; import ContentLoading from '@components/ContentLoading'; +import ContentError from '@components/ContentError'; const QS_CONFIG = getQSConfig('inventory', { page: 1, @@ -21,7 +22,7 @@ function InventoryStep({ i18n }) { const { isLoading, - // error, + error, result: { inventories, count }, request: fetchInventories, } = useRequest( @@ -46,6 +47,9 @@ function InventoryStep({ i18n }) { if (isLoading) { return ; } + if (error) { + return ; + } return ( c.id); + } onLaunch(postValues); }; From 9c218fa5f5bb9fcd1dbeaef5458817769d59465e Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Mon, 6 Apr 2020 17:31:52 -0700 Subject: [PATCH 4/9] flush out prompt misc fields --- .../components/LaunchPrompt/LaunchPrompt.jsx | 23 ++-- .../LaunchPrompt/OtherPromptsStep.jsx | 125 +++++++++++++++++- 2 files changed, 131 insertions(+), 17 deletions(-) diff --git a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx index 3743168953..a932aee367 100644 --- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx @@ -19,14 +19,6 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) { component: , }); } - // TODO: match old UI Logic: - // if (vm.promptDataClone.launchConf.ask_credential_on_launch || - // (_.has(vm, 'promptDataClone.prompts.credentials.passwords.vault') && - // vm.promptDataClone.prompts.credentials.passwords.vault.length > 0) || - // _.has(vm, 'promptDataClone.prompts.credentials.passwords.ssh_key_unlock') || - // _.has(vm, 'promptDataClone.prompts.credentials.passwords.become_password') || - // _.has(vm, 'promptDataClone.prompts.credentials.passwords.ssh_password') - // ) { if (config.ask_credential_on_launch) { initialValues.credentials = resource?.summary_fields?.credentials || []; steps.push({ @@ -34,15 +26,18 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) { component: , }); } + + // TODO: Add Credential Passwords step + if ( - config.ask_scm_branch_on_launch || - (config.ask_variables_on_launch && !config.ignore_ask_variables) || - config.ask_tags_on_launch || - config.ask_diff_mode_on_launch || - config.ask_skip_tags_on_launch || config.ask_job_type_on_launch || config.ask_limit_on_launch || - config.ask_verbosity_on_launch + config.ask_verbosity_on_launch || + config.ask_tags_on_launch || + config.ask_skip_tags_on_launch || + config.ask_variables_on_launch || + config.ask_scm_branch_on_launch || + config.ask_diff_mode_on_launch ) { steps.push({ name: i18n._(t`Other Prompts`), diff --git a/awx/ui_next/src/components/LaunchPrompt/OtherPromptsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/OtherPromptsStep.jsx index c0b8e6ec14..826f90c86f 100644 --- a/awx/ui_next/src/components/LaunchPrompt/OtherPromptsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/OtherPromptsStep.jsx @@ -1,7 +1,126 @@ import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { useField } from 'formik'; +import { FormGroup } from '@patternfly/react-core'; +import FormField, { FieldTooltip } from '@components/FormField'; +import { TagMultiSelect } from '@components/MultiSelect'; +import AnsibleSelect from '@components/AnsibleSelect'; +import { VariablesField } from '@components/CodeMirrorInput'; -function InventoryStep() { - return
; +function OtherPromptsStep({ config, i18n }) { + return ( + <> + {config.ask_job_type_on_launch && ( + + )} + {config.ask_limit_on_launch && ( + + )} + {config.ask_verbosity_on_launch && } + {/* TODO: Show Changes toggle? */} + {config.ask_tags_on_launch && ( + + )} + {config.ask_skip_tags_on_launch && ( + + )} + {config.ask_variables_on_launch && ( + + )} + + ); } -export default InventoryStep; +function VerbosityField({ i18n }) { + const [field, meta] = useField('verbosity'); + const options = [ + { value: '0', key: '0', label: i18n._(t`0 (Normal)`) }, + { value: '1', key: '1', label: i18n._(t`1 (Verbose)`) }, + { value: '2', key: '2', label: i18n._(t`2 (More Verbose)`) }, + { value: '3', key: '3', label: i18n._(t`3 (Debug)`) }, + { value: '4', key: '4', label: i18n._(t`4 (Connection Debug)`) }, + ]; + const isValid = !(meta.touched && meta.error); + + return ( + + + + + ); +} + +function TagField({ id, name, label, tooltip }) { + const [field, , helpers] = useField(name); + return ( + + + + + ); +} + +/* + tooltips: + verbosity: Control the level of output ansible will produce as the playbook executes. + job tags: Tags are useful when you have a large playbook, and you want to run a specific part of a play or task. Use commas to separate multiple tags. Refer to Ansible Tower documentation for details on the usage of tags. + skip tags: Skip tags are useful when you have a large playbook, and you want to skip specific parts of a play or task. Use commas to separate multiple tags. Refer to Ansible Tower documentation for details on the usage of tags. + show changes: If enabled, show the changes made by Ansible tasks, where supported. This is equivalent to Ansible’s --diff mode. + extra variables: Pass extra command line variables to the playbook. This is the -e or --extra-vars command line parameter for ansible-playbook. Provide key/value pairs using either YAML or JSON. + + JSON: + { + "somevar": "somevalue", + "password": "magic" + } + YAML: + --- + somevar: somevalue + password: magic +*/ + +export default withI18n()(OtherPromptsStep); From 9cab5a50467f5cd5a0976e457599e964942a1531 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Wed, 8 Apr 2020 08:58:14 -0700 Subject: [PATCH 5/9] add 'other prompt' fields to launch API call --- .../components/LaunchButton/LaunchButton.jsx | 4 +- .../components/LaunchPrompt/LaunchPrompt.jsx | 42 ++++++++-- .../LaunchPrompt/OtherPromptsStep.jsx | 80 +++++++++++++++---- 3 files changed, 101 insertions(+), 25 deletions(-) diff --git a/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx b/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx index 878945f8d8..e93c53a181 100644 --- a/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx +++ b/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx @@ -88,8 +88,8 @@ class LaunchButton extends React.Component { const { history, resource } = this.props; const jobPromise = resource.type === 'workflow_job_template' - ? WorkflowJobTemplatesAPI.launch(resource.id, params) - : JobTemplatesAPI.launch(resource.id, params); + ? WorkflowJobTemplatesAPI.launch(resource.id, params || {}) + : JobTemplatesAPI.launch(resource.id, params || {}); const { data: job } = await jobPromise; history.push( diff --git a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx index a932aee367..4a5a256ad7 100644 --- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx @@ -39,6 +39,30 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) { config.ask_scm_branch_on_launch || config.ask_diff_mode_on_launch ) { + if (config.ask_job_type_on_launch) { + initialValues.job_type = resource.job_type || ''; + } + if (config.ask_limit_on_launch) { + initialValues.limit = resource.limit || ''; + } + if (config.ask_verbosity_on_launch) { + initialValues.verbosity = resource.verbosity || 0; + } + if (config.ask_tags_on_launch) { + initialValues.job_tags = resource.job_tags || ''; + } + if (config.ask_skip_tags_on_launch) { + initialValues.skip_tags = resource.skip_tags || ''; + } + if (config.ask_variables_on_launch) { + initialValues.extra_vars = resource.extra_vars || '---'; + } + if (config.ask_scm_branch_on_launch) { + initialValues.scm_branch = resource.scm_branch || ''; + } + if (config.ask_diff_mode_on_launch) { + initialValues.diff_mode = resource.diff_mode || false; + } steps.push({ name: i18n._(t`Other Prompts`), component: , @@ -58,12 +82,18 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) { const submit = values => { const postValues = {}; - if (values.inventory) { - postValues.inventory_id = values.inventory.id; - } - if (values.credentials) { - postValues.credentials = values.credentials.map(c => c.id); - } + const setValue = (key, value) => { + if (typeof value !== 'undefined' && value !== null) { + postValues[key] = value; + } + }; + setValue('inventory_id', values.inventory?.id); + setValue('credentials', values.credentials?.map(c => c.id)); + setValue('job_type', values.job_type); + setValue('limit', values.limit); + setValue('job_tags', values.job_tags); + setValue('skip_tags', values.skip_tags); + setValue('extra_vars', values.extra_vars); onLaunch(postValues); }; diff --git a/awx/ui_next/src/components/LaunchPrompt/OtherPromptsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/OtherPromptsStep.jsx index 826f90c86f..09445e63e9 100644 --- a/awx/ui_next/src/components/LaunchPrompt/OtherPromptsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/OtherPromptsStep.jsx @@ -2,7 +2,7 @@ import React from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { useField } from 'formik'; -import { FormGroup } from '@patternfly/react-core'; +import { Form, FormGroup, Switch } from '@patternfly/react-core'; import FormField, { FieldTooltip } from '@components/FormField'; import { TagMultiSelect } from '@components/MultiSelect'; import AnsibleSelect from '@components/AnsibleSelect'; @@ -10,17 +10,8 @@ import { VariablesField } from '@components/CodeMirrorInput'; function OtherPromptsStep({ config, i18n }) { return ( - <> - {config.ask_job_type_on_launch && ( - - )} +
+ {config.ask_job_type_on_launch && } {config.ask_limit_on_launch && ( )} {config.ask_verbosity_on_launch && } - {/* TODO: Show Changes toggle? */} + {config.ask_diff_mode_on_launch && } {config.ask_tags_on_launch && ( )} - + + ); +} + +function JobTypeField({ i18n }) { + const [field, meta, helpers] = useField('job_type'); + const options = [ + { + value: '', + key: '', + label: i18n._(t`Choose a job type`), + isDisabled: true, + }, + { value: 'run', key: 'run', label: i18n._(t`Run`), isDisabled: false }, + { + value: 'check', + key: 'check', + label: i18n._(t`Check`), + isDisabled: false, + }, + ]; + const isValid = !(meta.touched && meta.error); + return ( + + + helpers.setValue(value)} + /> + ); } function VerbosityField({ i18n }) { - const [field, meta] = useField('verbosity'); + const [field, meta, helpers] = useField('verbosity'); const options = [ { value: '0', key: '0', label: i18n._(t`0 (Normal)`) }, { value: '1', key: '1', label: i18n._(t`1 (Verbose)`) }, @@ -89,11 +118,28 @@ function VerbosityField({ i18n }) { content={i18n._(t`Control the level of output ansible will produce as the playbook executes.`)} /> - + helpers.setValue(value)} + /> ); } +function ShowChangesToggle({ i18n }) { + const [field, , helpers] = useField('diff_mode'); + return ( + + ); +} + function TagField({ id, name, label, tooltip }) { const [field, , helpers] = useField(name); return ( From 42898b94e2dea098dfcb10fcfd08e5fd56c26635 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Wed, 8 Apr 2020 11:48:11 -0700 Subject: [PATCH 6/9] add more prompt tests --- .../components/ContentError/ContentError.jsx | 1 - .../LaunchPrompt/CredentialsStep.test.jsx | 79 ++++++++++++++++ .../components/LaunchPrompt/LaunchPrompt.jsx | 2 +- .../LaunchPrompt/LaunchPrompt.test.jsx | 50 ++++++++++- .../LaunchPrompt/OtherPromptsStep.jsx | 58 ++++++------ .../LaunchPrompt/OtherPromptsStep.test.jsx | 90 +++++++++++++++++++ 6 files changed, 252 insertions(+), 28 deletions(-) create mode 100644 awx/ui_next/src/components/LaunchPrompt/CredentialsStep.test.jsx create mode 100644 awx/ui_next/src/components/LaunchPrompt/OtherPromptsStep.test.jsx diff --git a/awx/ui_next/src/components/ContentError/ContentError.jsx b/awx/ui_next/src/components/ContentError/ContentError.jsx index f6fb2d825c..7f766f7e91 100644 --- a/awx/ui_next/src/components/ContentError/ContentError.jsx +++ b/awx/ui_next/src/components/ContentError/ContentError.jsx @@ -25,7 +25,6 @@ function ContentError({ error, children, isNotFound, i18n }) { return null; } } - console.error(error); const is404 = isNotFound || (error && error.response && error.response.status === 404); const is401 = error && error.response && error.response.status === 401; diff --git a/awx/ui_next/src/components/LaunchPrompt/CredentialsStep.test.jsx b/awx/ui_next/src/components/LaunchPrompt/CredentialsStep.test.jsx new file mode 100644 index 0000000000..5038482125 --- /dev/null +++ b/awx/ui_next/src/components/LaunchPrompt/CredentialsStep.test.jsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { Formik } from 'formik'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import CredentialsStep from './CredentialsStep'; +import { CredentialsAPI, CredentialTypesAPI } from '@api'; + +jest.mock('@api/models/CredentialTypes'); +jest.mock('@api/models/Credentials'); + +const types = [ + { id: 1, kind: 'ssh', name: 'SSH' }, + { id: 2, kind: 'cloud', name: 'Ansible Tower' }, + { id: 3, kind: 'vault', name: 'Vault' }, +]; + +const credentials = [ + { id: 1, kind: 'cloud', name: 'Cred 1', url: 'www.google.com' }, + { id: 2, kind: 'ssh', name: 'Cred 2', url: 'www.google.com' }, + { id: 3, kind: 'Ansible', name: 'Cred 3', url: 'www.google.com' }, + { id: 4, kind: 'Machine', name: 'Cred 4', url: 'www.google.com' }, + { id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' }, +]; + +describe('CredentialsStep', () => { + beforeEach(() => { + CredentialTypesAPI.loadAllTypes.mockResolvedValue(types); + CredentialsAPI.read.mockResolvedValue({ + data: { + results: credentials, + count: 5, + }, + }); + }); + + test('should load credentials', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + wrapper.update(); + + expect(CredentialsAPI.read).toHaveBeenCalled(); + expect(wrapper.find('OptionsList').prop('options')).toEqual(credentials); + }); + + test('should load credentials for selected type', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + wrapper.update(); + + expect(CredentialsAPI.read).toHaveBeenCalledWith({ + credential_type: 1, + order_by: 'name', + page: 1, + page_size: 5, + }); + + await act(async () => { + wrapper.find('AnsibleSelect').invoke('onChange')({}, 2); + }); + expect(CredentialsAPI.read).toHaveBeenCalledWith({ + credential_type: 2, + order_by: 'name', + page: 1, + page_size: 5, + }); + }); +}); diff --git a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx index 4a5a256ad7..ecf6c760c5 100644 --- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx @@ -22,7 +22,7 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) { if (config.ask_credential_on_launch) { initialValues.credentials = resource?.summary_fields?.credentials || []; steps.push({ - name: i18n._(t`Credential`), + name: i18n._(t`Credentials`), component: , }); } diff --git a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx index edbb0cd7dc..3a490db6bc 100644 --- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx @@ -3,6 +3,8 @@ import { act, isElementOfType } from 'react-dom/test-utils'; import { mountWithContexts } from '@testUtils/enzymeHelpers'; import LaunchPrompt from './LaunchPrompt'; import InventoryStep from './InventoryStep'; +import CredentialsStep from './CredentialsStep'; +import OtherPromptsStep from './OtherPromptsStep'; import PreviewStep from './PreviewStep'; import { InventoriesAPI } from '@api'; @@ -69,7 +71,7 @@ describe('LaunchPrompt', () => { expect(steps).toHaveLength(5); expect(steps[0].name).toEqual('Inventory'); - expect(steps[1].name).toEqual('Credential'); + expect(steps[1].name).toEqual('Credentials'); expect(steps[2].name).toEqual('Other Prompts'); expect(steps[3].name).toEqual('Survey'); expect(steps[4].name).toEqual('Preview'); @@ -97,4 +99,50 @@ describe('LaunchPrompt', () => { expect(isElementOfType(steps[0].component, InventoryStep)).toEqual(true); expect(isElementOfType(steps[1].component, PreviewStep)).toEqual(true); }); + + test('should add credentials step', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + const steps = wrapper.find('Wizard').prop('steps'); + + expect(steps).toHaveLength(2); + expect(steps[0].name).toEqual('Credentials'); + expect(isElementOfType(steps[0].component, CredentialsStep)).toEqual(true); + expect(isElementOfType(steps[1].component, PreviewStep)).toEqual(true); + }); + + test('should add other prompts step', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + const steps = wrapper.find('Wizard').prop('steps'); + + expect(steps).toHaveLength(2); + expect(steps[0].name).toEqual('Other Prompts'); + expect(isElementOfType(steps[0].component, OtherPromptsStep)).toEqual(true); + expect(isElementOfType(steps[1].component, PreviewStep)).toEqual(true); + }); }); diff --git a/awx/ui_next/src/components/LaunchPrompt/OtherPromptsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/OtherPromptsStep.jsx index 09445e63e9..0989368652 100644 --- a/awx/ui_next/src/components/LaunchPrompt/OtherPromptsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/OtherPromptsStep.jsx @@ -7,6 +7,17 @@ import FormField, { FieldTooltip } from '@components/FormField'; import { TagMultiSelect } from '@components/MultiSelect'; import AnsibleSelect from '@components/AnsibleSelect'; import { VariablesField } from '@components/CodeMirrorInput'; +import styled from 'styled-components'; + +const FieldHeader = styled.div` + display: flex; + justify-content: space-between; + padding-bottom: var(--pf-c-form__label--PaddingBottom); + + label { + --pf-c-form__label--PaddingBottom: 0px; + } +`; function OtherPromptsStep({ config, i18n }) { return ( @@ -131,12 +142,28 @@ function VerbosityField({ i18n }) { function ShowChangesToggle({ i18n }) { const [field, , helpers] = useField('diff_mode'); return ( - + + + {' '} + + + + ); } @@ -150,23 +177,4 @@ function TagField({ id, name, label, tooltip }) { ); } -/* - tooltips: - verbosity: Control the level of output ansible will produce as the playbook executes. - job tags: Tags are useful when you have a large playbook, and you want to run a specific part of a play or task. Use commas to separate multiple tags. Refer to Ansible Tower documentation for details on the usage of tags. - skip tags: Skip tags are useful when you have a large playbook, and you want to skip specific parts of a play or task. Use commas to separate multiple tags. Refer to Ansible Tower documentation for details on the usage of tags. - show changes: If enabled, show the changes made by Ansible tasks, where supported. This is equivalent to Ansible’s --diff mode. - extra variables: Pass extra command line variables to the playbook. This is the -e or --extra-vars command line parameter for ansible-playbook. Provide key/value pairs using either YAML or JSON. - - JSON: - { - "somevar": "somevalue", - "password": "magic" - } - YAML: - --- - somevar: somevalue - password: magic -*/ - export default withI18n()(OtherPromptsStep); diff --git a/awx/ui_next/src/components/LaunchPrompt/OtherPromptsStep.test.jsx b/awx/ui_next/src/components/LaunchPrompt/OtherPromptsStep.test.jsx new file mode 100644 index 0000000000..58f0856131 --- /dev/null +++ b/awx/ui_next/src/components/LaunchPrompt/OtherPromptsStep.test.jsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { Formik } from 'formik'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import OtherPromptsStep from './OtherPromptsStep'; + +describe('OtherPromptsStep', () => { + test('should render job type field', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('JobTypeField')).toHaveLength(1); + expect( + wrapper.find('JobTypeField AnsibleSelect').prop('data') + ).toHaveLength(3); + expect(wrapper.find('JobTypeField AnsibleSelect').prop('value')).toEqual( + 'run' + ); + }); + + test('should render limit field', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('FormField#prompt-limit')).toHaveLength(1); + expect(wrapper.find('FormField#prompt-limit input').prop('name')).toEqual( + 'limit' + ); + }); + + test('should render verbosity field', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('VerbosityField')).toHaveLength(1); + expect( + wrapper.find('VerbosityField AnsibleSelect').prop('data') + ).toHaveLength(5); + }); + + test('should render show changes toggle', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('ShowChangesToggle')).toHaveLength(1); + expect(wrapper.find('ShowChangesToggle Switch').prop('isChecked')).toEqual( + true + ); + }); +}); From 6f76b15d9283ca246f7857bee18865748414d919 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Wed, 8 Apr 2020 15:36:45 -0700 Subject: [PATCH 7/9] fix LaunchButton tests --- awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx b/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx index 8db633fd8c..8ca2b3d167 100644 --- a/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx +++ b/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx @@ -62,7 +62,7 @@ describe('LaunchButton', () => { button.prop('onClick')(); expect(JobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1); await sleep(0); - expect(JobTemplatesAPI.launch).toHaveBeenCalledWith(1, null); + expect(JobTemplatesAPI.launch).toHaveBeenCalledWith(1, {}); expect(history.location.pathname).toEqual('/jobs/9000/output'); }); @@ -99,7 +99,7 @@ describe('LaunchButton', () => { button.prop('onClick')(); expect(WorkflowJobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1); await sleep(0); - expect(WorkflowJobTemplatesAPI.launch).toHaveBeenCalledWith(1, null); + expect(WorkflowJobTemplatesAPI.launch).toHaveBeenCalledWith(1, {}); expect(history.location.pathname).toEqual('/jobs/workflow/9000/output'); }); From af18aa8456e5b64a8540735ac5b2c27a3093481b Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Thu, 9 Apr 2020 08:58:12 -0700 Subject: [PATCH 8/9] restructure 'if's in LaunchPrompt --- .../components/LaunchPrompt/LaunchPrompt.jsx | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx index ecf6c760c5..4dfac93e23 100644 --- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx @@ -29,6 +29,30 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) { // TODO: Add Credential Passwords step + if (config.ask_job_type_on_launch) { + initialValues.job_type = resource.job_type || ''; + } + if (config.ask_limit_on_launch) { + initialValues.limit = resource.limit || ''; + } + if (config.ask_verbosity_on_launch) { + initialValues.verbosity = resource.verbosity || 0; + } + if (config.ask_tags_on_launch) { + initialValues.job_tags = resource.job_tags || ''; + } + if (config.ask_skip_tags_on_launch) { + initialValues.skip_tags = resource.skip_tags || ''; + } + if (config.ask_variables_on_launch) { + initialValues.extra_vars = resource.extra_vars || '---'; + } + if (config.ask_scm_branch_on_launch) { + initialValues.scm_branch = resource.scm_branch || ''; + } + if (config.ask_diff_mode_on_launch) { + initialValues.diff_mode = resource.diff_mode || false; + } if ( config.ask_job_type_on_launch || config.ask_limit_on_launch || @@ -39,30 +63,6 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) { config.ask_scm_branch_on_launch || config.ask_diff_mode_on_launch ) { - if (config.ask_job_type_on_launch) { - initialValues.job_type = resource.job_type || ''; - } - if (config.ask_limit_on_launch) { - initialValues.limit = resource.limit || ''; - } - if (config.ask_verbosity_on_launch) { - initialValues.verbosity = resource.verbosity || 0; - } - if (config.ask_tags_on_launch) { - initialValues.job_tags = resource.job_tags || ''; - } - if (config.ask_skip_tags_on_launch) { - initialValues.skip_tags = resource.skip_tags || ''; - } - if (config.ask_variables_on_launch) { - initialValues.extra_vars = resource.extra_vars || '---'; - } - if (config.ask_scm_branch_on_launch) { - initialValues.scm_branch = resource.scm_branch || ''; - } - if (config.ask_diff_mode_on_launch) { - initialValues.diff_mode = resource.diff_mode || false; - } steps.push({ name: i18n._(t`Other Prompts`), component: , From 7827a2aedddd72ee9fde682e11a64ec6ed7e86f0 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Thu, 9 Apr 2020 16:07:06 -0700 Subject: [PATCH 9/9] fix double-fetch of cred types in launch prompts --- awx/ui_next/src/components/LaunchPrompt/CredentialsStep.jsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/awx/ui_next/src/components/LaunchPrompt/CredentialsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/CredentialsStep.jsx index f86513a550..a389db0cff 100644 --- a/awx/ui_next/src/components/LaunchPrompt/CredentialsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/CredentialsStep.jsx @@ -24,7 +24,6 @@ function CredentialsStep({ i18n }) { const [selectedType, setSelectedType] = useState(null); const history = useHistory(); - const isTypeSelected = !!selectedType; const { result: types, error: typesError, @@ -33,13 +32,13 @@ function CredentialsStep({ i18n }) { } = useRequest( useCallback(async () => { const loadedTypes = await CredentialTypesAPI.loadAllTypes(); - if (!isTypeSelected && loadedTypes.length) { + if (loadedTypes.length) { const match = loadedTypes.find(type => type.kind === 'ssh') || loadedTypes[0]; setSelectedType(match); } return loadedTypes; - }, [isTypeSelected]), + }, []), [] );