Compare commits

...

3 Commits

Author SHA1 Message Date
Dillon Dumesnil
48ae83084b Adding images 2020-12-01 13:21:05 +00:00
Dillon Dumesnil
6d524f535d Use apostrophe instead of single quote. 2020-11-24 13:46:42 +00:00
Dillon Dumesnil
cb1ea7ad9f AA-303: Celebrate a user's first ever discussion post! 2020-11-20 16:04:15 +00:00
15 changed files with 198 additions and 36 deletions

View File

@@ -18,7 +18,7 @@ const messages = defineMessages({
},
success: {
id: 'learning.enrollment.success',
defaultMessage: "You've successfully enrolled in this course!",
defaultMessage: 'Youve successfully enrolled in this course!',
description: 'A message telling the user that their course enrollment was successful.',
},
});

View File

@@ -3,7 +3,7 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'datesBanner.datesTabInfoBanner.header': {
id: 'datesBanner.datesTabInfoBanner.header',
defaultMessage: "We've built a suggested schedule to help you stay on track. ",
defaultMessage: 'Weve built a suggested schedule to help you stay on track. ',
description: 'Strong text in Dates Tab Info Banner',
},
'datesBanner.datesTabInfoBanner.body': {
@@ -35,8 +35,8 @@ const messages = defineMessages({
},
'datesBanner.upgradeToResetBanner.body': {
id: 'datesBanner.upgradeToResetBanner.body',
defaultMessage: `which means that you are unable to participate in graded assignments. It looks like you missed
some important deadlines based on our suggested schedule. To complete graded assignments as part of this course
defaultMessage: `which means that you are unable to participate in graded assignments. It looks like you missed
some important deadlines based on our suggested schedule. To complete graded assignments as part of this course
and shift the past due assignments into the future, you can upgrade today.`,
description: 'Body in Upgrade To Reset Banner',
},
@@ -52,7 +52,7 @@ const messages = defineMessages({
},
'datesBanner.resetDatesBanner.body': {
id: 'datesBanner.resetDatesBanner.body',
defaultMessage: `To keep yourself on track, you can update this schedule and shift the past due assignments into
defaultMessage: `To keep yourself on track, you can update this schedule and shift the past due assignments into
the future. Dont worry—you wont lose any of the progress youve made when you shift your due dates.`,
description: 'Body in Reset Dates Banner',
},

View File

@@ -10,7 +10,7 @@ import useOfferAlert from '../../alerts/offer-alert';
import Sequence from './sequence';
import { CelebrationModal, shouldCelebrateOnSectionLoad } from './celebration';
import { FirstSectionCelebrationModal, shouldCelebrateOnSectionLoad } from './celebration';
import CourseBreadcrumbs from './CourseBreadcrumbs';
import CourseSock from './course-sock';
import ContentTools from './content-tools';
@@ -78,7 +78,7 @@ function Course({
previousSequenceHandler={previousSequenceHandler}
/>
{celebrationOpen && (
<CelebrationModal
<FirstSectionCelebrationModal
courseId={courseId}
open
/>

View File

@@ -71,9 +71,9 @@ describe('Course', () => {
handleNextSectionCelebration(sequenceId, sequenceId, testData.unitId);
render(<Course {...testData} />, { store: testStore });
const celebrationModal = screen.getByRole('dialog');
expect(celebrationModal).toBeInTheDocument();
expect(getByRole(celebrationModal, 'heading', { name: 'Congratulations!' })).toBeInTheDocument();
const FirstSectionCelebrationModal = screen.getByRole('dialog');
expect(FirstSectionCelebrationModal).toBeInTheDocument();
expect(getByRole(FirstSectionCelebrationModal, 'heading', { name: 'Congratulations!' })).toBeInTheDocument();
});
it('displays upgrade sock', async () => {

View File

@@ -0,0 +1,91 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Modal } from '@edx/paragon';
import { layoutGenerator } from 'react-break';
import { useDispatch } from 'react-redux';
import DiscussionMobile from './assets/FirstDiscussion_mobile.png';
import DiscussionTablet from './assets/FirstDiscussion_desktop_500.png';
import messages from './messages';
import { recordFirstDiscussionCelebration } from './utils';
import { updateModel } from '../../../generic/model-store';
function FirstDiscussionCelebrationModal({
courseId, firstDiscussionUserBucket, intl, open, ...rest
}) {
const dispatch = useDispatch();
const layout = layoutGenerator({
mobile: 0,
tablet: 400,
});
const OnMobile = layout.is('mobile');
const OnAtLeastTablet = layout.isAtLeast('tablet');
useEffect(() => {
if (open) {
recordFirstDiscussionCelebration(courseId);
}
}, [open]);
let normativeDataBodyText;
// Bucket 2 corresponds to showing normative data in the body text
if (firstDiscussionUserBucket === 2) {
if (Math.random() > 0.5) {
normativeDataBodyText = (<p className="mt-3">{intl.formatMessage(messages.discussionBodyText1)}</p>);
} else {
normativeDataBodyText = (<p className="mt-3">{intl.formatMessage(messages.discussionBodyText2)}</p>);
}
}
console.log('normativeDataBodyText:', normativeDataBodyText);
return (
<Modal
body={(
<>
<p>{intl.formatMessage(messages.conversation)}</p>
<OnMobile>
<img src={DiscussionMobile} alt="" className="img-fluid" />
</OnMobile>
<OnAtLeastTablet>
<img src={DiscussionTablet} alt="" className="img-fluid" />
</OnAtLeastTablet>
{normativeDataBodyText}
</>
)}
closeText={intl.formatMessage(messages.keepItUp)}
onClose={() => {
// Update our local copy of course data from LMS
console.log('updating model');
dispatch(updateModel({
modelType: 'courses',
model: {
id: courseId,
celebrations: {
firstDiscussion: false,
},
},
}));
}}
open={open}
title={intl.formatMessage(messages.firstDiscussionPost)}
{...rest}
/>
);
}
FirstDiscussionCelebrationModal.defaultProps = {
open: false,
firstDiscussionUserBucket: 2,
};
FirstDiscussionCelebrationModal.propTypes = {
courseId: PropTypes.string.isRequired,
firstDiscussionUserBucket: PropTypes.number,
intl: intlShape.isRequired,
open: PropTypes.bool,
};
export default injectIntl(FirstDiscussionCelebrationModal);

View File

@@ -10,7 +10,7 @@ import messages from './messages';
import SocialIcons from '../../social-share/SocialIcons';
import { recordFirstSectionCelebration } from './utils';
function CelebrationModal({
function FirstSectionCelebrationModal({
courseId, intl, open, ...rest
}) {
const layout = layoutGenerator({
@@ -58,14 +58,14 @@ function CelebrationModal({
);
}
CelebrationModal.defaultProps = {
FirstSectionCelebrationModal.defaultProps = {
open: false,
};
CelebrationModal.propTypes = {
FirstSectionCelebrationModal.propTypes = {
courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
open: PropTypes.bool,
};
export default injectIntl(CelebrationModal);
export default injectIntl(FirstSectionCelebrationModal);

Binary file not shown.

After

Width:  |  Height:  |  Size: 961 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 B

View File

@@ -4,9 +4,7 @@ import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
// Does not block on answer
export function postFirstSectionCelebrationComplete(courseId) {
export function postCelebrationComplete(courseId, payload) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/courseware/celebration/${courseId}`);
getAuthenticatedHttpClient().post(url.href, {
first_section: false,
});
getAuthenticatedHttpClient().post(url.href, payload);
}

View File

@@ -1,2 +1,7 @@
export { default as CelebrationModal } from './CelebrationModal';
export { handleNextSectionCelebration, shouldCelebrateOnSectionLoad } from './utils';
export { default as FirstDiscussionCelebrationModal } from './FirstDiscussionCelebrationModal';
export { default as FirstSectionCelebrationModal } from './FirstSectionCelebrationModal';
export {
handleNextSectionCelebration,
shouldCelebrateOnSectionLoad,
shouldCelebrateOnDiscussionPost,
} from './utils';

View File

@@ -9,20 +9,41 @@ const messages = defineMessages({
id: 'learning.celebration.congrats',
defaultMessage: 'Congratulations!',
},
conversation: {
id: 'learning.celebration.conversation',
defaultMessage: 'Nice job being part of the conversation.',
},
discussionBodyText1: {
id: 'learning.celebration.discussionBodyText1',
defaultMessage: 'Learners who participate in discussions are 10x more likely to complete their course than those who dont.',
},
discussionBodyText2: {
id: 'learning.celebration.discussionBodyText2',
defaultMessage: 'Learners who participate in discussions complete 3x as much course content on average vs. those who dont.',
},
earned: {
id: 'learning.celebration.earned',
defaultMessage: 'You earned it!',
},
emailSubject: {
id: 'learning.celebration.emailSubject',
defaultMessage: "I'm on my way to completing {title} online with {platform}!",
defaultMessage: 'Im on my way to completing {title} online with {platform}!',
description: 'Subject when sharing course progress via email',
},
firstDiscussionPost: {
id: 'learning.celebration.firstDiscussionPost',
defaultMessage: 'First Discussion post!',
},
forward: {
id: 'learning.celebration.forward',
defaultMessage: 'Keep going',
description: 'Button to close celebration dialog and get back to course',
},
keepItUp: {
id: 'learning.celebration.keepItUp',
defaultMessage: 'Keep it up',
description: 'Button to close celebration dialog and get back to course',
},
share: {
id: 'learning.celebration.share',
defaultMessage: 'Take a moment to celebrate and share your progress.',

View File

@@ -1,11 +1,11 @@
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { postFirstSectionCelebrationComplete } from './data/api';
import { postCelebrationComplete } from './data/api';
import { clearLocalStorage, getLocalStorage, setLocalStorage } from '../../../data/localStorage';
import { updateModel } from '../../../generic/model-store';
const CELEBRATION_LOCAL_STORAGE_KEY = 'CelebrationModal.showOnSectionLoad';
const CELEBRATION_LOCAL_STORAGE_KEY = 'FirstSectionCelebrationModal.showOnSectionLoad';
// Records clicks through the end of a section, so that we can know whether we should celebrate when we finish loading
function handleNextSectionCelebration(sequenceId, nextSequenceId, nextUnitId) {
@@ -16,18 +16,34 @@ function handleNextSectionCelebration(sequenceId, nextSequenceId, nextUnitId) {
});
}
function recordFirstSectionCelebration(courseId) {
// Tell the LMS
postFirstSectionCelebrationComplete(courseId);
// Tell our analytics
function sendCelebrationSegmentEvent(courseId, eventName) {
const { administrator } = getAuthenticatedUser();
sendTrackEvent('edx.ui.lms.celebration.first_section.opened', {
sendTrackEvent(eventName, {
course_id: courseId,
is_staff: administrator,
});
}
function recordFirstSectionCelebration(courseId) {
// Tell the LMS
postCelebrationComplete(courseId, { first_section: false });
// Tell our analytics
sendCelebrationSegmentEvent(courseId, 'edx.ui.lms.celebration.first_section.opened');
}
function recordFirstDiscussionCelebration(courseId) {
/* postCelebrationComplete should start being used once the Discussion MFE exists
and we no longer record the first discussion post in the JS handler in edx-platform
See edx-platform/common/static/common/js/discussion/views/new_post_view.js and
edx-platform/cms/static/common/js/discussion/views/discussion_thread_view.js */
// Tell the LMS
// postCelebrationComplete(courseId, { first_discussion: false });
// Tell our analytics
sendCelebrationSegmentEvent(courseId, 'edx.ui.lms.celebration.first_discussion.opened');
}
// Looks at local storage to see whether we just came from the end of a section.
// Note! This does have side effects (will clear some local storage and may start an api call).
function shouldCelebrateOnSectionLoad(courseId, sequenceId, unitId, celebrateFirstSection, dispatch) {
@@ -63,4 +79,16 @@ function shouldCelebrateOnSectionLoad(courseId, sequenceId, unitId, celebrateFir
return shouldCelebrate;
}
export { handleNextSectionCelebration, recordFirstSectionCelebration, shouldCelebrateOnSectionLoad };
function shouldCelebrateOnDiscussionPost(firstDiscussion, firstDiscussionUserBucket) {
// Bucket 0 === Control group which does not get the discussion celebration.
// That check can be removed when we stop using the flag as an experiment.
return firstDiscussion && firstDiscussionUserBucket !== 0;
}
export {
handleNextSectionCelebration,
recordFirstDiscussionCelebration,
recordFirstSectionCelebration,
shouldCelebrateOnSectionLoad,
shouldCelebrateOnDiscussionPost,
};

View File

@@ -42,7 +42,7 @@ const messages = defineMessages({
dashboardLink: {
id: 'courseExit.dashboardLink',
defaultMessage: 'Dashboard',
description: "Link to user's dashboard",
description: 'Link to users dashboard',
},
downloadButton: {
id: 'courseCelebration.downloadButton',
@@ -69,7 +69,7 @@ const messages = defineMessages({
linkedinAddToProfileButton: {
id: 'courseCelebration.linkedinAddToProfileButton',
defaultMessage: 'Add to LinkedIn profile',
description: "Button to add certificate information to the user's LinkedIn profile",
description: 'Button to add certificate information to the users LinkedIn profile',
},
nextButtonComplete: {
id: 'learn.sequence.navigation.complete.button', // for historical reasons
@@ -82,7 +82,7 @@ const messages = defineMessages({
profileLink: {
id: 'courseExit.profileLink',
defaultMessage: 'Profile',
description: "Link to user's profile",
description: 'Link to users profile',
},
requestCertificateBodyText: {
id: 'courseCelebration.requestCertificateBodyText',

View File

@@ -12,6 +12,7 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Modal } from '@edx/paragon';
import messages from './messages';
import BookmarkButton from '../bookmark/BookmarkButton';
import { FirstDiscussionCelebrationModal, shouldCelebrateOnDiscussionPost } from '../celebration';
import { useModel } from '../../../generic/model-store';
import PageLoading from '../../../generic/PageLoading';
import { processEvent } from '../../../course-home/data/thunks';
@@ -65,15 +66,21 @@ function Unit({
const [iframeHeight, setIframeHeight] = useState(0);
const [hasLoaded, setHasLoaded] = useState(false);
const [discussionPosted, setDiscussionPosted] = useState(false);
const [modalOptions, setModalOptions] = useState({ open: false });
const unit = useModel('units', id);
const course = useModel('courses', courseId);
const {
celebrations: {
firstDiscussion,
firstDiscussionUserBucket,
},
contentTypeGatingEnabled,
} = course;
const dispatch = useDispatch();
const shouldCelebrateDiscussionPost = shouldCelebrateOnDiscussionPost(firstDiscussion, firstDiscussionUserBucket);
// Do not remove this hook. See function description.
useLoadBearingHook(id);
@@ -109,6 +116,9 @@ function Unit({
return () => global.removeEventListener('message', messageEventListenerRef.current);
}, [id, setIframeHeight, hasLoaded, iframeHeight, setHasLoaded, onLoaded]);
console.log('shouldCelebrateDiscussionPost:', shouldCelebrateDiscussionPost);
console.log('discussionPosted:', discussionPosted);
return (
<div className="unit">
<h2 className="mb-0 h4">{unit.title}</h2>
@@ -157,6 +167,13 @@ function Unit({
dialogClassName="modal-lg"
/>
)}
{discussionPosted && shouldCelebrateDiscussionPost && (
<FirstDiscussionCelebrationModal
courseId={courseId}
firstDiscussionUserBucket={firstDiscussionUserBucket}
open
/>
)}
<div className="unit-iframe-wrapper">
<iframe
id="unit-iframe"
@@ -167,9 +184,11 @@ function Unit({
scrolling="no"
referrerPolicy="origin"
onLoad={() => {
window.onmessage = function handleResetDates(e) {
if (e.data.event_name) {
window.onmessage = function handleMessageEvent(e) {
if (e.data.event_name === 'post_event') {
dispatch(processEvent(e.data, fetchCourse));
} else if (e.data.event_name === 'discussion_post') {
setDiscussionPosted(true);
}
};
}}

View File

@@ -8,7 +8,7 @@ const messages = defineMessages({
},
'learn.contentLock.complete.prerequisite': {
id: 'learn.contentLock.complete.prerequisite',
defaultMessage: "You must complete the prerequisite: '{prereqSectionName}' to access this content.",
defaultMessage: 'You must complete the prerequisite: "{prereqSectionName}" to access this content.',
description: 'Message shown to indicate which prerequisite the student must complete prior to accessing the locked content. {prereqSectionName} is the name of the prerequisite.',
},
'learn.contentLock.goToSection': {