From cb1ea7ad9ffae73ab3a392a678c4de29a1aec294 Mon Sep 17 00:00:00 2001 From: Dillon Dumesnil Date: Fri, 20 Nov 2020 16:04:15 +0000 Subject: [PATCH] AA-303: Celebrate a user's first ever discussion post! --- src/courseware/course/Course.jsx | 4 +- src/courseware/course/Course.test.jsx | 6 +- .../FirstDiscussionCelebrationModal.jsx | 91 +++++++++++++++++++ ...l.jsx => FirstSectionCelebrationModal.jsx} | 8 +- src/courseware/course/celebration/data/api.js | 6 +- src/courseware/course/celebration/index.js | 9 +- src/courseware/course/celebration/messages.js | 21 +++++ src/courseware/course/celebration/utils.jsx | 46 ++++++++-- src/courseware/course/sequence/Unit.jsx | 23 ++++- 9 files changed, 188 insertions(+), 26 deletions(-) create mode 100644 src/courseware/course/celebration/FirstDiscussionCelebrationModal.jsx rename src/courseware/course/celebration/{CelebrationModal.jsx => FirstSectionCelebrationModal.jsx} (91%) diff --git a/src/courseware/course/Course.jsx b/src/courseware/course/Course.jsx index 5c180928..b13cddf4 100644 --- a/src/courseware/course/Course.jsx +++ b/src/courseware/course/Course.jsx @@ -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 && ( - diff --git a/src/courseware/course/Course.test.jsx b/src/courseware/course/Course.test.jsx index 2b0faca5..6496405a 100644 --- a/src/courseware/course/Course.test.jsx +++ b/src/courseware/course/Course.test.jsx @@ -71,9 +71,9 @@ describe('Course', () => { handleNextSectionCelebration(sequenceId, sequenceId, testData.unitId); render(, { 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 () => { diff --git a/src/courseware/course/celebration/FirstDiscussionCelebrationModal.jsx b/src/courseware/course/celebration/FirstDiscussionCelebrationModal.jsx new file mode 100644 index 00000000..e699073b --- /dev/null +++ b/src/courseware/course/celebration/FirstDiscussionCelebrationModal.jsx @@ -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 = (

{intl.formatMessage(messages.discussionBodyText1)}

); + } else { + normativeDataBodyText = (

{intl.formatMessage(messages.discussionBodyText2)}

); + } + } + + console.log('normativeDataBodyText:', normativeDataBodyText); + + return ( + +

{intl.formatMessage(messages.conversation)}

+ + + + + + + {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); diff --git a/src/courseware/course/celebration/CelebrationModal.jsx b/src/courseware/course/celebration/FirstSectionCelebrationModal.jsx similarity index 91% rename from src/courseware/course/celebration/CelebrationModal.jsx rename to src/courseware/course/celebration/FirstSectionCelebrationModal.jsx index 927cbda7..5ce5737e 100644 --- a/src/courseware/course/celebration/CelebrationModal.jsx +++ b/src/courseware/course/celebration/FirstSectionCelebrationModal.jsx @@ -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); diff --git a/src/courseware/course/celebration/data/api.js b/src/courseware/course/celebration/data/api.js index 609ab5e7..9a9fd5cb 100644 --- a/src/courseware/course/celebration/data/api.js +++ b/src/courseware/course/celebration/data/api.js @@ -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); } diff --git a/src/courseware/course/celebration/index.js b/src/courseware/course/celebration/index.js index 015f7d43..34bea7a9 100644 --- a/src/courseware/course/celebration/index.js +++ b/src/courseware/course/celebration/index.js @@ -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'; diff --git a/src/courseware/course/celebration/messages.js b/src/courseware/course/celebration/messages.js index 8fa81c65..c758d656 100644 --- a/src/courseware/course/celebration/messages.js +++ b/src/courseware/course/celebration/messages.js @@ -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.', diff --git a/src/courseware/course/celebration/utils.jsx b/src/courseware/course/celebration/utils.jsx index 9fdebf34..cac62eca 100644 --- a/src/courseware/course/celebration/utils.jsx +++ b/src/courseware/course/celebration/utils.jsx @@ -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, +}; diff --git a/src/courseware/course/sequence/Unit.jsx b/src/courseware/course/sequence/Unit.jsx index 5f03097e..2e8c8584 100644 --- a/src/courseware/course/sequence/Unit.jsx +++ b/src/courseware/course/sequence/Unit.jsx @@ -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 (

{unit.title}

@@ -157,6 +167,13 @@ function Unit({ dialogClassName="modal-lg" /> )} + {discussionPosted && shouldCelebrateDiscussionPost && ( + + )}