@@ -63,6 +65,7 @@ jest.mock('foremanReact/routes/common/PageLayout/PageLayout', () =>
const initialState = {
JOB_INVOCATION_KEY: {
response: jobInvocationData,
+ status: STATUS.RESOLVED,
},
GET_REPORT_TEMPLATES: mockReportTemplatesResponse,
extendable: {},
@@ -71,6 +74,7 @@ const initialState = {
const initialStateScheduled = {
JOB_INVOCATION_KEY: {
response: jobInvocationDataScheduled,
+ status: STATUS.RESOLVED,
},
extendable: {},
};
@@ -91,6 +95,15 @@ api.get.mockImplementation(({ handleSuccess, ...action }) => {
return { type: 'get', ...action };
});
+const initialStateRecurring = {
+ JOB_INVOCATION_KEY: {
+ response: jobInvocationDataRecurring,
+ status: STATUS.RESOLVED,
+ },
+ GET_REPORT_TEMPLATES: mockReportTemplatesResponse,
+ extendable: {},
+};
+
jest.mock('../JobInvocationHostTable.js', () => () => (
Mock Table
));
@@ -198,6 +211,29 @@ describe('JobInvocationDetailPage', () => {
).toEqual(`/legacy/job_invocations/${jobId}`);
});
+ it('keeps toolbar buttons mounted while job invocation data is refreshing', async () => {
+ const jobId = jobInvocationData.id;
+ const store = mockStore({
+ ...initialState,
+ JOB_INVOCATION_KEY: {
+ response: jobInvocationData,
+ status: STATUS.PENDING,
+ },
+ });
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByText('Create report')).toBeInTheDocument();
+ expect(screen.getByText('Rerun all')).toBeInTheDocument();
+ });
+
it('shows scheduled date', async () => {
const store = mockStore(initialStateScheduled);
render(
@@ -217,8 +253,9 @@ describe('JobInvocationDetailPage', () => {
it('should dispatch global actions', async () => {
// recurring in the future
const jobId = jobInvocationDataRecurring.id;
+ const taskId = jobInvocationDataRecurring.task.id;
const recurrenceId = jobInvocationDataRecurring.recurrence.id;
- const store = mockStore(jobInvocationDataRecurring);
+ const store = mockStore(initialStateRecurring);
render(
{
key: JOB_INVOCATION_KEY,
url: `/api/job_invocations/${jobId}`,
},
+ {
+ key: GET_TASK,
+ url: `/foreman_tasks/api/tasks/${taskId}`,
+ },
{
key: GET_REPORT_TEMPLATE_INPUTS,
url: `/api/templates/${reportTemplateJobId}/template_inputs`,
@@ -281,4 +322,61 @@ describe('JobInvocationDetailPage', () => {
}
});
});
+
+ it('renders empty state when the job invocation fails to load', () => {
+ const jobId = '99';
+ const errorMessage = 'Record not found';
+ const history = createMemoryHistory();
+ const ForemanContextWrapper = createForemanContextWrapper();
+ const store = mockStore({
+ JOB_INVOCATION_KEY: {
+ status: STATUS.ERROR,
+ response: {
+ message: errorMessage,
+ response: { status: 404 },
+ },
+ },
+ extendable: {},
+ });
+
+ render(
+
+
+
+
+
+
+
+ );
+
+ expect(
+ screen.getByRole('heading', {
+ name: 'Unable to load job invocation',
+ level: 5,
+ })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ (_, element) =>
+ element?.tagName === 'P' &&
+ element.textContent?.includes(
+ `The job invocation with id ${jobId} could not be found. It may have been deleted or may not be available in your current organization or location scope.`
+ )
+ )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(`Server returned: ${errorMessage}`)
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Go to job invocations' })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Create a new job invocation' })
+ ).toBeInTheDocument();
+ expect(screen.queryByText('Description')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('mock-table')).not.toBeInTheDocument();
+ });
});
diff --git a/webpack/JobInvocationDetail/__tests__/TableToolbarActions.test.js b/webpack/JobInvocationDetail/__tests__/TableToolbarActions.test.js
index 969afccb7..dad7ef793 100644
--- a/webpack/JobInvocationDetail/__tests__/TableToolbarActions.test.js
+++ b/webpack/JobInvocationDetail/__tests__/TableToolbarActions.test.js
@@ -2,6 +2,7 @@ import '@testing-library/jest-dom/extend-expect';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import axios from 'axios';
import { foremanUrl } from 'foremanReact/common/helpers';
+import { usePermissions } from 'foremanReact/common/hooks/Permissions/permissionHooks';
import { useAPI } from 'foremanReact/common/hooks/API/APIHooks';
import React from 'react';
import { Provider } from 'react-redux';
@@ -13,6 +14,7 @@ import { PopupAlert } from '../OpenAllInvocationsModal';
jest.mock('axios');
jest.mock('foremanReact/common/hooks/API/APIHooks');
+jest.mock('foremanReact/common/hooks/Permissions/permissionHooks');
jest.mock('../JobInvocationSelectors');
jest.mock('../JobInvocationConstants', () => ({
@@ -26,8 +28,8 @@ jest.mock('../JobInvocationConstants', () => ({
selectors.selectItems.mockImplementation(() => ({
targeting: { search_query: 'name~*' },
}));
-selectors.selectHasPermission.mockImplementation(() => () => true);
selectors.selectTaskCancelable.mockImplementation(() => true);
+usePermissions.mockReturnValue(true);
const mockStore = configureStore([]);
const store = mockStore({});
diff --git a/webpack/JobInvocationDetail/__tests__/foremanTestHelpers.js b/webpack/JobInvocationDetail/__tests__/foremanTestHelpers.js
new file mode 100644
index 000000000..3f610b54e
--- /dev/null
+++ b/webpack/JobInvocationDetail/__tests__/foremanTestHelpers.js
@@ -0,0 +1,30 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { getForemanContext } from 'foremanReact/Root/Context/ForemanContext';
+
+export const createForemanContextWrapper = (
+ permissions = ['view_job_invocations', 'create_job_invocations']
+) => {
+ const foremanContextValue = {
+ context: {
+ metadata: {
+ permissions: new Set(permissions),
+ UISettings: { perPage: 20 },
+ },
+ },
+ setContext: jest.fn(),
+ };
+ const ForemanContext = getForemanContext(foremanContextValue);
+
+ const ForemanContextWrapper = ({ children }) => (
+
+ {children}
+
+ );
+
+ ForemanContextWrapper.propTypes = {
+ children: PropTypes.node.isRequired,
+ };
+
+ return ForemanContextWrapper;
+};
diff --git a/webpack/JobInvocationDetail/index.js b/webpack/JobInvocationDetail/index.js
index 34fc19443..085aee8f9 100644
--- a/webpack/JobInvocationDetail/index.js
+++ b/webpack/JobInvocationDetail/index.js
@@ -5,29 +5,33 @@ import {
PageSectionVariants,
Skeleton,
} from '@patternfly/react-core';
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useMemo, useState } from 'react';
import { translate as __, documentLocale } from 'foremanReact/common/I18n';
import { useDispatch, useSelector } from 'react-redux';
import PageLayout from 'foremanReact/routes/common/PageLayout/PageLayout';
import PropTypes from 'prop-types';
import SkeletonLoader from 'foremanReact/components/common/SkeletonLoader';
import { stopInterval } from 'foremanReact/redux/middlewares/IntervalMiddleware';
-import { useAPI } from 'foremanReact/common/hooks/API/APIHooks';
+import { STATUS as API_STATUS } from 'foremanReact/constants';
+import {
+ selectAPIErrorMessage,
+ selectAPIHttpStatus,
+ selectAPIStatus,
+} from 'foremanReact/redux/API/APISelectors';
import { JobAdditionInfo } from './JobAdditionInfo';
import JobInvocationHostTable from './JobInvocationHostTable';
import JobInvocationOverview from './JobInvocationOverview';
import JobInvocationSystemStatusChart from './JobInvocationSystemStatusChart';
+import JobInvocationEmptyState from './JobInvocationEmptyState';
import JobInvocationToolbarButtons from './JobInvocationToolbarButtons';
import { getJobInvocation, getTask } from './JobInvocationActions';
import './JobInvocationDetail.scss';
import {
- CURRENT_PERMISSIONS,
DATE_OPTIONS,
JOB_INVOCATION_KEY,
STATUS,
STATUS_UPPERCASE,
- currentPermissionsUrl,
} from './JobInvocationConstants';
import { selectItems } from './JobInvocationSelectors';
@@ -51,9 +55,15 @@ const JobInvocationDetailPage = ({
statusLabel === STATUS.SUCCEEDED ||
statusLabel === STATUS.CANCELLED;
const autoRefresh = task?.state === STATUS.PENDING || false;
- useAPI('get', currentPermissionsUrl, {
- key: CURRENT_PERMISSIONS,
- });
+ const jobInvocationApiStatus = useSelector(state =>
+ selectAPIStatus(state, JOB_INVOCATION_KEY)
+ );
+ const jobInvocationErrorMessage = useSelector(state =>
+ selectAPIErrorMessage(state, JOB_INVOCATION_KEY)
+ );
+ const jobInvocationHttpStatus = useSelector(state =>
+ selectAPIHttpStatus(state, JOB_INVOCATION_KEY)
+ );
const [selectedFilter, setSelectedFilter] = useState('');
const handleFilterChange = newFilter => {
@@ -88,6 +98,25 @@ const JobInvocationDetailPage = ({
}
}, [dispatch, taskId]);
+ const apiFailed = jobInvocationApiStatus === API_STATUS.ERROR;
+
+ const backendErrorMessage = useMemo(() => {
+ if (jobInvocationApiStatus === API_STATUS.ERROR) {
+ return jobInvocationErrorMessage || null;
+ }
+ return null;
+ }, [jobInvocationApiStatus, jobInvocationErrorMessage]);
+
+ if (apiFailed) {
+ return (
+
+ );
+ }
+
const pageStatus =
items.id === undefined
? STATUS_UPPERCASE.PENDING
@@ -123,7 +152,11 @@ const JobInvocationDetailPage = ({
}
+ toolbarButtons={
+ items.id !== undefined && (
+
+ )
+ }
searchable={false}
>
diff --git a/webpack/__mocks__/foremanReact/Root/Context/ForemanContext/index.js b/webpack/__mocks__/foremanReact/Root/Context/ForemanContext/index.js
index 849ccf623..360f83a19 100644
--- a/webpack/__mocks__/foremanReact/Root/Context/ForemanContext/index.js
+++ b/webpack/__mocks__/foremanReact/Root/Context/ForemanContext/index.js
@@ -1,6 +1,21 @@
+import React from 'react';
+
+const getForemanContext = contextData => {
+ window.tfm_forced_singletons = window.tfm_forced_singletons || {};
+
+ if (!window.tfm_forced_singletons.Context) {
+ window.tfm_forced_singletons.Context = React.createContext(contextData);
+ }
+
+ return window.tfm_forced_singletons.Context;
+};
+
+export { getForemanContext };
export const useForemanOrganization = () => ({ id: 1 });
export const useForemanLocation = () => ({ id: 2 });
export const useForemanVersion = () => '3.7';
export const useForemanHostsPageUrl = () => '/hosts';
export const useForemanHostDetailsPageUrl = () => '/hosts/';
export const useForemanSettings = () => ({ perPage: 20 });
+export const useForemanPermissions = () =>
+ new Set(['view_job_invocations', 'create_job_invocations']);
diff --git a/webpack/__mocks__/foremanReact/common/hooks/Permissions/permissionHooks.js b/webpack/__mocks__/foremanReact/common/hooks/Permissions/permissionHooks.js
new file mode 100644
index 000000000..2247d77a9
--- /dev/null
+++ b/webpack/__mocks__/foremanReact/common/hooks/Permissions/permissionHooks.js
@@ -0,0 +1 @@
+export const usePermissions = jest.fn(() => true);
diff --git a/webpack/__mocks__/foremanReact/redux/API/APISelectors.js b/webpack/__mocks__/foremanReact/redux/API/APISelectors.js
index 908d3ef1f..3c0bbb1dc 100644
--- a/webpack/__mocks__/foremanReact/redux/API/APISelectors.js
+++ b/webpack/__mocks__/foremanReact/redux/API/APISelectors.js
@@ -19,3 +19,8 @@ export const selectAPIErrorMessage = (state, key) => {
const error = selectAPIError(state, key);
return error && error.message;
};
+
+export const selectAPIHttpStatus = (state, key) => {
+ const error = selectAPIError(state, key);
+ return error?.response?.status;
+};