Skip to content
Merged
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
13 changes: 8 additions & 5 deletions src/Main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { CurrentAppProvider, getSiteConfig, useIntl } from '@openedx/frontend-ba
import { Helmet } from 'react-helmet';
import { Outlet } from 'react-router-dom';
import { AlertProvider } from './providers/AlertProvider';
import { AccessErrorProvider } from './providers/AccessErrorProvider';
import { appId } from './constants';
import messages from './messages';
import PageWrapper from './pageWrapper/PageWrapper';
Expand All @@ -20,11 +21,13 @@ const Main = () => {
</title>
</Helmet>
<AlertProvider>
<main className="d-flex flex-column flex-grow-1">
<PageWrapper>
<Outlet />
</PageWrapper>
</main>
<AccessErrorProvider>
<main className="d-flex flex-column flex-grow-1">
<PageWrapper>
<Outlet />
</PageWrapper>
</main>
</AccessErrorProvider>
</AlertProvider>
</CurrentAppProvider>
);
Expand Down
1 change: 1 addition & 0 deletions src/data/apiHook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const useCourseInfo = (courseId: string) => (
enabled: !!courseId,
refetchOnWindowFocus: false,
refetchOnMount: false,
retry: false,
})
);

Expand Down
7 changes: 7 additions & 0 deletions src/data/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const isForbiddenError = (error: any): boolean => {
return error?.response?.status === 403 || error?.status === 403;
};

export const isUnauthorizedError = (error: any): boolean => {
return error?.response?.status === 401 || error?.status === 401;
};
6 changes: 6 additions & 0 deletions src/instructorNav/InstructorNav.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ jest.mock('@src/data/apiHook', () => ({
jest.mock('@src/providers/AlertProvider', () => ({
useAlert: jest.fn(),
}));
jest.mock('@src/providers/AccessErrorProvider', () => ({
useAccessError: jest.fn(() => ({
clearError: jest.fn(),
errorType: null,
})),
}));
jest.mock('../slots/SlotUtils', () => ({
useWidgetProps: jest.fn(),
}));
Expand Down
17 changes: 13 additions & 4 deletions src/instructorNav/InstructorNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useParams, Link } from 'react-router-dom';
import { Nav, Navbar, Skeleton } from '@openedx/paragon';
import { useCourseInfo } from '@src/data/apiHook';
import { useAlert } from '@src/providers/AlertProvider';
import { useAccessError } from '@src/providers/AccessErrorProvider';
import { useWidgetProps } from '@src/slots/SlotUtils';

export interface TabProps {
Expand All @@ -17,9 +18,17 @@ const InstructorNav = () => {
const { data: courseInfo, isLoading } = useCourseInfo(courseId);
const widgetPropsArray = useWidgetProps('org.openedx.frontend.slot.instructorDashboard.tabs.v1') as TabProps[];
const { clearAlerts } = useAlert();
const { clearError, errorType } = useAccessError();

const handleTabClick = () => {
clearAlerts();
clearError();
};

const hasError = errorType !== null;

const sortedTabs = useMemo(() => {
if (isLoading) return [];
if (isLoading || hasError) return [];
const apiTabs: TabProps[] = courseInfo?.tabs ?? [];
const tabMap = new Map<string, TabProps>();

Expand All @@ -41,13 +50,13 @@ const InstructorNav = () => {

// Tabs are sorted by sortOrder, with a fallback to 1000 to be placed at the end for tabs that don't have sortOrder defined (to avoid NaN issues)
return allTabs.sort((a, b) => (a.sortOrder ?? 1000) - (b.sortOrder ?? 1000));
}, [courseInfo?.tabs, isLoading, widgetPropsArray]);
}, [courseInfo?.tabs, isLoading, widgetPropsArray, hasError]);

if (isLoading) {
return <Skeleton className="lead" />;
}

if (sortedTabs.length === 0) return null;
if (sortedTabs.length === 0 || hasError) return null;

return (
<Navbar expand="md" className="py-0">
Expand All @@ -68,7 +77,7 @@ const InstructorNav = () => {
: { href: tab.url }
)}
active={tab.tabId === tabId}
onClick={() => clearAlerts()}
onClick={handleTabClick}
>
{tab.title}
</Nav.Link>
Expand Down
11 changes: 8 additions & 3 deletions src/pageWrapper/PageWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,21 @@ import { useIntl } from '@openedx/frontend-base';
import messages from './messages';
import { Container } from '@openedx/paragon';
import InstructorNav from '@src/instructorNav/InstructorNav';
import { AccessErrorGuard } from '@src/providers/AccessErrorProvider';
import AccessErrorObserver from '@src/providers/AccessErrorObserver';

const PageWrapper = ({ children }: { children: React.ReactNode }) => {
const { formatMessage } = useIntl();
return (
<Container size="xl" fluid>
<AccessErrorObserver />
<h2 className="text-primary-700 m-4">{formatMessage(messages.pageTitle)}</h2>
<InstructorNav />
<div className="m-4">
{children}
</div>
<AccessErrorGuard>
<div className="m-4">
{children}
</div>
</AccessErrorGuard>
</Container>
);
};
Expand Down
92 changes: 92 additions & 0 deletions src/providers/AccessErrorObserver.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { render } from '@testing-library/react';
import AccessErrorObserver from './AccessErrorObserver';
import { useCourseInfo } from '@src/data/apiHook';
import { useAccessError } from '@src/providers/AccessErrorProvider';

jest.mock('react-router-dom', () => ({
useParams: () => ({ courseId: 'course-v1:edX+DemoX+Demo_Course' }),
}));

jest.mock('@src/data/apiHook', () => ({
useCourseInfo: jest.fn(),
}));

jest.mock('@src/providers/AccessErrorProvider', () => ({
useAccessError: jest.fn(),
}));

const mockSetErrorType = jest.fn();
const mockSetLoading = jest.fn();

describe('AccessErrorObserver', () => {
beforeEach(() => {
jest.clearAllMocks();
(useAccessError as jest.Mock).mockReturnValue({
setErrorType: mockSetErrorType,
setLoading: mockSetLoading,
});
});

it('renders nothing', () => {
(useCourseInfo as jest.Mock).mockReturnValue({ isLoading: false, error: null });

const { container } = render(<AccessErrorObserver />);
expect(container).toBeEmptyDOMElement();
});

it('sets loading state when query is loading', () => {
(useCourseInfo as jest.Mock).mockReturnValue({ isLoading: true, error: null });

render(<AccessErrorObserver />);

expect(mockSetLoading).toHaveBeenCalledWith(true);
expect(mockSetErrorType).toHaveBeenCalledWith(null);
});

it('sets errorType to forbidden on 403 error', () => {
const error = { response: { status: 403 } };
(useCourseInfo as jest.Mock).mockReturnValue({ isLoading: false, error });

render(<AccessErrorObserver />);

expect(mockSetLoading).toHaveBeenCalledWith(false);
expect(mockSetErrorType).toHaveBeenCalledWith('forbidden');
});

it('sets errorType to unauthorized on 401 error', () => {
const error = { response: { status: 401 } };
(useCourseInfo as jest.Mock).mockReturnValue({ isLoading: false, error });

render(<AccessErrorObserver />);

expect(mockSetLoading).toHaveBeenCalledWith(false);
expect(mockSetErrorType).toHaveBeenCalledWith('unauthorized');
});

it('clears errorType when there is no error', () => {
(useCourseInfo as jest.Mock).mockReturnValue({ isLoading: false, error: null });

render(<AccessErrorObserver />);

expect(mockSetLoading).toHaveBeenCalledWith(false);
expect(mockSetErrorType).toHaveBeenCalledWith(null);
});

it('sets errorType to generic for non-401/403 errors', () => {
const error = { response: { status: 500 } };
(useCourseInfo as jest.Mock).mockReturnValue({ isLoading: false, error });

render(<AccessErrorObserver />);

expect(mockSetErrorType).toHaveBeenCalledWith('generic');
});

it('handles error with status directly on error object', () => {
const error = { status: 403 };
(useCourseInfo as jest.Mock).mockReturnValue({ isLoading: false, error });

render(<AccessErrorObserver />);

expect(mockSetErrorType).toHaveBeenCalledWith('forbidden');
});
});
35 changes: 35 additions & 0 deletions src/providers/AccessErrorObserver.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { useCourseInfo } from '@src/data/apiHook';
import { isForbiddenError, isUnauthorizedError } from '@src/data/utils';
import { useAccessError } from '@src/providers/AccessErrorProvider';

/**
* Observes the courseInfo query and syncs 401/403 errors with the AccessErrorProvider.
* This component must be rendered inside AccessErrorProvider.
* By keeping this logic here (instead of inside useCourseInfo), the hook stays
* decoupled from the provider and can be used in slots or other contexts
* that live outside the provider tree.
*/
const AccessErrorObserver = () => {
const { courseId = '' } = useParams<{ courseId: string }>();
const { isLoading, error } = useCourseInfo(courseId);
const { setErrorType, setLoading } = useAccessError();

useEffect(() => {
setLoading(isLoading);
if (error && isForbiddenError(error)) {
setErrorType('forbidden');
} else if (error && isUnauthorizedError(error)) {
setErrorType('unauthorized');
} else if (error) {
setErrorType('generic');
} else {
setErrorType(null);
}
}, [isLoading, error, setErrorType, setLoading]);

return null;
};

export default AccessErrorObserver;
94 changes: 94 additions & 0 deletions src/providers/AccessErrorProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { render, screen, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@openedx/frontend-base';
import { AccessErrorProvider, AccessErrorGuard, useAccessError } from './AccessErrorProvider';

const TestComponent = () => {
const { errorType, setErrorType } = useAccessError();

return (
<div>
<p data-testid="error-status">{errorType ?? 'allowed'}</p>
<button
data-testid="trigger-forbidden"
onClick={() => setErrorType('forbidden')}
>
Trigger Forbidden
</button>
<button
data-testid="trigger-unauthorized"
onClick={() => setErrorType('unauthorized')}
>
Trigger Unauthorized
</button>
<AccessErrorGuard>
<div data-testid="protected-content">Protected Content</div>
</AccessErrorGuard>
</div>
);
};

describe('AccessErrorProvider', () => {
it('should render protected content when no error', () => {
render(
<IntlProvider locale="en" messages={{}}>
<AccessErrorProvider>
<TestComponent />
</AccessErrorProvider>
</IntlProvider>
);

expect(screen.getByTestId('error-status')).toHaveTextContent('allowed');
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
});

it('should show forbidden error message when errorType is forbidden', async () => {
const user = userEvent.setup();

render(
<IntlProvider locale="en" messages={{}}>
<AccessErrorProvider>
<TestComponent />
</AccessErrorProvider>
</IntlProvider>
);

await act(async () => {
await user.click(screen.getByTestId('trigger-forbidden'));
});

expect(screen.getByTestId('error-status')).toHaveTextContent('forbidden');
expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument();
expect(screen.getByText('Access Denied')).toBeInTheDocument();
});

it('should show unauthorized error message when errorType is unauthorized', async () => {
const user = userEvent.setup();

render(
<IntlProvider locale="en" messages={{}}>
<AccessErrorProvider>
<TestComponent />
</AccessErrorProvider>
</IntlProvider>
);

await act(async () => {
await user.click(screen.getByTestId('trigger-unauthorized'));
});

expect(screen.getByTestId('error-status')).toHaveTextContent('unauthorized');
expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument();
expect(screen.getByText('Unauthorized')).toBeInTheDocument();
});

it('should throw error when used outside provider', () => {
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});

expect(() => {
render(<TestComponent />);
}).toThrow('useAccessError must be used within a AccessErrorProvider');

spy.mockRestore();
});
});
Loading
Loading