Powiadomienia (w aplikacji i mailowe)
View on GitHubDodaj obsługę @mention do pola tekstowego
Przećwiczony przykład: system @mention już zbudowany dla komentarzy do zadań — wpisz @ w polu komentarza, wybierz członka projektu z pływającej listy podpowiedzi, a otrzyma powiadomienie "You were mentioned" (NotificationType::IssueMentioned, poprzez nowy event IssueMentioned). Ten przewodnik dokumentuje ten system od początku do końca, zarówno jako "jak to działa", jak i jako szablon do dodania tej samej funkcjonalności do innego pola tekstowego (opisu projektu, tytułu zadania, dowolnego innego pola opartego na zwykłym <textarea>).
NotificationType::IssueMentioned oraz jego wiersz w ustawieniach konta (tablica defaultNotificationTypes w AccountSettingsNotificationsTab.tsx) istniały już przed tą funkcjonalnością — ten przewodnik dotyczy podłączenia nowego eventu domenowego do pipeline'u za istniejącym typem powiadomienia, a nie dodania samego typu (zobacz
./01-add-a-new-notification-type.mdPowiadomienia (w aplikacji i mailowe)Dodaj nowy typ powiadomieniaPrzećwiczony przykład: powiadamianie członka, gdy zmieni się jego rola w projekcie — NotificationType::MemberRoleChanged. Dziś ProjectMemberService::update…
po to drugie).
Najważniejsza zasada w tym przewodniku
Śledź wzmiankę po id użytkownika i dokładnym zakresie znaków — nigdy przez dopasowywanie wpisanego tekstu @Name do listy nazw. Dwóch członków projektu może mieć tę samą nazwę wyświetlaną. Jeśli rozwiązujesz "kto został wspomniany" przez wyszukiwanie w treści komentarza @Jane Cooper, nie da się stwierdzić, o którą Jane Cooper chodzi, a klient mógłby — celowo lub nie — powiadomić niewłaściwą osobę. Wszędzie poniżej (wykrywanie, śledzenie przez kolejne edycje, zapis i renderowanie) operuje na id, nie na ciągach znaków z nazwami.
Jak działa komponowanie wzmianki (frontend)
Krok 1 — Wykryj wpisywane w trakcie zapytanie @query
Plik: resources/js/utils/mentions.ts
export interface ActiveMention {
query: string;
start: number;
}
/**
* Looks backward from the cursor for an in-progress "@mention": an "@"
* preceded by start-of-text or whitespace, followed by up to two
* space-separated words (enough to filter by first or first+last name) with
* no line break in between. Returns the partial name typed so far and the
* index of the "@" itself, or null if the cursor isn't inside a mention.
*/
export function findActiveMention(
text: string,
cursor: number,
): ActiveMention | null {
const upToCursor = text.slice(0, cursor);
const match = upToCursor.match(/(?:^|\s)@([^\s@\n]*(?:\s[^\s@\n]+)?)$/);
if (!match) return null;
const query = match[1];
return { query, start: cursor - query.length - 1 };
}
/**
* Ranks project members by how well their name matches the in-progress
* mention query, limited to a handful of results so the floating menu stays
* short. Matching is by name only (it's just filtering candidates to show),
* identity of whichever one gets picked is always tracked by id afterwards.
*/
export function filterUsersByMention(
users: AssignableUser[],
query: string,
): AssignableUser[] {
const normalized = query.trim().toLowerCase();
if (!normalized) return users.slice(0, 5);
return users
.filter((user) => user.name.toLowerCase().includes(normalized))
.slice(0, 5);
}
findActiveMention patrzy tylko wstecz od kursora — jest wywoływana przy każdym naciśnięciu klawisza, kliknięciu i zmianie zaznaczenia (syncMentionState w CommentForm.tsx), niczego nie mutuje i zwraca null w momencie, gdy kursor wyjdzie poza @słowo (np. adres email jak foo@bar nigdy tego nie wyzwala, ponieważ @ nie jest tam poprzedzone białym znakiem ani początkiem tekstu). filterUsersByMention jest celowo oparte na nazwie — decyduje wyłącznie, które kandydatury pokazać, a osoba, którą użytkownik faktycznie wybierze, jest od tego momentu identyfikowana po id.
Krok 2 — Pokaż pływające menu podpowiedzi
Plik: resources/js/Components/Molecules/MentionSuggestions/MentionSuggestions.tsx
import Avatar from '@/Components/Atoms/Avatar/Avatar';
import DropdownItem from '@/Components/Atoms/DropdownItem/DropdownItem';
import DropdownMenu from '@/Components/Atoms/DropdownMenu/DropdownMenu';
import { MentionSuggestionsProps } from '@/types/Components';
import { createPortal } from 'react-dom';
/**
* Floating "@mention" suggestion list, positioned at an arbitrary viewport
* coordinate (the typed "@" inside a textarea) rather than anchored to a
* trigger element, so it's rendered through a portal with its own fixed
* position instead of reusing useFloatingDropdown (which tracks a trigger
* element's bounding box).
*/
export default function MentionSuggestions({
users,
activeIndex,
position,
onSelect,
onHover,
}: MentionSuggestionsProps) {
if (users.length === 0) return null;
// Two project members can share a display name - without this, the list
// would show two identical-looking rows with no way to tell which is
// which before picking one.
const nameCounts = new Map<string, number>();
users.forEach((user) => {
nameCounts.set(user.name, (nameCounts.get(user.name) ?? 0) + 1);
});
return createPortal(
<DropdownMenu
position="floating"
style={{
position: 'fixed',
top: position.top,
left: position.left,
minWidth: 200,
zIndex: 9999,
}}
>
{users.map((user, index) => (
<DropdownItem
key={user.id}
appearance="flat"
isActive={index === activeIndex}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => onHover(index)}
onClick={() => onSelect(user)}
label={
<>
<Avatar
src={user.avatar ?? undefined}
initials={user.name.charAt(0)}
size="sm"
/>
<span className="truncate">{user.name}</span>
{(nameCounts.get(user.name) ?? 0) > 1 && (
<span className="shrink-0 text-xs text-[var(--text-muted-color)]">
#{user.id}
</span>
)}
</>
}
/>
))}
</DropdownMenu>,
document.body,
);
}
onMouseDown={(e) => e.preventDefault()} ma znaczenie: bez tego kliknięcie podpowiedzi odebrałoby fokus polu tekstowemu zanim zadziała onClick tego kliknięcia, tracąc pozycję kursora, której potrzebuje selectMention (krok 3). Odznaka #id obok zduplikowanej nazwy to jedyna rzecz odróżniająca dwa identycznie wyglądające wiersze — użyj ponownie tego wzorca nameCounts przy każdym pickerze listującym encje, które mogą dzielić etykietę.
Krok 3 — Śledź każdą wstawioną wzmiankę po dokładnym zakresie znaków, nie po nazwie
Plik: resources/js/utils/mentions.ts
/**
* A mention inserted into a comment being composed, tracked by its exact
* character range in the textarea's plain-text value rather than by name —
* this is what lets the compose box show clean "@Full Name" text while still
* being able to tell apart two project members who happen to share a name.
*/
export interface MentionRange {
start: number;
length: number;
userId: number;
name: string;
}
/**
* Re-anchors tracked mention ranges against a known, exact edit: the region
* `[editStart, editEnd)` of the text was replaced by `insertedLength`
* characters. A range entirely before that region is kept as-is, one
* entirely after it is shifted by the resulting length delta, and one that
* overlaps it is dropped — its "@Name" text is no longer guaranteed intact,
* so it can no longer be trusted to mean that user.
*
* The edit's boundaries must be known exactly (from the selection right
* before the edit was applied — see CommentForm's editRangeRef), not
* inferred by diffing the before/after text: a naive common-prefix/suffix
* walk can misjudge the boundary whenever the text right at the edit point
* happens to coincide with the inserted text, which for mentions is
* common — every mention starts with "@", so inserting one right before
* another is exactly this case.
*/
export function applyRangeEdit(
ranges: MentionRange[],
editStart: number,
editEnd: number,
insertedLength: number,
): MentionRange[] {
if (ranges.length === 0) return ranges;
const delta = insertedLength - (editEnd - editStart);
const survivors: MentionRange[] = [];
for (const range of ranges) {
const end = range.start + range.length;
if (end <= editStart) {
survivors.push(range);
} else if (range.start >= editEnd) {
survivors.push({ ...range, start: range.start + delta });
}
// else: the edit overlaps this range - drop it.
}
return survivors;
}
applyRangeEdit potrzebuje dokładnych granic edycji. CommentForm.tsx uzyskuje je, robiąc migawkę zaznaczenia tuż przed tym, jak DOM zastosuje edycję — przy keydown (z korektą dla Backspace/Delete, ponieważ te klawisze rozszerzają zwinięty kursor odpowiednio wstecz/w przód o jeden znak przed usunięciem), oraz przy paste/cut:
const editRangeRef = useRef<{ start: number; end: number }>({
start: 0,
end: 0,
});
const captureEditRange = (
el: HTMLTextAreaElement,
deleteDirection?: 'backward' | 'forward',
) => {
let { selectionStart: start, selectionEnd: end } = el;
if (start === end) {
if (deleteDirection === 'backward' && start > 0) start -= 1;
else if (deleteDirection === 'forward') end += 1;
}
editRangeRef.current = { start, end };
};
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newBody = e.target.value;
const { start, end } = editRangeRef.current;
const insertedLength = newBody.length - body.length + (end - start);
setMentionRanges((prev) =>
applyRangeEdit(prev, start, end, insertedLength),
);
setBody(newBody);
syncMentionState(e.target);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// ... mention-menu navigation (ArrowUp/Down/Enter/Tab/Escape) goes
// here first and `return`s before reaching this - see the full file.
if (e.key === 'Backspace') {
captureEditRange(e.currentTarget, 'backward');
} else if (e.key === 'Delete') {
captureEditRange(e.currentTarget, 'forward');
} else {
captureEditRange(e.currentTarget);
}
};
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
captureEditRange(e.currentTarget);
};
const handleCut = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
captureEditRange(e.currentTarget);
};
selectMention (wstawienie wybranej podpowiedzi) już zna dokładnie swoje własne granice edycji — mention.start oraz aktualny selectionStart pola tekstowego — więc wywołuje applyRangeEdit bezpośrednio z tymi wartościami, uzgadniając każdy już śledzony zakres z tym nowym wstawieniem, zanim doda ten świeżo wybrany:
const selectMention = (user: AssignableUser) => {
if (!mention || !textareaRef.current) return;
const cursor = textareaRef.current.selectionStart;
const mentionText = `@${user.name}`;
const newBody =
body.slice(0, mention.start) +
mentionText +
' ' +
body.slice(cursor);
setBody(newBody);
setMentionRanges((prev) => [
// Reconciles existing ranges against this exact, known edit -
// inserting text at `mention.start` shifts any mention that
// comes after it.
...applyRangeEdit(
prev,
mention.start,
cursor,
mentionText.length + 1,
),
{
start: mention.start,
length: mentionText.length,
userId: user.id,
name: user.name,
},
]);
setMention(null);
setPendingCaret(mention.start + mentionText.length + 1);
};
Jeśli dodajesz wzmianki do innego pola, cały ten mechanizm śledzenia zakresów (MentionRange + applyRangeEdit + przechwytywanie editRangeRef) jest częścią wielokrotnego użytku — skopiuj go bez zmian; zmienia się tylko otaczające podłączenie pola tekstowego.
Krok 4 — Przepisz zwykły tekst na jednoznaczny token przed wysłaniem
Plik: resources/js/utils/mentions.ts
/**
* Rewrites a comment's plain-text body into the persisted form, replacing
* each tracked mention's plain "@Name" with an "@[Name](id)" token so it can
* be resolved back to the exact user unambiguously later, regardless of any
* other project member sharing that same display name. Applied right-to-left
* so earlier ranges' offsets stay valid while later ones are rewritten.
*/
export function tokenizeMentionRanges(
body: string,
ranges: MentionRange[],
): string {
const sorted = [...ranges].sort((a, b) => b.start - a.start);
return sorted.reduce((text, range) => {
const token = `@[${range.name}](${range.userId})`;
return (
text.slice(0, range.start) +
token +
text.slice(range.start + range.length)
);
}, body);
}
handleSubmit w CommentForm.tsx wywołuje to tuż przed wysłaniem, więc samo pole tekstowe pokazuje podczas komponowania zawsze czysty tekst @Full Name (nigdy surowy token):
const handleSubmit = (e: SyntheticEvent) => {
e.preventDefault();
if (!body.trim()) return;
const finalBody = tokenizeMentionRanges(body, mentionRanges);
const mentionedUserIds = [
...new Set(mentionRanges.map((range) => range.userId)),
];
onSubmit(finalBody, mentionedUserIds);
setBody('');
setMentionRanges([]);
setMention(null);
};
Zarówno stokenizowane body, jak i zwykła tablica mentionedUserIds trafiają do backendu — backend na nowo wyprowadza wiarygodną wersję tego drugiego z tego pierwszego (krok 5), nigdy po prostu nie ufa tej tablicy.
Krok 5 — Wyrenderuj token z powrotem jako chip wzmianki
Plik: resources/js/utils/mentions.ts
const MENTION_TOKEN_PATTERN = /@\[([^\]]+)\]\((\d+)\)/g;
export type MentionSegment =
| { type: 'text'; value: string }
| {
type: 'mention';
value: string;
userId: number;
name: string;
avatar?: string | null;
};
/**
* Splits a persisted comment body into plain-text and mention segments,
* parsing the "@[Name](id)" token format so each mention resolves back to an
* exact user id — never ambiguous even when two project members share a
* display name. The rendered name/avatar are looked up live by id (falling
* back to the name captured in the token if that member is no longer in the
* project), so a later rename still shows correctly.
*/
export function splitMentionText(
body: string,
users: AssignableUser[] = [],
): MentionSegment[] {
if (!body) return [{ type: 'text', value: body }];
const usersById = new Map(users.map((user) => [user.id, user]));
const segments: MentionSegment[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
MENTION_TOKEN_PATTERN.lastIndex = 0;
while ((match = MENTION_TOKEN_PATTERN.exec(body)) !== null) {
const [full, tokenName, tokenId] = match;
const userId = Number(tokenId);
const user = usersById.get(userId);
if (match.index > lastIndex) {
segments.push({
type: 'text',
value: body.slice(lastIndex, match.index),
});
}
segments.push({
type: 'mention',
value: `@${user?.name ?? tokenName}`,
userId,
name: user?.name ?? tokenName,
avatar: user?.avatar,
});
lastIndex = match.index + full.length;
}
if (lastIndex < body.length) {
segments.push({ type: 'text', value: body.slice(lastIndex) });
}
if (segments.length === 0) {
segments.push({ type: 'text', value: body });
}
return segments;
}
CommentItem.tsx używa tego przez prop renderDisplay komponentu EditableText, żeby wyrenderować każdą wzmiankę jako chip z awatarem i nazwą:
const renderBody = (value: string) =>
splitMentionText(value, users).map((segment, index) =>
segment.type === 'mention' ? (
<span
key={index}
className="bg-[var(--accent-color)]/10 mx-0.5 inline-flex items-center gap-1 rounded px-1 align-middle font-medium text-[var(--accent-color)]"
>
<Avatar
src={segment.avatar ?? undefined}
initials={segment.name.charAt(0)}
size="sm"
/>
{segment.value}
</span>
) : (
<React.Fragment key={index}>{segment.value}</React.Fragment>
),
);
Edycja istniejącego komentarza pokazuje surowy token @[Name](id) w zwykłym <textarea> (nie ma bogatego, ponownego skomponowania już stokenizowanej wzmianki) — to akceptowalne, ponieważ tryb edycji EditableText już pokazuje dosłowną zapisaną wartość dla każdego pola, wzmianki włącznie.
Jak backend rozwiązuje i powiadamia o wzmiankach
Krok 6 — Zwaliduj i rozwiąż mentioned_user_ids
Plik: app/Http/Controllers/CommentController.php
public function store(Request $request, Issue $issue): RedirectResponse
{
$this->authorize('create', [Comment::class, $issue]);
$data = $request->validate([
'body' => 'required|string',
'mentioned_user_ids' => 'sometimes|array',
'mentioned_user_ids.*' => 'integer|exists:users,id',
]);
$this->commentService->addComment($issue, $data);
return redirect()->back()
->with('success', 'Comment added.')
->with('action_url', route('issues.show', [$issue->project_id, $issue->id]));
}
Walidacja potwierdza tylko, że każde id to prawdziwy użytkownik — nic nie mówi o tym, czy ten użytkownik faktycznie należy do tego projektu, ani czy faktycznie jest wymieniony w komentarzu. CommentService sam wykonuje obie te kontrole, ponieważ spreparowane żądanie mogłoby w przeciwnym razie sprawić, że niezwiązana osoba zostanie powiadomiona jako "wspomniana", mimo że jej nazwa nigdy nie pojawia się w widocznej treści komentarza:
Plik: app/Services/CommentService.php
/**
* Keeps only the mentioned user ids that are (a) actually members of the
* issue's project, and (b) actually referenced by an "@[Name](id)"
* mention token in the comment body — a client can't claim
* mentioned_user_ids for someone the visible comment text never
* actually mentions.
*
* @return list<int>
*/
private function resolveMentionedUserIds(Issue $issue, array $data): array
{
$requestedIds = array_unique($data['mentioned_user_ids'] ?? []);
$requestedIds = array_filter($requestedIds, fn ($id) => $id !== auth()->id());
if (empty($requestedIds)) {
return [];
}
$tokenIds = $this->extractMentionTokenIds($data['body'] ?? '');
$requestedIds = array_intersect($requestedIds, $tokenIds);
if (empty($requestedIds)) {
return [];
}
$memberIds = $this->projectRepository->getMemberIds($issue->project);
return array_values(array_intersect($requestedIds, $memberIds));
}
/**
* @return list<int>
*/
private function extractMentionTokenIds(string $body): array
{
preg_match_all('/@\[[^\]]+\]\((\d+)\)/', $body, $matches);
return array_map('intval', $matches[1] ?? []);
}
ProjectRepository::getMemberIds() to metoda repozytorium stojąca za sprawdzeniem członkostwa — kod zapytań należy tam, nie inline w serwisie:
Plik: app/Repositories/ProjectRepository.php
/**
* @return list<int>
*/
public function getMemberIds(Project $project): array
{
return $project->users()->pluck('users.id')->all();
}
Krok 7 — Stwórz event i wystrzel go
Plik: app/Events/IssueMentioned.php
<?php
namespace App\Events;
use App\Models\Comment;
use App\Models\Issue;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
final class IssueMentioned
{
use Dispatchable;
public function __construct(
public readonly Issue $issue,
public readonly Comment $comment,
public readonly User $mentionedUser,
public readonly ?User $actor,
) {}
}
Ta sama konwencja co CommentAdded: niesie prawdziwe modele (Issue, Comment, wspomnianego User, opcjonalnego aktora User), nie surowe id — zobacz
../integrations/03-add-a-new-event-type.mdIntegracjeDodaj nowy typ eventuPrzećwiczony przykład: dodanie IssueCreated — rodzaju aktywności, który jeszcze nie istnieje. Dziś stworzenie issue odpala tylko IssueAssigned, i to tylko…
po pełne uzasadnienie. CommentService wystrzeliwuje jeden IssueMentioned na każde rozwiązane id, używając UserRepository::findById() (nie User::find() inline) do wczytania każdego wspomnianego użytkownika:
Plik: app/Services/CommentService.php
/**
* @param list<int> $mentionedUserIds
*/
private function fireMentionEvents(Issue $issue, Comment $comment, array $mentionedUserIds): void
{
foreach ($mentionedUserIds as $userId) {
$mentionedUser = $this->userRepository->findById($userId);
if (! $mentionedUser) {
continue;
}
event(new IssueMentioned($issue, $comment, $mentionedUser, auth()->user()));
}
}
Zarówno addComment(), jak i updateComment() wywołują resolveMentionedUserIds(), a potem fireMentionEvents() — edycja komentarza w celu dodania nowej wzmianki powiadamia tę osobę tak samo, jak stworzenie komentarza, który ją wspomina.
Krok 8 — Zarejestruj go i obsłuż w SendNotificationListener
Plik: app/Providers/AppServiceProvider.php, boot() — zarejestrowany wyłącznie dla SendNotificationListener. To osobiste powiadomienie dla jednego odbiorcy, a nie aktywność w całym projekcie, więc nie jest dodawany do listy eventów NotifyProjectIntegrationsListener:
Event::listen([
IssueAssigned::class,
IssueUnassigned::class,
IssueUpdated::class,
CommentAdded::class,
IssueMentioned::class,
ProjectInvited::class,
IssuesImported::class,
], SendNotificationListener::class);
Plik: app/Listeners/SendNotificationListener.php:
public function handle(object $event): void
{
match (true) {
$event instanceof IssueAssigned => $this->handleIssueAssigned($event),
$event instanceof IssueUnassigned => $this->handleIssueUnassigned($event),
$event instanceof IssueUpdated => $this->handleIssueUpdated($event),
$event instanceof CommentAdded => $this->handleCommentAdded($event),
$event instanceof IssueMentioned => $this->handleIssueMentioned($event),
$event instanceof ProjectInvited => $this->handleProjectInvited($event),
$event instanceof IssuesImported => $this->handleIssuesImported($event),
default => null,
};
}
private function handleIssueMentioned(IssueMentioned $event): void
{
if ($event->actor && $event->actor->id === $event->mentionedUser->id) {
return;
}
$issue = $event->issue;
$actorName = $event->actor?->name ?? 'Someone';
$this->notificationService->notify(
$event->mentionedUser->id,
NotificationType::IssueMentioned,
'info',
'You were mentioned',
"$actorName mentioned you in a comment on \"$issue->title\" (#$issue->id).",
route('issues.show', [$issue->project_id, $issue->id])
);
}
Zabezpieczenie aktor/wspomniany-użytkownik odzwierciedla wzorzec "nie powiadamiaj kogoś o jego własnym działaniu" z handleCommentAdded() — istotne, ponieważ resolveMentionedUserIds() już usuwa auth()->id() z żądanych id, ale to jest ostatnia linia obrony, gdyby to kiedyś się zmieniło. NotificationType::IssueMentioned oraz jego wiersz w ustawieniach konta istniały już przed tą funkcjonalnością (zobacz
./01-add-a-new-notification-type.mdPowiadomienia (w aplikacji i mailowe)Dodaj nowy typ powiadomieniaPrzećwiczony przykład: powiadamianie członka, gdy zmieni się jego rola w projekcie — NotificationType::MemberRoleChanged. Dziś ProjectMemberService::update…
krok 5, jeśli dodajesz nowy typ zamiast używać istniejącego).
Krok 9 — Testy
resources/js/utils/mentions.test.ts— pokrywafindActiveMention,filterUsersByMention,applyRangeEdit(włącznie z przypadkiem regresyjnym wstawienia jednej wzmianki bezpośrednio przed inną, obie zaczynające się od@),tokenizeMentionRangesorazsplitMentionText(włącznie z odróżnieniem dwóch użytkowników o tej samej nazwie po id w tokenie).resources/js/Components/Molecules/CommentForm/CommentForm.test.tsx— pokrywa wpisanie@pokazujące podpowiedzi, wybranie jednej myszką i klawiaturą, wysłanie stokenizowanej treści, usunięcie wybranej wzmianki (usuwa ją z wysłanych id) oraz wstawienie wzmianki przed już wybraną (obie przetrwają z poprawnymi offsetami):test('inserting a mention before an already-selected one keeps both intact', async () => { const handleSubmit = vi.fn(); render(<CommentForm onSubmit={handleSubmit} users={users} />); const textarea = screen.getByPlaceholderText( 'Leave a comment...', ) as HTMLTextAreaElement; await userEvent.type(textarea, '@b'); await userEvent.click(screen.getByText('Bob Smith')); expect(textarea).toHaveValue('@Bob Smith '); textarea.focus(); textarea.setSelectionRange(0, 0); await userEvent.keyboard('@jane'); await userEvent.click(screen.getByText('Jane Cooper')); expect(textarea).toHaveValue('@Jane Cooper @Bob Smith '); await userEvent.click( screen.getByRole('button', { name: 'Post comment' }), ); expect(handleSubmit).toHaveBeenCalledWith( '@[Jane Cooper](1) @[Bob Smith](2) ', expect.arrayContaining([1, 2]), ); });resources/js/Components/Molecules/CommentItem/CommentItem.test.tsx— pokrywa podświetlenie tokena wzmianki oraz odróżnienie dwóch użytkowników o tej samej nazwie po id w tokenie.resources/js/Components/Molecules/MentionSuggestions/MentionSuggestions.test.tsx— pokrywa renderowanie, wybieranie i najechanie na podpowiedź.tests/Feature/CommentServiceTest.php—'addComment fires IssueMentioned for each mentioned project member','addComment ignores a mentioned id with no matching mention token in the body'(przypadek istotny pod kątem bezpieczeństwa — treść, która nigdy faktycznie nie wymienia tej osoby),'addComment ignores mentioned ids that are not project members','addComment does not fire IssueMentioned for mentioning yourself'oraz odpowiednik dlaupdateComment:test('addComment ignores a mentioned id with no matching mention token in the body', function () { // A client can't get someone notified as "mentioned" just by listing // their id - the visible comment text has to actually name them. $author = User::factory()->create(); $project = Project::factory()->create(); $mentioned = User::factory()->create(); $project->users()->attach([$author->id => ['role' => 'member'], $mentioned->id => ['role' => 'member']]); $issue = Issue::factory()->create(['project_id' => $project->id, 'assignee_id' => null]); $comment = Comment::factory()->make(['issue_id' => $issue->id, 'user_id' => $author->id]); $this->actingAs($author); $this->commentRepository->shouldReceive('store')->once()->andReturn($comment); $this->activityLogService->shouldReceive('log')->once(); $this->service->addComment($issue, [ 'body' => 'Hi there, nothing mentioned here', 'mentioned_user_ids' => [$mentioned->id], ]); Event::assertNotDispatched(IssueMentioned::class); });tests/Feature/CommentControllerTest.php—'commenting with mentioned_user_ids fires IssueMentioned for that member'oraz'commenting ignores a mentioned_user_ids entry with no matching mention token in the body', plus istniejący'commenting rejects a mentioned_user_ids entry that is not a real user'dla samej reguły walidacji.tests/Feature/SendNotificationListenerTest.php—'IssueMentioned notifies the mentioned user about the actor\'s comment'oraz'IssueMentioned does not notify when the actor mentions themself', odzwierciedlające kształt testówProjectInvited.tests/Feature/ProjectRepositoryTest.php—'it can get the member ids of a project', pokrywającygetMemberIds()bezpośrednio.
