From bf14a9be3dfc163de07efe290ff8fd37743122b4 Mon Sep 17 00:00:00 2001 From: Aura Alba Date: Tue, 26 Sep 2023 16:21:18 -0500 Subject: [PATCH] feat: Add instructors component --- .../InstructorsPage/_test_/index.test.jsx | 57 ++++++++++++++ .../InstructorsPage/_test_/reducer.test.jsx | 70 ++++++++++++++++++ .../Instructors/InstructorsPage/index.jsx | 74 +++++++++++++++++++ .../Instructors/InstructorsPage/reducer.jsx | 39 ++++++++++ .../InstructorsTable/_test_/columns.test.jsx | 31 ++++++++ .../InstructorsTable/_test_/index.test.jsx | 49 ++++++++++++ .../Instructors/InstructorsTable/columns.jsx | 26 +++++++ .../Instructors/InstructorsTable/index.jsx | 50 +++++++++++++ src/features/Instructors/actionTypes.js | 4 + .../Instructors/data/_test_/api.test.js | 36 +++++++++ src/features/Instructors/data/api.js | 18 +++++ src/features/Main/Header/index.jsx | 2 +- src/features/Main/Header/index.scss | 2 +- src/features/Main/Sidebar/index.jsx | 15 +++- src/features/Main/index.jsx | 18 +++-- 15 files changed, 483 insertions(+), 8 deletions(-) create mode 100644 src/features/Instructors/InstructorsPage/_test_/index.test.jsx create mode 100644 src/features/Instructors/InstructorsPage/_test_/reducer.test.jsx create mode 100644 src/features/Instructors/InstructorsPage/index.jsx create mode 100644 src/features/Instructors/InstructorsPage/reducer.jsx create mode 100644 src/features/Instructors/InstructorsTable/_test_/columns.test.jsx create mode 100644 src/features/Instructors/InstructorsTable/_test_/index.test.jsx create mode 100644 src/features/Instructors/InstructorsTable/columns.jsx create mode 100644 src/features/Instructors/InstructorsTable/index.jsx create mode 100644 src/features/Instructors/actionTypes.js create mode 100644 src/features/Instructors/data/_test_/api.test.js create mode 100644 src/features/Instructors/data/api.js diff --git a/src/features/Instructors/InstructorsPage/_test_/index.test.jsx b/src/features/Instructors/InstructorsPage/_test_/index.test.jsx new file mode 100644 index 00000000..08701911 --- /dev/null +++ b/src/features/Instructors/InstructorsPage/_test_/index.test.jsx @@ -0,0 +1,57 @@ +import React from 'react'; +import axios from 'axios'; +import { + render, + waitFor, +} from '@testing-library/react'; +import InstructorsPage from '../index'; +import '@testing-library/jest-dom/extend-expect'; + +jest.mock('axios'); + +jest.mock('@edx/frontend-platform/logging', () => ({ + logError: jest.fn(), +})); + +const mockResponse = { + data: { + results: [ + { + instructor_username: 'Instructor1', + instructor_name: 'Instructor 1', + instructor_email: 'instructor1@example.com', + ccx_id: 'CCX1', + ccx_name: 'CCX 1', + }, + { + instructor_username: 'Instructor2', + instructor_name: 'Instructor 2', + instructor_email: 'instructor2@example.com', + ccx_id: 'CCX2', + ccx_name: 'CCX 2', + }, + ], + count: 2, + num_pages: 1, + current_page: 1, + }, +}; + +describe('InstructorPage', () => { + test('render instructor page', () => { + axios.get.mockResolvedValue(mockResponse); + + const component = render(); + + waitFor(() => { + expect(component.container).toHaveTextContent('Instructor1'); + expect(component.container).toHaveTextContent('Instructor2'); + expect(component.container).toHaveTextContent('Instructor 1'); + expect(component.container).toHaveTextContent('Instructor 2'); + expect(component.container).toHaveTextContent('instructor1@example.com'); + expect(component.container).toHaveTextContent('instructor2@example.com'); + expect(component.container).toHaveTextContent('CCX1'); + expect(component.container).toHaveTextContent('CCX 2'); + }); + }); +}); diff --git a/src/features/Instructors/InstructorsPage/_test_/reducer.test.jsx b/src/features/Instructors/InstructorsPage/_test_/reducer.test.jsx new file mode 100644 index 00000000..4359c935 --- /dev/null +++ b/src/features/Instructors/InstructorsPage/_test_/reducer.test.jsx @@ -0,0 +1,70 @@ +import { + FETCH_INSTRUCTOR_DATA_REQUEST, + FETCH_INSTRUCTOR_DATA_SUCCESS, + FETCH_INSTRUCTOR_DATA_FAILURE, + UPDATE_CURRENT_PAGE, +} from '../../actionTypes'; +import { RequestStatus } from '../../../constants'; +import reducer from '../reducer'; + +describe('Instructor page reducers', () => { + const initialState = { + data: [], + error: null, + currentPage: 1, + numPages: 0, + }; + + test('should handle FETCH_INSTRUCTOR_DATA_REQUEST', () => { + const state = { + ...initialState, + status: RequestStatus.LOADING, + }; + const action = { + type: FETCH_INSTRUCTOR_DATA_REQUEST, + }; + expect(reducer(state, action)).toEqual(state); + }); + + test('should handle FFETCH_INSTRUCTOR_DATA_SUCCESS', () => { + const state = { + ...initialState, + status: RequestStatus.SUCCESS, + count: 0, + }; + const action = { + type: FETCH_INSTRUCTOR_DATA_SUCCESS, + payload: { + results: [], + count: 0, + numPages: 0, + }, + }; + expect(reducer(state, action)).toEqual(state); + }); + + test('should handle FETCH_INSTRUCTOR_DATA_FAILURE', () => { + const state = { + ...initialState, + status: RequestStatus.ERROR, + error: '', + }; + const action = { + type: FETCH_INSTRUCTOR_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/Instructors/InstructorsPage/index.jsx b/src/features/Instructors/InstructorsPage/index.jsx new file mode 100644 index 00000000..fc0559b7 --- /dev/null +++ b/src/features/Instructors/InstructorsPage/index.jsx @@ -0,0 +1,74 @@ +import React, { useEffect, useState, useReducer } from 'react'; + +import { logError } from '@edx/frontend-platform/logging'; +import Container from '@edx/paragon/dist/Container'; +import { + Pagination, +} from '@edx/paragon'; +import InstructorsTable from '../InstructorsTable'; + +import { getInstructorData } from '../data/api'; +import reducer from './reducer'; +import { + FETCH_INSTRUCTOR_DATA_REQUEST, + FETCH_INSTRUCTOR_DATA_SUCCESS, + FETCH_INSTRUCTOR_DATA_FAILURE, + UPDATE_CURRENT_PAGE, +} from '../actionTypes'; +import { RequestStatus } from '../../constants'; + +const initialState = { + data: [], + status: RequestStatus.SUCCESS, + error: null, + currentPage: 1, + numPages: 0, +}; + +const InstructorsPage = () => { + const [state, dispatch] = useReducer(reducer, initialState); + const [currentPage, setCurrentPage] = useState(1); + + const fetchData = async () => { + dispatch({ type: FETCH_INSTRUCTOR_DATA_REQUEST }); + + try { + const response = await getInstructorData(currentPage); + dispatch({ type: FETCH_INSTRUCTOR_DATA_SUCCESS, payload: response.data }); + } catch (error) { + dispatch({ type: FETCH_INSTRUCTOR_DATA_FAILURE, payload: error }); + logError(error); + } + }; + + useEffect(() => { + fetchData(); // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentPage]); + + const handlePagination = (targetPage) => { + setCurrentPage(targetPage); + dispatch({ type: UPDATE_CURRENT_PAGE, payload: targetPage }); + fetchData(); + }; + + return ( + +

Instructors

+ + +
+ ); +}; + +export default InstructorsPage; diff --git a/src/features/Instructors/InstructorsPage/reducer.jsx b/src/features/Instructors/InstructorsPage/reducer.jsx new file mode 100644 index 00000000..5178de16 --- /dev/null +++ b/src/features/Instructors/InstructorsPage/reducer.jsx @@ -0,0 +1,39 @@ +import { + FETCH_INSTRUCTOR_DATA_REQUEST, + FETCH_INSTRUCTOR_DATA_SUCCESS, + FETCH_INSTRUCTOR_DATA_FAILURE, + UPDATE_CURRENT_PAGE, +} from '../actionTypes'; +import { RequestStatus } from '../../constants'; + +const reducer = (state, action) => { + switch (action.type) { + case FETCH_INSTRUCTOR_DATA_REQUEST: + return { ...state, status: RequestStatus.LOADING }; + case FETCH_INSTRUCTOR_DATA_SUCCESS: { + const { results, count, numPages } = action.payload; + return { + ...state, + status: RequestStatus.SUCCESS, + data: results, + numPages, + count, + }; + } + case FETCH_INSTRUCTOR_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/Instructors/InstructorsTable/_test_/columns.test.jsx b/src/features/Instructors/InstructorsTable/_test_/columns.test.jsx new file mode 100644 index 00000000..89ac4825 --- /dev/null +++ b/src/features/Instructors/InstructorsTable/_test_/columns.test.jsx @@ -0,0 +1,31 @@ +import { columns } from '../columns'; + +describe('columns', () => { + test('returns an array of columns with correct properties', () => { + expect(columns).toBeInstanceOf(Array); + expect(columns).toHaveLength(5); + + const [ + usernameColumn, + nameColumn, + emailColumn, + courseNameColumn, + courseKeyColumn, + ] = columns; + + expect(usernameColumn).toHaveProperty('Header', 'User Name'); + expect(usernameColumn).toHaveProperty('accessor', 'instructor_username'); + + expect(nameColumn).toHaveProperty('Header', 'Name'); + expect(nameColumn).toHaveProperty('accessor', 'instructor_name'); + + expect(emailColumn).toHaveProperty('Header', 'Email'); + expect(emailColumn).toHaveProperty('accessor', 'instructor_email'); + + expect(courseNameColumn).toHaveProperty('Header', 'Course key'); + expect(courseNameColumn).toHaveProperty('accessor', 'ccx_id'); + + expect(courseKeyColumn).toHaveProperty('Header', 'Course name'); + expect(courseKeyColumn).toHaveProperty('accessor', 'ccx_name'); + }); +}); diff --git a/src/features/Instructors/InstructorsTable/_test_/index.test.jsx b/src/features/Instructors/InstructorsTable/_test_/index.test.jsx new file mode 100644 index 00000000..0a3321a9 --- /dev/null +++ b/src/features/Instructors/InstructorsTable/_test_/index.test.jsx @@ -0,0 +1,49 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; + +import InstructorsTable from '..'; +import { columns } from '../columns'; + +describe('Instructor Table', () => { + test('renders InstructorsTable without data', () => { + render(); + const emptyTableText = screen.getByText('No instructors found.'); + expect(emptyTableText).toBeInTheDocument(); + }); + + test('renders InstructorsTable with data', () => { + const data = [ + { + instructor_username: 'Instructor1', + instructor_name: 'Instructor 1', + instructor_email: 'instructor1@example.com', + ccx_id: 'CCX1', + ccx_name: 'CCX 1', + }, + { + instructor_username: 'Instructor2', + instructor_name: 'Instructor 2', + instructor_email: 'instructor2@example.com', + ccx_id: 'CCX2', + ccx_name: 'CCX 2', + }, + ]; + + 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(component.container).toHaveTextContent('Instructor1'); + expect(component.container).toHaveTextContent('Instructor2'); + expect(component.container).toHaveTextContent('Instructor 1'); + expect(component.container).toHaveTextContent('Instructor 2'); + expect(component.container).toHaveTextContent('instructor1@example.com'); + expect(component.container).toHaveTextContent('instructor2@example.com'); + expect(component.container).toHaveTextContent('CCX 1'); + expect(component.container).toHaveTextContent('CCX 2'); + }); +}); diff --git a/src/features/Instructors/InstructorsTable/columns.jsx b/src/features/Instructors/InstructorsTable/columns.jsx new file mode 100644 index 00000000..b2a51743 --- /dev/null +++ b/src/features/Instructors/InstructorsTable/columns.jsx @@ -0,0 +1,26 @@ +const columns = [ + { + Header: 'User Name', + accessor: 'instructor_username', + }, + { + Header: 'Name', + accessor: 'instructor_name', + }, + { + Header: 'Email', + accessor: 'instructor_email', + }, + { + Header: 'Course key', + accessor: 'ccx_id', + }, + { + Header: 'Course name', + accessor: 'ccx_name', + }, +]; + +const hideColumns = { hiddenColumns: ['ccx_id'] }; + +export { hideColumns, columns }; diff --git a/src/features/Instructors/InstructorsTable/index.jsx b/src/features/Instructors/InstructorsTable/index.jsx new file mode 100644 index 00000000..b73b127c --- /dev/null +++ b/src/features/Instructors/InstructorsTable/index.jsx @@ -0,0 +1,50 @@ +import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; + +import { IntlProvider } from 'react-intl'; +import { + Row, + Col, +} from '@edx/paragon'; +import DataTable from '@edx/paragon/dist/DataTable'; + +import { hideColumns, columns } from './columns'; + +const InstructorsTable = ({ + data, + count, +}) => { + const COLUMNS = useMemo(() => columns, []); + + return ( + + + + + + + + + + + + ); +}; + +InstructorsTable.propTypes = { + data: PropTypes.arrayOf(PropTypes.shape([])), + count: PropTypes.number, +}; + +InstructorsTable.defaultProps = { + data: [], + count: 0, +}; + +export default InstructorsTable; diff --git a/src/features/Instructors/actionTypes.js b/src/features/Instructors/actionTypes.js new file mode 100644 index 00000000..0ddfea42 --- /dev/null +++ b/src/features/Instructors/actionTypes.js @@ -0,0 +1,4 @@ +export const FETCH_INSTRUCTOR_DATA_REQUEST = 'FETCH_INSTRUCTOR_DATA_REQUEST'; +export const FETCH_INSTRUCTOR_DATA_SUCCESS = 'FETCH_INSTRUCTOR_DATA_SUCCESS'; +export const FETCH_INSTRUCTOR_DATA_FAILURE = 'FETCH_INSTRUCTOR_DATA_FAILURE'; +export const UPDATE_CURRENT_PAGE = 'UPDATE_CURRENT_PAGE'; diff --git a/src/features/Instructors/data/_test_/api.test.js b/src/features/Instructors/data/_test_/api.test.js new file mode 100644 index 00000000..e95579f3 --- /dev/null +++ b/src/features/Instructors/data/_test_/api.test.js @@ -0,0 +1,36 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { getInstructorData } from '../api'; + +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedHttpClient: jest.fn(), +})); + +jest.mock('@edx/frontend-platform', () => ({ + getConfig: jest.fn(() => ({ + LMS_BASE_URL: 'http://localhost:18000', + COURSE_OPERATIONS_API_V2_BASE_URL: 'http://localhost:18000/pearson_course_operation/api/v2', + })), +})); + +describe('getInstructorData', () => { + test('should call getAuthenticatedHttpClient with the correct parameters', () => { + const httpClientMock = { + get: jest.fn(), + }; + + const page = 1; + + getAuthenticatedHttpClient.mockReturnValue(httpClientMock); + + getInstructorData(page); + + expect(getAuthenticatedHttpClient).toHaveBeenCalledTimes(1); + expect(getAuthenticatedHttpClient).toHaveBeenCalledWith(); + + expect(httpClientMock.get).toHaveBeenCalledTimes(1); + expect(httpClientMock.get).toHaveBeenCalledWith( + 'http://localhost:18000/pearson_course_operation/api/v2/instructors/', + { params: { page } }, + ); + }); +}); diff --git a/src/features/Instructors/data/api.js b/src/features/Instructors/data/api.js new file mode 100644 index 00000000..7f2c6fbc --- /dev/null +++ b/src/features/Instructors/data/api.js @@ -0,0 +1,18 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { getConfig } from '@edx/frontend-platform'; + +function getInstructorData(page) { + const apiV2BaseUrl = getConfig().COURSE_OPERATIONS_API_V2_BASE_URL; + const params = { + page, + }; + + return getAuthenticatedHttpClient().get( + `${apiV2BaseUrl}/instructors/`, + { params }, + ); +} + +export { + getInstructorData, +}; diff --git a/src/features/Main/Header/index.jsx b/src/features/Main/Header/index.jsx index c458e871..6649363b 100644 --- a/src/features/Main/Header/index.jsx +++ b/src/features/Main/Header/index.jsx @@ -54,7 +54,7 @@ export const Header = () => { }, []); return ( -
+
{state.data.length >= 1 ? (

Global Admin

diff --git a/src/features/Main/Header/index.scss b/src/features/Main/Header/index.scss index ceffcf5b..da188340 100644 --- a/src/features/Main/Header/index.scss +++ b/src/features/Main/Header/index.scss @@ -10,7 +10,7 @@ } } -header { +header.institution-header { justify-content: space-between; align-items: center; display: flex; diff --git a/src/features/Main/Sidebar/index.jsx b/src/features/Main/Sidebar/index.jsx index 42bcf2c6..86143a1b 100644 --- a/src/features/Main/Sidebar/index.jsx +++ b/src/features/Main/Sidebar/index.jsx @@ -3,7 +3,7 @@ import { useHistory } from 'react-router-dom'; import React, { useContext, useState } from 'react'; import { getConfig } from '@edx/frontend-platform'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faUsers } from '@fortawesome/free-solid-svg-icons'; +import { faUsers, faUser } from '@fortawesome/free-solid-svg-icons'; import './index.scss'; export const Sidebar = () => { @@ -39,6 +39,19 @@ export const Sidebar = () => { Students +
  • + +
  • diff --git a/src/features/Main/index.jsx b/src/features/Main/index.jsx index 1a0af9fc..ccd78717 100644 --- a/src/features/Main/index.jsx +++ b/src/features/Main/index.jsx @@ -4,7 +4,9 @@ import { Sidebar } from 'features/Main/Sidebar'; import { Header } from 'features/Main/Header'; import { Footer } from 'features/Main/Footer'; import StudentsPage from 'features/Students/StudentsPage'; +import Container from '@edx/paragon/dist/Container'; import { getConfig } from '@edx/frontend-platform'; +import InstructorsPage from '../Instructors/InstructorsPage'; import './index.scss'; const Main = () => ( @@ -12,11 +14,17 @@ const Main = () => (
    -
    - - - -