Break long values on the webhook page and event log at phone width (closes #391) #495

Merged
clawbot merged 1 commits from issue-391-phone-width-rows into next 2026-10-03 06:56:44 +02:00
8 changed files with 96 additions and 20 deletions
Showing only changes of commit d5bdef9b56 - Show all commits
+3 -1
View File
@@ -1280,7 +1280,9 @@ the event log only the newest starts expanded, and an event there expands and
collapses when its row's caret or its ID is clicked, and from the keyboard, but collapses when its row's caret or its ID is clicked, and from the keyboard, but
not when its ID is selected with the mouse, and a delivery's attempts inside it not when its ID is selected with the mouse, and a delivery's attempts inside it
expand and collapse; and at phone width the menu button opens and closes the expand and collapse; and at phone width the menu button opens and closes the
mobile menu. It also fails if the browser reports a console warning or error, an mobile menu, and neither the webhook page nor the event log, with a delivery's
attempts open, scrolls sideways or cuts anything off at the page's or a card's
edge. It also fails if the browser reports a console warning or error, an
uncaught exception, or anything the policy refused. `make check` and the image uncaught exception, or anything the policy refused. `make check` and the image
build lint it but do not run it, and `make test` leaves it out (its file is build lint it but do not run it, and `make test` leaves it out (its file is
built only with the `browser` build tag). Run it with `make test-browser` after built only with the `browser` build tag). Run it with `make test-browser` after
+4 -3
View File
@@ -17,14 +17,15 @@ import (
// arrivedAt is how a page names the entrypoint an event arrived at. // arrivedAt is how a page names the entrypoint an event arrived at.
func arrivedAt(name string) string { func arrivedAt(name string) string {
return `Arrived at <span class="text-gray-900">` + name + `</span>` return `Arrived at <span class="text-gray-900 wrap-anywhere">` + name +
`</span>`
} }
// copiedRequestArrivedAt is how a page names, for a resubmitted copy, // copiedRequestArrivedAt is how a page names, for a resubmitted copy,
// the entrypoint the request it copies arrived at. // the entrypoint the request it copies arrived at.
func copiedRequestArrivedAt(name string) string { func copiedRequestArrivedAt(name string) string {
return `The request it copies arrived at <span class="text-gray-900">` + return `The request it copies arrived at ` +
name + `</span>` `<span class="text-gray-900 wrap-anywhere">` + name + `</span>`
} }
// headerBox is how a page shows an event's request header lines: as // headerBox is how a page shows an event's request header lines: as
+67 -2
View File
@@ -98,20 +98,25 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
checkRefusedNewWebhook(ctx, t, srv.URL+"/hooks/new") checkRefusedNewWebhook(ctx, t, srv.URL+"/hooks/new")
checkEventLog(ctx, t, page+"/events", event.ID, older.ID, target.Name) checkEventLog(ctx, t, page+"/events", event.ID, older.ID, target.Name)
checkMobileMenu(ctx, t, page) checkMobileMenu(ctx, t, page)
checkPhoneWidth(ctx, t, page, page+"/events", target.Name)
assert.Empty(t, problems(), "the browser reported problems") assert.Empty(t, problems(), "the browser reported problems")
} }
// seedBrowserWebhook seeds the webhook the browser test loads, owned by // seedBrowserWebhook seeds the webhook the browser test loads, owned by
// userID: an entrypoint, two events, and a target whose delivery of the // userID: an entrypoint, two events, and a target whose delivery of the
// newer event failed once with a 502. It returns the webhook, the older // newer event failed once with a 502. The webhook's name and the newer
// and the newer event, and the target. // event's content type are each too long for one line on a phone. It
// returns the webhook, the older and the newer event, and the target.
func seedBrowserWebhook( func seedBrowserWebhook(
t *testing.T, env *testEnv, userID string, t *testing.T, env *testEnv, userID string,
) (*database.Webhook, *database.Event, *database.Event, *database.Target) { ) (*database.Webhook, *database.Event, *database.Event, *database.Target) {
t.Helper() t.Helper()
webhook := env.seedWebhook(t, userID) webhook := env.seedWebhook(t, userID)
require.NoError(t, env.db.DB().Model(webhook).Update(
"name", "payment_provider_production_notifications",
).Error)
require.NoError(t, env.db.DB().Omit(clause.Associations).Create( require.NoError(t, env.db.DB().Omit(clause.Associations).Create(
&database.Entrypoint{ &database.Entrypoint{
WebhookID: webhook.ID, WebhookID: webhook.ID,
@@ -126,6 +131,9 @@ func seedBrowserWebhook(
webhookDB, err := env.dbMgr.GetDB(webhook.ID) webhookDB, err := env.dbMgr.GetDB(webhook.ID)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, webhookDB.Model(event).Update(
"content_type", "application/vnd.paymentprovider.event+json",
).Error)
require.NoError(t, webhookDB.Omit(clause.Associations).Create( require.NoError(t, webhookDB.Omit(clause.Associations).Create(
&database.DeliveryResult{ &database.DeliveryResult{
DeliveryID: dlv.ID, DeliveryID: dlv.ID,
@@ -1292,3 +1300,60 @@ func checkMobileMenu(ctx context.Context, t *testing.T, url string) {
click(ctx, t, button) click(ctx, t, button)
assert.True(t, hidden(ctx, menu), "the menu button does not close the menu") assert.True(t, hidden(ctx, menu), "the menu button does not close the menu")
} }
// scrollsSideways reports whether the page is wider than the window. A
// page's clientWidth is the window's width less its scroll bar.
const scrollsSideways = `document.documentElement.scrollWidth >
document.documentElement.clientWidth`
// cutOffElements lists each element, without elements inside it, that
// is shown but runs past the page's edge or its card's, by more than a
// pixel of rounding. A card hides what runs past its edge.
const cutOffElements = `[...document.querySelectorAll("body *")]
.filter((el) => {
const box = el.getBoundingClientRect();
const card = el.closest(".card")?.getBoundingClientRect();
const left = card ? card.left : 0;
const right = card ? card.right : document.documentElement.clientWidth;
return el.children.length === 0 && box.width > 0 &&
(box.left < left - 1 || box.right > right + 1);
})
.map((el) => el.outerHTML.slice(0, 120))`
// checkPhoneWidth loads the webhook page, url, and its event log,
// eventLog, in a phone-sized window, the event log with the attempts of
// the newest event's delivery to targetName shown. It checks that
// neither page scrolls sideways and that nothing shown on either, no
// status, time or control, is cut off at the page's or its card's edge.
func checkPhoneWidth(
ctx context.Context, t *testing.T, url, eventLog, targetName string,
) {
t.Helper()
var (
sideways bool
cutOff []string
)
measure := chromedp.Tasks{
chromedp.Evaluate(scrollsSideways, &sideways),
chromedp.Evaluate(cutOffElements, &cutOff),
}
require.NoError(t, chromedp.Run(
ctx,
chromedp.EmulateViewport(phoneWidth, phoneHeight),
loadPage(url),
measure,
))
assert.False(t, sideways, "the webhook page scrolls sideways on a phone")
assert.Empty(t, cutOff, "the webhook page cuts these off on a phone")
require.NoError(t, chromedp.Run(ctx, loadPage(eventLog)))
click(ctx, t, `//span[text()="`+targetName+`"]`)
require.True(t, shown(ctx, `//span[text()="Attempt 1"]`),
"clicking the delivery does not show its attempts")
require.NoError(t, chromedp.Run(ctx, measure))
assert.False(t, sideways, "the event log scrolls sideways on a phone")
assert.Empty(t, cutOff, "the event log cuts these off on a phone")
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -4,7 +4,7 @@
an event's own page. Spans only, since a button may hold no div. --> an event's own page. Spans only, since a button may hold no div. -->
<span class="flex flex-1 flex-wrap items-center justify-between gap-3"> <span class="flex flex-1 flex-wrap items-center justify-between gap-3">
<span class="flex flex-wrap items-center gap-3"> <span class="flex flex-wrap items-center gap-3">
<span class="text-sm text-gray-700">{{.Target.DisplayName}}</span> <span class="text-sm text-gray-700 wrap-anywhere">{{.Target.DisplayName}}</span>
{{if .Replay}}<span class="text-xs text-gray-500">replay</span>{{end}} {{if .Replay}}<span class="text-xs text-gray-500">replay</span>{{end}}
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">{{with .Paused}}waiting: target paused after repeated failures, next try no earlier than {{.Until}} ({{.Relative}}){{else}}{{.Status}}{{end}}</span> <span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">{{with .Paused}}waiting: target paused after repeated failures, next try no earlier than {{.Until}} ({{.Relative}}){{else}}{{.Status}}{{end}}</span>
</span> </span>
+2 -2
View File
@@ -6,9 +6,9 @@
entrypoint; the request it copies did. --> entrypoint; the request it copies did. -->
<div class="space-y-2 text-xs"> <div class="space-y-2 text-xs">
{{if .ResubmittedFrom}} {{if .ResubmittedFrom}}
<p class="text-gray-500">The request it copies arrived at <span class="text-gray-900">{{.Entrypoint}}</span></p> <p class="text-gray-500">The request it copies arrived at <span class="text-gray-900 wrap-anywhere">{{.Entrypoint}}</span></p>
{{else}} {{else}}
<p class="text-gray-500">Arrived at <span class="text-gray-900">{{.Entrypoint}}</span></p> <p class="text-gray-500">Arrived at <span class="text-gray-900 wrap-anywhere">{{.Entrypoint}}</span></p>
{{end}} {{end}}
{{if .HeadersCut}} {{if .HeadersCut}}
<p class="text-gray-500">The request headers are larger than the event log shows. <a href="{{.Body.EventURL}}" class="btn-small">Show the request headers</a></p> <p class="text-gray-500">The request headers are larger than the event log shows. <a href="{{.Body.EventURL}}" class="btn-small">Show the request headers</a></p>
+8 -5
View File
@@ -6,15 +6,18 @@
<!-- 108rem, half again the 72rem (max-w-6xl) of the webhook list, the <!-- 108rem, half again the 72rem (max-w-6xl) of the webhook list, the
event log, the navbar and the footer, so an entrypoint URL fits on event log, the navbar and the footer, so an entrypoint URL fits on
one line. An inline style, because the committed tailwind.css has one line. An inline style, because the committed tailwind.css has
no class this wide. --> no class this wide. wrap-anywhere goes only on names and
descriptions: a row too wide for a phone must still run past the
edge, where the browser test sees it, rather than break its
controls mid-word. -->
<div class="mx-auto px-6 py-8" style="max-width: 108rem"> <div class="mx-auto px-6 py-8" style="max-width: 108rem">
<div class="mb-6"> <div class="mb-6">
<a href="/hooks" class="btn-small">&larr; Back to webhooks</a> <a href="/hooks" class="btn-small">&larr; Back to webhooks</a>
<div class="flex flex-wrap justify-between items-center gap-2 mt-2"> <div class="flex flex-wrap justify-between items-center gap-2 mt-2">
<div> <div>
<h1 class="text-2xl font-medium text-gray-900">{{.Webhook.Name}}</h1> <h1 class="text-2xl font-medium text-gray-900 wrap-anywhere">{{.Webhook.Name}}</h1>
{{if .Webhook.Description}} {{if .Webhook.Description}}
<p class="text-sm text-gray-500 mt-1">{{.Webhook.Description}}</p> <p class="text-sm text-gray-500 mt-1 wrap-anywhere">{{.Webhook.Description}}</p>
{{end}} {{end}}
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
@@ -60,7 +63,7 @@
{{range .Entrypoints}} {{range .Entrypoints}}
<div class="p-4" x-data="collapsible"> <div class="p-4" x-data="collapsible">
<div class="flex flex-wrap items-center justify-between gap-2 mb-1"> <div class="flex flex-wrap items-center justify-between gap-2 mb-1">
<span x-show="closed" class="text-sm font-medium text-gray-900">{{if .Description}}{{.Description}}{{else}}Entrypoint{{end}}</span> <span x-show="closed" class="text-sm font-medium text-gray-900 wrap-anywhere">{{if .Description}}{{.Description}}{{else}}Entrypoint{{end}}</span>
<!-- Edit shows this form in place of the <!-- Edit shows this form in place of the
description and hides until it closes, and description and hides until it closes, and
Cancel resets what was typed. With Cancel resets what was typed. With
@@ -249,7 +252,7 @@
{{range .Targets}} {{range .Targets}}
<div class="p-4"> <div class="p-4">
<div class="flex flex-wrap items-center justify-between gap-2 mb-1"> <div class="flex flex-wrap items-center justify-between gap-2 mb-1">
<span class="text-sm font-medium text-gray-900">{{.Name}}</span> <span class="text-sm font-medium text-gray-900 wrap-anywhere">{{.Name}}</span>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<span class="badge-info">{{if eq .Type "database"}}archive{{else}}{{.Type}}{{end}}</span> <span class="badge-info">{{if eq .Type "database"}}archive{{else}}{{.Type}}{{end}}</span>
{{if .Active}} {{if .Active}}
+10 -5
View File
@@ -3,10 +3,15 @@
{{define "title"}}Full Event Log - {{.Webhook.Name}} - Webhooker{{end}} {{define "title"}}Full Event Log - {{.Webhook.Name}} - Webhooker{{end}}
{{define "content"}} {{define "content"}}
<!-- wrap-anywhere goes only on names, IDs and content types: a row too
wide for a phone must still run past the edge, where the browser
test sees it, rather than break its statuses, times or controls
mid-word. So a target's name in an event's row, which shares its
element with the delivery's status, goes without. -->
<div class="max-w-6xl mx-auto px-6 py-8"> <div class="max-w-6xl mx-auto px-6 py-8">
<div class="mb-6"> <div class="mb-6">
<a href="/hook/{{.Webhook.ID}}" class="btn-small">&larr; Back to {{.Webhook.Name}}</a> <a href="/hook/{{.Webhook.ID}}" class="btn-small wrap-anywhere">&larr; Back to {{.Webhook.Name}}</a>
<div class="flex justify-between items-center mt-2"> <div class="flex flex-wrap justify-between items-center gap-2 mt-2">
<h1 class="text-2xl font-medium text-gray-900">Full Event Log</h1> <h1 class="text-2xl font-medium text-gray-900">Full Event Log</h1>
<!-- Under a filter, this counts the events the filter lists. --> <!-- Under a filter, this counts the events the filter lists. -->
<span class="text-sm text-gray-500">{{if gt .TotalEvents (len .Events)}}{{len .Events}} most recent of {{.TotalEvents}} events{{else}}{{.TotalEvents}}{{if not .Show}} total{{end}} event{{if ne .TotalEvents 1}}s{{end}}{{end}}{{if eq .Show "failed"}} with a failed delivery{{else if eq .Show "pending"}} with a delivery pending or retrying{{end}}</span> <span class="text-sm text-gray-500">{{if gt .TotalEvents (len .Events)}}{{len .Events}} most recent of {{.TotalEvents}} events{{else}}{{.TotalEvents}}{{if not .Show}} total{{end}} event{{if ne .TotalEvents 1}}s{{end}}{{end}}{{if eq .Show "failed"}} with a failed delivery{{else if eq .Show "pending"}} with a delivery pending or retrying{{end}}</span>
@@ -28,8 +33,8 @@
<div role="button" tabindex="0" class="btn-small w-full flex flex-wrap justify-between gap-2" :aria-expanded="open" @mousedown="cancelPendingToggle" @click="toggleUnlessSelecting" @keydown.enter.prevent="toggle" @keydown.space.prevent="toggle"> <div role="button" tabindex="0" class="btn-small w-full flex flex-wrap justify-between gap-2" :aria-expanded="open" @mousedown="cancelPendingToggle" @click="toggleUnlessSelecting" @keydown.enter.prevent="toggle" @keydown.space.prevent="toggle">
<span class="flex flex-wrap items-center gap-3"> <span class="flex flex-wrap items-center gap-3">
<span class="badge-info">{{.Method}}</span> <span class="badge-info">{{.Method}}</span>
<span class="text-sm font-mono text-gray-700">{{.ID}}</span> <span class="text-sm font-mono text-gray-700 wrap-anywhere">{{.ID}}</span>
<span class="text-sm text-gray-500">{{.ContentType}}</span> <span class="text-sm text-gray-500 wrap-anywhere">{{.ContentType}}</span>
{{if .ResubmittedFrom}} {{if .ResubmittedFrom}}
<span class="text-xs text-gray-500" title="This event is a copy of {{.ResubmittedFromID}}">resubmitted copy</span> <span class="text-xs text-gray-500" title="This event is a copy of {{.ResubmittedFromID}}">resubmitted copy</span>
{{end}} {{end}}
@@ -54,7 +59,7 @@
<div x-show="open" x-cloak class="mt-3 p-3 bg-gray-50 rounded-md"> <div x-show="open" x-cloak class="mt-3 p-3 bg-gray-50 rounded-md">
<div class="mb-3 flex flex-wrap items-center justify-between gap-2"> <div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<div class="text-xs text-gray-500"> <div class="text-xs text-gray-500">
{{if .ResubmittedFrom}}Resubmitted from event <a href="/hook/{{$.Webhook.ID}}/events/{{.ResubmittedFromID}}" class="btn-small font-mono">{{.ResubmittedFromID}}</a>.{{end}} {{if .ResubmittedFrom}}Resubmitted from event <a href="/hook/{{$.Webhook.ID}}/events/{{.ResubmittedFromID}}" class="btn-small font-mono wrap-anywhere">{{.ResubmittedFromID}}</a>.{{end}}
{{if .ResubmitCount}}Resubmitted as {{.ResubmitCount}} new event{{if ne .ResubmitCount 1}}s{{end}}.{{end}} {{if .ResubmitCount}}Resubmitted as {{.ResubmitCount}} new event{{if ne .ResubmitCount 1}}s{{end}}.{{end}}
</div> </div>
<form method="POST" action="/hook/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline"> <form method="POST" action="/hook/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline">