From a802d36afeccdc68466d727a4fe20c45271c471d Mon Sep 17 00:00:00 2001 From: Ryan Weaver Date: Mon, 20 Mar 2023 15:17:18 -0400 Subject: [PATCH] [Live] Add proper support for boolean checkboxes + fix bug for non-model checkboxes --- src/LiveComponent/CHANGELOG.md | 4 ++ src/LiveComponent/assets/dist/dom_utils.d.ts | 2 +- .../assets/dist/live_controller.js | 23 +++++--- src/LiveComponent/assets/src/dom_utils.ts | 29 ++++++---- .../assets/test/dom_utils.test.ts | 53 +++++++++++++++++-- src/LiveComponent/doc/index.rst | 47 ++++++++++++++++ ux.symfony.com/assets/bootstrap.js | 1 + .../components/search_packages.html.twig | 2 + 8 files changed, 140 insertions(+), 21 deletions(-) diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index ef2c403b656..9e9a2467278 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -19,6 +19,10 @@ public User $user; the DOM inside a live component, those changes will now be _kept_ when the component is re-rendered. This has limitations - see the documentation. +- Boolean checkboxes are now supported. Of a checkbox does **not** have a + `value` attribute, then the associated `LiveProp` will be set to a boolean + when the input is checked/unchecked. + - Added support for setting `writable` to a property that is an object (previously, only scalar values were supported). The object is passed through the serializer. diff --git a/src/LiveComponent/assets/dist/dom_utils.d.ts b/src/LiveComponent/assets/dist/dom_utils.d.ts index dfa0368076a..7f03232d9c3 100644 --- a/src/LiveComponent/assets/dist/dom_utils.d.ts +++ b/src/LiveComponent/assets/dist/dom_utils.d.ts @@ -1,7 +1,7 @@ import ValueStore from './Component/ValueStore'; import { Directive } from './Directive/directives_parser'; import Component from './Component'; -export declare function getValueFromElement(element: HTMLElement, valueStore: ValueStore): string | string[] | null; +export declare function getValueFromElement(element: HTMLElement, valueStore: ValueStore): string | string[] | null | boolean; export declare function setValueOnElement(element: HTMLElement, value: any): void; export declare function getAllModelDirectiveFromElements(element: HTMLElement): Directive[]; export declare function getModelDirectiveFromElement(element: HTMLElement, throwOnMissing?: boolean): null | Directive; diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 68fbbf7fbae..a819550f306 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -156,15 +156,17 @@ function normalizeModelName(model) { function getValueFromElement(element, valueStore) { if (element instanceof HTMLInputElement) { if (element.type === 'checkbox') { - const modelNameData = getModelDirectiveFromElement(element); - if (modelNameData === null) { - return null; + const modelNameData = getModelDirectiveFromElement(element, false); + if (modelNameData !== null) { + const modelValue = valueStore.get(modelNameData.action); + if (Array.isArray(modelValue)) { + return getMultipleCheckboxValue(element, modelValue); + } } - const modelValue = valueStore.get(modelNameData.action); - if (Array.isArray(modelValue)) { - return getMultipleCheckboxValue(element, modelValue); + if (element.hasAttribute('value')) { + return element.checked ? element.getAttribute('value') : null; } - return element.checked ? inputValue(element) : null; + return element.checked; } return inputValue(element); } @@ -205,7 +207,12 @@ function setValueOnElement(element, value) { element.checked = valueFound; } else { - element.checked = element.value == value; + if (element.hasAttribute('value')) { + element.checked = element.value == value; + } + else { + element.checked = value; + } } return; } diff --git a/src/LiveComponent/assets/src/dom_utils.ts b/src/LiveComponent/assets/src/dom_utils.ts index d31009c655a..679f50e2fd9 100644 --- a/src/LiveComponent/assets/src/dom_utils.ts +++ b/src/LiveComponent/assets/src/dom_utils.ts @@ -11,20 +11,25 @@ import Component from './Component'; * elements. In those cases, it will return the "full", final value * for the model, which includes previously-selected values. */ -export function getValueFromElement(element: HTMLElement, valueStore: ValueStore): string | string[] | null { +export function getValueFromElement(element: HTMLElement, valueStore: ValueStore): string | string[] | null | boolean { if (element instanceof HTMLInputElement) { if (element.type === 'checkbox') { - const modelNameData = getModelDirectiveFromElement(element); - if (modelNameData === null) { - return null; + const modelNameData = getModelDirectiveFromElement(element, false); + if (modelNameData !== null) { + // if there's a model - try to determine if it's an array + const modelValue = valueStore.get(modelNameData.action); + if (Array.isArray(modelValue)) { + return getMultipleCheckboxValue(element, modelValue); + } } - const modelValue = valueStore.get(modelNameData.action); - if (Array.isArray(modelValue)) { - return getMultipleCheckboxValue(element, modelValue); + // read the attribute directly to avoid the default "on" value + if (element.hasAttribute('value')) { + // if the checkbox has a value="", then the unchecked value is null + return element.checked ? element.getAttribute('value') : null; } - return element.checked ? inputValue(element) : null; + return element.checked; } return inputValue(element); @@ -86,7 +91,13 @@ export function setValueOnElement(element: HTMLElement, value: any): void { element.checked = valueFound; } else { - element.checked = element.value == value; + if (element.hasAttribute('value')) { + // if the checkbox has a value="", then check if it matches + element.checked = element.value == value; + } else { + // no value, treat it like a boolean + element.checked = value; + } } return; diff --git a/src/LiveComponent/assets/test/dom_utils.test.ts b/src/LiveComponent/assets/test/dom_utils.test.ts index 1d596ed13cc..5026fd7d179 100644 --- a/src/LiveComponent/assets/test/dom_utils.test.ts +++ b/src/LiveComponent/assets/test/dom_utils.test.ts @@ -17,7 +17,7 @@ const createStore = function(props: any = {}): ValueStore { } describe('getValueFromElement', () => { - it('Correctly adds data from checked checkbox', () => { + it('Correctly adds data from valued checked checkbox', () => { const input = document.createElement('input'); input.type = 'checkbox'; input.checked = true; @@ -34,7 +34,7 @@ describe('getValueFromElement', () => { .toEqual(['bar', 'the_checkbox_value']); }); - it('Correctly removes data from unchecked checkbox', () => { + it('Correctly removes data from valued unchecked checkbox', () => { const input = document.createElement('input'); input.type = 'checkbox'; input.checked = false; @@ -50,7 +50,36 @@ describe('getValueFromElement', () => { .toEqual(['bar']); expect(getValueFromElement(input, createStore({ foo: ['bar', 'the_checkbox_value'] }))) .toEqual(['bar']); - }) + }); + + it('Correctly handles boolean checkbox', () => { + const input = document.createElement('input'); + input.type = 'checkbox'; + input.checked = true; + input.dataset.model = 'foo'; + + expect(getValueFromElement(input, createStore())) + .toEqual(true); + + input.checked = false; + + expect(getValueFromElement(input, createStore())) + .toEqual(false); + }); + + it('Correctly returns for non-model checkboxes', () => { + const input = document.createElement('input'); + input.type = 'checkbox'; + input.checked = true; + input.value = 'the_checkbox_value'; + + expect(getValueFromElement(input, createStore())) + .toEqual('the_checkbox_value'); + + input.checked = false; + expect(getValueFromElement(input, createStore())) + .toEqual(null); + }); it('Correctly sets data from select multiple', () => { const select = document.createElement('select'); @@ -132,6 +161,24 @@ describe('setValueOnElement', () => { expect(input.checked).toBeFalsy(); }); + it('Checks checkbox with boolean value', () => { + const input = document.createElement('input'); + input.type = 'checkbox'; + input.checked = false; + + setValueOnElement(input, true); + expect(input.checked).toBeTruthy(); + }); + + it('Unchecks checkbox with boolean value', () => { + const input = document.createElement('input'); + input.type = 'checkbox'; + input.checked = true; + + setValueOnElement(input, false); + expect(input.checked).toBeFalsy(); + }); + it('Sets data onto select multiple', () => { const select = document.createElement('select'); select.multiple = true; diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst index 2d30958d253..d890da9437d 100644 --- a/src/LiveComponent/doc/index.rst +++ b/src/LiveComponent/doc/index.rst @@ -487,6 +487,53 @@ changed, added or removed:: Writable path values are dehydrated/hydrated using the same process as the top-level properties (i.e. Symfony's serializer). +Checkboxes, Select Elements Radios & Arrays +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 2.8 + + The ability to use checkboxes to set boolean values was added in LiveComponent 2.8. + +Checkboxes can be used to set a boolean or an array of strings:: + + #[LiveProp(writable: true)] + public bool $agreeToTerms = false; + + #[LiveProp(writable: true)] + public array $foods = ['pizza', 'tacos']; + +In the template, setting a ``value`` attribute on the checkbox will set that +value on checked. If no ``value`` is set, the checkbox will set a boolean value: + +.. code-block:: twig + + + + + + + +``select`` and ``radio`` elements are a bit easier: use these to either set a +single value or an array of values:: + + #[LiveProp(writable: true)] + public string $meal = 'lunch'; + + #[LiveProp(writable: true)] + public array $foods = ['pizza', 'tacos']; + +.. code-block:: twig + + + + + + + Allowing an Entity to be Changed to Another ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/ux.symfony.com/assets/bootstrap.js b/ux.symfony.com/assets/bootstrap.js index e385ca9a320..d8cdaf6e1d1 100644 --- a/ux.symfony.com/assets/bootstrap.js +++ b/ux.symfony.com/assets/bootstrap.js @@ -11,5 +11,6 @@ export const app = startStimulusApp(require.context( app.debug = process.env.NODE_ENV === 'development'; app.register('clipboard', Clipboard); +app.register('live', Live); // register any custom, 3rd party controllers here diff --git a/ux.symfony.com/templates/components/search_packages.html.twig b/ux.symfony.com/templates/components/search_packages.html.twig index 83bc653e4e2..c81c0ade024 100644 --- a/ux.symfony.com/templates/components/search_packages.html.twig +++ b/ux.symfony.com/templates/components/search_packages.html.twig @@ -6,6 +6,8 @@ class="form-control" > + + {% if computed.packages|length > 0 %}
{% for package in computed.packages %}