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}
+