Skip to content

Commit af8046c

Browse files
committed
fix(field): format conditionally on user input only
1 parent 187d50b commit af8046c

File tree

3 files changed

+82
-34
lines changed

3 files changed

+82
-34
lines changed

packages/field/src/FormatMixin.js

Lines changed: 51 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,16 @@ import { ObserverMixin } from '@lion/core/src/ObserverMixin.js';
66
import { Unparseable } from '@lion/validate';
77

88
/**
9-
* @polymerMixin
9+
* @desc Designed to be applied on top of a LionField
10+
*
11+
* FormatMixin supports these two main flows:
12+
* 1) Application Developer sets `.modelValue`:
13+
* Flow: `.modelValue` -> `.formattedValue` -> `.inputElement.value`
14+
* -> `.serializedValue`
15+
* 2) End user interacts with field:
16+
* Flow: `@user-input-changed` -> `.modelValue` -> `.formattedValue` - (debounce till reflect condition (formatOn) is met) -> `.inputElement.value`
17+
* -> `.serializedValue`
18+
*
1019
* @mixinFunction
1120
*/
1221
export const FormatMixin = dedupeMixin(
@@ -26,7 +35,8 @@ export const FormatMixin = dedupeMixin(
2635
*
2736
* Examples:
2837
* - For a date input: a String '20/01/1999' will be converted to new Date('1999/01/20')
29-
* - For a number input: a formatted String '1.234,56' will be converted to a Number: 1234.56
38+
* - For a number input: a formatted String '1.234,56' will be converted to a Number:
39+
* 1234.56
3040
*/
3141
modelValue: {
3242
type: Object,
@@ -48,13 +58,13 @@ export const FormatMixin = dedupeMixin(
4858
/**
4959
* The serialized version of the model value.
5060
* This value exists for maximal compatibility with the platform API.
51-
* The serialized value can be an interface in context where data binding is not supported
52-
* and a serialized string needs to be set.
61+
* The serialized value can be an interface in context where data binding is not
62+
* supported and a serialized string needs to be set.
5363
*
5464
* Examples:
5565
* - For a date input, this would be the iso format of a date, e.g. '1999-01-20'.
56-
* - For a number input this would be the String representation of a float ('1234.56' instead
57-
* of 1234.56)
66+
* - For a number input this would be the String representation of a float ('1234.56'
67+
* instead of 1234.56)
5868
*
5969
* When no parser is available, the value is usually the same as the formattedValue
6070
* (being inputElement.value)
@@ -139,15 +149,16 @@ export const FormatMixin = dedupeMixin(
139149
}
140150

141151
/**
142-
* Responsible for storing all representations(modelValue, serializedValue, formattedValue and
143-
* value) of the input value.
144-
* Prevents infinite loops, so all value observers can be treated like they will only be called
145-
* once, without indirectly calling other observers.
146-
* (in fact, some are called twice, but the __preventRecursiveTrigger lock prevents the second
147-
* call from having effect).
152+
* Responsible for storing all representations(modelValue, serializedValue, formattedValue
153+
* and value) of the input value.
154+
* Prevents infinite loops, so all value observers can be treated like they will only be
155+
* called once, without indirectly calling other observers.
156+
* (in fact, some are called twice, but the __preventRecursiveTrigger lock prevents the
157+
* second call from having effect).
148158
*
149-
* @param {string} source - the type of value that triggered this method. It should not be set
150-
* again, so that its observer won't be triggered. Can be: 'model'|'formatted'|'serialized'.
159+
* @param {string} source - the type of value that triggered this method. It should not be
160+
* set again, so that its observer won't be triggered. Can be:
161+
* 'model'|'formatted'|'serialized'.
151162
*/
152163
_calculateValues({ source } = {}) {
153164
if (this.__preventRecursiveTrigger) return; // prevent infinite loops
@@ -182,7 +193,19 @@ export const FormatMixin = dedupeMixin(
182193
if (this.modelValue instanceof Unparseable) {
183194
return this.modelValue.viewValue;
184195
}
185-
if (this.errorState) {
196+
197+
// - Why check for this.errorState?
198+
// We only want to format values that are considered valid. For best UX,
199+
// we only 'reward' valid inputs.
200+
// - Why check for __isHandlingUserInput?
201+
// Downwards sync is prevented whenever we are in a `@user-input-changed` flow.
202+
// If we are in a 'imperatively set `.modelValue`' flow, we want to reflect back
203+
// the value, no matter what.
204+
// This means, whenever we are in errorState, we and modelValue is set
205+
// imperatively, we DO want to format a value (it is the only way to get meaningful
206+
// input into `.inputElement` with modelValue as input)
207+
208+
if (this.__isHandlingUserInput && this.errorState) {
186209
return this.inputElement ? this.value : undefined;
187210
}
188211
return this.formatter(this.modelValue, this.formatOptions);
@@ -231,21 +254,19 @@ export const FormatMixin = dedupeMixin(
231254
*/
232255
_syncValueUpwards() {
233256
// Downwards syncing should only happen for <lion-field>.value changes from 'above'
234-
this.__preventDownwardsSync = true;
235257
// This triggers _onModelValueChanged and connects user input to the
236258
// parsing/formatting/serializing loop
237259
this.modelValue = this.__callParser(this.value);
238-
this.__preventDownwardsSync = false;
239260
}
240261

241262
/**
242263
* Synchronization from <lion-field>.value to <input>.value
243264
*/
244265
_reflectBackFormattedValueToUser() {
245266
// Downwards syncing 'back and forth' prevents change event from being fired in IE.
246-
// So only sync when the source of new <lion-field>.value change was not the 'input' event of
247-
// inputElement
248-
if (!this.__preventDownwardsSync) {
267+
// So only sync when the source of new <lion-field>.value change was not the 'input' event
268+
// of inputElement
269+
if (!this.__isHandlingUserInput) {
249270
// Text 'undefined' should not end up in <input>
250271
this.value = typeof this.formattedValue !== 'undefined' ? this.formattedValue : '';
251272
}
@@ -266,9 +287,12 @@ export const FormatMixin = dedupeMixin(
266287
}
267288

268289
_onUserInputChanged() {
269-
// Upwards syncing. Most properties are delegated right away, value is synced to <lion-field>,
270-
// to be able to act on (imperatively set) value changes
290+
// Upwards syncing. Most properties are delegated right away, value is synced to
291+
// <lion-field>, to be able to act on (imperatively set) value changes
292+
293+
this.__isHandlingUserInput = true;
271294
this._syncValueUpwards();
295+
this.__isHandlingUserInput = false;
272296
}
273297

274298
constructor() {
@@ -289,11 +313,11 @@ export const FormatMixin = dedupeMixin(
289313
this.inputElement.addEventListener(this.formatOn, this._reflectBackFormattedValueDebounced);
290314
this.inputElement.addEventListener('input', this._proxyInputEvent);
291315
this.addEventListener('user-input-changed', this._onUserInputChanged);
292-
// Connect the value found in <input> to the formatting/parsing/serializing loop as a fallback
293-
// mechanism. Assume the user uses the value property of the <lion-field>(recommended api) as
294-
// the api (this is a downwards sync).
295-
// However, when no value is specified on <lion-field>, have support for sync of the real input
296-
// to the <lion-field> (upwards sync).
316+
// Connect the value found in <input> to the formatting/parsing/serializing loop as a
317+
// fallback mechanism. Assume the user uses the value property of the
318+
// <lion-field>(recommended api) as the api (this is a downwards sync).
319+
// However, when no value is specified on <lion-field>, have support for sync of the real
320+
// input to the <lion-field> (upwards sync).
297321
if (typeof this.modelValue === 'undefined') {
298322
this._syncValueUpwards();
299323
}

packages/field/test/FormatMixin.test.js

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,25 @@ describe('FormatMixin', () => {
157157
expect(formatEl.inputElement.value).to.equal('foo: test');
158158
});
159159

160+
it('reflects back .formattedValue immediately when .modelValue changed imperatively', async () => {
161+
const el = await fixture(html`
162+
<${elem} .formatter="${value => `foo: ${value}`}">
163+
<input slot="input" />
164+
</${elem}>
165+
`);
166+
// The FormatMixin can be used in conjunction with the ValidateMixin, in which case
167+
// it can hold errorState (affecting the formatting)
168+
el.errorState = true;
169+
170+
// users types value 'test'
171+
mimicUserInput(el, 'test');
172+
expect(el.inputElement.value).to.not.equal('foo: test');
173+
174+
// Now see the difference for an imperative change
175+
el.modelValue = 'test2';
176+
expect(el.inputElement.value).to.equal('foo: test2');
177+
});
178+
160179
describe('parsers/formatters/serializers', () => {
161180
it('should call the parser|formatter|serializer provided by user', async () => {
162181
const formatterSpy = sinon.spy(value => `foo: ${value}`);
@@ -206,7 +225,7 @@ describe('FormatMixin', () => {
206225
expect(parserSpy.callCount).to.equal(1);
207226
});
208227

209-
it('will only call the formatter for valid values', async () => {
228+
it('will only call the formatter for valid values on `user-input-changed` ', async () => {
210229
const formatterSpy = sinon.spy(value => `foo: ${value}`);
211230
const el = await fixture(html`
212231
<${elem} .formatter=${formatterSpy}>
@@ -216,12 +235,12 @@ describe('FormatMixin', () => {
216235
expect(formatterSpy.callCount).to.equal(1);
217236

218237
el.errorState = true;
219-
el.modelValue = 'bar';
238+
mimicUserInput(el, 'bar');
220239
expect(formatterSpy.callCount).to.equal(1);
221-
expect(el.formattedValue).to.equal('foo: init-string');
240+
expect(el.formattedValue).to.equal('bar');
222241

223242
el.errorState = false;
224-
el.modelValue = 'bar2';
243+
mimicUserInput(el, 'bar2');
225244
expect(formatterSpy.callCount).to.equal(2);
226245

227246
expect(el.formattedValue).to.equal('foo: bar2');

packages/field/test/lion-field.test.js

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ const tag = unsafeStatic(tagString);
1919
const inputSlotString = '<input slot="input" />';
2020
const inputSlot = unsafeHTML(inputSlotString);
2121

22+
function mimicUserInput(formControl, newViewValue) {
23+
formControl.value = newViewValue; // eslint-disable-line no-param-reassign
24+
formControl.inputElement.dispatchEvent(new CustomEvent('input', { bubbles: true }));
25+
}
26+
2227
beforeEach(() => {
2328
localizeTearDown();
2429
});
@@ -328,7 +333,7 @@ describe('<lion-field>', () => {
328333
expect(lionField.error.required).to.be.undefined;
329334
});
330335

331-
it('will only update formattedValue the value when valid', async () => {
336+
it('will only update formattedValue when valid on `user-input-changed`', async () => {
332337
const formatterSpy = sinon.spy(value => `foo: ${value}`);
333338
function isBarValidator(value) {
334339
return { isBar: value === 'bar' };
@@ -348,9 +353,9 @@ describe('<lion-field>', () => {
348353
expect(formatterSpy.callCount).to.equal(1);
349354
expect(lionField.formattedValue).to.equal('foo: bar');
350355

351-
lionField.modelValue = 'foo';
356+
mimicUserInput(lionField, 'foo');
352357
expect(formatterSpy.callCount).to.equal(1);
353-
expect(lionField.formattedValue).to.equal('foo: bar');
358+
expect(lionField.formattedValue).to.equal('foo');
354359
});
355360
});
356361

0 commit comments

Comments
 (0)