mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 17:53:12 +00:00
* 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>
188 lines
5.4 KiB
TypeScript
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 };
|
|
}
|