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(
+ ' ',
+ '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) %}
-
+
{% if show_label_instead_icon %}{% trans %}search.options.label{% endtrans %}{% else %} {% endif %}