Skip to content

Commit de1a245

Browse files
Merge pull request #3337 from juarezsousa-ctrl/feature/task-collaborators
Add collaborator support to task assignment and UI (list + kanban)
2 parents a4acef8 + 23dffbf commit de1a245

6 files changed

Lines changed: 188 additions & 20 deletions

File tree

app/Domain/Calendar/Repositories/Calendar.php

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use Leantime\Core\Db\Db as DbCore;
1010
use Leantime\Core\Db\Repository as RepositoryCore;
1111
use Leantime\Core\Language as LanguageCore;
12+
use Leantime\Core\Support\EntityRelationshipEnum;
1213
use Leantime\Core\Support\DateTimeHelper;
1314
use Leantime\Domain\Setting\Repositories\Setting;
1415
use Leantime\Domain\Tickets\Services\Tickets;
@@ -309,7 +310,16 @@ public function getTicketWishDates(): false|array
309310
->select('id', 'headline', 'dateToFinish')
310311
->where(function ($query) {
311312
$query->where('userId', session('userdata.id'))
312-
->orWhere('editorId', (string) session('userdata.id'));
313+
->orWhere('editorId', (string) session('userdata.id'))
314+
->orWhereExists(function ($subquery) {
315+
$subquery->selectRaw('1')
316+
->from('zp_entity_relationship')
317+
->whereColumn('zp_entity_relationship.entityA', 'zp_tickets.id')
318+
->where('zp_entity_relationship.entityAType', 'Ticket')
319+
->where('zp_entity_relationship.entityBType', 'User')
320+
->where('zp_entity_relationship.relationship', EntityRelationshipEnum::Collaborator->value)
321+
->where('zp_entity_relationship.entityB', session('userdata.id'));
322+
});
313323
})
314324
->where('dateToFinish', '<>', '000-00-00 00:00:00')
315325
->get();
@@ -323,7 +333,16 @@ public function getTicketEditDates(): false|array
323333
->select('id', 'headline', 'editFrom', 'editTo')
324334
->where(function ($query) {
325335
$query->where('userId', session('userdata.id'))
326-
->orWhere('editorId', (string) session('userdata.id'));
336+
->orWhere('editorId', (string) session('userdata.id'))
337+
->orWhereExists(function ($subquery) {
338+
$subquery->selectRaw('1')
339+
->from('zp_entity_relationship')
340+
->whereColumn('zp_entity_relationship.entityA', 'zp_tickets.id')
341+
->where('zp_entity_relationship.entityAType', 'Ticket')
342+
->where('zp_entity_relationship.entityBType', 'User')
343+
->where('zp_entity_relationship.relationship', EntityRelationshipEnum::Collaborator->value)
344+
->where('zp_entity_relationship.entityB', session('userdata.id'));
345+
});
327346
})
328347
->where('editFrom', '<>', '000-00-00 00:00:00')
329348
->get();

app/Domain/Projects/Services/Projects.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,9 @@ private function isUserInvolvedInNotification(int $userId, Notification $notific
475475
if (isset($entity['editorId']) && (int) $entity['editorId'] === $userId) {
476476
return true;
477477
}
478+
if (isset($entity['collaborators']) && is_array($entity['collaborators']) && in_array($userId, array_map('intval', $entity['collaborators']), true)) {
479+
return true;
480+
}
478481
if (isset($entity['userId']) && (int) $entity['userId'] === $userId) {
479482
return true;
480483
}
@@ -486,6 +489,9 @@ private function isUserInvolvedInNotification(int $userId, Notification $notific
486489
if (isset($entity->editorId) && (int) $entity->editorId === $userId) {
487490
return true;
488491
}
492+
if (isset($entity->collaborators) && is_array($entity->collaborators) && in_array($userId, array_map('intval', $entity->collaborators), true)) {
493+
return true;
494+
}
489495
if (isset($entity->userId) && (int) $entity->userId === $userId) {
490496
return true;
491497
}

app/Domain/Tickets/Repositories/Tickets.php

Lines changed: 78 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -447,12 +447,6 @@ public function getAllBySearchCriteria(array $searchCriteria, string $sort = 'st
447447
$join->on('zp_tickets.projectId', '=', 'rup.projectId')
448448
->where('rup.userId', '=', $userId);
449449
})
450-
->leftJoin('zp_entity_relationship as er', function ($join) {
451-
$join->on('er.entityAType', '=', $this->connection->raw("'Ticket'"))
452-
->on('er.entityBType', '=', $this->connection->raw("'User'"))
453-
->on('er.entityA', '=', 'zp_tickets.id')
454-
->on('er.relationship', '=', $this->connection->raw("'".EntityRelationshipEnum::Collaborator->value."'"));
455-
})
456450
->where(function ($q) use ($clientId) {
457451
$q->whereNotNull('rup.projectId')
458452
->orWhere('zp_projects.psettings', 'all')
@@ -489,7 +483,15 @@ public function getAllBySearchCriteria(array $searchCriteria, string $sort = 'st
489483
$userIds = explode(',', $searchCriteria['users']);
490484
$query->where(function ($q) use ($userIds) {
491485
$q->whereIn('zp_tickets.editorId', $userIds)
492-
->orWhereIn('er.entityB', $userIds);
486+
->orWhereExists(function ($subquery) use ($userIds) {
487+
$subquery->selectRaw('1')
488+
->from('zp_entity_relationship')
489+
->whereColumn('zp_entity_relationship.entityA', 'zp_tickets.id')
490+
->where('zp_entity_relationship.entityAType', 'Ticket')
491+
->where('zp_entity_relationship.entityBType', 'User')
492+
->where('zp_entity_relationship.relationship', EntityRelationshipEnum::Collaborator->value)
493+
->whereIn('zp_entity_relationship.entityB', $userIds);
494+
});
493495
});
494496
}
495497

@@ -672,12 +674,6 @@ public function simpleTicketQuery(?int $userId, ?int $projectId, array $types =
672674
$join->on('requestor.id', '=', $this->connection->raw((int) $requestorId));
673675
})
674676
->leftJoin('zp_tickets as milestones', 'zp_tickets.milestoneid', '=', 'milestones.id')
675-
->leftJoin('zp_entity_relationship as er', function ($join) {
676-
$join->on('er.entityAType', '=', $this->connection->raw("'Ticket'"))
677-
->on('er.entityBType', '=', $this->connection->raw("'User'"))
678-
->on('er.relationship', '=', $this->connection->raw("'Collaborator'"))
679-
->on('er.entityA', '=', 'zp_tickets.id');
680-
})
681677
->where(function ($q) use ($requestorId, $clientId) {
682678
$q->whereIn('zp_tickets.projectId', function ($subquery) use ($requestorId) {
683679
$subquery->select('projectId')
@@ -699,7 +695,15 @@ public function simpleTicketQuery(?int $userId, ?int $projectId, array $types =
699695
if (isset($userId) && $userId > 0) {
700696
$query->where(function ($q) use ($userId) {
701697
$q->where('zp_tickets.editorId', (string) $userId)
702-
->orWhere('er.entityB', $userId);
698+
->orWhereExists(function ($subquery) use ($userId) {
699+
$subquery->selectRaw('1')
700+
->from('zp_entity_relationship')
701+
->whereColumn('zp_entity_relationship.entityA', 'zp_tickets.id')
702+
->where('zp_entity_relationship.entityAType', 'Ticket')
703+
->where('zp_entity_relationship.entityBType', 'User')
704+
->where('zp_entity_relationship.relationship', EntityRelationshipEnum::Collaborator->value)
705+
->where('zp_entity_relationship.entityB', $userId);
706+
});
703707
});
704708
}
705709

@@ -764,7 +768,18 @@ public function getScheduledTasks(CarbonImmutable $dateFrom, CarbonImmutable $da
764768
->where('zp_tickets.type', '<>', 'milestone');
765769

766770
if (isset($userId)) {
767-
$query->where('zp_tickets.editorId', (string) $userId);
771+
$query->where(function ($q) use ($userId) {
772+
$q->where('zp_tickets.editorId', (string) $userId)
773+
->orWhereExists(function ($subquery) use ($userId) {
774+
$subquery->selectRaw('1')
775+
->from('zp_entity_relationship')
776+
->whereColumn('zp_entity_relationship.entityA', 'zp_tickets.id')
777+
->where('zp_entity_relationship.entityAType', 'Ticket')
778+
->where('zp_entity_relationship.entityBType', 'User')
779+
->where('zp_entity_relationship.relationship', EntityRelationshipEnum::Collaborator->value)
780+
->where('zp_entity_relationship.entityB', $userId);
781+
});
782+
});
768783
}
769784

770785
$query->where(function ($q) use ($dateFrom, $dateTo) {
@@ -1129,7 +1144,18 @@ public function getAllMilestones(array $searchCriteria, string $sort = 'standard
11291144

11301145
if (isset($searchCriteria['users']) && $searchCriteria['users'] != '') {
11311146
$userIds = explode(',', $searchCriteria['users']);
1132-
$query->whereIn('zp_tickets.editorId', $userIds);
1147+
$query->where(function ($q) use ($userIds) {
1148+
$q->whereIn('zp_tickets.editorId', $userIds)
1149+
->orWhereExists(function ($subquery) use ($userIds) {
1150+
$subquery->selectRaw('1')
1151+
->from('zp_entity_relationship')
1152+
->whereColumn('zp_entity_relationship.entityA', 'zp_tickets.id')
1153+
->where('zp_entity_relationship.entityAType', 'Ticket')
1154+
->where('zp_entity_relationship.entityBType', 'User')
1155+
->where('zp_entity_relationship.relationship', EntityRelationshipEnum::Collaborator->value)
1156+
->whereIn('zp_entity_relationship.entityB', $userIds);
1157+
});
1158+
});
11331159
}
11341160

11351161
if (isset($searchCriteria['milestone']) && $searchCriteria['milestone'] != '') {
@@ -1800,6 +1826,42 @@ public function getCollaborators(int $ticketId): array
18001826
->toArray();
18011827
}
18021828

1829+
/**
1830+
* Retrieves collaborators for multiple tickets in a single query.
1831+
*
1832+
* @param array<int, int|string> $ticketIds
1833+
* @return array<int, array<int, int>>
1834+
*/
1835+
public function getCollaboratorsByTicketIds(array $ticketIds): array
1836+
{
1837+
$ticketIds = array_values(array_filter(array_map('intval', $ticketIds)));
1838+
1839+
if (empty($ticketIds)) {
1840+
return [];
1841+
}
1842+
1843+
$rows = $this->connection->table('zp_entity_relationship')
1844+
->select(['entityA', 'entityB'])
1845+
->whereIn('entityA', $ticketIds)
1846+
->where('entityAType', 'Ticket')
1847+
->where('entityBType', 'User')
1848+
->where('relationship', EntityRelationshipEnum::Collaborator->value)
1849+
->orderBy('entityA')
1850+
->orderBy('entityB')
1851+
->get();
1852+
1853+
$collaboratorsByTicket = [];
1854+
1855+
foreach ($rows as $row) {
1856+
$ticketId = (int) $row->entityA;
1857+
$userId = (int) $row->entityB;
1858+
$collaboratorsByTicket[$ticketId] ??= [];
1859+
$collaboratorsByTicket[$ticketId][] = $userId;
1860+
}
1861+
1862+
return $collaboratorsByTicket;
1863+
}
1864+
18031865
/**
18041866
* Removes all collaborators from a ticket.
18051867
*

app/Domain/Tickets/Services/Tickets.php

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2812,6 +2812,7 @@ public function getTicketTemplateAssignments($params): array
28122812
$searchUrlString = '?'.http_build_query($this->getSetFilters($searchCriteria, true));
28132813
}
28142814

2815+
$allTickets = $this->enrichGroupedTicketsWithCollaborators($allTickets);
28152816
$allTickets = self::dispatchFilter('filterTickets', $allTickets);
28162817

28172818
return [
@@ -2836,6 +2837,56 @@ public function getTicketTemplateAssignments($params): array
28362837
];
28372838
}
28382839

2840+
/**
2841+
* Adds collaborator display metadata to grouped ticket collections used by list/kanban views.
2842+
*
2843+
* @param array<string, array<string, mixed>> $groupedTickets
2844+
* @return array<string, array<string, mixed>>
2845+
*/
2846+
private function enrichGroupedTicketsWithCollaborators(array $groupedTickets): array
2847+
{
2848+
$ticketIds = [];
2849+
2850+
foreach ($groupedTickets as $group) {
2851+
foreach (($group['items'] ?? []) as $ticket) {
2852+
if (isset($ticket['id'])) {
2853+
$ticketIds[] = (int) $ticket['id'];
2854+
}
2855+
}
2856+
}
2857+
2858+
if (empty($ticketIds)) {
2859+
return $groupedTickets;
2860+
}
2861+
2862+
$collaboratorsByTicket = $this->ticketRepository->getCollaboratorsByTicketIds($ticketIds);
2863+
2864+
foreach ($groupedTickets as &$group) {
2865+
foreach (($group['items'] ?? []) as &$ticket) {
2866+
$ticketId = (int) ($ticket['id'] ?? 0);
2867+
$editorId = (int) ($ticket['editorId'] ?? 0);
2868+
$collaboratorIds = $collaboratorsByTicket[$ticketId] ?? [];
2869+
2870+
// Do not duplicate the primary assignee in the collaborator UI stack.
2871+
if ($editorId > 0) {
2872+
$collaboratorIds = array_values(array_filter(
2873+
$collaboratorIds,
2874+
fn ($userId) => (int) $userId !== $editorId
2875+
));
2876+
}
2877+
2878+
$ticket['collaborators'] = $collaboratorIds;
2879+
$ticket['collaboratorPreview'] = array_slice($collaboratorIds, 0, 2);
2880+
$ticket['collaboratorCount'] = count($collaboratorIds);
2881+
$ticket['collaboratorOverflow'] = max(0, count($collaboratorIds) - count($ticket['collaboratorPreview']));
2882+
}
2883+
unset($ticket);
2884+
}
2885+
unset($group);
2886+
2887+
return $groupedTickets;
2888+
}
2889+
28392890
/**
28402891
* Retrieves the assignments for the ToDoWidget.
28412892
*

app/Domain/Tickets/Templates/showAll.tpl.php

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,11 +263,26 @@
263263
<td data-order="<?= $row['editorFirstname'] != '' ? $tpl->escape($row['editorFirstname']) : $tpl->__('dropdown.not_assigned')?>">
264264
<div class="dropdown ticketDropdown userDropdown noBg show f-left">
265265
<a class="dropdown-toggle" href="javascript:void(0);" role="button" id="userDropdownMenuLink<?= $row['id']?>" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
266-
<span class="text">
266+
<span class="text" style="display:inline-flex; align-items:center; gap:6px;">
267267
<?php if ($row['editorFirstname'] != '') {
268268
echo "<span id='userImage".$row['id']."'><img src='".BASE_URL.'/api/users?profileImage='.$row['editorId']."' width='25' style='vertical-align: middle; margin-right:5px;'/></span><span id='user".$row['id']."'>".$tpl->escape($row['editorFirstname']).'</span>';
269269
} else {
270270
echo "<span id='userImage".$row['id']."'><img src='".BASE_URL."/api/users?profileImage=false' width='25' style='vertical-align: middle; margin-right:5px;'/></span><span id='user".$row['id']."'>".$tpl->__('dropdown.not_assigned').'</span>';
271+
}
272+
273+
if (! empty($row['collaboratorPreview'])) {
274+
echo "<span class='ticket-collaborators' style='display:inline-flex; align-items:center; margin-left:4px;'>";
275+
276+
foreach ($row['collaboratorPreview'] as $index => $collaboratorId) {
277+
$offset = $index > 0 ? 'margin-left:-8px;' : '';
278+
echo "<span class='ticket-collaborator-avatar' title='".$tpl->__('label.collaborators')."' style='display:inline-flex; width:20px; height:20px; border-radius:999px; border:2px solid var(--main-background-color, #fff); overflow:hidden; ".$offset."'><img src='".BASE_URL.'/api/users?profileImage='.$collaboratorId."' width='20' height='20' style='display:block; width:20px; height:20px;'/></span>";
279+
}
280+
281+
if (($row['collaboratorOverflow'] ?? 0) > 0) {
282+
echo "<span class='ticket-collaborator-more' title='".$tpl->__('label.collaborators')."' style='display:inline-flex; align-items:center; justify-content:center; min-width:20px; height:20px; padding:0 5px; margin-left:4px; border-radius:999px; background:var(--accent-color, #e9ecef); color:var(--secondary-font-color, #333); font-size:11px; line-height:20px;'>+".(int) $row['collaboratorOverflow'].'</span>';
283+
}
284+
285+
echo '</span>';
271286
}?>
272287
</span>
273288
&nbsp;<i class="fa fa-caret-down" aria-hidden="true"></i>

0 commit comments

Comments
 (0)