Zf3 populate select Element with data from Database - zend-framework3

I know this topic was discussed 2 years ago. But I stuck on difficulty that I would like to solve.
I would like to create a zf3 form which contains more than two Select Element.
I would like to populate them with data coming from different repositories (options values of each Select item come from distinct repository).
First all, I tried to pass the service manager (from where I can access to my repositories) in the constructor of my form but I heard that this solution is not suitable at all.
So how can I include multiples repositories in my form to populate my Select Elements?

Short answer:
Create a class that extends the Select
Create a factory for such class
Add this custom element in your module configuration (module.config.php)
Use this class as type for your form elements
Retrieve the form through the form manager
Example, for controllers, adapt the controller's factory
Detailed answer:
Create a class that extends the Select, like BrandSelect
namespace MyModule\Form\Element;
use Laminas\Form\Element\Select;
class BrandSelect extends Select {
protected $repository;
public function __construct($repository, $name = null, $options = []) {
parent::__construct($name, $options);
$this->repository = $repository;
}
/**
* Initialize the element
*
* #return void
*/
public function init() {
$valueOptions = [];
foreach ($this->repository->fetchBrands() as $brand) {
$valueOptions[$brand->getBrandId()] = $brand->getName();
}
asort($valueOptions);
$this->setValueOptions($valueOptions);
}
}
Create a factory for such class
namespace MyModule\Form\Element;
use Laminas\ServiceManager\Factory\FactoryInterface;
use Interop\Container\ContainerInterface;
use MyModule\Db\Repository;
class BrandSelectFactory implements FactoryInterface {
public function __invoke(ContainerInterface $container, $requestedName, $options = null): BrandSelect {
$repository = $container->get(Repository::class);
return new BrandSelect($repository);
}
}
Add this custom element in your module configuration (module.config.php)
namespace MyModule;
return [
// ..
// Other configs
// ..
'form_elements' => [
'factories' => [
Form\Element\BrandSelect::class => Form\Element\BrandSelectFactory::class
]
]
];
Use this class as type for your form elements.
It is really important to add all elements in the init() method, otherwise it will not work. I also added the InputFilterProviderInterface.
In this case, form doens't require any other element its constructor. If needed, you must create a factory for the form and pass all params you need. The form factory must be added in module.config.php configuration, always under the form_elements key (as did for the BrandSelect):
namespace MyModule\Form;
use Laminas\Form\Form;
use Laminas\InputFilter\InputFilterProviderInterface;
class BrandForm extends Form implements InputFilterProviderInterface {
public function __construct($name = null, $options = []) {
parent::__construct($name, $options);
}
// IT IS REALLY IMPORTANT TO ADD ELEMENTS IN INIT METHOD!
public function init() {
parent::init();
$this->add([
'name' => 'brand_id',
'type' => Element\BrandSelect::class,
'options' => [
'label' => 'Brands',
]
]);
}
public function getInputFilterSpecification() {
$inputFilter[] = [
'name' => 'brand_id',
'required' => true,
'filters' => [
['name' => 'Int']
]
];
return $inputFilter;
}
}
Retrieve the form through the form manager
Form must be retrieved using correct manager, which isn't the service manager, but the FormElementManager.
For example, if you need the form inside BrandController:
<?php
namespace MyModule\Controller;
use Laminas\Form\FormElementManager;
use Laminas\Mvc\Controller\AbstractActionController;
use Laminas\View\Model\ViewModel;
class BrandController extends AbstractActionController {
private $formManager;
public function __construct(FormElementManager $formManager) {
$this->formManager = $formManager;
}
public function addBrandAction() {
$form = $this->formManager->get(\MyModule\Form\BrandForm::class);
// Do stuff
return new ViewModel([
'form' => $form
]);
}
}
Finally, you'll have to adapt the controller's factory:
namespace MyModule\Controller;
use Laminas\ServiceManager\Factory\FactoryInterface;
use Interop\Container\ContainerInterface;
class BrandControllerFactory implements FactoryInterface {
public function __invoke(ContainerInterface $container, $requestedName, $options = null): BrandController {
$formManager = $container->get('FormElementManager');
return new BrandController($formManager);
}
}
which must be configured under controllers key in module.config.php:
namespace MyModule;
return [
// ..
// Other configs
// ..
'controllers' => [
'factories' => [
Controller\BrandController::class => Controller\BrandControllerFactory::class
],
],
'form_elements' => [
'factories' => [
Form\Element\BrandSelect::class => Form\Element\BrandSelectFactory::class
]
]
];

Related

ManyToMany new value must be an array or an instance of \Traversable, "NULL" given

I have a ManyToMany relation in my Symfony 4.2.6 application and I would like for it to be possible to have this to be null.
So my first entity SpecialOffers is as follows :
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
/**
* #ORM\Entity(repositoryClass="App\Repository\SpecialOfferRepository")
*/
class SpecialOffer
{
/**
* #ORM\ManyToMany(targetEntity="App\Entity\Neighbourhood", inversedBy="specialOffers")
*/
private $neighbourhood;
public function __construct()
{
$this->neighbourhood = new ArrayCollection();
}
/**
* #return Collection|Neighbourhood[]
*/
public function getNeighbourhood(): Collection
{
return $this->neighbourhood;
}
public function addNeighbourhood(Neighbourhood $neighbourhood): self
{
if (!$this->neighbourhood->contains($neighbourhood)) {
$this->neighbourhood[] = $neighbourhood;
}
return $this;
}
public function removeNeighbourhood(Neighbourhood $neighbourhood): self
{
if ($this->neighbourhood->contains($neighbourhood)) {
$this->neighbourhood->removeElement($neighbourhood);
}
return $this;
}
}
It is related to the neighbourhood class :
/**
* #ORM\Entity(repositoryClass="App\Repository\NeighbourhoodRepository")
*/
class Neighbourhood implements ResourceInterface
{
/**
* #ORM\ManyToMany(targetEntity="App\Entity\SpecialOffer", mappedBy="neighbourhood")
*/
private $specialOffers;
public function __construct()
{
$this->specialOffers = new ArrayCollection();
}
/**
* #return Collection|SpecialOffer[]
*/
public function getSpecialOffers(): Collection
{
return $this->specialOffers;
}
public function addSpecialOffer(SpecialOffer $specialOffer): self
{
if (!$this->specialOffers->contains($specialOffer)) {
$this->specialOffers[] = $specialOffer;
$specialOffer->addNeighbourhood($this);
}
return $this;
}
public function removeSpecialOffer(SpecialOffer $specialOffer): self
{
if ($this->specialOffers->contains($specialOffer)) {
$this->specialOffers->removeElement($specialOffer);
$specialOffer->removeNeighbourhood($this);
}
return $this;
}
}
And finally the form is
class SpecialOfferType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add(
'neighbourhood',
EntityType::class,
[
'class' => Neighbourhood::class,
'label' => 'form.neighbourhood.label',
'translation_domain' => 'Default',
'required' => false,
'placeholder' => 'form.neighbourhood.all'
]
);
}
}
But when I don't select a specific neighbourhood for the Special offer in my form I get the following error :
Could not determine access type for property "neighbourhood" in class "App\Entity\SpecialOffer": The property "neighbourhood" in class "App\Entity\SpecialOffer" can be defined with the methods "addNeighbourhood()", "removeNeighbourhood()" but the new value must be an array or an instance of \Traversable, "NULL" given.
Is there anyway I can make it so that my special offer either contains and array of neighbourhoods or just null ?
I feel like I'm overlooking something really obvious, any help would be greatly appreciated
Test =>
$builder
->add(
'neighbourhood',
EntityType::class,
[
'class' => Neighbourhood::class,
'label' => 'form.neighbourhood.label',
'translation_domain' => 'Default',
'required' => false,
'multiple' => true,
'placeholder' => 'form.neighbourhood.all'
]
);
Since your fields on the entities are both many-to-many, thus expecting an array (or similar) and the form field is of EntityType, which will return one Entity of the expected type or null, I feel like there is some form of asymmetry.
I would consider using the CollectionType from the start or at least setting the multiple option on the form to true, so that the return value is an array.
Another option would be to add a DataTransformer to the form field, which turns null into an empty array and one entity into an array of one entity, and vice-versa.

easyadmin entity field's dynamic custom choices

Installed easyadminbundle with symfony 4, configured for an entity name Delivery and it has a field associated to another entity name WeeklyMenu:
easy_amin.yaml:
Delivery:
...
form:
fields:
- { property: 'delivered'}
- { property: 'weeklyMenu', type: 'choice', type_options: { choices: null }}
I need a dynamically filtered results of weeklyMenu entity here, so I can get a list of the next days menus and so on. It's set to null now but have to get a filtered result here.
I've read about overriding the AdminController which I stucked with it. I believe that I have to override easyadmin's query builder that listing an associated entity's result.
i've figured out, here is the solution if someone looking for:
namespace App\Controller;
use Doctrine\ORM\EntityRepository;
use EasyCorp\Bundle\EasyAdminBundle\Controller\EasyAdminController;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\FormBuilder;
class AdminController extends EasyAdminController {
public function createDeliveryEntityFormBuilder($entity, $view) {
$formBuilder = parent::createEntityFormBuilder($entity, $view);
$fields = $formBuilder->all();
/**
* #var $fieldId string
* #var $field FormBuilder
*/
foreach ($fields as $fieldId => $field) {
if ($fieldId == 'weeklyMenu') {
$options = [
'attr' => ['size' => 1,],
'required' => true,
'multiple' => false,
'expanded' => false,
'class' => 'App\Entity\WeeklyMenu',
];
$options['query_builder'] = function (EntityRepository $er) {
$qb = $er->createQueryBuilder('e');
return $qb->where($qb->expr()->gt('e.date', ':today'))
->setParameter('today', new \DateTime("today"))
->andWhere($qb->expr()->eq('e.delivery', ':true'))
->setParameter('true', 1)
->orderBy('e.date', 'DESC');
};
$formBuilder->add($fieldId, EntityType::class, $options);
}
}
return $formBuilder;
}
}
so the easyAdmin check if a formbuilder exists with the entity's name i.e. create<ENTITYNAME>FormBuilder(); and you can override here with your own logic.
Another approach to this would be to create new FormTypeConfigurator and overwrite choices and/or labels. And tag it as:
App\Form\Type\Configurator\UserTypeConfigurator:
tags: ['easyadmin.form.type.configurator']
and the configurator looks like this:
<?php
declare(strict_types = 1);
namespace App\Form\Type\Configurator;
use App\Entity\User;
use EasyCorp\Bundle\EasyAdminBundle\Form\Type\Configurator\TypeConfiguratorInterface;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormConfigInterface;
final class UserTypeConfigurator implements TypeConfiguratorInterface
{
/**
* {#inheritdoc}
*/
public function configure($name, array $options, array $metadata, FormConfigInterface $parentConfig)
{
if ($parentConfig->getData() instanceof User) {
$options['choices'] = User::getUserStatusAvailableChoices();
}
return $options;
}
/**
* {#inheritdoc}
*/
public function supports($type, array $options, array $metadata)
{
return in_array($type, ['choice', ChoiceType::class], true);
}
}

empty_data not working for compound forms, or entity is not being instantiated (ArgumentCountError: too few arguments to function)

I've got a Company that has many Employees. In my form, I want the user to be able to dynamically add employees (easy enough). EmployeeType (an AbstractType) is compound, containing a first and last name. On form submission, Symfony doesn't seem to carry over the data from the form into the constructor for the "new" Employee. I get an erro
ArgumentCountError: Too few arguments to function Employee::__construct() ... 0 passed in ... and exactly 3 expected
Showing and editing existing Employees works, so I'm confident my relationships, etc., are all correct.
Abbreviated code:
Company
class Company
{
protected $employees;
public function __construct()
{
$this->employees = new ArrayCollection();
}
public function addEmployee(Employee $employee)
{
if ($this->employees->contains($employee)) {
return;
}
$this->employees->add($employee);
}
public function removeEmployee(Employee $employee)
{
if (!$this->employees->contains($employee)) {
return;
}
$this->employees->removeElement($employee);
}
}
Employee
class Employee
{
// ... firstName and lastName properties...
public function __construct(Company $company, $firstName, $lastName)
{
$this->company = $company;
$this->company->addEmployee($this);
}
// ...getter and setter for firstName / lastName...
}
CompanyType
class CompanyType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add('employees', CollectionType::class, [
'entry_type' => EmployeeType::class,
'allow_add' => true,
'allow_delete' => false,
'required' => false,
]);
// ...other fields, some are CollectionType of TextTypes that work correctly...
}
}
EmployeeType
class EmployeeType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add('firstName')
->add('lastName');
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Employee::class,
]);
}
}
CompanyController
class CompanyController
{
// Never mind that this is a show and not edit, etc.
public function showAction()
{
// Assume $this->company is a new or existing Company
$form = $this->createForm(CompanyType::class, $this->company);
$form->handleRequest($this->request);
if ($form->isSubmitted() && $form->isValid()) {
$company = $form->getData();
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($company);
$entityManager->flush();
}
// set flash message, redirect, etc.
}
// ...render view...
}
The above will work when modifying existing Employees, just not when creating new ones. Debugging from within the Symfony code, I can see that no data exists for the new employees, so it's trying to find a closure or definition for empty_data in CompanyType. I've tried this every which way (via configureOptions, and empty_data option when building the CompanyType::buildForm form), e.g. https://symfony.com/doc/current/form/use_empty_data.html. My gut tells me I don't even need to do this, because the form data should not be empty (I explicitly filled out the fields).
I tried using a model transformer as well. In that case, the transformation from the form (second function argument passed to new CallbackTransformer) isn't even hit.
The view properly sets name attributes when adding new employee fields, e.g. form[employees][1][firstName], etc. That isn't the problem. It also sends the right data to the controller. I confirmed this by inspecting the form submission data via CompanyType::onPreSubmit (using an event listener).
I also have a CollectionType of TextTypes for other things in CompanyType, those work fine. So the issue seems to be related to the fact that EmployeeType is compound (containing multiple fields).
Hopefully the above is enough to illustrate the problem. Any ideas?
UPDATE:
It seems the issue is there isn't an instantiation of Employee for Symfony to work with. Internally, each field gets passed to Symfony\Component\Form\Form::submit(). For existing employees, there is also an Employee passed in. For the new one, it's null. That explains why it's looking for empty_data, but I don't know why I can't get empty_data to work.
The solution was to define empty_data in the compound form, and not the CollectionType form.
My situation is a little weird, because I also need the instance of Company in my EmployeeType, as it must be passed to the constructor for Employee. I accomplished this by passing in the Company as form option into configureOptions (supplied by the controller), and then into entry_options. I don't know if this is best practice, but it works:
CompanyController
Make sure we pass in the Company instance, so it can be used in EmployeeType when building a new Employee:
$form = $this->createForm(CompanyType::class, $this->company, [
// This is a form option, valid because it's in CompanyType::configureOptions()
'company' => $this->company,
]);
CompanyType
class CompanyType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add('employees', CollectionType::class, [
// ...
// Pass the Company instance to the EmployeeType.
'entry_options' => [ 'company' => $options['company'] ],
// This was also needed, apparently.
'by_reference' => false,
]);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
// Allows the controller to pass in a Company instance.
'company' => null,
]);
}
}
EmployeeType
Here we make sure empty_data properly builds an Employee from the form data.
class EmployeeType extends AbstractType
{
private $company;
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add('firstName')
->add('lastName');
// A little weird to set a class property here, but we need the Company
// instance in the 'empty_data' definition in configureOptions(),
// at which point we otherwise wouldn't have access to the Company.
$this->company = $options['company'];
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Employee::class,
'empty_data' => function (FormInterface $form) use ($resolver) {
return new Employee(
$this->company,
$form->get('firstName')->getData(),
$form->get('lastName')->getData(),
);
},
]);
}
}
Viola! I can now add new employees.
Hope this helps other people!

JMS Serializer: Dynamically change the name of a virtual property at run time

I use JMS Serializer Bundle and Symfony2. I am using VirtualProperties. currently, I set the name of a property using the SerializedName annotation.
/**
* #JMS\VirtualProperty()
* #JMS\SerializedName("SOME_NAME")
*/
public function getSomething()
{
return $this->something
}
Is it possible to set the serialized name dynamically inside the function? Or is it possible to dynamically influence the name using Post/Pre serialization events?
Thanks!
I don't think you can do this directly, but you could accomplish something similar by having several virtual properties, one for each possible name. If the name is not relevant to a particular entity, have the method return null, and disable null serialization in the JMS config.
In the moment when you go to serialize the object, do the following:
$this->serializer = SerializerBuilder::create()->setPropertyNamingStrategy(new IdenticalPropertyNamingStrategy())->build();
$json = $this->serializer->serialize($object, 'json');
dump($json);
Entity
/**
* #JMS\VirtualProperty("something", exp="context", options={
* #JMS\Expose,
* })
*/
class SomeEntity
{
}
Event Listener
abstract class AbstractEntitySubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
[
'event' => Events::POST_SERIALIZE,
'method' => 'onPostSerialize',
'class' => static::getClassName(),
'format' => JsonEncoder::FORMAT,
'priority' => 0,
],
];
}
public function onPostSerialize(ObjectEvent $event): void
{
foreach ($this->getMethodNames() as $methodName) {
$visitor = $event->getVisitor();
$metadata = new VirtualPropertyMetadata(static::getClassName(), $methodName);
if ($visitor->hasData($metadata->name)) {
$value = $this->{$methodName}($event->getObject());
$visitor->visitProperty(
new StaticPropertyMetadata(static::getClassName(), $metadata->name, $value),
$value
);
}
}
}
abstract protected static function getClassName(): string;
abstract protected function getMethodNames(): array;
}
...
class SomeEntitySubscriber extends AbstractEntitySubscriber
{
protected static function getClassName(): string
{
return SomeEntity::class;
}
protected function getMethodNames(): array
{
return ['getSomething'];
}
protected function getSomething(SomeEntity $someEntity)
{
return 'some text';
}
}

Good practices using forms

I have to change a web that is using different types to generate forms. There is in Bundle/Form/ folder 2 files:
ProductType.php
ProductEditType.php
It's working fine, the first one is used to generate the new product form and the second one the form to edit it.
Almost 95% of both files is the same, so I guess it has to exist any way to use one type to generate more than one form.
I have been reading about how to modify forms using form events, but I have not found clearly what is the general good practice about it.
Thanks a lot.
Update
I wrote an Event Subscriber as follows.
<?php
namespace Project\MyBundle\Form\EventListener;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Description of ProductTypeOptionsSubscriber
*
* #author Javi
*/
class ProductTypeOptionsSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents() {
return array(FormEvents::PRE_SET_DATA => 'preSetData');
}
public function preSetData(FormEvent $event){
$data = $event->getData();
$form = $event->getForm();
if( !$data || !$data->getId() ){
// No ID, it's a new product
//.... some code for other options .....
$form->add('submit','submit',
array(
'label' => 'New Produtc',
'attr' => array('class' => 'btn btn-primary')
));
}else{
// ID exists, generating edit options .....
$form->add('submit','submit',
array(
'label' => 'Update Product',
'attr' => array('class' => 'btn btn-primary')
));
}
}
}
In ProductType, inside buildForm:
$builder->addEventSubscriber(new ProductTypeOptionsSubscriber());
So that's all, it was very easy to write and it works fine.
You can read this cookbook event subscriber, the first scenario can do for you.
Returning to the example of the documentation..
Add the fields that you want them to being modified in this way:
$builder->addEventSubscriber(new AddNameFieldSubscriber());
Then create the event event subscriber by entering your logic:
// src/Acme/DemoBundle/Form/EventListener/AddNameFieldSubscriber.php
namespace Acme\DemoBundle\Form\EventListener;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class AddNameFieldSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents()
{
// Tells the dispatcher that you want to listen on the form.pre_set_data
// event and that the preSetData method should be called.
return array(FormEvents::PRE_SET_DATA => 'preSetData');
}
public function preSetData(FormEvent $event)
{
$data = $event->getData();
$form = $event->getForm();
// check if the product object is "new"
// If you didn't pass any data to the form, the data is "null".
// This should be considered a new "Product"
if (!$data || !$data->getId()) {
$form->add('name', 'text');
....
..... // other fields
}
}
}

Resources