Started to work on the possibilty to create new entities directly from the part edit page.

This fixes issue #203.
This commit is contained in:
Jan Böhmer 2023-01-29 20:42:18 +01:00
parent 672d55624f
commit 1654010ea3
8 changed files with 142 additions and 84 deletions

View file

@ -33,10 +33,13 @@ export default class extends Controller {
//Extract empty message from data attribute //Extract empty message from data attribute
this._emptyMessage = this.element.getAttribute("data-empty-message") ?? ""; this._emptyMessage = this.element.getAttribute("data-empty-message") ?? "";
const allowAdd = this.element.getAttribute("data-allow-add") === "true";
let settings = { let settings = {
allowEmptyOption: true, allowEmptyOption: true,
selectOnTab: true, selectOnTab: true,
maxOptions: null, maxOptions: null,
create: allowAdd,
searchField: [ searchField: [
{field: "text", weight : 2}, {field: "text", weight : 2},

View file

@ -75,6 +75,7 @@ class AttachmentFormType extends AbstractType
'label' => 'attachment.edit.attachment_type', 'label' => 'attachment.edit.attachment_type',
'class' => AttachmentType::class, 'class' => AttachmentType::class,
'disable_not_selectable' => true, 'disable_not_selectable' => true,
'allow_add' => $this->security->isGranted('@attachment_types.create'),
]); ]);
$builder->add('showInTable', CheckboxType::class, [ $builder->add('showInTable', CheckboxType::class, [

View file

@ -63,6 +63,7 @@ class OrderdetailType extends AbstractType
'class' => Supplier::class, 'class' => Supplier::class,
'disable_not_selectable' => true, 'disable_not_selectable' => true,
'label' => 'orderdetails.edit.supplier', 'label' => 'orderdetails.edit.supplier',
'allow_add' => $this->security->isGranted('@suppliers.create'),
]); ]);
$builder->add('supplier_product_url', UrlType::class, [ $builder->add('supplier_product_url', UrlType::class, [

View file

@ -104,15 +104,15 @@ class PartBaseType extends AbstractType
]) ])
->add('category', StructuralEntityType::class, [ ->add('category', StructuralEntityType::class, [
'class' => Category::class, 'class' => Category::class,
'allow_add' => $this->security->isGranted('@categories.create'),
'label' => 'part.edit.category', 'label' => 'part.edit.category',
'disable_not_selectable' => true, 'disable_not_selectable' => true,
'constraints' => [
],
]) ])
->add('footprint', StructuralEntityType::class, [ ->add('footprint', StructuralEntityType::class, [
'class' => Footprint::class, 'class' => Footprint::class,
'required' => false, 'required' => false,
'label' => 'part.edit.footprint', 'label' => 'part.edit.footprint',
'allow_add' => $this->security->isGranted('@footprints.create'),
'disable_not_selectable' => true, 'disable_not_selectable' => true,
]) ])
->add('tags', TextType::class, [ ->add('tags', TextType::class, [
@ -131,6 +131,7 @@ class PartBaseType extends AbstractType
'class' => Manufacturer::class, 'class' => Manufacturer::class,
'required' => false, 'required' => false,
'label' => 'part.edit.manufacturer.label', 'label' => 'part.edit.manufacturer.label',
'allow_add' => $this->security->isGranted('@manufacturers.create'),
'disable_not_selectable' => true, 'disable_not_selectable' => true,
]) ])
->add('manufacturer_product_url', UrlType::class, [ ->add('manufacturer_product_url', UrlType::class, [

View file

@ -60,6 +60,7 @@ class PartLotType extends AbstractType
'label' => 'part_lot.edit.location', 'label' => 'part_lot.edit.location',
'required' => false, 'required' => false,
'disable_not_selectable' => true, 'disable_not_selectable' => true,
'allow_add' => $this->security->isGranted('@storelocations.create'),
]); ]);
$builder->add('amount', SIUnitType::class, [ $builder->add('amount', SIUnitType::class, [

View file

@ -0,0 +1,104 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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;
}
}

View file

@ -24,6 +24,7 @@ namespace App\Form\Type;
use App\Entity\Attachments\AttachmentType; use App\Entity\Attachments\AttachmentType;
use App\Entity\Base\AbstractStructuralDBElement; use App\Entity\Base\AbstractStructuralDBElement;
use App\Form\Type\Helper\StructuralEntityChoiceLoader;
use App\Services\Attachments\AttachmentURLGenerator; use App\Services\Attachments\AttachmentURLGenerator;
use App\Services\Trees\NodesListBuilder; use App\Services\Trees\NodesListBuilder;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@ -37,6 +38,9 @@ use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView; use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver; 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 * 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( $builder->addModelTransformer(new CallbackTransformer(
function ($value) use ($options) { function ($value) use ($options) {
return $this->transform($value, $options); return $this->modelTransform($value, $options);
}, function ($value) use ($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->setRequired(['class']);
$resolver->setDefaults([ $resolver->setDefaults([
'allow_add' => false,
'show_fullpath_in_subtext' => true, //When this is enabled, the full path will be shown in subtext '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 'subentities_of' => null, //Only show entities with the given parent class
'disable_not_selectable' => false, //Disable entries with not selectable property '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_value' => 'id', //Use the element id as option value and for comparing items
'choice_loader' => function (Options $options) { 'choice_loader' => function (Options $options) {
return new CallbackChoiceLoader(function () use ($options) { return new StructuralEntityChoiceLoader($options, $this->builder, $this->em);
return $this->getEntries($options);
});
}, },
'choice_label' => function (Options $options) { 'choice_label' => function (Options $options) {
return function ($choice, $key, $value) use ($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 '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('empty_message', null);
$resolver->setDefault('controller', 'elements--structural-entity-select'); $resolver->setDefault('controller', 'elements--structural-entity-select');
@ -102,6 +114,7 @@ class StructuralEntityType extends AbstractType
$resolver->setDefault('attr', static function (Options $options) { $resolver->setDefault('attr', static function (Options $options) {
$tmp = [ $tmp = [
'data-controller' => $options['controller'], 'data-controller' => $options['controller'],
'data-allow-add' => $options['allow_add'] ? 'true' : 'false',
]; ];
if ($options['empty_message']) { if ($options['empty_message']) {
$tmp['data-empty-message'] = $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 public function getParent(): string
{ {
return ChoiceType::class; return ChoiceType::class;
} }
/** public function modelTransform($value, array $options)
* 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)
{ {
return $value; return $value;
} }
/** public function modelReverseTransform($value, array $options)
* 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)
{ {
/* This step is important in combination with the caching! /* 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, 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 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 protected function generateChoiceAttr(AbstractStructuralDBElement $choice, $key, $value, $options): array

View file

@ -59,7 +59,8 @@ class ValidPartLotValidator extends ConstraintValidator
if ($value->getStorageLocation()) { if ($value->getStorageLocation()) {
$repo = $this->em->getRepository(Storelocation::class); $repo = $this->em->getRepository(Storelocation::class);
//We can only determine associated parts, if the part have an ID //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())); $parts = new ArrayCollection($repo->getParts($value->getStorageLocation()));
} else { } else {
$parts = new ArrayCollection([]); $parts = new ArrayCollection([]);