diff --git a/ghost/admin/app/adapters/application.js b/ghost/admin/app/adapters/application.js
index c6a840c521..42d45faf65 100644
--- a/ghost/admin/app/adapters/application.js
+++ b/ghost/admin/app/adapters/application.js
@@ -1,3 +1,9 @@
import EmbeddedRelationAdapter from 'ghost/adapters/embedded-relation-adapter';
-export default EmbeddedRelationAdapter.extend();
+export default EmbeddedRelationAdapter.extend({
+
+ shouldBackgroundReloadRecord: function () {
+ return false;
+ }
+
+});
diff --git a/ghost/admin/app/adapters/base.js b/ghost/admin/app/adapters/base.js
index bb5f750d7a..427459f05e 100644
--- a/ghost/admin/app/adapters/base.js
+++ b/ghost/admin/app/adapters/base.js
@@ -5,6 +5,10 @@ export default DS.RESTAdapter.extend({
host: window.location.origin,
namespace: ghostPaths().apiRoot.slice(1),
+ shouldBackgroundReloadRecord: function () {
+ return false;
+ },
+
query: function (store, type, query) {
var id;
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 1fab2cd7ac..7ccc472485 100644
--- a/ghost/admin/app/styles/layouts/tags.css
+++ b/ghost/admin/app/styles/layouts/tags.css
@@ -2,69 +2,6 @@
/* ---------------------------------------------------------- */
-/* Search
-/* ---------------------------------------------------------- */
-
-.tags-search {
- position: relative;
- display: inline-block;
- margin-left: 7px;
-}
-
-.tags-search .btn {
- position: relative;
- padding-right: 10px;
- padding-left: 10px;
- transition: padding 0.3s ease-in-out;
-}
-
-.tags-search .btn.active {
- box-shadow: none;
-}
-
-.tags-search .btn .icon-search:before {
- font-size: 1.3rem;
-}
-
-.tags-search .tags-search-input {
- position: absolute;
- top: 1px;
- left: 1px;
- margin: 0;
- padding: 7px 10px;
- width: 0;
- border: 0;
- border-top-right-radius: 0;
- border-bottom-right-radius: 0;
- opacity: 0;
- transition: all 0.3s ease-in-out;
- pointer-events: none;
-}
-
-@media (max-width: 400px) {
- .tags-search.opened .btn {
- padding-left: 120px;
- }
- .tags-search.opened .tags-search-input {
- width: 110px;
- }
-}
-
-@media (min-width: 401px) {
- .tags-search.opened .btn {
- padding-left: 140px;
- }
- .tags-search.opened .tags-search-input {
- width: 130px;
- }
-}
-
-.tags-search.opened .tags-search-input {
- opacity: 1;
- pointer-events: auto;
-}
-
-
/* Tag
/* ---------------------------------------------------------- */
@@ -76,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 {
@@ -92,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 {
@@ -121,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 @@
+
{{! .settings-menu-pane }}
+
+
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-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}}
+
+
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();
+ });
+ });
+ }
+);