1
0
Fork 0
mirror of https://github.com/TryGhost/Ghost-Admin.git synced 2023-12-14 02:33:04 +01:00

Fix nav regressions in admin client

issue #5841

- fix relative link checks in navlink url input component
- fix navlink url input component sending absolute URLs instead of relative URLs to action handler
- remove URL manipulation in navigation settings controller (url input handles URL manipulation, validator flags anything that's still incorrect)
- capture cmd-s in url input to ensure changes are actioned before save
- automatically add mailto: to e-mail addresses
- add gh-validation-state-container component so .error/.success validation classes can be applied to any container element
- add validation-state mixin that can be mixed in to any other component to give it access to validation status (used in gh-navitem component to keep alignment when inline error message elements are added)
- validate and display inline errors on save
- improve ember test coverage for navigation settings related controller and components
This commit is contained in:
Kevin Ansfield 2015-09-16 18:02:06 +01:00
parent d1bf239c9d
commit 4ebacc7d9c
21 changed files with 1070 additions and 199 deletions

View file

@ -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'
});

View file

@ -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');
}
});

View file

@ -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);

View file

@ -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: {

View file

@ -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';
})
});

View file

@ -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. ' +
'<br /> 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);
}
}

View file

@ -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

View file

@ -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;
})
});

View file

@ -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;

View file

@ -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);

View file

@ -5,12 +5,14 @@
{{/unless}}
<div class="gh-blognav-line">
<span class="gh-blognav-label">
{{gh-trim-focus-input class="gh-input" focus=navItem.last placeholder="Label" value=navItem.label}}
</span>
<span class="gh-blognav-url">
{{gh-navitem-url-input class="gh-input" baseUrl=baseUrl url=navItem.url last=navItem.last change="updateUrl"}}
</span>
{{#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}}
</div>
{{#if navItem.last}}

View file

@ -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;
}
});

View file

@ -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",

View file

@ -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');

View file

@ -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}}<div class="js-gh-blognav"><div class="gh-blognav-item"></div></div>{{/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"}}
<form id="settings-navigation" class="gh-blognav js-gh-blognav" novalidate="novalidate">
{{#each navigationItems as |navItem|}}
{{gh-navitem navItem=navItem baseUrl=blogUrl addItem="addItem" deleteItem="deleteItem" updateUrl="updateUrl"}}
{{/each}}
</form>
{{/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'
});
});
}
);

View file

@ -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');
});
}
);

View file

@ -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);
});
}
);

View file

@ -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');
});
}
);

View file

@ -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/'

View file

@ -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']);
});
});
}
);

View file

@ -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;
});
});