diff --git a/jsconfig.json b/jsconfig.json deleted file mode 100644 index ea773e93..00000000 --- a/jsconfig.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "compilerOptions": { - "baseUrl": "src" - }, - "include": ["src"] - } \ No newline at end of file diff --git a/src/features/Courses/CoursesFilters/_test_/index.test.jsx b/src/features/Courses/CoursesFilters/_test_/index.test.jsx index 2b4ec2bc..74f487e5 100644 --- a/src/features/Courses/CoursesFilters/_test_/index.test.jsx +++ b/src/features/Courses/CoursesFilters/_test_/index.test.jsx @@ -1,8 +1,18 @@ /* eslint-disable react/prop-types */ +import MockAdapter from 'axios-mock-adapter'; import React from 'react'; +import { Provider } from 'react-redux'; import { render, fireEvent, act } from '@testing-library/react'; import CoursesFilters from 'features/Courses/CoursesFilters'; import '@testing-library/jest-dom/extend-expect'; +import { initializeStore } from 'store'; +import { fetchCoursesData } from 'features/Courses/data/thunks'; +import { executeThunk } from 'test-utils'; +import { initializeMockApp } from '@edx/frontend-platform/testing'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +let axiosMock; +let store; jest.mock('react-select', () => function reactSelect({ options, valueR, onChange }) { function handleChange(event) { @@ -23,14 +33,8 @@ jest.mock('react-select', () => function reactSelect({ options, valueR, onChange ); }); -describe('InstructorsFilters Component', () => { - const mockSetFilters = jest.fn(); - - beforeEach(() => { - mockSetFilters.mockClear(); - }); - - const dataCourses = [ +const mockResponse = { + results: [ { masterCourseName: 'Demo Course 1', numberOfClasses: 1, @@ -38,27 +42,47 @@ describe('InstructorsFilters Component', () => { 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('CoursesFilters Component', () => { + const mockSetFilters = jest.fn(); + + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 1, + username: 'testuser', + administrator: true, + roles: [], + }, + }); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + mockSetFilters.mockClear(); + store = initializeStore(); + + const coursesApiUrl = `${process.env.COURSE_OPERATIONS_API_V2_BASE_URL}/courses/?limit=true&institution_id=1`; + axiosMock.onGet(coursesApiUrl) + .reply(200, mockResponse); + }); + + afterEach(() => { + axiosMock.reset(); + }); test('call service when apply filters', async () => { - const fetchData = jest.fn(); const resetPagination = jest.fn(); const { getByText, getByTestId } = render( - , + + + , ); + await executeThunk(fetchCoursesData(1), store.dispatch, store.getState); + const courseSelect = getByTestId('select'); const buttonApplyFilters = getByText('Apply'); @@ -71,19 +95,14 @@ describe('InstructorsFilters Component', () => { await act(async () => { fireEvent.click(buttonApplyFilters); }); - expect(fetchData).toHaveBeenCalledTimes(1); }); test('clear filters', async () => { - const fetchData = jest.fn(); const resetPagination = jest.fn(); const { getByText, getByTestId } = render( - , + + + , ); const courseSelect = getByTestId('select'); @@ -97,6 +116,5 @@ describe('InstructorsFilters Component', () => { await act(async () => { fireEvent.click(buttonClearFilters); }); - expect(resetPagination).toHaveBeenCalledTimes(1); }); }); diff --git a/src/features/Courses/CoursesFilters/index.jsx b/src/features/Courses/CoursesFilters/index.jsx index 728ff4f2..2d59c571 100644 --- a/src/features/Courses/CoursesFilters/index.jsx +++ b/src/features/Courses/CoursesFilters/index.jsx @@ -1,46 +1,57 @@ import React, { useState, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { Col, Form } from '@edx/paragon'; import { Select, Button } from 'react-paragon-topaz'; import { logError } from '@edx/frontend-platform/logging'; import PropTypes from 'prop-types'; -const CoursesFilters = ({ - fetchData, resetPagination, dataCourses, setFilters, -}) => { +import { updateFilters, updateCurrentPage } from 'features/Courses/data/slice'; +import { fetchCoursesData } from 'features/Courses/data/thunks'; +import { initialPage } from 'features/constants'; + +const CoursesFilters = ({ resetPagination }) => { + const dispatch = useDispatch(); + const stateInstitution = useSelector((state) => state.main.institution.data); + const stateCourses = useSelector((state) => state.courses.table.data); const [courseOptions, setCourseOptions] = useState([]); const [courseSelected, setCourseSelected] = useState(null); + let id = ''; + if (stateInstitution.length === 1) { + id = stateInstitution[0].id; + } const handleCoursesFilter = async (e) => { e.preventDefault(); const form = e.target; const formData = new FormData(form); const formJson = Object.fromEntries(formData.entries()); - setFilters(formJson); + dispatch(updateFilters(formJson)); try { - fetchData(formJson); + dispatch(updateCurrentPage(initialPage)); + dispatch(fetchCoursesData(id, initialPage, formJson)); } catch (error) { logError(error); } }; const handleCleanFilters = () => { - fetchData(); + dispatch(fetchCoursesData(id)); resetPagination(); setCourseSelected(null); - setFilters({}); + dispatch(updateFilters({})); }; useEffect(() => { - if (dataCourses.length > 0) { - const options = dataCourses.map(course => ({ + if (stateCourses.length > 0) { + const options = stateCourses.map(course => ({ ...course, label: course.masterCourseName, value: course.masterCourseName, })); setCourseOptions(options); } - }, [dataCourses]); + }, [stateCourses]); return (
@@ -70,10 +81,7 @@ const CoursesFilters = ({ }; CoursesFilters.propTypes = { - fetchData: PropTypes.func.isRequired, resetPagination: PropTypes.func.isRequired, - dataCourses: PropTypes.instanceOf(Array).isRequired, - setFilters: PropTypes.func.isRequired, }; export default CoursesFilters; diff --git a/src/features/Courses/CoursesPage/_test_/reducer.test.jsx b/src/features/Courses/CoursesPage/_test_/reducer.test.jsx deleted file mode 100644 index c0c91718..00000000 --- a/src/features/Courses/CoursesPage/_test_/reducer.test.jsx +++ /dev/null @@ -1,71 +0,0 @@ -import { - FETCH_COURSES_DATA_FAILURE, - FETCH_COURSES_DATA_REQUEST, - FETCH_COURSES_DATA_SUCCESS, - UPDATE_CURRENT_PAGE, -} from 'features/Courses/actionTypes'; -import { RequestStatus } from 'features/constants'; -import reducer from 'features/Courses/CoursesPage/reducer'; - -describe('Instructor page reducers', () => { - const initialState = { - data: [], - status: RequestStatus.SUCCESS, - error: null, - currentPage: 1, - numPages: 0, - }; - - test('should handle FETCH_COURSES_DATA_REQUEST', () => { - const state = { - ...initialState, - status: RequestStatus.LOADING, - }; - const action = { - type: FETCH_COURSES_DATA_REQUEST, - }; - expect(reducer(state, action)).toEqual(state); - }); - - test('should handle FETCH_COURSES_DATA_SUCCESSS', () => { - const state = { - ...initialState, - status: RequestStatus.SUCCESS, - count: 0, - }; - const action = { - type: FETCH_COURSES_DATA_SUCCESS, - payload: { - results: [], - count: 0, - numPages: 0, - }, - }; - expect(reducer(state, action)).toEqual(state); - }); - - test('should handle FETCH_COURSES_DATA_FAILURE', () => { - const state = { - ...initialState, - status: RequestStatus.ERROR, - error: '', - }; - const action = { - type: FETCH_COURSES_DATA_FAILURE, - payload: '', - }; - expect(reducer(state, action)).toEqual(state); - }); - - test('should handle UPDATE_CURRENT_PAGE', () => { - const state = { - ...initialState, - currentPage: 1, - }; - const action = { - type: UPDATE_CURRENT_PAGE, - payload: 1, - }; - expect(reducer(state, action)).toEqual(state); - }); -}); diff --git a/src/features/Courses/CoursesPage/index.jsx b/src/features/Courses/CoursesPage/index.jsx index 9c59b92d..2b935195 100644 --- a/src/features/Courses/CoursesPage/index.jsx +++ b/src/features/Courses/CoursesPage/index.jsx @@ -1,88 +1,52 @@ -import React, { - useEffect, useState, useReducer, -} from 'react'; -import { useSelector } from 'react-redux'; -import { camelCaseObject } from '@edx/frontend-platform'; +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; -import { logError } from '@edx/frontend-platform/logging'; import Container from '@edx/paragon/dist/Container'; import { Pagination } from '@edx/paragon'; import CoursesTable from 'features/Courses/CoursesTable'; import CoursesFilters from 'features/Courses/CoursesFilters'; -import reducer from 'features/Courses/CoursesPage/reducer'; -import { getCoursesByInstitution } from 'features/Common/data/api'; -import { - FETCH_COURSES_DATA_REQUEST, - FETCH_COURSES_DATA_SUCCESS, - FETCH_COURSES_DATA_FAILURE, - UPDATE_CURRENT_PAGE, -} from 'features/Courses/actionTypes'; -import { RequestStatus } from 'features/constants'; - -const initialState = { - data: [], - status: RequestStatus.SUCCESS, - error: null, - currentPage: 1, - numPages: 0, -}; +import { updateCurrentPage } from 'features/Courses/data/slice'; +import { fetchCoursesData } from 'features/Courses/data/thunks'; +import { initialPage } from 'features/constants'; const CoursesPage = () => { const stateInstitution = useSelector((state) => state.main.institution.data); - const [state, dispatch] = useReducer(reducer, initialState); - const [currentPage, setCurrentPage] = useState(1); - const [filters, setFilters] = useState({}); + const stateCourses = useSelector((state) => state.courses); + const dispatch = useDispatch(); + const [currentPage, setCurrentPage] = useState(initialPage); // check this after implementation of selector institution let id = ''; if (stateInstitution.length === 1) { id = stateInstitution[0].id; } - const fetchData = async (filtersData) => { - dispatch({ type: FETCH_COURSES_DATA_REQUEST }); - - try { - const response = camelCaseObject(await getCoursesByInstitution(id, true, currentPage, filtersData)); - dispatch({ type: FETCH_COURSES_DATA_SUCCESS, payload: response.data }); - } catch (error) { - dispatch({ type: FETCH_COURSES_DATA_FAILURE, payload: error }); - logError(error); - } - }; - useEffect(() => { - fetchData(filters); - }, [currentPage, id, filters]); // eslint-disable-line react-hooks/exhaustive-deps + dispatch(fetchCoursesData(id, currentPage, stateCourses.filters)); + }, [currentPage]); // eslint-disable-line react-hooks/exhaustive-deps const handlePagination = (targetPage) => { setCurrentPage(targetPage); - dispatch({ type: UPDATE_CURRENT_PAGE, payload: targetPage }); - fetchData(); + dispatch(updateCurrentPage(targetPage)); }; const resetPagination = () => { - setCurrentPage(1); + setCurrentPage(initialPage); }; return (

Courses

- + - {state.numPages > 1 && ( + {stateCourses.table.numPages > 1 && ( { - switch (action.type) { - case FETCH_COURSES_DATA_REQUEST: - return { ...state, status: RequestStatus.LOADING }; - case FETCH_COURSES_DATA_SUCCESS: { - const { results, count, numPages } = action.payload; - return { - ...state, - status: RequestStatus.SUCCESS, - data: results, - numPages, - count, - }; - } - case FETCH_COURSES_DATA_FAILURE: - return { - ...state, - status: RequestStatus.ERROR, - error: action.payload, - }; - case UPDATE_CURRENT_PAGE: - return { - ...state, - currentPage: action.payload, - }; - default: - return state; - } -}; - -export default reducer; diff --git a/src/features/Courses/CoursesTable/index.jsx b/src/features/Courses/CoursesTable/index.jsx index 9674484b..4c809732 100644 --- a/src/features/Courses/CoursesTable/index.jsx +++ b/src/features/Courses/CoursesTable/index.jsx @@ -2,10 +2,7 @@ import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; import { IntlProvider } from 'react-intl'; -import { - Row, - Col, -} from '@edx/paragon'; +import { Row, Col } from '@edx/paragon'; import DataTable from '@edx/paragon/dist/DataTable'; import { columns } from 'features/Courses/CoursesTable/columns'; diff --git a/src/features/Courses/actionTypes.js b/src/features/Courses/actionTypes.js deleted file mode 100644 index 156fc950..00000000 --- a/src/features/Courses/actionTypes.js +++ /dev/null @@ -1,4 +0,0 @@ -export const FETCH_COURSES_DATA_REQUEST = 'FETCH_COURSES_DATA_REQUEST'; -export const FETCH_COURSES_DATA_SUCCESS = 'FETCH_COURSES_DATA_SUCCESS'; -export const FETCH_COURSES_DATA_FAILURE = 'FETCH_COURSES_DATA_FAILURE'; -export const UPDATE_CURRENT_PAGE = 'UPDATE_CURRENT_PAGE'; diff --git a/src/features/Courses/data/_test_/redux.test.js b/src/features/Courses/data/_test_/redux.test.js new file mode 100644 index 00000000..1a7debab --- /dev/null +++ b/src/features/Courses/data/_test_/redux.test.js @@ -0,0 +1,104 @@ +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { initializeMockApp } from '@edx/frontend-platform/testing'; +import { fetchCoursesData } from 'features/Courses/data/thunks'; +import { updateCurrentPage, updateFilters } from 'features/Courses/data/slice'; +import { executeThunk } from 'test-utils'; +import { initializeStore } from 'store'; + +let axiosMock; +let store; + +describe('Courses redux tests', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 1, + username: 'testuser', + administrator: true, + roles: [], + }, + }); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + + store = initializeStore(); + }); + + afterEach(() => { + axiosMock.reset(); + }); + + test('successful fetch courses data', async () => { + const coursesApiUrl = `${process.env.COURSE_OPERATIONS_API_V2_BASE_URL}/courses/?limit=true&institution_id=1`; + const mockResponse = { + results: [ + { + masterCourseName: 'Demo Course 1', + numberOfClasses: 1, + missingClassesForInstructor: null, + numberOfStudents: 1, + numberOfPendingStudents: 11, + }, + ], + count: 2, + num_pages: 1, + current_page: 1, + }; + axiosMock.onGet(coursesApiUrl) + .reply(200, mockResponse); + + expect(store.getState().courses.table.status) + .toEqual('loading'); + + await executeThunk(fetchCoursesData(1), store.dispatch, store.getState); + + expect(store.getState().courses.table.data) + .toEqual(mockResponse.results); + + expect(store.getState().courses.table.status) + .toEqual('success'); + }); + + test('failed fetch courses data', async () => { + const coursesApiUrl = `${process.env.COURSE_OPERATIONS_API_V2_BASE_URL}/courses/?limit=true&institution_id=1`; + axiosMock.onGet(coursesApiUrl) + .reply(500); + + expect(store.getState().courses.table.status) + .toEqual('loading'); + + await executeThunk(fetchCoursesData(1), store.dispatch, store.getState); + + expect(store.getState().courses.table.data) + .toEqual([]); + + expect(store.getState().courses.table.status) + .toEqual('error'); + }); + + test('update current page', () => { + const newPage = 2; + const intialState = store.getState().courses.table; + const expectState = { + ...intialState, + currentPage: newPage, + }; + + store.dispatch(updateCurrentPage(newPage)); + expect(store.getState().courses.table).toEqual(expectState); + }); + + test('update filters', () => { + const filters = { + course_name: 'Demo Course 1', + }; + const intialState = store.getState().courses.filters; + const expectState = { + ...intialState, + ...filters, + }; + + store.dispatch(updateFilters(filters)); + expect(store.getState().courses.filters).toEqual(expectState); + }); +}); diff --git a/src/features/Courses/data/index.js b/src/features/Courses/data/index.js new file mode 100644 index 00000000..0db816a6 --- /dev/null +++ b/src/features/Courses/data/index.js @@ -0,0 +1,2 @@ +export { reducer } from 'features/Courses/data/slice'; +export { fetchCoursesData } from 'features/Courses/data/thunks'; diff --git a/src/features/Courses/data/slice.js b/src/features/Courses/data/slice.js new file mode 100644 index 00000000..3be35933 --- /dev/null +++ b/src/features/Courses/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 coursesSlice = createSlice({ + name: 'courses', + initialState, + reducers: { + updateCurrentPage: (state, { payload }) => { + state.table.currentPage = payload; + }, + fetchCoursesDataRequest: (state) => { + state.table.status = RequestStatus.LOADING; + }, + fetchCoursesDataSuccess: (state, { payload }) => { + const { results, count, numPages } = payload; + state.table.status = RequestStatus.SUCCESS; + state.table.data = results; + state.table.numPages = numPages; + state.table.count = count; + }, + fetchCoursesDataFailed: (state) => { + state.table.status = RequestStatus.ERROR; + }, + updateFilters: (state, { payload }) => { + state.filters = payload; + }, + }, +}); + +export const { + updateCurrentPage, + fetchCoursesDataRequest, + fetchCoursesDataSuccess, + fetchCoursesDataFailed, + updateFilters, +} = coursesSlice.actions; + +export const { reducer } = coursesSlice; diff --git a/src/features/Courses/data/thunks.js b/src/features/Courses/data/thunks.js new file mode 100644 index 00000000..1222f4d2 --- /dev/null +++ b/src/features/Courses/data/thunks.js @@ -0,0 +1,26 @@ +import { logError } from '@edx/frontend-platform/logging'; +import { camelCaseObject } from '@edx/frontend-platform'; +import { + fetchCoursesDataRequest, + fetchCoursesDataSuccess, + fetchCoursesDataFailed, +} from 'features/Courses/data/slice'; +import { getCoursesByInstitution } from 'features/Common/data/api'; + +function fetchCoursesData(id, currentPage, filtersData) { + return async (dispatch) => { + dispatch(fetchCoursesDataRequest); + + try { + const response = camelCaseObject(await getCoursesByInstitution(id, true, currentPage, filtersData)); + dispatch(fetchCoursesDataSuccess(response.data)); + } catch (error) { + dispatch(fetchCoursesDataFailed()); + logError(error); + } + }; +} + +export { + fetchCoursesData, +}; diff --git a/src/features/Instructors/InstructorsFilters/index.jsx b/src/features/Instructors/InstructorsFilters/index.jsx index 8dbf9b57..7dd05d13 100644 --- a/src/features/Instructors/InstructorsFilters/index.jsx +++ b/src/features/Instructors/InstructorsFilters/index.jsx @@ -9,8 +9,9 @@ import { Select, Button } from 'react-paragon-topaz'; import { logError } from '@edx/frontend-platform/logging'; import { fetchInstructorsData, fetchCoursesData } from 'features/Instructors/data/thunks'; -import { updateFilters } from 'features/Instructors/data/slice'; +import { updateFilters, updateCurrentPage } from 'features/Instructors/data/slice'; import PropTypes from 'prop-types'; +import { initialPage } from 'features/constants'; const InstructorsFilters = ({ resetPagination }) => { const stateInstitution = useSelector((state) => state.main.institution.data); @@ -43,7 +44,8 @@ const InstructorsFilters = ({ resetPagination }) => { const formJson = Object.fromEntries(formData.entries()); dispatch(updateFilters(formJson)); try { - dispatch(fetchInstructorsData(currentPage, formJson)); + dispatch(updateCurrentPage(initialPage)); + dispatch(fetchInstructorsData(initialPage, formJson)); } catch (error) { logError(error); } diff --git a/src/features/Instructors/InstructorsPage/index.jsx b/src/features/Instructors/InstructorsPage/index.jsx index 074ee418..a31ae6ca 100644 --- a/src/features/Instructors/InstructorsPage/index.jsx +++ b/src/features/Instructors/InstructorsPage/index.jsx @@ -2,22 +2,19 @@ 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 { Pagination } from '@edx/paragon'; import InstructorsTable from 'features/Instructors/InstructorsTable'; import InstructorsFilters from 'features/Instructors/InstructorsFilters'; import AddInstructors from 'features/Instructors/AddInstructors'; -import { - updateCurrentPage, -} from 'features/Instructors/data/slice'; +import { updateCurrentPage } from 'features/Instructors/data/slice'; import { fetchInstructorsData } from 'features/Instructors/data/thunks'; +import { initialPage } from 'features/constants'; const InstructorsPage = () => { - const stateInstructors = useSelector((state) => state.instructors.table); + const stateInstructors = useSelector((state) => state.instructors); const dispatch = useDispatch(); - const [currentPage, setCurrentPage] = useState(1); + const [currentPage, setCurrentPage] = useState(initialPage); useEffect(() => { dispatch(fetchInstructorsData(currentPage, stateInstructors.filters)); @@ -29,7 +26,7 @@ const InstructorsPage = () => { }; const resetPagination = () => { - setCurrentPage(1); + setCurrentPage(initialPage); }; return ( @@ -41,13 +38,13 @@ const InstructorsPage = () => {
- {stateInstructors.numPages > 1 && ( + {stateInstructors.table.numPages > 1 && ( { - switch (action.type) { - case FETCH_INSTITUTION_DATA_REQUEST: - return { ...state, status: RequestStatus.LOADING }; - case FETCH_INSTITUTION_DATA_SUCCESS: - return { - ...state, - status: RequestStatus.SUCCESS, - data: action.payload, - }; - case FETCH_INSTITUTION_DATA_FAILURE: - return { - ...state, - status: RequestStatus.ERROR, - error: action.payload, - }; - default: - return state; - } -}; - -export default reducer; diff --git a/src/features/Students/StudentsFilters/_test_/index.test.jsx b/src/features/Students/StudentsFilters/_test_/index.test.jsx index c9e2a0ab..66972fe4 100644 --- a/src/features/Students/StudentsFilters/_test_/index.test.jsx +++ b/src/features/Students/StudentsFilters/_test_/index.test.jsx @@ -14,7 +14,6 @@ jest.mock('@edx/frontend-platform/logging', () => ({ })); describe('StudentsFilters Component', () => { - const fetchData = jest.fn(); const resetPagination = jest.fn(); const mockSetFilters = jest.fn(); @@ -26,11 +25,7 @@ describe('StudentsFilters Component', () => { test('renders input fields with placeholders', () => { const { getByText, getByPlaceholderText } = render( - + , ); diff --git a/src/features/Students/StudentsFilters/_test_/reducer.test.jsx b/src/features/Students/StudentsFilters/_test_/reducer.test.jsx deleted file mode 100644 index 38c2eaad..00000000 --- a/src/features/Students/StudentsFilters/_test_/reducer.test.jsx +++ /dev/null @@ -1,117 +0,0 @@ -import { - FETCH_COURSES_DATA_FAILURE, - FETCH_COURSES_DATA_REQUEST, - FETCH_COURSES_DATA_SUCCESS, - FETCH_CLASSES_DATA_REQUEST, - FETCH_CLASSES_DATA_SUCCESS, - FETCH_CLASSES_DATA_FAILURE, -} from 'features/Students/actionTypes'; -import { RequestStatus } from 'features/constants'; -import reducer from 'features/Students/StudentsFilters/reducer'; - -describe('Student filter reducers', () => { - const initialState = { - courses: { - data: [], - status: RequestStatus.SUCCESS, - error: null, - }, - classes: { - data: [], - status: RequestStatus.SUCCESS, - error: null, - }, - }; - - test('should handle FETCH_COURSES_DATA_REQUEST', () => { - const state = { - ...initialState, - courses: { - ...initialState.courses, - status: RequestStatus.LOADING, - }, - }; - const action = { - type: FETCH_COURSES_DATA_REQUEST, - }; - expect(reducer(state, action)).toEqual(state); - }); - - test('should handle FETCH_COURSES_DATA_SUCCESS', () => { - const state = { - ...initialState, - courses: { - ...initialState.courses, - status: RequestStatus.SUCCESS, - data: [], - }, - }; - const action = { - type: FETCH_COURSES_DATA_SUCCESS, - payload: [], - }; - expect(reducer(state, action)).toEqual(state); - }); - - test('should handle FETCH_COURSES_DATA_FAILURE', () => { - const state = { - ...initialState, - courses: { - ...initialState.courses, - status: RequestStatus.ERROR, - error: '', - }, - }; - const action = { - type: FETCH_COURSES_DATA_FAILURE, - payload: '', - }; - expect(reducer(state, action)).toEqual(state); - }); - - test('should handle FETCH_CLASSES_DATA_REQUEST', () => { - const state = { - ...initialState, - classes: { - ...initialState.courses, - status: RequestStatus.LOADING, - }, - }; - const action = { - type: FETCH_CLASSES_DATA_REQUEST, - }; - expect(reducer(state, action)).toEqual(state); - }); - - test('should handle FETCH_CLASSES_DATA_SUCCESS', () => { - const state = { - ...initialState, - classes: { - ...initialState.courses, - status: RequestStatus.SUCCESS, - data: [], - }, - }; - const action = { - type: FETCH_CLASSES_DATA_SUCCESS, - payload: [], - }; - expect(reducer(state, action)).toEqual(state); - }); - - test('should handle FETCH_CLASSES_DATA_FAILURE', () => { - const state = { - ...initialState, - classes: { - ...initialState.courses, - status: RequestStatus.ERROR, - error: '', - }, - }; - const action = { - type: FETCH_CLASSES_DATA_FAILURE, - payload: '', - }; - expect(reducer(state, action)).toEqual(state); - }); -}); diff --git a/src/features/Students/StudentsFilters/index.jsx b/src/features/Students/StudentsFilters/index.jsx index e9d30f62..79671a86 100644 --- a/src/features/Students/StudentsFilters/index.jsx +++ b/src/features/Students/StudentsFilters/index.jsx @@ -1,41 +1,18 @@ -import React, { - useEffect, useReducer, useState, -} from 'react'; -import { useSelector } from 'react-redux'; +import React, { useEffect, useState } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; import { Col, Form } from '@edx/paragon'; import { logError } from '@edx/frontend-platform/logging'; -import { camelCaseObject } from '@edx/frontend-platform'; import { Select, Button } from 'react-paragon-topaz'; -import { getClassesByInstitution } from 'features/Students/data/api'; -import { getCoursesByInstitution } from 'features/Common/data/api'; -import reducer from 'features/Students/StudentsFilters/reducer'; -import { - FETCH_COURSES_DATA_REQUEST, - FETCH_COURSES_DATA_SUCCESS, - FETCH_COURSES_DATA_FAILURE, - FETCH_CLASSES_DATA_REQUEST, - FETCH_CLASSES_DATA_SUCCESS, - FETCH_CLASSES_DATA_FAILURE, -} from 'features/Students/actionTypes'; -import { RequestStatus } from 'features/constants'; import PropTypes from 'prop-types'; +import { updateCurrentPage, updateFilters } from 'features/Students/data/slice'; +import { fetchCoursesData, fetchClassesData, fetchStudentsData } from 'features/Students/data/thunks'; +import { initialPage } from 'features/constants'; -const initialState = { - courses: { - data: [], - status: RequestStatus.SUCCESS, - error: null, - }, - classes: { - data: [], - status: RequestStatus.SUCCESS, - error: null, - }, -}; - -const StudentsFilters = ({ resetPagination, fetchData, setFilters }) => { +const StudentsFilters = ({ resetPagination }) => { + const dispatch = useDispatch(); const stateInstitution = useSelector((state) => state.main.institution.data); - const [state, dispatch] = useReducer(reducer, initialState); + const stateCourses = useSelector((state) => state.students.courses); + const stateClasses = useSelector((state) => state.students.classes); const [courseOptions, setCourseOptions] = useState([]); const [classesOptions, setClassesOptions] = useState([]); const [studentName, setStudentName] = useState(''); @@ -50,32 +27,8 @@ const StudentsFilters = ({ resetPagination, fetchData, setFilters }) => { id = stateInstitution[0].id; } - const fetchCoursesData = async () => { - dispatch({ type: FETCH_COURSES_DATA_REQUEST }); - - try { - const response = camelCaseObject(await getCoursesByInstitution(id, false)); - dispatch({ type: FETCH_COURSES_DATA_SUCCESS, payload: response.data }); - } catch (error) { - dispatch({ type: FETCH_COURSES_DATA_FAILURE, payload: error }); - logError(error); - } - }; - - const fetchClassesData = async (courseName) => { - dispatch({ type: FETCH_CLASSES_DATA_REQUEST }); - - try { - const response = camelCaseObject(await getClassesByInstitution(id, courseName)); - dispatch({ type: FETCH_CLASSES_DATA_SUCCESS, payload: response.data }); - } catch (error) { - dispatch({ type: FETCH_CLASSES_DATA_FAILURE, payload: error }); - logError(error); - } - }; - const handleCleanFilters = () => { - fetchData(); + dispatch(fetchStudentsData()); resetPagination(); setStudentName(''); setStudentEmail(''); @@ -83,53 +36,54 @@ const StudentsFilters = ({ resetPagination, fetchData, setFilters }) => { setClassSelected(null); setStatusSelected(null); setExamSelected(null); - setFilters({}); + dispatch(updateFilters({})); }; useEffect(() => { - fetchCoursesData(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id]); + dispatch(fetchCoursesData(id)); + }, [id]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (courseSelected) { - fetchClassesData(courseSelected.value); + dispatch(fetchClassesData(id, courseSelected.value)); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [courseSelected]); + }, [id, courseSelected]); useEffect(() => { - if (state.courses.data.length > 0) { - const options = state.courses.data.map(course => ({ + if (stateCourses.data.length > 0) { + const options = stateCourses.data.map(course => ({ ...course, label: course.masterCourseName, value: course.masterCourseName, })); setCourseOptions(options); } - }, [state.courses]); + }, [stateCourses]); const handleStudentsFilter = async (e) => { e.preventDefault(); const form = e.target; const formData = new FormData(form); const formJson = Object.fromEntries(formData.entries()); - setFilters(formJson); + dispatch(updateFilters(formJson)); try { - fetchData(formJson); + dispatch(updateCurrentPage(initialPage)); + dispatch(fetchStudentsData(initialPage, formJson)); } catch (error) { logError(error); } }; useEffect(() => { - if (state.classes.data.length > 0) { - const options = state.classes.data.map(ccx => ({ + if (stateClasses.data.length > 0) { + const options = stateClasses.data.map(ccx => ({ ...ccx, label: ccx.className, value: ccx.className, })); setClassesOptions(options); } - }, [state.classes]); + }, [stateClasses]); return (
@@ -222,9 +176,7 @@ const StudentsFilters = ({ resetPagination, fetchData, setFilters }) => { }; StudentsFilters.propTypes = { - fetchData: PropTypes.func.isRequired, resetPagination: PropTypes.func.isRequired, - setFilters: PropTypes.func.isRequired, }; export default StudentsFilters; diff --git a/src/features/Students/StudentsFilters/reducer.jsx b/src/features/Students/StudentsFilters/reducer.jsx deleted file mode 100644 index 362db041..00000000 --- a/src/features/Students/StudentsFilters/reducer.jsx +++ /dev/null @@ -1,70 +0,0 @@ -import { - FETCH_COURSES_DATA_FAILURE, - FETCH_COURSES_DATA_REQUEST, - FETCH_COURSES_DATA_SUCCESS, - FETCH_CLASSES_DATA_REQUEST, - FETCH_CLASSES_DATA_SUCCESS, - FETCH_CLASSES_DATA_FAILURE, -} from 'features/Students/actionTypes'; -import { RequestStatus } from 'features/constants'; - -const reducer = (state, action) => { - switch (action.type) { - case FETCH_COURSES_DATA_REQUEST: - return { - ...state, - courses: { - ...state.courses, - status: RequestStatus.LOADING, - }, - }; - case FETCH_COURSES_DATA_SUCCESS: - return { - ...state, - courses: { - ...state.courses, - status: RequestStatus.SUCCESS, - data: action.payload, - }, - }; - case FETCH_COURSES_DATA_FAILURE: - return { - ...state, - courses: { - ...state.courses, - status: RequestStatus.ERROR, - error: action.payload, - }, - }; - case FETCH_CLASSES_DATA_REQUEST: - return { - ...state, - classes: { - ...state.classes, - status: RequestStatus.LOADING, - }, - }; - case FETCH_CLASSES_DATA_SUCCESS: - return { - ...state, - classes: { - ...state.classes, - status: RequestStatus.SUCCESS, - data: action.payload, - }, - }; - case FETCH_CLASSES_DATA_FAILURE: - return { - ...state, - classes: { - ...state.classes, - status: RequestStatus.ERROR, - error: action.payload, - }, - }; - default: - return state; - } -}; - -export default reducer; diff --git a/src/features/Students/StudentsMetrics/_test_/index.test.jsx b/src/features/Students/StudentsMetrics/_test_/index.test.jsx index 44e66678..c772e7a3 100644 --- a/src/features/Students/StudentsMetrics/_test_/index.test.jsx +++ b/src/features/Students/StudentsMetrics/_test_/index.test.jsx @@ -1,12 +1,22 @@ import React from 'react'; +import { Provider } from 'react-redux'; import { render } from '@testing-library/react'; import StudentsMetrics from 'features/Students/StudentsMetrics'; import '@testing-library/jest-dom/extend-expect'; +import { initializeStore } from 'store'; + +let store; describe('StudentsMetrics component', () => { + beforeEach(() => { + store = initializeStore(); + }); + test('renders components', () => { const { getByText } = render( - , + + + , ); expect(getByText('This week')).toBeInTheDocument(); diff --git a/src/features/Students/StudentsMetrics/_test_/reducer.test.jsx b/src/features/Students/StudentsMetrics/_test_/reducer.test.jsx deleted file mode 100644 index 08bb8de4..00000000 --- a/src/features/Students/StudentsMetrics/_test_/reducer.test.jsx +++ /dev/null @@ -1,52 +0,0 @@ -import { - FETCH_METRICS_DATA_REQUEST, - FETCH_METRICS_DATA_SUCCESS, - FETCH_METRICS_DATA_FAILURE, -} from 'features/Students/actionTypes'; -import { RequestStatus } from 'features/constants'; -import reducer from 'features/Students/StudentsMetrics/reducer'; - -describe('Student filter reducers', () => { - const initialState = { - data: [], - status: RequestStatus.SUCCESS, - error: null, - }; - - test('should handle FETCH_METRICS_DATA_REQUEST', () => { - const state = { - ...initialState, - status: RequestStatus.LOADING, - }; - const action = { - type: FETCH_METRICS_DATA_REQUEST, - }; - expect(reducer(state, action)).toEqual(state); - }); - - test('should handle FETCH_METRICS_DATA_SUCCESS', () => { - const state = { - ...initialState, - status: RequestStatus.SUCCESS, - data: [], - }; - const action = { - type: FETCH_METRICS_DATA_SUCCESS, - payload: [], - }; - expect(reducer(state, action)).toEqual(state); - }); - - test('should handle FETCH_METRICS_DATA_FAILURE', () => { - const state = { - ...initialState, - status: RequestStatus.ERROR, - error: '', - }; - const action = { - type: FETCH_METRICS_DATA_FAILURE, - payload: '', - }; - expect(reducer(state, action)).toEqual(state); - }); -}); diff --git a/src/features/Students/StudentsMetrics/index.jsx b/src/features/Students/StudentsMetrics/index.jsx index a45e75e1..5fde3a94 100644 --- a/src/features/Students/StudentsMetrics/index.jsx +++ b/src/features/Students/StudentsMetrics/index.jsx @@ -1,42 +1,18 @@ -import React, { useEffect, useReducer } from 'react'; +import React, { useEffect } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; import { Card, CardGrid, ToggleButton } from '@edx/paragon'; import { ToggleButtonGroup } from 'react-paragon-topaz'; -import { logError } from '@edx/frontend-platform/logging'; -import { camelCaseObject } from '@edx/frontend-platform'; -import { getMetricsStudents } from 'features/Students/data/api'; -import { RequestStatus } from 'features/constants'; -import reducer from 'features/Students/StudentsMetrics/reducer'; -import { - FETCH_METRICS_DATA_REQUEST, - FETCH_METRICS_DATA_SUCCESS, - FETCH_METRICS_DATA_FAILURE, -} from 'features/Students/actionTypes'; -import 'features/Students/StudentsMetrics/index.scss'; +import { fetchMetricsData } from 'features/Students/data/thunks'; -const initialState = { - data: [], - status: RequestStatus.SUCCESS, - error: null, -}; +import 'features/Students/StudentsMetrics/index.scss'; const StudentsMetrics = () => { - const [state, dispatch] = useReducer(reducer, initialState); - - const fetchMetricsData = async () => { - dispatch({ type: FETCH_METRICS_DATA_REQUEST }); - - try { - const response = camelCaseObject(await getMetricsStudents()); - dispatch({ type: FETCH_METRICS_DATA_SUCCESS, payload: response }); - } catch (error) { - dispatch({ type: FETCH_METRICS_DATA_FAILURE, payload: error }); - logError(error); - } - }; + const dispatch = useDispatch(); + const stateMetrics = useSelector((state) => state.students.metrics.data); useEffect(() => { - fetchMetricsData(); - }, []); + dispatch(fetchMetricsData()); + }, []); // eslint-disable-line react-hooks/exhaustive-deps return (
@@ -63,7 +39,7 @@ const StudentsMetrics = () => { title="New students registered" /> -
{state.data.newStudentsRegistered}
+
{stateMetrics.newStudentsRegistered}
@@ -71,7 +47,7 @@ const StudentsMetrics = () => { title="Classes scheduled" /> -
{state.data.classesScheduled}
+
{stateMetrics.classesScheduled}
diff --git a/src/features/Students/StudentsMetrics/reducer.jsx b/src/features/Students/StudentsMetrics/reducer.jsx deleted file mode 100644 index 0e839e41..00000000 --- a/src/features/Students/StudentsMetrics/reducer.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import { - FETCH_METRICS_DATA_REQUEST, - FETCH_METRICS_DATA_SUCCESS, - FETCH_METRICS_DATA_FAILURE, -} from 'features/Students/actionTypes'; -import { RequestStatus } from 'features/constants'; - -const reducer = (state, action) => { - switch (action.type) { - case FETCH_METRICS_DATA_REQUEST: - return { - ...state, - status: RequestStatus.LOADING, - }; - case FETCH_METRICS_DATA_SUCCESS: - return { - ...state, - status: RequestStatus.SUCCESS, - data: action.payload, - }; - case FETCH_METRICS_DATA_FAILURE: - return { - ...state, - status: RequestStatus.ERROR, - error: action.payload, - }; - default: - return state; - } -}; - -export default reducer; diff --git a/src/features/Students/StudentsPage/_test_/index.test.jsx b/src/features/Students/StudentsPage/_test_/index.test.jsx index 9b9cca0c..1f163363 100644 --- a/src/features/Students/StudentsPage/_test_/index.test.jsx +++ b/src/features/Students/StudentsPage/_test_/index.test.jsx @@ -1,10 +1,7 @@ import React from 'react'; import axios from 'axios'; import StudentsPage from 'features/Students/StudentsPage'; -import { - render, - waitFor, -} from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; import { Provider } from 'react-redux'; import { initializeStore } from 'store'; diff --git a/src/features/Students/StudentsPage/_test_/reducer.test.jsx b/src/features/Students/StudentsPage/_test_/reducer.test.jsx deleted file mode 100644 index fc99db58..00000000 --- a/src/features/Students/StudentsPage/_test_/reducer.test.jsx +++ /dev/null @@ -1,70 +0,0 @@ -import { - FETCH_STUDENTS_DATA_REQUEST, - FETCH_STUDENTS_DATA_SUCCESS, - FETCH_STUDENTS_DATA_FAILURE, - UPDATE_CURRENT_PAGE, -} from 'features/Students/actionTypes'; -import { RequestStatus } from 'features/constants'; -import reducer from 'features/Students/StudentsPage/reducer'; - -describe('Instructor page reducers', () => { - const initialState = { - data: [], - error: null, - currentPage: 1, - numPages: 0, - }; - - test('should handle FETCH_STUDENTS_DATA_REQUEST', () => { - const state = { - ...initialState, - status: RequestStatus.LOADING, - }; - const action = { - type: FETCH_STUDENTS_DATA_REQUEST, - }; - expect(reducer(state, action)).toEqual(state); - }); - - test('should handle FETCH_STUDENTS_DATA_SUCCESS', () => { - const state = { - ...initialState, - status: RequestStatus.SUCCESS, - count: 0, - }; - const action = { - type: FETCH_STUDENTS_DATA_SUCCESS, - payload: { - results: [], - count: 0, - numPages: 0, - }, - }; - expect(reducer(state, action)).toEqual(state); - }); - - test('should handle FETCH_STUDENTS_DATA_FAILURE', () => { - const state = { - ...initialState, - status: RequestStatus.ERROR, - error: '', - }; - const action = { - type: FETCH_STUDENTS_DATA_FAILURE, - payload: '', - }; - expect(reducer(state, action)).toEqual(state); - }); - - test('should handle UPDATE_CURRENT_PAGE', () => { - const state = { - ...initialState, - currentPage: 1, - }; - const action = { - type: UPDATE_CURRENT_PAGE, - payload: 1, - }; - expect(reducer(state, action)).toEqual(state); - }); -}); diff --git a/src/features/Students/StudentsPage/index.jsx b/src/features/Students/StudentsPage/index.jsx index 37561f7f..ed2c958e 100644 --- a/src/features/Students/StudentsPage/index.jsx +++ b/src/features/Students/StudentsPage/index.jsx @@ -1,61 +1,30 @@ -import { getStudentbyInstitutionAdmin } from 'features/Students/data/api'; +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { StudentsTable } from 'features/Students/StudentsTable/index'; import StudentsFilters from 'features/Students/StudentsFilters'; import StudentsMetrics from 'features/Students/StudentsMetrics'; -import { RequestStatus } from 'features/constants'; -import reducer from 'features/Students/StudentsPage/reducer'; - -import { logError } from '@edx/frontend-platform/logging'; import Container from '@edx/paragon/dist/Container'; - -import React, { useEffect, useState, useReducer } from 'react'; -import { camelCaseObject } from '@edx/frontend-platform'; -import { - Pagination, -} from '@edx/paragon'; -import { - FETCH_STUDENTS_DATA_REQUEST, - FETCH_STUDENTS_DATA_SUCCESS, - FETCH_STUDENTS_DATA_FAILURE, - UPDATE_CURRENT_PAGE, -} from 'features/Students/actionTypes'; - -const initialState = { - data: [], - status: RequestStatus.SUCCESS, - error: null, - currentPage: 1, - numPages: 0, -}; +import { Pagination } from '@edx/paragon'; +import { updateCurrentPage } from 'features/Students/data/slice'; +import { fetchStudentsData } from 'features/Students/data/thunks'; +import { initialPage } from 'features/constants'; const StudentsPage = () => { - const [state, dispatch] = useReducer(reducer, initialState); - const [currentPage, setCurrentPage] = useState(1); - const [filters, setFilters] = useState({}); - - const fetchData = async (filtersData) => { - dispatch({ type: FETCH_STUDENTS_DATA_REQUEST }); - - try { - const response = camelCaseObject(await getStudentbyInstitutionAdmin(currentPage, filtersData)); - dispatch({ type: FETCH_STUDENTS_DATA_SUCCESS, payload: response.data }); - } catch (error) { - dispatch({ type: FETCH_STUDENTS_DATA_FAILURE, payload: error }); - logError(error); - } - }; + const dispatch = useDispatch(); + const stateStudents = useSelector((state) => state.students); + const [currentPage, setCurrentPage] = useState(initialPage); useEffect(() => { - fetchData(filters); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentPage, filters]); + dispatch(fetchStudentsData(currentPage, stateStudents.filters)); + }, [currentPage]); // eslint-disable-line react-hooks/exhaustive-deps const resetPagination = () => { - setCurrentPage(1); + setCurrentPage(initialPage); }; const handlePagination = (targetPage) => { setCurrentPage(targetPage); - dispatch({ type: UPDATE_CURRENT_PAGE, payload: targetPage }); + dispatch(updateCurrentPage(targetPage)); }; return ( @@ -63,16 +32,15 @@ const StudentsPage = () => {

Students

- + - {state.numPages > 1 && ( + {stateStudents.table.numPages > 1 && ( { - switch (action.type) { - case FETCH_STUDENTS_DATA_REQUEST: - return { ...state, status: RequestStatus.LOADING }; - case FETCH_STUDENTS_DATA_SUCCESS: { - const { results, count, numPages } = action.payload; - return { - ...state, - status: RequestStatus.SUCCESS, - data: results, - numPages, - count, - }; - } - case FETCH_STUDENTS_DATA_FAILURE: - return { - ...state, - status: RequestStatus.ERROR, - error: action.payload, - }; - case UPDATE_CURRENT_PAGE: - return { - ...state, - currentPage: action.payload, - }; - default: - return state; - } -}; - -export default reducer; diff --git a/src/features/Students/StudentsTable/_test_/index.test.jsx b/src/features/Students/StudentsTable/_test_/index.test.jsx index a6bde8ab..01f1a917 100644 --- a/src/features/Students/StudentsTable/_test_/index.test.jsx +++ b/src/features/Students/StudentsTable/_test_/index.test.jsx @@ -1,12 +1,24 @@ import React from 'react'; +import { Provider } from 'react-redux'; import '@testing-library/jest-dom'; import { render, screen } from '@testing-library/react'; import { StudentsTable } from 'features/Students/StudentsTable'; import { getColumns } from 'features/Students/StudentsTable/columns'; +import { initializeStore } from 'store'; + +let store; describe('Student Table', () => { + beforeEach(() => { + store = initializeStore(); + }); + test('renders StudentsTable without data', () => { - render(); + render( + + + , + ); const emptyTableText = screen.getByText('No students found.'); expect(emptyTableText).toBeInTheDocument(); }); @@ -44,34 +56,29 @@ describe('Student Table', () => { ]; const component = render( - , + + + , ); - // Check if the table rows are present const tableRows = screen.getAllByRole('row'); - expect(tableRows).toHaveLength(data.length + 1); // Data rows + 1 header row + expect(tableRows).toHaveLength(data.length + 1); - // Check student names expect(component.container).toHaveTextContent('Student 1'); expect(component.container).toHaveTextContent('Student 2'); - // Check course names expect(component.container).toHaveTextContent('course 1'); expect(component.container).toHaveTextContent('course 2'); - // Check class names expect(component.container).toHaveTextContent('class 1'); expect(component.container).toHaveTextContent('class 2'); - // Check instructors names expect(component.container).toHaveTextContent('Instructor 1'); expect(component.container).toHaveTextContent('Instructor 2'); - // Check exam ready expect(component.container).toHaveTextContent('yes'); expect(component.container).toHaveTextContent('no'); - // Ensure "No students found." is not present expect(screen.queryByText('No students found.')).toBeNull(); }); }); diff --git a/src/features/Students/StudentsTable/index.jsx b/src/features/Students/StudentsTable/index.jsx index 2b605e23..e9827c16 100644 --- a/src/features/Students/StudentsTable/index.jsx +++ b/src/features/Students/StudentsTable/index.jsx @@ -1,4 +1,5 @@ import React, { useState, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; import PropTypes from 'prop-types'; import DataTable from '@edx/paragon/dist/DataTable'; import { @@ -12,12 +13,13 @@ import { import { IntlProvider } from 'react-intl'; import { handleEnrollments } from 'features/Students/data/api'; import { getColumns, hideColumns } from 'features/Students/StudentsTable/columns'; +import { fetchStudentsData } from 'features/Students/data/thunks'; const StudentsTable = ({ data, count, - fetchData, }) => { + const dispatch = useDispatch(); const [isOpenAlertModal, openAlertModal, closeAlertModal] = useToggle(false); const [selectedRow, setRow] = useState({}); const COLUMNS = useMemo(() => getColumns({ openAlertModal, setRow }), [openAlertModal]); @@ -27,7 +29,7 @@ const StudentsTable = ({ const handleStudentsActions = async () => { await handleEnrollments(enrollmentData, selectedRow.ccxId); - fetchData(); + dispatch(fetchStudentsData()); closeAlertModal(); }; @@ -72,13 +74,11 @@ const StudentsTable = ({ StudentsTable.propTypes = { data: PropTypes.arrayOf(PropTypes.shape([])), count: PropTypes.number, - fetchData: PropTypes.func, }; StudentsTable.defaultProps = { data: [], count: 0, - fetchData: null, }; export { StudentsTable }; diff --git a/src/features/Students/actionTypes.js b/src/features/Students/actionTypes.js deleted file mode 100644 index 7f025608..00000000 --- a/src/features/Students/actionTypes.js +++ /dev/null @@ -1,17 +0,0 @@ -export const FETCH_STUDENTS_DATA_REQUEST = 'FETCH_STUDENTS_DATA_REQUEST'; -export const FETCH_STUDENTS_DATA_SUCCESS = 'FETCH_STUDENTS_DATA_SUCCESS'; -export const FETCH_STUDENTS_DATA_FAILURE = 'FETCH_STUDENTS_DATA_FAILURE'; - -export const FETCH_COURSES_DATA_REQUEST = 'FETCH_COURSES_DATA_REQUEST'; -export const FETCH_COURSES_DATA_SUCCESS = 'FETCH_COURSES_DATA_SUCCESS'; -export const FETCH_COURSES_DATA_FAILURE = 'FETCH_COURSES_DATA_FAILURE'; - -export const FETCH_CLASSES_DATA_REQUEST = 'FETCH_CLASSES_DATA_REQUEST'; -export const FETCH_CLASSES_DATA_SUCCESS = 'FETCH_CLASSES_DATA_SUCCESS'; -export const FETCH_CLASSES_DATA_FAILURE = 'FETCH_CLASSES_DATA_FAILURE'; - -export const FETCH_METRICS_DATA_REQUEST = 'FETCH_METRICS_DATA_REQUEST'; -export const FETCH_METRICS_DATA_SUCCESS = 'FETCH_METRICS_DATA_SUCCESS'; -export const FETCH_METRICS_DATA_FAILURE = 'FETCH_METRICS_DATA_FAILURE'; - -export const UPDATE_CURRENT_PAGE = 'UPDATE_CURRENT_PAGE'; diff --git a/src/features/Students/data/_test_/redux.test.js b/src/features/Students/data/_test_/redux.test.js new file mode 100644 index 00000000..37a7db6f --- /dev/null +++ b/src/features/Students/data/_test_/redux.test.js @@ -0,0 +1,228 @@ +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { initializeMockApp } from '@edx/frontend-platform/testing'; +import { + fetchStudentsData, + fetchCoursesData, + fetchClassesData, + fetchMetricsData, +} from 'features/Students/data/thunks'; +import { updateCurrentPage, updateFilters } from 'features/Students/data/slice'; +import { executeThunk } from 'test-utils'; +import { initializeStore } from 'store'; + +let axiosMock; +let store; + +describe('Students redux tests', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 1, + username: 'testuser', + administrator: true, + roles: [], + }, + }); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + + store = initializeStore(); + }); + + afterEach(() => { + axiosMock.reset(); + }); + + test('successful fetch students data', async () => { + const studentsApiUrl = `${process.env.COURSE_OPERATIONS_API_V2_BASE_URL}/students/`; + const mockResponse = { + results: [ + { + learnerName: 'pending_enrollment', + learnerEmail: 'student04@example.com', + instructors: [ + 'Instructor01', + ], + courseName: 'Demo Course 1', + classId: 'ccx-v1:demo+demo1+2020+ccx@2', + className: 'test ccx1', + status: 'Pending', + examReady: false, + }, + ], + count: 2, + num_pages: 1, + current_page: 1, + }; + axiosMock.onGet(studentsApiUrl) + .reply(200, mockResponse); + + expect(store.getState().students.table.status) + .toEqual('loading'); + + await executeThunk(fetchStudentsData(), store.dispatch, store.getState); + + expect(store.getState().students.table.data) + .toEqual(mockResponse.results); + + expect(store.getState().students.table.status) + .toEqual('success'); + }); + + test('failed fetch students data', async () => { + const studentsApiUrl = `${process.env.COURSE_OPERATIONS_API_V2_BASE_URL}/students/`; + axiosMock.onGet(studentsApiUrl) + .reply(500); + + expect(store.getState().students.table.status) + .toEqual('loading'); + + await executeThunk(fetchStudentsData(), store.dispatch, store.getState); + + expect(store.getState().students.table.data) + .toEqual([]); + + expect(store.getState().students.table.status) + .toEqual('error'); + }); + + test('successful fetch courses data', async () => { + const studentsApiUrl = `${process.env.COURSE_OPERATIONS_API_V2_BASE_URL}/courses/?limit=false&institution_id=1`; + const mockResponse = [ + { + masterCourseName: 'Demo Course 1', + numberOfClasses: 1, + missingClassesForInstructor: null, + numberOfStudents: 1, + numberOfPendingStudents: 11, + }, + ]; + axiosMock.onGet(studentsApiUrl) + .reply(200, mockResponse); + + expect(store.getState().students.courses.status) + .toEqual('loading'); + + await executeThunk(fetchCoursesData(1), store.dispatch, store.getState); + + expect(store.getState().students.courses.data) + .toEqual(mockResponse); + + expect(store.getState().students.courses.status) + .toEqual('success'); + }); + + test('failed fetch courses data', async () => { + const studentsApiUrl = `${process.env.COURSE_OPERATIONS_API_V2_BASE_URL}/courses/?limit=false&institution_id=1`; + axiosMock.onGet(studentsApiUrl) + .reply(500); + + expect(store.getState().students.courses.status) + .toEqual('loading'); + + await executeThunk(fetchCoursesData(1), store.dispatch, store.getState); + + expect(store.getState().students.courses.data) + .toEqual([]); + + expect(store.getState().students.courses.status) + .toEqual('error'); + }); + + test('successful fetch classes data', async () => { + const studentsApiUrl = `${process.env.COURSE_OPERATIONS_API_V2_BASE_URL}/classes/?limit=false&institution_id=1&course_name=Demo`; + const mockResponse = [ + { + classId: 'ccx-v1:demo+demo1+2020+ccx@2', + className: 'test ccx1', + masterCourseName: 'Demo', + }, + ]; + axiosMock.onGet(studentsApiUrl) + .reply(200, mockResponse); + + expect(store.getState().students.classes.status) + .toEqual('loading'); + + await executeThunk(fetchClassesData(1, 'Demo'), store.dispatch, store.getState); + + expect(store.getState().students.classes.data) + .toEqual(mockResponse); + + expect(store.getState().students.classes.status) + .toEqual('success'); + }); + + test('failed fetch classes data', async () => { + const studentsApiUrl = `${process.env.COURSE_OPERATIONS_API_V2_BASE_URL}/classes/?limit=false&institution_id=1`; + axiosMock.onGet(studentsApiUrl) + .reply(500); + + expect(store.getState().students.classes.status) + .toEqual('loading'); + + await executeThunk(fetchClassesData(1), store.dispatch, store.getState); + + expect(store.getState().students.classes.data) + .toEqual([]); + + expect(store.getState().students.classes.status) + .toEqual('error'); + }); + + test('successful fetch metrics data', async () => { + const mockResponse = { + classesScheduled: '71%', + newStudentsRegistered: '367', + }; + + expect(store.getState().students.metrics.status) + .toEqual('loading'); + + await executeThunk(fetchMetricsData(), store.dispatch, store.getState); + + expect(store.getState().students.metrics.data) + .toEqual(mockResponse); + + expect(store.getState().students.metrics.status) + .toEqual('success'); + }); + + test('update current page', () => { + const newPage = 2; + const intialState = store.getState().students.table; + const expectState = { + ...intialState, + currentPage: newPage, + }; + + store.dispatch(updateCurrentPage(newPage)); + expect(store.getState().students.table).toEqual(expectState); + }); + + test('update current page', () => { + const newPage = 2; + const intialState = store.getState().students.table; + const expectState = { + ...intialState, + currentPage: newPage, + }; + + store.dispatch(updateCurrentPage(newPage)); + expect(store.getState().students.table).toEqual(expectState); + }); + + test('update filters', () => { + const filters = { + course_name: 'Demo Course 1', + }; + const intialState = store.getState().students.filters; + const expectState = { + ...intialState, + ...filters, + }; + + store.dispatch(updateFilters(filters)); + expect(store.getState().students.filters).toEqual(expectState); + }); +}); diff --git a/src/features/Students/data/api.js b/src/features/Students/data/api.js index 940fc8e8..2985b06c 100644 --- a/src/features/Students/data/api.js +++ b/src/features/Students/data/api.js @@ -34,8 +34,10 @@ function getClassesByInstitution(institutionId, courseName) { function getMetricsStudents() { const metricsData = { - new_students_registered: '367', - classes_scheduled: '71%', + data: { + new_students_registered: '367', + classes_scheduled: '71%', + }, }; return metricsData; } diff --git a/src/features/Students/data/index.js b/src/features/Students/data/index.js new file mode 100644 index 00000000..49aea945 --- /dev/null +++ b/src/features/Students/data/index.js @@ -0,0 +1,2 @@ +export { reducer } from 'features/Students/data/slice'; +export { fetchStudentsData } from 'features/Students/data/thunks'; diff --git a/src/features/Students/data/slice.js b/src/features/Students/data/slice.js new file mode 100644 index 00000000..4234404d --- /dev/null +++ b/src/features/Students/data/slice.js @@ -0,0 +1,99 @@ +/* eslint-disable no-param-reassign */ +import { createSlice } from '@reduxjs/toolkit'; +import { RequestStatus, initialStateService } from 'features/constants'; + +const initialState = { + table: { + currentPage: 1, + data: [], + status: RequestStatus.LOADING, + error: null, + numPages: 0, + count: 0, + }, + courses: { + ...initialStateService, + }, + classes: { + ...initialStateService, + }, + metrics: { + ...initialStateService, + }, + filters: {}, +}; + +export const studentsSlice = createSlice({ + name: 'students', + initialState, + reducers: { + updateCurrentPage: (state, { payload }) => { + state.table.currentPage = payload; + }, + fetchStudentsDataRequest: (state) => { + state.table.status = RequestStatus.LOADING; + }, + fetchStudentsDataSuccess: (state, { payload }) => { + const { results, count, numPages } = payload; + state.table.status = RequestStatus.SUCCESS; + state.table.data = results; + state.table.numPages = numPages; + state.table.count = count; + }, + fetchStudentsDataFailed: (state) => { + state.table.status = RequestStatus.ERROR; + }, + updateFilters: (state, { payload }) => { + state.filters = payload; + }, + fetchCoursesDataRequest: (state) => { + state.courses.status = RequestStatus.LOADING; + }, + fetchCoursesDataSuccess: (state, { payload }) => { + state.courses.status = RequestStatus.SUCCESS; + state.courses.data = payload; + }, + fetchCoursesDataFailed: (state) => { + state.courses.status = RequestStatus.ERROR; + }, + fetchClassesDataRequest: (state) => { + state.classes.status = RequestStatus.LOADING; + }, + fetchClassesDataSuccess: (state, { payload }) => { + state.classes.status = RequestStatus.SUCCESS; + state.classes.data = payload; + }, + fetchClassesDataFailed: (state) => { + state.classes.status = RequestStatus.ERROR; + }, + fetchMetricsDataRequest: (state) => { + state.metrics.status = RequestStatus.LOADING; + }, + fetchMetricsDataSuccess: (state, { payload }) => { + state.metrics.status = RequestStatus.SUCCESS; + state.metrics.data = payload; + }, + fetchMetricsDataFailed: (state) => { + state.metrics.status = RequestStatus.ERROR; + }, + }, +}); + +export const { + updateCurrentPage, + fetchStudentsDataRequest, + fetchStudentsDataSuccess, + fetchStudentsDataFailed, + updateFilters, + fetchCoursesDataRequest, + fetchCoursesDataSuccess, + fetchCoursesDataFailed, + fetchClassesDataRequest, + fetchClassesDataSuccess, + fetchClassesDataFailed, + fetchMetricsDataRequest, + fetchMetricsDataSuccess, + fetchMetricsDataFailed, +} = studentsSlice.actions; + +export const { reducer } = studentsSlice; diff --git a/src/features/Students/data/thunks.js b/src/features/Students/data/thunks.js new file mode 100644 index 00000000..160579c5 --- /dev/null +++ b/src/features/Students/data/thunks.js @@ -0,0 +1,81 @@ +import { logError } from '@edx/frontend-platform/logging'; +import { camelCaseObject } from '@edx/frontend-platform'; +import { + fetchStudentsDataRequest, + fetchStudentsDataSuccess, + fetchStudentsDataFailed, + fetchCoursesDataRequest, + fetchCoursesDataSuccess, + fetchCoursesDataFailed, + fetchClassesDataRequest, + fetchClassesDataSuccess, + fetchClassesDataFailed, + fetchMetricsDataRequest, + fetchMetricsDataSuccess, + fetchMetricsDataFailed, +} from 'features/Students/data/slice'; +import { getStudentbyInstitutionAdmin, getClassesByInstitution, getMetricsStudents } from 'features/Students/data/api'; +import { getCoursesByInstitution } from 'features/Common/data/api'; + +function fetchStudentsData(currentPage, filtersData) { + return async (dispatch) => { + dispatch(fetchStudentsDataRequest()); + + try { + const response = camelCaseObject(await getStudentbyInstitutionAdmin(currentPage, filtersData)); + dispatch(fetchStudentsDataSuccess(response.data)); + } catch (error) { + dispatch(fetchStudentsDataFailed()); + logError(error); + } + }; +} + +function fetchCoursesData(id) { + return async (dispatch) => { + dispatch(fetchCoursesDataRequest()); + + try { + const response = camelCaseObject(await getCoursesByInstitution(id, false)); + dispatch(fetchCoursesDataSuccess(response.data)); + } catch (error) { + dispatch(fetchCoursesDataFailed()); + logError(error); + } + }; +} + +function fetchClassesData(id, courseName) { + return async (dispatch) => { + dispatch(fetchClassesDataRequest()); + + try { + const response = camelCaseObject(await getClassesByInstitution(id, courseName)); + dispatch(fetchClassesDataSuccess(response.data)); + } catch (error) { + dispatch(fetchClassesDataFailed()); + logError(error); + } + }; +} + +function fetchMetricsData() { + return async (dispatch) => { + dispatch(fetchMetricsDataRequest()); + + try { + const response = camelCaseObject(await getMetricsStudents()); + dispatch(fetchMetricsDataSuccess(response.data)); + } catch (error) { + dispatch(fetchMetricsDataFailed()); + logError(error); + } + }; +} + +export { + fetchStudentsData, + fetchCoursesData, + fetchClassesData, + fetchMetricsData, +}; diff --git a/src/features/constants.js b/src/features/constants.js index 6b12f26c..3bbead96 100644 --- a/src/features/constants.js +++ b/src/features/constants.js @@ -8,3 +8,20 @@ export const RequestStatus = { SUCCESS: 'success', ERROR: 'error', }; + +/** + * Obj for initial service state. + * @object + */ +export const initialStateService = { + data: [], + status: RequestStatus.LOADING, + error: null, +}; + +/** + * Number for initial page. + * @readonly + * @number + */ +export const initialPage = 1; diff --git a/src/store.js b/src/store.js index b01a3d08..b5d44a04 100644 --- a/src/store.js +++ b/src/store.js @@ -1,12 +1,16 @@ import { configureStore } from '@reduxjs/toolkit'; import { reducer as instructorsReducer } from 'features/Instructors/data'; import { reducer as mainReducer } from 'features/Main/data'; +import { reducer as coursesReducer } from 'features/Courses/data'; +import { reducer as studentsReducer } from 'features/Students/data'; export function initializeStore(preloadedState = undefined) { return configureStore({ reducer: { instructors: instructorsReducer, main: mainReducer, + courses: coursesReducer, + students: studentsReducer, }, preloadedState, });