Skip to content

Commit 64b30ef

Browse files
committedAug 23, 2024
add LazyChoiceLoader and choice_lazy option
1 parent e45d22e commit 64b30ef

File tree

7 files changed

+249
-16
lines changed

7 files changed

+249
-16
lines changed
 

‎CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ CHANGELOG
66

77
* Deprecate the `VersionAwareTest` trait, use feature detection instead
88
* Add support for the `calendar` option in `DateType`
9+
* Add `LazyChoiceLoader` and `choice_lazy` option in `ChoiceType` for loading and rendering choices on demand
910

1011
7.1
1112
---
+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Form\ChoiceList\Loader;
13+
14+
use Symfony\Component\Form\ChoiceList\ArrayChoiceList;
15+
use Symfony\Component\Form\ChoiceList\ChoiceListInterface;
16+
17+
/**
18+
* A choice loader that loads its choices and values lazily, only when necessary.
19+
*
20+
* @author Yonel Ceruto <yonelceruto@gmail.com>
21+
*/
22+
class LazyChoiceLoader implements ChoiceLoaderInterface
23+
{
24+
private ?ChoiceListInterface $choiceList = null;
25+
26+
public function __construct(
27+
private readonly ChoiceLoaderInterface $loader,
28+
) {
29+
}
30+
31+
public function loadChoiceList(?callable $value = null): ChoiceListInterface
32+
{
33+
return $this->choiceList ??= new ArrayChoiceList([], $value);
34+
}
35+
36+
public function loadChoicesForValues(array $values, ?callable $value = null): array
37+
{
38+
$choices = $this->loader->loadChoicesForValues($values, $value);
39+
$this->choiceList = new ArrayChoiceList($choices, $value);
40+
41+
return $choices;
42+
}
43+
44+
public function loadValuesForChoices(array $choices, ?callable $value = null): array
45+
{
46+
$values = $this->loader->loadValuesForChoices($choices, $value);
47+
48+
if ($this->choiceList?->getValuesForChoices($choices) !== $values) {
49+
$this->loadChoicesForValues($values, $value);
50+
}
51+
52+
return $values;
53+
}
54+
}

‎Extension/Core/Type/ChoiceType.php

+19
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,12 @@
2727
use Symfony\Component\Form\ChoiceList\Factory\DefaultChoiceListFactory;
2828
use Symfony\Component\Form\ChoiceList\Factory\PropertyAccessDecorator;
2929
use Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface;
30+
use Symfony\Component\Form\ChoiceList\Loader\LazyChoiceLoader;
3031
use Symfony\Component\Form\ChoiceList\View\ChoiceGroupView;
3132
use Symfony\Component\Form\ChoiceList\View\ChoiceListView;
3233
use Symfony\Component\Form\ChoiceList\View\ChoiceView;
3334
use Symfony\Component\Form\Event\PreSubmitEvent;
35+
use Symfony\Component\Form\Exception\LogicException;
3436
use Symfony\Component\Form\Exception\TransformationFailedException;
3537
use Symfony\Component\Form\Extension\Core\DataMapper\CheckboxListMapper;
3638
use Symfony\Component\Form\Extension\Core\DataMapper\RadioListMapper;
@@ -333,11 +335,24 @@ public function configureOptions(OptionsResolver $resolver): void
333335
return $choiceTranslationDomain;
334336
};
335337

338+
$choiceLoaderNormalizer = static function (Options $options, ?ChoiceLoaderInterface $choiceLoader) {
339+
if (!$options['choice_lazy']) {
340+
return $choiceLoader;
341+
}
342+
343+
if (null === $choiceLoader) {
344+
throw new LogicException('The "choice_lazy" option can only be used if the "choice_loader" option is set.');
345+
}
346+
347+
return new LazyChoiceLoader($choiceLoader);
348+
};
349+
336350
$resolver->setDefaults([
337351
'multiple' => false,
338352
'expanded' => false,
339353
'choices' => [],
340354
'choice_filter' => null,
355+
'choice_lazy' => false,
341356
'choice_loader' => null,
342357
'choice_label' => null,
343358
'choice_name' => null,
@@ -365,9 +380,11 @@ public function configureOptions(OptionsResolver $resolver): void
365380

366381
$resolver->setNormalizer('placeholder', $placeholderNormalizer);
367382
$resolver->setNormalizer('choice_translation_domain', $choiceTranslationDomainNormalizer);
383+
$resolver->setNormalizer('choice_loader', $choiceLoaderNormalizer);
368384

369385
$resolver->setAllowedTypes('choices', ['null', 'array', \Traversable::class]);
370386
$resolver->setAllowedTypes('choice_translation_domain', ['null', 'bool', 'string']);
387+
$resolver->setAllowedTypes('choice_lazy', 'bool');
371388
$resolver->setAllowedTypes('choice_loader', ['null', ChoiceLoaderInterface::class, ChoiceLoader::class]);
372389
$resolver->setAllowedTypes('choice_filter', ['null', 'callable', 'string', PropertyPath::class, ChoiceFilter::class]);
373390
$resolver->setAllowedTypes('choice_label', ['null', 'bool', 'callable', 'string', PropertyPath::class, ChoiceLabel::class]);
@@ -381,6 +398,8 @@ public function configureOptions(OptionsResolver $resolver): void
381398
$resolver->setAllowedTypes('separator_html', ['bool']);
382399
$resolver->setAllowedTypes('duplicate_preferred_choices', 'bool');
383400
$resolver->setAllowedTypes('group_by', ['null', 'callable', 'string', PropertyPath::class, GroupBy::class]);
401+
402+
$resolver->setInfo('choice_lazy', 'Load choices on demand. When set to true, only the selected choices are loaded and rendered.');
384403
}
385404

386405
public function getBlockPrefix(): string
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Form\Tests\ChoiceList\Loader;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\Form\ChoiceList\Loader\LazyChoiceLoader;
16+
use Symfony\Component\Form\Tests\Fixtures\ArrayChoiceLoader;
17+
18+
class LazyChoiceLoaderTest extends TestCase
19+
{
20+
private LazyChoiceLoader $loader;
21+
22+
protected function setUp(): void
23+
{
24+
$this->loader = new LazyChoiceLoader(new ArrayChoiceLoader(['A', 'B', 'C']));
25+
}
26+
27+
public function testInitialEmptyChoiceListLoading()
28+
{
29+
$this->assertSame([], $this->loader->loadChoiceList()->getChoices());
30+
}
31+
32+
public function testOnDemandChoiceListAfterLoadingValuesForChoices()
33+
{
34+
$this->loader->loadValuesForChoices(['A']);
35+
$this->assertSame(['A' => 'A'], $this->loader->loadChoiceList()->getChoices());
36+
}
37+
38+
public function testOnDemandChoiceListAfterLoadingChoicesForValues()
39+
{
40+
$this->loader->loadChoicesForValues(['B']);
41+
$this->assertSame(['B' => 'B'], $this->loader->loadChoiceList()->getChoices());
42+
}
43+
44+
public function testOnDemandChoiceList()
45+
{
46+
$this->loader->loadValuesForChoices(['A']);
47+
$this->loader->loadChoicesForValues(['B']);
48+
$this->assertSame(['B' => 'B'], $this->loader->loadChoiceList()->getChoices());
49+
}
50+
}

‎Tests/Extension/Core/Type/ChoiceTypeTest.php

+108
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Symfony\Component\Form\ChoiceList\Loader\CallbackChoiceLoader;
1515
use Symfony\Component\Form\ChoiceList\View\ChoiceGroupView;
1616
use Symfony\Component\Form\ChoiceList\View\ChoiceView;
17+
use Symfony\Component\Form\Exception\LogicException;
1718
use Symfony\Component\Form\Exception\TransformationFailedException;
1819
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
1920
use Symfony\Component\Form\FormInterface;
@@ -2277,4 +2278,111 @@ public function testWithSameLoaderAndDifferentChoiceValueCallbacks()
22772278
$this->assertSame('20', $view['choice_two']->vars['choices'][1]->value);
22782279
$this->assertSame('30', $view['choice_two']->vars['choices'][2]->value);
22792280
}
2281+
2282+
public function testChoiceLazyThrowsWhenChoiceLoaderIsNotSet()
2283+
{
2284+
$this->expectException(LogicException::class);
2285+
$this->expectExceptionMessage('The "choice_lazy" option can only be used if the "choice_loader" option is set.');
2286+
2287+
$this->factory->create(static::TESTED_TYPE, null, [
2288+
'choice_lazy' => true,
2289+
]);
2290+
}
2291+
2292+
public function testChoiceLazyLoadsAndRendersNothingWhenNoDataSet()
2293+
{
2294+
$form = $this->factory->create(static::TESTED_TYPE, null, [
2295+
'choice_loader' => new CallbackChoiceLoader(fn () => ['a' => 'A', 'b' => 'B']),
2296+
'choice_lazy' => true,
2297+
]);
2298+
2299+
$this->assertNull($form->getData());
2300+
2301+
$view = $form->createView();
2302+
$this->assertArrayHasKey('choices', $view->vars);
2303+
$this->assertSame([], $view->vars['choices']);
2304+
}
2305+
2306+
public function testChoiceLazyLoadsAndRendersOnlyDataSetViaDefault()
2307+
{
2308+
$form = $this->factory->create(static::TESTED_TYPE, 'A', [
2309+
'choice_loader' => new CallbackChoiceLoader(fn () => ['a' => 'A', 'b' => 'B']),
2310+
'choice_lazy' => true,
2311+
]);
2312+
2313+
$this->assertSame('A', $form->getData());
2314+
2315+
$view = $form->createView();
2316+
$this->assertArrayHasKey('choices', $view->vars);
2317+
$this->assertCount(1, $view->vars['choices']);
2318+
$this->assertSame('A', $view->vars['choices'][0]->value);
2319+
}
2320+
2321+
public function testChoiceLazyLoadsAndRendersOnlyDataSetViaSubmit()
2322+
{
2323+
$form = $this->factory->create(static::TESTED_TYPE, null, [
2324+
'choice_loader' => new CallbackChoiceLoader(fn () => ['a' => 'A', 'b' => 'B']),
2325+
'choice_lazy' => true,
2326+
]);
2327+
2328+
$form->submit('B');
2329+
$this->assertSame('B', $form->getData());
2330+
2331+
$view = $form->createView();
2332+
$this->assertArrayHasKey('choices', $view->vars);
2333+
$this->assertCount(1, $view->vars['choices']);
2334+
$this->assertSame('B', $view->vars['choices'][0]->value);
2335+
}
2336+
2337+
public function testChoiceLazyErrorWhenInvalidSubmitData()
2338+
{
2339+
$form = $this->factory->create(static::TESTED_TYPE, null, [
2340+
'choice_loader' => new CallbackChoiceLoader(fn () => ['a' => 'A', 'b' => 'B']),
2341+
'choice_lazy' => true,
2342+
]);
2343+
2344+
$form->submit('invalid');
2345+
$this->assertNull($form->getData());
2346+
2347+
$view = $form->createView();
2348+
$this->assertArrayHasKey('choices', $view->vars);
2349+
$this->assertCount(0, $view->vars['choices']);
2350+
$this->assertCount(1, $form->getErrors());
2351+
$this->assertSame('ERROR: The selected choice is invalid.', trim((string) $form->getErrors()));
2352+
}
2353+
2354+
public function testChoiceLazyMultipleWithDefaultData()
2355+
{
2356+
$form = $this->factory->create(static::TESTED_TYPE, ['A', 'B'], [
2357+
'choice_loader' => new CallbackChoiceLoader(fn () => ['a' => 'A', 'b' => 'B', 'c' => 'C']),
2358+
'choice_lazy' => true,
2359+
'multiple' => true,
2360+
]);
2361+
2362+
$this->assertSame(['A', 'B'], $form->getData());
2363+
2364+
$view = $form->createView();
2365+
$this->assertArrayHasKey('choices', $view->vars);
2366+
$this->assertCount(2, $view->vars['choices']);
2367+
$this->assertSame('A', $view->vars['choices'][0]->value);
2368+
$this->assertSame('B', $view->vars['choices'][1]->value);
2369+
}
2370+
2371+
public function testChoiceLazyMultipleWithSubmittedData()
2372+
{
2373+
$form = $this->factory->create(static::TESTED_TYPE, null, [
2374+
'choice_loader' => new CallbackChoiceLoader(fn () => ['a' => 'A', 'b' => 'B', 'c' => 'C']),
2375+
'choice_lazy' => true,
2376+
'multiple' => true,
2377+
]);
2378+
2379+
$form->submit(['B', 'C']);
2380+
$this->assertSame(['B', 'C'], $form->getData());
2381+
2382+
$view = $form->createView();
2383+
$this->assertArrayHasKey('choices', $view->vars);
2384+
$this->assertCount(2, $view->vars['choices']);
2385+
$this->assertSame('B', $view->vars['choices'][0]->value);
2386+
$this->assertSame('C', $view->vars['choices'][1]->value);
2387+
}
22802388
}

‎Tests/Fixtures/Descriptor/resolved_form_type_1.json

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"choice_attr",
77
"choice_filter",
88
"choice_label",
9+
"choice_lazy",
910
"choice_loader",
1011
"choice_name",
1112
"choice_translation_domain",

‎Tests/Fixtures/Descriptor/resolved_form_type_1.txt

+16-16
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,22 @@ Symfony\Component\Form\Extension\Core\Type\ChoiceType (Block prefix: "choice")
88
choice_attr FormType FormType FormTypeCsrfExtension
99
choice_filter -------------------- ------------------------------ -----------------------
1010
choice_label compound action csrf_field_name
11-
choice_loader data_class allow_file_upload csrf_message
12-
choice_name empty_data attr csrf_protection
13-
choice_translation_domain error_bubbling attr_translation_parameters csrf_token_id
14-
choice_translation_parameters invalid_message auto_initialize csrf_token_manager
15-
choice_value trim block_name
16-
choices block_prefix
17-
duplicate_preferred_choices by_reference
18-
expanded data
19-
group_by disabled
20-
multiple form_attr
21-
placeholder getter
22-
placeholder_attr help
23-
preferred_choices help_attr
24-
separator help_html
25-
separator_html help_translation_parameters
26-
inherit_data
11+
choice_lazy data_class allow_file_upload csrf_message
12+
choice_loader empty_data attr csrf_protection
13+
choice_name error_bubbling attr_translation_parameters csrf_token_id
14+
choice_translation_domain invalid_message auto_initialize csrf_token_manager
15+
choice_translation_parameters trim block_name
16+
choice_value block_prefix
17+
choices by_reference
18+
duplicate_preferred_choices data
19+
expanded disabled
20+
group_by form_attr
21+
multiple getter
22+
placeholder help
23+
placeholder_attr help_attr
24+
preferred_choices help_html
25+
separator help_translation_parameters
26+
separator_html inherit_data
2727
invalid_message_parameters
2828
is_empty_callback
2929
label

0 commit comments

Comments
 (0)
Please sign in to comment.