diff --git a/src/features/Dashboard/InstructorAssignSection/ClassCard.jsx b/src/features/Dashboard/InstructorAssignSection/ClassCard.jsx index 2ca3b7ce..047aad6e 100644 --- a/src/features/Dashboard/InstructorAssignSection/ClassCard.jsx +++ b/src/features/Dashboard/InstructorAssignSection/ClassCard.jsx @@ -1,24 +1,43 @@ import React from 'react'; +import { useDispatch } from 'react-redux'; import { format } from 'date-fns'; import PropTypes from 'prop-types'; import { Button } from 'react-paragon-topaz'; +import { useToggle } from '@edx/paragon'; +import AssignInstructors from 'features/Instructors/AssignInstructors'; + +import { updateClassSelected } from 'features/Instructors/data/slice'; import 'features/Dashboard/InstructorAssignSection/index.scss'; const ClassCard = ({ data }) => { + const dispatch = useDispatch(); + const [isOpen, open, close] = useToggle(false); const fullDate = format(new Date(data.startDate), 'PP'); + const handleAssignModal = () => { + dispatch(updateClassSelected(data.classId)); // eslint-disable-line react/prop-types + open(); + }; + return ( -
-

{data?.className}

-

{data?.masterCourseName}

-

{fullDate}

- -
+ <> + +
+

{data?.className}

+

{data?.masterCourseName}

+

{fullDate}

+ +
+ + ); }; diff --git a/src/features/Dashboard/InstructorAssignSection/index.jsx b/src/features/Dashboard/InstructorAssignSection/index.jsx index c32ddf82..d046a0b8 100644 --- a/src/features/Dashboard/InstructorAssignSection/index.jsx +++ b/src/features/Dashboard/InstructorAssignSection/index.jsx @@ -41,6 +41,9 @@ const InstructorAssignSection = () => { )} + {classesData.length === 0 && ( +
No classes found
+ )} ); diff --git a/src/features/Dashboard/InstructorAssignSection/index.scss b/src/features/Dashboard/InstructorAssignSection/index.scss index 776320a2..5b979c19 100644 --- a/src/features/Dashboard/InstructorAssignSection/index.scss +++ b/src/features/Dashboard/InstructorAssignSection/index.scss @@ -21,6 +21,11 @@ } } +.instructor-assign-section .empty-content { + padding: 1rem; + font-size: 1rem; +} + .class-card-container { padding: 1rem; border-bottom: 1px solid $gray-20; diff --git a/src/features/Instructors/AssignInstructors/AssignTable.jsx b/src/features/Instructors/AssignInstructors/AssignTable.jsx new file mode 100644 index 00000000..a36c400e --- /dev/null +++ b/src/features/Instructors/AssignInstructors/AssignTable.jsx @@ -0,0 +1,45 @@ +import React, { useMemo, useEffect, useState } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; + +import { IntlProvider } from 'react-intl'; +import { + Row, + Col, + DataTable, +} from '@edx/paragon'; + +import { updateRowsSelected } from 'features/Instructors/data/slice'; + +import { columns } from 'features/Instructors/AssignInstructors/columns'; + +const AssignTable = () => { + const dispatch = useDispatch(); + const stateInstructors = useSelector((state) => state.instructors.table); + const [rowsSelected, setRowsSelected] = useState([]); + const COLUMNS = useMemo(() => columns({ setRowsSelected }), []); + + useEffect(() => { + dispatch(updateRowsSelected(rowsSelected)); + }, [rowsSelected, dispatch]); + + return ( + + + + + + + + + + + + ); +}; + +export default AssignTable; diff --git a/src/features/Instructors/AssignInstructors/_test_/index.test.jsx b/src/features/Instructors/AssignInstructors/_test_/index.test.jsx new file mode 100644 index 00000000..2f68dc4b --- /dev/null +++ b/src/features/Instructors/AssignInstructors/_test_/index.test.jsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { waitFor, fireEvent } from '@testing-library/react'; +import AssignInstructors from 'features/Instructors/AssignInstructors'; +import '@testing-library/jest-dom/extend-expect'; +import { renderWithProviders } from 'test-utils'; + +jest.mock('@edx/frontend-platform/logging', () => ({ + logError: jest.fn(), +})); + +const mockStore = { + instructors: { + table: { + data: [ + { + instructorUsername: 'Instructor1', + instructorName: 'Instructor 1', + instructorEmail: 'instructor1@example.com', + ccxId: 'CCX1', + ccxName: 'CCX 1', + }, + { + instructorUsername: 'Instructor2', + instructorName: 'Instructor 2', + instructorEmail: 'instructor2@example.com', + ccxId: 'CCX2', + ccxName: 'CCX 2', + }, + ], + count: 2, + num_pages: 1, + current_page: 1, + }, + classes: { + data: [], + }, + courses: { + data: [], + }, + }, +}; + +describe('Assign instructors modal', () => { + test('render assing instructors modal', () => { + const {getByText, getAllByRole, getByTestId} = renderWithProviders( + {}} />, + { preloadedState: mockStore }, + ); + + waitFor(() => { + expect(getByText('Instructor')).toBeInTheDocument(); + expect(getByText('Instructor1')).toBeInTheDocument() + expect(getByText('Instructor2')).toBeInTheDocument() + expect(getByText('Last seen')).toBeInTheDocument() + expect(getByText('Courses taught')).toBeInTheDocument() + }); + + const checkboxFields = getAllByRole('checkbox'); + fireEvent.click(checkboxFields[0]) + + const assignButton = getByTestId('assignButton') + fireEvent.click(assignButton) + }); +}); diff --git a/src/features/Instructors/AssignInstructors/columns.jsx b/src/features/Instructors/AssignInstructors/columns.jsx new file mode 100644 index 00000000..7aa527c5 --- /dev/null +++ b/src/features/Instructors/AssignInstructors/columns.jsx @@ -0,0 +1,58 @@ +/* eslint-disable react/prop-types, no-nested-ternary */ +import { differenceInHours, differenceInDays, differenceInWeeks } from 'date-fns'; +import { CheckboxControl } from '@edx/paragon'; + +const handleCheckbox = (setRowsSelected, row) => { + setRowsSelected(prevState => { + const rowSelected = row.original.instructorUsername; + if (prevState.includes(rowSelected)) { + const filterData = prevState.filter(rowState => rowState !== rowSelected); + return filterData; + } + return [...prevState, rowSelected]; + }); +}; + +const columns = ({ setRowsSelected }) => [ + { + Header: '', + id: 'checkbox', + width: 30, + Cell: ({ row }) => ( +
+ handleCheckbox(setRowsSelected, row)} + /> +
+ ), + }, + { + Header: 'Instructor', + accessor: 'instructorName', + }, + { + Header: 'Last seen', + accessor: 'lastAccess', + Cell: ({ row }) => { + const currentDate = Date.now(); + const lastDate = new Date(row.values.lastAccess); + const diffHours = differenceInHours(currentDate, lastDate); + const diffDays = differenceInDays(currentDate, lastDate); + const diffWeeks = differenceInWeeks(currentDate, lastDate); + return ( + {diffHours < 24 + ? 'Today' + : diffDays < 7 + ? `${diffDays} days ago` + : `${diffWeeks} wks ago`} + + ); + }, + }, + { + Header: 'Courses Taught', + accessor: 'classes', + }, +]; + +export { columns }; diff --git a/src/features/Instructors/AssignInstructors/index.jsx b/src/features/Instructors/AssignInstructors/index.jsx new file mode 100644 index 00000000..9f3d6d41 --- /dev/null +++ b/src/features/Instructors/AssignInstructors/index.jsx @@ -0,0 +1,102 @@ +import React, { useState, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import PropTypes from 'prop-types'; + +import { ModalDialog, ModalCloseButton, Pagination } from '@edx/paragon'; +import { Button } from 'react-paragon-topaz'; +import InstructorsFilters from 'features/Instructors/InstructorsFilters'; +import AssignTable from 'features/Instructors/AssignInstructors/AssignTable'; + +import { fetchInstructorsData, assignInstructors } from 'features/Instructors/data'; +import { + updateCurrentPage, updateRowsSelected, updateFilters, updateClassSelected, +} from 'features/Instructors/data/slice'; + +import { initialPage } from 'features/constants'; +import 'features/Instructors/AssignInstructors/index.scss'; + +const AssignInstructors = ({ isOpen, close }) => { + const dispatch = useDispatch(); + const selectedInstitution = useSelector((state) => state.main.selectedInstitution); + const stateInstructors = useSelector((state) => state.instructors); + const rowsSelected = useSelector((state) => state.instructors.rowsSelected); + const classId = useSelector((state) => state.instructors.classSelected); + const [currentPage, setCurrentPage] = useState(initialPage); + + const resetPagination = () => { + setCurrentPage(initialPage); + }; + + const handlePagination = (targetPage) => { + setCurrentPage(targetPage); + dispatch(updateCurrentPage(targetPage)); + }; + + const handleAssignInstructors = async () => { + // eslint-disable-next-line array-callback-return + rowsSelected.map(row => { + const enrollmentData = new FormData(); + enrollmentData.append('unique_student_identifier', row); + enrollmentData.append('rolename', 'staff'); + enrollmentData.append('action', 'allow'); + dispatch(assignInstructors(enrollmentData, classId, selectedInstitution.id)); + }); + close(); + }; + + useEffect(() => { + if (Object.keys(selectedInstitution).length > 0) { + dispatch(fetchInstructorsData(selectedInstitution.id, currentPage, stateInstructors.filters)); + } + }, [currentPage, selectedInstitution, dispatch]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!isOpen) { + dispatch(updateRowsSelected([])); + dispatch(updateFilters({})); + dispatch(updateClassSelected('')); + } + }, [isOpen, dispatch]); + + return ( + + + + Assign instructor + + + + + + {stateInstructors.table.numPages > 1 && ( + + )} +
+ Close + +
+
+
+ ); +}; + +AssignInstructors.propTypes = { + isOpen: PropTypes.bool.isRequired, + close: PropTypes.func.isRequired, +}; + +export default AssignInstructors; diff --git a/src/features/Instructors/AssignInstructors/index.scss b/src/features/Instructors/AssignInstructors/index.scss new file mode 100644 index 00000000..1e7655f1 --- /dev/null +++ b/src/features/Instructors/AssignInstructors/index.scss @@ -0,0 +1,13 @@ +@import "assets/colors.scss"; + +.pgn__modal-close-button.btn-icon.btn-icon-primary { + color: $gray-70; + + &:hover, + &:active, + &:focus { + color: $gray-70; + background-color: $gray-20; + box-shadow: none; + } +} diff --git a/src/features/Instructors/InstructorsFilters/index.jsx b/src/features/Instructors/InstructorsFilters/index.jsx index 4921a185..6f2df6de 100644 --- a/src/features/Instructors/InstructorsFilters/index.jsx +++ b/src/features/Instructors/InstructorsFilters/index.jsx @@ -13,7 +13,7 @@ import { updateFilters, updateCurrentPage } from 'features/Instructors/data/slic import PropTypes from 'prop-types'; import { initialPage } from 'features/constants'; -const InstructorsFilters = ({ resetPagination }) => { +const InstructorsFilters = ({ resetPagination, isAssignModal }) => { const selectedInstitution = useSelector((state) => state.main.selectedInstitution); const stateInstructors = useSelector((state) => state.instructors.courses); const currentPage = useSelector((state) => state.instructors.table.currentPage); @@ -47,10 +47,10 @@ const InstructorsFilters = ({ resetPagination }) => { }; useEffect(() => { - if (Object.keys(selectedInstitution).length > 0) { + if (Object.keys(selectedInstitution).length > 0 && !isAssignModal) { dispatch(fetchCoursesData(selectedInstitution.id)); } - }, [selectedInstitution, dispatch]); + }, [selectedInstitution, dispatch, isAssignModal]); useEffect(() => { if (stateInstructors.data.length > 0) { @@ -91,23 +91,25 @@ const InstructorsFilters = ({ resetPagination }) => { /> -
- - - setCourseSelected(option)} + value={courseSelected} + /> + + + )} +
+ + +
@@ -118,6 +120,11 @@ const InstructorsFilters = ({ resetPagination }) => { InstructorsFilters.propTypes = { resetPagination: PropTypes.func.isRequired, + isAssignModal: PropTypes.bool, +}; + +InstructorsFilters.defaultProps = { + isAssignModal: false, }; export default InstructorsFilters; diff --git a/src/features/Instructors/data/_test_/redux.test.js b/src/features/Instructors/data/_test_/redux.test.js index 2b4b6753..920faaa5 100644 --- a/src/features/Instructors/data/_test_/redux.test.js +++ b/src/features/Instructors/data/_test_/redux.test.js @@ -2,6 +2,9 @@ import MockAdapter from 'axios-mock-adapter'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { initializeMockApp } from '@edx/frontend-platform/testing'; import { fetchInstructorsData, fetchCoursesData, fetchClassesData } from 'features/Instructors/data/thunks'; +import { + updateCurrentPage, updateFilters, updateRowsSelected, updateClassSelected, +} from 'features/Instructors/data/slice'; import { executeThunk } from 'test-utils'; import { initializeStore } from 'store'; @@ -65,6 +68,23 @@ describe('Instructors redux tests', () => { .toEqual('success'); }); + test('failed fetch instructors data', async () => { + const instructorsApiUrl = `${process.env.COURSE_OPERATIONS_API_V2_BASE_URL}/instructors/`; + axiosMock.onGet(instructorsApiUrl) + .reply(500); + + expect(store.getState().instructors.table.status) + .toEqual('loading'); + + await executeThunk(fetchInstructorsData(), store.dispatch, store.getState); + + expect(store.getState().instructors.table.data) + .toEqual([]); + + expect(store.getState().instructors.table.status) + .toEqual('error'); + }); + test('successful fetch courses data', async () => { const instructorsApiUrl = `${process.env.COURSE_OPERATIONS_API_V2_BASE_URL}/courses/?limit=false&institution_id=1`; const mockResponse = [ @@ -91,6 +111,23 @@ describe('Instructors redux tests', () => { .toEqual('success'); }); + test('failed fetch courses data', async () => { + const instructorsApiUrl = `${process.env.COURSE_OPERATIONS_API_V2_BASE_URL}/courses/?limit=false&institution_id=1`; + axiosMock.onGet(instructorsApiUrl) + .reply(500); + + expect(store.getState().instructors.courses.status) + .toEqual('loading'); + + await executeThunk(fetchCoursesData(1), store.dispatch, store.getState); + + expect(store.getState().instructors.courses.data) + .toEqual([]); + + expect(store.getState().instructors.courses.status) + .toEqual('error'); + }); + test('successful fetch classes data', async () => { const instructorsApiUrl = `${process.env.COURSE_OPERATIONS_API_V2_BASE_URL}/classes/?limit=false`; const mockResponse = [ @@ -114,4 +151,65 @@ describe('Instructors redux tests', () => { expect(store.getState().instructors.classes.status) .toEqual('success'); }); + + test('failed fetch classes data', async () => { + const instructorsApiUrl = `${process.env.COURSE_OPERATIONS_API_V2_BASE_URL}/classes/?limit=false`; + axiosMock.onGet(instructorsApiUrl) + .reply(500); + + expect(store.getState().instructors.classes.status) + .toEqual('loading'); + + await executeThunk(fetchClassesData(), store.dispatch, store.getState); + + expect(store.getState().instructors.classes.data) + .toEqual([]); + + expect(store.getState().instructors.classes.status) + .toEqual('error'); + }); + + test('update current page', () => { + const newPage = 2; + const intialState = store.getState().instructors.table; + const expectState = { + ...intialState, + currentPage: newPage, + }; + + store.dispatch(updateCurrentPage(newPage)); + expect(store.getState().instructors.table).toEqual(expectState); + }); + + test('update filters', () => { + const filters = { + course_name: 'Demo Course 1', + }; + const intialState = store.getState().instructors.filters; + const expectState = { + ...intialState, + ...filters, + }; + + store.dispatch(updateFilters(filters)); + expect(store.getState().instructors.filters).toEqual(expectState); + }); + + test('update rowsSelected', () => { + const rowSelected = [ + 'Instructor01', + ]; + const expectState = rowSelected; + + store.dispatch(updateRowsSelected(rowSelected)); + expect(store.getState().instructors.rowsSelected).toEqual(expectState); + }); + + test('update classSelected', () => { + const classSelected = 'ccx1'; + const expectState = classSelected; + + store.dispatch(updateClassSelected(classSelected)); + expect(store.getState().instructors.classSelected).toEqual(expectState); + }); }); diff --git a/src/features/Instructors/data/index.js b/src/features/Instructors/data/index.js index b0bf14cc..cab4f5dd 100644 --- a/src/features/Instructors/data/index.js +++ b/src/features/Instructors/data/index.js @@ -1,2 +1,4 @@ export { reducer } from 'features/Instructors/data/slice'; -export { fetchInstructorsData, fetchCoursesData, fetchClassesData } from 'features/Instructors/data/thunks'; +export { + fetchInstructorsData, fetchCoursesData, fetchClassesData, assignInstructors, +} from 'features/Instructors/data/thunks'; diff --git a/src/features/Instructors/data/slice.js b/src/features/Instructors/data/slice.js index b2c58da3..f2b53151 100644 --- a/src/features/Instructors/data/slice.js +++ b/src/features/Instructors/data/slice.js @@ -19,6 +19,13 @@ const initialState = { }, filters: { }, + rowsSelected: [], + classSelected: '', + assignInstructors: { + status: RequestStatus.LOADING, + error: null, + data: null, + }, }; export const instructorsSlice = createSlice({ @@ -64,6 +71,22 @@ export const instructorsSlice = createSlice({ updateFilters: (state, { payload }) => { state.filters = payload; }, + updateRowsSelected: (state, { payload }) => { + state.rowsSelected = payload; + }, + updateClassSelected: (state, { payload }) => { + state.classSelected = payload; + }, + assingInstructorsRequest: (state) => { + state.assignInstructors.status = RequestStatus.LOADING; + }, + assingInstructorsSuccess: (state, { payload }) => { + state.assignInstructors.status = RequestStatus.SUCCESS; + state.assignInstructors.data = payload; + }, + assingInstructorsFailed: (state) => { + state.assignInstructors.status = RequestStatus.ERROR; + }, }, }); @@ -79,6 +102,11 @@ export const { fetchClassesDataSuccess, fetchClassesDataFailed, updateFilters, + updateRowsSelected, + updateClassSelected, + assingInstructorsRequest, + assingInstructorsSuccess, + assingInstructorsFailed, } = instructorsSlice.actions; export const { reducer } = instructorsSlice; diff --git a/src/features/Instructors/data/thunks.js b/src/features/Instructors/data/thunks.js index c112c9ce..082f7f6b 100644 --- a/src/features/Instructors/data/thunks.js +++ b/src/features/Instructors/data/thunks.js @@ -1,6 +1,6 @@ import { logError } from '@edx/frontend-platform/logging'; import { camelCaseObject } from '@edx/frontend-platform'; -import { getInstructorData, getCCXList } from 'features/Instructors/data/api'; +import { getInstructorData, getCCXList, handleInstructorsEnrollment } from 'features/Instructors/data/api'; import { getCoursesByInstitution } from 'features/Common/data/api'; import { fetchInstructorsDataRequest, @@ -12,7 +12,11 @@ import { fetchClassesDataRequest, fetchClassesDataSuccess, fetchClassesDataFailed, + assingInstructorsRequest, + assingInstructorsSuccess, + assingInstructorsFailed, } from 'features/Instructors/data/slice'; +import { fetchClassesData as fetchClassesDataHome } from 'features/Dashboard/data'; function fetchInstructorsData(id, currentPage, filtersData) { return async (dispatch) => { @@ -53,8 +57,24 @@ function fetchClassesData() { }; } +function assignInstructors(data, classId, institutionId) { + return async (dispatch) => { + dispatch(assingInstructorsRequest()); + try { + const response = await handleInstructorsEnrollment(data, classId); + dispatch(assingInstructorsSuccess(response.data)); + } catch (error) { + dispatch(assingInstructorsFailed()); + logError(error); + } finally { + dispatch(fetchClassesDataHome(institutionId, false)); + } + }; +} + export { fetchInstructorsData, fetchCoursesData, fetchClassesData, + assignInstructors, };