richie-education 2.33.1-dev9 → 2.34.1-dev2

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 (60) hide show
  1. package/i18n/locales/ar-SA.json +48 -8
  2. package/i18n/locales/es-ES.json +48 -8
  3. package/i18n/locales/fa-IR.json +48 -8
  4. package/i18n/locales/fr-CA.json +48 -8
  5. package/i18n/locales/fr-FR.json +48 -8
  6. package/i18n/locales/ko-KR.json +48 -8
  7. package/i18n/locales/pt-PT.json +55 -15
  8. package/i18n/locales/ru-RU.json +48 -8
  9. package/i18n/locales/vi-VN.json +48 -8
  10. package/js/api/joanie.ts +5 -0
  11. package/js/api/lms/dummy.ts +12 -10
  12. package/js/components/AddressesManagement/index.spec.tsx +1 -1
  13. package/js/components/AddressesManagement/index.tsx +123 -129
  14. package/js/components/ContractFrame/AbstractContractFrame.tsx +1 -1
  15. package/js/components/Icon/index.stories.tsx +1 -1
  16. package/js/components/PaymentInterfaces/PayplugLightbox.tsx +1 -1
  17. package/js/components/PaymentScheduleGrid/_styles.scss +6 -5
  18. package/js/components/PaymentScheduleGrid/index.tsx +16 -0
  19. package/js/components/SaleTunnel/SaleTunnelSavePaymentMethod/index.tsx +2 -2
  20. package/js/components/SearchInput/index.spec.tsx +6 -5
  21. package/js/components/SearchInput/index.tsx +9 -1
  22. package/js/hooks/useCreditCards/index.spec.tsx +83 -0
  23. package/js/hooks/useCreditCards/index.ts +53 -1
  24. package/js/hooks/useCreditCardsManagement.tsx +1 -10
  25. package/js/hooks/useLearnerCoursesSearch/index.tsx +2 -2
  26. package/js/pages/DashboardCourses/index.spec.tsx +51 -7
  27. package/js/pages/DashboardCreditCardsManagement/DashboardCreditCardBox.tsx +3 -5
  28. package/js/pages/DashboardCreditCardsManagement/_styles.scss +11 -3
  29. package/js/pages/DashboardCreditCardsManagement/index.spec.tsx +46 -25
  30. package/js/pages/DashboardCreditCardsManagement/index.tsx +21 -37
  31. package/js/pages/TeacherDashboardContractsLayout/hooks/useCheckContractArchiveExists/index.spec.tsx +4 -2
  32. package/js/pages/TeacherDashboardContractsLayout/hooks/useCheckContractArchiveExists/index.tsx +1 -1
  33. package/js/pages/TeacherDashboardOrganizationCourseLoader/index.spec.tsx +8 -5
  34. package/js/translations/ar-SA.json +1 -1
  35. package/js/translations/es-ES.json +1 -1
  36. package/js/translations/fa-IR.json +1 -1
  37. package/js/translations/fr-CA.json +1 -1
  38. package/js/translations/fr-FR.json +1 -1
  39. package/js/translations/ko-KR.json +1 -1
  40. package/js/translations/pt-PT.json +1 -1
  41. package/js/translations/ru-RU.json +1 -1
  42. package/js/translations/vi-VN.json +1 -1
  43. package/js/types/Joanie.ts +13 -1
  44. package/js/utils/OrderHelper/index.ts +9 -0
  45. package/js/utils/errors/HttpError.ts +1 -0
  46. package/js/utils/test/wrappers/types.ts +2 -2
  47. package/js/widgets/Dashboard/components/DashboardItem/Enrollment/ProductCertificateFooter/index.spec.tsx +7 -1
  48. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrderContract.useUnionResource.cache.spec.tsx +14 -2
  49. package/js/widgets/Dashboard/components/DashboardItem/Order/Installment/index.tsx +3 -2
  50. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentDetailsModal/index.tsx +12 -5
  51. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentRetryModal/index.tsx +1 -1
  52. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateLearnerMessage/index.tsx +5 -0
  53. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateMessage/index.tsx +10 -8
  54. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateTeacherMessage/index.tsx +5 -0
  55. package/js/widgets/Dashboard/components/SearchBar/index.spec.tsx +1 -1
  56. package/js/widgets/Dashboard/components/SearchResultsCount/index.spec.tsx +1 -1
  57. package/js/widgets/Dashboard/index.spec.tsx +7 -1
  58. package/js/widgets/SyllabusCourseRunsList/components/CourseRunEnrollment/index.tsx +1 -5
  59. package/package.json +41 -39
  60. package/scss/vendors/css/cunningham-tokens.css +1 -0
@@ -9,6 +9,11 @@ const messages = defineMessages({
9
9
  description: 'Accessibility text for the search button inside the Search input.',
10
10
  id: 'components.SearchInput.button',
11
11
  },
12
+ label: {
13
+ defaultMessage: 'Search',
14
+ description: 'Accessibility text for the search input label.',
15
+ id: 'components.SearchInput.label',
16
+ },
12
17
  });
13
18
 
14
19
  /**
@@ -23,7 +28,10 @@ export const SearchInput = ({
23
28
  onClick = () => {}, // by default, do nothing, this will just remove focus and close suggestions
24
29
  }: { inputProps: any; onClick?: () => void } & CommonDataProps) => (
25
30
  <div className="search-input">
26
- <input {...inputProps} />
31
+ <label className="offscreen" htmlFor="search-input-field">
32
+ <FormattedMessage {...messages.label} />
33
+ </label>
34
+ <input {...inputProps} id="search-input-field" />
27
35
  <button className="search-input__btn" onClick={onClick}>
28
36
  <Icon name={IconTypeEnum.MAGNIFYING_GLASS} className="search-input__btn__icon" />{' '}
29
37
  <span className="offscreen">
@@ -175,4 +175,87 @@ describe('useCreditCards', () => {
175
175
  expect(result.current.states.isPending).toBe(false);
176
176
  expect(result.current.states.tokenizing).toBe(false);
177
177
  });
178
+
179
+ it('promotes a credit card', async () => {
180
+ const responseDeferred = new Deferred();
181
+ fetchMock.patch(
182
+ 'https://joanie.endpoint/api/v1.0/credit-cards/1/promote/',
183
+ responseDeferred.promise,
184
+ );
185
+ const { result } = renderHook(() => useCreditCards(undefined, { enabled: false }), {
186
+ wrapper: Wrapper,
187
+ });
188
+
189
+ await waitFor(() => {
190
+ expect(result.current).not.toBeNull();
191
+ });
192
+
193
+ await act(async () => {
194
+ result.current.methods.promote('1');
195
+ });
196
+
197
+ await waitFor(() => {
198
+ expect(result.current.states.updating).toBe(true);
199
+ });
200
+
201
+ expect(result.current.states.deleting).toBe(false);
202
+ expect(result.current.states.tokenizing).toBe(false);
203
+ expect(result.current.states.fetching).toBe(false);
204
+ expect(result.current.states.isFetched).toBe(true);
205
+ expect(result.current.states.isPending).toBe(true);
206
+ expect(result.current.states.error).toBe(undefined);
207
+
208
+ await act(async () => {
209
+ responseDeferred.resolve({});
210
+ });
211
+
212
+ expect(result.current.states.updating).toBe(false);
213
+ expect(result.current.states.isPending).toBe(false);
214
+ expect(result.current.states.error).toBe(undefined);
215
+ });
216
+
217
+ it('manages error during credit card promotion', async () => {
218
+ fetchMock.patch(
219
+ 'https://joanie.endpoint/api/v1.0/credit-cards/1/promote/',
220
+ HttpStatusCode.INTERNAL_SERVER_ERROR,
221
+ );
222
+ const { result } = renderHook(() => useCreditCards(undefined, { enabled: true }), {
223
+ wrapper: Wrapper,
224
+ });
225
+
226
+ await waitFor(() => {
227
+ expect(result.current).not.toBeNull();
228
+ });
229
+
230
+ await act(async () => {
231
+ await expect(result.current.methods.promote('1')).rejects.toThrow('Internal Server Error');
232
+ });
233
+
234
+ expect(result.current.states.error).toBe('Cannot set the credit card as default');
235
+ expect(result.current.states.isPending).toBe(false);
236
+ expect(result.current.states.updating).toBe(false);
237
+ });
238
+
239
+ it('has a specific error when credit card deletion fails because it is used', async () => {
240
+ const creditCard = CreditCardFactory({
241
+ id: '1',
242
+ last_numbers: '1337',
243
+ }).one();
244
+ fetchMock.delete('https://joanie.endpoint/api/v1.0/credit-cards/1/', HttpStatusCode.CONFLICT);
245
+ const { result } = renderHook(() => useCreditCards(undefined, { enabled: true }), {
246
+ wrapper: Wrapper,
247
+ });
248
+
249
+ await waitFor(() => {
250
+ expect(result.current).not.toBeNull();
251
+ });
252
+
253
+ await act(async () => {
254
+ result.current.methods.delete(creditCard);
255
+ });
256
+
257
+ expect(result.current.states.error).toBe(
258
+ 'Cannot delete the credit card •••• •••• •••• 1337 because it is used to pay at least one of your order.',
259
+ );
260
+ });
178
261
  });
@@ -1,8 +1,10 @@
1
1
  import { defineMessages, useIntl } from 'react-intl';
2
2
  import { useMutation, useQueryClient } from '@tanstack/react-query';
3
+ import { MutateOptions } from '@tanstack/query-core';
3
4
  import { API, CreditCard } from 'types/Joanie';
4
5
  import { useJoanieApi } from 'contexts/JoanieApiContext';
5
6
  import { useSessionMutation } from 'utils/react-query/useSessionMutation';
7
+ import { HttpError, HttpStatusCode } from 'utils/errors/HttpError';
6
8
  import {
7
9
  QueryOptions,
8
10
  ResourcesQuery,
@@ -27,6 +29,13 @@ const messages = defineMessages({
27
29
  description: 'Error message shown to the user when credit card deletion request fails.',
28
30
  defaultMessage: 'An error occurred while deleting the credit card. Please retry later.',
29
31
  },
32
+ errorCannotDelete: {
33
+ id: 'hooks.useCreditCards.errorCannotDelete',
34
+ description:
35
+ 'Error message shown to the user when trying to delete a credit card that is used to pay at least order.',
36
+ defaultMessage:
37
+ 'Cannot delete the credit card •••• •••• •••• {last_numbers} because it is used to pay at least one of your order.',
38
+ },
30
39
  errorTokenize: {
31
40
  id: 'hooks.useCreditCards.errorTokenize',
32
41
  description: 'Error message shown to the user when credit card tokenize request fails.',
@@ -37,6 +46,11 @@ const messages = defineMessages({
37
46
  description: 'Error message shown to the user when no credit cards matches.',
38
47
  defaultMessage: 'Cannot find the credit card',
39
48
  },
49
+ errorPromote: {
50
+ id: 'hooks.useCreditCards.errorPromote',
51
+ description: 'Error message shown to the user when promoting a credit card fails.',
52
+ defaultMessage: 'Cannot set the credit card as default',
53
+ },
40
54
  });
41
55
 
42
56
  const useCreditCardResources =
@@ -55,17 +69,55 @@ const useCreditCardResources =
55
69
  },
56
70
  onError: () => custom.methods.setError(intl.formatMessage(messages.errorTokenize)),
57
71
  });
72
+ const promoteHandler = mutation({
73
+ mutationFn: api.promote,
74
+ onSuccess: async () => {
75
+ custom.methods.setError(undefined);
76
+ custom.methods.invalidate();
77
+ props.onMutationSuccess?.(queryClient);
78
+ },
79
+ onError: () => custom.methods.setError(intl.formatMessage(messages.errorPromote)),
80
+ });
81
+
82
+ /**
83
+ * Override the default delete mutation to handle error more specifically.
84
+ * If the error is a 409, it means the credit card is used to pay at least one order
85
+ * and the user should be informed about that.
86
+ */
87
+ const deleteMutateAsync = async (creditCard: CreditCard, options?: MutateOptions) => {
88
+ return custom.methods.delete(creditCard.id, {
89
+ ...options,
90
+ onError: (error: HttpError, variables, context) => {
91
+ if (error.code === HttpStatusCode.CONFLICT) {
92
+ custom.methods.setError(
93
+ intl.formatMessage(messages.errorCannotDelete, {
94
+ last_numbers: creditCard.last_numbers,
95
+ }),
96
+ );
97
+ } else {
98
+ custom.methods.setError(intl.formatMessage(messages.errorDelete));
99
+ }
100
+ options?.onError?.(error, variables, context);
101
+ },
102
+ });
103
+ };
58
104
 
59
105
  return {
60
106
  ...custom,
61
107
  methods: {
62
108
  ...custom.methods,
109
+ delete: deleteMutateAsync,
63
110
  tokenize: tokenizeHandler.mutateAsync,
111
+ promote: promoteHandler.mutateAsync,
64
112
  },
65
113
  states: {
66
114
  ...custom.states,
67
- isPending: [tokenizeHandler, custom.states].some((value) => value?.isPending),
115
+ isPending: [tokenizeHandler, promoteHandler, custom.states].some(
116
+ (value) => value?.isPending,
117
+ ),
118
+ updating: custom.states.updating || promoteHandler.isPending,
68
119
  tokenizing: tokenizeHandler.isPending,
120
+ promoting: promoteHandler.isPending,
69
121
  },
70
122
  };
71
123
  };
@@ -5,11 +5,6 @@ import { CreditCard } from 'types/Joanie';
5
5
  import { confirm } from 'utils/indirection/window';
6
6
 
7
7
  const messages = defineMessages({
8
- errorCannotRemoveMain: {
9
- id: 'hooks.useCreditCardsManagement.errorCannotRemoveMain',
10
- description: 'Error shown if a user tries to delete a main credit card',
11
- defaultMessage: 'Cannot remove main credit card.',
12
- },
13
8
  deletionConfirmation: {
14
9
  id: 'hooks.useCreditCardsManagement.deletionConfirmation',
15
10
  description: 'Confirmation message shown to the user when he wants to delete a credit card',
@@ -23,15 +18,11 @@ export const useCreditCardsManagement = () => {
23
18
  const creditCards = useCreditCards();
24
19
 
25
20
  const safeDelete = (creditCard: CreditCard, options?: MutateOptions) => {
26
- if (creditCard.is_main) {
27
- creditCards.methods.setError(intl.formatMessage(messages.errorCannotRemoveMain));
28
- return;
29
- }
30
21
  const sure = confirm(intl.formatMessage(messages.deletionConfirmation));
31
22
  if (!sure) {
32
23
  return;
33
24
  }
34
- creditCards.methods.delete(creditCard.id, options);
25
+ creditCards.methods.delete(creditCard, options);
35
26
  };
36
27
 
37
28
  return {
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useState } from 'react';
2
2
  import { useSearchParams } from 'react-router';
3
- import { Enrollment, CredentialOrder, OrderState, ProductType } from 'types/Joanie';
3
+ import { Enrollment, CredentialOrder, ProductType, CANCELED_ORDER_STATES } from 'types/Joanie';
4
4
  import { Maybe, Nullable } from 'types/utils';
5
5
  import { useOrdersEnrollments } from 'pages/DashboardCourses/useOrdersEnrollments';
6
6
 
@@ -23,7 +23,7 @@ const useLearnerCoursesSearch = () => {
23
23
  query,
24
24
  orderFilters: {
25
25
  product_type: [ProductType.CREDENTIAL],
26
- state_exclude: [OrderState.CANCELED],
26
+ state_exclude: CANCELED_ORDER_STATES,
27
27
  },
28
28
  });
29
29
 
@@ -103,7 +103,13 @@ describe('<DashboardCourses/>', () => {
103
103
  it('renders an empty placeholder', async () => {
104
104
  const ordersDeferred = new Deferred();
105
105
  fetchMock.get(
106
- `https://joanie.endpoint/api/v1.0/orders/?product_type=credential&state_exclude=canceled&page=1&page_size=${perPage}`,
106
+ 'https://joanie.endpoint/api/v1.0/orders/' +
107
+ '?product_type=credential' +
108
+ '&state_exclude=canceled' +
109
+ '&state_exclude=refunding' +
110
+ '&state_exclude=refunded' +
111
+ '&page=1' +
112
+ `&page_size=${perPage}`,
107
113
  ordersDeferred.promise,
108
114
  );
109
115
  const enrollmentsDeferred = new Deferred();
@@ -145,25 +151,57 @@ describe('<DashboardCourses/>', () => {
145
151
  client,
146
152
  );
147
153
  fetchMock.get(
148
- `https://joanie.endpoint/api/v1.0/orders/?product_type=credential&state_exclude=canceled&page=1&page_size=${perPage}`,
154
+ 'https://joanie.endpoint/api/v1.0/orders/' +
155
+ '?product_type=credential' +
156
+ '&state_exclude=canceled' +
157
+ '&state_exclude=refunding' +
158
+ '&state_exclude=refunded' +
159
+ '&page=1' +
160
+ `&page_size=${perPage}`,
149
161
  {
150
162
  results: orders.slice(0, perPage),
151
- next: `https://joanie.endpoint/api/v1.0/orders/?product_type=credentia&state_exclude=canceled&page=2&page_size=${perPage}`,
163
+ next:
164
+ 'https://joanie.endpoint/api/v1.0/orders/' +
165
+ '?product_type=credential' +
166
+ '&state_exclude=canceled' +
167
+ '&state_exclude=refunding' +
168
+ '&state_exclude=refunded' +
169
+ '&page=2' +
170
+ `&page_size=${perPage}`,
152
171
  previous: null,
153
172
  count: orders.length,
154
173
  },
155
174
  );
156
175
  fetchMock.get(
157
- `https://joanie.endpoint/api/v1.0/orders/?product_type=credential&state_exclude=canceled&page=2&page_size=${perPage}`,
176
+ 'https://joanie.endpoint/api/v1.0/orders/' +
177
+ '?product_type=credential' +
178
+ '&state_exclude=canceled' +
179
+ '&state_exclude=refunding' +
180
+ '&state_exclude=refunded' +
181
+ '&page=2' +
182
+ `&page_size=${perPage}`,
158
183
  {
159
184
  results: orders.slice(perPage, perPage * 2),
160
- next: `https://joanie.endpoint/api/v1.0/orders/?product_type=credential&state_exclude=canceled&page=3&page_size=${perPage}`,
185
+ next:
186
+ 'https://joanie.endpoint/api/v1.0/orders/' +
187
+ '?product_type=credential' +
188
+ '&state_exclude=canceled' +
189
+ '&state_exclude=refunding' +
190
+ '&state_exclude=refunded' +
191
+ '&page=3' +
192
+ `&page_size=${perPage}`,
161
193
  previous: null,
162
194
  count: orders.length,
163
195
  },
164
196
  );
165
197
  fetchMock.get(
166
- `https://joanie.endpoint/api/v1.0/orders/?product_type=credential&state_exclude=canceled&page=3&page_size=${perPage}`,
198
+ 'https://joanie.endpoint/api/v1.0/orders/' +
199
+ '?product_type=credential' +
200
+ '&state_exclude=canceled' +
201
+ '&state_exclude=refunding' +
202
+ '&state_exclude=refunded' +
203
+ '&page=3' +
204
+ `&page_size=${perPage}`,
167
205
  {
168
206
  results: orders.slice(perPage * 2, perPage * 3),
169
207
  next: null,
@@ -237,7 +275,13 @@ describe('<DashboardCourses/>', () => {
237
275
  jest.spyOn(console, 'error').mockImplementation(noop);
238
276
  const ordersDeferred = new Deferred();
239
277
  fetchMock.get(
240
- `https://joanie.endpoint/api/v1.0/orders/?product_type=credential&state_exclude=canceled&page=1&page_size=${perPage}`,
278
+ 'https://joanie.endpoint/api/v1.0/orders/' +
279
+ '?product_type=credential' +
280
+ '&state_exclude=canceled' +
281
+ '&state_exclude=refunding' +
282
+ '&state_exclude=refunded' +
283
+ '&page=1' +
284
+ `&page_size=${perPage}`,
241
285
  ordersDeferred.promise,
242
286
  );
243
287
  fetchMock.get(
@@ -76,11 +76,9 @@ export const DashboardCreditCardBox = ({ creditCard, promote, edit, remove }: Pr
76
76
  <FormattedMessage {...messages.edit} />
77
77
  </Button>
78
78
  </div>
79
- {!creditCard.is_main && (
80
- <Button color="primary" onClick={() => remove(creditCard)}>
81
- <FormattedMessage {...messages.delete} />
82
- </Button>
83
- )}
79
+ <Button color="primary" onClick={() => remove(creditCard)}>
80
+ <FormattedMessage {...messages.delete} />
81
+ </Button>
84
82
  </>
85
83
  }
86
84
  >
@@ -2,12 +2,23 @@
2
2
  display: flex;
3
3
  flex-direction: column;
4
4
  gap: 2rem;
5
+ position: relative;
5
6
 
6
7
  &__empty {
7
8
  text-align: center;
8
9
  color: r-theme-val(dashboard-credit-cards, empty-color);
9
10
  margin: 0;
10
11
  }
12
+
13
+ &__loading-overlay {
14
+ position: absolute;
15
+ pointer-events: none;
16
+ inset: 0;
17
+ display: grid;
18
+ place-items: center;
19
+ background-color: rgba(255, 255, 255, 0.8);
20
+ z-index: 9999;
21
+ }
11
22
  }
12
23
 
13
24
  .dashboard-credit-card {
@@ -34,9 +45,6 @@
34
45
  }
35
46
  }
36
47
 
37
- &__data {
38
- }
39
-
40
48
  &__brand {
41
49
  font-weight: bold;
42
50
 
@@ -191,7 +191,7 @@ describe('<DashboardCreditCardsManagement/>', () => {
191
191
  });
192
192
 
193
193
  it('deletes a credit card', async () => {
194
- const creditCards = CreditCardFactory().many(5);
194
+ const creditCards = CreditCardFactory().many(2);
195
195
  fetchMock.get('https://joanie.endpoint/api/v1.0/credit-cards/', creditCards);
196
196
  render(<DashboardPreferences />, {
197
197
  queryOptions: { client: createTestQueryClient({ user: richieUser }) },
@@ -231,6 +231,46 @@ describe('<DashboardCreditCardsManagement/>', () => {
231
231
  expect(screen.queryByText(creditCard.title!)).toBeNull();
232
232
  });
233
233
 
234
+ it('deletes a main credit card', async () => {
235
+ const creditCard = CreditCardFactory({ is_main: true }).one();
236
+ fetchMock.get('https://joanie.endpoint/api/v1.0/credit-cards/', [creditCard]);
237
+ render(<DashboardPreferences />, {
238
+ queryOptions: { client: createTestQueryClient({ user: richieUser }) },
239
+ });
240
+ // No error is shown.
241
+ expect(screen.queryByText('An error occurred', { exact: false })).toBeNull();
242
+
243
+ // Find the delete button of the credit card.
244
+ await screen.findByText(creditCard.title!);
245
+ const creditCardContainer = screen.getByTestId('dashboard-credit-card__' + creditCard.id);
246
+ const deleteButton = getByRole(creditCardContainer, 'button', {
247
+ name: 'Delete',
248
+ });
249
+
250
+ // Mock delete route and the refresh route to returns `creditCards` without the first one.
251
+ const deleteUrl = 'https://joanie.endpoint/api/v1.0/credit-cards/' + creditCard.id + '/';
252
+ fetchMock.delete(deleteUrl, []);
253
+ fetchMock.get('https://joanie.endpoint/api/v1.0/credit-cards/', [], {
254
+ overwriteRoutes: true,
255
+ });
256
+
257
+ // Clicking on the delete button calls delete API route.
258
+ expect(fetchMock.called(deleteUrl)).toBe(false);
259
+ // Clicking on the delete button calls window.confirm function.
260
+ expect(confirm).not.toHaveBeenCalled();
261
+ await act(async () => {
262
+ fireEvent.click(deleteButton);
263
+ });
264
+ expect(fetchMock.called(deleteUrl)).toBe(true);
265
+ expect(confirm).toHaveBeenCalled();
266
+
267
+ // No error is shown.
268
+ expect(screen.queryByText('An error occurred', { exact: false })).toBeNull();
269
+
270
+ // The address does not appear anymore in the list.
271
+ expect(screen.queryByText(creditCard.title!)).toBeNull();
272
+ });
273
+
234
274
  it('promotes a credit card', async () => {
235
275
  const creditCards = CreditCardFactory().many(5);
236
276
  fetchMock.get('https://joanie.endpoint/api/v1.0/credit-cards/', creditCards);
@@ -251,8 +291,9 @@ describe('<DashboardCreditCardsManagement/>', () => {
251
291
  expect(queryByText(creditCardContainer, 'Default credit card')).toBeNull();
252
292
 
253
293
  // Mock the update url and the refresh URL to return the first credit card as main.
254
- const updateUrl = 'https://joanie.endpoint/api/v1.0/credit-cards/' + creditCard.id + '/';
255
- fetchMock.put(updateUrl, []);
294
+ const promoteUrl =
295
+ 'https://joanie.endpoint/api/v1.0/credit-cards/' + creditCard.id + '/promote/';
296
+ fetchMock.patch(promoteUrl, {});
256
297
  fetchMock.get(
257
298
  'https://joanie.endpoint/api/v1.0/credit-cards/',
258
299
  [{ ...creditCard, is_main: true }, ...creditCards.splice(1)],
@@ -260,11 +301,11 @@ describe('<DashboardCreditCardsManagement/>', () => {
260
301
  );
261
302
 
262
303
  // Clicking on the promote button calls the update API route.
263
- expect(fetchMock.called(updateUrl)).toBe(false);
304
+ expect(fetchMock.called(promoteUrl)).toBe(false);
264
305
  await act(async () => {
265
306
  fireEvent.click(promoteButton);
266
307
  });
267
- expect(fetchMock.called(updateUrl)).toBe(true);
308
+ expect(fetchMock.called(promoteUrl)).toBe(true);
268
309
 
269
310
  // Assert that "Default credit card" is displayed on the credit card's box.
270
311
  creditCardContainer = screen.getByTestId('dashboard-credit-card__' + creditCard.id);
@@ -298,26 +339,6 @@ describe('<DashboardCreditCardsManagement/>', () => {
298
339
  );
299
340
  });
300
341
 
301
- it('cannot delete a main credit card', async () => {
302
- const creditCards = CreditCardFactory().many(5);
303
- const mainCreditCard = creditCards[3];
304
- mainCreditCard.is_main = true;
305
- fetchMock.get('https://joanie.endpoint/api/v1.0/credit-cards/', creditCards);
306
- render(<DashboardPreferences />, {
307
- queryOptions: { client: createTestQueryClient({ user: richieUser }) },
308
- });
309
-
310
- // The delete button is not displayed.
311
- const creditCardContainer = await screen.findByTestId(
312
- 'dashboard-credit-card__' + mainCreditCard.id,
313
- );
314
- expect(
315
- queryByRole(creditCardContainer, 'button', {
316
- name: 'Delete',
317
- }),
318
- ).toBeNull();
319
- });
320
-
321
342
  it('cannot promote a main credit card', async () => {
322
343
  const creditCards = CreditCardFactory().many(5);
323
344
  const mainCreditCard = creditCards[3];
@@ -13,11 +13,6 @@ const messages = defineMessages({
13
13
  description: 'Title of the dashboard credit cards management block',
14
14
  defaultMessage: 'Credit cards',
15
15
  },
16
- errorCannotPromoteMain: {
17
- id: 'components.DashboardCreditCardsManagement.errorCannotPromoteMain',
18
- description: 'Error shown if a user tries to promote a main credit card',
19
- defaultMessage: 'Cannot promote main credit card.',
20
- },
21
16
  emptyList: {
22
17
  id: 'components.DashboardCreditCardsManagement.emptyList',
23
18
  description: 'Empty placeholder of the dashboard credit cards management block',
@@ -33,7 +28,7 @@ export const DashboardCreditCardsManagement = ({ onClickEdit }: Props) => {
33
28
  const intl = useIntl();
34
29
  const {
35
30
  states: { error, isPending },
36
- methods: { setError, update, safeDelete },
31
+ methods: { promote, safeDelete },
37
32
  ...creditCards
38
33
  } = useCreditCardsManagement();
39
34
 
@@ -54,40 +49,29 @@ export const DashboardCreditCardsManagement = ({ onClickEdit }: Props) => {
54
49
  return creditCards.items.sort(sortByMainFirstThenByTitle);
55
50
  }, [creditCards.items]);
56
51
 
57
- const promote = (creditCard: CreditCard) => {
58
- if (creditCard.is_main) {
59
- setError(intl.formatMessage(messages.errorCannotPromoteMain));
60
- return;
61
- }
62
- update({
63
- ...creditCard,
64
- is_main: true,
65
- });
66
- };
67
-
68
52
  return (
69
53
  <DashboardCard header={<FormattedMessage {...messages.header} />}>
70
- <div className="dashboard-credit-cards">
71
- {isPending && <Spinner />}
72
- {!isPending && (
73
- <>
74
- {error && <Banner message={error} type={BannerType.ERROR} rounded />}
75
- {!error && creditCardsList.length === 0 && (
76
- <p className="dashboard-credit-cards__empty">
77
- <FormattedMessage {...messages.emptyList} />
78
- </p>
79
- )}
80
- {creditCardsList.map((creditCard) => (
81
- <DashboardCreditCardBox
82
- creditCard={creditCard}
83
- key={creditCard.id}
84
- edit={(_creditCard) => onClickEdit?.(_creditCard)}
85
- promote={promote}
86
- remove={safeDelete}
87
- />
88
- ))}
89
- </>
54
+ <div className="dashboard-credit-cards" aria-busy={isPending}>
55
+ {isPending && (
56
+ <div className="dashboard-credit-cards__loading-overlay">
57
+ <Spinner />
58
+ </div>
59
+ )}
60
+ {error && <Banner message={error} type={BannerType.ERROR} rounded />}
61
+ {!error && creditCardsList.length === 0 && (
62
+ <p className="dashboard-credit-cards__empty">
63
+ <FormattedMessage {...messages.emptyList} />
64
+ </p>
90
65
  )}
66
+ {creditCardsList.map((creditCard) => (
67
+ <DashboardCreditCardBox
68
+ key={creditCard.id}
69
+ creditCard={creditCard}
70
+ edit={(instance) => onClickEdit?.(instance)}
71
+ promote={({ id }) => promote(id)}
72
+ remove={safeDelete}
73
+ />
74
+ ))}
91
75
  </div>
92
76
  </DashboardCard>
93
77
  );
@@ -98,7 +98,7 @@ describe.each([
98
98
  expect(mockCheckArchive).not.toHaveBeenCalled();
99
99
  });
100
100
 
101
- it('should check if archive exist when a id is stored', async () => {
101
+ it('should check if archive exist when an id is stored', async () => {
102
102
  storeContractArchiveId({
103
103
  ...localStorageArchiveFilters,
104
104
  contractArchiveId: faker.string.uuid(),
@@ -115,7 +115,9 @@ describe.each([
115
115
  expect(mockCheckArchive).toHaveBeenCalledTimes(1);
116
116
  });
117
117
 
118
- expect(result.current.isPolling).toBe(false);
118
+ await waitFor(() => {
119
+ expect(result.current.isPolling).toBe(false);
120
+ });
119
121
  expect(result.current.isContractArchiveExists).toBe(true);
120
122
  });
121
123
 
@@ -25,7 +25,7 @@ const useCheckContractArchiveExist = (
25
25
  // stay null until fetched
26
26
  const [isContractArchiveExists, setIsContractArchiveExists] = useState<Nullable<boolean>>(null);
27
27
 
28
- const timeoutRef = useRef<NodeJS.Timeout>();
28
+ const timeoutRef = useRef<NodeJS.Timeout>(undefined);
29
29
 
30
30
  // This method will check if the archive exists on the server
31
31
  // option.polling === true will recursivly poll archive existence
@@ -41,6 +41,9 @@ describe('components/TeacherDashboardOrganizationCourseLoader', () => {
41
41
  beforeEach(() => {
42
42
  nbApiCalls = joanieSessionData.nbSessionApiRequest;
43
43
  });
44
+ afterEach(() => {
45
+ fetchMock.restore();
46
+ });
44
47
 
45
48
  it('should render', async () => {
46
49
  const organization = OrganizationFactory().one();
@@ -59,6 +62,10 @@ describe('components/TeacherDashboardOrganizationCourseLoader', () => {
59
62
  `https://joanie.endpoint/api/v1.0/organizations/${organization.id}/course-product-relations/?product_type=credential&page=1&page_size=${perPage}`,
60
63
  mockPaginatedResponse(CourseProductRelationFactory().many(15), 15, false),
61
64
  );
65
+ fetchMock.get(
66
+ `https://joanie.endpoint/api/v1.0/organizations/${organization.id}/contracts/?signature_state=half_signed&page=1`,
67
+ [],
68
+ );
62
69
 
63
70
  render(<TeacherDashboardOrganizationCourseLoader />, {
64
71
  routerOptions: {
@@ -70,6 +77,7 @@ describe('components/TeacherDashboardOrganizationCourseLoader', () => {
70
77
 
71
78
  nbApiCalls += 1; // course api call
72
79
  nbApiCalls += 1; // course-product-relations api call
80
+ nbApiCalls += 1; // contracts api call
73
81
  const calledUrls = fetchMock.calls().map((call) => call[0]);
74
82
  expect(calledUrls).toHaveLength(nbApiCalls);
75
83
  expect(calledUrls).toContain(
@@ -78,11 +86,6 @@ describe('components/TeacherDashboardOrganizationCourseLoader', () => {
78
86
  expect(calledUrls).toContain(
79
87
  `https://joanie.endpoint/api/v1.0/organizations/${organization.id}/course-product-relations/?product_type=credential&page=1&page_size=${perPage}`,
80
88
  );
81
-
82
- fetchMock.get(
83
- `https://joanie.endpoint/api/v1.0/organizations/${organization.id}/contracts/?signature_state=half_signed&page=1`,
84
- [],
85
- );
86
89
  await expectNoSpinner('Loading organization...');
87
90
 
88
91
  expect(