Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Symfony2 forms and collections - an Order, OrderItem implementation

Tags:

php

symfony

So I've spent around 5 or 6 hours today battling with Symfony2 forms and am at the point where I'd like some advice from other members of the community. I've tried over 3 different methods to achieve what I'm after and had no success. I've read through the docs, googled everything, asked others, and I'm only a little bit better off than when I started.

My use case

I'm building a system where you can order tickets. But the core problem is how to design the order part of the system.

  • a Ticket has a name, and start and end dates where it's available (other stuff as well but lets keep the example simple.
  • an Order may have multiple Tickets selected and for each Ticket there is a quantity.
  • an Order has a Customer. This part is fine and works dandy!

After reading around and trying different things, I gathered that to represent the Order's Ticket and quantity, I needed another entity OrderTicket corresponds to an OrderItem from https://github.com/beberlei/AcmePizzaBundle and the Pizza is my Ticket.

  • an OrderTicket has a Ticket and quantity.

On my order page where an order is created, I want the following:

  • a form for Customer details - name, email, address. This part works fine.
  • a form for the Tickets. I want the Ticket name displayed, in a textbox or even a string; not in a select box (which is what is happening now). I want the quantity to be specified next to the ticket name. If there's no quantity set, this means that ticket is not selected.
  • the Tickets should be filtered where they are available depending on todays date - this is achieved elsewhere (in the backend admin where they're created) by using a custom repository method on a form type with a query builder closure.

My back end

The Order/OrderTicket/Ticket design is largely based on https://github.com/beberlei/AcmePizzaBundle

Ticket

/**
 * @ORM\Entity(repositoryClass="Foo\BackendBundle\Entity\TicketsRepository")
 * @ORM\HasLifecycleCallbacks
 * @ORM\Table(name="tickets")
 */
class Tickets
{
    // id fields and others
    
    /**
     * @Assert\NotBlank
     * @ORM\Column(type="string", nullable=true)
     */
    protected $name;

    /**
     * @ORM\Column(type="date", name="available_from", nullable=true)
     */    
    protected $availableFrom;

    /**
     * @ORM\Column(type="date", name="available_to", nullable=true)
     */    
    protected $availableTo;
}

OrderTicket

/**
 * @ORM\Table()
 * @ORM\Entity
 */
class OrderTicket
{
    // id field

    /**
     * @ORM\Column(name="quantity", type="integer")
     */
    private $quantity;

    /**
     * @ORM\ManyToOne(targetEntity="Tickets")
     */
    protected $ticket;

    /**
     * @ORM\ManyToOne(targetEntity="Orders", inversedBy="tickets")
     */
    protected $order;
    
    // getters and setters for quantity, ticket and order
}

Order

/**
 * @ORM\Entity
 * @ORM\HasLifecycleCallbacks
 * @ORM\Table(name="orders")
 */
class Orders
{   
    // id field and other stuff
    
    /**
     * @ORM\OneToMany(targetEntity="OrderTicket", mappedBy="order", cascade={"persist"})
     **/
    protected $tickets;

    /**
     * @ORM\ManyToOne(targetEntity="Customer", cascade={"persist"})
     */
    protected $customer;

    public function __construct()
    {
        $this->tickets = new \Doctrine\Common\Collections\ArrayCollection();
    }
    
    // getters, setters, add for Tickets and Customer
}

Customer

/**
 * @ORM\Table()
 * @ORM\Entity
 */
class Customer
{
    // id, name, email, address fields

}

This creates a schema like so (table naming differences are from auto generation):

CREATE TABLE `tickets` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL,
  `available_from` date DEFAULT NULL,
  `available_to` date DEFAULT NULL,
  PRIMARY KEY (`id`)
);
CREATE TABLE `Customer` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `email` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
  `name` varchar(255) COLLATE utf8_unicode_ci NOT NULL,
  `address` longtext COLLATE utf8_unicode_ci NOT NULL,
  PRIMARY KEY (`id`)
);
CREATE TABLE `OrderTicket` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `ticket_id` int(11) DEFAULT NULL,
  `order_id` int(11) DEFAULT NULL,
  `quantity` int(11) NOT NULL,
  PRIMARY KEY (`id`)
);
CREATE TABLE `orders` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `customer_id` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
);

Forms

class CustomerType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('email')
            ->add('name')
            ->add('address')
        ;
    }

    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => 'Foo\BackendBundle\Entity\Customer'
        ));
    }

    public function getName()
    {
        return 'foo_backendbundle_customertype';
    }
}

class OrderTicketType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('quantity', 'integer')
            ->add('ticket')
        ;
    }

    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => 'Foo\BackendBundle\Entity\OrderTicket'
        ));
    }

    public function getName()
    {
        return 'foo_backendbundle_ordertickettype';
    }
}

class OrdersType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('customer', new CustomerType())
            ->add('tickets', 'collection', array(
                'type' => new OrderTicketType(),
                'allow_add'    => true,
                'allow_delete' => true,
                'prototype'    => true,
            ))
        ;
    }

    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => 'Foo\BackendBundle\Entity\Orders',
        ));
    }

    public function getName()
    {
        return 'foo_backendbundle_orderstype';
    }
}

The form

<form action="{{ path('index') }}" method="post" {{ form_enctype(form) }}>
    <h3>Tickets</h3>

    {{ form_errors(form) }}

    <table>
        <thead>
            <tr>
                <td>Ticket</td>
                <td>Quantity</td>
        </thead>
        <tbody>
            {% for ticketrow in form.tickets %}
            <tr>
                <td>{{ form_widget(ticketrow.ticket) }}</td>
                <td>{{ form_widget(ticketrow.quantity) }}</td>
            </tr>
            {% endfor %}
        </tbody>
    </table>

    <h3>Customer</h3>

    {% for customer in form.customer %}
        {{ form_row(customer) }}
    {% endfor %}
</form>

And finally the controller

class DefaultController extends Controller
{
    /**
     * @Route("/", name="index")
     * @Template()
     */
    public function indexAction(Request $request)
    {
        $em = $this->getDoctrine()->getManager();
        // IMPORTANT - the Tickets are prefiltered for active Tickets, these have to be injected into the Order atm. In other places I use this method on the query builder
        $tickets = $em->getRepository('FooBackendBundle:Tickets')->findActive();

        // check no tickets

        $order = new Orders();

        // To prepopulate the order with the available tickets, we have to do it like this, due to it being a collection,
        // rather than using the forms query_builder like everywhere else
        foreach($tickets as $ticket) {
            $ot = new OrderTicket();
            $ot->setTicket($ticket);
            $ot->setQuantity(0);
            $ot->setOrder($order);
            $order->addTicket($ot);
        }

        $form = $this->createForm(new OrdersType(), $order);

        if ($request->isMethod('POST')) {

            $form->bind($request);
            
            // IMPORTANT here I have to remove the previously added Tickets where the quantity is 0 - as they're not wanted in the Order.  Is there a better way to do this?
            // if the quantity of Ticket is 0, do not add to order
            // note we use the validation callback in Order to check total quantity of OrderTickets is > 0
            $order->removeTicketsWithNoQuantity();

            if ($form->isValid()) {
            
                $em->persist($order);
                $em->flush();

                return $this->redirect($this->generateUrl('order_show', array('id' => $order->getId())));
            }
        }

        return array('form' => $form->createView());
    }
}

Summary

This works and will save the Order correctly, but I'm not sure it is the correct way to do what I want, and it does not display as I want.

You can see below in the images how it looks and how the Order goes through. It is worth noting that in each of the Ticket drop downs is the rest of the Tickets but which are not active.

The Order page:

order1

The Order summary page after save:

order2

The 3 Tickets displayed are the ones that have been filtered, and I only want these Tickets on the form. I ONLY WANT TO SEE THE TICKET NAME, NOT AN EDITABLE DROP DOWN.

The core problem is that they are presented as editable drop downs. I may just want a text string of the Ticket name, or maybe even the Ticket price in the future. I'm not sure how to achieve this. I know that the ticket field and relationship must be rendered somehow so that it can be bound in the controller. So basically I want to be able to use the Ticket entity and it's fields on the same row as the quantity text box.

So let's step out of the crapstorm of Symfony2's forms and put this in perspective - in the normal world, obviously I'd just retrieve the Tickets, then for each Ticket, I'd print the Ticket name, any other stuff I wanted, a hidden Ticket id, then an input for the Ticket quantity. Back into SF2 a little - I guess I need the Ticket entity available whilst looping the OrderTicket collection.

Please help me!

like image 810
jmoz Avatar asked Oct 22 '22 20:10

jmoz


1 Answers

The easiest solution based on your code above would be create a custom type for your Ticket entity that simply displays the current ticket as a label, and also create a data transformer for it.

namespace WineVision\BackendBundle\Form\Type;

use Symfony\Component\Form\AbstractType;
use Doctrine\Common\Persistence\ObjectManager;
use Symfony\Component\Form\FormBuilderInterface;

use WineVision\BackendBundle\Form\Transformer\TicketToIdTransformer;

class TicketLabelType extends AbstractType
{
    private $om;

    public function __construct(ObjectManager $om)
    {
        $this->om = $om;
    }

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $transformer = new TicketToIdTransformer($this->om);
        $builder->addViewTransformer($transformer);
    }    

    public function getParent()
    {
        return 'hidden';
    }

    public function getName()
    {
        return 'ticket_label_type';
    }    
}

Then create a widget in Resources/Form/fields.html.twig

{% block ticket_label_type_widget %}
    {% spaceless %}
    <input type="hidden" {{ block('widget_attributes') }} {% if value is not empty %}value="{{ value }}" {% endif %} />
    <span class="ticketName">{{ form.vars.data.ticketNameMethod }}</span>
    {% endspaceless %}
{% endblock %}

TicketToIdTransformer:

namespace WineVision\BackendBundle\Form\Transformer;

use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\UnexpectedTypeException;

use Doctrine\Common\Persistence\ObjectManager;

class TicketToIdTransformer implements DataTransformerInterface
{
    private $om;

    public function __construct(ObjectManager $om)
    {
        $this->om = $om;
    }

    public function transform($ticket)
    {
        if (null === $ticket) {
            return "";
        }

        if (!$ticket instanceof \WineVision\BackendBundle\Entity\Ticket) {
            throw new UnexpectedTypeException($ticket, '\WineVision\BackendBundle\Entity\Ticket');
        }


        return $ticket->getId();
    }

    public function reverseTransform($id)
    {

        if ('' === $id || null === $id) {
            return null;
        }

        return $this->om
                    ->getRepository('WineVisionBackendBundle:Ticket')
                    ->findOneBy(array('id' => $id));

    }
}

Then create the service for your TicketType and pass the doctrine.orm.entity_manager as an argument, and in your OrderTicketType, use

$builder->add('ticket', 'ticket_label_type');

That should solve your problem for the code you've given above. To further extend the solution, you shouldn't pre-populate each order with each ticket type, and instead create a a custom collectiontype that uses Form Events to populate the collection with all of the ticket fields.

Hope this helps! I apologize if there are any syntax errors here. I've copied the code from one of my applications, and modified it for your needs.

like image 122
Mike Avatar answered Nov 04 '22 01:11

Mike