mirror of
https://github.com/vernu/textbee.git
synced 2026-09-03 03:29:58 +03:00
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:
@@ -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