diff --git a/assets/controllers/elements/attachment_autocomplete_controller.js b/assets/controllers/elements/attachment_autocomplete_controller.js index 0175b284..94b01136 100644 --- a/assets/controllers/elements/attachment_autocomplete_controller.js +++ b/assets/controllers/elements/attachment_autocomplete_controller.js @@ -34,6 +34,11 @@ export default class extends Controller { connect() { + let dropdownParent = "body"; + if (this.element.closest('.modal')) { + dropdownParent = null + } + let settings = { persistent: false, create: true, @@ -42,7 +47,7 @@ export default class extends Controller { selectOnTab: true, //This a an ugly solution to disable the delimiter parsing of the TomSelect plugin delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING', - dropdownParent: 'body', + dropdownParent: dropdownParent, render: { item: (data, escape) => { return '' + escape(data.label) + ''; diff --git a/assets/controllers/elements/part_select_controller.js b/assets/controllers/elements/part_select_controller.js index 0658f4b4..8a4e19b8 100644 --- a/assets/controllers/elements/part_select_controller.js +++ b/assets/controllers/elements/part_select_controller.js @@ -10,13 +10,19 @@ export default class extends Controller { connect() { + //Check if tomselect is inside an modal and do not attach the dropdown to body in that case (as it breaks the modal) + let dropdownParent = "body"; + if (this.element.closest('.modal')) { + dropdownParent = null + } + let settings = { allowEmptyOption: true, plugins: ['dropdown_input'], searchField: ["name", "description", "category", "footprint"], valueField: "id", labelField: "name", - dropdownParent: 'body', + dropdownParent: dropdownParent, preload: "focus", render: { item: (data, escape) => { diff --git a/assets/controllers/elements/select_controller.js b/assets/controllers/elements/select_controller.js index f933731a..d70e588c 100644 --- a/assets/controllers/elements/select_controller.js +++ b/assets/controllers/elements/select_controller.js @@ -38,13 +38,17 @@ export default class extends Controller { this._emptyMessage = this.element.getAttribute('title'); } + let dropdownParent = "body"; + if (this.element.closest('.modal')) { + dropdownParent = null + } let settings = { plugins: ["clear_button"], allowEmptyOption: true, selectOnTab: true, maxOptions: null, - dropdownParent: 'body', + dropdownParent: dropdownParent, render: { item: this.renderItem.bind(this), diff --git a/assets/controllers/elements/select_multiple_controller.js b/assets/controllers/elements/select_multiple_controller.js index daa6b0a1..17e85fae 100644 --- a/assets/controllers/elements/select_multiple_controller.js +++ b/assets/controllers/elements/select_multiple_controller.js @@ -26,10 +26,15 @@ export default class extends Controller { _tomSelect; connect() { + let dropdownParent = "body"; + if (this.element.closest('.modal')) { + dropdownParent = null + } + this._tomSelect = new TomSelect(this.element, { maxItems: 1000, allowEmptyOption: true, - dropdownParent: 'body', + dropdownParent: dropdownParent, plugins: ['remove_button'], }); } diff --git a/assets/controllers/elements/static_file_autocomplete_controller.js b/assets/controllers/elements/static_file_autocomplete_controller.js index 0421a26d..9703c618 100644 --- a/assets/controllers/elements/static_file_autocomplete_controller.js +++ b/assets/controllers/elements/static_file_autocomplete_controller.js @@ -40,6 +40,11 @@ export default class extends Controller { connect() { + let dropdownParent = "body"; + if (this.element.closest('.modal')) { + dropdownParent = null + } + let settings = { persistent: false, create: true, @@ -50,7 +55,7 @@ export default class extends Controller { valueField: 'text', searchField: 'text', orderField: 'text', - dropdownParent: 'body', + dropdownParent: dropdownParent, //This a an ugly solution to disable the delimiter parsing of the TomSelect plugin delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING', diff --git a/assets/controllers/elements/structural_entity_select_controller.js b/assets/controllers/elements/structural_entity_select_controller.js index 5c6f9490..4b220d5b 100644 --- a/assets/controllers/elements/structural_entity_select_controller.js +++ b/assets/controllers/elements/structural_entity_select_controller.js @@ -40,7 +40,10 @@ export default class extends Controller { const allowAdd = this.element.getAttribute("data-allow-add") === "true"; const addHint = this.element.getAttribute("data-add-hint") ?? ""; - + let dropdownParent = "body"; + if (this.element.closest('.modal')) { + dropdownParent = null + } let settings = { @@ -54,7 +57,7 @@ export default class extends Controller { maxItems: 1, delimiter: "$$VERY_LONG_DELIMITER_THAT_SHOULD_NEVER_APPEAR$$", splitOn: null, - dropdownParent: 'body', + dropdownParent: dropdownParent, searchField: [ {field: "text", weight : 2}, diff --git a/assets/controllers/elements/tagsinput_controller.js b/assets/controllers/elements/tagsinput_controller.js index 53bf7608..14725227 100644 --- a/assets/controllers/elements/tagsinput_controller.js +++ b/assets/controllers/elements/tagsinput_controller.js @@ -33,6 +33,11 @@ export default class extends Controller { _tomSelect; connect() { + let dropdownParent = "body"; + if (this.element.closest('.modal')) { + dropdownParent = null + } + let settings = { plugins: { remove_button:{}, @@ -43,7 +48,7 @@ export default class extends Controller { selectOnTab: true, createOnBlur: true, create: true, - dropdownParent: 'body', + dropdownParent: dropdownParent, }; if(this.element.dataset.autocomplete) { diff --git a/src/Controller/PartListsController.php b/src/Controller/PartListsController.php index 8ea218f4..808b0c5d 100644 --- a/src/Controller/PartListsController.php +++ b/src/Controller/PartListsController.php @@ -36,6 +36,7 @@ use App\Exceptions\InvalidRegexException; use App\Form\Filters\PartFilterType; use App\Services\Parts\PartsTableActionHandler; use App\Services\Trees\NodesListBuilder; +use App\Settings\BehaviorSettings\SidebarSettings; use App\Settings\BehaviorSettings\TableSettings; use Doctrine\DBAL\Exception\DriverException; use Doctrine\ORM\EntityManagerInterface; @@ -56,11 +57,21 @@ class PartListsController extends AbstractController private readonly NodesListBuilder $nodesListBuilder, private readonly DataTableFactory $dataTableFactory, private readonly TranslatorInterface $translator, - private readonly TableSettings $tableSettings + private readonly TableSettings $tableSettings, + private readonly SidebarSettings $sidebarSettings, ) { } + /** + * Gets the filter operator to use by default (INCLUDING_CHILDREN or =) + * @return string + */ + private function getFilterOperator(): string + { + return $this->sidebarSettings->dataStructureNodesTableIncludeChildren ? 'INCLUDING_CHILDREN' : '='; + } + #[Route(path: '/table/action', name: 'table_action', methods: ['POST'])] public function tableAction(Request $request, PartsTableActionHandler $actionHandler): Response { @@ -203,7 +214,7 @@ class PartListsController extends AbstractController return $this->showListWithFilter($request, 'parts/lists/category_list.html.twig', function (PartFilter $filter) use ($category) { - $filter->category->setOperator('INCLUDING_CHILDREN')->setValue($category); + $filter->category->setOperator($this->getFilterOperator())->setValue($category); }, function (FormInterface $filterForm) { $this->disableFormFieldAfterCreation($filterForm->get('category')->get('value')); }, [ @@ -221,7 +232,7 @@ class PartListsController extends AbstractController return $this->showListWithFilter($request, 'parts/lists/footprint_list.html.twig', function (PartFilter $filter) use ($footprint) { - $filter->footprint->setOperator('INCLUDING_CHILDREN')->setValue($footprint); + $filter->footprint->setOperator($this->getFilterOperator())->setValue($footprint); }, function (FormInterface $filterForm) { $this->disableFormFieldAfterCreation($filterForm->get('footprint')->get('value')); }, [ @@ -239,7 +250,7 @@ class PartListsController extends AbstractController return $this->showListWithFilter($request, 'parts/lists/manufacturer_list.html.twig', function (PartFilter $filter) use ($manufacturer) { - $filter->manufacturer->setOperator('INCLUDING_CHILDREN')->setValue($manufacturer); + $filter->manufacturer->setOperator($this->getFilterOperator())->setValue($manufacturer); }, function (FormInterface $filterForm) { $this->disableFormFieldAfterCreation($filterForm->get('manufacturer')->get('value')); }, [ @@ -257,7 +268,7 @@ class PartListsController extends AbstractController return $this->showListWithFilter($request, 'parts/lists/store_location_list.html.twig', function (PartFilter $filter) use ($storelocation) { - $filter->storelocation->setOperator('INCLUDING_CHILDREN')->setValue($storelocation); + $filter->storelocation->setOperator($this->getFilterOperator())->setValue($storelocation); }, function (FormInterface $filterForm) { $this->disableFormFieldAfterCreation($filterForm->get('storelocation')->get('value')); }, [ @@ -275,7 +286,7 @@ class PartListsController extends AbstractController return $this->showListWithFilter($request, 'parts/lists/supplier_list.html.twig', function (PartFilter $filter) use ($supplier) { - $filter->supplier->setOperator('INCLUDING_CHILDREN')->setValue($supplier); + $filter->supplier->setOperator($this->getFilterOperator())->setValue($supplier); }, function (FormInterface $filterForm) { $this->disableFormFieldAfterCreation($filterForm->get('supplier')->get('value')); }, [ diff --git a/src/DataTables/Filters/Constraints/TextConstraint.php b/src/DataTables/Filters/Constraints/TextConstraint.php index 31b12a5e..c6a6fe19 100644 --- a/src/DataTables/Filters/Constraints/TextConstraint.php +++ b/src/DataTables/Filters/Constraints/TextConstraint.php @@ -96,14 +96,15 @@ class TextConstraint extends AbstractConstraint //The CONTAINS, LIKE, STARTS and ENDS operators use the LIKE operator, but we have to build the value string differently $like_value = null; + $escaped_value = str_replace(['%', '_'], ['\%', '\_'], $this->value); if ($this->operator === 'LIKE') { - $like_value = $this->value; + $like_value = $this->value; //Here we do not escape anything, as the user may provide % and _ wildcards } elseif ($this->operator === 'STARTS') { - $like_value = $this->value . '%'; + $like_value = $escaped_value . '%'; } elseif ($this->operator === 'ENDS') { - $like_value = '%' . $this->value; + $like_value = '%' . $escaped_value; } elseif ($this->operator === 'CONTAINS') { - $like_value = '%' . $this->value . '%'; + $like_value = '%' . $escaped_value . '%'; } if ($like_value !== null) { diff --git a/src/DataTables/Filters/PartSearchFilter.php b/src/DataTables/Filters/PartSearchFilter.php index 60832b26..aa8c20f4 100644 --- a/src/DataTables/Filters/PartSearchFilter.php +++ b/src/DataTables/Filters/PartSearchFilter.php @@ -144,6 +144,8 @@ class PartSearchFilter implements FilterInterface if ($this->regex) { $queryBuilder->setParameter('search_query', $this->keyword); } else { + //Escape % and _ characters in the keyword + $this->keyword = str_replace(['%', '_'], ['\%', '\_'], $this->keyword); $queryBuilder->setParameter('search_query', '%' . $this->keyword . '%'); } } diff --git a/src/Doctrine/Functions/ILike.php b/src/Doctrine/Functions/ILike.php index 5246220a..ff2d2163 100644 --- a/src/Doctrine/Functions/ILike.php +++ b/src/Doctrine/Functions/ILike.php @@ -56,7 +56,6 @@ class ILike extends FunctionNode { $platform = $sqlWalker->getConnection()->getDatabasePlatform(); - // if ($platform instanceof AbstractMySQLPlatform || $platform instanceof SQLitePlatform) { $operator = 'LIKE'; } elseif ($platform instanceof PostgreSQLPlatform) { @@ -66,6 +65,12 @@ class ILike extends FunctionNode throw new \RuntimeException('Platform ' . gettype($platform) . ' does not support case insensitive like expressions.'); } - return '(' . $this->value->dispatch($sqlWalker) . ' ' . $operator . ' ' . $this->expr->dispatch($sqlWalker) . ')'; + $escape = ""; + if ($platform instanceof SQLitePlatform) { + //SQLite needs ESCAPE explicitly defined backslash as escape character + $escape = " ESCAPE '\\'"; + } + + return '(' . $this->value->dispatch($sqlWalker) . ' ' . $operator . ' ' . $this->expr->dispatch($sqlWalker) . $escape . ')'; } -} \ No newline at end of file +} diff --git a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php index d09f1d05..a655a0df 100644 --- a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php +++ b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php @@ -221,7 +221,7 @@ final class DTOtoEntityConverter $attachment = $this->convertFile($image, $image_type); $attachments_grouped[$attachment->getName()][] = $attachment; - if (count($attachments_grouped[$attachment->getName()] ?? []) > 1) { + if (count($attachments_grouped[$attachment->getName()]) > 1) { $attachment->setName($attachment->getName() . ' (' . (count($attachments_grouped[$attachment->getName()]) + 1) . ')'); } @@ -236,7 +236,7 @@ final class DTOtoEntityConverter $attachment = $this->convertFile($datasheet, $datasheet_type); $attachments_grouped[$attachment->getName()][] = $attachment; - if (count($attachments_grouped[$attachment->getName()] ?? []) > 1) { + if (count($attachments_grouped[$attachment->getName()]) > 1) { $attachment->setName($attachment->getName() . ' (' . (count($attachments_grouped[$attachment->getName()])) . ')'); } @@ -357,4 +357,4 @@ final class DTOtoEntityConverter return $tmp; } -} \ No newline at end of file +} diff --git a/src/Services/ProjectSystem/ProjectBuildHelper.php b/src/Services/ProjectSystem/ProjectBuildHelper.php index 269c7e4c..a541c29d 100644 --- a/src/Services/ProjectSystem/ProjectBuildHelper.php +++ b/src/Services/ProjectSystem/ProjectBuildHelper.php @@ -31,9 +31,9 @@ use App\Services\Parts\PartLotWithdrawAddHelper; /** * @see \App\Tests\Services\ProjectSystem\ProjectBuildHelperTest */ -class ProjectBuildHelper +final readonly class ProjectBuildHelper { - public function __construct(private readonly PartLotWithdrawAddHelper $withdraw_add_helper) + public function __construct(private PartLotWithdrawAddHelper $withdraw_add_helper) { } @@ -63,20 +63,37 @@ class ProjectBuildHelper */ public function getMaximumBuildableCount(Project $project): int { + $bom_entries = $project->getBomEntries(); + if ($bom_entries->isEmpty()) { + return 0; + } $maximum_buildable_count = PHP_INT_MAX; - foreach ($project->getBomEntries() as $bom_entry) { + foreach ($bom_entries as $bom_entry) { //Skip BOM entries without a part (as we can not determine that) if (!$bom_entry->isPartBomEntry()) { continue; } - //The maximum buildable count for the whole project is the minimum of all BOM entries $maximum_buildable_count = min($maximum_buildable_count, $this->getMaximumBuildableCountForBOMEntry($bom_entry)); } - return $maximum_buildable_count; } + /** + * Returns the maximum buildable amount of the given project as string, based on the stock of the used parts in the BOM. + * If the maximum buildable count is infinite, the string '∞' is returned. + * @param Project $project + * @return string + */ + public function getMaximumBuildableCountAsString(Project $project): string + { + $max_count = $this->getMaximumBuildableCount($project); + if ($max_count === PHP_INT_MAX) { + return '∞'; + } + return (string) $max_count; + } + /** * Checks if the given project can be built with the current stock. * This means that the maximum buildable count is greater or equal than the requested $number_of_projects diff --git a/src/Settings/BehaviorSettings/SidebarSettings.php b/src/Settings/BehaviorSettings/SidebarSettings.php index 1266fa47..a1ff6985 100644 --- a/src/Settings/BehaviorSettings/SidebarSettings.php +++ b/src/Settings/BehaviorSettings/SidebarSettings.php @@ -73,4 +73,11 @@ class SidebarSettings */ #[SettingsParameter(label: new TM("settings.behavior.sidebar.rootNodeRedirectsToNewEntity"))] public bool $rootNodeRedirectsToNewEntity = false; -} \ No newline at end of file + + /** + * @var bool Whether to include child nodes in the data structure nodes table, or only show the selected node's parts. + */ + #[SettingsParameter(label: new TM("settings.behavior.sidebar.data_structure_nodes_table_include_children"), + description: new TM("settings.behavior.sidebar.data_structure_nodes_table_include_children.help"))] + public bool $dataStructureNodesTableIncludeChildren = true; +} diff --git a/src/Twig/FormatExtension.php b/src/Twig/FormatExtension.php index 76628ccd..46313aaf 100644 --- a/src/Twig/FormatExtension.php +++ b/src/Twig/FormatExtension.php @@ -82,7 +82,7 @@ final class FormatExtension extends AbstractExtension public function formatBytes(int $bytes, int $precision = 2): string { $size = ['B','kB','MB','GB','TB','PB','EB','ZB','YB']; - $factor = floor((strlen((string) $bytes) - 1) / 3); + $factor = (int) floor((strlen((string) $bytes) - 1) / 3); //We use the real (10 based) SI prefix here return sprintf("%.{$precision}f", $bytes / (1000 ** $factor)) . ' ' . @$size[$factor]; } diff --git a/templates/projects/build/build.html.twig b/templates/projects/build/build.html.twig index 783c3794..a5ff3d9a 100644 --- a/templates/projects/build/build.html.twig +++ b/templates/projects/build/build.html.twig @@ -8,7 +8,8 @@ {% endblock %} {% block card_content %} - {% set can_build = buildHelper.projectBuildable(project, number_of_builds) %} + {% set bom_empty = project.bomEntries | length == 0 %} + {% set can_build = not bom_empty and buildHelper.projectBuildable(project, number_of_builds) %} {% import "components/projects.macro.html.twig" as project_macros %} {% if project.status is not empty and project.status != "in_production" %} @@ -17,8 +18,10 @@ {% endif %} -