Skip to content

[Live] Add proper support for boolean checkboxes + fix bug for non-model checkboxes #747

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 1 commit into from
Mar 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions src/LiveComponent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/LiveComponent/assets/dist/dom_utils.d.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
23 changes: 15 additions & 8 deletions src/LiveComponent/assets/dist/live_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
}
Expand Down
29 changes: 20 additions & 9 deletions src/LiveComponent/assets/src/dom_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
53 changes: 50 additions & 3 deletions src/LiveComponent/assets/test/dom_utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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');
Expand Down Expand Up @@ -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;
Expand Down
47 changes: 47 additions & 0 deletions src/LiveComponent/doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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

<input type="checkbox" data-model="agreeToTerms">

<input type="checkbox" data-model="foods" value="pizza">
<input type="checkbox" data-model="foods" value="tacos">
<input type="checkbox" data-model="foods" value="sushi">

``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

<input type="radio" data-model="meal" value="breakfast">
<input type="radio" data-model="meal" value="lunch">
<input type="radio" data-model="meal" value="dinner">

<select data-model="foods" multiple>
<option value="pizza">Pizza</option>
<option value="tacos">Tacos</option>
<option value="sushi">Sushi</option>
</select>

Allowing an Entity to be Changed to Another
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
1 change: 1 addition & 0 deletions ux.symfony.com/assets/bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

2 changes: 2 additions & 0 deletions ux.symfony.com/templates/components/search_packages.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
class="form-control"
>

<input type="checkbox" data-payload="5" value="hi">

{% if computed.packages|length > 0 %}
<div data-loading="addClass(opacity-50)" class="mt-3 row">
{% for package in computed.packages %}
Expand Down