Introduce `issueSidebarLabelsData` to handle all sidebar labels related data.
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
}
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)
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
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
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 {
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
}
}
- 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)
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
}
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 {
}
}
- // 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) {
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
}
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)
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() {
+++ /dev/null
-<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>
+++ /dev/null
-<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> {{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> {{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>
+++ /dev/null
-<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>
<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>
--- /dev/null
+{{$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>
--- /dev/null
+{{$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>
-{{$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">
{{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}}
{{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}}
{{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" $}}
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 */
.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) {
}
.archived-label-hint {
- float: right;
- margin: -12px;
+ position: absolute;
+ top: 10px;
+ right: 5px;
}
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
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() {
}
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(',');
});
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;
},
});
}
--- /dev/null
+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.
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() {
} 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;
}
});
for (const [elementId, item] of itemEntries) {
await updateIssuesMeta(
item['update-url'],
- item.action,
+ item['action'],
item['issue-id'],
elementId,
);
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')) {
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));
}
});
});
+ // 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');
}
}
});
- $('.ui.dropdown.label-filter, .ui.dropdown.select-label').dropdown('setting', {'hideDividers': 'empty'}).dropdown('refreshItems');
}
export function initRepoIssueCommentDelete() {
}
}
-// 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;
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';
initRepoIssueContentHistory,
initRepoIssueList,
initRepoIssueSidebarList,
- initArchivedLabelHandler,
initRepoIssueReferenceRepositorySearch,
initRepoIssueTimeTracking,
initRepoIssueWipTitle,
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
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);
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);
}
// 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);
}