Merge pull request '[FEAT] Add label filters in organization issues dashboard' (#2944) from iminfinity/forgejo:add/label-filters into forgejo

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/2944
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
This commit is contained in:
Earl Warren 2024-04-04 16:58:12 +00:00
commit 4ccb8c8b1f
7 changed files with 169 additions and 59 deletions

View file

@ -538,6 +538,36 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
}
}
if org != nil {
// Get Org Labels
labels, err := issues_model.GetLabelsByOrgID(ctx, ctx.Org.Organization.ID, ctx.FormString("sort"), db.ListOptions{})
if err != nil {
ctx.ServerError("GetLabelsByOrgID", err)
return
}
// Get the exclusive scope for every label ID
labelExclusiveScopes := make([]string, 0, len(opts.LabelIDs))
for _, labelID := range opts.LabelIDs {
foundExclusiveScope := false
for _, label := range labels {
if label.ID == labelID || label.ID == -labelID {
labelExclusiveScopes = append(labelExclusiveScopes, label.ExclusiveScope())
foundExclusiveScope = true
break
}
}
if !foundExclusiveScope {
labelExclusiveScopes = append(labelExclusiveScopes, "")
}
}
for _, l := range labels {
l.LoadSelectedLabelsAfterClick(opts.LabelIDs, labelExclusiveScopes)
}
ctx.Data["Labels"] = labels
}
// ------------------------------
// Get issues as defined by opts.
// ------------------------------
@ -621,6 +651,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
ctx.Data["SortType"] = sortType
ctx.Data["IsShowClosed"] = isShowClosed
ctx.Data["SelectLabels"] = selectedLabels
ctx.Data["PageIsOrgIssues"] = org != nil
if isShowClosed {
ctx.Data["State"] = "closed"

View file

@ -8,6 +8,7 @@ import (
"testing"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/setting"
@ -130,3 +131,36 @@ func TestDashboardPagination(t *testing.T) {
assert.NoError(t, err)
assert.Contains(t, out, `<a class=" item navigation" href="/?page=2">`)
}
func TestOrgLabels(t *testing.T) {
assert.NoError(t, unittest.LoadFixtures())
ctx, _ := contexttest.MockContext(t, "org/org3/issues")
contexttest.LoadUser(t, ctx, 2)
contexttest.LoadOrganization(t, ctx, 3)
Issues(ctx)
assert.EqualValues(t, http.StatusOK, ctx.Resp.Status())
assert.True(t, ctx.Data["PageIsOrgIssues"].(bool))
orgLabels := []struct {
ID int64
OrgID int64
Name string
}{
{3, 3, "orglabel3"},
{4, 3, "orglabel4"},
}
labels, ok := ctx.Data["Labels"].([]*issues_model.Label)
assert.True(t, ok)
if assert.Len(t, labels, len(orgLabels)) {
for i, label := range labels {
assert.EqualValues(t, orgLabels[i].OrgID, label.OrgID)
assert.EqualValues(t, orgLabels[i].ID, label.ID)
assert.EqualValues(t, orgLabels[i].Name, label.Name)
}
}
}

View file

@ -15,6 +15,7 @@ import (
"testing"
"time"
org_model "code.gitea.io/gitea/models/organization"
access_model "code.gitea.io/gitea/models/perm/access"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
@ -146,6 +147,19 @@ func LoadUser(t *testing.T, ctx gocontext.Context, userID int64) {
}
}
// LoadOrganization load an org into a test context
func LoadOrganization(t *testing.T, ctx gocontext.Context, orgID int64) {
org := unittest.AssertExistsAndLoadBean(t, &org_model.Organization{ID: orgID})
switch ctx := ctx.(type) {
case *context.Context:
ctx.Org.Organization = org
case *context.APIContext:
ctx.Org.Organization = org
default:
assert.FailNow(t, "context is not *context.Context or *context.APIContext")
}
}
// LoadGitRepo load a git repo into a test context. Requires that ctx.Repo has
// already been populated.
func LoadGitRepo(t *testing.T, ctx *context.Context) {

View file

@ -1,53 +1,5 @@
<!-- Label -->
<div class="ui {{if not .Labels}}disabled{{end}} dropdown jump item label-filter">
<span class="text">
{{ctx.Locale.Tr "repo.issues.filter_label"}}
</span>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu">
<div class="ui icon search input">
<i class="icon">{{svg "octicon-search" 16}}</i>
<input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_label"}}">
</div>
<div class="ui checkbox compact archived-label-filter">
<input name="archived" type="checkbox"
id="archived-filter-checkbox"
{{if .ShowArchivedLabels}}checked{{end}}
>
<label for="archived-filter-checkbox">
{{ctx.Locale.Tr "repo.issues.label_archived_filter"}}
<i class="tw-ml-1" data-tooltip-content={{ctx.Locale.Tr "repo.issues.label_archive_tooltip"}}>
{{svg "octicon-info"}}
</i>
</label>
</div>
<span class="info">{{ctx.Locale.Tr "repo.issues.filter_label_exclude"}}</span>
<div class="divider"></div>
<a rel="nofollow" class="{{if .AllLabels}}active selected {{end}}item" href="?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&milestone={{$.MilestoneID}}&project={{$.ProjectID}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}{{if $.ShowArchivedLabels}}&archived=true{{end}}">{{ctx.Locale.Tr "repo.issues.filter_label_no_select"}}</a>
<a rel="nofollow" class="{{if .NoLabel}}active selected {{end}}item" href="?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels=0&milestone={{$.MilestoneID}}&project={{$.ProjectID}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}{{if $.ShowArchivedLabels}}&archived=true{{end}}">{{ctx.Locale.Tr "repo.issues.filter_label_select_no_label"}}</a>
{{$previousExclusiveScope := "_no_scope"}}
{{range .Labels}}
{{$exclusiveScope := .ExclusiveScope}}
{{if and (ne $previousExclusiveScope $exclusiveScope)}}
<div class="divider"></div>
{{end}}
{{$previousExclusiveScope = $exclusiveScope}}
<a rel="nofollow" class="item label-filter-item tw-flex tw-items-center" {{if .IsArchived}}data-is-archived{{end}} href="?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels={{.QueryString}}&milestone={{$.MilestoneID}}&project={{$.ProjectID}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}{{if $.ShowArchivedLabels}}&archived=true{{end}}" data-label-id="{{.ID}}">
{{if .IsExcluded}}
{{svg "octicon-circle-slash"}}
{{else if .IsSelected}}
{{if $exclusiveScope}}
{{svg "octicon-dot-fill"}}
{{else}}
{{svg "octicon-check"}}
{{end}}
{{end}}
{{RenderLabel $.Context ctx.Locale .}}
<p class="tw-ml-auto">{{template "repo/issue/labels/label_archived" .}}</p>
</a>
{{end}}
</div>
</div>
{{template "shared/label_filter" .}}
{{if not .Milestone}}
<!-- Milestone -->

View file

@ -0,0 +1,50 @@
<!-- Label -->
<div class="ui {{if not .Labels}}disabled{{end}} dropdown jump item label-filter">
<span class="text">
{{ctx.Locale.Tr "repo.issues.filter_label"}}
</span>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu">
<div class="ui icon search input">
<i class="icon">{{svg "octicon-search" 16}}</i>
<input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_label"}}">
</div>
<div class="ui checkbox compact archived-label-filter">
<input name="archived" type="checkbox"
id="archived-filter-checkbox"
{{if .ShowArchivedLabels}}checked{{end}}
>
<label for="archived-filter-checkbox">
{{ctx.Locale.Tr "repo.issues.label_archived_filter"}}
<i class="tw-ml-1" data-tooltip-content={{ctx.Locale.Tr "repo.issues.label_archive_tooltip"}}>
{{svg "octicon-info"}}
</i>
</label>
</div>
<span class="info">{{ctx.Locale.Tr "repo.issues.filter_label_exclude"}}</span>
<div class="divider"></div>
<a rel="nofollow" class="{{if .AllLabels}}active selected {{end}}item" href="?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&milestone={{$.MilestoneID}}&project={{$.ProjectID}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}{{if $.ShowArchivedLabels}}&archived=true{{end}}">{{ctx.Locale.Tr "repo.issues.filter_label_no_select"}}</a>
<a rel="nofollow" class="{{if .NoLabel}}active selected {{end}}item" href="?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&state={{$.State}}&labels=0&milestone={{$.MilestoneID}}&project={{$.ProjectID}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}{{if $.ShowArchivedLabels}}&archived=true{{end}}">{{ctx.Locale.Tr "repo.issues.filter_label_select_no_label"}}</a>
{{$previousExclusiveScope := "_no_scope"}}
{{range .Labels}}
{{$exclusiveScope := .ExclusiveScope}}
{{if and (ne $previousExclusiveScope $exclusiveScope)}}
<div class="divider"></div>
{{end}}
{{$previousExclusiveScope = $exclusiveScope}}
<a rel="nofollow" class="item label-filter-item tw-flex tw-items-center" {{if .IsArchived}}data-is-archived{{end}} href="?q={{$.Keyword}}&type={{$.ViewType}}&sort={{$.SortType}}&labels={{.QueryString}}&state={{$.State}}&milestone={{$.MilestoneID}}&project={{$.ProjectID}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}{{if $.ShowArchivedLabels}}&archived=true{{end}}" data-label-id="{{.ID}}">
{{if .IsExcluded}}
{{svg "octicon-circle-slash"}}
{{else if .IsSelected}}
{{if $exclusiveScope}}
{{svg "octicon-dot-fill"}}
{{else}}
{{svg "octicon-check"}}
{{end}}
{{end}}
{{RenderLabel $.Context ctx.Locale .}}
<p class="tw-ml-auto">{{template "repo/issue/labels/label_archived" .}}</p>
</a>
{{end}}
</div>
</div>

View file

@ -37,11 +37,11 @@
<div class="flex-container-main content">
<div class="list-header">
<div class="small-menu-items ui compact tiny menu list-header-toggle">
<a class="item{{if not .IsShowClosed}} active{{end}}" href="?type={{$.ViewType}}&sort={{$.SortType}}&state=open&q={{$.Keyword}}">
<a class="item{{if not .IsShowClosed}} active{{end}}" href="?type={{$.ViewType}}&sort={{$.SortType}}&state=open&labels={{.SelectLabels}}&q={{$.Keyword}}">
{{svg "octicon-issue-opened" 16 "tw-mr-2"}}
{{ctx.Locale.PrettyNumber .IssueStats.OpenCount}}&nbsp;{{ctx.Locale.Tr "repo.issues.open_title"}}
</a>
<a class="item{{if .IsShowClosed}} active{{end}}" href="?type={{$.ViewType}}&sort={{$.SortType}}&state=closed&q={{$.Keyword}}">
<a class="item{{if .IsShowClosed}} active{{end}}" href="?type={{$.ViewType}}&sort={{$.SortType}}&state=closed&labels={{.SelectLabels}}&q={{$.Keyword}}">
{{svg "octicon-issue-closed" 16 "tw-mr-2"}}
{{ctx.Locale.PrettyNumber .IssueStats.ClosedCount}}&nbsp;{{ctx.Locale.Tr "repo.issues.closed_title"}}
</a>
@ -56,6 +56,10 @@
{{template "shared/search/button"}}
</div>
</form>
<!-- Label -->
{{if .PageIsOrgIssues}}
{{template "shared/label_filter" .}}
{{end}}
<!-- Sort -->
<div class="list-header-sort ui small dropdown type jump item">
<span class="text tw-whitespace-nowrap">
@ -63,14 +67,14 @@
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
</span>
<div class="menu">
<a class="{{if eq .SortType "recentupdate"}}active {{end}}item" href="?type={{$.ViewType}}&sort=recentupdate&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.issues.filter_sort.recentupdate"}}</a>
<a class="{{if eq .SortType "leastupdate"}}active {{end}}item" href="?type={{$.ViewType}}&sort=leastupdate&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.issues.filter_sort.leastupdate"}}</a>
<a class="{{if or (eq .SortType "latest") (not .SortType)}}active {{end}}item" href="?type={{$.ViewType}}&sort=latest&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.issues.filter_sort.latest"}}</a>
<a class="{{if eq .SortType "oldest"}}active {{end}}item" href="?type={{$.ViewType}}&sort=oldest&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.issues.filter_sort.oldest"}}</a>
<a class="{{if eq .SortType "mostcomment"}}active {{end}}item" href="?type={{$.ViewType}}&sort=mostcomment&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.issues.filter_sort.mostcomment"}}</a>
<a class="{{if eq .SortType "leastcomment"}}active {{end}}item" href="?type={{$.ViewType}}&sort=leastcomment&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.issues.filter_sort.leastcomment"}}</a>
<a class="{{if eq .SortType "nearduedate"}}active {{end}}item" href="?type={{$.ViewType}}&sort=nearduedate&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.issues.filter_sort.nearduedate"}}</a>
<a class="{{if eq .SortType "farduedate"}}active {{end}}item" href="?type={{$.ViewType}}&sort=farduedate&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.issues.filter_sort.farduedate"}}</a>
<a class="{{if eq .SortType "recentupdate"}}active {{end}}item" href="?type={{$.ViewType}}&sort=recentupdate&state={{$.State}}&labels={{.SelectLabels}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.issues.filter_sort.recentupdate"}}</a>
<a class="{{if eq .SortType "leastupdate"}}active {{end}}item" href="?type={{$.ViewType}}&sort=leastupdate&state={{$.State}}&labels={{.SelectLabels}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.issues.filter_sort.leastupdate"}}</a>
<a class="{{if or (eq .SortType "latest") (not .SortType)}}active {{end}}item" href="?type={{$.ViewType}}&sort=latest&state={{$.State}}&labels={{.SelectLabels}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.issues.filter_sort.latest"}}</a>
<a class="{{if eq .SortType "oldest"}}active {{end}}item" href="?type={{$.ViewType}}&sort=oldest&state={{$.State}}&labels={{.SelectLabels}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.issues.filter_sort.oldest"}}</a>
<a class="{{if eq .SortType "mostcomment"}}active {{end}}item" href="?type={{$.ViewType}}&sort=mostcomment&state={{$.State}}&labels={{.SelectLabels}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.issues.filter_sort.mostcomment"}}</a>
<a class="{{if eq .SortType "leastcomment"}}active {{end}}item" href="?type={{$.ViewType}}&sort=leastcomment&state={{$.State}}&labels={{.SelectLabels}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.issues.filter_sort.leastcomment"}}</a>
<a class="{{if eq .SortType "nearduedate"}}active {{end}}item" href="?type={{$.ViewType}}&sort=nearduedate&state={{$.State}}&labels={{.SelectLabels}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.issues.filter_sort.nearduedate"}}</a>
<a class="{{if eq .SortType "farduedate"}}active {{end}}item" href="?type={{$.ViewType}}&sort=farduedate&state={{$.State}}&labels={{.SelectLabels}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.issues.filter_sort.farduedate"}}</a>
</div>
</div>
</div>

View file

@ -222,3 +222,28 @@ func TestTeamSearch(t *testing.T) {
req.Header.Add("X-Csrf-Token", csrf)
session.MakeRequest(t, req, http.StatusNotFound)
}
func TestOrgDashboardLabels(t *testing.T) {
defer tests.PrepareTestEnv(t)()
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3, Type: user_model.UserTypeOrganization})
session := loginUser(t, user.Name)
req := NewRequestf(t, "GET", "/org/%s/issues?labels=3,4", org.Name)
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
labelFilterHref, ok := htmlDoc.Find(".list-header-sort a").Attr("href")
assert.True(t, ok)
assert.Contains(t, labelFilterHref, "labels=3%2c4")
// Exclude label
req = NewRequestf(t, "GET", "/org/%s/issues?labels=3,-4", org.Name)
resp = session.MakeRequest(t, req, http.StatusOK)
htmlDoc = NewHTMLParser(t, resp.Body)
labelFilterHref, ok = htmlDoc.Find(".list-header-sort a").Attr("href")
assert.True(t, ok)
assert.Contains(t, labelFilterHref, "labels=3%2c-4")
}