diff --git a/common/static/common/js/utils/clamp-html.js b/common/static/common/js/utils/clamp-html.js new file mode 100644 index 0000000000..c9e33660b4 --- /dev/null +++ b/common/static/common/js/utils/clamp-html.js @@ -0,0 +1,53 @@ +/** + * Used to ellipsize a section of arbitrary HTML after a specified number of words. + * + * Note: this will modify the DOM structure of root in place. + * To keep the original around, you may want to save the result of cloneNode(true) before calling this method. + * + * Known bug: This method will ignore any special whitespace in the source and simply output single spaces. + * Which means that will not be respected. This is not considered worth solving at time of writing. + * + * Returns how many words remain (or a negative number if the content got clamped) + */ +function clampHtmlByWords(root, wordsLeft) { + 'use strict'; + + var remaining = wordsLeft; + var nodes = Array.from(root.childNodes ? root.childNodes : []); + var words, chopped; + + // First, cut short any text in our node, as necessary + if (root.nodeName === '#text' && root.data) { + // split on words, ignoring any resulting empty strings + words = root.data.split(/\s+/).filter(Boolean); + if (remaining < 0) { + root.data = ''; // eslint-disable-line no-param-reassign + } else if (remaining > words.length) { + remaining -= words.length; + } else { + // OK, let's add an ellipsis and cut some of root.data + chopped = words.slice(0, remaining).join(' ') + '…'; + // But be careful to get any preceding space too + if (root.data.match(/^\s/)) { + chopped = ' ' + chopped; + } + root.data = chopped; // eslint-disable-line no-param-reassign + remaining = -1; + } + } + + // Now do the same for any child nodes + nodes.forEach(function(node) { + if (remaining < 0) { + root.removeChild(node); + } else { + remaining = clampHtmlByWords(node, remaining); + } + }); + + return remaining; +} + +module.exports = { + clampHtmlByWords: clampHtmlByWords +}; diff --git a/common/static/common/js/utils/clamp-html.test.jsx b/common/static/common/js/utils/clamp-html.test.jsx new file mode 100644 index 0000000000..381a8461ca --- /dev/null +++ b/common/static/common/js/utils/clamp-html.test.jsx @@ -0,0 +1,33 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { clampHtmlByWords } from './clamp-html'; + +let container; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); +}); + +afterEach(() => { + document.body.removeChild(container); + container = null; +}); + +describe('ClampHtml', () => { + test.each([ + ['', 0, ''], + ['a b', 0, '…'], + ['a b', 1, 'a…'], + ['a b c', 2, 'a b…'], + ['a aa ab b', 2, 'a aa…'], + ['a aa ab ac', 2, 'a aa…'], + ['a aa aaa', 2, 'a aa…'], + ['a aa aaa ab', 3, 'a aa aaa…'], + ['a aa ab b c', 4, 'a aa ab b…'], + ])('clamps by words: %s, %i', (input, wordsLeft, expected) => { + const div = ReactDOM.render(
, container); + clampHtmlByWords(div, wordsLeft); + expect(div.innerHTML).toEqual(expected); + }); +}); diff --git a/lms/static/sass/features/_course-experience.scss b/lms/static/sass/features/_course-experience.scss index 66ba712ea7..620441245e 100644 --- a/lms/static/sass/features/_course-experience.scss +++ b/lms/static/sass/features/_course-experience.scss @@ -201,6 +201,13 @@ border-color: transparent; } } + + .welcome-message-show-more { + float: right; + background-color: transparent; + color: $black; + cursor: pointer; + } } // Course sidebar @@ -406,7 +413,7 @@ #expand-collapse-outline-all-button { float: right; - background-color: $white; + background-color: transparent; color: $black; cursor: pointer; } diff --git a/openedx/features/course_experience/__init__.py b/openedx/features/course_experience/__init__.py index 5829ef695d..c84d302e2a 100644 --- a/openedx/features/course_experience/__init__.py +++ b/openedx/features/course_experience/__init__.py @@ -85,6 +85,9 @@ RELATIVE_DATES_FLAG = ExperimentWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'relative_date # Waffle flag to enable user calendar syncing CALENDAR_SYNC_FLAG = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'calendar_sync') +# Waffle flag to ellipsize course welcome messages if they are too long +SHORTEN_WELCOME_MESSAGE_FLAG = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'shorten_welcome_message') + def course_home_page_title(course): # pylint: disable=unused-argument """ diff --git a/openedx/features/course_experience/static/course_experience/fixtures/welcome-message-fragment.html b/openedx/features/course_experience/static/course_experience/fixtures/welcome-message-fragment.html index 95c718e577..e83f92403d 100644 --- a/openedx/features/course_experience/static/course_experience/fixtures/welcome-message-fragment.html +++ b/openedx/features/course_experience/static/course_experience/fixtures/welcome-message-fragment.html @@ -5,5 +5,28 @@ - This is a useful welcome message! + + + + diff --git a/openedx/features/course_experience/static/course_experience/js/WelcomeMessage.js b/openedx/features/course_experience/static/course_experience/js/WelcomeMessage.js index 4a2c130c49..d0a3683f1d 100644 --- a/openedx/features/course_experience/static/course_experience/js/WelcomeMessage.js +++ b/openedx/features/course_experience/static/course_experience/js/WelcomeMessage.js @@ -1,5 +1,7 @@ /* globals $ */ import 'jquery.cookie'; +import gettext from 'gettext'; // eslint-disable-line +import { clampHtmlByWords } from 'common/js/utils/clamp-html'; // eslint-disable-line export class WelcomeMessage { // eslint-disable-line import/prefer-default-export @@ -35,5 +37,29 @@ export class WelcomeMessage { // eslint-disable-line import/prefer-default-expo } } $('.dismiss-message button').click(() => WelcomeMessage.dismissWelcomeMessage(options.dismissUrl)); + + + // "Show More" support for welcome messages + const messageContent = document.querySelector('#welcome-message-content'); + const fullText = messageContent.innerHTML; + if (options.shortenWelcomeMessage && clampHtmlByWords(messageContent, 100) < 0) { + const showMoreButton = document.querySelector('#welcome-message-show-more'); + const shortText = messageContent.innerHTML; + + showMoreButton.removeAttribute('hidden'); + + showMoreButton.addEventListener('click', (event) => { + if (showMoreButton.getAttribute('data-state') === 'less') { + showMoreButton.textContent = gettext('Show More'); + messageContent.innerHTML = shortText; + showMoreButton.setAttribute('data-state', 'more'); + } else { + showMoreButton.textContent = gettext('Show Less'); + messageContent.innerHTML = fullText; + showMoreButton.setAttribute('data-state', 'less'); + } + event.stopImmediatePropagation(); + }); + } } } diff --git a/openedx/features/course_experience/static/course_experience/js/spec/WelcomeMessage_spec.js b/openedx/features/course_experience/static/course_experience/js/spec/WelcomeMessage_spec.js index 9f8c06fae6..9f5595a5c0 100644 --- a/openedx/features/course_experience/static/course_experience/js/spec/WelcomeMessage_spec.js +++ b/openedx/features/course_experience/static/course_experience/js/spec/WelcomeMessage_spec.js @@ -82,4 +82,27 @@ describe('Welcome Message factory', () => { requests.restore(); }); }); + + describe('Shortened welcome message', () => { + const endpointUrl = '/course/course_id/dismiss_message/'; + + beforeEach(() => { + loadFixtures('course_experience/fixtures/welcome-message-fragment.html'); + new WelcomeMessage({ // eslint-disable-line no-new + dismissUrl: endpointUrl, + shortenWelcomeMessage: true, + }); + }); + + it('Shortened message can be toggled', () => { + expect($('#welcome-message-content').text()).toContain('…'); + expect($('#welcome-message-show-more').text()).toContain('Show More'); + $('#welcome-message-show-more').click(); + expect($('#welcome-message-content').text()).not.toContain('…'); + expect($('#welcome-message-show-more').text()).toContain('Show Less'); + $('#welcome-message-show-more').click(); + expect($('#welcome-message-content').text()).toContain('…'); + expect($('#welcome-message-show-more').text()).toContain('Show More'); + }); + }); }); diff --git a/openedx/features/course_experience/templates/course_experience/welcome-message-fragment.html b/openedx/features/course_experience/templates/course_experience/welcome-message-fragment.html index b20e8ee758..5e2568a824 100644 --- a/openedx/features/course_experience/templates/course_experience/welcome-message-fragment.html +++ b/openedx/features/course_experience/templates/course_experience/welcome-message-fragment.html @@ -5,7 +5,7 @@ <%! from django.utils.translation import ugettext as _ -from openedx.core.djangolib.js_utils import js_escaped_string +from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string from openedx.core.djangolib.markup import HTML %> @@ -18,12 +18,25 @@ from openedx.core.djangolib.markup import HTML - ${HTML(welcome_message_html)} + + + %block> <%static:webpack entry="WelcomeMessage"> new WelcomeMessage({ dismissUrl: "${dismiss_url | n, js_escaped_string}", + shortenWelcomeMessage: ${shorten_welcome_message | n, dump_js_escaped_json}, }); %static:webpack> diff --git a/openedx/features/course_experience/views/welcome_message.py b/openedx/features/course_experience/views/welcome_message.py index 324ad5b367..ce5636059d 100644 --- a/openedx/features/course_experience/views/welcome_message.py +++ b/openedx/features/course_experience/views/welcome_message.py @@ -14,6 +14,7 @@ from web_fragments.fragment import Fragment from lms.djangoapps.courseware.courses import get_course_with_access from openedx.core.djangoapps.plugin_api.views import EdxFragmentView from openedx.core.djangoapps.user_api.course_tag.api import get_course_tag, set_course_tag +from openedx.features.course_experience import SHORTEN_WELCOME_MESSAGE_FLAG from .course_updates import get_ordered_updates @@ -43,6 +44,7 @@ class WelcomeMessageFragmentView(EdxFragmentView): context = { 'dismiss_url': dismiss_url, 'welcome_message_html': welcome_message_html, + 'shorten_welcome_message': SHORTEN_WELCOME_MESSAGE_FLAG.is_enabled(course_key), } if get_course_tag(request.user, course_key, PREFERENCE_KEY) == 'False':