richie-education 2.25.0-b2.dev131 → 2.25.0-b2.dev138
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/js/components/TeacherDashboardCourseList/_styles.scss +4 -0
- package/js/components/TeacherDashboardCourseList/index.spec.tsx +55 -113
- package/js/components/TeacherDashboardCourseList/index.tsx +20 -14
- package/js/hooks/useCourseProductUnion/index.ts +4 -2
- package/js/hooks/useTeacherCoursesSearch/index.tsx +75 -0
- package/js/hooks/useUnionResource/index.ts +1 -1
- package/js/pages/DashboardCourses/index.spec.tsx +1 -1
- package/js/pages/TeacherDashboardCoursesLoader/index.spec.tsx +53 -38
- package/js/pages/TeacherDashboardCoursesLoader/index.tsx +24 -4
- package/js/pages/TeacherDashboardOrganizationCourseLoader/index.spec.tsx +152 -0
- package/js/pages/TeacherDashboardOrganizationCourseLoader/index.tsx +31 -8
- package/js/types/Joanie.ts +2 -0
- package/js/utils/test/expectBanner.ts +8 -8
- package/js/utils/test/wrappers/PresentationalAppWrapper.tsx +7 -7
- package/js/widgets/Dashboard/components/SearchBar/_styles.scss +12 -0
- package/js/widgets/Dashboard/components/SearchBar/index.spec.tsx +24 -0
- package/js/widgets/Dashboard/components/SearchBar/index.tsx +84 -0
- package/js/widgets/Dashboard/components/SearchResultsCount/index.spec.tsx +57 -0
- package/js/widgets/Dashboard/components/SearchResultsCount/index.tsx +32 -0
- package/package.json +1 -1
- package/scss/components/_index.scss +1 -0
- package/scss/objects/_dashboard.scss +13 -0
- package/scss/objects/_list.scss +4 -0
- package/js/settings.dev.ts +0 -12
|
@@ -1,20 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { QueryClientProvider } from '@tanstack/react-query';
|
|
3
|
-
import { render, screen } from '@testing-library/react';
|
|
1
|
+
import { screen } from '@testing-library/react';
|
|
4
2
|
import fetchMock from 'fetch-mock';
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
UserFactory,
|
|
11
|
-
} from 'utils/test/factories/richie';
|
|
12
|
-
import JoanieSessionProvider from 'contexts/SessionContext/JoanieSessionProvider';
|
|
13
|
-
import { CourseListItemFactory } from 'utils/test/factories/joanie';
|
|
14
|
-
import { createTestQueryClient } from 'utils/test/createTestQueryClient';
|
|
15
|
-
import { mockPaginatedResponse } from 'utils/test/mockPaginatedResponse';
|
|
16
|
-
import { expectNoSpinner } from 'utils/test/expectSpinner';
|
|
17
|
-
import { PER_PAGE } from 'settings';
|
|
3
|
+
import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
|
|
4
|
+
import { CourseListItemFactory, CourseProductRelationFactory } from 'utils/test/factories/joanie';
|
|
5
|
+
import { render } from 'utils/test/render';
|
|
6
|
+
import { setupJoanieSession } from 'utils/test/wrappers/JoanieAppWrapper';
|
|
7
|
+
import { expectNoSpinner, expectSpinner } from 'utils/test/expectSpinner';
|
|
18
8
|
import TeacherDashboardCourseList from '.';
|
|
19
9
|
|
|
20
10
|
jest.mock('utils/context', () => ({
|
|
@@ -38,125 +28,77 @@ jest.mock('hooks/useIntersectionObserver', () => ({
|
|
|
38
28
|
}));
|
|
39
29
|
|
|
40
30
|
describe('components/TeacherDashboardCourseList', () => {
|
|
41
|
-
const
|
|
31
|
+
const joanieSessionData = setupJoanieSession();
|
|
42
32
|
let nbApiCalls: number;
|
|
43
33
|
beforeEach(() => {
|
|
44
|
-
|
|
45
|
-
fetchMock.get('https://joanie.endpoint/api/v1.0/credit-cards/', [], { overwriteRoutes: true });
|
|
46
|
-
fetchMock.get('https://joanie.endpoint/api/v1.0/addresses/', [], { overwriteRoutes: true });
|
|
47
|
-
nbApiCalls = 3;
|
|
48
|
-
});
|
|
49
|
-
afterEach(() => {
|
|
50
|
-
fetchMock.restore();
|
|
34
|
+
nbApiCalls = joanieSessionData.nbSessionApiRequest;
|
|
51
35
|
});
|
|
52
36
|
|
|
53
|
-
it('should render', async () => {
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
title: 'Full training: How to cook birds',
|
|
66
|
-
}).one();
|
|
67
|
-
const productDancing: CourseListItem = CourseListItemFactory({
|
|
68
|
-
title: "Full training: Let's dance, the online lesson",
|
|
69
|
-
}).one();
|
|
70
|
-
fetchMock.get(
|
|
71
|
-
`https://joanie.endpoint/api/v1.0/course-product-relations/?product_type=credential&page=1&page_size=${perPage}`,
|
|
72
|
-
mockPaginatedResponse([productCooking, productDancing], 15, false),
|
|
37
|
+
it('should render loading more state', async () => {
|
|
38
|
+
const trainings = CourseProductRelationFactory().many(2);
|
|
39
|
+
const courses = CourseListItemFactory().many(2);
|
|
40
|
+
const courseAndProductList = [...courses, ...trainings];
|
|
41
|
+
|
|
42
|
+
render(
|
|
43
|
+
<TeacherDashboardCourseList
|
|
44
|
+
titleTranslated="TeacherDashboardCourseList test title"
|
|
45
|
+
isLoadingMore={true}
|
|
46
|
+
loadMore={jest.fn()}
|
|
47
|
+
courseAndProductList={courseAndProductList}
|
|
48
|
+
/>,
|
|
73
49
|
);
|
|
74
50
|
|
|
75
|
-
|
|
51
|
+
await expectSpinner('Loading courses...');
|
|
52
|
+
expect(fetchMock.calls()).toHaveLength(nbApiCalls);
|
|
53
|
+
|
|
54
|
+
courses.forEach((course) => {
|
|
55
|
+
expect(screen.getByRole('heading', { name: course.title })).toBeInTheDocument();
|
|
56
|
+
});
|
|
57
|
+
trainings.forEach((training) => {
|
|
58
|
+
expect(screen.getByRole('heading', { name: training.product.title })).toBeInTheDocument();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should render courses and products list', async () => {
|
|
63
|
+
const trainings = CourseProductRelationFactory().many(2);
|
|
64
|
+
const courses = CourseListItemFactory().many(2);
|
|
65
|
+
const courseAndProductList = [...courses, ...trainings];
|
|
66
|
+
|
|
76
67
|
render(
|
|
77
|
-
<
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
</MemoryRouter>
|
|
83
|
-
</JoanieSessionProvider>
|
|
84
|
-
</QueryClientProvider>
|
|
85
|
-
</IntlProvider>,
|
|
68
|
+
<TeacherDashboardCourseList
|
|
69
|
+
titleTranslated="TeacherDashboardCourseList test title"
|
|
70
|
+
loadMore={jest.fn()}
|
|
71
|
+
courseAndProductList={courseAndProductList}
|
|
72
|
+
/>,
|
|
86
73
|
);
|
|
87
|
-
nbApiCalls += 1; // courses api call
|
|
88
|
-
nbApiCalls += 1; // course-product-relations api call
|
|
89
74
|
|
|
90
75
|
await expectNoSpinner('Loading courses...');
|
|
76
|
+
|
|
91
77
|
expect(
|
|
92
78
|
screen.getByRole('heading', { name: /TeacherDashboardCourseList test title/ }),
|
|
93
79
|
).toBeInTheDocument();
|
|
94
80
|
|
|
95
|
-
|
|
96
|
-
expect(calledUrls).toHaveLength(nbApiCalls);
|
|
97
|
-
expect(calledUrls).toContain(
|
|
98
|
-
`https://joanie.endpoint/api/v1.0/courses/?has_listed_course_runs=true&page=1&page_size=${perPage}`,
|
|
99
|
-
);
|
|
100
|
-
expect(calledUrls).toContain(
|
|
101
|
-
`https://joanie.endpoint/api/v1.0/course-product-relations/?product_type=credential&page=1&page_size=${perPage}`,
|
|
102
|
-
);
|
|
81
|
+
expect(fetchMock.calls()).toHaveLength(nbApiCalls);
|
|
103
82
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
)
|
|
107
|
-
|
|
108
|
-
screen.getByRole('heading', { name:
|
|
109
|
-
)
|
|
110
|
-
expect(
|
|
111
|
-
screen.getByRole('heading', { name: /Full training: How to cook birds/ }),
|
|
112
|
-
).toBeInTheDocument();
|
|
113
|
-
expect(
|
|
114
|
-
screen.getByRole('heading', { name: /Full training: Let's dance, the online lesson/ }),
|
|
115
|
-
).toBeInTheDocument();
|
|
83
|
+
courses.forEach((course) => {
|
|
84
|
+
expect(screen.getByRole('heading', { name: course.title })).toBeInTheDocument();
|
|
85
|
+
});
|
|
86
|
+
trainings.forEach((training) => {
|
|
87
|
+
expect(screen.getByRole('heading', { name: training.product.title })).toBeInTheDocument();
|
|
88
|
+
});
|
|
116
89
|
});
|
|
117
90
|
|
|
118
91
|
it('should render empty list', async () => {
|
|
119
|
-
fetchMock.get(
|
|
120
|
-
`https://joanie.endpoint/api/v1.0/courses/?has_listed_course_runs=true&page=1&page_size=${perPage}`,
|
|
121
|
-
mockPaginatedResponse([], 0, false),
|
|
122
|
-
{
|
|
123
|
-
overwriteRoutes: true,
|
|
124
|
-
},
|
|
125
|
-
);
|
|
126
|
-
fetchMock.get(
|
|
127
|
-
`https://joanie.endpoint/api/v1.0/course-product-relations/?product_type=credential&page=1&page_size=${perPage}`,
|
|
128
|
-
mockPaginatedResponse([], 0, false),
|
|
129
|
-
{
|
|
130
|
-
overwriteRoutes: true,
|
|
131
|
-
},
|
|
132
|
-
);
|
|
133
|
-
|
|
134
|
-
const user = UserFactory().one();
|
|
135
92
|
render(
|
|
136
|
-
<
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
</MemoryRouter>
|
|
142
|
-
</JoanieSessionProvider>
|
|
143
|
-
</QueryClientProvider>
|
|
144
|
-
</IntlProvider>,
|
|
93
|
+
<TeacherDashboardCourseList
|
|
94
|
+
titleTranslated="TeacherDashboardCourseList test title"
|
|
95
|
+
loadMore={jest.fn()}
|
|
96
|
+
courseAndProductList={[]}
|
|
97
|
+
/>,
|
|
145
98
|
);
|
|
146
|
-
nbApiCalls += 1; // courses api call
|
|
147
|
-
nbApiCalls += 1; // course-product-relations api call
|
|
148
99
|
|
|
149
100
|
expect(await screen.getByRole('heading', { name: /TeacherDashboardCourseList test title/ }));
|
|
150
|
-
|
|
151
|
-
const calledUrls = fetchMock.calls().map((call) => call[0]);
|
|
152
|
-
expect(calledUrls).toHaveLength(nbApiCalls);
|
|
153
|
-
expect(calledUrls).toContain(
|
|
154
|
-
`https://joanie.endpoint/api/v1.0/courses/?has_listed_course_runs=true&page=1&page_size=${perPage}`,
|
|
155
|
-
);
|
|
156
|
-
expect(calledUrls).toContain(
|
|
157
|
-
`https://joanie.endpoint/api/v1.0/course-product-relations/?product_type=credential&page=1&page_size=${perPage}`,
|
|
158
|
-
);
|
|
159
|
-
|
|
101
|
+
expect(fetchMock.calls()).toHaveLength(nbApiCalls);
|
|
160
102
|
expect(await screen.findByText('You have no courses yet.')).toBeInTheDocument();
|
|
161
103
|
});
|
|
162
104
|
});
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|
2
2
|
import { useRef } from 'react';
|
|
3
3
|
import { Button } from '@openfun/cunningham-react';
|
|
4
|
+
import classNames from 'classnames';
|
|
4
5
|
import { CourseGlimpseList, getCourseGlimpseListProps } from 'components/CourseGlimpseList';
|
|
5
6
|
import { Spinner } from 'components/Spinner';
|
|
6
7
|
import context from 'utils/context';
|
|
7
|
-
import { useCourseProductUnion } from 'hooks/useCourseProductUnion';
|
|
8
8
|
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
|
|
9
|
-
import {
|
|
9
|
+
import { CourseListItem, CourseProductRelation } from 'types/Joanie';
|
|
10
10
|
|
|
11
11
|
const messages = defineMessages({
|
|
12
12
|
loading: {
|
|
@@ -27,30 +27,36 @@ const messages = defineMessages({
|
|
|
27
27
|
});
|
|
28
28
|
|
|
29
29
|
interface TeacherDashboardCourseListProps {
|
|
30
|
-
titleTranslated
|
|
30
|
+
titleTranslated?: string;
|
|
31
31
|
organizationId?: string;
|
|
32
|
+
loadMore: () => void;
|
|
33
|
+
courseAndProductList?: (CourseListItem | CourseProductRelation)[];
|
|
34
|
+
isLoadingMore?: boolean;
|
|
35
|
+
hasMore?: boolean;
|
|
36
|
+
isLoading?: boolean;
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
const TeacherDashboardCourseList = ({
|
|
35
40
|
titleTranslated,
|
|
36
41
|
organizationId,
|
|
42
|
+
loadMore,
|
|
43
|
+
courseAndProductList = [],
|
|
44
|
+
isLoading = false,
|
|
45
|
+
isLoadingMore = false,
|
|
46
|
+
hasMore = false,
|
|
37
47
|
}: TeacherDashboardCourseListProps) => {
|
|
38
48
|
const loadMoreButtonRef = useRef<HTMLButtonElement & HTMLAnchorElement>(null);
|
|
39
49
|
const intl = useIntl();
|
|
40
|
-
const {
|
|
41
|
-
data: courseAndProductList,
|
|
42
|
-
isLoading,
|
|
43
|
-
next,
|
|
44
|
-
hasMore,
|
|
45
|
-
} = useCourseProductUnion({ perPage: 25, organizationId, productType: ProductType.CREDENTIAL });
|
|
46
50
|
useIntersectionObserver({
|
|
47
51
|
target: loadMoreButtonRef,
|
|
48
|
-
onIntersect:
|
|
52
|
+
onIntersect: loadMore,
|
|
49
53
|
enabled: hasMore,
|
|
50
54
|
});
|
|
51
55
|
|
|
52
56
|
return (
|
|
53
|
-
<div
|
|
57
|
+
<div
|
|
58
|
+
className={classNames('dashboard-course-list', { 'dashboard-course-list--fade': isLoading })}
|
|
59
|
+
>
|
|
54
60
|
{titleTranslated && (
|
|
55
61
|
<h2 className="dashboard-course-list__title dashboard__page_title">{titleTranslated}</h2>
|
|
56
62
|
)}
|
|
@@ -64,7 +70,7 @@ const TeacherDashboardCourseList = ({
|
|
|
64
70
|
<FormattedMessage {...messages.emptyList} />
|
|
65
71
|
)}
|
|
66
72
|
|
|
67
|
-
{
|
|
73
|
+
{isLoadingMore && (
|
|
68
74
|
<Spinner aria-labelledby="loading-courses-data">
|
|
69
75
|
<span id="loading-courses-data">
|
|
70
76
|
<FormattedMessage {...messages.loading} />
|
|
@@ -74,8 +80,8 @@ const TeacherDashboardCourseList = ({
|
|
|
74
80
|
|
|
75
81
|
{hasMore && (
|
|
76
82
|
<Button
|
|
77
|
-
onClick={() =>
|
|
78
|
-
disabled={
|
|
83
|
+
onClick={() => loadMore()}
|
|
84
|
+
disabled={isLoadingMore}
|
|
79
85
|
ref={loadMoreButtonRef}
|
|
80
86
|
color="tertiary"
|
|
81
87
|
>
|
|
@@ -28,12 +28,14 @@ const messages = defineMessages({
|
|
|
28
28
|
interface UseCourseProductUnionProps extends ResourceUnionPaginationProps {
|
|
29
29
|
organizationId?: string;
|
|
30
30
|
productType?: ProductType;
|
|
31
|
+
query?: string;
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
export const useCourseProductUnion = ({
|
|
34
35
|
perPage = 50,
|
|
35
36
|
organizationId,
|
|
36
37
|
productType,
|
|
38
|
+
query,
|
|
37
39
|
}: UseCourseProductUnionProps = {}) => {
|
|
38
40
|
const api = useJoanieApi();
|
|
39
41
|
return useUnionResource<
|
|
@@ -45,12 +47,12 @@ export const useCourseProductUnion = ({
|
|
|
45
47
|
queryAConfig: {
|
|
46
48
|
queryKey: ['user', 'courses'],
|
|
47
49
|
fn: api.courses.get,
|
|
48
|
-
filters: { organization_id: organizationId, has_listed_course_runs: true },
|
|
50
|
+
filters: { query, organization_id: organizationId, has_listed_course_runs: true },
|
|
49
51
|
},
|
|
50
52
|
queryBConfig: {
|
|
51
53
|
queryKey: ['user', 'course_product_relations'],
|
|
52
54
|
fn: api.courseProductRelations.get,
|
|
53
|
-
filters: { organization_id: organizationId, product_type: productType },
|
|
55
|
+
filters: { query, organization_id: organizationId, product_type: productType },
|
|
54
56
|
},
|
|
55
57
|
perPage,
|
|
56
58
|
errorGetMessage: messages.errorGet,
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { useParams, useSearchParams } from 'react-router-dom';
|
|
3
|
+
import { useCourseProductUnion } from 'hooks/useCourseProductUnion';
|
|
4
|
+
import { CourseListItem, CourseProductRelation, ProductType } from 'types/Joanie';
|
|
5
|
+
import { Maybe, Nullable } from 'types/utils';
|
|
6
|
+
|
|
7
|
+
const useTeacherCoursesSearch = () => {
|
|
8
|
+
const { organizationId } = useParams<{ organizationId: string }>();
|
|
9
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
10
|
+
const [count, setCount] = useState<Maybe<number>>(0);
|
|
11
|
+
const [courseAndProductList, setCourseAndProductList] = useState<
|
|
12
|
+
(CourseListItem | CourseProductRelation)[]
|
|
13
|
+
>([]);
|
|
14
|
+
const [isNewSearchLoading, setIsNewSearchLoading] = useState(false);
|
|
15
|
+
const query = searchParams.get('query') || undefined;
|
|
16
|
+
const {
|
|
17
|
+
data,
|
|
18
|
+
isLoading,
|
|
19
|
+
next,
|
|
20
|
+
hasMore,
|
|
21
|
+
count: currentCount,
|
|
22
|
+
} = useCourseProductUnion({
|
|
23
|
+
query,
|
|
24
|
+
organizationId,
|
|
25
|
+
perPage: 25,
|
|
26
|
+
productType: ProductType.CREDENTIAL,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!data.length && isLoading) {
|
|
31
|
+
setIsNewSearchLoading(true);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (isLoading) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (isNewSearchLoading) {
|
|
39
|
+
setIsNewSearchLoading(false);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (isNewSearchLoading || data.length > courseAndProductList?.length) {
|
|
43
|
+
setCourseAndProductList(data);
|
|
44
|
+
|
|
45
|
+
// research counter should not be displayed when query is empty
|
|
46
|
+
if (query) {
|
|
47
|
+
setCount(currentCount);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}, [data.length, isLoading, isNewSearchLoading, query]);
|
|
51
|
+
|
|
52
|
+
const submitSearch = (newQuery: Nullable<string>) => {
|
|
53
|
+
if (newQuery === null) {
|
|
54
|
+
searchParams.delete('query');
|
|
55
|
+
} else {
|
|
56
|
+
searchParams.set('query', newQuery);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
setSearchParams(searchParams);
|
|
60
|
+
if (!newQuery) {
|
|
61
|
+
setCount(undefined);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
submitSearch,
|
|
67
|
+
data: courseAndProductList,
|
|
68
|
+
isNewSearchLoading,
|
|
69
|
+
isLoadingMore: isLoading && !isNewSearchLoading,
|
|
70
|
+
next,
|
|
71
|
+
hasMore,
|
|
72
|
+
count,
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
export default useTeacherCoursesSearch;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { useQueryClient } from '@tanstack/react-query';
|
|
3
3
|
import { MessageDescriptor, defineMessages, useIntl } from 'react-intl';
|
|
4
|
-
import { Maybe } from 'yup';
|
|
5
4
|
import { PaginatedResourceQuery, PaginatedResponse } from 'types/Joanie';
|
|
6
5
|
import { PER_PAGE } from 'settings';
|
|
7
6
|
import { useQueryKeyInvalidateListener } from 'hooks/useQueryKeyInvalidateListener';
|
|
7
|
+
import { Maybe } from 'types/utils';
|
|
8
8
|
import { syncIntegrityCount } from './utils/syncIntegrityCount';
|
|
9
9
|
import { FetchEntityData } from './utils/fetchEntities';
|
|
10
10
|
import { QueryConfig } from './utils/fetchEntity';
|
|
@@ -123,7 +123,7 @@ describe('<DashboardCourses/>', () => {
|
|
|
123
123
|
enrollmentsDeferred.resolve({ results: [], next: null, previous: null, count: 0 });
|
|
124
124
|
|
|
125
125
|
await expectNoSpinner('Loading orders and enrollments...');
|
|
126
|
-
expectBannerInfo('You have no enrollments nor orders yet.');
|
|
126
|
+
await expectBannerInfo('You have no enrollments nor orders yet.');
|
|
127
127
|
expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument();
|
|
128
128
|
});
|
|
129
129
|
|
|
@@ -1,20 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { QueryClientProvider } from '@tanstack/react-query';
|
|
3
|
-
import { render, screen } from '@testing-library/react';
|
|
1
|
+
import { screen, waitFor } from '@testing-library/react';
|
|
4
2
|
import fetchMock from 'fetch-mock';
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
import { CunninghamProvider } from '@openfun/cunningham-react';
|
|
8
|
-
import {
|
|
9
|
-
RichieContextFactory as mockRichieContextFactory,
|
|
10
|
-
UserFactory,
|
|
11
|
-
} from 'utils/test/factories/richie';
|
|
12
|
-
import JoanieSessionProvider from 'contexts/SessionContext/JoanieSessionProvider';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
|
|
13
5
|
import { CourseListItemFactory, CourseProductRelationFactory } from 'utils/test/factories/joanie';
|
|
14
|
-
import { createTestQueryClient } from 'utils/test/createTestQueryClient';
|
|
15
6
|
import { expectNoSpinner } from 'utils/test/expectSpinner';
|
|
16
7
|
import { mockPaginatedResponse } from 'utils/test/mockPaginatedResponse';
|
|
17
8
|
import { PER_PAGE } from 'settings';
|
|
9
|
+
import { setupJoanieSession } from 'utils/test/wrappers/JoanieAppWrapper';
|
|
10
|
+
import { render } from 'utils/test/render';
|
|
18
11
|
import { TeacherDashboardCoursesLoader } from '.';
|
|
19
12
|
|
|
20
13
|
jest.mock('utils/context', () => ({
|
|
@@ -38,17 +31,15 @@ jest.mock('hooks/useIntersectionObserver', () => ({
|
|
|
38
31
|
}));
|
|
39
32
|
|
|
40
33
|
describe('components/TeacherDashboardCoursesLoader', () => {
|
|
34
|
+
const joanieSessionData = setupJoanieSession();
|
|
41
35
|
const perPage = PER_PAGE.useCourseProductUnion;
|
|
42
36
|
let nbApiCalls: number;
|
|
43
37
|
beforeEach(() => {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
fetchMock.get('https://joanie.endpoint/api/v1.0/credit-cards/', []);
|
|
47
|
-
fetchMock.get('https://joanie.endpoint/api/v1.0/addresses/', []);
|
|
38
|
+
nbApiCalls = joanieSessionData.nbSessionApiRequest;
|
|
39
|
+
|
|
48
40
|
// teacher course sidebar calls
|
|
49
41
|
fetchMock.get('https://joanie.endpoint/api/v1.0/organizations/', []);
|
|
50
|
-
|
|
51
|
-
nbApiCalls = 4;
|
|
42
|
+
nbApiCalls += 1;
|
|
52
43
|
});
|
|
53
44
|
|
|
54
45
|
it('should render', async () => {
|
|
@@ -61,25 +52,7 @@ describe('components/TeacherDashboardCoursesLoader', () => {
|
|
|
61
52
|
mockPaginatedResponse(CourseProductRelationFactory().many(15), 15, false),
|
|
62
53
|
);
|
|
63
54
|
|
|
64
|
-
|
|
65
|
-
render(
|
|
66
|
-
<IntlProvider locale="en">
|
|
67
|
-
<QueryClientProvider client={createTestQueryClient({ user })}>
|
|
68
|
-
<JoanieSessionProvider>
|
|
69
|
-
<CunninghamProvider>
|
|
70
|
-
<RouterProvider
|
|
71
|
-
router={createMemoryRouter([
|
|
72
|
-
{
|
|
73
|
-
path: '',
|
|
74
|
-
element: <TeacherDashboardCoursesLoader />,
|
|
75
|
-
},
|
|
76
|
-
])}
|
|
77
|
-
/>
|
|
78
|
-
</CunninghamProvider>
|
|
79
|
-
</JoanieSessionProvider>
|
|
80
|
-
</QueryClientProvider>
|
|
81
|
-
</IntlProvider>,
|
|
82
|
-
);
|
|
55
|
+
render(<TeacherDashboardCoursesLoader />);
|
|
83
56
|
await expectNoSpinner('Loading courses...');
|
|
84
57
|
|
|
85
58
|
nbApiCalls += 1; // course api call
|
|
@@ -92,7 +65,7 @@ describe('components/TeacherDashboardCoursesLoader', () => {
|
|
|
92
65
|
|
|
93
66
|
// section titles
|
|
94
67
|
expect(
|
|
95
|
-
await screen.
|
|
68
|
+
await screen.findByRole('heading', {
|
|
96
69
|
name: 'Your courses',
|
|
97
70
|
}),
|
|
98
71
|
).toBeInTheDocument();
|
|
@@ -100,4 +73,46 @@ describe('components/TeacherDashboardCoursesLoader', () => {
|
|
|
100
73
|
// Lessons
|
|
101
74
|
expect(await screen.findAllByTestId('course-glimpse')).toHaveLength(25);
|
|
102
75
|
});
|
|
76
|
+
|
|
77
|
+
it('should perform search', async () => {
|
|
78
|
+
fetchMock.get(
|
|
79
|
+
`https://joanie.endpoint/api/v1.0/courses/?has_listed_course_runs=true&page=1&page_size=${perPage}`,
|
|
80
|
+
mockPaginatedResponse(CourseListItemFactory().many(15), 15, false),
|
|
81
|
+
);
|
|
82
|
+
fetchMock.get(
|
|
83
|
+
`https://joanie.endpoint/api/v1.0/course-product-relations/?product_type=credential&page=1&page_size=${perPage}`,
|
|
84
|
+
mockPaginatedResponse(CourseProductRelationFactory().many(15), 15, false),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
render(<TeacherDashboardCoursesLoader />);
|
|
88
|
+
await expectNoSpinner('Loading courses...');
|
|
89
|
+
fetchMock.restore();
|
|
90
|
+
|
|
91
|
+
fetchMock.get(
|
|
92
|
+
`https://joanie.endpoint/api/v1.0/courses/?query=text+query&has_listed_course_runs=true&page=1&page_size=${perPage}`,
|
|
93
|
+
mockPaginatedResponse(CourseListItemFactory().many(5), 5, false),
|
|
94
|
+
);
|
|
95
|
+
fetchMock.get(
|
|
96
|
+
`https://joanie.endpoint/api/v1.0/course-product-relations/?query=text+query&product_type=credential&page=1&page_size=${perPage}`,
|
|
97
|
+
mockPaginatedResponse(CourseProductRelationFactory().many(5), 5, false),
|
|
98
|
+
);
|
|
99
|
+
const user = userEvent.setup();
|
|
100
|
+
await user.type(screen.getByRole('textbox', { name: /Search/ }), 'text query');
|
|
101
|
+
await user.click(screen.getByRole('button', { name: /Search/ }));
|
|
102
|
+
|
|
103
|
+
nbApiCalls = 1; // course api call
|
|
104
|
+
nbApiCalls += 1; // course-product-relations api call
|
|
105
|
+
const calledUrls = fetchMock.calls().map((call) => call[0]);
|
|
106
|
+
expect(calledUrls).toHaveLength(nbApiCalls);
|
|
107
|
+
expect(calledUrls).toContain(
|
|
108
|
+
`https://joanie.endpoint/api/v1.0/courses/?query=text+query&has_listed_course_runs=true&page=1&page_size=${perPage}`,
|
|
109
|
+
);
|
|
110
|
+
expect(calledUrls).toContain(
|
|
111
|
+
`https://joanie.endpoint/api/v1.0/course-product-relations/?query=text+query&product_type=credential&page=1&page_size=${perPage}`,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
await waitFor(() => {
|
|
115
|
+
expect(screen.getAllByTestId('course-glimpse')).toHaveLength(10);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
103
118
|
});
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import { FormattedMessage, defineMessages } from 'react-intl';
|
|
3
2
|
import TeacherDashboardCourseList from 'components/TeacherDashboardCourseList';
|
|
4
3
|
import { DashboardLayout } from 'widgets/Dashboard/components/DashboardLayout';
|
|
5
4
|
import { TeacherDashboardProfileSidebar } from 'widgets/Dashboard/components/TeacherDashboardProfileSidebar';
|
|
5
|
+
import SearchBar from 'widgets/Dashboard/components/SearchBar';
|
|
6
|
+
import SearchResultsCount from 'widgets/Dashboard/components/SearchResultsCount';
|
|
7
|
+
import useTeacherCoursesSearch from 'hooks/useTeacherCoursesSearch';
|
|
6
8
|
|
|
7
9
|
const messages = defineMessages({
|
|
8
10
|
courses: {
|
|
@@ -28,11 +30,29 @@ const messages = defineMessages({
|
|
|
28
30
|
});
|
|
29
31
|
|
|
30
32
|
export const TeacherDashboardCoursesLoader = () => {
|
|
31
|
-
const
|
|
33
|
+
const { data, isLoadingMore, isNewSearchLoading, next, hasMore, submitSearch, count } =
|
|
34
|
+
useTeacherCoursesSearch();
|
|
35
|
+
|
|
32
36
|
return (
|
|
33
37
|
<DashboardLayout sidebar={<TeacherDashboardProfileSidebar />}>
|
|
38
|
+
<div className="dashboard__page_head">
|
|
39
|
+
<div className="dashboard__page_title_container">
|
|
40
|
+
<h1 className="dashboard__page_title">
|
|
41
|
+
<FormattedMessage {...messages.courses} />
|
|
42
|
+
</h1>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<SearchBar onSubmit={submitSearch} />
|
|
46
|
+
<SearchResultsCount nbResults={count} />
|
|
47
|
+
</div>
|
|
34
48
|
<div className="teacher-courses-page">
|
|
35
|
-
<TeacherDashboardCourseList
|
|
49
|
+
<TeacherDashboardCourseList
|
|
50
|
+
courseAndProductList={data}
|
|
51
|
+
loadMore={next}
|
|
52
|
+
isLoadingMore={isLoadingMore}
|
|
53
|
+
isLoading={isNewSearchLoading}
|
|
54
|
+
hasMore={hasMore}
|
|
55
|
+
/>
|
|
36
56
|
</div>
|
|
37
57
|
</DashboardLayout>
|
|
38
58
|
);
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import fetchMock from 'fetch-mock';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
|
|
5
|
+
import {
|
|
6
|
+
CourseListItemFactory,
|
|
7
|
+
CourseProductRelationFactory,
|
|
8
|
+
OrganizationFactory,
|
|
9
|
+
} from 'utils/test/factories/joanie';
|
|
10
|
+
import { expectNoSpinner } from 'utils/test/expectSpinner';
|
|
11
|
+
import { mockPaginatedResponse } from 'utils/test/mockPaginatedResponse';
|
|
12
|
+
import { PER_PAGE } from 'settings';
|
|
13
|
+
import { setupJoanieSession } from 'utils/test/wrappers/JoanieAppWrapper';
|
|
14
|
+
import { render } from 'utils/test/render';
|
|
15
|
+
import { TeacherDashboardOrganizationCourseLoader } from '.';
|
|
16
|
+
|
|
17
|
+
jest.mock('utils/context', () => ({
|
|
18
|
+
__esModule: true,
|
|
19
|
+
default: mockRichieContextFactory({
|
|
20
|
+
authentication: { backend: 'fonzie', endpoint: 'https://demo.endpoint' },
|
|
21
|
+
joanie_backend: { endpoint: 'https://joanie.endpoint' },
|
|
22
|
+
}).one(),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
jest.mock('settings', () => ({
|
|
26
|
+
__esModule: true,
|
|
27
|
+
...jest.requireActual('settings'),
|
|
28
|
+
PER_PAGE: { useCourseProductUnion: 25 },
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
jest.mock('hooks/useIntersectionObserver', () => ({
|
|
32
|
+
useIntersectionObserver: (props: any) => {
|
|
33
|
+
(globalThis as any).__intersection_observer_props__ = props;
|
|
34
|
+
},
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
describe('components/TeacherDashboardOrganizationCourseLoader', () => {
|
|
38
|
+
const joanieSessionData = setupJoanieSession();
|
|
39
|
+
const perPage = PER_PAGE.useCourseProductUnion;
|
|
40
|
+
let nbApiCalls: number;
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
nbApiCalls = joanieSessionData.nbSessionApiRequest;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should render', async () => {
|
|
46
|
+
const organization = OrganizationFactory().one();
|
|
47
|
+
fetchMock.get(
|
|
48
|
+
`https://joanie.endpoint/api/v1.0/organizations/${organization.id}/`,
|
|
49
|
+
organization,
|
|
50
|
+
);
|
|
51
|
+
nbApiCalls += 1;
|
|
52
|
+
|
|
53
|
+
fetchMock.get(
|
|
54
|
+
`https://joanie.endpoint/api/v1.0/organizations/${organization.id}/courses/?has_listed_course_runs=true&page=1&page_size=${perPage}`,
|
|
55
|
+
mockPaginatedResponse(CourseListItemFactory().many(15), 15, false),
|
|
56
|
+
);
|
|
57
|
+
fetchMock.get(
|
|
58
|
+
`https://joanie.endpoint/api/v1.0/organizations/${organization.id}/course-product-relations/?product_type=credential&page=1&page_size=${perPage}`,
|
|
59
|
+
mockPaginatedResponse(CourseProductRelationFactory().many(15), 15, false),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
render(<TeacherDashboardOrganizationCourseLoader />, {
|
|
63
|
+
routerOptions: {
|
|
64
|
+
path: '/:organizationId',
|
|
65
|
+
initialEntries: [`/${organization.id}`],
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
await expectNoSpinner('Loading courses...');
|
|
69
|
+
|
|
70
|
+
nbApiCalls += 1; // course api call
|
|
71
|
+
nbApiCalls += 1; // course-product-relations api call
|
|
72
|
+
const calledUrls = fetchMock.calls().map((call) => call[0]);
|
|
73
|
+
expect(calledUrls).toHaveLength(nbApiCalls);
|
|
74
|
+
expect(calledUrls).toContain(
|
|
75
|
+
`https://joanie.endpoint/api/v1.0/organizations/${organization.id}/courses/?has_listed_course_runs=true&page=1&page_size=${perPage}`,
|
|
76
|
+
);
|
|
77
|
+
expect(calledUrls).toContain(
|
|
78
|
+
`https://joanie.endpoint/api/v1.0/organizations/${organization.id}/course-product-relations/?product_type=credential&page=1&page_size=${perPage}`,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
await expectNoSpinner('Loading organization...');
|
|
82
|
+
|
|
83
|
+
expect(
|
|
84
|
+
screen.getByRole('heading', {
|
|
85
|
+
name: `Courses of ${organization.title}`,
|
|
86
|
+
}),
|
|
87
|
+
).toBeInTheDocument();
|
|
88
|
+
|
|
89
|
+
// Lessons
|
|
90
|
+
expect(await screen.findAllByTestId('course-glimpse')).toHaveLength(25);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should perform search', async () => {
|
|
94
|
+
const organization = OrganizationFactory().one();
|
|
95
|
+
fetchMock.get(
|
|
96
|
+
`https://joanie.endpoint/api/v1.0/organizations/${organization.id}/contracts/?signature_state=half_signed&page=1`,
|
|
97
|
+
[],
|
|
98
|
+
);
|
|
99
|
+
nbApiCalls += 1;
|
|
100
|
+
fetchMock.get(
|
|
101
|
+
`https://joanie.endpoint/api/v1.0/organizations/${organization.id}/`,
|
|
102
|
+
organization,
|
|
103
|
+
);
|
|
104
|
+
nbApiCalls += 1;
|
|
105
|
+
|
|
106
|
+
fetchMock.get(
|
|
107
|
+
`https://joanie.endpoint/api/v1.0/organizations/${organization.id}/courses/?has_listed_course_runs=true&page=1&page_size=${perPage}`,
|
|
108
|
+
mockPaginatedResponse(CourseListItemFactory().many(15), 15, false),
|
|
109
|
+
);
|
|
110
|
+
fetchMock.get(
|
|
111
|
+
`https://joanie.endpoint/api/v1.0/organizations/${organization.id}/course-product-relations/?product_type=credential&page=1&page_size=${perPage}`,
|
|
112
|
+
mockPaginatedResponse(CourseProductRelationFactory().many(15), 15, false),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
render(<TeacherDashboardOrganizationCourseLoader />, {
|
|
116
|
+
routerOptions: {
|
|
117
|
+
path: '/:organizationId',
|
|
118
|
+
initialEntries: [`/${organization.id}`],
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
await expectNoSpinner('Loading courses...');
|
|
122
|
+
await expectNoSpinner('Loading organization...');
|
|
123
|
+
fetchMock.restore();
|
|
124
|
+
|
|
125
|
+
fetchMock.get(
|
|
126
|
+
`https://joanie.endpoint/api/v1.0/organizations/${organization.id}/courses/?query=text+query&has_listed_course_runs=true&page=1&page_size=${perPage}`,
|
|
127
|
+
mockPaginatedResponse(CourseListItemFactory().many(5), 5, false),
|
|
128
|
+
);
|
|
129
|
+
fetchMock.get(
|
|
130
|
+
`https://joanie.endpoint/api/v1.0/organizations/${organization.id}/course-product-relations/?query=text+query&product_type=credential&page=1&page_size=${perPage}`,
|
|
131
|
+
mockPaginatedResponse(CourseProductRelationFactory().many(5), 5, false),
|
|
132
|
+
);
|
|
133
|
+
const user = userEvent.setup();
|
|
134
|
+
await user.type(screen.getByRole('textbox', { name: /Search/ }), 'text query');
|
|
135
|
+
await user.click(screen.getByRole('button', { name: /Search/ }));
|
|
136
|
+
|
|
137
|
+
nbApiCalls = 1; // course api call
|
|
138
|
+
nbApiCalls += 1; // course-product-relations api call
|
|
139
|
+
const calledUrls = fetchMock.calls().map((call) => call[0]);
|
|
140
|
+
expect(calledUrls).toHaveLength(nbApiCalls);
|
|
141
|
+
expect(calledUrls).toContain(
|
|
142
|
+
`https://joanie.endpoint/api/v1.0/organizations/${organization.id}/courses/?query=text+query&has_listed_course_runs=true&page=1&page_size=${perPage}`,
|
|
143
|
+
);
|
|
144
|
+
expect(calledUrls).toContain(
|
|
145
|
+
`https://joanie.endpoint/api/v1.0/organizations/${organization.id}/course-product-relations/?query=text+query&product_type=credential&page=1&page_size=${perPage}`,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
await waitFor(() => {
|
|
149
|
+
expect(screen.getAllByTestId('course-glimpse')).toHaveLength(10);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineMessages, FormattedMessage
|
|
1
|
+
import { defineMessages, FormattedMessage } from 'react-intl';
|
|
2
2
|
import { useParams } from 'react-router-dom';
|
|
3
3
|
import { Spinner } from 'components/Spinner';
|
|
4
4
|
import { DashboardLayout } from 'widgets/Dashboard/components/DashboardLayout';
|
|
@@ -6,6 +6,9 @@ import { TeacherDashboardOrganizationSidebar } from 'widgets/Dashboard/component
|
|
|
6
6
|
import { useOrganization } from 'hooks/useOrganizations';
|
|
7
7
|
import TeacherDashboardCourseList from 'components/TeacherDashboardCourseList';
|
|
8
8
|
import { useBreadcrumbsPlaceholders } from 'hooks/useBreadcrumbsPlaceholders';
|
|
9
|
+
import SearchResultsCount from 'widgets/Dashboard/components/SearchResultsCount';
|
|
10
|
+
import SearchBar from 'widgets/Dashboard/components/SearchBar';
|
|
11
|
+
import useTeacherCoursesSearch from 'hooks/useTeacherCoursesSearch';
|
|
9
12
|
|
|
10
13
|
const messages = defineMessages({
|
|
11
14
|
title: {
|
|
@@ -21,8 +24,10 @@ const messages = defineMessages({
|
|
|
21
24
|
});
|
|
22
25
|
|
|
23
26
|
export const TeacherDashboardOrganizationCourseLoader = () => {
|
|
24
|
-
const intl = useIntl();
|
|
25
27
|
const { organizationId } = useParams<{ organizationId: string }>();
|
|
28
|
+
const { data, isLoadingMore, isNewSearchLoading, next, hasMore, submitSearch, count } =
|
|
29
|
+
useTeacherCoursesSearch();
|
|
30
|
+
|
|
26
31
|
const {
|
|
27
32
|
item: organization,
|
|
28
33
|
states: { fetching },
|
|
@@ -30,6 +35,7 @@ export const TeacherDashboardOrganizationCourseLoader = () => {
|
|
|
30
35
|
useBreadcrumbsPlaceholders({
|
|
31
36
|
organizationTitle: organization?.title ?? '',
|
|
32
37
|
});
|
|
38
|
+
|
|
33
39
|
return (
|
|
34
40
|
<DashboardLayout sidebar={<TeacherDashboardOrganizationSidebar />}>
|
|
35
41
|
{fetching && (
|
|
@@ -40,12 +46,29 @@ export const TeacherDashboardOrganizationCourseLoader = () => {
|
|
|
40
46
|
</Spinner>
|
|
41
47
|
)}
|
|
42
48
|
{!fetching && (
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
<>
|
|
50
|
+
<div className="dashboard__page_head">
|
|
51
|
+
<div className="dashboard__page_title_container">
|
|
52
|
+
<h1 className="dashboard__page_title">
|
|
53
|
+
<FormattedMessage
|
|
54
|
+
{...messages.title}
|
|
55
|
+
values={{ organizationTitle: organization.title }}
|
|
56
|
+
/>
|
|
57
|
+
</h1>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<SearchBar onSubmit={submitSearch} />
|
|
61
|
+
<SearchResultsCount nbResults={count} />
|
|
62
|
+
</div>
|
|
63
|
+
<TeacherDashboardCourseList
|
|
64
|
+
organizationId={organization.id}
|
|
65
|
+
courseAndProductList={data}
|
|
66
|
+
loadMore={next}
|
|
67
|
+
isLoadingMore={isLoadingMore}
|
|
68
|
+
isLoading={isNewSearchLoading}
|
|
69
|
+
hasMore={hasMore}
|
|
70
|
+
/>
|
|
71
|
+
</>
|
|
49
72
|
)}
|
|
50
73
|
</DashboardLayout>
|
|
51
74
|
);
|
package/js/types/Joanie.ts
CHANGED
|
@@ -473,6 +473,7 @@ export interface CourseQueryFilters extends ResourcesQuery {
|
|
|
473
473
|
id?: CourseListItem['id'];
|
|
474
474
|
organization_id?: Organization['id'];
|
|
475
475
|
has_listed_course_runs?: Boolean;
|
|
476
|
+
query?: string;
|
|
476
477
|
}
|
|
477
478
|
export interface CourseProductQueryFilters extends ResourcesQuery {
|
|
478
479
|
id?: Product['id'];
|
|
@@ -482,6 +483,7 @@ export interface CourseProductRelationQueryFilters extends PaginatedResourceQuer
|
|
|
482
483
|
id?: CourseProductRelation['id'];
|
|
483
484
|
organization_id?: Organization['id'];
|
|
484
485
|
product_type?: ProductType;
|
|
486
|
+
query?: string;
|
|
485
487
|
}
|
|
486
488
|
|
|
487
489
|
export enum ContractState {
|
|
@@ -1,34 +1,34 @@
|
|
|
1
1
|
import { screen, waitFor } from '@testing-library/react';
|
|
2
2
|
import { BannerType, getBannerTestId } from 'components/Banner';
|
|
3
3
|
|
|
4
|
-
export const expectBannerError =
|
|
4
|
+
export const expectBannerError = (message: string, rootElement: ParentNode = document) => {
|
|
5
5
|
return expectBanner(BannerType.ERROR, message, rootElement);
|
|
6
6
|
};
|
|
7
|
-
export const expectBannerInfo =
|
|
7
|
+
export const expectBannerInfo = (message: string, rootElement: ParentNode = document) => {
|
|
8
8
|
return expectBanner(BannerType.INFO, message, rootElement);
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
-
export const expectBanner =
|
|
11
|
+
export const expectBanner = (
|
|
12
12
|
type: BannerType,
|
|
13
13
|
message: string,
|
|
14
14
|
rootElement: ParentNode = document,
|
|
15
15
|
) => {
|
|
16
|
-
|
|
16
|
+
return waitFor(async () => {
|
|
17
17
|
const banner = rootElement.querySelector('.banner--' + type) as HTMLElement;
|
|
18
18
|
expect(banner).not.toBeNull();
|
|
19
19
|
expect(banner).toHaveTextContent(message);
|
|
20
20
|
});
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
-
export const expectNoBannerError =
|
|
23
|
+
export const expectNoBannerError = (message: string) => {
|
|
24
24
|
return expectNoBanner(BannerType.ERROR, message);
|
|
25
25
|
};
|
|
26
|
-
export const expectNoBannerInfo =
|
|
26
|
+
export const expectNoBannerInfo = (message: string) => {
|
|
27
27
|
return expectNoBanner(BannerType.INFO, message);
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
-
export const expectNoBanner =
|
|
31
|
-
|
|
30
|
+
export const expectNoBanner = (type: BannerType, message: string) => {
|
|
31
|
+
return waitFor(() => {
|
|
32
32
|
expect(screen.queryByTestId(getBannerTestId(message, type))).toBeNull();
|
|
33
33
|
});
|
|
34
34
|
};
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { PropsWithChildren } from 'react';
|
|
2
2
|
import { CunninghamProvider } from '@openfun/cunningham-react';
|
|
3
|
-
import { IntlWrapper } from './IntlWrapper';
|
|
4
|
-
import { RouterWrapper } from './RouterWrapper';
|
|
5
|
-
import { AppWrapperProps } from './types';
|
|
3
|
+
import { IntlWrapper, IntlWrapperProps } from './IntlWrapper';
|
|
4
|
+
import { RouterWrapper, RouterWrapperProps } from './RouterWrapper';
|
|
6
5
|
|
|
7
6
|
export const PresentationalAppWrapper = ({
|
|
8
7
|
children,
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
intlOptions,
|
|
9
|
+
routerOptions,
|
|
10
|
+
}: PropsWithChildren<{ intlOptions?: IntlWrapperProps; routerOptions?: RouterWrapperProps }>) => {
|
|
11
11
|
return (
|
|
12
|
-
<IntlWrapper {...(
|
|
12
|
+
<IntlWrapper {...(intlOptions || { locale: 'en' })}>
|
|
13
13
|
<CunninghamProvider>
|
|
14
|
-
<RouterWrapper {...
|
|
14
|
+
<RouterWrapper {...routerOptions}>{children}</RouterWrapper>
|
|
15
15
|
</CunninghamProvider>
|
|
16
16
|
</IntlWrapper>
|
|
17
17
|
);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { screen } from '@testing-library/dom';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { render } from 'utils/test/render';
|
|
4
|
+
import { PresentationalAppWrapper } from 'utils/test/wrappers/PresentationalAppWrapper';
|
|
5
|
+
import SearchBar from '.';
|
|
6
|
+
|
|
7
|
+
describe('Dashbaord/components/SearchBar', () => {
|
|
8
|
+
it('should render', () => {
|
|
9
|
+
render(<SearchBar onSubmit={jest.fn()} />, { wrapper: PresentationalAppWrapper });
|
|
10
|
+
expect(screen.getByRole('textbox', { name: /Search/ })).toBeInTheDocument();
|
|
11
|
+
expect(screen.getByRole('button', { name: /Search/ })).toBeInTheDocument();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('should call onSubmit callback', async () => {
|
|
15
|
+
const onSubmit = jest.fn();
|
|
16
|
+
render(<SearchBar onSubmit={onSubmit} />, { wrapper: PresentationalAppWrapper });
|
|
17
|
+
|
|
18
|
+
const user = userEvent.setup();
|
|
19
|
+
await user.type(screen.getByRole('textbox', { name: /Search/ }), 'text query');
|
|
20
|
+
await user.click(screen.getByRole('button', { name: /Search/ }));
|
|
21
|
+
|
|
22
|
+
expect(onSubmit).toHaveBeenNthCalledWith(1, 'text query');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Button, Input } from '@openfun/cunningham-react';
|
|
2
|
+
import { MouseEvent, useRef, useState } from 'react';
|
|
3
|
+
import { defineMessages, useIntl } from 'react-intl';
|
|
4
|
+
import { useSearchParams } from 'react-router-dom';
|
|
5
|
+
import { Nullable } from 'types/utils';
|
|
6
|
+
|
|
7
|
+
const messages = defineMessages({
|
|
8
|
+
searchPlaceholder: {
|
|
9
|
+
defaultMessage: 'Search',
|
|
10
|
+
description: 'Placeholder of the dashboard search bar',
|
|
11
|
+
id: 'Dashboard.components.SearchBar.searchPlaceholder',
|
|
12
|
+
},
|
|
13
|
+
searchButtonLabel: {
|
|
14
|
+
defaultMessage: 'Search',
|
|
15
|
+
description: 'Label of the dashboard search bar submit button',
|
|
16
|
+
id: 'Dashboard.components.SearchBar.searchButtonLabel',
|
|
17
|
+
},
|
|
18
|
+
clearSearchButtonLabel: {
|
|
19
|
+
defaultMessage: 'clear current research',
|
|
20
|
+
description: 'Label of the dashboard search bar clear button',
|
|
21
|
+
id: 'Dashboard.components.SearchBar.clearSearchButtonLabel',
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
interface SearchBarProps {
|
|
26
|
+
onSubmit: (query: Nullable<string>) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const SearchBar = ({ onSubmit }: SearchBarProps) => {
|
|
30
|
+
const intl = useIntl();
|
|
31
|
+
const [searchParams] = useSearchParams();
|
|
32
|
+
const query = (searchParams.get('query') || '').trim();
|
|
33
|
+
const [innerQuery, setInnerQuery] = useState<string>(query);
|
|
34
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
35
|
+
|
|
36
|
+
const handleOnSubmit = (event: MouseEvent<HTMLButtonElement> & MouseEvent<HTMLAnchorElement>) => {
|
|
37
|
+
event.preventDefault();
|
|
38
|
+
onSubmit(innerQuery.trim() || null);
|
|
39
|
+
};
|
|
40
|
+
const clear = (event: MouseEvent<HTMLButtonElement> & MouseEvent<HTMLAnchorElement>) => {
|
|
41
|
+
event.stopPropagation();
|
|
42
|
+
|
|
43
|
+
setInnerQuery('');
|
|
44
|
+
onSubmit(null);
|
|
45
|
+
|
|
46
|
+
event.currentTarget.blur();
|
|
47
|
+
inputRef?.current?.blur();
|
|
48
|
+
};
|
|
49
|
+
return (
|
|
50
|
+
<form className="dashboard-search-bar">
|
|
51
|
+
<Input
|
|
52
|
+
ref={inputRef}
|
|
53
|
+
label={intl.formatMessage(messages.searchPlaceholder)}
|
|
54
|
+
value={innerQuery}
|
|
55
|
+
onChange={(e) => setInnerQuery(e.target.value)}
|
|
56
|
+
rightIcon={
|
|
57
|
+
query && (
|
|
58
|
+
<Button
|
|
59
|
+
className="dashboard-search-bar__input"
|
|
60
|
+
type="button"
|
|
61
|
+
size="small"
|
|
62
|
+
color="tertiary"
|
|
63
|
+
icon={<span className="material-icons">close</span>}
|
|
64
|
+
onClick={clear}
|
|
65
|
+
aria-label={intl.formatMessage(messages.clearSearchButtonLabel)}
|
|
66
|
+
/>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
tabIndex={0}
|
|
70
|
+
/>
|
|
71
|
+
|
|
72
|
+
<Button
|
|
73
|
+
className="dashboard-search-bar__input"
|
|
74
|
+
type="submit"
|
|
75
|
+
icon={<span className="material-icons">search</span>}
|
|
76
|
+
onClick={handleOnSubmit}
|
|
77
|
+
tabIndex={0}
|
|
78
|
+
aria-label={intl.formatMessage(messages.searchButtonLabel)}
|
|
79
|
+
/>
|
|
80
|
+
</form>
|
|
81
|
+
);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export default SearchBar;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { screen } from '@testing-library/dom';
|
|
2
|
+
import { render } from 'utils/test/render';
|
|
3
|
+
import { PresentationalAppWrapper } from 'utils/test/wrappers/PresentationalAppWrapper';
|
|
4
|
+
import SearchResultsCount from '.';
|
|
5
|
+
|
|
6
|
+
describe('Dashbaord/components/SearchResultsCount', () => {
|
|
7
|
+
it('should render singular message', () => {
|
|
8
|
+
render(<SearchResultsCount nbResults={1} />, {
|
|
9
|
+
wrapper: PresentationalAppWrapper,
|
|
10
|
+
routerOptions: {
|
|
11
|
+
initialEntries: ['/?query=test+query'],
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
const $text = screen.getByText('1 result matching your search');
|
|
15
|
+
expect($text).toBeInTheDocument();
|
|
16
|
+
expect($text).not.toHaveClass('list__count-description--no-results');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should render plural message', () => {
|
|
20
|
+
render(<SearchResultsCount nbResults={10} />, {
|
|
21
|
+
wrapper: PresentationalAppWrapper,
|
|
22
|
+
routerOptions: {
|
|
23
|
+
initialEntries: ['/?query=test+query'],
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
const $text = screen.getByText('10 results matching your search');
|
|
27
|
+
expect($text).toBeInTheDocument();
|
|
28
|
+
expect($text).not.toHaveClass('list__count-description--no-results');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it.each([0, undefined])(
|
|
32
|
+
'should render with visibility hidden when nbResults is %s',
|
|
33
|
+
(nbResults) => {
|
|
34
|
+
render(<SearchResultsCount nbResults={nbResults} />, {
|
|
35
|
+
wrapper: PresentationalAppWrapper,
|
|
36
|
+
routerOptions: {
|
|
37
|
+
initialEntries: ['/?query=test+query'],
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
const $text = screen.getByTestId('search-results-count');
|
|
41
|
+
expect($text).toBeInTheDocument();
|
|
42
|
+
expect($text).toHaveClass('list__count-description--no-results');
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
it('should render with visibility hidden when no research is active', () => {
|
|
47
|
+
render(<SearchResultsCount nbResults={undefined} />, {
|
|
48
|
+
wrapper: PresentationalAppWrapper,
|
|
49
|
+
routerOptions: {
|
|
50
|
+
initialEntries: ['/'],
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
const $text = screen.getByTestId('search-results-count');
|
|
54
|
+
expect($text).toBeInTheDocument();
|
|
55
|
+
expect($text).toHaveClass('list__count-description--no-results');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import classNames from 'classnames';
|
|
2
|
+
import { FormattedMessage, defineMessages } from 'react-intl';
|
|
3
|
+
import { useSearchParams } from 'react-router-dom';
|
|
4
|
+
|
|
5
|
+
const messages = defineMessages({
|
|
6
|
+
searchCountText: {
|
|
7
|
+
defaultMessage:
|
|
8
|
+
'{nbResults} {nbResults, plural, one {result} other {results}} matching your search',
|
|
9
|
+
description: 'Text to indicate the total number of results for a research',
|
|
10
|
+
id: 'Dashboard.components.SearchResultsCount.searchCountText',
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
interface SearchResultsCountProps {
|
|
15
|
+
nbResults?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const SearchResultsCount = ({ nbResults }: SearchResultsCountProps) => {
|
|
19
|
+
const [searchParams] = useSearchParams();
|
|
20
|
+
return (
|
|
21
|
+
<div
|
|
22
|
+
className={classNames('list__count-description', {
|
|
23
|
+
'list__count-description--no-results': !searchParams.get('query') || !nbResults,
|
|
24
|
+
})}
|
|
25
|
+
data-testid="search-results-count"
|
|
26
|
+
>
|
|
27
|
+
<FormattedMessage {...messages.searchCountText} values={{ nbResults }} />
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export default SearchResultsCount;
|
package/package.json
CHANGED
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
@import '../../js/widgets/Dashboard/components/TeacherDashboardCourseSidebar/styles';
|
|
41
41
|
@import '../../js/widgets/Dashboard/components/TeacherDashboardProfileSidebar/components/OrganizationLinks/styles';
|
|
42
42
|
@import '../../js/widgets/LtiConsumer/styles';
|
|
43
|
+
@import '../../js/widgets/Dashboard/components/SearchBar/styles';
|
|
43
44
|
@import '../../js/widgets/Search/components/SearchFilterGroup/styles';
|
|
44
45
|
@import '../../js/widgets/Search/components/SearchFilterGroupModal/styles';
|
|
45
46
|
@import '../../js/widgets/Search/components/SearchFiltersPane/styles';
|
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
.dashboard {
|
|
2
|
+
&__page_head {
|
|
3
|
+
display: flex;
|
|
4
|
+
flex-direction: column;
|
|
5
|
+
margin-bottom: rem-calc(4px);
|
|
6
|
+
|
|
7
|
+
&__search_bar_container {
|
|
8
|
+
margin-bottom: rem-calc(16px);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
2
12
|
&__page_title,
|
|
3
13
|
&__title--small,
|
|
4
14
|
&__title--large {
|
|
@@ -28,6 +38,9 @@
|
|
|
28
38
|
|
|
29
39
|
&__page_title_container {
|
|
30
40
|
margin-bottom: rem-calc(20px);
|
|
41
|
+
&:last-child {
|
|
42
|
+
margin-bottom: 0;
|
|
43
|
+
}
|
|
31
44
|
}
|
|
32
45
|
|
|
33
46
|
&__title_container--small {
|
package/scss/objects/_list.scss
CHANGED