diff --git a/awx/ui_next/src/components/AdHocCommands/AdHocDetailsStep.test.jsx b/awx/ui_next/src/components/AdHocCommands/AdHocDetailsStep.test.jsx index 699d768417..9207c659ff 100644 --- a/awx/ui_next/src/components/AdHocCommands/AdHocDetailsStep.test.jsx +++ b/awx/ui_next/src/components/AdHocCommands/AdHocDetailsStep.test.jsx @@ -27,6 +27,7 @@ const initialValues = { changes: false, escalation: false, extra_vars: '---', + module_name: 'shell', }; describe('', () => { diff --git a/awx/ui_next/src/components/CopyButton/CopyButton.jsx b/awx/ui_next/src/components/CopyButton/CopyButton.jsx index dd1e91a7aa..f8eeda7626 100644 --- a/awx/ui_next/src/components/CopyButton/CopyButton.jsx +++ b/awx/ui_next/src/components/CopyButton/CopyButton.jsx @@ -9,17 +9,24 @@ import useRequest, { useDismissableError } from '../../util/useRequest'; import AlertModal from '../AlertModal'; import ErrorDetail from '../ErrorDetail'; -function CopyButton({ i18n, copyItem, onLoading, onDoneLoading, helperText }) { +function CopyButton({ + i18n, + copyItem, + isDisabled, + onCopyStart, + onCopyFinish, + helperText, +}) { const { isLoading, error: copyError, request: copyItemToAPI } = useRequest( copyItem ); useEffect(() => { if (isLoading) { - return onLoading(); + return onCopyStart(); } - return onDoneLoading(); - }, [isLoading, onLoading, onDoneLoading]); + return onCopyFinish(); + }, [isLoading, onCopyStart, onCopyFinish]); const { error, dismissError } = useDismissableError(copyError); @@ -27,6 +34,7 @@ function CopyButton({ i18n, copyItem, onLoading, onDoneLoading, helperText }) { <> + + ) : ( + '' + )} + {inventory.summary_fields.user_capabilities.copy && ( + - - - - ) : ( - '' - )} - {inventory.summary_fields.user_capabilities.copy && ( - setIsDisabled(true)} - onDoneLoading={() => setIsDisabled(false)} - helperText={{ - tooltip: i18n._(t`Copy Inventory`), - errorMessage: i18n._(t`Failed to copy inventory.`), - }} - /> - )} - + onCopyStart={handleCopyStart} + onCopyFinish={handleCopyFinish} + helperText={{ + tooltip: i18n._(t`Copy Inventory`), + errorMessage: i18n._(t`Failed to copy inventory.`), + }} + /> + )} + + )} ); diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/useWsInventories.js b/awx/ui_next/src/screens/Inventory/InventoryList/useWsInventories.js index 60fae9e90e..7797256064 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryList/useWsInventories.js +++ b/awx/ui_next/src/screens/Inventory/InventoryList/useWsInventories.js @@ -1,11 +1,21 @@ import { useState, useEffect } from 'react'; +import { useLocation, useHistory } from 'react-router-dom'; +import { + parseQueryString, + replaceParams, + encodeNonDefaultQueryString, +} from '../../../util/qs'; import useWebsocket from '../../../util/useWebsocket'; import useThrottle from '../../../util/useThrottle'; export default function useWsInventories( initialInventories, - fetchInventoriesById + fetchInventories, + fetchInventoriesById, + qsConfig ) { + const location = useLocation(); + const history = useHistory(); const [inventories, setInventories] = useState(initialInventories); const [inventoriesToFetch, setInventoriesToFetch] = useState([]); const throttledInventoriesToFetch = useThrottle(inventoriesToFetch, 5000); @@ -53,7 +63,8 @@ export default function useWsInventories( function processWsMessage() { if ( !lastMessage?.inventory_id || - lastMessage.type !== 'inventory_update' + (lastMessage.type !== 'inventory_update' && + lastMessage.group_name !== 'inventories') ) { return; } @@ -64,16 +75,59 @@ export default function useWsInventories( return; } - if (!['pending', 'waiting', 'running'].includes(lastMessage.status)) { - enqueueId(lastMessage.inventory_id); - return; - } + const params = parseQueryString(qsConfig, location.search); const inventory = inventories[index]; const updatedInventory = { ...inventory, - isSourceSyncRunning: true, }; + + if ( + lastMessage.group_name === 'inventories' && + lastMessage.status === 'deleted' && + inventories.length === 1 && + params.page > 1 + ) { + // We've deleted the last inventory on this page so we'll + // try to navigate back to the previous page + const newParams = encodeNonDefaultQueryString( + qsConfig, + replaceParams(params, { + page: params.page - 1, + }) + ); + history.push(`${location.pathname}?${newParams}`); + return; + } + + if ( + lastMessage.group_name === 'inventories' && + lastMessage.status === 'deleted' + ) { + fetchInventories(); + return; + } + + if ( + !['pending', 'waiting', 'running', 'pending_deletion'].includes( + lastMessage.status + ) + ) { + enqueueId(lastMessage.inventory_id); + return; + } + + if ( + lastMessage.group_name === 'inventories' && + lastMessage.status === 'pending_deletion' + ) { + updatedInventory.pending_deletion = true; + } + + if (lastMessage.group_name !== 'inventories') { + updatedInventory.isSourceSyncRunning = true; + } + setInventories([ ...inventories.slice(0, index), updatedInventory, diff --git a/awx/ui_next/src/screens/Inventory/InventoryList/useWsInventories.test.jsx b/awx/ui_next/src/screens/Inventory/InventoryList/useWsInventories.test.jsx index 196166add6..fa18e5b175 100644 --- a/awx/ui_next/src/screens/Inventory/InventoryList/useWsInventories.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventoryList/useWsInventories.test.jsx @@ -16,11 +16,25 @@ jest.mock('../../../util/useThrottle', () => ({ function TestInner() { return
; } -function Test({ inventories, fetch }) { - const syncedJobs = useWsInventories(inventories, fetch); - return ; +function Test({ + inventories, + fetchInventories, + fetchInventoriesById, + qsConfig, +}) { + const syncedInventories = useWsInventories( + inventories, + fetchInventories, + fetchInventoriesById, + qsConfig + ); + return ; } +const QS_CONFIG = { + defaultParams: {}, +}; + describe('useWsInventories hook', () => { let debug; let wrapper; @@ -31,14 +45,16 @@ describe('useWsInventories hook', () => { afterEach(() => { global.console.debug = debug; + WS.clean(); }); test('should return inventories list', () => { const inventories = [{ id: 1 }]; - wrapper = mountWithContexts(); + wrapper = mountWithContexts( + + ); expect(wrapper.find('TestInner').prop('inventories')).toEqual(inventories); - WS.clean(); }); test('should establish websocket connection', async () => { @@ -47,7 +63,9 @@ describe('useWsInventories hook', () => { const inventories = [{ id: 1 }]; await act(async () => { - wrapper = await mountWithContexts(); + wrapper = await mountWithContexts( + + ); }); await mockServer.connected; @@ -61,7 +79,6 @@ describe('useWsInventories hook', () => { }, }) ); - WS.clean(); }); test('should update inventory sync status', async () => { @@ -70,7 +87,9 @@ describe('useWsInventories hook', () => { const inventories = [{ id: 1 }]; await act(async () => { - wrapper = await mountWithContexts(); + wrapper = await mountWithContexts( + + ); }); await mockServer.connected; @@ -98,17 +117,22 @@ describe('useWsInventories hook', () => { expect( wrapper.find('TestInner').prop('inventories')[0].isSourceSyncRunning ).toEqual(true); - WS.clean(); }); test('should fetch fresh inventory after sync runs', async () => { global.document.cookie = 'csrftoken=abc123'; const mockServer = new WS('wss://localhost/websocket/'); const inventories = [{ id: 1 }]; - const fetch = jest.fn(() => []); + const fetchInventories = jest.fn(() => []); + const fetchInventoriesById = jest.fn(() => []); await act(async () => { wrapper = await mountWithContexts( - + ); }); @@ -123,7 +147,75 @@ describe('useWsInventories hook', () => { ); }); - expect(fetch).toHaveBeenCalledWith([1]); - WS.clean(); + expect(fetchInventoriesById).toHaveBeenCalledWith([1]); + }); + + test('should update inventory pending_deletion', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('wss://localhost/websocket/'); + + const inventories = [{ id: 1, pending_deletion: false }]; + await act(async () => { + wrapper = await mountWithContexts( + + ); + }); + + await mockServer.connected; + await expect(mockServer).toReceiveMessage( + JSON.stringify({ + xrftoken: 'abc123', + groups: { + inventories: ['status_changed'], + jobs: ['status_changed'], + control: ['limit_reached_1'], + }, + }) + ); + act(() => { + mockServer.send( + JSON.stringify({ + inventory_id: 1, + group_name: 'inventories', + status: 'pending_deletion', + }) + ); + }); + wrapper.update(); + + expect( + wrapper.find('TestInner').prop('inventories')[0].pending_deletion + ).toEqual(true); + }); + + test('should refetch inventories after an inventory is deleted', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('wss://localhost/websocket/'); + const inventories = [{ id: 1 }, { id: 2 }]; + const fetchInventories = jest.fn(() => []); + const fetchInventoriesById = jest.fn(() => []); + await act(async () => { + wrapper = await mountWithContexts( + + ); + }); + + await mockServer.connected; + await act(async () => { + mockServer.send( + JSON.stringify({ + inventory_id: 1, + group_name: 'inventories', + status: 'deleted', + }) + ); + }); + + expect(fetchInventories).toHaveBeenCalled(); }); }); diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx index e23f3339c5..b2539e5f87 100644 --- a/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx +++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectListItem.jsx @@ -79,6 +79,14 @@ function ProjectListItem({ ); }; + const handleCopyStart = useCallback(() => { + setIsDisabled(true); + }, []); + + const handleCopyFinish = useCallback(() => { + setIsDisabled(false); + }, []); + const labelId = `check-action-${project.id}`; return ( setIsDisabled(true)} - onDoneLoading={() => setIsDisabled(false)} + onCopyStart={handleCopyStart} + onCopyFinish={handleCopyFinish} helperText={{ tooltip: i18n._(t`Copy Project`), errorMessage: i18n._(t`Failed to copy project.`), diff --git a/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx b/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx index 10a34bedc7..242f757a54 100644 --- a/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx +++ b/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx @@ -60,6 +60,14 @@ function TemplateListItem({ await fetchTemplates(); }, [fetchTemplates, template.id, template.name, template.type]); + const handleCopyStart = useCallback(() => { + setIsDisabled(true); + }, []); + + const handleCopyFinish = useCallback(() => { + setIsDisabled(false); + }, []); + const missingResourceIcon = template.type === 'job_template' && (!template.summary_fields.project || @@ -157,8 +165,8 @@ function TemplateListItem({ errorMessage: i18n._(t`Failed to copy template.`), }} isDisabled={isDisabled} - onLoading={() => setIsDisabled(true)} - onDoneLoading={() => setIsDisabled(false)} + onCopyStart={handleCopyStart} + onCopyFinish={handleCopyFinish} copyItem={copyTemplate} /> )}