diff --git a/assets/controllers/elements/part_search_controller.js b/assets/controllers/elements/part_search_controller.js index 78d947b9..16b4c632 100644 --- a/assets/controllers/elements/part_search_controller.js +++ b/assets/controllers/elements/part_search_controller.js @@ -120,6 +120,9 @@ export default class extends Controller { this._autocomplete = autocomplete({ container: this.element, + initialState: { + query: this.element.dataset.initialQuery || that.inputTarget.value || "" + }, //Place the panel in the navbar, if the element is in navbar mode panelContainer: navbar_mode ? document.getElementById("navbar-search-form") : document.body, panelPlacement: this.element.dataset.panelPlacement, @@ -162,7 +165,10 @@ export default class extends Controller { } input.value = state.query; - input.form.requestSubmit(); + + if (input.form) { + input.form.requestSubmit(); + } }, getSources({ query }) { diff --git a/assets/controllers/elements/search_options_controller.js b/assets/controllers/elements/search_options_controller.js new file mode 100644 index 00000000..e57011ae --- /dev/null +++ b/assets/controllers/elements/search_options_controller.js @@ -0,0 +1,76 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["datasource", "partOptions", "assemblyOptions", "projectOptions", "divider"]; + static values = { + isSearchList: Boolean + }; + + connect() { + // Delay update slightly to ensure all child controllers are connected and DOM is ready + setTimeout(() => { + this.updateVisibility(); + }, 1000); + } + + onDatasourceChange() { + this.updateVisibility(); + } + + updateVisibility() { + if (!this.hasDatasourceTarget) return; + + const datasource = this.datasourceTarget.value; + const isSearchList = this.isSearchListValue; + const isPart = (datasource === "parts"); + const isAssembly = (datasource === "assemblies"); + const isProject = (datasource === "projects"); + + if (this.hasPartOptionsTarget) { + this.toggleOptions(this.partOptionsTarget, isPart, isSearchList); + } + + if (this.hasAssemblyOptionsTarget) { + this.toggleOptions(this.assemblyOptionsTarget, isAssembly, isSearchList); + } + + if (this.hasProjectOptionsTarget) { + this.toggleOptions(this.projectOptionsTarget, isProject, isSearchList); + } + + if (this.hasDividerTarget) { + this.dividerTarget.classList.toggle("d-none", !isPart && !isAssembly && !isProject); + } + } + + toggleOptions(container, show, isSearchList) { + const wasHidden = container.classList.contains("d-none"); + container.classList.toggle("d-none", !show); + + const checkboxes = container.querySelectorAll('input[type="checkbox"]'); + if (!show) { + // Deselect checkboxes if not in correct mode + checkboxes.forEach(checkbox => { + // Store current state to restore it later if the user switches back + if (checkbox.checked) { + checkbox.dataset.previousState = "true"; + checkbox.checked = false; + // Trigger a change event to update sessionStorage via the sessionStorage_checkbox controller + // We use a CustomEvent to pass the skipStorage flag + checkbox.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { skipStorage: true } })); + } + }); + } else if (wasHidden) { + // Restore state when switching back + checkboxes.forEach(checkbox => { + // Restore state if NOT on search list + // On search list, we don't restore to avoid overwriting Twig's checked state + if (!isSearchList && checkbox.dataset.previousState === "true") { + checkbox.checked = true; + checkbox.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { skipStorage: true } })); + } + delete checkbox.dataset.previousState; + }); + } + } +} diff --git a/assets/controllers/elements/sessionStorage_checkbox_controller.js b/assets/controllers/elements/sessionStorage_checkbox_controller.js new file mode 100644 index 00000000..cab3d7e7 --- /dev/null +++ b/assets/controllers/elements/sessionStorage_checkbox_controller.js @@ -0,0 +1,81 @@ +/* + * 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 . + */ + +import {Controller} from "@hotwired/stimulus"; + +export default class extends Controller +{ + static values = { + id: String, + isSearchList: Boolean + } + + connect() { + if (this.isSearchListValue) { + // If we are on the search list, we want to update the localStorage with the current (server-side) state + // to ensure consistency. + this.saveState(); + } else { + // Otherwise, we load the state from localStorage. + this.loadState(); + } + this.element.addEventListener('change', (event) => { + // Don't save state if we are currently being toggled by the search_options controller + // to avoid saving "unchecked" states when options are hidden. + // CustomEvent's detail property contains the data we passed. + if (event instanceof CustomEvent && event.detail && event.detail.skipStorage) { + return; + } + this.saveState() + }); + } + + loadState() { + let storageKey = this.getStorageKey(); + let value = localStorage.getItem(storageKey); + if (value === null) { + return; + } + + if (value === 'true') { + this.element.checked = true + } + if (value === 'false') { + this.element.checked = false + } + } + + saveState() { + let storageKey = this.getStorageKey(); + + if (this.element.checked) { + localStorage.setItem(storageKey, 'true'); + } else { + localStorage.setItem(storageKey, 'false'); + } + } + + getStorageKey() { + if (this.hasIdValue) { + return 'persistent_checkbox_' + this.idValue + } + + return 'persistent_checkbox_' + this.element.id; + } +} diff --git a/assets/controllers/helpers/form_cleanup_controller.js b/assets/controllers/helpers/form_cleanup_controller.js index d554371d..b0331b83 100644 --- a/assets/controllers/helpers/form_cleanup_controller.js +++ b/assets/controllers/helpers/form_cleanup_controller.js @@ -34,6 +34,10 @@ export default class extends Controller { /** @type {HTMLFormElement} */ const form = event.target.closest('form'); + if (!form) { + return; + } + for(const element of form.elements) { if(! element.value) { element.disabled = true; @@ -53,6 +57,11 @@ export default class extends Controller { clearAll(event) { const form = event.target.closest('form'); + + if (!form) { + return; + } + for(const element of form.elements) { // Do not clear elements with data-no-clear attribute if(element.dataset.noClear) { diff --git a/docs/configuration.md b/docs/configuration.md index 6410e313..de48e5b3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -147,6 +147,9 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept time). Also specify the default order of the columns. This is a comma separated list of column names. Available columns are: `name`, `id`, `ipn`, `description`, `category`, `footprint`, `manufacturer`, `storage_location`, `amount`, `minamount`, `partUnit`, `partCustomState`, `addedDate`, `lastModified`, `needs_review`, `favorite`, `manufacturing_status`, `manufacturer_product_number`, `mass`, `tags`, `attachments`, `edit`. +* `TABLE_PROJECTS_DEFAULT_COLUMNS`: The columns in projects tables, which are visible by default (when loading table for first time). + Also specify the default order of the columns. This is a comma separated list of column names. Available columns + are: `name`, `id`, `description`, `notes`, `edit`, `addedDate`, `lastModified`. * `TABLE_ASSEMBLIES_DEFAULT_COLUMNS`: The columns in assemblies tables, which are visible by default (when loading table for first time). Also specify the default order of the columns. This is a comma separated list of column names. Available columns are: `name`, `id`, `ipn`, `description`, `referencedAssemblies`, `edit`, `addedDate`, `lastModified`. diff --git a/src/Controller/PartListsController.php b/src/Controller/PartListsController.php index f2eef604..6cfb9056 100644 --- a/src/Controller/PartListsController.php +++ b/src/Controller/PartListsController.php @@ -24,8 +24,17 @@ namespace App\Controller; use App\DataTables\ErrorDataTable; use App\DataTables\Filters\PartFilter; +use App\DataTables\Filters\AssemblyFilter; +use App\DataTables\Filters\ProjectFilter; +use App\DataTables\AssemblyDataTable; +use App\DataTables\Filters\AssemblySearchFilter; use App\DataTables\Filters\PartSearchFilter; +use App\DataTables\Filters\ProjectSearchFilter; +use App\Form\Filters\AssemblyFilterType; +use App\Form\Filters\PartFilterType; +use App\Form\Filters\ProjectFilterType; use App\DataTables\PartsDataTable; +use App\DataTables\ProjectSearchDataTable; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; @@ -33,7 +42,6 @@ use App\Entity\Parts\Part; use App\Entity\Parts\StorageLocation; use App\Entity\Parts\Supplier; use App\Exceptions\InvalidRegexException; -use App\Form\Filters\PartFilterType; use App\Services\Parts\PartsTableActionHandler; use App\Services\Trees\NodesListBuilder; use App\Settings\BehaviorSettings\SidebarSettings; @@ -160,25 +168,52 @@ class PartListsController extends AbstractController $formRequest = clone $request; $formRequest->setMethod('GET'); - $filter = new PartFilter($this->nodesListBuilder); - if($filter_changer !== null){ + + $filterType = $additional_table_vars['filterType'] ?? PartFilter::class; + unset($additional_table_vars['filterType']); + + if ($filterType === PartFilter::class) { + $filter = new PartFilter($this->nodesListBuilder); + } elseif ($filterType === AssemblyFilter::class) { + $filter = new AssemblyFilter($this->nodesListBuilder); + } elseif ($filterType === ProjectFilter::class) { + $filter = new ProjectFilter($this->nodesListBuilder); + } else { + $filter = null; + } + + if ($filter !== null && $filter_changer !== null) { $filter_changer($filter); } //If we are in a post request for the tables, we only have to apply the filter form if the submit query param was set //This saves us some time from creating this complicated term on simple list pages, where no special filter is applied $filterForm = null; - if ($request->getMethod() !== 'POST' || $request->query->has('part_filter')) { - $filterForm = $this->createForm(PartFilterType::class, $filter, ['method' => 'GET']); - if ($form_changer !== null) { - $form_changer($filterForm); - } + if ($request->getMethod() !== 'POST' || $request->query->has('part_filter') || $request->query->has('assembly_filter') || $request->query->has('project_filter') || $request->query->has('submit')) { + $formType = match ($filterType) { + PartFilter::class => PartFilterType::class, + AssemblyFilter::class => AssemblyFilterType::class, + ProjectFilter::class => ProjectFilterType::class, + default => null, + }; - $filterForm->handleRequest($formRequest); + if ($formType !== null) { + $filterForm = $this->createForm($formType, $filter, ['method' => 'GET']); + if ($form_changer !== null) { + $form_changer($filterForm); + } + + $filterForm->handleRequest($formRequest); + } } - $table = $this->dataTableFactory->createFromType(PartsDataTable::class, array_merge( - ['filter' => $filter], $additional_table_vars), + $dataTableType = $additional_table_vars['dataTableType'] ?? PartsDataTable::class; + unset($additional_table_vars['dataTableType']); + + $tableOptions = array_merge( + ['filter' => $filter], $additional_table_vars); + + $table = $this->dataTableFactory->createFromType($dataTableType, $tableOptions, ['pageLength' => $this->tableSettings->fullDefaultPageSize, 'lengthMenu' => PartsDataTable::LENGTH_MENU]) ->handleRequest($request); @@ -313,25 +348,99 @@ class PartListsController extends AbstractController ); } - private function searchRequestToFilter(Request $request): PartSearchFilter + /** + * @return PartSearchFilter|AssemblySearchFilter|ProjectSearchFilter + */ + private function searchRequestToFilter(Request $request): object { - $filter = new PartSearchFilter($request->query->get('keyword', '')); + $datasource = $request->query->get('datasource', 'parts'); + $keyword = $request->query->get('keyword', ''); + + if ($datasource === 'assemblies') { + $filter = new AssemblySearchFilter($keyword); + $filter->setDatasource($datasource); + $filter->setRegex($request->query->getBoolean('regex')); + + //As an unchecked checkbox is not set in the query, the default value for all bools have to be false (which is the default argument value)! + //But if we are coming from a simple search (without search options set), we want to search in all fields by default + if ($request->query->has('name') || $request->query->has('description') || $request->query->has('comment') || $request->query->has('ipn') || $request->query->has('category') || $request->query->has('status') || $request->query->has('dbid')) { + $filter->setName($request->query->getBoolean('name')); + $filter->setDescription($request->query->getBoolean('description')); + $filter->setComment($request->query->getBoolean('comment') || $request->query->getBoolean('notes')); + $filter->setIPN($request->query->getBoolean('ipn')); + $filter->setCategory($request->query->getBoolean('category')); + $filter->setStatus($request->query->getBoolean('status')); + $filter->setDbId($request->query->getBoolean('dbid')); + } else { + //Simple search: search in all fields + $filter->setName(true); + $filter->setDescription(true); + $filter->setComment(true); + $filter->setIPN(true); + $filter->setStatus(true); + } + + return $filter; + } + + if ($datasource === 'projects') { + $filter = new ProjectSearchFilter($keyword); + $filter->setDatasource($datasource); + $filter->setRegex($request->query->getBoolean('regex')); + + //As an unchecked checkbox is not set in the query, the default value for all bools have to be false (which is the default argument value)! + if ($request->query->has('name') || $request->query->has('description') || $request->query->has('comment') || $request->query->has('notes') || $request->query->has('category') || $request->query->has('status') || $request->query->has('dbid')) { + $filter->setName($request->query->getBoolean('name')); + $filter->setDescription($request->query->getBoolean('description')); + $filter->setComment($request->query->getBoolean('comment') || $request->query->getBoolean('notes')); + $filter->setCategory($request->query->getBoolean('category')); + $filter->setStatus($request->query->getBoolean('status')); + $filter->setDbId($request->query->getBoolean('dbid')); + } else { + //Simple search: search in all fields + $filter->setName(true); + $filter->setDescription(true); + $filter->setComment(true); + $filter->setStatus(true); + } + + return $filter; + } + + $filter = new PartSearchFilter($keyword); + $filter->setDatasource($datasource); //As an unchecked checkbox is not set in the query, the default value for all bools have to be false (which is the default argument value)! - $filter->setName($request->query->getBoolean('name')); - $filter->setDbId($request->query->getBoolean('dbid')); - $filter->setCategory($request->query->getBoolean('category')); - $filter->setDescription($request->query->getBoolean('description')); - $filter->setMpn($request->query->getBoolean('mpn')); - $filter->setTags($request->query->getBoolean('tags')); - $filter->setStorelocation($request->query->getBoolean('storelocation')); - $filter->setComment($request->query->getBoolean('comment')); - $filter->setIPN($request->query->getBoolean('ipn')); - $filter->setOrdernr($request->query->getBoolean('ordernr')); - $filter->setSupplier($request->query->getBoolean('supplier')); - $filter->setManufacturer($request->query->getBoolean('manufacturer')); - $filter->setFootprint($request->query->getBoolean('footprint')); - $filter->setAssembly($request->query->getBoolean('assembly')); + if ($request->query->has('name') || $request->query->has('description') || $request->query->has('comment') || $request->query->has('ipn') || $request->query->has('category') || $request->query->has('dbid') || $request->query->has('mpn') || $request->query->has('tags') || $request->query->has('storelocation') || $request->query->has('ordernr') || $request->query->has('supplier') || $request->query->has('manufacturer') || $request->query->has('footprint') || $request->query->has('assembly') || $request->query->has('manufacturing_status')) { + $filter->setName($request->query->getBoolean('name')); + $filter->setDbId($request->query->getBoolean('dbid')); + $filter->setCategory($request->query->getBoolean('category')); + $filter->setDescription($request->query->getBoolean('description')); + $filter->setMpn($request->query->getBoolean('mpn')); + $filter->setTags($request->query->getBoolean('tags')); + $filter->setStorelocation($request->query->getBoolean('storelocation')); + $filter->setComment($request->query->getBoolean('comment')); + $filter->setManufacturingStatus($request->query->getBoolean('manufacturing_status')); + $filter->setIPN($request->query->getBoolean('ipn')); + $filter->setOrdernr($request->query->getBoolean('ordernr')); + $filter->setSupplier($request->query->getBoolean('supplier')); + $filter->setManufacturer($request->query->getBoolean('manufacturer')); + $filter->setFootprint($request->query->getBoolean('footprint')); + $filter->setAssembly($request->query->getBoolean('assembly')); + } else { + //Simple search: search in all fields + $filter->setName(true); + $filter->setCategory(true); + $filter->setDescription(true); + $filter->setComment(true); + $filter->setManufacturingStatus(true); + $filter->setTags(true); + $filter->setStorelocation(true); + $filter->setOrdernr(true); + $filter->setMpn(true); + $filter->setIPN(true); + $filter->setAssembly(true); + } $filter->setRegex($request->query->getBoolean('regex')); @@ -342,17 +451,34 @@ class PartListsController extends AbstractController public function showSearch(Request $request, DataTableFactory $dataTable): Response { $searchFilter = $this->searchRequestToFilter($request); + $datasource = $request->query->get('datasource', 'parts'); + + $dataTableType = PartsDataTable::class; + $template = 'parts/lists/search_list.html.twig'; + $filterType = PartFilter::class; + + if ($searchFilter instanceof AssemblySearchFilter) { + $dataTableType = AssemblyDataTable::class; + $filterType = AssemblyFilter::class; + } elseif ($searchFilter instanceof ProjectSearchFilter) { + $dataTableType = ProjectSearchDataTable::class; + $filterType = ProjectFilter::class; + } return $this->showListWithFilter($request, - 'parts/lists/search_list.html.twig', + $template, null, null, [ 'keyword' => $searchFilter->getKeyword(), 'searchFilter' => $searchFilter, + 'dataTableType' => $dataTableType, + 'datasource' => $datasource, ], [ 'search' => $searchFilter, + 'dataTableType' => $dataTableType, + 'filterType' => $filterType, ] ); } diff --git a/src/Controller/TypeaheadController.php b/src/Controller/TypeaheadController.php index 13a57dd7..1648ec73 100644 --- a/src/Controller/TypeaheadController.php +++ b/src/Controller/TypeaheadController.php @@ -136,7 +136,7 @@ class TypeaheadController extends AbstractController $partRepository = $entityManager->getRepository(Part::class); - $parts = $partRepository->autocompleteSearch($query, 100); + $parts = $partRepository->autocompleteSearch($query, 10); /** @var Part[]|Assembly[] $data */ $data = []; @@ -167,7 +167,7 @@ class TypeaheadController extends AbstractController if ($this->isGranted('@projects.read')) { $projectRepository = $entityManager->getRepository(Project::class); - $projects = $projectRepository->autocompleteSearch($query, 100); + $projects = $projectRepository->autocompleteSearch($query, 10); foreach ($projects as $project) { $preview_attachment = $projectPreviewGenerator->getTablePreviewAttachment($project); @@ -194,7 +194,7 @@ class TypeaheadController extends AbstractController if ($this->isGranted('@assemblies.read')) { $assemblyRepository = $entityManager->getRepository(Assembly::class); - $assemblies = $assemblyRepository->autocompleteSearch($query, 100); + $assemblies = $assemblyRepository->autocompleteSearch($query, 10); foreach ($assemblies as $assembly) { $preview_attachment = $assemblyPreviewGenerator->getTablePreviewAttachment($assembly); diff --git a/src/DataTables/AssemblyDataTable.php b/src/DataTables/AssemblyDataTable.php index aaad2e45..aa205712 100644 --- a/src/DataTables/AssemblyDataTable.php +++ b/src/DataTables/AssemblyDataTable.php @@ -82,6 +82,8 @@ final class AssemblyDataTable implements DataTableTypeInterface 'label' => '', 'className' => 'no-colvis', 'render' => fn($value, Assembly $context) => $this->assemblyDataTableHelper->renderPicture($context), + 'orderable' => false, + 'searchable' => false, ], visibility_configurable: false) ->add('name', TextColumn::class, [ 'label' => $this->translator->trans('assembly.table.name'), @@ -98,6 +100,10 @@ final class AssemblyDataTable implements DataTableTypeInterface ->add('description', MarkdownColumn::class, [ 'label' => $this->translator->trans('assembly.table.description'), ]) + ->add('comment', MarkdownColumn::class, [ + 'label' => $this->translator->trans('assembly.table.comment'), + 'render' => fn($value, Assembly $context) => $context->getComment() + ]) ->add('addedDate', LocaleDateTimeColumn::class, [ 'label' => $this->translator->trans('assembly.table.addedDate'), ]) @@ -222,11 +228,23 @@ final class AssemblyDataTable implements DataTableTypeInterface //The join fields get prefixed with an underscore, so we can check if they are used in the query easy without confusing them for a assembly subfield $dql = $builder->getDQL(); - if (str_contains($dql, '_master_picture_attachment')) { + //Helper function to check if a join alias is already present in the QueryBuilder + $hasJoin = static function (QueryBuilder $qb, string $alias): bool { + foreach ($qb->getDQLPart('join') as $joins) { + foreach ($joins as $join) { + if ($join->getAlias() === $alias) { + return true; + } + } + } + return false; + }; + + if (str_contains($dql, '_master_picture_attachment') && !$hasJoin($builder, '_master_picture_attachment')) { $builder->leftJoin('assembly.master_picture_attachment', '_master_picture_attachment'); $builder->addGroupBy('_master_picture_attachment'); } - if (str_contains($dql, '_attachments')) { + if (str_contains($dql, '_attachments') && !$hasJoin($builder, '_attachments')) { $builder->leftJoin('assembly.attachments', '_attachments'); } diff --git a/src/DataTables/Filters/AssemblyFilter.php b/src/DataTables/Filters/AssemblyFilter.php index d8d07a1e..f53305d4 100644 --- a/src/DataTables/Filters/AssemblyFilter.php +++ b/src/DataTables/Filters/AssemblyFilter.php @@ -22,10 +22,12 @@ declare(strict_types=1); */ namespace App\DataTables\Filters; +use App\DataTables\Filters\Constraints\ChoiceConstraint; use App\DataTables\Filters\Constraints\DateTimeConstraint; use App\DataTables\Filters\Constraints\EntityConstraint; use App\DataTables\Filters\Constraints\IntConstraint; use App\DataTables\Filters\Constraints\TextConstraint; +use App\Entity\AssemblySystem\Assembly; use App\Entity\Attachments\AttachmentType; use App\Services\Trees\NodesListBuilder; use Doctrine\ORM\QueryBuilder; @@ -40,6 +42,8 @@ class AssemblyFilter implements FilterInterface public readonly TextConstraint $name; public readonly TextConstraint $description; public readonly TextConstraint $comment; + public readonly ChoiceConstraint $status; + public readonly EntityConstraint $category; public readonly DateTimeConstraint $lastModified; public readonly DateTimeConstraint $addedDate; @@ -52,6 +56,8 @@ class AssemblyFilter implements FilterInterface $this->name = new TextConstraint('assembly.name'); $this->description = new TextConstraint('assembly.description'); $this->comment = new TextConstraint('assembly.comment'); + $this->status = new ChoiceConstraint('assembly.status'); + $this->category = new EntityConstraint($nodesListBuilder, Assembly::class, 'assembly.parent'); $this->dbId = new IntConstraint('assembly.id'); $this->ipn = new TextConstraint('assembly.ipn'); $this->addedDate = new DateTimeConstraint('assembly.addedDate'); diff --git a/src/DataTables/Filters/AssemblySearchFilter.php b/src/DataTables/Filters/AssemblySearchFilter.php index 2ab33c83..d41649d8 100644 --- a/src/DataTables/Filters/AssemblySearchFilter.php +++ b/src/DataTables/Filters/AssemblySearchFilter.php @@ -30,19 +30,34 @@ class AssemblySearchFilter implements FilterInterface protected bool $regex = false; /** @var bool Use name field for searching */ - protected bool $name = true; + protected bool $name = false; /** @var bool Use description for searching */ - protected bool $description = true; + protected bool $description = false; /** @var bool Use comment field for searching */ - protected bool $comment = true; - - /** @var bool Use ordernr for searching */ - protected bool $ordernr = true; + protected bool $comment = false; /** @var bool Use Internal part number for searching */ - protected bool $ipn = true; + protected bool $ipn = false; + + /** @var bool Use id field for searching */ + protected bool $dbId = false; + + /** + * If true, we search in the name of the parent assembly (if available). + * This field is named "category" to keep the API consistent with PartSearchFilter, + * although assemblies don't have categories (they only have parents). + */ + protected bool $category = false; + + /** @var bool Use status field for searching */ + protected bool $status = false; + + /** @var string The datasource used for searching */ + protected string $datasource = 'assemblies'; + + protected static int $parameterCounter = 0; public function __construct( /** @var string The string to query for */ @@ -51,6 +66,13 @@ class AssemblySearchFilter implements FilterInterface { } + protected function generateParameterIdentifier(string $property): string + { + //Replace all special characters with underscores + $property = preg_replace('/\W/', '_', $property); + return $property . '_' . (self::$parameterCounter++) . '_'; + } + protected function getFieldsToSearch(): array { $fields_to_search = []; @@ -67,12 +89,41 @@ class AssemblySearchFilter implements FilterInterface if ($this->ipn) { $fields_to_search[] = 'assembly.ipn'; } + if ($this->status) { + $fields_to_search[] = 'assembly.status'; + } + if ($this->dbId) { + $fields_to_search[] = 'assembly.id'; + } + if ($this->category) { + // We search in the name of the parent assembly. + // This is named category for consistency with PartSearchFilter. + $fields_to_search[] = '_search_parent.name'; + } return $fields_to_search; } public function apply(QueryBuilder $queryBuilder): void { + if ($this->category) { + // We search in the parent assembly. + // Check if the join alias is already present in the QueryBuilder + $hasJoin = false; + foreach ($queryBuilder->getDQLPart('join') as $joins) { + foreach ($joins as $join) { + if ($join->getAlias() === '_search_parent') { + $hasJoin = true; + break 2; + } + } + } + + if (!$hasJoin) { + $queryBuilder->leftJoin('assembly.parent', '_search_parent'); + } + } + $fields_to_search = $this->getFieldsToSearch(); //If we have nothing to search for, do nothing @@ -80,13 +131,15 @@ class AssemblySearchFilter implements FilterInterface return; } + $parameterIdentifier = $this->generateParameterIdentifier('search_query'); + //Convert the fields to search to a list of expressions - $expressions = array_map(function (string $field): string { + $expressions = array_map(function (string $field) use ($parameterIdentifier): string { if ($this->regex) { - return sprintf("REGEXP(%s, :search_query) = TRUE", $field); + return sprintf("REGEXP(%s, :%s) = TRUE", $field, $parameterIdentifier); } - return sprintf("ILIKE(%s, :search_query) = TRUE", $field); + return sprintf("ILIKE(%s, :%s) = TRUE", $field, $parameterIdentifier); }, $fields_to_search); //Add Or concatenation of the expressions to our query @@ -96,9 +149,11 @@ class AssemblySearchFilter implements FilterInterface //For regex, we pass the query as is, for like we add % to the start and end as wildcards if ($this->regex) { - $queryBuilder->setParameter('search_query', $this->keyword); + $queryBuilder->setParameter($parameterIdentifier, $this->keyword); } else { - $queryBuilder->setParameter('search_query', '%' . $this->keyword . '%'); + //Escape % and _ characters in the keyword + $keyword_escaped = str_replace(['%', '_'], ['\%', '\_'], $this->keyword); + $queryBuilder->setParameter($parameterIdentifier, '%' . $keyword_escaped . '%'); } } @@ -162,11 +217,137 @@ class AssemblySearchFilter implements FilterInterface return $this->comment; } - public function setComment(bool $comment): AssemblySearchFilter + public function setComment(bool $comment): self { $this->comment = $comment; return $this; } + public function isCategory(): bool + { + return $this->category; + } + /** + * Set if the parent assembly name should be searched. + * This is named "category" for consistency with PartSearchFilter. + */ + public function setCategory(bool $category): self + { + $this->category = $category; + return $this; + } + + public function isStatus(): bool + { + return $this->status; + } + + public function setStatus(bool $status): self + { + $this->status = $status; + return $this; + } + + public function isMpn(): bool + { + return false; + } + + public function setMpn(bool $mpn): self + { + return $this; + } + + public function isTags(): bool + { + return false; + } + + public function setTags(bool $tags): self + { + return $this; + } + + public function isStorelocation(): bool + { + return false; + } + + public function setStorelocation(bool $storelocation): self + { + return $this; + } + + public function isSupplier(): bool + { + return false; + } + + public function setSupplier(bool $supplier): self + { + return $this; + } + + public function isManufacturer(): bool + { + return false; + } + + public function setManufacturer(bool $manufacturer): self + { + return $this; + } + + public function isFootprint(): bool + { + return false; + } + + public function setFootprint(bool $footprint): self + { + return $this; + } + + public function isDbId(): bool + { + return $this->dbId; + } + + public function setDbId(bool $dbId): self + { + $this->dbId = $dbId; + return $this; + } + + public function isAssembly(): bool + { + return false; + } + + public function setAssembly(bool $assembly): self + { + return $this; + } + + public function isOrdernr(): bool + { + return false; + } + + public function setOrdernr(bool $ordernr): self + { + return $this; + } + + public function getDatasource(): string + { + return $this->datasource; + } + + public function setDatasource(string $datasource): self + { + $this->datasource = $datasource; + return $this; + } } diff --git a/src/DataTables/Filters/PartSearchFilter.php b/src/DataTables/Filters/PartSearchFilter.php index 6c94e324..084ae291 100644 --- a/src/DataTables/Filters/PartSearchFilter.php +++ b/src/DataTables/Filters/PartSearchFilter.php @@ -32,31 +32,31 @@ class PartSearchFilter implements FilterInterface protected bool $regex = false; /** @var bool Use name field for searching */ - protected bool $name = true; + protected bool $name = false; /** @var bool Use id field for searching */ protected bool $dbId = false; /** @var bool Use category name for searching */ - protected bool $category = true; + protected bool $category = false; /** @var bool Use description for searching */ - protected bool $description = true; + protected bool $description = false; /** @var bool Use tags for searching */ - protected bool $tags = true; + protected bool $tags = false; /** @var bool Use storelocation name for searching */ - protected bool $storelocation = true; + protected bool $storelocation = false; /** @var bool Use comment field for searching */ - protected bool $comment = true; + protected bool $comment = false; /** @var bool Use ordernr for searching */ - protected bool $ordernr = true; + protected bool $ordernr = false; /** @var bool Use manufacturer product name for searching */ - protected bool $mpn = true; + protected bool $mpn = false; /** @var bool Use supplier name for searching */ protected bool $supplier = false; @@ -67,11 +67,19 @@ class PartSearchFilter implements FilterInterface /** @var bool Use footprint name for searching */ protected bool $footprint = false; + /** @var bool Use manufacturing status for searching */ + protected bool $manufacturingStatus = false; + /** @var bool Use Internal Part number for searching */ - protected bool $ipn = true; + protected bool $ipn = false; /** @var bool Use assembly name for searching */ - protected bool $assembly = true; + protected bool $assembly = false; + + /** @var string The datasource used for searching */ + protected string $datasource = 'parts'; + + protected static int $parameterCounter = 0; public function __construct( /** @var string The string to query for */ @@ -80,6 +88,13 @@ class PartSearchFilter implements FilterInterface { } + protected function generateParameterIdentifier(string $property): string + { + //Replace all special characters with underscores + $property = preg_replace('/\W/', '_', $property); + return $property . '_' . (self::$parameterCounter++) . '_'; + } + protected function getFieldsToSearch(): array { $fields_to_search = []; @@ -88,7 +103,7 @@ class PartSearchFilter implements FilterInterface $fields_to_search[] = 'part.name'; } if($this->category) { - $fields_to_search[] = '_category.name'; + $fields_to_search[] = '_search_category.name'; } if($this->description) { $fields_to_search[] = 'part.description'; @@ -100,29 +115,32 @@ class PartSearchFilter implements FilterInterface $fields_to_search[] = 'part.tags'; } if($this->storelocation) { - $fields_to_search[] = '_storelocations.name'; + $fields_to_search[] = '_search_storelocations.name'; } if($this->ordernr) { - $fields_to_search[] = '_orderdetails.supplierpartnr'; + $fields_to_search[] = '_search_orderdetails.supplierpartnr'; } if($this->mpn) { $fields_to_search[] = 'part.manufacturer_product_number'; } if($this->supplier) { - $fields_to_search[] = '_suppliers.name'; + $fields_to_search[] = '_search_suppliers.name'; } if($this->manufacturer) { - $fields_to_search[] = '_manufacturer.name'; + $fields_to_search[] = '_search_manufacturer.name'; } if($this->footprint) { - $fields_to_search[] = '_footprint.name'; + $fields_to_search[] = '_search_footprint.name'; + } + if($this->manufacturingStatus) { + $fields_to_search[] = 'part.manufacturing_status'; } if ($this->ipn) { $fields_to_search[] = 'part.ipn'; } if ($this->assembly) { - $fields_to_search[] = '_assembly.name'; - $fields_to_search[] = '_assembly.ipn'; + $fields_to_search[] = '_search_assembly.name'; + $fields_to_search[] = '_search_assembly.ipn'; } return $fields_to_search; @@ -130,6 +148,33 @@ class PartSearchFilter implements FilterInterface public function apply(QueryBuilder $queryBuilder): void { + if ($this->category) { + $queryBuilder->leftJoin('part.category', '_search_category'); + } + if ($this->storelocation) { + $queryBuilder->leftJoin('part.partLots', '_search_partLots') + ->leftJoin('_search_partLots.storage_location', '_search_storelocations'); + } + if ($this->ordernr) { + $queryBuilder->leftJoin('part.orderdetails', '_search_orderdetails'); + } + if ($this->supplier) { + if (!$this->ordernr) { + $queryBuilder->leftJoin('part.orderdetails', '_search_orderdetails'); + } + $queryBuilder->leftJoin('_search_orderdetails.supplier', '_search_suppliers'); + } + if ($this->manufacturer) { + $queryBuilder->leftJoin('part.manufacturer', '_search_manufacturer'); + } + if ($this->footprint) { + $queryBuilder->leftJoin('part.footprint', '_search_footprint'); + } + if ($this->assembly) { + $queryBuilder->leftJoin('part.assembly_bom_entries', '_search_assemblyBomEntries') + ->leftJoin('_search_assemblyBomEntries.assembly', '_search_assembly'); + } + $fields_to_search = $this->getFieldsToSearch(); $is_numeric = preg_match('/^\d+$/', $this->keyword) === 1; @@ -141,32 +186,34 @@ class PartSearchFilter implements FilterInterface return; } + $parameterIdentifier = $this->generateParameterIdentifier('search_query'); $expressions = []; - + if($fields_to_search !== []) { //Convert the fields to search to a list of expressions - $expressions = array_map(function (string $field): string { + $expressions = array_map(function (string $field) use ($parameterIdentifier): string { if ($this->regex) { - return sprintf("REGEXP(%s, :search_query) = TRUE", $field); + return sprintf("REGEXP(%s, :%s) = TRUE", $field, $parameterIdentifier); } - return sprintf("ILIKE(%s, :search_query) = TRUE", $field); + return sprintf("ILIKE(%s, :%s) = TRUE", $field, $parameterIdentifier); }, $fields_to_search); - + //For regex, we pass the query as is, for like we add % to the start and end as wildcards if ($this->regex) { - $queryBuilder->setParameter('search_query', $this->keyword); + $queryBuilder->setParameter($parameterIdentifier, $this->keyword); } else { //Escape % and _ characters in the keyword - $this->keyword = str_replace(['%', '_'], ['\%', '\_'], $this->keyword); - $queryBuilder->setParameter('search_query', '%' . $this->keyword . '%'); + $keyword_escaped = str_replace(['%', '_'], ['\%', '\_'], $this->keyword); + $queryBuilder->setParameter($parameterIdentifier, '%' . $keyword_escaped . '%'); } } //Use equal expression to just search for exact numeric matches if ($search_dbId) { - $expressions[] = $queryBuilder->expr()->eq('part.id', ':id_exact'); - $queryBuilder->setParameter('id_exact', (int) $this->keyword, + $idParameterIdentifier = $this->generateParameterIdentifier('id_exact'); + $expressions[] = $queryBuilder->expr()->eq('part.id', ':' . $idParameterIdentifier); + $queryBuilder->setParameter($idParameterIdentifier, (int) $this->keyword, ParameterType::INTEGER); } @@ -333,6 +380,17 @@ class PartSearchFilter implements FilterInterface return $this; } + public function isManufacturingStatus(): bool + { + return $this->manufacturingStatus; + } + + public function setManufacturingStatus(bool $manufacturingStatus): PartSearchFilter + { + $this->manufacturingStatus = $manufacturingStatus; + return $this; + } + public function isComment(): bool { return $this->comment; @@ -354,4 +412,31 @@ class PartSearchFilter implements FilterInterface $this->assembly = $assembly; return $this; } + + /** + * Dummy method for compatibility with assembly/project search options in Twig. + */ + public function isStatus(): bool + { + return false; + } + + /** + * Dummy method for compatibility with assembly/project search options in Twig. + */ + public function setStatus(bool $status): PartSearchFilter + { + return $this; + } + + public function getDatasource(): string + { + return $this->datasource; + } + + public function setDatasource(string $datasource): PartSearchFilter + { + $this->datasource = $datasource; + return $this; + } } diff --git a/src/DataTables/Filters/ProjectFilter.php b/src/DataTables/Filters/ProjectFilter.php new file mode 100644 index 00000000..0444171d --- /dev/null +++ b/src/DataTables/Filters/ProjectFilter.php @@ -0,0 +1,73 @@ +. + */ + +namespace App\DataTables\Filters; + +use App\DataTables\Filters\Constraints\ChoiceConstraint; +use App\DataTables\Filters\Constraints\DateTimeConstraint; +use App\DataTables\Filters\Constraints\EntityConstraint; +use App\DataTables\Filters\Constraints\IntConstraint; +use App\DataTables\Filters\Constraints\TextConstraint; +use App\Entity\Attachments\AttachmentType; +use App\Entity\ProjectSystem\Project; +use App\Services\Trees\NodesListBuilder; +use Doctrine\ORM\QueryBuilder; + +class ProjectFilter implements FilterInterface +{ + use CompoundFilterTrait; + + public readonly IntConstraint $dbId; + public readonly TextConstraint $name; + public readonly TextConstraint $description; + public readonly TextConstraint $comment; + public readonly ChoiceConstraint $status; + public readonly EntityConstraint $category; + public readonly DateTimeConstraint $lastModified; + public readonly DateTimeConstraint $addedDate; + + public readonly IntConstraint $attachmentsCount; + public readonly EntityConstraint $attachmentType; + public readonly TextConstraint $attachmentName; + + public function __construct(NodesListBuilder $nodesListBuilder) + { + $this->name = new TextConstraint('project.name'); + $this->description = new TextConstraint('project.description'); + $this->comment = new TextConstraint('project.comment'); + $this->status = new ChoiceConstraint('project.status'); + $this->category = new EntityConstraint($nodesListBuilder, Project::class, 'project.parent'); + $this->dbId = new IntConstraint('project.id'); + $this->addedDate = new DateTimeConstraint('project.addedDate'); + $this->lastModified = new DateTimeConstraint('project.lastModified'); + + $this->attachmentsCount = new IntConstraint('COUNT(_attachments)'); + $this->attachmentType = new EntityConstraint($nodesListBuilder, AttachmentType::class, '_attachments.attachment_type'); + $this->attachmentName = new TextConstraint('_attachments.name'); + } + + public function apply(QueryBuilder $queryBuilder): void + { + $this->applyAllChildFilters($queryBuilder); + } +} diff --git a/src/DataTables/Filters/ProjectSearchFilter.php b/src/DataTables/Filters/ProjectSearchFilter.php new file mode 100644 index 00000000..860fc55e --- /dev/null +++ b/src/DataTables/Filters/ProjectSearchFilter.php @@ -0,0 +1,346 @@ +. + */ + +namespace App\DataTables\Filters; + +use Doctrine\ORM\QueryBuilder; + +class ProjectSearchFilter implements FilterInterface +{ + /** @var boolean Whether to use regex for searching */ + protected bool $regex = false; + + /** @var bool Use name field for searching */ + protected bool $name = false; + + /** @var bool Use description for searching */ + protected bool $description = false; + + /** @var bool Use comment field for searching */ + protected bool $comment = false; + + /** @var bool Use status field for searching */ + protected bool $status = false; + + /** + * If true, we search in the name of the parent project (if available). + * This field is named "category" to keep the API consistent with PartSearchFilter and AssemblySearchFilter, + * although projects don't have categories (they only have parents). + */ + protected bool $category = false; + + /** @var bool Use dbId field for searching */ + protected bool $dbId = false; + + /** @var string The datasource used for searching */ + protected string $datasource = 'projects'; + + protected static int $parameterCounter = 0; + + public function __construct( + /** @var string The string to query for */ + protected string $keyword + ) { + } + + protected function generateParameterIdentifier(string $property): string + { + //Replace all special characters with underscores + $property = preg_replace('/\W/', '_', $property); + return $property . '_' . (self::$parameterCounter++) . '_'; + } + + protected function getFieldsToSearch(): array + { + $fields_to_search = []; + + if ($this->name) { + $fields_to_search[] = 'project.name'; + } + if ($this->description) { + $fields_to_search[] = 'project.description'; + } + if ($this->comment) { + $fields_to_search[] = 'project.comment'; + } + if ($this->status) { + $fields_to_search[] = 'project.status'; + } + if ($this->category) { + // We search in the name of the parent project. + // This is named category for consistency with PartSearchFilter and AssemblySearchFilter. + $fields_to_search[] = '_search_parent.name'; + } + if ($this->dbId) { + $fields_to_search[] = 'project.id'; + } + + return $fields_to_search; + } + + public function apply(QueryBuilder $queryBuilder): void + { + if ($this->category) { + // We search in the parent project. + // Check if the join alias is already present in the QueryBuilder + $hasJoin = false; + foreach ($queryBuilder->getDQLPart('join') as $joins) { + foreach ($joins as $join) { + if ($join->getAlias() === '_search_parent') { + $hasJoin = true; + break 2; + } + } + } + + if (!$hasJoin) { + $queryBuilder->leftJoin('project.parent', '_search_parent'); + } + } + + $fields_to_search = $this->getFieldsToSearch(); + + //If we have nothing to search for, do nothing + if ($fields_to_search === [] || $this->keyword === '') { + return; + } + + $parameterIdentifier = $this->generateParameterIdentifier('search_query'); + + //Convert the fields to search to a list of expressions + $expressions = array_map(function (string $field) use ($parameterIdentifier): string { + if ($this->regex) { + return sprintf("REGEXP(%s, :%s) = TRUE", $field, $parameterIdentifier); + } + + return sprintf("ILIKE(%s, :%s) = TRUE", $field, $parameterIdentifier); + }, $fields_to_search); + + //Add Or concatenation of the expressions to our query + $queryBuilder->andWhere( + $queryBuilder->expr()->orX(...$expressions) + ); + + //For regex, we pass the query as is, for like we add % to the start and end as wildcards + if ($this->regex) { + $queryBuilder->setParameter($parameterIdentifier, $this->keyword); + } else { + //Escape % and _ characters in the keyword + $keyword_escaped = str_replace(['%', '_'], ['\%', '\_'], $this->keyword); + $queryBuilder->setParameter($parameterIdentifier, '%' . $keyword_escaped . '%'); + } + } + + public function getKeyword(): string + { + return $this->keyword; + } + + public function setKeyword(string $keyword): self + { + $this->keyword = $keyword; + return $this; + } + + public function isRegex(): bool + { + return $this->regex; + } + + public function setRegex(bool $regex): self + { + $this->regex = $regex; + return $this; + } + + public function isName(): bool + { + return $this->name; + } + + public function setName(bool $name): self + { + $this->name = $name; + return $this; + } + + public function isDescription(): bool + { + return $this->description; + } + + public function setDescription(bool $description): self + { + $this->description = $description; + return $this; + } + + public function isComment(): bool + { + return $this->comment; + } + + public function setComment(bool $comment): self + { + $this->comment = $comment; + return $this; + } + + public function isStatus(): bool + { + return $this->status; + } + + public function setStatus(bool $status): self + { + $this->status = $status; + return $this; + } + + public function isCategory(): bool + { + return $this->category; + } + + /** + * Set if the parent project name should be searched. + * This is named "category" for consistency with PartSearchFilter and AssemblySearchFilter. + */ + public function setCategory(bool $category): self + { + $this->category = $category; + return $this; + } + + public function isMpn(): bool + { + return false; + } + + public function setMpn(bool $mpn): self + { + return $this; + } + + public function isTags(): bool + { + return false; + } + + public function setTags(bool $tags): self + { + return $this; + } + + public function isStorelocation(): bool + { + return false; + } + + public function setStorelocation(bool $storelocation): self + { + return $this; + } + + public function isSupplier(): bool + { + return false; + } + + public function setSupplier(bool $supplier): self + { + return $this; + } + + public function isManufacturer(): bool + { + return false; + } + + public function setManufacturer(bool $manufacturer): self + { + return $this; + } + + public function isFootprint(): bool + { + return false; + } + + public function setFootprint(bool $footprint): self + { + return $this; + } + + public function isDbId(): bool + { + return $this->dbId; + } + + public function setDbId(bool $dbId): self + { + $this->dbId = $dbId; + return $this; + } + + public function isAssembly(): bool + { + return false; + } + + public function setAssembly(bool $assembly): self + { + return $this; + } + + public function isOrdernr(): bool + { + return false; + } + + public function setOrdernr(bool $ordernr): self + { + return $this; + } + + public function isIPN(): bool + { + return false; + } + + public function setIPN(bool $ipn): self + { + return $this; + } + + public function getDatasource(): string + { + return $this->datasource; + } + + public function setDatasource(string $datasource): self + { + $this->datasource = $datasource; + return $this; + } +} diff --git a/src/DataTables/Helpers/ProjectDataTableHelper.php b/src/DataTables/Helpers/ProjectDataTableHelper.php new file mode 100644 index 00000000..1c7e4d78 --- /dev/null +++ b/src/DataTables/Helpers/ProjectDataTableHelper.php @@ -0,0 +1,74 @@ +. + */ + +namespace App\DataTables\Helpers; + +use App\Entity\Attachments\Attachment; +use App\Entity\ProjectSystem\Project; +use App\Services\Attachments\AttachmentURLGenerator; +use App\Services\Attachments\ProjectPreviewGenerator; +use App\Services\EntityURLGenerator; + +/** + * A helper service which contains common code to render columns for project related tables + */ +class ProjectDataTableHelper +{ + public function __construct( + private readonly EntityURLGenerator $entityURLGenerator, + private readonly ProjectPreviewGenerator $previewGenerator, + private readonly AttachmentURLGenerator $attachmentURLGenerator + ) { + } + + public function renderName(Project $context): string + { + return sprintf( + '%s', + $this->entityURLGenerator->infoURL($context), + htmlspecialchars($context->getName()) + ); + } + + public function renderPicture(Project $context): string + { + $preview_attachment = $this->previewGenerator->getTablePreviewAttachment($context); + if (!$preview_attachment instanceof Attachment) { + return ''; + } + + $title = htmlspecialchars($preview_attachment->getName()); + if ($preview_attachment->getFilename()) { + $title .= ' ('.htmlspecialchars($preview_attachment->getFilename()).')'; + } + + return sprintf( + '%s', + 'Project image', + $this->attachmentURLGenerator->getThumbnailURL($preview_attachment), + $this->attachmentURLGenerator->getThumbnailURL($preview_attachment, 'thumbnail_md'), + 'hoverpic project-table-image', + $title + ); + } +} diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php index 491a1fef..fa89f343 100644 --- a/src/DataTables/PartsDataTable.php +++ b/src/DataTables/PartsDataTable.php @@ -419,6 +419,18 @@ final class PartsDataTable implements DataTableTypeInterface //The join fields get prefixed with an underscore, so we can check if they are used in the query easy without confusing them for a part subfield $dql = $builder->getDQL(); + //Helper function to check if a join alias is already present in the QueryBuilder + $hasJoin = static function (QueryBuilder $qb, string $alias): bool { + foreach ($qb->getDQLPart('join') as $joins) { + foreach ($joins as $join) { + if ($join->getAlias() === $alias) { + return true; + } + } + } + return false; + }; + //Add the amountSum field, if it is used in the query if (str_contains($dql, 'amountSum')) { //Calculate amount sum using a subquery, so we can filter and sort by it @@ -433,69 +445,85 @@ final class PartsDataTable implements DataTableTypeInterface ); } - if (str_contains($dql, '_category')) { + if (str_contains($dql, '_category') && !$hasJoin($builder, '_category')) { $builder->leftJoin('part.category', '_category'); $builder->addGroupBy('_category'); } - if (str_contains($dql, '_master_picture_attachment')) { + if (str_contains($dql, '_master_picture_attachment') && !$hasJoin($builder, '_master_picture_attachment')) { $builder->leftJoin('part.master_picture_attachment', '_master_picture_attachment'); $builder->addGroupBy('_master_picture_attachment'); } if (str_contains($dql, '_partLots') || str_contains($dql, '_storelocations')) { - $builder->leftJoin('part.partLots', '_partLots'); - $builder->leftJoin('_partLots.storage_location', '_storelocations'); + if (!$hasJoin($builder, '_partLots')) { + $builder->leftJoin('part.partLots', '_partLots'); + } + if (str_contains($dql, '_storelocations') && !$hasJoin($builder, '_storelocations')) { + $builder->leftJoin('_partLots.storage_location', '_storelocations'); + } //Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1 //$builder->addGroupBy('_partLots'); //$builder->addGroupBy('_storelocations'); } - if (str_contains($dql, '_footprint')) { + if (str_contains($dql, '_footprint') && !$hasJoin($builder, '_footprint')) { $builder->leftJoin('part.footprint', '_footprint'); $builder->addGroupBy('_footprint'); } - if (str_contains($dql, '_manufacturer')) { + if (str_contains($dql, '_manufacturer') && !$hasJoin($builder, '_manufacturer')) { $builder->leftJoin('part.manufacturer', '_manufacturer'); $builder->addGroupBy('_manufacturer'); } if (str_contains($dql, '_orderdetails') || str_contains($dql, '_suppliers')) { - $builder->leftJoin('part.orderdetails', '_orderdetails'); - $builder->leftJoin('_orderdetails.supplier', '_suppliers'); + if (!$hasJoin($builder, '_orderdetails')) { + $builder->leftJoin('part.orderdetails', '_orderdetails'); + } + if (str_contains($dql, '_suppliers') && !$hasJoin($builder, '_suppliers')) { + $builder->leftJoin('_orderdetails.supplier', '_suppliers'); + } //Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1 //$builder->addGroupBy('_orderdetails'); //$builder->addGroupBy('_suppliers'); } - if (str_contains($dql, '_attachments')) { + if (str_contains($dql, '_attachments') && !$hasJoin($builder, '_attachments')) { $builder->leftJoin('part.attachments', '_attachments'); //Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1 //$builder->addGroupBy('_attachments'); } - if (str_contains($dql, '_partUnit')) { + if (str_contains($dql, '_partUnit') && !$hasJoin($builder, '_partUnit')) { $builder->leftJoin('part.partUnit', '_partUnit'); $builder->addGroupBy('_partUnit'); } - if (str_contains($dql, '_partCustomState')) { + if (str_contains($dql, '_partCustomState') && !$hasJoin($builder, '_partCustomState')) { $builder->leftJoin('part.partCustomState', '_partCustomState'); $builder->addGroupBy('_partCustomState'); } - if (str_contains($dql, '_parameters')) { + if (str_contains($dql, '_parameters') && !$hasJoin($builder, '_parameters')) { $builder->leftJoin('part.parameters', '_parameters'); //Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1 //$builder->addGroupBy('_parameters'); } - if (str_contains($dql, '_projectBomEntries')) { + if (str_contains($dql, '_projectBomEntries') && !$hasJoin($builder, '_projectBomEntries')) { $builder->leftJoin('part.project_bom_entries', '_projectBomEntries'); //Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1 //$builder->addGroupBy('_projectBomEntries'); } if (str_contains($dql, '_assembly.')) { - $builder->leftJoin('part.assembly_bom_entries', '_assemblyBomEntries'); - $builder->leftJoin('_assemblyBomEntries.assembly', '_assembly'); + if (!$hasJoin($builder, '_assemblyBomEntries')) { + $builder->leftJoin('part.assembly_bom_entries', '_assemblyBomEntries'); + } + if (!$hasJoin($builder, '_assembly')) { + $builder->leftJoin('_assemblyBomEntries.assembly', '_assembly'); + } } - if (str_contains($dql, '_assemblyBomEntries')) { + if (str_contains($dql, '_assemblyBomEntries') && !$hasJoin($builder, '_assemblyBomEntries')) { $builder->leftJoin('part.assembly_bom_entries', '_assemblyBomEntries'); } if (str_contains($dql, '_jobPart')) { - $builder->leftJoin('part.bulkImportJobParts', '_jobPart'); - $builder->leftJoin('_jobPart.job', '_bulkImportJob'); + if (!$hasJoin($builder, '_jobPart')) { + $builder->leftJoin('part.bulkImportJobParts', '_jobPart'); + } + if (!$hasJoin($builder, '_bulkImportJob')) { + $builder->leftJoin('_jobPart.job', '_bulkImportJob'); + } //Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1 //$builder->addGroupBy('_jobPart'); //$builder->addGroupBy('_bulkImportJob'); diff --git a/src/DataTables/ProjectSearchDataTable.php b/src/DataTables/ProjectSearchDataTable.php new file mode 100644 index 00000000..29a4c444 --- /dev/null +++ b/src/DataTables/ProjectSearchDataTable.php @@ -0,0 +1,188 @@ +. + */ + +namespace App\DataTables; + +use App\DataTables\Adapters\TwoStepORMAdapter; +use App\DataTables\Column\IconLinkColumn; +use App\DataTables\Column\LocaleDateTimeColumn; +use App\DataTables\Helpers\ColumnSortHelper; +use App\DataTables\Helpers\ProjectDataTableHelper; +use App\DataTables\Filters\ProjectFilter; +use App\DataTables\Filters\ProjectSearchFilter; +use App\DataTables\Column\MarkdownColumn; +use App\Entity\ProjectSystem\Project; +use App\Doctrine\Helpers\FieldHelper; +use App\Services\EntityURLGenerator; +use App\Settings\BehaviorSettings\TableSettings; +use Doctrine\ORM\AbstractQuery; +use Doctrine\ORM\QueryBuilder; +use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider; +use Omines\DataTablesBundle\Column\TextColumn; +use Omines\DataTablesBundle\DataTable; +use Omines\DataTablesBundle\DataTableTypeInterface; +use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Contracts\Translation\TranslatorInterface; + +class ProjectSearchDataTable implements DataTableTypeInterface +{ + public function __construct( + private readonly ProjectDataTableHelper $projectDataTableHelper, + private readonly TranslatorInterface $translator, + private readonly EntityURLGenerator $urlGenerator, + private readonly Security $security, + private readonly ColumnSortHelper $csh, + private readonly TableSettings $tableSettings + ) { + } + + public function configureOptions(OptionsResolver $optionsResolver): void + { + $optionsResolver->setDefaults([ + 'filter' => null, + 'search' => null, + ]); + + $optionsResolver->setAllowedTypes('filter', [ProjectFilter::class, 'null']); + $optionsResolver->setAllowedTypes('search', [ProjectSearchFilter::class, 'null']); + } + + public function configure(DataTable $dataTable, array $options): void + { + $resolver = new OptionsResolver(); + $this->configureOptions($resolver); + $options = $resolver->resolve($options); + + $this->csh + ->add('picture', TextColumn::class, [ + 'label' => '', + 'className' => 'no-colvis', + 'render' => function ($value, Project $context): string { + return $this->projectDataTableHelper->renderPicture($context); + }, + 'orderable' => false, + 'searchable' => false, + ], visibility_configurable: false) + ->add('name', TextColumn::class, [ + 'label' => $this->translator->trans('project.table.name'), + 'render' => function ($value, Project $context): string { + return $this->projectDataTableHelper->renderName($context); + }, + 'orderField' => 'NATSORT(project.name)' + ]) + ->add('id', TextColumn::class, [ + 'label' => $this->translator->trans('project.table.id'), + ]) + ->add('description', MarkdownColumn::class, [ + 'label' => $this->translator->trans('project.table.description'), + ]) + ->add('comment', MarkdownColumn::class, [ + 'label' => $this->translator->trans('project.table.comment'), + ]) + ->add('addedDate', LocaleDateTimeColumn::class, [ + 'label' => $this->translator->trans('project.table.addedDate'), + ]) + ->add('lastModified', LocaleDateTimeColumn::class, [ + 'label' => $this->translator->trans('project.table.lastModified'), + ]) + ->add('edit', IconLinkColumn::class, [ + 'label' => $this->translator->trans('project.table.edit'), + 'href' => fn($value, Project $context) => $this->urlGenerator->editURL($context), + 'disabled' => fn($value, Project $context) => !$this->security->isGranted('edit', $context), + 'title' => $this->translator->trans('project.table.edit.title'), + ]); + + //Apply the user configured order and visibility and add the columns to the table + $this->csh->applyVisibilityAndConfigureColumns($dataTable, $this->tableSettings->projectsDefaultColumns, + "TABLE_PROJECTS_DEFAULT_COLUMNS"); + + $dataTable->addOrderBy('name') + ->createAdapter(TwoStepORMAdapter::class, [ + 'filter_query' => $this->getFilterQuery(...), + 'detail_query' => $this->getDetailQuery(...), + 'entity' => Project::class, + 'hydrate' => AbstractQuery::HYDRATE_OBJECT, + 'simple_total_query' => true, + 'criteria' => [ + function (QueryBuilder $builder) use ($options): void { + if ($options['search'] instanceof ProjectSearchFilter) { + $options['search']->apply($builder); + } + + if ($options['filter'] instanceof ProjectFilter) { + $options['filter']->apply($builder); + } + }, + new SearchCriteriaProvider(), + ], + 'query_modifier' => $this->addJoins(...), + ]); + } + + public function getFilterQuery(QueryBuilder $builder): void + { + $builder + ->select('project.id') + ->from(Project::class, 'project'); + + $this->addJoins($builder); + } + + private function addJoins(QueryBuilder $builder): QueryBuilder + { + $dql = $builder->getDQL(); + + //Helper function to check if a join alias is already present in the QueryBuilder + $hasJoin = static function (QueryBuilder $qb, string $alias): bool { + foreach ($qb->getDQLPart('join') as $joins) { + foreach ($joins as $join) { + if ($join->getAlias() === $alias) { + return true; + } + } + } + return false; + }; + + if (str_contains($dql, '_master_picture_attachment') && !$hasJoin($builder, '_master_picture_attachment')) { + $builder->leftJoin('project.master_picture_attachment', '_master_picture_attachment'); + } + + return $builder; + } + + public function getDetailQuery(QueryBuilder $builder, array $filter_results): void + { + $ids = array_map(static fn($row) => $row['id'], $filter_results); + + $builder + ->select('project') + ->from(Project::class, 'project') + ->where('project.id IN (:ids)') + ->setParameter('ids', $ids); + + //Get the results in the same order as the IDs were passed + FieldHelper::addOrderByFieldParam($builder, 'project.id', 'ids'); + } +} diff --git a/src/Form/Filters/AssemblyFilterType.php b/src/Form/Filters/AssemblyFilterType.php index acfbb1a8..2da17e8e 100644 --- a/src/Form/Filters/AssemblyFilterType.php +++ b/src/Form/Filters/AssemblyFilterType.php @@ -23,7 +23,9 @@ declare(strict_types=1); namespace App\Form\Filters; use App\DataTables\Filters\AssemblyFilter; +use App\Entity\AssemblySystem\Assembly; use App\Entity\Attachments\AttachmentType; +use App\Form\Filters\Constraints\ChoiceConstraintType; use App\Form\Filters\Constraints\DateTimeConstraintType; use App\Form\Filters\Constraints\NumberConstraintType; use App\Form\Filters\Constraints\StructuralEntityConstraintType; @@ -59,6 +61,24 @@ class AssemblyFilterType extends AbstractType 'label' => 'assembly.filter.description', ]); + $builder->add('category', StructuralEntityConstraintType::class, [ + 'label' => 'assembly.filter.parent', + 'entity_class' => Assembly::class, + ]); + + $status_choices = [ + 'assembly.status.draft' => 'draft', + 'assembly.status.planning' => 'planning', + 'assembly.status.in_production' => 'in_production', + 'assembly.status.finished' => 'finished', + 'assembly.status.archived' => 'archived', + ]; + + $builder->add('status', ChoiceConstraintType::class, [ + 'label' => 'assembly.filter.status', + 'choices' => $status_choices, + ]); + $builder->add('comment', TextConstraintType::class, [ 'label' => 'assembly.filter.comment' ]); diff --git a/src/Form/Filters/ProjectFilterType.php b/src/Form/Filters/ProjectFilterType.php new file mode 100644 index 00000000..bb92c35a --- /dev/null +++ b/src/Form/Filters/ProjectFilterType.php @@ -0,0 +1,131 @@ +. + */ + +namespace App\Form\Filters; + +use App\DataTables\Filters\ProjectFilter; +use App\Entity\Attachments\AttachmentType; +use App\Entity\ProjectSystem\Project; +use App\Form\Filters\Constraints\ChoiceConstraintType; +use App\Form\Filters\Constraints\DateTimeConstraintType; +use App\Form\Filters\Constraints\NumberConstraintType; +use App\Form\Filters\Constraints\StructuralEntityConstraintType; +use App\Form\Filters\Constraints\TextConstraintType; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\ResetType; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class ProjectFilterType extends AbstractType +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'compound' => true, + 'data_class' => ProjectFilter::class, + 'csrf_protection' => false, + ]); + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + /* + * Common tab + */ + + $builder->add('name', TextConstraintType::class, [ + 'label' => 'project.filter.name', + ]); + + $builder->add('description', TextConstraintType::class, [ + 'label' => 'project.filter.description', + ]); + + $builder->add('category', StructuralEntityConstraintType::class, [ + 'label' => 'project.filter.parent', + 'entity_class' => Project::class, + ]); + + $status_choices = [ + 'project.status.draft' => 'draft', + 'project.status.planning' => 'planning', + 'project.status.in_production' => 'in_production', + 'project.status.finished' => 'finished', + 'project.status.archived' => 'archived', + ]; + + $builder->add('status', ChoiceConstraintType::class, [ + 'label' => 'project.filter.status', + 'choices' => $status_choices, + ]); + + $builder->add('comment', TextConstraintType::class, [ + 'label' => 'project.filter.comment' + ]); + + /* + * Advanced tab + */ + + $builder->add('dbId', NumberConstraintType::class, [ + 'label' => 'project.filter.dbId', + 'min' => 1, + 'step' => 1, + ]); + + $builder->add('lastModified', DateTimeConstraintType::class, [ + 'label' => 'lastModified' + ]); + + $builder->add('addedDate', DateTimeConstraintType::class, [ + 'label' => 'createdAt' + ]); + + /** + * Attachments count + */ + $builder->add('attachmentsCount', NumberConstraintType::class, [ + 'label' => 'project.filter.attachments_count', + 'step' => 1, + 'min' => 0, + ]); + + $builder->add('attachmentType', StructuralEntityConstraintType::class, [ + 'label' => 'attachment.attachment_type', + 'entity_class' => AttachmentType::class + ]); + + $builder->add('attachmentName', TextConstraintType::class, [ + 'label' => 'project.filter.attachmentName', + ]); + + $builder->add('submit', SubmitType::class, [ + 'label' => 'filter.submit', + ]); + + $builder->add('discard', ResetType::class, [ + 'label' => 'filter.discard', + ]); + } +} diff --git a/src/Services/Formatters/MarkdownParser.php b/src/Services/Formatters/MarkdownParser.php index f3ef07df..403f2758 100644 --- a/src/Services/Formatters/MarkdownParser.php +++ b/src/Services/Formatters/MarkdownParser.php @@ -37,13 +37,16 @@ class MarkdownParser * Mark the markdown for rendering. * The rendering of markdown is done on client side. * - * @param string $markdown the Markdown text that should be parsed to html + * @param string|null $markdown the Markdown text that should be parsed to html * @param bool $inline_mode When true, p blocks will have no margins behind them * * @return string the markdown in a version that can be parsed on client side */ - public function markForRendering(string $markdown, bool $inline_mode = false): string + public function markForRendering(?string $markdown, bool $inline_mode = false): string { + if ($markdown === null) { + $markdown = ''; + } return sprintf( '
%s
', $inline_mode ? 'markdown-inline' : '', //Add class if inline mode is enabled, to prevent margin after p diff --git a/src/Settings/BehaviorSettings/AssemblyTableColumns.php b/src/Settings/BehaviorSettings/AssemblyTableColumns.php index 02c315b4..34ddc694 100644 --- a/src/Settings/BehaviorSettings/AssemblyTableColumns.php +++ b/src/Settings/BehaviorSettings/AssemblyTableColumns.php @@ -33,6 +33,7 @@ enum AssemblyTableColumns : string implements TranslatableInterface case ID = "id"; case IPN = "ipn"; case DESCRIPTION = "description"; + case COMMENT = "comment"; case REFERENCED_ASSEMBLIES = "referencedAssemblies"; case ADDED_DATE = "addedDate"; case LAST_MODIFIED = "lastModified"; diff --git a/src/Settings/BehaviorSettings/ProjectTableColumns.php b/src/Settings/BehaviorSettings/ProjectTableColumns.php new file mode 100644 index 00000000..221bdc30 --- /dev/null +++ b/src/Settings/BehaviorSettings/ProjectTableColumns.php @@ -0,0 +1,46 @@ +. + */ + +declare(strict_types=1); + +namespace App\Settings\BehaviorSettings; + +use Symfony\Contracts\Translation\TranslatableInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +enum ProjectTableColumns : string implements TranslatableInterface +{ + case NAME = "name"; + case ID = "id"; + case DESCRIPTION = "description"; + case COMMENT = "comment"; + case ADDED_DATE = "addedDate"; + case LAST_MODIFIED = "lastModified"; + case EDIT = "edit"; + + public function trans(TranslatorInterface $translator, ?string $locale = null): string + { + $key = match($this) { + default => 'project.table.' . $this->value, + }; + + return $translator->trans($key, locale: $locale); + } +} diff --git a/src/Settings/BehaviorSettings/TableSettings.php b/src/Settings/BehaviorSettings/TableSettings.php index 920f80b4..648b3ff2 100644 --- a/src/Settings/BehaviorSettings/TableSettings.php +++ b/src/Settings/BehaviorSettings/TableSettings.php @@ -85,6 +85,21 @@ class TableSettings public array $assembliesDefaultColumns = [AssemblyTableColumns::ID, AssemblyTableColumns::IPN, AssemblyTableColumns::NAME, AssemblyTableColumns::DESCRIPTION, AssemblyTableColumns::REFERENCED_ASSEMBLIES, AssemblyTableColumns::EDIT]; + /** @var ProjectTableColumns[] */ + #[SettingsParameter(ArrayType::class, + label: new TM("settings.behavior.table.projects_default_columns"), + description: new TM("settings.behavior.table.projects_default_columns.help"), + options: ['type' => EnumType::class, 'options' => ['class' => ProjectTableColumns::class]], + formType: \Symfony\Component\Form\Extension\Core\Type\EnumType::class, + formOptions: ['class' => ProjectTableColumns::class, 'multiple' => true, 'ordered' => true], + envVar: "TABLE_PROJECTS_DEFAULT_COLUMNS", envVarMode: EnvVarMode::OVERWRITE, envVarMapper: [self::class, 'mapProjectsDefaultColumnsEnv'] + )] + #[Assert\NotBlank()] + #[Assert\Unique()] + #[Assert\All([new Assert\Type(ProjectTableColumns::class)])] + public array $projectsDefaultColumns = [ProjectTableColumns::NAME, ProjectTableColumns::DESCRIPTION, + ProjectTableColumns::COMMENT, ProjectTableColumns::EDIT]; + /** @var AssemblyBomTableColumns[] */ #[SettingsParameter(ArrayType::class, label: new TM("settings.behavior.table.assemblies_bom_default_columns"), @@ -148,6 +163,22 @@ class TableSettings return $ret; } + public static function mapProjectsDefaultColumnsEnv(string $columns): array + { + $exploded = explode(',', $columns); + $ret = []; + foreach ($exploded as $column) { + $enum = ProjectTableColumns::tryFrom($column); + if (!$enum) { + throw new \InvalidArgumentException("Invalid column '$column' in TABLE_PROJECTS_DEFAULT_COLUMNS"); + } + + $ret[] = $enum; + } + + return $ret; + } + public static function mapAssemblyBomsDefaultColumnsEnv(string $columns): array { $exploded = explode(',', $columns); diff --git a/templates/components/search.macro.html.twig b/templates/components/search.macro.html.twig index 361b20e4..f469243f 100644 --- a/templates/components/search.macro.html.twig +++ b/templates/components/search.macro.html.twig @@ -1,86 +1,230 @@ -{% macro settings_drodown(show_label_instead_icon = true) %} +{% macro settings_drodown(show_label_instead_icon = true, searchFilter = null, isSearchList = false) %} -