I'm developing a e-commerce system with customizable products. Each product may have some options that, for its part, may have one or many values selected by consumer. I can't use the variant approach due the high level of customization of my users (some products could have more than 1M of variants), so I need persist the option combination chosen by costumer.
The form options are assembled dynamically once each product may have distinct options. This form should transform the user choice into a storable structure for a relational database.
Basically, it's my scenario (my attempt):
Product
Option
OptionValue
ProductOption
Order
OrderItem
OrderItemOption
Fixture:
Option: 1#Salad
Values: 1#Tomato, 2#Lettuce, 3#Pickles, 3#Carrot
Product: Hamburger
ProductOption: 1#Salad
Values: 1#Tomato, 2#Lettuce, Picles
My target is something like:
class OrderItemType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$field = $builder->create('options', new OptionPickerType(), ['options' => $options['product']->getOptions()]);
$field->addModelTransformation(new FixOptionIndexTransformer());
$builder->add($field);
}
}
class OptionPickerType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
foreach ($options['options'] as $productOption) {
$name = $productOption->getId();
$builder->add($name, 'choice', array(
'choice_list' => new ObjectChoiceList($productOption->getValues(), 'label', array(), null, 'id'),
'multiple' => true,
'cascade_validation' => true,
'property_path' => '['.$name.']'
));
}
}
}
$form = $factory->create(new OrderItemType(), ['product' => $product]);
if ($request->isMethod('POST')) {
$form->bind($request);
if ($form->isValid()) {
$item = $form->getItem(); // A collection of ItemOption filled with the OptionValue picked out from Choice field
}
}
This configuration will return a collection of arrays of OptionValue as expected. In fact it isn't enough for my purposes. What I really need is a flatten collection with all chosen values more some extra data:
class ItemOption
{
protected $item;
protected $productOption;
protected $option; // $productOption->getName()
protected $optionValue;
protected $value; // / $optionValue->getLabel()
}
As you can see, the value of Choice field in fact is inside the ItemOption.
After some days trying, I could not figure out how to do this or even think in other approach.
Can you help me?
First of all, often when I find it hard to map a form to my model, I later discover that the model is overly complicated. Simplifying the model to have clear relationships and intermediate objects where necessary (and none where not necessary) often helps here.
That being said, it seems to me that a model transformer on your option picker should do the job:
foreach ($options['options'] as $productOption) {
// ...
}
$builder->addModelTransformer(new CallbackTransformer(
// model to normalized
// needed when setting default values, not sure if required in your case
function ($modelData) {
},
// normalized to model
// converts the array of arrays of OptionValues to an array of ItemOptions
function ($normalizedData) {
$itemOptions = array();
foreach ($normalizedData as $optionValues) {
foreach ($optionValues as $optionValue) {
$itemOption = new ItemOption();
$itemOption->setProductOption($optionValue->getProductOption());
$itemOption->setOptionValue($optionValue);
$itemOptions[] = $itemOption;
}
}
return $itemOptions;
}
));
Related
I'm trying to create a nested form for these three instances, where the inventory has default data, and the nested form InventoryProduct has all the Products in the database by default in the form.
Inventory (has one or more InventarioProduct) - Id, StartDate, EndDate
InventoryProduct - Id, Product, Units, RejectedUnits, QuarantineUnits
Product - Id, Name, Inci, some other data from product
So we add to InventoryCrudCrontroller the createEntityMethod:
public function createEntity(string $entityFqcn)
{
$inventory= new Inventory();
$inventory->setStartDate(new DateTime('now'));
$inventory->setEndDate(null);
$productRepository= $this->entityManager->getRepository(MateriaPrima::class);
$products= $productRepository->findAll();
foreach ($products as $product) {
$inventoryProduct= new InventoryProduct();
$inventoryProduct->setProduct($product);
$inventoryProduct->setUnits(0);
$inventoryProduct->setUnitsRejected(0);
$inventoryProduct->setUnitsQuarantine(0);
$inventoryProduct->setInventory($inventory);
$inventory->addInventarioProduct($inventoryProduct);
}
And on the configureFields method on InventoryCrudCrontroller:
public function configureFields(string $pageName): iterable
{
if (Crud::PAGE_EDIT === $pageName || Crud::PAGE_NEW == $pageName) {
return [
DateTimeField::new('startDate')
->setColumns(6)
->setValue(new DateTime()),
DateTimeField::new('endDate')
->setColumns(6),
CollectionField::new('products', 'Products:')
->onlyOnForms()
->allowAdd()
->allowDelete()
->setEntryIsComplex(false)
->setEntryType(InventoryProductType::class)
->renderExpanded(true)
->setFormTypeOptions(
[
'by_reference' => false,
]
)
->setColumns(12),
And we add the class InventoryProductType for the customs form:
class InventoryProducts extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add(
'product',
EntityType::class,
['class' => Product::class, 'label' => '-']
)
->add('units')
->add('unitsRejected')
->add('unitsQuarantine')
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => InventoryProduct::class,
]);
}
}
When we try to add another registry, we got:
Entity of type "App\Entity\Inventory" passed to the choice field must
be managed. Maybe you forget to persist it in the entity manager?
What am I doing wrong?
Thanks for your help!!
The error tell you that you are using an entity of type App\Entity\Inventory which is not managed by the entity manager.
If you look into your code, you are creating a new entity in your createEntity method, but never persisted it.
You could either persist it in your method like:
public function createEntity(string $entityFqcn)
{
$inventory= new Inventory();
$inventory->setStartDate(new DateTime('now'));
$inventory->setEndDate(null);
$this->entityManager->persist($inventory);
$productRepository= $this->entityManager->getRepository(MateriaPrima::class);
$products= $productRepository->findAll();
foreach ($products as $product) {
$inventoryProduct= new InventoryProduct();
$inventoryProduct->setProduct($product);
$inventoryProduct->setUnits(0);
$inventoryProduct->setUnitsRejected(0);
$inventoryProduct->setUnitsQuarantine(0);
$inventoryProduct->setInventory($inventory);
$this->entityManager->persist($inventoryProduct);
$inventory->addInventarioProduct($inventoryProduct);
}
}
Or add cascading persist on your entity.
I need to check inside the FormType which field has changed. Is there any method to do it? I've searched for a while, then tried to get edited entities field in few ways (with form events too) to catch the edited fields, but no simple result.
Is there any way to do it easy, or I need to be more creative in making such thing? The best it would be, if I can get an example with entity type, but any clue would be great.
P.S. I cant do it on client-side - I must do it on server side for particular reason.
Done with this:
https://stackoverflow.com/a/33923626/8732955
Suppose we want to check the "status" field in our ImportantObject, code needs to look like that
if($form->isSubmitted() && $form->isValid())
{
$uow = $em->getUnitOfWork();
$uow->computeChangeSets();
$changeSet = $uow->getEntityChangeSet($importantObject);
if(isset($changeSet['status'])){
//do something with that knowledge
}
}
Old post but interesting question.
How I solved it to check a relation between entities but it also works for a single field value. Easier than dealing with doctrine listeners.
Imagine you have a user with multiple tags and a form with checkboxes to add or remove tags
In the controller, create a new variable that contains the value to monitor :
$oldValue = '';
foreach ( $user->getTags() as $tag )
$oldValue .= $tag->getId().";";
Give it to the formType as an option
$form = $this->get('form.factory')->create(userType::class, $user,
['oldValue' => $oldValue ]);
In the formType, create an hidden field
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
public function buildForm(FormBuilderInterface $builder, array $options)
....
$oldValue = $options['oldValue'];
$builder
->add('oldValue', HiddenType::class, [
'data' => $oldValue,
'mapped' => false,
]);
...
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'pathToEntity',
'oldValue' => null,
));
}
Back in the controller get your old field value :
if ( $form->isSubmitted() && $form->isValid() )
{
// Stuff
$em->flush();
// Check changes :
$oldValue = $form->get('oldValue')->getData();
$oldValues = explode(';', $oldValue);
$newValues = $user->getTags();
Compare arrays and finish the stuff...
I have a simple form with Sonata admin.
I would like the user could add a list of integers (as many as he wants). And after it would be store as an array in my object:
[1, 2, 3, 6, 9]
There any way of doing it without creating another class to instantiate the integers?
UPDATE:
The only way I know how to something close is using choice like:
->add('type', 'choice', [
"required" => true,
"expanded" => true,
"multiple" => false,
"choices" => Campanha::getTypes(),
])
But with that I have a limited number of choices, I would like that it would be free to the user to add the quantity of numbers and the values he wants
All you need to accomplish this is a Data Transformer. Look at an example:
namespace AppBundle\Form\DataTransformer;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;
class ArrayToStringTransformer implements DataTransformerInterface
{
public function transform($array)
{
if (null === $array) {
$array = array();
}
if (!is_array($array)) {
throw new TransformationFailedException('Expected an array.');
}
return implode(',', $array);
}
public function reverseTransform($string)
{
if (null === $string || '' === $string) {
return array();
}
if (!is_string($string)) {
throw new TransformationFailedException('Expected a string.');
}
return explode(',', $string);
}
}
Later, use it where there is an array field. For greater reusability let's create a custom field type which extends of TextType:
namespace AppBundle\Form\Type;
use AppBundle\Form\DataTransformer\ArrayToStringTransformer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
class ArrayTextType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->addModelTransformer(new ArrayToStringTransformer());
}
public function getParent()
{
return TextType::class;
}
}
That's it! Now you can manage your array fields safely by using your ArrayTextType:
// in any controller context
public function fooAction()
{
$data = array(
'tags' => array(1, 2, 3, 4, 5),
);
$form = $this->createFormBuilder($data)
->add('tags', ArrayTextType::class)
->getForm();
return $this->render('default/index.html.twig', array('form' => $form->createView()));
}
Also we can use any Doctrine array mapped field (i.e. #ORM\Column(name="tags", type="array")).
Output result:
For better data entry I recommend using with this the Bootstrap Tags Input jQuery plugin. See examples here.
Try looking into sonata_type_native_collection:
From the Sonata Admin Docs:
This bundle handle the native Symfony collection form type by adding:
an add button if you set the allow_add option to true.
a delete button if you set the allow_delete option to true.
And the Symfony collection form type:
This field type is used to render a "collection" of some field or form. In the easiest sense, it could be an array of TextType fields that populate an array emails values.
So, for your case, maybe something like:
->add('type', 'sonata_type_native_collection', [
'required' => true,
'entry_type' => 'number',
'options' => [
// Any options you'd like the integer fields to have.
]
])
(This doesn't speak at all to the change's you'll need to make to the underlying model, of course.)
Edit: Changed the 'entry_options' array key to 'options', as per #Matheus Oliveira's comment.
There seems to be many possible ways to do this but none seem ideal.
Array solution
Use the same strings for both the choice values and labels (array keys and values)
// ContactType.php
$builder->add('gender', 'choice', array(
'choices' => array(
'Male' => 'Male',
'Female' => 'Female',
),
));
Now in a twig template on, for example, a confirmation page the choice can be output as follows:
// show.html.twig
{{ gender }}
Pros:
Simple to implement
Templates are simple to create and for designers to understand
Cons:
If the submitted form's data is stored in a database the gender option will be stored as a full string Male or Female rather than a machine friendly value such as 'm' or 'f', or 0 or 1, which seems bad programming practice
ChoiceList solution
Use machine friendly values for the choice options and create a Twig filter to read these in the template.
// GenderChoiceList.php
class GenderChoiceList extends LazyChoiceList
{
protected function loadChoiceList()
{
$choices = array(
0 => 'm',
1 => 'f',
);
$labels = array(
0 => 'Male',
1 => 'Female',
);
return new ChoiceList($choices, $labels);
}
}
// ContactType.php
$builder->add('gender', 'choice', array(
'choice_list' => new GenderChoiceList(),
));
// TwigExtension.php
$filter = new Twig_SimpleFilter('getChoice', function ($value) {
$genderChoiceList = new GenderChoiceList();
$choices = $genderChoiceList->getChoicesForValues(array($value))
return $choices[0];
});
// show.html.twig
{{ gender|getChoice }}
Pros:
The gender is now stored in the database in a more sensible format
Cons:
Lots more code for something that feels like it should be trivial
Use of the Twig filter in the template is not clear and obvious for designers
Doctrine solution
Create a related entity for the choice options.
// src/AppBundle/Entity/Gender.php
// ...
class Gender
{
/**
* #ORM\Column(type="integer")
* #ORM\Id
* #ORM\GeneratedValue(strategy="AUTO")
*/
protected $id;
/**
* #ORM\Column(type="string", length=100)
*/
protected $name;
public function __toString()
{
return $this->name;
}
public function getName()
{
return $this->name;
}
// ...
}
// src/AppBundle/Entity/Contact.php
// ...
class Contact
{
// ...
/**
* #ORM\ManyToOne(targetEntity="Gender", inversedBy="contacts")
* #ORM\JoinColumn(name="gender_id", referencedColumnName="id")
*/
protected $gender;
}
// ContactType.php
$builder->add('gender', 'entity', array(
'class' => 'AppBundle:Gender',
));
// show.html.twig
{{ gender }} or {{ gender.name }}
Pros:
The gender is stored in the database in a more sensible format
The choices are also stored in the database, which seems like a sensible place for this kind of data
The template code is simple and clear
Cons:
There is a lot of code for something that feels like it should be trivial
There will be additional database calls when rendering the template unless a custom query with joins is written, which is again more code
Concluding question
Is there a better option? Something simple with no cons?
UPDATE:
Perhaps this can be handled best in the Contact entity's getGender() method.
// Contact.php
class Contact
{
// ...
public function getGender()
{
if (null === $this->gender) {
return $this->gender;
}
$choices = array(
'm' => 'Male',
'f' => 'Female',
);
return $choices[$this->gender];
}
// ...
}
To output the full string Male or Female as follows:
// show.html.twig
{{ gender }}
I use the ChoiceList/TwigExtension approach for a couple of reasons:
Generically speaking, an entity shouldn't have to know about display logic
Centralizing retrieval logic in the TwigExtension means that it's easy to apply transformations to all your lists in the future
To make it easy to get at the choice values, define them so that they are accessible in the type:
class GenderChoiceList extends ChoiceList
{
public $keyValues = ['m' => 'Male', 'f' => 'Female'];
public function __construct()
{
parent::__construct(array_keys($this->keyValues), array_values($this->keyValues));
}
}
Make your filter or function aware of the ChoiceList form which the values should be retrieved (I typically use a function):
class ChoiceTwigExtension extends \Twig_Extension
{
public function getFunctions()
{
return [
new \Twig_SimpleFunction('choice', array($this, 'choiceFunction'))
];
}
public function choiceFunction($class, $choice)
{
$choiceList = new $class;
return $choiceList->keyValues[$choice];
}
...
In the template, pass the class and the key for the label:
{{ choice('Bundle\\ChoiceList\\GenderChoiceList', gender) }}
So, similar to the way that Twig's constant works.
Speaking of constants, this can probably be made a bit cleaner now that PHP 5.6 supports array consts.
Aside: I like having all the lists as distinct ChoiceList classes. This may seem like a bit of overkill at the beginning of a project, but makes life much easier as the system evolves and forms multiply.
I want to create a Survey that consists of multiple Questions (different implementation classes).
I would love to represent the Survey creation as well as all the Questions as FormType to make it easy to have Validation and all the good stuff from the Symfony Form Component.
It is very easy to nest forms like described here.
But now comes the tricky part:
Every Question has its own FormType and a survey should be created as well as answered (filled out by the survey taker) on one page. So all questions on one page. More or less this is like Google Forms, being able to add new Questions on one page quickly as well make it easy for the user to see all questions at once.
My 2 Question would be:
How can I add a FormType whose nested Types are known at runtime (admin can select which Question Type he wants to add)?
How can I validate and store all the Forms on one page when the survey taker fills out a survey?
I would love to hear some ideas from you.
Thanks,
Lukas
Use the power of Listeners. You can use almost the same flow that CollectionType uses with ResizeListener.
public function preSetData(FormEvent $event)
{
$form = $event->getForm();
$data = $event->getData();
...
foreach ($data as $name => $value) {
$form->add($name, $this->getTypeByClass($value), array_replace(array(
'property_path' => '['.$name.']',
), $this->options));
}
}
...
public function preSubmit(FormEvent $event)
{
$form = $event->getForm();
$data = $event->getData();
...
if ($this->allowAdd) {
foreach ($data as $name => $value) {
if (!$form->has($name)) {
// put special value into sub-form to indicate type of the question
$type = $value['type'];
unset($value['type']);
$form->add($name, $type, array_replace(array(
'property_path' => '['.$name.']',
), $this->options));
}
}
}
}
Try to implement the very similar flow with allowDelete, allowAdd features.
There should be another classes like SurveyData.{items, survey, ...} with n-1 relation to Survey, SurveyItem.{answer, ...} with n-1 association to QuestionAnswer. On the base of the your structure there should be written validators.
Cascade validation can be triggered with Valid constraint.
http://symfony.com/doc/current/reference/constraints/Valid.html
UPDATE
What to do to form mutable part.
public function buildForm(FormBuilderInterface $builder, array $options)
{
if ($options['allow_add'] && $options['prototyped']) {
// #var ['prototype_name' => '__name__', 'type' => 'question_type']
foreach ($options['prototypes'] as $prototype) {
$prototype = $builder->create($prototype['prototype_name'], $options['type'], $options['options']);
$prototype->add('type', 'hidden', ['data' => $options['type'], 'mapped' => false]);
$prototypes[$options['type']] = $prototype->getForm();
}
$builder->setAttribute('prototypes', $prototypes);
}
...
}
public function buildView(FormView $view, FormInterface $form, array $options)
{
$view->vars = array_replace($view->vars, array(
'allow_add' => $options['allow_add'],
'allow_delete' => $options['allow_delete'],
));
if ($form->getConfig()->hasAttribute('prototypes')) {
$view->vars['prototypes'] = $form->getConfig()->getAttribute('prototypes')->createView($view);
}
}
Now are able to use prototypes in the form block in twig.
{% for key, prototype in prototypes %}
{% set data_prototypes[key] = form_row(prototype) %}
{% endfor %}
{% set attr = attr|merge({'data-prototypes' : data_prototypes|json_encode })
Now you don't need ajax requests in JS - just use prototypes.
(var collection = $('your_collection')).append(collection.data('prototypes')[question_type].replace(/__name__/g, counter+1));
You added the element to collection, now admin can fill it and submit the form. Rest of the work (mapping data to class) will be done by Symfony.