Skip to content

Commit

Permalink
✨ success/failure state spinner buttons
Browse files Browse the repository at this point in the history
refs TryGhost/Ghost#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
  • Loading branch information
kevinansfield committed Mar 7, 2017
1 parent cd916ed commit e8b83b4
Show file tree
Hide file tree
Showing 20 changed files with 150 additions and 42 deletions.
38 changes: 35 additions & 3 deletions app/components/gh-task-button.js
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -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')) {
Expand Down Expand Up @@ -56,3 +82,9 @@ export default Component.extend({
}
})
});

GhTaskButton.reopenClass({
positionalParams: ['buttonText']
});

export default GhTaskButton;
10 changes: 5 additions & 5 deletions app/components/modals/new-subscriber.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,9 +23,10 @@ export default ModalComponent.extend({
}
}

// this is a route action so it should bubble up to the global
// error handler
throw error;
// route action so it should bubble up to the global error handler
if (error) {
throw error;
}
}
}).drop(),

Expand Down
4 changes: 4 additions & 0 deletions app/styles/patterns/buttons.css
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ fieldset[disabled] .gh-btn {
pointer-events: none;
}

/* TODO: replace with svg icons */
.gh-btn i {
display: inline-block;
}

/* Button highlights
/* ---------------------------------------------------------- */
Expand Down
2 changes: 1 addition & 1 deletion app/templates/components/gh-modal-dialog.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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 js-button-reject" {{action "confirm" "reject"}}>{{confirm.reject.text}}</button><button type="button" class="{{acceptButtonClass}}" {{action "confirm" "accept"}} data-test-modal-accept-button>{{confirm.accept.text}}</button>
</footer>
{{/if}}
</section>
Expand Down
22 changes: 14 additions & 8 deletions app/templates/components/gh-task-button.hbs
Original file line number Diff line number Diff line change
@@ -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}}
{{/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}}
2 changes: 1 addition & 1 deletion app/templates/components/modals/delete-all.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
2 changes: 1 addition & 1 deletion app/templates/components/modals/delete-post.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
2 changes: 1 addition & 1 deletion app/templates/components/modals/delete-subscriber.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
2 changes: 1 addition & 1 deletion app/templates/components/modals/delete-tag.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
2 changes: 1 addition & 1 deletion app/templates/components/modals/delete-theme.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
2 changes: 1 addition & 1 deletion app/templates/components/modals/delete-user.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
2 changes: 1 addition & 1 deletion app/templates/components/modals/invite-new-user.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
2 changes: 1 addition & 1 deletion app/templates/components/modals/new-subscriber.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
4 changes: 2 additions & 2 deletions app/templates/components/modals/re-authenticate.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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}}

Expand Down
2 changes: 1 addition & 1 deletion app/templates/components/modals/transfer-owner.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
2 changes: 1 addition & 1 deletion app/templates/components/modals/upload-image.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}
</div>
2 changes: 1 addition & 1 deletion app/templates/reset.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
4 changes: 2 additions & 2 deletions app/templates/team/user.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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>

Expand Down Expand Up @@ -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 }}
Expand Down
2 changes: 1 addition & 1 deletion tests/acceptance/settings/general-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
84 changes: 75 additions & 9 deletions tests/integration/components/gh-task-button-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand All @@ -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();

Expand All @@ -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();
Expand All @@ -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;

Expand All @@ -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(() => {
Expand All @@ -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');
Expand Down

0 comments on commit e8b83b4

Please sign in to comment.