feat(projects): sektioner kan trækkes op og ned, og båndet står ikke længere skævt

Rækkefølgen af sektioner *er* planen — "Planlægning" står før "I gang", fordi det
er den vej arbejdet går — og indtil nu kunne den kun laves om ved at slette en
sektion og lave den igen, hvilket smider dens opgaver ud i det usektionerede
område på vejen.

Hele overskriften er håndtaget. En sektionsoverskrift indeholder ikke noget, der
selv bliver trukket, så der er ingen geste at stjæle — hvilket er grunden til, at
sidebjælkens rækker skulle have et greb, og grunden til at disse ikke skal. Ikke
draggable mens den omdøbes: et draggable element lader ikke markøren vælge tekst
i det felt, det indeholder.

Serversiden er hele listen på én gang som projekter og grupper, og af samme
grund: et projekt har en håndfuld sektioner, så 0, 1, 2 … er én lille transaktion
uden præcision at løbe tør for, og en liste kan ikke ende halvt anvendt.
`ReorderSections` er scopet til projektet og ikke til en ejer — en sektion har
ingen ejer, den hører til et projekt, og om du må rykke rundt er et spørgsmål om
det projekt, som handleren allerede har stillet. Id'er fra et andet projekt
springes over af WHERE-klausulen; der er en prøve på præcis det, fordi ingenting
ellers kontrollerer id'erne enkeltvis.

Båndet stod fire pixels skævt. Det var trukket ud med --s2 og polstret med --s3 —
samme idé gjort med to forskellige tal — så det blev otte pixels bredere end
listen i hver side, og teksten landede fire pixels uden for den kolonne, rækkerne
holder. Til venstre var det usynligt. Til højre var det Omdøb og Slet, der stod
fire pixels forbi alt over og under sig, hvilket er præcis langt nok til at ligne
en fejl og ikke langt nok til at se hvorfor. En række er `.row` med
`padding: var(--s2)` inde i en boks uden, så båndet har nu samme polstring og
ingen margen.

Board-visningen er ikke rørt: der er kolonnerne vandrette, og det er en anden
geste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-20 14:56:26 +02:00
co-authored by Claude Opus 5
parent 98bfa216d6
commit e46aaa462a
9 changed files with 373 additions and 7 deletions
+28
View File
@@ -356,6 +356,34 @@ func (s *Server) handleCreateSection(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, sectionJSON{sec.ID, sec.ProjectID, sec.Name, sec.SortOrder})
}
// handleReorderSections sets the order of the sections inside one project.
//
// Under /projects/{projectID}/ rather than beside the other section routes, which
// hang off a section id: the thing being changed here is the project's list, not
// any one section in it. That also puts it behind the same project permission
// check as everything else in this subtree, instead of resolving a section to its
// project one id at a time and hoping they all named the same one.
func (s *Server) handleReorderSections(w http.ResponseWriter, r *http.Request) {
var req reorderProjectsRequest
if err := decodeJSON(w, r, &req); err != nil {
return
}
// The same ceiling as the projects list, and a bound on the transaction rather
// than an opinion about how many sections a project may have.
if len(req.IDs) > 500 {
writeFieldErrors(w, map[string]string{"ids": "at most 500 at a time"})
return
}
projectID := chi.URLParam(r, "projectID")
if err := s.db.ReorderSections(r.Context(), projectID, req.IDs); err != nil {
s.internal(w, r, "reorder sections", err)
return
}
s.activity(r, projectID, "", "section.updated", nil)
w.WriteHeader(http.StatusNoContent)
}
// sectionAccess resolves a section to its project and checks the caller may edit
// it. Sections have no permissions of their own — they belong to a project, and
// that is where access is decided.
+65
View File
@@ -327,6 +327,71 @@ func TestAProjectCanHaveSeveralSections(t *testing.T) {
}
}
// Sections can be dragged into another order, and the order is the project's.
//
// The list is what a project's plan *is* — "Planlægning" before "I gang" is the
// order the work happens in — and until this endpoint existed the only way to
// change it was to delete a section and make it again, which moves its tasks to
// the unsectioned area on the way past.
func TestSectionsCanBeReordered(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Ombygning"})
id := project["id"].(string)
ids := map[string]string{}
names := []string{"Planlægning", "I gang", "Til gennemsyn"}
for _, name := range names {
_, body := ts.do(t, "POST", "/api/v1/projects/"+id+"/sections", map[string]any{"name": name})
ids[name] = body["id"].(string)
}
want := []string{"Til gennemsyn", "Planlægning", "I gang"}
order := make([]string, len(want))
for i, name := range want {
order[i] = ids[name]
}
resp, body := ts.do(t, "POST", "/api/v1/projects/"+id+"/sections/reorder",
map[string]any{"ids": order})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("reorder: status %d, body %v", resp.StatusCode, body)
}
_, listed := ts.do(t, "GET", "/api/v1/projects/"+id+"/sections", nil)
got, _ := listed["sections"].([]any)
if len(got) != len(want) {
t.Fatalf("the project has %d sections, want %d", len(got), len(want))
}
for i, raw := range got {
if name := raw.(map[string]any)["name"]; name != want[i] {
t.Errorf("section %d is %v, want %q", i, name, want[i])
}
}
// A section belonging to somebody else's project is not moved by naming it
// here. The ids in the body are not checked one by one — the WHERE clause is
// what refuses them — so this is the assertion that it actually does.
_, other := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Sommerhus"})
otherID := other["id"].(string)
_, foreign := ts.do(t, "POST", "/api/v1/projects/"+otherID+"/sections",
map[string]any{"name": "Havearbejde"})
foreignID := foreign["id"].(string)
resp, _ = ts.do(t, "POST", "/api/v1/projects/"+id+"/sections/reorder",
map[string]any{"ids": []string{foreignID}})
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("reorder with a foreign id: status %d", resp.StatusCode)
}
_, stillThere := ts.do(t, "GET", "/api/v1/projects/"+otherID+"/sections", nil)
elsewhere, _ := stillThere["sections"].([]any)
if len(elsewhere) != 1 || elsewhere[0].(map[string]any)["name"] != "Havearbejde" {
t.Errorf("the other project's section did not survive: %v", elsewhere)
}
}
// A project can be created straight into a group.
//
// `group_id` was declared on the request type, documented on it, and applied by
+1
View File
@@ -426,6 +426,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Group(func(r chi.Router) {
r.Use(s.requireProject(store.RoleEditor))
r.Post("/sections", s.handleCreateSection)
r.Post("/sections/reorder", s.handleReorderSections)
})
r.Group(func(r chi.Router) {
+29
View File
@@ -646,6 +646,35 @@ func (db *DB) ReorderProjects(ctx context.Context, userID string, ids []string)
})
}
// ReorderSections writes the order of one project's sections, top to bottom.
//
// The whole list at once, the same as ReorderProjects and for the same reasons: a
// project has a handful of sections, so writing 0, 1, 2 … is one small
// transaction that cannot run out of precision, and a list is impossible to get
// into the half-ordered state a sequence of individual moves leaves behind when
// one of them fails.
//
// Scoped to the project rather than to a user. A section has no owner of its own —
// it belongs to a project, and whether the caller may rearrange it is a question
// about that project, which the handler has already asked. Ids belonging to some
// other project are skipped by the WHERE clause rather than refused, so a stale
// list from a client that has just moved a section elsewhere cannot rearrange a
// project the caller was never looking at.
func (db *DB) ReorderSections(ctx context.Context, projectID 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 sections SET sort_order = ?, updated_at = ?
WHERE id = ? AND project_id = ? AND deleted_at IS NULL`,
float64(i), now, id, projectID); err != nil {
return err
}
}
return nil
})
}
// SectionByName resolves a "/name" from quick add within one project.
//
// Case-insensitive, and scoped to the project the task is landing in — a section