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.
@@ -15,6 +15,10 @@
15
15
  margin-bottom: rem-calc(10px);
16
16
  }
17
17
 
18
+ &--fade {
19
+ opacity: 0.4;
20
+ }
21
+
18
22
  //
19
23
  // Course Glimpse in dashboards
20
24
  //
@@ -1,20 +1,10 @@
1
- import { MemoryRouter } from 'react-router-dom';
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 { IntlProvider } from 'react-intl';
6
-
7
- import { CourseListItem } from 'types/Joanie';
8
- import {
9
- RichieContextFactory as mockRichieContextFactory,
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 perPage = PER_PAGE.useCourseProductUnion;
31
+ const joanieSessionData = setupJoanieSession();
42
32
  let nbApiCalls: number;
43
33
  beforeEach(() => {
44
- fetchMock.get('https://joanie.endpoint/api/v1.0/orders/', [], { overwriteRoutes: true });
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 courseCooking: CourseListItem = CourseListItemFactory({
55
- title: 'One lesson about: How to cook birds',
56
- }).one();
57
- const courseDancing: CourseListItem = CourseListItemFactory({
58
- title: "One lesson about: Let's dance, the online lesson",
59
- }).one();
60
- fetchMock.get(
61
- `https://joanie.endpoint/api/v1.0/courses/?has_listed_course_runs=true&page=1&page_size=${perPage}`,
62
- mockPaginatedResponse([courseCooking, courseDancing], 15, false),
63
- );
64
- const productCooking: CourseListItem = CourseListItemFactory({
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
- const user = UserFactory().one();
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
- <IntlProvider locale="en">
78
- <QueryClientProvider client={createTestQueryClient({ user })}>
79
- <JoanieSessionProvider>
80
- <MemoryRouter>
81
- <TeacherDashboardCourseList titleTranslated="TeacherDashboardCourseList test title" />
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
- const calledUrls = fetchMock.calls().map((call) => call[0]);
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
- expect(
105
- await screen.findByRole('heading', { name: /One lesson about: How to cook birds/ }),
106
- ).toBeInTheDocument();
107
- expect(
108
- screen.getByRole('heading', { name: /One lesson about: Let's dance, the online lesson/ }),
109
- ).toBeInTheDocument();
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
- <IntlProvider locale="en">
137
- <QueryClientProvider client={createTestQueryClient({ user })}>
138
- <JoanieSessionProvider>
139
- <MemoryRouter>
140
- <TeacherDashboardCourseList titleTranslated="TeacherDashboardCourseList test title" />
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 { ProductType } from 'types/Joanie';
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: string;
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: next,
52
+ onIntersect: loadMore,
49
53
  enabled: hasMore,
50
54
  });
51
55
 
52
56
  return (
53
- <div className="dashboard-course-list">
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
- {isLoading && (
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={() => next()}
78
- disabled={isLoading}
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 { createMemoryRouter, RouterProvider } from 'react-router-dom';
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 { IntlProvider } from 'react-intl';
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
- // Joanie providers calls
45
- fetchMock.get('https://joanie.endpoint/api/v1.0/orders/', []);
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
- const user = UserFactory().one();
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.getByRole('heading', {
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 { defineMessages, useIntl } from 'react-intl';
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 intl = useIntl();
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 titleTranslated={intl.formatMessage(messages.courses)} />
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, useIntl } from 'react-intl';
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
- <TeacherDashboardCourseList
44
- titleTranslated={intl.formatMessage(messages.title, {
45
- organizationTitle: organization.title,
46
- })}
47
- organizationId={organization.id}
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
  );
@@ -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 = async (message: string, rootElement: ParentNode = document) => {
4
+ export const expectBannerError = (message: string, rootElement: ParentNode = document) => {
5
5
  return expectBanner(BannerType.ERROR, message, rootElement);
6
6
  };
7
- export const expectBannerInfo = async (message: string, rootElement: ParentNode = document) => {
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 = async (
11
+ export const expectBanner = (
12
12
  type: BannerType,
13
13
  message: string,
14
14
  rootElement: ParentNode = document,
15
15
  ) => {
16
- await waitFor(async () => {
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 = async (message: string) => {
23
+ export const expectNoBannerError = (message: string) => {
24
24
  return expectNoBanner(BannerType.ERROR, message);
25
25
  };
26
- export const expectNoBannerInfo = async (message: string) => {
26
+ export const expectNoBannerInfo = (message: string) => {
27
27
  return expectNoBanner(BannerType.INFO, message);
28
28
  };
29
29
 
30
- export const expectNoBanner = async (type: BannerType, message: string) => {
31
- await waitFor(() => {
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
- options,
10
- }: PropsWithChildren<{ options?: AppWrapperProps }>) => {
8
+ intlOptions,
9
+ routerOptions,
10
+ }: PropsWithChildren<{ intlOptions?: IntlWrapperProps; routerOptions?: RouterWrapperProps }>) => {
11
11
  return (
12
- <IntlWrapper {...(options?.intlOptions || { locale: 'en' })}>
12
+ <IntlWrapper {...(intlOptions || { locale: 'en' })}>
13
13
  <CunninghamProvider>
14
- <RouterWrapper {...options?.routerOptions}>{children}</RouterWrapper>
14
+ <RouterWrapper {...routerOptions}>{children}</RouterWrapper>
15
15
  </CunninghamProvider>
16
16
  </IntlWrapper>
17
17
  );
@@ -0,0 +1,12 @@
1
+ .dashboard-search-bar {
2
+ display: flex;
3
+ flex-direction: row;
4
+ align-items: center;
5
+ gap: rem-calc(8px);
6
+ margin-bottom: rem-calc(5px);
7
+
8
+ // TODO(rlecellier): cunningham should allow className on container
9
+ .c__field {
10
+ flex: 1 1 auto;
11
+ }
12
+ }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "richie-education",
3
- "version": "2.25.0-b2.dev131",
3
+ "version": "2.25.0-b2.dev138",
4
4
  "description": "A CMS to build learning portals for Open Education",
5
5
  "main": "sandbox/manage.py",
6
6
  "scripts": {
@@ -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 {
@@ -4,5 +4,9 @@
4
4
  color: r-theme-val(course-glimpse-list, count-color);
5
5
  text-align: right;
6
6
  align-self: self-end;
7
+
8
+ &--no-results {
9
+ visibility: hidden;
10
+ }
7
11
  }
8
12
  }
@@ -1,12 +0,0 @@
1
- /**
2
- * Available users:
3
- * * admin
4
- * * user0
5
- * * user1
6
- * * user2
7
- * * user3
8
- * * user4
9
- * * organization_owner
10
- * * student_user
11
- */
12
- export const CURRENT_JOANIE_DEV_DEMO_USER = 'student_user';