Normalize ticket mentions in editor and server

This commit is contained in:
codex-bot 2025-10-24 16:35:55 -03:00
parent cf11ac9bcb
commit 296e02cf0c
5 changed files with 447 additions and 40 deletions

View file

@ -84,13 +84,29 @@ function truncateSubject(subject: string) {
return `${subject.slice(0, 57)}`
}
const TICKET_MENTION_ANCHOR_CLASSES =
"ticket-mention inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-100 px-2.5 py-1 text-xs font-semibold text-neutral-800 no-underline transition hover:bg-slate-200"
const TICKET_MENTION_REF_CLASSES = "ticket-mention-ref text-neutral-900"
const TICKET_MENTION_SEP_CLASSES = "ticket-mention-sep text-neutral-400"
const TICKET_MENTION_SUBJECT_CLASSES = "ticket-mention-subject max-w-[220px] truncate text-neutral-700"
const TICKET_MENTION_DOT_BASE_CLASSES = "ticket-mention-dot inline-flex size-2 rounded-full"
const TICKET_MENTION_STATUS_TONE: Record<TicketStatusNormalized, string> = {
PENDING: "bg-amber-400",
AWAITING_ATTENDANCE: "bg-sky-500",
PAUSED: "bg-violet-500",
RESOLVED: "bg-emerald-500",
}
function buildTicketMentionAnchor(ticket: Doc<"tickets">): string {
const reference = ticket.reference
const subject = escapeHtml(ticket.subject ?? "")
const truncated = truncateSubject(subject)
const status = (ticket.status ?? "PENDING").toString().toUpperCase()
const priority = (ticket.priority ?? "MEDIUM").toString().toUpperCase()
return `<a data-ticket-mention="true" data-ticket-id="${String(ticket._id)}" data-ticket-reference="${reference}" data-ticket-status="${status}" data-ticket-priority="${priority}" data-ticket-subject="${subject}" href="/tickets/${String(ticket._id)}" class="ticket-mention" title="Chamado #${reference}${subject ? `${subject}` : ""}"><span class="ticket-mention-dot"></span><span class="ticket-mention-ref">#${reference}</span><span class="ticket-mention-sep">•</span><span class="ticket-mention-subject">${truncated}</span></a>`
const normalizedStatus = normalizeStatus(status)
const dotTone = TICKET_MENTION_STATUS_TONE[normalizedStatus] ?? "bg-slate-400"
const dotClass = `${TICKET_MENTION_DOT_BASE_CLASSES} ${dotTone}`
return `<a data-ticket-mention="true" data-ticket-id="${String(ticket._id)}" data-ticket-reference="${reference}" data-ticket-status="${status}" data-ticket-priority="${priority}" data-ticket-subject="${subject}" status="${normalizedStatus}" href="/tickets/${String(ticket._id)}" class="${TICKET_MENTION_ANCHOR_CLASSES}" rel="noopener noreferrer" target="_self" title="Chamado #${reference}${subject ? `${subject}` : ""}"><span class="${dotClass}"></span><span class="${TICKET_MENTION_REF_CLASSES}">#${reference}</span><span class="${TICKET_MENTION_SEP_CLASSES}">•</span><span class="${TICKET_MENTION_SUBJECT_CLASSES}">${truncated}</span></a>`
}
function canMentionTicket(viewerRole: string, viewerId: Id<"users">, ticket: Doc<"tickets">) {
@ -111,11 +127,11 @@ async function normalizeTicketMentions(
viewer: { user: Doc<"users">; role: string },
tenantId: string,
): Promise<string> {
if (!html || html.indexOf("data-ticket-mention") === -1) {
if (!html || (html.indexOf("data-ticket-mention") === -1 && html.indexOf("ticket-mention") === -1)) {
return html
}
const mentionPattern = /<a\b[^>]*data-ticket-mention="true"[^>]*>[\s\S]*?<\/a>/gi
const mentionPattern = /<a\b[^>]*(?:data-ticket-mention="true"|class="[^"]*ticket-mention[^"]*")[^>]*>[\s\S]*?<\/a>/gi
const matches = Array.from(html.matchAll(mentionPattern))
if (!matches.length) {
return html
@ -123,10 +139,23 @@ async function normalizeTicketMentions(
let output = html
const attributePattern = /(data-[\w-]+|class|href)="([^"]*)"/gi
for (const match of matches) {
const full = match[0]
const idMatch = /data-ticket-id="([^"]+)"/i.exec(full)
const ticketIdRaw = idMatch?.[1]
attributePattern.lastIndex = 0
const attributes: Record<string, string> = {}
let attrMatch: RegExpExecArray | null
while ((attrMatch = attributePattern.exec(full)) !== null) {
attributes[attrMatch[1]] = attrMatch[2]
}
let ticketIdRaw: string | null = attributes["data-ticket-id"] ?? null
if (!ticketIdRaw && attributes.href) {
const hrefPath = attributes.href.split("?")[0]
const segments = hrefPath.split("/").filter(Boolean)
ticketIdRaw = segments.pop() ?? null
}
let replacement = ""
if (ticketIdRaw) {

View file

@ -171,21 +171,20 @@
.rich-text .ProseMirror .ticket-mention {
@apply inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-100 px-2.5 py-1 text-xs font-semibold text-neutral-800 no-underline transition hover:bg-slate-200;
}
/* While editing, TipTap renders the mention as a span with data-type="ticketMention".
Mirror the same chip style so the layout stays consistent during edits. */
.rich-text .ProseMirror [data-type="ticketMention"] {
/* Fallback for legacy editing markup before the custom node view hydrates. */
.rich-text .ProseMirror [data-type="ticketMention"]:not([data-ticket-mention="true"]) {
@apply inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-100 px-2.5 py-1 text-xs font-semibold text-neutral-800 align-middle;
position: relative;
}
.rich-text .ProseMirror [data-type="ticketMention"]::before {
.rich-text .ProseMirror [data-type="ticketMention"]:not([data-ticket-mention="true"])::before {
content: "";
@apply inline-block size-2 rounded-full bg-slate-400;
margin-right: 0.375rem; /* ~gap-1.5 */
}
.rich-text .ProseMirror [data-type="ticketMention"][status="PENDING"]::before { @apply bg-amber-400; }
.rich-text .ProseMirror [data-type="ticketMention"][status="AWAITING_ATTENDANCE"]::before { @apply bg-sky-500; }
.rich-text .ProseMirror [data-type="ticketMention"][status="PAUSED"]::before { @apply bg-violet-500; }
.rich-text .ProseMirror [data-type="ticketMention"][status="RESOLVED"]::before { @apply bg-emerald-500; }
.rich-text .ProseMirror [data-type="ticketMention"][data-ticket-status="PENDING"]:not([data-ticket-mention="true"])::before { @apply bg-amber-400; }
.rich-text .ProseMirror [data-type="ticketMention"][data-ticket-status="AWAITING_ATTENDANCE"]:not([data-ticket-mention="true"])::before { @apply bg-sky-500; }
.rich-text .ProseMirror [data-type="ticketMention"][data-ticket-status="PAUSED"]:not([data-ticket-mention="true"])::before { @apply bg-violet-500; }
.rich-text .ProseMirror [data-type="ticketMention"][data-ticket-status="RESOLVED"]:not([data-ticket-mention="true"])::before { @apply bg-emerald-500; }
.rich-text [data-ticket-mention="true"] .ticket-mention-dot,
.rich-text .ticket-mention .ticket-mention-dot {
@apply inline-flex size-2 rounded-full bg-slate-400;

View file

@ -16,7 +16,13 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { toast } from "sonner"
import { Dropzone } from "@/components/ui/dropzone"
import { RichTextEditor, RichTextContent, sanitizeEditorHtml, stripLeadingEmptyParagraphs } from "@/components/ui/rich-text-editor"
import {
RichTextEditor,
RichTextContent,
sanitizeEditorHtml,
stripLeadingEmptyParagraphs,
normalizeTicketMentionHtml,
} from "@/components/ui/rich-text-editor"
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
@ -82,7 +88,8 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
}
const startEditingComment = useCallback((commentId: string, currentBody: string) => {
setEditingComment({ id: commentId, value: currentBody || "" })
const normalized = normalizeTicketMentionHtml(currentBody || "")
setEditingComment({ id: commentId, value: normalized })
}, [])
const cancelEditingComment = useCallback(() => {
@ -96,7 +103,8 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
if (commentId.startsWith("temp-")) return
const sanitized = sanitizeEditorHtml(editingComment.value)
if (sanitized === originalBody) {
const normalized = normalizeTicketMentionHtml(sanitized)
if (normalized === originalBody) {
setEditingComment(null)
return
}
@ -109,9 +117,9 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
ticketId: ticket.id as Id<"tickets">,
commentId: commentId as unknown as Id<"ticketComments">,
actorId: convexUserId as Id<"users">,
body: sanitized,
body: normalized,
})
setLocalBodies((prev) => ({ ...prev, [commentId]: sanitized }))
setLocalBodies((prev) => ({ ...prev, [commentId]: normalized }))
setEditingComment(null)
toast.success("Comentário atualizado!", { id: toastId })
} catch (error) {
@ -133,7 +141,8 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
if (!convexUserId) return
// Enforce generous max length for comment plain text
const sanitized = sanitizeEditorHtml(body)
const plain = sanitized.replace(/<[^>]*>/g, "").replace(/&nbsp;/g, " ").trim()
const normalized = normalizeTicketMentionHtml(sanitized)
const plain = normalized.replace(/<[^>]*>/g, "").replace(/&nbsp;/g, " ").trim()
const MAX_COMMENT_CHARS = 20000
if (plain.length > MAX_COMMENT_CHARS) {
toast.error(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`, { id: "comment" })
@ -149,7 +158,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
id: `temp-${now.getTime()}`,
author: ticket.requester,
visibility: selectedVisibility,
body: sanitized,
body: normalized,
attachments: attachments.map((attachment) => ({
id: attachment.storageId,
name: attachment.name,
@ -176,7 +185,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
ticketId: ticket.id as Id<"tickets">,
authorId: convexUserId as Id<"users">,
visibility: selectedVisibility,
body: optimistic.body,
body: normalized,
attachments: payload,
})
setPending([])

View file

@ -70,13 +70,51 @@ type TicketMentionSuggestionProps = {
onRegister?: (handler: ((event: KeyboardEvent) => boolean) | null) => void
}
const TICKET_MENTION_CLASS = "ticket-mention"
const TICKET_MENTION_BASE_CLASSES =
"ticket-mention inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-100 px-2.5 py-1 text-xs font-semibold text-neutral-800 no-underline transition hover:bg-slate-200"
const TICKET_MENTION_DOT_BASE_CLASSES = "ticket-mention-dot inline-flex size-2 rounded-full bg-slate-400"
const TICKET_MENTION_REF_CLASSES = "ticket-mention-ref text-neutral-900"
const TICKET_MENTION_SEP_CLASSES = "ticket-mention-sep text-neutral-400"
const TICKET_MENTION_SUBJECT_CLASSES = "ticket-mention-subject max-w-[220px] truncate text-neutral-700"
function formatMentionSubject(subject: string) {
if (!subject) return ""
return subject.length > 60 ? `${subject.slice(0, 57)}` : subject
}
function extractTicketIdFromHref(href: string | null | undefined): string {
if (!href) return ""
try {
const normalized = href.split("?")[0]
const segments = normalized.split("/").filter(Boolean)
return segments.pop() ?? ""
} catch {
return ""
}
}
function parseTicketMentionText(text: string | null | undefined) {
const fallback = { reference: "", subject: "" }
if (!text) return fallback
const trimmed = text.replace(/\s+/g, " ").trim()
if (!trimmed) return fallback
const bulletIndex = trimmed.indexOf("•")
if (bulletIndex === -1) {
const hashIndex = trimmed.indexOf("#")
const ref = hashIndex === -1 ? trimmed : trimmed.slice(hashIndex + 1)
return { reference: ref.trim(), subject: "" }
}
const referencePart = trimmed.slice(0, bulletIndex)
const subjectPart = trimmed.slice(bulletIndex + 1)
const ref = referencePart.replace(/^#/, "").trim()
return { reference: ref, subject: subjectPart.trim() }
}
type TicketMentionAttributes = Record<string, unknown>
const TICKET_MENTION_FALLBACK_STATUS = "PENDING"
const TICKET_MENTION_FALLBACK_PRIORITY = "MEDIUM"
const statusLabels: Record<string, string> = {
PENDING: "Pendente",
AWAITING_ATTENDANCE: "Em andamento",
@ -91,6 +129,183 @@ const statusTone: Record<string, string> = {
RESOLVED: "bg-emerald-500",
}
function toPlainString(value: unknown): string {
if (value === null || value === undefined) return ""
return String(value)
}
function normalizeTicketMentionAttributes(attrs: TicketMentionAttributes) {
const id = toPlainString(attrs.id ?? attrs["data-ticket-id"])
const referenceRaw = attrs.reference ?? attrs["data-ticket-reference"] ?? attrs.id ?? ""
const reference = toPlainString(referenceRaw)
const subject = toPlainString(attrs.subject ?? attrs["data-ticket-subject"])
const statusRaw = toPlainString(attrs.status ?? attrs["data-ticket-status"] ?? TICKET_MENTION_FALLBACK_STATUS)
const status = statusRaw ? statusRaw.toUpperCase() : TICKET_MENTION_FALLBACK_STATUS
const priorityRaw = toPlainString(attrs.priority ?? attrs["data-ticket-priority"] ?? TICKET_MENTION_FALLBACK_PRIORITY)
const priority = priorityRaw ? priorityRaw.toUpperCase() : TICKET_MENTION_FALLBACK_PRIORITY
const url = toPlainString(attrs.url ?? attrs["data-ticket-url"] ?? attrs.href ?? "")
return { id, reference, subject, status, priority, url }
}
type TicketMentionNodeElements = {
root: HTMLAnchorElement
dotEl: HTMLSpanElement
referenceEl: HTMLSpanElement
subjectEl: HTMLSpanElement
separatorEl: HTMLSpanElement
}
function updateTicketMentionNodeElements(elements: TicketMentionNodeElements, attrs: TicketMentionAttributes) {
const { root, dotEl, referenceEl, subjectEl, separatorEl } = elements
const normalized = normalizeTicketMentionAttributes(attrs)
const referenceLabel = normalized.reference || normalized.id
const displayedReference = referenceLabel ? `#${referenceLabel}` : "#"
const formattedSubject = normalized.subject ? formatMentionSubject(normalized.subject) : ""
const additionalClass = toPlainString(attrs.class ?? attrs.className)
const statusToneClass = statusTone[normalized.status] ?? "bg-slate-400"
root.className = cn(TICKET_MENTION_BASE_CLASSES, additionalClass)
root.dataset.ticketMention = "true"
dotEl.className = cn(TICKET_MENTION_DOT_BASE_CLASSES, statusToneClass)
referenceEl.className = TICKET_MENTION_REF_CLASSES
subjectEl.className = TICKET_MENTION_SUBJECT_CLASSES
separatorEl.className = TICKET_MENTION_SEP_CLASSES
referenceEl.textContent = displayedReference
subjectEl.textContent = formattedSubject
separatorEl.style.display = formattedSubject ? "" : "none"
if (normalized.id) {
root.dataset.ticketId = normalized.id
} else {
delete root.dataset.ticketId
}
if (normalized.reference) {
root.dataset.ticketReference = normalized.reference
} else {
delete root.dataset.ticketReference
}
if (normalized.status) {
root.dataset.ticketStatus = normalized.status
root.setAttribute("status", normalized.status)
} else {
root.dataset.ticketStatus = TICKET_MENTION_FALLBACK_STATUS
root.setAttribute("status", TICKET_MENTION_FALLBACK_STATUS)
}
if (normalized.priority) {
root.dataset.ticketPriority = normalized.priority
} else {
delete root.dataset.ticketPriority
}
if (normalized.subject) {
root.dataset.ticketSubject = normalized.subject
} else {
delete root.dataset.ticketSubject
}
if (normalized.url) {
root.dataset.ticketUrl = normalized.url
} else {
delete root.dataset.ticketUrl
}
if (normalized.url) {
root.href = normalized.url
root.rel = "noopener noreferrer"
root.target = "_self"
} else {
root.removeAttribute("href")
}
root.title = formattedSubject ? `${displayedReference}${formattedSubject}` : displayedReference
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
}
function buildTicketMentionAnchorHtml(attrs: {
id: string
reference: string
subject: string
status: string
priority: string
url: string
}) {
const id = attrs.id ?? ""
const reference = attrs.reference ?? ""
const subject = attrs.subject ?? ""
const status = (attrs.status || TICKET_MENTION_FALLBACK_STATUS).toUpperCase()
const priority = (attrs.priority || TICKET_MENTION_FALLBACK_PRIORITY).toUpperCase()
const url = attrs.url ?? ""
const displayedReference = reference || id
const formattedSubject = subject ? formatMentionSubject(subject) : ""
const dotToneClass = statusTone[status] ?? "bg-slate-400"
const dotClass = `${TICKET_MENTION_DOT_BASE_CLASSES} ${dotToneClass}`
const title = formattedSubject ? `#${displayedReference}${formattedSubject}` : `#${displayedReference}`
return `<a data-ticket-mention="true" data-ticket-id="${escapeHtml(id)}" data-ticket-reference="${escapeHtml(reference)}" data-ticket-status="${escapeHtml(status)}" data-ticket-priority="${escapeHtml(priority)}" data-ticket-subject="${escapeHtml(subject)}" status="${escapeHtml(status)}" href="${escapeHtml(url)}" class="${escapeHtml(TICKET_MENTION_BASE_CLASSES)}" rel="noopener noreferrer" target="_self" title="${escapeHtml(title)}"><span class="${escapeHtml(dotClass)}"></span><span class="${escapeHtml(TICKET_MENTION_REF_CLASSES)}">#${escapeHtml(displayedReference)}</span><span class="${escapeHtml(TICKET_MENTION_SEP_CLASSES)}">•</span><span class="${escapeHtml(TICKET_MENTION_SUBJECT_CLASSES)}">${escapeHtml(formattedSubject)}</span></a>`
}
export function normalizeTicketMentionHtml(html: string): string {
if (!html || html.indexOf("ticket-mention") === -1) {
return html
}
const mentionPattern = /<a\b[^>]*class="[^"]*ticket-mention[^"]*"[^>]*>[\s\S]*?<\/a>/gi
const attributePattern = /(data-[\w-]+|href|status)="([^"]*)"/gi
return html.replace(mentionPattern, (match) => {
const attributes: Record<string, string> = {}
let attrMatch: RegExpExecArray | null
attributePattern.lastIndex = 0
while ((attrMatch = attributePattern.exec(match)) !== null) {
attributes[attrMatch[1]] = attrMatch[2]
}
const href = attributes.href ?? ""
const parsedText = parseTicketMentionText(match.replace(/<[^>]*>/g, " "))
const id = attributes["data-ticket-id"] || extractTicketIdFromHref(href)
const reference = attributes["data-ticket-reference"] || parsedText.reference || id
const subject = attributes["data-ticket-subject"] || parsedText.subject || ""
const status = attributes["data-ticket-status"] || attributes.status || TICKET_MENTION_FALLBACK_STATUS
const priority = attributes["data-ticket-priority"] || TICKET_MENTION_FALLBACK_PRIORITY
return buildTicketMentionAnchorHtml({ id, reference, subject, status, priority, url: href })
})
}
function resolveMentionPopupContainer(editor: Editor): HTMLElement | null {
if (typeof document === "undefined") {
return null
}
const viewElement = editor.view.dom as HTMLElement | null
if (!viewElement) {
return document.body
}
const container =
viewElement.closest<HTMLElement>(
"[data-radix-dialog-content],[data-radix-drawer-content],[data-radix-popover-content],[data-radix-dropdown-menu-content],[data-radix-sheet-content]"
) ?? viewElement.closest<HTMLElement>("[role=\"dialog\"],[role=\"menu\"]")
return container ?? document.body
}
function toRelativeClientRect(rect: DOMRect | undefined, target: HTMLElement | null): DOMRect {
const baseRect = rect ?? new DOMRect()
if (typeof document === "undefined" || !target || target === document.body) {
return baseRect
}
const containerRect = target.getBoundingClientRect()
return new DOMRect(
baseRect.x - containerRect.x,
baseRect.y - containerRect.y,
baseRect.width,
baseRect.height
)
}
const priorityLabels: Record<string, string> = {
LOW: "Baixa",
MEDIUM: "Média",
@ -231,13 +446,15 @@ function TicketMentionList({ items, command, onRegister }: TicketMentionSuggesti
<span className="text-xs font-medium uppercase tracking-wide text-neutral-500">{priority}</span>
</div>
<div className="mt-1 line-clamp-1 text-sm text-neutral-700">{formatMentionSubject(item.subject)}</div>
<div className="mt-1 flex items-center gap-2 text-xs text-neutral-500">
<span className="inline-flex items-center gap-1">
<div className="mt-1 space-y-1 text-xs text-neutral-500">
<span className="inline-flex items-center gap-1 text-neutral-500">
<span className={cn("inline-flex size-2 rounded-full", statusDot)} />
{status}
</span>
{item.companyName ? <span> {item.companyName}</span> : null}
{item.assigneeName ? <span> {item.assigneeName}</span> : null}
{item.companyName ? <span className="block text-neutral-500">{item.companyName}</span> : null}
{item.assigneeName ? (
<span className="block text-neutral-400">Responsável {item.assigneeName}</span>
) : null}
</div>
</button>
)
@ -326,6 +543,29 @@ const TicketMentionExtension = Mention.extend({
return {}
},
},
{
tag: "a.ticket-mention",
getAttrs: (dom: HTMLElement | string) => {
if (dom instanceof HTMLElement) {
const href = dom.getAttribute("href") ?? null
const id = dom.dataset.ticketId ?? extractTicketIdFromHref(href)
const parsed = parseTicketMentionText(dom.textContent)
const reference = dom.dataset.ticketReference ?? parsed.reference ?? null
const subject = dom.dataset.ticketSubject ?? parsed.subject ?? null
const status = dom.dataset.ticketStatus ?? dom.getAttribute("status") ?? null
const priority = dom.dataset.ticketPriority ?? null
return {
id,
reference,
status,
priority,
subject,
url: href,
}
}
return {}
},
},
]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
@ -338,36 +578,41 @@ const TicketMentionExtension = Mention.extend({
const status = String(HTMLAttributes.status ?? HTMLAttributes["data-ticket-status"] ?? "PENDING").toUpperCase()
const priority = String(HTMLAttributes.priority ?? HTMLAttributes["data-ticket-priority"] ?? "MEDIUM").toUpperCase()
const href = String(HTMLAttributes.url ?? HTMLAttributes.href ?? "#")
const anchorClass = cn(TICKET_MENTION_BASE_CLASSES, toPlainString(HTMLAttributes.class ?? HTMLAttributes.className))
const statusToneClass = statusTone[status] ?? "bg-slate-400"
const dotClass = cn(TICKET_MENTION_DOT_BASE_CLASSES, statusToneClass)
return [
"a",
{
...HTMLAttributes,
href,
"data-type": HTMLAttributes["data-type"] ?? this.name,
"data-ticket-mention": "true",
"data-ticket-id": HTMLAttributes.id ?? HTMLAttributes["data-ticket-id"] ?? "",
"data-ticket-reference": reference ?? "",
"data-ticket-status": status,
"data-ticket-priority": priority,
"data-ticket-subject": subject ?? "",
class: TICKET_MENTION_CLASS,
status,
class: anchorClass,
},
[
"span",
{ class: "ticket-mention-dot" },
{ class: dotClass },
],
[
"span",
{ class: "ticket-mention-ref" },
{ class: TICKET_MENTION_REF_CLASSES },
`#${reference ?? ""}`,
],
[
"span",
{ class: "ticket-mention-sep" },
{ class: TICKET_MENTION_SEP_CLASSES },
"•",
],
[
"span",
{ class: "ticket-mention-subject" },
{ class: TICKET_MENTION_SUBJECT_CLASSES },
formatMentionSubject(subject ?? ""),
],
]
@ -380,6 +625,68 @@ const TicketMentionExtension = Mention.extend({
const subjectPart = displayedSubject ? `${formatMentionSubject(displayedSubject)}` : ""
return `#${reference}${subjectPart}`
},
addNodeView() {
const extensionName = this.name
return ({ node }: { node: { attrs: TicketMentionAttributes; type: { name: string } } }) => {
if (typeof document === "undefined") {
return {}
}
const root = document.createElement("a")
root.dataset.ticketMention = "true"
root.setAttribute("contenteditable", "false")
root.setAttribute("data-type", extensionName)
root.setAttribute("draggable", "false")
root.setAttribute("spellcheck", "false")
root.tabIndex = -1
const dotEl = document.createElement("span")
dotEl.className = TICKET_MENTION_DOT_BASE_CLASSES
dotEl.setAttribute("aria-hidden", "true")
const referenceEl = document.createElement("span")
referenceEl.className = TICKET_MENTION_REF_CLASSES
const separatorEl = document.createElement("span")
separatorEl.className = TICKET_MENTION_SEP_CLASSES
separatorEl.textContent = "•"
const subjectEl = document.createElement("span")
subjectEl.className = TICKET_MENTION_SUBJECT_CLASSES
root.append(dotEl, referenceEl, separatorEl, subjectEl)
const elements: TicketMentionNodeElements = {
root,
dotEl,
referenceEl,
subjectEl,
separatorEl,
}
updateTicketMentionNodeElements(elements, node.attrs ?? {})
const preventDefaultClick = (event: MouseEvent) => {
event.preventDefault()
}
root.addEventListener("click", preventDefaultClick)
return {
dom: root,
update: (updatedNode: { attrs: TicketMentionAttributes; type: { name: string } }) => {
if (updatedNode.type.name !== extensionName) {
return false
}
updateTicketMentionNodeElements(elements, updatedNode.attrs ?? {})
return true
},
ignoreMutation: () => true,
destroy: () => {
root.removeEventListener("click", preventDefaultClick)
},
}
}
},
}).configure({
suggestion: {
char: "@",
@ -389,6 +696,7 @@ const TicketMentionExtension = Mention.extend({
let component: ReactRenderer | null = null
let popup: Instance<TippyProps> | null = null
let keydownHandler: ((event: KeyboardEvent) => boolean) | null = null
let appendTarget: HTMLElement | null = null
const registerHandler = (handler: ((event: KeyboardEvent) => boolean) | null) => {
keydownHandler = handler
}
@ -403,10 +711,13 @@ const TicketMentionExtension = Mention.extend({
props: listProps,
editor: props.editor,
})
const container = resolveMentionPopupContainer(props.editor)
appendTarget = container ?? document.body
if (!props.clientRect) return
popup = tippy(document.body, {
getReferenceClientRect: () => props.clientRect?.() ?? new DOMRect(),
appendTo: () => document.body,
const computeRect = () => toRelativeClientRect(props.clientRect?.() ?? undefined, appendTarget)
popup = tippy(appendTarget, {
getReferenceClientRect: () => computeRect(),
appendTo: () => appendTarget ?? document.body,
content: component.element,
showOnCreate: true,
interactive: true,
@ -423,8 +734,9 @@ const TicketMentionExtension = Mention.extend({
onRegister: registerHandler,
})
if (!props.clientRect) return
const computeRect = () => toRelativeClientRect(props.clientRect?.() ?? undefined, appendTarget)
popup?.setProps({
getReferenceClientRect: () => props.clientRect?.() ?? new DOMRect(),
getReferenceClientRect: () => computeRect(),
})
},
onKeyDown(props) {
@ -441,6 +753,7 @@ const TicketMentionExtension = Mention.extend({
popup?.destroy()
component?.destroy()
keydownHandler = null
appendTarget = null
},
}
},
@ -459,6 +772,13 @@ export function RichTextEditor({
minHeight = 120,
ticketMention,
}: RichTextEditorProps) {
const normalizedInitialContent = useMemo(() => {
if (!ticketMention?.enabled) {
return value || ""
}
return normalizeTicketMentionHtml(value || "")
}, [ticketMention?.enabled, value])
const extensions = useMemo(() => {
const baseExtensions = [
StarterKit.configure({
@ -487,9 +807,11 @@ export function RichTextEditor({
"prose prose-sm max-w-none focus:outline-none text-foreground",
},
},
content: value || "",
content: normalizedInitialContent,
onUpdate({ editor }) {
onChange?.(editor.getHTML())
const html = editor.getHTML()
const normalized = ticketMention?.enabled ? normalizeTicketMentionHtml(html) : html
onChange?.(normalized)
},
editable: !disabled,
// Avoid SSR hydration mismatches per Tiptap recommendation
@ -555,11 +877,12 @@ export function RichTextEditor({
// Keep external value in sync when it changes
useEffect(() => {
if (!editor) return
const normalized = ticketMention?.enabled ? normalizeTicketMentionHtml(value || "") : value || ""
const current = editor.getHTML()
if ((value ?? "") !== current) {
editor.commands.setContent(value || "", { emitUpdate: false })
if (normalized !== current) {
editor.commands.setContent(normalized, { emitUpdate: false })
}
}, [value, editor])
}, [value, editor, ticketMention?.enabled])
// Reflect disabled prop changes after initialization
useEffect(() => {

View file

@ -0,0 +1,47 @@
/**
* @vitest-environment jsdom
*/
import { describe, expect, it } from "vitest"
import { normalizeTicketMentionHtml } from "@/components/ui/rich-text-editor"
function getAnchor(html: string): HTMLAnchorElement | null {
const template = document.createElement("template")
template.innerHTML = html
return template.content.querySelector("a.ticket-mention")
}
describe("normalizeTicketMentionHtml", () => {
it("returns original html when there is no ticket mention", () => {
const input = "<p>Sem menções aqui</p>"
expect(normalizeTicketMentionHtml(input)).toBe(input)
})
it("upgrades legacy mention markup with flattened text", () => {
const input =
'<p><a class="ticket-mention inline-flex items-center" href="/tickets/abc123">#41002•Integração ERP parada</a></p>'
const output = normalizeTicketMentionHtml(input)
const anchor = getAnchor(output)
expect(anchor).not.toBeNull()
expect(anchor?.dataset.ticketMention).toBe("true")
expect(anchor?.dataset.ticketId).toBe("abc123")
expect(anchor?.dataset.ticketReference).toBe("41002")
expect(anchor?.dataset.ticketStatus).toBe("PENDING")
expect(anchor?.dataset.ticketPriority).toBe("MEDIUM")
expect(anchor?.querySelector(".ticket-mention-dot")).not.toBeNull()
expect(anchor?.querySelector(".ticket-mention-ref")?.textContent).toBe("#41002")
expect(anchor?.querySelector(".ticket-mention-subject")?.textContent).toBe("Integração ERP parada")
})
it("preserves existing structured markup while refreshing classes", () => {
const input =
'<p><a data-ticket-mention="true" data-ticket-id="xyz" data-ticket-reference="123" data-ticket-status="RESOLVED" data-ticket-priority="LOW" data-ticket-subject="Teste" href="/tickets/xyz" class="ticket-mention"><span class="ticket-mention-dot"></span><span class="ticket-mention-ref">#123</span><span class="ticket-mention-sep">•</span><span class="ticket-mention-subject">Teste</span></a></p>'
const output = normalizeTicketMentionHtml(input)
const anchor = getAnchor(output)
expect(anchor).not.toBeNull()
expect(anchor?.dataset.ticketStatus).toBe("RESOLVED")
expect(anchor?.dataset.ticketPriority).toBe("LOW")
expect(anchor?.className).toContain("ticket-mention")
expect(anchor?.querySelector(".ticket-mention-dot")?.className).toContain("bg-emerald-500")
})
})