mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 21:28:11 +09:00 
			
		
		
		
	Refactor sidebar label selector (#32460)
Introduce `issueSidebarLabelsData` to handle all sidebar labels related data.
This commit is contained in:
		| @@ -788,7 +788,11 @@ func CompareDiff(ctx *context.Context) { | |||||||
|  |  | ||||||
| 		if !nothingToCompare { | 		if !nothingToCompare { | ||||||
| 			// Setup information for new form. | 			// 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() { | 			if ctx.Written() { | ||||||
| 				return | 				return | ||||||
| 			} | 			} | ||||||
| @@ -796,6 +800,10 @@ func CompareDiff(ctx *context.Context) { | |||||||
| 			if ctx.Written() { | 			if ctx.Written() { | ||||||
| 				return | 				return | ||||||
| 			} | 			} | ||||||
|  | 			_, templateErrs := setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates, labelsData) | ||||||
|  | 			if len(templateErrs) > 0 { | ||||||
|  | 				ctx.Flash.Warning(renderErrorOfTemplates(ctx, templateErrs), true) | ||||||
|  | 			} | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 	beforeCommitID := ctx.Data["BeforeCommitID"].(string) | 	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["Title"] = "Comparing " + base.ShortSha(beforeCommitID) + separator + base.ShortSha(afterCommitID) | ||||||
|  |  | ||||||
| 	ctx.Data["IsDiffCompare"] = true | 	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 content, ok := ctx.Data["content"].(string); ok && content != "" { | ||||||
| 		// If a template content is set, prepend the "content". In this case that's only | 		// If a template content is set, prepend the "content". In this case that's only | ||||||
|   | |||||||
| @@ -870,51 +870,112 @@ func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, is | |||||||
| 	ctx.Data["IssueSidebarReviewersData"] = data | 	ctx.Data["IssueSidebarReviewersData"] = data | ||||||
| } | } | ||||||
|  |  | ||||||
| // RetrieveRepoMetas find all the meta information of a repository | type issueSidebarLabelsData struct { | ||||||
| func RetrieveRepoMetas(ctx *context.Context, repo *repo_model.Repository, isPull bool) []*issues_model.Label { | 	Repository       *repo_model.Repository | ||||||
| 	if !ctx.Repo.CanWriteIssuesOrPulls(isPull) { | 	RepoLink         string | ||||||
| 		return nil | 	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{}) | 	labels, err := issues_model.GetLabelsByRepoID(ctx, repo.ID, "", db.ListOptions{}) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		ctx.ServerError("GetLabelsByRepoID", err) | 		ctx.ServerError("GetLabelsByRepoID", err) | ||||||
| 		return nil | 		return nil | ||||||
| 	} | 	} | ||||||
| 	ctx.Data["Labels"] = labels | 	labelsData.RepoLabels = labels | ||||||
|  |  | ||||||
| 	if repo.Owner.IsOrganization() { | 	if repo.Owner.IsOrganization() { | ||||||
| 		orgLabels, err := issues_model.GetLabelsByOrgID(ctx, repo.Owner.ID, ctx.FormString("sort"), db.ListOptions{}) | 		orgLabels, err := issues_model.GetLabelsByOrgID(ctx, repo.Owner.ID, ctx.FormString("sort"), db.ListOptions{}) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return 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 | // retrieveRepoMetasForIssueWriter finds some the meta information of a repository for an issue/pr writer | ||||||
| 		labels = append(labels, orgLabels...) | func retrieveRepoMetasForIssueWriter(ctx *context.Context, repo *repo_model.Repository, isPull bool) { | ||||||
|  | 	if !ctx.Repo.CanWriteIssuesOrPulls(isPull) { | ||||||
|  | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	RetrieveRepoMilestonesAndAssignees(ctx, repo) | 	RetrieveRepoMilestonesAndAssignees(ctx, repo) | ||||||
| 	if ctx.Written() { | 	if ctx.Written() { | ||||||
| 		return nil | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	retrieveProjects(ctx, repo) | 	retrieveProjects(ctx, repo) | ||||||
| 	if ctx.Written() { | 	if ctx.Written() { | ||||||
| 		return nil | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	PrepareBranchList(ctx) | 	PrepareBranchList(ctx) | ||||||
| 	if ctx.Written() { | 	if ctx.Written() { | ||||||
| 		return nil | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// Contains true if the user can create issue dependencies | 	// Contains true if the user can create issue dependencies | ||||||
| 	ctx.Data["CanCreateIssueDependencies"] = ctx.Repo.CanCreateIssueDependencies(ctx, ctx.Doer, isPull) | 	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. | // 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) | 	commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return false, nil | 		return false, nil | ||||||
| @@ -951,26 +1012,9 @@ func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles | |||||||
| 			ctx.Data["Fields"] = template.Fields | 			ctx.Data["Fields"] = template.Fields | ||||||
| 			ctx.Data["TemplateFile"] = template.FileName | 			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 { | 		labelsData.SetSelectedLabelNames(template.Labels) | ||||||
| 				for _, repoLabel := range repoLabels { |  | ||||||
| 					if strings.EqualFold(repoLabel.Name, metaLabel) { |  | ||||||
| 						repoLabel.IsChecked = true |  | ||||||
| 						labelIDs = append(labelIDs, strconv.FormatInt(repoLabel.ID, 10)) |  | ||||||
| 						break |  | ||||||
| 					} |  | ||||||
| 				} |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 		selectedAssigneeIDs := make([]int64, 0, len(template.Assignees)) | 		selectedAssigneeIDs := make([]int64, 0, len(template.Assignees)) | ||||||
| 		selectedAssigneeIDStrings := make([]string, 0, len(template.Assignees)) | 		selectedAssigneeIDStrings := make([]string, 0, len(template.Assignees)) | ||||||
| 		if userIDs, err := user_model.GetUserIDsByNames(ctx, template.Assignees, false); err == nil { | 		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> | 		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 | 			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["HasSelectedAssignee"] = len(selectedAssigneeIDs) > 0 | ||||||
| 		ctx.Data["assignee_ids"] = strings.Join(selectedAssigneeIDStrings, ",") | 		ctx.Data["assignee_ids"] = strings.Join(selectedAssigneeIDStrings, ",") | ||||||
| 		ctx.Data["SelectedAssigneeIDs"] = selectedAssigneeIDs | 		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) | 	tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		ctx.ServerError("GetTagNamesByRepoID", err) | 		ctx.ServerError("GetTagNamesByRepoID", err) | ||||||
| @@ -1052,7 +1101,7 @@ func NewIssue(ctx *context.Context) { | |||||||
| 	ctx.Data["Tags"] = tags | 	ctx.Data["Tags"] = tags | ||||||
|  |  | ||||||
| 	ret := issue_service.ParseTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) | 	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 { | 	for k, v := range errs { | ||||||
| 		ret.TemplateErrors[k] = v | 		ret.TemplateErrors[k] = v | ||||||
| 	} | 	} | ||||||
| @@ -1161,34 +1210,25 @@ func ValidateRepoMetas(ctx *context.Context, form forms.CreateIssueForm, isPull | |||||||
| 		err  error | 		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() { | 	if ctx.Written() { | ||||||
| 		return ret | 		return ret | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	var labelIDs []int64 | 	var labelIDs []int64 | ||||||
| 	hasSelected := false |  | ||||||
| 	// Check labels. | 	// Check labels. | ||||||
| 	if len(form.LabelIDs) > 0 { | 	if len(form.LabelIDs) > 0 { | ||||||
| 		labelIDs, err = base.StringsToInt64s(strings.Split(form.LabelIDs, ",")) | 		labelIDs, err = base.StringsToInt64s(strings.Split(form.LabelIDs, ",")) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return ret | 			return ret | ||||||
| 		} | 		} | ||||||
| 		labelIDMark := make(container.Set[int64]) | 		labelsData.SetSelectedLabelIDs(labelIDs) | ||||||
| 		labelIDMark.AddMultiple(labelIDs...) |  | ||||||
|  |  | ||||||
| 		for i := range labels { |  | ||||||
| 			if labelIDMark.Contains(labels[i].ID) { |  | ||||||
| 				labels[i].IsChecked = true |  | ||||||
| 				hasSelected = true |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	ctx.Data["Labels"] = labels |  | ||||||
| 	ctx.Data["HasSelectedLabel"] = hasSelected |  | ||||||
| 	ctx.Data["label_ids"] = form.LabelIDs |  | ||||||
|  |  | ||||||
| 	// Check milestone. | 	// Check milestone. | ||||||
| 	milestoneID := form.MilestoneID | 	milestoneID := form.MilestoneID | ||||||
| 	if milestoneID > 0 { | 	if milestoneID > 0 { | ||||||
| @@ -1579,38 +1619,15 @@ func ViewIssue(ctx *context.Context) { | |||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// Metas. | 	retrieveRepoMetasForIssueWriter(ctx, repo, issue.IsPull) | ||||||
| 	// Check labels. | 	if ctx.Written() { | ||||||
| 	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) |  | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| 	ctx.Data["Labels"] = labels | 	labelsData := retrieveRepoLabels(ctx, repo, issue.ID, issue.IsPull) | ||||||
|  | 	if ctx.Written() { | ||||||
| 	if repo.Owner.IsOrganization() { | 		return | ||||||
| 		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...) |  | ||||||
| 	} | 	} | ||||||
|  | 	labelsData.SetSelectedLabels(issue.Labels) | ||||||
| 	hasSelected := false |  | ||||||
| 	for i := range labels { |  | ||||||
| 		if labelIDMark.Contains(labels[i].ID) { |  | ||||||
| 			labels[i].IsChecked = true |  | ||||||
| 			hasSelected = true |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	ctx.Data["HasSelectedLabel"] = hasSelected |  | ||||||
|  |  | ||||||
| 	// Check milestone and assignee. | 	// Check milestone and assignee. | ||||||
| 	if ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { | 	if ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { | ||||||
|   | |||||||
| @@ -53,11 +53,11 @@ func InitializeLabels(ctx *context.Context) { | |||||||
| 	ctx.Redirect(ctx.Repo.RepoLink + "/labels") | 	ctx.Redirect(ctx.Repo.RepoLink + "/labels") | ||||||
| } | } | ||||||
|  |  | ||||||
| // RetrieveLabels find all the labels of a repository and organization | // RetrieveLabelsForList find all the labels of a repository and organization, it is only used by "/labels" page to list all labels | ||||||
| func RetrieveLabels(ctx *context.Context) { | func RetrieveLabelsForList(ctx *context.Context) { | ||||||
| 	labels, err := issues_model.GetLabelsByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormString("sort"), db.ListOptions{}) | 	labels, err := issues_model.GetLabelsByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormString("sort"), db.ListOptions{}) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		ctx.ServerError("RetrieveLabels.GetLabels", err) | 		ctx.ServerError("RetrieveLabelsForList.GetLabels", err) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|   | |||||||
| @@ -62,7 +62,7 @@ func TestRetrieveLabels(t *testing.T) { | |||||||
| 		contexttest.LoadUser(t, ctx, 2) | 		contexttest.LoadUser(t, ctx, 2) | ||||||
| 		contexttest.LoadRepo(t, ctx, testCase.RepoID) | 		contexttest.LoadRepo(t, ctx, testCase.RepoID) | ||||||
| 		ctx.Req.Form.Set("sort", testCase.Sort) | 		ctx.Req.Form.Set("sort", testCase.Sort) | ||||||
| 		RetrieveLabels(ctx) | 		RetrieveLabelsForList(ctx) | ||||||
| 		assert.False(t, ctx.Written()) | 		assert.False(t, ctx.Written()) | ||||||
| 		labels, ok := ctx.Data["Labels"].([]*issues_model.Label) | 		labels, ok := ctx.Data["Labels"].([]*issues_model.Label) | ||||||
| 		assert.True(t, ok) | 		assert.True(t, ok) | ||||||
|   | |||||||
| @@ -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("/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("/pulls/posters", repo.PullPosters) | ||||||
| 		m.Get("/comments/{id}/attachments", repo.GetCommentAttachments) | 		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("/milestones", repo.Milestones) | ||||||
| 		m.Get("/milestone/{id}", context.RepoRef(), repo.MilestoneIssuesAndPulls) | 		m.Get("/milestone/{id}", context.RepoRef(), repo.MilestoneIssuesAndPulls) | ||||||
| 		m.Group("/{type:issues|pulls}", func() { | 		m.Group("/{type:issues|pulls}", func() { | ||||||
|   | |||||||
| @@ -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> |  | ||||||
| @@ -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>  {{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> |  | ||||||
| @@ -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> |  | ||||||
| @@ -18,15 +18,15 @@ | |||||||
| 						<input type="hidden" name="template-file" value="{{.TemplateFile}}"> | 						<input type="hidden" name="template-file" value="{{.TemplateFile}}"> | ||||||
| 						{{range .Fields}} | 						{{range .Fields}} | ||||||
| 							{{if eq .Type "input"}} | 							{{if eq .Type "input"}} | ||||||
| 								{{template "repo/issue/fields/input" "item" .}} | 								{{template "repo/issue/fields/input" dict "item" .}} | ||||||
| 							{{else if eq .Type "markdown"}} | 							{{else if eq .Type "markdown"}} | ||||||
| 								{{template "repo/issue/fields/markdown" "item" .}} | 								{{template "repo/issue/fields/markdown" dict "item" .}} | ||||||
| 							{{else if eq .Type "textarea"}} | 							{{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"}} | 							{{else if eq .Type "dropdown"}} | ||||||
| 								{{template "repo/issue/fields/dropdown" "item" .}} | 								{{template "repo/issue/fields/dropdown" dict "item" .}} | ||||||
| 							{{else if eq .Type "checkboxes"}} | 							{{else if eq .Type "checkboxes"}} | ||||||
| 								{{template "repo/issue/fields/checkboxes" "item" .}} | 								{{template "repo/issue/fields/checkboxes" dict "item" .}} | ||||||
| 							{{end}} | 							{{end}} | ||||||
| 						{{end}} | 						{{end}} | ||||||
| 					{{else}} | 					{{else}} | ||||||
| @@ -49,13 +49,11 @@ | |||||||
| 	<div class="issue-content-right ui segment"> | 	<div class="issue-content-right ui segment"> | ||||||
| 		{{template "repo/issue/branch_selector_field" $}} | 		{{template "repo/issue/branch_selector_field" $}} | ||||||
| 		{{if .PageIsComparePull}} | 		{{if .PageIsComparePull}} | ||||||
| 			{{template "repo/issue/sidebar/reviewer_list" dict "IssueSidebarReviewersData" $.IssueSidebarReviewersData}} | 			{{template "repo/issue/sidebar/reviewer_list" $.IssueSidebarReviewersData}} | ||||||
| 			<div class="divider"></div> | 			<div class="divider"></div> | ||||||
| 		{{end}} | 		{{end}} | ||||||
|  |  | ||||||
| 		<input id="label_ids" name="label_ids" type="hidden" value="{{.label_ids}}"> | 		{{template "repo/issue/sidebar/label_list" $.IssueSidebarLabelsData}} | ||||||
| 		{{template "repo/issue/labels/labels_selector_field" .}} |  | ||||||
| 		{{template "repo/issue/labels/labels_sidebar" dict "root" $}} |  | ||||||
|  |  | ||||||
| 		<div class="divider"></div> | 		<div class="divider"></div> | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										51
									
								
								templates/repo/issue/sidebar/label_list.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								templates/repo/issue/sidebar/label_list.tmpl
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||||
							
								
								
									
										11
									
								
								templates/repo/issue/sidebar/label_list_item.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								templates/repo/issue/sidebar/label_list_item.tmpl
									
									
									
									
									
										Normal file
									
								
							| @@ -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> | ||||||
| @@ -1,11 +1,9 @@ | |||||||
| {{$data := .IssueSidebarReviewersData}} | {{$data := .}} | ||||||
| {{$hasCandidates := or $data.Reviewers $data.TeamReviewers}} | {{$hasCandidates := or $data.Reviewers $data.TeamReviewers}} | ||||||
| <div class="issue-sidebar-combo" data-sidebar-combo-for="reviewers" | <div class="issue-sidebar-combo" {{if $data.IssueID}}data-update-url="{{$data.RepoLink}}/issues/request_review?issue_ids={{$data.IssueID}}"{{end}}> | ||||||
| 		{{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 */}} | 	<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}}"> | 	<div class="ui dropdown {{if or (not $hasCandidates) (not $data.CanChooseReviewer)}}disabled{{end}}"> | ||||||
| 		<a class="muted text"> | 		<a class="text muted"> | ||||||
| 			<strong>{{ctx.Locale.Tr "repo.issues.review.reviewers"}}</strong> {{if and $data.CanChooseReviewer}}{{svg "octicon-gear"}}{{end}} | 			<strong>{{ctx.Locale.Tr "repo.issues.review.reviewers"}}</strong> {{if and $data.CanChooseReviewer}}{{svg "octicon-gear"}}{{end}} | ||||||
| 		</a> | 		</a> | ||||||
| 		<div class="menu flex-items-menu"> | 		<div class="menu flex-items-menu"> | ||||||
| @@ -19,7 +17,8 @@ | |||||||
| 				{{if .User}} | 				{{if .User}} | ||||||
| 					<a class="item muted {{if .Requested}}checked{{end}}" href="{{.User.HomeLink}}" data-value="{{.ItemID}}" data-can-change="{{.CanChange}}" | 					<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}}> | 						{{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> | 					</a> | ||||||
| 				{{end}} | 				{{end}} | ||||||
| 			{{end}} | 			{{end}} | ||||||
| @@ -29,7 +28,8 @@ | |||||||
| 					{{if .Team}} | 					{{if .Team}} | ||||||
| 						<a class="item muted {{if .Requested}}checked{{end}}" href="#" data-value="{{.ItemID}}" data-can-change="{{.CanChange}}" | 						<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}}> | 							{{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> | 						</a> | ||||||
| 					{{end}} | 					{{end}} | ||||||
| 				{{end}} | 				{{end}} | ||||||
|   | |||||||
| @@ -2,13 +2,12 @@ | |||||||
| 	{{template "repo/issue/branch_selector_field" $}} | 	{{template "repo/issue/branch_selector_field" $}} | ||||||
|  |  | ||||||
| 	{{if .Issue.IsPull}} | 	{{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" $}} | 		{{template "repo/issue/sidebar/wip_switch" $}} | ||||||
| 		<div class="divider"></div> | 		<div class="divider"></div> | ||||||
| 	{{end}} | 	{{end}} | ||||||
|  |  | ||||||
| 	{{template "repo/issue/labels/labels_selector_field" $}} | 	{{template "repo/issue/sidebar/label_list" $.IssueSidebarLabelsData}} | ||||||
| 	{{template "repo/issue/labels/labels_sidebar" dict "root" $}} |  | ||||||
|  |  | ||||||
| 	{{template "repo/issue/sidebar/milestone_list" $}} | 	{{template "repo/issue/sidebar/milestone_list" $}} | ||||||
| 	{{template "repo/issue/sidebar/project_list" $}} | 	{{template "repo/issue/sidebar/project_list" $}} | ||||||
|   | |||||||
| @@ -50,7 +50,7 @@ | |||||||
|   width: 300px; |   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; |   visibility: hidden; | ||||||
| } | } | ||||||
| /* ideally, we should move these styles to ".ui.dropdown .menu.flex-items-menu > .item ...", could be done later */ | /* 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 { | .issue-content-right .dropdown > .menu { | ||||||
|   max-width: 270px; |   max-width: 270px; | ||||||
|   min-width: 0; |   min-width: 0; | ||||||
|  |   max-height: 500px; | ||||||
|  |   overflow-x: auto; | ||||||
| } | } | ||||||
|  |  | ||||||
| @media (max-width: 767.98px) { | @media (max-width: 767.98px) { | ||||||
| @@ -110,10 +112,6 @@ | |||||||
|   left: 0; |   left: 0; | ||||||
| } | } | ||||||
|  |  | ||||||
| .repository .select-label .desc { |  | ||||||
|   padding-left: 23px; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /* For the secondary pointing menu, respect its own border-bottom */ | /* For the secondary pointing menu, respect its own border-bottom */ | ||||||
| /* style reference: https://semantic-ui.com/collections/menu.html#pointing */ | /* style reference: https://semantic-ui.com/collections/menu.html#pointing */ | ||||||
| .repository .ui.tabs.container .ui.menu:not(.secondary.pointing) { | .repository .ui.tabs.container .ui.menu:not(.secondary.pointing) { | ||||||
|   | |||||||
| @@ -47,6 +47,7 @@ | |||||||
| } | } | ||||||
|  |  | ||||||
| .archived-label-hint { | .archived-label-hint { | ||||||
|   float: right; |   position: absolute; | ||||||
|   margin: -12px; |   top: 10px; | ||||||
|  |   right: 5px; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -32,13 +32,13 @@ export function initGlobalDropdown() { | |||||||
|   const $uiDropdowns = fomanticQuery('.ui.dropdown'); |   const $uiDropdowns = fomanticQuery('.ui.dropdown'); | ||||||
|  |  | ||||||
|   // do not init "custom" dropdowns, "custom" dropdowns are managed by their own code. |   // 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, |   // The "jump" means this dropdown is mainly used for "menu" purpose, | ||||||
|   // clicking an item will jump to somewhere else or trigger an action/function. |   // clicking an item will jump to somewhere else or trigger an action/function. | ||||||
|   // When a dropdown is used for non-refresh actions with tippy, |   // 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. |   // 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', |     action: 'hide', | ||||||
|     onShow() { |     onShow() { | ||||||
|       // hide associated tooltip while dropdown is open |       // hide associated tooltip while dropdown is open | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| import {fomanticQuery} from '../modules/fomantic/base.ts'; | import {fomanticQuery} from '../modules/fomantic/base.ts'; | ||||||
| import {POST} from '../modules/fetch.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 | // if there are draft comments, confirm before reloading, to avoid losing comments | ||||||
| export function issueSidebarReloadConfirmDraftComment() { | export function issueSidebarReloadConfirmDraftComment() { | ||||||
| @@ -27,20 +27,37 @@ function collectCheckedValues(elDropdown: HTMLElement) { | |||||||
| } | } | ||||||
|  |  | ||||||
| export function initIssueSidebarComboList(container: HTMLElement) { | export function initIssueSidebarComboList(container: HTMLElement) { | ||||||
|   if (!container) return; |  | ||||||
|  |  | ||||||
|   const updateUrl = container.getAttribute('data-update-url'); |   const updateUrl = container.getAttribute('data-update-url'); | ||||||
|   const elDropdown = container.querySelector<HTMLElement>(':scope > .ui.dropdown'); |   const elDropdown = container.querySelector<HTMLElement>(':scope > .ui.dropdown'); | ||||||
|   const elList = container.querySelector<HTMLElement>(':scope > .ui.list'); |   const elList = container.querySelector<HTMLElement>(':scope > .ui.list'); | ||||||
|   const elComboValue = container.querySelector<HTMLInputElement>(':scope > .combo-value'); |   const elComboValue = container.querySelector<HTMLInputElement>(':scope > .combo-value'); | ||||||
|   const initialValues = collectCheckedValues(elDropdown); |   let initialValues = collectCheckedValues(elDropdown); | ||||||
|  |  | ||||||
|   elDropdown.addEventListener('click', (e) => { |   elDropdown.addEventListener('click', (e) => { | ||||||
|     const elItem = (e.target as HTMLElement).closest('.item'); |     const elItem = (e.target as HTMLElement).closest('.item'); | ||||||
|     if (!elItem) return; |     if (!elItem) return; | ||||||
|     e.preventDefault(); |     e.preventDefault(); | ||||||
|     if (elItem.getAttribute('data-can-change') !== 'true') return; |     if (elItem.hasAttribute('data-can-change') && elItem.getAttribute('data-can-change') !== 'true') return; | ||||||
|     elItem.classList.toggle('checked'); |  | ||||||
|  |     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(','); |     elComboValue.value = collectCheckedValues(elDropdown).join(','); | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
| @@ -61,29 +78,28 @@ export function initIssueSidebarComboList(container: HTMLElement) { | |||||||
|     if (changed) issueSidebarReloadConfirmDraftComment(); |     if (changed) issueSidebarReloadConfirmDraftComment(); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   const syncList = (changedValues) => { |   const syncUiList = (changedValues) => { | ||||||
|     const elEmptyTip = elList.querySelector('.item.empty-list'); |     const elEmptyTip = elList.querySelector('.item.empty-list'); | ||||||
|     queryElemChildren(elList, '.item:not(.empty-list)', (el) => el.remove()); |     queryElemChildren(elList, '.item:not(.empty-list)', (el) => el.remove()); | ||||||
|     for (const value of changedValues) { |     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; |       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); |       elList.append(listItem); | ||||||
|     } |     } | ||||||
|     const hasItems = Boolean(elList.querySelector('.item:not(.empty-list)')); |     const hasItems = Boolean(elList.querySelector('.item:not(.empty-list)')); | ||||||
|     toggleElem(elEmptyTip, !hasItems); |     toggleElem(elEmptyTip, !hasItems); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   fomanticQuery(elDropdown).dropdown({ |   fomanticQuery(elDropdown).dropdown('setting', { | ||||||
|     action: 'nothing', // do not hide the menu if user presses Enter |     action: 'nothing', // do not hide the menu if user presses Enter | ||||||
|     fullTextSearch: 'exact', |     fullTextSearch: 'exact', | ||||||
|     async onHide() { |     async onHide() { | ||||||
|  |       // TODO: support "Esc" to cancel the selection. Use partial page loading to avoid losing inputs. | ||||||
|       const changedValues = collectCheckedValues(elDropdown); |       const changedValues = collectCheckedValues(elDropdown); | ||||||
|       if (updateUrl) { |       syncUiList(changedValues); | ||||||
|         await updateToBackend(changedValues); // send requests to backend and reload the page |       if (updateUrl) await updateToBackend(changedValues); | ||||||
|       } else { |       initialValues = changedValues; | ||||||
|         syncList(changedValues); // only update the list in the sidebar |  | ||||||
|       } |  | ||||||
|     }, |     }, | ||||||
|   }); |   }); | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										27
									
								
								web_src/js/features/repo-issue-sidebar.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								web_src/js/features/repo-issue-sidebar.md
									
									
									
									
									
										Normal file
									
								
							| @@ -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. | ||||||
| @@ -3,7 +3,7 @@ import {POST} from '../modules/fetch.ts'; | |||||||
| import {updateIssuesMeta} from './repo-common.ts'; | import {updateIssuesMeta} from './repo-common.ts'; | ||||||
| import {svg} from '../svg.ts'; | import {svg} from '../svg.ts'; | ||||||
| import {htmlEscape} from 'escape-goat'; | 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'; | import {initIssueSidebarComboList, issueSidebarReloadConfirmDraftComment} from './repo-issue-sidebar-combolist.ts'; | ||||||
|  |  | ||||||
| function initBranchSelector() { | function initBranchSelector() { | ||||||
| @@ -28,7 +28,7 @@ function initBranchSelector() { | |||||||
|     } else { |     } else { | ||||||
|       // for new issue, only update UI&form, do not send request/reload |       // for new issue, only update UI&form, do not send request/reload | ||||||
|       const selectedHiddenSelector = this.getAttribute('data-id-selector'); |       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; |       elSelectBranch.querySelector('.text-branch-name').textContent = selectedText; | ||||||
|     } |     } | ||||||
|   }); |   }); | ||||||
| @@ -53,7 +53,7 @@ function initListSubmits(selector, outerSelector) { | |||||||
|         for (const [elementId, item] of itemEntries) { |         for (const [elementId, item] of itemEntries) { | ||||||
|           await updateIssuesMeta( |           await updateIssuesMeta( | ||||||
|             item['update-url'], |             item['update-url'], | ||||||
|             item.action, |             item['action'], | ||||||
|             item['issue-id'], |             item['issue-id'], | ||||||
|             elementId, |             elementId, | ||||||
|           ); |           ); | ||||||
| @@ -80,14 +80,14 @@ function initListSubmits(selector, outerSelector) { | |||||||
|       if (scope) { |       if (scope) { | ||||||
|         // Enable only clicked item for scoped labels |         // Enable only clicked item for scoped labels | ||||||
|         if (this.getAttribute('data-scope') !== scope) { |         if (this.getAttribute('data-scope') !== scope) { | ||||||
|           return true; |           return; | ||||||
|         } |         } | ||||||
|         if (this !== clickedItem && !this.classList.contains('checked')) { |         if (this !== clickedItem && !this.classList.contains('checked')) { | ||||||
|           return true; |           return; | ||||||
|         } |         } | ||||||
|       } else if (this !== clickedItem) { |       } else if (this !== clickedItem) { | ||||||
|         // Toggle for other labels |         // Toggle for other labels | ||||||
|         return true; |         return; | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       if (this.classList.contains('checked')) { |       if (this.classList.contains('checked')) { | ||||||
| @@ -258,13 +258,13 @@ export function initRepoIssueSidebar() { | |||||||
|   initRepoIssueDue(); |   initRepoIssueDue(); | ||||||
|  |  | ||||||
|   // TODO: refactor the legacy initListSubmits&selectItem to initIssueSidebarComboList |   // TODO: refactor the legacy initListSubmits&selectItem to initIssueSidebarComboList | ||||||
|   initListSubmits('select-label', 'labels'); |  | ||||||
|   initListSubmits('select-assignees', 'assignees'); |   initListSubmits('select-assignees', 'assignees'); | ||||||
|   initListSubmits('select-assignees-modify', 'assignees'); |   initListSubmits('select-assignees-modify', 'assignees'); | ||||||
|   selectItem('.select-project', '#project_id'); |  | ||||||
|   selectItem('.select-milestone', '#milestone_id'); |  | ||||||
|   selectItem('.select-assignee', '#assignee_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 |   selectItem('.select-project', '#project_id'); | ||||||
|   initIssueSidebarComboList(document.querySelector('.issue-sidebar-combo[data-sidebar-combo-for="reviewers"]')); |   selectItem('.select-milestone', '#milestone_id'); | ||||||
|  |  | ||||||
|  |   // 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)); | ||||||
| } | } | ||||||
|   | |||||||
| @@ -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) => { |   $('.menu .ui.dropdown.label-filter').on('keydown', (e) => { | ||||||
|     if (e.altKey && e.key === 'Enter') { |     if (e.altKey && e.key === 'Enter') { | ||||||
|       const selectedItem = document.querySelector('.menu .ui.dropdown.label-filter .menu .item.selected'); |       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() { | 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() { | export function initRepoCommentFormAndSidebar() { | ||||||
|   const $commentForm = $('.comment.form'); |   const $commentForm = $('.comment.form'); | ||||||
|   if (!$commentForm.length) return; |   if (!$commentForm.length) return; | ||||||
|   | |||||||
| @@ -30,7 +30,7 @@ import { | |||||||
|   initRepoIssueWipTitle, |   initRepoIssueWipTitle, | ||||||
|   initRepoPullRequestMergeInstruction, |   initRepoPullRequestMergeInstruction, | ||||||
|   initRepoPullRequestAllowMaintainerEdit, |   initRepoPullRequestAllowMaintainerEdit, | ||||||
|   initRepoPullRequestReview, initRepoIssueSidebarList, initArchivedLabelHandler, |   initRepoPullRequestReview, initRepoIssueSidebarList, | ||||||
| } from './features/repo-issue.ts'; | } from './features/repo-issue.ts'; | ||||||
| import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts'; | import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts'; | ||||||
| import {initRepoTopicBar} from './features/repo-home.ts'; | import {initRepoTopicBar} from './features/repo-home.ts'; | ||||||
| @@ -182,7 +182,6 @@ onDomReady(() => { | |||||||
|     initRepoIssueContentHistory, |     initRepoIssueContentHistory, | ||||||
|     initRepoIssueList, |     initRepoIssueList, | ||||||
|     initRepoIssueSidebarList, |     initRepoIssueSidebarList, | ||||||
|     initArchivedLabelHandler, |  | ||||||
|     initRepoIssueReferenceRepositorySearch, |     initRepoIssueReferenceRepositorySearch, | ||||||
|     initRepoIssueTimeTracking, |     initRepoIssueTimeTracking, | ||||||
|     initRepoIssueWipTitle, |     initRepoIssueWipTitle, | ||||||
|   | |||||||
| @@ -3,7 +3,7 @@ import type {Promisable} from 'type-fest'; | |||||||
| import type $ from 'jquery'; | import type $ from 'jquery'; | ||||||
|  |  | ||||||
| type ElementArg = Element | string | NodeListOf<Element> | Array<Element> | ReturnType<typeof $>; | 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 ElementsCallbackWithArgs = (el: Element, ...args: any[]) => Promisable<any>; | ||||||
| type ArrayLikeIterable<T> = ArrayLike<T> & Iterable<T>; // for NodeListOf and Array | type ArrayLikeIterable<T> = ArrayLike<T> & Iterable<T>; // for NodeListOf and Array | ||||||
|  |  | ||||||
| @@ -58,7 +58,7 @@ export function isElemHidden(el: ElementArg) { | |||||||
|   return res[0]; |   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) { |   if (fn) { | ||||||
|     for (const el of elems) { |     for (const el of elems) { | ||||||
|       fn(el); |       fn(el); | ||||||
| @@ -67,7 +67,7 @@ function applyElemsCallback<T extends Element>(elems: ArrayLikeIterable<T>, fn?: | |||||||
|   return elems; |   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[]; |   const elems = Array.from(el.parentNode.children) as T[]; | ||||||
|   return applyElemsCallback<T>(elems.filter((child: Element) => { |   return applyElemsCallback<T>(elems.filter((child: Element) => { | ||||||
|     return child !== el && child.matches(selector); |     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 | // 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); |   return applyElemsCallback<T>(parent.querySelectorAll(`:scope > ${selector}`), fn); | ||||||
| } | } | ||||||
|  |  | ||||||
| // it works like parent.querySelectorAll: all descendants are selected | // it works like parent.querySelectorAll: all descendants are selected | ||||||
| // in the future, all "queryElems(document, ...)" should be refactored to use a more specific parent | // 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); |   return applyElemsCallback<T>(parent.querySelectorAll(selector), fn); | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user