richie-education 2.25.0-b2.dev46 → 2.25.0-b2.dev49

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.
@@ -53,6 +53,16 @@ const TeacherDashboardCourseList = ({
53
53
  {titleTranslated && (
54
54
  <h2 className="dashboard-course-list__title dashboard__page_title">{titleTranslated}</h2>
55
55
  )}
56
+ {courseAndProductList.length > 0 ? (
57
+ <CourseGlimpseList
58
+ courses={getCourseGlimpseListProps(courseAndProductList, intl, organizationId)}
59
+ context={context}
60
+ className="dashboard__course-glimpse-list"
61
+ />
62
+ ) : (
63
+ <FormattedMessage {...messages.emptyList} />
64
+ )}
65
+
56
66
  {isLoading && (
57
67
  <Spinner aria-labelledby="loading-courses-data">
58
68
  <span id="loading-courses-data">
@@ -60,28 +70,17 @@ const TeacherDashboardCourseList = ({
60
70
  </span>
61
71
  </Spinner>
62
72
  )}
63
- {!isLoading &&
64
- (courseAndProductList.length > 0 ? (
65
- <>
66
- <CourseGlimpseList
67
- courses={getCourseGlimpseListProps(courseAndProductList, intl, organizationId)}
68
- context={context}
69
- className="dashboard__course-glimpse-list"
70
- />
71
- {hasMore && (
72
- <Button
73
- onClick={() => next()}
74
- disabled={isLoading}
75
- ref={loadMoreButtonRef}
76
- color="tertiary"
77
- >
78
- <FormattedMessage {...messages.loadMore} />
79
- </Button>
80
- )}
81
- </>
82
- ) : (
83
- <FormattedMessage {...messages.emptyList} />
84
- ))}
73
+
74
+ {hasMore && (
75
+ <Button
76
+ onClick={() => next()}
77
+ disabled={isLoading}
78
+ ref={loadMoreButtonRef}
79
+ color="tertiary"
80
+ >
81
+ <FormattedMessage {...messages.loadMore} />
82
+ </Button>
83
+ )}
85
84
  </div>
86
85
  );
87
86
  };
@@ -1,4 +1,4 @@
1
- import type { CourseState } from 'types';
1
+ import type { CourseState, OpenEdXEnrollment } from 'types';
2
2
  import type { Maybe, Nullable } from 'types/utils';
3
3
  import { Resource, ResourcesQuery } from 'hooks/useResources';
4
4
  import { OrderResourcesQuery } from 'hooks/useOrders';
@@ -190,8 +190,23 @@ export interface Enrollment {
190
190
  created_on: string;
191
191
  orders: OrderEnrollment[];
192
192
  product_relations: CourseProductRelation[];
193
- certificate_id?: string;
194
- }
193
+ certificate_id: Nullable<string>;
194
+ }
195
+ export const isEnrollment = (obj: unknown | Enrollment | OpenEdXEnrollment): obj is Enrollment => {
196
+ if (!obj || typeof obj !== 'object') {
197
+ return false;
198
+ }
199
+ return (
200
+ 'is_active' in obj &&
201
+ 'state' in obj &&
202
+ 'course_run' in obj &&
203
+ 'was_created_by_order' in obj &&
204
+ 'created_on' in obj &&
205
+ 'orders' in obj &&
206
+ 'product_relations' in obj &&
207
+ 'certificate_id' in obj
208
+ );
209
+ };
195
210
 
196
211
  export interface EnrollmentLight {
197
212
  id: string;
package/js/types/index.ts CHANGED
@@ -69,4 +69,5 @@ export interface OpenEdXEnrollment {
69
69
  * Use an unknown type to make sure we do not depend on any LMS-specific fields
70
70
  * on enrollment objects, just use HTTP response codes.
71
71
  */
72
+ // TODO(rlecellier): rename into UnknownEnrollment
72
73
  export type Enrollment = unknown;
@@ -81,6 +81,7 @@ export const EnrollmentFactory = factory((): Enrollment => {
81
81
  was_created_by_order: false,
82
82
  created_on: faker.date.past({ years: 1 }).toISOString(),
83
83
  orders: [],
84
+ certificate_id: null,
84
85
  };
85
86
  });
86
87
 
@@ -26,4 +26,5 @@ export const enrollment: Enrollment = {
26
26
  languages: ['en'],
27
27
  },
28
28
  product_relations: [],
29
+ certificate_id: null,
29
30
  };
@@ -3,7 +3,9 @@ import fetchMock from 'fetch-mock';
3
3
  import { QueryClientProvider } from '@tanstack/react-query';
4
4
  import { IntlProvider } from 'react-intl';
5
5
 
6
+ import { faker } from '@faker-js/faker';
6
7
  import { Deferred } from 'utils/test/deferred';
8
+ import { EnrollmentFactory as JoanieEnrollment } from 'utils/test/factories/joanie';
7
9
  import {
8
10
  CourseRunFactory,
9
11
  RichieContextFactory as mockRichieContextFactory,
@@ -12,6 +14,7 @@ import {
12
14
  import { SessionProvider } from 'contexts/SessionContext';
13
15
  import { createTestQueryClient } from 'utils/test/createTestQueryClient';
14
16
  import { HttpStatusCode } from 'utils/errors/HttpError';
17
+ import { Priority } from 'types';
15
18
  import CourseRunEnrollment from './index';
16
19
 
17
20
  jest.mock('utils/errors/handle');
@@ -96,10 +99,11 @@ describe('<CourseRunEnrollment /> with joanie backend ', () => {
96
99
  it('shows an error message when enrollment get request failed', async () => {
97
100
  const user = UserFactory().one();
98
101
  const courseRun = CourseRunFactory().one();
99
- courseRun.resource_link = `https://joanie.endpoint/api/v1.0/course-runs/${courseRun.id}`;
102
+ const joanieEnrollmentId = faker.string.uuid();
103
+ courseRun.resource_link = `https://joanie.endpoint/api/v1.0/course-runs/${joanieEnrollmentId}`;
100
104
 
101
105
  fetchMock.get(
102
- `${endpoint}/api/v1.0/enrollments/?course_run=${courseRun.id}`,
106
+ `${endpoint}/api/v1.0/enrollments/?course_run_id=${joanieEnrollmentId}`,
103
107
  HttpStatusCode.INTERNAL_SERVER_ERROR,
104
108
  );
105
109
 
@@ -118,6 +122,45 @@ describe('<CourseRunEnrollment /> with joanie backend ', () => {
118
122
  await screen.findByText('Enrollment fetching failed');
119
123
  });
120
124
 
125
+ it('shows a link to the course if the user is already enrolled', async () => {
126
+ // Joanie session requests
127
+ let nbApiCalls = 3;
128
+ const user = UserFactory().one();
129
+ const joanieEnrollment = JoanieEnrollment({ is_active: true }).one();
130
+ const courseRun = CourseRunFactory().one();
131
+ courseRun.resource_link = `https://joanie.endpoint/api/v1.0/course-runs/${joanieEnrollment.course_run.id}/`;
132
+ courseRun.state.priority = Priority.ONGOING_OPEN;
133
+
134
+ fetchMock.get(
135
+ `${endpoint}/api/v1.0/enrollments/?course_run_id=${joanieEnrollment.course_run.id}`,
136
+ {
137
+ count: 1,
138
+ next: null,
139
+ previous: null,
140
+ results: [joanieEnrollment],
141
+ },
142
+ );
143
+ nbApiCalls += 1;
144
+
145
+ await act(async () => {
146
+ render(
147
+ <QueryClientProvider client={createTestQueryClient({ user })}>
148
+ <IntlProvider locale="en">
149
+ <SessionProvider>
150
+ <CourseRunEnrollment courseRun={courseRun} />
151
+ </SessionProvider>
152
+ </IntlProvider>
153
+ </QueryClientProvider>,
154
+ );
155
+ });
156
+
157
+ const $goToCourseButton = await screen.findByRole('link', { name: 'Go to course' });
158
+ expect($goToCourseButton).toBeInTheDocument();
159
+ expect($goToCourseButton).toHaveAttribute('href', joanieEnrollment.course_run.resource_link);
160
+ expect(screen.getByText('You are enrolled in this course run')).toBeInTheDocument();
161
+ expect(fetchMock.calls()).toHaveLength(nbApiCalls);
162
+ });
163
+
121
164
  it('shows an "Unenroll" text and allows the user to unenroll', async () => {
122
165
  const user = UserFactory().one();
123
166
  const courseRun = CourseRunFactory().one();
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useEffect, useReducer } from 'react';
1
+ import React, { useCallback, useEffect, useMemo, useReducer } from 'react';
2
2
  import { defineMessages, FormattedMessage } from 'react-intl';
3
3
  import c from 'classnames';
4
4
  import { Button } from '@openfun/cunningham-react';
@@ -12,6 +12,7 @@ import { HttpError } from 'utils/errors/HttpError';
12
12
  import useCourseEnrollment from 'widgets/SyllabusCourseRunsList/hooks/useCourseEnrollment';
13
13
  import { CourseRunUnenrollButton } from 'widgets/SyllabusCourseRunsList/components/CourseRunEnrollment/CourseRunUnenrollmentButton';
14
14
  import useDateRelative from 'hooks/useDateRelative';
15
+ import { isEnrollment as isJoanieEnrollment } from 'types/Joanie';
15
16
 
16
17
  const messages = defineMessages({
17
18
  enroll: {
@@ -181,9 +182,8 @@ const reducer = ({ step, context }: ReducerState, action: ReducerAction): Reduce
181
182
 
182
183
  const CourseRunEnrollment: React.FC<CourseRunEnrollmentProps> = (props) => {
183
184
  const { user, login } = useSession();
184
- const { enrollmentIsActive, setEnrollment, canUnenroll, states } = useCourseEnrollment(
185
- props.courseRun.resource_link,
186
- );
185
+ const { enrollment, enrollmentIsActive, setEnrollment, canUnenroll, states } =
186
+ useCourseEnrollment(props.courseRun.resource_link);
187
187
  const startDate = new Date(props.courseRun.start);
188
188
  const isStarted = new Date() > startDate;
189
189
  const relativeStartDate = useDateRelative(startDate);
@@ -226,6 +226,16 @@ const CourseRunEnrollment: React.FC<CourseRunEnrollmentProps> = (props) => {
226
226
  [courseRun, currentUser, dispatch, enrollmentIsActive],
227
227
  );
228
228
 
229
+ const LmsCourseLink = useMemo(() => {
230
+ if (states.isLoading) {
231
+ return null;
232
+ }
233
+ if (isJoanieEnrollment(enrollment)) {
234
+ return enrollment.course_run.resource_link;
235
+ }
236
+ return courseRun.resource_link;
237
+ }, [courseRun, enrollment]);
238
+
229
239
  useEffect(() => {
230
240
  dispatch({
231
241
  payload: { currentUser: user, isEnrolled: enrollmentIsActive },
@@ -303,7 +313,8 @@ const CourseRunEnrollment: React.FC<CourseRunEnrollmentProps> = (props) => {
303
313
  return isStarted ? (
304
314
  <div>
305
315
  <Button
306
- href={courseRun.resource_link}
316
+ href={LmsCourseLink === null ? '#' : LmsCourseLink}
317
+ disabled={LmsCourseLink === null}
307
318
  className="course-run-enrollment__cta"
308
319
  fullWidth={true}
309
320
  >
@@ -21,22 +21,18 @@ const useCourseEnrollment = (resourceLink: string) => {
21
21
  const queryClient = useQueryClient();
22
22
  const EnrollmentAPI = EnrollmentApiInterface(resourceLink);
23
23
 
24
- const [{ data: enrollment, isError, isLoading }, queryKey] = useSessionQuery(
24
+ const [{ data: enrollment, isError, isLoading: isEnrollmentLoading }, queryKey] = useSessionQuery(
25
25
  ['enrollment', resourceLink],
26
26
  async () => {
27
27
  return EnrollmentAPI.get(resourceLink, user!);
28
28
  },
29
29
  );
30
30
 
31
- const [{ data: isActive, refetch: refetchIsActive }] = useSessionQuery(
32
- [...queryKey, 'is_active'],
33
- async () => EnrollmentAPI.isEnrolled(enrollment),
34
- {
31
+ const [{ data: isActive, refetch: refetchIsActive, isLoading: isActiveLoading }] =
32
+ useSessionQuery([...queryKey, 'is_active'], async () => EnrollmentAPI.isEnrolled(enrollment), {
35
33
  // Enrollment is null if it has been fetched
36
- enabled: !!user && enrollment !== undefined && !isLoading,
37
- },
38
- );
39
-
34
+ enabled: !!user && enrollment !== undefined && !isEnrollmentLoading,
35
+ });
40
36
  const { mutateAsync } = useSessionMutation({
41
37
  mutationFn: (activeEnrollment: boolean = true) =>
42
38
  EnrollmentAPI.set(resourceLink, user!, enrollment, activeEnrollment),
@@ -61,6 +57,7 @@ const useCourseEnrollment = (resourceLink: string) => {
61
57
  errors: {
62
58
  get: isError,
63
59
  },
60
+ isLoading: isEnrollmentLoading || isActiveLoading,
64
61
  },
65
62
  };
66
63
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "richie-education",
3
- "version": "2.25.0-b2.dev46",
3
+ "version": "2.25.0-b2.dev49",
4
4
  "description": "A CMS to build learning portals for Open Education",
5
5
  "main": "sandbox/manage.py",
6
6
  "scripts": {