From 74513b748d8ac14addec3bca6cf90bfca5ee135a Mon Sep 17 00:00:00 2001 From: Marcel Diegelmann Date: Thu, 12 Feb 2026 11:44:42 +0100 Subject: [PATCH] =?UTF-8?q?Unterst=C3=BCtzung=20f=C3=BCr=20Projekt-=20und?= =?UTF-8?q?=20Baugruppensuche=20zum=20QuickSearch-Suggest=20hinzuf=C3=BCge?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../elements/part_search_controller.js | 209 ++++++++++++++---- src/Controller/AssemblyController.php | 3 +- src/Controller/ProjectController.php | 3 +- src/Controller/TypeaheadController.php | 67 +++++- src/DataTables/Filters/PartSearchFilter.php | 1 + src/Repository/AssemblyRepository.php | 3 +- src/Repository/Parts/DeviceRepository.php | 14 ++ .../Attachments/ProjectPreviewGenerator.php | 93 ++++++++ templates/components/search.macro.html.twig | 4 +- translations/messages.cs.xlf | 12 + translations/messages.da.xlf | 12 + translations/messages.de.xlf | 12 + translations/messages.el.xlf | 14 +- translations/messages.en.xlf | 12 + translations/messages.es.xlf | 12 + translations/messages.fr.xlf | 12 + translations/messages.hu.xlf | 12 + translations/messages.it.xlf | 12 + translations/messages.ja.xlf | 12 + translations/messages.nl.xlf | 14 +- translations/messages.pl.xlf | 12 + translations/messages.ru.xlf | 12 + translations/messages.zh.xlf | 12 + 23 files changed, 519 insertions(+), 50 deletions(-) create mode 100644 src/Services/Attachments/ProjectPreviewGenerator.php diff --git a/assets/controllers/elements/part_search_controller.js b/assets/controllers/elements/part_search_controller.js index c1396900..aeeb701d 100644 --- a/assets/controllers/elements/part_search_controller.js +++ b/assets/controllers/elements/part_search_controller.js @@ -54,10 +54,19 @@ export default class extends Controller { } initialize() { - // The endpoint for searching parts + // The endpoint for searching parts or assemblies const base_url = this.element.dataset.autocomplete; // The URL template for the part detail pages const part_detail_uri_template = this.element.dataset.detailUrl; + // The URL template for the assembly detail pages + const assembly_detail_uri_template = this.element.dataset.assemblyDetailUrl; + // The URL template for the project detail pages + const project_detail_uri_template = this.element.dataset.projectDetailUrl; + + const hasAssemblyDetailUrl = + typeof assembly_detail_uri_template === "string" && assembly_detail_uri_template.length > 0; + const hasProjectDetailUrl = + typeof project_detail_uri_template === "string" && project_detail_uri_template.length > 0; //The URL of the placeholder picture const placeholder_image = this.element.dataset.placeholderImage; @@ -72,6 +81,43 @@ export default class extends Controller { limit: 5, }); + // Cache the last query to avoid fetching the same endpoint twice (parts source + assemblies source) + let lastQuery = null; + let lastFetchPromise = null; + + const fetchMixedItems = (query) => { + if (query === lastQuery && lastFetchPromise) { + return lastFetchPromise; + } + + lastQuery = query; + + const urlString = base_url.replace('__QUERY__', encodeURIComponent(query)); + const url = new URL(urlString, window.location.href); + if (hasAssemblyDetailUrl || hasProjectDetailUrl) { + url.searchParams.set('multidatasources', '1'); + } + + lastFetchPromise = fetch(url.toString()) + .then((response) => response.json()) + .then((items) => { + //Iterate over all fields besides the id and highlight them (if present) + const fields = ["name", "description", "category", "footprint"]; + + items.forEach((item) => { + for (const field of fields) { + if (item[field] !== undefined && item[field] !== null) { + item[field] = that._highlight(item[field], query); + } + } + }); + + return items; + }); + + return lastFetchPromise; + }; + this._autocomplete = autocomplete({ container: this.element, //Place the panel in the navbar, if the element is in navbar mode @@ -102,7 +148,7 @@ export default class extends Controller { }, // If the form is submitted, forward the term to the form - onSubmit({state, event, ...setters}) { + onSubmit({ state, event, ...setters }) { //Put the current text into each target input field const input = that.inputTarget; @@ -119,31 +165,15 @@ export default class extends Controller { input.form.requestSubmit(); }, - getSources({ query }) { - return [ - // The parts source + const sources = [ + // Parts source (filtered from mixed endpoint results) { sourceId: 'parts', getItems() { - const url = base_url.replace('__QUERY__', encodeURIComponent(query)); - - const data = fetch(url) - .then((response) => response.json()) - ; - - //Iterate over all fields besides the id and highlight them - const fields = ["name", "description", "category", "footprint"]; - - data.then((items) => { - items.forEach((item) => { - for (const field of fields) { - item[field] = that._highlight(item[field], query); - } - }); - }); - - return data; + return fetchMixedItems(query).then((items) => + items.filter((item) => item.type !== "assembly") + ); }, getItemUrl({ item }) { return part_detail_uri_template.replace('__ID__', item.id); @@ -151,36 +181,130 @@ export default class extends Controller { templates: { header({ html }) { return html`${trans("part.labelp")} -
`; +
`; }, - item({item, components, html}) { + item({ item, components, html }) { const details_url = part_detail_uri_template.replace('__ID__', item.id); return html` - -
-
- ${item.name} -
-
-
- - ${components.Highlight({hit: item, attribute: 'name'})} - + +
+
+ ${item.name}
-
- ${components.Highlight({hit: item, attribute: 'description'})} - ${item.category ? html`

${components.Highlight({hit: item, attribute: 'category'})}

` : ""} - ${item.footprint ? html`

${components.Highlight({hit: item, attribute: 'footprint'})}

` : ""} +
+
+ + ${components.Highlight({hit: item, attribute: 'name'})} + +
+
+ ${components.Highlight({hit: item, attribute: 'description'})} + ${item.category ? html`

${components.Highlight({hit: item, attribute: 'category'})}

` : ""} + ${item.footprint ? html`

${components.Highlight({hit: item, attribute: 'footprint'})}

` : ""} +
-
-
- `; + + `; }, }, }, ]; + + if (hasAssemblyDetailUrl) { + sources.push( + // Assemblies source (filtered from the same mixed endpoint results) + { + sourceId: 'assemblies', + getItems() { + return fetchMixedItems(query).then((items) => + items.filter((item) => item.type === "assembly") + ); + }, + getItemUrl({ item }) { + return assembly_detail_uri_template.replace('__ID__', item.id); + }, + templates: { + header({ html }) { + return html`${trans(STATISTICS_ASSEMBLIES)} +
`; + }, + item({ item, components, html }) { + const details_url = assembly_detail_uri_template.replace('__ID__', item.id); + + return html` + +
+
+ ${item.name} +
+
+
+ + ${components.Highlight({hit: item, attribute: 'name'})} + +
+
+ ${components.Highlight({hit: item, attribute: 'description'})} +
+
+
+
+ `; + }, + }, + } + ); + } + + if (hasProjectDetailUrl) { + sources.push( + // Projects source (filtered from the same mixed endpoint results) + { + sourceId: 'projects', + getItems() { + return fetchMixedItems(query).then((items) => + items.filter((item) => item.type === "project") + ); + }, + getItemUrl({ item }) { + return project_detail_uri_template.replace('__ID__', item.id); + }, + templates: { + header({ html }) { + return html`${trans(STATISTICS_PROJECTS)} +
`; + }, + item({ item, components, html }) { + const details_url = project_detail_uri_template.replace('__ID__', item.id); + + return html` + +
+
+ ${item.name} +
+
+
+ + ${components.Highlight({hit: item, attribute: 'name'})} + +
+
+ ${components.Highlight({hit: item, attribute: 'description'})} +
+
+
+
+ `; + }, + }, + } + ); + } + + return sources; }, }); @@ -192,6 +316,5 @@ export default class extends Controller { this._autocomplete.setIsOpen(false); }); } - } } diff --git a/src/Controller/AssemblyController.php b/src/Controller/AssemblyController.php index be97045b..5c558d66 100644 --- a/src/Controller/AssemblyController.php +++ b/src/Controller/AssemblyController.php @@ -128,7 +128,8 @@ class AssemblyController extends AbstractController ], $additonal_template_vars)); } - #[Route(path: '/{id}/info', name: 'assembly_info', requirements: ['id' => '\d+'])] + #[Route(path: '/{id}/info', name: 'assembly_info')] + #[Route(path: '/{id}', requirements: ['id' => '\d+'])] public function info(Assembly $assembly, Request $request): Response { $this->denyAccessUnlessGranted('read', $assembly); diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php index e510506f..aa0b3f76 100644 --- a/src/Controller/ProjectController.php +++ b/src/Controller/ProjectController.php @@ -58,7 +58,8 @@ class ProjectController extends AbstractController ) { } - #[Route(path: '/{id}/info', name: 'project_info', requirements: ['id' => '\d+'])] + #[Route(path: '/{id}/info', name: 'project_info')] + #[Route(path: '/{id}', requirements: ['id' => '\d+'])] public function info(Project $project, Request $request, ProjectBuildHelper $buildHelper, TableSettings $tableSettings): Response { $this->denyAccessUnlessGranted('read', $project); diff --git a/src/Controller/TypeaheadController.php b/src/Controller/TypeaheadController.php index efa17bb0..13a57dd7 100644 --- a/src/Controller/TypeaheadController.php +++ b/src/Controller/TypeaheadController.php @@ -24,6 +24,8 @@ namespace App\Controller; use App\Entity\AssemblySystem\Assembly; use App\Entity\Parameters\AbstractParameter; +use App\Entity\ProjectSystem\Project; +use App\Services\Attachments\ProjectPreviewGenerator; use App\Settings\MiscSettings\IpnSuggestSettings; use App\Services\Attachments\AssemblyPreviewGenerator; use Symfony\Component\HttpFoundation\Response; @@ -124,7 +126,10 @@ class TypeaheadController extends AbstractController public function parts( EntityManagerInterface $entityManager, PartPreviewGenerator $previewGenerator, + ProjectPreviewGenerator $projectPreviewGenerator, + AssemblyPreviewGenerator $assemblyPreviewGenerator, AttachmentURLGenerator $attachmentURLGenerator, + Request $request, string $query = "" ): JsonResponse { $this->denyAccessUnlessGranted('@parts.read'); @@ -133,18 +138,20 @@ class TypeaheadController extends AbstractController $parts = $partRepository->autocompleteSearch($query, 100); + /** @var Part[]|Assembly[] $data */ $data = []; foreach ($parts as $part) { //Determine the picture to show: $preview_attachment = $previewGenerator->getTablePreviewAttachment($part); if($preview_attachment instanceof Attachment) { - $preview_url = $attachmentURLGenerator->getThumbnailURL($preview_attachment, 'thumbnail_sm'); + $preview_url = $attachmentURLGenerator->getThumbnailURL($preview_attachment); } else { $preview_url = ''; } /** @var Part $part */ $data[] = [ + 'type' => 'part', 'id' => $part->getID(), 'name' => $part->getName(), 'category' => $part->getCategory() instanceof Category ? $part->getCategory()->getName() : 'Unknown', @@ -154,6 +161,64 @@ class TypeaheadController extends AbstractController ]; } + $multiDataSources = $request->query->getBoolean('multidatasources'); + + if ($multiDataSources) { + if ($this->isGranted('@projects.read')) { + $projectRepository = $entityManager->getRepository(Project::class); + + $projects = $projectRepository->autocompleteSearch($query, 100); + + foreach ($projects as $project) { + $preview_attachment = $projectPreviewGenerator->getTablePreviewAttachment($project); + + if ($preview_attachment instanceof Attachment) { + $preview_url = $attachmentURLGenerator->getThumbnailURL($preview_attachment); + } else { + $preview_url = ''; + } + + /** @var Project $project */ + $data[] = [ + 'type' => 'project', + 'id' => $project->getID(), + 'name' => $project->getName(), + 'category' => '', + 'footprint' => '', + 'description' => mb_strimwidth($project->getDescription(), 0, 127, '...'), + 'image' => $preview_url, + ]; + } + } + + if ($this->isGranted('@assemblies.read')) { + $assemblyRepository = $entityManager->getRepository(Assembly::class); + + $assemblies = $assemblyRepository->autocompleteSearch($query, 100); + + foreach ($assemblies as $assembly) { + $preview_attachment = $assemblyPreviewGenerator->getTablePreviewAttachment($assembly); + + if ($preview_attachment instanceof Attachment) { + $preview_url = $attachmentURLGenerator->getThumbnailURL($preview_attachment); + } else { + $preview_url = ''; + } + + /** @var Assembly $assembly */ + $data[] = [ + 'type' => 'assembly', + 'id' => $assembly->getID(), + 'name' => $assembly->getName(), + 'category' => '', + 'footprint' => '', + 'description' => mb_strimwidth($assembly->getDescription(), 0, 127, '...'), + 'image' => $preview_url, + ]; + } + } + } + return new JsonResponse($data); } diff --git a/src/DataTables/Filters/PartSearchFilter.php b/src/DataTables/Filters/PartSearchFilter.php index 1515e6b5..6c94e324 100644 --- a/src/DataTables/Filters/PartSearchFilter.php +++ b/src/DataTables/Filters/PartSearchFilter.php @@ -122,6 +122,7 @@ class PartSearchFilter implements FilterInterface } if ($this->assembly) { $fields_to_search[] = '_assembly.name'; + $fields_to_search[] = '_assembly.ipn'; } return $fields_to_search; diff --git a/src/Repository/AssemblyRepository.php b/src/Repository/AssemblyRepository.php index eef36690..d4c57cbb 100644 --- a/src/Repository/AssemblyRepository.php +++ b/src/Repository/AssemblyRepository.php @@ -57,7 +57,8 @@ class AssemblyRepository extends StructuralDBElementRepository $qb = $this->createQueryBuilder('assembly'); $qb->select('assembly') ->where('ILIKE(assembly.name, :query) = TRUE') - ->orWhere('ILIKE(assembly.description, :query) = TRUE'); + ->orWhere('ILIKE(assembly.description, :query) = TRUE') + ->orWhere('ILIKE(assembly.ipn, :query) = TRUE'); $qb->setParameter('query', '%'.$query.'%'); diff --git a/src/Repository/Parts/DeviceRepository.php b/src/Repository/Parts/DeviceRepository.php index 442c91e5..3fa93183 100644 --- a/src/Repository/Parts/DeviceRepository.php +++ b/src/Repository/Parts/DeviceRepository.php @@ -51,4 +51,18 @@ class DeviceRepository extends StructuralDBElementRepository //Prevent user from deleting devices, to not accidentally remove filled devices from old versions return 1; } + + public function autocompleteSearch(string $query, int $max_limits = 50): array + { + $qb = $this->createQueryBuilder('p'); + $qb->select('p') + ->where('ILIKE(p.name, :query) = TRUE'); + + $qb->setParameter('query', '%'.$query.'%'); + + $qb->setMaxResults($max_limits); + $qb->orderBy('NATSORT(p.name)', 'ASC'); + + return $qb->getQuery()->getResult(); + } } diff --git a/src/Services/Attachments/ProjectPreviewGenerator.php b/src/Services/Attachments/ProjectPreviewGenerator.php new file mode 100644 index 00000000..9929dbd3 --- /dev/null +++ b/src/Services/Attachments/ProjectPreviewGenerator.php @@ -0,0 +1,93 @@ +. + */ + +declare(strict_types=1); + +namespace App\Services\Attachments; + +use App\Entity\Attachments\Attachment; +use App\Entity\ProjectSystem\Project; + +class ProjectPreviewGenerator +{ + public function __construct(protected AttachmentManager $attachmentHelper) + { + } + + /** + * Returns a list of attachments that can be used for previewing the project ordered by priority. + * + * @param Project $project the project for which the attachments should be determined + * + * @return (Attachment|null)[] + * + * @psalm-return list + */ + public function getPreviewAttachments(Project $project): array + { + $list = []; + + //Master attachment has top priority + $attachment = $project->getMasterPictureAttachment(); + if ($this->isAttachmentValidPicture($attachment)) { + $list[] = $attachment; + } + + //Then comes the other images of the project + foreach ($project->getAttachments() as $attachment) { + //Dont show the master attachment twice + if ($this->isAttachmentValidPicture($attachment) && $attachment !== $project->getMasterPictureAttachment()) { + $list[] = $attachment; + } + } + + return $list; + } + + /** + * Determines what attachment should be used for previewing a project (especially in project table). + * The returned attachment is guaranteed to be existing and be a picture. + * + * @param Project $project The project for which the attachment should be determined + */ + public function getTablePreviewAttachment(Project $project): ?Attachment + { + $attachment = $project->getMasterPictureAttachment(); + if ($this->isAttachmentValidPicture($attachment)) { + return $attachment; + } + + return null; + } + + /** + * Checks if a attachment is exising and a valid picture. + * + * @param Attachment|null $attachment the attachment that should be checked + * + * @return bool true if the attachment is valid + */ + protected function isAttachmentValidPicture(?Attachment $attachment): bool + { + return $attachment instanceof Attachment + && $attachment->isPicture() + && $this->attachmentHelper->isFileExisting($attachment); + } +} diff --git a/templates/components/search.macro.html.twig b/templates/components/search.macro.html.twig index 17002d13..361b20e4 100644 --- a/templates/components/search.macro.html.twig +++ b/templates/components/search.macro.html.twig @@ -100,7 +100,9 @@ data-navbar-mode="{% if is_navbar %}true{% else %}false{% endif %}" data-placeholder-image="{{ asset('img/part_placeholder.svg') }}" data-autocomplete="{{ path('typeahead_parts', {'query': '__QUERY__'}) }}" - data-detail-url="{{ path('part_info', {'id': '__ID__'}) }}"> + data-detail-url="{{ path('part_info', {'id': '__ID__'}) }}" + data-project-detail-url="{{ path('project_info', {'id': '__ID__'}) }}" + data-assembly-detail-url="{{ path('assembly_info', {'id': '__ID__'}) }}">
diff --git a/translations/messages.cs.xlf b/translations/messages.cs.xlf index 26bcc03d..a413fe07 100644 --- a/translations/messages.cs.xlf +++ b/translations/messages.cs.xlf @@ -1839,6 +1839,18 @@ Související prvky budou přesunuty nahoru. Díly + + + statistics.projects + Projekty + + + + + statistics.assemblies + Sestavy + + new diff --git a/translations/messages.da.xlf b/translations/messages.da.xlf index a6ae3714..07577158 100644 --- a/translations/messages.da.xlf +++ b/translations/messages.da.xlf @@ -1797,6 +1797,18 @@ Underelementer vil blive flyttet opad. Komponenter + + + statistics.projects + Projekter + + + + + statistics.assemblies + Samlinger + + new diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf index 4fd692eb..2e64896c 100644 --- a/translations/messages.de.xlf +++ b/translations/messages.de.xlf @@ -1796,6 +1796,18 @@ Subelemente werden beim Löschen nach oben verschoben. Bauteile + + + statistics.assemblies + Baugruppen + + + + + statistics.projects + Projekte + + new diff --git a/translations/messages.el.xlf b/translations/messages.el.xlf index be974d25..6959f737 100644 --- a/translations/messages.el.xlf +++ b/translations/messages.el.xlf @@ -1871,5 +1871,17 @@ Επεξεργασία + + + statistics.assemblies + Συναρμολογήσεις + + + + + statistics.projects + Έργα + + - \ No newline at end of file + diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index c0045b42..07290f58 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -1797,6 +1797,18 @@ Sub elements will be moved upwards. Parts + + + statistics.assemblies + Assemblies + + + + + statistics.projects + Projects + + new diff --git a/translations/messages.es.xlf b/translations/messages.es.xlf index d024ed75..5e19a148 100644 --- a/translations/messages.es.xlf +++ b/translations/messages.es.xlf @@ -1839,6 +1839,18 @@ Subelementos serán desplazados hacia arriba. Componentes + + + statistics.projects + Proyectos + + + + + statistics.assemblies + Ensamblajes + + new diff --git a/translations/messages.fr.xlf b/translations/messages.fr.xlf index a4233d9f..6fbb667c 100644 --- a/translations/messages.fr.xlf +++ b/translations/messages.fr.xlf @@ -1822,6 +1822,18 @@ Show/Hide sidebar Composants + + + statistics.projects + Projets + + + + + statistics.assemblies + Assemblages + + new diff --git a/translations/messages.hu.xlf b/translations/messages.hu.xlf index 31c14153..53e16b95 100644 --- a/translations/messages.hu.xlf +++ b/translations/messages.hu.xlf @@ -1750,6 +1750,18 @@ Alkatrészek + + + statistics.projects + Projektek + + + + + statistics.assemblies + Összeállítások + + new diff --git a/translations/messages.it.xlf b/translations/messages.it.xlf index ac13366e..8b80bdbe 100644 --- a/translations/messages.it.xlf +++ b/translations/messages.it.xlf @@ -1839,6 +1839,18 @@ I sub elementi saranno spostati verso l'alto. Componenti + + + statistics.projects + Progetti + + + + + statistics.assemblies + Assiemi + + new diff --git a/translations/messages.ja.xlf b/translations/messages.ja.xlf index 08a4b4d9..e75953e4 100644 --- a/translations/messages.ja.xlf +++ b/translations/messages.ja.xlf @@ -1822,6 +1822,18 @@ 部品 + + + statistics.projects + プロジェクト + + + + + statistics.assemblies + アセンブリ + + new diff --git a/translations/messages.nl.xlf b/translations/messages.nl.xlf index e8953fc8..b5b076e2 100644 --- a/translations/messages.nl.xlf +++ b/translations/messages.nl.xlf @@ -1548,5 +1548,17 @@ _MENU_ + + + statistics.assemblies + Assemblages + + + + + statistics.projects + Projecten + + - \ No newline at end of file + diff --git a/translations/messages.pl.xlf b/translations/messages.pl.xlf index 33368720..7c0095e5 100644 --- a/translations/messages.pl.xlf +++ b/translations/messages.pl.xlf @@ -1836,6 +1836,18 @@ Po usunięciu pod elementy zostaną przeniesione na górę. Komponenty + + + statistics.projects + Projekty + + + + + statistics.assemblies + Zespoły + + new diff --git a/translations/messages.ru.xlf b/translations/messages.ru.xlf index 34f1ce9e..3c43a00c 100644 --- a/translations/messages.ru.xlf +++ b/translations/messages.ru.xlf @@ -1840,6 +1840,18 @@ Компоненты + + + statistics.projects + Проекты + + + + + statistics.assemblies + Сборки + + new diff --git a/translations/messages.zh.xlf b/translations/messages.zh.xlf index 563f519d..2d5eab25 100644 --- a/translations/messages.zh.xlf +++ b/translations/messages.zh.xlf @@ -1839,6 +1839,18 @@ 部件 + + + statistics.projects + 项目 + + + + + statistics.assemblies + 组件 + + new