feat(projects): drag the sidebar into the order you want

Projects have carried sort_order since the first migration and nothing
ever set it, so the sidebar was ordered by creation date for good.

POST /projects/reorder takes the whole list rather than one move. A
person has a handful of projects, so sending the order you want is
simpler to reason about than midpoints between neighbours — and it cannot
run out of precision, which is the failure the task ordering has to
respace a section to recover from. It also cannot land half-applied the
way a sequence of individual moves can.

Projects you do not own are skipped rather than refused. sort_order is a
column on the project, not a preference per viewer, so a shared project
sits where its owner put it and a member rearranging their own sidebar
does not rearrange somebody else's. The sidebar only makes your own
projects draggable, so that rule never shows up as a row that springs
back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-17 15:59:31 +02:00
committed by Kristian Wind
co-authored by Claude Opus 5
parent 3f4af6c0ab
commit c4e9e1519b
8 changed files with 280 additions and 1 deletions
+29
View File
@@ -756,6 +756,35 @@ paths:
"201": { $ref: "#/components/responses/Project" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/projects/reorder:
post:
tags: [projects]
summary: Set the order projects appear in.
description: |
The whole list at once rather than one move at a time. A person has a
handful of projects, so sending the order you want is simpler than
midpoints and cannot land half-applied.
Projects you do not own are skipped: `sort_order` is a column on the
project rather than a preference per viewer, so a shared one stays where
its owner put it.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [ids]
properties:
ids:
type: array
maxItems: 500
items: { type: string }
description: Project ids, top to bottom.
responses:
"204": { description: Reordered. }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/projects/{projectID}:
parameters:
- { name: projectID, in: path, required: true, schema: { type: string } }
+33
View File
@@ -69,6 +69,39 @@ type projectRequest struct {
Archived *bool `json:"archived"`
}
type reorderProjectsRequest struct {
// IDs in the order they should appear, top to bottom.
IDs []string `json:"ids"`
}
// handleReorderProjects sets the sidebar order.
//
// The whole list rather than one move: a person has a handful of projects, and
// sending the order you want is both simpler to reason about and impossible to
// get into the half-ordered state a sequence of individual moves can produce if
// one of them fails.
//
// Projects the caller does not own are skipped — sort_order is a column on the
// project, so a shared one sits where its owner put it.
func (s *Server) handleReorderProjects(w http.ResponseWriter, r *http.Request) {
var req reorderProjectsRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
// A ceiling rather than a guess at what is reasonable: this is a bound on
// the transaction, not an opinion about how many projects somebody may have.
if len(req.IDs) > 500 {
writeFieldErrors(w, map[string]string{"ids": "at most 500 at a time"})
return
}
if err := s.db.ReorderProjects(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil {
s.internal(w, "reorder projects", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
var req projectRequest
if err := decodeJSON(w, r, &req); err != nil {
+72
View File
@@ -81,3 +81,75 @@ func TestTheTrashIsPerOwner(t *testing.T) {
t.Errorf("status %d, want 404", resp.StatusCode)
}
}
func TestReorderProjectsWritesTheGivenOrder(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
var ids []string
for _, name := range []string{"Alfa", "Bravo", "Charlie"} {
_, body := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": name})
ids = append(ids, body["id"].(string))
}
// Back to front.
reversed := []string{ids[2], ids[1], ids[0]}
resp, _ := ts.do(t, "POST", "/api/v1/projects/reorder", map[string]any{"ids": reversed})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("reorder: status %d", resp.StatusCode)
}
_, list := ts.do(t, "GET", "/api/v1/projects", nil)
projects, _ := list["projects"].([]any)
var got []string
for _, raw := range projects {
p := raw.(map[string]any)
// The Inbox sorts ahead of everything by its own rule; it is not part of
// what was reordered.
if p["is_inbox"] == true {
continue
}
got = append(got, p["id"].(string))
}
if len(got) != 3 || got[0] != reversed[0] || got[1] != reversed[1] || got[2] != reversed[2] {
t.Errorf("order = %v, want %v", got, reversed)
}
}
// sort_order is a column on the project, not a per-viewer preference, so
// reordering must not rearrange a project somebody else owns.
func TestReorderProjectsLeavesSomebodyElsesAlone(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "anden@example.dk", "Anden")
_, theirs := other.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Deres"})
theirID := theirs["id"].(string)
_, before := other.do(t, "GET", "/api/v1/projects", nil)
var wanted float64
for _, raw := range before["projects"].([]any) {
if p := raw.(map[string]any); p["id"] == theirID {
wanted = p["sort_order"].(float64)
}
}
// Ask, as somebody else, to put their project first.
resp, _ := ts.do(t, "POST", "/api/v1/projects/reorder", map[string]any{
"ids": []string{theirID},
})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("reorder: status %d", resp.StatusCode)
}
_, after := other.do(t, "GET", "/api/v1/projects", nil)
for _, raw := range after["projects"].([]any) {
if p := raw.(map[string]any); p["id"] == theirID {
if p["sort_order"] != wanted {
t.Errorf("sort_order changed from %v to %v — somebody else moved it",
wanted, p["sort_order"])
}
}
}
}
+2
View File
@@ -259,6 +259,8 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Route("/projects", func(r chi.Router) {
r.Get("/", s.handleListProjects)
r.Post("/", s.handleCreateProject)
// Above /{projectID}, or chi reads "reorder" as an id.
r.Post("/reorder", s.handleReorderProjects)
r.Route("/{projectID}", func(r chi.Router) {
// Read access is the floor for the whole subtree; the writes
+27
View File
@@ -556,3 +556,30 @@ func joinComma(parts []string) string {
}
return out
}
// ReorderProjects writes the given order, and only for projects the user owns.
//
// The whole list at once, rather than the midpoint-between-neighbours dance the
// tasks do. A person has a handful of projects, not a thousand, so writing
// 0, 1, 2 … is one small transaction that cannot run out of precision — the
// failure the task ordering has to respace a section to recover from.
//
// Scoped to ownership because sort_order lives on the project row rather than
// per viewer: a shared project sits where its owner put it, and a member
// reordering their sidebar must not rearrange somebody else's. Ids that are not
// the caller's are skipped rather than refused, so one shared project in the
// list does not fail the whole drag.
func (db *DB) ReorderProjects(ctx context.Context, userID string, ids []string) error {
return db.Tx(ctx, func(tx *sql.Tx) error {
now := time.Now().Unix()
for i, id := range ids {
if _, err := tx.ExecContext(ctx,
`UPDATE projects SET sort_order = ?, updated_at = ?
WHERE id = ? AND owner_id = ? AND deleted_at IS NULL`,
float64(i), now, id, userID); err != nil {
return err
}
}
return nil
})
}
+1
View File
@@ -176,6 +176,7 @@ export const api = {
createProject: (data) => post('/projects', data),
updateProject: (id, data) => patch(`/projects/${id}`, data),
deleteProject: (id) => del(`/projects/${id}`),
reorderProjects: (ids) => post('/projects/reorder', { ids }),
listTrashedProjects: () => get('/trash/projects'),
restoreProject: (id) => post(`/trash/projects/${id}/restore`),
+93 -1
View File
@@ -33,8 +33,54 @@
});
let shared = $derived(app.projects.filter((p) => !p.is_inbox && p.shared));
let own = $derived(app.projects.filter((p) => !p.is_inbox && !p.shared));
// Sorted here rather than relying on the order the server sent, so a drag
// settles the moment it is dropped instead of after the round trip.
let own = $derived(
app.projects
.filter((p) => !p.is_inbox && !p.shared)
.sort((a, b) => a.sort_order - b.sort_order)
);
let current = $derived($page.url.pathname);
// --- reordering ---------------------------------------------------------------
let draggingId = $state(null);
let overId = $state(null);
let overBelow = $state(false);
function onDragStart(event, project) {
draggingId = project.id;
event.dataTransfer.effectAllowed = 'move';
// Firefox refuses to start a drag unless something is set on the transfer.
event.dataTransfer.setData('text/plain', project.id);
}
function onDragOver(event, project) {
if (!draggingId || draggingId === project.id) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
const box = event.currentTarget.getBoundingClientRect();
overId = project.id;
overBelow = event.clientY > box.top + box.height / 2;
}
async function onDrop(event, target) {
event.preventDefault();
const id = draggingId;
const below = overBelow;
draggingId = null;
overId = null;
if (!id || id === target.id) return;
const without = own.filter((p) => p.id !== id);
const at = without.findIndex((p) => p.id === target.id);
if (at < 0) return;
const ordered = [...without];
ordered.splice(below ? at + 1 : at, 0, own.find((p) => p.id === id));
await app.reorderProjects(ordered.map((p) => p.id));
}
</script>
<nav class="sidebar" aria-label="Hovedmenu">
@@ -90,8 +136,21 @@
{#each own as project (project.id)}
<a
href="/projekt/{project.id}"
class="sortable"
class:active={current === `/projekt/${project.id}`}
class:dragging={draggingId === project.id}
class:drop-above={overId === project.id && !overBelow}
class:drop-below={overId === project.id && overBelow}
onclick={onnavigate}
draggable="true"
ondragstart={(e) => onDragStart(e, project)}
ondragend={() => {
draggingId = null;
overId = null;
}}
ondragover={(e) => onDragOver(e, project)}
ondragleave={() => (overId = null)}
ondrop={(e) => onDrop(e, project)}
>
<span class="dot" aria-hidden="true"></span>
{project.name}
@@ -280,6 +339,39 @@
font-weight: 500;
}
/* Only your own projects reorder. A shared one sits where its owner put it —
sort_order is a column on the project, not a preference per viewer. */
a.sortable {
position: relative;
}
a.sortable.dragging {
opacity: 0.4;
}
/* A line in the gap rather than a highlighted row: the gap is the target. */
a.sortable::before {
content: '';
position: absolute;
left: var(--s2);
right: var(--s2);
height: 2px;
background: var(--accent);
opacity: 0;
pointer-events: none;
transition: opacity var(--fast) var(--ease);
}
a.sortable.drop-above::before {
top: -1px;
opacity: 1;
}
a.sortable.drop-below::before {
bottom: -1px;
opacity: 1;
}
.dot {
width: 6px;
height: 6px;
+23
View File
@@ -246,6 +246,29 @@ class AppState {
this.projects = projects;
}
/**
* Puts the projects in the given order.
*
* The whole list rather than one moved item: it is a handful of rows, and
* sending the order you want cannot land in the half-applied state a sequence
* of individual moves can. `sort_order` is set here as well as on the server,
* so the sidebar settles immediately rather than after a round trip.
*/
async reorderProjects(ids) {
const previous = this.projects;
const rank = new Map(ids.map((id, i) => [id, i]));
this.projects = this.projects.map((p) =>
rank.has(p.id) ? { ...p, sort_order: rank.get(p.id) } : p
);
try {
await api.reorderProjects(ids);
} catch (e) {
this.projects = previous;
this.toast(humanMessage(e));
}
}
// --- toasts -------------------------------------------------------------------
toast(message) {