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/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,28 @@ 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(`Withdrawn On ${withdrawnOnDate}.`, { exact: false });
|
|
212
|
+
expect(screen.queryByRole('link', { name: 'View details' })).not.toBeInTheDocument();
|
|
213
|
+
order.target_courses.forEach((course) => {
|
|
214
|
+
expect(
|
|
215
|
+
screen.queryByRole('heading', { level: 6, name: course.title }),
|
|
216
|
+
).not.toBeInTheDocument();
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
198
220
|
it('renders a non-writable order with enrolled target course ', async () => {
|
|
199
221
|
const order: CredentialOrder = CredentialOrderFactory({
|
|
200
222
|
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: 'Date at which the withdrawal was confirmed',
|
|
57
|
+
defaultMessage: '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,23 @@ 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
|
+
<OrderStateLearnerMessage order={order} />
|
|
122
|
+
{isWithdrawn && order.withdrawn_confirmation_at && (
|
|
123
|
+
<>
|
|
124
|
+
{' '}
|
|
125
|
+
<FormattedMessage
|
|
126
|
+
{...messages.withdrawnOn}
|
|
127
|
+
values={{ date: formatDate(order.withdrawn_confirmation_at) }}
|
|
128
|
+
/>
|
|
129
|
+
</>
|
|
130
|
+
)}
|
|
131
|
+
</span>
|
|
112
132
|
</>
|
|
113
133
|
)}
|
|
114
134
|
</div>
|
|
115
|
-
{!
|
|
135
|
+
{!hideResumableContent && showDetailsButton && !isFreeFromBatchOrder && (
|
|
116
136
|
<RouterButton
|
|
117
137
|
size="small"
|
|
118
138
|
className="dashboard-item__button"
|
|
@@ -170,7 +190,7 @@ export const DashboardItemOrder = ({
|
|
|
170
190
|
</>
|
|
171
191
|
}
|
|
172
192
|
>
|
|
173
|
-
{!
|
|
193
|
+
{!hideResumableContent && (
|
|
174
194
|
<DashboardSubItemsList
|
|
175
195
|
subItems={order.target_courses?.map((targetCourse) => (
|
|
176
196
|
<DashboardSubItem
|
|
@@ -195,15 +215,11 @@ export const DashboardItemOrder = ({
|
|
|
195
215
|
/>
|
|
196
216
|
)}
|
|
197
217
|
</DashboardItem>
|
|
198
|
-
{!
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
{writable && !isFreeFromBatchOrder && (
|
|
204
|
-
<OrganizationBlock order={order} product={product} />
|
|
205
|
-
)}
|
|
206
|
-
</>
|
|
218
|
+
{!hideResumableContent && showCertificate && !!product?.certificate_definition && (
|
|
219
|
+
<CertificateItem order={order} product={product} />
|
|
220
|
+
)}
|
|
221
|
+
{!isNotResumable && writable && !isFreeFromBatchOrder && (
|
|
222
|
+
<OrganizationBlock order={order} product={product} />
|
|
207
223
|
)}
|
|
208
224
|
</div>
|
|
209
225
|
);
|
|
@@ -88,7 +88,6 @@ describe('<DashboardItemOrder/> Contract', () => {
|
|
|
88
88
|
fetchMock.get(
|
|
89
89
|
'https://joanie.endpoint/api/v1.0/orders/' +
|
|
90
90
|
'?product_type=credential' +
|
|
91
|
-
'&state_exclude=canceled' +
|
|
92
91
|
'&state_exclude=refunding' +
|
|
93
92
|
'&state_exclude=refunded' +
|
|
94
93
|
'&page=1' +
|
|
@@ -267,7 +266,6 @@ describe('<DashboardItemOrder/> Contract', () => {
|
|
|
267
266
|
fetchMock.get(
|
|
268
267
|
'https://joanie.endpoint/api/v1.0/orders/' +
|
|
269
268
|
'?product_type=credential' +
|
|
270
|
-
'&state_exclude=canceled' +
|
|
271
269
|
'&state_exclude=refunding' +
|
|
272
270
|
'&state_exclude=refunded' +
|
|
273
271
|
'&page=1' +
|
package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateLearnerMessage/index.spec.tsx
CHANGED
|
@@ -19,6 +19,7 @@ describe('<OrderStateLearnerMessage/>', () => {
|
|
|
19
19
|
[OrderState.NO_PAYMENT, 'First direct debit has failed'],
|
|
20
20
|
[OrderState.PENDING, 'Pending for the first direct debit'],
|
|
21
21
|
[OrderState.PENDING_PAYMENT, 'On going'],
|
|
22
|
+
[OrderState.PENDING_WITHDRAW, 'Withdrawal in progress'],
|
|
22
23
|
[OrderState.SIGNING, 'Signature required'],
|
|
23
24
|
[OrderState.TO_SAVE_PAYMENT_METHOD, 'Payment method is missing'],
|
|
24
25
|
[OrderState.TO_SIGN, 'Signature required'],
|
|
@@ -30,6 +31,17 @@ describe('<OrderStateLearnerMessage/>', () => {
|
|
|
30
31
|
expect(screen.getByText(expectedMessage)).toBeInTheDocument();
|
|
31
32
|
});
|
|
32
33
|
|
|
34
|
+
it('should display message for a withdrawn order', () => {
|
|
35
|
+
const order = CredentialOrderFactory({
|
|
36
|
+
state: OrderState.CANCELED,
|
|
37
|
+
withdrawn_confirmation_at: new Date().toISOString(),
|
|
38
|
+
}).one();
|
|
39
|
+
render(<OrderStateLearnerMessage order={order} />, {
|
|
40
|
+
wrapper: ({ children }: PropsWithChildren) => <IntlWrapper>{children}</IntlWrapper>,
|
|
41
|
+
});
|
|
42
|
+
expect(screen.getByText('Withdrawn')).toBeInTheDocument();
|
|
43
|
+
});
|
|
44
|
+
|
|
33
45
|
it('should display message for completed order that have a generated certificate', () => {
|
|
34
46
|
const order = CredentialOrderFactory({
|
|
35
47
|
state: OrderState.COMPLETED,
|
package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateLearnerMessage/index.tsx
CHANGED
|
@@ -71,6 +71,17 @@ export const messages = defineMessages<MessageKeys>({
|
|
|
71
71
|
description: 'Status shown on the dashboard order item when order is refunded',
|
|
72
72
|
defaultMessage: 'Refunded',
|
|
73
73
|
},
|
|
74
|
+
statusPendingWithdrawal: {
|
|
75
|
+
id: 'components.DashboardItem.Order.OrderStateLearnerMessage.statusPendingWithdrawal',
|
|
76
|
+
description:
|
|
77
|
+
'Status shown on the dashboard order item when a withdrawal request is being processed',
|
|
78
|
+
defaultMessage: 'Withdrawal in progress',
|
|
79
|
+
},
|
|
80
|
+
statusWithdrawn: {
|
|
81
|
+
id: 'components.DashboardItem.Order.OrderStateLearnerMessage.statusWithdrawn',
|
|
82
|
+
description: 'Status shown on the dashboard order item when order has been withdrawn',
|
|
83
|
+
defaultMessage: 'Withdrawn',
|
|
84
|
+
},
|
|
74
85
|
});
|
|
75
86
|
|
|
76
87
|
const OrderStateLearnerMessage = (props: OrderStateMessageBaseProps) => {
|
|
@@ -19,10 +19,12 @@ export type MessageKeys =
|
|
|
19
19
|
| 'statusPassed'
|
|
20
20
|
| 'statusPending'
|
|
21
21
|
| 'statusPendingPayment'
|
|
22
|
+
| 'statusPendingWithdrawal'
|
|
22
23
|
| 'statusRefunded'
|
|
23
24
|
| 'statusWaitingCounterSignature'
|
|
24
25
|
| 'statusWaitingPaymentMethod'
|
|
25
|
-
| 'statusWaitingSignature'
|
|
26
|
+
| 'statusWaitingSignature'
|
|
27
|
+
| 'statusWithdrawn';
|
|
26
28
|
|
|
27
29
|
interface OrderStateMessageProps extends OrderStateMessageBaseProps {
|
|
28
30
|
messages: Record<MessageKeys, MessageDescriptor>;
|
|
@@ -45,10 +47,12 @@ const OrderStateMessage = ({ order, messages }: OrderStateMessageProps) => {
|
|
|
45
47
|
[OrderStatus.PASSED]: messages.statusPassed,
|
|
46
48
|
[OrderStatus.PENDING]: messages.statusPending,
|
|
47
49
|
[OrderStatus.PENDING_PAYMENT]: messages.statusPendingPayment,
|
|
50
|
+
[OrderStatus.PENDING_WITHDRAWAL]: messages.statusPendingWithdrawal,
|
|
48
51
|
[OrderStatus.REFUNDED]: messages.statusRefunded,
|
|
49
52
|
[OrderStatus.WAITING_COUNTER_SIGNATURE]: messages.statusWaitingCounterSignature,
|
|
50
53
|
[OrderStatus.WAITING_PAYMENT_METHOD]: messages.statusWaitingPaymentMethod,
|
|
51
54
|
[OrderStatus.WAITING_SIGNATURE]: messages.statusWaitingSignature,
|
|
55
|
+
[OrderStatus.WITHDRAWN]: messages.statusWithdrawn,
|
|
52
56
|
};
|
|
53
57
|
const status = OrderHelper.getState(order);
|
|
54
58
|
|
package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateTeacherMessage/index.spec.tsx
CHANGED
|
@@ -19,6 +19,7 @@ describe('<OrderStateTeacherMessage/>', () => {
|
|
|
19
19
|
[OrderState.NO_PAYMENT, 'First direct debit has failed'],
|
|
20
20
|
[OrderState.PENDING, 'Pending for the first direct debit'],
|
|
21
21
|
[OrderState.PENDING_PAYMENT, 'On going'],
|
|
22
|
+
[OrderState.PENDING_WITHDRAW, 'Withdrawal in progress'],
|
|
22
23
|
[OrderState.SIGNING, "Pending for learner's signature"],
|
|
23
24
|
[OrderState.TO_SAVE_PAYMENT_METHOD, 'Payment method is missing'],
|
|
24
25
|
[OrderState.TO_SIGN, "Pending for learner's signature"],
|
|
@@ -30,6 +31,17 @@ describe('<OrderStateTeacherMessage/>', () => {
|
|
|
30
31
|
expect(screen.getByText(expectedMessage)).toBeInTheDocument();
|
|
31
32
|
});
|
|
32
33
|
|
|
34
|
+
it('should display message for a withdrawn order', () => {
|
|
35
|
+
const order = CredentialOrderFactory({
|
|
36
|
+
state: OrderState.CANCELED,
|
|
37
|
+
withdrawn_confirmation_at: new Date().toISOString(),
|
|
38
|
+
}).one();
|
|
39
|
+
render(<OrderStateTeacherMessage order={order} />, {
|
|
40
|
+
wrapper: ({ children }: PropsWithChildren) => <IntlWrapper>{children}</IntlWrapper>,
|
|
41
|
+
});
|
|
42
|
+
expect(screen.getByText('Withdrawn')).toBeInTheDocument();
|
|
43
|
+
});
|
|
44
|
+
|
|
33
45
|
it('should display message for validated order that need organization signature', () => {
|
|
34
46
|
const order = CredentialOrderFactory({
|
|
35
47
|
state: OrderState.PENDING_PAYMENT,
|
package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateTeacherMessage/index.tsx
CHANGED
|
@@ -72,6 +72,17 @@ export const messages = defineMessages<MessageKeys>({
|
|
|
72
72
|
description: 'Status shown on the dashboard order item when order is refunded',
|
|
73
73
|
defaultMessage: 'Refunded',
|
|
74
74
|
},
|
|
75
|
+
statusPendingWithdrawal: {
|
|
76
|
+
id: 'components.DashboardItem.Order.OrderStateTeacherMessage.statusPendingWithdrawal',
|
|
77
|
+
description:
|
|
78
|
+
'Status shown on the dashboard order item when a withdrawal request is being processed',
|
|
79
|
+
defaultMessage: 'Withdrawal in progress',
|
|
80
|
+
},
|
|
81
|
+
statusWithdrawn: {
|
|
82
|
+
id: 'components.DashboardItem.Order.OrderStateTeacherMessage.statusWithdrawn',
|
|
83
|
+
description: 'Status shown on the dashboard order item when order has been withdrawn',
|
|
84
|
+
defaultMessage: 'Withdrawn',
|
|
85
|
+
},
|
|
75
86
|
});
|
|
76
87
|
|
|
77
88
|
const OrderStateTeacherMessage = (props: OrderStateMessageBaseProps) => {
|
package/js/widgets/Dashboard/components/DashboardItem/Order/OrderWithdrawalModal/index.spec.tsx
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import fetchMock from 'fetch-mock';
|
|
4
|
+
import { OrderState } from 'types/Joanie';
|
|
5
|
+
import {
|
|
6
|
+
UserFactory,
|
|
7
|
+
RichieContextFactory as mockRichieContextFactory,
|
|
8
|
+
} from 'utils/test/factories/richie';
|
|
9
|
+
import { CredentialOrderFactory, CertificateOrderFactory } from 'utils/test/factories/joanie';
|
|
10
|
+
import { setupJoanieSession } from 'utils/test/wrappers/JoanieAppWrapper';
|
|
11
|
+
import { render } from 'utils/test/render';
|
|
12
|
+
import { createTestQueryClient } from 'utils/test/createTestQueryClient';
|
|
13
|
+
import { HttpStatusCode } from 'utils/errors/HttpError';
|
|
14
|
+
import { OrderWithdrawalModal } from '.';
|
|
15
|
+
|
|
16
|
+
jest.mock('utils/context', () => ({
|
|
17
|
+
__esModule: true,
|
|
18
|
+
default: mockRichieContextFactory({
|
|
19
|
+
authentication: { backend: 'fonzie', endpoint: 'https://auth.endpoint.test' },
|
|
20
|
+
joanie_backend: { endpoint: 'https://joanie.endpoint' },
|
|
21
|
+
}).one(),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
const mockMessageModal = jest.fn();
|
|
25
|
+
jest.mock('@openfun/cunningham-react', () => ({
|
|
26
|
+
...jest.requireActual('@openfun/cunningham-react'),
|
|
27
|
+
useModals: () => ({
|
|
28
|
+
messageModal: mockMessageModal,
|
|
29
|
+
}),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
describe('<OrderWithdrawalModal/>', () => {
|
|
33
|
+
setupJoanieSession();
|
|
34
|
+
const user = UserFactory().one();
|
|
35
|
+
const renderModal = (props: Partial<React.ComponentProps<typeof OrderWithdrawalModal>>) =>
|
|
36
|
+
render(
|
|
37
|
+
<OrderWithdrawalModal
|
|
38
|
+
isOpen
|
|
39
|
+
onClose={jest.fn()}
|
|
40
|
+
order={CredentialOrderFactory().one()}
|
|
41
|
+
productTitle="Some product"
|
|
42
|
+
reference="COURSE-CODE"
|
|
43
|
+
{...props}
|
|
44
|
+
/>,
|
|
45
|
+
{ queryOptions: { client: createTestQueryClient({ user }) } },
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
it('should display order and user information, without the account update link for a non-keycloak backend', () => {
|
|
49
|
+
const order = CredentialOrderFactory().one();
|
|
50
|
+
|
|
51
|
+
renderModal({ order });
|
|
52
|
+
|
|
53
|
+
expect(
|
|
54
|
+
screen.getByText('You wish to cancel your subscription to', { exact: false }),
|
|
55
|
+
).toBeInTheDocument();
|
|
56
|
+
expect(screen.getByText(user.full_name!)).toBeInTheDocument();
|
|
57
|
+
expect(screen.getByText(user.email!)).toBeInTheDocument();
|
|
58
|
+
expect(screen.getByText(order.id)).toBeInTheDocument();
|
|
59
|
+
expect(
|
|
60
|
+
screen.queryByRole('link', { name: 'please update your account' }),
|
|
61
|
+
).not.toBeInTheDocument();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should confirm the withdrawal and display a confirmed message when the order is directly canceled', async () => {
|
|
65
|
+
const order = CredentialOrderFactory().one();
|
|
66
|
+
const updatedOrder = { ...order, state: OrderState.CANCELED };
|
|
67
|
+
fetchMock.post(`https://joanie.endpoint/api/v1.0/orders/${order.id}/withdraw/`, updatedOrder);
|
|
68
|
+
const onClose = jest.fn();
|
|
69
|
+
|
|
70
|
+
renderModal({ order, onClose });
|
|
71
|
+
|
|
72
|
+
const submitButton = screen.getByTestId('order-withdrawal-modal-submit-button');
|
|
73
|
+
await userEvent.click(submitButton);
|
|
74
|
+
|
|
75
|
+
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
|
76
|
+
expect(mockMessageModal).toHaveBeenCalledWith(
|
|
77
|
+
expect.objectContaining({
|
|
78
|
+
title: 'Withdrawal confirmed',
|
|
79
|
+
children: 'Your order has been canceled following your withdrawal request.',
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should confirm the withdrawal and display a pending message when the order needs manual review', async () => {
|
|
85
|
+
const order = CertificateOrderFactory().one();
|
|
86
|
+
const updatedOrder = { ...order, state: OrderState.PENDING_WITHDRAW };
|
|
87
|
+
fetchMock.post(`https://joanie.endpoint/api/v1.0/orders/${order.id}/withdraw/`, updatedOrder);
|
|
88
|
+
const onSuccess = jest.fn();
|
|
89
|
+
|
|
90
|
+
renderModal({ order, onSuccess });
|
|
91
|
+
|
|
92
|
+
const submitButton = screen.getByTestId('order-withdrawal-modal-submit-button');
|
|
93
|
+
await userEvent.click(submitButton);
|
|
94
|
+
|
|
95
|
+
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(updatedOrder));
|
|
96
|
+
expect(mockMessageModal).toHaveBeenCalledWith(
|
|
97
|
+
expect.objectContaining({
|
|
98
|
+
title: 'Withdrawal request recorded',
|
|
99
|
+
children: 'Your withdrawal request has been recorded and is being processed.',
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should display a specific error message when the withdrawal delay has expired', async () => {
|
|
105
|
+
const order = CredentialOrderFactory().one();
|
|
106
|
+
fetchMock.post(
|
|
107
|
+
`https://joanie.endpoint/api/v1.0/orders/${order.id}/withdraw/`,
|
|
108
|
+
HttpStatusCode.UNPROCESSABLE_ENTITY,
|
|
109
|
+
);
|
|
110
|
+
const onClose = jest.fn();
|
|
111
|
+
|
|
112
|
+
renderModal({ order, onClose });
|
|
113
|
+
|
|
114
|
+
const submitButton = screen.getByTestId('order-withdrawal-modal-submit-button');
|
|
115
|
+
await userEvent.click(submitButton);
|
|
116
|
+
|
|
117
|
+
await screen.findByText('The withdrawal period for this order has expired.');
|
|
118
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
119
|
+
expect(mockMessageModal).not.toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should display a generic error message for any other API error', async () => {
|
|
123
|
+
const order = CredentialOrderFactory().one();
|
|
124
|
+
fetchMock.post(
|
|
125
|
+
`https://joanie.endpoint/api/v1.0/orders/${order.id}/withdraw/`,
|
|
126
|
+
HttpStatusCode.INTERNAL_SERVER_ERROR,
|
|
127
|
+
);
|
|
128
|
+
const onClose = jest.fn();
|
|
129
|
+
|
|
130
|
+
renderModal({ order, onClose });
|
|
131
|
+
|
|
132
|
+
const submitButton = screen.getByTestId('order-withdrawal-modal-submit-button');
|
|
133
|
+
await userEvent.click(submitButton);
|
|
134
|
+
|
|
135
|
+
await screen.findByText('An error occurred, please contact our support team.');
|
|
136
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
137
|
+
expect(mockMessageModal).not.toHaveBeenCalled();
|
|
138
|
+
});
|
|
139
|
+
});
|