From 89b7ff3320b530560eb93941549089e6ede245aa Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Thu, 15 Oct 2015 13:03:26 +0100 Subject: [PATCH] Routable tags refs #5845 - Updates tag settings screen to match content screen behaviour. Each now tag has it's own route that is link-able from other areas of the app - Updates a number of places where jQuery event handler code was not wrapped in Ember's run loop --- ghost/admin/app/adapters/application.js | 2 +- ghost/admin/app/components/gh-cm-editor.js | 19 +- ghost/admin/app/components/gh-dropdown.js | 10 +- .../app/components/gh-tag-settings-form.js | 127 ++++++ ghost/admin/app/components/gh-uploader.js | 53 +-- .../app/controllers/modals/delete-tag.js | 14 +- ghost/admin/app/controllers/settings/tags.js | 156 +------- .../app/controllers/settings/tags/tag.js | 40 ++ ghost/admin/app/mixins/shortcuts-route.js | 4 +- ghost/admin/app/router.js | 5 +- ghost/admin/app/routes/settings/tags.js | 84 +++- ghost/admin/app/routes/settings/tags/index.js | 13 + ghost/admin/app/routes/settings/tags/new.js | 15 + ghost/admin/app/routes/settings/tags/tag.js | 9 + ghost/admin/app/styles/layouts/content.css | 4 +- ghost/admin/app/styles/layouts/tags.css | 53 ++- .../components/gh-tag-settings-form.hbs | 78 ++++ ghost/admin/app/templates/settings/tags.hbs | 43 +- .../app/templates/settings/tags/index.hbs | 6 + .../admin/app/templates/settings/tags/tag.hbs | 1 + ghost/admin/app/utils/document-title.js | 6 +- .../acceptance/settings/navigation-test.js | 57 +-- .../tests/acceptance/settings/tags-test.js | 375 ++++++++++++++++++ ghost/admin/tests/fixtures/settings.js | 51 +++ ghost/admin/tests/index.html | 2 +- .../components/gh-cm-editor-test.js | 53 +++ .../components/gh-tag-settings-form-test.js | 301 ++++++++++++++ 27 files changed, 1276 insertions(+), 305 deletions(-) create mode 100644 ghost/admin/app/components/gh-tag-settings-form.js create mode 100644 ghost/admin/app/controllers/settings/tags/tag.js create mode 100644 ghost/admin/app/routes/settings/tags/index.js create mode 100644 ghost/admin/app/routes/settings/tags/new.js create mode 100644 ghost/admin/app/routes/settings/tags/tag.js create mode 100644 ghost/admin/app/templates/components/gh-tag-settings-form.hbs create mode 100644 ghost/admin/app/templates/settings/tags/index.hbs create mode 100644 ghost/admin/app/templates/settings/tags/tag.hbs create mode 100644 ghost/admin/tests/acceptance/settings/tags-test.js create mode 100644 ghost/admin/tests/fixtures/settings.js create mode 100644 ghost/admin/tests/integration/components/gh-cm-editor-test.js create mode 100644 ghost/admin/tests/integration/components/gh-tag-settings-form-test.js diff --git a/ghost/admin/app/adapters/application.js b/ghost/admin/app/adapters/application.js index b1adf340e7..42d45faf65 100644 --- a/ghost/admin/app/adapters/application.js +++ b/ghost/admin/app/adapters/application.js @@ -6,4 +6,4 @@ export default EmbeddedRelationAdapter.extend({ return false; } - }); +}); diff --git a/ghost/admin/app/components/gh-cm-editor.js b/ghost/admin/app/components/gh-cm-editor.js index 5e9d211db5..ebf9525c53 100644 --- a/ghost/admin/app/components/gh-cm-editor.js +++ b/ghost/admin/app/components/gh-cm-editor.js @@ -18,20 +18,17 @@ export default Ember.Component.extend({ didInsertElement: function () { var options = this.getProperties('lineNumbers', 'indentUnit', 'mode', 'theme'), - self = this, - editor; - editor = new CodeMirror(this.get('element'), options); + editor = new CodeMirror(this.get('element'), options); + editor.getDoc().setValue(this.get('value')); // events - editor.on('focus', function () { - self.set('isFocused', true); - }); - editor.on('blur', function () { - self.set('isFocused', false); - }); - editor.on('change', function () { - self.set('value', editor.getDoc().getValue()); + editor.on('focus', Ember.run.bind(this, 'set', 'isFocused', true)); + editor.on('blur', Ember.run.bind(this, 'set', 'isFocused', false)); + editor.on('change', () => { + Ember.run(this, function () { + this.set('value', editor.getDoc().getValue()); + }); }); this.set('editor', editor); diff --git a/ghost/admin/app/components/gh-dropdown.js b/ghost/admin/app/components/gh-dropdown.js index ded61279be..1bba5b3a99 100644 --- a/ghost/admin/app/components/gh-dropdown.js +++ b/ghost/admin/app/components/gh-dropdown.js @@ -37,10 +37,12 @@ export default Ember.Component.extend(DropdownMixin, { } this.$().on('animationend webkitAnimationEnd oanimationend MSAnimationEnd', function (event) { if (event.originalEvent.animationName === 'fade-out') { - if (self.get('closing')) { - self.set('isOpen', false); - self.set('closing', false); - } + Ember.run(self, function () { + if (this.get('closing')) { + this.set('isOpen', false); + this.set('closing', false); + } + }); } }); }, diff --git a/ghost/admin/app/components/gh-tag-settings-form.js b/ghost/admin/app/components/gh-tag-settings-form.js new file mode 100644 index 0000000000..e3d6891424 --- /dev/null +++ b/ghost/admin/app/components/gh-tag-settings-form.js @@ -0,0 +1,127 @@ +/* global key */ +import Ember from 'ember'; +import boundOneWay from 'ghost/utils/bound-one-way'; + +const {get} = Ember; + +export default Ember.Component.extend({ + + tag: null, + + scratchName: boundOneWay('tag.name'), + scratchSlug: boundOneWay('tag.slug'), + scratchDescription: boundOneWay('tag.description'), + scratchMetaTitle: boundOneWay('tag.meta_title'), + scratchMetaDescription: boundOneWay('tag.meta_description'), + + isViewingSubview: false, + + config: Ember.inject.service(), + + title: Ember.computed('tag.isNew', function () { + if (this.get('tag.isNew')) { + return 'New Tag'; + } else { + return 'Tag Settings'; + } + }), + + seoTitle: Ember.computed('scratchName', 'scratchMetaTitle', function () { + let metaTitle = this.get('scratchMetaTitle') || ''; + + metaTitle = metaTitle.length > 0 ? metaTitle : this.get('scratchName'); + + if (metaTitle && metaTitle.length > 70) { + metaTitle = metaTitle.substring(0, 70).trim(); + metaTitle = Ember.Handlebars.Utils.escapeExpression(metaTitle); + metaTitle = Ember.String.htmlSafe(metaTitle + '…'); + } + + return metaTitle; + }), + + seoURL: Ember.computed('scratchSlug', function () { + const blogUrl = this.get('config.blogUrl'), + seoSlug = this.get('scratchSlug') || ''; + + let seoURL = blogUrl + '/tag/' + seoSlug; + + // only append a slash to the URL if the slug exists + if (seoSlug) { + seoURL += '/'; + } + + if (seoURL.length > 70) { + seoURL = seoURL.substring(0, 70).trim(); + seoURL = Ember.String.htmlSafe(seoURL + '…'); + } + + return seoURL; + }), + + seoDescription: Ember.computed('scratchDescription', 'scratchMetaDescription', function () { + let metaDescription = this.get('scratchMetaDescription') || ''; + + metaDescription = metaDescription.length > 0 ? metaDescription : this.get('scratchDescription'); + + if (metaDescription && metaDescription.length > 156) { + metaDescription = metaDescription.substring(0, 156).trim(); + metaDescription = Ember.Handlebars.Utils.escapeExpression(metaDescription); + metaDescription = Ember.String.htmlSafe(metaDescription + '…'); + } + + return metaDescription; + }), + + didReceiveAttrs: function (attrs) { + if (get(attrs, 'newAttrs.tag.value.id') !== get(attrs, 'oldAttrs.tag.value.id')) { + this.reset(); + } + }, + + reset: function () { + this.set('isViewingSubview', false); + if (this.$()) { + this.$('.settings-menu-pane').scrollTop(0); + } + }, + + focusIn: function () { + key.setScope('tag-settings-form'); + }, + + focusOut: function () { + key.setScope('default'); + }, + + actions: { + setProperty: function (property, value) { + this.attrs.setProperty(property, value); + }, + + setCoverImage: function (image) { + this.attrs.setProperty('image', image); + }, + + clearCoverImage: function () { + this.attrs.setProperty('image', ''); + }, + + setUploaderReference: function () { + // noop + }, + + openMeta: function () { + this.set('isViewingSubview', true); + }, + + closeMeta: function () { + this.set('isViewingSubview', false); + }, + + deleteTag: function () { + this.sendAction('openModal', 'delete-tag', this.get('tag')); + } + } + +}); diff --git a/ghost/admin/app/components/gh-uploader.js b/ghost/admin/app/components/gh-uploader.js index 3cc6d3ed29..0497f529ba 100644 --- a/ghost/admin/app/components/gh-uploader.js +++ b/ghost/admin/app/components/gh-uploader.js @@ -10,29 +10,6 @@ export default Ember.Component.extend({ return this.get('image') || ''; }), - /** - * Sets up the uploader on render - */ - setup: function () { - var $this = this.$(), - self = this; - - // this.set('uploaderReference', uploader.call($this, { - // editor: true, - // fileStorage: this.get('config.fileStorage') - // })); - - $this.on('uploadsuccess', function (event, result) { - if (result && result !== '' && result !== 'http://') { - self.sendAction('uploaded', result); - } - }); - - $this.on('imagecleared', function () { - self.sendAction('canceled'); - }); - }, - // removes event listeners from the uploader removeListeners: function () { var $this = this.$(); @@ -41,17 +18,6 @@ export default Ember.Component.extend({ $this.find('.js-cancel').off(); }, - // didInsertElement: function () { - // Ember.run.scheduleOnce('afterRender', this, this.setup()); - // }, - didInsertElement: function () { - this.send('initUploader'); - }, - - willDestroyElement: function () { - this.removeListeners(); - }, - // NOTE: because the uploader is sometimes in the same place in the DOM // between transitions Glimmer will re-use the existing elements including // those that arealready decorated by jQuery. The following works around @@ -77,13 +43,20 @@ export default Ember.Component.extend({ } }, + didInsertElement: function () { + this.send('initUploader'); + }, + + willDestroyElement: function () { + this.removeListeners(); + }, + actions: { initUploader: function () { var ref, - el, + el = this.$(), self = this; - el = this.$(); ref = uploader.call(el, { editor: true, fileStorage: this.get('config.fileStorage') @@ -91,13 +64,13 @@ export default Ember.Component.extend({ el.on('uploadsuccess', function (event, result) { if (result && result !== '' && result !== 'http://') { - self.sendAction('uploaded', result); + Ember.run(self, function () { + this.sendAction('uploaded', result); + }); } }); - el.on('imagecleared', function () { - self.sendAction('canceled'); - }); + el.on('imagecleared', Ember.run.bind(self, 'sendAction', 'canceled')); this.sendAction('initUploader', ref); } diff --git a/ghost/admin/app/controllers/modals/delete-tag.js b/ghost/admin/app/controllers/modals/delete-tag.js index 5381daad19..39cd15454a 100644 --- a/ghost/admin/app/controllers/modals/delete-tag.js +++ b/ghost/admin/app/controllers/modals/delete-tag.js @@ -2,6 +2,7 @@ import Ember from 'ember'; export default Ember.Controller.extend({ notifications: Ember.inject.service(), + application: Ember.inject.controller(), postInflection: Ember.computed('model.post_count', function () { return this.get('model.post_count') > 1 ? 'posts' : 'post'; @@ -9,13 +10,18 @@ export default Ember.Controller.extend({ actions: { confirmAccept: function () { - var tag = this.get('model'), - self = this; + var tag = this.get('model'); this.send('closeMenus'); - tag.destroyRecord().catch(function (error) { - self.get('notifications').showAPIError(error, {key: 'tag.delete'}); + tag.destroyRecord().then(() => { + let currentRoute = this.get('application.currentRouteName') || ''; + + if (currentRoute.match(/^settings\.tags/)) { + this.transitionToRoute('settings.tags.index'); + } + }).catch((error) => { + this.get('notifications').showAPIError(error, {key: 'tag.delete'}); }); }, diff --git a/ghost/admin/app/controllers/settings/tags.js b/ghost/admin/app/controllers/settings/tags.js index 9da426ee3f..0b8197e334 100644 --- a/ghost/admin/app/controllers/settings/tags.js +++ b/ghost/admin/app/controllers/settings/tags.js @@ -1,153 +1,21 @@ import Ember from 'ember'; -import SettingsMenuMixin from 'ghost/mixins/settings-menu-controller'; -import boundOneWay from 'ghost/utils/bound-one-way'; -export default Ember.Controller.extend(SettingsMenuMixin, { - tags: Ember.computed.alias('model'), +export default Ember.Controller.extend({ - activeTag: null, - activeTagNameScratch: boundOneWay('activeTag.name'), - activeTagSlugScratch: boundOneWay('activeTag.slug'), - activeTagDescriptionScratch: boundOneWay('activeTag.description'), - activeTagMetaTitleScratch: boundOneWay('activeTag.meta_title'), - activeTagMetaDescriptionScratch: boundOneWay('activeTag.meta_description'), + tagListFocused: Ember.computed.equal('keyboardFocus', 'tagList'), + tagContentFocused: Ember.computed.equal('keyboardFocus', 'tagContent'), - application: Ember.inject.controller(), - config: Ember.inject.service(), - notifications: Ember.inject.service(), + tags: Ember.computed.sort('model', function (a, b) { + const idA = +a.get('id'), + idB = +b.get('id'); - uploaderReference: null, - - // This observer loads and resets the uploader whenever the active tag changes, - // ensuring that we can reuse the whole settings menu. - updateUploader: Ember.observer('activeTag.image', 'uploaderReference', function () { - var uploader = this.get('uploaderReference'), - image = this.get('activeTag.image'); - - if (uploader && uploader[0]) { - if (image) { - uploader[0].uploaderUi.initWithImage(); - } else { - uploader[0].uploaderUi.reset(); - } - } - }), - - saveActiveTagProperty: function (propKey, newValue) { - var activeTag = this.get('activeTag'), - currentValue = activeTag.get(propKey), - self = this; - - newValue = newValue.trim(); - - // Quit if there was no change - if (newValue === currentValue) { - return; + if (idA > idB) { + return 1; + } else if (idA < idB) { + return -1; } - activeTag.set(propKey, newValue); - activeTag.get('hasValidated').addObject(propKey); + return 0; + }) - activeTag.save().catch(function (error) { - if (error) { - self.get('notifications').showAPIError(error, {key: 'tag.save'}); - } - }); - }, - - seoTitle: Ember.computed('scratch', 'activeTagNameScratch', 'activeTagMetaTitleScratch', function () { - var metaTitle = this.get('activeTagMetaTitleScratch') || ''; - - metaTitle = metaTitle.length > 0 ? metaTitle : this.get('activeTagNameScratch'); - - if (metaTitle && metaTitle.length > 70) { - metaTitle = metaTitle.substring(0, 70).trim(); - metaTitle = Ember.Handlebars.Utils.escapeExpression(metaTitle); - metaTitle = Ember.String.htmlSafe(metaTitle + '…'); - } - - return metaTitle; - }), - - seoURL: Ember.computed('activeTagSlugScratch', function () { - var blogUrl = this.get('config.blogUrl'), - seoSlug = this.get('activeTagSlugScratch') ? this.get('activeTagSlugScratch') : '', - seoURL = blogUrl + '/tag/' + seoSlug; - - // only append a slash to the URL if the slug exists - if (seoSlug) { - seoURL += '/'; - } - - if (seoURL.length > 70) { - seoURL = seoURL.substring(0, 70).trim(); - seoURL = Ember.String.htmlSafe(seoURL + '…'); - } - - return seoURL; - }), - - seoDescription: Ember.computed('scratch', 'activeTagDescriptionScratch', 'activeTagMetaDescriptionScratch', function () { - var metaDescription = this.get('activeTagMetaDescriptionScratch') || ''; - - metaDescription = metaDescription.length > 0 ? metaDescription : this.get('activeTagDescriptionScratch'); - - if (metaDescription && metaDescription.length > 156) { - metaDescription = metaDescription.substring(0, 156).trim(); - metaDescription = Ember.Handlebars.Utils.escapeExpression(metaDescription); - metaDescription = Ember.String.htmlSafe(metaDescription + '…'); - } - - return metaDescription; - }), - - actions: { - newTag: function () { - this.set('activeTag', this.store.createRecord('tag', {post_count: 0})); - this.get('activeTag.errors').clear(); - this.send('openSettingsMenu'); - }, - - editTag: function (tag) { - tag.validate(); - this.set('activeTag', tag); - this.send('openSettingsMenu'); - }, - - saveActiveTagName: function (name) { - this.saveActiveTagProperty('name', name); - }, - - saveActiveTagSlug: function (slug) { - this.saveActiveTagProperty('slug', slug); - }, - - saveActiveTagDescription: function (description) { - this.saveActiveTagProperty('description', description); - }, - - saveActiveTagMetaTitle: function (metaTitle) { - this.saveActiveTagProperty('meta_title', metaTitle); - }, - - saveActiveTagMetaDescription: function (metaDescription) { - this.saveActiveTagProperty('meta_description', metaDescription); - }, - - setCoverImage: function (image) { - this.saveActiveTagProperty('image', image); - }, - - clearCoverImage: function () { - this.saveActiveTagProperty('image', ''); - }, - - closeNavMenu: function () { - this.get('application').send('closeNavMenu'); - }, - - setUploaderReference: function (ref) { - this.set('uploaderReference', ref); - } - } }); diff --git a/ghost/admin/app/controllers/settings/tags/tag.js b/ghost/admin/app/controllers/settings/tags/tag.js new file mode 100644 index 0000000000..4e012dac41 --- /dev/null +++ b/ghost/admin/app/controllers/settings/tags/tag.js @@ -0,0 +1,40 @@ +import Ember from 'ember'; + +const {computed} = Ember, + {alias} = computed; + +export default Ember.Controller.extend({ + + tag: alias('model'), + + saveTagProperty: function (propKey, newValue) { + const tag = this.get('tag'), + currentValue = tag.get(propKey); + + newValue = newValue.trim(); + + // Quit if there was no change + if (newValue === currentValue) { + return; + } + + tag.set(propKey, newValue); + // TODO: This is required until .validate/.save mark fields as validated + tag.get('hasValidated').addObject(propKey); + + tag.save().then((savedTag) => { + // replace 'new' route with 'tag' route + this.replaceWith('settings.tags.tag', savedTag); + }).catch((error) => { + if (error) { + this.notifications.showAPIError(error, {key: 'tag.save'}); + } + }); + }, + + actions: { + setProperty: function (propKey, value) { + this.saveTagProperty(propKey, value); + } + } +}); diff --git a/ghost/admin/app/mixins/shortcuts-route.js b/ghost/admin/app/mixins/shortcuts-route.js index 1ca2766203..d894f2b66a 100644 --- a/ghost/admin/app/mixins/shortcuts-route.js +++ b/ghost/admin/app/mixins/shortcuts-route.js @@ -60,7 +60,9 @@ export default Ember.Mixin.create({ key(shortcut, scope, function (event) { // stop things like ctrl+s from actually opening a save dialogue event.preventDefault(); - self.send(action, options); + Ember.run(self, function () { + this.send(action, options); + }); }); }); }, diff --git a/ghost/admin/app/router.js b/ghost/admin/app/router.js index 6a7f4e53e9..6cf2948189 100644 --- a/ghost/admin/app/router.js +++ b/ghost/admin/app/router.js @@ -43,7 +43,10 @@ Router.map(function () { }); this.route('settings.general', {path: '/settings/general'}); - this.route('settings.tags', {path: '/settings/tags'}); + this.route('settings.tags', {path: '/settings/tags'}, function () { + this.route('tag', {path: ':tag_id'}); + this.route('new'); + }); this.route('settings.labs', {path: '/settings/labs'}); this.route('settings.code-injection', {path: '/settings/code-injection'}); this.route('settings.navigation', {path: '/settings/navigation'}); diff --git a/ghost/admin/app/routes/settings/tags.js b/ghost/admin/app/routes/settings/tags.js index ae91eaea5f..a83bbb3b49 100644 --- a/ghost/admin/app/routes/settings/tags.js +++ b/ghost/admin/app/routes/settings/tags.js @@ -1,8 +1,10 @@ +import Ember from 'ember'; import AuthenticatedRoute from 'ghost/routes/authenticated'; import CurrentUserSettings from 'ghost/mixins/current-user-settings'; -import PaginationRouteMixin from 'ghost/mixins/pagination-route'; +import ShortcutsRoute from 'ghost/mixins/shortcuts-route'; +import PaginationRoute from 'ghost/mixins/pagination-route'; -export default AuthenticatedRoute.extend(CurrentUserSettings, PaginationRouteMixin, { +export default AuthenticatedRoute.extend(CurrentUserSettings, PaginationRoute, ShortcutsRoute, { titleToken: 'Settings - Tags', paginationModel: 'tag', @@ -11,6 +13,14 @@ export default AuthenticatedRoute.extend(CurrentUserSettings, PaginationRouteMix limit: 15 }, + shortcuts: { + 'up, k': 'moveUp', + 'down, j': 'moveDown', + left: 'focusList', + right: 'focusContent', + c: 'newTag' + }, + beforeModel: function () { this._super(...arguments); @@ -20,22 +30,70 @@ export default AuthenticatedRoute.extend(CurrentUserSettings, PaginationRouteMix model: function () { this.store.unloadAll('tag'); - this.loadFirstPage(); - return this.store.filter('tag', function (tag) { - return !tag.get('isNew'); - }); - }, - - renderTemplate: function (controller, model) { - this._super(controller, model); - this.render('settings/tags/settings-menu', { - into: 'application', - outlet: 'settings-menu' + return this.loadFirstPage().then(() => { + return this.store.filter('tag', (tag) => { + return !tag.get('isNew'); + }); }); }, deactivate: function () { this.send('resetPagination'); + }, + + stepThroughTags: function (step) { + let currentTag = this.modelFor('settings.tags.tag'), + tags = this.get('controller.tags'), + length = tags.get('length'); + + if (currentTag && length) { + let newPosition = tags.indexOf(currentTag) + step; + + if (newPosition >= length) { + return; + } else if (newPosition < 0) { + return; + } + + this.transitionTo('settings.tags.tag', tags.objectAt(newPosition)); + } + }, + + scrollContent: function (amount) { + let content = Ember.$('.tag-settings-pane'), + scrolled = content.scrollTop(); + + content.scrollTop(scrolled + 50 * amount); + }, + + actions: { + moveUp: function () { + if (this.controller.get('tagContentFocused')) { + this.scrollContent(-1); + } else { + this.stepThroughTags(-1); + } + }, + + moveDown: function () { + if (this.controller.get('tagContentFocused')) { + this.scrollContent(1); + } else { + this.stepThroughTags(1); + } + }, + + focusList: function () { + this.set('controller.keyboardFocus', 'tagList'); + }, + + focusContent: function () { + this.set('controller.keyboardFocus', 'tagContent'); + }, + + newTag: function () { + this.transitionTo('settings.tags.new'); + } } }); diff --git a/ghost/admin/app/routes/settings/tags/index.js b/ghost/admin/app/routes/settings/tags/index.js new file mode 100644 index 0000000000..726e7e503b --- /dev/null +++ b/ghost/admin/app/routes/settings/tags/index.js @@ -0,0 +1,13 @@ +import AuthenticatedRoute from 'ghost/routes/authenticated'; + +export default AuthenticatedRoute.extend({ + + beforeModel: function () { + const firstTag = this.modelFor('settings.tags').get('firstObject'); + + if (firstTag) { + this.transitionTo('settings.tags.tag', firstTag); + } + } + +}); diff --git a/ghost/admin/app/routes/settings/tags/new.js b/ghost/admin/app/routes/settings/tags/new.js new file mode 100644 index 0000000000..69d79e3df1 --- /dev/null +++ b/ghost/admin/app/routes/settings/tags/new.js @@ -0,0 +1,15 @@ +import AuthenticatedRoute from 'ghost/routes/authenticated'; + +export default AuthenticatedRoute.extend({ + + controllerName: 'settings.tags.tag', + + model: function () { + return this.store.createRecord('tag'); + }, + + renderTemplate: function () { + this.render('settings.tags.tag'); + } + +}); diff --git a/ghost/admin/app/routes/settings/tags/tag.js b/ghost/admin/app/routes/settings/tags/tag.js new file mode 100644 index 0000000000..4041c04891 --- /dev/null +++ b/ghost/admin/app/routes/settings/tags/tag.js @@ -0,0 +1,9 @@ +import AuthenticatedRoute from 'ghost/routes/authenticated'; + +export default AuthenticatedRoute.extend({ + + model: function (params) { + return this.store.findRecord('tag', params.tag_id); + } + +}); diff --git a/ghost/admin/app/styles/layouts/content.css b/ghost/admin/app/styles/layouts/content.css index 369cb9d2ee..5c6f68d50b 100644 --- a/ghost/admin/app/styles/layouts/content.css +++ b/ghost/admin/app/styles/layouts/content.css @@ -267,7 +267,9 @@ /* This has to be a pseudo element to sit over the top of everything else in the content list */ -.content-list.keyboard-focused:before { +.content-list.keyboard-focused:before, +.tag-list-content.keyboard-focused:before, +.tag-settings.keyboard-focused:before { content: ""; position: absolute; top: 0; diff --git a/ghost/admin/app/styles/layouts/tags.css b/ghost/admin/app/styles/layouts/tags.css index e928fe7f35..7ccc472485 100644 --- a/ghost/admin/app/styles/layouts/tags.css +++ b/ghost/admin/app/styles/layouts/tags.css @@ -13,15 +13,14 @@ } .settings-tag .tag-edit-button { + display: block; padding: 20px; width: calc(100% + 45px); text-align: left; } -.settings-tag .tag-edit-button:hover, -.settings-tag .tag-edit-button:focus, -.settings-tag .tag-edit-button:active { - background: color(#dfe1e3 lightness(+10%)); +.settings-tag .tag-edit-button.active { + border-left: 3px solid; } .settings-tag:last-of-type:hover .tag-edit-button { @@ -29,9 +28,12 @@ } .settings-tag .label { - position: relative; - top: -2px; - margin-left: 2px; + display: inline-block; + overflow: hidden; + max-width: 100%; + vertical-align: middle; + text-overflow: ellipsis; + white-space: nowrap; } .settings-tag .label-alt { @@ -58,3 +60,40 @@ color: color(#dfe1e3 lightness(-10%)); font-size: 16px; } + +/* Tag List (Left pane) +/* ---------------------------------------------------------- */ + +.tag-list { + position: absolute; + top: 0; + bottom: 0; + left: 0; + overflow: auto; + max-width: calc(100% - 350px); + width: 66%; + border-right: #dfe1e3 1px solid; + background: #fff; +} + +/* Tag Settings (Right pane) +/* ---------------------------------------------------------- */ + +.tag-settings { + position: absolute; + top: 0; + right: 0; + bottom: 0; + overflow-x: hidden; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + min-width: 350px; + width: 34%; + border: none; + background: #fff; + transform: none; +} + +.tag-settings .no-posts h3 { + text-align: center; +} diff --git a/ghost/admin/app/templates/components/gh-tag-settings-form.hbs b/ghost/admin/app/templates/components/gh-tag-settings-form.hbs new file mode 100644 index 0000000000..fc44143cf6 --- /dev/null +++ b/ghost/admin/app/templates/components/gh-tag-settings-form.hbs @@ -0,0 +1,78 @@ +
+
+

{{title}}

+
+
+ {{gh-uploader uploaded="setCoverImage" canceled="clearCoverImage" description="Add tag image" image=tag.image initUploader="setUploaderReference" tagName="section"}} +
+ {{#gh-form-group errors=tag.errors hasValidated=tag.hasValidated property="name"}} + + {{gh-input id="tag-name" name="name" type="text" value=scratchName focus-out=(action 'setProperty' 'name')}} + {{gh-error-message errors=tag.errors property="name"}} + {{/gh-form-group}} + + {{#gh-form-group errors=tag.errors hasValidated=tag.hasValidated property="slug"}} + + {{gh-input id="tag-slug" name="slug" type="text" value=scratchSlug focus-out=(action 'setProperty' 'slug')}} + {{gh-url-preview prefix="tag" slug=scratchSlug tagName="p" classNames="description"}} + {{gh-error-message errors=activeTag.errors property="slug"}} + {{/gh-form-group}} + + {{#gh-form-group errors=tag.errors hasValidated=tag.hasValidated property="description"}} + + {{gh-textarea id="tag-description" name="description" value=scratchDescription focus-out=(action 'setProperty' 'description')}} + {{gh-error-message errors=tag.errors property="description"}} +

Maximum: 200 characters. You’ve used {{gh-count-down-characters scratchDescription 200}}

+ {{/gh-form-group}} + + + + {{#unless tag.isNew}} + + {{/unless}} +
+
+
{{! .settings-menu-pane }} + +
+
+ +

Meta Data

+
{{!flexbox space-between}}
+
+ +
+
+ {{#gh-form-group errors=tag.errors hasValidated=tag.hasValidated property="meta_title"}} + + {{gh-input id="meta-title" name="meta_title" type="text" value=scratchMetaTitle focus-out=(action 'setProperty' 'meta_title')}} + {{gh-error-message errors=tag.errors property="meta_title"}} +

Recommended: 70 characters. You’ve used {{gh-count-down-characters scratchMetaTitle 70}}

+ {{/gh-form-group}} + + {{#gh-form-group errors=tag.errors hasValidated=tag.hasValidated property="meta_description"}} + + {{gh-textarea id="meta-description" name="meta_description" value=scratchMetaDescription focus-out=(action 'setProperty' 'meta_description')}} + {{gh-error-message errors=tag.errors property="meta_description"}} +

Recommended: 156 characters. You’ve used {{gh-count-down-characters scratchMetaDescription 156}}

+ {{/gh-form-group}} + +
+ +
+
{{seoTitle}}
+ +
{{seoDescription}}
+
+
+
+
+
diff --git a/ghost/admin/app/templates/settings/tags.hbs b/ghost/admin/app/templates/settings/tags.hbs index ccc320d596..4b6e48bba7 100644 --- a/ghost/admin/app/templates/settings/tags.hbs +++ b/ghost/admin/app/templates/settings/tags.hbs @@ -2,25 +2,32 @@
{{#gh-view-title openMobileMenu="openMobileMenu"}}Tags{{/gh-view-title}}
- + {{#link-to "settings.tags.new" class="btn btn-green" title="New Tag"}}New Tag{{/link-to}} + {{!-- --}}
- {{#gh-infinite-scroll - fetch="loadNextPage" - isLoading=isLoading - tagName="section" - classNames="view-container settings-tags" - }} - {{#each tags as |tag|}} -
- -
- {{/each}} - {{/gh-infinite-scroll}} +
+ {{#gh-infinite-scroll + fetch="loadNextPage" + isLoading=isLoading + classNames="tag-list" + }} +
+ {{#each tags as |tag|}} +
+ {{#link-to 'settings.tags.tag' tag.id class="tag-edit-button"}} + {{tag.name}} + /{{tag.slug}} +

{{tag.description}}

+ {{tag.post_count}} + {{/link-to}} +
+ {{/each}} +
+ {{/gh-infinite-scroll}} +
+ {{outlet}} +
+
diff --git a/ghost/admin/app/templates/settings/tags/index.hbs b/ghost/admin/app/templates/settings/tags/index.hbs new file mode 100644 index 0000000000..42f93a98e4 --- /dev/null +++ b/ghost/admin/app/templates/settings/tags/index.hbs @@ -0,0 +1,6 @@ +
+
+

You haven't added any Tags yet!

+ {{#link-to "settings.tags.new"}}{{/link-to}} +
+
diff --git a/ghost/admin/app/templates/settings/tags/tag.hbs b/ghost/admin/app/templates/settings/tags/tag.hbs new file mode 100644 index 0000000000..78655f3a5c --- /dev/null +++ b/ghost/admin/app/templates/settings/tags/tag.hbs @@ -0,0 +1 @@ +{{gh-tag-settings-form tag=tag setProperty=(action "setProperty") openModal="openModal"}} diff --git a/ghost/admin/app/utils/document-title.js b/ghost/admin/app/utils/document-title.js index 1c0ce6a975..3db4a28cda 100644 --- a/ghost/admin/app/utils/document-title.js +++ b/ghost/admin/app/utils/document-title.js @@ -50,11 +50,7 @@ export default function () { }), setTitle: function (title) { - if (Ember.testing) { - this._title = title; - } else { - window.document.title = title; - } + window.document.title = title; } }); } diff --git a/ghost/admin/tests/acceptance/settings/navigation-test.js b/ghost/admin/tests/acceptance/settings/navigation-test.js index cd0e0f98dc..4b786b4129 100644 --- a/ghost/admin/tests/acceptance/settings/navigation-test.js +++ b/ghost/admin/tests/acceptance/settings/navigation-test.js @@ -10,60 +10,9 @@ import Ember from 'ember'; import startApp from '../../helpers/start-app'; import Pretender from 'pretender'; import { invalidateSession, authenticateSession } from 'ghost/tests/helpers/ember-simple-auth'; +import requiredSettings from '../../fixtures/settings'; -const {run} = Ember, - // TODO: Pull this into a fixture or similar when required elsewhere - requiredSettings = [{ - created_at: '2015-09-11T09:44:30.805Z', - created_by: 1, - id: 5, - key: 'title', - type: 'blog', - updated_at: '2015-10-04T16:26:05.195Z', - updated_by: 1, - uuid: '39e16daf-43fa-4bf0-87d4-44948ba8bf4c', - value: 'The Daily Awesome' - }, { - created_at: '2015-09-11T09:44:30.806Z', - created_by: 1, - id: 6, - key: 'description', - type: 'blog', - updated_at: '2015-10-04T16:26:05.198Z', - updated_by: 1, - uuid: 'e6c8b636-6925-4c4a-a5d9-1dc0870fb8ea', - value: 'Thoughts, stories and ideas.' - }, { - created_at: '2015-09-11T09:44:30.809Z', - created_by: 1, - id: 10, - key: 'postsPerPage', - type: 'blog', - updated_at: '2015-10-04T16:26:05.211Z', - updated_by: 1, - uuid: '775e6ca1-bcc3-4347-a53d-15d5d76c04a4', - value: '5' - }, { - created_at: '2015-09-11T09:44:30.809Z', - created_by: 1, - id: 13, - key: 'ghost_head', - type: 'blog', - updated_at: '2015-09-23T13:32:49.858Z', - updated_by: 1, - uuid: 'df7f3151-bc08-4a77-be9d-dd315b630d51', - value: '' - }, { - created_at: '2015-09-11T09:44:30.809Z', - created_by: 1, - id: 14, - key: 'ghost_foot', - type: 'blog', - updated_at: '2015-09-23T13:32:49.858Z', - updated_by: 1, - uuid: '0649d45e-828b-4dd0-8381-3dff6d1d5ddb', - value: '' - }]; +const {run} = Ember; describe('Acceptance: Settings - Navigation', function () { let application, @@ -72,7 +21,7 @@ describe('Acceptance: Settings - Navigation', function () { beforeEach(function () { application = startApp(); - store = application.__container__.lookup('store:main'); + store = application.__container__.lookup('service:store'); server = new Pretender(function () { // TODO: This needs to either be fleshed out to include all user data, or be killed with fire // as it needs to be loaded with all authenticated page loads diff --git a/ghost/admin/tests/acceptance/settings/tags-test.js b/ghost/admin/tests/acceptance/settings/tags-test.js new file mode 100644 index 0000000000..872e968f7b --- /dev/null +++ b/ghost/admin/tests/acceptance/settings/tags-test.js @@ -0,0 +1,375 @@ +/* jshint expr:true */ +import { + describe, + it, + beforeEach, + afterEach +} from 'mocha'; +import { expect } from 'chai'; +import Ember from 'ember'; +import startApp from '../../helpers/start-app'; +import Pretender from 'pretender'; +import { invalidateSession, authenticateSession } from 'ghost/tests/helpers/ember-simple-auth'; +import requiredSettings from '../../fixtures/settings'; + +const {run} = Ember, + // Grabbed from keymaster's testing code because Ember's `keyEvent` helper + // is for some reason not triggering the events in a way that keymaster detects: + // https://github.com/madrobby/keymaster/blob/master/test/keymaster.html#L31 + modifierMap = { + 16:'shiftKey', + 18:'altKey', + 17:'ctrlKey', + 91:'metaKey' + }, + keydown = function (code, modifiers, el) { + let event = document.createEvent('Event'); + event.initEvent('keydown', true, true); + event.keyCode = code; + if (modifiers && modifiers.length > 0) { + for (let i in modifiers) { + event[modifierMap[modifiers[i]]] = true; + } + } + (el || document).dispatchEvent(event); + }, + keyup = function (code, el) { + let event = document.createEvent('Event'); + event.initEvent('keyup', true, true); + event.keyCode = code; + (el || document).dispatchEvent(event); + }; + +describe('Acceptance: Settings - Tags', function () { + let application, + store, + server, + roleName; + + beforeEach(function () { + application = startApp(); + store = application.__container__.lookup('service:store'); + server = new Pretender(function () { + // TODO: This needs to either be fleshed out to include all user data, or be killed with fire + // as it needs to be loaded with all authenticated page loads + this.get('/ghost/api/v0.1/users/me', function () { + return [200, {'Content-Type': 'application/json'}, JSON.stringify({users: [{ + id: '1', + roles: [{ + id: 1, + name: roleName, + slug: 'barry' + }] + }]})]; + }); + + this.get('/ghost/api/v0.1/settings/', function (_request) { + let response = {meta: {filters: 'blog,theme'}}; + response.settings = requiredSettings; + return [200, {'Content-Type': 'application/json'}, JSON.stringify(response)]; + }); + + // TODO: This will be needed for all authenticated page loads + // - is there some way to make this a default? + this.get('/ghost/api/v0.1/notifications/', function (_request) { + return [200, {'Content-Type': 'application/json'}, JSON.stringify({notifications: []})]; + }); + + this.get('/ghost/api/v0.1/tags/', function (_request) { + let response = {}; + + response.meta = { + pagination: { + page: 1, + limit: 15, + pages: 1, + total: 2, + next: null, + prev: null + } + }; + + response.tags = [ + { + id: 1, + parent: null, + uuid: 'e2016ef1-4b51-46ff-9388-c6f066fc2e6c', + image: '/content/images/2015/10/tag-1.jpg', + name: 'Tag One', + slug: 'tag-one', + description: 'Description one.', + meta_title: 'Meta Title One', + meta_description: 'Meta description one.', + created_at: '2015-09-11T09:44:29.871Z', + created_by: 1, + updated_at: '2015-10-19T16:25:07.756Z', + updated_by: 1, + hidden: false, + post_count: 1 + }, + { + id: 2, + parent: null, + uuid: '0cade0f9-7a3f-4fd1-a80a-3a1ab7028340', + image: '/content/images/2015/10/tag-2.jpg', + name: 'Tag Two', + slug: 'tag-two', + description: 'Description two.', + meta_title: 'Meta Title Two', + meta_description: 'Meta description two.', + created_at: '2015-09-11T09:44:29.871Z', + created_by: 1, + updated_at: '2015-10-19T16:25:07.756Z', + updated_by: 1, + hidden: false, + post_count: 2 + } + ]; + + return [200, {'Content-Type': 'application/json'}, JSON.stringify(response)]; + }); + + this.put('/ghost/api/v0.1/tags/2/', function (_request) { + let response = {}; + + response.tag = { + id: 2, + parent: null, + uuid: '0cade0f9-7a3f-4fd1-a80a-3a1ab7028340', + image: '/content/images/2015/10/tag-2.jpg', + name: 'Saved Tag', + slug: 'tag-two', + description: 'Description two.', + meta_title: 'Meta Title Two', + meta_description: 'Meta description two.', + created_at: '2015-09-11T09:44:29.871Z', + created_by: 1, + updated_at: '2015-10-19T16:25:07.756Z', + updated_by: 1, + hidden: false, + post_count: 2 + }; + + return [200, {'Content-Type': 'application/json'}, JSON.stringify(response)]; + }); + + this.post('/ghost/api/v0.1/tags/', function (_request) { + let response = {}; + + response.tag = { + id: 3, + parent: null, + uuid: 'de9f4636-0398-4e23-a963-e073d12bc511', + image: '/content/images/2015/10/tag-3.jpg', + name: 'Tag Three', + slug: 'tag-three', + description: 'Description three.', + meta_title: 'Meta Title Three', + meta_description: 'Meta description three.', + created_at: '2015-09-11T09:44:29.871Z', + created_by: 1, + updated_at: '2015-10-19T16:25:07.756Z', + updated_by: 1, + hidden: false, + post_count: 2 + }; + + return [200, {'Content-Type': 'application/json'}, JSON.stringify(response)]; + }); + + this.delete('/ghost/api/v0.1/tags/3/', function (_request) { + let response = {tags: []}; + + response.tags.push({ + id: 3, + parent: null, + uuid: 'de9f4636-0398-4e23-a963-e073d12bc511', + image: '/content/images/2015/10/tag-3.jpg', + name: 'Tag Three', + slug: 'tag-three', + description: 'Description three.', + meta_title: 'Meta Title Three', + meta_description: 'Meta description three.', + created_at: '2015-09-11T09:44:29.871Z', + created_by: 1, + updated_at: '2015-10-19T16:25:07.756Z', + updated_by: 1, + hidden: false, + post_count: 2 + }); + + return [200, {'Content-Type': 'application/json'}, JSON.stringify(response)]; + }); + }); + }); + + afterEach(function () { + Ember.run(application, 'destroy'); + }); + + it('redirects to signin when not authenticated', function () { + invalidateSession(application); + visit('/settings/tags'); + + andThen(() => { + expect(currentURL()).to.equal('/signin'); + }); + }); + + it('redirects to team page when authenticated as author', function () { + roleName = 'Author'; + authenticateSession(application); + visit('/settings/navigation'); + + andThen(() => { + expect(currentURL()).to.match(/^\/team\//); + }); + }); + + describe('when logged in', function () { + beforeEach(function () { + roleName = 'Administrator'; + authenticateSession(application); + }); + + it('it renders, can be navigated, can edit, create & delete tags', function () { + visit('/settings/tags'); + + andThen(() => { + // it redirects to first tag + expect(currentURL(), 'currentURL').to.equal('/settings/tags/1'); + + // it has correct page title + expect(document.title, 'page title').to.equal('Settings - Tags - Test Blog'); + + // it highlights nav menu + expect($('.gh-nav-settings-tags').hasClass('active'), 'highlights nav menu item') + .to.be.true; + + // it lists all tags + expect(find('.settings-tags .settings-tag').length, 'tag list count') + .to.equal(2); + expect(find('.settings-tags .settings-tag:first .tag-title').text(), 'tag list item title') + .to.equal('Tag One'); + + // it highlights selected tag + expect(find('a[href="/settings/tags/1"]').hasClass('active'), 'highlights selected tag') + .to.be.true; + + // it shows selected tag form + expect(find('.tag-settings-pane h4').text(), 'settings pane title') + .to.equal('Tag Settings'); + expect(find('.tag-settings-pane input[name="name"]').val(), 'loads correct tag into form') + .to.equal('Tag One'); + }); + + // click the second tag in the list + click('.tag-edit-button:last'); + + andThen(() => { + // it navigates to selected tag + expect(currentURL(), 'url after clicking tag').to.equal('/settings/tags/2'); + + // it highlights selected tag + expect(find('a[href="/settings/tags/2"]').hasClass('active'), 'highlights selected tag') + .to.be.true; + + // it shows selected tag form + expect(find('.tag-settings-pane input[name="name"]').val(), 'loads correct tag into form') + .to.equal('Tag Two'); + }); + + andThen(() => { + // simulate up arrow press + run(() => { + keydown(38); + keyup(38); + }); + + // it navigates to previous tag + expect(currentURL(), 'url after keyboard up arrow').to.equal('/settings/tags/1'); + + // it highlights selected tag + expect(find('a[href="/settings/tags/1"]').hasClass('active'), 'selects previous tag') + .to.be.true; + }); + + andThen(() => { + // simulate down arrow press + run(() => { + keydown(40); + keyup(40); + }); + + // it navigates to previous tag + expect(currentURL(), 'url after keyboard down arrow').to.equal('/settings/tags/2'); + + // it highlights selected tag + expect(find('a[href="/settings/tags/2"]').hasClass('active'), 'selects next tag') + .to.be.true; + }); + + // trigger save + fillIn('.tag-settings-pane input[name="name"]', 'New Name'); + triggerEvent('.tag-settings-pane input[name="name"]', 'blur'); + + andThen(() => { + // check we update with the data returned from the server + expect(find('.settings-tags .settings-tag:last .tag-title').text(), 'tag list updates on save') + .to.equal('Saved Tag'); + expect(find('.tag-settings-pane input[name="name"]').val(), 'settings form updates on save') + .to.equal('Saved Tag'); + }); + + // start new tag + click('.view-actions .btn-green'); + + andThen(() => { + // it navigates to the new tag route + expect(currentURL(), 'new tag URL').to.equal('/settings/tags/new'); + + // it displays the new tag form + expect(find('.tag-settings-pane h4').text(), 'settings pane title') + .to.equal('New Tag'); + + // all fields start blank + find('.tag-settings-pane input, .tag-settings-pane textarea').each(function () { + expect($(this).val(), `input field for ${$(this).attr('name')}`) + .to.be.blank; + }); + }); + + // save new tag + fillIn('.tag-settings-pane input[name="name"]', 'New Tag'); + triggerEvent('.tag-settings-pane input[name="name"]', 'blur'); + + andThen(() => { + // it redirects to the new tag's URL + expect(currentURL(), 'URL after tag creation').to.equal('/settings/tags/3'); + + // it adds the tag to the list and selects + expect(find('.settings-tags .settings-tag').length, 'tag list count after creation') + .to.equal(3); + expect(find('.settings-tags .settings-tag:last .tag-title').text(), 'new tag list item title') + .to.equal('Tag Three'); + expect(find('a[href="/settings/tags/3"]').hasClass('active'), 'highlights new tag') + .to.be.true; + }); + + // delete tag + click('.tag-delete-button'); + click('.modal-container .btn-red'); + + andThen(() => { + // it redirects to the first tag + expect(currentURL(), 'URL after tag deletion').to.equal('/settings/tags/1'); + + // it removes the tag from the list + expect(find('.settings-tags .settings-tag').length, 'tag list count after deletion') + .to.equal(2); + }); + }); + + it('has infinite scroll pagination of tags list'); + }); +}); diff --git a/ghost/admin/tests/fixtures/settings.js b/ghost/admin/tests/fixtures/settings.js new file mode 100644 index 0000000000..380ea124a0 --- /dev/null +++ b/ghost/admin/tests/fixtures/settings.js @@ -0,0 +1,51 @@ +export default [{ + created_at: '2015-09-11T09:44:30.805Z', + created_by: 1, + id: 5, + key: 'title', + type: 'blog', + updated_at: '2015-10-04T16:26:05.195Z', + updated_by: 1, + uuid: '39e16daf-43fa-4bf0-87d4-44948ba8bf4c', + value: 'Test Blog' +}, { + created_at: '2015-09-11T09:44:30.806Z', + created_by: 1, + id: 6, + key: 'description', + type: 'blog', + updated_at: '2015-10-04T16:26:05.198Z', + updated_by: 1, + uuid: 'e6c8b636-6925-4c4a-a5d9-1dc0870fb8ea', + value: 'Thoughts, stories and ideas.' +}, { + created_at: '2015-09-11T09:44:30.809Z', + created_by: 1, + id: 10, + key: 'postsPerPage', + type: 'blog', + updated_at: '2015-10-04T16:26:05.211Z', + updated_by: 1, + uuid: '775e6ca1-bcc3-4347-a53d-15d5d76c04a4', + value: '5' +}, { + created_at: '2015-09-11T09:44:30.809Z', + created_by: 1, + id: 13, + key: 'ghost_head', + type: 'blog', + updated_at: '2015-09-23T13:32:49.858Z', + updated_by: 1, + uuid: 'df7f3151-bc08-4a77-be9d-dd315b630d51', + value: '' +}, { + created_at: '2015-09-11T09:44:30.809Z', + created_by: 1, + id: 14, + key: 'ghost_foot', + type: 'blog', + updated_at: '2015-09-23T13:32:49.858Z', + updated_by: 1, + uuid: '0649d45e-828b-4dd0-8381-3dff6d1d5ddb', + value: '' +}]; diff --git a/ghost/admin/tests/index.html b/ghost/admin/tests/index.html index 3dee3fb615..48404eec58 100644 --- a/ghost/admin/tests/index.html +++ b/ghost/admin/tests/index.html @@ -13,7 +13,7 @@ - + diff --git a/ghost/admin/tests/integration/components/gh-cm-editor-test.js b/ghost/admin/tests/integration/components/gh-cm-editor-test.js new file mode 100644 index 0000000000..c88b8ad703 --- /dev/null +++ b/ghost/admin/tests/integration/components/gh-cm-editor-test.js @@ -0,0 +1,53 @@ +/* jshint expr:true */ +import { expect } from 'chai'; +import { + describeComponent, + it +} from 'ember-mocha'; +import hbs from 'htmlbars-inline-precompile'; +import Ember from 'ember'; + +const {run} = Ember; + +describeComponent( + 'gh-cm-editor', + 'Integration: Component: gh-cm-editor', + { + integration: true + }, + function () { + it('handles editor events', function () { + this.set('text', ''); + + this.render(hbs`{{gh-cm-editor class="gh-input" value=text}}`); + let input = this.$('.gh-input'); + + expect(input.hasClass('focused'), 'has focused class on first render') + .to.be.false; + + run(() => { + input.find('textarea').trigger('focus'); + }); + + expect(input.hasClass('focused'), 'has focused class after focus') + .to.be.true; + + run(() => { + input.find('textarea').trigger('blur'); + }); + + expect(input.hasClass('focused'), 'loses focused class on blur') + .to.be.false; + + run(() => { + // access CodeMirror directly as it doesn't pick up changes + // to the textarea + let cm = input.find('.CodeMirror').get(0).CodeMirror; + cm.setValue('Testing'); + }); + + expect(this.get('text'), 'text value after CM editor change') + .to.equal('Testing'); + }); + } +); diff --git a/ghost/admin/tests/integration/components/gh-tag-settings-form-test.js b/ghost/admin/tests/integration/components/gh-tag-settings-form-test.js new file mode 100644 index 0000000000..901c803312 --- /dev/null +++ b/ghost/admin/tests/integration/components/gh-tag-settings-form-test.js @@ -0,0 +1,301 @@ +/* jshint expr:true */ +import { expect } from 'chai'; +import { + describeComponent, + it +} from 'ember-mocha'; +import hbs from 'htmlbars-inline-precompile'; +import Ember from 'ember'; +import DS from 'ember-data'; + +const {run} = Ember, + configStub = Ember.Service.extend({ + blogUrl: 'http://localhost:2368' + }); + +describeComponent( + 'gh-tag-settings-form', + 'Integration: Component: gh-tag-settings-form', + { + integration: true + }, + function () { + beforeEach(function () { + let tag = Ember.Object.create({ + id: 1, + name: 'Test', + slug: 'test', + description: 'Description.', + meta_title: 'Meta Title', + meta_description: 'Meta description', + errors: DS.Errors.create(), + hasValidated: [] + }); + + this.set('tag', tag); + this.set('actions.setProperty', function (property, value) { + // this should be overridden if a call is expected + console.error(`setProperty called '${property}: ${value}'`); + }); + + this.register('service:config', configStub); + this.inject.service('config', {as: 'config'}); + }); + + it('renders', function () { + this.render(hbs` + {{gh-tag-settings-form tag=tag setProperty=(action 'setProperty') openModal='openModal'}} + `); + expect(this.$()).to.have.length(1); + }); + + it('has the correct title', function () { + this.render(hbs` + {{gh-tag-settings-form tag=tag setProperty=(action 'setProperty') openModal='openModal'}} + `); + expect(this.$('.tag-settings-pane h4').text(), 'existing tag title').to.equal('Tag Settings'); + + this.set('tag.isNew', true); + expect(this.$('.tag-settings-pane h4').text(), 'new tag title').to.equal('New Tag'); + }); + + it('renders main settings', function () { + this.render(hbs` + {{gh-tag-settings-form tag=tag setProperty=(action 'setProperty') openModal='openModal'}} + `); + + expect(this.$('.image-uploader').length, 'displays image uploader').to.equal(1); + expect(this.$('input[name="name"]').val(), 'name field value').to.equal('Test'); + expect(this.$('input[name="slug"]').val(), 'slug field value').to.equal('test'); + expect(this.$('textarea[name="description"]').val(), 'description field value').to.equal('Description.'); + expect(this.$('input[name="meta_title"]').val(), 'meta_title field value').to.equal('Meta Title'); + expect(this.$('textarea[name="meta_description"]').val(), 'meta_description field value').to.equal('Meta description'); + }); + + it('can switch between main/meta settings', function () { + this.render(hbs` + {{gh-tag-settings-form tag=tag setProperty=(action 'setProperty') openModal='openModal'}} + `); + + expect(this.$('.tag-settings-pane').hasClass('settings-menu-pane-in'), 'main settings are displayed by default').to.be.true; + expect(this.$('.tag-meta-settings-pane').hasClass('settings-menu-pane-out-right'), 'meta settings are hidden by default').to.be.true; + + run(() => { + this.$('.meta-data-button').click(); + }); + + expect(this.$('.tag-settings-pane').hasClass('settings-menu-pane-out-left'), 'main settings are hidden after clicking Meta Data button').to.be.true; + expect(this.$('.tag-meta-settings-pane').hasClass('settings-menu-pane-in'), 'meta settings are displayed after clicking Meta Data button').to.be.true; + + run(() => { + this.$('.back').click(); + }); + + expect(this.$('.tag-settings-pane').hasClass('settings-menu-pane-in'), 'main settings are displayed after clicking "back"').to.be.true; + expect(this.$('.tag-meta-settings-pane').hasClass('settings-menu-pane-out-right'), 'meta settings are hidden after clicking "back"').to.be.true; + }); + + it('has one-way binding for properties', function () { + this.set('actions.setProperty', function () { + // noop + }); + + this.render(hbs` + {{gh-tag-settings-form tag=tag setProperty=(action 'setProperty') openModal='openModal'}} + `); + + run(() => { + this.$('input[name="name"]').val('New name'); + this.$('input[name="slug"]').val('new-slug'); + this.$('textarea[name="description"]').val('New description'); + this.$('input[name="meta_title"]').val('New meta_title'); + this.$('textarea[name="meta_description"]').val('New meta_description'); + }); + + expect(this.get('tag.name'), 'tag name').to.equal('Test'); + expect(this.get('tag.slug'), 'tag slug').to.equal('test'); + expect(this.get('tag.description'), 'tag description').to.equal('Description.'); + expect(this.get('tag.meta_title'), 'tag meta_title').to.equal('Meta Title'); + expect(this.get('tag.meta_description'), 'tag meta_description').to.equal('Meta description'); + }); + + it('triggers setProperty action on blur of all fields', function () { + let expectedProperty = '', + expectedValue = ''; + + this.set('actions.setProperty', function (property, value) { + expect(property, 'property').to.equal(expectedProperty); + expect(value, 'value').to.equal(expectedValue); + }); + + this.render(hbs` + {{gh-tag-settings-form tag=tag setProperty=(action 'setProperty') openModal='openModal'}} + `); + + expectedProperty = 'name'; + expectedValue = 'new-slug'; + run(() => { + this.$('input[name="name"]').val('New name'); + }); + + expectedProperty = 'url'; + expectedValue = 'new-slug'; + run(() => { + this.$('input[name="slug"]').val('new-slug'); + }); + + expectedProperty = 'description'; + expectedValue = 'New description'; + run(() => { + this.$('textarea[name="description"]').val('New description'); + }); + + expectedProperty = 'meta_title'; + expectedValue = 'New meta_title'; + run(() => { + this.$('input[name="meta_title"]').val('New meta_title'); + }); + + expectedProperty = 'meta_description'; + expectedValue = 'New meta_description'; + run(() => { + this.$('textarea[name="meta_description"]').val('New meta_description'); + }); + }); + + it('displays error messages for validated fields', function () { + let errors = this.get('tag.errors'), + hasValidated = this.get('tag.hasValidated'); + + errors.add('name', 'must be present'); + hasValidated.push('name'); + + errors.add('slug', 'must be present'); + hasValidated.push('slug'); + + errors.add('description', 'is too long'); + hasValidated.push('description'); + + errors.add('meta_title', 'is too long'); + hasValidated.push('meta_title'); + + errors.add('meta_description', 'is too long'); + hasValidated.push('meta_description'); + + this.render(hbs` + {{gh-tag-settings-form tag=tag setProperty=(action 'setProperty') openModal='openModal'}} + `); + + let nameFormGroup = this.$('input[name="name"]').closest('.form-group'); + expect(nameFormGroup.hasClass('error'), 'name form group has error state').to.be.true; + expect(nameFormGroup.find('.response').length, 'name form group has error message').to.equal(1); + + let slugFormGroup = this.$('input[name="slug"]').closest('.form-group'); + expect(slugFormGroup.hasClass('error'), 'slug form group has error state').to.be.true; + expect(slugFormGroup.find('.response').length, 'slug form group has error message').to.equal(1); + + let descriptionFormGroup = this.$('textarea[name="description"]').closest('.form-group'); + expect(descriptionFormGroup.hasClass('error'), 'description form group has error state').to.be.true; + + let metaTitleFormGroup = this.$('input[name="meta_title"]').closest('.form-group'); + expect(metaTitleFormGroup.hasClass('error'), 'meta_title form group has error state').to.be.true; + expect(metaTitleFormGroup.find('.response').length, 'meta_title form group has error message').to.equal(1); + + let metaDescriptionFormGroup = this.$('textarea[name="meta_description"]').closest('.form-group'); + expect(metaDescriptionFormGroup.hasClass('error'), 'meta_description form group has error state').to.be.true; + expect(metaDescriptionFormGroup.find('.response').length, 'meta_description form group has error message').to.equal(1); + }); + + it('displays char count for text fields', function () { + this.render(hbs` + {{gh-tag-settings-form tag=tag setProperty=(action 'setProperty') openModal='openModal'}} + `); + + let descriptionFormGroup = this.$('textarea[name="description"]').closest('.form-group'); + expect(descriptionFormGroup.find('.word-count').text(), 'description char count').to.equal('12'); + + let metaDescriptionFormGroup = this.$('textarea[name="meta_description"]').closest('.form-group'); + expect(metaDescriptionFormGroup.find('.word-count').text(), 'description char count').to.equal('16'); + }); + + it('renders SEO title preview', function () { + this.render(hbs` + {{gh-tag-settings-form tag=tag setProperty=(action 'setProperty') openModal='openModal'}} + `); + expect(this.$('.seo-preview-title').text(), 'displays meta title if present').to.equal('Meta Title'); + + run(() => { + this.set('tag.meta_title', ''); + }); + expect(this.$('.seo-preview-title').text(), 'falls back to tag name without meta_title').to.equal('Test'); + + run(() => { + this.set('tag.name', (new Array(151).join('x'))); + }); + let expectedLength = 70 + '…'.length; + expect(this.$('.seo-preview-title').text().length, 'cuts title to max 70 chars').to.equal(expectedLength); + }); + + it('renders SEO URL preview', function () { + this.render(hbs` + {{gh-tag-settings-form tag=tag setProperty=(action 'setProperty') openModal='openModal'}} + `); + expect(this.$('.seo-preview-link').text(), 'adds url and tag prefix').to.equal('http://localhost:2368/tag/test/'); + + run(() => { + this.set('tag.slug', (new Array(151).join('x'))); + }); + let expectedLength = 70 + '…'.length; + expect(this.$('.seo-preview-link').text().length, 'cuts slug to max 70 chars').to.equal(expectedLength); + }); + + it('renders SEO description preview', function () { + this.render(hbs` + {{gh-tag-settings-form tag=tag setProperty=(action 'setProperty') openModal='openModal'}} + `); + expect(this.$('.seo-preview-description').text(), 'displays meta description if present').to.equal('Meta description'); + + run(() => { + this.set('tag.meta_description', ''); + }); + expect(this.$('.seo-preview-description').text(), 'falls back to tag description without meta_description').to.equal('Description.'); + + run(() => { + this.set('tag.description', (new Array(200).join('x'))); + }); + let expectedLength = 156 + '…'.length; + expect(this.$('.seo-preview-description').text().length, 'cuts description to max 156 chars').to.equal(expectedLength); + }); + + it('resets if a new tag is received', function () { + this.render(hbs` + {{gh-tag-settings-form tag=tag setProperty=(action 'setProperty') openModal='openModal'}} + `); + run(() => { + this.$('.meta-data-button').click(); + }); + expect(this.$('.tag-meta-settings-pane').hasClass('settings-menu-pane-in'), 'meta data pane is shown').to.be.true; + + run(() => { + this.set('tag', Ember.Object.create({id: '2'})); + }); + expect(this.$('.tag-settings-pane').hasClass('settings-menu-pane-in'), 'resets to main settings').to.be.true; + }); + + it('triggers delete tag modal on delete click', function (done) { + this.set('actions.openModal', (modalName, model) => { + expect(modalName, 'passed modal name').to.equal('delete-tag'); + expect(model, 'passed model').to.equal(this.get('tag')); + done(); + }); + + this.render(hbs` + {{gh-tag-settings-form tag=tag setProperty=(action 'setProperty') openModal='openModal'}} + `); + + run(() => { + this.$('.tag-delete-button').click(); + }); + }); + } +);