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"}} +
+ {{#each navigationItems as |navItem|}} + {{gh-navitem navItem=navItem baseUrl=blogUrl addItem="addItem" deleteItem="deleteItem" updateUrl="updateUrl"}} + {{/each}} +
+ {{/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; + }); +});