richie-education 3.4.0 → 3.4.1-dev13
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/.storybook/main.js +11 -12
- package/js/api/joanie.ts +20 -0
- package/js/api/utils.ts +4 -3
- package/js/components/DownloadAgreementButton/index.tsx +51 -0
- package/js/components/DownloadBatchOrderSeatsButton/index.spec.tsx +46 -0
- package/js/components/DownloadBatchOrderSeatsButton/index.tsx +80 -0
- package/js/components/SaleTunnel/SaleTunnelInformation/SaleTunnelInformationGroup.tsx +1 -1
- package/js/components/SaleTunnel/SaleTunnelSuccess/index.tsx +1 -1
- package/js/components/SaleTunnel/SubscriptionButton/index.tsx +2 -1
- package/js/components/SaleTunnel/index.full-process-b2b.spec.tsx +5 -5
- package/js/components/SaleTunnel/index.full-process-b2c.spec.tsx +1 -1
- package/js/hooks/useBatchOrder/index.tsx +21 -1
- package/js/hooks/useDownloadAgreement/index.spec.tsx +136 -0
- package/js/hooks/useDownloadAgreement/index.tsx +25 -0
- package/js/hooks/useDownloadBatchOrderSeats/index.spec.tsx +132 -0
- package/js/hooks/useDownloadBatchOrderSeats/index.tsx +24 -0
- package/js/pages/DashboardBatchOrderLayout/index.spec.tsx +19 -2
- package/js/pages/TeacherDashboardOrganizationQuotes/BatchOrderSeatInfoQuote.tsx +112 -0
- package/js/pages/TeacherDashboardOrganizationQuotes/_styles.scss +17 -0
- package/js/pages/TeacherDashboardOrganizationQuotes/index.full-process.spec.tsx +5 -2
- package/js/pages/TeacherDashboardOrganizationQuotes/index.spec.tsx +7 -3
- package/js/pages/TeacherDashboardOrganizationQuotes/index.tsx +38 -26
- package/js/types/Joanie.ts +21 -1
- package/js/utils/download.ts +3 -1
- package/js/utils/test/factories/joanie.ts +15 -1
- package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/BatchOrderAgreementInfo.tsx +72 -0
- package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/BatchOrderSeatInfo.spec.tsx +114 -0
- package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/BatchOrderSeatInfo.tsx +133 -0
- package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/DashboardBatchOrderSubItems.tsx +17 -1
- package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/batchOrderSeatInfoMessages.ts +24 -0
- package/js/widgets/Dashboard/components/DashboardItem/BatchOrder/index.tsx +16 -3
- package/js/widgets/Dashboard/components/DashboardItem/_styles.scss +6 -2
- package/js/widgets/Dashboard/utils/teacherDashboardPaths.tsx +2 -2
- package/js/widgets/Slider/index.tsx +7 -6
- package/package.json +2 -7
- package/scss/components/templates/richie/slider/_slider.scss +1 -1
- package/scss/objects/_course_glimpses.scss +1 -0
- package/scss/objects/_dashboard.scss +77 -0
package/.storybook/main.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { dirname, join } from "path";
|
|
2
|
+
import { createRequire } from "module";
|
|
3
|
+
import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin';
|
|
2
4
|
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
|
|
7
|
+
function getAbsolutePath(value) {
|
|
8
|
+
return dirname(require.resolve(join(value, "package.json")));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default {
|
|
12
|
+
stories: ['../stories/**/*.stories.@(js|jsx|ts|tsx)', '../js/**/*.stories.@(js|jsx|ts|tsx)'],
|
|
6
13
|
addons: [
|
|
7
14
|
getAbsolutePath('@storybook/addon-links'),
|
|
8
|
-
getAbsolutePath('@storybook/addon-essentials'),
|
|
9
|
-
getAbsolutePath('@storybook/addon-interactions'),
|
|
10
15
|
getAbsolutePath('@storybook/addon-webpack5-compiler-babel'),
|
|
11
16
|
],
|
|
12
17
|
framework: {
|
|
@@ -23,9 +28,7 @@ module.exports = {
|
|
|
23
28
|
from: '../../richie/apps/core/static',
|
|
24
29
|
to: '/static',
|
|
25
30
|
}, '../../richie/apps/core/templates/richie'],
|
|
26
|
-
webpackFinal: async (config, {
|
|
27
|
-
configType,
|
|
28
|
-
}) => {
|
|
31
|
+
webpackFinal: async (config, { configType }) => {
|
|
29
32
|
config.resolve.plugins = [new TsconfigPathsPlugin()];
|
|
30
33
|
return config;
|
|
31
34
|
},
|
|
@@ -33,7 +36,3 @@ module.exports = {
|
|
|
33
36
|
autodocs: false,
|
|
34
37
|
},
|
|
35
38
|
};
|
|
36
|
-
|
|
37
|
-
function getAbsolutePath(value) {
|
|
38
|
-
return dirname(require.resolve(join(value, "package.json")));
|
|
39
|
-
}
|
package/js/api/joanie.ts
CHANGED
|
@@ -107,6 +107,10 @@ export const getRoutes = () => {
|
|
|
107
107
|
submit_for_payment: {
|
|
108
108
|
create: `${baseUrl}/batch-orders/:id/submit-for-payment/`,
|
|
109
109
|
},
|
|
110
|
+
seats: {
|
|
111
|
+
get: `${baseUrl}/batch-orders/:batch_order_id/seats/`,
|
|
112
|
+
},
|
|
113
|
+
seats_export: `${baseUrl}/batch-orders/:id/seats-export/`,
|
|
110
114
|
},
|
|
111
115
|
certificates: {
|
|
112
116
|
download: `${baseUrl}/certificates/:id/download/`,
|
|
@@ -161,6 +165,7 @@ export const getRoutes = () => {
|
|
|
161
165
|
},
|
|
162
166
|
agreements: {
|
|
163
167
|
get: `${baseUrl}/organizations/:organization_id/agreements/:id/`,
|
|
168
|
+
download: `${baseUrl}/organizations/:organization_id/agreements/:id/download/`,
|
|
164
169
|
},
|
|
165
170
|
},
|
|
166
171
|
courses: {
|
|
@@ -354,6 +359,17 @@ const API = (): Joanie.API => {
|
|
|
354
359
|
).then(checkStatus);
|
|
355
360
|
},
|
|
356
361
|
},
|
|
362
|
+
seats: {
|
|
363
|
+
get: async (filters?: Joanie.BatchOrderSeatsQueryFilters) => {
|
|
364
|
+
return fetchWithJWT(buildApiUrl(ROUTES.user.batchOrders.seats.get, filters)).then(
|
|
365
|
+
checkStatus,
|
|
366
|
+
);
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
seats_export: async (id: string): Promise<File> =>
|
|
370
|
+
fetchWithJWT(ROUTES.user.batchOrders.seats_export.replace(':id', id))
|
|
371
|
+
.then(checkStatus)
|
|
372
|
+
.then(getFileFromResponse),
|
|
357
373
|
},
|
|
358
374
|
enrollments: {
|
|
359
375
|
create: async (payload) =>
|
|
@@ -545,6 +561,10 @@ const API = (): Joanie.API => {
|
|
|
545
561
|
method: 'GET',
|
|
546
562
|
}).then(checkStatus);
|
|
547
563
|
},
|
|
564
|
+
download: async (filters: { organization_id: string; id: string }): Promise<File> =>
|
|
565
|
+
fetchWithJWT(buildApiUrl(ROUTES.organizations.agreements.download, filters))
|
|
566
|
+
.then(checkStatus)
|
|
567
|
+
.then(getFileFromResponse),
|
|
548
568
|
},
|
|
549
569
|
},
|
|
550
570
|
courses: {
|
package/js/api/utils.ts
CHANGED
|
@@ -16,11 +16,12 @@ export async function getFileFromResponse(response: Response): Promise<File> {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export function getResponseBody(response: Response) {
|
|
19
|
-
|
|
19
|
+
const contentType = (response.headers.get('Content-Type') || '').split(';')[0].trim();
|
|
20
|
+
if (contentType === 'application/json') {
|
|
20
21
|
return response.json();
|
|
21
22
|
}
|
|
22
|
-
const fileType = ['application/pdf', 'application/zip'];
|
|
23
|
-
if (fileType.includes(
|
|
23
|
+
const fileType = ['application/pdf', 'application/zip', 'text/csv'];
|
|
24
|
+
if (fileType.includes(contentType)) {
|
|
24
25
|
return new Promise((resolve) => resolve(response));
|
|
25
26
|
}
|
|
26
27
|
return response.text();
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useId } from 'react';
|
|
2
|
+
import { Button } from '@openfun/cunningham-react';
|
|
3
|
+
import { FormattedMessage, defineMessages } from 'react-intl';
|
|
4
|
+
import { Spinner } from 'components/Spinner';
|
|
5
|
+
import { useDownloadAgreement } from 'hooks/useDownloadAgreement';
|
|
6
|
+
|
|
7
|
+
const messages = defineMessages({
|
|
8
|
+
download: {
|
|
9
|
+
defaultMessage: 'Download agreement',
|
|
10
|
+
description: 'Label for the button to download a signed agreement PDF',
|
|
11
|
+
id: 'components.DownloadAgreementButton.download',
|
|
12
|
+
},
|
|
13
|
+
generating: {
|
|
14
|
+
defaultMessage: 'Downloading...',
|
|
15
|
+
description: 'Accessible label displayed while agreement PDF is being downloaded.',
|
|
16
|
+
id: 'components.DownloadAgreementButton.generating',
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
interface DownloadAgreementButtonProps {
|
|
21
|
+
organizationId: string;
|
|
22
|
+
agreementId: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DownloadAgreementButton = ({ organizationId, agreementId }: DownloadAgreementButtonProps) => {
|
|
26
|
+
const { download, loading } = useDownloadAgreement();
|
|
27
|
+
const labelId = useId();
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<Button
|
|
31
|
+
size="small"
|
|
32
|
+
color="brand"
|
|
33
|
+
variant="primary"
|
|
34
|
+
className="dashboard-item__action-button"
|
|
35
|
+
disabled={loading}
|
|
36
|
+
onClick={() => download(organizationId, agreementId)}
|
|
37
|
+
>
|
|
38
|
+
{loading ? (
|
|
39
|
+
<Spinner theme="primary" aria-labelledby={labelId}>
|
|
40
|
+
<span id={labelId}>
|
|
41
|
+
<FormattedMessage {...messages.generating} />
|
|
42
|
+
</span>
|
|
43
|
+
</Spinner>
|
|
44
|
+
) : (
|
|
45
|
+
<FormattedMessage {...messages.download} />
|
|
46
|
+
)}
|
|
47
|
+
</Button>
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export default DownloadAgreementButton;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { buildFilename, sanitizeForFilename } from '.';
|
|
2
|
+
|
|
3
|
+
describe('sanitizeForFilename', () => {
|
|
4
|
+
it('replaces spaces with underscores', () => {
|
|
5
|
+
expect(sanitizeForFilename('Formation React')).toBe('Formation_React');
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
it('removes diacritics', () => {
|
|
9
|
+
expect(sanitizeForFilename('Développement web')).toBe('Developpement_web');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('removes special characters', () => {
|
|
13
|
+
expect(sanitizeForFilename('C++ / Python')).toBe('C_Python');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('preserves hyphens', () => {
|
|
17
|
+
expect(sanitizeForFilename('Formation React - Advanced')).toBe('Formation_React_-_Advanced');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('trims leading and trailing spaces', () => {
|
|
21
|
+
expect(sanitizeForFilename(' Formation ')).toBe('Formation');
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('buildFilename', () => {
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
jest.useFakeTimers();
|
|
28
|
+
jest.setSystemTime(new Date('2026-04-15T09:30:00Z'));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
jest.useRealTimers();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('builds the expected filename', () => {
|
|
36
|
+
expect(buildFilename('seats', 'Formation React')).toBe(
|
|
37
|
+
'seats_Formation_React_2026-04-15_09-30.csv',
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('sanitizes the product title in the filename', () => {
|
|
42
|
+
expect(buildFilename('seats', 'Développement web avancé')).toBe(
|
|
43
|
+
'seats_Developpement_web_avance_2026-04-15_09-30.csv',
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { useId } from 'react';
|
|
2
|
+
import { Button } from '@openfun/cunningham-react';
|
|
3
|
+
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
|
4
|
+
import { Spinner } from 'components/Spinner';
|
|
5
|
+
import { useDownloadBatchOrderSeats } from 'hooks/useDownloadBatchOrderSeats';
|
|
6
|
+
|
|
7
|
+
const messages = defineMessages({
|
|
8
|
+
download: {
|
|
9
|
+
defaultMessage: 'Export CSV',
|
|
10
|
+
description: 'Label for the button to export batch order seats as CSV',
|
|
11
|
+
id: 'components.DownloadBatchOrderSeatsButton.download',
|
|
12
|
+
},
|
|
13
|
+
generating: {
|
|
14
|
+
defaultMessage: 'Generating export...',
|
|
15
|
+
description: 'Accessible label displayed while CSV export is being generated.',
|
|
16
|
+
id: 'components.DownloadBatchOrderSeatsButton.generating',
|
|
17
|
+
},
|
|
18
|
+
seats: {
|
|
19
|
+
defaultMessage: 'Seats',
|
|
20
|
+
description: 'Text displayed for seats value in batch order',
|
|
21
|
+
id: 'batchOrder.seats',
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const sanitizeForFilename = (str: string) =>
|
|
26
|
+
str
|
|
27
|
+
.normalize('NFD')
|
|
28
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
29
|
+
.replace(/[^a-zA-Z0-9\s-]/g, '')
|
|
30
|
+
.trim()
|
|
31
|
+
.replace(/\s+/g, '_');
|
|
32
|
+
|
|
33
|
+
export const buildFilename = (prefix: string, productTitle: string) => {
|
|
34
|
+
const now = new Date();
|
|
35
|
+
const date = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}-${String(now.getUTCDate()).padStart(2, '0')}`;
|
|
36
|
+
const time = `${String(now.getUTCHours()).padStart(2, '0')}-${String(now.getUTCMinutes()).padStart(2, '0')}`;
|
|
37
|
+
return `${prefix}_${sanitizeForFilename(productTitle)}_${date}_${time}.csv`;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
interface DownloadBatchOrderSeatsButtonProps {
|
|
41
|
+
batchOrderId: string;
|
|
42
|
+
productTitle: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const DownloadBatchOrderSeatsButton = ({
|
|
46
|
+
batchOrderId,
|
|
47
|
+
productTitle,
|
|
48
|
+
}: DownloadBatchOrderSeatsButtonProps) => {
|
|
49
|
+
const { download, loading } = useDownloadBatchOrderSeats();
|
|
50
|
+
const labelId = useId();
|
|
51
|
+
const intl = useIntl();
|
|
52
|
+
|
|
53
|
+
const handleClick = () => {
|
|
54
|
+
const prefix = intl.formatMessage(messages.seats);
|
|
55
|
+
download(batchOrderId, buildFilename(prefix, productTitle));
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<Button
|
|
60
|
+
size="small"
|
|
61
|
+
color="brand"
|
|
62
|
+
variant="primary"
|
|
63
|
+
className="dashboard-item__action-button"
|
|
64
|
+
disabled={loading}
|
|
65
|
+
onClick={handleClick}
|
|
66
|
+
>
|
|
67
|
+
{loading ? (
|
|
68
|
+
<Spinner theme="primary" aria-labelledby={labelId}>
|
|
69
|
+
<span id={labelId}>
|
|
70
|
+
<FormattedMessage {...messages.generating} />
|
|
71
|
+
</span>
|
|
72
|
+
</Spinner>
|
|
73
|
+
) : (
|
|
74
|
+
<FormattedMessage {...messages.download} />
|
|
75
|
+
)}
|
|
76
|
+
</Button>
|
|
77
|
+
);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export default DownloadBatchOrderSeatsButton;
|
|
@@ -96,7 +96,7 @@ export const SaleTunnelInformationGroup = () => {
|
|
|
96
96
|
);
|
|
97
97
|
};
|
|
98
98
|
|
|
99
|
-
const EMAIL_REGEX = /^[\w
|
|
99
|
+
const EMAIL_REGEX = /^[\w.+\-]+@([\w-]+\.)+[\w-]{2,4}$/;
|
|
100
100
|
|
|
101
101
|
export const validationSchema = Yup.object().shape({
|
|
102
102
|
offering_id: Yup.string().required(),
|
|
@@ -20,7 +20,7 @@ const messages = defineMessages({
|
|
|
20
20
|
},
|
|
21
21
|
successDetailMessage: {
|
|
22
22
|
defaultMessage:
|
|
23
|
-
'You
|
|
23
|
+
'You’ll be able to start your training once the first installment has been paid, or once access opens if no payment is required.',
|
|
24
24
|
description: 'Text to explain when the user will be able to start its training.',
|
|
25
25
|
id: 'components.SaleTunnelSuccess.successDetailMessage',
|
|
26
26
|
},
|
|
@@ -203,6 +203,7 @@ const SubscriptionButton = ({ buildOrderPayload }: Props) => {
|
|
|
203
203
|
};
|
|
204
204
|
|
|
205
205
|
const walkthroughMessages = useMemo(() => {
|
|
206
|
+
if (batchOrder) return;
|
|
206
207
|
if (product.contract_definition && product.price > 0) {
|
|
207
208
|
return messages.walkthroughToSignAndSavePayment;
|
|
208
209
|
} else if (product.contract_definition && product.price === 0) {
|
|
@@ -210,7 +211,7 @@ const SubscriptionButton = ({ buildOrderPayload }: Props) => {
|
|
|
210
211
|
} else if (!product.contract_definition && product.price > 0 && needsPayment) {
|
|
211
212
|
return messages.walkthroughToSavePayment;
|
|
212
213
|
}
|
|
213
|
-
}, [product, creditCard, needsPayment]);
|
|
214
|
+
}, [product, creditCard, needsPayment, batchOrder]);
|
|
214
215
|
|
|
215
216
|
useEffect(() => {
|
|
216
217
|
if (order) nextStep();
|
|
@@ -210,11 +210,11 @@ describe('SaleTunnel', () => {
|
|
|
210
210
|
await user.type($lastName, 'Doe');
|
|
211
211
|
await user.type($firstName, 'John');
|
|
212
212
|
await user.type($role, 'HR');
|
|
213
|
-
await user.type($email, 'john.doe@fun-mooc.com');
|
|
213
|
+
await user.type($email, 'john.doe+admin@fun-mooc.com');
|
|
214
214
|
await user.type($phone, '+338203920103');
|
|
215
215
|
|
|
216
216
|
expect($lastName).toHaveValue('Doe');
|
|
217
|
-
expect($email).toHaveValue('john.doe@fun-mooc.com');
|
|
217
|
+
expect($email).toHaveValue('john.doe+admin@fun-mooc.com');
|
|
218
218
|
|
|
219
219
|
// Signatory step
|
|
220
220
|
await user.click(screen.getByRole('button', { name: 'Next' }));
|
|
@@ -227,7 +227,7 @@ describe('SaleTunnel', () => {
|
|
|
227
227
|
await user.type($signatoryLastName, 'Doe');
|
|
228
228
|
await user.type($signatoryFirstName, 'John');
|
|
229
229
|
await user.type($signatoryRole, 'CEO');
|
|
230
|
-
await user.type($signatoryEmail, 'john.doe@fun-mooc.com');
|
|
230
|
+
await user.type($signatoryEmail, 'john.doe+ceo@fun-mooc.com');
|
|
231
231
|
await user.type($signatoryPhone, '+338203920103');
|
|
232
232
|
|
|
233
233
|
// Participants step
|
|
@@ -298,12 +298,12 @@ describe('SaleTunnel', () => {
|
|
|
298
298
|
administrative_lastname: 'Doe',
|
|
299
299
|
administrative_firstname: 'John',
|
|
300
300
|
administrative_profession: 'HR',
|
|
301
|
-
administrative_email: 'john.doe@fun-mooc.com',
|
|
301
|
+
administrative_email: 'john.doe+admin@fun-mooc.com',
|
|
302
302
|
administrative_telephone: '+338203920103',
|
|
303
303
|
signatory_lastname: 'Doe',
|
|
304
304
|
signatory_firstname: 'John',
|
|
305
305
|
signatory_profession: 'CEO',
|
|
306
|
-
signatory_email: 'john.doe@fun-mooc.com',
|
|
306
|
+
signatory_email: 'john.doe+ceo@fun-mooc.com',
|
|
307
307
|
signatory_telephone: '+338203920103',
|
|
308
308
|
nb_seats: '13',
|
|
309
309
|
payment_method: PaymentMethod.PURCHASE_ORDER,
|
|
@@ -404,7 +404,7 @@ describe('SaleTunnel', () => {
|
|
|
404
404
|
await screen.findByTestId('generic-sale-tunnel-success-step');
|
|
405
405
|
screen.getByText('Subscription confirmed!');
|
|
406
406
|
screen.getByText(
|
|
407
|
-
/
|
|
407
|
+
/you’ll be able to start your training once the first installment has been paid, or once access opens if no payment is required\./i,
|
|
408
408
|
);
|
|
409
409
|
screen.getByRole('link', { name: 'Close' });
|
|
410
410
|
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { defineMessages } from 'react-intl';
|
|
2
2
|
import { useJoanieApi } from 'contexts/JoanieApiContext';
|
|
3
3
|
import { ResourcesQuery, useResource, useResources, UseResourcesProps } from 'hooks/useResources';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
API,
|
|
6
|
+
BatchOrderQueryFilters,
|
|
7
|
+
BatchOrderRead,
|
|
8
|
+
BatchOrderSeat,
|
|
9
|
+
BatchOrderSeatsQueryFilters,
|
|
10
|
+
} from 'types/Joanie';
|
|
5
11
|
|
|
6
12
|
const messages = defineMessages({
|
|
7
13
|
errorCreate: {
|
|
@@ -34,3 +40,17 @@ export const useBatchOrdersActions = () => {
|
|
|
34
40
|
submitForPayment,
|
|
35
41
|
};
|
|
36
42
|
};
|
|
43
|
+
|
|
44
|
+
const seatsProps: UseResourcesProps<
|
|
45
|
+
BatchOrderSeat,
|
|
46
|
+
BatchOrderSeatsQueryFilters,
|
|
47
|
+
API['user']['batchOrders']['seats']
|
|
48
|
+
> = {
|
|
49
|
+
queryKey: ['batchOrders', 'seats'],
|
|
50
|
+
apiInterface: () => useJoanieApi().user.batchOrders.seats,
|
|
51
|
+
session: true,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const useBatchOrderSeats = useResources<BatchOrderSeat, BatchOrderSeatsQueryFilters>(
|
|
55
|
+
seatsProps,
|
|
56
|
+
);
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { faker } from '@faker-js/faker';
|
|
2
|
+
import fetchMock from 'fetch-mock';
|
|
3
|
+
import { PropsWithChildren } from 'react';
|
|
4
|
+
import { QueryClientProvider } from '@tanstack/react-query';
|
|
5
|
+
import { IntlProvider } from 'react-intl';
|
|
6
|
+
import { act, fireEvent, renderHook, waitFor } from '@testing-library/react';
|
|
7
|
+
import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
|
|
8
|
+
import { createTestQueryClient } from 'utils/test/createTestQueryClient';
|
|
9
|
+
import { useDownloadAgreement } from 'hooks/useDownloadAgreement/index';
|
|
10
|
+
import { handle } from 'utils/errors/handle';
|
|
11
|
+
import { SessionProvider } from 'contexts/SessionContext';
|
|
12
|
+
import { Deferred } from 'utils/test/deferred';
|
|
13
|
+
import { OrganizationFactory } from 'utils/test/factories/joanie';
|
|
14
|
+
import { HttpStatusCode } from 'utils/errors/HttpError';
|
|
15
|
+
|
|
16
|
+
jest.mock('utils/errors/handle');
|
|
17
|
+
jest.mock('utils/context', () => ({
|
|
18
|
+
__esModule: true,
|
|
19
|
+
default: mockRichieContextFactory({
|
|
20
|
+
authentication: { backend: 'fonzie', endpoint: 'https://auth.test' },
|
|
21
|
+
joanie_backend: { endpoint: 'https://joanie.test' },
|
|
22
|
+
}).one(),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
const mockHandle = handle as jest.MockedFn<typeof handle>;
|
|
26
|
+
|
|
27
|
+
describe('useDownloadAgreement', () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
fetchMock.get('https://joanie.test/api/v1.0/orders/', []);
|
|
30
|
+
fetchMock.get('https://joanie.test/api/v1.0/addresses/', []);
|
|
31
|
+
fetchMock.get('https://joanie.test/api/v1.0/credit-cards/', []);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
beforeAll(() => {
|
|
35
|
+
// eslint-disable-next-line compat/compat
|
|
36
|
+
URL.createObjectURL = jest.fn();
|
|
37
|
+
// eslint-disable-next-line compat/compat
|
|
38
|
+
URL.revokeObjectURL = jest.fn();
|
|
39
|
+
HTMLAnchorElement.prototype.click = jest.fn();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
jest.clearAllMocks();
|
|
44
|
+
fetchMock.restore();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const Wrapper = ({ children }: PropsWithChildren) => {
|
|
48
|
+
return (
|
|
49
|
+
<QueryClientProvider client={createTestQueryClient({ user: true })}>
|
|
50
|
+
<IntlProvider locale="en">
|
|
51
|
+
<SessionProvider>{children}</SessionProvider>
|
|
52
|
+
</IntlProvider>
|
|
53
|
+
</QueryClientProvider>
|
|
54
|
+
);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
it('downloads the agreement PDF', async () => {
|
|
58
|
+
const organization = OrganizationFactory().one();
|
|
59
|
+
const agreementId = faker.string.uuid();
|
|
60
|
+
const DOWNLOAD_URL = `https://joanie.test/api/v1.0/organizations/${organization.id}/agreements/${agreementId}/download/`;
|
|
61
|
+
const deferred = new Deferred();
|
|
62
|
+
fetchMock.get(DOWNLOAD_URL, deferred.promise);
|
|
63
|
+
|
|
64
|
+
const { result } = renderHook(() => useDownloadAgreement(), {
|
|
65
|
+
wrapper: Wrapper,
|
|
66
|
+
});
|
|
67
|
+
await waitFor(() => expect(result.current).not.toBeNull());
|
|
68
|
+
|
|
69
|
+
expect(fetchMock.called(DOWNLOAD_URL)).toBe(false);
|
|
70
|
+
// eslint-disable-next-line compat/compat
|
|
71
|
+
expect(URL.createObjectURL).toHaveBeenCalledTimes(0);
|
|
72
|
+
// eslint-disable-next-line compat/compat
|
|
73
|
+
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(0);
|
|
74
|
+
expect(result.current.loading).toBe(false);
|
|
75
|
+
|
|
76
|
+
act(() => {
|
|
77
|
+
result.current.download(organization.id, agreementId);
|
|
78
|
+
});
|
|
79
|
+
expect(result.current.loading).toBe(true);
|
|
80
|
+
|
|
81
|
+
deferred.resolve({
|
|
82
|
+
status: HttpStatusCode.OK,
|
|
83
|
+
body: new Blob(['%PDF-1.4']),
|
|
84
|
+
headers: {
|
|
85
|
+
'Content-Disposition': 'attachment; filename="Convention_de_formation.pdf";',
|
|
86
|
+
'Content-Type': 'application/pdf',
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await waitFor(() => {
|
|
91
|
+
expect(fetchMock.called(DOWNLOAD_URL)).toBe(true);
|
|
92
|
+
// eslint-disable-next-line compat/compat
|
|
93
|
+
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
|
|
94
|
+
// eslint-disable-next-line compat/compat
|
|
95
|
+
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(0);
|
|
96
|
+
expect(result.current.loading).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
fireEvent.blur(window);
|
|
100
|
+
// eslint-disable-next-line compat/compat
|
|
101
|
+
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('handles an error if agreement download request fails', async () => {
|
|
105
|
+
const organization = OrganizationFactory().one();
|
|
106
|
+
const agreementId = faker.string.uuid();
|
|
107
|
+
const DOWNLOAD_URL = `https://joanie.test/api/v1.0/organizations/${organization.id}/agreements/${agreementId}/download/`;
|
|
108
|
+
fetchMock.get(DOWNLOAD_URL, HttpStatusCode.UNAUTHORIZED);
|
|
109
|
+
|
|
110
|
+
const { result } = renderHook(() => useDownloadAgreement(), {
|
|
111
|
+
wrapper: Wrapper,
|
|
112
|
+
});
|
|
113
|
+
await waitFor(() => expect(result.current).not.toBeNull());
|
|
114
|
+
|
|
115
|
+
expect(fetchMock.called(DOWNLOAD_URL)).toBe(false);
|
|
116
|
+
expect(mockHandle).not.toHaveBeenCalled();
|
|
117
|
+
// eslint-disable-next-line compat/compat
|
|
118
|
+
expect(URL.createObjectURL).toHaveBeenCalledTimes(0);
|
|
119
|
+
// eslint-disable-next-line compat/compat
|
|
120
|
+
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(0);
|
|
121
|
+
|
|
122
|
+
act(() => {
|
|
123
|
+
result.current.download(organization.id, agreementId);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await waitFor(() => {
|
|
127
|
+
expect(fetchMock.called(DOWNLOAD_URL)).toBe(true);
|
|
128
|
+
expect(mockHandle).toHaveBeenNthCalledWith(1, new Error('Unauthorized'));
|
|
129
|
+
// eslint-disable-next-line compat/compat
|
|
130
|
+
expect(URL.createObjectURL).toHaveBeenCalledTimes(0);
|
|
131
|
+
// eslint-disable-next-line compat/compat
|
|
132
|
+
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(0);
|
|
133
|
+
expect(result.current.loading).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useJoanieApi } from 'contexts/JoanieApiContext';
|
|
3
|
+
import { browserDownloadFromBlob } from 'utils/download';
|
|
4
|
+
|
|
5
|
+
export const useDownloadAgreement = () => {
|
|
6
|
+
const [loading, setLoading] = useState(false);
|
|
7
|
+
const API = useJoanieApi();
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
download: async (organizationId: string, agreementId: string) => {
|
|
11
|
+
setLoading(true);
|
|
12
|
+
try {
|
|
13
|
+
await browserDownloadFromBlob(() =>
|
|
14
|
+
API.organizations.agreements.download({
|
|
15
|
+
organization_id: organizationId,
|
|
16
|
+
id: agreementId,
|
|
17
|
+
}),
|
|
18
|
+
);
|
|
19
|
+
} finally {
|
|
20
|
+
setLoading(false);
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
loading,
|
|
24
|
+
};
|
|
25
|
+
};
|