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

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.
Files changed (21) hide show
  1. package/.nvmrc +1 -1
  2. package/js/components/TeacherDashboardCourseList/index.tsx +21 -22
  3. package/js/pages/TeacherDashboardContractsLayout/TeacherDashboardContracts/index.spec.tsx +9 -9
  4. package/js/pages/TeacherDashboardContractsLayout/components/ContractActionsBar/index.spec.tsx +56 -31
  5. package/js/pages/TeacherDashboardContractsLayout/components/ContractActionsBar/index.tsx +4 -3
  6. package/js/pages/TeacherDashboardContractsLayout/hooks/useDefaultOrganizationId/index.spec.tsx +134 -0
  7. package/js/pages/TeacherDashboardContractsLayout/hooks/useDefaultOrganizationId/index.tsx +28 -0
  8. package/js/pages/TeacherDashboardContractsLayout/hooks/useHasContractToDownload/index.tsx +6 -2
  9. package/js/pages/TeacherDashboardContractsLayout/hooks/useTeacherContractFilters/index.spec.tsx +193 -0
  10. package/js/pages/TeacherDashboardContractsLayout/hooks/useTeacherContractFilters/index.tsx +44 -0
  11. package/js/types/Joanie.ts +18 -3
  12. package/js/types/index.ts +1 -0
  13. package/js/utils/search/getSuggestionsSection/index.spec.ts +1 -1
  14. package/js/utils/test/factories/joanie.ts +1 -0
  15. package/js/widgets/Dashboard/components/DashboardItem/stories.mock.ts +1 -0
  16. package/js/widgets/SyllabusCourseRunsList/components/CourseRunEnrollment/index.joanie.spec.tsx +45 -2
  17. package/js/widgets/SyllabusCourseRunsList/components/CourseRunEnrollment/index.tsx +16 -5
  18. package/js/widgets/SyllabusCourseRunsList/hooks/useCourseEnrollment/index.ts +6 -9
  19. package/package.json +2 -2
  20. package/scss/objects/_organization_glimpses.scss +2 -8
  21. package/js/pages/TeacherDashboardContractsLayout/hooks/useTeacherContractFilters.tsx +0 -28
package/.nvmrc CHANGED
@@ -1 +1 @@
1
- 18.19.0
1
+ 20.11.0
@@ -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
  };
@@ -12,7 +12,7 @@ import { createTestQueryClient } from 'utils/test/createTestQueryClient';
12
12
  import { ContractFactory, OrganizationFactory } from 'utils/test/factories/joanie';
13
13
  import { expectNoSpinner } from 'utils/test/expectSpinner';
14
14
  import { expectBannerError } from 'utils/test/expectBanner';
15
- import { HttpError } from 'utils/errors/HttpError';
15
+ import { HttpStatusCode } from 'utils/errors/HttpError';
16
16
  import TeacherDashboardContracts from '.';
17
17
 
18
18
  jest.mock('utils/context', () => ({
@@ -82,12 +82,12 @@ describe('pages/TeacherDashboardContracts', () => {
82
82
  fetchMock.get(`https://joanie.test/api/v1.0/organizations/`, [organization]);
83
83
  // TeacherDashboardContracts request a paginated list of contracts to display
84
84
  fetchMock.get(
85
- `https://joanie.test/api/v1.0/organizations/${organization.id}/contracts/?signature_state=signed&course_product_relation_id=2&page=1&page_size=25`,
85
+ `https://joanie.test/api/v1.0/organizations/${organization.id}/contracts/?course_product_relation_id=2&signature_state=signed&page=1&page_size=25`,
86
86
  { results: contracts, count: 0, previous: null, next: null },
87
87
  );
88
88
  // useTeacherContractsToSign request all contract to sign, without pagination
89
89
  fetchMock.get(
90
- `https://joanie.test/api/v1.0/organizations/${organization.id}/contracts/?signature_state=half_signed&course_product_relation_id=2`,
90
+ `https://joanie.test/api/v1.0/organizations/${organization.id}/contracts/?course_product_relation_id=2&signature_state=half_signed`,
91
91
  { results: [], count: 0, previous: null, next: null },
92
92
  );
93
93
 
@@ -244,6 +244,10 @@ describe('pages/TeacherDashboardContracts', () => {
244
244
  abilities: { sign: true },
245
245
  }).many(3);
246
246
 
247
+ fetchMock.get(
248
+ `https://joanie.test/api/v1.0/organizations/1/contracts/?signature_state=signed&page=1&page_size=25`,
249
+ { results: [], count: 0, previous: null, next: null },
250
+ );
247
251
  fetchMock.get(
248
252
  `https://joanie.test/api/v1.0/organizations/1/contracts/?signature_state=half_signed&page=1&page_size=25`,
249
253
  { results: contracts, count: 3, previous: null, next: null },
@@ -309,15 +313,11 @@ describe('pages/TeacherDashboardContracts', () => {
309
313
  it('should render an error banner if an error occured during contracts fetching', async () => {
310
314
  fetchMock.get(
311
315
  `https://joanie.test/api/v1.0/organizations/1/contracts/?signature_state=signed&page=1&page_size=25`,
312
- () => {
313
- throw new HttpError(404, 'Not found');
314
- },
316
+ HttpStatusCode.NOT_FOUND,
315
317
  );
316
318
  fetchMock.get(
317
319
  `https://joanie.test/api/v1.0/organizations/1/contracts/?signature_state=half_signed`,
318
- () => {
319
- throw new HttpError(404, 'Not found');
320
- },
320
+ HttpStatusCode.NOT_FOUND,
321
321
  );
322
322
 
323
323
  render(
@@ -87,10 +87,7 @@ describe('TeacherDashboardContractsLayout/ContractActionsBar', () => {
87
87
 
88
88
  render(
89
89
  <Wrapper>
90
- <ContractActionsBar
91
- courseProductRelationId={faker.string.uuid()}
92
- organizationId={faker.string.uuid()}
93
- />
90
+ <ContractActionsBar organizationId={faker.string.uuid()} />
94
91
  </Wrapper>,
95
92
  );
96
93
 
@@ -99,26 +96,47 @@ describe('TeacherDashboardContractsLayout/ContractActionsBar', () => {
99
96
  expect(screen.getByRole('button', { name: /Request contracts archive/ })).toBeInTheDocument();
100
97
  });
101
98
 
102
- it("shouldn't only display sign button", () => {
103
- mockHasContractToDownload = false;
104
- mockCanSignContracts = true;
105
- mockContractsToSignCount = 1;
106
-
107
- render(
108
- <Wrapper>
109
- <ContractActionsBar
110
- courseProductRelationId={faker.string.uuid()}
111
- organizationId={faker.string.uuid()}
112
- />
113
- </Wrapper>,
114
- );
115
-
116
- expect(screen.getByTestId('teacher-contracts-list-actionsBar')).toBeInTheDocument();
117
- expect(screen.getByRole('button', { name: /Sign all pending contracts/ })).toBeInTheDocument();
118
- expect(
119
- screen.queryByRole('button', { name: /Request contracts archive/ }),
120
- ).not.toBeInTheDocument();
121
- });
99
+ it.each([
100
+ {
101
+ label: "doesn't have contract to download",
102
+ hasContractToDownload: false,
103
+ courseProductRelationId: undefined,
104
+ },
105
+ {
106
+ label: 'has contract to download and courseProductRelationId',
107
+ hasContractToDownload: true,
108
+ courseProductRelationId: faker.string.uuid(),
109
+ },
110
+ {
111
+ label: "doesn't have contract to download and courseProductRelationId",
112
+ hasContractToDownload: false,
113
+ courseProductRelationId: faker.string.uuid(),
114
+ },
115
+ ])(
116
+ "shouldn't only display sign button when $label",
117
+ ({ hasContractToDownload, courseProductRelationId }) => {
118
+ mockHasContractToDownload = hasContractToDownload;
119
+ mockCanSignContracts = true;
120
+ mockContractsToSignCount = 1;
121
+
122
+ render(
123
+ <Wrapper>
124
+ <ContractActionsBar
125
+ courseProductRelationId={courseProductRelationId}
126
+ organizationId={faker.string.uuid()}
127
+ />
128
+ </Wrapper>,
129
+ );
130
+
131
+ expect(screen.getByTestId('teacher-contracts-list-actionsBar')).toBeInTheDocument();
132
+ expect(
133
+ screen.getByRole('button', { name: /Sign all pending contracts/ }),
134
+ ).toBeInTheDocument();
135
+ expect(
136
+ screen.queryByRole('button', { name: /Request contracts archive/ }),
137
+ ).not.toBeInTheDocument();
138
+ },
139
+ );
122
140
 
123
141
  it("shouldn't only display download button", () => {
124
142
  mockHasContractToDownload = true;
@@ -127,10 +145,7 @@ describe('TeacherDashboardContractsLayout/ContractActionsBar', () => {
127
145
 
128
146
  render(
129
147
  <Wrapper>
130
- <ContractActionsBar
131
- courseProductRelationId={faker.string.uuid()}
132
- organizationId={faker.string.uuid()}
133
- />
148
+ <ContractActionsBar organizationId={faker.string.uuid()} />
134
149
  </Wrapper>,
135
150
  );
136
151
 
@@ -141,15 +156,25 @@ describe('TeacherDashboardContractsLayout/ContractActionsBar', () => {
141
156
  ).not.toBeInTheDocument();
142
157
  });
143
158
 
144
- it('should return nothing when no actions are available', () => {
145
- mockHasContractToDownload = false;
159
+ it.each([
160
+ {
161
+ label: 'only download is available but we have a courseProductRelationId',
162
+ hasContractToDownload: true,
163
+ courseProductRelationId: faker.string.uuid(),
164
+ },
165
+ {
166
+ label: 'no actions are available',
167
+ hasContractToDownload: false,
168
+ },
169
+ ])('should return nothing when $label', ({ hasContractToDownload, courseProductRelationId }) => {
170
+ mockHasContractToDownload = hasContractToDownload;
146
171
  mockCanSignContracts = false;
147
172
  mockContractsToSignCount = 0;
148
173
 
149
174
  render(
150
175
  <Wrapper>
151
176
  <ContractActionsBar
152
- courseProductRelationId={faker.string.uuid()}
177
+ courseProductRelationId={courseProductRelationId}
153
178
  organizationId={faker.string.uuid()}
154
179
  />
155
180
  </Wrapper>,
@@ -15,9 +15,10 @@ const ContractActionsBar = ({ organizationId, courseProductRelationId }: Contrac
15
15
  organizationId,
16
16
  courseProductRelationId,
17
17
  });
18
- const hasContractToDownload = useHasContractToDownload(organizationId);
18
+ const hasContractToDownload = useHasContractToDownload(organizationId, courseProductRelationId);
19
19
 
20
- const nbAvailableActions = [canSignContracts, hasContractToDownload].filter((val) => val).length;
20
+ const canDownloadContracts = hasContractToDownload && !courseProductRelationId;
21
+ const nbAvailableActions = [canSignContracts, canDownloadContracts].filter((val) => val).length;
21
22
  return (
22
23
  nbAvailableActions > 0 && (
23
24
  <div
@@ -35,7 +36,7 @@ const ContractActionsBar = ({ organizationId, courseProductRelationId }: Contrac
35
36
  />
36
37
  </div>
37
38
  )}
38
- {hasContractToDownload && <BulkDownloadContractButton organizationId={organizationId} />}
39
+ {canDownloadContracts && <BulkDownloadContractButton organizationId={organizationId} />}
39
40
  </div>
40
41
  )
41
42
  );
@@ -0,0 +1,134 @@
1
+ import { QueryClientProvider } from '@tanstack/react-query';
2
+ import { PropsWithChildren } from 'react';
3
+ import { IntlProvider } from 'react-intl';
4
+ import { MemoryRouter, Route, Routes } from 'react-router-dom';
5
+ import { renderHook, waitFor } from '@testing-library/react';
6
+ import fetchMock from 'fetch-mock';
7
+ import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
8
+ import JoanieSessionProvider from 'contexts/SessionContext/JoanieSessionProvider';
9
+ import { createTestQueryClient } from 'utils/test/createTestQueryClient';
10
+ import { OrganizationFactory } from 'utils/test/factories/joanie';
11
+ import { Organization } from 'types/Joanie';
12
+ import useDefaultOrganizationId from '.';
13
+
14
+ jest.mock('utils/context', () => ({
15
+ __esModule: true,
16
+ default: mockRichieContextFactory({
17
+ authentication: { backend: 'fonzie', endpoint: 'https://demo.test' },
18
+ joanie_backend: { endpoint: 'https://joanie.test' },
19
+ }).one(),
20
+ }));
21
+
22
+ interface WrapperProps {
23
+ routePath: string;
24
+ initialEntry: string;
25
+ }
26
+
27
+ describe('useDefaultOrganizationId', () => {
28
+ const organizations: {
29
+ routeOrganization: Organization;
30
+ queryOrganization: Organization;
31
+ userOrganizationList: Organization[];
32
+ } = {
33
+ routeOrganization: OrganizationFactory().one(),
34
+ queryOrganization: OrganizationFactory().one(),
35
+ userOrganizationList: OrganizationFactory().many(2),
36
+ };
37
+
38
+ const Wrapper = ({ children, routePath, initialEntry }: PropsWithChildren<WrapperProps>) => {
39
+ return (
40
+ <IntlProvider locale="en">
41
+ <QueryClientProvider client={createTestQueryClient({ user: true })}>
42
+ <JoanieSessionProvider>
43
+ <MemoryRouter initialEntries={[initialEntry]}>
44
+ <Routes>
45
+ <Route path={routePath} element={children} />
46
+ </Routes>
47
+ </MemoryRouter>
48
+ </JoanieSessionProvider>
49
+ </QueryClientProvider>
50
+ </IntlProvider>
51
+ );
52
+ };
53
+
54
+ beforeEach(() => {
55
+ // Joanie provider's calls
56
+ fetchMock.get('https://joanie.test/api/v1.0/orders/', []);
57
+ fetchMock.get('https://joanie.test/api/v1.0/credit-cards/', []);
58
+ fetchMock.get('https://joanie.test/api/v1.0/addresses/', []);
59
+ });
60
+
61
+ afterEach(() => {
62
+ fetchMock.restore();
63
+ });
64
+
65
+ it.each([
66
+ {
67
+ testLabel: 'route organization before query',
68
+ routeOrganization: organizations.routeOrganization,
69
+ queryOrganization: organizations.queryOrganization,
70
+ userOrganizationList: organizations.userOrganizationList,
71
+ expectedOrganizationId: organizations.routeOrganization.id,
72
+ },
73
+ {
74
+ testLabel: 'query organization before first element of list',
75
+ routeOrganization: undefined,
76
+ queryOrganization: organizations.queryOrganization,
77
+ userOrganizationList: organizations.userOrganizationList,
78
+ expectedOrganizationId: organizations.queryOrganization.id,
79
+ },
80
+ {
81
+ testLabel: 'first element of list when nothing else is found',
82
+ routeOrganization: undefined,
83
+ queryOrganization: undefined,
84
+ userOrganizationList: organizations.userOrganizationList,
85
+ expectedOrganizationId: organizations.userOrganizationList[0].id,
86
+ },
87
+ {
88
+ testLabel: 'undefined when user have no organization in his list',
89
+ routeOrganization: undefined,
90
+ queryOrganization: undefined,
91
+ userOrganizationList: [],
92
+ expectedOrganizationId: undefined,
93
+ },
94
+ ])(
95
+ 'should return $testLabel',
96
+ async ({
97
+ routeOrganization,
98
+ queryOrganization,
99
+ userOrganizationList,
100
+ expectedOrganizationId,
101
+ }) => {
102
+ let routePath = '/';
103
+ if (routeOrganization) {
104
+ routePath += ':organizationId/';
105
+ }
106
+ let initialEntry = '/';
107
+ if (routeOrganization) {
108
+ initialEntry += `${routeOrganization.id}/`;
109
+ }
110
+ if (queryOrganization) {
111
+ initialEntry += `?organization_id=${queryOrganization.id}`;
112
+ }
113
+
114
+ fetchMock.get(
115
+ 'https://joanie.test/api/v1.0/organizations/',
116
+ [...userOrganizationList, routeOrganization, queryOrganization].filter(
117
+ (organization) => organization !== undefined,
118
+ ),
119
+ );
120
+ const { result } = renderHook(useDefaultOrganizationId, {
121
+ wrapper: ({ children }) => (
122
+ <Wrapper routePath={routePath} initialEntry={initialEntry}>
123
+ {children}
124
+ </Wrapper>
125
+ ),
126
+ });
127
+
128
+ // when looking in organization list defaultOrganization will be updated when organizations are fetched.
129
+ await waitFor(() => {
130
+ expect(result.current).toBe(expectedOrganizationId);
131
+ });
132
+ },
133
+ );
134
+ });
@@ -0,0 +1,28 @@
1
+ import { useParams, useSearchParams } from 'react-router-dom';
2
+ import { useOrganizations } from 'hooks/useOrganizations';
3
+ import { Organization } from 'types/Joanie';
4
+
5
+ /**
6
+ * return organization id with this priority:
7
+ * * route param
8
+ * * query param
9
+ * * first organization of user's organizations
10
+ */
11
+ const useDefaultOrganizationId = () => {
12
+ const { organizationId: routeOrganizationId } = useParams<{
13
+ organizationId?: Organization['id'];
14
+ }>();
15
+ const [searchParams] = useSearchParams();
16
+ const queryOrganizationId = searchParams.get('organization_id') || undefined;
17
+ const { items: organizations } = useOrganizations(undefined, {
18
+ enabled: !routeOrganizationId && !queryOrganizationId,
19
+ });
20
+
21
+ return (
22
+ routeOrganizationId ||
23
+ queryOrganizationId ||
24
+ (organizations.length > 0 ? organizations[0].id : undefined)
25
+ );
26
+ };
27
+
28
+ export default useDefaultOrganizationId;
@@ -1,13 +1,17 @@
1
1
  import { useOrganizationContracts } from 'hooks/useContracts';
2
2
  import { PER_PAGE } from 'settings';
3
- import { ContractState, Organization } from 'types/Joanie';
3
+ import { ContractState, CourseProductRelation, Organization } from 'types/Joanie';
4
4
 
5
- const useHasContractToDownload = (organizationId: Organization['id']) => {
5
+ const useHasContractToDownload = (
6
+ organizationId: Organization['id'],
7
+ courseProductRelationId?: CourseProductRelation['id'],
8
+ ) => {
6
9
  const {
7
10
  items: contracts,
8
11
  states: { isFetched },
9
12
  } = useOrganizationContracts({
10
13
  organization_id: organizationId,
14
+ course_product_relation_id: courseProductRelationId,
11
15
  signature_state: ContractState.SIGNED,
12
16
  page: 1,
13
17
  page_size: PER_PAGE.teacherContractList,
@@ -0,0 +1,193 @@
1
+ import fetchMock from 'fetch-mock';
2
+ import { PropsWithChildren } from 'react';
3
+ import { IntlProvider } from 'react-intl';
4
+ import { QueryClientProvider } from '@tanstack/react-query';
5
+ import { renderHook, waitFor } from '@testing-library/react';
6
+ import { MemoryRouter, Route, Routes } from 'react-router-dom';
7
+ import { act } from 'react-dom/test-utils';
8
+ import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
9
+ import { createTestQueryClient } from 'utils/test/createTestQueryClient';
10
+ import JoanieSessionProvider from 'contexts/SessionContext/JoanieSessionProvider';
11
+ import { CourseProductRelationFactory, OrganizationFactory } from 'utils/test/factories/joanie';
12
+ import { ContractState } from 'types/Joanie';
13
+ import useTeacherContractFilters from '.';
14
+
15
+ jest.mock('utils/context', () => ({
16
+ __esModule: true,
17
+ default: mockRichieContextFactory({
18
+ authentication: { backend: 'fonzie', endpoint: 'https://auth.test' },
19
+ joanie_backend: { endpoint: 'https://joanie.test' },
20
+ }).one(),
21
+ }));
22
+
23
+ interface WrapperProps {
24
+ routePath: string;
25
+ initialEntry: string;
26
+ }
27
+
28
+ describe('useTeacherContractFilters', () => {
29
+ const Wrapper = ({ children, routePath, initialEntry }: PropsWithChildren<WrapperProps>) => {
30
+ return (
31
+ <IntlProvider locale="en">
32
+ <QueryClientProvider client={createTestQueryClient({ user: true })}>
33
+ <JoanieSessionProvider>
34
+ <MemoryRouter initialEntries={[initialEntry]}>
35
+ <Routes>
36
+ <Route path={routePath} element={children} />
37
+ </Routes>
38
+ </MemoryRouter>
39
+ </JoanieSessionProvider>
40
+ </QueryClientProvider>
41
+ </IntlProvider>
42
+ );
43
+ };
44
+ beforeEach(() => {
45
+ // Joanie provider's calls
46
+ fetchMock.get('https://joanie.test/api/v1.0/orders/', []);
47
+ fetchMock.get('https://joanie.test/api/v1.0/credit-cards/', []);
48
+ fetchMock.get('https://joanie.test/api/v1.0/addresses/', []);
49
+ });
50
+
51
+ afterEach(() => {
52
+ fetchMock.restore();
53
+ });
54
+
55
+ it('should return default filter when called in a route without parameters', async () => {
56
+ const defaultOrganization = OrganizationFactory({ id: 'default' }).one();
57
+ // fetching user's organizations to initialize default organizationId.
58
+ fetchMock.get('https://joanie.test/api/v1.0/organizations/', [defaultOrganization]);
59
+ const { result } = renderHook(useTeacherContractFilters, {
60
+ wrapper: ({ children }) => (
61
+ <Wrapper routePath="/" initialEntry="/">
62
+ {children}
63
+ </Wrapper>
64
+ ),
65
+ });
66
+
67
+ await waitFor(() => {
68
+ expect(result.current.initialFilters).toStrictEqual({
69
+ contract_ids: [],
70
+ organization_id: defaultOrganization.id,
71
+ course_product_relation_id: undefined,
72
+ signature_state: ContractState.SIGNED,
73
+ });
74
+ expect(result.current.filters).toStrictEqual({
75
+ contract_ids: [],
76
+ organization_id: defaultOrganization.id,
77
+ course_product_relation_id: undefined,
78
+ signature_state: ContractState.SIGNED,
79
+ });
80
+ });
81
+ });
82
+
83
+ it('should use route parameters values when given', async () => {
84
+ const defaultOrganization = OrganizationFactory({ id: 'default' }).one();
85
+ const filteredOrganization = OrganizationFactory({ id: 'filtered' }).one();
86
+ const routeOrganization = OrganizationFactory({ id: 'route' }).one();
87
+ const routeCourseProductRelation = CourseProductRelationFactory().one();
88
+ // fetching user's organizations to initialize default organizationId.
89
+ fetchMock.get('https://joanie.test/api/v1.0/organizations/', [
90
+ defaultOrganization,
91
+ filteredOrganization,
92
+ ]);
93
+ const { result } = renderHook(useTeacherContractFilters, {
94
+ wrapper: ({ children }) => (
95
+ <Wrapper
96
+ routePath="/:organizationId/:courseProductRelationId"
97
+ initialEntry={`/${routeOrganization.id}/${routeCourseProductRelation.id}?organization_id=${filteredOrganization.id}&signature_state=${ContractState?.UNSIGNED}&contract_ids=1&contract_ids=2`}
98
+ >
99
+ {children}
100
+ </Wrapper>
101
+ ),
102
+ });
103
+
104
+ await waitFor(() => {
105
+ expect(result.current.initialFilters).toStrictEqual({
106
+ contract_ids: ['1', '2'],
107
+ organization_id: routeOrganization.id,
108
+ course_product_relation_id: routeCourseProductRelation.id,
109
+ signature_state: ContractState.UNSIGNED,
110
+ });
111
+ expect(result.current.filters).toStrictEqual({
112
+ contract_ids: ['1', '2'],
113
+ organization_id: routeOrganization.id,
114
+ course_product_relation_id: routeCourseProductRelation.id,
115
+ signature_state: ContractState.UNSIGNED,
116
+ });
117
+ });
118
+ });
119
+
120
+ it("should use organizationId from query parameters when it's not in route params", async () => {
121
+ const defaultOrganization = OrganizationFactory({ id: 'default' }).one();
122
+ const filteredOrganization = OrganizationFactory({ id: 'filtered' }).one();
123
+ const routeCourseProductRelation = CourseProductRelationFactory({ id: 'route' }).one();
124
+ // fetching user's organizations to initialize default organizationId.
125
+ fetchMock.get('https://joanie.test/api/v1.0/organizations/', [
126
+ defaultOrganization,
127
+ filteredOrganization,
128
+ ]);
129
+ const { result } = renderHook(useTeacherContractFilters, {
130
+ wrapper: ({ children }) => (
131
+ <Wrapper
132
+ routePath="/:courseProductRelationId"
133
+ initialEntry={`/${routeCourseProductRelation.id}/?organization_id=${filteredOrganization.id}&signature_state=${ContractState?.UNSIGNED}&contract_ids=1&contract_ids=2`}
134
+ >
135
+ {children}
136
+ </Wrapper>
137
+ ),
138
+ });
139
+
140
+ await waitFor(() => {
141
+ expect(result.current.initialFilters).toStrictEqual({
142
+ contract_ids: ['1', '2'],
143
+ organization_id: filteredOrganization.id,
144
+ course_product_relation_id: routeCourseProductRelation.id,
145
+ signature_state: ContractState.UNSIGNED,
146
+ });
147
+ expect(result.current.filters).toStrictEqual({
148
+ contract_ids: ['1', '2'],
149
+ organization_id: filteredOrganization.id,
150
+ course_product_relation_id: routeCourseProductRelation.id,
151
+ signature_state: ContractState.UNSIGNED,
152
+ });
153
+ });
154
+ });
155
+
156
+ it('setFilters should update filter state', async () => {
157
+ const defaultOrganization = OrganizationFactory({ id: 'default' }).one();
158
+ const routeOrganization = OrganizationFactory({ id: 'route' }).one();
159
+ const routeCourseProductRelation = CourseProductRelationFactory().one();
160
+ // fetching user's organizations to initialize default organizationId.
161
+ fetchMock.get('https://joanie.test/api/v1.0/organizations/', [defaultOrganization]);
162
+ const { result } = renderHook(useTeacherContractFilters, {
163
+ wrapper: ({ children }) => (
164
+ <Wrapper routePath="/" initialEntry="/">
165
+ {children}
166
+ </Wrapper>
167
+ ),
168
+ });
169
+
170
+ const expectedInitialFilters = {
171
+ contract_ids: [],
172
+ organization_id: defaultOrganization.id,
173
+ course_product_relation_id: undefined,
174
+ signature_state: ContractState.SIGNED,
175
+ };
176
+ await waitFor(() => {
177
+ expect(result.current.initialFilters).toStrictEqual(expectedInitialFilters);
178
+ });
179
+
180
+ const newFilters = {
181
+ contract_ids: ['1', '2'],
182
+ organization_id: routeOrganization.id,
183
+ course_product_relation_id: routeCourseProductRelation.id,
184
+ signature_state: ContractState.UNSIGNED,
185
+ };
186
+ act(() => {
187
+ result.current.setFilters(newFilters);
188
+ });
189
+
190
+ expect(result.current.filters).toStrictEqual(newFilters);
191
+ expect(result.current.initialFilters).toStrictEqual(expectedInitialFilters);
192
+ });
193
+ });
@@ -0,0 +1,44 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import { useParams, useSearchParams } from 'react-router-dom';
3
+ import { ContractResourceQuery, ContractState } from 'types/Joanie';
4
+ import useDefaultOrganizationId from '../useDefaultOrganizationId';
5
+
6
+ export type TeacherDashboardContractsParams = {
7
+ organizationId?: string;
8
+ courseProductRelationId?: string;
9
+ };
10
+
11
+ const useTeacherContractFilters = () => {
12
+ const { courseProductRelationId } = useParams<TeacherDashboardContractsParams>();
13
+ const [searchParams] = useSearchParams();
14
+ const searchFilters: ContractResourceQuery = useMemo(() => {
15
+ return {
16
+ organization_id: searchParams.get('organization_id') || undefined,
17
+ course_product_relation_id: searchParams.get('course_product_relation_id') || undefined,
18
+ contract_ids: searchParams.getAll('contract_ids') || undefined,
19
+ signature_state:
20
+ (searchParams.get('signature_state') as ContractState) || ContractState.SIGNED,
21
+ };
22
+ }, Array.from(searchParams.entries()));
23
+
24
+ // default orgnizationId between (ordered by priority): route, query, first user's organization.
25
+ const defaultOrganizationId = useDefaultOrganizationId();
26
+
27
+ const initialFilters = useMemo(() => {
28
+ return {
29
+ ...searchFilters,
30
+ organization_id: defaultOrganizationId,
31
+ course_product_relation_id: courseProductRelationId,
32
+ };
33
+ }, [defaultOrganizationId]);
34
+ const [filters, setFilters] = useState<ContractResourceQuery>(initialFilters);
35
+
36
+ // update current filter with initial value when it's ready
37
+ useEffect(() => {
38
+ setFilters(initialFilters);
39
+ }, [initialFilters]);
40
+
41
+ return { initialFilters, filters, setFilters };
42
+ };
43
+
44
+ export default useTeacherContractFilters;
@@ -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;
@@ -78,7 +78,7 @@ describe('utils/search/getSuggestionsSection', () => {
78
78
  expect(mockHandle).toHaveBeenCalledWith(
79
79
  new Error(
80
80
  'Failed to decode JSON in getSuggestionSection FetchError: invalid json response body at ' +
81
- '/api/v1.0/courses/autocomplete/?query=some%20search reason: Unexpected token o in JSON at position 1',
81
+ '/api/v1.0/courses/autocomplete/?query=some%20search reason: Unexpected token \'o\', "not json" is not valid JSON',
82
82
  ),
83
83
  );
84
84
  });
@@ -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.dev58",
4
4
  "description": "A CMS to build learning portals for Open Education",
5
5
  "main": "sandbox/manage.py",
6
6
  "scripts": {
@@ -147,6 +147,6 @@
147
147
  "workerDirectory": "../richie/static/richie/js"
148
148
  },
149
149
  "volta": {
150
- "node": "18.19.0"
150
+ "node": "20.11.0"
151
151
  }
152
152
  }
@@ -131,18 +131,12 @@
131
131
  }
132
132
 
133
133
  &__logo {
134
- position: relative;
134
+ height: 100%;
135
135
  width: 100%;
136
- padding-bottom: 56.25%; // Aspect ratio 16/9
137
136
 
138
137
  & > img {
139
- position: absolute;
140
- top: 0;
141
- right: 0;
142
- bottom: 0;
143
- left: 0;
144
- width: 100%;
145
138
  height: 100%;
139
+ width: 100%;
146
140
  object-fit: contain;
147
141
  object-position: center;
148
142
  }
@@ -1,28 +0,0 @@
1
- import { useMemo, useState } from 'react';
2
- import { useParams, useSearchParams } from 'react-router-dom';
3
- import { ContractResourceQuery, ContractState } from 'types/Joanie';
4
-
5
- export type TeacherDashboardContractsParams = {
6
- organizationId?: string;
7
- courseProductRelationId?: string;
8
- };
9
-
10
- const useTeacherContractFilters = () => {
11
- const { organizationId, courseProductRelationId } = useParams<TeacherDashboardContractsParams>();
12
- const [searchParams] = useSearchParams();
13
-
14
- const initialFilters = useMemo(
15
- () => ({
16
- signature_state:
17
- (searchParams.get('signature_state') as ContractState) || ContractState.SIGNED,
18
- organization_id: organizationId,
19
- course_product_relation_id: courseProductRelationId,
20
- }),
21
- [],
22
- );
23
- const [filters, setFilters] = useState<ContractResourceQuery>(initialFilters);
24
-
25
- return { initialFilters, filters, setFilters };
26
- };
27
-
28
- export default useTeacherContractFilters;