Added autocomplete for part parameters

This commit is contained in:
Jan Böhmer 2022-09-05 17:02:57 +02:00
parent 44b288b807
commit 9a7e47863b
18 changed files with 209 additions and 134 deletions

View file

@ -8,7 +8,6 @@ export default class extends Controller {
_tomSelect;
connect() {
let settings = {
plugins: {
remove_button:{

View file

@ -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 = '<div>'
+ '<span>' + escape(data.name) + '</span><br>';
if (data.symbol) {
tmp += '<span>' + katex.renderToString(data.symbol) + '</span>'
}
if (data.unit) {
tmp += '<span class="ms-2">' + katex.renderToString('[' + data.unit + ']') + '</span>'
}
//+ '<span class="text-muted">' + escape(data.unit) + '</span>'
tmp += '</div>';
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);
}
}

View file

@ -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"= ".+"})
*/

View file

@ -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")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,33 @@
<?php
namespace App\Repository;
class ParameterRepository extends DBElementRepository
{
/**
* Find parameters using a parameter name
* @param string $name The name to search for
* @param bool $exact True, if only exact names should match. False, if the name just needs to be contained in the parameter name
* @param int $max_results
* @return array
*/
public function autocompleteParamName(string $name, bool $exact = false, int $max_results = 50): array
{
$qb = $this->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();
}
}

View file

@ -1,117 +0,0 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2020 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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,
]);
}
}

View file

@ -68,13 +68,13 @@
{% block parameter_widget %}
{% import 'components/collection_type.macro.html.twig' as collection %}
<tr>
<td>{{ form_widget(form.name) }}{{ form_errors(form.name) }}</td>
<td>{{ form_widget(form.symbol) }}{{ form_errors(form.symbol) }}</td>
<tr {{ stimulus_controller('pages/parameters_autocomplete', {"url": url('typeahead_parameters', {"query": "__QUERY__", "type": "part"})}) }}>
<td>{{ form_widget(form.name, {"attr": {"data-pages--parameters-autocomplete-target": "name"}}) }}{{ form_errors(form.name) }}</td>
<td>{{ form_widget(form.symbol, {"attr": {"data-pages--parameters-autocomplete-target": "symbol"}}) }}{{ form_errors(form.symbol) }}</td>
<td>{{ form_widget(form.value_min) }}{{ form_errors(form.value_min) }}</td>
<td>{{ form_widget(form.value_typical) }}{{ form_errors(form.value_typical) }}</td>
<td>{{ form_widget(form.value_max) }}{{ form_errors(form.value_max) }}</td>
<td>{{ form_widget(form.unit) }}{{ form_errors(form.unit) }}</td>
<td>{{ form_widget(form.unit, {"attr": {"data-pages--parameters-autocomplete-target": "unit"}}) }}{{ form_errors(form.unit) }}</td>
<td>{{ form_widget(form.value_text) }}{{ form_errors(form.value_text) }}</td>
<td>{{ form_widget(form.group) }}{{ form_errors(form.group) }}</td>
<td>