diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 9855b8e..8aace00 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -82,6 +82,12 @@ test('hvert link i sidebjælken fører et sted hen', async ({ page }) => { for (const href of hrefs) { await sidebar.locator(`a[href="${href}"]`).first().click(); + // Wait for the navigation before looking at the page. Without this the + // assertion can be satisfied by the heading of the page you just left — + // which is not a hypothetical: a change that removed a project page's + // heading entirely passed here on a fast machine and failed on CI. + await page.waitForURL(`**${href}`); + // Something with a heading rendered. A route written outside the routes // tree lands on the SPA shell and renders nothing — which is exactly how // the label route broke, and it answered 200 the whole time. @@ -196,9 +202,11 @@ test('et projekt kan omdøbes, slettes og hentes tilbage', async ({ page }) => { await sidebar.getByLabel('Projektnavn').fill('Ferie'); await sidebar.getByLabel('Projektnavn').press('Enter'); - const title = page.getByLabel('Projektets navn'); - await expect(title).toHaveValue('Ferie'); + await expect(page.getByRole('heading', { name: 'Ferie' })).toBeVisible(); + // The heading is the rename affordance: click it, and it becomes a field. + await page.getByRole('button', { name: 'Ferie' }).click(); + const title = page.getByLabel('Projektets navn'); await title.fill('Sommerferie'); await title.press('Enter'); await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeVisible(); diff --git a/web/src/routes/projekt/[id]/+page.svelte b/web/src/routes/projekt/[id]/+page.svelte index 64c7606..7943362 100644 --- a/web/src/routes/projekt/[id]/+page.svelte +++ b/web/src/routes/projekt/[id]/+page.svelte @@ -66,19 +66,19 @@ let open = $derived(app.tasks.filter((t) => !t.completed && !t.parent_id)); let unsectioned = $derived(open.filter((t) => !t.section_id)); + let editing = $state(false); + /** Renames on blur. A no-op when nothing changed, so tabbing through is free. */ async function rename(input) { const name = input.value.trim(); - if (!name || name === project.name) { - input.value = project.name; - return; - } + editing = false; + if (!name || name === project.name) return; + try { project = await api.updateProject(project.id, { name }); // The sidebar reads its own copy, so it has to be told. await app.refreshProjects(); } catch (e) { - input.value = project.name; app.toast(humanMessage(e)); } } @@ -123,11 +123,16 @@
{#if project}
- {#if isOwner} - + + {#if editing} + rename(e.currentTarget)} @@ -135,12 +140,20 @@ if (e.key === 'Enter') e.currentTarget.blur(); if (e.key === 'Escape') { e.currentTarget.value = project.name; - e.currentTarget.blur(); + editing = false; } }} /> {:else} -

{project.name}

+

+ {#if isOwner} + + {:else} + {project.name} + {/if} +

{/if}
{#each [['list', 'Liste'], ['board', 'Board'], ['calendar', 'Kalender']] as [value, label]} @@ -287,7 +300,21 @@ overflow-wrap: anywhere; } - /* Reads as the heading it replaces until you put the cursor in it. */ + /* The heading's button carries no button-ness: it is the heading, and the only + hint that it does anything is the underline on hover. */ + h1.renameable button { + font: inherit; + letter-spacing: inherit; + color: inherit; + text-align: left; + border-bottom: 1px solid transparent; + } + + h1.renameable button:hover { + border-bottom-color: var(--line-strong); + } + + /* Matches the heading it stands in for, so the swap does not move the page. */ .title { flex: 1; min-width: 0;