Add constructed inventory add form

This commit is contained in:
Marliana Lara 2023-02-23 10:02:44 -05:00 committed by Rick Elrod
parent e3d167dfd1
commit d576e65858
11 changed files with 795 additions and 14 deletions

View File

@ -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",

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -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);
});
});

View File

@ -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 () => {

View 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;

View File

@ -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();
});
});

View 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;

View File

@ -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')
);
});
});

View File

@ -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.`,