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();
+ });
+});