feat(sidebar): de faste visninger kan sorteres, og rækkefølgen følger kontoen
Release / Build and publish image (push) Failing after 9m57s
Release / Build and publish image (push) Failing after 9m57s
I dag, Kommende, Venter og Indbakken kan trækkes i den rækkefølge, man vil have dem. På kontoen frem for i browseren: sidebjælkens bredde er en kendsgerning om skærmen, og svaret på en telefon er ikke svaret på en skærm — men hvilken rækkefølge man vil have dem i, er en kendsgerning om personen. En liste af nøgler frem for et tal pr. visning. Ukendte nøgler droppes og manglende føjes til sidst, så en visning kan tilføjes eller fjernes uden at strande nogens rækkefølge — hvilket allerede er sket én gang, da Færdige forlod menuen. To ting, prøverne fandt undervejs, og begge var mine: Rækken som trækgreb gjorde alt indeni trækbart, og sidebjælken er allerede modtager for opgaver — en opgave trukket til I dag holdt op med at lande, fordi rækken havde taget gestussen. Et lille greb tager ingenting, før man griber det. Og drop-håndteringen slugte begivenheden, før den tjekkede, om den overhovedet var en menuflytning. Migration 0019. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
90bdf4b725
commit
f557d319ca
@@ -1875,6 +1875,13 @@ test('tallet ved et projekt er opgaver, og sidebjælken kan foldes med tastature
|
||||
await box.fill('første i dag');
|
||||
await box.press('Enter');
|
||||
|
||||
// In the project itself before looking at the sidebar. Without this the count
|
||||
// can be read while the task is still on its way, or — worse — after it landed
|
||||
// in the inbox because the box that took the text belonged to the previous
|
||||
// view. Then the assertion below fails for a reason that has nothing to do with
|
||||
// what it is testing.
|
||||
await expect(page.getByText('første', { exact: true })).toBeVisible();
|
||||
|
||||
// Reloaded before looking. What is asserted is that the number is open tasks
|
||||
// and comes from the server, which holds on any load.
|
||||
//
|
||||
|
||||
@@ -188,6 +188,7 @@ export const api = {
|
||||
updateProfile: (data) => patch('/auth/me', data),
|
||||
// Its own route rather than a field on the profile: that one backs a form with
|
||||
// a save button, and this writes on every click of a chevron.
|
||||
setNavOrder: (order) => put('/auth/nav-order', { order }),
|
||||
setSidebarSections: (sections) => put('/auth/sidebar-sections', { sections }),
|
||||
signup: (data) => post('/auth/signup', data),
|
||||
forgotPassword: (email) => post('/auth/password/forgot', { email }),
|
||||
|
||||
@@ -117,6 +117,70 @@
|
||||
}
|
||||
|
||||
|
||||
// --- the fixed views ---------------------------------------------------------
|
||||
|
||||
// Delegated is only offered where delegating is possible at all: on an instance
|
||||
// with one person that view can never have anything in it, and a permanent empty
|
||||
// entry is a question the sidebar keeps asking and answering "no".
|
||||
let navItems = $derived(
|
||||
[
|
||||
{ key: 'today', href: '/', label: 'nav.today' },
|
||||
{ key: 'upcoming', href: '/upcoming', label: 'nav.upcoming' },
|
||||
app.projects.some((p) => p.shared)
|
||||
? { key: 'delegated', href: '/uddelegeret', label: 'nav.delegated' }
|
||||
: null,
|
||||
// No href: the inbox is a project and is drawn by projectRow, which knows
|
||||
// its id. It is in this list only so it can be dragged with the others.
|
||||
{ key: 'inbox', label: 'nav.inbox' }
|
||||
].filter(Boolean)
|
||||
);
|
||||
|
||||
let orderedNav = $derived(
|
||||
app
|
||||
.navOrder(navItems.map((i) => i.key))
|
||||
.map((key) => navItems.find((i) => i.key === key))
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
let draggingNav = $state(null);
|
||||
let overNav = $state(null);
|
||||
|
||||
function onNavDragStart(event, key) {
|
||||
draggingNav = key;
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
// A payload, because Firefox refuses to start a drag without one.
|
||||
event.dataTransfer.setData('text/plain', key);
|
||||
}
|
||||
|
||||
function onNavDragOver(event, key) {
|
||||
if (!draggingNav || key === draggingNav) return;
|
||||
event.preventDefault();
|
||||
overNav = key;
|
||||
}
|
||||
|
||||
function clearNavDrag() {
|
||||
draggingNav = null;
|
||||
overNav = null;
|
||||
}
|
||||
|
||||
async function onNavDrop(event, key) {
|
||||
const moved = draggingNav;
|
||||
// Checked before anything is swallowed. Stopping the event first meant a task
|
||||
// dragged onto I dag never reached the handler that gives it a date — the row
|
||||
// took the drop and then discovered it was not for it.
|
||||
if (!moved || moved === key) {
|
||||
clearNavDrag();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clearNavDrag();
|
||||
|
||||
const keys = orderedNav.map((i) => i.key).filter((k) => k !== moved);
|
||||
keys.splice(keys.indexOf(key), 0, moved);
|
||||
await app.setNavOrder(keys);
|
||||
}
|
||||
|
||||
// --- resizing -------------------------------------------------------------------
|
||||
|
||||
let resizing = $state(false);
|
||||
@@ -340,43 +404,60 @@
|
||||
|
||||
<div class="views">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<a
|
||||
href="/"
|
||||
class:active={current === '/'}
|
||||
class:receiving={overToday}
|
||||
onclick={onnavigate}
|
||||
ondragover={(e) => {
|
||||
if (!carries(e, TASK)) return;
|
||||
accept(e);
|
||||
overToday = true;
|
||||
}}
|
||||
ondragleave={() => (overToday = false)}
|
||||
ondrop={onDropOnToday}
|
||||
>
|
||||
<span class="dot today" aria-hidden="true"></span>
|
||||
{t('nav.today')}
|
||||
</a>
|
||||
<a href="/upcoming" class:active={current === '/upcoming'} onclick={onnavigate}>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{t('nav.upcoming')}
|
||||
</a>
|
||||
{#if app.projects.some((p) => p.shared)}
|
||||
<!-- Only where delegating is possible at all. On an instance with one
|
||||
person this view can never have anything in it, and a permanent empty
|
||||
entry is a question the sidebar keeps asking and answering "no". -->
|
||||
<a href="/uddelegeret" class:active={current === '/uddelegeret'} onclick={onnavigate}>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{t('nav.delegated')}
|
||||
</a>
|
||||
{/if}
|
||||
<!-- Færdige is not in this list any more. It used to sit here on the argument
|
||||
that somebody hunting a task they closed by mistake does not know which
|
||||
view they closed it in — which is true, and is also a rare errand next to
|
||||
four entries that are read every day. It lives under Settings → Data, with
|
||||
the trash, where the other "where did that go" questions are answered. -->
|
||||
{#if app.inbox}
|
||||
{@render projectRow(app.inbox, false)}
|
||||
{/if}
|
||||
<!-- The fixed views, in the order this person put them.
|
||||
Drawn from a list rather than written out, because they can be reordered
|
||||
now and a hand-written run cannot be. Unknown keys are dropped and new
|
||||
ones appended, so adding a view later does not strand anybody's order. -->
|
||||
{#each orderedNav as item (item.key)}
|
||||
{#if item.key === 'inbox'}
|
||||
{#if app.inbox}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="navrow"
|
||||
class:dragging={draggingNav === item.key}
|
||||
class:over={overNav === item.key}
|
||||
ondragover={(e) => onNavDragOver(e, item.key)}
|
||||
ondragleave={() => (overNav = null)}
|
||||
ondrop={(e) => onNavDrop(e, item.key)}
|
||||
>
|
||||
{@render grip(item.key)}
|
||||
{@render projectRow(app.inbox, false)}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="navrow"
|
||||
class:dragging={draggingNav === item.key}
|
||||
class:over={overNav === item.key}
|
||||
ondragover={(e) => onNavDragOver(e, item.key)}
|
||||
ondragleave={() => (overNav = null)}
|
||||
ondrop={(e) => onNavDrop(e, item.key)}
|
||||
>
|
||||
{@render grip(item.key)}
|
||||
<!-- I dag also takes a task dropped on it — the most-made rescheduling
|
||||
there is, and the sidebar is where the pointer already is. The
|
||||
handlers were lost when this run became a list; the test caught
|
||||
it, which is the second time that drop has earned its test. -->
|
||||
<a
|
||||
href={item.href}
|
||||
class:active={current === item.href}
|
||||
class:receiving={item.key === 'today' && overToday}
|
||||
onclick={onnavigate}
|
||||
ondragover={(e) => {
|
||||
if (item.key !== 'today' || !carries(e, TASK)) return;
|
||||
accept(e);
|
||||
overToday = true;
|
||||
}}
|
||||
ondragleave={() => (overToday = false)}
|
||||
ondrop={(e) => item.key === 'today' && onDropOnToday(e)}
|
||||
>
|
||||
<span class="dot" class:today={item.key === 'today'} aria-hidden="true"></span>
|
||||
{t(item.label)}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Notes stand apart, with a rule above them.
|
||||
@@ -395,6 +476,21 @@
|
||||
three copies of that would be three chances to fix a bug in two of them.
|
||||
Only your own reorder: `sort_order` is a column on the project, so a shared
|
||||
one stays where its owner put it. -->
|
||||
<!-- A handle rather than the whole row.
|
||||
Making the row draggable made everything inside it draggable too, and the
|
||||
sidebar is already a drop target for tasks — a task dragged onto I dag
|
||||
stopped landing, because the row had claimed the gesture. A grip claims
|
||||
nothing until it is grabbed. -->
|
||||
{#snippet grip(key)}
|
||||
<span
|
||||
class="grip"
|
||||
draggable="true"
|
||||
ondragstart={(e) => onNavDragStart(e, key)}
|
||||
ondragend={clearNavDrag}
|
||||
aria-hidden="true">⠿</span
|
||||
>
|
||||
{/snippet}
|
||||
|
||||
{#snippet projectRow(project, sortable)}
|
||||
<a
|
||||
href="/projekt/{project.id}"
|
||||
@@ -871,6 +967,53 @@
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.navrow {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Takes no room until it is wanted: it sits in the padding the row already has,
|
||||
so nothing moves when it appears. */
|
||||
.grip {
|
||||
position: absolute;
|
||||
left: -2px;
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
color: var(--ink-faint);
|
||||
cursor: grab;
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.navrow:hover .grip {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.navrow > :global(a) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.navrow.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* A line where it will land, not a box around what it will land on: the first
|
||||
reads as a position and the second as a target. */
|
||||
.navrow.over::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: var(--s2);
|
||||
right: var(--s2);
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* A rule and a little air, which is the whole of "separate". Notes are the other
|
||||
half of the program, not a fifth way of looking at the same tasks. */
|
||||
.apart {
|
||||
|
||||
@@ -562,6 +562,31 @@ class AppState {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The order of the fixed views, as keys.
|
||||
*
|
||||
* Unknown keys are dropped and missing ones appended, so the stored order never
|
||||
* has to be migrated when a view is added or taken away — which has already
|
||||
* happened once, when Færdige left the sidebar.
|
||||
*/
|
||||
navOrder(keys) {
|
||||
const saved = this.user?.nav_order ?? [];
|
||||
const known = new Set(keys);
|
||||
const ordered = saved.filter((k) => known.has(k));
|
||||
return [...ordered, ...keys.filter((k) => !ordered.includes(k))];
|
||||
}
|
||||
|
||||
async setNavOrder(order) {
|
||||
const before = this.user?.nav_order ?? [];
|
||||
this.user = { ...this.user, nav_order: order };
|
||||
try {
|
||||
await api.setNavOrder(order);
|
||||
} catch (e) {
|
||||
this.user = { ...this.user, nav_order: before };
|
||||
this.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
sectionCollapsed(key) {
|
||||
return (this.user?.sidebar_collapsed ?? []).includes(key);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user