mirror of
https://github.com/vernu/textbee.git
synced 2026-07-30 08:57:13 -04:00
fix(web): keep long message text inside the details dialog
DialogContent is a CSS grid, and an implicit grid track has a min-content floor. `break-words` does not reduce min-content size, so a long unbreakable URL in any child pushed every child past the card's max-width instead of wrapping. In message history that left the message box, the delivery error panel and the action button rendered hundreds of pixels outside the dialog, off the side of the screen. An explicit minmax(0,1fr) column removes that floor, after which break-words can do its job. This fixes every dialog in the app, not just this one: any long URL, API key or gateway ID hit the same track. Also makes the details dialog the single scroll container, matching api-keys and the webhook payload modal, instead of capping the message body at 224px behind a scrollbar nested in a scrollbar. And the list row preview now wraps, so a URL-heavy message fills both clamped lines rather than being cut off partway through the first. Covered by an e2e regression test at 375px and 1280px that asserts the dialog does not scroll sideways and that the action button stays inside it. Verified failing before this change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user