richie-education 2.29.1-dev32 → 2.29.1-dev37

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 (21) hide show
  1. package/js/components/ContractStatus/index.spec.tsx +1 -1
  2. package/js/components/ContractStatus/index.tsx +1 -1
  3. package/js/components/PurchaseButton/index.tsx +9 -27
  4. package/js/components/SaleTunnel/index.tsx +2 -1
  5. package/js/pages/DashboardOrderLayout/index.spec.tsx +10 -1
  6. package/js/utils/ProductHelper/index.spec.ts +322 -166
  7. package/js/utils/ProductHelper/index.ts +32 -0
  8. package/js/widgets/Dashboard/components/DashboardItem/Contract/index.spec.tsx +2 -2
  9. package/js/widgets/Dashboard/components/DashboardItem/Order/CertificateItem/index.tsx +51 -0
  10. package/js/widgets/Dashboard/components/DashboardItem/Order/ContractItem/index.tsx +52 -0
  11. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemNotResumable.spec.tsx +109 -0
  12. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.spec.tsx +105 -0
  13. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.tsx +103 -324
  14. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrderContract.spec.tsx +59 -8
  15. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrderContract.useUnionResource.cache.spec.tsx +12 -1
  16. package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemSavePaymentMethod.spec.tsx +116 -0
  17. package/js/widgets/Dashboard/components/DashboardItem/Order/Installment/index.tsx +174 -0
  18. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderPaymentDetailsModal/index.tsx +2 -1
  19. package/js/widgets/Dashboard/components/DashboardItem/Order/OrganizationBlock/index.tsx +150 -0
  20. package/js/widgets/Dashboard/components/DashboardOrderLoader/index.tsx +29 -3
  21. package/package.json +2 -2
@@ -2,6 +2,8 @@ import { IntlShape } from 'react-intl';
2
2
  import { CourseProductRelation, Product, TargetCourse } from 'types/Joanie';
3
3
  import { Maybe } from 'types/utils';
4
4
  import { IntlHelper } from 'utils/IntlHelper';
5
+ import * as Joanie from 'types/Joanie';
6
+ import { isOpenedCourseRunCertificate, isOpenedCourseRunCredential } from 'utils/CourseRuns';
5
7
 
6
8
  /**
7
9
  * Helper class for products
@@ -45,4 +47,34 @@ export class ProductHelper {
45
47
  static getActiveOrderGroups(courseProductRelation: CourseProductRelation) {
46
48
  return courseProductRelation.order_groups?.filter((orderGroup) => orderGroup.is_active);
47
49
  }
50
+
51
+ static hasRemainingSeats(product: Maybe<Product>) {
52
+ if (!product) return false;
53
+ return typeof product?.remaining_order_count !== 'number' || product.remaining_order_count > 0;
54
+ }
55
+
56
+ static hasOpenedTargetCourse(product: Maybe<Product>, enrollment?: Maybe<Joanie.Enrollment>) {
57
+ if (!product) return false;
58
+
59
+ if (product.type === Joanie.ProductType.CERTIFICATE) {
60
+ if (!enrollment?.course_run) {
61
+ throw new Error(
62
+ 'Unable to check if the certificate product relies on an opened course run without enrollment.',
63
+ );
64
+ }
65
+
66
+ return isOpenedCourseRunCertificate(enrollment.course_run.state);
67
+ }
68
+
69
+ return (
70
+ product.target_courses.length > 0 &&
71
+ product.target_courses.every(({ course_runs }) =>
72
+ course_runs.some((targetCourseRun) => isOpenedCourseRunCredential(targetCourseRun.state)),
73
+ )
74
+ );
75
+ }
76
+
77
+ static isPurchasable(product: Maybe<Product>, enrollment?: Maybe<Joanie.Enrollment>) {
78
+ return this.hasOpenedTargetCourse(product, enrollment) && this.hasRemainingSeats(product);
79
+ }
48
80
  }
@@ -122,7 +122,7 @@ describe.each([
122
122
  expect(await screen.findByText(contract.definition.title)).toBeInTheDocument();
123
123
  expect(screen.getByText(contract.order.product_title)).toBeInTheDocument();
124
124
  expect(
125
- screen.getByText('You have to sign this training contract to access your training.'),
125
+ screen.getByText('You have to sign this training contract to finalize your subscription.'),
126
126
  ).toBeInTheDocument();
127
127
 
128
128
  expect(screen.queryByRole('button', { name: 'Sign' })).toBeInTheDocument();
@@ -149,7 +149,7 @@ describe.each([
149
149
  expect(await screen.findByText(contract.definition.title)).toBeInTheDocument();
150
150
  expect(screen.getByText(contract.order.product_title)).toBeInTheDocument();
151
151
  expect(
152
- screen.getByText('You have to sign this training contract to access your training.'),
152
+ screen.getByText('You have to sign this training contract to finalize your subscription.'),
153
153
  ).toBeInTheDocument();
154
154
 
155
155
  expect(screen.queryByRole('link', { name: 'Sign' })).toBeInTheDocument();
@@ -0,0 +1,51 @@
1
+ import { defineMessages, FormattedMessage } from 'react-intl';
2
+ import { CredentialOrder, Product } from 'types/Joanie';
3
+ import { DashboardItemCertificate } from 'widgets/Dashboard/components/DashboardItem/Certificate';
4
+ import { useCertificate } from 'hooks/useCertificates';
5
+ import { Spinner } from 'components/Spinner';
6
+
7
+ const messages = defineMessages({
8
+ loadingCertificate: {
9
+ id: 'components.DashboardItemOrder.CertificateItem.loadingCertificate',
10
+ description: 'Accessible label displayed while certificate is being fetched on the dashboard.',
11
+ defaultMessage: 'Loading certificate...',
12
+ },
13
+ });
14
+
15
+ interface DashboardItemOrderCertificateProps {
16
+ order: CredentialOrder;
17
+ product: Product;
18
+ }
19
+
20
+ const CertificateItem = ({ order, product }: DashboardItemOrderCertificateProps) => {
21
+ if (!order.certificate_id) {
22
+ return (
23
+ <DashboardItemCertificate
24
+ certificateDefinition={product.certificate_definition}
25
+ productType={product.type}
26
+ mode="compact"
27
+ />
28
+ );
29
+ }
30
+ const certificate = useCertificate(order.certificate_id);
31
+ return (
32
+ <>
33
+ {certificate.states.fetching && (
34
+ <Spinner aria-labelledby="loading-certificate">
35
+ <span id="loading-certificate">
36
+ <FormattedMessage {...messages.loadingCertificate} />
37
+ </span>
38
+ </Spinner>
39
+ )}
40
+ {certificate.item && (
41
+ <DashboardItemCertificate
42
+ certificate={certificate.item}
43
+ productType={product.type}
44
+ mode="compact"
45
+ />
46
+ )}
47
+ </>
48
+ );
49
+ };
50
+
51
+ export default CertificateItem;
@@ -0,0 +1,52 @@
1
+ import { defineMessages, FormattedMessage } from 'react-intl';
2
+ import classNames from 'classnames';
3
+ import { CredentialOrder, Product } from 'types/Joanie';
4
+ import { OrderHelper } from 'utils/OrderHelper';
5
+ import ContractStatus from 'components/ContractStatus';
6
+ import SignContractButton from 'components/SignContractButton';
7
+
8
+ const messages = defineMessages({
9
+ trainingContractTitle: {
10
+ id: 'components.DashboardItemOrder.ContractItem.trainingContractTitle',
11
+ description: 'Title of the training contract section',
12
+ defaultMessage: 'Training contract',
13
+ },
14
+ });
15
+
16
+ const ContractItem = ({ order, product }: { order: CredentialOrder; product: Product }) => {
17
+ if (!product?.contract_definition) {
18
+ return;
19
+ }
20
+
21
+ const needsSignature = OrderHelper.orderNeedsSignature(order);
22
+ return (
23
+ <div
24
+ id="dashboard-item-contract"
25
+ className="dashboard-splitted-card__item"
26
+ data-testid={`dashboard-item-contract-${order.id}`}
27
+ >
28
+ <div
29
+ className={classNames('dashboard-splitted-card__item__title', {
30
+ 'dashboard-splitted-card__item__title--dot': needsSignature,
31
+ })}
32
+ >
33
+ <span>
34
+ <FormattedMessage {...messages.trainingContractTitle} />
35
+ </span>
36
+ </div>
37
+ <div className="dashboard-splitted-card__item__description">
38
+ <ContractStatus contract={order.contract} />
39
+ </div>
40
+ <div className="dashboard-splitted-card__item__actions">
41
+ <SignContractButton
42
+ order={order}
43
+ contract={order.contract}
44
+ writable={true}
45
+ className="dashboard-item__button"
46
+ />
47
+ </div>
48
+ </div>
49
+ );
50
+ };
51
+
52
+ export default ContractItem;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Test suite for DashboardItem component with a non-resumable order (inactive and related product
3
+ * is no more purchasable (no remaining seats if order group, some target courses are not opened)).
4
+ */
5
+
6
+ import fetchMock from 'fetch-mock';
7
+ import { render, screen, within } from '@testing-library/react';
8
+ import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
9
+ import { setupJoanieSession } from 'utils/test/wrappers/JoanieAppWrapper';
10
+ import { CredentialOrderFactory } from 'utils/test/factories/joanie';
11
+ import { OrderState } from 'types/Joanie';
12
+ import { mockCourseProductWithOrder } from 'utils/test/mockCourseProductWithOrder';
13
+ import { DashboardTest } from 'widgets/Dashboard/components/DashboardTest';
14
+ import { LearnerDashboardPaths } from 'widgets/Dashboard/utils/learnerRoutesPaths';
15
+ import { BaseJoanieAppWrapper } from 'utils/test/wrappers/BaseJoanieAppWrapper';
16
+
17
+ jest.mock('utils/context', () => ({
18
+ __esModule: true,
19
+ default: mockRichieContextFactory({
20
+ authentication: { backend: 'fonzie', endpoint: 'https://demo.endpoint' },
21
+ joanie_backend: { endpoint: 'https://joanie.endpoint' },
22
+ }).one(),
23
+ }));
24
+
25
+ describe('DashboardItemOrder / Not resumable', () => {
26
+ setupJoanieSession();
27
+ beforeEach(() => {
28
+ fetchMock.get(
29
+ 'begin:https://joanie.endpoint/api/v1.0/enrollments/',
30
+ { results: [], next: null, previous: null, count: null },
31
+ { overwriteRoutes: true },
32
+ );
33
+ });
34
+ afterEach(() => {
35
+ fetchMock.restore();
36
+ });
37
+
38
+ describe('non-writable', () => {
39
+ it('renders elements to explain that the order process is not resumable', async () => {
40
+ const order = CredentialOrderFactory({
41
+ state: OrderState.TO_SIGN,
42
+ }).one();
43
+
44
+ fetchMock.get('begin:https://joanie.endpoint/api/v1.0/orders/', {
45
+ results: [order],
46
+ next: null,
47
+ previous: null,
48
+ count: null,
49
+ });
50
+
51
+ const { product } = mockCourseProductWithOrder(order);
52
+ // Make product no more purchasable
53
+ product.remaining_order_count = 0;
54
+
55
+ render(<DashboardTest initialRoute={LearnerDashboardPaths.COURSES} />, {
56
+ wrapper: BaseJoanieAppWrapper,
57
+ });
58
+
59
+ const dashboardItem = await screen.findByTestId(`dashboard-item-order-${order.id}`);
60
+ within(dashboardItem).getByRole('heading', { level: 5, name: product.title });
61
+ within(dashboardItem).getByText(
62
+ 'The subscription process cannot be resumed. The related training is no more purchasable.',
63
+ );
64
+
65
+ // No subitem should be displayed (target course details)
66
+ expect(within(dashboardItem).queryAllByTestId('dashboard-sub-item')).toHaveLength(0);
67
+ });
68
+ });
69
+
70
+ describe('writable', () => {
71
+ it('renders elements to explain that the order process is not resumable', async () => {
72
+ const order = CredentialOrderFactory({
73
+ state: OrderState.TO_SAVE_PAYMENT_METHOD,
74
+ }).one();
75
+
76
+ fetchMock.get(
77
+ 'https://joanie.endpoint/api/v1.0/orders/',
78
+ { results: [order], next: null, previous: null, count: null },
79
+ { overwriteRoutes: true },
80
+ );
81
+
82
+ const url = `begin:https://joanie.endpoint/api/v1.0/orders/?`;
83
+ fetchMock.get(url, [order]);
84
+
85
+ const { product } = mockCourseProductWithOrder(order);
86
+ // Make product no more purchasable
87
+ product.remaining_order_count = 0;
88
+
89
+ render(
90
+ <DashboardTest initialRoute={LearnerDashboardPaths.ORDER.replace(':orderId', order.id)} />,
91
+ {
92
+ wrapper: BaseJoanieAppWrapper,
93
+ },
94
+ );
95
+
96
+ const dashboardItem = await screen.findByTestId(`dashboard-item-order-${order.id}`);
97
+ within(dashboardItem).getByRole('heading', { level: 5, name: product.title });
98
+ within(dashboardItem).getByText(
99
+ 'The subscription process cannot be resumed. The related training is no more purchasable.',
100
+ );
101
+
102
+ // No subitem should be displayed (target course details)
103
+ expect(within(dashboardItem).queryAllByTestId('dashboard-sub-item')).toHaveLength(0);
104
+
105
+ // Organization block should not be displayed
106
+ expect(within(dashboardItem).queryByTestId('organization-block')).toBeNull();
107
+ });
108
+ });
109
+ });
@@ -12,6 +12,7 @@ import {
12
12
  import { faker } from '@faker-js/faker';
13
13
  import fetchMock from 'fetch-mock';
14
14
  import userEvent from '@testing-library/user-event';
15
+ import queryString from 'query-string';
15
16
  import { DEFAULT_DATE_FORMAT } from 'hooks/useDateFormat';
16
17
  import {
17
18
  CourseStateFactory,
@@ -31,6 +32,7 @@ import {
31
32
  CourseLight,
32
33
  CourseRun,
33
34
  CredentialOrder,
35
+ NOT_CANCELED_ORDER_STATES,
34
36
  OrderState,
35
37
  PaymentScheduleState,
36
38
  } from 'types/Joanie';
@@ -257,6 +259,15 @@ describe('<DashboardItemOrder/>', () => {
257
259
  order.target_courses = [];
258
260
  const { product } = mockCourseProductWithOrder(order);
259
261
 
262
+ const orderQueryParameters = {
263
+ course_code: order.course.code,
264
+ product_id: order.product_id,
265
+ state: NOT_CANCELED_ORDER_STATES,
266
+ };
267
+ const queryParams = queryString.stringify(orderQueryParameters);
268
+ const url = `https://joanie.endpoint/api/v1.0/orders/?${queryParams}`;
269
+ fetchMock.get(url, [order]);
270
+
260
271
  render(<DashboardItemOrder order={order} writable={true} showDetailsButton={false} />);
261
272
 
262
273
  await screen.findByRole('heading', { level: 5, name: product.title });
@@ -284,6 +295,15 @@ describe('<DashboardItemOrder/>', () => {
284
295
 
285
296
  const { product } = mockCourseProductWithOrder(order);
286
297
 
298
+ const orderQueryParameters = {
299
+ course_code: order.course.code,
300
+ product_id: order.product_id,
301
+ state: NOT_CANCELED_ORDER_STATES,
302
+ };
303
+ const queryParams = queryString.stringify(orderQueryParameters);
304
+ const url = `https://joanie.endpoint/api/v1.0/orders/?${queryParams}`;
305
+ fetchMock.get(url, [order]);
306
+
287
307
  render(<DashboardItemOrder order={order} writable={true} showDetailsButton={false} />);
288
308
 
289
309
  await screen.findByRole('heading', { level: 5, name: product.title });
@@ -349,6 +369,14 @@ describe('<DashboardItemOrder/>', () => {
349
369
  { results: [order], next: null, previous: null, count: null },
350
370
  { overwriteRoutes: true },
351
371
  );
372
+ const orderQueryParameters = {
373
+ course_code: order.course.code,
374
+ product_id: order.product_id,
375
+ state: NOT_CANCELED_ORDER_STATES,
376
+ };
377
+ const queryParams = queryString.stringify(orderQueryParameters);
378
+ const url = `https://joanie.endpoint/api/v1.0/orders/?${queryParams}`;
379
+ fetchMock.get(url, [order]);
352
380
 
353
381
  // The order with an enrollment that will be returned from the API when the orders will be
354
382
  // invalided after the click on the Enroll button.
@@ -432,6 +460,15 @@ describe('<DashboardItemOrder/>', () => {
432
460
  { overwriteRoutes: true },
433
461
  );
434
462
 
463
+ const orderQueryParameters = {
464
+ course_code: order.course.code,
465
+ product_id: order.product_id,
466
+ state: NOT_CANCELED_ORDER_STATES,
467
+ };
468
+ const queryParams = queryString.stringify(orderQueryParameters);
469
+ const url = `https://joanie.endpoint/api/v1.0/orders/?${queryParams}`;
470
+ fetchMock.get(url, [order]);
471
+
435
472
  fetchMock.post('https://joanie.endpoint/api/v1.0/enrollments/', {
436
473
  status: HttpStatusCode.INTERNAL_SERVER_ERROR,
437
474
  body: 'Internal Server Error',
@@ -502,6 +539,14 @@ describe('<DashboardItemOrder/>', () => {
502
539
  { results: [order], next: null, previous: null, count: null },
503
540
  { overwriteRoutes: true },
504
541
  );
542
+ const orderQueryParameters = {
543
+ course_code: order.course.code,
544
+ product_id: order.product_id,
545
+ state: NOT_CANCELED_ORDER_STATES,
546
+ };
547
+ const queryParams = queryString.stringify(orderQueryParameters);
548
+ const url = `https://joanie.endpoint/api/v1.0/orders/?${queryParams}`;
549
+ fetchMock.get(url, [order]);
505
550
 
506
551
  // The order with new enrollment that will be returned from the API when the orders will be
507
552
  // invalided after the click on the Enroll button.
@@ -608,6 +653,14 @@ describe('<DashboardItemOrder/>', () => {
608
653
  { results: [order], next: null, previous: null, count: null },
609
654
  { overwriteRoutes: true },
610
655
  );
656
+ const orderQueryParameters = {
657
+ course_code: order.course.code,
658
+ product_id: order.product_id,
659
+ state: NOT_CANCELED_ORDER_STATES,
660
+ };
661
+ const queryParams = queryString.stringify(orderQueryParameters);
662
+ const url = `https://joanie.endpoint/api/v1.0/orders/?${queryParams}`;
663
+ fetchMock.get(url, [order]);
611
664
 
612
665
  const courseRun = order.target_courses[0].course_runs[0];
613
666
  const newEnrolledCourseRun = order.target_courses[0].course_runs[1];
@@ -693,6 +746,14 @@ describe('<DashboardItemOrder/>', () => {
693
746
  { results: [order], next: null, previous: null, count: null },
694
747
  { overwriteRoutes: true },
695
748
  );
749
+ const orderQueryParameters = {
750
+ course_code: order.course.code,
751
+ product_id: order.product_id,
752
+ state: NOT_CANCELED_ORDER_STATES,
753
+ };
754
+ const queryParams = queryString.stringify(orderQueryParameters);
755
+ const url = `https://joanie.endpoint/api/v1.0/orders/?${queryParams}`;
756
+ fetchMock.get(url, [order]);
696
757
 
697
758
  // The order with new enrollment that will be returned from the API when the orders will be
698
759
  // invalided after the click on the Enroll button.
@@ -772,6 +833,14 @@ describe('<DashboardItemOrder/>', () => {
772
833
  }).many(1),
773
834
  }).one();
774
835
  const { product } = mockCourseProductWithOrder(order);
836
+ const orderQueryParameters = {
837
+ course_code: order.course.code,
838
+ product_id: order.product_id,
839
+ state: NOT_CANCELED_ORDER_STATES,
840
+ };
841
+ const queryParams = queryString.stringify(orderQueryParameters);
842
+ const url = `https://joanie.endpoint/api/v1.0/orders/?${queryParams}`;
843
+ fetchMock.get(url, [order]);
775
844
 
776
845
  render(<DashboardItemOrder order={order} writable={true} showDetailsButton={false} />);
777
846
 
@@ -808,6 +877,15 @@ describe('<DashboardItemOrder/>', () => {
808
877
 
809
878
  const { product } = mockCourseProductWithOrder(order);
810
879
 
880
+ const orderQueryParameters = {
881
+ course_code: order.course.code,
882
+ product_id: order.product_id,
883
+ state: NOT_CANCELED_ORDER_STATES,
884
+ };
885
+ const queryParams = queryString.stringify(orderQueryParameters);
886
+ const url = `https://joanie.endpoint/api/v1.0/orders/?${queryParams}`;
887
+ fetchMock.get(url, [order]);
888
+
811
889
  render(<DashboardItemOrder order={order} writable={true} showDetailsButton={false} />);
812
890
 
813
891
  await screen.findByRole('heading', { level: 5, name: product.title });
@@ -836,6 +914,15 @@ describe('<DashboardItemOrder/>', () => {
836
914
 
837
915
  const { product } = mockCourseProductWithOrder(order);
838
916
 
917
+ const orderQueryParameters = {
918
+ course_code: order.course.code,
919
+ product_id: order.product_id,
920
+ state: NOT_CANCELED_ORDER_STATES,
921
+ };
922
+ const queryParams = queryString.stringify(orderQueryParameters);
923
+ const url = `https://joanie.endpoint/api/v1.0/orders/?${queryParams}`;
924
+ fetchMock.get(url, [order]);
925
+
839
926
  render(<DashboardItemOrder order={order} writable={true} showDetailsButton={false} />);
840
927
 
841
928
  await screen.findByRole('heading', { level: 5, name: product.title });
@@ -852,6 +939,15 @@ describe('<DashboardItemOrder/>', () => {
852
939
  const order: CredentialOrder = CredentialOrderFactory().one();
853
940
  const { product } = mockCourseProductWithOrder(order);
854
941
 
942
+ const orderQueryParameters = {
943
+ course_code: order.course.code,
944
+ product_id: order.product_id,
945
+ state: NOT_CANCELED_ORDER_STATES,
946
+ };
947
+ const queryParams = queryString.stringify(orderQueryParameters);
948
+ const url = `https://joanie.endpoint/api/v1.0/orders/?${queryParams}`;
949
+ fetchMock.get(url, [order]);
950
+
855
951
  render(<DashboardItemOrder order={order} writable={true} showDetailsButton={false} />);
856
952
 
857
953
  await screen.findByRole('heading', { level: 5, name: product.title });
@@ -886,6 +982,15 @@ describe('<DashboardItemOrder/>', () => {
886
982
  )
887
983
  .get(`https://joanie.endpoint/api/v1.0/orders/${order.id}/`, validOrder);
888
984
 
985
+ const orderQueryParameters = {
986
+ course_code: order.course.code,
987
+ product_id: order.product_id,
988
+ state: NOT_CANCELED_ORDER_STATES,
989
+ };
990
+ const queryParams = queryString.stringify(orderQueryParameters);
991
+ const url = `https://joanie.endpoint/api/v1.0/orders/?${queryParams}`;
992
+ fetchMock.get(url, [order]);
993
+
889
994
  order.state = OrderState.FAILED_PAYMENT;
890
995
  order.payment_schedule![1].state = PaymentScheduleState.REFUSED;
891
996