From 4ee0bf86879f3550447272f65b0545676e6acd8e Mon Sep 17 00:00:00 2001 From: Peter Ondrejka Date: Fri, 24 Apr 2026 04:02:35 -0400 Subject: [PATCH 1/3] Fixes #39253 - Implement "Not Found" error handling for invalid Job Invocation IDs --- .../JobInvocationEmptyState.js | 86 +++++++++++++++++++ .../__tests__/MainInformation.test.js | 7 +- webpack/JobInvocationDetail/index.js | 24 +++++- 3 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 webpack/JobInvocationDetail/JobInvocationEmptyState.js diff --git a/webpack/JobInvocationDetail/JobInvocationEmptyState.js b/webpack/JobInvocationDetail/JobInvocationEmptyState.js new file mode 100644 index 000000000..aced5a7d5 --- /dev/null +++ b/webpack/JobInvocationDetail/JobInvocationEmptyState.js @@ -0,0 +1,86 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { useHistory } from 'react-router-dom'; +import { translate as __, sprintf } from 'foremanReact/common/I18n'; +import { foremanUrl } from 'foremanReact/common/helpers'; +import { + Bullseye, + Button, + EmptyState, + EmptyStateBody, + EmptyStateIcon, + PageSection, + PageSectionVariants, + EmptyStateVariant, + EmptyStateHeader, + EmptyStateActions, + EmptyStateFooter, +} from '@patternfly/react-core'; +import { SearchIcon } from '@patternfly/react-icons'; + +const jobInvocationsIndexPath = '/job_invocations'; + +const JobInvocationEmptyState = ({ jobInvocationId }) => { + const history = useHistory(); + return ( + + + + + {sprintf(__('Job invocation not found'))}} + headingLevel="h5" + /> + + + {sprintf( + __( + 'There is no job invocation with id %s or there are access permissions needed. Please contact your administrator if this issue continues.' + ), + jobInvocationId + )} + + + + + + + + + + + + + + ); +}; + +JobInvocationEmptyState.propTypes = { + jobInvocationId: PropTypes.string.isRequired, +}; + +export default JobInvocationEmptyState; 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..796bb6469 100644 --- a/webpack/JobInvocationDetail/index.js +++ b/webpack/JobInvocationDetail/index.js @@ -13,11 +13,14 @@ 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 { 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'; @@ -51,9 +54,16 @@ const JobInvocationDetailPage = ({ statusLabel === STATUS.SUCCEEDED || statusLabel === STATUS.CANCELLED; const autoRefresh = task?.state === STATUS.PENDING || false; - useAPI('get', currentPermissionsUrl, { - key: CURRENT_PERMISSIONS, - }); + const { status: permissionsApiStatus } = useAPI( + 'get', + currentPermissionsUrl, + { + key: CURRENT_PERMISSIONS, + } + ); + const jobInvocationApiStatus = useSelector(state => + selectAPIStatus(state, JOB_INVOCATION_KEY) + ); const [selectedFilter, setSelectedFilter] = useState(''); const handleFilterChange = newFilter => { @@ -88,6 +98,14 @@ const JobInvocationDetailPage = ({ } }, [dispatch, taskId]); + const apiFailed = + permissionsApiStatus === API_STATUS.ERROR || + jobInvocationApiStatus === API_STATUS.ERROR; + + if (apiFailed) { + return ; + } + const pageStatus = items.id === undefined ? STATUS_UPPERCASE.PENDING From afbb4da6d17f7afa420d460e4a831db9f5339463 Mon Sep 17 00:00:00 2001 From: Peter Ondrejka Date: Tue, 12 May 2026 08:55:46 -0400 Subject: [PATCH 2/3] Fixes #39256 - reviewer feedback added --- .../JobInvocationEmptyState.js | 127 +++++++++--------- .../JobInvocationSelectors.js | 35 +++++ webpack/JobInvocationDetail/index.js | 56 ++++++-- 3 files changed, 145 insertions(+), 73 deletions(-) diff --git a/webpack/JobInvocationDetail/JobInvocationEmptyState.js b/webpack/JobInvocationDetail/JobInvocationEmptyState.js index aced5a7d5..b1d963076 100644 --- a/webpack/JobInvocationDetail/JobInvocationEmptyState.js +++ b/webpack/JobInvocationDetail/JobInvocationEmptyState.js @@ -1,86 +1,89 @@ import PropTypes from 'prop-types'; import React from 'react'; import { useHistory } from 'react-router-dom'; -import { translate as __, sprintf } from 'foremanReact/common/I18n'; -import { foremanUrl } from 'foremanReact/common/helpers'; import { - Bullseye, Button, - EmptyState, - EmptyStateBody, - EmptyStateIcon, + EmptyStateVariant, PageSection, PageSectionVariants, - EmptyStateVariant, - EmptyStateHeader, - EmptyStateActions, - EmptyStateFooter, } from '@patternfly/react-core'; import { SearchIcon } from '@patternfly/react-icons'; +import { translate as __, sprintf } from 'foremanReact/common/I18n'; +import { foremanUrl } from 'foremanReact/common/helpers'; +import EmptyStatePattern from 'foremanReact/components/common/EmptyState/EmptyStatePattern'; const jobInvocationsIndexPath = '/job_invocations'; -const JobInvocationEmptyState = ({ jobInvocationId }) => { +const JobInvocationEmptyState = ({ jobInvocationId, errorMessage = null }) => { const history = useHistory(); + + const descriptionContent = ( + <> +

+ {sprintf( + __( + 'There is no job invocation with id %s or there are access permissions needed. Opening this page requires the view_job_invocations permission. Please contact your administrator if this issue continues.' + ), + jobInvocationId + )} +

+ {errorMessage ? ( +

+ {sprintf(__('The server returned: %s'), errorMessage)} +

+ ) : null} + + ); + return ( - - - - {sprintf(__('Job invocation not found'))}} - headingLevel="h5" - /> - - - {sprintf( - __( - 'There is no job invocation with id %s or there are access permissions needed. Please contact your administrator if this issue continues.' - ), - jobInvocationId - )} - - - - - - - - - - - - + } + header={__('Job invocation not found')} + description={descriptionContent} + action={ + + } + secondaryActions={ + <> + + + + } + /> ); }; 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/index.js b/webpack/JobInvocationDetail/index.js index 796bb6469..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'; @@ -14,7 +14,10 @@ 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 { selectAPIStatus } from 'foremanReact/redux/API/APISelectors'; +import { + selectAPIResponse, + selectAPIStatus, +} from 'foremanReact/redux/API/APISelectors'; import { JobAdditionInfo } from './JobAdditionInfo'; import JobInvocationHostTable from './JobInvocationHostTable'; @@ -32,7 +35,7 @@ import { STATUS_UPPERCASE, currentPermissionsUrl, } from './JobInvocationConstants'; -import { selectItems } from './JobInvocationSelectors'; +import { formatForemanApiError, selectItems } from './JobInvocationSelectors'; const JobInvocationDetailPage = ({ match: { @@ -54,16 +57,20 @@ const JobInvocationDetailPage = ({ statusLabel === STATUS.SUCCEEDED || statusLabel === STATUS.CANCELLED; const autoRefresh = task?.state === STATUS.PENDING || false; - const { status: permissionsApiStatus } = useAPI( - 'get', - currentPermissionsUrl, - { - key: CURRENT_PERMISSIONS, - } - ); + 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 => { @@ -102,8 +109,35 @@ const JobInvocationDetailPage = ({ 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 ; + return ( + + ); } const pageStatus = From 0a17018ad40c36f1d08db88f67350aa1ed6aa9fb Mon Sep 17 00:00:00 2001 From: Peter Ondrejka Date: Fri, 15 May 2026 10:26:43 -0400 Subject: [PATCH 3/3] Fixes #39256 - relying on emptystate page from foreman --- .../JobInvocationEmptyState.js | 101 +++++------------- .../__mocks__/foremanReact/common/helpers.js | 4 + 2 files changed, 32 insertions(+), 73 deletions(-) diff --git a/webpack/JobInvocationDetail/JobInvocationEmptyState.js b/webpack/JobInvocationDetail/JobInvocationEmptyState.js index b1d963076..9bc10919d 100644 --- a/webpack/JobInvocationDetail/JobInvocationEmptyState.js +++ b/webpack/JobInvocationDetail/JobInvocationEmptyState.js @@ -1,81 +1,36 @@ import PropTypes from 'prop-types'; import React from 'react'; -import { useHistory } from 'react-router-dom'; -import { - Button, - EmptyStateVariant, - PageSection, - PageSectionVariants, -} from '@patternfly/react-core'; -import { SearchIcon } from '@patternfly/react-icons'; -import { translate as __, sprintf } from 'foremanReact/common/I18n'; -import { foremanUrl } from 'foremanReact/common/helpers'; -import EmptyStatePattern from 'foremanReact/components/common/EmptyState/EmptyStatePattern'; +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 = null }) => { - const history = useHistory(); - - const descriptionContent = ( - <> -

- {sprintf( - __( - 'There is no job invocation with id %s or there are access permissions needed. Opening this page requires the view_job_invocations permission. Please contact your administrator if this issue continues.' - ), - jobInvocationId - )} -

- {errorMessage ? ( -

- {sprintf(__('The server returned: %s'), errorMessage)} -

- ) : null} - - ); - - return ( - - } - header={__('Job invocation not found')} - description={descriptionContent} - action={ - - } - secondaryActions={ - <> - - - - } - /> - - ); -}; +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, 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; +});