From bde4b174f3848b433a549509c087d59d090ce24c Mon Sep 17 00:00:00 2001 From: Sergio Valero Date: Mon, 19 Feb 2024 19:39:32 -0400 Subject: [PATCH] feat: add Classes Table component --- src/features/Classes/ClassesPage/index.jsx | 53 ++++++++++++++++++ .../ClassesTable/_test_/index.test.jsx | 55 +++++++++++++++++++ src/features/Classes/ClassesTable/columns.jsx | 42 ++++++++++++++ src/features/Classes/ClassesTable/index.jsx | 43 +++++++++++++++ src/features/Classes/_test_/index.test.jsx | 0 .../Classes/data/_test_/redux.test.js | 0 src/features/Classes/data/index.js | 2 + src/features/Classes/data/slice.js | 51 +++++++++++++++++ src/features/Classes/data/thunks.js | 26 +++++++++ src/features/Common/data/api.js | 4 +- src/features/Main/Sidebar/index.jsx | 11 ++++ src/features/Main/index.jsx | 4 ++ src/store.js | 2 + 13 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 src/features/Classes/ClassesPage/index.jsx create mode 100644 src/features/Classes/ClassesTable/_test_/index.test.jsx create mode 100644 src/features/Classes/ClassesTable/columns.jsx create mode 100644 src/features/Classes/ClassesTable/index.jsx create mode 100644 src/features/Classes/_test_/index.test.jsx create mode 100644 src/features/Classes/data/_test_/redux.test.js create mode 100644 src/features/Classes/data/index.js create mode 100644 src/features/Classes/data/slice.js create mode 100644 src/features/Classes/data/thunks.js diff --git a/src/features/Classes/ClassesPage/index.jsx b/src/features/Classes/ClassesPage/index.jsx new file mode 100644 index 00000000..d7c3dc08 --- /dev/null +++ b/src/features/Classes/ClassesPage/index.jsx @@ -0,0 +1,53 @@ +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import Container from '@edx/paragon/dist/Container'; +import { Pagination } from '@edx/paragon'; +import ClassesTable from 'features/Classes/ClassesTable'; + +import { updateCurrentPage } from 'features/Classes/data/slice'; +import { fetchClassesData } from 'features/Classes/data/thunks'; +import { initialPage } from 'features/constants'; + +const ClassesPage = () => { + const selectedInstitution = useSelector((state) => state.main.selectedInstitution); + const stateClasses = useSelector((state) => state.classes); + const dispatch = useDispatch(); + const [currentPage, setCurrentPage] = useState(initialPage); + + useEffect(() => { + if (Object.keys(selectedInstitution).length > 0) { + dispatch(fetchClassesData(selectedInstitution.id, currentPage)); + } + }, [currentPage, selectedInstitution, dispatch]); // eslint-disable-line react-hooks/exhaustive-deps + + const handlePagination = (targetPage) => { + setCurrentPage(targetPage); + dispatch(updateCurrentPage(targetPage)); + }; + + return ( + +

Classes

+
+ + {stateClasses.table.numPages > 1 && ( + + )} +
+
+ ); +}; + +export default ClassesPage; diff --git a/src/features/Classes/ClassesTable/_test_/index.test.jsx b/src/features/Classes/ClassesTable/_test_/index.test.jsx new file mode 100644 index 00000000..d1f48a60 --- /dev/null +++ b/src/features/Classes/ClassesTable/_test_/index.test.jsx @@ -0,0 +1,55 @@ +import React from 'react'; +import CoursesPage from 'features/Courses/CoursesPage'; +import { waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import { renderWithProviders } from 'test-utils'; + +jest.mock('@edx/frontend-platform/logging', () => ({ + logError: jest.fn(), +})); + +const mockStore = { + courses: { + table: { + data: [ + { + masterCourseName: 'Demo Course 1', + numberOfClasses: 1, + missingClassesForInstructor: null, + numberOfStudents: 1, + numberOfPendingStudents: 1, + }, + { + masterCourseName: 'Demo Course 2', + numberOfClasses: 1, + missingClassesForInstructor: 1, + numberOfStudents: 16, + numberOfPendingStudents: 0, + }, + ], + count: 2, + num_pages: 1, + current_page: 1, + }, + }, +}; + +describe('CoursesPage', () => { + it('renders courses data and pagination', async () => { + const component = renderWithProviders( + , + { preloadedState: mockStore }, + ); + + waitFor(() => { + expect(component.container).toHaveTextContent('Demo Course 1'); + expect(component.container).toHaveTextContent('Demo Course 2'); + expect(component.container).toHaveTextContent('Ready'); + expect(component.container).toHaveTextContent('Missing (1)'); + expect(component.container).toHaveTextContent('Pending (1)'); + expect(component.container).toHaveTextContent('Complete'); + expect(component.container).toHaveTextContent('1/2'); + expect(component.container).toHaveTextContent('16/16'); + }); + }); +}); diff --git a/src/features/Classes/ClassesTable/columns.jsx b/src/features/Classes/ClassesTable/columns.jsx new file mode 100644 index 00000000..3825e49d --- /dev/null +++ b/src/features/Classes/ClassesTable/columns.jsx @@ -0,0 +1,42 @@ +/* eslint-disable react/prop-types, no-nested-ternary */ +import React from 'react'; +import { format } from 'date-fns'; + +const columns = [ + { + Header: 'Course', + accessor: 'masterCourseName', + }, + { + Header: 'Class', + accessor: 'className', + }, + { + Header: 'Start Date', + accessor: 'startDate', + Cell: ({ row }) => (row.values.startDate ? format(row.values.startDate, 'MM/dd/yy') : ''), + }, + { + Header: 'End Date', + accessor: 'endDate', + Cell: ({ row }) => (row.values.endDate ? format(row.values.endDate, 'MM/dd/yy') : ''), + }, + { + Header: 'Students Enrolled', + accessor: 'numberOfStudents', + }, + { + Header: 'Max', + accessor: 'maxStudents', + }, + { + Header: 'Instructors', + accessor: ({ instructors }) => ( + + ), + }, +]; + +export { columns }; diff --git a/src/features/Classes/ClassesTable/index.jsx b/src/features/Classes/ClassesTable/index.jsx new file mode 100644 index 00000000..f2c2eb33 --- /dev/null +++ b/src/features/Classes/ClassesTable/index.jsx @@ -0,0 +1,43 @@ +import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; + +import { IntlProvider } from 'react-intl'; +import { Row, Col } from '@edx/paragon'; +import DataTable from '@edx/paragon/dist/DataTable'; + +import { columns } from 'features/Classes/ClassesTable/columns'; + +const ClassesTable = ({ data, count }) => { + const COLUMNS = useMemo(() => columns, []); + + return ( + + + + + + + + + + + + ); +}; + +ClassesTable.propTypes = { + data: PropTypes.arrayOf(PropTypes.shape([])), + count: PropTypes.number, +}; + +ClassesTable.defaultProps = { + data: [], + count: 0, +}; + +export default ClassesTable; diff --git a/src/features/Classes/_test_/index.test.jsx b/src/features/Classes/_test_/index.test.jsx new file mode 100644 index 00000000..e69de29b diff --git a/src/features/Classes/data/_test_/redux.test.js b/src/features/Classes/data/_test_/redux.test.js new file mode 100644 index 00000000..e69de29b diff --git a/src/features/Classes/data/index.js b/src/features/Classes/data/index.js new file mode 100644 index 00000000..a6a37d7b --- /dev/null +++ b/src/features/Classes/data/index.js @@ -0,0 +1,2 @@ +export { reducer } from 'features/Classes/data/slice'; +export { fetchClassesData } from 'features/Classes/data/thunks'; diff --git a/src/features/Classes/data/slice.js b/src/features/Classes/data/slice.js new file mode 100644 index 00000000..fa58456b --- /dev/null +++ b/src/features/Classes/data/slice.js @@ -0,0 +1,51 @@ +/* eslint-disable no-param-reassign */ +import { createSlice } from '@reduxjs/toolkit'; +import { RequestStatus } from 'features/constants'; + +const initialState = { + table: { + currentPage: 1, + data: [], + status: RequestStatus.LOADING, + error: null, + numPages: 0, + count: 0, + }, + filters: {}, +}; + +export const classesSlice = createSlice({ + name: 'classes', + initialState, + reducers: { + updateCurrentPage: (state, { payload }) => { + state.table.currentPage = payload; + }, + fetchClassesDataRequest: (state) => { + state.table.status = RequestStatus.LOADING; + }, + fetchClassesDataSuccess: (state, { payload }) => { + const { results, count, numPages } = payload; + state.table.status = RequestStatus.SUCCESS; + state.table.data = results; + state.table.numPages = numPages; + state.table.count = count; + }, + fetchClassesDataFailed: (state) => { + state.table.status = RequestStatus.ERROR; + }, + updateFilters: (state, { payload }) => { + state.filters = payload; + }, + }, +}); + +export const { + updateCurrentPage, + fetchClassesDataRequest, + fetchClassesDataSuccess, + fetchClassesDataFailed, + updateFilters, +} = classesSlice.actions; + +export const { reducer } = classesSlice; diff --git a/src/features/Classes/data/thunks.js b/src/features/Classes/data/thunks.js new file mode 100644 index 00000000..0091b763 --- /dev/null +++ b/src/features/Classes/data/thunks.js @@ -0,0 +1,26 @@ +import { logError } from '@edx/frontend-platform/logging'; +import { camelCaseObject } from '@edx/frontend-platform'; +import { + fetchClassesDataRequest, + fetchClassesDataSuccess, + fetchClassesDataFailed, +} from 'features/Classes/data/slice'; +import { getClassesByInstitution } from 'features/Common/data/api'; + +function fetchClassesData(id, currentPage) { + return async (dispatch) => { + dispatch(fetchClassesDataRequest); + + try { + const response = camelCaseObject(await getClassesByInstitution(id, '', true, '', currentPage)); + dispatch(fetchClassesDataSuccess(response.data)); + } catch (error) { + dispatch(fetchClassesDataFailed()); + logError(error); + } + }; +} + +export { + fetchClassesData, +}; diff --git a/src/features/Common/data/api.js b/src/features/Common/data/api.js index 669dbfa3..8c7d9fdb 100644 --- a/src/features/Common/data/api.js +++ b/src/features/Common/data/api.js @@ -19,12 +19,12 @@ function getLicensesByInstitution(institutionId, limit, page = '') { ); } -function getClassesByInstitution(institutionId, courseName, limit = false, instructorsList = '') { +function getClassesByInstitution(institutionId, courseName, limit = false, instructorsList = '', page = '') { const encodedCourseName = encodeURIComponent(courseName); return getAuthenticatedHttpClient().get( `${getConfig().COURSE_OPERATIONS_API_V2_BASE_URL}/classes` - + `/?limit=${limit}&institution_id=${institutionId}&course_name=${encodedCourseName}&instructors=${instructorsList}`, + + `/?limit=${limit}&institution_id=${institutionId}&course_name=${encodedCourseName}&instructors=${instructorsList}&page=${page}`, ); } diff --git a/src/features/Main/Sidebar/index.jsx b/src/features/Main/Sidebar/index.jsx index 0c51f18b..2542398a 100644 --- a/src/features/Main/Sidebar/index.jsx +++ b/src/features/Main/Sidebar/index.jsx @@ -73,6 +73,17 @@ export const Sidebar = () => { Courses +
  • + +
  • diff --git a/src/features/Main/index.jsx b/src/features/Main/index.jsx index e56b6c8f..b7e6e994 100644 --- a/src/features/Main/index.jsx +++ b/src/features/Main/index.jsx @@ -14,6 +14,7 @@ import InstructorsPage from 'features/Instructors/InstructorsPage'; import CoursesPage from 'features/Courses/CoursesPage'; import DashboardPage from 'features/Dashboard/DashboardPage'; import LicensesPage from 'features/Licenses/LicensesPage'; +import ClassesPage from 'features/Classes/ClassesPage'; import { Select } from 'react-paragon-topaz'; import { fetchInstitutionData } from 'features/Main/data/thunks'; @@ -93,6 +94,9 @@ const Main = () => { + + +