Skip to content

Commit b355eb9

Browse files
committed
bug #221 [LiveComponent] Fix expanded choices and checkboxes in forms (Lustmored)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [LiveComponent] Fix expanded choices and checkboxes in forms | Q | A | ------------- | --- | Bug fix? | yes | New feature? | no | Tickets | | License | MIT When using live component with forms that contains expanded choices list (rendered as a list of radio buttons) extracting form values returns array of nulls (one per choice), while frontend expects single value. Therefore after calling first `update` and then trying to call update on expanded field controller throws (`The property used in data-model="${propertyPath}" was never initialized. Did you forget to add exposed={"${lastPart}"} to its LiveProp?`). This simple change fixes the issue. I have also added simple test case for a few basic form types. Commits ------- 7605821 Code Style 2de0cbf Rebuild dist file d96d82f Adapt to changes in component 2f735a2 Simplify form functional test eb92ebd Use $initialData instead of 'data' in FormType to make tests easier to follow 33dabc0 fetchMock.done() is not needed anymore 857ff1f Return early and throw on unsupported element bd05418 Renames and comments f7dbdc1 Add description to updateArrayDataFromChangedElement d195ee9 Rename getArrayValue 914bcd5 Spacing issues f13a498 Merge branch '2.x' into fix-expanded-choices a636a71 Add simple select multiple test to functional test 4c8336b Add select multiple to backend form test d00cb41 Rebuild dist/ 3204490 getArrayValues should return empty array instead of null to mimic backend behavior f87796d Rebuild `dist/` 56caf14 Fix last tests e51f202 Add tests for checkbox and select multiple cases f342a7a Introduce getArrayValue with unit tests 7580a73 Add test for normalizeModelName and explain change within a194f0c After merge cleanup and tests fixing 18741bd Merge branch '2.x' into fix-expanded-choices a5f56f2 Other attempt at tests ffd6b72 Code style 296f9a4 misstype 6d41d90 Try to make JS tests work 1e31c30 Move array handling into _updateModelFromElement d183645 Add backend web test case for checkboxes 66f85e8 Rework FormComponent1 to use form type and register it in test kernel. 546c5a7 Don't pass values to extractFormValues 71f49ae Rebuild dist assets 0f4e70e Merge branch '2.x' into fix-expanded-choices a17870f Resolve conflicts 2f1d0ee Handle collections of checkboxes as data arrays 255d9f9 Add choice_multiple to test and fix uninitialized value cfbbf9d Set data to null on unchecked elements (checkboxes) f5e34fa Always propagate form null values to formValues cab4c4a Rename test component to avoid possible conflicts 841e07f Simplify if clause and fix assert arguments order fc953dd Code style 4a37d10 Fix form values for ChoiceType with expanded: true 76de2a2 Add basic test for formValues() e76bdb9 Add symfony/form to dev dependencies
2 parents 2ae5058 + 7605821 commit b355eb9

14 files changed

+713
-23
lines changed

src/LiveComponent/assets/dist/live_controller.js

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -898,14 +898,23 @@ function combineSpacedArray(parts) {
898898
return finalParts;
899899
}
900900

901-
function setDeepData(data, propertyPath, value) {
901+
function parseDeepData(data, propertyPath) {
902902
const finalData = JSON.parse(JSON.stringify(data));
903903
let currentLevelData = finalData;
904904
const parts = propertyPath.split('.');
905905
for (let i = 0; i < parts.length - 1; i++) {
906906
currentLevelData = currentLevelData[parts[i]];
907907
}
908908
const finalKey = parts[parts.length - 1];
909+
return {
910+
currentLevelData,
911+
finalData,
912+
finalKey,
913+
parts
914+
};
915+
}
916+
function setDeepData(data, propertyPath, value) {
917+
const { currentLevelData, finalData, finalKey, parts } = parseDeepData(data, propertyPath);
909918
if (typeof currentLevelData !== 'object') {
910919
const lastPart = parts.pop();
911920
throw new Error(`Cannot set data-model="${propertyPath}". The parent "${parts.join('.')}" data does not appear to be an object (it's "${currentLevelData}"). Did you forget to add exposed={"${lastPart}"} to its LiveProp?`);
@@ -928,10 +937,12 @@ function doesDeepPropertyExist(data, propertyPath) {
928937
}
929938
function normalizeModelName(model) {
930939
return model
940+
.replace(/\[]$/, '')
931941
.split('[')
932942
.map(function (s) {
933943
return s.replace(']', '');
934-
}).join('.');
944+
})
945+
.join('.');
935946
}
936947

937948
function haveRenderedValuesChanged(originalDataJson, currentDataJson, newDataJson) {
@@ -979,6 +990,30 @@ function cloneHTMLElement(element) {
979990
return newElement;
980991
}
981992

993+
function updateArrayDataFromChangedElement(element, value, currentValues) {
994+
if (!(currentValues instanceof Array)) {
995+
currentValues = [];
996+
}
997+
if (element instanceof HTMLInputElement && element.type === 'checkbox') {
998+
const index = currentValues.indexOf(value);
999+
if (element.checked) {
1000+
if (index === -1) {
1001+
currentValues.push(value);
1002+
}
1003+
return currentValues;
1004+
}
1005+
if (index > -1) {
1006+
currentValues.splice(index, 1);
1007+
}
1008+
return currentValues;
1009+
}
1010+
if (element instanceof HTMLSelectElement) {
1011+
currentValues = Array.from(element.selectedOptions).map(el => el.value);
1012+
return currentValues;
1013+
}
1014+
throw new Error(`The element used to determine array data from is unsupported (${element.tagName} provided)`);
1015+
}
1016+
9821017
const DEFAULT_DEBOUNCE = 150;
9831018
class default_1 extends Controller {
9841019
constructor() {
@@ -1022,12 +1057,10 @@ class default_1 extends Controller {
10221057
window.removeEventListener('beforeunload', this.markAsWindowUnloaded);
10231058
}
10241059
update(event) {
1025-
const value = this._getValueFromElement(event.target);
1026-
this._updateModelFromElement(event.target, value, true);
1060+
this._updateModelFromElement(event.target, this._getValueFromElement(event.target), true);
10271061
}
10281062
updateDefer(event) {
1029-
const value = this._getValueFromElement(event.target);
1030-
this._updateModelFromElement(event.target, value, false);
1063+
this._updateModelFromElement(event.target, this._getValueFromElement(event.target), false);
10311064
}
10321065
action(event) {
10331066
const rawAction = event.currentTarget.dataset.actionName;
@@ -1088,7 +1121,17 @@ class default_1 extends Controller {
10881121
const clonedElement = cloneHTMLElement(element);
10891122
throw new Error(`The update() method could not be called for "${clonedElement.outerHTML}": the element must either have a "data-model" or "name" attribute set to the model name.`);
10901123
}
1091-
this.$updateModel(model, value, shouldRender, element.hasAttribute('name') ? element.getAttribute('name') : null);
1124+
if (/\[]$/.test(model)) {
1125+
const { currentLevelData, finalKey } = parseDeepData(this.dataValue, normalizeModelName(model));
1126+
const currentValue = currentLevelData[finalKey];
1127+
value = updateArrayDataFromChangedElement(element, value, currentValue);
1128+
}
1129+
else if (element instanceof HTMLInputElement
1130+
&& element.type === 'checkbox'
1131+
&& !element.checked) {
1132+
value = null;
1133+
}
1134+
this.$updateModel(model, value, shouldRender, element.hasAttribute('name') ? element.getAttribute('name') : null, {});
10921135
}
10931136
$updateModel(model, value, shouldRender = true, extraModelName = null, options = {}) {
10941137
const directives = parseDirectives(model);
@@ -1112,7 +1155,7 @@ class default_1 extends Controller {
11121155
this._dispatchEvent('live:update-model', {
11131156
modelName,
11141157
extraModelName: normalizedExtraModelName,
1115-
value,
1158+
value
11161159
});
11171160
}
11181161
this.dataValue = setDeepData(this.dataValue, modelName, value);

src/LiveComponent/assets/src/live_controller.ts

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import { Controller } from '@hotwired/stimulus';
22
import morphdom from 'morphdom';
33
import { parseDirectives, Directive } from './directives_parser';
44
import { combineSpacedArray } from './string_utils';
5-
import { setDeepData, doesDeepPropertyExist, normalizeModelName } from './set_deep_data';
5+
import { setDeepData, doesDeepPropertyExist, normalizeModelName, parseDeepData } from './set_deep_data';
66
import { haveRenderedValuesChanged } from './have_rendered_values_changed';
77
import { normalizeAttributesForComparison } from './normalize_attributes_for_comparison';
88
import { cloneHTMLElement } from './clone_html_element';
9+
import { updateArrayDataFromChangedElement } from "./update_array_data";
910

1011
interface ElementLoadingDirectives {
1112
element: HTMLElement|SVGElement,
@@ -105,15 +106,11 @@ export default class extends Controller {
105106
* Called to update one piece of the model
106107
*/
107108
update(event: any) {
108-
const value = this._getValueFromElement(event.target);
109-
110-
this._updateModelFromElement(event.target, value, true);
109+
this._updateModelFromElement(event.target, this._getValueFromElement(event.target), true);
111110
}
112111

113112
updateDefer(event: any) {
114-
const value = this._getValueFromElement(event.target);
115-
116-
this._updateModelFromElement(event.target, value, false);
113+
this._updateModelFromElement(event.target, this._getValueFromElement(event.target), false);
117114
}
118115

119116
action(event: any) {
@@ -194,11 +191,10 @@ export default class extends Controller {
194191
return element.dataset.value || (element as any).value;
195192
}
196193

197-
_updateModelFromElement(element: Element, value: string, shouldRender: boolean) {
194+
_updateModelFromElement(element: Element, value: string|null, shouldRender: boolean) {
198195
if (!(element instanceof HTMLElement)) {
199196
throw new Error('Could not update model for non HTMLElement');
200197
}
201-
202198
const model = element.dataset.model || element.getAttribute('name');
203199

204200
if (!model) {
@@ -207,7 +203,25 @@ export default class extends Controller {
207203
throw new Error(`The update() method could not be called for "${clonedElement.outerHTML}": the element must either have a "data-model" or "name" attribute set to the model name.`);
208204
}
209205

210-
this.$updateModel(model, value, shouldRender, element.hasAttribute('name') ? element.getAttribute('name') : null);
206+
// HTML form elements with name ending with [] require array as data
207+
// we need to handle addition and removal of values from it to send
208+
// back only required data
209+
if (/\[]$/.test(model)) {
210+
// Get current value from data
211+
const { currentLevelData, finalKey } = parseDeepData(this.dataValue, normalizeModelName(model))
212+
const currentValue = currentLevelData[finalKey];
213+
214+
value = updateArrayDataFromChangedElement(element, value, currentValue);
215+
} else if (
216+
element instanceof HTMLInputElement
217+
&& element.type === 'checkbox'
218+
&& !element.checked
219+
) {
220+
// Unchecked checkboxes in a single value scenarios should map to `null`
221+
value = null;
222+
}
223+
224+
this.$updateModel(model, value, shouldRender, element.hasAttribute('name') ? element.getAttribute('name') : null, {});
211225
}
212226

213227
/**
@@ -257,7 +271,7 @@ export default class extends Controller {
257271
this._dispatchEvent('live:update-model', {
258272
modelName,
259273
extraModelName: normalizedExtraModelName,
260-
value,
274+
value
261275
});
262276
}
263277

src/LiveComponent/assets/src/set_deep_data.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
// post.user.username
2-
export function setDeepData(data, propertyPath, value) {
3-
// cheap way to deep clone simple data
2+
export function parseDeepData(data, propertyPath) {
43
const finalData = JSON.parse(JSON.stringify(data));
54

65
let currentLevelData = finalData;
@@ -14,6 +13,18 @@ export function setDeepData(data, propertyPath, value) {
1413
// now finally change the key on that deeper object
1514
const finalKey = parts[parts.length - 1];
1615

16+
return {
17+
currentLevelData,
18+
finalData,
19+
finalKey,
20+
parts
21+
}
22+
}
23+
24+
// post.user.username
25+
export function setDeepData(data, propertyPath, value) {
26+
const { currentLevelData, finalData, finalKey, parts } = parseDeepData(data, propertyPath)
27+
1728
// make sure the currentLevelData is an object, not a scalar
1829
// if it is, it means the initial data didn't know that sub-properties
1930
// could be exposed. Or, you're just trying to set some deep
@@ -64,9 +75,14 @@ export function doesDeepPropertyExist(data, propertyPath) {
6475
*/
6576
export function normalizeModelName(model) {
6677
return model
78+
// Names ending in "[]" represent arrays in HTML.
79+
// To get normalized name we need to ignore this part.
80+
// For example: "user[mailing][]" becomes "user.mailing" (and has array typed value)
81+
.replace(/\[]$/, '')
6782
.split('[')
6883
// ['object', 'foo', 'bar', 'ya']
6984
.map(function (s) {
7085
return s.replace(']', '')
71-
}).join('.')
86+
})
87+
.join('.')
7288
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* Adds or removes a key from an array element based on an array element.
3+
*
4+
* Given an "array" element (e.g. )
5+
* and the current data for "preferences" (e.g. ["text", "phone"]), this function will add or
6+
* remove the value (e.g. email) from that array (based on if the element (un)checked) and
7+
* return the final, updated array (e.g. ["text", "phone", "email"]).
8+
*
9+
* @param element Current HTML element
10+
* @param value The value that should be set or removed from currentValues
11+
* @param currentValues Current data value
12+
*/
13+
export function updateArrayDataFromChangedElement(
14+
element: HTMLElement,
15+
value: string|null,
16+
currentValues: any
17+
): Array<string> {
18+
// Enforce returned value is an array
19+
if (!(currentValues instanceof Array)) {
20+
currentValues = [];
21+
}
22+
23+
if (element instanceof HTMLInputElement && element.type === 'checkbox') {
24+
const index = currentValues.indexOf(value);
25+
26+
if (element.checked) {
27+
// Add value to an array if it's not in it already
28+
if (index === -1) {
29+
currentValues.push(value);
30+
}
31+
32+
return currentValues;
33+
}
34+
35+
// Remove value from an array
36+
if (index > -1) {
37+
currentValues.splice(index, 1);
38+
}
39+
40+
return currentValues;
41+
}
42+
43+
if (element instanceof HTMLSelectElement) {
44+
// Select elements with `multiple` option require mapping chosen options to their values
45+
currentValues = Array.from(element.selectedOptions).map(el => el.value);
46+
47+
return currentValues;
48+
}
49+
50+
throw new Error(`The element used to determine array data from is unsupported (${element.tagName} provided)`);
51+
}

0 commit comments

Comments
 (0)