diff --git a/assets/controllers/elements/part_select_controller.js b/assets/controllers/elements/part_select_controller.js new file mode 100644 index 00000000..6e3dfb57 --- /dev/null +++ b/assets/controllers/elements/part_select_controller.js @@ -0,0 +1,58 @@ +import {Controller} from "@hotwired/stimulus"; + +import "tom-select/dist/css/tom-select.bootstrap5.css"; +import '../../css/components/tom-select_extensions.css'; +import TomSelect from "tom-select"; +import {marked} from "marked"; + +export default class extends Controller { + _tomSelect; + + connect() { + + let settings = { + allowEmptyOption: true, + searchField: "name", + valueField: "id", + render: { + item: (data, escape) => { + return '' + "" + escape(data.name) + ''; + }, + option: (data, escape) => { + let tmp = '
' + + "
" + + "
" + + '
' + escape(data.name) + '
' + + '

' + marked.parseInline(data.description) + '

' + + '

' + escape(data.category); + + if (data.footprint) { //If footprint is defined for the part show it next to the category + tmp += ' ' + escape(data.footprint); + } + + return tmp + '

' + + '
'; + } + } + }; + + + if (this.element.dataset.autocomplete) { + const base_url = this.element.dataset.autocomplete; + settings.valueField = "id"; + settings.load = (query, callback) => { + const url = base_url.replace('__QUERY__', encodeURIComponent(query)); + + fetch(url) + .then(response => response.json()) + .then(json => {callback(json);}) + .catch(() => { + callback() + }); + }; + + + this._tomSelect = new TomSelect(this.element, settings); + } + } +} \ No newline at end of file diff --git a/src/Controller/TypeaheadController.php b/src/Controller/TypeaheadController.php index efefc78e..87d0f4ff 100644 --- a/src/Controller/TypeaheadController.php +++ b/src/Controller/TypeaheadController.php @@ -32,10 +32,12 @@ use App\Entity\Parameters\MeasurementUnitParameter; use App\Entity\Parameters\PartParameter; use App\Entity\Parameters\StorelocationParameter; use App\Entity\Parameters\SupplierParameter; +use App\Entity\Parts\Part; use App\Entity\PriceInformations\Currency; use App\Repository\ParameterRepository; use App\Services\Attachments\AttachmentURLGenerator; use App\Services\Attachments\BuiltinAttachmentsFinder; +use App\Services\Attachments\PartPreviewGenerator; use App\Services\Tools\TagFinder; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -128,6 +130,45 @@ class TypeaheadController extends AbstractController } } + /** + * @Route("/parts/search/{query}", name="typeahead_parts") + * @param string $query + * @param EntityManagerInterface $entityManager + * @return JsonResponse + */ + public function parts(string $query, EntityManagerInterface $entityManager, PartPreviewGenerator $previewGenerator, + AttachmentURLGenerator $attachmentURLGenerator): JsonResponse + { + $this->denyAccessUnlessGranted('@parts.read'); + + $repo = $entityManager->getRepository(Part::class); + + $parts = $repo->autocompleteSearch($query); + + $data = []; + foreach ($parts as $part) { + //Determine the picture to show: + $preview_attachment = $previewGenerator->getTablePreviewAttachment($part); + if($preview_attachment !== null) { + $preview_url = $attachmentURLGenerator->getThumbnailURL($preview_attachment, 'thumbnail_sm'); + } else { + $preview_url = ''; + } + + /** @var Part $part */ + $data[] = [ + 'id' => $part->getID(), + 'name' => $part->getName(), + 'category' => $part->getCategory() ? $part->getCategory()->getName() : 'Unknown', + 'footprint' => $part->getFootprint() ? $part->getFootprint()->getName() : '', + 'description' => mb_strimwidth($part->getDescription(), 0, 127, '...'), + 'image' => $preview_url, + ]; + } + + return new JsonResponse($data); + } + /** * @Route("/parameters/{type}/search/{query}", name="typeahead_parameters", requirements={"type" = ".+"}) * @param string $query diff --git a/src/Form/ProjectSystem/ProjectBOMEntryType.php b/src/Form/ProjectSystem/ProjectBOMEntryType.php index a210fdfd..81ec9329 100644 --- a/src/Form/ProjectSystem/ProjectBOMEntryType.php +++ b/src/Form/ProjectSystem/ProjectBOMEntryType.php @@ -4,6 +4,7 @@ namespace App\Form\ProjectSystem; use App\Entity\Parts\Part; use App\Entity\ProjectSystem\ProjectBOMEntry; +use App\Form\Type\PartSelectType; use Svg\Tag\Text; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; @@ -23,9 +24,7 @@ class ProjectBOMEntryType extends AbstractType 'label' => 'project.bom.quantity', ]) - ->add('part', EntityType::class, [ - 'class' => Part::class, - 'choice_label' => 'name', + ->add('part', PartSelectType::class, [ 'required' => false, ]) diff --git a/src/Form/Type/PartSelectType.php b/src/Form/Type/PartSelectType.php new file mode 100644 index 00000000..84466cb0 --- /dev/null +++ b/src/Form/Type/PartSelectType.php @@ -0,0 +1,54 @@ +urlGenerator = $urlGenerator; + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'class' => Part::class, + 'choice_label' => 'name', + 'placeholder' => 'None' + ]); + + $resolver->setDefaults([ + 'attr' => [ + 'data-controller' => 'elements--part-select', + 'data-autocomplete' => $this->urlGenerator->generate('typeahead_parts', ['query' => '__QUERY__']), + //Disable browser autocomplete + 'autocomplete' => 'off', + ], + ]); + + $resolver->setDefaults(['choices' => []]); + + $resolver->setDefaults([ + 'choice_attr' => ChoiceList::attr($this, function (?Part $part) { + return $part ? [ + //'data-description' => $part->getDescription(), + //'data-category' => $part->getCategory() ? $part->getCategory()->getName() : '', + ] : []; + }) + ]); + } + + public function getParent() + { + return EntityType::class; + } +} \ No newline at end of file diff --git a/src/Repository/PartRepository.php b/src/Repository/PartRepository.php index 25b07eb6..c88f7c70 100644 --- a/src/Repository/PartRepository.php +++ b/src/Repository/PartRepository.php @@ -66,4 +66,24 @@ class PartRepository extends NamedDBElementRepository return (int) ($query->getSingleScalarResult() ?? 0); } + + public function autocompleteSearch(string $query, int $max_limits = 50): array + { + $qb = $this->createQueryBuilder('part'); + $qb->select('part') + ->leftJoin('part.category', 'category') + + ->where('part.name LIKE :query') + ->orWhere('part.description LIKE :query') + ->orWhere('category.name LIKE :query') + + ; + + $qb->setParameter('query', '%'.$query.'%'); + + $qb->setMaxResults($max_limits); + $qb->orderBy('part.name', 'ASC'); + + return $qb->getQuery()->getResult(); + } }