From 73143771112973495ad9cc20234c8a22108ab1e0 Mon Sep 17 00:00:00 2001 From: Aura Alba Date: Thu, 1 Feb 2024 11:04:06 -0500 Subject: [PATCH] feat: Add weekly schedule UI --- package-lock.json | 27 ++++++ package.json | 1 + .../DashboardPage/_test_/index.test.jsx | 19 +++- .../Dashboard/DashboardPage/index.jsx | 6 ++ .../Dashboard/DashboardPage/index.scss | 4 + .../_test_/index.test.jsx | 2 +- .../InstructorAssignSection/index.jsx | 4 +- .../WeeklySchedule/_test_/index.test.jsx | 59 +++++++++++++ .../Dashboard/WeeklySchedule/index.jsx | 79 +++++++++++++++++ .../Dashboard/WeeklySchedule/index.scss | 87 +++++++++++++++++++ .../Dashboard/data/_test_/redux.test.jsx | 59 +++++++++++-- src/features/Dashboard/data/slice.js | 16 ++++ src/features/Dashboard/data/thunks.js | 20 +++-- src/index.scss | 2 + 14 files changed, 371 insertions(+), 14 deletions(-) create mode 100644 src/features/Dashboard/WeeklySchedule/_test_/index.test.jsx create mode 100644 src/features/Dashboard/WeeklySchedule/index.jsx create mode 100644 src/features/Dashboard/WeeklySchedule/index.scss diff --git a/package-lock.json b/package-lock.json index 806a55f5..2b7def23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "date-fns": "^3.3.1", "prop-types": "15.8.1", "react": "16.14.0", + "react-date-range": "^2.0.0-alpha.4", "react-dom": "16.14.0", "react-intl": "^5.25.1", "react-paragon-topaz": "^1.2.1", @@ -19871,6 +19872,21 @@ "react-dom": ">=16.8.0" } }, + "node_modules/react-date-range": { + "version": "2.0.0-alpha.4", + "resolved": "https://registry.npmjs.org/react-date-range/-/react-date-range-2.0.0-alpha.4.tgz", + "integrity": "sha512-8IP6DVW6nGQu1PUUw7iCAOnRfLP8cKrjDNKZFb9z7SAWUZ/RiUYNbUfdZxGySlcx5V52BpgFpEuAKD16pEN+MA==", + "dependencies": { + "classnames": "^2.2.6", + "prop-types": "^15.7.2", + "react-list": "^0.8.13", + "shallow-equal": "^1.2.1" + }, + "peerDependencies": { + "date-fns": "3.0.6 || >=3.0.0", + "react": "^0.14 || ^15.0.0-rc || >=15.0" + } + }, "node_modules/react-dev-utils": { "version": "12.0.1", "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz", @@ -20148,6 +20164,17 @@ "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" }, + "node_modules/react-list": { + "version": "0.8.17", + "resolved": "https://registry.npmjs.org/react-list/-/react-list-0.8.17.tgz", + "integrity": "sha512-pgmzGi0G5uGrdHzMhgO7KR1wx5ZXVvI3SsJUmkblSAKtewIhMwbQiMuQiTE83ozo04BQJbe0r3WIWzSO0dR1xg==", + "dependencies": { + "prop-types": "15" + }, + "peerDependencies": { + "react": "0.14 || 15 - 18" + } + }, "node_modules/react-loading-skeleton": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/react-loading-skeleton/-/react-loading-skeleton-3.2.1.tgz", diff --git a/package.json b/package.json index 101b134c..2f8d6cc0 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "date-fns": "^3.3.1", "prop-types": "15.8.1", "react": "16.14.0", + "react-date-range": "^2.0.0-alpha.4", "react-dom": "16.14.0", "react-intl": "^5.25.1", "react-paragon-topaz": "^1.2.1", diff --git a/src/features/Dashboard/DashboardPage/_test_/index.test.jsx b/src/features/Dashboard/DashboardPage/_test_/index.test.jsx index feac9ce9..370c2ae9 100644 --- a/src/features/Dashboard/DashboardPage/_test_/index.test.jsx +++ b/src/features/Dashboard/DashboardPage/_test_/index.test.jsx @@ -20,7 +20,7 @@ describe('DashboardPage component', () => { }, ], }, - classes: { + classesNoInstructors: { data: [ { classId: 'ccx-v1:demo+demo1+2020+ccx1', @@ -35,6 +35,21 @@ describe('DashboardPage component', () => { }, ], }, + classes: { + data: [ + { + classId: 'ccx-v1:demo+demo1+2020+ccx2', + className: 'ccx 2', + masterCourseName: 'Demo Course 1', + instructors: [], + numberOfStudents: 0, + numberOfPendingStudents: 0, + maxStudents: 20, + startDate: '2024-01-23T21:50:51Z', + endDate: null, + }, + ], + }, }, }; @@ -56,5 +71,7 @@ describe('DashboardPage component', () => { expect(getByText('ccx 1')).toBeInTheDocument(); expect(getByText('Demo Course 1')).toBeInTheDocument(); expect(getByText('License Name 1')).toBeInTheDocument(); + expect(getByText('Class schedule')).toBeInTheDocument(); + expect(getByText('ccx 2')).toBeInTheDocument(); }); }); diff --git a/src/features/Dashboard/DashboardPage/index.jsx b/src/features/Dashboard/DashboardPage/index.jsx index 0e2df626..138826e5 100644 --- a/src/features/Dashboard/DashboardPage/index.jsx +++ b/src/features/Dashboard/DashboardPage/index.jsx @@ -7,6 +7,7 @@ import StudentsMetrics from 'features/Students/StudentsMetrics'; import LicensesTable from 'features/Licenses/LicensesTable'; import { Button } from 'react-paragon-topaz'; import InstructorAssignSection from 'features/Dashboard/InstructorAssignSection'; +import WeeklySchedule from 'features/Dashboard/WeeklySchedule'; import { fetchLicensesData } from 'features/Dashboard/data'; import { updateActiveTab } from 'features/Main/data/slice'; @@ -50,6 +51,11 @@ const DashboardPage = () => { {Object.keys(selectedInstitution).length > 0 ? `Welcome to ${selectedInstitution?.name}` : `Welcome to ${stateInstitution[0]?.name}`} + + + + +
diff --git a/src/features/Dashboard/DashboardPage/index.scss b/src/features/Dashboard/DashboardPage/index.scss index 6f9ccdab..a51d46bd 100644 --- a/src/features/Dashboard/DashboardPage/index.scss +++ b/src/features/Dashboard/DashboardPage/index.scss @@ -14,3 +14,7 @@ box-shadow: 0px 3px 12px 0px rgba(0, 0, 0, 0.16); border-radius: 0.375rem; } + +.schedule-section { + margin-bottom: 2rem; +} diff --git a/src/features/Dashboard/InstructorAssignSection/_test_/index.test.jsx b/src/features/Dashboard/InstructorAssignSection/_test_/index.test.jsx index c98517b1..550a14c5 100644 --- a/src/features/Dashboard/InstructorAssignSection/_test_/index.test.jsx +++ b/src/features/Dashboard/InstructorAssignSection/_test_/index.test.jsx @@ -10,7 +10,7 @@ jest.mock('@edx/frontend-platform/logging', () => ({ describe('Instructor Assign component', () => { const mockStore = { dashboard: { - classes: { + classesNoInstructors: { data: [ { classId: 'ccx-v1:demo+demo1+2020+ccx1', diff --git a/src/features/Dashboard/InstructorAssignSection/index.jsx b/src/features/Dashboard/InstructorAssignSection/index.jsx index 8b4c9deb..c32ddf82 100644 --- a/src/features/Dashboard/InstructorAssignSection/index.jsx +++ b/src/features/Dashboard/InstructorAssignSection/index.jsx @@ -12,13 +12,13 @@ import 'features/Dashboard/InstructorAssignSection/index.scss'; const InstructorAssignSection = () => { const dispatch = useDispatch(); const selectedInstitution = useSelector((state) => state.main.selectedInstitution); - const classesData = useSelector((state) => state.dashboard.classes.data); + const classesData = useSelector((state) => state.dashboard.classesNoInstructors.data); const [classCards, setClassCards] = useState([]); const numberOfClasses = 2; useEffect(() => { if (Object.keys(selectedInstitution).length > 0) { - dispatch(fetchClassesData(selectedInstitution?.id)); + dispatch(fetchClassesData(selectedInstitution?.id, false)); } }, [selectedInstitution, dispatch]); diff --git a/src/features/Dashboard/WeeklySchedule/_test_/index.test.jsx b/src/features/Dashboard/WeeklySchedule/_test_/index.test.jsx new file mode 100644 index 00000000..0b7cf165 --- /dev/null +++ b/src/features/Dashboard/WeeklySchedule/_test_/index.test.jsx @@ -0,0 +1,59 @@ +import React from 'react'; +import WeeklySchedule from 'features/Dashboard/WeeklySchedule'; +import '@testing-library/jest-dom/extend-expect'; +import { renderWithProviders } from 'test-utils'; + +jest.mock('@edx/frontend-platform/logging', () => ({ + logError: jest.fn(), +})); + +describe('WeeklySchedule component', () => { + const mockStore = { + dashboard: { + classes: { + data: [ + { + classId: 'ccx-v1:demo+demo1+2020+ccx1', + className: 'ccx 1', + masterCourseName: 'Demo Course 1', + instructors: [ + 'instructor1', + ], + numberOfStudents: 0, + numberOfPendingStudents: 0, + maxStudents: 20, + startDate: '2024-01-23T21:50:51Z', + endDate: null, + }, + { + classId: 'ccx-v1:demo+demo1+2020+ccx2', + className: 'ccx 2', + masterCourseName: 'Demo Course 1', + instructors: [ + 'instructor1', + ], + numberOfStudents: 0, + numberOfPendingStudents: 0, + maxStudents: 20, + startDate: '2023-10-02T12:58:43Z', + endDate: null, + }, + ], + }, + }, + }; + const component = renderWithProviders( + , + { preloadedState: mockStore }, + ); + + test('renders components', () => { + const { getByText } = component; + + expect(getByText('Class schedule')).toBeInTheDocument(); + expect(getByText('ccx 1')).toBeInTheDocument(); + expect(getByText('Jan 23, 2024')).toBeInTheDocument(); + expect(getByText('ccx 2')).toBeInTheDocument(); + expect(getByText('Oct 2, 2023')).toBeInTheDocument(); + }); +}); diff --git a/src/features/Dashboard/WeeklySchedule/index.jsx b/src/features/Dashboard/WeeklySchedule/index.jsx new file mode 100644 index 00000000..ee26708c --- /dev/null +++ b/src/features/Dashboard/WeeklySchedule/index.jsx @@ -0,0 +1,79 @@ +import React, { useEffect, useState } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; + +import { DateRange } from 'react-date-range'; +import { + startOfWeek, + endOfWeek, + format, +} from 'date-fns'; + +import { fetchClassesData } from 'features/Dashboard/data'; + +import 'features/Dashboard/WeeklySchedule/index.scss'; + +const WeeklySchedule = () => { + const dispatch = useDispatch(); + const selectedInstitution = useSelector((state) => state.main.selectedInstitution); + const classesData = useSelector((state) => state.dashboard.classes.data); + const [classList, setClassList] = useState([]); + const startWeek = startOfWeek(new Date()); + const endWeek = endOfWeek(new Date()); + const [stateDate, setStateDate] = useState([ + { + startDate: startWeek, + endDate: endWeek, + key: 'selection', + }, + ]); + const numberOfClasses = 3; + + useEffect(() => { + if (Object.keys(selectedInstitution).length > 0) { + dispatch(fetchClassesData(selectedInstitution?.id, true)); + } + }, [selectedInstitution, dispatch]); + + useEffect(() => { + // Display only the first 'NumberOfClasses' on the homepage. + if (classesData.length > numberOfClasses) { + setClassList(classesData.slice(0, numberOfClasses)); + } else { + setClassList(classesData); + } + }, [classesData]); + + return ( + <> +
+

Class schedule

+
+
+
+ {classList.map(classInfo => { + const date = format(new Date(classInfo?.startDate), 'PP'); + return ( +
+
+

{classInfo?.className}

+

+ {date} +

+
+
+ ); + })} +
+ setStateDate([item.selection])} + moveRangeOnFirstSelection={false} + ranges={stateDate} + rangeColors={['#e4faff']} + /> +
+ + ); +}; + +export default WeeklySchedule; diff --git a/src/features/Dashboard/WeeklySchedule/index.scss b/src/features/Dashboard/WeeklySchedule/index.scss new file mode 100644 index 00000000..572f07ce --- /dev/null +++ b/src/features/Dashboard/WeeklySchedule/index.scss @@ -0,0 +1,87 @@ +@import "assets/colors.scss"; + +.header-schedule { + background-color: $primary; + padding: 1rem; + border-top-right-radius: 0.375rem; + border-top-left-radius: 0.375rem; + + h3 { + color: $color-white; + margin: 0; + } +} + +.content-schedule { + box-shadow: 0px 3px 12px 0px rgba(0, 0, 0, 0.16); + border-radius: 0.375rem; + + .rdrDay:not(.rdrDayPassive) .rdrInRange ~ .rdrDayNumber span, + .rdrDay:not(.rdrDayPassive) .rdrStartEdge ~ .rdrDayNumber span, + .rdrDay:not(.rdrDayPassive) .rdrEndEdge ~ .rdrDayNumber span, + .rdrDay:not(.rdrDayPassive) .rdrSelected ~ .rdrDayNumber span { + color: $primary; + } + + .rdrStartEdge { + border-top: 1px solid $primary; + border-left: 1px solid $primary; + border-bottom: 1px solid $primary; + } + + .rdrInRange { + border-top: 1px solid $primary; + border-bottom: 1px solid $primary; + } + + .rdrEndEdge { + border-top: 1px solid $primary; + border-right : 1px solid $primary; + border-bottom: 1px solid $primary; + } + + .rdrCalendarWrapper { + border-radius: 0.375rem; + } + + .rdrMonthAndYearPickers select, + .rdrMonthAndYearPickers select { + font-weight: 700; + } +} + +.container-class-schedule { + width: 100%; + background: $color-white; + padding: 0.8rem; + border-radius: 0.375rem; + border-right: 1px solid $gray-20; + + .class-schedule { + margin-top: 0.5rem; + border-bottom: 1px solid $gray-20; + + .class-name { + margin: 0; + font-weight: 600; + color: $primary; + } + + .class-descr { + margin: 0; + font-size: 12px; + } + + .fa-calendar-day { + margin-right: 8px; + } + } + + .class-text { + border-left: 3px solid $primary; + padding-left: 1rem; + padding-left: 1rem; + margin-bottom: 1rem; + padding-bottom: 0.5rem; + } +} diff --git a/src/features/Dashboard/data/_test_/redux.test.jsx b/src/features/Dashboard/data/_test_/redux.test.jsx index ac4ce56f..df5b6963 100644 --- a/src/features/Dashboard/data/_test_/redux.test.jsx +++ b/src/features/Dashboard/data/_test_/redux.test.jsx @@ -77,9 +77,58 @@ describe('Dashboard redux tests', () => { .toEqual('error'); }); - test('successful fetch classes data', async () => { + test('successful fetch classesNoInstructors data', async () => { + const classesApiUrl = `${process.env.COURSE_OPERATIONS_API_V2_BASE_URL}/classes/` + + '?limit=false&institution_id=1&course_name=&instructors=null'; + const mockResponse = [ + { + classId: 'ccx-v1:demo+demo1+2020+ccx1', + className: 'ccx 1', + masterCourseName: 'Demo Course 1', + instructors: [], + numberOfStudents: 0, + numberOfPendingStudents: 0, + maxStudents: 20, + startDate: '2024-01-23T21:50:51Z', + endDate: null, + }, + ]; + axiosMock.onGet(classesApiUrl) + .reply(200, mockResponse); + + expect(store.getState().dashboard.classesNoInstructors.status) + .toEqual('loading'); + + await executeThunk(fetchClassesData(1, false), store.dispatch, store.getState); + + expect(store.getState().dashboard.classesNoInstructors.data) + .toEqual(mockResponse); + + expect(store.getState().dashboard.classesNoInstructors.status) + .toEqual('success'); + }); + + test('failed fetch classesNoInstructors data', async () => { const classesApiUrl = `${process.env.COURSE_OPERATIONS_API_V2_BASE_URL}/classes/` + '?limit=false&institution_id=1&course_name=&instructors=null'; + axiosMock.onGet(classesApiUrl) + .reply(500); + + expect(store.getState().dashboard.classesNoInstructors.status) + .toEqual('loading'); + + await executeThunk(fetchClassesData(1, false), store.dispatch, store.getState); + + expect(store.getState().dashboard.classesNoInstructors.data) + .toEqual([]); + + expect(store.getState().dashboard.classesNoInstructors.status) + .toEqual('error'); + }); + + test('successful fetch classes data', async () => { + const classesApiUrl = `${process.env.COURSE_OPERATIONS_API_V2_BASE_URL}/classes/` + + '?limit=false&institution_id=1&course_name=&instructors='; const mockResponse = [ { classId: 'ccx-v1:demo+demo1+2020+ccx1', @@ -99,7 +148,7 @@ describe('Dashboard redux tests', () => { expect(store.getState().dashboard.classes.status) .toEqual('loading'); - await executeThunk(fetchClassesData(1), store.dispatch, store.getState); + await executeThunk(fetchClassesData(1, true), store.dispatch, store.getState); expect(store.getState().dashboard.classes.data) .toEqual(mockResponse); @@ -108,16 +157,16 @@ describe('Dashboard redux tests', () => { .toEqual('success'); }); - test('failed fetch licenses data', async () => { + test('failed fetch classes data', async () => { const classesApiUrl = `${process.env.COURSE_OPERATIONS_API_V2_BASE_URL}/classes/` - + '?limit=false&institution_id=1&course_name=&instructors=null'; + + '?limit=false&institution_id=1&course_name=&instructors='; axiosMock.onGet(classesApiUrl) .reply(500); expect(store.getState().dashboard.classes.status) .toEqual('loading'); - await executeThunk(fetchClassesData(1), store.dispatch, store.getState); + await executeThunk(fetchClassesData(1, true), store.dispatch, store.getState); expect(store.getState().dashboard.classes.data) .toEqual([]); diff --git a/src/features/Dashboard/data/slice.js b/src/features/Dashboard/data/slice.js index 31adc748..9591c2f7 100644 --- a/src/features/Dashboard/data/slice.js +++ b/src/features/Dashboard/data/slice.js @@ -6,6 +6,9 @@ const initialState = { tableLicense: { ...initialStateService, }, + classesNoInstructors: { + ...initialStateService, + }, classes: { ...initialStateService, }, @@ -25,6 +28,16 @@ export const dashboardSlice = createSlice({ fetchLicensesDataFailed: (state) => { state.tableLicense.status = RequestStatus.ERROR; }, + fetchClassesNoInstructorsDataRequest: (state) => { + state.classesNoInstructors.status = RequestStatus.LOADING; + }, + fetchClassesNoInstructorsDataSuccess: (state, { payload }) => { + state.classesNoInstructors.status = RequestStatus.SUCCESS; + state.classesNoInstructors.data = payload; + }, + fetchClassesNoInstructorsDataFailed: (state) => { + state.classesNoInstructors.status = RequestStatus.ERROR; + }, fetchClassesDataRequest: (state) => { state.classes.status = RequestStatus.LOADING; }, @@ -42,6 +55,9 @@ export const { fetchLicensesDataRequest, fetchLicensesDataSuccess, fetchLicensesDataFailed, + fetchClassesNoInstructorsDataRequest, + fetchClassesNoInstructorsDataSuccess, + fetchClassesNoInstructorsDataFailed, fetchClassesDataRequest, fetchClassesDataSuccess, fetchClassesDataFailed, diff --git a/src/features/Dashboard/data/thunks.js b/src/features/Dashboard/data/thunks.js index 9988ee98..231b5dfd 100644 --- a/src/features/Dashboard/data/thunks.js +++ b/src/features/Dashboard/data/thunks.js @@ -6,6 +6,9 @@ import { fetchLicensesDataRequest, fetchLicensesDataSuccess, fetchLicensesDataFailed, + fetchClassesNoInstructorsDataRequest, + fetchClassesNoInstructorsDataSuccess, + fetchClassesNoInstructorsDataFailed, fetchClassesDataRequest, fetchClassesDataSuccess, fetchClassesDataFailed, @@ -24,14 +27,21 @@ function fetchLicensesData(id) { }; } -function fetchClassesData(id) { +function fetchClassesData(id, hasInstructors = false) { return async (dispatch) => { - dispatch(fetchClassesDataRequest()); + // eslint-disable-next-line no-unused-expressions + hasInstructors ? dispatch(fetchClassesDataRequest()) : dispatch(fetchClassesNoInstructorsDataRequest()); try { - const response = camelCaseObject(await getClassesByInstitution(id, '', false, 'null')); - dispatch(fetchClassesDataSuccess(response.data)); + if (hasInstructors) { + const response = camelCaseObject(await getClassesByInstitution(id, '', false)); + dispatch(fetchClassesDataSuccess(response.data)); + } else { + const response = camelCaseObject(await getClassesByInstitution(id, '', false, 'null')); + dispatch(fetchClassesNoInstructorsDataSuccess(response.data)); + } } catch (error) { - dispatch(fetchClassesDataFailed()); + // eslint-disable-next-line no-unused-expressions + hasInstructors ? dispatch(fetchClassesDataFailed()) : dispatch(fetchClassesNoInstructorsDataFailed()); logError(error); } }; diff --git a/src/index.scss b/src/index.scss index 211ae33e..bbabd7bd 100644 --- a/src/index.scss +++ b/src/index.scss @@ -7,3 +7,5 @@ @import "react-paragon-topaz/src/Form/Form.scss"; @import "react-paragon-topaz/src/Pagination/Pagination.scss"; @import "assets/global.scss"; +@import "react-date-range/dist/styles.css"; +@import "react-date-range/dist/theme/default.css";