diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c5cd91156..99791ee742 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Versioning](https://semver.org/spec/v2.0.0.html). - list teacher's organizations in the teacher dashboard sidebar. - 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..da8b590e8e --- /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_image: 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_image: { 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.stories.tsx b/src/frontend/js/components/CourseGlimpse/index.stories.tsx index bcd7b0555a..2361feaf82 100644 --- a/src/frontend/js/components/CourseGlimpse/index.stories.tsx +++ b/src/frontend/js/components/CourseGlimpse/index.stories.tsx @@ -1,5 +1,5 @@ import { Meta, StoryObj } from '@storybook/react'; -import { RichieContextFactory, CourseFactory } from 'utils/test/factories/richie'; +import { RichieContextFactory, CourseLightFactory } from 'utils/test/factories/richie'; import { CourseGlimpse } from '.'; export default { @@ -11,6 +11,6 @@ type Story = StoryObj; export const RichieCourse: Story = { args: { context: RichieContextFactory().generate(), - course: CourseFactory.generate(), + course: CourseLightFactory.generate(), }, }; 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..e9c4e9963e --- /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_image + ? { + src: course.cover_image.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( ; export const RichieCourseList: Story = { args: { context: RichieContextFactory().generate(), - courses: CourseFactory.generate(10), + courses: CourseLightFactory.generate(10), meta: { count: 10, offset: 0, diff --git a/src/frontend/js/components/CourseGlimpseList/index.tsx b/src/frontend/js/components/CourseGlimpseList/index.tsx index a1a5f66440..fc59ffa702 100644 --- a/src/frontend/js/components/CourseGlimpseList/index.tsx +++ b/src/frontend/js/components/CourseGlimpseList/index.tsx @@ -2,8 +2,7 @@ import { defineMessages, FormattedMessage } from 'react-intl'; import { APIResponseListMeta } from 'types/api'; import { CommonDataProps } from 'types/commonDataProps'; -import { Course } from 'types/Course'; -import { CourseGlimpse } from '../CourseGlimpse'; +import { CourseGlimpse, CourseGlimpseCourse } from 'components/CourseGlimpse'; const messages = defineMessages({ courseCount: { @@ -22,43 +21,59 @@ const messages = defineMessages({ }); interface CourseGlimpseListProps { - courses: Course[]; - meta: APIResponseListMeta; + courses: CourseGlimpseCourse[]; + meta?: APIResponseListMeta; + className?: string; } export const CourseGlimpseList = ({ context, courses, meta, + className, }: CourseGlimpseListProps & CommonDataProps) => { + const containerClassnames = ['course-glimpse-list']; + if (className) { + containerClassnames.push(className); + } + return ( -
-
- -
- - {courses.map( - (course) => course && , +
+ {meta && ( +
+
+ +
+ +
)} +
+ {courses.map( + (course) => + 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..b14eb45853 --- /dev/null +++ b/src/frontend/js/components/DashboardCourseList/_styles.scss @@ -0,0 +1,37 @@ +$r-course-glimpse-dashboard-gutter: rem-calc(30px) !default; + +.dashboard-course-list { + margin-bottom: 40px; + &:last-child { + margin-bottom: 0; + } + + &__title { + font-size: 22px; + color: r-theme-val(dashboard-course-list, title-color); + white-space: nowrap; + margin-bottom: 10px; + } + + // + // Course Glimpse in dashboards + // + + .dashboard { + &__course-glimpse-list { + padding-top: 0; + margin-top: 0; + .course-glimpse-list__content { + gap: $r-course-glimpse-dashboard-gutter; + .course-glimpse, + .course-glimpse__large { + @include media-breakpoint-up(lg) { + @include sv-flex(1, 0, calc(32% - ($r-course-glimpse-dashboard-gutter / 3))); + } + 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..96da6ef4bc --- /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.TeacherCoursesDashboardLoader.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..fa765600fb --- /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_CERTIFICATE = 'micro_certificate', +} + +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/hooks/useOrders.ts b/src/frontend/js/hooks/useOrders.ts index 57c79be0a1..abb239aaf5 100644 --- a/src/frontend/js/hooks/useOrders.ts +++ b/src/frontend/js/hooks/useOrders.ts @@ -1,11 +1,11 @@ import { defineMessages } from 'react-intl'; -import { API, Course, Order, OrderState, PaginatedResourceQuery, Product } from 'types/Joanie'; +import { API, CourseLight, Order, OrderState, PaginatedResourceQuery, Product } from 'types/Joanie'; import { useJoanieApi } from 'contexts/JoanieApiContext'; import { useSessionMutation } from 'utils/react-query/useSessionMutation'; import { QueryOptions, useResource, useResourcesCustom, UseResourcesProps } from './useResources'; export type OrderResourcesQuery = PaginatedResourceQuery & { - course?: Course['code']; + course?: CourseLight['code']; product?: Product['id']; state?: OrderState[]; }; 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..29ee7a0d45 --- /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 leason/, + }), + ).toBeInTheDocument(); + expect( + await screen.getByRole('heading', { + name: /Ongoing leason/, + }), + ).toBeInTheDocument(); + expect( + await screen.getByRole('heading', { + name: /Archived leason/, + }), + ).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 4c10059dae..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 @@ -34,7 +36,13 @@ export interface CourseRun { text: StateText; }; title: string; - course?: Course; + course?: CourseLight; +} + +export interface CourseFilters extends ResourcesQuery { + status: CourseStatusFilter; + type: CourseTypeFilter; + per_page?: number; } // - Certificate @@ -78,6 +86,7 @@ export interface Product { // - Course export interface AbstractCourse { + id: string; code: string; organizations: Organization[]; title: string; @@ -106,7 +115,7 @@ export interface CourseProduct extends Product { target_courses: TargetCourse[]; } -export interface Course extends AbstractCourse { +export interface CourseLight extends AbstractCourse { products: CourseProduct[]; orders?: OrderLite[]; } @@ -136,7 +145,7 @@ export enum OrderState { export interface Order { id: string; - course?: Course['code'] | Course; + course?: CourseLight['code'] | CourseLight; created_on: string; enrollments: Enrollment[]; main_proforma_invoice: string; @@ -183,11 +192,11 @@ export interface Address { // Wishlist export interface UserWishlistCourse { id: string; - course: Course['code']; + course: CourseLight['code']; } export interface UserWishlistCreationPayload { - course: Course['code']; + course: CourseLight['code']; } // Payment @@ -217,7 +226,7 @@ export interface AddressCreationPayload extends Omit interface OrderCreationPayload { product: Product['id']; - course: Course['code']; + course: CourseLight['code']; billing_address?: Omit; credit_card_id?: CreditCard['id']; } @@ -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 2e37bda1b7..65eecac2c1 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({ @@ -99,12 +100,27 @@ export const CourseRunFactory = (scopes?: { course: Boolean }) => { text: 'closing on', }, ...(scopes?.course && { - course: CourseFactory.generate(), + course: CourseLightFactory.generate(), }), }); }; export const CourseFactory = createSpec({ + code: faker.random.alphaNumeric(5), + organization: OrganizationFactory, + title: faker.unique(faker.random.words(Math.ceil(Math.random() * 3))), + state: { + call_to_action: null, + datetime: derived(() => faker.date.past(0.25)().toISOString()), + priority: Priority.ONGOING_OPEN, + text: 'Ongoing', + }, + cover_image: { + src: '/static/course_cover_image.jpg', + }, +}); + +export const CourseLightFactory = createSpec({ code: faker.random.alphaNumeric(5), organization: OrganizationLightFactory, title: faker.unique(faker.random.words(Math.ceil(Math.random() * 3))), diff --git a/src/frontend/js/utils/test/factories/richie.ts b/src/frontend/js/utils/test/factories/richie.ts index 2de14a8938..fbef7e44b3 100644 --- a/src/frontend/js/utils/test/factories/richie.ts +++ b/src/frontend/js/utils/test/factories/richie.ts @@ -61,7 +61,7 @@ export const RichieContextFactory = (context: Partial context.web_analytics_providers || null), }); -export const CourseFactory = createSpec({ +export const CourseLightFactory = createSpec({ absolute_url: '', categories: [], code: faker.random.alphaNumeric(5), diff --git a/src/frontend/js/widgets/CourseAddToWishlist/index.tsx b/src/frontend/js/widgets/CourseAddToWishlist/index.tsx index 6003ac7b8c..27e8b56fda 100644 --- a/src/frontend/js/widgets/CourseAddToWishlist/index.tsx +++ b/src/frontend/js/widgets/CourseAddToWishlist/index.tsx @@ -37,7 +37,7 @@ enum ComponentStates { } export interface Props { - courseCode: Joanie.Course['code']; + courseCode: Joanie.CourseLight['code']; } const CourseAddToWishlist = ({ courseCode }: Props) => { diff --git a/src/frontend/js/widgets/CourseProductItem/components/CourseProductCourseRuns/index.spec.tsx b/src/frontend/js/widgets/CourseProductItem/components/CourseProductCourseRuns/index.spec.tsx index 5babda8fee..34fbbb5391 100644 --- a/src/frontend/js/widgets/CourseProductItem/components/CourseProductCourseRuns/index.spec.tsx +++ b/src/frontend/js/widgets/CourseProductItem/components/CourseProductCourseRuns/index.spec.tsx @@ -6,13 +6,13 @@ import { IntlProvider } from 'react-intl'; import { QueryClientProvider } from '@tanstack/react-query'; import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie'; import { - CourseFactory, + CourseLightFactory, CourseRunFactory, EnrollmentFactory, OrderFactory, } from 'utils/test/factories/joanie'; import JoanieApiProvider from 'contexts/JoanieApiContext'; -import type { Course, CourseRun, Enrollment, OrderLite } from 'types/Joanie'; +import type { CourseLight, CourseRun, Enrollment, OrderLite } from 'types/Joanie'; import { Deferred } from 'utils/test/deferred'; import { Priority } from 'types'; import { createTestQueryClient } from 'utils/test/createTestQueryClient'; @@ -133,7 +133,7 @@ describe('CourseProductCourseRuns', () => { }); it('renders a list of course runs with a call to action to enroll', async () => { - const course: Course = CourseFactory.generate(); + const course: CourseLight = CourseLightFactory.generate(); const courseRuns: CourseRun[] = CourseRunFactory().generate(2); const order: OrderLite = OrderFactory.generate(); @@ -233,7 +233,7 @@ describe('CourseProductCourseRuns', () => { }); it('enroll with errors', async () => { - const course: Course = CourseFactory.generate(); + const course: CourseLight = CourseLightFactory.generate(); const courseRuns: CourseRun[] = CourseRunFactory().generate(2); const order: OrderLite = OrderFactory.generate(); fetchMock.get(`https://joanie.test/api/v1.0/courses/${course.code}/`, 200); diff --git a/src/frontend/js/widgets/CourseProductItem/contexts/CourseProductContext/index.tsx b/src/frontend/js/widgets/CourseProductItem/contexts/CourseProductContext/index.tsx index 4c1ec09cd0..56aa63d2b3 100644 --- a/src/frontend/js/widgets/CourseProductItem/contexts/CourseProductContext/index.tsx +++ b/src/frontend/js/widgets/CourseProductItem/contexts/CourseProductContext/index.tsx @@ -4,13 +4,13 @@ import type * as Joanie from 'types/Joanie'; const Context = createContext< Maybe<{ - courseCode: Joanie.Course['code']; + courseCode: Joanie.CourseLight['code']; productId: Joanie.Product['id']; }> >(undefined); export interface CourseProductProviderProps { - courseCode: Joanie.Course['code']; + courseCode: Joanie.CourseLight['code']; productId: Joanie.Product['id']; } diff --git a/src/frontend/js/widgets/CourseProductItem/index.tsx b/src/frontend/js/widgets/CourseProductItem/index.tsx index d9f11fd611..cf1b340a9d 100644 --- a/src/frontend/js/widgets/CourseProductItem/index.tsx +++ b/src/frontend/js/widgets/CourseProductItem/index.tsx @@ -33,7 +33,7 @@ const messages = defineMessages({ export interface Props { productId: Joanie.Product['id']; - courseCode: Joanie.Course['code']; + courseCode: Joanie.CourseLight['code']; } const CourseProductItem = ({ productId, courseCode }: Props) => { diff --git a/src/frontend/js/widgets/Dashboard/components/DashboardItem/Certificate/index.spec.tsx b/src/frontend/js/widgets/Dashboard/components/DashboardItem/Certificate/index.spec.tsx index d7654009ac..b8ca41bca1 100644 --- a/src/frontend/js/widgets/Dashboard/components/DashboardItem/Certificate/index.spec.tsx +++ b/src/frontend/js/widgets/Dashboard/components/DashboardItem/Certificate/index.spec.tsx @@ -5,7 +5,7 @@ import { IntlProvider } from 'react-intl'; import userEvent from '@testing-library/user-event'; import fetchMock from 'fetch-mock'; import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie'; -import { Certificate, Course } from 'types/Joanie'; +import { Certificate, CourseLight } from 'types/Joanie'; import { createTestQueryClient } from 'utils/test/createTestQueryClient'; import { SessionProvider } from 'contexts/SessionContext'; import { DashboardItemCertificate } from 'widgets/Dashboard/components/DashboardItem/Certificate/index'; @@ -52,7 +52,7 @@ describe('', () => { render(, { wrapper: Wrapper }); await waitFor(() => screen.getByText(certificate.certificate_definition.title)); - screen.getByText((certificate.order.course as Course).title); + screen.getByText((certificate.order.course as CourseLight).title); screen.getByText( 'Issued on ' + new Intl.DateTimeFormat('en', DEFAULT_DATE_FORMAT).format(new Date(certificate.issued_on)), diff --git a/src/frontend/js/widgets/Dashboard/components/DashboardItem/Certificate/index.tsx b/src/frontend/js/widgets/Dashboard/components/DashboardItem/Certificate/index.tsx index b12ec3bf5b..dd4a63d00a 100644 --- a/src/frontend/js/widgets/Dashboard/components/DashboardItem/Certificate/index.tsx +++ b/src/frontend/js/widgets/Dashboard/components/DashboardItem/Certificate/index.tsx @@ -1,7 +1,7 @@ import { defineMessages, FormattedMessage } from 'react-intl'; import { Button } from 'components/Button'; import { Icon } from 'components/Icon'; -import { Certificate, CertificateDefinition, Course } from 'types/Joanie'; +import { Certificate, CertificateDefinition, CourseLight } from 'types/Joanie'; import { useDownloadCertificate } from 'hooks/useDownloadCertificate'; import { Spinner } from 'components/Spinner'; import useDateFormat from 'hooks/useDateFormat'; @@ -58,7 +58,7 @@ export const DashboardItemCertificate = ({ throw new Error('certificate or certificateDefinition is required'); } - const course = certificate?.order.course as Maybe; + const course = certificate?.order.course as Maybe; const { download, loading } = useDownloadCertificate(); const formatDate = useDateFormat(); diff --git a/src/frontend/js/widgets/Dashboard/components/DashboardItem/DashboardItemCourseEnrolling.stories.tsx b/src/frontend/js/widgets/Dashboard/components/DashboardItem/DashboardItemCourseEnrolling.stories.tsx index a52b4acfcc..0c5f2b57ce 100644 --- a/src/frontend/js/widgets/Dashboard/components/DashboardItem/DashboardItemCourseEnrolling.stories.tsx +++ b/src/frontend/js/widgets/Dashboard/components/DashboardItem/DashboardItemCourseEnrolling.stories.tsx @@ -1,7 +1,7 @@ import { Meta, StoryObj } from '@storybook/react'; import { createMemoryRouter, RouterProvider } from 'react-router-dom'; import faker from 'faker'; -import { CourseFactory } from 'utils/test/factories/joanie'; +import { CourseLightFactory } from 'utils/test/factories/joanie'; import { StorybookHelper } from 'utils/StorybookHelper'; import { Priority } from 'types'; import { enrollment } from './stories.mock'; @@ -39,7 +39,7 @@ type Story = StoryObj; export const ReadonlyEnrolledOpened: Story = { args: { - course: CourseFactory.generate(), + course: CourseLightFactory.generate(), activeEnrollment: { ...enrollment, course_run: { @@ -53,7 +53,7 @@ export const ReadonlyEnrolledOpened: Story = { export const ReadonlyEnrolledClosed: Story = { args: { - course: CourseFactory.generate(), + course: CourseLightFactory.generate(), activeEnrollment: { ...enrollment, course_run: { @@ -67,6 +67,6 @@ export const ReadonlyEnrolledClosed: Story = { export const ReadonlyNotEnrolled: Story = { args: { - course: CourseFactory.generate(), + course: CourseLightFactory.generate(), }, }; 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..2fcbf74016 100644 --- a/src/frontend/js/widgets/Dashboard/components/DashboardItem/stories.mock.ts +++ b/src/frontend/js/widgets/Dashboard/components/DashboardItem/stories.mock.ts @@ -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: '0001', 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..62aec5773f --- /dev/null +++ b/src/frontend/js/widgets/Dashboard/components/TeacherCourseSearchFilters/index.tsx @@ -0,0 +1,154 @@ +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', + }, +}); + +export interface TeacherCourseSearchFiltersBarProps { + filters: TeacherCourseSearchFilters; +} + +const buildOption = ({ + label, + prependLabel, + value, +}: { + label: string; + prependLabel: string; + value: string; +}) => ( + +); + +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_CERTIFICATE, + }, + ]; + + const onChangeFilter = (e: ChangeEvent) => { + const { + target: { name, value }, + } = e; + + navigate(`?${querystring.stringify({ ...filters, [name]: value })}`, { replace: true }); + }; + + return ( +
+ + + {statusOptions.map(({ label, value }) => + buildOption({ + label, + prependLabel: intl.formatMessage(messages.filterStatusPrepend), + value, + }), + )} + + + + {typeOptions.map(({ label, value }) => + buildOption({ + label, + prependLabel: intl.formatMessage(messages.filterTypePrepend), + value, + }), + )} + +
+ ); +}; + +export default TeacherCourseSearchFiltersBar; diff --git a/src/frontend/js/widgets/Dashboard/index.tsx b/src/frontend/js/widgets/Dashboard/index.tsx index beaf3b588e..5f8399bde5 100644 --- a/src/frontend/js/widgets/Dashboard/index.tsx +++ b/src/frontend/js/widgets/Dashboard/index.tsx @@ -7,7 +7,6 @@ import useDashboardRouter from './hooks/useDashboardRouter'; interface DashboardProps { router?: RouterProviderProps['router']; } - const Dashboard = ({ router }: DashboardProps) => { const { user } = useSession(); const routerToUse = router ?? useDashboardRouter(); 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}