diff --git a/common/static/common/templates/discussion/customwmd-prompt.underscore b/common/static/common/templates/discussion/customwmd-prompt.underscore new file mode 100644 index 0000000000..9a13d32509 --- /dev/null +++ b/common/static/common/templates/discussion/customwmd-prompt.underscore @@ -0,0 +1,51 @@ +

<%- title %>

+
+
+
+
+ <%- title %> +
+ + + <% if (imageUploadHandler) { %> + + + <% } %> + +
+ <%- urlHelp %> +
+
+
+ + + +
+ <%- urlDescHelp %> + <% if (urlDescHelpLink) { %> + + <%- urlDescHelpLink['text'] %> + + <% } %> +
+
+
<% if (imageUploadHandler) { %> + <% } + %>
+
+
+ + +
+
+
diff --git a/common/test/acceptance/pages/lms/discussion.py b/common/test/acceptance/pages/lms/discussion.py index 6a8275fb5d..f4bbb5a339 100644 --- a/common/test/acceptance/pages/lms/discussion.py +++ b/common/test/acceptance/pages/lms/discussion.py @@ -195,17 +195,58 @@ class DiscussionThreadPage(PageObject, DiscussionPageMixin): """Replace the contents of the response editor""" self._find_within(".response_{} .discussion-response .wmd-input".format(response_id)).fill(new_body) + def verify_link_editor_error_messages_shown(self): + """ + Confirm that the error messages are displayed in the editor. + """ + def errors_visible(): + """ + Returns True if both errors are visible, False otherwise. + """ + return ( + self.q(css="#new-url-input-field-message.has-error").visible and + self.q(css="#new-url-desc-input-field-message.has-error").visible + ) + + self.wait_for(errors_visible, "Form errors should be visible.") + + def add_content_via_editor_button(self, content_type, response_id, url, description, is_decorative=False): + """Replace the contents of the response editor""" + self._find_within( + "#wmd-{}-button-edit-post-body-{}".format( + content_type, + response_id, + ) + ).click() + self.q(css='#new-url-input').fill(url) + self.q(css='#new-url-desc-input').fill(description) + + if is_decorative: + self.q(css='#img-is-decorative').click() + + self.q(css='input[value="OK"]').click() + def submit_response_edit(self, response_id, new_response_body): """Click the submit button on the response editor""" - self._find_within(".response_{} .discussion-response .post-update".format(response_id)).first.click() - EmptyPromise( - lambda: ( + + def submit_response_check_func(): + """ + Tries to click "Update post" and returns True if the post + was successfully updated, False otherwise. + """ + self._find_within( + ".response_{} .discussion-response .post-update".format( + response_id + ) + ).first.click() + + return ( not self.is_response_editor_visible(response_id) and self.is_response_visible(response_id) and self.get_response_body(response_id) == new_response_body - ), - "Comment edit succeeded" - ).fulfill() + ) + + self.wait_for(submit_response_check_func, "Comment edit succeeded") def is_show_comments_visible(self, response_id): """Returns true if the "show comments" link is visible for a response""" diff --git a/common/test/acceptance/tests/discussion/test_discussion.py b/common/test/acceptance/tests/discussion/test_discussion.py index 0d4b988288..def96e830e 100644 --- a/common/test/acceptance/tests/discussion/test_discussion.py +++ b/common/test/acceptance/tests/discussion/test_discussion.py @@ -449,6 +449,145 @@ class DiscussionResponseEditTest(BaseDiscussionTestCase): page.set_response_editor_value(response_id, new_response) page.submit_response_edit(response_id, new_response) + def test_edit_response_add_link(self): + """ + Scenario: User submits valid input to the 'add link' form + Given I am editing a response on a discussion page + When I click the 'add link' icon in the editor toolbar + And enter a valid url to the URL input field + And enter a valid string in the Description input field + And click the 'OK' button + Then the edited response should contain the new link + """ + self.setup_user() + self.setup_view() + page = self.create_single_thread_page("response_edit_test_thread") + page.visit() + + response_id = "response_self_author" + url = "http://example.com" + description = "example" + + page.start_response_edit(response_id) + page.set_response_editor_value(response_id, "") + page.add_content_via_editor_button( + "link", response_id, url, description) + page.submit_response_edit(response_id, description) + + expected_response_html = ( + '

{}

'.format(url, description) + ) + actual_response_html = page.q( + css=".response_{} .response-body".format(response_id) + ).html[0] + self.assertEqual(expected_response_html, actual_response_html) + + def test_edit_response_add_image(self): + """ + Scenario: User submits valid input to the 'add image' form + Given I am editing a response on a discussion page + When I click the 'add image' icon in the editor toolbar + And enter a valid url to the URL input field + And enter a valid string in the Description input field + And click the 'OK' button + Then the edited response should contain the new image + """ + self.setup_user() + self.setup_view() + page = self.create_single_thread_page("response_edit_test_thread") + page.visit() + + response_id = "response_self_author" + url = "http://www.example.com/something.png" + description = "image from example.com" + + page.start_response_edit(response_id) + page.set_response_editor_value(response_id, "") + page.add_content_via_editor_button( + "image", response_id, url, description) + page.submit_response_edit(response_id, '') + + expected_response_html = ( + '

{}

'.format(url, description) + ) + actual_response_html = page.q( + css=".response_{} .response-body".format(response_id) + ).html[0] + self.assertEqual(expected_response_html, actual_response_html) + + def test_edit_response_add_image_error_msg(self): + """ + Scenario: User submits invalid input to the 'add image' form + Given I am editing a response on a discussion page + When I click the 'add image' icon in the editor toolbar + And enter an invalid url to the URL input field + And enter an empty string in the Description input field + And click the 'OK' button + Then I should be shown 2 error messages + """ + self.setup_user() + self.setup_view() + page = self.create_single_thread_page("response_edit_test_thread") + page.visit() + page.start_response_edit("response_self_author") + page.add_content_via_editor_button( + "image", "response_self_author", '', '') + page.verify_link_editor_error_messages_shown() + + def test_edit_response_add_decorative_image(self): + """ + Scenario: User submits invalid input to the 'add image' form + Given I am editing a response on a discussion page + When I click the 'add image' icon in the editor toolbar + And enter a valid url to the URL input field + And enter an empty string in the Description input field + And I check the 'image is decorative' checkbox + And click the 'OK' button + Then the edited response should contain the new image + """ + self.setup_user() + self.setup_view() + page = self.create_single_thread_page("response_edit_test_thread") + page.visit() + + response_id = "response_self_author" + url = "http://www.example.com/something.png" + description = "" + + page.start_response_edit(response_id) + page.set_response_editor_value(response_id, "Some content") + page.add_content_via_editor_button( + "image", response_id, url, description, is_decorative=True) + page.submit_response_edit(response_id, "Some content") + + expected_response_html = ( + '

Some content{}

'.format( + url, description) + ) + actual_response_html = page.q( + css=".response_{} .response-body".format(response_id) + ).html[0] + self.assertEqual(expected_response_html, actual_response_html) + + def test_edit_response_add_link_error_msg(self): + """ + Scenario: User submits invalid input to the 'add link' form + Given I am editing a response on a discussion page + When I click the 'add link' icon in the editor toolbar + And enter an invalid url to the URL input field + And enter an empty string in the Description input field + And click the 'OK' button + Then I should be shown 2 error messages + """ + self.setup_user() + self.setup_view() + page = self.create_single_thread_page("response_edit_test_thread") + page.visit() + page.start_response_edit("response_self_author") + page.add_content_via_editor_button( + "link", "response_self_author", '', '') + page.verify_link_editor_error_messages_shown() + def test_edit_response_as_student(self): """ Scenario: Students should be able to edit the response they created not responses of other users diff --git a/lms/static/js/Markdown.Editor.js b/lms/static/js/Markdown.Editor.js index abcd95f5a2..8be91adde1 100644 --- a/lms/static/js/Markdown.Editor.js +++ b/lms/static/js/Markdown.Editor.js @@ -21,25 +21,35 @@ // ------------------------------------------------------------------- // YOUR CHANGES GO HERE // - // I've tried to localize the things you are likely to change to + // I've tried to localize the things you are likely to change to // this area. // ------------------------------------------------------------------- - // The text that appears on the upper part of the dialog box when - // entering links. - var linkDialogText = "

" + gettext("Insert Hyperlink") + "

http://example.com/ " + - // Translators: Please keep the quotation marks (") around this text - gettext("\"optional title\"") + "

"; - var imageDialogText = "

" + gettext("Insert Image (upload file or type url)") + "

http://example.com/images/diagram.jpg " + - // Translators: Please keep the quotation marks (") around this text - gettext("\"optional title\"") + "

"; + // The text that appears on the dialog box when entering links. + var linkDialogText = gettext("Insert Hyperlink"), + linkUrlHelpText = gettext("e.g. 'http://google.com/'"), + linkDestinationLabel = gettext("Link Description"), + linkDestinationHelpText = gettext("e.g. 'google'"), + linkDestinationError = gettext("Please provide a description of the link destination."), + linkDefaultText = "http://"; // The default text that appears in input - // The default text that appears in the dialog input box when entering - // links. - var imageDefaultText = "http://"; - var linkDefaultText = "http://"; + // The text that appears on the dialog box when entering Images. + var imageDialogText = gettext("Insert Image (upload file or type URL)"), + imageUrlHelpText = gettext("Type in a URL or use the \"Choose File\" button to upload a file from your machine. (e.g. 'http://example.com/img/clouds.jpg')"), // jshint ignore:line + imageDescriptionLabel = gettext("Image Description"), + imageDefaultText = "http://", // The default text that appears in input + imageDescError = gettext("Please describe this image or agree that it has no contextual value by checking the checkbox."), // jshint ignore:line + imageDescriptionHelpText = gettext("e.g. 'Sky with clouds'. The description is helpful for users who cannot see the image."), // jshint ignore:line + imageDescriptionHelpLink = { + href: 'http://www.w3.org/TR/html5/embedded-content-0.html#alt', + text: gettext("How to create useful text alternatives.") + }, + imageIsDecorativeLabel = gettext("This image is for decorative purposes only and does not require a description."); // jshint ignore:line - var defaultHelpHoverTitle = gettext("Markdown Editing Help"); + // Text that is shared between both link and image dialog boxes. + var defaultHelpHoverTitle = gettext("Markdown Editing Help"), + urlLabel = gettext("URL"), + urlError = gettext("Please provide a valid URL."); // ------------------------------------------------------------------- // END OF YOUR CHANGES @@ -64,6 +74,7 @@ * its own image insertion dialog, this hook should return true, and the callback should be called with the chosen * image url (or null if the user cancelled). If this hook returns false, the default dialog will be used. */ + this.util = util; this.getConverter = function () { return markdownConverter; } @@ -164,7 +175,7 @@ beforeReplacer = function (s) { that.before += s; return ""; } afterReplacer = function (s) { that.after = s + that.after; return ""; } } - + this.selection = this.selection.replace(/^(\s*)/, beforeReplacer).replace(/(\s*)$/, afterReplacer); }; @@ -232,14 +243,14 @@ } }; - // end of Chunks + // end of Chunks // A collection of the important regions on the page. // Cached so we don't have to keep traversing the DOM. // Also holds ieCachedRange and ieCachedScrollTop, where necessary; working around // this issue: // Internet explorer has problems with CSS sprite buttons that use HTML - // lists. When you click on the background image "button", IE will + // lists. When you click on the background image "button", IE will // select the non-existent link text and discard the selection in the // textarea. The solution to this is to cache the textarea selection // on the button's mousedown event and set a flag. In the part of the @@ -256,6 +267,10 @@ this.input = doc.getElementById("wmd-input" + postfix); }; + util.isValidUrl = function(url) { + return /^((?:http|https|ftp):\/{2}|\/)[^]+$/.test(url); + }; + // Returns true if the DOM element is visible, false if it's hidden. // Checks if display is anything other than none. util.isVisible = function (elem) { @@ -584,7 +599,7 @@ setMode("escape"); } else if ((keyCode < 16 || keyCode > 20) && keyCode != 91) { - // 16-20 are shift, etc. + // 16-20 are shift, etc. // 91: left window key // I think this might be a little messed up since there are // a lot of nonprinting keys above 20. @@ -728,7 +743,7 @@ if (panels.ieCachedRange) stateObj.scrollTop = panels.ieCachedScrollTop; // this is set alongside with ieCachedRange - + panels.ieCachedRange = null; this.setInputAreaSelection(); @@ -975,9 +990,9 @@ var background = doc.createElement("div"), style = background.style; - + background.className = "wmd-prompt-background"; - + style.position = "absolute"; style.top = "0"; @@ -1014,17 +1029,28 @@ // callback: The function which is executed when the prompt is dismissed, either via OK or Cancel. // It receives a single argument; either the entered text (if OK was chosen) or null (if Cancel // was chosen). - ui.prompt = function (text, defaultInputText, callback, imageUploadHandler) { + ui.prompt = function (title, + urlLabel, + urlHelp, + urlError, + urlDescLabel, + urlDescHelp, + urlDescHelpLink, + urlDescError, + defaultInputText, + callback, + imageIsDecorativeLabel, + imageUploadHandler) { // These variables need to be declared at this level since they are used // in multiple functions. - var dialog; // The dialog box. - var input; // The text box where you enter the hyperlink. - - - if (defaultInputText === undefined) { - defaultInputText = ""; - } + var dialog, // The dialog box. + urlInput, // The text box where you enter the hyperlink. + urlErrorMsg, + descInput, // The text box where you enter the description. + descErrorMsg, + okButton, + cancelButton; // Used as a keydown event handler. Esc dismisses the prompt. // Key code 27 is ESC. @@ -1035,112 +1061,108 @@ } }; + var clearFormErrorMessages = function () { + urlInput.classList.remove('has-error'); + urlErrorMsg.style.display = 'none'; + descInput.classList.remove('has-error'); + descErrorMsg.style.display = 'none'; + }; + // Dismisses the hyperlink input box. // isCancel is true if we don't care about the input text. // isCancel is false if we are going to keep the text. var close = function (isCancel) { util.removeEvent(doc.body, "keydown", checkEscape); - var text = input.value; + var url = urlInput.value.trim(); + var description = descInput.value.trim(); + + clearFormErrorMessages(); if (isCancel) { - text = null; + url = null; } else { // Fixes common pasting errors. - text = text.replace(/^http:\/\/(https?|ftp):\/\//, '$1://'); + url = url.replace(/^http:\/\/(https?|ftp):\/\//, '$1://'); // doesn't change url if started with '/' (local) - if (!/^(?:https?|ftp):\/\//.test(text) && text.charAt(0) != '/') { - text = 'http://' + text; + if (!/^(?:https?|ftp):\/\//.test(url) && url.charAt(0) !== '/') { + url = 'http://' + url; } } - dialog.parentNode.removeChild(dialog); + var isValidUrl = util.isValidUrl(url), + isValidDesc = ( + descInput.checkValidity() && + (descInput.required ? description.length : true) + ); + + if ((isValidUrl && isValidDesc) || isCancel) { + dialog.parentNode.removeChild(dialog); + callback(url, description); + } else { + var errorCount = 0; + if (!isValidUrl) { + urlInput.classList.add('has-error'); + urlErrorMsg.style.display = 'inline-block'; + errorCount += 1; + } if (!isValidDesc) { + descInput.classList.add('has-error'); + descErrorMsg.style.display = 'inline-block'; + errorCount += 1; + } + + document.getElementById('wmd-editor-dialog-form-errors').textContent = [ + interpolate( + ngettext( + // Translators: 'errorCount' is the number of errors found in the form. + '%(errorCount)s error found in form.', '%(errorCount)s errors found in form.', + errorCount + ), {'errorCount': errorCount}, true + ), + !isValidUrl ? urlErrorMsg.textContent : '', + !isValidDesc ? descErrorMsg.textContent : '' + ].join(' '); + + document.getElementById('wmd-editor-dialog-form-errors').focus(); + } - callback(text); return false; }; - - // Create the text input box form/window. var createDialog = function () { - // The main dialog box. dialog = doc.createElement("div"); + dialog.innerHTML = _.template( + document.getElementById("customwmd-prompt-template").innerHTML, { + title: title, + uploadFieldClass: (imageUploadHandler ? 'file-upload' : ''), + urlLabel: urlLabel, + urlError: urlError, + urlHelp: urlHelp, + urlDescLabel: urlDescLabel, + descError: urlDescError, + urlDescHelp: urlDescHelp, + urlDescHelpLink: urlDescHelpLink, + okText: gettext("OK"), + cancelText: gettext("Cancel"), + chooseFileText: gettext("Choose File"), + imageIsDecorativeLabel: imageIsDecorativeLabel, + imageUploadHandler: imageUploadHandler + }); dialog.setAttribute("role", "dialog"); + dialog.setAttribute("tabindex", "-1"); + dialog.setAttribute("aria-labelledby", "editorDialogTitle"); dialog.className = "wmd-prompt-dialog"; dialog.style.padding = "10px;"; dialog.style.position = "fixed"; - dialog.style.width = "400px"; + dialog.style.width = "500px"; dialog.style.zIndex = "1001"; - // The dialog text. - var question = doc.createElement("div"); - question.innerHTML = text; - question.style.padding = "5px"; - dialog.appendChild(question); - - // The web form container for the text box and buttons. - var form = doc.createElement("form"), - style = form.style; - form.onsubmit = function () { return close(false); }; - style.padding = "0"; - style.margin = "0"; - style.cssFloat = "left"; - style.width = "100%"; - style.textAlign = "center"; - style.position = "relative"; - dialog.appendChild(form); - - // The input text box - input = doc.createElement("input"); - input.type = "text"; - input.value = defaultInputText; - style = input.style; - style.display = "block"; - style.width = "80%"; - style.marginLeft = style.marginRight = "auto"; - form.appendChild(input); - - // The choose file button if prompt type is 'image' - - if (imageUploadHandler) { - var chooseFile = doc.createElement("input"); - chooseFile.type = "file"; - chooseFile.name = "file-upload"; - chooseFile.id = "file-upload"; - chooseFile.onchange = function() { - imageUploadHandler(this, input); - }; - form.appendChild(doc.createElement("br")); - form.appendChild(chooseFile); - } - - - // The ok button - var okButton = doc.createElement("input"); - okButton.type = "button"; - okButton.onclick = function () { return close(false); }; - okButton.value = "OK"; - style = okButton.style; - style.margin = "10px"; - style.display = "inline"; - style.width = "7em"; - - - // The cancel button - var cancelButton = doc.createElement("input"); - cancelButton.type = "button"; - cancelButton.onclick = function () { return close(true); }; - cancelButton.value = "Cancel"; - style = cancelButton.style; - style.margin = "10px"; - style.display = "inline"; - style.width = "7em"; - - form.appendChild(okButton); - form.appendChild(cancelButton); + doc.body.appendChild(dialog); + // This has to be done AFTER adding the dialog to the form if you + // want it to be centered. util.addEvent(doc.body, "keydown", checkEscape); dialog.style.top = "50%"; dialog.style.left = "50%"; @@ -1150,15 +1172,57 @@ dialog.style.top = doc.documentElement.scrollTop + 200 + "px"; dialog.style.left = "50%"; } - doc.body.appendChild(dialog); - - // This has to be done AFTER adding the dialog to the form if you - // want it to be centered. dialog.style.marginTop = -(position.getHeight(dialog) / 2) + "px"; dialog.style.marginLeft = -(position.getWidth(dialog) / 2) + "px"; + urlInput = document.getElementById("new-url-input"); + urlErrorMsg = document.getElementById("new-url-input-field-message"); + descInput = document.getElementById("new-url-desc-input"); + descErrorMsg = document.getElementById("new-url-desc-input-field-message"); + urlInput.value = defaultInputText; + + okButton = document.getElementById("new-link-image-ok"); + cancelButton = document.getElementById("new-link-image-cancel"); + + okButton.onclick = function () { return close(false); }; + cancelButton.onclick = function () { return close(true); }; + + if(imageUploadHandler) { + var startUploadHandler = function () { + document.getElementById("file-upload").onchange = function() { + imageUploadHandler(this, urlInput); + urlInput.focus(); + + // Ensures that a user can update their file choice. + startUploadHandler(); + }; + }; + startUploadHandler(); + document.getElementById("file-upload-proxy").onclick = function () { + document.getElementById("file-upload").click(); + return false; + }; + document.getElementById("img-is-decorative").onchange = function () { + descInput.required = !descInput.required; + }; + } + + // trap focus in the dialog box + $(dialog).on("keydown", function (event) { + // On tab backward from the first tabbable item in the prompt + if (event.which === 9 && event.shiftKey && event.target === urlInput) { + event.preventDefault(); + cancelButton.focus(); + } + // On tab forward from the last tabbable item in the prompt + else if (event.which === 9 && !event.shiftKey && event.target === cancelButton) { + event.preventDefault(); + urlInput.focus(); + } + }); }; + // Why is this in a zero-length timeout? // Is it working around a browser bug? setTimeout(function () { @@ -1166,19 +1230,19 @@ createDialog(); var defTextLen = defaultInputText.length; - if (input.selectionStart !== undefined) { - input.selectionStart = 0; - input.selectionEnd = defTextLen; + if (urlInput.selectionStart !== undefined) { + urlInput.selectionStart = 0; + urlInput.selectionEnd = defTextLen; } - else if (input.createTextRange) { - var range = input.createTextRange(); + else if (urlInput.createTextRange) { + var range = urlInput.createTextRange(); range.collapse(false); range.moveStart("character", -defTextLen); range.moveEnd("character", defTextLen); range.select(); } - input.focus(); + dialog.focus(); }, 0); }; @@ -1310,7 +1374,7 @@ // // var link = CreateLinkDialog(); // makeMarkdownLink(link); - // + // // Instead of this straightforward method of handling a // dialog I have to pass any code which would execute // after the dialog is dismissed (e.g. link creation) @@ -1688,7 +1752,6 @@ } commandProto.doLinkOrImage = function (chunk, postProcessing, isImage, imageUploadHandler) { - chunk.trimWhitespace(); chunk.findTags(/\s*!?\[/, /\][ ]?(?:\n[ ]*)?(\[.*?\])?/); var background; @@ -1701,7 +1764,7 @@ } else { - + // We're moving start and end tag back into the selection, since (as we're in the else block) we're not // *removing* a link, but *adding* one, so whatever findTags() found is now back to being part of the // link text. linkEnteredCallback takes care of escaping any brackets. @@ -1715,8 +1778,7 @@ var that = this; // The function to be executed when you enter a link and press OK or Cancel. // Marks up the link and adds the ref. - var linkEnteredCallback = function (link) { - + var linkEnteredCallback = function (link, description) { background.parentNode.removeChild(background); if (link !== null) { @@ -1739,7 +1801,7 @@ // would mean a zero-width match at the start. Since zero-width matches advance the string position, // the first bracket could then not act as the "not a backslash" for the second. chunk.selection = (" " + chunk.selection).replace(/([^\\](?:\\\\)*)(?=[[\]])/g, "$1\\").substr(1); - + var linkDef = " [999]: " + properlyEncoded(link); var num = that.addLinkDef(chunk, linkDef); @@ -1748,10 +1810,10 @@ if (!chunk.selection) { if (isImage) { - chunk.selection = gettext("enter image description here"); + chunk.selection = description ? description : ""; } else { - chunk.selection = gettext("enter link description here"); + chunk.selection = description ? description : gettext("enter link description here"); } } } @@ -1761,11 +1823,36 @@ background = ui.createBackground(); if (isImage) { - if (!this.hooks.insertImageDialog(linkEnteredCallback)) - ui.prompt(imageDialogText, imageDefaultText, linkEnteredCallback, imageUploadHandler); + if (!this.hooks.insertImageDialog(linkEnteredCallback)) { + ui.prompt( + imageDialogText, + urlLabel, + imageUrlHelpText, + urlError, + imageDescriptionLabel, + imageDescriptionHelpText, + imageDescriptionHelpLink, + imageDescError, + imageDefaultText, + linkEnteredCallback, + imageIsDecorativeLabel, + imageUploadHandler + ); + } } else { - ui.prompt(linkDialogText, linkDefaultText, linkEnteredCallback); + ui.prompt( + linkDialogText, + urlLabel, + linkUrlHelpText, + urlError, + linkDestinationLabel, + linkDestinationHelpText, + '', + linkDestinationError, + linkDefaultText, + linkEnteredCallback + ); } return true; } @@ -1781,7 +1868,7 @@ chunk.before = chunk.before.replace(/(\n|^)[ ]{0,3}([*+-]|\d+[.])[ \t]*\n$/, "\n\n"); chunk.before = chunk.before.replace(/(\n|^)[ ]{0,3}>[ \t]*\n$/, "\n\n"); chunk.before = chunk.before.replace(/(\n|^)[ \t]+\n$/, "\n\n"); - + // There's no selection, end the cursor wasn't at the end of the line: // The user wants to split the current list item / code line / blockquote line // (for the latter it doesn't really matter) in two. Temporarily select the @@ -1809,7 +1896,7 @@ commandMgr.doCode(chunk); } } - + if (fakeSelection) { chunk.after = chunk.selection + chunk.after; chunk.selection = ""; diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index bf49ffa4ad..427b066f55 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -221,7 +221,7 @@ exports: 'Markdown.Converter' }, 'Markdown.Editor': { - deps: ['Markdown.Converter'], + deps: ['Markdown.Converter', 'gettext', 'underscore'], exports: 'Markdown.Editor' }, 'Markdown.Sanitizer': { @@ -744,7 +744,8 @@ 'lms/include/js/spec/financial-assistance/financial_assistance_form_view_spec.js', 'lms/include/js/spec/bookmarks/bookmarks_list_view_spec.js', 'lms/include/js/spec/bookmarks/bookmark_button_view_spec.js', - 'lms/include/js/spec/views/message_banner_spec.js' + 'lms/include/js/spec/views/message_banner_spec.js', + 'lms/include/js/spec/markdown_editor_spec.js' ]); }).call(this, requirejs, define); diff --git a/lms/static/js/spec/markdown_editor_spec.js b/lms/static/js/spec/markdown_editor_spec.js new file mode 100644 index 0000000000..674039575b --- /dev/null +++ b/lms/static/js/spec/markdown_editor_spec.js @@ -0,0 +1,53 @@ +define(['Markdown.Editor'], function(MarkdownEditor) { + 'use strict'; + describe('Markdown.Editor', function() { + var editor = new MarkdownEditor(); + + describe('util.isValidUrl', function () { + it('should return true for http://example.com', function () { + expect( + editor.util.isValidUrl('http://example.com') + ).toBeTruthy(); + }); + it('should return true for https://example.com', function () { + expect( + editor.util.isValidUrl('https://example.com') + ).toBeTruthy(); + }); + it('should return true for ftp://example.com', function () { + expect( + editor.util.isValidUrl('ftp://example.com') + ).toBeTruthy(); + }); + it('should return false for http://', function () { + expect(editor.util.isValidUrl('http://')).toBeFalsy(); + }); + it('should return false for https://', function () { + expect(editor.util.isValidUrl('https://')).toBeFalsy(); + }); + it('should return false for ftp://', function () { + expect(editor.util.isValidUrl('ftp://')).toBeFalsy(); + }); + it('should return false for fake://example.com', function () { + expect( + editor.util.isValidUrl('fakeprotocol://example.com') + ).toBeFalsy(); + }); + it('should return false for fake://', function () { + expect( + editor.util.isValidUrl('fakeprotocol://') + ).toBeFalsy(); + }); + it('should return false for www.noprotocol.com', function () { + expect( + editor.util.isValidUrl('www.noprotocol.com') + ).toBeFalsy(); + }); + it('should return false for an empty string', function () { + expect( + editor.util.isValidUrl('') + ).toBeFalsy(); + }); + }); + }); +}); diff --git a/lms/static/sass/_build-course.scss b/lms/static/sass/_build-course.scss index bc92cf0e9c..662b00dd2f 100644 --- a/lms/static/sass/_build-course.scss +++ b/lms/static/sass/_build-course.scss @@ -59,6 +59,7 @@ // discussion @import "course/discussion/form-wmd-toolbar"; +@import "course/discussion/form"; // search @import 'search/_search'; diff --git a/lms/static/sass/course/discussion/_form-wmd-toolbar.scss b/lms/static/sass/course/discussion/_form-wmd-toolbar.scss index 45db5c108a..5b777be6a6 100644 --- a/lms/static/sass/course/discussion/_form-wmd-toolbar.scss +++ b/lms/static/sass/course/discussion/_form-wmd-toolbar.scss @@ -105,16 +105,38 @@ font-family: arial, helvetica, sans-serif; } +.wmd-prompt-dialog { + .form { + width: 400px; + margin: 0 auto; + } + .form-actions { + text-align: center; + } -.wmd-prompt-dialog > form > input[type="text"] { - border: 1px solid #999999; - color: black; + .input-text { + width: 400px; + padding: 0px 12px; + } + + .field-hint, + .field-message.has-error { + width: 380px; + } + + .file-upload { + .input-text { + width: 230px; + } + .field-message.has-error { + margin-top: -1px; + width: 210px; + } + .form-btn { + margin: 0 0 0 10px; + height: 35px; + width: 130px; + padding: 6px; + } + } } - -.wmd-prompt-dialog > form > input[type="button"]{ - border: 1px solid #888888; - font-family: trebuchet MS, helvetica, sans-serif; - font-size: 1em; - font-weight: bold; -} - diff --git a/lms/static/sass/course/discussion/_form.scss b/lms/static/sass/course/discussion/_form.scss new file mode 100644 index 0000000000..7f50f6e4b6 --- /dev/null +++ b/lms/static/sass/course/discussion/_form.scss @@ -0,0 +1,203 @@ +// ------------------------------ +// edX Pattern Library: Components - Forms + +// About: Contains base styling for forms + +// #SETTINGS +// #GLOBAL +// #INPUT TEXT +// #INPUT RADIO/CHECKBOX +// ------------------------------ + + +// ------------------------------ +// IMPORTANT: This is meant for the modals in the discussion forum +// when doing updates on them we added the classes to make it compatible +// with the pattern library. So, it is modified and scoped to that modal. + +// TODO: Remove this file once the pattern library it implemented. +// ------------------------------ +.wmd-prompt-dialog { + // ---------------------------- + // #SETTINGS + // ---------------------------- + $spacing-vertical-x-small: ($baseline/2); + $spacing-vertical-base: ($baseline*2); + $spacing-vertical-mid-small: ($baseline*1.5); + $spacing-vertical-small: $baseline; + + $font-size-large: 18px; + $font-size-base: 16px; + $font-size-small: 14px; + + $component-border-radius: 3px !default; + + $error-base: rgb(178, 6, 16); + $error-dark: rgb(125, 9, 16); + $grayscale-x-dark: rgb(77, 75, 75); + $grayscale-x-light: rgb(231, 230, 230); + $grayscale-white: rgb(252, 252, 252); + $grayscale-cool-x-dark: rgb(52, 56, 58); + $grayscale-cool-x-light: rgb(229, 233, 235); + $primary-accent: rgb(14, 166, 236); + $transparent: rgba(167, 164, 164, 0.498039); + + $text-base-color: $grayscale-x-dark !default; + $label-color: $text-base-color !default; + $label-color-active: $grayscale-x-dark !default; + $input-placeholder-text: $grayscale-cool-x-light !default; + $input-default-background: $grayscale-white !default; + $input-default-border-color: $grayscale-x-light !default; + $input-default-focus-border-color: $primary-accent !default; + $input-default-color: $grayscale-cool-x-dark !default; + $input-default-focus-color: $grayscale-cool-x-dark !default; + $input-alt-background: $transparent !default; + $input-alt-focus-border-color: $grayscale-x-dark !default; + + + // ---------------------------- + // #GLOBAL + // ---------------------------- + // sections of a form + .form-group { + margin-bottom: $spacing-vertical-mid-small; + + // section title or legend + .form-group-hd { + margin-bottom: $spacing-vertical-small; + font-size: $font-size-large; + } + + .field { + margin-bottom: $spacing-vertical-base; + + &:last-child { + margin-bottom: 0; + } + } + } + + // radio button and checkbox fieldsets + .field-group { + margin-bottom: $spacing-vertical-small; + + // group title or legend + .field-group-hd { + margin-bottom: $spacing-vertical-small; + font-size: $font-size-large; + } + + .field { + margin-bottom: $spacing-vertical-x-small; + + &:last-child { + margin-bottom: 0; + } + } + } + + .field-label { + display: block; + width: auto; + margin-bottom: $spacing-vertical-x-small; + font-size: $font-size-base; + line-height: 100%; + color: $label-color; + + // presents the label inline with the form control + &.label-inline { + display: inline-block; + margin-bottom: 0; + } + + // STATE: is selected + .field-input:checked + .field-input-label, + .field-radio:checked + .field-input-label, + &.is-active, + &.is-selected { + color: $label-color-active; + } + } + + .field-message { + font-size: $font-size-small; + border-bottom-left-radius: $component-border-radius; + border-bottom-right-radius: $component-border-radius; + + &.has-error { + padding: $spacing-vertical-x-small; + background: $error-base; + color: $grayscale-white; + } + } + + .field-input, + .field-select, + .field-textarea { + display: inline-block; + padding: rem($baseline/2); + border: 1px solid $input-default-border-color; + background: $input-default-background; + font-size: $font-size-base; + color: $input-default-color; + + // STATE: is active or has focus + &:focus, + &.is-active { + border-color: $input-default-focus-border-color; + color: $input-default-focus-color; + } + + // STATE: has an error + &.has-error { + border-color: $error-base; + + & + .field-hint { + color: $error-dark; + } + + .icon { + fill: $error-base; + } + } + } + + + // ---------------------------- + // #INPUT TEXT + // ---------------------------- + .input-text { + + &.input-alt { + padding: $spacing-vertical-small 0; + border-width: 0 0 2px 0; + background: $input-alt-background; + + // STATE: is active or has focus + &:focus, + &.is-active { + border-color: $input-alt-focus-border-color; + background: $input-alt-background; + } + + &.has-error { + border-width: 1px 1px 2px 1px; + border-color: $error-base; + } + } + } + + // ---------------------------- + // Buttons + // ---------------------------- + .form-btn { + display: inline-block; + margin: 10px; + border-style: solid; + border-radius: $component-border-radius; + border-width: 1px; + padding: $spacing-vertical-x-small $baseline; + font-size: $font-size-base; + font-weight: 600; + } +} diff --git a/lms/templates/discussion/_underscore_templates.html b/lms/templates/discussion/_underscore_templates.html index 7056157416..20100c78e4 100644 --- a/lms/templates/discussion/_underscore_templates.html +++ b/lms/templates/discussion/_underscore_templates.html @@ -11,7 +11,7 @@ template_names = [ 'thread', 'thread-show', 'thread-edit', 'thread-response', 'thread-response-show', 'thread-response-edit', 'response-comment-show', 'response-comment-edit', 'thread-list-item', 'discussion-home', 'search-alert', 'new-post', 'thread-type', 'new-post-menu-entry', 'new-post-menu-category', 'topic', 'post-user-display', - 'inline-discussion', 'pagination', 'user-profile', 'profile-thread' + 'inline-discussion', 'pagination', 'user-profile', 'profile-thread', 'customwmd-prompt' ] %>