shoc-frontend-new/src/domain/work-orders/utils/comment-mentions.ts
Arthur Bassi f35fce9bd5
feat(work-orders): comment @mentions autocomplete (SH-217) (#91)
* docs(work-orders): add pending BE contract checklist for design parity gaps

* feat(work-orders): add mentions in work-order comments composer

* docs(work-orders): align pr description with jira and qa residuals

* refactor(workorders): split work order view page into view components

* fix(work-orders): export comment assignment vendor input types for view hook

* fix(work-orders): drop preferredContact from vendor update payload

Align with WorkOrderVendorUpdatePayload on current dev so the mentions branch typechecks.

* fix(work-orders): remove Preferred Contact from WO vendor editor (SH-180)

EOF

* fix(work-orders): harden comment mentions and clear draft on success

* fix(work-orders): harden mention highlight boundary and submit catch [SH-217]

* fix(work-orders): keep mention cursor sync on current value [SH-217]

* fix(work-orders): keep mention menu open after typing @

Pass live text into syncCursor instead of a render-time valueRef.
Add a composer interaction regression for @ autocomplete visibility.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Alexandre Brandizzi <alex_brandizzi@hotmail.com>
2026-08-11 14:07:02 -03:00

188 lines
5.4 KiB
TypeScript

/** Encoded mention token stored in comment text: `@[userId:Display Name]`. */
export const MENTION_TOKEN_PATTERN = /@\[([^:\]]+):([^\]]+)\]/g;
export type CommentBodySegment =
{ type: "text"; value: string } | { type: "mention"; userId: string; label: string };
export type MentionLookup = {
id: string | number;
name: string;
};
export function encodeMentionToken(userId: string | number, userName: string): string {
const trimmedName = userName.trim();
return `@[${String(userId)}:${trimmedName}]`;
}
export function extractMentionIds(text: string): string[] {
const ids: string[] = [];
const pattern = new RegExp(MENTION_TOKEN_PATTERN.source, "g");
for (const match of text.matchAll(pattern)) {
const userId = match[1];
if (userId && !ids.includes(userId)) {
ids.push(userId);
}
}
return ids;
}
export function toDisplayText(text: string): string {
return text.replace(MENTION_TOKEN_PATTERN, (_full, _userId: string, userName: string) => {
return `@${userName}`;
});
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function pushTextSegment(segments: CommentBodySegment[], value: string): void {
if (!value) {
return;
}
const last = segments.length > 0 ? segments[segments.length - 1] : undefined;
if (last?.type === "text") {
last.value += value;
return;
}
segments.push({ type: "text", value });
}
function resolveMentionNames(
mentionIds: string[] | undefined,
users: MentionLookup[] | undefined,
): Map<string, string> {
const byId = new Map<string, string>();
const usersById = new Map((users ?? []).map((user) => [String(user.id), user.name] as const));
// Only ids that were actually mentioned — never seed the full dispatcher roster.
for (const mentionId of mentionIds ?? []) {
const id = String(mentionId);
byId.set(id, usersById.get(id) ?? id);
}
return byId;
}
function appendPlainMentionMatches(
segments: CommentBodySegment[],
plainText: string,
mentionNames: string[],
): void {
if (!plainText || mentionNames.length === 0) {
pushTextSegment(segments, plainText);
return;
}
const sortedNames = [...mentionNames].sort((a, b) => b.length - a.length);
const namePattern = sortedNames.map((name) => escapeRegExp(name)).join("|");
// Require a non-word boundary after the name so "@Ann" does not match inside "@Annual".
const mentionRegex = new RegExp(`@(${namePattern})(?![\\w])`, "g");
let cursor = 0;
for (const match of plainText.matchAll(mentionRegex)) {
const index = match.index ?? 0;
pushTextSegment(segments, plainText.slice(cursor, index));
const label = match[1] ?? "";
segments.push({ type: "mention", userId: label, label });
cursor = index + match[0].length;
}
pushTextSegment(segments, plainText.slice(cursor));
}
export function buildCommentBodySegments(
text: string,
mentionIds?: string[],
users?: MentionLookup[],
): CommentBodySegment[] {
if (!text) {
return [];
}
const segments: CommentBodySegment[] = [];
const encodedPattern = new RegExp(MENTION_TOKEN_PATTERN.source, "g");
const mentionNameById = resolveMentionNames(mentionIds, users);
const encodedLabels: string[] = [];
for (const match of text.matchAll(new RegExp(MENTION_TOKEN_PATTERN.source, "g"))) {
const label = match[2]?.trim();
if (label) encodedLabels.push(label);
}
const mentionNames = [...new Set([...mentionNameById.values(), ...encodedLabels])].filter(
Boolean,
);
let cursor = 0;
for (const match of text.matchAll(encodedPattern)) {
const index = match.index ?? 0;
const plainBetween = text.slice(cursor, index);
appendPlainMentionMatches(segments, plainBetween, mentionNames);
const userId = match[1] ?? "";
const label = match[2] ?? userId;
segments.push({ type: "mention", userId, label });
cursor = index + match[0].length;
}
const trailingPlain = text.slice(cursor);
appendPlainMentionMatches(segments, trailingPlain, mentionNames);
return segments.length > 0 ? segments : [{ type: "text", value: text }];
}
export type ActiveMentionQuery = {
start: number;
query: string;
};
export function findActiveMentionQuery(text: string, cursor: number): ActiveMentionQuery | null {
const beforeCursor = text.slice(0, cursor);
const atIndex = beforeCursor.lastIndexOf("@");
if (atIndex < 0) {
return null;
}
const prefix = beforeCursor.slice(0, atIndex);
const lastPrefixChar = prefix.length > 0 ? prefix[prefix.length - 1] : "";
if (prefix.length > 0 && !/\s/.test(lastPrefixChar)) {
return null;
}
const query = beforeCursor.slice(atIndex + 1);
if (/[\n\r\t[\]]/.test(query)) {
return null;
}
return { start: atIndex, query };
}
export function filterMentionCandidates(
candidates: MentionLookup[],
query: string,
): MentionLookup[] {
const normalized = query.trim().toLowerCase();
const filtered = normalized
? candidates.filter((candidate) => candidate.name.toLowerCase().includes(normalized))
: candidates;
return filtered.slice(0, 8);
}
export function insertMentionToken(
text: string,
queryStart: number,
cursor: number,
user: MentionLookup,
): { nextText: string; nextCursor: number } {
const token = `${encodeMentionToken(user.id, user.name)} `;
const nextText = `${text.slice(0, queryStart)}${token}${text.slice(cursor)}`;
const nextCursor = queryStart + token.length;
return { nextText, nextCursor };
}