feat(projects): rename, delete, and a trash you can actually reach

A project could be created and shared and never renamed or deleted. Both
have worked through the API since the beginning; there was simply nothing
to click.

The heading is now the field — a heading that happens to be editable
rather than a form that looks like one — and saves on blur, with Escape
putting the old name back. Owner only, and the Inbox keeps its plain
heading because it cannot be either renamed away or deleted.

Deleting needed more than a button. It is soft, with thirty days to
change your mind, and restoring has been possible the whole time — from
an id, which is exactly what you no longer have once the project has gone
from the screen. A recovery window nobody can reach is not one. So
GET /trash/projects lists what you have deleted, with how many tasks went
with it and how long is left, and Settings → Data has the button.

Owned, not shared: a member of a project somebody else deleted has no
business bringing it back, and there is a test that says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-17 15:02:41 +02:00
committed by Kristian Wind
co-authored by Claude Opus 5
parent 0dbea0ea4b
commit 8a6e1bc39e
10 changed files with 424 additions and 4 deletions
+39
View File
@@ -180,6 +180,45 @@ test('manifestet og service workeren findes, og ikonerne findes også', async ({
expect(await worker.text()).toContain("addEventListener('push'");
});
/**
* A project can be renamed, deleted and brought back.
*
* The round trip matters more than the three steps: deleting used to be a
* one-way door in the interface — the API could restore, but only from an id
* that had already disappeared from the screen.
*/
test('et projekt kan omdøbes, slettes og hentes tilbage', 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('Ferie');
await sidebar.getByLabel('Projektnavn').press('Enter');
const title = page.getByLabel('Projektets navn');
await expect(title).toHaveValue('Ferie');
await title.fill('Sommerferie');
await title.press('Enter');
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeVisible();
page.once('dialog', (dialog) => dialog.accept());
await page.getByRole('button', { name: 'Slet projektet' }).click();
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeHidden();
// And it is reachable again, which is the half that did not exist.
await page.goto('/indstillinger/data');
const trash = page.locator('section.panel').filter({ hasText: 'Papirkurv' });
await expect(trash.getByText('Sommerferie')).toBeVisible();
await trash.getByRole('button', { name: 'Hent tilbage' }).click();
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeVisible();
expect(trouble).toEqual([]);
});
/**
* The MCP connector address must not fall through to the app shell.
*