diff --git a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx index 5be5cf580f..6e4a831afa 100644 --- a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx +++ b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx @@ -17,7 +17,7 @@ import PaginatedDataList, { } from '../../../components/PaginatedDataList'; import useRequest, { useDeleteItems } from '../../../util/useRequest'; import { getQSConfig, parseQueryString } from '../../../util/qs'; - +import useWsTemplates from './useWsTemplates'; import AddDropDownButton from '../../../components/AddDropDownButton'; import TemplateListItem from './TemplateListItem'; @@ -36,27 +36,27 @@ function TemplateList({ i18n }) { const [selected, setSelected] = useState([]); const { - result: { templates, count, jtActions, wfjtActions }, + result: { results, count, jtActions, wfjtActions }, error: contentError, isLoading, request: fetchTemplates, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, location.search); - const results = await Promise.all([ + const responses = await Promise.all([ UnifiedJobTemplatesAPI.read(params), JobTemplatesAPI.readOptions(), WorkflowJobTemplatesAPI.readOptions(), ]); return { - templates: results[0].data.results, - count: results[0].data.count, - jtActions: results[1].data.actions, - wfjtActions: results[2].data.actions, + results: responses[0].data.results, + count: responses[0].data.count, + jtActions: responses[1].data.actions, + wfjtActions: responses[2].data.actions, }; }, [location]), { - templates: [], + results: [], count: 0, jtActions: {}, wfjtActions: {}, @@ -67,6 +67,8 @@ function TemplateList({ i18n }) { fetchTemplates(); }, [fetchTemplates]); + const templates = useWsTemplates(results); + const isAllSelected = selected.length === templates.length && selected.length > 0; const { diff --git a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.test.jsx b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.test.jsx index b04ef9c35b..377e87583e 100644 --- a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.test.jsx +++ b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.test.jsx @@ -75,6 +75,7 @@ const mockTemplates = [ ]; describe('', () => { + let debug; beforeEach(() => { UnifiedJobTemplatesAPI.read.mockResolvedValue({ data: { @@ -88,10 +89,13 @@ describe('', () => { actions: [], }, }); + debug = global.console.debug; // eslint-disable-line prefer-destructuring + global.console.debug = () => {}; }); afterEach(() => { jest.clearAllMocks(); + global.console.debug = debug; }); test('initially renders successfully', async () => { diff --git a/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.js b/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.js new file mode 100644 index 0000000000..fa10424c85 --- /dev/null +++ b/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.js @@ -0,0 +1,63 @@ +import { useState, useEffect } from 'react'; +import useWebsocket from '../../../util/useWebsocket'; + +export default function useWsTemplates(initialTemplates) { + const [templates, setTemplates] = useState(initialTemplates); + const lastMessage = useWebsocket({ + jobs: ['status_changed'], + control: ['limit_reached_1'], + }); + + useEffect(() => { + setTemplates(initialTemplates); + }, [initialTemplates]); + + useEffect( + function parseWsMessage() { + if (!lastMessage?.unified_job_id) { + return; + } + const index = templates.findIndex( + t => t.id === lastMessage.unified_job_template_id + ); + if (index === -1) { + return; + } + + const template = templates[index]; + const updated = [...templates]; + updated[index] = updateTemplate(template, lastMessage); + setTemplates(updated); + }, + [lastMessage] // eslint-disable-line react-hooks/exhaustive-deps + ); + + return templates; +} + +function updateTemplate(template, message) { + const recentJobs = [...(template.summary_fields.recent_jobs || [])]; + const job = { + id: message.unified_job_id, + status: message.status, + finished: message.finished || null, + type: message.type, + }; + const index = recentJobs.findIndex(j => j.id === job.id); + if (index > -1) { + recentJobs[index] = { + ...recentJobs[index], + ...job, + }; + } else { + recentJobs.unshift(job); + } + + return { + ...template, + summary_fields: { + ...template.summary_fields, + recent_jobs: recentJobs.slice(0, 10), + }, + }; +} diff --git a/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.test.jsx b/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.test.jsx new file mode 100644 index 0000000000..61bc6e042e --- /dev/null +++ b/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.test.jsx @@ -0,0 +1,193 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import WS from 'jest-websocket-mock'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import useWsTemplates from './useWsTemplates'; + +/* + Jest mock timers don’t play well with jest-websocket-mock, + so we'll stub out throttling to resolve immediately +*/ +jest.mock('../../../util/useThrottle', () => ({ + __esModule: true, + default: jest.fn(val => val), +})); + +function TestInner() { + return
; +} +function Test({ templates }) { + const syncedTemplates = useWsTemplates(templates); + return ; +} + +describe('useWsTemplates hook', () => { + let debug; + let wrapper; + beforeEach(() => { + debug = global.console.debug; // eslint-disable-line prefer-destructuring + global.console.debug = () => {}; + }); + + afterEach(() => { + global.console.debug = debug; + }); + + test('should return templates list', () => { + const templates = [{ id: 1 }]; + wrapper = mountWithContexts(); + + expect(wrapper.find('TestInner').prop('templates')).toEqual(templates); + WS.clean(); + }); + + test('should establish websocket connection', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('wss://localhost/websocket/'); + + const templates = [{ id: 1 }]; + await act(async () => { + wrapper = await mountWithContexts(); + }); + + await mockServer.connected; + await expect(mockServer).toReceiveMessage( + JSON.stringify({ + xrftoken: 'abc123', + groups: { + jobs: ['status_changed'], + control: ['limit_reached_1'], + }, + }) + ); + WS.clean(); + }); + + test('should update recent job status', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('wss://localhost/websocket/'); + + const templates = [ + { + id: 1, + summary_fields: { + recent_jobs: [ + { + id: 10, + type: 'job', + status: 'running', + }, + { + id: 11, + type: 'job', + status: 'successful', + }, + ], + }, + }, + ]; + await act(async () => { + wrapper = await mountWithContexts(); + }); + + await mockServer.connected; + await expect(mockServer).toReceiveMessage( + JSON.stringify({ + xrftoken: 'abc123', + groups: { + jobs: ['status_changed'], + control: ['limit_reached_1'], + }, + }) + ); + expect( + wrapper.find('TestInner').prop('templates')[0].summary_fields + .recent_jobs[0].status + ).toEqual('running'); + act(() => { + mockServer.send( + JSON.stringify({ + unified_job_template_id: 1, + unified_job_id: 10, + type: 'job', + status: 'successful', + }) + ); + }); + wrapper.update(); + + expect( + wrapper.find('TestInner').prop('templates')[0].summary_fields + .recent_jobs[0].status + ).toEqual('successful'); + WS.clean(); + }); + + test('should add new job status', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('wss://localhost/websocket/'); + + const templates = [ + { + id: 1, + summary_fields: { + recent_jobs: [ + { + id: 10, + type: 'job', + status: 'running', + }, + { + id: 11, + type: 'job', + status: 'successful', + }, + ], + }, + }, + ]; + await act(async () => { + wrapper = await mountWithContexts(); + }); + + await mockServer.connected; + await expect(mockServer).toReceiveMessage( + JSON.stringify({ + xrftoken: 'abc123', + groups: { + jobs: ['status_changed'], + control: ['limit_reached_1'], + }, + }) + ); + expect( + wrapper.find('TestInner').prop('templates')[0].summary_fields + .recent_jobs[0].status + ).toEqual('running'); + act(() => { + mockServer.send( + JSON.stringify({ + unified_job_template_id: 1, + unified_job_id: 13, + type: 'job', + status: 'running', + }) + ); + }); + wrapper.update(); + + expect( + wrapper.find('TestInner').prop('templates')[0].summary_fields.recent_jobs + ).toHaveLength(3); + expect( + wrapper.find('TestInner').prop('templates')[0].summary_fields + .recent_jobs[0] + ).toEqual({ + id: 13, + status: 'running', + finished: null, + type: 'job', + }); + WS.clean(); + }); +});