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:
parent
32e5971aba
commit
7688d468e4
23 changed files with 225 additions and 212 deletions
|
@ -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
|
||||
});
|
4
app/components/poll-evaluation-summary-option.js
Normal file
4
app/components/poll-evaluation-summary-option.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
import Component from '@ember/component';
|
||||
|
||||
export default Component.extend({
|
||||
});
|
|
@ -39,39 +39,39 @@ export default Controller.extend(Validations, {
|
|||
this.transitionToRoute('create.options');
|
||||
}
|
||||
},
|
||||
submit() {
|
||||
if (this.validations.isValid) {
|
||||
let poll = this.model;
|
||||
async submit() {
|
||||
if (!this.validations.isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// set timezone if there is atleast one option with time
|
||||
if (
|
||||
poll.isFindADate &&
|
||||
poll.options.any(({ title }) => {
|
||||
return !moment(title, 'YYYY-MM-DD', true).isValid();
|
||||
})
|
||||
) {
|
||||
this.set('model.timezone', moment.tz.guess());
|
||||
}
|
||||
let poll = this.model;
|
||||
|
||||
// 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;
|
||||
// set timezone if there is atleast one option with time
|
||||
if (
|
||||
poll.isFindADate &&
|
||||
poll.options.any(({ title }) => {
|
||||
return !moment(title, 'YYYY-MM-DD', true).isValid();
|
||||
})
|
||||
) {
|
||||
this.set('model.timezone', moment.tz.guess());
|
||||
}
|
||||
|
||||
this.transitionToRoute('poll', poll, {
|
||||
queryParams: {
|
||||
encryptionKey,
|
||||
},
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// ToDo: Show feedback to user
|
||||
return;
|
||||
});
|
||||
// save poll
|
||||
try {
|
||||
await poll.save();
|
||||
|
||||
// reload as workaround for bug: duplicated records after save
|
||||
await poll.reload();
|
||||
|
||||
// redirect to new poll
|
||||
await this.transitionToRoute('poll', poll, {
|
||||
queryParams: {
|
||||
encryptionKey: this.encryption.key,
|
||||
},
|
||||
});
|
||||
} catch(err) {
|
||||
// TODO: show feedback to user
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
updateAnswerType(answerType) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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.
|
||||
|
|
10
app/templates/components/back-button.hbs
Normal file
10
app/templates/components/back-button.hbs
Normal 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>
|
|
@ -1 +0,0 @@
|
|||
{{yield}}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
1
app/templates/components/loading-spinner.hbs
Normal file
1
app/templates/components/loading-spinner.hbs
Normal file
|
@ -0,0 +1 @@
|
|||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
21
app/templates/components/next-button.hbs
Normal file
21
app/templates/components/next-button.hbs
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
{
|
||||
"jquery-integration": false
|
||||
"jquery-integration": false,
|
||||
"template-only-glimmer-components": true
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
14
tests/integration/components/back-button-test.js
Normal file
14
tests/integration/components/back-button-test.js
Normal 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();
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
});
|
30
tests/integration/components/next-button-test.js
Normal file
30
tests/integration/components/next-button-test.js
Normal 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();
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue