mirror of
https://github.com/ansible/awx.git
synced 2026-01-13 02:50:02 -03:30
Add constructed inventory add form
This commit is contained in:
parent
e3d167dfd1
commit
d576e65858
@ -84,6 +84,7 @@
|
||||
"displayKey",
|
||||
"sortedColumnKey",
|
||||
"maxHeight",
|
||||
"maxWidth",
|
||||
"role",
|
||||
"aria-haspopup",
|
||||
"dropDirection",
|
||||
@ -97,7 +98,8 @@
|
||||
"data-cy",
|
||||
"fieldName",
|
||||
"splitButtonVariant",
|
||||
"pageKey"
|
||||
"pageKey",
|
||||
"textId"
|
||||
],
|
||||
"ignore": [
|
||||
"Ansible",
|
||||
|
||||
@ -14,6 +14,7 @@ class Inventories extends InstanceGroupsMixin(Base) {
|
||||
this.readGroupsOptions = this.readGroupsOptions.bind(this);
|
||||
this.promoteGroup = this.promoteGroup.bind(this);
|
||||
this.readInputInventories = this.readInputInventories.bind(this);
|
||||
this.associateInventory = this.associateInventory.bind(this);
|
||||
}
|
||||
|
||||
readAccessList(id, params) {
|
||||
@ -137,6 +138,12 @@ class Inventories extends InstanceGroupsMixin(Base) {
|
||||
disassociate: true,
|
||||
});
|
||||
}
|
||||
|
||||
associateInventory(id, inputInventoryId) {
|
||||
return this.http.post(`${this.baseUrl}${id}/input_inventories/`, {
|
||||
id: inputInventoryId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default Inventories;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { string, bool, func, oneOf } from 'prop-types';
|
||||
import { string, bool, func, oneOf, shape } from 'prop-types';
|
||||
|
||||
import { t } from '@lingui/macro';
|
||||
import { useField } from 'formik';
|
||||
@ -38,6 +38,8 @@ function VariablesField({
|
||||
tooltip,
|
||||
initialMode,
|
||||
onModeChange,
|
||||
isRequired,
|
||||
validators,
|
||||
}) {
|
||||
// track focus manually, because the Code Editor library doesn't wire
|
||||
// into Formik completely
|
||||
@ -48,13 +50,22 @@ function VariablesField({
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
parseVariableField(value);
|
||||
const parsedVariables = parseVariableField(value);
|
||||
if (validators) {
|
||||
const errorMessages = Object.keys(validators)
|
||||
.map((field) => validators[field](parsedVariables[field]))
|
||||
.filter((e) => e);
|
||||
|
||||
if (errorMessages.length > 0) {
|
||||
return errorMessages;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return error.message;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[shouldValidate]
|
||||
[shouldValidate, validators]
|
||||
);
|
||||
const [field, meta, helpers] = useField({ name, validate });
|
||||
const [mode, setMode] = useState(() =>
|
||||
@ -120,6 +131,7 @@ function VariablesField({
|
||||
setMode={handleModeChange}
|
||||
setShouldValidate={setShouldValidate}
|
||||
handleChange={handleChange}
|
||||
isRequired={isRequired}
|
||||
/>
|
||||
<Modal
|
||||
variant="xlarge"
|
||||
@ -157,7 +169,11 @@ function VariablesField({
|
||||
</Modal>
|
||||
{meta.error ? (
|
||||
<div className="pf-c-form__helper-text pf-m-error" aria-live="polite">
|
||||
{meta.error}
|
||||
{(Array.isArray(meta.error) ? meta.error : [meta.error]).map(
|
||||
(errorMessage) => (
|
||||
<p key={errorMessage}>{errorMessage}</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@ -171,12 +187,16 @@ VariablesField.propTypes = {
|
||||
promptId: string,
|
||||
initialMode: oneOf([YAML_MODE, JSON_MODE]),
|
||||
onModeChange: func,
|
||||
isRequired: bool,
|
||||
validators: shape({}),
|
||||
};
|
||||
VariablesField.defaultProps = {
|
||||
readOnly: false,
|
||||
promptId: null,
|
||||
initialMode: YAML_MODE,
|
||||
onModeChange: () => {},
|
||||
isRequired: false,
|
||||
validators: {},
|
||||
};
|
||||
|
||||
function VariablesFieldInternals({
|
||||
@ -192,6 +212,7 @@ function VariablesFieldInternals({
|
||||
onExpand,
|
||||
setShouldValidate,
|
||||
handleChange,
|
||||
isRequired,
|
||||
}) {
|
||||
const [field, meta, helpers] = useField(name);
|
||||
|
||||
@ -213,6 +234,12 @@ function VariablesFieldInternals({
|
||||
<SplitItem>
|
||||
<label htmlFor={id} className="pf-c-form__label">
|
||||
<span className="pf-c-form__label-text">{label}</span>
|
||||
{isRequired && (
|
||||
<span className="pf-c-form__label-required" aria-hidden="true">
|
||||
{' '}
|
||||
*{' '}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
{tooltip && <Popover content={tooltip} id={`${id}-tooltip`} />}
|
||||
</SplitItem>
|
||||
|
||||
@ -1,14 +1,54 @@
|
||||
/* eslint i18next/no-literal-string: "off" */
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Card, PageSection } from '@patternfly/react-core';
|
||||
import { ConstructedInventoriesAPI, InventoriesAPI } from 'api';
|
||||
import { CardBody } from 'components/Card';
|
||||
import ConstructedInventoryForm from '../shared/ConstructedInventoryForm';
|
||||
|
||||
function ConstructedInventoryAdd() {
|
||||
const history = useHistory();
|
||||
const [submitError, setSubmitError] = useState(null);
|
||||
|
||||
const handleCancel = () => {
|
||||
history.push('/inventories');
|
||||
};
|
||||
|
||||
const handleSubmit = async (values) => {
|
||||
try {
|
||||
const {
|
||||
data: { id: inventoryId },
|
||||
} = await ConstructedInventoriesAPI.create({
|
||||
...values,
|
||||
organization: values.organization?.id,
|
||||
kind: 'constructed',
|
||||
});
|
||||
/* eslint-disable no-await-in-loop, no-restricted-syntax */
|
||||
for (const inputInventory of values.inputInventories) {
|
||||
await InventoriesAPI.associateInventory(inventoryId, inputInventory.id);
|
||||
}
|
||||
for (const instanceGroup of values.instanceGroups) {
|
||||
await InventoriesAPI.associateInstanceGroup(
|
||||
inventoryId,
|
||||
instanceGroup.id
|
||||
);
|
||||
}
|
||||
/* eslint-enable no-await-in-loop, no-restricted-syntax */
|
||||
|
||||
history.push(`/inventories/constructed_inventory/${inventoryId}/details`);
|
||||
} catch (error) {
|
||||
setSubmitError(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
<CardBody>
|
||||
<div>Coming Soon!</div>
|
||||
<ConstructedInventoryForm
|
||||
onCancel={handleCancel}
|
||||
onSubmit={handleSubmit}
|
||||
submitError={submitError}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</PageSection>
|
||||
|
||||
@ -1,15 +1,120 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../testUtils/enzymeHelpers';
|
||||
import '@testing-library/jest-dom';
|
||||
import { ConstructedInventoriesAPI, InventoriesAPI } from 'api';
|
||||
import ConstructedInventoryAdd from './ConstructedInventoryAdd';
|
||||
|
||||
jest.mock('api');
|
||||
|
||||
describe('<ConstructedInventoryAdd />', () => {
|
||||
test('initially renders successfully', async () => {
|
||||
let wrapper;
|
||||
let wrapper;
|
||||
let history;
|
||||
|
||||
const formData = {
|
||||
name: 'Mock',
|
||||
description: 'Foo',
|
||||
organization: { id: 1 },
|
||||
kind: 'constructed',
|
||||
source_vars: 'plugin: constructed',
|
||||
inputInventories: [{ id: 2 }],
|
||||
instanceGroups: [],
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
ConstructedInventoriesAPI.readOptions.mockResolvedValue({
|
||||
data: {
|
||||
related: {},
|
||||
actions: {
|
||||
POST: {
|
||||
limit: {
|
||||
label: 'Limit',
|
||||
help_text: '',
|
||||
},
|
||||
update_cache_timeout: {
|
||||
label: 'Update cache timeout',
|
||||
help_text: 'help',
|
||||
},
|
||||
verbosity: {
|
||||
label: 'Verbosity',
|
||||
help_text: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
history = createMemoryHistory({
|
||||
initialEntries: ['/inventories/constructed_inventory/add'],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<ConstructedInventoryAdd />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
test('should navigate to inventories list on cancel', async () => {
|
||||
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
|
||||
expect(history.location.pathname).toEqual(
|
||||
'/inventories/constructed_inventory/add'
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
|
||||
});
|
||||
expect(history.location.pathname).toEqual('/inventories');
|
||||
});
|
||||
|
||||
test('should navigate to constructed inventory detail after successful submission', async () => {
|
||||
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
|
||||
ConstructedInventoriesAPI.create.mockResolvedValueOnce({ data: { id: 1 } });
|
||||
expect(history.location.pathname).toEqual(
|
||||
'/inventories/constructed_inventory/add'
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper.find('ConstructedInventoryForm').invoke('onSubmit')(formData);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(history.location.pathname).toEqual(
|
||||
'/inventories/constructed_inventory/1/details'
|
||||
);
|
||||
});
|
||||
|
||||
test('should make expected api requests on submit', async () => {
|
||||
ConstructedInventoriesAPI.create.mockResolvedValueOnce({ data: { id: 1 } });
|
||||
await act(async () => {
|
||||
wrapper.find('ConstructedInventoryForm').invoke('onSubmit')(formData);
|
||||
});
|
||||
expect(ConstructedInventoriesAPI.create).toHaveBeenCalledTimes(1);
|
||||
expect(InventoriesAPI.associateInventory).toHaveBeenCalledTimes(1);
|
||||
expect(InventoriesAPI.associateInventory).toHaveBeenCalledWith(1, 2);
|
||||
expect(InventoriesAPI.associateInstanceGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('unsuccessful form submission should show an error message', async () => {
|
||||
const error = {
|
||||
response: {
|
||||
data: { detail: 'An error occurred' },
|
||||
},
|
||||
};
|
||||
ConstructedInventoriesAPI.create.mockImplementationOnce(() =>
|
||||
Promise.reject(error)
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<ConstructedInventoryAdd />);
|
||||
});
|
||||
expect(wrapper.length).toBe(1);
|
||||
expect(wrapper.find('ConstructedInventoryAdd').length).toBe(1);
|
||||
expect(wrapper.find('FormSubmitError').length).toBe(0);
|
||||
await act(async () => {
|
||||
wrapper.find('ConstructedInventoryForm').invoke('onSubmit')(formData);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('FormSubmitError').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -323,7 +323,7 @@ describe('<InventoryRelatedGroupList> for constructed inventories', () => {
|
||||
});
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: [
|
||||
'/inventories/constructed_inventory/1/groups/2/nested_groupss',
|
||||
'/inventories/constructed_inventory/1/groups/2/nested_groups',
|
||||
],
|
||||
});
|
||||
await act(async () => {
|
||||
|
||||
229
awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.js
Normal file
229
awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.js
Normal file
@ -0,0 +1,229 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { Formik, useField, useFormikContext } from 'formik';
|
||||
import { func, shape } from 'prop-types';
|
||||
import { t } from '@lingui/macro';
|
||||
import { ConstructedInventoriesAPI } from 'api';
|
||||
import { minMaxValue, required } from 'util/validators';
|
||||
import useRequest from 'hooks/useRequest';
|
||||
import { Form, FormGroup } from '@patternfly/react-core';
|
||||
import { VariablesField } from 'components/CodeEditor';
|
||||
import ContentError from 'components/ContentError';
|
||||
import ContentLoading from 'components/ContentLoading';
|
||||
import FormActionGroup from 'components/FormActionGroup/FormActionGroup';
|
||||
import FormField, { FormSubmitError } from 'components/FormField';
|
||||
import { FormFullWidthLayout, FormColumnLayout } from 'components/FormLayout';
|
||||
import InstanceGroupsLookup from 'components/Lookup/InstanceGroupsLookup';
|
||||
import InventoryLookup from 'components/Lookup/InventoryLookup';
|
||||
import OrganizationLookup from 'components/Lookup/OrganizationLookup';
|
||||
import Popover from 'components/Popover';
|
||||
import { VerbositySelectField } from 'components/VerbositySelectField';
|
||||
|
||||
import ConstructedInventoryHint from './ConstructedInventoryHint';
|
||||
import getInventoryHelpTextStrings from './Inventory.helptext';
|
||||
|
||||
const constructedPluginValidator = {
|
||||
plugin: required(t`The plugin parameter is required.`),
|
||||
};
|
||||
|
||||
function ConstructedInventoryFormFields({ inventory = {}, options }) {
|
||||
const helpText = getInventoryHelpTextStrings();
|
||||
const { setFieldValue, setFieldTouched } = useFormikContext();
|
||||
|
||||
const [instanceGroupsField, , instanceGroupsHelpers] =
|
||||
useField('instanceGroups');
|
||||
const [organizationField, organizationMeta, organizationHelpers] =
|
||||
useField('organization');
|
||||
const [inputInventoriesField, inputInventoriesMeta, inputInventoriesHelpers] =
|
||||
useField({
|
||||
name: 'inputInventories',
|
||||
validate: (value) => {
|
||||
if (value.length === 0) {
|
||||
return t`This field must not be blank`;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const handleOrganizationUpdate = useCallback(
|
||||
(value) => {
|
||||
setFieldValue('organization', value);
|
||||
setFieldTouched('organization', true, false);
|
||||
},
|
||||
[setFieldValue, setFieldTouched]
|
||||
);
|
||||
const handleInputInventoriesUpdate = useCallback(
|
||||
(value) => {
|
||||
setFieldValue('inputInventories', value);
|
||||
setFieldTouched('inputInventories', true, false);
|
||||
},
|
||||
[setFieldValue, setFieldTouched]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
id="name"
|
||||
label={t`Name`}
|
||||
name="name"
|
||||
type="text"
|
||||
validate={required(null)}
|
||||
isRequired
|
||||
/>
|
||||
<FormField
|
||||
id="description"
|
||||
label={t`Description`}
|
||||
name="description"
|
||||
type="text"
|
||||
/>
|
||||
<OrganizationLookup
|
||||
autoPopulate={!inventory?.id}
|
||||
helperTextInvalid={organizationMeta.error}
|
||||
isValid={!organizationMeta.touched || !organizationMeta.error}
|
||||
onBlur={() => organizationHelpers.setTouched()}
|
||||
onChange={handleOrganizationUpdate}
|
||||
validate={required(t`Select a value for this field`)}
|
||||
value={organizationField.value}
|
||||
required
|
||||
/>
|
||||
<InstanceGroupsLookup
|
||||
value={instanceGroupsField.value}
|
||||
onChange={(value) => {
|
||||
instanceGroupsHelpers.setValue(value);
|
||||
}}
|
||||
tooltip={t`Select the Instance Groups for this Inventory to run on.`}
|
||||
/>
|
||||
<FormGroup
|
||||
isRequired
|
||||
fieldId="input-inventories-lookup"
|
||||
id="input-inventories-lookup"
|
||||
helperTextInvalid={inputInventoriesMeta.error}
|
||||
label={t`Input Inventories`}
|
||||
labelIcon={
|
||||
<Popover
|
||||
content={t`Select Input Inventories for the constructed inventory plugin.`}
|
||||
/>
|
||||
}
|
||||
validated={
|
||||
!inputInventoriesMeta.touched || !inputInventoriesMeta.error
|
||||
? 'default'
|
||||
: 'error'
|
||||
}
|
||||
>
|
||||
<InventoryLookup
|
||||
fieldId="inputInventories"
|
||||
error={inputInventoriesMeta.error}
|
||||
onBlur={() => inputInventoriesHelpers.setTouched()}
|
||||
onChange={handleInputInventoriesUpdate}
|
||||
touched={inputInventoriesMeta.touched}
|
||||
value={inputInventoriesField.value}
|
||||
hideAdvancedInventories
|
||||
multiple
|
||||
required
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormField
|
||||
id="cache-timeout"
|
||||
label={t`Cache timeout (seconds)`}
|
||||
max="2147483647"
|
||||
min="0"
|
||||
name="update_cache_timeout"
|
||||
tooltip={options.update_cache_timeout.help_text}
|
||||
type="number"
|
||||
validate={minMaxValue(0, 2147483647)}
|
||||
/>
|
||||
<VerbositySelectField
|
||||
fieldId="verbosity"
|
||||
tooltip={options.verbosity.help_text}
|
||||
/>
|
||||
<FormFullWidthLayout>
|
||||
<ConstructedInventoryHint />
|
||||
</FormFullWidthLayout>
|
||||
<FormField
|
||||
id="limit"
|
||||
label={t`Limit`}
|
||||
name="limit"
|
||||
type="text"
|
||||
tooltip={options.limit.help_text}
|
||||
/>
|
||||
<FormFullWidthLayout>
|
||||
<VariablesField
|
||||
id="source_vars"
|
||||
name="source_vars"
|
||||
label={t`Source vars`}
|
||||
tooltip={helpText.constructedInventorySourceVars()}
|
||||
validators={constructedPluginValidator}
|
||||
isRequired
|
||||
/>
|
||||
</FormFullWidthLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ConstructedInventoryForm({ onCancel, onSubmit, submitError }) {
|
||||
const initialValues = {
|
||||
description: '',
|
||||
instanceGroups: [],
|
||||
kind: 'constructed',
|
||||
inputInventories: [],
|
||||
limit: '',
|
||||
name: '',
|
||||
organization: null,
|
||||
source_vars: '---',
|
||||
update_cache_timeout: 0,
|
||||
verbosity: 0,
|
||||
};
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
error,
|
||||
request: fetchOptions,
|
||||
result: options,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const res = await ConstructedInventoriesAPI.readOptions();
|
||||
const { data } = res;
|
||||
return data.actions.POST;
|
||||
}, []),
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOptions();
|
||||
}, [fetchOptions]);
|
||||
|
||||
if (isLoading || (!options && !error)) {
|
||||
return <ContentLoading />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ContentError error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Formik initialValues={initialValues} onSubmit={onSubmit}>
|
||||
{(formik) => (
|
||||
<Form role="form" autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||
<FormColumnLayout>
|
||||
<ConstructedInventoryFormFields options={options} />
|
||||
<FormSubmitError error={submitError} />
|
||||
<FormActionGroup
|
||||
onCancel={onCancel}
|
||||
onSubmit={formik.handleSubmit}
|
||||
/>
|
||||
</FormColumnLayout>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
}
|
||||
|
||||
ConstructedInventoryForm.propTypes = {
|
||||
onCancel: func.isRequired,
|
||||
onSubmit: func.isRequired,
|
||||
submitError: shape({}),
|
||||
};
|
||||
|
||||
ConstructedInventoryForm.defaultProps = {
|
||||
submitError: null,
|
||||
};
|
||||
|
||||
export default ConstructedInventoryForm;
|
||||
@ -0,0 +1,123 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { ConstructedInventoriesAPI } from 'api';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../testUtils/enzymeHelpers';
|
||||
import ConstructedInventoryForm from './ConstructedInventoryForm';
|
||||
|
||||
jest.mock('../../../api');
|
||||
|
||||
const mockFormValues = {
|
||||
kind: 'constructed',
|
||||
name: 'new constructed inventory',
|
||||
description: '',
|
||||
organization: { id: 1, name: 'mock organization' },
|
||||
instanceGroups: [],
|
||||
source_vars: 'plugin: constructed',
|
||||
inputInventories: [{ id: 100, name: 'East' }],
|
||||
};
|
||||
|
||||
describe('<ConstructedInventoryForm />', () => {
|
||||
let wrapper;
|
||||
const onSubmit = jest.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
ConstructedInventoriesAPI.readOptions.mockResolvedValue({
|
||||
data: {
|
||||
related: {},
|
||||
actions: {
|
||||
POST: {
|
||||
limit: {
|
||||
label: 'Limit',
|
||||
help_text: '',
|
||||
},
|
||||
update_cache_timeout: {
|
||||
label: 'Update cache timeout',
|
||||
help_text: 'help',
|
||||
},
|
||||
verbosity: {
|
||||
label: 'Verbosity',
|
||||
help_text: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<ConstructedInventoryForm onCancel={() => {}} onSubmit={onSubmit} />
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
test('should show expected form fields', () => {
|
||||
expect(wrapper.find('FormGroup[label="Name"]')).toHaveLength(1);
|
||||
expect(wrapper.find('FormGroup[label="Description"]')).toHaveLength(1);
|
||||
expect(wrapper.find('FormGroup[label="Organization"]')).toHaveLength(1);
|
||||
expect(wrapper.find('FormGroup[label="Instance Groups"]')).toHaveLength(1);
|
||||
expect(wrapper.find('FormGroup[label="Input Inventories"]')).toHaveLength(
|
||||
1
|
||||
);
|
||||
expect(
|
||||
wrapper.find('FormGroup[label="Cache timeout (seconds)"]')
|
||||
).toHaveLength(1);
|
||||
expect(wrapper.find('FormGroup[label="Verbosity"]')).toHaveLength(1);
|
||||
expect(wrapper.find('FormGroup[label="Limit"]')).toHaveLength(1);
|
||||
expect(wrapper.find('VariablesField[label="Source vars"]')).toHaveLength(1);
|
||||
expect(wrapper.find('ConstructedInventoryHint')).toHaveLength(1);
|
||||
expect(wrapper.find('Button[aria-label="Save"]')).toHaveLength(1);
|
||||
expect(wrapper.find('Button[aria-label="Cancel"]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('should show field error when form is saved without a input inventories', async () => {
|
||||
const inventoryErrorHelper = 'div#input-inventories-lookup-helper';
|
||||
expect(wrapper.find(inventoryErrorHelper).length).toBe(0);
|
||||
wrapper.find('input#name').simulate('change', {
|
||||
target: { value: mockFormValues.name, name: 'name' },
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper.find('button[aria-label="Save"]').simulate('click');
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find(inventoryErrorHelper).length).toBe(1);
|
||||
expect(wrapper.find(inventoryErrorHelper).text()).toContain(
|
||||
'This field must not be blank'
|
||||
);
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should show field error when form is saved without constructed plugin parameter', async () => {
|
||||
expect(wrapper.find('VariablesField .pf-m-error').length).toBe(0);
|
||||
await act(async () => {
|
||||
wrapper.find('VariablesField CodeEditor').invoke('onBlur')('');
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('VariablesField .pf-m-error').length).toBe(1);
|
||||
expect(wrapper.find('VariablesField .pf-m-error').text()).toBe(
|
||||
'The plugin parameter is required.'
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw content error when option request fails', async () => {
|
||||
let newWrapper;
|
||||
ConstructedInventoriesAPI.readOptions.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error())
|
||||
);
|
||||
await act(async () => {
|
||||
newWrapper = mountWithContexts(
|
||||
<ConstructedInventoryForm onCancel={() => {}} onSubmit={() => {}} />
|
||||
);
|
||||
});
|
||||
expect(newWrapper.find('ContentError').length).toBe(0);
|
||||
newWrapper.update();
|
||||
expect(newWrapper.find('ContentError').length).toBe(1);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
});
|
||||
164
awx/ui/src/screens/Inventory/shared/ConstructedInventoryHint.js
Normal file
164
awx/ui/src/screens/Inventory/shared/ConstructedInventoryHint.js
Normal file
@ -0,0 +1,164 @@
|
||||
import React from 'react';
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Alert,
|
||||
AlertActionLink,
|
||||
CodeBlock,
|
||||
CodeBlockAction,
|
||||
CodeBlockCode,
|
||||
ClipboardCopyButton,
|
||||
} from '@patternfly/react-core';
|
||||
import {
|
||||
TableComposable,
|
||||
Thead,
|
||||
Tr,
|
||||
Th,
|
||||
Tbody,
|
||||
Td,
|
||||
} from '@patternfly/react-table';
|
||||
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
|
||||
import getDocsBaseUrl from 'util/getDocsBaseUrl';
|
||||
import { useConfig } from 'contexts/Config';
|
||||
|
||||
function ConstructedInventoryHint() {
|
||||
const config = useConfig();
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const clipboardCopyFunc = (event, text) => {
|
||||
navigator.clipboard.writeText(text.toString());
|
||||
};
|
||||
|
||||
const onClick = (event, text) => {
|
||||
clipboardCopyFunc(event, text);
|
||||
setCopied(true);
|
||||
};
|
||||
|
||||
const pluginSample = `plugin: constructed
|
||||
strict: true
|
||||
use_vars_plugins: true
|
||||
groups:
|
||||
shutdown: resolved_state == "shutdown"
|
||||
shutdown_in_product_dev: resolved_state == "shutdown" and account_alias == "product_dev"
|
||||
compose:
|
||||
resolved_state: state | default("running")`;
|
||||
|
||||
return (
|
||||
<Alert
|
||||
isExpandable
|
||||
isInline
|
||||
variant="info"
|
||||
title={t`How to use constructed inventory plugin`}
|
||||
actionLinks={
|
||||
<AlertActionLink
|
||||
href={`${getDocsBaseUrl(
|
||||
config
|
||||
)}/html/userguide/inventories.html#constructed-inventories`}
|
||||
component="a"
|
||||
target="_blank"
|
||||
>
|
||||
{t`View constructed plugin documentation here`}{' '}
|
||||
<ExternalLinkAltIcon />
|
||||
</AlertActionLink>
|
||||
}
|
||||
>
|
||||
<span>{t`WIP - More to come...`}</span>
|
||||
<br />
|
||||
<br />
|
||||
<TableComposable
|
||||
aria-label={t`Constructed inventory parameters table`}
|
||||
variant="compact"
|
||||
>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>{t`Parameter`}</Th>
|
||||
<Th>{t`Description`}</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
<Tr ouiaId="plugin-row">
|
||||
<Td dataLabel={t`name`}>
|
||||
<code>plugin</code>
|
||||
<p style={{ color: 'blue' }}>{t`string`}</p>
|
||||
<p style={{ color: 'red' }}>{t`required`}</p>
|
||||
</Td>
|
||||
<Td dataLabel={t`description`}>
|
||||
{t`Token that ensures this is a source file
|
||||
for the ‘constructed’ plugin.`}
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr key="strict">
|
||||
<Td dataLabel={t`name`}>
|
||||
<code>strict</code>
|
||||
<p style={{ color: 'blue' }}>{t`boolean`}</p>
|
||||
</Td>
|
||||
<Td dataLabel={t`description`}>
|
||||
{t`If yes make invalid entries a fatal error, otherwise skip and
|
||||
continue.`}{' '}
|
||||
<br />
|
||||
{t`If users need feedback about the correctness
|
||||
of their constructed groups, it is highly recommended
|
||||
to use strict: true in the plugin configuration.`}
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr key="use_vars_plugins">
|
||||
<Td dataLabel={t`name`}>
|
||||
<code>use_vars_plugins</code>
|
||||
<p style={{ color: 'blue' }}>{t`string`}</p>
|
||||
</Td>
|
||||
<Td dataLabel={t`description`}>
|
||||
{t`Normally, for performance reasons, vars plugins get
|
||||
executed after the inventory sources complete the
|
||||
base inventory, this option allows for getting vars
|
||||
related to hosts/groups from those plugins.`}
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr key="groups">
|
||||
<Td dataLabel={t`name`}>
|
||||
<code>groups</code>
|
||||
<p style={{ color: 'blue' }}>{t`dictionary`}</p>
|
||||
</Td>
|
||||
<Td dataLabel={t`description`}>
|
||||
{t`Add hosts to group based on Jinja2 conditionals.`}
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr key="compose">
|
||||
<Td dataLabel={t`name`}>
|
||||
<code>compose</code>
|
||||
<p style={{ color: 'blue' }}>{t`dictionary`}</p>
|
||||
</Td>
|
||||
<Td dataLabel={t`description`}>
|
||||
{t`Create vars from jinja2 expressions.`}
|
||||
</Td>
|
||||
</Tr>
|
||||
</Tbody>
|
||||
</TableComposable>
|
||||
<br />
|
||||
<br />
|
||||
<b>{t`Sample constructed inventory plugin:`}</b>
|
||||
<CodeBlock
|
||||
actions={
|
||||
<CodeBlockAction>
|
||||
<ClipboardCopyButton
|
||||
id="basic-copy-button"
|
||||
textId="code-content"
|
||||
aria-label={t`Copy to clipboard`}
|
||||
onClick={(e) => onClick(e, pluginSample)}
|
||||
exitDelay={copied ? 1500 : 600}
|
||||
maxWidth="110px"
|
||||
variant="plain"
|
||||
onTooltipHidden={() => setCopied(false)}
|
||||
>
|
||||
{copied
|
||||
? t`Successfully copied to clipboard!`
|
||||
: t`Copy to clipboard`}
|
||||
</ClipboardCopyButton>
|
||||
</CodeBlockAction>
|
||||
}
|
||||
>
|
||||
<CodeBlockCode id="code-content">{pluginSample}</CodeBlockCode>
|
||||
</CodeBlock>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConstructedInventoryHint;
|
||||
@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import ConstructedInventoryHint from './ConstructedInventoryHint';
|
||||
|
||||
jest.mock('../../../api');
|
||||
|
||||
describe('<ConstructedInventoryHint />', () => {
|
||||
test('should render link to docs', () => {
|
||||
render(<ConstructedInventoryHint />);
|
||||
expect(
|
||||
screen.getByRole('link', {
|
||||
name: 'View constructed plugin documentation here',
|
||||
})
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should expand hint details', () => {
|
||||
const { container } = render(<ConstructedInventoryHint />);
|
||||
|
||||
expect(container.querySelector('table')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('code')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Info alert details' }));
|
||||
expect(container.querySelector('table')).toBeInTheDocument();
|
||||
expect(container.querySelector('code')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should copy sample plugin code block', () => {
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: () => {},
|
||||
},
|
||||
});
|
||||
jest.spyOn(navigator.clipboard, 'writeText');
|
||||
|
||||
const { container } = render(<ConstructedInventoryHint />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Info alert details' }));
|
||||
fireEvent.click(
|
||||
container.querySelector('button[aria-label="Copy to clipboard"]')
|
||||
);
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining('plugin: constructed')
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -19,6 +19,8 @@ const ansibleDocUrls = {
|
||||
rhv: 'https://docs.ansible.com/ansible/latest/collections/ovirt/ovirt/ovirt_inventory.html',
|
||||
vmware:
|
||||
'https://docs.ansible.com/ansible/latest/collections/community/vmware/vmware_vm_inventory_inventory.html',
|
||||
constructed:
|
||||
'https://docs.ansible.com/ansible/latest/collections/ansible/builtin/constructed_inventory.html',
|
||||
};
|
||||
|
||||
const getInventoryHelpTextStrings = () => ({
|
||||
@ -189,6 +191,42 @@ const getInventoryHelpTextStrings = () => ({
|
||||
</>
|
||||
);
|
||||
},
|
||||
constructedInventorySourceVars: () => {
|
||||
const yamlExample = `
|
||||
---
|
||||
plugin: constructed
|
||||
strict: true
|
||||
use_vars_plugins: true
|
||||
`;
|
||||
return (
|
||||
<>
|
||||
<Trans>
|
||||
Variables used to configure the constructed inventory plugin. For a
|
||||
detailed description of how to configure this plugin, see{' '}
|
||||
<a
|
||||
href={ansibleDocUrls.constructed}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
constructed inventory
|
||||
</a>{' '}
|
||||
plugin configuration guide.
|
||||
</Trans>
|
||||
<br />
|
||||
<br />
|
||||
<hr />
|
||||
<br />
|
||||
<Trans>
|
||||
Variables must be in JSON or YAML syntax. Use the radio button to
|
||||
toggle between the two.
|
||||
</Trans>
|
||||
<br />
|
||||
<br />
|
||||
<Trans>YAML:</Trans>
|
||||
<pre>{yamlExample}</pre>
|
||||
</>
|
||||
);
|
||||
},
|
||||
sourcePath: t`The inventory file
|
||||
to be synced by this source. You can select from
|
||||
the dropdown or enter a file within the input.`,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user