diff --git a/Gruntfile.js b/Gruntfile.js index 7ec6298db353..2aa2c1266588 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -38,6 +38,7 @@ module.exports = function (grunt) { 'cloudcare/form_entry', 'hqwebapp/bootstrap3', 'hqwebapp/bootstrap5', + 'hqwebapp/components', 'case_importer', ]; diff --git a/corehq/apps/hqwebapp/static/hqwebapp/js/components/quill.css b/corehq/apps/hqwebapp/static/hqwebapp/js/components/quill.css new file mode 100644 index 000000000000..612a62894673 --- /dev/null +++ b/corehq/apps/hqwebapp/static/hqwebapp/js/components/quill.css @@ -0,0 +1,50 @@ +.ql-snow .ql-picker.ql-font .ql-picker-label[data-value]::before, +.ql-snow .ql-picker.ql-font .ql-picker-item[data-value]::before { + content: attr(data-value) !important; +} + +.ql-editor { + height: 250px; +} + +.ql-toolbar { + border-top-left-radius: 3px; + border-top-right-radius: 3px; +} + +.ql-container { + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; +} + +.ql-picker-label { + overflow: hidden; +} + +.ql-picker-item[data-value="Ariel"] { + font-family: "Arial", sans-serif; +} + +.ql-picker-item[data-value="Courier New"] { + font-family: "Courier New", sans-serif; +} + +.ql-picker-item[data-value="Georgia"] { + font-family: "Georgia", serif; +} + +.ql-picker-item[data-value="Lucida Sans Unicode"] { + font-family: "Lucida Sans Unicode", sans-serif; +} + +.ql-picker-item[data-value="Tahoma"] { + font-family: "Tahoma", sans-serif; +} + +.ql-picker-item[data-value="Times New Roman"] { + font-family: "Times New Roman", serif; +} + +.ql-picker-item[data-value="Verdana"] { + font-family: "Verdana", sans-serif; +} diff --git a/corehq/apps/hqwebapp/static/hqwebapp/js/components/rich_text_knockout_bindings.js b/corehq/apps/hqwebapp/static/hqwebapp/js/components/rich_text_knockout_bindings.js new file mode 100644 index 000000000000..5b37df0ac759 --- /dev/null +++ b/corehq/apps/hqwebapp/static/hqwebapp/js/components/rich_text_knockout_bindings.js @@ -0,0 +1,183 @@ +import ko from "knockout"; + +import "quill/dist/quill.snow.css"; +import "hqwebapp/js/components/quill.css"; +import Quill from 'quill'; +import Toolbar from "quill/modules/toolbar"; +import Snow from "quill/themes/snow"; +import Bold from "quill/formats/bold"; +import Italic from "quill/formats/italic"; +import Header from "quill/formats/header"; +import {QuillDeltaToHtmlConverter} from 'quill-delta-to-html-upate'; + +import initialPageData from "hqwebapp/js/initial_page_data"; + +Quill.register({ + "modules/toolbar": Toolbar, + "themes/snow": Snow, + "formats/bold": Bold, + "formats/italic": Italic, + "formats/header": Header, +}); + +function imageHandler() { + const self = this; + const input = document.createElement("input"); + input.accept = "image/png, image/jpeg"; + input.type = "file"; + input.onchange = function (onChangeEvent) { + const file = onChangeEvent.target.files[0]; + const uploadUrl = initialPageData.reverse("upload_messaging_image"); + let formData = new FormData(); + + formData.append("upload", file, file.name); + const spinner = $('
').appendTo('body'); + fetch(uploadUrl, { + method: "POST", + body: formData, + headers: { + "X-CSRFTOKEN": $("#csrfTokenContainer").val(), + }, + }) + .then(function (response) { + if (!response.ok) { + if (response.status === 400) { + return response.json().then(function (errorJson) { + throw Error(gettext('Failed to upload image: ') + errorJson.error.message); + }); + } + throw Error(gettext('Failed to upload image. Please try again.')); + } + return response.json(); + }) + .then(function (data) { + const Delta = Quill.import("delta"); + const selectionRange = self.quill.getSelection(true); + self.quill.updateContents( + new Delta() + .retain(selectionRange.index) + .delete(selectionRange.length) + .insert({ + image: data.url, + }, { + alt: file.name, + }), + ); + }) + .catch(function (error) { + alert(error.message || gettext('Failed to upload image. Please try again.')); + }) + .finally(function () { + spinner.remove(); + }); + }; + input.click(); +} + +function linkHandler(value) { + if (value) { + let href = prompt('Enter the URL').trim(); + if (!href.match(/^\w+:/)) { + href = "http://" + href; + } + this.quill.format('link', href); + } +} + +const converterOptions = { + inlineStyles: true, + linkTarget: "", +}; + +const orderedListTypes = ["1", "a", "i"]; +function updateListType(element, level) { + element.childNodes.forEach(function (child) { + if (child.tagName && child.tagName.toLowerCase() === 'ol') { + child.type = orderedListTypes[level % orderedListTypes.length]; + updateListType(child, level + 1); + } else { + updateListType(child, level); + } + }); +} + +const parser = new DOMParser(); + +function deltaToHtml(delta) { + // nice for adding more test data + // console.log(JSON.stringify(delta, null, 4)); + if (!delta) { + return + } + const converter = new QuillDeltaToHtmlConverter(delta.ops, converterOptions); + const body = converter.convert(); + + const xmlDoc = parser.parseFromString(body, "text/html"); + updateListType(xmlDoc, 0); + const html = `${xmlDoc.querySelector("body").innerHTML}`; + return html; +} + +ko.bindingHandlers.richEditor = { + init: function (element, valueAccessor) { + const fontFamilyArr = [ + "Arial", + "Courier New", + "Georgia", + "Lucida Sans Unicode", + "Tahoma", + "Times New Roman", + "Trebuchet MS", + "Verdana", + ]; + let fonts = Quill.import("attributors/style/font"); + fonts.whitelist = fontFamilyArr; + Quill.register(fonts, true); + + const toolbar = element.parentElement.querySelector("#ql-toolbar"); + const editor = new Quill(element, { + modules: { + toolbar: { + container: toolbar, + handlers: { + image: imageHandler, + link: linkHandler, + }, + }, + }, + theme: "snow", + }); + + const value = ko.utils.unwrapObservable(valueAccessor()); + editor.clipboard.dangerouslyPasteHTML(value); + + let isSubscriberChange = false; + let isEditorChange = false; + + editor.on("text-change", function () { + if (!isSubscriberChange) { + isEditorChange = true; + const html = deltaToHtml(editor.getContents()); + valueAccessor()(html); + isEditorChange = false; + } + }); + + valueAccessor().subscribe(function (value) { + if (!isEditorChange) { + isSubscriberChange = true; + editor.clipboard.dangerouslyPasteHTML(value); + isSubscriberChange = false; + } + }); + + if (initialPageData.get("read_only_mode")) { + editor.enable(false); + } + }, +}; + +export { + deltaToHtml, + updateListType, +}; diff --git a/corehq/apps/hqwebapp/static/hqwebapp/spec/components/main.js b/corehq/apps/hqwebapp/static/hqwebapp/spec/components/main.js new file mode 100644 index 000000000000..b9a7a4a3f971 --- /dev/null +++ b/corehq/apps/hqwebapp/static/hqwebapp/spec/components/main.js @@ -0,0 +1,6 @@ +import hqMocha from "mocha/js/main"; +import "commcarehq"; + +import "hqwebapp/spec/components/rich_text_spec"; + +hqMocha.run(); diff --git a/corehq/apps/hqwebapp/static/hqwebapp/spec/components/rich_text_spec.js b/corehq/apps/hqwebapp/static/hqwebapp/spec/components/rich_text_spec.js new file mode 100644 index 000000000000..26ba9cadddd5 --- /dev/null +++ b/corehq/apps/hqwebapp/static/hqwebapp/spec/components/rich_text_spec.js @@ -0,0 +1,334 @@ +import { deltaToHtml, updateListType } from "hqwebapp/js/components/rich_text_knockout_bindings"; + +describe('Rich Text Editor', function () { + describe('updateListType', function () { + const parser = new DOMParser(); + + it('add type 1 to first level', function () { + const html = "
  1. item
"; + const xmlDoc = parser.parseFromString(html, "text/html"); + updateListType(xmlDoc, 0); + const updatedHtml = xmlDoc.querySelector("body").innerHTML; + assert.equal('
  1. item
', updatedHtml); + }); + + it('add type 1 to first level for multiple lists', function () { + const html = "
  1. item

p

  1. item
"; + const xmlDoc = parser.parseFromString(html, "text/html"); + updateListType(xmlDoc, 0); + const updatedHtml = xmlDoc.querySelector("body").innerHTML; + assert.equal('
  1. item

p

  1. item
', updatedHtml); + }); + + it('add type a to second level', function () { + const html = "
  1. item
    1. item
"; + const xmlDoc = parser.parseFromString(html, "text/html"); + updateListType(xmlDoc, 0); + const updatedHtml = xmlDoc.querySelector("body").innerHTML; + assert.equal('
  1. item
    1. item
', updatedHtml); + }); + + it("handle invalid html", function () { + const html = "
  1. item
  2. item
"; + const xmlDoc = parser.parseFromString(html, "text/html"); + updateListType(xmlDoc, 0); + const updatedHtml = xmlDoc.querySelector("body").innerHTML; + assert.equal('
  1. item
  2. item
', updatedHtml); + }); + + }); + + describe('deltaToHtml', function () { + it('unordered list', function () { + const delta = + { + "ops": [ + { + "insert": "item", + }, + { + "attributes": { + "list": "bullet", + }, + "insert": "\n", + }, + { + "insert": "item", + }, + { + "attributes": { + "list": "bullet", + }, + "insert": "\n", + }, + ], + }; + const html = deltaToHtml(delta); + assert.equal("", html); + }); + + it('ordered list', function () { + const delta = + { + "ops": [ + { + "insert": "item", + }, + { + "attributes": { + "list": "ordered", + }, + "insert": "\n", + }, + { + "insert": "item", + }, + { + "attributes": { + "list": "ordered", + }, + "insert": "\n", + }, + ], + }; + const html = deltaToHtml(delta); + assert.equal('
  1. item
  2. item
', html); + }); + + it('ordered list indent', function () { + const delta = + { + "ops": [ + { + "insert": "item", + }, + { + "attributes": { + "list": "ordered", + }, + "insert": "\n", + }, + { + "insert": "item", + }, + { + "attributes": { + "indent": 1, + "list": "ordered", + }, + "insert": "\n", + }, + ], + }; + const html = deltaToHtml(delta); + assert.equal('
  1. item
    1. item
', html); + }); + + it('text sizes', function () { + const delta = + { + "ops": [ + { + "attributes": { + "size": "small", + }, + "insert": "small", + }, + { + "insert": "\ndefaul\n", + }, + { + "attributes": { + "size": "large", + }, + "insert": "big", + }, + { + "insert": "\n", + }, + { + "attributes": { + "size": "huge", + }, + "insert": "huge", + }, + { + "insert": "\n", + }, + ], + }; + const html = deltaToHtml(delta); + assert.equal("

small
defaul
" + + "big
huge" + + "

", html); + }); + + it('text align', function () { + const delta = + { + "ops": [ + { + "insert": "left\nright", + }, + { + "attributes": { + "align": "right", + }, + "insert": "\n", + }, + { + "insert": "center", + }, + { + "attributes": { + "align": "center", + }, + "insert": "\n", + }, + { + "insert": "justified", + }, + { + "attributes": { + "align": "justify", + }, + "insert": "\n", + }, + ], + }; + const html = deltaToHtml(delta); + assert.equal("

left

right

" + + "

center

justified

" + + "", html); + }); + + it('text font', function () { + const delta = + { + "ops": [ + { + "attributes": { + "font": "Arial", + }, + "insert": "Arial", + }, + { + "insert": "\n", + }, + { + "attributes": { + "font": "Times New Roman", + }, + "insert": "Times New Roman", + }, + { + "insert": "\n", + }, + ], + }; + const html = deltaToHtml(delta); + assert.equal("

Arial
" + + "Times New Roman

", html); + }); + + it('text color', function () { + const delta = + { + "ops": [ + { + "attributes": { + "color": "#e60000", + }, + "insert": "color", + }, + { + "insert": "\n", + }, + { + "attributes": { + "background": "#e60000", + }, + "insert": "background", + }, + { + "insert": "\n", + }, + ], + }; + const html = deltaToHtml(delta); + assert.equal("

color
" + + "background

", html); + }); + + it('link', function () { + const delta = + { + "ops": [ + { + "attributes": { + "link": "https://dimagi.com", + }, + "insert": "link", + }, + { + "insert": "\n", + }, + ], + }; + const html = deltaToHtml(delta); + assert.equal("

link

", html); + }); + + it('should handle missing ops', function () { + const delta = {}; + const html = deltaToHtml(delta); + assert.equal("", html); + }); + + it('should handle ops without insert', function () { + const delta = { + "ops": [ + { + "attributes": { + "color": "#e60000", + }, + }, + ], + }; + const html = deltaToHtml(delta); + assert.equal("", html); + }); + + it('should handle invalid color codes', function () { + const delta = { + "ops": [ + { + "attributes": { + "color": "invalid-color", + }, + "insert": "text", + }, + {"insert": "\n"}, + ], + }; + const html = deltaToHtml(delta); + assert.equal("

text

", html); + }); + + it('should sanitize malicious links', function () { + const delta = { + "ops": [ + { + "attributes": { + "link": "javascript:alert('xss')", + }, + "insert": "malicious link", + }, + {"insert": "\n"}, + ], + }; + const html = deltaToHtml(delta); + assert.equal('

malicious link

', html); + }); + }); +}); diff --git a/corehq/apps/hqwebapp/templates/hqwebapp/spec/components/mocha.html b/corehq/apps/hqwebapp/templates/hqwebapp/spec/components/mocha.html new file mode 100644 index 000000000000..f292f450e80f --- /dev/null +++ b/corehq/apps/hqwebapp/templates/hqwebapp/spec/components/mocha.html @@ -0,0 +1,3 @@ +{% extends "mocha/base.html" %} +{% load hq_shared_tags %} +{% js_entry_b3 "hqwebapp/spec/components/main" %} diff --git a/corehq/messaging/scheduling/const.py b/corehq/messaging/scheduling/const.py index 92282f6d6493..cc9310f1cafb 100644 --- a/corehq/messaging/scheduling/const.py +++ b/corehq/messaging/scheduling/const.py @@ -8,6 +8,7 @@ ALLOWED_HTML_TAGS = { + "h1", "h2", "h3", "h4", @@ -38,25 +39,23 @@ "tbody", "tr", "td", - "html", - "head", "meta", "title", - "body", "style", } ALLOWED_HTML_ATTRIBUTES = { - 'a': ['href', 'title'], + 'a': ['href', 'title', 'style', 'class'], 'abbr': ['title'], 'acronym': ['title'], + 'p': ['style', 'class'], 'div': ['style', 'class'], 'span': ['style', 'class'], 'img': ['style', 'src', 'width', 'height', 'class'], 'figcaption': ['style', 'class'], 'figure': ['style', 'class'], - 'table': ['class', 'role','cellspacing', 'cellpadding', 'border', 'align', 'width'], + 'table': ['class', 'role', 'cellspacing', 'cellpadding', 'border', 'align', 'width'], 'td': ['valign'], 'meta': ['charset', 'name', 'viewport', 'content', 'initial-scale'] } diff --git a/corehq/messaging/scheduling/forms.py b/corehq/messaging/scheduling/forms.py index a25032b927db..70b6af878bf1 100644 --- a/corehq/messaging/scheduling/forms.py +++ b/corehq/messaging/scheduling/forms.py @@ -495,13 +495,26 @@ def _distill_rich_text_email(self): plaintext_message[lang] = soup.find("body").get_text() except AttributeError: plaintext_message[lang] = strip_tags(content) - html_message[lang] = bleach.clean( - content, + + # bleach.clean throws out html, head and body tags not matter what to keep them we need to clean them + # separately + bleached_head = "" + if soup.head: + bleached_head = bleach.clean( + soup.head.decode_contents(), + attributes=ALLOWED_HTML_ATTRIBUTES, + tags=ALLOWED_HTML_TAGS, + css_sanitizer=css_sanitizer, + strip=True, + ) + bleached_body = bleach.clean( + soup.body.decode_contents(), attributes=ALLOWED_HTML_ATTRIBUTES, tags=ALLOWED_HTML_TAGS, css_sanitizer=css_sanitizer, strip=True, ) + html_message[lang] = f"{bleached_head}{bleached_body}" return EmailContent( subject=self.cleaned_data['subject'], message=plaintext_message, diff --git a/corehq/messaging/scheduling/static/scheduling/js/create_schedule.js b/corehq/messaging/scheduling/static/scheduling/js/create_schedule.js index c7b55647d7f4..e5630906e1a3 100644 --- a/corehq/messaging/scheduling/static/scheduling/js/create_schedule.js +++ b/corehq/messaging/scheduling/static/scheduling/js/create_schedule.js @@ -3,39 +3,41 @@ import ko from "knockout"; import "jquery-ui/ui/widgets/datepicker"; import "bootstrap-timepicker/js/bootstrap-timepicker"; + +import "hqwebapp/js/components/rich_text_knockout_bindings"; import "hqwebapp/js/components/select_toggle"; import initialPageData from "hqwebapp/js/initial_page_data"; import select2Handler from "hqwebapp/js/select2_handler"; ko.bindingHandlers.useTimePicker = { - init: function (element, valueAccessor, allBindings, viewModel, bindingContext) { + init: function (element) { $(element).timepicker({ showMeridian: false, showSeconds: false, defaultTime: $(element).val() || '', }); }, - update: function (element, valueAccessor, allBindings, viewModel, bindingContext) {}, + update: function () {}, }; -var MessageViewModel = function (language_code, message) { +var MessageViewModel = function (languageCode, message) { var self = this; - self.language_code = ko.observable(language_code); + self.language_code = ko.observable(languageCode); self.message = ko.observable(message); self.html_message = ko.observable(message); }; -var TranslationViewModel = function (language_codes, translations) { +var TranslationViewModel = function (languageCodes, translations) { var self = this; if (typeof translations === 'string') { translations = JSON.parse(translations); } translations = translations || {}; - var initial_translate = !($.isEmptyObject(translations) || '*' in translations); + var initialTranslate = !($.isEmptyObject(translations) || '*' in translations); - self.translate = ko.observable(initial_translate); + self.translate = ko.observable(initialTranslate); self.nonTranslatedMessage = ko.observable(translations['*']); self.translatedMessages = ko.observableArray(); @@ -79,58 +81,58 @@ var TranslationViewModel = function (language_codes, translations) { }); self.loadInitialTranslatedMessages = function () { - language_codes.forEach(function (language_code) { - self.translatedMessages.push(new MessageViewModel(language_code, translations[language_code])); + languageCodes.forEach(function (languageCode) { + self.translatedMessages.push(new MessageViewModel(languageCode, translations[languageCode])); }); }; self.loadInitialTranslatedMessages(); }; -var ContentViewModel = function (initial_values) { +var ContentViewModel = function (initialValues) { var self = this; self.subject = new TranslationViewModel( initialPageData.get("language_list"), - initial_values.subject, + initialValues.subject, ); self.message = new TranslationViewModel( initialPageData.get("language_list"), - initial_values.message, + initialValues.message, ); self.html_message = new TranslationViewModel( initialPageData.get("language_list"), - initial_values.html_message, + initialValues.html_message, ); - self.survey_reminder_intervals_enabled = ko.observable(initial_values.survey_reminder_intervals_enabled); - self.fcm_message_type = ko.observable(initial_values.fcm_message_type); + self.survey_reminder_intervals_enabled = ko.observable(initialValues.survey_reminder_intervals_enabled); + self.fcm_message_type = ko.observable(initialValues.fcm_message_type); }; -var EventAndContentViewModel = function (initial_values) { +var EventAndContentViewModel = function (initialValues) { var self = this; - ContentViewModel.call(self, initial_values); + ContentViewModel.call(self, initialValues); - self.day = ko.observable(initial_values.day); - self.time = ko.observable(initial_values.time); - self.case_property_name = ko.observable(initial_values.case_property_name); - self.minutes_to_wait = ko.observable(initial_values.minutes_to_wait); - self.deleted = ko.observable(initial_values.DELETE); - self.order = ko.observable(initial_values.ORDER); + self.day = ko.observable(initialValues.day); + self.time = ko.observable(initialValues.time); + self.case_property_name = ko.observable(initialValues.case_property_name); + self.minutesToWait = ko.observable(initialValues.minutesToWait); + self.deleted = ko.observable(initialValues.DELETE); + self.order = ko.observable(initialValues.ORDER); self.waitTimeDisplay = ko.computed(function () { - var minutes_to_wait = parseInt(self.minutes_to_wait()); - if (minutes_to_wait >= 0) { - var hours = Math.floor(minutes_to_wait / 60); - var minutes = minutes_to_wait % 60; - var hours_text = hours + ' ' + gettext('hour(s)'); - var minutes_text = minutes + ' ' + gettext('minute(s)'); + var minutesToWait = parseInt(self.minutesToWait()); + if (minutesToWait >= 0) { + var hours = Math.floor(minutesToWait / 60); + var minutes = minutesToWait % 60; + var hoursText = hours + ' ' + gettext('hour(s)'); + var minutesText = minutes + ' ' + gettext('minute(s)'); if (hours > 0) { - return hours_text + ', ' + minutes_text; + return hoursText + ', ' + minutesText; } else { - return minutes_text; + return minutesText; } } return ''; @@ -168,67 +170,67 @@ var CustomEventContainer = function (id) { }); }; -var CreateScheduleViewModel = function (initial_values, select2_user_recipients, - select2_user_group_recipients, select2_user_organization_recipients, select2_location_types, - select2_case_group_recipients, current_visit_scheduler_form) { +var CreateScheduleViewModel = function (initialValues, select2UserRecipients, + select2UserGroupRecipients, select2UserOrganizationRecipients, select2LocationTypes, + select2CaseGroupRecipients, currentVisitSchedulerForm) { var self = this; - self.useCase = ko.observable(initial_values.use_case); + self.useCase = ko.observable(initialValues.use_case); self.timestamp = new Date().getTime(); - self.send_frequency = ko.observable(initial_values.send_frequency); - self.weekdays = ko.observableArray(initial_values.weekdays || []); - self.days_of_month = ko.observableArray(initial_values.days_of_month || []); - self.send_time = ko.observable(initial_values.send_time); - self.send_time_type = ko.observable(initial_values.send_time_type); - self.start_date = ko.observable(initial_values.start_date); - self.start_date_type = ko.observable(initial_values.start_date_type); - self.start_offset_type = ko.observable(initial_values.start_offset_type); - self.repeat = ko.observable(initial_values.repeat); - self.repeat_every = ko.observable(initial_values.repeat_every); - self.stop_type = ko.observable(initial_values.stop_type); - self.occurrences = ko.observable(initial_values.occurrences); - self.recipient_types = ko.observableArray(initial_values.recipient_types || []); + self.send_frequency = ko.observable(initialValues.send_frequency); + self.weekdays = ko.observableArray(initialValues.weekdays || []); + self.days_of_month = ko.observableArray(initialValues.days_of_month || []); + self.send_time = ko.observable(initialValues.send_time); + self.send_time_type = ko.observable(initialValues.send_time_type); + self.start_date = ko.observable(initialValues.start_date); + self.start_date_type = ko.observable(initialValues.start_date_type); + self.start_offset_type = ko.observable(initialValues.start_offset_type); + self.repeat = ko.observable(initialValues.repeat); + self.repeat_every = ko.observable(initialValues.repeat_every); + self.stop_type = ko.observable(initialValues.stop_type); + self.occurrences = ko.observable(initialValues.occurrences); + self.recipient_types = ko.observableArray(initialValues.recipient_types || []); $('#id_schedule-recipient_types').select2(); - self.user_recipients = new recipientsSelect2Handler(select2_user_recipients, - initial_values.user_recipients, 'schedule-user_recipients'); + self.user_recipients = new recipientsSelect2Handler(select2UserRecipients, + initialValues.user_recipients, 'schedule-user_recipients'); self.user_recipients.init(); - self.user_group_recipients = new recipientsSelect2Handler(select2_user_group_recipients, - initial_values.user_group_recipients, 'schedule-user_group_recipients'); + self.user_group_recipients = new recipientsSelect2Handler(select2UserGroupRecipients, + initialValues.user_group_recipients, 'schedule-user_group_recipients'); self.user_group_recipients.init(); - self.user_organization_recipients = new recipientsSelect2Handler(select2_user_organization_recipients, - initial_values.user_organization_recipients, 'schedule-user_organization_recipients'); + self.user_organization_recipients = new recipientsSelect2Handler(select2UserOrganizationRecipients, + initialValues.user_organization_recipients, 'schedule-user_organization_recipients'); self.user_organization_recipients.init(); - self.include_descendant_locations = ko.observable(initial_values.include_descendant_locations); - self.restrict_location_types = ko.observable(initial_values.restrict_location_types); + self.include_descendant_locations = ko.observable(initialValues.include_descendant_locations); + self.restrict_location_types = ko.observable(initialValues.restrict_location_types); - self.location_types = new recipientsSelect2Handler(select2_location_types, - initial_values.location_types, 'schedule-location_types'); + self.location_types = new recipientsSelect2Handler(select2LocationTypes, + initialValues.location_types, 'schedule-location_types'); self.location_types.init(); - self.case_group_recipients = new recipientsSelect2Handler(select2_case_group_recipients, - initial_values.case_group_recipients, 'schedule-case_group_recipients'); + self.case_group_recipients = new recipientsSelect2Handler(select2CaseGroupRecipients, + initialValues.case_group_recipients, 'schedule-case_group_recipients'); self.case_group_recipients.init(); - self.reset_case_property_enabled = ko.observable(initial_values.reset_case_property_enabled); - self.stop_date_case_property_enabled = ko.observable(initial_values.stop_date_case_property_enabled); - self.submit_partially_completed_forms = ko.observable(initial_values.submit_partially_completed_forms); + self.reset_case_property_enabled = ko.observable(initialValues.reset_case_property_enabled); + self.stop_date_case_property_enabled = ko.observable(initialValues.stop_date_case_property_enabled); + self.submit_partially_completed_forms = ko.observable(initialValues.submit_partially_completed_forms); - self.is_trial_project = initial_values.is_trial_project; + self.is_trial_project = initialValues.is_trial_project; self.displayed_email_trial_message = false; - self.content = ko.observable(initial_values.content); - self.standalone_content_form = new ContentViewModel(initial_values.standalone_content_form); + self.content = ko.observable(initialValues.content); + self.standalone_content_form = new ContentViewModel(initialValues.standalone_content_form); self.custom_events = ko.observableArray(); - self.visit_scheduler_app_and_form_unique_id = new formSelect2Handler(current_visit_scheduler_form, + self.visit_scheduler_app_and_form_unique_id = new formSelect2Handler(currentVisitSchedulerForm, 'schedule-visit_scheduler_app_and_form_unique_id', self.timestamp); self.visit_scheduler_app_and_form_unique_id.init(); - self.use_user_data_filter = ko.observable(initial_values.use_user_data_filter); - self.capture_custom_metadata_item = ko.observable(initial_values.capture_custom_metadata_item); - self.editing_custom_immediate_schedule = ko.observable(initial_values.editing_custom_immediate_schedule); + self.use_user_data_filter = ko.observable(initialValues.use_user_data_filter); + self.capture_custom_metadata_item = ko.observable(initialValues.capture_custom_metadata_item); + self.editing_custom_immediate_schedule = ko.observable(initialValues.editing_custom_immediate_schedule); self.create_day_of_month_choice = function (value) { if (value === '-1') { @@ -287,66 +289,66 @@ var CreateScheduleViewModel = function (initial_values, select2_user_recipients, return $.inArray(self.send_frequency(), ['daily', 'weekly', 'monthly', 'custom_daily']) !== -1; }); - self.calculateDailyEndDate = function (start_date_milliseconds, repeat_every, occurrences) { - var milliseconds_in_a_day = 24 * 60 * 60 * 1000; - var days_until_end_date = (occurrences - 1) * repeat_every; - return new Date(start_date_milliseconds + (days_until_end_date * milliseconds_in_a_day)); + self.calculateDailyEndDate = function (startDateMilliseconds, repeatEvery, occurrences) { + var millisecondsInADay = 24 * 60 * 60 * 1000; + var daysUntilEndDate = (occurrences - 1) * repeatEvery; + return new Date(startDateMilliseconds + (daysUntilEndDate * millisecondsInADay)); }; - self.calculateWeeklyEndDate = function (start_date_milliseconds, repeat_every, occurrences) { - var milliseconds_in_a_day = 24 * 60 * 60 * 1000; - var js_start_day_of_week = new Date(start_date_milliseconds).getUTCDay(); - var python_start_day_of_week = (js_start_day_of_week + 6) % 7; - var offset_to_last_weekday_in_schedule = null; + self.calculateWeeklyEndDate = function (startDateMilliseconds, repeatEvery, occurrences) { + var millisecondsInADay = 24 * 60 * 60 * 1000; + var jsStartDayOfWeek = new Date(startDateMilliseconds).getUTCDay(); + var pythonStartDayOfWeek = (jsStartDayOfWeek + 6) % 7; + var offsetToLastWeekdayInSchedule = null; for (var i = 0; i < 7; i++) { - var current_weekday = (python_start_day_of_week + i) % 7; - if (self.weekdays().indexOf(current_weekday.toString()) !== -1) { - offset_to_last_weekday_in_schedule = i; + var currentWeekday = (pythonStartDayOfWeek + i) % 7; + if (self.weekdays().indexOf(currentWeekday.toString()) !== -1) { + offsetToLastWeekdayInSchedule = i; } } - if (offset_to_last_weekday_in_schedule === null) { + if (offsetToLastWeekdayInSchedule === null) { return null; } return new Date( - start_date_milliseconds + - offset_to_last_weekday_in_schedule * milliseconds_in_a_day + - (occurrences - 1) * 7 * repeat_every * milliseconds_in_a_day, + startDateMilliseconds + + offsetToLastWeekdayInSchedule * millisecondsInADay + + (occurrences - 1) * 7 * repeatEvery * millisecondsInADay, ); }; - self.calculateMonthlyEndDate = function (start_date_milliseconds, repeat_every, occurrences) { - var last_day = null; + self.calculateMonthlyEndDate = function (startDateMilliseconds, repeatEvery, occurrences) { + var lastDay = null; self.days_of_month().forEach(function (value) { value = parseInt(value); - if (last_day === null) { - last_day = value; - } else if (last_day > 0) { + if (lastDay === null) { + lastDay = value; + } else if (lastDay > 0) { if (value < 0) { - last_day = value; - } else if (value > last_day) { - last_day = value; + lastDay = value; + } else if (value > lastDay) { + lastDay = value; } } else { - if (value < 0 && value > last_day) { - last_day = value; + if (value < 0 && value > lastDay) { + lastDay = value; } } }); - if (last_day === null) { + if (lastDay === null) { return null; } - var end_date = new Date(start_date_milliseconds); - end_date.setUTCMonth(end_date.getUTCMonth() + (occurrences - 1) * repeat_every); - if (last_day < 0) { - end_date.setUTCMonth(end_date.getUTCMonth() + 1); + var endDate = new Date(startDateMilliseconds); + endDate.setUTCMonth(endDate.getUTCMonth() + (occurrences - 1) * repeatEvery); + if (lastDay < 0) { + endDate.setUTCMonth(endDate.getUTCMonth() + 1); // Using a value of 0 sets it to the last day of the previous month - end_date.setUTCDate(last_day + 1); + endDate.setUTCDate(lastDay + 1); } else { - end_date.setUTCDate(last_day); + endDate.setUTCDate(lastDay); } - return end_date; + return endDate; }; self.calculateOccurrences = function () { @@ -376,29 +378,29 @@ var CreateScheduleViewModel = function (initial_values, select2_user_recipients, }; self.computedEndDate = ko.computed(function () { - var start_date_milliseconds = Date.parse(self.start_date()); - var repeat_every = self.calculateRepeatEvery(); + var startDateMilliseconds = Date.parse(self.start_date()); + var repeatEvery = self.calculateRepeatEvery(); var occurrences = self.calculateOccurrences(); if (self.start_date_type() && self.start_date_type() !== 'SPECIFIC_DATE') { return ''; } - if (isNaN(start_date_milliseconds) || isNaN(occurrences) || isNaN(repeat_every)) { + if (isNaN(startDateMilliseconds) || isNaN(occurrences) || isNaN(repeatEvery)) { return ''; } - var end_date = null; + var endDate = null; if (self.send_frequency() === 'daily') { - end_date = self.calculateDailyEndDate(start_date_milliseconds, repeat_every, occurrences); + endDate = self.calculateDailyEndDate(startDateMilliseconds, repeatEvery, occurrences); } else if (self.send_frequency() === 'weekly') { - end_date = self.calculateWeeklyEndDate(start_date_milliseconds, repeat_every, occurrences); + endDate = self.calculateWeeklyEndDate(startDateMilliseconds, repeatEvery, occurrences); } else if (self.send_frequency() === 'monthly') { - end_date = self.calculateMonthlyEndDate(start_date_milliseconds, repeat_every, occurrences); + endDate = self.calculateMonthlyEndDate(startDateMilliseconds, repeatEvery, occurrences); } - if (end_date) { - return end_date.toJSON().substr(0, 10); + if (endDate) { + return endDate.toJSON().substr(0, 10); } return ''; @@ -430,52 +432,52 @@ var CreateScheduleViewModel = function (initial_values, select2_user_recipients, self.custom_events.push(new CustomEventContainer(id)); }; - self.markCustomEventDeleted = function (event_id) { + self.markCustomEventDeleted = function (eventId) { $.each(self.custom_events(), function (index, value) { - if (value.event_id === event_id) { + if (value.event_id === eventId) { value.eventAndContentViewModel.deleted(true); } }); }; - self.getCustomEventIndex = function (event_id, arr) { - var item_index = null; + self.getCustomEventIndex = function (eventId, arr) { + var itemIndex = null; $.each(arr, function (index, value) { - if (value.event_id === event_id) { - item_index = index; + if (value.event_id === eventId) { + itemIndex = index; } }); - return item_index; + return itemIndex; }; - self.moveCustomEventUp = function (event_id) { - var new_array = self.custom_events(); - var item_index = self.getCustomEventIndex(event_id, new_array); - var swapped_item = null; + self.moveCustomEventUp = function (eventId) { + var newArray = self.custom_events(); + var itemIndex = self.getCustomEventIndex(eventId, newArray); + var swappedItem = null; - while (item_index > 0 && (swapped_item === null || swapped_item.eventAndContentViewModel.deleted())) { - swapped_item = new_array[item_index - 1]; - new_array[item_index - 1] = new_array[item_index]; - new_array[item_index] = swapped_item; - item_index -= 1; + while (itemIndex > 0 && (swappedItem === null || swappedItem.eventAndContentViewModel.deleted())) { + swappedItem = newArray[itemIndex - 1]; + newArray[itemIndex - 1] = newArray[itemIndex]; + newArray[itemIndex] = swappedItem; + itemIndex -= 1; } - self.custom_events(new_array); + self.custom_events(newArray); }; - self.moveCustomEventDown = function (event_id) { - var new_array = self.custom_events(); - var item_index = self.getCustomEventIndex(event_id, new_array); - var swapped_item = null; + self.moveCustomEventDown = function (eventId) { + var newArray = self.custom_events(); + var itemIndex = self.getCustomEventIndex(eventId, newArray); + var swappedItem = null; - while ((item_index < (new_array.length - 1)) && (swapped_item === null || swapped_item.eventAndContentViewModel.deleted())) { - swapped_item = new_array[item_index + 1]; - new_array[item_index + 1] = new_array[item_index]; - new_array[item_index] = swapped_item; - item_index += 1; + while ((itemIndex < (newArray.length - 1)) && (swappedItem === null || swappedItem.eventAndContentViewModel.deleted())) { + swappedItem = newArray[itemIndex + 1]; + newArray[itemIndex + 1] = newArray[itemIndex]; + newArray[itemIndex] = swappedItem; + itemIndex += 1; } - self.custom_events(new_array); + self.custom_events(newArray); }; self.custom_events.subscribe(function (newValue) { @@ -497,23 +499,23 @@ var CreateScheduleViewModel = function (initial_values, select2_user_recipients, self.initDatePicker($("#id_schedule-start_date")); self.setRepeatOptionText(self.send_frequency()); - var custom_events = []; + var customEvents = []; for (var i = 0; i < self.getNextCustomEventIndex(); i++) { - custom_events.push(new CustomEventContainer(i)); + customEvents.push(new CustomEventContainer(i)); } - custom_events.sort(function (item1, item2) { + customEvents.sort(function (item1, item2) { return item1.eventAndContentViewModel.order() - item2.eventAndContentViewModel.order(); }); - self.custom_events(custom_events); + self.custom_events(customEvents); }; }; var baseSelect2Handler = select2Handler.baseSelect2Handler, - recipientsSelect2Handler = function (initial_object_list, initial_comma_separated_list, field) { + recipientsSelect2Handler = function (initialObjectList, initialCommaSeparatedList, field) { /* - * initial_object_list is a list of {id: ..., text: ...} objects representing the initial value + * initialObjectList is a list of {id: ..., text: ...} objects representing the initial value * - * intial_comma_separated_list is a string representation of initial_object_list consisting of just + * intial_comma_separated_list is a string representation of initialObjectList consisting of just * the ids separated by a comma */ var self = baseSelect2Handler({ @@ -526,10 +528,10 @@ var baseSelect2Handler = select2Handler.baseSelect2Handler, }; self.getInitialData = function () { - return initial_object_list; + return initialObjectList; }; - self.value(initial_comma_separated_list); + self.value(initialCommaSeparatedList); return self; }; @@ -537,9 +539,9 @@ var baseSelect2Handler = select2Handler.baseSelect2Handler, recipientsSelect2Handler.prototype = Object.create(recipientsSelect2Handler.prototype); recipientsSelect2Handler.prototype.constructor = recipientsSelect2Handler; -var formSelect2Handler = function (initial_object, field, timestamp) { +var formSelect2Handler = function (initialObject, field, timestamp) { /* - * initial_object is an {id: ..., text: ...} object representing the initial value + * initialObject is an {id: ..., text: ...} object representing the initial value */ var self = baseSelect2Handler({ fieldName: field, @@ -555,10 +557,10 @@ var formSelect2Handler = function (initial_object, field, timestamp) { }; self.getInitialData = function () { - return initial_object; + return initialObject; }; - self.value(initial_object ? initial_object.id : ''); + self.value(initialObject ? initialObject.id : ''); return self; }; diff --git a/corehq/messaging/scheduling/templates/scheduling/partials/rich_text_editor.html b/corehq/messaging/scheduling/templates/scheduling/partials/rich_text_editor.html new file mode 100644 index 000000000000..617e555eb2ac --- /dev/null +++ b/corehq/messaging/scheduling/templates/scheduling/partials/rich_text_editor.html @@ -0,0 +1,38 @@ +
+ + + + + + + + + + + + + + + + + +
+
diff --git a/corehq/messaging/scheduling/templates/scheduling/partials/rich_text_message_configuration.html b/corehq/messaging/scheduling/templates/scheduling/partials/rich_text_message_configuration.html index 8f842ec8fd23..5b4d0fdbb68a 100644 --- a/corehq/messaging/scheduling/templates/scheduling/partials/rich_text_message_configuration.html +++ b/corehq/messaging/scheduling/templates/scheduling/partials/rich_text_message_configuration.html @@ -14,12 +14,7 @@
- + {% include "scheduling/partials/rich_text_editor.html" with editableText="nonTranslatedMessage" %}
@@ -28,11 +23,6 @@ >{% trans "Translation" %} (): - + {% include "scheduling/partials/rich_text_editor.html" with editableText="html_message" %}
diff --git a/corehq/toggles/__init__.py b/corehq/toggles/__init__.py index 73f609f58fe7..a3406c3c4a5a 100644 --- a/corehq/toggles/__init__.py +++ b/corehq/toggles/__init__.py @@ -1563,6 +1563,7 @@ def _commtrackify(domain_name, toggle_is_enabled): 'Enable sending rich text HTML emails in conditional alerts and broadcasts', TAG_CUSTOM, [NAMESPACE_DOMAIN], + help_link='https://dimagi.atlassian.net/wiki/spaces/USH/pages/2901835924/Rich+text+emails' ) RUN_AUTO_CASE_UPDATES_ON_SAVE = StaticToggle( diff --git a/package.json b/package.json index c239a3a695ce..ec0872be4d7e 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "nvd3": "1.1.10", "nvd3-1.8.6": "npm:nvd3#1.8.6", "quill": "2.0.3", + "quill-delta-to-html-upate": "^0.13.0", "requirejs": "2.3.7", "requirejs-babel7": "^1.3.2", "select2": "4.1.0-rc.0", diff --git a/yarn.lock b/yarn.lock index 302df8c3fd3a..15524ff1ca1c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6656,6 +6656,13 @@ quickselect@^2.0.0: resolved "https://registry.yarnpkg.com/quickselect/-/quickselect-2.0.0.tgz#f19680a486a5eefb581303e023e98faaf25dd018" integrity sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw== +quill-delta-to-html-upate@^0.13.0: + version "0.13.0" + resolved "https://registry.yarnpkg.com/quill-delta-to-html-upate/-/quill-delta-to-html-upate-0.13.0.tgz#ff37eec7276126d96e320afbfa041ecc67d069d1" + integrity sha512-1uJRFq2NO0sl9lO/R0wd00ewPSaX5+hpM+G4M+XSVd6dEuqSwpVP+yGrhQ8bnvzTx6oeURnwXxV9VrE5o19phQ== + dependencies: + lodash.isequal "^4.5.0" + quill-delta@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/quill-delta/-/quill-delta-5.1.0.tgz#1c4bc08f7c8e5cc4bdc88a15a1a70c1cc72d2b48" @@ -7300,16 +7307,7 @@ string-template@~0.2.1: resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add" integrity sha1-QpMuWYo1LQH8IuwzZ9nYTuxsmt0= -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -7355,7 +7353,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -7369,13 +7367,6 @@ strip-ansi@^6.0.0: dependencies: ansi-regex "^5.0.0" -strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1, strip-ansi@^7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -8012,7 +8003,8 @@ workerpool@^6.5.1: resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.5.1.tgz#060f73b39d0caf97c6db64da004cd01b4c099544" integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: + name wrap-ansi-cjs version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -8030,15 +8022,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"