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
+
+
+