diff --git a/assets/ts_src/event_listeners.ts b/assets/ts_src/event_listeners.ts index 3672011f..c48de553 100644 --- a/assets/ts_src/event_listeners.ts +++ b/assets/ts_src/event_listeners.ts @@ -231,9 +231,38 @@ $(document).on("ajaxUI:reload", function () { $(".file").fileinput(); }); -$(document).on("ajaxUI:reload", function () { - //@ts-ignore - $("input[data-role='tagsinput']").tagsinput(); +$(document).on("ajaxUI:start ajaxUI:reload", function () { + $('input.tagsinput').each(function() { + + //Use typeahead if an autocomplete url was specified. + if($(this).data('autocomplete')) { + + //@ts-ignore + var engine = new Bloodhound({ + //@ts-ignore + datumTokenizer: Bloodhound.tokenizers.obj.whitespace(''), + //@ts-ignore + queryTokenizer: Bloodhound.tokenizers.obj.whitespace(''), + remote: { + url: $(this).data('autocomplete'), + wildcard: 'QUERY' + } + }); + + //@ts-ignore + $(this).tagsinput({ + typeaheadjs: { + name: 'tags', + source: engine.ttAdapter() + } + }); + + + } else { //Init tagsinput without typeahead + //@ts-ignore + $(this).tagsinput(); + } + }) }); /** diff --git a/src/Controller/TypeaheadController.php b/src/Controller/TypeaheadController.php index 62d00d8a..7b1579b0 100644 --- a/src/Controller/TypeaheadController.php +++ b/src/Controller/TypeaheadController.php @@ -24,6 +24,7 @@ namespace App\Controller; use App\Services\Attachments\BuiltinAttachmentsFinder; +use App\Services\TagFinder; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -47,6 +48,24 @@ class TypeaheadController extends AbstractController $array = $finder->find($query); + $normalizers = [ + new ObjectNormalizer() + ]; + $encoders = [ + new JsonEncoder() + ]; + $serializer = new Serializer($normalizers, $encoders); + $data = $serializer->serialize($array, 'json'); + return new JsonResponse($data, 200, [], true); + } + + /** + * @Route("/tags/search/{query}", name="typeahead_tags", requirements={"query"= ".+"}) + */ + public function tags(string $query, TagFinder $finder) + { + $array = $finder->searchTags($query); + $normalizers = [ new ObjectNormalizer() ]; diff --git a/src/Form/Part/PartBaseType.php b/src/Form/Part/PartBaseType.php index 1790bb89..e257e3d0 100644 --- a/src/Form/Part/PartBaseType.php +++ b/src/Form/Part/PartBaseType.php @@ -51,6 +51,7 @@ use Symfony\Component\Form\Extension\Core\Type\ResetType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Security\Core\Security; use Symfony\Contracts\Translation\TranslatorInterface; use function foo\func; @@ -59,11 +60,13 @@ class PartBaseType extends AbstractType { protected $security; protected $trans; + protected $urlGenerator; - public function __construct(Security $security, TranslatorInterface $trans) + public function __construct(Security $security, TranslatorInterface $trans, UrlGeneratorInterface $urlGenerator) { $this->security = $security; $this->trans = $trans; + $this->urlGenerator = $urlGenerator; } public function buildForm(FormBuilderInterface $builder, array $options) @@ -119,7 +122,9 @@ class PartBaseType extends AbstractType 'required' => false, 'label' => $this->trans->trans('part.edit.tags'), 'empty_data' => "", - 'attr' => ['data-role' => 'tagsinput'], + 'attr' => [ + 'class' => 'tagsinput', + 'data-autocomplete' => $this->urlGenerator->generate('typeahead_tags', ['query' => 'QUERY']),], 'disabled' => !$this->security->isGranted('tags.edit', $part) ]); diff --git a/src/Services/TagFinder.php b/src/Services/TagFinder.php new file mode 100644 index 00000000..8098e1b2 --- /dev/null +++ b/src/Services/TagFinder.php @@ -0,0 +1,95 @@ +em = $entityManager; + } + + protected function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'query_limit' => 75, + 'return_limit' => 25, + 'min_keyword_length' => 3 + ]); + } + + /** + * 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 = []) + { + $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']); + } +} \ No newline at end of file