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.
Files changed (69) hide show
  1. package/js/components/CourseRunEnrollment/index.spec.tsx +8 -6
  2. package/js/components/CourseRunEnrollment/index.tsx +6 -5
  3. package/js/components/PaginateCourseSearch/index.tsx +4 -10
  4. package/js/components/Root/index.spec.tsx +37 -10
  5. package/js/components/Root/index.tsx +10 -3
  6. package/js/components/UserLogin/index.spec.tsx +1 -1
  7. package/js/components/UserLogin/index.tsx +1 -1
  8. package/js/data/CourseCodeProvider/index.spec.tsx +24 -0
  9. package/js/data/CourseCodeProvider/index.tsx +31 -0
  10. package/js/data/JoanieApiProvider/index.spec.tsx +34 -0
  11. package/js/data/JoanieApiProvider/index.tsx +31 -0
  12. package/js/data/SessionProvider/BaseSessionProvider.tsx +87 -0
  13. package/js/data/SessionProvider/JoanieSessionProvider.spec.tsx +101 -0
  14. package/js/data/SessionProvider/JoanieSessionProvider.tsx +133 -0
  15. package/js/data/SessionProvider/SessionContext.tsx +18 -0
  16. package/js/data/SessionProvider/index.spec.tsx +195 -0
  17. package/js/data/SessionProvider/index.tsx +55 -0
  18. package/js/data/SessionProvider/no-authentication.spec.tsx +37 -0
  19. package/js/data/{useEnrollment → useCourseEnrollment}/index.spec.tsx +6 -6
  20. package/js/data/useCourseEnrollment/index.ts +54 -0
  21. package/js/hooks/useAddresses.ts +56 -0
  22. package/js/hooks/useCourse.ts +50 -0
  23. package/js/hooks/useCreditCards.ts +57 -0
  24. package/js/hooks/useEnrollment.ts +37 -0
  25. package/js/hooks/useOrders.ts +56 -0
  26. package/js/settings.ts +3 -1
  27. package/js/testSetup.ts +11 -0
  28. package/js/types/Joanie.ts +247 -0
  29. package/js/types/User.ts +2 -1
  30. package/js/types/WebAnalytics.ts +17 -0
  31. package/js/types/api.ts +8 -6
  32. package/js/types/commonDataProps.ts +4 -1
  33. package/js/types/web-analytics/google_analytics.d.ts +17 -0
  34. package/js/types/web-analytics/google_tag_manager.d.ts +19 -0
  35. package/js/utils/api/{courseEnrollment.ts → enrollment.ts} +0 -0
  36. package/js/utils/api/joanie.ts +273 -0
  37. package/js/utils/api/lms/openedx-dogwood.spec.ts +28 -0
  38. package/js/utils/api/lms/openedx-dogwood.ts +4 -4
  39. package/js/utils/api/lms/openedx-fonzie.spec.ts +43 -0
  40. package/js/utils/api/lms/openedx-fonzie.ts +17 -4
  41. package/js/utils/api/lms/openedx-hawthorn.spec.ts +16 -12
  42. package/js/utils/api/lms/openedx-hawthorn.ts +56 -59
  43. package/js/utils/api/web-analytics/base.ts +23 -0
  44. package/js/utils/api/web-analytics/google_analytics.spec.ts +24 -0
  45. package/js/utils/api/web-analytics/google_analytics.ts +26 -0
  46. package/js/utils/api/web-analytics/google_tag_manager.spec.ts +23 -0
  47. package/js/utils/api/web-analytics/google_tag_manager.ts +33 -0
  48. package/js/utils/api/web-analytics/index.ts +24 -0
  49. package/js/utils/api/web-analytics/no_provider.spec.ts +13 -0
  50. package/js/utils/api/web-analytics/unknown_provider.spec.ts +15 -0
  51. package/js/utils/errors/ErrorBoundary.tsx +35 -0
  52. package/js/utils/errors/HttpError.ts +12 -0
  53. package/js/utils/react-query/createQueryClient.ts +7 -3
  54. package/js/utils/react-query/useLocalizedQueryKey.ts +15 -0
  55. package/js/utils/react-query/useSessionKey.ts +20 -0
  56. package/js/utils/react-query/useSessionMutation/index.spec.tsx +90 -0
  57. package/js/utils/react-query/useSessionMutation/index.ts +37 -0
  58. package/js/utils/react-query/useSessionQuery/index.spec.tsx +87 -0
  59. package/js/utils/react-query/useSessionQuery/index.ts +72 -0
  60. package/js/utils/test/factories.ts +139 -1
  61. package/js/utils/test/isTestEnv.ts +2 -0
  62. package/js/utils/usePrevious.ts +19 -0
  63. package/package.json +23 -23
  64. package/js/data/useEnrollment/index.ts +0 -38
  65. package/js/data/useSession/index.spec.tsx +0 -146
  66. package/js/data/useSession/index.tsx +0 -109
  67. package/js/data/useSession/no-authentication.spec.tsx +0 -47
  68. package/js/utils/react-query/createSessionStoragePersistor/index.spec.ts +0 -125
  69. 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/useSession';
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`, enrollmentAction.promise);
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
- enrollmentAction.reject('500 - Internal Server Error');
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('500 - Internal Server Error');
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/useSession';
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 useEnrollment from 'data/useEnrollment';
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 } = useEnrollment(props.courseRun.resource_link);
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 response = await setEnrollment();
164
+ const isEnrolled = await setEnrollment().catch(() => undefined);
165
+
165
166
  dispatch({
166
167
  type: ActionType.UPDATE_CONTEXT,
167
- payload: { isEnrolled: response },
168
+ payload: { isEnrolled },
168
169
  });
169
170
  }
170
171
  }, [courseRun, currentUser, dispatch]);
@@ -1,5 +1,5 @@
1
- import { Fragment, useState } from 'react';
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
- <div id={`pagination-label-${componentId}`} className="offscreen">
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/useSession', () => ({
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, 'warn').mockImplementation(() => {});
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/useSession';
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 = window.__richie_frontend_context__.context;
83
+ props.context = context;
82
84
  }
83
85
 
84
- return ReactDOM.createPortal(<Component {...props} />, element);
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/useSession';
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/useSession';
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
+ );