Skip to content

Commit d5d8252

Browse files
committed
feature #472 [Waiting][Live] Doc'ing how to set values via Js, select sync bug & auto-setting data-model values (weaverryan)
This PR was merged into the 2.x branch. Discussion ---------- [Waiting][Live] Doc'ing how to set values via Js, select sync bug & auto-setting data-model values | Q | A | ------------- | --- | Bug fix? | yes | New feature? | no | Tickets | Fixes #469 Fixes #473 | License | MIT This does 3 unrelated things to close #469 and #473 **NOTE: BUILT ON TOP OF #466 A) [X] Documents how to change a "model" field via JavaScript B) [X] Fixes a problem where a `select` element is rendered without an `empty` option, and so the component data isn't aware of the pre-selected option. C) [X] Automatically set the `value` of a `data-model` field - #473. Cheers! Commits ------- af084ba doc'ing how to set values via JS, auto-set "value" of model elements, sync empty select fields
2 parents e71e305 + af084ba commit d5d8252

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)