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 + +