From 2876c28f281fbad8b4b5939f899037cb593a8acf Mon Sep 17 00:00:00 2001 From: Azan Bin Zahid Date: Thu, 1 Apr 2021 14:14:05 +0500 Subject: [PATCH] feat: show user license subscription data from license-manager --- .env | 1 + .env.development | 1 + .env.test | 1 + src/index.jsx | 7 +- src/setupTest.js | 4 + src/users/Licenses.jsx | 145 ++++++++++++++++++++++++++++++++ src/users/Licenses.test.jsx | 48 +++++++++++ src/users/UserPage.jsx | 11 +++ src/users/data/api.js | 43 +++++++++- src/users/data/api.test.js | 51 +++++++++++ src/users/data/test/licenses.js | 28 ++++++ 11 files changed, 338 insertions(+), 2 deletions(-) create mode 100644 src/users/Licenses.jsx create mode 100644 src/users/Licenses.test.jsx create mode 100644 src/users/data/test/licenses.js diff --git a/.env b/.env index 05e2ed61b..5bafdc397 100644 --- a/.env +++ b/.env @@ -6,6 +6,7 @@ CSRF_TOKEN_API_PATH=null ECOMMERCE_BASE_URL=null LANGUAGE_PREFERENCE_COOKIE_NAME=null LMS_BASE_URL=null +LICENSE_MANAGER_URL=null DISCOVERY_API_BASE_URL=null LOGIN_URL=null LOGOUT_URL=null diff --git a/.env.development b/.env.development index a9e6c2680..a0a3972cb 100644 --- a/.env.development +++ b/.env.development @@ -7,6 +7,7 @@ CSRF_TOKEN_API_PATH='/csrf/api/v1/token' ECOMMERCE_BASE_URL='http://localhost:18130' LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference' LMS_BASE_URL='http://localhost:18000' +LICENSE_MANAGER_URL='http://localhost:18170' DISCOVERY_API_BASE_URL='http://localhost:18381' LOGIN_URL='http://localhost:18000/login' LOGOUT_URL='http://localhost:18000/logout' diff --git a/.env.test b/.env.test index 972885570..2d5090b15 100644 --- a/.env.test +++ b/.env.test @@ -5,6 +5,7 @@ CSRF_TOKEN_API_PATH='/csrf/api/v1/token' ECOMMERCE_BASE_URL='http://localhost:18130' LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference' LMS_BASE_URL='http://localhost:18000' +LICENSE_MANAGER_URL='http://localhost:18170' DISCOVERY_API_BASE_URL='http://localhost:18381' LOGIN_URL='http://localhost:18000/login' LOGOUT_URL='http://localhost:18000/logout' diff --git a/src/index.jsx b/src/index.jsx index 3f6d8ec49..792e1575b 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -1,10 +1,11 @@ import 'babel-polyfill'; import { - APP_INIT_ERROR, APP_READY, subscribe, initialize, + APP_INIT_ERROR, APP_READY, subscribe, initialize, mergeConfig, } from '@edx/frontend-platform'; import { AppProvider, ErrorPage } from '@edx/frontend-platform/react'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; + import React from 'react'; import ReactDOM from 'react-dom'; import { Switch, Route } from 'react-router-dom'; @@ -17,6 +18,10 @@ import UserMessagesProvider from './user-messages/UserMessagesProvider'; import './index.scss'; +mergeConfig({ + LICENSE_MANAGER_URL: process.env.LICENSE_MANAGER_URL, +}); + subscribe(APP_READY, () => { const { administrator } = getAuthenticatedUser(); if (!administrator) { diff --git a/src/setupTest.js b/src/setupTest.js index 9acb48d06..d381014b4 100755 --- a/src/setupTest.js +++ b/src/setupTest.js @@ -8,6 +8,10 @@ import { MockAuthService } from '@edx/frontend-platform/auth'; Enzyme.configure({ adapter: new Adapter() }); +mergeConfig({ + LICENSE_MANAGER_URL: process.env.LICENSE_MANAGER_URL, +}); + initialize({ handlers: { config: () => { diff --git a/src/users/Licenses.jsx b/src/users/Licenses.jsx new file mode 100644 index 000000000..f6327f358 --- /dev/null +++ b/src/users/Licenses.jsx @@ -0,0 +1,145 @@ +import React, { + useMemo, + useState, + useCallback, +} from 'react'; +import PropTypes from 'prop-types'; +import { Collapsible, Badge } from '@edx/paragon'; +import sort from './sort'; +import Table from '../Table'; +import formatDate from '../dates/formatDate'; + +export default function Licenses({ + data, status, expanded, +}) { + const [sortColumn, setSortColumn] = useState('status'); + const [sortDirection, setSortDirection] = useState('desc'); + const responseStatus = useMemo(() => status, [status]); + const tableData = useMemo(() => { + if (data === null || data.length === 0) { + return []; + } + return data.map(result => ({ + status: { + value: result.status, + }, + assignedDate: { + displayValue: formatDate(result.assignedDate), + value: result.assignedDate, + }, + activationDate: { + displayValue: formatDate(result.activationDate), + value: result.activationDate, + }, + revokedDate: { + displayValue: formatDate(result.revokedDate), + value: result.revokedDate, + }, + lastRemindDate: { + displayValue: formatDate(result.lastRemindDate), + value: result.lastRemindDate, + }, + subscriptionPlanTitle: { + value: result.subscriptionPlanTitle, + }, + subscriptionPlanExpirationDate: { + displayValue: formatDate(result.subscriptionPlanExpirationDate), + value: result.subscriptionPlanExpirationDate, + }, + activationLink: { + displayValue: {result.activationLink}, + value: result.activationLink, + }, + })); + }, [data]); + + const setSort = useCallback((column) => { + if (sortColumn === column) { + setSortDirection(sortDirection === 'desc' ? 'asc' : 'desc'); + } else { + setSortDirection('desc'); + } + setSortColumn(column); + }); + const columns = [ + + { + label: 'Status', key: 'status', columnSortable: true, onSort: () => setSort('status'), width: 'col-3', + }, + { + label: 'Assigned Date', key: 'assignedDate', columnSortable: true, onSort: () => setSort('assignedDate'), width: 'col-3', + }, + { + label: 'Activation Date', key: 'activationDate', columnSortable: true, onSort: () => setSort('activationDate'), width: 'col-3', + }, + { + label: 'Revoked Date', key: 'revokedDate', columnSortable: true, onSort: () => setSort('revokedDate'), width: 'col-3', + }, + { + label: 'Last Remind Date', key: 'lastRemindDate', columnSortable: true, onSort: () => setSort('lastRemindDate'), width: 'col-3', + }, + { + label: 'Subscription Plan Title', key: 'subscriptionPlanTitle', columnSortable: true, onSort: () => setSort('subscriptionPlanTitle'), width: 'col-3', + }, + { + label: 'Subscription Plan Expiration Date', key: 'subscriptionPlanExpirationDate', columnSortable: true, onSort: () => setSort('subscriptionPlanExpirationDate'), width: 'col-3', + }, + { + label: 'Activation Link', key: 'activationLink', columnSortable: true, onSort: () => setSort('activationLink'), width: 'col-3', + }, + ]; + + const tableDataSortable = [...tableData]; + let statusMsg; + if (responseStatus !== '') { + statusMsg = {`Fetch Status: ${responseStatus}`}; + } else { + statusMsg = null; + } + + return ( +
+ + {`Licenses (${tableData.length})`} + {statusMsg} + + )} + defaultOpen={expanded} + > + sort(firstElement, secondElement, sortColumn, sortDirection), + )} + columns={columns} + tableSortable + defaultSortedColumn="status" + defaultSortDirection="desc" + /> + + + ); +} + +Licenses.propTypes = { + data: PropTypes.arrayOf(PropTypes.shape({ + status: PropTypes.string, + assignedDate: PropTypes.string, + revokedDate: PropTypes.string, + activationDate: PropTypes.string, + subscriptionPlanTitle: PropTypes.string, + lastRemindDate: PropTypes.string, + activationLink: PropTypes.string, + subscriptionPlanExpirationDate: PropTypes.string, + })), + status: PropTypes.string, + expanded: PropTypes.bool, +}; + +Licenses.defaultProps = { + data: null, + status: '', + expanded: false, +}; diff --git a/src/users/Licenses.test.jsx b/src/users/Licenses.test.jsx new file mode 100644 index 000000000..762de77e1 --- /dev/null +++ b/src/users/Licenses.test.jsx @@ -0,0 +1,48 @@ +import { mount } from 'enzyme'; +import React from 'react'; + +import Licenses from './Licenses'; +import licensesData from './data/test/licenses'; +import UserMessagesProvider from '../user-messages/UserMessagesProvider'; + +const LicensesPageWrapper = (props) => ( + + + +); + +describe('User Licenses Listing', () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(); + }); + + it('default collapsible with enrollment data', () => { + const collapsible = wrapper.find('CollapsibleAdvanced').find('.collapsible-trigger').hostNodes(); + expect(collapsible.text()).toEqual('Licenses (2)'); + }); + + it('No License Data', () => { + const licenseData = { ...licensesData, data: [], status: 'No record found' }; + wrapper = mount(); + const collapsible = wrapper.find('CollapsibleAdvanced').find('.collapsible-trigger').hostNodes(); + expect(collapsible.text()).toEqual('Licenses (0)Fetch Status: No record found'); + }); + + it('Sorting Columns Button Enabled by default', () => { + const dataTable = wrapper.find('table.table'); + const tableHeaders = dataTable.find('thead tr th'); + + tableHeaders.forEach(header => { + const sortButton = header.find('button.btn-header'); + expect(sortButton.disabled).toBeFalsy(); + }); + }); + + it('Table Header Lenght', () => { + const dataTable = wrapper.find('table.table'); + const tableHeaders = dataTable.find('thead tr th'); + expect(tableHeaders).toHaveLength(Object.keys(licensesData.data[0]).length); + }); +}); diff --git a/src/users/UserPage.jsx b/src/users/UserPage.jsx index 13f3beca9..f46366bbc 100644 --- a/src/users/UserPage.jsx +++ b/src/users/UserPage.jsx @@ -11,6 +11,7 @@ import UserMessagesContext from '../user-messages/UserMessagesContext'; import { isEmail, isValidUsername } from '../utils/index'; import { getAllUserData } from './data/api'; import Enrollments from './Enrollments'; +import Licenses from './Licenses'; import Entitlements from './entitlements/Entitlements'; import UserSearch from './UserSearch'; import UserSummary from './UserSummary'; @@ -27,6 +28,7 @@ export default function UserPage({ location }) { const [loading, setLoading] = useState(false); const [showEnrollments, setShowEnrollments] = useState(true); const [showEntitlements, setShowEntitlements] = useState(false); + const [showLicenses, setShowLicenses] = useState(false); const { add, clear } = useContext(UserMessagesContext); function pushHistoryIfChanged(nextUrl) { @@ -93,6 +95,7 @@ export default function UserPage({ location }) { setSearching(true); setShowEntitlements(false); setShowEnrollments(true); + setShowLicenses(false); handleFetchSearchResults(searchValue); }); @@ -103,12 +106,14 @@ export default function UserPage({ location }) { const handleEntitlementsChange = useCallback(() => { setShowEntitlements(true); + setShowLicenses(true); setShowEnrollments(false); handleFetchSearchResults(userIdentifier); }); const handleEnrollmentsChange = useCallback(() => { setShowEntitlements(false); + setShowLicenses(false); setShowEnrollments(true); handleFetchSearchResults(userIdentifier); }); @@ -149,6 +154,11 @@ export default function UserPage({ location }) { ssoRecords={data.ssoRecords} changeHandler={handleUserSummaryChange} /> + + )} {!loading && !userIdentifier && ( diff --git a/src/users/data/api.js b/src/users/data/api.js index 88ef3f889..f221577d2 100644 --- a/src/users/data/api.js +++ b/src/users/data/api.js @@ -1,4 +1,4 @@ -import { getConfig } from '@edx/frontend-platform'; +import { getConfig, ensureConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import * as messages from '../../user-messages/messages'; import { isEmail, isValidUsername } from '../../utils/index'; @@ -156,6 +156,44 @@ export async function getUserPasswordStatus(userIdentifier) { return data; } +ensureConfig([ + 'LICENSE_MANAGER_URL', +], 'getLicense'); + +export async function getLicense(userEmail) { + const defaultResponse = { + status: '', + results: [], + }; + try { + const { data } = await getAuthenticatedHttpClient().post( + `${getConfig().LICENSE_MANAGER_URL}/api/v1/staff_lookup_licenses/`, + { user_email: userEmail }, + ); + defaultResponse.results = data; + return defaultResponse; + } catch (error) { + let errorStatus = -1; + + if ('customAttributes' in error) { + errorStatus = error.customAttributes.httpErrorStatus; + } + + if (errorStatus === 404) { + defaultResponse.status = 'No record found'; + } else if (errorStatus === 400) { + defaultResponse.status = 'User email is not provided'; + } else if (errorStatus === 403) { + defaultResponse.status = 'Forbidden: User does not have permission to view this data'; + } else if (errorStatus === 401) { + defaultResponse.status = 'Unauthenticated: Could not autheticate user to view this data'; + } else { + defaultResponse.status = 'Unable to connect to the service'; + } + return defaultResponse; + } +} + export async function getAllUserData(userIdentifier) { const errors = []; let user = null; @@ -163,6 +201,7 @@ export async function getAllUserData(userIdentifier) { let enrollments = []; let verificationStatus = null; let ssoRecords = null; + let licenses = []; try { user = await getUser(userIdentifier); } catch (error) { @@ -178,6 +217,7 @@ export async function getAllUserData(userIdentifier) { verificationStatus = await getUserVerificationStatus(user.username); ssoRecords = await getSsoRecords(user.username); user.passwordStatus = await getUserPasswordStatus(user.username); + licenses = await getLicense(user.email); } return { @@ -187,6 +227,7 @@ export async function getAllUserData(userIdentifier) { enrollments, verificationStatus, ssoRecords, + licenses, }; } diff --git a/src/users/data/api.test.js b/src/users/data/api.test.js index feb2c2ba4..cdfea5eb2 100644 --- a/src/users/data/api.test.js +++ b/src/users/data/api.test.js @@ -15,6 +15,7 @@ describe('API', () => { const entitlementsApiBaseUrl = `${getConfig().LMS_BASE_URL}/api/entitlements/v1/entitlements/?user=${testUsername}`; const verificationDetailsApiUrl = `${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${testUsername}/verifications/`; const verificationStatusApiUrl = `${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${testUsername}/verification_status/`; + const licensesApiUrl = `${getConfig().LICENSE_MANAGER_URL}/api/v1/staff_lookup_licenses/`; let mockAdapter; @@ -278,6 +279,7 @@ describe('API', () => { mockAdapter.onGet(verificationDetailsApiUrl).reply(200, {}); mockAdapter.onGet(verificationStatusApiUrl).reply(200, {}); mockAdapter.onGet(passwordStatusApiUrl).reply(200, {}); + mockAdapter.onPost(licensesApiUrl, { user_email: testEmail }).reply(200, []); const response = await api.getAllUserData(testUsername); expect(response).toEqual({ @@ -287,6 +289,7 @@ describe('API', () => { verificationStatus: { extraData: {} }, enrollments: {}, entitlements: { results: [], next: null }, + licenses: { results: [], status: '' }, }); }); }); @@ -530,6 +533,54 @@ describe('API', () => { const response = await api.postEnrollmentChange({ ...apiCallData }); expect(response).toEqual(expectedSuccessResponse); }); + + it('Successful license fetch with data', async () => { + const expectedSuccessResponse = [ + { + status: 'unassigned', + assigned_date: null, + activation_date: null, + revoked_date: null, + last_remind_date: null, + subscription_plan_title: 'test', + subscription_plan_expiration_date: '2021-04-01', + activation_link: 'http://localhost:8734/test/licenses/None/activate', + }, + ]; + mockAdapter.onPost(licensesApiUrl, { user_email: testEmail }).reply(200, expectedSuccessResponse); + const response = await api.getLicense(testEmail); + expect(response).toEqual({ results: expectedSuccessResponse, status: '' }); + }); + + it('Unsuccessful license fetch when no email provided', async () => { + mockAdapter.onPost(licensesApiUrl, { user_email: null }).reply(() => throwError(400, '')); + const response = await api.getLicense(null); + expect(response).toEqual({ results: [], status: 'User email is not provided' }); + }); + + it('Unsuccessful license fetch when no record found', async () => { + mockAdapter.onPost(licensesApiUrl, { user_email: testEmail }).reply(() => throwError(404, '')); + const response = await api.getLicense(testEmail); + expect(response).toEqual({ results: [], status: 'No record found' }); + }); + + it('Unsuccessful license fetch when user does not have permission to license manager', async () => { + mockAdapter.onPost(licensesApiUrl, { user_email: testEmail }).reply(() => throwError(403, '')); + const response = await api.getLicense(testEmail); + expect(response).toEqual({ results: [], status: 'Forbidden: User does not have permission to view this data' }); + }); + + it('Unsuccessful license fetch when user is not authenticated', async () => { + mockAdapter.onPost(licensesApiUrl, { user_email: testEmail }).reply(() => throwError(401, '')); + const response = await api.getLicense(testEmail); + expect(response).toEqual({ results: [], status: 'Unauthenticated: Could not autheticate user to view this data' }); + }); + + it('Unsuccessful license fetch when unexpected error comes', async () => { + mockAdapter.onPost(licensesApiUrl, { user_email: testEmail }).reply(() => throwError(500, '')); + const response = await api.getLicense(testEmail); + expect(response).toEqual({ results: [], status: 'Unable to connect to the service' }); + }); }); }); }); diff --git a/src/users/data/test/licenses.js b/src/users/data/test/licenses.js new file mode 100644 index 000000000..efc71ed94 --- /dev/null +++ b/src/users/data/test/licenses.js @@ -0,0 +1,28 @@ +const licensesData = { + data: [ + { + status: 'unassigned', + assignedDate: null, + revokedDate: null, + activationDate: new Date().toISOString().replace(/T.*/, ''), + subscriptionPlanTitle: 'test', + lastRemindDate: new Date().toISOString().replace(/T.*/, ''), + activationLink: null, + subscriptionPlanExpirationDate: 'http://localhost:8734/test/licenses/None/activate', + }, + { + status: 'unassigned', + assignedDate: new Date().toISOString().replace(/T.*/, ''), + revokedDate: null, + activationDate: null, + subscriptionPlanTitle: 'test2', + lastRemindDate: new Date().toISOString().replace(/T.*/, ''), + activationLink: null, + subscriptionPlanExpirationDate: null, + }, + ], + status: '', + expanded: true, +}; + +export default licensesData;