Slide 1

Slide 1 text

Welcome to SymfonyCon Berlin 2016 Symfony Forms One use case, many optimizations

Slide 2

Slide 2 text

Jules Pietri Symfony/PHP developer @SensioLabs @julespietri @HeahDude

Slide 3

Slide 3 text

Devs Symfony

Slide 4

Slide 4 text

But when it comes to forms…

Slide 5

Slide 5 text

Haaave you met @webmozart? Bernhard Schussek is the core developer of the Form Component https://speakerdeck.com/webmozart https://webmozart.io

Slide 6

Slide 6 text

Haaave you met @webmozart? Bernhard Schussek is the core developer of the Form Component https://speakerdeck.com/webmozart https://webmozart.io

Slide 7

Slide 7 text

“Make simple cases simple, make complex cases possible” @webmozart, talking about Form component

Slide 8

Slide 8 text

MODELS FORMS Simple Complex Complex

Slide 9

Slide 9 text

MODELS FORMS Simple Complex Complex

Slide 10

Slide 10 text

MODELS FORMS Simple Complex Complex OK I got it WTF? I thought I got it

Slide 11

Slide 11 text

On the road to mastering Form types with a simple use case 1. ManyToMany relationship 2. Handling unmapped data 3. Work around PHP 7 scalar type hints 4. Using form types inheritance mechanism

Slide 12

Slide 12 text

Back to basics: a simple Post class Post { private $id; private $title; private $slug; private $summary; private $content; private $authorEmail; private $publishedAt; // Getters and setters // ... }

Slide 13

Slide 13 text

A simple FormType class PostType extends AbstractType { public function buildForm(…) { $builder ->add('title', TextType::class, ...) ->add('summary', TextareaType::class, ...) ->add('content', TextareaType::class, ...) ->add('authorEmail', TextType::class, ...) ->add('publishedAt', DateTimeType::class, ...) ; } public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'data_class' => Post::class, ]); } }

Slide 14

Slide 14 text

A simple action public function newAction(Request $request) { $post = new Post(); $post->setAuthorEmail($this->getUser()->getEmail()); $form = $this->createForm(PostType::class, $post); $form->handleRequest($request); // ...

Slide 15

Slide 15 text

/** * @ORM\Entity * @UniqueEntity(fields={"name"}) */ class Tag { /** * @ORM\Id * @ORM\GeneratedValue * @ORM\Column(type="integer") */ private $id; /** * @ORM\Column(type="string") * @Assert\NotBlank */ private $name; /** * @var Collection|Post[] * * @ORM\ManyToMany(targetEntity="Post", mappedBy="tags") */ private $posts; // ... }

Slide 16

Slide 16 text

class Tag { // ... /** * @return Collection|Post[] */ public function getPosts() { return $this->posts; } /** * @param Post $post */ public function addPost(Post $post) { if (!$this->posts->contains($post)) { $this->posts->add($post); } } /** * @param Post $post */ public function removePost(Post $post) { $this->posts->remove($post); } }

Slide 17

Slide 17 text

class Post { // ... /** * @ORM\ManyToMany( * targetEntity="AppBundle\Entity\Tag", * inversedBy="posts", * cascade={"persist"} * ) */ private $tags; /* * @var Collection|Tag[] */ public function getTags() { return $this->tags; } // ...

Slide 18

Slide 18 text

class Post { // ... public function addTag(Tag $tag) { if (!$this->tags->contains($tag)) { $this->tags->add($tag); $tag->addPost($this); } } public function removeTag(Tag $tag) { $this->tags->removeElement($tag); } }

Slide 19

Slide 19 text

WOW! Sometimes it really is simple. class TagType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder->add('name', TextType::class); } public function configureOptions(OptionsResolver $resolver) { $resolver->setDefault('data_class', Tag::class); } }

Slide 20

Slide 20 text

class PostType extends AbstractType { public function buildForm(...) { $builder // ... ->add('tags', CollectionType::class, [ 'entry_type' => TagType::class, 'allow_add' => true, 'by_reference' => false, ]) ; } } “allow_add” option allows to create new tags while attaching them to a post

Slide 21

Slide 21 text

But what if a tag already exists or is modified? /** * @ORM\Entity * @UniqueEntity(fields={"name"}) */ class Tag { // ... /** * @ORM\Column(type="string") * @NotBlank */ private $name; // ... }

Slide 22

Slide 22 text

The CollectionType is too permissive about editing nested entities Problem 1

Slide 23

Slide 23 text

What about using EntityType instead? Solution 1

Slide 24

Slide 24 text

class PostType extends AbstractType { public function buildForm(...) { $builder // ... ->add('tags', EntityType::class, [ 'class' => Tag::class, 'choice_label' => 'name', 'multiple' => true, ]) ; } }

Slide 25

Slide 25 text

The EntityType does not allow adding new nested entities Problem 2

Slide 26

Slide 26 text

What about using both EntityType and CollectionType? Solution 2

Slide 27

Slide 27 text

class PostType extends AbstractType { public function buildForm(...) { $builder // ... ->add('current_tags', CollectionType::class, [ 'property_path' => 'tags', 'class' => Tag::class, 'choice_label' => 'name', 'multiple' => true, ]) ->add('new_tags', CollectionType::class, [ 'entry_type' => TagType::class, 'by_reference' => false, 'allow_add' => true, 'mapped' => false, ]) ; } }

Slide 28

Slide 28 text

Using Form events? How can we update the post from this unmapped field? $builder // … ->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) { $post = $event->getData(); $newTags = $event->getForm()->get('new_tags')->getData(); foreach ($newTags as $tag) { $post->addTag($tag); } }) ;

Slide 29

Slide 29 text

$builder // ... ->add('new_tags', CollectionType::class, [ 'entry_type' => TagType::class, 'entry_options' => [ 'empty_data' => function(FormInterface $form) { $tag = new Tag(); $tag->setName($form->get('name')->getData()); $form->getParent()->getParent()->getData()->addTag($tag); }, ], 'by_reference' => false, 'allow_add' => true, 'mapped' => false, ]) ; Or using “empty_data” option

Slide 30

Slide 30 text

It just works!

Slide 31

Slide 31 text

But maybe not on PHP 7.0+… Fatal error: Uncaught TypeError: Argument 1 passed to Tag::setName() must be of the type string, null given declare('strict_types', 0/1);

Slide 32

Slide 32 text

class Tag { // ... /** * @ORM\Column(type="string") * @Assert\NotBlank */ private $name = ''; public function setName(string $name) { $this->name = $name; } public function getName() : string { return $this->name; } // ... }

Slide 33

Slide 33 text

Update to PHP 7.1, using nullable type hints? class Tag { // ... /** * @ORM\Column(type="string") * @NotBlank */ private $name; public function setName(?string $name) { $this->name = $name; } public function getName() : ?string { return $this->name; } // ... }

Slide 34

Slide 34 text

Allow null in the model? It will be validated anyway… class Tag { // ... /** * @ORM\Column(type="string") * @Assert\NotBlank */ private $name = ''; public function setName(string $name = null) { $this->name = $name; } public function getName() : string { return $this->name; } // ... }

Slide 35

Slide 35 text

Nope. Again, use “empty_data” Symfony 3.1+ only ⚠ class TagType extends AbstractType { public function buildForm(...) { $builder->add('name', TextType::class, [ 'empty_data' => '', ]); } public function configureOptions(OptionsResolver $resolver) { $resolver->setDefault('data_class', Tag::class); } }

Slide 36

Slide 36 text

Use it globally with a FormTypeExtension namespace AppBundle\Form\Extension; use Symfony\Component\Form\AbstractTypeExtension; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\OptionsResolver\OptionsResolver; class TextTypeExtension extends AbstractTypeExtension { public function configureOptions(OptionsResolver $resolver) { $resolver->setDefault('empty_data', ''); } public function getExtendedType() { return TextType::class; } }

Slide 37

Slide 37 text

Don’t forget to define the FormTypeExtension as a service app.form.text_type_extension: class: AppBundle\Form\Type\Extension\TextTypeExtension tags: - name: form.type_extension extended_type: Symfony\Component\Form\Extension\Core\Type\TextType

Slide 38

Slide 38 text

It just works with all PHP versions!

Slide 39

Slide 39 text

Alright, but I’d prefer to have one field… Problem 3

Slide 40

Slide 40 text

MODELS FORMS Simple Complex Complex OK I got it WTF? I thought I got it

Slide 41

Slide 41 text

MODELS FORMS Simple Complex Complex OK I got it WTF? I thought I got it UX constraint

Slide 42

Slide 42 text

Problem 3: mix both fields class PostType extends AbstractType { public function buildForm(...) { $builder // ... ->add('current_tags', EntityType::class, [...]) ->add('new_tags', CollectionType::class, [...]) ; } }

Slide 43

Slide 43 text

using FormType inheritance mechanism class TagsType extends AbstractType { public function getParent() { return ChoiceType::class; } } Solution 3

Slide 44

Slide 44 text

First of all, we’ll need the Tag repository use Doctrine\Common\Persistence\ManagerRegistry; use Doctrine\ORM\EntityRepository; class TagsType extends AbstractType { /** * @var EntityRepository */ private $repo; public function __construct(ManagerRegistry $doctrine) { $this->repo = $doctrine->getRepository(Tag::class); } public function getParent() { return ChoiceType::class; } }

Slide 45

Slide 45 text

So we need to declare the FormType as a service app.form.tags_type: class: AppBundle\Form\Type\TagsType arguments: ['@doctrine'] tags: - name: form.type

Slide 46

Slide 46 text

use Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface; class TagsType extends AbstractType implements ChoiceLoaderInterface { // ... public function loadChoiceList($value = null) { // returns a Symfony\Component\Form\ChoiceList\ChoiceListInterface } public function loadChoicesForValues(array $values, $value = null) { // returns an array of entities for the given values } public function loadValuesForChoices(array $choices, $value = null) { // returns an array of values for the given entities } } We’ll need a ChoiceLoader

Slide 47

Slide 47 text

use Symfony\Component\Form\ChoiceList\ArrayChoiceList; class TagsType extends AbstractType implements ChoiceLoaderInterface { private $repo; private $choiceList; // ... public function loadChoiceList($value = null) { if (null !== $this->choiceList) { return $this->choiceList; } $tags = $this->repo->findAll(); $tagValue = function (Tag $tag = null) { return null === $tag ? '' : $tag->getName(); }; return new ArrayChoiceList($tags, $tagValue); } // ...

Slide 48

Slide 48 text

Handling pre set data public function loadValuesForChoices(array $choices, $value = null) { if (empty($choices)) { return []; } if (null !== $this->choiceList) { return $this->choiceList->getValuesForChoices($choices); } $tagNames = []; foreach ($choices as $tag) { $tagNames[] = $tag->getName(); } return $tagNames; }

Slide 49

Slide 49 text

Handling submitted data public function loadChoicesForValues(array $values, $value = null) { if (empty($values)) { return []; } if (null !== $this->choiceList) { return $this->choiceList->getChoicesForValues($values); } return $this->repo->findTags($values); }

Slide 50

Slide 50 text

Handling submitted data class TagRepository extends EntityRepository { /** * @param string[] $names * * @return Tag[] */ public function findTags(array $names) { return $this->createQueryBuilder('t') ->where('t.name IN(:names)') ->setParameter('names', $names) ->getQuery() ->getResult() ; } }

Slide 51

Slide 51 text

We’ll need a view DataTransformer use Symfony\Component\Form\DataTransformerInterface; class TagsType extends AbstractType implements ChoiceLoaderInterface, DataTransformerInterface { // ... public function transform($data) { // get a string from tag names array } public function reverseTransform($value) { // get an array of tag names from submitted string } }

Slide 52

Slide 52 text

class TagsType ... { // ... public function transform($data) { if (empty($data)) { return ''; } return implode(', ', $data); } public function reverseTransform($value) { if (null === $value) { return []; } return explode(',', $value); } // ...

Slide 53

Slide 53 text

So we need to trim values public function loadChoicesForValues(array $values, $value = null) { if (empty($values)) { return []; } if (null !== $this->choiceList) { return $this->choiceList->getChoicesForValues($values); } return $this->repo->findTags(array_map(function ($value) { return trim($value); }, $values)); }

Slide 54

Slide 54 text

Let’s override ChoiceType default configuration class TagsType ... { // ... public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'choice_loader' => $this, 'multiple' => true, ]); } // ... }

Slide 55

Slide 55 text

Let’s extend ChoiceType form building use Symfony\Bridge\Doctrine\Form\DataTransformer\CollectionToArrayTransformer; class TagsType ... { // ... public function buildForm(FormBuilderInterface $builder, array $options) { $builder->addModelTransformer(new CollectionToArrayTransformer()); $builder->addViewTransformer($this); } // ... }

Slide 56

Slide 56 text

Let’s override ChoiceType view class TagsType ... { // ... public function finishView(FormView $view, FormInterface $form, array $options) { // remove the trailing "[]" $view->vars['full_name'] = substr($view->vars['full_name'], 0, -2); } // ... }

Slide 57

Slide 57 text

Let’s override ChoiceType view "block_prefixes" => array:4 [▼ 0 => "form" 1 => "choice" 2 => "tags" 3 => "_post_tags" ]

Slide 58

Slide 58 text

Let’s override ChoiceType view {% form_theme form _self %}
 
 {% block choice_widget %}
 {{ form_widget(form) }}
 {% endblock %}

Slide 59

Slide 59 text

Problem 3: mix both fields class PostType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder // ... ->add('current_tags', EntityType::class, [ 'property_path' => 'tags', 'class' => Tag::class, 'choice_label' => 'name', 'multiple' => true, ]) ->add('new_tags', CollectionType::class, [ 'entry_type' => TagType::class, 'entry_options' => [ 'empty_data' => function(FormInterface $form) {...}, ], 'by_reference' => false, 'allow_add' => true, 'mapped' => false, ]) ; } }

Slide 60

Slide 60 text

Solution 3: mix both fields class PostType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder // ... ->add('tags', TagsType::class) ; } }

Slide 61

Slide 61 text

Mission complete!

Slide 62

Slide 62 text

https://leaverou.github.io/awesomplete

Slide 63

Slide 63 text

Just add this in your base template

Slide 64

Slide 64 text

We need our input to get those attributes

Slide 65

Slide 65 text

Lets’ extend ChoiceType view vars class TagsType ... { // ... public function finishView(FormView $view, FormInterface $form, array $options) { // remove the trailing "[]" $view->vars['full_name'] = substr($view->vars['full_name'], 0, -2); $class = ''; if (isset($view->vars['attr']['class'])) { $class = $view->vars['attr']['class']; } $class .= ' awesomplete'; $view->vars['attr']['class'] = $class; $tagNames = $form->getConfig()->getAttribute('choice_list')->getValues(); $view->vars['attr']['data-list'] = implode(', ', $tagNames); $view->vars['attr']['data-multiple'] = ''; } // ... }

Slide 66

Slide 66 text

Thank you!