diff --git a/webpack/assets/javascripts/react_app/components/HostDetails/EmptyState/index.js b/webpack/assets/javascripts/react_app/components/HostDetails/EmptyState/index.js index 6fb945fb51..b9877db8b2 100644 --- a/webpack/assets/javascripts/react_app/components/HostDetails/EmptyState/index.js +++ b/webpack/assets/javascripts/react_app/components/HostDetails/EmptyState/index.js @@ -1,39 +1,55 @@ import PropTypes from 'prop-types'; import React from 'react'; -import { Redirect } from 'react-router-dom'; -import { translate as __, sprintf } from '../../../common/I18n'; -import { foremanUrl } from '../../../common/helpers'; +import { translate as __ } from '../../../common/I18n'; +import { visit, foremanUrl } from '../../../common/helpers'; +import { ResourceLoadFailedEmptyState } from '../../common/EmptyState'; +import { + useForemanHostsPageUrl, + useForemanSettings, +} from '../../../Root/Context/ForemanContext'; -const RedirectToEmptyHostPage = ({ hostname }) => ( - { + const { displayNewHostsPage } = useForemanSettings(); + const hostsIndexUrl = useForemanHostsPageUrl(); + + const hostsIndexAction = displayNewHostsPage + ? { url: hostsIndexUrl } + : { onClick: () => visit(foremanUrl(hostsIndexUrl)) }; + + return ( + visit(foremanUrl('/hosts/new')), + ouiaId: 'host-details-empty-state-create-host', }, - secondayActions: [ - { - title: __('Create a host'), - url: foremanUrl('/hosts/new'), - }, - ], - }, - }} - /> -); + ]} + ouiaIdPrefix="host-details-empty-state" + /> + ); +}; -RedirectToEmptyHostPage.propTypes = { +HostDetailsEmptyState.propTypes = { hostname: PropTypes.string.isRequired, + httpStatus: PropTypes.number, + errorMessage: PropTypes.string, +}; + +HostDetailsEmptyState.defaultProps = { + httpStatus: null, + errorMessage: null, }; -export default RedirectToEmptyHostPage; +export default HostDetailsEmptyState; diff --git a/webpack/assets/javascripts/react_app/components/HostDetails/index.js b/webpack/assets/javascripts/react_app/components/HostDetails/index.js index 328029f282..37f4ae6d32 100644 --- a/webpack/assets/javascripts/react_app/components/HostDetails/index.js +++ b/webpack/assets/javascripts/react_app/components/HostDetails/index.js @@ -29,7 +29,11 @@ import { import { selectIsCollapsed } from '../Layout/LayoutSelectors'; import ActionsBar from './ActionsBar'; import { registerCoreTabs } from './Tabs'; -import { HOST_DETAILS_API_OPTIONS, TABS_SLOT_ID } from './consts'; +import { + HOST_DETAILS_API_OPTIONS, + HOST_DETAILS_KEY, + TABS_SLOT_ID, +} from './consts'; import { translate as __, sprintf } from '../../common/I18n'; import HostGlobalStatus from './Status/GlobalStatus'; @@ -37,8 +41,12 @@ import SkeletonLoader from '../common/SkeletonLoader'; import { STATUS } from '../../constants'; import './HostDetails.scss'; import { useAPI } from '../../common/hooks/API/APIHooks'; +import { + selectAPIErrorMessage, + selectAPIHttpStatus, +} from '../../redux/API/APISelectors'; import TabRouter from './Tabs/TabRouter'; -import RedirectToEmptyHostPage from './EmptyState'; +import HostDetailsEmptyState from './EmptyState'; import BreadcrumbBar from '../BreadcrumbBar'; import { CardExpansionContextWrapper } from './CardExpansionContext'; import Head from '../Head'; @@ -60,6 +68,12 @@ const HostDetails = ({ `/api/hosts/${id}?show_hidden_parameters=true`, HOST_DETAILS_API_OPTIONS ); + const httpStatus = useSelector(state => + selectAPIHttpStatus(state, HOST_DETAILS_KEY) + ); + const errorMessage = useSelector(state => + selectAPIErrorMessage(state, HOST_DETAILS_KEY) + ); const isNavCollapsed = useSelector(selectIsCollapsed); const hostsIndexUrl = useForemanHostsPageUrl(); const tabs = useSelector( @@ -91,7 +105,14 @@ const HostDetails = ({ tab => !slotMetadata?.[tab]?.hideTab?.({ hostDetails: response }) ) ?? []; - if (status === STATUS.ERROR) return ; + if (status === STATUS.ERROR) + return ( + + ); return ( <> diff --git a/webpack/assets/javascripts/react_app/components/common/EmptyState/DefaultEmptyState.js b/webpack/assets/javascripts/react_app/components/common/EmptyState/DefaultEmptyState.js index 77080b9151..7409676ede 100644 --- a/webpack/assets/javascripts/react_app/components/common/EmptyState/DefaultEmptyState.js +++ b/webpack/assets/javascripts/react_app/components/common/EmptyState/DefaultEmptyState.js @@ -14,6 +14,7 @@ const DefaultEmptyState = props => { documentation, action, secondaryActions, + variant, } = props; const dispatch = useDispatch(); @@ -55,6 +56,7 @@ const DefaultEmptyState = props => { documentation={documentation} action={ActionButton} secondaryActions={SecondaryButton} + variant={variant} /> ); }; diff --git a/webpack/assets/javascripts/react_app/components/common/EmptyState/EmptyStatePropTypes.js b/webpack/assets/javascripts/react_app/components/common/EmptyState/EmptyStatePropTypes.js index 4801ffdc36..3f014b9f10 100644 --- a/webpack/assets/javascripts/react_app/components/common/EmptyState/EmptyStatePropTypes.js +++ b/webpack/assets/javascripts/react_app/components/common/EmptyState/EmptyStatePropTypes.js @@ -28,3 +28,31 @@ export const defaultEmptyStatePropTypes = { action: PropTypes.shape(actionButtonPropTypes), secondaryActions: PropTypes.arrayOf(PropTypes.shape(actionButtonPropTypes)), }; + +export const footerActionPropTypes = { + label: PropTypes.node.isRequired, + onClick: PropTypes.func, + url: PropTypes.string, + ouiaId: PropTypes.string, +}; + +export const resourceLoadFailedEmptyStatePropTypes = { + resourceLabel: PropTypes.string.isRequired, + resourceId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + header: PropTypes.string, + description: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + errorMessage: PropTypes.string, + /** HTTP status from the failed load request (e.g. axios error.response.status). */ + httpStatus: PropTypes.number, + /** Permissions required to view the resource; used with usePermissions and httpStatus. */ + viewPermissions: PropTypes.arrayOf(PropTypes.string), + /** Optional page-level permissions shown as documentation when load did not fail for access. */ + requiredPermissions: PropTypes.arrayOf(PropTypes.string), + primaryAction: PropTypes.shape(footerActionPropTypes).isRequired, + secondaryActions: PropTypes.arrayOf(PropTypes.shape(footerActionPropTypes)), + showBackButton: PropTypes.bool, + backButtonLabel: PropTypes.string, + icon: PropTypes.node, + variant: PropTypes.oneOf(['xs', 'sm', 'lg', 'xl', 'full']), + ouiaIdPrefix: PropTypes.string, +}; diff --git a/webpack/assets/javascripts/react_app/components/common/EmptyState/ResourceLoadFailedEmptyState.js b/webpack/assets/javascripts/react_app/components/common/EmptyState/ResourceLoadFailedEmptyState.js new file mode 100644 index 0000000000..d6598dda2a --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/EmptyState/ResourceLoadFailedEmptyState.js @@ -0,0 +1,234 @@ +import React from 'react'; +import { useHistory } from 'react-router-dom'; +import { Button, EmptyStateVariant } from '@patternfly/react-core'; +import { LockIcon, SearchIcon } from '@patternfly/react-icons'; +import EmptyStatePattern from './EmptyStatePattern'; +import { resourceLoadFailedEmptyStatePropTypes } from './EmptyStatePropTypes'; +import { translate as __, sprintf } from '../../../common/I18n'; +import { usePermissions } from '../../../common/hooks/Permissions/permissionHooks'; +import { useForemanPermissions } from '../../../Root/Context/ForemanContext'; + +const FAILURE_REASON = { + FORBIDDEN: 'forbidden', + NOT_FOUND: 'not_found', + UNKNOWN: 'unknown', +}; + +const resolveFailureReason = ({ + httpStatus, + viewPermissions, + hasViewPermission, +}) => { + if (httpStatus === 403) return FAILURE_REASON.FORBIDDEN; + if (viewPermissions?.length > 0 && !hasViewPermission) { + return FAILURE_REASON.FORBIDDEN; + } + if (httpStatus === 404) return FAILURE_REASON.NOT_FOUND; + if (viewPermissions?.length > 0 && hasViewPermission) { + return FAILURE_REASON.NOT_FOUND; + } + return FAILURE_REASON.UNKNOWN; +}; + +const getDefaultDescription = (failureReason, resourceLabel, resourceId) => { + const hasResourceId = resourceId !== null && resourceId !== undefined; + + switch (failureReason) { + case FAILURE_REASON.FORBIDDEN: + return hasResourceId + ? sprintf( + __('You do not have permission to view the %s with id %s.'), + resourceLabel, + resourceId + ) + : sprintf( + __('You do not have permission to view this %s.'), + resourceLabel + ); + case FAILURE_REASON.NOT_FOUND: + return hasResourceId + ? sprintf( + __( + 'The %s with id %s could not be found. It may have been deleted or may not be available in your current organization or location scope.' + ), + resourceLabel, + resourceId + ) + : sprintf( + __( + 'The %s could not be found. It may have been deleted or may not be available in your current organization or location scope.' + ), + resourceLabel + ); + default: + return hasResourceId + ? sprintf( + __('The %s with id %s could not be loaded.'), + resourceLabel, + resourceId + ) + : sprintf(__('The %s could not be loaded.'), resourceLabel); + } +}; + +const invokeFooterAction = (action, history) => { + if (action.onClick) { + action.onClick(); + } else if (action.url) { + history.push(action.url); + } +}; + +const ResourceLoadFailedEmptyState = ({ + resourceLabel, + resourceId, + header, + description, + errorMessage, + httpStatus, + viewPermissions, + requiredPermissions, + primaryAction, + secondaryActions, + showBackButton, + backButtonLabel, + icon, + variant, + ouiaIdPrefix, + ...props +}) => { + const history = useHistory(); + const userPermissions = useForemanPermissions(); + const hasViewPermission = usePermissions(viewPermissions || []); + const failureReason = resolveFailureReason({ + httpStatus, + viewPermissions, + hasViewPermission, + }); + const isForbidden = failureReason === FAILURE_REASON.FORBIDDEN; + + const missingViewPermissions = + viewPermissions?.filter(permission => !userPermissions.has(permission)) || + []; + + let permissionsToList = requiredPermissions; + if (isForbidden && missingViewPermissions.length > 0) { + permissionsToList = missingViewPermissions; + } + + const resolvedHeader = + header || + (isForbidden + ? __('Permission denied') + : sprintf(__('Unable to load %s'), resourceLabel)); + + const resolvedIcon = icon ?? (isForbidden ? : ); + + const defaultDescription = getDefaultDescription( + failureReason, + resourceLabel, + resourceId + ); + + const descriptionContent = ( + +

+ {description ?? defaultDescription} + {permissionsToList?.length > 0 ? ( + + {' '} + {__('Accessing this page requires the following permissions:')}{' '} + {permissionsToList.map((permission, index) => ( + + {index > 0 ? ', ' : null} + {permission} + + ))} + + ) : null} +

+ {errorMessage ? ( +

+ {__('Server returned: ')} {errorMessage} +

+ ) : null} +
+ ); + + const renderFooterButton = ( + { label, onClick, url, ouiaId }, + buttonVariant, + defaultOuiaId + ) => ( + + ); + + const footerSecondaryActions = ( + + {secondaryActions.map((action, index) => ( + + {renderFooterButton( + action, + 'link', + `${ouiaIdPrefix}-secondary-action-${index}` + )} + + ))} + {showBackButton + ? renderFooterButton( + { + label: backButtonLabel, + onClick: () => history.goBack(), + ouiaId: `${ouiaIdPrefix}-go-back`, + }, + 'link', + `${ouiaIdPrefix}-go-back` + ) + : null} + + ); + + return ( + + ); +}; + +ResourceLoadFailedEmptyState.propTypes = resourceLoadFailedEmptyStatePropTypes; + +ResourceLoadFailedEmptyState.defaultProps = { + resourceId: null, + header: null, + description: null, + errorMessage: null, + httpStatus: null, + viewPermissions: null, + requiredPermissions: null, + secondaryActions: [], + showBackButton: true, + backButtonLabel: __('Return to the previous page'), + icon: null, + variant: EmptyStateVariant.lg, + ouiaIdPrefix: 'resource-load-failed-empty-state', +}; + +export default ResourceLoadFailedEmptyState; diff --git a/webpack/assets/javascripts/react_app/components/common/EmptyState/ResourceLoadFailedEmptyState.test.js b/webpack/assets/javascripts/react_app/components/common/EmptyState/ResourceLoadFailedEmptyState.test.js new file mode 100644 index 0000000000..4d4d19e8ab --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/EmptyState/ResourceLoadFailedEmptyState.test.js @@ -0,0 +1,311 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { createMemoryHistory } from 'history'; +import { Router } from 'react-router-dom'; +import { SearchIcon } from '@patternfly/react-icons'; +import { ResourceLoadFailedEmptyState } from './index'; +import { rtlHelpers } from '../../../common/rtlTestHelpers'; +import { usePermissions } from '../../../common/hooks/Permissions/permissionHooks'; + +jest.mock('../../../common/hooks/Permissions/permissionHooks'); + +const renderComponent = (props, history = createMemoryHistory()) => { + const defaultProps = { + resourceLabel: 'hardware model', + resourceId: 42, + primaryAction: { + label: 'Back to list', + url: '/models', + }, + }; + + return { + history, + ...rtlHelpers.renderWithStore( + + + + ), + }; +}; + +const permissionsIntro = + /Accessing this page requires the following permissions/; + +describe('ResourceLoadFailedEmptyState', () => { + beforeEach(() => { + usePermissions.mockReturnValue(true); + }); + + describe('failure messaging', () => { + it('renders unknown failure messaging when view permissions are not provided', () => { + renderComponent(); + + expect( + screen.getByRole('heading', { + name: 'Unable to load hardware model', + level: 5, + }) + ).toBeInTheDocument(); + expect( + screen.getByText('The hardware model with id 42 could not be loaded.') + ).toBeInTheDocument(); + expect(screen.queryByText(permissionsIntro)).not.toBeInTheDocument(); + }); + + it('renders unknown failure messaging without a resource id', () => { + renderComponent({ resourceId: null }); + + expect( + screen.getByText('The hardware model could not be loaded.') + ).toBeInTheDocument(); + }); + + it('renders not-found messaging when the user can view the resource', () => { + renderComponent({ viewPermissions: ['view_models'] }); + + expect( + screen.getByText( + 'The hardware model with id 42 could not be found. It may have been deleted or may not be available in your current organization or location scope.' + ) + ).toBeInTheDocument(); + }); + + it('renders not-found messaging without a resource id', () => { + renderComponent({ viewPermissions: ['view_models'], resourceId: null }); + + expect( + screen.getByText( + 'The hardware model could not be found. It may have been deleted or may not be available in your current organization or location scope.' + ) + ).toBeInTheDocument(); + }); + + it('renders permission-denied messaging when view permissions are missing', () => { + usePermissions.mockReturnValue(false); + + renderComponent({ viewPermissions: ['view_models'] }); + + expect( + screen.getByRole('heading', { name: 'Permission denied', level: 5 }) + ).toBeInTheDocument(); + expect( + screen.getByText( + /You do not have permission to view the hardware model with id 42/ + ) + ).toBeInTheDocument(); + }); + + it('renders permission-denied messaging without a resource id', () => { + usePermissions.mockReturnValue(false); + + renderComponent({ + viewPermissions: ['view_models'], + resourceId: null, + }); + + expect( + screen.getByText(/You do not have permission to view this hardware model/) + ).toBeInTheDocument(); + }); + + it('treats HTTP 403 as permission denied regardless of cached permissions', () => { + usePermissions.mockReturnValue(true); + + renderComponent({ httpStatus: 403, viewPermissions: ['view_models'] }); + + expect( + screen.getByRole('heading', { name: 'Permission denied', level: 5 }) + ).toBeInTheDocument(); + expect( + screen.getByText( + /You do not have permission to view the hardware model with id 42/ + ) + ).toBeInTheDocument(); + }); + + it('treats HTTP 404 as not found when view permissions are granted', () => { + renderComponent({ httpStatus: 404, viewPermissions: ['view_models'] }); + + expect( + screen.getByText( + 'The hardware model with id 42 could not be found. It may have been deleted or may not be available in your current organization or location scope.' + ) + ).toBeInTheDocument(); + }); + + it('renders server error message when provided', () => { + renderComponent({ errorMessage: 'Record not found' }); + + expect( + screen.getByText('Server returned: Record not found') + ).toBeInTheDocument(); + }); + + it('uses custom header and description when provided', () => { + renderComponent({ + header: 'Custom header', + description: 'Custom description', + resourceId: null, + }); + + expect( + screen.getByRole('heading', { name: 'Custom header', level: 5 }) + ).toBeInTheDocument(); + expect(screen.getByText('Custom description')).toBeInTheDocument(); + expect( + screen.queryByText('The hardware model could not be loaded.') + ).not.toBeInTheDocument(); + }); + }); + + describe('permissions list', () => { + it('renders required permissions when load did not fail for access', () => { + renderComponent({ + viewPermissions: ['view_models'], + requiredPermissions: ['view_models', 'edit_models'], + }); + + const paragraph = screen.getByText((_, element) => { + return ( + element?.tagName === 'P' && + element.textContent?.includes('could not be found') && + element.textContent?.includes('view_models') + ); + }); + expect(paragraph.textContent).toContain( + 'Accessing this page requires the following permissions' + ); + expect(paragraph).toHaveTextContent('edit_models'); + }); + + it('lists missing view permissions when access is denied', () => { + usePermissions.mockReturnValue(false); + + renderComponent({ + viewPermissions: ['view_models'], + requiredPermissions: ['view_models', 'edit_models'], + }); + + const paragraph = screen.getByText((_, element) => { + return ( + element?.tagName === 'P' && + element.textContent?.match(permissionsIntro) && + element.textContent?.includes('view_models') + ); + }); + expect(paragraph).toHaveTextContent('view_models'); + expect(paragraph).not.toHaveTextContent('edit_models'); + }); + + it('falls back to requiredPermissions on HTTP 403 when view permissions are granted in cache', () => { + usePermissions.mockReturnValue(true); + + renderComponent({ + httpStatus: 403, + viewPermissions: ['test_permission_one'], + requiredPermissions: ['view_models', 'edit_models'], + }); + + expect(screen.getByText(permissionsIntro)).toBeInTheDocument(); + expect(screen.getByText('view_models')).toBeInTheDocument(); + expect(screen.getByText('edit_models')).toBeInTheDocument(); + }); + + it('does not render a permissions list when none are configured', () => { + usePermissions.mockReturnValue(false); + + renderComponent({ + viewPermissions: [], + requiredPermissions: null, + }); + + expect(screen.queryByText(permissionsIntro)).not.toBeInTheDocument(); + }); + }); + + describe('presentation', () => { + it('renders a custom icon when provided', () => { + usePermissions.mockReturnValue(false); + + renderComponent({ + viewPermissions: ['view_models'], + icon: , + }); + + expect(screen.getByTestId('custom-icon')).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Permission denied' })).toBeInTheDocument(); + }); + }); + + describe('footer actions', () => { + it('renders primary and back footer actions by default', () => { + renderComponent(); + + expect( + screen.getByRole('button', { name: 'Back to list' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Return to the previous page' }) + ).toBeInTheDocument(); + }); + + it('navigates using primary and secondary action urls', () => { + const history = createMemoryHistory(); + const pushSpy = jest.spyOn(history, 'push'); + + renderComponent( + { + secondaryActions: [ + { + label: 'Create new', + url: '/models/new', + }, + ], + }, + history + ); + + fireEvent.click(screen.getByRole('button', { name: 'Back to list' })); + expect(pushSpy).toHaveBeenCalledWith('/models'); + + fireEvent.click(screen.getByRole('button', { name: 'Create new' })); + expect(pushSpy).toHaveBeenCalledWith('/models/new'); + }); + + it('invokes primaryAction onClick when url is not provided', () => { + const onClick = jest.fn(); + + renderComponent({ + primaryAction: { + label: 'Retry', + onClick, + }, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('navigates back when the back button is clicked', () => { + const history = createMemoryHistory(); + const goBackSpy = jest.spyOn(history, 'goBack'); + + renderComponent({}, history); + + fireEvent.click( + screen.getByRole('button', { name: 'Return to the previous page' }) + ); + expect(goBackSpy).toHaveBeenCalledTimes(1); + }); + + it('can hide the back button', () => { + renderComponent({ showBackButton: false }); + + expect( + screen.queryByRole('button', { name: 'Return to the previous page' }) + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/webpack/assets/javascripts/react_app/components/common/EmptyState/index.js b/webpack/assets/javascripts/react_app/components/common/EmptyState/index.js index 80fa907a4d..762d905a2d 100644 --- a/webpack/assets/javascripts/react_app/components/common/EmptyState/index.js +++ b/webpack/assets/javascripts/react_app/components/common/EmptyState/index.js @@ -1,5 +1,6 @@ import EmptyStatePattern from './EmptyStatePattern'; import DefaultEmptyState from './DefaultEmptyState'; +import ResourceLoadFailedEmptyState from './ResourceLoadFailedEmptyState'; export default DefaultEmptyState; -export { EmptyStatePattern }; +export { EmptyStatePattern, ResourceLoadFailedEmptyState }; diff --git a/webpack/assets/javascripts/react_app/redux/API/APISelectors.js b/webpack/assets/javascripts/react_app/redux/API/APISelectors.js index 4a47a5617b..0197f57bdd 100644 --- a/webpack/assets/javascripts/react_app/redux/API/APISelectors.js +++ b/webpack/assets/javascripts/react_app/redux/API/APISelectors.js @@ -20,5 +20,15 @@ export const selectAPIError = (state, key) => export const selectAPIErrorMessage = (state, key) => { const error = selectAPIError(state, key); - return error && error.message; + if (!error) return undefined; + return ( + error.response?.data?.error?.message || + error.response?.data?.message || + error.message + ); +}; + +export const selectAPIHttpStatus = (state, key) => { + const error = selectAPIError(state, key); + return error?.response?.status; }; diff --git a/webpack/assets/javascripts/react_app/redux/API/__tests__/APISelectors.test.js b/webpack/assets/javascripts/react_app/redux/API/__tests__/APISelectors.test.js index 6756cb2677..c8750fd261 100644 --- a/webpack/assets/javascripts/react_app/redux/API/__tests__/APISelectors.test.js +++ b/webpack/assets/javascripts/react_app/redux/API/__tests__/APISelectors.test.js @@ -5,12 +5,21 @@ import { selectAPIStatus, selectAPIError, selectAPIErrorMessage, + selectAPIHttpStatus, selectAPIResponse, selectAPIPayload, } from '../APISelectors'; import { key, payload, data, error } from '../APIFixtures'; import { STATUS } from '../../../constants'; +const apiError = { + message: 'Request failed with status code 404', + response: { + status: 404, + data: { error: { message: 'Record not found' } }, + }, +}; + const successState = { API: { [key]: { @@ -31,6 +40,16 @@ const failureState = { }, }; +const axiosFailureState = { + API: { + [key]: { + payload, + response: apiError, + status: STATUS.ERROR, + }, + }, +}; + const fixtures = { 'should return the API wrapper': () => selectAPI(successState), 'should return the API substate by key': () => @@ -45,6 +64,12 @@ const fixtures = { selectAPIError(failureState, key), 'should return the API substate error message': () => selectAPIErrorMessage(failureState, key), + 'should return the API error message from an axios response body': () => + selectAPIErrorMessage(axiosFailureState, key), + 'should return the API HTTP status from an axios error': () => + selectAPIHttpStatus(axiosFailureState, key), + 'should return undefined HTTP status when there is no API error': () => + selectAPIHttpStatus(successState, key), }; describe('API selectors', () => testSelectorsSnapshotWithFixtures(fixtures)); diff --git a/webpack/assets/javascripts/react_app/redux/API/__tests__/__snapshots__/APISelectors.test.js.snap b/webpack/assets/javascripts/react_app/redux/API/__tests__/__snapshots__/APISelectors.test.js.snap index 62c36f7bc2..f45b16efe1 100644 --- a/webpack/assets/javascripts/react_app/redux/API/__tests__/__snapshots__/APISelectors.test.js.snap +++ b/webpack/assets/javascripts/react_app/redux/API/__tests__/__snapshots__/APISelectors.test.js.snap @@ -1,5 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`API selectors should return the API HTTP status from an axios error 1`] = `404`; + +exports[`API selectors should return the API error message from an axios response body 1`] = `"Record not found"`; + exports[`API selectors should return the API substate by key 1`] = ` Object { "payload": Object { @@ -49,3 +53,5 @@ Object { }, } `; + +exports[`API selectors should return undefined HTTP status when there is no API error 1`] = `undefined`; diff --git a/webpack/assets/javascripts/react_app/routes/Models/EditModelFormPage.js b/webpack/assets/javascripts/react_app/routes/Models/EditModelFormPage.js index ee20044e2a..81e5b9ff0e 100644 --- a/webpack/assets/javascripts/react_app/routes/Models/EditModelFormPage.js +++ b/webpack/assets/javascripts/react_app/routes/Models/EditModelFormPage.js @@ -9,6 +9,7 @@ import { STATUS } from '../../constants'; import { APIActions } from '../../redux/API'; import { selectAPIErrorMessage, + selectAPIHttpStatus, selectAPIResponse, selectAPIStatus, } from '../../redux/API/APISelectors'; @@ -48,6 +49,7 @@ const EditModelFormPage = ({ const errorMessage = useSelector(state => selectAPIErrorMessage(state, fetchKey) ); + const httpStatus = useSelector(state => selectAPIHttpStatus(state, fetchKey)); const [isSubmitting, setIsSubmitting] = useState(false); useEffect(() => { @@ -112,7 +114,11 @@ const EditModelFormPage = ({ let pageContent; if (status === STATUS.ERROR) { pageContent = ( - + ); } else if (isLoading) { pageContent = ; diff --git a/webpack/assets/javascripts/react_app/routes/Models/ModelFormEmptyState.js b/webpack/assets/javascripts/react_app/routes/Models/ModelFormEmptyState.js index dd80aeaf22..f56fc90f87 100644 --- a/webpack/assets/javascripts/react_app/routes/Models/ModelFormEmptyState.js +++ b/webpack/assets/javascripts/react_app/routes/Models/ModelFormEmptyState.js @@ -1,73 +1,42 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { useHistory } from 'react-router-dom'; -import { Button, EmptyStateVariant } from '@patternfly/react-core'; -import { SearchIcon } from '@patternfly/react-icons'; -import EmptyStatePattern from '../../components/common/EmptyState/EmptyStatePattern'; -import { translate as __, sprintf } from '../../common/I18n'; +import { ResourceLoadFailedEmptyState } from '../../components/common/EmptyState'; +import { translate as __ } from '../../common/I18n'; import { MODELS_PATH, MODELS_PATH_NEW } from './constants'; -const ModelFormEmptyState = ({ modelId, errorMessage }) => { - const history = useHistory(); - - const description = ( - <> -

- {sprintf( - __( - 'The hardware model with id %s could not be loaded. It may not exist or you may not have permission to view it.' - ), - modelId - )} -

- {errorMessage ?

{errorMessage}

: null} - - ); - - return ( - } - header={__('Unable to load hardware model')} - description={description} - action={ - - } - secondaryActions={ - <> - - - - } - /> - ); -}; +const ModelFormEmptyState = ({ modelId, errorMessage, httpStatus }) => ( + +); ModelFormEmptyState.propTypes = { modelId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, errorMessage: PropTypes.string, + httpStatus: PropTypes.number, }; ModelFormEmptyState.defaultProps = { errorMessage: null, + httpStatus: null, }; export default ModelFormEmptyState;