Skip to content

Commit 21cae7c

Browse files
committed
Fixes #39253 - more granular messaging
1 parent acf6bd6 commit 21cae7c

10 files changed

Lines changed: 462 additions & 110 deletions

File tree

webpack/assets/javascripts/react_app/components/HostDetails/EmptyState/index.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
useForemanSettings,
99
} from '../../../Root/Context/ForemanContext';
1010

11-
const HostDetailsEmptyState = ({ hostname }) => {
11+
const HostDetailsEmptyState = ({ hostname, httpStatus, errorMessage }) => {
1212
const { displayNewHostsPage } = useForemanSettings();
1313
const hostsIndexUrl = useForemanHostsPageUrl();
1414

@@ -20,6 +20,9 @@ const HostDetailsEmptyState = ({ hostname }) => {
2020
<ResourceLoadFailedEmptyState
2121
resourceLabel={__('host')}
2222
resourceId={hostname}
23+
httpStatus={httpStatus}
24+
errorMessage={errorMessage}
25+
viewPermissions={['view_hosts']}
2326
primaryAction={{
2427
label: __('Back to all hosts'),
2528
...hostsIndexAction,
@@ -40,6 +43,13 @@ const HostDetailsEmptyState = ({ hostname }) => {
4043

4144
HostDetailsEmptyState.propTypes = {
4245
hostname: PropTypes.string.isRequired,
46+
httpStatus: PropTypes.number,
47+
errorMessage: PropTypes.string,
48+
};
49+
50+
HostDetailsEmptyState.defaultProps = {
51+
httpStatus: null,
52+
errorMessage: null,
4353
};
4454

4555
export default HostDetailsEmptyState;

webpack/assets/javascripts/react_app/components/HostDetails/index.js

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,22 @@ import {
2929
import { selectIsCollapsed } from '../Layout/LayoutSelectors';
3030
import ActionsBar from './ActionsBar';
3131
import { registerCoreTabs } from './Tabs';
32-
import { HOST_DETAILS_API_OPTIONS, TABS_SLOT_ID } from './consts';
32+
import {
33+
HOST_DETAILS_API_OPTIONS,
34+
HOST_DETAILS_KEY,
35+
TABS_SLOT_ID,
36+
} from './consts';
3337

3438
import { translate as __, sprintf } from '../../common/I18n';
3539
import HostGlobalStatus from './Status/GlobalStatus';
3640
import SkeletonLoader from '../common/SkeletonLoader';
3741
import { STATUS } from '../../constants';
3842
import './HostDetails.scss';
3943
import { useAPI } from '../../common/hooks/API/APIHooks';
44+
import {
45+
selectAPIErrorMessage,
46+
selectAPIHttpStatus,
47+
} from '../../redux/API/APISelectors';
4048
import TabRouter from './Tabs/TabRouter';
4149
import HostDetailsEmptyState from './EmptyState';
4250
import BreadcrumbBar from '../BreadcrumbBar';
@@ -60,6 +68,12 @@ const HostDetails = ({
6068
`/api/hosts/${id}?show_hidden_parameters=true`,
6169
HOST_DETAILS_API_OPTIONS
6270
);
71+
const httpStatus = useSelector(state =>
72+
selectAPIHttpStatus(state, HOST_DETAILS_KEY)
73+
);
74+
const errorMessage = useSelector(state =>
75+
selectAPIErrorMessage(state, HOST_DETAILS_KEY)
76+
);
6377
const isNavCollapsed = useSelector(selectIsCollapsed);
6478
const hostsIndexUrl = useForemanHostsPageUrl();
6579
const tabs = useSelector(
@@ -91,7 +105,14 @@ const HostDetails = ({
91105
tab => !slotMetadata?.[tab]?.hideTab?.({ hostDetails: response })
92106
) ?? [];
93107

94-
if (status === STATUS.ERROR) return <HostDetailsEmptyState hostname={id} />;
108+
if (status === STATUS.ERROR)
109+
return (
110+
<HostDetailsEmptyState
111+
hostname={id}
112+
httpStatus={httpStatus}
113+
errorMessage={errorMessage}
114+
/>
115+
);
95116
return (
96117
<>
97118
<Head>

webpack/assets/javascripts/react_app/components/common/EmptyState/EmptyStatePropTypes.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ export const resourceLoadFailedEmptyStatePropTypes = {
4242
header: PropTypes.string,
4343
description: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
4444
errorMessage: PropTypes.string,
45+
/** HTTP status from the failed load request (e.g. axios error.response.status). */
46+
httpStatus: PropTypes.number,
47+
/** Permissions required to view the resource; used with usePermissions and httpStatus. */
48+
viewPermissions: PropTypes.arrayOf(PropTypes.string),
49+
/** Optional page-level permissions shown as documentation when load did not fail for access. */
4550
requiredPermissions: PropTypes.arrayOf(PropTypes.string),
4651
primaryAction: PropTypes.shape(footerActionPropTypes).isRequired,
4752
secondaryActions: PropTypes.arrayOf(PropTypes.shape(footerActionPropTypes)),

webpack/assets/javascripts/react_app/components/common/EmptyState/ResourceLoadFailedEmptyState.js

Lines changed: 103 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,75 @@
11
import React from 'react';
22
import { useHistory } from 'react-router-dom';
33
import { Button, EmptyStateVariant } from '@patternfly/react-core';
4-
import { SearchIcon } from '@patternfly/react-icons';
4+
import { LockIcon, SearchIcon } from '@patternfly/react-icons';
55
import EmptyStatePattern from './EmptyStatePattern';
66
import { resourceLoadFailedEmptyStatePropTypes } from './EmptyStatePropTypes';
77
import { translate as __, sprintf } from '../../../common/I18n';
8+
import { usePermissions } from '../../../common/hooks/Permissions/permissionHooks';
9+
import { useForemanPermissions } from '../../../Root/Context/ForemanContext';
10+
11+
const FAILURE_REASON = {
12+
FORBIDDEN: 'forbidden',
13+
NOT_FOUND: 'not_found',
14+
UNKNOWN: 'unknown',
15+
};
16+
17+
const resolveFailureReason = ({
18+
httpStatus,
19+
viewPermissions,
20+
hasViewPermission,
21+
}) => {
22+
if (httpStatus === 403) return FAILURE_REASON.FORBIDDEN;
23+
if (viewPermissions?.length > 0 && !hasViewPermission) {
24+
return FAILURE_REASON.FORBIDDEN;
25+
}
26+
if (httpStatus === 404) return FAILURE_REASON.NOT_FOUND;
27+
if (viewPermissions?.length > 0 && hasViewPermission) {
28+
return FAILURE_REASON.NOT_FOUND;
29+
}
30+
return FAILURE_REASON.UNKNOWN;
31+
};
32+
33+
const getDefaultDescription = (failureReason, resourceLabel, resourceId) => {
34+
const hasResourceId = resourceId !== null && resourceId !== undefined;
35+
36+
switch (failureReason) {
37+
case FAILURE_REASON.FORBIDDEN:
38+
return hasResourceId
39+
? sprintf(
40+
__('You do not have permission to view the %s with id %s.'),
41+
resourceLabel,
42+
resourceId
43+
)
44+
: sprintf(
45+
__('You do not have permission to view this %s.'),
46+
resourceLabel
47+
);
48+
case FAILURE_REASON.NOT_FOUND:
49+
return hasResourceId
50+
? sprintf(
51+
__(
52+
'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.'
53+
),
54+
resourceLabel,
55+
resourceId
56+
)
57+
: sprintf(
58+
__(
59+
'The %s could not be found. It may have been deleted or may not be available in your current organization or location scope.'
60+
),
61+
resourceLabel
62+
);
63+
default:
64+
return hasResourceId
65+
? sprintf(
66+
__('The %s with id %s could not be loaded.'),
67+
resourceLabel,
68+
resourceId
69+
)
70+
: sprintf(__('The %s could not be loaded.'), resourceLabel);
71+
}
72+
};
873

974
const invokeFooterAction = (action, history) => {
1075
if (action.onClick) {
@@ -20,6 +85,8 @@ const ResourceLoadFailedEmptyState = ({
2085
header,
2186
description,
2287
errorMessage,
88+
httpStatus,
89+
viewPermissions,
2390
requiredPermissions,
2491
primaryAction,
2592
secondaryActions,
@@ -31,35 +98,47 @@ const ResourceLoadFailedEmptyState = ({
3198
...props
3299
}) => {
33100
const history = useHistory();
101+
const userPermissions = useForemanPermissions();
102+
const hasViewPermission = usePermissions(viewPermissions || []);
103+
const failureReason = resolveFailureReason({
104+
httpStatus,
105+
viewPermissions,
106+
hasViewPermission,
107+
});
108+
const isForbidden = failureReason === FAILURE_REASON.FORBIDDEN;
109+
110+
const missingViewPermissions =
111+
viewPermissions?.filter(permission => !userPermissions.has(permission)) ||
112+
[];
113+
114+
let permissionsToList = requiredPermissions;
115+
if (isForbidden && missingViewPermissions.length > 0) {
116+
permissionsToList = missingViewPermissions;
117+
}
34118

35119
const resolvedHeader =
36-
header || sprintf(__('Unable to load %s'), resourceLabel);
37-
38-
const defaultDescription =
39-
resourceId !== null && resourceId !== undefined
40-
? sprintf(
41-
__(
42-
'The %s with id %s could not be loaded. It may not exist or you may not have permission to view it.'
43-
),
44-
resourceLabel,
45-
resourceId
46-
)
47-
: sprintf(
48-
__(
49-
'The %s could not be loaded. It may not exist or you may not have permission to view it.'
50-
),
51-
resourceLabel
52-
);
120+
header ||
121+
(isForbidden
122+
? __('Permission denied')
123+
: sprintf(__('Unable to load %s'), resourceLabel));
124+
125+
const resolvedIcon = icon ?? (isForbidden ? <LockIcon /> : <SearchIcon />);
126+
127+
const defaultDescription = getDefaultDescription(
128+
failureReason,
129+
resourceLabel,
130+
resourceId
131+
);
53132

54133
const descriptionContent = (
55134
<React.Fragment>
56135
<p>
57136
{description ?? defaultDescription}
58-
{requiredPermissions?.length > 0 ? (
137+
{permissionsToList?.length > 0 ? (
59138
<React.Fragment>
60139
{' '}
61140
{__('Accessing this page requires the following permissions:')}{' '}
62-
{requiredPermissions.map((permission, index) => (
141+
{permissionsToList.map((permission, index) => (
63142
<React.Fragment key={permission}>
64143
{index > 0 ? ', ' : null}
65144
<strong>{permission}</strong>
@@ -120,7 +199,7 @@ const ResourceLoadFailedEmptyState = ({
120199
return (
121200
<EmptyStatePattern
122201
variant={variant}
123-
icon={icon}
202+
icon={resolvedIcon}
124203
header={resolvedHeader}
125204
description={descriptionContent}
126205
action={renderFooterButton(
@@ -141,11 +220,13 @@ ResourceLoadFailedEmptyState.defaultProps = {
141220
header: null,
142221
description: null,
143222
errorMessage: null,
223+
httpStatus: null,
224+
viewPermissions: null,
144225
requiredPermissions: null,
145226
secondaryActions: [],
146227
showBackButton: true,
147228
backButtonLabel: __('Return to the previous page'),
148-
icon: <SearchIcon />,
229+
icon: null,
149230
variant: EmptyStateVariant.lg,
150231
ouiaIdPrefix: 'resource-load-failed-empty-state',
151232
};

0 commit comments

Comments
 (0)