From fc32fea607136ae2a6a703ea27e7399e9693bfed Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Mon, 17 Aug 2026 22:04:55 +0200 Subject: [PATCH] fix(projects): a section can be dropped into, and a failed load says so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things from the same report — an external member who could see a project but could not get work into it. **A section with no tasks could not be filled by dragging.** Only the task rows accepted a drop, so the target was always another row; a section with none had nothing to aim at, and a section you have just made is empty by definition, which is exactly when you want to put something in it. The board has taken a drop on the column since it was written. The list now does the same: the section is the target, and a drop on it puts the task at the end. Dropping on a row still means "here exactly" and stops there rather than bubbling to the section behind it. The unsectioned block at the top is a target too, which is how a task comes back *out* of a section — previously only possible if some other loose task happened to be there to aim at. **And the page said "Projektet findes ikke, eller du har ikke adgang til det" for three different things.** `project` started null with no loading state, so that sentence was what you saw before the first request returned, what you saw when it failed, and what you saw when you genuinely had no access — and the effect never asked again, so the wrong one stayed until somebody reloaded. That is very likely what was reported: the container was restarted twice while people had the app open, and a request that lands mid-restart leaves the page insisting the project does not exist. Four states now. Loading renders nothing, as the shell does. 404 keeps the old sentence, since the server deliberately answers 404 for both "no such project" and "not yours" and the interface must not guess between them. Anything else says the request failed and offers to try again. What is *not* a bug: a viewer cannot create or move tasks. Checked at the API — an editor invited by link gets 201 on create, 201 on quick add and 200 on a move into a section; a viewer gets 404 on all of them, which is the rule working. If the person reporting this cannot add tasks, their membership is viewer rather than editor. Co-Authored-By: Claude Opus 5 --- web/e2e/smoke.spec.js | 53 ++++++++ web/src/lib/components/TaskList.svelte | 4 + web/src/routes/projekt/[id]/+page.svelte | 159 ++++++++++++++++++++--- 3 files changed, 198 insertions(+), 18 deletions(-) diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index df26aaa..e57994c 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -350,6 +350,59 @@ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne me expect(trouble).toEqual([]); }); +/** + * A task can be dropped into a section, including one with nothing in it. + * + * Only the task *rows* took a drop, so a section with no rows had nothing to aim + * at — and a section you have just made is always empty, which is exactly when + * you want to fill it. The board has always taken a drop on the column; the list + * had no equivalent. + * + * The second half is the other thing that made this hard to report: the page used + * to say "Projektet findes ikke, eller du har ikke adgang til det" whenever the + * load failed *or* had not finished, so a dropped connection and a project you + * cannot see were one sentence, and it never asked again. + */ +test('en opgave kan trækkes ned i en tom sektion, og en fejlet indlæsning siger det', 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('Risteriet'); + await sidebar.getByLabel('Projektnavn').press('Enter'); + + await page.getByLabel('Ny opgave').fill('brænde kaffe'); + await page.getByLabel('Ny opgave').press('Enter'); + await expect(page.getByText('brænde kaffe')).toBeVisible(); + + await page.getByRole('button', { name: '+ Tilføj sektion' }).click(); + await page.getByLabel('Ny sektion').fill('Uge 34'); + await page.getByLabel('Ny sektion').press('Enter'); + + const section = page + .locator('section') + .filter({ has: page.getByRole('heading', { name: 'Uge 34' }) }); + await expect(section.getByText('Tom')).toBeVisible(); + + await page.locator('.sortable').filter({ hasText: 'brænde kaffe' }).dragTo(section); + await expect(section.getByText('brænde kaffe')).toBeVisible(); + await expect(page.locator('.toast')).toHaveCount(0); + + // A load that fails says so, and offers to try again. + await page.route('**/api/v1/projects/*', (r) => r.abort()); + await page.reload(); + await expect(page.getByText('Serveren svarede ikke')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Prøv igen' })).toBeVisible(); + await page.unroute('**/api/v1/projects/*'); + await page.getByRole('button', { name: 'Prøv igen' }).click(); + await expect(page.getByRole('heading', { name: 'Risteriet' })).toBeVisible(); + + expect(trouble.filter((t) => !t.includes('projects'))).toEqual([]); +}); + /** * A task can be dragged onto another day, and onto another project. * diff --git a/web/src/lib/components/TaskList.svelte b/web/src/lib/components/TaskList.svelte index 39bdce1..ec48629 100644 --- a/web/src/lib/components/TaskList.svelte +++ b/web/src/lib/components/TaskList.svelte @@ -50,6 +50,10 @@ async function onDrop(event, target) { event.preventDefault(); + // The section around this list also takes drops, for the coarse "into here" + // gesture and for sections with no rows to aim at. A drop that landed on a + // row has already said exactly where it goes, so it stops here. + event.stopPropagation(); const id = draggingId; const below = overBelow; draggingId = null; diff --git a/web/src/routes/projekt/[id]/+page.svelte b/web/src/routes/projekt/[id]/+page.svelte index eaab02b..91234e3 100644 --- a/web/src/routes/projekt/[id]/+page.svelte +++ b/web/src/routes/projekt/[id]/+page.svelte @@ -5,12 +5,24 @@ import { api, humanMessage } from '$lib/api.js'; import { app } from '$lib/stores.svelte.js'; import { COLORS, colorVar } from '$lib/colors.js'; + import { TASK, carries, dragged, accept } from '$lib/dnd.js'; import TaskList from '$lib/components/TaskList.svelte'; import QuickAdd from '$lib/components/QuickAdd.svelte'; import BoardView from '$lib/components/BoardView.svelte'; import CalendarView from '$lib/components/CalendarView.svelte'; let project = $state(null); + /** + * loading | ready | denied | failed. + * + * Four states rather than "is project null", which is what this was: the page + * showed "Projektet findes ikke, eller du har ikke adgang til det" before the + * first request had even returned, and again if it failed for any reason at + * all. A slow load, a dropped connection, a server being restarted underneath + * you and a project you genuinely cannot see were one message — and it never + * asked again, so the wrong one stayed until somebody reloaded. + */ + let status = $state('loading'); let sections = $state([]); let members = $state([]); let showShare = $state(false); @@ -22,23 +34,31 @@ let id = $derived($page.params.id); $effect(() => { - const projectId = id; - if (!projectId) return; - - Promise.all([ - api.getProject(projectId), - api.listSections(projectId), - app.loadTasks({ project_id: projectId }) - ]) - .then(([p, s]) => { - project = p; - sections = s.sections; - }) - .catch(() => { - project = null; - }); + load(id); }); + async function load(projectId) { + if (!projectId) return; + status = 'loading'; + try { + const [p, s] = await Promise.all([ + api.getProject(projectId), + api.listSections(projectId), + app.loadTasks({ project_id: projectId }) + ]); + project = p; + sections = s.sections; + status = 'ready'; + } catch (e) { + project = null; + // 404 is the app's answer for both "no such project" and "not yours" — + // deliberately, so probing ids teaches nothing. Anything else is the + // request itself having failed, which is worth saying differently and + // worth offering to try again. + status = e?.status === 404 ? 'denied' : 'failed'; + } + } + $effect(() => { if (showShare && project) { api.listMembers(project.id).then((r) => (members = r.members)); @@ -120,6 +140,56 @@ } } + // --- dropping a task into a section --------------------------------------------- + + /** + * The section under the pointer, '' for the unsectioned block at the top. + * + * Only the task *rows* used to accept a drop, so a section with nothing in it + * had nothing to aim at and could not be filled by dragging at all — and a + * section you have just made is always empty. The board has always taken a drop + * on the column; this is the same idea in the list. + */ + let overSection = $state(null); + + function onSectionDragOver(event, sectionId) { + if (!canEdit || !carries(event, TASK)) return; + accept(event); + overSection = sectionId; + } + + async function onSectionDrop(event, sectionId) { + event.preventDefault(); + const id = dragged(event, TASK); + overSection = null; + if (!id || !canEdit) return; + + const task = app.get(id); + if (!task || (task.section_id ?? '') === sectionId) return; + + // At the end of the section: dropping on the area rather than between two + // rows is the coarse gesture, and landing at the top would push whatever is + // there down. + const existing = open.filter((t) => (t.section_id ?? '') === sectionId && t.id !== id); + const after = [...existing].sort((a, b) => a.sort_order - b.sort_order).at(-1); + + const previous = { ...task }; + app.replace(id, { ...task, section_id: sectionId }); + try { + app.replace( + id, + await api.moveTask(id, { + project_id: project.id, + section_id: sectionId, + after_id: after?.id ?? '' + }) + ); + } catch (e) { + app.replace(id, previous); + app.toast(humanMessage(e)); + } + } + // --- sections ------------------------------------------------------------------- let addingSection = $state(false); @@ -343,13 +413,28 @@ {:else if mode === 'calendar'} {:else} -
+ +
onSectionDragOver(e, '')} + ondragleave={() => (overSection = null)} + ondrop={(e) => onSectionDrop(e, '')} + > + {#if !unsectioned.length} +

Uden sektion

+ {/if}
{#each sections as section (section.id)} {@const tasks = open.filter((t) => t.section_id === section.id)} -
+ +
onSectionDragOver(e, section.id)} + ondragleave={() => (overSection = null)} + ondrop={(e) => onSectionDrop(e, section.id)} + >
{#if renamingSection === section.id}
renameSection(e, section)}> @@ -424,8 +509,18 @@ {#if project.role === 'viewer'}

Du kan se dette projekt, men ikke ændre det.

{/if} - {:else} + {:else if status === 'loading'} + +
+ {:else if status === 'denied'}

Projektet findes ikke, eller du har ikke adgang til det.

+ {:else} +

+ + Kunne ikke hente projektet. Serveren svarede ikke. + +

{/if}
@@ -741,6 +836,14 @@ section { margin-top: var(--s5); + border-radius: var(--radius); + } + + /* The whole section lights up, because the whole section is the target. A line + between two rows means "here exactly"; this means "in this one". */ + section.over { + box-shadow: 0 0 0 1px var(--accent); + background: var(--surface-sunken); } h2 { @@ -855,4 +958,24 @@ color: var(--accent); opacity: 0.5; } + + .booting { + min-height: 50vh; + } + + .retry { + padding: var(--s2) var(--s4); + border: 1px solid var(--line); + border-radius: var(--radius); + font-size: var(--text-sm); + color: var(--ink-muted); + transition: + border-color var(--fast) var(--ease), + color var(--fast) var(--ease); + } + + .retry:hover { + border-color: var(--accent); + color: var(--ink); + }