diff --git a/src/ProgramEnrollments/LinkProgramEnrollments.jsx b/src/ProgramEnrollments/LinkProgramEnrollments.jsx
new file mode 100644
index 000000000..b781664ee
--- /dev/null
+++ b/src/ProgramEnrollments/LinkProgramEnrollments.jsx
@@ -0,0 +1,95 @@
+import { Input, Button } from '@edx/paragon';
+import React, { useState, useCallback } from 'react';
+import getLinkProgramEnrollmentDetails from './data/api';
+import LinkProgramEnrollmentsTable from './LinkProgramEnrollmentsTable';
+
+export default function LinkProgramEnrollments() {
+ const [programID, setProgramID] = useState(undefined);
+ const [usernamePairText, setUsernamePairText] = useState(undefined);
+ const [successMessage, setSuccessMessage] = useState(undefined);
+ const [errorMessage, setErrorMessage] = useState(undefined);
+ const [isFetchingData, setIsFetchingData] = useState(false);
+
+ const onProgramChange = (e) => {
+ if (e.currentTarget.value) {
+ setProgramID(e.currentTarget.value);
+ } else {
+ setProgramID(undefined);
+ }
+ };
+
+ const onUserTextChange = (e) => {
+ if (e.currentTarget.value) {
+ setUsernamePairText(e.currentTarget.value);
+ } else {
+ setUsernamePairText(undefined);
+ }
+ };
+
+ const handleSubmit = () => {
+ setIsFetchingData(true);
+ getLinkProgramEnrollmentDetails({ programID, usernamePairText }).then((response) => {
+ setSuccessMessage(response.successes);
+ setErrorMessage(response.errors);
+ setIsFetchingData(false);
+ });
+ };
+
+ const submit = useCallback((event) => {
+ event.preventDefault();
+ handleSubmit();
+ return false;
+ });
+
+ return (
+ <>
+
Link Program Enrollments
+
+ {((errorMessage && errorMessage.length > 0)
+ || (successMessage && successMessage.length > 0)) && (
+
+ )}
+ >
+ );
+}
diff --git a/src/ProgramEnrollments/LinkProgramEnrollments.test.jsx b/src/ProgramEnrollments/LinkProgramEnrollments.test.jsx
new file mode 100644
index 000000000..bdd34269c
--- /dev/null
+++ b/src/ProgramEnrollments/LinkProgramEnrollments.test.jsx
@@ -0,0 +1,173 @@
+import { mount } from 'enzyme';
+import React from 'react';
+import { MemoryRouter } from 'react-router-dom';
+import { history } from '@edx/frontend-platform';
+import { waitForComponentToPaint } from '../setupTest';
+import LinkProgramEnrollments from './LinkProgramEnrollments';
+import UserMessagesProvider from '../userMessages/UserMessagesProvider';
+import {
+ lpeSuccessResponse,
+ lpeErrorResponseInvalidUUID,
+ lpeErrorResponseEmptyValues,
+ lpeErrorResponseInvalidUsername,
+ lpeErrorResponseInvalidExternalKey,
+ lpeErrorResponseAlreadyLinked,
+} from './data/test/linkProgramEnrollment';
+
+import * as api from './data/api';
+
+const LinkProgramEnrollmentsWrapper = (props) => (
+
+
+
+
+
+);
+
+describe('Link Program Enrollments', () => {
+ let wrapper;
+ let apiMock;
+ const data = {
+ programID: '8bee627e-d85e-4a76-be41-d58921da666e',
+ usernamePairText: 'testuser,verified',
+ };
+
+ beforeEach(() => {
+ if (apiMock) {
+ apiMock.mockReset();
+ }
+ });
+
+ it('default page render', async () => {
+ wrapper = mount();
+
+ const programIdInput = wrapper.find("input[name='programUUID']");
+ const usernamePairInput = wrapper.find("textarea[name='usernamePairText']");
+ const submitButton = wrapper.find('button.btn-primary');
+
+ expect(programIdInput.prop('defaultValue')).toEqual(undefined);
+ expect(usernamePairInput.prop('defaultValue')).toEqual(undefined);
+ expect(submitButton.text()).toEqual('Submit');
+ });
+
+ it('valid search value', async () => {
+ apiMock = jest
+ .spyOn(api, 'default')
+ .mockImplementationOnce(() => Promise.resolve(lpeSuccessResponse));
+ history.push = jest.fn();
+
+ wrapper = mount();
+
+ wrapper.find('input[name="programUUID"]').instance().value = data.programID;
+ wrapper.find('textarea[name="usernamePairText"]').instance().value = data.usernamePairText;
+ wrapper.find('button.btn-primary').simulate('click');
+
+ await waitForComponentToPaint(wrapper);
+ expect(apiMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('api call made on each click', async () => {
+ apiMock = jest
+ .spyOn(api, 'default')
+ .mockImplementation(() => Promise.resolve(lpeSuccessResponse));
+ history.push = jest.fn();
+
+ wrapper = mount();
+
+ wrapper.find('input[name="programUUID"]').instance().value = data.programID;
+ wrapper.find('textarea[name="usernamePairText"]').instance().value = data.usernamePairText;
+ wrapper.find('button.btn-primary').simulate('click');
+
+ await waitForComponentToPaint(wrapper);
+ expect(apiMock).toHaveBeenCalledTimes(1);
+
+ wrapper.find('button.btn-primary').simulate('click');
+ await waitForComponentToPaint(wrapper);
+ expect(apiMock).toHaveBeenCalledTimes(2);
+ });
+
+ it('empty search value yields error response', async () => {
+ apiMock = jest
+ .spyOn(api, 'default')
+ .mockImplementationOnce(() => Promise.resolve(lpeErrorResponseEmptyValues));
+ history.replace = jest.fn();
+ wrapper = mount();
+
+ wrapper.find('input[name="programUUID"]').instance().value = '';
+ wrapper.find('textarea[name="usernamePairText"]').instance().value = '';
+ wrapper.find('button.btn-primary').simulate('click');
+
+ await waitForComponentToPaint(wrapper);
+ expect(apiMock).toHaveBeenCalledTimes(1);
+ expect(wrapper.find('.error-message')).toHaveLength(1);
+ expect(wrapper.find('.success-message')).toHaveLength(0);
+ });
+
+ it('Invalid Program UUID value', async () => {
+ apiMock = jest
+ .spyOn(api, 'default')
+ .mockImplementationOnce(() => Promise.resolve(lpeErrorResponseInvalidUUID));
+ history.replace = jest.fn();
+ wrapper = mount();
+
+ wrapper.find('input[name="programUUID"]').instance().value = data.programID;
+ wrapper.find('textarea[name="usernamePairText"]').instance().value = data.usernamePairText;
+ wrapper.find('button.btn-primary').simulate('click');
+
+ await waitForComponentToPaint(wrapper);
+ expect(apiMock).toHaveBeenCalledTimes(1);
+ expect(wrapper.find('.error-message')).toHaveLength(1);
+ expect(wrapper.find('.success-message')).toHaveLength(0);
+ });
+
+ it('Invalid Username value', async () => {
+ apiMock = jest
+ .spyOn(api, 'default')
+ .mockImplementationOnce(() => Promise.resolve(lpeErrorResponseInvalidUsername));
+ history.replace = jest.fn();
+ wrapper = mount();
+
+ wrapper.find('input[name="programUUID"]').instance().value = data.programID;
+ wrapper.find('textarea[name="usernamePairText"]').instance().value = data.usernamePairText;
+ wrapper.find('button.btn-primary').simulate('click');
+
+ await waitForComponentToPaint(wrapper);
+ expect(apiMock).toHaveBeenCalledTimes(1);
+ expect(wrapper.find('.error-message')).toHaveLength(1);
+ expect(wrapper.find('.success-message')).toHaveLength(0);
+ });
+
+ it('Invalid External User Key value', async () => {
+ apiMock = jest
+ .spyOn(api, 'default')
+ .mockImplementationOnce(() => Promise.resolve(lpeErrorResponseInvalidExternalKey));
+ history.replace = jest.fn();
+ wrapper = mount();
+
+ wrapper.find('input[name="programUUID"]').instance().value = data.programID;
+ wrapper.find('textarea[name="usernamePairText"]').instance().value = data.usernamePairText;
+ wrapper.find('button.btn-primary').simulate('click');
+
+ await waitForComponentToPaint(wrapper);
+ expect(apiMock).toHaveBeenCalledTimes(1);
+ expect(wrapper.find('.error-message')).toHaveLength(1);
+ expect(wrapper.find('.success-message')).toHaveLength(0);
+ });
+
+ it('Program Already Linked', async () => {
+ apiMock = jest
+ .spyOn(api, 'default')
+ .mockImplementationOnce(() => Promise.resolve(lpeErrorResponseAlreadyLinked));
+ history.replace = jest.fn();
+ wrapper = mount();
+
+ wrapper.find('input[name="programUUID"]').instance().value = data.programID;
+ wrapper.find('textarea[name="usernamePairText"]').instance().value = data.usernamePairText;
+ wrapper.find('button.btn-primary').simulate('click');
+
+ await waitForComponentToPaint(wrapper);
+ expect(apiMock).toHaveBeenCalledTimes(1);
+ expect(wrapper.find('.error-message')).toHaveLength(1);
+ expect(wrapper.find('.success-message')).toHaveLength(0);
+ });
+});
diff --git a/src/ProgramEnrollments/LinkProgramEnrollmentsTable.jsx b/src/ProgramEnrollments/LinkProgramEnrollmentsTable.jsx
new file mode 100644
index 000000000..22c4104a1
--- /dev/null
+++ b/src/ProgramEnrollments/LinkProgramEnrollmentsTable.jsx
@@ -0,0 +1,69 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import TableV2 from '../components/Table';
+import { extractMessageTuple } from '../utils/index';
+
+export default function LinkProgramEnrollmentsTable({
+ successMessage,
+ errorMessage,
+}) {
+ return (
+ <>
+ {successMessage && successMessage.length > 0 && (
+
+
Successes
+
{
+ const pair = extractMessageTuple(text);
+ return {
+ external_user_key: pair[0],
+ lms_username: pair[1],
+ message: 'Linkage Successfully Created',
+ };
+ })}
+ styleName="custom-table success-table"
+ />
+
+ )}
+ {errorMessage && errorMessage.length > 0 && (
+
+
Errors
+
({ message: text }))}
+ styleName="custom-table error-table"
+ />
+
+ )}
+ >
+ );
+}
+
+LinkProgramEnrollmentsTable.propTypes = {
+ successMessage: PropTypes.arrayOf(PropTypes.string),
+ errorMessage: PropTypes.arrayOf(PropTypes.string),
+};
+
+LinkProgramEnrollmentsTable.defaultProps = {
+ successMessage: [],
+ errorMessage: [],
+};
diff --git a/src/ProgramEnrollments/LinkProgramEnrollmentsTable.test.jsx b/src/ProgramEnrollments/LinkProgramEnrollmentsTable.test.jsx
new file mode 100644
index 000000000..22a659fdf
--- /dev/null
+++ b/src/ProgramEnrollments/LinkProgramEnrollmentsTable.test.jsx
@@ -0,0 +1,122 @@
+import { mount } from 'enzyme';
+import React from 'react';
+import LinkProgramEnrollmentsTable from './LinkProgramEnrollmentsTable';
+import {
+ lpeSuccessResponse,
+ lpeErrorResponseInvalidUUID,
+ lpeErrorResponseEmptyValues,
+ lpeErrorResponseInvalidUsername,
+ lpeErrorResponseInvalidExternalKey,
+ lpeErrorResponseAlreadyLinked,
+} from './data/test/linkProgramEnrollment';
+
+describe('Link Program Enrollment Tables component', () => {
+ let wrapper;
+
+ afterEach(() => {
+ wrapper.unmount();
+ });
+
+ describe('Success Table', () => {
+ it('Success Table exists', () => {
+ wrapper = mount(
+ ,
+ );
+
+ const header = wrapper.find('.success-message h4');
+ const dataTable = wrapper.find('table.success-table tr');
+ const headingRow = dataTable.at(0);
+ const dataRow = dataTable.at(1);
+
+ expect(header.text()).toEqual('Successes');
+
+ expect(headingRow.find('th').at(0).text()).toEqual('External User Key');
+ expect(headingRow.find('th').at(1).text()).toEqual('LMS Username');
+ expect(headingRow.find('th').at(2).text()).toEqual('Message');
+
+ expect(dataRow.find('td').at(0).text()).toEqual('testuser');
+ expect(dataRow.find('td').at(1).text()).toEqual('verified');
+ expect(dataRow.find('td').at(2).text()).toEqual('Linkage Successfully Created');
+ });
+ });
+
+ describe('Error Table', () => {
+ it('Error when empty value', () => {
+ wrapper = mount(
+ ,
+ );
+ const header = wrapper.find('.error-message h4');
+ const dataTable = wrapper.find('table.error-table tr');
+ const headingRow = dataTable.at(0);
+ const dataRow = dataTable.at(1);
+
+ expect(header.text()).toEqual('Errors');
+ expect(headingRow.find('th').at(0).text()).toEqual('Error Messages');
+ expect(dataRow.find('td').at(0).text()).toEqual("You must provide both a program uuid and a series of lines with the format 'external_user_key,lms_username'.");
+ });
+ it('Error when Invalid Program ID', () => {
+ wrapper = mount(
+ ,
+ );
+ const header = wrapper.find('.error-message h4');
+ const dataTable = wrapper.find('table.error-table tr');
+ const headingRow = dataTable.at(0);
+ const dataRow = dataTable.at(1);
+
+ expect(header.text()).toEqual('Errors');
+ expect(headingRow.find('th').at(0).text()).toEqual('Error Messages');
+ expect(dataRow.find('td').at(0).text()).toEqual("Supplied program UUID '8bee627e-d85e-4a76-be41-d58921da666e' is not a valid UUID.");
+ });
+ it('Error when Invalid Username', () => {
+ wrapper = mount(
+ ,
+ );
+ const header = wrapper.find('.error-message h4');
+ const dataTable = wrapper.find('table.error-table tr');
+ const headingRow = dataTable.at(0);
+ const dataRow = dataTable.at(1);
+
+ expect(header.text()).toEqual('Errors');
+ expect(headingRow.find('th').at(0).text()).toEqual('Error Messages');
+ expect(dataRow.find('td').at(0).text()).toEqual('No user found with username verified');
+ });
+ it('Error when Invalid External Key', () => {
+ wrapper = mount(
+ ,
+ );
+ const header = wrapper.find('.error-message h4');
+ const dataTable = wrapper.find('table.error-table tr');
+ const headingRow = dataTable.at(0);
+ const dataRow = dataTable.at(1);
+
+ expect(header.text()).toEqual('Errors');
+ expect(headingRow.find('th').at(0).text()).toEqual('Error Messages');
+ expect(dataRow.find('td').at(0).text()).toEqual('No program enrollment found for program uuid=8bee627e-d85e-4a76-be41-d58921da666e and external student key=testuser');
+ });
+ it('Error when Already Linked ID', () => {
+ wrapper = mount(
+ ,
+ );
+ const header = wrapper.find('.error-message h4');
+ const dataTable = wrapper.find('table.error-table tr');
+ const headingRow = dataTable.at(0);
+ const dataRow = dataTable.at(1);
+
+ expect(header.text()).toEqual('Errors');
+ expect(headingRow.find('th').at(0).text()).toEqual('Error Messages');
+ expect(dataRow.find('td').at(0).text()).toEqual('Program enrollment with external_student_key=testuser1 is already linked to target account username=verified');
+ });
+ });
+});
diff --git a/src/ProgramEnrollments/ProgramEnrollmentsIndexPage.jsx b/src/ProgramEnrollments/ProgramEnrollmentsIndexPage.jsx
new file mode 100644
index 000000000..16dcb2884
--- /dev/null
+++ b/src/ProgramEnrollments/ProgramEnrollmentsIndexPage.jsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import LinkProgramEnrollments from './LinkProgramEnrollments';
+
+export default function ProgramEnrollmentsIndexPage({ location }) {
+ return (
+
+
+
+
+
+ );
+}
+
+ProgramEnrollmentsIndexPage.propTypes = {
+ location: PropTypes.shape({
+ pathname: PropTypes.string,
+ search: PropTypes.string,
+ }).isRequired,
+};
diff --git a/src/ProgramEnrollments/data/api.js b/src/ProgramEnrollments/data/api.js
new file mode 100644
index 000000000..b1f5d5809
--- /dev/null
+++ b/src/ProgramEnrollments/data/api.js
@@ -0,0 +1,30 @@
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+import { getConfig } from '@edx/frontend-platform';
+
+const { LMS_BASE_URL } = getConfig();
+
+export default async function getLinkProgramEnrollmentDetails({
+ programID,
+ usernamePairText,
+}) {
+ const apiUrl = `${LMS_BASE_URL}/support/link_program_enrollments_details/`;
+ const formData = new FormData();
+ formData.append('program_uuid', programID);
+ formData.append('username_pair_text', usernamePairText);
+ try {
+ const { data } = await getAuthenticatedHttpClient().post(apiUrl, formData);
+ return data;
+ } catch (error) {
+ return {
+ error: [
+ {
+ code: null,
+ dismissible: true,
+ text: `Unexpected error while linking program enrollments for Program ${programID}`,
+ type: 'error',
+ topic: 'linkProgramEnrollment',
+ },
+ ],
+ };
+ }
+}
diff --git a/src/ProgramEnrollments/data/api.test.js b/src/ProgramEnrollments/data/api.test.js
new file mode 100644
index 000000000..836a39973
--- /dev/null
+++ b/src/ProgramEnrollments/data/api.test.js
@@ -0,0 +1,62 @@
+import MockAdapter from 'axios-mock-adapter';
+import { getConfig } from '@edx/frontend-platform';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+import { lpeSuccessResponse } from './test/linkProgramEnrollment';
+import * as api from './api';
+
+describe('Link Program Enrollments API', () => {
+ let mockAdapter;
+
+ const data = {
+ programID: '8bee627e-d85e-4a76-be41-d58921da666e',
+ usernamePairText: 'testuser,verified',
+ };
+ const lpeDetailsApiUrl = `${
+ getConfig().LMS_BASE_URL
+ }/support/link_program_enrollments_details/`;
+
+ const throwError = (errorCode, dataString) => {
+ const error = new Error();
+ error.customAttributes = {
+ httpErrorStatus: errorCode,
+ httpErrorResponseData: JSON.stringify(dataString),
+ };
+ throw error;
+ };
+
+ beforeEach(() => {
+ mockAdapter = new MockAdapter(getAuthenticatedHttpClient(), {
+ onNoMatch: 'throwException',
+ });
+ });
+
+ afterEach(() => {
+ mockAdapter.reset();
+ });
+
+ it('Successful api fetch', async () => {
+ mockAdapter.onPost(lpeDetailsApiUrl).reply(200, lpeSuccessResponse);
+
+ const response = await api.default(data);
+ expect(response).toEqual(lpeSuccessResponse);
+ });
+
+ it('Unsuccessful api fetch', async () => {
+ const expectedErrors = [
+ {
+ code: null,
+ dismissible: true,
+ text: 'Unexpected error while linking program enrollments for Program 8bee627e-d85e-4a76-be41-d58921da666e',
+ type: 'error',
+ topic: 'linkProgramEnrollment',
+ },
+ ];
+
+ mockAdapter
+ .onPost(lpeDetailsApiUrl)
+ .reply(() => throwError(500, 'Server Error'));
+
+ const response = await api.default(data);
+ expect(response.error).toEqual(expectedErrors);
+ });
+});
diff --git a/src/ProgramEnrollments/data/test/linkProgramEnrollment.js b/src/ProgramEnrollments/data/test/linkProgramEnrollment.js
new file mode 100644
index 000000000..0616ed3a2
--- /dev/null
+++ b/src/ProgramEnrollments/data/test/linkProgramEnrollment.js
@@ -0,0 +1,49 @@
+export const lpeSuccessResponse = {
+ successes: ["('testuser', 'verified')"],
+ errors: [],
+ program_uuid: '8bee627e-d85e-4a76-be41-d58921da666e',
+ username_pair_text: 'testuser,verified',
+};
+
+export const lpeErrorResponseAlreadyLinked = {
+ successes: [],
+ errors: [
+ 'Program enrollment with external_student_key=testuser1 is already linked to target account username=verified',
+ ],
+ program_uuid: '8bee627e-d85e-4a76-be41-d58921da666e',
+ username_pair_text: 'testuser,verified',
+};
+
+export const lpeErrorResponseInvalidUUID = {
+ successes: [],
+ errors: [
+ "Supplied program UUID '8bee627e-d85e-4a76-be41-d58921da666e' is not a valid UUID.",
+ ],
+ program_uuid: '8bee627e-d85e-4a76-be41-d58921da666e',
+ username_pair_text: 'testuser,verified',
+};
+
+export const lpeErrorResponseInvalidUsername = {
+ successes: [],
+ errors: ['No user found with username verified'],
+ program_uuid: '8bee627e-d85e-4a76-be41-d58921da666e',
+ username_pair_text: 'testuser,verified',
+};
+
+export const lpeErrorResponseInvalidExternalKey = {
+ successes: [],
+ errors: [
+ 'No program enrollment found for program uuid=8bee627e-d85e-4a76-be41-d58921da666e and external student key=testuser',
+ ],
+ program_uuid: '8bee627e-d85e-4a76-be41-d58921da666e',
+ username_pair_text: 'testuser,verified',
+};
+
+export const lpeErrorResponseEmptyValues = {
+ successes: [],
+ errors: [
+ "You must provide both a program uuid and a series of lines with the format 'external_user_key,lms_username'.",
+ ],
+ program_uuid: '',
+ username_pair_text: '',
+};
diff --git a/src/SupportToolsTab/SupportToolsTab.jsx b/src/SupportToolsTab/SupportToolsTab.jsx
index 5aec87b4a..2b1e4556e 100644
--- a/src/SupportToolsTab/SupportToolsTab.jsx
+++ b/src/SupportToolsTab/SupportToolsTab.jsx
@@ -3,14 +3,31 @@ import PropTypes from 'prop-types';
import { Tabs, Tab } from '@edx/paragon';
import { history } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
-import { FEATURE_BASED_ENROLLMENT_TAB, LEARNER_INFO_TAB, TAB_PATH_MAP } from './constants';
+import {
+ FEATURE_BASED_ENROLLMENT_TAB,
+ LEARNER_INFO_TAB,
+ PROGRAM_ENROLLMENT_TAB,
+ TAB_PATH_MAP,
+} from './constants';
import UserPage from '../users/v2/UserPage';
import FeatureBasedEnrollmentIndexPage from '../FeatureBasedEnrollments/FeatureBasedEnrollmentIndexPage';
+import ProgramEnrollmentsIndexPage from '../ProgramEnrollments/ProgramEnrollmentsIndexPage';
export default function SupportToolsTab({ location }) {
let tabKey = LEARNER_INFO_TAB;
- if (location.pathname.indexOf(TAB_PATH_MAP[FEATURE_BASED_ENROLLMENT_TAB]) !== -1) {
- tabKey = FEATURE_BASED_ENROLLMENT_TAB;
+ switch (location.pathname) {
+ case TAB_PATH_MAP[FEATURE_BASED_ENROLLMENT_TAB]:
+ tabKey = FEATURE_BASED_ENROLLMENT_TAB;
+ break;
+ case TAB_PATH_MAP[LEARNER_INFO_TAB]:
+ tabKey = LEARNER_INFO_TAB;
+ break;
+ case TAB_PATH_MAP[PROGRAM_ENROLLMENT_TAB]:
+ tabKey = PROGRAM_ENROLLMENT_TAB;
+ break;
+ default:
+ tabKey = LEARNER_INFO_TAB;
+ break;
}
return (
@@ -40,11 +57,17 @@ export default function SupportToolsTab({ location }) {
-
+
-
+
+
+
+
diff --git a/src/SupportToolsTab/SupportToolsTab.test.jsx b/src/SupportToolsTab/SupportToolsTab.test.jsx
index 455d1fb9e..5c805324e 100644
--- a/src/SupportToolsTab/SupportToolsTab.test.jsx
+++ b/src/SupportToolsTab/SupportToolsTab.test.jsx
@@ -32,9 +32,10 @@ describe('Support Tools Main tab', () => {
wrapper = mount();
const tabs = wrapper.find('nav.nav-tabs a');
- expect(tabs.length).toEqual(2);
+ expect(tabs.length).toEqual(3);
expect(tabs.at(0).text()).toEqual('Learner Information');
expect(tabs.at(1).text()).toEqual('Feature Based Enrollment');
+ expect(tabs.at(2).text()).toEqual('Program Enrollments');
expect(wrapper.find('h2').text()).toEqual('Support Tools');
expect(wrapper.find('p').text()).toEqual(
@@ -54,6 +55,7 @@ describe('Support Tools Main tab', () => {
expect(history.replace).toHaveBeenCalledWith(TAB_PATH_MAP['feature-based-enrollment']);
expect(tabs.at(0).html()).not.toEqual(expect.stringContaining('active'));
expect(tabs.at(1).html()).toEqual(expect.stringContaining('active'));
+ expect(tabs.at(2).html()).not.toEqual(expect.stringContaining('active'));
expect(fbeTab.html()).toEqual(expect.stringContaining('active'));
expect(fbeTab.find('label').text()).toEqual('Course ID');
@@ -63,13 +65,21 @@ describe('Support Tools Main tab', () => {
expect(history.replace).toHaveBeenCalledWith(TAB_PATH_MAP['learner-information']);
expect(tabs.at(0).html()).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(learnerTab.html()).toEqual(expect.stringContaining('active'));
expect(learnerTab.find('label').text()).toEqual('Username, Email or LMS User ID');
+ tabs.at(2).simulate('click');
+ tabs = wrapper.find('nav.nav-tabs a');
+ expect(history.replace).toHaveBeenCalledWith(TAB_PATH_MAP['program-enrollment']);
+ 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()).toEqual(expect.stringContaining('active'));
+
history.replace.mockReset();
});
- it('default tab changes based on pathname', () => {
+ it('default tab changes based on feature-based-enrollment pathname', () => {
location = { pathname: TAB_PATH_MAP['feature-based-enrollment'], search: '' };
wrapper = mount();
@@ -77,5 +87,28 @@ describe('Support Tools Main tab', () => {
expect(tabs.at(0).html()).not.toEqual(expect.stringContaining('active'));
expect(tabs.at(1).html()).toEqual(expect.stringContaining('active'));
+ expect(tabs.at(2).html()).not.toEqual(expect.stringContaining('active'));
+ });
+
+ it('default tab changes based on learner-information pathname', () => {
+ location = { pathname: TAB_PATH_MAP['learner-information'], search: '' };
+
+ wrapper = mount();
+ const tabs = wrapper.find('nav.nav-tabs a');
+
+ expect(tabs.at(0).html()).toEqual(expect.stringContaining('active'));
+ expect(tabs.at(1).html()).not.toEqual(expect.stringContaining('active'));
+ expect(tabs.at(2).html()).not.toEqual(expect.stringContaining('active'));
+ });
+
+ it('default tab changes based on program-enrollment pathname', () => {
+ location = { pathname: TAB_PATH_MAP['program-enrollment'], search: '' };
+
+ wrapper = mount();
+ const 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()).toEqual(expect.stringContaining('active'));
});
});
diff --git a/src/SupportToolsTab/constants.js b/src/SupportToolsTab/constants.js
index 5009ccc4e..58ec3ad54 100644
--- a/src/SupportToolsTab/constants.js
+++ b/src/SupportToolsTab/constants.js
@@ -1,7 +1,9 @@
export const LEARNER_INFO_TAB = 'learner-information';
export const FEATURE_BASED_ENROLLMENT_TAB = 'feature-based-enrollment';
+export const PROGRAM_ENROLLMENT_TAB = 'program-enrollment';
export const TAB_PATH_MAP = {
[LEARNER_INFO_TAB]: '/v2/learner_information',
[FEATURE_BASED_ENROLLMENT_TAB]: '/v2/feature_based_enrollments',
+ [PROGRAM_ENROLLMENT_TAB]: '/v2/program_enrollment',
};
diff --git a/src/index.jsx b/src/index.jsx
index 18bb94f57..68bae156d 100755
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -18,6 +18,7 @@ import SupportToolsTab from './SupportToolsTab/SupportToolsTab';
import UserPageV2 from './users/v2/UserPage';
import FBEIndexPage from './FeatureBasedEnrollments/FeatureBasedEnrollmentIndexPage';
import UserMessagesProvider from './userMessages/UserMessagesProvider';
+import ProgramEnrollmentsIndexPage from './ProgramEnrollments/ProgramEnrollmentsIndexPage';
import './index.scss';
@@ -41,6 +42,7 @@ subscribe(APP_READY, () => {
+
,
diff --git a/src/overrides.scss b/src/overrides.scss
index 7a5bcaafc..01aeec56a 100644
--- a/src/overrides.scss
+++ b/src/overrides.scss
@@ -153,3 +153,10 @@ $darkCyan: #00262b;
font-weight: normal;
}
}
+
+.link-program-enrollments {
+ border: none;
+ border-radius: 0;
+ border-right: 1px solid lightgray;
+ padding-left: 0;
+}
diff --git a/src/utils/index.js b/src/utils/index.js
index 8541d33ff..88d7064c1 100644
--- a/src/utils/index.js
+++ b/src/utils/index.js
@@ -55,3 +55,27 @@ export function sortedCompareDates(x, y, asc) {
const b = new Date(y);
return asc ? a - b : b - a;
}
+
+export function extractMessageTuple(message) {
+ /*
+ Support Tools API sends an array of success tuples for
+ Link Program Enrollments of format
+ [
+ '('external_user_key', 'lms_username')',
+ '('external_user_key', 'lms_username')',
+ ]
+ that are identified as strings by the JS.
+ This function removes the overhead characters and
+ splits the string into an array of format
+ [
+ [external_user_key,lms_username],
+ [external_user_key,lms_username],
+ ]
+ */
+ return message
+ .replace('(', '')
+ .replace(')', '')
+ .replace(/'/g, '')
+ .replace(/ /g, '')
+ .split(',');
+}
diff --git a/src/utils/index.test.js b/src/utils/index.test.js
index 3c24e070c..e375884dc 100644
--- a/src/utils/index.test.js
+++ b/src/utils/index.test.js
@@ -8,6 +8,7 @@ import {
titleCase,
sortedCompareDates,
isValidCourseID,
+ extractMessageTuple,
} from './index';
describe('Test Utils', () => {
@@ -172,4 +173,12 @@ describe('Test Utils', () => {
)).toEqual(dscSortedDates);
});
});
+
+ describe('Extract Message Tuples', () => {
+ const message = "('external_user_key', 'lms_username')";
+ expect(extractMessageTuple(message)).toEqual([
+ 'external_user_key',
+ 'lms_username',
+ ]);
+ });
});