Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

imp: Allow to search tickets by team with the advanced syntax #908

Merged
merged 1 commit into from
Jan 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/Repository/TeamRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(<<<SQL
SELECT t
FROM App\Entity\Team t
WHERE LOWER(t.name) LIKE :value
SQL);
$query->setParameter('value', "%{$value}%");

return $query->getResult();
}
}
55 changes: 54 additions & 1 deletion src/SearchEngine/Ticket/QueryBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
Expand Down Expand Up @@ -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(...),
Expand All @@ -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(...),
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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];
}
}
}
30 changes: 30 additions & 0 deletions templates/pages/advanced_search_syntax/tickets.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,36 @@
</table>
</div>

<div class="flow">
<h3>{{ 'advanced_search_syntax.tickets.qualifiers.team.title' | trans }}</h3>

<table>
<thead>
<tr>
<th class="col--size30">{{ 'advanced_search_syntax.example' | trans }}</th>
<th>{{ 'advanced_search_syntax.description' | trans }}</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>team:web</code></td>
<td>{{ 'advanced_search_syntax.tickets.qualifiers.team.example1' | trans }}</td>
</tr>

<tr>
<td><code>no:team</code></td>
<td>{{ 'advanced_search_syntax.tickets.qualifiers.team.example2' | trans }}</td>
</tr>

<tr>
<td><code>has:team</code></td>
<td>{{ 'advanced_search_syntax.tickets.qualifiers.team.example3' | trans }}</td>
</tr>
</tbody>
</table>
</div>

<div class="flow">
<h3>{{ 'advanced_search_syntax.tickets.qualifiers.organization.title' | trans }}</h3>

Expand Down
116 changes: 116 additions & 0 deletions tests/SearchEngine/Ticket/SearcherTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 4 additions & 0 deletions translations/messages+intl-icu.en_GB.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: <code>new</code>, <code>in_progress</code>, <code>planned</code>, <code>pending</code>, <code>resolved</code>, <code>closed</code>, as well as the shortcuts <code>open</code> (includes the first 4) and <code>finished</code> (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”'
Expand Down
4 changes: 4 additions & 0 deletions translations/messages+intl-icu.fr_FR.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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\_: <code>new</code>, <code>in_progress</code>, <code>planned</code>, <code>pending</code>, <code>resolved</code>, <code>closed</code>, ainsi que les raccourcis <code>open</code> (incluant les 4 premiers) et <code>finished</code> (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\_»"
Expand Down
Loading