add spin-button component & implement it

closes #3928
- adds spin-button component & styles
- implements spin-button in places where buttons trigger async tasks
This commit is contained in:
Austin Burdine 2015-08-10 09:43:49 -06:00
parent 6648de5845
commit 3c0bd3e8b2
31 changed files with 273 additions and 110 deletions

View File

@ -9,6 +9,7 @@ export default Ember.Component.extend({
isPublished: null,
willPublish: null,
postOrPage: null,
submitting: false,
// Tracks whether we're going to change the state of the post on save
isDangerous: Ember.computed('isPublished', 'willPublish', function () {

View File

@ -0,0 +1,38 @@
import Ember from 'ember';
export default Ember.Component.extend({
tagName: 'button',
buttonText: '',
submitting: false,
autoWidth: true,
// Disable Button when isLoading equals true
attributeBindings: ['disabled'],
// Must be set on the controller
disabled: Ember.computed.equal('submitting', true),
click: function () {
if (this.get('action')) {
this.sendAction('action');
return false;
}
return true;
},
setSize: function () {
if (!this.get('submitting') && this.get('autoWidth')) {
// this exists so that the spinner doesn't change the size of the button
this.$().width(this.$().width()); // sets width of button
this.$().height(this.$().height()); // sets height of button
}
},
width: Ember.observer('buttonText', 'autoWidth', function () {
this.setSize();
}),
didInsertElement: function () {
this.setSize();
}
});

View File

@ -3,6 +3,7 @@ import ValidationEngine from 'ghost/mixins/validation-engine';
export default Ember.Controller.extend(ValidationEngine, {
validationType: 'signin',
submitting: false,
application: Ember.inject.controller(),
notifications: Ember.inject.service(),
@ -28,6 +29,7 @@ export default Ember.Controller.extend(ValidationEngine, {
// it needs to be caught so it doesn't generate an exception in the console,
// but it's actually "handled" by the sessionAuthenticationFailed action handler.
}).finally(function () {
self.toggleProperty('submitting');
appController.set('skipAuthSuccessHandler', undefined);
});
},
@ -35,6 +37,8 @@ export default Ember.Controller.extend(ValidationEngine, {
validateAndAuthenticate: function () {
var self = this;
this.toggleProperty('submitting');
// Manually trigger events for input fields, ensuring legacy compatibility with
// browsers and password managers that don't send proper events on autofill
$('#login').find('input').trigger('change');

View File

@ -1,17 +1,14 @@
import Ember from 'ember';
import SettingsSaveMixin from 'ghost/mixins/settings-save';
export default Ember.Controller.extend({
export default Ember.Controller.extend(SettingsSaveMixin, {
notifications: Ember.inject.service(),
actions: {
save: function () {
var notifications = this.get('notifications');
save: function () {
var notifications = this.get('notifications');
return this.get('model').save().then(function (model) {
return model;
}).catch(function (error) {
notifications.showAPIError(error);
});
}
return this.get('model').save().catch(function (error) {
notifications.showAPIError(error);
});
}
});

View File

@ -1,7 +1,8 @@
import Ember from 'ember';
import SettingsSaveMixin from 'ghost/mixins/settings-save';
import randomPassword from 'ghost/utils/random-password';
export default Ember.Controller.extend({
export default Ember.Controller.extend(SettingsSaveMixin, {
notifications: Ember.inject.service(),
config: Ember.inject.service(),
@ -62,26 +63,26 @@ export default Ember.Controller.extend({
}
}),
save: function () {
var notifications = this.get('notifications'),
config = this.get('config');
return this.get('model').save().then(function (model) {
config.set('blogTitle', model.get('title'));
return model;
}).catch(function (error) {
if (error) {
notifications.showAPIError(error);
}
});
},
actions: {
validate: function () {
this.get('model').validate(arguments);
},
save: function () {
var notifications = this.get('notifications'),
config = this.get('config');
return this.get('model').save().then(function (model) {
config.set('blogTitle', model.get('title'));
return model;
}).catch(function (error) {
if (error) {
notifications.showAPIError(error);
}
});
},
checkPostsPerPage: function () {
var postsPerPage = this.get('model.postsPerPage');

View File

@ -4,6 +4,7 @@ import {request as ajax} from 'ic-ajax';
export default Ember.Controller.extend({
uploadButtonText: 'Import',
importErrors: '',
submitting: false,
ghostPaths: Ember.inject.service('ghost-paths'),
notifications: Ember.inject.service(),
@ -78,18 +79,23 @@ export default Ember.Controller.extend({
},
sendTestEmail: function () {
var notifications = this.get('notifications');
var notifications = this.get('notifications'),
self = this;
this.toggleProperty('submitting');
ajax(this.get('ghostPaths.url').api('mail', 'test'), {
type: 'POST'
}).then(function () {
notifications.showAlert('Check your email for the test message.', {type: 'info'});
self.toggleProperty('submitting');
}).catch(function (error) {
if (typeof error.jqXHR !== 'undefined') {
notifications.showAPIError(error);
} else {
notifications.showErrors(error);
}
self.toggleProperty('submitting');
});
}
}

View File

@ -1,4 +1,5 @@
import Ember from 'ember';
import SettingsSaveMixin from 'ghost/mixins/settings-save';
var NavItem = Ember.Object.extend({
label: '',
@ -10,7 +11,7 @@ var NavItem = Ember.Object.extend({
})
});
export default Ember.Controller.extend({
export default Ember.Controller.extend(SettingsSaveMixin, {
config: Ember.inject.service(),
notifications: Ember.inject.service(),
@ -54,6 +55,65 @@ export default Ember.Controller.extend({
});
}),
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');
// 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;
}
navSetting = navItems.map(function (item) {
var label,
url;
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 += '/';
}
} else if (!validator.isURL(url) && url !== '' && url[0] !== '/' && url.indexOf('mailto:') !== 0) {
url = '/' + url;
}
return {label: label, url: url};
}).compact();
this.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');
notifications.closeNotifications();
return this.get('model').save().catch(function (err) {
notifications.showErrors(err);
});
},
actions: {
addItem: function () {
var navItems = this.get('navigationItems'),
@ -94,65 +154,6 @@ export default Ember.Controller.extend({
}
navItem.set('url', url);
},
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');
// 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;
}
navSetting = navItems.map(function (item) {
var label,
url;
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 += '/';
}
} else if (!validator.isURL(url) && url !== '' && url[0] !== '/' && url.indexOf('mailto:') !== 0) {
url = '/' + url;
}
return {label: label, url: url};
}).compact();
this.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');
notifications.closeNotifications();
this.get('model').save().catch(function (err) {
notifications.showErrors(err);
});
}
}
});

View File

@ -9,6 +9,7 @@ export default Ember.Controller.extend({
users: '',
ownerEmail: Ember.computed.alias('two.email'),
submitting: false,
usersArray: Ember.computed('users', function () {
var users = this.get('users').split('\n').filter(function (email) {
return email.trim().length > 0;
@ -75,6 +76,7 @@ export default Ember.Controller.extend({
this.get('errors').clear();
if (validationErrors === true && users.length > 0) {
this.toggleProperty('submitting');
this.get('authorRole').then(function (authorRole) {
Ember.RSVP.Promise.all(
users.map(function (user) {
@ -123,6 +125,8 @@ export default Ember.Controller.extend({
self.send('loadServerNotifications');
self.transitionTo('posts.index');
}
self.toggleProperty('submitting');
});
});
} else if (users.length === 0) {

View File

@ -11,6 +11,7 @@ export default Ember.Controller.extend(ValidationEngine, {
password: null,
image: null,
blogCreated: false,
submitting: false,
ghostPaths: Ember.inject.service('ghost-paths'),
notifications: Ember.inject.service(),
@ -54,6 +55,8 @@ export default Ember.Controller.extend(ValidationEngine, {
config = this.get('config'),
method = this.get('blogCreated') ? 'PUT' : 'POST';
this.toggleProperty('submitting');
this.validate().then(function () {
self.set('showError', false);
ajax({
@ -80,18 +83,23 @@ export default Ember.Controller.extend(ValidationEngine, {
if (data.image) {
self.sendImage(result.users[0])
.then(function () {
self.toggleProperty('submitting');
self.transitionToRoute('setup.three');
}).catch(function (resp) {
self.toggleProperty('submitting');
notifications.showAPIError(resp);
});
} else {
self.toggleProperty('submitting');
self.transitionToRoute('setup.three');
}
});
}).catch(function (resp) {
self.toggleProperty('submitting');
notifications.showAPIError(resp);
});
}).catch(function () {
self.toggleProperty('submitting');
self.set('showError', true);
});
},

View File

@ -4,6 +4,7 @@ import {request as ajax} from 'ic-ajax';
export default Ember.Controller.extend(ValidationEngine, {
submitting: false,
loggingIn: false,
ghostPaths: Ember.inject.service('ghost-paths'),
notifications: Ember.inject.service(),
@ -19,12 +20,15 @@ export default Ember.Controller.extend(ValidationEngine, {
authStrategy = 'simple-auth-authenticator:oauth2-password-grant',
data = model.getProperties('identification', 'password');
this.get('session').authenticate(authStrategy, data).catch(function (err) {
this.get('session').authenticate(authStrategy, data).then(function () {
self.toggleProperty('loggingIn');
}).catch(function (err) {
self.toggleProperty('loggingIn');
if (err.errors) {
self.set('flowErrors', err.errors[0].message.string);
}
// If authentication fails a rejected promise will be returned.
// if authentication fails a rejected promise will be returned.
// it needs to be caught so it doesn't generate an exception in the console,
// but it's actually "handled" by the sessionAuthenticationFailed action handler.
});
@ -39,6 +43,7 @@ export default Ember.Controller.extend(ValidationEngine, {
this.validate().then(function () {
self.get('notifications').closeNotifications();
self.toggleProperty('loggingIn');
self.send('authenticate');
}).catch(function (error) {
if (error) {
@ -56,7 +61,7 @@ export default Ember.Controller.extend(ValidationEngine, {
this.set('flowErrors', '');
this.validate({property: 'identification'}).then(function () {
self.set('submitting', true);
self.toggleProperty('submitting');
ajax({
url: self.get('ghostPaths.url').api('authentication', 'passwordreset'),
@ -67,10 +72,10 @@ export default Ember.Controller.extend(ValidationEngine, {
}]
}
}).then(function () {
self.set('submitting', false);
self.toggleProperty('submitting');
notifications.showAlert('Please check your email for instructions.', {type: 'info'});
}).catch(function (resp) {
self.set('submitting', false);
self.toggleProperty('submitting');
if (resp && resp.jqXHR && resp.jqXHR.responseJSON && resp.jqXHR.responseJSON.errors) {
self.set('flowErrors', resp.jqXHR.responseJSON.errors[0].message);
} else {

View File

@ -7,6 +7,7 @@ import ValidationEngine from 'ghost/mixins/validation-engine';
export default Ember.Controller.extend(ValidationEngine, {
// ValidationEngine settings
validationType: 'user',
submitting: false,
ghostPaths: Ember.inject.service('ghost-paths'),
notifications: Ember.inject.service(),
@ -103,6 +104,8 @@ export default Ember.Controller.extend(ValidationEngine, {
user.set('slug', slugValue);
}
this.toggleProperty('submitting');
promise = Ember.RSVP.resolve(afterUpdateSlug).then(function () {
return user.save({format: false});
}).then(function (model) {
@ -121,11 +124,15 @@ export default Ember.Controller.extend(ValidationEngine, {
window.history.replaceState({path: newPath}, '', newPath);
}
this.toggleProperty('submitting');
return model;
}).catch(function (errors) {
if (errors) {
self.get('notifications').showErrors(errors);
}
this.toggleProperty('submitting');
});
this.set('lastPromise', promise);

View File

@ -17,6 +17,7 @@ export default Ember.Mixin.create({
autoSaveId: null,
timedSaveId: null,
editor: null,
submitting: false,
notifications: Ember.inject.service(),
@ -247,6 +248,8 @@ export default Ember.Mixin.create({
options = options || {};
this.toggleProperty('submitting');
if (options.backgroundSave) {
// do not allow a post's status to be set to published by a background save
status = 'draft';
@ -294,6 +297,7 @@ export default Ember.Mixin.create({
self.showSaveNotification(prevStatus, model.get('status'), isNew ? true : false);
}
self.toggleProperty('submitting');
return model;
});
}).catch(function (errors) {
@ -303,6 +307,7 @@ export default Ember.Mixin.create({
self.set('model.status', prevStatus);
self.toggleProperty('submitting');
return self.get('model');
});

View File

@ -0,0 +1,17 @@
import Ember from 'ember';
export default Ember.Mixin.create({
submitting: false,
actions: {
save: function () {
var self = this;
this.set('submitting', true);
this.save().then(function () {
self.set('submitting', false);
});
}
}
});

View File

@ -200,3 +200,27 @@ input[type="reset"].btn-block,
input[type="button"].btn-block {
width: 100%;
}
/* Spin Buttons!
/* ---------------------------------------------------------- */
.spinner {
position: relative;
display: inline-block;
box-sizing: border-box;
margin: -2px 0;
width: 14px;
height: 14px;
border: rgba(0,0,0,0.2) solid 4px;
border-radius: 100px;
animation: spin 1s linear infinite;
}
.spinner:before {
content: "";
display: block;
margin-top: 6px;
width: 4px;
height: 4px;
background: rgba(0,0,0,0.6);
border-radius: 100px;
}

View File

@ -406,6 +406,15 @@ img {
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.fade-in {
animation: fade-in 0.2s;
animation-fill-mode: forwards;

View File

@ -1,6 +1,4 @@
<button type="button" {{action "save"}} class="btn btn-sm js-publish-button {{if isDangerous 'btn-red' 'btn-blue'}}">
{{saveText}}
</button>
{{gh-spin-button type="button" classNameBindings=":btn :btn-sm :js-publish-button isDangerous:btn-red:btn-blue" action="save" buttonText=saveText submitting=submitting}}
{{#gh-dropdown-button dropdownName="post-save-menu" classNameBindings=":btn :btn-sm isDangerous:btn-red:btn-blue btnopen:active :dropdown-toggle :up"}}
<i class="options icon-arrow2"></i>

View File

@ -0,0 +1,9 @@
{{#unless submitting}}
{{#if buttonText}}
{{buttonText}}
{{else}}
{{{yield}}}
{{/if}}
{{else}}
<span class="spinner"></span>
{{/unless}}

View File

@ -16,6 +16,7 @@
save="save"
setSaveType="setSaveType"
delete="openDeleteModal"
submitting=submitting
}}
</section>
</header>

View File

@ -5,7 +5,7 @@
<div class="password-wrap">
{{input class="gh-input password" type="password" placeholder="Password" name="password" value=password}}
</div>
<button class="btn btn-blue" type="submit" {{action "validateAndAuthenticate"}} disabled={{submitting}}>Log in</button>
{{gh-spin-button class="btn btn-blue" type="submit" action="validateAndAuthenticate" submitting=submitting buttonText="Log in"}}
</form>
{{/gh-modal-dialog}}

View File

@ -17,7 +17,7 @@
{{input value=ne2Password class="gh-input password" type="password" placeholder="Confirm Password" name="ne2password" focusOut=(action "validate" "ne2Password")}}
{{gh-error-message errors=errors property="ne2Password"}}
{{/gh-form-group}}
<button class="btn btn-blue btn-block" type="submit" disabled={{submitting}}>Reset Password</button>
{{gh-spin-button class="btn btn-blue btn-block" type="submit" submitting=submitting buttonText="Reset Password" autoWidth=false}}
</form>
</section>
</div>

View File

@ -2,7 +2,7 @@
<header class="view-header">
{{#gh-view-title openMobileMenu="openMobileMenu"}}Code Injection{{/gh-view-title}}
<section class="view-actions">
<button type="button" class="btn btn-blue" {{action "save"}}>Save</button>
{{gh-spin-button type="button" class="btn btn-blue" action="save" buttonText="Save" submitting=submitting}}
</section>
</header>

View File

@ -2,7 +2,7 @@
<header class="view-header">
{{#gh-view-title openMobileMenu="openMobileMenu"}}General{{/gh-view-title}}
<section class="view-actions">
<button type="button" class="btn btn-blue" {{action "save"}}>Save</button>
{{gh-spin-button type="button" class="btn btn-blue" action="save" buttonText="Save" submitting=submitting}}
</section>
</header>

View File

@ -37,7 +37,7 @@
<fieldset>
<div class="form-group">
<label>Send a test email</label>
<button type="button" id="sendtestmail" class="btn btn-blue" {{action "sendTestEmail"}}>Send</button>
{{gh-spin-button type="button" id="sendtestemail" class="btn btn-blue" action="sendTestEmail" buttonText="Send" submitting=submitting}}
<p>Sends a test email to your address.</p>
</div>
</fieldset>

View File

@ -2,7 +2,7 @@
<header class="view-header">
{{#gh-view-title openMobileMenu="openMobileMenu"}}Navigation{{/gh-view-title}}
<section class="view-actions">
<button type="button" class="btn btn-blue" {{action "save"}}>Save</button>
{{gh-spin-button type="button" class="btn btn-blue" action="save" buttonText="Save" submitting=submitting}}
</section>
</header>

View File

@ -11,9 +11,8 @@
{{gh-error-message errors=errors property="users"}}
</form>
<button {{action 'invite'}} class="btn btn-default btn-lg btn-block {{buttonClass}}">
{{buttonText}}
</button>
{{gh-spin-button type="button" classNameBindings=":btn :btn-default :btn-lg :btn-block buttonClass" action="invite" buttonText=buttonText submitting=submitting autoWidth=false}}
<button class="gh-flow-skip" {{action "skipInvite"}}>
I'll do this later, take me to my blog!
</button>

View File

@ -37,6 +37,7 @@
{{gh-error-message errors=errors property="blogTitle"}}
{{/gh-form-group}}
</form>
<button type="submit" class="btn btn-green btn-lg btn-block" {{action "setup"}}>Last step: Invite your team <i class="icon-chevron"></i></button>
{{#gh-spin-button type="button" class="btn btn-green btn-lg btn-block" action="setup" submitting=submitting autoWidth=false}}
Last step: Invite your team <i class="icon-chevron"></i>
{{/gh-spin-button}}
<p class="main-error">{{#if showError}}{{invalidMessage}}{{/if}}</p>

View File

@ -15,7 +15,7 @@
</span>
{{gh-error-message errors=model.errors property="password"}}
{{/gh-form-group}}
<button id="login-button" class="login btn btn-blue btn-block" type="submit" tabindex="3" disabled={{submitting}}>Sign in</button>
{{gh-spin-button class="login btn btn-blue btn-block" type="submit" tabindex="3" buttonText="Sign in" submitting=loggingIn autoWidth=false}}
</form>
<p class="main-error">{{{flowErrors}}}</p>

View File

@ -48,7 +48,7 @@
{{/gh-form-group}}
</form>
<button type="submit" class="btn btn-green btn-lg btn-block" {{action "signup"}} disabled={{submitting}}>Create Account</button>
{{gh-spin-button type="submit" class="btn btn-green btn-lg btn-block" action="signup" submitting=submitting autoWidth=false}}
</section>
</div>

View File

@ -30,7 +30,7 @@
</span>
{{/if}}
<button class="btn btn-blue" {{action "save"}}>Save</button>
{{gh-spin-button class="btn btn-blue" action="save" buttonText="Save" submitting=submitting}}
</section>
</header>

View File

@ -12,6 +12,7 @@ describeComponent(
needs: [
'component:gh-dropdown-button',
'component:gh-dropdown',
'component:gh-spin-button',
'service:dropdown'
]
},

View File

@ -0,0 +1,27 @@
/* jshint expr:true */
import {expect} from 'chai';
import {
describeComponent,
it
} from 'ember-mocha';
describeComponent(
'gh-spin-button',
'GhSpinButtonComponent',
{
// 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');
});
}
);