diff --git a/src/features/Common/data/_test_/api.test.js b/src/features/Common/data/_test_/api.test.js index 7c11fdbb..39baa76c 100644 --- a/src/features/Common/data/_test_/api.test.js +++ b/src/features/Common/data/_test_/api.test.js @@ -1,5 +1,5 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { getCoursesByInstitution } from 'features/Common/data/api'; +import { getCoursesByInstitution, getLicensesByInstitution } from 'features/Common/data/api'; jest.mock('@edx/frontend-platform/auth', () => ({ getAuthenticatedHttpClient: jest.fn(), @@ -12,8 +12,8 @@ jest.mock('@edx/frontend-platform', () => ({ })), })); -describe('getCoursesByInstitution', () => { - test('should call getAuthenticatedHttpClient with the correct parameters', () => { +describe('Common api services', () => { + test('should call getCoursesByInstitution with the correct parameters', () => { const httpClientMock = { get: jest.fn(), }; @@ -34,4 +34,24 @@ describe('getCoursesByInstitution', () => { { params: { page } }, ); }); + + test('should call getLicensesByInstitution with the correct parameters', () => { + const httpClientMock = { + get: jest.fn(), + }; + + const institutionId = 1; + + getAuthenticatedHttpClient.mockReturnValue(httpClientMock); + + getLicensesByInstitution(institutionId, true); + + expect(getAuthenticatedHttpClient).toHaveBeenCalledTimes(2); + expect(getAuthenticatedHttpClient).toHaveBeenCalledWith(); + + expect(httpClientMock.get).toHaveBeenCalledTimes(1); + expect(httpClientMock.get).toHaveBeenCalledWith( + 'http://localhost:18000/pearson_course_operation/api/v2/license-pool/?limit=true&institution_id=1', + ); + }); }); diff --git a/src/features/Common/data/api.js b/src/features/Common/data/api.js index b2938082..ab26bcf4 100644 --- a/src/features/Common/data/api.js +++ b/src/features/Common/data/api.js @@ -12,6 +12,13 @@ function getCoursesByInstitution(institutionId, limit, page, filters) { ); } +function getLicensesByInstitution(institutionId, limit) { + return getAuthenticatedHttpClient().get( + `${getConfig().COURSE_OPERATIONS_API_V2_BASE_URL}/license-pool/?limit=${limit}&institution_id=${institutionId}`, + ); +} + export { getCoursesByInstitution, + getLicensesByInstitution, }; diff --git a/src/features/Dashboard/DashboardPage/_test_/index.test.jsx b/src/features/Dashboard/DashboardPage/_test_/index.test.jsx index c92d546c..225d5e2e 100644 --- a/src/features/Dashboard/DashboardPage/_test_/index.test.jsx +++ b/src/features/Dashboard/DashboardPage/_test_/index.test.jsx @@ -7,6 +7,12 @@ import { initializeStore } from 'store'; let store; +jest.mock('axios'); + +jest.mock('@edx/frontend-platform/logging', () => ({ + logError: jest.fn(), +})); + describe('DashboardPage component', () => { beforeEach(() => { store = initializeStore(); @@ -23,5 +29,6 @@ describe('DashboardPage component', () => { expect(getByText('Next month')).toBeInTheDocument(); expect(getByText('New students registered')).toBeInTheDocument(); expect(getByText('Classes scheduled')).toBeInTheDocument(); + expect(getByText('License inventory')).toBeInTheDocument(); }); }); diff --git a/src/features/Dashboard/DashboardPage/index.jsx b/src/features/Dashboard/DashboardPage/index.jsx index 215eba88..08632ad8 100644 --- a/src/features/Dashboard/DashboardPage/index.jsx +++ b/src/features/Dashboard/DashboardPage/index.jsx @@ -1,10 +1,40 @@ -import React from 'react'; -import { useSelector } from 'react-redux'; +import React, { useEffect, useState } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; + import { Container } from '@edx/paragon'; import StudentsMetrics from 'features/Students/StudentsMetrics'; +import LicensesTable from 'features/Licenses/LicensesTable'; +import { Button } from 'react-paragon-topaz'; + +import { fetchLicensesData } from 'features/Dashboard/data'; + +import 'features/Dashboard/DashboardPage/index.scss'; const DashboardPage = () => { + const dispatch = useDispatch(); const stateInstitution = useSelector((state) => state.main.institution.data); + const licenseData = useSelector((state) => state.dashboard.tableLicense.data); + const [dataTableLicense, setDataTableLicense] = useState([]); + + let idInstitution = ''; + // eslint-disable-next-line no-unused-expressions + stateInstitution.length > 0 ? idInstitution = stateInstitution[0].id : idInstitution = ''; + + useEffect(() => { + if (licenseData.length > 5) { + // Return 5 licenses with fewest remaining seats + const arraySorted = licenseData.slice().sort((license1, license2) => { + if (license1.numberOfPendingStudents > license2.numberOfPendingStudents) { return 1; } + if (license1.numberOfPendingStudents < license2.numberOfPendingStudents) { return -1; } + return 0; + }); + setDataTableLicense(arraySorted.slice(0, 5)); + } else { setDataTableLicense(licenseData); } + }, [licenseData]); + + useEffect(() => { + dispatch(fetchLicensesData(idInstitution)); + }, [idInstitution]); // eslint-disable-line react-hooks/exhaustive-deps return ( @@ -12,6 +42,13 @@ const DashboardPage = () => { {stateInstitution.length === 1 ? `Welcome to ${stateInstitution[0].name}` : 'Select an institution'} + + + License inventory + View All + + + ); }; diff --git a/src/features/Dashboard/DashboardPage/index.scss b/src/features/Dashboard/DashboardPage/index.scss new file mode 100644 index 00000000..ea96a74c --- /dev/null +++ b/src/features/Dashboard/DashboardPage/index.scss @@ -0,0 +1,6 @@ +@import "assets/colors.scss"; + +.license-section { + background-color: $color-white; + padding: 2rem; +} diff --git a/src/features/Dashboard/data/_test_/redux.test.jsx b/src/features/Dashboard/data/_test_/redux.test.jsx new file mode 100644 index 00000000..615d5894 --- /dev/null +++ b/src/features/Dashboard/data/_test_/redux.test.jsx @@ -0,0 +1,77 @@ +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { initializeMockApp } from '@edx/frontend-platform/testing'; +import { fetchLicensesData } from 'features/Dashboard/data'; +import { executeThunk } from 'test-utils'; +import { initializeStore } from 'store'; + +let axiosMock; +let store; + +describe('Dashboard redux tests', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 1, + username: 'testuser', + administrator: true, + roles: [], + }, + }); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + + store = initializeStore(); + }); + + afterEach(() => { + axiosMock.reset(); + }); + + test('successful fetch licenses data', async () => { + const licensesApiUrl = `${process.env.COURSE_OPERATIONS_API_V2_BASE_URL}/license-pool/?limit=false&institution_id=1`; + const mockResponse = [ + { + licenseName: 'License Name 1', + purchasedSeats: 20, + numberOfStudents: 6, + numberOfPendingStudents: 11, + }, + { + licenseName: 'License Name 2', + purchasedSeats: 10, + numberOfStudents: 1, + numberOfPendingStudents: 5, + }, + ]; + axiosMock.onGet(licensesApiUrl) + .reply(200, mockResponse); + + expect(store.getState().dashboard.tableLicense.status) + .toEqual('loading'); + + await executeThunk(fetchLicensesData(1), store.dispatch, store.getState); + + expect(store.getState().dashboard.tableLicense.data) + .toEqual(mockResponse); + + expect(store.getState().dashboard.tableLicense.status) + .toEqual('success'); + }); + + test('failed fetch licenses data', async () => { + const licensesApiUrl = `${process.env.COURSE_OPERATIONS_API_V2_BASE_URL}/license-pool/?limit=false&institution_id=1`; + axiosMock.onGet(licensesApiUrl) + .reply(500); + + expect(store.getState().dashboard.tableLicense.status) + .toEqual('loading'); + + await executeThunk(fetchLicensesData(1), store.dispatch, store.getState); + + expect(store.getState().dashboard.tableLicense.data) + .toEqual([]); + + expect(store.getState().dashboard.tableLicense.status) + .toEqual('error'); + }); +}); diff --git a/src/features/Dashboard/data/index.js b/src/features/Dashboard/data/index.js new file mode 100644 index 00000000..96f810e7 --- /dev/null +++ b/src/features/Dashboard/data/index.js @@ -0,0 +1,2 @@ +export { reducer } from 'features/Dashboard/data/slice'; +export { fetchLicensesData } from 'features/Dashboard/data/thunks'; diff --git a/src/features/Dashboard/data/slice.js b/src/features/Dashboard/data/slice.js new file mode 100644 index 00000000..165c8e4e --- /dev/null +++ b/src/features/Dashboard/data/slice.js @@ -0,0 +1,34 @@ +/* eslint-disable no-param-reassign */ +import { createSlice } from '@reduxjs/toolkit'; +import { RequestStatus, initialStateService } from 'features/constants'; + +const initialState = { + tableLicense: { + ...initialStateService, + }, +}; + +export const dashboardSlice = createSlice({ + name: 'dashboard', + initialState, + reducers: { + fetchLicensesDataRequest: (state) => { + state.tableLicense.status = RequestStatus.LOADING; + }, + fetchLicensesDataSuccess: (state, { payload }) => { + state.tableLicense.status = RequestStatus.SUCCESS; + state.tableLicense.data = payload; + }, + fetchLicensesDataFailed: (state) => { + state.tableLicense.status = RequestStatus.ERROR; + }, + }, +}); + +export const { + fetchLicensesDataRequest, + fetchLicensesDataSuccess, + fetchLicensesDataFailed, +} = dashboardSlice.actions; + +export const { reducer } = dashboardSlice; diff --git a/src/features/Dashboard/data/thunks.js b/src/features/Dashboard/data/thunks.js new file mode 100644 index 00000000..910479a8 --- /dev/null +++ b/src/features/Dashboard/data/thunks.js @@ -0,0 +1,26 @@ +import { logError } from '@edx/frontend-platform/logging'; +import { camelCaseObject } from '@edx/frontend-platform'; + +import { getLicensesByInstitution } from 'features/Common/data/api'; +import { + fetchLicensesDataRequest, + fetchLicensesDataSuccess, + fetchLicensesDataFailed, +} from 'features/Dashboard/data/slice'; + +function fetchLicensesData(id) { + return async (dispatch) => { + dispatch(fetchLicensesDataRequest()); + try { + const response = camelCaseObject(await getLicensesByInstitution(id, false)); + dispatch(fetchLicensesDataSuccess(response.data)); + } catch (error) { + dispatch(fetchLicensesDataFailed()); + logError(error); + } + }; +} + +export { + fetchLicensesData, +}; diff --git a/src/features/Licenses/LicensesTable/_test_/columns.test.jsx b/src/features/Licenses/LicensesTable/_test_/columns.test.jsx new file mode 100644 index 00000000..92340c2a --- /dev/null +++ b/src/features/Licenses/LicensesTable/_test_/columns.test.jsx @@ -0,0 +1,27 @@ +import { columns } from 'features/Licenses/LicensesTable/columns'; + +describe('columns in license table', () => { + test('returns an array of columns with correct properties', () => { + expect(columns).toBeInstanceOf(Array); + expect(columns).toHaveLength(4); + + const [ + nameColumn, + purchasedColumn, + enrolledColumn, + remainingColumn, + ] = columns; + + expect(nameColumn).toHaveProperty('Header', 'License Pool'); + expect(nameColumn).toHaveProperty('accessor', 'licenseName'); + + expect(purchasedColumn).toHaveProperty('Header', 'Purchased'); + expect(purchasedColumn).toHaveProperty('accessor', 'purchasedSeats'); + + expect(enrolledColumn).toHaveProperty('Header', 'Enrolled'); + expect(enrolledColumn).toHaveProperty('accessor', 'numberOfStudents'); + + expect(remainingColumn).toHaveProperty('Header', 'Remaining'); + expect(remainingColumn).toHaveProperty('accessor', 'numberOfPendingStudents'); + }); +}); diff --git a/src/features/Licenses/LicensesTable/_test_/index.test.jsx b/src/features/Licenses/LicensesTable/_test_/index.test.jsx new file mode 100644 index 00000000..42e1c079 --- /dev/null +++ b/src/features/Licenses/LicensesTable/_test_/index.test.jsx @@ -0,0 +1,47 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; + +import LicensesTable from 'features/Licenses/LicensesTable'; +import { columns } from 'features/Licenses/LicensesTable/columns'; + +describe('Licenses Table', () => { + test('renders Licenses table without data', () => { + render(); + const emptyTableText = screen.getByText('No licenses found.'); + expect(emptyTableText).toBeInTheDocument(); + }); + + test('renders Licenses table with data', () => { + const data = [ + { + licenseName: 'License Name 1', + purchasedSeats: 20, + numberOfStudents: 6, + numberOfPendingStudents: 11, + }, + { + licenseName: 'License Name 2', + purchasedSeats: 10, + numberOfStudents: 1, + numberOfPendingStudents: 5, + }, + ]; + + 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('License Name 1'); + expect(component.container).toHaveTextContent('License Name 2'); + expect(component.container).toHaveTextContent('20'); + expect(component.container).toHaveTextContent('10'); + expect(component.container).toHaveTextContent('6'); + expect(component.container).toHaveTextContent('1'); + expect(component.container).toHaveTextContent('11'); + expect(component.container).toHaveTextContent('5'); + }); +}); diff --git a/src/features/Licenses/LicensesTable/columns.jsx b/src/features/Licenses/LicensesTable/columns.jsx new file mode 100644 index 00000000..37036896 --- /dev/null +++ b/src/features/Licenses/LicensesTable/columns.jsx @@ -0,0 +1,20 @@ +const columns = [ + { + Header: 'License Pool', + accessor: 'licenseName', + }, + { + Header: 'Purchased', + accessor: 'purchasedSeats', + }, + { + Header: 'Enrolled', + accessor: 'numberOfStudents', + }, + { + Header: 'Remaining', + accessor: 'numberOfPendingStudents', + }, +]; + +export { columns }; diff --git a/src/features/Licenses/LicensesTable/index.jsx b/src/features/Licenses/LicensesTable/index.jsx new file mode 100644 index 00000000..ff90220b --- /dev/null +++ b/src/features/Licenses/LicensesTable/index.jsx @@ -0,0 +1,40 @@ +import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; + +import { IntlProvider } from 'react-intl'; +import { Row, Col, DataTable } from '@edx/paragon'; +import { columns } from 'features/Licenses/LicensesTable/columns'; + +const LicensesTable = ({ data, count }) => { + const COLUMNS = useMemo(() => columns, []); + + return ( + + + + + + + + + + + ); +}; + +LicensesTable.propTypes = { + data: PropTypes.arrayOf(PropTypes.shape([])), + count: PropTypes.number, +}; + +LicensesTable.defaultProps = { + data: [], + count: 0, +}; + +export default LicensesTable; diff --git a/src/store.js b/src/store.js index b5d44a04..128a606a 100644 --- a/src/store.js +++ b/src/store.js @@ -3,6 +3,7 @@ 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'; +import { reducer as dashboardReducer } from 'features/Dashboard/data'; export function initializeStore(preloadedState = undefined) { return configureStore({ @@ -11,6 +12,7 @@ export function initializeStore(preloadedState = undefined) { main: mainReducer, courses: coursesReducer, students: studentsReducer, + dashboard: dashboardReducer, }, preloadedState, });