Compare commits
10 Commits
a59088dc35
...
fbe593b131
| Author | SHA1 | Date |
|---|---|---|
|
|
fbe593b131 | |
|
|
733fa94826 | |
|
|
040f848268 | |
|
|
4298b7f5f5 | |
|
|
e0b5fd8938 | |
|
|
2687fe3603 | |
|
|
51eeb4c9c1 | |
|
|
537199169e | |
|
|
0f4286032d | |
|
|
ee703b08e4 |
|
|
@ -636,7 +636,7 @@ func (c *Client) EditIssue(owner, repo string, number int64, req EditIssueReques
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
return fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody))
|
return fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -35,6 +36,15 @@ type ticketListRow struct {
|
||||||
|
|
||||||
func (h *TicketHandler) List(c *gin.Context) {
|
func (h *TicketHandler) List(c *gin.Context) {
|
||||||
statusFilter := c.Query("status")
|
statusFilter := c.Query("status")
|
||||||
|
priorityFilter := c.Query("priority")
|
||||||
|
productFilter := c.Query("product")
|
||||||
|
reporterFilter := c.Query("reporter")
|
||||||
|
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
const pageSize = 20
|
||||||
|
|
||||||
// Load all ticket mappings with User and Repo joins
|
// Load all ticket mappings with User and Repo joins
|
||||||
type ticketMapping struct {
|
type ticketMapping struct {
|
||||||
|
|
@ -53,7 +63,6 @@ func (h *TicketHandler) List(c *gin.Context) {
|
||||||
Joins("JOIN repos ON repos.id = tickets.repo_id").
|
Joins("JOIN repos ON repos.id = tickets.repo_id").
|
||||||
Joins("JOIN users ON users.id = tickets.user_id").
|
Joins("JOIN users ON users.id = tickets.user_id").
|
||||||
Order("tickets.created_at DESC").
|
Order("tickets.created_at DESC").
|
||||||
Limit(100).
|
|
||||||
Scan(&mappings).Error; err != nil {
|
Scan(&mappings).Error; err != nil {
|
||||||
log.Error().Err(err).Msg("list tickets error")
|
log.Error().Err(err).Msg("list tickets error")
|
||||||
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to load tickets")
|
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to load tickets")
|
||||||
|
|
@ -83,7 +92,7 @@ func (h *TicketHandler) List(c *gin.Context) {
|
||||||
apiState = "open"
|
apiState = "open"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch issues from Forgejo per repo and match
|
// Fetch issues from Forgejo per repo and build all rows
|
||||||
var tickets []ticketListRow
|
var tickets []ticketListRow
|
||||||
for _, group := range repoGroups {
|
for _, group := range repoGroups {
|
||||||
issues, err := h.deps.ForgejoClient.ListIssues(group.owner, group.repo, apiState, "")
|
issues, err := h.deps.ForgejoClient.ListIssues(group.owner, group.repo, apiState, "")
|
||||||
|
|
@ -114,17 +123,11 @@ func (h *TicketHandler) List(c *gin.Context) {
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
status := forgejo.DeriveStatus(issue)
|
|
||||||
|
|
||||||
// Apply client-side status filter
|
|
||||||
if statusFilter != "" && status != statusFilter {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
tickets = append(tickets, ticketListRow{
|
tickets = append(tickets, ticketListRow{
|
||||||
ID: m.ID,
|
ID: m.ID,
|
||||||
Title: issue.Title,
|
Title: issue.Title,
|
||||||
Status: status,
|
Status: forgejo.DeriveStatus(issue),
|
||||||
Priority: forgejo.DerivePriority(issue),
|
Priority: forgejo.DerivePriority(issue),
|
||||||
Pinned: issue.PinOrder > 0,
|
Pinned: issue.PinOrder > 0,
|
||||||
RepoName: m.RepoName,
|
RepoName: m.RepoName,
|
||||||
|
|
@ -137,6 +140,47 @@ func (h *TicketHandler) List(c *gin.Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collect unique product names and reporter names before filtering
|
||||||
|
productSet := map[string]bool{}
|
||||||
|
reporterSet := map[string]bool{}
|
||||||
|
for _, t := range tickets {
|
||||||
|
if t.RepoName != "" {
|
||||||
|
productSet[t.RepoName] = true
|
||||||
|
}
|
||||||
|
if t.UserName != "" {
|
||||||
|
reporterSet[t.UserName] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var products []string
|
||||||
|
for p := range productSet {
|
||||||
|
products = append(products, p)
|
||||||
|
}
|
||||||
|
sort.Strings(products)
|
||||||
|
var reporters []string
|
||||||
|
for r := range reporterSet {
|
||||||
|
reporters = append(reporters, r)
|
||||||
|
}
|
||||||
|
sort.Strings(reporters)
|
||||||
|
|
||||||
|
// Apply in-memory filters
|
||||||
|
filtered := tickets[:0]
|
||||||
|
for _, t := range tickets {
|
||||||
|
if statusFilter != "" && t.Status != statusFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if priorityFilter != "" && t.Priority != priorityFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if productFilter != "" && t.RepoName != productFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if reporterFilter != "" && t.UserName != reporterFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, t)
|
||||||
|
}
|
||||||
|
tickets = filtered
|
||||||
|
|
||||||
// Sort: pinned first, then by created date
|
// Sort: pinned first, then by created date
|
||||||
sort.SliceStable(tickets, func(i, j int) bool {
|
sort.SliceStable(tickets, func(i, j int) bool {
|
||||||
if tickets[i].Pinned != tickets[j].Pinned {
|
if tickets[i].Pinned != tickets[j].Pinned {
|
||||||
|
|
@ -145,9 +189,31 @@ func (h *TicketHandler) List(c *gin.Context) {
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Paginate
|
||||||
|
totalPages := (len(tickets) + pageSize - 1) / pageSize
|
||||||
|
if totalPages < 1 {
|
||||||
|
totalPages = 1
|
||||||
|
}
|
||||||
|
if page > totalPages {
|
||||||
|
page = totalPages
|
||||||
|
}
|
||||||
|
start := (page - 1) * pageSize
|
||||||
|
end := start + pageSize
|
||||||
|
if end > len(tickets) {
|
||||||
|
end = len(tickets)
|
||||||
|
}
|
||||||
|
tickets = tickets[start:end]
|
||||||
|
|
||||||
h.deps.Renderer.Render(c.Writer, c.Request, "admin/tickets/list", map[string]interface{}{
|
h.deps.Renderer.Render(c.Writer, c.Request, "admin/tickets/list", map[string]interface{}{
|
||||||
"Tickets": tickets,
|
"Tickets": tickets,
|
||||||
"StatusFilter": statusFilter,
|
"StatusFilter": statusFilter,
|
||||||
|
"PriorityFilter": priorityFilter,
|
||||||
|
"ProductFilter": productFilter,
|
||||||
|
"ReporterFilter": reporterFilter,
|
||||||
|
"Products": products,
|
||||||
|
"Reporters": reporters,
|
||||||
|
"Page": page,
|
||||||
|
"TotalPages": totalPages,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,18 @@ type TicketHandler struct {
|
||||||
func (h *TicketHandler) List(c *gin.Context) {
|
func (h *TicketHandler) List(c *gin.Context) {
|
||||||
user := auth.CurrentUser(c)
|
user := auth.CurrentUser(c)
|
||||||
|
|
||||||
|
statusFilter := c.Query("status")
|
||||||
|
priorityFilter := c.Query("priority")
|
||||||
|
productFilter := c.Query("product")
|
||||||
|
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
const pageSize = 20
|
||||||
|
|
||||||
var tickets []models.Ticket
|
var tickets []models.Ticket
|
||||||
if err := h.deps.DB.Preload("Repo").Where("user_id = ?", user.ID).Order("created_at DESC").Limit(50).Find(&tickets).Error; err != nil {
|
if err := h.deps.DB.Preload("Repo").Where("user_id = ?", user.ID).Order("created_at DESC").Find(&tickets).Error; err != nil {
|
||||||
log.Error().Err(err).Msg("list tickets error")
|
log.Error().Err(err).Msg("list tickets error")
|
||||||
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to load tickets")
|
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to load tickets")
|
||||||
return
|
return
|
||||||
|
|
@ -35,6 +45,9 @@ func (h *TicketHandler) List(c *gin.Context) {
|
||||||
if len(tickets) == 0 {
|
if len(tickets) == 0 {
|
||||||
h.deps.Renderer.Render(c.Writer, c.Request, "tickets/list", map[string]interface{}{
|
h.deps.Renderer.Render(c.Writer, c.Request, "tickets/list", map[string]interface{}{
|
||||||
"Tickets": []map[string]interface{}{},
|
"Tickets": []map[string]interface{}{},
|
||||||
|
"StatusFilter": statusFilter,
|
||||||
|
"PriorityFilter": priorityFilter,
|
||||||
|
"ProductFilter": productFilter,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -52,6 +65,14 @@ func (h *TicketHandler) List(c *gin.Context) {
|
||||||
repoMap[t.RepoID].tickets = append(repoMap[t.RepoID].tickets, t)
|
repoMap[t.RepoID].tickets = append(repoMap[t.RepoID].tickets, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine Forgejo API state filter
|
||||||
|
apiState := "all"
|
||||||
|
if statusFilter == "closed" {
|
||||||
|
apiState = "closed"
|
||||||
|
} else if statusFilter == "open" || statusFilter == "in_progress" {
|
||||||
|
apiState = "open"
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch issues from Forgejo per repo and build view models
|
// Fetch issues from Forgejo per repo and build view models
|
||||||
type ticketView struct {
|
type ticketView struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
|
|
@ -66,7 +87,7 @@ func (h *TicketHandler) List(c *gin.Context) {
|
||||||
var views []ticketView
|
var views []ticketView
|
||||||
|
|
||||||
for _, group := range repoMap {
|
for _, group := range repoMap {
|
||||||
issues, err := h.deps.ForgejoClient.ListIssues(group.repo.ForgejoOwner, group.repo.ForgejoRepo, "all", "")
|
issues, err := h.deps.ForgejoClient.ListIssues(group.repo.ForgejoOwner, group.repo.ForgejoRepo, apiState, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Str("repo", group.repo.Name).Msg("forgejo list issues error")
|
log.Error().Err(err).Str("repo", group.repo.Name).Msg("forgejo list issues error")
|
||||||
// Show tickets with unknown status on API failure
|
// Show tickets with unknown status on API failure
|
||||||
|
|
@ -103,6 +124,35 @@ func (h *TicketHandler) List(c *gin.Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collect unique product names before filtering
|
||||||
|
productSet := map[string]bool{}
|
||||||
|
for _, v := range views {
|
||||||
|
if v.RepoName != "" {
|
||||||
|
productSet[v.RepoName] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var products []string
|
||||||
|
for p := range productSet {
|
||||||
|
products = append(products, p)
|
||||||
|
}
|
||||||
|
sort.Strings(products)
|
||||||
|
|
||||||
|
// Apply in-memory filters
|
||||||
|
filtered := views[:0]
|
||||||
|
for _, v := range views {
|
||||||
|
if statusFilter != "" && v.Status != statusFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if priorityFilter != "" && v.Priority != priorityFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if productFilter != "" && v.RepoName != productFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, v)
|
||||||
|
}
|
||||||
|
views = filtered
|
||||||
|
|
||||||
// Sort: pinned first, then by created date
|
// Sort: pinned first, then by created date
|
||||||
sort.SliceStable(views, func(i, j int) bool {
|
sort.SliceStable(views, func(i, j int) bool {
|
||||||
if views[i].Pinned != views[j].Pinned {
|
if views[i].Pinned != views[j].Pinned {
|
||||||
|
|
@ -111,8 +161,29 @@ func (h *TicketHandler) List(c *gin.Context) {
|
||||||
return false // preserve existing order for non-pinned
|
return false // preserve existing order for non-pinned
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Paginate
|
||||||
|
totalPages := (len(views) + pageSize - 1) / pageSize
|
||||||
|
if totalPages < 1 {
|
||||||
|
totalPages = 1
|
||||||
|
}
|
||||||
|
if page > totalPages {
|
||||||
|
page = totalPages
|
||||||
|
}
|
||||||
|
start := (page - 1) * pageSize
|
||||||
|
end := start + pageSize
|
||||||
|
if end > len(views) {
|
||||||
|
end = len(views)
|
||||||
|
}
|
||||||
|
views = views[start:end]
|
||||||
|
|
||||||
h.deps.Renderer.Render(c.Writer, c.Request, "tickets/list", map[string]interface{}{
|
h.deps.Renderer.Render(c.Writer, c.Request, "tickets/list", map[string]interface{}{
|
||||||
"Tickets": views,
|
"Tickets": views,
|
||||||
|
"StatusFilter": statusFilter,
|
||||||
|
"PriorityFilter": priorityFilter,
|
||||||
|
"ProductFilter": productFilter,
|
||||||
|
"Products": products,
|
||||||
|
"Page": page,
|
||||||
|
"TotalPages": totalPages,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package templates
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -87,5 +88,48 @@ func templateFuncs() template.FuncMap {
|
||||||
}
|
}
|
||||||
return dict
|
return dict
|
||||||
},
|
},
|
||||||
|
"add": func(a, b int) int {
|
||||||
|
return a + b
|
||||||
|
},
|
||||||
|
"sub": func(a, b int) int {
|
||||||
|
return a - b
|
||||||
|
},
|
||||||
|
"filterURL": func(baseURL string, params map[string]interface{}, setKey, setValue string) string {
|
||||||
|
u, err := url.Parse(baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return baseURL
|
||||||
|
}
|
||||||
|
q := url.Values{}
|
||||||
|
for k, v := range params {
|
||||||
|
if k == "page" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
q.Set(k, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if setValue != "" {
|
||||||
|
q.Set(setKey, setValue)
|
||||||
|
} else {
|
||||||
|
q.Del(setKey)
|
||||||
|
}
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
return u.String()
|
||||||
|
},
|
||||||
|
"pageURL": func(baseURL string, params map[string]interface{}, page int) string {
|
||||||
|
u, err := url.Parse(baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return baseURL
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
for k, v := range params {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
q.Set(k, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
q.Set("page", fmt.Sprintf("%d", page))
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
return u.String()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,297 @@
|
||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/mattnite/forgejo-tickets/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAllTemplatesExecute parses every template and executes it with
|
||||||
|
// representative data wrapped in PageData, exactly as the real Renderer does.
|
||||||
|
// This catches errors like accessing a field on PageData that only exists in
|
||||||
|
// the Data map (e.g. $.ProductFilter instead of using a captured variable).
|
||||||
|
func TestAllTemplatesExecute(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
dueDate := now.Add(24 * time.Hour)
|
||||||
|
|
||||||
|
testUser := &models.User{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Email: "test@example.com",
|
||||||
|
Name: "Test User",
|
||||||
|
Role: "customer",
|
||||||
|
}
|
||||||
|
|
||||||
|
adminUser := &models.User{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Email: "admin@example.com",
|
||||||
|
Name: "Admin",
|
||||||
|
Role: "admin",
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := models.Repo{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Name: "Test Product",
|
||||||
|
Slug: "test-product",
|
||||||
|
ForgejoOwner: "owner",
|
||||||
|
ForgejoRepo: "repo",
|
||||||
|
}
|
||||||
|
|
||||||
|
ticketID := uuid.New()
|
||||||
|
|
||||||
|
// Each entry maps a template name to data that exercises all template paths,
|
||||||
|
// including {{range}} loops that re-scope the dot.
|
||||||
|
cases := map[string]struct {
|
||||||
|
data interface{}
|
||||||
|
user *models.User
|
||||||
|
}{
|
||||||
|
"home": {data: nil, user: nil},
|
||||||
|
|
||||||
|
"login": {data: map[string]interface{}{
|
||||||
|
"GoogleEnabled": true,
|
||||||
|
"MicrosoftEnabled": false,
|
||||||
|
"AppleEnabled": false,
|
||||||
|
"Error": "",
|
||||||
|
"Email": "test@example.com",
|
||||||
|
}},
|
||||||
|
|
||||||
|
"register": {data: map[string]interface{}{
|
||||||
|
"Name": "Test",
|
||||||
|
"Email": "test@example.com",
|
||||||
|
"Error": "",
|
||||||
|
}},
|
||||||
|
|
||||||
|
"forgot-password": {data: nil},
|
||||||
|
|
||||||
|
"reset-password": {data: map[string]interface{}{
|
||||||
|
"Token": "abc123",
|
||||||
|
"Error": "",
|
||||||
|
}},
|
||||||
|
|
||||||
|
"change-password": {data: map[string]interface{}{
|
||||||
|
"HasPassword": true,
|
||||||
|
"Error": "",
|
||||||
|
}, user: testUser},
|
||||||
|
|
||||||
|
"tickets/list": {data: map[string]interface{}{
|
||||||
|
"Tickets": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"ID": ticketID,
|
||||||
|
"Title": "Test Ticket",
|
||||||
|
"Status": "open",
|
||||||
|
"Priority": "high",
|
||||||
|
"Pinned": false,
|
||||||
|
"RepoName": "Test Product",
|
||||||
|
"DueDate": &dueDate,
|
||||||
|
"CreatedAt": now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"StatusFilter": "open",
|
||||||
|
"PriorityFilter": "",
|
||||||
|
"ProductFilter": "Test Product",
|
||||||
|
"Products": []string{"Test Product", "Other Product"},
|
||||||
|
"Page": 1,
|
||||||
|
"TotalPages": 2,
|
||||||
|
}, user: testUser},
|
||||||
|
|
||||||
|
"tickets/new": {data: map[string]interface{}{
|
||||||
|
"Repos": []models.Repo{repo},
|
||||||
|
"Error": "",
|
||||||
|
"Title": "",
|
||||||
|
"Description": "",
|
||||||
|
"RepoID": "",
|
||||||
|
}, user: testUser},
|
||||||
|
|
||||||
|
"tickets/detail": {data: map[string]interface{}{
|
||||||
|
"Ticket": map[string]interface{}{
|
||||||
|
"ID": ticketID,
|
||||||
|
"Title": "Test Ticket",
|
||||||
|
"Description": "A test description",
|
||||||
|
"Status": "open",
|
||||||
|
"Priority": "high",
|
||||||
|
"Pinned": false,
|
||||||
|
"Assignees": "Dev User",
|
||||||
|
"DueDate": &dueDate,
|
||||||
|
"Attachments": []map[string]interface{}{
|
||||||
|
{"ID": int64(1), "Name": "file.txt", "Size": int64(1024)},
|
||||||
|
},
|
||||||
|
"CreatedAt": now,
|
||||||
|
},
|
||||||
|
"Repo": repo,
|
||||||
|
"Timeline": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"Type": "comment",
|
||||||
|
"Body": "A comment",
|
||||||
|
"AuthorName": "Dev",
|
||||||
|
"IsTeam": true,
|
||||||
|
"EventText": "",
|
||||||
|
"CreatedAt": now,
|
||||||
|
"Attachments": []map[string]interface{}{
|
||||||
|
{"ID": int64(2), "Name": "img.png"},
|
||||||
|
},
|
||||||
|
"CommentID": int64(100),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Type": "status_change",
|
||||||
|
"Body": "",
|
||||||
|
"AuthorName": "Dev",
|
||||||
|
"IsTeam": true,
|
||||||
|
"EventText": "changed status to open",
|
||||||
|
"CreatedAt": now,
|
||||||
|
"Attachments": []map[string]interface{}{},
|
||||||
|
"CommentID": int64(0),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"RelatedIssues": []map[string]interface{}{
|
||||||
|
{"Number": int64(5), "Title": "Related", "IsVisible": true, "DisplayText": "Related", "TicketID": uuid.New().String()},
|
||||||
|
},
|
||||||
|
"Mentions": map[string]string{"dev": "Dev User"},
|
||||||
|
}, user: testUser},
|
||||||
|
|
||||||
|
"admin/dashboard": {data: map[string]interface{}{
|
||||||
|
"UserCount": int64(10),
|
||||||
|
"TotalTickets": int64(50),
|
||||||
|
"OpenTickets": int64(20),
|
||||||
|
"InProgressTickets": int64(15),
|
||||||
|
"ClosedTickets": int64(15),
|
||||||
|
}, user: adminUser},
|
||||||
|
|
||||||
|
"admin/users/list": {data: map[string]interface{}{
|
||||||
|
"Users": []models.User{*adminUser},
|
||||||
|
}, user: adminUser},
|
||||||
|
|
||||||
|
"admin/users/new": {data: map[string]interface{}{
|
||||||
|
"Error": "",
|
||||||
|
"Name": "",
|
||||||
|
"Email": "",
|
||||||
|
}, user: adminUser},
|
||||||
|
|
||||||
|
"admin/users/pending": {data: map[string]interface{}{
|
||||||
|
"Users": []models.User{{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Email: "pending@example.com",
|
||||||
|
Name: "Pending User",
|
||||||
|
Role: "customer",
|
||||||
|
}},
|
||||||
|
}, user: adminUser},
|
||||||
|
|
||||||
|
"admin/users/detail": {data: map[string]interface{}{
|
||||||
|
"User": *testUser,
|
||||||
|
"Tickets": []map[string]interface{}{},
|
||||||
|
"AllRepos": []models.Repo{repo},
|
||||||
|
"AssignedRepoIDs": map[string]bool{
|
||||||
|
repo.ID.String(): true,
|
||||||
|
},
|
||||||
|
}, user: adminUser},
|
||||||
|
|
||||||
|
"admin/repos/list": {data: map[string]interface{}{
|
||||||
|
"Repos": []models.Repo{repo},
|
||||||
|
"BaseURL": "https://example.com",
|
||||||
|
}, user: adminUser},
|
||||||
|
|
||||||
|
"admin/repos/new": {data: map[string]interface{}{
|
||||||
|
"Error": "",
|
||||||
|
"Name": "",
|
||||||
|
"Slug": "",
|
||||||
|
"ForgejoOwner": "",
|
||||||
|
"ForgejoRepo": "",
|
||||||
|
}, user: adminUser},
|
||||||
|
|
||||||
|
"admin/repos/edit": {data: map[string]interface{}{
|
||||||
|
"Repo": repo,
|
||||||
|
"BaseURL": "https://example.com",
|
||||||
|
"ForgejoURL": "https://forgejo.example.com",
|
||||||
|
"RepoPermErr": "",
|
||||||
|
}, user: adminUser},
|
||||||
|
|
||||||
|
"admin/tickets/list": {data: map[string]interface{}{
|
||||||
|
"Tickets": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"ID": ticketID,
|
||||||
|
"Title": "Test Ticket",
|
||||||
|
"Status": "open",
|
||||||
|
"Priority": "high",
|
||||||
|
"Pinned": false,
|
||||||
|
"RepoName": "Test Product",
|
||||||
|
"UserName": "Test User",
|
||||||
|
"DueDate": &dueDate,
|
||||||
|
"CreatedAt": now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"StatusFilter": "",
|
||||||
|
"PriorityFilter": "",
|
||||||
|
"ProductFilter": "Test Product",
|
||||||
|
"ReporterFilter": "Test User",
|
||||||
|
"Products": []string{"Test Product", "Other Product"},
|
||||||
|
"Reporters": []string{"Test User", "Other User"},
|
||||||
|
"Page": 1,
|
||||||
|
"TotalPages": 1,
|
||||||
|
}, user: adminUser},
|
||||||
|
|
||||||
|
"admin/tickets/detail": {data: map[string]interface{}{
|
||||||
|
"Ticket": map[string]interface{}{
|
||||||
|
"ID": ticketID,
|
||||||
|
"Title": "Test Ticket",
|
||||||
|
"Description": "A test description",
|
||||||
|
"Status": "open",
|
||||||
|
"Priority": "high",
|
||||||
|
"Pinned": false,
|
||||||
|
"Assignees": "Dev User",
|
||||||
|
"DueDate": &dueDate,
|
||||||
|
"Attachments": []map[string]interface{}{},
|
||||||
|
"ForgejoIssueNumber": int64(1),
|
||||||
|
"CreatedAt": now,
|
||||||
|
},
|
||||||
|
"User": *testUser,
|
||||||
|
"Repo": repo,
|
||||||
|
"Timeline": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"Type": "comment",
|
||||||
|
"Body": "A comment",
|
||||||
|
"AuthorName": "Dev",
|
||||||
|
"IsTeam": true,
|
||||||
|
"EventText": "",
|
||||||
|
"CreatedAt": now,
|
||||||
|
"Attachments": []map[string]interface{}{},
|
||||||
|
"CommentID": int64(100),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"RelatedIssues": []map[string]interface{}{},
|
||||||
|
"Mentions": map[string]string{},
|
||||||
|
}, user: adminUser},
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRenderer expects web/templates relative to cwd; chdir to project root.
|
||||||
|
if err := os.Chdir(filepath.Join("..", "..")); err != nil {
|
||||||
|
t.Fatalf("chdir to project root: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err := NewRenderer()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRenderer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, tc := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
pd := PageData{
|
||||||
|
User: tc.user,
|
||||||
|
CSRFToken: "test-csrf-token",
|
||||||
|
Data: tc.data,
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, ok := r.templates[name]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("template %q not found", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, pd); err != nil {
|
||||||
|
t.Errorf("template %q execution failed: %v", name, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
document.querySelectorAll('select[data-filter-nav]').forEach(function (sel) {
|
||||||
|
sel.addEventListener('change', function () {
|
||||||
|
window.location.href = this.value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -14,5 +14,6 @@
|
||||||
{{block "content" .}}{{end}}
|
{{block "content" .}}{{end}}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="/static/js/filters.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<span class="hidden ring-blue-400 bg-blue-50 border-blue-400 hover:text-red-500"></span>
|
<span class="hidden ring-blue-400 bg-blue-50 border-blue-400 hover:text-red-500"></span>
|
||||||
|
<script src="/static/js/filters.js"></script>
|
||||||
<script>
|
<script>
|
||||||
if (document.querySelector('pre.mermaid')) {
|
if (document.querySelector('pre.mermaid')) {
|
||||||
var s = document.createElement('script');
|
var s = document.createElement('script');
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,50 @@
|
||||||
<h1 class="text-2xl font-bold text-gray-900 mb-6">All Tickets</h1>
|
<h1 class="text-2xl font-bold text-gray-900 mb-6">All Tickets</h1>
|
||||||
|
|
||||||
{{with .Data}}
|
{{with .Data}}
|
||||||
<div class="mb-4 flex gap-2">
|
{{$filters := dict "status" .StatusFilter "priority" .PriorityFilter "product" .ProductFilter "reporter" .ReporterFilter}}
|
||||||
<a href="/admin/tickets" class="rounded-md px-3 py-1.5 text-sm font-medium {{if not .StatusFilter}}bg-gray-900 text-white{{else}}bg-white text-gray-700 ring-1 ring-gray-300 hover:bg-gray-50{{end}}">All</a>
|
{{$productFilter := .ProductFilter}}
|
||||||
<a href="/admin/tickets?status=open" class="rounded-md px-3 py-1.5 text-sm font-medium {{if eq .StatusFilter "open"}}bg-gray-900 text-white{{else}}bg-white text-gray-700 ring-1 ring-gray-300 hover:bg-gray-50{{end}}">Open</a>
|
{{$reporterFilter := .ReporterFilter}}
|
||||||
<a href="/admin/tickets?status=in_progress" class="rounded-md px-3 py-1.5 text-sm font-medium {{if eq .StatusFilter "in_progress"}}bg-gray-900 text-white{{else}}bg-white text-gray-700 ring-1 ring-gray-300 hover:bg-gray-50{{end}}">In Progress</a>
|
<div class="flex items-center gap-4 mb-4">
|
||||||
<a href="/admin/tickets?status=closed" class="rounded-md px-3 py-1.5 text-sm font-medium {{if eq .StatusFilter "closed"}}bg-gray-900 text-white{{else}}bg-white text-gray-700 ring-1 ring-gray-300 hover:bg-gray-50{{end}}">Closed</a>
|
<div class="flex items-center gap-1.5">
|
||||||
|
<label class="text-xs font-medium text-gray-500 uppercase">Status</label>
|
||||||
|
<select data-filter-nav class="rounded-md border-gray-300 text-sm py-1.5 pr-8">
|
||||||
|
<option value="{{filterURL "/admin/tickets" $filters "status" ""}}" {{if not .StatusFilter}}selected{{end}}>All</option>
|
||||||
|
<option value="{{filterURL "/admin/tickets" $filters "status" "open"}}" {{if eq .StatusFilter "open"}}selected{{end}}>Open</option>
|
||||||
|
<option value="{{filterURL "/admin/tickets" $filters "status" "in_progress"}}" {{if eq .StatusFilter "in_progress"}}selected{{end}}>In Progress</option>
|
||||||
|
<option value="{{filterURL "/admin/tickets" $filters "status" "closed"}}" {{if eq .StatusFilter "closed"}}selected{{end}}>Closed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<label class="text-xs font-medium text-gray-500 uppercase">Priority</label>
|
||||||
|
<select data-filter-nav class="rounded-md border-gray-300 text-sm py-1.5 pr-8">
|
||||||
|
<option value="{{filterURL "/admin/tickets" $filters "priority" ""}}" {{if not .PriorityFilter}}selected{{end}}>All</option>
|
||||||
|
<option value="{{filterURL "/admin/tickets" $filters "priority" "high"}}" {{if eq .PriorityFilter "high"}}selected{{end}}>High</option>
|
||||||
|
<option value="{{filterURL "/admin/tickets" $filters "priority" "medium"}}" {{if eq .PriorityFilter "medium"}}selected{{end}}>Medium</option>
|
||||||
|
<option value="{{filterURL "/admin/tickets" $filters "priority" "low"}}" {{if eq .PriorityFilter "low"}}selected{{end}}>Low</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{{if .Products}}
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<label class="text-xs font-medium text-gray-500 uppercase">Product</label>
|
||||||
|
<select data-filter-nav class="rounded-md border-gray-300 text-sm py-1.5 pr-8">
|
||||||
|
<option value="{{filterURL "/admin/tickets" $filters "product" ""}}" {{if not .ProductFilter}}selected{{end}}>All</option>
|
||||||
|
{{range .Products}}
|
||||||
|
<option value="{{filterURL "/admin/tickets" $filters "product" .}}" {{if eq $productFilter .}}selected{{end}}>{{.}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if .Reporters}}
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<label class="text-xs font-medium text-gray-500 uppercase">Reporter</label>
|
||||||
|
<select data-filter-nav class="rounded-md border-gray-300 text-sm py-1.5 pr-8">
|
||||||
|
<option value="{{filterURL "/admin/tickets" $filters "reporter" ""}}" {{if not .ReporterFilter}}selected{{end}}>All</option>
|
||||||
|
{{range .Reporters}}
|
||||||
|
<option value="{{filterURL "/admin/tickets" $filters "reporter" .}}" {{if eq $reporterFilter .}}selected{{end}}>{{.}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if .Tickets}}
|
{{if .Tickets}}
|
||||||
|
|
@ -45,8 +84,27 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{{if gt .TotalPages 1}}
|
||||||
|
<nav class="flex items-center justify-between mt-4 px-1">
|
||||||
|
<div>
|
||||||
|
{{if gt .Page 1}}
|
||||||
|
<a href="{{pageURL "/admin/tickets" $filters (sub .Page 1)}}" class="rounded-md px-3 py-1.5 text-sm font-medium bg-white text-gray-700 ring-1 ring-gray-300 hover:bg-gray-50">Previous</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-gray-500">Page {{.Page}} of {{.TotalPages}}</span>
|
||||||
|
<div>
|
||||||
|
{{if lt .Page .TotalPages}}
|
||||||
|
<a href="{{pageURL "/admin/tickets" $filters (add .Page 1)}}" class="rounded-md px-3 py-1.5 text-sm font-medium bg-white text-gray-700 ring-1 ring-gray-300 hover:bg-gray-50">Next</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
{{if or .StatusFilter .PriorityFilter .ProductFilter .ReporterFilter}}
|
||||||
|
<p class="text-sm text-gray-500">No tickets match the current filters.</p>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="text-sm text-gray-500">No tickets found.</p>
|
<p class="text-sm text-gray-500">No tickets found.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,40 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{with .Data}}
|
{{with .Data}}
|
||||||
|
{{$filters := dict "status" .StatusFilter "priority" .PriorityFilter "product" .ProductFilter}}
|
||||||
|
{{$productFilter := .ProductFilter}}
|
||||||
|
<div class="flex items-center gap-4 mb-4">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<label class="text-xs font-medium text-gray-500 uppercase">Status</label>
|
||||||
|
<select data-filter-nav class="rounded-md border-gray-300 text-sm py-1.5 pr-8">
|
||||||
|
<option value="{{filterURL "/tickets" $filters "status" ""}}" {{if not .StatusFilter}}selected{{end}}>All</option>
|
||||||
|
<option value="{{filterURL "/tickets" $filters "status" "open"}}" {{if eq .StatusFilter "open"}}selected{{end}}>Open</option>
|
||||||
|
<option value="{{filterURL "/tickets" $filters "status" "in_progress"}}" {{if eq .StatusFilter "in_progress"}}selected{{end}}>In Progress</option>
|
||||||
|
<option value="{{filterURL "/tickets" $filters "status" "closed"}}" {{if eq .StatusFilter "closed"}}selected{{end}}>Closed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<label class="text-xs font-medium text-gray-500 uppercase">Priority</label>
|
||||||
|
<select data-filter-nav class="rounded-md border-gray-300 text-sm py-1.5 pr-8">
|
||||||
|
<option value="{{filterURL "/tickets" $filters "priority" ""}}" {{if not .PriorityFilter}}selected{{end}}>All</option>
|
||||||
|
<option value="{{filterURL "/tickets" $filters "priority" "high"}}" {{if eq .PriorityFilter "high"}}selected{{end}}>High</option>
|
||||||
|
<option value="{{filterURL "/tickets" $filters "priority" "medium"}}" {{if eq .PriorityFilter "medium"}}selected{{end}}>Medium</option>
|
||||||
|
<option value="{{filterURL "/tickets" $filters "priority" "low"}}" {{if eq .PriorityFilter "low"}}selected{{end}}>Low</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{{if .Products}}
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<label class="text-xs font-medium text-gray-500 uppercase">Product</label>
|
||||||
|
<select data-filter-nav class="rounded-md border-gray-300 text-sm py-1.5 pr-8">
|
||||||
|
<option value="{{filterURL "/tickets" $filters "product" ""}}" {{if not .ProductFilter}}selected{{end}}>All</option>
|
||||||
|
{{range .Products}}
|
||||||
|
<option value="{{filterURL "/tickets" $filters "product" .}}" {{if eq $productFilter .}}selected{{end}}>{{.}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
{{if .Tickets}}
|
{{if .Tickets}}
|
||||||
<div class="overflow-hidden bg-white shadow ring-1 ring-gray-200 rounded-lg">
|
<div class="overflow-hidden bg-white shadow ring-1 ring-gray-200 rounded-lg">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
|
@ -39,10 +73,29 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{{if gt .TotalPages 1}}
|
||||||
|
<nav class="flex items-center justify-between mt-4 px-1">
|
||||||
|
<div>
|
||||||
|
{{if gt .Page 1}}
|
||||||
|
<a href="{{pageURL "/tickets" $filters (sub .Page 1)}}" class="rounded-md px-3 py-1.5 text-sm font-medium bg-white text-gray-700 ring-1 ring-gray-300 hover:bg-gray-50">Previous</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-gray-500">Page {{.Page}} of {{.TotalPages}}</span>
|
||||||
|
<div>
|
||||||
|
{{if lt .Page .TotalPages}}
|
||||||
|
<a href="{{pageURL "/tickets" $filters (add .Page 1)}}" class="rounded-md px-3 py-1.5 text-sm font-medium bg-white text-gray-700 ring-1 ring-gray-300 hover:bg-gray-50">Next</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
{{end}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="text-center py-12 bg-white rounded-lg shadow ring-1 ring-gray-200">
|
<div class="text-center py-12 bg-white rounded-lg shadow ring-1 ring-gray-200">
|
||||||
|
{{if or .StatusFilter .PriorityFilter .ProductFilter}}
|
||||||
|
<p class="text-gray-500">No tickets match the current filters.</p>
|
||||||
|
{{else}}
|
||||||
<p class="text-gray-500">No tickets yet.</p>
|
<p class="text-gray-500">No tickets yet.</p>
|
||||||
<a href="/tickets/new" class="mt-4 inline-block text-sm font-medium text-blue-600 hover:text-blue-500">Create your first ticket</a>
|
<a href="/tickets/new" class="mt-4 inline-block text-sm font-medium text-blue-600 hover:text-blue-500">Create your first ticket</a>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue