From 83f0008e523995bd4662b7af9e436d1bf504607a Mon Sep 17 00:00:00 2001 From: Marien Fressinaud Date: Thu, 9 Jan 2025 17:34:05 +0100 Subject: [PATCH] imp: Allow to search tickets by team with the advanced syntax --- src/Repository/TeamRepository.php | 19 +++ src/SearchEngine/Ticket/QueryBuilder.php | 55 ++++++++- .../advanced_search_syntax/tickets.html.twig | 30 +++++ tests/SearchEngine/Ticket/SearcherTest.php | 116 ++++++++++++++++++ translations/messages+intl-icu.en_GB.yaml | 4 + translations/messages+intl-icu.fr_FR.yaml | 4 + 6 files changed, 227 insertions(+), 1 deletion(-) diff --git a/src/Repository/TeamRepository.php b/src/Repository/TeamRepository.php index 89c9dfea..66b671aa 100644 --- a/src/Repository/TeamRepository.php +++ b/src/Repository/TeamRepository.php @@ -45,4 +45,23 @@ public function findByOrganization(Organization $organization): array return $query->getResult(); } + + /** + * @return Team[] + */ + public function findLike(string $value): array + { + $entityManager = $this->getEntityManager(); + + $value = mb_strtolower($value); + + $query = $entityManager->createQuery(<<setParameter('value', "%{$value}%"); + + return $query->getResult(); + } } diff --git a/src/SearchEngine/Ticket/QueryBuilder.php b/src/SearchEngine/Ticket/QueryBuilder.php index 0a3abdee..ab5f17c8 100644 --- a/src/SearchEngine/Ticket/QueryBuilder.php +++ b/src/SearchEngine/Ticket/QueryBuilder.php @@ -20,8 +20,9 @@ class QueryBuilder extends SearchEngine\QueryBuilder { public function __construct( protected Repository\LabelRepository $labelRepository, - protected Repository\UserRepository $userRepository, protected Repository\OrganizationRepository $organizationRepository, + protected Repository\TeamRepository $teamRepository, + protected Repository\UserRepository $userRepository, protected Security $security, protected ORM\EntityManagerInterface $entityManager, ) { @@ -49,6 +50,7 @@ protected function getQualifiersMapping(): array 'assignee' => $this->buildAssigneeExpr(...), 'requester' => $this->buildRequesterExpr(...), 'observer' => $this->buildObserverExpr(...), + 'team' => $this->buildTeamExpr(...), 'involves' => $this->buildInvolvesExpr(...), 'org' => $this->buildOrganizationExpr(...), 'uid' => $this->buildUidExpr(...), @@ -59,10 +61,12 @@ protected function getQualifiersMapping(): array 'contract' => $this->buildContractExpr(...), 'label' => $this->buildLabelExpr(...), 'no:assignee' => $this->buildNoAssigneeExpr(...), + 'no:team' => $this->buildNoTeamExpr(...), 'no:solution' => $this->buildNoSolutionExpr(...), 'no:contract' => $this->buildNoContractExpr(...), 'no:label' => $this->buildNoLabelExpr(...), 'has:assignee' => $this->buildHasAssigneeExpr(...), + 'has:team' => $this->buildHasTeamExpr(...), 'has:solution' => $this->buildHasSolutionExpr(...), 'has:contract' => $this->buildHasContractExpr(...), 'has:label' => $this->buildHasLabelExpr(...), @@ -121,6 +125,15 @@ protected function buildObserverExpr(SearchEngine\Query\Condition $condition): s } } + /** + * @return literal-string + */ + protected function buildTeamExpr(SearchEngine\Query\Condition $condition): string + { + $value = $this->processValue($condition->getValue(), $this->processTeamValue(...)); + return $this->buildExpr('COALESCE(IDENTITY(t.team), 0)', $value, $condition->not()); + } + /** * @return literal-string */ @@ -275,6 +288,14 @@ protected function buildNoAssigneeExpr(SearchEngine\Query\Condition $condition): return $this->buildExpr('t.assignee', null, $condition->not()); } + /** + * @return literal-string + */ + protected function buildNoTeamExpr(SearchEngine\Query\Condition $condition): string + { + return $this->buildExpr('t.team', null, $condition->not()); + } + /** * @return literal-string */ @@ -307,6 +328,14 @@ protected function buildHasAssigneeExpr(SearchEngine\Query\Condition $condition) return $this->buildExpr('t.assignee', null, !$condition->not()); } + /** + * @return literal-string + */ + protected function buildHasTeamExpr(SearchEngine\Query\Condition $condition): string + { + return $this->buildExpr('t.team', null, !$condition->not()); + } + /** * @return literal-string */ @@ -360,4 +389,28 @@ protected function processActorValue(mixed $value): array return [-1]; } } + + /** + * @return mixed[] + */ + protected function processTeamValue(mixed $value): array + { + $id = $this->extractId($value); + + if ($id !== null) { + return [$id]; + } + + $teams = $this->teamRepository->findLike($value); + + $ids = array_map(function ($team): int { + return $team->getId(); + }, $teams); + + if ($ids) { + return $ids; + } else { + return [-1]; + } + } } diff --git a/templates/pages/advanced_search_syntax/tickets.html.twig b/templates/pages/advanced_search_syntax/tickets.html.twig index 39aebbe2..19ebb22c 100644 --- a/templates/pages/advanced_search_syntax/tickets.html.twig +++ b/templates/pages/advanced_search_syntax/tickets.html.twig @@ -231,6 +231,36 @@ +
+

{{ 'advanced_search_syntax.tickets.qualifiers.team.title' | trans }}

+ + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'advanced_search_syntax.example' | trans }}{{ 'advanced_search_syntax.description' | trans }}
team:web{{ 'advanced_search_syntax.tickets.qualifiers.team.example1' | trans }}
no:team{{ 'advanced_search_syntax.tickets.qualifiers.team.example2' | trans }}
has:team{{ 'advanced_search_syntax.tickets.qualifiers.team.example3' | trans }}
+
+

{{ 'advanced_search_syntax.tickets.qualifiers.organization.title' | trans }}

diff --git a/tests/SearchEngine/Ticket/SearcherTest.php b/tests/SearchEngine/Ticket/SearcherTest.php index becd6c64..a6da1841 100644 --- a/tests/SearchEngine/Ticket/SearcherTest.php +++ b/tests/SearchEngine/Ticket/SearcherTest.php @@ -304,6 +304,122 @@ public function testGetTicketsCanLimitToTicketsObservedByUser(): void $this->assertSame($ticket2->getId(), $ticketsPagination->items[0]->getId()); } + public function testGetTicketsCanLimitToTicketsAssignedToSpecificTeam(): void + { + $client = static::createClient(); + $container = static::getContainer(); + /** @var SearchEngine\Ticket\Searcher */ + $ticketSearcher = $container->get(SearchEngine\Ticket\Searcher::class); + $user = Factory\UserFactory::createOne(); + $client->loginUser($user->_real()); + $organization = Factory\OrganizationFactory::createOne(); + $team1 = Factory\TeamFactory::createOne([ + 'name' => 'Team support', + ]); + $team2 = Factory\TeamFactory::createOne([ + 'name' => 'Team Web', + ]); + $ticket1 = Factory\TicketFactory::createOne([ + 'requester' => $user, + 'team' => $team1, + ]); + $ticket2 = Factory\TicketFactory::createOne([ + 'requester' => $user, + 'team' => $team2, + ]); + + $query = SearchEngine\Query::fromString('team:web'); + $ticketsPagination = $ticketSearcher->getTickets($query); + + $this->assertSame(1, $ticketsPagination->count); + $this->assertSame($ticket2->getId(), $ticketsPagination->items[0]->getId()); + } + + public function testGetTicketsCanExcludeTicketsAssignedToSpecificTeam(): void + { + $client = static::createClient(); + $container = static::getContainer(); + /** @var SearchEngine\Ticket\Searcher */ + $ticketSearcher = $container->get(SearchEngine\Ticket\Searcher::class); + $user = Factory\UserFactory::createOne(); + $client->loginUser($user->_real()); + $organization = Factory\OrganizationFactory::createOne(); + $team1 = Factory\TeamFactory::createOne([ + 'name' => 'Team support', + ]); + $team2 = Factory\TeamFactory::createOne([ + 'name' => 'Team Web', + ]); + $ticket1 = Factory\TicketFactory::createOne([ + 'requester' => $user, + 'team' => $team1, + ]); + $ticket2 = Factory\TicketFactory::createOne([ + 'requester' => $user, + 'team' => $team2, + ]); + + $query = SearchEngine\Query::fromString('-team:web'); + $ticketsPagination = $ticketSearcher->getTickets($query); + + $this->assertSame(1, $ticketsPagination->count); + $this->assertSame($ticket1->getId(), $ticketsPagination->items[0]->getId()); + } + + public function testGetTicketsCanLimitToTicketsAssignedToAnyTeam(): void + { + $client = static::createClient(); + $container = static::getContainer(); + /** @var SearchEngine\Ticket\Searcher */ + $ticketSearcher = $container->get(SearchEngine\Ticket\Searcher::class); + $user = Factory\UserFactory::createOne(); + $client->loginUser($user->_real()); + $organization = Factory\OrganizationFactory::createOne(); + $team = Factory\TeamFactory::createOne([ + 'name' => 'Team support', + ]); + $ticket1 = Factory\TicketFactory::createOne([ + 'requester' => $user, + ]); + $ticket2 = Factory\TicketFactory::createOne([ + 'requester' => $user, + 'team' => $team, + ]); + + $query = SearchEngine\Query::fromString('has:team'); + $ticketsPagination = $ticketSearcher->getTickets($query); + + $this->assertSame(1, $ticketsPagination->count); + $this->assertSame($ticket2->getId(), $ticketsPagination->items[0]->getId()); + } + + public function testGetTicketsCanExcludeTicketsAssignedToAnyTeam(): void + { + $client = static::createClient(); + $container = static::getContainer(); + /** @var SearchEngine\Ticket\Searcher */ + $ticketSearcher = $container->get(SearchEngine\Ticket\Searcher::class); + $user = Factory\UserFactory::createOne(); + $client->loginUser($user->_real()); + $organization = Factory\OrganizationFactory::createOne(); + $team = Factory\TeamFactory::createOne([ + 'name' => 'Team support', + ]); + $ticket1 = Factory\TicketFactory::createOne([ + 'requester' => $user, + ]); + $ticket2 = Factory\TicketFactory::createOne([ + 'requester' => $user, + 'team' => $team, + ]); + + $query = SearchEngine\Query::fromString('no:team'); + $ticketsPagination = $ticketSearcher->getTickets($query); + + $this->assertSame(1, $ticketsPagination->count); + $this->assertSame($ticket1->getId(), $ticketsPagination->items[0]->getId()); + } + public function testGetTicketsCanRestrictToAGivenContract(): void { $client = static::createClient(); diff --git a/translations/messages+intl-icu.en_GB.yaml b/translations/messages+intl-icu.en_GB.yaml index 191bd467..32c49b18 100644 --- a/translations/messages+intl-icu.en_GB.yaml +++ b/translations/messages+intl-icu.en_GB.yaml @@ -75,6 +75,10 @@ advanced_search_syntax.tickets.qualifiers.status.example1: 'Tickets with the sta advanced_search_syntax.tickets.qualifiers.status.example2: 'Tickets with the status “new” or “in progress”' advanced_search_syntax.tickets.qualifiers.status.title: Status advanced_search_syntax.tickets.qualifiers.status.values: 'The possible values for status are: new, in_progress, planned, pending, resolved, closed, as well as the shortcuts open (includes the first 4) and finished (includes the last 2).' +advanced_search_syntax.tickets.qualifiers.team.example1: 'Tickets assigned to a team whose name contains “web”' +advanced_search_syntax.tickets.qualifiers.team.example2: 'Tickets with no assigned team' +advanced_search_syntax.tickets.qualifiers.team.example3: 'Tickets assigned to a team' +advanced_search_syntax.tickets.qualifiers.team.title: Teams advanced_search_syntax.tickets.qualifiers.title: 'Search by qualifiers' advanced_search_syntax.tickets.qualifiers.type.example1: 'Tickets of type “incident”' advanced_search_syntax.tickets.qualifiers.type.example2: 'Tickets of type “request”' diff --git a/translations/messages+intl-icu.fr_FR.yaml b/translations/messages+intl-icu.fr_FR.yaml index 1aef6551..5d8c0f3d 100644 --- a/translations/messages+intl-icu.fr_FR.yaml +++ b/translations/messages+intl-icu.fr_FR.yaml @@ -75,6 +75,10 @@ advanced_search_syntax.tickets.qualifiers.status.example1: "Les tickets avec le advanced_search_syntax.tickets.qualifiers.status.example2: "Les tickets avec les statuts «\_nouveau\_» ou «\_en cours\_»" advanced_search_syntax.tickets.qualifiers.status.title: Statut advanced_search_syntax.tickets.qualifiers.status.values: "Les valeurs de statut possibles sont\_: new, in_progress, planned, pending, resolved, closed, ainsi que les raccourcis open (incluant les 4 premiers) et finished (incluant les 2 derniers)." +advanced_search_syntax.tickets.qualifiers.team.example1: "Les tickets attribués à une équipe dont le nom contient «\_web\_»" +advanced_search_syntax.tickets.qualifiers.team.example2: 'Les tickets n’ayant aucune équipe attribuée' +advanced_search_syntax.tickets.qualifiers.team.example3: 'Les tickets étant attribuée à une équipe' +advanced_search_syntax.tickets.qualifiers.team.title: Équipes advanced_search_syntax.tickets.qualifiers.title: 'Rechercher par qualificateurs' advanced_search_syntax.tickets.qualifiers.type.example1: "Les tickets de type «\_incident\_»" advanced_search_syntax.tickets.qualifiers.type.example2: "Les tickets de type «\_demande\_»"