From d6dbb65ee5df11d22e50f0fa64de85f7728ce88c Mon Sep 17 00:00:00 2001 From: Aura Milena Alba <36944773+AuraAlba@users.noreply.github.com> Date: Wed, 17 Jan 2024 10:44:23 -0500 Subject: [PATCH] PADV-949 Add licenses page (#29) feat: Add licenses page --- .../Dashboard/DashboardPage/index.jsx | 12 ++- .../Dashboard/DashboardPage/index.scss | 2 +- .../LicensesPage/_test_/index.test.jsx | 68 +++++++++++++ src/features/Licenses/LicensesPage/index.jsx | 55 +++++++++++ src/features/Licenses/LicensesTable/index.jsx | 2 +- .../Licenses/data/_test_/redux.test.jsx | 95 +++++++++++++++++++ src/features/Licenses/data/index.js | 2 + src/features/Licenses/data/slice.js | 46 +++++++++ src/features/Licenses/data/thunks.js | 26 +++++ .../Main/Sidebar/_test_/index.test.jsx | 14 ++- src/features/Main/Sidebar/index.jsx | 20 +++- src/features/Main/data/_test_/redux.test.js | 8 ++ src/features/Main/data/slice.js | 5 + src/features/Main/index.jsx | 6 +- src/store.js | 2 + 15 files changed, 354 insertions(+), 9 deletions(-) create mode 100644 src/features/Licenses/LicensesPage/_test_/index.test.jsx create mode 100644 src/features/Licenses/LicensesPage/index.jsx create mode 100644 src/features/Licenses/data/_test_/redux.test.jsx create mode 100644 src/features/Licenses/data/index.js create mode 100644 src/features/Licenses/data/slice.js create mode 100644 src/features/Licenses/data/thunks.js diff --git a/src/features/Dashboard/DashboardPage/index.jsx b/src/features/Dashboard/DashboardPage/index.jsx index 08632ad8..5067089d 100644 --- a/src/features/Dashboard/DashboardPage/index.jsx +++ b/src/features/Dashboard/DashboardPage/index.jsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from 'react'; import { useSelector, useDispatch } from 'react-redux'; +import { useHistory } from 'react-router-dom'; import { Container } from '@edx/paragon'; import StudentsMetrics from 'features/Students/StudentsMetrics'; @@ -7,10 +8,12 @@ import LicensesTable from 'features/Licenses/LicensesTable'; import { Button } from 'react-paragon-topaz'; import { fetchLicensesData } from 'features/Dashboard/data'; +import { updateActiveTab } from 'features/Main/data/slice'; import 'features/Dashboard/DashboardPage/index.scss'; const DashboardPage = () => { + const history = useHistory(); const dispatch = useDispatch(); const stateInstitution = useSelector((state) => state.main.institution.data); const licenseData = useSelector((state) => state.dashboard.tableLicense.data); @@ -20,6 +23,11 @@ const DashboardPage = () => { // eslint-disable-next-line no-unused-expressions stateInstitution.length > 0 ? idInstitution = stateInstitution[0].id : idInstitution = ''; + const handleViewAllLicenses = () => { + history.push('/licenses'); + dispatch(updateActiveTab('licenses')); + }; + useEffect(() => { if (licenseData.length > 5) { // Return 5 licenses with fewest remaining seats @@ -43,9 +51,9 @@ const DashboardPage = () => {
-
+

License inventory

- +
diff --git a/src/features/Dashboard/DashboardPage/index.scss b/src/features/Dashboard/DashboardPage/index.scss index ea96a74c..38cf436e 100644 --- a/src/features/Dashboard/DashboardPage/index.scss +++ b/src/features/Dashboard/DashboardPage/index.scss @@ -2,5 +2,5 @@ .license-section { background-color: $color-white; - padding: 2rem; + padding: 2rem 0; } diff --git a/src/features/Licenses/LicensesPage/_test_/index.test.jsx b/src/features/Licenses/LicensesPage/_test_/index.test.jsx new file mode 100644 index 00000000..765a42f9 --- /dev/null +++ b/src/features/Licenses/LicensesPage/_test_/index.test.jsx @@ -0,0 +1,68 @@ +import React from 'react'; +import axios from 'axios'; +import { render, waitFor } from '@testing-library/react'; +import LicensesPage from 'features/Licenses/LicensesPage'; +import '@testing-library/jest-dom/extend-expect'; +import { Provider } from 'react-redux'; +import { initializeStore } from 'store'; + +let store; + +jest.mock('axios'); + +jest.mock('@edx/frontend-platform/logging', () => ({ + logError: jest.fn(), +})); + +const mockResponse = { + data: { + results: [ + { + licenseName: 'License Name 1', + purchasedSeats: 20, + numberOfStudents: 6, + numberOfPendingStudents: 11, + }, + { + licenseName: 'License Name 2', + purchasedSeats: 10, + numberOfStudents: 1, + numberOfPendingStudents: 5, + }, + ], + count: 2, + num_pages: 1, + current_page: 1, + }, +}; + +describe('LicensesPage component', () => { + beforeEach(() => { + store = initializeStore(); + }); + + test('renders licenses data components', () => { + axios.get.mockResolvedValue(mockResponse); + + const component = render( + + + , + ); + + waitFor(() => { + expect(component.container).toHaveTextContent('License Pool'); + expect(component.container).toHaveTextContent('License Name 1'); + expect(component.container).toHaveTextContent('License Name 2'); + expect(component.container).toHaveTextContent('Purchased'); + expect(component.container).toHaveTextContent('20'); + expect(component.container).toHaveTextContent('10'); + expect(component.container).toHaveTextContent('Enrolled'); + expect(component.container).toHaveTextContent('6'); + expect(component.container).toHaveTextContent('1'); + expect(component.container).toHaveTextContent('Remaining'); + expect(component.container).toHaveTextContent('11'); + expect(component.container).toHaveTextContent('5'); + }); + }); +}); diff --git a/src/features/Licenses/LicensesPage/index.jsx b/src/features/Licenses/LicensesPage/index.jsx new file mode 100644 index 00000000..583a1ef0 --- /dev/null +++ b/src/features/Licenses/LicensesPage/index.jsx @@ -0,0 +1,55 @@ +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import Container from '@edx/paragon/dist/Container'; +import LicensesTable from 'features/Licenses/LicensesTable'; +import { Pagination } from '@edx/paragon'; + +import { fetchLicensesData } from 'features/Licenses/data'; +import { updateCurrentPage } from 'features/Licenses/data/slice'; +import { initialPage } from 'features/constants'; + +const LicensesPage = () => { + const dispatch = useDispatch(); + const stateInstitution = useSelector((state) => state.main.institution.data); + const stateLicenses = useSelector((state) => state.licenses.table); + const [currentPage, setCurrentPage] = useState(initialPage); + + let idInstitution = ''; + // eslint-disable-next-line no-unused-expressions + stateInstitution.length > 0 ? idInstitution = stateInstitution[0].id : idInstitution = ''; + + const handlePagination = (targetPage) => { + setCurrentPage(targetPage); + dispatch(updateCurrentPage(targetPage)); + }; + + useEffect(() => { + dispatch(fetchLicensesData(idInstitution)); + }, [currentPage]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + +

License pool inventory

+
+ + {stateLicenses.numPages > 1 && ( + + )} +
+
+ ); +}; + +export default LicensesPage; diff --git a/src/features/Licenses/LicensesTable/index.jsx b/src/features/Licenses/LicensesTable/index.jsx index ff90220b..b9f1c0e5 100644 --- a/src/features/Licenses/LicensesTable/index.jsx +++ b/src/features/Licenses/LicensesTable/index.jsx @@ -11,7 +11,7 @@ const LicensesTable = ({ data, count }) => { return ( - + { + 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=true&institution_id=1`; + const mockResponse = { + results: [ + { + licenseName: 'License Name 1', + purchasedSeats: 20, + numberOfStudents: 6, + numberOfPendingStudents: 11, + }, + { + licenseName: 'License Name 2', + purchasedSeats: 10, + numberOfStudents: 1, + numberOfPendingStudents: 5, + }, + ], + count: 2, + num_pages: 1, + current_page: 1, + }; + axiosMock.onGet(licensesApiUrl) + .reply(200, mockResponse); + + expect(store.getState().licenses.table.status) + .toEqual('loading'); + + await executeThunk(fetchLicensesData(1), store.dispatch, store.getState); + + expect(store.getState().licenses.table.data) + .toEqual(mockResponse.results); + + expect(store.getState().licenses.table.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().licenses.table.status) + .toEqual('loading'); + + await executeThunk(fetchLicensesData(1), store.dispatch, store.getState); + + expect(store.getState().licenses.table.data) + .toEqual([]); + + expect(store.getState().licenses.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().licenses.table).toEqual(expectState); + }); +}); diff --git a/src/features/Licenses/data/index.js b/src/features/Licenses/data/index.js new file mode 100644 index 00000000..28d81c01 --- /dev/null +++ b/src/features/Licenses/data/index.js @@ -0,0 +1,2 @@ +export { reducer } from 'features/Licenses/data/slice'; +export { fetchLicensesData } from 'features/Licenses/data/thunks'; diff --git a/src/features/Licenses/data/slice.js b/src/features/Licenses/data/slice.js new file mode 100644 index 00000000..f006a1cf --- /dev/null +++ b/src/features/Licenses/data/slice.js @@ -0,0 +1,46 @@ +/* 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, + }, +}; + +export const licensesSlice = createSlice({ + name: 'licenses', + initialState, + reducers: { + updateCurrentPage: (state, { payload }) => { + state.table.currentPage = payload; + }, + fetchLicensesDataRequest: (state) => { + state.table.status = RequestStatus.LOADING; + }, + fetchLicensesDataSuccess: (state, { payload }) => { + const { results, count, numPages } = payload; + state.table.status = RequestStatus.SUCCESS; + state.table.data = results; + state.table.numPages = numPages; + state.table.count = count; + }, + fetchLicensesDataFailed: (state) => { + state.table.status = RequestStatus.ERROR; + }, + }, +}); + +export const { + updateCurrentPage, + fetchLicensesDataRequest, + fetchLicensesDataSuccess, + fetchLicensesDataFailed, +} = licensesSlice.actions; + +export const { reducer } = licensesSlice; diff --git a/src/features/Licenses/data/thunks.js b/src/features/Licenses/data/thunks.js new file mode 100644 index 00000000..70ac5558 --- /dev/null +++ b/src/features/Licenses/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/Licenses/data/slice'; + +function fetchLicensesData(id) { + return async (dispatch) => { + dispatch(fetchLicensesDataRequest()); + try { + const response = camelCaseObject(await getLicensesByInstitution(id, true)); + dispatch(fetchLicensesDataSuccess(response.data)); + } catch (error) { + dispatch(fetchLicensesDataFailed()); + logError(error); + } + }; +} + +export { + fetchLicensesData, +}; diff --git a/src/features/Main/Sidebar/_test_/index.test.jsx b/src/features/Main/Sidebar/_test_/index.test.jsx index 853fe953..35119ff4 100644 --- a/src/features/Main/Sidebar/_test_/index.test.jsx +++ b/src/features/Main/Sidebar/_test_/index.test.jsx @@ -1,7 +1,11 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react'; +import { Provider } from 'react-redux'; import { Sidebar } from 'features/Main/Sidebar'; import '@testing-library/jest-dom/extend-expect'; +import { initializeStore } from 'store'; + +let store; const mockHistoryPush = jest.fn(); @@ -16,8 +20,16 @@ jest.mock('react-router', () => ({ })); describe('Sidebar', () => { + beforeEach(() => { + store = initializeStore(); + }); + it('should render properly', () => { - const { getByRole } = render(); + const { getByRole } = render( + + + , + ); const studentsTabButton = getByRole('button', { name: /students/i }); expect(studentsTabButton).toBeInTheDocument(); diff --git a/src/features/Main/Sidebar/index.jsx b/src/features/Main/Sidebar/index.jsx index f46e65b0..0c51f18b 100644 --- a/src/features/Main/Sidebar/index.jsx +++ b/src/features/Main/Sidebar/index.jsx @@ -1,13 +1,16 @@ -import React, { useState } from 'react'; +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { useHistory } from 'react-router-dom'; +import { updateActiveTab } from 'features/Main/data/slice'; import './index.scss'; export const Sidebar = () => { - const [activeTab, setActiveTab] = useState('dashboard'); + const dispatch = useDispatch(); + const activeTab = useSelector((state) => state.main.activeTab); const history = useHistory(); const handleTabClick = (tabName) => { - setActiveTab(tabName); + dispatch(updateActiveTab(tabName)); history.push(`/${tabName}`); }; @@ -26,6 +29,17 @@ export const Sidebar = () => { Home +
  • + +