diff --git a/webpack/JobInvocationDetail/JobInvocationDetail.scss b/webpack/JobInvocationDetail/JobInvocationDetail.scss
index 38f0615b0..0d0c5fa9c 100644
--- a/webpack/JobInvocationDetail/JobInvocationDetail.scss
+++ b/webpack/JobInvocationDetail/JobInvocationDetail.scss
@@ -74,6 +74,10 @@ section.job-additional-info {
margin-left: 10px;
margin-right: 15px;
}
+
+ .pf-v5-c-table__tbody > tr.row-hidden {
+ display: none;
+ }
}
.template-invocation {
diff --git a/webpack/JobInvocationDetail/JobInvocationHostTable.js b/webpack/JobInvocationDetail/JobInvocationHostTable.js
index 33cde9700..26636c436 100644
--- a/webpack/JobInvocationDetail/JobInvocationHostTable.js
+++ b/webpack/JobInvocationDetail/JobInvocationHostTable.js
@@ -61,9 +61,47 @@ const JobInvocationHostTable = ({
const [allHostsIds, setAllHostsIds] = useState([]);
// Expansive items
- const [expandedHost, setExpandedHost] = useState([]);
+ const [expandedHost, setExpandedHost] = useState(new Set());
const prevStatusLabel = useRef(statusLabel);
+ const [hostInvocationStates, setHostInvocationStates] = useState({});
+
+ const getInvocationState = hostId =>
+ hostInvocationStates[hostId] || {
+ showOutputType: { stderr: true, stdout: true, debug: true },
+ showTemplatePreview: false,
+ showCommand: false,
+ };
+
+ const updateInvocationState = (hostId, stateKey, value) => {
+ setHostInvocationStates(prevStates => {
+ const currentHostState = getInvocationState(hostId);
+
+ const newValue =
+ typeof value === 'function' ? value(currentHostState[stateKey]) : value;
+
+ return {
+ ...prevStates,
+ [hostId]: {
+ ...currentHostState,
+ [stateKey]: newValue,
+ },
+ };
+ });
+ };
+
+ const isHostExpanded = hostId => expandedHost.has(hostId);
+ const setHostExpanded = (hostId, isExpanding = true) =>
+ setExpandedHost(prevExpandedSet => {
+ const newSet = new Set(prevExpandedSet);
+ if (isExpanding) {
+ newSet.add(hostId);
+ } else {
+ newSet.delete(hostId);
+ }
+ return newSet;
+ });
+
// Page table params
// Parse URL
const {
@@ -307,29 +345,18 @@ const JobInvocationHostTable = ({
);
- const isHostExpanded = host => expandedHost.includes(host.id);
-
- const setHostExpanded = (host, isExpanding = true) =>
- setExpandedHost(prevExpanded => {
- const otherExpandedHosts = prevExpanded.filter(h => h !== host.id);
- return isExpanding
- ? [...otherExpandedHosts, host.id]
- : otherExpandedHosts;
- });
-
const pageHostIds = results.map(h => h.id);
const areAllPageRowsExpanded =
pageHostIds.length > 0 &&
- pageHostIds.every(hostId => expandedHost.includes(hostId));
+ pageHostIds.every(hostId => expandedHost.has(hostId));
const onExpandAll = () => {
setExpandedHost(() => {
if (areAllPageRowsExpanded) {
- return [];
+ return new Set();
}
-
- return pageHostIds;
+ return new Set(pageHostIds);
});
};
@@ -393,54 +420,88 @@ const JobInvocationHostTable = ({
isDeleteable={false}
childrenOutsideTbody
>
- {results.map((result, rowIndex) => (
-
-
- |
- setHostExpanded(result, !isHostExpanded(result)),
- expandId: 'host-expandable',
- }}
- />
-
- {columnNamesKeys.map(k => (
- | {columns[k].wrapper(result)} |
- ))}
-
-
- |
-
-
- | {
+ const currentInvocationState = getInvocationState(result.id);
+ return (
+ |
+
+ |
+ setHostExpanded(result.id, !isHostExpanded(result.id)),
+ expandId: 'host-expandable',
+ }}
+ />
+
+ {columnNamesKeys.map(k => (
+ | {columns[k].wrapper(result)} |
+ ))}
+
+
+ |
+
+
-
- {result.job_status === 'cancelled' ||
- result.job_status === 'N/A' ? (
-
- {__('A task for this host has not been started')}
-
- ) : (
-
- )}
-
-
-
-
- ))}
+
+
+ {result.job_status === 'cancelled' ||
+ result.job_status === 'N/A' ? (
+
+ {__('A task for this host has not been started')}
+
+ ) : (
+
+ updateInvocationState(
+ result.id,
+ 'showOutputType',
+ value
+ )
+ }
+ setShowTemplatePreview={value =>
+ updateInvocationState(
+ result.id,
+ 'showTemplatePreview',
+ value
+ )
+ }
+ setShowCommand={value =>
+ updateInvocationState(
+ result.id,
+ 'showCommand',
+ value
+ )
+ }
+ />
+ )}
+
+ |
+
+
+ );
+ })}
>
diff --git a/webpack/JobInvocationDetail/TemplateInvocation.js b/webpack/JobInvocationDetail/TemplateInvocation.js
index 175b41061..9045be218 100644
--- a/webpack/JobInvocationDetail/TemplateInvocation.js
+++ b/webpack/JobInvocationDetail/TemplateInvocation.js
@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useRef } from 'react';
+import React, { useEffect, useRef } from 'react';
import { isEmpty } from 'lodash';
import PropTypes from 'prop-types';
import { ClipboardCopyButton, Alert, Skeleton } from '@patternfly/react-core';
@@ -60,6 +60,12 @@ export const TemplateInvocation = ({
isExpanded,
hostName,
hostProxy,
+ showOutputType,
+ setShowOutputType,
+ showTemplatePreview,
+ setShowTemplatePreview,
+ showCommand,
+ setShowCommand,
}) => {
const intervalRef = useRef(null);
const templateURL = showTemplateInvocationUrl(hostID, jobID);
@@ -74,14 +80,6 @@ export const TemplateInvocation = ({
responseRef.current = response;
}, [response]);
- const [showOutputType, setShowOutputType] = useState({
- stderr: true,
- stdout: true,
- debug: true,
- });
- const [showTemplatePreview, setShowTemplatePreview] = useState(false);
- const [showCommand, setShowCommand] = useState(false);
-
useEffect(() => {
const dispatchFetch = () => {
dispatch(
@@ -123,12 +121,8 @@ export const TemplateInvocation = ({
};
}, [isExpanded, dispatch, templateURL, hostID]);
- if (!isExpanded) {
- return null;
- }
-
- if ((status === STATUS.PENDING && isEmpty(response)) || !response) {
- return ;
+ if (!response || (status === STATUS.PENDING && isEmpty(response))) {
+ return ;
}
const errorMessage =
@@ -239,6 +233,16 @@ TemplateInvocation.propTypes = {
jobID: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
isInTableView: PropTypes.bool,
isExpanded: PropTypes.bool,
+ showOutputType: PropTypes.shape({
+ stderr: PropTypes.bool,
+ stdout: PropTypes.bool,
+ debug: PropTypes.bool,
+ }).isRequired,
+ setShowOutputType: PropTypes.func.isRequired,
+ showTemplatePreview: PropTypes.bool.isRequired,
+ setShowTemplatePreview: PropTypes.func.isRequired,
+ showCommand: PropTypes.bool.isRequired,
+ setShowCommand: PropTypes.func.isRequired,
};
TemplateInvocation.defaultProps = {
diff --git a/webpack/JobInvocationDetail/TemplateInvocationComponents/OutputToggleGroup.js b/webpack/JobInvocationDetail/TemplateInvocationComponents/OutputToggleGroup.js
index 8d0e6e1e5..6f2591316 100644
--- a/webpack/JobInvocationDetail/TemplateInvocationComponents/OutputToggleGroup.js
+++ b/webpack/JobInvocationDetail/TemplateInvocationComponents/OutputToggleGroup.js
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import {
ToggleGroup,
@@ -27,31 +27,49 @@ export const OutputToggleGroup = ({
taskCancellable,
permissions,
}) => {
- const handleSTDERRClick = _isSelected => {
- setShowOutputType(prevShowOutputType => ({
- ...prevShowOutputType,
- stderr: _isSelected,
- }));
- };
+ const handleSTDERRClick = useCallback(
+ _isSelected => {
+ setShowOutputType(prevShowOutputType => ({
+ ...prevShowOutputType,
+ stderr: _isSelected,
+ }));
+ },
+ [setShowOutputType]
+ );
- const handleSTDOUTClick = _isSelected => {
- setShowOutputType(prevShowOutputType => ({
- ...prevShowOutputType,
- stdout: _isSelected,
- }));
- };
- const handleDEBUGClick = _isSelected => {
- setShowOutputType(prevShowOutputType => ({
- ...prevShowOutputType,
- debug: _isSelected,
- }));
- };
- const handlePreviewTemplateClick = _isSelected => {
- setShowTemplatePreview(_isSelected);
- };
- const handleCommandClick = _isSelected => {
- setShowCommand(_isSelected);
- };
+ const handleSTDOUTClick = useCallback(
+ _isSelected => {
+ setShowOutputType(prevShowOutputType => ({
+ ...prevShowOutputType,
+ stdout: _isSelected,
+ }));
+ },
+ [setShowOutputType]
+ );
+
+ const handleDEBUGClick = useCallback(
+ _isSelected => {
+ setShowOutputType(prevShowOutputType => ({
+ ...prevShowOutputType,
+ debug: _isSelected,
+ }));
+ },
+ [setShowOutputType]
+ );
+
+ const handlePreviewTemplateClick = useCallback(
+ _isSelected => {
+ setShowTemplatePreview(_isSelected);
+ },
+ [setShowTemplatePreview]
+ );
+
+ const handleCommandClick = useCallback(
+ _isSelected => {
+ setShowCommand(_isSelected);
+ },
+ [setShowCommand]
+ );
const toggleGroupItems = {
stderr: {
diff --git a/webpack/JobInvocationDetail/TemplateInvocationPage.js b/webpack/JobInvocationDetail/TemplateInvocationPage.js
index 741a4a591..2c60b934d 100644
--- a/webpack/JobInvocationDetail/TemplateInvocationPage.js
+++ b/webpack/JobInvocationDetail/TemplateInvocationPage.js
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import PageLayout from 'foremanReact/routes/common/PageLayout/PageLayout';
@@ -26,6 +26,15 @@ const TemplateInvocationPage = ({
],
isPf4: true,
};
+
+ const [showOutputType, setShowOutputType] = useState({
+ stderr: true,
+ stdout: true,
+ debug: true,
+ });
+ const [showTemplatePreview, setShowTemplatePreview] = useState(false);
+ const [showCommand, setShowCommand] = useState(false);
+
return (
);
diff --git a/webpack/JobInvocationDetail/__tests__/TemplateInvocation.test.js b/webpack/JobInvocationDetail/__tests__/TemplateInvocation.test.js
index 1634c8ea3..3e4dee3f0 100644
--- a/webpack/JobInvocationDetail/__tests__/TemplateInvocation.test.js
+++ b/webpack/JobInvocationDetail/__tests__/TemplateInvocation.test.js
@@ -1,7 +1,7 @@
import React from 'react';
import configureMockStore from 'redux-mock-store';
import { Provider } from 'react-redux';
-import { render, screen, act, fireEvent } from '@testing-library/react';
+import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import * as api from 'foremanReact/redux/API';
import * as selectors from '../JobInvocationSelectors';
@@ -10,12 +10,7 @@ import { mockTemplateInvocationResponse } from './fixtures';
jest.spyOn(api, 'get');
jest.mock('../JobInvocationSelectors');
-selectors.selectTemplateInvocationStatus.mockImplementation(() => () =>
- 'RESOLVED'
-);
-selectors.selectTemplateInvocation.mockImplementation(() => () =>
- mockTemplateInvocationResponse
-);
+
const mockStore = configureMockStore([]);
const store = mockStore({
HOSTS_API: {
@@ -24,79 +19,122 @@ const store = mockStore({
},
},
});
+
+Object.assign(navigator, {
+ clipboard: {
+ writeText: jest.fn().mockResolvedValue(undefined),
+ },
+});
+
+const mockProps = {
+ hostID: '1',
+ jobID: '1',
+ isInTableView: false,
+ isExpanded: true,
+ hostName: 'example-host',
+ hostProxy: { name: 'example-proxy', href: '#' },
+ showOutputType: { stderr: true, stdout: true, debug: true },
+ setShowOutputType: jest.fn(),
+ showTemplatePreview: false,
+ setShowTemplatePreview: jest.fn(),
+ showCommand: false,
+ setShowCommand: jest.fn(),
+};
+
describe('TemplateInvocation', () => {
- test('render', async () => {
+ beforeEach(() => {
+ selectors.selectTemplateInvocationStatus.mockImplementation(() => () =>
+ 'RESOLVED'
+ );
+ selectors.selectTemplateInvocation.mockImplementation(() => () =>
+ mockTemplateInvocationResponse
+ );
+ });
+
+ test('render', () => {
render(
-
+
);
expect(screen.getByText('example-host')).toBeInTheDocument();
expect(screen.getByText('example-proxy')).toBeInTheDocument();
-
expect(screen.getByText(/using Smart Proxy/)).toBeInTheDocument();
expect(screen.getByText(/Target:/)).toBeInTheDocument();
-
expect(screen.getByText('This is red text')).toBeInTheDocument();
expect(screen.getByText('This is default text')).toBeInTheDocument();
+ expect(screen.getByLabelText('Copy to clipboard')).toBeInTheDocument();
});
- test('filtering toggles', () => {
- render(
+
+ test('shows "No output" message when all toggles are off', () => {
+ const { rerender } = render(
-
+
);
- act(() => {
- fireEvent.click(screen.getByText('STDOUT'));
- fireEvent.click(screen.getByText('DEBUG'));
- fireEvent.click(screen.getByText('STDERR'));
- });
expect(
- screen.queryAllByText('No output for the selected filters')
- ).toHaveLength(1);
- expect(screen.queryAllByText('Exit status: 1')).toHaveLength(0);
- expect(
- screen.queryAllByText('StandardError: Job execution failed')
- ).toHaveLength(0);
+ screen.queryByText('No output for the selected filters')
+ ).not.toBeInTheDocument();
+
+ const newProps = {
+ ...mockProps,
+ showOutputType: { stderr: false, stdout: false, debug: false },
+ };
+
+ rerender(
+
+
+
+ );
- act(() => {
- fireEvent.click(screen.getByText('STDOUT'));
- });
expect(
- screen.queryAllByText('No output for the selected filters')
- ).toHaveLength(0);
- expect(screen.queryAllByText('Exit status: 1')).toHaveLength(1);
+ screen.getByText('No output for the selected filters')
+ ).toBeInTheDocument();
+ });
+
+ test('correctly filters specific output types', () => {
+ const { rerender } = render(
+
+
+
+ );
+
+ expect(screen.getByText('Exit status: 1')).toBeInTheDocument(); // stdout
expect(
- screen.queryAllByText('StandardError: Job execution failed')
- ).toHaveLength(0);
+ screen.getByText('StandardError: Job execution failed')
+ ).toBeInTheDocument(); // debug
- act(() => {
- fireEvent.click(screen.getByText('DEBUG'));
- });
+ // Turn off stdout
+ rerender(
+
+
+
+ );
+ expect(screen.queryByText('Exit status: 1')).not.toBeInTheDocument();
expect(
- screen.queryAllByText('No output for the selected filters')
- ).toHaveLength(0);
- expect(screen.queryAllByText('Exit status: 1')).toHaveLength(1);
+ screen.getByText('StandardError: Job execution failed')
+ ).toBeInTheDocument();
+
+ // Turn off debug
+ rerender(
+
+
+
+ );
+ expect(screen.queryByText('Exit status: 1')).not.toBeInTheDocument();
expect(
- screen.queryAllByText('StandardError: Job execution failed')
- ).toHaveLength(1);
+ screen.queryByText('StandardError: Job execution failed')
+ ).not.toBeInTheDocument();
});
+
test('displays an error alert when there is an error', async () => {
selectors.selectTemplateInvocationStatus.mockImplementation(() => () =>
'ERROR'
@@ -106,14 +144,7 @@ describe('TemplateInvocation', () => {
}));
render(
-
+
);
@@ -129,19 +160,30 @@ describe('TemplateInvocation', () => {
selectors.selectTemplateInvocationStatus.mockImplementation(() => () =>
'PENDING'
);
- selectors.selectTemplateInvocation.mockImplementation(() => () => ({}));
+ selectors.selectTemplateInvocation.mockImplementation(() => () => null);
render(
-
+
);
- expect(document.querySelectorAll('.pf-v5-c-skeleton')).toHaveLength(1);
+ expect(
+ screen.getByTestId('template-invocation-skeleton')
+ ).toBeInTheDocument();
+ });
+
+ test('copies text to clipboard when clicked', async () => {
+ render(
+
+
+
+ );
+
+ const copyButton = screen.getByLabelText('Copy to clipboard');
+ fireEvent.click(copyButton);
+ expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1);
+ expect(
+ await screen.findByText('Successfully copied to clipboard!')
+ ).toBeInTheDocument();
});
});