Merge remote-tracking branch 'origin/main' into LPM-115

This commit is contained in:
Robin Olsen
2026-03-23 20:18:47 +01:00
105 changed files with 6303 additions and 3758 deletions

View File

@@ -1,7 +1,9 @@
package handler
import (
"fmt"
"net/http"
"sort"
"strings"
"github.com/LazyBachelor/LazyPM/internal/models"
@@ -20,19 +22,72 @@ func DashboardHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Check if board view is requested
isBoardView := r.URL.Query().Get("board") == "true"
sort.Slice(issues, func(i, j int) bool {
return issues[i].Priority > issues[j].Priority
})
isBoardView := r.URL.Query().Get("board") == "true" || strings.HasPrefix(r.URL.Path, "/board/")
if isBoardView {
ctx := r.Context()
backlogIssues, err := app.Issues.GetIssuesNotInAnySprint(ctx)
if err != nil {
backlogIssues = []*models.Issue{}
}
sort.Slice(backlogIssues, func(i, j int) bool {
return backlogIssues[i].Priority > backlogIssues[j].Priority
})
sprints, err := app.Issues.GetSprints(ctx)
if err != nil {
sprints = []int{}
}
currentSprint := 0
sprintParam := r.URL.Query().Get("sprint")
if sprintParam != "" {
fmt.Sscanf(sprintParam, "%d", &currentSprint)
}
if currentSprint == 0 && len(sprints) > 0 {
currentSprint = sprints[0]
}
var sprintIssues []*models.Issue
if currentSprint > 0 {
sprintIssues, err = app.Issues.GetIssuesBySprint(ctx, currentSprint)
if err != nil {
sprintIssues = []*models.Issue{}
}
}
sort.Slice(sprintIssues, func(i, j int) bool {
return sprintIssues[i].Priority > sprintIssues[j].Priority
})
boardProps := routes.BoardViewProps{
BaseURL: "/?board=true",
QueryParam: query,
Issues: issues,
EmptyText: "No issues found",
BaseURL: "/?board=true",
QueryParam: query,
Issues: issues,
BacklogIssues: backlogIssues,
SprintIssues: sprintIssues,
CurrentSprint: currentSprint,
Sprints: sprints,
EmptyText: "No issues found",
}
if hx.IsHxRequest() {
routes.BoardViewContent(boardProps).Render(r.Context(), w)
if r.URL.Query().Get("board") != "true" {
w.Header().Set("HX-Push-Url", fmt.Sprintf("/?board=true&sprint=%d", currentSprint))
}
if strings.HasPrefix(r.URL.Path, "/board/") {
routes.BoardColumns(boardProps).Render(r.Context(), w)
} else {
routes.BoardViewContent(boardProps).Render(r.Context(), w)
}
return
}
@@ -40,7 +95,6 @@ func DashboardHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Regular dashboard view
var selectedIssue *models.Issue
if len(issues) > 0 {
selectedIssue = issues[0]
@@ -56,6 +110,13 @@ func DashboardHandler(w http.ResponseWriter, r *http.Request) {
}
}
if selectedIssue != nil {
comments, err := app.Issues.GetIssueComments(r.Context(), selectedIssue.ID)
if err == nil && comments != nil {
selectedIssue.Comments = comments
}
}
isPartial := r.URL.Query().Get("partial") == "true"
if hx.IsHxRequest() && isPartial {
routes.DashboardIssueList(issues, selectedIssueID).Render(r.Context(), w)
@@ -77,3 +138,18 @@ func DashboardHandler(w http.ResponseWriter, r *http.Request) {
routes.Dashboard(props).Render(r.Context(), w)
}
func CreateSprintHandler(w http.ResponseWriter, r *http.Request) {
app := App(r)
ctx := r.Context()
sprintNum, err := app.Issues.AddSprint(ctx)
if err != nil {
http.Error(w, "Failed to create sprint", http.StatusInternalServerError)
return
}
redirectURL := fmt.Sprintf("/?board=true&sprint=%d", sprintNum)
w.Header().Set("HX-Redirect", redirectURL)
w.WriteHeader(http.StatusOK)
}

View File

@@ -2,8 +2,10 @@ package handler
import (
"context"
"fmt"
"html"
"net/http"
"net/url"
"strings"
"github.com/LazyBachelor/LazyPM/internal/models"
@@ -18,7 +20,7 @@ const commentsKey = "comments"
type IssueForm struct {
Title string `form:"title" validate:"required,max=255"`
Description string `form:"description" validate:"max=2000"`
Status models.Status `form:"status" validate:"required,oneof=open in_progress blocked ready_to_sprint closed"`
Status models.Status `form:"status" validate:"required,oneof=open in_progress blocked closed"`
IssueType models.IssueType `form:"issue_type" validate:"required,oneof=task bug feature chore"`
Priority int `form:"priority" validate:"gte=0,lte=4"`
}
@@ -26,10 +28,11 @@ type IssueForm struct {
type UpdateIssueForm struct {
Title *string `form:"title" validate:"omitempty,max=255"`
Description *string `form:"description" validate:"omitempty,max=2000"`
Status *models.Status `form:"status" validate:"omitempty,oneof=open in_progress blocked ready_to_sprint closed"`
Status *models.Status `form:"status" validate:"omitempty,oneof=open in_progress blocked closed"`
CloseReason *string `form:"close_reason" validate:"omitempty,max=2000"`
IssueType *models.IssueType `form:"issue_type" validate:"omitempty,oneof=task bug feature chore"`
Priority *int `form:"priority" validate:"omitempty,gte=0,lte=4"`
Assignee *string `form:"assignee" validate:"omitempty,max=100"`
}
func CreateIssue(w http.ResponseWriter, r *http.Request) {
@@ -63,7 +66,7 @@ func CreateIssue(w http.ResponseWriter, r *http.Request) {
from := r.URL.Query().Get("from")
referer := r.Header.Get("Referer")
boardView := from == "board" || strings.Contains(referer, "board=true")
if boardView {
w.Header().Set("HX-Redirect", "/?board=true")
} else {
@@ -123,20 +126,8 @@ func GetIssue(w http.ResponseWriter, r *http.Request) {
comments := r.Context().Value(commentsKey).([]*models.Comment)
hx := HTMX(r)
from := r.URL.Query().Get("from")
detailProps := routes.IssueDetailProps{
Issue: issue,
Comments: comments,
From: from,
}
if !hx.IsHxRequest() && strings.Contains(r.Header.Get("Accept"), "text/html") {
routes.IssueDetail(detailProps).Render(r.Context(), w)
return
}
if hx.IsHxRequest() {
routes.IssueDetailContent(detailProps).Render(r.Context(), w)
if strings.Contains(r.Header.Get("Accept"), "text/html") && !hx.IsHxRequest() {
routes.IssueDetailPage(issue, comments).Render(r.Context(), w)
return
}
@@ -171,6 +162,24 @@ func UpdateIssue(w http.ResponseWriter, r *http.Request) {
return
}
ctx := r.Context()
addToSprint := r.FormValue("add_to_sprint")
removeFromSprint := r.FormValue("remove_from_sprint")
if addToSprint != "" {
sprintNum := 0
fmt.Sscanf(addToSprint, "%d", &sprintNum)
if sprintNum > 0 {
app.Issues.AddIssueToSprint(ctx, issue.ID, sprintNum)
}
} else if removeFromSprint != "" {
sprintNum := 0
fmt.Sscanf(removeFromSprint, "%d", &sprintNum)
if sprintNum > 0 {
app.Issues.RemoveIssueFromSprint(ctx, issue.ID, sprintNum)
}
}
issue, err = app.Issues.GetIssue(r.Context(), issue.ID)
if err != nil {
http.Error(w, "Failed to retrieve updated issue", http.StatusInternalServerError)
@@ -178,13 +187,20 @@ func UpdateIssue(w http.ResponseWriter, r *http.Request) {
}
if hx.IsHxRequest() {
// Check if 'from' parameter says board view
from := r.URL.Query().Get("from")
referer := r.Header.Get("Referer")
boardView := from == "board" || strings.Contains(referer, "board=true")
if boardView {
w.Header().Set("HX-Redirect", "/?board=true")
redirectURL := "/?board=true"
if strings.Contains(referer, "sprint=") {
if parsedURL, err := url.Parse(referer); err == nil {
if sprintParam := parsedURL.Query().Get("sprint"); sprintParam != "" {
redirectURL = redirectURL + "&sprint=" + sprintParam
}
}
}
w.Header().Set("HX-Redirect", redirectURL)
} else {
w.Header().Set("HX-Redirect", "/?selected-issue="+issue.ID)
}
@@ -197,7 +213,12 @@ func UpdateIssue(w http.ResponseWriter, r *http.Request) {
func UpdateAssignee(w http.ResponseWriter, r *http.Request) {
issue := r.Context().Value(issueKey).(*models.Issue)
assignee := r.FormValue("assignee")
assignMe := r.FormValue("assign_me")
assignee := ""
if assignMe != "" {
assignee = "Me"
}
if err := App(r).Issues.UpdateIssue(r.Context(), issue.ID, map[string]any{"assignee": assignee}, ""); err != nil {
http.Error(w, "Failed to update assignee", http.StatusInternalServerError)
@@ -214,7 +235,7 @@ func UpdateAssignee(w http.ResponseWriter, r *http.Request) {
// Check if we're in board view
referer := r.Header.Get("Referer")
boardView := strings.Contains(referer, "board=true")
if boardView {
w.Header().Set("HX-Redirect", "/?board=true&selected-issue="+issue.ID)
} else {
@@ -387,7 +408,7 @@ func DeleteIssue(w http.ResponseWriter, r *http.Request) {
from := r.URL.Query().Get("from")
referer := r.Header.Get("Referer")
boardView := from == "board" || strings.Contains(referer, "board=true")
if boardView {
w.Header().Set("HX-Redirect", "/?board=true")
} else {
@@ -472,5 +493,8 @@ func (f *UpdateIssueForm) toChanges() map[string]any {
if f.Priority != nil {
changes["priority"] = *f.Priority
}
if f.Assignee != nil {
changes["assignee"] = *f.Assignee
}
return changes
}

View File

@@ -30,11 +30,15 @@ func HandleTaskStatus(w http.ResponseWriter, r *http.Request) {
hx := HTMX(r)
if hx.IsHxRequest() {
hx.WriteString(`
if taskFeedback.Success {
w.Header().Set("HX-Trigger", "task-status-success")
w.WriteHeader(http.StatusNoContent)
} else {
hx.WriteString(`
<div id="status" type="button" hx-get="/status" hx-target="#status" hx-swap="outerHTML">
<button class="btn btn-error btn-sm" hx-get="/status/modal" hx-target="#modal-container" hx-swap="innerHTML">` + taskFeedback.Message + `</button>
</div>`)
}
return
}