diff --git a/awx/ui/.eslintrc.json b/awx/ui/.eslintrc.json index 7cf4965cbd..85eb903553 100644 --- a/awx/ui/.eslintrc.json +++ b/awx/ui/.eslintrc.json @@ -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", diff --git a/awx/ui/src/api/models/Inventories.js b/awx/ui/src/api/models/Inventories.js index 4fd145e178..8e586201b5 100644 --- a/awx/ui/src/api/models/Inventories.js +++ b/awx/ui/src/api/models/Inventories.js @@ -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; diff --git a/awx/ui/src/components/CodeEditor/VariablesField.js b/awx/ui/src/components/CodeEditor/VariablesField.js index bbe8312c3d..eb48332d12 100644 --- a/awx/ui/src/components/CodeEditor/VariablesField.js +++ b/awx/ui/src/components/CodeEditor/VariablesField.js @@ -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} /> {meta.error ? (
- {meta.error} + {(Array.isArray(meta.error) ? meta.error : [meta.error]).map( + (errorMessage) => ( +

{errorMessage}

+ ) + )}
) : null} @@ -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({ {tooltip && } diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.js b/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.js index 1aaa2b7679..4263088d5f 100644 --- a/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.js +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.js @@ -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 ( -
Coming Soon!
+
diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.test.js b/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.test.js index 0a9a6eedd5..1272bf3b2e 100644 --- a/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.test.js +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.test.js @@ -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('', () => { - 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(, { + 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(); }); - 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); }); }); diff --git a/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.test.js b/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.test.js index a8cba4aeac..3dad28809f 100644 --- a/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.test.js +++ b/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.test.js @@ -323,7 +323,7 @@ describe(' 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 () => { diff --git a/awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.js b/awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.js new file mode 100644 index 0000000000..6c1832c1d1 --- /dev/null +++ b/awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.js @@ -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 ( + <> + + + organizationHelpers.setTouched()} + onChange={handleOrganizationUpdate} + validate={required(t`Select a value for this field`)} + value={organizationField.value} + required + /> + { + instanceGroupsHelpers.setValue(value); + }} + tooltip={t`Select the Instance Groups for this Inventory to run on.`} + /> + + } + validated={ + !inputInventoriesMeta.touched || !inputInventoriesMeta.error + ? 'default' + : 'error' + } + > + inputInventoriesHelpers.setTouched()} + onChange={handleInputInventoriesUpdate} + touched={inputInventoriesMeta.touched} + value={inputInventoriesField.value} + hideAdvancedInventories + multiple + required + /> + + + + + + + + + + + + ); +} + +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 ; + } + + if (error) { + return ; + } + + return ( + + {(formik) => ( +
+ + + + + +
+ )} +
+ ); +} + +ConstructedInventoryForm.propTypes = { + onCancel: func.isRequired, + onSubmit: func.isRequired, + submitError: shape({}), +}; + +ConstructedInventoryForm.defaultProps = { + submitError: null, +}; + +export default ConstructedInventoryForm; diff --git a/awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.test.js b/awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.test.js new file mode 100644 index 0000000000..e3f50f1b93 --- /dev/null +++ b/awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.test.js @@ -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('', () => { + 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( + {}} 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( + {}} onSubmit={() => {}} /> + ); + }); + expect(newWrapper.find('ContentError').length).toBe(0); + newWrapper.update(); + expect(newWrapper.find('ContentError').length).toBe(1); + jest.clearAllMocks(); + }); +}); diff --git a/awx/ui/src/screens/Inventory/shared/ConstructedInventoryHint.js b/awx/ui/src/screens/Inventory/shared/ConstructedInventoryHint.js new file mode 100644 index 0000000000..34a3ca48f1 --- /dev/null +++ b/awx/ui/src/screens/Inventory/shared/ConstructedInventoryHint.js @@ -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 ( + + {t`View constructed plugin documentation here`}{' '} + + + } + > + {t`WIP - More to come...`} +
+
+ + + + {t`Parameter`} + {t`Description`} + + + + + + plugin +

{t`string`}

+

{t`required`}

+ + + {t`Token that ensures this is a source file + for the ‘constructed’ plugin.`} + + + + + strict +

{t`boolean`}

+ + + {t`If yes make invalid entries a fatal error, otherwise skip and + continue.`}{' '} +
+ {t`If users need feedback about the correctness + of their constructed groups, it is highly recommended + to use strict: true in the plugin configuration.`} + + + + + use_vars_plugins +

{t`string`}

+ + + {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.`} + + + + + groups +

{t`dictionary`}

+ + + {t`Add hosts to group based on Jinja2 conditionals.`} + + + + + compose +

{t`dictionary`}

+ + + {t`Create vars from jinja2 expressions.`} + + + +
+
+
+ {t`Sample constructed inventory plugin:`} + + onClick(e, pluginSample)} + exitDelay={copied ? 1500 : 600} + maxWidth="110px" + variant="plain" + onTooltipHidden={() => setCopied(false)} + > + {copied + ? t`Successfully copied to clipboard!` + : t`Copy to clipboard`} + + + } + > + {pluginSample} + +
+ ); +} + +export default ConstructedInventoryHint; diff --git a/awx/ui/src/screens/Inventory/shared/ConstructedInventoryHint.test.js b/awx/ui/src/screens/Inventory/shared/ConstructedInventoryHint.test.js new file mode 100644 index 0000000000..e4745773f4 --- /dev/null +++ b/awx/ui/src/screens/Inventory/shared/ConstructedInventoryHint.test.js @@ -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('', () => { + test('should render link to docs', () => { + render(); + expect( + screen.getByRole('link', { + name: 'View constructed plugin documentation here', + }) + ).toBeInTheDocument(); + }); + + test('should expand hint details', () => { + const { container } = render(); + + 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(); + 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') + ); + }); +}); diff --git a/awx/ui/src/screens/Inventory/shared/Inventory.helptext.js b/awx/ui/src/screens/Inventory/shared/Inventory.helptext.js index 158eaf62df..5345c115bb 100644 --- a/awx/ui/src/screens/Inventory/shared/Inventory.helptext.js +++ b/awx/ui/src/screens/Inventory/shared/Inventory.helptext.js @@ -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 ( + <> + + Variables used to configure the constructed inventory plugin. For a + detailed description of how to configure this plugin, see{' '} + + constructed inventory + {' '} + plugin configuration guide. + +
+
+
+
+ + Variables must be in JSON or YAML syntax. Use the radio button to + toggle between the two. + +
+
+ YAML: +
{yamlExample}
+ + ); + }, sourcePath: t`The inventory file to be synced by this source. You can select from the dropdown or enter a file within the input.`,