richie-education 3.1.3-dev2 → 3.1.3-dev23

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 (86) hide show
  1. package/js/api/joanie.ts +8 -8
  2. package/js/components/ContractFrame/OrganizationContractFrame.spec.tsx +11 -19
  3. package/js/components/ContractFrame/OrganizationContractFrame.tsx +4 -4
  4. package/js/components/CourseGlimpse/index.spec.tsx +2 -0
  5. package/js/components/CourseGlimpse/index.tsx +2 -0
  6. package/js/components/CourseGlimpse/utils.ts +29 -30
  7. package/js/components/CourseGlimpseList/utils.ts +2 -2
  8. package/js/components/PurchaseButton/index.tsx +3 -3
  9. package/js/components/SaleTunnel/CredentialSaleTunnel/index.tsx +1 -3
  10. package/js/components/SaleTunnel/GenericSaleTunnel.tsx +3 -1
  11. package/js/components/SaleTunnel/SaleTunnelInformation/index.tsx +5 -3
  12. package/js/components/SaleTunnel/SubscriptionButton/index.tsx +1 -2
  13. package/js/components/SaleTunnel/index.credential.spec.tsx +5 -19
  14. package/js/components/SaleTunnel/index.full-process.spec.tsx +3 -3
  15. package/js/components/SaleTunnel/index.spec.tsx +116 -28
  16. package/js/components/SaleTunnel/index.stories.tsx +0 -1
  17. package/js/components/SaleTunnel/index.tsx +2 -2
  18. package/js/components/TeacherDashboardCourseList/index.spec.tsx +3 -3
  19. package/js/components/TeacherDashboardCourseList/index.tsx +2 -2
  20. package/js/hooks/useContractArchive/index.ts +3 -3
  21. package/js/hooks/useCourseProductUnion/index.spec.tsx +16 -18
  22. package/js/hooks/useCourseProductUnion/index.ts +7 -7
  23. package/js/hooks/useCourseProducts.ts +4 -8
  24. package/js/hooks/useDefaultOrganizationId/index.tsx +4 -7
  25. package/js/hooks/useOffering/index.ts +32 -0
  26. package/js/hooks/useTeacherCoursesSearch/index.tsx +2 -2
  27. package/js/hooks/useTeacherPendingContractsCount/index.ts +4 -4
  28. package/js/pages/DashboardCourses/index.spec.tsx +14 -14
  29. package/js/pages/TeacherDashboardContractsLayout/TeacherDashboardContracts/index.spec.tsx +11 -14
  30. package/js/pages/TeacherDashboardContractsLayout/TeacherDashboardContracts/index.tsx +4 -9
  31. package/js/pages/TeacherDashboardContractsLayout/components/BulkDownloadContractButton/index.spec.tsx +11 -11
  32. package/js/pages/TeacherDashboardContractsLayout/components/BulkDownloadContractButton/index.timer.spec.tsx +10 -13
  33. package/js/pages/TeacherDashboardContractsLayout/components/BulkDownloadContractButton/index.tsx +4 -4
  34. package/js/pages/TeacherDashboardContractsLayout/components/ContractActionsBar/index.spec.tsx +20 -28
  35. package/js/pages/TeacherDashboardContractsLayout/components/ContractActionsBar/index.tsx +8 -11
  36. package/js/pages/TeacherDashboardContractsLayout/components/SignOrganizationContractButton/index.spec.tsx +6 -6
  37. package/js/pages/TeacherDashboardContractsLayout/components/SignOrganizationContractButton/index.tsx +4 -4
  38. package/js/pages/TeacherDashboardContractsLayout/hooks/useCheckContractArchiveExists/index.spec.tsx +7 -7
  39. package/js/pages/TeacherDashboardContractsLayout/hooks/useCheckContractArchiveExists/index.tsx +5 -5
  40. package/js/pages/TeacherDashboardContractsLayout/hooks/useDownloadContractArchive/contractArchiveLocalStorage.spec.ts +21 -28
  41. package/js/pages/TeacherDashboardContractsLayout/hooks/useDownloadContractArchive/contractArchiveLocalStorage.ts +13 -17
  42. package/js/pages/TeacherDashboardContractsLayout/hooks/useDownloadContractArchive/index.spec.tsx +11 -13
  43. package/js/pages/TeacherDashboardContractsLayout/hooks/useDownloadContractArchive/index.tsx +6 -6
  44. package/js/pages/TeacherDashboardContractsLayout/hooks/useHasContractToDownload/index.tsx +3 -3
  45. package/js/pages/TeacherDashboardContractsLayout/hooks/useTeacherContractFilters/index.spec.tsx +16 -16
  46. package/js/pages/TeacherDashboardContractsLayout/hooks/useTeacherContractFilters/index.tsx +4 -4
  47. package/js/pages/TeacherDashboardContractsLayout/hooks/useTeacherContractsToSign.tsx +4 -4
  48. package/js/pages/TeacherDashboardCourseLearnersLayout/hooks/useCourseLearnersFilters/index.spec.tsx +21 -21
  49. package/js/pages/TeacherDashboardCourseLearnersLayout/hooks/useCourseLearnersFilters/index.ts +5 -10
  50. package/js/pages/TeacherDashboardCourseLearnersLayout/index.spec.tsx +61 -79
  51. package/js/pages/TeacherDashboardCourseLearnersLayout/index.tsx +1 -1
  52. package/js/pages/TeacherDashboardCoursesLoader/index.spec.tsx +11 -11
  53. package/js/pages/TeacherDashboardOrganizationCourseLoader/index.spec.tsx +11 -11
  54. package/js/pages/TeacherDashboardTraining/TeacherDashboardTrainingLoader.tsx +7 -7
  55. package/js/pages/TeacherDashboardTraining/index.spec.tsx +21 -29
  56. package/js/pages/TeacherDashboardTraining/index.tsx +12 -16
  57. package/js/types/Course.ts +2 -0
  58. package/js/types/Joanie.ts +34 -29
  59. package/js/types/index.ts +4 -2
  60. package/js/utils/ProductHelper/index.ts +1 -5
  61. package/js/utils/test/factories/joanie.ts +17 -25
  62. package/js/utils/test/factories/richie.ts +6 -2
  63. package/js/utils/test/mockCourseProductWithOrder.ts +4 -4
  64. package/js/widgets/Dashboard/components/DashboardItem/Enrollment/DashboardItemEnrollment.tsx +1 -1
  65. package/js/widgets/Dashboard/components/DashboardItem/Enrollment/ProductCertificateFooter/index.spec.tsx +1 -1
  66. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.tsx +3 -3
  67. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrderContract.useUnionResource.cache.spec.tsx +1 -1
  68. package/js/widgets/Dashboard/components/DashboardItem/Order/Installment/index.tsx +4 -4
  69. package/js/widgets/Dashboard/components/DashboardItem/stories.mock.ts +1 -1
  70. package/js/widgets/Dashboard/components/DashboardSidebar/components/ContractNavLink/index.spec.tsx +23 -28
  71. package/js/widgets/Dashboard/components/DashboardSidebar/components/ContractNavLink/index.tsx +4 -8
  72. package/js/widgets/Dashboard/components/TeacherDashboardCourseSidebar/index.spec.tsx +17 -24
  73. package/js/widgets/Dashboard/components/TeacherDashboardCourseSidebar/index.tsx +18 -21
  74. package/js/widgets/Dashboard/components/TeacherDashboardCourseSidebar/utils.ts +4 -4
  75. package/js/widgets/Dashboard/components/TeacherDashboardOrganizationSidebar/index.tsx +3 -7
  76. package/js/widgets/Dashboard/utils/teacherDashboardPaths.tsx +4 -4
  77. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/CourseProductItemFooter/index.tsx +19 -34
  78. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/_styles.scss +34 -8
  79. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/components/CourseProductCourseRuns/CourseRunList.tsx +3 -3
  80. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/components/CourseProductCourseRuns/_styles.scss +9 -0
  81. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/index.spec.tsx +186 -140
  82. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/index.stories.tsx +11 -2
  83. package/js/widgets/SyllabusCourseRunsList/components/CourseProductItem/index.tsx +111 -24
  84. package/js/widgets/SyllabusCourseRunsList/index.spec.tsx +8 -8
  85. package/package.json +1 -1
  86. package/js/hooks/useCourseProductRelation/index.ts +0 -44
@@ -15,6 +15,7 @@ import {
15
15
  AddressFactory,
16
16
  CertificateOrderFactory,
17
17
  CertificateProductFactory,
18
+ OfferingFactory,
18
19
  CredentialOrderFactory,
19
20
  CredentialProductFactory,
20
21
  CreditCardFactory,
@@ -181,7 +182,9 @@ describe.each([
181
182
  nbApiCalls += 1; // useProductOrder call.
182
183
  nbApiCalls += 1; // get user account call.
183
184
  nbApiCalls += 1; // get user preferences call.
184
- nbApiCalls += 1; // product payment-schedule call
185
+ if (product.type === ProductType.CREDENTIAL) {
186
+ nbApiCalls += 1; // product payment-schedule call
187
+ }
185
188
  await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
186
189
 
187
190
  const user = userEvent.setup({ delay: null });
@@ -268,7 +271,9 @@ describe.each([
268
271
  nbApiCalls += 1; // useProductOrder get order with filters
269
272
  nbApiCalls += 1; // get user account call.
270
273
  nbApiCalls += 1; // get user preferences call.
271
- nbApiCalls += 1; // get product payment schedule.
274
+ if (product.type === ProductType.CREDENTIAL) {
275
+ nbApiCalls += 1; // get product payment schedule.
276
+ }
272
277
  await waitFor(() => expect(fetchMock.calls()).toHaveLength(nbApiCalls));
273
278
 
274
279
  const user = userEvent.setup({ delay: null });
@@ -402,36 +407,119 @@ describe.each([
402
407
  queryOptions: { client: createTestQueryClient({ user: richieUser }) },
403
408
  });
404
409
 
405
- await screen.findByRole('heading', {
406
- level: 4,
407
- name: 'Payment schedule',
408
- });
410
+ if (product.type === ProductType.CREDENTIAL) {
411
+ await screen.findByRole('heading', {
412
+ level: 4,
413
+ name: 'Payment schedule',
414
+ });
409
415
 
410
- const scheduleTable = screen.getByRole('table');
411
- const scheduleTableRows = within(scheduleTable).getAllByRole('row');
412
- expect(scheduleTableRows).toHaveLength(schedule.length);
416
+ const scheduleTable = screen.getByRole('table');
417
+ const scheduleTableRows = within(scheduleTable).getAllByRole('row');
418
+ expect(scheduleTableRows).toHaveLength(schedule.length);
413
419
 
414
- scheduleTableRows.forEach((row, index) => {
415
- const installment = schedule[index];
416
- // A first column should show the installment index
417
- within(row).getByRole('cell', {
418
- name: (index + 1).toString(),
419
- });
420
- // A 2nd column should show the installment amount
421
- within(row).getByRole('cell', {
422
- name: formatPrice(installment.amount, installment.currency),
423
- });
424
- // A 3rd column should show the installment withdraw date
425
- within(row).getByRole('cell', {
426
- name: `Withdrawn on ${intl.formatDate(installment.due_date, {
427
- ...DEFAULT_DATE_FORMAT,
428
- })}`,
429
- });
430
- // A 4th column should show the installment state
431
- within(row).getByRole('cell', {
432
- name: StringHelper.capitalizeFirst(installment.state.replace('_', ' '))!,
420
+ scheduleTableRows.forEach((row, index) => {
421
+ const installment = schedule[index];
422
+ // A first column should show the installment index
423
+ within(row).getByRole('cell', {
424
+ name: (index + 1).toString(),
425
+ });
426
+ // A 2nd column should show the installment amount
427
+ within(row).getByRole('cell', {
428
+ name: formatPrice(installment.amount, installment.currency),
429
+ });
430
+ // A 3rd column should show the installment withdraw date
431
+ within(row).getByRole('cell', {
432
+ name: `Withdrawn on ${intl.formatDate(installment.due_date, {
433
+ ...DEFAULT_DATE_FORMAT,
434
+ })}`,
435
+ });
436
+ // A 4th column should show the installment state
437
+ within(row).getByRole('cell', {
438
+ name: StringHelper.capitalizeFirst(installment.state.replace('_', ' '))!,
439
+ });
433
440
  });
441
+ } else {
442
+ expect(screen.queryByRole('heading', { level: 4, name: 'Payment schedule' })).toBeNull();
443
+ expect(screen.queryByRole('table')).toBeNull();
444
+ }
445
+
446
+ const $totalAmount = screen.getByTestId('sale-tunnel__total__amount');
447
+ expect($totalAmount).toHaveTextContent(
448
+ 'Total' + formatPrice(product.price, product.price_currency).replace(/(\u202F|\u00a0)/g, ' '),
449
+ );
450
+ });
451
+
452
+ it('should show the product payment schedule with discounted price', async () => {
453
+ const intl = createIntl({ locale: 'en' });
454
+ const schedule = PaymentInstallmentFactory().many(2);
455
+
456
+ const offering = OfferingFactory({
457
+ product: ProductFactory({
458
+ price: 840,
459
+ price_currency: 'EUR',
460
+ }).one(),
461
+ rules: {
462
+ discounted_price: 800,
463
+ discount_rate: 0.3,
464
+ },
465
+ }).one();
466
+ const { product } = offering;
467
+
468
+ fetchMock
469
+ .get(
470
+ `https://joanie.endpoint/api/v1.0/orders/?${queryString.stringify(getFetchOrderQueryParams(product))}`,
471
+ [],
472
+ )
473
+ .get(
474
+ `https://joanie.endpoint/api/v1.0/courses/${course.code}/products/${product.id}/payment-schedule/`,
475
+ schedule,
476
+ );
477
+
478
+ render(<Wrapper product={product} offering={offering} isWithdrawable={true} />, {
479
+ queryOptions: { client: createTestQueryClient({ user: richieUser }) },
434
480
  });
481
+
482
+ if (product.type === ProductType.CREDENTIAL) {
483
+ await screen.findByRole('heading', { level: 4, name: 'Payment schedule' });
484
+
485
+ const scheduleTable = screen.getByRole('table');
486
+ const scheduleTableRows = within(scheduleTable).getAllByRole('row');
487
+ expect(scheduleTableRows).toHaveLength(schedule.length);
488
+
489
+ scheduleTableRows.forEach((row, index) => {
490
+ const installment = schedule[index];
491
+ // A first column should show the installment index
492
+ within(row).getByRole('cell', {
493
+ name: (index + 1).toString(),
494
+ });
495
+ // A 2nd column should show the installment amount
496
+ within(row).getByRole('cell', {
497
+ name: formatPrice(installment.amount, installment.currency),
498
+ });
499
+ // A 3rd column should show the installment withdraw date
500
+ within(row).getByRole('cell', {
501
+ name: `Withdrawn on ${intl.formatDate(installment.due_date, {
502
+ ...DEFAULT_DATE_FORMAT,
503
+ })}`,
504
+ });
505
+ // A 4th column should show the installment state
506
+ within(row).getByRole('cell', {
507
+ name: StringHelper.capitalizeFirst(installment.state.replace('_', ' '))!,
508
+ });
509
+ });
510
+ } else {
511
+ expect(screen.queryByRole('heading', { level: 4, name: 'Payment schedule' })).toBeNull();
512
+ expect(screen.queryByRole('table')).toBeNull();
513
+ }
514
+
515
+ const $totalAmount = screen.getByTestId('sale-tunnel__total__amount');
516
+ expect($totalAmount).toHaveTextContent(
517
+ 'Total' +
518
+ formatPrice(offering!.rules.discounted_price!, product.price_currency).replace(
519
+ /(\u202F|\u00a0)/g,
520
+ ' ',
521
+ ),
522
+ );
435
523
  });
436
524
 
437
525
  it('should show a walkthrough to explain the subscription process', async () => {
@@ -15,7 +15,6 @@ export default {
15
15
  isWithdrawable: true,
16
16
  // enrollment?: Enrollment;
17
17
  // product: CredentialProduct | CertificateProduct;
18
- // orderGroup?: OrderGroup;
19
18
  // onFinish?: (order: Order) => void;
20
19
  };
21
20
  return (
@@ -2,10 +2,10 @@ import { ModalProps } from '@openfun/cunningham-react';
2
2
  import {
3
3
  CertificateProduct,
4
4
  CourseLight,
5
+ Offering,
5
6
  CredentialProduct,
6
7
  Enrollment,
7
8
  Order,
8
- OrderGroup,
9
9
  Organization,
10
10
  Product,
11
11
  ProductType,
@@ -16,11 +16,11 @@ import { PacedCourse } from 'types';
16
16
 
17
17
  export interface SaleTunnelProps extends Pick<ModalProps, 'isOpen' | 'onClose'> {
18
18
  product: Product;
19
+ offering?: Offering;
19
20
  organizations?: Organization[];
20
21
  isWithdrawable: boolean;
21
22
  course?: PacedCourse | CourseLight;
22
23
  enrollment?: Enrollment;
23
- orderGroup?: OrderGroup;
24
24
  onFinish?: (order: Order) => void;
25
25
  }
26
26
 
@@ -1,7 +1,7 @@
1
1
  import { screen } from '@testing-library/react';
2
2
  import fetchMock from 'fetch-mock';
3
3
  import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
4
- import { CourseListItemFactory, CourseProductRelationFactory } from 'utils/test/factories/joanie';
4
+ import { CourseListItemFactory, OfferingFactory } from 'utils/test/factories/joanie';
5
5
  import { render } from 'utils/test/render';
6
6
  import { setupJoanieSession } from 'utils/test/wrappers/JoanieAppWrapper';
7
7
  import { expectNoSpinner, expectSpinner } from 'utils/test/expectSpinner';
@@ -35,7 +35,7 @@ describe('components/TeacherDashboardCourseList', () => {
35
35
  });
36
36
 
37
37
  it('should render loading more state', async () => {
38
- const trainings = CourseProductRelationFactory().many(2);
38
+ const trainings = OfferingFactory().many(2);
39
39
  const courses = CourseListItemFactory().many(2);
40
40
  const courseAndProductList = [...courses, ...trainings];
41
41
 
@@ -60,7 +60,7 @@ describe('components/TeacherDashboardCourseList', () => {
60
60
  });
61
61
 
62
62
  it('should render courses and products list', async () => {
63
- const trainings = CourseProductRelationFactory().many(2);
63
+ const trainings = OfferingFactory().many(2);
64
64
  const courses = CourseListItemFactory().many(2);
65
65
  const courseAndProductList = [...courses, ...trainings];
66
66
 
@@ -6,7 +6,7 @@ import { CourseGlimpseList, getCourseGlimpseListProps } from 'components/CourseG
6
6
  import { Spinner } from 'components/Spinner';
7
7
  import context from 'utils/context';
8
8
  import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
9
- import { CourseListItem, CourseProductRelationLight } from 'types/Joanie';
9
+ import { CourseListItem, OfferingLight } from 'types/Joanie';
10
10
  import Banner from 'components/Banner';
11
11
 
12
12
  const messages = defineMessages({
@@ -31,7 +31,7 @@ interface TeacherDashboardCourseListProps {
31
31
  titleTranslated?: string;
32
32
  organizationId?: string;
33
33
  loadMore: () => void;
34
- courseAndProductList?: (CourseListItem | CourseProductRelationLight)[];
34
+ courseAndProductList?: (CourseListItem | OfferingLight)[];
35
35
  isLoadingMore?: boolean;
36
36
  hasMore?: boolean;
37
37
  isNewSearchLoading?: boolean;
@@ -1,5 +1,5 @@
1
1
  import { useJoanieApi } from 'contexts/JoanieApiContext';
2
- import { CourseProductRelation, Organization } from 'types/Joanie';
2
+ import { Offering, Organization } from 'types/Joanie';
3
3
  import { browserDownloadFromBlob } from 'utils/download';
4
4
  import { HttpStatusCode } from 'utils/errors/HttpError';
5
5
  import { handle } from 'utils/errors/handle';
@@ -53,11 +53,11 @@ const useContractArchive = () => {
53
53
  },
54
54
  create: async (
55
55
  organizationId?: Organization['id'],
56
- courseProductRelationId?: CourseProductRelation['id'],
56
+ offeringId?: Offering['id'],
57
57
  ): Promise<string> => {
58
58
  const response = await api.user.contracts.zip_archive.create({
59
59
  organization_id: organizationId,
60
- course_product_relation_id: courseProductRelationId,
60
+ offering_id: offeringId,
61
61
  });
62
62
 
63
63
  return extractArchiveId(response.url);
@@ -2,13 +2,13 @@ import { renderHook, waitFor } from '@testing-library/react';
2
2
  import { QueryClient } from '@tanstack/react-query';
3
3
  import fetchMock from 'fetch-mock';
4
4
  import { PropsWithChildren } from 'react';
5
- import { CourseListItem, CourseProductRelation } from 'types/Joanie';
5
+ import { CourseListItem, Offering } from 'types/Joanie';
6
6
  import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
7
7
  import { createTestQueryClient } from 'utils/test/createTestQueryClient';
8
8
  import { SessionProvider } from 'contexts/SessionContext';
9
9
  import { getRoutes } from 'api/joanie';
10
10
  import { mockPaginatedResponse } from 'utils/test/mockPaginatedResponse';
11
- import { CourseListItemFactory, CourseProductRelationFactory } from 'utils/test/factories/joanie';
11
+ import { CourseListItemFactory, OfferingFactory } from 'utils/test/factories/joanie';
12
12
  import { BaseJoanieAppWrapper } from 'utils/test/wrappers/BaseJoanieAppWrapper';
13
13
  import { useCourseProductUnion } from '.';
14
14
 
@@ -41,12 +41,12 @@ const renderUseCourseProductUnion = ({ organizationId }: { organizationId?: stri
41
41
 
42
42
  describe('useCourseProductUnion', () => {
43
43
  let courseList: CourseListItem[];
44
- let courseProductRelationList: CourseProductRelation[];
44
+ let offeringList: Offering[];
45
45
  let nbApiCalls: number;
46
46
 
47
47
  beforeEach(() => {
48
48
  courseList = CourseListItemFactory().many(6);
49
- courseProductRelationList = CourseProductRelationFactory().many(6);
49
+ offeringList = OfferingFactory().many(6);
50
50
 
51
51
  fetchMock.get('https://joanie.endpoint/api/v1.0/orders/', [], { overwriteRoutes: true });
52
52
  fetchMock.get('https://joanie.endpoint/api/v1.0/credit-cards/', [], { overwriteRoutes: true });
@@ -59,38 +59,38 @@ describe('useCourseProductUnion', () => {
59
59
  fetchMock.restore();
60
60
  });
61
61
 
62
- it('should call courses and coursesProductRelation endpoints', async () => {
62
+ it('should call courses and offering endpoints', async () => {
63
63
  const ROUTES = getRoutes();
64
64
  const coursesUrl = ROUTES.courses.get.replace(':id/', '');
65
- const courseProductRelationsUrl = ROUTES.courseProductRelations.get.replace(':id/', '');
65
+ const offeringsUrl = ROUTES.offerings.get.replace(':id/', '');
66
66
  fetchMock.get(
67
67
  `${coursesUrl}?has_listed_course_runs=true&page=1&page_size=${PER_PAGE}`,
68
68
  mockPaginatedResponse(courseList.slice(0, PER_PAGE), 0, false),
69
69
  );
70
70
  fetchMock.get(
71
- `${courseProductRelationsUrl}?page=1&page_size=${PER_PAGE}`,
72
- mockPaginatedResponse(courseProductRelationList.slice(0, PER_PAGE), 0, false),
71
+ `${offeringsUrl}?page=1&page_size=${PER_PAGE}`,
72
+ mockPaginatedResponse(offeringList.slice(0, PER_PAGE), 0, false),
73
73
  );
74
74
  const { result } = renderUseCourseProductUnion();
75
75
  await waitFor(() => expect(result.current.isLoading).toBe(false));
76
76
  expect(result.current.data.length).toBe(PER_PAGE);
77
77
  nbApiCalls += 1; // courses page 1
78
- nbApiCalls += 1; // course product relations page 1
78
+ nbApiCalls += 1; // offerings page 1
79
79
  const calledUrls = fetchMock.calls().map((call) => call[0]);
80
80
  expect(calledUrls).toHaveLength(nbApiCalls);
81
81
  expect(calledUrls).toContain(
82
82
  `${coursesUrl}?has_listed_course_runs=true&page=1&page_size=${PER_PAGE}`,
83
83
  );
84
- expect(calledUrls).toContain(`${courseProductRelationsUrl}?page=1&page_size=${PER_PAGE}`);
84
+ expect(calledUrls).toContain(`${offeringsUrl}?page=1&page_size=${PER_PAGE}`);
85
85
  }, 25000);
86
86
 
87
- it('should call organization courses and organization coursesProductRelation endpoints', async () => {
87
+ it('should call organization courses and organization offering endpoints', async () => {
88
88
  const organizationId = 'DUMMY_ORGANIZATION_ID';
89
89
  const ROUTES = getRoutes();
90
90
  const organizationCoursesUrl = ROUTES.organizations.courses.get
91
91
  .replace(':organization_id', organizationId)
92
92
  .replace(':id/', '');
93
- const organizationCourseProductRelationsUrl = ROUTES.organizations.courseProductRelations.get
93
+ const organizationOfferingsUrl = ROUTES.organizations.offerings.get
94
94
  .replace(':organization_id', organizationId)
95
95
  .replace(':id/', '');
96
96
  fetchMock.get(
@@ -98,21 +98,19 @@ describe('useCourseProductUnion', () => {
98
98
  mockPaginatedResponse(courseList.slice(0, PER_PAGE), 0, false),
99
99
  );
100
100
  fetchMock.get(
101
- `${organizationCourseProductRelationsUrl}?page=1&page_size=${PER_PAGE}`,
102
- mockPaginatedResponse(courseProductRelationList.slice(0, PER_PAGE), 0, false),
101
+ `${organizationOfferingsUrl}?page=1&page_size=${PER_PAGE}`,
102
+ mockPaginatedResponse(offeringList.slice(0, PER_PAGE), 0, false),
103
103
  );
104
104
  const { result } = renderUseCourseProductUnion({ organizationId: 'DUMMY_ORGANIZATION_ID' });
105
105
  await waitFor(() => expect(result.current.isLoading).toBe(false));
106
106
  expect(result.current.data.length).toBe(PER_PAGE);
107
107
  nbApiCalls += 1; // courses page 1
108
- nbApiCalls += 1; // course product relations page 1
108
+ nbApiCalls += 1; // offerings page 1
109
109
  const calledUrls = fetchMock.calls().map((call) => call[0]);
110
110
  expect(calledUrls).toHaveLength(nbApiCalls);
111
111
  expect(calledUrls).toContain(
112
112
  `${organizationCoursesUrl}?has_listed_course_runs=true&page=1&page_size=${PER_PAGE}`,
113
113
  );
114
- expect(calledUrls).toContain(
115
- `${organizationCourseProductRelationsUrl}?page=1&page_size=${PER_PAGE}`,
116
- );
114
+ expect(calledUrls).toContain(`${organizationOfferingsUrl}?page=1&page_size=${PER_PAGE}`);
117
115
  });
118
116
  });
@@ -3,11 +3,11 @@ import { useJoanieApi } from 'contexts/JoanieApiContext';
3
3
  import {
4
4
  CourseListItem,
5
5
  Product,
6
- CourseProductRelation,
6
+ Offering,
7
7
  CourseQueryFilters,
8
- CourseProductRelationQueryFilters,
8
+ OfferingQueryFilters,
9
9
  ProductType,
10
- CourseProductRelationLight,
10
+ OfferingLight,
11
11
  } from 'types/Joanie';
12
12
  import useUnionResource, { ResourceUnionPaginationProps } from 'hooks/useUnionResource';
13
13
 
@@ -41,9 +41,9 @@ export const useCourseProductUnion = ({
41
41
  const api = useJoanieApi();
42
42
  return useUnionResource<
43
43
  CourseListItem,
44
- CourseProductRelation | CourseProductRelationLight,
44
+ Offering | OfferingLight,
45
45
  CourseQueryFilters,
46
- CourseProductRelationQueryFilters
46
+ OfferingQueryFilters
47
47
  >({
48
48
  queryAConfig: {
49
49
  queryKey: ['user', 'courses'],
@@ -51,8 +51,8 @@ export const useCourseProductUnion = ({
51
51
  filters: { query, organization_id: organizationId, has_listed_course_runs: true },
52
52
  },
53
53
  queryBConfig: {
54
- queryKey: ['user', 'course_product_relations'],
55
- fn: api.courseProductRelations.get,
54
+ queryKey: ['user', 'offerings'],
55
+ fn: api.offerings.get,
56
56
  filters: { query, organization_id: organizationId, product_type: productType },
57
57
  },
58
58
  perPage,
@@ -1,5 +1,5 @@
1
1
  import { defineMessages } from 'react-intl';
2
- import { API, CourseProductQueryFilters, CourseProductRelation, Product } from 'types/Joanie';
2
+ import { API, CourseProductQueryFilters, Offering, Product } from 'types/Joanie';
3
3
  import { QueryOptions, useResourcesCustom, UseResourcesProps } from 'hooks/useResources';
4
4
  import { useJoanieApi } from 'contexts/JoanieApiContext';
5
5
 
@@ -19,11 +19,7 @@ export const messages = defineMessages({
19
19
  /**
20
20
  * Joanie Api hook to retrieve a product through its id and a course code.
21
21
  */
22
- const props: UseResourcesProps<
23
- CourseProductRelation,
24
- CourseProductQueryFilters,
25
- API['courses']['products']
26
- > = {
22
+ const props: UseResourcesProps<Offering, CourseProductQueryFilters, API['courses']['products']> = {
27
23
  queryKey: ['courses-products'],
28
24
  apiInterface: () => useJoanieApi().courses.products,
29
25
  messages,
@@ -31,11 +27,11 @@ const props: UseResourcesProps<
31
27
 
32
28
  export const useCourseProduct = (
33
29
  filters: Omit<CourseProductQueryFilters, 'id'> & { product_id: Product['id'] },
34
- queryOptions?: QueryOptions<CourseProductRelation>,
30
+ queryOptions?: QueryOptions<Offering>,
35
31
  ) => {
36
32
  const { product_id: productId, ...queryfilters } = filters;
37
33
  const enabled = !!productId && !!queryfilters.course_id;
38
- const resources = useResourcesCustom<CourseProductRelation, CourseProductQueryFilters>({
34
+ const resources = useResourcesCustom<Offering, CourseProductQueryFilters>({
39
35
  ...props,
40
36
  filters: { id: productId, ...queryfilters },
41
37
  queryOptions: { ...queryOptions, enabled },
@@ -1,6 +1,6 @@
1
1
  import { useParams, useSearchParams } from 'react-router';
2
2
  import { useOrganizations } from 'hooks/useOrganizations';
3
- import { CourseProductRelation, Organization } from 'types/Joanie';
3
+ import { Offering, Organization } from 'types/Joanie';
4
4
 
5
5
  /**
6
6
  * return organization id with this priority:
@@ -9,17 +9,14 @@ import { CourseProductRelation, Organization } from 'types/Joanie';
9
9
  * * first organization of user's organizations
10
10
  */
11
11
  const useDefaultOrganizationId = () => {
12
- const {
13
- organizationId: routeOrganizationId,
14
- courseProductRelationId: routeCourseProductRelationId,
15
- } = useParams<{
12
+ const { organizationId: routeOrganizationId, offeringId: routeOfferingId } = useParams<{
16
13
  organizationId?: Organization['id'];
17
- courseProductRelationId: CourseProductRelation['id'];
14
+ offeringId: Offering['id'];
18
15
  }>();
19
16
  const [searchParams] = useSearchParams();
20
17
  const queryOrganizationId = searchParams.get('organization_id') || undefined;
21
18
  const { items: organizations } = useOrganizations(
22
- { course_product_relation_id: routeCourseProductRelationId },
19
+ { offering_id: routeOfferingId },
23
20
  {
24
21
  enabled: !routeOrganizationId && !queryOrganizationId,
25
22
  },
@@ -0,0 +1,32 @@
1
+ import { defineMessages } from 'react-intl';
2
+ import { useJoanieApi } from 'contexts/JoanieApiContext';
3
+ import { API, Offering, OfferingQueryFilters } from 'types/Joanie';
4
+ import { useResource, useResources, UseResourcesProps } from 'hooks/useResources';
5
+
6
+ const messages = defineMessages({
7
+ errorGet: {
8
+ id: 'hooks.useOfferings.errorGet',
9
+ description: 'Error message shown to the user when offering fetch request fails.',
10
+ defaultMessage: 'An error occurred while fetching trainings. Please retry later.',
11
+ },
12
+ errorNotFound: {
13
+ id: 'hooks.useOfferings.errorNotFound',
14
+ description: 'Error message shown to the user when no offering matches.',
15
+ defaultMessage: 'Cannot find the training.',
16
+ },
17
+ });
18
+
19
+ /**
20
+ * Joanie Api hook to retrieve/create/update/delete course
21
+ * owned by the authenticated user.
22
+ */
23
+ const props: UseResourcesProps<Offering, OfferingQueryFilters, API['offerings']> = {
24
+ queryKey: ['offerings'],
25
+ apiInterface: () => useJoanieApi().offerings,
26
+ session: true,
27
+ messages,
28
+ };
29
+
30
+ export const useOfferings = useResources<Offering, OfferingQueryFilters, API['offerings']>(props);
31
+
32
+ export const useOffering = useResource<Offering, OfferingQueryFilters>(props);
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useState } from 'react';
2
2
  import { useParams, useSearchParams } from 'react-router';
3
3
  import { useCourseProductUnion } from 'hooks/useCourseProductUnion';
4
- import { CourseListItem, CourseProductRelationLight, ProductType } from 'types/Joanie';
4
+ import { CourseListItem, OfferingLight, ProductType } from 'types/Joanie';
5
5
  import { Maybe, Nullable } from 'types/utils';
6
6
 
7
7
  const useTeacherCoursesSearch = () => {
@@ -9,7 +9,7 @@ const useTeacherCoursesSearch = () => {
9
9
  const [searchParams, setSearchParams] = useSearchParams();
10
10
  const [count, setCount] = useState<Maybe<number>>(0);
11
11
  const [courseAndProductList, setCourseAndProductList] = useState<
12
- (CourseListItem | CourseProductRelationLight)[]
12
+ (CourseListItem | OfferingLight)[]
13
13
  >([]);
14
14
  const [isNewSearchLoading, setIsNewSearchLoading] = useState(false);
15
15
  const query = searchParams.get('query') || undefined;
@@ -1,19 +1,19 @@
1
1
  import { useOrganizationContracts } from 'hooks/useContracts';
2
2
  import { PER_PAGE } from 'settings';
3
- import { ContractState, CourseProductRelation, Organization } from 'types/Joanie';
3
+ import { ContractState, Offering, Organization } from 'types/Joanie';
4
4
 
5
5
  interface UseTeacherPendingContractsCountProps {
6
6
  organizationId?: Organization['id'];
7
- courseProductRelationId?: CourseProductRelation['id'];
7
+ offeringId?: Offering['id'];
8
8
  }
9
9
 
10
10
  const useTeacherPendingContractsCount = ({
11
11
  organizationId,
12
- courseProductRelationId,
12
+ offeringId,
13
13
  }: UseTeacherPendingContractsCountProps) => {
14
14
  const { items: contracts, meta } = useOrganizationContracts({
15
15
  organization_id: organizationId,
16
- course_product_relation_id: courseProductRelationId,
16
+ offering_id: offeringId,
17
17
  signature_state: ContractState.LEARNER_SIGNED,
18
18
  page: 1,
19
19
  page_size: PER_PAGE.teacherContractList,
@@ -5,12 +5,12 @@ import userEvent from '@testing-library/user-event';
5
5
  import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
6
6
  import { DashboardTest } from 'widgets/Dashboard/components/DashboardTest';
7
7
  import {
8
- CourseProductRelationFactory,
8
+ OfferingFactory,
9
9
  EnrollmentFactory,
10
10
  CredentialOrderFactory,
11
11
  } from 'utils/test/factories/joanie';
12
12
  import { createTestQueryClient } from 'utils/test/createTestQueryClient';
13
- import { CourseLight, CourseProductRelation, Enrollment, CredentialOrder } from 'types/Joanie';
13
+ import { CourseLight, Offering, Enrollment, CredentialOrder } from 'types/Joanie';
14
14
  import { expectNoSpinner, expectSpinner } from 'utils/test/expectSpinner';
15
15
  import { expectBannerError, expectBannerInfo, expectNoBannerInfo } from 'utils/test/expectBanner';
16
16
  import { Deferred } from 'utils/test/deferred';
@@ -54,15 +54,15 @@ describe('<DashboardCourses/>', () => {
54
54
  const perPage = PER_PAGE.useOrdersEnrollments;
55
55
 
56
56
  const mockOrders = (orders: CredentialOrder[], client?: QueryClient) => {
57
- const relations: Record<string, CourseProductRelation> = {};
57
+ const offerings: Record<string, Offering> = {};
58
58
  orders.forEach((order) => {
59
59
  const productId = order.product_id;
60
60
  const courseCode = (order.course as CourseLight).code;
61
- const relation = CourseProductRelationFactory().one();
61
+ const offering = OfferingFactory().one();
62
62
 
63
63
  fetchMock.get(
64
64
  `https://joanie.endpoint/api/v1.0/courses/${courseCode}/products/${productId}/`,
65
- relation,
65
+ offering,
66
66
  );
67
67
 
68
68
  // Allowing this option boosts the test performances. Without it, the tests case with 200+
@@ -70,28 +70,28 @@ describe('<DashboardCourses/>', () => {
70
70
  if (client) {
71
71
  client.setQueryData(
72
72
  ['courses-products', JSON.stringify({ id: courseCode, productId })],
73
- relation,
73
+ offering,
74
74
  );
75
75
  }
76
- relations[order.id] = relation;
76
+ offerings[order.id] = offering;
77
77
  });
78
78
  orders.sort((a, b) => {
79
79
  const aDate = new Date(a.created_on);
80
80
  const bDate = new Date(b.created_on);
81
81
  return bDate.getTime() - aDate.getTime();
82
82
  });
83
- return { orders, relations };
83
+ return { orders, offerings };
84
84
  };
85
85
 
86
86
  const expectList = (
87
87
  entities: (CredentialOrder | Enrollment)[],
88
- relations: Record<string, CourseProductRelation>,
88
+ offerings: Record<string, Offering>,
89
89
  ) => {
90
90
  const itemElements = document.querySelectorAll<HTMLElement>('.dashboard__courses__list__item');
91
91
  expect(itemElements.length).toBe(entities.length);
92
92
  entities.forEach((entity, i) => {
93
93
  const title = isOrder(entity)
94
- ? relations[entity.id].product.title
94
+ ? offerings[entity.id].product.title
95
95
  : entity.course_run.course?.title;
96
96
  getByRole(itemElements[i], 'heading', {
97
97
  name: title,
@@ -146,7 +146,7 @@ describe('<DashboardCourses/>', () => {
146
146
 
147
147
  it('should render the list of entities', async () => {
148
148
  const client = createTestQueryClient({ user: true });
149
- const { orders, relations } = mockOrders(
149
+ const { orders, offerings } = mockOrders(
150
150
  CredentialOrderFactory().many(perPage * 2 + 1),
151
151
  client,
152
152
  );
@@ -255,18 +255,18 @@ describe('<DashboardCourses/>', () => {
255
255
  await expectNoBannerInfo('You have no enrollments nor orders yet');
256
256
  let loadMoreButton = await screen.findByRole('button', { name: 'Load more' });
257
257
  expect(loadMoreButton).toBeEnabled();
258
- await waitFor(() => expectList(entities.slice(0, perPage), relations), { interval: 200 });
258
+ await waitFor(() => expectList(entities.slice(0, perPage), offerings), { interval: 200 });
259
259
 
260
260
  // Click on load more button to load slice 2.
261
261
  await act(async () => userEvent.click(loadMoreButton));
262
- await waitFor(() => expectList(entities.slice(0, perPage * 2), relations));
262
+ await waitFor(() => expectList(entities.slice(0, perPage * 2), offerings));
263
263
  loadMoreButton = await screen.findByRole('button', { name: 'Load more' });
264
264
  expect(loadMoreButton).toBeEnabled();
265
265
 
266
266
  // Activate intersection observe to load slice 3.
267
267
  const { onIntersect } = (globalThis as any).__intersection_observer_props__;
268
268
  await waitFor(async () => onIntersect());
269
- await waitFor(() => expectList(entities.slice(0, perPage * 3), relations), { timeout: 30000 });
269
+ await waitFor(() => expectList(entities.slice(0, perPage * 3), offerings), { timeout: 30000 });
270
270
  loadMoreButton = await screen.findByRole('button', { name: 'Load more' });
271
271
  expect(loadMoreButton).toBeEnabled();
272
272
  }, 15000);