diff --git a/.env b/.env index d85583660..812730138 100644 --- a/.env +++ b/.env @@ -14,8 +14,10 @@ LOGIN_URL=null LOGOUT_URL=null MARKETING_SITE_BASE_URL=null ORDER_HISTORY_URL=null +RECORDS_BASE_URL=null REFRESH_ACCESS_TOKEN_ENDPOINT=null SEGMENT_KEY=null SITE_NAME=null +USE_LEARNER_RECORD_TAB=null USER_INFO_COOKIE_NAME=null PUBLISHER_BASE_URL=null diff --git a/.env.development b/.env.development index b975456fb..d0f6ff23a 100644 --- a/.env.development +++ b/.env.development @@ -19,8 +19,10 @@ LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg/ FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico MARKETING_SITE_BASE_URL='http://localhost:18000' ORDER_HISTORY_URL='localhost:1996/orders' +RECORDS_BASE_URL='localhost:1990' REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh' SEGMENT_KEY=null SITE_NAME='edX' +USE_LEARNER_RECORD_TAB='true' USER_INFO_COOKIE_NAME='edx-user-info' PUBLISHER_BASE_URL='http://localhost:18400' diff --git a/.env.test b/.env.test index 2d5090b15..afdc6b36b 100644 --- a/.env.test +++ b/.env.test @@ -11,7 +11,9 @@ LOGIN_URL='http://localhost:18000/login' LOGOUT_URL='http://localhost:18000/logout' MARKETING_SITE_BASE_URL='http://localhost:18000' ORDER_HISTORY_URL='localhost:1996/orders' +RECORDS_BASE_URL='localhost:1990' REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh' SEGMENT_KEY=null SITE_NAME='edX' +USE_LEARNER_RECORD_TAB='true' USER_INFO_COOKIE_NAME='edx-user-info' diff --git a/src/index.jsx b/src/index.jsx index eb622146c..efc59457c 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -22,6 +22,8 @@ import './index.scss'; mergeConfig({ LICENSE_MANAGER_URL: process.env.LICENSE_MANAGER_URL, + RECORDS_BASE_URL: process.env.RECORDS_BASE_URL, + USE_LEARNER_RECORD_TAB: process.env.USE_LEARNER_RECORD_TAB, }); subscribe(APP_READY, () => { diff --git a/src/setupTest.js b/src/setupTest.js index db2bb0da3..434975788 100755 --- a/src/setupTest.js +++ b/src/setupTest.js @@ -9,6 +9,8 @@ Enzyme.configure({ adapter: new Adapter() }); mergeConfig({ LICENSE_MANAGER_URL: process.env.LICENSE_MANAGER_URL, + RECORDS_BASE_URL: process.env.RECORDS_BASE_URL, + USE_LEARNER_RECORD_TAB: process.env.USE_LEARNER_RECORD_TAB, }); initialize({ diff --git a/src/users/LearnerInformation.jsx b/src/users/LearnerInformation.jsx index 4530a7078..49ab57e7a 100644 --- a/src/users/LearnerInformation.jsx +++ b/src/users/LearnerInformation.jsx @@ -1,15 +1,18 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Tabs, Tab } from '@edx/paragon'; +import { getConfig } from '@edx/frontend-platform'; import UserSummary from './UserSummary'; import SingleSignOnRecords from './SingleSignOnRecords'; import Licenses from './licenses/Licenses'; import EntitlementsAndEnrollmentsContainer from './EntitlementsAndEnrollmentsContainer'; import LearnerCredentials from './LearnerCredentials'; +import LearnerRecords from './LearnerRecords'; export default function LearnerInformation({ user, changeHandler, }) { + const { USE_LEARNER_RECORD_TAB } = getConfig(); return ( <>
@@ -45,7 +48,12 @@ export default function LearnerInformation({
- + {USE_LEARNER_RECORD_TAB && ( + +
+ +
+ )} ); diff --git a/src/users/LearnerInformation.test.jsx b/src/users/LearnerInformation.test.jsx index 67295709d..76bcca1b1 100644 --- a/src/users/LearnerInformation.test.jsx +++ b/src/users/LearnerInformation.test.jsx @@ -54,12 +54,13 @@ describe('Learners and Enrollments component', () => { it('renders correctly', () => { const tabs = wrapper.find('nav.nav-tabs a'); - expect(tabs.length).toEqual(4); + expect(tabs.length).toEqual(5); expect(tabs.at(0).text()).toEqual('Account Information'); expect(tabs.at(1).text()).toEqual('Enrollments/Entitlements'); expect(tabs.at(2).text()).toEqual('SSO/License Info'); expect(tabs.at(3).text()).toEqual('Learner Credentials'); + expect(tabs.at(4).text()).toEqual('Learner Records'); }); it('Account Information Tab', () => { @@ -71,6 +72,7 @@ describe('Learners and Enrollments component', () => { expect(tabs.at(1).html()).not.toEqual(expect.stringContaining('active')); expect(tabs.at(2).html()).not.toEqual(expect.stringContaining('active')); expect(tabs.at(3).html()).not.toEqual(expect.stringContaining('active')); + expect(tabs.at(4).html()).not.toEqual(expect.stringContaining('active')); const accountInfo = wrapper.find('.tab-content div#learner-information-tabpane-account'); expect(accountInfo.html()).toEqual(expect.stringContaining('active')); @@ -86,6 +88,7 @@ describe('Learners and Enrollments component', () => { expect(tabs.at(1).html()).toEqual(expect.stringContaining('active')); expect(tabs.at(2).html()).not.toEqual(expect.stringContaining('active')); expect(tabs.at(3).html()).not.toEqual(expect.stringContaining('active')); + expect(tabs.at(4).html()).not.toEqual(expect.stringContaining('active')); const enrollmentsEntitlements = wrapper.find('.tab-content div#learner-information-tabpane-enrollments-entitlements'); expect(enrollmentsEntitlements.html()).toEqual(expect.stringContaining('active')); @@ -102,6 +105,7 @@ describe('Learners and Enrollments component', () => { expect(tabs.at(1).html()).not.toEqual(expect.stringContaining('active')); expect(tabs.at(2).html()).toEqual(expect.stringContaining('active')); expect(tabs.at(3).html()).not.toEqual(expect.stringContaining('active')); + expect(tabs.at(4).html()).not.toEqual(expect.stringContaining('active')); const ssoRecords = wrapper.find('.tab-content div#learner-information-tabpane-sso'); expect(ssoRecords.html()).toEqual(expect.stringContaining('active')); @@ -122,6 +126,7 @@ describe('Learners and Enrollments component', () => { expect(tabs.at(1).html()).not.toEqual(expect.stringContaining('active')); expect(tabs.at(2).html()).not.toEqual(expect.stringContaining('active')); expect(tabs.at(3).html()).toEqual(expect.stringContaining('active')); + expect(tabs.at(4).html()).not.toEqual(expect.stringContaining('active')); const credentials = wrapper.find( '.tab-content div#learner-information-tabpane-credentials', @@ -131,4 +136,24 @@ describe('Learners and Enrollments component', () => { expect.stringContaining('Learner Credentials'), ); }); + + it('Learner Records Tab', () => { + let tabs = wrapper.find('nav.nav-tabs a'); + + tabs.at(4).simulate('click'); + tabs = wrapper.find('nav.nav-tabs a'); + expect(tabs.at(0).html()).not.toEqual(expect.stringContaining('active')); + expect(tabs.at(1).html()).not.toEqual(expect.stringContaining('active')); + expect(tabs.at(2).html()).not.toEqual(expect.stringContaining('active')); + expect(tabs.at(3).html()).not.toEqual(expect.stringContaining('active')); + expect(tabs.at(4).html()).toEqual(expect.stringContaining('active')); + + const records = wrapper.find( + '.tab-content div#learner-information-tabpane-records', + ); + expect(records.html()).toEqual(expect.stringContaining('active')); + expect(records.html()).toEqual( + expect.stringContaining('Learner Records'), + ); + }); }); diff --git a/src/users/LearnerRecords.jsx b/src/users/LearnerRecords.jsx new file mode 100644 index 000000000..ba79ad17c --- /dev/null +++ b/src/users/LearnerRecords.jsx @@ -0,0 +1,152 @@ +import { getConfig } from '@edx/frontend-platform'; +import { Alert, Button } from '@edx/paragon'; +import PropTypes from 'prop-types'; +import React, { useEffect, useState } from 'react'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import Table from '../components/Table'; +import { getLearnerRecords } from './data/api'; + +import messages from './messages'; + +function LearnerRecords({ username, intl }) { + const [records, setRecords] = useState(null); + const [error, setError] = useState(null); + const { RECORDS_BASE_URL } = getConfig(); + + useEffect(() => { + if (username) { + getLearnerRecords(username).then((data) => { + if (!data.errors) { + setRecords(data); + } else { + setError(data.errors[0]); + } + }); + } + }, [username]); + + const handleCopyButton = (uuid) => { + navigator.clipboard.writeText(`${RECORDS_BASE_URL}/shared/${uuid}`); + }; + + const renderStatus = (program) => { + if (program.completed) { + return ( + intl.formatMessage(messages.earnedStatus) + ); + } + if (program.empty) { + return ( + intl.formatMessage(messages.notEarnedStatus) + ); + } + return ( + intl.formatMessage(messages.partiallyCompletedStatus) + ); + }; + + const renderDate = (date) => ( + `${intl.formatMessage(messages.recordTableLastUpdated)}: ${new Date(date).toLocaleDateString()}` + ); + + return ( +
+

{intl.formatMessage(messages.learnerRecordsTabHeader)}

+ { + records ? ( + records.map(({ record, uuid }, idx) => ( +
+
+
+

{record.program.name}

+

{record.program.type_name}

+

{renderStatus(record.program)}

+

{renderDate(record.program.last_updated)}

+
+ {record.shared_program_record_uuid && ( + + )} +
+ ({ + name: grade.name, + school: grade.school, + course_id: grade.course_id.split(':')[1], + letter_grade: grade.letter_grade, + attempts: grade.attempts, + percent_grade: + grade.issue_date + ? `${parseInt(Math.round(grade.percent_grade * 100), 10).toString()}%` + : '', + issue_date: + grade.issue_date + ? new Date(grade.issue_date).toLocaleDateString() + : '', + status: + grade.issue_date + ? intl.formatMessage(messages.earnedStatus) + : intl.formatMessage(messages.notEarnedStatus), + }))} + styleName="custom-table" + /> + + )) + ) : ( + <> + {error ? ( + {error.text} + ) : ( +

{`${intl.formatMessage(messages.noRecordsFound)}: ${username}`}

+ )} + + ) + } + + ); +} + +LearnerRecords.propTypes = { + username: PropTypes.string.isRequired, + intl: intlShape.isRequired, +}; + +export default injectIntl(LearnerRecords); diff --git a/src/users/LearnerRecords.test.jsx b/src/users/LearnerRecords.test.jsx new file mode 100644 index 000000000..59f6f3542 --- /dev/null +++ b/src/users/LearnerRecords.test.jsx @@ -0,0 +1,143 @@ +import { mount } from 'enzyme'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { waitForComponentToPaint } from '../setupTest'; +import UserMessagesProvider from '../userMessages/UserMessagesProvider'; +import * as api from './data/api'; +import records from './data/test/records'; +import LearnerRecords from './LearnerRecords'; + +const LearnerRecordsWrapper = (props) => ( + + + + + + + +); + +describe('Learner Records Tests', () => { + let wrapper; + let apiMock; + const data = { + username: 'edx', + }; + + beforeEach(() => { + if (apiMock) { + apiMock.mockReset(); + } + }); + + afterEach(() => { + wrapper.unmount(); + }); + + it('renders a message with no results', async () => { + wrapper = mount(); + apiMock = jest + .spyOn(api, 'getLearnerRecords') + .mockImplementationOnce(() => Promise.resolve([])); + + await waitForComponentToPaint(wrapper); + + expect(wrapper.find('p').text()).toEqual(`No results found for username: ${data.username}`); + }); + + it('renders an error message', async () => { + const expectedError = { + errors: [ + { + code: null, + dismissible: true, + text: 'There was an error retrieving records for the user', + type: 'danger', + topic: 'credentials', + }, + ], + }; + apiMock = jest + .spyOn(api, 'getLearnerRecords') + .mockImplementationOnce(() => Promise.resolve(expectedError)); + wrapper = mount(); + + await waitForComponentToPaint(wrapper); + + expect(wrapper.find('.alert').text()).toEqual(expectedError.errors[0].text); + }); + + it('renders metadata for a program record', async () => { + apiMock = jest + .spyOn(api, 'getLearnerRecords') + .mockImplementationOnce(() => Promise.resolve(records)); + + wrapper = mount(); + + await waitForComponentToPaint(wrapper); + + const { program } = records[0].record; + + expect(wrapper.find('h4').text()).toEqual(program.name); + expect(wrapper.find('p').at(0).text()).toEqual(program.type_name); + expect(wrapper.find('p').at(1).text()).toEqual('Partially Completed'); + expect(wrapper.find('p').at(2).text()).toEqual(`Last updated: ${new Date(program.last_updated).toLocaleDateString()}`); + }); + + it('copies a link to the clipboard when the "Copy Program Record link" button is clicked', async () => { + apiMock = jest + .spyOn(api, 'getLearnerRecords') + .mockImplementationOnce(() => Promise.resolve(records)); + + wrapper = mount(); + + await waitForComponentToPaint(wrapper); + + Object.assign(navigator, { + clipboard: { + writeText: () => {}, + }, + }); + jest.spyOn(navigator.clipboard, 'writeText'); + + const copyButton = wrapper.find('button').at(0); + expect(copyButton.text()).toEqual('Copy public record link'); + copyButton.simulate('click'); + + expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1); + }); + + it('renders a table for a program record', async () => { + apiMock = jest + .spyOn(api, 'getLearnerRecords') + .mockImplementationOnce(() => Promise.resolve(records)); + + wrapper = mount(); + + await waitForComponentToPaint(wrapper); + + const dataTable = wrapper.find('table.custom-table').at(0); + const firstDataRow = dataTable.find('tr').at(1); + + expect(dataTable.find('th').at(0).text()).toEqual('Course Name'); + expect(dataTable.find('th').at(1).text()).toEqual('School'); + expect(dataTable.find('th').at(2).text()).toEqual('Course ID'); + expect(dataTable.find('th').at(3).text()).toEqual('Highest grade earned'); + expect(dataTable.find('th').at(4).text()).toEqual('Letter Grade'); + expect(dataTable.find('th').at(5).text()).toEqual('Verified Attempts'); + expect(dataTable.find('th').at(6).text()).toEqual('Date Earned'); + expect(dataTable.find('th').at(7).text()).toEqual('Status'); + + const grade = records[0].record.grades[0]; + + expect(firstDataRow.find('td').at(0).text()).toEqual(grade.name); + expect(firstDataRow.find('td').at(1).text()).toEqual(grade.school); + expect(firstDataRow.find('td').at(2).text()).toEqual(grade.course_id.split(':')[1]); + expect(firstDataRow.find('td').at(3).text()).toEqual(`${parseInt(Math.round(grade.percent_grade * 100), 10).toString()}%`); + expect(firstDataRow.find('td').at(4).text()).toEqual(grade.letter_grade); + expect(firstDataRow.find('td').at(5).text()).toEqual(grade.attempts.toString()); + expect(firstDataRow.find('td').at(6).text()).toEqual(new Date(grade.issue_date).toLocaleDateString()); + expect(firstDataRow.find('td').at(7).text()).toEqual('Earned'); + }); +}); diff --git a/src/users/data/api.js b/src/users/data/api.js index 70f1aa0a5..85f7ef935 100644 --- a/src/users/data/api.js +++ b/src/users/data/api.js @@ -698,3 +698,31 @@ export async function getUserProgramCredentials(username, page = 1) { }; } } + +export async function getLearnerRecords(username) { + try { + const { data } = await getAuthenticatedHttpClient().get(`${AppUrls.getLearnerRecordsUrl()}/?username=${username}`); + const programDetails = []; + + if (data.enrolled_programs.length > 0) { + await Promise.all(data.enrolled_programs.map(program => ( + getAuthenticatedHttpClient().get(`${AppUrls.getLearnerRecordsUrl()}/${program.uuid}/?username=${username}`) + .then(response => programDetails.push(response.data)) + ))); + } + + return programDetails; + } catch (error) { + return { + errors: [ + { + code: null, + dismissible: true, + text: 'There was an error retrieving records for the user', + type: 'danger', + topic: 'credentials', + }, + ], + }; + } +} diff --git a/src/users/data/api.test.js b/src/users/data/api.test.js index 9e6e53211..897b7f3c4 100644 --- a/src/users/data/api.test.js +++ b/src/users/data/api.test.js @@ -7,6 +7,7 @@ import { downloadableCertificate } from './test/certificates'; import verifiedNameHistoryData from './test/verifiedNameHistory'; import OnboardingStatusData from './test/onboardingStatus'; import { credentials } from './test/credentials'; +import records from './test/records'; import * as api from './api'; import * as urls from './urls'; import * as messages from '../../userMessages/messages'; @@ -32,6 +33,7 @@ describe('API', () => { const generateCertificateUrl = urls.generateCertificateUrl(); const regenerateCertificateUrl = urls.regenerateCertificateUrl(); const getEnterpriseCustomerUsersUrl = urls.getEnterpriseCustomerUsersUrl(testUsername); + const programRecordsUrl = urls.getLearnerRecordsUrl(); let mockAdapter; @@ -1110,4 +1112,57 @@ describe('API', () => { expect(response).toEqual(expectedData); }); }); + + describe('Learner Records', () => { + const expectedPrograms = { + enrolled_programs: [ + { + name: 'Tightrope walking', + uuid: '82d38639ccc340db8be5f0f259500dde', + partner: 'edX', + completed: false, + empty: false, + }, + ], + }; + const expectedRecord = records[0]; + const expectedError = { + errors: [ + { + code: null, + dismissible: true, + text: 'There was an error retrieving records for the user', + type: 'danger', + topic: 'credentials', + }, + ], + }; + + it('Successful Learner Records fetch', async () => { + mockAdapter.onGet(`${programRecordsUrl}/?username=${testUsername}`).reply(200, expectedPrograms); + mockAdapter.onGet(`${programRecordsUrl}/${expectedRecord.uuid}/?username=${testUsername}`).reply(200, expectedRecord); + const response = await api.getLearnerRecords(testUsername); + expect(response.length).toEqual(1); + expect(response).toEqual(records); + }); + + it('Empty Learner Records fetch', async () => { + mockAdapter.onGet(`${programRecordsUrl}/?username=${testUsername}`).reply(200, { enrolled_programs: [] }); + const response = await api.getLearnerRecords(testUsername); + expect(response).toEqual([]); + }); + + it('Unsuccessful Learner Records fetch', async () => { + mockAdapter.onGet(`${programRecordsUrl}/?username=${testUsername}`).reply(400, ''); + const response = await api.getLearnerRecords(testUsername); + expect(response).toEqual(expectedError); + }); + + it('Unsuccessful Learner Record Details fetch', async () => { + mockAdapter.onGet(`${programRecordsUrl}/?username=${testUsername}`).reply(200, expectedPrograms); + mockAdapter.onGet(`${programRecordsUrl}/${expectedRecord.uuid}/?username=${testUsername}`).reply(400, expectedRecord); + const response = await api.getLearnerRecords(testUsername); + expect(response).toEqual(expectedError); + }); + }); }); diff --git a/src/users/data/test/records.js b/src/users/data/test/records.js new file mode 100644 index 000000000..e0b2f99bd --- /dev/null +++ b/src/users/data/test/records.js @@ -0,0 +1,56 @@ +const records = [ + { + record: { + learner: { + full_name: 'Sebastian Malone', + username: 'username', + email: 'email@example.com', + }, + program: { + name: 'Tightrope walking', + type: 'professional-certificate', + type_name: 'Professional Certificate', + completed: false, + empty: false, + last_updated: '2022-06-28T18:46:59.978935+00:00', + school: 'edX', + }, + platform_name: 'Open edX', + grades: [ + { + name: 'Intro to Tightrope walking', + school: 'edX', + attempts: 1, + course_id: 'course-v1:edX+Tightrope101+2T2022', + issue_date: '2022-06-28T18:46:59+00:00', + percent_grade: 1.0, + letter_grade: 'Pass', + }, + { + name: 'Advanced Tightrope Walking', + school: 'edX', + attempts: 0, + course_id: '', + issue_date: '', + percent_grade: 0.0, + letter_grade: '', + }, + ], + pathways: [ + { + name: 'Funambulist', + id: 1, + status: 'sent', + is_active: true, + pathway_type: 'credit', + }, + ], + shared_program_record_uuid: '99a78cf1-354a-4f8a-8922-913f695e187f', + }, + is_public: false, + uuid: '82d38639ccc340db8be5f0f259500dde', + records_help_url: 'http://localhost:18000/faq', + }, +]; + +export default records; diff --git a/src/users/data/urls.js b/src/users/data/urls.js index 0297fffda..0089cadde 100644 --- a/src/users/data/urls.js +++ b/src/users/data/urls.js @@ -104,3 +104,5 @@ export const regenerateCertificateUrl = () => `${ }/certificates/regenerate`; export const getUserCredentialsUrl = () => `${CREDENTIALS_BASE_URL}/api/v2/credentials`; + +export const getLearnerRecordsUrl = () => `${CREDENTIALS_BASE_URL}/records/api/v1/program_records`; diff --git a/src/users/messages.js b/src/users/messages.js new file mode 100644 index 000000000..f0713abbb --- /dev/null +++ b/src/users/messages.js @@ -0,0 +1,66 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + learnerRecordsTabHeader: { + id: 'learner.record.tab.header', + defaultMessage: 'Learner Records', + }, + earnedStatus: { + id: 'earned.status', + defaultMessage: 'Earned', + }, + notEarnedStatus: { + id: 'not.earned.status', + defaultMessage: 'Not Earned', + }, + partiallyCompletedStatus: { + id: 'partially.completed.status', + defaultMessage: 'Partially Completed', + }, + recordTableLastUpdated: { + id: 'record.table.last.updated', + defaultMessage: 'Last updated', + }, + copyPublicRecordLinkButton: { + id: 'copy.public.record.link', + defaultMessage: 'Copy public record link', + }, + recordTableHeaderCourseName: { + id: 'record.table.header.course.name', + defaultMessage: 'Course Name', + }, + recordTableHeaderSchool: { + id: 'record.table.header.school', + defaultMessage: 'School', + }, + recordTableHeaderCourseId: { + id: 'record.table.header.course.id', + defaultMessage: 'Course ID', + }, + recordTableHeaderHighestGrade: { + id: 'record.table.header.highest.grade', + defaultMessage: 'Highest grade earned', + }, + recordTableHeaderLetterGrade: { + id: 'record.table.header.letter.grade', + defaultMessage: 'Letter Grade', + }, + recordTableHeaderVerifiedAttempts: { + id: 'record.table.header.verified.attempts', + defaultMessage: 'Verified Attempts', + }, + recordTableHeaderDateEarned: { + id: 'record.table.header.date.earned', + defaultMessage: 'Date Earned', + }, + recordTableHeaderStatus: { + id: 'record.table.header.status', + defaultMessage: 'Status', + }, + noRecordsFound: { + id: 'no.records.found', + defaultMessage: 'No results found for username', + }, +}); + +export default messages;