richie-education 3.5.1-dev2 → 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/i18n/locales/ar-SA.json +4 -0
- package/i18n/locales/es-ES.json +4 -0
- package/i18n/locales/fa-IR.json +4 -0
- package/i18n/locales/fr-CA.json +4 -0
- package/i18n/locales/fr-FR.json +5 -1
- package/i18n/locales/ko-KR.json +4 -0
- package/i18n/locales/pt-PT.json +4 -0
- package/i18n/locales/ru-RU.json +4 -0
- package/i18n/locales/vi-VN.json +4 -0
- 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/translations/ar-SA.json +1 -1
- package/js/translations/es-ES.json +1 -1
- package/js/translations/fa-IR.json +1 -1
- package/js/translations/fr-CA.json +1 -1
- package/js/translations/fr-FR.json +1 -1
- package/js/translations/ko-KR.json +1 -1
- package/js/translations/pt-PT.json +1 -1
- package/js/translations/ru-RU.json +1 -1
- package/js/translations/vi-VN.json +1 -1
- 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/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
|
+
});
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Alert,
|
|
3
|
+
Button,
|
|
4
|
+
Modal,
|
|
5
|
+
ModalProps,
|
|
6
|
+
ModalSize,
|
|
7
|
+
useModals,
|
|
8
|
+
VariantType,
|
|
9
|
+
} from '@openfun/cunningham-react';
|
|
10
|
+
import { defineMessages, FormattedMessage, MessageDescriptor, useIntl } from 'react-intl';
|
|
11
|
+
import { ReactNode, useState } from 'react';
|
|
12
|
+
import { Order, OrderEnrollment, OrderState } from 'types/Joanie';
|
|
13
|
+
import { useJoanieApi } from 'contexts/JoanieApiContext';
|
|
14
|
+
import { useOrders } from 'hooks/useOrders';
|
|
15
|
+
import { useSession } from 'contexts/SessionContext';
|
|
16
|
+
import { UserHelper } from 'utils/UserHelper';
|
|
17
|
+
import { AuthenticationApi } from 'api/authentication';
|
|
18
|
+
import { APIBackend, KeycloakAccountApi } from 'types/api';
|
|
19
|
+
import context from 'utils/context';
|
|
20
|
+
import { Spinner } from 'components/Spinner';
|
|
21
|
+
import { HttpStatusCode, isHttpError } from 'utils/errors/HttpError';
|
|
22
|
+
|
|
23
|
+
const boldChunk = (chunks: ReactNode) => <strong>{chunks}</strong>;
|
|
24
|
+
|
|
25
|
+
const InfoRow = ({ label, value }: { label: MessageDescriptor; value: ReactNode }) => (
|
|
26
|
+
<p className="mb-s">
|
|
27
|
+
<span className="fw-bold">
|
|
28
|
+
<FormattedMessage {...label} />
|
|
29
|
+
</span>{' '}
|
|
30
|
+
{value}
|
|
31
|
+
</p>
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const messages = defineMessages({
|
|
35
|
+
title: {
|
|
36
|
+
id: 'components.DashboardItemOrder.OrderWithdrawalModal.title',
|
|
37
|
+
defaultMessage: 'Withdrawal request',
|
|
38
|
+
description: 'Title of the withdrawal request modal',
|
|
39
|
+
},
|
|
40
|
+
description: {
|
|
41
|
+
id: 'components.DashboardItemOrder.OrderWithdrawalModal.description',
|
|
42
|
+
defaultMessage:
|
|
43
|
+
'You wish to cancel your subscription to <bold>{productTitle} - Ref. {reference}</bold>.',
|
|
44
|
+
description: 'Message displayed in the withdrawal request modal',
|
|
45
|
+
},
|
|
46
|
+
informationTitle: {
|
|
47
|
+
id: 'components.DashboardItemOrder.OrderWithdrawalModal.informationTitle',
|
|
48
|
+
defaultMessage: 'Information',
|
|
49
|
+
description: 'Title of the information recap section in the withdrawal request modal',
|
|
50
|
+
},
|
|
51
|
+
informationDescription: {
|
|
52
|
+
id: 'components.DashboardItemOrder.OrderWithdrawalModal.informationDescription',
|
|
53
|
+
defaultMessage:
|
|
54
|
+
'This information will be included in the acknowledgement of receipt for your request.',
|
|
55
|
+
description: 'Description of the information recap section in the withdrawal request modal',
|
|
56
|
+
},
|
|
57
|
+
accountNameLabel: {
|
|
58
|
+
id: 'components.DashboardItemOrder.OrderWithdrawalModal.accountNameLabel',
|
|
59
|
+
defaultMessage: 'Account name',
|
|
60
|
+
description: 'Label for the account name in the withdrawal request modal',
|
|
61
|
+
},
|
|
62
|
+
accountEmailLabel: {
|
|
63
|
+
id: 'components.DashboardItemOrder.OrderWithdrawalModal.accountEmailLabel',
|
|
64
|
+
defaultMessage: 'Account email',
|
|
65
|
+
description: 'Label for the account email in the withdrawal request modal',
|
|
66
|
+
},
|
|
67
|
+
orderReferenceLabel: {
|
|
68
|
+
id: 'components.DashboardItemOrder.OrderWithdrawalModal.orderReferenceLabel',
|
|
69
|
+
defaultMessage: 'Order reference',
|
|
70
|
+
description: 'Label for the order reference in the withdrawal request modal',
|
|
71
|
+
},
|
|
72
|
+
accountLinkInfo: {
|
|
73
|
+
id: 'components.DashboardItemOrder.OrderWithdrawalModal.accountLinkInfo',
|
|
74
|
+
defaultMessage: 'If any of the information above is incorrect,',
|
|
75
|
+
description: 'Text before the account update link in the withdrawal request modal',
|
|
76
|
+
},
|
|
77
|
+
accountLinkLabel: {
|
|
78
|
+
id: 'components.DashboardItemOrder.OrderWithdrawalModal.accountLinkLabel',
|
|
79
|
+
defaultMessage: 'please update your account',
|
|
80
|
+
description: 'Label of the account update link in the withdrawal request modal',
|
|
81
|
+
},
|
|
82
|
+
submit: {
|
|
83
|
+
id: 'components.DashboardItemOrder.OrderWithdrawalModal.submit',
|
|
84
|
+
defaultMessage: 'I confirm my withdrawal request',
|
|
85
|
+
description: 'Submit button label of the withdrawal request modal',
|
|
86
|
+
},
|
|
87
|
+
submitInProgress: {
|
|
88
|
+
id: 'components.DashboardItemOrder.OrderWithdrawalModal.submitInProgress',
|
|
89
|
+
defaultMessage: 'Withdrawal request in progress',
|
|
90
|
+
description: 'Label for screen reader when a withdrawal request is in progress.',
|
|
91
|
+
},
|
|
92
|
+
successConfirmedTitle: {
|
|
93
|
+
id: 'components.DashboardItemOrder.OrderWithdrawalModal.successConfirmedTitle',
|
|
94
|
+
defaultMessage: 'Withdrawal confirmed',
|
|
95
|
+
description: 'Title of the success modal when the withdrawal is confirmed immediately',
|
|
96
|
+
},
|
|
97
|
+
successConfirmedDescription: {
|
|
98
|
+
id: 'components.DashboardItemOrder.OrderWithdrawalModal.successConfirmedDescription',
|
|
99
|
+
defaultMessage: 'Your order has been canceled following your withdrawal request.',
|
|
100
|
+
description: 'Description of the success modal when the withdrawal is confirmed immediately',
|
|
101
|
+
},
|
|
102
|
+
successPendingTitle: {
|
|
103
|
+
id: 'components.DashboardItemOrder.OrderWithdrawalModal.successPendingTitle',
|
|
104
|
+
defaultMessage: 'Withdrawal request recorded',
|
|
105
|
+
description: 'Title of the success modal when the withdrawal request needs manual review',
|
|
106
|
+
},
|
|
107
|
+
successPendingDescription: {
|
|
108
|
+
id: 'components.DashboardItemOrder.OrderWithdrawalModal.successPendingDescription',
|
|
109
|
+
defaultMessage: 'Your withdrawal request has been recorded and is being processed.',
|
|
110
|
+
description: 'Description of the success modal when the withdrawal request needs manual review',
|
|
111
|
+
},
|
|
112
|
+
errorDelayExpired: {
|
|
113
|
+
id: 'components.DashboardItemOrder.OrderWithdrawalModal.errorDelayExpired',
|
|
114
|
+
defaultMessage: 'The withdrawal period for this order has expired.',
|
|
115
|
+
description: 'Error message when the withdrawal delay has expired',
|
|
116
|
+
},
|
|
117
|
+
errorDefault: {
|
|
118
|
+
id: 'components.DashboardItemOrder.OrderWithdrawalModal.errorDefault',
|
|
119
|
+
defaultMessage: 'An error occurred, please contact our support team.',
|
|
120
|
+
description: 'Generic error message when the withdrawal request failed',
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
interface Props extends Pick<ModalProps, 'isOpen' | 'onClose'> {
|
|
125
|
+
order: Order | OrderEnrollment;
|
|
126
|
+
productTitle: string;
|
|
127
|
+
reference: string;
|
|
128
|
+
/**
|
|
129
|
+
* Called with the fresh order returned by the API once the withdrawal request succeeds.
|
|
130
|
+
* Callers relying on locally held order state (rather than a query that gets invalidated)
|
|
131
|
+
* must use this to update it, otherwise the UI keeps showing stale data until a full
|
|
132
|
+
* page refresh.
|
|
133
|
+
*/
|
|
134
|
+
onSuccess?: (order: Order) => void;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
enum ComponentStates {
|
|
138
|
+
IDLE = 'idle',
|
|
139
|
+
LOADING = 'loading',
|
|
140
|
+
ERROR = 'error',
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export const OrderWithdrawalModal = ({
|
|
144
|
+
order,
|
|
145
|
+
productTitle,
|
|
146
|
+
reference,
|
|
147
|
+
onSuccess,
|
|
148
|
+
...props
|
|
149
|
+
}: Props) => {
|
|
150
|
+
const intl = useIntl();
|
|
151
|
+
const API = useJoanieApi();
|
|
152
|
+
const { user } = useSession();
|
|
153
|
+
const { methods: orderMethods } = useOrders(undefined, { enabled: false });
|
|
154
|
+
const modals = useModals();
|
|
155
|
+
const [state, setState] = useState<ComponentStates>(ComponentStates.IDLE);
|
|
156
|
+
const [error, setError] = useState<string>();
|
|
157
|
+
|
|
158
|
+
const isKeycloakBackend = [APIBackend.KEYCLOAK, APIBackend.FONZIE_KEYCLOAK].includes(
|
|
159
|
+
context?.authentication.backend as APIBackend,
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const submit = async () => {
|
|
163
|
+
setState(ComponentStates.LOADING);
|
|
164
|
+
try {
|
|
165
|
+
const updatedOrder = await API.user.orders.withdraw(order.id);
|
|
166
|
+
await orderMethods.invalidate();
|
|
167
|
+
onSuccess?.(updatedOrder);
|
|
168
|
+
props.onClose();
|
|
169
|
+
await modals.messageModal({
|
|
170
|
+
messageType: VariantType.SUCCESS,
|
|
171
|
+
title: intl.formatMessage(
|
|
172
|
+
updatedOrder.state === OrderState.PENDING_WITHDRAW
|
|
173
|
+
? messages.successPendingTitle
|
|
174
|
+
: messages.successConfirmedTitle,
|
|
175
|
+
),
|
|
176
|
+
children: intl.formatMessage(
|
|
177
|
+
updatedOrder.state === OrderState.PENDING_WITHDRAW
|
|
178
|
+
? messages.successPendingDescription
|
|
179
|
+
: messages.successConfirmedDescription,
|
|
180
|
+
),
|
|
181
|
+
});
|
|
182
|
+
} catch (submitError) {
|
|
183
|
+
setState(ComponentStates.ERROR);
|
|
184
|
+
setError(
|
|
185
|
+
intl.formatMessage(
|
|
186
|
+
isHttpError(submitError) && submitError.code === HttpStatusCode.UNPROCESSABLE_ENTITY
|
|
187
|
+
? messages.errorDelayExpired
|
|
188
|
+
: messages.errorDefault,
|
|
189
|
+
),
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<Modal
|
|
196
|
+
{...props}
|
|
197
|
+
size={ModalSize.MEDIUM}
|
|
198
|
+
title={intl.formatMessage(messages.title)}
|
|
199
|
+
closeOnEsc={state !== ComponentStates.LOADING}
|
|
200
|
+
preventClose={state === ComponentStates.LOADING}
|
|
201
|
+
hideCloseButton={state === ComponentStates.LOADING}
|
|
202
|
+
actions={
|
|
203
|
+
<Button
|
|
204
|
+
color="error"
|
|
205
|
+
variant="primary"
|
|
206
|
+
size="small"
|
|
207
|
+
fullWidth={true}
|
|
208
|
+
onClick={submit}
|
|
209
|
+
disabled={state === ComponentStates.LOADING}
|
|
210
|
+
data-testid="order-withdrawal-modal-submit-button"
|
|
211
|
+
>
|
|
212
|
+
{state === ComponentStates.LOADING ? (
|
|
213
|
+
<Spinner theme="light" aria-labelledby="withdrawal-request-in-progress">
|
|
214
|
+
<span id="withdrawal-request-in-progress">
|
|
215
|
+
<FormattedMessage {...messages.submitInProgress} />
|
|
216
|
+
</span>
|
|
217
|
+
</Spinner>
|
|
218
|
+
) : (
|
|
219
|
+
<FormattedMessage {...messages.submit} />
|
|
220
|
+
)}
|
|
221
|
+
</Button>
|
|
222
|
+
}
|
|
223
|
+
>
|
|
224
|
+
{error && (
|
|
225
|
+
<Alert type={VariantType.ERROR} className="mb-t">
|
|
226
|
+
{error}
|
|
227
|
+
</Alert>
|
|
228
|
+
)}
|
|
229
|
+
<p className="mb-b">
|
|
230
|
+
<FormattedMessage
|
|
231
|
+
{...messages.description}
|
|
232
|
+
values={{
|
|
233
|
+
productTitle,
|
|
234
|
+
reference,
|
|
235
|
+
bold: boldChunk,
|
|
236
|
+
}}
|
|
237
|
+
/>
|
|
238
|
+
</p>
|
|
239
|
+
<h3 className="block-title mb-t">
|
|
240
|
+
<FormattedMessage {...messages.informationTitle} />
|
|
241
|
+
</h3>
|
|
242
|
+
<p className="mb-s">
|
|
243
|
+
<FormattedMessage {...messages.informationDescription} />
|
|
244
|
+
</p>
|
|
245
|
+
<InfoRow label={messages.accountNameLabel} value={user ? UserHelper.getName(user) : ''} />
|
|
246
|
+
<InfoRow label={messages.accountEmailLabel} value={user?.email} />
|
|
247
|
+
<InfoRow label={messages.orderReferenceLabel} value={order.id} />
|
|
248
|
+
{isKeycloakBackend && (
|
|
249
|
+
<p className="mb-b">
|
|
250
|
+
<FormattedMessage {...messages.accountLinkInfo} />{' '}
|
|
251
|
+
<a href={(AuthenticationApi!.account as KeycloakAccountApi).updateUrl()}>
|
|
252
|
+
<FormattedMessage {...messages.accountLinkLabel} />
|
|
253
|
+
</a>
|
|
254
|
+
.
|
|
255
|
+
</p>
|
|
256
|
+
)}
|
|
257
|
+
</Modal>
|
|
258
|
+
);
|
|
259
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { screen, within } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
|
|
4
|
+
import { CredentialOrderFactory, CredentialProductFactory } from 'utils/test/factories/joanie';
|
|
5
|
+
import { setupJoanieSession } from 'utils/test/wrappers/JoanieAppWrapper';
|
|
6
|
+
import { render } from 'utils/test/render';
|
|
7
|
+
import OrganizationBlock from '.';
|
|
8
|
+
|
|
9
|
+
jest.mock('utils/context', () => ({
|
|
10
|
+
__esModule: true,
|
|
11
|
+
default: mockRichieContextFactory({
|
|
12
|
+
authentication: { backend: 'fonzie', endpoint: 'https://auth.endpoint.test' },
|
|
13
|
+
joanie_backend: { endpoint: 'https://joanie.endpoint' },
|
|
14
|
+
}).one(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
jest.mock('../OrderWithdrawalModal', () => ({
|
|
18
|
+
__esModule: true,
|
|
19
|
+
OrderWithdrawalModal: ({
|
|
20
|
+
isOpen,
|
|
21
|
+
order,
|
|
22
|
+
productTitle,
|
|
23
|
+
reference,
|
|
24
|
+
}: {
|
|
25
|
+
isOpen: boolean;
|
|
26
|
+
order: { id: string };
|
|
27
|
+
productTitle: string;
|
|
28
|
+
reference: string;
|
|
29
|
+
}) => {
|
|
30
|
+
if (!isOpen) return null;
|
|
31
|
+
return (
|
|
32
|
+
<div data-testid="OrderWithdrawalModalMock">
|
|
33
|
+
{order.id} - {productTitle} - {reference}
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
},
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
describe('<OrganizationBlock/>', () => {
|
|
40
|
+
setupJoanieSession();
|
|
41
|
+
const product = CredentialProductFactory({ contract_definition: undefined }).one();
|
|
42
|
+
|
|
43
|
+
it('should display the withdrawal block with the withdraw button when the order is eligible', () => {
|
|
44
|
+
const order = CredentialOrderFactory({
|
|
45
|
+
total: 0,
|
|
46
|
+
eligible_to_withdraw: true,
|
|
47
|
+
withdrawal_date_limit: '2026-08-30T10:00:00.000Z',
|
|
48
|
+
withdrawn_confirmation_at: null,
|
|
49
|
+
}).one();
|
|
50
|
+
|
|
51
|
+
render(<OrganizationBlock order={order} product={product} />);
|
|
52
|
+
|
|
53
|
+
const block = screen.getByTestId('dashboard-item-withdrawal');
|
|
54
|
+
within(block).getByRole('button', { name: 'Withdraw' });
|
|
55
|
+
within(block).getByText('Aug 30, 2026', { exact: false });
|
|
56
|
+
expect(screen.queryByTestId('dashboard-item-withdrawn')).not.toBeInTheDocument();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should not display any withdrawal information when the order is not eligible and has not been withdrawn', () => {
|
|
60
|
+
const order = CredentialOrderFactory({
|
|
61
|
+
total: 0,
|
|
62
|
+
eligible_to_withdraw: false,
|
|
63
|
+
withdrawn_confirmation_at: null,
|
|
64
|
+
}).one();
|
|
65
|
+
|
|
66
|
+
render(<OrganizationBlock order={order} product={product} />);
|
|
67
|
+
|
|
68
|
+
expect(screen.queryByTestId('dashboard-item-withdrawal')).not.toBeInTheDocument();
|
|
69
|
+
expect(screen.queryByTestId('dashboard-item-withdrawn')).not.toBeInTheDocument();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should display the withdrawn status instead of the withdrawal block once the order has been withdrawn', () => {
|
|
73
|
+
const order = CredentialOrderFactory({
|
|
74
|
+
total: 0,
|
|
75
|
+
eligible_to_withdraw: false,
|
|
76
|
+
withdrawn_confirmation_at: '2026-08-10T10:00:00.000Z',
|
|
77
|
+
}).one();
|
|
78
|
+
|
|
79
|
+
render(<OrganizationBlock order={order} product={product} />);
|
|
80
|
+
|
|
81
|
+
const block = screen.getByTestId('dashboard-item-withdrawn');
|
|
82
|
+
within(block).getByText('Aug 10, 2026', { exact: false });
|
|
83
|
+
expect(screen.queryByTestId('dashboard-item-withdrawal')).not.toBeInTheDocument();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should open the withdrawal modal with the order reference and product title when clicking the withdraw button', async () => {
|
|
87
|
+
const order = CredentialOrderFactory({
|
|
88
|
+
total: 0,
|
|
89
|
+
eligible_to_withdraw: true,
|
|
90
|
+
withdrawn_confirmation_at: null,
|
|
91
|
+
}).one();
|
|
92
|
+
|
|
93
|
+
render(<OrganizationBlock order={order} product={product} />);
|
|
94
|
+
|
|
95
|
+
expect(screen.queryByTestId('OrderWithdrawalModalMock')).not.toBeInTheDocument();
|
|
96
|
+
await userEvent.click(screen.getByRole('button', { name: 'Withdraw' }));
|
|
97
|
+
|
|
98
|
+
within(screen.getByTestId('OrderWithdrawalModalMock')).getByText(
|
|
99
|
+
`${order.id} - ${product.title} - ${order.course.code}`,
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { defineMessages, FormattedMessage } from 'react-intl';
|
|
2
|
-
import { Button } from '@openfun/cunningham-react';
|
|
1
|
+
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|
2
|
+
import { Button, useModal } from '@openfun/cunningham-react';
|
|
3
3
|
import { CredentialOrder, Product } from 'types/Joanie';
|
|
4
4
|
import { AddressView } from 'components/Address';
|
|
5
5
|
import { OrderHelper } from 'utils/OrderHelper';
|
|
6
|
+
import useDateFormat, { DATETIME_FORMAT } from 'hooks/useDateFormat';
|
|
6
7
|
import ContractItem from '../ContractItem';
|
|
7
8
|
import Installment from '../Installment';
|
|
9
|
+
import { OrderWithdrawalModal } from '../OrderWithdrawalModal';
|
|
8
10
|
|
|
9
11
|
const messages = defineMessages({
|
|
10
12
|
contactDescription: {
|
|
@@ -52,6 +54,31 @@ const messages = defineMessages({
|
|
|
52
54
|
description: 'Subtitle for the organization contacts section',
|
|
53
55
|
defaultMessage: 'Contacts',
|
|
54
56
|
},
|
|
57
|
+
withdrawalTitle: {
|
|
58
|
+
id: 'components.DashboardItemOrder.OrganizationBlock.withdrawalTitle',
|
|
59
|
+
description: 'Title of the withdrawal block',
|
|
60
|
+
defaultMessage: 'Withdrawal',
|
|
61
|
+
},
|
|
62
|
+
withdrawalDescription: {
|
|
63
|
+
id: 'components.DashboardItemOrder.OrganizationBlock.withdrawalDescription',
|
|
64
|
+
description: 'Description of the withdrawal block',
|
|
65
|
+
defaultMessage: 'You can withdraw from your contract until {date}.',
|
|
66
|
+
},
|
|
67
|
+
withdrawalButton: {
|
|
68
|
+
id: 'components.DashboardItemOrder.OrganizationBlock.withdrawalButton',
|
|
69
|
+
description: 'Button label of the withdrawal block',
|
|
70
|
+
defaultMessage: 'Withdraw',
|
|
71
|
+
},
|
|
72
|
+
withdrawnTitle: {
|
|
73
|
+
id: 'components.DashboardItemOrder.OrganizationBlock.withdrawnTitle',
|
|
74
|
+
description: 'Title of the withdrawal block once the withdrawal is confirmed',
|
|
75
|
+
defaultMessage: 'Withdrawn',
|
|
76
|
+
},
|
|
77
|
+
withdrawnDescription: {
|
|
78
|
+
id: 'components.DashboardItemOrder.OrganizationBlock.withdrawnDescription',
|
|
79
|
+
description: 'Description shown once the withdrawal is confirmed',
|
|
80
|
+
defaultMessage: 'You withdrew from this order on {date}.',
|
|
81
|
+
},
|
|
55
82
|
});
|
|
56
83
|
|
|
57
84
|
type Props = {
|
|
@@ -148,7 +175,62 @@ const OrganizationBlock = ({ order, product }: Props) => {
|
|
|
148
175
|
</div>
|
|
149
176
|
)}
|
|
150
177
|
{!hidePaymentBlock && <Installment order={order} />}
|
|
178
|
+
{order.eligible_to_withdraw && <Withdrawal order={order} product={product} />}
|
|
179
|
+
{OrderHelper.isWithdrawn(order) && <WithdrawnStatus order={order} />}
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const Withdrawal = ({ order, product }: Props) => {
|
|
186
|
+
const intl = useIntl();
|
|
187
|
+
const formatDate = useDateFormat(DATETIME_FORMAT);
|
|
188
|
+
const modal = useModal();
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div data-testid="dashboard-item-withdrawal" className="dashboard-splitted-card__item">
|
|
192
|
+
<div className="dashboard-splitted-card__item__title">
|
|
193
|
+
<span>
|
|
194
|
+
<FormattedMessage {...messages.withdrawalTitle} />
|
|
195
|
+
</span>
|
|
196
|
+
</div>
|
|
197
|
+
<p className="dashboard-splitted-card__item__description">
|
|
198
|
+
<FormattedMessage
|
|
199
|
+
{...messages.withdrawalDescription}
|
|
200
|
+
values={{ date: formatDate(order.withdrawal_date_limit) }}
|
|
201
|
+
/>
|
|
202
|
+
</p>
|
|
203
|
+
<div className="dashboard-splitted-card__item__actions">
|
|
204
|
+
<Button size="small" color="brand" variant="secondary" onClick={modal.open}>
|
|
205
|
+
{intl.formatMessage(messages.withdrawalButton)}
|
|
206
|
+
</Button>
|
|
207
|
+
</div>
|
|
208
|
+
<OrderWithdrawalModal
|
|
209
|
+
{...modal}
|
|
210
|
+
order={order}
|
|
211
|
+
productTitle={product.title}
|
|
212
|
+
reference={order.course.code}
|
|
213
|
+
/>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const WithdrawnStatus = ({ order }: { order: CredentialOrder }) => {
|
|
219
|
+
const formatDate = useDateFormat(DATETIME_FORMAT);
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<div data-testid="dashboard-item-withdrawn" className="dashboard-splitted-card__item">
|
|
223
|
+
<div className="dashboard-splitted-card__item__title">
|
|
224
|
+
<span>
|
|
225
|
+
<FormattedMessage {...messages.withdrawnTitle} />
|
|
226
|
+
</span>
|
|
151
227
|
</div>
|
|
228
|
+
<p className="dashboard-splitted-card__item__description">
|
|
229
|
+
<FormattedMessage
|
|
230
|
+
{...messages.withdrawnDescription}
|
|
231
|
+
values={{ date: formatDate(order.withdrawn_confirmation_at) }}
|
|
232
|
+
/>
|
|
233
|
+
</p>
|
|
152
234
|
</div>
|
|
153
235
|
);
|
|
154
236
|
};
|
|
@@ -56,7 +56,9 @@
|
|
|
56
56
|
|
|
57
57
|
&__status {
|
|
58
58
|
display: flex;
|
|
59
|
-
|
|
59
|
+
// flex-start (not center) so the icon stays aligned with the first line
|
|
60
|
+
// of text when the text wraps onto several lines.
|
|
61
|
+
align-items: flex-start;
|
|
60
62
|
gap: 0.5rem;
|
|
61
63
|
|
|
62
64
|
@include media-breakpoint-down(sm) {
|
|
@@ -179,6 +181,7 @@
|
|
|
179
181
|
justify-content: space-between;
|
|
180
182
|
padding: 0.5rem 1rem;
|
|
181
183
|
min-height: 3.75rem;
|
|
184
|
+
border-top: $onepixel solid r-theme-val(dashboard-item, base-border);
|
|
182
185
|
|
|
183
186
|
&:first-child {
|
|
184
187
|
border-top: 0;
|
|
@@ -201,6 +204,11 @@
|
|
|
201
204
|
p {
|
|
202
205
|
margin: 0;
|
|
203
206
|
}
|
|
207
|
+
|
|
208
|
+
&__withdrawal__button {
|
|
209
|
+
text-decoration: underline;
|
|
210
|
+
white-space: nowrap;
|
|
211
|
+
}
|
|
204
212
|
}
|
|
205
213
|
|
|
206
214
|
&__runs {
|