From ba43f5ace2d7dff18050d4ebc1b6f7923dadfc01 Mon Sep 17 00:00:00 2001 From: Michael Roytman Date: Mon, 25 Jun 2018 15:10:43 -0400 Subject: [PATCH] feat(coursechecklist): add update links to relevant checklist items --- package-lock.json | 82 ++++++--- package.json | 1 + src/SFE.scss | 12 ++ .../CourseChecklist/CourseChecklist.scss | 22 +-- .../CourseChecklist/CourseChecklist.test.jsx | 106 ++++++++--- src/components/CourseChecklist/container.jsx | 2 +- .../CourseChecklist/displayMessages.jsx | 126 +++++++++++++ src/components/CourseChecklist/index.jsx | 169 ++++++++++++++---- .../CourseChecklistPage.test.jsx | 13 +- .../CourseChecklistPage/displayMessages.jsx | 16 ++ src/components/CourseChecklistPage/index.jsx | 8 +- src/data/i18n/default/transifex_input.json | 26 +++ .../CourseChecklist/courseChecklistData.jsx | 22 --- 13 files changed, 481 insertions(+), 124 deletions(-) create mode 100644 src/components/CourseChecklist/displayMessages.jsx create mode 100644 src/components/CourseChecklistPage/displayMessages.jsx diff --git a/package-lock.json b/package-lock.json index 882087cc..29d47bed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -878,6 +878,22 @@ "indent-string": "3.2.0" } }, + "airbnb-prop-types": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/airbnb-prop-types/-/airbnb-prop-types-2.10.0.tgz", + "integrity": "sha512-M7kDqFO6kFNGV0fHPZaBx672m0jwbpCdbrtW2lcevCEuPB2sKCY3IPa030K/N1iJLEGwCNk4NSag65XBEulwhg==", + "requires": { + "array.prototype.find": "2.0.4", + "function.prototype.name": "1.1.0", + "has": "1.0.1", + "is-regex": "1.0.4", + "object-is": "1.0.1", + "object.assign": "4.1.0", + "object.entries": "1.0.4", + "prop-types": "15.6.1", + "prop-types-exact": "1.2.0" + } + }, "ajv": { "version": "5.5.2", "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz", @@ -1124,6 +1140,15 @@ "integrity": "sha1-odl8yvy8JiXMcPrc6zalDFiwGlM=", "dev": true }, + "array.prototype.find": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/array.prototype.find/-/array.prototype.find-2.0.4.tgz", + "integrity": "sha1-VWpcU2LAhkgyPdrrnenRS8GGTJA=", + "requires": { + "define-properties": "1.1.2", + "es-abstract": "1.11.0" + } + }, "arrify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", @@ -4376,7 +4401,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.2.tgz", "integrity": "sha1-g6c/L+pWmJj7c3GTyPhzyvbUXJQ=", - "dev": true, "requires": { "foreach": "2.0.5", "object-keys": "1.0.11" @@ -4998,7 +5022,6 @@ "version": "1.11.0", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.11.0.tgz", "integrity": "sha512-ZnQrE/lXTTQ39ulXZ+J1DTFazV9qBy61x2bY071B+qGco8Z8q1QddsLdt/EF8Ai9hcWH72dWS0kFqXLxOxqslA==", - "dev": true, "requires": { "es-to-primitive": "1.1.1", "function-bind": "1.1.1", @@ -5011,7 +5034,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.1.1.tgz", "integrity": "sha1-RTVSSKiJeQNLZ5Lhm7gfK3l13Q0=", - "dev": true, "requires": { "is-callable": "1.1.3", "is-date-object": "1.0.1", @@ -6290,8 +6312,7 @@ "foreach": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz", - "integrity": "sha1-C+4AUBiusmDQo6865ljdATbsG5k=", - "dev": true + "integrity": "sha1-C+4AUBiusmDQo6865ljdATbsG5k=" }, "forever-agent": { "version": "0.6.1", @@ -6919,14 +6940,12 @@ "function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" }, "function.prototype.name": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.0.tgz", "integrity": "sha512-Bs0VRrTz4ghD8pTmbJQD1mZ8A/mN0ur/jGz+A6FBxPDUPkm1tNfF6bhTYPA7i7aF4lZJVr+OXTNNrnnIl58Wfg==", - "dev": true, "requires": { "define-properties": "1.1.2", "function-bind": "1.1.1", @@ -7533,7 +7552,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/has/-/has-1.0.1.tgz", "integrity": "sha1-hGFzP1OLCDfJNh45qauelwTcLyg=", - "dev": true, "requires": { "function-bind": "1.1.1" } @@ -7567,8 +7585,7 @@ "has-symbols": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.0.tgz", - "integrity": "sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q=", - "dev": true + "integrity": "sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q=" }, "has-to-string-tag-x": { "version": "1.4.1", @@ -8737,8 +8754,7 @@ "is-callable": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.3.tgz", - "integrity": "sha1-hut1OSgF3cM69xySoO7fdO52BLI=", - "dev": true + "integrity": "sha1-hut1OSgF3cM69xySoO7fdO52BLI=" }, "is-ci": { "version": "1.1.0", @@ -8761,8 +8777,7 @@ "is-date-object": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.1.tgz", - "integrity": "sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY=", - "dev": true + "integrity": "sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY=" }, "is-descriptor": { "version": "0.1.6", @@ -9001,7 +9016,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.4.tgz", "integrity": "sha1-VRdIm1RwkbCTDglWVM7SXul+lJE=", - "dev": true, "requires": { "has": "1.0.1" } @@ -9071,8 +9085,7 @@ "is-symbol": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.1.tgz", - "integrity": "sha1-PMWfAAJRlLarLjjbrmaJJWtmBXI=", - "dev": true + "integrity": "sha1-PMWfAAJRlLarLjjbrmaJJWtmBXI=" }, "is-text-path": { "version": "1.0.1", @@ -12004,14 +12017,12 @@ "object-is": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.0.1.tgz", - "integrity": "sha1-CqYOyZiaCz7Xlc9NBvYs8a1lObY=", - "dev": true + "integrity": "sha1-CqYOyZiaCz7Xlc9NBvYs8a1lObY=" }, "object-keys": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.0.11.tgz", - "integrity": "sha1-xUYBd4rVYPEULODgG8yotW0TQm0=", - "dev": true + "integrity": "sha1-xUYBd4rVYPEULODgG8yotW0TQm0=" }, "object-visit": { "version": "1.0.1", @@ -12034,7 +12045,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz", "integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==", - "dev": true, "requires": { "define-properties": "1.1.2", "function-bind": "1.1.1", @@ -12046,7 +12056,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.0.4.tgz", "integrity": "sha1-G/mk3SKI9bM/Opk9JXZh8F0WGl8=", - "dev": true, "requires": { "define-properties": "1.1.2", "es-abstract": "1.11.0", @@ -14958,6 +14967,26 @@ "object-assign": "4.1.1" } }, + "prop-types-exact": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/prop-types-exact/-/prop-types-exact-1.2.0.tgz", + "integrity": "sha512-K+Tk3Kd9V0odiXFP9fwDHUYRyvK3Nun3GVyPapSIs5OBkITAm15W0CPFD/YKTkMUAbc0b9CUwRQp2ybiBIq+eA==", + "requires": { + "has": "1.0.3", + "object.assign": "4.1.0", + "reflect.ownkeys": "0.2.0" + }, + "dependencies": { + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "requires": { + "function-bind": "1.1.1" + } + } + } + }, "protocols": { "version": "1.4.6", "resolved": "https://registry.npmjs.org/protocols/-/protocols-1.4.6.tgz", @@ -15623,6 +15652,11 @@ "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.2.0.tgz", "integrity": "sha1-5hWhbha0ehmlFXZhM9Hj6Zt4UuU=" }, + "reflect.ownkeys": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz", + "integrity": "sha1-dJrO7H8/34tj+SegSAnpDFwLNGA=" + }, "regenerate": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.0.tgz", diff --git a/package.json b/package.json index 8ce66622..c37d8287 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dependencies": { "@edx/edx-bootstrap": "^1.0.0", "@edx/paragon": "3.1.2", + "airbnb-prop-types": "^2.10.0", "babel-polyfill": "^6.26.0", "classnames": "^2.2.5", "copy-to-clipboard": "^3.0.8", diff --git a/src/SFE.scss b/src/SFE.scss index 3153aca9..abdcda95 100644 --- a/src/SFE.scss +++ b/src/SFE.scss @@ -137,3 +137,15 @@ input[type="search"] { .modal-footer { flex: 0 0 auto; } + +.font-small { + font-size: $font-size-sm; +} + +.font-large { + font-size: $font-size-lg; +} + +.font-extra-large { + font-size: 1.5*$font-size-base; +} diff --git a/src/components/CourseChecklist/CourseChecklist.scss b/src/components/CourseChecklist/CourseChecklist.scss index c88984ec..a5b48c56 100644 --- a/src/components/CourseChecklist/CourseChecklist.scss +++ b/src/components/CourseChecklist/CourseChecklist.scss @@ -1,10 +1,16 @@ @import 'edx-bootstrap'; +@import "~bootstrap/scss/_buttons"; +@import "~bootstrap/scss/utilities/_screenreaders"; -$dark-color: theme-color("dark"); +$light-color: theme-color("light"); $lightest-color: theme-color("lightest"); $primary-color: theme-color("primary"); $success-color: theme-color("success"); +.checklist-item-link { + text-decoration: none; +} + //complete checklist item style .checklist-item-complete { box-shadow: -5px 0 0 0 $success-color; @@ -19,7 +25,7 @@ $success-color: theme-color("success"); box-shadow: -5px 0 0 0 $lightest-color; .checklist-icon-incomplete { - color: $dark-color; + color: $light-color; } &:hover { @@ -30,15 +36,3 @@ $success-color: theme-color("success"); } } } - -.font-large { - font-size: $font-size-lg; -} - -.font-small { - font-size: $font-size-sm; -} - -.font-extra-large { - font-size: 1.5*$font-size-base; -} \ No newline at end of file diff --git a/src/components/CourseChecklist/CourseChecklist.test.jsx b/src/components/CourseChecklist/CourseChecklist.test.jsx index dea4b93b..1f86cc01 100644 --- a/src/components/CourseChecklist/CourseChecklist.test.jsx +++ b/src/components/CourseChecklist/CourseChecklist.test.jsx @@ -1,13 +1,17 @@ -import { Icon } from '@edx/paragon'; +import { Icon, Hyperlink } from '@edx/paragon'; +import { IntlProvider, FormattedMessage } from 'react-intl'; import React from 'react'; import CourseChecklist from '.'; import getFilteredChecklist from '../../utils/CourseChecklist/getFilteredChecklist'; import getValidatedValue from '../../utils/CourseChecklist/getValidatedValue'; +import messages from './displayMessages'; import { shallowWithIntl } from '../../utils/i18n/enzymeHelper'; +import WrappedMessage from '../../utils/i18n/formattedMessageWrapper'; + // 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 = ['welcomeMessage', 'gradingPolicy', 'certificate', 'courseDates', 'assignmentDeadlines'].reduce(((accumulator, currentValue) => { accumulator.push({ id: currentValue }); return accumulator; }), []); /** * generating test validated values to mock the implementation @@ -36,6 +40,9 @@ getFilteredChecklist.mockImplementation( dataList => (dataList), ); +const intlProvider = new IntlProvider({ locale: 'en', messages: {} }, {}); +const { intl } = intlProvider.getChildContext(); + let wrapper; const testData = { @@ -46,10 +53,19 @@ const testData = { const defaultProps = { data: testData, - dataHeading: 'test', + dataHeading: , dataList: testChecklistData, + idPrefix: 'test', + links: { + course_updates: 'welcomeMessageTest', + grading_policy: 'gradingPolicyTest', + certificates: 'certificatesTest', + settings: 'settingsTest', + }, }; +const getCompletionCountID = () => (`${defaultProps.idPrefix.split(/\s/).join('-')}-completion-count`); + describe('CourseChecklist', () => { describe('renders', () => { it('a heading using the dataHeading prop', () => { @@ -58,14 +74,14 @@ describe('CourseChecklist', () => { const heading = wrapper.find('h3').at(0); expect(heading).toHaveLength(1); - expect(heading.text()).toEqual(defaultProps.dataHeading); + expect(heading.containsMatchingElement(defaultProps.dataHeading)).toEqual(true); }); it('a heading with correct props', () => { wrapper = shallowWithIntl(); const heading = wrapper.find('h3'); - expect(heading.find('span').prop('id')).toEqual(`${defaultProps.dataHeading}-heading`); + expect(heading.prop('aria-describedby')).toEqual(getCompletionCountID()); }); it('completion count text', () => { @@ -74,16 +90,24 @@ describe('CourseChecklist', () => { const completed = Object.values(validatedValues).filter(value => value).length; const total = Object.values(validatedValues).length; - const completionCount = wrapper.find('#completion-count'); + const completionCount = wrapper.find('.row .col').at(1).find(WrappedMessage); + expect(completionCount).toHaveLength(1); - expect(completionCount.text()).toEqual(`${completed}/${total} completed`); + expect(completionCount.prop('message')).toEqual(messages.completionCountLabel); + expect(completionCount.prop('values').completed).toEqual(completed); + expect(completionCount.prop('values').total).toEqual(total); }); it('a completion count with correct props', () => { wrapper = shallowWithIntl(); - const completionCount = wrapper.find('#completion-count'); - expect(completionCount.prop('aria-describedby')).toEqual(`${defaultProps.dataHeading}-heading`); + const completionCountSection = wrapper.find('.row .col').at(1).find(WrappedMessage); + + const completionCount = completionCountSection.dive({ context: { intl } }) + .dive({ context: { intl } }).find(FormattedMessage) + .dive({ context: { intl } }); + + expect(completionCount.prop('id')).toEqual(getCompletionCountID()); }); describe('checks with', () => { @@ -95,7 +119,7 @@ describe('CourseChecklist', () => { }); testChecklistData.forEach((check) => { - describe(`check with id ${check.id}`, () => { + describe(`check with id '${check.id}'`, () => { wrapper = shallowWithIntl(); const checkItem = wrapper.find(`#checklist-item-${check.id}`); @@ -104,7 +128,13 @@ describe('CourseChecklist', () => { }); it('has correct icon', () => { - const icon = checkItem.find(Icon); + const iconSection = checkItem.find(WrappedMessage).at(0); + + const icon = iconSection.dive({ context: { intl } }) + .dive({ context: { intl } }) + .find(FormattedMessage) + .dive({ context: { intl } }) + .find(Icon); expect(icon).toHaveLength(1); expect(icon.prop('id')).toEqual(`icon-${check.id}`); @@ -124,12 +154,40 @@ describe('CourseChecklist', () => { it('has correct short description', () => { expect(checkItem - .containsMatchingElement(
{check.shortDescription}
)).toEqual(true); + .containsMatchingElement(
)).toEqual(true); }); it('has correct long description', () => { expect(checkItem - .containsMatchingElement(
{check.longDescription}
)).toEqual(true); + .containsMatchingElement(
)).toEqual(true); + }); + + describe('has correct link', () => { + const shouldShowLink = wrapper.instance().shouldShowUpdateLink(check.id); + + if (shouldShowLink) { + it('with a Hyperlink', () => { + const updateLink = checkItem.find(Hyperlink); + + expect(updateLink).toHaveLength(1); + }); + + it('a Hyperlink with correct props', () => { + const updateLink = checkItem.find(Hyperlink); + expect(updateLink.prop('className')).toEqual(expect.stringContaining('btn')); + expect(updateLink.prop('className')).toEqual(expect.stringContaining('btn-primary')); + expect(updateLink.prop('className')).toEqual(expect.stringContaining('checklist-item-link')); + + const updateLinkContent = shallowWithIntl(updateLink.prop('content'), { context: { intl } }); + expect(updateLinkContent.prop('message')).toEqual(messages.updateLinkLabel); + }); + } else { + it('without a Hyperlink', () => { + const updateLink = checkItem.find(Hyperlink); + + expect(updateLink).toHaveLength(0); + }); + } }); }); }); @@ -139,25 +197,30 @@ describe('CourseChecklist', () => { describe('behaves', () => { const emptyProps = { data: {}, - dataHeading: '', + dataHeading: , dataList: [], + idPrefix: '', + links: { + course_updates: '', + grading_policy: '', + certificates: '', + settings: '', + }, }; it('has correct intitial state', () => { wrapper = shallowWithIntl(); - expect(wrapper.state('headingID')).toEqual(''); expect(wrapper.state('checks')).toEqual([]); - expect(wrapper.state('totalChecks')).toEqual(0); + expect(wrapper.state('totalCompletedChecks')).toEqual(0); expect(wrapper.state('values')).toEqual({}); }); it('has correct state after componentWillMount', () => { wrapper = shallowWithIntl(); - expect(wrapper.state('headingID')).toEqual(`${defaultProps.dataHeading.split(/\s/).join('-')}-heading`); expect(wrapper.state('checks')).toEqual(defaultProps.dataList); - expect(wrapper.state('totalChecks')).toEqual(Object.values(validatedValues).filter(value => value).length); + expect(wrapper.state('totalCompletedChecks')).toEqual(Object.values(validatedValues).filter(value => value).length); expect(wrapper.state('values')).toEqual(validatedValues); }); @@ -168,10 +231,13 @@ describe('CourseChecklist', () => { ...defaultProps, }); - expect(wrapper.state('headingID')).toEqual(''); expect(wrapper.state('checks')).toEqual(defaultProps.dataList); - expect(wrapper.state('totalChecks')).toEqual(Object.values(validatedValues).filter(value => value).length); + expect(wrapper.state('totalCompletedChecks')).toEqual(Object.values(validatedValues).filter(value => value).length); expect(wrapper.state('values')).toEqual(validatedValues); }); + + it('getUpdateLinkDestination returns null for unknown checklist item', () => { + expect(wrapper.instance().getUpdateLinkDestination('test')).toBeNull(); + }); }); }); diff --git a/src/components/CourseChecklist/container.jsx b/src/components/CourseChecklist/container.jsx index d3a0b307..f243f7ee 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 => ({ - courseDetails: state.studioDetails.course, + links: state.studioDetails.links, }); const mapDispatchToProps = () => ({}); diff --git a/src/components/CourseChecklist/displayMessages.jsx b/src/components/CourseChecklist/displayMessages.jsx new file mode 100644 index 00000000..3fa27c30 --- /dev/null +++ b/src/components/CourseChecklist/displayMessages.jsx @@ -0,0 +1,126 @@ +import { defineMessages } from 'react-intl'; + +const messages = defineMessages({ + welcomeMessageShortDescription: { + id: 'welcomeMessageShortDescription', + defaultMessage: 'Add a Welcome Message', + description: 'Label for a section that describes a welcome message for a course', + }, + welcomeMessageLongDescription: { + id: 'welcomeMessageLongDescription', + defaultMessage: 'Personally welcome learners into your course and prepare learners for a positive course experience.', + description: 'Description for a section that prompts a user to enter a welcome message for a course', + }, + gradingPolicyShortDescription: { + id: 'gradingPolicyShortDescription', + defaultMessage: 'Create Your Course Grading Policy', + description: 'Label for a section that describes a grading policy for a course', + }, + gradingPolicyLongDescription: { + id: 'gradingPolicyLongDescription', + defaultMessage: 'Establish your grading policy, including assignment types and passing score. All assignments add up to 100%.', + description: 'Description for a section that prompts a user to enter a grading policy for a course', + }, + certificateShortDescription: { + id: 'certificateShortDescription', + defaultMessage: 'Enable Your Certificate', + description: 'Label for a section that describes a certificate for completing a course', + }, + certificateLongDescription: { + id: 'certificateLongDescription', + defaultMessage: 'Make sure that all text is correct, signatures have been uploaded, and the certificate has been activated.', + description: 'Description for a section that prompts a user to create a course completion certificate', + }, + courseDatesShortDescription: { + id: 'courseDatesShortDescription', + defaultMessage: 'Set Important Course Dates', + description: 'Label for a section that describes a certificate for completing a course', + }, + courseDatesLongDescription: { + id: 'courseDatesLongDescription', + defaultMessage: 'Establish your course schedule, including when the course starts and ends.', + description: 'Description for a section that prompts a user to set up a course schedule', + }, + assignmentDeadlinesShortDescription: { + id: 'assignmentDeadlinesShortDescription', + defaultMessage: 'Validate Assignment Deadlines', + description: 'Label for a section that describes course assignment deadlines', + }, + assignmentDeadlinesLongDescription: { + id: 'assignmentDeadlinesLongDescription', + defaultMessage: 'Ensure all assignment deadlines are between course start and end dates.', + description: 'Description for a section that prompts a user to enter course assignment deadlines', + }, + videoDurationShortDescription: { + id: 'videoDurationShortDescription', + defaultMessage: 'Video Duration', + description: 'Label for a section that describes video durations', + }, + videoDurationLongDescription: { + id: 'videoDurationLongDescription', + defaultMessage: 'Learners engage best with shorter video content followed by opportunities to practice. 80% or more of course videos should be less than 10 minutes in duration.', + description: 'Description for a section that prompts a user to follow best practices for video length', + }, + mobileFriendlyVideoShortDescription: { + id: 'mobileFriendlyVideoShortDescription', + defaultMessage: 'Mobile Friendly Video', + description: 'Label for a section that describes mobile friendly videos', + }, + mobileFriendlyVideoLongDescription: { + id: 'mobileFriendlyVideoLongDescription', + defaultMessage: '90% or more of course videos are mobile friendly.', + description: 'Description for a section that prompts a user to follow best practices for mobile friendly videos', + }, + diverseSequencesShortDescription: { + id: 'diverseSequencesShortDescription', + defaultMessage: 'Diverse Learning Sequences', + description: 'Label for a section that describes diverse sequences of educational content', + }, + diverseSequencesLongDescription: { + id: 'diverseSequencesLongDescription', + defaultMessage: '90% or more of course videos are mobile friendly.', + description: 'Description for a section that prompts a user to follow best practices diverse sequences of educational content', + }, + weekylHighlightsShortDescription: { + id: 'weekylHighlightsShortDescription', + defaultMessage: 'Weekly Highlights', + description: 'Label for a section that describes weekly highlights', + }, + weeklyHightlightsLongDescription: { + id: 'weeklyHightlightsLongDescription', + defaultMessage: 'Weekly highlights are enabled and populated with text. These highlights keep learners engaged and on-track, no matter where they are in your course.', + description: 'Description for a section that prompts a user to follow best practices for course weekly highlights', + }, + unitDepthShortDescription: { + id: 'unitDepthShortDescription', + defaultMessage: 'Unit Depth', + description: 'Label for a section that describes course unit depth', + }, + unitDepthLongDescription: { + id: 'unitDepthLongDescription', + defaultMessage: 'Breaking up course content into manageable pieces promotes learner engagement. We recommend units contain no more than 3 components.', + description: 'Description for a section that prompts a user to follow best practices for course unit depth', + }, + updateLinkLabel: { + id: 'updateLinkLabel', + defaultMessage: 'Update', + description: 'Label for a link that takes the user to a page where they can update settings', + }, + completionCountLabel: { + id: 'completionCountLabel', + defaultMessage: '{completed}/{total} completed', + description: 'Label that describes how many tasks have been completed out of a total number of tasks', + }, + completedItemLabel: { + id: 'completedItemLabel', + defaultMessage: 'completed', + description: 'Label that describes a completed task', + }, + uncompletedItemLabel: { + id: 'uncompletedItemLabel', + defaultMessage: 'uncompleted', + description: 'Label that describes an uncompleted task', + }, +}); + +export default messages; diff --git a/src/components/CourseChecklist/index.jsx b/src/components/CourseChecklist/index.jsx index 28c0e932..92fbd731 100644 --- a/src/components/CourseChecklist/index.jsx +++ b/src/components/CourseChecklist/index.jsx @@ -1,75 +1,152 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { Icon } from '@edx/paragon'; import classNames from 'classnames'; +import { elementType } from 'airbnb-prop-types'; import FontAwesomeStyles from 'font-awesome/css/font-awesome.min.css'; +import { Hyperlink, Icon } from '@edx/paragon'; +import PropTypes from 'prop-types'; +import React from 'react'; + import getFilteredChecklist from '../../utils/CourseChecklist/getFilteredChecklist'; import getValidatedValue from '../../utils/CourseChecklist/getValidatedValue'; +import messages from './displayMessages'; import styles from './CourseChecklist.scss'; +import WrappedMessage from '../../utils/i18n/formattedMessageWrapper'; class CourseChecklist extends React.Component { constructor(props) { super(props); this.state = { - headingID: '', checks: [], - totalChecks: 0, + totalCompletedChecks: 0, values: {}, }; } componentWillMount() { this.updateChecklistState(this.props); - - if (this.props.dataHeading) { - this.setState({ - headingID: `${this.props.dataHeading.split(/\s/).join('-')}-heading`, - }); - } } componentWillReceiveProps(nextProps) { this.updateChecklistState(nextProps); } + getCompletionCountID = () => (`${this.props.idPrefix.split(/\s/).join('-')}-completion-count`); + getHeading = () => ( -

- {this.props.dataHeading} +

+ {this.props.dataHeading}

) - getCompletionCount = () => { - const totalChecks = Object.values(this.state.checks).length; + const totalCompletedChecks = Object.values(this.state.checks).length; return ( -
{`${this.state.totalChecks}/${totalChecks} completed`}
+ + {displayText => + (
+ {displayText} +
) + } +
); } - getIconClassNames = (check) => { - const isComplete = this.state.values[check.id]; + getCompletionIcon = (checkID) => { + const isCompleted = this.isCheckCompleted(checkID); + const message = isCompleted ? messages.completedItemLabel : messages.uncompletedItemLabel; - return isComplete ? classNames('fa-check-circle', 'text-success') : classNames('fa-circle-thin', styles['checklist-icon-incomplete']); + return ( + + {displayText => ( + + ) + } + + ); } - getListItems = () => - ( - this.state.checks.map((check) => { - const isComplete = this.state.values[check.id]; - const itemColorClassName = isComplete ? styles['checklist-item-complete'] : styles['checklist-item-incomplete']; - return ( -
- + getCompletionIconClassNames = isCompleted => ( + isCompleted ? ['fa-check-circle', 'text-success'] : ['fa-circle-thin', styles['checklist-icon-incomplete']] + ); + + getChecklistItemColorClassName = isCompleted => ( + isCompleted ? styles['checklist-item-complete'] : styles['checklist-item-incomplete'] + ); + + getShortDescription = checkID => ( +
+ +
+ ); + + getLongDescription = checkID => ( +
+ +
+ ) + + 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`; + default: return null; + } + } + + getUpdateLink = checkID => ( +
+ } + destination={this.getUpdateLinkDestination(checkID)} + /> +
+ ); + + getListItems = () => ( + this.state.checks.map((check) => { + const isCompleted = this.isCheckCompleted(check.id); + + return ( +
+
+ {this.getCompletionIcon(check.id)}
-
{check.shortDescription}
-
{check.longDescription}
+ {this.getShortDescription(check.id)} + {this.getLongDescription(check.id)}
+ {this.shouldShowUpdateLink(check.id) ? this.getUpdateLink(check.id) : null}
- ); - }) - ); +
+ ); + }) + ); updateChecklistState(props) { if (Object.keys(props.data).length > 0) { @@ -77,13 +154,13 @@ class CourseChecklist extends React.Component { props.dataList, props.data.is_self_paced); const values = {}; - let totalChecks = 0; + let totalCompletedChecks = 0; checks.forEach((check) => { const value = getValidatedValue(props, check.id); if (value) { - totalChecks += 1; + totalCompletedChecks += 1; } values[check.id] = value; @@ -91,12 +168,24 @@ class CourseChecklist extends React.Component { this.setState({ checks, - totalChecks, + totalCompletedChecks, values, }); } } + isCheckCompleted = checkID => (this.state.values[checkID]) + + shouldShowUpdateLink = (checkID) => { + switch (checkID) { + case 'welcomeMessage': return true; + case 'gradingPolicy': return true; + case 'certificate': return true; + case 'courseDates': return true; + default: return false; + } + } + render() { return (
@@ -110,7 +199,11 @@ class CourseChecklist extends React.Component { {this.getCompletionCount()}
- {this.getListItems()} +
+
+ {this.getListItems()} +
+
); } @@ -170,7 +263,9 @@ CourseChecklist.propTypes = { is_self_paced: PropTypes.bool, }).isRequired, ]).isRequired, - dataHeading: PropTypes.string.isRequired, + dataHeading: elementType(WrappedMessage).isRequired, // 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, }; diff --git a/src/components/CourseChecklistPage/CourseChecklistPage.test.jsx b/src/components/CourseChecklistPage/CourseChecklistPage.test.jsx index 60e9de24..8fa29e99 100644 --- a/src/components/CourseChecklistPage/CourseChecklistPage.test.jsx +++ b/src/components/CourseChecklistPage/CourseChecklistPage.test.jsx @@ -3,8 +3,10 @@ import React from 'react'; import { courseDetails } from '../../utils/testConstants'; import CourseChecklistPage from '.'; import { launchChecklist, bestPracticesChecklist } from '../../utils/CourseChecklist/courseChecklistData'; +import messages from './displayMessages'; import { shallowWithIntl } from '../../utils/i18n/enzymeHelper'; import WrappedCourseChecklist from '../CourseChecklist/container'; +import WrappedMessage from '../../utils/i18n/formattedMessageWrapper'; let wrapper; @@ -45,21 +47,23 @@ describe('CourseChecklistPage', () => { const checklist = wrapper.find(WrappedCourseChecklist).at(0); - expect(checklist.prop('dataHeading')).toEqual(launchChecklist.heading); + expect(checklist.prop('dataHeading')).toEqual(); expect(checklist.prop('dataList')).toEqual(launchChecklist.data); expect(checklist.prop('data')).toEqual(testCourseLaunchData); + expect(checklist.prop('idPrefix')).toEqual('launchChecklist'); }); }); - describe('for the quality checklist with', () => { + describe('for the best practices checklist checklist with', () => { it('correct props', () => { wrapper = shallowWithIntl(); const checklist = wrapper.find(WrappedCourseChecklist).at(1); - expect(checklist.prop('dataHeading')).toEqual(bestPracticesChecklist.heading); + expect(checklist.prop('dataHeading')).toEqual(); expect(checklist.prop('dataList')).toEqual(bestPracticesChecklist.data); expect(checklist.prop('data')).toEqual(testCourseBestPracticesData); + expect(checklist.prop('idPrefix')).toEqual('bestPracticesChecklist'); }); }); }); @@ -89,9 +93,10 @@ describe('CourseChecklistPage', () => { const checklist = wrapper.find(WrappedCourseChecklist).at(0); - expect(checklist.prop('dataHeading')).toEqual(launchChecklist.heading); + expect(checklist.prop('dataHeading')).toEqual(); expect(checklist.prop('dataList')).toEqual(launchChecklist.data); expect(checklist.prop('data')).toEqual(testCourseLaunchData); + expect(checklist.prop('idPrefix')).toEqual('launchChecklist'); }); }); }); diff --git a/src/components/CourseChecklistPage/displayMessages.jsx b/src/components/CourseChecklistPage/displayMessages.jsx new file mode 100644 index 00000000..9fd74d10 --- /dev/null +++ b/src/components/CourseChecklistPage/displayMessages.jsx @@ -0,0 +1,16 @@ +import { defineMessages } from 'react-intl'; + +const messages = defineMessages({ + launchChecklistLabel: { + id: 'launchChecklistLabel', + defaultMessage: 'Launch Checklist', + description: 'Header text for a checklist that describes actions to have completed before a course should launch', + }, + bestPracticesChecklistLabel: { + id: 'bestPracticesChecklistLabel', + defaultMessage: 'Best Practices Checklist', + description: 'Header text for a checklist that describes best practices for a course', + }, +}); + +export default messages; diff --git a/src/components/CourseChecklistPage/index.jsx b/src/components/CourseChecklistPage/index.jsx index 0ec3baac..9a672292 100644 --- a/src/components/CourseChecklistPage/index.jsx +++ b/src/components/CourseChecklistPage/index.jsx @@ -2,7 +2,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import { launchChecklist, bestPracticesChecklist } from '../../utils/CourseChecklist/courseChecklistData'; +import messages from './displayMessages'; import WrappedCourseChecklist from '../CourseChecklist/container'; +import WrappedMessage from '../../utils/i18n/formattedMessageWrapper'; export default class CourseChecklistPage extends React.Component { componentDidMount() { @@ -13,18 +15,20 @@ export default class CourseChecklistPage extends React.Component { render() { const courseBestPracticesChecklist = ( } dataList={bestPracticesChecklist.data} data={this.props.courseBestPracticesData} + idPrefix="bestPracticesChecklist" /> ); return ( } dataList={launchChecklist.data} data={this.props.courseLaunchData} + idPrefix="launchChecklist" /> { this.props.studioDetails.enable_quality ? courseBestPracticesChecklist : null diff --git a/src/data/i18n/default/transifex_input.json b/src/data/i18n/default/transifex_input.json index 89ce780e..45610a4d 100644 --- a/src/data/i18n/default/transifex_input.json +++ b/src/data/i18n/default/transifex_input.json @@ -91,6 +91,32 @@ "assetsTableLearnMore": "Learn more.", "assetsTableDeleteWarning": "Deleting {displayName} cannot be undone.", "assetsTableDeleteConsequences": "Any links or references to this file will no longer work. {link}", + "welcomeMessageShortDescription": "Add a Welcome Message", + "welcomeMessageLongDescription": "Personally welcome learners into your course and prepare learners for a positive course experience.", + "gradingPolicyShortDescription": "Create Your Course Grading Policy", + "gradingPolicyLongDescription": "Establish your grading policy, including assignment types and passing score. All assignments add up to 100%.", + "certificateShortDescription": "Enable Your Certificate", + "certificateLongDescription": "Make sure that all text is correct, signatures have been uploaded, and the certificate has been activated.", + "courseDatesShortDescription": "Set Important Course Dates", + "courseDatesLongDescription": "Establish your course schedule, including when the course starts and ends.", + "assignmentDeadlinesShortDescription": "Validate Assignment Deadlines", + "assignmentDeadlinesLongDescription": "Ensure all assignment deadlines are between course start and end dates.", + "videoDurationShortDescription": "Video Duration", + "videoDurationLongDescription": "Learners engage best with shorter video content followed by opportunities to practice. 80% or more of course videos should be less than 10 minutes in duration.", + "mobileFriendlyVideoShortDescription": "Mobile Friendly Video", + "mobileFriendlyVideoLongDescription": "90% or more of course videos are mobile friendly.", + "diverseSequencesShortDescription": "Diverse Learning Sequences", + "diverseSequencesLongDescription": "90% or more of course videos are mobile friendly.", + "weekylHighlightsShortDescription": "Weekly Highlights", + "weeklyHightlightsLongDescription": "Weekly highlights are enabled and populated with text. These highlights keep learners engaged and on-track, no matter where they are in your course.", + "unitDepthShortDescription": "Unit Depth", + "unitDepthLongDescription": "Breaking up course content into manageable pieces promotes learner engagement. We recommend units contain no more than 3 components.", + "updateLinkLabel": "Update", + "completionCountLabel": "{completed}/{total} completed", + "completedItemLabel": "completed", + "uncompletedItemLabel": "uncompleted", + "launchChecklistLabel": "Launch Checklist", + "bestPracticesChecklistLabel": "Best Practices Checklist", "editImageModalAssetsListLoadingSpinner": "Loading spinner", "editImageModalAssetsListNoAssetsMessage": "0 images uploaded", "editImageModalAssetsListNoResultsMessage": "0 images found", diff --git a/src/utils/CourseChecklist/courseChecklistData.jsx b/src/utils/CourseChecklist/courseChecklistData.jsx index 3c4c1610..5aff4a2d 100644 --- a/src/utils/CourseChecklist/courseChecklistData.jsx +++ b/src/utils/CourseChecklist/courseChecklistData.jsx @@ -5,72 +5,50 @@ export const filters = { }; export const launchChecklist = { - heading: 'Launch Checklist', data: [ { id: 'welcomeMessage', - shortDescription: 'Add a Welcome Message', - longDescription: 'Personally welcome learners into your course and prepare learners for a positive course experience.', pacingTypeFilter: filters.ALL, }, { id: 'gradingPolicy', - shortDescription: 'Create Your Course Grading Policy', - longDescription: 'Establish your grading policy, including assignment types, passing score, and certificate eligibility. All assignments add up to 100%.', pacingTypeFilter: filters.ALL, }, { id: 'certificate', - shortDescription: 'Enable Your Certificate', - longDescription: 'Make sure that all text is correct, signatures have been uploaded, and the certificate has been activated in Studio.', pacingTypeFilter: filters.ALL, }, { id: 'courseDates', - shortDescription: 'Set Important Course Dates', - longDescription: 'Establish your course schedule, including when the course starts and ends.', pacingTypeFilter: filters.ALL, }, { id: 'assignmentDeadlines', - shortDescription: 'Validate Assignment Deadlines', - longDescription: 'Ensure all assignment deadlines are between course start and end dates.', pacingTypeFilter: filters.INSTRUCTOR_PACED, }, ], }; export const bestPracticesChecklist = { - heading: 'Best Practices Checklist', data: [ { id: 'videoDuration', - shortDescription: 'Video Duration', - longDescription: 'Learners engage best with shorter video content followed by opportunities to practice. 80% or more of course videos should be less than 10 minutes in duration.', pacingTypeFilter: filters.ALL, }, { id: 'mobileFriendlyVideo', - shortDescription: 'Mobile Friendly Video', - longDescription: '90% or more of course videos are mobile friendly.', pacingTypeFilter: filters.ALL, }, { id: 'diverseSequences', - shortDescription: 'Diverse Learning Sequences', - longDescription: 'Research shows that a diverse content experience drives learner engagement. We recommend 80% or more of each learning sequence or subsection includes multiple content types (such as video, discussion, or problem).', pacingTypeFilter: filters.ALL, }, { id: 'weeklyHighlights', - shortDescription: 'Weekly Highlights (Self Paced Courses Only)', - longDescription: 'Weekly highlights are enabled and populated with text. These highlights keep learners engaged and on-track, no matter where they are in your course.', pacingTypeFilter: filters.SELF_PACED, }, { id: 'unitDepth', - shortDescription: 'Unit Depth', - longDescription: 'Breaking up course content into manageable pieces promotes learner engagement. We recommend units contain no more than 3 components.', pacingTypeFilter: filters.ALL, }, ],