symfony / ux

Symfony UX initiative: a JavaScript ecosystem for Symfony
https://ux.symfony.com/
MIT License
781 stars 274 forks source link

Missing 'Add' label Internationalization AutoComplete (Tom Select) when item add allowable #1900

Closed xDeSwa closed 4 days ago

xDeSwa commented 3 weeks ago

Missing 'Add' label Internationalization AutoComplete (Tom Select) when item add allowable

Q A
Bug fix? no
New feature? yes
Issues -
License MIT
smnandre commented 3 weeks ago

Thank you for this suggestion.

Minor comment: how can it be a bug and ... a missing feature ? :)

xDeSwa commented 3 weeks ago

You are right, I missed it. I changed it, thank you.

smnandre commented 2 weeks ago

There is something i may not follow...

AutoComplete is not able to create items.. expect maybe tags or scalar/serialized stuff like this, or am i wrong ?

Because if so ... i am not sure we would want to expose a feature that cannot be used/implemented easily by users.

xDeSwa commented 2 weeks ago

There is something i may not follow...

AutoComplete is not able to create items.. expect maybe tags or scalar/serialized stuff like this, or am i wrong ?

Because if so ... i am not sure we would want to expose a feature that cannot be used/implemented easily by users.

You are right, I was currently using it for tags and separating them with commas (delimiter). Below, I have created a system that can create a new entity, but this may be amateurish, since you are more experienced than me, it can be made easier for users to implement with more proper coding, and this can be explained in the documentation.

The new feature works as follows;

Demo Video

screen-recorder-fri-jun-14-2024-20-35-58.webm

Entities

<?php

# src/Entity/News.php

namespace App\Entity;

use App\Repository\NewsRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: NewsRepository::class)]
class News
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private ?string $title = null;

    #[ORM\ManyToOne(inversedBy: 'news')]
    private ?Group $membergroup = null;

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getTitle(): ?string
    {
        return $this->title;
    }

    public function setTitle(string $title): static
    {
        $this->title = $title;

        return $this;
    }

    public function getMembergroup(): ?Group
    {
        return $this->membergroup;
    }

    public function setMembergroup(?Group $membergroup): static
    {
        $this->membergroup = $membergroup;

        return $this;
    }
}
<?php

# src/Entity/Group.php

namespace App\Entity;

use App\Repository\GroupRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: GroupRepository::class)]
#[ORM\Table(name: '`group`')]
class Group
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private ?string $title = null;

    /**
     * @var Collection<int, News>
     */
    #[ORM\OneToMany(mappedBy: 'membergroup', targetEntity: News::class)]
    private Collection $news;

    public function __construct()
    {
        $this->news = new ArrayCollection();
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getTitle(): ?string
    {
        return $this->title;
    }

    public function setTitle(string $title): static
    {
        $this->title = $title;

        return $this;
    }

    /**
     * @return Collection<int, News>
     */
    public function getNews(): Collection
    {
        return $this->news;
    }

    public function addNews(News $news): static
    {
        if (!$this->news->contains($news)) {
            $this->news->add($news);
            $news->setMembergroup($this);
        }

        return $this;
    }

    public function removeNews(News $news): static
    {
        if ($this->news->removeElement($news)) {
            // set the owning side to null (unless already changed)
            if ($news->getMembergroup() === $this) {
                $news->setMembergroup(null);
            }
        }

        return $this;
    }

    public function __toString() {
        return $this->getTitle();
    }
}

Create UX Live Component

<?php

# src/Twig/components/News/NewGroup.php

namespace App\Twig\Components\News;

use App\Entity\Group;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\DefaultActionTrait;

#[AsLiveComponent]
class NewGroup
{
    use DefaultActionTrait;

    #[LiveAction]
    public function insert(EntityManagerInterface $entityManager, Request $request): void
    {
        $data = json_decode($request->request->get('data'), true);
        if ($data && $data['args']['newItem']) {
            $newgroup = new Group();
            $newgroup->setTitle($data['args']['newItem']);
            $entityManager->persist($newgroup);
            $entityManager->flush();
        }
    }
}

Create UX Live Component template

templates/components/News/NewGroup.html.twig

<div {{ attributes }}></div>

Create AutoComplete Field for Group entity

<?php

# src/Form/GroupAutocompleteField.php

namespace App\Form;

use App\Entity\Group;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\UX\Autocomplete\Form\AsEntityAutocompleteField;
use Symfony\UX\Autocomplete\Form\BaseEntityAutocompleteType;

#[AsEntityAutocompleteField]
class GroupAutocompleteField extends AbstractType
{
    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'class' => Group::class,
            'placeholder' => 'Choose a Group',
            'tom_select_options' => [
                'create' => true,
                'createOnBlur' => true,
            ],
            'add_new_item_live_component_name' => 'News:NewGroup', // new Feature
        ]);
    }

    public function getParent(): string
    {
        return BaseEntityAutocompleteType::class;
    }
}

Create Form Type for News Entity

<?php

# src/Form/NewsType.php

namespace App\Form;

use App\Entity\News;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class NewsType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('title')
            ->add('membergroup', GroupAutocompleteField::class, [
                'label' => 'Group',
            ])
        ;
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => News::class,
        ]);
    }
}

Example Template

<div class="col-3">
   {{ form_start(form) }}
     {{ form_widget(form) }}
   {{ form_end(form) }}

{# Call Live Component in how you want use #}
{# this required for trigger from AutoComplete Stimulus Controller #}
  <twig:News:NewGroup></twig:News:NewGroup>
</div>

AutoComplete Modifications

// vendor/symfony/ux-autocomplete/assets/dist/controller.js

const config = {
        render,
        plugins,
        onItemAdd: (item) => {
          // Magic Area -> Trigger UX Live Component with item data
            if (this.addNewItemLiveComponentNameValue) {
                let live_component = document.querySelector(`[data-live-name-value="${this.addNewItemLiveComponentNameValue}"]`);
                if (live_component !== undefined && live_component !== null) {
                    const component = document.getElementById(live_component.id).__component;
                    component.action('insert', {newItem: item})
                }
            }
            this.tomSelect.setTextboxValue('');
        },
        closeAfterSelect: true,
    };
// vendor/symfony/ux-autocomplete/assets/dist/controller.js

default_1.values = {
    url: String,
    optionsAsHtml: Boolean,
    optionCreateText: String,
    loadingMoreText: String,
    noResultsFoundText: String,
    noMoreResultsText: String,
    minCharacters: Number,
    tomSelectOptions: Object,
    preload: String,
    addNewItemLiveComponentName: String, // New Feature
};

Symfony UX Form Type Modification

# vendor/symfony/ux-autocomplete/src/Form/AutocompleteChoiceTypeExtension.php

<?php

# add in finishView function 
if ($options['add_new_item_live_component_name']) {
    $values['add-new-item-live-component-name'] = $options['add_new_item_live_component_name'];
}

# add setDefaults in configureOptions function 
 'add_new_item_live_component_name' => null,
smnandre commented 2 weeks ago

Thank you for taking the time to explain and share @xDeSwa

However, I fear this somewhat proves my point: to enable some "add" features, users still need to write some code (not complex, but very use-case dependent).

In this context, adding a translation message won't be the most challenging part. Plus, TomSelect options can be easily updated or overwritten using the Stimulus Controller.

Conversely, some users might not understand why they cannot automatically add a new entity, especially if there are related messages in the translation messages.

Let's wait other opinions :)