mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-25 11:55:03 +03:00
✨ 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:
parent
c5b0301e87
commit
f1512d12c2
@ -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;
|
||||
|
@ -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: {
|
||||
|
@ -45,6 +45,10 @@ fieldset[disabled] .gh-btn {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* TODO: replace with svg icons */
|
||||
.gh-btn i {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Button highlights
|
||||
/* ---------------------------------------------------------- */
|
||||
|
@ -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>
|
||||
|
@ -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}}
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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}}
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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 }}
|
||||
|
@ -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);
|
||||
|
@ -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');
|
||||
|
Loading…
Reference in New Issue
Block a user