diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 6642265..98c50d7 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -156,6 +156,7 @@ test('hver fane under indstillinger renderer', async ({ page }) => { // Only an administrator sees these two, and the account these tests run as // is the first account, which is one. 'Brugere', + 'Historik', 'Fejl', 'Data og skabeloner' ]) { @@ -263,8 +264,8 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me await sidebar.getByLabel('Gruppens navn').fill('Arbejde'); await sidebar.getByLabel('Gruppens navn').press('Enter'); - // `exact`, because the heading's colour dot is a button too and its label is - // "Farve på Arbejde" — and Playwright matches an accessible name by substring + // `exact`, because a group's name is a user-chosen word that can turn up inside + // another button's name — Playwright matches an accessible name by substring // unless told otherwise. const heading = sidebar.getByRole('button', { name: 'Arbejde', exact: true }); await expect(heading).toBeVisible(); @@ -311,16 +312,34 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me ); await sidebar.getByRole('button', { name: 'Arbejde', exact: true }).click(); - // Colour, and indentation. The dot is the control as well as the preview, so - // the thing you press is the thing you are about to change. - await sidebar.getByRole('button', { name: 'Farve på Arbejde' }).click(); + // Colour, which is chosen while renaming rather than from the row. Both are + // edits to the same thing, and a heading carrying four controls crowded the + // name it exists to show. + await sidebar.getByRole('button', { name: 'Omdøb' }).click(); await sidebar.getByRole('button', { name: 'Petrol' }).click(); + // Still open: picking a colour must not close the form under the pointer, which + // is what a plain blur-to-cancel does in the two browsers that do not focus a + // button on click. + await expect(sidebar.getByLabel('Gruppens navn')).toBeVisible(); + await sidebar.getByLabel('Gruppens navn').press('Enter'); - const painted = await sidebar.locator('.folder-head .group-dot').evaluate((el) => - getComputedStyle(el).backgroundColor - ); + const painted = await sidebar + .locator('.folder-head .group-dot') + .evaluate((el) => getComputedStyle(el).backgroundColor); expect(painted, 'gruppens prik fik ikke sin farve').not.toBe('rgba(0, 0, 0, 0)'); + // The heading itself is *not* indented — only what is filed under it. A heading + // that starts further in than the rows above it says the heading is inside + // something, which it is not. + const headLeft = await sidebar + .locator('.folder-head') + .evaluate((el) => el.getBoundingClientRect().left + parseFloat(getComputedStyle(el).paddingLeft)); + const looseLeft = await sidebar + .locator('.views a') + .first() + .evaluate((el) => el.getBoundingClientRect().left + parseFloat(getComputedStyle(el).paddingLeft)); + expect(headLeft, 'gruppehovedet er rykket ind').toBeLessThanOrEqual(looseLeft); + // A project inside a group starts further in than a row outside one. Measured // rather than asserted on a class, because indentation is a fact about where // the row's contents land — and measured at the *content* edge, not the box: @@ -465,7 +484,7 @@ test('en opgave kan trækkes til en anden dag og videre til et projekt', async ( // browser is pinned to Europe/Copenhagen and this process is not, and for two // hours of every day they disagree about what today is. await page.goto('/upcoming'); - await page.getByRole('button', { name: 'Kalender' }).click(); + await page.getByRole('button', { name: 'Måned', exact: true }).click(); const chip = page.locator('.chip').filter({ hasText: 'hent pakken' }); await expect(chip).toBeVisible(); @@ -804,6 +823,139 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) => expect(trouble).toEqual([]); }); +/** + * A task dragged across a month boundary. + * + * The month grid could not do this. It is anchored to a month, so the two days on + * either side of its edge sit in different grids, and getting from one to the other + * means paging — which cannot be done mid-drag, because a drag in flight swallows + * the click that would page it. The week view exists for exactly this: a week that + * straddles the 31st and the 1st has both days in the same row. + * + * The dates are worked out in the browser, not here. Playwright pins the page to + * Europe/Copenhagen and this process is not pinned to anything, so for two hours of + * every day the two disagree about what today is. + */ +test('en opgave kan trækkes hen over et månedsskifte i uge-visningen', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.goto('/'); + + // The first day, starting tomorrow, whose next day is in another month *and* in + // the same Monday-to-Sunday week. Such a pair exists at every month boundary + // except one that falls between a Sunday and a Monday, so searching forward + // finds one within about two months whatever today is. + const { from, to } = await page.evaluate(() => { + const iso = (d) => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String( + d.getDate() + ).padStart(2, '0')}`; + const monday = (d) => { + const m = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + m.setDate(m.getDate() - ((m.getDay() + 6) % 7)); + return iso(m); + }; + const day = new Date(); + for (let i = 0; i < 90; i++) { + day.setDate(day.getDate() + 1); + const next = new Date(day.getFullYear(), day.getMonth(), day.getDate() + 1); + if (day.getMonth() !== next.getMonth() && monday(day) === monday(next)) { + return { from: iso(day), to: iso(next) }; + } + } + throw new Error('no month boundary inside a week in the next 90 days'); + }); + + const box = page.getByLabel('Ny opgave'); + await box.fill(`skifte dæk ${from}`); + await box.press('Enter'); + // Not asserted here: this is the Today view and the task is dated weeks out, so + // it is correctly absent. The week grid below is where it has to turn up. + await expect(box).toHaveValue(''); + + await page.goto('/upcoming'); + await page.getByRole('button', { name: 'Uge', exact: true }).click(); + + // Forward a week at a time until the row is the one holding that date. Bounded, + // because a loop that pages forever on a broken button is a timeout with no + // explanation in it. + const cell = page.locator(`[data-date="${from}"]`); + const next = page.getByRole('button', { name: 'Næste uge' }); + for (let i = 0; i < 15 && (await cell.count()) === 0; i++) { + await next.click(); + } + await expect(cell, `uge-visningen nåede aldrig frem til ${from}`).toBeVisible(); + + // The claim the view is for: both sides of the month boundary, in one row. + await expect(page.locator(`[data-date="${to}"]`)).toBeVisible(); + expect(from.slice(0, 7), 'de to dage skulle ligge i hver sin måned').not.toEqual(to.slice(0, 7)); + + const chip = page.locator('.chip').filter({ hasText: 'skifte dæk' }); + await expect(chip).toBeVisible(); + await chip.dragTo(page.locator(`[data-date="${to}"]`)); + + await expect(page.locator(`[data-date="${to}"]`).getByText('skifte dæk')).toBeVisible(); + await expect(page.locator(`[data-date="${from}"]`).getByText('skifte dæk')).toBeHidden(); + + expect(trouble).toEqual([]); +}); + +/** + * The instance-wide history. + * + * The per-project panel has shown the same rows for a while; this is the question + * it cannot answer, which is what happened in a project the administrator is not a + * member of. The test uses a second person's own project for exactly that reason — + * asserting against a project the admin can already see would pass with the old + * per-project endpoint behind it. + */ +test('historik-siden viser hændelser fra et projekt, administratoren ikke er med i', async ({ + browser, + page +}) => { + const trouble = watchForTrouble(page); + + // A second account, with a project of its own. storageState is spelled out: + // a "fresh" context inherits the signed-in one otherwise, which is the single + // case this test is not about. + const context = await browser.newContext({ storageState: { cookies: [], origins: [] } }); + const theirs = await context.newPage(); + const email = `historik-${Date.now()}@example.dk`; + + await page.goto('/indstillinger/brugere'); + await page.getByLabel('E-mailadresse').fill(email); + await page.getByRole('button', { name: 'Send invitation' }).click(); + const link = await page.locator('.link-out').textContent(); + expect(link).toContain('/invite?token='); + + await theirs.goto(link); + await theirs.getByLabel('Navn').fill('Sigrid'); + await theirs.getByLabel(/Adgangskode/).fill('et langt kodeord til test'); + await theirs.getByRole('button', { name: 'Opret konto' }).click(); + await expect(theirs.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible(); + + const sidebar = theirs.getByRole('navigation', { name: 'Hovedmenu' }); + await sidebar.getByRole('button', { name: 'Nyt projekt' }).click(); + await sidebar.getByLabel('Projektnavn').fill('Sigrids eget'); + await sidebar.getByLabel('Projektnavn').press('Enter'); + await expect(sidebar.getByRole('link', { name: 'Sigrids eget' })).toBeVisible(); + + // The administrator cannot open that project, and can still see that it happened. + await page.goto('/indstillinger/historik'); + const rows = page.locator('.rows li').filter({ hasText: 'Sigrids eget' }); + await expect(rows.first()).toBeVisible(); + await expect(rows.first().getByText('oprettede projektet')).toBeVisible(); + // `exact`, or this matches the project name "Sigrids eget" in the same row. + await expect(rows.first().getByText('Sigrid', { exact: true })).toBeVisible(); + + // And the filters narrow it rather than emptying it. + await page.getByLabel('Hændelse').selectOption('project.created'); + await expect(page.locator('.rows li').filter({ hasText: 'Sigrids eget' }).first()).toBeVisible(); + await page.getByRole('button', { name: 'Ryd' }).click(); + + await context.close(); + expect(trouble).toEqual([]); +}); + /** * The project page on a phone. * @@ -854,6 +1006,175 @@ test('projektsiden er brugbar på en telefon', async ({ page }) => { expect(trouble).toEqual([]); }); +/** + * The sidebar never scrolls sideways. + * + * It grew a horizontal scrollbar on an ordinary desktop, under the whole menu. + * The cause was one flex item: the account name had `overflow: hidden` and no + * `min-width: 0`, so it refused to shrink below its content and pushed the row + * past the column — and `overflow-y: auto` on the sidebar resolves to `auto` on + * the other axis too, which turned "a row that is too wide" into a scrollbar. + * + * Asserted with a long name, because the name is the part that varies and a short + * one hides the whole class of bug. The mobile test measures the *document*; this + * measures the sidebar itself, which is a different box and was never checked. + */ +test('sidebjælken kan ikke scrolles vandret', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.goto('/indstillinger'); + + // A name long enough to overflow the column at its default width. + await page.getByLabel('Navn').fill('Kristian Vinterberg-Skovgaard'); + await page.getByRole('button', { name: 'Gem' }).first().click(); + await expect(page.getByText('Kristian Vinterberg-Skovgaard').first()).toBeVisible(); + + const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' }); + const sideways = await sidebar.evaluate((el) => el.scrollWidth - el.clientWidth); + expect(sideways, 'sidebjælken kan scrolles vandret').toBeLessThanOrEqual(1); + + // And the whole page, for the same reason at a different scale. + const page_sideways = await page.evaluate( + () => document.documentElement.scrollWidth - document.documentElement.clientWidth + ); + expect(page_sideways, 'siden kan scrolles vandret').toBeLessThanOrEqual(1); + + // Put the name back, so the tests after this one see the account they expect. + await page.getByLabel('Navn').fill(USER.name); + await page.getByRole('button', { name: 'Gem' }).first().click(); + + expect(trouble).toEqual([]); +}); + +/** + * A project can have more than one section. + * + * Reported from use: sections "have no function, because you cannot create more + * than one". The API makes as many as you ask for — there is a Go test that makes + * three — so whatever this is, it is in the page. Every other test here makes + * exactly one section, which is the shape of test that cannot see it. + */ +test('et projekt kan have flere sektioner @forms', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.goto('/'); + + const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' }); + await sidebar.getByRole('button', { name: 'Nyt projekt' }).click(); + await sidebar.getByLabel('Projektnavn').fill('Ombygning'); + await sidebar.getByLabel('Projektnavn').press('Enter'); + await expect(page.getByRole('heading', { name: 'Ombygning' })).toBeVisible(); + + for (const name of ['Planlægning', 'I gang', 'Til gennemsyn']) { + await page.getByRole('button', { name: '+ Tilføj sektion' }).click(); + const field = page.getByLabel('Ny sektion'); + await expect(field, `feltet kom ikke frem for "${name}"`).toBeVisible(); + await field.fill(name); + await field.press('Enter'); + await expect( + page.getByRole('heading', { name, exact: true }), + `sektionen "${name}" kom ikke på siden` + ).toBeVisible(); + } + + // All three at once, still there — an each-block that threw would have stopped + // rendering at the one that broke it. + for (const name of ['Planlægning', 'I gang', 'Til gennemsyn']) { + await expect(page.getByRole('heading', { name, exact: true })).toBeVisible(); + } + + // And they survive a reload, which is the difference between "the page kept up" + // and "the server took it". + await page.reload(); + for (const name of ['Planlægning', 'I gang', 'Til gennemsyn']) { + await expect(page.getByRole('heading', { name, exact: true })).toBeVisible(); + } + + // The board is where somebody actually looks for a section, because a column on + // a board *is* one — and it had no way to make one at all. Reported as + // "sections have no function: there is no way to create them", which from + // inside a board was exactly true. + await page.getByRole('button', { name: 'Board', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Uden sektion' })).toBeVisible(); + await page.getByRole('button', { name: '+ Tilføj sektion' }).click(); + const field = page.getByLabel('Ny sektion'); + await expect(field, 'boardet har ingen måde at lave en sektion på').toBeVisible(); + await field.fill('Afsluttet'); + await field.press('Enter'); + await expect(page.getByRole('heading', { name: 'Afsluttet', exact: true })).toBeVisible(); + + await page.reload(); + await expect(page.getByRole('heading', { name: 'Afsluttet', exact: true })).toBeVisible(); + + expect(trouble).toEqual([]); +}); + +/** + * Form fields on a touch device are at least 16px. + * + * Not a matter of taste. Below 16px, iOS Safari zooms the page in when a field + * takes focus and does not zoom back out when it is blurred — so the person is + * left with the whole interface too large and has to pinch it back by hand, once + * per field they tap. The type scale tops out at 15px, so every input in the app + * did it; the task drawer worst, being four fields deep. + * + * Chromium cannot reproduce the zoom, but it can be asked the question that causes + * it, which is what this checks. A touch context rather than a narrow viewport: + * the rule is keyed on `(hover: none) and (pointer: coarse)`, because an iPad does + * the same thing at a width no phone breakpoint would catch. + */ +test('felter er mindst 16px, så iOS ikke zoomer ind og bliver der', async ({ browser }) => { + const phone = { viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true }; + + const tooSmall = async (page, where) => { + const bad = await page.evaluate(() => + [...document.querySelectorAll('input, textarea, select')] + .filter((el) => el.getClientRects().length) + .map((el) => ({ + what: el.tagName.toLowerCase() + (el.type ? `[type=${el.type}]` : ''), + size: parseFloat(getComputedStyle(el).fontSize) + })) + .filter((f) => f.size < 16) + ); + expect(bad, `for små felter på ${where}`).toEqual([]); + }; + + // The sign-in form, in a context with the session spelled out as empty — + // newContext inherits the project's storageState, so a "fresh" browser is + // otherwise already signed in and the form never renders. + const signedOut = await browser.newContext({ + ...phone, + storageState: { cookies: [], origins: [] } + }); + const anon = await signedOut.newPage(); + await anon.goto('/'); + await expect(anon.getByRole('button', { name: 'Log ind' })).toBeVisible(); + await tooSmall(anon, 'login-siden'); + await signedOut.close(); + + const context = await browser.newContext(phone); + const page = await context.newPage(); + const trouble = watchForTrouble(page); + + await page.goto('/'); + await expect(page.getByLabel('Ny opgave')).toBeVisible(); + await tooSmall(page, 'I dag'); + + // The task drawer, which is where it was worst: four fields deep. Dated today, + // or it goes to the Inbox and is correctly absent from the view being looked at. + await page.getByLabel('Ny opgave').fill('slibe gulvet i dag'); + await page.getByLabel('Ny opgave').press('Enter'); + await page.getByText('slibe gulvet', { exact: true }).click(); + await expect(page.getByRole('complementary', { name: 'Opgave' })).toBeVisible(); + await tooSmall(page, 'opgavedetaljer'); + + // And the settings pages, which are almost entirely fields. + await page.goto('/indstillinger'); + await expect(page.locator('section.panel').first()).toBeVisible(); + await tooSmall(page, 'indstillinger'); + + await context.close(); + expect(trouble).toEqual([]); +}); + /** * The MCP connector address must not fall through to the app shell. * diff --git a/web/playwright.config.js b/web/playwright.config.js index 94b4283..9690638 100644 --- a/web/playwright.config.js +++ b/web/playwright.config.js @@ -65,6 +65,32 @@ export default defineConfig({ name: 'chromium', use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' }, dependencies: ['setup'] + }, + /** + * A short WebKit pass over the forms, and nothing else. + * + * The rest of this suite is Chromium only on purpose: a smoke test exists to + * catch a build that does not work, and running it in three engines finds the + * same bug three times. Focus is the exception, and it cost a real report to + * learn it. Safari ignores the `autofocus` attribute on anything inserted + * after the page has loaded — so every inline form in this app opened with no + * cursor in it, and what you typed went nowhere. It does not look like a bug; + * it looks like the feature does not work. "Sections have no function, + * because you cannot create more than one" was this. + * + * One test, not four. The engines share this server and this database, so a + * second pass over tests that create projects by name collides with what the + * first pass already made — which fails as a name that is suddenly ambiguous + * rather than as anything about WebKit. The sections test is the one that + * earns the second engine, because it is the one the focus bug broke. + * + * WebKit here is the same engine Safari is, which is the whole point. + */ + { + name: 'webkit-forms', + grep: /@forms/, + use: { ...devices['Desktop Safari'], storageState: 'e2e/.auth/user.json' }, + dependencies: ['setup'] } ], diff --git a/web/src/app.css b/web/src/app.css index e8c9acd..0b5851f 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -407,6 +407,32 @@ button { } } +/* Form fields are 16px on a touch device, whatever the component asked for. + * + * Below 16px, iOS Safari zooms the page in when a field takes focus — and it does + * not zoom back out when the field is blurred. The person is left with the whole + * interface too large and has to pinch it back themselves, once per field they + * tap. The type scale here tops out at 15px, so every input in the app did it: + * quick add, the sign-in form, and the task drawer worst of all, which is four + * fields deep. + * + * `!important` on purpose. This is a browser workaround that has to beat every + * component's own typography, and each of those is a scoped class that outranks + * any selector this file could write. Narrowing it further would mean editing the + * sixty-nine places that set a field's font size and adding a rule none of them + * can be allowed to break. + * + * Touch rather than a width: an iPad in Safari does the same thing at a width no + * phone breakpoint would catch. + */ +@media (hover: none) and (pointer: coarse) { + input, + textarea, + select { + font-size: 16px !important; + } +} + .visually-hidden { position: absolute; width: 1px; diff --git a/web/src/lib/components/BoardView.svelte b/web/src/lib/components/BoardView.svelte index 3856816..58925b3 100644 --- a/web/src/lib/components/BoardView.svelte +++ b/web/src/lib/components/BoardView.svelte @@ -9,11 +9,36 @@ * them — and it gives keyboard users the browser's own behaviour for free. */ import { app } from '$lib/stores.svelte.js'; - import { api } from '$lib/api.js'; + import { api, humanMessage } from '$lib/api.js'; import { TASK, startDrag, carries, accept } from '$lib/dnd.js'; + import { focusOnMount } from '$lib/focus.js'; import TaskRow from './TaskRow.svelte'; - let { project, sections, canEdit } = $props(); + let { project, sections, canEdit, onsectionadded } = $props(); + + // --- adding a column ------------------------------------------------------------ + // + // A column on a board *is* a section, so this is where somebody looks for one. + // It was only in the list view, which meant a board — the view whose entire + // shape is its sections — had no way to grow one. Reported as "sections have no + // function: there is no way to create them", which from inside a board is + // exactly true. + + let adding = $state(false); + let name = $state(''); + + async function addSection(event) { + event.preventDefault(); + const trimmed = name.trim(); + if (!trimmed) return; + adding = false; + name = ''; + try { + onsectionadded?.(await api.createSection(project.id, trimmed)); + } catch (e) { + app.toast(humanMessage(e)); + } + } let draggingId = $state(null); let overColumn = $state(null); @@ -123,6 +148,31 @@ {/each} + + {#if canEdit} +
+ {#if adding} +
+ !name.trim() && (adding = false)} + onkeydown={(e) => e.key === 'Escape' && (adding = false)} + /> +
+ {:else} + + {/if} +
+ {/if}