improve form buttons (#211)

* Adds a loading spinner to form buttons as long as submission is pending.
* Does some refactoring of form navigation buttons.
* Updates expected bundle size.
This commit is contained in:
jelhan 2019-06-12 09:07:48 +02:00 committed by GitHub
parent 32e5971aba
commit 7688d468e4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 225 additions and 212 deletions

View file

@ -1,96 +0,0 @@
import { inject as service } from '@ember/service';
import Component from '@ember/component';
import { computed } from '@ember/object';
import { translationMacro as t } from 'ember-i18n';
export default Component.extend({
actions: {
prev() {
this.onPrev();
}
},
/**
* If `true` next button is disabled.
*
* @property disableNextButton
* @type Boolean
* @default false
* @public
*/
disableNextButton: false,
/**
* If `true` prev button is disabled.
*
* @property disablePrevButton
* @type Boolean
* @default false
* @public
*/
disablePrevButton: false,
i18n: service(),
/**
* @property nextButtonClasses
* @type array
* @private
*/
nextButtonClasses: computed('renderPrevButton', function() {
let renderPrevButton = this.renderPrevButton;
if (renderPrevButton) {
return ['col-6', 'col-md-8'];
} else {
return ['col-md-8', 'offset-md-4'];
}
}),
/**
* @property nextButtonClassesString
* @type String
* @private
*/
nextButtonClassesString: computed('nextButtonClasses.[]', function() {
let nextButtonClasses = this.nextButtonClasses;
return nextButtonClasses.join(' ');
}),
/**
* @property nextButtonText
* @type String
* @default t('action.next')
* @public
*/
nextButtonText: t('action.next'),
/**
* @property prevButtonText
* @type String
* @default t('action.back')
* @public
*/
prevButtonText: t('action.back'),
/**
* If `true` a next button is rendered.
*
* @property renderNextButton
* @type Boolean
* @default true
* @public
*/
renderNextButton: true,
/**
* If `true` a prev button is rendered.
*
* @property renderPrevButton
* @type Boolean
* @default true
* @public
*/
renderPrevButton: true
});

View file

@ -0,0 +1,4 @@
import Component from '@ember/component';
export default Component.extend({
});

View file

@ -39,8 +39,11 @@ export default Controller.extend(Validations, {
this.transitionToRoute('create.options');
}
},
submit() {
if (this.validations.isValid) {
async submit() {
if (!this.validations.isValid) {
return;
}
let poll = this.model;
// set timezone if there is atleast one option with time
@ -54,24 +57,21 @@ export default Controller.extend(Validations, {
}
// save poll
poll.save()
.then(() => {
// reload as workaround for bug: duplicated records after save
poll.reload().then(() => {
// redirect to new poll
let { key: encryptionKey } = this.encryption;
try {
await poll.save();
this.transitionToRoute('poll', poll, {
// reload as workaround for bug: duplicated records after save
await poll.reload();
// redirect to new poll
await this.transitionToRoute('poll', poll, {
queryParams: {
encryptionKey,
encryptionKey: this.encryption.key,
},
});
});
})
.catch(() => {
// ToDo: Show feedback to user
return;
});
} catch(err) {
// TODO: show feedback to user
throw err;
}
},
updateAnswerType(answerType) {

View file

@ -57,7 +57,7 @@ const SelectionValidations = buildValidations({
export default Controller.extend(Validations, {
actions: {
submit() {
async submit() {
if (!this.get('validations.isValid')) {
return;
}
@ -94,7 +94,7 @@ export default Controller.extend(Validations, {
});
this.set('newUserRecord', user);
this.send('save');
await this.actions.save.bind(this)();
},
async save() {
let user = this.newUserRecord;

View file

@ -29,6 +29,7 @@
@import "ember-bootstrap/modal";
@import "ember-bootstrap/input-group";
@import "ember-bootstrap/custom-forms";
@import "ember-bootstrap/spinners";
// Overriding bootstrap selectors with properties we cannot influence by
// changing variables.

View file

@ -0,0 +1,10 @@
<BsButton
@disabled={{@disabled}}
@onClick={{@onClick}}
class="cr-steps-bottom-nav__button cr-steps-bottom-nav__prev-button prev"
>
<span class="cr-steps-bottom-nav__icon oi oi-caret-left" title={{t "action.back"}} aria-hidden="true"></span>
<span class="cr-steps-bottom-nav__label">
{{t "action.back"}}
</span>
</BsButton>

View file

@ -1 +0,0 @@
{{yield}}

View file

@ -88,8 +88,13 @@
{{/form.element}}
{{/if}}
{{form-navigation-buttons
onPrev=(action "previousPage")
}}
<div class="row cr-steps-bottom-nav">
<div class="col-6 col-md-4 text-right">
<BackButton @onClick={{action "previousPage"}} />
</div>
<div class="col-6 col-md-8">
<NextButton />
</div>
</div>
{{/bs-form}}
</div>

View file

@ -21,8 +21,13 @@
}}
{{/if}}
{{form-navigation-buttons
onPrev=(action "previousPage")
}}
<div class="row cr-steps-bottom-nav">
<div class="col-6 col-md-4 text-right">
<BackButton @onClick={{action "previousPage"}} />
</div>
<div class="col-6 col-md-8">
<NextButton />
</div>
</div>
{{/bs-form}}
</div>

View file

@ -1,31 +0,0 @@
<div class="row cr-steps-bottom-nav">
{{#if renderPrevButton}}
<div class="col-6 col-md-4 text-right">
{{#bs-button
onClick=(action "prev")
classNames="cr-steps-bottom-nav__button cr-steps-bottom-nav__prev-button prev"
disabled=disablePrevButton
}}
<span class="cr-steps-bottom-nav__icon oi oi-caret-left" title={{ prevButtonText }} aria-hidden="true"></span>
<span class="cr-steps-bottom-nav__label">
{{ prevButtonText }}
</span>
{{/bs-button}}
</div>
{{/if}}
{{#if renderNextButton}}
<div class={{nextButtonClassesString}}>
{{#bs-button
buttonType="submit"
classNames="cr-steps-bottom-nav__button cr-steps-bottom-nav__next-button next"
disabled=disableNextButton
type="primary"
}}
<span class="cr-steps-bottom-nav__label">
{{ nextButtonText }}
</span>
<span class="cr-steps-bottom-nav__icon oi oi-caret-right" title={{ nextButtonText }} aria-hidden="true"></span>
{{/bs-button}}
</div>
{{/if}}
</div>

View file

@ -0,0 +1 @@
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>

View file

@ -0,0 +1,21 @@
<BsButton
@buttonType={{if @buttonType @buttonType "submit"}}
@type="primary"
class="cr-steps-bottom-nav__button cr-steps-bottom-nav__next-button next"
disabled={{@disabled}}
...attributes
>
<span class="cr-steps-bottom-nav__label">
{{#if (has-block)}}
{{yield}}
{{else}}
{{t "action.next"}}
{{/if}}
</span>
{{#if @isPending}}
<LoadingSpinner />
{{else}}
<span class="cr-steps-bottom-nav__icon oi oi-caret-right"></span>
{{/if}}
</BsButton>

View file

@ -28,8 +28,14 @@
</option>
{{/autofocusable-element}}
{{/form.element}}
{{form-navigation-buttons
disablePrevButton=true
}}
<div class="row cr-steps-bottom-nav">
<div class="col-6 col-md-4 text-right">
<BackButton @disabled={{true}} />
</div>
<div class="col-6 col-md-8">
<NextButton />
</div>
</div>
{{/bs-form}}
</div>

View file

@ -21,8 +21,14 @@
placeholder=(t "create.meta.input.description.placeholder")
property="description"
}}
{{form-navigation-buttons
onPrev=(action "previousPage")
}}
<div class="row cr-steps-bottom-nav">
<div class="col-6 col-md-4 text-right">
<BackButton @onClick={{action "previousPage"}} />
</div>
<div class="col-6 col-md-8">
<NextButton />
</div>
</div>
{{/bs-form}}
</div>

View file

@ -62,9 +62,16 @@
showValidationOn="change"
property="forceAnswer"
}}
{{form-navigation-buttons
nextButtonText=(t "action.save")
onPrev=(action "previousPage")
}}
<div class="row cr-steps-bottom-nav">
<div class="col-6 col-md-4 text-right">
<BackButton @onClick={{action "previousPage"}} />
</div>
<div class="col-6 col-md-8">
<NextButton @isPending={{form.isSubmitting}} data-test-button="submit">
{{t "action.save"}}
</NextButton>
</div>
</div>
{{/bs-form}}
</div>

View file

@ -69,9 +69,13 @@
{{/each}}
</div>
{{form-navigation-buttons
renderPrevButton=false
}}
<div class="row cr-steps-bottom-nav">
<div class="col-md-8 offset-md-4">
<NextButton @isPending={{form.isSubmitting}} data-test-button="submit">
{{t "action.save"}}
</NextButton>
</div>
</div>
{{/bs-form}}
</div>

View file

@ -4,12 +4,12 @@ module.exports = {
app: {
javascript: {
pattern: 'assets/*.js',
limit: '375KB',
limit: '376KB',
compression: 'gzip'
},
css: {
pattern: 'assets/*.css',
limit: '22KB',
limit: '15KB',
compression: 'gzip'
}
}

View file

@ -1,3 +1,4 @@
{
"jquery-integration": false
"jquery-integration": false,
"template-only-glimmer-components": true
}

View file

@ -1,4 +1,4 @@
import { currentURL, currentRouteName, findAll } from '@ember/test-helpers';
import { currentURL, currentRouteName, findAll, settled, waitFor } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
@ -121,16 +121,36 @@ module('Acceptance | create a poll', function(hooks) {
'available answers selection has autofocus'
);
// simulate temporate this.server error
// simulate temporary server error
this.server.post('/polls', undefined, 503);
await pageCreateSettings.save();
assert.equal(currentRouteName(), 'create.settings');
// simulate this.server is available again
this.server.post('/polls');
// simulate server is available again
// defer creation for testing loading spinner
let resolveSubmission;
let resolveSubmissionWith;
this.server.post('/polls', function(schema) {
return new Promise((resolve) => {
let attrs = this.normalizedRequestAttrs();
resolveSubmission = resolve;
resolveSubmissionWith = schema.polls.create(attrs);
});
});
pageCreateSettings.save();
// shows loading spinner while saving
await waitFor('[data-test-button="submit"] .spinner-border', {
timeoutMessage: 'timeout while waiting for loading spinner to appear',
});
assert.ok(true, 'loading spinner is shown');
resolveSubmission(resolveSubmissionWith);
await settled();
await pageCreateSettings.save();
assert.equal(currentRouteName(), 'poll.participation');
assert.ok(
pagePollParticipation.urlIsValid() === true,

View file

@ -4,6 +4,7 @@ import {
findAll,
currentURL,
currentRouteName,
waitFor,
visit
} from '@ember/test-helpers';
import { module, test } from 'qunit';
@ -171,4 +172,34 @@ module('Acceptance | participate in a poll', function(hooks) {
'participants table shows correct answers for new participant'
);
});
test('shows loading spinner while submitting', async function(assert) {
let encryptionKey = 'abcdefghijklmnopqrstuvwxyz0123456789';
let poll = this.server.create('poll', {
encryptionKey
});
let resolveSubmission;
let resolveSubmissionWith;
this.server.post('/users', function(schema) {
return new Promise((resolve) => {
let attrs = this.normalizedRequestAttrs();
resolveSubmission = resolve;
resolveSubmissionWith = schema.users.create(attrs);
});
});
await visit(`/poll/${poll.id}/participation?encryptionKey=${encryptionKey}`);
pollParticipate('John Doe', ['yes', 'no']);
await waitFor('[data-test-button="submit"] .spinner-border', {
timeoutMessage: 'timeout while waiting for loading spinner to appear',
});
assert.ok(true, 'loading spinner shown cause otherwise there would have been a timeout');
// resolve promise for test to finish
// need to resolve with a valid response cause otherwise Ember Data would throw
resolveSubmission(resolveSubmissionWith);
});
});

View file

@ -0,0 +1,14 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
module('Integration | Component | back-button', function(hooks) {
setupRenderingTest(hooks);
test('it renders a button', async function(assert) {
await render(hbs`<BackButton />`);
assert.dom('button').exists();
});
});

View file

@ -1,25 +0,0 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, findAll, find } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
module('Integration | Component | form navigation buttons', function(hooks) {
setupRenderingTest(hooks);
test('it renders two buttons as default', async function(assert) {
await render(hbs`{{form-navigation-buttons}}`);
assert.equal(findAll('button').length, 2);
});
test('buttons could be disabled', async function(assert) {
await render(hbs`{{form-navigation-buttons disableNextButton=true disablePrevButton=true}}`);
assert.equal(find('button.next').disabled, true, 'next button is disabled');
assert.equal(find('button.prev').disabled, true, 'prev button is disabled');
});
test('could prevent rendering of prev button', async function(assert) {
await render(hbs`{{form-navigation-buttons renderPrevButton=false}}`);
assert.ok(findAll('button.prev').length === 0, 'prev button is not rendered');
assert.ok(findAll('button.next').length === 1, 'next button is rendered');
});
});

View file

@ -0,0 +1,30 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
module('Integration | Component | next-button', function(hooks) {
setupRenderingTest(hooks);
test('it renders a button', async function(assert) {
await render(hbs`<NextButton />`);
assert.dom('button').exists();
});
test('it supports block mode', async function(assert) {
await render(hbs`
<NextButton>
some text
</NextButton>
`);
assert.dom('button').hasText('some text');
});
test('it renders a loading spinner if `@isPending` is `true`', async function(assert) {
await render(hbs`<NextButton @isPending={{true}} />`);
assert.dom('button .spinner-border').exists();
});
});