richie-education 3.5.1 → 3.5.2-dev6
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/js/api/joanie.ts +5 -0
- package/js/components/SaleTunnel/index.spec.tsx +1 -1
- package/js/hooks/useLearnerCoursesSearch/index.tsx +17 -4
- package/js/hooks/useProductOrder/index.spec.tsx +1 -0
- package/js/pages/DashboardCourses/index.spec.tsx +40 -8
- 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/joanie.ts +10 -0
- 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 +22 -0
- package/js/widgets/Dashboard/components/DashboardItem/Order/DashboardItemOrder.tsx +29 -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 +139 -0
- package/js/widgets/Dashboard/components/DashboardItem/Order/OrderWithdrawalModal/index.tsx +259 -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/_styles.scss +9 -1
- package/js/widgets/Dashboard/index.spec.tsx +0 -1
- package/package.json +1 -1
package/js/api/joanie.ts
CHANGED
|
@@ -101,6 +101,7 @@ export const getRoutes = () => {
|
|
|
101
101
|
submit_for_signature: `${baseUrl}/orders/:id/submit_for_signature/`,
|
|
102
102
|
submit_installment_payment: `${baseUrl}/orders/:id/submit-installment-payment/`,
|
|
103
103
|
set_payment_method: `${baseUrl}/orders/:id/payment-method/`,
|
|
104
|
+
withdraw: `${baseUrl}/orders/:id/withdraw/`,
|
|
104
105
|
},
|
|
105
106
|
batchOrders: {
|
|
106
107
|
get: `${baseUrl}/batch-orders/:id/`,
|
|
@@ -340,6 +341,10 @@ const API = (): Joanie.API => {
|
|
|
340
341
|
method: 'POST',
|
|
341
342
|
body: JSON.stringify(payload),
|
|
342
343
|
}).then(checkStatus),
|
|
344
|
+
withdraw: async (id) =>
|
|
345
|
+
fetchWithJWT(ROUTES.user.orders.withdraw.replace(':id', id), {
|
|
346
|
+
method: 'POST',
|
|
347
|
+
}).then(checkStatus),
|
|
343
348
|
},
|
|
344
349
|
batchOrders: {
|
|
345
350
|
create: async (payload) =>
|
|
@@ -552,7 +552,7 @@ describe.each([
|
|
|
552
552
|
enrollmentDiscounted.offerings[0].product = product;
|
|
553
553
|
fetchMock
|
|
554
554
|
.get(
|
|
555
|
-
`https://joanie.endpoint/api/v1.0/orders/?enrollment_id=${enrollmentDiscounted.id}&product_id=${product.id}&state=pending&state=pending_payment&state=no_payment&state=failed_payment&state=completed&state=draft&state=assigned&state=to_sign&state=signing&state=to_save_payment_method`,
|
|
555
|
+
`https://joanie.endpoint/api/v1.0/orders/?enrollment_id=${enrollmentDiscounted.id}&product_id=${product.id}&state=pending&state=pending_payment&state=no_payment&state=failed_payment&state=completed&state=pending_withdraw&state=draft&state=assigned&state=to_sign&state=signing&state=to_save_payment_method`,
|
|
556
556
|
{
|
|
557
557
|
results: [],
|
|
558
558
|
next: null,
|
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
2
|
import { useSearchParams } from 'react-router';
|
|
3
|
-
import { Enrollment, CredentialOrder, ProductType,
|
|
3
|
+
import { Enrollment, CredentialOrder, ProductType, OrderState } from 'types/Joanie';
|
|
4
4
|
import { Maybe, Nullable } from 'types/utils';
|
|
5
|
-
import {
|
|
5
|
+
import { OrderHelper } from 'utils/OrderHelper';
|
|
6
|
+
import { isOrder, useOrdersEnrollments } from 'pages/DashboardCourses/useOrdersEnrollments';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Orders canceled following the buyer's withdrawal must stay visible in the dashboard
|
|
10
|
+
* (the learner needs to keep a trace of that legal action), unlike other cancellation
|
|
11
|
+
* causes. The API can't filter on that distinction yet, so we only exclude REFUNDING/
|
|
12
|
+
* REFUNDED server-side and drop the remaining non-withdrawn CANCELED orders here.
|
|
13
|
+
*/
|
|
14
|
+
const isHiddenCanceledOrder = (item: CredentialOrder | Enrollment) =>
|
|
15
|
+
isOrder(item) && OrderHelper.isCanceled(item) && !OrderHelper.isWithdrawn(item);
|
|
6
16
|
|
|
7
17
|
const useLearnerCoursesSearch = () => {
|
|
8
18
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
@@ -23,7 +33,7 @@ const useLearnerCoursesSearch = () => {
|
|
|
23
33
|
query,
|
|
24
34
|
orderFilters: {
|
|
25
35
|
product_type: [ProductType.CREDENTIAL],
|
|
26
|
-
state_exclude:
|
|
36
|
+
state_exclude: [OrderState.REFUNDING, OrderState.REFUNDED],
|
|
27
37
|
},
|
|
28
38
|
});
|
|
29
39
|
|
|
@@ -41,7 +51,10 @@ const useLearnerCoursesSearch = () => {
|
|
|
41
51
|
}
|
|
42
52
|
|
|
43
53
|
if (isNewSearchLoading || data.length > orderAndEnrollmentList?.length) {
|
|
44
|
-
|
|
54
|
+
const visibleData = (data as (CredentialOrder | Enrollment)[]).filter(
|
|
55
|
+
(item) => !isHiddenCanceledOrder(item),
|
|
56
|
+
);
|
|
57
|
+
setOrderAndEnrollmentList(visibleData);
|
|
45
58
|
setCount(currentCount);
|
|
46
59
|
}
|
|
47
60
|
}, [data.length, isLoading, isNewSearchLoading, query]);
|
|
@@ -84,6 +84,7 @@ describe('useProductOrder', () => {
|
|
|
84
84
|
`&state=${OrderState.NO_PAYMENT}` +
|
|
85
85
|
`&state=${OrderState.FAILED_PAYMENT}` +
|
|
86
86
|
`&state=${OrderState.COMPLETED}` +
|
|
87
|
+
`&state=${OrderState.PENDING_WITHDRAW}` +
|
|
87
88
|
`&state=${OrderState.DRAFT}` +
|
|
88
89
|
`&state=${OrderState.ASSIGNED}` +
|
|
89
90
|
`&state=${OrderState.TO_SIGN}` +
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
CredentialOrderFactory,
|
|
11
11
|
} from 'utils/test/factories/joanie';
|
|
12
12
|
import { createTestQueryClient } from 'utils/test/createTestQueryClient';
|
|
13
|
-
import { CourseLight, Offering, Enrollment, CredentialOrder } from 'types/Joanie';
|
|
13
|
+
import { CourseLight, Offering, Enrollment, CredentialOrder, OrderState } 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';
|
|
@@ -105,7 +105,6 @@ describe('<DashboardCourses/>', () => {
|
|
|
105
105
|
fetchMock.get(
|
|
106
106
|
'https://joanie.endpoint/api/v1.0/orders/' +
|
|
107
107
|
'?product_type=credential' +
|
|
108
|
-
'&state_exclude=canceled' +
|
|
109
108
|
'&state_exclude=refunding' +
|
|
110
109
|
'&state_exclude=refunded' +
|
|
111
110
|
'&page=1' +
|
|
@@ -153,7 +152,6 @@ describe('<DashboardCourses/>', () => {
|
|
|
153
152
|
fetchMock.get(
|
|
154
153
|
'https://joanie.endpoint/api/v1.0/orders/' +
|
|
155
154
|
'?product_type=credential' +
|
|
156
|
-
'&state_exclude=canceled' +
|
|
157
155
|
'&state_exclude=refunding' +
|
|
158
156
|
'&state_exclude=refunded' +
|
|
159
157
|
'&page=1' +
|
|
@@ -163,7 +161,6 @@ describe('<DashboardCourses/>', () => {
|
|
|
163
161
|
next:
|
|
164
162
|
'https://joanie.endpoint/api/v1.0/orders/' +
|
|
165
163
|
'?product_type=credential' +
|
|
166
|
-
'&state_exclude=canceled' +
|
|
167
164
|
'&state_exclude=refunding' +
|
|
168
165
|
'&state_exclude=refunded' +
|
|
169
166
|
'&page=2' +
|
|
@@ -175,7 +172,6 @@ describe('<DashboardCourses/>', () => {
|
|
|
175
172
|
fetchMock.get(
|
|
176
173
|
'https://joanie.endpoint/api/v1.0/orders/' +
|
|
177
174
|
'?product_type=credential' +
|
|
178
|
-
'&state_exclude=canceled' +
|
|
179
175
|
'&state_exclude=refunding' +
|
|
180
176
|
'&state_exclude=refunded' +
|
|
181
177
|
'&page=2' +
|
|
@@ -185,7 +181,6 @@ describe('<DashboardCourses/>', () => {
|
|
|
185
181
|
next:
|
|
186
182
|
'https://joanie.endpoint/api/v1.0/orders/' +
|
|
187
183
|
'?product_type=credential' +
|
|
188
|
-
'&state_exclude=canceled' +
|
|
189
184
|
'&state_exclude=refunding' +
|
|
190
185
|
'&state_exclude=refunded' +
|
|
191
186
|
'&page=3' +
|
|
@@ -197,7 +192,6 @@ describe('<DashboardCourses/>', () => {
|
|
|
197
192
|
fetchMock.get(
|
|
198
193
|
'https://joanie.endpoint/api/v1.0/orders/' +
|
|
199
194
|
'?product_type=credential' +
|
|
200
|
-
'&state_exclude=canceled' +
|
|
201
195
|
'&state_exclude=refunding' +
|
|
202
196
|
'&state_exclude=refunded' +
|
|
203
197
|
'&page=3' +
|
|
@@ -271,13 +265,51 @@ describe('<DashboardCourses/>', () => {
|
|
|
271
265
|
expect(loadMoreButton).toBeEnabled();
|
|
272
266
|
}, 15000);
|
|
273
267
|
|
|
268
|
+
it('keeps a withdrawn order visible while filtering out other canceled orders', async () => {
|
|
269
|
+
const activeOrder = CredentialOrderFactory({
|
|
270
|
+
state: OrderState.COMPLETED,
|
|
271
|
+
created_on: '2026-01-03T00:00:00.000Z',
|
|
272
|
+
}).one();
|
|
273
|
+
const withdrawnOrder = CredentialOrderFactory({
|
|
274
|
+
state: OrderState.CANCELED,
|
|
275
|
+
created_on: '2026-01-02T00:00:00.000Z',
|
|
276
|
+
withdrawn_confirmation_at: '2026-01-02T00:00:00.000Z',
|
|
277
|
+
}).one();
|
|
278
|
+
const canceledOrder = CredentialOrderFactory({
|
|
279
|
+
state: OrderState.CANCELED,
|
|
280
|
+
created_on: '2026-01-01T00:00:00.000Z',
|
|
281
|
+
withdrawn_confirmation_at: null,
|
|
282
|
+
}).one();
|
|
283
|
+
const { orders, offerings } = mockOrders([activeOrder, withdrawnOrder, canceledOrder]);
|
|
284
|
+
|
|
285
|
+
fetchMock.get(
|
|
286
|
+
'https://joanie.endpoint/api/v1.0/orders/' +
|
|
287
|
+
'?product_type=credential' +
|
|
288
|
+
'&state_exclude=refunding' +
|
|
289
|
+
'&state_exclude=refunded' +
|
|
290
|
+
'&page=1' +
|
|
291
|
+
`&page_size=${perPage}`,
|
|
292
|
+
{ results: orders, next: null, previous: null, count: orders.length },
|
|
293
|
+
);
|
|
294
|
+
fetchMock.get(
|
|
295
|
+
`https://joanie.endpoint/api/v1.0/enrollments/?was_created_by_order=false&is_active=true&page=1&page_size=${perPage}`,
|
|
296
|
+
{ results: [], next: null, previous: null, count: 0 },
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
render(<DashboardTest initialRoute={LearnerDashboardPaths.COURSES} />, {
|
|
300
|
+
wrapper: BaseJoanieAppWrapper,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
await expectNoSpinner('Loading orders and enrollments...');
|
|
304
|
+
await waitFor(() => expectList([activeOrder, withdrawnOrder], offerings));
|
|
305
|
+
});
|
|
306
|
+
|
|
274
307
|
it('shows an error', async () => {
|
|
275
308
|
jest.spyOn(console, 'error').mockImplementation(noop);
|
|
276
309
|
const ordersDeferred = new Deferred();
|
|
277
310
|
fetchMock.get(
|
|
278
311
|
'https://joanie.endpoint/api/v1.0/orders/' +
|
|
279
312
|
'?product_type=credential' +
|
|
280
|
-
'&state_exclude=canceled' +
|
|
281
313
|
'&state_exclude=refunding' +
|
|
282
314
|
'&state_exclude=refunded' +
|
|
283
315
|
'&page=1' +
|
package/js/types/Joanie.ts
CHANGED
|
@@ -312,6 +312,7 @@ export enum OrderState {
|
|
|
312
312
|
NO_PAYMENT = 'no_payment',
|
|
313
313
|
PENDING = 'pending',
|
|
314
314
|
PENDING_PAYMENT = 'pending_payment',
|
|
315
|
+
PENDING_WITHDRAW = 'pending_withdraw',
|
|
315
316
|
SIGNING = 'signing',
|
|
316
317
|
TO_SAVE_PAYMENT_METHOD = 'to_save_payment_method',
|
|
317
318
|
TO_SIGN = 'to_sign',
|
|
@@ -331,6 +332,7 @@ export const ACTIVE_ORDER_STATES = [
|
|
|
331
332
|
OrderState.NO_PAYMENT,
|
|
332
333
|
OrderState.FAILED_PAYMENT,
|
|
333
334
|
OrderState.COMPLETED,
|
|
335
|
+
OrderState.PENDING_WITHDRAW,
|
|
334
336
|
];
|
|
335
337
|
|
|
336
338
|
export const NOT_CANCELED_ORDER_STATES = [...ACTIVE_ORDER_STATES, ...PURCHASABLE_ORDER_STATES];
|
|
@@ -367,6 +369,11 @@ export interface Order {
|
|
|
367
369
|
payment_schedule?: PaymentSchedule;
|
|
368
370
|
credit_card_id?: CreditCard['id'];
|
|
369
371
|
from_batch_order?: boolean;
|
|
372
|
+
has_waived_withdrawal_right: boolean;
|
|
373
|
+
eligible_to_withdraw: boolean;
|
|
374
|
+
withdrawal_date_limit: Nullable<string>;
|
|
375
|
+
withdrawn_requested_at: Nullable<string>;
|
|
376
|
+
withdrawn_confirmation_at: Nullable<string>;
|
|
370
377
|
}
|
|
371
378
|
|
|
372
379
|
export interface CredentialOrder extends Order {
|
|
@@ -418,7 +425,16 @@ export interface NestedCredentialOrder extends AbstractNestedOrder {
|
|
|
418
425
|
|
|
419
426
|
export type OrderEnrollment = Pick<
|
|
420
427
|
Order,
|
|
421
|
-
|
|
428
|
+
| 'id'
|
|
429
|
+
| 'state'
|
|
430
|
+
| 'product_id'
|
|
431
|
+
| 'certificate_id'
|
|
432
|
+
| 'payment_schedule'
|
|
433
|
+
| 'has_waived_withdrawal_right'
|
|
434
|
+
| 'eligible_to_withdraw'
|
|
435
|
+
| 'withdrawal_date_limit'
|
|
436
|
+
| 'withdrawn_requested_at'
|
|
437
|
+
| 'withdrawn_confirmation_at'
|
|
422
438
|
>;
|
|
423
439
|
|
|
424
440
|
export interface NestedCourseOrder {
|
|
@@ -838,6 +854,7 @@ interface APIUser {
|
|
|
838
854
|
payload?: OrderSubmitInstallmentPayment,
|
|
839
855
|
): Promise<Payment>;
|
|
840
856
|
set_payment_method(payload: OrderSetPaymentMethodPayload): Promise<void>;
|
|
857
|
+
withdraw(id: Order['id']): Promise<CredentialOrder | CertificateOrder>;
|
|
841
858
|
};
|
|
842
859
|
batchOrders: {
|
|
843
860
|
create(payload: BatchOrder): Promise<BatchOrderRead>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { OrderState } from 'types/Joanie';
|
|
2
|
+
import { CredentialOrderFactory, OrderEnrollmentFactory } from 'utils/test/factories/joanie';
|
|
3
|
+
import { OrderHelper, OrderStatus } from '.';
|
|
4
|
+
|
|
5
|
+
describe('OrderHelper', () => {
|
|
6
|
+
describe('isWithdrawn', () => {
|
|
7
|
+
it('should return false when the order is undefined', () => {
|
|
8
|
+
expect(OrderHelper.isWithdrawn(undefined)).toBe(false);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('should return false when withdrawn_confirmation_at is not set', () => {
|
|
12
|
+
const order = CredentialOrderFactory({ withdrawn_confirmation_at: null }).one();
|
|
13
|
+
expect(OrderHelper.isWithdrawn(order)).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('should return true when withdrawn_confirmation_at is set', () => {
|
|
17
|
+
const order = CredentialOrderFactory({
|
|
18
|
+
withdrawn_confirmation_at: new Date().toISOString(),
|
|
19
|
+
}).one();
|
|
20
|
+
expect(OrderHelper.isWithdrawn(order)).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('getState', () => {
|
|
25
|
+
it('should return WITHDRAWN when the order has been withdrawn, regardless of its state', () => {
|
|
26
|
+
const order = CredentialOrderFactory({
|
|
27
|
+
state: OrderState.CANCELED,
|
|
28
|
+
withdrawn_confirmation_at: new Date().toISOString(),
|
|
29
|
+
}).one();
|
|
30
|
+
expect(OrderHelper.getState(order)).toBe(OrderStatus.WITHDRAWN);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should return PENDING_WITHDRAWAL for a pending_withdraw order', () => {
|
|
34
|
+
const order = CredentialOrderFactory({
|
|
35
|
+
state: OrderState.PENDING_WITHDRAW,
|
|
36
|
+
withdrawn_confirmation_at: null,
|
|
37
|
+
}).one();
|
|
38
|
+
expect(OrderHelper.getState(order)).toBe(OrderStatus.PENDING_WITHDRAWAL);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('getActiveEnrollmentOrder', () => {
|
|
43
|
+
it('should return a withdrawn order matching the product id even though it is not active', () => {
|
|
44
|
+
const withdrawnOrder = OrderEnrollmentFactory({
|
|
45
|
+
state: OrderState.CANCELED,
|
|
46
|
+
product_id: 'PRODUCT_ID',
|
|
47
|
+
withdrawn_confirmation_at: new Date().toISOString(),
|
|
48
|
+
}).one();
|
|
49
|
+
|
|
50
|
+
expect(OrderHelper.getActiveEnrollmentOrder([withdrawnOrder], 'PRODUCT_ID')).toBe(
|
|
51
|
+
withdrawnOrder,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should not return a canceled order that has not been withdrawn', () => {
|
|
56
|
+
const canceledOrder = OrderEnrollmentFactory({
|
|
57
|
+
state: OrderState.CANCELED,
|
|
58
|
+
product_id: 'PRODUCT_ID',
|
|
59
|
+
withdrawn_confirmation_at: null,
|
|
60
|
+
}).one();
|
|
61
|
+
|
|
62
|
+
expect(OrderHelper.getActiveEnrollmentOrder([canceledOrder], 'PRODUCT_ID')).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('isPurchasable', () => {
|
|
67
|
+
it('should return true when the order has been withdrawn, regardless of its state', () => {
|
|
68
|
+
const order = CredentialOrderFactory({
|
|
69
|
+
state: OrderState.CANCELED,
|
|
70
|
+
withdrawn_confirmation_at: new Date().toISOString(),
|
|
71
|
+
}).one();
|
|
72
|
+
expect(OrderHelper.isPurchasable(order)).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -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
|
|
|
@@ -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
|
});
|