diff --git a/core/client/app/components/gh-form-group.js b/core/client/app/components/gh-form-group.js
index 3238b5e1f4..5445314cc5 100644
--- a/core/client/app/components/gh-form-group.js
+++ b/core/client/app/components/gh-form-group.js
@@ -1,32 +1,5 @@
-import Ember from 'ember';
+import ValidationStatusContainer from 'ghost/components/gh-validation-status-container';
-/**
- * Handles the CSS necessary to show a specific property state. When passed a
- * DS.Errors object and a property name, if the DS.Errors object has errors for
- * the specified property, it will change the CSS to reflect the error state
- * @param {DS.Errors} errors The DS.Errors object
- * @param {string} property Name of the property
- */
-export default Ember.Component.extend({
- classNames: 'form-group',
- classNameBindings: ['errorClass'],
-
- errors: null,
- property: '',
- hasValidated: Ember.A(),
-
- errorClass: Ember.computed('errors.[]', 'property', 'hasValidated.[]', function () {
- var property = this.get('property'),
- errors = this.get('errors'),
- hasValidated = this.get('hasValidated');
-
- // If we haven't yet validated this field, there is no validation class needed
- if (!hasValidated || !hasValidated.contains(property)) {
- return '';
- }
-
- if (errors) {
- return errors.get(property) ? 'error' : 'success';
- }
- })
+export default ValidationStatusContainer.extend({
+ classNames: 'form-group'
});
diff --git a/core/client/app/components/gh-navigation.js b/core/client/app/components/gh-navigation.js
index beaf51f045..3eba3c4aaf 100644
--- a/core/client/app/components/gh-navigation.js
+++ b/core/client/app/components/gh-navigation.js
@@ -9,6 +9,8 @@ export default Ember.Component.extend({
navElements = '.gh-blognav-item:not(.gh-blognav-item:last-child)',
self = this;
+ this._super(...arguments);
+
navContainer.sortable({
handle: '.gh-blognav-grab',
items: navElements,
@@ -28,6 +30,6 @@ export default Ember.Component.extend({
},
willDestroyElement: function () {
- this.$('.js-gh-blognav').sortable('destroy');
+ this.$('.ui-sortable').sortable('destroy');
}
});
diff --git a/core/client/app/components/gh-navitem-url-input.js b/core/client/app/components/gh-navitem-url-input.js
index cf13cfc69c..1c74ba74a7 100644
--- a/core/client/app/components/gh-navitem-url-input.js
+++ b/core/client/app/components/gh-navitem-url-input.js
@@ -1,6 +1,9 @@
import Ember from 'ember';
-function joinUrlParts(url, path) {
+var joinUrlParts,
+ isRelative;
+
+joinUrlParts = function (url, path) {
if (path[0] !== '/' && url.slice(-1) !== '/') {
path = '/' + path;
} else if (path[0] === '/' && url.slice(-1) === '/') {
@@ -8,9 +11,16 @@ function joinUrlParts(url, path) {
}
return url + path;
-}
+};
+
+isRelative = function (url) {
+ // "protocol://", "//example.com", "scheme:", "#anchor", & invalid paths
+ // should all be treated as absolute
+ return !url.match(/\s/) && !validator.isURL(url) && !url.match(/^(\/\/|#|[a-zA-Z0-9\-]+:)/);
+};
export default Ember.TextField.extend({
+ classNames: 'gh-input',
classNameBindings: ['fakePlaceholder'],
didReceiveAttrs: function () {
@@ -18,8 +28,7 @@ export default Ember.TextField.extend({
baseUrl = this.get('baseUrl');
// if we have a relative url, create the absolute url to be displayed in the input
- // if (this.get('isRelative')) {
- if (!validator.isURL(url) && url.indexOf('mailto:') !== 0) {
+ if (isRelative(url)) {
url = joinUrlParts(baseUrl, url);
}
@@ -34,10 +43,6 @@ export default Ember.TextField.extend({
return this.get('isBaseUrl') && this.get('last') && !this.get('hasFocus');
}),
- isRelative: Ember.computed('value', function () {
- return !validator.isURL(this.get('value')) && this.get('value').indexOf('mailto:') !== 0;
- }),
-
focusIn: function (event) {
this.set('hasFocus', true);
@@ -58,6 +63,11 @@ export default Ember.TextField.extend({
event.preventDefault();
}
+
+ // CMD-S
+ if (event.keyCode === 83 && event.metaKey) {
+ this.notifyUrlChanged();
+ }
},
keyPress: function (event) {
@@ -80,11 +90,35 @@ export default Ember.TextField.extend({
this.set('value', this.get('value').trim());
var url = this.get('value'),
- baseUrl = this.get('baseUrl');
+ urlParts = document.createElement('a'),
+ baseUrl = this.get('baseUrl'),
+ baseUrlParts = document.createElement('a');
+
+ // leverage the browser's native URI parsing
+ urlParts.href = url;
+ baseUrlParts.href = baseUrl;
+
+ // if we have an email address, add the mailto:
+ if (validator.isEmail(url)) {
+ url = `mailto:${url}`;
+ this.set('value', url);
+ }
// if we have a relative url, create the absolute url to be displayed in the input
- if (this.get('isRelative')) {
- this.set('value', joinUrlParts(baseUrl, url));
+ if (isRelative(url)) {
+ url = joinUrlParts(baseUrl, url);
+ this.set('value', url);
+ }
+
+ // remove the base url before sending to action
+ if (urlParts.host === baseUrlParts.host && !url.match(/^#/)) {
+ url = url.replace(/^[a-zA-Z0-9\-]+:/, '');
+ url = url.replace(/^\/\//, '');
+ url = url.replace(baseUrlParts.host, '');
+ url = url.replace(baseUrlParts.pathname, '');
+ if (!url.match(/^\//)) {
+ url = '/' + url;
+ }
}
this.sendAction('change', url);
diff --git a/core/client/app/components/gh-navitem.js b/core/client/app/components/gh-navitem.js
index 7f762306c8..12c7c8e131 100644
--- a/core/client/app/components/gh-navitem.js
+++ b/core/client/app/components/gh-navitem.js
@@ -1,10 +1,19 @@
import Ember from 'ember';
+import ValidationStateMixin from 'ghost/mixins/validation-state';
-export default Ember.Component.extend({
+export default Ember.Component.extend(ValidationStateMixin, {
classNames: 'gh-blognav-item',
+ classNameBindings: ['errorClass'],
attributeBindings: ['order:data-order'],
order: Ember.computed.readOnly('navItem.order'),
+ errors: Ember.computed.readOnly('navItem.errors'),
+
+ errorClass: Ember.computed('hasError', function () {
+ if (this.get('hasError')) {
+ return 'gh-blognav-item--error';
+ }
+ }),
keyPress: function (event) {
// enter key
@@ -12,6 +21,8 @@ export default Ember.Component.extend({
event.preventDefault();
this.send('addItem');
}
+
+ this.get('navItem.errors').clear();
},
actions: {
diff --git a/core/client/app/components/gh-validation-status-container.js b/core/client/app/components/gh-validation-status-container.js
new file mode 100644
index 0000000000..c859eaf857
--- /dev/null
+++ b/core/client/app/components/gh-validation-status-container.js
@@ -0,0 +1,17 @@
+import Ember from 'ember';
+import ValidationStateMixin from 'ghost/mixins/validation-state';
+
+/**
+ * Handles the CSS necessary to show a specific property state. When passed a
+ * DS.Errors object and a property name, if the DS.Errors object has errors for
+ * the specified property, it will change the CSS to reflect the error state
+ * @param {DS.Errors} errors The DS.Errors object
+ * @param {string} property Name of the property
+ */
+export default Ember.Component.extend(ValidationStateMixin, {
+ classNameBindings: ['errorClass'],
+
+ errorClass: Ember.computed('hasError', function () {
+ return this.get('hasError') ? 'error' : 'success';
+ })
+});
diff --git a/core/client/app/controllers/settings/navigation.js b/core/client/app/controllers/settings/navigation.js
index 2b490f3cc2..e8366d2415 100644
--- a/core/client/app/controllers/settings/navigation.js
+++ b/core/client/app/controllers/settings/navigation.js
@@ -1,14 +1,24 @@
import Ember from 'ember';
+import DS from 'ember-data';
import SettingsSaveMixin from 'ghost/mixins/settings-save';
+import ValidationEngine from 'ghost/mixins/validation-engine';
-var NavItem = Ember.Object.extend({
+export const NavItem = Ember.Object.extend(ValidationEngine, {
label: '',
url: '',
last: false,
+ validationType: 'navItem',
+
isComplete: Ember.computed('label', 'url', function () {
return !(Ember.isBlank(this.get('label').trim()) || Ember.isBlank(this.get('url')));
- })
+ }),
+
+ init: function () {
+ this._super(...arguments);
+ this.set('errors', DS.Errors.create());
+ this.set('hasValidated', Ember.A());
+ }
});
export default Ember.Controller.extend(SettingsSaveMixin, {
@@ -57,58 +67,38 @@ export default Ember.Controller.extend(SettingsSaveMixin, {
save: function () {
var navSetting,
- blogUrl = this.get('config').blogUrl,
- blogUrlRegex = new RegExp('^' + blogUrl + '(.*)', 'i'),
navItems = this.get('navigationItems'),
- message = 'One of your navigation items has an empty label. ' +
- '
Please enter a new label or delete the item before saving.',
- match,
- notifications = this.get('notifications');
+ notifications = this.get('notifications'),
+ validationPromises,
+ self = this;
- // Don't save if there's a blank label.
- if (navItems.find(function (item) {return !item.get('isComplete') && !item.get('last');})) {
- notifications.showAlert(message.htmlSafe(), {type: 'error'});
- return;
- }
+ validationPromises = navItems.map(function (item) {
+ return item.validate();
+ });
- navSetting = navItems.map(function (item) {
- var label,
- url;
+ return Ember.RSVP.all(validationPromises).then(function () {
+ navSetting = navItems.map(function (item) {
+ var label = item.get('label').trim(),
+ url = item.get('url').trim();
- if (!item || !item.get('isComplete')) {
- return;
- }
-
- label = item.get('label').trim();
- url = item.get('url').trim();
-
- // is this an internal URL?
- match = url.match(blogUrlRegex);
-
- if (match) {
- url = match[1];
-
- // if the last char is not a slash, then add one,
- // as long as there is no # or . in the URL (anchor or file extension)
- // this also handles the empty case for the homepage
- if (url[url.length - 1] !== '/' && url.indexOf('#') === -1 && url.indexOf('.') === -1) {
- url += '/';
+ if (item.get('last') && !item.get('isComplete')) {
+ return null;
}
- } else if (!validator.isURL(url) && url !== '' && url[0] !== '/' && url.indexOf('mailto:') !== 0) {
- url = '/' + url;
- }
- return {label: label, url: url};
- }).compact();
+ return {label: label, url: url};
+ }).compact();
- this.set('model.navigation', JSON.stringify(navSetting));
+ self.set('model.navigation', JSON.stringify(navSetting));
- // trigger change event because even if the final JSON is unchanged
- // we need to have navigationItems recomputed.
- this.get('model').notifyPropertyChange('navigation');
+ // trigger change event because even if the final JSON is unchanged
+ // we need to have navigationItems recomputed.
+ self.get('model').notifyPropertyChange('navigation');
- return this.get('model').save().catch(function (err) {
- notifications.showErrors(err);
+ return self.get('model').save().catch(function (err) {
+ notifications.showErrors(err);
+ });
+ }).catch(function () {
+ // TODO: noop - needed to satisfy spinner button
});
},
@@ -145,12 +135,6 @@ export default Ember.Controller.extend(SettingsSaveMixin, {
return;
}
- if (Ember.isBlank(url)) {
- navItem.set('url', this.get('blogUrl'));
-
- return;
- }
-
navItem.set('url', url);
}
}
diff --git a/core/client/app/mixins/validation-engine.js b/core/client/app/mixins/validation-engine.js
index 7cc8f1694c..18981024c6 100644
--- a/core/client/app/mixins/validation-engine.js
+++ b/core/client/app/mixins/validation-engine.js
@@ -11,6 +11,7 @@ import SettingValidator from 'ghost/validators/setting';
import ResetValidator from 'ghost/validators/reset';
import UserValidator from 'ghost/validators/user';
import TagSettingsValidator from 'ghost/validators/tag-settings';
+import NavItemValidator from 'ghost/validators/nav-item';
// our extensions to the validator library
ValidatorExtensions.init();
@@ -35,7 +36,8 @@ export default Ember.Mixin.create({
setting: SettingValidator,
reset: ResetValidator,
user: UserValidator,
- tag: TagSettingsValidator
+ tag: TagSettingsValidator,
+ navItem: NavItemValidator
},
// This adds the Errors object to the validation engine, and shouldn't affect
diff --git a/core/client/app/mixins/validation-state.js b/core/client/app/mixins/validation-state.js
new file mode 100644
index 0000000000..d0aa77bb12
--- /dev/null
+++ b/core/client/app/mixins/validation-state.js
@@ -0,0 +1,31 @@
+import Ember from 'ember';
+
+export default Ember.Mixin.create({
+
+ errors: null,
+ property: '',
+ hasValidated: Ember.A(),
+
+ hasError: Ember.computed('errors.[]', 'property', 'hasValidated.[]', function () {
+ var property = this.get('property'),
+ errors = this.get('errors'),
+ hasValidated = this.get('hasValidated');
+
+ // if we aren't looking at a specific property we always want an error class
+ if (!property && !Ember.isEmpty(errors)) {
+ return true;
+ }
+
+ // If we haven't yet validated this field, there is no validation class needed
+ if (!hasValidated || !hasValidated.contains(property)) {
+ return false;
+ }
+
+ if (errors) {
+ return errors.get(property);
+ }
+
+ return false;
+ })
+
+});
diff --git a/core/client/app/styles/layouts/settings.css b/core/client/app/styles/layouts/settings.css
index 5529254a41..3631d998e0 100644
--- a/core/client/app/styles/layouts/settings.css
+++ b/core/client/app/styles/layouts/settings.css
@@ -16,6 +16,15 @@
padding: 0 20px;
}
+.gh-blognav-item--error {
+ margin-bottom: calc(1em + 10px);
+}
+
+.gh-blognav-item .response {
+ position: absolute;
+ margin-bottom: 0;
+}
+
.gh-blognav-grab {
padding: 0 16px 0 0;
width: 16px;
diff --git a/core/client/app/styles/patterns/forms.css b/core/client/app/styles/patterns/forms.css
index 8eb860b938..44e7cfc8e7 100644
--- a/core/client/app/styles/patterns/forms.css
+++ b/core/client/app/styles/patterns/forms.css
@@ -37,7 +37,7 @@ input {
user-select: text;
}
-.form-group.error .response {
+.error .response {
color: var(--red);
}
@@ -123,7 +123,7 @@ select {
}
.gh-input.error,
-.form-group.error .gh-input,
+.error .gh-input,
.gh-select.error,
select.error {
border-color: var(--red);
diff --git a/core/client/app/templates/components/gh-navitem.hbs b/core/client/app/templates/components/gh-navitem.hbs
index 1dbd845c5b..ed48c83447 100644
--- a/core/client/app/templates/components/gh-navitem.hbs
+++ b/core/client/app/templates/components/gh-navitem.hbs
@@ -5,12 +5,14 @@
{{/unless}}
-
- {{gh-trim-focus-input class="gh-input" focus=navItem.last placeholder="Label" value=navItem.label}}
-
-
- {{gh-navitem-url-input class="gh-input" baseUrl=baseUrl url=navItem.url last=navItem.last change="updateUrl"}}
-
+ {{#gh-validation-status-container tagName="span" class="gh-blognav-label" errors=navItem.errors property="label" hasValidated=navItem.hasValidated}}
+ {{gh-trim-focus-input focus=navItem.last placeholder="Label" value=navItem.label}}
+ {{gh-error-message errors=navItem.errors property="label"}}
+ {{/gh-validation-status-container}}
+ {{#gh-validation-status-container tagName="span" class="gh-blognav-url" errors=navItem.errors property="url" hasValidated=navItem.hasValidated}}
+ {{gh-navitem-url-input baseUrl=baseUrl url=navItem.url last=navItem.last change="updateUrl"}}
+ {{gh-error-message errors=navItem.errors property="url"}}
+ {{/gh-validation-status-container}}
{{#if navItem.last}}
diff --git a/core/client/app/validators/nav-item.js b/core/client/app/validators/nav-item.js
new file mode 100644
index 0000000000..538459c460
--- /dev/null
+++ b/core/client/app/validators/nav-item.js
@@ -0,0 +1,52 @@
+import BaseValidator from './base';
+
+export default BaseValidator.create({
+ properties: ['label', 'url'],
+
+ label: function (model) {
+ var label = model.get('label'),
+ hasValidated = model.get('hasValidated');
+
+ if (this.canBeIgnored(model)) { return; }
+
+ if (validator.empty(label)) {
+ model.get('errors').add('label', 'You must specify a label');
+ this.invalidate();
+ }
+
+ hasValidated.addObject('label');
+ },
+
+ url: function (model) {
+ var url = model.get('url'),
+ hasValidated = model.get('hasValidated'),
+ validatorOptions = {require_protocol: true},
+ urlRegex = new RegExp(/^(\/|#|[a-zA-Z0-9\-]+:)/);
+
+ if (this.canBeIgnored(model)) { return; }
+
+ if (validator.empty(url)) {
+ model.get('errors').add('url', 'You must specify a URL or relative path');
+ this.invalidate();
+ } else if (url.match(/\s/) || (!validator.isURL(url, validatorOptions) && !url.match(urlRegex))) {
+ model.get('errors').add('url', 'You must specify a valid URL or relative path');
+ this.invalidate();
+ }
+
+ hasValidated.addObject('url');
+ },
+
+ canBeIgnored: function (model) {
+ var label = model.get('label'),
+ url = model.get('url'),
+ isLast = model.get('last');
+
+ // if nav item is last and completely blank, mark it valid and skip
+ if (isLast && (validator.empty(url) || url === '/') && validator.empty(label)) {
+ model.get('errors').clear();
+ return true;
+ }
+
+ return false;
+ }
+});
diff --git a/core/client/bower.json b/core/client/bower.json
index fa02d3227a..d0252d6a61 100644
--- a/core/client/bower.json
+++ b/core/client/bower.json
@@ -19,6 +19,7 @@
"jquery-hammerjs": "1.0.1",
"jquery-ui": "1.11.4",
"jqueryui-touch-punch": "furf/jquery-ui-touch-punch",
+ "jquery.simulate.drag-sortable": "0.1.0",
"keymaster": "1.6.3",
"loader.js": "3.2.1",
"moment": "2.10.3",
diff --git a/core/client/ember-cli-build.js b/core/client/ember-cli-build.js
index c52e7be7f3..84de3285cf 100644
--- a/core/client/ember-cli-build.js
+++ b/core/client/ember-cli-build.js
@@ -68,6 +68,10 @@ module.exports = function (defaults) {
app.import('bower_components/password-generator/lib/password-generator.js');
app.import('bower_components/blueimp-md5/js/md5.js');
+ if (app.env === 'test') {
+ app.import('bower_components/jquery.simulate.drag-sortable/jquery.simulate.drag-sortable.js');
+ }
+
// 'dem Styles
app.import('bower_components/codemirror/lib/codemirror.css');
app.import('bower_components/codemirror/theme/xq-light.css');
diff --git a/core/client/tests/integration/components/gh-navigation-test.js b/core/client/tests/integration/components/gh-navigation-test.js
new file mode 100644
index 0000000000..2503b26836
--- /dev/null
+++ b/core/client/tests/integration/components/gh-navigation-test.js
@@ -0,0 +1,71 @@
+/* jshint expr:true */
+import { expect } from 'chai';
+import { describeComponent, it } from 'ember-mocha';
+import hbs from 'htmlbars-inline-precompile';
+import Ember from 'ember';
+import { NavItem } from 'ghost/controllers/settings/navigation';
+
+const { run } = Ember;
+
+describeComponent(
+ 'gh-navigation',
+ 'Integration : Component : gh-navigation',
+ {
+ integration: true
+ },
+ function () {
+ it('renders', function () {
+ this.render(hbs`{{#gh-navigation}}{{/gh-navigation}}`);
+ expect(this.$('section.gh-view')).to.have.length(1);
+ expect(this.$('.ui-sortable')).to.have.length(1);
+ });
+
+ it('triggers reorder action', function () {
+ let navItems = [],
+ expectedOldIndex = -1,
+ expectedNewIndex = -1;
+
+ navItems.pushObject(NavItem.create({label: 'First', url: '/first'}));
+ navItems.pushObject(NavItem.create({label: 'Second', url: '/second'}));
+ navItems.pushObject(NavItem.create({label: 'Third', url: '/third'}));
+ navItems.pushObject(NavItem.create({label: '', url: '', last: true}));
+ this.set('navigationItems', navItems);
+ this.set('blogUrl', 'http://localhost:2368');
+
+ this.on('moveItem', (oldIndex, newIndex) => {
+ expect(oldIndex).to.equal(expectedOldIndex);
+ expect(newIndex).to.equal(expectedNewIndex);
+ });
+
+ run(() => {
+ this.render(hbs `
+ {{#gh-navigation moveItem="moveItem"}}
+
+ {{/gh-navigation}}`);
+ });
+
+ // check it renders the nav item rows
+ expect(this.$('.gh-blognav-item')).to.have.length(4);
+
+ // move second item up one
+ expectedOldIndex = 1;
+ expectedNewIndex = 0;
+ Ember.$(this.$('.gh-blognav-item')[1]).simulateDragSortable({
+ move: -1,
+ handle: '.gh-blognav-grab'
+ });
+
+ // move second item down one
+ expectedOldIndex = 1;
+ expectedNewIndex = 2;
+ Ember.$(this.$('.gh-blognav-item')[1]).simulateDragSortable({
+ move: 1,
+ handle: '.gh-blognav-grab'
+ });
+ });
+ }
+);
diff --git a/core/client/tests/integration/components/gh-navitem-test.js b/core/client/tests/integration/components/gh-navitem-test.js
new file mode 100644
index 0000000000..5e444ef8ab
--- /dev/null
+++ b/core/client/tests/integration/components/gh-navitem-test.js
@@ -0,0 +1,110 @@
+/* jshint expr:true */
+import { expect } from 'chai';
+import { describeComponent, it } from 'ember-mocha';
+import hbs from 'htmlbars-inline-precompile';
+import Ember from 'ember';
+import { NavItem } from 'ghost/controllers/settings/navigation';
+
+const { run } = Ember;
+
+describeComponent(
+ 'gh-navitem',
+ 'Integration : Component : gh-navitem',
+ {
+ integration: true
+ },
+ function () {
+ beforeEach(function () {
+ this.set('baseUrl', 'http://localhost:2368');
+ });
+
+ it('renders', function () {
+ this.set('navItem', NavItem.create({label: 'Test', url: '/url'}));
+
+ this.render(hbs`{{gh-navitem navItem=navItem baseUrl=baseUrl}}`);
+ let $item = this.$('.gh-blognav-item');
+
+ expect($item.find('.gh-blognav-grab').length).to.equal(1);
+ expect($item.find('.gh-blognav-label').length).to.equal(1);
+ expect($item.find('.gh-blognav-url').length).to.equal(1);
+ expect($item.find('.gh-blognav-delete').length).to.equal(1);
+
+ // doesn't show any errors
+ expect($item.hasClass('gh-blognav-item--error')).to.be.false;
+ expect($item.find('.error').length).to.equal(0);
+ expect($item.find('.response:visible').length).to.equal(0);
+ });
+
+ it('doesn\'t show drag handle for last item', function () {
+ this.set('navItem', NavItem.create({label: 'Test', url: '/url', last: true}));
+
+ this.render(hbs`{{gh-navitem navItem=navItem baseUrl=baseUrl}}`);
+ let $item = this.$('.gh-blognav-item');
+
+ expect($item.find('.gh-blognav-grab').length).to.equal(0);
+ });
+
+ it('shows add button for last item', function () {
+ this.set('navItem', NavItem.create({label: 'Test', url: '/url', last: true}));
+
+ this.render(hbs`{{gh-navitem navItem=navItem baseUrl=baseUrl}}`);
+ let $item = this.$('.gh-blognav-item');
+
+ expect($item.find('.gh-blognav-add').length).to.equal(1);
+ expect($item.find('.gh-blognav-delete').length).to.equal(0);
+ });
+
+ it('triggers delete action', function () {
+ this.set('navItem', NavItem.create({label: 'Test', url: '/url'}));
+
+ let deleteActionCallCount = 0;
+ this.on('deleteItem', (navItem) => {
+ expect(navItem).to.equal(this.get('navItem'));
+ deleteActionCallCount++;
+ });
+
+ this.render(hbs`{{gh-navitem navItem=navItem baseUrl=baseUrl deleteItem="deleteItem"}}`);
+ this.$('.gh-blognav-delete').trigger('click');
+
+ expect(deleteActionCallCount).to.equal(1);
+ });
+
+ it('triggers add action', function () {
+ this.set('navItem', NavItem.create({label: 'Test', url: '/url', last: true}));
+
+ let addActionCallCount = 0;
+ this.on('add', () => { addActionCallCount++; });
+
+ this.render(hbs`{{gh-navitem navItem=navItem baseUrl=baseUrl addItem="add"}}`);
+ this.$('.gh-blognav-add').trigger('click');
+
+ expect(addActionCallCount).to.equal(1);
+ });
+
+ it('triggers update action', function () {
+ this.set('navItem', NavItem.create({label: 'Test', url: '/url'}));
+
+ let updateActionCallCount = 0;
+ this.on('update', () => { updateActionCallCount++; });
+
+ this.render(hbs`{{gh-navitem navItem=navItem baseUrl=baseUrl updateUrl="update"}}`);
+ this.$('.gh-blognav-url input').trigger('blur');
+
+ expect(updateActionCallCount).to.equal(1);
+ });
+
+ it('displays inline errors', function () {
+ this.set('navItem', NavItem.create({label: '', url: ''}));
+ this.get('navItem').validate();
+
+ this.render(hbs`{{gh-navitem navItem=navItem baseUrl=baseUrl}}`);
+ let $item = this.$('.gh-blognav-item');
+
+ expect($item.hasClass('gh-blognav-item--error')).to.be.true;
+ expect($item.find('.gh-blognav-label').hasClass('error')).to.be.true;
+ expect($item.find('.gh-blognav-label .response').text().trim()).to.equal('You must specify a label');
+ expect($item.find('.gh-blognav-url').hasClass('error')).to.be.true;
+ expect($item.find('.gh-blognav-url .response').text().trim()).to.equal('You must specify a URL or relative path');
+ });
+ }
+);
diff --git a/core/client/tests/integration/components/gh-navitem-url-input-test.js b/core/client/tests/integration/components/gh-navitem-url-input-test.js
new file mode 100644
index 0000000000..1d5227d64e
--- /dev/null
+++ b/core/client/tests/integration/components/gh-navitem-url-input-test.js
@@ -0,0 +1,332 @@
+/* jshint scripturl:true */
+import { expect } from 'chai';
+import {
+ describeComponent,
+ it
+} from 'ember-mocha';
+import hbs from 'htmlbars-inline-precompile';
+import Ember from 'ember';
+
+const { run } = Ember,
+ // we want baseUrl to match the running domain so relative URLs are
+ // handled as expected (browser auto-sets the domain when using a.href)
+ currentUrl = `${window.location.protocol}//${window.location.host}/`;
+
+describeComponent(
+ 'gh-navitem-url-input',
+ 'Integration : Component : gh-navitem-url-input', {
+ integration: true
+ },
+ function () {
+ beforeEach(function () {
+ // set defaults
+ this.set('baseUrl', currentUrl);
+ this.set('url', '');
+ this.set('isLast', false);
+ });
+
+ it('renders correctly with blank url', function () {
+ this.render(hbs`
+ {{gh-navitem-url-input baseUrl=baseUrl url=url last=isLast change="updateUrl"}}
+ `);
+ let $input = this.$('input');
+
+ expect($input).to.have.length(1);
+ expect($input.hasClass('gh-input')).to.be.true;
+ expect($input.val()).to.equal(currentUrl);
+ });
+
+ it('renders correctly with relative urls', function () {
+ this.set('url', '/about');
+ this.render(hbs`
+ {{gh-navitem-url-input baseUrl=baseUrl url=url last=isLast change="updateUrl"}}
+ `);
+ let $input = this.$('input');
+
+ expect($input.val()).to.equal(`${currentUrl}about`);
+
+ this.set('url', '/about#contact');
+ expect($input.val()).to.equal(`${currentUrl}about#contact`);
+ });
+
+ it('renders correctly with absolute urls', function () {
+ this.set('url', 'https://example.com:2368/#test');
+ this.render(hbs`
+ {{gh-navitem-url-input baseUrl=baseUrl url=url last=isLast change="updateUrl"}}
+ `);
+ let $input = this.$('input');
+
+ expect($input.val()).to.equal('https://example.com:2368/#test');
+
+ this.set('url', 'mailto:test@example.com');
+ expect($input.val()).to.equal('mailto:test@example.com');
+
+ this.set('url', 'tel:01234-5678-90');
+ expect($input.val()).to.equal('tel:01234-5678-90');
+
+ this.set('url', '//protocol-less-url.com');
+ expect($input.val()).to.equal('//protocol-less-url.com');
+
+ this.set('url', '#anchor');
+ expect($input.val()).to.equal('#anchor');
+ });
+
+ it('deletes base URL on backspace', function () {
+ this.render(hbs`
+ {{gh-navitem-url-input baseUrl=baseUrl url=url last=isLast change="updateUrl"}}
+ `);
+ let $input = this.$('input');
+
+ expect($input.val()).to.equal(currentUrl);
+ run(() => {
+ // TODO: why is ember's keyEvent helper not available here?
+ let e = Ember.$.Event('keydown');
+ e.keyCode = 8;
+ $input.trigger(e);
+ });
+ expect($input.val()).to.equal('');
+ });
+
+ it('deletes base URL on delete', function () {
+ this.render(hbs`
+ {{gh-navitem-url-input baseUrl=baseUrl url=url last=isLast change="updateUrl"}}
+ `);
+ let $input = this.$('input');
+
+ expect($input.val()).to.equal(currentUrl);
+ run(() => {
+ // TODO: why is ember's keyEvent helper not available here?
+ let e = Ember.$.Event('keydown');
+ e.keyCode = 46;
+ $input.trigger(e);
+ });
+ expect($input.val()).to.equal('');
+ });
+
+ it('adds base url to relative urls on blur', function () {
+ this.on('updateUrl', () => { return null; });
+ this.render(hbs`
+ {{gh-navitem-url-input baseUrl=baseUrl url=url last=isLast change="updateUrl"}}
+ `);
+ let $input = this.$('input');
+
+ run(() => { $input.val('/about').trigger('input'); });
+ run(() => { $input.trigger('blur'); });
+
+ expect($input.val()).to.equal(`${currentUrl}about`);
+ });
+
+ it('adds "mailto:" to e-mail addresses on blur', function () {
+ this.on('updateUrl', () => { return null; });
+ this.render(hbs`
+ {{gh-navitem-url-input baseUrl=baseUrl url=url last=isLast change="updateUrl"}}
+ `);
+ let $input = this.$('input');
+
+ run(() => { $input.val('test@example.com').trigger('input'); });
+ run(() => { $input.trigger('blur'); });
+
+ expect($input.val()).to.equal('mailto:test@example.com');
+
+ // ensure we don't double-up on the mailto:
+ run(() => { $input.trigger('blur'); });
+ expect($input.val()).to.equal('mailto:test@example.com');
+ });
+
+ it('doesn\'t add base url to invalid urls on blur', function () {
+ this.on('updateUrl', () => { return null; });
+ this.render(hbs`
+ {{gh-navitem-url-input baseUrl=baseUrl url=url last=isLast change="updateUrl"}}
+ `);
+ let $input = this.$('input');
+
+ let changeValue = function (value) {
+ run(() => {
+ $input.val(value).trigger('input').trigger('blur');
+ });
+ };
+
+ changeValue('with spaces');
+ expect($input.val()).to.equal('with spaces');
+
+ changeValue('/with spaces');
+ expect($input.val()).to.equal('/with spaces');
+ });
+
+ it('doesn\'t mangle invalid urls on blur', function () {
+ this.on('updateUrl', () => { return null; });
+ this.render(hbs`
+ {{gh-navitem-url-input baseUrl=baseUrl url=url last=isLast change="updateUrl"}}
+ `);
+ let $input = this.$('input');
+
+ run(() => {
+ $input.val(`${currentUrl} /test`).trigger('input').trigger('blur');
+ });
+
+ expect($input.val()).to.equal(`${currentUrl} /test`);
+ });
+
+ it('toggles .fake-placeholder on focus', function () {
+ this.set('isLast', true);
+ this.render(hbs `
+ {{gh-navitem-url-input baseUrl=baseUrl url=url last=isLast change="updateUrl"}}
+ `);
+ let $input = this.$('input');
+
+ expect($input.hasClass('fake-placeholder')).to.be.true;
+
+ run(() => { $input.trigger('focus'); });
+ expect($input.hasClass('fake-placeholder')).to.be.false;
+ });
+
+ it('triggers "change" action on blur', function () {
+ let changeActionCallCount = 0;
+ this.on('updateUrl', () => { changeActionCallCount++; });
+
+ this.render(hbs `
+ {{gh-navitem-url-input baseUrl=baseUrl url=url last=isLast change="updateUrl"}}
+ `);
+ let $input = this.$('input');
+
+ $input.trigger('blur');
+
+ expect(changeActionCallCount).to.equal(1);
+ });
+
+ it('triggers "change" action on enter', function () {
+ let changeActionCallCount = 0;
+ this.on('updateUrl', () => { changeActionCallCount++; });
+
+ this.render(hbs `
+ {{gh-navitem-url-input baseUrl=baseUrl url=url last=isLast change="updateUrl"}}
+ `);
+ let $input = this.$('input');
+
+ run(() => {
+ // TODO: why is ember's keyEvent helper not available here?
+ let e = Ember.$.Event('keypress');
+ e.keyCode = 13;
+ $input.trigger(e);
+ });
+
+ expect(changeActionCallCount).to.equal(1);
+ });
+
+ it('triggers "change" action on CMD-S', function () {
+ let changeActionCallCount = 0;
+ this.on('updateUrl', () => { changeActionCallCount++; });
+
+ this.render(hbs `
+ {{gh-navitem-url-input baseUrl=baseUrl url=url last=isLast change="updateUrl"}}
+ `);
+ let $input = this.$('input');
+
+ run(() => {
+ // TODO: why is ember's keyEvent helper not available here?
+ let e = Ember.$.Event('keydown');
+ e.keyCode = 83;
+ e.metaKey = true;
+ $input.trigger(e);
+ });
+
+ expect(changeActionCallCount).to.equal(1);
+ });
+
+ it('sends absolute urls straight through to change action', function () {
+ let expectedUrl = '';
+
+ this.on('updateUrl', (url) => {
+ expect(url).to.equal(expectedUrl);
+ });
+
+ this.render(hbs `
+ {{gh-navitem-url-input baseUrl=baseUrl url=url last=isLast change="updateUrl"}}
+ `);
+ let $input = this.$('input');
+
+ let testUrl = (url) => {
+ expectedUrl = url;
+ run(() => { $input.val(url).trigger('input'); });
+ run(() => { $input.trigger('blur'); });
+ };
+
+ testUrl('http://example.com');
+ testUrl('http://example.com/');
+ testUrl('https://example.com');
+ testUrl('//example.com');
+ testUrl('//localhost:1234');
+ testUrl('#anchor');
+ testUrl('mailto:test@example.com');
+ testUrl('tel:12345-567890');
+ testUrl('javascript:alert("testing");');
+ });
+
+ it('strips base url from relative urls before sending to change action', function () {
+ let expectedUrl = '';
+
+ this.on('updateUrl', (url) => {
+ expect(url).to.equal(expectedUrl);
+ });
+
+ this.render(hbs `
+ {{gh-navitem-url-input baseUrl=baseUrl url=url last=isLast change="updateUrl"}}
+ `);
+ let $input = this.$('input');
+
+ let testUrl = (url) => {
+ expectedUrl = `/${url}`;
+ run(() => { $input.val(`${currentUrl}${url}`).trigger('input'); });
+ run(() => { $input.trigger('blur'); });
+ };
+
+ testUrl('about');
+ testUrl('about#contact');
+ testUrl('test/nested');
+ });
+
+ it('handles a baseUrl with a path component', function () {
+ let expectedUrl = '';
+
+ this.set('baseUrl', `${currentUrl}blog/`);
+
+ this.on('updateUrl', (url) => {
+ expect(url).to.equal(expectedUrl);
+ });
+
+ this.render(hbs `
+ {{gh-navitem-url-input baseUrl=baseUrl url=url last=isLast change="updateUrl"}}
+ `);
+ let $input = this.$('input');
+
+ let testUrl = (url) => {
+ expectedUrl = url;
+ run(() => { $input.val(`${currentUrl}blog${url}`).trigger('input'); });
+ run(() => { $input.trigger('blur'); });
+ };
+
+ testUrl('/about');
+ testUrl('/about#contact');
+ testUrl('/test/nested');
+ });
+
+ it('handles links to subdomains of blog domain', function () {
+ let expectedUrl = '';
+
+ this.set('baseUrl', 'http://example.com/');
+
+ this.on('updateUrl', (url) => {
+ expect(url).to.equal(expectedUrl);
+ });
+
+ this.render(hbs `
+ {{gh-navitem-url-input baseUrl=baseUrl url=url last=isLast change="updateUrl"}}
+ `);
+ let $input = this.$('input');
+
+ expectedUrl = 'http://test.example.com/';
+ run(() => { $input.val(expectedUrl).trigger('input').trigger('blur'); });
+ expect($input.val()).to.equal(expectedUrl);
+ });
+ }
+);
diff --git a/core/client/tests/unit/components/gh-navigation-test.js b/core/client/tests/unit/components/gh-navigation-test.js
deleted file mode 100644
index 5a9e9cca4a..0000000000
--- a/core/client/tests/unit/components/gh-navigation-test.js
+++ /dev/null
@@ -1,27 +0,0 @@
-/* jshint expr:true */
-import {expect} from 'chai';
-import {
- describeComponent,
- it
-} from 'ember-mocha';
-
-describeComponent(
- 'gh-navigation',
- 'GhNavigationComponent',
- {
- // specify the other units that are required for this test
- // needs: ['component:foo', 'helper:bar']
- },
- function () {
- it('renders', function () {
- // creates the component instance
- var component = this.subject();
-
- expect(component._state).to.equal('preRender');
-
- // renders the component on the page
- this.render();
- expect(component._state).to.equal('inDOM');
- });
- }
-);
diff --git a/core/client/tests/unit/components/gh-navitem-url-input-test.js b/core/client/tests/unit/components/gh-navitem-url-input-test.js
index 170dd52bef..4d7203e898 100644
--- a/core/client/tests/unit/components/gh-navitem-url-input-test.js
+++ b/core/client/tests/unit/components/gh-navitem-url-input-test.js
@@ -11,75 +11,6 @@ describeComponent(
'GhNavitemUrlInputComponent',
{},
function () {
- it('renders', function () {
- var component = this.subject();
-
- expect(component._state).to.equal('preRender');
-
- this.render();
-
- expect(component._state).to.equal('inDOM');
- });
-
- it('renders correctly with a URL that matches the base URL', function () {
- var component = this.subject({
- baseUrl: 'http://example.com/'
- });
-
- Ember.run(function () {
- component.set('value', 'http://example.com/');
- });
-
- this.render();
-
- expect(this.$().val()).to.equal('http://example.com/');
- });
-
- it('renders correctly with a relative URL', function () {
- var component = this.subject({
- baseUrl: 'http://example.com/'
- });
-
- Ember.run(function () {
- component.set('value', '/go/docs');
- });
-
- this.render();
-
- expect(this.$().val()).to.equal('/go/docs');
- });
-
- it('renders correctly with a mailto URL', function () {
- var component = this.subject({
- baseUrl: 'http://example.com/'
- });
-
- Ember.run(function () {
- component.set('value', 'mailto:someone@example.com');
- });
-
- this.render();
-
- expect(this.$().val()).to.equal('mailto:someone@example.com');
- });
-
- it('identifies a URL as relative', function () {
- var component = this.subject({
- baseUrl: 'http://example.com/',
- url: '/go/docs'
- });
-
- this.render();
-
- expect(component.get('isRelative')).to.be.ok;
-
- Ember.run(function () {
- component.set('value', 'http://example.com/go/docs');
- });
-
- expect(component.get('isRelative')).to.not.be.ok;
- });
-
it('identifies a URL as the base URL', function () {
var component = this.subject({
baseUrl: 'http://example.com/'
diff --git a/core/client/tests/unit/controllers/settings/navigation-test.js b/core/client/tests/unit/controllers/settings/navigation-test.js
new file mode 100644
index 0000000000..d5d617271c
--- /dev/null
+++ b/core/client/tests/unit/controllers/settings/navigation-test.js
@@ -0,0 +1,212 @@
+/* jshint expr:true */
+import { expect, assert } from 'chai';
+import { describeModule, it } from 'ember-mocha';
+import Ember from 'ember';
+import { NavItem } from 'ghost/controllers/settings/navigation';
+
+const { run } = Ember;
+
+var navSettingJSON = `[
+ {"label":"Home","url":"/"},
+ {"label":"JS Test","url":"javascript:alert('hello');"},
+ {"label":"About","url":"/about"},
+ {"label":"Sub Folder","url":"/blah/blah"},
+ {"label":"Telephone","url":"tel:01234-567890"},
+ {"label":"Mailto","url":"mailto:test@example.com"},
+ {"label":"External","url":"https://example.com/testing?query=test#anchor"},
+ {"label":"No Protocol","url":"//example.com"}
+]`;
+
+describeModule(
+ 'controller:settings/navigation',
+ 'Unit : Controller : settings/navigation',
+ {
+ // Specify the other units that are required for this test.
+ needs: ['service:config', 'service:notifications']
+ },
+ function () {
+ it('blogUrl: captures config and ensures trailing slash', function () {
+ var ctrl = this.subject();
+ ctrl.set('config.blogUrl', 'http://localhost:2368/blog');
+ expect(ctrl.get('blogUrl')).to.equal('http://localhost:2368/blog/');
+ });
+
+ it('navigationItems: generates list of NavItems', function () {
+ var ctrl = this.subject(),
+ lastItem;
+
+ run(() => {
+ ctrl.set('model', Ember.Object.create({navigation: navSettingJSON}));
+ expect(ctrl.get('navigationItems.length')).to.equal(9);
+ expect(ctrl.get('navigationItems.firstObject.label')).to.equal('Home');
+ expect(ctrl.get('navigationItems.firstObject.url')).to.equal('/');
+ expect(ctrl.get('navigationItems.firstObject.last')).to.be.false;
+
+ // adds a blank item as last one is complete
+ lastItem = ctrl.get('navigationItems.lastObject');
+ expect(lastItem.get('label')).to.equal('');
+ expect(lastItem.get('url')).to.equal('');
+ expect(lastItem.get('last')).to.be.true;
+ });
+ });
+
+ it('navigationItems: adds blank item if navigation setting is empty', function () {
+ var ctrl = this.subject(),
+ lastItem;
+
+ run(() => {
+ ctrl.set('model', Ember.Object.create({navigation: null}));
+ expect(ctrl.get('navigationItems.length')).to.equal(1);
+
+ lastItem = ctrl.get('navigationItems.lastObject');
+ expect(lastItem.get('label')).to.equal('');
+ expect(lastItem.get('url')).to.equal('');
+ });
+ });
+
+ it('updateLastNavItem: correctly sets "last" properties', function () {
+ var ctrl = this.subject(),
+ item1,
+ item2;
+
+ run(() => {
+ ctrl.set('model', Ember.Object.create({navigation: navSettingJSON}));
+
+ item1 = ctrl.get('navigationItems.lastObject');
+ expect(item1.get('last')).to.be.true;
+
+ ctrl.get('navigationItems').addObject(Ember.Object.create({label: 'Test', url: '/test'}));
+
+ item2 = ctrl.get('navigationItems.lastObject');
+ expect(item2.get('last')).to.be.true;
+ expect(item1.get('last')).to.be.false;
+ });
+ });
+
+ it('save: validates nav items', function (done) {
+ var ctrl = this.subject();
+
+ run(() => {
+ ctrl.set('model', Ember.Object.create({navigation: `[
+ {"label":"First", "url":"/"},
+ {"label":"", "url":"/second"},
+ {"label":"Third", "url":""}
+ ]`}));
+ // blank item won't get added because the last item is incomplete
+ expect(ctrl.get('navigationItems.length')).to.equal(3);
+
+ ctrl.save().then(function passedValidation() {
+ assert(false, 'navigationItems weren\'t validated on save');
+ done();
+ }).catch(function failedValidation() {
+ let navItems = ctrl.get('navigationItems');
+ expect(navItems[0].get('errors')).to.be.empty;
+ expect(navItems[1].get('errors.firstObject.attribute')).to.equal('label');
+ expect(navItems[2].get('errors.firstObject.attribute')).to.equal('url');
+ done();
+ });
+ });
+ });
+
+ it('save: generates new navigation JSON', function (done) {
+ var ctrl = this.subject(),
+ model = Ember.Object.create({navigation: {}}),
+ expectedJSON = `[{"label":"New","url":"/new"}]`;
+
+ model.save = function () {
+ var self = this;
+ return new Ember.RSVP.Promise(function (resolve, reject) {
+ return resolve(self);
+ });
+ };
+
+ run(() => {
+ ctrl.set('model', model);
+
+ // remove inserted blank item so validation works
+ ctrl.get('navigationItems').removeObject(ctrl.get('navigationItems.firstObject'));
+ // add new object
+ ctrl.get('navigationItems').addObject(NavItem.create({label:'New', url:'/new'}));
+
+ ctrl.save().then(function success() {
+ expect(ctrl.get('model.navigation')).to.equal(expectedJSON);
+ done();
+ }, function failure() {
+ assert(false, 'save failed with valid data');
+ done();
+ });
+ });
+ });
+
+ it('action - addItem: adds item to navigationItems', function () {
+ var ctrl = this.subject();
+
+ run(() => {
+ ctrl.set('navigationItems', [NavItem.create({label: 'First', url: '/first', last: true})]);
+ expect(ctrl.get('navigationItems.length')).to.equal(1);
+ ctrl.send('addItem');
+ expect(ctrl.get('navigationItems.length')).to.equal(2);
+ expect(ctrl.get('navigationItems.firstObject.last')).to.be.false;
+ expect(ctrl.get('navigationItems.lastObject.label')).to.equal('');
+ expect(ctrl.get('navigationItems.lastObject.url')).to.equal('');
+ expect(ctrl.get('navigationItems.lastObject.last')).to.be.true;
+ });
+ });
+
+ it('action - addItem: doesn\'t insert new item if last object is incomplete', function () {
+ var ctrl = this.subject();
+
+ run(() => {
+ ctrl.set('navigationItems', [NavItem.create({label: '', url: '', last: true})]);
+ expect(ctrl.get('navigationItems.length')).to.equal(1);
+ ctrl.send('addItem');
+ expect(ctrl.get('navigationItems.length')).to.equal(1);
+ });
+ });
+
+ it('action - deleteItem: removes item from navigationItems', function () {
+ var ctrl = this.subject(),
+ navItems = [
+ NavItem.create({label: 'First', url: '/first'}),
+ NavItem.create({label: 'Second', url: '/second', last: true})
+ ];
+
+ run(() => {
+ ctrl.set('navigationItems', navItems);
+ expect(ctrl.get('navigationItems').mapBy('label')).to.deep.equal(['First', 'Second']);
+ ctrl.send('deleteItem', ctrl.get('navigationItems.firstObject'));
+ expect(ctrl.get('navigationItems').mapBy('label')).to.deep.equal(['Second']);
+ });
+ });
+
+ it('action - moveItem: updates navigationItems list', function () {
+ var ctrl = this.subject(),
+ navItems = [
+ NavItem.create({label: 'First', url: '/first'}),
+ NavItem.create({label: 'Second', url: '/second', last: true})
+ ];
+
+ run(() => {
+ ctrl.set('navigationItems', navItems);
+ expect(ctrl.get('navigationItems').mapBy('label')).to.deep.equal(['First', 'Second']);
+ ctrl.send('moveItem', 1, 0);
+ expect(ctrl.get('navigationItems').mapBy('label')).to.deep.equal(['Second', 'First']);
+ });
+ });
+
+ it('action - updateUrl: updates URL on navigationItem', function () {
+ var ctrl = this.subject(),
+ navItems = [
+ NavItem.create({label: 'First', url: '/first'}),
+ NavItem.create({label: 'Second', url: '/second', last: true})
+ ];
+
+ run(() => {
+ ctrl.set('navigationItems', navItems);
+ expect(ctrl.get('navigationItems').mapBy('url')).to.deep.equal(['/first', '/second']);
+ ctrl.send('updateUrl', '/new', ctrl.get('navigationItems.firstObject'));
+ expect(ctrl.get('navigationItems').mapBy('url')).to.deep.equal(['/new', '/second']);
+ });
+ });
+ }
+);
diff --git a/core/client/tests/unit/validators/nav-item-test.js b/core/client/tests/unit/validators/nav-item-test.js
new file mode 100644
index 0000000000..6df58227b9
--- /dev/null
+++ b/core/client/tests/unit/validators/nav-item-test.js
@@ -0,0 +1,120 @@
+/* jshint expr:true */
+import { expect } from 'chai';
+import {
+ describe,
+ it
+} from 'mocha';
+import validator from 'ghost/validators/nav-item';
+import { NavItem } from 'ghost/controllers/settings/navigation';
+
+var testInvalidUrl,
+ testValidUrl;
+
+testInvalidUrl = function (url) {
+ let navItem = NavItem.create({url: url});
+
+ validator.check(navItem, 'url');
+
+ expect(validator.get('passed'), `"${url}" passed`).to.be.false;
+ expect(navItem.get('errors').errorsFor('url')).to.deep.equal([{
+ attribute: 'url',
+ message: 'You must specify a valid URL or relative path'
+ }]);
+ expect(navItem.get('hasValidated')).to.include('url');
+};
+
+testValidUrl = function (url) {
+ let navItem = NavItem.create({url: url});
+
+ validator.check(navItem, 'url');
+
+ expect(validator.get('passed'), `"${url}" failed`).to.be.true;
+ expect(navItem.get('hasValidated')).to.include('url');
+};
+
+describe('Unit : Validator : nav-item', function () {
+ it('requires label presence', function () {
+ let navItem = NavItem.create();
+
+ validator.check(navItem, 'label');
+
+ expect(validator.get('passed')).to.be.false;
+ expect(navItem.get('errors').errorsFor('label')).to.deep.equal([{
+ attribute: 'label',
+ message: 'You must specify a label'
+ }]);
+ expect(navItem.get('hasValidated')).to.include('label');
+ });
+
+ it('doesn\'t validate label if empty and last', function () {
+ let navItem = NavItem.create({last: true});
+
+ validator.check(navItem, 'label');
+
+ expect(validator.get('passed')).to.be.true;
+ });
+
+ it('requires url presence', function () {
+ let navItem = NavItem.create();
+
+ validator.check(navItem, 'url');
+
+ expect(validator.get('passed')).to.be.false;
+ expect(navItem.get('errors').errorsFor('url')).to.deep.equal([{
+ attribute: 'url',
+ message: 'You must specify a URL or relative path'
+ }]);
+ expect(navItem.get('hasValidated')).to.include('url');
+ });
+
+ it('fails on invalid url values', function () {
+ let invalidUrls = [
+ 'test@example.com',
+ '/has spaces',
+ 'no-leading-slash',
+ 'http://example.com/with spaces'
+ ];
+
+ invalidUrls.forEach(function (url) {
+ testInvalidUrl(url);
+ });
+ });
+
+ it('passes on valid url values', function () {
+ let validUrls = [
+ 'http://localhost:2368',
+ 'http://localhost:2368/some-path',
+ 'https://localhost:2368/some-path',
+ '//localhost:2368/some-path',
+ 'http://localhost:2368/#test',
+ 'http://localhost:2368/?query=test&another=example',
+ 'http://localhost:2368/?query=test&another=example#test',
+ 'tel:01234-567890',
+ 'mailto:test@example.com',
+ 'http://some:user@example.com:1234',
+ '/relative/path'
+ ];
+
+ validUrls.forEach(function (url) {
+ testValidUrl(url);
+ });
+ });
+
+ it('doesn\'t validate url if empty and last', function () {
+ let navItem = NavItem.create({last: true});
+
+ validator.check(navItem, 'url');
+
+ expect(validator.get('passed')).to.be.true;
+ });
+
+ it('validates url and label by default', function () {
+ let navItem = NavItem.create();
+
+ validator.check(navItem);
+
+ expect(navItem.get('errors').errorsFor('label')).to.not.be.empty;
+ expect(navItem.get('errors').errorsFor('url')).to.not.be.empty;
+ expect(validator.get('passed')).to.be.false;
+ });
+});