mirror of
https://github.com/ansible/awx.git
synced 2026-05-11 03:17:38 -02:30
Merge pull request #6662 from keithjgrant/5909-jt-launch-prompt-2
JT Launch Prompting (phase 2) Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
@@ -5,6 +5,28 @@ class CredentialTypes extends Base {
|
|||||||
super(http);
|
super(http);
|
||||||
this.baseUrl = '/api/v2/credential_types/';
|
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;
|
export default CredentialTypes;
|
||||||
|
|||||||
65
awx/ui_next/src/api/models/CredentialTypes.test.js
Normal file
65
awx/ui_next/src/api/models/CredentialTypes.test.js
Normal file
@@ -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]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -88,8 +88,8 @@ class LaunchButton extends React.Component {
|
|||||||
const { history, resource } = this.props;
|
const { history, resource } = this.props;
|
||||||
const jobPromise =
|
const jobPromise =
|
||||||
resource.type === 'workflow_job_template'
|
resource.type === 'workflow_job_template'
|
||||||
? WorkflowJobTemplatesAPI.launch(resource.id, params)
|
? WorkflowJobTemplatesAPI.launch(resource.id, params || {})
|
||||||
: JobTemplatesAPI.launch(resource.id, params);
|
: JobTemplatesAPI.launch(resource.id, params || {});
|
||||||
|
|
||||||
const { data: job } = await jobPromise;
|
const { data: job } = await jobPromise;
|
||||||
history.push(
|
history.push(
|
||||||
|
|||||||
@@ -1,7 +1,171 @@
|
|||||||
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 ContentError from '@components/ContentError';
|
||||||
|
import { getQSConfig, parseQueryString } from '@util/qs';
|
||||||
|
import useRequest from '@util/useRequest';
|
||||||
|
|
||||||
function CredentialsStep() {
|
const QS_CONFIG = getQSConfig('inventory', {
|
||||||
return <div />;
|
page: 1,
|
||||||
|
page_size: 5,
|
||||||
|
order_by: 'name',
|
||||||
|
});
|
||||||
|
|
||||||
|
function CredentialsStep({ i18n }) {
|
||||||
|
const [field, , helpers] = useField('credentials');
|
||||||
|
const [selectedType, setSelectedType] = useState(null);
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
const {
|
||||||
|
result: types,
|
||||||
|
error: typesError,
|
||||||
|
isLoading: isTypesLoading,
|
||||||
|
request: fetchTypes,
|
||||||
|
} = useRequest(
|
||||||
|
useCallback(async () => {
|
||||||
|
const loadedTypes = await CredentialTypesAPI.loadAllTypes();
|
||||||
|
if (loadedTypes.length) {
|
||||||
|
const match =
|
||||||
|
loadedTypes.find(type => type.kind === 'ssh') || loadedTypes[0];
|
||||||
|
setSelectedType(match);
|
||||||
|
}
|
||||||
|
return loadedTypes;
|
||||||
|
}, []),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTypes();
|
||||||
|
}, [fetchTypes]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
result: { credentials, count },
|
||||||
|
error: credentialsError,
|
||||||
|
isLoading: isCredentialsLoading,
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
credentials: data.results,
|
||||||
|
count: data.count,
|
||||||
|
};
|
||||||
|
}, [selectedType, history.location.search]),
|
||||||
|
{ credentials: [], count: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCredentials();
|
||||||
|
}, [fetchCredentials]);
|
||||||
|
|
||||||
|
if (isTypesLoading) {
|
||||||
|
return <ContentLoading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typesError || credentialsError) {
|
||||||
|
return <ContentError error={typesError || credentialsError} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isVault = selectedType?.kind === 'vault';
|
||||||
|
|
||||||
|
const renderChip = ({ item, removeItem, canDelete }) => (
|
||||||
|
<CredentialChip
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => removeItem(item)}
|
||||||
|
isReadOnly={!canDelete}
|
||||||
|
credential={item}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{types && types.length > 0 && (
|
||||||
|
<ToolbarItem css=" display: flex; align-items: center;">
|
||||||
|
<div css="flex: 0 0 25%; margin-right: 32px">
|
||||||
|
{i18n._(t`Selected Category`)}
|
||||||
|
</div>
|
||||||
|
<AnsibleSelect
|
||||||
|
css="flex: 1 1 75%;"
|
||||||
|
id="multiCredentialsLookUp-select"
|
||||||
|
label={i18n._(t`Selected Category`)}
|
||||||
|
data={types.map(type => ({
|
||||||
|
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)));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ToolbarItem>
|
||||||
|
)}
|
||||||
|
{!isCredentialsLoading && (
|
||||||
|
<OptionsList
|
||||||
|
value={field.value || []}
|
||||||
|
options={credentials}
|
||||||
|
optionCount={count}
|
||||||
|
searchColumns={[
|
||||||
|
{
|
||||||
|
name: i18n._(t`Name`),
|
||||||
|
key: 'name',
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: i18n._(t`Created By (Username)`),
|
||||||
|
key: 'created_by__username',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: i18n._(t`Modified By (Username)`),
|
||||||
|
key: 'modified_by__username',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
sortColumns={[
|
||||||
|
{
|
||||||
|
name: i18n._(t`Name`),
|
||||||
|
key: 'name',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
multiple={isVault}
|
||||||
|
header={i18n._(t`Credentials`)}
|
||||||
|
name="credentials"
|
||||||
|
qsConfig={QS_CONFIG}
|
||||||
|
readOnly={false}
|
||||||
|
selectItem={item => {
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CredentialsStep;
|
export default withI18n()(CredentialsStep);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<Formik>
|
||||||
|
<CredentialsStep />
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
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(
|
||||||
|
<Formik>
|
||||||
|
<CredentialsStep />
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import { getQSConfig, parseQueryString } from '@util/qs';
|
|||||||
import useRequest from '@util/useRequest';
|
import useRequest from '@util/useRequest';
|
||||||
import OptionsList from '@components/OptionsList';
|
import OptionsList from '@components/OptionsList';
|
||||||
import ContentLoading from '@components/ContentLoading';
|
import ContentLoading from '@components/ContentLoading';
|
||||||
|
import ContentError from '@components/ContentError';
|
||||||
|
|
||||||
const QS_CONFIG = getQSConfig('inventory', {
|
const QS_CONFIG = getQSConfig('inventory', {
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -16,12 +17,12 @@ const QS_CONFIG = getQSConfig('inventory', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function InventoryStep({ i18n }) {
|
function InventoryStep({ i18n }) {
|
||||||
const history = useHistory();
|
|
||||||
const [field, , helpers] = useField('inventory');
|
const [field, , helpers] = useField('inventory');
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isLoading,
|
isLoading,
|
||||||
// error,
|
error,
|
||||||
result: { inventories, count },
|
result: { inventories, count },
|
||||||
request: fetchInventories,
|
request: fetchInventories,
|
||||||
} = useRequest(
|
} = useRequest(
|
||||||
@@ -46,6 +47,9 @@ function InventoryStep({ i18n }) {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <ContentLoading />;
|
return <ContentLoading />;
|
||||||
}
|
}
|
||||||
|
if (error) {
|
||||||
|
return <ContentError error={error} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OptionsList
|
<OptionsList
|
||||||
|
|||||||
@@ -19,30 +19,49 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) {
|
|||||||
component: <InventoryStep />,
|
component: <InventoryStep />,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// 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) {
|
if (config.ask_credential_on_launch) {
|
||||||
initialValues.credentials = resource?.summary_fields?.credentials || [];
|
initialValues.credentials = resource?.summary_fields?.credentials || [];
|
||||||
steps.push({
|
steps.push({
|
||||||
name: i18n._(t`Credential`),
|
name: i18n._(t`Credentials`),
|
||||||
component: <CredentialsStep />,
|
component: <CredentialsStep />,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 (
|
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_job_type_on_launch ||
|
||||||
config.ask_limit_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({
|
steps.push({
|
||||||
name: i18n._(t`Other Prompts`),
|
name: i18n._(t`Other Prompts`),
|
||||||
@@ -63,9 +82,18 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) {
|
|||||||
|
|
||||||
const submit = values => {
|
const submit = values => {
|
||||||
const postValues = {};
|
const postValues = {};
|
||||||
if (values.inventory) {
|
const setValue = (key, value) => {
|
||||||
postValues.inventory_id = values.inventory.id;
|
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);
|
onLaunch(postValues);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { act, isElementOfType } from 'react-dom/test-utils';
|
|||||||
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
||||||
import LaunchPrompt from './LaunchPrompt';
|
import LaunchPrompt from './LaunchPrompt';
|
||||||
import InventoryStep from './InventoryStep';
|
import InventoryStep from './InventoryStep';
|
||||||
|
import CredentialsStep from './CredentialsStep';
|
||||||
|
import OtherPromptsStep from './OtherPromptsStep';
|
||||||
import PreviewStep from './PreviewStep';
|
import PreviewStep from './PreviewStep';
|
||||||
import { InventoriesAPI } from '@api';
|
import { InventoriesAPI } from '@api';
|
||||||
|
|
||||||
@@ -69,7 +71,7 @@ describe('LaunchPrompt', () => {
|
|||||||
|
|
||||||
expect(steps).toHaveLength(5);
|
expect(steps).toHaveLength(5);
|
||||||
expect(steps[0].name).toEqual('Inventory');
|
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[2].name).toEqual('Other Prompts');
|
||||||
expect(steps[3].name).toEqual('Survey');
|
expect(steps[3].name).toEqual('Survey');
|
||||||
expect(steps[4].name).toEqual('Preview');
|
expect(steps[4].name).toEqual('Preview');
|
||||||
@@ -97,4 +99,50 @@ describe('LaunchPrompt', () => {
|
|||||||
expect(isElementOfType(steps[0].component, InventoryStep)).toEqual(true);
|
expect(isElementOfType(steps[0].component, InventoryStep)).toEqual(true);
|
||||||
expect(isElementOfType(steps[1].component, PreviewStep)).toEqual(true);
|
expect(isElementOfType(steps[1].component, PreviewStep)).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should add credentials step', async () => {
|
||||||
|
let wrapper;
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<LaunchPrompt
|
||||||
|
config={{
|
||||||
|
...config,
|
||||||
|
ask_credential_on_launch: true,
|
||||||
|
}}
|
||||||
|
resource={resource}
|
||||||
|
onLaunch={noop}
|
||||||
|
onCancel={noop}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
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(
|
||||||
|
<LaunchPrompt
|
||||||
|
config={{
|
||||||
|
...config,
|
||||||
|
ask_verbosity_on_launch: true,
|
||||||
|
}}
|
||||||
|
resource={resource}
|
||||||
|
onLaunch={noop}
|
||||||
|
onCancel={noop}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,180 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { useField } from 'formik';
|
||||||
|
import { Form, FormGroup, Switch } 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';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
function InventoryStep() {
|
const FieldHeader = styled.div`
|
||||||
return <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 (
|
||||||
|
<Form>
|
||||||
|
{config.ask_job_type_on_launch && <JobTypeField i18n={i18n} />}
|
||||||
|
{config.ask_limit_on_launch && (
|
||||||
|
<FormField
|
||||||
|
id="prompt-limit"
|
||||||
|
name="limit"
|
||||||
|
label={i18n._(t`Limit`)}
|
||||||
|
tooltip={i18n._(t`Provide a host pattern to further constrain the list
|
||||||
|
of hosts that will be managed or affected by the playbook. Multiple
|
||||||
|
patterns are allowed. Refer to Ansible documentation for more
|
||||||
|
information and examples on patterns.`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{config.ask_verbosity_on_launch && <VerbosityField i18n={i18n} />}
|
||||||
|
{config.ask_diff_mode_on_launch && <ShowChangesToggle i18n={i18n} />}
|
||||||
|
{config.ask_tags_on_launch && (
|
||||||
|
<TagField
|
||||||
|
id="prompt-job-tags"
|
||||||
|
name="job_tags"
|
||||||
|
label={i18n._(t`Job Tags`)}
|
||||||
|
tooltip={i18n._(t`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.`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{config.ask_skip_tags_on_launch && (
|
||||||
|
<TagField
|
||||||
|
id="prompt-skip-tags"
|
||||||
|
name="skip_tags"
|
||||||
|
label={i18n._(t`Skip Tags`)}
|
||||||
|
tooltip={i18n._(t`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.`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{config.ask_variables_on_launch && (
|
||||||
|
<VariablesField
|
||||||
|
id="prompt-variables"
|
||||||
|
name="extra_vars"
|
||||||
|
label={i18n._(t`Variables`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default InventoryStep;
|
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 (
|
||||||
|
<FormGroup
|
||||||
|
fieldId="propmt-job-type"
|
||||||
|
label={i18n._(t`Job Type`)}
|
||||||
|
isValid={isValid}
|
||||||
|
>
|
||||||
|
<FieldTooltip
|
||||||
|
content={i18n._(t`For job templates, select run to execute the playbook.
|
||||||
|
Select check to only check playbook syntax, test environment setup,
|
||||||
|
and report problems without executing the playbook.`)}
|
||||||
|
/>
|
||||||
|
<AnsibleSelect
|
||||||
|
id="prompt-job-type"
|
||||||
|
data={options}
|
||||||
|
{...field}
|
||||||
|
onChange={(event, value) => helpers.setValue(value)}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VerbosityField({ i18n }) {
|
||||||
|
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)`) },
|
||||||
|
{ 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 (
|
||||||
|
<FormGroup
|
||||||
|
fieldId="prompt-verbosity"
|
||||||
|
isValid={isValid}
|
||||||
|
label={i18n._(t`Verbosity`)}
|
||||||
|
>
|
||||||
|
<FieldTooltip
|
||||||
|
content={i18n._(t`Control the level of output ansible
|
||||||
|
will produce as the playbook executes.`)}
|
||||||
|
/>
|
||||||
|
<AnsibleSelect
|
||||||
|
id="prompt-verbosity"
|
||||||
|
data={options}
|
||||||
|
{...field}
|
||||||
|
onChange={(event, value) => helpers.setValue(value)}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShowChangesToggle({ i18n }) {
|
||||||
|
const [field, , helpers] = useField('diff_mode');
|
||||||
|
return (
|
||||||
|
<FormGroup fieldId="prompt-show-changes">
|
||||||
|
<FieldHeader>
|
||||||
|
{' '}
|
||||||
|
<label className="pf-c-form__label" htmlFor="prompt-show-changes">
|
||||||
|
<span className="pf-c-form__label-text">
|
||||||
|
{i18n._(t`Show Changes`)}
|
||||||
|
<FieldTooltip
|
||||||
|
content={i18n._(t`If enabled, show the changes made
|
||||||
|
by Ansible tasks, where supported. This is equivalent to Ansible’s
|
||||||
|
--diff mode.`)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</FieldHeader>
|
||||||
|
<Switch
|
||||||
|
id="prompt-show-changes"
|
||||||
|
label={i18n._(t`On`)}
|
||||||
|
labelOff={i18n._(t`Off`)}
|
||||||
|
isChecked={field.value}
|
||||||
|
onChange={helpers.setValue}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TagField({ id, name, label, tooltip }) {
|
||||||
|
const [field, , helpers] = useField(name);
|
||||||
|
return (
|
||||||
|
<FormGroup fieldId={id} label={label}>
|
||||||
|
<FieldTooltip content={tooltip} />
|
||||||
|
<TagMultiSelect value={field.value} onChange={helpers.setValue} />
|
||||||
|
</FormGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n()(OtherPromptsStep);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<Formik initialValues={{ job_type: 'run' }}>
|
||||||
|
<OtherPromptsStep
|
||||||
|
config={{
|
||||||
|
ask_job_type_on_launch: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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(
|
||||||
|
<Formik>
|
||||||
|
<OtherPromptsStep
|
||||||
|
config={{
|
||||||
|
ask_limit_on_launch: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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(
|
||||||
|
<Formik initialValues={{ verbosity: '' }}>
|
||||||
|
<OtherPromptsStep
|
||||||
|
config={{
|
||||||
|
ask_verbosity_on_launch: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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(
|
||||||
|
<Formik initialValues={{ diff_mode: true }}>
|
||||||
|
<OtherPromptsStep
|
||||||
|
config={{
|
||||||
|
ask_diff_mode_on_launch: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.find('ShowChangesToggle')).toHaveLength(1);
|
||||||
|
expect(wrapper.find('ShowChangesToggle Switch').prop('isChecked')).toEqual(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,27 +17,6 @@ const QS_CONFIG = getQSConfig('credentials', {
|
|||||||
order_by: 'name',
|
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) {
|
async function loadCredentials(params, selectedCredentialTypeId) {
|
||||||
params.credential_type = selectedCredentialTypeId || 1;
|
params.credential_type = selectedCredentialTypeId || 1;
|
||||||
const { data } = await CredentialsAPI.read(params);
|
const { data } = await CredentialsAPI.read(params);
|
||||||
@@ -54,7 +33,7 @@ function MultiCredentialsLookup(props) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const types = await loadCredentialTypes();
|
const types = await CredentialTypesAPI.loadAllTypes();
|
||||||
setCredentialTypes(types);
|
setCredentialTypes(types);
|
||||||
const match = types.find(type => type.kind === 'ssh') || types[0];
|
const match = types.find(type => type.kind === 'ssh') || types[0];
|
||||||
setSelectedType(match);
|
setSelectedType(match);
|
||||||
|
|||||||
@@ -18,21 +18,16 @@ describe('<MultiCredentialsLookup />', () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
CredentialTypesAPI.read.mockResolvedValueOnce({
|
CredentialTypesAPI.loadAllTypes.mockResolvedValueOnce([
|
||||||
data: {
|
{
|
||||||
results: [
|
id: 400,
|
||||||
{
|
kind: 'ssh',
|
||||||
id: 400,
|
namespace: 'biz',
|
||||||
kind: 'ssh',
|
name: 'Amazon Web Services',
|
||||||
namespace: 'biz',
|
|
||||||
name: 'Amazon Web Services',
|
|
||||||
},
|
|
||||||
{ id: 500, kind: 'vault', namespace: 'buzz', name: 'Vault' },
|
|
||||||
{ id: 600, kind: 'machine', namespace: 'fuzz', name: 'Machine' },
|
|
||||||
],
|
|
||||||
count: 2,
|
|
||||||
},
|
},
|
||||||
});
|
{ id: 500, kind: 'vault', namespace: 'buzz', name: 'Vault' },
|
||||||
|
{ id: 600, kind: 'machine', namespace: 'fuzz', name: 'Machine' },
|
||||||
|
]);
|
||||||
CredentialsAPI.read.mockResolvedValueOnce({
|
CredentialsAPI.read.mockResolvedValueOnce({
|
||||||
data: {
|
data: {
|
||||||
results: [
|
results: [
|
||||||
@@ -52,7 +47,7 @@ describe('<MultiCredentialsLookup />', () => {
|
|||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('MultiCredentialsLookup renders properly', async () => {
|
test('should load credential types', async () => {
|
||||||
const onChange = jest.fn();
|
const onChange = jest.fn();
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
@@ -64,8 +59,9 @@ describe('<MultiCredentialsLookup />', () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
wrapper.update();
|
||||||
expect(wrapper.find('MultiCredentialsLookup')).toHaveLength(1);
|
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 () => {
|
test('onChange is called when you click to remove a credential from input', async () => {
|
||||||
@@ -118,12 +114,12 @@ describe('<MultiCredentialsLookup />', () => {
|
|||||||
count: 1,
|
count: 1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(CredentialsAPI.read).toHaveBeenCalledTimes(2);
|
expect(CredentialsAPI.read).toHaveBeenCalledTimes(1);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
select.invoke('onChange')({}, 500);
|
select.invoke('onChange')({}, 500);
|
||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
expect(CredentialsAPI.read).toHaveBeenCalledTimes(3);
|
expect(CredentialsAPI.read).toHaveBeenCalledTimes(2);
|
||||||
expect(wrapper.find('OptionsList').prop('options')).toEqual([
|
expect(wrapper.find('OptionsList').prop('options')).toEqual([
|
||||||
{ id: 1, kind: 'cloud', name: 'New Cred', url: 'www.google.com' },
|
{ id: 1, kind: 'cloud', name: 'New Cred', url: 'www.google.com' },
|
||||||
]);
|
]);
|
||||||
|
|||||||
Reference in New Issue
Block a user