diff --git a/webpack/JobInvocationDetail/JobInvocationConstants.js b/webpack/JobInvocationDetail/JobInvocationConstants.js
index 12d6deb11..12453eca2 100644
--- a/webpack/JobInvocationDetail/JobInvocationConstants.js
+++ b/webpack/JobInvocationDetail/JobInvocationConstants.js
@@ -38,6 +38,7 @@ export const STATUS = {
SUCCEEDED: 'succeeded',
FAILED: 'failed',
CANCELLED: 'cancelled',
+ RUNNING: 'running',
};
export const STATUS_UPPERCASE = {
diff --git a/webpack/JobInvocationDetail/JobInvocationToolbarButtons.js b/webpack/JobInvocationDetail/JobInvocationToolbarButtons.js
index 5fadf906b..7869d331c 100644
--- a/webpack/JobInvocationDetail/JobInvocationToolbarButtons.js
+++ b/webpack/JobInvocationDetail/JobInvocationToolbarButtons.js
@@ -58,6 +58,12 @@ const JobInvocationToolbarButtons = ({ jobId, data }) => {
);
}, [jobId, reportTemplateJobId, templateInputId]);
+ const isCreateReportDisabled =
+ !canGenerateReportTemplates ||
+ task?.state === STATUS.RUNNING ||
+ task?.state === STATUS.PENDING ||
+ reportHref === undefined;
+
const onActionFocus = useCallback(() => {
const element = document.getElementById(
`toggle-split-button-action-primary-${jobId}`
@@ -280,11 +286,7 @@ const JobInvocationToolbarButtons = ({ jobId, data }) => {
className="button-create-report"
href={reportHref}
variant="secondary"
- isDisabled={
- !canGenerateReportTemplates ||
- task?.state === STATUS.PENDING ||
- reportHref === undefined
- }
+ isDisabled={isCreateReportDisabled}
>
{__(`Create report`)}
diff --git a/webpack/JobInvocationDetail/__tests__/MainInformation.test.js b/webpack/JobInvocationDetail/__tests__/MainInformation.test.js
index 0cf21da4b..76ce24b39 100644
--- a/webpack/JobInvocationDetail/__tests__/MainInformation.test.js
+++ b/webpack/JobInvocationDetail/__tests__/MainInformation.test.js
@@ -1,15 +1,15 @@
/* eslint-disable max-lines */
import React from 'react';
-import configureMockStore from 'redux-mock-store';
-import { fireEvent, render, screen, act } from '@testing-library/react';
-import '@testing-library/jest-dom/extend-expect';
+import { fireEvent, screen, act } from '@testing-library/react';
+import '@testing-library/jest-dom';
import { createMemoryHistory } from 'history';
import { Router } from 'react-router-dom';
-import { Provider } from 'react-redux';
-import thunk from 'redux-thunk';
+import { rtlHelpers } from 'foremanReact/common/testHelpers';
import { STATUS } from 'foremanReact/constants';
import { foremanUrl } from 'foremanReact/common/helpers';
+import { usePermissions } from 'foremanReact/common/hooks/Permissions/permissionHooks';
import * as api from 'foremanReact/redux/API';
+import { APIActions } from 'foremanReact/redux/API';
import JobInvocationDetailPage from '../index';
import {
jobInvocationData,
@@ -23,6 +23,7 @@ import {
enableRecurringLogic,
cancelRecurringLogic,
} from '../JobInvocationActions';
+import { createForemanContextWrapper } from './foremanTestHelpers';
import {
CANCEL_JOB,
CANCEL_RECURRING_LOGIC,
@@ -32,9 +33,29 @@ import {
GET_TASK,
JOB_INVOCATION_KEY,
} from '../JobInvocationConstants';
-import { createForemanContextWrapper } from './foremanTestHelpers';
+
+const { renderWithStore } = rtlHelpers;
+
+jest.mock('foremanReact/redux/API/APISelectors', () =>
+ jest.requireActual('foremanReact/redux/API/APISelectors')
+);
+
+jest.mock('foremanReact/common/hooks/Permissions/permissionHooks');
+jest.mock('../JobInvocationActions', () => {
+ const actual = jest.requireActual('../JobInvocationActions');
+
+ return {
+ ...actual,
+ getJobInvocation: jest.fn(() => () => undefined),
+ getTask: jest.fn(() => () => undefined),
+ };
+});
jest.spyOn(api, 'get');
+jest.spyOn(APIActions, 'post');
+jest.spyOn(APIActions, 'put');
+
+usePermissions.mockReturnValue(true);
// Mock toLocaleString to always use UTC timezone for consistent test results
const originalToLocaleString = Date.prototype.toLocaleString;
@@ -62,46 +83,59 @@ jest.mock('foremanReact/routes/common/PageLayout/PageLayout', () =>
))
);
-const initialState = {
- JOB_INVOCATION_KEY: {
- response: jobInvocationData,
- status: STATUS.RESOLVED,
- },
- GET_REPORT_TEMPLATES: mockReportTemplatesResponse,
- extendable: {},
-};
+const setupApiMocks = () => {
+ api.get.mockImplementation(({ handleSuccess, key, ...action }) => {
+ if (key === GET_REPORT_TEMPLATES) {
+ if (handleSuccess) {
+ handleSuccess({
+ data: mockReportTemplatesResponse,
+ });
+ }
+ } else if (key === GET_REPORT_TEMPLATE_INPUTS) {
+ if (handleSuccess) {
+ handleSuccess({
+ data: mockReportTemplateInputsResponse,
+ });
+ }
+ }
-const initialStateScheduled = {
- JOB_INVOCATION_KEY: {
- response: jobInvocationDataScheduled,
- status: STATUS.RESOLVED,
- },
- extendable: {},
+ return { type: 'get', key, ...action };
+ });
+ APIActions.post.mockImplementation(payload => ({ type: 'post', ...payload }));
+ APIActions.put.mockImplementation(payload => ({ type: 'put', ...payload }));
};
-api.get.mockImplementation(({ handleSuccess, ...action }) => {
- if (action.key === 'GET_REPORT_TEMPLATES') {
- handleSuccess &&
- handleSuccess({
- data: mockReportTemplatesResponse,
- });
- } else if (action.key === 'GET_REPORT_TEMPLATE_INPUTS') {
- handleSuccess &&
- handleSuccess({
- data: mockReportTemplateInputsResponse,
- });
- }
-
- return { type: 'get', ...action };
-});
+const createJobInvocationDetailState = ({
+ jobInvocation = jobInvocationData,
+ jobInvocationStatus = STATUS.RESOLVED,
+ jobInvocationError = null,
+ includeTaskState = true,
+} = {}) => {
+ const jobInvocationResponse =
+ jobInvocationError || JSON.parse(JSON.stringify(jobInvocation));
-const initialStateRecurring = {
- JOB_INVOCATION_KEY: {
- response: jobInvocationDataRecurring,
- status: STATUS.RESOLVED,
- },
- GET_REPORT_TEMPLATES: mockReportTemplatesResponse,
- extendable: {},
+ return {
+ API: {
+ [JOB_INVOCATION_KEY]: jobInvocationError
+ ? {
+ response: jobInvocationResponse,
+ status: STATUS.ERROR,
+ }
+ : {
+ response: jobInvocationResponse,
+ status: jobInvocationStatus,
+ },
+ ...(includeTaskState && {
+ [GET_TASK]: {
+ response: {
+ available_actions: { cancellable: true },
+ ...(jobInvocationResponse.task || {}),
+ },
+ status: STATUS.RESOLVED,
+ },
+ }),
+ },
+ };
};
jest.mock('../JobInvocationHostTable.js', () => () => (
@@ -110,21 +144,66 @@ jest.mock('../JobInvocationHostTable.js', () => () => (
const reportTemplateJobId = mockReportTemplatesResponse.results[0].id;
-const mockStore = configureMockStore([thunk]);
-const props = { history: { push: jest.fn() } };
+const defaultHistory = { push: jest.fn() };
+
+const renderJobInvocationDetailPage = (
+ initialState,
+ { jobId, history = defaultHistory } = {}
+) => {
+ const id =
+ jobId ?? initialState.API?.[JOB_INVOCATION_KEY]?.response?.id ?? '1';
+
+ return renderWithStore(
+ ,
+ initialState,
+ undefined
+ );
+};
describe('JobInvocationDetailPage', () => {
+ beforeEach(() => {
+ setupApiMocks();
+ usePermissions.mockReturnValue(true);
+
+ const { getJobInvocation, getTask } = jest.requireMock(
+ '../JobInvocationActions'
+ );
+ getJobInvocation.mockImplementation(() => () => undefined);
+ getTask.mockImplementation(() => () => undefined);
+ });
+
+ afterEach(() => {
+ api.get.mockClear();
+ APIActions.post.mockClear();
+ APIActions.put.mockClear();
+ });
+
+ it('shows scheduled date', async () => {
+ const scheduledJobInvocation = JSON.parse(
+ JSON.stringify(jobInvocationDataScheduled)
+ );
+
+ renderJobInvocationDetailPage(
+ createJobInvocationDetailState({
+ jobInvocation: scheduledJobInvocation,
+ includeTaskState: false,
+ }),
+ { jobId: jobInvocationDataScheduled.id }
+ );
+
+ expect(screen.getByText('Scheduled at:')).toBeInTheDocument();
+ expect(screen.getByText('Jan 1, 3000, 11:34 UTC')).toBeInTheDocument();
+ expect(screen.getByText('Not yet')).toBeInTheDocument();
+ });
+
it('renders main information', async () => {
const jobId = jobInvocationData.id;
- const store = mockStore(initialState);
-
- const { container } = render(
-
-
-
+
+ const { container } = renderJobInvocationDetailPage(
+ createJobInvocationDetailState()
);
expect(screen.getByText('Description')).toBeInTheDocument();
@@ -212,115 +291,121 @@ describe('JobInvocationDetailPage', () => {
});
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(
-
-
-
+ renderJobInvocationDetailPage(
+ createJobInvocationDetailState({
+ jobInvocationStatus: STATUS.PENDING,
+ })
);
expect(screen.getByText('Create report')).toBeInTheDocument();
expect(screen.getByText('Rerun all')).toBeInTheDocument();
});
- it('shows scheduled date', async () => {
- const store = mockStore(initialStateScheduled);
- render(
-
-
-
+ it('disables Create report when the job task state is running', async () => {
+ usePermissions.mockImplementation(requiredPermissions =>
+ requiredPermissions.includes('generate_report_templates')
);
- expect(screen.getByText('Scheduled at:')).toBeInTheDocument();
- expect(screen.getByText('Jan 1, 3000, 11:34 UTC')).toBeInTheDocument();
- expect(screen.getByText('Not yet')).toBeInTheDocument();
+ renderJobInvocationDetailPage(
+ createJobInvocationDetailState({
+ jobInvocation: {
+ ...jobInvocationData,
+ task: { ...jobInvocationData.task, state: 'running' },
+ },
+ })
+ );
+
+ const createReportButton = screen.getByRole('link', {
+ name: 'Create report',
+ });
+
+ expect(createReportButton).toHaveAttribute('aria-disabled', 'true');
+ expect(createReportButton).toHaveClass('pf-m-disabled');
});
it('should dispatch global actions', async () => {
- // recurring in the future
+ const actualActions = jest.requireActual('../JobInvocationActions');
+ const { getTask } = jest.requireMock('../JobInvocationActions');
+
+ getTask.mockImplementation(taskId => dispatch =>
+ actualActions.getTask(taskId)(dispatch)
+ );
+
const jobId = jobInvocationDataRecurring.id;
const taskId = jobInvocationDataRecurring.task.id;
const recurrenceId = jobInvocationDataRecurring.recurrence.id;
- const store = mockStore(initialStateRecurring);
- render(
-
-
-
+
+ const { store } = renderJobInvocationDetailPage(
+ createJobInvocationDetailState({
+ jobInvocation: jobInvocationDataRecurring,
+ }),
+ { jobId }
);
- const expectedActions = [
- { key: GET_REPORT_TEMPLATES, url: '/api/report_templates' },
- {
- key: JOB_INVOCATION_KEY,
- url: `/api/job_invocations/${jobId}`,
- },
- {
+ expect(api.get).toHaveBeenCalledWith(
+ expect.objectContaining({
+ key: GET_REPORT_TEMPLATES,
+ url: '/api/report_templates',
+ })
+ );
+ expect(api.get).toHaveBeenCalledWith(
+ expect.objectContaining({
key: GET_TASK,
url: `/foreman_tasks/api/tasks/${taskId}`,
- },
- {
+ })
+ );
+ expect(api.get).toHaveBeenCalledWith(
+ expect.objectContaining({
key: GET_REPORT_TEMPLATE_INPUTS,
url: `/api/templates/${reportTemplateJobId}/template_inputs`,
- },
- {
+ })
+ );
+
+ api.get.mockClear();
+ APIActions.post.mockClear();
+ APIActions.put.mockClear();
+
+ store.dispatch(cancelJob(jobId, false));
+ store.dispatch(cancelJob(jobId, true));
+ store.dispatch(enableRecurringLogic(recurrenceId, true, jobId));
+ store.dispatch(enableRecurringLogic(recurrenceId, false, jobId));
+ store.dispatch(cancelRecurringLogic(recurrenceId, jobId));
+
+ expect(APIActions.post).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
key: CANCEL_JOB,
url: `/job_invocations/${jobId}/cancel`,
- },
- {
+ })
+ );
+ expect(APIActions.post).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
key: CANCEL_JOB,
url: `/job_invocations/${jobId}/cancel?force=true`,
- },
- {
+ })
+ );
+ expect(APIActions.put).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
key: CHANGE_ENABLED_RECURRING_LOGIC,
url: `/foreman_tasks/api/recurring_logics/${recurrenceId}`,
- },
- {
+ })
+ );
+ expect(APIActions.put).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
key: CHANGE_ENABLED_RECURRING_LOGIC,
url: `/foreman_tasks/api/recurring_logics/${recurrenceId}`,
- },
- {
+ })
+ );
+ expect(APIActions.post).toHaveBeenNthCalledWith(
+ 3,
+ expect.objectContaining({
key: CANCEL_RECURRING_LOGIC,
url: `/foreman_tasks/recurring_logics/${recurrenceId}/cancel`,
- },
- ];
-
- store.dispatch(cancelJob(jobId, false));
- store.dispatch(cancelJob(jobId, true));
- store.dispatch(enableRecurringLogic(recurrenceId, true, jobId));
- store.dispatch(enableRecurringLogic(recurrenceId, false, jobId));
- store.dispatch(cancelRecurringLogic(recurrenceId, jobId));
-
- const actualActions = store.getActions();
- expect(actualActions).toHaveLength(expectedActions.length);
-
- expectedActions.forEach((expectedAction, index) => {
- if (actualActions[index].type === 'WITH_INTERVAL') {
- expect(actualActions[index].key.key).toEqual(expectedAction.key);
- expect(actualActions[index].key.url).toEqual(expectedAction.url);
- } else {
- expect(actualActions[index].key).toEqual(expectedAction.key);
- if (expectedAction.url) {
- expect(actualActions[index].url).toEqual(expectedAction.url);
- }
- }
- });
+ })
+ );
});
it('renders empty state when the job invocation fails to load', () => {
@@ -328,28 +413,23 @@ describe('JobInvocationDetailPage', () => {
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(
+ renderWithStore(
-
-
-
+
-
+ ,
+ createJobInvocationDetailState({
+ jobInvocationError: {
+ message: errorMessage,
+ response: { status: 404 },
+ },
+ }),
+ undefined
);
expect(