Added the very basic foundations for a filter system

This commit is contained in:
Jan Böhmer 2022-08-15 01:01:27 +02:00
parent ef389dcc15
commit f9d945c4c7
15 changed files with 460 additions and 5 deletions

View file

@ -1,6 +1,6 @@
twig: twig:
default_path: '%kernel.project_dir%/templates' 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: paths:
'%kernel.project_dir%/assets/css': css '%kernel.project_dir%/assets/css': css

View file

@ -42,12 +42,14 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\DataTables\Filters\PartFilter;
use App\DataTables\PartsDataTable; use App\DataTables\PartsDataTable;
use App\Entity\Parts\Category; use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint; use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer; use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\Storelocation; use App\Entity\Parts\Storelocation;
use App\Entity\Parts\Supplier; use App\Entity\Parts\Supplier;
use App\Form\Filters\PartFilterType;
use App\Services\Parts\PartsTableActionHandler; use App\Services\Parts\PartsTableActionHandler;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Omines\DataTablesBundle\DataTableFactory; use Omines\DataTablesBundle\DataTableFactory;
@ -245,11 +247,11 @@ class PartListsController extends AbstractController
'regex' => $request->query->getBoolean('regex'), 'regex' => $request->query->getBoolean('regex'),
]; ];
$table = $dataTable->createFromType(PartsDataTable::class, [ $table = $dataTable->createFromType(PartsDataTable::class, [
'search' => $search, 'search' => $search,
'search_options' => $search_options, 'search_options' => $search_options,
]) ])->handleRequest($request);
->handleRequest($request);
if ($table->isCallback()) { if ($table->isCallback()) {
return $table->getResponse(); return $table->getResponse();
@ -258,6 +260,7 @@ class PartListsController extends AbstractController
return $this->render('Parts/lists/search_list.html.twig', [ return $this->render('Parts/lists/search_list.html.twig', [
'datatable' => $table, 'datatable' => $table,
'keyword' => $search, 'keyword' => $search,
'filterForm' => $filterForm->createView()
]); ]);
} }
@ -268,13 +271,22 @@ class PartListsController extends AbstractController
*/ */
public function showAll(Request $request, DataTableFactory $dataTable) 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); ->handleRequest($request);
if ($table->isCallback()) { if ($table->isCallback()) {
return $table->getResponse(); 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()]);
} }
} }

View file

@ -0,0 +1,28 @@
<?php
namespace App\DataTables\Filters\Constraints;
use App\DataTables\Filters\FilterInterface;
use Doctrine\ORM\QueryBuilder;
abstract class AbstractSimpleConstraint implements FilterInterface
{
use FilterTrait;
/**
* @var string The property where this BooleanConstraint should apply to
*/
protected $property;
/**
* @var string
*/
protected $identifier;
public function __construct(string $property, string $identifier = null)
{
$this->property = $property;
$this->identifier = $identifier ?? $this->generateParameterIdentifier($property);
}
}

View file

@ -0,0 +1,48 @@
<?php
namespace App\DataTables\Filters\Constraints;
use App\DataTables\Filters\FilterInterface;
use Doctrine\ORM\QueryBuilder;
class BooleanConstraint extends AbstractSimpleConstraint
{
/** @var bool|null The value of our constraint */
protected $value;
public function __construct(string $property, string $identifier = null, ?bool $default_value = null)
{
parent::__construct($property, $identifier, $default_value);
}
/**
* Gets the value of this constraint. Null means "don't filter", true means "filter for true", false means "filter for false".
* @return bool|null
*/
public function getValue(): ?bool
{
return $this->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);
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\DataTables\Filters\Constraints;
use Doctrine\ORM\QueryBuilder;
trait FilterTrait
{
/**
* 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
* @return string
*/
protected function generateParameterIdentifier(string $property): string
{
return str_replace('.', '_', $property) . '_' . uniqid("", false);
}
/**
* Adds a simple constraint in the form of (property OPERATOR value) (e.g. "part.name = :name") to the given query builder.
* @param QueryBuilder $queryBuilder
* @param string $property
* @param string $comparison_operator
* @param mixed $value
* @return void
*/
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->setParameter($parameterIdentifier, $value);
}
}

View file

@ -0,0 +1,110 @@
<?php
namespace App\DataTables\Filters\Constraints;
use Doctrine\ORM\QueryBuilder;
use \RuntimeException;
class NumberConstraint extends AbstractSimpleConstraint
{
public const ALLOWED_OPERATOR_VALUES = ['=', '!=', '<', '>', '<=', '>=', '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);
}
}
}

View file

@ -0,0 +1,16 @@
<?php
namespace App\DataTables\Filters;
use Doctrine\ORM\QueryBuilder;
interface FilterInterface
{
/**
* Apply the given filter to the given query builder on the given property
* @param QueryBuilder $queryBuilder
* @return void
*/
public function apply(QueryBuilder $queryBuilder): void;
}

View file

@ -0,0 +1,54 @@
<?php
namespace App\DataTables\Filters;
use App\DataTables\Filters\Constraints\BooleanConstraint;
use App\DataTables\Filters\Constraints\NumberConstraint;
use Doctrine\ORM\QueryBuilder;
class PartFilter implements FilterInterface
{
/** @var BooleanConstraint */
protected $favorite;
/** @var BooleanConstraint */
protected $needsReview;
/** @var NumberConstraint */
protected $mass;
/**
* @return BooleanConstraint|false
*/
public function getFavorite()
{
return $this->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);
}
}

View file

@ -48,6 +48,7 @@ use App\DataTables\Column\LocaleDateTimeColumn;
use App\DataTables\Column\MarkdownColumn; use App\DataTables\Column\MarkdownColumn;
use App\DataTables\Column\PartAttachmentsColumn; use App\DataTables\Column\PartAttachmentsColumn;
use App\DataTables\Column\TagsColumn; use App\DataTables\Column\TagsColumn;
use App\DataTables\Filters\PartFilter;
use App\Entity\Parts\Category; use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint; use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer; use App\Entity\Parts\Manufacturer;
@ -108,6 +109,7 @@ final class PartsDataTable implements DataTableTypeInterface
'supplier' => null, 'supplier' => null,
'tag' => null, 'tag' => null,
'search' => null, 'search' => null,
'filter' => null
]); ]);
$optionsResolver->setAllowedTypes('category', ['null', Category::class]); $optionsResolver->setAllowedTypes('category', ['null', Category::class]);
@ -351,6 +353,13 @@ final class PartsDataTable implements DataTableTypeInterface
private function buildCriteria(QueryBuilder $builder, array $options): void 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'])) { if (isset($options['category'])) {
$category = $options['category']; $category = $options['category'];
$list = $this->treeBuilder->typeToNodesList(Category::class, $category); $list = $this->treeBuilder->typeToNodesList(Category::class, $category);

View file

@ -0,0 +1,28 @@
<?php
namespace App\Form\Filters\Constraints;
use App\DataTables\Filters\Constraints\BooleanConstraint;
use App\Form\Type\TriStateCheckboxType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class BooleanConstraintType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->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,
]);
}
}

View file

@ -0,0 +1,57 @@
<?php
namespace App\Form\Filters\Constraints;
use App\DataTables\Filters\Constraints\NumberConstraint;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
class NumberConstraintType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->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,
]);
}
}

View file

@ -0,0 +1,43 @@
<?php
namespace App\Form\Filters;
use App\DataTables\Filters\PartFilter;
use App\Form\Filters\Constraints\BooleanConstraintType;
use App\Form\Filters\Constraints\NumberConstraintType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\SubmitButton;
use Symfony\Component\OptionsResolver\OptionsResolver;
class PartFilterType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->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',
]);
}
}

View file

@ -0,0 +1,7 @@
{% block number_constraint_widget %}
<div class="input-group">
{{ form_widget(form.operator) }}
{{ form_widget(form.value1) }}
{{ form_widget(form.value2) }}
</div>
{% endblock %}

View file

@ -0,0 +1,8 @@
<div class="card mb-4">
<div class="card-header">Filter</div>
<div class="card-body">
{{ form_start(filterForm) }}
{{ form_end(filterForm) }}
</div>
</div>

View file

@ -6,6 +6,8 @@
{% block content %} {% block content %}
{% include "Parts/lists/_filter.html.twig" %}
{% include "Parts/lists/_action_bar.html.twig" with {'url_options': {}} %} {% include "Parts/lists/_action_bar.html.twig" with {'url_options': {}} %}
{% include "Parts/lists/_parts_list.html.twig" %} {% include "Parts/lists/_parts_list.html.twig" %}