Skip to content

Commit bcfbb43

Browse files
committed
Merge branch '4.x' into 5.x
* 4.x: Tweaks Add choice_label support to CrudAutocompleteType
2 parents e341b76 + 70f894d commit bcfbb43

3 files changed

Lines changed: 104 additions & 20 deletions

File tree

src/Form/EventListener/CrudAutocompleteSubscriber.php

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
namespace EasyCorp\Bundle\EasyAdminBundle\Form\EventListener;
44

5-
use Doctrine\ORM\Mapping\FieldMapping;
65
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
76
use Symfony\Bridge\Doctrine\Types\UlidType;
87
use Symfony\Bridge\Doctrine\Types\UuidType;
@@ -40,29 +39,31 @@ public function preSetData(FormEvent $event): void
4039
$options['compound'] = false;
4140
$options['choices'] = is_iterable($data) ? $data : [$data];
4241

43-
// apply custom choice_label if autocomplete is customized so the selected item looks the same as the other entries.
44-
// note: we don't escape here because Twig already escapes the <option> content automatically;
45-
// the renderAsHtml flag controls how TomSelect renders the item (via data-ea-autocomplete-render-items-as-html).
42+
// strip EA-specific options before forwarding to EntityType
4643
$callback = $options['autocomplete_callback'] ?? null;
4744
$template = $options['autocomplete_template'] ?? null;
48-
49-
if (null !== $template) {
50-
$twig = $this->twig;
51-
$options['choice_label'] = static function ($entity) use ($twig, $template): string {
52-
return $twig->render($template, ['entity' => $entity]);
53-
};
54-
} elseif (null !== $callback) {
55-
$options['choice_label'] = static function ($entity) use ($callback): string {
56-
return (string) $callback($entity);
57-
};
45+
unset($options['autocomplete_callback'], $options['autocomplete_template']);
46+
47+
// Resolve choice_label:
48+
// - if the user supplied one (via value_type_options.choice_label), keep it;
49+
// - otherwise derive one from autocomplete_template / autocomplete_callback so the
50+
// selected item matches the rendering of other entries in the dropdown;
51+
// - otherwise drop the option entirely so EntityType falls back to __toString().
52+
//
53+
// Note: we don't escape here because Twig already escapes the <option> content
54+
// automatically; the renderAsHtml flag controls how TomSelect renders the item
55+
// (via data-ea-autocomplete-render-items-as-html).
56+
if (null === ($options['choice_label'] ?? null)) {
57+
if (null !== $template) {
58+
$twig = $this->twig;
59+
$options['choice_label'] = static fn ($entity): string => $twig->render($template, ['entity' => $entity]);
60+
} elseif (null !== $callback) {
61+
$options['choice_label'] = static fn ($entity): string => (string) $callback($entity);
62+
} else {
63+
unset($options['choice_label']);
64+
}
5865
}
5966

60-
// remove custom options before passing to EntityType
61-
unset(
62-
$options['autocomplete_callback'],
63-
$options['autocomplete_template']
64-
);
65-
6667
$form->add('autocomplete', EntityType::class, $options);
6768
}
6869

src/Form/Type/CrudAutocompleteType.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use Symfony\Component\Form\FormInterface;
1010
use Symfony\Component\Form\FormView;
1111
use Symfony\Component\OptionsResolver\OptionsResolver;
12+
use Symfony\Component\PropertyAccess\PropertyPath;
1213
use Twig\Environment;
1314

1415
/**
@@ -48,11 +49,17 @@ public function configureOptions(OptionsResolver $resolver): void
4849
// options for custom rendering of selected items (to match the rendering of the other entries in the dropdown)
4950
'autocomplete_callback' => null,
5051
'autocomplete_template' => null,
52+
// forwarded to the underlying EntityType so users can customize the
53+
// label of the selected items via setFormTypeOption('value_type_options', ...)
54+
'choice_label' => null,
5155
]);
5256

5357
$resolver->setRequired(['class']);
5458
$resolver->setAllowedTypes('autocomplete_callback', ['null', 'callable']);
5559
$resolver->setAllowedTypes('autocomplete_template', ['null', 'string']);
60+
// mirror Symfony's ChoiceType allowed types for choice_label so anything accepted by
61+
// EntityType is also accepted here (we only skip ChoiceLabel::class, which is marked as internal)
62+
$resolver->setAllowedTypes('choice_label', ['null', 'bool', 'callable', 'string', PropertyPath::class]);
5663
}
5764

5865
public function getBlockPrefix(): string

tests/Unit/Filter/CrudAutocompleteTypeTest.php

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,4 +151,80 @@ public function testSubmitEmptyMultipleData(): void
151151
$this->assertTrue($form->isSynchronized());
152152
$this->assertEquals(new ArrayCollection(), $form->getData());
153153
}
154+
155+
public function testCustomChoiceLabelIsPreserved(): void
156+
{
157+
$project = (new Project())->setId(123)->setName('Foo');
158+
159+
$this->entityManager
160+
->method('contains')
161+
->with($project)
162+
->willReturn(true);
163+
$this->repository
164+
->method('findBy')
165+
->willReturn([$project]);
166+
$this->classMetadata
167+
->method('getIdentifierValues')
168+
->with($project)
169+
->willReturn(['id' => $project->getId()]);
170+
171+
$form = $this->factory->create(CrudAutocompleteType::class, null, [
172+
'class' => Project::class,
173+
'choice_label' => static fn (Project $p): string => sprintf('[%d] %s', $p->getId(),
174+
$p->getName()),
175+
]);
176+
$form->submit(['autocomplete' => '123']);
177+
178+
$this->assertTrue($form->isSynchronized());
179+
180+
$view = $form->createView();
181+
182+
// the user-supplied callable is used to build the option label,
183+
// overriding the default Project::__toString() output
184+
$this->assertEquals(
185+
['123' => new ChoiceView($project, 123, '[123] Foo')],
186+
$view->children['autocomplete']->vars['choices'],
187+
);
188+
}
189+
190+
public function testAutocompleteCallbackStillWinsWhenChoiceLabelIsNotSet(): void
191+
{
192+
$project = (new Project())->setId(123)->setName('Foo');
193+
194+
$this->entityManager
195+
->method('contains')
196+
->with($project)
197+
->willReturn(true);
198+
$this->repository
199+
->method('findBy')
200+
->willReturn([$project]);
201+
$this->classMetadata
202+
->method('getIdentifierValues')
203+
->with($project)
204+
->willReturn(['id' => $project->getId()]);
205+
206+
$form = $this->factory->create(CrudAutocompleteType::class, null, [
207+
'class' => Project::class,
208+
'autocomplete_callback' => static fn (Project $p): string => 'cb:'.$p->getName(),
209+
]);
210+
$form->submit(['autocomplete' => '123']);
211+
212+
$view = $form->createView();
213+
214+
$this->assertEquals(
215+
['123' => new ChoiceView($project, 123, 'cb:Foo')],
216+
$view->children['autocomplete']->vars['choices'],
217+
);
218+
}
219+
220+
public function testChoiceLabelOptionRejectsInvalidType(): void
221+
{
222+
223+
$this->expectException(\Symfony\Component\OptionsResolver\Exception\InvalidOptionsException::class);
224+
225+
$this->factory->create(CrudAutocompleteType::class, null, [
226+
'class' => Project::class,
227+
'choice_label' => 42, // int is not in the allowed types
228+
]);
229+
}
154230
}

0 commit comments

Comments
 (0)