diff --git a/assets/controllers/elements/structural_entity_select_controller.js b/assets/controllers/elements/structural_entity_select_controller.js index 4c0236e7..27425101 100644 --- a/assets/controllers/elements/structural_entity_select_controller.js +++ b/assets/controllers/elements/structural_entity_select_controller.js @@ -33,10 +33,13 @@ export default class extends Controller { //Extract empty message from data attribute this._emptyMessage = this.element.getAttribute("data-empty-message") ?? ""; + const allowAdd = this.element.getAttribute("data-allow-add") === "true"; + let settings = { allowEmptyOption: true, selectOnTab: true, maxOptions: null, + create: allowAdd, searchField: [ {field: "text", weight : 2}, diff --git a/src/Form/AttachmentFormType.php b/src/Form/AttachmentFormType.php index 79cc28a1..ad1c53a9 100644 --- a/src/Form/AttachmentFormType.php +++ b/src/Form/AttachmentFormType.php @@ -75,6 +75,7 @@ class AttachmentFormType extends AbstractType 'label' => 'attachment.edit.attachment_type', 'class' => AttachmentType::class, 'disable_not_selectable' => true, + 'allow_add' => $this->security->isGranted('@attachment_types.create'), ]); $builder->add('showInTable', CheckboxType::class, [ diff --git a/src/Form/Part/OrderdetailType.php b/src/Form/Part/OrderdetailType.php index 08c51a34..8489bc73 100644 --- a/src/Form/Part/OrderdetailType.php +++ b/src/Form/Part/OrderdetailType.php @@ -63,6 +63,7 @@ class OrderdetailType extends AbstractType 'class' => Supplier::class, 'disable_not_selectable' => true, 'label' => 'orderdetails.edit.supplier', + 'allow_add' => $this->security->isGranted('@suppliers.create'), ]); $builder->add('supplier_product_url', UrlType::class, [ diff --git a/src/Form/Part/PartBaseType.php b/src/Form/Part/PartBaseType.php index d9365369..db049c9d 100644 --- a/src/Form/Part/PartBaseType.php +++ b/src/Form/Part/PartBaseType.php @@ -104,15 +104,15 @@ class PartBaseType extends AbstractType ]) ->add('category', StructuralEntityType::class, [ 'class' => Category::class, + 'allow_add' => $this->security->isGranted('@categories.create'), 'label' => 'part.edit.category', 'disable_not_selectable' => true, - 'constraints' => [ - ], ]) ->add('footprint', StructuralEntityType::class, [ 'class' => Footprint::class, 'required' => false, 'label' => 'part.edit.footprint', + 'allow_add' => $this->security->isGranted('@footprints.create'), 'disable_not_selectable' => true, ]) ->add('tags', TextType::class, [ @@ -131,6 +131,7 @@ class PartBaseType extends AbstractType 'class' => Manufacturer::class, 'required' => false, 'label' => 'part.edit.manufacturer.label', + 'allow_add' => $this->security->isGranted('@manufacturers.create'), 'disable_not_selectable' => true, ]) ->add('manufacturer_product_url', UrlType::class, [ diff --git a/src/Form/Part/PartLotType.php b/src/Form/Part/PartLotType.php index f56756ab..2e23c94a 100644 --- a/src/Form/Part/PartLotType.php +++ b/src/Form/Part/PartLotType.php @@ -60,6 +60,7 @@ class PartLotType extends AbstractType 'label' => 'part_lot.edit.location', 'required' => false, 'disable_not_selectable' => true, + 'allow_add' => $this->security->isGranted('@storelocations.create'), ]); $builder->add('amount', SIUnitType::class, [ diff --git a/src/Form/Type/Helper/StructuralEntityChoiceLoader.php b/src/Form/Type/Helper/StructuralEntityChoiceLoader.php new file mode 100644 index 00000000..13c351e7 --- /dev/null +++ b/src/Form/Type/Helper/StructuralEntityChoiceLoader.php @@ -0,0 +1,104 @@ +. + */ + +namespace App\Form\Type\Helper; + +use App\Entity\Base\AbstractDBElement; +use App\Entity\Base\AbstractStructuralDBElement; +use App\Services\Trees\NodesListBuilder; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\Form\ChoiceList\Loader\AbstractChoiceLoader; +use Symfony\Component\OptionsResolver\Options; + +class StructuralEntityChoiceLoader extends AbstractChoiceLoader +{ + private Options $options; + private NodesListBuilder $builder; + private EntityManagerInterface $entityManager; + + public function __construct(Options $options, NodesListBuilder $builder, EntityManagerInterface $entityManager) + { + $this->options = $options; + $this->builder = $builder; + $this->entityManager = $entityManager; + } + + protected function loadChoices(): iterable + { + return $this->builder->typeToNodesList($this->options['class'], null); + } + + public function loadChoicesForValues(array $values, callable $value = null) + { + $tmp = parent::loadChoicesForValues($values, $value); + + if ($this->options['allow_add'] && empty($tmp)) { + if (count($values) > 1) { + throw new \InvalidArgumentException('Cannot add multiple entities at once.'); + } + + //Dont create a new entity for the empty option + if ($values[0] === "" || $values[0] === null) { + return $tmp; + } + + return [$this->createNewEntityFromValue((string)$values[0])]; + } + + return $tmp; + } + + /*public function loadValuesForChoices(array $choices, callable $value = null) + { + $tmp = parent::loadValuesForChoices($choices, $value); + + if ($this->options['allow_add'] && count($tmp) === 1) { + if ($tmp[0] instanceof AbstractDBElement && $tmp[0]->getID() === null) { + return [$tmp[0]->getName()]; + } + + return [(string)$choices[0]->getID()]; + } + + return $tmp; + }*/ + + + public function createNewEntityFromValue(string $value): AbstractStructuralDBElement + { + if (!$this->options['allow_add']) { + throw new \RuntimeException('Cannot create new entity, because allow_add is not enabled!'); + } + + if (trim($value) === '') { + throw new \InvalidArgumentException('Cannot create new entity, because the name is empty!'); + } + + $class = $this->options['class']; + /** @var AbstractStructuralDBElement $entity */ + $entity = new $class(); + $entity->setName($value); + + //Persist element to database + $this->entityManager->persist($entity); + + return $entity; + } +} \ No newline at end of file diff --git a/src/Form/Type/StructuralEntityType.php b/src/Form/Type/StructuralEntityType.php index b582fae9..f03a3af3 100644 --- a/src/Form/Type/StructuralEntityType.php +++ b/src/Form/Type/StructuralEntityType.php @@ -24,6 +24,7 @@ namespace App\Form\Type; use App\Entity\Attachments\AttachmentType; use App\Entity\Base\AbstractStructuralDBElement; +use App\Form\Type\Helper\StructuralEntityChoiceLoader; use App\Services\Attachments\AttachmentURLGenerator; use App\Services\Trees\NodesListBuilder; use Doctrine\ORM\EntityManagerInterface; @@ -37,6 +38,9 @@ use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormView; use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Validator\Constraints\AtLeastOneOf; +use Symfony\Component\Validator\Constraints\IsNull; +use Symfony\Component\Validator\Constraints\Valid; /** * This class provides a choice form type similar to EntityType, with the difference, that the tree structure @@ -63,9 +67,9 @@ class StructuralEntityType extends AbstractType { $builder->addModelTransformer(new CallbackTransformer( function ($value) use ($options) { - return $this->transform($value, $options); + return $this->modelTransform($value, $options); }, function ($value) use ($options) { - return $this->reverseTransform($value, $options); + return $this->modelReverseTransform($value, $options); })); } @@ -73,14 +77,13 @@ class StructuralEntityType extends AbstractType { $resolver->setRequired(['class']); $resolver->setDefaults([ + 'allow_add' => false, 'show_fullpath_in_subtext' => true, //When this is enabled, the full path will be shown in subtext 'subentities_of' => null, //Only show entities with the given parent class 'disable_not_selectable' => false, //Disable entries with not selectable property 'choice_value' => 'id', //Use the element id as option value and for comparing items 'choice_loader' => function (Options $options) { - return new CallbackChoiceLoader(function () use ($options) { - return $this->getEntries($options); - }); + return new StructuralEntityChoiceLoader($options, $this->builder, $this->em); }, 'choice_label' => function (Options $options) { return function ($choice, $key, $value) use ($options) { @@ -95,6 +98,15 @@ class StructuralEntityType extends AbstractType 'choice_translation_domain' => false, //Don't translate the entity names ]); + //Set the constraints for the case that allow add is enabled (we then have to check that the new element is valid) + $resolver->setNormalizer('constraints', function (Options $options, $value) { + if ($options['allow_add']) { + $value[] = new Valid(); + } + + return $value; + }); + $resolver->setDefault('empty_message', null); $resolver->setDefault('controller', 'elements--structural-entity-select'); @@ -102,6 +114,7 @@ class StructuralEntityType extends AbstractType $resolver->setDefault('attr', static function (Options $options) { $tmp = [ 'data-controller' => $options['controller'], + 'data-allow-add' => $options['allow_add'] ? 'true' : 'false', ]; if ($options['empty_message']) { $tmp['data-empty-message'] = $options['empty_message']; @@ -111,91 +124,18 @@ class StructuralEntityType extends AbstractType }); } - /** - * Gets the entries from database and return an array of them. - */ - public function getEntries(Options $options): array - { - return $this->builder->typeToNodesList($options['class'], null); - } public function getParent(): string { return ChoiceType::class; } - /** - * Transforms a value from the original representation to a transformed representation. - * - * This method is called when the form field is initialized with its default data, on - * two occasions for two types of transformers: - * - * 1. Model transformers which normalize the model data. - * This is mainly useful when the same form type (the same configuration) - * has to handle different kind of underlying data, e.g The DateType can - * deal with strings or \DateTime objects as input. - * - * 2. View transformers which adapt the normalized data to the view format. - * a/ When the form is simple, the value returned by convention is used - * directly in the view and thus can only be a string or an array. In - * this case the data class should be null. - * - * b/ When the form is compound the returned value should be an array or - * an object to be mapped to the children. Each property of the compound - * data will be used as model data by each child and will be transformed - * too. In this case data class should be the class of the object, or null - * when it is an array. - * - * All transformers are called in a configured order from model data to view value. - * At the end of this chain the view data will be validated against the data class - * setting. - * - * This method must be able to deal with empty values. Usually this will - * be NULL, but depending on your implementation other empty values are - * possible as well (such as empty strings). The reasoning behind this is - * that data transformers must be chainable. If the transform() method - * of the first data transformer outputs NULL, the second must be able to - * process that value. - * - * @param mixed $value The value in the original representation - * - * @return mixed The value in the transformed representation - * - * @throws TransformationFailedException when the transformation fails - */ - public function transform($value, array $options) + public function modelTransform($value, array $options) { return $value; } - /** - * Transforms a value from the transformed representation to its original - * representation. - * - * This method is called when {@link Form::submit()} is called to transform the requests tainted data - * into an acceptable format. - * - * The same transformers are called in the reverse order so the responsibility is to - * return one of the types that would be expected as input of transform(). - * - * This method must be able to deal with empty values. Usually this will - * be an empty string, but depending on your implementation other empty - * values are possible as well (such as NULL). The reasoning behind - * this is that value transformers must be chainable. If the - * reverseTransform() method of the first value transformer outputs an - * empty string, the second value transformer must be able to process that - * value. - * - * By convention, reverseTransform() should return NULL if an empty string - * is passed. - * - * @param mixed $value The value in the transformed representation - * - * @return mixed The value in the original representation - * - * @throws TransformationFailedException when the transformation fails - */ - public function reverseTransform($value, array $options) + public function modelReverseTransform($value, array $options) { /* This step is important in combination with the caching! The elements deserialized from cache, are not known to Doctrinte ORM any more, so doctrine thinks, @@ -209,7 +149,13 @@ class StructuralEntityType extends AbstractType return null; } - return $this->em->find($options['class'], $value->getID()); + //If the value is already in the db, retrieve it freshly + if ($value->getID()) { + return $this->em->find($options['class'], $value->getID()); + } + + //Otherwise just return the value + return $value; } protected function generateChoiceAttr(AbstractStructuralDBElement $choice, $key, $value, $options): array diff --git a/src/Validator/Constraints/ValidPartLotValidator.php b/src/Validator/Constraints/ValidPartLotValidator.php index 2182c539..d77ecd0e 100644 --- a/src/Validator/Constraints/ValidPartLotValidator.php +++ b/src/Validator/Constraints/ValidPartLotValidator.php @@ -59,7 +59,8 @@ class ValidPartLotValidator extends ConstraintValidator if ($value->getStorageLocation()) { $repo = $this->em->getRepository(Storelocation::class); //We can only determine associated parts, if the part have an ID - if (null !== $value->getID()) { + //When the storage location is new (no ID), we can just assume there are no other parts + if (null !== $value->getID() && $value->getStorageLocation()->getID()) { $parts = new ArrayCollection($repo->getParts($value->getStorageLocation())); } else { $parts = new ArrayCollection([]);