Skip to content

[LiveComponent] Fix expanded choices and checkboxes in forms #221

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 44 commits into from
Feb 7, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
e76bdb9
Add symfony/form to dev dependencies
Lustmored Jan 14, 2022
76de2a2
Add basic test for formValues()
Lustmored Jan 14, 2022
4a37d10
Fix form values for ChoiceType with expanded: true
Lustmored Jan 14, 2022
fc953dd
Code style
Lustmored Jan 14, 2022
841e07f
Simplify if clause and fix assert arguments order
Lustmored Jan 14, 2022
cab4c4a
Rename test component to avoid possible conflicts
Lustmored Jan 21, 2022
f5e34fa
Always propagate form null values to formValues
Lustmored Jan 21, 2022
cfbbf9d
Set data to null on unchecked elements (checkboxes)
Lustmored Jan 21, 2022
255d9f9
Add choice_multiple to test and fix uninitialized value
Lustmored Jan 21, 2022
2f1d0ee
Handle collections of checkboxes as data arrays
Lustmored Jan 21, 2022
a17870f
Resolve conflicts
Lustmored Jan 21, 2022
0f4e70e
Merge branch '2.x' into fix-expanded-choices
Lustmored Jan 28, 2022
71f49ae
Rebuild dist assets
Lustmored Jan 28, 2022
546c5a7
Don't pass values to extractFormValues
Lustmored Jan 28, 2022
66f85e8
Rework FormComponent1 to use form type and register it in test kernel.
Lustmored Jan 28, 2022
d183645
Add backend web test case for checkboxes
Lustmored Jan 28, 2022
1e31c30
Move array handling into _updateModelFromElement
Lustmored Jan 28, 2022
6d41d90
Try to make JS tests work
Lustmored Jan 28, 2022
296f9a4
misstype
Lustmored Jan 28, 2022
ffd6b72
Code style
Lustmored Jan 28, 2022
a5f56f2
Other attempt at tests
Lustmored Jan 28, 2022
18741bd
Merge branch '2.x' into fix-expanded-choices
Lustmored Feb 4, 2022
a194f0c
After merge cleanup and tests fixing
Lustmored Feb 4, 2022
7580a73
Add test for normalizeModelName and explain change within
Lustmored Feb 4, 2022
f342a7a
Introduce getArrayValue with unit tests
Lustmored Feb 4, 2022
e51f202
Add tests for checkbox and select multiple cases
Lustmored Feb 4, 2022
56caf14
Fix last tests
Lustmored Feb 4, 2022
f87796d
Rebuild `dist/`
Lustmored Feb 4, 2022
3204490
getArrayValues should return empty array instead of null to mimic bac…
Lustmored Feb 4, 2022
d00cb41
Rebuild dist/
Lustmored Feb 4, 2022
4c8336b
Add select multiple to backend form test
Lustmored Feb 4, 2022
a636a71
Add simple select multiple test to functional test
Lustmored Feb 4, 2022
f13a498
Merge branch '2.x' into fix-expanded-choices
Lustmored Feb 5, 2022
914bcd5
Spacing issues
Lustmored Feb 5, 2022
d195ee9
Rename getArrayValue
Lustmored Feb 5, 2022
f7dbdc1
Add description to updateArrayDataFromChangedElement
Lustmored Feb 5, 2022
bd05418
Renames and comments
Lustmored Feb 5, 2022
857ff1f
Return early and throw on unsupported element
Lustmored Feb 5, 2022
33dabc0
fetchMock.done() is not needed anymore
Lustmored Feb 5, 2022
eb92ebd
Use $initialData instead of 'data' in FormType to make tests easier t…
Lustmored Feb 5, 2022
2f735a2
Simplify form functional test
Lustmored Feb 5, 2022
d96d82f
Adapt to changes in component
Lustmored Feb 5, 2022
2de0cbf
Rebuild dist file
Lustmored Feb 5, 2022
7605821
Code Style
Lustmored Feb 5, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 51 additions & 8 deletions src/LiveComponent/assets/dist/live_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -898,14 +898,23 @@ function combineSpacedArray(parts) {
return finalParts;
}

function setDeepData(data, propertyPath, value) {
function parseDeepData(data, propertyPath) {
const finalData = JSON.parse(JSON.stringify(data));
let currentLevelData = finalData;
const parts = propertyPath.split('.');
for (let i = 0; i < parts.length - 1; i++) {
currentLevelData = currentLevelData[parts[i]];
}
const finalKey = parts[parts.length - 1];
return {
currentLevelData,
finalData,
finalKey,
parts
};
}
function setDeepData(data, propertyPath, value) {
const { currentLevelData, finalData, finalKey, parts } = parseDeepData(data, propertyPath);
if (typeof currentLevelData !== 'object') {
const lastPart = parts.pop();
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?`);
Expand All @@ -928,10 +937,12 @@ function doesDeepPropertyExist(data, propertyPath) {
}
function normalizeModelName(model) {
return model
.replace(/\[]$/, '')
.split('[')
.map(function (s) {
return s.replace(']', '');
}).join('.');
})
.join('.');
}

function haveRenderedValuesChanged(originalDataJson, currentDataJson, newDataJson) {
Expand Down Expand Up @@ -979,6 +990,30 @@ function cloneHTMLElement(element) {
return newElement;
}

function updateArrayDataFromChangedElement(element, value, currentValues) {
if (!(currentValues instanceof Array)) {
currentValues = [];
}
if (element instanceof HTMLInputElement && element.type === 'checkbox') {
const index = currentValues.indexOf(value);
if (element.checked) {
if (index === -1) {
currentValues.push(value);
}
return currentValues;
}
if (index > -1) {
currentValues.splice(index, 1);
}
return currentValues;
}
if (element instanceof HTMLSelectElement) {
currentValues = Array.from(element.selectedOptions).map(el => el.value);
return currentValues;
}
throw new Error(`The element used to determine array data from is unsupported (${element.tagName} provided)`);
}

const DEFAULT_DEBOUNCE = 150;
class default_1 extends Controller {
constructor() {
Expand Down Expand Up @@ -1022,12 +1057,10 @@ class default_1 extends Controller {
window.removeEventListener('beforeunload', this.markAsWindowUnloaded);
}
update(event) {
const value = this._getValueFromElement(event.target);
this._updateModelFromElement(event.target, value, true);
this._updateModelFromElement(event.target, this._getValueFromElement(event.target), true);
}
updateDefer(event) {
const value = this._getValueFromElement(event.target);
this._updateModelFromElement(event.target, value, false);
this._updateModelFromElement(event.target, this._getValueFromElement(event.target), false);
}
action(event) {
const rawAction = event.currentTarget.dataset.actionName;
Expand Down Expand Up @@ -1088,7 +1121,17 @@ class default_1 extends Controller {
const clonedElement = cloneHTMLElement(element);
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.`);
}
this.$updateModel(model, value, shouldRender, element.hasAttribute('name') ? element.getAttribute('name') : null);
if (/\[]$/.test(model)) {
const { currentLevelData, finalKey } = parseDeepData(this.dataValue, normalizeModelName(model));
const currentValue = currentLevelData[finalKey];
value = updateArrayDataFromChangedElement(element, value, currentValue);
}
else if (element instanceof HTMLInputElement
&& element.type === 'checkbox'
&& !element.checked) {
value = null;
}
this.$updateModel(model, value, shouldRender, element.hasAttribute('name') ? element.getAttribute('name') : null, {});
}
$updateModel(model, value, shouldRender = true, extraModelName = null, options = {}) {
const directives = parseDirectives(model);
Expand All @@ -1112,7 +1155,7 @@ class default_1 extends Controller {
this._dispatchEvent('live:update-model', {
modelName,
extraModelName: normalizedExtraModelName,
value,
value
});
}
this.dataValue = setDeepData(this.dataValue, modelName, value);
Expand Down
36 changes: 25 additions & 11 deletions src/LiveComponent/assets/src/live_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import { Controller } from '@hotwired/stimulus';
import morphdom from 'morphdom';
import { parseDirectives, Directive } from './directives_parser';
import { combineSpacedArray } from './string_utils';
import { setDeepData, doesDeepPropertyExist, normalizeModelName } from './set_deep_data';
import { setDeepData, doesDeepPropertyExist, normalizeModelName, parseDeepData } from './set_deep_data';
import { haveRenderedValuesChanged } from './have_rendered_values_changed';
import { normalizeAttributesForComparison } from './normalize_attributes_for_comparison';
import { cloneHTMLElement } from './clone_html_element';
import { updateArrayDataFromChangedElement } from "./update_array_data";

interface ElementLoadingDirectives {
element: HTMLElement|SVGElement,
Expand Down Expand Up @@ -105,15 +106,11 @@ export default class extends Controller {
* Called to update one piece of the model
*/
update(event: any) {
const value = this._getValueFromElement(event.target);

this._updateModelFromElement(event.target, value, true);
this._updateModelFromElement(event.target, this._getValueFromElement(event.target), true);
}

updateDefer(event: any) {
const value = this._getValueFromElement(event.target);

this._updateModelFromElement(event.target, value, false);
this._updateModelFromElement(event.target, this._getValueFromElement(event.target), false);
}

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

_updateModelFromElement(element: Element, value: string, shouldRender: boolean) {
_updateModelFromElement(element: Element, value: string|null, shouldRender: boolean) {
if (!(element instanceof HTMLElement)) {
throw new Error('Could not update model for non HTMLElement');
}

const model = element.dataset.model || element.getAttribute('name');

if (!model) {
Expand All @@ -207,7 +203,25 @@ export default class extends Controller {
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.`);
}

this.$updateModel(model, value, shouldRender, element.hasAttribute('name') ? element.getAttribute('name') : null);
// HTML form elements with name ending with [] require array as data
// we need to handle addition and removal of values from it to send
// back only required data
if (/\[]$/.test(model)) {
// Get current value from data
const { currentLevelData, finalKey } = parseDeepData(this.dataValue, normalizeModelName(model))
const currentValue = currentLevelData[finalKey];

value = updateArrayDataFromChangedElement(element, value, currentValue);
} else if (
element instanceof HTMLInputElement
&& element.type === 'checkbox'
&& !element.checked
) {
// Unchecked checkboxes in a single value scenarios should map to `null`
value = null;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand things correctly, this handles a case where, for example, we have an <input type="checkbox" name="newsletters[]" value="email">. And all we know in this method is that, for example, that the email checkbox was changed. And so, the purpose of this block is to try to figure out what the true current value of newsletters is and then add/remove email (based on if it was checked or unchecked). Is that correct?

If so, I'd love to isolate this logic to an external module/file - like in set_deep_data.ts. It would help because we could give the method a name (the logic is very unclear right now: it's just complex, so it's hard to see what it's doing) and we could even unit test it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just did as you asked - great call! Resulting function is much simpler and cleaner in my opinion and it was extremely easy to unit test it. I have already incorporate "select multiple" case within it, so it should start working soon.

Still some test to write and I'll manually check how it behaves too, but for the first time I can see the finish line 😄

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is all reading VERY nicely now. It's a complex situation, but I can understand it - nice work!


this.$updateModel(model, value, shouldRender, element.hasAttribute('name') ? element.getAttribute('name') : null, {});
}

/**
Expand Down Expand Up @@ -257,7 +271,7 @@ export default class extends Controller {
this._dispatchEvent('live:update-model', {
modelName,
extraModelName: normalizedExtraModelName,
value,
value
});
}

Expand Down
22 changes: 19 additions & 3 deletions src/LiveComponent/assets/src/set_deep_data.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
// post.user.username
export function setDeepData(data, propertyPath, value) {
// cheap way to deep clone simple data
export function parseDeepData(data, propertyPath) {
const finalData = JSON.parse(JSON.stringify(data));

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

return {
currentLevelData,
finalData,
finalKey,
parts
}
}

// post.user.username
export function setDeepData(data, propertyPath, value) {
const { currentLevelData, finalData, finalKey, parts } = parseDeepData(data, propertyPath)

// make sure the currentLevelData is an object, not a scalar
// if it is, it means the initial data didn't know that sub-properties
// could be exposed. Or, you're just trying to set some deep
Expand Down Expand Up @@ -64,9 +75,14 @@ export function doesDeepPropertyExist(data, propertyPath) {
*/
export function normalizeModelName(model) {
return model
// Names ending in "[]" represent arrays in HTML.
// To get normalized name we need to ignore this part.
// For example: "user[mailing][]" becomes "user.mailing" (and has array typed value)
.replace(/\[]$/, '')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a test to the bottom of set_deep_data.test.ts to test & explain what this does?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will paste here comment as I'm adding it:

Names ending in "[]" represent arrays in HTML.
To get normalized name we need to ignore this part.
For example: "user[mailing][]" becomes "user.mailing" (and has array typed value)

I have also added simple test case

.split('[')
// ['object', 'foo', 'bar', 'ya']
.map(function (s) {
return s.replace(']', '')
}).join('.')
})
.join('.')
}
51 changes: 51 additions & 0 deletions src/LiveComponent/assets/src/update_array_data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Adds or removes a key from an array element based on an array element.
*
* Given an "array" element (e.g. )
* and the current data for "preferences" (e.g. ["text", "phone"]), this function will add or
* remove the value (e.g. email) from that array (based on if the element (un)checked) and
* return the final, updated array (e.g. ["text", "phone", "email"]).
*
* @param element Current HTML element
* @param value The value that should be set or removed from currentValues
* @param currentValues Current data value
*/
export function updateArrayDataFromChangedElement(
element: HTMLElement,
value: string|null,
currentValues: any
): Array<string> {
// Enforce returned value is an array
if (!(currentValues instanceof Array)) {
currentValues = [];
}

if (element instanceof HTMLInputElement && element.type === 'checkbox') {
const index = currentValues.indexOf(value);

if (element.checked) {
// Add value to an array if it's not in it already
if (index === -1) {
currentValues.push(value);
}

return currentValues;
}

// Remove value from an array
if (index > -1) {
currentValues.splice(index, 1);
}

return currentValues;
}

if (element instanceof HTMLSelectElement) {
// Select elements with `multiple` option require mapping chosen options to their values
currentValues = Array.from(element.selectedOptions).map(el => el.value);

return currentValues;
}

throw new Error(`The element used to determine array data from is unsupported (${element.tagName} provided)`);
}
Loading