Skip to content

Commit 2e1dc75

Browse files
authored
feat(authz): [FC-0099] create an custom error boundery (#11)
Create a custom error boundary for libraries team management workflow.
1 parent b332f62 commit 2e1dc75

12 files changed

Lines changed: 274 additions & 18 deletions

File tree

.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
NODE_ENV='production'
22
ACCESS_TOKEN_COOKIE_NAME=''
33
BASE_URL=''
4+
COURSE_AUTHORING_MICROFRONTEND_URL= ''
45
CREDENTIALS_BASE_URL=''
56
CSRF_TOKEN_API_PATH=''
67
ECOMMERCE_BASE_URL=''

.env.development

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ NODE_ENV='development'
22
PORT=2025
33
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
44
BASE_URL='http://localhost:2025'
5+
COURSE_AUTHORING_MICROFRONTEND_URL= 'http://apps.local.openedx.io:2001/authoring'
56
CREDENTIALS_BASE_URL='http://localhost:18150'
67
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
78
ECOMMERCE_BASE_URL='http://localhost:18130'
@@ -15,9 +16,10 @@ LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
1516
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
1617
MARKETING_SITE_BASE_URL='http://localhost:18000'
1718
ORDER_HISTORY_URL='http://localhost:1996/orders'
18-
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
19+
REFRESH_ACCESS_TOKEN_ENDPOINT='http://local.openedx.io:8000/login_refresh'
1920
SEGMENT_KEY=''
2021
SITE_NAME=localhost
22+
STUDIO_BASE_URL='http://studio.local.openedx.io:8001'
2123
USER_INFO_COOKIE_NAME='edx-user-info'
2224
APP_ID='admin-console'
2325
MFE_CONFIG_API_URL=''

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"lodash.debounce": "^4.0.8",
4747
"react": "^18.3.1",
4848
"react-dom": "^18.3.1",
49+
"react-error-boundary": "^4.1.2",
4950
"react-router-dom": "^6.0.0"
5051
},
5152
"devDependencies": {

src/authz-module/index.tsx

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,29 @@
11
import { Suspense } from 'react';
22
import { Routes, Route } from 'react-router-dom';
3-
import { ErrorBoundary } from '@edx/frontend-platform/react';
3+
import { ErrorBoundary } from 'react-error-boundary';
4+
import { QueryErrorResetBoundary } from '@tanstack/react-query';
45
import LoadingPage from '@src/components/LoadingPage';
6+
import LibrariesErrorFallback from '@src/authz-module/libraries-manager/ErrorPage';
57
import { LibrariesTeamManager, LibrariesUserManager, LibrariesLayout } from './libraries-manager';
68
import { ROUTES } from './constants';
79

810
import './index.scss';
911

1012
const AuthZModule = () => (
11-
<ErrorBoundary>
12-
<Suspense fallback={<LoadingPage />}>
13-
<Routes>
14-
<Route element={<LibrariesLayout />}>
15-
<Route path={ROUTES.LIBRARIES_TEAM_PATH} element={<LibrariesTeamManager />} />
16-
<Route path={ROUTES.LIBRARIES_USER_PATH} element={<LibrariesUserManager />} />
17-
</Route>
18-
</Routes>
19-
</Suspense>
20-
</ErrorBoundary>
13+
<QueryErrorResetBoundary>
14+
{({ reset }) => (
15+
<ErrorBoundary fallbackRender={LibrariesErrorFallback} onReset={reset}>
16+
<Suspense fallback={<LoadingPage />}>
17+
<Routes>
18+
<Route element={<LibrariesLayout />}>
19+
<Route path={ROUTES.LIBRARIES_TEAM_PATH} element={<LibrariesTeamManager />} />
20+
<Route path={ROUTES.LIBRARIES_USER_PATH} element={<LibrariesUserManager />} />
21+
</Route>
22+
</Routes>
23+
</Suspense>
24+
</ErrorBoundary>
25+
)}
26+
</QueryErrorResetBoundary>
2127
);
2228

2329
export default AuthZModule;
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import { ErrorBoundary } from 'react-error-boundary';
4+
import { renderWrapper } from '@src/setupTest';
5+
import LibrariesErrorFallback from './index';
6+
7+
const ThrowError = ({ error }: { error:Error }) => {
8+
throw error;
9+
return null;
10+
};
11+
12+
describe('LibrariesErrorFallback', () => {
13+
it('renders Access Denied for 401', () => {
14+
const error = { name: '', message: 'NO_ACCESS', customAtributtes: { httpErrorStatus: 401 } };
15+
renderWrapper(
16+
<ErrorBoundary FallbackComponent={LibrariesErrorFallback}>
17+
<ThrowError error={error} />
18+
</ErrorBoundary>,
19+
);
20+
expect(screen.getByText(/Access Denied/i)).toBeInTheDocument();
21+
expect(screen.getByText(/Back to Libraries/i)).toBeInTheDocument();
22+
});
23+
24+
it('renders Not Found for 404', () => {
25+
const error = { name: '', message: 'NOT_FOUND', customAtributtes: { httpErrorStatus: 404 } };
26+
renderWrapper(
27+
<ErrorBoundary FallbackComponent={LibrariesErrorFallback}>
28+
<ThrowError error={error} />
29+
</ErrorBoundary>,
30+
);
31+
expect(screen.getByText(/Page Not Found/i)).toBeInTheDocument();
32+
expect(screen.getByText(/Back to Libraries/i)).toBeInTheDocument();
33+
});
34+
35+
it('renders Server Error for 500 and shows reload', async () => {
36+
const error = { name: '', message: 'SERVER_ERROR', customAtributtes: { httpErrorStatus: 500 } };
37+
renderWrapper(
38+
<ErrorBoundary FallbackComponent={LibrariesErrorFallback}>
39+
<ThrowError error={error} />
40+
</ErrorBoundary>,
41+
);
42+
expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument();
43+
expect(screen.getByText(/Reload Page/i)).toBeInTheDocument();
44+
expect(screen.getByText(/Back to Libraries/i)).toBeInTheDocument();
45+
});
46+
47+
it('renders generic error for other error error', () => {
48+
const error = { name: '', message: 'SOMETHING_ELSE', customAtributtes: { httpErrorStatus: 418 } };
49+
renderWrapper(
50+
<ErrorBoundary FallbackComponent={LibrariesErrorFallback}>
51+
<ThrowError error={error} />
52+
</ErrorBoundary>,
53+
);
54+
expect(screen.getByText(/Error/i)).toBeInTheDocument();
55+
expect(screen.getByText(/Back to Libraries/i)).toBeInTheDocument();
56+
});
57+
58+
it('calls reload action if present', async () => {
59+
// Simulate error with a refetch function
60+
const refetch = jest.fn();
61+
const error = {
62+
name: '', message: 'SERVER_ERROR', customAtributtes: { httpErrorStatus: 500 }, refetch,
63+
};
64+
renderWrapper(
65+
<ErrorBoundary FallbackComponent={LibrariesErrorFallback} onReset={refetch}>
66+
<ThrowError error={error} />
67+
</ErrorBoundary>,
68+
);
69+
const user = userEvent.setup();
70+
await user.click(screen.getByText(/Reload Page/i));
71+
expect(refetch).toHaveBeenCalled();
72+
});
73+
});
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { useState } from 'react';
2+
import { FallbackProps } from 'react-error-boundary';
3+
import { getConfig } from '@edx/frontend-platform';
4+
import { useIntl } from '@edx/frontend-platform/i18n';
5+
import {
6+
Button, Container, Hyperlink, Row,
7+
} from '@openedx/paragon';
8+
import { CustomErrors, ERROR_STATUS } from '@src/constants';
9+
10+
import messages from './messages';
11+
12+
const getErrorConfig = ({ errorMessage, errorStatus }) => {
13+
if (errorMessage === CustomErrors.NO_ACCESS || ERROR_STATUS.NO_ACCESS.includes(errorStatus)) {
14+
return ({
15+
title: messages['error.page.title.noAccess'],
16+
description: messages['error.page.message.noAccess'],
17+
statusCode: errorStatus || ERROR_STATUS.NO_ACCESS[0],
18+
showBackButton: true,
19+
});
20+
}
21+
if (errorMessage === CustomErrors.NOT_FOUND || ERROR_STATUS.NOT_FOUND.includes(errorStatus)) {
22+
return ({
23+
title: messages['error.page.title.notFound'],
24+
description: messages['error.page.message.notFound'],
25+
statusCode: errorStatus || ERROR_STATUS.NOT_FOUND[0],
26+
showBackButton: true,
27+
});
28+
}
29+
if (errorMessage === CustomErrors.SERVER_ERROR || ERROR_STATUS.SERVER_ERROR.includes(errorStatus)) {
30+
return ({
31+
title: messages['error.page.title.server'],
32+
description: messages['error.page.message.server'],
33+
statusCode: errorStatus || ERROR_STATUS.SERVER_ERROR[0],
34+
showBackButton: true,
35+
showReloadButton: true,
36+
});
37+
}
38+
return ({
39+
title: messages['error.page.title.generic'],
40+
description: messages['error.page.message.generic'],
41+
showBackButton: true,
42+
showReloadButton: true,
43+
});
44+
};
45+
46+
const ErrorPage = ({ error, resetErrorBoundary }: FallbackProps) => {
47+
const intl = useIntl();
48+
const [reloading, setReloading] = useState(false);
49+
50+
const errorStatus: number = error?.customAttributes?.httpErrorStatus;
51+
const errorMessage: string = error?.message;
52+
const {
53+
title, description, statusCode, showBackButton, showReloadButton,
54+
} = getErrorConfig({ errorMessage, errorStatus });
55+
56+
const handleReload = () => {
57+
setReloading(true);
58+
resetErrorBoundary();
59+
};
60+
return (
61+
<Container className="d-flex flex-column align-items-center justify-content-center min-vh-100 bg-light-200">
62+
<h1 className="display-4 text-primary-200">{statusCode}</h1>
63+
<h1 className="text-primary">{intl.formatMessage(title)}</h1>
64+
<p>{intl.formatMessage(description)}</p>
65+
<Row>
66+
{showReloadButton && (
67+
<Button
68+
className="m-2"
69+
disabled={reloading}
70+
onClick={handleReload}
71+
>
72+
{intl.formatMessage(messages['error.page.action.reload'])}
73+
</Button>
74+
)}
75+
{showBackButton && (
76+
<Button
77+
as={Hyperlink}
78+
destination={`${getConfig().COURSE_AUTHORING_MICROFRONTEND_URL}/libraries`}
79+
className="m-2"
80+
variant={showReloadButton ? 'outline-primary' : 'primary'}
81+
>
82+
{intl.formatMessage(messages['error.page.action.back'])}
83+
</Button>
84+
)}
85+
86+
</Row>
87+
</Container>
88+
);
89+
};
90+
91+
const LibrariesErrorFallback = (props: FallbackProps) => <ErrorPage {...props} />;
92+
export default LibrariesErrorFallback;
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { defineMessages } from '@edx/frontend-platform/i18n';
2+
3+
const messages = defineMessages({
4+
'error.page.title.noAccess': {
5+
id: 'error.page.tile.noAccess',
6+
defaultMessage: 'Access Denied',
7+
description: 'Title error when user does not have access to view',
8+
},
9+
'error.page.message.noAccess': {
10+
id: 'error.page.message.noAccess',
11+
defaultMessage: 'You do not have permission to view this page.',
12+
description: 'Error message when user does not have access to view',
13+
},
14+
'error.page.title.notFound': {
15+
id: 'error.page.tile.notFound',
16+
defaultMessage: 'Page Not Found',
17+
description: 'Error the resource is not found',
18+
},
19+
'error.page.message.notFound': {
20+
id: 'error.page.message.notFound',
21+
defaultMessage: 'The library you are looking for could not be found.',
22+
description: 'Error message when the resource is not found',
23+
},
24+
'error.page.title.server': {
25+
id: 'error.page.tile.server',
26+
defaultMessage: 'Something went wrong',
27+
description: 'Title for server error',
28+
},
29+
'error.page.message.server': {
30+
id: 'error.page.message.server.error',
31+
defaultMessage: 'We\'re experiencing an internal server problem. Please try again later',
32+
description: 'Server error message for unexpected errors',
33+
},
34+
'error.page.title.generic': {
35+
id: 'error.page.tile.generic',
36+
defaultMessage: 'Something went wrong',
37+
description: 'Title for unexpected error',
38+
},
39+
'error.page.message.generic': {
40+
id: 'error.page.message.server',
41+
defaultMessage: 'An unexpected error occurred. Please click the button below to refresh the page.',
42+
description: 'Error message for unexpected errors',
43+
},
44+
'error.page.action.reload': {
45+
id: 'error.page.action.reload',
46+
defaultMessage: 'Reload Page',
47+
description: 'Label for reload action',
48+
},
49+
'error.page.action.back': {
50+
id: 'error.page.action.back',
51+
defaultMessage: 'Back to Libraries',
52+
description: 'Label for return to libraries action',
53+
},
54+
});
55+
56+
export default messages;

src/authz-module/libraries-manager/context.test.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useParams } from 'react-router-dom';
44
import { useValidateUserPermissions } from '@src/data/hooks';
55
import { renderWrapper } from '@src/setupTest';
66
import { usePermissionsByRole } from '@src/authz-module/data/hooks';
7+
import { CustomErrors } from '@src/constants';
78
import { LibraryAuthZProvider, useLibraryAuthZ } from './context';
89

910
jest.mock('react-router-dom', () => ({
@@ -130,7 +131,7 @@ describe('LibraryAuthZProvider', () => {
130131
<TestComponent />
131132
</LibraryAuthZProvider>,
132133
);
133-
}).toThrow('NoAccess');
134+
}).toThrow(CustomErrors.NO_ACCESS);
134135
});
135136

136137
it('provides context when user can view but not manage team', () => {
@@ -161,7 +162,7 @@ describe('LibraryAuthZProvider', () => {
161162
</LibraryAuthZProvider>
162163
</ErrorBoundary>,
163164
);
164-
}).toThrow('MissingLibrary');
165+
}).toThrow(CustomErrors.NOT_FOUND);
165166
});
166167

167168
it('throws error when useLibraryAuthZ is used outside provider', () => {

src/authz-module/libraries-manager/context.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { AppContext } from '@edx/frontend-platform/react';
66
import { useValidateUserPermissions } from '@src/data/hooks';
77
import { usePermissionsByRole } from '@src/authz-module/data/hooks';
88
import { PermissionMetadata, ResourceMetadata, Role } from 'types';
9+
import { CustomErrors } from '@src/constants';
910
import { libraryPermissions, libraryResourceTypes, libraryRolesMetadata } from './constants';
1011

1112
const LIBRARY_TEAM_PERMISSIONS = ['view_library_team', 'manage_library_team'];
@@ -38,15 +39,15 @@ export const LibraryAuthZProvider: React.FC<AuthZProviderProps> = ({ children }:
3839

3940
// TODO: Implement a custom error view
4041
if (!libraryId) {
41-
throw new Error('MissingLibrary');
42+
throw new Error(CustomErrors.NOT_FOUND);
4243
}
4344
const permissions = LIBRARY_TEAM_PERMISSIONS.map(action => ({ action, scope: libraryId }));
4445

4546
const { data: userPermissions } = useValidateUserPermissions(permissions);
4647
const [{ allowed: canViewTeam }, { allowed: canManageTeam }] = userPermissions;
4748

4849
if (!canViewTeam && !canManageTeam) {
49-
throw new Error('NoAccess');
50+
throw new Error(CustomErrors.NO_ACCESS);
5051
}
5152

5253
const { data: libraryRoles } = usePermissionsByRole(libraryId);

0 commit comments

Comments
 (0)