fix: URL-to-link rendering, fix horizontal overflow in timeline

- LinkedText component splits on newlines and converts raw URLs to clickable
  links showing just the hostname (e.g. 'ct.sendgrid.net' instead of full URL)
- [overflow-wrap:anywhere] on all text content (more aggressive than break-words)
- w-0 flex-1 on timeline item content divs to prevent flex children expanding
  past container boundary
- Applied to description, resolution, note bodies, time entry notes
This commit is contained in:
lorentz 2026-03-23 11:21:01 -04:00
parent df50c3868d
commit 21a41ef8ef

View file

@ -34,6 +34,55 @@ import {
// noteType values that are system/automated (workflow rules, monitoring, auto-close, etc.)
const SYSTEM_NOTE_TYPES = new Set([13, 91, 93, 94, 99, 101]);
const URL_REGEX = /https?:\/\/[^\s<>"')]+/g;
function renderTextWithLinks(text: string): React.ReactNode[] {
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
URL_REGEX.lastIndex = 0;
while ((match = URL_REGEX.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
const url = match[0].replace(/[.,;:!?)]+$/, '');
let label: string;
try {
label = new URL(url).hostname.replace(/^www\./, '');
} catch {
label = 'link';
}
parts.push(
<a
key={match.index}
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
{label}
</a>
);
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) parts.push(text.slice(lastIndex));
return parts;
}
function LinkedText({ text, className }: { text: string; className?: string }) {
const lines = text.split('\n');
return (
<span className={className}>
{lines.map((line, i) => (
<span key={i}>
{renderTextWithLinks(line)}
{i < lines.length - 1 && <br />}
</span>
))}
</span>
);
}
interface TicketDetailModalProps {
ticketNumber: string;
open: boolean;
@ -179,7 +228,7 @@ export function TicketDetailModal({ ticketNumber, open, onOpenChange }: TicketDe
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl w-[90vw] max-h-[85vh] overflow-y-auto overflow-x-hidden">
<DialogContent className="max-w-4xl w-[90vw] max-h-[85vh] overflow-y-auto">
<DialogHeader className="pb-0">
<DialogTitle className="flex items-center gap-2 text-base">
<TicketIcon className="h-4 w-4 shrink-0" />
@ -202,7 +251,7 @@ export function TicketDetailModal({ ticketNumber, open, onOpenChange }: TicketDe
)}
{ticket && !loading && (
<div className="space-y-3">
<div className="space-y-3 min-w-0 overflow-hidden">
{/* Metadata */}
<div className="flex flex-wrap items-center gap-2 text-sm">
{getStatusBadge(ticket.status, ticket.statusLabel)}
@ -250,8 +299,8 @@ export function TicketDetailModal({ ticketNumber, open, onOpenChange }: TicketDe
{ticket.description && (
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-1">Description</p>
<div className="p-3 bg-muted rounded-lg whitespace-pre-wrap break-words text-sm">
{ticket.description}
<div className="p-3 bg-muted rounded-lg text-sm [overflow-wrap:anywhere]">
<LinkedText text={ticket.description} />
</div>
</div>
)}
@ -260,8 +309,8 @@ export function TicketDetailModal({ ticketNumber, open, onOpenChange }: TicketDe
{ticket.resolution && (
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-1">Resolution</p>
<div className="p-3 bg-muted rounded-lg whitespace-pre-wrap break-words text-sm">
{ticket.resolution}
<div className="p-3 bg-muted rounded-lg text-sm [overflow-wrap:anywhere]">
<LinkedText text={ticket.resolution} />
</div>
</div>
)}
@ -340,7 +389,7 @@ export function TicketDetailModal({ ticketNumber, open, onOpenChange }: TicketDe
</div>
{!isLast && <div className="w-px flex-1 bg-border my-1" />}
</div>
<div className={`pb-4 min-w-0 flex-1 ${isLast ? '' : ''}`}>
<div className={`pb-4 min-w-0 w-0 flex-1 ${isLast ? '' : ''}`}>
<div className="flex items-center justify-between gap-2 mb-0.5">
<span className="text-xs font-medium">{e.resourceName || 'Unknown'}</span>
<div className="flex items-center gap-1.5 shrink-0">
@ -349,7 +398,9 @@ export function TicketDetailModal({ ticketNumber, open, onOpenChange }: TicketDe
</div>
</div>
{e.summaryNotes && (
<p className="text-xs text-muted-foreground whitespace-pre-wrap break-words">{e.summaryNotes}</p>
<p className="text-xs text-muted-foreground [overflow-wrap:anywhere]">
<LinkedText text={e.summaryNotes} />
</p>
)}
</div>
</div>
@ -370,14 +421,16 @@ export function TicketDetailModal({ ticketNumber, open, onOpenChange }: TicketDe
</div>
{!isLast && <div className="w-px flex-1 bg-border my-1" />}
</div>
<div className={`pb-4 min-w-0 flex-1 ${isSystem ? 'opacity-60' : ''}`}>
<div className={`pb-4 min-w-0 w-0 flex-1 ${isSystem ? 'opacity-60' : ''}`}>
<div className="flex items-center justify-between gap-2 mb-0.5">
<span className="text-xs font-medium">{n.creatorName || (isSystem ? 'System' : 'Unknown')}</span>
<span className="text-xs text-muted-foreground shrink-0">{formatDate(n.createDateTime)}</span>
</div>
{n.title && <p className="text-xs font-medium text-muted-foreground mb-0.5">{n.title}</p>}
{n.description && (
<p className="text-xs whitespace-pre-wrap break-words">{n.description}</p>
<p className="text-xs [overflow-wrap:anywhere]">
<LinkedText text={n.description} />
</p>
)}
</div>
</div>