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();
+ }
}