success/failure state spinner buttons (#566)

refs https://github.com/TryGhost/Ghost/issues/7515
- changes to `gh-task-button`:
  - can take `buttonText` (default: "Save"), `runningText` ("Saving"), `successText` ("Saved"), and `failureText` ("Retry") params
  - positional param for `buttonText`
  - default button display can be overridden by passing in a block, in that scenario the component will yield a hash containing all states to be used in this fashion:
    ```
    {{#gh-task-button task=myTask as |task|}}
    {{if task.isIdle "Save me"}}
    {{if task.isRunning "Saving"}}
    {{if task.isSuccess "Thank you!"}}
    {{if task.isFailure "Nooooooo!"}}
    {{/gh-task-button}}
    ```
- update existing uses of `gh-task-button` to match new component signature
This commit is contained in:
Kevin Ansfield 2017-03-07 17:28:52 +00:00 committed by Austin Burdine
parent c5b0301e87
commit f1512d12c2
20 changed files with 150 additions and 42 deletions

View File

@ -1,6 +1,7 @@
import Component from 'ember-component';
import observer from 'ember-metal/observer';
import {reads} from 'ember-computed';
import computed, {reads} from 'ember-computed';
import {isBlank} from 'ember-utils';
import {invokeAction} from 'ember-invoke-action';
/**
@ -14,16 +15,41 @@ import {invokeAction} from 'ember-invoke-action';
* component, all running promises will automatically be cancelled when this
* component is removed from the DOM
*/
export default Component.extend({
const GhTaskButton = Component.extend({
tagName: 'button',
classNameBindings: ['isRunning:appear-disabled'],
classNameBindings: ['isRunning:appear-disabled', 'isSuccess:gh-btn-green', 'isFailure:gh-btn-red'],
attributeBindings: ['disabled', 'type', 'tabindex'],
task: null,
disabled: false,
buttonText: 'Save',
runningText: reads('buttonText'),
successText: 'Saved',
failureText: 'Retry',
isRunning: reads('task.last.isRunning'),
isSuccess: computed('isRunning', 'task.last.value', function () {
if (this.get('isRunning')) {
return false;
}
let value = this.get('task.last.value');
return !isBlank(value) && value !== false;
}),
isFailure: computed('isRunning', 'isSuccess', 'task.last.error', function () {
if (this.get('isRunning') || this.get('isSuccess')) {
return false;
}
return this.get('task.last.error') !== undefined;
}),
isIdle: computed('isRunning', 'isSuccess', 'isFailure', function () {
return !this.get('isRunning') && !this.get('isSuccess') && !this.get('isFailure');
}),
click() {
// do nothing if disabled externally
if (this.get('disabled')) {
@ -56,3 +82,9 @@ export default Component.extend({
}
})
});
GhTaskButton.reopenClass({
positionalParams: ['buttonText']
});
export default GhTaskButton;

View File

@ -11,8 +11,7 @@ export default ModalComponent.extend({
this.send('closeModal');
} catch (error) {
// TODO: server-side validation errors should be serialized
// properly so that errors are added to the model's errors
// property
// properly so that errors are added to model.errors automatically
if (error && isInvalidError(error)) {
let [firstError] = error.errors;
let {message} = firstError;
@ -24,10 +23,11 @@ export default ModalComponent.extend({
}
}
// this is a route action so it should bubble up to the global
// error handler
// route action so it should bubble up to the global error handler
if (error) {
throw error;
}
}
}).drop(),
actions: {

View File

@ -45,6 +45,10 @@ fieldset[disabled] .gh-btn {
pointer-events: none;
}
/* TODO: replace with svg icons */
.gh-btn i {
display: inline-block;
}
/* Button highlights
/* ---------------------------------------------------------- */

View File

@ -9,7 +9,7 @@
{{#if confirm}}
<footer class="modal-footer">
{{! Buttons must be on one line to prevent white-space errors }}
<button type="button" class="{{rejectButtonClass}} btn-minor js-button-reject" {{action "confirm" "reject"}}>{{confirm.reject.text}}</button><button type="button" class="{{acceptButtonClass}} js-button-accept" {{action "confirm" "accept"}}>{{confirm.accept.text}}</button>
<button type="button" class="{{rejectButtonClass}} btn-minor" {{action "confirm" "reject"}} data-test-modal-reject-button>{{confirm.reject.text}}</button><button type="button" class="{{acceptButtonClass}}" {{action "confirm" "accept"}} data-test-modal-accept-button>{{confirm.accept.text}}</button>
</footer>
{{/if}}
</section>

View File

@ -1,9 +1,15 @@
{{#if isRunning}}
<span class="spinner"></span>
{{#if hasBlock}}
{{yield (hash
isIdle=isIdle
isRunning=isRunning
isSuccess=isSuccess
isFailure=isFailure
)}}
{{else}}
{{#if buttonText}}
{{buttonText}}
{{else}}
{{{yield}}}
{{/if}}
<span>
{{#if isRunning}}<span class="spinner"></span>{{/if}}
{{if (or isIdle isRunning) buttonText}}
{{#if isSuccess}}<i class="icon-check"></i> {{successText}}{{/if}}
{{#if isFailure}}<i class="icon-x"></i> {{failureText}}{{/if}}
</span>
{{/if}}

View File

@ -9,5 +9,5 @@
<div class="modal-footer">
<button {{action "closeModal"}} class="gh-btn"><span>Cancel</span></button>
{{#gh-task-button task=deleteAll class="gh-btn gh-btn-red"}}<span>Delete</span>{{/gh-task-button}}
{{gh-task-button "Delete" successText="Deleted" task=deleteAll class="gh-btn gh-btn-red"}}
</div>

View File

@ -11,5 +11,5 @@
<div class="modal-footer">
<button {{action "closeModal"}} class="gh-btn"><span>Cancel</span></button>
{{#gh-task-button task=deletePost class="gh-btn gh-btn-red"}}<span>Delete</span>{{/gh-task-button}}
{{gh-task-button "Delete" successText="Deleted" task=deletePost class="gh-btn gh-btn-red"}}
</div>

View File

@ -9,5 +9,5 @@
<div class="modal-footer">
<button {{action "closeModal"}} class="gh-btn"><span>Cancel</span></button>
{{#gh-task-button task=deleteSubscriber class="gh-btn gh-btn-red"}}<span>Delete</span>{{/gh-task-button}}
{{gh-task-button "Delete" successText="Deleted" task=deleteSubscriber class="gh-btn gh-btn-red"}}
</div>

View File

@ -12,5 +12,5 @@
<div class="modal-footer">
<button {{action "closeModal"}} class="gh-btn"><span>Cancel</span></button>
{{#gh-task-button task=deleteTag class="gh-btn gh-btn-red"}}<span>Delete</span>{{/gh-task-button}}
{{gh-task-button "Delete" successText="Deleted" task=deleteTag class="gh-btn gh-btn-red"}}
</div>

View File

@ -9,5 +9,5 @@
<div class="modal-footer">
<button {{action "closeModal"}} class="gh-btn" data-test-cancel-button><span>Cancel</span></button>
{{#gh-task-button task=deleteTheme class="gh-btn gh-btn-red" data-test-delete-button=true}}<span>Delete</span>{{/gh-task-button}}
{{gh-task-button "Delete" successText="Deleted" task=deleteTheme class="gh-btn gh-btn-red" data-test-delete-button=true}}
</div>

View File

@ -13,5 +13,5 @@
<div class="modal-footer">
<button {{action "closeModal"}} class="gh-btn"><span>Cancel</span></button>
{{#gh-task-button task=deleteUser class="gh-btn gh-btn-red"}}<span>Delete</span>{{/gh-task-button}}
{{gh-task-button "Delete" successText="Deleted" task=deleteUser class="gh-btn gh-btn-red"}}
</div>

View File

@ -42,5 +42,5 @@
</div>
<div class="modal-footer">
{{#gh-task-button task=sendInvitation class="gh-btn gh-btn-green"}}<span>Send invitation now</span>{{/gh-task-button}}
{{gh-task-button "Send invitation now" successText="Sent" task=sendInvitation class="gh-btn gh-btn-green"}}
</div>

View File

@ -25,5 +25,5 @@
<div class="modal-footer">
<button {{action "closeModal"}} class="gh-btn"><span>Cancel</span></button>
{{#gh-task-button task=addSubscriber class="gh-btn gh-btn-green"}}<span>Add</span>{{/gh-task-button}}
{{gh-task-button "Add" successText="Added" task=addSubscriber class="gh-btn gh-btn-green"}}
</div>

View File

@ -6,13 +6,13 @@
<div class="modal-body {{if authenticationError 'error'}}">
{{#if config.ghostOAuth}}
{{#gh-task-button task=reauthenticate class="login gh-btn gh-btn-blue gh-btn-block" tabindex="3" autoWidth="false"}}<span>Sign in with Ghost</span>{{/gh-task-button}}
{{gh-task-button "Sign in with Ghost" task=reauthenticate class="login gh-btn gh-btn-blue gh-btn-block" tabindex="3" autoWidth="false"}}
{{else}}
<form id="login" class="login-form" method="post" novalidate="novalidate" {{action "confirm" on="submit"}}>
{{#gh-validation-status-container class="password-wrap" errors=errors property="password" hasValidated=hasValidated}}
{{gh-input password class="password" type="password" placeholder="Password" name="password" update=(action (mut password))}}
{{/gh-validation-status-container}}
{{#gh-task-button task=reauthenticate class="gh-btn gh-btn-blue" type="submit"}}<span>Log in</span>{{/gh-task-button}}
{{gh-task-button "Log in" task=reauthenticate class="gh-btn gh-btn-blue" type="submit"}}
</form>
{{/if}}

View File

@ -12,5 +12,5 @@
<div class="modal-footer">
<button {{action "closeModal"}} class="gh-btn"><span>Cancel</span></button>
{{#gh-task-button task=transferOwnership class="gh-btn gh-btn-red"}}<span>Yep - I'm sure</span>{{/gh-task-button}}
{{gh-task-button "Yep - I'm sure" task=transferOwnership class="gh-btn gh-btn-red"}}
</div>

View File

@ -20,5 +20,5 @@
<div class="modal-footer">
<button {{action "closeModal"}} class="gh-btn"><span>Cancel</span></button>
{{#gh-task-button task=uploadImage class="gh-btn gh-btn-blue right js-button-accept"}}<span>Save</span>{{/gh-task-button}}
{{gh-task-button task=uploadImage class="gh-btn gh-btn-blue right" data-test-modal-accept-button=true}}
</div>

View File

@ -9,7 +9,7 @@
{{gh-input ne2Password type="password" name="ne2password" placeholder="Confirm Password" class="password" autocorrect="off" autofocus="autofocus" update=(action (mut ne2Password))}}
{{/gh-form-group}}
{{#gh-task-button task=resetPassword class="gh-btn gh-btn-blue gh-btn-block" type="submit" autoWidth="false"}}<span>Reset Password</span>{{/gh-task-button}}
{{gh-task-button "Reset Password" task=resetPassword class="gh-btn gh-btn-blue gh-btn-block" type="submit" autoWidth="false"}}
</form>
<p class="main-error">{{{flowErrors}}}</p>

View File

@ -45,7 +45,7 @@
</span>
{{/if}}
{{#gh-task-button class="gh-btn gh-btn-blue" task=save}}<span>Save</span>{{/gh-task-button}}
{{gh-task-button class="gh-btn gh-btn-blue" task=save}}
</section>
</header>
@ -199,7 +199,7 @@
{{/gh-form-group}}
<div class="form-group">
{{#gh-task-button class="gh-btn gh-btn-red button-change-password" task=user.saveNewPassword}}<span>Change Password</span>{{/gh-task-button}}
{{gh-task-button "Change Password" class="gh-btn gh-btn-red button-change-password" task=user.saveNewPassword}}
</div>
</fieldset>
</form> {{! change password form }}

View File

@ -135,7 +135,7 @@ describe('Acceptance: Settings - General', function () {
expect(find('.fullscreen-modal .modal-content .gh-image-uploader').length, 'modal selector').to.equal(1);
});
click('.fullscreen-modal .modal-footer .js-button-accept');
click(testSelector('modal-accept-button'));
andThen(() => {
expect(find('.fullscreen-modal').length).to.equal(0);

View File

@ -13,21 +13,29 @@ describe('Integration: Component: gh-task-button', function() {
});
it('renders', function () {
this.render(hbs`{{#gh-task-button}}Test{{/gh-task-button}}`);
// sets button text using positional param
this.render(hbs`{{gh-task-button "Test"}}`);
expect(this.$('button')).to.exist;
expect(this.$('button')).to.contain('Test');
expect(this.$('button')).to.have.prop('disabled', false);
this.render(hbs`{{#gh-task-button class="testing"}}Test{{/gh-task-button}}`);
this.render(hbs`{{gh-task-button class="testing"}}`);
expect(this.$('button')).to.have.class('testing');
// default button text is "Save"
expect(this.$('button')).to.contain('Save');
this.render(hbs`{{#gh-task-button disabled=true}}Test{{/gh-task-button}}`);
// passes disabled attr
this.render(hbs`{{gh-task-button disabled=true buttonText="Test"}}`);
expect(this.$('button')).to.have.prop('disabled', true);
// allows button text to be set via hash param
expect(this.$('button')).to.contain('Test');
this.render(hbs`{{#gh-task-button type="submit"}}Test{{/gh-task-button}}`);
// passes type attr
this.render(hbs`{{gh-task-button type="submit"}}`);
expect(this.$('button')).to.have.attr('type', 'submit');
this.render(hbs`{{#gh-task-button tabindex="-1"}}Test{{/gh-task-button}}`);
// passes tabindex attr
this.render(hbs`{{gh-task-button tabindex="-1"}}`);
expect(this.$('button')).to.have.attr('tabindex', '-1');
});
@ -36,7 +44,7 @@ describe('Integration: Component: gh-task-button', function() {
yield timeout(50);
}));
this.render(hbs`{{#gh-task-button task=myTask}}Test{{/gh-task-button}}`);
this.render(hbs`{{gh-task-button task=myTask}}`);
this.get('myTask').perform();
@ -52,7 +60,7 @@ describe('Integration: Component: gh-task-button', function() {
yield timeout(50);
}));
this.render(hbs`{{#gh-task-button task=myTask}}Test{{/gh-task-button}}`);
this.render(hbs`{{gh-task-button task=myTask}}`);
expect(this.$('button'), 'initial class').to.not.have.class('appear-disabled');
this.get('myTask').perform();
@ -68,6 +76,64 @@ describe('Integration: Component: gh-task-button', function() {
wait().then(done);
});
it('shows success on success', function (done) {
this.set('myTask', task(function* () {
yield timeout(50);
return true;
}));
this.render(hbs`{{gh-task-button task=myTask}}`);
this.get('myTask').perform();
run.later(this, function () {
expect(this.$('button')).to.have.class('gh-btn-green');
expect(this.$('button')).to.contain('Saved');
}, 70);
wait().then(done);
});
it('shows failure when task errors', function (done) {
this.set('myTask', task(function* () {
try {
yield timeout(50);
throw new ReferenceError('test error');
} catch (error) {
// noop, prevent mocha triggering unhandled error assert
}
}));
this.render(hbs`{{gh-task-button task=myTask}}`);
this.get('myTask').perform();
run.later(this, function () {
expect(this.$('button')).to.have.class('gh-btn-red');
expect(this.$('button')).to.contain('Retry');
}, 70);
wait().then(done);
});
it('shows failure on falsy response', function (done) {
this.set('myTask', task(function* () {
yield timeout(50);
return false;
}));
this.render(hbs`{{gh-task-button task=myTask}}`);
this.get('myTask').perform();
run.later(this, function () {
expect(this.$('button')).to.have.class('gh-btn-red');
expect(this.$('button')).to.contain('Retry');
}, 70);
wait().then(done);
});
it('performs task on click', function (done) {
let taskCount = 0;
@ -76,7 +142,7 @@ describe('Integration: Component: gh-task-button', function() {
taskCount = taskCount + 1;
}));
this.render(hbs`{{#gh-task-button task=myTask}}Test{{/gh-task-button}}`);
this.render(hbs`{{gh-task-button task=myTask}}`);
this.$('button').click();
wait().then(() => {
@ -90,7 +156,7 @@ describe('Integration: Component: gh-task-button', function() {
yield timeout(50);
}));
this.render(hbs`{{#gh-task-button task=myTask}}Test{{/gh-task-button}}`);
this.render(hbs`{{gh-task-button task=myTask}}`);
let width = this.$('button').width();
let height = this.$('button').height();
expect(this.$('button')).to.not.have.attr('style');