diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index e6154f8a9dd..06c281c0c15 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -898,7 +898,7 @@ 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('.'); @@ -906,6 +906,15 @@ function setDeepData(data, propertyPath, value) { 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?`); @@ -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) { @@ -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() { @@ -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; @@ -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); @@ -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); diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index e66e8f6c8ea..9bda5781207 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -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, @@ -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) { @@ -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) { @@ -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; + } + + this.$updateModel(model, value, shouldRender, element.hasAttribute('name') ? element.getAttribute('name') : null, {}); } /** @@ -257,7 +271,7 @@ export default class extends Controller { this._dispatchEvent('live:update-model', { modelName, extraModelName: normalizedExtraModelName, - value, + value }); } diff --git a/src/LiveComponent/assets/src/set_deep_data.ts b/src/LiveComponent/assets/src/set_deep_data.ts index 9d6cd404058..36d18187895 100644 --- a/src/LiveComponent/assets/src/set_deep_data.ts +++ b/src/LiveComponent/assets/src/set_deep_data.ts @@ -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; @@ -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 @@ -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(/\[]$/, '') .split('[') // ['object', 'foo', 'bar', 'ya'] .map(function (s) { return s.replace(']', '') - }).join('.') + }) + .join('.') } diff --git a/src/LiveComponent/assets/src/update_array_data.ts b/src/LiveComponent/assets/src/update_array_data.ts new file mode 100644 index 00000000000..92e140cc1e9 --- /dev/null +++ b/src/LiveComponent/assets/src/update_array_data.ts @@ -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 { + // 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)`); +} diff --git a/src/LiveComponent/assets/test/controller/model.test.ts b/src/LiveComponent/assets/test/controller/model.test.ts index 937df1b10b3..6d501bf49f6 100644 --- a/src/LiveComponent/assets/test/controller/model.test.ts +++ b/src/LiveComponent/assets/test/controller/model.test.ts @@ -11,7 +11,7 @@ import { clearDOM } from '@symfony/stimulus-testing'; import { initLiveComponent, mockRerender, startStimulus } from '../tools'; -import {getByLabelText, getByText, waitFor} from '@testing-library/dom'; +import { getByLabelText, getByText, waitFor } from '@testing-library/dom'; import userEvent from '@testing-library/user-event'; import fetchMock from 'fetch-mock-jest'; @@ -200,6 +200,222 @@ describe('LiveController data-model Tests', () => { expect(controller.dataValue).toEqual({ user: { firstName: 'Ryan Weaver' } }); }); + it('sends correct data for checkbox fields', async () => { + const checkboxTemplate = (data: any) => ` +
+ + + + + Checkbox 2 is ${data.form.check2 ? 'checked' : 'unchecked' } +
+ `; + const data = { form: { check1: false, check2: false} }; + const { element, controller } = await startStimulus(checkboxTemplate(data)); + + const check1Element = getByLabelText(element, 'Checkbox 1:'); + const check2Element = getByLabelText(element, 'Checkbox 2:'); + + // no mockRerender needed... not sure why. This first Ajax call is likely + // interrupted by the next immediately starting + await userEvent.click(check1Element); + + mockRerender({ form: {check1: '1', check2: '1'}}, checkboxTemplate); + + await userEvent.click(check2Element); + await waitFor(() => expect(element).toHaveTextContent('Checkbox 2 is checked')); + + expect(controller.dataValue).toEqual({form: {check1: '1', check2: '1'}}); + }); + + it('sends correct data for initially checked checkbox fields', async () => { + const checkboxTemplate = (data: any) => ` +
+ + + + + Checkbox 1 is ${data.form.check1 ? 'checked' : 'unchecked' } +
+ `; + const data = { form: { check1: '1', check2: false} }; + const { element, controller } = await startStimulus(checkboxTemplate(data)); + + const check1Element = getByLabelText(element, 'Checkbox 1:'); + const check2Element = getByLabelText(element, 'Checkbox 2:'); + + // no mockRerender needed... not sure why. This first Ajax call is likely + // interrupted by the next immediately starting + await userEvent.click(check2Element); + + mockRerender({ form: {check1: null, check2: '1'}}, checkboxTemplate); + + await userEvent.click(check1Element); + await waitFor(() => expect(element).toHaveTextContent('Checkbox 1 is unchecked')); + + expect(controller.dataValue).toEqual({form: {check1: null, check2: '1'}}); + }); + + it('sends correct data for array valued checkbox fields', async () => { + const checkboxTemplate = (data: any) => ` +
+ + + + + Checkbox 2 is ${data.form.check.indexOf('bar') > -1 ? 'checked' : 'unchecked' } +
+ `; + const data = { form: { check: []} }; + const { element, controller } = await startStimulus(checkboxTemplate(data)); + + const check1Element = getByLabelText(element, 'Checkbox 1:'); + const check2Element = getByLabelText(element, 'Checkbox 2:'); + + // no mockRerender needed... not sure why. This first Ajax call is likely + // interrupted by the next immediately starting + await userEvent.click(check1Element); + + mockRerender({ form: {check: ['foo', 'bar']}}, checkboxTemplate); + + await userEvent.click(check2Element); + await waitFor(() => expect(element).toHaveTextContent('Checkbox 2 is checked')); + + expect(controller.dataValue).toEqual({form: {check: ['foo', 'bar']}}); + }); + + it('sends correct data for array valued checkbox fields with initial data', async () => { + const checkboxTemplate = (data: any) => ` +
+ + + + + Checkbox 1 is ${data.form.check.indexOf('foo') > -1 ? 'checked' : 'unchecked' } +
+ `; + const data = { form: { check: ['foo']} }; + const { element, controller } = await startStimulus(checkboxTemplate(data)); + + const check1Element = getByLabelText(element, 'Checkbox 1:'); + const check2Element = getByLabelText(element, 'Checkbox 2:'); + + // no mockRerender needed... not sure why. This first Ajax call is likely + // interrupted by the next immediately starting + await userEvent.click(check2Element); + + mockRerender({ form: {check: ['bar']}}, checkboxTemplate); + + await userEvent.click(check1Element); + await waitFor(() => expect(element).toHaveTextContent('Checkbox 1 is unchecked')); + + expect(controller.dataValue).toEqual({form: {check: ['bar']}}); + }); + + it('sends correct data for select multiple field', async () => { + const checkboxTemplate = (data: any) => ` +
+ + + Option 2 is ${data.form.select?.indexOf('bar') > -1 ? 'selected' : 'unselected' } +
+ `; + const data = { form: { select: []} }; + const { element, controller } = await startStimulus(checkboxTemplate(data)); + + const selectElement = getByLabelText(element, 'Select:'); + + // no mockRerender needed... not sure why. This first Ajax call is likely + // interrupted by the next immediately starting + await userEvent.selectOptions(selectElement, 'foo'); + + mockRerender({ form: {select: ['foo', 'bar']}}, checkboxTemplate); + + await userEvent.selectOptions(selectElement, 'bar'); + + await waitFor(() => expect(element).toHaveTextContent('Select: foo bar Option 2 is selected')); + + expect(controller.dataValue).toEqual({form: {select: ['foo', 'bar']}}); + }); + + it('sends correct data for select multiple field with initial data', async () => { + const checkboxTemplate = (data: any) => ` +
+ + + Option 2 is ${data.form.select?.indexOf('bar') > -1 ? 'selected' : 'unselected' } +
+ `; + const data = { form: { select: ['foo']} }; + const { element, controller } = await startStimulus(checkboxTemplate(data)); + + const selectElement = getByLabelText(element, 'Select:'); + + // no mockRerender needed... not sure why. This first Ajax call is likely + // interrupted by the next immediately starting + await userEvent.selectOptions(selectElement, 'bar'); + + mockRerender({ form: {select: ['bar']}}, checkboxTemplate); + + await userEvent.deselectOptions(selectElement, 'foo'); + + await waitFor(() => expect(element).toHaveTextContent('Select: foo bar Option 2 is selected')); + + mockRerender({ form: {select: []}}, checkboxTemplate); + + await userEvent.deselectOptions(selectElement, 'bar'); + + await waitFor(() => expect(element).toHaveTextContent('Select: foo bar Option 2 is unselected')); + + expect(controller.dataValue).toEqual({form: {select: []}}); + }); + it('updates correctly when live#update is on a parent element', async () => { const parentUpdateTemplate = (data) => `
{ it('can normalize a string with []', () => { expect(normalizeModelName('user[firstName]')).toEqual('user.firstName'); }); + + it('can normalize a string ending in []', () => { + expect(normalizeModelName('user[mailing][]')).toEqual('user.mailing'); + }); }); diff --git a/src/LiveComponent/assets/test/update_array_data.test.ts b/src/LiveComponent/assets/test/update_array_data.test.ts new file mode 100644 index 00000000000..ef202f7d16e --- /dev/null +++ b/src/LiveComponent/assets/test/update_array_data.test.ts @@ -0,0 +1,61 @@ +import {updateArrayDataFromChangedElement} from "../src/update_array_data"; + + +describe('getArrayValue', () => { + it('Correctly adds data from checkbox', () => { + const input = document.createElement('input'); + input.type = 'checkbox'; + input.checked = true; + + expect(updateArrayDataFromChangedElement(input, 'foo', null)) + .toEqual(['foo']); + expect(updateArrayDataFromChangedElement(input, 'foo', [])) + .toEqual(['foo']); + expect(updateArrayDataFromChangedElement(input, 'foo', ['bar'])) + .toEqual(['bar', 'foo']); + }) + + it('Correctly removes data from checkbox', () => { + const input = document.createElement('input'); + input.type = 'checkbox'; + input.checked = false; + + expect(updateArrayDataFromChangedElement(input, 'foo', null)) + .toEqual([]); + expect(updateArrayDataFromChangedElement(input, 'foo', ['foo'])) + .toEqual([]); + expect(updateArrayDataFromChangedElement(input, 'foo', ['bar'])) + .toEqual(['bar']); + expect(updateArrayDataFromChangedElement(input, 'foo', ['foo', 'bar'])) + .toEqual(['bar']); + }) + + it('Correctly sets data from select multiple', () => { + const select = document.createElement('select'); + select.multiple = true; + const fooOption = document.createElement('option'); + fooOption.value = 'foo'; + select.add(fooOption); + const barOption = document.createElement('option'); + barOption.value = 'bar'; + select.add(barOption); + + expect(updateArrayDataFromChangedElement(select, '', null)) + .toEqual([]); + + fooOption.selected = true; + expect(updateArrayDataFromChangedElement(select, '', null)) + .toEqual(['foo']); + + barOption.selected = true; + expect(updateArrayDataFromChangedElement(select, '', null)) + .toEqual(['foo', 'bar']); + }) + + it('Throws on unsupported elements', () => { + const div = document.createElement('div'); + + expect(() => updateArrayDataFromChangedElement(div, '', null)) + .toThrowError('The element used to determine array data from is unsupported (DIV provided)') + }); +}); diff --git a/src/LiveComponent/src/ComponentWithFormTrait.php b/src/LiveComponent/src/ComponentWithFormTrait.php index 0e23ee2cbf1..d1c5557bc7f 100644 --- a/src/LiveComponent/src/ComponentWithFormTrait.php +++ b/src/LiveComponent/src/ComponentWithFormTrait.php @@ -176,6 +176,7 @@ private function submitForm(bool $validateAll = true): void private function extractFormValues(FormView $formView): array { $values = []; + foreach ($formView->children as $child) { $name = $child->vars['name']; diff --git a/src/LiveComponent/tests/Fixtures/Component/FormComponentWithManyDifferentFieldsType.php b/src/LiveComponent/tests/Fixtures/Component/FormComponentWithManyDifferentFieldsType.php new file mode 100644 index 00000000000..71f11b0e2c5 --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/Component/FormComponentWithManyDifferentFieldsType.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component; + +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\Form\FormFactoryInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; +use Symfony\UX\LiveComponent\ComponentWithFormTrait; +use Symfony\UX\LiveComponent\DefaultActionTrait; +use Symfony\UX\LiveComponent\Tests\Fixtures\Form\FormWithManyDifferentFieldsType; + +/** + * @author Jakub Caban + */ +#[AsLiveComponent('form_with_many_different_fields_type')] +class FormComponentWithManyDifferentFieldsType extends AbstractController +{ + use ComponentWithFormTrait; + use DefaultActionTrait; + + public array $initialData = []; + + public function __construct(private FormFactoryInterface $formFactory) + { + } + + protected function instantiateForm(): FormInterface + { + return $this->formFactory->createNamed( + 'form', + FormWithManyDifferentFieldsType::class, + $this->initialData + ); + } +} diff --git a/src/LiveComponent/tests/Fixtures/Form/FormWithManyDifferentFieldsType.php b/src/LiveComponent/tests/Fixtures/Form/FormWithManyDifferentFieldsType.php new file mode 100644 index 00000000000..c0ec8624510 --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/Form/FormWithManyDifferentFieldsType.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\UX\LiveComponent\Tests\Fixtures\Form; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\FileType; +use Symfony\Component\Form\Extension\Core\Type\HiddenType; +use Symfony\Component\Form\Extension\Core\Type\RangeType; +use Symfony\Component\Form\Extension\Core\Type\TextareaType; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * @author Jakub Caban + */ +class FormWithManyDifferentFieldsType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder + ->add('text', TextType::class) + ->add('textarea', TextareaType::class) + ->add('range', RangeType::class) + ->add('choice', ChoiceType::class, [ + 'choices' => [ + 'foo' => 1, + 'bar' => 2, + ], + ]) + ->add('choice_expanded', ChoiceType::class, [ + 'choices' => [ + 'foo' => 1, + 'bar' => 2, + ], + 'expanded' => true, + ]) + ->add('choice_multiple', ChoiceType::class, [ + 'choices' => [ + 'foo' => 1, + 'bar' => 2, + ], + 'expanded' => true, + 'multiple' => true, + ]) + ->add('select_multiple', ChoiceType::class, [ + 'choices' => [ + 'foo' => 1, + 'bar' => 2, + ], + 'multiple' => true, + ]) + ->add('checkbox', CheckboxType::class) + ->add('checkbox_checked', CheckboxType::class) + ->add('file', FileType::class) + ->add('hidden', HiddenType::class) + ; + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'csrf_protection' => false, + ]); + } +} diff --git a/src/LiveComponent/tests/Fixtures/Kernel.php b/src/LiveComponent/tests/Fixtures/Kernel.php index cc1be180a41..e645cfea51c 100644 --- a/src/LiveComponent/tests/Fixtures/Kernel.php +++ b/src/LiveComponent/tests/Fixtures/Kernel.php @@ -26,6 +26,7 @@ use Symfony\UX\LiveComponent\Tests\Fixtures\Component\Component2; use Symfony\UX\LiveComponent\Tests\Fixtures\Component\Component3; use Symfony\UX\LiveComponent\Tests\Fixtures\Component\Component6; +use Symfony\UX\LiveComponent\Tests\Fixtures\Component\FormComponentWithManyDifferentFieldsType; use Symfony\UX\LiveComponent\Tests\Fixtures\Component\ComponentWithAttributes; use Symfony\UX\LiveComponent\Tests\Fixtures\Component\FormWithCollectionTypeComponent; use Symfony\UX\TwigComponent\TwigComponentBundle; @@ -69,6 +70,7 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load $c->register(Component3::class)->setAutoconfigured(true)->setAutowired(true); $c->register(Component6::class)->setAutoconfigured(true)->setAutowired(true); $c->register(ComponentWithAttributes::class)->setAutoconfigured(true)->setAutowired(true); + $c->register(FormComponentWithManyDifferentFieldsType::class)->setAutoconfigured(true)->setAutowired(true); $c->register(FormWithCollectionTypeComponent::class)->setAutoconfigured(true)->setAutowired(true); $c->loadFromExtension('framework', [ diff --git a/src/LiveComponent/tests/Fixtures/templates/components/form_with_many_different_fields_type.html.twig b/src/LiveComponent/tests/Fixtures/templates/components/form_with_many_different_fields_type.html.twig new file mode 100644 index 00000000000..0e5e12d68f8 --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/templates/components/form_with_many_different_fields_type.html.twig @@ -0,0 +1,3 @@ +
+ {{ form(this.form) }} +
diff --git a/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php b/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php index 7be4c894edf..e66901c1c56 100644 --- a/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php +++ b/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php @@ -146,4 +146,107 @@ public function testFormRemembersValidationFromInitialForm(): void ->assertContains('The title field should not be blank') ; } + + public function testHandleCheckboxChanges(): void + { + /** @var LiveComponentHydrator $hydrator */ + $hydrator = self::getContainer()->get('ux.live_component.component_hydrator'); + + /** @var ComponentFactory $factory */ + $factory = self::getContainer()->get('ux.twig_component.component_factory'); + + $mounted = $factory->create( + 'form_with_many_different_fields_type', + [ + 'initialData' => [ + 'choice_multiple' => [2], + 'select_multiple' => [2], + 'checkbox_checked' => true, + ], + ] + ); + + $dehydrated = $hydrator->dehydrate($mounted); + $bareForm = [ + 'text' => '', + 'textarea' => '', + 'range' => '', + 'choice' => '', + 'choice_expanded' => '', + 'choice_multiple' => ['2'], + 'select_multiple' => ['2'], + 'checkbox' => null, + 'checkbox_checked' => '1', + 'file' => '', + 'hidden' => '', + ]; + + $this->browser() + ->throwExceptions() + ->get('/_components/form_with_many_different_fields_type?data='.urlencode(json_encode($dehydrated))) + ->assertSuccessful() + ->assertContains('') + ->assertContains('') + ->assertContains('') + ->assertContains('') + ->use(function (HtmlResponse $response) use (&$dehydrated, &$bareForm) { + $data = json_decode( + $response->crawler()->filter('div')->first()->attr('data-live-data-value'), + true + ); + self::assertEquals($bareForm, $data['form']); + + // check both multiple fields + $bareForm['choice_multiple'] = ['1', '2']; + + $dehydrated['form'] = $bareForm; + }) + ->get('/_components/form_with_many_different_fields_type?data='.urlencode(json_encode($dehydrated))) + ->assertContains('') + ->assertContains('') + ->use(function (HtmlResponse $response) use (&$dehydrated, &$bareForm) { + $data = json_decode( + $response->crawler()->filter('div')->first()->attr('data-live-data-value'), + true + ); + self::assertEquals($bareForm, $data['form']); + + // uncheck multiple, check single checkbox + $bareForm['checkbox'] = '1'; + $bareForm['choice_multiple'] = []; + + // uncheck previously checked checkbox + $bareForm['checkbox_checked'] = null; + + $dehydrated['form'] = $bareForm; + }) + ->get('/_components/form_with_many_different_fields_type?data='.urlencode(json_encode($dehydrated))) + ->assertContains('') + ->assertContains('') + ->assertContains('') + ->assertContains('') + ->use(function (HtmlResponse $response) use (&$dehydrated, &$bareForm) { + $data = json_decode( + $response->crawler()->filter('div')->first()->attr('data-live-data-value'), + true + ); + self::assertEquals($bareForm, $data['form']); + + // check both multiple fields + $bareForm['select_multiple'] = ['2', '1']; + + $dehydrated['form'] = $bareForm; + }) + ->get('/_components/form_with_many_different_fields_type?data='.urlencode(json_encode($dehydrated))) + ->assertContains('