From ef389dcc15a1562335acada1d2ce8cddead80487 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 15 Aug 2022 00:55:26 +0200 Subject: [PATCH 01/64] Use own-written TriState checkbox library, which makes form submissions a lot easier. --- assets/js/lib/TristateCheckbox.js | 207 +++++++++++++++++++++++++++++ assets/js/lib/jquery.tristate.js | 213 ------------------------------ assets/js/tristate_checkboxes.js | 48 +++---- 3 files changed, 225 insertions(+), 243 deletions(-) create mode 100644 assets/js/lib/TristateCheckbox.js delete mode 100644 assets/js/lib/jquery.tristate.js diff --git a/assets/js/lib/TristateCheckbox.js b/assets/js/lib/TristateCheckbox.js new file mode 100644 index 00000000..c8ceed0e --- /dev/null +++ b/assets/js/lib/TristateCheckbox.js @@ -0,0 +1,207 @@ +const DEFAULT_OPTIONS = { + true: "true", + false: "false", + null: "indeterminate", +}; + +/** + * A simple tristate checkbox + */ +export default class TristateCheckbox { + + static instances = new Map(); + + /** + * + * @type {null|boolean} + * @private + */ + _state = false; + + /** + * The element representing the checkbox. + * @type {HTMLInputElement} + * @private + */ + _element = null; + + /** + * The hidden input element representing the value of the checkbox + * @type {HTMLInputElement} + * @private + */ + _hiddenInput = null; + + /** + * The values of the checkbox. + * @type {{null: string, true: string, false: string}} + * @private + */ + _options = DEFAULT_OPTIONS; + + /** + * Retrieve the instance of the TristateCheckbox for the given element if already existing, otherwise a new one is created. + * @param element + * @param options + * @return {any} + */ + static getInstance(element, options = {}) + { + if(!TristateCheckbox.instances.has(element)) { + TristateCheckbox.instances.set(element, new TristateCheckbox(element, options)); + } + + return TristateCheckbox.instances.get(element); + } + + /** + * @param {HTMLElement} element + */ + constructor(element, options = {}) + { + if(!element instanceof HTMLInputElement || !(element.tagName === 'INPUT' && element.type === 'checkbox')) { + throw new Error("The given element is not an input checkbox"); + } + + //Apply options + this._options = Object.assign(this._options, options); + + this._element = element; + + //Set the state of our element to the value of the passed input value + this._parseInitialState(); + + //Create a hidden input field to store the value of the checkbox, because this will be always be submitted in the form + this._hiddenInput = document.createElement('input'); + this._hiddenInput.type = 'hidden'; + this._hiddenInput.name = this._element.name; + this._hiddenInput.value = this._element.value; + + //Insert the hidden input field after the checkbox and remove the checkbox from form submission (by removing the name property) + element.after(this._hiddenInput); + this._element.removeAttribute('name'); + + //Do a refresh to set the correct styling of the checkbox + this._refresh(); + + this._element.addEventListener('click', this.click.bind(this)); + } + + /** + * Parse the attributes of the checkbox and set the correct state. + * @private + */ + _parseInitialState() + { + if(this._element.hasAttribute('value')) { + this._state = this._stringToState(this._element.getAttribute('value')); + return; + } + + if(this._element.checked) { + this._state = true; + return; + } + + if(this._element.indeterminate) { + this._state = null; + return; + } + + this._state = false; + } + + _refresh() + { + this._element.indeterminate = this._state === null; + this._element.checked = this._state === true; + //Set the value field of the checkbox and the hidden input to correct value + this._element.value = this._stateToString(this._state); + this._hiddenInput.value = this._stateToString(this._state); + } + + + /** + * Returns the current state of the checkbox. True if checked, false if unchecked, null if indeterminate. + * @return {boolean|null} + */ + get state() { + return this._state; + } + + /** + * Sets the state of the checkbox. True if checked, false if unchecked, null if indeterminate. + * @param state + */ + set state(state) { + this._state = state; + this._refresh(); + } + + /** + * Returns the current state of the checkbox as string, according to the options. + * @return {string} + */ + get stateString() { + return this._stateToString(this._state); + } + + set stateString(string) { + this.state = this._stringToState(string); + this._refresh(); + } + + /** + * @param {boolean|null} state + * @return string + * @private + */ + _stateToString(state) + { + if (this.state === null) { + return this._options.null; + } else if (this.state === true) { + return this._options.true; + } else if (this.state === false) { + return this._options.false; + } + + throw new Error("Invalid state " + state); + } + + /** + * Converts a string to a state according to the options. + * @param string + * @param throwError + * @return {null|boolean} + * @private + */ + _stringToState(string, throwError = true) + { + if (string === this._options.true) { + return true; + } else if (string === this._options.false) { + return false; + } else if (string === this._options.null) { + return null; + } + + if(throwError) { + throw new Error("Invalid state string " + string); + } else { + return null; + } + } + + click() + { + switch (this._state) { + case true: this._state = false; break; + case false: this._state = null; break; + default: this._state = true; break; + } + + this._refresh(); + } + +} \ No newline at end of file diff --git a/assets/js/lib/jquery.tristate.js b/assets/js/lib/jquery.tristate.js deleted file mode 100644 index c1a85d29..00000000 --- a/assets/js/lib/jquery.tristate.js +++ /dev/null @@ -1,213 +0,0 @@ -/*jslint devel: true, bitwise: true, regexp: true, browser: true, confusion: true, unparam: true, eqeq: true, white: true, nomen: true, plusplus: true, maxerr: 50, indent: 4 */ -/*globals jQuery */ - -/*! - * Tristate v1.2.1 - * - * Copyright (c) 2013-2017 Martijn W. van der Lee - * Licensed under the MIT. - */ -/* Based on work by: - * Chris Coyier (http://css-tricks.com/indeterminate-checkboxes/) - * - * Tristate checkbox with support features - * pseudo selectors - * val() overwrite - */ - -;(function($, undefined) { - 'use strict'; - - var pluginName = 'tristate', - defaults = { - 'change': undefined, - 'checked': undefined, - 'indeterminate': undefined, - 'init': undefined, - 'reverse': false, - 'state': undefined, - 'unchecked': undefined, - 'value': undefined // one-way only! - }, - valFunction = $.fn.val; - - function Plugin(element, options) { - if($(element).is(':checkbox')) { - this.element = $(element); - this.settings = $.extend( {}, defaults, options ); - this._create(); - } - } - - $.extend(Plugin.prototype, { - _create: function() { - var that = this, - state; - - // Fix for #1 - if (window.navigator.userAgent.indexOf('Trident') >= 0) { - this.element.click(function(e) { - that._change.call(that, e); - that.element.closest('form').change(); - }); - } else { - this.element.change(function(e) { - that._change.call(that, e); - }); - } - - this.settings.checked = this.element.attr('checkedvalue') || this.settings.checked; - this.settings.unchecked = this.element.attr('uncheckedvalue') || this.settings.unchecked; - this.settings.indeterminate = this.element.attr('indeterminatevalue') || this.settings.indeterminate; - - // Initially, set state based on option state or attributes - if (typeof this.settings.state === 'undefined') { - this.settings.state = typeof this.element.attr('indeterminate') !== 'undefined'? null : this.element.is(':checked'); - } - - // If value specified, overwrite with value - if (typeof this.settings.value !== 'undefined') { - state = this._parseValue(this.settings.value); - if (typeof state !== 'undefined') { - this.settings.state = state; - } - } - - this._refresh(this.settings.init); - - return this; - }, - - _change: function(e) { - if (e.isTrigger || !e.hasOwnProperty('which')) { - e.preventDefault(); - } - - switch (this.settings.state) { - case true: this.settings.state = (this.settings.reverse ? false : null); break; - case false: this.settings.state = (this.settings.reverse ? null : true); break; - default: this.settings.state = (this.settings.reverse ? true : false); break; - } - - this._refresh(this.settings.change); - }, - - _refresh: function(callback) { - var value = this.value(); - - this.element.data("vanderlee." + pluginName, value); - - this.element[this.settings.state === null ? 'attr' : 'removeAttr']('indeterminate', 'indeterminate'); - this.element.prop('indeterminate', this.settings.state === null); - this.element.get(0).indeterminate = this.settings.state === null; - - this.element[this.settings.state === true ? 'attr' : 'removeAttr']('checked', true); - this.element.prop('checked', this.settings.state === true); - - if ($.isFunction(callback)) { - callback.call(this.element, this.settings.state, this.value()); - } - }, - - state: function(value) { - if (typeof value === 'undefined') { - return this.settings.state; - } else if (value === true || value === false || value === null) { - this.settings.state = value; - - this._refresh(this.settings.change); - } - return this; - }, - - _parseValue: function(value) { - if (value === this.settings.checked) { - return true; - } else if (value === this.settings.unchecked) { - return false; - } else if (value === this.settings.indeterminate) { - return null; - } - }, - - value: function(value) { - if (typeof value === 'undefined') { - var value; - switch (this.settings.state) { - case true: - value = this.settings.checked; - break; - - case false: - value = this.settings.unchecked; - break; - - case null: - value = this.settings.indeterminate; - break; - } - return typeof value === 'undefined'? this.element.attr('value') : value; - } else { - var state = this._parseValue(value); - if (typeof state !== 'undefined') { - this.settings.state = state; - this._refresh(this.settings.change); - } - } - } - }); - - $.fn[pluginName] = function (options, value) { - var result = this; - - this.each(function() { - if (!$.data(this, "plugin.vanderlee." + pluginName)) { - $.data(this, "plugin.vanderlee." + pluginName, new Plugin(this, options)); - } else if (typeof options === 'string') { - if (typeof value === 'undefined') { - result = $(this).data("plugin.vanderlee." + pluginName)[options](); - return false; - } else { - $(this).data("plugin.vanderlee." + pluginName)[options](value); - } - } - }); - - return result; - }; - - // Overwrite fn.val - $.fn.val = function(value) { - var data = this.data("vanderlee." + pluginName); - if (typeof data === 'undefined') { - if (typeof value === 'undefined') { - return valFunction.call(this); - } else { - return valFunction.call(this, value); - } - } else { - if (typeof value === 'undefined') { - return data; - } else { - this.data("vanderlee." + pluginName, value); - return this; - } - } - }; - - // :indeterminate pseudo selector - $.expr.filters.indeterminate = function(element) { - var $element = $(element); - return typeof $element.data("vanderlee." + pluginName) !== 'undefined' && $element.prop('indeterminate'); - }; - - // :determinate pseudo selector - $.expr.filters.determinate = function(element) { - return !($.expr.filters.indeterminate(element)); - }; - - // :tristate selector - $.expr.filters.tristate = function(element) { - return typeof $(element).data("vanderlee." + pluginName) !== 'undefined'; - }; -})(jQuery); diff --git a/assets/js/tristate_checkboxes.js b/assets/js/tristate_checkboxes.js index 5ec16b19..c59a3e22 100644 --- a/assets/js/tristate_checkboxes.js +++ b/assets/js/tristate_checkboxes.js @@ -1,49 +1,37 @@ 'use strict'; -import "./lib/jquery.tristate" +import TristateCheckbox from "./lib/TristateCheckbox"; class TristateHelper { constructor() { this.registerTriStateCheckboxes(); - this.registerSubmitHandler(); - } - - registerSubmitHandler() { - document.addEventListener("turbo:submit-start", (e) => { - var form = e.detail.formSubmission.formElement; - var formData = e.detail.formSubmission.formData; - - var $tristate_checkboxes = $('.tristate:checkbox', form); - - //Iterate over each tristate checkbox in the form and set formData to the correct value - $tristate_checkboxes.each(function() { - var $checkbox = $(this); - var state = $checkbox.tristate('state'); - - formData.set($checkbox.attr('name'), state); - }); - }); } registerTriStateCheckboxes() { //Initialize tristate checkboxes and if needed the multicheckbox functionality const listener = () => { - $(".tristate").tristate( { - checked: "true", - unchecked: "false", - indeterminate: "indeterminate", + + const tristates = document.querySelectorAll("input.tristate"); + + tristates.forEach(tristate => { + TristateCheckbox.getInstance(tristate); }); - $('.permission_multicheckbox:checkbox').change(function() { - //Find the other checkboxes in this row, and change their value - var $row = $(this).parents('tr'); - //@ts-ignore - var new_state = $(this).tristate('state'); + //Register multi checkboxes in permission tables + const multicheckboxes = document.querySelectorAll("input.permission_multicheckbox"); + multicheckboxes.forEach(multicheckbox => { + multicheckbox.addEventListener("change", (event) => { + const newValue = TristateCheckbox.getInstance(event.target).state; + const row = event.target.closest("tr"); - //@ts-ignore - $('.tristate:checkbox', $row).tristate('state', new_state); + //Find all tristate checkboxes in the same row and set their state to the new value + const tristateCheckboxes = row.querySelectorAll("input.tristate"); + tristateCheckboxes.forEach(tristateCheckbox => { + TristateCheckbox.getInstance(tristateCheckbox).state = newValue; + }); + }); }); } From f9d945c4c78c91a858cd2adb021701f545eb0477 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 15 Aug 2022 01:01:27 +0200 Subject: [PATCH 02/64] Added the very basic foundations for a filter system --- config/packages/twig.yaml | 2 +- src/Controller/PartListsController.php | 20 +++- .../Constraints/AbstractSimpleConstraint.php | 28 +++++ .../Filters/Constraints/BooleanConstraint.php | 48 ++++++++ .../Filters/Constraints/FilterTrait.php | 33 ++++++ .../Filters/Constraints/NumberConstraint.php | 110 ++++++++++++++++++ src/DataTables/Filters/FilterInterface.php | 16 +++ src/DataTables/Filters/PartFilter.php | 54 +++++++++ src/DataTables/PartsDataTable.php | 9 ++ .../Constraints/BooleanConstraintType.php | 28 +++++ .../Constraints/NumberConstraintType.php | 57 +++++++++ src/Form/Filters/PartFilterType.php | 43 +++++++ templates/Form/FilterTypesLayout.html.twig | 7 ++ templates/Parts/lists/_filter.html.twig | 8 ++ templates/Parts/lists/all_list.html.twig | 2 + 15 files changed, 460 insertions(+), 5 deletions(-) create mode 100644 src/DataTables/Filters/Constraints/AbstractSimpleConstraint.php create mode 100644 src/DataTables/Filters/Constraints/BooleanConstraint.php create mode 100644 src/DataTables/Filters/Constraints/FilterTrait.php create mode 100644 src/DataTables/Filters/Constraints/NumberConstraint.php create mode 100644 src/DataTables/Filters/FilterInterface.php create mode 100644 src/DataTables/Filters/PartFilter.php create mode 100644 src/Form/Filters/Constraints/BooleanConstraintType.php create mode 100644 src/Form/Filters/Constraints/NumberConstraintType.php create mode 100644 src/Form/Filters/PartFilterType.php create mode 100644 templates/Form/FilterTypesLayout.html.twig create mode 100644 templates/Parts/lists/_filter.html.twig diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index 5fdd5bec..0c4492af 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -1,6 +1,6 @@ twig: default_path: '%kernel.project_dir%/templates' - form_themes: ['bootstrap_5_horizontal_layout.html.twig', 'Form/extendedBootstrap4_layout.html.twig', 'Form/permissionLayout.html.twig' ] + form_themes: ['bootstrap_5_horizontal_layout.html.twig', 'Form/extendedBootstrap4_layout.html.twig', 'Form/permissionLayout.html.twig', 'Form/FilterTypesLayout.html.twig'] paths: '%kernel.project_dir%/assets/css': css diff --git a/src/Controller/PartListsController.php b/src/Controller/PartListsController.php index 32719591..070db537 100644 --- a/src/Controller/PartListsController.php +++ b/src/Controller/PartListsController.php @@ -42,12 +42,14 @@ declare(strict_types=1); namespace App\Controller; +use App\DataTables\Filters\PartFilter; use App\DataTables\PartsDataTable; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; use App\Entity\Parts\Storelocation; use App\Entity\Parts\Supplier; +use App\Form\Filters\PartFilterType; use App\Services\Parts\PartsTableActionHandler; use Doctrine\ORM\EntityManagerInterface; use Omines\DataTablesBundle\DataTableFactory; @@ -245,11 +247,11 @@ class PartListsController extends AbstractController 'regex' => $request->query->getBoolean('regex'), ]; + $table = $dataTable->createFromType(PartsDataTable::class, [ 'search' => $search, 'search_options' => $search_options, - ]) - ->handleRequest($request); + ])->handleRequest($request); if ($table->isCallback()) { return $table->getResponse(); @@ -258,6 +260,7 @@ class PartListsController extends AbstractController return $this->render('Parts/lists/search_list.html.twig', [ 'datatable' => $table, 'keyword' => $search, + 'filterForm' => $filterForm->createView() ]); } @@ -268,13 +271,22 @@ class PartListsController extends AbstractController */ public function showAll(Request $request, DataTableFactory $dataTable) { - $table = $dataTable->createFromType(PartsDataTable::class) + + $formRequest = clone $request; + $formRequest->setMethod('GET'); + $filter = new PartFilter(); + $filterForm = $this->createForm(PartFilterType::class, $filter, ['method' => 'GET']); + $filterForm->handleRequest($formRequest); + + $table = $dataTable->createFromType(PartsDataTable::class, [ + 'filter' => $filter, + ]) ->handleRequest($request); if ($table->isCallback()) { return $table->getResponse(); } - return $this->render('Parts/lists/all_list.html.twig', ['datatable' => $table]); + return $this->render('Parts/lists/all_list.html.twig', ['datatable' => $table, 'filterForm' => $filterForm->createView()]); } } diff --git a/src/DataTables/Filters/Constraints/AbstractSimpleConstraint.php b/src/DataTables/Filters/Constraints/AbstractSimpleConstraint.php new file mode 100644 index 00000000..e45f1909 --- /dev/null +++ b/src/DataTables/Filters/Constraints/AbstractSimpleConstraint.php @@ -0,0 +1,28 @@ +property = $property; + $this->identifier = $identifier ?? $this->generateParameterIdentifier($property); + } +} \ No newline at end of file diff --git a/src/DataTables/Filters/Constraints/BooleanConstraint.php b/src/DataTables/Filters/Constraints/BooleanConstraint.php new file mode 100644 index 00000000..4051ad49 --- /dev/null +++ b/src/DataTables/Filters/Constraints/BooleanConstraint.php @@ -0,0 +1,48 @@ +value; + } + + /** + * Sets the value of this constraint. Null means "don't filter", true means "filter for true", false means "filter for false". + * @param bool|null $value + */ + public function setValue(?bool $value): void + { + $this->value = $value; + } + + + + public function apply(QueryBuilder $queryBuilder): void + { + //Do not apply a filter if value is null (filter is set to ignore) + if($this->value === null) { + return; + } + + $this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, '=', $this->value); + } +} \ No newline at end of file diff --git a/src/DataTables/Filters/Constraints/FilterTrait.php b/src/DataTables/Filters/Constraints/FilterTrait.php new file mode 100644 index 00000000..3823ef89 --- /dev/null +++ b/src/DataTables/Filters/Constraints/FilterTrait.php @@ -0,0 +1,33 @@ +andWhere(sprintf("%s %s :%s", $property, $comparison_operator, $parameterIdentifier)); + $queryBuilder->setParameter($parameterIdentifier, $value); + } +} \ No newline at end of file diff --git a/src/DataTables/Filters/Constraints/NumberConstraint.php b/src/DataTables/Filters/Constraints/NumberConstraint.php new file mode 100644 index 00000000..410b6f63 --- /dev/null +++ b/src/DataTables/Filters/Constraints/NumberConstraint.php @@ -0,0 +1,110 @@ +', '<=', '>=', 'BETWEEN']; + + + /** + * The value1 used for comparison (this is the main one used for all mono-value comparisons) + * @var float|null + */ + protected $value1; + + /** + * The second value used when operator is RANGE; this is the upper bound of the range + * @var float|null + */ + protected $value2; + + /** + * @var string The operator to use + */ + protected $operator; + + /** + * @return float|mixed|null + */ + public function getValue1() + { + return $this->value1; + } + + /** + * @param float|mixed|null $value1 + */ + public function setValue1($value1): void + { + $this->value1 = $value1; + } + + /** + * @return float|mixed|null + */ + public function getValue2() + { + return $this->value2; + } + + /** + * @param float|mixed|null $value2 + */ + public function setValue2($value2): void + { + $this->value2 = $value2; + } + + /** + * @return mixed|string + */ + public function getOperator() + { + return $this->operator; + } + + /** + * @param mixed|string $operator + */ + public function setOperator($operator): void + { + $this->operator = $operator; + } + + + public function __construct(string $property, string $identifier = null, $value1 = null, $operator = '>', $value2 = null) + { + parent::__construct($property, $identifier); + $this->value1 = $value1; + $this->value2 = $value2; + $this->operator = $operator; + } + + public function apply(QueryBuilder $queryBuilder): void + { + //If no value is provided then we do not apply a filter + if ($this->value1 === null) { + return; + } + + //Ensure we have an valid operator + if(!in_array($this->operator, self::ALLOWED_OPERATOR_VALUES, true)) { + throw new \InvalidArgumentException('Invalid operator '. $this->operator . ' provided. Valid operators are '. implode(', ', self::ALLOWED_OPERATOR_VALUES)); + } + + if ($this->operator !== 'BETWEEN') { + $this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, $this->operator, $this->value1); + } else { + if ($this->value2 === null) { + throw new RuntimeException("Cannot use operator BETWEEN without value2!"); + } + + $this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier . '1', '>=', $this->value1); + $this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier . '2', '<=', $this->value2); + } + } +} \ No newline at end of file diff --git a/src/DataTables/Filters/FilterInterface.php b/src/DataTables/Filters/FilterInterface.php new file mode 100644 index 00000000..0a13a4aa --- /dev/null +++ b/src/DataTables/Filters/FilterInterface.php @@ -0,0 +1,16 @@ +favorite; + } + + /** + * @return BooleanConstraint + */ + public function getNeedsReview(): BooleanConstraint + { + return $this->needsReview; + } + + public function getMass(): NumberConstraint + { + return $this->mass; + } + + public function __construct() + { + $this->favorite = new BooleanConstraint('part.favorite'); + $this->needsReview = new BooleanConstraint('part.needs_review'); + $this->mass = new NumberConstraint('part.mass'); + } + + public function apply(QueryBuilder $queryBuilder): void + { + $this->favorite->apply($queryBuilder); + $this->needsReview->apply($queryBuilder); + $this->mass->apply($queryBuilder); + } +} diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php index db81b348..de06a09f 100644 --- a/src/DataTables/PartsDataTable.php +++ b/src/DataTables/PartsDataTable.php @@ -48,6 +48,7 @@ use App\DataTables\Column\LocaleDateTimeColumn; use App\DataTables\Column\MarkdownColumn; use App\DataTables\Column\PartAttachmentsColumn; use App\DataTables\Column\TagsColumn; +use App\DataTables\Filters\PartFilter; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; @@ -108,6 +109,7 @@ final class PartsDataTable implements DataTableTypeInterface 'supplier' => null, 'tag' => null, 'search' => null, + 'filter' => null ]); $optionsResolver->setAllowedTypes('category', ['null', Category::class]); @@ -351,6 +353,13 @@ final class PartsDataTable implements DataTableTypeInterface private function buildCriteria(QueryBuilder $builder, array $options): void { + + if (isset($options['filter']) && $options['filter'] instanceof PartFilter) { + $filter = $options['filter']; + + $filter->apply($builder); + } + if (isset($options['category'])) { $category = $options['category']; $list = $this->treeBuilder->typeToNodesList(Category::class, $category); diff --git a/src/Form/Filters/Constraints/BooleanConstraintType.php b/src/Form/Filters/Constraints/BooleanConstraintType.php new file mode 100644 index 00000000..0ad27372 --- /dev/null +++ b/src/Form/Filters/Constraints/BooleanConstraintType.php @@ -0,0 +1,28 @@ +setDefaults([ + 'compound' => true, + 'data_class' => BooleanConstraint::class, + ]); + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('value', TriStateCheckboxType::class, [ + 'label' => $options['label'], + 'required' => false, + ]); + } +} \ No newline at end of file diff --git a/src/Form/Filters/Constraints/NumberConstraintType.php b/src/Form/Filters/Constraints/NumberConstraintType.php new file mode 100644 index 00000000..2f006ecf --- /dev/null +++ b/src/Form/Filters/Constraints/NumberConstraintType.php @@ -0,0 +1,57 @@ +setDefaults([ + 'compound' => true, + 'data_class' => NumberConstraint::class, + ]); + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $choices = [ + '=' => '=', + '!=' => '!=', + '<' => '<', + '>' => '>', + '<=' => '<=', + '>=' => '>=', + 'BETWEEN' => 'BETWEEN', + ]; + + $builder->add('value1', NumberType::class, [ + 'label' => 'filter.number_constraint.value1', + 'attr' => [ + 'placeholder' => 'filter.number_constraint.value1', + ], + 'required' => false, + 'html5' => true, + ]); + $builder->add('value2', NumberType::class, [ + 'label' => 'filter.number_constraint.value2', + 'attr' => [ + 'placeholder' => 'filter.number_constraint.value2', + ], + 'required' => false, + 'html5' => true, + ]); + $builder->add('operator', ChoiceType::class, [ + 'label' => 'filter.number_constraint.operator', + 'choices' => $choices, + ]); + } +} \ No newline at end of file diff --git a/src/Form/Filters/PartFilterType.php b/src/Form/Filters/PartFilterType.php new file mode 100644 index 00000000..1129e9dd --- /dev/null +++ b/src/Form/Filters/PartFilterType.php @@ -0,0 +1,43 @@ +setDefaults([ + 'compound' => true, + 'data_class' => PartFilter::class, + 'csrf_protection' => false, + ]); + } + + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder->add('favorite', BooleanConstraintType::class, [ + 'label' => 'part.edit.is_favorite' + ]); + + $builder->add('needsReview', BooleanConstraintType::class, [ + 'label' => 'part.edit.needs_review' + ]); + + $builder->add('mass', NumberConstraintType::class, [ + 'label' => 'part.edit.mass' + ]); + + $builder->add('submit', SubmitType::class, [ + 'label' => 'Update', + ]); + } +} \ No newline at end of file diff --git a/templates/Form/FilterTypesLayout.html.twig b/templates/Form/FilterTypesLayout.html.twig new file mode 100644 index 00000000..9db0c3bd --- /dev/null +++ b/templates/Form/FilterTypesLayout.html.twig @@ -0,0 +1,7 @@ +{% block number_constraint_widget %} +
+ {{ form_widget(form.operator) }} + {{ form_widget(form.value1) }} + {{ form_widget(form.value2) }} +
+{% endblock %} \ No newline at end of file diff --git a/templates/Parts/lists/_filter.html.twig b/templates/Parts/lists/_filter.html.twig new file mode 100644 index 00000000..f4510002 --- /dev/null +++ b/templates/Parts/lists/_filter.html.twig @@ -0,0 +1,8 @@ +
+
Filter
+
+ {{ form_start(filterForm) }} + + {{ form_end(filterForm) }} +
+
\ No newline at end of file diff --git a/templates/Parts/lists/all_list.html.twig b/templates/Parts/lists/all_list.html.twig index 04adc60c..9f72d44e 100644 --- a/templates/Parts/lists/all_list.html.twig +++ b/templates/Parts/lists/all_list.html.twig @@ -6,6 +6,8 @@ {% block content %} + {% include "Parts/lists/_filter.html.twig" %} + {% include "Parts/lists/_action_bar.html.twig" with {'url_options': {}} %} {% include "Parts/lists/_parts_list.html.twig" %} From f6239dfd5075bfcbfc9abe00e5c13d7557d6455a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 15 Aug 2022 01:32:09 +0200 Subject: [PATCH 03/64] Improved NumberConstraintType a bit. --- .../Constraints/NumberConstraintType.php | 31 ++++++++++++++++--- src/Form/Filters/PartFilterType.php | 6 ++-- templates/Form/FilterTypesLayout.html.twig | 6 +++- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/Form/Filters/Constraints/NumberConstraintType.php b/src/Form/Filters/Constraints/NumberConstraintType.php index 2f006ecf..2dfde4de 100644 --- a/src/Form/Filters/Constraints/NumberConstraintType.php +++ b/src/Form/Filters/Constraints/NumberConstraintType.php @@ -18,6 +18,14 @@ class NumberConstraintType extends AbstractType $resolver->setDefaults([ 'compound' => true, 'data_class' => NumberConstraint::class, + 'text_suffix' => '', // An suffix which is attached as text-append to the input group. This can for example be used for units + + 'min' => null, + 'max' => null, + 'step' => 'any', + + 'value1_options' => [], // Options for the first value input + 'value2_options' => [], // Options for the second value input ]); } @@ -33,25 +41,40 @@ class NumberConstraintType extends AbstractType 'BETWEEN' => 'BETWEEN', ]; - $builder->add('value1', NumberType::class, [ + $builder->add('value1', NumberType::class, array_merge_recursive([ 'label' => 'filter.number_constraint.value1', 'attr' => [ 'placeholder' => 'filter.number_constraint.value1', + 'max' => $options['max'], + 'min' => $options['min'], + 'step' => $options['step'], ], 'required' => false, 'html5' => true, - ]); - $builder->add('value2', NumberType::class, [ + ], $options['value1_options'])); + + $builder->add('value2', NumberType::class, array_merge_recursive([ 'label' => 'filter.number_constraint.value2', 'attr' => [ 'placeholder' => 'filter.number_constraint.value2', + 'max' => $options['max'], + 'min' => $options['min'], + 'step' => $options['step'], ], 'required' => false, 'html5' => true, - ]); + ], $options['value2_options'])); + $builder->add('operator', ChoiceType::class, [ 'label' => 'filter.number_constraint.operator', 'choices' => $choices, ]); } + + public function buildView(FormView $view, FormInterface $form, array $options) + { + parent::buildView($view, $form, $options); + + $view->vars['text_suffix'] = $options['text_suffix']; + } } \ No newline at end of file diff --git a/src/Form/Filters/PartFilterType.php b/src/Form/Filters/PartFilterType.php index 1129e9dd..3a609b95 100644 --- a/src/Form/Filters/PartFilterType.php +++ b/src/Form/Filters/PartFilterType.php @@ -29,11 +29,13 @@ class PartFilterType extends AbstractType ]); $builder->add('needsReview', BooleanConstraintType::class, [ - 'label' => 'part.edit.needs_review' + 'label' => 'part.edit.needs_review' ]); $builder->add('mass', NumberConstraintType::class, [ - 'label' => 'part.edit.mass' + 'label' => 'part.edit.mass', + 'text_suffix' => 'g', + 'min' => 0, ]); $builder->add('submit', SubmitType::class, [ diff --git a/templates/Form/FilterTypesLayout.html.twig b/templates/Form/FilterTypesLayout.html.twig index 9db0c3bd..b49a79d0 100644 --- a/templates/Form/FilterTypesLayout.html.twig +++ b/templates/Form/FilterTypesLayout.html.twig @@ -1,7 +1,11 @@ {% block number_constraint_widget %}
- {{ form_widget(form.operator) }} + {{ form_widget(form.operator, {"attr": {"class": "form-select"}}) }} {{ form_widget(form.value1) }} + AND {{ form_widget(form.value2) }} + {% if form.vars["text_suffix"] %} + {{ form.vars["text_suffix"] }} + {% endif %}
{% endblock %} \ No newline at end of file From f8562f9622b2b9e3f3abb8cfeb5bb1be45b31049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Thu, 18 Aug 2022 00:00:54 +0200 Subject: [PATCH 04/64] Added an basic TextConstraint for part filtering. --- ...eConstraint.php => AbstractConstraint.php} | 8 +- .../Filters/Constraints/BooleanConstraint.php | 8 +- .../Filters/Constraints/NumberConstraint.php | 14 ++- .../Filters/Constraints/TextConstraint.php | 109 ++++++++++++++++++ src/DataTables/Filters/PartFilter.php | 21 ++++ .../Constraints/NumberConstraintType.php | 2 + .../Constraints/TextConstraintType.php | 60 ++++++++++ src/Form/Filters/PartFilterType.php | 9 ++ templates/Form/FilterTypesLayout.html.twig | 10 ++ 9 files changed, 234 insertions(+), 7 deletions(-) rename src/DataTables/Filters/Constraints/{AbstractSimpleConstraint.php => AbstractConstraint.php} (63%) create mode 100644 src/DataTables/Filters/Constraints/TextConstraint.php create mode 100644 src/Form/Filters/Constraints/TextConstraintType.php diff --git a/src/DataTables/Filters/Constraints/AbstractSimpleConstraint.php b/src/DataTables/Filters/Constraints/AbstractConstraint.php similarity index 63% rename from src/DataTables/Filters/Constraints/AbstractSimpleConstraint.php rename to src/DataTables/Filters/Constraints/AbstractConstraint.php index e45f1909..0af33ce5 100644 --- a/src/DataTables/Filters/Constraints/AbstractSimpleConstraint.php +++ b/src/DataTables/Filters/Constraints/AbstractConstraint.php @@ -5,7 +5,7 @@ namespace App\DataTables\Filters\Constraints; use App\DataTables\Filters\FilterInterface; use Doctrine\ORM\QueryBuilder; -abstract class AbstractSimpleConstraint implements FilterInterface +abstract class AbstractConstraint implements FilterInterface { use FilterTrait; @@ -20,6 +20,12 @@ abstract class AbstractSimpleConstraint implements FilterInterface protected $identifier; + /** + * Determines whether this constraint is active or not. This should be decided accordingly to the value of the constraint + * @return bool True if the constraint is active, false otherwise + */ + abstract public function isEnabled(): bool; + public function __construct(string $property, string $identifier = null) { $this->property = $property; diff --git a/src/DataTables/Filters/Constraints/BooleanConstraint.php b/src/DataTables/Filters/Constraints/BooleanConstraint.php index 4051ad49..dd2870ed 100644 --- a/src/DataTables/Filters/Constraints/BooleanConstraint.php +++ b/src/DataTables/Filters/Constraints/BooleanConstraint.php @@ -5,7 +5,7 @@ namespace App\DataTables\Filters\Constraints; use App\DataTables\Filters\FilterInterface; use Doctrine\ORM\QueryBuilder; -class BooleanConstraint extends AbstractSimpleConstraint +class BooleanConstraint extends AbstractConstraint { /** @var bool|null The value of our constraint */ protected $value; @@ -34,12 +34,16 @@ class BooleanConstraint extends AbstractSimpleConstraint $this->value = $value; } + public function isEnabled(): bool + { + return $this->value !== null; + } public function apply(QueryBuilder $queryBuilder): void { //Do not apply a filter if value is null (filter is set to ignore) - if($this->value === null) { + if(!$this->isEnabled()) { return; } diff --git a/src/DataTables/Filters/Constraints/NumberConstraint.php b/src/DataTables/Filters/Constraints/NumberConstraint.php index 410b6f63..ef758e23 100644 --- a/src/DataTables/Filters/Constraints/NumberConstraint.php +++ b/src/DataTables/Filters/Constraints/NumberConstraint.php @@ -5,7 +5,7 @@ namespace App\DataTables\Filters\Constraints; use Doctrine\ORM\QueryBuilder; use \RuntimeException; -class NumberConstraint extends AbstractSimpleConstraint +class NumberConstraint extends AbstractConstraint { public const ALLOWED_OPERATOR_VALUES = ['=', '!=', '<', '>', '<=', '>=', 'BETWEEN']; @@ -62,7 +62,7 @@ class NumberConstraint extends AbstractSimpleConstraint /** * @return mixed|string */ - public function getOperator() + public function getOperator(): string { return $this->operator; } @@ -70,7 +70,7 @@ class NumberConstraint extends AbstractSimpleConstraint /** * @param mixed|string $operator */ - public function setOperator($operator): void + public function setOperator(?string $operator): void { $this->operator = $operator; } @@ -84,6 +84,12 @@ class NumberConstraint extends AbstractSimpleConstraint $this->operator = $operator; } + public function isEnabled(): bool + { + return $this->value1 !== null + && !empty($this->operator); + } + public function apply(QueryBuilder $queryBuilder): void { //If no value is provided then we do not apply a filter @@ -93,7 +99,7 @@ class NumberConstraint extends AbstractSimpleConstraint //Ensure we have an valid operator if(!in_array($this->operator, self::ALLOWED_OPERATOR_VALUES, true)) { - throw new \InvalidArgumentException('Invalid operator '. $this->operator . ' provided. Valid operators are '. implode(', ', self::ALLOWED_OPERATOR_VALUES)); + throw new \RuntimeException('Invalid operator '. $this->operator . ' provided. Valid operators are '. implode(', ', self::ALLOWED_OPERATOR_VALUES)); } if ($this->operator !== 'BETWEEN') { diff --git a/src/DataTables/Filters/Constraints/TextConstraint.php b/src/DataTables/Filters/Constraints/TextConstraint.php new file mode 100644 index 00000000..f4cf9b40 --- /dev/null +++ b/src/DataTables/Filters/Constraints/TextConstraint.php @@ -0,0 +1,109 @@ +value = $value; + $this->operator = $operator; + } + + /** + * @return string + */ + public function getOperator(): ?string + { + return $this->operator; + } + + /** + * @param string $operator + */ + public function setOperator(?string $operator): self + { + $this->operator = $operator; + return $this; + } + + /** + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * @param string $value + */ + public function setValue(string $value): self + { + $this->value = $value; + return $this; + } + + public function isEnabled(): bool + { + return $this->value !== null + && !empty($this->operator); + } + + public function apply(QueryBuilder $queryBuilder): void + { + if(!$this->isEnabled()) { + return; + } + + if(!in_array($this->operator, self::ALLOWED_OPERATOR_VALUES, true)) { + throw new \RuntimeException('Invalid operator '. $this->operator . ' provided. Valid operators are '. implode(', ', self::ALLOWED_OPERATOR_VALUES)); + } + + //Equal and not equal can be handled easily + if($this->operator === '=' || $this->operator === '!=') { + + $this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, $this->operator, $this->value); + return; + } + + //The CONTAINS, LIKE, STARTS and ENDS operators use the LIKE operator but we have to build the value string differently + $like_value = null; + if ($this->operator === 'LIKE') { + $like_value = $this->value; + } else if ($this->operator === 'STARTS') { + $like_value = $this->value . '%'; + } else if ($this->operator === 'ENDS') { + $like_value = '%' . $this->value; + } else if ($this->operator === 'CONTAINS') { + $like_value = '%' . $this->value . '%'; + } + + if ($like_value !== null) { + $this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, 'LIKE', $like_value); + return; + } + + //Regex is only supported on MySQL and needs a special function + if ($this->operator === 'REGEX') { + $queryBuilder->andWhere(sprintf('REGEXP(%s, :%s) = 1', $this->property, $this->identifier)); + $queryBuilder->setParameter($this->identifier, $this->value); + } + } +} \ No newline at end of file diff --git a/src/DataTables/Filters/PartFilter.php b/src/DataTables/Filters/PartFilter.php index 49fc8b9b..be8a7e7f 100644 --- a/src/DataTables/Filters/PartFilter.php +++ b/src/DataTables/Filters/PartFilter.php @@ -4,10 +4,17 @@ namespace App\DataTables\Filters; use App\DataTables\Filters\Constraints\BooleanConstraint; use App\DataTables\Filters\Constraints\NumberConstraint; +use App\DataTables\Filters\Constraints\TextConstraint; use Doctrine\ORM\QueryBuilder; class PartFilter implements FilterInterface { + /** @var TextConstraint */ + protected $name; + + /** @var TextConstraint */ + protected $description; + /** @var BooleanConstraint */ protected $favorite; @@ -38,11 +45,23 @@ class PartFilter implements FilterInterface return $this->mass; } + public function getName(): TextConstraint + { + return $this->name; + } + + public function getDescription(): TextConstraint + { + return $this->description; + } + public function __construct() { $this->favorite = new BooleanConstraint('part.favorite'); $this->needsReview = new BooleanConstraint('part.needs_review'); $this->mass = new NumberConstraint('part.mass'); + $this->name = new TextConstraint('part.name'); + $this->description = new TextConstraint('part.description'); } public function apply(QueryBuilder $queryBuilder): void @@ -50,5 +69,7 @@ class PartFilter implements FilterInterface $this->favorite->apply($queryBuilder); $this->needsReview->apply($queryBuilder); $this->mass->apply($queryBuilder); + $this->name->apply($queryBuilder); + $this->description->apply($queryBuilder); } } diff --git a/src/Form/Filters/Constraints/NumberConstraintType.php b/src/Form/Filters/Constraints/NumberConstraintType.php index 2dfde4de..022cfdbe 100644 --- a/src/Form/Filters/Constraints/NumberConstraintType.php +++ b/src/Form/Filters/Constraints/NumberConstraintType.php @@ -32,6 +32,7 @@ class NumberConstraintType extends AbstractType public function buildForm(FormBuilderInterface $builder, array $options): void { $choices = [ + '' => '', '=' => '=', '!=' => '!=', '<' => '<', @@ -68,6 +69,7 @@ class NumberConstraintType extends AbstractType $builder->add('operator', ChoiceType::class, [ 'label' => 'filter.number_constraint.operator', 'choices' => $choices, + 'required' => false, ]); } diff --git a/src/Form/Filters/Constraints/TextConstraintType.php b/src/Form/Filters/Constraints/TextConstraintType.php new file mode 100644 index 00000000..be54a3ee --- /dev/null +++ b/src/Form/Filters/Constraints/TextConstraintType.php @@ -0,0 +1,60 @@ +setDefaults([ + 'compound' => true, + 'data_class' => TextConstraint::class, + 'text_suffix' => '', // An suffix which is attached as text-append to the input group. This can for example be used for units + ]); + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $choices = [ + '' => '', + '=' => '=', + '!=' => '!=', + 'STARTS' => 'STARTS', + 'ENDS' => 'ENDS', + 'CONTAINS' => 'CONTAINS', + 'LIKE' => 'LIKE', + 'REGEX' => 'REGEX', + ]; + + $builder->add('value', SearchType::class, [ + 'attr' => [ + 'placeholder' => 'filter.text_constraint.value', + ], + 'required' => false, + ]); + + + $builder->add('operator', ChoiceType::class, [ + 'label' => 'filter.text_constraint.operator', + 'choices' => $choices, + 'required' => false, + ]); + } + + public function buildView(FormView $view, FormInterface $form, array $options) + { + parent::buildView($view, $form, $options); + + $view->vars['text_suffix'] = $options['text_suffix']; + } +} \ No newline at end of file diff --git a/src/Form/Filters/PartFilterType.php b/src/Form/Filters/PartFilterType.php index 3a609b95..dcd5ce78 100644 --- a/src/Form/Filters/PartFilterType.php +++ b/src/Form/Filters/PartFilterType.php @@ -5,6 +5,7 @@ namespace App\Form\Filters; use App\DataTables\Filters\PartFilter; use App\Form\Filters\Constraints\BooleanConstraintType; use App\Form\Filters\Constraints\NumberConstraintType; +use App\Form\Filters\Constraints\TextConstraintType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\FormBuilderInterface; @@ -38,6 +39,14 @@ class PartFilterType extends AbstractType 'min' => 0, ]); + $builder->add('name', TextConstraintType::class, [ + 'label' => 'part.edit.name', + ]); + + $builder->add('description', TextConstraintType::class, [ + 'label' => 'part.edit.description', + ]); + $builder->add('submit', SubmitType::class, [ 'label' => 'Update', ]); diff --git a/templates/Form/FilterTypesLayout.html.twig b/templates/Form/FilterTypesLayout.html.twig index b49a79d0..9344cdc7 100644 --- a/templates/Form/FilterTypesLayout.html.twig +++ b/templates/Form/FilterTypesLayout.html.twig @@ -8,4 +8,14 @@ {{ form.vars["text_suffix"] }} {% endif %} +{% endblock %} + +{% block text_constraint_widget %} +
+ {{ form_widget(form.operator, {"attr": {"class": "form-select"}}) }} + {{ form_widget(form.value) }} + {% if form.vars["text_suffix"] %} + {{ form.vars["text_suffix"] }} + {% endif %} +
{% endblock %} \ No newline at end of file From 798eb4c1bc925b2f94384988e4343e6367694f95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Thu, 18 Aug 2022 00:17:07 +0200 Subject: [PATCH 05/64] Automatically apply all filters of a compound filter using reflection. --- .../Filters/CompoundFilterTrait.php | 44 +++++++++++++++++++ src/DataTables/Filters/PartFilter.php | 9 ++-- 2 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 src/DataTables/Filters/CompoundFilterTrait.php diff --git a/src/DataTables/Filters/CompoundFilterTrait.php b/src/DataTables/Filters/CompoundFilterTrait.php new file mode 100644 index 00000000..70536512 --- /dev/null +++ b/src/DataTables/Filters/CompoundFilterTrait.php @@ -0,0 +1,44 @@ + $filter_object + * @return FilterInterface[] + */ + protected function findAllChildFilters(): array + { + $filters = []; + $reflection = new \ReflectionClass($this); + + foreach ($reflection->getProperties() as $property) { + $value = $property->getValue($this); + //We only want filters (objects implementing FilterInterface) + if($value instanceof FilterInterface) { + $filters[$property->getName()] = $value; + } + } + return $filters; + } + + /** + * Applies all children filters that are declared as property of this filter using reflection. + * @param QueryBuilder $queryBuilder + * @return void + */ + protected function applyAllChildFilters(QueryBuilder $queryBuilder): void + { + //Retrieve all child filters and apply them + $filters = $this->findAllChildFilters(); + + foreach ($filters as $filter) { + $filter->apply($queryBuilder); + } + } +} \ No newline at end of file diff --git a/src/DataTables/Filters/PartFilter.php b/src/DataTables/Filters/PartFilter.php index be8a7e7f..9ceb7b93 100644 --- a/src/DataTables/Filters/PartFilter.php +++ b/src/DataTables/Filters/PartFilter.php @@ -9,6 +9,9 @@ use Doctrine\ORM\QueryBuilder; class PartFilter implements FilterInterface { + + use CompoundFilterTrait; + /** @var TextConstraint */ protected $name; @@ -66,10 +69,6 @@ class PartFilter implements FilterInterface public function apply(QueryBuilder $queryBuilder): void { - $this->favorite->apply($queryBuilder); - $this->needsReview->apply($queryBuilder); - $this->mass->apply($queryBuilder); - $this->name->apply($queryBuilder); - $this->description->apply($queryBuilder); + $this->applyAllChildFilters($queryBuilder); } } From d3a42cd98966626f269c4783da2f7600390e547d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 20 Aug 2022 00:10:41 +0200 Subject: [PATCH 06/64] Added filters for creationDate and lastModified state --- .../Constraints/DateTimeConstraint.php | 10 +++ .../Filters/Constraints/NumberConstraint.php | 16 ++-- src/DataTables/Filters/PartFilter.php | 29 ++++++- .../Constraints/DateTimeConstraintType.php | 76 +++++++++++++++++++ src/Form/Filters/PartFilterType.php | 11 +++ templates/Form/FilterTypesLayout.html.twig | 4 + 6 files changed, 137 insertions(+), 9 deletions(-) create mode 100644 src/DataTables/Filters/Constraints/DateTimeConstraint.php create mode 100644 src/Form/Filters/Constraints/DateTimeConstraintType.php diff --git a/src/DataTables/Filters/Constraints/DateTimeConstraint.php b/src/DataTables/Filters/Constraints/DateTimeConstraint.php new file mode 100644 index 00000000..4cc3f50f --- /dev/null +++ b/src/DataTables/Filters/Constraints/DateTimeConstraint.php @@ -0,0 +1,10 @@ +description; } + /** + * @return DateTimeConstraint + */ + public function getLastModified(): DateTimeConstraint + { + return $this->lastModified; + } + + /** + * @return DateTimeConstraint + */ + public function getAddedDate(): DateTimeConstraint + { + return $this->addedDate; + } + + + public function __construct() { - $this->favorite = new BooleanConstraint('part.favorite'); + $this->favorite = $this->needsReview = new BooleanConstraint('part.needs_review'); $this->mass = new NumberConstraint('part.mass'); $this->name = new TextConstraint('part.name'); $this->description = new TextConstraint('part.description'); + $this->addedDate = new DateTimeConstraint('part.addedDate'); + $this->lastModified = new DateTimeConstraint('part.lastModified'); } public function apply(QueryBuilder $queryBuilder): void diff --git a/src/Form/Filters/Constraints/DateTimeConstraintType.php b/src/Form/Filters/Constraints/DateTimeConstraintType.php new file mode 100644 index 00000000..9cc805b6 --- /dev/null +++ b/src/Form/Filters/Constraints/DateTimeConstraintType.php @@ -0,0 +1,76 @@ +setDefaults([ + 'compound' => true, + 'data_class' => DateTimeConstraint::class, + 'text_suffix' => '', // An suffix which is attached as text-append to the input group. This can for example be used for units + + 'value1_options' => [], // Options for the first value input + 'value2_options' => [], // Options for the second value input + ]); + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $choices = [ + '' => '', + '=' => '=', + '!=' => '!=', + '<' => '<', + '>' => '>', + '<=' => '<=', + '>=' => '>=', + 'BETWEEN' => 'BETWEEN', + ]; + + $builder->add('value1', DateTimeType::class, array_merge_recursive([ + 'label' => 'filter.datetime_constraint.value1', + 'attr' => [ + 'placeholder' => 'filter.datetime_constraint.value1', + ], + 'required' => false, + 'html5' => true, + 'widget' => 'single_text', + ], $options['value1_options'])); + + $builder->add('value2', DateTimeType::class, array_merge_recursive([ + 'label' => 'filter.datetime_constraint.value2', + 'attr' => [ + 'placeholder' => 'filter.datetime_constraint.value2', + ], + 'required' => false, + 'html5' => true, + 'widget' => 'single_text', + ], $options['value2_options'])); + + $builder->add('operator', ChoiceType::class, [ + 'label' => 'filter.datetime_constraint.operator', + 'choices' => $choices, + 'required' => false, + ]); + } + + public function buildView(FormView $view, FormInterface $form, array $options) + { + parent::buildView($view, $form, $options); + + $view->vars['text_suffix'] = $options['text_suffix']; + } +} \ No newline at end of file diff --git a/src/Form/Filters/PartFilterType.php b/src/Form/Filters/PartFilterType.php index dcd5ce78..ffa76976 100644 --- a/src/Form/Filters/PartFilterType.php +++ b/src/Form/Filters/PartFilterType.php @@ -4,9 +4,11 @@ namespace App\Form\Filters; use App\DataTables\Filters\PartFilter; use App\Form\Filters\Constraints\BooleanConstraintType; +use App\Form\Filters\Constraints\DateTimeConstraintType; use App\Form\Filters\Constraints\NumberConstraintType; use App\Form\Filters\Constraints\TextConstraintType; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\DateTimeType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\SubmitButton; @@ -47,6 +49,15 @@ class PartFilterType extends AbstractType 'label' => 'part.edit.description', ]); + $builder->add('lastModified', DateTimeConstraintType::class, [ + 'label' => 'lastModified' + ]); + + $builder->add('addedDate', DateTimeConstraintType::class, [ + 'label' => 'createdAt' + ]); + + $builder->add('submit', SubmitType::class, [ 'label' => 'Update', ]); diff --git a/templates/Form/FilterTypesLayout.html.twig b/templates/Form/FilterTypesLayout.html.twig index 9344cdc7..46dc7e5f 100644 --- a/templates/Form/FilterTypesLayout.html.twig +++ b/templates/Form/FilterTypesLayout.html.twig @@ -18,4 +18,8 @@ {{ form.vars["text_suffix"] }} {% endif %} +{% endblock %} + +{% block date_time_constraint_widget %} + {{ block('number_constraint_widget') }} {% endblock %} \ No newline at end of file From b11ef1d60d8fa03e133784dbf805955921292673 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 20 Aug 2022 00:39:09 +0200 Subject: [PATCH 07/64] Hide the second value of constraints based on which operator is selected. --- .../filters/number_constraint_controller.js | 21 +++++++++++++++++++ templates/Form/FilterTypesLayout.html.twig | 12 +++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 assets/controllers/filters/number_constraint_controller.js diff --git a/assets/controllers/filters/number_constraint_controller.js b/assets/controllers/filters/number_constraint_controller.js new file mode 100644 index 00000000..3f7ad065 --- /dev/null +++ b/assets/controllers/filters/number_constraint_controller.js @@ -0,0 +1,21 @@ +import {Controller} from "@hotwired/stimulus"; + +export default class extends Controller { + + static targets = ["operator", "thingsToHide"]; + + connect() { + this.update(); + debugger; + } + + /** + * Updates the visibility state of the value2 input, based on the operator selection. + */ + update() + { + for (const thingToHide of this.thingsToHideTargets) { + thingToHide.classList.toggle("d-none", this.operatorTarget.value !== "BETWEEN"); + } + } +} \ No newline at end of file diff --git a/templates/Form/FilterTypesLayout.html.twig b/templates/Form/FilterTypesLayout.html.twig index 46dc7e5f..ec082128 100644 --- a/templates/Form/FilterTypesLayout.html.twig +++ b/templates/Form/FilterTypesLayout.html.twig @@ -1,9 +1,13 @@ {% block number_constraint_widget %} -
- {{ form_widget(form.operator, {"attr": {"class": "form-select"}}) }} +
+ {{ form_widget(form.operator, {"attr": { + "class": "form-select", + "data-filters--number-constraint-target": "operator", + "data-action": "change->filters--number-constraint#update" + }}) }} {{ form_widget(form.value1) }} - AND - {{ form_widget(form.value2) }} + AND + {{ form_widget(form.value2, {"attr": {"class": "d-none", "data-filters--number-constraint-target": "thingsToHide"}}) }} {% if form.vars["text_suffix"] %} {{ form.vars["text_suffix"] }} {% endif %} From fc1bf5d037940884aabe44ee6d77b8ceb77e1ab6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 20 Aug 2022 01:04:53 +0200 Subject: [PATCH 08/64] Improved translations for filter operators. --- .../Constraints/DateTimeConstraintType.php | 2 +- .../Constraints/NumberConstraintType.php | 2 +- .../Constraints/TextConstraintType.php | 14 ++--- templates/Form/FilterTypesLayout.html.twig | 2 +- translations/messages.en.xlf | 54 +++++++++++++++++++ 5 files changed, 64 insertions(+), 10 deletions(-) diff --git a/src/Form/Filters/Constraints/DateTimeConstraintType.php b/src/Form/Filters/Constraints/DateTimeConstraintType.php index 9cc805b6..6ac4e177 100644 --- a/src/Form/Filters/Constraints/DateTimeConstraintType.php +++ b/src/Form/Filters/Constraints/DateTimeConstraintType.php @@ -37,7 +37,7 @@ class DateTimeConstraintType extends AbstractType '>' => '>', '<=' => '<=', '>=' => '>=', - 'BETWEEN' => 'BETWEEN', + 'filter.number_constraint.value.operator.BETWEEN' => 'BETWEEN', ]; $builder->add('value1', DateTimeType::class, array_merge_recursive([ diff --git a/src/Form/Filters/Constraints/NumberConstraintType.php b/src/Form/Filters/Constraints/NumberConstraintType.php index 022cfdbe..25b6666a 100644 --- a/src/Form/Filters/Constraints/NumberConstraintType.php +++ b/src/Form/Filters/Constraints/NumberConstraintType.php @@ -39,7 +39,7 @@ class NumberConstraintType extends AbstractType '>' => '>', '<=' => '<=', '>=' => '>=', - 'BETWEEN' => 'BETWEEN', + 'filter.number_constraint.value.operator.BETWEEN' => 'BETWEEN', ]; $builder->add('value1', NumberType::class, array_merge_recursive([ diff --git a/src/Form/Filters/Constraints/TextConstraintType.php b/src/Form/Filters/Constraints/TextConstraintType.php index be54a3ee..2344b883 100644 --- a/src/Form/Filters/Constraints/TextConstraintType.php +++ b/src/Form/Filters/Constraints/TextConstraintType.php @@ -27,13 +27,13 @@ class TextConstraintType extends AbstractType { $choices = [ '' => '', - '=' => '=', - '!=' => '!=', - 'STARTS' => 'STARTS', - 'ENDS' => 'ENDS', - 'CONTAINS' => 'CONTAINS', - 'LIKE' => 'LIKE', - 'REGEX' => 'REGEX', + 'filter.text_constraint.value.operator.EQ' => '=', + 'filter.text_constraint.value.operator.NEQ' => '!=', + 'filter.text_constraint.value.operator.STARTS' => 'STARTS', + 'filter.text_constraint.value.operator.ENDS' => 'ENDS', + 'filter.text_constraint.value.operator.CONTAINS' => 'CONTAINS', + 'filter.text_constraint.value.operator.LIKE' => 'LIKE', + 'filter.text_constraint.value.operator.REGEX' => 'REGEX', ]; $builder->add('value', SearchType::class, [ diff --git a/templates/Form/FilterTypesLayout.html.twig b/templates/Form/FilterTypesLayout.html.twig index ec082128..25a23e0f 100644 --- a/templates/Form/FilterTypesLayout.html.twig +++ b/templates/Form/FilterTypesLayout.html.twig @@ -6,7 +6,7 @@ "data-action": "change->filters--number-constraint#update" }}) }} {{ form_widget(form.value1) }} - AND + {% trans %}filter.number_constraint.AND{% endtrans %} {{ form_widget(form.value2, {"attr": {"class": "d-none", "data-filters--number-constraint-target": "thingsToHide"}}) }} {% if form.vars["text_suffix"] %} {{ form.vars["text_suffix"] }} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 04ccc941..13fb5de1 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -9357,5 +9357,59 @@ Element 3 Do you really want to delete this attachment? + + + filter.text_constraint.value.operator.EQ + Is + + + + + filter.text_constraint.value.operator.NEQ + Is not + + + + + filter.text_constraint.value.operator.STARTS + Starts with + + + + + filter.text_constraint.value.operator.CONTAINS + Contains + + + + + filter.text_constraint.value.operator.ENDS + Ends with + + + + + filter.text_constraint.value.operator.LIKE + LIKE pattern + + + + + filter.text_constraint.value.operator.REGEX + Regular expression + + + + + filter.number_constraint.value.operator.BETWEEN + Between + + + + + filter.number_constraint.AND + and + + From b1e6a583b89dcac198ad2c60b811cf22084945a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 20 Aug 2022 01:26:21 +0200 Subject: [PATCH 09/64] Group filter constraints in tabs --- .../Filters/Constraints/NumberConstraint.php | 2 +- templates/Parts/lists/_filter.html.twig | 30 ++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/DataTables/Filters/Constraints/NumberConstraint.php b/src/DataTables/Filters/Constraints/NumberConstraint.php index 1df2f6e3..92ef51ad 100644 --- a/src/DataTables/Filters/Constraints/NumberConstraint.php +++ b/src/DataTables/Filters/Constraints/NumberConstraint.php @@ -76,7 +76,7 @@ class NumberConstraint extends AbstractConstraint } - public function __construct(string $property, string $identifier = null, $value1 = null, $operator = '>', $value2 = null) + public function __construct(string $property, string $identifier = null, $value1 = null, $operator = null, $value2 = null) { parent::__construct($property, $identifier); $this->value1 = $value1; diff --git a/templates/Parts/lists/_filter.html.twig b/templates/Parts/lists/_filter.html.twig index f4510002..825ab339 100644 --- a/templates/Parts/lists/_filter.html.twig +++ b/templates/Parts/lists/_filter.html.twig @@ -1,8 +1,36 @@
-
Filter
+
Filter
+ + {{ form_start(filterForm) }} +
+
+ {{ form_row(filterForm.name) }} + {{ form_row(filterForm.description) }} +
+ +
+ {{ form_row(filterForm.favorite) }} + {{ form_row(filterForm.needsReview) }} + {{ form_row(filterForm.mass) }} + {{ form_row(filterForm.lastModified) }} + {{ form_row(filterForm.addedDate) }} +
+ +
+ + + {{ form_row(filterForm.submit) }} + {{ form_end(filterForm) }}
\ No newline at end of file From 271f0701417b3c835ecf020d0826b7872addae41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 20 Aug 2022 01:30:52 +0200 Subject: [PATCH 10/64] Removed leftover debugger statement --- assets/controllers/filters/number_constraint_controller.js | 1 - 1 file changed, 1 deletion(-) diff --git a/assets/controllers/filters/number_constraint_controller.js b/assets/controllers/filters/number_constraint_controller.js index 3f7ad065..fe236bcf 100644 --- a/assets/controllers/filters/number_constraint_controller.js +++ b/assets/controllers/filters/number_constraint_controller.js @@ -6,7 +6,6 @@ export default class extends Controller { connect() { this.update(); - debugger; } /** From 3dde40b91d2aefaa9d0bcedc4521f27fe82c96a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 20 Aug 2022 01:46:49 +0200 Subject: [PATCH 11/64] Show error box if an error occurs during loading of a datatable. --- assets/js/error_handler.js | 105 +++++++++++++++----------- src/Controller/HomepageController.php | 2 + 2 files changed, 61 insertions(+), 46 deletions(-) diff --git a/assets/js/error_handler.js b/assets/js/error_handler.js index 573a0915..a8c5c683 100644 --- a/assets/js/error_handler.js +++ b/assets/js/error_handler.js @@ -10,6 +10,61 @@ class ErrorHandlerHelper { const content = document.getElementById('content'); content.addEventListener('turbo:before-fetch-response', (event) => this.handleError(event)); + + $(document).ajaxError(this.handleJqueryErrror.bind(this)); + } + + _showAlert(statusText, statusCode, location, responseHTML) + { + //Create error text + const title = statusText + ' (Status ' + statusCode + ')'; + + let trimString = function (string, length) { + return string.length > length ? + string.substring(0, length) + '...' : + string; + }; + + const short_location = trimString(location, 50); + + const alert = bootbox.alert( + { + size: 'large', + message: function() { + let url = location; + let msg = `Error calling ${short_location}.
`; + msg += 'Try to reload the page or contact the administrator if this error persists.'; + + msg += '

' + 'View details' + ""; + msg += "
"; + + return msg; + }, + title: title, + callback: function () { + //Remove blur + $('#content').removeClass('loading-content'); + } + + }); + + alert.init(function (){ + var dstFrame = document.getElementById('error-iframe'); + //@ts-ignore + var dstDoc = dstFrame.contentDocument || dstFrame.contentWindow.document; + dstDoc.write(responseHTML) + dstDoc.close(); + }); + } + + handleJqueryErrror(event, jqXHR, ajaxSettings, thrownError) + { + //Ignore status 422 as this means a symfony validation error occured and we need to show it to user. This is no (unexpected) error. + if (jqXHR.status === 422) { + return; + } + + this._showAlert(jqXHR.statusText, jqXHR.status, ajaxSettings.url, jqXHR.responseText); } handleError(event) { @@ -27,52 +82,10 @@ class ErrorHandlerHelper { } if(fetchResponse.failed) { - //Create error text - let title = response.statusText + ' (Status ' + response.status + ')'; - - /** - switch(response.status) { - case 500: - title = 'Internal Server Error!'; - break; - case 404: - title = "Site not found!"; - break; - case 403: - title = "Permission denied!"; - break; - } **/ - - const alert = bootbox.alert( - { - size: 'large', - message: function() { - let url = fetchResponse.location.toString(); - let msg = `Error calling ${url}. `; - msg += 'Try to reload the page or contact the administrator if this error persists.' - - msg += '

' + 'View details' + ""; - msg += "
"; - - return msg; - }, - title: title, - callback: function () { - //Remove blur - $('#content').removeClass('loading-content'); - } - - }); - - //@ts-ignore - alert.init(function (){ - response.text().then( (html) => { - var dstFrame = document.getElementById('error-iframe'); - //@ts-ignore - var dstDoc = dstFrame.contentDocument || dstFrame.contentWindow.document; - dstDoc.write(html) - dstDoc.close(); - }); + response.text().then(responseHTML => { + this._showAlert(response.statusText, response.status, fetchResponse.location.toString(), responseHTML); + }).catch(err => { + this._showAlert(response.statusText, response.status, fetchResponse.location.toString(), '
' + err + '
'); }); } } diff --git a/src/Controller/HomepageController.php b/src/Controller/HomepageController.php index b9126867..0dd55c1e 100644 --- a/src/Controller/HomepageController.php +++ b/src/Controller/HomepageController.php @@ -84,6 +84,8 @@ class HomepageController extends AbstractController */ public function homepage(Request $request, GitVersionInfo $versionInfo): Response { + throw new \RuntimeException("Test"); + if ($this->isGranted('@tools.lastActivity')) { $table = $this->dataTable->createFromType( LogDataTable::class, From 0bc9d8cba1b1b82d1863633dd31be48f6248eaf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sat, 20 Aug 2022 02:43:15 +0200 Subject: [PATCH 12/64] Implement Regex on SQLite platform using a callback to PHP. --- src/Controller/HomepageController.php | 2 -- src/Doctrine/SQLiteRegexExtension.php | 40 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 src/Doctrine/SQLiteRegexExtension.php diff --git a/src/Controller/HomepageController.php b/src/Controller/HomepageController.php index 0dd55c1e..b9126867 100644 --- a/src/Controller/HomepageController.php +++ b/src/Controller/HomepageController.php @@ -84,8 +84,6 @@ class HomepageController extends AbstractController */ public function homepage(Request $request, GitVersionInfo $versionInfo): Response { - throw new \RuntimeException("Test"); - if ($this->isGranted('@tools.lastActivity')) { $table = $this->dataTable->createFromType( LogDataTable::class, diff --git a/src/Doctrine/SQLiteRegexExtension.php b/src/Doctrine/SQLiteRegexExtension.php new file mode 100644 index 00000000..fe24ae9a --- /dev/null +++ b/src/Doctrine/SQLiteRegexExtension.php @@ -0,0 +1,40 @@ +getConnection(); + + //We only execute this on SQLite databases + if ($connection->getDatabasePlatform() instanceof SqlitePlatform) { + $native_connection = $connection->getNativeConnection(); + + //Ensure that the function really exists on the connection, as it is marked as experimental according to PHP documentation + if($native_connection instanceof \PDO && method_exists($native_connection, 'sqliteCreateFunction' )) { + $native_connection->sqliteCreateFunction('REGEXP', function ($pattern, $value) { + return (false !== mb_ereg($pattern, $value)) ? 1 : 0; + }); + } + } + } + + public function getSubscribedEvents() + { + return[ + Events::postConnect + ]; + } +} \ No newline at end of file From c9151c65bafdb22a701aeb5423344e12e4a0611a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 21 Aug 2022 01:34:17 +0200 Subject: [PATCH 13/64] Implemented a filter constraint for entities --- .../elements/selectpicker_controller.js | 2 +- assets/css/lib/boostrap-select.css | 491 ++++++++++++++++++ src/Controller/PartListsController.php | 6 +- .../Filters/Constraints/EntityConstraint.php | 180 +++++++ .../Filters/Constraints/FilterTrait.php | 2 +- .../Filters/Constraints/NumberConstraint.php | 4 +- .../Filters/Constraints/TextConstraint.php | 2 +- src/DataTables/Filters/PartFilter.php | 42 +- .../StructuralEntityConstraintType.php | 55 ++ src/Form/Filters/PartFilterType.php | 7 + src/Services/Trees/NodesListBuilder.php | 18 +- templates/Form/FilterTypesLayout.html.twig | 4 + translations/messages.en.xlf | 24 + 13 files changed, 810 insertions(+), 27 deletions(-) create mode 100644 assets/css/lib/boostrap-select.css create mode 100644 src/DataTables/Filters/Constraints/EntityConstraint.php create mode 100644 src/Form/Filters/Constraints/StructuralEntityConstraintType.php diff --git a/assets/controllers/elements/selectpicker_controller.js b/assets/controllers/elements/selectpicker_controller.js index 182a2e35..aac08719 100644 --- a/assets/controllers/elements/selectpicker_controller.js +++ b/assets/controllers/elements/selectpicker_controller.js @@ -2,7 +2,7 @@ const bootstrap = window.bootstrap = require('bootstrap'); // without this boots require('bootstrap-select/js/bootstrap-select'); // we have to manually require the working js file import {Controller} from "@hotwired/stimulus"; -import "bootstrap-select/dist/css/bootstrap-select.css"; +import "../../css/lib/boostrap-select.css"; import "../../css/selectpicker_extensions.css"; export default class extends Controller { diff --git a/assets/css/lib/boostrap-select.css b/assets/css/lib/boostrap-select.css new file mode 100644 index 00000000..7ae65947 --- /dev/null +++ b/assets/css/lib/boostrap-select.css @@ -0,0 +1,491 @@ +/*! + * Bootstrap-select v1.14.0-beta3 (https://developer.snapappointments.com/bootstrap-select) + * + * Copyright 2012-2022 SnapAppointments, LLC + * Licensed under MIT (https://github.com/snapappointments/bootstrap-select/blob/master/LICENSE) + */ + +/** + * Modified by Jan Böhmer 2022, to improve styling with BS5: + * - Keep border around selectpicker in a form-control + */ + +@-webkit-keyframes bs-notify-fadeOut { + 0% { + opacity: 0.9; + } + 100% { + opacity: 0; + } +} +@-o-keyframes bs-notify-fadeOut { + 0% { + opacity: 0.9; + } + 100% { + opacity: 0; + } +} +@keyframes bs-notify-fadeOut { + 0% { + opacity: 0.9; + } + 100% { + opacity: 0; + } +} +select.bs-select-hidden, +.bootstrap-select > select.bs-select-hidden, +select.selectpicker { + display: none !important; +} +.bootstrap-select { + width: 220px; + vertical-align: middle; +} +.bootstrap-select > .dropdown-toggle { + position: relative; + width: 100%; + text-align: right; + white-space: nowrap; + display: -webkit-inline-box; + display: -webkit-inline-flex; + display: -ms-inline-flexbox; + display: inline-flex; + -webkit-box-align: center; + -webkit-align-items: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: justify; + -webkit-justify-content: space-between; + -ms-flex-pack: justify; + justify-content: space-between; +} +.bootstrap-select > .dropdown-toggle:after { + margin-top: -1px; +} +.bootstrap-select > .dropdown-toggle.bs-placeholder, +.bootstrap-select > .dropdown-toggle.bs-placeholder:hover, +.bootstrap-select > .dropdown-toggle.bs-placeholder:focus, +.bootstrap-select > .dropdown-toggle.bs-placeholder:active { + color: #999; +} +.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-primary, +.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-secondary, +.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-success, +.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-danger, +.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-info, +.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-dark, +.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-primary:hover, +.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-secondary:hover, +.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-success:hover, +.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-danger:hover, +.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-info:hover, +.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-dark:hover, +.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-primary:focus, +.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-secondary:focus, +.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-success:focus, +.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-danger:focus, +.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-info:focus, +.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-dark:focus, +.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-primary:active, +.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-secondary:active, +.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-success:active, +.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-danger:active, +.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-info:active, +.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-dark:active { + color: rgba(255, 255, 255, 0.5); +} +.bootstrap-select > select { + position: absolute !important; + bottom: 0; + left: 50%; + display: block !important; + width: 0.5px !important; + height: 100% !important; + padding: 0 !important; + opacity: 0 !important; + border: none; + z-index: 0 !important; +} +.bootstrap-select > select.mobile-device { + top: 0; + left: 0; + display: block !important; + width: 100% !important; + z-index: 2 !important; +} +.has-error .bootstrap-select .dropdown-toggle, +.error .bootstrap-select .dropdown-toggle, +.bootstrap-select.is-invalid .dropdown-toggle, +.was-validated .bootstrap-select select:invalid + .dropdown-toggle { + border-color: #b94a48; +} +.bootstrap-select.is-valid .dropdown-toggle, +.was-validated .bootstrap-select select:valid + .dropdown-toggle { + border-color: #28a745; +} +.bootstrap-select.fit-width { + width: auto !important; +} +.bootstrap-select:not([class*="col-"]):not([class*="form-control"]):not(.input-group-btn) { + width: 220px; +} +.bootstrap-select > select.mobile-device:focus + .dropdown-toggle, +.bootstrap-select .dropdown-toggle:focus { + outline: thin dotted #333333 !important; + outline: 5px auto -webkit-focus-ring-color !important; + outline-offset: -2px; +} +.bootstrap-select.form-control { + margin-bottom: 0; + padding: 0; + /*border: none;*/ + height: auto; +} +:not(.input-group) > .bootstrap-select.form-control:not([class*="col-"]) { + width: 100%; +} +.bootstrap-select.form-control.input-group-btn { + float: none; + z-index: auto; +} +.form-inline .bootstrap-select, +.form-inline .bootstrap-select.form-control:not([class*="col-"]) { + width: auto; +} +.bootstrap-select:not(.input-group-btn), +.bootstrap-select[class*="col-"] { + float: none; + display: inline-block; + margin-left: 0; +} +.bootstrap-select.dropdown-menu-right, +.bootstrap-select[class*="col-"].dropdown-menu-right, +.row .bootstrap-select[class*="col-"].dropdown-menu-right { + float: right; +} +.form-inline .bootstrap-select, +.form-horizontal .bootstrap-select, +.form-group .bootstrap-select { + margin-bottom: 0; +} +.form-group-lg .bootstrap-select.form-control, +.form-group-sm .bootstrap-select.form-control { + padding: 0; +} +.form-group-lg .bootstrap-select.form-control .dropdown-toggle, +.form-group-sm .bootstrap-select.form-control .dropdown-toggle { + height: 100%; + font-size: inherit; + line-height: inherit; + border-radius: inherit; +} +.bootstrap-select.form-control-sm .dropdown-toggle, +.bootstrap-select.form-control-lg .dropdown-toggle { + font-size: inherit; + line-height: inherit; + border-radius: inherit; +} +.bootstrap-select.form-control-sm .dropdown-toggle { + padding: 0.25rem 0.5rem; +} +.bootstrap-select.form-control-lg .dropdown-toggle { + padding: 0.5rem 1rem; +} +.form-inline .bootstrap-select .form-control { + width: 100%; +} +.bootstrap-select.disabled, +.bootstrap-select > .disabled { + cursor: not-allowed; +} +.bootstrap-select.disabled:focus, +.bootstrap-select > .disabled:focus { + outline: none !important; +} +.bootstrap-select.bs-container { + position: absolute; + top: 0; + left: 0; + height: 0 !important; + padding: 0 !important; +} +.bootstrap-select.bs-container .dropdown-menu { + z-index: 1060; +} +.bootstrap-select .dropdown-toggle .filter-option { + position: static; + top: 0; + left: 0; + float: left; + height: 100%; + width: 100%; + text-align: left; + overflow: hidden; + -webkit-box-flex: 0; + -webkit-flex: 0 1 auto; + -ms-flex: 0 1 auto; + flex: 0 1 auto; +} +.bs3.bootstrap-select .dropdown-toggle .filter-option { + padding-right: inherit; +} +.input-group .bs3-has-addon.bootstrap-select .dropdown-toggle .filter-option { + position: absolute; + padding-top: inherit; + padding-bottom: inherit; + padding-left: inherit; + float: none; +} +.input-group .bs3-has-addon.bootstrap-select .dropdown-toggle .filter-option .filter-option-inner { + padding-right: inherit; +} +.bootstrap-select .dropdown-toggle .filter-option-inner-inner { + overflow: hidden; +} +.bootstrap-select .dropdown-toggle .filter-expand { + width: 0 !important; + float: left; + opacity: 0 !important; + overflow: hidden; +} +.bootstrap-select .dropdown-toggle .caret { + position: absolute; + top: 50%; + right: 12px; + margin-top: -2px; + vertical-align: middle; +} +.bootstrap-select .dropdown-toggle .bs-select-clear-selected { + position: relative; + display: block; + margin-right: 5px; + text-align: center; +} +.bs3.bootstrap-select .dropdown-toggle .bs-select-clear-selected { + padding-right: inherit; +} +.bootstrap-select .dropdown-toggle .bs-select-clear-selected span { + position: relative; + top: -webkit-calc(((-1em / 1.5) + 1ex) / 2); + top: calc(((-1em / 1.5) + 1ex) / 2); + pointer-events: none; +} +.bs3.bootstrap-select .dropdown-toggle .bs-select-clear-selected span { + top: auto; +} +.bootstrap-select .dropdown-toggle.bs-placeholder .bs-select-clear-selected { + display: none; +} +.input-group .bootstrap-select.form-control .dropdown-toggle { + border-radius: inherit; +} +.bootstrap-select[class*="col-"] .dropdown-toggle { + width: 100%; +} +.bootstrap-select .dropdown-menu { + min-width: 100%; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} +.bootstrap-select .dropdown-menu > .inner:focus { + outline: none !important; +} +.bootstrap-select .dropdown-menu.inner { + position: static; + float: none; + border: 0; + padding: 0; + margin: 0; + border-radius: 0; + -webkit-box-shadow: none; + box-shadow: none; +} +.bootstrap-select .dropdown-menu li { + position: relative; +} +.bootstrap-select .dropdown-menu li.active small { + color: rgba(255, 255, 255, 0.5) !important; +} +.bootstrap-select .dropdown-menu li.disabled a { + cursor: not-allowed; +} +.bootstrap-select .dropdown-menu li a { + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} +.bootstrap-select .dropdown-menu li a.opt { + position: relative; + padding-left: 2.25em; +} +.bootstrap-select .dropdown-menu li a span.check-mark { + display: none; +} +.bootstrap-select .dropdown-menu li a span.text { + display: inline-block; +} +.bootstrap-select .dropdown-menu li small { + padding-left: 0.5em; +} +.bootstrap-select .dropdown-menu .notify { + position: absolute; + bottom: 5px; + width: 96%; + margin: 0 2%; + min-height: 26px; + padding: 3px 5px; + background: #f5f5f5; + border: 1px solid #e3e3e3; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05); + pointer-events: none; + opacity: 0.9; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} +.bootstrap-select .dropdown-menu .notify.fadeOut { + -webkit-animation: 300ms linear 750ms forwards bs-notify-fadeOut; + -o-animation: 300ms linear 750ms forwards bs-notify-fadeOut; + animation: 300ms linear 750ms forwards bs-notify-fadeOut; +} +.bootstrap-select .no-results { + padding: 3px; + background: #f5f5f5; + margin: 0 5px; + white-space: nowrap; +} +.bootstrap-select.fit-width .dropdown-toggle .filter-option { + position: static; + display: inline; + padding: 0; +} +.bootstrap-select.fit-width .dropdown-toggle .filter-option-inner, +.bootstrap-select.fit-width .dropdown-toggle .filter-option-inner-inner { + display: inline; +} +.bootstrap-select.fit-width .dropdown-toggle .bs-caret:before { + content: '\00a0'; +} +.bootstrap-select.fit-width .dropdown-toggle .caret { + position: static; + top: auto; + margin-top: -1px; +} +.bootstrap-select.show-tick .dropdown-menu .selected span.check-mark { + position: absolute; + display: inline-block; + right: 15px; + top: 5px; +} +.bootstrap-select.show-tick .dropdown-menu li a span.text { + margin-right: 34px; +} +.bootstrap-select .bs-ok-default:after { + content: ''; + display: block; + width: 0.5em; + height: 1em; + border-style: solid; + border-width: 0 0.26em 0.26em 0; + -webkit-transform-style: preserve-3d; + transform-style: preserve-3d; + -webkit-transform: rotate(45deg); + -ms-transform: rotate(45deg); + -o-transform: rotate(45deg); + transform: rotate(45deg); +} +.bootstrap-select.show-menu-arrow.open > .dropdown-toggle, +.bootstrap-select.show-menu-arrow.show > .dropdown-toggle { + z-index: 1061; +} +.bootstrap-select.show-menu-arrow .dropdown-toggle .filter-option:before { + content: ''; + border-left: 7px solid transparent; + border-right: 7px solid transparent; + border-bottom: 7px solid rgba(204, 204, 204, 0.2); + position: absolute; + bottom: -4px; + left: 9px; + display: none; +} +.bootstrap-select.show-menu-arrow .dropdown-toggle .filter-option:after { + content: ''; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-bottom: 6px solid white; + position: absolute; + bottom: -4px; + left: 10px; + display: none; +} +.bootstrap-select.show-menu-arrow.dropup .dropdown-toggle .filter-option:before { + bottom: auto; + top: -4px; + border-top: 7px solid rgba(204, 204, 204, 0.2); + border-bottom: 0; +} +.bootstrap-select.show-menu-arrow.dropup .dropdown-toggle .filter-option:after { + bottom: auto; + top: -4px; + border-top: 6px solid white; + border-bottom: 0; +} +.bootstrap-select.show-menu-arrow.pull-right .dropdown-toggle .filter-option:before { + right: 12px; + left: auto; +} +.bootstrap-select.show-menu-arrow.pull-right .dropdown-toggle .filter-option:after { + right: 13px; + left: auto; +} +.bootstrap-select.show-menu-arrow.open > .dropdown-toggle .filter-option:before, +.bootstrap-select.show-menu-arrow.show > .dropdown-toggle .filter-option:before, +.bootstrap-select.show-menu-arrow.open > .dropdown-toggle .filter-option:after, +.bootstrap-select.show-menu-arrow.show > .dropdown-toggle .filter-option:after { + display: block; +} +.bs-searchbox, +.bs-actionsbox, +.bs-donebutton { + padding: 4px 8px; +} +.bs-actionsbox { + width: 100%; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} +.bs-actionsbox .btn-group { + display: block; +} +.bs-actionsbox .btn-group button { + width: 50%; +} +.bs-donebutton { + float: left; + width: 100%; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} +.bs-donebutton .btn-group { + display: block; +} +.bs-donebutton .btn-group button { + width: 100%; +} +.bs-searchbox + .bs-actionsbox { + padding: 0 8px 4px; +} +.bs-searchbox .form-control { + margin-bottom: 0; + width: 100%; + float: none; +} +/*# sourceMappingURL=bootstrap-select.css.map */ \ No newline at end of file diff --git a/src/Controller/PartListsController.php b/src/Controller/PartListsController.php index 070db537..116edaab 100644 --- a/src/Controller/PartListsController.php +++ b/src/Controller/PartListsController.php @@ -51,6 +51,7 @@ use App\Entity\Parts\Storelocation; use App\Entity\Parts\Supplier; use App\Form\Filters\PartFilterType; use App\Services\Parts\PartsTableActionHandler; +use App\Services\Trees\NodesListBuilder; use Doctrine\ORM\EntityManagerInterface; use Omines\DataTablesBundle\DataTableFactory; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -260,7 +261,6 @@ class PartListsController extends AbstractController return $this->render('Parts/lists/search_list.html.twig', [ 'datatable' => $table, 'keyword' => $search, - 'filterForm' => $filterForm->createView() ]); } @@ -269,12 +269,12 @@ class PartListsController extends AbstractController * * @return JsonResponse|Response */ - public function showAll(Request $request, DataTableFactory $dataTable) + public function showAll(Request $request, DataTableFactory $dataTable, NodesListBuilder $nodesListBuilder) { $formRequest = clone $request; $formRequest->setMethod('GET'); - $filter = new PartFilter(); + $filter = new PartFilter($nodesListBuilder); $filterForm = $this->createForm(PartFilterType::class, $filter, ['method' => 'GET']); $filterForm->handleRequest($formRequest); diff --git a/src/DataTables/Filters/Constraints/EntityConstraint.php b/src/DataTables/Filters/Constraints/EntityConstraint.php new file mode 100644 index 00000000..0e2ec7ce --- /dev/null +++ b/src/DataTables/Filters/Constraints/EntityConstraint.php @@ -0,0 +1,180 @@ + The class to use for the comparison + */ + protected $class; + + /** + * @var string|null The operator to use + */ + protected $operator; + + /** + * @var T The value to compare to + */ + protected $value; + + /** + * @param NodesListBuilder $nodesListBuilder + * @param class-string $class + * @param string $property + * @param string|null $identifier + * @param $value + * @param string $operator + */ + public function __construct(NodesListBuilder $nodesListBuilder, string $class, string $property, string $identifier = null, $value = null, string $operator = '') + { + $this->nodesListBuilder = $nodesListBuilder; + $this->class = $class; + + parent::__construct($property, $identifier); + $this->value = $value; + $this->operator = $operator; + } + + public function getClass(): string + { + return $this->class; + } + + /** + * @return string|null + */ + public function getOperator(): ?string + { + return $this->operator; + } + + /** + * @param string|null $operator + */ + public function setOperator(?string $operator): self + { + $this->operator = $operator; + return $this; + } + + /** + * @return mixed|null + */ + public function getValue(): ?AbstractDBElement + { + return $this->value; + } + + /** + * @param T|null $value + */ + public function setValue(?AbstractDBElement $value): void + { + if (!$value instanceof $this->class) { + throw new \InvalidArgumentException('The value must be an instance of ' . $this->class); + } + + $this->value = $value; + } + + /** + * Checks whether the constraints apply to a structural type or not + * @return bool + */ + public function isStructural(): bool + { + return is_subclass_of($this->class, AbstractStructuralDBElement::class); + } + + /** + * Returns a list of operators which are allowed with the given class. + * @return string[] + */ + public function getAllowedOperatorValues(): array + { + //Base operators are allowed for everything + $tmp = self::ALLOWED_OPERATOR_VALUES_BASE; + + if ($this->isStructural()) { + $tmp = array_merge($tmp, self::ALLOWED_OPERATOR_VALUES_STRUCTURAL); + } + + return $tmp; + } + + public function isEnabled(): bool + { + return !empty($this->operator); + } + + public function apply(QueryBuilder $queryBuilder): void + { + //If no value is provided then we do not apply a filter + if (!$this->isEnabled()) { + return; + } + + //Ensure we have an valid operator + if(!in_array($this->operator, $this->getAllowedOperatorValues(), true)) { + throw new \RuntimeException('Invalid operator '. $this->operator . ' provided. Valid operators are '. implode(', ', $this->getAllowedOperatorValues())); + } + + //We need to handle null values differently, as they can not be compared with == or != + if ($this->value === null) { + if($this->operator === '=' || $this->operator === 'INCLUDING_CHILDREN') { + $queryBuilder->andWhere(sprintf("%s IS NULL", $this->property)); + return; + } + + if ($this->operator === '!=' || $this->operator === 'EXCLUDING_CHILDREN') { + $queryBuilder->andWhere(sprintf("%s IS NOT NULL", $this->property)); + return; + } + + throw new \RuntimeException('Unknown operator '. $this->operator . ' provided. Valid operators are '. implode(', ', $this->getAllowedOperatorValues())); + } + + if($this->operator === '=' || $this->operator === '!=') { + $this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, $this->operator, $this->value); + return; + } + + //Otherwise retrieve the children list and apply the operator to it + if($this->isStructural()) { + $list = $this->nodesListBuilder->getChildrenFlatList($this->value); + //Add the element itself to the list + $list[] = $this->value; + + if ($this->operator === 'INCLUDING_CHILDREN') { + $this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, 'IN', $list); + return; + } + + if ($this->operator === 'EXCLUDING_CHILDREN') { + $this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, 'NOT IN', $list); + return; + } + } else { + throw new \RuntimeException('Cannot apply operator '. $this->operator . ' to non-structural type'); + } + + } +} \ No newline at end of file diff --git a/src/DataTables/Filters/Constraints/FilterTrait.php b/src/DataTables/Filters/Constraints/FilterTrait.php index 3823ef89..4cb9939e 100644 --- a/src/DataTables/Filters/Constraints/FilterTrait.php +++ b/src/DataTables/Filters/Constraints/FilterTrait.php @@ -27,7 +27,7 @@ trait FilterTrait */ protected function addSimpleAndConstraint(QueryBuilder $queryBuilder, string $property, string $parameterIdentifier, string $comparison_operator, $value): void { - $queryBuilder->andWhere(sprintf("%s %s :%s", $property, $comparison_operator, $parameterIdentifier)); + $queryBuilder->andWhere(sprintf("%s %s (:%s)", $property, $comparison_operator, $parameterIdentifier)); $queryBuilder->setParameter($parameterIdentifier, $value); } } \ No newline at end of file diff --git a/src/DataTables/Filters/Constraints/NumberConstraint.php b/src/DataTables/Filters/Constraints/NumberConstraint.php index 92ef51ad..fa1b073d 100644 --- a/src/DataTables/Filters/Constraints/NumberConstraint.php +++ b/src/DataTables/Filters/Constraints/NumberConstraint.php @@ -76,7 +76,7 @@ class NumberConstraint extends AbstractConstraint } - public function __construct(string $property, string $identifier = null, $value1 = null, $operator = null, $value2 = null) + public function __construct(string $property, string $identifier = null, $value1 = null, string $operator = null, $value2 = null) { parent::__construct($property, $identifier); $this->value1 = $value1; @@ -93,7 +93,7 @@ class NumberConstraint extends AbstractConstraint public function apply(QueryBuilder $queryBuilder): void { //If no value is provided then we do not apply a filter - if ($this->value1 === null) { + if (!$this->isEnabled()) { return; } diff --git a/src/DataTables/Filters/Constraints/TextConstraint.php b/src/DataTables/Filters/Constraints/TextConstraint.php index f4cf9b40..3ce5c7eb 100644 --- a/src/DataTables/Filters/Constraints/TextConstraint.php +++ b/src/DataTables/Filters/Constraints/TextConstraint.php @@ -19,7 +19,7 @@ class TextConstraint extends AbstractConstraint */ protected $value; - public function __construct(string $property, string $identifier = null, $value = null, $operator = '') + public function __construct(string $property, string $identifier = null, $value = null, string $operator = '') { parent::__construct($property, $identifier); $this->value = $value; diff --git a/src/DataTables/Filters/PartFilter.php b/src/DataTables/Filters/PartFilter.php index 66770e76..4fccbcc4 100644 --- a/src/DataTables/Filters/PartFilter.php +++ b/src/DataTables/Filters/PartFilter.php @@ -4,8 +4,11 @@ namespace App\DataTables\Filters; use App\DataTables\Filters\Constraints\BooleanConstraint; use App\DataTables\Filters\Constraints\DateTimeConstraint; +use App\DataTables\Filters\Constraints\EntityConstraint; use App\DataTables\Filters\Constraints\NumberConstraint; use App\DataTables\Filters\Constraints\TextConstraint; +use App\Entity\Parts\Category; +use App\Services\Trees\NodesListBuilder; use Doctrine\ORM\QueryBuilder; class PartFilter implements FilterInterface @@ -34,6 +37,28 @@ class PartFilter implements FilterInterface /** @var DateTimeConstraint */ protected $addedDate; + /** @var EntityConstraint */ + protected $category; + + public function __construct(NodesListBuilder $nodesListBuilder) + { + $this->favorite = + $this->needsReview = new BooleanConstraint('part.needs_review'); + $this->mass = new NumberConstraint('part.mass'); + $this->name = new TextConstraint('part.name'); + $this->description = new TextConstraint('part.description'); + $this->addedDate = new DateTimeConstraint('part.addedDate'); + $this->lastModified = new DateTimeConstraint('part.lastModified'); + + $this->category = new EntityConstraint($nodesListBuilder, Category::class, 'part.category'); + } + + public function apply(QueryBuilder $queryBuilder): void + { + $this->applyAllChildFilters($queryBuilder); + } + + /** * @return BooleanConstraint|false */ @@ -81,21 +106,8 @@ class PartFilter implements FilterInterface return $this->addedDate; } - - - public function __construct() + public function getCategory(): EntityConstraint { - $this->favorite = - $this->needsReview = new BooleanConstraint('part.needs_review'); - $this->mass = new NumberConstraint('part.mass'); - $this->name = new TextConstraint('part.name'); - $this->description = new TextConstraint('part.description'); - $this->addedDate = new DateTimeConstraint('part.addedDate'); - $this->lastModified = new DateTimeConstraint('part.lastModified'); - } - - public function apply(QueryBuilder $queryBuilder): void - { - $this->applyAllChildFilters($queryBuilder); + return $this->category; } } diff --git a/src/Form/Filters/Constraints/StructuralEntityConstraintType.php b/src/Form/Filters/Constraints/StructuralEntityConstraintType.php new file mode 100644 index 00000000..4730caec --- /dev/null +++ b/src/Form/Filters/Constraints/StructuralEntityConstraintType.php @@ -0,0 +1,55 @@ +setDefaults([ + 'compound' => true, + 'data_class' => EntityConstraint::class, + 'text_suffix' => '', // An suffix which is attached as text-append to the input group. This can for example be used for units + ]); + + $resolver->setRequired('entity_class'); + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $choices = [ + '' => '', + 'filter.entity_constraint.operator.EQ' => '=', + 'filter.entity_constraint.operator.NEQ' => '!=', + 'filter.entity_constraint.operator.INCLUDING_CHILDREN' => 'INCLUDING_CHILDREN', + 'filter.entity_constraint.operator.EXCLUDING_CHILDREN' => 'EXCLUDING_CHILDREN', + ]; + + $builder->add('value', StructuralEntityType::class, [ + 'class' => $options['entity_class'], + 'required' => false, + ]); + + + $builder->add('operator', ChoiceType::class, [ + 'label' => 'filter.text_constraint.operator', + 'choices' => $choices, + 'required' => false, + ]); + } + + public function buildView(FormView $view, FormInterface $form, array $options) + { + parent::buildView($view, $form, $options); + $view->vars['text_suffix'] = $options['text_suffix']; + } +} \ No newline at end of file diff --git a/src/Form/Filters/PartFilterType.php b/src/Form/Filters/PartFilterType.php index ffa76976..6c63cc15 100644 --- a/src/Form/Filters/PartFilterType.php +++ b/src/Form/Filters/PartFilterType.php @@ -3,9 +3,11 @@ namespace App\Form\Filters; use App\DataTables\Filters\PartFilter; +use App\Entity\Parts\Category; use App\Form\Filters\Constraints\BooleanConstraintType; 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\DateTimeType; @@ -27,6 +29,11 @@ class PartFilterType extends AbstractType public function buildForm(FormBuilderInterface $builder, array $options) { + $builder->add('category', StructuralEntityConstraintType::class, [ + 'label' => 'part.edit.category', + 'entity_class' => Category::class + ]); + $builder->add('favorite', BooleanConstraintType::class, [ 'label' => 'part.edit.is_favorite' ]); diff --git a/src/Services/Trees/NodesListBuilder.php b/src/Services/Trees/NodesListBuilder.php index 3bb13486..b2d2c159 100644 --- a/src/Services/Trees/NodesListBuilder.php +++ b/src/Services/Trees/NodesListBuilder.php @@ -84,10 +84,20 @@ class NodesListBuilder return $this->cache->get($key, function (ItemInterface $item) use ($class_name, $parent, $secure_class_name) { // Invalidate when groups, a element with the class or the user changes $item->tag(['groups', 'tree_list', $this->keyGenerator->generateKey(), $secure_class_name]); - /** @var StructuralDBElementRepository */ - $repo = $this->em->getRepository($class_name); - - return $repo->toNodesList($parent); + return $this->em->getRepository($class_name)->toNodesList($parent); }); } + + /** + * Returns a flattened list of all (recursive) children elements of the given AbstractStructuralDBElement. + * The value is cached for performance reasons. + * + * @template T + * @param T $element + * @return T[] + */ + public function getChildrenFlatList(AbstractStructuralDBElement $element): array + { + return $this->typeToNodesList(get_class($element), $element); + } } diff --git a/templates/Form/FilterTypesLayout.html.twig b/templates/Form/FilterTypesLayout.html.twig index 25a23e0f..776700b5 100644 --- a/templates/Form/FilterTypesLayout.html.twig +++ b/templates/Form/FilterTypesLayout.html.twig @@ -24,6 +24,10 @@
{% endblock %} +{% block structural_entity_constraint_widget %} + {{ block('text_constraint_widget') }} +{% endblock %} + {% block date_time_constraint_widget %} {{ block('number_constraint_widget') }} {% endblock %} \ No newline at end of file diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 13fb5de1..fe581953 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -9411,5 +9411,29 @@ Element 3 and + + + filter.entity_constraint.operator.EQ + Is (excluding children) + + + + + filter.entity_constraint.operator.NEQ + Is not (exluding children) + + + + + filter.entity_constraint.operator.INCLUDING_CHILDREN + Is (including children) + + + + + filter.entity_constraint.operator.EXCLUDING_CHILDREN + Is not (excluding children) + + From ff5b59e25db2304762c6032d165ed50f17ef1fcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 21 Aug 2022 02:26:05 +0200 Subject: [PATCH 14/64] Added more filters --- src/DataTables/Filters/PartFilter.php | 140 +++++++++++++++++++++++- src/Form/Filters/PartFilterType.php | 85 +++++++++++++- templates/Parts/lists/_filter.html.twig | 34 +++++- translations/messages.en.xlf | 6 + 4 files changed, 253 insertions(+), 12 deletions(-) diff --git a/src/DataTables/Filters/PartFilter.php b/src/DataTables/Filters/PartFilter.php index 4fccbcc4..11725f6c 100644 --- a/src/DataTables/Filters/PartFilter.php +++ b/src/DataTables/Filters/PartFilter.php @@ -8,6 +8,11 @@ use App\DataTables\Filters\Constraints\EntityConstraint; use App\DataTables\Filters\Constraints\NumberConstraint; use App\DataTables\Filters\Constraints\TextConstraint; use App\Entity\Parts\Category; +use App\Entity\Parts\Footprint; +use App\Entity\Parts\Manufacturer; +use App\Entity\Parts\MeasurementUnit; +use App\Entity\Parts\Storelocation; +use App\Entity\Parts\Supplier; use App\Services\Trees\NodesListBuilder; use Doctrine\ORM\QueryBuilder; @@ -16,12 +21,21 @@ class PartFilter implements FilterInterface use CompoundFilterTrait; + /** @var NumberConstraint */ + protected $dbId; + /** @var TextConstraint */ protected $name; /** @var TextConstraint */ protected $description; + /** @var TextConstraint */ + protected $comment; + + /** @var NumberConstraint */ + protected $minAmount; + /** @var BooleanConstraint */ protected $favorite; @@ -40,17 +54,51 @@ class PartFilter implements FilterInterface /** @var EntityConstraint */ protected $category; + /** @var EntityConstraint */ + protected $footprint; + + /** @var EntityConstraint */ + protected $manufacturer; + + /** @var EntityConstraint */ + protected $supplier; + + /** @var EntityConstraint */ + protected $storelocation; + + /** @var EntityConstraint */ + protected $measurementUnit; + + /** @var TextConstraint */ + protected $manufacturer_product_url; + + /** @var TextConstraint */ + protected $manufacturer_product_number; + public function __construct(NodesListBuilder $nodesListBuilder) { - $this->favorite = - $this->needsReview = new BooleanConstraint('part.needs_review'); - $this->mass = new NumberConstraint('part.mass'); $this->name = new TextConstraint('part.name'); $this->description = new TextConstraint('part.description'); + $this->comment = new TextConstraint('part.comment'); + $this->category = new EntityConstraint($nodesListBuilder, Category::class, 'part.category'); + $this->footprint = new EntityConstraint($nodesListBuilder, Footprint::class, 'part.footprint'); + + $this->favorite = new BooleanConstraint('part.favorite'); + $this->needsReview = new BooleanConstraint('part.needs_review'); + $this->measurementUnit = new EntityConstraint($nodesListBuilder, MeasurementUnit::class, 'part.partUnit'); + $this->mass = new NumberConstraint('part.mass'); + $this->dbId = new NumberConstraint('part.id'); $this->addedDate = new DateTimeConstraint('part.addedDate'); $this->lastModified = new DateTimeConstraint('part.lastModified'); - $this->category = new EntityConstraint($nodesListBuilder, Category::class, 'part.category'); + $this->minAmount = new NumberConstraint('part.minAmount'); + $this->supplier = new EntityConstraint($nodesListBuilder, Supplier::class, 'orderdetails.supplier'); + + $this->manufacturer = new EntityConstraint($nodesListBuilder, Manufacturer::class, 'part.manufacturer'); + $this->manufacturer_product_number = new TextConstraint('part.manufacturer_product_number'); + $this->manufacturer_product_url = new TextConstraint('part.manufacturer_product_url'); + + $this->storelocation = new EntityConstraint($nodesListBuilder, Storelocation::class, 'partLots.storage_location'); } public function apply(QueryBuilder $queryBuilder): void @@ -110,4 +158,88 @@ class PartFilter implements FilterInterface { return $this->category; } + + /** + * @return EntityConstraint + */ + public function getFootprint(): EntityConstraint + { + return $this->footprint; + } + + /** + * @return EntityConstraint + */ + public function getManufacturer(): EntityConstraint + { + return $this->manufacturer; + } + + /** + * @return EntityConstraint + */ + public function getSupplier(): EntityConstraint + { + return $this->supplier; + } + + /** + * @return EntityConstraint + */ + public function getStorelocation(): EntityConstraint + { + return $this->storelocation; + } + + /** + * @return EntityConstraint + */ + public function getMeasurementUnit(): EntityConstraint + { + return $this->measurementUnit; + } + + /** + * @return NumberConstraint + */ + public function getDbId(): NumberConstraint + { + return $this->dbId; + } + + /** + * @return TextConstraint + */ + public function getComment(): TextConstraint + { + return $this->comment; + } + + /** + * @return NumberConstraint + */ + public function getMinAmount(): NumberConstraint + { + return $this->minAmount; + } + + /** + * @return TextConstraint + */ + public function getManufacturerProductUrl(): TextConstraint + { + return $this->manufacturer_product_url; + } + + /** + * @return TextConstraint + */ + public function getManufacturerProductNumber(): TextConstraint + { + return $this->manufacturer_product_number; + } + + + + } diff --git a/src/Form/Filters/PartFilterType.php b/src/Form/Filters/PartFilterType.php index 6c63cc15..b5e4186a 100644 --- a/src/Form/Filters/PartFilterType.php +++ b/src/Form/Filters/PartFilterType.php @@ -4,11 +4,16 @@ namespace App\Form\Filters; use App\DataTables\Filters\PartFilter; use App\Entity\Parts\Category; +use App\Entity\Parts\Footprint; +use App\Entity\Parts\Manufacturer; +use App\Entity\Parts\MeasurementUnit; +use App\Entity\Parts\Storelocation; use App\Form\Filters\Constraints\BooleanConstraintType; 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 Svg\Tag\Text; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\DateTimeType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; @@ -29,11 +34,41 @@ class PartFilterType extends AbstractType public function buildForm(FormBuilderInterface $builder, array $options) { + /* + * Common tab + */ + + $builder->add('name', TextConstraintType::class, [ + 'label' => 'part.edit.name', + ]); + + $builder->add('description', TextConstraintType::class, [ + 'label' => 'part.edit.description', + ]); + $builder->add('category', StructuralEntityConstraintType::class, [ 'label' => 'part.edit.category', 'entity_class' => Category::class ]); + $builder->add('footprint', StructuralEntityConstraintType::class, [ + 'label' => 'part.edit.footprint', + 'entity_class' => Footprint::class + ]); + + $builder->add('comment', TextConstraintType::class, [ + 'label' => 'part.edit.comment' + ]); + + /* + * Advanced tab + */ + + $builder->add('dbId', NumberConstraintType::class, [ + 'label' => 'part.filter.dbId', + 'min' => 1, + ]); + $builder->add('favorite', BooleanConstraintType::class, [ 'label' => 'part.edit.is_favorite' ]); @@ -48,12 +83,9 @@ class PartFilterType extends AbstractType 'min' => 0, ]); - $builder->add('name', TextConstraintType::class, [ - 'label' => 'part.edit.name', - ]); - - $builder->add('description', TextConstraintType::class, [ - 'label' => 'part.edit.description', + $builder->add('measurementUnit', StructuralEntityConstraintType::class, [ + 'label' => 'part.edit.partUnit', + 'entity_class' => MeasurementUnit::class ]); $builder->add('lastModified', DateTimeConstraintType::class, [ @@ -65,6 +97,47 @@ class PartFilterType extends AbstractType ]); + /* + * Manufacturer tab + */ + + $builder->add('manufacturer', StructuralEntityConstraintType::class, [ + 'label' => 'part.edit.manufacturer.label', + 'entity_class' => Manufacturer::class + ]); + + $builder->add('manufacturer_product_url', TextConstraintType::class, [ + 'label' => 'part.edit.manufacturer_url.label' + ]); + + $builder->add('manufacturer_product_number', TextConstraintType::class, [ + 'label' => 'part.edit.mpn' + ]); + + /* + * Purchasee informations + */ + + $builder->add('supplier', StructuralEntityConstraintType::class, [ + 'label' => 'supplier.label', + 'entity_class' => Manufacturer::class + ]); + + + /* + * Stocks tabs + */ + $builder->add('storelocation', StructuralEntityConstraintType::class, [ + 'label' => 'storelocation.label', + 'entity_class' => Storelocation::class + ]); + + $builder->add('minAmount', NumberConstraintType::class, [ + 'label' => 'part.edit.mininstock', + 'min' => 0, + ]); + + $builder->add('submit', SubmitType::class, [ 'label' => 'Update', ]); diff --git a/templates/Parts/lists/_filter.html.twig b/templates/Parts/lists/_filter.html.twig index 825ab339..833c61bb 100644 --- a/templates/Parts/lists/_filter.html.twig +++ b/templates/Parts/lists/_filter.html.twig @@ -3,10 +3,19 @@
@@ -16,16 +25,37 @@
{{ form_row(filterForm.name) }} {{ form_row(filterForm.description) }} + {{ form_row(filterForm.category) }} + {{ form_row(filterForm.footprint) }} + {{ form_row(filterForm.comment) }} +
+ +
+ {{ form_row(filterForm.manufacturer) }} + {{ form_row(filterForm.manufacturer_product_number) }} + {{ form_row(filterForm.manufacturer_product_url) }}
{{ form_row(filterForm.favorite) }} {{ form_row(filterForm.needsReview) }} + {{ form_row(filterForm.measurementUnit) }} {{ form_row(filterForm.mass) }} + {{ form_row(filterForm.dbId) }} {{ form_row(filterForm.lastModified) }} {{ form_row(filterForm.addedDate) }}
+
+ {{ form_row(filterForm.storelocation) }} + {{ form_row(filterForm.minAmount) }} +
+ +
+ {{ form_row(filterForm.supplier) }} +
+ +
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index fe581953..012f926d 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -9435,5 +9435,11 @@ Element 3 Is not (excluding children) + + + part.filter.dbId + Database ID + + From 7c14ebaa2858c178987f46b14fbb2047af012aa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 21 Aug 2022 02:41:04 +0200 Subject: [PATCH 15/64] Fixed handling of empty values with TextConstraint --- src/Form/Filters/Constraints/TextConstraintType.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Form/Filters/Constraints/TextConstraintType.php b/src/Form/Filters/Constraints/TextConstraintType.php index 2344b883..c4b16d51 100644 --- a/src/Form/Filters/Constraints/TextConstraintType.php +++ b/src/Form/Filters/Constraints/TextConstraintType.php @@ -41,6 +41,7 @@ class TextConstraintType extends AbstractType 'placeholder' => 'filter.text_constraint.value', ], 'required' => false, + 'empty_data' => '', ]); From 37a5c52907d77540c996382993494bdc8db0ca86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 21 Aug 2022 03:14:22 +0200 Subject: [PATCH 16/64] Use filter system for category parts list. --- src/Controller/PartListsController.php | 41 +++++++++++++++++-- templates/Parts/lists/category_list.html.twig | 2 + 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/Controller/PartListsController.php b/src/Controller/PartListsController.php index 116edaab..4974af46 100644 --- a/src/Controller/PartListsController.php +++ b/src/Controller/PartListsController.php @@ -55,6 +55,7 @@ use App\Services\Trees\NodesListBuilder; use Doctrine\ORM\EntityManagerInterface; use Omines\DataTablesBundle\DataTableFactory; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -63,10 +64,12 @@ use Symfony\Component\Routing\Annotation\Route; class PartListsController extends AbstractController { private $entityManager; + private $nodesListBuilder; - public function __construct(EntityManagerInterface $entityManager) + public function __construct(EntityManagerInterface $entityManager, NodesListBuilder $nodesListBuilder) { $this->entityManager = $entityManager; + $this->nodesListBuilder = $nodesListBuilder; } /** @@ -100,6 +103,25 @@ class PartListsController extends AbstractController return $this->redirect($redirect); } + /** + * Disable the given form interface after creation of the form by removing and reattaching the form. + * @param FormInterface $form + * @return void + */ + private function disableFormFieldAfterCreation(FormInterface $form, bool $disabled = true): void + { + $attrs = $form->getConfig()->getOptions(); + $attrs['disabled'] = $disabled; + + $parent = $form->getParent(); + if ($parent === null) { + throw new \RuntimeException('This function can only be used on form fields that are children of another form!'); + } + + $parent->remove($form->getName()); + $parent->add($form->getName(), get_class($form->getConfig()->getType()->getInnerType()), $attrs); + } + /** * @Route("/category/{id}/parts", name="part_list_category") * @@ -107,7 +129,17 @@ class PartListsController extends AbstractController */ public function showCategory(Category $category, Request $request, DataTableFactory $dataTable) { - $table = $dataTable->createFromType(PartsDataTable::class, ['category' => $category]) + $formRequest = clone $request; + $formRequest->setMethod('GET'); + $filter = new PartFilter($this->nodesListBuilder); + + //Set the category as default filter and disable to change that constraint value + $filter->getCategory()->setOperator('INCLUDING_CHILDREN')->setValue($category); + $filterForm = $this->createForm(PartFilterType::class, $filter, ['method' => 'GET']); + $this->disableFormFieldAfterCreation($filterForm->get('category')->get('value')); + $filterForm->handleRequest($formRequest); + + $table = $dataTable->createFromType(PartsDataTable::class, ['filter' => $filter]) ->handleRequest($request); if ($table->isCallback()) { @@ -118,6 +150,7 @@ class PartListsController extends AbstractController 'datatable' => $table, 'entity' => $category, 'repo' => $this->entityManager->getRepository(Category::class), + 'filterForm' => $filterForm->createView(), ]); } @@ -269,12 +302,12 @@ class PartListsController extends AbstractController * * @return JsonResponse|Response */ - public function showAll(Request $request, DataTableFactory $dataTable, NodesListBuilder $nodesListBuilder) + public function showAll(Request $request, DataTableFactory $dataTable) { $formRequest = clone $request; $formRequest->setMethod('GET'); - $filter = new PartFilter($nodesListBuilder); + $filter = new PartFilter($this->nodesListBuilder); $filterForm = $this->createForm(PartFilterType::class, $filter, ['method' => 'GET']); $filterForm->handleRequest($formRequest); diff --git a/templates/Parts/lists/category_list.html.twig b/templates/Parts/lists/category_list.html.twig index 676bba99..fd0b0b6d 100644 --- a/templates/Parts/lists/category_list.html.twig +++ b/templates/Parts/lists/category_list.html.twig @@ -8,6 +8,8 @@ {% include "Parts/lists/_info_card.html.twig" with {'header_label': 'category.label'} %} + {% include "Parts/lists/_filter.html.twig" %} + {% include "Parts/lists/_action_bar.html.twig" with {'url_options': {'category': entity.iD}} %} {% include "Parts/lists/_parts_list.html.twig" %} From 4d3ff7d7b5547e743fc0cea4a85439e076a38ccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 21 Aug 2022 20:39:18 +0200 Subject: [PATCH 17/64] Fixed badge styling in datatables --- src/DataTables/AttachmentDataTable.php | 2 +- src/DataTables/Column/TagsColumn.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/DataTables/AttachmentDataTable.php b/src/DataTables/AttachmentDataTable.php index 3a113ccf..c8988ea1 100644 --- a/src/DataTables/AttachmentDataTable.php +++ b/src/DataTables/AttachmentDataTable.php @@ -166,7 +166,7 @@ final class AttachmentDataTable implements DataTableTypeInterface } return sprintf( - ' + ' %s ', $this->translator->trans('attachment.file_not_found') diff --git a/src/DataTables/Column/TagsColumn.php b/src/DataTables/Column/TagsColumn.php index b0364cd7..d02dc1f9 100644 --- a/src/DataTables/Column/TagsColumn.php +++ b/src/DataTables/Column/TagsColumn.php @@ -79,7 +79,7 @@ class TagsColumn extends AbstractColumn break; } $html .= sprintf( - '%s', + '%s', $this->urlGenerator->generate('part_list_tags', ['tag' => $tag]), htmlspecialchars($tag) ); From 4ba58cc621b0d69dee731eb328040e8588939c35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 21 Aug 2022 23:01:10 +0200 Subject: [PATCH 18/64] Added an filter constraint based on part tags. --- .../Constraints/Part/TagsConstraint.php | 137 ++++++++++++++++++ src/DataTables/Filters/PartFilter.php | 15 +- .../Constraints/TagsConstraintType.php | 56 +++++++ src/Form/Filters/PartFilterType.php | 5 + templates/Form/FilterTypesLayout.html.twig | 6 +- templates/Parts/lists/_filter.html.twig | 1 + translations/messages.en.xlf | 18 +++ 7 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 src/DataTables/Filters/Constraints/Part/TagsConstraint.php create mode 100644 src/Form/Filters/Constraints/TagsConstraintType.php diff --git a/src/DataTables/Filters/Constraints/Part/TagsConstraint.php b/src/DataTables/Filters/Constraints/Part/TagsConstraint.php new file mode 100644 index 00000000..ecfb6274 --- /dev/null +++ b/src/DataTables/Filters/Constraints/Part/TagsConstraint.php @@ -0,0 +1,137 @@ +value = $value; + $this->operator = $operator; + } + + /** + * @return string + */ + public function getOperator(): ?string + { + return $this->operator; + } + + /** + * @param string $operator + */ + public function setOperator(?string $operator): self + { + $this->operator = $operator; + return $this; + } + + /** + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * @param string $value + */ + public function setValue(string $value): self + { + $this->value = $value; + return $this; + } + + public function isEnabled(): bool + { + return $this->value !== null + && !empty($this->operator); + } + + /** + * Returns a list of tags based on the comma separated tags list + * @return string[] + */ + public function getTags(): array + { + return explode(',', trim($this->value, ',')); + } + + /** + * Builds an expression to query for a single tag + * @param QueryBuilder $queryBuilder + * @param string $tag + * @return Expr\Orx + */ + protected function getExpressionForTag(QueryBuilder $queryBuilder, string $tag): Expr\Orx + { + $tag_identifier_prefix = uniqid($this->identifier . '_', false); + + $expr = $queryBuilder->expr(); + + $tmp = $expr->orX( + $expr->like($this->property, ':' . $tag_identifier_prefix . '_1'), + $expr->like($this->property, ':' . $tag_identifier_prefix . '_2'), + $expr->like($this->property, ':' . $tag_identifier_prefix . '_3'), + $expr->eq($this->property, ':' . $tag_identifier_prefix . '_4'), + ); + + //Set the parameters for the LIKE expression, in each variation of the tag (so with a comma, at the end, at the beginning, and on both ends, and equaling the tag) + $queryBuilder->setParameter($tag_identifier_prefix . '_1', '%,' . $tag . ',%'); + $queryBuilder->setParameter($tag_identifier_prefix . '_2', '%,' . $tag); + $queryBuilder->setParameter($tag_identifier_prefix . '_3', $tag . ',%'); + $queryBuilder->setParameter($tag_identifier_prefix . '_4', $tag); + + return $tmp; + } + + public function apply(QueryBuilder $queryBuilder): void + { + if(!$this->isEnabled()) { + return; + } + + if(!in_array($this->operator, self::ALLOWED_OPERATOR_VALUES, true)) { + throw new \RuntimeException('Invalid operator '. $this->operator . ' provided. Valid operators are '. implode(', ', self::ALLOWED_OPERATOR_VALUES)); + } + + $tagsExpressions = []; + foreach ($this->getTags() as $tag) { + $tagsExpressions[] = $this->getExpressionForTag($queryBuilder, $tag); + } + + if ($this->operator === 'ANY') { + $queryBuilder->andWhere($queryBuilder->expr()->orX(...$tagsExpressions)); + return; + } + + if ($this->operator === 'ALL') { + $queryBuilder->andWhere($queryBuilder->expr()->andX(...$tagsExpressions)); + return; + } + + if ($this->operator === 'NONE') { + $queryBuilder->andWhere($queryBuilder->expr()->not($queryBuilder->expr()->orX(...$tagsExpressions))); + return; + } + } +} \ No newline at end of file diff --git a/src/DataTables/Filters/PartFilter.php b/src/DataTables/Filters/PartFilter.php index 11725f6c..9b46e4e8 100644 --- a/src/DataTables/Filters/PartFilter.php +++ b/src/DataTables/Filters/PartFilter.php @@ -6,6 +6,7 @@ use App\DataTables\Filters\Constraints\BooleanConstraint; use App\DataTables\Filters\Constraints\DateTimeConstraint; use App\DataTables\Filters\Constraints\EntityConstraint; use App\DataTables\Filters\Constraints\NumberConstraint; +use App\DataTables\Filters\Constraints\Part\TagsConstraint; use App\DataTables\Filters\Constraints\TextConstraint; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; @@ -33,6 +34,9 @@ class PartFilter implements FilterInterface /** @var TextConstraint */ protected $comment; + /** @var TagsConstraint */ + protected $tags; + /** @var NumberConstraint */ protected $minAmount; @@ -82,6 +86,7 @@ class PartFilter implements FilterInterface $this->comment = new TextConstraint('part.comment'); $this->category = new EntityConstraint($nodesListBuilder, Category::class, 'part.category'); $this->footprint = new EntityConstraint($nodesListBuilder, Footprint::class, 'part.footprint'); + $this->tags = new TagsConstraint('part.tags'); $this->favorite = new BooleanConstraint('part.favorite'); $this->needsReview = new BooleanConstraint('part.needs_review'); @@ -239,7 +244,11 @@ class PartFilter implements FilterInterface return $this->manufacturer_product_number; } - - - + /** + * @return TagsConstraint + */ + public function getTags(): TagsConstraint + { + return $this->tags; + } } diff --git a/src/Form/Filters/Constraints/TagsConstraintType.php b/src/Form/Filters/Constraints/TagsConstraintType.php new file mode 100644 index 00000000..c5c9d043 --- /dev/null +++ b/src/Form/Filters/Constraints/TagsConstraintType.php @@ -0,0 +1,56 @@ +urlGenerator = $urlGenerator; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'compound' => true, + 'data_class' => TagsConstraint::class, + ]); + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $choices = [ + '' => '', + 'filter.tags_constraint.operator.ANY' => 'ANY', + 'filter.tags_constraint.operator.ALL' => 'ALL', + 'filter.tags_constraint.operator.NONE' => 'NONE' + ]; + + $builder->add('value', SearchType::class, [ + 'attr' => [ + 'class' => 'tagsinput', + 'data-controller' => 'elements--tagsinput', + 'data-autocomplete' => $this->urlGenerator->generate('typeahead_tags', ['query' => '__QUERY__']), + ], + 'required' => false, + 'empty_data' => '', + ]); + + + $builder->add('operator', ChoiceType::class, [ + 'label' => 'filter.text_constraint.operator', + 'choices' => $choices, + 'required' => false, + ]); + } +} \ No newline at end of file diff --git a/src/Form/Filters/PartFilterType.php b/src/Form/Filters/PartFilterType.php index b5e4186a..f1ed8d66 100644 --- a/src/Form/Filters/PartFilterType.php +++ b/src/Form/Filters/PartFilterType.php @@ -12,6 +12,7 @@ use App\Form\Filters\Constraints\BooleanConstraintType; use App\Form\Filters\Constraints\DateTimeConstraintType; use App\Form\Filters\Constraints\NumberConstraintType; use App\Form\Filters\Constraints\StructuralEntityConstraintType; +use App\Form\Filters\Constraints\TagsConstraintType; use App\Form\Filters\Constraints\TextConstraintType; use Svg\Tag\Text; use Symfony\Component\Form\AbstractType; @@ -56,6 +57,10 @@ class PartFilterType extends AbstractType 'entity_class' => Footprint::class ]); + $builder->add('tags', TagsConstraintType::class, [ + 'label' => 'part.edit.tags' + ]); + $builder->add('comment', TextConstraintType::class, [ 'label' => 'part.edit.comment' ]); diff --git a/templates/Form/FilterTypesLayout.html.twig b/templates/Form/FilterTypesLayout.html.twig index 776700b5..1656c6c0 100644 --- a/templates/Form/FilterTypesLayout.html.twig +++ b/templates/Form/FilterTypesLayout.html.twig @@ -18,7 +18,7 @@
{{ form_widget(form.operator, {"attr": {"class": "form-select"}}) }} {{ form_widget(form.value) }} - {% if form.vars["text_suffix"] %} + {% if form.vars["text_suffix"] is defined and form.vars["text_suffix"] %} {{ form.vars["text_suffix"] }} {% endif %}
@@ -30,4 +30,8 @@ {% block date_time_constraint_widget %} {{ block('number_constraint_widget') }} +{% endblock %} + +{% block tags_constraint_widget %} + {{ block('text_constraint_widget') }} {% endblock %} \ No newline at end of file diff --git a/templates/Parts/lists/_filter.html.twig b/templates/Parts/lists/_filter.html.twig index 833c61bb..6a999fd9 100644 --- a/templates/Parts/lists/_filter.html.twig +++ b/templates/Parts/lists/_filter.html.twig @@ -27,6 +27,7 @@ {{ form_row(filterForm.description) }} {{ form_row(filterForm.category) }} {{ form_row(filterForm.footprint) }} + {{ form_row(filterForm.tags) }} {{ form_row(filterForm.comment) }}
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 012f926d..db335770 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -9441,5 +9441,23 @@ Element 3 Database ID + + + filter.tags_constraint.operator.ANY + Any of the tags + + + + + filter.tags_constraint.operator.ALL + All of the tags + + + + + filter.tags_constraint.operator.NONE + None of the tags + + From 99b25fb293ac66acbd23510b8b2f6ef86d858542 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 28 Aug 2022 18:40:16 +0200 Subject: [PATCH 19/64] Disable content security policy in development env, as symfony profiler uses a lot of inline js --- config/packages/nelmio_security.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/config/packages/nelmio_security.yaml b/config/packages/nelmio_security.yaml index 24e0a50d..d97b3983 100644 --- a/config/packages/nelmio_security.yaml +++ b/config/packages/nelmio_security.yaml @@ -58,3 +58,9 @@ nelmio_security: - 'data:' block-all-mixed-content: true # defaults to false, blocks HTTP content over HTTPS transport # upgrade-insecure-requests: true # defaults to false, upgrades HTTP requests to HTTPS transport + +when@dev: + # disables the Content-Security-Policy header + nelmio_security: + csp: + enabled: false \ No newline at end of file From b8c77ca85554da6320f36fb51034eb9e9b492b72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 28 Aug 2022 19:39:16 +0200 Subject: [PATCH 20/64] Allow to filter by the number of part lots. --- .../Filters/Constraints/FilterTrait.php | 29 +++++++++++++++++-- .../Filters/Constraints/IntConstraint.php | 20 +++++++++++++ src/DataTables/Filters/PartFilter.php | 12 +++++++- src/Form/Filters/PartFilterType.php | 5 ++++ 4 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 src/DataTables/Filters/Constraints/IntConstraint.php diff --git a/src/DataTables/Filters/Constraints/FilterTrait.php b/src/DataTables/Filters/Constraints/FilterTrait.php index 4cb9939e..01130c1b 100644 --- a/src/DataTables/Filters/Constraints/FilterTrait.php +++ b/src/DataTables/Filters/Constraints/FilterTrait.php @@ -7,6 +7,23 @@ use Doctrine\ORM\QueryBuilder; trait FilterTrait { + protected $useHaving = false; + + public function useHaving($value = true): self + { + $this->useHaving = $value; + return $this; + } + + /** + * Checks if the given input is an aggregateFunction like COUNT(part.partsLot) or so + * @return bool + */ + protected function isAggregateFunctionString(string $input): bool + { + return preg_match('/^[a-zA-Z]+\(.*\)$/', $input) === 1; + } + /** * Generates a parameter identifier that can be used for the given property. It gives random results, to be unique, so you have to cache it. * @param string $property @@ -14,7 +31,10 @@ trait FilterTrait */ protected function generateParameterIdentifier(string $property): string { - return str_replace('.', '_', $property) . '_' . uniqid("", false); + //Replace all special characters with underscores + $property = preg_replace('/[^a-zA-Z0-9_]/', '_', $property); + //Add a random number to the end of the property name for uniqueness + return $property . '_' . uniqid("", false); } /** @@ -27,7 +47,12 @@ trait FilterTrait */ protected function addSimpleAndConstraint(QueryBuilder $queryBuilder, string $property, string $parameterIdentifier, string $comparison_operator, $value): void { - $queryBuilder->andWhere(sprintf("%s %s (:%s)", $property, $comparison_operator, $parameterIdentifier)); + if($this->useHaving || $this->isAggregateFunctionString($property)) { //If the property is an aggregate function, we have to use the "having" instead of the "where" + $queryBuilder->andHaving(sprintf("%s %s (:%s)", $property, $comparison_operator, $parameterIdentifier)); + } else { + $queryBuilder->andWhere(sprintf("%s %s (:%s)", $property, $comparison_operator, $parameterIdentifier)); + } + $queryBuilder->setParameter($parameterIdentifier, $value); } } \ No newline at end of file diff --git a/src/DataTables/Filters/Constraints/IntConstraint.php b/src/DataTables/Filters/Constraints/IntConstraint.php new file mode 100644 index 00000000..2df3864e --- /dev/null +++ b/src/DataTables/Filters/Constraints/IntConstraint.php @@ -0,0 +1,20 @@ +value1 !== null) { + $this->value1 = (int) $this->value1; + } + if($this->value2 !== null) { + $this->value2 = (int) $this->value2; + } + + parent::apply($queryBuilder); + } +} \ No newline at end of file diff --git a/src/DataTables/Filters/PartFilter.php b/src/DataTables/Filters/PartFilter.php index 9b46e4e8..8935d6ba 100644 --- a/src/DataTables/Filters/PartFilter.php +++ b/src/DataTables/Filters/PartFilter.php @@ -5,6 +5,7 @@ namespace App\DataTables\Filters; use App\DataTables\Filters\Constraints\BooleanConstraint; use App\DataTables\Filters\Constraints\DateTimeConstraint; use App\DataTables\Filters\Constraints\EntityConstraint; +use App\DataTables\Filters\Constraints\IntConstraint; use App\DataTables\Filters\Constraints\NumberConstraint; use App\DataTables\Filters\Constraints\Part\TagsConstraint; use App\DataTables\Filters\Constraints\TextConstraint; @@ -70,6 +71,9 @@ class PartFilter implements FilterInterface /** @var EntityConstraint */ protected $storelocation; + /** @var NumberConstraint */ + protected $lotCount; + /** @var EntityConstraint */ protected $measurementUnit; @@ -92,7 +96,7 @@ class PartFilter implements FilterInterface $this->needsReview = new BooleanConstraint('part.needs_review'); $this->measurementUnit = new EntityConstraint($nodesListBuilder, MeasurementUnit::class, 'part.partUnit'); $this->mass = new NumberConstraint('part.mass'); - $this->dbId = new NumberConstraint('part.id'); + $this->dbId = new IntConstraint('part.id'); $this->addedDate = new DateTimeConstraint('part.addedDate'); $this->lastModified = new DateTimeConstraint('part.lastModified'); @@ -104,6 +108,7 @@ class PartFilter implements FilterInterface $this->manufacturer_product_url = new TextConstraint('part.manufacturer_product_url'); $this->storelocation = new EntityConstraint($nodesListBuilder, Storelocation::class, 'partLots.storage_location'); + $this->lotCount = new IntConstraint('COUNT(partLots)'); } public function apply(QueryBuilder $queryBuilder): void @@ -244,6 +249,11 @@ class PartFilter implements FilterInterface return $this->manufacturer_product_number; } + public function getLotCount(): NumberConstraint + { + return $this->lotCount; + } + /** * @return TagsConstraint */ diff --git a/src/Form/Filters/PartFilterType.php b/src/Form/Filters/PartFilterType.php index f1ed8d66..55891457 100644 --- a/src/Form/Filters/PartFilterType.php +++ b/src/Form/Filters/PartFilterType.php @@ -142,6 +142,11 @@ class PartFilterType extends AbstractType 'min' => 0, ]); + $builder->add('lotCount', NumberConstraintType::class, [ + 'label' => 'part.filter.lot_count', + 'min' => 0 + ]); + $builder->add('submit', SubmitType::class, [ 'label' => 'Update', From 768618cede294e36d9b603b97888b7468cf690b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 29 Aug 2022 01:05:53 +0200 Subject: [PATCH 21/64] Fixed reveal error on tab for new BS5 tab data-attribute. --- assets/js/tab_remember.js | 1 + 1 file changed, 1 insertion(+) diff --git a/assets/js/tab_remember.js b/assets/js/tab_remember.js index b0697393..78309742 100644 --- a/assets/js/tab_remember.js +++ b/assets/js/tab_remember.js @@ -55,6 +55,7 @@ class TabRememberHelper { while(parent) { //Invoker can either be a button or a element let tabInvoker = document.querySelector("button[data-content='#" + parent.id + "']") + ?? document.querySelector("button[data-bs-target='#" + parent.id + "']") ?? document.querySelector("a[href='#" + parent.id + "']"); Tab.getOrCreateInstance(tabInvoker).show(); From 5402d3b03187bf6a6973230b8f41e6e61827b3af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 29 Aug 2022 01:12:36 +0200 Subject: [PATCH 22/64] Added constraints to filter for the number of orderdetails and attachments --- .../Filters/Constraints/FilterTrait.php | 12 ++++++-- src/DataTables/Filters/PartFilter.php | 29 ++++++++++++++++++- src/DataTables/PartsDataTable.php | 5 +++- src/Form/Filters/PartFilterType.php | 24 ++++++++++++++- templates/Parts/lists/_filter.html.twig | 9 ++++++ translations/messages.en.xlf | 18 ++++++++++++ 6 files changed, 91 insertions(+), 6 deletions(-) diff --git a/src/DataTables/Filters/Constraints/FilterTrait.php b/src/DataTables/Filters/Constraints/FilterTrait.php index 01130c1b..dbf57691 100644 --- a/src/DataTables/Filters/Constraints/FilterTrait.php +++ b/src/DataTables/Filters/Constraints/FilterTrait.php @@ -47,10 +47,16 @@ trait FilterTrait */ protected function addSimpleAndConstraint(QueryBuilder $queryBuilder, string $property, string $parameterIdentifier, string $comparison_operator, $value): void { - if($this->useHaving || $this->isAggregateFunctionString($property)) { //If the property is an aggregate function, we have to use the "having" instead of the "where" - $queryBuilder->andHaving(sprintf("%s %s (:%s)", $property, $comparison_operator, $parameterIdentifier)); + if ($comparison_operator === 'IN') { + $expression = sprintf("%s %s (:%s)", $property, $comparison_operator, $parameterIdentifier); } else { - $queryBuilder->andWhere(sprintf("%s %s (:%s)", $property, $comparison_operator, $parameterIdentifier)); + $expression = sprintf("%s %s :%s", $property, $comparison_operator, $parameterIdentifier); + } + + if($this->useHaving || $this->isAggregateFunctionString($property)) { //If the property is an aggregate function, we have to use the "having" instead of the "where" + $queryBuilder->andHaving($expression); + } else { + $queryBuilder->andWhere($expression); } $queryBuilder->setParameter($parameterIdentifier, $value); diff --git a/src/DataTables/Filters/PartFilter.php b/src/DataTables/Filters/PartFilter.php index 8935d6ba..f6e9c277 100644 --- a/src/DataTables/Filters/PartFilter.php +++ b/src/DataTables/Filters/PartFilter.php @@ -68,10 +68,13 @@ class PartFilter implements FilterInterface /** @var EntityConstraint */ protected $supplier; + /** @var IntConstraint */ + protected $orderdetailsCount; + /** @var EntityConstraint */ protected $storelocation; - /** @var NumberConstraint */ + /** @var IntConstraint */ protected $lotCount; /** @var EntityConstraint */ @@ -83,6 +86,9 @@ class PartFilter implements FilterInterface /** @var TextConstraint */ protected $manufacturer_product_number; + /** @var IntConstraint */ + protected $attachmentsCount; + public function __construct(NodesListBuilder $nodesListBuilder) { $this->name = new TextConstraint('part.name'); @@ -108,7 +114,10 @@ class PartFilter implements FilterInterface $this->manufacturer_product_url = new TextConstraint('part.manufacturer_product_url'); $this->storelocation = new EntityConstraint($nodesListBuilder, Storelocation::class, 'partLots.storage_location'); + $this->lotCount = new IntConstraint('COUNT(partLots)'); + $this->attachmentsCount = new IntConstraint('COUNT(attachments)'); + $this->orderdetailsCount = new IntConstraint('COUNT(orderdetails)'); } public function apply(QueryBuilder $queryBuilder): void @@ -261,4 +270,22 @@ class PartFilter implements FilterInterface { return $this->tags; } + + /** + * @return IntConstraint + */ + public function getOrderdetailsCount(): IntConstraint + { + return $this->orderdetailsCount; + } + + /** + * @return IntConstraint + */ + public function getAttachmentsCount(): IntConstraint + { + return $this->attachmentsCount; + } + + } diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php index de06a09f..3cca6ba2 100644 --- a/src/DataTables/PartsDataTable.php +++ b/src/DataTables/PartsDataTable.php @@ -348,7 +348,10 @@ final class PartsDataTable implements DataTableTypeInterface ->leftJoin('part.orderdetails', 'orderdetails') ->leftJoin('orderdetails.supplier', 'suppliers') ->leftJoin('part.attachments', 'attachments') - ->leftJoin('part.partUnit', 'partUnit'); + ->leftJoin('part.partUnit', 'partUnit') + + ->groupBy('part') + ; } private function buildCriteria(QueryBuilder $builder, array $options): void diff --git a/src/Form/Filters/PartFilterType.php b/src/Form/Filters/PartFilterType.php index 55891457..bf0e2cde 100644 --- a/src/Form/Filters/PartFilterType.php +++ b/src/Form/Filters/PartFilterType.php @@ -17,6 +17,7 @@ use App\Form\Filters\Constraints\TextConstraintType; use Svg\Tag\Text; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\DateTimeType; +use Symfony\Component\Form\Extension\Core\Type\ResetType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\SubmitButton; @@ -72,6 +73,7 @@ class PartFilterType extends AbstractType $builder->add('dbId', NumberConstraintType::class, [ 'label' => 'part.filter.dbId', 'min' => 1, + 'step' => 1, ]); $builder->add('favorite', BooleanConstraintType::class, [ @@ -128,6 +130,12 @@ class PartFilterType extends AbstractType 'entity_class' => Manufacturer::class ]); + $builder->add('orderdetailsCount', NumberConstraintType::class, [ + 'label' => 'part.filter.orderdetails_count', + 'step' => 1, + 'min' => 0, + ]); + /* * Stocks tabs @@ -144,12 +152,26 @@ class PartFilterType extends AbstractType $builder->add('lotCount', NumberConstraintType::class, [ 'label' => 'part.filter.lot_count', - 'min' => 0 + 'min' => 0, + 'step' => 1, ]); + /** + * Attachments count + */ + $builder->add('attachmentsCount', NumberConstraintType::class, [ + 'label' => 'part.filter.attachments_count', + 'step' => 1, + 'min' => 0, + ]); $builder->add('submit', SubmitType::class, [ 'label' => 'Update', ]); + + $builder->add('reset', ResetType::class, [ + 'label' => 'Reset', + ]); + } } \ No newline at end of file diff --git a/templates/Parts/lists/_filter.html.twig b/templates/Parts/lists/_filter.html.twig index 6a999fd9..c236d5f2 100644 --- a/templates/Parts/lists/_filter.html.twig +++ b/templates/Parts/lists/_filter.html.twig @@ -14,6 +14,9 @@ + @@ -50,10 +53,16 @@
{{ form_row(filterForm.storelocation) }} {{ form_row(filterForm.minAmount) }} + {{ form_row(filterForm.lotCount) }} +
+ +
+ {{ form_row(filterForm.attachmentsCount) }}
{{ form_row(filterForm.supplier) }} + {{ form_row(filterForm.orderdetailsCount) }}
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index db335770..06471c86 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -9459,5 +9459,23 @@ Element 3 None of the tags + + + part.filter.lot_count + Number of lots + + + + + part.filter.attachments_count + Number of attachments + + + + + part.filter.orderdetails_count + Number of orderdetails + + From 22eb6601e899f0d8bc4c4c2443e53670d8f2d5a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 29 Aug 2022 01:28:16 +0200 Subject: [PATCH 23/64] Added some more constraints for part lots. --- src/DataTables/Filters/PartFilter.php | 39 ++++++++++++++++++- .../Constraints/DateTimeConstraintType.php | 5 ++- src/Form/Filters/PartFilterType.php | 14 +++++++ templates/Parts/lists/_filter.html.twig | 3 ++ translations/messages.en.xlf | 18 +++++++++ 5 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/DataTables/Filters/PartFilter.php b/src/DataTables/Filters/PartFilter.php index f6e9c277..77b11ab8 100644 --- a/src/DataTables/Filters/PartFilter.php +++ b/src/DataTables/Filters/PartFilter.php @@ -77,6 +77,15 @@ class PartFilter implements FilterInterface /** @var IntConstraint */ protected $lotCount; + /** @var BooleanConstraint */ + protected $lotNeedsRefill; + + /** @var BooleanConstraint */ + protected $lotUnknownAmount; + + /** @var DateTimeConstraint */ + protected $lotExpirationDate; + /** @var EntityConstraint */ protected $measurementUnit; @@ -107,7 +116,11 @@ class PartFilter implements FilterInterface $this->lastModified = new DateTimeConstraint('part.lastModified'); $this->minAmount = new NumberConstraint('part.minAmount'); + $this->lotCount = new IntConstraint('COUNT(partLots)'); $this->supplier = new EntityConstraint($nodesListBuilder, Supplier::class, 'orderdetails.supplier'); + $this->lotNeedsRefill = new BooleanConstraint('partLots.needs_refill'); + $this->lotUnknownAmount = new BooleanConstraint('partLots.instock_unknown'); + $this->lotExpirationDate = new DateTimeConstraint('partLots.expiration_date'); $this->manufacturer = new EntityConstraint($nodesListBuilder, Manufacturer::class, 'part.manufacturer'); $this->manufacturer_product_number = new TextConstraint('part.manufacturer_product_number'); @@ -115,7 +128,6 @@ class PartFilter implements FilterInterface $this->storelocation = new EntityConstraint($nodesListBuilder, Storelocation::class, 'partLots.storage_location'); - $this->lotCount = new IntConstraint('COUNT(partLots)'); $this->attachmentsCount = new IntConstraint('COUNT(attachments)'); $this->orderdetailsCount = new IntConstraint('COUNT(orderdetails)'); } @@ -287,5 +299,30 @@ class PartFilter implements FilterInterface return $this->attachmentsCount; } + /** + * @return BooleanConstraint + */ + public function getLotNeedsRefill(): BooleanConstraint + { + return $this->lotNeedsRefill; + } + + /** + * @return BooleanConstraint + */ + public function getLotUnknownAmount(): BooleanConstraint + { + return $this->lotUnknownAmount; + } + + /** + * @return DateTimeConstraint + */ + public function getLotExpirationDate(): DateTimeConstraint + { + return $this->lotExpirationDate; + } + + } diff --git a/src/Form/Filters/Constraints/DateTimeConstraintType.php b/src/Form/Filters/Constraints/DateTimeConstraintType.php index 6ac4e177..96a3b940 100644 --- a/src/Form/Filters/Constraints/DateTimeConstraintType.php +++ b/src/Form/Filters/Constraints/DateTimeConstraintType.php @@ -24,6 +24,7 @@ class DateTimeConstraintType extends AbstractType 'value1_options' => [], // Options for the first value input 'value2_options' => [], // Options for the second value input + 'input_type' => DateTimeType::class, ]); } @@ -40,7 +41,7 @@ class DateTimeConstraintType extends AbstractType 'filter.number_constraint.value.operator.BETWEEN' => 'BETWEEN', ]; - $builder->add('value1', DateTimeType::class, array_merge_recursive([ + $builder->add('value1', $options['input_type'], array_merge_recursive([ 'label' => 'filter.datetime_constraint.value1', 'attr' => [ 'placeholder' => 'filter.datetime_constraint.value1', @@ -50,7 +51,7 @@ class DateTimeConstraintType extends AbstractType 'widget' => 'single_text', ], $options['value1_options'])); - $builder->add('value2', DateTimeType::class, array_merge_recursive([ + $builder->add('value2', $options['input_type'], array_merge_recursive([ 'label' => 'filter.datetime_constraint.value2', 'attr' => [ 'placeholder' => 'filter.datetime_constraint.value2', diff --git a/src/Form/Filters/PartFilterType.php b/src/Form/Filters/PartFilterType.php index bf0e2cde..cf0f6bca 100644 --- a/src/Form/Filters/PartFilterType.php +++ b/src/Form/Filters/PartFilterType.php @@ -17,6 +17,7 @@ use App\Form\Filters\Constraints\TextConstraintType; use Svg\Tag\Text; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\DateTimeType; +use Symfony\Component\Form\Extension\Core\Type\DateType; use Symfony\Component\Form\Extension\Core\Type\ResetType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\FormBuilderInterface; @@ -156,6 +157,19 @@ class PartFilterType extends AbstractType 'step' => 1, ]); + $builder->add('lotNeedsRefill', BooleanConstraintType::class, [ + 'label' => 'part.filter.lotNeedsRefill' + ]); + + $builder->add('lotUnknownAmount', BooleanConstraintType::class, [ + 'label' => 'part.filter.lotUnknwonAmount' + ]); + + $builder->add('lotExpirationDate', DateTimeConstraintType::class, [ + 'label' => 'part.filter.lotExpirationDate', + 'input_type' => DateType::class, + ]); + /** * Attachments count */ diff --git a/templates/Parts/lists/_filter.html.twig b/templates/Parts/lists/_filter.html.twig index c236d5f2..f68f2223 100644 --- a/templates/Parts/lists/_filter.html.twig +++ b/templates/Parts/lists/_filter.html.twig @@ -54,6 +54,9 @@ {{ form_row(filterForm.storelocation) }} {{ form_row(filterForm.minAmount) }} {{ form_row(filterForm.lotCount) }} + {{ form_row(filterForm.lotExpirationDate) }} + {{ form_row(filterForm.lotNeedsRefill) }} + {{ form_row(filterForm.lotUnknownAmount) }}
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 06471c86..a7d63ee0 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -9477,5 +9477,23 @@ Element 3 Number of orderdetails + + + part.filter.lotExpirationDate + Lot expiration date + + + + + part.filter.lotNeedsRefill + Any lot needs refill + + + + + part.filter.lotUnknwonAmount + Any lot has unknown amount + + From 7b3538a2c7f81bbc16cf76b62af1770dd189f490 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Thu, 1 Sep 2022 00:34:41 +0200 Subject: [PATCH 24/64] Added filters for attachment types and attachment names of parts. --- src/DataTables/Filters/PartFilter.php | 27 +++++++++++++++++++++++++ src/Form/Filters/PartFilterType.php | 10 +++++++++ templates/Parts/lists/_filter.html.twig | 2 ++ translations/messages.en.xlf | 6 ++++++ 4 files changed, 45 insertions(+) diff --git a/src/DataTables/Filters/PartFilter.php b/src/DataTables/Filters/PartFilter.php index 77b11ab8..d4a441eb 100644 --- a/src/DataTables/Filters/PartFilter.php +++ b/src/DataTables/Filters/PartFilter.php @@ -9,6 +9,7 @@ use App\DataTables\Filters\Constraints\IntConstraint; use App\DataTables\Filters\Constraints\NumberConstraint; use App\DataTables\Filters\Constraints\Part\TagsConstraint; use App\DataTables\Filters\Constraints\TextConstraint; +use App\Entity\Attachments\AttachmentType; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; @@ -17,6 +18,7 @@ use App\Entity\Parts\Storelocation; use App\Entity\Parts\Supplier; use App\Services\Trees\NodesListBuilder; use Doctrine\ORM\QueryBuilder; +use Svg\Tag\Text; class PartFilter implements FilterInterface { @@ -98,6 +100,12 @@ class PartFilter implements FilterInterface /** @var IntConstraint */ protected $attachmentsCount; + /** @var EntityConstraint */ + protected $attachmentType; + + /** @var TextConstraint */ + protected $attachmentName; + public function __construct(NodesListBuilder $nodesListBuilder) { $this->name = new TextConstraint('part.name'); @@ -129,6 +137,9 @@ class PartFilter implements FilterInterface $this->storelocation = new EntityConstraint($nodesListBuilder, Storelocation::class, 'partLots.storage_location'); $this->attachmentsCount = new IntConstraint('COUNT(attachments)'); + $this->attachmentType = new EntityConstraint($nodesListBuilder, AttachmentType::class, 'attachments.attachment_type'); + $this->attachmentName = new TextConstraint('attachments.name'); + $this->orderdetailsCount = new IntConstraint('COUNT(orderdetails)'); } @@ -323,6 +334,22 @@ class PartFilter implements FilterInterface return $this->lotExpirationDate; } + /** + * @return EntityConstraint + */ + public function getAttachmentType(): EntityConstraint + { + return $this->attachmentType; + } + + /** + * @return TextConstraint + */ + public function getAttachmentName(): TextConstraint + { + return $this->attachmentName; + } + } diff --git a/src/Form/Filters/PartFilterType.php b/src/Form/Filters/PartFilterType.php index cf0f6bca..548b0467 100644 --- a/src/Form/Filters/PartFilterType.php +++ b/src/Form/Filters/PartFilterType.php @@ -3,6 +3,7 @@ namespace App\Form\Filters; use App\DataTables\Filters\PartFilter; +use App\Entity\Attachments\AttachmentType; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; @@ -179,6 +180,15 @@ class PartFilterType extends AbstractType 'min' => 0, ]); + $builder->add('attachmentType', StructuralEntityConstraintType::class, [ + 'label' => 'attachment.attachment_type', + 'entity_class' => AttachmentType::class + ]); + + $builder->add('attachmentName', TextConstraintType::class, [ + 'label' => 'part.filter.attachmentName', + ]); + $builder->add('submit', SubmitType::class, [ 'label' => 'Update', ]); diff --git a/templates/Parts/lists/_filter.html.twig b/templates/Parts/lists/_filter.html.twig index f68f2223..3db3a241 100644 --- a/templates/Parts/lists/_filter.html.twig +++ b/templates/Parts/lists/_filter.html.twig @@ -61,6 +61,8 @@
{{ form_row(filterForm.attachmentsCount) }} + {{ form_row(filterForm.attachmentType) }} + {{ form_row(filterForm.attachmentName) }}
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index a7d63ee0..6e9ab783 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -9495,5 +9495,11 @@ Element 3 Any lot has unknown amount + + + part.filter.attachmentName + Attachment name + + From ec5e956e3168198574e9f08f3a0e349858774ca6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 4 Sep 2022 00:45:10 +0200 Subject: [PATCH 25/64] Added filter constraint for manufacturing status. --- .../elements/select_multiple_controller.js | 15 +++ .../Filters/Constraints/ChoiceConstraint.php | 84 +++++++++++++ .../Filters/Constraints/FilterTrait.php | 2 +- src/DataTables/Filters/PartFilter.php | 10 ++ .../Constraints/ChoiceConstraintType.php | 48 ++++++++ src/Form/Filters/PartFilterType.php | 15 +++ templates/Form/FilterTypesLayout.html.twig | 4 + templates/Parts/lists/_filter.html.twig | 1 + .../Filters/CompoundFilterTraitTest.php | 112 ++++++++++++++++++ .../Filters/Constraints/FilterTraitTest.php | 40 +++++++ translations/messages.en.xlf | 12 ++ 11 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 assets/controllers/elements/select_multiple_controller.js create mode 100644 src/DataTables/Filters/Constraints/ChoiceConstraint.php create mode 100644 src/Form/Filters/Constraints/ChoiceConstraintType.php create mode 100644 tests/DataTables/Filters/CompoundFilterTraitTest.php create mode 100644 tests/DataTables/Filters/Constraints/FilterTraitTest.php diff --git a/assets/controllers/elements/select_multiple_controller.js b/assets/controllers/elements/select_multiple_controller.js new file mode 100644 index 00000000..25291b3e --- /dev/null +++ b/assets/controllers/elements/select_multiple_controller.js @@ -0,0 +1,15 @@ +import {Controller} from "@hotwired/stimulus"; +import TomSelect from "tom-select"; + +export default class extends Controller { + _tomSelect; + + connect() { + this._tomSelect = new TomSelect(this.element, { + maxItems: 1000, + allowEmptyOption: true, + plugins: ['remove_button'], + }); + } + +} \ No newline at end of file diff --git a/src/DataTables/Filters/Constraints/ChoiceConstraint.php b/src/DataTables/Filters/Constraints/ChoiceConstraint.php new file mode 100644 index 00000000..c1355889 --- /dev/null +++ b/src/DataTables/Filters/Constraints/ChoiceConstraint.php @@ -0,0 +1,84 @@ +value; + } + + /** + * @param string[] $value + * @return ChoiceConstraint + */ + public function setValue(array $value): ChoiceConstraint + { + $this->value = $value; + return $this; + } + + /** + * @return string + */ + public function getOperator(): string + { + return $this->operator; + } + + /** + * @param string $operator + * @return ChoiceConstraint + */ + public function setOperator(string $operator): ChoiceConstraint + { + $this->operator = $operator; + return $this; + } + + + + public function isEnabled(): bool + { + return !empty($this->operator); + } + + public function apply(QueryBuilder $queryBuilder): void + { + //If no value is provided then we do not apply a filter + if (!$this->isEnabled()) { + return; + } + + //Ensure we have an valid operator + if(!in_array($this->operator, self::ALLOWED_OPERATOR_VALUES, true)) { + throw new \RuntimeException('Invalid operator '. $this->operator . ' provided. Valid operators are '. implode(', ', self::ALLOWED_OPERATOR_VALUES)); + } + + if ($this->operator === 'ANY') { + $this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, 'IN', $this->value); + } elseif ($this->operator === 'NONE') { + $this->addSimpleAndConstraint($queryBuilder, $this->property, $this->identifier, 'NOT IN', $this->value); + } else { + throw new \RuntimeException('Unknown operator '. $this->operator . ' provided. Valid operators are '. implode(', ', self::ALLOWED_OPERATOR_VALUES)); + } + } +} \ No newline at end of file diff --git a/src/DataTables/Filters/Constraints/FilterTrait.php b/src/DataTables/Filters/Constraints/FilterTrait.php index dbf57691..275fb17a 100644 --- a/src/DataTables/Filters/Constraints/FilterTrait.php +++ b/src/DataTables/Filters/Constraints/FilterTrait.php @@ -47,7 +47,7 @@ trait FilterTrait */ protected function addSimpleAndConstraint(QueryBuilder $queryBuilder, string $property, string $parameterIdentifier, string $comparison_operator, $value): void { - if ($comparison_operator === 'IN') { + if ($comparison_operator === 'IN' || $comparison_operator === 'NOT IN') { $expression = sprintf("%s %s (:%s)", $property, $comparison_operator, $parameterIdentifier); } else { $expression = sprintf("%s %s :%s", $property, $comparison_operator, $parameterIdentifier); diff --git a/src/DataTables/Filters/PartFilter.php b/src/DataTables/Filters/PartFilter.php index d4a441eb..ea3d4c64 100644 --- a/src/DataTables/Filters/PartFilter.php +++ b/src/DataTables/Filters/PartFilter.php @@ -3,6 +3,7 @@ namespace App\DataTables\Filters; use App\DataTables\Filters\Constraints\BooleanConstraint; +use App\DataTables\Filters\Constraints\ChoiceConstraint; use App\DataTables\Filters\Constraints\DateTimeConstraint; use App\DataTables\Filters\Constraints\EntityConstraint; use App\DataTables\Filters\Constraints\IntConstraint; @@ -67,6 +68,9 @@ class PartFilter implements FilterInterface /** @var EntityConstraint */ protected $manufacturer; + /** @var ChoiceConstraint */ + protected $manufacturing_status; + /** @var EntityConstraint */ protected $supplier; @@ -133,6 +137,7 @@ class PartFilter implements FilterInterface $this->manufacturer = new EntityConstraint($nodesListBuilder, Manufacturer::class, 'part.manufacturer'); $this->manufacturer_product_number = new TextConstraint('part.manufacturer_product_number'); $this->manufacturer_product_url = new TextConstraint('part.manufacturer_product_url'); + $this->manufacturing_status = new ChoiceConstraint('part.manufacturing_status'); $this->storelocation = new EntityConstraint($nodesListBuilder, Storelocation::class, 'partLots.storage_location'); @@ -350,6 +355,11 @@ class PartFilter implements FilterInterface return $this->attachmentName; } + public function getManufacturingStatus(): ChoiceConstraint + { + return $this->manufacturing_status; + } + } diff --git a/src/Form/Filters/Constraints/ChoiceConstraintType.php b/src/Form/Filters/Constraints/ChoiceConstraintType.php new file mode 100644 index 00000000..c9e3320b --- /dev/null +++ b/src/Form/Filters/Constraints/ChoiceConstraintType.php @@ -0,0 +1,48 @@ +setRequired('choices'); + $resolver->setAllowedTypes('choices', 'array'); + + $resolver->setDefaults([ + 'compound' => true, + 'data_class' => ChoiceConstraint::class, + ]); + + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $choices = [ + '' => '', + 'filter.choice_constraint.operator.ANY' => 'ANY', + 'filter.choice_constraint.operator.NONE' => 'NONE', + ]; + + $builder->add('operator', ChoiceType::class, [ + 'choices' => $choices, + 'required' => false, + ]); + + $builder->add('value', ChoiceType::class, [ + 'choices' => $options['choices'], + 'required' => false, + 'multiple' => true, + 'attr' => [ + 'data-controller' => 'elements--select-multiple', + ] + ]); + } + +} \ No newline at end of file diff --git a/src/Form/Filters/PartFilterType.php b/src/Form/Filters/PartFilterType.php index 548b0467..c7894371 100644 --- a/src/Form/Filters/PartFilterType.php +++ b/src/Form/Filters/PartFilterType.php @@ -10,6 +10,7 @@ use App\Entity\Parts\Manufacturer; use App\Entity\Parts\MeasurementUnit; use App\Entity\Parts\Storelocation; use App\Form\Filters\Constraints\BooleanConstraintType; +use App\Form\Filters\Constraints\ChoiceConstraintType; use App\Form\Filters\Constraints\DateTimeConstraintType; use App\Form\Filters\Constraints\NumberConstraintType; use App\Form\Filters\Constraints\StructuralEntityConstraintType; @@ -123,6 +124,20 @@ class PartFilterType extends AbstractType 'label' => 'part.edit.mpn' ]); + $status_choices = [ + 'm_status.unknown' => '', + 'm_status.announced' => 'announced', + 'm_status.active' => 'active', + 'm_status.nrfnd' => 'nrfnd', + 'm_status.eol' => 'eol', + 'm_status.discontinued' => 'discontinued', + ]; + + $builder->add('manufacturing_status', ChoiceConstraintType::class, [ + 'label' => 'part.edit.manufacturing_status', + 'choices' => $status_choices, + ]); + /* * Purchasee informations */ diff --git a/templates/Form/FilterTypesLayout.html.twig b/templates/Form/FilterTypesLayout.html.twig index 1656c6c0..0b2dfa17 100644 --- a/templates/Form/FilterTypesLayout.html.twig +++ b/templates/Form/FilterTypesLayout.html.twig @@ -34,4 +34,8 @@ {% block tags_constraint_widget %} {{ block('text_constraint_widget') }} +{% endblock %} + +{% block choice_constraint_widget %} + {{ block('text_constraint_widget') }} {% endblock %} \ No newline at end of file diff --git a/templates/Parts/lists/_filter.html.twig b/templates/Parts/lists/_filter.html.twig index 3db3a241..254298b6 100644 --- a/templates/Parts/lists/_filter.html.twig +++ b/templates/Parts/lists/_filter.html.twig @@ -36,6 +36,7 @@
{{ form_row(filterForm.manufacturer) }} + {{ form_row(filterForm.manufacturing_status) }} {{ form_row(filterForm.manufacturer_product_number) }} {{ form_row(filterForm.manufacturer_product_url) }}
diff --git a/tests/DataTables/Filters/CompoundFilterTraitTest.php b/tests/DataTables/Filters/CompoundFilterTraitTest.php new file mode 100644 index 00000000..fa09a16e --- /dev/null +++ b/tests/DataTables/Filters/CompoundFilterTraitTest.php @@ -0,0 +1,112 @@ +findAllChildFilters(); + } + }; + + $result = $filter->_findAllChildFilters(); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function testFindAllChildFilters(): void + { + $f1 = $this->createMock(FilterInterface::class); + $f2 = $this->createMock(FilterInterface::class); + $f3 = $this->createMock(FilterInterface::class); + + $filter = new class($f1, $f2, $f3, null) { + use CompoundFilterTrait; + + protected $filter1; + private $filter2; + public $filter3; + protected $filter4; + + public function __construct($f1, $f2, $f3, $f4) { + $this->filter1 = $f1; + $this->filter2 = $f2; + $this->filter3 = $f3; + $this->filter4 = $f4; + } + + public function _findAllChildFilters() + { + return $this->findAllChildFilters(); + } + }; + + $result = $filter->_findAllChildFilters(); + + $this->assertIsArray($result); + $this->assertContainsOnlyInstancesOf(FilterInterface::class, $result); + $this->assertSame([ + 'filter1' => $f1, + 'filter2' => $f2, + 'filter3' => $f3 + ], $result); + } + + public function testApplyAllChildFilters(): void + { + $f1 = $this->createMock(FilterInterface::class); + $f2 = $this->createMock(FilterInterface::class); + $f3 = $this->createMock(FilterInterface::class); + + $f1->expects($this->once()) + ->method('apply') + ->with($this->isInstanceOf(QueryBuilder::class)); + + $f2->expects($this->once()) + ->method('apply') + ->with($this->isInstanceOf(QueryBuilder::class)); + + $f3->expects($this->once()) + ->method('apply') + ->with($this->isInstanceOf(QueryBuilder::class)); + + $filter = new class($f1, $f2, $f3, null) { + use CompoundFilterTrait; + + protected $filter1; + private $filter2; + public $filter3; + protected $filter4; + + public function __construct($f1, $f2, $f3, $f4) { + $this->filter1 = $f1; + $this->filter2 = $f2; + $this->filter3 = $f3; + $this->filter4 = $f4; + } + + public function _applyAllChildFilters(QueryBuilder $queryBuilder): void + { + $this->applyAllChildFilters($queryBuilder); + } + }; + + $qb = $this->createMock(QueryBuilder::class); + $filter->_applyAllChildFilters($qb); + } + + +} diff --git a/tests/DataTables/Filters/Constraints/FilterTraitTest.php b/tests/DataTables/Filters/Constraints/FilterTraitTest.php new file mode 100644 index 00000000..e79e421f --- /dev/null +++ b/tests/DataTables/Filters/Constraints/FilterTraitTest.php @@ -0,0 +1,40 @@ +assertFalse($this->useHaving); + + $this->useHaving(); + $this->assertTrue($this->useHaving); + + $this->useHaving(false); + $this->assertFalse($this->useHaving); + } + + public function isAggregateFunctionStringDataProvider(): iterable + { + yield [false, 'parts.test']; + yield [false, 'attachments.test']; + yield [true, 'COUNT(attachments)']; + yield [true, 'MAX(attachments.value)']; + } + + /** + * @dataProvider isAggregateFunctionStringDataProvider + */ + public function testIsAggregateFunctionString(bool $expected, string $input): void + { + $this->assertEquals($expected, $this->isAggregateFunctionString($input)); + } + +} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 6e9ab783..bf636009 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -9501,5 +9501,17 @@ Element 3 Attachment name + + + filter.choice_constraint.operator.ANY + Any of + + + + + filter.choice_constraint.operator.NONE + None of + + From 8f94a58c7114f7ad6e80ec4d3749259a97d94429 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 4 Sep 2022 03:37:54 +0200 Subject: [PATCH 26/64] Allow to order and filter by the amount sum of parts. --- config/packages/doctrine.yaml | 3 ++- .../Filters/Constraints/FilterTrait.php | 1 + .../Filters/Constraints/NumberConstraint.php | 1 + src/DataTables/Filters/PartFilter.php | 15 +++++++++++++-- src/DataTables/PartsDataTable.php | 13 +++++++++++++ src/Form/Filters/PartFilterType.php | 5 +++++ templates/Parts/lists/_filter.html.twig | 1 + translations/messages.en.xlf | 6 ++++++ 8 files changed, 42 insertions(+), 3 deletions(-) diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 8f9d1586..44ea77f3 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -32,4 +32,5 @@ doctrine: dql: string_functions: - regexp: DoctrineExtensions\Query\Mysql\Regexp \ No newline at end of file + regexp: DoctrineExtensions\Query\Mysql\Regexp + ifnull: DoctrineExtensions\Query\Mysql\IfNull \ No newline at end of file diff --git a/src/DataTables/Filters/Constraints/FilterTrait.php b/src/DataTables/Filters/Constraints/FilterTrait.php index 275fb17a..5371a48d 100644 --- a/src/DataTables/Filters/Constraints/FilterTrait.php +++ b/src/DataTables/Filters/Constraints/FilterTrait.php @@ -2,6 +2,7 @@ namespace App\DataTables\Filters\Constraints; +use Doctrine\DBAL\ParameterType; use Doctrine\ORM\QueryBuilder; trait FilterTrait diff --git a/src/DataTables/Filters/Constraints/NumberConstraint.php b/src/DataTables/Filters/Constraints/NumberConstraint.php index fa1b073d..bacd289a 100644 --- a/src/DataTables/Filters/Constraints/NumberConstraint.php +++ b/src/DataTables/Filters/Constraints/NumberConstraint.php @@ -2,6 +2,7 @@ namespace App\DataTables\Filters\Constraints; +use Doctrine\DBAL\ParameterType; use Doctrine\ORM\QueryBuilder; use \RuntimeException; diff --git a/src/DataTables/Filters/PartFilter.php b/src/DataTables/Filters/PartFilter.php index ea3d4c64..df5c976b 100644 --- a/src/DataTables/Filters/PartFilter.php +++ b/src/DataTables/Filters/PartFilter.php @@ -19,7 +19,6 @@ use App\Entity\Parts\Storelocation; use App\Entity\Parts\Supplier; use App\Services\Trees\NodesListBuilder; use Doctrine\ORM\QueryBuilder; -use Svg\Tag\Text; class PartFilter implements FilterInterface { @@ -83,6 +82,9 @@ class PartFilter implements FilterInterface /** @var IntConstraint */ protected $lotCount; + /** @var NumberConstraint */ + protected $amountSum; + /** @var BooleanConstraint */ protected $lotNeedsRefill; @@ -127,7 +129,12 @@ class PartFilter implements FilterInterface $this->addedDate = new DateTimeConstraint('part.addedDate'); $this->lastModified = new DateTimeConstraint('part.lastModified'); - $this->minAmount = new NumberConstraint('part.minAmount'); + $this->minAmount = new NumberConstraint('part.minamount'); + /* We have to use an IntConstraint here because otherwise we get just an empty result list when applying the filter + This seems to be related to the fact, that PDO does not have an float parameter type and using string type does not work in this situation (at least in SQLite) + TODO: Find a better solution here + */ + $this->amountSum = new IntConstraint('amountSum'); $this->lotCount = new IntConstraint('COUNT(partLots)'); $this->supplier = new EntityConstraint($nodesListBuilder, Supplier::class, 'orderdetails.supplier'); $this->lotNeedsRefill = new BooleanConstraint('partLots.needs_refill'); @@ -360,6 +367,10 @@ class PartFilter implements FilterInterface return $this->manufacturing_status; } + public function getAmountSum(): NumberConstraint + { + return $this->amountSum; + } } diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php index 3cca6ba2..cc0fe1b2 100644 --- a/src/DataTables/PartsDataTable.php +++ b/src/DataTables/PartsDataTable.php @@ -53,6 +53,7 @@ use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; use App\Entity\Parts\Part; +use App\Entity\Parts\PartLot; use App\Entity\Parts\Storelocation; use App\Entity\Parts\Supplier; use App\Services\AmountFormatter; @@ -232,6 +233,7 @@ final class PartsDataTable implements DataTableTypeInterface return $this->amountFormatter->format($amount, $context->getPartUnit()); }, + 'orderField' => 'amountSum' ]) ->add('minamount', TextColumn::class, [ 'label' => $this->translator->trans('part.table.minamount'), @@ -326,6 +328,7 @@ final class PartsDataTable implements DataTableTypeInterface private function getQuery(QueryBuilder $builder): void { + $builder->distinct()->select('part') ->addSelect('category') ->addSelect('footprint') @@ -337,6 +340,16 @@ final class PartsDataTable implements DataTableTypeInterface ->addSelect('orderdetails') ->addSelect('attachments') ->addSelect('storelocations') + //Calculate amount sum using a subquery, so we can filter and sort by it + ->addSelect( + '( + SELECT IFNULL(SUM(partLot.amount), 0.0) + FROM '. PartLot::class. ' partLot + WHERE partLot.part = part.id + AND partLot.instock_unknown = false + AND (partLot.expiration_date IS NULL OR partLot.expiration_date > CURRENT_DATE()) + ) AS HIDDEN amountSum' + ) ->from(Part::class, 'part') ->leftJoin('part.category', 'category') ->leftJoin('part.master_picture_attachment', 'master_picture_attachment') diff --git a/src/Form/Filters/PartFilterType.php b/src/Form/Filters/PartFilterType.php index c7894371..968187ac 100644 --- a/src/Form/Filters/PartFilterType.php +++ b/src/Form/Filters/PartFilterType.php @@ -173,6 +173,11 @@ class PartFilterType extends AbstractType 'step' => 1, ]); + $builder->add('amountSum', NumberConstraintType::class, [ + 'label' => 'part.filter.amount_sum', + 'min' => 0, + ]); + $builder->add('lotNeedsRefill', BooleanConstraintType::class, [ 'label' => 'part.filter.lotNeedsRefill' ]); diff --git a/templates/Parts/lists/_filter.html.twig b/templates/Parts/lists/_filter.html.twig index 254298b6..953ce0d6 100644 --- a/templates/Parts/lists/_filter.html.twig +++ b/templates/Parts/lists/_filter.html.twig @@ -54,6 +54,7 @@
{{ form_row(filterForm.storelocation) }} {{ form_row(filterForm.minAmount) }} + {{ form_row(filterForm.amountSum) }} {{ form_row(filterForm.lotCount) }} {{ form_row(filterForm.lotExpirationDate) }} {{ form_row(filterForm.lotNeedsRefill) }} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index bf636009..3c94b9d9 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -9513,5 +9513,11 @@ Element 3 None of + + + part.filter.amount_sum + Total amount + + From 87913ba3b55ecc9c7433d249b09adf320a6db27e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 4 Sep 2022 16:09:56 +0200 Subject: [PATCH 27/64] Make URLs created by filter form a lot shorter --- .../helpers/form_cleanup_controller.js | 43 +++++++++++++++++++ src/Form/Filters/PartFilterType.php | 6 +-- src/Form/Type/TriStateCheckboxType.php | 2 +- templates/Parts/lists/_filter.html.twig | 11 ++++- translations/messages.en.xlf | 24 +++++++++++ 5 files changed, 80 insertions(+), 6 deletions(-) create mode 100644 assets/controllers/helpers/form_cleanup_controller.js diff --git a/assets/controllers/helpers/form_cleanup_controller.js b/assets/controllers/helpers/form_cleanup_controller.js new file mode 100644 index 00000000..955c1e09 --- /dev/null +++ b/assets/controllers/helpers/form_cleanup_controller.js @@ -0,0 +1,43 @@ +import {Controller} from "@hotwired/stimulus"; + +/** + * Purpose of this controller is to clean up the form before it is finally submitted. This means empty fields get disabled, so they are not submitted. + * This is especially useful for GET forms, to prevent very long URLs + */ +export default class extends Controller { + + /** + * Call during the submit event of the form. This will disable all empty fields, so they are not submitted. + * @param event + */ + submit(event) { + /** Find the form this event belongs to */ + /** @type {HTMLFormElement} */ + const form = event.target.closest('form'); + + for(const element of form.elements) { + if(! element.value) { + element.disabled = true; + } + + //Workaround for tristate checkboxes which use a hidden field to store the value + if ((element.type === 'hidden' || element.type === 'checkbox') && element.value === 'indeterminate') { + element.disabled = true; + } + } + } + + /** + * Submits the form with all form elements disabled, so they are not submitted. This is useful for GET forms, to reset the form to not filled state. + * @param event + */ + clearAll(event) + { + const form = event.target.closest('form'); + for(const element of form.elements) { + element.disabled = true; + } + + form.submit(); + } +} \ No newline at end of file diff --git a/src/Form/Filters/PartFilterType.php b/src/Form/Filters/PartFilterType.php index 968187ac..b3d491ab 100644 --- a/src/Form/Filters/PartFilterType.php +++ b/src/Form/Filters/PartFilterType.php @@ -210,11 +210,11 @@ class PartFilterType extends AbstractType ]); $builder->add('submit', SubmitType::class, [ - 'label' => 'Update', + 'label' => 'filter.submit', ]); - $builder->add('reset', ResetType::class, [ - 'label' => 'Reset', + $builder->add('discard', ResetType::class, [ + 'label' => 'filter.discard', ]); } diff --git a/src/Form/Type/TriStateCheckboxType.php b/src/Form/Type/TriStateCheckboxType.php index 87bdc473..9b3b2514 100644 --- a/src/Form/Type/TriStateCheckboxType.php +++ b/src/Form/Type/TriStateCheckboxType.php @@ -171,10 +171,10 @@ final class TriStateCheckboxType extends AbstractType implements DataTransformer case 'true': return true; case 'false': - case '': return false; case 'indeterminate': case 'null': + case '': return null; default: throw new InvalidArgumentException('Invalid value encountered!: '.$value); diff --git a/templates/Parts/lists/_filter.html.twig b/templates/Parts/lists/_filter.html.twig index 953ce0d6..aa2eb474 100644 --- a/templates/Parts/lists/_filter.html.twig +++ b/templates/Parts/lists/_filter.html.twig @@ -1,5 +1,5 @@
-
Filter
+
{% trans %}filter.title{% endtrans %}
- {{ form_start(filterForm) }} + {{ form_start(filterForm, {"attr": {"data-controller": "helpers--form-cleanup", "data-action": "helpers--form-cleanup#submit"}}) }}
@@ -77,6 +77,13 @@ {{ form_row(filterForm.submit) }} + {{ form_row(filterForm.discard) }} + +
+
+ +
+
{{ form_end(filterForm) }}
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 3c94b9d9..73469609 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -9519,5 +9519,29 @@ Element 3 Total amount + + + filter.submit + Update + + + + + filter.discard + Discard changes + + + + + filter.clear_filters + Clear all filters + + + + + filter.title + Filter + + From 44b288b807ad4b1d4e32b35e5efc9ddbf88582a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 4 Sep 2022 23:02:31 +0200 Subject: [PATCH 28/64] Show type icon in the breadcrumb of part lists. --- src/Twig/AppExtension.php | 39 +++++++++++++ templates/Parts/lists/_info_card.html.twig | 2 + templates/helper.twig | 66 +++++++++++++++++----- 3 files changed, 93 insertions(+), 14 deletions(-) diff --git a/src/Twig/AppExtension.php b/src/Twig/AppExtension.php index 578984f4..99291c2a 100644 --- a/src/Twig/AppExtension.php +++ b/src/Twig/AppExtension.php @@ -42,9 +42,20 @@ declare(strict_types=1); namespace App\Twig; +use App\Entity\Attachments\Attachment; use App\Entity\Base\AbstractDBElement; +use App\Entity\Devices\Device; +use App\Entity\LabelSystem\LabelProfile; +use App\Entity\Parts\Category; +use App\Entity\Parts\Footprint; +use App\Entity\Parts\Manufacturer; use App\Entity\Parts\MeasurementUnit; +use App\Entity\Parts\Part; +use App\Entity\Parts\Storelocation; +use App\Entity\Parts\Supplier; use App\Entity\PriceInformations\Currency; +use App\Entity\UserSystem\Group; +use App\Entity\UserSystem\User; use App\Services\AmountFormatter; use App\Services\Attachments\AttachmentURLGenerator; use App\Services\EntityURLGenerator; @@ -116,6 +127,12 @@ class AppExtension extends AbstractExtension new TwigTest('instanceof', static function ($var, $instance) { return $var instanceof $instance; }), + new TwigTest('entity', static function ($var) { + return $var instanceof AbstractDBElement; + }), + new TwigTest('object', static function ($var) { + return is_object($var); + }), ]; } @@ -125,9 +142,31 @@ class AppExtension extends AbstractExtension new TwigFunction('generateTreeData', [$this, 'treeData']), new TwigFunction('attachment_thumbnail', [$this->attachmentURLGenerator, 'getThumbnailURL']), new TwigFunction('ext_to_fa_icon', [$this->FAIconGenerator, 'fileExtensionToFAType']), + new TwigFunction('entity_type', [$this, 'getEntityType']), ]; } + public function getEntityType($entity): ?string + { + $map = [ + Part::class => 'part', + Footprint::class => 'footprint', + Storelocation::class => 'storelocation', + Manufacturer::class => 'manufacturer', + Category::class => 'category', + Device::class => 'device', + Attachment::class => 'attachment', + Supplier::class => 'supplier', + User::class => 'user', + Group::class => 'group', + Currency::class => 'currency', + MeasurementUnit::class => 'measurement_unit', + LabelProfile::class => 'label_profile', + ]; + + return $map[get_class($entity)] ?? null; + } + public function treeData(AbstractDBElement $element, string $type = 'newEdit'): string { $tree = $this->treeBuilder->getTreeView(get_class($element), null, $type, $element); diff --git a/templates/Parts/lists/_info_card.html.twig b/templates/Parts/lists/_info_card.html.twig index 33879611..da038760 100644 --- a/templates/Parts/lists/_info_card.html.twig +++ b/templates/Parts/lists/_info_card.html.twig @@ -9,6 +9,8 @@ diff --git a/templates/helper.twig b/templates/helper.twig index 67528e8b..34e22791 100644 --- a/templates/helper.twig +++ b/templates/helper.twig @@ -71,20 +71,58 @@ {% endif %} {% endmacro %} -{% macro breadcrumb_entity_link(entity, link_type = "list_parts") %} - +{% macro entity_icon(entity_or_type, classes = "", style = "") %} + {% set map = { + "attachment_type": ["fa-solid fa-file-alt", "attachment_type.label"], + "category": ["fa-solid fa-tags", "category.label"], + "currency": ["fa-solid fa-coins", "currency.label"], + "device": ["fa-solid fa-archive", "device.label"], + "footprint": ["fa-solid fa-microchip", "footprint.label"], + "group": ["fa-solid fa-users", "group.label"], + "label_profile": ["fa-solid fa-qrcode", "label_profile.label"], + "manufacturer": ["fa-solid fa-industry", "manufacturer.label"], + "measurement_unit": ["fa-solid fa-balance-scale", "measurement_unit.label"], + "storelocation": ["fa-solid fa-cube", "storelocation.label"], + "supplier": ["fa-solid fa-truck", "supplier.label"], + "user": ["fa-solid fa-user", "user.label"], + } %} + + {% if entity_or_type is entity %} + {% set type = entity_type(entity_or_type) %} + {% else %} + {% set type = entity_or_type %} + {% endif %} + + {% if type is not null and map[type] is defined %} + {% set icon = map[type][0] %} + {% set label = map[type][1] %} + {% else %} + {% set icon = "fa-solid fa-question" %} + {% set label = "Unknown type " ~ type %} + {% endif %} + + +{% endmacro %} + +{% macro breadcrumb_entity_link(entity, link_type = "list_parts", icon = "") %} + {% endmacro %} {% macro bool_icon(bool) %} From 9a7e47863bb2c4c928de00f2c548dc507a5b07fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 5 Sep 2022 17:02:57 +0200 Subject: [PATCH 29/64] Added autocomplete for part parameters --- .../elements/tagsinput_controller.js | 1 - .../parameters_autocomplete_controller.js | 95 ++++++++++++++ src/Controller/TypeaheadController.php | 65 ++++++++++ src/Entity/Parameters/AbstractParameter.php | 2 +- .../Parameters/AttachmentTypeParameter.php | 2 +- src/Entity/Parameters/CategoryParameter.php | 2 +- src/Entity/Parameters/CurrencyParameter.php | 2 +- src/Entity/Parameters/DeviceParameter.php | 2 +- src/Entity/Parameters/FootprintParameter.php | 2 +- src/Entity/Parameters/GroupParameter.php | 2 +- .../Parameters/ManufacturerParameter.php | 2 +- .../Parameters/MeasurementUnitParameter.php | 2 +- src/Entity/Parameters/PartParameter.php | 2 +- .../Parameters/StorelocationParameter.php | 2 +- src/Entity/Parameters/SupplierParameter.php | 2 +- src/Repository/ParameterRepository.php | 33 +++++ src/Services/TagFinder.php | 117 ------------------ .../Parts/edit/edit_form_styles.html.twig | 8 +- 18 files changed, 209 insertions(+), 134 deletions(-) create mode 100644 assets/controllers/pages/parameters_autocomplete_controller.js create mode 100644 src/Repository/ParameterRepository.php delete mode 100644 src/Services/TagFinder.php diff --git a/assets/controllers/elements/tagsinput_controller.js b/assets/controllers/elements/tagsinput_controller.js index 4454089f..88f1626a 100644 --- a/assets/controllers/elements/tagsinput_controller.js +++ b/assets/controllers/elements/tagsinput_controller.js @@ -8,7 +8,6 @@ export default class extends Controller { _tomSelect; connect() { - let settings = { plugins: { remove_button:{ diff --git a/assets/controllers/pages/parameters_autocomplete_controller.js b/assets/controllers/pages/parameters_autocomplete_controller.js new file mode 100644 index 00000000..2d75bc57 --- /dev/null +++ b/assets/controllers/pages/parameters_autocomplete_controller.js @@ -0,0 +1,95 @@ +import {Controller} from "@hotwired/stimulus"; +import TomSelect from "tom-select"; +import katex from "katex"; +import "katex/dist/katex.css"; + +/* stimulusFetch: 'lazy' */ +export default class extends Controller +{ + static values = { + url: String, + } + + static targets = ["name", "symbol", "unit"] + + onItemAdd(value, item) { + //Retrieve the unit and symbol from the item + const symbol = item.dataset.symbol; + const unit = item.dataset.unit; + + if (this.symbolTarget && symbol !== undefined) { + this.symbolTarget.value = symbol; + } + if (this.unitTarget && unit !== undefined) { + this.unitTarget.value = unit; + } + } + + connect() { + const settings = { + plugins: { + clear_button:{} + }, + persistent: false, + maxItems: 1, + //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', + createOnBlur: true, + create: true, + searchField: "name", + //labelField: "name", + valueField: "name", + onItemAdd: this.onItemAdd.bind(this), + render: { + option: (data, escape) => { + let tmp = '
' + + '' + escape(data.name) + '
'; + + if (data.symbol) { + tmp += '' + katex.renderToString(data.symbol) + '' + } + if (data.unit) { + tmp += '' + katex.renderToString('[' + data.unit + ']') + '' + } + + + //+ '' + escape(data.unit) + '' + tmp += '
'; + + return tmp; + }, + item: (data, escape) => { + //We use the item to transfert data to the onItemAdd function using data attributes + const element = document.createElement('div'); + element.innerText = data.name; + if(data.unit !== undefined) { + element.dataset.unit = data.unit; + } + if (data.symbol !== undefined) { + element.dataset.symbol = data.symbol; + } + + return element.outerHTML; + } + } + }; + + if(this.urlValue) { + const base_url = this.urlValue; + settings.load = (query, callback) => { + const url = base_url.replace('__QUERY__', encodeURIComponent(query)); + + fetch(url) + .then(response => response.json()) + .then(json => { + //const data = json.map(x => {return {"value": x, "text": x}}); + callback(json); + }).catch(()=>{ + callback(); + }); + } + } + + this._tomSelect = new TomSelect(this.nameTarget, settings); + } +} \ No newline at end of file diff --git a/src/Controller/TypeaheadController.php b/src/Controller/TypeaheadController.php index f69f137e..77edae80 100644 --- a/src/Controller/TypeaheadController.php +++ b/src/Controller/TypeaheadController.php @@ -42,9 +42,22 @@ declare(strict_types=1); namespace App\Controller; +use App\Entity\Parameters\AttachmentTypeParameter; +use App\Entity\Parameters\CategoryParameter; +use App\Entity\Parameters\DeviceParameter; +use App\Entity\Parameters\FootprintParameter; +use App\Entity\Parameters\GroupParameter; +use App\Entity\Parameters\ManufacturerParameter; +use App\Entity\Parameters\MeasurementUnitParameter; +use App\Entity\Parameters\PartParameter; +use App\Entity\Parameters\StorelocationParameter; +use App\Entity\Parameters\SupplierParameter; +use App\Entity\PriceInformations\Currency; +use App\Repository\ParameterRepository; use App\Services\Attachments\AttachmentURLGenerator; use App\Services\Attachments\BuiltinAttachmentsFinder; use App\Services\TagFinder; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Asset\Packages; use Symfony\Component\HttpFoundation\JsonResponse; @@ -99,6 +112,58 @@ class TypeaheadController extends AbstractController return new JsonResponse($data, 200, [], true); } + /** + * This functions map the parameter type to the class, so we can access its repository + * @param string $type + * @return class-string + */ + private function typeToParameterClass(string $type): string + { + switch ($type) { + case 'category': + return CategoryParameter::class; + case 'part': + return PartParameter::class; + case 'device': + return DeviceParameter::class; + case 'footprint': + return FootprintParameter::class; + case 'manufacturer': + return ManufacturerParameter::class; + case 'storelocation': + return StorelocationParameter::class; + case 'supplier': + return SupplierParameter::class; + case 'attachment_type': + return AttachmentTypeParameter::class; + case 'group': + return GroupParameter::class; + case 'measurement_unit': + return MeasurementUnitParameter::class; + case 'currency': + return Currency::class; + + default: + throw new \InvalidArgumentException('Invalid parameter type: '.$type); + } + } + + /** + * @Route("/parameters/{type}/search/{query}", name="typeahead_parameters", requirements={"type" = ".+"}) + * @param string $query + * @return JsonResponse + */ + public function parameters(string $type, EntityManagerInterface $entityManager, string $query = ""): JsonResponse + { + $class = $this->typeToParameterClass($type); + /** @var ParameterRepository $repository */ + $repository = $entityManager->getRepository($class); + + $data = $repository->autocompleteParamName($query); + + return new JsonResponse($data); + } + /** * @Route("/tags/search/{query}", name="typeahead_tags", requirements={"query"= ".+"}) */ diff --git a/src/Entity/Parameters/AbstractParameter.php b/src/Entity/Parameters/AbstractParameter.php index ba13f623..b7427985 100644 --- a/src/Entity/Parameters/AbstractParameter.php +++ b/src/Entity/Parameters/AbstractParameter.php @@ -33,7 +33,7 @@ use Symfony\Component\Validator\Constraints as Assert; use function sprintf; /** - * @ORM\Entity() + * @ORM\Entity(repositoryClass="App\Repository\ParameterRepository") * @ORM\Table("parameters") * @ORM\InheritanceType("SINGLE_TABLE") * @ORM\DiscriminatorColumn(name="type", type="smallint") diff --git a/src/Entity/Parameters/AttachmentTypeParameter.php b/src/Entity/Parameters/AttachmentTypeParameter.php index d1ad9094..1a7e4f37 100644 --- a/src/Entity/Parameters/AttachmentTypeParameter.php +++ b/src/Entity/Parameters/AttachmentTypeParameter.php @@ -28,7 +28,7 @@ use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; /** - * @ORM\Entity() + * @ORM\Entity(repositoryClass="App\Repository\ParameterRepository") * @UniqueEntity(fields={"name", "group", "element"}) */ class AttachmentTypeParameter extends AbstractParameter diff --git a/src/Entity/Parameters/CategoryParameter.php b/src/Entity/Parameters/CategoryParameter.php index 86925755..1165f985 100644 --- a/src/Entity/Parameters/CategoryParameter.php +++ b/src/Entity/Parameters/CategoryParameter.php @@ -28,7 +28,7 @@ use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; /** - * @ORM\Entity() + * @ORM\Entity(repositoryClass="App\Repository\ParameterRepository") * @UniqueEntity(fields={"name", "group", "element"}) */ class CategoryParameter extends AbstractParameter diff --git a/src/Entity/Parameters/CurrencyParameter.php b/src/Entity/Parameters/CurrencyParameter.php index 28f3c934..8651ec13 100644 --- a/src/Entity/Parameters/CurrencyParameter.php +++ b/src/Entity/Parameters/CurrencyParameter.php @@ -30,7 +30,7 @@ use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; /** * A attachment attached to a category element. * - * @ORM\Entity() + * @ORM\Entity(repositoryClass="App\Repository\ParameterRepository") * @UniqueEntity(fields={"name", "group", "element"}) */ class CurrencyParameter extends AbstractParameter diff --git a/src/Entity/Parameters/DeviceParameter.php b/src/Entity/Parameters/DeviceParameter.php index 724ad3be..1534c84a 100644 --- a/src/Entity/Parameters/DeviceParameter.php +++ b/src/Entity/Parameters/DeviceParameter.php @@ -28,7 +28,7 @@ use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; /** - * @ORM\Entity() + * @ORM\Entity(repositoryClass="App\Repository\ParameterRepository") * @UniqueEntity(fields={"name", "group", "element"}) */ class DeviceParameter extends AbstractParameter diff --git a/src/Entity/Parameters/FootprintParameter.php b/src/Entity/Parameters/FootprintParameter.php index d92ed8cb..ef354ef1 100644 --- a/src/Entity/Parameters/FootprintParameter.php +++ b/src/Entity/Parameters/FootprintParameter.php @@ -28,7 +28,7 @@ use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; /** - * @ORM\Entity() + * @ORM\Entity(repositoryClass="App\Repository\ParameterRepository") * @UniqueEntity(fields={"name", "group", "element"}) */ class FootprintParameter extends AbstractParameter diff --git a/src/Entity/Parameters/GroupParameter.php b/src/Entity/Parameters/GroupParameter.php index c6b62aa1..361a60cd 100644 --- a/src/Entity/Parameters/GroupParameter.php +++ b/src/Entity/Parameters/GroupParameter.php @@ -28,7 +28,7 @@ use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; /** - * @ORM\Entity() + * @ORM\Entity(repositoryClass="App\Repository\ParameterRepository") * @UniqueEntity(fields={"name", "group", "element"}) */ class GroupParameter extends AbstractParameter diff --git a/src/Entity/Parameters/ManufacturerParameter.php b/src/Entity/Parameters/ManufacturerParameter.php index b06633f0..fbdf8fe6 100644 --- a/src/Entity/Parameters/ManufacturerParameter.php +++ b/src/Entity/Parameters/ManufacturerParameter.php @@ -28,7 +28,7 @@ use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; /** - * @ORM\Entity() + * @ORM\Entity(repositoryClass="App\Repository\ParameterRepository") * @UniqueEntity(fields={"name", "group", "element"}) */ class ManufacturerParameter extends AbstractParameter diff --git a/src/Entity/Parameters/MeasurementUnitParameter.php b/src/Entity/Parameters/MeasurementUnitParameter.php index 91ce5809..52af7a13 100644 --- a/src/Entity/Parameters/MeasurementUnitParameter.php +++ b/src/Entity/Parameters/MeasurementUnitParameter.php @@ -28,7 +28,7 @@ use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; /** - * @ORM\Entity() + * @ORM\Entity(repositoryClass="App\Repository\ParameterRepository") * @UniqueEntity(fields={"name", "group", "element"}) */ class MeasurementUnitParameter extends AbstractParameter diff --git a/src/Entity/Parameters/PartParameter.php b/src/Entity/Parameters/PartParameter.php index d7d08cfa..7f30cd02 100644 --- a/src/Entity/Parameters/PartParameter.php +++ b/src/Entity/Parameters/PartParameter.php @@ -28,7 +28,7 @@ use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; /** - * @ORM\Entity() + * @ORM\Entity(repositoryClass="App\Repository\ParameterRepository") * @UniqueEntity(fields={"name", "group", "element"}) */ class PartParameter extends AbstractParameter diff --git a/src/Entity/Parameters/StorelocationParameter.php b/src/Entity/Parameters/StorelocationParameter.php index 44077d48..76c209a6 100644 --- a/src/Entity/Parameters/StorelocationParameter.php +++ b/src/Entity/Parameters/StorelocationParameter.php @@ -28,7 +28,7 @@ use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; /** - * @ORM\Entity() + * @ORM\Entity(repositoryClass="App\Repository\ParameterRepository") * @UniqueEntity(fields={"name", "group", "element"}) */ class StorelocationParameter extends AbstractParameter diff --git a/src/Entity/Parameters/SupplierParameter.php b/src/Entity/Parameters/SupplierParameter.php index e61137a0..3bc42a56 100644 --- a/src/Entity/Parameters/SupplierParameter.php +++ b/src/Entity/Parameters/SupplierParameter.php @@ -28,7 +28,7 @@ use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; /** - * @ORM\Entity() + * @ORM\Entity(repositoryClass="App\Repository\ParameterRepository") * @UniqueEntity(fields={"name", "group", "element"}) */ class SupplierParameter extends AbstractParameter diff --git a/src/Repository/ParameterRepository.php b/src/Repository/ParameterRepository.php new file mode 100644 index 00000000..36ee2ff8 --- /dev/null +++ b/src/Repository/ParameterRepository.php @@ -0,0 +1,33 @@ +createQueryBuilder('parameter'); + + $qb->distinct() + ->select('parameter.name') + ->addSelect('parameter.symbol') + ->addSelect('parameter.unit') + ->where('parameter.name LIKE :name'); + if ($exact) { + $qb->setParameter('name', $name); + } else { + $qb->setParameter('name', '%'.$name.'%'); + } + + $qb->setMaxResults($max_results); + + return $qb->getQuery()->getArrayResult(); + } +} \ No newline at end of file diff --git a/src/Services/TagFinder.php b/src/Services/TagFinder.php deleted file mode 100644 index 4038bc34..00000000 --- a/src/Services/TagFinder.php +++ /dev/null @@ -1,117 +0,0 @@ -. - */ - -declare(strict_types=1); - -/** - * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). - * - * Copyright (C) 2019 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 General Public License - * as published by the Free Software Foundation; either version 2 - * 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 General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA - */ - -namespace App\Services; - -use App\Entity\Parts\Part; -use Doctrine\ORM\EntityManagerInterface; -use Symfony\Component\OptionsResolver\OptionsResolver; - -use function array_slice; - -/** - * A service related for searching for tags. Mostly useful for autocomplete reasons. - */ -class TagFinder -{ - protected $em; - - public function __construct(EntityManagerInterface $entityManager) - { - $this->em = $entityManager; - } - - /** - * Search tags that begins with the certain keyword. - * - * @param string $keyword The keyword the tag must begin with - * @param array $options Some options specifying the search behavior. See configureOptions for possible options. - * - * @return string[] an array containing the tags that match the given keyword - */ - public function searchTags(string $keyword, array $options = []): array - { - $results = []; - $keyword_regex = '/^'.preg_quote($keyword, '/').'/'; - - $resolver = new OptionsResolver(); - $this->configureOptions($resolver); - - $options = $resolver->resolve($options); - - //If the keyword is too short we will get to much results, which takes too much time... - if (mb_strlen($keyword) < $options['min_keyword_length']) { - return []; - } - - //Build a query to get all - $qb = $this->em->createQueryBuilder(); - - $qb->select('p.tags') - ->from(Part::class, 'p') - ->where('p.tags LIKE ?1') - ->setMaxResults($options['query_limit']) - //->orderBy('RAND()') - ->setParameter(1, '%'.$keyword.'%'); - - $possible_tags = $qb->getQuery()->getArrayResult(); - - //Iterate over each possible tags (which are comma separated) and extract tags which match our keyword - foreach ($possible_tags as $tags) { - $tags = explode(',', $tags['tags']); - $results = array_merge($results, preg_grep($keyword_regex, $tags)); - } - - $results = array_unique($results); - //Limit the returned tag count to specified value. - return array_slice($results, 0, $options['return_limit']); - } - - protected function configureOptions(OptionsResolver $resolver): void - { - $resolver->setDefaults([ - 'query_limit' => 75, - 'return_limit' => 75, - 'min_keyword_length' => 2, - ]); - } -} diff --git a/templates/Parts/edit/edit_form_styles.html.twig b/templates/Parts/edit/edit_form_styles.html.twig index 8fef44b4..003be938 100644 --- a/templates/Parts/edit/edit_form_styles.html.twig +++ b/templates/Parts/edit/edit_form_styles.html.twig @@ -68,13 +68,13 @@ {% block parameter_widget %} {% import 'components/collection_type.macro.html.twig' as collection %} - - {{ form_widget(form.name) }}{{ form_errors(form.name) }} - {{ form_widget(form.symbol) }}{{ form_errors(form.symbol) }} + + {{ form_widget(form.name, {"attr": {"data-pages--parameters-autocomplete-target": "name"}}) }}{{ form_errors(form.name) }} + {{ form_widget(form.symbol, {"attr": {"data-pages--parameters-autocomplete-target": "symbol"}}) }}{{ form_errors(form.symbol) }} {{ form_widget(form.value_min) }}{{ form_errors(form.value_min) }} {{ form_widget(form.value_typical) }}{{ form_errors(form.value_typical) }} {{ form_widget(form.value_max) }}{{ form_errors(form.value_max) }} - {{ form_widget(form.unit) }}{{ form_errors(form.unit) }} + {{ form_widget(form.unit, {"attr": {"data-pages--parameters-autocomplete-target": "unit"}}) }}{{ form_errors(form.unit) }} {{ form_widget(form.value_text) }}{{ form_errors(form.value_text) }} {{ form_widget(form.group) }}{{ form_errors(form.group) }} From 34053f6591745a9801b19e5fa88d92c25b33f344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 5 Sep 2022 17:20:36 +0200 Subject: [PATCH 30/64] Added a preview for latex rendered unit and symbol to parameters --- .../pages/latex_preview_controller.js | 21 +++++++++++++++++++ .../Parts/edit/edit_form_styles.html.twig | 4 ++-- 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 assets/controllers/pages/latex_preview_controller.js diff --git a/assets/controllers/pages/latex_preview_controller.js b/assets/controllers/pages/latex_preview_controller.js new file mode 100644 index 00000000..8c1279f1 --- /dev/null +++ b/assets/controllers/pages/latex_preview_controller.js @@ -0,0 +1,21 @@ +import {Controller} from "@hotwired/stimulus"; +import katex from "katex"; +import "katex/dist/katex.css"; + +/* stimulusFetch: 'lazy' */ +export default class extends Controller { + static targets = ["input", "preview"]; + + updatePreview() + { + katex.render(this.inputTarget.value, this.previewTarget, { + throwOnError: false, + }); + } + + connect() + { + this.updatePreview(); + this.inputTarget.addEventListener('input', this.updatePreview.bind(this)); + } +} \ No newline at end of file diff --git a/templates/Parts/edit/edit_form_styles.html.twig b/templates/Parts/edit/edit_form_styles.html.twig index 003be938..77439f20 100644 --- a/templates/Parts/edit/edit_form_styles.html.twig +++ b/templates/Parts/edit/edit_form_styles.html.twig @@ -70,11 +70,11 @@ {% import 'components/collection_type.macro.html.twig' as collection %} {{ form_widget(form.name, {"attr": {"data-pages--parameters-autocomplete-target": "name"}}) }}{{ form_errors(form.name) }} - {{ form_widget(form.symbol, {"attr": {"data-pages--parameters-autocomplete-target": "symbol"}}) }}{{ form_errors(form.symbol) }} + {{ form_widget(form.symbol, {"attr": {"data-pages--parameters-autocomplete-target": "symbol", "data-pages--latex-preview-target": "input"}}) }}{{ form_errors(form.symbol) }} {{ form_widget(form.value_min) }}{{ form_errors(form.value_min) }} {{ form_widget(form.value_typical) }}{{ form_errors(form.value_typical) }} {{ form_widget(form.value_max) }}{{ form_errors(form.value_max) }} - {{ form_widget(form.unit, {"attr": {"data-pages--parameters-autocomplete-target": "unit"}}) }}{{ form_errors(form.unit) }} + {{ form_widget(form.unit, {"attr": {"data-pages--parameters-autocomplete-target": "unit", "data-pages--latex-preview-target": "input"}}) }}{{ form_errors(form.unit) }} {{ form_widget(form.value_text) }}{{ form_errors(form.value_text) }} {{ form_widget(form.group) }}{{ form_errors(form.group) }} From 4d78f8d4e869ea2afe38a6deadf282b0c48d3cf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 5 Sep 2022 17:38:47 +0200 Subject: [PATCH 31/64] Use the correct autocomplete type for non-part entities. --- src/Form/ParameterType.php | 39 +++++++++++++++++++ .../Parts/edit/edit_form_styles.html.twig | 2 +- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/Form/ParameterType.php b/src/Form/ParameterType.php index 413df3e4..e8e6db1c 100644 --- a/src/Form/ParameterType.php +++ b/src/Form/ParameterType.php @@ -24,10 +24,23 @@ declare(strict_types=1); namespace App\Form; use App\Entity\Parameters\AbstractParameter; +use App\Entity\Parameters\AttachmentTypeParameter; +use App\Entity\Parameters\CategoryParameter; +use App\Entity\Parameters\CurrencyParameter; +use App\Entity\Parameters\DeviceParameter; +use App\Entity\Parameters\FootprintParameter; +use App\Entity\Parameters\GroupParameter; +use App\Entity\Parameters\ManufacturerParameter; +use App\Entity\Parameters\PartParameter; +use App\Entity\Parameters\StorelocationParameter; +use App\Entity\Parameters\SupplierParameter; +use App\Entity\Parts\MeasurementUnit; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\NumberType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; use Symfony\Component\OptionsResolver\OptionsResolver; class ParameterType extends AbstractType @@ -117,6 +130,32 @@ class ParameterType extends AbstractType ]); } + public function finishView(FormView $view, FormInterface $form, array $options) + { + //By default use part parameters for autocomplete + $view->vars['type'] = 'part'; + + $map = [ + PartParameter::class => 'part', + AttachmentTypeParameter::class => 'attachment_type', + CategoryParameter::class => 'category', + CurrencyParameter::class => 'currency', + DeviceParameter::class => 'device', + FootprintParameter::class => 'footprint', + GroupParameter::class => 'group', + ManufacturerParameter::class => 'manufacturer', + MeasurementUnit::class => 'measurement_unit', + StorelocationParameter::class => 'storelocation', + SupplierParameter::class => 'supplier', + ]; + + if (isset($map[$options['data_class']])) { + $view->vars['type'] = $map[$options['data_class']]; + } + + parent::finishView($view, $form, $options); // TODO: Change the autogenerated stub + } + public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ diff --git a/templates/Parts/edit/edit_form_styles.html.twig b/templates/Parts/edit/edit_form_styles.html.twig index 77439f20..47b66476 100644 --- a/templates/Parts/edit/edit_form_styles.html.twig +++ b/templates/Parts/edit/edit_form_styles.html.twig @@ -68,7 +68,7 @@ {% block parameter_widget %} {% import 'components/collection_type.macro.html.twig' as collection %} - + {{ form_widget(form.name, {"attr": {"data-pages--parameters-autocomplete-target": "name"}}) }}{{ form_errors(form.name) }} {{ form_widget(form.symbol, {"attr": {"data-pages--parameters-autocomplete-target": "symbol", "data-pages--latex-preview-target": "input"}}) }}{{ form_errors(form.symbol) }} {{ form_widget(form.value_min) }}{{ form_errors(form.value_min) }} From 9ed953d1b257cf68e3de703f68b52a3c1b7673d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Tue, 6 Sep 2022 00:25:02 +0200 Subject: [PATCH 32/64] Implemented the basics for a parametric search --- .../elements/collection_type_controller.js | 22 ++-- .../Filters/CompoundFilterTrait.php | 10 ++ .../Constraints/Part/ParameterConstraint.php | 116 ++++++++++++++++++ src/DataTables/Filters/PartFilter.php | 16 +++ .../Constraints/ParameterConstraintType.php | 37 ++++++ src/Form/Filters/PartFilterType.php | 10 ++ templates/Form/FilterTypesLayout.html.twig | 17 +++ templates/Parts/lists/_filter.html.twig | 32 +++++ 8 files changed, 253 insertions(+), 7 deletions(-) create mode 100644 src/DataTables/Filters/Constraints/Part/ParameterConstraint.php create mode 100644 src/Form/Filters/Constraints/ParameterConstraintType.php diff --git a/assets/controllers/elements/collection_type_controller.js b/assets/controllers/elements/collection_type_controller.js index 4c804534..607984a2 100644 --- a/assets/controllers/elements/collection_type_controller.js +++ b/assets/controllers/elements/collection_type_controller.js @@ -103,12 +103,20 @@ export default class extends Controller { } deleteElement(event) { - bootbox.confirm(this.deleteMessageValue, (result) => { - if(result) { - const target = event.target; - //Remove the row element from the table - target.closest("tr").remove(); - } - }); + const del = () => { + const target = event.target; + //Remove the row element from the table + target.closest("tr").remove(); + } + + if(this.deleteMessageValue) { + bootbox.confirm(this.deleteMessageValue, (result) => { + if (result) { + del(); + } + }); + } else { + del(); + } } } \ No newline at end of file diff --git a/src/DataTables/Filters/CompoundFilterTrait.php b/src/DataTables/Filters/CompoundFilterTrait.php index 70536512..d39257fa 100644 --- a/src/DataTables/Filters/CompoundFilterTrait.php +++ b/src/DataTables/Filters/CompoundFilterTrait.php @@ -2,6 +2,7 @@ namespace App\DataTables\Filters; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\QueryBuilder; trait CompoundFilterTrait @@ -23,6 +24,15 @@ trait CompoundFilterTrait if($value instanceof FilterInterface) { $filters[$property->getName()] = $value; } + + //Add filters in collections + if ($value instanceof Collection) { + foreach ($value as $key => $filter) { + if($filter instanceof FilterInterface) { + $filters[$property->getName() . '.' . (string) $key] = $filter; + } + } + } } return $filters; } diff --git a/src/DataTables/Filters/Constraints/Part/ParameterConstraint.php b/src/DataTables/Filters/Constraints/Part/ParameterConstraint.php new file mode 100644 index 00000000..318cc3d4 --- /dev/null +++ b/src/DataTables/Filters/Constraints/Part/ParameterConstraint.php @@ -0,0 +1,116 @@ +getEntityManager()); + + //The alias has to be uniq for each subquery, so generate a random one + $alias = uniqid('param_', false); + + $subqb->select('COUNT(' . $alias . ')') + ->from(PartParameter::class, $alias) + ->where($alias . '.element = part'); + + if (!empty($this->name)) { + $paramName = $this->generateParameterIdentifier('params.name'); + $subqb->andWhere($alias . '.name = :' . $paramName); + $queryBuilder->setParameter($paramName, $this->name); + } + + if (!empty($this->symbol)) { + $paramName = $this->generateParameterIdentifier('params.symbol'); + $subqb->andWhere($alias . '.symbol = :' . $paramName); + $queryBuilder->setParameter($paramName, $this->symbol); + } + + if (!empty($this->unit)) { + $paramName = $this->generateParameterIdentifier('params.unit'); + $subqb->andWhere($alias . '.unit = :' . $paramName); + $queryBuilder->setParameter($paramName, $this->unit); + } + + $queryBuilder->andWhere('(' . $subqb->getDQL() . ') > 0'); + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * @param string $name + * @return ParameterConstraint + */ + public function setName(string $name): ParameterConstraint + { + $this->name = $name; + return $this; + } + + /** + * @return string + */ + public function getSymbol(): string + { + return $this->symbol; + } + + /** + * @param string $symbol + * @return ParameterConstraint + */ + public function setSymbol(string $symbol): ParameterConstraint + { + $this->symbol = $symbol; + return $this; + } + + /** + * @return string + */ + public function getUnit(): string + { + return $this->unit; + } + + /** + * @param string $unit + * @return ParameterConstraint + */ + public function setUnit(string $unit): ParameterConstraint + { + $this->unit = $unit; + return $this; + } +} \ No newline at end of file diff --git a/src/DataTables/Filters/PartFilter.php b/src/DataTables/Filters/PartFilter.php index df5c976b..1e6a33a3 100644 --- a/src/DataTables/Filters/PartFilter.php +++ b/src/DataTables/Filters/PartFilter.php @@ -8,6 +8,7 @@ use App\DataTables\Filters\Constraints\DateTimeConstraint; use App\DataTables\Filters\Constraints\EntityConstraint; use App\DataTables\Filters\Constraints\IntConstraint; use App\DataTables\Filters\Constraints\NumberConstraint; +use App\DataTables\Filters\Constraints\Part\ParameterConstraint; use App\DataTables\Filters\Constraints\Part\TagsConstraint; use App\DataTables\Filters\Constraints\TextConstraint; use App\Entity\Attachments\AttachmentType; @@ -18,6 +19,7 @@ use App\Entity\Parts\MeasurementUnit; use App\Entity\Parts\Storelocation; use App\Entity\Parts\Supplier; use App\Services\Trees\NodesListBuilder; +use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\QueryBuilder; class PartFilter implements FilterInterface @@ -112,6 +114,9 @@ class PartFilter implements FilterInterface /** @var TextConstraint */ protected $attachmentName; + /** @var ArrayCollection */ + protected $parameters; + public function __construct(NodesListBuilder $nodesListBuilder) { $this->name = new TextConstraint('part.name'); @@ -153,6 +158,8 @@ class PartFilter implements FilterInterface $this->attachmentName = new TextConstraint('attachments.name'); $this->orderdetailsCount = new IntConstraint('COUNT(orderdetails)'); + + $this->parameters = new ArrayCollection(); } public function apply(QueryBuilder $queryBuilder): void @@ -372,5 +379,14 @@ class PartFilter implements FilterInterface return $this->amountSum; } + /** + * @return ArrayCollection + */ + public function getParameters(): ArrayCollection + { + return $this->parameters; + } + + } diff --git a/src/Form/Filters/Constraints/ParameterConstraintType.php b/src/Form/Filters/Constraints/ParameterConstraintType.php new file mode 100644 index 00000000..39a2ca9e --- /dev/null +++ b/src/Form/Filters/Constraints/ParameterConstraintType.php @@ -0,0 +1,37 @@ +setDefaults([ + 'compound' => true, + 'data_class' => ParameterConstraint::class, + ]); + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('name', TextType::class, [ + 'required' => false, + ]); + + $builder->add('unit', SearchType::class, [ + 'required' => false, + ]); + + $builder->add('symbol', SearchType::class, [ + 'required' => false + ]); + } +} \ No newline at end of file diff --git a/src/Form/Filters/PartFilterType.php b/src/Form/Filters/PartFilterType.php index b3d491ab..0583715e 100644 --- a/src/Form/Filters/PartFilterType.php +++ b/src/Form/Filters/PartFilterType.php @@ -13,11 +13,13 @@ use App\Form\Filters\Constraints\BooleanConstraintType; use App\Form\Filters\Constraints\ChoiceConstraintType; use App\Form\Filters\Constraints\DateTimeConstraintType; use App\Form\Filters\Constraints\NumberConstraintType; +use App\Form\Filters\Constraints\ParameterConstraintType; use App\Form\Filters\Constraints\StructuralEntityConstraintType; use App\Form\Filters\Constraints\TagsConstraintType; use App\Form\Filters\Constraints\TextConstraintType; use Svg\Tag\Text; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\CollectionType; use Symfony\Component\Form\Extension\Core\Type\DateTimeType; use Symfony\Component\Form\Extension\Core\Type\DateType; use Symfony\Component\Form\Extension\Core\Type\ResetType; @@ -209,6 +211,14 @@ class PartFilterType extends AbstractType 'label' => 'part.filter.attachmentName', ]); + $builder->add('parameters', CollectionType::class, [ + 'label' => 'parameter.label', + 'entry_type' => ParameterConstraintType::class, + 'allow_delete' => true, + 'allow_add' => true, + 'reindex_enable' => false, + ]); + $builder->add('submit', SubmitType::class, [ 'label' => 'filter.submit', ]); diff --git a/templates/Form/FilterTypesLayout.html.twig b/templates/Form/FilterTypesLayout.html.twig index 0b2dfa17..a84ca51e 100644 --- a/templates/Form/FilterTypesLayout.html.twig +++ b/templates/Form/FilterTypesLayout.html.twig @@ -38,4 +38,21 @@ {% block choice_constraint_widget %} {{ block('text_constraint_widget') }} +{% endblock %} + +{% block parameter_constraint_widget %} + {% import 'components/collection_type.macro.html.twig' as collection %} + + {{ form_widget(form.name, {"attr": {"data-pages--parameters-autocomplete-target": "name"}}) }} + {{ form_widget(form.symbol, {"attr": {"data-pages--parameters-autocomplete-target": "symbol", "data-pages--latex-preview-target": "input"}}) }} + + {{ form_widget(form.unit, {"attr": {"data-pages--parameters-autocomplete-target": "unit", "data-pages--latex-preview-target": "input"}}) }} + + + + {{ form_errors(form) }} + + {% endblock %} \ No newline at end of file diff --git a/templates/Parts/lists/_filter.html.twig b/templates/Parts/lists/_filter.html.twig index aa2eb474..9e48cd05 100644 --- a/templates/Parts/lists/_filter.html.twig +++ b/templates/Parts/lists/_filter.html.twig @@ -20,6 +20,9 @@ + {{ form_start(filterForm, {"attr": {"data-controller": "helpers--form-cleanup", "data-action": "helpers--form-cleanup#submit"}}) }} @@ -72,6 +75,35 @@ {{ form_row(filterForm.orderdetailsCount) }}
+
+ {% import 'components/collection_type.macro.html.twig' as collection %} + +
+ + + + + + + + + + + + + {% for param in filterForm.parameters %} + {{ form_widget(param) }} + {% endfor %} + +
{% trans %}specifications.property{% endtrans %}{% trans %}specifications.symbol{% endtrans %}{% trans %}specifications.value{% endtrans %}{% trans %}specifications.unit{% endtrans %}{% trans %}specifications.text{% endtrans %}
+ + +
+
+
From bc0365fe1636891f72d5635eff7a36ea26a3e9df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Wed, 7 Sep 2022 21:43:01 +0200 Subject: [PATCH 33/64] Allow to filter parameters based on their text value --- .../Constraints/Part/ParameterConstraint.php | 57 ++++++++++++++++--- .../Constraints/ParameterConstraintType.php | 24 +++++++- src/Form/Filters/PartFilterType.php | 5 ++ templates/Form/FilterTypesLayout.html.twig | 2 +- 4 files changed, 78 insertions(+), 10 deletions(-) diff --git a/src/DataTables/Filters/Constraints/Part/ParameterConstraint.php b/src/DataTables/Filters/Constraints/Part/ParameterConstraint.php index 318cc3d4..288eff88 100644 --- a/src/DataTables/Filters/Constraints/Part/ParameterConstraint.php +++ b/src/DataTables/Filters/Constraints/Part/ParameterConstraint.php @@ -3,8 +3,10 @@ namespace App\DataTables\Filters\Constraints\Part; use App\DataTables\Filters\Constraints\AbstractConstraint; +use App\DataTables\Filters\Constraints\TextConstraint; use App\Entity\Parameters\PartParameter; use Doctrine\ORM\QueryBuilder; +use Svg\Tag\Text; class ParameterConstraint extends AbstractConstraint { @@ -17,9 +19,20 @@ class ParameterConstraint extends AbstractConstraint /** @var string */ protected $unit; + /** @var TextConstraint */ + protected $value_text; + + /** @var string The alias to use for the subquery */ + protected $alias; + public function __construct() { parent::__construct("parts.parameters"); + + //The alias has to be uniq for each subquery, so generate a random one + $this->alias = uniqid('param_', false); + + $this->value_text = new TextConstraint($this->alias . '.value_text'); } public function isEnabled(): bool @@ -32,31 +45,39 @@ class ParameterConstraint extends AbstractConstraint //Create a new qb to build the subquery $subqb = new QueryBuilder($queryBuilder->getEntityManager()); - //The alias has to be uniq for each subquery, so generate a random one - $alias = uniqid('param_', false); - $subqb->select('COUNT(' . $alias . ')') - ->from(PartParameter::class, $alias) - ->where($alias . '.element = part'); + + $subqb->select('COUNT(' . $this->alias . ')') + ->from(PartParameter::class, $this->alias) + ->where($this->alias . '.element = part'); if (!empty($this->name)) { $paramName = $this->generateParameterIdentifier('params.name'); - $subqb->andWhere($alias . '.name = :' . $paramName); + $subqb->andWhere($this->alias . '.name = :' . $paramName); $queryBuilder->setParameter($paramName, $this->name); } if (!empty($this->symbol)) { $paramName = $this->generateParameterIdentifier('params.symbol'); - $subqb->andWhere($alias . '.symbol = :' . $paramName); + $subqb->andWhere($this->alias . '.symbol = :' . $paramName); $queryBuilder->setParameter($paramName, $this->symbol); } if (!empty($this->unit)) { $paramName = $this->generateParameterIdentifier('params.unit'); - $subqb->andWhere($alias . '.unit = :' . $paramName); + $subqb->andWhere($this->alias . '.unit = :' . $paramName); $queryBuilder->setParameter($paramName, $this->unit); } + //Apply all subfilters + $this->value_text->apply($subqb); + + //Copy all parameters from the subquery to the main query + //We can not use setParameters here, as this would override the exiting paramaters in queryBuilder + foreach ($subqb->getParameters() as $parameter) { + $queryBuilder->setParameter($parameter->getName(), $parameter->getValue()); + } + $queryBuilder->andWhere('(' . $subqb->getDQL() . ') > 0'); } @@ -113,4 +134,24 @@ class ParameterConstraint extends AbstractConstraint $this->unit = $unit; return $this; } + + /** + * @return TextConstraint + */ + public function getValueText(): TextConstraint + { + return $this->value_text; + } + + /** + * DO NOT USE THIS SETTER! + * This is just a workaround for collectionType behavior + * @param $value + * @return $this + */ + /*public function setValueText($value): self + { + //Do not really set the value here, as we dont want to override the constraint created in the constructor + return $this; + }*/ } \ No newline at end of file diff --git a/src/Form/Filters/Constraints/ParameterConstraintType.php b/src/Form/Filters/Constraints/ParameterConstraintType.php index 39a2ca9e..f5596bfe 100644 --- a/src/Form/Filters/Constraints/ParameterConstraintType.php +++ b/src/Form/Filters/Constraints/ParameterConstraintType.php @@ -8,6 +8,8 @@ use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\SearchType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; use Symfony\Component\OptionsResolver\OptionsResolver; class ParameterConstraintType extends AbstractType @@ -17,6 +19,7 @@ class ParameterConstraintType extends AbstractType $resolver->setDefaults([ 'compound' => true, 'data_class' => ParameterConstraint::class, + 'empty_data' => new ParameterConstraint(), ]); } @@ -31,7 +34,26 @@ class ParameterConstraintType extends AbstractType ]); $builder->add('symbol', SearchType::class, [ - 'required' => false + 'required' => false ]); + + $builder->add('value_text', TextConstraintType::class, [ + //'required' => false, + ] ); + + /* + * I am not quite sure why this is needed, but somehow symfony tries to create a new instance of TextConstraint + * instead of using the existing one for the prototype (or the one from empty data). This fails as the constructor of TextConstraint requires + * arguments. + * Ensure that the data is never null, but use an empty ParameterConstraint instead + */ + $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) { + $form = $event->getForm(); + $data = $event->getData(); + + if ($data === null) { + $event->setData(new ParameterConstraint()); + } + }); } } \ No newline at end of file diff --git a/src/Form/Filters/PartFilterType.php b/src/Form/Filters/PartFilterType.php index 0583715e..84d3137f 100644 --- a/src/Form/Filters/PartFilterType.php +++ b/src/Form/Filters/PartFilterType.php @@ -2,6 +2,7 @@ namespace App\Form\Filters; +use App\DataTables\Filters\Constraints\Part\ParameterConstraint; use App\DataTables\Filters\PartFilter; use App\Entity\Attachments\AttachmentType; use App\Entity\Parts\Category; @@ -211,12 +212,16 @@ class PartFilterType extends AbstractType 'label' => 'part.filter.attachmentName', ]); + $constraint_prototype = new ParameterConstraint(); + $builder->add('parameters', CollectionType::class, [ 'label' => 'parameter.label', 'entry_type' => ParameterConstraintType::class, 'allow_delete' => true, 'allow_add' => true, 'reindex_enable' => false, + 'prototype_data' => $constraint_prototype, + 'empty_data' => $constraint_prototype, ]); $builder->add('submit', SubmitType::class, [ diff --git a/templates/Form/FilterTypesLayout.html.twig b/templates/Form/FilterTypesLayout.html.twig index a84ca51e..55292570 100644 --- a/templates/Form/FilterTypesLayout.html.twig +++ b/templates/Form/FilterTypesLayout.html.twig @@ -47,7 +47,7 @@ {{ form_widget(form.symbol, {"attr": {"data-pages--parameters-autocomplete-target": "symbol", "data-pages--latex-preview-target": "input"}}) }} {{ form_widget(form.unit, {"attr": {"data-pages--parameters-autocomplete-target": "unit", "data-pages--latex-preview-target": "input"}}) }} - + {{ form_widget(form.value_text) }}
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index f0945934..f80ac072 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -9633,5 +9633,41 @@ Element 3 Range intersects Value range + + + filter.text_constraint.value + No value set + + + + + filter.number_constraint.value1 + No value set + + + + + filter.number_constraint.value2 + Maximum value + + + + + filter.datetime_constraint.value1 + No datetime set + + + + + filter.datetime_constraint.value2 + Maximum datetime + + + + + filter.constraint.add + Add constraint + + From 28c09eb51d20882c51f90eb056b7929cb1d052bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Thu, 8 Sep 2022 00:31:18 +0200 Subject: [PATCH 40/64] Added some more filter possibilities. --- src/DataTables/Filters/PartFilter.php | 41 +++++++++++++++++++++++-- src/DataTables/PartsDataTable.php | 1 + src/Form/Filters/PartFilterType.php | 15 ++++++++- templates/Parts/lists/_filter.html.twig | 3 ++ translations/messages.en.xlf | 12 ++++++++ 5 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/DataTables/Filters/PartFilter.php b/src/DataTables/Filters/PartFilter.php index 1e6a33a3..2d3e6087 100644 --- a/src/DataTables/Filters/PartFilter.php +++ b/src/DataTables/Filters/PartFilter.php @@ -21,6 +21,7 @@ use App\Entity\Parts\Supplier; use App\Services\Trees\NodesListBuilder; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\QueryBuilder; +use Svg\Tag\Text; class PartFilter implements FilterInterface { @@ -78,6 +79,9 @@ class PartFilter implements FilterInterface /** @var IntConstraint */ protected $orderdetailsCount; + /** @var BooleanConstraint */ + protected $obsolete; + /** @var EntityConstraint */ protected $storelocation; @@ -90,6 +94,9 @@ class PartFilter implements FilterInterface /** @var BooleanConstraint */ protected $lotNeedsRefill; + /** @var TextConstraint */ + protected $lotDescription; + /** @var BooleanConstraint */ protected $lotUnknownAmount; @@ -117,6 +124,9 @@ class PartFilter implements FilterInterface /** @var ArrayCollection */ protected $parameters; + /** @var IntConstraint */ + protected $parametersCount; + public function __construct(NodesListBuilder $nodesListBuilder) { $this->name = new TextConstraint('part.name'); @@ -141,25 +151,28 @@ class PartFilter implements FilterInterface */ $this->amountSum = new IntConstraint('amountSum'); $this->lotCount = new IntConstraint('COUNT(partLots)'); - $this->supplier = new EntityConstraint($nodesListBuilder, Supplier::class, 'orderdetails.supplier'); + + $this->storelocation = new EntityConstraint($nodesListBuilder, Storelocation::class, 'partLots.storage_location'); $this->lotNeedsRefill = new BooleanConstraint('partLots.needs_refill'); $this->lotUnknownAmount = new BooleanConstraint('partLots.instock_unknown'); $this->lotExpirationDate = new DateTimeConstraint('partLots.expiration_date'); + $this->lotDescription = new TextConstraint('partLots.description'); $this->manufacturer = new EntityConstraint($nodesListBuilder, Manufacturer::class, 'part.manufacturer'); $this->manufacturer_product_number = new TextConstraint('part.manufacturer_product_number'); $this->manufacturer_product_url = new TextConstraint('part.manufacturer_product_url'); $this->manufacturing_status = new ChoiceConstraint('part.manufacturing_status'); - $this->storelocation = new EntityConstraint($nodesListBuilder, Storelocation::class, 'partLots.storage_location'); - $this->attachmentsCount = new IntConstraint('COUNT(attachments)'); $this->attachmentType = new EntityConstraint($nodesListBuilder, AttachmentType::class, 'attachments.attachment_type'); $this->attachmentName = new TextConstraint('attachments.name'); + $this->supplier = new EntityConstraint($nodesListBuilder, Supplier::class, 'orderdetails.supplier'); $this->orderdetailsCount = new IntConstraint('COUNT(orderdetails)'); + $this->obsolete = new BooleanConstraint('orderdetails.obsolete'); $this->parameters = new ArrayCollection(); + $this->parametersCount = new IntConstraint('COUNT(parameters)'); } public function apply(QueryBuilder $queryBuilder): void @@ -387,6 +400,28 @@ class PartFilter implements FilterInterface return $this->parameters; } + public function getParametersCount(): IntConstraint + { + return $this->parametersCount; + } + + /** + * @return TextConstraint + */ + public function getLotDescription(): TextConstraint + { + return $this->lotDescription; + } + + /** + * @return BooleanConstraint + */ + public function getObsolete(): BooleanConstraint + { + return $this->obsolete; + } + + } diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php index cc0fe1b2..53150ab3 100644 --- a/src/DataTables/PartsDataTable.php +++ b/src/DataTables/PartsDataTable.php @@ -362,6 +362,7 @@ final class PartsDataTable implements DataTableTypeInterface ->leftJoin('orderdetails.supplier', 'suppliers') ->leftJoin('part.attachments', 'attachments') ->leftJoin('part.partUnit', 'partUnit') + ->leftJoin('part.parameters', 'parameters') ->groupBy('part') ; diff --git a/src/Form/Filters/PartFilterType.php b/src/Form/Filters/PartFilterType.php index 84d3137f..99948355 100644 --- a/src/Form/Filters/PartFilterType.php +++ b/src/Form/Filters/PartFilterType.php @@ -156,6 +156,9 @@ class PartFilterType extends AbstractType 'min' => 0, ]); + $builder->add('obsolete', BooleanConstraintType::class, [ + 'label' => 'orderdetails.edit.obsolete' + ]); /* * Stocks tabs @@ -194,6 +197,10 @@ class PartFilterType extends AbstractType 'input_type' => DateType::class, ]); + $builder->add('lotDescription', TextConstraintType::class, [ + 'label' => 'part.filter.lotDescription', + ]); + /** * Attachments count */ @@ -215,7 +222,7 @@ class PartFilterType extends AbstractType $constraint_prototype = new ParameterConstraint(); $builder->add('parameters', CollectionType::class, [ - 'label' => 'parameter.label', + 'label' => false, 'entry_type' => ParameterConstraintType::class, 'allow_delete' => true, 'allow_add' => true, @@ -224,6 +231,12 @@ class PartFilterType extends AbstractType 'empty_data' => $constraint_prototype, ]); + $builder->add('parametersCount', NumberConstraintType::class, [ + 'label' => 'part.filter.parameters_count', + 'step' => 1, + 'min' => 0, + ]); + $builder->add('submit', SubmitType::class, [ 'label' => 'filter.submit', ]); diff --git a/templates/Parts/lists/_filter.html.twig b/templates/Parts/lists/_filter.html.twig index e40af175..c7f0358c 100644 --- a/templates/Parts/lists/_filter.html.twig +++ b/templates/Parts/lists/_filter.html.twig @@ -60,6 +60,7 @@ {{ form_row(filterForm.amountSum) }} {{ form_row(filterForm.lotCount) }} {{ form_row(filterForm.lotExpirationDate) }} + {{ form_row(filterForm.lotDescription) }} {{ form_row(filterForm.lotNeedsRefill) }} {{ form_row(filterForm.lotUnknownAmount) }}
@@ -73,10 +74,12 @@
{{ form_row(filterForm.supplier) }} {{ form_row(filterForm.orderdetailsCount) }} + {{ form_row(filterForm.obsolete) }}
{% import 'components/collection_type.macro.html.twig' as collection %} + {{ form_row(filterForm.parametersCount) }}
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index f80ac072..81e2113a 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -9669,5 +9669,17 @@ Element 3Add constraint + + + part.filter.parameters_count + Number of parameters + + + + + part.filter.lotDescription + Lot description + + From b0d29eaeaf110b9949f03b43fed2450393f0efc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Thu, 8 Sep 2022 00:35:39 +0200 Subject: [PATCH 41/64] Dont change the original collection when calling getOrderdetails with $hide_obsolete = true This caused the orderdetails tab to be hidden on part info, when the orderdetail was obsolete --- src/Entity/Parts/PartTraits/OrderTrait.php | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/Entity/Parts/PartTraits/OrderTrait.php b/src/Entity/Parts/PartTraits/OrderTrait.php index 79947596..a46b1f1e 100644 --- a/src/Entity/Parts/PartTraits/OrderTrait.php +++ b/src/Entity/Parts/PartTraits/OrderTrait.php @@ -44,6 +44,7 @@ namespace App\Entity\Parts\PartTraits; use App\Entity\PriceInformations\Orderdetail; use App\Security\Annotations\ColumnSecurity; +use Doctrine\Common\Collections\ArrayCollection; use Symfony\Component\Validator\Constraints as Assert; use function count; use Doctrine\Common\Collections\Collection; @@ -129,14 +130,9 @@ trait OrderTrait { //If needed hide the obsolete entries if ($hide_obsolete) { - $orderdetails = $this->orderdetails; - foreach ($orderdetails as $key => $details) { - if ($details->getObsolete()) { - unset($orderdetails[$key]); - } - } - - return $orderdetails; + return $this->orderdetails->filter(function (Orderdetail $orderdetail) { + return ! $orderdetail->getObsolete(); + }); } return $this->orderdetails; From 3b48fc813f84243ec87e785606a42e29970601b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Thu, 8 Sep 2022 00:38:42 +0200 Subject: [PATCH 42/64] Improved styling of image attached to an structural element. --- templates/Parts/lists/_info_card.html.twig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/Parts/lists/_info_card.html.twig b/templates/Parts/lists/_info_card.html.twig index da038760..c6b097de 100644 --- a/templates/Parts/lists/_info_card.html.twig +++ b/templates/Parts/lists/_info_card.html.twig @@ -8,7 +8,7 @@
+ {% endfor %} +
{% if pictures | length > 1 %} - + + {% endif %} From b52c61bfa3bb7bcc41eaf510b2f7ac3d685d5f58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Thu, 8 Sep 2022 22:46:40 +0200 Subject: [PATCH 44/64] Put the filter menu into the accordion on part list --- templates/Parts/lists/_filter.html.twig | 230 +++++++++--------- templates/Parts/lists/_info_card.html.twig | 212 ++++++++-------- templates/Parts/lists/all_list.html.twig | 6 +- templates/Parts/lists/category_list.html.twig | 2 - 4 files changed, 229 insertions(+), 221 deletions(-) diff --git a/templates/Parts/lists/_filter.html.twig b/templates/Parts/lists/_filter.html.twig index c7f0358c..1aad0f2a 100644 --- a/templates/Parts/lists/_filter.html.twig +++ b/templates/Parts/lists/_filter.html.twig @@ -1,125 +1,129 @@ -
-
{% trans %}filter.title{% endtrans %}
-
- +
+
+ +
+
+
+ + + {{ form_start(filterForm, {"attr": {"data-controller": "helpers--form-cleanup", "data-action": "helpers--form-cleanup#submit"}}) }} + +
+
+ {{ form_row(filterForm.name) }} + {{ form_row(filterForm.description) }} + {{ form_row(filterForm.category) }} + {{ form_row(filterForm.footprint) }} + {{ form_row(filterForm.tags) }} + {{ form_row(filterForm.comment) }} +
+ +
+ {{ form_row(filterForm.manufacturer) }} + {{ form_row(filterForm.manufacturing_status) }} + {{ form_row(filterForm.manufacturer_product_number) }} + {{ form_row(filterForm.manufacturer_product_url) }} +
+ +
+ {{ form_row(filterForm.favorite) }} + {{ form_row(filterForm.needsReview) }} + {{ form_row(filterForm.measurementUnit) }} + {{ form_row(filterForm.mass) }} + {{ form_row(filterForm.dbId) }} + {{ form_row(filterForm.lastModified) }} + {{ form_row(filterForm.addedDate) }} +
+ +
+ {{ form_row(filterForm.storelocation) }} + {{ form_row(filterForm.minAmount) }} + {{ form_row(filterForm.amountSum) }} + {{ form_row(filterForm.lotCount) }} + {{ form_row(filterForm.lotExpirationDate) }} + {{ form_row(filterForm.lotDescription) }} + {{ form_row(filterForm.lotNeedsRefill) }} + {{ form_row(filterForm.lotUnknownAmount) }} +
+ +
+ {{ form_row(filterForm.attachmentsCount) }} + {{ form_row(filterForm.attachmentType) }} + {{ form_row(filterForm.attachmentName) }} +
+ +
+ {{ form_row(filterForm.supplier) }} + {{ form_row(filterForm.orderdetailsCount) }} + {{ form_row(filterForm.obsolete) }} +
+ +
+ {% import 'components/collection_type.macro.html.twig' as collection %} + {{ form_row(filterForm.parametersCount) }} + +
+
+ + + + + + + + + + + + {% for param in filterForm.parameters %} + {{ form_widget(param) }} + {% endfor %} + +
{% trans %}specifications.property{% endtrans %}{% trans %}specifications.symbol{% endtrans %}{% trans %}specifications.value{% endtrans %}{% trans %}specifications.unit{% endtrans %}{% trans %}specifications.text{% endtrans %}
+ + +
+
- {{ form_start(filterForm, {"attr": {"data-controller": "helpers--form-cleanup", "data-action": "helpers--form-cleanup#submit"}}) }} -
-
- {{ form_row(filterForm.name) }} - {{ form_row(filterForm.description) }} - {{ form_row(filterForm.category) }} - {{ form_row(filterForm.footprint) }} - {{ form_row(filterForm.tags) }} - {{ form_row(filterForm.comment) }}
-
- {{ form_row(filterForm.manufacturer) }} - {{ form_row(filterForm.manufacturing_status) }} - {{ form_row(filterForm.manufacturer_product_number) }} - {{ form_row(filterForm.manufacturer_product_url) }} -
-
- {{ form_row(filterForm.favorite) }} - {{ form_row(filterForm.needsReview) }} - {{ form_row(filterForm.measurementUnit) }} - {{ form_row(filterForm.mass) }} - {{ form_row(filterForm.dbId) }} - {{ form_row(filterForm.lastModified) }} - {{ form_row(filterForm.addedDate) }} -
+ {{ form_row(filterForm.submit) }} + {{ form_row(filterForm.discard) }} -
- {{ form_row(filterForm.storelocation) }} - {{ form_row(filterForm.minAmount) }} - {{ form_row(filterForm.amountSum) }} - {{ form_row(filterForm.lotCount) }} - {{ form_row(filterForm.lotExpirationDate) }} - {{ form_row(filterForm.lotDescription) }} - {{ form_row(filterForm.lotNeedsRefill) }} - {{ form_row(filterForm.lotUnknownAmount) }} -
- -
- {{ form_row(filterForm.attachmentsCount) }} - {{ form_row(filterForm.attachmentType) }} - {{ form_row(filterForm.attachmentName) }} -
- -
- {{ form_row(filterForm.supplier) }} - {{ form_row(filterForm.orderdetailsCount) }} - {{ form_row(filterForm.obsolete) }} -
- -
- {% import 'components/collection_type.macro.html.twig' as collection %} - {{ form_row(filterForm.parametersCount) }} - -
- - - - - - - - - - - - - {% for param in filterForm.parameters %} - {{ form_widget(param) }} - {% endfor %} - -
{% trans %}specifications.property{% endtrans %}{% trans %}specifications.symbol{% endtrans %}{% trans %}specifications.value{% endtrans %}{% trans %}specifications.unit{% endtrans %}{% trans %}specifications.text{% endtrans %}
- - +
+
+
- + {{ form_end(filterForm) }}
- - - {{ form_row(filterForm.submit) }} - {{ form_row(filterForm.discard) }} - -
-
- -
-
- - {{ form_end(filterForm) }}
\ No newline at end of file diff --git a/templates/Parts/lists/_info_card.html.twig b/templates/Parts/lists/_info_card.html.twig index c6b097de..1874b1be 100644 --- a/templates/Parts/lists/_info_card.html.twig +++ b/templates/Parts/lists/_info_card.html.twig @@ -3,133 +3,137 @@ {{ helper.breadcrumb_entity_link(entity) }} -
-
-
- -
-
-
-
-
-