mirror of
https://github.com/ansible/awx.git
synced 2026-03-09 13:39:27 -02:30
Add constructed inventory add form
This commit is contained in:
committed by
Rick Elrod
parent
e3d167dfd1
commit
d576e65858
@@ -84,6 +84,7 @@
|
|||||||
"displayKey",
|
"displayKey",
|
||||||
"sortedColumnKey",
|
"sortedColumnKey",
|
||||||
"maxHeight",
|
"maxHeight",
|
||||||
|
"maxWidth",
|
||||||
"role",
|
"role",
|
||||||
"aria-haspopup",
|
"aria-haspopup",
|
||||||
"dropDirection",
|
"dropDirection",
|
||||||
@@ -97,7 +98,8 @@
|
|||||||
"data-cy",
|
"data-cy",
|
||||||
"fieldName",
|
"fieldName",
|
||||||
"splitButtonVariant",
|
"splitButtonVariant",
|
||||||
"pageKey"
|
"pageKey",
|
||||||
|
"textId"
|
||||||
],
|
],
|
||||||
"ignore": [
|
"ignore": [
|
||||||
"Ansible",
|
"Ansible",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class Inventories extends InstanceGroupsMixin(Base) {
|
|||||||
this.readGroupsOptions = this.readGroupsOptions.bind(this);
|
this.readGroupsOptions = this.readGroupsOptions.bind(this);
|
||||||
this.promoteGroup = this.promoteGroup.bind(this);
|
this.promoteGroup = this.promoteGroup.bind(this);
|
||||||
this.readInputInventories = this.readInputInventories.bind(this);
|
this.readInputInventories = this.readInputInventories.bind(this);
|
||||||
|
this.associateInventory = this.associateInventory.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
readAccessList(id, params) {
|
readAccessList(id, params) {
|
||||||
@@ -137,6 +138,12 @@ class Inventories extends InstanceGroupsMixin(Base) {
|
|||||||
disassociate: true,
|
disassociate: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
associateInventory(id, inputInventoryId) {
|
||||||
|
return this.http.post(`${this.baseUrl}${id}/input_inventories/`, {
|
||||||
|
id: inputInventoryId,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Inventories;
|
export default Inventories;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
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 { t } from '@lingui/macro';
|
||||||
import { useField } from 'formik';
|
import { useField } from 'formik';
|
||||||
@@ -38,6 +38,8 @@ function VariablesField({
|
|||||||
tooltip,
|
tooltip,
|
||||||
initialMode,
|
initialMode,
|
||||||
onModeChange,
|
onModeChange,
|
||||||
|
isRequired,
|
||||||
|
validators,
|
||||||
}) {
|
}) {
|
||||||
// track focus manually, because the Code Editor library doesn't wire
|
// track focus manually, because the Code Editor library doesn't wire
|
||||||
// into Formik completely
|
// into Formik completely
|
||||||
@@ -48,13 +50,22 @@ function VariablesField({
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
return error.message;
|
return error.message;
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
[shouldValidate]
|
[shouldValidate, validators]
|
||||||
);
|
);
|
||||||
const [field, meta, helpers] = useField({ name, validate });
|
const [field, meta, helpers] = useField({ name, validate });
|
||||||
const [mode, setMode] = useState(() =>
|
const [mode, setMode] = useState(() =>
|
||||||
@@ -120,6 +131,7 @@ function VariablesField({
|
|||||||
setMode={handleModeChange}
|
setMode={handleModeChange}
|
||||||
setShouldValidate={setShouldValidate}
|
setShouldValidate={setShouldValidate}
|
||||||
handleChange={handleChange}
|
handleChange={handleChange}
|
||||||
|
isRequired={isRequired}
|
||||||
/>
|
/>
|
||||||
<Modal
|
<Modal
|
||||||
variant="xlarge"
|
variant="xlarge"
|
||||||
@@ -157,7 +169,11 @@ function VariablesField({
|
|||||||
</Modal>
|
</Modal>
|
||||||
{meta.error ? (
|
{meta.error ? (
|
||||||
<div className="pf-c-form__helper-text pf-m-error" aria-live="polite">
|
<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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -171,12 +187,16 @@ VariablesField.propTypes = {
|
|||||||
promptId: string,
|
promptId: string,
|
||||||
initialMode: oneOf([YAML_MODE, JSON_MODE]),
|
initialMode: oneOf([YAML_MODE, JSON_MODE]),
|
||||||
onModeChange: func,
|
onModeChange: func,
|
||||||
|
isRequired: bool,
|
||||||
|
validators: shape({}),
|
||||||
};
|
};
|
||||||
VariablesField.defaultProps = {
|
VariablesField.defaultProps = {
|
||||||
readOnly: false,
|
readOnly: false,
|
||||||
promptId: null,
|
promptId: null,
|
||||||
initialMode: YAML_MODE,
|
initialMode: YAML_MODE,
|
||||||
onModeChange: () => {},
|
onModeChange: () => {},
|
||||||
|
isRequired: false,
|
||||||
|
validators: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
function VariablesFieldInternals({
|
function VariablesFieldInternals({
|
||||||
@@ -192,6 +212,7 @@ function VariablesFieldInternals({
|
|||||||
onExpand,
|
onExpand,
|
||||||
setShouldValidate,
|
setShouldValidate,
|
||||||
handleChange,
|
handleChange,
|
||||||
|
isRequired,
|
||||||
}) {
|
}) {
|
||||||
const [field, meta, helpers] = useField(name);
|
const [field, meta, helpers] = useField(name);
|
||||||
|
|
||||||
@@ -213,6 +234,12 @@ function VariablesFieldInternals({
|
|||||||
<SplitItem>
|
<SplitItem>
|
||||||
<label htmlFor={id} className="pf-c-form__label">
|
<label htmlFor={id} className="pf-c-form__label">
|
||||||
<span className="pf-c-form__label-text">{label}</span>
|
<span className="pf-c-form__label-text">{label}</span>
|
||||||
|
{isRequired && (
|
||||||
|
<span className="pf-c-form__label-required" aria-hidden="true">
|
||||||
|
{' '}
|
||||||
|
*{' '}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
{tooltip && <Popover content={tooltip} id={`${id}-tooltip`} />}
|
{tooltip && <Popover content={tooltip} id={`${id}-tooltip`} />}
|
||||||
</SplitItem>
|
</SplitItem>
|
||||||
|
|||||||
@@ -1,14 +1,54 @@
|
|||||||
/* eslint i18next/no-literal-string: "off" */
|
import React, { useState } from 'react';
|
||||||
import React from 'react';
|
import { useHistory } from 'react-router-dom';
|
||||||
import { Card, PageSection } from '@patternfly/react-core';
|
import { Card, PageSection } from '@patternfly/react-core';
|
||||||
|
import { ConstructedInventoriesAPI, InventoriesAPI } from 'api';
|
||||||
import { CardBody } from 'components/Card';
|
import { CardBody } from 'components/Card';
|
||||||
|
import ConstructedInventoryForm from '../shared/ConstructedInventoryForm';
|
||||||
|
|
||||||
function ConstructedInventoryAdd() {
|
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 (
|
return (
|
||||||
<PageSection>
|
<PageSection>
|
||||||
<Card>
|
<Card>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<div>Coming Soon!</div>
|
<ConstructedInventoryForm
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
submitError={submitError}
|
||||||
|
/>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
</PageSection>
|
</PageSection>
|
||||||
|
|||||||
@@ -1,15 +1,120 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { act } from 'react-dom/test-utils';
|
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';
|
import ConstructedInventoryAdd from './ConstructedInventoryAdd';
|
||||||
|
|
||||||
|
jest.mock('api');
|
||||||
|
|
||||||
describe('<ConstructedInventoryAdd />', () => {
|
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 () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<ConstructedInventoryAdd />);
|
wrapper = mountWithContexts(<ConstructedInventoryAdd />);
|
||||||
});
|
});
|
||||||
expect(wrapper.length).toBe(1);
|
expect(wrapper.find('FormSubmitError').length).toBe(0);
|
||||||
expect(wrapper.find('ConstructedInventoryAdd').length).toBe(1);
|
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({
|
const history = createMemoryHistory({
|
||||||
initialEntries: [
|
initialEntries: [
|
||||||
'/inventories/constructed_inventory/1/groups/2/nested_groupss',
|
'/inventories/constructed_inventory/1/groups/2/nested_groups',
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
await act(async () => {
|
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',
|
rhv: 'https://docs.ansible.com/ansible/latest/collections/ovirt/ovirt/ovirt_inventory.html',
|
||||||
vmware:
|
vmware:
|
||||||
'https://docs.ansible.com/ansible/latest/collections/community/vmware/vmware_vm_inventory_inventory.html',
|
'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 = () => ({
|
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
|
sourcePath: t`The inventory file
|
||||||
to be synced by this source. You can select from
|
to be synced by this source. You can select from
|
||||||
the dropdown or enter a file within the input.`,
|
the dropdown or enter a file within the input.`,
|
||||||
|
|||||||
Reference in New Issue
Block a user