From 326c1f7ff6cb27d0f83d55678371ab47697d7d8b Mon Sep 17 00:00:00 2001 From: Romain Le Cellier Date: Mon, 24 Apr 2023 14:59:01 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20build=20page=20TeacherCou?= =?UTF-8?q?rse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of teacher dashboard development, list teacher courses. --- CHANGELOG.md | 3 +- src/frontend/js/api/joanie.ts | 18 ++ .../mocks/joanie/assets/course_cover_001.jpg | Bin 0 -> 48783 bytes .../mocks/joanie/assets/course_icon_001.png | Bin 0 -> 2673 bytes src/frontend/js/api/mocks/joanie/courses.ts | 48 ++++++ .../CourseGlimpse/CourseGlimpseFooter.tsx | 14 +- .../components/CourseGlimpse/index.spec.tsx | 40 ++--- .../js/components/CourseGlimpse/index.tsx | 49 ++++-- .../js/components/CourseGlimpse/utils.ts | 43 +++++ .../CourseGlimpseList/index.spec.tsx | 27 ++- .../js/components/CourseGlimpseList/index.tsx | 74 +++++---- .../js/components/CourseGlimpseList/utils.ts | 9 + .../DashboardCourseList/_styles.scss | 40 +++++ .../DashboardCourseList/index.spec.tsx | 89 ++++++++++ .../components/DashboardCourseList/index.tsx | 62 +++++++ .../js/hooks/useCourses/index.spec.tsx | 152 +++++++++++++++++ src/frontend/js/hooks/useCourses/index.ts | 69 ++++++++ .../index.spec.tsx | 157 ++++++++++++++++++ .../TeacherCoursesDashboardLoader/index.tsx | 77 ++++++++- .../index.tsx | 2 +- src/frontend/js/types/Course.ts | 14 +- src/frontend/js/types/Joanie.ts | 14 ++ src/frontend/js/types/commonDataProps.ts | 22 +-- src/frontend/js/types/index.ts | 4 +- src/frontend/js/types/utils.ts | 6 + .../js/utils/test/factories/joanie.ts | 2 + .../components/DashboardItem/stories.mock.ts | 5 +- .../TeacherCourseSearchFilters/_styles.scss | 14 ++ .../TeacherCourseSearchFilters/index.tsx | 151 +++++++++++++++++ src/frontend/js/widgets/Search/index.tsx | 4 +- src/frontend/mocks/handlers.ts | 3 +- src/frontend/scss/colors/_theme.scss | 3 + src/frontend/scss/components/_index.scss | 2 + .../scss/objects/_course_glimpses.scss | 12 +- 34 files changed, 1120 insertions(+), 109 deletions(-) create mode 100644 src/frontend/js/api/mocks/joanie/assets/course_cover_001.jpg create mode 100644 src/frontend/js/api/mocks/joanie/assets/course_icon_001.png create mode 100644 src/frontend/js/api/mocks/joanie/courses.ts create mode 100644 src/frontend/js/components/CourseGlimpse/utils.ts create mode 100644 src/frontend/js/components/CourseGlimpseList/utils.ts create mode 100644 src/frontend/js/components/DashboardCourseList/_styles.scss create mode 100644 src/frontend/js/components/DashboardCourseList/index.spec.tsx create mode 100644 src/frontend/js/components/DashboardCourseList/index.tsx create mode 100644 src/frontend/js/hooks/useCourses/index.spec.tsx create mode 100644 src/frontend/js/hooks/useCourses/index.ts create mode 100644 src/frontend/js/pages/TeacherCoursesDashboardLoader/index.spec.tsx create mode 100644 src/frontend/js/widgets/Dashboard/components/TeacherCourseSearchFilters/_styles.scss create mode 100644 src/frontend/js/widgets/Dashboard/components/TeacherCourseSearchFilters/index.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index f8a890619f..6638f83150 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,10 @@ Versioning](https://semver.org/spec/v2.0.0.html). ### Added - Add head_js block into base html template -- list teacher's organizations in the teacher dashboard sidebar. +- List teacher's courses in the teacher courses dashboard page. - Added Certificates in the dashboard - Dashboard infinite scroll of orders and enrollments +- list teacher's courses in the teacher courses dashboard page. ### Fixed diff --git a/src/frontend/js/api/joanie.ts b/src/frontend/js/api/joanie.ts index 599d8517bd..198be9352b 100644 --- a/src/frontend/js/api/joanie.ts +++ b/src/frontend/js/api/joanie.ts @@ -151,6 +151,9 @@ const getRoutes = () => { courseRuns: { get: `${baseUrl}/course-runs/:id/`, }, + courses: { + get: `${baseUrl}/courses/`, + }, }; }; @@ -343,6 +346,21 @@ const API = (): Joanie.API => { url += '?' + queryString.stringify(queryParameters); } + return fetchWithJWT(url).then(checkStatus); + }, + }, + courses: { + get: (filters) => { + const { id, ...queryParameters } = filters || {}; + let url; + + if (id) url = ROUTES.courses.get.replace(':id', id); + else url = ROUTES.courses.get.replace(':id/', ''); + + if (!ObjectHelper.isEmpty(queryParameters)) { + url += '?' + queryString.stringify(queryParameters); + } + return fetchWithJWT(url).then(checkStatus); }, }, diff --git a/src/frontend/js/api/mocks/joanie/assets/course_cover_001.jpg b/src/frontend/js/api/mocks/joanie/assets/course_cover_001.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a0fd28b015c3b3cc09a2d4552b114553bee6d62a GIT binary patch literal 48783 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 literal 0 HcmV?d00001 diff --git a/src/frontend/js/api/mocks/joanie/assets/course_icon_001.png b/src/frontend/js/api/mocks/joanie/assets/course_icon_001.png new file mode 100644 index 0000000000000000000000000000000000000000..35cd9b03d85afbe7dcf29db21b5ea0f56f7999fc GIT binary patch literal 2673 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 literal 0 HcmV?d00001 diff --git a/src/frontend/js/api/mocks/joanie/courses.ts b/src/frontend/js/api/mocks/joanie/courses.ts new file mode 100644 index 0000000000..8706c46e01 --- /dev/null +++ b/src/frontend/js/api/mocks/joanie/courses.ts @@ -0,0 +1,48 @@ +import { rest } from 'msw'; +import type { CourseRun } from 'types/Joanie'; +import { getAPIEndpoint } from 'api/joanie'; +import { CourseFactory } from 'utils/test/factories/joanie'; +import { Nullable } from 'types/utils'; +import { CourseState } from 'types'; +import { Resource } from 'types/Resource'; +import { OrganizationMock } from './organizations'; + +export interface CourseListItemMock extends Resource { + id: string; + title: string; + code: string; + organization: OrganizationMock; + course_runs: CourseRun['id'][]; + cover: Nullable<{ + filename: string; + url: string; + height: number; + width: number; + }>; + state: CourseState; +} + +export const listCourses = rest.get( + `${getAPIEndpoint()}/courses/`, + (req, res, ctx) => { + const queryPerPage = req.url.searchParams.get('per_page'); + const perPage = queryPerPage === null ? 6 : parseInt(queryPerPage, 10); + + const organizationCover001 = require('./assets/organization_cover_001.jpg'); + const courseCover001 = require('./assets/course_cover_001.jpg'); + const courses: CourseListItemMock[] = CourseFactory.generate(perPage).map( + (course: CourseListItemMock) => ({ + ...course, + organization: { + title: 'Awesome university', + logo: { + url: organizationCover001.default, + }, + }, + cover: { url: courseCover001.default }, + }), + ); + + return res(ctx.status(200), ctx.json(courses)); + }, +); diff --git a/src/frontend/js/components/CourseGlimpse/CourseGlimpseFooter.tsx b/src/frontend/js/components/CourseGlimpse/CourseGlimpseFooter.tsx index 8087556272..7ef92d8b3b 100644 --- a/src/frontend/js/components/CourseGlimpse/CourseGlimpseFooter.tsx +++ b/src/frontend/js/components/CourseGlimpse/CourseGlimpseFooter.tsx @@ -2,7 +2,7 @@ import { defineMessages, useIntl } from 'react-intl'; import { Icon } from 'components/Icon'; import { CommonDataProps } from 'types/commonDataProps'; -import { Course } from 'types/Course'; +import { CourseState } from 'types'; const messages = defineMessages({ dateIconAlt: { @@ -16,16 +16,18 @@ const messages = defineMessages({ * . * This is spun off from to allow easier override through webpack. */ -export const CourseGlimpseFooter: React.FC<{ course: Course } & CommonDataProps> = ({ course }) => { +export const CourseGlimpseFooter: React.FC<{ courseState: CourseState } & CommonDataProps> = ({ + courseState, +}) => { const intl = useIntl(); return (
- {course.state.text.charAt(0).toUpperCase() + - course.state.text.substr(1) + - (course.state.datetime - ? ` ${intl.formatDate(new Date(course.state.datetime!), { + {courseState.text.charAt(0).toUpperCase() + + courseState.text.substr(1) + + (courseState.datetime + ? ` ${intl.formatDate(new Date(courseState.datetime!), { year: 'numeric', month: 'short', day: 'numeric', diff --git a/src/frontend/js/components/CourseGlimpse/index.spec.tsx b/src/frontend/js/components/CourseGlimpse/index.spec.tsx index fb63d301f1..906238af7a 100644 --- a/src/frontend/js/components/CourseGlimpse/index.spec.tsx +++ b/src/frontend/js/components/CourseGlimpse/index.spec.tsx @@ -3,40 +3,37 @@ import { IntlProvider } from 'react-intl'; import { CommonDataProps } from 'types/commonDataProps'; import { RichieContextFactory } from 'utils/test/factories/richie'; -import { CourseGlimpse } from '.'; +import { CourseGlimpse, CourseGlimpseCourse } from '.'; describe('widgets/Search/components/CourseGlimpse', () => { - const course = { - absolute_url: 'https://example/com/courses/42/', - categories: ['24', '42'], + const course: CourseGlimpseCourse = { + course_url: 'https://example/com/courses/42/', code: '123abc', cover_image: { sizes: '330px', src: '/thumbs/small.png', srcset: 'some srcset', }, - duration: '3 months', - effort: '3 hours', icon: { sizes: '60px', src: '/thumbs/icon_small.png', srcset: 'some srcset', title: 'Some icon', - color: 'red', }, id: '742', - organization_highlighted: 'Some Organization', - organization_highlighted_cover_image: { - sizes: '330px', - src: '/thumbs/org_small.png', - srcset: 'some srcset', + organization: { + title: 'Some Organization', + image: { + sizes: '330px', + src: '/thumbs/org_small.png', + srcset: 'some srcset', + }, }, - organizations: ['36', '63'], state: { - call_to_action: 'Enroll now', + call_to_action: 'enroll now', datetime: '2019-03-14T10:35:47.823Z', priority: 0, - text: 'starts on', + text: 'starting on', }, title: 'Course 42', }; @@ -46,14 +43,7 @@ describe('widgets/Search/components/CourseGlimpse', () => { it('renders a course glimpse with its data', () => { const { container } = render( - + , ); @@ -70,9 +60,9 @@ describe('widgets/Search/components/CourseGlimpse', () => { screen.getByLabelText('Organization'); screen.getByText('Some Organization'); screen.getByText('Category'); - // Matches on 'Starts on Mar 14, 2019', date is wrapped with intl + // Matches on 'Starting on Mar 14, 2019', date is wrapped with intl screen.getByLabelText('Course date'); - screen.getByText('Starts on Mar 14, 2019'); + screen.getByText('Starting on Mar 14, 2019'); // Check course logo const courseGlipseMedia = container.getElementsByClassName('course-glimpse__media'); diff --git a/src/frontend/js/components/CourseGlimpse/index.tsx b/src/frontend/js/components/CourseGlimpse/index.tsx index b7fff16148..5b62382da7 100644 --- a/src/frontend/js/components/CourseGlimpse/index.tsx +++ b/src/frontend/js/components/CourseGlimpse/index.tsx @@ -1,13 +1,41 @@ import React, { memo } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { Nullable } from 'types/utils'; import { CommonDataProps } from 'types/commonDataProps'; -import { Course } from 'types/Course'; import { Icon } from 'components/Icon'; +import { CourseState } from 'types'; import { CourseGlimpseFooter } from './CourseGlimpseFooter'; +export interface CourseGlimpseCourse { + id: string; + code: Nullable; + course_url?: string; + cover_image?: Nullable<{ + src: string; + sizes?: string; + srcset?: string; + }>; + title: string; + organization: { + title: string; + image?: Nullable<{ + src: string; + sizes?: string; + srcset?: string; + }>; + }; + icon?: Nullable<{ + title: string; + src: string; + sizes?: string; + srcset?: string; + }>; + state: CourseState; +} + export interface CourseGlimpseProps { - course: Course; + course: CourseGlimpseCourse; } const messages = defineMessages({ @@ -40,7 +68,7 @@ const CourseGlimpseBase = ({ context, course }: CourseGlimpseProps & CommonDataP {/* the media link is only here for mouse users, so hide it for keyboard/screen reader users. Keyboard/sr will focus the link on the title */}
); @@ -122,3 +150,4 @@ const areEqual: ( prevProps.context === newProps.context && prevProps.course.id === newProps.course.id; export const CourseGlimpse = memo(CourseGlimpseBase, areEqual); +export { getCourseGlimpsProps } from './utils'; diff --git a/src/frontend/js/components/CourseGlimpse/utils.ts b/src/frontend/js/components/CourseGlimpse/utils.ts new file mode 100644 index 0000000000..8cf644a434 --- /dev/null +++ b/src/frontend/js/components/CourseGlimpse/utils.ts @@ -0,0 +1,43 @@ +import { Course as RichieCourse, isRichieCourse } from 'types/Course'; +import { CourseListItemMock as JoanieCourse } from 'api/mocks/joanie/courses'; +import { CourseGlimpseCourse } from '.'; + +const getCourseGlimpsePropsFromRichieCourse = (course: RichieCourse): CourseGlimpseCourse => ({ + id: course.id, + code: course.code, + course_url: course.absolute_url, + cover_image: course.cover_image, + title: course.title, + organization: { + title: course.organization_highlighted, + image: course.organization_highlighted_cover_image, + }, + icon: course.icon, + state: course.state, +}); + +const getCourseGlimpsePropsFromJoanieCourse = (course: JoanieCourse): CourseGlimpseCourse => ({ + id: course.id, + code: course.code, + cover_image: course.cover + ? { + src: course.cover.url, + } + : null, + title: course.title, + organization: { + title: course.organization.title, + image: course.organization.logo + ? { + src: course.organization.logo.url, + } + : null, + }, + state: course.state, +}); + +export const getCourseGlimpsProps = (course: JoanieCourse | RichieCourse): CourseGlimpseCourse => { + return isRichieCourse(course) + ? getCourseGlimpsePropsFromRichieCourse(course) + : getCourseGlimpsePropsFromJoanieCourse(course); +}; diff --git a/src/frontend/js/components/CourseGlimpseList/index.spec.tsx b/src/frontend/js/components/CourseGlimpseList/index.spec.tsx index 8f76d6c7f5..c19280798a 100644 --- a/src/frontend/js/components/CourseGlimpseList/index.spec.tsx +++ b/src/frontend/js/components/CourseGlimpseList/index.spec.tsx @@ -3,7 +3,8 @@ import { IntlProvider } from 'react-intl'; import { RichieContextFactory } from 'utils/test/factories/richie'; import { CommonDataProps } from 'types/commonDataProps'; -import { Course } from 'types/Course'; +import { CourseGlimpseCourse } from 'components/CourseGlimpse'; +import { Priority } from 'types'; import { CourseGlimpseList } from '.'; describe('widgets/Search/components/CourseGlimpseList', () => { @@ -13,15 +14,33 @@ describe('widgets/Search/components/CourseGlimpseList', () => { const courses = [ { id: '44', - state: { datetime: '2019-03-14T10:35:47.823Z', text: '' }, + code: 'AAA', + organization: { + title: "Awesome univ'", + }, + state: { + datetime: '2019-03-14T10:35:47.823Z', + text: 'archived', + call_to_action: null, + priority: Priority.ARCHIVED_CLOSED, + }, title: 'Course 44', }, { id: '45', - state: { datetime: '2019-03-14T10:35:47.823Z', text: '' }, + code: 'BBB', + organization: { + title: "Bad univ'", + }, + state: { + datetime: '2019-03-14T10:35:47.823Z', + text: 'archived', + call_to_action: null, + priority: Priority.ARCHIVED_CLOSED, + }, title: 'Course 45', }, - ] as Course[]; + ] as CourseGlimpseCourse[]; const { container } = render( { + const containerClassnames = ['course-glimpse-list']; + if (className) { + containerClassnames.push(className); + } + return ( -
-
- -
- - {courses.map( - (course) => course && , +
+ {meta && ( +
+
+ +
+ +
)} +
+ {courses.map((course) => ( + + ))} +
); }; + +export { getCourseGlimpsListProps } from './utils'; diff --git a/src/frontend/js/components/CourseGlimpseList/utils.ts b/src/frontend/js/components/CourseGlimpseList/utils.ts new file mode 100644 index 0000000000..f33f9ff250 --- /dev/null +++ b/src/frontend/js/components/CourseGlimpseList/utils.ts @@ -0,0 +1,9 @@ +import { CourseListItemMock as JoanieCourse } from 'api/mocks/joanie/courses'; +import { Course as RichieCourse } from 'types/Course'; +import { CourseGlimpseCourse, getCourseGlimpsProps } from 'components/CourseGlimpse'; + +export const getCourseGlimpsListProps = ( + courses: JoanieCourse[] | RichieCourse[], +): CourseGlimpseCourse[] => { + return courses.map((course) => getCourseGlimpsProps(course)); +}; diff --git a/src/frontend/js/components/DashboardCourseList/_styles.scss b/src/frontend/js/components/DashboardCourseList/_styles.scss new file mode 100644 index 0000000000..e9262d711b --- /dev/null +++ b/src/frontend/js/components/DashboardCourseList/_styles.scss @@ -0,0 +1,40 @@ +.dashboard-course-list { + $gutter: rem-calc(30px); + $nb-columns: 3; + $nb-gutters: calc($nb-columns - 1); + $glimpse-width: calc(calc(100% / $nb-columns) - calc($gutter / $nb-columns * $nb-gutters)); + + margin-bottom: rem-calc(40px); + &:last-child { + margin-bottom: 0; + } + + &__title { + font-size: rem-calc(22px); + color: r-theme-val(dashboard-course-list, title-color); + white-space: nowrap; + margin-bottom: rem-calc(10px); + } + + // + // Course Glimpse in dashboards + // + + .dashboard { + &__course-glimpse-list { + padding-top: 0; + margin-top: 0; + .course-glimpse-list__content { + gap: $gutter; + .course-glimpse, + .course-glimpse__large { + @include media-breakpoint-up(lg) { + @include sv-flex(1, 0, $glimpse-width); + } + min-width: auto; + margin: 0; + } + } + } + } +} diff --git a/src/frontend/js/components/DashboardCourseList/index.spec.tsx b/src/frontend/js/components/DashboardCourseList/index.spec.tsx new file mode 100644 index 0000000000..5f00b498b4 --- /dev/null +++ b/src/frontend/js/components/DashboardCourseList/index.spec.tsx @@ -0,0 +1,89 @@ +import { MemoryRouter } from 'react-router-dom'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import fetchMock from 'fetch-mock'; +import { IntlProvider } from 'react-intl'; + +import { + RichieContextFactory as mockRichieContextFactory, + UserFactory, +} from 'utils/test/factories/richie'; +import JoanieSessionProvider from 'contexts/SessionContext/JoanieSessionProvider'; +import { CourseListItemMock } from 'api/mocks/joanie/courses'; +import { CourseFactory } from 'utils/test/factories/joanie'; +import { TeacherCourseSearchFilters, CourseTypeFilter, CourseStatusFilter } from 'hooks/useCourses'; +import { createTestQueryClient } from 'utils/test/createTestQueryClient'; +import DashboardCourseList from '.'; + +jest.mock('utils/context', () => ({ + __esModule: true, + default: mockRichieContextFactory({ + authentication: { backend: 'fonzie', endpoint: 'https://demo.endpoint' }, + joanie_backend: { endpoint: 'https://joanie.endpoint' }, + }).generate(), +})); + +describe('components/DashboardCourseList', () => { + let nbApiCalls: number; + beforeEach(() => { + fetchMock.get('https://joanie.endpoint/api/v1.0/orders/', []); + fetchMock.get('https://joanie.endpoint/api/v1.0/credit-cards/', []); + fetchMock.get('https://joanie.endpoint/api/v1.0/addresses/', []); + nbApiCalls = 3; + }); + + it('do render', async () => { + CourseFactory.beforeGenerate((shape: CourseListItemMock) => { + return { + ...shape, + title: 'How to cook birds', + }; + }); + const courseCooking: CourseListItemMock = CourseFactory.generate(); + + CourseFactory.beforeGenerate((shape: CourseListItemMock) => { + return { + ...shape, + title: "Let's dance, the online leason", + }; + }); + const courseDancing: CourseListItemMock = CourseFactory.generate(); + fetchMock.get('https://joanie.endpoint/api/v1.0/courses/?status=all&type=all', [ + courseCooking, + courseDancing, + ]); + + const filters: TeacherCourseSearchFilters = { + status: CourseStatusFilter.ALL, + type: CourseTypeFilter.ALL, + }; + + const user = UserFactory.generate(); + render( + + + + + + + + + , + ); + nbApiCalls += 1; // courses api call + + expect(await screen.getByRole('heading', { name: /DashboardCourseList test title/ })); + + const calledUrls = fetchMock.calls().map((call) => call[0]); + expect(calledUrls).toHaveLength(nbApiCalls); + expect(calledUrls).toContain('https://joanie.endpoint/api/v1.0/courses/?status=all&type=all'); + + expect(await screen.findByRole('heading', { name: /How to cook birds/ })).toBeInTheDocument(); + expect( + screen.getByRole('heading', { name: /Let's dance, the online leason/ }), + ).toBeInTheDocument(); + }); +}); diff --git a/src/frontend/js/components/DashboardCourseList/index.tsx b/src/frontend/js/components/DashboardCourseList/index.tsx new file mode 100644 index 0000000000..18a0ac1149 --- /dev/null +++ b/src/frontend/js/components/DashboardCourseList/index.tsx @@ -0,0 +1,62 @@ +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { Link } from 'react-router-dom'; +import queryString from 'query-string'; +import { CourseGlimpseList, getCourseGlimpsListProps } from 'components/CourseGlimpseList'; +import { Spinner } from 'components/Spinner'; +import { useCourses, TeacherCourseSearchFilters } from 'hooks/useCourses'; +import { getDashboardRoutePath } from 'widgets/Dashboard/utils/dashboardRoutes'; +import { TeacherDashboardPaths } from 'widgets/Dashboard/utils/teacherRouteMessages'; +import context from 'utils/context'; + +const messages = defineMessages({ + loading: { + defaultMessage: 'Loading courses...', + description: "Message displayed while loading courses on the teacher's dashboard'", + id: 'components.DashboardCourseList.loading', + }, +}); + +interface DashboardCourseListProps { + titleTranslated: string; + filters: TeacherCourseSearchFilters; +} + +const DashboardCourseList = ({ titleTranslated, filters }: DashboardCourseListProps) => { + const intl = useIntl(); + const coursesResults = useCourses(filters); + + const { + items: courses, + states: { fetching }, + } = coursesResults; + + return ( +
+ {titleTranslated && ( + +

{titleTranslated}

+ + )} + {fetching && ( + + + + + + )} + {!fetching && courses.length && ( + + )} +
+ ); +}; + +export default DashboardCourseList; diff --git a/src/frontend/js/hooks/useCourses/index.spec.tsx b/src/frontend/js/hooks/useCourses/index.spec.tsx new file mode 100644 index 0000000000..ce99ba16ce --- /dev/null +++ b/src/frontend/js/hooks/useCourses/index.spec.tsx @@ -0,0 +1,152 @@ +import { PropsWithChildren } from 'react'; +import { IntlProvider } from 'react-intl'; +import fetchMock from 'fetch-mock'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { QueryClientProvider } from '@tanstack/react-query'; + +import { + RichieContextFactory as mockRichieContextFactory, + UserFactory, +} from 'utils/test/factories/richie'; +import { CourseFactory } from 'utils/test/factories/joanie'; +import JoanieSessionProvider from 'contexts/SessionContext/JoanieSessionProvider'; +import { createTestQueryClient } from 'utils/test/createTestQueryClient'; +import { User } from 'types/User'; +import { Deferred } from 'utils/test/deferred'; +import { useCourses, TeacherCourseSearchFilters, CourseStatusFilter, CourseTypeFilter } from '.'; + +jest.mock('utils/context', () => ({ + __esModule: true, + default: mockRichieContextFactory({ + authentication: { backend: 'fonzie', endpoint: 'https://demo.endpoint' }, + joanie_backend: { endpoint: 'https://joanie.endpoint' }, + }).generate(), +})); + +interface RenderUseCoursesProps extends PropsWithChildren { + user?: User; + filters?: TeacherCourseSearchFilters; +} +const renderUseCourses = ({ user, filters }: RenderUseCoursesProps) => { + const Wrapper = ({ children }: PropsWithChildren) => ( + + + {children} + + + ); + return renderHook(() => useCourses(filters), { wrapper: Wrapper }); +}; + +describe('hooks/useCourses', () => { + beforeEach(() => { + fetchMock.get('https://joanie.endpoint/api/v1.0/orders/', []); + fetchMock.get('https://joanie.endpoint/api/v1.0/addresses/', []); + fetchMock.get('https://joanie.endpoint/api/v1.0/credit-cards/', []); + }); + + afterEach(() => { + jest.clearAllMocks(); + fetchMock.restore(); + }); + + it('fetch all courses', async () => { + const responseDeferred = new Deferred(); + fetchMock.get( + 'https://joanie.endpoint/api/v1.0/courses/?status=all&type=all', + responseDeferred.promise, + ); + + const user = UserFactory.generate(); + const { result } = renderUseCourses({ user }); + + await waitFor(() => { + expect(result.current.states.fetching).toBe(true); + expect(result.current.items).toEqual([]); + }); + + expect(result.current.states.creating).toBeUndefined(); + expect(result.current.states.deleting).toBeUndefined(); + expect(result.current.states.updating).toBeUndefined(); + expect(result.current.states.isLoading).toBe(true); + expect(result.current.states.error).toBeUndefined(); + + const courses = CourseFactory.generate(3); + await act(async () => { + responseDeferred.resolve(courses); + }); + + await waitFor(() => { + expect(result.current.states.fetching).toBe(false); + expect(JSON.stringify(result.current.items)).toBe(JSON.stringify(courses)); + }); + expect(result.current.states.creating).toBeUndefined(); + expect(result.current.states.deleting).toBeUndefined(); + expect(result.current.states.updating).toBeUndefined(); + expect(result.current.states.isLoading).toBe(false); + expect(result.current.states.error).toBeUndefined(); + }); + + it('fetch with filter "incoming"', async () => { + const courseRuns = CourseFactory.generate(3); + fetchMock.get('https://joanie.endpoint/api/v1.0/courses/?status=incoming&type=all', courseRuns); + + const user = UserFactory.generate(); + const filters: TeacherCourseSearchFilters = { + status: CourseStatusFilter.INCOMING, + type: CourseTypeFilter.ALL, + }; + + const { result } = renderUseCourses({ user, filters }); + await waitFor(() => { + expect(result.current.states.fetching).toBe(false); + }); + const calledUrls = fetchMock.calls().map((call) => call[0]); + expect(calledUrls).toContain( + 'https://joanie.endpoint/api/v1.0/courses/?status=incoming&type=all', + ); + expect(JSON.stringify(result.current.items)).toBe(JSON.stringify(courseRuns)); + }); + + it('fetch with filter "ongoing"', async () => { + const courseRuns = CourseFactory.generate(3); + fetchMock.get('https://joanie.endpoint/api/v1.0/courses/?status=ongoing&type=all', courseRuns); + + const user = UserFactory.generate(); + const filters: TeacherCourseSearchFilters = { + status: CourseStatusFilter.ONGOING, + type: CourseTypeFilter.ALL, + }; + + const { result } = renderUseCourses({ user, filters }); + await waitFor(() => { + expect(result.current.states.fetching).toBe(false); + }); + const calledUrls = fetchMock.calls().map((call) => call[0]); + expect(calledUrls).toContain( + 'https://joanie.endpoint/api/v1.0/courses/?status=ongoing&type=all', + ); + expect(JSON.stringify(result.current.items)).toBe(JSON.stringify(courseRuns)); + }); + + it('fetch with filter "archived"', async () => { + const courseRuns = CourseFactory.generate(3); + fetchMock.get('https://joanie.endpoint/api/v1.0/courses/?status=archived&type=all', courseRuns); + + const user = UserFactory.generate(); + const filters: TeacherCourseSearchFilters = { + status: CourseStatusFilter.ARCHIVED, + type: CourseTypeFilter.ALL, + }; + + const { result } = renderUseCourses({ user, filters }); + await waitFor(() => { + expect(result.current.states.fetching).toBe(false); + }); + const calledUrls = fetchMock.calls().map((call) => call[0]); + expect(calledUrls).toContain( + 'https://joanie.endpoint/api/v1.0/courses/?status=archived&type=all', + ); + expect(JSON.stringify(result.current.items)).toBe(JSON.stringify(courseRuns)); + }); +}); diff --git a/src/frontend/js/hooks/useCourses/index.ts b/src/frontend/js/hooks/useCourses/index.ts new file mode 100644 index 0000000000..7bb514c263 --- /dev/null +++ b/src/frontend/js/hooks/useCourses/index.ts @@ -0,0 +1,69 @@ +import { defineMessages } from 'react-intl'; +import { useJoanieApi } from 'contexts/JoanieApiContext'; +import { CourseListItemMock } from 'api/mocks/joanie/courses'; +import { API, CourseFilters } from 'types/Joanie'; +import { useResources, UseResourcesProps } from 'hooks/useResources'; + +const messages = defineMessages({ + errorGet: { + id: 'hooks.useCourses.errorSelect', + description: 'Error message shown to the user when course fetch request fails.', + defaultMessage: 'An error occurred while fetching course. Please retry later.', + }, + errorNotFound: { + id: 'hooks.useCourses.errorNotFound', + description: 'Error message shown to the user when not course matches.', + defaultMessage: 'Cannot find the course.', + }, +}); + +export enum CourseStatusFilter { + ALL = 'all', + INCOMING = 'incoming', + ONGOING = 'ongoing', + ARCHIVED = 'archived', +} + +export enum CourseTypeFilter { + ALL = 'all', + SESSION = 'session', + MIRCO_CREDENTIAL = 'micro_credential', +} + +export interface TeacherCourseSearchFilters { + status: CourseStatusFilter; + type: CourseTypeFilter; + perPage?: number; +} + +/** + * Joanie Api hook to retrieve/create/update/delete course + * owned by the authenticated user. + */ +const props: UseResourcesProps = { + queryKey: ['courses'], + apiInterface: () => useJoanieApi().courses, + session: true, + messages, +}; + +const filtersToApiFilters = ( + filters: TeacherCourseSearchFilters = { + status: CourseStatusFilter.ALL, + type: CourseTypeFilter.ALL, + }, +): CourseFilters => { + const apiFilters: CourseFilters = { + status: filters.status, + type: filters.type, + }; + if (filters.perPage) { + apiFilters.per_page = filters.perPage; + } + return apiFilters; +}; + +export const useCourses = (filters?: TeacherCourseSearchFilters) => { + const apiFilters: CourseFilters = filtersToApiFilters(filters); + return useResources(props)(apiFilters); +}; diff --git a/src/frontend/js/pages/TeacherCoursesDashboardLoader/index.spec.tsx b/src/frontend/js/pages/TeacherCoursesDashboardLoader/index.spec.tsx new file mode 100644 index 0000000000..60ed97c13b --- /dev/null +++ b/src/frontend/js/pages/TeacherCoursesDashboardLoader/index.spec.tsx @@ -0,0 +1,157 @@ +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '@testing-library/react'; +import fetchMock from 'fetch-mock'; +import { IntlProvider } from 'react-intl'; + +import { + RichieContextFactory as mockRichieContextFactory, + UserFactory, +} from 'utils/test/factories/richie'; +import JoanieSessionProvider from 'contexts/SessionContext/JoanieSessionProvider'; +import { CourseFactory } from 'utils/test/factories/joanie'; +import { CourseListItemMock as Course } from 'api/mocks/joanie/courses'; +import { createTestQueryClient } from 'utils/test/createTestQueryClient'; +import { expectNoSpinner } from 'utils/test/expectSpinner'; +import { TeacherCoursesDashboardLoader } from '.'; + +jest.mock('utils/context', () => ({ + __esModule: true, + default: mockRichieContextFactory({ + authentication: { backend: 'fonzie', endpoint: 'https://demo.endpoint' }, + joanie_backend: { endpoint: 'https://joanie.endpoint' }, + }).generate(), +})); + +describe('components/TeacherCoursesDashboardLoader', () => { + let nbApiCalls: number; + beforeEach(() => { + // Joanie providers calls + fetchMock.get('https://joanie.endpoint/api/v1.0/orders/', []); + fetchMock.get('https://joanie.endpoint/api/v1.0/credit-cards/', []); + fetchMock.get('https://joanie.endpoint/api/v1.0/addresses/', []); + // teacher course sidebar calls + fetchMock.get('https://joanie.endpoint/api/v1.0/organizations/', []); + + nbApiCalls = 4; + }); + + it('do render', async () => { + CourseFactory.beforeGenerate((shape: Course) => { + return { + ...shape, + title: 'Incoming leason', + }; + }); + + const courseIncoming: Course = CourseFactory.generate(); + fetchMock.get( + 'https://joanie.endpoint/api/v1.0/courses/?per_page=3&status=incoming&type=all', + [courseIncoming], + { + repeat: 1, + }, + ); + CourseFactory.beforeGenerate((shape: Course) => { + return { + ...shape, + title: 'Ongoing leason', + }; + }); + const courseOngoing: Course = CourseFactory.generate(); + fetchMock.get( + 'https://joanie.endpoint/api/v1.0/courses/?per_page=3&status=ongoing&type=all', + [courseOngoing], + { + repeat: 1, + overwriteRoutes: false, + }, + ); + CourseFactory.beforeGenerate((shape: Course) => { + return { + ...shape, + title: 'Archived leason', + }; + }); + const courseAchived: Course = CourseFactory.generate(); + fetchMock.get( + 'https://joanie.endpoint/api/v1.0/courses/?per_page=3&status=archived&type=all', + [courseAchived], + { + repeat: 1, + overwriteRoutes: false, + }, + ); + + const user = UserFactory.generate(); + render( + + + + , + }, + ])} + /> + + + , + ); + await expectNoSpinner('Loading courses ...'); + + nbApiCalls += 1; // incoming courses api call + nbApiCalls += 1; // ongoing courses api call + nbApiCalls += 1; // archived courses api call + const calledUrls = fetchMock.calls().map((call) => call[0]); + expect(calledUrls).toHaveLength(nbApiCalls); + expect(calledUrls).toContain( + 'https://joanie.endpoint/api/v1.0/courses/?per_page=3&status=incoming&type=all', + ); + expect(calledUrls).toContain( + 'https://joanie.endpoint/api/v1.0/courses/?per_page=3&status=ongoing&type=all', + ); + expect(calledUrls).toContain( + 'https://joanie.endpoint/api/v1.0/courses/?per_page=3&status=archived&type=all', + ); + + expect(screen.getByDisplayValue('Status: All')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Training type: All')).toBeInTheDocument(); + + // section titles + expect( + await screen.getByRole('heading', { + name: 'Incoming', + }), + ).toBeInTheDocument(); + expect( + await screen.getByRole('heading', { + name: 'Ongoing', + }), + ).toBeInTheDocument(); + expect( + await screen.getByRole('heading', { + name: 'Archived', + }), + ).toBeInTheDocument(); + + // Leason titles + expect( + await screen.getByRole('heading', { + name: /Incoming/, + }), + ).toBeInTheDocument(); + expect( + await screen.getByRole('heading', { + name: /Ongoing/, + }), + ).toBeInTheDocument(); + expect( + await screen.getByRole('heading', { + name: /Archived/, + }), + ).toBeInTheDocument(); + }); +}); diff --git a/src/frontend/js/pages/TeacherCoursesDashboardLoader/index.tsx b/src/frontend/js/pages/TeacherCoursesDashboardLoader/index.tsx index 3fed40d082..0e0009d05e 100644 --- a/src/frontend/js/pages/TeacherCoursesDashboardLoader/index.tsx +++ b/src/frontend/js/pages/TeacherCoursesDashboardLoader/index.tsx @@ -1,16 +1,81 @@ -import { useIntl } from 'react-intl'; +import { useMemo } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useSearchParams } from 'react-router-dom'; + +import DashboardCourseList from 'components/DashboardCourseList'; import { DashboardLayout } from 'widgets/Dashboard/components/DashboardLayout'; import { TeacherProfileDashboardSidebar } from 'widgets/Dashboard/components/TeacherProfileDashboardSidebar'; -import RouteInfo from 'widgets/Dashboard/components/RouteInfo'; -import { getDashboardRouteLabel } from 'widgets/Dashboard/utils/dashboardRoutes'; -import { TeacherDashboardPaths } from 'widgets/Dashboard/utils/teacherRouteMessages'; +import TeacherCourseSearchFiltersBar from 'widgets/Dashboard/components/TeacherCourseSearchFilters'; +import { CourseStatusFilter, CourseTypeFilter, TeacherCourseSearchFilters } from 'hooks/useCourses'; +import { isEnumValue } from 'types/utils'; + +const messages = defineMessages({ + courses: { + defaultMessage: 'Your courses', + description: 'Filtered courses title', + id: 'components.TeacherCoursesDashboardLoader.title.filteredCourses', + }, + incoming: { + defaultMessage: 'Incoming', + description: 'Incoming courses title', + id: 'components.TeacherCoursesDashboardLoader.title.incoming', + }, + ongoing: { + defaultMessage: 'Ongoing', + description: 'Ongoing courses title', + id: 'components.TeacherCoursesDashboardLoader.title.ongoing', + }, + archived: { + defaultMessage: 'Archived', + description: 'Archived courses title', + id: 'components.TeacherCoursesDashboardLoader.title.archived', + }, +}); export const TeacherCoursesDashboardLoader = () => { const intl = useIntl(); - const getRouteLabel = getDashboardRouteLabel(intl); + const [searchParams] = useSearchParams({ + status: CourseStatusFilter.ALL, + type: CourseTypeFilter.ALL, + }); + const filters = useMemo(() => { + const queryStatus = searchParams.get('status') || ''; + const queryType = searchParams.get('type') || ''; + return { + status: isEnumValue(queryStatus, CourseStatusFilter) ? queryStatus : CourseStatusFilter.ALL, + type: isEnumValue(queryType, CourseTypeFilter) ? queryType : CourseTypeFilter.ALL, + }; + }, [searchParams.get('status'), searchParams.get('type')]); + return ( }> - , +
+ + +
+ {filters.status === CourseStatusFilter.ALL ? ( + <> + + + + + ) : ( + + )} +
+
); }; diff --git a/src/frontend/js/pages/TeacherOrganizationCourseDashboardLoader/index.tsx b/src/frontend/js/pages/TeacherOrganizationCourseDashboardLoader/index.tsx index 7d01f03520..76118c2e20 100644 --- a/src/frontend/js/pages/TeacherOrganizationCourseDashboardLoader/index.tsx +++ b/src/frontend/js/pages/TeacherOrganizationCourseDashboardLoader/index.tsx @@ -6,7 +6,7 @@ import { TeacherOrganizationDashboardSidebar } from 'widgets/Dashboard/component const messages = defineMessages({ loading: { defaultMessage: 'Loading organization ...', - description: "Message displayed while loading courses on the teacher's dashboard'", + description: 'Message displayed while loading an organization', id: 'components.TeacherOrganizationCourseDashboardLoader.loading', }, }); diff --git a/src/frontend/js/types/Course.ts b/src/frontend/js/types/Course.ts index 7383bc7119..5db1ce450e 100644 --- a/src/frontend/js/types/Course.ts +++ b/src/frontend/js/types/Course.ts @@ -1,6 +1,7 @@ -import { Priority } from 'types'; +import { CourseState } from 'types'; import { Resource } from 'types/Resource'; import { Nullable } from 'types/utils'; +import { CourseListItemMock as JoanieCourse } from 'api/mocks/joanie/courses'; export interface Course extends Resource { absolute_url: string; @@ -27,10 +28,9 @@ export interface Course extends Resource { srcset: string; }>; organizations: string[]; - state: { - call_to_action: Nullable; - datetime: Nullable; - priority: Priority; - text: string; - }; + state: CourseState; +} + +export function isRichieCourse(course: Course | JoanieCourse): course is Course { + return (course as Course).organization_highlighted !== undefined; } diff --git a/src/frontend/js/types/Joanie.ts b/src/frontend/js/types/Joanie.ts index 5af61cbda3..ed08f9f1d3 100644 --- a/src/frontend/js/types/Joanie.ts +++ b/src/frontend/js/types/Joanie.ts @@ -2,6 +2,8 @@ import type { Priority, StateCTA, StateText } from 'types'; import type { Nullable } from 'types/utils'; import { Resource, ResourcesQuery } from 'hooks/useResources'; import { OrderResourcesQuery } from 'hooks/useOrders'; +import { CourseListItemMock } from 'api/mocks/joanie/courses'; +import { CourseStatusFilter, CourseTypeFilter } from 'hooks/useCourses'; import { OrganizationMock } from '../api/mocks/joanie/organizations'; // - Generic @@ -37,6 +39,12 @@ export interface CourseRun { course?: CourseLight; } +export interface CourseFilters extends ResourcesQuery { + status: CourseStatusFilter; + type: CourseTypeFilter; + per_page?: number; +} + // - Certificate export interface CertificateDefinition { id: number; @@ -78,6 +86,7 @@ export interface Product { // - Course export interface AbstractCourse { + id: string; code: string; organizations: Organization[]; title: string; @@ -324,6 +333,11 @@ export interface API { products: { get(filters?: ResourcesQuery): Promise>; }; + courses: { + get( + filters?: Filters, + ): Promise>; + }; } export interface Backend { diff --git a/src/frontend/js/types/commonDataProps.ts b/src/frontend/js/types/commonDataProps.ts index 8369036ac5..66e8db21f9 100644 --- a/src/frontend/js/types/commonDataProps.ts +++ b/src/frontend/js/types/commonDataProps.ts @@ -16,15 +16,17 @@ export interface AuthenticationBackend { endpoint: string; } +export interface RichieContext { + authentication: AuthenticationBackend; + csrftoken: string; + environment: string; + lms_backends?: LMSBackend[]; + joanie_backend?: JoanieBackend; + release: string; + sentry_dsn: Nullable; + web_analytics_providers?: Nullable; +} + export interface CommonDataProps { - context: { - authentication: AuthenticationBackend; - csrftoken: string; - environment: string; - lms_backends?: LMSBackend[]; - joanie_backend?: JoanieBackend; - release: string; - sentry_dsn: Nullable; - web_analytics_providers?: Nullable; - }; + context: RichieContext; } diff --git a/src/frontend/js/types/index.ts b/src/frontend/js/types/index.ts index 58243513a4..9167f000ec 100644 --- a/src/frontend/js/types/index.ts +++ b/src/frontend/js/types/index.ts @@ -36,8 +36,8 @@ export enum Priority { export interface CourseState { priority: Priority; - datetime: string; - call_to_action: StateCTA; + datetime: Nullable; + call_to_action: Nullable; text: StateText; } diff --git a/src/frontend/js/types/utils.ts b/src/frontend/js/types/utils.ts index c3ad5b04ca..cc4be78a39 100644 --- a/src/frontend/js/types/utils.ts +++ b/src/frontend/js/types/utils.ts @@ -8,3 +8,9 @@ export type AddParameters< TFunction extends (...args: readonly unknown[]) => unknown, TParameters extends [...args: readonly unknown[]], > = (...args: [...Parameters, ...TParameters]) => ReturnType; + +export const isEnumValue = ( + something: any, + enumObject: T, +): something is T[keyof T] => + typeof something === 'string' && Object.values(enumObject).includes(something); diff --git a/src/frontend/js/utils/test/factories/joanie.ts b/src/frontend/js/utils/test/factories/joanie.ts index d3b9b1e7d6..6c52da3428 100644 --- a/src/frontend/js/utils/test/factories/joanie.ts +++ b/src/frontend/js/utils/test/factories/joanie.ts @@ -1,4 +1,5 @@ import { compose, createSpec, derived, faker, oneOf } from '@helpscout/helix'; +import { Priority } from 'types'; import { EnrollmentState, OrderState, PaymentProviders, ProductType } from 'types/Joanie'; export const EnrollmentFactory = createSpec({ @@ -105,6 +106,7 @@ export const CourseRunFactory = (scopes?: { course: Boolean }) => { }; export const CourseFactory = createSpec({ + id: faker.datatype.uuid(), code: faker.random.alphaNumeric(5), organization: OrganizationFactory, title: faker.unique(faker.random.words(Math.ceil(Math.random() * 3))), diff --git a/src/frontend/js/widgets/Dashboard/components/DashboardItem/stories.mock.ts b/src/frontend/js/widgets/Dashboard/components/DashboardItem/stories.mock.ts index 8ec377731b..36e95da9f0 100644 --- a/src/frontend/js/widgets/Dashboard/components/DashboardItem/stories.mock.ts +++ b/src/frontend/js/widgets/Dashboard/components/DashboardItem/stories.mock.ts @@ -2,13 +2,13 @@ import { CourseStateFactory } from 'utils/test/factories/richie'; import { Enrollment, EnrollmentState } from 'types/Joanie'; export const enrollment: Enrollment = { - id: '1', + id: '99d9a14c-a05b-4dd4-b5bf-8a6922e9934a', state: EnrollmentState.SET, is_active: true, was_created_by_order: true, created_on: '2022-09-09T12:00:00+00:00', course_run: { - id: '1', + id: '18cede01-231e-4061-92d1-5716cd990e33', title: '', start: '2022-09-09T12:00:00+00:00', end: '2022-10-01T13:00:00+00:00', @@ -17,6 +17,7 @@ export const enrollment: Enrollment = { resource_link: 'https://lms.fun-mooc.fr/courses/course-v1:supagro+120001+archive_ouvert/info', state: CourseStateFactory.generate(), course: { + id: '1cc49d2b-fc08-46d2-9fb8-594d90494cd3', code: '09391', title: 'Learn disruptive technologies', products: [], diff --git a/src/frontend/js/widgets/Dashboard/components/TeacherCourseSearchFilters/_styles.scss b/src/frontend/js/widgets/Dashboard/components/TeacherCourseSearchFilters/_styles.scss new file mode 100644 index 0000000000..8562f8e539 --- /dev/null +++ b/src/frontend/js/widgets/Dashboard/components/TeacherCourseSearchFilters/_styles.scss @@ -0,0 +1,14 @@ +.dashboard-course-search-filters { + display: flex; + justify-content: flex-end; + margin-bottom: 20px; + + &__filter_select { + margin-left: 30px; + + // override select form field default + &.form-field { + margin-bottom: 0; + } + } +} diff --git a/src/frontend/js/widgets/Dashboard/components/TeacherCourseSearchFilters/index.tsx b/src/frontend/js/widgets/Dashboard/components/TeacherCourseSearchFilters/index.tsx new file mode 100644 index 0000000000..f766570bc1 --- /dev/null +++ b/src/frontend/js/widgets/Dashboard/components/TeacherCourseSearchFilters/index.tsx @@ -0,0 +1,151 @@ +import { ChangeEvent } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import querystring from 'query-string'; +import { CourseStatusFilter, CourseTypeFilter, TeacherCourseSearchFilters } from 'hooks/useCourses'; +import { SelectField } from 'components/Form'; + +const messages = defineMessages({ + filterStatusPrepend: { + defaultMessage: 'Status:', + description: 'Option prepend label for courses search filters', + id: 'components.TeacherCourseSearchFiltersBar.status.option.label.prepend', + }, + filterStatusAll: { + defaultMessage: 'All', + description: 'Option label for all courses statuses in search filters', + id: 'components.TeacherCourseSearchFiltersBar.status.option.label.all', + }, + filterIncoming: { + defaultMessage: 'Incoming', + description: 'Option label for courses status incoming in search filters', + id: 'components.TeacherCourseSearchFiltersBar.status.option.label.incoming', + }, + filterOngoing: { + defaultMessage: 'Ongoing', + description: 'Option label for courses status ongoing in search filters', + id: 'components.TeacherCourseSearchFiltersBar.status.option.label.ongoing', + }, + filterArchived: { + defaultMessage: 'Archived', + description: 'Option label for courses status archived in search filters', + id: 'components.TeacherCourseSearchFiltersBar.status.option.label.archived', + }, + filterTypePrepend: { + defaultMessage: 'Training type:', + description: 'Option prepend label for courses search filters', + id: 'components.TeacherCourseSearchFiltersBar.type.option.label.prepend', + }, + filterTypeAll: { + defaultMessage: 'All', + description: 'Option prepend label for courses training types in search filters', + id: 'components.TeacherCourseSearchFiltersBar.type.option.label.all', + }, + filterCourseRun: { + defaultMessage: 'Course run', + description: 'Option label for courses run training type in search filters', + id: 'components.TeacherCourseSearchFiltersBar.type.option.label.courseRun', + }, + filterMicroCredential: { + defaultMessage: 'Micro credential', + description: 'Option label for courses micro credential training type in search filters', + id: 'components.TeacherCourseSearchFiltersBar.type.option.label.microCredential', + }, +}); + +interface SearchFilterOptionProps { + value: string; + prependLabel: string; + label: string; +} +const SearchFilterOption = ({ value, prependLabel, label }: SearchFilterOptionProps) => ( + +); + +export interface TeacherCourseSearchFiltersBarProps { + filters: TeacherCourseSearchFilters; +} + +const TeacherCourseSearchFiltersBar = ({ filters }: TeacherCourseSearchFiltersBarProps) => { + const intl = useIntl(); + const navigate = useNavigate(); + const statusOptions = [ + { + label: intl.formatMessage(messages.filterIncoming), + value: CourseStatusFilter.INCOMING, + }, + { + label: intl.formatMessage(messages.filterOngoing), + value: CourseStatusFilter.ONGOING, + }, + { + label: intl.formatMessage(messages.filterArchived), + value: CourseStatusFilter.ARCHIVED, + }, + ]; + const typeOptions = [ + { + label: intl.formatMessage(messages.filterCourseRun), + value: CourseTypeFilter.SESSION, + }, + { + label: intl.formatMessage(messages.filterMicroCredential), + value: CourseTypeFilter.MIRCO_CREDENTIAL, + }, + ]; + + const onChangeFilter = (e: ChangeEvent) => { + const { + target: { name, value }, + } = e; + + navigate(`?${querystring.stringify({ ...filters, [name]: value })}`, { replace: true }); + }; + + return ( +
+ + + {statusOptions.map(({ label, value }) => ( + + ))} + + + + {typeOptions.map(({ label, value }) => ( + + ))} + +
+ ); +}; + +export default TeacherCourseSearchFiltersBar; diff --git a/src/frontend/js/widgets/Search/index.tsx b/src/frontend/js/widgets/Search/index.tsx index 1237681658..31f447d73a 100644 --- a/src/frontend/js/widgets/Search/index.tsx +++ b/src/frontend/js/widgets/Search/index.tsx @@ -6,7 +6,7 @@ import { useCourseSearchParams, CourseSearchParamsAction } from 'hooks/useCourse import useMatchMedia from 'hooks/useMatchMedia'; import { CommonDataProps } from 'types/commonDataProps'; import { scroll } from 'utils/indirection/window'; -import { CourseGlimpseList } from 'components/CourseGlimpseList'; +import { CourseGlimpseList, getCourseGlimpsListProps } from 'components/CourseGlimpseList'; import { PaginateCourseSearch } from './components/PaginateCourseSearch'; import { SearchFiltersPane } from './components/SearchFiltersPane'; import { useCourseSearch } from './hooks/useCourseSearch'; @@ -148,7 +148,7 @@ const Search = ({ context }: CommonDataProps) => { ) : null}