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

@ -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(() => {