Skip to content

Commit af084ba

Browse files
committed
doc'ing how to set values via JS, auto-set "value" of model elements, sync empty select fields
2 features/bugs mixed together: 1) Auto-setting "value" of data-model elements from component data 2) Fixing bug where select element without a blank doesn't sync value back to component Fixing a few model-related rendering bugs A) Only re-set values after re-render for models that have fully changed, not just fields that have been modified (but show value may not be synced back to the value store yet) B) When setting the value property on an input, skip any models that we know are in an unsynced state (i.e. we know they have a field that has changed, but the value hasn't been set onto the value store yet)
1 parent e71e305 commit af084ba

File tree

9 files changed

+693
-256
lines changed

9 files changed

+693
-256
lines changed

src/LiveComponent/CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,23 @@
99
it finishes. Then, all queued changes (potentially multiple model updates
1010
or actions) will be sent all at once on the next request.
1111

12+
- [BEHAVIOR CHANGE] Fields with `data-model` will now have their `value` set
13+
automatically when the component initially loads and re-renders. For example,
14+
previously you needed to manually set the value in your component template:
15+
16+
```twig
17+
<!-- BEFORE -->
18+
<input data-model="firstName" value="{{ firstName }}">
19+
```
20+
21+
This is no longer necessary: Live Components will now set the value on load,
22+
which allows you to simply have the following in your template:
23+
24+
```twig
25+
<!-- AFTER -->
26+
<input data-model="firstName">
27+
```
28+
1229
## 2.4.0
1330
1431
- [BC BREAK] Previously, the `id` attribute was used with `morphdom` as the

src/LiveComponent/assets/dist/live_controller.js

Lines changed: 199 additions & 171 deletions
Large diffs are not rendered by default.

src/LiveComponent/assets/src/UnsyncedInputContainer.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
/**
2+
* Tracks field & models whose values are "unsynced".
3+
*
4+
* Unsynced means that the value has been updated inside of
5+
* a field (e.g. an input), but that this new value hasn't
6+
* yet been set onto the actual model data. It is "unsynced"
7+
* from the underlying model data.
8+
*/
19
export default class UnsyncedInputContainer {
210
#mappedFields: Map<string, HTMLElement>;
311
#unmappedFields: Array<HTMLElement> = [];
@@ -20,11 +28,11 @@ export default class UnsyncedInputContainer {
2028
return [...this.#unmappedFields, ...this.#mappedFields.values()]
2129
}
2230

23-
allMappedFields(): Map<string, HTMLElement> {
24-
return this.#mappedFields;
31+
markModelAsSynced(modelName: string): void {
32+
this.#mappedFields.delete(modelName);
2533
}
2634

27-
remove(modelName: string) {
28-
this.#mappedFields.delete(modelName);
35+
getModifiedModels(): string[] {
36+
return Array.from(this.#mappedFields.keys());
2937
}
3038
}

src/LiveComponent/assets/src/dom_utils.ts

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ import { normalizeModelName } from './string_utils';
1111
* elements. In those cases, it will return the "full", final value
1212
* for the model, which includes previously-selected values.
1313
*/
14-
export function getValueFromInput(element: HTMLElement, valueStore: ValueStore): string|string[]|null {
14+
export function getValueFromElement(element: HTMLElement, valueStore: ValueStore): string|string[]|null {
1515
if (element instanceof HTMLInputElement) {
1616
if (element.type === 'checkbox') {
17-
const modelNameData = getModelDirectiveFromInput(element);
17+
const modelNameData = getModelDirectiveFromElement(element);
1818
if (modelNameData === null) {
1919
return null;
2020
}
@@ -46,7 +46,7 @@ export function getValueFromInput(element: HTMLElement, valueStore: ValueStore):
4646

4747
// e.g. a textarea
4848
if ('value' in element) {
49-
// the "as" is a cheap way to hint to TypeScript that the value propery exists
49+
// the "as" is a cheap way to hint to TypeScript that the value property exists
5050
return (element as HTMLInputElement).value;
5151
}
5252

@@ -57,7 +57,61 @@ export function getValueFromInput(element: HTMLElement, valueStore: ValueStore):
5757
return null;
5858
}
5959

60-
export function getModelDirectiveFromInput(element: HTMLElement, throwOnMissing = true): null|Directive {
60+
/**
61+
* Adapted from https://github.com/livewire/livewire
62+
*/
63+
export function setValueOnElement(element: HTMLElement, value: any): void {
64+
if (element instanceof HTMLInputElement) {
65+
if (element.type === 'file') {
66+
return;
67+
}
68+
69+
if (element.type === 'radio') {
70+
element.checked = element.value == value
71+
72+
return;
73+
}
74+
75+
if (element.type === 'checkbox') {
76+
if (Array.isArray(value)) {
77+
// I'm purposely not using Array.includes here because it's
78+
// strict, and because of Numeric/String mis-casting, I
79+
// want the "includes" to be "fuzzy".
80+
let valueFound = false
81+
value.forEach(val => {
82+
if (val == element.value) {
83+
valueFound = true
84+
}
85+
})
86+
87+
element.checked = valueFound
88+
} else {
89+
element.checked = element.value == value;
90+
}
91+
92+
return;
93+
}
94+
}
95+
96+
if (element instanceof HTMLSelectElement) {
97+
const arrayWrappedValue = [].concat(value).map(value => {
98+
return value + ''
99+
})
100+
101+
Array.from(element.options).forEach(option => {
102+
option.selected = arrayWrappedValue.includes(option.value)
103+
})
104+
105+
return;
106+
}
107+
108+
value = value === undefined ? '' : value;
109+
110+
// silencing the typescript warning
111+
(element as HTMLInputElement).value = value
112+
}
113+
114+
export function getModelDirectiveFromElement(element: HTMLElement, throwOnMissing = true): null|Directive {
61115
if (element.dataset.model) {
62116
const directives = parseDirectives(element.dataset.model);
63117
const directive = directives[0];

src/LiveComponent/assets/src/live_controller.ts

Lines changed: 75 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,15 @@ import { combineSpacedArray, normalizeModelName } from './string_utils';
55
import { haveRenderedValuesChanged } from './have_rendered_values_changed';
66
import { normalizeAttributesForComparison } from './normalize_attributes_for_comparison';
77
import ValueStore from './ValueStore';
8-
import { elementBelongsToThisController, getModelDirectiveFromInput, getValueFromInput, cloneHTMLElement, htmlToElement, getElementAsTagText } from './dom_utils';
8+
import {
9+
elementBelongsToThisController,
10+
getModelDirectiveFromElement,
11+
getValueFromElement,
12+
cloneHTMLElement,
13+
htmlToElement,
14+
getElementAsTagText,
15+
setValueOnElement
16+
} from './dom_utils';
917
import UnsyncedInputContainer from './UnsyncedInputContainer';
1018

1119
interface ElementLoadingDirectives {
@@ -88,6 +96,7 @@ export default class extends Controller implements LiveController {
8896
this.originalDataJSON = this.valueStore.asJson();
8997
this.unsyncedInputs = new UnsyncedInputContainer();
9098
this._exposeOriginalData();
99+
this.synchronizeValueOfModelFields();
91100
}
92101

93102
connect() {
@@ -198,7 +207,7 @@ export default class extends Controller implements LiveController {
198207
// if so, to be safe, slightly delay the action so that the
199208
// change/input listener on LiveController can process the
200209
// model change *before* sending the action
201-
if (getModelDirectiveFromInput(event.currentTarget, false)) {
210+
if (getModelDirectiveFromElement(event.currentTarget, false)) {
202211
this.pendingActionTriggerModelElement = event.currentTarget;
203212
this.#clearRequestDebounceTimeout();
204213
window.setTimeout(() => {
@@ -234,7 +243,7 @@ export default class extends Controller implements LiveController {
234243
throw new Error('Could not update model for non HTMLElement');
235244
}
236245

237-
const modelDirective = getModelDirectiveFromInput(element, false);
246+
const modelDirective = getModelDirectiveFromElement(element, false);
238247
if (eventName === 'input') {
239248
const modelName = modelDirective ? modelDirective.action : null;
240249
// track any inputs that are "unsynced"
@@ -300,7 +309,7 @@ export default class extends Controller implements LiveController {
300309
}
301310
}
302311

303-
const finalValue = getValueFromInput(element, this.valueStore);
312+
const finalValue = getValueFromElement(element, this.valueStore);
304313

305314
this.$updateModel(
306315
modelDirective.action,
@@ -368,6 +377,9 @@ export default class extends Controller implements LiveController {
368377
// the string "4" - back into an array with [id=4, title=new_title].
369378
this.valueStore.set(modelName, value);
370379

380+
// the model's data is no longer unsynced
381+
this.unsyncedInputs.markModelAsSynced(modelName);
382+
371383
// skip rendering if there is an action Ajax call processing
372384
if (shouldRender) {
373385
let debounce: number = this.getDefaultDebounce();
@@ -376,6 +388,9 @@ export default class extends Controller implements LiveController {
376388
}
377389

378390
this.#clearRequestDebounceTimeout();
391+
// debouncing even with a 0 value is enough to allow any other potential
392+
// events happening right now (e.g. from custom user JavaScript) to
393+
// finish setting other models before making the request.
379394
this.requestDebounceTimeout = window.setTimeout(() => {
380395
this.requestDebounceTimeout = null;
381396
this.isRerenderRequested = true;
@@ -405,15 +420,6 @@ export default class extends Controller implements LiveController {
405420
// we're making a request NOW, so no need to make another one after debouncing
406421
this.#clearRequestDebounceTimeout();
407422

408-
// check if any unsynced inputs are now "in sync": their value matches what's in the store
409-
// if they ARE, then they are on longer "unsynced", which means that any
410-
// potential new values from the server *should* now be respected and used
411-
this.unsyncedInputs.allMappedFields().forEach((element, modelName) => {
412-
if (getValueFromInput(element, this.valueStore) === this.valueStore.get(modelName)) {
413-
this.unsyncedInputs.remove(modelName);
414-
}
415-
});
416-
417423
const fetchOptions: RequestInit = {};
418424
fetchOptions.headers = {
419425
'Accept': 'application/vnd.live-component+html',
@@ -506,16 +512,14 @@ export default class extends Controller implements LiveController {
506512
}
507513

508514
/**
509-
* If this re-render contains "mapped" fields that were updated after
510-
* the Ajax call started, then we need those "unsynced" values to
511-
* take precedence over the (out-of-date) values returned by the server.
515+
* For any models modified since the last request started, grab
516+
* their value now: we will re-set them after the new data from
517+
* the server has been processed.
512518
*/
513519
const modifiedModelValues: any = {};
514-
if (this.unsyncedInputs.allMappedFields().size > 0) {
515-
for (const [modelName] of this.unsyncedInputs.allMappedFields()) {
516-
modifiedModelValues[modelName] = this.valueStore.get(modelName);
517-
}
518-
}
520+
this.valueStore.updatedModels.forEach((modelName) => {
521+
modifiedModelValues[modelName] = this.valueStore.get(modelName);
522+
});
519523

520524
// merge/patch in the new HTML
521525
this._executeMorphdom(html, this.unsyncedInputs.all());
@@ -524,6 +528,8 @@ export default class extends Controller implements LiveController {
524528
Object.keys(modifiedModelValues).forEach((modelName) => {
525529
this.valueStore.set(modelName, modifiedModelValues[modelName]);
526530
});
531+
532+
this.synchronizeValueOfModelFields();
527533
}
528534

529535
_onLoadingStart() {
@@ -694,9 +700,10 @@ export default class extends Controller implements LiveController {
694700
return false;
695701
}
696702

697-
// if this field has been modified since this HTML was requested, do not update it
703+
// if this field's value has been modified since this HTML was
704+
// requested, set the toEl's value to match the fromEl
698705
if (modifiedElements.includes(fromEl)) {
699-
return false;
706+
setValueOnElement(toEl, getValueFromElement(fromEl, this.valueStore))
700707
}
701708

702709
// https://github.com/patrick-steele-idem/morphdom#can-i-make-morphdom-blaze-through-the-dom-tree-even-faster-yes
@@ -1080,6 +1087,51 @@ export default class extends Controller implements LiveController {
10801087
this.requestDebounceTimeout = null;
10811088
}
10821089
}
1090+
1091+
/**
1092+
* Sets the "value" of all model fields to the component data.
1093+
*
1094+
* This is called when the component initializes and after re-render.
1095+
* Take the following element:
1096+
*
1097+
* <input data-model="firstName">
1098+
*
1099+
* This method will set the "value" of that element to the value of
1100+
* the "firstName" model.
1101+
*/
1102+
private synchronizeValueOfModelFields(): void {
1103+
this.element.querySelectorAll('[data-model]').forEach((element) => {
1104+
if (!(element instanceof HTMLElement)) {
1105+
throw new Error('Invalid element using data-model.');
1106+
}
1107+
1108+
if (element instanceof HTMLFormElement) {
1109+
return;
1110+
}
1111+
1112+
const modelDirective = getModelDirectiveFromElement(element);
1113+
if (!modelDirective) {
1114+
return;
1115+
}
1116+
1117+
const modelName = modelDirective.action;
1118+
1119+
// skip any elements whose model name is currently in an unsynced state
1120+
if (this.unsyncedInputs.getModifiedModels().includes(modelName)) {
1121+
return;
1122+
}
1123+
1124+
if (this.valueStore.has(modelName)) {
1125+
setValueOnElement(element, this.valueStore.get(modelName))
1126+
}
1127+
1128+
// for select elements without a blank value, one might be selected automatically
1129+
// https://github.com/symfony/ux/issues/469
1130+
if (element instanceof HTMLSelectElement && !element.multiple) {
1131+
this.valueStore.set(modelName, getValueFromElement(element, this.valueStore));
1132+
}
1133+
})
1134+
}
10831135
}
10841136

10851137
class BackendRequest {

src/LiveComponent/assets/test/UnsyncedInputContainer.test.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ describe('UnsyncedInputContainer', () => {
1212
expect(container.all()).toEqual([element1, element2]);
1313
});
1414

15-
it('removes items added to it', () => {
15+
it('markModelAsSynced removes items added to it', () => {
1616
const container = new UnsyncedInputContainer();
1717
const element1 = htmlToElement('<span>element1</span');
1818
const element2 = htmlToElement('<span>element2</span');
@@ -21,8 +21,21 @@ describe('UnsyncedInputContainer', () => {
2121
container.add(element2, 'some_model2');
2222
container.add(element3, 'some_model3');
2323

24-
container.remove('some_model2');
24+
container.markModelAsSynced('some_model2');
2525

2626
expect(container.all()).toEqual([element1, element3]);
2727
});
28+
29+
it('returns modified models via getModifiedModels()', () => {
30+
const container = new UnsyncedInputContainer();
31+
const element1 = htmlToElement('<span>element1</span');
32+
const element2 = htmlToElement('<span>element2</span');
33+
const element3 = htmlToElement('<span>element3</span');
34+
container.add(element1);
35+
container.add(element2, 'some_model2');
36+
container.add(element3, 'some_model3');
37+
38+
container.markModelAsSynced('some_model2');
39+
expect(container.getModifiedModels()).toEqual(['some_model3'])
40+
});
2841
});

0 commit comments

Comments
 (0)