Skip to content

Commit 467fb9d

Browse files
committed
refactor: add typed getHttpErrorStatus helper and PlatformError ambient type
1 parent e3d90d8 commit 467fb9d

6 files changed

Lines changed: 51 additions & 21 deletions

File tree

src/authz-module/audit-user/index.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import AuthZLayout from '@src/authz-module/components/AuthZLayout';
1515
import { useNavigate, useParams } from 'react-router-dom';
1616
import { useUserAccount, useValidateUserPermissionsNonSuspense } from '@src/data/hooks';
17+
import { getHttpErrorStatus } from '@src/data/utils';
1718
import baseMessages from '@src/authz-module/messages';
1819
import AddRoleButton from '@src/authz-module/components/AddRoleButton';
1920
import {
@@ -22,6 +23,7 @@ import {
2223
} from '@src/authz-module/components/TableCells';
2324
import { useQuerySettings } from '@src/authz-module/hooks/useQuerySettings';
2425
import { useRevokeUserRoles, useUserAssignedRoles } from '@src/authz-module/data/hooks';
26+
import type { RevokeUserRolesRequest } from '@src/authz-module/data/api';
2527
import { RoleToDelete } from '@src/types';
2628
import { useToastManager } from '@src/components/ToastManager/ToastManagerContext';
2729
import UserPermissions from '@src/authz-module/components/UserPermissions';
@@ -79,9 +81,8 @@ const AuditUserPage = () => {
7981

8082
useEffect(() => {
8183
if (!user && !isLoadingUser) {
82-
// @ts-ignore
83-
if (!isErrorUser || errorUser?.customAttributes?.httpErrorStatus === 404) {
84-
navigate(AUTHZ_HOME_PATH);
84+
if (!isErrorUser || getHttpErrorStatus(errorUser) === 404) {
85+
navigate(ROUTES.HOME_PATH);
8586
}
8687
}
8788
}, [user, isLoadingUser, navigate, isErrorUser, errorUser]);
@@ -166,7 +167,7 @@ const AuditUserPage = () => {
166167
scope: roleToDelete.scope,
167168
};
168169

169-
const runRevokeRole = (variables) => {
170+
const runRevokeRole = (variables: { data: RevokeUserRolesRequest }) => {
170171
const variablesData = {
171172
data: {
172173
...variables.data,

src/authz-module/components/ErrorPage/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import {
99
CustomErrors, ERROR_STATUS, STATUS_400, STATUS_404,
1010
} from '@src/constants';
11+
import { getHttpErrorStatus } from '@src/data/utils';
1112

1213
import messages from './messages';
1314

@@ -52,7 +53,7 @@ const ErrorPage = ({ error, resetErrorBoundary }: FallbackProps) => {
5253
const intl = useIntl();
5354
const [reloading, setReloading] = useState(false);
5455

55-
const errorStatus: number = error?.customAttributes?.httpErrorStatus;
56+
const errorStatus = getHttpErrorStatus(error);
5657
const errorMessage: string = error?.message;
5758
const {
5859
title, description, statusCode, showBackButton, showReloadButton,

src/authz-module/role-assignation-wizard/AssignRoleWizard.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from '@openedx/paragon';
88
import { SpinnerSimple } from '@openedx/paragon/icons';
99
import { RoleMetadata } from '@src/types';
10+
import { getHttpErrorStatus } from '@src/data/utils';
1011
import { useToastManager } from '@src/components/ToastManager/ToastManagerContext';
1112
import SelectUsersAndRoleStep from './components/SelectUsersAndRoleStep';
1213
import DefineApplicationScopeStep from './components/DefineApplicationScopeStep';
@@ -141,7 +142,7 @@ const AssignRoleWizard = ({
141142
}
142143
} catch (error) {
143144
// TODO: remove once the backend supports the permissions endpoint without a required scope.
144-
if ((error as any)?.customAttributes?.httpErrorStatus === 403) {
145+
if (getHttpErrorStatus(error) === 403) {
145146
showToast({
146147
message: intl.formatMessage(messages['wizard.save.error.forbidden']),
147148
type: 'error',

src/components/ToastManager/ToastManagerContext.tsx

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import {
2-
createContext, useContext, useState, useMemo,
2+
createContext, useContext, useState, useMemo, useCallback, useEffect, useRef,
33
} from 'react';
44
import { logError } from '@edx/frontend-platform/logging';
55
import { useIntl } from '@edx/frontend-platform/i18n';
66
import { Toast } from '@openedx/paragon';
77
import messages from '@src/authz-module/messages';
88
import { DEFAULT_TOAST_DELAY, RETRY_TOAST_DELAY } from '@src/authz-module/constants';
9+
import { getHttpErrorStatus } from '@src/data/utils';
910

1011
type ToastType = 'success' | 'error' | 'error-retry';
1112

@@ -33,7 +34,7 @@ const Br = () => <br />;
3334

3435
type ToastManagerContextType = {
3536
showToast: (toast: Omit<AppToast, 'id'>) => void;
36-
showErrorToast: (error, retryFn?: () => void) => void;
37+
showErrorToast: (error: unknown, retryFn?: () => void) => void;
3738
Bold: (chunks: React.ReactNode[]) => JSX.Element;
3839
Br: () => JSX.Element;
3940
};
@@ -47,26 +48,33 @@ interface ToastManagerProviderProps {
4748
export const ToastManagerProvider = ({ children }: ToastManagerProviderProps) => {
4849
const intl = useIntl();
4950
const [toasts, setToasts] = useState<(AppToast & { visible: boolean })[]>([]);
51+
const removalTimers = useRef<ReturnType<typeof setTimeout>[]>([]);
5052

51-
const showToast = (toast: Omit<AppToast, 'id'>) => {
53+
const showToast = useCallback((toast: Omit<AppToast, 'id'>) => {
5254
const id = `toast-notification-${Date.now()}-${Math.floor(Math.random() * 1000000)}`;
5355
const newToast = { ...toast, id, visible: true };
5456
setToasts(prev => [...prev, newToast]);
55-
};
57+
}, []);
5658

57-
const discardToast = (id: string) => {
59+
const discardToast = useCallback((id: string) => {
5860
setToasts(prev => prev.map(t => (t.id === id ? { ...t, visible: false } : t)));
5961

60-
setTimeout(() => {
62+
const timer = setTimeout(() => {
6163
setToasts(prev => prev.filter(t => t.id !== id));
62-
}, 5000);
63-
};
64+
}, DEFAULT_TOAST_DELAY);
65+
removalTimers.current.push(timer);
66+
}, []);
67+
68+
// Clear any pending removal timers when the provider unmounts.
69+
useEffect(() => () => {
70+
removalTimers.current.forEach(clearTimeout);
71+
}, []);
6472

6573
const value = useMemo<ToastManagerContextType>(() => {
66-
const showErrorToast = (error, retryFn?: () => void) => {
67-
logError(error);
68-
const errorStatus = error?.customAttributes?.httpErrorStatus;
69-
const toastConfig = ERROR_TOAST_MAP[errorStatus] || ERROR_TOAST_MAP.DEFAULT;
74+
const showErrorToast = (error: unknown, retryFn?: () => void) => {
75+
logError(error as Error);
76+
const errorStatus = getHttpErrorStatus(error);
77+
const toastConfig = (errorStatus !== undefined && ERROR_TOAST_MAP[errorStatus]) || ERROR_TOAST_MAP.DEFAULT;
7078
const message = intl.formatMessage(messages[toastConfig.messageId], { Bold, Br });
7179
/**
7280
* For retryable errors, we set a longer delay to give users more time to read the message
@@ -90,7 +98,7 @@ export const ToastManagerProvider = ({ children }: ToastManagerProviderProps) =>
9098
Bold,
9199
Br,
92100
});
93-
}, [intl]);
101+
}, [intl, showToast]);
94102

95103
return (
96104
<ToastManagerContext.Provider value={value}>

src/data/utils.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
11
import { getConfig } from '@edx/frontend-platform';
22

33
export const getApiUrl = (path: string) => `${getConfig().LMS_BASE_URL}${path || ''}`;
4-
export const getStudioApiUrl = (path: string) => `${getConfig().STUDIO_BASE_URL}${path || ''}`;
4+
5+
/**
6+
* Safely reads the HTTP status that @edx/frontend-platform's HTTP client attaches
7+
* to thrown errors. Returns `undefined` when no status is present.
8+
*/
9+
export const getHttpErrorStatus = (error: unknown): number | undefined => (
10+
(error as PlatformError | null | undefined)?.customAttributes?.httpErrorStatus
11+
);

src/global.d.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
1-
// Module augmentations for external libraries.
1+
// Module augmentations and ambient platform types for external libraries.
22
// Application domain types belong in src/types.ts, not here.
33

44
export {};
55

6+
declare global {
7+
/**
8+
* Error shape produced by @edx/frontend-platform's HTTP client, which attaches
9+
* the HTTP status under `customAttributes`. Read it with `getHttpErrorStatus`.
10+
*/
11+
interface PlatformError extends Error {
12+
customAttributes?: {
13+
httpErrorStatus?: number;
14+
};
15+
}
16+
}
17+
618
declare module '@openedx/paragon' {
719
export interface DataTableRow<T> {
820
original: T;

0 commit comments

Comments
 (0)