Part 1 - An Easy Way To Handle 'Other' With Drop Downs


In this, and the next two videos, we are going to cover a possible solution to a frequently asked question:

In the event that none of the available options in a select box / drop down list meet their needs, how can I give a user the option to provide an 'Other' response?

So, when a drop down has, for example, four available options:

  • 15
  • 30
  • 45
  • 60

And none of the above are satisfactory, we would like to be able to offer the end-user the ability to select 'Other', and then provide an input field for them to specify a custom entry.

On the face of it, this seems like it should be quite easy. And honestly, you know, maybe it is. Maybe I am about to over-complicate this. So if you know of a better way then by all means, do shout up!

Assumption Junction

I'm going to make an assumption that the 'Other' option might need to be re-used. This is to say that we may want to provide this functionality in more than a single form in our project.

If we can keep the logic largely separated, it should (in theory) be easier to transfer this solution to future projects that also would like this feature.

This will have an impact on how we structure the object(s), the form(s), and also the JavaScript that shows or hides the 'Other' entry box.

There will also be a potential issue arising, depending on how you query your data, as we shall see momentarily. The pain point is that we will need to keep track of two columns in MySQL, rather than a single column for this particular piece of information.

Thinking In Objects

The first step is to forget about MySQL - or whatever underlying database you are using.

Thinking in terms of database tables is going to cause headaches. After all, we will be using the Symfony form component with objects, and then saving the resulting objects using Doctrine. The way in which this data will be stored is really not the concern of this 'layer'.

M, y, S, Q, and L should now be just letters to you ;)

Ok, so in this example we will be working with a product feed - but ultimately it's just a URL pointing to a CSV, XML (bluergh), JSON feed, or whatever, that we want to use some download mechanism to grab on a regular basis.

A quick look at this object / entity:

<?php

// /src/AppBundle/Entity/DataFeed.php

namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 * @ORM\Table(name="datafeed")
 */
class DataFeed
{
    /**
     * @ORM\Column(type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @ORM\Column(type="string")
     */
    protected $name;

    /**
     * @ORM\Column(type="string")
     */
    protected $url;

    /**
     * @ORM\Column(type="boolean")
     */
    protected $enabled;

    // getters / setters removed for brevity

Fairly standard stuff.

In my mind, there's no reason for a DataFeed to be concerned with when it will be downloaded. All this object cares about is storing the bits and bobs associated with the URL itself. Frankly, the enabled field shouldn't even be on this object, but I wanted to add it on to have something other than string fields.

Creating a form for this entity should not be new to you at this point, but here it is all the same:

<?php

// /src/AppBundle/Form/Type/DataFeedType.php

namespace AppBundle\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class DataFeedType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('name')
            ->add('url', UrlType::class)
            ->add('enabled', ChoiceType::class, [
                'choices' => [
                    'Yes'  => true,
                    'No'   => false,
                ],
                'expanded' => true,
            ])
            ->add('save', SubmitType::class)
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => 'AppBundle\Entity\DataFeed',
        ]);
    }
}

And this is all good, and should work - providing you update your database schema accordingly - but so far we haven't directly addressed the problem.

Now, we could add another field to our DataFeed entry - maybe called updateEveryX or something (bit terrible) - and then add another ChoiceType to our form. This would work, and we have covered how to do this already, but it doesn't allow a custom / other entry.

And herein lies the first problem.

Even if we do get the data into the database - somehow - how can we properly re-load that data if a user wishes to edit the DataFeed in the future?

Even if the data is stored properly, and the form reloads, the form itself is incapable of rendering the unexpected value back on to the form - that value is not in the list of available choices. Now, that value won't be lost - for the moment.

But the user will see the wrong value and be very confused. Worse, if they then save the form then the new value wipes over their previous - possibly correct value. Oh my.

So, in summary, all of this is pretty bad.

There likely is a way around this with some combination of data transformers, and / or form events.

However, I'm all for the simple approach until simple no longer is good enough.

A Simple Object Relationship

As I've decided that a DataFeed need not directly know about any timetables / schedules, I am instead going to create a separate entity to hold timetable information, and relate the two.

Now, I've just said the DataFeed doesn't directly know about its timetable information, but it seems fair to be able to ask a relation.

In my case though, I want to keep things as simple as possible so my relationship is going to be unidirectional. Also, a DataFeed can only have one Timetable, so the relationship will be a unidirectional one-to-one.

All this means is that a DataFeed knows about its Timetable, but you cannot ask a Timetable which DataFeed it is associated with.

First, let's define the Timetable entity:

<?php

// /src/AppBundle/Entity/Timetable.php

namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * Timetable
 * @ORM\Table(name="timetable")
 * @ORM\Entity()
 */
class Timetable
{
    /**
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id()
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @ORM\Column(type="integer")
     */
    protected $presetChoice;

    /**
     * @ORM\Column(type="integer")
     */
    protected $manualEntry;

    // getters and setters removed for brevity

Now this entity will need a little modification along the way, but for now this is good enough to be able to create the association:

// /src/AppBundle/Entity/DataFeed.php

/**
 * @ORM\Entity
 * @ORM\Table(name="datafeed")
 */
class DataFeed
{
    // * snip *

    /**
     * @ORM\OneToOne(targetEntity="Timetable")
     * @ORM\JoinColumn(name="timetable_id", referencedColumnName="id")
     * 
     * @Assert\Valid()
     */
    protected $timetable;

    // and also getters / setters

And with a database schema update:

php bin/console doctrine:schema:update --force

We should be all set.

Well, I say all set, this is actually going to fail almost immediately, but lets roll with it.

Fake It Till You Make It

At this stage we could create our TimetableFormType and start mucking about with the Twig templates and what have you. But hold off on that for a moment, as not only does it mean we have to do more work (boo!) it also means we add complexity and potentially make debugging the next immediate problem that little bit harder. Especially if this is your first time using an embedded form. This will all come in the very next video by the way, so don't worry that it won't be covered.

Instead, we can fake the form submission process, and 'trick' Symfony into thinking that our form was all set up anyway.

Let's quickly recap our controller:

<?php

// /src/AppBundle/Controller/FormExampleController.php

namespace AppBundle\Controller;

use AppBundle\Entity\DataFeed;
use AppBundle\Entity\Timetable;
use AppBundle\Form\Type\DataFeedType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;

class FormExampleController extends Controller
{
    /**
     * @Route("/", name="form_add_example")
     */
    public function formAddExampleAction(Request $request)
    {
        $form = $this->createForm(DataFeedType::class);

        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {

            $em = $this->getDoctrine()->getManager();

            $dataFeed = $form->getData();

            $em->persist($dataFeed);
            $em->flush();

            $this->addFlash('success', 'We saved a data feed with id ' . $dataFeed->getId());
        }

        return $this->render(':form-example:index.html.twig', [
            'myForm' => $form->createView()
        ]);
    }

Hey hey, it's exactly the same as we have been using throughout this entire series. Pretty awesome really - aside from changing the variable name and the flash message, this baby has been very, very good to us in terms of reusability.

But lets get back to faking our setup.

We control the whole process, so we can deliberately bypass the validation stages, and then starting -mutilating- mutating our $dataFeed entity to meet our needs.

Before we worry about embedding forms and that sort of thing, we can carry out the process manually and verify that it will work as expected.

To do this, we can create a new instance of a Timetable entity, and set this by hand:

// /src/AppBundle/Controller/FormExampleController.php

if ($form->isSubmitted() && $form->isValid()) {

    $em = $this->getDoctrine()->getManager();

    /**
     * @var $dataFeed \AppBundle\Entity\DataFeed
     */
    $dataFeed = $form->getData();

    // 'fake' the creation of a timetable object
    // this is what would normally be done for us by the form component behind the scenes
    $timetable = new Timetable();
    $timetable->setManualEntry(120);
    $timetable->setPresetChoice(45);

    // add in the relationship
    $dataFeed->setTimetable($timetable);

    // only need to persist the $dataFeed - kind of, see below
    $em->persist($dataFeed);
    $em->flush();

    $this->addFlash('success', 'We saved a data feed with id ' . $dataFeed->getId());
}

However, this will throw up an error when we submit:

symfony doctrine error a new entity was found through the relationship
A new entity was found through the relationship 'AppBundle\Entity\DataFeed#timetable' that was not configured to cascade persist operations for entity: AppBundle\Entity\Timetable@000000001b83f2fc000000003d8a78ea. To solve this issue: Either explicitly call EntityManager#persist() on this unknown entity or configure cascade persist this association in the mapping for example @ManyToOne(..,cascade={"persist"}). If you cannot find out which entity causes the problem implement 'AppBundle\Entity\Timetable#__toString()' to get a clue. 500 Internal Server Error - ORMInvalidArgumentException

Lovely.

But actually this error is pretty helpful.

It tells us exactly what to do, and why this happened.

Doctrine has tried to save off our DataFeed and associate this datafeed with a timetable. The problem being that the Timetable is new, and therefore doesn't have an ID. So, how can the two be related?

Well, Doctrine can handle this all for us if we just add in the right annotation - by adding in the cascade persist mapping as indicated in the error message:

// /src/AppBundle/Entity/DataFeed.php

    /**
     * @ORM\OneToOne(targetEntity="Timetable", cascade={"persist"})
     * @ORM\JoinColumn(name="timetable_id", referencedColumnName="id")
     * 
     * @Assert\Valid()
     */
    protected $timetable;

And as simple as that, Doctrine will be able to resolve our problem and save properly.

There is another cheeky way to do this - without adding the cascade - we could have first called persist on the $timetable in the controller:

// /src/AppBundle/Controller/FormExampleController.php

if ($form->isSubmitted() && $form->isValid()) {

    $timetable = new Timetable(); 
    // etc

    $dataFeed->setTimetable($timetable);

    $em->persist($dataFeed->getTimetable());
    $em->persist($dataFeed);
    $em->flush();

    $this->addFlash('success', 'We saved a data feed with id ' . $dataFeed->getId());
}

But what's the point of doing that, as it's just extra work and doesn't scale very well at all. So let Doctrine do what Doctrine does.

Ultimately all the form for Timetable will do for us is exactly this.

Of course, this isn't properly sorted yet. Our form isn't right, and we can save total nonsense because we aren't validating anything. And besides, we are doing everything manually.

In the very next video we will sort out the vast majority of this, only leaving out the JavaScript, which will come in the final part of this series.

Code For This Course

Get the code for this course.

Episodes