diff --git a/awx/ui_next/src/components/RoutedTabs/RoutedTabs.jsx b/awx/ui_next/src/components/RoutedTabs/RoutedTabs.jsx index 0e90017b22..964806f05e 100644 --- a/awx/ui_next/src/components/RoutedTabs/RoutedTabs.jsx +++ b/awx/ui_next/src/components/RoutedTabs/RoutedTabs.jsx @@ -3,6 +3,7 @@ import { shape, string, number, arrayOf } from 'prop-types'; import { Tab, Tabs as PFTabs } from '@patternfly/react-core'; import { withRouter } from 'react-router-dom'; import styled from 'styled-components'; +import { CaretLeftIcon } from '@patternfly/react-icons'; const Tabs = styled(PFTabs)` --pf-c-tabs__button--PaddingLeft: 20px; @@ -62,7 +63,15 @@ function RoutedTabs(props) { eventKey={tab.id} key={tab.id} link={tab.link} - title={tab.name} + title={ + tab.isNestedTabs ? ( + <> + {tab.name} + + ) : ( + tab.name + ) + } /> ))} diff --git a/awx/ui_next/src/index.jsx b/awx/ui_next/src/index.jsx index 9227e6e7e3..06d5245d81 100644 --- a/awx/ui_next/src/index.jsx +++ b/awx/ui_next/src/index.jsx @@ -15,7 +15,7 @@ import CredentialTypes from '@screens/CredentialType'; import Dashboard from '@screens/Dashboard'; import Hosts from '@screens/Host'; import InstanceGroups from '@screens/InstanceGroup'; -import Inventories from '@screens/Inventory'; +import Inventory from '@screens/Inventory'; import InventoryScripts from '@screens/InventoryScript'; import { Jobs } from '@screens/Job'; import Login from '@screens/Login'; @@ -139,7 +139,7 @@ export function main(render) { { title: i18n._(t`Inventories`), path: '/inventories', - component: Inventories, + component: Inventory, }, { title: i18n._(t`Hosts`), diff --git a/awx/ui_next/src/screens/Inventory/Inventories.jsx b/awx/ui_next/src/screens/Inventory/Inventories.jsx index 1d72507c03..4253078486 100644 --- a/awx/ui_next/src/screens/Inventory/Inventories.jsx +++ b/awx/ui_next/src/screens/Inventory/Inventories.jsx @@ -27,7 +27,7 @@ class Inventories extends Component { }; } - setBreadCrumbConfig = inventory => { + setBreadCrumbConfig = (inventory, group) => { const { i18n } = this.props; if (!inventory) { return; @@ -57,6 +57,15 @@ class Inventories extends Component { ), [`/inventories/inventory/${inventory.id}/sources`]: i18n._(t`Sources`), [`/inventories/inventory/${inventory.id}/groups`]: i18n._(t`Groups`), + [`/inventories/inventory/${inventory.id}/groups/add`]: i18n._( + t`Create New Group` + ), + [`/inventories/inventory/${inventory.id}/groups/${group && + group.id}`]: `${group && group.name}`, + [`/inventories/inventory/${inventory.id}/groups/${group && + group.id}/details`]: i18n._(t`Group Details`), + [`/inventories/inventory/${inventory.id}/groups/${group && + group.id}/edit`]: i18n._(t`Edit Details`), }; this.setState({ breadcrumbConfig }); }; diff --git a/awx/ui_next/src/screens/Inventory/Inventory.jsx b/awx/ui_next/src/screens/Inventory/Inventory.jsx index 5352b949a4..f3e78d584b 100644 --- a/awx/ui_next/src/screens/Inventory/Inventory.jsx +++ b/awx/ui_next/src/screens/Inventory/Inventory.jsx @@ -57,7 +57,11 @@ function Inventory({ history, i18n, location, match, setBreadcrumb }) { ); - if (location.pathname.endsWith('edit') || location.pathname.endsWith('add')) { + if ( + location.pathname.endsWith('edit') || + location.pathname.endsWith('add') || + location.pathname.includes('groups/') + ) { cardHeader = null; } @@ -123,7 +127,15 @@ function Inventory({ history, i18n, location, match, setBreadcrumb }) { } + render={() => ( + + )} />, { @@ -39,11 +39,11 @@ function InventoryEdit({ history, i18n, inventory }) { } catch (err) { setError(err); } finally { - setIsLoading(false); + setContentLoading(false); } }; loadData(); - }, [inventory.id, isLoading, inventory, credentialTypeId]); + }, [inventory.id, contentLoading, inventory, credentialTypeId]); const handleCancel = () => { history.push('/inventories'); @@ -85,7 +85,7 @@ function InventoryEdit({ history, i18n, inventory }) { history.push(`${url}`); } }; - if (isLoading) { + if (contentLoading) { return ; } if (error) { diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx new file mode 100644 index 0000000000..1c510f4fe6 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.jsx @@ -0,0 +1,159 @@ +import React, { useEffect, useState } from 'react'; +import { t } from '@lingui/macro'; +import { withI18n } from '@lingui/react'; +import { CardHeader } from '@patternfly/react-core'; + +import { Switch, Route, withRouter, Link, Redirect } from 'react-router-dom'; +import { GroupsAPI } from '@api'; +import CardCloseButton from '@components/CardCloseButton'; +import RoutedTabs from '@components/RoutedTabs'; +import ContentError from '@components/ContentError'; +import ContentLoading from '@components/ContentLoading'; +import InventoryGroupEdit from '../InventoryGroupEdit/InventoryGroupEdit'; +import InventoryGroupDetail from '../InventoryGroupDetail/InventoryGroupDetail'; + +function InventoryGroups({ i18n, match, setBreadcrumb, inventory, history }) { + const [inventoryGroup, setInventoryGroup] = useState(null); + const [contentLoading, setContentLoading] = useState(true); + const [contentError, setContentError] = useState(null); + + useEffect(() => { + const loadData = async () => { + try { + const { data } = await GroupsAPI.readDetail(match.params.groupId); + setInventoryGroup(data); + setBreadcrumb(inventory, data); + } catch (err) { + setContentError(err); + } finally { + setContentLoading(false); + } + }; + + loadData(); + }, [ + history.location.pathname, + match.params.groupId, + inventory, + setBreadcrumb, + ]); + + const tabsArray = [ + { + name: i18n._(t`Return to Groups`), + link: `/inventories/inventory/${inventory.id}/groups`, + id: 99, + isNestedTabs: true, + }, + { + name: i18n._(t`Details`), + link: `/inventories/inventory/${inventory.id}/groups/${inventoryGroup && + inventoryGroup.id}/details`, + id: 0, + }, + { + name: i18n._(t`Related Groups`), + link: `/inventories/inventory/${inventory.id}/groups/${inventoryGroup && + inventoryGroup.id}/nested_groups`, + id: 1, + }, + { + name: i18n._(t`Hosts`), + link: `/inventories/inventory/${inventory.id}/groups/${inventoryGroup && + inventoryGroup.id}/nested_hosts`, + id: 2, + }, + ]; + + // In cases where a user manipulates the url such that they try to navigate to a Inventory Group + // that is not associated with the Inventory Id in the Url this Content Error is thrown. + // Inventory Groups have a 1: 1 relationship to Inventories thus their Ids must corrolate. + + if (contentLoading) { + return ; + } + + if ( + inventoryGroup.summary_fields.inventory.id !== parseInt(match.params.id, 10) + ) { + return ( + + {inventoryGroup && ( + + {i18n._(t`View Inventory Groups`)} + + )} + + ); + } + + if (contentError) { + return ; + } + + let cardHeader = null; + if ( + history.location.pathname.includes('groups/') && + !history.location.pathname.endsWith('edit') + ) { + cardHeader = ( + + + + + ); + } + return ( + <> + {cardHeader} + + + {inventoryGroup && [ + { + return ( + + ); + }} + />, + { + return ; + }} + />, + ]} + { + return ( + + {inventory && ( + + {i18n._(t`View Inventory Details`)} + + )} + + ); + }} + /> + + + ); +} + +export { InventoryGroups as _InventoryGroups }; +export default withI18n()(withRouter(InventoryGroups)); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.test.jsx new file mode 100644 index 0000000000..6273de12d8 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroup/InventoryGroup.test.jsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { GroupsAPI } from '@api'; +import { Route } from 'react-router-dom'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; + +import InventoryGroup from './InventoryGroup'; + +jest.mock('@api'); +GroupsAPI.readDetail.mockResolvedValue({ + data: { + id: 1, + name: 'Foo', + description: 'Bar', + variables: 'bizz: buzz', + summary_fields: { + inventory: { id: 1 }, + created_by: { id: 1, name: 'Athena' }, + modified_by: { id: 1, name: 'Apollo' }, + }, + }, +}); +describe('', () => { + let wrapper; + let history; + const inventory = { id: 1, name: 'Foo' }; + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/1/groups/1/details'], + }); + await act(async () => { + wrapper = mountWithContexts( + ( + {}} inventory={inventory} /> + )} + />, + { + context: { + router: { + history, + route: { + location: history.location, + match: { + params: { id: 1 }, + }, + }, + }, + }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('renders successfully', async () => { + expect(wrapper.length).toBe(1); + }); + test('expect all tabs to exist, including Return to Groups', async () => { + expect(wrapper.find('button[aria-label="Return to Groups"]').length).toBe( + 1 + ); + expect(wrapper.find('button[aria-label="Details"]').length).toBe(1); + expect(wrapper.find('button[aria-label="Related Groups"]').length).toBe(1); + expect(wrapper.find('button[aria-label="Hosts"]').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroup/index.js b/awx/ui_next/src/screens/Inventory/InventoryGroup/index.js new file mode 100644 index 0000000000..9de3820a01 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroup/index.js @@ -0,0 +1 @@ +export { default } from './InventoryGroup'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupAdd/InventoryGroupAdd.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupAdd/InventoryGroupAdd.jsx new file mode 100644 index 0000000000..eff8a2fe10 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupAdd/InventoryGroupAdd.jsx @@ -0,0 +1,39 @@ +import React, { useState, useEffect } from 'react'; +import { withI18n } from '@lingui/react'; +import { withRouter } from 'react-router-dom'; +import { GroupsAPI } from '@api'; +import { Card } from '@patternfly/react-core'; + +import InventoryGroupForm from '../shared/InventoryGroupForm'; + +function InventoryGroupsAdd({ history, inventory, setBreadcrumb }) { + const [error, setError] = useState(null); + + useEffect(() => setBreadcrumb(inventory), [inventory, setBreadcrumb]); + + const handleSubmit = async values => { + values.inventory = inventory.id; + try { + const { data } = await GroupsAPI.create(values); + history.push(`/inventories/inventory/${inventory.id}/groups/${data.id}`); + } catch (err) { + setError(err); + } + }; + + const handleCancel = () => { + history.push(`/inventories/inventory/${inventory.id}/groups`); + }; + + return ( + + + + ); +} +export default withI18n()(withRouter(InventoryGroupsAdd)); +export { InventoryGroupsAdd as _InventoryGroupsAdd }; diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupAdd/InventoryGroupAdd.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupAdd/InventoryGroupAdd.test.jsx new file mode 100644 index 0000000000..a3abb97035 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupAdd/InventoryGroupAdd.test.jsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { Route } from 'react-router-dom'; + +import { GroupsAPI } from '@api'; +import { createMemoryHistory } from 'history'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; + +import InventoryGroupAdd from './InventoryGroupAdd'; + +jest.mock('@api'); + +describe('', () => { + let wrapper; + let history; + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/1/groups/add'], + }); + await act(async () => { + wrapper = mountWithContexts( + ( + {}} inventory={{ id: 1 }} /> + )} + />, + { + context: { + router: { history, route: { location: history.location } }, + }, + } + ); + }); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('InventoryGroupAdd renders successfully', () => { + expect(wrapper.length).toBe(1); + }); + test('cancel should navigate user to Inventory Groups List', async () => { + wrapper.find('button[aria-label="Cancel"]').simulate('click'); + expect(history.location.pathname).toEqual( + '/inventories/inventory/1/groups' + ); + }); + test('handleSubmit should call api', async () => { + await act(async () => { + wrapper.find('InventoryGroupForm').prop('handleSubmit')({ + name: 'Bar', + description: 'Ansible', + variables: 'ying: yang', + }); + }); + + expect(GroupsAPI.create).toBeCalled(); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupAdd/index.js b/awx/ui_next/src/screens/Inventory/InventoryGroupAdd/index.js new file mode 100644 index 0000000000..0e15c69a55 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupAdd/index.js @@ -0,0 +1 @@ +export { default } from './InventoryGroupAdd'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.jsx new file mode 100644 index 0000000000..8cf97cffc0 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.jsx @@ -0,0 +1,165 @@ +import React, { useState } from 'react'; +import { t } from '@lingui/macro'; + +import { CardBody, Button } from '@patternfly/react-core'; +import { withI18n } from '@lingui/react'; +import { withRouter, Link } from 'react-router-dom'; +import styled from 'styled-components'; +import { VariablesInput as CodeMirrorInput } from '@components/CodeMirrorInput'; +import ErrorDetail from '@components/ErrorDetail'; +import AlertModal from '@components/AlertModal'; +import { formatDateString } from '@util/dates'; + +import { GroupsAPI } from '@api'; +import { DetailList, Detail } from '@components/DetailList'; + +const VariablesInput = styled(CodeMirrorInput)` + .pf-c-form__label { + font-weight: 600; + font-size: 16px; + } + margin: 20px 0; +`; +const ActionButtonWrapper = styled.div` + display: flex; + justify-content: flex-end; + margin-top: 20px; + & > :not(:first-child) { + margin-left: 20px; + } +`; +function InventoryGroupDetail({ i18n, history, match, inventoryGroup }) { + const { + summary_fields: { created_by, modified_by }, + created, + modified, + name, + description, + variables, + } = inventoryGroup; + const [error, setError] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + + const handleDelete = async () => { + setIsDeleteModalOpen(false); + try { + await GroupsAPI.destroy(inventoryGroup.id); + history.push(`/inventories/inventory/${match.params.id}/groups`); + } catch (err) { + setError(err); + } + }; + + let createdBy = ''; + if (created) { + if (created_by && created_by.username) { + createdBy = ( + + {i18n._(t`${formatDateString(inventoryGroup.created)} by`)}{' '} + {created_by.username} + + ); + } else { + createdBy = formatDateString(inventoryGroup.created); + } + } + + let modifiedBy = ''; + if (modified) { + if (modified_by && modified_by.username) { + modifiedBy = ( + + {i18n._(t`${formatDateString(inventoryGroup.modified)} by`)}{' '} + {modified_by.username} + + ); + } else { + modifiedBy = formatDateString(inventoryGroup.modified); + } + } + + return ( + + + + + + + + {createdBy && } + {modifiedBy && ( + + )} + + + + + + {isDeleteModalOpen && ( + setIsDeleteModalOpen(false)} + actions={[ + , + , + ]} + > + {i18n._(t`Are you sure you want to delete:`)} +
+ {inventoryGroup.name} +
+
+ )} + {error && ( + setError(false)} + > + {i18n._(t`Failed to delete group ${inventoryGroup.name}.`)} + + + )} +
+ ); +} +export default withI18n()(withRouter(InventoryGroupDetail)); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.test.jsx new file mode 100644 index 0000000000..99a017ce32 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.test.jsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { GroupsAPI } from '@api'; +import { Route } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; + +import { act } from 'react-dom/test-utils'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; + +import InventoryGroupDetail from './InventoryGroupDetail'; + +jest.mock('@api'); +const inventoryGroup = { + name: 'Foo', + description: 'Bar', + variables: 'bizz: buzz', + id: 1, + created: '2019-12-02T15:58:16.276813Z', + modified: '2019-12-03T20:33:46.207654Z', + summary_fields: { + created_by: { + username: 'James', + id: 13, + }, + modified_by: { + username: 'Bond', + id: 14, + }, + }, +}; +describe('', () => { + let wrapper; + let history; + beforeEach(async () => { + await act(async () => { + history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/1/groups/1/details'], + }); + wrapper = mountWithContexts( + ( + + )} + />, + { + context: { + router: { history, route: { location: history.location } }, + }, + } + ); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('InventoryGroupDetail renders successfully', () => { + expect(wrapper.length).toBe(1); + }); + test('should open delete modal and then call api to delete the group', async () => { + await act(async () => { + wrapper.find('button[aria-label="Delete"]').simulate('click'); + }); + await waitForElement(wrapper, 'Modal', el => el.length === 1); + expect(wrapper.find('Modal').length).toBe(1); + await act(async () => { + wrapper.find('button[aria-label="confirm delete"]').simulate('click'); + }); + expect(GroupsAPI.destroy).toBeCalledWith(1); + }); + test('should navigate user to edit form on edit button click', async () => { + wrapper.find('button[aria-label="Edit"]').simulate('click'); + expect(history.location.pathname).toEqual( + '/inventories/inventory/1/groups/1/edit' + ); + }); + test('details shoudld render with the proper values', () => { + expect(wrapper.find('Detail[label="Name"]').prop('value')).toBe('Foo'); + expect(wrapper.find('Detail[label="Description"]').prop('value')).toBe( + 'Bar' + ); + expect(wrapper.find('Detail[label="Created"]').length).toBe(1); + expect(wrapper.find('Detail[label="Modified"]').length).toBe(1); + expect(wrapper.find('VariablesInput').prop('value')).toBe('bizz: buzz'); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupDetail/index.js b/awx/ui_next/src/screens/Inventory/InventoryGroupDetail/index.js new file mode 100644 index 0000000000..155a1c8e10 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupDetail/index.js @@ -0,0 +1 @@ +export { default } from './InventoryGroupDetail'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupEdit/InventoryGroupEdit.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupEdit/InventoryGroupEdit.jsx new file mode 100644 index 0000000000..230314ce7c --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupEdit/InventoryGroupEdit.jsx @@ -0,0 +1,38 @@ +import React, { useState } from 'react'; +import { withI18n } from '@lingui/react'; +import { withRouter } from 'react-router-dom'; +import { GroupsAPI } from '@api'; + +import InventoryGroupForm from '../shared/InventoryGroupForm'; + +function InventoryGroupEdit({ history, inventoryGroup, inventory, match }) { + const [error, setError] = useState(null); + + const handleSubmit = async values => { + try { + await GroupsAPI.update(match.params.groupId, values); + history.push( + `/inventories/inventory/${inventory.id}/groups/${inventoryGroup.id}` + ); + } catch (err) { + setError(err); + } + }; + + const handleCancel = () => { + history.push( + `/inventories/inventory/${inventory.id}/groups/${inventoryGroup.id}` + ); + }; + + return ( + + ); +} +export default withI18n()(withRouter(InventoryGroupEdit)); +export { InventoryGroupEdit as _InventoryGroupEdit }; diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupEdit/InventoryGroupEdit.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroupEdit/InventoryGroupEdit.test.jsx new file mode 100644 index 0000000000..240bb3dec0 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupEdit/InventoryGroupEdit.test.jsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { Route } from 'react-router-dom'; +import { GroupsAPI } from '@api'; +import { createMemoryHistory } from 'history'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; + +import InventoryGroupEdit from './InventoryGroupEdit'; + +jest.mock('@api'); +GroupsAPI.readDetail.mockResolvedValue({ + data: { + name: 'Foo', + description: 'Bar', + variables: 'bizz: buzz', + }, +}); +describe('', () => { + let wrapper; + let history; + beforeEach(async () => { + history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/1/groups/2/edit'], + }); + await act(async () => { + wrapper = mountWithContexts( + ( + {}} + inventory={{ id: 1 }} + inventoryGroup={{ id: 2 }} + /> + )} + />, + { + context: { + router: { + history, + route: { + match: { + params: { groupId: 13 }, + }, + location: history.location, + }, + }, + }, + } + ); + }); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('InventoryGroupEdit renders successfully', () => { + expect(wrapper.length).toBe(1); + }); + test('cancel should navigate user to Inventory Groups List', async () => { + wrapper.find('button[aria-label="Cancel"]').simulate('click'); + expect(history.location.pathname).toEqual( + '/inventories/inventory/1/groups/2' + ); + }); + test('handleSubmit should call api', async () => { + wrapper.find('InventoryGroupForm').prop('handleSubmit')({ + name: 'Bar', + description: 'Ansible', + variables: 'ying: yang', + }); + expect(GroupsAPI.update).toBeCalled(); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroupEdit/index.js b/awx/ui_next/src/screens/Inventory/InventoryGroupEdit/index.js new file mode 100644 index 0000000000..75519c821b --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroupEdit/index.js @@ -0,0 +1 @@ +export { default } from './InventoryGroupEdit'; diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupItem.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupItem.jsx index c8f6b78621..91f0b0c4f9 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupItem.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupItem.jsx @@ -27,7 +27,7 @@ function InventoryGroupItem({ onSelect, }) { const labelId = `check-action-${group.id}`; - const detailUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/detail`; + const detailUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/details`; const editUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/edit`; return ( diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx index 49f55cd4d8..2917f3f96d 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.jsx @@ -1,250 +1,45 @@ -import React, { useState, useEffect } from 'react'; -import { TrashAltIcon } from '@patternfly/react-icons'; -import { withRouter } from 'react-router-dom'; +import React from 'react'; import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; -import { getQSConfig, parseQueryString } from '@util/qs'; -import { InventoriesAPI, GroupsAPI } from '@api'; -import { Button, Tooltip } from '@patternfly/react-core'; -import AlertModal from '@components/AlertModal'; -import ErrorDetail from '@components/ErrorDetail'; -import DataListToolbar from '@components/DataListToolbar'; -import PaginatedDataList, { - ToolbarAddButton, -} from '@components/PaginatedDataList'; -import styled from 'styled-components'; -import InventoryGroupItem from './InventoryGroupItem'; -import InventoryGroupsDeleteModal from '../shared/InventoryGroupsDeleteModal'; -const QS_CONFIG = getQSConfig('host', { - page: 1, - page_size: 20, - order_by: 'name', -}); +import { Switch, Route, withRouter } from 'react-router-dom'; -const DeleteButton = styled(Button)` - padding: 5px 8px; +import InventoryGroupAdd from '../InventoryGroupAdd/InventoryGroupAdd'; - &:hover { - background-color: #d9534f; - color: white; - } - - &[disabled] { - color: var(--pf-c-button--m-plain--Color); - pointer-events: initial; - cursor: not-allowed; - } -`; - -function cannotDelete(item) { - return !item.summary_fields.user_capabilities.delete; -} - -const useModal = () => { - const [isModalOpen, setIsModalOpen] = useState(false); - - function toggleModal() { - setIsModalOpen(!isModalOpen); - } - - return { - isModalOpen, - toggleModal, - }; -}; - -function InventoryGroups({ i18n, location, match }) { - const [actions, setActions] = useState(null); - const [contentError, setContentError] = useState(null); - const [deletionError, setDeletionError] = useState(null); - const [groupCount, setGroupCount] = useState(0); - const [groups, setGroups] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [selected, setSelected] = useState([]); - const { isModalOpen, toggleModal } = useModal(); - - const inventoryId = match.params.id; - - const fetchGroups = (id, queryString) => { - const params = parseQueryString(QS_CONFIG, queryString); - return InventoriesAPI.readGroups(id, params); - }; - - useEffect(() => { - async function fetchData() { - try { - const [ - { - data: { count, results }, - }, - { - data: { actions: optionActions }, - }, - ] = await Promise.all([ - fetchGroups(inventoryId, location.search), - InventoriesAPI.readGroupsOptions(inventoryId), - ]); - - setGroups(results); - setGroupCount(count); - setActions(optionActions); - } catch (error) { - setContentError(error); - } finally { - setIsLoading(false); - } - } - fetchData(); - }, [inventoryId, location]); - - const handleSelectAll = isSelected => { - setSelected(isSelected ? [...groups] : []); - }; - - const handleSelect = row => { - if (selected.some(s => s.id === row.id)) { - setSelected(selected.filter(s => s.id !== row.id)); - } else { - setSelected(selected.concat(row)); - } - }; - - const renderTooltip = () => { - const itemsUnableToDelete = selected - .filter(cannotDelete) - .map(item => item.name) - .join(', '); - - if (selected.some(cannotDelete)) { - return ( -
- {i18n._( - t`You do not have permission to delete the following Groups: ${itemsUnableToDelete}` - )} -
- ); - } - if (selected.length) { - return i18n._(t`Delete`); - } - return i18n._(t`Select a row to delete`); - }; - - const handleDelete = async option => { - setIsLoading(true); - - try { - /* eslint-disable no-await-in-loop, no-restricted-syntax */ - /* Delete groups sequentially to avoid api integrity errors */ - for (const group of selected) { - if (option === 'delete') { - await GroupsAPI.destroy(+group.id); - } else if (option === 'promote') { - await InventoriesAPI.promoteGroup(inventoryId, +group.id); - } - } - /* eslint-enable no-await-in-loop, no-restricted-syntax */ - } catch (error) { - setDeletionError(error); - } - - toggleModal(); - - try { - const { - data: { count, results }, - } = await fetchGroups(inventoryId, location.search); - setGroups(results); - setGroupCount(count); - } catch (error) { - setContentError(error); - } - - setIsLoading(false); - }; - - const canAdd = - actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); - const isAllSelected = - selected.length > 0 && selected.length === groups.length; +import InventoryGroup from '../InventoryGroup/InventoryGroup'; +import InventoryGroupsList from './InventoryGroupsList'; +function InventoryGroups({ setBreadcrumb, inventory, location, match }) { return ( - <> - ( - row.id === item.id)} - onSelect={() => handleSelect(item)} - /> - )} - renderToolbar={props => ( - -
- - - -
- , - canAdd && ( - - ), - ]} - /> - )} - emptyStateControls={ - canAdd && ( - + { + return ( + - ) - } + ); + }} /> - {deletionError && ( - setDeletionError(null)} - > - {i18n._(t`Failed to delete one or more groups.`)} - - - )} - ( + + )} /> - + { + return ; + }} + /> + ); } +export { InventoryGroups as _InventoryGroups }; export default withI18n()(withRouter(InventoryGroups)); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.test.jsx index 2b5a7340c0..935ef7bb04 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroups.test.jsx @@ -1,82 +1,23 @@ import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; import { act } from 'react-dom/test-utils'; -import { Route } from 'react-router-dom'; import { createMemoryHistory } from 'history'; -import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; -import { InventoriesAPI, GroupsAPI } from '@api'; import InventoryGroups from './InventoryGroups'; jest.mock('@api'); -const mockGroups = [ - { - id: 1, - type: 'group', - name: 'foo', - inventory: 1, - url: '/api/v2/groups/1', - summary_fields: { - user_capabilities: { - delete: true, - edit: true, - }, - }, - }, - { - id: 2, - type: 'group', - name: 'bar', - inventory: 1, - url: '/api/v2/groups/2', - summary_fields: { - user_capabilities: { - delete: true, - edit: true, - }, - }, - }, - { - id: 3, - type: 'group', - name: 'baz', - inventory: 1, - url: '/api/v2/groups/3', - summary_fields: { - user_capabilities: { - delete: false, - edit: false, - }, - }, - }, -]; - describe('', () => { - let wrapper; - - beforeEach(async () => { - InventoriesAPI.readGroups.mockResolvedValue({ - data: { - count: mockGroups.length, - results: mockGroups, - }, - }); - InventoriesAPI.readGroupsOptions.mockResolvedValue({ - data: { - actions: { - GET: {}, - POST: {}, - }, - }, - }); + test('initially renders successfully', async () => { + let wrapper; const history = createMemoryHistory({ - initialEntries: ['/inventories/inventory/3/groups'], + initialEntries: ['/inventories/inventory/1/groups'], }); + const inventory = { id: 1, name: 'Foo' }; + await act(async () => { wrapper = mountWithContexts( - } - />, + {}} inventory={inventory} />, + { context: { router: { history, route: { location: history.location } }, @@ -84,134 +25,25 @@ describe('', () => { } ); }); - await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + expect(wrapper.length).toBe(1); + expect(wrapper.find('InventoryGroupsList').length).toBe(1); }); - - test('initially renders successfully', () => { - expect(wrapper.find('InventoryGroups').length).toBe(1); - }); - - test('should fetch groups from api and render them in the list', async () => { - expect(InventoriesAPI.readGroups).toHaveBeenCalled(); - expect(wrapper.find('InventoryGroupItem').length).toBe(3); - }); - - test('should check and uncheck the row item', async () => { - expect( - wrapper.find('PFDataListCheck[id="select-group-1"]').props().checked - ).toBe(false); - + test('test that InventoryGroupsAdd renders', async () => { + const history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/1/groups/add'], + }); + const inventory = { id: 1, name: 'Foo' }; + let wrapper; await act(async () => { - wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')( - true - ); - }); - wrapper.update(); - expect( - wrapper.find('PFDataListCheck[id="select-group-1"]').props().checked - ).toBe(true); - - await act(async () => { - wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')( - false - ); - }); - wrapper.update(); - expect( - wrapper.find('PFDataListCheck[id="select-group-1"]').props().checked - ).toBe(false); - }); - - test('should check all row items when select all is checked', async () => { - wrapper.find('PFDataListCheck').forEach(el => { - expect(el.props().checked).toBe(false); - }); - await act(async () => { - wrapper.find('Checkbox#select-all').invoke('onChange')(true); - }); - wrapper.update(); - wrapper.find('PFDataListCheck').forEach(el => { - expect(el.props().checked).toBe(true); - }); - await act(async () => { - wrapper.find('Checkbox#select-all').invoke('onChange')(false); - }); - wrapper.update(); - wrapper.find('PFDataListCheck').forEach(el => { - expect(el.props().checked).toBe(false); - }); - }); - - test('should show content error when api throws error on initial render', async () => { - InventoriesAPI.readGroupsOptions.mockImplementation(() => - Promise.reject(new Error()) - ); - await act(async () => { - wrapper = mountWithContexts(); - }); - await waitForElement(wrapper, 'ContentError', el => el.length === 1); - }); - - test('should show content error if groups are not successfully fetched from api', async () => { - InventoriesAPI.readGroups.mockImplementation(() => - Promise.reject(new Error()) - ); - await act(async () => { - wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')(); - }); - wrapper.update(); - await act(async () => { - wrapper.find('Toolbar Button[aria-label="Delete"]').invoke('onClick')(); - }); - await waitForElement( - wrapper, - 'InventoryGroupsDeleteModal', - el => el.props().isModalOpen === true - ); - await act(async () => { - wrapper - .find('ModalBoxFooter Button[aria-label="Delete"]') - .invoke('onClick')(); - }); - await waitForElement(wrapper, 'ContentError', el => el.length === 1); - }); - - test('should show error modal when group is not successfully deleted from api', async () => { - GroupsAPI.destroy.mockRejectedValue( - new Error({ - response: { - config: { - method: 'delete', - url: '/api/v2/groups/1', + wrapper = mountWithContexts( + {}} inventory={inventory} />, + { + context: { + router: { history, route: { location: history.location } }, }, - data: 'An error occurred', - }, - }) - ); - await act(async () => { - wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')(); - }); - wrapper.update(); - await act(async () => { - wrapper.find('Toolbar Button[aria-label="Delete"]').invoke('onClick')(); - }); - await waitForElement( - wrapper, - 'InventoryGroupsDeleteModal', - el => el.props().isModalOpen === true - ); - await act(async () => { - wrapper.find('Radio[id="radio-delete"]').invoke('onChange')(); - }); - wrapper.update(); - await act(async () => { - wrapper - .find('ModalBoxFooter Button[aria-label="Delete"]') - .invoke('onClick')(); - }); - await waitForElement(wrapper, { title: 'Error!', variant: 'danger' }); - await act(async () => { - wrapper.find('ModalBoxCloseButton').invoke('onClose')(); + } + ); }); + expect(wrapper.find('InventoryGroupsAdd').length).toBe(1); }); }); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx new file mode 100644 index 0000000000..1840c3815c --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.jsx @@ -0,0 +1,248 @@ +import React, { useState, useEffect } from 'react'; +import { TrashAltIcon } from '@patternfly/react-icons'; +import { withRouter } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { getQSConfig, parseQueryString } from '@util/qs'; +import { InventoriesAPI, GroupsAPI } from '@api'; +import { Button, Tooltip } from '@patternfly/react-core'; +import AlertModal from '@components/AlertModal'; +import ErrorDetail from '@components/ErrorDetail'; +import DataListToolbar from '@components/DataListToolbar'; +import PaginatedDataList, { + ToolbarAddButton, +} from '@components/PaginatedDataList'; +import styled from 'styled-components'; +import InventoryGroupItem from './InventoryGroupItem'; +import InventoryGroupsDeleteModal from '../shared/InventoryGroupsDeleteModal'; + +const QS_CONFIG = getQSConfig('group', { + page: 1, + page_size: 20, + order_by: 'name', +}); + +const DeleteButton = styled(Button)` + padding: 5px 8px; + + &:hover { + background-color: #d9534f; + color: white; + } + + &[disabled] { + color: var(--pf-c-button--m-plain--Color); + pointer-events: initial; + cursor: not-allowed; + } +`; + +function cannotDelete(item) { + return !item.summary_fields.user_capabilities.delete; +} + +const useModal = () => { + const [isModalOpen, setIsModalOpen] = useState(false); + + function toggleModal() { + setIsModalOpen(!isModalOpen); + } + + return { + isModalOpen, + toggleModal, + }; +}; + +function InventoryGroupsList({ i18n, location, match }) { + const [actions, setActions] = useState(null); + const [contentError, setContentError] = useState(null); + const [deletionError, setDeletionError] = useState(null); + const [groupCount, setGroupCount] = useState(0); + const [groups, setGroups] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [selected, setSelected] = useState([]); + const { isModalOpen, toggleModal } = useModal(); + + const inventoryId = match.params.id; + const fetchGroups = (id, queryString) => { + const params = parseQueryString(QS_CONFIG, queryString); + return InventoriesAPI.readGroups(id, params); + }; + + useEffect(() => { + async function fetchData() { + try { + const [ + { + data: { count, results }, + }, + { + data: { actions: optionActions }, + }, + ] = await Promise.all([ + fetchGroups(inventoryId, location.search), + InventoriesAPI.readGroupsOptions(inventoryId), + ]); + + setGroups(results); + setGroupCount(count); + setActions(optionActions); + } catch (error) { + setContentError(error); + } finally { + setIsLoading(false); + } + } + fetchData(); + }, [inventoryId, location]); + + const handleSelectAll = isSelected => { + setSelected(isSelected ? [...groups] : []); + }; + + const handleSelect = row => { + if (selected.some(s => s.id === row.id)) { + setSelected(selected.filter(s => s.id !== row.id)); + } else { + setSelected(selected.concat(row)); + } + }; + + const renderTooltip = () => { + const itemsUnableToDelete = selected + .filter(cannotDelete) + .map(item => item.name) + .join(', '); + + if (selected.some(cannotDelete)) { + return ( +
+ {i18n._( + t`You do not have permission to delete the following Groups: ${itemsUnableToDelete}` + )} +
+ ); + } + if (selected.length) { + return i18n._(t`Delete`); + } + return i18n._(t`Select a row to delete`); + }; + + const handleDelete = async option => { + setIsLoading(true); + + try { + /* eslint-disable no-await-in-loop, no-restricted-syntax */ + /* Delete groups sequentially to avoid api integrity errors */ + for (const group of selected) { + if (option === 'delete') { + await GroupsAPI.destroy(+group.id); + } else if (option === 'promote') { + await InventoriesAPI.promoteGroup(inventoryId, +group.id); + } + } + /* eslint-enable no-await-in-loop, no-restricted-syntax */ + } catch (error) { + setDeletionError(error); + } + + toggleModal(); + + try { + const { + data: { count, results }, + } = await fetchGroups(inventoryId, location.search); + setGroups(results); + setGroupCount(count); + } catch (error) { + setContentError(error); + } + + setIsLoading(false); + }; + + const canAdd = + actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); + const isAllSelected = + selected.length > 0 && selected.length === groups.length; + + return ( + <> + ( + row.id === item.id)} + onSelect={() => handleSelect(item)} + /> + )} + renderToolbar={props => ( + +
+ + + +
+ , + canAdd && ( + + ), + ]} + /> + )} + emptyStateControls={ + canAdd && ( + + ) + } + /> + {deletionError && ( + setDeletionError(null)} + > + {i18n._(t`Failed to delete one or more groups.`)} + + + )} + + + ); +} +export default withI18n()(withRouter(InventoryGroupsList)); diff --git a/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.test.jsx new file mode 100644 index 0000000000..8c60d8bfbd --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventoryGroups/InventoryGroupsList.test.jsx @@ -0,0 +1,217 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { Route } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import { InventoriesAPI, GroupsAPI } from '@api'; +import InventoryGroupsList from './InventoryGroupsList'; + +jest.mock('@api'); + +const mockGroups = [ + { + id: 1, + type: 'group', + name: 'foo', + inventory: 1, + url: '/api/v2/groups/1', + summary_fields: { + user_capabilities: { + delete: true, + edit: true, + }, + }, + }, + { + id: 2, + type: 'group', + name: 'bar', + inventory: 1, + url: '/api/v2/groups/2', + summary_fields: { + user_capabilities: { + delete: true, + edit: true, + }, + }, + }, + { + id: 3, + type: 'group', + name: 'baz', + inventory: 1, + url: '/api/v2/groups/3', + summary_fields: { + user_capabilities: { + delete: false, + edit: false, + }, + }, + }, +]; + +describe('', () => { + let wrapper; + + beforeEach(async () => { + InventoriesAPI.readGroups.mockResolvedValue({ + data: { + count: mockGroups.length, + results: mockGroups, + }, + }); + InventoriesAPI.readGroupsOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + }, + }); + const history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/3/groups'], + }); + await act(async () => { + wrapper = mountWithContexts( + } + />, + { + context: { + router: { history, route: { location: history.location } }, + }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + + test('initially renders successfully', () => { + expect(wrapper.find('InventoryGroupsList').length).toBe(1); + }); + + test('should fetch groups from api and render them in the list', async () => { + expect(InventoriesAPI.readGroups).toHaveBeenCalled(); + expect(wrapper.find('InventoryGroupItem').length).toBe(3); + }); + + test('should check and uncheck the row item', async () => { + expect( + wrapper.find('PFDataListCheck[id="select-group-1"]').props().checked + ).toBe(false); + + await act(async () => { + wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')( + true + ); + }); + wrapper.update(); + expect( + wrapper.find('PFDataListCheck[id="select-group-1"]').props().checked + ).toBe(true); + + await act(async () => { + wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')( + false + ); + }); + wrapper.update(); + expect( + wrapper.find('PFDataListCheck[id="select-group-1"]').props().checked + ).toBe(false); + }); + + test('should check all row items when select all is checked', async () => { + wrapper.find('PFDataListCheck').forEach(el => { + expect(el.props().checked).toBe(false); + }); + await act(async () => { + wrapper.find('Checkbox#select-all').invoke('onChange')(true); + }); + wrapper.update(); + wrapper.find('PFDataListCheck').forEach(el => { + expect(el.props().checked).toBe(true); + }); + await act(async () => { + wrapper.find('Checkbox#select-all').invoke('onChange')(false); + }); + wrapper.update(); + wrapper.find('PFDataListCheck').forEach(el => { + expect(el.props().checked).toBe(false); + }); + }); + + test('should show content error when api throws error on initial render', async () => { + InventoriesAPI.readGroupsOptions.mockImplementation(() => + Promise.reject(new Error()) + ); + await act(async () => { + wrapper = mountWithContexts(); + }); + await waitForElement(wrapper, 'ContentError', el => el.length === 1); + }); + + test('should show content error if groups are not successfully fetched from api', async () => { + InventoriesAPI.readGroups.mockImplementation(() => + Promise.reject(new Error()) + ); + await act(async () => { + wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')(); + }); + wrapper.update(); + await act(async () => { + wrapper.find('Toolbar Button[aria-label="Delete"]').invoke('onClick')(); + }); + await waitForElement( + wrapper, + 'InventoryGroupsDeleteModal', + el => el.props().isModalOpen === true + ); + await act(async () => { + wrapper + .find('ModalBoxFooter Button[aria-label="Delete"]') + .invoke('onClick')(); + }); + await waitForElement(wrapper, 'ContentError', el => el.length === 1); + }); + + test('should show error modal when group is not successfully deleted from api', async () => { + GroupsAPI.destroy.mockRejectedValue( + new Error({ + response: { + config: { + method: 'delete', + url: '/api/v2/groups/1', + }, + data: 'An error occurred', + }, + }) + ); + await act(async () => { + wrapper.find('PFDataListCheck[id="select-group-1"]').invoke('onChange')(); + }); + wrapper.update(); + await act(async () => { + wrapper.find('Toolbar Button[aria-label="Delete"]').invoke('onClick')(); + }); + await waitForElement( + wrapper, + 'InventoryGroupsDeleteModal', + el => el.props().isModalOpen === true + ); + await act(async () => { + wrapper.find('Radio[id="radio-delete"]').invoke('onChange')(); + }); + wrapper.update(); + await act(async () => { + wrapper + .find('ModalBoxFooter Button[aria-label="Delete"]') + .invoke('onClick')(); + }); + await waitForElement(wrapper, { title: 'Error!', variant: 'danger' }); + await act(async () => { + wrapper.find('ModalBoxCloseButton').invoke('onClose')(); + }); + }); +}); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventoryGroupForm.jsx b/awx/ui_next/src/screens/Inventory/shared/InventoryGroupForm.jsx new file mode 100644 index 0000000000..f6dea49aee --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventoryGroupForm.jsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { withRouter } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { Formik } from 'formik'; +import { Form, Card, CardBody } from '@patternfly/react-core'; +import { t } from '@lingui/macro'; + +import FormRow from '@components/FormRow'; +import FormField from '@components/FormField'; +import FormActionGroup from '@components/FormActionGroup/FormActionGroup'; +import { VariablesField } from '@components/CodeMirrorInput'; +import { required } from '@util/validators'; + +function InventoryGroupForm({ + i18n, + error, + group = {}, + handleSubmit, + handleCancel, +}) { + const initialValues = { + name: group.name || '', + description: group.description || '', + variables: group.variables || '---', + }; + + return ( + + + ( +
+ + + + + + + + + {error ?
error
: null} + + )} + /> +
+
+ ); +} + +export default withI18n()(withRouter(InventoryGroupForm)); diff --git a/awx/ui_next/src/screens/Inventory/shared/InventoryGroupForm.test.jsx b/awx/ui_next/src/screens/Inventory/shared/InventoryGroupForm.test.jsx new file mode 100644 index 0000000000..ebf459f76f --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/shared/InventoryGroupForm.test.jsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import InventoryGroupForm from './InventoryGroupForm'; + +const group = { + id: 1, + name: 'Foo', + description: 'Bar', + variables: 'ying: false', +}; +describe('', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts( + + ); + }); + afterEach(() => { + wrapper.unmount(); + }); + test('initially renders successfully', () => { + expect(wrapper.length).toBe(1); + }); + test('should render values for the fields that have them', () => { + expect(wrapper.find("FormGroup[label='Name']").length).toBe(1); + expect(wrapper.find("FormGroup[label='Description']").length).toBe(1); + expect(wrapper.find("VariablesField[label='Variables']").length).toBe(1); + }); +});