AA-303: Celebrate a user's first ever discussion post!

This commit is contained in:
Dillon Dumesnil
2020-11-20 16:04:15 +00:00
parent 4f9cd060be
commit cb1ea7ad9f
9 changed files with 188 additions and 26 deletions

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 ClapsMobile from './assets/claps_280x201.gif';
import ClapsTablet from './assets/claps_456x328.gif';
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 === 0) {
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={ClapsMobile} alt="" className="img-fluid" />
</OnMobile>
<OnAtLeastTablet>
<img src={ClapsTablet} alt="" className="img-fluid w-100" />
</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);

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,6 +9,18 @@ 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 don't.",
},
discussionBodyText2: {
id: 'learning.celebration.discussionBodyText2',
defaultMessage: "Learners who participate in discussions complete 3x as much course content on average vs. those who don't.",
},
earned: {
id: 'learning.celebration.earned',
defaultMessage: 'You earned it!',
@@ -18,11 +30,20 @@ const messages = defineMessages({
defaultMessage: "I'm 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

@@ -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);
}
};
}}