From 07629dd9abd14c0a9d39aca46f7311ec50b4b5bf Mon Sep 17 00:00:00 2001 From: William Dibbern Date: Sun, 15 Sep 2013 13:14:36 -0500 Subject: [PATCH] Publish button amendments Fixes #667 - Removed superfluous as-of-yet-unused options in the publish menu. - Adjusted display names of publish buttons according to differing states the publish menu can be in (new post, saved draft, published post). - Added red highlight style to "important" status change options in the publish menu (draft => published, published => unpublished). - Added suite of functional tests around new labels and classes. --- core/client/assets/sass/layouts/editor.scss | 8 +- core/client/assets/sass/modules/forms.scss | 1 + core/client/views/editor.js | 121 +++++++++------- core/server/views/editor.hbs | 10 +- core/test/functional/admin/03_editor_test.js | 140 ++++++++++++++++++- core/test/functional/admin/06_flow_test.js | 4 +- 6 files changed, 218 insertions(+), 66 deletions(-) diff --git a/core/client/assets/sass/layouts/editor.scss b/core/client/assets/sass/layouts/editor.scss index be4a40ea6d..310a2669ff 100644 --- a/core/client/assets/sass/layouts/editor.scss +++ b/core/client/assets/sass/layouts/editor.scss @@ -420,9 +420,11 @@ body.zen { box-shadow: rgba(255,255,255,0.4) 0 1px 0 inset; } - .splitbutton-save{ - .button-save{ - @include transition(width 0.25s ease); + .splitbutton-save, + .splitbutton-delete{ + .button-save, + .button-delete{ + @include transition(width 0.25s ease, background-color 0.3s linear); } .editor-options{ diff --git a/core/client/assets/sass/modules/forms.scss b/core/client/assets/sass/modules/forms.scss index 043f1f9e64..77acd79d6f 100644 --- a/core/client/assets/sass/modules/forms.scss +++ b/core/client/assets/sass/modules/forms.scss @@ -315,6 +315,7 @@ input[type="reset"] { @include transition-duration(0.3); @include transition-timing-function(ease); }; + @include transition(background-color 0.3s linear); // Keep the arrow spun when the associated menu is open &.active:before { diff --git a/core/client/views/editor.js b/core/client/views/editor.js index 172139c04f..18401b9d3e 100644 --- a/core/client/views/editor.js +++ b/core/client/views/editor.js @@ -54,23 +54,24 @@ events: { 'click [data-set-status]': 'handleStatus', - 'click .js-post-button': 'handlePostButton' + 'click .js-publish-button': 'handlePostButton' }, - statusMap: { + statusMap: null, + + createStatusMap: { 'draft': 'Save Draft', - 'published': 'Publish Now', - 'scheduled': 'Save Schedued Post', - 'queue': 'Add to Queue', - 'publish-on': 'Publish on...' + 'published': 'Publish Now' + }, + + updateStatusMap: { + 'draft': 'Unpublish', + 'published': 'Update Post' }, notificationMap: { - 'draft': 'has been saved as a draft', - 'published': 'has been published', - 'scheduled': 'has been scheduled', - 'queue': 'has been added to the queue', - 'publish-on': 'will be published' + 'draft': 'saved as a draft', + 'published': 'published' }, initialize: function () { @@ -94,66 +95,72 @@ toggleStatus: function () { var self = this, keys = Object.keys(this.statusMap), - model = this.model, - prevStatus = this.model.get('status'), + model = self.model, + prevStatus = model.get('status'), currentIndex = keys.indexOf(prevStatus), newIndex; + newIndex = currentIndex + 1 > keys.length - 1 ? 0 : currentIndex + 1; - if (keys[currentIndex + 1] === 'scheduled') { // TODO: Remove once scheduled posts work - newIndex = currentIndex + 2 > keys.length - 1 ? 0 : currentIndex + 1; - } else { - newIndex = currentIndex + 1 > keys.length - 1 ? 0 : currentIndex + 1; - } + this.setActiveStatus(keys[newIndex], this.statusMap[keys[newIndex]], prevStatus); this.savePost({ status: keys[newIndex] }).then(function () { Ghost.notifications.addItem({ type: 'success', - message: 'Your post ' + this.notificationMap[newIndex] + '.', + message: 'Your post has been ' + self.notificationMap[newIndex] + '.', status: 'passive' }); }, function (xhr) { var status = keys[newIndex]; // Show a notification about the error self.reportSaveError(xhr, model, status); - // Set the button text back to previous - model.set({ status: prevStatus }); }); }, - setActiveStatus: function (status, displayText) { - // Set the publish button's action - $('.js-post-button') - .attr('data-status', status) - .text(displayText); + setActiveStatus: function (newStatus, displayText, currentStatus) { + var isPublishing = (newStatus === 'published' && currentStatus !== 'published'), + isUnpublishing = (newStatus === 'draft' && currentStatus === 'published'), + // Controls when background of button has the splitbutton-delete/button-delete classes applied + isImportantStatus = (isPublishing || isUnpublishing); + + $('.js-publish-splitbutton') + .removeClass(isImportantStatus ? 'splitbutton-save' : 'splitbutton-delete') + .addClass(isImportantStatus ? 'splitbutton-delete' : 'splitbutton-save'); + + // Set the publish button's action and proper coloring + $('.js-publish-button') + .attr('data-status', newStatus) + .text(displayText) + .removeClass(isImportantStatus ? 'button-save' : 'button-delete') + .addClass(isImportantStatus ? 'button-delete' : 'button-save'); // Remove the animated popup arrow - $('.splitbutton-save > a') + $('.js-publish-splitbutton > a') .removeClass('active'); // Set the active action in the popup - $('.splitbutton-save .editor-options li') + $('.js-publish-splitbutton .editor-options li') .removeClass('active') - .filter(['li[data-set-status="', status, '"]'].join('')) + .filter(['li[data-set-status="', newStatus, '"]'].join('')) .addClass('active'); }, handleStatus: function (e) { if (e) { e.preventDefault(); } - var status = $(e.currentTarget).attr('data-set-status'); + var status = $(e.currentTarget).attr('data-set-status'), + currentStatus = this.model.get('status'); - this.setActiveStatus(status, this.statusMap[status]); + this.setActiveStatus(status, this.statusMap[status], currentStatus); // Dismiss the popup menu $('body').find('.overlay:visible').fadeOut(); }, handlePostButton: function (e) { - e.preventDefault(); - - var status = $(e.currentTarget).attr("data-status"); + if (e) { e.preventDefault(); } + var status = $(e.currentTarget).attr('data-status'); this.updatePost(status); }, @@ -167,32 +174,23 @@ // Default to same status if not passed in status = status || prevStatus; - if (status === 'publish-on') { - return Ghost.notifications.addItem({ - type: 'alert', - message: 'Scheduled publishing not supported yet.', - status: 'passive' - }); - } - if (status === 'queue') { - return Ghost.notifications.addItem({ - type: 'alert', - message: 'Scheduled publishing not supported yet.', - status: 'passive' - }); - } - - this.model.trigger('willSave'); + model.trigger('willSave'); this.savePost({ status: status }).then(function () { Ghost.notifications.addItem({ type: 'success', - message: ['Your post ', notificationMap[status], '.'].join(''), + message: ['Your post has been ', notificationMap[status], '.'].join(''), status: 'passive' }); + // Refresh publish button and all relevant controls with updated status. + self.render(); }, function (xhr) { + // Set the model status back to previous + model.set({ status: prevStatus }); + // Set appropriate button status + self.setActiveStatus(status, self.statusMap[status], prevStatus); // Show a notification about the error self.reportSaveError(xhr, model, status); }); @@ -219,7 +217,8 @@ reportSaveError: function (response, model, status) { var title = model.get('title') || '[Untitled]', - message = 'Your post: ' + title + ' has not been ' + status; + notificationStatus = this.notificationMap[status], + message = 'Your post: ' + title + ' has not been ' + notificationStatus; if (response) { // Get message from response @@ -236,11 +235,27 @@ }); }, + setStatusLabels: function (statusMap) { + _.each(statusMap, function (label, status) { + $('li[data-set-status="' + status + '"] > a').text(label); + }); + }, + render: function () { var status = this.model.get('status'); + // Assume that we're creating a new post + if (status !== 'published') { + this.statusMap = this.createStatusMap; + } else { + this.statusMap = this.updateStatusMap; + } + + // Populate the publish menu with the appropriate verbiage + this.setStatusLabels(this.statusMap); + // Default the selected publish option to the current status of the post. - this.setActiveStatus(status, this.statusMap[status]); + this.setActiveStatus(status, this.statusMap[status], status); } }); diff --git a/core/server/views/editor.hbs b/core/server/views/editor.hbs index 5f5197aeb8..ba6ea2c2e7 100644 --- a/core/server/views/editor.hbs +++ b/core/server/views/editor.hbs @@ -64,14 +64,12 @@ -
- +
+
diff --git a/core/test/functional/admin/03_editor_test.js b/core/test/functional/admin/03_editor_test.js index 8e0594d868..d2004e12cf 100644 --- a/core/test/functional/admin/03_editor_test.js +++ b/core/test/functional/admin/03_editor_test.js @@ -18,7 +18,7 @@ casper.test.begin("Ghost editor is correct", 10, function suite(test) { } // test saving with no data - casper.thenClick('.button-save'); + casper.thenClick('.js-publish-button'); casper.waitForSelector('.notification-error', function onSuccess() { test.assert(true, 'Save without title results in error notification as expected'); @@ -39,7 +39,7 @@ casper.test.begin("Ghost editor is correct", 10, function suite(test) { casper.on('resource.received', handleResource); }); - casper.thenClick('.button-save'); + casper.thenClick('.js-publish-button'); casper.waitForResource(/posts/, function checkPostWasCreated() { var urlRegExp = new RegExp("^" + url + "ghost\/editor\/[0-9]*"); @@ -153,6 +153,142 @@ casper.test.begin('Title Trimming', function suite(test) { }, trimmedTitle, 'Entry title should match expected value.'); }); + casper.run(function () { + test.done(); + }); +}); + +casper.test.begin('Publish menu - new post', function suite(test) { + test.filename = 'publish_menu_new_post.png'; + + casper.start(url + 'ghost/editor/', function testTitleAndUrl() { + test.assertTitle('', 'Ghost admin has no title'); + }).viewport(1280, 1024); + + // ... check default option status, label, class + casper.then(function () { + test.assertExists('.js-publish-splitbutton'); + test.assertExists('.js-publish-splitbutton.splitbutton-save'); + test.assertExists('.js-publish-button'); + test.assertExists('.js-publish-button.button-save'); + test.assertSelectorHasText('.js-publish-button', 'Save Draft'); + test.assertEval(function() { + return (__utils__.findOne('.js-publish-button').getAttribute('data-status') === 'draft'); + }, 'Publish button\'s initial status should be "draft"'); + }); + + casper.then(function () { + // ... click the menu + this.click('.js-publish-splitbutton .options.up'); + // ... click publish + this.click('.js-publish-splitbutton li[data-set-status="published"]'); + }); + + // ... check status, label, class + casper.then(function () { + test.assertExists('.js-publish-splitbutton.splitbutton-delete', 'Publish split button should have .splitbutton-delete'); + test.assertExists('.js-publish-button.button-delete', 'Publish button should have .button-delete'); + test.assertSelectorHasText('.js-publish-button', 'Publish Now'); + test.assertEval(function() { + return (__utils__.findOne('.js-publish-button').getAttribute('data-status') === 'published'); + }, 'Publish button\'s updated status should be "published"'); + }); + + casper.run(function () { + test.done(); + }); +}); + +casper.test.begin('Publish menu - existing post', function suite(test) { + test.filename = 'publish_menu_existing_post.png'; + + // Create a post, save it and test refreshed editor + casper.start(url + 'ghost/editor/', function testTitleAndUrl() { + test.assertTitle('', 'Ghost admin has no title'); + }).viewport(1280, 1024); + + casper.then(function createTestPost() { + casper.sendKeys('#entry-title', testPost.title); + casper.writeContentToCodeMirror(testPost.html); + }); + + // We must wait after sending keys to CodeMirror + casper.wait(1000, function doneWait() { + this.echo("I've waited for 1 seconds."); + }); + + // Create a post in draft status + casper.thenClick('.js-publish-button'); + + casper.waitForResource(/posts/, function checkPostWasCreated() { + var urlRegExp = new RegExp("^" + url + "ghost\/editor\/[0-9]*"); + test.assertUrlMatch(urlRegExp, 'got an id on our URL'); + }); + + // ... check option status, label, class now that we're *saved* as 'draft' + casper.then(function () { + test.assertExists('.js-publish-splitbutton'); + test.assertExists('.js-publish-splitbutton.splitbutton-save'); + test.assertExists('.js-publish-button'); + test.assertExists('.js-publish-button.button-save'); + test.assertSelectorHasText('.js-publish-button', 'Save Draft'); + test.assertEval(function() { + return (__utils__.findOne('.js-publish-button').getAttribute('data-status') === 'draft'); + }, 'Publish button\'s initial status should be "draft"'); + }); + + // Open the publish options menu; + casper.thenClick('.js-publish-splitbutton .options.up'); + + // Select the publish post button + casper.thenClick('.js-publish-splitbutton li[data-set-status="published"]'); + + // ... check status, label, class + casper.then(function () { + test.assertExists('.js-publish-splitbutton.splitbutton-delete', 'Publish split button should have .splitbutton-delete'); + test.assertExists('.js-publish-button.button-delete', 'Publish button should have .button-delete'); + test.assertSelectorHasText('.js-publish-button', 'Publish Now'); + test.assertEval(function() { + return (__utils__.findOne('.js-publish-button').getAttribute('data-status') === 'published'); + }, 'Publish button\'s updated status should be "published"'); + }); + + // Publish the post + casper.thenClick('.js-publish-button'); + + casper.waitForResource(/posts/, function checkPostWasCreated() { + var urlRegExp = new RegExp("^" + url + "ghost\/editor\/[0-9]*"); + test.assertUrlMatch(urlRegExp, 'got an id on our URL'); + }); + + // ... check option status, label, class for saved as 'published' + casper.then(function () { + test.assertExists('.js-publish-splitbutton'); + test.assertExists('.js-publish-splitbutton.splitbutton-save'); + test.assertExists('.js-publish-button'); + test.assertExists('.js-publish-button.button-save'); + test.assertSelectorHasText('.js-publish-button', 'Update Post'); + test.assertEval(function() { + return (__utils__.findOne('.js-publish-button').getAttribute('data-status') === 'published'); + }, 'Publish button\'s initial status on an already published post should be "published"'); + }); + + // Open the publish options menu + casper.thenClick('.js-publish-splitbutton .options.up'); + + // Click the 'unpublish' option + casper.thenClick('.js-publish-splitbutton li[data-set-status="draft"]'); + + // ... check status, label, class + casper.then(function () { + test.assertExists('.js-publish-splitbutton.splitbutton-delete', 'Publish split button should have .splitbutton-delete'); + test.assertExists('.js-publish-button.button-delete', 'Publish button should have .button-delete'); + test.assertSelectorHasText('.js-publish-button', 'Unpublish'); + test.assertEval(function() { + return (__utils__.findOne('.js-publish-button').getAttribute('data-status') === 'draft'); + }, 'Publish button\'s updated status should be "draft"'); + }); + casper.run(function () { test.done(); }); diff --git a/core/test/functional/admin/06_flow_test.js b/core/test/functional/admin/06_flow_test.js index 50bf9e59d7..72a57f7113 100644 --- a/core/test/functional/admin/06_flow_test.js +++ b/core/test/functional/admin/06_flow_test.js @@ -21,7 +21,7 @@ casper.test.begin("Ghost edit draft flow works correctly", 7, function suite(tes this.echo("I've waited for 1 seconds."); }); - casper.thenClick('.button-save'); + casper.thenClick('.js-publish-button'); casper.waitForResource(/posts/); casper.waitForSelector('.notification-success', function onSuccess() { @@ -46,7 +46,7 @@ casper.test.begin("Ghost edit draft flow works correctly", 7, function suite(tes test.assertUrlMatch(/editor/, "Ghost sucessfully loaded the editor page again"); }); - casper.thenClick('.button-save'); + casper.thenClick('.js-publish-button'); casper.waitForResource(/posts/); casper.waitForSelector('.notification-success', function onSuccess() {