diff --git a/cms/static/js/base.js b/cms/static/js/base.js index c8ab1a469145..5f970a89d592 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -75,6 +75,7 @@ function( $body.click(function() { $('.nav-dd .nav-item .wrapper-nav-sub').removeClass('is-shown'); $('.nav-dd .nav-item .title').removeClass('is-selected'); + $('.custom-dropdown .dropdown-options').hide(); }); $('.nav-dd .nav-item, .filterable-column .nav-item').click(function(e) { diff --git a/cms/static/js/spec/views/pages/container_subviews_spec.js b/cms/static/js/spec/views/pages/container_subviews_spec.js index cde0b42d8109..28ea2a4b9196 100644 --- a/cms/static/js/spec/views/pages/container_subviews_spec.js +++ b/cms/static/js/spec/views/pages/container_subviews_spec.js @@ -581,33 +581,6 @@ describe('Container Subviews', function() { }); }); - describe('PublishHistory', function() { - var lastPublishCss = '.wrapper-last-publish'; - - it('renders never published when the block is unpublished', function() { - renderContainerPage(this, mockContainerXBlockHtml, { - published: false, published_on: null, published_by: null - }); - expect(containerPage.$(lastPublishCss).text()).toContain('Never published'); - }); - - it('renders the last published date and user when the block is published', function() { - renderContainerPage(this, mockContainerXBlockHtml); - fetch({ - published: true, published_on: 'Jul 01, 2014 at 12:45 UTC', published_by: 'amako' - }); - expect(containerPage.$(lastPublishCss).text()) - .toContain('Last published Jul 01, 2014 at 12:45 UTC by amako'); - }); - - it('renders correctly when the block is published without publish info', function() { - renderContainerPage(this, mockContainerXBlockHtml); - fetch({ - published: true, published_on: null, published_by: null - }); - expect(containerPage.$(lastPublishCss).text()).toContain('Previously published'); - }); - }); describe('Message Area', function() { var messageSelector = '.container-message .warning', diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js index 8f296ebb6b58..10f9636b08b8 100644 --- a/cms/static/js/views/pages/container.js +++ b/cms/static/js/views/pages/container.js @@ -69,6 +69,7 @@ function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView model: this.model }); this.messageView.render(); + this.clipboardBroadcastChannel = new BroadcastChannel("studio_clipboard_channel"); // Display access message on units and split test components if (!this.isLibraryPage) { this.containerAccessView = new ContainerSubviews.ContainerAccess({ @@ -81,7 +82,8 @@ function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView el: this.$('#publish-unit'), model: this.model, // When "Discard Changes" is clicked, the whole page must be re-rendered. - renderPage: this.render + renderPage: this.render, + clipboardBroadcastChannel: this.clipboardBroadcastChannel, }); this.xblockPublisher.render(); @@ -105,7 +107,6 @@ function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView } this.listenTo(Backbone, 'move:onXBlockMoved', this.onXBlockMoved); - this.clipboardBroadcastChannel = new BroadcastChannel("studio_clipboard_channel"); }, getViewParameters: function() { @@ -158,6 +159,7 @@ function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView if (!self.isLibraryPage && !self.isLibraryContentPage) { self.initializePasteButton(); } + }, block_added: options && options.block_added }); diff --git a/cms/static/js/views/pages/container_subviews.js b/cms/static/js/views/pages/container_subviews.js index fc7f807257ca..69e1ce90c071 100644 --- a/cms/static/js/views/pages/container_subviews.js +++ b/cms/static/js/views/pages/container_subviews.js @@ -106,7 +106,8 @@ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, MoveXBlockUtils, H events: { 'click .action-publish': 'publish', 'click .action-discard': 'discardChanges', - 'click .action-staff-lock': 'toggleStaffLock' + 'click .action-staff-lock': 'toggleStaffLock', + 'click .action-copy': 'copyToClipboard' }, // takes XBlockInfo as a model @@ -116,6 +117,7 @@ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, MoveXBlockUtils, H this.template = this.loadTemplate('publish-xblock'); this.model.on('sync', this.onSync, this); this.renderPage = this.options.renderPage; + this.clipboardBroadcastChannel = this.options.clipboardBroadcastChannel; }, onSync: function(model) { @@ -173,6 +175,50 @@ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, MoveXBlockUtils, H }); }, + copyToClipboard: function(e) { + e.preventDefault(); + e.stopPropagation(); + const clipboardEndpoint = "/api/content-staging/v1/clipboard/"; + const usageKeyToCopy = this.model.get('id'); + // Start showing a "Copying" notification: + ViewUtils.runOperationShowingMessage(gettext('Copying'), () => { + return $.postJSON( + clipboardEndpoint, + { usage_key: usageKeyToCopy }, + ).then((data) => { + const status = data.content?.status; + if (status === "ready") { + // something that enables the paste button in the actions dropdown + this.clipboardBroadcastChannel.postMessage(data); + return data; + } else if (status === "loading") { + // The clipboard is being loaded asynchonously. + // Poll the endpoint until the copying process is complete: + const deferred = $.Deferred(); + const checkStatus = () => { + $.getJSON(clipboardEndpoint, (pollData) => { + const newStatus = pollData.content?.status; + if (newStatus === "ready") { + // something that enables the paste button in actions dropdown + this.clipboardBroadcastChannel.postMessage(pollData); + deferred.resolve(pollData); + } else if (newStatus === "loading") { + setTimeout(checkStatus, 1_000); + } else { + deferred.reject(); + throw new Error(`Unexpected clipboard status "${newStatus}" in successful API response.`); + } + }) + } + setTimeout(checkStatus, 1_000); + return deferred; + } else { + throw new Error(`Unexpected clipboard status "${status}" in successful API response.`); + } + }); + }); + }, + discardChanges: function(e) { var xblockInfo = this.model, renderPage = this.renderPage; diff --git a/cms/static/js/views/utils/xblock_utils.js b/cms/static/js/views/utils/xblock_utils.js index d3c1fce9e00e..9abe0866ed48 100644 --- a/cms/static/js/views/utils/xblock_utils.js +++ b/cms/static/js/views/utils/xblock_utils.js @@ -8,7 +8,7 @@ function($, _, gettext, ViewUtils, ModuleUtils, XBlockInfo, StringUtils) { var addXBlock, duplicateXBlock, deleteXBlock, createUpdateRequestData, updateXBlockField, VisibilityState, getXBlockVisibilityClass, getXBlockListTypeClass, updateXBlockFields, getXBlockType, findXBlockInfo, - moveXBlock; + moveXBlock, pasteXBlock; /** * Represents the possible visibility states for an xblock: @@ -69,6 +69,85 @@ function($, _, gettext, ViewUtils, ModuleUtils, XBlockInfo, StringUtils) { }); }; + pasteXBlock = function(target) { + var parentLocator = target.data('parent'), + displayName = target.data('default-name'); + + return ViewUtils.runOperationShowingMessage(gettext('Pasting'), () => { + return $.postJSON(ModuleUtils.getUpdateUrl(), { + parent_locator: parentLocator, + staged_content: "clipboard", + }).then((data) => { + return data; + }); + }).done((data) => { + const { + conflicting_files: conflictingFiles, + error_files: errorFiles, + new_files: newFiles, + } = data.static_file_notices; + + const notices = []; + if (errorFiles.length) { + notices.push((next) => new PromptView.Error({ + title: gettext("Some errors occurred"), + message: ( + gettext("The following required files could not be added to the course:") + + " " + errorFiles.join(", ") + ), + actions: {primary: {text: gettext("OK"), click: (x) => { x.hide(); next(); }}}, + })); + } + if (conflictingFiles.length) { + notices.push((next) => new PromptView.Warning({ + title: gettext("You may need to update a file(s) manually"), + message: ( + gettext( + "The following files already exist in this course but don't match the " + + "version used by the component you pasted:" + ) + " " + conflictingFiles.join(", ") + ), + actions: {primary: {text: gettext("OK"), click: (x) => { x.hide(); next(); }}}, + })); + } + if (newFiles.length) { + notices.push(() => new NotificationView.Info({ + title: gettext("New file(s) added to Files & Uploads."), + message: ( + gettext("The following required files were imported to this course:") + + " " + newFiles.join(", ") + ), + actions: { + primary: { + text: gettext('View files'), + click: function(notification) { + const article = document.querySelector('[data-course-assets]'); + const assetsUrl = $(article).attr('data-course-assets'); + window.location.href = assetsUrl; + return; + } + }, + secondary: { + text: gettext('Dismiss'), + click: function(notification) { + return notification.hide(); + } + } + } + })); + } + if (notices.length) { + // Show the notices, one at a time: + const showNext = () => { + const view = notices.shift()(showNext); + view.show(); + } + // Delay to avoid conflict with the "Pasting..." notification. + setTimeout(showNext, 1250); + } + }); + }; + /** * Duplicates the specified xblock element in its parent xblock. * @param {jquery Element} xblockElement The xblock element to be duplicated. @@ -308,6 +387,7 @@ function($, _, gettext, ViewUtils, ModuleUtils, XBlockInfo, StringUtils) { getXBlockListTypeClass: getXBlockListTypeClass, updateXBlockFields: updateXBlockFields, getXBlockType: getXBlockType, - findXBlockInfo: findXBlockInfo + findXBlockInfo: findXBlockInfo, + pasteXBlock: pasteXBlock }; }); diff --git a/cms/static/js/views/xblock.js b/cms/static/js/views/xblock.js index 6b913d5239da..adedd2e2c093 100644 --- a/cms/static/js/views/xblock.js +++ b/cms/static/js/views/xblock.js @@ -14,6 +14,10 @@ function($, _, ViewUtils, BaseView, XBlock, HtmlUtils) { 'click .notification-action-button': 'fireNotificationActionEvent' }, + options: { + clipboardData: { content: null }, + }, + initialize: function() { BaseView.prototype.initialize.call(this); this.view = this.options.view; diff --git a/cms/static/sass/elements/_navigation.scss b/cms/static/sass/elements/_navigation.scss index 97ff8b8aacfb..453108c0e548 100644 --- a/cms/static/sass/elements/_navigation.scss +++ b/cms/static/sass/elements/_navigation.scss @@ -288,6 +288,11 @@ $seq-nav-height: 40px; ol { display: flex; + .custom-dropdown { + position: relative; + display: inline-flex; + } + li { box-sizing: border-box; min-width: 40px; @@ -300,6 +305,47 @@ $seq-nav-height: 40px; @include border-right-style(solid); } + .dropdown-main-button { + border-right: 1px solid #e7e7e7 !important; + } + + .dropdown-toggle-button { + width: 15% !important; + + &:hover { + border-bottom: 1px solid #e7e7e7 !important; + } + } + + .dropdown-options { + position: absolute; + top: 100%; + z-index: 1000; + background-color: #ffffff; + min-width: 265px; + right: 0; + + li { + padding: 0.5em 1em; + cursor: pointer; + + a { + display: block; + width: 100%; + color: black; + } + + .checkmark { + float: right; + margin-left: 10px; + } + } + } + + .dropdown-options li:hover { + background-color: #f1f1f1; + } + button { @extend %ui-fake-link; @extend %ui-clear-button; diff --git a/cms/static/sass/views/_container.scss b/cms/static/sass/views/_container.scss index cf8824ca5110..4f5fafcc60f6 100644 --- a/cms/static/sass/views/_container.scss +++ b/cms/static/sass/views/_container.scss @@ -239,6 +239,19 @@ color: $gray-l1; } } + + .action-copy { + width: 100%; + border-color: #0075b4; + padding-top: 10px; + padding-bottom: 10px; + line-height: 24px; + border-radius: 4px; + + &:hover { + @extend %btn-primary-blue; + } + } } } diff --git a/cms/templates/container.html b/cms/templates/container.html index 2f83c0c6d5e1..f72ab7ea4874 100644 --- a/cms/templates/container.html +++ b/cms/templates/container.html @@ -53,26 +53,57 @@ clipboardData: ${user_clipboard | n, dump_js_escaped_json}, } ); - require(["js/models/xblock_info", "js/views/xblock", "js/views/utils/xblock_utils", "common/js/components/utils/view_utils"], function (XBlockInfo, XBlockView, XBlockUtils, ViewUtils) { + + require(["js/models/xblock_info", "js/views/xblock", "js/views/utils/xblock_utils", "common/js/components/utils/view_utils", "gettext"], function (XBlockInfo, XBlockView, XBlockUtils, ViewUtils, gettext) { var model = new XBlockInfo({ id: '${subsection.location|n, decode.utf8}' }); var xblockView = new XBlockView({ model: model, el: $('#sequence-nav'), - view: 'author_view?position=${position|n, decode.utf8}&next_url=${next_url|n, decode.utf8}&prev_url=${prev_url|n, decode.utf8}' + view: 'author_view?position=${position|n, decode.utf8}&next_url=${next_url|n, decode.utf8}&prev_url=${prev_url|n, decode.utf8}', + clipboardData: ${user_clipboard | n, dump_js_escaped_json}, }); + xblockView.xblockReady = function() { - $('.seq_new_button').click(function(evt) { - evt.preventDefault(); - XBlockUtils.addXBlock($(evt.target)).done(function(locator) { + + var toggleCaretButton = function(clipboardData) { + if (clipboardData && clipboardData.content && clipboardData.source_usage_key.includes("vertical")) { + $('.dropdown-toggle-button').show(); + } else { + $('.dropdown-toggle-button').hide(); + $('.dropdown-options').hide(); + } + }; + this.clipboardBroadcastChannel = new BroadcastChannel("studio_clipboard_channel"); + this.clipboardBroadcastChannel.onmessage = (event) => { + toggleCaretButton(event.data); + }; + toggleCaretButton(this.options.clipboardData); + + $('#new-unit-button').on('click', function(event) { + event.preventDefault(); + XBlockUtils.addXBlock($(this)).done(function(locator) { ViewUtils.redirect('/container/' + locator + '?action=new'); - return false; }); - return false; }); + $('.custom-dropdown .dropdown-toggle-button').on('click', function(event) { + event.stopPropagation(); // Prevent the event from closing immediately when we open it + $(this).next('.dropdown-options').slideToggle('fast'); // This toggles the dropdown visibility + var isExpanded = $(this).attr('aria-expanded') === 'true'; + $(this).attr('aria-expanded', !isExpanded); + }); + + $('.seq_paste_unit').on('click', function(event) { + event.preventDefault(); + $('.dropdown-options').hide(); + XBlockUtils.pasteXBlock($(this)).done(function(data) { + ViewUtils.redirect('/container/' + data.locator + '?action=new'); + }); + }); }; + xblockView.render(); }); diff --git a/cms/templates/js/publish-history.underscore b/cms/templates/js/publish-history.underscore index ca8648503385..354faa55ee7e 100644 --- a/cms/templates/js/publish-history.underscore +++ b/cms/templates/js/publish-history.underscore @@ -10,8 +10,3 @@ if (published_on && published_by) { copy = gettext("Previously published"); } %> - -
- <% // xss-lint: disable=underscore-not-escaped %> -

<%= copy %>

-
diff --git a/cms/templates/js/publish-xblock.underscore b/cms/templates/js/publish-xblock.underscore index cc78ba256456..66ef72fe8c81 100644 --- a/cms/templates/js/publish-xblock.underscore +++ b/cms/templates/js/publish-xblock.underscore @@ -128,4 +128,14 @@ var visibleToStaffOnly = visibilityState === 'staff_only'; +
+ +
diff --git a/lms/templates/seq_block.html b/lms/templates/seq_block.html index 58cb2d2e7e65..3c8699bc8f18 100644 --- a/lms/templates/seq_block.html +++ b/lms/templates/seq_block.html @@ -85,22 +85,17 @@ % endfor % endif % if exclude_units: -
  • - +
  • % endif diff --git a/scripts/xsslint_thresholds.json b/scripts/xsslint_thresholds.json index 6c35512f3398..26c267c074cd 100644 --- a/scripts/xsslint_thresholds.json +++ b/scripts/xsslint_thresholds.json @@ -11,7 +11,7 @@ "django-trans-missing-escape": 0, "javascript-concat-html": 2, "javascript-escape": 1, - "javascript-jquery-append": 1, + "javascript-jquery-append": 2, "javascript-jquery-html": 5, "javascript-jquery-insert-into-target": 2, "javascript-jquery-insertion": 0, @@ -36,5 +36,5 @@ "python-wrap-html": 0, "underscore-not-escaped": 2 }, - "total": 63 + "total": 64 }