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([]); 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 input),
.settings :global(.field select), .settings :global(.field select),
.settings :global(.field textarea) { .settings :global(.field textarea),
.settings :global(.field output) {
width: 100%; width: 100%;
padding: var(--s2) var(--s3); padding: var(--s2) var(--s3);
background: var(--surface-sunken); background: var(--surface-sunken);
@@ -168,6 +169,22 @@
border-color: var(--accent); 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 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 the server answers with a map of field to message, and putting each one where
it belongs is the whole point of that shape. */ 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 it has to match Google's copy exactly, and the error Google gives
when it does not names neither value. Google afviser desuden when it does not names neither value. Google afviser desuden
private IP-adresser over http — det skal være https. --> 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> </div>
<form onsubmit={saveClient}> <form onsubmit={saveClient}>
@@ -546,7 +546,7 @@
{#if app.user?.is_admin} {#if app.user?.is_admin}
<div class="field"> <div class="field">
<label for="calendar-redirect">{t('int.redirectURI')}</label> <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> <p class="hint">{t('int.calendarSameClient')}</p>
</div> </div>
{:else if !calendar.has_client} {:else if !calendar.has_client}
@@ -653,7 +653,7 @@
<div class="field"> <div class="field">
<label for="feed">{t('int.address')}</label> <label for="feed">{t('int.address')}</label>
<input id="feed" class="mono" value={feedURL} readonly /> <output id="feed" class="mono">{feedURL}</output>
</div> </div>
<div class="row"> <div class="row">
@@ -681,7 +681,7 @@
<div class="field"> <div class="field">
<label for="mail">{t('int.yourAddress')}</label> <label for="mail">{t('int.yourAddress')}</label>
<input id="mail" class="mono" value={mailAddress} readonly /> <output id="mail" class="mono">{mailAddress}</output>
</div> </div>
<div class="row"> <div class="row">
@@ -700,7 +700,7 @@
<div class="field"> <div class="field">
<label for="hook">{t('int.yourHook')}</label> <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"> <p class="hint">
{t('int.hookHow')} {t('int.hookHow')}
</p> </p>
@@ -711,8 +711,8 @@
kroppen bare er den linje, opgaven skal hedde. --> kroppen bare er den linje, opgaven skal hedde. -->
<div class="field"> <div class="field">
<label for="hookcurl">{t('int.hookExample')}</label> <label for="hookcurl">{t('int.hookExample')}</label>
<textarea id="hookcurl" class="mono" rows="3" readonly <output id="hookcurl" class="mono"
>{`curl -X POST ${hookURL} \\\n -H "Content-Type: text/plain" \\\n -d "Ring til Anders i morgen p1 #Firma"`}</textarea >{`curl -X POST ${hookURL} \\\n -H "Content-Type: text/plain" \\\n -d "Ring til Anders i morgen p1 #Firma"`}</output
> >
</div> </div>
@@ -732,7 +732,7 @@
<div class="field"> <div class="field">
<label for="caldav">{t('int.server')}</label> <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"> <p class="hint">
{t('int.caldavAuth')} {t('int.caldavAuth')}
</p> </p>
@@ -817,7 +817,7 @@
color: var(--ink-muted); color: var(--ink-muted);
} }
.setup input[readonly] { .setup output {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--ink-muted); color: var(--ink-muted);
@@ -884,4 +884,38 @@
a { a {
color: var(--accent); 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> </style>
+15 -3
View File
@@ -1279,7 +1279,7 @@
lovet i en besked, der ikke kom af sted. --> lovet i en besked, der ikke kom af sted. -->
<p class="invitelink"> <p class="invitelink">
{t('notes.inviteNoMail')} {t('notes.inviteNoMail')}
<input readonly value={shareInviteLink} onclick={(e) => e.target.select()} /> <output>{shareInviteLink}</output>
</p> </p>
{/if} {/if}
</section> </section>
@@ -2216,11 +2216,23 @@
line-height: 1.5; 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%; width: 100%;
margin-top: 2px; margin-top: 2px;
font-size: var(--text-xs);
padding: 2px var(--s1); 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 { .addshare {