]> source.dussan.org Git - gitea.git/commitdiff
Refactor sidebar label selector (#32460)
authorwxiaoguang <wxiaoguang@gmail.com>
Sun, 10 Nov 2024 08:26:42 +0000 (16:26 +0800)
committerGitHub <noreply@github.com>
Sun, 10 Nov 2024 08:26:42 +0000 (08:26 +0000)
Introduce `issueSidebarLabelsData` to handle all sidebar labels related data.

22 files changed:
routers/web/repo/compare.go
routers/web/repo/issue.go
routers/web/repo/issue_label.go
routers/web/repo/issue_label_test.go
routers/web/web.go
templates/repo/issue/labels/label.tmpl [deleted file]
templates/repo/issue/labels/labels_selector_field.tmpl [deleted file]
templates/repo/issue/labels/labels_sidebar.tmpl [deleted file]
templates/repo/issue/new_form.tmpl
templates/repo/issue/sidebar/label_list.tmpl [new file with mode: 0644]
templates/repo/issue/sidebar/label_list_item.tmpl [new file with mode: 0644]
templates/repo/issue/sidebar/reviewer_list.tmpl
templates/repo/issue/view_content/sidebar.tmpl
web_src/css/repo.css
web_src/css/repo/issue-label.css
web_src/js/features/common-page.ts
web_src/js/features/repo-issue-sidebar-combolist.ts
web_src/js/features/repo-issue-sidebar.md [new file with mode: 0644]
web_src/js/features/repo-issue-sidebar.ts
web_src/js/features/repo-issue.ts
web_src/js/index.ts
web_src/js/utils/dom.ts

index 3477ba36e801b16ab912a351c45dee15caa29004..9a7d3dfbf653c8552b16ffaf68cc1ea3d751fceb 100644 (file)
@@ -788,7 +788,11 @@ func CompareDiff(ctx *context.Context) {
 
                if !nothingToCompare {
                        // Setup information for new form.
-                       RetrieveRepoMetas(ctx, ctx.Repo.Repository, true)
+                       retrieveRepoMetasForIssueWriter(ctx, ctx.Repo.Repository, true)
+                       if ctx.Written() {
+                               return
+                       }
+                       labelsData := retrieveRepoLabels(ctx, ctx.Repo.Repository, 0, true)
                        if ctx.Written() {
                                return
                        }
@@ -796,6 +800,10 @@ func CompareDiff(ctx *context.Context) {
                        if ctx.Written() {
                                return
                        }
+                       _, templateErrs := setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates, labelsData)
+                       if len(templateErrs) > 0 {
+                               ctx.Flash.Warning(renderErrorOfTemplates(ctx, templateErrs), true)
+                       }
                }
        }
        beforeCommitID := ctx.Data["BeforeCommitID"].(string)
@@ -808,11 +816,6 @@ func CompareDiff(ctx *context.Context) {
        ctx.Data["Title"] = "Comparing " + base.ShortSha(beforeCommitID) + separator + base.ShortSha(afterCommitID)
 
        ctx.Data["IsDiffCompare"] = true
-       _, templateErrs := setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates)
-
-       if len(templateErrs) > 0 {
-               ctx.Flash.Warning(renderErrorOfTemplates(ctx, templateErrs), true)
-       }
 
        if content, ok := ctx.Data["content"].(string); ok && content != "" {
                // If a template content is set, prepend the "content". In this case that's only
index 7fa8d428d3656a0f01684966c64d7ef3cf9e2468..a4e2fd8cea310b83171fc508d127e25e5d0b11b1 100644 (file)
@@ -870,51 +870,112 @@ func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, is
        ctx.Data["IssueSidebarReviewersData"] = data
 }
 
-// RetrieveRepoMetas find all the meta information of a repository
-func RetrieveRepoMetas(ctx *context.Context, repo *repo_model.Repository, isPull bool) []*issues_model.Label {
-       if !ctx.Repo.CanWriteIssuesOrPulls(isPull) {
-               return nil
+type issueSidebarLabelsData struct {
+       Repository       *repo_model.Repository
+       RepoLink         string
+       IssueID          int64
+       IsPullRequest    bool
+       AllLabels        []*issues_model.Label
+       RepoLabels       []*issues_model.Label
+       OrgLabels        []*issues_model.Label
+       SelectedLabelIDs string
+}
+
+func makeSelectedStringIDs[KeyType, ItemType comparable](
+       allLabels []*issues_model.Label, candidateKey func(candidate *issues_model.Label) KeyType,
+       selectedItems []ItemType, selectedKey func(selected ItemType) KeyType,
+) string {
+       selectedIDSet := make(container.Set[string])
+       allLabelMap := map[KeyType]*issues_model.Label{}
+       for _, label := range allLabels {
+               allLabelMap[candidateKey(label)] = label
+       }
+       for _, item := range selectedItems {
+               if label, ok := allLabelMap[selectedKey(item)]; ok {
+                       label.IsChecked = true
+                       selectedIDSet.Add(strconv.FormatInt(label.ID, 10))
+               }
+       }
+       ids := selectedIDSet.Values()
+       sort.Strings(ids)
+       return strings.Join(ids, ",")
+}
+
+func (d *issueSidebarLabelsData) SetSelectedLabels(labels []*issues_model.Label) {
+       d.SelectedLabelIDs = makeSelectedStringIDs(
+               d.AllLabels, func(label *issues_model.Label) int64 { return label.ID },
+               labels, func(label *issues_model.Label) int64 { return label.ID },
+       )
+}
+
+func (d *issueSidebarLabelsData) SetSelectedLabelNames(labelNames []string) {
+       d.SelectedLabelIDs = makeSelectedStringIDs(
+               d.AllLabels, func(label *issues_model.Label) string { return strings.ToLower(label.Name) },
+               labelNames, strings.ToLower,
+       )
+}
+
+func (d *issueSidebarLabelsData) SetSelectedLabelIDs(labelIDs []int64) {
+       d.SelectedLabelIDs = makeSelectedStringIDs(
+               d.AllLabels, func(label *issues_model.Label) int64 { return label.ID },
+               labelIDs, func(labelID int64) int64 { return labelID },
+       )
+}
+
+func retrieveRepoLabels(ctx *context.Context, repo *repo_model.Repository, issueID int64, isPull bool) *issueSidebarLabelsData {
+       labelsData := &issueSidebarLabelsData{
+               Repository:    repo,
+               RepoLink:      ctx.Repo.RepoLink,
+               IssueID:       issueID,
+               IsPullRequest: isPull,
        }
+       ctx.Data["IssueSidebarLabelsData"] = labelsData
 
        labels, err := issues_model.GetLabelsByRepoID(ctx, repo.ID, "", db.ListOptions{})
        if err != nil {
                ctx.ServerError("GetLabelsByRepoID", err)
                return nil
        }
-       ctx.Data["Labels"] = labels
+       labelsData.RepoLabels = labels
+
        if repo.Owner.IsOrganization() {
                orgLabels, err := issues_model.GetLabelsByOrgID(ctx, repo.Owner.ID, ctx.FormString("sort"), db.ListOptions{})
                if err != nil {
                        return nil
                }
+               labelsData.OrgLabels = orgLabels
+       }
+       labelsData.AllLabels = append(labelsData.AllLabels, labelsData.RepoLabels...)
+       labelsData.AllLabels = append(labelsData.AllLabels, labelsData.OrgLabels...)
+       return labelsData
+}
 
-               ctx.Data["OrgLabels"] = orgLabels
-               labels = append(labels, orgLabels...)
+// retrieveRepoMetasForIssueWriter finds some the meta information of a repository for an issue/pr writer
+func retrieveRepoMetasForIssueWriter(ctx *context.Context, repo *repo_model.Repository, isPull bool) {
+       if !ctx.Repo.CanWriteIssuesOrPulls(isPull) {
+               return
        }
 
        RetrieveRepoMilestonesAndAssignees(ctx, repo)
        if ctx.Written() {
-               return nil
+               return
        }
 
        retrieveProjects(ctx, repo)
        if ctx.Written() {
-               return nil
+               return
        }
 
        PrepareBranchList(ctx)
        if ctx.Written() {
-               return nil
+               return
        }
-
        // Contains true if the user can create issue dependencies
        ctx.Data["CanCreateIssueDependencies"] = ctx.Repo.CanCreateIssueDependencies(ctx, ctx.Doer, isPull)
-
-       return labels
 }
 
 // Tries to load and set an issue template. The first return value indicates if a template was loaded.
-func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles []string) (bool, map[string]error) {
+func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles []string, labelsData *issueSidebarLabelsData) (bool, map[string]error) {
        commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch)
        if err != nil {
                return false, nil
@@ -951,26 +1012,9 @@ func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles
                        ctx.Data["Fields"] = template.Fields
                        ctx.Data["TemplateFile"] = template.FileName
                }
-               labelIDs := make([]string, 0, len(template.Labels))
-               if repoLabels, err := issues_model.GetLabelsByRepoID(ctx, ctx.Repo.Repository.ID, "", db.ListOptions{}); err == nil {
-                       ctx.Data["Labels"] = repoLabels
-                       if ctx.Repo.Owner.IsOrganization() {
-                               if orgLabels, err := issues_model.GetLabelsByOrgID(ctx, ctx.Repo.Owner.ID, ctx.FormString("sort"), db.ListOptions{}); err == nil {
-                                       ctx.Data["OrgLabels"] = orgLabels
-                                       repoLabels = append(repoLabels, orgLabels...)
-                               }
-                       }
 
-                       for _, metaLabel := range template.Labels {
-                               for _, repoLabel := range repoLabels {
-                                       if strings.EqualFold(repoLabel.Name, metaLabel) {
-                                               repoLabel.IsChecked = true
-                                               labelIDs = append(labelIDs, strconv.FormatInt(repoLabel.ID, 10))
-                                               break
-                                       }
-                               }
-                       }
-               }
+               labelsData.SetSelectedLabelNames(template.Labels)
+
                selectedAssigneeIDs := make([]int64, 0, len(template.Assignees))
                selectedAssigneeIDStrings := make([]string, 0, len(template.Assignees))
                if userIDs, err := user_model.GetUserIDsByNames(ctx, template.Assignees, false); err == nil {
@@ -983,8 +1027,7 @@ func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles
                if template.Ref != "" && !strings.HasPrefix(template.Ref, "refs/") { // Assume that the ref intended is always a branch - for tags users should use refs/tags/<ref>
                        template.Ref = git.BranchPrefix + template.Ref
                }
-               ctx.Data["HasSelectedLabel"] = len(labelIDs) > 0
-               ctx.Data["label_ids"] = strings.Join(labelIDs, ",")
+
                ctx.Data["HasSelectedAssignee"] = len(selectedAssigneeIDs) > 0
                ctx.Data["assignee_ids"] = strings.Join(selectedAssigneeIDStrings, ",")
                ctx.Data["SelectedAssigneeIDs"] = selectedAssigneeIDs
@@ -1042,8 +1085,14 @@ func NewIssue(ctx *context.Context) {
                }
        }
 
-       RetrieveRepoMetas(ctx, ctx.Repo.Repository, false)
-
+       retrieveRepoMetasForIssueWriter(ctx, ctx.Repo.Repository, false)
+       if ctx.Written() {
+               return
+       }
+       labelsData := retrieveRepoLabels(ctx, ctx.Repo.Repository, 0, false)
+       if ctx.Written() {
+               return
+       }
        tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID)
        if err != nil {
                ctx.ServerError("GetTagNamesByRepoID", err)
@@ -1052,7 +1101,7 @@ func NewIssue(ctx *context.Context) {
        ctx.Data["Tags"] = tags
 
        ret := issue_service.ParseTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
-       templateLoaded, errs := setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates)
+       templateLoaded, errs := setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates, labelsData)
        for k, v := range errs {
                ret.TemplateErrors[k] = v
        }
@@ -1161,34 +1210,25 @@ func ValidateRepoMetas(ctx *context.Context, form forms.CreateIssueForm, isPull
                err  error
        )
 
-       labels := RetrieveRepoMetas(ctx, ctx.Repo.Repository, isPull)
+       retrieveRepoMetasForIssueWriter(ctx, ctx.Repo.Repository, isPull)
+       if ctx.Written() {
+               return ret
+       }
+       labelsData := retrieveRepoLabels(ctx, ctx.Repo.Repository, 0, isPull)
        if ctx.Written() {
                return ret
        }
 
        var labelIDs []int64
-       hasSelected := false
        // Check labels.
        if len(form.LabelIDs) > 0 {
                labelIDs, err = base.StringsToInt64s(strings.Split(form.LabelIDs, ","))
                if err != nil {
                        return ret
                }
-               labelIDMark := make(container.Set[int64])
-               labelIDMark.AddMultiple(labelIDs...)
-
-               for i := range labels {
-                       if labelIDMark.Contains(labels[i].ID) {
-                               labels[i].IsChecked = true
-                               hasSelected = true
-                       }
-               }
+               labelsData.SetSelectedLabelIDs(labelIDs)
        }
 
-       ctx.Data["Labels"] = labels
-       ctx.Data["HasSelectedLabel"] = hasSelected
-       ctx.Data["label_ids"] = form.LabelIDs
-
        // Check milestone.
        milestoneID := form.MilestoneID
        if milestoneID > 0 {
@@ -1579,38 +1619,15 @@ func ViewIssue(ctx *context.Context) {
                }
        }
 
-       // Metas.
-       // Check labels.
-       labelIDMark := make(container.Set[int64])
-       for _, label := range issue.Labels {
-               labelIDMark.Add(label.ID)
-       }
-       labels, err := issues_model.GetLabelsByRepoID(ctx, repo.ID, "", db.ListOptions{})
-       if err != nil {
-               ctx.ServerError("GetLabelsByRepoID", err)
+       retrieveRepoMetasForIssueWriter(ctx, repo, issue.IsPull)
+       if ctx.Written() {
                return
        }
-       ctx.Data["Labels"] = labels
-
-       if repo.Owner.IsOrganization() {
-               orgLabels, err := issues_model.GetLabelsByOrgID(ctx, repo.Owner.ID, ctx.FormString("sort"), db.ListOptions{})
-               if err != nil {
-                       ctx.ServerError("GetLabelsByOrgID", err)
-                       return
-               }
-               ctx.Data["OrgLabels"] = orgLabels
-
-               labels = append(labels, orgLabels...)
-       }
-
-       hasSelected := false
-       for i := range labels {
-               if labelIDMark.Contains(labels[i].ID) {
-                       labels[i].IsChecked = true
-                       hasSelected = true
-               }
+       labelsData := retrieveRepoLabels(ctx, repo, issue.ID, issue.IsPull)
+       if ctx.Written() {
+               return
        }
-       ctx.Data["HasSelectedLabel"] = hasSelected
+       labelsData.SetSelectedLabels(issue.Labels)
 
        // Check milestone and assignee.
        if ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) {
index 81bee4dbb531c9c857bdde7ce13b1ce7e901bffe..4874baaa543ca2f5ef813654ae4e30b318a247db 100644 (file)
@@ -53,11 +53,11 @@ func InitializeLabels(ctx *context.Context) {
        ctx.Redirect(ctx.Repo.RepoLink + "/labels")
 }
 
-// RetrieveLabels find all the labels of a repository and organization
-func RetrieveLabels(ctx *context.Context) {
+// RetrieveLabelsForList find all the labels of a repository and organization, it is only used by "/labels" page to list all labels
+func RetrieveLabelsForList(ctx *context.Context) {
        labels, err := issues_model.GetLabelsByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormString("sort"), db.ListOptions{})
        if err != nil {
-               ctx.ServerError("RetrieveLabels.GetLabels", err)
+               ctx.ServerError("RetrieveLabelsForList.GetLabels", err)
                return
        }
 
index 93fc72300b30529b92da7f711d999dbfc71b2613..c86a03da51a28960557df68ecf26723831e45912 100644 (file)
@@ -62,7 +62,7 @@ func TestRetrieveLabels(t *testing.T) {
                contexttest.LoadUser(t, ctx, 2)
                contexttest.LoadRepo(t, ctx, testCase.RepoID)
                ctx.Req.Form.Set("sort", testCase.Sort)
-               RetrieveLabels(ctx)
+               RetrieveLabelsForList(ctx)
                assert.False(t, ctx.Written())
                labels, ok := ctx.Data["Labels"].([]*issues_model.Label)
                assert.True(t, ok)
index 29dd8a8edcb6689912dd90f0764be9594e9be68d..907bf88f6f77c4671dca4ebfd2c9a30c99883b8d 100644 (file)
@@ -1163,7 +1163,7 @@ func registerRoutes(m *web.Router) {
                m.Get("/issues/posters", repo.IssuePosters) // it can't use {type:issues|pulls} because it would conflict with other routes like "/pulls/{index}"
                m.Get("/pulls/posters", repo.PullPosters)
                m.Get("/comments/{id}/attachments", repo.GetCommentAttachments)
-               m.Get("/labels", repo.RetrieveLabels, repo.Labels)
+               m.Get("/labels", repo.RetrieveLabelsForList, repo.Labels)
                m.Get("/milestones", repo.Milestones)
                m.Get("/milestone/{id}", context.RepoRef(), repo.MilestoneIssuesAndPulls)
                m.Group("/{type:issues|pulls}", func() {
diff --git a/templates/repo/issue/labels/label.tmpl b/templates/repo/issue/labels/label.tmpl
deleted file mode 100644 (file)
index f40c792..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-<a
-       class="item {{if not .label.IsChecked}}tw-hidden{{end}}"
-       id="label_{{.label.ID}}"
-       href="{{.root.RepoLink}}/{{if or .root.IsPull .root.Issue.IsPull}}pulls{{else}}issues{{end}}?labels={{.label.ID}}"{{/* FIXME: use .root.Issue.Link or create .root.Link */}}
->
-       {{- ctx.RenderUtils.RenderLabel .label -}}
-</a>
diff --git a/templates/repo/issue/labels/labels_selector_field.tmpl b/templates/repo/issue/labels/labels_selector_field.tmpl
deleted file mode 100644 (file)
index 96fb658..0000000
+++ /dev/null
@@ -1,46 +0,0 @@
-<div class="ui {{if or (not .HasIssuesOrPullsWritePermission) .Repository.IsArchived}}disabled{{end}} floating jump select-label dropdown">
-       <span class="text muted flex-text-block">
-               <strong>{{ctx.Locale.Tr "repo.issues.new.labels"}}</strong>
-               {{if and .HasIssuesOrPullsWritePermission (not .Repository.IsArchived)}}
-                       {{svg "octicon-gear" 16 "tw-ml-1"}}
-               {{end}}
-       </span>
-       <div class="filter menu" {{if .Issue}}data-action="update" data-issue-id="{{$.Issue.ID}}" data-update-url="{{$.RepoLink}}/issues/labels"{{else}}data-id="#label_ids"{{end}}>
-               {{if or .Labels .OrgLabels}}
-                       <div class="ui icon search input">
-                               <i class="icon">{{svg "octicon-search" 16}}</i>
-                               <input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_labels"}}">
-                       </div>
-               {{end}}
-               <a class="no-select item" href="#">{{ctx.Locale.Tr "repo.issues.new.clear_labels"}}</a>
-               {{if or .Labels .OrgLabels}}
-                       {{$previousExclusiveScope := "_no_scope"}}
-                       {{range .Labels}}
-                               {{$exclusiveScope := .ExclusiveScope}}
-                               {{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}}
-                                       <div class="divider"></div>
-                               {{end}}
-                               {{$previousExclusiveScope = $exclusiveScope}}
-                               <a class="{{if .IsChecked}}checked{{end}} item" href="#" data-id="{{.ID}}" {{if .IsArchived}}data-is-archived{{end}} data-id-selector="#label_{{.ID}}" data-scope="{{$exclusiveScope}}"><span class="octicon-check {{if not .IsChecked}}tw-invisible{{end}}">{{svg (Iif $exclusiveScope "octicon-dot-fill" "octicon-check")}}</span>&nbsp;&nbsp;{{ctx.RenderUtils.RenderLabel .}}
-                                       {{if .Description}}<br><small class="desc">{{.Description | ctx.RenderUtils.RenderEmoji}}</small>{{end}}
-                                       <p class="archived-label-hint">{{template "repo/issue/labels/label_archived" .}}</p>
-                               </a>
-                       {{end}}
-                       <div class="divider"></div>
-                       {{$previousExclusiveScope = "_no_scope"}}
-                       {{range .OrgLabels}}
-                               {{$exclusiveScope := .ExclusiveScope}}
-                               {{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}}
-                                       <div class="divider"></div>
-                               {{end}}
-                               {{$previousExclusiveScope = $exclusiveScope}}
-                               <a class="{{if .IsChecked}}checked{{end}} item" href="#" data-id="{{.ID}}" {{if .IsArchived}}data-is-archived{{end}} data-id-selector="#label_{{.ID}}" data-scope="{{$exclusiveScope}}"><span class="octicon-check {{if not .IsChecked}}tw-invisible{{end}}">{{svg (Iif $exclusiveScope "octicon-dot-fill" "octicon-check")}}</span>&nbsp;&nbsp;{{ctx.RenderUtils.RenderLabel .}}
-                                       {{if .Description}}<br><small class="desc">{{.Description | ctx.RenderUtils.RenderEmoji}}</small>{{end}}
-                                       <p class="archived-label-hint">{{template "repo/issue/labels/label_archived" .}}</p>
-                               </a>
-                       {{end}}
-               {{else}}
-                       <div class="disabled item">{{ctx.Locale.Tr "repo.issues.new.no_items"}}</div>
-               {{end}}
-       </div>
-</div>
diff --git a/templates/repo/issue/labels/labels_sidebar.tmpl b/templates/repo/issue/labels/labels_sidebar.tmpl
deleted file mode 100644 (file)
index 0b7b9b8..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-<div class="ui labels list">
-       <span class="labels-list">
-               <span class="no-select {{if .root.HasSelectedLabel}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_label"}}</span>
-               {{range .root.Labels}}
-                       {{template "repo/issue/labels/label" dict "root" $.root "label" .}}
-               {{end}}
-               {{range .root.OrgLabels}}
-                       {{template "repo/issue/labels/label" dict "root" $.root "label" .}}
-               {{end}}
-       </span>
-</div>
index 190d52cf4772d4192b42e74c1780cc90e4653330..65d359e9dcda68d3b9d205f66a27c4025cbf7758 100644 (file)
                                                <input type="hidden" name="template-file" value="{{.TemplateFile}}">
                                                {{range .Fields}}
                                                        {{if eq .Type "input"}}
-                                                               {{template "repo/issue/fields/input" "item" .}}
+                                                               {{template "repo/issue/fields/input" dict "item" .}}
                                                        {{else if eq .Type "markdown"}}
-                                                               {{template "repo/issue/fields/markdown" "item" .}}
+                                                               {{template "repo/issue/fields/markdown" dict "item" .}}
                                                        {{else if eq .Type "textarea"}}
-                                                               {{template "repo/issue/fields/textarea" "item" . "root" $}}
+                                                               {{template "repo/issue/fields/textarea" dict "item" . "root" $}}
                                                        {{else if eq .Type "dropdown"}}
-                                                               {{template "repo/issue/fields/dropdown" "item" .}}
+                                                               {{template "repo/issue/fields/dropdown" dict "item" .}}
                                                        {{else if eq .Type "checkboxes"}}
-                                                               {{template "repo/issue/fields/checkboxes" "item" .}}
+                                                               {{template "repo/issue/fields/checkboxes" dict "item" .}}
                                                        {{end}}
                                                {{end}}
                                        {{else}}
        <div class="issue-content-right ui segment">
                {{template "repo/issue/branch_selector_field" $}}
                {{if .PageIsComparePull}}
-                       {{template "repo/issue/sidebar/reviewer_list" dict "IssueSidebarReviewersData" $.IssueSidebarReviewersData}}
+                       {{template "repo/issue/sidebar/reviewer_list" $.IssueSidebarReviewersData}}
                        <div class="divider"></div>
                {{end}}
 
-               <input id="label_ids" name="label_ids" type="hidden" value="{{.label_ids}}">
-               {{template "repo/issue/labels/labels_selector_field" .}}
-               {{template "repo/issue/labels/labels_sidebar" dict "root" $}}
+               {{template "repo/issue/sidebar/label_list" $.IssueSidebarLabelsData}}
 
                <div class="divider"></div>
 
diff --git a/templates/repo/issue/sidebar/label_list.tmpl b/templates/repo/issue/sidebar/label_list.tmpl
new file mode 100644 (file)
index 0000000..e9f4baa
--- /dev/null
@@ -0,0 +1,51 @@
+{{$data := .}}
+{{$canChange := and ctx.RootData.HasIssuesOrPullsWritePermission (not $data.Repository.IsArchived)}}
+<div class="issue-sidebar-combo" {{if $data.IssueID}}data-update-url="{{$data.RepoLink}}/issues/labels?issue_ids={{$data.IssueID}}"{{end}}>
+       <input class="combo-value" name="label_ids" type="hidden" value="{{$data.SelectedLabelIDs}}">
+       <div class="ui dropdown {{if not $canChange}}disabled{{end}}">
+               <a class="text muted">
+                       <strong>{{ctx.Locale.Tr "repo.issues.new.labels"}}</strong> {{if $canChange}}{{svg "octicon-gear"}}{{end}}
+               </a>
+               <div class="menu">
+                       {{if not $data.AllLabels}}
+                               <div class="item disabled">{{ctx.Locale.Tr "repo.issues.new.no_items"}}</div>
+                       {{else}}
+                               <div class="ui icon search input">
+                                       <i class="icon">{{svg "octicon-search" 16}}</i>
+                                       <input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_labels"}}">
+                               </div>
+                               <a class="item clear-selection" href="#">{{ctx.Locale.Tr "repo.issues.new.clear_labels"}}</a>
+                               {{$previousExclusiveScope := "_no_scope"}}
+                               {{range .RepoLabels}}
+                                       {{$exclusiveScope := .ExclusiveScope}}
+                                       {{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}}
+                                               <div class="divider"></div>
+                                       {{end}}
+                                       {{$previousExclusiveScope = $exclusiveScope}}
+                                       {{template "repo/issue/sidebar/label_list_item" dict "Label" .}}
+                               {{end}}
+                               <div class="divider"></div>
+                               {{$previousExclusiveScope = "_no_scope"}}
+                               {{range .OrgLabels}}
+                                       {{$exclusiveScope := .ExclusiveScope}}
+                                       {{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}}
+                                               <div class="divider"></div>
+                                       {{end}}
+                                       {{$previousExclusiveScope = $exclusiveScope}}
+                                       {{template "repo/issue/sidebar/label_list_item" dict "Label" .}}
+                               {{end}}
+                       {{end}}
+               </div>
+       </div>
+
+       <div class="ui list labels-list tw-my-2 tw-flex tw-gap-2">
+               <span class="item empty-list {{if $data.SelectedLabelIDs}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_label"}}</span>
+               {{range $data.AllLabels}}
+                       {{if .IsChecked}}
+                               <a class="item" href="{{$data.RepoLink}}/{{if $data.IsPullRequest}}pulls{{else}}issues{{end}}?labels={{.ID}}">
+                                       {{- ctx.RenderUtils.RenderLabel . -}}
+                               </a>
+                       {{end}}
+               {{end}}
+       </div>
+</div>
diff --git a/templates/repo/issue/sidebar/label_list_item.tmpl b/templates/repo/issue/sidebar/label_list_item.tmpl
new file mode 100644 (file)
index 0000000..ad878e9
--- /dev/null
@@ -0,0 +1,11 @@
+{{$label := .Label}}
+<a class="item {{if $label.IsChecked}}checked{{else if $label.IsArchived}}tw-hidden{{end}}" href="#"
+       data-scope="{{$label.ExclusiveScope}}" data-value="{{$label.ID}}" {{if $label.IsArchived}}data-is-archived{{end}}
+>
+       <span class="item-check-mark">{{svg (Iif $label.ExclusiveScope "octicon-dot-fill" "octicon-check")}}</span>
+       {{ctx.RenderUtils.RenderLabel $label}}
+       <div class="item-secondary-info">
+               {{if $label.Description}}<div class="tw-pl-[20px]"><small>{{$label.Description | ctx.RenderUtils.RenderEmoji}}</small></div>{{end}}
+               <div class="archived-label-hint">{{template "repo/issue/labels/label_archived" $label}}</div>
+       </div>
+</a>
index 2d3218e92726f251c396979a81148ca6742d7e68..cf7b97c02b194041e9e409d09b467f8516d79908 100644 (file)
@@ -1,11 +1,9 @@
-{{$data := .IssueSidebarReviewersData}}
+{{$data := .}}
 {{$hasCandidates := or $data.Reviewers $data.TeamReviewers}}
-<div class="issue-sidebar-combo" data-sidebar-combo-for="reviewers"
-               {{if $data.IssueID}}data-update-url="{{$data.RepoLink}}/issues/request_review?issue_ids={{$data.IssueID}}"{{end}}
->
+<div class="issue-sidebar-combo" {{if $data.IssueID}}data-update-url="{{$data.RepoLink}}/issues/request_review?issue_ids={{$data.IssueID}}"{{end}}>
        <input type="hidden" class="combo-value" name="reviewer_ids">{{/* match CreateIssueForm */}}
-       <div class="ui dropdown custom {{if or (not $hasCandidates) (not $data.CanChooseReviewer)}}disabled{{end}}">
-               <a class="muted text">
+       <div class="ui dropdown {{if or (not $hasCandidates) (not $data.CanChooseReviewer)}}disabled{{end}}">
+               <a class="text muted">
                        <strong>{{ctx.Locale.Tr "repo.issues.review.reviewers"}}</strong> {{if and $data.CanChooseReviewer}}{{svg "octicon-gear"}}{{end}}
                </a>
                <div class="menu flex-items-menu">
@@ -19,7 +17,8 @@
                                {{if .User}}
                                        <a class="item muted {{if .Requested}}checked{{end}}" href="{{.User.HomeLink}}" data-value="{{.ItemID}}" data-can-change="{{.CanChange}}"
                                                {{if not .CanChange}}data-tooltip-content="{{ctx.Locale.Tr "repo.issues.remove_request_review_block"}}"{{end}}>
-                                               {{svg "octicon-check"}} {{ctx.AvatarUtils.Avatar .User 20}} {{template "repo/search_name" .User}}
+                                               <span class="item-check-mark">{{svg "octicon-check"}}</span>
+                                               {{ctx.AvatarUtils.Avatar .User 20}} {{template "repo/search_name" .User}}
                                        </a>
                                {{end}}
                        {{end}}
@@ -29,7 +28,8 @@
                                        {{if .Team}}
                                                <a class="item muted {{if .Requested}}checked{{end}}" href="#" data-value="{{.ItemID}}" data-can-change="{{.CanChange}}"
                                                        {{if not .CanChange}} data-tooltip-content="{{ctx.Locale.Tr "repo.issues.remove_request_review_block"}}"{{end}}>
-                                                       {{svg "octicon-check"}} {{svg "octicon-people" 20}} {{$data.RepoOwnerName}}/{{.Team.Name}}
+                                                       <span class="item-check-mark">{{svg "octicon-check"}}</span>
+                                                       {{svg "octicon-people" 20}} {{$data.RepoOwnerName}}/{{.Team.Name}}
                                                </a>
                                        {{end}}
                                {{end}}
index 7a4027475978fc1580a7968b270bcfda675d08ee..0fae1e9e1c1a50b43e53b12750441e609a40dabb 100644 (file)
@@ -2,13 +2,12 @@
        {{template "repo/issue/branch_selector_field" $}}
 
        {{if .Issue.IsPull}}
-               {{template "repo/issue/sidebar/reviewer_list" dict "IssueSidebarReviewersData" $.IssueSidebarReviewersData}}
+               {{template "repo/issue/sidebar/reviewer_list" $.IssueSidebarReviewersData}}
                {{template "repo/issue/sidebar/wip_switch" $}}
                <div class="divider"></div>
        {{end}}
 
-       {{template "repo/issue/labels/labels_selector_field" $}}
-       {{template "repo/issue/labels/labels_sidebar" dict "root" $}}
+       {{template "repo/issue/sidebar/label_list" $.IssueSidebarLabelsData}}
 
        {{template "repo/issue/sidebar/milestone_list" $}}
        {{template "repo/issue/sidebar/project_list" $}}
index 185a5f6f558e52a857e2b16ca0ba34c1c177cb20..ff8342d29aae78e4dae5faf7a649e77ec070c09a 100644 (file)
@@ -50,7 +50,7 @@
   width: 300px;
 }
 
-.issue-sidebar-combo .ui.dropdown .item:not(.checked) svg.octicon-check {
+.issue-sidebar-combo .ui.dropdown .item:not(.checked) .item-check-mark {
   visibility: hidden;
 }
 /* ideally, we should move these styles to ".ui.dropdown .menu.flex-items-menu > .item ...", could be done later */
@@ -62,6 +62,8 @@
 .issue-content-right .dropdown > .menu {
   max-width: 270px;
   min-width: 0;
+  max-height: 500px;
+  overflow-x: auto;
 }
 
 @media (max-width: 767.98px) {
   left: 0;
 }
 
-.repository .select-label .desc {
-  padding-left: 23px;
-}
-
 /* 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) {
index 9b4b144a0090d610e0551e7ff50dc3185a42beab..0a25d31da9e05dd4a42a1bfd306ff80510344951 100644 (file)
@@ -47,6 +47,7 @@
 }
 
 .archived-label-hint {
-  float: right;
-  margin: -12px;
+  position: absolute;
+  top: 10px;
+  right: 5px;
 }
index beec92d152b7d654f860dcf26b16293ae02c7bba..56c5915b6dbf84e997e444c2b863a20c15055c17 100644 (file)
@@ -32,13 +32,13 @@ export function initGlobalDropdown() {
   const $uiDropdowns = fomanticQuery('.ui.dropdown');
 
   // do not init "custom" dropdowns, "custom" dropdowns are managed by their own code.
-  $uiDropdowns.filter(':not(.custom)').dropdown();
+  $uiDropdowns.filter(':not(.custom)').dropdown({hideDividers: 'empty'});
 
   // The "jump" means this dropdown is mainly used for "menu" purpose,
   // clicking an item will jump to somewhere else or trigger an action/function.
   // When a dropdown is used for non-refresh actions with tippy,
   // it must have this "jump" class to hide the tippy when dropdown is closed.
-  $uiDropdowns.filter('.jump').dropdown({
+  $uiDropdowns.filter('.jump').dropdown('setting', {
     action: 'hide',
     onShow() {
       // hide associated tooltip while dropdown is open
index d5416159887fac7d4b5869ca5935c6bf893c413d..f408eb43ba0d866eb90e77ef7a7727b2507fbf0a 100644 (file)
@@ -1,6 +1,6 @@
 import {fomanticQuery} from '../modules/fomantic/base.ts';
 import {POST} from '../modules/fetch.ts';
-import {queryElemChildren, toggleElem} from '../utils/dom.ts';
+import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
 
 // if there are draft comments, confirm before reloading, to avoid losing comments
 export function issueSidebarReloadConfirmDraftComment() {
@@ -27,20 +27,37 @@ function collectCheckedValues(elDropdown: HTMLElement) {
 }
 
 export function initIssueSidebarComboList(container: HTMLElement) {
-  if (!container) return;
-
   const updateUrl = container.getAttribute('data-update-url');
   const elDropdown = container.querySelector<HTMLElement>(':scope > .ui.dropdown');
   const elList = container.querySelector<HTMLElement>(':scope > .ui.list');
   const elComboValue = container.querySelector<HTMLInputElement>(':scope > .combo-value');
-  const initialValues = collectCheckedValues(elDropdown);
+  let initialValues = collectCheckedValues(elDropdown);
 
   elDropdown.addEventListener('click', (e) => {
     const elItem = (e.target as HTMLElement).closest('.item');
     if (!elItem) return;
     e.preventDefault();
-    if (elItem.getAttribute('data-can-change') !== 'true') return;
-    elItem.classList.toggle('checked');
+    if (elItem.hasAttribute('data-can-change') && elItem.getAttribute('data-can-change') !== 'true') return;
+
+    if (elItem.matches('.clear-selection')) {
+      queryElems(elDropdown, '.menu > .item', (el) => el.classList.remove('checked'));
+      elComboValue.value = '';
+      return;
+    }
+
+    const scope = elItem.getAttribute('data-scope');
+    if (scope) {
+      // scoped items could only be checked one at a time
+      const elSelected = elDropdown.querySelector<HTMLElement>(`.menu > .item.checked[data-scope="${CSS.escape(scope)}"]`);
+      if (elSelected === elItem) {
+        elItem.classList.toggle('checked');
+      } else {
+        queryElems(elDropdown, `.menu > .item[data-scope="${CSS.escape(scope)}"]`, (el) => el.classList.remove('checked'));
+        elItem.classList.toggle('checked', true);
+      }
+    } else {
+      elItem.classList.toggle('checked');
+    }
     elComboValue.value = collectCheckedValues(elDropdown).join(',');
   });
 
@@ -61,29 +78,28 @@ export function initIssueSidebarComboList(container: HTMLElement) {
     if (changed) issueSidebarReloadConfirmDraftComment();
   };
 
-  const syncList = (changedValues) => {
+  const syncUiList = (changedValues) => {
     const elEmptyTip = elList.querySelector('.item.empty-list');
     queryElemChildren(elList, '.item:not(.empty-list)', (el) => el.remove());
     for (const value of changedValues) {
-      const el = elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${value}"]`);
+      const el = elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${CSS.escape(value)}"]`);
       const listItem = el.cloneNode(true) as HTMLElement;
-      listItem.querySelector('svg.octicon-check')?.remove();
+      queryElems(listItem, '.item-check-mark, .item-secondary-info', (el) => el.remove());
       elList.append(listItem);
     }
     const hasItems = Boolean(elList.querySelector('.item:not(.empty-list)'));
     toggleElem(elEmptyTip, !hasItems);
   };
 
-  fomanticQuery(elDropdown).dropdown({
+  fomanticQuery(elDropdown).dropdown('setting', {
     action: 'nothing', // do not hide the menu if user presses Enter
     fullTextSearch: 'exact',
     async onHide() {
+      // TODO: support "Esc" to cancel the selection. Use partial page loading to avoid losing inputs.
       const changedValues = collectCheckedValues(elDropdown);
-      if (updateUrl) {
-        await updateToBackend(changedValues); // send requests to backend and reload the page
-      } else {
-        syncList(changedValues); // only update the list in the sidebar
-      }
+      syncUiList(changedValues);
+      if (updateUrl) await updateToBackend(changedValues);
+      initialValues = changedValues;
     },
   });
 }
diff --git a/web_src/js/features/repo-issue-sidebar.md b/web_src/js/features/repo-issue-sidebar.md
new file mode 100644 (file)
index 0000000..3022b52
--- /dev/null
@@ -0,0 +1,27 @@
+A sidebar combo (dropdown+list) is like this:
+
+```html
+<div class="issue-sidebar-combo" data-update-url="...">
+  <input class="combo-value" name="..." type="hidden" value="...">
+  <div class="ui dropdown">
+    <div class="menu">
+      <div class="item clear-selection">clear</div>
+      <div class="item" data-value="..." data-scope="...">
+        <span class="item-check-mark">...</span>
+        ...
+      </div>
+    </div>
+  </div>
+  <div class="ui list">
+    <span class="item empty-list">no item</span>
+    <span class="item">...</span>
+  </div>
+</div>
+```
+
+When the selected items change, the `combo-value` input will be updated.
+If there is `data-update-url`, it also calls backend to attach/detach the changed items.
+
+Also, the changed items will be syncronized to the `ui list` items.
+
+The items with the same data-scope only allow one selected at a time.
index 4a1ef02aab72783c518d8c2f8935d2703fbfe18c..52878848e8cb3b09af8c52effe2b6fb48cd4933f 100644 (file)
@@ -3,7 +3,7 @@ import {POST} from '../modules/fetch.ts';
 import {updateIssuesMeta} from './repo-common.ts';
 import {svg} from '../svg.ts';
 import {htmlEscape} from 'escape-goat';
-import {toggleElem} from '../utils/dom.ts';
+import {queryElems, toggleElem} from '../utils/dom.ts';
 import {initIssueSidebarComboList, issueSidebarReloadConfirmDraftComment} from './repo-issue-sidebar-combolist.ts';
 
 function initBranchSelector() {
@@ -28,7 +28,7 @@ function initBranchSelector() {
     } else {
       // for new issue, only update UI&form, do not send request/reload
       const selectedHiddenSelector = this.getAttribute('data-id-selector');
-      document.querySelector(selectedHiddenSelector).value = selectedValue;
+      document.querySelector<HTMLInputElement>(selectedHiddenSelector).value = selectedValue;
       elSelectBranch.querySelector('.text-branch-name').textContent = selectedText;
     }
   });
@@ -53,7 +53,7 @@ function initListSubmits(selector, outerSelector) {
         for (const [elementId, item] of itemEntries) {
           await updateIssuesMeta(
             item['update-url'],
-            item.action,
+            item['action'],
             item['issue-id'],
             elementId,
           );
@@ -80,14 +80,14 @@ function initListSubmits(selector, outerSelector) {
       if (scope) {
         // Enable only clicked item for scoped labels
         if (this.getAttribute('data-scope') !== scope) {
-          return true;
+          return;
         }
         if (this !== clickedItem && !this.classList.contains('checked')) {
-          return true;
+          return;
         }
       } else if (this !== clickedItem) {
         // Toggle for other labels
-        return true;
+        return;
       }
 
       if (this.classList.contains('checked')) {
@@ -258,13 +258,13 @@ export function initRepoIssueSidebar() {
   initRepoIssueDue();
 
   // TODO: refactor the legacy initListSubmits&selectItem to initIssueSidebarComboList
-  initListSubmits('select-label', 'labels');
   initListSubmits('select-assignees', 'assignees');
   initListSubmits('select-assignees-modify', 'assignees');
+  selectItem('.select-assignee', '#assignee_id');
+
   selectItem('.select-project', '#project_id');
   selectItem('.select-milestone', '#milestone_id');
-  selectItem('.select-assignee', '#assignee_id');
 
-  // init the combo list: a dropdown for selecting reviewers, and a list for showing selected reviewers and related actions
-  initIssueSidebarComboList(document.querySelector('.issue-sidebar-combo[data-sidebar-combo-for="reviewers"]'));
+  // init the combo list: a dropdown for selecting items, and a list for showing selected items and related actions
+  queryElems<HTMLElement>(document, '.issue-sidebar-combo', (el) => initIssueSidebarComboList(el));
 }
index 92916ec8d72bc4a9e2528007aff1ef84a178e271..7457531ece47d4bc24f252b9ee82589a91fa43d7 100644 (file)
@@ -98,6 +98,7 @@ export function initRepoIssueSidebarList() {
     });
   });
 
+  // FIXME: it is wrong place to init ".ui.dropdown.label-filter"
   $('.menu .ui.dropdown.label-filter').on('keydown', (e) => {
     if (e.altKey && e.key === 'Enter') {
       const selectedItem = document.querySelector('.menu .ui.dropdown.label-filter .menu .item.selected');
@@ -106,7 +107,6 @@ export function initRepoIssueSidebarList() {
       }
     }
   });
-  $('.ui.dropdown.label-filter, .ui.dropdown.select-label').dropdown('setting', {'hideDividers': 'empty'}).dropdown('refreshItems');
 }
 
 export function initRepoIssueCommentDelete() {
@@ -652,19 +652,6 @@ function initIssueTemplateCommentEditors($commentForm) {
   }
 }
 
-// This function used to show and hide archived label on issue/pr
-//  page in the sidebar where we select the labels
-//  If we have any archived label tagged to issue and pr. We will show that
-//  archived label with checked classed otherwise we will hide it
-//  with the help of this function.
-//  This function runs globally.
-export function initArchivedLabelHandler() {
-  if (!document.querySelector('.archived-label-hint')) return;
-  for (const label of document.querySelectorAll('[data-is-archived]')) {
-    toggleElem(label, label.classList.contains('checked'));
-  }
-}
-
 export function initRepoCommentFormAndSidebar() {
   const $commentForm = $('.comment.form');
   if (!$commentForm.length) return;
index 487aac97aa3a59e8c9e71626ddde10779b5d0c94..eeead37333bd8d9c9680a97788d6935716bab80a 100644 (file)
@@ -30,7 +30,7 @@ import {
   initRepoIssueWipTitle,
   initRepoPullRequestMergeInstruction,
   initRepoPullRequestAllowMaintainerEdit,
-  initRepoPullRequestReview, initRepoIssueSidebarList, initArchivedLabelHandler,
+  initRepoPullRequestReview, initRepoIssueSidebarList,
 } from './features/repo-issue.ts';
 import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts';
 import {initRepoTopicBar} from './features/repo-home.ts';
@@ -182,7 +182,6 @@ onDomReady(() => {
     initRepoIssueContentHistory,
     initRepoIssueList,
     initRepoIssueSidebarList,
-    initArchivedLabelHandler,
     initRepoIssueReferenceRepositorySearch,
     initRepoIssueTimeTracking,
     initRepoIssueWipTitle,
index 79ce05a7adb22deef659c0bbed9980aa78866743..29b34dd1e3f4252c98998f258adfaf8b8968c8f1 100644 (file)
@@ -3,7 +3,7 @@ import type {Promisable} from 'type-fest';
 import type $ from 'jquery';
 
 type ElementArg = Element | string | NodeListOf<Element> | Array<Element> | ReturnType<typeof $>;
-type ElementsCallback = (el: Element) => Promisable<any>;
+type ElementsCallback<T extends Element> = (el: T) => Promisable<any>;
 type ElementsCallbackWithArgs = (el: Element, ...args: any[]) => Promisable<any>;
 type ArrayLikeIterable<T> = ArrayLike<T> & Iterable<T>; // for NodeListOf and Array
 
@@ -58,7 +58,7 @@ export function isElemHidden(el: ElementArg) {
   return res[0];
 }
 
-function applyElemsCallback<T extends Element>(elems: ArrayLikeIterable<T>, fn?: ElementsCallback): ArrayLikeIterable<T> {
+function applyElemsCallback<T extends Element>(elems: ArrayLikeIterable<T>, fn?: ElementsCallback<T>): ArrayLikeIterable<T> {
   if (fn) {
     for (const el of elems) {
       fn(el);
@@ -67,7 +67,7 @@ function applyElemsCallback<T extends Element>(elems: ArrayLikeIterable<T>, fn?:
   return elems;
 }
 
-export function queryElemSiblings<T extends Element>(el: Element, selector = '*', fn?: ElementsCallback): ArrayLikeIterable<T> {
+export function queryElemSiblings<T extends Element>(el: Element, selector = '*', fn?: ElementsCallback<T>): ArrayLikeIterable<T> {
   const elems = Array.from(el.parentNode.children) as T[];
   return applyElemsCallback<T>(elems.filter((child: Element) => {
     return child !== el && child.matches(selector);
@@ -75,13 +75,13 @@ export function queryElemSiblings<T extends Element>(el: Element, selector = '*'
 }
 
 // it works like jQuery.children: only the direct children are selected
-export function queryElemChildren<T extends Element>(parent: Element | ParentNode, selector = '*', fn?: ElementsCallback): ArrayLikeIterable<T> {
+export function queryElemChildren<T extends Element>(parent: Element | ParentNode, selector = '*', fn?: ElementsCallback<T>): ArrayLikeIterable<T> {
   return applyElemsCallback<T>(parent.querySelectorAll(`:scope > ${selector}`), fn);
 }
 
 // it works like parent.querySelectorAll: all descendants are selected
 // in the future, all "queryElems(document, ...)" should be refactored to use a more specific parent
-export function queryElems<T extends Element>(parent: Element | ParentNode, selector: string, fn?: ElementsCallback): ArrayLikeIterable<T> {
+export function queryElems<T extends Element>(parent: Element | ParentNode, selector: string, fn?: ElementsCallback<T>): ArrayLikeIterable<T> {
   return applyElemsCallback<T>(parent.querySelectorAll(selector), fn);
 }