Merge pull request #74 from kristianwind/kalender-url-ud-over-kanten
fix: URL'er kan læses helt på en telefon
This commit is contained in:
@@ -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([]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user