Merge pull request #74 from kristianwind/kalender-url-ud-over-kanten
CI / OpenAPI is valid (push) Successful in 9s
CI / End-to-end (push) Successful in 8m13s
CI / Docker image builds (push) Successful in 1m30s
CI / Go (push) Failing after 13m56s

fix: URL'er kan læses helt på en telefon
This commit is contained in:
Kristian Wind
2026-09-12 09:43:40 +02:00
committed by GitHub
4 changed files with 170 additions and 13 deletions
+94
View File
@@ -4360,3 +4360,97 @@ test('dagens plan står på I dag, og ikke kun som en besked', async ({ page })
expect(trouble).toEqual([]);
});
/**
* Adresserne på integrationssiden kan ses helt på en telefon.
*
* En URL er ét ord på et par hundrede tegn uden et mellemrum i, og den kan derfor
* ikke ombryde af sig selv. Det gav to forskellige fejl på den samme side, og
* prøven her holder øje med dem begge:
*
* Abonnementets adresse lå i en liste uden noget, der lod den bryde, og det
* bredeste element på en side bestemmer sidens bredde — så hele indholdsspalten fik
* et vandret rullepanel, ikke bare den ene linje.
*
* Feed'et, krogen og CalDAV-serveren lå i skrivebeskyttede <input>, som ruller
* indeni i stedet for at ombryde. Der var ikke noget rullepanel at se: adressen
* stoppede bare midt i kassen, og resten fandtes kun ved at trække inde i feltet.
*
* Abonnementet kommer fra et svar, prøven selv skriver. Serveren ville skulle hente
* kalenderen for at have et at vise, og en prøve, der går på nettet, er en prøve,
* der fejler en dag af en anden grund end sin egen.
*/
test('adresserne på integrationssiden kan ses helt på en telefon', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.route('**/api/v1/calendar', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
connected: false,
calendars: [],
has_client: false,
read_only: true,
redirect_uri: 'http://localhost:8097/api/v1/calendar/callback',
subscriptions: [
{
id: '0198f000-0000-7000-8000-000000000001',
provider: 'ics',
account: 'kw@netic.dk',
url:
'https://calendar.google.com/calendar/ical/kw%40netic.dk/private-3f9a1c7e5b2d4a6f8c0e1d2b3a4f5e6d/basic.ics',
created_at: '2026-09-01T09:00:00+02:00'
}
]
})
})
);
// En iPhone i stående format. Bredden er det eneste, der betyder noget her.
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/indstillinger/integrationer');
await expect(page.getByText('kw@netic.dk')).toBeVisible();
// `.content` og ikke dokumentet. Skallen holder selv siden på skærmens bredde,
// så documentElement måler 390 uanset hvad — det er indholdsspalten indeni, der
// ruller, og det er den, man skubber med tommelfingeren. En prøve på dokumentet
// består med fejlen i behold; den er prøvet.
const spill = await page.evaluate(() => {
const el = document.querySelector('.content');
const url = document.querySelector('.subs-list .url');
return {
content: el.scrollWidth - el.clientWidth,
past: Math.round(url.getBoundingClientRect().right) - document.documentElement.clientWidth
};
});
expect(spill.content).toBe(0);
expect(spill.past).toBeLessThanOrEqual(0);
// Og adresserne på siden står der helt. De lå i skrivebeskyttede <input>, som
// ikke kan ombryde: kassen var 306 px og indholdet 595, så resten af adressen
// fandtes kun ved at rulle inde i feltet — hvilket ingen opdager. Et felt, der
// er klippet, er et felt, hvor scrollWidth er større end clientWidth, og det er
// præcis det, der måles her.
//
// Felterne står ved navn og ikke som en forespørgsel på en klasse: en prøve, der
// måler `.field output` og finder nul, består — også den dag felterne er lavet om
// til noget andet klippet. Den skal fejle, hvis et af dem forsvinder.
const clipped = await page.evaluate(() =>
['feed', 'mail', 'hook', 'hookcurl', 'caldav']
.map((id) => {
const el = document.getElementById(id);
if (!el) return `${id}: findes ikke`;
if (el.scrollWidth - el.clientWidth > 1) return `${id}: ${el.scrollWidth} > ${el.clientWidth}`;
return null;
})
.filter(Boolean)
);
expect(clipped).toEqual([]);
// Selve teksten, ikke kun kassens mål: krogens adresse er den, der skal kunne
// læses af, hvis man taster den ind i en genvej på telefonen.
await expect(page.locator('#hook')).toHaveText(/^https?:\/\/.+\/inbound\/hook\/.+/);
expect(trouble).toEqual([]);
});
+18 -1
View File
@@ -151,7 +151,8 @@
.settings :global(.field input),
.settings :global(.field select),
.settings :global(.field textarea) {
.settings :global(.field textarea),
.settings :global(.field output) {
width: 100%;
padding: var(--s2) var(--s3);
background: var(--surface-sunken);
@@ -168,6 +169,22 @@
border-color: var(--accent);
}
/* Adresserne — feed'et, krogen, CalDAV-serveren — vises i et <output> og ikke i
et skrivebeskyttet <input>. Et input kan ikke ombryde: det ruller indeni, så
på en telefon står halvdelen af adressen uden for kassen, og der er ingen måde
at se resten på. <output> er stadig et felt, en <label for> kan pege på, og det
er tekst, der ombryder.
`user-select: all` er der, fordi det var det eneste, inputtet gjorde bedre: ét
tryk tog hele adressen. Uden den skulle man trække hen over fire linjer. */
.settings :global(.field output) {
display: block;
white-space: pre-wrap;
overflow-wrap: anywhere;
user-select: all;
line-height: 1.5;
}
/* The field error sits under its own input rather than in a summary at the top:
the server answers with a map of field to message, and putting each one where
it belongs is the whole point of that shape. */
@@ -460,7 +460,7 @@
it has to match Google's copy exactly, and the error Google gives
when it does not names neither value. Google afviser desuden
private IP-adresser over http — det skal være https. -->
<input id="redirect" readonly value={client.redirect_uri} />
<output id="redirect">{client.redirect_uri}</output>
</div>
<form onsubmit={saveClient}>
@@ -546,7 +546,7 @@
{#if app.user?.is_admin}
<div class="field">
<label for="calendar-redirect">{t('int.redirectURI')}</label>
<input id="calendar-redirect" class="mono" readonly value={calendar.redirect_uri} />
<output id="calendar-redirect" class="mono">{calendar.redirect_uri}</output>
<p class="hint">{t('int.calendarSameClient')}</p>
</div>
{:else if !calendar.has_client}
@@ -653,7 +653,7 @@
<div class="field">
<label for="feed">{t('int.address')}</label>
<input id="feed" class="mono" value={feedURL} readonly />
<output id="feed" class="mono">{feedURL}</output>
</div>
<div class="row">
@@ -681,7 +681,7 @@
<div class="field">
<label for="mail">{t('int.yourAddress')}</label>
<input id="mail" class="mono" value={mailAddress} readonly />
<output id="mail" class="mono">{mailAddress}</output>
</div>
<div class="row">
@@ -700,7 +700,7 @@
<div class="field">
<label for="hook">{t('int.yourHook')}</label>
<input id="hook" class="mono" value={hookURL} readonly />
<output id="hook" class="mono">{hookURL}</output>
<p class="hint">
{t('int.hookHow')}
</p>
@@ -711,8 +711,8 @@
kroppen bare er den linje, opgaven skal hedde. -->
<div class="field">
<label for="hookcurl">{t('int.hookExample')}</label>
<textarea id="hookcurl" class="mono" rows="3" readonly
>{`curl -X POST ${hookURL} \\\n -H "Content-Type: text/plain" \\\n -d "Ring til Anders i morgen p1 #Firma"`}</textarea
<output id="hookcurl" class="mono"
>{`curl -X POST ${hookURL} \\\n -H "Content-Type: text/plain" \\\n -d "Ring til Anders i morgen p1 #Firma"`}</output
>
</div>
@@ -732,7 +732,7 @@
<div class="field">
<label for="caldav">{t('int.server')}</label>
<input id="caldav" class="mono" value={`${location.origin}/caldav/`} readonly />
<output id="caldav" class="mono">{`${location.origin}/caldav/`}</output>
<p class="hint">
{t('int.caldavAuth')}
</p>
@@ -817,7 +817,7 @@
color: var(--ink-muted);
}
.setup input[readonly] {
.setup output {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--ink-muted);
@@ -884,4 +884,38 @@
a {
color: var(--accent);
}
/* The bullet list this was is why the page scrolled sideways on a phone. A
subscription address is one word with nothing to break at, so it cannot wrap
on its own — and the widest element on a page sets the width of the page, not
of its own line. Hence `anywhere`, which breaks mid-word when there is no
other opportunity, plus `min-width: 0` so the flex item is allowed to be
narrower than its content in the first place. */
.subs-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--s3);
}
.subs-list li {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--s1);
min-width: 0;
}
.subs-list .url {
font-size: var(--text-xs);
color: var(--ink-muted);
overflow-wrap: anywhere;
max-width: 100%;
}
.subs-list button {
margin-top: var(--s1);
}
</style>
+15 -3
View File
@@ -1279,7 +1279,7 @@
lovet i en besked, der ikke kom af sted. -->
<p class="invitelink">
{t('notes.inviteNoMail')}
<input readonly value={shareInviteLink} onclick={(e) => e.target.select()} />
<output>{shareInviteLink}</output>
</p>
{/if}
</section>
@@ -2216,11 +2216,23 @@
line-height: 1.5;
}
.invitelink input {
/* <output> og ikke et skrivebeskyttet <input>: et input kan ikke ombryde, så et
invitationslink stod halvt uden for kassen på en telefon, og resten kunne kun
nås ved at rulle inde i feltet. `user-select: all` beholder det, inputtet var
god til — ét tryk tager hele linket. */
.invitelink output {
display: block;
width: 100%;
margin-top: 2px;
font-size: var(--text-xs);
padding: 2px var(--s1);
background: var(--surface-sunken);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--ink);
overflow-wrap: anywhere;
user-select: all;
}
.addshare {