Skip to content
Open
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
29 changes: 29 additions & 0 deletions src/LiveComponent/assets/test/unit/controller/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,35 @@ describe('LiveController data-model Tests', () => {
expect(test.component.valueStore.getOriginalProps()).toEqual({ form: { check1: null, check2: '1' } });
});

it('syncs a checkbox the server disabled and cleared: unchecked and disabled', async () => {
const test = await createTest(
{ check1: '1', isDisabled: false },
(data: any) => `
<div ${initComponent(data)}>
<label>
Checkbox 1: <input type="checkbox" data-model="check1" value="1" ${data.check1 ? 'checked' : ''} ${data.isDisabled ? 'disabled' : ''} />
</label>
</div>
`
);

const check1Element = getByLabelText(test.element, 'Checkbox 1:') as HTMLInputElement;
expect(check1Element.checked).toBe(true);
expect(check1Element.disabled).toBe(false);

// the server disables the checkbox and clears its data
test.expectsAjaxCall().serverWillChangeProps((data: any) => {
data.check1 = null;
data.isDisabled = true;
});

await test.component.render();

expect(check1Element.checked).toBe(false);
expect(check1Element.disabled).toBe(true);
expect(test.component.valueStore.getOriginalProps()).toEqual({ check1: null, isDisabled: true });
});

it('sends correct data for array valued checkbox fields', async () => {
const test = await createTest(
{ form: { check: [] } },
Expand Down
38 changes: 36 additions & 2 deletions src/LiveComponent/src/ComponentWithFormTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -265,9 +265,33 @@ private function extractFormValues(FormView $formView): array
continue;
}

// <input type="checkbox">
// <input type="checkbox"> - Simulate browser behavior
// Browsers never submit disabled controls, so a checked but
// disabled checkbox is treated as unchecked.
if (\array_key_exists('checked', $child->vars)) {
$values[$name] = $child->vars['checked'] ? $child->vars['value'] : null;
$values[$name] = $child->vars['checked'] && !self::isFieldDisabled($child) ? $child->vars['value'] : null;
continue;
}

// Expanded ChoiceType - Simulate browser behavior
// The "value" already aggregates the checked checkboxes/radios,
// but browsers never submit disabled controls, so the values of
// disabled choices are dropped.
if ($child->vars['expanded'] ?? false) {
$disabledValues = [];
foreach ($child->children as $expandedChild) {
if (self::isFieldDisabled($expandedChild)) {
$disabledValues[] = $expandedChild->vars['value'];
}
}

$value = $child->vars['value'];
if ($disabledValues) {
$value = \is_array($value)
? array_values(array_diff($value, $disabledValues))
: (\in_array($value, $disabledValues, true) ? '' : $value);
}
$values[$name] = $value;
continue;
}

Expand Down Expand Up @@ -308,6 +332,16 @@ private function extractFormValues(FormView $formView): array
return $values;
}

/**
* A field can be disabled at the form level (the "disabled" option) or
* only in HTML (a "disabled" attribute, e.g. set through "choice_attr"):
* browsers do not submit the control in either case.
*/
private static function isFieldDisabled(FormView $view): bool
{
return ($view->vars['disabled'] ?? false) || ($view->vars['attr']['disabled'] ?? false);
}

private function clearErrorsForNonValidatedFields(FormInterface $form, string $currentPath = ''): void
{
if ($form instanceof ClearableErrorsInterface && (!$currentPath || !\in_array($currentPath, $this->validatedFields, true))) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,23 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
'expanded' => true,
'multiple' => true,
])
->add('choice_multiple_disabled', ChoiceType::class, [
'choices' => [
'foo' => 1,
'bar' => 2,
],
'expanded' => true,
'multiple' => true,
'choice_attr' => static fn ($choice) => 1 === $choice ? ['disabled' => true] : [],
])
->add('choice_expanded_disabled', ChoiceType::class, [
'choices' => [
'foo' => 1,
'bar' => 2,
],
'expanded' => true,
'choice_attr' => static fn ($choice) => 1 === $choice ? ['disabled' => true] : [],
])
->add('select_multiple', ChoiceType::class, [
'choices' => [
'foo' => 1,
Expand All @@ -134,6 +151,9 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
])
->add('checkbox', CheckboxType::class)
->add('checkbox_checked', CheckboxType::class)
->add('checkbox_checked_disabled', CheckboxType::class, [
'disabled' => true,
])
->add('file', FileType::class)
->add('hidden', HiddenType::class)
->add('complexType', ComplexFieldType::class)
Expand Down
45 changes: 45 additions & 0 deletions src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -192,10 +192,13 @@ public function testHandleCheckboxChanges()
'choice_required_with_empty_preferred_choices' => 'ok',
'choice_expanded' => '',
'choice_multiple' => ['2'],
'choice_multiple_disabled' => [],
'choice_expanded_disabled' => '',
'select_multiple' => ['2'],
'entity' => (string) $id,
'checkbox' => null,
'checkbox_checked' => '1',
'checkbox_checked_disabled' => null,
'file' => '',
'hidden' => '',
'complexType' => [
Expand Down Expand Up @@ -294,6 +297,48 @@ public function testHandleCheckboxChanges()
;
}

public function testDisabledChoicesAreNeverSubmitted()
{
CategoryFixtureEntityFactory::createMany(5);

$mounted = $this->mountComponent(
'form_with_many_different_fields_type',
[
'initialData' => [
// "foo" (value 1) is disabled through "choice_attr"
'choice_multiple_disabled' => [1, 2],
],
]
);

$dehydratedProps = $this->dehydrateComponent($mounted)->getProps();

// like a browser, the checked but disabled choice is not part of the
// values that would be submitted
$this->assertSame(['2'], $dehydratedProps['form']['choice_multiple_disabled']);

// a model update must not resurrect the disabled value either
$crawler = $this->browser()
->throwExceptions()
->post('/_components/form_with_many_different_fields_type', [
'body' => [
'data' => json_encode([
'props' => $dehydratedProps,
'updated' => ['form' => ['choice_multiple_disabled' => []]],
]),
],
])
->assertSuccessful()
->crawler()
;

$dehydratedProps = json_decode(
$crawler->filter('div')->first()->attr('data-live-props-value'),
true
);
$this->assertSame([], $dehydratedProps['form']['choice_multiple_disabled']);
}

public function testLiveCollectionTypeAddButtonsByDefault()
{
$dehydrated = $this->dehydrateComponent($this->mountComponent('form_with_live_collection_type'))->getProps();
Expand Down
8 changes: 8 additions & 0 deletions src/LiveComponent/tests/Unit/Form/ComponentWithFormTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,11 @@ public function testFormValues()
$component = new FormComponentWithManyDifferentFieldsType($formFactory);
$component->initialData = [
'choice_multiple' => [2],
'choice_multiple_disabled' => [1, 2],
'choice_expanded_disabled' => 1,
'select_multiple' => [2],
'checkbox_checked' => true,
'checkbox_checked_disabled' => true,
];
$component->initializeForm([]);

Expand All @@ -54,10 +57,15 @@ public function testFormValues()
'choice_required_with_empty_preferred_choices' => 'ok',
'choice_expanded' => '',
'choice_multiple' => ['2'],
// disabled choices are never submitted by browsers, so their
// values must not appear here even when initially selected
'choice_multiple_disabled' => ['2'],
'choice_expanded_disabled' => '',
'select_multiple' => ['2'],
'entity' => (string) $id,
'checkbox' => null,
'checkbox_checked' => '1',
'checkbox_checked_disabled' => null,
'file' => '',
'hidden' => '',
'complexType' => [
Expand Down
Loading