diff --git a/webpack/JobInvocationDetail/JobInvocationEmptyState.js b/webpack/JobInvocationDetail/JobInvocationEmptyState.js new file mode 100644 index 000000000..9bc10919d --- /dev/null +++ b/webpack/JobInvocationDetail/JobInvocationEmptyState.js @@ -0,0 +1,44 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { PageSection, PageSectionVariants } from '@patternfly/react-core'; +import { translate as __ } from 'foremanReact/common/I18n'; +import { foremanUrl, visit } from 'foremanReact/common/helpers'; +import ResourceLoadFailedEmptyState from 'foremanReact/components/common/EmptyState/ResourceLoadFailedEmptyState'; + +const jobInvocationsIndexPath = '/job_invocations'; + +const JobInvocationEmptyState = ({ jobInvocationId, errorMessage }) => ( + + visit(foremanUrl(jobInvocationsIndexPath)), + ouiaId: 'job-invocation-empty-state-go-to-job-invocations-button', + }} + secondaryActions={[ + { + label: __('Create a new job invocation'), + onClick: () => visit(foremanUrl('/job_invocations/new')), + ouiaId: 'job-invocation-empty-state-create-new-job-invocation-button', + }, + ]} + backButtonLabel={__('Return to the last page')} + ouiaIdPrefix="job-invocation-empty-state" + /> + +); + +JobInvocationEmptyState.propTypes = { + jobInvocationId: PropTypes.string.isRequired, + errorMessage: PropTypes.string, +}; + +JobInvocationEmptyState.defaultProps = { + errorMessage: null, +}; + +export default JobInvocationEmptyState; diff --git a/webpack/JobInvocationDetail/JobInvocationSelectors.js b/webpack/JobInvocationDetail/JobInvocationSelectors.js index eca70541a..a7ce1f084 100644 --- a/webpack/JobInvocationDetail/JobInvocationSelectors.js +++ b/webpack/JobInvocationDetail/JobInvocationSelectors.js @@ -42,3 +42,38 @@ export const selectHasPermission = permissionRequired => state => { ) : false; }; + +export const formatForemanApiError = apiFailureResponse => { + if (!apiFailureResponse) { + return null; + } + const { response, message } = apiFailureResponse; + const err = response?.data?.error; + if (err) { + if (Array.isArray(err.full_messages) && err.full_messages.length) { + return err.full_messages.join(' '); + } + if (typeof err.details === 'string' && err.details.trim()) { + return err.details.trim(); + } + if ( + Array.isArray(err.missing_permissions) && + err.missing_permissions.length + ) { + return err.missing_permissions.join(', '); + } + if (typeof err.message === 'string' && err.message) { + return err.message; + } + if (typeof err === 'string') { + return err; + } + } + if (typeof response?.data?.message === 'string') { + return response.data.message; + } + if (message) { + return message; + } + return null; +}; diff --git a/webpack/JobInvocationDetail/__tests__/MainInformation.test.js b/webpack/JobInvocationDetail/__tests__/MainInformation.test.js index e3e62c0dc..47536f754 100644 --- a/webpack/JobInvocationDetail/__tests__/MainInformation.test.js +++ b/webpack/JobInvocationDetail/__tests__/MainInformation.test.js @@ -35,8 +35,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(locale, options) { + return originalToLocaleString.call(this, locale, { + ...options, + timeZone: 'UTC', + }); }; }); afterAll(() => { diff --git a/webpack/JobInvocationDetail/index.js b/webpack/JobInvocationDetail/index.js index 6b266241b..5439d86e9 100644 --- a/webpack/JobInvocationDetail/index.js +++ b/webpack/JobInvocationDetail/index.js @@ -5,7 +5,7 @@ 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'; @@ -13,11 +13,17 @@ 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 { + selectAPIResponse, + 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'; @@ -29,7 +35,7 @@ import { STATUS_UPPERCASE, currentPermissionsUrl, } from './JobInvocationConstants'; -import { selectItems } from './JobInvocationSelectors'; +import { formatForemanApiError, selectItems } from './JobInvocationSelectors'; const JobInvocationDetailPage = ({ match: { @@ -51,9 +57,20 @@ const JobInvocationDetailPage = ({ statusLabel === STATUS.SUCCEEDED || statusLabel === STATUS.CANCELLED; const autoRefresh = task?.state === STATUS.PENDING || false; - useAPI('get', currentPermissionsUrl, { + const { + status: permissionsApiStatus, + response: permissionsApiResponse, + } = useAPI('get', currentPermissionsUrl, { key: CURRENT_PERMISSIONS, }); + const jobInvocationApiStatus = useSelector(state => + selectAPIStatus(state, JOB_INVOCATION_KEY) + ); + const jobInvocationApiErrorPayload = useSelector(state => + jobInvocationApiStatus === API_STATUS.ERROR + ? selectAPIResponse(state, JOB_INVOCATION_KEY) + : null + ); const [selectedFilter, setSelectedFilter] = useState(''); const handleFilterChange = newFilter => { @@ -88,6 +105,41 @@ const JobInvocationDetailPage = ({ } }, [dispatch, taskId]); + const apiFailed = + permissionsApiStatus === API_STATUS.ERROR || + jobInvocationApiStatus === API_STATUS.ERROR; + + const backendErrorMessage = useMemo(() => { + const parts = []; + if (jobInvocationApiStatus === API_STATUS.ERROR) { + const msg = formatForemanApiError(jobInvocationApiErrorPayload); + if (msg) { + parts.push(msg); + } + } + if (permissionsApiStatus === API_STATUS.ERROR) { + const msg = formatForemanApiError(permissionsApiResponse); + if (msg) { + parts.push(msg); + } + } + return parts.length ? parts.join('\n\n') : null; + }, [ + jobInvocationApiStatus, + permissionsApiStatus, + jobInvocationApiErrorPayload, + permissionsApiResponse, + ]); + + if (apiFailed) { + return ( + + ); + } + const pageStatus = items.id === undefined ? STATUS_UPPERCASE.PENDING diff --git a/webpack/__mocks__/foremanReact/common/helpers.js b/webpack/__mocks__/foremanReact/common/helpers.js index c0e38c44f..2939da4de 100644 --- a/webpack/__mocks__/foremanReact/common/helpers.js +++ b/webpack/__mocks__/foremanReact/common/helpers.js @@ -1 +1,5 @@ export const foremanUrl = path => `foreman${path}`; + +export const visit = jest.fn(url => { + global.window.location.href = url; +});