fix(projects): a section can be dropped into, and a failed load says so
Release / Build and publish image (push) Failing after 9m53s
Release / Build and publish image (push) Failing after 9m53s
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8b1f374855
commit
fc32fea607
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'}
|
||||
<CalendarView projectId={project.id} />
|
||||
{:else}
|
||||
<section>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<section
|
||||
class:over={overSection === ''}
|
||||
ondragover={(e) => onSectionDragOver(e, '')}
|
||||
ondragleave={() => (overSection = null)}
|
||||
ondrop={(e) => onSectionDrop(e, '')}
|
||||
>
|
||||
<TaskList tasks={unsectioned} projectId={project.id} sectionId="" {canEdit} />
|
||||
{#if !unsectioned.length}
|
||||
<p class="empty">Uden sektion</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#each sections as section (section.id)}
|
||||
{@const tasks = open.filter((t) => t.section_id === section.id)}
|
||||
<section>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<section
|
||||
class:over={overSection === section.id}
|
||||
ondragover={(e) => onSectionDragOver(e, section.id)}
|
||||
ondragleave={() => (overSection = null)}
|
||||
ondrop={(e) => onSectionDrop(e, section.id)}
|
||||
>
|
||||
<div class="section-head">
|
||||
{#if renamingSection === section.id}
|
||||
<form onsubmit={(e) => renameSection(e, section)}>
|
||||
@@ -424,8 +509,18 @@
|
||||
{#if project.role === 'viewer'}
|
||||
<p class="readonly">Du kan se dette projekt, men ikke ændre det.</p>
|
||||
{/if}
|
||||
{:else}
|
||||
{:else if status === 'loading'}
|
||||
<!-- Deliberately blank, as on first load elsewhere: a spinner for a request
|
||||
that usually resolves in 30ms is a flash of anxiety, not feedback. -->
|
||||
<div class="booting"></div>
|
||||
{:else if status === 'denied'}
|
||||
<p class="clear">Projektet findes ikke, eller du har ikke adgang til det.</p>
|
||||
{:else}
|
||||
<p class="clear">
|
||||
<span class="rune" aria-hidden="true">ᚹ</span>
|
||||
Kunne ikke hente projektet. Serveren svarede ikke.
|
||||
<button class="retry" onclick={() => load(id)}>Prøv igen</button>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user