feat: project groups, a month on Kommende, and dragging things where they go
The three the handoff named as next. They are one commit because they are one mechanism: all three end in something being dragged, and a drop target cannot tell what is coming without the module they now share. Groups are foldable headings over the projects in the sidebar. A table rather than a `group_name` column on the project, because a group has to be renamable in one write, has to survive being emptied, and has to remember whether it is folded — none of which a string repeated on every project row can do. Folded state lives on the row, not in localStorage. The sidebar's *width* is a property of the screen you are sitting at, which is why that one is per browser; a folded group is a statement about the group — "this is the part of my work I am not in right now" — and it is true on the laptop and on the desktop. There is nobody else's answer to disagree with either: a group belongs to exactly one person. A shared project cannot be filed. `group_id` is a column on the project row, so a member grouping somebody else's project would move it in the owner's sidebar too — the same reason `sort_order` is owner-scoped. The listing reports the column only to the owner, so a viewer is never told where somebody else keeps it. Deleting a group takes the heading and leaves the projects, and the sidebar decides where a project renders by whether its group still exists rather than by the id alone. A group deleted in another tab arrives as one event and not one per project, so for a moment the projects still name a heading that is no longer drawn — filing by the raw id would drop them out of the sidebar entirely. Kommende gains the month grid the project pages already had, behind a Liste/Kalender toggle. The two views ask for different things — seven days from today against six whole weeks around an arbitrary month — so each loads its own, and the grid reports which dates it is showing rather than having the page guess. Which view is remembered in localStorage: a month of seven columns needs room, and the answer on a phone is not the answer on a wide monitor. And a task can now be dropped on a project in the sidebar, on a day in Kommende, or on a cell in any month grid. Onto a project it is a *move*, not a field: `sort_order` belongs to the project, so it has to find a place among that project's tasks and leave its old section behind. Each drag now carries its own MIME type. `getData` is deliberately unreadable while a drag is in the air — the browser hands the payload over on drop and not before, so a page cannot read what is being dragged across it — and `dataTransfer.types` is all a target has to decide with. Deciding is the point: calling preventDefault on a drag you cannot accept is what turns "no" into a silent, wrong drop. The sidebar takes a project and a task on the same row and has to tell them apart. Two things fell out of building it. The month grid has always had one cell narrower than the other six: `.today` styled the "I dag" button in the header, and the day cell for today wears the same class, so `margin-left: auto` was shrinking a grid item to its content. It is scoped to the header now. And a project page listed whatever was in the store rather than what belonged to that project, which did not matter until a task could leave a project while you were looking at it. Covered by eight new API tests and two new smoke tests, and the smoke tests drive the drags for real — the type check they exercise exists only while a drag is in flight, so nothing else can reach it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2300f1dc3b
commit
c14559ed0c
@@ -0,0 +1,145 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// Groups are the foldable headings over the projects in the sidebar.
|
||||
//
|
||||
// They sit outside /projects rather than under it: a group is not a project and
|
||||
// has no members, no tasks and no roles. Every route here is scoped to the caller
|
||||
// in the query itself, so there is no permission middleware to forget — a group
|
||||
// that is not yours is not found.
|
||||
|
||||
type projectGroupJSON struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Collapsed bool `json:"collapsed"`
|
||||
SortOrder float64 `json:"sort_order"`
|
||||
}
|
||||
|
||||
func toProjectGroupJSON(g store.ProjectGroup) projectGroupJSON {
|
||||
return projectGroupJSON{ID: g.ID, Name: g.Name, Collapsed: g.Collapsed, SortOrder: g.SortOrder}
|
||||
}
|
||||
|
||||
func (s *Server) handleListProjectGroups(w http.ResponseWriter, r *http.Request) {
|
||||
groups, err := s.db.ListProjectGroups(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, "list project groups", err)
|
||||
return
|
||||
}
|
||||
out := make([]projectGroupJSON, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
out = append(out, toProjectGroupJSON(g))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"groups": out})
|
||||
}
|
||||
|
||||
type projectGroupRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Collapsed *bool `json:"collapsed"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateProjectGroup(w http.ResponseWriter, r *http.Request) {
|
||||
var req projectGroupRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
name := ""
|
||||
if req.Name != nil {
|
||||
name = strings.TrimSpace(*req.Name)
|
||||
}
|
||||
if fields := validateGroupName(name); len(fields) > 0 {
|
||||
writeFieldErrors(w, fields)
|
||||
return
|
||||
}
|
||||
|
||||
g := &store.ProjectGroup{Name: name, OwnerID: userFrom(r.Context()).ID}
|
||||
if req.Collapsed != nil {
|
||||
g.Collapsed = *req.Collapsed
|
||||
}
|
||||
if err := s.db.CreateProjectGroup(r.Context(), g); err != nil {
|
||||
s.internal(w, "create project group", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "project_group.created", toProjectGroupJSON(*g))
|
||||
writeJSON(w, http.StatusCreated, toProjectGroupJSON(*g))
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateProjectGroup(w http.ResponseWriter, r *http.Request) {
|
||||
var req projectGroupRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
groupID := chi.URLParam(r, "groupID")
|
||||
userID := userFrom(r.Context()).ID
|
||||
|
||||
if req.Name != nil {
|
||||
name := strings.TrimSpace(*req.Name)
|
||||
if fields := validateGroupName(name); len(fields) > 0 {
|
||||
writeFieldErrors(w, fields)
|
||||
return
|
||||
}
|
||||
req.Name = &name
|
||||
}
|
||||
|
||||
if err := s.db.UpdateProjectGroup(r.Context(), groupID, userID, store.ProjectGroupUpdate{
|
||||
Name: req.Name, Collapsed: req.Collapsed,
|
||||
}); err != nil {
|
||||
s.storeError(w, "update project group", err)
|
||||
return
|
||||
}
|
||||
g, err := s.db.GetProjectGroup(r.Context(), groupID, userID)
|
||||
if err != nil {
|
||||
s.storeError(w, "get project group", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "project_group.updated", toProjectGroupJSON(*g))
|
||||
writeJSON(w, http.StatusOK, toProjectGroupJSON(*g))
|
||||
}
|
||||
|
||||
// handleDeleteProjectGroup removes the heading. The projects under it come back
|
||||
// out as ungrouped, which is why this is not behind a confirmation about losing
|
||||
// work: there is none to lose.
|
||||
func (s *Server) handleDeleteProjectGroup(w http.ResponseWriter, r *http.Request) {
|
||||
groupID := chi.URLParam(r, "groupID")
|
||||
if err := s.db.DeleteProjectGroup(r.Context(), groupID, userFrom(r.Context()).ID); err != nil {
|
||||
s.storeError(w, "delete project group", err)
|
||||
return
|
||||
}
|
||||
s.publishToOwner(r, "project_group.deleted", map[string]string{"id": groupID})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleReorderProjectGroups takes the whole list, like the projects do — see
|
||||
// handleReorderProjects for why.
|
||||
func (s *Server) handleReorderProjectGroups(w http.ResponseWriter, r *http.Request) {
|
||||
var req reorderProjectsRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if len(req.IDs) > 500 {
|
||||
writeFieldErrors(w, map[string]string{"ids": "at most 500 at a time"})
|
||||
return
|
||||
}
|
||||
if err := s.db.ReorderProjectGroups(r.Context(), userFrom(r.Context()).ID, req.IDs); err != nil {
|
||||
s.internal(w, "reorder project groups", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func validateGroupName(name string) map[string]string {
|
||||
switch {
|
||||
case name == "":
|
||||
return map[string]string{"name": "required"}
|
||||
case len([]rune(name)) > 200:
|
||||
return map[string]string{"name": "must be 200 characters or fewer"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAGroupHoldsProjectsAndSurvivesBeingEmptied(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create group: %d %v", resp.StatusCode, group)
|
||||
}
|
||||
groupID := group["id"].(string)
|
||||
if group["collapsed"] != false {
|
||||
t.Errorf("a new group should be open: %v", group)
|
||||
}
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab"})
|
||||
projectID := project["id"].(string)
|
||||
|
||||
resp, filed := ts.do(t, "PATCH", "/api/v1/projects/"+projectID,
|
||||
map[string]any{"group_id": groupID})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("file project: %d %v", resp.StatusCode, filed)
|
||||
}
|
||||
if filed["group_id"] != groupID {
|
||||
t.Errorf("group_id = %v, want %s", filed["group_id"], groupID)
|
||||
}
|
||||
|
||||
// The listing is what the sidebar reads, so the filing has to survive it.
|
||||
if got := groupOf(t, ts, projectID); got != groupID {
|
||||
t.Errorf("group_id in the list = %q, want %s", got, groupID)
|
||||
}
|
||||
|
||||
// An empty group is still a group. This is the whole reason it is a table and
|
||||
// not a string repeated on every project row.
|
||||
resp, _ = ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"group_id": ""})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("ungroup: status %d", resp.StatusCode)
|
||||
}
|
||||
if got := groupOf(t, ts, projectID); got != "" {
|
||||
t.Errorf("group_id = %q after ungrouping, want empty", got)
|
||||
}
|
||||
|
||||
_, listed := ts.do(t, "GET", "/api/v1/project-groups", nil)
|
||||
if groups, _ := listed["groups"].([]any); len(groups) != 1 {
|
||||
t.Fatalf("the emptied group is gone: %v", listed)
|
||||
}
|
||||
}
|
||||
|
||||
// An omitted group_id must not clear the field: that is the difference between a
|
||||
// PATCH and a PUT, and renaming a project would otherwise tip it out of its group.
|
||||
func TestRenamingAProjectLeavesItInItsGroup(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
||||
groupID := group["id"].(string)
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab"})
|
||||
projectID := project["id"].(string)
|
||||
ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"group_id": groupID})
|
||||
|
||||
_, renamed := ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"name": "Bogholderi"})
|
||||
if renamed["group_id"] != groupID {
|
||||
t.Errorf("group_id = %v after a rename, want %s", renamed["group_id"], groupID)
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting the heading is not a request to delete the work filed under it.
|
||||
func TestDeletingAGroupKeepsItsProjects(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
||||
groupID := group["id"].(string)
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab"})
|
||||
projectID := project["id"].(string)
|
||||
ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"group_id": groupID})
|
||||
|
||||
resp, _ := ts.do(t, "DELETE", "/api/v1/project-groups/"+groupID, nil)
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("delete group: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, projects := ts.do(t, "GET", "/api/v1/projects", nil)
|
||||
found := false
|
||||
for _, raw := range projects["projects"].([]any) {
|
||||
p := raw.(map[string]any)
|
||||
if p["id"] != projectID {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if p["group_id"] != nil && p["group_id"] != "" {
|
||||
t.Errorf("the project still points at a group that is gone: %v", p["group_id"])
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("the project went with the group it was filed under")
|
||||
}
|
||||
}
|
||||
|
||||
// Folding is stored on the account rather than in the browser, so it has to come
|
||||
// back from the server.
|
||||
func TestFoldingAGroupIsRemembered(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
||||
groupID := group["id"].(string)
|
||||
|
||||
resp, folded := ts.do(t, "PATCH", "/api/v1/project-groups/"+groupID,
|
||||
map[string]any{"collapsed": true})
|
||||
if resp.StatusCode != http.StatusOK || folded["collapsed"] != true {
|
||||
t.Fatalf("fold: %d %v", resp.StatusCode, folded)
|
||||
}
|
||||
|
||||
_, listed := ts.do(t, "GET", "/api/v1/project-groups", nil)
|
||||
first := listed["groups"].([]any)[0].(map[string]any)
|
||||
if first["collapsed"] != true {
|
||||
t.Errorf("collapsed = %v after re-reading, want true", first["collapsed"])
|
||||
}
|
||||
}
|
||||
|
||||
// Groups are per person: somebody else's is not yours to see, rename or delete,
|
||||
// and a group that is not yours is not found rather than forbidden.
|
||||
func TestGroupsArePerPerson(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
||||
groupID := group["id"].(string)
|
||||
|
||||
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||
|
||||
_, listed := other.do(t, "GET", "/api/v1/project-groups", nil)
|
||||
if groups, _ := listed["groups"].([]any); len(groups) != 0 {
|
||||
t.Errorf("somebody else's groups are visible: %v", groups)
|
||||
}
|
||||
if resp, _ := other.do(t, "PATCH", "/api/v1/project-groups/"+groupID,
|
||||
map[string]any{"name": "Mit"}); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("renaming somebody else's group: status %d, want 404", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := other.do(t, "DELETE", "/api/v1/project-groups/"+groupID, nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("deleting somebody else's group: status %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// A project cannot be filed into a group that is not the caller's, even though
|
||||
// both the project and the group exist and both requests would be well-formed.
|
||||
func TestAProjectCannotJoinSomebodyElsesGroup(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
other := ts.newUser(t, "anden@example.dk", "Anden")
|
||||
_, group := other.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Deres"})
|
||||
groupID := group["id"].(string)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
|
||||
projectID := project["id"].(string)
|
||||
|
||||
resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"group_id": groupID})
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("status %d, want 404 — a group you cannot see is not one you can file into",
|
||||
resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupsReorderAsAWholeList(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
ids := make([]string, 3)
|
||||
for i, name := range []string{"En", "To", "Tre"} {
|
||||
_, g := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": name})
|
||||
ids[i] = g["id"].(string)
|
||||
}
|
||||
|
||||
reversed := []string{ids[2], ids[1], ids[0]}
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/project-groups/reorder", map[string]any{"ids": reversed})
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("reorder: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
_, listed := ts.do(t, "GET", "/api/v1/project-groups", nil)
|
||||
got := listed["groups"].([]any)
|
||||
for i, want := range reversed {
|
||||
if got[i].(map[string]any)["id"] != want {
|
||||
t.Fatalf("order = %v, want %v", got, reversed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A name is required, the same as a project's. An unnamed heading is a heading
|
||||
// nobody can tell apart from the next one.
|
||||
func TestAGroupNeedsAName(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": " "})
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("status %d, want 422 — body %v", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// groupOf reads a project's group back out of the listing the sidebar uses.
|
||||
func groupOf(t *testing.T, ts *testServer, projectID string) string {
|
||||
t.Helper()
|
||||
_, body := ts.do(t, "GET", "/api/v1/projects", nil)
|
||||
for _, raw := range body["projects"].([]any) {
|
||||
p := raw.(map[string]any)
|
||||
if p["id"] == projectID {
|
||||
id, _ := p["group_id"].(string)
|
||||
return id
|
||||
}
|
||||
}
|
||||
t.Fatalf("project %s is not in the listing", projectID)
|
||||
return ""
|
||||
}
|
||||
@@ -24,6 +24,10 @@ type projectJSON struct {
|
||||
Role string `json:"role"`
|
||||
MemberCount int `json:"member_count"`
|
||||
Shared bool `json:"shared"`
|
||||
// GroupID is the sidebar group the asking user has filed it under, empty for
|
||||
// none. Always empty on a project shared with them: grouping is a column on
|
||||
// the project row, so it is the owner's filing and nobody else's.
|
||||
GroupID string `json:"group_id,omitempty"`
|
||||
}
|
||||
|
||||
func toProjectJSON(p store.Project) projectJSON {
|
||||
@@ -31,7 +35,7 @@ func toProjectJSON(p store.Project) projectJSON {
|
||||
ID: p.ID, Name: p.Name, Color: p.Color, Icon: p.Icon, ViewMode: p.ViewMode,
|
||||
OwnerID: p.OwnerID, IsInbox: p.IsInbox, Archived: p.Archived,
|
||||
SortOrder: p.SortOrder, Role: string(p.Role), MemberCount: p.MemberCount,
|
||||
Shared: p.MemberCount > 1,
|
||||
Shared: p.MemberCount > 1, GroupID: p.GroupID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +71,11 @@ type projectRequest struct {
|
||||
Icon *string `json:"icon"`
|
||||
ViewMode *string `json:"view_mode"`
|
||||
Archived *bool `json:"archived"`
|
||||
// GroupID files the project under a sidebar group; "" takes it out of one.
|
||||
// The empty string rather than a JSON null, because null and absent are the
|
||||
// same nil pointer once decoded — and telling "put it nowhere" apart from
|
||||
// "I did not mention it" is the whole point of a PATCH.
|
||||
GroupID *string `json:"group_id"`
|
||||
}
|
||||
|
||||
type reorderProjectsRequest struct {
|
||||
@@ -162,6 +171,17 @@ func (s *Server) handleUpdateProject(w http.ResponseWriter, r *http.Request) {
|
||||
s.storeError(w, "update project", err)
|
||||
return
|
||||
}
|
||||
// Grouping is written separately because it is scoped differently: the fields
|
||||
// above belong to the project, and this one belongs to whoever is filing it.
|
||||
// The route already requires ownership, so the check inside is a second lock
|
||||
// on the same door rather than a different one.
|
||||
if req.GroupID != nil {
|
||||
if err := s.db.SetProjectGroup(r.Context(), projectID, *req.GroupID,
|
||||
userFrom(r.Context()).ID); err != nil {
|
||||
s.storeError(w, "set project group", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
s.activity(r, projectID, "", "project.updated", nil)
|
||||
|
||||
p, err := s.db.GetProject(r.Context(), projectID, userFrom(r.Context()).ID)
|
||||
|
||||
@@ -287,6 +287,18 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
})
|
||||
})
|
||||
|
||||
// Groups are a person's own headings over their projects, so there is
|
||||
// no project permission to check — every query here is scoped to the
|
||||
// caller.
|
||||
r.Route("/project-groups", func(r chi.Router) {
|
||||
r.Get("/", s.handleListProjectGroups)
|
||||
r.Post("/", s.handleCreateProjectGroup)
|
||||
// Above /{groupID}, or chi reads "reorder" as an id.
|
||||
r.Post("/reorder", s.handleReorderProjectGroups)
|
||||
r.Patch("/{groupID}", s.handleUpdateProjectGroup)
|
||||
r.Delete("/{groupID}", s.handleDeleteProjectGroup)
|
||||
})
|
||||
|
||||
// The trash is outside the block above: a deleted project is invisible
|
||||
// to the permission check by design, so these handlers resolve
|
||||
// ownership themselves.
|
||||
|
||||
Reference in New Issue
Block a user