From 83ccf1dd36ebf30581c3c67417da80fef55f2903 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Sat, 18 Feb 2023 11:33:44 -0500 Subject: [PATCH 1/2] Add constructed inventory detail's sync button --- awx/ui/src/api/models/Inventories.js | 4 +- .../ConstructedInventoryDetail.js | 140 +++++++++--- .../ConstructedInventoryDetail.test.js | 210 ++++++++++++++++-- .../ConstructedInventorySyncButton.js | 59 +++++ .../ConstructedInventorySyncButton.test.js | 41 ++++ .../InventorySourceDetail.js | 2 +- .../useWsInventorySourcesDetails.js | 0 .../useWsInventorySourcesDetails.test.js | 0 8 files changed, 407 insertions(+), 49 deletions(-) create mode 100644 awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventorySyncButton.js create mode 100644 awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventorySyncButton.test.js rename awx/ui/src/screens/Inventory/{InventorySources => shared}/useWsInventorySourcesDetails.js (100%) rename awx/ui/src/screens/Inventory/{InventorySources => shared}/useWsInventorySourcesDetails.test.js (100%) diff --git a/awx/ui/src/api/models/Inventories.js b/awx/ui/src/api/models/Inventories.js index 37654478a7..4fd145e178 100644 --- a/awx/ui/src/api/models/Inventories.js +++ b/awx/ui/src/api/models/Inventories.js @@ -13,7 +13,7 @@ class Inventories extends InstanceGroupsMixin(Base) { this.readGroups = this.readGroups.bind(this); this.readGroupsOptions = this.readGroupsOptions.bind(this); this.promoteGroup = this.promoteGroup.bind(this); - this.readSourceInventories = this.readSourceInventories.bind(this); + this.readInputInventories = this.readInputInventories.bind(this); } readAccessList(id, params) { @@ -73,7 +73,7 @@ class Inventories extends InstanceGroupsMixin(Base) { }); } - readSourceInventories(inventoryId, params) { + readInputInventories(inventoryId, params) { return this.http.get(`${this.baseUrl}${inventoryId}/input_inventories/`, { params, }); diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.js b/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.js index 914e86b0b1..d8e136646b 100644 --- a/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.js +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.js @@ -9,50 +9,97 @@ import { TextListItem, TextListItemVariants, TextListVariants, + Tooltip, } from '@patternfly/react-core'; +import { InventoriesAPI, ConstructedInventoriesAPI } from 'api'; +import { Inventory } from 'types'; +import { formatDateString } from 'util/dates'; +import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails'; +import useRequest, { useDismissableError } from 'hooks/useRequest'; import AlertModal from 'components/AlertModal'; import { CardBody, CardActionsRow } from 'components/Card'; -import { DetailList, Detail, UserDateDetail } from 'components/DetailList'; +import ChipGroup from 'components/ChipGroup'; import { VariablesDetail } from 'components/CodeEditor'; -import DeleteButton from 'components/DeleteButton'; -import ErrorDetail from 'components/ErrorDetail'; import ContentError from 'components/ContentError'; import ContentLoading from 'components/ContentLoading'; -import ChipGroup from 'components/ChipGroup'; -import Popover from 'components/Popover'; -import { InventoriesAPI, ConstructedInventoriesAPI } from 'api'; -import useRequest, { useDismissableError } from 'hooks/useRequest'; -import { Inventory } from 'types'; -import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails'; +import { DetailList, Detail, UserDateDetail } from 'components/DetailList'; +import DeleteButton from 'components/DeleteButton'; +import ErrorDetail from 'components/ErrorDetail'; import InstanceGroupLabels from 'components/InstanceGroupLabels'; +import JobCancelButton from 'components/JobCancelButton'; +import Popover from 'components/Popover'; +import StatusLabel from 'components/StatusLabel'; +import ConstructedInventorySyncButton from './ConstructedInventorySyncButton'; +import useWsInventorySourcesDetails from '../shared/useWsInventorySourcesDetails'; import getHelpText from '../shared/Inventory.helptext'; +function JobStatusLabel({ job }) { + if (!job) { + return null; + } + + return ( + +
{t`MOST RECENT SYNC`}
+
+ {t`JOB ID:`} {job.id} +
+
+ {t`STATUS:`} {job.status.toUpperCase()} +
+ {job.finished && ( +
+ {t`FINISHED:`} {formatDateString(job.finished)} +
+ )} + + } + key={job.id} + > + + + +
+ ); +} + function ConstructedInventoryDetail({ inventory }) { const history = useHistory(); const helpText = getHelpText(); const { - result: { instanceGroups, sourceInventories, actions }, + result: { instanceGroups, inputInventories, inventorySource, actions }, request: fetchRelatedDetails, error: contentError, isLoading, } = useRequest( useCallback(async () => { - const [response, sourceInvResponse, options] = await Promise.all([ + const [ + instanceGroupsResponse, + inputInventoriesResponse, + inventorySourceResponse, + optionsResponse, + ] = await Promise.all([ InventoriesAPI.readInstanceGroups(inventory.id), - InventoriesAPI.readSourceInventories(inventory.id), - ConstructedInventoriesAPI.readOptions(inventory.id), + InventoriesAPI.readInputInventories(inventory.id), + InventoriesAPI.readSources(inventory.id), + ConstructedInventoriesAPI.readOptions(), ]); return { - instanceGroups: response.data.results, - sourceInventories: sourceInvResponse.data.results, - actions: options.data.actions.GET, + instanceGroups: instanceGroupsResponse.data.results, + inputInventories: inputInventoriesResponse.data.results, + inventorySource: inventorySourceResponse.data.results[0], + actions: optionsResponse.data.actions.GET, }; }, [inventory.id]), { instanceGroups: [], - sourceInventories: [], + inputInventories: [], + inventorySource: {}, actions: {}, isLoading: true, } @@ -62,6 +109,12 @@ function ConstructedInventoryDetail({ inventory }) { fetchRelatedDetails(); }, [fetchRelatedDetails]); + const wsInventorySource = useWsInventorySourcesDetails(inventorySource); + const inventorySourceSyncJob = + wsInventorySource.summary_fields?.current_job || + wsInventorySource.summary_fields?.last_job || + null; + const { request: deleteInventory, error: deleteError } = useRequest( useCallback(async () => { await InventoriesAPI.destroy(inventory.id); @@ -71,9 +124,6 @@ function ConstructedInventoryDetail({ inventory }) { const { error, dismissError } = useDismissableError(deleteError); - const { organization, user_capabilities: userCapabilities } = - inventory.summary_fields; - const deleteDetailsRequests = relatedResourceDeleteRequests.inventory(inventory); @@ -93,6 +143,14 @@ function ConstructedInventoryDetail({ inventory }) { value={inventory.name} dataCy="constructed-inventory-name" /> + + ) + } + /> - {organization.name} + + {inventory.summary_fields?.organization.name} } /> @@ -204,26 +264,26 @@ function ConstructedInventoryDetail({ inventory }) { /> - {sourceInventories?.map((sourceInventory) => ( + {inputInventories?.map((inputInventory) => ( - - {sourceInventory.name} + + {inputInventory.name} ))} } - isEmpty={sourceInventories?.length === 0} + isEmpty={inputInventories?.length === 0} /> - {userCapabilities.edit && ( + {inventory?.summary_fields?.user_capabilities?.edit && ( + + {startError && ( + + {t`Failed to sync constructed inventory source`} + + + )} + + ); +} + +ConstructedInventorySyncButton.propTypes = { + inventoryId: PropTypes.number.isRequired, +}; + +export default ConstructedInventorySyncButton; diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventorySyncButton.test.js b/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventorySyncButton.test.js new file mode 100644 index 0000000000..75a5900abb --- /dev/null +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventorySyncButton.test.js @@ -0,0 +1,41 @@ +import React from 'react'; +import { InventoriesAPI } from 'api'; +import ConstructedInventorySyncButton from './ConstructedInventorySyncButton'; +import { render, fireEvent, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +jest.mock('../../../api'); + +const inventory = { id: 100, name: 'Constructed Inventory' }; + +describe('', () => { + const Component = () => ( + + ); + + test('should render start sync button', () => { + render(); + expect( + screen.getByRole('button', { name: 'Start inventory source sync' }) + ).toBeInTheDocument(); + }); + + test('should make expected api request on sync', async () => { + render(); + const syncButton = screen.queryByText('Sync'); + fireEvent.click(syncButton); + await waitFor(() => + expect(InventoriesAPI.syncAllSources).toHaveBeenCalledWith(100) + ); + }); + + test('should show alert modal on throw', async () => { + InventoriesAPI.syncAllSources.mockRejectedValueOnce(new Error()); + render(); + await waitFor(() => { + const syncButton = screen.queryByText('Sync'); + fireEvent.click(syncButton); + }); + expect(screen.getByRole('dialog', { name: 'Alert modal Error!' })); + }); +}); diff --git a/awx/ui/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.js b/awx/ui/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.js index 87e9ece5cd..af657b4fbe 100644 --- a/awx/ui/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.js +++ b/awx/ui/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.js @@ -31,7 +31,7 @@ import { formatDateString } from 'util/dates'; import Popover from 'components/Popover'; import { VERBOSITY } from 'components/VerbositySelectField'; import InventorySourceSyncButton from '../shared/InventorySourceSyncButton'; -import useWsInventorySourcesDetails from '../InventorySources/useWsInventorySourcesDetails'; +import useWsInventorySourcesDetails from '../shared/useWsInventorySourcesDetails'; import getHelpText from '../shared/Inventory.helptext'; function InventorySourceDetail({ inventorySource }) { diff --git a/awx/ui/src/screens/Inventory/InventorySources/useWsInventorySourcesDetails.js b/awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.js similarity index 100% rename from awx/ui/src/screens/Inventory/InventorySources/useWsInventorySourcesDetails.js rename to awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.js diff --git a/awx/ui/src/screens/Inventory/InventorySources/useWsInventorySourcesDetails.test.js b/awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.test.js similarity index 100% rename from awx/ui/src/screens/Inventory/InventorySources/useWsInventorySourcesDetails.test.js rename to awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.test.js From 295ec4f22a74e487e303a52a61f828d72cf0c69c Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Tue, 21 Feb 2023 17:47:56 -0500 Subject: [PATCH 2/2] Update inventory details after inventory source sync --- .../ConstructedInventoryDetail.js | 41 +++++++++------ .../shared/useWsInventorySourcesDetails.js | 50 ++++++++++++------- .../useWsInventorySourcesDetails.test.js | 24 +++++++++ 3 files changed, 82 insertions(+), 33 deletions(-) diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.js b/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.js index d8e136646b..6108dc2330 100644 --- a/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.js +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.js @@ -5,6 +5,8 @@ import { t } from '@lingui/macro'; import { Button, Chip, + Label, + LabelGroup, TextList, TextListItem, TextListItemVariants, @@ -114,6 +116,10 @@ function ConstructedInventoryDetail({ inventory }) { wsInventorySource.summary_fields?.current_job || wsInventorySource.summary_fields?.last_job || null; + const wsInventory = { + ...inventory, + ...wsInventorySource?.summary_fields?.inventory, + }; const { request: deleteInventory, error: deleteError } = useRequest( useCallback(async () => { @@ -180,19 +186,19 @@ function ConstructedInventoryDetail({ inventory }) { /> @@ -204,7 +210,7 @@ function ConstructedInventoryDetail({ inventory }) { /> @@ -266,22 +272,25 @@ function ConstructedInventoryDetail({ inventory }) { fullWidth label={t`Input Inventories`} value={ - + {inputInventories?.map((inputInventory) => ( - ( + + {content} + + )} > - - {inputInventory.name} - - + {inputInventory.name} + ))} - + } isEmpty={inputInventories?.length === 0} /> diff --git a/awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.js b/awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.js index e93f28f58b..e010b8916a 100644 --- a/awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.js +++ b/awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.js @@ -1,16 +1,17 @@ import { useState, useEffect } from 'react'; import useWebsocket from 'hooks/useWebsocket'; +import { InventorySourcesAPI } from 'api'; -export default function useWsInventorySourcesDetails(initialSources) { - const [sources, setSources] = useState(initialSources); +export default function useWsInventorySourcesDetails(initialSource) { + const [source, setSource] = useState(initialSource); const lastMessage = useWebsocket({ jobs: ['status_changed'], control: ['limit_reached_1'], }); useEffect(() => { - setSources(initialSources); - }, [initialSources]); + setSource(initialSource); + }, [initialSource]); useEffect( () => { @@ -21,22 +22,37 @@ export default function useWsInventorySourcesDetails(initialSources) { ) { return; } - const updateSource = { - ...sources, - summary_fields: { - ...sources.summary_fields, - current_job: { - id: lastMessage.unified_job_id, - status: lastMessage.status, - finished: lastMessage.finished, - }, - }, - }; - setSources(updateSource); + if ( + ['successful', 'failed', 'error', 'cancelled'].includes( + lastMessage.status + ) + ) { + fetchSource(); + } + setSource(updateSource(source, lastMessage)); }, [lastMessage] // eslint-disable-line react-hooks/exhaustive-deps ); - return sources; + async function fetchSource() { + const { data } = await InventorySourcesAPI.readDetail(source.id); + setSource(data); + } + + return source; +} + +function updateSource(source, message) { + return { + ...source, + summary_fields: { + ...source.summary_fields, + current_job: { + id: message.unified_job_id, + status: message.status, + finished: message.finished, + }, + }, + }; } diff --git a/awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.test.js b/awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.test.js index 25fb97850b..d1f1e17009 100644 --- a/awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.test.js +++ b/awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.test.js @@ -1,9 +1,12 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import WS from 'jest-websocket-mock'; +import { InventorySourcesAPI } from 'api'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import useWsInventorySourceDetails from './useWsInventorySourcesDetails'; +jest.mock('../../../api/models/InventorySources'); + function TestInner() { return
; } @@ -111,6 +114,27 @@ describe('useWsProject', () => { status: 'running', finished: null, }); + + expect(InventorySourcesAPI.readDetail).toHaveBeenCalledTimes(0); + InventorySourcesAPI.readDetail.mockResolvedValue({ + data: {}, + }); + await act(async () => { + mockServer.send( + JSON.stringify({ + group_name: 'jobs', + inventory_id: 1, + status: 'successful', + type: 'inventory_update', + unified_job_id: 2, + unified_job_template_id: 1, + inventory_source_id: 1, + }) + ); + }); + expect(InventorySourcesAPI.readDetail).toHaveBeenCalledTimes(1); + + jest.clearAllMocks(); WS.clean(); }); });