Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/authz-module/audit-user/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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';
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -166,7 +167,7 @@ const AuditUserPage = () => {
scope: roleToDelete.scope,
};

const runRevokeRole = (variables) => {
const runRevokeRole = (variables: { data: RevokeUserRolesRequest }) => {
const variablesData = {
data: {
...variables.data,
Expand Down
3 changes: 2 additions & 1 deletion src/authz-module/components/ErrorPage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down
34 changes: 21 additions & 13 deletions src/components/ToastManager/ToastManagerContext.tsx
Original file line number Diff line number Diff line change
@@ -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';

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

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

const showToast = (toast: Omit<AppToast, 'id'>) => {
const showToast = useCallback((toast: Omit<AppToast, 'id'>) => {
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<ToastManagerContextType>(() => {
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
Expand All @@ -90,7 +98,7 @@ export const ToastManagerProvider = ({ children }: ToastManagerProviderProps) =>
Bold,
Br,
});
}, [intl]);
}, [intl, showToast]);

return (
<ToastManagerContext.Provider value={value}>
Expand Down
8 changes: 8 additions & 0 deletions src/data/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
14 changes: 13 additions & 1 deletion src/global.d.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
original: T;
Expand Down