diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 000000000..cbe4e48bf --- /dev/null +++ b/jest.config.js @@ -0,0 +1,22 @@ +const tfmConfig = require('@theforeman/test/src/pluginConfig'); +const { foremanRelativePath, foremanLocation } = require('@theforeman/find-foreman'); + +const foremanReactRelative = 'webpack/assets/javascripts/react_app'; +const foremanFull = foremanLocation(); +const foremanReactFull = foremanRelativePath(foremanReactRelative); + +tfmConfig.moduleNameMapper['^foremanReact(.*)$'] = `${foremanReactFull}/$1`; + +tfmConfig.resolver = null; +tfmConfig.moduleDirectories = [ + `${foremanFull}/node_modules`, + `${foremanFull}/node_modules/@theforeman/vendor-core/node_modules`, + 'node_modules', +]; + +tfmConfig.setupFilesAfterEnv = [ + ...(tfmConfig.setupFilesAfterEnv || []), + '@testing-library/jest-dom', +]; + +module.exports = tfmConfig; diff --git a/package.json b/package.json index b5c621879..6f7cd6383 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,9 @@ "scripts": { "lint": "tfm-lint --plugin -d /webpack", "lint:custom": "eslint ./webpack", - "test": "tfm-test --plugin", - "test:watch": "tfm-test --plugin --watchAll", - "test:current": "tfm-test --plugin --watch", + "test": "tfm-test --plugin --config jest.config.js", + "test:watch": "tfm-test --plugin --config jest.config.js --watchAll", + "test:current": "tfm-test --plugin --config jest.config.js --watch", "publish-coverage": "tfm-publish-coverage" }, "repository": { diff --git a/webpack/JobInvocationDetail/CheckboxesActions.js b/webpack/JobInvocationDetail/CheckboxesActions.js index f8f825f87..9f4a1d42a 100644 --- a/webpack/JobInvocationDetail/CheckboxesActions.js +++ b/webpack/JobInvocationDetail/CheckboxesActions.js @@ -14,6 +14,7 @@ import { } from '@patternfly/react-icons'; import axios from 'axios'; import { foremanUrl } from 'foremanReact/common/helpers'; +import { usePermissions } from 'foremanReact/common/hooks/Permissions/permissionHooks'; import { translate as __, sprintf } from 'foremanReact/common/I18n'; import { addToast } from 'foremanReact/components/ToastsList'; import PropTypes from 'prop-types'; @@ -24,10 +25,7 @@ import { DIRECT_OPEN_HOST_LIMIT, templateInvocationPageUrl, } from './JobInvocationConstants'; -import { - selectHasPermission, - selectTaskCancelable, -} from './JobInvocationSelectors'; +import { selectTaskCancelable } from './JobInvocationSelectors'; import OpenAllInvocationsModal from './OpenAllInvocationsModal'; /* eslint-disable camelcase */ @@ -115,12 +113,8 @@ export const CheckboxesActions = ({ const dispatch = useDispatch(); const [toBeOpened, setToBeOpened] = useState([]); - const hasCreatePermission = useSelector( - selectHasPermission('create_job_invocations') - ); - const hasCancelPermission = useSelector( - selectHasPermission('cancel_job_invocations') - ); + const hasCreatePermission = usePermissions(['create_job_invocations']); + const hasCancelPermission = usePermissions(['cancel_job_invocations']); const jobSearchQuery = `job_invocation.id = ${jobID}`; const filterQuery = filter && filter !== 'all_statuses' diff --git a/webpack/JobInvocationDetail/JobInvocationConstants.js b/webpack/JobInvocationDetail/JobInvocationConstants.js index 8eb70ffe0..12d6deb11 100644 --- a/webpack/JobInvocationDetail/JobInvocationConstants.js +++ b/webpack/JobInvocationDetail/JobInvocationConstants.js @@ -6,7 +6,6 @@ import { useForemanHostDetailsPageUrl } from 'foremanReact/Root/Context/ForemanC import JobStatusIcon from '../react_app/components/RecentJobsCard/JobStatusIcon'; export const JOB_INVOCATION_KEY = 'JOB_INVOCATION_KEY'; -export const CURRENT_PERMISSIONS = 'CURRENT_PERMISSIONS'; export const UPDATE_JOB = 'UPDATE_JOB'; export const CANCEL_JOB = 'CANCEL_JOB'; export const GET_TASK = 'GET_TASK'; @@ -20,9 +19,6 @@ export const GET_TEMPLATE_INVOCATION = 'GET_TEMPLATE_INVOCATION'; export const DIRECT_OPEN_HOST_LIMIT = 3; export const ALL_JOB_HOSTS = 'ALL_JOB_HOSTS'; export const AWAITING_STATUS_FILTER = '(job_invocation.result = N/A)'; -export const currentPermissionsUrl = foremanUrl( - '/api/v2/permissions/current_permissions' -); export const showTemplateInvocationUrl = (hostID, jobID) => `/show_template_invocation_by_host/${hostID}/job_invocation/${jobID}`; @@ -32,6 +28,10 @@ export const templateInvocationPageUrl = (hostID, jobID) => `/job_invocations_detail/${jobID}/host_invocation/${hostID}`; export const jobInvocationDetailsUrl = id => `/job_invocations/${id}`; +export const jobInvocationsIndexPath = '/job_invocations'; +export const jobInvocationsNewPath = '/job_invocations/new'; +export const jobInvocationsIndexUrl = foremanUrl(jobInvocationsIndexPath); +export const jobInvocationsNewUrl = foremanUrl(jobInvocationsNewPath); export const STATUS = { PENDING: 'pending', diff --git a/webpack/JobInvocationDetail/JobInvocationEmptyState.js b/webpack/JobInvocationDetail/JobInvocationEmptyState.js new file mode 100644 index 000000000..8835b41f7 --- /dev/null +++ b/webpack/JobInvocationDetail/JobInvocationEmptyState.js @@ -0,0 +1,52 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { PageSection, PageSectionVariants } from '@patternfly/react-core'; +import { translate as __ } from 'foremanReact/common/I18n'; +import ResourceLoadFailedEmptyState from 'foremanReact/components/common/EmptyState/ResourceLoadFailedEmptyState'; +import { + jobInvocationsIndexUrl, + jobInvocationsNewUrl, +} from './JobInvocationConstants'; + +const JobInvocationEmptyState = ({ + jobInvocationId, + httpStatus, + errorMessage, +}) => ( + + + +); + +JobInvocationEmptyState.propTypes = { + jobInvocationId: PropTypes.string.isRequired, + httpStatus: PropTypes.number, + errorMessage: PropTypes.string, +}; + +JobInvocationEmptyState.defaultProps = { + httpStatus: null, + errorMessage: null, +}; + +export default JobInvocationEmptyState; diff --git a/webpack/JobInvocationDetail/JobInvocationSelectors.js b/webpack/JobInvocationDetail/JobInvocationSelectors.js index eca70541a..e43bfd208 100644 --- a/webpack/JobInvocationDetail/JobInvocationSelectors.js +++ b/webpack/JobInvocationDetail/JobInvocationSelectors.js @@ -3,13 +3,11 @@ import { selectAPIResponse, selectAPIStatus, } from 'foremanReact/redux/API/APISelectors'; -import { STATUS as APIStatus } from 'foremanReact/constants'; import { JOB_INVOCATION_KEY, GET_TASK, GET_TEMPLATE_INVOCATION, LIST_TEMPLATE_INVOCATIONS, - CURRENT_PERMISSIONS, } from './JobInvocationConstants'; export const selectItems = state => @@ -29,16 +27,3 @@ export const selectTemplateInvocationStatus = hostID => state => export const selectTemplateInvocationList = state => selectAPIResponse(state, LIST_TEMPLATE_INVOCATIONS) ?.template_invocations_task_by_hosts; - -export const selectCurrentPermisions = state => - selectAPIResponse(state, CURRENT_PERMISSIONS); - -export const selectHasPermission = permissionRequired => state => { - const status = selectAPIStatus(state, CURRENT_PERMISSIONS); - const selectCurrentPermissions = selectCurrentPermisions(state)?.results; - return status === APIStatus.RESOLVED - ? selectCurrentPermissions?.some( - permission => permission.name === permissionRequired - ) - : false; -}; diff --git a/webpack/JobInvocationDetail/JobInvocationToolbarButtons.js b/webpack/JobInvocationDetail/JobInvocationToolbarButtons.js index 8c2d621fb..5fadf906b 100644 --- a/webpack/JobInvocationDetail/JobInvocationToolbarButtons.js +++ b/webpack/JobInvocationDetail/JobInvocationToolbarButtons.js @@ -1,5 +1,6 @@ +/* eslint-disable max-lines */ import PropTypes from 'prop-types'; -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { Button, Split, SplitItem } from '@patternfly/react-core'; import { UndoIcon } from '@patternfly/react-icons'; @@ -12,6 +13,7 @@ import { } from '@patternfly/react-core/deprecated'; import { translate as __ } from 'foremanReact/common/I18n'; import { foremanUrl } from 'foremanReact/common/helpers'; +import { usePermissions } from 'foremanReact/common/hooks/Permissions/permissionHooks'; import { get } from 'foremanReact/redux/API'; import { cancelJob, @@ -23,10 +25,7 @@ import { GET_REPORT_TEMPLATES, GET_REPORT_TEMPLATE_INPUTS, } from './JobInvocationConstants'; -import { - selectTaskCancelable, - selectHasPermission, -} from './JobInvocationSelectors'; +import { selectTaskCancelable } from './JobInvocationSelectors'; const JobInvocationToolbarButtons = ({ jobId, data }) => { const { succeeded, failed, task, recurrence, permissions } = data; @@ -38,165 +37,238 @@ const JobInvocationToolbarButtons = ({ jobId, data }) => { ? permissions.edit_recurring_logics : false; const isTaskCancelable = useSelector(selectTaskCancelable); - const useHasPermission = permissionRequired => - useSelector(selectHasPermission(permissionRequired)); + const canCreateJobInvocations = usePermissions(['create_job_invocations']); + const canCancelJobInvocations = usePermissions(['cancel_job_invocations']); + const canGenerateReportTemplates = usePermissions([ + 'generate_report_templates', + ]); const [isActionOpen, setIsActionOpen] = useState(false); const [reportTemplateJobId, setReportTemplateJobId] = useState(undefined); const [templateInputId, setTemplateInputId] = useState(undefined); - const queryParams = new URLSearchParams({ - [`report_template_report[input_values][${templateInputId}][value]`]: jobId, - }); const dispatch = useDispatch(); + const reportHref = useMemo(() => { + if (reportTemplateJobId === undefined || templateInputId === undefined) { + return undefined; + } + const queryParams = new URLSearchParams({ + [`report_template_report[input_values][${templateInputId}][value]`]: jobId, + }); + return foremanUrl( + `/templates/report_templates/${reportTemplateJobId}/generate?${queryParams.toString()}` + ); + }, [jobId, reportTemplateJobId, templateInputId]); - const onActionFocus = () => { + const onActionFocus = useCallback(() => { const element = document.getElementById( `toggle-split-button-action-primary-${jobId}` ); - element.focus(); - }; - const onActionSelect = () => { + if (element) { + element.focus(); + } + }, [jobId]); + const onActionSelect = useCallback(() => { setIsActionOpen(false); onActionFocus(); - }; + }, [onActionFocus]); + const onActionToggle = useCallback((_event, val) => setIsActionOpen(val), []); useEffect(() => { + let isMounted = true; dispatch( get({ key: GET_REPORT_TEMPLATES, url: '/api/report_templates', handleSuccess: ({ data: { results } }) => { - setReportTemplateJobId( - results.find(result => result.name === 'Job - Invocation Report') - ?.id - ); + if (isMounted) { + setReportTemplateJobId( + results.find(result => result.name === 'Job - Invocation Report') + ?.id + ); + } }, handleError: () => { - setReportTemplateJobId(undefined); + if (isMounted) { + setReportTemplateJobId(undefined); + } }, }) ); + return () => { + isMounted = false; + }; }, [dispatch]); useEffect(() => { + let isMounted = true; if (reportTemplateJobId !== undefined) { dispatch( get({ key: GET_REPORT_TEMPLATE_INPUTS, url: `/api/templates/${reportTemplateJobId}/template_inputs`, handleSuccess: ({ data: { results } }) => { - setTemplateInputId( - results.find(result => result.name === 'job_id')?.id - ); + if (isMounted) { + setTemplateInputId( + results.find(result => result.name === 'job_id')?.id + ); + } }, handleError: () => { - setTemplateInputId(undefined); + if (isMounted) { + setTemplateInputId(undefined); + } }, }) ); } + return () => { + isMounted = false; + }; }, [dispatch, reportTemplateJobId]); - const recurrenceDropdownItems = recurrence - ? [ - , - - dispatch( - enableRecurringLogic(recurrence?.id, recurringEnabled, jobId) - ) - } - key="change-enabled-recurring" - component="button" - isDisabled={ - recurrence?.id === undefined || - recurrence?.state === 'cancelled' || - !canEditRecurringLogic - } - > - {recurringEnabled ? __('Disable recurring') : __('Enable recurring')} - , - dispatch(cancelRecurringLogic(recurrence?.id, jobId))} - key="cancel-recurring" - component="button" - isDisabled={ - recurrence?.id === undefined || - recurrence?.state === 'cancelled' || - !canEditRecurringLogic - } - > - {__('Cancel recurring')} - , - ] - : []; + const recurrenceDropdownItems = useMemo( + () => + recurrence + ? [ + , + + dispatch( + enableRecurringLogic(recurrence?.id, recurringEnabled, jobId) + ) + } + key="change-enabled-recurring" + component="button" + isDisabled={ + recurrence?.id === undefined || + recurrence?.state === 'cancelled' || + !canEditRecurringLogic + } + > + {recurringEnabled + ? __('Disable recurring') + : __('Enable recurring')} + , + + dispatch(cancelRecurringLogic(recurrence?.id, jobId)) + } + key="cancel-recurring" + component="button" + isDisabled={ + recurrence?.id === undefined || + recurrence?.state === 'cancelled' || + !canEditRecurringLogic + } + > + {__('Cancel recurring')} + , + ] + : [], + [recurrence, recurringEnabled, canEditRecurringLogic, dispatch, jobId] + ); + + const dropdownItems = useMemo( + () => [ + 0)} + description="Rerun job on successful hosts" + > + {__('Rerun successful')} + , + 0)} + description="Rerun job on failed hosts" + > + {__('Rerun failed')} + , + + {__('View task')} + , + , + dispatch(cancelJob(jobId, false))} + key="cancel" + component="button" + isDisabled={!canCancelJobInvocations || !isTaskCancelable} + description="Cancel job gracefully" + > + {__('Cancel')} + , + dispatch(cancelJob(jobId, true))} + key="abort" + component="button" + isDisabled={!canCancelJobInvocations || !isTaskCancelable} + description="Cancel job immediately" + > + {__('Abort')} + , + ...recurrenceDropdownItems, + , + } + href={`/legacy/job_invocations/${jobId}`} + key="legacy-ui" + > + {__('Legacy UI')} + , + ], + [ + canCancelJobInvocations, + canCreateJobInvocations, + canViewForemanTasks, + dispatch, + failed, + isTaskCancelable, + jobId, + recurrenceDropdownItems, + succeeded, + task, + ] + ); - const dropdownItems = [ - 0) - } - description="Rerun job on successful hosts" - > - {__('Rerun successful')} - , - 0)} - description="Rerun job on failed hosts" - > - {__('Rerun failed')} - , - - {__('View task')} - , - , - dispatch(cancelJob(jobId, false))} - key="cancel" - component="button" - isDisabled={ - !useHasPermission('cancel_job_invocations') || !isTaskCancelable - } - description="Cancel job gracefully" - > - {__('Cancel')} - , - dispatch(cancelJob(jobId, true))} - key="abort" - component="button" - isDisabled={ - !useHasPermission('cancel_job_invocations') || !isTaskCancelable - } - description="Cancel job immediately" - > - {__('Abort')} - , - ...recurrenceDropdownItems, - , - } - href={`/legacy/job_invocations/${jobId}`} - key="legacy-ui" - > - {__('Legacy UI')} - , - ]; + const dropdownToggle = useMemo( + () => ( + + {__(`Rerun all`)} + , + ]} + splitButtonVariant="action" + onToggle={onActionToggle} + /> + ), + [canCreateJobInvocations, jobId, onActionToggle] + ); return ( <> @@ -206,14 +278,12 @@ const JobInvocationToolbarButtons = ({ jobId, data }) => { component="a" ouiaId="button-create-report" className="button-create-report" - href={foremanUrl( - `/templates/report_templates/${reportTemplateJobId}/generate?${queryParams.toString()}` - )} + href={reportHref} variant="secondary" isDisabled={ - !useHasPermission('generate_report_templates') || + !canGenerateReportTemplates || task?.state === STATUS.PENDING || - templateInputId === undefined + reportHref === undefined } > {__(`Create report`)} @@ -224,26 +294,7 @@ const JobInvocationToolbarButtons = ({ jobId, data }) => { ouiaId="job-invocation-global-actions-dropdown" onSelect={onActionSelect} position={DropdownPosition.right} - toggle={ - - {__(`Rerun all`)} - , - ]} - splitButtonVariant="action" - onToggle={(_event, val) => setIsActionOpen(val)} - /> - } + toggle={dropdownToggle} isOpen={isActionOpen} dropdownItems={dropdownItems} /> diff --git a/webpack/JobInvocationDetail/__tests__/JobInvocationDetailEmptyState.test.js b/webpack/JobInvocationDetail/__tests__/JobInvocationDetailEmptyState.test.js new file mode 100644 index 000000000..85be12f5d --- /dev/null +++ b/webpack/JobInvocationDetail/__tests__/JobInvocationDetailEmptyState.test.js @@ -0,0 +1,53 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import { createMemoryHistory } from 'history'; +import { Router } from 'react-router-dom'; +import JobInvocationEmptyState from '../JobInvocationEmptyState'; +import { createForemanContextWrapper } from './foremanTestHelpers'; + +describe('JobInvocationEmptyState', () => { + it('renders the failed load empty state for a job invocation', () => { + const jobInvocationId = '99'; + const errorMessage = 'Record not found'; + const history = createMemoryHistory(); + const ForemanContextWrapper = createForemanContextWrapper(); + + 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 ${jobInvocationId} 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(); + }); +}); diff --git a/webpack/JobInvocationDetail/__tests__/MainInformation.test.js b/webpack/JobInvocationDetail/__tests__/MainInformation.test.js index d30172220..0cf21da4b 100644 --- a/webpack/JobInvocationDetail/__tests__/MainInformation.test.js +++ b/webpack/JobInvocationDetail/__tests__/MainInformation.test.js @@ -1,9 +1,13 @@ +/* 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 { createMemoryHistory } from 'history'; +import { Router } from 'react-router-dom'; import { Provider } from 'react-redux'; import thunk from 'redux-thunk'; +import { STATUS } from 'foremanReact/constants'; import { foremanUrl } from 'foremanReact/common/helpers'; import * as api from 'foremanReact/redux/API'; import JobInvocationDetailPage from '../index'; @@ -11,7 +15,6 @@ import { jobInvocationData, jobInvocationDataScheduled, jobInvocationDataRecurring, - mockPermissionsData, mockReportTemplatesResponse, mockReportTemplateInputsResponse, } from './fixtures'; @@ -26,8 +29,10 @@ import { CHANGE_ENABLED_RECURRING_LOGIC, GET_REPORT_TEMPLATES, GET_REPORT_TEMPLATE_INPUTS, + GET_TASK, JOB_INVOCATION_KEY, } from '../JobInvocationConstants'; +import { createForemanContextWrapper } from './foremanTestHelpers'; jest.spyOn(api, 'get'); @@ -35,8 +40,11 @@ jest.spyOn(api, 'get'); const originalToLocaleString = Date.prototype.toLocaleString; beforeAll(() => { // eslint-disable-next-line no-extend-native - Date.prototype.toLocaleString = function (locale, options) { - return originalToLocaleString.call(this, locale, { ...options, timeZone: 'UTC' }); + Date.prototype.toLocaleString = function toLocaleStringUTC(locale, options) { + return originalToLocaleString.call(this, locale, { + ...options, + timeZone: 'UTC', + }); }; }); afterAll(() => { @@ -44,12 +52,6 @@ afterAll(() => { Date.prototype.toLocaleString = originalToLocaleString; }); -jest.mock('foremanReact/common/hooks/API/APIHooks', () => ({ - useAPI: jest.fn(() => ({ - response: mockPermissionsData, - })), -})); - jest.mock('foremanReact/routes/common/PageLayout/PageLayout', () => jest.fn(props => (
@@ -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; +};