Skip to content

Commit

Permalink
Merge pull request #415 from zeppelin/resilience
Browse files Browse the repository at this point in the history
Add resilience for when scripts fail to load
  • Loading branch information
hakilebara authored Feb 10, 2022
2 parents 8f2f4c5 + 77b9977 commit b99aed4
Show file tree
Hide file tree
Showing 3 changed files with 217 additions and 12 deletions.
46 changes: 36 additions & 10 deletions addon/components/phone-input.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,13 @@ import { isPresent } from '@ember/utils';
export default Component.extend({
tagName: 'input',

attributeBindings: ['type', 'disabled', 'required', 'autocomplete'],
attributeBindings: [
'type',
'disabled',
'required',
'autocomplete',
'isLoadingIti:data-test-loading-iti'
],
type: 'tel',

phoneInput: service(),
Expand Down Expand Up @@ -160,7 +166,8 @@ export default Component.extend({
},

input() {
const internationalPhoneNumber = this._iti.getNumber();
const internationalPhoneNumber =
this._iti?.getNumber() ?? this.element.value;

var meta = this._metaData(this._iti);
this.update(internationalPhoneNumber, meta);
Expand All @@ -182,23 +189,37 @@ export default Component.extend({
},

willDestroyElement() {
this._iti.destroy();
this._iti?.destroy();
this.element.removeEventListener('countrychange', this._onCountryChange);

this._super(...arguments);
},

async _loadAndSetup() {
await this.phoneInput.load();
try {
this.set('isLoadingIti', true);

this._setupLibrary();
await this.phoneInput.load();

this._formatNumber();
// Even if the above promise resolves, it might be at the end of the
// component lifecycle
if (this.isDestroying || this.isDestroyed) {
return;
}

this.element.addEventListener(
'countrychange',
this._onCountryChange.bind(this)
);
this._setupLibrary();

this._formatNumber();

this.element.addEventListener(
'countrychange',
this._onCountryChange.bind(this)
);
} finally {
if (!this.isDestroying && !this.isDestroyed) {
this.set('isLoadingIti', false);
}
}
},

_setupLibrary() {
Expand Down Expand Up @@ -259,6 +280,11 @@ export default Component.extend({
},

_metaData(iti) {
if (!iti) {
// Libraries may rely on always receiving an object
return {};
}

const extension = iti.getExtension();
const selectedCountryData = iti.getSelectedCountryData();
const isValidNumber = iti.isValidNumber();
Expand Down
7 changes: 7 additions & 0 deletions tests/dummy/app/templates/docs/action-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@

Data changes that occur in the component are not propagated to the outside using two-way bindings, but rather they are communicated via the update action.

Note that `ember-phone-input` can function without the `intl-tel-input` &
`libphonenumber` scripts being loaded, in case if they load slowly or fail
completely, but the user began interacting with the component. In both cases the
`<input>` field's value as `number` and an empty `metaData` object is sent as
`update` action arguments. If the scripts are loaded later, the component will
proceed to initializing the scripts, keeping the value already entered.

## `update` action

{{docs/components/phone-input/action-handling}}
176 changes: 174 additions & 2 deletions tests/integration/components/phone-input-test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { module, test } from 'qunit';
import QUnit, { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { fillIn, render, find, typeIn } from '@ember/test-helpers';
import { fillIn, render, find, typeIn, waitUntil } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';

module('Integration | Component | phone-input', function (hooks) {
Expand Down Expand Up @@ -179,4 +179,176 @@ module('Integration | Component | phone-input', function (hooks) {

assert.dom('.iti__flag').hasClass('iti__my');
});

module('resilience', function (hooks) {
let originalOnUncaughtException;

hooks.before(function () {
originalOnUncaughtException = QUnit.onUncaughtException;
QUnit.onUncaughtException = () => {};
});

hooks.after(function () {
QUnit.onUncaughtException = originalOnUncaughtException;
});

test('intl-tel-input is loaded after user interaction', async function (assert) {
let service = this.owner.lookup('service:phone-input');
let load = service.load;
let resolveLoading;

service.load = () => new Promise((resolve) => (resolveLoading = resolve));

this.number = null;
this.metaData = null;

this.set('update', (value, metaData) => {
this.set('number', value);
this.set('metaData', metaData);
});

await render(
hbs`<PhoneInput @number={{this.number}} @update={{this.update}} />`
);

assert.dom('input').doesNotHaveAttribute('data-intl-tel-input-id');

assert.strictEqual(
this.number,
null,
'number is null when rendered but intl-tel-input is not loaded yet'
);
assert.strictEqual(
this.metaData,
null,
'metaData is null when rendered but intl-tel-input is not loaded yet'
);

await fillIn('input', '9');

assert.strictEqual(
this.number,
'9',
'number is correct after input without intl-tel-input'
);
assert.deepEqual(
this.metaData,
{},
'metaData is an empty object after input without intl-tel-input'
);

resolveLoading(load.call(service));

await waitUntil(() => find('input:not([data-test-loading-iti])'));

assert.dom('input').hasAttribute('data-intl-tel-input-id');

assert.strictEqual(
this.number,
'9',
'number is correct after intl-tel-input is loaded'
);

assert.deepEqual(
this.metaData,
{
extension: '',
isValidNumber: false,
numberFormat: null,
selectedCountryData: {}
},
'metaData is correct after intl-tel-input is loaded'
);

await fillIn('input', '8');

assert.strictEqual(
this.number,
'8',
'number is correct after input when the intl-tel-input is loaded'
);

assert.deepEqual(
this.metaData,
{
extension: '',
isValidNumber: false,
numberFormat: null,
selectedCountryData: {}
},
'metaData is correct after input when the intl-tel-input is loaded'
);
});

test('intl-tel-input fails to load', async function (assert) {
let tmp = QUnit.onUncaughtException;
QUnit.onUncaughtException = () => {};

let service = this.owner.lookup('service:phone-input');
let rejectLoading;

service.load = () =>
new Promise((_resolve, reject) => (rejectLoading = reject));

this.number = null;
this.metaData = null;

this.set('update', (value, metaData) => {
this.set('number', value);
this.set('metaData', metaData);
});

await render(
hbs`<PhoneInput @number={{this.number}} @update={{this.update}} />`
);

assert.dom('input').doesNotHaveAttribute('data-intl-tel-input-id');

assert.strictEqual(
this.number,
null,
'number is null when rendered but intl-tel-input is not loaded yet'
);
assert.strictEqual(
this.metaData,
null,
'metaData is null when rendered but intl-tel-input is not loaded yet'
);

await fillIn('input', '9');

assert.strictEqual(
this.number,
'9',
'number is correct after input without intl-tel-input'
);
assert.deepEqual(
this.metaData,
{},
'metaData is an empty object after input without intl-tel-input'
);

rejectLoading();

await waitUntil(() => find('input:not([data-test-loading-iti])'));

assert.dom('input').doesNotHaveAttribute('data-intl-tel-input-id');

await fillIn('input', '8');

assert.strictEqual(
this.number,
'8',
'number is correct when intl-tel-input is loaded'
);

assert.deepEqual(
this.metaData,
{},
'metaData is correct when intl-tel-input is loaded'
);

QUnit.onUncaughtException = tmp;
});
});
});

0 comments on commit b99aed4

Please sign in to comment.