richie-education 2.25.0-b2.dev66 → 2.25.0-b2.dev69

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 CHANGED
@@ -23,13 +23,21 @@ interface CheckStatusOptions {
23
23
  ignoredErrorStatus: (number | HttpStatusCode)[];
24
24
  }
25
25
 
26
+ export async function getFileFromResponse(response: Response): Promise<File> {
27
+ const filenameRegex = /filename="(.*)";/;
28
+ const dispositionHeader = response.headers.get('Content-Disposition');
29
+ const matches = dispositionHeader?.match(filenameRegex);
30
+
31
+ return new File([await response.blob()], matches ? matches[1] : '');
32
+ }
33
+
26
34
  export function getResponseBody(response: Response) {
27
35
  if (response.headers.get('Content-Type') === 'application/json') {
28
36
  return response.json();
29
37
  }
30
38
  const fileType = ['application/pdf', 'application/zip'];
31
39
  if (fileType.includes(response.headers.get('Content-Type') || '')) {
32
- return response.blob();
40
+ return new Promise((resolve) => resolve(response));
33
41
  }
34
42
  return response.text();
35
43
  }
@@ -321,7 +329,7 @@ const API = (): Joanie.API => {
321
329
  let url = ROUTES.user.orders.invoice.download.replace(':id', order_id);
322
330
  url += `?${queryString.stringify({ reference: invoice_reference })}`;
323
331
 
324
- return fetchWithJWT(url).then(checkStatus);
332
+ return fetchWithJWT(url).then(checkStatus).then(getFileFromResponse);
325
333
  },
326
334
  },
327
335
  submit_for_signature: async (id) =>
@@ -346,7 +354,9 @@ const API = (): Joanie.API => {
346
354
  },
347
355
  certificates: {
348
356
  download: async (id: string): Promise<File> =>
349
- fetchWithJWT(ROUTES.user.certificates.download.replace(':id', id)).then(checkStatus),
357
+ fetchWithJWT(ROUTES.user.certificates.download.replace(':id', id))
358
+ .then(checkStatus)
359
+ .then(getFileFromResponse),
350
360
  get: async (filters) => {
351
361
  return fetchWithJWT(buildApiUrl(ROUTES.user.certificates.get, filters)).then(checkStatus);
352
362
  },
@@ -382,7 +392,9 @@ const API = (): Joanie.API => {
382
392
  download(id: string): Promise<any> {
383
393
  return fetchWithJWT(ROUTES.user.contracts.download.replace(':id', id), {
384
394
  method: 'GET',
385
- }).then(checkStatus);
395
+ })
396
+ .then(checkStatus)
397
+ .then(getFileFromResponse);
386
398
  },
387
399
  zip_archive: {
388
400
  check: async (id) => {
@@ -399,7 +411,9 @@ const API = (): Joanie.API => {
399
411
  get: async (id) => {
400
412
  return fetchWithJWT(ROUTES.user.contracts.zip_archive.get.replace(':id', id), {
401
413
  method: 'GET',
402
- }).then(checkStatus);
414
+ })
415
+ .then(checkStatus)
416
+ .then(getFileFromResponse);
403
417
  },
404
418
  },
405
419
  },
@@ -10,6 +10,7 @@ import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/fac
10
10
  import { createTestQueryClient } from 'utils/test/createTestQueryClient';
11
11
  import JoanieApiProvider from 'contexts/JoanieApiContext';
12
12
  import { alert } from 'utils/indirection/window';
13
+ import { HttpStatusCode } from 'utils/errors/HttpError';
13
14
  import DownloadContractButton from '.';
14
15
 
15
16
  jest.mock('utils/context', () => ({
@@ -65,7 +66,16 @@ describe('<DownloadContractButton/>', () => {
65
66
  const DOWNLOAD_URL = `https://joanie.endpoint/api/v1.0/contracts/${
66
67
  order.contract!.id
67
68
  }/download/`;
68
- fetchMock.get(DOWNLOAD_URL, 'contract content');
69
+
70
+ fetchMock.get(DOWNLOAD_URL, () => ({
71
+ status: HttpStatusCode.OK,
72
+ body: new Blob(['contract content']),
73
+ headers: {
74
+ 'Content-Disposition': 'attachment; filename="test.pdf";',
75
+ 'Content-Type': 'application/pdf',
76
+ },
77
+ }));
78
+ const expectedFile = new File(['contract content'], 'test.pdf');
69
79
 
70
80
  render(
71
81
  <Wrapper>
@@ -88,9 +98,9 @@ describe('<DownloadContractButton/>', () => {
88
98
  // eslint-disable-next-line compat/compat
89
99
  expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
90
100
  // eslint-disable-next-line compat/compat
91
- expect(URL.createObjectURL).toHaveBeenCalledWith('contract content');
101
+ expect(URL.createObjectURL).toHaveBeenCalledWith(expectedFile);
92
102
  expect(window.open).toHaveBeenCalledTimes(1);
93
- expect(window.open).toHaveBeenCalledWith('contract content');
103
+ expect(window.open).toHaveBeenCalledWith(expectedFile);
94
104
  });
95
105
 
96
106
  it('fails downloading the contract and shows an error', async () => {
@@ -109,7 +119,10 @@ describe('<DownloadContractButton/>', () => {
109
119
  const DOWNLOAD_URL = `https://joanie.endpoint/api/v1.0/contracts/${
110
120
  order.contract!.id
111
121
  }/download/`;
112
- fetchMock.get(DOWNLOAD_URL, 500);
122
+ fetchMock.get(DOWNLOAD_URL, {
123
+ status: HttpStatusCode.INTERNAL_SERVER_ERROR,
124
+ body: 'Bad request',
125
+ });
113
126
 
114
127
  render(
115
128
  <Wrapper>
@@ -1,9 +1,9 @@
1
1
  import { Button } from '@openfun/cunningham-react';
2
2
  import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
3
3
  import { useJoanieApi } from 'contexts/JoanieApiContext';
4
- import { handle } from 'utils/errors/handle';
5
4
  import { Contract } from 'types/Joanie';
6
5
  import { alert } from 'utils/indirection/window';
6
+ import { browserDownloadFromBlob } from 'utils/download';
7
7
 
8
8
  const messages = defineMessages({
9
9
  contractDownloadActionLabel: {
@@ -29,16 +29,11 @@ const DownloadContractButton = ({ contract, className }: DownloadContractButtonP
29
29
  const intl = useIntl();
30
30
 
31
31
  const downloadContract = async () => {
32
- try {
33
- const blob = await api.user.contracts.download(contract!.id);
34
- // eslint-disable-next-line compat/compat
35
- const file = window.URL.createObjectURL(blob);
36
- window.open(file);
37
-
38
- // eslint-disable-next-line compat/compat
39
- URL.revokeObjectURL(file);
40
- } catch (e) {
41
- handle(e);
32
+ const success = await browserDownloadFromBlob(
33
+ () => api.user.contracts.download(contract!.id),
34
+ true,
35
+ );
36
+ if (!success) {
42
37
  alert(intl.formatMessage(messages.contractDownloadError));
43
38
  }
44
39
  };
@@ -71,7 +71,14 @@ describe('useContractArchive', () => {
71
71
  expect(URL.revokeObjectURL).toHaveBeenCalledTimes(0);
72
72
 
73
73
  result.current.methods.get(archiveId);
74
- deferred.resolve(HttpStatusCode.OK);
74
+ deferred.resolve({
75
+ status: HttpStatusCode.OK,
76
+ body: new Blob(['test']),
77
+ headers: {
78
+ 'Content-Disposition': 'attachment; filename="test.pdf";',
79
+ 'Content-Type': 'application/pdf',
80
+ },
81
+ });
75
82
 
76
83
  await waitFor(() => {
77
84
  expect(fetchMock.called(DOWNLOAD_URL)).toBe(true);
@@ -1,4 +1,5 @@
1
1
  import { useJoanieApi } from 'contexts/JoanieApiContext';
2
+ import { browserDownloadFromBlob } from 'utils/download';
2
3
  import { HttpStatusCode } from 'utils/errors/HttpError';
3
4
  import { handle } from 'utils/errors/handle';
4
5
 
@@ -17,25 +18,6 @@ const extractArchiveId = (url: string): string => {
17
18
  return match[0];
18
19
  };
19
20
 
20
- // TODO: should be factorized with useDownloadCertificate
21
- // and maybe DownloadContractButton
22
- const buildArchiveFromBlob = (fileName: string, blob: Blob) => {
23
- // eslint-disable-next-line compat/compat
24
- const url = URL.createObjectURL(blob);
25
- const $link = document.createElement('a');
26
- $link.href = url;
27
- $link.download = fileName;
28
-
29
- const revokeObject = () => {
30
- // eslint-disable-next-line compat/compat
31
- URL.revokeObjectURL(url);
32
- window.removeEventListener('blur', revokeObject);
33
- };
34
-
35
- window.addEventListener('blur', revokeObject);
36
- $link.click();
37
- };
38
-
39
21
  const useContractArchive = () => {
40
22
  const api = useJoanieApi();
41
23
  return {
@@ -57,17 +39,16 @@ const useContractArchive = () => {
57
39
  return false;
58
40
  },
59
41
  get: async (archiveId: string): Promise<true | void> => {
60
- try {
61
- const response = api.user.contracts.zip_archive.get(archiveId);
62
- buildArchiveFromBlob('contracts.zip', await response);
63
- return true;
64
- } catch (error) {
65
- handle(error);
66
- }
67
-
68
42
  // FIXME: two thing could happen here with HttpStatusCode.NOT_FOUND response:
69
43
  // * nothing found because the zip is generating.
70
44
  // * nothing found because the zip doesn't and will never exist.
45
+ const success = await browserDownloadFromBlob(() =>
46
+ api.user.contracts.zip_archive.get(archiveId),
47
+ );
48
+
49
+ if (success) {
50
+ return true;
51
+ }
71
52
  },
72
53
  create: async (organizationId: string): Promise<string> => {
73
54
  const response = await api.user.contracts.zip_archive.create({
@@ -2,7 +2,7 @@ import fetchMock from 'fetch-mock';
2
2
  import { PropsWithChildren } from 'react';
3
3
  import { QueryClientProvider } from '@tanstack/react-query';
4
4
  import { IntlProvider } from 'react-intl';
5
- import { fireEvent, renderHook, waitFor } from '@testing-library/react';
5
+ import { act, fireEvent, renderHook, waitFor } from '@testing-library/react';
6
6
  import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
7
7
  import { createTestQueryClient } from 'utils/test/createTestQueryClient';
8
8
  import { useDownloadCertificate } from 'hooks/useDownloadCertificate/index';
@@ -72,9 +72,19 @@ describe('useDownloadCertificate', () => {
72
72
  expect(URL.revokeObjectURL).toHaveBeenCalledTimes(0);
73
73
  expect(result.current.loading).toBe(false);
74
74
 
75
- result.current.download(certificate.id);
76
- await waitFor(() => expect(result.current.loading).toBe(true));
77
- deferred.resolve(HttpStatusCode.OK);
75
+ act(() => {
76
+ result.current.download(certificate.id);
77
+ });
78
+ expect(result.current.loading).toBe(true);
79
+
80
+ deferred.resolve({
81
+ status: HttpStatusCode.OK,
82
+ body: new Blob(['test']),
83
+ headers: {
84
+ 'Content-Disposition': 'attachment; filename="test.pdf";',
85
+ 'Content-Type': 'application/pdf',
86
+ },
87
+ });
78
88
 
79
89
  await waitFor(() => {
80
90
  expect(fetchMock.called(DOWNLOAD_URL)).toBe(true);
@@ -109,7 +119,9 @@ describe('useDownloadCertificate', () => {
109
119
  // eslint-disable-next-line compat/compat
110
120
  expect(URL.revokeObjectURL).toHaveBeenCalledTimes(0);
111
121
 
112
- await result.current.download(certificate.id);
122
+ act(() => {
123
+ result.current.download(certificate.id);
124
+ });
113
125
 
114
126
  await waitFor(() => {
115
127
  expect(fetchMock.called(DOWNLOAD_URL)).toBe(true);
@@ -1,6 +1,6 @@
1
1
  import { useState } from 'react';
2
- import { handle } from 'utils/errors/handle';
3
2
  import { useJoanieApi } from 'contexts/JoanieApiContext';
3
+ import { browserDownloadFromBlob } from 'utils/download';
4
4
 
5
5
  export const useDownloadCertificate = () => {
6
6
  const [loading, setLoading] = useState(false);
@@ -9,25 +9,7 @@ export const useDownloadCertificate = () => {
9
9
  return {
10
10
  download: async (certificateId: string) => {
11
11
  setLoading(true);
12
- try {
13
- const $link = document.createElement('a');
14
- const file = await API.user.certificates.download(certificateId);
15
- // eslint-disable-next-line compat/compat
16
- const url = URL.createObjectURL(file);
17
- $link.href = url;
18
- $link.download = '';
19
-
20
- const revokeObject = () => {
21
- // eslint-disable-next-line compat/compat
22
- URL.revokeObjectURL(url);
23
- window.removeEventListener('blur', revokeObject);
24
- };
25
-
26
- window.addEventListener('blur', revokeObject);
27
- $link.click();
28
- } catch (error) {
29
- handle(error);
30
- }
12
+ await browserDownloadFromBlob(() => API.user.certificates.download(certificateId));
31
13
  setLoading(false);
32
14
  },
33
15
  loading,
@@ -0,0 +1,43 @@
1
+ import { handle } from './errors/handle';
2
+
3
+ /**
4
+ * browserDownloadFromBlob handle direct download of a file api response.
5
+ *
6
+ * @param downloadFunction, an api promise that return a File
7
+ * @param newWindow, does it open in a new window or not
8
+ * @returns boolean, true for success
9
+ */
10
+ export const browserDownloadFromBlob = async (
11
+ downloadFunction: () => Promise<File>,
12
+ newWindow: boolean = false,
13
+ ) => {
14
+ try {
15
+ const file = await downloadFunction();
16
+
17
+ // eslint-disable-next-line compat/compat
18
+ const url = URL.createObjectURL(file);
19
+
20
+ if (newWindow) {
21
+ window.open(url);
22
+ return;
23
+ }
24
+
25
+ const $link = document.createElement('a');
26
+ $link.href = url;
27
+ $link.download = file.name;
28
+
29
+ const revokeObject = () => {
30
+ // eslint-disable-next-line compat/compat
31
+ URL.revokeObjectURL(url);
32
+ window.removeEventListener('blur', revokeObject);
33
+ };
34
+
35
+ window.addEventListener('blur', revokeObject);
36
+ $link.click();
37
+ return true;
38
+ } catch (error) {
39
+ handle(error);
40
+ }
41
+
42
+ return false;
43
+ };
@@ -48,9 +48,13 @@ describe.each([
48
48
  beforeAll(() => {
49
49
  // eslint-disable-next-line compat/compat
50
50
  URL.createObjectURL = jest.fn();
51
+ // eslint-disable-next-line compat/compat
52
+ URL.revokeObjectURL = jest.fn();
53
+ HTMLAnchorElement.prototype.click = jest.fn();
51
54
  });
52
55
 
53
56
  beforeEach(() => {
57
+ // SessionProvider queries
54
58
  fetchMock.get('https://joanie.test/api/v1.0/orders/', []);
55
59
  fetchMock.get('https://joanie.test/api/v1.0/addresses/', []);
56
60
  fetchMock.get('https://joanie.test/api/v1.0/credit-cards/', []);
@@ -88,10 +92,14 @@ describe.each([
88
92
  order: OrderFactory().one(),
89
93
  }).one();
90
94
 
91
- fetchMock.get(
92
- `https://joanie.test/api/v1.0/certificates/${certificate.id}/download/`,
93
- HttpStatusCode.OK,
94
- );
95
+ fetchMock.get(`https://joanie.test/api/v1.0/certificates/${certificate.id}/download/`, () => ({
96
+ status: HttpStatusCode.OK,
97
+ body: new Blob(['test']),
98
+ headers: {
99
+ 'Content-Disposition': 'attachment; filename="test.pdf";',
100
+ 'Content-Type': 'application/pdf',
101
+ },
102
+ }));
95
103
 
96
104
  render(
97
105
  <DashboardItemCertificate certificate={certificate} productType={ProductType.CREDENTIAL} />,
@@ -91,7 +91,14 @@ describe('CourseProductCertificateItem', () => {
91
91
  // When user clicks on "Download" button, the certificate should be downloaded
92
92
  fetchMock.get(
93
93
  `https://joanie.test/api/v1.0/certificates/${order.certificate_id}/download/`,
94
- HttpStatusCode.OK,
94
+ () => ({
95
+ status: HttpStatusCode.OK,
96
+ body: new Blob(['test']),
97
+ headers: {
98
+ 'Content-Disposition': 'attachment; filename="test.pdf";',
99
+ 'Content-Type': 'application/pdf',
100
+ },
101
+ }),
95
102
  );
96
103
 
97
104
  fireEvent.click($button);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "richie-education",
3
- "version": "2.25.0-b2.dev66",
3
+ "version": "2.25.0-b2.dev69",
4
4
  "description": "A CMS to build learning portals for Open Education",
5
5
  "main": "sandbox/manage.py",
6
6
  "scripts": {
@@ -44,43 +44,43 @@
44
44
  "@babel/preset-env": "7.23.9",
45
45
  "@babel/preset-react": "7.23.3",
46
46
  "@babel/preset-typescript": "7.23.3",
47
- "@faker-js/faker": "8.4.0",
47
+ "@faker-js/faker": "8.4.1",
48
48
  "@formatjs/cli": "6.2.7",
49
49
  "@formatjs/intl-relativetimeformat": "11.2.12",
50
50
  "@hookform/resolvers": "3.3.4",
51
51
  "@openfun/cunningham-react": "2.4.0",
52
52
  "@openfun/cunningham-tokens": "2.1.0",
53
- "@sentry/browser": "7.98.0",
54
- "@sentry/types": "7.98.0",
55
- "@storybook/addon-actions": "7.6.10",
56
- "@storybook/addon-essentials": "7.6.10",
57
- "@storybook/addon-interactions": "7.6.10",
58
- "@storybook/addon-links": "7.6.10",
59
- "@storybook/react": "7.6.10",
60
- "@storybook/react-webpack5": "7.6.10",
53
+ "@sentry/browser": "7.100.1",
54
+ "@sentry/types": "7.100.1",
55
+ "@storybook/addon-actions": "7.6.13",
56
+ "@storybook/addon-essentials": "7.6.13",
57
+ "@storybook/addon-interactions": "7.6.13",
58
+ "@storybook/addon-links": "7.6.13",
59
+ "@storybook/react": "7.6.13",
60
+ "@storybook/react-webpack5": "7.6.13",
61
61
  "@storybook/testing-library": "0.2.2",
62
- "@tanstack/query-core": "5.17.19",
63
- "@tanstack/query-sync-storage-persister": "5.17.19",
64
- "@tanstack/react-query": "5.17.19",
65
- "@tanstack/react-query-persist-client": "5.17.19",
62
+ "@tanstack/query-core": "5.18.1",
63
+ "@tanstack/query-sync-storage-persister": "5.18.1",
64
+ "@tanstack/react-query": "5.18.1",
65
+ "@tanstack/react-query-persist-client": "5.18.1",
66
66
  "@testing-library/dom": "9.3.4",
67
- "@testing-library/jest-dom": "6.4.0",
68
- "@testing-library/react": "14.1.2",
67
+ "@testing-library/jest-dom": "6.4.2",
68
+ "@testing-library/react": "14.2.1",
69
69
  "@testing-library/user-event": "14.5.2",
70
70
  "@types/fetch-mock": "7.3.8",
71
71
  "@types/iframe-resizer": "3.5.13",
72
- "@types/jest": "29.5.11",
72
+ "@types/jest": "29.5.12",
73
73
  "@types/js-cookie": "3.0.6",
74
74
  "@types/lodash-es": "4.17.12",
75
75
  "@types/node-fetch": "2.6.11",
76
76
  "@types/query-string": "6.3.0",
77
- "@types/react": "18.2.48",
77
+ "@types/react": "18.2.55",
78
78
  "@types/react-autosuggest": "10.1.11",
79
- "@types/react-dom": "18.2.18",
79
+ "@types/react-dom": "18.2.19",
80
80
  "@types/react-modal": "3.16.3",
81
81
  "@types/uuid": "9.0.8",
82
- "@typescript-eslint/eslint-plugin": "6.20.0",
83
- "@typescript-eslint/parser": "6.20.0",
82
+ "@typescript-eslint/eslint-plugin": "6.21.0",
83
+ "@typescript-eslint/parser": "6.21.0",
84
84
  "babel-jest": "29.7.0",
85
85
  "babel-loader": "9.1.3",
86
86
  "babel-plugin-react-intl": "8.2.25",
@@ -105,7 +105,7 @@
105
105
  "fetch-mock": "9.11.0",
106
106
  "file-loader": "6.2.0",
107
107
  "glob": "10.3.10",
108
- "i18n-iso-countries": "7.8.0",
108
+ "i18n-iso-countries": "7.10.0",
109
109
  "iframe-resizer": "4.3.9",
110
110
  "intl-pluralrules": "2.0.1",
111
111
  "jest": "29.7.0",
@@ -113,25 +113,25 @@
113
113
  "js-cookie": "3.0.5",
114
114
  "lodash-es": "4.17.21",
115
115
  "mdn-polyfills": "5.20.0",
116
- "msw": "2.1.5",
116
+ "msw": "2.1.7",
117
117
  "node-fetch": ">2.6.6 <3",
118
118
  "nodemon": "3.0.3",
119
- "prettier": "3.2.4",
120
- "query-string": "8.1.0",
119
+ "prettier": "3.2.5",
120
+ "query-string": "8.2.0",
121
121
  "react": "18.2.0",
122
122
  "react-autosuggest": "10.1.0",
123
123
  "react-dom": "18.2.0",
124
- "react-hook-form": "7.49.3",
124
+ "react-hook-form": "7.50.1",
125
125
  "react-intl": "6.6.2",
126
126
  "react-modal": "3.16.1",
127
- "react-router-dom": "6.21.3",
127
+ "react-router-dom": "6.22.0",
128
128
  "sass": "1.70.0",
129
129
  "source-map-loader": "5.0.0",
130
- "storybook": "7.6.10",
130
+ "storybook": "7.6.13",
131
131
  "tsconfig-paths-webpack-plugin": "4.1.0",
132
132
  "typescript": "5.3.3",
133
133
  "uuid": "9.0.1",
134
- "webpack": "5.90.0",
134
+ "webpack": "5.90.1",
135
135
  "webpack-cli": "5.1.4",
136
136
  "whatwg-fetch": "3.6.20",
137
137
  "xhr-mock": "2.5.1",
@@ -140,8 +140,8 @@
140
140
  },
141
141
  "resolutions": {
142
142
  "@testing-library/dom": "9.3.4",
143
- "@types/react": "18.2.48",
144
- "@types/react-dom": "18.2.18"
143
+ "@types/react": "18.2.55",
144
+ "@types/react-dom": "18.2.19"
145
145
  },
146
146
  "msw": {
147
147
  "workerDirectory": "../richie/static/richie/js"
package/tsconfig.json CHANGED
@@ -14,18 +14,18 @@
14
14
  "es2017",
15
15
  "es2020.intl",
16
16
  "es2021.string",
17
- "esnext.intl",
17
+ "esnext.intl"
18
18
  ],
19
19
  "module": "esnext",
20
20
  "moduleResolution": "node",
21
21
  "paths": {
22
- "intl-pluralrules": ["types/libs/intl-pluralrules"],
22
+ "intl-pluralrules": ["types/libs/intl-pluralrules"]
23
23
  },
24
24
  "resolveJsonModule": true,
25
25
  "skipLibCheck": true,
26
26
  "strict": true,
27
27
  "sourceMap": true,
28
- "target": "es6",
28
+ "target": "es6"
29
29
  },
30
- "include": ["./**/*"],
30
+ "include": ["./**/*"]
31
31
  }