Merge pull request #247 from vernu/fix/dialog-long-text-overflow

fix(web): keep long message text inside the details dialog
This commit is contained in:
vernu
2026-07-22 17:18:51 +03:00
committed by GitHub
4 changed files with 91 additions and 5 deletions
@@ -66,7 +66,9 @@ export function MessageRow({ message, device, onSelect }: MessageRowProps) {
</span>
</span>
<span className='mt-0.5 line-clamp-2 block text-sm text-muted-foreground'>
{/* break-words so a message that is one long URL fills both clamped
lines instead of being cut off partway through the first. */}
<span className='mt-0.5 line-clamp-2 block break-words text-sm text-muted-foreground'>
{message.message}
</span>
@@ -54,7 +54,10 @@ export default function SmsDetailsDialog({
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-[540px]'>
{/* The dialog is the only scroll container. Capping the message body
instead put a scrollbar inside a scrollbar, which is easy to miss
on touch. */}
<DialogContent className='max-h-[85vh] overflow-y-auto sm:max-w-[540px]'>
{/* text-left overrides the primitive's mobile centring, which left
the title on the left and the date centred under it. */}
<DialogHeader className='pr-8 text-left'>
@@ -92,7 +95,7 @@ export default function SmsDetailsDialog({
{/* The message body leads: it is the reason this dialog is open. */}
<div className='relative'>
<div className='max-h-56 overflow-y-auto whitespace-pre-wrap break-words rounded-lg bg-muted p-3 pr-11 text-sm'>
<div className='whitespace-pre-wrap break-words rounded-lg bg-muted p-3 pr-11 text-sm'>
{message.message}
</div>
<CopyButton
@@ -149,7 +152,7 @@ export default function SmsDetailsDialog({
</p>
)}
{message.errorMessage && (
<p className='max-h-24 overflow-y-auto break-words text-xs text-destructive'>
<p className='break-words text-xs text-destructive'>
{message.errorMessage}
</p>
)}
+4 -1
View File
@@ -37,8 +37,11 @@ const DialogContent = React.forwardRef<
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
// The explicit minmax(0,1fr) column is load bearing: an implicit grid
// track has a min-content floor, so a long URL or ID in any child pushed
// every child past the card's max-width instead of wrapping.
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:rounded-lg",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg grid-cols-[minmax(0,1fr)] translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:rounded-lg",
className
)}
{...props}
+78
View File
@@ -233,6 +233,84 @@ test.describe('message history (mocked API, no real backend)', () => {
expect(await read()).toBe('665f1c2a9b1e4a0012ab34cd')
})
// Support alerts carry a full conversation URL, which has no break
// opportunity. That single token used to set a min-content floor on the
// dialog's grid track, pushing the message box, the error panel and the
// action button hundreds of pixels outside the card.
for (const viewport of [
{ name: 'mobile', width: 375, height: 800 },
{ name: 'desktop', width: 1280, height: 800 },
]) {
test(`a long unbreakable URL stays inside the details dialog on ${viewport.name}`, async ({
page,
context,
}) => {
await page.setViewportSize({
width: viewport.width,
height: viewport.height,
})
await authenticate(context)
await mockApi(page)
await page.route('**/api/v1/gateway/devices/*/messages*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: [
{
_id: 'longurl_1',
recipient: '+251912657519',
message:
'Support request - textbee support\nUrgency: Urgent\nView conversation:\nhttps://dash.supporthq.app/dashboard/projects/69a591f28bdb2cda5452246b/conversations/69c2a0a058b1a815140a9be5',
status: 'failed',
type: 'sent',
errorCode: '1',
errorMessage:
'SMS failed on device. Common causes: no SMS credit on SIM, weak signal, or carrier blocked. Check SIM balance and signal, then try again. (code 0)',
device: { _id: 'device_1', brand: 'samsung', model: 'SM-A346E' },
requestedAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
},
],
meta: { total: 1, page: 1, limit: 20, totalPages: 1 },
}),
})
)
await page.goto('/dashboard/messaging/history')
await page.getByRole('button', { name: /Support request/ }).click()
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible()
const overflows = await dialog.evaluate(
(el) => el.scrollWidth > el.clientWidth + 1
)
expect(overflows, 'details dialog must not scroll sideways').toBe(false)
// The visible symptom was the action button rendering outside the card,
// which a scrollWidth check alone would miss if a parent clipped it.
const dialogBox = await dialog.boundingBox()
const buttonBox = await dialog
.getByRole('button', { name: 'Follow up' })
.boundingBox()
expect(dialogBox && buttonBox).toBeTruthy()
expect(
buttonBox!.x + buttonBox!.width,
'the action button must stay inside the dialog'
).toBeLessThanOrEqual(dialogBox!.x + dialogBox!.width + 1)
// The page must not gain a sideways scrollbar either.
const pageOverflow = await page.evaluate(
() => document.documentElement.scrollWidth - window.innerWidth
)
expect(pageOverflow, 'page must not scroll sideways').toBeLessThanOrEqual(
0
)
})
}
test('replying from a received message keeps a device selected', async ({
page,
context,