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