@openedx/frontend-app-authn 1.0.0-alpha.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 (177) hide show
  1. package/LICENSE +661 -0
  2. package/README.rst +204 -0
  3. package/package.json +79 -0
  4. package/src/Main.tsx +23 -0
  5. package/src/__mocks__/file.js +1 -0
  6. package/src/__mocks__/svg.js +1 -0
  7. package/src/app.scss +6 -0
  8. package/src/app.ts +43 -0
  9. package/src/base-container/components/default-layout/DefaultLayout.test.jsx +48 -0
  10. package/src/base-container/components/default-layout/LargeLayout.jsx +42 -0
  11. package/src/base-container/components/default-layout/MediumLayout.jsx +49 -0
  12. package/src/base-container/components/default-layout/SmallLayout.jsx +37 -0
  13. package/src/base-container/components/default-layout/index.jsx +3 -0
  14. package/src/base-container/components/default-layout/messages.js +16 -0
  15. package/src/base-container/components/image-layout/ExtraSmallLayout.jsx +31 -0
  16. package/src/base-container/components/image-layout/LargeLayout.jsx +32 -0
  17. package/src/base-container/components/image-layout/MediumLayout.jsx +32 -0
  18. package/src/base-container/components/image-layout/SmallLayout.jsx +31 -0
  19. package/src/base-container/components/image-layout/index.jsx +4 -0
  20. package/src/base-container/components/image-layout/index.scss +37 -0
  21. package/src/base-container/components/image-layout/messages.js +16 -0
  22. package/src/base-container/components/welcome-page-layout/LargeLayout.jsx +46 -0
  23. package/src/base-container/components/welcome-page-layout/MediumLayout.jsx +49 -0
  24. package/src/base-container/components/welcome-page-layout/SmallLayout.jsx +38 -0
  25. package/src/base-container/components/welcome-page-layout/index.jsx +3 -0
  26. package/src/base-container/components/welcome-page-layout/messages.js +21 -0
  27. package/src/base-container/index.jsx +70 -0
  28. package/src/base-container/tests/BaseContainer.test.jsx +44 -0
  29. package/src/common-components/EmbeddedRegistrationRoute.jsx +26 -0
  30. package/src/common-components/EnterpriseSSO.jsx +110 -0
  31. package/src/common-components/FormGroup.jsx +106 -0
  32. package/src/common-components/InstitutionLogistration.jsx +102 -0
  33. package/src/common-components/NotFoundPage.jsx +15 -0
  34. package/src/common-components/PasswordField.jsx +175 -0
  35. package/src/common-components/RedirectLogistration.jsx +94 -0
  36. package/src/common-components/RegisterFaIcons.jsx +8 -0
  37. package/src/common-components/SocialAuthProviders.jsx +75 -0
  38. package/src/common-components/ThirdPartyAuth.jsx +123 -0
  39. package/src/common-components/ThirdPartyAuthAlert.jsx +49 -0
  40. package/src/common-components/UnAuthOnlyRoute.jsx +41 -0
  41. package/src/common-components/data/actions.js +27 -0
  42. package/src/common-components/data/reducers.js +63 -0
  43. package/src/common-components/data/sagas.js +32 -0
  44. package/src/common-components/data/selectors.js +28 -0
  45. package/src/common-components/data/service.js +23 -0
  46. package/src/common-components/data/tests/reducer.test.js +82 -0
  47. package/src/common-components/data/tests/sagas.test.js +71 -0
  48. package/src/common-components/index.jsx +14 -0
  49. package/src/common-components/messages.jsx +127 -0
  50. package/src/common-components/tests/EmbeddedRegistrationRoute.test.jsx +76 -0
  51. package/src/common-components/tests/FormField.test.jsx +289 -0
  52. package/src/common-components/tests/SocialAuthProviders.test.jsx +75 -0
  53. package/src/common-components/tests/ThirdPartyAuthAlert.test.jsx +39 -0
  54. package/src/common-components/tests/UnAuthOnlyRoute.test.jsx +73 -0
  55. package/src/common-components/tests/__snapshots__/SocialAuthProviders.test.jsx.snap +156 -0
  56. package/src/common-components/tests/__snapshots__/ThirdPartyAuthAlert.test.jsx.snap +53 -0
  57. package/src/constants.ts +1 -0
  58. package/src/data/configureStore.js +33 -0
  59. package/src/data/constants.js +38 -0
  60. package/src/data/countries.ts +59 -0
  61. package/src/data/oneTrust.js +3 -0
  62. package/src/data/reducers.js +36 -0
  63. package/src/data/sagas.js +19 -0
  64. package/src/data/tests/cookies.test.js +42 -0
  65. package/src/data/tests/dataUtils.test.js +19 -0
  66. package/src/data/tests/reduxUtils.test.js +14 -0
  67. package/src/data/utils/cookies.js +12 -0
  68. package/src/data/utils/dataUtils.js +81 -0
  69. package/src/data/utils/index.js +11 -0
  70. package/src/data/utils/reduxUtils.js +34 -0
  71. package/src/data/utils/useMobileResponsive.js +24 -0
  72. package/src/field-renderer/FieldRenderer.jsx +156 -0
  73. package/src/field-renderer/index.jsx +2 -0
  74. package/src/field-renderer/tests/FieldRenderer.test.jsx +201 -0
  75. package/src/forgot-password/ForgotPasswordAlert.jsx +97 -0
  76. package/src/forgot-password/ForgotPasswordPage.jsx +193 -0
  77. package/src/forgot-password/data/actions.js +32 -0
  78. package/src/forgot-password/data/reducers.js +58 -0
  79. package/src/forgot-password/data/sagas.js +35 -0
  80. package/src/forgot-password/data/selectors.js +10 -0
  81. package/src/forgot-password/data/service.js +21 -0
  82. package/src/forgot-password/data/tests/reducers.test.js +34 -0
  83. package/src/forgot-password/data/tests/sagas.test.js +67 -0
  84. package/src/forgot-password/index.js +5 -0
  85. package/src/forgot-password/messages.js +128 -0
  86. package/src/forgot-password/tests/ForgotPasswordPage.test.jsx +269 -0
  87. package/src/i18n/index.js +25 -0
  88. package/src/index.ts +3 -0
  89. package/src/login/AccountActivationMessage.jsx +78 -0
  90. package/src/login/ChangePasswordPrompt.jsx +91 -0
  91. package/src/login/LoginFailure.jsx +231 -0
  92. package/src/login/LoginPage.jsx +368 -0
  93. package/src/login/data/actions.js +39 -0
  94. package/src/login/data/constants.js +20 -0
  95. package/src/login/data/reducers.js +76 -0
  96. package/src/login/data/sagas.js +45 -0
  97. package/src/login/data/service.js +24 -0
  98. package/src/login/data/tests/reducers.test.js +155 -0
  99. package/src/login/data/tests/sagas.test.js +110 -0
  100. package/src/login/index.js +5 -0
  101. package/src/login/messages.jsx +211 -0
  102. package/src/login/tests/AccountActivationMessage.test.jsx +122 -0
  103. package/src/login/tests/ChangePasswordPrompt.test.jsx +75 -0
  104. package/src/login/tests/LoginFailure.test.jsx +323 -0
  105. package/src/login/tests/LoginPage.test.jsx +839 -0
  106. package/src/logistration/Logistration.jsx +189 -0
  107. package/src/logistration/Logistration.test.jsx +275 -0
  108. package/src/progressive-profiling/ProgressiveProfiling.jsx +313 -0
  109. package/src/progressive-profiling/ProgressiveProfilingPageModal.jsx +56 -0
  110. package/src/progressive-profiling/data/actions.js +22 -0
  111. package/src/progressive-profiling/data/reducers.js +38 -0
  112. package/src/progressive-profiling/data/sagas.js +24 -0
  113. package/src/progressive-profiling/data/selectors.js +14 -0
  114. package/src/progressive-profiling/data/service.js +17 -0
  115. package/src/progressive-profiling/index.js +5 -0
  116. package/src/progressive-profiling/messages.jsx +57 -0
  117. package/src/progressive-profiling/tests/ProgressiveProfiling.test.jsx +322 -0
  118. package/src/register/RegistrationFields/CountryField/CountryField.jsx +164 -0
  119. package/src/register/RegistrationFields/CountryField/CountryField.test.jsx +204 -0
  120. package/src/register/RegistrationFields/CountryField/validator.js +32 -0
  121. package/src/register/RegistrationFields/EmailField/EmailField.jsx +140 -0
  122. package/src/register/RegistrationFields/EmailField/EmailField.test.jsx +236 -0
  123. package/src/register/RegistrationFields/EmailField/constants.js +131 -0
  124. package/src/register/RegistrationFields/EmailField/validator.js +126 -0
  125. package/src/register/RegistrationFields/EmailField/validator.test.js +39 -0
  126. package/src/register/RegistrationFields/HonorCodeField/HonorCode.jsx +104 -0
  127. package/src/register/RegistrationFields/HonorCodeField/HonorCode.test.jsx +70 -0
  128. package/src/register/RegistrationFields/NameField/NameField.jsx +68 -0
  129. package/src/register/RegistrationFields/NameField/NameField.test.jsx +146 -0
  130. package/src/register/RegistrationFields/NameField/validator.js +22 -0
  131. package/src/register/RegistrationFields/TermsOfServiceField/TermsOfService.jsx +58 -0
  132. package/src/register/RegistrationFields/TermsOfServiceField/TermsOfService.test.jsx +55 -0
  133. package/src/register/RegistrationFields/UsernameField/UsernameField.jsx +162 -0
  134. package/src/register/RegistrationFields/UsernameField/UsernameField.test.jsx +319 -0
  135. package/src/register/RegistrationFields/UsernameField/validator.js +16 -0
  136. package/src/register/RegistrationFields/index.js +6 -0
  137. package/src/register/RegistrationPage.jsx +428 -0
  138. package/src/register/RegistrationPage.test.jsx +854 -0
  139. package/src/register/components/ConfigurableRegistrationForm.jsx +244 -0
  140. package/src/register/components/RegistrationFailure.jsx +80 -0
  141. package/src/register/components/tests/ConfigurableRegistrationForm.test.jsx +401 -0
  142. package/src/register/components/tests/RegistrationFailure.test.jsx +212 -0
  143. package/src/register/components/tests/ThirdPartyAuth.test.jsx +439 -0
  144. package/src/register/data/actions.js +85 -0
  145. package/src/register/data/constants.js +13 -0
  146. package/src/register/data/reducers.js +140 -0
  147. package/src/register/data/sagas.js +58 -0
  148. package/src/register/data/selectors.js +34 -0
  149. package/src/register/data/service.js +45 -0
  150. package/src/register/data/tests/reducers.test.js +277 -0
  151. package/src/register/data/tests/sagas.test.js +239 -0
  152. package/src/register/data/tests/utils.test.js +98 -0
  153. package/src/register/data/utils.js +153 -0
  154. package/src/register/index.js +4 -0
  155. package/src/register/messages.jsx +206 -0
  156. package/src/reset-password/ResetPasswordFailure.jsx +55 -0
  157. package/src/reset-password/ResetPasswordPage.jsx +224 -0
  158. package/src/reset-password/ResetPasswordSuccess.jsx +19 -0
  159. package/src/reset-password/data/actions.js +50 -0
  160. package/src/reset-password/data/constants.js +16 -0
  161. package/src/reset-password/data/reducers.js +44 -0
  162. package/src/reset-password/data/sagas.js +67 -0
  163. package/src/reset-password/data/selectors.js +10 -0
  164. package/src/reset-password/data/service.js +61 -0
  165. package/src/reset-password/data/tests/sagas.test.js +185 -0
  166. package/src/reset-password/index.js +5 -0
  167. package/src/reset-password/messages.js +88 -0
  168. package/src/reset-password/tests/ResetPasswordPage.test.jsx +241 -0
  169. package/src/routes.jsx +79 -0
  170. package/src/sass/_base_component.scss +202 -0
  171. package/src/sass/_forgot_password.scss +3 -0
  172. package/src/sass/_login_page.scss +3 -0
  173. package/src/sass/_progressive_profiling_page.scss +30 -0
  174. package/src/sass/_registration.scss +123 -0
  175. package/src/sass/_reset_password.scss +3 -0
  176. package/src/sass/_style.scss +465 -0
  177. package/src/setupTest.js +68 -0
@@ -0,0 +1,322 @@
1
+ import { Provider } from 'react-redux';
2
+
3
+ import {
4
+ CurrentAppProvider,
5
+ configureI18n,
6
+ getAuthenticatedUser,
7
+ getSiteConfig,
8
+ identifyAuthenticatedUser,
9
+ injectIntl,
10
+ IntlProvider,
11
+ mergeAppConfig,
12
+ sendTrackEvent
13
+ } from '@openedx/frontend-base';
14
+ import {
15
+ fireEvent, render, screen,
16
+ } from '@testing-library/react';
17
+ import { MemoryRouter, mockNavigate, useLocation } from 'react-router-dom';
18
+ import configureStore from 'redux-mock-store';
19
+
20
+ import { appId } from '../../constants';
21
+ import {
22
+ AUTHN_PROGRESSIVE_PROFILING,
23
+ COMPLETE_STATE, DEFAULT_REDIRECT_URL,
24
+ EMBEDDED,
25
+ FAILURE_STATE,
26
+ PENDING_STATE,
27
+ } from '../../data/constants';
28
+ import { saveUserProfile } from '../data/actions';
29
+ import ProgressiveProfiling from '../ProgressiveProfiling';
30
+
31
+ const IntlProgressiveProfilingPage = injectIntl(ProgressiveProfiling);
32
+ const mockStore = configureStore();
33
+
34
+ jest.mock('@openedx/frontend-base', () => ({
35
+ ...jest.requireActual('@openedx/frontend-base'),
36
+ sendPageEvent: jest.fn(),
37
+ sendTrackEvent: jest.fn(),
38
+ identifyAuthenticatedUser: jest.fn(),
39
+ configureAuth: jest.fn(),
40
+ getAuthenticatedUser: jest.fn(),
41
+ getLoggingService: jest.fn(),
42
+ }));
43
+ jest.mock('react-router-dom', () => {
44
+ const mockNavigation = jest.fn();
45
+
46
+ // eslint-disable-next-line react/prop-types
47
+ const Navigate = ({ to }) => {
48
+ mockNavigation(to);
49
+ return <div />;
50
+ };
51
+
52
+ return {
53
+ ...jest.requireActual('react-router-dom'),
54
+ Navigate,
55
+ mockNavigate: mockNavigation,
56
+ useLocation: jest.fn(),
57
+ };
58
+ });
59
+
60
+ describe('ProgressiveProfilingTests', () => {
61
+ let store = {};
62
+
63
+ const DASHBOARD_URL = getSiteConfig().lmsBaseUrl.concat(DEFAULT_REDIRECT_URL);
64
+ const registrationResult = { redirectUrl: getSiteConfig().lmsBaseUrl + DEFAULT_REDIRECT_URL, success: true };
65
+ const fields = {
66
+ company: { name: 'company', type: 'text', label: 'Company' },
67
+ gender: {
68
+ name: 'gender',
69
+ type: 'select',
70
+ label: 'Gender',
71
+ options: [['m', 'Male'], ['f', 'Female'], ['o', 'Other/Prefer Not to Say']],
72
+ },
73
+ };
74
+ const extendedProfile = ['company'];
75
+ const optionalFields = { fields, extended_profile: extendedProfile };
76
+ const initialState = {
77
+ welcomePage: {},
78
+ commonComponents: {
79
+ thirdPartyAuthApiStatus: null,
80
+ optionalFields: {},
81
+ thirdPartyAuthContext: {
82
+ welcomePageRedirectUrl: null,
83
+ },
84
+ },
85
+ };
86
+
87
+ const reduxWrapper = children => (
88
+ <IntlProvider locale="en">
89
+ <MemoryRouter>
90
+ <CurrentAppProvider appId={appId}>
91
+ <Provider store={store}>{children}</Provider>
92
+ </CurrentAppProvider>
93
+ </MemoryRouter>
94
+ </IntlProvider>
95
+ );
96
+
97
+ beforeEach(() => {
98
+ store = mockStore(initialState);
99
+ configureI18n({
100
+ messages: { 'es-419': {}, de: {}, 'en-us': {} },
101
+ });
102
+ useLocation.mockReturnValue({
103
+ state: {
104
+ registrationResult,
105
+ optionalFields,
106
+ },
107
+ });
108
+ getAuthenticatedUser.mockReturnValue({ userId: 3, username: 'abc123', name: 'Test User' });
109
+ });
110
+
111
+ // ******** test form links and modal ********
112
+
113
+ it('should not display button "Learn more about how we use this information."', () => {
114
+ mergeAppConfig(appId, {
115
+ AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK: '',
116
+ });
117
+ const { queryByRole } = render(reduxWrapper(<IntlProgressiveProfilingPage />));
118
+ const button = queryByRole('button', { name: /learn more about how we use this information/i });
119
+
120
+ expect(button).toBeNull();
121
+ });
122
+
123
+ it('should display button "Learn more about how we use this information."', () => {
124
+ mergeAppConfig(appId, {
125
+ AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK: 'http://localhost:1999/support',
126
+ });
127
+
128
+ const { getByText } = render(reduxWrapper(<IntlProgressiveProfilingPage />));
129
+
130
+ const learnMoreButton = getByText('Learn more about how we use this information.');
131
+
132
+ expect(learnMoreButton).toBeDefined();
133
+ });
134
+
135
+ it('should open modal on pressing skip for now button', () => {
136
+ delete window.location;
137
+ window.location = { href: getSiteConfig().baseUrl.concat(AUTHN_PROGRESSIVE_PROFILING) };
138
+ const { getByRole } = render(reduxWrapper(<IntlProgressiveProfilingPage />));
139
+
140
+ const skipButton = getByRole('button', { name: /skip for now/i });
141
+ fireEvent.click(skipButton);
142
+
143
+ const modalContentContainer = document.getElementsByClassName('.pgn__modal-content-container');
144
+
145
+ expect(modalContentContainer).toBeTruthy();
146
+
147
+ expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.welcome.page.skip.link.clicked', { host: '' });
148
+ });
149
+
150
+ // ******** test event functionality ********
151
+
152
+ it('should make identify call to segment on progressive profiling page', () => {
153
+ render(reduxWrapper(<IntlProgressiveProfilingPage />));
154
+
155
+ expect(identifyAuthenticatedUser).toHaveBeenCalledWith(3);
156
+ expect(identifyAuthenticatedUser).toHaveBeenCalled();
157
+ });
158
+
159
+ it('should send analytic event for support link click', () => {
160
+ mergeAppConfig(appId, {
161
+ AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK: 'http://localhost:1999/support',
162
+ });
163
+ render(reduxWrapper(<IntlProgressiveProfilingPage />));
164
+
165
+ const supportLink = screen.getByRole('link', { name: /learn more about how we use this information/i });
166
+ fireEvent.click(supportLink);
167
+
168
+ expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.welcome.page.support.link.clicked');
169
+ });
170
+
171
+ // ******** test form submission ********
172
+
173
+ it('should show error message when patch request fails', () => {
174
+ store = mockStore({
175
+ ...initialState,
176
+ welcomePage: {
177
+ ...initialState.welcomePage,
178
+ showError: true,
179
+ },
180
+ });
181
+
182
+ const { container } = render(reduxWrapper(<IntlProgressiveProfilingPage />));
183
+ const errorElement = container.querySelector('#pp-page-errors');
184
+
185
+ expect(errorElement).toBeTruthy();
186
+ });
187
+
188
+ // ******** miscellaneous tests ********
189
+
190
+ it('should redirect to login page if unauthenticated user tries to access welcome page', () => {
191
+ getAuthenticatedUser.mockReturnValue(null);
192
+ delete window.location;
193
+ window.location = {
194
+ assign: jest.fn().mockImplementation((value) => { window.location.href = value; }),
195
+ href: getSiteConfig().baseUrl,
196
+ };
197
+
198
+ render(reduxWrapper(<IntlProgressiveProfilingPage />));
199
+ expect(window.location.href).toEqual(DASHBOARD_URL);
200
+ });
201
+
202
+ describe('Embedded Form Workflow Test', () => {
203
+ mergeAppConfig(appId, {
204
+ SEARCH_CATALOG_URL: 'http://localhost/search',
205
+ });
206
+ const host = 'http://example.com';
207
+
208
+ beforeEach(() => {
209
+ useLocation.mockReturnValue({
210
+ state: {},
211
+ });
212
+ store = mockStore({
213
+ ...initialState,
214
+ commonComponents: {
215
+ ...initialState.commonComponents,
216
+ thirdPartyAuthApiStatus: COMPLETE_STATE,
217
+ optionalFields,
218
+ },
219
+ });
220
+ });
221
+
222
+ it('should set host property value embedded host for on ramp experience for skip link event', () => {
223
+ delete window.location;
224
+ window.location = {
225
+ href: getSiteConfig().baseUrl.concat(AUTHN_PROGRESSIVE_PROFILING),
226
+ search: `?host=${host}&variant=${EMBEDDED}`,
227
+ };
228
+ render(reduxWrapper(<IntlProgressiveProfilingPage />));
229
+
230
+ const skipLinkButton = screen.getByText('Skip for now');
231
+ fireEvent.click(skipLinkButton);
232
+
233
+ expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.welcome.page.skip.link.clicked', { host });
234
+ });
235
+
236
+ it('should show spinner while fetching the optional fields', () => {
237
+ delete window.location;
238
+ window.location = {
239
+ assign: jest.fn().mockImplementation((value) => { window.location.href = value; }),
240
+ href: getSiteConfig().baseUrl.concat(AUTHN_PROGRESSIVE_PROFILING),
241
+ search: `?host=${host}&variant=${EMBEDDED}`,
242
+ };
243
+
244
+ store = mockStore({
245
+ ...initialState,
246
+ commonComponents: {
247
+ ...initialState.commonComponents,
248
+ thirdPartyAuthApiStatus: PENDING_STATE,
249
+ optionalFields,
250
+ },
251
+ });
252
+
253
+ const { container } = render(reduxWrapper(<IntlProgressiveProfilingPage />));
254
+
255
+ const tpaSpinnerElement = container.querySelector('#tpa-spinner');
256
+ expect(tpaSpinnerElement).toBeTruthy();
257
+ });
258
+
259
+ it('should render fields returned by backend API', () => {
260
+ delete window.location;
261
+ window.location = {
262
+ assign: jest.fn().mockImplementation((value) => { window.location.href = value; }),
263
+ href: getSiteConfig().baseUrl,
264
+ search: `?variant=${EMBEDDED}&host=${host}`,
265
+ };
266
+
267
+ const { container } = render(reduxWrapper(<IntlProgressiveProfilingPage />));
268
+
269
+ const genderField = container.querySelector('#gender');
270
+ expect(genderField).toBeTruthy();
271
+ });
272
+
273
+ it('should redirect to dashboard if API call to get form field fails', () => {
274
+ delete window.location;
275
+ window.location = {
276
+ assign: jest.fn().mockImplementation((value) => { window.location.href = value; }),
277
+ href: getSiteConfig().baseUrl,
278
+ search: `?variant=${EMBEDDED}`,
279
+ };
280
+ store = mockStore({
281
+ ...initialState,
282
+ commonComponents: {
283
+ ...initialState.commonComponents,
284
+ thirdPartyAuthApiStatus: FAILURE_STATE,
285
+ },
286
+ });
287
+
288
+ render(reduxWrapper(<IntlProgressiveProfilingPage />));
289
+ expect(window.location.href).toBe(DASHBOARD_URL);
290
+ });
291
+
292
+ it('should redirect to provided redirect url', () => {
293
+ const redirectUrl = 'https://redirect-test.com';
294
+ delete window.location;
295
+ window.location = {
296
+ assign: jest.fn().mockImplementation((value) => { window.location.href = value; }),
297
+ href: getSiteConfig().baseUrl,
298
+ search: `?variant=${EMBEDDED}&host=${host}&next=${redirectUrl}`,
299
+ };
300
+ store = mockStore({
301
+ ...initialState,
302
+ commonComponents: {
303
+ ...initialState.commonComponents,
304
+ thirdPartyAuthApiStatus: COMPLETE_STATE,
305
+ optionalFields,
306
+ thirdPartyAuthContext: {
307
+ welcomePageRedirectUrl: redirectUrl,
308
+ },
309
+ },
310
+ welcomePage: {
311
+ ...initialState.welcomePage,
312
+ success: true,
313
+ },
314
+ });
315
+
316
+ render(reduxWrapper(<IntlProgressiveProfilingPage />));
317
+ const submitButton = screen.getByText('Submit');
318
+ fireEvent.click(submitButton);
319
+ expect(window.location.href).toBe(redirectUrl);
320
+ });
321
+ });
322
+ });
@@ -0,0 +1,164 @@
1
+ import { useEffect } from 'react';
2
+ import { useDispatch, useSelector } from 'react-redux';
3
+
4
+ import { useIntl } from '@openedx/frontend-base';
5
+ import { FormAutosuggest, FormAutosuggestOption, FormControlFeedback } from '@openedx/paragon';
6
+ import classNames from 'classnames';
7
+ import PropTypes from 'prop-types';
8
+
9
+ import { clearRegistrationBackendError } from '../../data/actions';
10
+ import messages from '../../messages';
11
+ import validateCountryField, { COUNTRY_CODE_KEY, COUNTRY_DISPLAY_KEY } from './validator';
12
+
13
+ /**
14
+ * Country field wrapper. It accepts following handlers
15
+ * - handleChange for setting value change and
16
+ * - handleErrorChange for setting error
17
+ *
18
+ * It is responsible for
19
+ * - Auto populating country field if backendCountryCode is available in redux
20
+ * - Performing country field validations
21
+ * - clearing error on focus
22
+ * - setting value on change and selection
23
+ */
24
+ const CountryField = (props) => {
25
+ const {
26
+ countryList,
27
+ selectedCountry,
28
+ onChangeHandler,
29
+ handleErrorChange,
30
+ onFocusHandler,
31
+ } = props;
32
+ const { formatMessage } = useIntl();
33
+ const dispatch = useDispatch();
34
+
35
+ const countryFieldValue = {
36
+ userProvidedText: selectedCountry.displayValue,
37
+ selectionValue: selectedCountry.displayValue,
38
+ selectionId: selectedCountry.countryCode,
39
+ };
40
+
41
+ const backendCountryCode = useSelector(state => state.register.backendCountryCode);
42
+
43
+ useEffect(() => {
44
+ if (backendCountryCode && backendCountryCode !== selectedCountry?.countryCode) {
45
+ let countryCode = '';
46
+ let countryDisplayValue = '';
47
+
48
+ const countryVal = countryList.find(
49
+ (country) => (country[COUNTRY_CODE_KEY].toLowerCase() === backendCountryCode.toLowerCase()),
50
+ );
51
+ if (countryVal) {
52
+ countryCode = countryVal[COUNTRY_CODE_KEY];
53
+ countryDisplayValue = countryVal[COUNTRY_DISPLAY_KEY];
54
+ }
55
+ onChangeHandler(
56
+ { target: { name: 'country' } },
57
+ { countryCode, displayValue: countryDisplayValue },
58
+ );
59
+ } else if (!selectedCountry.displayValue) {
60
+ onChangeHandler(
61
+ { target: { name: 'country' } },
62
+ { countryCode: '', displayValue: '' },
63
+ );
64
+ }
65
+ }, [backendCountryCode, countryList]); // eslint-disable-line react-hooks/exhaustive-deps
66
+
67
+ const handleOnBlur = (event) => {
68
+ // Do not run validations when drop-down arrow is clicked
69
+ if (event.relatedTarget && event.relatedTarget.className.includes('pgn__form-autosuggest__icon-button')) {
70
+ return;
71
+ }
72
+
73
+ const { value } = event.target;
74
+
75
+ const { error } = validateCountryField(
76
+ value.trim(),
77
+ countryList,
78
+ formatMessage(messages['empty.country.field.error']),
79
+ formatMessage(messages['invalid.country.field.error'])
80
+ );
81
+ handleErrorChange('country', error);
82
+ };
83
+
84
+ const handleOnFocus = (event) => {
85
+ handleErrorChange('country', '');
86
+ dispatch(clearRegistrationBackendError('country'));
87
+ onFocusHandler(event);
88
+ };
89
+
90
+ const handleOnChange = (value) => {
91
+ onChangeHandler(
92
+ { target: { name: 'country' } },
93
+ { countryCode: value.selectionId, displayValue: value.userProvidedText }
94
+ );
95
+
96
+ // We have put this check because proviously we also had onSelected event handler and we call
97
+ // the onBlur on that event handler but now there is no such handler and we only have
98
+ // onChange so we check the is there is proper sectionId which only be
99
+ // proper one when we select it from dropdown's item otherwise its null.
100
+ if (value.selectionId !== '') {
101
+ handleOnBlur({ target: { name: 'country', value: value.userProvidedText } });
102
+ }
103
+ };
104
+
105
+ const getCountryList = () => countryList.map((country) => (
106
+ <FormAutosuggestOption key={country[COUNTRY_DISPLAY_KEY]} id={country[COUNTRY_CODE_KEY]}>
107
+ {country[COUNTRY_DISPLAY_KEY]}
108
+ </FormAutosuggestOption>
109
+ ));
110
+
111
+ return (
112
+ <div className="mb-4">
113
+ <FormAutosuggest
114
+ floatingLabel={formatMessage(messages['registration.country.label'])}
115
+ aria-label="form autosuggest"
116
+ name="country"
117
+ value={countryFieldValue || {}}
118
+ className={classNames({ 'form-field-error': props.errorMessage })}
119
+ onFocus={(e) => handleOnFocus(e)}
120
+ onBlur={(e) => handleOnBlur(e)}
121
+ onChange={(value) => handleOnChange(value)}
122
+ >
123
+ {getCountryList()}
124
+ </FormAutosuggest>
125
+ {props.errorMessage !== '' && (
126
+ <FormControlFeedback
127
+ key="error"
128
+ className="form-text-size"
129
+ hasIcon={false}
130
+ feedback-for="country"
131
+ type="invalid"
132
+ >
133
+ {props.errorMessage}
134
+ </FormControlFeedback>
135
+ )}
136
+ </div>
137
+ );
138
+ };
139
+
140
+ CountryField.propTypes = {
141
+ countryList: PropTypes.arrayOf(
142
+ PropTypes.shape({
143
+ code: PropTypes.string,
144
+ name: PropTypes.string,
145
+ }),
146
+ ).isRequired,
147
+ errorMessage: PropTypes.string,
148
+ onChangeHandler: PropTypes.func.isRequired,
149
+ handleErrorChange: PropTypes.func.isRequired,
150
+ onFocusHandler: PropTypes.func.isRequired,
151
+ selectedCountry: PropTypes.shape({
152
+ displayValue: PropTypes.string,
153
+ countryCode: PropTypes.string,
154
+ }),
155
+ };
156
+
157
+ CountryField.defaultProps = {
158
+ errorMessage: null,
159
+ selectedCountry: {
160
+ value: '',
161
+ },
162
+ };
163
+
164
+ export default CountryField;
@@ -0,0 +1,204 @@
1
+ import { Provider } from 'react-redux';
2
+
3
+ import { injectIntl, IntlProvider } from '@openedx/frontend-base';
4
+ import { fireEvent, render } from '@testing-library/react';
5
+ import { BrowserRouter as Router } from 'react-router-dom';
6
+ import configureStore from 'redux-mock-store';
7
+
8
+ import { CountryField } from '../index';
9
+ import { COUNTRY_CODE_KEY, COUNTRY_DISPLAY_KEY } from './validator';
10
+
11
+ const IntlCountryField = injectIntl(CountryField);
12
+ const mockStore = configureStore();
13
+
14
+ jest.mock('react-router-dom', () => {
15
+ const mockNavigation = jest.fn();
16
+
17
+ // eslint-disable-next-line react/prop-types
18
+ const Navigate = ({ to }) => {
19
+ mockNavigation(to);
20
+ return <div />;
21
+ };
22
+
23
+ return {
24
+ ...jest.requireActual('react-router-dom'),
25
+ Navigate,
26
+ mockNavigate: mockNavigation,
27
+ };
28
+ });
29
+
30
+ describe('CountryField', () => {
31
+ let props = {};
32
+ let store = {};
33
+
34
+ const reduxWrapper = children => (
35
+ <IntlProvider locale="en">
36
+ <Provider store={store}>{children}</Provider>
37
+ </IntlProvider>
38
+ );
39
+
40
+ const routerWrapper = children => (
41
+ <Router>
42
+ {children}
43
+ </Router>
44
+ );
45
+
46
+ const initialState = {
47
+ register: {},
48
+ };
49
+
50
+ beforeEach(() => {
51
+ store = mockStore(initialState);
52
+ props = {
53
+ countryList: [{
54
+ [COUNTRY_CODE_KEY]: 'PK',
55
+ [COUNTRY_DISPLAY_KEY]: 'Pakistan',
56
+ }],
57
+ selectedCountry: {
58
+ countryCode: '',
59
+ displayValue: '',
60
+ },
61
+ errorMessage: '',
62
+ onChangeHandler: jest.fn(),
63
+ handleErrorChange: jest.fn(),
64
+ onFocusHandler: jest.fn(),
65
+ };
66
+ window.location = { search: '' };
67
+ });
68
+
69
+ afterEach(() => {
70
+ jest.clearAllMocks();
71
+ });
72
+
73
+ describe('Test Country Field', () => {
74
+ const emptyFieldValidation = {
75
+ country: 'Select your country or region of residence',
76
+ };
77
+
78
+ it('should run country field validation when onBlur is fired', () => {
79
+ const { container } = render(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
80
+ const countryInput = container.querySelector('input[name="country"]');
81
+
82
+ fireEvent.blur(countryInput, {
83
+ target: { value: '', name: 'country' },
84
+ });
85
+
86
+ expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
87
+ expect(props.handleErrorChange).toHaveBeenCalledWith(
88
+ 'country',
89
+ emptyFieldValidation.country,
90
+ );
91
+ });
92
+
93
+ it('should run country field validation when country name is invalid', () => {
94
+ const { container } = render(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
95
+ const countryInput = container.querySelector('input[name="country"]');
96
+
97
+ fireEvent.blur(countryInput, {
98
+ target: { value: 'Pak', name: 'country' },
99
+ });
100
+
101
+ expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
102
+ expect(props.handleErrorChange).toHaveBeenCalledWith(
103
+ 'country',
104
+ 'Country must match with an option available in the dropdown.',
105
+ );
106
+ });
107
+
108
+ it('should not run country field validation when onBlur is fired by drop-down arrow icon click', () => {
109
+ const { container } = render(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
110
+ const countryInput = container.querySelector('input[name="country"]');
111
+ const dropdownArrowIcon = container.querySelector('.btn-icon.pgn__form-autosuggest__icon-button');
112
+
113
+ fireEvent.blur(countryInput, {
114
+ target: { value: '', name: 'country' },
115
+ relatedTarget: dropdownArrowIcon,
116
+ });
117
+
118
+ expect(props.handleErrorChange).toHaveBeenCalledTimes(0);
119
+ });
120
+
121
+ it('should update errors for frontend validations', () => {
122
+ const { container } = render(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
123
+ const countryInput = container.querySelector('input[name="country"]');
124
+
125
+ fireEvent.blur(countryInput, { target: { value: '', name: 'country' } });
126
+
127
+ expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
128
+ expect(props.handleErrorChange).toHaveBeenCalledWith('country', emptyFieldValidation.country);
129
+ });
130
+
131
+ it('should clear error on focus', () => {
132
+ const { container } = render(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
133
+ const countryInput = container.querySelector('input[name="country"]');
134
+
135
+ fireEvent.focus(countryInput);
136
+
137
+ expect(props.handleErrorChange).toHaveBeenCalledTimes(1);
138
+ expect(props.handleErrorChange).toHaveBeenCalledWith('country', '');
139
+ });
140
+
141
+ it('should update state from country code present in redux store', () => {
142
+ store = mockStore({
143
+ ...initialState,
144
+ register: {
145
+ ...initialState.register,
146
+ backendCountryCode: 'PK',
147
+ },
148
+ });
149
+
150
+ const { container } = render(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
151
+
152
+ container.querySelector('input[name="country"]');
153
+ expect(props.onChangeHandler).toHaveBeenCalledTimes(1);
154
+ expect(props.onChangeHandler).toHaveBeenCalledWith(
155
+ { target: { name: 'country' } },
156
+ { countryCode: 'PK', displayValue: 'Pakistan' },
157
+ );
158
+ });
159
+
160
+ it('should set option on dropdown menu item click', () => {
161
+ const { container } = render(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
162
+
163
+ const dropdownButton = container.querySelector('.pgn__form-autosuggest__icon-button');
164
+ fireEvent.click(dropdownButton);
165
+
166
+ const dropdownItem = container.querySelector('.dropdown-item');
167
+ fireEvent.click(dropdownItem);
168
+
169
+ expect(props.onChangeHandler).toHaveBeenCalledTimes(2);
170
+ expect(props.onChangeHandler).toHaveBeenCalledWith(
171
+ { target: { name: 'country' } },
172
+ { countryCode: 'PK', displayValue: 'Pakistan' },
173
+ );
174
+ });
175
+
176
+ it('should set value on change', () => {
177
+ const { container } = render(
178
+ routerWrapper(reduxWrapper(<IntlCountryField {...props} />)),
179
+ );
180
+
181
+ const countryInput = container.querySelector('input[name="country"]');
182
+ fireEvent.change(countryInput, { target: { value: 'pak', name: 'country' } });
183
+
184
+ expect(props.onChangeHandler).toHaveBeenCalledTimes(2);
185
+ expect(props.onChangeHandler).toHaveBeenCalledWith(
186
+ { target: { name: 'country' } },
187
+ { countryCode: '', displayValue: 'pak' },
188
+ );
189
+ });
190
+
191
+ it('should display error on invalid country input', () => {
192
+ props = {
193
+ ...props,
194
+ errorMessage: 'country error message',
195
+ };
196
+
197
+ const { container } = render(routerWrapper(reduxWrapper(<IntlCountryField {...props} />)));
198
+
199
+ const feedbackElement = container.querySelector('div[feedback-for="country"]');
200
+ expect(feedbackElement).toBeTruthy();
201
+ expect(feedbackElement.textContent).toEqual('country error message');
202
+ });
203
+ });
204
+ });