richie-education 2.12.0 → 2.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/js/components/CourseRunEnrollment/index.spec.tsx +8 -6
- package/js/components/CourseRunEnrollment/index.tsx +6 -5
- package/js/components/PaginateCourseSearch/index.tsx +4 -10
- package/js/components/Root/index.spec.tsx +37 -10
- package/js/components/Root/index.tsx +10 -3
- package/js/components/UserLogin/index.spec.tsx +1 -1
- package/js/components/UserLogin/index.tsx +1 -1
- package/js/data/CourseCodeProvider/index.spec.tsx +24 -0
- package/js/data/CourseCodeProvider/index.tsx +31 -0
- package/js/data/JoanieApiProvider/index.spec.tsx +34 -0
- package/js/data/JoanieApiProvider/index.tsx +31 -0
- package/js/data/SessionProvider/BaseSessionProvider.tsx +87 -0
- package/js/data/SessionProvider/JoanieSessionProvider.spec.tsx +101 -0
- package/js/data/SessionProvider/JoanieSessionProvider.tsx +133 -0
- package/js/data/SessionProvider/SessionContext.tsx +18 -0
- package/js/data/SessionProvider/index.spec.tsx +195 -0
- package/js/data/SessionProvider/index.tsx +55 -0
- package/js/data/SessionProvider/no-authentication.spec.tsx +37 -0
- package/js/data/{useEnrollment → useCourseEnrollment}/index.spec.tsx +6 -6
- package/js/data/useCourseEnrollment/index.ts +54 -0
- package/js/hooks/useAddresses.ts +56 -0
- package/js/hooks/useCourse.ts +50 -0
- package/js/hooks/useCreditCards.ts +57 -0
- package/js/hooks/useEnrollment.ts +37 -0
- package/js/hooks/useOrders.ts +56 -0
- package/js/settings.ts +3 -1
- package/js/testSetup.ts +11 -0
- package/js/types/Joanie.ts +247 -0
- package/js/types/User.ts +2 -1
- package/js/types/WebAnalytics.ts +17 -0
- package/js/types/api.ts +8 -6
- package/js/types/commonDataProps.ts +4 -1
- package/js/types/web-analytics/google_analytics.d.ts +17 -0
- package/js/types/web-analytics/google_tag_manager.d.ts +19 -0
- package/js/utils/api/{courseEnrollment.ts → enrollment.ts} +0 -0
- package/js/utils/api/joanie.ts +273 -0
- package/js/utils/api/lms/openedx-dogwood.spec.ts +28 -0
- package/js/utils/api/lms/openedx-dogwood.ts +4 -4
- package/js/utils/api/lms/openedx-fonzie.spec.ts +43 -0
- package/js/utils/api/lms/openedx-fonzie.ts +17 -4
- package/js/utils/api/lms/openedx-hawthorn.spec.ts +16 -12
- package/js/utils/api/lms/openedx-hawthorn.ts +56 -59
- package/js/utils/api/web-analytics/base.ts +23 -0
- package/js/utils/api/web-analytics/google_analytics.spec.ts +24 -0
- package/js/utils/api/web-analytics/google_analytics.ts +26 -0
- package/js/utils/api/web-analytics/google_tag_manager.spec.ts +23 -0
- package/js/utils/api/web-analytics/google_tag_manager.ts +33 -0
- package/js/utils/api/web-analytics/index.ts +24 -0
- package/js/utils/api/web-analytics/no_provider.spec.ts +13 -0
- package/js/utils/api/web-analytics/unknown_provider.spec.ts +15 -0
- package/js/utils/errors/ErrorBoundary.tsx +35 -0
- package/js/utils/errors/HttpError.ts +12 -0
- package/js/utils/react-query/createQueryClient.ts +7 -3
- package/js/utils/react-query/useLocalizedQueryKey.ts +15 -0
- package/js/utils/react-query/useSessionKey.ts +20 -0
- package/js/utils/react-query/useSessionMutation/index.spec.tsx +90 -0
- package/js/utils/react-query/useSessionMutation/index.ts +37 -0
- package/js/utils/react-query/useSessionQuery/index.spec.tsx +87 -0
- package/js/utils/react-query/useSessionQuery/index.ts +72 -0
- package/js/utils/test/factories.ts +139 -1
- package/js/utils/test/isTestEnv.ts +2 -0
- package/js/utils/usePrevious.ts +19 -0
- package/package.json +23 -23
- package/js/data/useEnrollment/index.ts +0 -38
- package/js/data/useSession/index.spec.tsx +0 -146
- package/js/data/useSession/index.tsx +0 -109
- package/js/data/useSession/no-authentication.spec.tsx +0 -47
- package/js/utils/react-query/createSessionStoragePersistor/index.spec.ts +0 -125
- package/js/utils/react-query/createSessionStoragePersistor/index.ts +0 -47
|
@@ -11,7 +11,7 @@ import createQueryClient from 'utils/react-query/createQueryClient';
|
|
|
11
11
|
import { REACT_QUERY_SETTINGS } from 'settings';
|
|
12
12
|
import { handle } from 'utils/errors/handle';
|
|
13
13
|
import context from 'utils/context';
|
|
14
|
-
import { SessionProvider } from 'data/
|
|
14
|
+
import { SessionProvider } from 'data/SessionProvider';
|
|
15
15
|
import CourseRunEnrollment from '.';
|
|
16
16
|
|
|
17
17
|
jest.mock('utils/errors/handle');
|
|
@@ -147,17 +147,19 @@ describe('<CourseRunEnrollment />', () => {
|
|
|
147
147
|
|
|
148
148
|
const button = screen.getByRole('button', { name: 'Enroll now' });
|
|
149
149
|
|
|
150
|
-
const enrollmentAction = new Deferred();
|
|
151
|
-
fetchMock.post(`${endpoint}/api/enrollment/v1/enrollment`,
|
|
152
|
-
fireEvent.click(button);
|
|
150
|
+
// const enrollmentAction = new Deferred();
|
|
151
|
+
fetchMock.post(`${endpoint}/api/enrollment/v1/enrollment`, 500);
|
|
153
152
|
|
|
154
153
|
await act(async () => {
|
|
155
|
-
|
|
154
|
+
expect(() => fireEvent.click(button)).not.toThrow();
|
|
155
|
+
// enrollmentAction.reject('500 - Internal Server Error');
|
|
156
156
|
});
|
|
157
157
|
|
|
158
158
|
screen.getByRole('button', { name: 'Enroll now' });
|
|
159
159
|
screen.getByText('Your enrollment request failed.');
|
|
160
|
-
expect(mockHandle).toHaveBeenCalledWith(
|
|
160
|
+
expect(mockHandle).toHaveBeenCalledWith(
|
|
161
|
+
new Error('[SET - Enrollment] > 500 - Internal Server Error'),
|
|
162
|
+
);
|
|
161
163
|
});
|
|
162
164
|
|
|
163
165
|
it('shows a link to the course if the user is already enrolled', async () => {
|
|
@@ -2,13 +2,13 @@ import React, { useCallback, useEffect, useReducer } from 'react';
|
|
|
2
2
|
import { defineMessages, FormattedMessage } from 'react-intl';
|
|
3
3
|
|
|
4
4
|
import { Spinner } from 'components/Spinner';
|
|
5
|
-
import { useSession } from 'data/
|
|
5
|
+
import { useSession } from 'data/SessionProvider';
|
|
6
6
|
import { Priority } from 'types';
|
|
7
7
|
import { User } from 'types/User';
|
|
8
8
|
import { Maybe, Nullable } from 'types/utils';
|
|
9
9
|
import { handle } from 'utils/errors/handle';
|
|
10
10
|
import { CommonDataProps } from 'types/commonDataProps';
|
|
11
|
-
import
|
|
11
|
+
import useCourseEnrollment from 'data/useCourseEnrollment';
|
|
12
12
|
|
|
13
13
|
const messages = defineMessages({
|
|
14
14
|
enroll: {
|
|
@@ -143,7 +143,7 @@ const reducer = ({ step, context }: ReducerState, action: ReducerAction): Reduce
|
|
|
143
143
|
|
|
144
144
|
const CourseRunEnrollment: React.FC<CourseRunEnrollmentProps & CommonDataProps> = (props) => {
|
|
145
145
|
const { user, login } = useSession();
|
|
146
|
-
const { enrollmentIsActive, setEnrollment } =
|
|
146
|
+
const { enrollmentIsActive, setEnrollment } = useCourseEnrollment(props.courseRun.resource_link);
|
|
147
147
|
|
|
148
148
|
const [
|
|
149
149
|
{
|
|
@@ -161,10 +161,11 @@ const CourseRunEnrollment: React.FC<CourseRunEnrollmentProps & CommonDataProps>
|
|
|
161
161
|
const enroll = useCallback(async () => {
|
|
162
162
|
dispatch({ type: ActionType.ENROLL });
|
|
163
163
|
if (courseRun && currentUser) {
|
|
164
|
-
const
|
|
164
|
+
const isEnrolled = await setEnrollment().catch(() => undefined);
|
|
165
|
+
|
|
165
166
|
dispatch({
|
|
166
167
|
type: ActionType.UPDATE_CONTEXT,
|
|
167
|
-
payload: { isEnrolled
|
|
168
|
+
payload: { isEnrolled },
|
|
168
169
|
});
|
|
169
170
|
}
|
|
170
171
|
}, [courseRun, currentUser, dispatch]);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Fragment
|
|
2
|
-
import { defineMessages, FormattedMessage } from 'react-intl';
|
|
1
|
+
import { Fragment } from 'react';
|
|
2
|
+
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|
3
3
|
|
|
4
4
|
import { CourseSearchParamsAction, useCourseSearchParams } from 'data/useCourseSearchParams';
|
|
5
5
|
|
|
@@ -49,11 +49,8 @@ interface PaginateCourseSearchProps {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
export const PaginateCourseSearch = ({ courseSearchTotalCount }: PaginateCourseSearchProps) => {
|
|
52
|
-
// Generate a unique ID per instance to ensure our aria-labelledby do not break if there are two
|
|
53
|
-
// or more instances of <PaginateCourseSearch /> on the page
|
|
54
|
-
const [componentId] = useState(Math.random());
|
|
55
52
|
const { courseSearchParams, dispatchCourseSearchParamsUpdate } = useCourseSearchParams();
|
|
56
|
-
|
|
53
|
+
const intl = useIntl();
|
|
57
54
|
// Extract pagination information from params and search results meta
|
|
58
55
|
const limit = Number(courseSearchParams.limit);
|
|
59
56
|
const offset = Number(courseSearchParams.offset);
|
|
@@ -90,10 +87,7 @@ export const PaginateCourseSearch = ({ courseSearchTotalCount }: PaginateCourseS
|
|
|
90
87
|
|
|
91
88
|
return (
|
|
92
89
|
<div className="pagination">
|
|
93
|
-
<
|
|
94
|
-
<FormattedMessage {...messages.pagination} />
|
|
95
|
-
</div>
|
|
96
|
-
<nav aria-labelledby={`pagination-label-${componentId}`}>
|
|
90
|
+
<nav aria-label={intl.formatMessage(messages.pagination)}>
|
|
97
91
|
<ul className="pagination__list">
|
|
98
92
|
{pageList.map((page, index) => (
|
|
99
93
|
<Fragment key={page}>
|
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
import { PropsWithChildren } from 'react';
|
|
1
|
+
import type { PropsWithChildren } from 'react';
|
|
2
2
|
import { IntlProvider } from 'react-intl';
|
|
3
|
-
|
|
4
3
|
import { findByText, render } from '@testing-library/react';
|
|
5
|
-
import { ContextFactory } from 'utils/test/factories';
|
|
4
|
+
import { ContextFactory as mockContextFactory } from 'utils/test/factories';
|
|
5
|
+
import { handle as mockHandle } from 'utils/errors/handle';
|
|
6
|
+
import { noop } from 'utils';
|
|
7
|
+
import { Root } from '.';
|
|
6
8
|
|
|
9
|
+
jest.mock('utils/context', () => ({
|
|
10
|
+
__esModule: true,
|
|
11
|
+
default: mockContextFactory({ authentication: undefined }).generate(),
|
|
12
|
+
}));
|
|
13
|
+
jest.mock('utils/errors/handle');
|
|
7
14
|
jest.mock('components/UserLogin', () => ({
|
|
8
15
|
__esModule: true,
|
|
9
16
|
default: () => 'user login component rendered',
|
|
@@ -15,19 +22,15 @@ jest.mock('components/RootSearchSuggestField', () => ({
|
|
|
15
22
|
`root search suggest field component rendered with ${exampleProp}`,
|
|
16
23
|
}));
|
|
17
24
|
|
|
18
|
-
jest.mock('data/
|
|
25
|
+
jest.mock('data/SessionProvider', () => ({
|
|
19
26
|
__esModule: true,
|
|
20
27
|
SessionProvider: (props: PropsWithChildren<any>) => props.children,
|
|
21
28
|
}));
|
|
22
29
|
|
|
23
30
|
describe('<Root />', () => {
|
|
24
|
-
window.__richie_frontend_context__ = {
|
|
25
|
-
context: ContextFactory({ authentication: undefined }).generate(),
|
|
26
|
-
};
|
|
27
|
-
const { Root } = require('.');
|
|
28
|
-
|
|
29
31
|
beforeEach(() => {
|
|
30
|
-
jest.spyOn(console, '
|
|
32
|
+
jest.spyOn(console, 'error').mockImplementation(noop);
|
|
33
|
+
jest.spyOn(console, 'warn').mockImplementation(noop);
|
|
31
34
|
});
|
|
32
35
|
|
|
33
36
|
afterEach(() => {
|
|
@@ -88,4 +91,28 @@ describe('<Root />', () => {
|
|
|
88
91
|
'Failed to load React component: no such component in Library UserFeedback',
|
|
89
92
|
);
|
|
90
93
|
});
|
|
94
|
+
|
|
95
|
+
it('renders properly components even if one of them raises an error', async () => {
|
|
96
|
+
// Create a <UserLogin /> component which renders properly
|
|
97
|
+
const userLoginContainer = document.createElement('div');
|
|
98
|
+
userLoginContainer.setAttribute('class', 'richie-react richie-react--user-login');
|
|
99
|
+
document.body.append(userLoginContainer);
|
|
100
|
+
// On the other hand, <Search /> component raises an error
|
|
101
|
+
jest.doMock('components/Search', () => {
|
|
102
|
+
throw Error('Failed to render Search component.');
|
|
103
|
+
});
|
|
104
|
+
const searchFailingComponent = document.createElement('div');
|
|
105
|
+
searchFailingComponent.setAttribute('class', 'richie-react richie-react--search');
|
|
106
|
+
document.body.append(searchFailingComponent);
|
|
107
|
+
|
|
108
|
+
// Render the root component, passing our real element and our bogus one
|
|
109
|
+
render(
|
|
110
|
+
<IntlProvider locale="en">
|
|
111
|
+
<Root richieReactSpots={[userLoginContainer, searchFailingComponent]} />
|
|
112
|
+
</IntlProvider>,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
await findByText(userLoginContainer, 'user login component rendered');
|
|
116
|
+
expect(mockHandle).toHaveBeenCalledWith(new Error('Failed to render Search component.'));
|
|
117
|
+
});
|
|
91
118
|
});
|
|
@@ -4,8 +4,10 @@ import startCase from 'lodash-es/startCase';
|
|
|
4
4
|
import { lazy, Suspense } from 'react';
|
|
5
5
|
import ReactDOM from 'react-dom';
|
|
6
6
|
import { HistoryProvider } from 'data/useHistory';
|
|
7
|
-
import { SessionProvider } from 'data/
|
|
7
|
+
import { SessionProvider } from 'data/SessionProvider';
|
|
8
8
|
import { Spinner } from 'components/Spinner';
|
|
9
|
+
import ErrorBoundary from 'utils/errors/ErrorBoundary';
|
|
10
|
+
import context from 'utils/context';
|
|
9
11
|
|
|
10
12
|
const CourseRunEnrollment = lazy(() => import('components/CourseRunEnrollment'));
|
|
11
13
|
const LanguageSelector = lazy(() => import('components/LanguageSelector'));
|
|
@@ -78,10 +80,15 @@ export const Root = ({ richieReactSpots }: RootProps) => {
|
|
|
78
80
|
|
|
79
81
|
// Add context to props if they do not already include it
|
|
80
82
|
if (!props.context) {
|
|
81
|
-
props.context =
|
|
83
|
+
props.context = context;
|
|
82
84
|
}
|
|
83
85
|
|
|
84
|
-
return ReactDOM.createPortal(
|
|
86
|
+
return ReactDOM.createPortal(
|
|
87
|
+
<ErrorBoundary>
|
|
88
|
+
<Component {...props} />
|
|
89
|
+
</ErrorBoundary>,
|
|
90
|
+
element,
|
|
91
|
+
);
|
|
85
92
|
} else {
|
|
86
93
|
// Emit a warning at runtime when we fail to find a matching component for an element that required one
|
|
87
94
|
console.warn('Failed to load React component: no such component in Library ' + componentName);
|
|
@@ -14,7 +14,7 @@ import { Deferred } from 'utils/test/deferred';
|
|
|
14
14
|
import createQueryClient from 'utils/react-query/createQueryClient';
|
|
15
15
|
import context from 'utils/context';
|
|
16
16
|
import { REACT_QUERY_SETTINGS } from 'settings';
|
|
17
|
-
import { SessionProvider } from 'data/
|
|
17
|
+
import { SessionProvider } from 'data/SessionProvider';
|
|
18
18
|
import UserLogin from '.';
|
|
19
19
|
|
|
20
20
|
jest.mock('utils/errors/handle', () => ({
|
|
@@ -4,7 +4,7 @@ import { defineMessages, FormattedMessage, MessageDescriptor, useIntl } from 're
|
|
|
4
4
|
|
|
5
5
|
import { Spinner } from 'components/Spinner';
|
|
6
6
|
import { UserMenu } from 'components/UserMenu';
|
|
7
|
-
import { useSession } from 'data/
|
|
7
|
+
import { useSession } from 'data/SessionProvider';
|
|
8
8
|
import { CommonDataProps } from 'types/commonDataProps';
|
|
9
9
|
|
|
10
10
|
const messages: { [key: string]: MessageDescriptor } = defineMessages({
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { CourseCodeProviderProps } from 'data/CourseCodeProvider/index';
|
|
2
|
+
import { CourseCodeProvider, useCourseCode } from 'data/CourseCodeProvider/index';
|
|
3
|
+
import type { PropsWithChildren } from 'react';
|
|
4
|
+
import { renderHook } from '@testing-library/react-hooks';
|
|
5
|
+
|
|
6
|
+
describe('useCourseCode', () => {
|
|
7
|
+
it('returns the course code stored within CourseCodeProvider', () => {
|
|
8
|
+
const { result } = renderHook(useCourseCode, {
|
|
9
|
+
wrapper: ({ code, children }: PropsWithChildren<CourseCodeProviderProps>) => (
|
|
10
|
+
<CourseCodeProvider code={code}>{children}</CourseCodeProvider>
|
|
11
|
+
),
|
|
12
|
+
initialProps: { code: '00013' },
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
expect(result.current).toBe('00013');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('throws an error if it is not used within a CourseCodeProvider', () => {
|
|
19
|
+
const { result } = renderHook(useCourseCode);
|
|
20
|
+
expect(result.error).toEqual(
|
|
21
|
+
new Error('useCourse must be used within a component wrapped by a <CourseProvider />.'),
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { PropsWithChildren } from 'react';
|
|
2
|
+
import { createContext, useContext } from 'react';
|
|
3
|
+
import type { Maybe } from 'types/utils';
|
|
4
|
+
|
|
5
|
+
const Context = createContext<Maybe<string>>(undefined);
|
|
6
|
+
|
|
7
|
+
export interface CourseCodeProviderProps {
|
|
8
|
+
code: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A React Provider which aims to wrap components related to a specific course. In this
|
|
13
|
+
* way we are able to pass down course's code to children.
|
|
14
|
+
*/
|
|
15
|
+
export const CourseCodeProvider = ({
|
|
16
|
+
code,
|
|
17
|
+
children,
|
|
18
|
+
}: PropsWithChildren<CourseCodeProviderProps>) => (
|
|
19
|
+
<Context.Provider value={code}>{children}</Context.Provider>
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A hook to use within `CourseCodeProvider`. It returns the course code context.
|
|
24
|
+
*/
|
|
25
|
+
export const useCourseCode = () => {
|
|
26
|
+
const context = useContext(Context);
|
|
27
|
+
if (context === undefined) {
|
|
28
|
+
throw new Error('useCourse must be used within a component wrapped by a <CourseProvider />.');
|
|
29
|
+
}
|
|
30
|
+
return context;
|
|
31
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { PropsWithChildren } from 'react';
|
|
2
|
+
import { renderHook } from '@testing-library/react-hooks';
|
|
3
|
+
import { ContextFactory as mockContextFactory } from 'utils/test/factories';
|
|
4
|
+
import JoanieApiProvider, { useJoanieApi } from 'data/JoanieApiProvider/index';
|
|
5
|
+
|
|
6
|
+
jest.mock('utils/context', () => ({
|
|
7
|
+
__esModule: true,
|
|
8
|
+
default: mockContextFactory({
|
|
9
|
+
joanie_backend: {
|
|
10
|
+
endpoint: 'https://joanie.test',
|
|
11
|
+
},
|
|
12
|
+
}).generate(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
describe('useJoanieApi', () => {
|
|
16
|
+
it('returns the joanie api interface', () => {
|
|
17
|
+
const { result } = renderHook(useJoanieApi, {
|
|
18
|
+
wrapper: ({ children }: PropsWithChildren<{}>) => (
|
|
19
|
+
<JoanieApiProvider>{children}</JoanieApiProvider>
|
|
20
|
+
),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(result.current).toBeInstanceOf(Object);
|
|
24
|
+
expect(result.current.user).toBeInstanceOf(Object);
|
|
25
|
+
expect(result.current.courses).toBeInstanceOf(Object);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('throws an error if it is not used within a JoanieApiProvider', () => {
|
|
29
|
+
const { result } = renderHook(useJoanieApi);
|
|
30
|
+
expect(result.error).toEqual(
|
|
31
|
+
new Error('useJoanieApi must be used within a JoanieApiProvider.'),
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { PropsWithChildren } from 'react';
|
|
2
|
+
import { createContext, useContext } from 'react';
|
|
3
|
+
import type * as Joanie from 'types/Joanie';
|
|
4
|
+
import API from 'utils/api/joanie';
|
|
5
|
+
import type { Maybe } from 'types/utils';
|
|
6
|
+
|
|
7
|
+
const JoanieApiContext = createContext<Maybe<Joanie.API>>(undefined);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Provider to access to the Joanie API interface.
|
|
11
|
+
*/
|
|
12
|
+
const JoanieApiProvider = ({ children }: PropsWithChildren<{}>) => {
|
|
13
|
+
const api = API();
|
|
14
|
+
|
|
15
|
+
return <JoanieApiContext.Provider value={api}>{children}</JoanieApiContext.Provider>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Hook to use within `JoanieApiProvider`. It returns the joanie api interface.
|
|
20
|
+
*/
|
|
21
|
+
export const useJoanieApi = () => {
|
|
22
|
+
const context = useContext(JoanieApiContext);
|
|
23
|
+
|
|
24
|
+
if (context === undefined) {
|
|
25
|
+
throw new Error('useJoanieApi must be used within a JoanieApiProvider.');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return context;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export default JoanieApiProvider;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { PropsWithChildren, useCallback, useEffect, useMemo } from 'react';
|
|
2
|
+
import { AuthenticationApi } from 'utils/api/authentication';
|
|
3
|
+
import { useQuery, useQueryClient } from 'react-query';
|
|
4
|
+
import { Nullable } from 'types/utils';
|
|
5
|
+
import { User } from 'types/User';
|
|
6
|
+
import { REACT_QUERY_SETTINGS } from 'settings';
|
|
7
|
+
import usePrevious from 'utils/usePrevious';
|
|
8
|
+
import { Session } from './SessionContext';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* BaseSessionProvider
|
|
12
|
+
*
|
|
13
|
+
* @param children - Elements to render inside SessionProvider
|
|
14
|
+
*
|
|
15
|
+
* @return {Object} Session
|
|
16
|
+
* @return {Object} Session.user - authenticated user information
|
|
17
|
+
* @return {Function} Session.login - redirect to the login page
|
|
18
|
+
* @return {Function} Session.register - redirect to the register page
|
|
19
|
+
* @return {Function} Session.destroy - set Session to undefined then make a request to logout user to the authentication service
|
|
20
|
+
*/
|
|
21
|
+
const BaseSessionProvider = ({ children }: PropsWithChildren<any>) => {
|
|
22
|
+
/**
|
|
23
|
+
* `user` is:
|
|
24
|
+
* - `undefined` when we have not made the `whoami` request yet;
|
|
25
|
+
* - `null` when the user is anonymous or the request failed;
|
|
26
|
+
* - a user object when the user is logged in.
|
|
27
|
+
*/
|
|
28
|
+
const { data: user } = useQuery<Nullable<User>>('user', AuthenticationApi!.me, {
|
|
29
|
+
refetchOnWindowFocus: true,
|
|
30
|
+
staleTime: REACT_QUERY_SETTINGS.staleTimes.session,
|
|
31
|
+
});
|
|
32
|
+
const previousUserState = usePrevious(user);
|
|
33
|
+
|
|
34
|
+
const queryClient = useQueryClient();
|
|
35
|
+
|
|
36
|
+
const login = useCallback(() => {
|
|
37
|
+
queryClient.clear();
|
|
38
|
+
AuthenticationApi!.login();
|
|
39
|
+
}, [queryClient]);
|
|
40
|
+
|
|
41
|
+
const register = useCallback(() => {
|
|
42
|
+
queryClient.clear();
|
|
43
|
+
AuthenticationApi!.register();
|
|
44
|
+
}, [queryClient]);
|
|
45
|
+
|
|
46
|
+
const invalidate = useCallback(() => {
|
|
47
|
+
/*
|
|
48
|
+
Invalidate all queries except 'user' as we can set it to null manually
|
|
49
|
+
after logout to avoid extra requests
|
|
50
|
+
*/
|
|
51
|
+
queryClient.removeQueries({
|
|
52
|
+
predicate: (query: any) =>
|
|
53
|
+
query.options.queryKey.includes('user') && query.options.queryKey !== 'user',
|
|
54
|
+
});
|
|
55
|
+
queryClient.setQueryData('user', null);
|
|
56
|
+
}, [queryClient]);
|
|
57
|
+
|
|
58
|
+
const destroy = useCallback(async () => {
|
|
59
|
+
await AuthenticationApi!.logout();
|
|
60
|
+
invalidate();
|
|
61
|
+
}, [invalidate]);
|
|
62
|
+
|
|
63
|
+
const context = useMemo(
|
|
64
|
+
() => ({
|
|
65
|
+
user,
|
|
66
|
+
destroy,
|
|
67
|
+
invalidate,
|
|
68
|
+
login,
|
|
69
|
+
register,
|
|
70
|
+
}),
|
|
71
|
+
[user, destroy, login, register],
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
// When user is updated, session queries should be invalidated.
|
|
76
|
+
if (previousUserState !== user) {
|
|
77
|
+
queryClient.removeQueries({
|
|
78
|
+
predicate: (query: any) =>
|
|
79
|
+
query.options.queryKey.includes('user') && query.options.queryKey !== 'user',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}, [user]);
|
|
83
|
+
|
|
84
|
+
return <Session.Provider value={context}>{children}</Session.Provider>;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export default BaseSessionProvider;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fetchMock from 'fetch-mock';
|
|
2
|
+
import { IntlProvider } from 'react-intl';
|
|
3
|
+
import { QueryClientProvider } from 'react-query';
|
|
4
|
+
import { act, render, waitFor } from '@testing-library/react';
|
|
5
|
+
import { ContextFactory as mockContextFactory, FonzieUserFactory } from 'utils/test/factories';
|
|
6
|
+
import createQueryClient from 'utils/react-query/createQueryClient';
|
|
7
|
+
import { Deferred } from 'utils/test/deferred';
|
|
8
|
+
import { RICHIE_USER_TOKEN } from 'settings';
|
|
9
|
+
import JoanieSessionProvider from './JoanieSessionProvider';
|
|
10
|
+
|
|
11
|
+
jest.mock('utils/errors/handle');
|
|
12
|
+
jest.mock('utils/context', () => ({
|
|
13
|
+
__esModule: true,
|
|
14
|
+
default: mockContextFactory({
|
|
15
|
+
authentication: { backend: 'fonzie', endpoint: 'https://auth.endpoint.test' },
|
|
16
|
+
joanie_backend: { endpoint: 'https://joanie.endpoint.test' },
|
|
17
|
+
}).generate(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
// - Joanie Session Provider test suite
|
|
21
|
+
describe('JoanieSessionProvider', () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
fetchMock.restore();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('stores user access token within session storage', async () => {
|
|
27
|
+
const queryClient = createQueryClient();
|
|
28
|
+
const user = FonzieUserFactory.generate();
|
|
29
|
+
|
|
30
|
+
fetchMock
|
|
31
|
+
.get('https://auth.endpoint.test/api/v1.0/user/me', user)
|
|
32
|
+
.get('https://joanie.endpoint.test/api/addresses/', [])
|
|
33
|
+
.get('https://joanie.endpoint.test/api/credit-cards/', [])
|
|
34
|
+
.get('https://joanie.endpoint.test/api/orders/', []);
|
|
35
|
+
|
|
36
|
+
render(
|
|
37
|
+
<IntlProvider locale="en">
|
|
38
|
+
<QueryClientProvider client={queryClient}>
|
|
39
|
+
<JoanieSessionProvider />
|
|
40
|
+
</QueryClientProvider>
|
|
41
|
+
</IntlProvider>,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
await waitFor(() => {
|
|
45
|
+
expect(fetchMock.lastUrl()).toEqual('https://auth.endpoint.test/api/v1.0/user/me');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(sessionStorage.getItem(RICHIE_USER_TOKEN)).toEqual(user.access_token);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('prefetches addresses, credit-cards and order when user is authenticated', async () => {
|
|
52
|
+
const queryClient = createQueryClient();
|
|
53
|
+
const user = FonzieUserFactory.generate();
|
|
54
|
+
const deferredUser = new Deferred();
|
|
55
|
+
|
|
56
|
+
fetchMock
|
|
57
|
+
.get('https://auth.endpoint.test/api/v1.0/user/me', deferredUser.promise)
|
|
58
|
+
.get('https://joanie.endpoint.test/api/addresses/', [])
|
|
59
|
+
.get('https://joanie.endpoint.test/api/credit-cards/', [])
|
|
60
|
+
.get('https://joanie.endpoint.test/api/orders/', []);
|
|
61
|
+
|
|
62
|
+
render(
|
|
63
|
+
<IntlProvider locale="en">
|
|
64
|
+
<QueryClientProvider client={queryClient}>
|
|
65
|
+
<JoanieSessionProvider />
|
|
66
|
+
</QueryClientProvider>
|
|
67
|
+
</IntlProvider>,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
await act(async () => deferredUser.resolve(user));
|
|
71
|
+
|
|
72
|
+
const calls = fetchMock.calls();
|
|
73
|
+
expect(calls).toHaveLength(4);
|
|
74
|
+
expect(calls[0][0]).toEqual('https://auth.endpoint.test/api/v1.0/user/me');
|
|
75
|
+
expect(calls[1][0]).toEqual('https://joanie.endpoint.test/api/addresses/');
|
|
76
|
+
expect(calls[2][0]).toEqual('https://joanie.endpoint.test/api/credit-cards/');
|
|
77
|
+
expect(calls[3][0]).toEqual('https://joanie.endpoint.test/api/orders/');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('does not prefetch address, credit-cards, and order when user is anonymous', async () => {
|
|
81
|
+
const queryClient = createQueryClient();
|
|
82
|
+
const deferredUser = new Deferred();
|
|
83
|
+
|
|
84
|
+
fetchMock.get('https://auth.endpoint.test/api/v1.0/user/me', deferredUser.promise);
|
|
85
|
+
|
|
86
|
+
render(
|
|
87
|
+
<IntlProvider locale="en">
|
|
88
|
+
<QueryClientProvider client={queryClient}>
|
|
89
|
+
<JoanieSessionProvider />
|
|
90
|
+
</QueryClientProvider>
|
|
91
|
+
</IntlProvider>,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
await act(async () => deferredUser.resolve(null));
|
|
95
|
+
|
|
96
|
+
expect(fetchMock.lastUrl()).toEqual('https://auth.endpoint.test/api/v1.0/user/me');
|
|
97
|
+
|
|
98
|
+
expect(fetchMock.calls()).toHaveLength(1);
|
|
99
|
+
expect(fetchMock.lastUrl()).toEqual('https://auth.endpoint.test/api/v1.0/user/me');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
3
|
+
import { useQuery, useQueryClient } from 'react-query';
|
|
4
|
+
import { AuthenticationApi } from 'utils/api/authentication';
|
|
5
|
+
import isTestEnv from 'utils/test/isTestEnv';
|
|
6
|
+
import type { User } from 'types/User';
|
|
7
|
+
import type { Nullable } from 'types/utils';
|
|
8
|
+
import { useCreditCards } from 'hooks/useCreditCards';
|
|
9
|
+
import { useAddresses } from 'hooks/useAddresses';
|
|
10
|
+
import { useOrders } from 'hooks/useOrders';
|
|
11
|
+
import { REACT_QUERY_SETTINGS, RICHIE_USER_TOKEN } from 'settings';
|
|
12
|
+
import JoanieApiProvider from 'data/JoanieApiProvider';
|
|
13
|
+
import usePrevious from 'utils/usePrevious';
|
|
14
|
+
import { Session } from './SessionContext';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* JoanieSessionProvider
|
|
18
|
+
*
|
|
19
|
+
* It retrieves the user then prefetches its orders, addresses and credit-cards.
|
|
20
|
+
*
|
|
21
|
+
* @param children - Elements to render inside SessionProvider
|
|
22
|
+
*
|
|
23
|
+
* @return {Object} Session
|
|
24
|
+
* @return {Object} Session.user - authenticated user information
|
|
25
|
+
* @return {Function} Session.login - redirect to the login page
|
|
26
|
+
* @return {Function} Session.register - redirect to the register page
|
|
27
|
+
* @return {Function} Session.destroy - set Session to undefined then make a request to logout user to the authentication service
|
|
28
|
+
*/
|
|
29
|
+
const JoanieSessionProvider = ({ children }: React.PropsWithChildren<any>) => {
|
|
30
|
+
/**
|
|
31
|
+
* `user` is:
|
|
32
|
+
* - `undefined` when we have not made the `whoami` request yet;
|
|
33
|
+
* - `null` when the user is anonymous or the request failed;
|
|
34
|
+
* - a user object when the user is logged in.
|
|
35
|
+
*/
|
|
36
|
+
const [refetchInterval, setRefetchInterval] = useState<false | number>(false);
|
|
37
|
+
const { data: user, isStale } = useQuery<Nullable<User>>('user', AuthenticationApi!.me, {
|
|
38
|
+
refetchOnWindowFocus: true,
|
|
39
|
+
refetchInterval,
|
|
40
|
+
staleTime: REACT_QUERY_SETTINGS.staleTimes.session,
|
|
41
|
+
onError: () => {
|
|
42
|
+
sessionStorage.removeItem(RICHIE_USER_TOKEN);
|
|
43
|
+
},
|
|
44
|
+
onSuccess: (data) => {
|
|
45
|
+
sessionStorage.removeItem(RICHIE_USER_TOKEN);
|
|
46
|
+
if (data) {
|
|
47
|
+
sessionStorage.setItem(RICHIE_USER_TOKEN, data.access_token!);
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
const previousUserState = usePrevious(user);
|
|
52
|
+
|
|
53
|
+
const queryClient = useQueryClient();
|
|
54
|
+
const addresses = useAddresses();
|
|
55
|
+
const creditCards = useCreditCards();
|
|
56
|
+
const orders = useOrders();
|
|
57
|
+
|
|
58
|
+
const login = useCallback(() => {
|
|
59
|
+
sessionStorage.removeItem(REACT_QUERY_SETTINGS.cacheStorage.key);
|
|
60
|
+
sessionStorage.removeItem(RICHIE_USER_TOKEN);
|
|
61
|
+
AuthenticationApi!.login();
|
|
62
|
+
}, [queryClient]);
|
|
63
|
+
|
|
64
|
+
const register = useCallback(() => {
|
|
65
|
+
sessionStorage.removeItem(REACT_QUERY_SETTINGS.cacheStorage.key);
|
|
66
|
+
sessionStorage.removeItem(RICHIE_USER_TOKEN);
|
|
67
|
+
AuthenticationApi!.register();
|
|
68
|
+
}, [queryClient]);
|
|
69
|
+
|
|
70
|
+
const invalidate = useCallback(() => {
|
|
71
|
+
/*
|
|
72
|
+
Invalidate all queries except 'user' as we can set it to null manually
|
|
73
|
+
after logout to avoid extra requests
|
|
74
|
+
*/
|
|
75
|
+
sessionStorage.removeItem(RICHIE_USER_TOKEN);
|
|
76
|
+
queryClient.removeQueries({
|
|
77
|
+
predicate: (query: any) =>
|
|
78
|
+
query.options.queryKey.includes('user') && query.options.queryKey !== 'user',
|
|
79
|
+
});
|
|
80
|
+
queryClient.setQueryData('user', null);
|
|
81
|
+
}, [queryClient]);
|
|
82
|
+
|
|
83
|
+
const destroy = useCallback(async () => {
|
|
84
|
+
await AuthenticationApi!.logout();
|
|
85
|
+
invalidate();
|
|
86
|
+
}, [invalidate]);
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (user && !isStale) {
|
|
90
|
+
addresses.methods.prefetch();
|
|
91
|
+
creditCards.methods.prefetch();
|
|
92
|
+
orders.methods.prefetch();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!isTestEnv) {
|
|
96
|
+
// We do not want to enable refetchInterval during tests as it can pollute
|
|
97
|
+
// the fetchMock when we use fake timers.
|
|
98
|
+
if (user) {
|
|
99
|
+
setRefetchInterval(REACT_QUERY_SETTINGS.staleTimes.session);
|
|
100
|
+
} else {
|
|
101
|
+
setRefetchInterval(false);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}, [user]);
|
|
105
|
+
|
|
106
|
+
const context = useMemo(
|
|
107
|
+
() => ({
|
|
108
|
+
user,
|
|
109
|
+
destroy,
|
|
110
|
+
login,
|
|
111
|
+
register,
|
|
112
|
+
}),
|
|
113
|
+
[user, destroy, login, register],
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
// When user is updated, session queries should be invalidated.
|
|
118
|
+
if (previousUserState !== user) {
|
|
119
|
+
queryClient.removeQueries({
|
|
120
|
+
predicate: (query: any) =>
|
|
121
|
+
query.options.queryKey.includes('user') && query.options.queryKey !== 'user',
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}, [user]);
|
|
125
|
+
|
|
126
|
+
return <Session.Provider value={context}>{children}</Session.Provider>;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export default ({ children, ...props }: React.PropsWithChildren<any>) => (
|
|
130
|
+
<JoanieApiProvider>
|
|
131
|
+
<JoanieSessionProvider {...props}>{children}</JoanieSessionProvider>
|
|
132
|
+
</JoanieApiProvider>
|
|
133
|
+
);
|