Slide 1

Slide 1 text

Welcome to SymfonyLive London 2016 Symfony Forms Use cases & Optimisation

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 mostly, devs don’t like forms…

Slide 5

Slide 5 text

Have 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

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

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

On the road to mastering Form types with a simple use case 1. ManyToMany relationship between a post and some tags 2. Handling extra data and use it in the model 3. Work around PHP 7 scalar type hints 4. Using form types inheritance mechanism

Slide 9

Slide 9 text

class Post { /** * @ORM\Id * @ORM\GeneratedValue * @ORM\Column(type="integer") */ private $id; /** * @ORM\Column(type="string") * @Assert\NotBlank() */ private $title; /** * @ORM\Column(type="string") */ private $slug; /** * @ORM\Column(type="string") * @Assert\NotBlank(message="post.blank_summary") */ private $summary; /** * @ORM\Column(type="text") * @Assert\NotBlank(message="post.blank_content") * @Assert\Length(min = "10", minMessage = "post.too_short_content") */ private $content; /** * @ORM\Column(type="string") * @Assert\Email() */ private $authorEmail; /** * @ORM\Column(type="datetime") * @Assert\DateTime() */ private $publishedAt; } Back to basics: a simple post

Slide 10

Slide 10 text

does that sound familiar? class PostType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder ->add('title', null, [ 'attr' => ['autofocus' => true], 'label' => 'label.title', ]) ->add('summary', TextareaType::class, [ 'label' => 'label.summary', ]) ->add('content', null, [ 'attr' => ['rows' => 20], 'label' => 'label.content', ]) ->add('authorEmail', null, [ 'label' => 'label.author_email', ]) ->add('publishedAt', DateTimePickerType::class, [ 'label' => 'label.published_at', ]) ; } public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'data_class' => Post::class, ]); } } A simple form type,

Slide 11

Slide 11 text

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

Slide 12

Slide 12 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; // ... } First we need a tag entity Unique by its name A non blank name

Slide 13

Slide 13 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); } } Don’t add it twice No need for a setter

Slide 14

Slide 14 text

class Post { // ... /** *@ORM\ManyToMany( * targetEntity=“AppBundle\Entity\Tag”, * inversedBy=“posts”, * cascade={“persist”} *) */ private $tags; // … public function getTags() { return $this->tags; } public function setTags($tags) { $this->tags = $tags; } 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); } } Adding a “tags” field to the post

Slide 15

Slide 15 text

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); } } WOW! Sometimes it really is simple.

Slide 16

Slide 16 text

class PostType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder // ... ->add('tags', CollectionType::class, [ 'entry_type' => TagType::class, 'by_reference' => false, 'allow_add' => true, 'prototype_data' => new Tag(), ]) ; } }

Slide 17

Slide 17 text

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

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

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, 'by_reference' => false, 'allow_add' => true, 'mapped' => false, ]) ; } }

Slide 20

Slide 20 text

Problem: How can we update the post from this unmapped field?

Slide 21

Slide 21 text

Using Form events? PRE_SUBMIT => submitted data array POST_SUBMIT => hydrated post, no more submitted data

Slide 22

Slide 22 text

class PostType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $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, ]) ; } }

Slide 23

Slide 23 text

It just works!

Slide 24

Slide 24 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 25

Slide 25 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 26

Slide 26 text

Wait for PHP 7.1? class Tag { // ... /** * @ORM\Column(type="string") * @NotBlank */ private $name; public function setName(?string $name) { $this->name = $name; } public function getName() : ?string { return $this->name; } // ... } Nullable type hint

Slide 27

Slide 27 text

Allow null in the model? It must 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 28

Slide 28 text

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

Slide 29

Slide 29 text

Use a form type extension 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 30

Slide 30 text

Don’t forget to define it as a service app.form.text_type_extension: class: AppBundleFormExtensionTextTypeExtension arguments: ['@appdoctrinetag_repository'] tags: - name: form.type_extension extended_type: SymfonyComponentFormExtensionCoreTypeTextType

Slide 31

Slide 31 text

It just works! And with all PHP version!

Slide 32

Slide 32 text

No content

Slide 33

Slide 33 text

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

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

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

Slide 37

Slide 37 text

class PostType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder // ... ->add('tags', EntityType::class, [ '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, ]) ; Problem: how can we mix both fields?

Slide 38

Slide 38 text

One way to do it: using form type inheritance mechanism class TagsType extends AbstractType { public function getParent() { return ChoiceType::class; } }

Slide 39

Slide 39 text

use Doctrine\ORM\EntityRepository; class TagsType extends AbstractType { private $repo; public function __construct(EntityRepository $repo) { $this->repo = $repo; } public function getParent() { return ChoiceType::class; } } First of all, we’ll need the repository

Slide 40

Slide 40 text

app.form.tags_type: class: AppBundleFormTagsType arguments: [‘@doctrine.orm.entity_manager’] tags: - { name: form.type } Then declare our form type as a service

Slide 41

Slide 41 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 } }

Slide 42

Slide 42 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 = null) { return null === $tag ? '' : $tag->getName(); }; return new ArrayChoiceList($tags, $tagValue); }

Slide 43

Slide 43 text

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 44

Slide 44 text

public function loadChoicesForValues(array $values, $value = null) { if (empty($values)) { return []; } if (null !== $this->choiceList) { return $this->choiceList->getChoicesForValues($values); } $tags = []; foreach ($values as $tagName) { if (empty($name)) { continue; } $tag = $this->repo->findOneBy(['name' => $name]); if (null === $tag) { $tag = new Tag(); $tag->setName($name); } $tags[] = $tag; } return $tags; }

Slide 45

Slide 45 text

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 46

Slide 46 text

class TagsType extends AbstractType implements ChoiceLoaderInterface, DataTransformerInterface { // ... public function transform($data) { if (empty($data)) { return ''; } return implode(', ', $data); } }

Slide 47

Slide 47 text

use Symfony\Component\Form\Exception\UnexpectedTypeException; class TagsType extends AbstractType implements ChoiceLoaderInterface, DataTransformerInterface { // ... public function reverseTransform($value) { if (null === $value) { return []; } if (!is_string($value)) { throw new UnexpectedTypeException($value, 'string or null'); } return explode(',', $value); } No space in the separator

Slide 48

Slide 48 text

class TagsType extends AbstractType implements ChoiceLoaderInterface { // ... public function loadChoicesForValues(array $values, $value = null) { if (empty($values)) { return array(); } $tags = []; foreach ($values as $name) { $name = trim($name); $tag = $this->repo->findOneBy([‘name' => $name]); if (null === $tag) { $tag = new Tag(); $tag->setName($name); } $tags[] = $tag; } return $tags; } }

Slide 49

Slide 49 text

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

Slide 50

Slide 50 text

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

Slide 51

Slide 51 text

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

Slide 52

Slide 52 text

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

Slide 53

Slide 53 text

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

Slide 54

Slide 54 text

class PostType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder // ... ->add('tags', EntityType::class, [ '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, ]) ; Problem: how can we mix both fields?

Slide 55

Slide 55 text

class PostType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder // ... ->add('tags', TagsType::class) ; } } WOW! Simple again :)

Slide 56

Slide 56 text

Mission complete!

Slide 57

Slide 57 text

No content

Slide 58

Slide 58 text

https://leaverou.github.io/awesomplete

Slide 59

Slide 59 text

Just add this in your base template

Slide 60

Slide 60 text

We need our input to get these attributes

Slide 61

Slide 61 text

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; $view->vars['attr']['data-list'] = implode(', ', $view->vars['choices']); } Lets’ extend ChoiceType view vars

Slide 62

Slide 62 text

Thank you!!