From 90d20be5411a25e6e5e3ac25990265445ec71bc0 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Tue, 10 Dec 2024 11:38:22 +0800 Subject: [PATCH] Refactor issue filter (labels, poster, assignee) (#32771) Rewrite a lot of legacy strange code, remove duplicate code, remove jquery, and make these filters reusable. Let's forget the old code, new code affects: * issue list open/close switch * issue list filter (label, author, assignee) * milestone list open/close switch * milestone issue list filter (label, author, assignee) * project view (label, assignee) --- modules/templates/helper.go | 13 +- options/locale/locale_en-US.ini | 5 +- routers/web/org/projects.go | 7 +- routers/web/repo/issue_list.go | 29 ++--- routers/web/repo/milestone.go | 6 - routers/web/repo/projects.go | 7 +- templates/projects/view.tmpl | 84 ++----------- templates/repo/issue/filter_item_label.tmpl | 45 +++++++ .../repo/issue/filter_item_user_assign.tmpl | 31 +++++ .../repo/issue/filter_item_user_fetch.tmpl | 23 ++++ templates/repo/issue/filter_list.tmpl | 111 ++++-------------- templates/repo/issue/openclose.tmpl | 25 ++-- web_src/css/base.css | 5 +- web_src/css/repo.css | 36 +++--- web_src/css/repo/issue-list.css | 6 +- web_src/js/features/repo-issue-list.ts | 78 +++++------- web_src/js/features/repo-issue.ts | 98 +++++++++++----- web_src/js/index.ts | 4 +- 18 files changed, 293 insertions(+), 320 deletions(-) create mode 100644 templates/repo/issue/filter_item_label.tmpl create mode 100644 templates/repo/issue/filter_item_user_assign.tmpl create mode 100644 templates/repo/issue/filter_item_user_fetch.tmpl diff --git a/modules/templates/helper.go b/modules/templates/helper.go index e262892069..ff9673ccef 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -42,7 +42,7 @@ func NewFuncMap() template.FuncMap { "HTMLFormat": htmlutil.HTMLFormat, "HTMLEscape": htmlEscape, "QueryEscape": queryEscape, - "QueryBuild": queryBuild, + "QueryBuild": QueryBuild, "JSEscape": jsEscapeSafe, "SanitizeHTML": SanitizeHTML, "URLJoin": util.URLJoin, @@ -294,24 +294,27 @@ func timeEstimateString(timeSec any) string { return util.TimeEstimateString(v) } -func queryBuild(a ...any) template.URL { +// QueryBuild builds a query string from a list of key-value pairs. +// It omits the nil and empty strings, but it doesn't omit other zero values, +// because the zero value of number types may have a meaning. +func QueryBuild(a ...any) template.URL { var s string if len(a)%2 == 1 { if v, ok := a[0].(string); ok { if v == "" || (v[0] != '?' && v[0] != '&') { - panic("queryBuild: invalid argument") + panic("QueryBuild: invalid argument") } s = v } else if v, ok := a[0].(template.URL); ok { s = string(v) } else { - panic("queryBuild: invalid argument") + panic("QueryBuild: invalid argument") } } for i := len(a) % 2; i < len(a); i += 2 { k, ok := a[i].(string) if !ok { - panic("queryBuild: invalid argument") + panic("QueryBuild: invalid argument") } var v string if va, ok := a[i+1].(string); ok { diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 1c56dce822..f50ad1f298 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1109,7 +1109,7 @@ delete_preexisting_success = Deleted unadopted files in %s blame_prior = View blame prior to this change blame.ignore_revs = Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view. blame.ignore_revs.failed = Failed to ignore revisions in .git-blame-ignore-revs. -author_search_tooltip = Shows a maximum of 30 users +user_search_tooltip = Shows a maximum of 30 users tree_path_not_found_commit = Path %[1]s doesn't exist in commit %[2]s tree_path_not_found_branch = Path %[1]s doesn't exist in branch %[2]s @@ -1529,7 +1529,8 @@ issues.filter_assignee = Assignee issues.filter_assginee_no_select = All assignees issues.filter_assginee_no_assignee = No assignee issues.filter_poster = Author -issues.filter_poster_no_select = All authors +issues.filter_user_placeholder = Search users +issues.filter_user_no_select = All users issues.filter_type = Type issues.filter_type.all_issues = All issues issues.filter_type.assigned_to_you = Assigned to you diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go index 6dfefbf68d..b94344f2ec 100644 --- a/routers/web/org/projects.go +++ b/routers/web/org/projects.go @@ -339,12 +339,7 @@ func ViewProject(ctx *context.Context) { // 0 means issues with no label // blank means labels will not be filtered for issues selectLabels := ctx.FormString("labels") - if selectLabels == "" { - ctx.Data["AllLabels"] = true - } else if selectLabels == "0" { - ctx.Data["NoLabel"] = true - } - if len(selectLabels) > 0 { + if selectLabels != "" { labelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ",")) if err != nil { ctx.Flash.Error(ctx.Tr("invalid_data", selectLabels), true) diff --git a/routers/web/repo/issue_list.go b/routers/web/repo/issue_list.go index 2123d4a5b6..6451f7ac76 100644 --- a/routers/web/repo/issue_list.go +++ b/routers/web/repo/issue_list.go @@ -7,7 +7,6 @@ import ( "bytes" "fmt" "net/http" - "net/url" "strconv" "strings" @@ -531,12 +530,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt // 0 means issues with no label // blank means labels will not be filtered for issues selectLabels := ctx.FormString("labels") - if selectLabels == "" { - ctx.Data["AllLabels"] = true - } else if selectLabels == "0" { - ctx.Data["NoLabel"] = true - } - if len(selectLabels) > 0 { + if selectLabels != "" { labelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ",")) if err != nil { ctx.Flash.Error(ctx.Tr("invalid_data", selectLabels), true) @@ -616,8 +610,6 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt ctx.Data["TotalTrackedTime"] = totalTrackedTime } - archived := ctx.FormBool("archived") - page := ctx.FormInt("page") if page <= 1 { page = 1 @@ -792,21 +784,13 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt return } + showArchivedLabels := ctx.FormBool("archived_labels") + ctx.Data["ShowArchivedLabels"] = showArchivedLabels ctx.Data["PinnedIssues"] = pinned ctx.Data["IsRepoAdmin"] = ctx.IsSigned && (ctx.Repo.IsAdmin() || ctx.Doer.IsAdmin) ctx.Data["IssueStats"] = issueStats ctx.Data["OpenCount"] = issueStats.OpenCount ctx.Data["ClosedCount"] = issueStats.ClosedCount - linkStr := "%s?q=%s&type=%s&sort=%s&state=%s&labels=%s&milestone=%d&project=%d&assignee=%d&poster=%v&archived=%t" - ctx.Data["AllStatesLink"] = fmt.Sprintf(linkStr, ctx.Link, - url.QueryEscape(keyword), url.QueryEscape(viewType), url.QueryEscape(sortType), "all", url.QueryEscape(selectLabels), - milestoneID, projectID, assigneeID, url.QueryEscape(posterUsername), archived) - ctx.Data["OpenLink"] = fmt.Sprintf(linkStr, ctx.Link, - url.QueryEscape(keyword), url.QueryEscape(viewType), url.QueryEscape(sortType), "open", url.QueryEscape(selectLabels), - milestoneID, projectID, assigneeID, url.QueryEscape(posterUsername), archived) - ctx.Data["ClosedLink"] = fmt.Sprintf(linkStr, ctx.Link, - url.QueryEscape(keyword), url.QueryEscape(viewType), url.QueryEscape(sortType), "closed", url.QueryEscape(selectLabels), - milestoneID, projectID, assigneeID, url.QueryEscape(posterUsername), archived) ctx.Data["SelLabelIDs"] = labelIDs ctx.Data["SelectLabels"] = selectLabels ctx.Data["ViewType"] = viewType @@ -814,6 +798,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt ctx.Data["MilestoneID"] = milestoneID ctx.Data["ProjectID"] = projectID ctx.Data["AssigneeID"] = assigneeID + ctx.Data["PosterUserID"] = posterUserID ctx.Data["PosterUsername"] = posterUsername ctx.Data["Keyword"] = keyword ctx.Data["IsShowClosed"] = isShowClosed @@ -825,7 +810,6 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt default: ctx.Data["State"] = "open" } - ctx.Data["ShowArchivedLabels"] = archived pager.AddParamString("q", keyword) pager.AddParamString("type", viewType) @@ -836,8 +820,9 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt pager.AddParamString("project", fmt.Sprint(projectID)) pager.AddParamString("assignee", fmt.Sprint(assigneeID)) pager.AddParamString("poster", posterUsername) - pager.AddParamString("archived", fmt.Sprint(archived)) - + if showArchivedLabels { + pager.AddParamString("archived_labels", "true") + } ctx.Data["Page"] = pager } diff --git a/routers/web/repo/milestone.go b/routers/web/repo/milestone.go index 3afdcfad8b..33c15e7767 100644 --- a/routers/web/repo/milestone.go +++ b/routers/web/repo/milestone.go @@ -66,12 +66,6 @@ func Milestones(ctx *context.Context) { } ctx.Data["OpenCount"] = stats.OpenCount ctx.Data["ClosedCount"] = stats.ClosedCount - linkStr := "%s/milestones?state=%s&q=%s&sort=%s" - ctx.Data["OpenLink"] = fmt.Sprintf(linkStr, ctx.Repo.RepoLink, "open", - url.QueryEscape(keyword), url.QueryEscape(sortType)) - ctx.Data["ClosedLink"] = fmt.Sprintf(linkStr, ctx.Repo.RepoLink, "closed", - url.QueryEscape(keyword), url.QueryEscape(sortType)) - if ctx.Repo.Repository.IsTimetrackerEnabled(ctx) { if err := issues_model.MilestoneList(miles).LoadTotalTrackedTimes(ctx); err != nil { ctx.ServerError("LoadTotalTrackedTimes", err) diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index 95ae84ab93..168da2ca1f 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -312,12 +312,7 @@ func ViewProject(ctx *context.Context) { // 0 means issues with no label // blank means labels will not be filtered for issues selectLabels := ctx.FormString("labels") - if selectLabels == "" { - ctx.Data["AllLabels"] = true - } else if selectLabels == "0" { - ctx.Data["NoLabel"] = true - } - if len(selectLabels) > 0 { + if selectLabels != "" { labelIDs, err = base.StringsToInt64s(strings.Split(selectLabels, ",")) if err != nil { ctx.Flash.Error(ctx.Tr("invalid_data", selectLabels), true) diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl index acaf45e8d2..966d3bf604 100644 --- a/templates/projects/view.tmpl +++ b/templates/projects/view.tmpl @@ -5,79 +5,19 @@

{{.Project.Title}}

{{if $canWriteProject}} diff --git a/templates/repo/issue/filter_item_label.tmpl b/templates/repo/issue/filter_item_label.tmpl new file mode 100644 index 0000000000..67bfab6fb0 --- /dev/null +++ b/templates/repo/issue/filter_item_label.tmpl @@ -0,0 +1,45 @@ +{{/* +* "labels" from query string (needed by JS) +* QueryLink +* Labels +* SupportArchivedLabel, if true, then it needs "archived_labels" from query string +*/}} +{{$queryLink := .QueryLink}} + diff --git a/templates/repo/issue/filter_item_user_assign.tmpl b/templates/repo/issue/filter_item_user_assign.tmpl new file mode 100644 index 0000000000..4f1db71d57 --- /dev/null +++ b/templates/repo/issue/filter_item_user_assign.tmpl @@ -0,0 +1,31 @@ +{{/* This is a user list for filter, the data is provided by a local variable assignment +* QueryParamKey: eg: "poster", "assignee" +* QueryLink +* UserSearchList +* SelectedUserId: 0 or empty means default, -1 means "no user is set" +* TextFilterTitle +* TextZeroValue: the text for "all issues" +* TextNegativeOne: the text for "issues with no assignee" +*/}} +{{$queryLink := .QueryLink}} + diff --git a/templates/repo/issue/filter_item_user_fetch.tmpl b/templates/repo/issue/filter_item_user_fetch.tmpl new file mode 100644 index 0000000000..cab128a787 --- /dev/null +++ b/templates/repo/issue/filter_item_user_fetch.tmpl @@ -0,0 +1,23 @@ +{{/* This is a user list for filter, the data is provided by a remote "fetch" request +* QueryParamKey: eg: "poster", "assignee" +* QueryLink +* UserSearchUrl +* SelectedUserId +* TextFilterTitle +*/}} +{{$queryLink := .QueryLink}} + diff --git a/templates/repo/issue/filter_list.tmpl b/templates/repo/issue/filter_list.tmpl index e686f1d60f..c78d23d51c 100644 --- a/templates/repo/issue/filter_list.tmpl +++ b/templates/repo/issue/filter_list.tmpl @@ -1,55 +1,6 @@ -{{$queryLink := QueryBuild "?" "q" $.Keyword "type" $.ViewType "sort" $.SortType "state" $.State "labels" $.SelectLabels "milestone" $.MilestoneID "project" $.ProjectID "assignee" $.AssigneeID "poster" $.PosterUsername "archived" (Iif $.ShowArchivedLabels NIL)}} - - +{{$queryLink := QueryBuild "?" "q" $.Keyword "type" $.ViewType "sort" $.SortType "state" $.State "labels" $.SelectLabels "milestone" $.MilestoneID "project" $.ProjectID "assignee" $.AssigneeID "poster" $.PosterUsername "archived_labels" (Iif $.ShowArchivedLabels "true")}} + +{{template "repo/issue/filter_item_label" dict "Labels" .Labels "QueryLink" $queryLink "SupportArchivedLabel" true}} {{if not .Milestone}} @@ -128,46 +79,24 @@ - - +{{/* TODO: the UserSearchUrl is old logic but not right, milestone could also have "pull request" posters */}} +{{template "repo/issue/filter_item_user_fetch" dict + "QueryParamKey" "poster" + "QueryLink" $queryLink + "UserSearchUrl" (Iif .Milestone (print $.RepoLink "/issues/posters") (print $.Link "/posters")) + "SelectedUserId" $.PosterUserID + "TextFilterTitle" (ctx.Locale.Tr "repo.issues.filter_poster") +}} - - +{{template "repo/issue/filter_item_user_assign" dict + "QueryParamKey" "assignee" + "QueryLink" $queryLink + "UserSearchList" $.Assignees + "SelectedUserId" $.AssigneeID + "TextFilterTitle" (ctx.Locale.Tr "repo.issues.filter_assignee") + "TextZeroValue" (ctx.Locale.Tr "repo.issues.filter_assginee_no_select") + "TextNegativeOne" (ctx.Locale.Tr "repo.issues.filter_assginee_no_assignee") +}} {{if .IsSigned}} diff --git a/templates/repo/issue/openclose.tmpl b/templates/repo/issue/openclose.tmpl index eb2d6e09ee..b9dd04a7db 100644 --- a/templates/repo/issue/openclose.tmpl +++ b/templates/repo/issue/openclose.tmpl @@ -1,16 +1,23 @@ +{{/* this tmpl is quite dirty, it should not mix unrelated things together .... need to split it in the future*/}} +{{$allStatesLink := ""}}{{$openLink := ""}}{{$closedLink := ""}} +{{if .PageIsMilestones}} + {{$allStatesLink = QueryBuild "?" "q" $.Keyword "sort" $.SortType "state" "all"}} +{{else}} + {{$allStatesLink = QueryBuild "?" "q" $.Keyword "type" $.ViewType "sort" $.SortType "state" "all" "labels" $.SelectLabels "milestone" $.MilestoneID "project" $.ProjectID "assignee" $.AssigneeID "poster" $.PosterUsername "archived_labels" (Iif $.ShowArchivedLabels "true")}} +{{end}} +{{$openLink = QueryBuild $allStatesLink "state" "open"}} +{{$closedLink = QueryBuild $allStatesLink "state" "closed"}} diff --git a/web_src/css/base.css b/web_src/css/base.css index 8f5ef51c4a..04f3678f3a 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -1390,8 +1390,9 @@ table th[data-sortt-desc] .svg { min-width: 0; } -/* to override Fomantic's default display: block for ".menu .item", and use a slightly larger gap for menu item content */ -.ui.dropdown .menu.flex-items-menu > .item { +/* to override Fomantic's default display: block for ".menu .item", and use a slightly larger gap for menu item content +the "!important" is necessary to override Fomantic UI menu item styles, meanwhile we should keep the "hidden" items still hidden */ +.ui.dropdown .menu.flex-items-menu > .item:not(.hidden, .filtered, .tw-hidden) { display: flex !important; align-items: center; gap: .5rem; diff --git a/web_src/css/repo.css b/web_src/css/repo.css index 14bdc43474..9e1def87a7 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -74,24 +74,6 @@ } } -.repository .filter.menu.labels .label-filter .menu .info { - display: inline-block; - padding: 0.5rem 0; - font-size: 12px; - width: 100%; - white-space: nowrap; - margin-left: 10px; - margin-right: 8px; - text-align: left; -} - -.repository .filter.menu.labels .label-filter .menu .info code { - border: 1px solid var(--color-secondary); - border-radius: var(--border-radius); - padding: 1px 2px; - font-size: 11px; -} - /* make all issue filter dropdown menus popup leftward, to avoid go out the viewport (right side) */ .repository .filter.menu .ui.dropdown .menu { max-height: 500px; @@ -108,6 +90,24 @@ left: 0; } +.repository .filter.menu .ui.dropdown.label-filter .menu .info { + display: inline-block; + padding: 0.5rem 0; + font-size: 12px; + width: 100%; + white-space: nowrap; + margin-left: 10px; + margin-right: 8px; + text-align: left; +} + +.repository .filter.menu .ui.dropdown.label-filter .menu .info code { + border: 1px solid var(--color-secondary); + border-radius: var(--border-radius); + padding: 1px 2px; + font-size: 11px; +} + /* For the secondary pointing menu, respect its own border-bottom */ /* style reference: https://semantic-ui.com/collections/menu.html#pointing */ .repository .ui.tabs.container .ui.menu:not(.secondary.pointing) { diff --git a/web_src/css/repo/issue-list.css b/web_src/css/repo/issue-list.css index 1e0f82ce27..4fafc7d6f8 100644 --- a/web_src/css/repo/issue-list.css +++ b/web_src/css/repo/issue-list.css @@ -68,10 +68,8 @@ background-color: var(--color-secondary-dark-4); } -.archived-label-filter { - margin-left: 10px; +.label-filter-archived-toggle { + margin: 8px 10px; font-size: 12px; - display: flex !important; - margin-bottom: 8px; min-width: fit-content; } diff --git a/web_src/js/features/repo-issue-list.ts b/web_src/js/features/repo-issue-list.ts index 48e22ba3c9..a0550837ec 100644 --- a/web_src/js/features/repo-issue-list.ts +++ b/web_src/js/features/repo-issue-list.ts @@ -1,5 +1,5 @@ import {updateIssuesMeta} from './repo-common.ts'; -import {toggleElem, hideElem, isElemHidden, queryElems} from '../utils/dom.ts'; +import {toggleElem, isElemHidden, queryElems} from '../utils/dom.ts'; import {htmlEscape} from 'escape-goat'; import {confirmModal} from './comp/ConfirmModal.ts'; import {showErrorToast} from '../modules/toast.ts'; @@ -95,34 +95,51 @@ function initRepoIssueListCheckboxes() { function initDropdownUserRemoteSearch(el: Element) { let searchUrl = el.getAttribute('data-search-url'); const actionJumpUrl = el.getAttribute('data-action-jump-url'); - const selectedUserId = el.getAttribute('data-selected-user-id'); + const selectedUserId = parseInt(el.getAttribute('data-selected-user-id')); + let selectedUsername = ''; if (!searchUrl.includes('?')) searchUrl += '?'; const $searchDropdown = fomanticQuery(el); + const elSearchInput = el.querySelector('.ui.search input'); + const elItemFromInput = el.querySelector('.menu > .item-from-input'); + $searchDropdown.dropdown('setting', { fullTextSearch: true, selectOnKeydown: false, - apiSettings: { + action: (_text, value) => { + window.location.href = actionJumpUrl.replace('{username}', encodeURIComponent(value)); + }, + }); + + type ProcessedResult = {value: string, name: string}; + const processedResults: ProcessedResult[] = []; // to be used by dropdown to generate menu items + const syncItemFromInput = () => { + elItemFromInput.setAttribute('data-value', elSearchInput.value); + elItemFromInput.textContent = elSearchInput.value; + toggleElem(elItemFromInput, !processedResults.length); + }; + + if (!searchUrl) { + elSearchInput.addEventListener('input', syncItemFromInput); + } else { + $searchDropdown.dropdown('setting', '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 + processedResults.length = 0; for (const item of resp.results) { let html = `${htmlEscape(item.username)}`; if (item.full_name) html += `${htmlEscape(item.full_name)}`; + if (selectedUserId === item.user_id) selectedUsername = item.username; processedResults.push({value: item.username, name: html}); } resp.results = processedResults; + syncItemFromInput(); return resp; }, - }, - action: (_text, value) => { - window.location.href = actionJumpUrl.replace('{username}', encodeURIComponent(value)); - }, - onShow: () => { - $searchDropdown.dropdown('filter', ' '); // trigger a search on first show - }, - }); + }); + $searchDropdown.dropdown('setting', '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')}; @@ -151,7 +168,7 @@ function initDropdownUserRemoteSearch(el: Element) { for (const el of menu.querySelectorAll('.item.active, .item.selected')) { el.classList.remove('active', 'selected'); } - menu.querySelector(`.item[data-value="${selectedUserId}"]`)?.classList.add('selected'); + menu.querySelector(`.item[data-value="${CSS.escape(selectedUsername)}"]`)?.classList.add('selected'); }, 0); }; } @@ -203,44 +220,9 @@ async function initIssuePinSort() { }); } -function initArchivedLabelFilter() { - const archivedLabelEl = document.querySelector('#archived-filter-checkbox'); - if (!archivedLabelEl) return; - - const url = new URL(window.location.href); - const archivedLabels = document.querySelectorAll('[data-is-archived]'); - - if (!archivedLabels.length) { - hideElem('.archived-label-filter'); - return; - } - const selectedLabels = (url.searchParams.get('labels') || '') - .split(',') - .map((id) => parseInt(id) < 0 ? `${~id + 1}` : id); // selectedLabels contains -ve ids, which are excluded so convert any -ve value id to +ve - - const archivedElToggle = () => { - for (const label of archivedLabels) { - const id = label.getAttribute('data-label-id'); - toggleElem(label, archivedLabelEl.checked || selectedLabels.includes(id)); - } - }; - - archivedElToggle(); - archivedLabelEl.addEventListener('change', () => { - archivedElToggle(); - if (archivedLabelEl.checked) { - url.searchParams.set('archived', 'true'); - } else { - url.searchParams.delete('archived'); - } - window.location.href = url.href; - }); -} - export function initRepoIssueList() { if (!document.querySelector('.page-content.repository.issue-list, .page-content.repository.milestone-issue-list')) return; initRepoIssueListCheckboxes(); queryElems(document, '.ui.dropdown.user-remote-search', (el) => initDropdownUserRemoteSearch(el)); initIssuePinSort(); - initArchivedLabelFilter(); } diff --git a/web_src/js/features/repo-issue.ts b/web_src/js/features/repo-issue.ts index f5a36b7717..e4f9ce4cde 100644 --- a/web_src/js/features/repo-issue.ts +++ b/web_src/js/features/repo-issue.ts @@ -1,7 +1,14 @@ import $ from 'jquery'; import {htmlEscape} from 'escape-goat'; import {createTippy, showTemporaryTooltip} from '../modules/tippy.ts'; -import {addDelegatedEventListener, createElementFromHTML, hideElem, showElem, toggleElem} from '../utils/dom.ts'; +import { + addDelegatedEventListener, + createElementFromHTML, + hideElem, + queryElems, + showElem, + toggleElem, +} from '../utils/dom.ts'; import {setFileFolding} from './file-fold.ts'; import {ComboMarkdownEditor, getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts'; import {parseIssuePageInfo, toAbsoluteUrl} from '../utils.ts'; @@ -12,19 +19,6 @@ import {fomanticQuery} from '../modules/fomantic/base.ts'; const {appSubUrl} = window.config; -/** - * @param {HTMLElement} item - */ -function excludeLabel(item) { - const href = item.getAttribute('href'); - const id = item.getAttribute('data-label-id'); - - const regStr = `labels=((?:-?[0-9]+%2c)*)(${id})((?:%2c-?[0-9]+)*)&`; - const newStr = 'labels=$1-$2$3&'; - - window.location.assign(href.replace(new RegExp(regStr), newStr)); -} - export function initRepoIssueSidebarList() { const issuePageInfo = parseIssuePageInfo(); const crossRepoSearch = $('#crossRepoSearch').val(); @@ -58,24 +52,74 @@ export function initRepoIssueSidebarList() { }); } -export function initRepoIssueLabelFilter() { - // the "label-filter" is used in 2 templates: projects/view, issue/filter_list (issue list page including the milestone page) - $('.ui.dropdown.label-filter a.label-filter-item').each(function () { - $(this).on('click', function (e) { - if (e.altKey) { - e.preventDefault(); - excludeLabel(this); - } +function initRepoIssueLabelFilter(elDropdown: Element) { + const url = new URL(window.location.href); + const showArchivedLabels = url.searchParams.get('archived_labels') === 'true'; + const queryLabels = url.searchParams.get('labels') || ''; + const selectedLabelIds = new Set(); + for (const id of queryLabels ? queryLabels.split(',') : []) { + selectedLabelIds.add(`${Math.abs(parseInt(id))}`); // "labels" contains negative ids, which are excluded + } + + const excludeLabel = (e: MouseEvent|KeyboardEvent, item: Element) => { + e.preventDefault(); + e.stopPropagation(); + const labelId = item.getAttribute('data-label-id'); + let labelIds: string[] = queryLabels ? queryLabels.split(',') : []; + labelIds = labelIds.filter((id) => Math.abs(parseInt(id)) !== Math.abs(parseInt(labelId))); + labelIds.push(`-${labelId}`); + url.searchParams.set('labels', labelIds.join(',')); + window.location.assign(url); + }; + + // alt(or option) + click to exclude label + queryElems(elDropdown, '.label-filter-query-item', (el) => { + el.addEventListener('click', (e: MouseEvent) => { + if (e.altKey) excludeLabel(e, el); }); }); - $('.ui.dropdown.label-filter').on('keydown', (e) => { + // alt(or option) + enter to exclude selected label + elDropdown.addEventListener('keydown', (e: KeyboardEvent) => { if (e.altKey && e.key === 'Enter') { - const selectedItem = document.querySelector('.ui.dropdown.label-filter .menu .item.selected'); - if (selectedItem) { - excludeLabel(selectedItem); - } + const selectedItem = elDropdown.querySelector('.label-filter-query-item.selected'); + if (selectedItem) excludeLabel(e, selectedItem); } }); + // no "labels" query parameter means "all issues" + elDropdown.querySelector('.label-filter-query-default').classList.toggle('selected', queryLabels === ''); + // "labels=0" query parameter means "issues without label" + elDropdown.querySelector('.label-filter-query-not-set').classList.toggle('selected', queryLabels === '0'); + + // prepare to process "archived" labels + const elShowArchivedLabel = elDropdown.querySelector('.label-filter-archived-toggle'); + if (!elShowArchivedLabel) return; + const elShowArchivedInput = elShowArchivedLabel.querySelector('input'); + elShowArchivedInput.checked = showArchivedLabels; + const archivedLabels = elDropdown.querySelectorAll('.item[data-is-archived]'); + // if no archived labels, hide the toggle and return + if (!archivedLabels.length) { + hideElem(elShowArchivedLabel); + return; + } + + // show the archived labels if the toggle is checked or the label is selected + for (const label of archivedLabels) { + toggleElem(label, showArchivedLabels || selectedLabelIds.has(label.getAttribute('data-label-id'))); + } + // update the url when the toggle is changed and reload + elShowArchivedInput.addEventListener('input', () => { + if (elShowArchivedInput.checked) { + url.searchParams.set('archived_labels', 'true'); + } else { + url.searchParams.delete('archived_labels'); + } + window.location.assign(url); + }); +} + +export function initRepoIssueFilterItemLabel() { + // the "label-filter" is used in 2 templates: projects/view, issue/filter_list (issue list page including the milestone page) + queryElems(document, '.ui.dropdown.label-filter', initRepoIssueLabelFilter); } export function initRepoIssueCommentDelete() { diff --git a/web_src/js/index.ts b/web_src/js/index.ts index 2964ef5572..51d8c96fbd 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -29,7 +29,7 @@ import { initRepoIssueWipTitle, initRepoPullRequestMergeInstruction, initRepoPullRequestAllowMaintainerEdit, - initRepoPullRequestReview, initRepoIssueSidebarList, initRepoIssueLabelFilter, + initRepoPullRequestReview, initRepoIssueSidebarList, initRepoIssueFilterItemLabel, } from './features/repo-issue.ts'; import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts'; import {initRepoTopicBar} from './features/repo-home.ts'; @@ -181,7 +181,7 @@ onDomReady(() => { initRepoGraphGit, initRepoIssueContentHistory, initRepoIssueList, - initRepoIssueLabelFilter, + initRepoIssueFilterItemLabel, initRepoIssueSidebarList, initRepoIssueReferenceRepositorySearch, initRepoIssueWipTitle,