richie-education 2.25.0-b2.dev103 → 2.25.0-b2.dev111

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 (33) hide show
  1. package/js/api/joanie.ts +13 -0
  2. package/js/hooks/useCourseOrders/index.ts +32 -0
  3. package/js/{pages/TeacherDashboardContractsLayout/hooks → hooks}/useDefaultOrganizationId/index.spec.tsx +6 -42
  4. package/js/hooks/useOrganizations/index.ts +4 -4
  5. package/js/hooks/useResources/index.tsx +2 -0
  6. package/js/pages/TeacherDashboardContractsLayout/TeacherDashboardContracts/index.spec.tsx +68 -83
  7. package/js/pages/TeacherDashboardContractsLayout/components/ContractFiltersBar/index.spec.tsx +21 -62
  8. package/js/pages/TeacherDashboardContractsLayout/hooks/useTeacherContractFilters/index.tsx +1 -1
  9. package/js/pages/TeacherDashboardContractsLayout/styles.scss +0 -4
  10. package/js/pages/TeacherDashboardCourseLearnersLayout/components/CourseLearnerDataGrid/index.spec.tsx +62 -0
  11. package/js/pages/TeacherDashboardCourseLearnersLayout/components/CourseLearnerDataGrid/index.tsx +123 -0
  12. package/js/pages/TeacherDashboardCourseLearnersLayout/components/CourseLearnersFiltersBar/index.spec.tsx +70 -0
  13. package/js/pages/TeacherDashboardCourseLearnersLayout/components/CourseLearnersFiltersBar/index.tsx +31 -0
  14. package/js/pages/TeacherDashboardCourseLearnersLayout/hooks/useCourseLearnersFilters/index.spec.tsx +158 -0
  15. package/js/pages/TeacherDashboardCourseLearnersLayout/hooks/useCourseLearnersFilters/index.ts +44 -0
  16. package/js/pages/TeacherDashboardCourseLearnersLayout/index.spec.tsx +385 -0
  17. package/js/pages/TeacherDashboardCourseLearnersLayout/index.tsx +141 -0
  18. package/js/settings/index.ts +1 -7
  19. package/js/settings/settings.prod.ts +1 -0
  20. package/js/types/Joanie.ts +47 -0
  21. package/js/utils/OrderHelper/index.ts +5 -1
  22. package/js/utils/test/factories/cunningham.ts +13 -0
  23. package/js/utils/test/factories/joanie.ts +44 -0
  24. package/js/widgets/Dashboard/components/DashboardItem/Order/OrderStateMessage/index.tsx +8 -2
  25. package/js/widgets/Dashboard/components/DashboardSidebar/components/ContractNavLink/index.tsx +1 -1
  26. package/js/widgets/Dashboard/components/FilterOrganization/index.tsx +27 -10
  27. package/js/widgets/Dashboard/components/TeacherDashboardCourseSidebar/index.spec.tsx +36 -76
  28. package/js/widgets/Dashboard/components/TeacherDashboardCourseSidebar/utils.ts +6 -1
  29. package/js/widgets/Dashboard/utils/teacherRouteMessages.tsx +23 -0
  30. package/js/widgets/Dashboard/utils/teacherRoutes.tsx +31 -0
  31. package/package.json +1 -1
  32. package/scss/objects/_dashboard.scss +7 -0
  33. /package/js/{pages/TeacherDashboardContractsLayout/hooks → hooks}/useDefaultOrganizationId/index.tsx +0 -0
@@ -0,0 +1,385 @@
1
+ import fetchMock from 'fetch-mock';
2
+ import { screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import queryString from 'query-string';
5
+ import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
6
+ import {
7
+ CourseProductRelationFactory,
8
+ NestedCourseOrderFactory,
9
+ OrganizationFactory,
10
+ } from 'utils/test/factories/joanie';
11
+ import { expectNoSpinner } from 'utils/test/expectSpinner';
12
+ import { PER_PAGE } from 'settings';
13
+ import { HttpStatusCode } from 'utils/errors/HttpError';
14
+ import { expectBannerError } from 'utils/test/expectBanner';
15
+ import { render } from 'utils/test/render';
16
+ import { setupJoanieSession } from 'utils/test/wrappers/JoanieAppWrapper';
17
+ import { TeacherDashboardCourseLearnersLayout } from '.';
18
+
19
+ jest.mock('utils/context', () => ({
20
+ __esModule: true,
21
+ default: mockRichieContextFactory({
22
+ authentication: { backend: 'fonzie', endpoint: 'https://auth.endpoint.test' },
23
+ joanie_backend: { endpoint: 'https://joanie.endpoint' },
24
+ }).one(),
25
+ }));
26
+
27
+ describe('pages/TeacherDashboardCourseLearnersLayout', () => {
28
+ setupJoanieSession();
29
+ beforeEach(() => {
30
+ // CourseSidebar api calls
31
+ fetchMock.get('https://joanie.endpoint/api/v1.0/courses/', {});
32
+ fetchMock.get('https://joanie.endpoint/api/v1.0/organizations/', []);
33
+ });
34
+
35
+ it.each([
36
+ {
37
+ expectedLabel: 'should not render organization filter without organizations',
38
+ nbOrganization: 0,
39
+ organizationFilterShouldBeDisplayed: false,
40
+ },
41
+ {
42
+ expectedLabel: 'should not render organization filter with 1 organization',
43
+ nbOrganization: 1,
44
+ organizationFilterShouldBeDisplayed: false,
45
+ },
46
+ {
47
+ expectedLabel: 'should render organization filter with 2 organization',
48
+ nbOrganization: 2,
49
+ organizationFilterShouldBeDisplayed: true,
50
+ },
51
+ ])('$expectedLabel', async ({ nbOrganization, organizationFilterShouldBeDisplayed }) => {
52
+ const courseProductRelation = CourseProductRelationFactory().one();
53
+ const organizationList = OrganizationFactory().many(nbOrganization);
54
+ fetchMock.get(
55
+ `https://joanie.endpoint/api/v1.0/organizations/?course_product_relation_id=${courseProductRelation.id}`,
56
+ organizationList,
57
+ );
58
+
59
+ // Course sidebar query
60
+ fetchMock.get(
61
+ `https://joanie.endpoint/api/v1.0/course-product-relations/${courseProductRelation.id}/`,
62
+ {},
63
+ );
64
+
65
+ // First request before finding default organizationId
66
+ const courseOrderListQueryParams = {
67
+ course_product_relation_id: courseProductRelation.id,
68
+ page: 1,
69
+ page_size: PER_PAGE.courseLearnerList,
70
+ };
71
+ fetchMock.get(
72
+ `https://joanie.endpoint/api/v1.0/courses/${courseProductRelation.course.id}/orders/?${queryString.stringify(courseOrderListQueryParams, { sort: false })}`,
73
+ [],
74
+ );
75
+
76
+ if (organizationList.length > 0) {
77
+ // Course sidebar query
78
+ fetchMock.get(
79
+ `https://joanie.endpoint/api/v1.0/organizations/${organizationList[0].id}/contracts/?course_product_relation_id=${courseProductRelation.id}&signature_state=half_signed&page=1&page_size=${PER_PAGE.teacherContractList}`,
80
+ [],
81
+ );
82
+
83
+ // Second request when default organizationId is fetched
84
+ fetchMock.get(
85
+ `https://joanie.endpoint/api/v1.0/courses/${courseProductRelation.course.id}/orders/?${queryString.stringify({ organization_id: organizationList[0].id, ...courseOrderListQueryParams }, { sort: false })}`,
86
+ [],
87
+ );
88
+ }
89
+ render(<TeacherDashboardCourseLearnersLayout />, {
90
+ routerOptions: {
91
+ path: '/:courseId/:courseProductRelationId',
92
+ initialEntries: [`/${courseProductRelation.course.id}/${courseProductRelation.id}`],
93
+ },
94
+ });
95
+
96
+ await expectNoSpinner();
97
+ if (organizationFilterShouldBeDisplayed) {
98
+ expect(
99
+ await screen.findByRole('combobox', {
100
+ name: /Organization/,
101
+ hidden: true,
102
+ }),
103
+ ).toBeInTheDocument();
104
+ } else {
105
+ expect(
106
+ screen.queryByRole('combobox', {
107
+ name: /Organization/,
108
+ hidden: true,
109
+ }),
110
+ ).not.toBeInTheDocument();
111
+ }
112
+ });
113
+
114
+ it('should call onFiltersChange on organization filter change', async () => {
115
+ const courseProductRelation = CourseProductRelationFactory().one();
116
+ const defaultOrganization = OrganizationFactory().one();
117
+ const otherOrganization = OrganizationFactory().one();
118
+ const organizationList = [defaultOrganization, otherOrganization];
119
+
120
+ // Course sidebar queries
121
+ fetchMock.get(
122
+ `https://joanie.endpoint/api/v1.0/organizations/${defaultOrganization.id}/contracts/?course_product_relation_id=${courseProductRelation.id}&signature_state=half_signed&page=1&page_size=${PER_PAGE.teacherContractList}`,
123
+ [],
124
+ );
125
+ fetchMock.get(
126
+ `https://joanie.endpoint/api/v1.0/course-product-relations/${courseProductRelation.id}/`,
127
+ {},
128
+ );
129
+
130
+ fetchMock.get(
131
+ `https://joanie.endpoint/api/v1.0/organizations/?course_product_relation_id=${courseProductRelation.id}`,
132
+ organizationList,
133
+ );
134
+ // First request before finding default organizationId
135
+ const courseOrderListQueryParams = {
136
+ course_product_relation_id: courseProductRelation.id,
137
+ page: 1,
138
+ page_size: PER_PAGE.courseLearnerList,
139
+ };
140
+ fetchMock.get(
141
+ `https://joanie.endpoint/api/v1.0/courses/${courseProductRelation.course.id}/orders/?${queryString.stringify(courseOrderListQueryParams, { sort: false })}`,
142
+ [],
143
+ );
144
+ // Second request when default organizationId is fetched
145
+ fetchMock.get(
146
+ `https://joanie.endpoint/api/v1.0/courses/${courseProductRelation.course.id}/orders/?${queryString.stringify({ organization_id: defaultOrganization.id, ...courseOrderListQueryParams }, { sort: false })}`,
147
+ [],
148
+ );
149
+
150
+ render(<TeacherDashboardCourseLearnersLayout />, {
151
+ routerOptions: {
152
+ path: '/:courseId/:courseProductRelationId',
153
+ initialEntries: [`/${courseProductRelation.course.id}/${courseProductRelation.id}`],
154
+ },
155
+ });
156
+
157
+ const organizationFilter: HTMLInputElement = await screen.findByRole('combobox', {
158
+ name: 'Organization',
159
+ hidden: true,
160
+ });
161
+
162
+ const user = userEvent.setup();
163
+ await user.click(organizationFilter);
164
+ const optionToSelect = screen.getByRole('option', { name: organizationList[1].title });
165
+
166
+ fetchMock.get(
167
+ `https://joanie.endpoint/api/v1.0/courses/${courseProductRelation.course.id}/orders/?${queryString.stringify({ organization_id: otherOrganization.id, ...courseOrderListQueryParams }, { sort: false })}`,
168
+ [],
169
+ );
170
+ await user.click(optionToSelect);
171
+ // onload default value is undefine and is onFiltersChange called once
172
+ expect(
173
+ fetchMock.called(
174
+ `https://joanie.endpoint/api/v1.0/courses/${courseProductRelation.course.id}/orders/?${queryString.stringify({ organization_id: otherOrganization.id, ...courseOrderListQueryParams }, { sort: false })}`,
175
+ ),
176
+ ).toBe(true);
177
+ });
178
+
179
+ it('should render a list of course learners for a course product relation', async () => {
180
+ const defaultOrganization = OrganizationFactory().one();
181
+ const otherOrganization = OrganizationFactory().one();
182
+ const organizationList = [defaultOrganization, otherOrganization];
183
+ const courseProductRelation = CourseProductRelationFactory().one();
184
+ const courseOrderList = NestedCourseOrderFactory().many(3);
185
+ // https://joanie.endpoint/api/v1.0/organizations/?course_product_relation_id=d390d6a9-8437-4f92-8897-982e12f97259
186
+ // Course sidebar queries
187
+ fetchMock.get(
188
+ `https://joanie.endpoint/api/v1.0/organizations/${defaultOrganization.id}/contracts/?course_product_relation_id=${courseProductRelation.id}&signature_state=half_signed&page=1&page_size=${PER_PAGE.teacherContractList}`,
189
+ [],
190
+ );
191
+ fetchMock.get(
192
+ `https://joanie.endpoint/api/v1.0/course-product-relations/${courseProductRelation.id}/`,
193
+ {},
194
+ );
195
+
196
+ fetchMock.get(
197
+ `https://joanie.endpoint/api/v1.0/organizations/?course_product_relation_id=${courseProductRelation.id}`,
198
+ organizationList,
199
+ );
200
+
201
+ // First request before finding default organizationId
202
+ const courseOrderListQueryParams = {
203
+ course_product_relation_id: courseProductRelation.id,
204
+ page: 1,
205
+ page_size: PER_PAGE.courseLearnerList,
206
+ };
207
+ fetchMock.get(
208
+ `https://joanie.endpoint/api/v1.0/courses/${courseProductRelation.course.id}/orders/?${queryString.stringify(courseOrderListQueryParams, { sort: false })}`,
209
+ courseOrderList,
210
+ );
211
+ // Second request when default organizationId is fetched
212
+ fetchMock.get(
213
+ `https://joanie.endpoint/api/v1.0/courses/${courseProductRelation.course.id}/orders/?${queryString.stringify({ organization_id: defaultOrganization.id, ...courseOrderListQueryParams }, { sort: false })}`,
214
+ courseOrderList,
215
+ );
216
+
217
+ render(<TeacherDashboardCourseLearnersLayout />, {
218
+ routerOptions: {
219
+ path: '/:courseId/:courseProductRelationId',
220
+ initialEntries: [`/${courseProductRelation.course.id}/${courseProductRelation.id}`],
221
+ },
222
+ });
223
+
224
+ await expectNoSpinner();
225
+
226
+ // Organization filter should have been rendered
227
+ const organizationFilter: HTMLInputElement = await screen.findByRole('combobox', {
228
+ name: 'Organization',
229
+ });
230
+ expect(organizationFilter).toHaveAttribute('value', '');
231
+
232
+ expect(screen.getByRole('table')).toBeInTheDocument();
233
+ // Table body should have been rendered with 3 rows (one per courseOrder)
234
+ // Table content is tested in CourseLearnerDataGrid
235
+ courseOrderList.forEach((courseOrder) => {
236
+ expect(screen.getByTestId(courseOrder.id)).toBeInTheDocument();
237
+ });
238
+ });
239
+
240
+ it('should render a list of course learners for an organization', async () => {
241
+ const organization = OrganizationFactory().one();
242
+ const courseProductRelation = CourseProductRelationFactory().one();
243
+ const courseOrderList = NestedCourseOrderFactory().many(3);
244
+
245
+ // Course sidebar queries
246
+ fetchMock.get(
247
+ `https://joanie.endpoint/api/v1.0/organizations/${organization.id}/contracts/?course_product_relation_id=${courseProductRelation.id}&signature_state=half_signed&page=1&page_size=${PER_PAGE.teacherContractList}`,
248
+ [],
249
+ );
250
+ fetchMock.get(
251
+ `https://joanie.endpoint/api/v1.0/organizations/${organization.id}/course-product-relations/${courseProductRelation.id}/`,
252
+ {},
253
+ );
254
+
255
+ // before default organization's fetched, we query all organization to decide if we should display organization filter or not.
256
+ fetchMock.get(
257
+ `https://joanie.endpoint/api/v1.0/organizations/?course_product_relation_id=${courseProductRelation.id}`,
258
+ [organization],
259
+ );
260
+
261
+ const courseOrderListQueryParams = {
262
+ organization_id: organization.id,
263
+ course_product_relation_id: courseProductRelation.id,
264
+ page: 1,
265
+ page_size: PER_PAGE.courseLearnerList,
266
+ };
267
+ fetchMock.get(
268
+ `https://joanie.endpoint/api/v1.0/courses/${courseProductRelation.course.id}/orders/?${queryString.stringify(courseOrderListQueryParams, { sort: false })}`,
269
+ courseOrderList,
270
+ );
271
+
272
+ render(<TeacherDashboardCourseLearnersLayout />, {
273
+ routerOptions: {
274
+ path: '/:organizationId/:courseId/:courseProductRelationId',
275
+ initialEntries: [
276
+ `/${organization.id}/${courseProductRelation.course.id}/${courseProductRelation.id}`,
277
+ ],
278
+ },
279
+ });
280
+
281
+ await expectNoSpinner();
282
+
283
+ // Organization filter should not have been rendered
284
+ const organizationFilter = screen.queryByRole('combobox', { name: 'Organization' });
285
+ expect(organizationFilter).not.toBeInTheDocument();
286
+
287
+ expect(screen.getByRole('table')).toBeInTheDocument();
288
+ // Table body should have been rendered with 3 rows (one per courseOrder)
289
+ // Table content is tested in CourseLearnerDataGrid
290
+ courseOrderList.forEach((courseOrder) => {
291
+ expect(screen.getByTestId(courseOrder.id)).toBeInTheDocument();
292
+ });
293
+ });
294
+
295
+ it('should render an empty table if there are no course learners', async () => {
296
+ const organization = OrganizationFactory().one();
297
+ const courseProductRelation = CourseProductRelationFactory().one();
298
+
299
+ // Course sidebar queries
300
+ fetchMock.get(
301
+ `https://joanie.endpoint/api/v1.0/organizations/${organization.id}/contracts/?course_product_relation_id=${courseProductRelation.id}&signature_state=half_signed&page=1&page_size=${PER_PAGE.teacherContractList}`,
302
+ [],
303
+ );
304
+ fetchMock.get(
305
+ `https://joanie.endpoint/api/v1.0/organizations/${organization.id}/course-product-relations/${courseProductRelation.id}/`,
306
+ {},
307
+ );
308
+
309
+ // before default organization's fetched, we query all organization to decide if we should display organization filter or not.
310
+ fetchMock.get(
311
+ `https://joanie.endpoint/api/v1.0/organizations/?course_product_relation_id=${courseProductRelation.id}`,
312
+ [organization],
313
+ );
314
+
315
+ const courseOrderListQueryParams = {
316
+ organization_id: organization.id,
317
+ course_product_relation_id: courseProductRelation.id,
318
+ page: 1,
319
+ page_size: PER_PAGE.courseLearnerList,
320
+ };
321
+ fetchMock.get(
322
+ `https://joanie.endpoint/api/v1.0/courses/${courseProductRelation.course.id}/orders/?${queryString.stringify(courseOrderListQueryParams, { sort: false })}`,
323
+ [],
324
+ );
325
+
326
+ render(<TeacherDashboardCourseLearnersLayout />, {
327
+ routerOptions: {
328
+ path: '/:organizationId/:courseId/:courseProductRelationId',
329
+ initialEntries: [
330
+ `/${organization.id}/${courseProductRelation.course.id}/${courseProductRelation.id}`,
331
+ ],
332
+ },
333
+ });
334
+
335
+ await expectNoSpinner();
336
+
337
+ // A message should have been rendered to inform the user that there are no contracts
338
+ screen.getByRole('img', { name: /illustration of an empty table/i });
339
+ screen.getByText(/this table is empty/i);
340
+ });
341
+
342
+ it('should render an error banner if an error occured during course learners fetching', async () => {
343
+ const organization = OrganizationFactory().one();
344
+ const courseProductRelation = CourseProductRelationFactory().one();
345
+
346
+ // Course sidebar queries
347
+ fetchMock.get(
348
+ `https://joanie.endpoint/api/v1.0/organizations/${organization.id}/contracts/?course_product_relation_id=${courseProductRelation.id}&signature_state=half_signed&page=1&page_size=${PER_PAGE.teacherContractList}`,
349
+ [],
350
+ );
351
+ fetchMock.get(
352
+ `https://joanie.endpoint/api/v1.0/organizations/${organization.id}/course-product-relations/${courseProductRelation.id}/`,
353
+ {},
354
+ );
355
+
356
+ // before default organization's fetched, we query all organization to decide if we should display organization filter or not.
357
+ fetchMock.get(
358
+ `https://joanie.endpoint/api/v1.0/organizations/?course_product_relation_id=${courseProductRelation.id}`,
359
+ [organization],
360
+ );
361
+
362
+ const courseOrderListQueryParams = {
363
+ organization_id: organization.id,
364
+ course_product_relation_id: courseProductRelation.id,
365
+ page: 1,
366
+ page_size: PER_PAGE.courseLearnerList,
367
+ };
368
+ fetchMock.get(
369
+ `https://joanie.endpoint/api/v1.0/courses/${courseProductRelation.course.id}/orders/?${queryString.stringify(courseOrderListQueryParams, { sort: false })}`,
370
+ new Response('', { status: HttpStatusCode.NOT_FOUND }),
371
+ );
372
+
373
+ render(<TeacherDashboardCourseLearnersLayout />, {
374
+ routerOptions: {
375
+ path: '/:organizationId/:courseId/:courseProductRelationId',
376
+ initialEntries: [
377
+ `/${organization.id}/${courseProductRelation.course.id}/${courseProductRelation.id}`,
378
+ ],
379
+ },
380
+ });
381
+
382
+ await expectNoSpinner();
383
+ await expectBannerError('An error occurred while fetching orders. Please retry later.');
384
+ });
385
+ });
@@ -0,0 +1,141 @@
1
+ import { FormattedMessage, defineMessages } from 'react-intl';
2
+
3
+ import { useParams, useSearchParams } from 'react-router-dom';
4
+ import { SortModel, usePagination } from '@openfun/cunningham-react';
5
+ import { useEffect, useMemo, useState } from 'react';
6
+ import { TeacherDashboardCourseSidebar } from 'widgets/Dashboard/components/TeacherDashboardCourseSidebar';
7
+ import { DashboardLayout } from 'widgets/Dashboard/components/DashboardLayout';
8
+ import { useCourseOrders } from 'hooks/useCourseOrders';
9
+ import { PER_PAGE } from 'settings';
10
+ import Banner, { BannerType } from 'components/Banner';
11
+ import { CourseOrderResourceQuery, Organization } from 'types/Joanie';
12
+ import { useOrganizations } from 'hooks/useOrganizations';
13
+ import { Spinner } from 'components/Spinner';
14
+ import CourseLearnerDataGrid from './components/CourseLearnerDataGrid';
15
+ import useCourseLearnersFilters from './hooks/useCourseLearnersFilters';
16
+ import CourseLearnersFiltersBar from './components/CourseLearnersFiltersBar';
17
+
18
+ const messages = defineMessages({
19
+ pageTitle: {
20
+ defaultMessage: 'Learners',
21
+ description: "Use for the page title of the course's contracts page",
22
+ id: 'pages.TeacherDashboardCourseLearnersLayout.pageTitle',
23
+ },
24
+ totalLearnerText: {
25
+ defaultMessage:
26
+ '{nbLearners} {nbLearners, plural, one {learner is enrolled} other {learners are enrolled}} for this training',
27
+ description: 'Text to indicate the total number of learner on a training',
28
+ id: 'pages.TeacherDashboardCourseLearnersLayout.totalLearnerText',
29
+ },
30
+ });
31
+
32
+ export const TeacherDashboardCourseLearnersLayout = () => {
33
+ const { organizationId: routeOrganizationId } = useParams<{
34
+ organizationId?: Organization['id'];
35
+ }>();
36
+ const [searchParams] = useSearchParams();
37
+ const page = searchParams.get('page') ?? '1';
38
+ const pagination = usePagination({
39
+ defaultPage: page ? parseInt(page, 10) : 1,
40
+ pageSize: PER_PAGE.teacherContractList,
41
+ });
42
+ const [sortModel, setSortModel] = useState<SortModel>([
43
+ {
44
+ field: 'created_on',
45
+ sort: 'desc',
46
+ },
47
+ ]);
48
+
49
+ // our list is always ordered by created_on asc or desc
50
+ // here we remove the neutral position that remove the "sortable" arrow icon.
51
+ const handleSetSortModel = (model: SortModel) => {
52
+ setSortModel(
53
+ model.length === 0
54
+ ? [
55
+ {
56
+ field: 'created_on',
57
+ sort: 'asc',
58
+ },
59
+ ]
60
+ : model,
61
+ );
62
+ };
63
+
64
+ const { filters, setFilters } = useCourseLearnersFilters();
65
+ const {
66
+ items: organizations,
67
+ states: { isFetched: isOrganizationFetched },
68
+ } = useOrganizations({ course_product_relation_id: filters.course_product_relation_id });
69
+ const {
70
+ items: courseOrders,
71
+ meta,
72
+ states: { fetching, isFetched, error },
73
+ } = useCourseOrders(
74
+ {
75
+ ...filters,
76
+ page: pagination.page,
77
+ page_size: PER_PAGE.courseLearnerList,
78
+ },
79
+ { enabled: isOrganizationFetched },
80
+ );
81
+
82
+ const showFilters = useMemo(() => {
83
+ return !routeOrganizationId && isOrganizationFetched && organizations.length > 1;
84
+ }, [isOrganizationFetched, organizations.length, routeOrganizationId]);
85
+
86
+ const onFiltersChange = (newFilters: Partial<CourseOrderResourceQuery>) => {
87
+ // Reset pagination
88
+ pagination.setPage(1);
89
+ setFilters((prevFilters) => ({ ...prevFilters, ...newFilters }));
90
+ };
91
+
92
+ useEffect(() => {
93
+ if (isFetched && meta?.pagination?.count) {
94
+ pagination.setPagesCount(Math.ceil(meta!.pagination!.count / PER_PAGE.teacherContractList));
95
+ }
96
+ }, [meta, isFetched]);
97
+
98
+ if (error) {
99
+ return <Banner message={error} type={BannerType.ERROR} rounded />;
100
+ }
101
+
102
+ if (!isOrganizationFetched) {
103
+ return <Spinner aria-labelledby="loading-organizations" />;
104
+ }
105
+
106
+ return (
107
+ <DashboardLayout sidebar={<TeacherDashboardCourseSidebar />}>
108
+ <div className="dashboard__page_title_container">
109
+ <h1 className="dashboard__page_title">
110
+ <FormattedMessage {...messages.pageTitle} />
111
+ </h1>
112
+ {!!meta?.pagination?.count && (
113
+ <div className="list__count-description">
114
+ <FormattedMessage
115
+ {...messages.totalLearnerText}
116
+ values={{ nbLearners: meta?.pagination.count }}
117
+ />
118
+ </div>
119
+ )}
120
+ </div>
121
+ <div className="teacher-training-learners-page">
122
+ {showFilters && (
123
+ <div className="dashboard__page__actions">
124
+ <CourseLearnersFiltersBar
125
+ defaultValues={filters}
126
+ organizationList={organizations}
127
+ onFiltersChange={onFiltersChange}
128
+ />
129
+ </div>
130
+ )}
131
+ <CourseLearnerDataGrid
132
+ courseOrders={courseOrders}
133
+ sortModel={sortModel}
134
+ setSortModel={handleSetSortModel}
135
+ pagination={pagination}
136
+ isLoading={fetching}
137
+ />
138
+ </div>
139
+ </DashboardLayout>
140
+ );
141
+ };
@@ -5,7 +5,7 @@ import * as testSettings from './settings.test';
5
5
  let settingsOverride = {};
6
6
  if (process.env.NODE_ENV === 'development') {
7
7
  try {
8
- settingsOverride = require('./settings.local.ts');
8
+ settingsOverride = require('./settings.dev.ts');
9
9
  } catch {
10
10
  // no local settings found, do nothing
11
11
  }
@@ -13,12 +13,6 @@ if (process.env.NODE_ENV === 'development') {
13
13
  settingsOverride = testSettings;
14
14
  }
15
15
 
16
- try {
17
- settingsOverride = require('./settings.local.ts');
18
- } catch {
19
- // no local settings found, do nothing
20
- }
21
-
22
16
  const settings = mergeWith({}, prodSettings, settingsOverride);
23
17
 
24
18
  export const {
@@ -54,6 +54,7 @@ export const CONTRACT_DOWNLOAD_SETTINGS = {
54
54
  const DEFAULT_PER_PAGE = 50;
55
55
  export const PER_PAGE = {
56
56
  teacherContractList: 25,
57
+ courseLearnerList: DEFAULT_PER_PAGE,
57
58
  useUnionResources: DEFAULT_PER_PAGE,
58
59
  useCourseProductUnion: DEFAULT_PER_PAGE,
59
60
  useOrdersEnrollments: DEFAULT_PER_PAGE,
@@ -18,6 +18,13 @@ export interface PaginatedParameters {
18
18
  offset: number;
19
19
  }
20
20
 
21
+ export interface UserLight {
22
+ id: string;
23
+ username: string;
24
+ full_name: string;
25
+ email: string;
26
+ }
27
+
21
28
  export interface Organization {
22
29
  id: string;
23
30
  code: string;
@@ -25,6 +32,10 @@ export interface Organization {
25
32
  logo: Nullable<JoanieFile>;
26
33
  }
27
34
 
35
+ export interface OrganizationResourceQuery extends ResourcesQuery {
36
+ course_product_relation_id?: CourseProductRelation['id'];
37
+ }
38
+
28
39
  export interface ContractDefinition {
29
40
  id: string;
30
41
  description: string;
@@ -47,6 +58,8 @@ export interface Contract {
47
58
  order: NestedCertificateOrder | NestedCredentialOrder;
48
59
  }
49
60
 
61
+ export type ContractLight = Pick<Contract, 'id' | 'organization_signed_on' | 'student_signed_on'>;
62
+
50
63
  export interface CourseListItem extends Resource {
51
64
  id: string;
52
65
  title: string;
@@ -134,6 +147,12 @@ export interface CourseProduct extends Product {
134
147
  order: Nullable<OrderLite>;
135
148
  }
136
149
 
150
+ export interface DefinitionResourcesProduct {
151
+ id: Product['id'];
152
+ certificate_definition_id: Nullable<CertificateDefinition['id']>;
153
+ contract_definition_id: Nullable<ContractDefinition['id']>;
154
+ }
155
+
137
156
  export interface CourseProductRelation {
138
157
  id: string;
139
158
  course: CourseLight;
@@ -301,6 +320,27 @@ export interface NestedCredentialOrder extends AbstractNestedOrder {
301
320
 
302
321
  export type OrderEnrollment = Pick<Order, 'id' | 'state' | 'product_id' | 'certificate_id'>;
303
322
 
323
+ export interface NestedCourseOrder {
324
+ id: Order['id'];
325
+ created_on: Order['created_on'];
326
+ owner: UserLight;
327
+ course_id: Order['id'];
328
+ product_id: Order['id'];
329
+ state: Order['state'];
330
+ enrollment_id: Enrollment['id'];
331
+ organization: Organization;
332
+ certificate_id?: Order['certificate_id'];
333
+ product: DefinitionResourcesProduct;
334
+ contract: ContractLight;
335
+ }
336
+
337
+ export interface CourseOrderResourceQuery extends PaginatedResourceQuery {
338
+ course_id?: CourseListItem['id'];
339
+ course_product_relation_id?: CourseProductRelation['id'];
340
+ organization_id?: Organization['id'];
341
+ product_id?: Product['id'];
342
+ }
343
+
304
344
  export interface OrderGroup {
305
345
  id: string;
306
346
  is_active: boolean;
@@ -557,6 +597,13 @@ export interface API {
557
597
  products: {
558
598
  get(filters?: CourseProductQueryFilters): Promise<Nullable<CourseProductRelation>>;
559
599
  };
600
+ orders: {
601
+ get(
602
+ filters?: CourseOrderResourceQuery,
603
+ ): CourseOrderResourceQuery extends { id: string }
604
+ ? Promise<Nullable<NestedCourseOrder>>
605
+ : Promise<PaginatedResponse<NestedCourseOrder>>;
606
+ };
560
607
  };
561
608
  organizations: {
562
609
  get<Filters extends ResourcesQuery = ResourcesQuery>(
@@ -4,6 +4,7 @@ import {
4
4
  Order,
5
5
  OrderState,
6
6
  ContractDefinition,
7
+ NestedCourseOrder,
7
8
  } from 'types/Joanie';
8
9
 
9
10
  /**
@@ -22,7 +23,10 @@ export class OrderHelper {
22
23
  /**
23
24
  * tell us if a order need to be sign by it's owner (the learner user).
24
25
  */
25
- static orderNeedsSignature(order: Order, contractDefinition?: ContractDefinition) {
26
+ static orderNeedsSignature(
27
+ order: Order | NestedCourseOrder,
28
+ contractDefinition?: ContractDefinition,
29
+ ) {
26
30
  return (
27
31
  order?.state === OrderState.VALIDATED &&
28
32
  contractDefinition &&
@@ -0,0 +1,13 @@
1
+ import { usePagination } from '@openfun/cunningham-react';
2
+ import { factory } from './factories';
3
+
4
+ export const PaginationFactory = factory((): ReturnType<typeof usePagination> => {
5
+ return {
6
+ page: 1,
7
+ setPage: jest.fn(),
8
+ onPageChange: jest.fn(),
9
+ pagesCount: 1,
10
+ setPagesCount: jest.fn(),
11
+ pageSize: 50,
12
+ };
13
+ });