Normalize ticket mentions in editor and server
This commit is contained in:
parent
cf11ac9bcb
commit
296e02cf0c
5 changed files with 447 additions and 40 deletions
|
|
@ -84,13 +84,29 @@ function truncateSubject(subject: string) {
|
||||||
return `${subject.slice(0, 57)}…`
|
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 {
|
function buildTicketMentionAnchor(ticket: Doc<"tickets">): string {
|
||||||
const reference = ticket.reference
|
const reference = ticket.reference
|
||||||
const subject = escapeHtml(ticket.subject ?? "")
|
const subject = escapeHtml(ticket.subject ?? "")
|
||||||
const truncated = truncateSubject(subject)
|
const truncated = truncateSubject(subject)
|
||||||
const status = (ticket.status ?? "PENDING").toString().toUpperCase()
|
const status = (ticket.status ?? "PENDING").toString().toUpperCase()
|
||||||
const priority = (ticket.priority ?? "MEDIUM").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">) {
|
function canMentionTicket(viewerRole: string, viewerId: Id<"users">, ticket: Doc<"tickets">) {
|
||||||
|
|
@ -111,11 +127,11 @@ async function normalizeTicketMentions(
|
||||||
viewer: { user: Doc<"users">; role: string },
|
viewer: { user: Doc<"users">; role: string },
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
): Promise<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
|
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))
|
const matches = Array.from(html.matchAll(mentionPattern))
|
||||||
if (!matches.length) {
|
if (!matches.length) {
|
||||||
return html
|
return html
|
||||||
|
|
@ -123,10 +139,23 @@ async function normalizeTicketMentions(
|
||||||
|
|
||||||
let output = html
|
let output = html
|
||||||
|
|
||||||
|
const attributePattern = /(data-[\w-]+|class|href)="([^"]*)"/gi
|
||||||
|
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
const full = match[0]
|
const full = match[0]
|
||||||
const idMatch = /data-ticket-id="([^"]+)"/i.exec(full)
|
attributePattern.lastIndex = 0
|
||||||
const ticketIdRaw = idMatch?.[1]
|
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 = ""
|
let replacement = ""
|
||||||
|
|
||||||
if (ticketIdRaw) {
|
if (ticketIdRaw) {
|
||||||
|
|
|
||||||
|
|
@ -171,21 +171,20 @@
|
||||||
.rich-text .ProseMirror .ticket-mention {
|
.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;
|
@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".
|
/* Fallback for legacy editing markup before the custom node view hydrates. */
|
||||||
Mirror the same chip style so the layout stays consistent during edits. */
|
.rich-text .ProseMirror [data-type="ticketMention"]:not([data-ticket-mention="true"]) {
|
||||||
.rich-text .ProseMirror [data-type="ticketMention"] {
|
|
||||||
@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;
|
@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;
|
position: relative;
|
||||||
}
|
}
|
||||||
.rich-text .ProseMirror [data-type="ticketMention"]::before {
|
.rich-text .ProseMirror [data-type="ticketMention"]:not([data-ticket-mention="true"])::before {
|
||||||
content: "";
|
content: "";
|
||||||
@apply inline-block size-2 rounded-full bg-slate-400;
|
@apply inline-block size-2 rounded-full bg-slate-400;
|
||||||
margin-right: 0.375rem; /* ~gap-1.5 */
|
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"][data-ticket-status="PENDING"]:not([data-ticket-mention="true"])::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"][data-ticket-status="AWAITING_ATTENDANCE"]:not([data-ticket-mention="true"])::before { @apply bg-sky-500; }
|
||||||
.rich-text .ProseMirror [data-type="ticketMention"][status="PAUSED"]::before { @apply bg-violet-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"][status="RESOLVED"]::before { @apply bg-emerald-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 [data-ticket-mention="true"] .ticket-mention-dot,
|
||||||
.rich-text .ticket-mention .ticket-mention-dot {
|
.rich-text .ticket-mention .ticket-mention-dot {
|
||||||
@apply inline-flex size-2 rounded-full bg-slate-400;
|
@apply inline-flex size-2 rounded-full bg-slate-400;
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,13 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Dropzone } from "@/components/ui/dropzone"
|
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 { Dialog, DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
|
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) => {
|
const startEditingComment = useCallback((commentId: string, currentBody: string) => {
|
||||||
setEditingComment({ id: commentId, value: currentBody || "" })
|
const normalized = normalizeTicketMentionHtml(currentBody || "")
|
||||||
|
setEditingComment({ id: commentId, value: normalized })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const cancelEditingComment = useCallback(() => {
|
const cancelEditingComment = useCallback(() => {
|
||||||
|
|
@ -96,7 +103,8 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
if (commentId.startsWith("temp-")) return
|
if (commentId.startsWith("temp-")) return
|
||||||
|
|
||||||
const sanitized = sanitizeEditorHtml(editingComment.value)
|
const sanitized = sanitizeEditorHtml(editingComment.value)
|
||||||
if (sanitized === originalBody) {
|
const normalized = normalizeTicketMentionHtml(sanitized)
|
||||||
|
if (normalized === originalBody) {
|
||||||
setEditingComment(null)
|
setEditingComment(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -109,9 +117,9 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
ticketId: ticket.id as Id<"tickets">,
|
ticketId: ticket.id as Id<"tickets">,
|
||||||
commentId: commentId as unknown as Id<"ticketComments">,
|
commentId: commentId as unknown as Id<"ticketComments">,
|
||||||
actorId: convexUserId as Id<"users">,
|
actorId: convexUserId as Id<"users">,
|
||||||
body: sanitized,
|
body: normalized,
|
||||||
})
|
})
|
||||||
setLocalBodies((prev) => ({ ...prev, [commentId]: sanitized }))
|
setLocalBodies((prev) => ({ ...prev, [commentId]: normalized }))
|
||||||
setEditingComment(null)
|
setEditingComment(null)
|
||||||
toast.success("Comentário atualizado!", { id: toastId })
|
toast.success("Comentário atualizado!", { id: toastId })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -133,7 +141,8 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
if (!convexUserId) return
|
if (!convexUserId) return
|
||||||
// Enforce generous max length for comment plain text
|
// Enforce generous max length for comment plain text
|
||||||
const sanitized = sanitizeEditorHtml(body)
|
const sanitized = sanitizeEditorHtml(body)
|
||||||
const plain = sanitized.replace(/<[^>]*>/g, "").replace(/ /g, " ").trim()
|
const normalized = normalizeTicketMentionHtml(sanitized)
|
||||||
|
const plain = normalized.replace(/<[^>]*>/g, "").replace(/ /g, " ").trim()
|
||||||
const MAX_COMMENT_CHARS = 20000
|
const MAX_COMMENT_CHARS = 20000
|
||||||
if (plain.length > MAX_COMMENT_CHARS) {
|
if (plain.length > MAX_COMMENT_CHARS) {
|
||||||
toast.error(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`, { id: "comment" })
|
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()}`,
|
id: `temp-${now.getTime()}`,
|
||||||
author: ticket.requester,
|
author: ticket.requester,
|
||||||
visibility: selectedVisibility,
|
visibility: selectedVisibility,
|
||||||
body: sanitized,
|
body: normalized,
|
||||||
attachments: attachments.map((attachment) => ({
|
attachments: attachments.map((attachment) => ({
|
||||||
id: attachment.storageId,
|
id: attachment.storageId,
|
||||||
name: attachment.name,
|
name: attachment.name,
|
||||||
|
|
@ -176,7 +185,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
ticketId: ticket.id as Id<"tickets">,
|
ticketId: ticket.id as Id<"tickets">,
|
||||||
authorId: convexUserId as Id<"users">,
|
authorId: convexUserId as Id<"users">,
|
||||||
visibility: selectedVisibility,
|
visibility: selectedVisibility,
|
||||||
body: optimistic.body,
|
body: normalized,
|
||||||
attachments: payload,
|
attachments: payload,
|
||||||
})
|
})
|
||||||
setPending([])
|
setPending([])
|
||||||
|
|
|
||||||
|
|
@ -70,13 +70,51 @@ type TicketMentionSuggestionProps = {
|
||||||
onRegister?: (handler: ((event: KeyboardEvent) => boolean) | null) => void
|
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) {
|
function formatMentionSubject(subject: string) {
|
||||||
if (!subject) return ""
|
if (!subject) return ""
|
||||||
return subject.length > 60 ? `${subject.slice(0, 57)}…` : subject
|
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> = {
|
const statusLabels: Record<string, string> = {
|
||||||
PENDING: "Pendente",
|
PENDING: "Pendente",
|
||||||
AWAITING_ATTENDANCE: "Em andamento",
|
AWAITING_ATTENDANCE: "Em andamento",
|
||||||
|
|
@ -91,6 +129,183 @@ const statusTone: Record<string, string> = {
|
||||||
RESOLVED: "bg-emerald-500",
|
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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
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> = {
|
const priorityLabels: Record<string, string> = {
|
||||||
LOW: "Baixa",
|
LOW: "Baixa",
|
||||||
MEDIUM: "Média",
|
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>
|
<span className="text-xs font-medium uppercase tracking-wide text-neutral-500">{priority}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 line-clamp-1 text-sm text-neutral-700">{formatMentionSubject(item.subject)}</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">
|
<div className="mt-1 space-y-1 text-xs text-neutral-500">
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1 text-neutral-500">
|
||||||
<span className={cn("inline-flex size-2 rounded-full", statusDot)} />
|
<span className={cn("inline-flex size-2 rounded-full", statusDot)} />
|
||||||
{status}
|
{status}
|
||||||
</span>
|
</span>
|
||||||
{item.companyName ? <span>• {item.companyName}</span> : null}
|
{item.companyName ? <span className="block text-neutral-500">{item.companyName}</span> : null}
|
||||||
{item.assigneeName ? <span>• {item.assigneeName}</span> : null}
|
{item.assigneeName ? (
|
||||||
|
<span className="block text-neutral-400">Responsável • {item.assigneeName}</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|
@ -326,6 +543,29 @@ const TicketMentionExtension = Mention.extend({
|
||||||
return {}
|
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> }) {
|
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 status = String(HTMLAttributes.status ?? HTMLAttributes["data-ticket-status"] ?? "PENDING").toUpperCase()
|
||||||
const priority = String(HTMLAttributes.priority ?? HTMLAttributes["data-ticket-priority"] ?? "MEDIUM").toUpperCase()
|
const priority = String(HTMLAttributes.priority ?? HTMLAttributes["data-ticket-priority"] ?? "MEDIUM").toUpperCase()
|
||||||
const href = String(HTMLAttributes.url ?? HTMLAttributes.href ?? "#")
|
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 [
|
return [
|
||||||
"a",
|
"a",
|
||||||
{
|
{
|
||||||
...HTMLAttributes,
|
...HTMLAttributes,
|
||||||
href,
|
href,
|
||||||
|
"data-type": HTMLAttributes["data-type"] ?? this.name,
|
||||||
"data-ticket-mention": "true",
|
"data-ticket-mention": "true",
|
||||||
"data-ticket-id": HTMLAttributes.id ?? HTMLAttributes["data-ticket-id"] ?? "",
|
"data-ticket-id": HTMLAttributes.id ?? HTMLAttributes["data-ticket-id"] ?? "",
|
||||||
"data-ticket-reference": reference ?? "",
|
"data-ticket-reference": reference ?? "",
|
||||||
"data-ticket-status": status,
|
"data-ticket-status": status,
|
||||||
"data-ticket-priority": priority,
|
"data-ticket-priority": priority,
|
||||||
"data-ticket-subject": subject ?? "",
|
"data-ticket-subject": subject ?? "",
|
||||||
class: TICKET_MENTION_CLASS,
|
status,
|
||||||
|
class: anchorClass,
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
"span",
|
"span",
|
||||||
{ class: "ticket-mention-dot" },
|
{ class: dotClass },
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"span",
|
"span",
|
||||||
{ class: "ticket-mention-ref" },
|
{ class: TICKET_MENTION_REF_CLASSES },
|
||||||
`#${reference ?? ""}`,
|
`#${reference ?? ""}`,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"span",
|
"span",
|
||||||
{ class: "ticket-mention-sep" },
|
{ class: TICKET_MENTION_SEP_CLASSES },
|
||||||
"•",
|
"•",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"span",
|
"span",
|
||||||
{ class: "ticket-mention-subject" },
|
{ class: TICKET_MENTION_SUBJECT_CLASSES },
|
||||||
formatMentionSubject(subject ?? ""),
|
formatMentionSubject(subject ?? ""),
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
|
|
@ -380,6 +625,68 @@ const TicketMentionExtension = Mention.extend({
|
||||||
const subjectPart = displayedSubject ? ` • ${formatMentionSubject(displayedSubject)}` : ""
|
const subjectPart = displayedSubject ? ` • ${formatMentionSubject(displayedSubject)}` : ""
|
||||||
return `#${reference}${subjectPart}`
|
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({
|
}).configure({
|
||||||
suggestion: {
|
suggestion: {
|
||||||
char: "@",
|
char: "@",
|
||||||
|
|
@ -389,6 +696,7 @@ const TicketMentionExtension = Mention.extend({
|
||||||
let component: ReactRenderer | null = null
|
let component: ReactRenderer | null = null
|
||||||
let popup: Instance<TippyProps> | null = null
|
let popup: Instance<TippyProps> | null = null
|
||||||
let keydownHandler: ((event: KeyboardEvent) => boolean) | null = null
|
let keydownHandler: ((event: KeyboardEvent) => boolean) | null = null
|
||||||
|
let appendTarget: HTMLElement | null = null
|
||||||
const registerHandler = (handler: ((event: KeyboardEvent) => boolean) | null) => {
|
const registerHandler = (handler: ((event: KeyboardEvent) => boolean) | null) => {
|
||||||
keydownHandler = handler
|
keydownHandler = handler
|
||||||
}
|
}
|
||||||
|
|
@ -403,10 +711,13 @@ const TicketMentionExtension = Mention.extend({
|
||||||
props: listProps,
|
props: listProps,
|
||||||
editor: props.editor,
|
editor: props.editor,
|
||||||
})
|
})
|
||||||
|
const container = resolveMentionPopupContainer(props.editor)
|
||||||
|
appendTarget = container ?? document.body
|
||||||
if (!props.clientRect) return
|
if (!props.clientRect) return
|
||||||
popup = tippy(document.body, {
|
const computeRect = () => toRelativeClientRect(props.clientRect?.() ?? undefined, appendTarget)
|
||||||
getReferenceClientRect: () => props.clientRect?.() ?? new DOMRect(),
|
popup = tippy(appendTarget, {
|
||||||
appendTo: () => document.body,
|
getReferenceClientRect: () => computeRect(),
|
||||||
|
appendTo: () => appendTarget ?? document.body,
|
||||||
content: component.element,
|
content: component.element,
|
||||||
showOnCreate: true,
|
showOnCreate: true,
|
||||||
interactive: true,
|
interactive: true,
|
||||||
|
|
@ -423,8 +734,9 @@ const TicketMentionExtension = Mention.extend({
|
||||||
onRegister: registerHandler,
|
onRegister: registerHandler,
|
||||||
})
|
})
|
||||||
if (!props.clientRect) return
|
if (!props.clientRect) return
|
||||||
|
const computeRect = () => toRelativeClientRect(props.clientRect?.() ?? undefined, appendTarget)
|
||||||
popup?.setProps({
|
popup?.setProps({
|
||||||
getReferenceClientRect: () => props.clientRect?.() ?? new DOMRect(),
|
getReferenceClientRect: () => computeRect(),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onKeyDown(props) {
|
onKeyDown(props) {
|
||||||
|
|
@ -441,6 +753,7 @@ const TicketMentionExtension = Mention.extend({
|
||||||
popup?.destroy()
|
popup?.destroy()
|
||||||
component?.destroy()
|
component?.destroy()
|
||||||
keydownHandler = null
|
keydownHandler = null
|
||||||
|
appendTarget = null
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -459,6 +772,13 @@ export function RichTextEditor({
|
||||||
minHeight = 120,
|
minHeight = 120,
|
||||||
ticketMention,
|
ticketMention,
|
||||||
}: RichTextEditorProps) {
|
}: RichTextEditorProps) {
|
||||||
|
const normalizedInitialContent = useMemo(() => {
|
||||||
|
if (!ticketMention?.enabled) {
|
||||||
|
return value || ""
|
||||||
|
}
|
||||||
|
return normalizeTicketMentionHtml(value || "")
|
||||||
|
}, [ticketMention?.enabled, value])
|
||||||
|
|
||||||
const extensions = useMemo(() => {
|
const extensions = useMemo(() => {
|
||||||
const baseExtensions = [
|
const baseExtensions = [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
|
|
@ -487,9 +807,11 @@ export function RichTextEditor({
|
||||||
"prose prose-sm max-w-none focus:outline-none text-foreground",
|
"prose prose-sm max-w-none focus:outline-none text-foreground",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
content: value || "",
|
content: normalizedInitialContent,
|
||||||
onUpdate({ editor }) {
|
onUpdate({ editor }) {
|
||||||
onChange?.(editor.getHTML())
|
const html = editor.getHTML()
|
||||||
|
const normalized = ticketMention?.enabled ? normalizeTicketMentionHtml(html) : html
|
||||||
|
onChange?.(normalized)
|
||||||
},
|
},
|
||||||
editable: !disabled,
|
editable: !disabled,
|
||||||
// Avoid SSR hydration mismatches per Tiptap recommendation
|
// Avoid SSR hydration mismatches per Tiptap recommendation
|
||||||
|
|
@ -555,11 +877,12 @@ export function RichTextEditor({
|
||||||
// Keep external value in sync when it changes
|
// Keep external value in sync when it changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
|
const normalized = ticketMention?.enabled ? normalizeTicketMentionHtml(value || "") : value || ""
|
||||||
const current = editor.getHTML()
|
const current = editor.getHTML()
|
||||||
if ((value ?? "") !== current) {
|
if (normalized !== current) {
|
||||||
editor.commands.setContent(value || "", { emitUpdate: false })
|
editor.commands.setContent(normalized, { emitUpdate: false })
|
||||||
}
|
}
|
||||||
}, [value, editor])
|
}, [value, editor, ticketMention?.enabled])
|
||||||
|
|
||||||
// Reflect disabled prop changes after initialization
|
// Reflect disabled prop changes after initialization
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
47
tests/ticket-mention-html.test.ts
Normal file
47
tests/ticket-mention-html.test.ts
Normal 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")
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue