Skip to content

add filter by author/assignee in /issues #25979 #26661

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions models/issues/issue_stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const (
FilterModeMention
FilterModeReviewRequested
FilterModeReviewed
FilterModeSearch
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it should be a filter mode since this filter can combind with other filters. It's just standalone filters.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need some way to tell getUserIssueStats func that poster/assignee filter is applied, otherwise it will put doerID in everything and the count(open/closed/total) will be incorrect

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

even if we pass opts with correct data, it wont apply without the filtermode
https://github.com/go-gitea/gitea/blob/main/routers/web/user/home.go#L792-L806

FilterModeYourRepositories
)

Expand Down
17 changes: 11 additions & 6 deletions models/repo/user_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,12 +163,17 @@ func GetIssuePostersWithSearch(ctx context.Context, repo *Repository, isPull boo
if isShowFullName {
prefixCond = prefixCond.Or(builder.Like{"full_name", "%" + search + "%"})
}

cond := builder.In("`user`.id",
builder.Select("poster_id").From("issue").Where(
builder.Eq{"repo_id": repo.ID}.
And(builder.Eq{"is_pull": isPull}),
).GroupBy("poster_id")).And(prefixCond)
var cond builder.Cond
if repo != nil {
cond = builder.In("`user`.id",
builder.Select("poster_id").From("issue").Where(
builder.Eq{"repo_id": repo.ID}.
And(builder.Eq{"is_pull": isPull}),
).GroupBy("poster_id")).And(prefixCond)
} else {
cond = builder.In("`user`.id",
builder.Select("poster_id").From("issue").GroupBy("poster_id")).And(prefixCond)
}

return users, db.GetEngine(ctx).
Where(cond).
Expand Down
38 changes: 24 additions & 14 deletions routers/web/user/home.go
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,8 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
filterMode = issues_model.FilterModeReviewRequested
case "reviewed_by":
filterMode = issues_model.FilterModeReviewed
case "search":
filterMode = issues_model.FilterModeSearch
case "your_repositories":
fallthrough
default:
Expand Down Expand Up @@ -495,6 +497,18 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
opts.AllPublic = true
}

// Get filter by author id
filterByAuthorID, errorParsingAuthorID := strconv.ParseInt(ctx.FormString("author"), 10, 64)
if errorParsingAuthorID != nil {
filterByAuthorID = 0
}

// Get filter by assignee id
filterByAssigneeID, errorParsingAssigneeID := strconv.ParseInt(ctx.FormString("assignee"), 10, 64)
if errorParsingAssigneeID != nil {
filterByAssigneeID = 0
}

switch filterMode {
case issues_model.FilterModeAll:
case issues_model.FilterModeYourRepositories:
Expand All @@ -508,6 +522,9 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
opts.ReviewRequestedID = ctx.Doer.ID
case issues_model.FilterModeReviewed:
opts.ReviewedID = ctx.Doer.ID
case issues_model.FilterModeSearch:
opts.PosterID = filterByAuthorID
opts.AssigneeID = filterByAssigneeID
}

// keyword holds the search term entered into the search field.
Expand Down Expand Up @@ -625,6 +642,8 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
ctx.Data["SortType"] = sortType
ctx.Data["IsShowClosed"] = isShowClosed
ctx.Data["SelectLabels"] = selectedLabels
ctx.Data["FilterByAuthorID"] = filterByAuthorID
ctx.Data["FilterByAssigneeID"] = filterByAssigneeID

if isShowClosed {
ctx.Data["State"] = "closed"
Expand All @@ -639,7 +658,8 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
pager.AddParam(ctx, "state", "State")
pager.AddParam(ctx, "labels", "SelectLabels")
pager.AddParam(ctx, "milestone", "MilestoneID")
pager.AddParam(ctx, "assignee", "AssigneeID")
pager.AddParam(ctx, "assignee", "FilterByAssigneeID")
pager.AddParam(ctx, "author", "FilterByAuthorID")
ctx.Data["Page"] = pager

ctx.HTML(http.StatusOK, tplIssues)
Expand Down Expand Up @@ -764,19 +784,6 @@ func UsernameSubRoute(ctx *context.Context) {
func getUserIssueStats(ctx *context.Context, ctxUser *user_model.User, filterMode int, opts *issue_indexer.SearchOptions) (*issues_model.IssueStats, error) {
doerID := ctx.Doer.ID

opts = opts.Copy(func(o *issue_indexer.SearchOptions) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why remove these code?

// If the doer is the same as the context user, which means the doer is viewing his own dashboard,
// it's not enough to show the repos that the doer owns or has been explicitly granted access to,
// because the doer may create issues or be mentioned in any public repo.
// So we need search issues in all public repos.
o.AllPublic = doerID == ctxUser.ID
o.AssigneeID = nil
o.PosterID = nil
o.MentionID = nil
o.ReviewRequestedID = nil
o.ReviewedID = nil
})

var (
err error
ret = &issues_model.IssueStats{}
Expand All @@ -799,6 +806,9 @@ func getUserIssueStats(ctx *context.Context, ctxUser *user_model.User, filterMod
openClosedOpts.ReviewRequestedID = &doerID
case issues_model.FilterModeReviewed:
openClosedOpts.ReviewedID = &doerID
case issues_model.FilterModeSearch:
openClosedOpts.PosterID = opts.PosterID
openClosedOpts.AssigneeID = opts.AssigneeID
}
openClosedOpts.IsClosed = util.OptionalBoolFalse
ret.OpenCount, err = issue_indexer.CountIssues(ctx, openClosedOpts)
Expand Down
7 changes: 6 additions & 1 deletion routers/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -495,10 +495,15 @@ func registerRoutes(m *web.Route) {
}, ignExploreSignIn)
m.Group("/issues", func() {
m.Get("", user.Issues)
m.Get("/posters", repo.IssuePosters)
m.Get("/filter", user.Issues)
m.Get("/search", repo.SearchIssues)
}, reqSignIn)

m.Get("/pulls", reqSignIn, user.Pulls)
m.Group("/pulls", func() {
m.Get("", user.Pulls)
m.Get("/posters", repo.IssuePosters)
}, reqSignIn)
m.Get("/milestones", reqSignIn, reqMilestonesDashboardPageEnabled, user.Milestones)

// ***** START: User *****
Expand Down
46 changes: 44 additions & 2 deletions templates/user/dashboard/issues.tmpl
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content dashboard issues">
{{template "user/dashboard/navbar" .}}

<div class="ui container">
<div class="flex-container">
<div class="flex-container-nav">
Expand Down Expand Up @@ -36,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="{{.Link}}?type={{$.ViewType}}&sort={{$.SortType}}&state=open&q={{$.Keyword}}">
<a class="item{{if not .IsShowClosed}} active{{end}}" href="{{.Link}}?type={{$.ViewType}}&sort={{$.SortType}}&state=open&q={{$.Keyword}}&assignee={{$.FilterByAssigneeID}}&author={{$.FilterByAuthorID}}">
{{svg "octicon-issue-opened" 16 "gt-mr-3"}}
{{ctx.Locale.PrettyNumber .IssueStats.OpenCount}}&nbsp;{{ctx.Locale.Tr "repo.issues.open_title"}}
</a>
<a class="item{{if .IsShowClosed}} active{{end}}" href="{{.Link}}?type={{$.ViewType}}&sort={{$.SortType}}&state=closed&q={{$.Keyword}}">
<a class="item{{if .IsShowClosed}} active{{end}}" href="{{.Link}}?type={{$.ViewType}}&sort={{$.SortType}}&state=closed&q={{$.Keyword}}&assignee={{$.FilterByAssigneeID}}&author={{$.FilterByAuthorID}}">
{{svg "octicon-issue-closed" 16 "gt-mr-3"}}
{{ctx.Locale.PrettyNumber .IssueStats.ClosedCount}}&nbsp;{{ctx.Locale.Tr "repo.issues.closed_title"}}
</a>
Expand Down Expand Up @@ -72,7 +73,48 @@
<a class="{{if eq .SortType "farduedate"}}active {{end}}item" href="{{$.Link}}?type={{$.ViewType}}&sort=farduedate&state={{$.State}}&q={{$.Keyword}}">{{ctx.Locale.Tr "repo.issues.filter_sort.farduedate"}}</a>
</div>
</div>
<div id="issue-filters" class="issue-list-toolbar-right">
<div class="ui secondary filter menu labels">
<!-- Author -->
<div class="ui dropdown jump item user-remote-search" data-tooltip-content="{{ctx.Locale.Tr "repo.author_search_tooltip"}}"
data-search-url="{{$.Link}}/posters"
data-selected-user-id="{{$.PosterID}}"
data-action-jump-url="{{$.Link}}?type=search&sort={{$.SortType}}&state={{$.State}}&labels={{$.SelectLabels}}&assignee={{$.FilterByAssigneeID}}&author={user_id}"
>
<span class="text">
{{ctx.Locale.Tr "repo.issues.filter_poster"}}
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
</span>
<div class="menu">
<div class="ui icon search input">
<i class="icon gt-df gt-ac gt-jc">{{svg "octicon-search" 16}}</i>
<input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_poster"}}">
</div>
<a class="item" data-value="0">{{ctx.Locale.Tr "repo.issues.filter_poster_no_select"}}</a>
</div>
</div>
<!-- Assignee -->
<div class="ui dropdown jump item user-remote-search" data-tooltip-content="{{ctx.Locale.Tr "repo.author_search_tooltip"}}"
data-search-url="{{$.Link}}/posters"
data-selected-user-id="{{$.PosterID}}"
data-action-jump-url="{{$.Link}}?type=search&sort={{$.SortType}}&state={{$.State}}&labels={{$.SelectLabels}}&assignee={user_id}&author={{$.FilterByAuthorID}}"
>
<span class="text">
{{ctx.Locale.Tr "repo.issues.filter_assignee"}}
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
</span>
<div class="menu">
<div class="ui icon search input">
<i class="icon gt-df gt-ac gt-jc">{{svg "octicon-search" 16}}</i>
<input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_assignee"}}">
</div>
<a class="item" data-value="0">{{ctx.Locale.Tr "repo.issues.filter_assginee_no_select"}}</a>
</div>
</div>
</div>
</div>
</div>

{{template "shared/issuelist" dict "." . "listType" "dashboard"}}
</div>
</div>
Expand Down
116 changes: 60 additions & 56 deletions web_src/js/features/repo-issue-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,62 +82,66 @@ function initRepoIssueListCheckboxes() {
});
}

function initRepoIssueListAuthorDropdown() {
const $searchDropdown = $('.user-remote-search');
if (!$searchDropdown.length) return;

let searchUrl = $searchDropdown.attr('data-search-url');
const actionJumpUrl = $searchDropdown.attr('data-action-jump-url');
const selectedUserId = $searchDropdown.attr('data-selected-user-id');
if (!searchUrl.includes('?')) searchUrl += '?';

$searchDropdown.dropdown('setting', {
fullTextSearch: true,
selectOnKeydown: false,
apiSettings: {
cache: false,
url: `${searchUrl}&q={query}`,
onResponse(resp) {
// the content is provided by backend IssuePosters handler
const processedResults = []; // to be used by dropdown to generate menu items
for (const item of resp.results) {
let html = `<img class="ui avatar gt-vm" src="${htmlEscape(item.avatar_link)}" aria-hidden="true" alt="" width="20" height="20"><span class="gt-ellipsis">${htmlEscape(item.username)}</span>`;
if (item.full_name) html += `<span class="search-fullname gt-ml-3">${htmlEscape(item.full_name)}</span>`;
processedResults.push({value: item.user_id, name: html});
}
resp.results = processedResults;
return resp;
function initRepoIssueListUserDropdowns() {
const userDropdowns = document.getElementsByClassName('user-remote-search');
if (!userDropdowns.length) return;

for (let i = 0; i < userDropdowns.length; i++) {
const $searchDropdown = $(userDropdowns[i]);

let searchUrl = $searchDropdown.attr('data-search-url');
const actionJumpUrl = $searchDropdown.attr('data-action-jump-url');
const selectedUserId = $searchDropdown.attr('data-selected-user-id');
if (!searchUrl.includes('?')) searchUrl += '?';

$searchDropdown.dropdown('setting', {
fullTextSearch: true,
selectOnKeydown: false,
apiSettings: {
cache: false,
url: `${searchUrl}&q={query}`,
onResponse(resp) {
// the content is provided by backend IssuePosters handler
const processedResults = []; // to be used by dropdown to generate menu items
for (const item of resp.results) {
let html = `<img class="ui avatar gt-vm" src="${htmlEscape(item.avatar_link)}" aria-hidden="true" alt="" width="20" height="20"><span class="gt-ellipsis">${htmlEscape(item.username)}</span>`;
if (item.full_name) html += `<span class="search-fullname gt-ml-3">${htmlEscape(item.full_name)}</span>`;
processedResults.push({value: item.user_id, name: html});
}
resp.results = processedResults;
return resp;
},
},
},
action: (_text, value) => {
window.location.href = actionJumpUrl.replace('{user_id}', encodeURIComponent(value));
},
onShow: () => {
$searchDropdown.dropdown('filter', ' '); // trigger a search on first show
},
});
action: (_text, value) => {
window.location.href = actionJumpUrl.replace('{user_id}', encodeURIComponent(value));
},
onShow: () => {
$searchDropdown.dropdown('filter', ' '); // trigger a search on first show
},
});

// we want to generate the dropdown menu items by ourselves, replace its internal setup functions
const dropdownSetup = {...$searchDropdown.dropdown('internal', 'setup')};
const dropdownTemplates = $searchDropdown.dropdown('setting', 'templates');
$searchDropdown.dropdown('internal', 'setup', dropdownSetup);
dropdownSetup.menu = function (values) {
const $menu = $searchDropdown.find('> .menu');
$menu.find('> .dynamic-item').remove(); // remove old dynamic items

const newMenuHtml = dropdownTemplates.menu(values, $searchDropdown.dropdown('setting', 'fields'), true /* html */, $searchDropdown.dropdown('setting', 'className'));
if (newMenuHtml) {
const $newMenuItems = $(newMenuHtml);
$newMenuItems.addClass('dynamic-item');
$menu.append('<div class="divider dynamic-item"></div>', ...$newMenuItems);
}
$searchDropdown.dropdown('refresh');
// defer our selection to the next tick, because dropdown will set the selection item after this `menu` function
setTimeout(() => {
$menu.find('.item.active, .item.selected').removeClass('active selected');
$menu.find(`.item[data-value="${selectedUserId}"]`).addClass('selected');
}, 0);
};
// we want to generate the dropdown menu items by ourselves, replace its internal setup functions
const dropdownSetup = {...$searchDropdown.dropdown('internal', 'setup')};
const dropdownTemplates = $searchDropdown.dropdown('setting', 'templates');
$searchDropdown.dropdown('internal', 'setup', dropdownSetup);
dropdownSetup.menu = function (values) {
const $menu = $searchDropdown.find('> .menu');
$menu.find('> .dynamic-item').remove(); // remove old dynamic items

const newMenuHtml = dropdownTemplates.menu(values, $searchDropdown.dropdown('setting', 'fields'), true /* html */, $searchDropdown.dropdown('setting', 'className'));
if (newMenuHtml) {
const $newMenuItems = $(newMenuHtml);
$newMenuItems.addClass('dynamic-item');
$menu.append('<div class="divider dynamic-item"></div>', ...$newMenuItems);
}
$searchDropdown.dropdown('refresh');
// defer our selection to the next tick, because dropdown will set the selection item after this `menu` function
setTimeout(() => {
$menu.find('.item.active, .item.selected').removeClass('active selected');
$menu.find(`.item[data-value="${selectedUserId}"]`).addClass('selected');
}, 0);
};
}
}

function initPinRemoveButton() {
Expand Down Expand Up @@ -222,9 +226,9 @@ function initArchivedLabelFilter() {
}

export function initRepoIssueList() {
if (!document.querySelectorAll('.page-content.repository.issue-list, .page-content.repository.milestone-issue-list').length) return;
if (!document.querySelectorAll('.page-content.repository.issue-list, .page-content.repository.milestone-issue-list, .page-content.dashboard.issues').length) return;
initRepoIssueListCheckboxes();
initRepoIssueListAuthorDropdown();
initRepoIssueListUserDropdowns();
initIssuePinSort();
initArchivedLabelFilter();
}