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,