feat: link tickets in comments and align admin sidebars
This commit is contained in:
parent
c35eb673d3
commit
b0f57009ac
15 changed files with 1606 additions and 424 deletions
|
|
@ -1,11 +1,21 @@
|
|||
"use client"
|
||||
|
||||
import { forwardRef, useCallback, useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import type { ReactNode } from "react"
|
||||
import { useEditor, EditorContent } from "@tiptap/react"
|
||||
import StarterKit from "@tiptap/starter-kit"
|
||||
import Link from "@tiptap/extension-link"
|
||||
import Placeholder from "@tiptap/extension-placeholder"
|
||||
import Mention from "@tiptap/extension-mention"
|
||||
import { ReactRenderer } from "@tiptap/react"
|
||||
import tippy, { type Instance, type Props as TippyProps } from "tippy.js"
|
||||
import { cn } from "@/lib/utils"
|
||||
import sanitize from "sanitize-html"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
|
@ -33,8 +43,356 @@ type RichTextEditorProps = {
|
|||
placeholder?: string
|
||||
disabled?: boolean
|
||||
minHeight?: number
|
||||
ticketMention?: {
|
||||
enabled?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
type TicketMentionItem = {
|
||||
id: string
|
||||
reference: number
|
||||
subject: string
|
||||
status: string
|
||||
priority: string
|
||||
requesterName: string | null
|
||||
assigneeName: string | null
|
||||
companyName: string | null
|
||||
url: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
type TicketMentionSuggestionProps = {
|
||||
command: (item: TicketMentionItem) => void
|
||||
items: TicketMentionItem[]
|
||||
onRegister?: (handler: ((event: KeyboardEvent) => boolean) | null) => void
|
||||
}
|
||||
|
||||
const TICKET_MENTION_CLASS = "ticket-mention"
|
||||
|
||||
function formatMentionSubject(subject: string) {
|
||||
if (!subject) return ""
|
||||
return subject.length > 60 ? `${subject.slice(0, 57)}…` : subject
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
PENDING: "Pendente",
|
||||
AWAITING_ATTENDANCE: "Em andamento",
|
||||
PAUSED: "Pausado",
|
||||
RESOLVED: "Resolvido",
|
||||
}
|
||||
|
||||
const statusTone: Record<string, string> = {
|
||||
PENDING: "bg-amber-400",
|
||||
AWAITING_ATTENDANCE: "bg-sky-500",
|
||||
PAUSED: "bg-violet-500",
|
||||
RESOLVED: "bg-emerald-500",
|
||||
}
|
||||
|
||||
const priorityLabels: Record<string, string> = {
|
||||
LOW: "Baixa",
|
||||
MEDIUM: "Média",
|
||||
HIGH: "Alta",
|
||||
URGENT: "Urgente",
|
||||
}
|
||||
|
||||
const mentionCache = new Map<string, TicketMentionItem[]>()
|
||||
let mentionAbortController: AbortController | null = null
|
||||
|
||||
async function fetchTicketMentions(query: string): Promise<TicketMentionItem[]> {
|
||||
const cacheKey = query.trim().toLowerCase()
|
||||
if (mentionCache.has(cacheKey)) {
|
||||
return mentionCache.get(cacheKey) ?? []
|
||||
}
|
||||
|
||||
try {
|
||||
mentionAbortController?.abort()
|
||||
mentionAbortController = new AbortController()
|
||||
const response = await fetch(`/api/tickets/mentions?q=${encodeURIComponent(query)}`, {
|
||||
signal: mentionAbortController.signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
return []
|
||||
}
|
||||
const json = (await response.json()) as { items?: TicketMentionItem[] }
|
||||
const items = Array.isArray(json.items) ? json.items : []
|
||||
mentionCache.set(cacheKey, items)
|
||||
return items
|
||||
} catch (error) {
|
||||
if ((error as Error).name === "AbortError") {
|
||||
return []
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function TicketMentionList({ items, command, onRegister }: TicketMentionSuggestionProps) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
const selectItem = useCallback(
|
||||
(index: number) => {
|
||||
const item = items[index]
|
||||
if (item) {
|
||||
command(item)
|
||||
}
|
||||
},
|
||||
[command, items]
|
||||
)
|
||||
|
||||
const upHandler = useCallback(() => {
|
||||
setSelectedIndex((prev) => {
|
||||
const nextIndex = (prev + items.length - 1) % items.length
|
||||
return nextIndex
|
||||
})
|
||||
}, [items.length])
|
||||
|
||||
const downHandler = useCallback(() => {
|
||||
setSelectedIndex((prev) => {
|
||||
const nextIndex = (prev + 1) % items.length
|
||||
return nextIndex
|
||||
})
|
||||
}, [items.length])
|
||||
|
||||
const enterHandler = useCallback(() => {
|
||||
selectItem(selectedIndex)
|
||||
}, [selectItem, selectedIndex])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key === "ArrowUp") {
|
||||
upHandler()
|
||||
return true
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
downHandler()
|
||||
return true
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
enterHandler()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
[downHandler, enterHandler, upHandler]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
onRegister?.(handleKeyDown)
|
||||
return () => {
|
||||
onRegister?.(null)
|
||||
}
|
||||
}, [handleKeyDown, onRegister])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [items])
|
||||
|
||||
if (!items.length) {
|
||||
return (
|
||||
<div className="min-w-[260px] p-3 text-sm text-muted-foreground">
|
||||
Nenhum chamado encontrado com esse termo.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-h-72 min-w-[320px] space-y-1 overflow-y-auto p-2">
|
||||
{items.map((item, index) => {
|
||||
const isActive = index === selectedIndex
|
||||
const status = statusLabels[item.status] ?? item.status
|
||||
const statusDot = statusTone[item.status] ?? "bg-slate-400"
|
||||
const priority = priorityLabels[item.priority] ?? item.priority
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full rounded-lg border border-transparent px-3 py-2 text-left transition",
|
||||
isActive ? "border-slate-200 bg-slate-100" : "hover:bg-slate-50"
|
||||
)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
selectItem(index)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-neutral-900">#{item.reference}</span>
|
||||
<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">
|
||||
<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}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
TicketMentionList.displayName = "TicketMentionList"
|
||||
|
||||
const TicketMentionListComponent = (props: TicketMentionSuggestionProps) => (
|
||||
<TicketMentionList {...props} />
|
||||
)
|
||||
|
||||
const TicketMentionExtension = Mention.extend({
|
||||
name: "ticketMention",
|
||||
addAttributes() {
|
||||
return {
|
||||
id: { default: null },
|
||||
reference: { default: null },
|
||||
subject: { default: null },
|
||||
status: { default: null },
|
||||
priority: { default: null },
|
||||
url: { default: null },
|
||||
}
|
||||
},
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `a[data-ticket-mention="true"]`,
|
||||
getAttrs: (dom: HTMLElement | string) => {
|
||||
if (dom instanceof HTMLElement) {
|
||||
return {
|
||||
id: dom.dataset.ticketId ?? null,
|
||||
reference: dom.dataset.ticketReference ?? null,
|
||||
status: dom.dataset.ticketStatus ?? null,
|
||||
priority: dom.dataset.ticketPriority ?? null,
|
||||
subject: dom.getAttribute("data-ticket-subject") ?? dom.textContent ?? null,
|
||||
url: dom.getAttribute("href") ?? null,
|
||||
}
|
||||
}
|
||||
return {}
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
const referenceValue =
|
||||
HTMLAttributes.reference ?? HTMLAttributes["data-ticket-reference"] ?? ""
|
||||
const reference = String(referenceValue ?? "")
|
||||
const subjectValue =
|
||||
HTMLAttributes.subject ?? HTMLAttributes["data-ticket-subject"] ?? ""
|
||||
const subject = String(subjectValue ?? "")
|
||||
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 ?? "#")
|
||||
return [
|
||||
"a",
|
||||
{
|
||||
...HTMLAttributes,
|
||||
href,
|
||||
"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,
|
||||
},
|
||||
[
|
||||
"span",
|
||||
{ class: "ticket-mention-dot" },
|
||||
],
|
||||
[
|
||||
"span",
|
||||
{ class: "ticket-mention-ref" },
|
||||
`#${reference ?? ""}`,
|
||||
],
|
||||
[
|
||||
"span",
|
||||
{ class: "ticket-mention-sep" },
|
||||
"•",
|
||||
],
|
||||
[
|
||||
"span",
|
||||
{ class: "ticket-mention-subject" },
|
||||
formatMentionSubject(subject ?? ""),
|
||||
],
|
||||
]
|
||||
},
|
||||
renderLabel({ node }: { node: { attrs: Record<string, unknown> } }) {
|
||||
const subjectAttr = node.attrs.subject ?? node.attrs["data-ticket-subject"] ?? ""
|
||||
const displayedSubject = typeof subjectAttr === "string" ? subjectAttr : String(subjectAttr ?? "")
|
||||
const refAttr = node.attrs.reference ?? node.attrs.id ?? node.attrs["data-ticket-reference"] ?? ""
|
||||
const reference = typeof refAttr === "string" ? refAttr : String(refAttr ?? "")
|
||||
const subjectPart = displayedSubject ? ` • ${formatMentionSubject(displayedSubject)}` : ""
|
||||
return `#${reference}${subjectPart}`
|
||||
},
|
||||
}).configure({
|
||||
suggestion: {
|
||||
char: "@",
|
||||
startOfLine: false,
|
||||
allowSpaces: false,
|
||||
render: () => {
|
||||
let component: ReactRenderer | null = null
|
||||
let popup: Instance<TippyProps> | null = null
|
||||
let keydownHandler: ((event: KeyboardEvent) => boolean) | null = null
|
||||
const registerHandler = (handler: ((event: KeyboardEvent) => boolean) | null) => {
|
||||
keydownHandler = handler
|
||||
}
|
||||
return {
|
||||
onStart: (props) => {
|
||||
const listProps: TicketMentionSuggestionProps = {
|
||||
command: props.command,
|
||||
items: props.items,
|
||||
onRegister: registerHandler,
|
||||
}
|
||||
component = new ReactRenderer(TicketMentionListComponent, {
|
||||
props: listProps,
|
||||
editor: props.editor,
|
||||
})
|
||||
if (!props.clientRect) return
|
||||
popup = tippy(document.body, {
|
||||
getReferenceClientRect: () => props.clientRect?.() ?? new DOMRect(),
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
})
|
||||
},
|
||||
onUpdate(props) {
|
||||
component?.updateProps({
|
||||
command: props.command,
|
||||
items: props.items,
|
||||
onRegister: registerHandler,
|
||||
})
|
||||
if (!props.clientRect) return
|
||||
popup?.setProps({
|
||||
getReferenceClientRect: () => props.clientRect?.() ?? new DOMRect(),
|
||||
})
|
||||
},
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === "Escape") {
|
||||
popup?.hide()
|
||||
return true
|
||||
}
|
||||
if (keydownHandler && keydownHandler(props.event)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
onExit() {
|
||||
popup?.destroy()
|
||||
component?.destroy()
|
||||
keydownHandler = null
|
||||
},
|
||||
}
|
||||
},
|
||||
items: async ({ query }) => {
|
||||
return fetchTicketMentions(query)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export function RichTextEditor({
|
||||
value,
|
||||
onChange,
|
||||
|
|
@ -42,9 +400,10 @@ export function RichTextEditor({
|
|||
placeholder = "Escreva aqui...",
|
||||
disabled,
|
||||
minHeight = 120,
|
||||
ticketMention,
|
||||
}: RichTextEditorProps) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
const extensions = useMemo(() => {
|
||||
const baseExtensions = [
|
||||
StarterKit.configure({
|
||||
bulletList: { keepMarks: true },
|
||||
orderedList: { keepMarks: true },
|
||||
|
|
@ -56,6 +415,13 @@ export function RichTextEditor({
|
|||
HTMLAttributes: { rel: "noopener noreferrer", target: "_blank" },
|
||||
}),
|
||||
Placeholder.configure({ placeholder }),
|
||||
]
|
||||
return ticketMention?.enabled ? [...baseExtensions, TicketMentionExtension] : baseExtensions
|
||||
}, [placeholder, ticketMention?.enabled])
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
...extensions,
|
||||
],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
|
|
@ -300,15 +666,48 @@ export function sanitizeEditorHtml(html: string): string {
|
|||
"p","br","a","strong","em","u","s","blockquote","ul","ol","li","code","pre","span","h1","h2","h3"
|
||||
],
|
||||
allowedAttributes: {
|
||||
a: ["href","name","target","rel"],
|
||||
span: ["style"],
|
||||
a: [
|
||||
"href",
|
||||
"name",
|
||||
"target",
|
||||
"rel",
|
||||
"class",
|
||||
"data-ticket-mention",
|
||||
"data-ticket-id",
|
||||
"data-ticket-reference",
|
||||
"data-ticket-status",
|
||||
"data-ticket-priority",
|
||||
"data-ticket-subject",
|
||||
"title",
|
||||
],
|
||||
span: [
|
||||
"style",
|
||||
"class",
|
||||
"data-ticket-mention",
|
||||
"data-ticket-id",
|
||||
"data-ticket-reference",
|
||||
"data-ticket-status",
|
||||
"data-ticket-priority",
|
||||
"data-ticket-subject",
|
||||
],
|
||||
code: ["class"],
|
||||
pre: ["class"],
|
||||
},
|
||||
allowedSchemes: ["http","https","mailto"],
|
||||
// prevent target=_self phishing
|
||||
transformTags: {
|
||||
a: sanitize.simpleTransform("a", { rel: "noopener noreferrer", target: "_blank" }, true),
|
||||
a: (tagName, attribs) => {
|
||||
const isMention = attribs["data-ticket-mention"] === "true"
|
||||
const nextAttribs = {
|
||||
...attribs,
|
||||
rel: attribs.rel ?? "noopener noreferrer",
|
||||
target: isMention ? "_self" : "_blank",
|
||||
}
|
||||
return {
|
||||
tagName,
|
||||
attribs: nextAttribs,
|
||||
}
|
||||
},
|
||||
},
|
||||
// disallow inline event handlers
|
||||
allowVulnerableTags: false,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue