diff --git a/src/components/EditCoursePage/EditCourseForm.jsx b/src/components/EditCoursePage/EditCourseForm.jsx
index 4b982a10a..5e0279a05 100644
--- a/src/components/EditCoursePage/EditCourseForm.jsx
+++ b/src/components/EditCoursePage/EditCourseForm.jsx
@@ -28,12 +28,14 @@ import Collapsible from '../Collapsible';
import PriceList from '../PriceList';
import {
- PUBLISHED, REVIEWED, EXECUTIVE_EDUCATION_SLUG, COURSE_URL_SLUG_VALIDATION_MESSAGE,
+ PUBLISHED, REVIEWED, EXECUTIVE_EDUCATION_SLUG, COURSE_URL_SLUG_VALIDATION_MESSAGE, REVIEW_BY_INTERNAL,
} from '../../data/constants';
import {
titleHelp, typeHelp, getUrlSlugHelp, productSourceHelp,
} from '../../helpText';
-import { handleCourseEditFail, editCourseValidate, courseTagValidate } from '../../utils/validation';
+import {
+ handleCourseEditFail, editCourseValidate, courseTagValidate, emailValidate,
+} from '../../utils/validation';
import {
formatCollaboratorOptions, getDateWithSlashes, getFormattedUTCTimeString, getOptionsData, isPristine,
parseCourseTypeOptions, parseOptions, loadOptions, courseTagObjectsToSelectOptions, getCourseUrlSlugPattern,
@@ -370,6 +372,30 @@ export class BaseEditCourseForm extends React.Component {
disabled={disabled || !administrator}
optional
/>
+ {administrator && (
+
+ A list of email addresses that will receive
+ notifications when the course run of the course is published or reviewed.
+
+ )}
+ optional
+ />
+ )}
+ isMulti
+ disabled={!(courseInfo?.data?.course_run_statuses?.includes(REVIEW_BY_INTERNAL) && administrator)}
+ optional
+ isCreatable
+ createOptionValidator={emailValidate}
+ />
+ )}
{parsedProductSource}
@@ -1091,6 +1117,7 @@ export class BaseEditCourseForm extends React.Component {
disabled={disabled}
optional
/>
+
{administrator && (
<>
diff --git a/src/components/EditCoursePage/EditCourseForm.test.jsx b/src/components/EditCoursePage/EditCourseForm.test.jsx
index 872e2660f..b6747cd96 100644
--- a/src/components/EditCoursePage/EditCourseForm.test.jsx
+++ b/src/components/EditCoursePage/EditCourseForm.test.jsx
@@ -40,6 +40,7 @@ describe('BaseEditCourseForm', () => {
skill_names: [],
organization_logo_override: 'http://image.src.small',
organization_short_code_override: 'test short code',
+ watchers: ['test@test.com'],
location_restriction: {
restriction_type: 'allowlist',
countries: ['AF', 'AX'],
diff --git a/src/components/EditCoursePage/EditCoursePage.test.jsx b/src/components/EditCoursePage/EditCoursePage.test.jsx
index 6ba863cc0..9903bf7cc 100644
--- a/src/components/EditCoursePage/EditCoursePage.test.jsx
+++ b/src/components/EditCoursePage/EditCoursePage.test.jsx
@@ -26,6 +26,7 @@ describe('EditCoursePage', () => {
const defaultPrice = '77';
const defaultEnd = '2019-08-14T00:00:00Z';
const defaultUpgradeDeadlineOverride = '2019-09-14T00:00:00Z';
+ const watchers = ['test@test.com'];
const courseInfo = {
data: {
@@ -161,6 +162,8 @@ describe('EditCoursePage', () => {
skill_names: [],
organization_logo_override_url: 'http://image.src.small',
organization_short_code_override: 'test short code',
+ watchers,
+ watchers_list: watchers?.length ? watchers.map(w => ({ label: w, value: w })) : null,
location_restriction: {
restriction_type: 'allowlist',
countries: [
@@ -384,6 +387,8 @@ describe('EditCoursePage', () => {
},
organization_logo_override_url: 'http://image.src.small',
organization_short_code_override: 'test short code',
+ watchers,
+ watchers_list: watchers?.length ? watchers.map(w => ({ label: w, value: w })) : null,
outcome: '
Stuff
',
prerequisites_raw: '',
prices: {
@@ -426,6 +431,7 @@ describe('EditCoursePage', () => {
},
organization_logo_override: 'http://image.src.small',
organization_short_code_override: 'test short code',
+ watchers,
outcome: '
Stuff
',
prerequisites_raw: '',
prices: {
@@ -968,6 +974,7 @@ describe('EditCoursePage', () => {
},
organization_logo_override: 'http://image.src.small',
organization_short_code_override: 'test short code',
+ watchers,
outcome: '
Stuff
',
prerequisites_raw: '',
prices: {
diff --git a/src/components/EditCoursePage/__snapshots__/EditCourseForm.test.jsx.snap b/src/components/EditCoursePage/__snapshots__/EditCourseForm.test.jsx.snap
index 3c545dfab..9337ca4cc 100644
--- a/src/components/EditCoursePage/__snapshots__/EditCourseForm.test.jsx.snap
+++ b/src/components/EditCoursePage/__snapshots__/EditCourseForm.test.jsx.snap
@@ -1700,6 +1700,9 @@ exports[`BaseEditCourseForm override slug format when IS_NEW_SLUG_FORMAT_ENABLED
"type": "8a8f30e1-23ce-4ed3-a361-1325c656b67b",
"uuid": "11111111-1111-1111-1111-111111111111",
"videoSrc": "https://www.video.src/watch?v=fdsafd",
+ "watchers": Array [
+ "test@test.com",
+ ],
}
}
editable={false}
@@ -3521,6 +3524,9 @@ exports[`BaseEditCourseForm renders correctly when submitting for review 1`] = `
"type": "8a8f30e1-23ce-4ed3-a361-1325c656b67b",
"uuid": "11111111-1111-1111-1111-111111111111",
"videoSrc": "https://www.video.src/watch?v=fdsafd",
+ "watchers": Array [
+ "test@test.com",
+ ],
}
}
editable={false}
@@ -5342,6 +5348,9 @@ exports[`BaseEditCourseForm renders html correctly while submitting 1`] = `
"type": "8a8f30e1-23ce-4ed3-a361-1325c656b67b",
"uuid": "11111111-1111-1111-1111-111111111111",
"videoSrc": "https://www.video.src/watch?v=fdsafd",
+ "watchers": Array [
+ "test@test.com",
+ ],
}
}
editable={false}
@@ -5396,6 +5405,9 @@ exports[`BaseEditCourseForm renders html correctly while submitting 1`] = `
"type": "8a8f30e1-23ce-4ed3-a361-1325c656b67b",
"uuid": "11111111-1111-1111-1111-111111111111",
"videoSrc": "https://www.video.src/watch?v=fdsafd",
+ "watchers": Array [
+ "test@test.com",
+ ],
}
}
isSubmittingForReview={false}
@@ -5671,6 +5683,29 @@ exports[`BaseEditCourseForm renders html correctly with administrator being true
pattern="^[a-z0-9_]+(?:-[a-z0-9_]+)*$"
type="text"
/>
+
+ A list of email addresses that will receive notifications when the course run of the course is published or reviewed.
+
+ }
+ id="watchers.label"
+ optional={true}
+ text="Watchers"
+ />
+ }
+ name="watchers_list"
+ optional={true}
+ />
watcher.value) : [],
outcome: courseData.outcome,
prerequisites_raw: courseData.prerequisites_raw,
...priceData,
@@ -618,6 +619,7 @@ class EditCoursePage extends React.Component {
enterprise_subscription_inclusion,
organization_short_code_override,
organization_logo_override_url,
+ watchers,
},
},
} = this.props;
@@ -660,9 +662,12 @@ class EditCoursePage extends React.Component {
enterprise_subscription_inclusion,
organization_short_code_override,
organization_logo_override_url,
+ // Adding watchers to initialValues so that it can be used in the form to show and update state of watchers_list
+ watchers,
location_restriction: this.buildLocationRestriction(),
in_year_value: this.buildInYearValue(),
tags: topics?.length ? topics.map(t => ({ label: t, value: t })) : null,
+ watchers_list: watchers?.length ? watchers.map(w => ({ label: w, value: w })) : null,
};
}
diff --git a/src/data/actions/courseInfo.js b/src/data/actions/courseInfo.js
index 3c28d84a0..d4ec6e450 100644
--- a/src/data/actions/courseInfo.js
+++ b/src/data/actions/courseInfo.js
@@ -112,6 +112,7 @@ function updateFormValuesAfterSave(change, currentFormValues, initialValues) {
tags,
url_slug: urlSlug,
imageSrc: initialImageSrc,
+ watchers: courseWatchers,
course_runs: initialCourseRuns,
in_year_value: {
per_lead_usa: perLeadUSA,
@@ -127,6 +128,7 @@ function updateFormValuesAfterSave(change, currentFormValues, initialValues) {
change('geoLocationLng', geoLocationLng);
change('tags', tags);
change('url_slug', urlSlug);
+ change('watchers', courseWatchers);
change('in_year_value.per_lead_usa', perLeadUSA);
change('in_year_value.per_lead_international', perLeadInternational);
change('in_year_value.per_click_usa', perClickUSA);
diff --git a/src/data/actions/courseInfo.test.js b/src/data/actions/courseInfo.test.js
index 937650867..81a37d7ee 100644
--- a/src/data/actions/courseInfo.test.js
+++ b/src/data/actions/courseInfo.test.js
@@ -196,6 +196,7 @@ describe('courseInfo edit course actions', () => {
geoLocationLng: '45.0000',
geoLocationLat: '50.0000',
url_slug: 'test_slug',
+ watchers: ['test@test.com'],
tags: ['tag1', 'tag2'],
in_year_value: {
per_lead_usa: 10,
@@ -218,12 +219,13 @@ describe('courseInfo edit course actions', () => {
expect(changeMock).toHaveBeenNthCalledWith(3, 'geoLocationLng', '45.0000');
expect(changeMock).toHaveBeenNthCalledWith(4, 'tags', ['tag1', 'tag2']);
expect(changeMock).toHaveBeenNthCalledWith(5, 'url_slug', 'test_slug');
- expect(changeMock).toHaveBeenNthCalledWith(6, 'in_year_value.per_lead_usa', 10);
- expect(changeMock).toHaveBeenNthCalledWith(7, 'in_year_value.per_lead_international', 20);
- expect(changeMock).toHaveBeenNthCalledWith(8, 'in_year_value.per_click_usa', 25);
- expect(changeMock).toHaveBeenNthCalledWith(9, 'in_year_value.per_click_international', 30);
- expect(changeMock).toHaveBeenNthCalledWith(10, 'course_runs[0].status', 'published');
- expect(changeMock).toHaveBeenNthCalledWith(11, 'course_runs[0].transcript_languages', ['en-us']);
+ expect(changeMock).toHaveBeenNthCalledWith(6, 'watchers', ['test@test.com']);
+ expect(changeMock).toHaveBeenNthCalledWith(7, 'in_year_value.per_lead_usa', 10);
+ expect(changeMock).toHaveBeenNthCalledWith(8, 'in_year_value.per_lead_international', 20);
+ expect(changeMock).toHaveBeenNthCalledWith(9, 'in_year_value.per_click_usa', 25);
+ expect(changeMock).toHaveBeenNthCalledWith(10, 'in_year_value.per_click_international', 30);
+ expect(changeMock).toHaveBeenNthCalledWith(11, 'course_runs[0].status', 'published');
+ expect(changeMock).toHaveBeenNthCalledWith(12, 'course_runs[0].transcript_languages', ['en-us']);
});
});
diff --git a/src/utils/validation.js b/src/utils/validation.js
index 339309b55..0e6d53c4b 100644
--- a/src/utils/validation.js
+++ b/src/utils/validation.js
@@ -15,6 +15,13 @@ function courseTagValidate(tagValue, selectValue, options) {
return ![...selectValue, ...options].some(x => x.label.toLowerCase() === tagValue.toLowerCase());
}
+function emailValidate(emailValue, selectValue, options) {
+ // emailValue should only contain alphabets, numbers, hyphen, underscore, dot and @
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/i.test(emailValue)) { return false; }
+ // disallow emails that have already been selected or are present in options(dropdown)
+ return ![...selectValue, ...options].some(x => x.label.toLowerCase() === emailValue.toLowerCase());
+}
+
/**
* Iterates through errors on a form and returns the first field name with an error.
*
@@ -198,4 +205,5 @@ export {
handleStafferOrCreateFormFail,
editCourseValidate,
courseTagValidate,
+ emailValidate,
};
diff --git a/src/utils/validation.test.js b/src/utils/validation.test.js
index 97144c521..7c61649b0 100644
--- a/src/utils/validation.test.js
+++ b/src/utils/validation.test.js
@@ -3,6 +3,7 @@ import {
basicValidate,
getFieldName,
editCourseValidate,
+ emailValidate,
} from './validation';
import { PUBLISHED, UNPUBLISHED } from '../data/constants';
@@ -301,4 +302,45 @@ describe('editCourseValidate', () => {
{ ...expectedError, course_runs: [null] },
);
});
+
+ describe('emailValidate function', () => {
+ const selectValue = [];
+ const options = [{ label: 'john@example.com' }, { label: 'jane@example.com' }];
+
+ it('should return true for a valid email that is not present in selectValue or options', () => {
+ const emailValue = 'newuser@example.com';
+ const isValid = emailValidate(emailValue, selectValue, options);
+ expect(isValid).toBe(true);
+ });
+
+ it('should return false for a valid email that is already present in selectValue', () => {
+ const emailValue = 'john@example.com';
+ const isValid = emailValidate(emailValue, selectValue, options);
+ expect(isValid).toBe(false);
+ });
+
+ it('should return false for a valid email that is already present in options', () => {
+ const emailValue = 'jane@example.com';
+ const isValid = emailValidate(emailValue, selectValue, options);
+ expect(isValid).toBe(false);
+ });
+
+ it('should return false for an invalid email format', () => {
+ const emailValue = 'invalidemailformat';
+ const isValid = emailValidate(emailValue, selectValue, options);
+ expect(isValid).toBe(false);
+ });
+
+ it('should return false for a valid email format with invalid characters', () => {
+ const emailValue = 'user$example.com';
+ const isValid = emailValidate(emailValue, selectValue, options);
+ expect(isValid).toBe(false);
+ });
+
+ it('should return false for an empty email value', () => {
+ const emailValue = '';
+ const isValid = emailValidate(emailValue, selectValue, options);
+ expect(isValid).toBe(false);
+ });
+ });
});