diff --git a/package-lock.json b/package-lock.json index 50b808b7..3e75adad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@pearsonedunext/frontend-component-cookie-policy-banner": "^5.0.2", "@reduxjs/toolkit": "^1.9.5", + "@testing-library/react-hooks": "^8.0.1", "core-js": "3.31.0", "date-fns": "^3.3.1", "prop-types": "15.8.1", @@ -4798,6 +4799,35 @@ "react-dom": "<18.0.0" } }, + "node_modules/@testing-library/react-hooks": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz", + "integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "react-error-boundary": "^3.1.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "@types/react": "^16.9.0 || ^17.0.0", + "react": "^16.9.0 || ^17.0.0", + "react-dom": "^16.9.0 || ^17.0.0", + "react-test-renderer": "^16.9.0 || ^17.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-test-renderer": { + "optional": true + } + } + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -5079,12 +5109,12 @@ "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==" }, "node_modules/@types/react": { - "version": "18.0.38", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.38.tgz", - "integrity": "sha512-ExsidLLSzYj4cvaQjGnQCk4HFfVT9+EZ9XZsQ8Hsrcn8QNgXtpZ3m9vSIC2MWtx7jHictK6wYhQgGh6ic58oOw==", + "version": "17.0.80", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.80.tgz", + "integrity": "sha512-LrgHIu2lEtIo8M7d1FcI3BdwXWoRQwMoXOZ7+dPTW0lYREjmlHl3P0U1VD0i/9tppOuv8/sam7sOjx34TxSFbA==", "dependencies": { "@types/prop-types": "*", - "@types/scheduler": "*", + "@types/scheduler": "^0.16", "csstype": "^3.0.2" } }, @@ -5097,17 +5127,6 @@ "@types/react": "^17" } }, - "node_modules/@types/react-dom/node_modules/@types/react": { - "version": "17.0.62", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.62.tgz", - "integrity": "sha512-eANCyz9DG8p/Vdhr0ZKST8JV12PhH2ACCDYlFw6DIO+D+ca+uP4jtEDEpVqXZrh/uZdXQGwk7whJa3ah5DtyLw==", - "dev": true, - "dependencies": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" - } - }, "node_modules/@types/react-redux": { "version": "7.1.25", "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.25.tgz", @@ -16919,6 +16938,21 @@ "react": ">= 16.8 || 18.0.0" } }, + "node_modules/react-error-boundary": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-error-overlay": { "version": "6.0.11", "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz", diff --git a/package.json b/package.json index e1d379af..9d63f758 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@pearsonedunext/frontend-component-cookie-policy-banner": "^5.0.2", "@reduxjs/toolkit": "^1.9.5", + "@testing-library/react-hooks": "^8.0.1", "core-js": "3.31.0", "date-fns": "^3.3.1", "prop-types": "15.8.1", diff --git a/src/features/Classes/Class/ClassPage/Actions.jsx b/src/features/Classes/Class/ClassPage/Actions.jsx index 8e19a96d..e12989ec 100644 --- a/src/features/Classes/Class/ClassPage/Actions.jsx +++ b/src/features/Classes/Class/ClassPage/Actions.jsx @@ -11,6 +11,7 @@ import { import { MoreVert } from '@edx/paragon/icons'; import { setAssignStaffRole } from 'helpers'; +import { useInstitutionIdQueryParam } from 'hooks'; import AddClass from 'features/Courses/AddClass'; import EnrollStudent from 'features/Classes/EnrollStudent'; @@ -33,9 +34,10 @@ const Actions = ({ previousPage }) => { const [isEnrollModalOpen, setIsEnrollModalOpen] = useState(false); const handleEnrollStudentModal = () => setIsEnrollModalOpen(!isEnrollModalOpen); + const addQueryParam = useInstitutionIdQueryParam(); const handleManageButton = () => { - history.push(`/manage-instructors/${courseName}/${className}?classId=${queryClassId}&previous=${previousPage}`); + history.push(addQueryParam(`/manage-instructors/${courseName}/${className}?classId=${queryClassId}&previous=${previousPage}`)); }; return ( diff --git a/src/features/Classes/Class/ClassPage/index.jsx b/src/features/Classes/Class/ClassPage/index.jsx index 65b0ed7a..e8fbe289 100644 --- a/src/features/Classes/Class/ClassPage/index.jsx +++ b/src/features/Classes/Class/ClassPage/index.jsx @@ -17,6 +17,8 @@ import { initialPage, RequestStatus } from 'features/constants'; import { resetClassesTable, resetClasses } from 'features/Classes/data/slice'; import { fetchAllClassesData } from 'features/Classes/data/thunks'; +import { useInstitutionIdQueryParam } from 'hooks'; + import 'features/Classes/Class/ClassPage/index.scss'; const ClassPage = () => { @@ -33,6 +35,7 @@ const ClassPage = () => { const [currentPage, setCurrentPage] = useState(initialPage); const institution = useSelector((state) => state.main.selectedInstitution); const students = useSelector((state) => state.students.table); + const addQueryParam = useInstitutionIdQueryParam(); const isLoadingStudents = students.status === RequestStatus.LOADING; @@ -87,8 +90,9 @@ const ClassPage = () => { } if (institution.id !== institutionRef.current) { - history.push('/courses'); + history.push(addQueryParam('/courses')); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [institution, history]); return ( diff --git a/src/features/Classes/ClassesTable/columns.jsx b/src/features/Classes/ClassesTable/columns.jsx index 34ce2193..a04e23ec 100644 --- a/src/features/Classes/ClassesTable/columns.jsx +++ b/src/features/Classes/ClassesTable/columns.jsx @@ -1,6 +1,5 @@ /* eslint-disable react/prop-types */ import React from 'react'; -import { Link } from 'react-router-dom'; import { Dropdown, @@ -13,6 +12,7 @@ import { MoreHoriz } from '@edx/paragon/icons'; import { getConfig } from '@edx/frontend-platform'; import AddClass from 'features/Courses/AddClass'; +import LinkWithQuery from 'features/Main/LinkWithQuery'; import { formatUTCDate, setAssignStaffRole } from 'helpers'; @@ -21,12 +21,12 @@ const columns = [ Header: 'Class', accessor: 'className', Cell: ({ row }) => ( - {row.values.className} - + ), }, { @@ -124,13 +124,13 @@ const columns = [ View class content - Manage Instructors - + { const { courseName } = useParams(); + return ( - {row.values.className} - + ); }, }, @@ -186,13 +188,13 @@ const columns = [ View class content - Manage Instructors - + diff --git a/src/features/Courses/CoursesDetailPage/index.jsx b/src/features/Courses/CoursesDetailPage/index.jsx index eb1950a0..7d192ccd 100644 --- a/src/features/Courses/CoursesDetailPage/index.jsx +++ b/src/features/Courses/CoursesDetailPage/index.jsx @@ -1,20 +1,23 @@ import React, { useState, useEffect, useRef, useMemo, } from 'react'; -import { Link, useParams, useHistory } from 'react-router-dom'; +import { useParams, useHistory } from 'react-router-dom'; import { useDispatch, useSelector } from 'react-redux'; - +import { getConfig } from '@edx/frontend-platform'; import { Container, Pagination, useToggle } from '@edx/paragon'; -import CourseDetailTable from 'features/Courses/CourseDetailTable'; import { Button } from 'react-paragon-topaz'; -import { getConfig } from '@edx/frontend-platform'; + import AddClass from 'features/Courses/AddClass'; +import LinkWithQuery from 'features/Main/LinkWithQuery'; +import CourseDetailTable from 'features/Courses/CourseDetailTable'; import { fetchClassesData } from 'features/Classes/data/thunks'; import { fetchCoursesData } from 'features/Courses/data/thunks'; import { fetchClassesDataSuccess } from 'features/Classes/data/slice'; import { fetchCoursesDataSuccess, updateCurrentPage } from 'features/Courses/data/slice'; + import { initialPage } from 'features/constants'; +import { useInstitutionIdQueryParam } from 'hooks'; import 'features/Courses/CoursesDetailPage/index.scss'; @@ -23,6 +26,7 @@ const CoursesDetailPage = () => { const dispatch = useDispatch(); const { courseName } = useParams(); const courseNameDecoded = decodeURIComponent(courseName); + const addQueryParam = useInstitutionIdQueryParam(); const institutionRef = useRef(undefined); const [currentPage, setCurrentPage] = useState(initialPage); @@ -83,17 +87,18 @@ const CoursesDetailPage = () => { } if (institution.id !== institutionRef.current) { - history.push('/courses'); + history.push(addQueryParam('/courses')); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [institution, history]); return (
- + - +

{courseNameDecoded}

diff --git a/src/features/Courses/CoursesTable/columns.jsx b/src/features/Courses/CoursesTable/columns.jsx index 0a18fbb2..6f6da4d1 100644 --- a/src/features/Courses/CoursesTable/columns.jsx +++ b/src/features/Courses/CoursesTable/columns.jsx @@ -2,19 +2,20 @@ import React from 'react'; import { getConfig } from '@edx/frontend-platform'; -import { Link } from 'react-router-dom'; import { Badge } from 'react-paragon-topaz'; import { Dropdown, IconButton, Icon, useToggle, } from '@edx/paragon'; import { MoreHoriz } from '@edx/paragon/icons'; + import AddClass from 'features/Courses/AddClass'; +import LinkWithQuery from 'features/Main/LinkWithQuery'; const columns = [ { Header: 'Courses', accessor: 'masterCourseName', - Cell: ({ row }) => ({row.values.masterCourseName}), + Cell: ({ row }) => ({row.values.masterCourseName}), }, { Header: 'Classes', diff --git a/src/features/Dashboard/DashboardPage/index.jsx b/src/features/Dashboard/DashboardPage/index.jsx index 8a0f90ce..745a32d5 100644 --- a/src/features/Dashboard/DashboardPage/index.jsx +++ b/src/features/Dashboard/DashboardPage/index.jsx @@ -13,6 +13,8 @@ import WeeklySchedule from 'features/Dashboard/WeeklySchedule'; import { fetchLicensesData } from 'features/Dashboard/data'; import { updateActiveTab } from 'features/Main/data/slice'; +import { useInstitutionIdQueryParam } from 'hooks'; + import 'features/Dashboard/DashboardPage/index.scss'; const DashboardPage = () => { @@ -24,8 +26,10 @@ const DashboardPage = () => { const [dataTableLicense, setDataTableLicense] = useState([]); const imageDashboard = getConfig().IMAGE_DASHBOARD_URL; + const addQueryParam = useInstitutionIdQueryParam(); + const handleViewAllLicenses = () => { - history.push('/licenses'); + history.push(addQueryParam('/licenses')); dispatch(updateActiveTab('licenses')); }; diff --git a/src/features/Dashboard/InstructorAssignSection/ClassCard.jsx b/src/features/Dashboard/InstructorAssignSection/ClassCard.jsx index 5ae1abbd..d31920fe 100644 --- a/src/features/Dashboard/InstructorAssignSection/ClassCard.jsx +++ b/src/features/Dashboard/InstructorAssignSection/ClassCard.jsx @@ -5,14 +5,16 @@ import PropTypes from 'prop-types'; import { Button } from 'react-paragon-topaz'; import { formatDateRange } from 'helpers'; +import { useInstitutionIdQueryParam } from 'hooks'; import 'features/Dashboard/InstructorAssignSection/index.scss'; const ClassCard = ({ data }) => { const history = useHistory(); + const addQueryParam = useInstitutionIdQueryParam(); const handleManageButton = () => { - history.push(`/manage-instructors/${encodeURIComponent(data?.masterCourseName)}/${encodeURIComponent(data?.className)}?classId=${data?.classId}&previous=dashboard`); + history.push(addQueryParam(`/manage-instructors/${encodeURIComponent(data?.masterCourseName)}/${encodeURIComponent(data?.className)}?classId=${data?.classId}&previous=dashboard`)); }; return ( diff --git a/src/features/Dashboard/InstructorAssignSection/index.jsx b/src/features/Dashboard/InstructorAssignSection/index.jsx index be558277..08637e08 100644 --- a/src/features/Dashboard/InstructorAssignSection/index.jsx +++ b/src/features/Dashboard/InstructorAssignSection/index.jsx @@ -9,6 +9,8 @@ import { Button } from 'react-paragon-topaz'; import { fetchClassesData } from 'features/Dashboard/data'; import { updateActiveTab } from 'features/Main/data/slice'; +import { useInstitutionIdQueryParam } from 'hooks'; + import 'features/Dashboard/InstructorAssignSection/index.scss'; const InstructorAssignSection = () => { @@ -19,8 +21,10 @@ const InstructorAssignSection = () => { const [classCards, setClassCards] = useState([]); const numberOfClasses = 2; + const addQueryParam = useInstitutionIdQueryParam(); + const handleViewAllClasses = () => { - history.push('/classes?instructors=null'); + history.push(addQueryParam('/classes?instructors=null')); dispatch(updateActiveTab('classes')); }; diff --git a/src/features/Dashboard/WeeklySchedule/index.jsx b/src/features/Dashboard/WeeklySchedule/index.jsx index c39b5cc8..420800c9 100644 --- a/src/features/Dashboard/WeeklySchedule/index.jsx +++ b/src/features/Dashboard/WeeklySchedule/index.jsx @@ -1,6 +1,5 @@ import React, { useEffect, useState } from 'react'; import { useSelector, useDispatch } from 'react-redux'; -import { Link } from 'react-router-dom'; import { Schedule } from 'react-paragon-topaz'; import { @@ -10,6 +9,7 @@ import { } from 'date-fns'; import { fetchClassesData } from 'features/Dashboard/data'; +import LinkWithQuery from 'features/Main/LinkWithQuery'; import { formatUTCDate } from 'helpers'; @@ -77,12 +77,12 @@ const WeeklySchedule = () => { return (
- {classInfo?.className} - +

{date} diff --git a/src/features/Instructors/InstructorDetailTable/columns.jsx b/src/features/Instructors/InstructorDetailTable/columns.jsx index a02d6264..67b54cbf 100644 --- a/src/features/Instructors/InstructorDetailTable/columns.jsx +++ b/src/features/Instructors/InstructorDetailTable/columns.jsx @@ -1,22 +1,23 @@ /* eslint-disable react/prop-types, no-nested-ternary */ import React from 'react'; -import { Link } from 'react-router-dom'; import { Badge } from 'react-paragon-topaz'; import { ClassStatus, badgeVariants } from 'features/constants'; import { formatUTCDate } from 'helpers'; +import LinkWithQuery from 'features/Main/LinkWithQuery'; + const columns = [ { Header: 'Class', accessor: 'className', Cell: ({ row }) => ( - {row.values.className} - + ), }, { diff --git a/src/features/Instructors/InstructorsDetailPage/index.jsx b/src/features/Instructors/InstructorsDetailPage/index.jsx index 4313f96c..1b3db6f5 100644 --- a/src/features/Instructors/InstructorsDetailPage/index.jsx +++ b/src/features/Instructors/InstructorsDetailPage/index.jsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef } from 'react'; -import { Link, useParams, useHistory } from 'react-router-dom'; +import { useParams, useHistory } from 'react-router-dom'; import { useDispatch, useSelector } from 'react-redux'; import { Container, @@ -14,10 +14,15 @@ import { fetchInstructorsData } from 'features/Instructors/data/thunks'; import { columns } from 'features/Instructors/InstructorDetailTable/columns'; import { initialPage, RequestStatus } from 'features/constants'; +import { useInstitutionIdQueryParam } from 'hooks'; + +import LinkWithQuery from 'features/Main/LinkWithQuery'; + const InstructorsDetailPage = () => { const history = useHistory(); const dispatch = useDispatch(); const { instructorUsername } = useParams(); + const addQueryParam = useInstitutionIdQueryParam(); const institutionRef = useRef(undefined); const [currentPage, setCurrentPage] = useState(initialPage); @@ -59,17 +64,18 @@ const InstructorsDetailPage = () => { } if (institution.id !== institutionRef.current) { - history.push('/instructors'); + history.push(addQueryParam('/instructors')); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [institution, history]); return (

- + - +

{instructorInfo.instructorName}

diff --git a/src/features/Instructors/InstructorsTable/columns.jsx b/src/features/Instructors/InstructorsTable/columns.jsx index c27db08a..118a65b2 100644 --- a/src/features/Instructors/InstructorsTable/columns.jsx +++ b/src/features/Instructors/InstructorsTable/columns.jsx @@ -1,20 +1,21 @@ /* eslint-disable react/prop-types, no-nested-ternary */ import { differenceInHours, differenceInDays, differenceInWeeks } from 'date-fns'; -import { Link } from 'react-router-dom'; import { daysWeek, hoursDay } from 'features/constants'; +import LinkWithQuery from 'features/Main/LinkWithQuery'; + const columns = [ { Header: 'Instructor', accessor: 'instructorName', Cell: ({ row }) => ( - {row.values.instructorName} - + ), }, { diff --git a/src/features/Licenses/LicenseDetailTable/columns.jsx b/src/features/Licenses/LicenseDetailTable/columns.jsx index d29e7a14..a81aa1a9 100644 --- a/src/features/Licenses/LicenseDetailTable/columns.jsx +++ b/src/features/Licenses/LicenseDetailTable/columns.jsx @@ -1,12 +1,12 @@ /* eslint-disable react/prop-types, no-nested-ternary */ -import { Link } from 'react-router-dom'; +import LinkWithQuery from 'features/Main/LinkWithQuery'; const columns = [ { Header: 'Course', accessor: 'masterCourseName', Cell: ({ row }) => ( - {row.values.masterCourseName} + {row.values.masterCourseName} ), }, { diff --git a/src/features/Licenses/LicensesDetailPage/index.jsx b/src/features/Licenses/LicensesDetailPage/index.jsx index 4fa46056..9ff8b431 100644 --- a/src/features/Licenses/LicensesDetailPage/index.jsx +++ b/src/features/Licenses/LicensesDetailPage/index.jsx @@ -1,22 +1,27 @@ import React, { useState, useEffect, useRef } from 'react'; -import { Link, useParams, useHistory } from 'react-router-dom'; +import { useParams, useHistory } from 'react-router-dom'; import { useDispatch, useSelector } from 'react-redux'; - import { Button } from 'react-paragon-topaz'; import { Container, Pagination, Spinner } from '@edx/paragon'; + import Table from 'features/Main/Table'; +import LinkWithQuery from 'features/Main/LinkWithQuery'; + import { columns } from 'features/Licenses/LicenseDetailTable/columns'; import { fetchCoursesData } from 'features/Courses/data/thunks'; import { resetCoursesTable, updateCurrentPage } from 'features/Courses/data/slice'; import { fetchLicensesData } from 'features/Licenses/data'; import { initialPage, licenseBuyLink, RequestStatus } from 'features/constants'; +import { useInstitutionIdQueryParam } from 'hooks'; + import 'features/Licenses/LicensesDetailPage/index.scss'; const LicensesDetailPage = () => { const history = useHistory(); const dispatch = useDispatch(); const { licenseId } = useParams(); + const addQueryParam = useInstitutionIdQueryParam(); const institutionRef = useRef(undefined); const [currentPage, setCurrentPage] = useState(initialPage); @@ -58,17 +63,18 @@ const LicensesDetailPage = () => { } if (institution.id !== institutionRef.current) { - history.push('/licenses'); + history.push(addQueryParam('/licenses')); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [institution, history]); return (
- + - +

License pool: {licenseInfo.licenseName}

diff --git a/src/features/Licenses/LicensesTable/columns.jsx b/src/features/Licenses/LicensesTable/columns.jsx index ec20cf12..6e3c2699 100644 --- a/src/features/Licenses/LicensesTable/columns.jsx +++ b/src/features/Licenses/LicensesTable/columns.jsx @@ -1,11 +1,11 @@ /* eslint-disable react/prop-types */ -import { Link } from 'react-router-dom'; +import LinkWithQuery from 'features/Main/LinkWithQuery'; const columns = [ { Header: 'License Pool', accessor: 'licenseName', - Cell: ({ row }) => ({row.values.licenseName}), + Cell: ({ row }) => ({row.values.licenseName}), }, { Header: 'Purchased', diff --git a/src/features/Main/InstitutionSelector/__test__/index.test.jsx b/src/features/Main/InstitutionSelector/__test__/index.test.jsx new file mode 100644 index 00000000..e8eb5955 --- /dev/null +++ b/src/features/Main/InstitutionSelector/__test__/index.test.jsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { Router } from 'react-router-dom'; +import { fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; + +import InstitutionSelector from 'features/Main/InstitutionSelector'; +import { renderWithProviders } from 'test-utils'; + +jest.mock('react-select', () => function reactSelect({ options, currentValue, onChange }) { + function handleChange(event) { + onChange({ id: event.currentTarget.value }); + + return event; + } + + return ( + + ); +}); + +describe('InstitutionSelector', () => { + let history; + + beforeEach(() => { + history = { + location: { search: '' }, + push: jest.fn(), + replace: jest.fn(), + listen: jest.fn(), + createHref: jest.fn(), + }; + }); + + test('Should render the select options and handle selection', () => { + const preloadedState = { + main: { + institution: { + data: [ + { id: 1, name: 'Institution 1' }, + { id: 2, name: 'Institution 2' }, + ], + }, + selectedInstitution: null, + }, + }; + + const { getByText, getByTestId } = renderWithProviders( + + + , + { preloadedState }, + ); + + expect(getByText('Select an institution')).toBeInTheDocument(); + + fireEvent.change(getByTestId('select'), { target: { value: '1', id: '1' } }); + + expect(getByText('Institution 1')).toBeInTheDocument(); + }); +}); diff --git a/src/features/Main/InstitutionSelector/index.jsx b/src/features/Main/InstitutionSelector/index.jsx new file mode 100644 index 00000000..0170a9ef --- /dev/null +++ b/src/features/Main/InstitutionSelector/index.jsx @@ -0,0 +1,84 @@ +import React, { + useEffect, + useState, +} from 'react'; + +import { Row, Col } from '@edx/paragon'; +import { Select } from 'react-paragon-topaz'; + +import { useLocation, useHistory } from 'react-router-dom'; +import { useSelector, useDispatch } from 'react-redux'; + +import { formatSelectOptions } from 'helpers'; +import { INSTITUTION_QUERY_ID } from 'features/constants'; + +import { updateSelectedInstitution } from 'features/Main/data/slice'; + +const selectorStyles = { + control: base => ({ + ...base, + padding: '3px', + }), +}; + +const InstitutionSelector = () => { + const dispatch = useDispatch(); + + const history = useHistory(); + const location = useLocation(); + const institutions = useSelector((state) => state.main.institution.data); + const selectedInstitution = useSelector((state) => state.main.selectedInstitution); + + const [institutionOptions, setInstitutionOptions] = useState([]); + + const searchParams = new URLSearchParams(location.search); + + const updateQueryParam = (value) => { + const { id = '' } = value; + searchParams.set(INSTITUTION_QUERY_ID, id); + history.push({ search: searchParams.toString() }); + + dispatch(updateSelectedInstitution(value)); + }; + + useEffect(() => { + if (institutions.length > 0) { + const institutionId = searchParams.get(INSTITUTION_QUERY_ID); + + const options = formatSelectOptions(institutions); + setInstitutionOptions(options); + + if (institutionId) { + const institutionByQuery = options.filter((option) => option.id === parseInt(institutionId, 10)); + + if (institutionByQuery[0]) { + dispatch(updateSelectedInstitution(institutionByQuery[0])); + } else { + dispatch(updateSelectedInstitution(options[0])); + } + } else { + updateQueryParam(options[0]); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [institutions, dispatch]); + + return ( + + +

Select an institution

+ ({ - ...base, - padding: '3px', - }), - }} - placeholder="Institution" - name="institution" - options={institutionOptions} - defaultValue={institutionOptions[0]} - onChange={option => dispatch(updateSelectedInstitution(option))} - value={selectedInstitution} - /> - - - )} + {institutions.length > 1 && ()} - + {routes.map(({ path, exact, component: Component }) => ( ( - {row.values.className} - + ), }, { diff --git a/src/features/constants.js b/src/features/constants.js index 17784a8b..7d8c59a0 100644 --- a/src/features/constants.js +++ b/src/features/constants.js @@ -64,3 +64,15 @@ export const badgeVariants = { complete: 'success', pending: 'warning', }; + +/** + * Query parameter name for the institution ID. + * @constant {string} + */ +export const INSTITUTION_QUERY_ID = 'institutionId'; + +/** + * Text to inform users about the use of cookies on the website. + * @constant {string} + */ +export const cookieText = 'This website uses cookies to ensure you get the best experience on our website. If you continue browsing this site, we understand that you accept the use of cookies.'; diff --git a/src/helpers/index.js b/src/helpers/index.js index 38132165..9a3bd867 100644 --- a/src/helpers/index.js +++ b/src/helpers/index.js @@ -118,3 +118,38 @@ export const getInitials = (name) => { export const setAssignStaffRole = (url, classId) => assignStaffRole(classId).catch(logError).finally(() => { window.open(url, '_blank', 'noopener,noreferrer'); }); + +/** + * Transforms an array of options to the format required for select components. + * + * @param {Array} options - The array of options to be formatted. + * @param {string} options[].name - The name of the option. + * @param {number|string} options[].id - The ID of the option. + * + * @returns {Array|null} The formatted options array or null if there's an error. + */ +export const formatSelectOptions = (options) => { + if (!Array.isArray(options)) { + logError('An array is required'); + + return []; + } + + if (options.length === 0) { + logError('An array with options are required'); + + return []; + } + + if (Object.keys(options[0]).length === 0) { + logError('An array with keys are required'); + + return []; + } + + return options.map(option => ({ + ...option, + label: option.name, + value: option.id, + })); +}; diff --git a/src/hooks/__test__/index.test.jsx b/src/hooks/__test__/index.test.jsx new file mode 100644 index 00000000..7a387dc2 --- /dev/null +++ b/src/hooks/__test__/index.test.jsx @@ -0,0 +1,51 @@ +import { renderHook } from '@testing-library/react-hooks'; + +import { renderWithProviders } from 'test-utils'; +import { useInstitutionIdQueryParam } from 'hooks'; +import { INSTITUTION_QUERY_ID } from 'features/constants'; + +describe('useInstitutionIdQueryParam', () => { + test('Should return the URL unchanged if institutionId is not defined', () => { + const preloadedState = { + main: { + selectedInstitution: null, + }, + }; + + const { result } = renderHook(() => useInstitutionIdQueryParam(), { + wrapper: ({ children }) => renderWithProviders(children, { preloadedState }).store, + }); + + expect(result.current('http://example.com')).toBe('http://example.com'); + }); + + test('Should add the institutionId as a query param if institutionId is defined', () => { + const institutionId = '12345'; + const preloadedState = { + main: { + selectedInstitution: { id: institutionId }, + }, + }; + + const { result } = renderHook(() => useInstitutionIdQueryParam(), { + wrapper: ({ children }) => renderWithProviders(children, { preloadedState }).store, + }); + + expect(result.current('http://example.com')).toBe(`http://example.com?${INSTITUTION_QUERY_ID}=${institutionId}`); + }); + + test('Should append the institutionId as a query param if other query params exist', () => { + const institutionId = '12345'; + const preloadedState = { + main: { + selectedInstitution: { id: institutionId }, + }, + }; + + const { result } = renderHook(() => useInstitutionIdQueryParam(), { + wrapper: ({ children }) => renderWithProviders(children, { preloadedState }).store, + }); + + expect(result.current('http://example.com?foo=bar')).toBe(`http://example.com?foo=bar&${INSTITUTION_QUERY_ID}=${institutionId}`); + }); +}); diff --git a/src/hooks/index.jsx b/src/hooks/index.jsx new file mode 100644 index 00000000..92ee9444 --- /dev/null +++ b/src/hooks/index.jsx @@ -0,0 +1,18 @@ +import { useSelector } from 'react-redux'; + +import { INSTITUTION_QUERY_ID } from 'features/constants'; + +export const useInstitutionIdQueryParam = () => { + const institutionId = useSelector((state) => state.main.selectedInstitution?.id); + + const addQueryParam = (url) => { + if (!institutionId) { + return url; + } + + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}${INSTITUTION_QUERY_ID}=${institutionId}`; + }; + + return addQueryParam; +}; diff --git a/webpack.dev.config.js b/webpack.dev.config.js index d034dd0d..17555e95 100644 --- a/webpack.dev.config.js +++ b/webpack.dev.config.js @@ -7,6 +7,7 @@ module.exports = createConfig('webpack-dev', { features: path.resolve(__dirname, 'src/features'), assets: path.resolve(__dirname, 'src/assets'), helpers: path.resolve(__dirname, 'src/helpers'), + hooks: path.resolve(__dirname, 'src/hooks'), }, }, }); diff --git a/webpack.prod.config.js b/webpack.prod.config.js index 15a92a35..1c75898f 100644 --- a/webpack.prod.config.js +++ b/webpack.prod.config.js @@ -7,6 +7,7 @@ module.exports = createConfig('webpack-prod', { features: path.resolve(__dirname, 'src/features'), assets: path.resolve(__dirname, 'src/assets'), helpers: path.resolve(__dirname, 'src/helpers'), + hooks: path.resolve(__dirname, 'src/hooks'), }, }, });