From b9d1bf06247da6399e0d7481ae1cf0244a746d22 Mon Sep 17 00:00:00 2001 From: Dillon Dumesnil Date: Tue, 18 Jan 2022 06:11:36 -0800 Subject: [PATCH] feat: AA-1138: Adds in Weekly Goal Celebration Modal (#797) The logic to show the modal is controlled by the backend. Displays the modal only in courseware the first time the learner hits their weekly learning goal. After viewing the goal, the database row is updated to not show the modal again. Also updates first section celebration to use the StandardModal component as the Modal component has been deprecated. --- src/courseware/course/Course.jsx | 28 +++++-- src/courseware/course/Course.test.jsx | 34 +++++--- .../course/celebration/CelebrationModal.jsx | 71 ++++++++-------- .../WeeklyGoalCelebrationModal.jsx | 80 +++++++++++++++++++ .../course/celebration/assets/target.svg | 24 ++++++ src/courseware/course/celebration/data/api.js | 6 +- src/courseware/course/celebration/index.js | 1 + src/courseware/course/celebration/messages.js | 9 +++ src/courseware/course/celebration/utils.jsx | 26 +++++- src/courseware/data/api.js | 2 +- 10 files changed, 220 insertions(+), 61 deletions(-) create mode 100644 src/courseware/course/celebration/WeeklyGoalCelebrationModal.jsx create mode 100644 src/courseware/course/celebration/assets/target.svg diff --git a/src/courseware/course/Course.jsx b/src/courseware/course/Course.jsx index 7da85b51..55f2a63b 100644 --- a/src/courseware/course/Course.jsx +++ b/src/courseware/course/Course.jsx @@ -8,7 +8,7 @@ import { AlertList } from '../../generic/user-messages'; import Sequence from './sequence'; -import { CelebrationModal, shouldCelebrateOnSectionLoad } from './celebration'; +import { CelebrationModal, shouldCelebrateOnSectionLoad, WeeklyGoalCelebrationModal } from './celebration'; import ContentTools from './content-tools'; import CourseBreadcrumbs from './CourseBreadcrumbs'; import NotificationTrigger from './NotificationTrigger'; @@ -41,15 +41,22 @@ function Course({ const { celebrations, + courseGoals, verifiedMode, } = course; // Below the tabs, above the breadcrumbs alerts (appearing in the order listed here) const dispatch = useDispatch(); const celebrateFirstSection = celebrations && celebrations.firstSection; - const celebrationOpen = shouldCelebrateOnSectionLoad( + const [firstSectionCelebrationOpen, setFirstSectionCelebrationOpen] = useState(shouldCelebrateOnSectionLoad( courseId, sequenceId, unitId, celebrateFirstSection, dispatch, celebrations, + )); + // If streakLengthToCelebrate is populated, that modal takes precedence. Wait til the next load to display + // the weekly goal celebration modal. + const [weeklyGoalCelebrationOpen, setWeeklyGoalCelebrationOpen] = useState( + celebrations && !celebrations.streakLengthToCelebrate && celebrations.weeklyGoal, ); + const daysPerWeek = courseGoals?.selectedGoal?.daysPerWeek; // Responsive breakpoints for showing the notification button/tray const shouldDisplayNotificationTriggerInCourse = useWindowSize().width >= responsiveBreakpoints.small.minWidth; @@ -145,12 +152,17 @@ function Course({ //* * [MM-P2P] Experiment */ mmp2p={MMP2P} /> - {celebrationOpen && ( - - )} + setFirstSectionCelebrationOpen(false)} + /> + setWeeklyGoalCelebrationOpen(false)} + /> { /** [MM-P2P] Experiment */ } { MMP2P.meta.modalLock && } diff --git a/src/courseware/course/Course.test.jsx b/src/courseware/course/Course.test.jsx index 2f44ff3f..be9171e2 100644 --- a/src/courseware/course/Course.test.jsx +++ b/src/courseware/course/Course.test.jsx @@ -65,11 +65,7 @@ describe('Course', () => { ); }); - it('displays celebration modal', async () => { - // TODO: Remove these console mocks after merging https://github.com/edx/paragon/pull/526. - jest.spyOn(console, 'warn').mockImplementation(() => {}); - jest.spyOn(console, 'error').mockImplementation(() => {}); - + it('displays first section celebration modal', async () => { const courseMetadata = Factory.build('courseMetadata', { celebrations: { firstSection: true } }); const testStore = await initializeTestStore({ courseMetadata }, false); const { courseware, models } = testStore.getState(); @@ -84,9 +80,27 @@ 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 weekly goal celebration modal', async () => { + const courseMetadata = Factory.build('courseMetadata', { celebrations: { weeklyGoal: true } }); + const testStore = await initializeTestStore({ courseMetadata }, false); + const { courseware, models } = testStore.getState(); + const { courseId, sequenceId } = courseware; + const testData = { + ...mockData, + courseId, + sequenceId, + unitId: Object.values(models.units)[0].id, + }; + render(, { store: testStore }); + + const weeklyGoalCelebrationModal = screen.getByRole('dialog'); + expect(weeklyGoalCelebrationModal).toBeInTheDocument(); + expect(getByRole(weeklyGoalCelebrationModal, 'heading', { name: 'You met your goal!' })).toBeInTheDocument(); }); it('displays notification trigger and toggles active class on click', async () => { @@ -171,8 +185,8 @@ describe('Course', () => { loadUnit(); await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument()); // expect the section and sequence "titles" to be loaded in as breadcrumb labels. - expect(screen.getByText('cdabcdabcdabcdabcdabcdabcdabcd13')).toBeInTheDocument(); - expect(screen.getByText('cdabcdabcdabcdabcdabcdabcdabcd12')).toBeInTheDocument(); + expect(screen.getByText(Object.values(models.sections)[0].title)).toBeInTheDocument(); + expect(screen.getByText(Object.values(models.sequences)[0].title)).toBeInTheDocument(); }); it('passes handlers to the sequence', async () => { diff --git a/src/courseware/course/celebration/CelebrationModal.jsx b/src/courseware/course/celebration/CelebrationModal.jsx index c38f0b8a..5284dbd3 100644 --- a/src/courseware/course/celebration/CelebrationModal.jsx +++ b/src/courseware/course/celebration/CelebrationModal.jsx @@ -1,7 +1,7 @@ import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { Modal } from '@edx/paragon'; +import { ActionRow, Button, StandardModal } from '@edx/paragon'; import { layoutGenerator } from 'react-break'; import ClapsMobile from './assets/claps_280x201.gif'; @@ -12,7 +12,7 @@ import { recordFirstSectionCelebration } from './utils'; import { useModel } from '../../../generic/model-store'; function CelebrationModal({ - courseId, intl, open, ...rest + courseId, intl, isOpen, onClose, ...rest }) { const { org } = useModel('coursewareMeta', courseId); @@ -25,50 +25,53 @@ function CelebrationModal({ const OnAtLeastTablet = layout.isAtLeast('tablet'); useEffect(() => { - if (open) { + if (isOpen) { recordFirstSectionCelebration(org, courseId); } - }, [open]); + }, [isOpen]); return ( - -

{intl.formatMessage(messages.completed)}

- - - - - - -

- {intl.formatMessage(messages.earned)} {intl.formatMessage(messages.share)} -

- - + + + + )} + hasCloseButton={false} + isOpen={isOpen} + onClose={onClose} + title={( +

{intl.formatMessage(messages.congrats)}

)} - closeText={intl.formatMessage(messages.forward)} - onClose={() => {}} // Don't do anything special, just having the modal close is enough (this is a required prop) - open={open} - title={intl.formatMessage(messages.congrats)} {...rest} - /> + > + <> +

{intl.formatMessage(messages.completed)}

+ + + + + + +

+ {intl.formatMessage(messages.earned)} {intl.formatMessage(messages.share)} +

+ + +
); } -CelebrationModal.defaultProps = { - open: false, -}; - CelebrationModal.propTypes = { courseId: PropTypes.string.isRequired, intl: intlShape.isRequired, - open: PropTypes.bool, + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, }; export default injectIntl(CelebrationModal); diff --git a/src/courseware/course/celebration/WeeklyGoalCelebrationModal.jsx b/src/courseware/course/celebration/WeeklyGoalCelebrationModal.jsx new file mode 100644 index 00000000..402e72c7 --- /dev/null +++ b/src/courseware/course/celebration/WeeklyGoalCelebrationModal.jsx @@ -0,0 +1,80 @@ +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { + ActionRow, Button, Icon, StandardModal, +} from '@edx/paragon'; +import { Lightbulb } from '@edx/paragon/icons'; + +import Target from './assets/target.svg'; +import messages from './messages'; +import { recordWeeklyGoalCelebration } from './utils'; +import { useModel } from '../../../generic/model-store'; + +function WeeklyGoalCelebrationModal({ + courseId, daysPerWeek, intl, isOpen, onClose, ...rest +}) { + const { org } = useModel('coursewareMeta', courseId); + + useEffect(() => { + if (isOpen) { + recordWeeklyGoalCelebration(org, courseId); + } + }, [isOpen]); + + return ( + + + + )} + hasCloseButton={false} + isOpen={isOpen} + onClose={onClose} + title={( +

{intl.formatMessage(messages.goalMet)}

+ )} + {...rest} + > + <> +
+ {daysPerWeek} {daysPerWeek === 1 ? 'time' : 'times'}), + }} + /> +
+
+ +
+
+ + achieve higher performance), + }} + /> +
+ +
+ ); +} + +WeeklyGoalCelebrationModal.propTypes = { + courseId: PropTypes.string.isRequired, + daysPerWeek: PropTypes.number.isRequired, + intl: intlShape.isRequired, + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, +}; + +export default injectIntl(WeeklyGoalCelebrationModal); diff --git a/src/courseware/course/celebration/assets/target.svg b/src/courseware/course/celebration/assets/target.svg new file mode 100644 index 00000000..37c2b3ae --- /dev/null +++ b/src/courseware/course/celebration/assets/target.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/courseware/course/celebration/data/api.js b/src/courseware/course/celebration/data/api.js index 609ab5e7..4f50dc92 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, data) { const url = new URL(`${getConfig().LMS_BASE_URL}/api/courseware/celebration/${courseId}`); - getAuthenticatedHttpClient().post(url.href, { - first_section: false, - }); + getAuthenticatedHttpClient().post(url.href, data); } diff --git a/src/courseware/course/celebration/index.js b/src/courseware/course/celebration/index.js index 015f7d43..755bb23a 100644 --- a/src/courseware/course/celebration/index.js +++ b/src/courseware/course/celebration/index.js @@ -1,2 +1,3 @@ export { default as CelebrationModal } from './CelebrationModal'; +export { default as WeeklyGoalCelebrationModal } from './WeeklyGoalCelebrationModal'; export { handleNextSectionCelebration, shouldCelebrateOnSectionLoad } from './utils'; diff --git a/src/courseware/course/celebration/messages.js b/src/courseware/course/celebration/messages.js index 8fa81c65..4f3f79d6 100644 --- a/src/courseware/course/celebration/messages.js +++ b/src/courseware/course/celebration/messages.js @@ -23,6 +23,15 @@ const messages = defineMessages({ defaultMessage: 'Keep going', description: 'Button to close celebration dialog and get back to course', }, + goalMet: { + id: 'learning.celebration.goalMet', + defaultMessage: 'You met your goal!', + }, + 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 23662714..1c1c687a 100644 --- a/src/courseware/course/celebration/utils.jsx +++ b/src/courseware/course/celebration/utils.jsx @@ -1,7 +1,7 @@ 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'; @@ -18,7 +18,7 @@ function handleNextSectionCelebration(sequenceId, nextSequenceId, nextUnitId) { function recordFirstSectionCelebration(org, courseId) { // Tell the LMS - postFirstSectionCelebrationComplete(courseId); + postCelebrationComplete(courseId, { first_section: false }); // Tell our analytics const { administrator } = getAuthenticatedUser(); @@ -30,6 +30,19 @@ function recordFirstSectionCelebration(org, courseId) { }); } +function recordWeeklyGoalCelebration(org, courseId) { + // Tell the LMS + postCelebrationComplete(courseId, { weekly_goal: false }); + + // Tell our analytics + const { administrator } = getAuthenticatedUser(); + sendTrackEvent('edx.ui.lms.celebration.weekly_goal.opened', { + org_key: org, + courserun_key: courseId, + is_staff: administrator, + }); +} + // 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, celebrations) { @@ -51,7 +64,7 @@ function shouldCelebrateOnSectionLoad(courseId, sequenceId, unitId, celebrateFir // If we are going to celebrate a streak then we will not also celebrate the first section. // We will still mark the first section as celebrated, so that we don't incorrectly celebrate the second section. shouldCelebrate = false; - postFirstSectionCelebrationComplete(courseId); + postCelebrationComplete(courseId, { first_section: false }); } if (sequenceId !== prevSequenceId && !onTargetUnit) { @@ -74,4 +87,9 @@ function shouldCelebrateOnSectionLoad(courseId, sequenceId, unitId, celebrateFir return shouldCelebrate; } -export { handleNextSectionCelebration, recordFirstSectionCelebration, shouldCelebrateOnSectionLoad }; +export { + handleNextSectionCelebration, + recordFirstSectionCelebration, + recordWeeklyGoalCelebration, + shouldCelebrateOnSectionLoad, +}; diff --git a/src/courseware/data/api.js b/src/courseware/data/api.js index c927e4a6..1214ec20 100644 --- a/src/courseware/data/api.js +++ b/src/courseware/data/api.js @@ -198,7 +198,7 @@ function normalizeMetadata(metadata) { accessExpiration: camelCaseObject(data.access_expiration), canShowUpgradeSock: data.can_show_upgrade_sock, contentTypeGatingEnabled: data.content_type_gating_enabled, - courseGoals: data.course_goals, + courseGoals: camelCaseObject(data.course_goals), id: data.id, title: data.name, number: data.number,