diff --git a/src/components/CourseChecklist/CourseChecklist.test.jsx b/src/components/CourseChecklist/CourseChecklist.test.jsx index 30ffd119..9eaeb41d 100644 --- a/src/components/CourseChecklist/CourseChecklist.test.jsx +++ b/src/components/CourseChecklist/CourseChecklist.test.jsx @@ -3,6 +3,7 @@ import { IntlProvider, FormattedMessage, FormattedNumber } from 'react-intl'; import React from 'react'; import CourseChecklist from '.'; +import { courseDetails } from '../../utils/testConstants'; import getFilteredChecklist from '../../utils/CourseChecklist/getFilteredChecklist'; import getValidatedValue from '../../utils/CourseChecklist/getValidatedValue'; import messages from './displayMessages'; @@ -43,6 +44,10 @@ getFilteredChecklist.mockImplementation( const intlProvider = new IntlProvider({ locale: 'en', messages: {} }, {}); const { intl } = intlProvider.getChildContext(); +global.analytics = { + track: () => {}, +}; + let wrapper; const testData = { @@ -61,12 +66,16 @@ const defaultProps = { dataHeading: , dataList: testChecklistData, idPrefix: 'test', - links: { - certificates: 'certificatesTest', - course_outline: 'courseOutlineTest', - course_updates: 'welcomeMessageTest', - grading_policy: 'gradingPolicyTest', - settings: 'settingsTest', + studioDetails: { + course: courseDetails, + enable_quality: true, + links: { + certificates: 'certificatesTest', + course_outline: 'courseOutlineTest', + course_updates: 'welcomeMessageTest', + grading_policy: 'gradingPolicyTest', + settings: 'settingsTest', + }, }, }; @@ -368,10 +377,24 @@ describe('CourseChecklist', () => { content === assignmentsWithDatesBeforeStart[0].display_name).toEqual(true); const destination = assignmentLink.prop('destination'); - expect(destination === `${defaultProps.links.course_outline}#${assignmentsWithDatesAfterEnd[0].id}` || - destination === `${defaultProps.links.course_outline}#${assignmentsWithDatesBeforeStart[0].id}`).toEqual(true); + expect(destination === `${defaultProps.studioDetails.links.course_outline}#${assignmentsWithDatesAfterEnd[0].id}` || + destination === `${defaultProps.studioDetails.links.course_outline}#${assignmentsWithDatesBeforeStart[0].id}`).toEqual(true); }); }); + + it('calls trackEvent when an assignment Hyperlink is clicked', () => { + const comment = wrapper.find('#checklist-item-assignmentDeadlines').find('[data-identifier="comment"]'); + + const assignmentList = comment.find('ul'); + const assignments = assignmentList.find('li'); + + const assignmentLink = assignments.find(Hyperlink).at(0); + const trackEventSpy = jest.fn(); + global.analytics.track = trackEventSpy; + + assignmentLink.simulate('click'); + expect(trackEventSpy).toHaveBeenCalledTimes(1); + }); }); }); }); @@ -382,11 +405,16 @@ describe('CourseChecklist', () => { dataHeading: , dataList: [], idPrefix: '', - links: { - course_updates: '', - grading_policy: '', - certificates: '', - settings: '', + studioDetails: { + course: courseDetails, + enable_quality: true, + links: { + certificates: '', + course_outline: '', + course_updates: '', + grading_policy: '', + settings: '', + }, }, }; @@ -425,5 +453,16 @@ describe('CourseChecklist', () => { it('getCommentSection returns null for unknown checklist item id', () => { expect(wrapper.instance().getCommentSection('test')).toBeNull(); }); + + it('calls trackEvent when an update link is clicked', () => { + wrapper = shallowWithIntl(); + + const updateLink = wrapper.find(Hyperlink).at(0); + const trackEventSpy = jest.fn(); + global.analytics.track = trackEventSpy; + + updateLink.simulate('click'); + expect(trackEventSpy).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/src/components/CourseChecklist/container.jsx b/src/components/CourseChecklist/container.jsx index f243f7ee..181d7ff0 100644 --- a/src/components/CourseChecklist/container.jsx +++ b/src/components/CourseChecklist/container.jsx @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; import CourseChecklist from '.'; const mapStateToProps = state => ({ - links: state.studioDetails.links, + studioDetails: state.studioDetails, }); const mapDispatchToProps = () => ({}); diff --git a/src/components/CourseChecklist/index.jsx b/src/components/CourseChecklist/index.jsx index 9277e01b..29770e35 100644 --- a/src/components/CourseChecklist/index.jsx +++ b/src/components/CourseChecklist/index.jsx @@ -6,6 +6,7 @@ import { Hyperlink, Icon } from '@edx/paragon'; import PropTypes from 'prop-types'; import React from 'react'; +import { trackEvent } from '../../utils/analytics'; import getFilteredChecklist from '../../utils/CourseChecklist/getFilteredChecklist'; import getValidatedValue from '../../utils/CourseChecklist/getValidatedValue'; import messages from './displayMessages'; @@ -21,6 +22,9 @@ class CourseChecklist extends React.Component { totalCompletedChecks: 0, values: {}, }; + + this.onAssignmentHyperlinkClick = this.onAssignmentHyperlinkClick.bind(this); + this.onCheckUpdateHyperlinkClick = this.onCheckUpdateHyperlinkClick.bind(this); } componentWillMount() { @@ -31,6 +35,26 @@ class CourseChecklist extends React.Component { this.updateChecklistState(nextProps); } + onAssignmentHyperlinkClick = (assignmentID) => { + trackEvent( + 'edx.bi.studio.course.checklist.invalid-assignment.clicked', { + category: 'click', + event_type: `invalid-assignment-${assignmentID}`, + label: this.props.studioDetails.course.id, + }, + ); + } + + onCheckUpdateHyperlinkClick = (checkID) => { + trackEvent( + 'edx.bi.studio.course.checklist.update.clicked', { + category: 'click', + event_type: `update-${checkID}`, + label: this.props.studioDetails.course.id, + }, + ); + } + getCompletionCountID = () => (`${this.props.idPrefix.split(/\s/).join('-')}-completion-count`); getHeading = () => ( @@ -105,10 +129,10 @@ class CourseChecklist extends React.Component { getUpdateLinkDestination = (checkID) => { switch (checkID) { - case 'welcomeMessage': return this.props.links.course_updates; - case 'gradingPolicy': return this.props.links.grading_policy; - case 'certificate': return this.props.links.certificates; - case 'courseDates': return `${this.props.links.settings}#schedule`; + case 'welcomeMessage': return this.props.studioDetails.links.course_updates; + case 'gradingPolicy': return this.props.studioDetails.links.grading_policy; + case 'certificate': return this.props.studioDetails.links.certificates; + case 'courseDates': return `${this.props.studioDetails.links.settings}#schedule`; default: return null; } } @@ -119,6 +143,7 @@ class CourseChecklist extends React.Component { className={classNames('px-3', styles.btn, styles['btn-primary'], styles['checklist-item-link'])} content={} destination={this.getUpdateLinkDestination(checkID)} + onClick={() => this.onCheckUpdateHyperlinkClick(checkID)} /> ); @@ -197,7 +222,11 @@ class CourseChecklist extends React.Component { { gradedAssignmentsOutsideDateRange.map(assignment => (
  • - + this.onAssignmentHyperlinkClick(assignment.id)} + />
  • )) } @@ -367,5 +396,24 @@ CourseChecklist.propTypes = { // eslint-disable-next-line react/no-unused-prop-types dataList: PropTypes.arrayOf(PropTypes.object).isRequired, idPrefix: PropTypes.string.isRequired, - links: PropTypes.objectOf(PropTypes.string).isRequired, + studioDetails: PropTypes.shape({ + course: PropTypes.shape({ + base_url: PropTypes.string, + course_release_date: PropTypes.string, + display_course_number: PropTypes.string, + enable_quality: PropTypes.bool, + id: PropTypes.string, + is_course_self_paced: PropTypes.boolean, + lang: PropTypes.string, + name: PropTypes.string, + num: PropTypes.string, + org: PropTypes.string, + revision: PropTypes.string, + url_name: PropTypes.string, + }), + enable_quality: PropTypes.boolean, + help_tokens: PropTypes.objectOf(PropTypes.string), + lang: PropTypes.string, + links: PropTypes.objectOf(PropTypes.string), + }).isRequired, }; diff --git a/src/components/CourseOutlineStatus/CourseOutlineStatus.test.jsx b/src/components/CourseOutlineStatus/CourseOutlineStatus.test.jsx index 3874b24e..a00accfc 100644 --- a/src/components/CourseOutlineStatus/CourseOutlineStatus.test.jsx +++ b/src/components/CourseOutlineStatus/CourseOutlineStatus.test.jsx @@ -1,6 +1,8 @@ +import { Hyperlink } from '@edx/paragon'; +import { IntlProvider, FormattedMessage } from 'react-intl'; import React from 'react'; -import CourseOutlineStatus from './'; +import CourseOutlineStatus from './'; import { courseDetails } from '../../utils/testConstants'; import getFilteredChecklist from '../../utils/CourseChecklist/getFilteredChecklist'; import getValidatedValue from '../../utils/CourseChecklist/getValidatedValue'; @@ -8,7 +10,14 @@ import { launchChecklist, bestPracticesChecklist } from '../../utils/CourseCheck import { shallowWithIntl } from '../../utils/i18n/enzymeHelper'; // generating test checklist to avoid relying on actual data -const testChecklistData = ['a', 'b', 'c', 'd'].reduce(((accumulator, currentValue) => { accumulator.push({ id: currentValue, shortDescription: currentValue, longDescription: currentValue }); return accumulator; }), []); +const testChecklistData = ['a', 'b', 'c', 'd'].reduce(((accumulator, currentValue) => { + accumulator.push({ + id: currentValue, + shortDescription: currentValue, + longDescription: currentValue, + }); + return accumulator; +}), []); const testChecklist = { heading: 'test', data: testChecklistData, @@ -57,15 +66,22 @@ getFilteredChecklist.mockImplementation( dataList => (dataList), ); +const intlProvider = new IntlProvider({ locale: 'en', messages: {} }, {}); +const { intl } = intlProvider.getChildContext(); + +global.analytics = { + track: () => {}, +}; + let wrapper; const defaultProps = { - studioDetails: { course: courseDetails, enable_quality: true }, - getCourseBestPractices: () => { }, - getCourseLaunch: () => { }, courseBestPracticesData: {}, courseLaunchData: {}, enable_quality: true, + getCourseBestPractices: () => { }, + getCourseLaunch: () => { }, + studioDetails: { course: courseDetails, enable_quality: true }, }; describe('CourseOutlineStatus', () => { @@ -78,12 +94,12 @@ describe('CourseOutlineStatus', () => { expect(header.text()).toEqual('Checklists'); }); - it('an anchor with correct href', () => { + it('a Hyperlink with correct href', () => { wrapper = shallowWithIntl(); - const anchor = wrapper.find('a'); - expect(anchor).toHaveLength(1); - expect(anchor.prop('href')).toEqual(`/checklists/${defaultProps.studioDetails.course.id}`); + const checklistsLink = wrapper.find(Hyperlink); + expect(checklistsLink).toHaveLength(1); + expect(checklistsLink.prop('destination')).toEqual(`/checklists/${defaultProps.studioDetails.course.id}`); }); describe('if enable_quality prop is true', () => { @@ -99,8 +115,12 @@ describe('CourseOutlineStatus', () => { courseLaunchData: testChecklist, }); - const anchor = wrapper.find('a'); - expect(anchor.text()).toEqual(`${completed}/${total} complete`); + const checklistsLink = wrapper.find(Hyperlink); + const checklistsLinkContent = shallowWithIntl(checklistsLink.prop('content'), { context: { intl } }); + const completionCount = checklistsLinkContent.dive({ context: { intl } }) + .find(FormattedMessage).dive({ context: { intl } }); + + expect(completionCount.text()).toEqual(`${completed}/${total} completed`); }); }); @@ -127,8 +147,12 @@ describe('CourseOutlineStatus', () => { courseLaunchData: testChecklist, }); - const anchor = wrapper.find('a'); - expect(anchor.text()).toEqual(`${completed}/${total} complete`); + const checklistsLink = wrapper.find(Hyperlink); + const checklistsLinkContent = shallowWithIntl(checklistsLink.prop('content'), { context: { intl } }); + const completionCount = checklistsLinkContent.dive({ context: { intl } }) + .find(FormattedMessage).dive({ context: { intl } }); + + expect(completionCount.text()).toEqual(`${completed}/${total} completed`); }); }); }); @@ -167,5 +191,16 @@ describe('CourseOutlineStatus', () => { defaultProps.studioDetails.course, ); }); + + it('calls trackEvent when checklist link is clicked', () => { + wrapper = shallowWithIntl(); + + const completionLink = wrapper.find(Hyperlink); + const trackEventSpy = jest.fn(); + global.analytics.track = trackEventSpy; + + completionLink.simulate('click'); + expect(trackEventSpy).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/src/components/CourseOutlineStatus/displayMessages.jsx b/src/components/CourseOutlineStatus/displayMessages.jsx new file mode 100644 index 00000000..7e81d06e --- /dev/null +++ b/src/components/CourseOutlineStatus/displayMessages.jsx @@ -0,0 +1,11 @@ +import { defineMessages } from 'react-intl'; + +const messages = defineMessages({ + completionCountLabel: { + id: 'completionCountLabel', + defaultMessage: '{completed}/{total} completed', + description: 'Label that describes how many tasks have been completed out of a total number of tasks', + }, +}); + +export default messages; diff --git a/src/components/CourseOutlineStatus/index.jsx b/src/components/CourseOutlineStatus/index.jsx index da13cf89..364cb346 100644 --- a/src/components/CourseOutlineStatus/index.jsx +++ b/src/components/CourseOutlineStatus/index.jsx @@ -1,11 +1,15 @@ import classNames from 'classnames'; -import React from 'react'; +import { Hyperlink } from '@edx/paragon'; import PropTypes from 'prop-types'; +import React from 'react'; +import { trackEvent } from '../../utils/analytics'; import getFilteredChecklist from '../../utils/CourseChecklist/getFilteredChecklist'; import getValidatedValue from '../../utils/CourseChecklist/getValidatedValue'; import { launchChecklist, bestPracticesChecklist } from '../../utils/CourseChecklist/courseChecklistData'; +import messages from './displayMessages'; import styles from './CourseOutlineStatus.scss'; +import WrappedMessage from '../../utils/i18n/formattedMessageWrapper'; export default class CourseOutlineStatus extends React.Component { constructor(props) { @@ -90,7 +94,7 @@ export default class CourseOutlineStatus extends React.Component { totalCourseLaunchChecks, } = this.state; - const totalCompleteChecks = this.props.studioDetails.enable_quality ? + const totalCompletedChecks = this.props.studioDetails.enable_quality ? completedCourseBestPracticesChecks + completedCourseLaunchChecks : completedCourseLaunchChecks; @@ -101,9 +105,33 @@ export default class CourseOutlineStatus extends React.Component { return (
    +

    Checklists

    + + {displayText => + ( + {displayText} + ) + } + + } + destination={`/checklists/${this.props.studioDetails.course.id}`} + onClick={() => trackEvent( + 'edx.bi.studio.course.checklist.accessed', { + category: 'click', + event_type: 'outline-access', + label: this.props.studioDetails.course.id, + }, + )} + /> +
    ); diff --git a/src/utils/analytics.js b/src/utils/analytics.js new file mode 100644 index 00000000..62cb1524 --- /dev/null +++ b/src/utils/analytics.js @@ -0,0 +1,7 @@ +// eslint-disable-next-line import/prefer-default-export +export const trackEvent = (key, value) => { + // TODO: Ticket EDUCATOR-3192 + // Add window.analytics.identify(user_id) + // that uses LMS auth user_id + window.analytics.track(key, value); +}; diff --git a/src/utils/analytics.test.jsx b/src/utils/analytics.test.jsx new file mode 100644 index 00000000..8a1ab12b --- /dev/null +++ b/src/utils/analytics.test.jsx @@ -0,0 +1,30 @@ +import { trackEvent } from './analytics'; + +const key = 'testKey'; +const category = 'testCategory'; +const courseId = 'testId'; +const eventType = 'testEventType'; +global.analytics = { + track: () => {}, +}; + +describe('Analytics Utility Functions', () => { + describe('trackEvent', () => { + it('calls the global track function', () => { + const analyticsSpy = jest.fn(); + global.analytics.track = analyticsSpy; + trackEvent(key, { + category, + event_type: eventType, + label: courseId, + }); + + expect(analyticsSpy).toHaveBeenCalledTimes(1); + expect(analyticsSpy).toHaveBeenCalledWith(key, { + category, + label: courseId, + event_type: eventType, + }); + }); + }); +});