richie-education 3.5.1 → 3.5.2-dev17
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.
- package/.nvmrc +1 -1
- package/.storybook/main.js +1 -1
- package/babel.config.js +23 -8
- package/js/api/joanie.ts +15 -0
- package/js/api/lms/dummy.ts +1 -0
- package/js/api/lms/openedx-fonzie-keycloak.spec.ts +7 -10
- package/js/api/lms/openedx-fonzie-keycloak.ts +24 -21
- package/js/components/SaleTunnel/index.spec.tsx +31 -1
- package/js/hooks/useCourseProductUnion/index.spec.tsx +2 -2
- package/js/hooks/useLearnerCoursesSearch/index.tsx +17 -4
- package/js/hooks/usePrevious.ts +1 -1
- package/js/hooks/useProductOrder/index.spec.tsx +1 -0
- package/js/pages/DashboardAddressesManagement/index.spec.tsx +2 -1
- package/js/pages/DashboardCourses/index.spec.tsx +40 -8
- package/js/pages/DashboardCreditCardsManagement/index.spec.tsx +5 -3
- package/js/types/Joanie.ts +18 -1
- package/js/utils/OrderHelper/index.spec.ts +75 -0
- package/js/utils/OrderHelper/index.ts +14 -2
- package/js/utils/test/factories/factories.ts +1 -1
- package/js/utils/test/factories/joanie.ts +10 -0
- package/js/utils/test/mockPaginatedResponse.ts +1 -1
- package/js/widgets/Dashboard/components/DashboardItem/Enrollment/ProductCertificateFooter/index.spec.tsx +213 -1
- package/js/widgets/Dashboard/components/DashboardItem/Enrollment/ProductCertificateFooter/index.tsx +133 -31
- package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.spec.tsx +24 -0
- package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.tsx +27 -13
- package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrderContract.useUnionResource.cache.spec.tsx +0 -2
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateLearnerMessage/index.spec.tsx +12 -0
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateLearnerMessage/index.tsx +11 -0
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateMessage/index.tsx +5 -1
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateTeacherMessage/index.spec.tsx +12 -0
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateTeacherMessage/index.tsx +11 -0
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrderWithdrawalModal/index.spec.tsx +167 -0
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrderWithdrawalModal/index.tsx +272 -0
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrganizationBlock/index.spec.tsx +102 -0
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrganizationBlock/index.tsx +84 -2
- package/js/widgets/Dashboard/components/DashboardItem/Order/_styles.scss +5 -0
- package/js/widgets/Dashboard/components/DashboardItem/_styles.scss +9 -1
- package/js/widgets/Dashboard/index.spec.tsx +0 -1
- package/package.json +11 -11
- package/scss/components/templates/courses/cms/_category_list.scss +5 -0
|
@@ -20,9 +20,11 @@ export enum OrderStatus {
|
|
|
20
20
|
PASSED = 'passed',
|
|
21
21
|
PENDING = 'pending',
|
|
22
22
|
PENDING_PAYMENT = 'pending_payment',
|
|
23
|
+
PENDING_WITHDRAWAL = 'pending_withdrawal',
|
|
23
24
|
WAITING_COUNTER_SIGNATURE = 'waiting_counter_signature',
|
|
24
25
|
WAITING_PAYMENT_METHOD = 'waiting_payment_method',
|
|
25
26
|
WAITING_SIGNATURE = 'waiting_signature',
|
|
27
|
+
WITHDRAWN = 'withdrawn',
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
/**
|
|
@@ -36,6 +38,9 @@ export class OrderHelper {
|
|
|
36
38
|
if (order.state === OrderState.COMPLETED && order.certificate_id) {
|
|
37
39
|
return OrderStatus.PASSED;
|
|
38
40
|
}
|
|
41
|
+
if (OrderHelper.isWithdrawn(order)) {
|
|
42
|
+
return OrderStatus.WITHDRAWN;
|
|
43
|
+
}
|
|
39
44
|
|
|
40
45
|
const orderStatusMap = {
|
|
41
46
|
[OrderState.ASSIGNED]: OrderStatus.ASSIGNED,
|
|
@@ -48,6 +53,7 @@ export class OrderHelper {
|
|
|
48
53
|
[OrderState.NO_PAYMENT]: OrderStatus.NO_PAYMENT,
|
|
49
54
|
[OrderState.PENDING]: OrderStatus.PENDING,
|
|
50
55
|
[OrderState.PENDING_PAYMENT]: OrderStatus.PENDING_PAYMENT,
|
|
56
|
+
[OrderState.PENDING_WITHDRAW]: OrderStatus.PENDING_WITHDRAWAL,
|
|
51
57
|
[OrderState.SIGNING]: OrderStatus.WAITING_SIGNATURE,
|
|
52
58
|
[OrderState.TO_SAVE_PAYMENT_METHOD]: OrderStatus.WAITING_PAYMENT_METHOD,
|
|
53
59
|
[OrderState.TO_SIGN]: OrderStatus.WAITING_SIGNATURE,
|
|
@@ -65,7 +71,8 @@ export class OrderHelper {
|
|
|
65
71
|
*/
|
|
66
72
|
static getActiveEnrollmentOrder(orders: OrderEnrollment[], productId: string) {
|
|
67
73
|
const filter = (order: OrderEnrollment) =>
|
|
68
|
-
ACTIVE_ORDER_STATES.includes(order.state)
|
|
74
|
+
(ACTIVE_ORDER_STATES.includes(order.state) || OrderHelper.isWithdrawn(order)) &&
|
|
75
|
+
order.product_id === productId;
|
|
69
76
|
return orders.find(filter);
|
|
70
77
|
}
|
|
71
78
|
|
|
@@ -100,8 +107,13 @@ export class OrderHelper {
|
|
|
100
107
|
return CANCELED_ORDER_STATES.includes(order.state);
|
|
101
108
|
}
|
|
102
109
|
|
|
110
|
+
static isWithdrawn(order?: Order | NestedCourseOrder | OrderEnrollment) {
|
|
111
|
+
if (!order) return false;
|
|
112
|
+
return Boolean('withdrawn_confirmation_at' in order && order.withdrawn_confirmation_at);
|
|
113
|
+
}
|
|
114
|
+
|
|
103
115
|
static isPurchasable(order?: Order | NestedCourseOrder | OrderEnrollment) {
|
|
104
|
-
if (!order) return true;
|
|
116
|
+
if (!order || OrderHelper.isWithdrawn(order)) return true;
|
|
105
117
|
return PURCHASABLE_ORDER_STATES.includes(order.state);
|
|
106
118
|
}
|
|
107
119
|
|
|
@@ -40,7 +40,7 @@ const setUndefinedValues = (obj: any) => {
|
|
|
40
40
|
};
|
|
41
41
|
|
|
42
42
|
type Factory<TData> = (override?: Subset<TData>) => FactoryInterface<TData>;
|
|
43
|
-
export const factory = <TData>(builder: FactoryBuilder<TData>): Factory<TData> => {
|
|
43
|
+
export const factory = <TData extends unknown>(builder: FactoryBuilder<TData>): Factory<TData> => {
|
|
44
44
|
const mergeData = (data: TData, override: Subset<TData>): TData => {
|
|
45
45
|
const customizer = (_objValue: any, srcValue: any) => {
|
|
46
46
|
if (Array.isArray(srcValue)) {
|
|
@@ -471,6 +471,11 @@ export const OrderEnrollmentFactory = factory((): OrderEnrollment => {
|
|
|
471
471
|
product_id: faker.string.uuid(),
|
|
472
472
|
state: OrderState.COMPLETED,
|
|
473
473
|
payment_schedule: PaymentInstallmentFactory().many(1),
|
|
474
|
+
has_waived_withdrawal_right: false,
|
|
475
|
+
eligible_to_withdraw: false,
|
|
476
|
+
withdrawal_date_limit: null,
|
|
477
|
+
withdrawn_requested_at: null,
|
|
478
|
+
withdrawn_confirmation_at: null,
|
|
474
479
|
};
|
|
475
480
|
});
|
|
476
481
|
|
|
@@ -600,6 +605,11 @@ const AbstractOrderFactory = factory((): Order => {
|
|
|
600
605
|
course: null,
|
|
601
606
|
organization_id: faker.string.uuid(),
|
|
602
607
|
organization: OrganizationFactory().one(),
|
|
608
|
+
has_waived_withdrawal_right: false,
|
|
609
|
+
eligible_to_withdraw: false,
|
|
610
|
+
withdrawal_date_limit: null,
|
|
611
|
+
withdrawn_requested_at: null,
|
|
612
|
+
withdrawn_confirmation_at: null,
|
|
603
613
|
};
|
|
604
614
|
});
|
|
605
615
|
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
PaymentInstallmentFactory,
|
|
24
24
|
} from 'utils/test/factories/joanie';
|
|
25
25
|
import { Priority } from 'types';
|
|
26
|
+
import { DATETIME_FORMAT } from 'hooks/useDateFormat';
|
|
26
27
|
import { DashboardTest } from 'widgets/Dashboard/components/DashboardTest';
|
|
27
28
|
import { expectNoSpinner } from 'utils/test/expectSpinner';
|
|
28
29
|
import { PER_PAGE } from 'settings';
|
|
@@ -70,6 +71,33 @@ jest.mock('widgets/Dashboard/components/DashboardItem/Order/OrderPaymentRetryMod
|
|
|
70
71
|
);
|
|
71
72
|
},
|
|
72
73
|
}));
|
|
74
|
+
jest.mock('widgets/Dashboard/components/DashboardItem/Order/OrderWithdrawalModal', () => ({
|
|
75
|
+
__esModule: true,
|
|
76
|
+
OrderWithdrawalModal: ({
|
|
77
|
+
isOpen,
|
|
78
|
+
order,
|
|
79
|
+
productTitle,
|
|
80
|
+
reference,
|
|
81
|
+
onSuccess,
|
|
82
|
+
}: {
|
|
83
|
+
isOpen: boolean;
|
|
84
|
+
order: { id: string; state: string };
|
|
85
|
+
productTitle: string;
|
|
86
|
+
reference: string;
|
|
87
|
+
onSuccess?: (order: any) => void;
|
|
88
|
+
}) => {
|
|
89
|
+
if (!isOpen) return null;
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div data-testid="OrderWithdrawalModalMock">
|
|
93
|
+
{order.id} - {productTitle} - {reference}
|
|
94
|
+
<button onClick={() => onSuccess?.({ ...order, state: 'canceled' })}>
|
|
95
|
+
Trigger Success
|
|
96
|
+
</button>
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
},
|
|
100
|
+
}));
|
|
73
101
|
|
|
74
102
|
describe('<ProductCertificateFooter/>', () => {
|
|
75
103
|
let product: CertificateProduct;
|
|
@@ -230,7 +258,6 @@ describe('<ProductCertificateFooter/>', () => {
|
|
|
230
258
|
fetchMock.get(
|
|
231
259
|
'https://joanie.endpoint/api/v1.0/orders/' +
|
|
232
260
|
'?product_type=credential' +
|
|
233
|
-
'&state_exclude=canceled' +
|
|
234
261
|
'&state_exclude=refunding' +
|
|
235
262
|
'&state_exclude=refunded' +
|
|
236
263
|
'&page=1' +
|
|
@@ -384,4 +411,189 @@ describe('<ProductCertificateFooter/>', () => {
|
|
|
384
411
|
expect(fetchMock.lastUrl()).toBe(`https://joanie.endpoint/api/v1.0/orders/${order.id}/`);
|
|
385
412
|
},
|
|
386
413
|
);
|
|
414
|
+
|
|
415
|
+
it('should display the withdrawal manager and open the withdrawal modal when the order is eligible', async () => {
|
|
416
|
+
const order = OrderEnrollmentFactory({
|
|
417
|
+
state: OrderState.COMPLETED,
|
|
418
|
+
certificate_id: undefined,
|
|
419
|
+
product_id: product.id,
|
|
420
|
+
eligible_to_withdraw: true,
|
|
421
|
+
withdrawal_date_limit: '2026-08-30T10:00:00.000Z',
|
|
422
|
+
}).one();
|
|
423
|
+
const enrollment = EnrollmentFactory({
|
|
424
|
+
orders: [order],
|
|
425
|
+
course_run: CourseRunFactory({ course }).one(),
|
|
426
|
+
}).one();
|
|
427
|
+
|
|
428
|
+
render(
|
|
429
|
+
<ProductCertificateFooter product={product} enrollment={enrollment} isWithdrawable={true} />,
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
const datetimeFormatter = new Intl.DateTimeFormat('en', DATETIME_FORMAT);
|
|
433
|
+
screen.getByText(`until ${datetimeFormatter.format(new Date(order.withdrawal_date_limit!))}.`, {
|
|
434
|
+
exact: false,
|
|
435
|
+
});
|
|
436
|
+
expect(screen.queryByTestId('OrderWithdrawalModalMock')).not.toBeInTheDocument();
|
|
437
|
+
|
|
438
|
+
const user = userEvent.setup();
|
|
439
|
+
await user.click(screen.getByRole('button', { name: 'I wish to withdraw' }));
|
|
440
|
+
|
|
441
|
+
const modal = screen.getByTestId('OrderWithdrawalModalMock');
|
|
442
|
+
within(modal).getByText(`${order.id} - ${product.title} - ${course.code}`);
|
|
443
|
+
|
|
444
|
+
// A successful withdrawal request updates the order held by the footer, hiding the
|
|
445
|
+
// withdrawal manager since the order is no longer active.
|
|
446
|
+
await user.click(within(modal).getByRole('button', { name: 'Trigger Success' }));
|
|
447
|
+
expect(screen.queryByRole('button', { name: 'I wish to withdraw' })).not.toBeInTheDocument();
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('should not display the withdrawal manager when the order is not eligible', () => {
|
|
451
|
+
const order = OrderEnrollmentFactory({
|
|
452
|
+
state: OrderState.COMPLETED,
|
|
453
|
+
certificate_id: undefined,
|
|
454
|
+
product_id: product.id,
|
|
455
|
+
eligible_to_withdraw: false,
|
|
456
|
+
}).one();
|
|
457
|
+
const enrollment = EnrollmentFactory({ orders: [order] }).one();
|
|
458
|
+
|
|
459
|
+
render(
|
|
460
|
+
<ProductCertificateFooter product={product} enrollment={enrollment} isWithdrawable={true} />,
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
expect(screen.queryByRole('button', { name: 'I wish to withdraw' })).not.toBeInTheDocument();
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it('should display a pending withdrawal message while the withdrawal request is being processed', () => {
|
|
467
|
+
const requestedAt = '2026-08-20T09:30:00.000Z';
|
|
468
|
+
const order = OrderEnrollmentFactory({
|
|
469
|
+
state: OrderState.PENDING_WITHDRAW,
|
|
470
|
+
certificate_id: undefined,
|
|
471
|
+
product_id: product.id,
|
|
472
|
+
eligible_to_withdraw: false,
|
|
473
|
+
withdrawn_requested_at: requestedAt,
|
|
474
|
+
}).one();
|
|
475
|
+
const enrollment = EnrollmentFactory({ orders: [order] }).one();
|
|
476
|
+
|
|
477
|
+
render(
|
|
478
|
+
<ProductCertificateFooter product={product} enrollment={enrollment} isWithdrawable={true} />,
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
screen.getByText(
|
|
482
|
+
`Your withdrawal request has been recorded on ${dateFormatter.format(
|
|
483
|
+
new Date(requestedAt),
|
|
484
|
+
)} and is being processed.`,
|
|
485
|
+
);
|
|
486
|
+
expect(
|
|
487
|
+
screen.queryByText(product.certificate_definition.title, { exact: false }),
|
|
488
|
+
).not.toBeInTheDocument();
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('should display a withdrawn message and hide the exam access status once the order has been withdrawn', () => {
|
|
492
|
+
const confirmedAt = '2026-08-25T14:00:00.000Z';
|
|
493
|
+
const order = OrderEnrollmentFactory({
|
|
494
|
+
state: OrderState.CANCELED,
|
|
495
|
+
certificate_id: undefined,
|
|
496
|
+
product_id: product.id,
|
|
497
|
+
eligible_to_withdraw: false,
|
|
498
|
+
withdrawn_confirmation_at: confirmedAt,
|
|
499
|
+
}).one();
|
|
500
|
+
const enrollment = EnrollmentFactory({ orders: [order] }).one();
|
|
501
|
+
|
|
502
|
+
render(
|
|
503
|
+
<ProductCertificateFooter product={product} enrollment={enrollment} isWithdrawable={true} />,
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
screen.getByText(
|
|
507
|
+
`You withdrew from this order on ${dateFormatter.format(new Date(confirmedAt))}.`,
|
|
508
|
+
);
|
|
509
|
+
expect(
|
|
510
|
+
screen.queryByText(product.certificate_definition.title, { exact: false }),
|
|
511
|
+
).not.toBeInTheDocument();
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* The footer holds the active order in local state, initialized once from the
|
|
516
|
+
* `enrollment` prop: it isn't kept in sync with prop changes, so the only way the
|
|
517
|
+
* learner sees an admin's decision on a pending withdrawal request is by reloading
|
|
518
|
+
* the dashboard, which remounts the footer with fresh data. These two tests simulate
|
|
519
|
+
* that reload via unmount + render, rather than rerender.
|
|
520
|
+
*/
|
|
521
|
+
it('reflects a confirmed withdrawal after the page is refreshed', () => {
|
|
522
|
+
const pendingOrder = OrderEnrollmentFactory({
|
|
523
|
+
state: OrderState.PENDING_WITHDRAW,
|
|
524
|
+
certificate_id: undefined,
|
|
525
|
+
product_id: product.id,
|
|
526
|
+
eligible_to_withdraw: false,
|
|
527
|
+
withdrawn_requested_at: '2026-08-20T09:30:00.000Z',
|
|
528
|
+
}).one();
|
|
529
|
+
const { unmount } = render(
|
|
530
|
+
<ProductCertificateFooter
|
|
531
|
+
product={product}
|
|
532
|
+
enrollment={EnrollmentFactory({ orders: [pendingOrder] }).one()}
|
|
533
|
+
isWithdrawable={true}
|
|
534
|
+
/>,
|
|
535
|
+
);
|
|
536
|
+
screen.getByText('and is being processed.', { exact: false });
|
|
537
|
+
unmount();
|
|
538
|
+
|
|
539
|
+
const confirmedOrder = {
|
|
540
|
+
...pendingOrder,
|
|
541
|
+
state: OrderState.CANCELED,
|
|
542
|
+
withdrawn_confirmation_at: '2026-08-21T10:00:00.000Z',
|
|
543
|
+
};
|
|
544
|
+
render(
|
|
545
|
+
<ProductCertificateFooter
|
|
546
|
+
product={product}
|
|
547
|
+
enrollment={EnrollmentFactory({ orders: [confirmedOrder] }).one()}
|
|
548
|
+
isWithdrawable={true}
|
|
549
|
+
/>,
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
screen.getByText(
|
|
553
|
+
`You withdrew from this order on ${dateFormatter.format(
|
|
554
|
+
new Date(confirmedOrder.withdrawn_confirmation_at),
|
|
555
|
+
)}.`,
|
|
556
|
+
);
|
|
557
|
+
expect(screen.queryByText('and is being processed.', { exact: false })).not.toBeInTheDocument();
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('reflects a rejected withdrawal after the page is refreshed', () => {
|
|
561
|
+
const pendingOrder = OrderEnrollmentFactory({
|
|
562
|
+
state: OrderState.PENDING_WITHDRAW,
|
|
563
|
+
certificate_id: undefined,
|
|
564
|
+
product_id: product.id,
|
|
565
|
+
eligible_to_withdraw: false,
|
|
566
|
+
withdrawn_requested_at: '2026-08-20T09:30:00.000Z',
|
|
567
|
+
}).one();
|
|
568
|
+
const { unmount } = render(
|
|
569
|
+
<ProductCertificateFooter
|
|
570
|
+
product={product}
|
|
571
|
+
enrollment={EnrollmentFactory({ orders: [pendingOrder] }).one()}
|
|
572
|
+
isWithdrawable={true}
|
|
573
|
+
/>,
|
|
574
|
+
);
|
|
575
|
+
screen.getByText('and is being processed.', { exact: false });
|
|
576
|
+
unmount();
|
|
577
|
+
|
|
578
|
+
// A rejected request resumes its normal course: the order goes back to completed
|
|
579
|
+
// and, since no certificate has been issued yet, remains eligible to a new request.
|
|
580
|
+
const rejectedOrder = {
|
|
581
|
+
...pendingOrder,
|
|
582
|
+
state: OrderState.COMPLETED,
|
|
583
|
+
eligible_to_withdraw: true,
|
|
584
|
+
};
|
|
585
|
+
render(
|
|
586
|
+
<ProductCertificateFooter
|
|
587
|
+
product={product}
|
|
588
|
+
enrollment={EnrollmentFactory({ orders: [rejectedOrder] }).one()}
|
|
589
|
+
isWithdrawable={true}
|
|
590
|
+
/>,
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
expect(screen.queryByText('and is being processed.', { exact: false })).not.toBeInTheDocument();
|
|
594
|
+
expect(
|
|
595
|
+
screen.queryByText('You withdrew from this order', { exact: false }),
|
|
596
|
+
).not.toBeInTheDocument();
|
|
597
|
+
screen.getByRole('button', { name: 'I wish to withdraw' });
|
|
598
|
+
});
|
|
387
599
|
});
|
package/js/widgets/Dashboard/components/DashboardItem/Enrollment/ProductCertificateFooter/index.tsx
CHANGED
|
@@ -16,9 +16,10 @@ import { useCertificate } from 'hooks/useCertificates';
|
|
|
16
16
|
import { isOpenedCourseRunCertificate } from 'utils/CourseRuns';
|
|
17
17
|
import { OrderHelper } from 'utils/OrderHelper';
|
|
18
18
|
import { OrderPaymentRetryModal } from 'widgets/Dashboard/components/DashboardItem/Order/OrderPaymentRetryModal';
|
|
19
|
+
import { OrderWithdrawalModal } from 'widgets/Dashboard/components/DashboardItem/Order/OrderWithdrawalModal';
|
|
19
20
|
import PaymentScheduleHelper from 'utils/PaymentScheduleHelper';
|
|
20
21
|
import { useOrder } from 'hooks/useOrders';
|
|
21
|
-
import useDateFormat from 'hooks/useDateFormat';
|
|
22
|
+
import useDateFormat, { DATETIME_FORMAT } from 'hooks/useDateFormat';
|
|
22
23
|
import CertificateStatus from '../../CertificateStatus';
|
|
23
24
|
|
|
24
25
|
const messages = defineMessages({
|
|
@@ -58,6 +59,27 @@ const messages = defineMessages({
|
|
|
58
59
|
description: 'Button label for the payment needed message',
|
|
59
60
|
defaultMessage: 'Pay {amount}',
|
|
60
61
|
},
|
|
62
|
+
withdrawalDescription: {
|
|
63
|
+
id: 'components.ProductCertificateFooter.withdrawalDescription',
|
|
64
|
+
description: 'Message displayed to offer the withdrawal of the certificate order',
|
|
65
|
+
defaultMessage:
|
|
66
|
+
"If you haven't accessed the exam yet, you can exercise your right of withdrawal until {date}.",
|
|
67
|
+
},
|
|
68
|
+
withdrawalButton: {
|
|
69
|
+
id: 'components.ProductCertificateFooter.withdrawalButton',
|
|
70
|
+
description: 'Button label to open the withdrawal request modal',
|
|
71
|
+
defaultMessage: 'I wish to withdraw',
|
|
72
|
+
},
|
|
73
|
+
pendingWithdrawalMessage: {
|
|
74
|
+
id: 'components.ProductCertificateFooter.pendingWithdrawalMessage',
|
|
75
|
+
description: 'Message displayed when a withdrawal request is being processed',
|
|
76
|
+
defaultMessage: 'Your withdrawal request has been recorded on {date} and is being processed.',
|
|
77
|
+
},
|
|
78
|
+
withdrawnMessage: {
|
|
79
|
+
id: 'components.ProductCertificateFooter.withdrawnMessage',
|
|
80
|
+
description: 'Message displayed once a withdrawal request has been confirmed',
|
|
81
|
+
defaultMessage: 'You withdrew from this order on {date}.',
|
|
82
|
+
},
|
|
61
83
|
});
|
|
62
84
|
|
|
63
85
|
export interface ProductCertificateFooterProps {
|
|
@@ -75,6 +97,8 @@ const ProductCertificateFooter = ({
|
|
|
75
97
|
OrderHelper.getActiveEnrollmentOrder(enrollment.orders || [], product.id),
|
|
76
98
|
);
|
|
77
99
|
const isOrderActive = OrderHelper.isActive(order);
|
|
100
|
+
const isOrderWithdrawn = OrderHelper.isWithdrawn(order);
|
|
101
|
+
const isOrderVisible = isOrderActive || isOrderWithdrawn;
|
|
78
102
|
const isPurchasable =
|
|
79
103
|
OrderHelper.isPurchasable(order) && isOpenedCourseRunCertificate(enrollment.course_run.state);
|
|
80
104
|
const isCertificateIssued = isOrderActive && Boolean(order!.certificate_id);
|
|
@@ -92,36 +116,46 @@ const ProductCertificateFooter = ({
|
|
|
92
116
|
}
|
|
93
117
|
|
|
94
118
|
return (
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
119
|
+
<>
|
|
120
|
+
<div className="dashboard-item__course-enrolling__infos">
|
|
121
|
+
{!isOrderVisible ? (
|
|
122
|
+
<ProductCertificateStatus />
|
|
123
|
+
) : (
|
|
124
|
+
<OrderCertificateStatus order={order!} product={product} hasError={hasError} />
|
|
125
|
+
)}
|
|
126
|
+
{isCertificateIssued && (
|
|
127
|
+
<DownloadCertificateButton
|
|
128
|
+
className="dashboard-item__button"
|
|
129
|
+
certificateId={order!.certificate_id!}
|
|
130
|
+
/>
|
|
131
|
+
)}
|
|
132
|
+
<PurchaseButton
|
|
103
133
|
className="dashboard-item__button"
|
|
104
|
-
|
|
134
|
+
product={product}
|
|
135
|
+
enrollment={enrollment}
|
|
136
|
+
isWithdrawable={isWithdrawable}
|
|
137
|
+
buttonProps={{ size: 'small' }}
|
|
138
|
+
disabled={!isPurchasable}
|
|
139
|
+
onFinish={(o) => {
|
|
140
|
+
/**
|
|
141
|
+
* As we do not refetch enrollments in DashboardCourses after SaleTunnel cache invalidation (to avoid
|
|
142
|
+
* scroll reset - and SaleTunnel modal unmounting too early caused by list reset) we need to manually
|
|
143
|
+
* update the active order in the enrollment in order to hide the buy button and display the download button.
|
|
144
|
+
*/
|
|
145
|
+
setOrder(o);
|
|
146
|
+
}}
|
|
147
|
+
/>
|
|
148
|
+
{hasError && <FailedInstallmentManager order={order!} updateOrder={setOrder} />}
|
|
149
|
+
</div>
|
|
150
|
+
{isOrderActive && order!.eligible_to_withdraw && (
|
|
151
|
+
<WithdrawalManager
|
|
152
|
+
order={order!}
|
|
153
|
+
product={product}
|
|
154
|
+
enrollment={enrollment}
|
|
155
|
+
updateOrder={setOrder}
|
|
105
156
|
/>
|
|
106
157
|
)}
|
|
107
|
-
|
|
108
|
-
className="dashboard-item__button"
|
|
109
|
-
product={product}
|
|
110
|
-
enrollment={enrollment}
|
|
111
|
-
isWithdrawable={isWithdrawable}
|
|
112
|
-
buttonProps={{ size: 'small' }}
|
|
113
|
-
disabled={!isPurchasable}
|
|
114
|
-
onFinish={(o) => {
|
|
115
|
-
/**
|
|
116
|
-
* As we do not refetch enrollments in DashboardCourses after SaleTunnel cache invalidation (to avoid
|
|
117
|
-
* scroll reset - and SaleTunnel modal unmounting too early caused by list reset) we need to manually
|
|
118
|
-
* update the active order in the enrollment in order to hide the buy button and display the download button.
|
|
119
|
-
*/
|
|
120
|
-
setOrder(o);
|
|
121
|
-
}}
|
|
122
|
-
/>
|
|
123
|
-
{hasError && <FailedInstallmentManager order={order!} updateOrder={setOrder} />}
|
|
124
|
-
</div>
|
|
158
|
+
</>
|
|
125
159
|
);
|
|
126
160
|
};
|
|
127
161
|
|
|
@@ -143,10 +177,20 @@ const OrderCertificateStatus = ({ order, product, hasError }: OrderCertificateSt
|
|
|
143
177
|
const formatDate = useDateFormat();
|
|
144
178
|
const nextInstallment = PaymentScheduleHelper.getPendingInstallment(order?.payment_schedule);
|
|
145
179
|
const canAccessToExam = ![OrderState.PENDING, OrderState.NO_PAYMENT].includes(order.state);
|
|
180
|
+
const isPendingWithdrawal = order.state === OrderState.PENDING_WITHDRAW;
|
|
181
|
+
const isWithdrawn = OrderHelper.isWithdrawn(order);
|
|
182
|
+
let statusIcon = IconTypeEnum.CERTIFICATE;
|
|
183
|
+
if (hasError) {
|
|
184
|
+
statusIcon = IconTypeEnum.WARNING;
|
|
185
|
+
} else if (isPendingWithdrawal) {
|
|
186
|
+
statusIcon = IconTypeEnum.CLOCK;
|
|
187
|
+
} else if (isWithdrawn) {
|
|
188
|
+
statusIcon = IconTypeEnum.ROUND_CLOSE;
|
|
189
|
+
}
|
|
146
190
|
|
|
147
191
|
return (
|
|
148
192
|
<div className="dashboard-item__block__status">
|
|
149
|
-
<Icon name={
|
|
193
|
+
<Icon name={statusIcon} />
|
|
150
194
|
<p>
|
|
151
195
|
{hasError && (
|
|
152
196
|
<>
|
|
@@ -156,12 +200,30 @@ const OrderCertificateStatus = ({ order, product, hasError }: OrderCertificateSt
|
|
|
156
200
|
<br />
|
|
157
201
|
</>
|
|
158
202
|
)}
|
|
159
|
-
{
|
|
203
|
+
{isPendingWithdrawal && (
|
|
204
|
+
<>
|
|
205
|
+
<strong>
|
|
206
|
+
<FormattedMessage
|
|
207
|
+
{...messages.pendingWithdrawalMessage}
|
|
208
|
+
values={{ date: formatDate(order.withdrawn_requested_at) }}
|
|
209
|
+
/>
|
|
210
|
+
</strong>
|
|
211
|
+
<br />
|
|
212
|
+
</>
|
|
213
|
+
)}
|
|
214
|
+
{isWithdrawn && (
|
|
215
|
+
<FormattedMessage
|
|
216
|
+
{...messages.withdrawnMessage}
|
|
217
|
+
values={{ date: formatDate(order.withdrawn_confirmation_at) }}
|
|
218
|
+
/>
|
|
219
|
+
)}
|
|
220
|
+
{!isWithdrawn && !isPendingWithdrawal && canAccessToExam && (
|
|
160
221
|
<>
|
|
161
222
|
{product.certificate_definition.title + '. '}
|
|
162
223
|
<CertificateStatus certificate={certificate} productType={product.type} />
|
|
163
224
|
</>
|
|
164
|
-
)
|
|
225
|
+
)}
|
|
226
|
+
{!isWithdrawn && !isPendingWithdrawal && !canAccessToExam && (
|
|
165
227
|
<FormattedMessage {...messages.examAccessBlocked} />
|
|
166
228
|
)}
|
|
167
229
|
{!hasError && nextInstallment && (
|
|
@@ -232,4 +294,44 @@ const FailedInstallmentManager = ({ order, updateOrder }: FailedInstallmentManag
|
|
|
232
294
|
);
|
|
233
295
|
};
|
|
234
296
|
|
|
297
|
+
type WithdrawalManagerProps = {
|
|
298
|
+
order: OrderEnrollment;
|
|
299
|
+
product: CertificateProduct;
|
|
300
|
+
enrollment: Enrollment;
|
|
301
|
+
updateOrder: (order: Order) => void;
|
|
302
|
+
};
|
|
303
|
+
const WithdrawalManager = ({ order, product, enrollment, updateOrder }: WithdrawalManagerProps) => {
|
|
304
|
+
const intl = useIntl();
|
|
305
|
+
const formatDate = useDateFormat(DATETIME_FORMAT);
|
|
306
|
+
const modal = useModal();
|
|
307
|
+
|
|
308
|
+
return (
|
|
309
|
+
<div className="dashboard-item__course-enrolling__infos">
|
|
310
|
+
<div className="dashboard-item__block__status">
|
|
311
|
+
<Icon name={IconTypeEnum.MONEY} />
|
|
312
|
+
<FormattedMessage
|
|
313
|
+
{...messages.withdrawalDescription}
|
|
314
|
+
values={{ date: formatDate(order.withdrawal_date_limit) }}
|
|
315
|
+
/>
|
|
316
|
+
</div>
|
|
317
|
+
<Button
|
|
318
|
+
className="dashboard-item__course-enrolling__infos__withdrawal__button"
|
|
319
|
+
size="small"
|
|
320
|
+
color="brand"
|
|
321
|
+
variant="tertiary"
|
|
322
|
+
onClick={modal.open}
|
|
323
|
+
>
|
|
324
|
+
{intl.formatMessage(messages.withdrawalButton)}
|
|
325
|
+
</Button>
|
|
326
|
+
<OrderWithdrawalModal
|
|
327
|
+
{...modal}
|
|
328
|
+
order={order}
|
|
329
|
+
productTitle={product.title}
|
|
330
|
+
reference={enrollment.course_run.course.code}
|
|
331
|
+
onSuccess={updateOrder}
|
|
332
|
+
/>
|
|
333
|
+
</div>
|
|
334
|
+
);
|
|
335
|
+
};
|
|
336
|
+
|
|
235
337
|
export default ProductCertificateFooter;
|
|
@@ -195,6 +195,30 @@ describe('<DashboardItemOrder/>', () => {
|
|
|
195
195
|
});
|
|
196
196
|
});
|
|
197
197
|
|
|
198
|
+
it('renders a withdrawn order without its active-course content', async () => {
|
|
199
|
+
const order: CredentialOrder = CredentialOrderFactory({
|
|
200
|
+
state: OrderState.CANCELED,
|
|
201
|
+
withdrawn_confirmation_at: '2026-01-15T10:00:00.000Z',
|
|
202
|
+
}).one();
|
|
203
|
+
const { product } = mockCourseProductWithOrder(order);
|
|
204
|
+
|
|
205
|
+
render(<DashboardItemOrder order={order} />);
|
|
206
|
+
|
|
207
|
+
await screen.findByRole('heading', { level: 5, name: product.title });
|
|
208
|
+
const withdrawnOnDate = new Intl.DateTimeFormat('en', DEFAULT_DATE_FORMAT).format(
|
|
209
|
+
new Date(order.withdrawn_confirmation_at!),
|
|
210
|
+
);
|
|
211
|
+
await screen.findByText(`You withdrew from this order on ${withdrawnOnDate}.`, {
|
|
212
|
+
exact: false,
|
|
213
|
+
});
|
|
214
|
+
expect(screen.queryByRole('link', { name: 'View details' })).not.toBeInTheDocument();
|
|
215
|
+
order.target_courses.forEach((course) => {
|
|
216
|
+
expect(
|
|
217
|
+
screen.queryByRole('heading', { level: 6, name: course.title }),
|
|
218
|
+
).not.toBeInTheDocument();
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
198
222
|
it('renders a non-writable order with enrolled target course ', async () => {
|
|
199
223
|
const order: CredentialOrder = CredentialOrderFactory({
|
|
200
224
|
target_courses: TargetCourseFactory().many(1),
|
|
@@ -7,6 +7,7 @@ import { DashboardSubItem } from 'widgets/Dashboard/components/DashboardItem/Das
|
|
|
7
7
|
import { RouterButton } from 'widgets/Dashboard/components/RouterButton';
|
|
8
8
|
import { useCourseProduct } from 'hooks/useCourseProducts';
|
|
9
9
|
import { OrderHelper } from 'utils/OrderHelper';
|
|
10
|
+
import useDateFormat from 'hooks/useDateFormat';
|
|
10
11
|
import { LearnerDashboardPaths } from 'widgets/Dashboard/utils/learnerRoutesPaths';
|
|
11
12
|
import OrganizationBlock from 'widgets/Dashboard/components/DashboardItem/Order/OrganizationBlock';
|
|
12
13
|
import CertificateItem from 'widgets/Dashboard/components/DashboardItem/Order/CertificateItem';
|
|
@@ -50,6 +51,11 @@ const messages = defineMessages({
|
|
|
50
51
|
defaultMessage:
|
|
51
52
|
'The subscription process cannot be resumed. The related training is no more purchasable.',
|
|
52
53
|
},
|
|
54
|
+
withdrawnOn: {
|
|
55
|
+
id: 'components.DashboardItemOrder.withdrawnOn',
|
|
56
|
+
description: 'Message displayed once a withdrawal request has been confirmed',
|
|
57
|
+
defaultMessage: 'You withdrew from this order on {date}.',
|
|
58
|
+
},
|
|
53
59
|
});
|
|
54
60
|
|
|
55
61
|
interface DashboardItemOrderProps {
|
|
@@ -67,6 +73,7 @@ export const DashboardItemOrder = ({
|
|
|
67
73
|
}: DashboardItemOrderProps) => {
|
|
68
74
|
const { course } = order;
|
|
69
75
|
const intl = useIntl();
|
|
76
|
+
const formatDate = useDateFormat();
|
|
70
77
|
const { item: offering } = useCourseProduct({
|
|
71
78
|
product_id: order.product_id,
|
|
72
79
|
course_id: course.code,
|
|
@@ -75,8 +82,10 @@ export const DashboardItemOrder = ({
|
|
|
75
82
|
const needsSignature = OrderHelper.orderNeedsSignature(order);
|
|
76
83
|
const needsPaymentMethod = order.state === OrderState.TO_SAVE_PAYMENT_METHOD;
|
|
77
84
|
const isActive = OrderHelper.isActive(order);
|
|
85
|
+
const isWithdrawn = OrderHelper.isWithdrawn(order);
|
|
78
86
|
const isProductPurchasable = ProductHelper.isPurchasable(offering?.product);
|
|
79
87
|
const isNotResumable = !isActive && !isProductPurchasable;
|
|
88
|
+
const hideResumableContent = isNotResumable || isWithdrawn;
|
|
80
89
|
const canEnroll = OrderHelper.allowEnrollment(order);
|
|
81
90
|
const isFreeFromBatchOrder = OrderHelper.isFreeFromBatchOrder(order);
|
|
82
91
|
|
|
@@ -107,12 +116,21 @@ export const DashboardItemOrder = ({
|
|
|
107
116
|
</>
|
|
108
117
|
) : (
|
|
109
118
|
<>
|
|
110
|
-
<Icon name={IconTypeEnum.SCHOOL} />
|
|
111
|
-
<
|
|
119
|
+
<Icon name={isWithdrawn ? IconTypeEnum.ROUND_CLOSE : IconTypeEnum.SCHOOL} />
|
|
120
|
+
<span>
|
|
121
|
+
{isWithdrawn && order.withdrawn_confirmation_at ? (
|
|
122
|
+
<FormattedMessage
|
|
123
|
+
{...messages.withdrawnOn}
|
|
124
|
+
values={{ date: formatDate(order.withdrawn_confirmation_at) }}
|
|
125
|
+
/>
|
|
126
|
+
) : (
|
|
127
|
+
<OrderStateLearnerMessage order={order} />
|
|
128
|
+
)}
|
|
129
|
+
</span>
|
|
112
130
|
</>
|
|
113
131
|
)}
|
|
114
132
|
</div>
|
|
115
|
-
{!
|
|
133
|
+
{!hideResumableContent && showDetailsButton && !isFreeFromBatchOrder && (
|
|
116
134
|
<RouterButton
|
|
117
135
|
size="small"
|
|
118
136
|
className="dashboard-item__button"
|
|
@@ -170,7 +188,7 @@ export const DashboardItemOrder = ({
|
|
|
170
188
|
</>
|
|
171
189
|
}
|
|
172
190
|
>
|
|
173
|
-
{!
|
|
191
|
+
{!hideResumableContent && (
|
|
174
192
|
<DashboardSubItemsList
|
|
175
193
|
subItems={order.target_courses?.map((targetCourse) => (
|
|
176
194
|
<DashboardSubItem
|
|
@@ -195,15 +213,11 @@ export const DashboardItemOrder = ({
|
|
|
195
213
|
/>
|
|
196
214
|
)}
|
|
197
215
|
</DashboardItem>
|
|
198
|
-
{!
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
{writable && !isFreeFromBatchOrder && (
|
|
204
|
-
<OrganizationBlock order={order} product={product} />
|
|
205
|
-
)}
|
|
206
|
-
</>
|
|
216
|
+
{!hideResumableContent && showCertificate && !!product?.certificate_definition && (
|
|
217
|
+
<CertificateItem order={order} product={product} />
|
|
218
|
+
)}
|
|
219
|
+
{!isNotResumable && writable && !isFreeFromBatchOrder && (
|
|
220
|
+
<OrganizationBlock order={order} product={product} />
|
|
207
221
|
)}
|
|
208
222
|
</div>
|
|
209
223
|
);
|