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,62 @@
1
+ import { createIntl } from 'react-intl';
2
+ import { screen } from '@testing-library/react';
3
+ import { getAllByRole, within } from '@testing-library/dom';
4
+ import { faker } from '@faker-js/faker';
5
+ import { NestedCourseOrderFactory } from 'utils/test/factories/joanie';
6
+ import { expectNoSpinner } from 'utils/test/expectSpinner';
7
+ import { PaginationFactory } from 'utils/test/factories/cunningham';
8
+ import { DEFAULT_DATE_FORMAT } from 'hooks/useDateFormat';
9
+ import { OrderState } from 'types/Joanie';
10
+ import { render } from 'utils/test/render';
11
+ import { PresentationalAppWrapper } from 'utils/test/wrappers/PresentationalAppWrapper';
12
+ import CourseLearnerDataGrid from '.';
13
+
14
+ describe('pages/CourseLearnerDataGrid', () => {
15
+ it('should render a list of user', async () => {
16
+ const courseOrderList = NestedCourseOrderFactory({
17
+ state: OrderState.VALIDATED,
18
+ certificate_id: faker.string.uuid(),
19
+ }).many(3);
20
+ render(
21
+ <CourseLearnerDataGrid
22
+ courseOrders={courseOrderList}
23
+ sortModel={[
24
+ {
25
+ field: 'created_on',
26
+ sort: 'asc',
27
+ },
28
+ ]}
29
+ setSortModel={jest.fn()}
30
+ pagination={PaginationFactory().one()}
31
+ isLoading={false}
32
+ />,
33
+ { wrapper: PresentationalAppWrapper },
34
+ );
35
+
36
+ await expectNoSpinner();
37
+
38
+ // Table header should have been rendered with 5 columns
39
+ const columnHeaders = screen.getAllByRole('columnheader');
40
+ expect(columnHeaders.length).toBe(5);
41
+ // avatar column have no header
42
+ expect(columnHeaders[0]).toHaveTextContent('');
43
+ expect(columnHeaders[1]).toHaveTextContent('Learner');
44
+ expect(columnHeaders[2]).toHaveTextContent('Enrolled on');
45
+ expect(columnHeaders[3]).toHaveTextContent('State');
46
+ expect(columnHeaders[4]).toHaveTextContent('Actions');
47
+
48
+ const intl = createIntl({ locale: 'en' });
49
+ // Table body should have been rendered with 3 rows (one per contract)
50
+ courseOrderList.forEach((courseOrder) => {
51
+ const row = screen.getByTestId(courseOrder.id);
52
+ const cells = getAllByRole(row, 'cell');
53
+ expect(cells.length).toBe(5);
54
+ expect(cells[1]).toHaveTextContent(courseOrder.owner.full_name);
55
+ expect(cells[2]).toHaveTextContent(
56
+ intl.formatDate(new Date(courseOrder.created_on), DEFAULT_DATE_FORMAT),
57
+ );
58
+ expect(cells[3]).toHaveTextContent('Completed');
59
+ expect(within(cells[4]).getByText('Contact')).toBeInTheDocument();
60
+ });
61
+ });
62
+ });
@@ -0,0 +1,123 @@
1
+ import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
2
+ import { Button, DataGrid, DataGridProps, PaginationProps, Row } from '@openfun/cunningham-react';
3
+ import { useMemo } from 'react';
4
+ import { NestedCourseOrder } from 'types/Joanie';
5
+ import { DEFAULT_DATE_FORMAT } from 'hooks/useDateFormat';
6
+ import OrderStateMessage from 'widgets/Dashboard/components/DashboardItem/Order/OrderStateMessage';
7
+ import DashboardListAvatar from 'widgets/Dashboard/components/DashboardListAvatar';
8
+
9
+ const messages = defineMessages({
10
+ columnLearnerName: {
11
+ defaultMessage: 'Learner',
12
+ description: "Label for learner's name column",
13
+ id: 'pages.CourseLearnerDataGrid.columnLearnerName',
14
+ },
15
+ columnActions: {
16
+ defaultMessage: 'Actions',
17
+ description: 'Label for actions column',
18
+ id: 'pages.CourseLearnerDataGrid.columnActions',
19
+ },
20
+ columnPurchaseDate: {
21
+ defaultMessage: 'Enrolled on',
22
+ description: 'Label for enrolled date column',
23
+ id: 'pages.CourseLearnerDataGrid.columnPurchaseDate',
24
+ },
25
+ columnState: {
26
+ defaultMessage: 'State',
27
+ description: 'Label for state column',
28
+ id: 'pages.CourseLearnerDataGrid.columnState',
29
+ },
30
+ contactButton: {
31
+ defaultMessage: 'Contact',
32
+ description: 'Label for the contact learner button',
33
+ id: 'pages.CourseLearnerDataGrid.contactButton',
34
+ },
35
+ });
36
+
37
+ interface CourseLearnerDataGridProps {
38
+ courseOrders: NestedCourseOrder[];
39
+ sortModel: DataGridProps['sortModel'];
40
+ setSortModel: DataGridProps['onSortModelChange'];
41
+ pagination: PaginationProps;
42
+ isLoading: boolean;
43
+ }
44
+
45
+ const CourseLearnerDataGrid = ({
46
+ courseOrders,
47
+ sortModel,
48
+ setSortModel,
49
+ pagination,
50
+ isLoading,
51
+ }: CourseLearnerDataGridProps) => {
52
+ const intl = useIntl();
53
+
54
+ const columns = [
55
+ {
56
+ id: 'avatar',
57
+ enableSorting: false,
58
+ renderCell: (params: { row: Row }) => {
59
+ return <DashboardListAvatar title={params.row.owner__full_name} />;
60
+ },
61
+ },
62
+ {
63
+ field: 'owner__full_name',
64
+ headerName: intl.formatMessage(messages.columnLearnerName),
65
+ enableSorting: false,
66
+ },
67
+ {
68
+ field: 'created_on',
69
+ headerName: intl.formatMessage(messages.columnPurchaseDate),
70
+ enableSorting: false,
71
+ },
72
+ {
73
+ id: 'orderState',
74
+ headerName: intl.formatMessage(messages.columnState),
75
+ enableSorting: false,
76
+ renderCell: (params: { row: Row }) => {
77
+ // TODO(rlecellier): create NestedOrderCourseStateMessage that's get label dedicated to teatchers.
78
+ // like signature needed for studdent is awaiting signature for a teacher.
79
+ return (
80
+ <OrderStateMessage
81
+ order={params.row.courseOrder}
82
+ contractDefinition={params.row.courseOrder.product.contract_definition_id}
83
+ />
84
+ );
85
+ },
86
+ },
87
+ {
88
+ id: 'actions',
89
+ headerName: intl.formatMessage(messages.columnActions),
90
+ renderCell: (params: { row: Row }) => {
91
+ return (
92
+ <Button href={`mailto:${params.row.owner__email}`} size="small" color="secondary">
93
+ <FormattedMessage {...messages.contactButton} />
94
+ </Button>
95
+ );
96
+ },
97
+ enableSorting: false,
98
+ },
99
+ ];
100
+
101
+ const rows = useMemo(() => {
102
+ return courseOrders.map((courseOrder) => ({
103
+ id: courseOrder.id,
104
+ owner__full_name: courseOrder.owner.full_name || courseOrder.owner.username,
105
+ owner__email: courseOrder.owner.email,
106
+ created_on: intl.formatDate(new Date(courseOrder.created_on), DEFAULT_DATE_FORMAT),
107
+ courseOrder,
108
+ }));
109
+ }, [courseOrders]);
110
+
111
+ return (
112
+ <DataGrid
113
+ columns={columns}
114
+ rows={rows}
115
+ pagination={pagination}
116
+ sortModel={sortModel}
117
+ onSortModelChange={setSortModel}
118
+ isLoading={isLoading}
119
+ />
120
+ );
121
+ };
122
+
123
+ export default CourseLearnerDataGrid;
@@ -0,0 +1,70 @@
1
+ import { screen } from '@testing-library/react';
2
+ import { userEvent } from '@testing-library/user-event';
3
+ import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
4
+ import { ContractState } from 'types/Joanie';
5
+ import { OrganizationFactory } from 'utils/test/factories/joanie';
6
+ import { noop } from 'utils';
7
+ import { setupJoanieSession } from 'utils/test/wrappers/JoanieAppWrapper';
8
+ import { render } from 'utils/test/render';
9
+ import CourseLearnersFiltersBar from '.';
10
+
11
+ jest.mock('utils/context', () => ({
12
+ __esModule: true,
13
+ default: mockRichieContextFactory({
14
+ authentication: { backend: 'fonzie', endpoint: 'https://auth.endpoint.test' },
15
+ joanie_backend: { endpoint: 'https://joanie.endpoint' },
16
+ }).one(),
17
+ }));
18
+
19
+ describe('<CourseLearnersFiltersBar/>', () => {
20
+ setupJoanieSession();
21
+
22
+ it('should render organization filter with default value', async () => {
23
+ const filterChange = jest.fn();
24
+ const organizations = OrganizationFactory().many(2);
25
+ const defaultValues = {
26
+ signature_state: ContractState.LEARNER_SIGNED,
27
+ organization_id: organizations[1].id,
28
+ };
29
+
30
+ render(
31
+ <CourseLearnersFiltersBar
32
+ onFiltersChange={filterChange}
33
+ defaultValues={defaultValues}
34
+ organizationList={organizations}
35
+ />,
36
+ );
37
+
38
+ // Two selects should have rendered
39
+ const organizationFilter: HTMLInputElement = screen.getByRole('combobox', {
40
+ name: 'Organization',
41
+ });
42
+
43
+ expect(organizationFilter).toHaveAttribute('value', organizations[1].title);
44
+ expect(filterChange).not.toHaveBeenCalled();
45
+
46
+ // Now change value of organization filter should trigger the onFiltersChange callback
47
+ const user = userEvent.setup();
48
+ await user.click(organizationFilter);
49
+
50
+ const optionToSelect = screen.getByRole('option', { name: organizations[0].title });
51
+ await user.click(optionToSelect);
52
+ expect(filterChange).toHaveBeenNthCalledWith(1, { organization_id: organizations[0].id });
53
+ });
54
+
55
+ it('should allow to hide organization filter', async () => {
56
+ const organizations = OrganizationFactory().many(2);
57
+
58
+ render(
59
+ <CourseLearnersFiltersBar
60
+ onFiltersChange={noop}
61
+ hideFilterOrganization={true}
62
+ organizationList={organizations}
63
+ />,
64
+ );
65
+
66
+ // Organization filter should not have been rendered
67
+ const organizationFilter = screen.queryByRole('combobox', { name: 'Organization' });
68
+ expect(organizationFilter).not.toBeInTheDocument();
69
+ });
70
+ });
@@ -0,0 +1,31 @@
1
+ import { CourseOrderResourceQuery, Organization } from 'types/Joanie';
2
+ import FilterOrganization from 'widgets/Dashboard/components/FilterOrganization';
3
+ import FiltersBar from 'widgets/Dashboard/components/FiltersBar';
4
+
5
+ export interface CourseLearnersFiltersBarProps {
6
+ defaultValues?: CourseOrderResourceQuery;
7
+ onFiltersChange: (filters: Partial<CourseOrderResourceQuery>) => void;
8
+ organizationList: Organization[];
9
+ hideFilterOrganization?: boolean;
10
+ }
11
+ const CourseLearnersFiltersBar = ({
12
+ defaultValues,
13
+ onFiltersChange,
14
+ organizationList,
15
+ hideFilterOrganization,
16
+ }: CourseLearnersFiltersBarProps) => {
17
+ return (
18
+ !hideFilterOrganization && (
19
+ <FiltersBar>
20
+ <FilterOrganization
21
+ defaultValue={defaultValues?.organization_id}
22
+ organizationList={organizationList}
23
+ onChange={onFiltersChange}
24
+ clearable={true}
25
+ />
26
+ </FiltersBar>
27
+ )
28
+ );
29
+ };
30
+
31
+ export default CourseLearnersFiltersBar;
@@ -0,0 +1,158 @@
1
+ import fetchMock from 'fetch-mock';
2
+ import { renderHook, waitFor } from '@testing-library/react';
3
+ import { act } from 'react-dom/test-utils';
4
+ import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
5
+ import { CourseProductRelationFactory, OrganizationFactory } from 'utils/test/factories/joanie';
6
+ import { JoanieAppWrapper, setupJoanieSession } from 'utils/test/wrappers/JoanieAppWrapper';
7
+ import useCourseLearnersFilters from '.';
8
+
9
+ jest.mock('utils/context', () => ({
10
+ __esModule: true,
11
+ default: mockRichieContextFactory({
12
+ authentication: { backend: 'fonzie', endpoint: 'https://auth.test' },
13
+ joanie_backend: { endpoint: 'https://joanie.endpoint' },
14
+ }).one(),
15
+ }));
16
+
17
+ describe('useCourseLearnersFilters', () => {
18
+ setupJoanieSession();
19
+
20
+ it('should return default filter when called in a route without parameters', async () => {
21
+ const defaultOrganization = OrganizationFactory().one();
22
+ // fetching user's organizations to initialize default organizationId.
23
+ fetchMock.get('https://joanie.endpoint/api/v1.0/organizations/', [defaultOrganization]);
24
+ const { result } = renderHook(useCourseLearnersFilters, {
25
+ wrapper: ({ children }) => (
26
+ <JoanieAppWrapper routerOptions={{ path: '/', initialEntries: ['/'] }}>
27
+ {children}
28
+ </JoanieAppWrapper>
29
+ ),
30
+ });
31
+
32
+ await waitFor(() => {
33
+ expect(result.current.initialFilters).toStrictEqual({
34
+ organization_id: undefined,
35
+ course_id: undefined,
36
+ course_product_relation_id: undefined,
37
+ });
38
+ expect(result.current.filters).toStrictEqual({
39
+ organization_id: undefined,
40
+ course_id: undefined,
41
+ course_product_relation_id: undefined,
42
+ });
43
+ });
44
+ });
45
+
46
+ it('should use route parameters values when given', async () => {
47
+ const defaultOrganization = OrganizationFactory().one();
48
+ const filteredOrganization = OrganizationFactory({ id: 'filtered' }).one();
49
+ const routeOrganization = OrganizationFactory({ id: 'route' }).one();
50
+ const routeCourseProductRelation = CourseProductRelationFactory().one();
51
+ // fetching user's organizations to initialize default organizationId.
52
+ fetchMock.get('https://joanie.endpoint/api/v1.0/organizations/', [
53
+ defaultOrganization,
54
+ filteredOrganization,
55
+ ]);
56
+ const { result } = renderHook(useCourseLearnersFilters, {
57
+ wrapper: ({ children }) => (
58
+ <JoanieAppWrapper
59
+ routerOptions={{
60
+ path: '/:organizationId/:courseId/:courseProductRelationId',
61
+ initialEntries: [
62
+ `/${routeOrganization.id}/${routeCourseProductRelation.course.id}/${routeCourseProductRelation.id}?organization_id=${filteredOrganization.id}`,
63
+ ],
64
+ }}
65
+ >
66
+ {children}
67
+ </JoanieAppWrapper>
68
+ ),
69
+ });
70
+
71
+ await waitFor(() => {
72
+ expect(result.current.initialFilters).toStrictEqual({
73
+ organization_id: routeOrganization.id,
74
+ course_id: routeCourseProductRelation.course.id,
75
+ course_product_relation_id: routeCourseProductRelation.id,
76
+ });
77
+ expect(result.current.filters).toStrictEqual({
78
+ organization_id: routeOrganization.id,
79
+ course_id: routeCourseProductRelation.course.id,
80
+ course_product_relation_id: routeCourseProductRelation.id,
81
+ });
82
+ });
83
+ });
84
+
85
+ it("should use organizationId from query parameters when it's not in route params", async () => {
86
+ const defaultOrganization = OrganizationFactory().one();
87
+ const filteredOrganization = OrganizationFactory({ id: 'filtered' }).one();
88
+ const routeCourseProductRelation = CourseProductRelationFactory({ id: 'route' }).one();
89
+ // fetching user's organizations to initialize default organizationId.
90
+ fetchMock.get('https://joanie.endpoint/api/v1.0/organizations/', [
91
+ defaultOrganization,
92
+ filteredOrganization,
93
+ ]);
94
+ const { result } = renderHook(useCourseLearnersFilters, {
95
+ wrapper: ({ children }) => (
96
+ <JoanieAppWrapper
97
+ routerOptions={{
98
+ path: '/:courseId/:courseProductRelationId',
99
+ initialEntries: [
100
+ `/${routeCourseProductRelation.course.id}/${routeCourseProductRelation.id}/?organization_id=${filteredOrganization.id}`,
101
+ ],
102
+ }}
103
+ >
104
+ {children}
105
+ </JoanieAppWrapper>
106
+ ),
107
+ });
108
+
109
+ await waitFor(() => {
110
+ expect(result.current.initialFilters).toStrictEqual({
111
+ organization_id: filteredOrganization.id,
112
+ course_id: routeCourseProductRelation.course.id,
113
+ course_product_relation_id: routeCourseProductRelation.id,
114
+ });
115
+ expect(result.current.filters).toStrictEqual({
116
+ organization_id: filteredOrganization.id,
117
+ course_id: routeCourseProductRelation.course.id,
118
+ course_product_relation_id: routeCourseProductRelation.id,
119
+ });
120
+ });
121
+ });
122
+
123
+ it('setFilters should update filter state', async () => {
124
+ const defaultOrganization = OrganizationFactory({ id: 'all' }).one();
125
+ const routeOrganization = OrganizationFactory({ id: 'route' }).one();
126
+ const routeCourseProductRelation = CourseProductRelationFactory().one();
127
+ // fetching user's organizations to initialize default organizationId.
128
+ fetchMock.get('https://joanie.endpoint/api/v1.0/organizations/', [defaultOrganization]);
129
+ const { result } = renderHook(useCourseLearnersFilters, {
130
+ wrapper: ({ children }) => (
131
+ <JoanieAppWrapper routerOptions={{ path: '/', initialEntries: ['/'] }}>
132
+ {children}
133
+ </JoanieAppWrapper>
134
+ ),
135
+ });
136
+
137
+ const expectedInitialFilters = {
138
+ organization_id: undefined,
139
+ course_id: undefined,
140
+ course_product_relation_id: undefined,
141
+ };
142
+ await waitFor(() => {
143
+ expect(result.current.initialFilters).toStrictEqual(expectedInitialFilters);
144
+ });
145
+
146
+ const newFilters = {
147
+ organization_id: routeOrganization.id,
148
+ course_id: routeCourseProductRelation.course.id,
149
+ course_product_relation_id: routeCourseProductRelation.id,
150
+ };
151
+ act(() => {
152
+ result.current.setFilters(newFilters);
153
+ });
154
+
155
+ expect(result.current.filters).toStrictEqual(newFilters);
156
+ expect(result.current.initialFilters).toStrictEqual(expectedInitialFilters);
157
+ });
158
+ });
@@ -0,0 +1,44 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import { useParams, useSearchParams } from 'react-router-dom';
3
+ import {
4
+ CourseListItem,
5
+ CourseOrderResourceQuery,
6
+ CourseProductRelation,
7
+ Organization,
8
+ } from 'types/Joanie';
9
+
10
+ export type CourseLearnersParams = {
11
+ courseId: CourseListItem['id'];
12
+ courseProductRelationId?: CourseProductRelation['id'];
13
+ organizationId?: Organization['id'];
14
+ };
15
+
16
+ const useCourseLearnersFilters = () => {
17
+ const { courseId, courseProductRelationId, organizationId } = useParams<CourseLearnersParams>();
18
+ const [searchParams] = useSearchParams();
19
+ const searchFilters: CourseOrderResourceQuery = useMemo(() => {
20
+ return {
21
+ course_id: courseId,
22
+ organization_id: searchParams.get('organization_id') || undefined,
23
+ course_product_relation_id: searchParams.get('course_product_relation_id') || undefined,
24
+ };
25
+ }, Array.from(searchParams.entries()));
26
+
27
+ const initialFilters = useMemo(() => {
28
+ return {
29
+ ...searchFilters,
30
+ organization_id: organizationId || searchFilters.organization_id,
31
+ course_product_relation_id: courseProductRelationId,
32
+ };
33
+ }, []);
34
+ const [filters, setFilters] = useState<CourseOrderResourceQuery>(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 useCourseLearnersFilters;