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 }) => {
/>
-
-
-
-
-
-
-
-
-
+
+ {!isAssignModal && (
+
+
+
+
+ )}
+
+
+
+
@@ -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,
};