Clarify web UI terminology and copy the entrypoint URL (refs #57)
All checks were successful
check / check (push) Successful in 3m40s
All checks were successful
check / check (push) Successful in 3m40s
Terminology: the nav labelled its link Sources while every heading said
Webhooks. Both nav links and the sources list page title now say
Webhooks, matching the product name and the database.Webhook model. The
/sources and /source/{id} routes are deliberately unchanged; renaming
them would break existing bookmarks for no gain, since the URL is not
what a user reads.
Profile: the "Settings" column held only placeholder copy promising
settings that would appear later. Password change is the only real
account setting and it already has its own card below, so the column is
removed and the two-column grid collapses to the single remaining one.
Entrypoint URL copy button as progressive enhancement. The button is
rendered with the hidden attribute and a data-copy-target naming the
element that holds the URL. app.js reveals it only after confirming
both a resolvable target and a usable Clipboard API, so a browser
without either shows no dead control, and the URL is plain selectable
text in every case.
Template rendering assertions cover the nav labels, the absence of any
remaining user-visible "Sources", and the button's hidden-by-default
markup, so the copy cannot drift back silently.
Note on the retention copy: it is untouched here. The reaper treats a
non-positive RetentionDays as retain-forever, but on this branch no UI
path can produce one, so copy describing that would be false today. The
work belongs with the change that makes the value reachable.
This commit is contained in:
143
internal/handlers/ui_copy_test.go
Normal file
143
internal/handlers/ui_copy_test.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// renderPage renders a page template through the real template set as
|
||||||
|
// an authenticated user and returns the resulting HTML.
|
||||||
|
func renderPage(
|
||||||
|
t *testing.T,
|
||||||
|
h *handlers.Handlers,
|
||||||
|
sess *session.Session,
|
||||||
|
page string,
|
||||||
|
data map[string]any,
|
||||||
|
) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cookies := authenticatedCookies(t, sess, "test-user-id", "testuser")
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/", nil,
|
||||||
|
)
|
||||||
|
for _, c := range cookies {
|
||||||
|
req.AddCookie(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.RenderTemplateForTest(w, req, page, data)
|
||||||
|
|
||||||
|
return w.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNavbarUsesWebhookTerminology pins the user-visible navigation
|
||||||
|
// label to "Webhooks". The /sources route is deliberately unchanged, so
|
||||||
|
// the assertion targets the link text rather than the href.
|
||||||
|
func TestNavbarUsesWebhookTerminology(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var h *handlers.Handlers
|
||||||
|
|
||||||
|
var sess *session.Session
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
body := renderPage(t, h, sess, "sources_list.html", map[string]any{
|
||||||
|
"Webhooks": []handlers.WebhookListItem{},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Contains(t, body, `class="btn-text">Webhooks</a>`)
|
||||||
|
assert.Contains(
|
||||||
|
t, body, `class="btn-text w-full text-left">Webhooks</a>`,
|
||||||
|
)
|
||||||
|
assert.Contains(
|
||||||
|
t, body,
|
||||||
|
`<h1 class="text-2xl font-medium text-gray-900">Webhooks</h1>`,
|
||||||
|
)
|
||||||
|
assert.NotContains(
|
||||||
|
t, body, ">Sources<",
|
||||||
|
"no user-visible element may still be labelled Sources",
|
||||||
|
)
|
||||||
|
assert.Contains(
|
||||||
|
t, body, `href="/sources"`,
|
||||||
|
"the /sources route itself must not change",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEditPageUsesWebhookTerminology pins the edit page's heading and
|
||||||
|
// its back link. The link's href still points at /source/{id}, which is
|
||||||
|
// intentional: only user-visible copy changes.
|
||||||
|
func TestEditPageUsesWebhookTerminology(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var h *handlers.Handlers
|
||||||
|
|
||||||
|
var sess *session.Session
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
webhook := database.Webhook{Name: "wh", RetentionDays: 14}
|
||||||
|
webhook.ID = "wh-1"
|
||||||
|
|
||||||
|
body := renderPage(t, h, sess, "source_edit.html", map[string]any{
|
||||||
|
"Webhook": webhook,
|
||||||
|
"Error": "",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Contains(t, body, "Edit Webhook")
|
||||||
|
assert.NotContains(t, body, ">Sources<")
|
||||||
|
assert.Contains(t, body, `href="/source/wh-1"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEntrypointCopyButtonIsProgressiveEnhancement proves the copy
|
||||||
|
// affordance degrades: the button ships with the hidden attribute, so a
|
||||||
|
// browser that never runs app.js shows no dead control, and the URL is
|
||||||
|
// rendered as ordinary selectable text either way.
|
||||||
|
func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var h *handlers.Handlers
|
||||||
|
|
||||||
|
var sess *session.Session
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
entrypoint := database.Entrypoint{Path: "abc123"}
|
||||||
|
entrypoint.ID = "ep-1"
|
||||||
|
|
||||||
|
body := renderPage(t, h, sess, "source_detail.html", map[string]any{
|
||||||
|
"Webhook": database.Webhook{Name: "wh"},
|
||||||
|
"Entrypoints": []database.Entrypoint{entrypoint},
|
||||||
|
"Targets": []database.Target{},
|
||||||
|
"Events": []database.Event{},
|
||||||
|
"BaseURL": "https://hooks.example.com",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Contains(
|
||||||
|
t, body,
|
||||||
|
`<code id="entrypoint-url-ep-1"`,
|
||||||
|
)
|
||||||
|
assert.Contains(t, body, "https://hooks.example.com/webhook/abc123")
|
||||||
|
assert.Contains(
|
||||||
|
t, body,
|
||||||
|
`hidden data-copy-target="entrypoint-url-ep-1"`,
|
||||||
|
"the button must start hidden and be revealed by script",
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,2 +1,62 @@
|
|||||||
// Webhooker client-side JavaScript
|
// Webhooker client-side JavaScript
|
||||||
console.log("Webhooker loaded");
|
console.log("Webhooker loaded");
|
||||||
|
|
||||||
|
// Copy-to-clipboard, as progressive enhancement.
|
||||||
|
//
|
||||||
|
// Markup renders each copy button with the `hidden` attribute and a
|
||||||
|
// `data-copy-target` pointing at the id of the element holding the
|
||||||
|
// text. This script reveals a button only once it has both a resolvable
|
||||||
|
// target and a usable Clipboard API, so a browser without either shows
|
||||||
|
// no button at all and the text stays selectable.
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var revertDelayMs = 2000;
|
||||||
|
|
||||||
|
function flash(button, message) {
|
||||||
|
var original = button.getAttribute("data-copy-label");
|
||||||
|
button.textContent = message;
|
||||||
|
window.setTimeout(function () {
|
||||||
|
button.textContent = original;
|
||||||
|
}, revertDelayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wire(button) {
|
||||||
|
var target = document.getElementById(
|
||||||
|
button.getAttribute("data-copy-target")
|
||||||
|
);
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.setAttribute("data-copy-label", button.textContent);
|
||||||
|
button.addEventListener("click", function () {
|
||||||
|
navigator.clipboard.writeText(target.textContent.trim()).then(
|
||||||
|
function () {
|
||||||
|
flash(button, "Copied");
|
||||||
|
},
|
||||||
|
function () {
|
||||||
|
flash(button, "Copy failed");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
button.removeAttribute("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (!navigator.clipboard || !navigator.clipboard.writeText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var buttons = document.querySelectorAll("[data-copy-target]");
|
||||||
|
for (var i = 0; i < buttons.length; i++) {
|
||||||
|
wire(buttons[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<!-- Desktop navigation -->
|
<!-- Desktop navigation -->
|
||||||
<div class="hidden md:flex items-center gap-4">
|
<div class="hidden md:flex items-center gap-4">
|
||||||
{{if .User}}
|
{{if .User}}
|
||||||
<a href="/sources" class="btn-text">Sources</a>
|
<a href="/sources" class="btn-text">Webhooks</a>
|
||||||
<a href="/user/{{.User.Username}}" class="btn-text">
|
<a href="/user/{{.User.Username}}" class="btn-text">
|
||||||
<svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 16 16">
|
<svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 16 16">
|
||||||
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
<div x-show="open" x-cloak x-transition class="md:hidden mt-4 pt-4 border-t border-gray-200">
|
<div x-show="open" x-cloak x-transition class="md:hidden mt-4 pt-4 border-t border-gray-200">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
{{if .User}}
|
{{if .User}}
|
||||||
<a href="/sources" class="btn-text w-full text-left">Sources</a>
|
<a href="/sources" class="btn-text w-full text-left">Webhooks</a>
|
||||||
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
|
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
|
||||||
<form method="POST" action="/pages/logout">
|
<form method="POST" action="/pages/logout">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
|||||||
@@ -34,24 +34,18 @@
|
|||||||
|
|
||||||
<hr class="border-gray-200 mb-6">
|
<hr class="border-gray-200 mb-6">
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
<div>
|
||||||
<div>
|
<h3 class="text-lg font-medium text-gray-900 mb-3">Account Information</h3>
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-3">Account Information</h3>
|
<dl class="space-y-3">
|
||||||
<dl class="space-y-3">
|
<div class="flex">
|
||||||
<div class="flex">
|
<dt class="w-32 text-sm font-medium text-gray-500">Username</dt>
|
||||||
<dt class="w-32 text-sm font-medium text-gray-500">Username</dt>
|
<dd class="text-sm text-gray-900">{{.User.Username}}</dd>
|
||||||
<dd class="text-sm text-gray-900">{{.User.Username}}</dd>
|
</div>
|
||||||
</div>
|
<div class="flex">
|
||||||
<div class="flex">
|
<dt class="w-32 text-sm font-medium text-gray-500">Account Type</dt>
|
||||||
<dt class="w-32 text-sm font-medium text-gray-500">Account Type</dt>
|
<dd class="text-sm text-gray-900">Standard User</dd>
|
||||||
<dd class="text-sm text-gray-900">Standard User</dd>
|
</div>
|
||||||
</div>
|
</dl>
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-3">Settings</h3>
|
|
||||||
<p class="text-sm text-gray-500">Profile settings and preferences will be available here.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,12 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<code class="text-xs text-gray-500 break-all block mt-1">{{$.BaseURL}}/webhook/{{.Path}}</code>
|
<div class="flex items-start gap-2 mt-1">
|
||||||
|
<code id="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 break-all block flex-1">{{$.BaseURL}}/webhook/{{.Path}}</code>
|
||||||
|
<!-- Hidden until app.js reveals it; without the
|
||||||
|
script the URL above stays selectable. -->
|
||||||
|
<button type="button" hidden data-copy-target="entrypoint-url-{{.ID}}" class="text-xs text-gray-500 hover:text-primary-600">Copy</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="p-4 text-sm text-gray-500">No entrypoints configured.</div>
|
<div class="p-4 text-sm text-gray-500">No entrypoints configured.</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{{template "base" .}}
|
{{template "base" .}}
|
||||||
|
|
||||||
{{define "title"}}Sources - Webhooker{{end}}
|
{{define "title"}}Webhooks - Webhooker{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="max-w-6xl mx-auto px-6 py-8">
|
<div class="max-w-6xl mx-auto px-6 py-8">
|
||||||
|
|||||||
Reference in New Issue
Block a user