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 0000000000..a0fd28b015 Binary files /dev/null and b/src/frontend/js/api/mocks/joanie/assets/course_cover_001.jpg differ 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 0000000000..35cd9b03d8 Binary files /dev/null and b/src/frontend/js/api/mocks/joanie/assets/course_icon_001.png differ 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..d6e4619f7e --- /dev/null +++ b/src/frontend/js/components/DashboardCourseList/index.spec.tsx @@ -0,0 +1,128 @@ +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/', [], { overwriteRoutes: true }); + fetchMock.get('https://joanie.endpoint/api/v1.0/credit-cards/', [], { overwriteRoutes: true }); + fetchMock.get('https://joanie.endpoint/api/v1.0/addresses/', [], { overwriteRoutes: true }); + nbApiCalls = 3; + }); + afterEach(() => { + fetchMock.restore(); + }); + + 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(); + }); + + it('do render empty list', async () => { + fetchMock.get('https://joanie.endpoint/api/v1.0/courses/?status=all&type=all', [], { + overwriteRoutes: true, + }); + + 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.findByText('You have no courses yet.')).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..393a22d320 --- /dev/null +++ b/src/frontend/js/components/DashboardCourseList/index.tsx @@ -0,0 +1,70 @@ +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', + }, + emptyList: { + description: "Empty placeholder of the dashboard's list of courses", + defaultMessage: 'You have no courses yet.', + id: 'components.DashboardCourseList.emptyList', + }, +}); + +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 > 0 ? ( + + ) : ( + + ))} +
+ ); +}; + +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..58295db35b 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}