richie-education 3.4.1-dev14 → 3.4.1-dev17

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
@@ -16,6 +16,7 @@ import { JOANIE_API_VERSION } from 'settings';
16
16
  import { ResourcesQuery } from 'hooks/useResources';
17
17
  import { ObjectHelper } from 'utils/ObjectHelper';
18
18
  import { Maybe, Nullable } from 'types/utils';
19
+ import { HttpError, HttpStatusCode } from 'utils/errors/HttpError';
19
20
  import { checkStatus, getFileFromResponse } from './utils';
20
21
 
21
22
  /*
@@ -366,10 +367,15 @@ const API = (): Joanie.API => {
366
367
  );
367
368
  },
368
369
  },
369
- seats_export: async (id: string): Promise<File> =>
370
- fetchWithJWT(ROUTES.user.batchOrders.seats_export.replace(':id', id))
371
- .then(checkStatus)
372
- .then(getFileFromResponse),
370
+ seats_export: async (id: string): Promise<File> => {
371
+ const response = await fetchWithJWT(
372
+ ROUTES.user.batchOrders.seats_export.replace(':id', id),
373
+ );
374
+ if (response.status === HttpStatusCode.UNPROCESSABLE_ENTITY) {
375
+ throw new HttpError(response.status, response.statusText);
376
+ }
377
+ return checkStatus(response).then(getFileFromResponse);
378
+ },
373
379
  },
374
380
  enrollments: {
375
381
  create: async (payload) =>
@@ -1,7 +1,9 @@
1
1
  import { useId } from 'react';
2
- import { Button } from '@openfun/cunningham-react';
2
+ import { Alert, Button, VariantType } from '@openfun/cunningham-react';
3
3
  import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
4
4
  import { Spinner } from 'components/Spinner';
5
+ import { Icon, IconTypeEnum } from 'components/Icon';
6
+ import { HttpStatusCode } from 'utils/errors/HttpError';
5
7
  import { useDownloadBatchOrderSeats } from 'hooks/useDownloadBatchOrderSeats';
6
8
 
7
9
  const messages = defineMessages({
@@ -20,6 +22,12 @@ const messages = defineMessages({
20
22
  description: 'Text displayed for seats value in batch order',
21
23
  id: 'batchOrder.seats',
22
24
  },
25
+ noSeatsOwned: {
26
+ defaultMessage:
27
+ 'No participants have claimed their seat yet. The export will be available once at least one participant has enrolled.',
28
+ description: 'Error message displayed when trying to export seats but no seats are owned yet.',
29
+ id: 'components.DownloadBatchOrderSeatsButton.noSeatsOwned',
30
+ },
23
31
  });
24
32
 
25
33
  export const sanitizeForFilename = (str: string) =>
@@ -46,7 +54,7 @@ const DownloadBatchOrderSeatsButton = ({
46
54
  batchOrderId,
47
55
  productTitle,
48
56
  }: DownloadBatchOrderSeatsButtonProps) => {
49
- const { download, loading } = useDownloadBatchOrderSeats();
57
+ const { download, loading, error } = useDownloadBatchOrderSeats();
50
58
  const labelId = useId();
51
59
  const intl = useIntl();
52
60
 
@@ -56,24 +64,33 @@ const DownloadBatchOrderSeatsButton = ({
56
64
  };
57
65
 
58
66
  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} />
67
+ <>
68
+ <Button
69
+ size="small"
70
+ color="brand"
71
+ variant="primary"
72
+ className="dashboard-item__action-button"
73
+ icon={<Icon name={IconTypeEnum.DOWNLOAD} size="small" />}
74
+ iconPosition="left"
75
+ disabled={loading}
76
+ onClick={handleClick}
77
+ >
78
+ {loading ? (
79
+ <Spinner theme="primary" aria-labelledby={labelId}>
80
+ <span id={labelId}>
81
+ <FormattedMessage {...messages.generating} />
82
+ </span>
83
+ </Spinner>
84
+ ) : (
85
+ <FormattedMessage {...messages.download} />
86
+ )}
87
+ </Button>
88
+ {error?.code === HttpStatusCode.UNPROCESSABLE_ENTITY && (
89
+ <Alert type={VariantType.ERROR} className="mt-s">
90
+ <FormattedMessage {...messages.noSeatsOwned} />
91
+ </Alert>
75
92
  )}
76
- </Button>
93
+ </>
77
94
  );
78
95
  };
79
96
 
@@ -38,6 +38,7 @@ export enum IconTypeEnum {
38
38
  COURSES = 'icon-courses',
39
39
  CREDIT_CARD = 'icon-creditCard',
40
40
  CROSS = 'icon-cross',
41
+ DOWNLOAD = 'icon-download',
41
42
  DURATION = 'icon-duration',
42
43
  ENVELOPE = 'icon-envelope',
43
44
  FACEBOOK = 'icon-facebook',
@@ -31,10 +31,13 @@
31
31
  &__left,
32
32
  &__right {
33
33
  flex: 1;
34
- overflow: hidden;
35
34
  position: relative;
36
35
  }
37
36
 
37
+ &__left {
38
+ overflow: hidden;
39
+ }
40
+
38
41
  &__column {
39
42
  display: flex;
40
43
  flex-direction: column;
@@ -129,4 +129,32 @@ describe('useDownloadBatchOrderSeats', () => {
129
129
  expect(result.current.loading).toBe(false);
130
130
  });
131
131
  });
132
+
133
+ it('exposes a 422 HttpError when seats export fails because no seats are owned', async () => {
134
+ const batchOrder = BatchOrderReadFactory().one();
135
+ const DOWNLOAD_URL = `https://joanie.test/api/v1.0/batch-orders/${batchOrder.id}/seats-export/`;
136
+ fetchMock.get(DOWNLOAD_URL, {
137
+ status: HttpStatusCode.UNPROCESSABLE_ENTITY,
138
+ body: { detail: 'Batch order has no seats owned, cannot export seats.' },
139
+ });
140
+
141
+ const { result } = renderHook(() => useDownloadBatchOrderSeats(), {
142
+ wrapper: Wrapper,
143
+ });
144
+ await waitFor(() => expect(result.current).not.toBeNull());
145
+
146
+ expect(result.current.error).toBeUndefined();
147
+
148
+ act(() => {
149
+ result.current.download(batchOrder.id);
150
+ });
151
+
152
+ await waitFor(() => {
153
+ expect(fetchMock.called(DOWNLOAD_URL)).toBe(true);
154
+ expect(result.current.error?.code).toBe(HttpStatusCode.UNPROCESSABLE_ENTITY);
155
+ // eslint-disable-next-line compat/compat
156
+ expect(URL.createObjectURL).toHaveBeenCalledTimes(0);
157
+ expect(result.current.loading).toBe(false);
158
+ });
159
+ });
132
160
  });
@@ -1,24 +1,34 @@
1
1
  import { useState } from 'react';
2
2
  import { useJoanieApi } from 'contexts/JoanieApiContext';
3
3
  import { browserDownloadFromBlob } from 'utils/download';
4
+ import { HttpError, HttpStatusCode } from 'utils/errors/HttpError';
4
5
 
5
6
  export const useDownloadBatchOrderSeats = () => {
6
7
  const [loading, setLoading] = useState(false);
8
+ const [error, setError] = useState<HttpError | undefined>();
7
9
  const API = useJoanieApi();
8
10
 
9
11
  return {
10
12
  download: async (batchOrderId: string, filename?: string) => {
11
13
  setLoading(true);
14
+ setError(undefined);
12
15
  try {
13
- await browserDownloadFromBlob(
14
- () => API.user.batchOrders.seats_export(batchOrderId),
15
- false,
16
- filename,
17
- );
16
+ const downloadFn = async () => {
17
+ try {
18
+ return await API.user.batchOrders.seats_export(batchOrderId);
19
+ } catch (err) {
20
+ if (err instanceof HttpError && err.code === HttpStatusCode.UNPROCESSABLE_ENTITY) {
21
+ setError(err);
22
+ }
23
+ throw err;
24
+ }
25
+ };
26
+ await browserDownloadFromBlob(downloadFn, false, filename);
18
27
  } finally {
19
28
  setLoading(false);
20
29
  }
21
30
  },
22
31
  loading,
32
+ error,
23
33
  };
24
34
  };
@@ -31,6 +31,7 @@ export enum HttpStatusCode {
31
31
  FORBIDDEN = 403,
32
32
  NOT_FOUND = 404,
33
33
  CONFLICT = 409,
34
+ UNPROCESSABLE_ENTITY = 422,
34
35
  TOO_MANY_REQUESTS = 429,
35
36
  INTERNAL_SERVER_ERROR = 500,
36
37
  }
@@ -105,27 +105,31 @@ export const BatchOrderSeatInfo = ({ batchOrder }: BatchOrderSeatInfoProps) => {
105
105
  <li key={seat.id}>{seat.owner_name ?? seat.voucher}</li>
106
106
  ))}
107
107
  </ul>
108
- {remainingCount > 0 && (
109
- <Button
110
- className="enrollment-load-more"
111
- color="brand"
112
- variant="secondary"
113
- size="small"
114
- onClick={() => setPage((p) => p + 1)}
115
- disabled={states.fetching}
116
- >
117
- {intl.formatMessage(batchOrderSeatInfoMessages.loadMore, {
118
- count: remainingCount,
119
- })}
120
- </Button>
121
- )}
108
+ <div className="enrollment-actions">
109
+ {remainingCount > 0 && (
110
+ <Button
111
+ className="enrollment-load-more"
112
+ color="brand"
113
+ variant="secondary"
114
+ size="small"
115
+ onClick={() => setPage((p) => p + 1)}
116
+ disabled={states.fetching}
117
+ >
118
+ {intl.formatMessage(batchOrderSeatInfoMessages.loadMore, {
119
+ count: remainingCount,
120
+ })}
121
+ </Button>
122
+ )}
123
+ <div>
124
+ <DownloadBatchOrderSeatsButton
125
+ batchOrderId={batchOrder.id}
126
+ productTitle={batchOrder.offering?.product.title ?? ''}
127
+ />
128
+ </div>
129
+ </div>
122
130
  </>
123
131
  )}
124
132
  </div>
125
- <DownloadBatchOrderSeatsButton
126
- batchOrderId={batchOrder.id}
127
- productTitle={batchOrder.offering?.product.title ?? ''}
128
- />
129
133
  </div>
130
134
  }
131
135
  />
@@ -53,7 +53,19 @@ const SlidePanel = ({
53
53
  })}
54
54
  >
55
55
  <strong className="slide__title">
56
- <span>{slides[activeSlideIndex].title}</span>
56
+ <span>
57
+ {slides[activeSlideIndex].link_url ? (
58
+ <a
59
+ href={slides[activeSlideIndex].link_url}
60
+ target={slides[activeSlideIndex].link_open_blank ? '_blank' : '_self'}
61
+ rel="noopener noreferrer"
62
+ >
63
+ {slides[activeSlideIndex].title}
64
+ </a>
65
+ ) : (
66
+ slides[activeSlideIndex].title
67
+ )}
68
+ </span>
57
69
  </strong>
58
70
  {hasSlideContent && (
59
71
  <div
@@ -71,13 +71,11 @@ describe('<Slider />', () => {
71
71
  // Check if all slides are rendered
72
72
  mockSlides.forEach((slide) => {
73
73
  expect(screen.getByRole('img', { name: slide.title })).toBeInTheDocument();
74
- // Check if the link is rendered
75
- const link = screen.queryByRole('link', { name: slide.title });
76
74
  if (slide.link_url) {
77
- expect(link).toHaveAttribute('href', slide.link_url);
78
- expect(link).toBeInTheDocument();
75
+ const links = screen.getAllByRole('link', { name: slide.title });
76
+ links.forEach((link) => expect(link).toHaveAttribute('href', slide.link_url));
79
77
  } else {
80
- expect(link).not.toBeInTheDocument();
78
+ expect(screen.queryByRole('link', { name: slide.title })).not.toBeInTheDocument();
81
79
  }
82
80
  });
83
81
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "richie-education",
3
- "version": "3.4.1-dev14",
3
+ "version": "3.4.1-dev17",
4
4
  "description": "A CMS to build learning portals for Open Education",
5
5
  "main": "sandbox/manage.py",
6
6
  "scripts": {
@@ -201,6 +201,16 @@ $r-slider-content-line-clamp: 4 !default;
201
201
  font-weight: $r-slider-title-fontweight;
202
202
  font-family: $r-slider-title-fontfamily;
203
203
 
204
+ a {
205
+ color: inherit;
206
+ text-decoration: none;
207
+
208
+ &:hover,
209
+ &:focus {
210
+ opacity: 0.8;
211
+ }
212
+ }
213
+
204
214
  & > span {
205
215
  display: inline-block;
206
216
  transform: translateY(0%);
@@ -152,9 +152,16 @@
152
152
  }
153
153
  }
154
154
 
155
+ .enrollment-actions {
156
+ display: flex;
157
+ align-items: flex-start;
158
+ justify-content: space-between;
159
+ gap: rem-calc(8px);
160
+ margin-top: rem-calc(8px);
161
+ }
162
+
155
163
  .enrollment-load-more {
156
164
  width: fit-content;
157
- margin-top: rem-calc(8px);
158
165
  padding: rem-calc(4px) rem-calc(12px);
159
166
  }
160
167