From 543fef978c51acec3878d4f6560a26bf2c3fe9a6 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Thu, 25 Jun 2026 17:45:04 +1000 Subject: [PATCH] refactor: add typed getHttpErrorStatus helper and PlatformError ambient type --- src/authz-module/audit-user/index.tsx | 7 ++-- .../components/ErrorPage/index.tsx | 3 +- .../AssignRoleWizard.tsx | 3 +- .../ToastManager/ToastManagerContext.tsx | 34 ++++++++++++------- src/data/utils.ts | 8 +++++ src/global.d.ts | 14 +++++++- 6 files changed, 50 insertions(+), 19 deletions(-) diff --git a/src/authz-module/audit-user/index.tsx b/src/authz-module/audit-user/index.tsx index 0619402a..362a42b4 100644 --- a/src/authz-module/audit-user/index.tsx +++ b/src/authz-module/audit-user/index.tsx @@ -14,6 +14,7 @@ import { import AuthZLayout from '@src/authz-module/components/AuthZLayout'; import { useNavigate, useParams } from 'react-router-dom'; import { useUserAccount, useValidateUserPermissionsNonSuspense } from '@src/data/hooks'; +import { getHttpErrorStatus } from '@src/data/utils'; import baseMessages from '@src/authz-module/messages'; import AddRoleButton from '@src/authz-module/components/AddRoleButton'; import { @@ -22,6 +23,7 @@ import { } from '@src/authz-module/components/TableCells'; import { useQuerySettings } from '@src/authz-module/hooks/useQuerySettings'; import { useRevokeUserRoles, useUserAssignedRoles } from '@src/authz-module/data/hooks'; +import type { RevokeUserRolesRequest } from '@src/authz-module/data/api'; import { RoleToDelete } from '@src/types'; import { useToastManager } from '@src/components/ToastManager/ToastManagerContext'; import UserPermissions from '@src/authz-module/components/UserPermissions'; @@ -79,8 +81,7 @@ const AuditUserPage = () => { useEffect(() => { if (!user && !isLoadingUser) { - // @ts-ignore - if (!isErrorUser || errorUser?.customAttributes?.httpErrorStatus === 404) { + if (!isErrorUser || getHttpErrorStatus(errorUser) === 404) { navigate(AUTHZ_HOME_PATH); } } @@ -166,7 +167,7 @@ const AuditUserPage = () => { scope: roleToDelete.scope, }; - const runRevokeRole = (variables) => { + const runRevokeRole = (variables: { data: RevokeUserRolesRequest }) => { const variablesData = { data: { ...variables.data, diff --git a/src/authz-module/components/ErrorPage/index.tsx b/src/authz-module/components/ErrorPage/index.tsx index 263865a7..fdf582ed 100644 --- a/src/authz-module/components/ErrorPage/index.tsx +++ b/src/authz-module/components/ErrorPage/index.tsx @@ -8,6 +8,7 @@ import { import { CustomErrors, ERROR_STATUS, STATUS_400, STATUS_404, } from '@src/constants'; +import { getHttpErrorStatus } from '@src/data/utils'; import messages from './messages'; @@ -52,7 +53,7 @@ const ErrorPage = ({ error, resetErrorBoundary }: FallbackProps) => { const intl = useIntl(); const [reloading, setReloading] = useState(false); - const errorStatus: number = error?.customAttributes?.httpErrorStatus; + const errorStatus = getHttpErrorStatus(error); const errorMessage: string = error?.message; const { title, description, statusCode, showBackButton, showReloadButton, diff --git a/src/authz-module/role-assignation-wizard/AssignRoleWizard.tsx b/src/authz-module/role-assignation-wizard/AssignRoleWizard.tsx index d15adab4..b37fda34 100644 --- a/src/authz-module/role-assignation-wizard/AssignRoleWizard.tsx +++ b/src/authz-module/role-assignation-wizard/AssignRoleWizard.tsx @@ -7,6 +7,7 @@ import { } from '@openedx/paragon'; import { SpinnerSimple } from '@openedx/paragon/icons'; import { RoleMetadata } from '@src/types'; +import { getHttpErrorStatus } from '@src/data/utils'; import { useToastManager } from '@src/components/ToastManager/ToastManagerContext'; import SelectUsersAndRoleStep from './components/SelectUsersAndRoleStep'; import DefineApplicationScopeStep from './components/DefineApplicationScopeStep'; @@ -141,7 +142,7 @@ const AssignRoleWizard = ({ } } catch (error) { // TODO: remove once the backend supports the permissions endpoint without a required scope. - if ((error as any)?.customAttributes?.httpErrorStatus === 403) { + if (getHttpErrorStatus(error) === 403) { showToast({ message: intl.formatMessage(messages['wizard.save.error.forbidden']), type: 'error', diff --git a/src/components/ToastManager/ToastManagerContext.tsx b/src/components/ToastManager/ToastManagerContext.tsx index 8a40dd9b..df7f619f 100644 --- a/src/components/ToastManager/ToastManagerContext.tsx +++ b/src/components/ToastManager/ToastManagerContext.tsx @@ -1,11 +1,12 @@ import { - createContext, useContext, useState, useMemo, + createContext, useContext, useState, useMemo, useCallback, useEffect, useRef, } from 'react'; import { logError } from '@edx/frontend-platform/logging'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Toast } from '@openedx/paragon'; import messages from '@src/authz-module/messages'; import { DEFAULT_TOAST_DELAY, RETRY_TOAST_DELAY } from '@src/authz-module/constants'; +import { getHttpErrorStatus } from '@src/data/utils'; type ToastType = 'success' | 'error' | 'error-retry'; @@ -33,7 +34,7 @@ const Br = () =>
; type ToastManagerContextType = { showToast: (toast: Omit) => void; - showErrorToast: (error, retryFn?: () => void) => void; + showErrorToast: (error: unknown, retryFn?: () => void) => void; Bold: (chunks: React.ReactNode[]) => JSX.Element; Br: () => JSX.Element; }; @@ -47,26 +48,33 @@ interface ToastManagerProviderProps { export const ToastManagerProvider = ({ children }: ToastManagerProviderProps) => { const intl = useIntl(); const [toasts, setToasts] = useState<(AppToast & { visible: boolean })[]>([]); + const removalTimers = useRef[]>([]); - const showToast = (toast: Omit) => { + const showToast = useCallback((toast: Omit) => { const id = `toast-notification-${Date.now()}-${Math.floor(Math.random() * 1000000)}`; const newToast = { ...toast, id, visible: true }; setToasts(prev => [...prev, newToast]); - }; + }, []); - const discardToast = (id: string) => { + const discardToast = useCallback((id: string) => { setToasts(prev => prev.map(t => (t.id === id ? { ...t, visible: false } : t))); - setTimeout(() => { + const timer = setTimeout(() => { setToasts(prev => prev.filter(t => t.id !== id)); - }, 5000); - }; + }, DEFAULT_TOAST_DELAY); + removalTimers.current.push(timer); + }, []); + + // Clear any pending removal timers when the provider unmounts. + useEffect(() => () => { + removalTimers.current.forEach(clearTimeout); + }, []); const value = useMemo(() => { - const showErrorToast = (error, retryFn?: () => void) => { - logError(error); - const errorStatus = error?.customAttributes?.httpErrorStatus; - const toastConfig = ERROR_TOAST_MAP[errorStatus] || ERROR_TOAST_MAP.DEFAULT; + const showErrorToast = (error: unknown, retryFn?: () => void) => { + logError(error as Error); + const errorStatus = getHttpErrorStatus(error); + const toastConfig = (errorStatus !== undefined && ERROR_TOAST_MAP[errorStatus]) || ERROR_TOAST_MAP.DEFAULT; const message = intl.formatMessage(messages[toastConfig.messageId], { Bold, Br }); /** * 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) => Bold, Br, }); - }, [intl]); + }, [intl, showToast]); return ( diff --git a/src/data/utils.ts b/src/data/utils.ts index 8676ba1a..e1536723 100644 --- a/src/data/utils.ts +++ b/src/data/utils.ts @@ -2,3 +2,11 @@ import { getConfig } from '@edx/frontend-platform'; export const getApiUrl = (path: string) => `${getConfig().LMS_BASE_URL}${path || ''}`; export const getStudioApiUrl = (path: string) => `${getConfig().STUDIO_BASE_URL}${path || ''}`; + +/** + * Safely reads the HTTP status that @edx/frontend-platform's HTTP client attaches + * to thrown errors. Returns `undefined` when no status is present. + */ +export const getHttpErrorStatus = (error: unknown): number | undefined => ( + (error as PlatformError | null | undefined)?.customAttributes?.httpErrorStatus +); diff --git a/src/global.d.ts b/src/global.d.ts index 9fb2f578..66c9f7c3 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -1,8 +1,20 @@ -// Module augmentations for external libraries. +// Module augmentations and ambient platform types for external libraries. // Application domain types belong in src/types.ts, not here. export {}; +declare global { + /** + * Error shape produced by @edx/frontend-platform's HTTP client, which attaches + * the HTTP status under `customAttributes`. Read it with `getHttpErrorStatus`. + */ + interface PlatformError extends Error { + customAttributes?: { + httpErrorStatus?: number; + }; + } +} + declare module '@openedx/paragon' { export interface DataTableRow { original: T;