From 5864d61b5b2229c448216119ae76615aa8251468 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Fri, 17 Jul 2020 16:25:45 -0400 Subject: [PATCH 1/4] Refactor organization look to use useRequest hook --- awx/ui_next/src/components/Lookup/OrganizationLookup.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx b/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx index 82274cd7fc..ec60c553cd 100644 --- a/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx +++ b/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx @@ -40,7 +40,7 @@ function OrganizationLookup({ organizations: data.results, itemCount: data.count, }; - }, [history.location]), + }, [history.location.search]), { organizations: [], itemCount: 0, From 6a304dce55d107b77bbee5a6907e1d7907f5137f Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Fri, 17 Jul 2020 16:27:57 -0400 Subject: [PATCH 2/4] Add smart inventory add form and host filter lookup --- .../components/Lookup/HostFilterLookup.jsx | 345 ++++++++++++++++++ awx/ui_next/src/components/Lookup/index.js | 1 + .../Lookup/shared/HostFilterUtils.jsx | 107 ++++++ .../Lookup/shared/HostFilterUtils.test.jsx | 109 ++++++ .../InventorySourceEdit.test.jsx | 2 +- .../SmartInventoryAdd/SmartInventoryAdd.jsx | 76 +++- .../SmartInventoryAdd.test.jsx | 139 +++++++ .../SmartInventoryDetail.test.jsx | 2 +- .../Inventory/shared/SmartInventoryForm.jsx | 176 +++++++++ .../shared/SmartInventoryForm.test.jsx | 176 +++++++++ .../shared/data.smart_inventory.json | 2 +- 11 files changed, 1126 insertions(+), 9 deletions(-) create mode 100644 awx/ui_next/src/components/Lookup/HostFilterLookup.jsx create mode 100644 awx/ui_next/src/components/Lookup/shared/HostFilterUtils.jsx create mode 100644 awx/ui_next/src/components/Lookup/shared/HostFilterUtils.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/SmartInventoryAdd/SmartInventoryAdd.test.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx create mode 100644 awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.test.jsx diff --git a/awx/ui_next/src/components/Lookup/HostFilterLookup.jsx b/awx/ui_next/src/components/Lookup/HostFilterLookup.jsx new file mode 100644 index 0000000000..dbbf5a1d9a --- /dev/null +++ b/awx/ui_next/src/components/Lookup/HostFilterLookup.jsx @@ -0,0 +1,345 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { withRouter, useHistory, useLocation } from 'react-router-dom'; +import { number, func, bool, string } from 'prop-types'; +import { withI18n } from '@lingui/react'; +import styled from 'styled-components'; +import { t } from '@lingui/macro'; +import { SearchIcon } from '@patternfly/react-icons'; +import { + Button, + ButtonVariant, + Chip, + FormGroup, + InputGroup, + Modal, +} from '@patternfly/react-core'; +import ChipGroup from '../ChipGroup'; +import DataListToolbar from '../DataListToolbar'; +import LookupErrorMessage from './shared/LookupErrorMessage'; +import PaginatedDataList, { PaginatedDataListItem } from '../PaginatedDataList'; +import { HostsAPI } from '../../api'; +import { getQSConfig, mergeParams, parseQueryString } from '../../util/qs'; +import useRequest, { useDismissableError } from '../../util/useRequest'; +import { + removeDefaultParams, + removeNamespacedKeys, + toHostFilter, + toQueryString, + toSearchParams, +} from './shared/HostFilterUtils'; + +const ChipHolder = styled.div` + --pf-c-form-control--Height: auto; + .pf-c-chip-group { + margin-right: 8px; + } +`; + +const ModalList = styled.div` + .pf-c-toolbar__content { + padding: 0 !important; + } +`; + +const useModal = () => { + const [isModalOpen, setIsModalOpen] = useState(false); + + function toggleModal() { + setIsModalOpen(!isModalOpen); + } + + function closeModal() { + setIsModalOpen(false); + } + + return { + isModalOpen, + toggleModal, + closeModal, + }; +}; + +const QS_CONFIG = getQSConfig( + 'smart_hosts', + { + page: 1, + page_size: 5, + order_by: 'name', + }, + ['id', 'page', 'page_size', 'inventory'] +); + +const buildSearchColumns = i18n => [ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true, + }, + { + name: i18n._(t`ID`), + key: 'id', + }, + { + name: i18n._(t`Group`), + key: 'groups__name', + }, + { + name: i18n._(t`Inventory`), + key: 'inventory', + }, + { + name: i18n._(t`Enabled`), + key: 'enabled', + }, + { + name: i18n._(t`Instance ID`), + key: 'instance_id', + }, + { + name: i18n._(t`Last job`), + key: 'last_job', + }, + { + name: i18n._(t`Insights system ID`), + key: 'insights_system_id', + }, + { + name: i18n._(t`Ansible facts modified`), + key: 'ansible_facts_modified', + }, +]; + +function HostFilterLookup({ + helperTextInvalid, + i18n, + isValid, + isDisabled, + onBlur, + onChange, + organizationId, + value, +}) { + const history = useHistory(); + const location = useLocation(); + const [chips, setChips] = useState({}); + const [queryString, setQueryString] = useState(''); + const { isModalOpen, toggleModal, closeModal } = useModal(); + const searchColumns = buildSearchColumns(i18n); + + const { + result: { count, hosts }, + error: contentError, + request: fetchHosts, + isLoading, + } = useRequest( + useCallback( + async orgId => { + const params = parseQueryString(QS_CONFIG, location.search); + const { data } = await HostsAPI.read( + mergeParams(params, { inventory__organization: orgId }) + ); + return { + count: data.count, + hosts: data.results, + }; + }, + [location.search] + ), + { + count: 0, + hosts: [], + } + ); + + const { error, dismissError } = useDismissableError(contentError); + + useEffect(() => { + if (isModalOpen && organizationId) { + dismissError(); + fetchHosts(organizationId); + } + }, [fetchHosts, organizationId, isModalOpen]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + const filters = toSearchParams(value); + setQueryString(toQueryString(QS_CONFIG, filters)); + setChips(buildChips(filters)); + }, [value]); + + function qsToHostFilter(qs) { + const searchParams = toSearchParams(qs); + const withoutNamespace = removeNamespacedKeys(QS_CONFIG, searchParams); + const withoutDefaultParams = removeDefaultParams( + QS_CONFIG, + withoutNamespace + ); + return toHostFilter(withoutDefaultParams); + } + + const save = () => { + const hostFilterString = qsToHostFilter(location.search); + onChange(hostFilterString); + closeModal(); + history.replace({ + pathname: `${location.pathname}`, + search: '', + }); + }; + + function buildChips(filter = {}) { + const inputGroupChips = Object.keys(filter).reduce((obj, param) => { + const parsedKey = param.replace('__icontains', '').replace('or__', ''); + const chipsArray = []; + + if (Array.isArray(filter[param])) { + filter[param].forEach(val => + chipsArray.push({ + key: `${param}:${val}`, + node: `${val}`, + }) + ); + } else { + chipsArray.push({ + key: `${param}:${filter[param]}`, + node: `${filter[param]}`, + }); + } + + obj[parsedKey] = { + key: parsedKey, + label: filter[param], + chips: [...chipsArray], + }; + + return obj; + }, {}); + + return inputGroupChips; + } + + const handleOpenModal = () => { + history.replace({ + pathname: `${location.pathname}`, + search: queryString, + }); + toggleModal(); + }; + + const handleClose = () => { + closeModal(); + history.replace({ + pathname: `${location.pathname}`, + search: '', + }); + }; + + return ( + + + + + {searchColumns.map(({ name, key }) => ( + + {chips[key]?.chips?.map((chip, index) => ( + + {chip.node} + + ))} + + ))} + + + + {i18n._(t`Select`)} + , + , + ]} + > + + {}} + pluralizedItemName={i18n._(t`hosts`)} + qsConfig={QS_CONFIG} + renderItem={item => ( + + )} + renderToolbar={props => } + toolbarSearchColumns={searchColumns} + toolbarSortColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + }, + { + name: i18n._(t`Created`), + key: 'created', + }, + { + name: i18n._(t`Modified`), + key: 'modified', + }, + ]} + /> + + + + + ); +} + +HostFilterLookup.propTypes = { + isValid: bool, + onBlur: func, + onChange: func, + organizationId: number, + value: string, +}; +HostFilterLookup.defaultProps = { + isValid: true, + onBlur: () => {}, + onChange: () => {}, + organizationId: null, + value: '', +}; + +export default withI18n()(withRouter(HostFilterLookup)); diff --git a/awx/ui_next/src/components/Lookup/index.js b/awx/ui_next/src/components/Lookup/index.js index fb99cd5681..2b9b147941 100644 --- a/awx/ui_next/src/components/Lookup/index.js +++ b/awx/ui_next/src/components/Lookup/index.js @@ -5,3 +5,4 @@ export { default as ProjectLookup } from './ProjectLookup'; export { default as MultiCredentialsLookup } from './MultiCredentialsLookup'; export { default as CredentialLookup } from './CredentialLookup'; export { default as ApplicationLookup } from './ApplicationLookup'; +export { default as HostFilterLookup } from './HostFilterLookup'; diff --git a/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.jsx b/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.jsx new file mode 100644 index 0000000000..f4b803784d --- /dev/null +++ b/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.jsx @@ -0,0 +1,107 @@ +/** + * Convert host filter string to params object + * @param {string} string host filter string + * @return {object} A string or array of strings keyed by query param key + */ +export function toSearchParams(string = '') { + if (string === '') { + return {}; + } + + return string + .replace(/^\?/, '') + .replace(/&/, ' and ') + .split(/ and | or /) + .map(s => s.split('=')) + .reduce((searchParams, [key, value]) => { + if (searchParams[key] === undefined) { + searchParams[key] = value; + } else if (Array.isArray(searchParams[key])) { + searchParams[key] = [...searchParams[key], value]; + } else { + searchParams[key] = [searchParams[key], value]; + } + return searchParams; + }, {}); +} + +/** + * Convert params object to an encoded namespaced url query string + * Used to put into url bar when modal opens + * @param {object} config Config object for namespacing params + * @param {object} obj A string or array of strings keyed by query param key + * @return {string} URL query string + */ +export function toQueryString(config, searchParams = {}) { + if (Object.keys(searchParams).length === 0) return ''; + + return Object.keys(searchParams) + .flatMap(key => { + if (Array.isArray(searchParams[key])) { + return searchParams[key].map( + val => + `${config.namespace}.${encodeURIComponent( + key + )}=${encodeURIComponent(val)}` + ); + } + return `${config.namespace}.${encodeURIComponent( + key + )}=${encodeURIComponent(searchParams[key])}`; + }) + .join('&'); +} + +/** + * Convert params object to host filter string + * @param {object} obj A string or array of strings keyed by query param key + * @return {string} Host filter string + */ +export function toHostFilter(searchParams = {}) { + return Object.keys(searchParams) + .flatMap(key => { + if (Array.isArray(searchParams[key])) { + return searchParams[key].map( + val => `${encodeURIComponent(key)}=${encodeURIComponent(val)}` + ); + } + return `${encodeURIComponent(key)}=${encodeURIComponent( + searchParams[key] + )}`; + }) + .join(' and '); +} + +/** + * Helper function to remove namespace from params object + * @param {object} config Config object with namespace param + * @param {object} obj A string or array of strings keyed by query param key + * @return {object} Params object without namespaced keys + */ +export function removeNamespacedKeys(config, obj = {}) { + const clonedObj = Object.assign({}, obj); + const newObj = {}; + Object.keys(clonedObj).forEach(nsKey => { + let key = nsKey; + if (nsKey.startsWith(config.namespace)) { + key = nsKey.substr(config.namespace.length + 1); + } + newObj[key] = clonedObj[nsKey]; + }); + return newObj; +} + +/** + * Helper function to remove default params from params object + * @param {object} config Config object with default params + * @param {object} obj A string or array of strings keyed by query param key + * @return {string} Params object without default params + */ +export function removeDefaultParams(config, obj = {}) { + const clonedObj = Object.assign({}, obj); + const defaultKeys = Object.keys(config.defaultParams); + defaultKeys.forEach(keyToOmit => { + delete clonedObj[keyToOmit]; + }); + return clonedObj; +} diff --git a/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.test.jsx b/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.test.jsx new file mode 100644 index 0000000000..c381ec4082 --- /dev/null +++ b/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.test.jsx @@ -0,0 +1,109 @@ +import { + removeDefaultParams, + removeNamespacedKeys, + toHostFilter, + toQueryString, + toSearchParams, +} from './HostFilterUtils'; + +const QS_CONFIG = { + namespace: 'mock', + defaultParams: { page: 1, page_size: 5, order_by: 'name' }, + integerFields: ['page', 'page_size', 'id', 'inventory'], +}; + +describe('toSearchParams', () => { + let string; + let paramsObject; + + test('should return an empty object', () => { + expect(toSearchParams(undefined)).toEqual({}); + expect(toSearchParams('')).toEqual({}); + }); + test('should take a query string and return search params object', () => { + string = '?foo=bar'; + paramsObject = { foo: 'bar' }; + expect(toSearchParams(string)).toEqual(paramsObject); + }); + test('should take a host filter string and return search params object', () => { + string = 'foo=bar and foo=baz and foo=qux and isa=sampu'; + paramsObject = { + foo: ['bar', 'baz', 'qux'], + isa: 'sampu', + }; + expect(toSearchParams(string)).toEqual(paramsObject); + }); +}); + +describe('toQueryString', () => { + test('should return an empty string', () => { + expect(toQueryString(QS_CONFIG, undefined)).toEqual(''); + }); + test('should return namespaced query string with a single key-value pair', () => { + const object = { + foo: 'bar', + }; + expect(toQueryString(QS_CONFIG, object)).toEqual('mock.foo=bar'); + }); + test('should return namespaced query string with multiple values per key', () => { + const object = { + foo: ['bar', 'baz'], + }; + expect(toQueryString(QS_CONFIG, object)).toEqual( + 'mock.foo=bar&mock.foo=baz' + ); + }); + test('should return namespaced query string with multiple key-value pairs', () => { + const object = { + foo: ['bar', 'baz', 'qux'], + isa: 'sampu', + }; + expect(toQueryString(QS_CONFIG, object)).toEqual( + 'mock.foo=bar&mock.foo=baz&mock.foo=qux&mock.isa=sampu' + ); + }); +}); + +describe('toHostFilter', () => { + test('should return an empty string', () => { + expect(toHostFilter(undefined)).toEqual(''); + }); + test('should return a host filter string', () => { + const object = { + isa: '2', + tatlo: ['foo', 'bar', 'baz'], + }; + expect(toHostFilter(object)).toEqual( + 'isa=2 and tatlo=foo and tatlo=bar and tatlo=baz' + ); + }); +}); + +describe('removeNamespacedKeys', () => { + test('should return an empty object', () => { + expect(removeNamespacedKeys(QS_CONFIG, undefined)).toEqual({}); + }); + test('should remove namespace from keys', () => { + expect(removeNamespacedKeys(QS_CONFIG, { 'mock.foo': 'bar' })).toEqual({ + foo: 'bar', + }); + }); +}); + +describe('removeDefaultParams', () => { + test('should return an empty object', () => { + expect(removeDefaultParams(QS_CONFIG, undefined)).toEqual({}); + }); + test('should remove default params', () => { + const object = { + foo: ['bar', 'baz', 'qux'], + apat: 'lima', + page: 10, + order_by: '-name', + }; + expect(removeDefaultParams(QS_CONFIG, object)).toEqual({ + foo: ['bar', 'baz', 'qux'], + apat: 'lima', + }); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventorySourceEdit/InventorySourceEdit.test.jsx b/awx/ui_next/src/screens/Inventory/InventorySourceEdit/InventorySourceEdit.test.jsx index 0d2e88d592..5198c523d3 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySourceEdit/InventorySourceEdit.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySourceEdit/InventorySourceEdit.test.jsx @@ -117,7 +117,7 @@ describe('', () => { ); }); - test('should navigate to inventory sources list when cancel is clicked', async () => { + test('should navigate to inventory source detail when cancel is clicked', async () => { await act(async () => { wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); }); diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryAdd/SmartInventoryAdd.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryAdd/SmartInventoryAdd.jsx index d29aa3ee5d..3accf3d0c5 100644 --- a/awx/ui_next/src/screens/Inventory/SmartInventoryAdd/SmartInventoryAdd.jsx +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryAdd/SmartInventoryAdd.jsx @@ -1,10 +1,74 @@ -import React, { Component } from 'react'; -import { PageSection } from '@patternfly/react-core'; +import React, { useCallback, useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; +import { Card, PageSection } from '@patternfly/react-core'; +import { CardBody } from '../../../components/Card'; +import SmartInventoryForm from '../shared/SmartInventoryForm'; +import useRequest from '../../../util/useRequest'; +import { InventoriesAPI } from '../../../api'; -class SmartInventoryAdd extends Component { - render() { - return Coming soon :); - } +function SmartInventoryAdd() { + const history = useHistory(); + + const { + error: submitError, + request: submitRequest, + result: inventoryId, + } = useRequest( + useCallback(async (values, groupsToAssociate) => { + const { + data: { id: invId }, + } = await InventoriesAPI.create(values); + + await Promise.all( + groupsToAssociate.map(({ id }) => + InventoriesAPI.associateInstanceGroup(invId, id) + ) + ); + return invId; + }, []) + ); + + const handleSubmit = async form => { + const { instance_groups, organization, ...remainingForm } = form; + + await submitRequest( + { + organization: organization?.id, + ...remainingForm, + }, + instance_groups + ); + }; + + const handleCancel = () => { + history.push({ + pathname: '/inventories', + search: '', + }); + }; + + useEffect(() => { + if (inventoryId) { + history.push({ + pathname: `/inventories/smart_inventory/${inventoryId}/details`, + search: '', + }); + } + }, [inventoryId, history]); + + return ( + + + + + + + + ); } export default SmartInventoryAdd; diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryAdd/SmartInventoryAdd.test.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryAdd/SmartInventoryAdd.test.jsx new file mode 100644 index 0000000000..b25ba03559 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryAdd/SmartInventoryAdd.test.jsx @@ -0,0 +1,139 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; +import SmartInventoryAdd from './SmartInventoryAdd'; +import { + InventoriesAPI, + OrganizationsAPI, + InstanceGroupsAPI, +} from '../../../api'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + }), +})); + +jest.mock('../../../api/models/Inventories'); +jest.mock('../../../api/models/Organizations'); +jest.mock('../../../api/models/InstanceGroups'); +OrganizationsAPI.read.mockResolvedValue({ data: { results: [], count: 0 } }); +InstanceGroupsAPI.read.mockResolvedValue({ data: { results: [], count: 0 } }); + +const formData = { + name: 'Mock', + description: 'Foo', + organization: { id: 1 }, + kind: 'smart', + host_filter: 'name__icontains=mock', + variables: '---', + instance_groups: [{ id: 2 }], +}; + +describe('', () => { + describe('when initialized by users with POST capability', () => { + let history; + let wrapper; + + beforeAll(async () => { + InventoriesAPI.create.mockResolvedValueOnce({ data: { id: 1 } }); + InventoriesAPI.readOptions.mockResolvedValue({ + data: { actions: { POST: true } }, + }); + history = createMemoryHistory({ + initialEntries: [`/inventories/smart_inventory/add`], + }); + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + + afterAll(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('should enable save button', () => { + expect(wrapper.find('Button[aria-label="Save"]').prop('isDisabled')).toBe( + false + ); + }); + + test('should post to the api when submit is clicked', async () => { + await act(async () => { + wrapper.find('SmartInventoryForm').invoke('onSubmit')(formData); + }); + const { instance_groups, ...formRequest } = formData; + expect(InventoriesAPI.create).toHaveBeenCalledTimes(1); + expect(InventoriesAPI.create).toHaveBeenCalledWith({ + ...formRequest, + organization: formRequest.organization.id, + }); + expect(InventoriesAPI.associateInstanceGroup).toHaveBeenCalledTimes(1); + expect(InventoriesAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 2); + }); + + test('successful form submission should trigger redirect to details', async () => { + expect(history.location.pathname).toEqual( + '/inventories/smart_inventory/1/details' + ); + }); + + test('should navigate to inventory list when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); + expect(history.location.pathname).toEqual('/inventories'); + }); + + test('unsuccessful form submission should show an error message', async () => { + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + InventoriesAPI.create.mockImplementationOnce(() => Promise.reject(error)); + await act(async () => { + wrapper = mountWithContexts(); + }); + expect(wrapper.find('FormSubmitError').length).toBe(0); + await act(async () => { + wrapper.find('SmartInventoryForm').invoke('onSubmit')({}); + }); + wrapper.update(); + expect(wrapper.find('FormSubmitError').length).toBe(1); + }); + }); + + describe('when initialized by users without POST capability', () => { + let wrapper; + + beforeAll(async () => { + InventoriesAPI.readOptions.mockResolvedValueOnce({ + data: { actions: { POST: false } }, + }); + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + afterAll(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('should disable save button', () => { + expect(wrapper.find('Button[aria-label="Save"]').prop('isDisabled')).toBe( + true + ); + }); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryDetail/SmartInventoryDetail.test.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryDetail/SmartInventoryDetail.test.jsx index 5207972921..988c3b99a8 100644 --- a/awx/ui_next/src/screens/Inventory/SmartInventoryDetail/SmartInventoryDetail.test.jsx +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryDetail/SmartInventoryDetail.test.jsx @@ -58,7 +58,7 @@ describe('', () => { assertDetail('Description', 'smart inv description'); assertDetail('Type', 'Smart inventory'); assertDetail('Organization', 'Default'); - assertDetail('Smart host filter', 'search=local'); + assertDetail('Smart host filter', 'name__icontains=local'); assertDetail('Instance groups', 'mock instance group'); expect(wrapper.find(`Detail[label="Activity"] Sparkline`)).toHaveLength( 1 diff --git a/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx new file mode 100644 index 0000000000..971f848142 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx @@ -0,0 +1,176 @@ +import React, { useEffect, useCallback } from 'react'; +import { Formik, useField } from 'formik'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { func, shape, object, arrayOf } from 'prop-types'; +import { Form } from '@patternfly/react-core'; +import { VariablesField } from '../../../components/CodeMirrorInput'; +import ContentError from '../../../components/ContentError'; +import ContentLoading from '../../../components/ContentLoading'; +import FormActionGroup from '../../../components/FormActionGroup'; +import FormField, { FormSubmitError } from '../../../components/FormField'; +import { + FormColumnLayout, + FormFullWidthLayout, +} from '../../../components/FormLayout'; +import HostFilterLookup from '../../../components/Lookup/HostFilterLookup'; +import InstanceGroupsLookup from '../../../components/Lookup/InstanceGroupsLookup'; +import OrganizationLookup from '../../../components/Lookup/OrganizationLookup'; +import useRequest from '../../../util/useRequest'; +import { required } from '../../../util/validators'; +import { InventoriesAPI } from '../../../api'; + +const SmartInventoryFormFields = withI18n()(({ i18n }) => { + const [organizationField, organizationMeta, organizationHelpers] = useField({ + name: 'organization', + validate: required(i18n._(t`Select a value for this field`), i18n), + }); + const [instanceGroupsField, , instanceGroupsHelpers] = useField({ + name: 'instance_groups', + }); + const [hostFilterField, hostFilterMeta, hostFilterHelpers] = useField({ + name: 'host_filter', + validate: required(null, i18n), + }); + + return ( + <> + + + organizationHelpers.setTouched()} + onChange={value => { + organizationHelpers.setValue(value); + }} + value={organizationField.value} + required + /> + { + hostFilterHelpers.setValue(value); + }} + onBlur={() => hostFilterHelpers.setTouched()} + isValid={!hostFilterMeta.touched || !hostFilterMeta.error} + isDisabled={!organizationField.value} + /> + { + instanceGroupsHelpers.setValue(value); + }} + /> + + + + + ); +}); + +function SmartInventoryForm({ + inventory, + instanceGroups, + onSubmit, + onCancel, + submitError, +}) { + const initialValues = { + description: inventory.description || '', + host_filter: inventory.host_filter || '', + instance_groups: instanceGroups || [], + kind: 'smart', + name: inventory.name || '', + organization: inventory.summary_fields?.organization || null, + variables: inventory.variables || '---', + }; + + const { + isLoading, + error: optionsError, + request: fetchOptions, + result: options, + } = useRequest( + useCallback(async () => { + const { data } = await InventoriesAPI.readOptions(); + return data; + }, []), + null + ); + + useEffect(() => { + fetchOptions(); + }, [fetchOptions]); + + if (isLoading) { + return ; + } + + if (optionsError) { + return ; + } + + return ( + { + onSubmit(values); + }} + > + {formik => ( +
+ + + {submitError && } + + +
+ )} +
+ ); +} + +SmartInventoryForm.propTypes = { + instanceGroups: arrayOf(object), + inventory: shape({}), + onCancel: func.isRequired, + onSubmit: func.isRequired, + submitError: shape({}), +}; + +SmartInventoryForm.defaultProps = { + instanceGroups: [], + inventory: {}, + submitError: null, +}; + +export default withI18n()(SmartInventoryForm); diff --git a/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.test.jsx new file mode 100644 index 0000000000..34d7fb72c7 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.test.jsx @@ -0,0 +1,176 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; +import SmartInventoryForm from './SmartInventoryForm'; +import { + InventoriesAPI, + OrganizationsAPI, + InstanceGroupsAPI, +} from '../../../api'; + +jest.mock('../../../api/models/Inventories'); +jest.mock('../../../api/models/Organizations'); +jest.mock('../../../api/models/InstanceGroups'); +OrganizationsAPI.read.mockResolvedValue({ data: { results: [], count: 0 } }); +InstanceGroupsAPI.read.mockResolvedValue({ data: { results: [], count: 0 } }); +InventoriesAPI.readOptions.mockResolvedValue({ + data: { actions: { POST: true } }, +}); + +const mockFormValues = { + kind: 'smart', + name: 'new smart inventory', + description: '', + organization: { id: 1, name: 'mock organization' }, + host_filter: + 'name__icontains=mock and name__icontains=foo and groups__name=mock group', + instance_groups: [{ id: 123 }], + variables: '---', +}; + +describe('', () => { + describe('when initialized by users with POST capability', () => { + let wrapper; + const onSubmit = jest.fn(); + + beforeAll(async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} onSubmit={onSubmit} /> + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + + afterAll(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('should enable save button', () => { + expect(wrapper.find('Button[aria-label="Save"]').prop('isDisabled')).toBe( + false + ); + }); + + 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="Host filter"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Instance Groups"]')).toHaveLength( + 1 + ); + expect(wrapper.find('VariablesField[label="Variables"]')).toHaveLength(1); + expect(wrapper.find('Button[aria-label="Save"]')).toHaveLength(1); + expect(wrapper.find('Button[aria-label="Cancel"]')).toHaveLength(1); + }); + + test('should enable host filter field when organization field has a value', async () => { + expect(wrapper.find('HostFilterLookup').prop('isDisabled')).toBe(true); + await act(async () => { + wrapper.find('OrganizationLookup').invoke('onBlur')(); + wrapper.find('OrganizationLookup').invoke('onChange')( + mockFormValues.organization + ); + }); + wrapper.update(); + expect(wrapper.find('HostFilterLookup').prop('isDisabled')).toBe(false); + }); + + test('should show error when form is saved without a host filter value', async () => { + expect(wrapper.find('HostFilterLookup #host-filter-helper').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(); + const hostFilterError = wrapper.find( + 'HostFilterLookup #host-filter-helper' + ); + expect(hostFilterError.length).toBe(1); + expect(hostFilterError.text()).toContain('This field must not be blank'); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + test('should display filter chips when host filter has a value', async () => { + await act(async () => { + wrapper.find('HostFilterLookup').invoke('onBlur')(); + wrapper.find('HostFilterLookup').invoke('onChange')( + mockFormValues.host_filter + ); + }); + wrapper.update(); + const nameChipGroup = wrapper.find( + 'HostFilterLookup ChipGroup[categoryName="Name"]' + ); + const groupChipGroup = wrapper.find( + 'HostFilterLookup ChipGroup[categoryName="Group"]' + ); + expect(nameChipGroup.find('Chip').length).toBe(2); + expect(groupChipGroup.find('Chip').length).toBe(1); + }); + + test('should submit expected form values on save', async () => { + await act(async () => { + wrapper.find('InstanceGroupsLookup').invoke('onChange')( + mockFormValues.instance_groups + ); + }); + wrapper.update(); + await act(async () => { + wrapper.find('button[aria-label="Save"]').simulate('click'); + }); + wrapper.update(); + expect(onSubmit).toHaveBeenCalledWith(mockFormValues); + }); + }); + + test('should throw content error when option request fails', async () => { + let wrapper; + InventoriesAPI.readOptions.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + await act(async () => { + wrapper = mountWithContexts( + {}} onSubmit={() => {}} /> + ); + }); + expect(wrapper.find('ContentError').length).toBe(0); + wrapper.update(); + expect(wrapper.find('ContentError').length).toBe(1); + wrapper.unmount(); + jest.clearAllMocks(); + }); + + test('should throw content error when option request fails', async () => { + let wrapper; + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + await act(async () => { + wrapper = mountWithContexts( + {}} + onSubmit={() => {}} + /> + ); + }); + expect(wrapper.find('FormSubmitError').length).toBe(1); + expect(wrapper.find('SmartInventoryForm').prop('submitError')).toEqual( + error + ); + wrapper.unmount(); + jest.clearAllMocks(); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/shared/data.smart_inventory.json b/awx/ui_next/src/screens/Inventory/shared/data.smart_inventory.json index 0ab15565f6..204f616b7e 100644 --- a/awx/ui_next/src/screens/Inventory/shared/data.smart_inventory.json +++ b/awx/ui_next/src/screens/Inventory/shared/data.smart_inventory.json @@ -80,7 +80,7 @@ "description": "smart inv description", "organization": 1, "kind": "smart", - "host_filter": "search=local", + "host_filter": "name__icontains=local", "variables": "", "has_active_failures": false, "total_hosts": 1, From 8e6d475a9df190203621bf4077c7f65b6cbfeb50 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Fri, 17 Jul 2020 16:28:14 -0400 Subject: [PATCH 3/4] Add smart inventory edit form --- .../Lookup/shared/HostFilterUtils.jsx | 4 +- .../SmartInventoryEdit/SmartInventoryEdit.jsx | 120 ++++++++++++- .../SmartInventoryEdit.test.jsx | 160 ++++++++++++++++++ 3 files changed, 277 insertions(+), 7 deletions(-) create mode 100644 awx/ui_next/src/screens/Inventory/SmartInventoryEdit/SmartInventoryEdit.test.jsx diff --git a/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.jsx b/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.jsx index f4b803784d..e8996bf2d6 100644 --- a/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.jsx +++ b/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.jsx @@ -29,7 +29,7 @@ export function toSearchParams(string = '') { * Convert params object to an encoded namespaced url query string * Used to put into url bar when modal opens * @param {object} config Config object for namespacing params - * @param {object} obj A string or array of strings keyed by query param key + * @param {object} searchParams A string or array of strings keyed by query param key * @return {string} URL query string */ export function toQueryString(config, searchParams = {}) { @@ -54,7 +54,7 @@ export function toQueryString(config, searchParams = {}) { /** * Convert params object to host filter string - * @param {object} obj A string or array of strings keyed by query param key + * @param {object} searchParams A string or array of strings keyed by query param key * @return {string} Host filter string */ export function toHostFilter(searchParams = {}) { diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryEdit/SmartInventoryEdit.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryEdit/SmartInventoryEdit.jsx index 3d179fbc25..b499efd3f7 100644 --- a/awx/ui_next/src/screens/Inventory/SmartInventoryEdit/SmartInventoryEdit.jsx +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryEdit/SmartInventoryEdit.jsx @@ -1,10 +1,120 @@ -import React, { Component } from 'react'; -import { PageSection } from '@patternfly/react-core'; +import React, { useCallback, useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; +import { Inventory } from '../../../types'; +import { getAddedAndRemoved } from '../../../util/lists'; +import useRequest from '../../../util/useRequest'; +import { InventoriesAPI } from '../../../api'; +import { CardBody } from '../../../components/Card'; +import ContentError from '../../../components/ContentError'; +import ContentLoading from '../../../components/ContentLoading'; +import SmartInventoryForm from '../shared/SmartInventoryForm'; -class SmartInventoryEdit extends Component { - render() { - return Coming soon :); +function SmartInventoryEdit({ inventory }) { + const history = useHistory(); + const detailsUrl = `/inventories/smart_inventory/${inventory.id}/details`; + + const { + error: contentError, + isLoading: hasContentLoading, + request: fetchInstanceGroups, + result: instanceGroups, + } = useRequest( + useCallback(async () => { + const { + data: { results }, + } = await InventoriesAPI.readInstanceGroups(inventory.id); + return results; + }, [inventory.id]), + [] + ); + + useEffect(() => { + fetchInstanceGroups(); + }, [fetchInstanceGroups]); + + const { + error: submitError, + request: submitRequest, + result: submitResult, + } = useRequest( + useCallback( + async (values, groupsToAssociate, groupsToDisassociate) => { + const { data } = await InventoriesAPI.update(inventory.id, values); + await Promise.all( + groupsToAssociate.map(id => + InventoriesAPI.associateInstanceGroup(inventory.id, id) + ) + ); + await Promise.all( + groupsToDisassociate.map(id => + InventoriesAPI.disassociateInstanceGroup(inventory.id, id) + ) + ); + return data; + }, + [inventory.id] + ) + ); + + useEffect(() => { + if (submitResult) { + history.push({ + pathname: detailsUrl, + search: '', + }); + } + }, [submitResult, detailsUrl, history]); + + const handleSubmit = async form => { + const { instance_groups, organization, ...remainingForm } = form; + + const { added, removed } = getAddedAndRemoved( + instanceGroups, + instance_groups + ); + const addedIds = added.map(({ id }) => id); + const removedIds = removed.map(({ id }) => id); + + await submitRequest( + { + organization: organization?.id, + ...remainingForm, + }, + addedIds, + removedIds + ); + }; + + const handleCancel = () => { + history.push({ + pathname: detailsUrl, + search: '', + }); + }; + + if (hasContentLoading) { + return ; } + + if (contentError) { + return ; + } + + return ( + + + + ); } +SmartInventoryEdit.propTypes = { + inventory: Inventory.isRequired, +}; + export default SmartInventoryEdit; diff --git a/awx/ui_next/src/screens/Inventory/SmartInventoryEdit/SmartInventoryEdit.test.jsx b/awx/ui_next/src/screens/Inventory/SmartInventoryEdit/SmartInventoryEdit.test.jsx new file mode 100644 index 0000000000..dea1b1e1ba --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/SmartInventoryEdit/SmartInventoryEdit.test.jsx @@ -0,0 +1,160 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; +import SmartInventoryEdit from './SmartInventoryEdit'; +import mockSmartInventory from '../shared/data.smart_inventory.json'; +import { + InventoriesAPI, + OrganizationsAPI, + InstanceGroupsAPI, +} from '../../../api'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 2, + }), +})); +jest.mock('../../../api/models/Inventories'); +jest.mock('../../../api/models/Organizations'); +jest.mock('../../../api/models/InstanceGroups'); +OrganizationsAPI.read.mockResolvedValue({ data: { results: [], count: 0 } }); +InstanceGroupsAPI.read.mockResolvedValue({ data: { results: [], count: 0 } }); + +const mockSmartInv = Object.assign( + {}, + { + ...mockSmartInventory, + organization: { + id: mockSmartInventory.organization, + }, + } +); + +describe('', () => { + let history; + let wrapper; + + beforeAll(async () => { + InventoriesAPI.associateInstanceGroup.mockResolvedValue(); + InventoriesAPI.disassociateInstanceGroup.mockResolvedValue(); + InventoriesAPI.update.mockResolvedValue({ data: mockSmartInv }); + InventoriesAPI.readOptions.mockResolvedValue({ + data: { actions: { POST: true } }, + }); + InventoriesAPI.readInstanceGroups.mockResolvedValue({ + data: { count: 0, results: [{ id: 10 }, { id: 20 }] }, + }); + history = createMemoryHistory({ + initialEntries: [`/inventories/smart_inventory/${mockSmartInv.id}/edit`], + }); + await act(async () => { + wrapper = mountWithContexts( + , + { + context: { router: { history } }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + + afterAll(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('should fetch related instance groups on initial render', async () => { + expect(InventoriesAPI.readInstanceGroups).toHaveBeenCalledTimes(1); + }); + + test('save button should be enabled for users with POST capability', () => { + expect(wrapper.find('Button[aria-label="Save"]').prop('isDisabled')).toBe( + false + ); + }); + + test('should post to the api when submit is clicked', async () => { + expect(InventoriesAPI.update).toHaveBeenCalledTimes(0); + expect(InventoriesAPI.associateInstanceGroup).toHaveBeenCalledTimes(0); + expect(InventoriesAPI.disassociateInstanceGroup).toHaveBeenCalledTimes(0); + await act(async () => { + wrapper.find('SmartInventoryForm').invoke('onSubmit')({ + ...mockSmartInv, + instance_groups: [{ id: 10 }, { id: 30 }], + }); + }); + expect(InventoriesAPI.update).toHaveBeenCalledTimes(1); + expect(InventoriesAPI.associateInstanceGroup).toHaveBeenCalledTimes(1); + expect(InventoriesAPI.disassociateInstanceGroup).toHaveBeenCalledTimes(1); + }); + + test('successful form submission should trigger redirect to details', async () => { + expect(wrapper.find('FormSubmitError').length).toBe(0); + expect(history.location.pathname).toEqual( + '/inventories/smart_inventory/2/details' + ); + }); + + test('should navigate to inventory details when cancel is clicked', async () => { + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); + expect(history.location.pathname).toEqual( + '/inventories/smart_inventory/2/details' + ); + }); + + test('unsuccessful form submission should show an error message', async () => { + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + InventoriesAPI.update.mockImplementationOnce(() => Promise.reject(error)); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + expect(wrapper.find('FormSubmitError').length).toBe(0); + await act(async () => { + wrapper.find('SmartInventoryForm').invoke('onSubmit')({}); + }); + wrapper.update(); + expect(wrapper.find('FormSubmitError').length).toBe(1); + }); + + test('should throw content error', async () => { + expect(wrapper.find('ContentError').length).toBe(0); + InventoriesAPI.readInstanceGroups.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + wrapper.update(); + expect(wrapper.find('ContentError').length).toBe(1); + }); + + test('save button should be disabled for users without POST capability', async () => { + InventoriesAPI.readOptions.mockResolvedValue({ + data: { actions: { POST: false } }, + }); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.find('Button[aria-label="Save"]').prop('isDisabled')).toBe( + true + ); + }); +}); From af218aaa0b392431fe8a5e1367ca18ca636b201a Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Wed, 22 Jul 2020 15:09:45 -0400 Subject: [PATCH 4/4] Decode host filter chip values and fix boolean search filter chip bug --- .../src/components/Lookup/HostFilterLookup.jsx | 7 ++----- .../components/Lookup/shared/HostFilterUtils.jsx | 15 ++++++--------- awx/ui_next/src/components/Search/Search.jsx | 11 +++++++---- .../Inventory/shared/SmartInventoryForm.jsx | 1 - 4 files changed, 15 insertions(+), 19 deletions(-) diff --git a/awx/ui_next/src/components/Lookup/HostFilterLookup.jsx b/awx/ui_next/src/components/Lookup/HostFilterLookup.jsx index dbbf5a1d9a..e819973083 100644 --- a/awx/ui_next/src/components/Lookup/HostFilterLookup.jsx +++ b/awx/ui_next/src/components/Lookup/HostFilterLookup.jsx @@ -84,12 +84,13 @@ const buildSearchColumns = i18n => [ key: 'groups__name', }, { - name: i18n._(t`Inventory`), + name: i18n._(t`Inventory ID`), key: 'inventory', }, { name: i18n._(t`Enabled`), key: 'enabled', + isBoolean: true, }, { name: i18n._(t`Instance ID`), @@ -103,10 +104,6 @@ const buildSearchColumns = i18n => [ name: i18n._(t`Insights system ID`), key: 'insights_system_id', }, - { - name: i18n._(t`Ansible facts modified`), - key: 'ansible_facts_modified', - }, ]; function HostFilterLookup({ diff --git a/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.jsx b/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.jsx index e8996bf2d6..27be7b3c99 100644 --- a/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.jsx +++ b/awx/ui_next/src/components/Lookup/shared/HostFilterUtils.jsx @@ -7,13 +7,14 @@ export function toSearchParams(string = '') { if (string === '') { return {}; } - return string .replace(/^\?/, '') - .replace(/&/, ' and ') + .replace(/&/g, ' and ') .split(/ and | or /) .map(s => s.split('=')) - .reduce((searchParams, [key, value]) => { + .reduce((searchParams, [k, v]) => { + const key = decodeURIComponent(k); + const value = decodeURIComponent(v); if (searchParams[key] === undefined) { searchParams[key] = value; } else if (Array.isArray(searchParams[key])) { @@ -61,13 +62,9 @@ export function toHostFilter(searchParams = {}) { return Object.keys(searchParams) .flatMap(key => { if (Array.isArray(searchParams[key])) { - return searchParams[key].map( - val => `${encodeURIComponent(key)}=${encodeURIComponent(val)}` - ); + return searchParams[key].map(val => `${key}=${val}`); } - return `${encodeURIComponent(key)}=${encodeURIComponent( - searchParams[key] - )}`; + return `${key}=${searchParams[key]}`; }) .join(' and '); } diff --git a/awx/ui_next/src/components/Search/Search.jsx b/awx/ui_next/src/components/Search/Search.jsx index 916629c691..8049a326e7 100644 --- a/awx/ui_next/src/components/Search/Search.jsx +++ b/awx/ui_next/src/components/Search/Search.jsx @@ -97,13 +97,16 @@ function Search({ }; const getLabelFromValue = (value, colKey) => { + let label = value; const currentSearchColumn = columns.find(({ key }) => key === colKey); if (currentSearchColumn?.options?.length) { - return currentSearchColumn.options.find( + [, label] = currentSearchColumn.options.find( ([optVal]) => optVal === value - )[1]; + ); + } else if (currentSearchColumn?.booleanLabels) { + label = currentSearchColumn.booleanLabels[value]; } - return value.toString(); + return label.toString(); }; const getChipsByKey = () => { @@ -227,7 +230,7 @@ function Search({ aria-label={name} onToggle={setIsFilterDropdownOpen} onSelect={(event, selection) => onReplaceSearch(key, selection)} - selections={chipsByKey[key].chips[0]} + selections={chipsByKey[key].chips[0]?.label} isOpen={isFilterDropdownOpen} placeholderText={`Filter By ${name}`} > diff --git a/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx index 971f848142..12cd3ee215 100644 --- a/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx +++ b/awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx @@ -81,7 +81,6 @@ const SmartInventoryFormFields = withI18n()(({ i18n }) => { id="variables" name="variables" label={i18n._(t`Variables`)} - promptId="variables" tooltip={i18n._( t`Enter inventory variables using either JSON or YAML syntax. Use the radio button to toggle between the two. Refer to the