diff --git a/internal/server/soft_delete_integration_test.go b/internal/server/soft_delete_integration_test.go index 0dd84b0..529c220 100644 --- a/internal/server/soft_delete_integration_test.go +++ b/internal/server/soft_delete_integration_test.go @@ -119,7 +119,7 @@ func TestHTTPListDeletedIssues(t *testing.T) { t.Fatalf("list deleted code = %d body = %s", code, body) } page := decodePage[model.Issue](t, body) - wantIDs := []uuid.UUID{deleted.ID, parent.ID, child.ID} + wantIDs := []uuid.UUID{deleted.ID, parent.ID} if len(page.Items) != len(wantIDs) || page.NextCursor != nil { t.Fatalf("deleted page = %+v next=%v, want %d items no cursor", page.Items, page.NextCursor, len(wantIDs)) } @@ -134,21 +134,29 @@ func TestHTTPListDeletedIssues(t *testing.T) { } } - code, body = e.do(t, http.MethodGet, e.projectIssuesPath()+"/deleted?limit=2", nil) + code, body = e.do(t, http.MethodGet, e.projectIssuesPath()+"/deleted?limit=1", nil) if code != http.StatusOK { t.Fatalf("deleted page 1 code = %d body = %s", code, body) } page = decodePage[model.Issue](t, body) - if len(page.Items) != 2 || page.NextCursor == nil { - t.Fatalf("deleted page 1 = %+v next=%v, want 2 items and cursor", page.Items, page.NextCursor) + if len(page.Items) != 1 || page.Items[0].ID != deleted.ID || page.NextCursor == nil { + t.Fatalf("deleted page 1 = %+v next=%v, want deleted issue and cursor", page.Items, page.NextCursor) } - code, body = e.do(t, http.MethodGet, e.projectIssuesPath()+"/deleted?limit=2&cursor="+url.QueryEscape(*page.NextCursor), nil) + code, body = e.do(t, http.MethodGet, e.projectIssuesPath()+"/deleted?limit=1&cursor="+url.QueryEscape(*page.NextCursor), nil) if code != http.StatusOK { t.Fatalf("deleted page 2 code = %d body = %s", code, body) } page = decodePage[model.Issue](t, body) - if len(page.Items) != 1 || page.Items[0].ID != child.ID || page.NextCursor != nil { - t.Fatalf("deleted page 2 = %+v next=%v, want child only", page.Items, page.NextCursor) + if len(page.Items) != 1 || page.Items[0].ID != parent.ID || page.NextCursor != nil { + t.Fatalf("deleted page 2 = %+v next=%v, want parent only", page.Items, page.NextCursor) + } + + code, body = e.do(t, http.MethodPost, e.issuePath(child)+"/restore", nil) + if code != http.StatusOK { + t.Fatalf("restore unlisted child code = %d body = %s", code, body) + } + if restored := decode[model.Issue](t, body); restored.ID != child.ID { + t.Fatalf("restored unlisted child = %+v, want %s", restored, child.ID) } code, body = e.do(t, http.MethodGet, e.issuePath(deleted), nil) diff --git a/internal/server/ui_home_integration_test.go b/internal/server/ui_home_integration_test.go index 3f32497..f582121 100644 --- a/internal/server/ui_home_integration_test.go +++ b/internal/server/ui_home_integration_test.go @@ -262,13 +262,15 @@ func TestUIRendersPersonalWorkViews(t *testing.T) { } allBody := e.uiGet(t, "/me/all", token) - for _, want := range []string{"All assigned issues", activeTodoP0.Title, activeDoneP1.Title, plannedAssigned.Title, backlogAssigned.Title, child.Title, otherP0.Title} { + for _, want := range []string{"All assigned issues", activeTodoP0.Title, activeDoneP1.Title, plannedAssigned.Title, backlogAssigned.Title, otherP0.Title} { if !strings.Contains(allBody, want) { t.Fatalf("me all body missing %q: %s", want, allBody) } } - if strings.Contains(allBody, activeUnassigned.Title) { - t.Fatalf("me all body included unassigned issue: %s", allBody) + for _, notWant := range []string{activeUnassigned.Title, child.Title} { + if strings.Contains(allBody, notWant) { + t.Fatalf("me all body included %q: %s", notWant, allBody) + } } filteredActive := e.uiGet(t, "/me?status=done&priority=P1", token) diff --git a/internal/server/ui_project_integration_test.go b/internal/server/ui_project_integration_test.go index bfcf283..dacaf2f 100644 --- a/internal/server/ui_project_integration_test.go +++ b/internal/server/ui_project_integration_test.go @@ -1193,8 +1193,10 @@ func TestUIRendersProjectPlannedAndAll(t *testing.T) { if backlogIdx < 0 || firstIdx < 0 || secondIdx < 0 || secondIdx > firstIdx || firstIdx > backlogIdx { t.Fatalf("all issue order wrong: backlog=%d first=%d second=%d body=%s", backlogIdx, firstIdx, secondIdx, body) } - if strings.Contains(body, "Other Planned Sprint") || strings.Contains(body, "other project backlog issue") || strings.Contains(body, "other project planned issue") { - t.Fatalf("all body included wrong scope: %s", body) + for _, notWant := range []string{"Other Planned Sprint", "other project backlog issue", "other project planned issue", progressTodo.Title, progressDone.Title} { + if strings.Contains(body, notWant) { + t.Fatalf("all body included %q: %s", notWant, body) + } } if got := strings.Count(body, `role="progressbar" aria-label="Sub-issues completed"`); got != 1 { t.Fatalf("all issue progress count = %d, want 1: %s", got, body) @@ -1300,13 +1302,8 @@ func TestUIProjectDeletedPageListsAndRestoresIssues(t *testing.T) { `href="` + e.issuePath(deleted) + `"`, `hx-get="` + e.issuePath(deleted) + `/panel"`, parent.Title, - child.Title, - "Sub-issue", `method="post" action="` + e.issuePath(deleted) + `/restore"`, `hx-post="` + e.issuePath(deleted) + `/restore"`, - `method="post" action="` + e.issuePath(child) + `/restore"`, - `hx-post="` + e.issuePath(child) + `/restore"`, - `hx-push-url="` + e.issuePath(child) + `"`, `data-lucide="rotate-ccw"`, "Restore", } { @@ -1319,7 +1316,7 @@ func TestUIProjectDeletedPageListsAndRestoresIssues(t *testing.T) { t.Fatalf("deleted body should not render back button markup %q: %s", notWant, body) } } - for _, notWant := range []string{live.Title, otherDeleted.Title, "Issue deleted", `aria-label="Project views"`, `aria-label="Project actions"`} { + for _, notWant := range []string{live.Title, otherDeleted.Title, child.Title, `method="post" action="` + e.issuePath(child) + `/restore"`, "Issue deleted", `aria-label="Project views"`, `aria-label="Project actions"`} { if strings.Contains(body, notWant) { t.Fatalf("deleted body included %q: %s", notWant, body) } diff --git a/internal/server/ui_project_pages.go b/internal/server/ui_project_pages.go index 2d76ca6..ff8ef0c 100644 --- a/internal/server/ui_project_pages.go +++ b/internal/server/ui_project_pages.go @@ -269,14 +269,13 @@ func (s *Server) uiAssignedIssues(ctx context.Context, projects []model.Project, var hasMore bool for _, project := range projects { issues, more, err := s.store.ListIssues(ctx, store.ListIssuesParams{ - ProjectID: project.ID, - Statuses: query.Statuses, - Priorities: query.Priorities, - AssigneeIDs: []uuid.UUID{userID}, - Limit: MaxLimit, - Sort: query.Sort, - Direction: query.Direction, - IncludeSubIssues: true, + ProjectID: project.ID, + Statuses: query.Statuses, + Priorities: query.Priorities, + AssigneeIDs: []uuid.UUID{userID}, + Limit: MaxLimit, + Sort: query.Sort, + Direction: query.Direction, }) if err != nil { return nil, false, err @@ -892,16 +891,15 @@ func (s *Server) uiBuildProjectAllIssuePage(ctx context.Context, r *http.Request cursor = &c } issues, hasMore, err := s.store.ListIssues(ctx, store.ListIssuesParams{ - ProjectID: project.ID, - Statuses: allQuery.Statuses, - Priorities: allQuery.Priorities, - TagNames: allQuery.TagNames, - AssigneeIDs: allQuery.AssigneeIDs, - Cursor: cursor, - Limit: DefaultLimit, - Sort: allQuery.Sort, - Direction: allQuery.Direction, - IncludeSubIssues: true, + ProjectID: project.ID, + Statuses: allQuery.Statuses, + Priorities: allQuery.Priorities, + TagNames: allQuery.TagNames, + AssigneeIDs: allQuery.AssigneeIDs, + Cursor: cursor, + Limit: DefaultLimit, + Sort: allQuery.Sort, + Direction: allQuery.Direction, }) if err != nil { return uiProjectAllIssuePageData{}, err diff --git a/internal/store/issues.go b/internal/store/issues.go index ab32588..9aee17d 100644 --- a/internal/store/issues.go +++ b/internal/store/issues.go @@ -361,8 +361,8 @@ type ListIssuesParams struct { Limit int Sort ListIssuesSort Direction ListIssuesSortDirection - // IncludeSubIssues is for personal/work views that should surface assigned - // child work. Project planning lists keep the default top-level-only shape. + // IncludeSubIssues is reserved for explicit parent/child traversal. Product + // issue collections keep the default top-level-only shape. IncludeSubIssues bool } @@ -693,7 +693,8 @@ func (s *Store) ListDeletedIssues(ctx context.Context, p ListDeletedIssuesParams FROM issues i JOIN projects pr ON pr.id = i.project_id JOIN users u ON u.id = pr.owner_id - WHERE i.project_id = $1 AND i.deleted_at IS NOT NULL AND pr.deleted_at IS NULL AND u.deleted_at IS NULL + WHERE i.project_id = $1 AND i.deleted_at IS NOT NULL AND i.parent_issue_id IS NULL + AND pr.deleted_at IS NULL AND u.deleted_at IS NULL ` if p.Cursor != nil { args = append(args, p.Cursor.Number) diff --git a/internal/store/soft_delete_integration_test.go b/internal/store/soft_delete_integration_test.go index be3b24e..af1d24f 100644 --- a/internal/store/soft_delete_integration_test.go +++ b/internal/store/soft_delete_integration_test.go @@ -155,7 +155,7 @@ func TestListDeletedIssues(t *testing.T) { if hasMore { t.Fatalf("ListDeletedIssues hasMore = true, want false") } - wantIDs := []uuid.UUID{deleted.ID, parent.ID, child.ID} + wantIDs := []uuid.UUID{deleted.ID, parent.ID} if len(got) != len(wantIDs) { t.Fatalf("ListDeletedIssues len = %d, want %d: %+v", len(got), len(wantIDs), got) } @@ -188,8 +188,12 @@ func TestListDeletedIssues(t *testing.T) { if err != nil { t.Fatalf("ListDeletedIssues next: %v", err) } - if hasMore || len(next) != 2 || next[0].ID != parent.ID || next[1].ID != child.ID { - t.Fatalf("ListDeletedIssues next = %+v hasMore=%v, want parent/child only", next, hasMore) + if hasMore || len(next) != 1 || next[0].ID != parent.ID { + t.Fatalf("ListDeletedIssues next = %+v hasMore=%v, want parent only", next, hasMore) + } + gotChild, err := env.store.GetDeletedIssueByOwnerKeyNumber(env.ctx, child.OwnerUsername, child.ProjectKey, child.Number) + if err != nil || gotChild.ID != child.ID { + t.Fatalf("GetDeletedIssue child = %+v, %v", gotChild, err) } }