diff --git a/modules/base/tool.go b/modules/base/tool.go index 9e43030f400..928c80700b8 100644 --- a/modules/base/tool.go +++ b/modules/base/tool.go @@ -147,6 +147,9 @@ func StringsToInt64s(strs []string) ([]int64, error) { } ints := make([]int64, 0, len(strs)) for _, s := range strs { + if s == "" { + continue + } n, err := strconv.ParseInt(s, 10, 64) if err != nil { return nil, err diff --git a/modules/base/tool_test.go b/modules/base/tool_test.go index 4af8b9bc4d5..86cccdf2092 100644 --- a/modules/base/tool_test.go +++ b/modules/base/tool_test.go @@ -152,6 +152,7 @@ func TestStringsToInt64s(t *testing.T) { } testSuccess(nil, nil) testSuccess([]string{}, []int64{}) + testSuccess([]string{""}, []int64{}) testSuccess([]string{"-1234"}, []int64{-1234}) testSuccess([]string{"1", "4", "16", "64", "256"}, []int64{1, 4, 16, 64, 256}) diff --git a/modules/container/set.go b/modules/container/set.go index adb77dcac7a..105533f2033 100644 --- a/modules/container/set.go +++ b/modules/container/set.go @@ -31,8 +31,8 @@ func (s Set[T]) AddMultiple(values ...T) { } } -// Contains determines whether a set contains the specified elements. -// Returns true if the set contains the specified element; otherwise, false. +// Contains determines whether a set contains all these elements. +// Returns true if the set contains all these elements; otherwise, false. func (s Set[T]) Contains(values ...T) bool { ret := true for _, value := range values { diff --git a/modules/container/set_test.go b/modules/container/set_test.go index 1502236034a..a8b7ff81908 100644 --- a/modules/container/set_test.go +++ b/modules/container/set_test.go @@ -18,7 +18,9 @@ func TestSet(t *testing.T) { assert.True(t, s.Contains("key1")) assert.True(t, s.Contains("key2")) + assert.True(t, s.Contains("key1", "key2")) assert.False(t, s.Contains("key3")) + assert.False(t, s.Contains("key1", "key3")) assert.True(t, s.Remove("key2")) assert.False(t, s.Contains("key2")) diff --git a/modules/templates/helper.go b/modules/templates/helper.go index efaa10624bd..3ef11772dc7 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -31,6 +31,7 @@ func NewFuncMap() template.FuncMap { "ctx": func() any { return nil }, // template context function "DumpVar": dumpVar, + "NIL": func() any { return nil }, // ----------------------------------------------------------------- // html/template related functions diff --git a/routers/web/repo/compare.go b/routers/web/repo/compare.go index 9a7d3dfbf65..a5fdba3fde6 100644 --- a/routers/web/repo/compare.go +++ b/routers/web/repo/compare.go @@ -788,19 +788,11 @@ func CompareDiff(ctx *context.Context) { if !nothingToCompare { // Setup information for new form. - retrieveRepoMetasForIssueWriter(ctx, ctx.Repo.Repository, true) + pageMetaData := retrieveRepoIssueMetaData(ctx, ctx.Repo.Repository, nil, true) if ctx.Written() { return } - labelsData := retrieveRepoLabels(ctx, ctx.Repo.Repository, 0, true) - if ctx.Written() { - return - } - RetrieveRepoReviewers(ctx, ctx.Repo.Repository, nil, true) - if ctx.Written() { - return - } - _, templateErrs := setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates, labelsData) + _, templateErrs := setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates, pageMetaData) if len(templateErrs) > 0 { ctx.Flash.Warning(renderErrorOfTemplates(ctx, templateErrs), true) } diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index a4e2fd8cea3..72f89bd27d0 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -431,7 +431,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt return 0 } - retrieveProjects(ctx, repo) + retrieveProjectsForIssueList(ctx, repo) if ctx.Written() { return } @@ -556,37 +556,147 @@ func renderMilestones(ctx *context.Context) { ctx.Data["ClosedMilestones"] = closedMilestones } -// RetrieveRepoMilestonesAndAssignees find all the milestones and assignees of a repository -func RetrieveRepoMilestonesAndAssignees(ctx *context.Context, repo *repo_model.Repository) { +type issueSidebarMilestoneData struct { + SelectedMilestoneID int64 + OpenMilestones []*issues_model.Milestone + ClosedMilestones []*issues_model.Milestone +} + +type issueSidebarAssigneesData struct { + SelectedAssigneeIDs string + CandidateAssignees []*user_model.User +} + +type IssuePageMetaData struct { + RepoLink string + Repository *repo_model.Repository + Issue *issues_model.Issue + IsPullRequest bool + CanModifyIssueOrPull bool + + ReviewersData *issueSidebarReviewersData + LabelsData *issueSidebarLabelsData + MilestonesData *issueSidebarMilestoneData + ProjectsData *issueSidebarProjectsData + AssigneesData *issueSidebarAssigneesData +} + +func retrieveRepoIssueMetaData(ctx *context.Context, repo *repo_model.Repository, issue *issues_model.Issue, isPull bool) *IssuePageMetaData { + data := &IssuePageMetaData{ + RepoLink: ctx.Repo.RepoLink, + Repository: repo, + Issue: issue, + IsPullRequest: isPull, + + ReviewersData: &issueSidebarReviewersData{}, + LabelsData: &issueSidebarLabelsData{}, + MilestonesData: &issueSidebarMilestoneData{}, + ProjectsData: &issueSidebarProjectsData{}, + AssigneesData: &issueSidebarAssigneesData{}, + } + ctx.Data["IssuePageMetaData"] = data + + if isPull { + data.retrieveReviewersData(ctx) + if ctx.Written() { + return data + } + } + data.retrieveLabelsData(ctx) + if ctx.Written() { + return data + } + + data.CanModifyIssueOrPull = ctx.Repo.CanWriteIssuesOrPulls(isPull) && !ctx.Repo.Repository.IsArchived + if !data.CanModifyIssueOrPull { + return data + } + + data.retrieveAssigneesDataForIssueWriter(ctx) + if ctx.Written() { + return data + } + + data.retrieveMilestonesDataForIssueWriter(ctx) + if ctx.Written() { + return data + } + + data.retrieveProjectsDataForIssueWriter(ctx) + if ctx.Written() { + return data + } + + PrepareBranchList(ctx) + if ctx.Written() { + return data + } + + ctx.Data["CanCreateIssueDependencies"] = ctx.Repo.CanCreateIssueDependencies(ctx, ctx.Doer, isPull) + return data +} + +func (d *IssuePageMetaData) retrieveMilestonesDataForIssueWriter(ctx *context.Context) { var err error - ctx.Data["OpenMilestones"], err = db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ - RepoID: repo.ID, + if d.Issue != nil { + d.MilestonesData.SelectedMilestoneID = d.Issue.MilestoneID + } + d.MilestonesData.OpenMilestones, err = db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ + RepoID: d.Repository.ID, IsClosed: optional.Some(false), }) if err != nil { ctx.ServerError("GetMilestones", err) return } - ctx.Data["ClosedMilestones"], err = db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ - RepoID: repo.ID, + d.MilestonesData.ClosedMilestones, err = db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ + RepoID: d.Repository.ID, IsClosed: optional.Some(true), }) if err != nil { ctx.ServerError("GetMilestones", err) return } +} - assigneeUsers, err := repo_model.GetRepoAssignees(ctx, repo) +func (d *IssuePageMetaData) retrieveAssigneesDataForIssueWriter(ctx *context.Context) { + var err error + d.AssigneesData.CandidateAssignees, err = repo_model.GetRepoAssignees(ctx, d.Repository) if err != nil { ctx.ServerError("GetRepoAssignees", err) return } - ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers) - + d.AssigneesData.CandidateAssignees = shared_user.MakeSelfOnTop(ctx.Doer, d.AssigneesData.CandidateAssignees) + if d.Issue != nil { + _ = d.Issue.LoadAssignees(ctx) + ids := make([]string, 0, len(d.Issue.Assignees)) + for _, a := range d.Issue.Assignees { + ids = append(ids, strconv.FormatInt(a.ID, 10)) + } + d.AssigneesData.SelectedAssigneeIDs = strings.Join(ids, ",") + } + // FIXME: this is a tricky part which writes ctx.Data["Mentionable*"] handleTeamMentions(ctx) } -func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) { +func retrieveProjectsForIssueList(ctx *context.Context, repo *repo_model.Repository) { + ctx.Data["OpenProjects"], ctx.Data["ClosedProjects"] = retrieveProjectsInternal(ctx, repo) +} + +type issueSidebarProjectsData struct { + SelectedProjectID int64 + OpenProjects []*project_model.Project + ClosedProjects []*project_model.Project +} + +func (d *IssuePageMetaData) retrieveProjectsDataForIssueWriter(ctx *context.Context) { + if d.Issue != nil && d.Issue.Project != nil { + d.ProjectsData.SelectedProjectID = d.Issue.Project.ID + } + d.ProjectsData.OpenProjects, d.ProjectsData.ClosedProjects = retrieveProjectsInternal(ctx, ctx.Repo.Repository) +} + +func retrieveProjectsInternal(ctx *context.Context, repo *repo_model.Repository) (open, closed []*project_model.Project) { // Distinguish whether the owner of the repository // is an individual or an organization repoOwnerType := project_model.TypeIndividual @@ -609,7 +719,7 @@ func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) { }) if err != nil { ctx.ServerError("GetProjects", err) - return + return nil, nil } closedProjects, err = db.Find[project_model.Project](ctx, project_model.SearchOptions{ ListOptions: db.ListOptionsAll, @@ -619,7 +729,7 @@ func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) { }) if err != nil { ctx.ServerError("GetProjects", err) - return + return nil, nil } } @@ -632,7 +742,7 @@ func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) { }) if err != nil { ctx.ServerError("GetProjects", err) - return + return nil, nil } openProjects = append(openProjects, openProjects2...) closedProjects2, err := db.Find[project_model.Project](ctx, project_model.SearchOptions{ @@ -643,13 +753,11 @@ func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) { }) if err != nil { ctx.ServerError("GetProjects", err) - return + return nil, nil } closedProjects = append(closedProjects, closedProjects2...) } - - ctx.Data["OpenProjects"] = openProjects - ctx.Data["ClosedProjects"] = closedProjects + return openProjects, closedProjects } // repoReviewerSelection items to bee shown @@ -665,10 +773,6 @@ type repoReviewerSelection struct { } type issueSidebarReviewersData struct { - Repository *repo_model.Repository - RepoOwnerName string - RepoLink string - IssueID int64 CanChooseReviewer bool OriginalReviews issues_model.ReviewList TeamReviewers []*repoReviewerSelection @@ -677,41 +781,44 @@ type issueSidebarReviewersData struct { } // RetrieveRepoReviewers find all reviewers of a repository. If issue is nil, it means the doer is creating a new PR. -func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, issue *issues_model.Issue, canChooseReviewer bool) { - data := &issueSidebarReviewersData{} - data.RepoLink = ctx.Repo.RepoLink - data.Repository = repo - data.RepoOwnerName = repo.OwnerName - data.CanChooseReviewer = canChooseReviewer +func (d *IssuePageMetaData) retrieveReviewersData(ctx *context.Context) { + data := d.ReviewersData + repo := d.Repository + if ctx.Doer != nil && ctx.IsSigned { + if d.Issue == nil { + data.CanChooseReviewer = true + } else { + data.CanChooseReviewer = issue_service.CanDoerChangeReviewRequests(ctx, ctx.Doer, repo, d.Issue) + } + } var posterID int64 var isClosed bool var reviews issues_model.ReviewList - if issue == nil { + if d.Issue == nil { posterID = ctx.Doer.ID } else { - posterID = issue.PosterID - if issue.OriginalAuthorID > 0 { + posterID = d.Issue.PosterID + if d.Issue.OriginalAuthorID > 0 { posterID = 0 // for migrated PRs, no poster ID } - data.IssueID = issue.ID - isClosed = issue.IsClosed || issue.PullRequest.HasMerged + isClosed = d.Issue.IsClosed || d.Issue.PullRequest.HasMerged - originalAuthorReviews, err := issues_model.GetReviewersFromOriginalAuthorsByIssueID(ctx, issue.ID) + originalAuthorReviews, err := issues_model.GetReviewersFromOriginalAuthorsByIssueID(ctx, d.Issue.ID) if err != nil { ctx.ServerError("GetReviewersFromOriginalAuthorsByIssueID", err) return } data.OriginalReviews = originalAuthorReviews - reviews, err = issues_model.GetReviewsByIssueID(ctx, issue.ID) + reviews, err = issues_model.GetReviewsByIssueID(ctx, d.Issue.ID) if err != nil { ctx.ServerError("GetReviewersByIssueID", err) return } - if len(reviews) == 0 && !canChooseReviewer { + if len(reviews) == 0 && !data.CanChooseReviewer { return } } @@ -724,7 +831,7 @@ func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, is reviewers []*user_model.User ) - if canChooseReviewer { + if data.CanChooseReviewer { var err error reviewers, err = repo_model.GetReviewers(ctx, repo, ctx.Doer.ID, posterID) if err != nil { @@ -760,7 +867,7 @@ func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, is tmp.ItemID = -review.ReviewerTeamID } - if canChooseReviewer { + if data.CanChooseReviewer { // Users who can choose reviewers can also remove review requests tmp.CanChange = true } else if ctx.Doer != nil && ctx.Doer.ID == review.ReviewerID && review.Type == issues_model.ReviewTypeRequest { @@ -770,7 +877,7 @@ func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, is pullReviews = append(pullReviews, tmp) - if canChooseReviewer { + if data.CanChooseReviewer { if tmp.IsTeam { teamReviewersResult = append(teamReviewersResult, tmp) } else { @@ -811,7 +918,7 @@ func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, is data.CurrentPullReviewers = currentPullReviewers } - if canChooseReviewer && reviewersResult != nil { + if data.CanChooseReviewer && reviewersResult != nil { preadded := len(reviewersResult) for _, reviewer := range reviewers { found := false @@ -839,7 +946,7 @@ func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, is data.Reviewers = reviewersResult } - if canChooseReviewer && teamReviewersResult != nil { + if data.CanChooseReviewer && teamReviewersResult != nil { preadded := len(teamReviewersResult) for _, team := range teamReviewers { found := false @@ -866,15 +973,9 @@ func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, is data.TeamReviewers = teamReviewersResult } - - ctx.Data["IssueSidebarReviewersData"] = data } 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 @@ -922,60 +1023,30 @@ func (d *issueSidebarLabelsData) SetSelectedLabelIDs(labelIDs []int64) { ) } -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 +func (d *IssuePageMetaData) retrieveLabelsData(ctx *context.Context) { + repo := d.Repository + labelsData := d.LabelsData labels, err := issues_model.GetLabelsByRepoID(ctx, repo.ID, "", db.ListOptions{}) if err != nil { ctx.ServerError("GetLabelsByRepoID", err) - return nil + return } 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 + return } labelsData.OrgLabels = orgLabels } labelsData.AllLabels = append(labelsData.AllLabels, labelsData.RepoLabels...) labelsData.AllLabels = append(labelsData.AllLabels, labelsData.OrgLabels...) - return labelsData -} - -// 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 - } - - retrieveProjects(ctx, repo) - if ctx.Written() { - return - } - - PrepareBranchList(ctx) - if ctx.Written() { - return - } - // Contains true if the user can create issue dependencies - ctx.Data["CanCreateIssueDependencies"] = ctx.Repo.CanCreateIssueDependencies(ctx, ctx.Doer, isPull) } // 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, labelsData *issueSidebarLabelsData) (bool, map[string]error) { +func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles []string, metaData *IssuePageMetaData) (bool, map[string]error) { commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch) if err != nil { return false, nil @@ -1013,24 +1084,20 @@ func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles ctx.Data["TemplateFile"] = template.FileName } - labelsData.SetSelectedLabelNames(template.Labels) + metaData.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 userIDs, err := user_model.GetUserIDsByNames(ctx, template.Assignees, true); err == nil { for _, userID := range userIDs { - selectedAssigneeIDs = append(selectedAssigneeIDs, userID) selectedAssigneeIDStrings = append(selectedAssigneeIDStrings, strconv.FormatInt(userID, 10)) } } + metaData.AssigneesData.SelectedAssigneeIDs = strings.Join(selectedAssigneeIDStrings, ",") 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["HasSelectedAssignee"] = len(selectedAssigneeIDs) > 0 - ctx.Data["assignee_ids"] = strings.Join(selectedAssigneeIDStrings, ",") - ctx.Data["SelectedAssigneeIDs"] = selectedAssigneeIDs ctx.Data["Reference"] = template.Ref ctx.Data["RefEndName"] = git.RefName(template.Ref).ShortName() return true, templateErrs @@ -1057,42 +1124,19 @@ func NewIssue(ctx *context.Context) { ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled upload.AddUploadContext(ctx, "comment") - milestoneID := ctx.FormInt64("milestone") - if milestoneID > 0 { - milestone, err := issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, milestoneID) - if err != nil { - log.Error("GetMilestoneByID: %d: %v", milestoneID, err) - } else { - ctx.Data["milestone_id"] = milestoneID - ctx.Data["Milestone"] = milestone - } + pageMetaData := retrieveRepoIssueMetaData(ctx, ctx.Repo.Repository, nil, false) + if ctx.Written() { + return } - projectID := ctx.FormInt64("project") - if projectID > 0 && isProjectsEnabled { - project, err := project_model.GetProjectByID(ctx, projectID) - if err != nil { - log.Error("GetProjectByID: %d: %v", projectID, err) - } else if project.RepoID != ctx.Repo.Repository.ID { - log.Error("GetProjectByID: %d: %v", projectID, fmt.Errorf("project[%d] not in repo [%d]", project.ID, ctx.Repo.Repository.ID)) - } else { - ctx.Data["project_id"] = projectID - ctx.Data["Project"] = project - } - + pageMetaData.MilestonesData.SelectedMilestoneID = ctx.FormInt64("milestone") + pageMetaData.ProjectsData.SelectedProjectID = ctx.FormInt64("project") + if pageMetaData.ProjectsData.SelectedProjectID > 0 { if len(ctx.Req.URL.Query().Get("project")) > 0 { ctx.Data["redirect_after_creation"] = "project" } } - 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) @@ -1101,7 +1145,7 @@ func NewIssue(ctx *context.Context) { ctx.Data["Tags"] = tags ret := issue_service.ParseTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) - templateLoaded, errs := setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates, labelsData) + templateLoaded, errs := setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates, pageMetaData) for k, v := range errs { ret.TemplateErrors[k] = v } @@ -1196,8 +1240,16 @@ func DeleteIssue(ctx *context.Context) { ctx.Redirect(fmt.Sprintf("%s/issues", ctx.Repo.Repository.Link()), http.StatusSeeOther) } -// ValidateRepoMetas check and returns repository's meta information -func ValidateRepoMetas(ctx *context.Context, form forms.CreateIssueForm, isPull bool) (ret struct { +func toSet[ItemType any, KeyType comparable](slice []ItemType, keyFunc func(ItemType) KeyType) container.Set[KeyType] { + s := make(container.Set[KeyType]) + for _, item := range slice { + s.Add(keyFunc(item)) + } + return s +} + +// ValidateRepoMetasForNewIssue check and returns repository's meta information +func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueForm, isPull bool) (ret struct { LabelIDs, AssigneeIDs []int64 MilestoneID, ProjectID int64 @@ -1205,126 +1257,76 @@ func ValidateRepoMetas(ctx *context.Context, form forms.CreateIssueForm, isPull TeamReviewers []*organization.Team }, ) { - var ( - repo = ctx.Repo.Repository - err error - ) - - retrieveRepoMetasForIssueWriter(ctx, ctx.Repo.Repository, isPull) - if ctx.Written() { - return ret - } - labelsData := retrieveRepoLabels(ctx, ctx.Repo.Repository, 0, isPull) + pageMetaData := retrieveRepoIssueMetaData(ctx, ctx.Repo.Repository, nil, isPull) if ctx.Written() { return ret } - var labelIDs []int64 - // Check labels. - if len(form.LabelIDs) > 0 { - labelIDs, err = base.StringsToInt64s(strings.Split(form.LabelIDs, ",")) - if err != nil { - return ret - } - labelsData.SetSelectedLabelIDs(labelIDs) + inputLabelIDs, _ := base.StringsToInt64s(strings.Split(form.LabelIDs, ",")) + candidateLabels := toSet(pageMetaData.LabelsData.AllLabels, func(label *issues_model.Label) int64 { return label.ID }) + if len(inputLabelIDs) > 0 && !candidateLabels.Contains(inputLabelIDs...) { + ctx.NotFound("", nil) + return ret } + pageMetaData.LabelsData.SetSelectedLabelIDs(inputLabelIDs) - // Check milestone. - milestoneID := form.MilestoneID - if milestoneID > 0 { - milestone, err := issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, milestoneID) - if err != nil { - ctx.ServerError("GetMilestoneByID", err) - return ret - } - if milestone.RepoID != repo.ID { - ctx.ServerError("GetMilestoneByID", err) - return ret - } - ctx.Data["Milestone"] = milestone - ctx.Data["milestone_id"] = milestoneID + allMilestones := append(slices.Clone(pageMetaData.MilestonesData.OpenMilestones), pageMetaData.MilestonesData.ClosedMilestones...) + candidateMilestones := toSet(allMilestones, func(milestone *issues_model.Milestone) int64 { return milestone.ID }) + if form.MilestoneID > 0 && !candidateMilestones.Contains(form.MilestoneID) { + ctx.NotFound("", nil) + return ret } + pageMetaData.MilestonesData.SelectedMilestoneID = form.MilestoneID - if form.ProjectID > 0 { - p, err := project_model.GetProjectByID(ctx, form.ProjectID) - if err != nil { - ctx.ServerError("GetProjectByID", err) - return ret - } - if p.RepoID != ctx.Repo.Repository.ID && p.OwnerID != ctx.Repo.Repository.OwnerID { - ctx.NotFound("", nil) - return ret - } - - ctx.Data["Project"] = p - ctx.Data["project_id"] = form.ProjectID + allProjects := append(slices.Clone(pageMetaData.ProjectsData.OpenProjects), pageMetaData.ProjectsData.ClosedProjects...) + candidateProjects := toSet(allProjects, func(project *project_model.Project) int64 { return project.ID }) + if form.ProjectID > 0 && !candidateProjects.Contains(form.ProjectID) { + ctx.NotFound("", nil) + return ret } + pageMetaData.ProjectsData.SelectedProjectID = form.ProjectID - // Check assignees - var assigneeIDs []int64 - if len(form.AssigneeIDs) > 0 { - assigneeIDs, err = base.StringsToInt64s(strings.Split(form.AssigneeIDs, ",")) - if err != nil { - return ret - } - - // Check if the passed assignees actually exists and is assignable - for _, aID := range assigneeIDs { - assignee, err := user_model.GetUserByID(ctx, aID) - if err != nil { - ctx.ServerError("GetUserByID", err) - return ret - } - - valid, err := access_model.CanBeAssigned(ctx, assignee, repo, isPull) - if err != nil { - ctx.ServerError("CanBeAssigned", err) - return ret - } - - if !valid { - ctx.ServerError("canBeAssigned", repo_model.ErrUserDoesNotHaveAccessToRepo{UserID: aID, RepoName: repo.Name}) - return ret - } - } + candidateAssignees := toSet(pageMetaData.AssigneesData.CandidateAssignees, func(user *user_model.User) int64 { return user.ID }) + inputAssigneeIDs, _ := base.StringsToInt64s(strings.Split(form.AssigneeIDs, ",")) + if len(inputAssigneeIDs) > 0 && !candidateAssignees.Contains(inputAssigneeIDs...) { + ctx.NotFound("", nil) + return ret } + pageMetaData.AssigneesData.SelectedAssigneeIDs = form.AssigneeIDs - // Keep the old assignee id thingy for compatibility reasons - if form.AssigneeID > 0 { - assigneeIDs = append(assigneeIDs, form.AssigneeID) - } - - // Check reviewers + // Check if the passed reviewers (user/team) actually exist var reviewers []*user_model.User var teamReviewers []*organization.Team - if isPull && len(form.ReviewerIDs) > 0 { - reviewerIDs, err := base.StringsToInt64s(strings.Split(form.ReviewerIDs, ",")) - if err != nil { - return ret + reviewerIDs, _ := base.StringsToInt64s(strings.Split(form.ReviewerIDs, ",")) + if isPull && len(reviewerIDs) > 0 { + userReviewersMap := map[int64]*user_model.User{} + teamReviewersMap := map[int64]*organization.Team{} + for _, r := range pageMetaData.ReviewersData.Reviewers { + userReviewersMap[r.User.ID] = r.User + } + for _, r := range pageMetaData.ReviewersData.TeamReviewers { + teamReviewersMap[r.Team.ID] = r.Team } - // Check if the passed reviewers (user/team) actually exist for _, rID := range reviewerIDs { - // negative reviewIDs represent team requests - if rID < 0 { - teamReviewer, err := organization.GetTeamByID(ctx, -rID) - if err != nil { - ctx.ServerError("GetTeamByID", err) + if rID < 0 { // negative reviewIDs represent team requests + team, ok := teamReviewersMap[-rID] + if !ok { + ctx.NotFound("", nil) return ret } - teamReviewers = append(teamReviewers, teamReviewer) - continue + teamReviewers = append(teamReviewers, team) + } else { + user, ok := userReviewersMap[rID] + if !ok { + ctx.NotFound("", nil) + return ret + } + reviewers = append(reviewers, user) } - - reviewer, err := user_model.GetUserByID(ctx, rID) - if err != nil { - ctx.ServerError("GetUserByID", err) - return ret - } - reviewers = append(reviewers, reviewer) } } - ret.LabelIDs, ret.AssigneeIDs, ret.MilestoneID, ret.ProjectID = labelIDs, assigneeIDs, milestoneID, form.ProjectID + ret.LabelIDs, ret.AssigneeIDs, ret.MilestoneID, ret.ProjectID = inputLabelIDs, inputAssigneeIDs, form.MilestoneID, form.ProjectID ret.Reviewers, ret.TeamReviewers = reviewers, teamReviewers return ret } @@ -1344,7 +1346,7 @@ func NewIssuePost(ctx *context.Context) { attachments []string ) - validateRet := ValidateRepoMetas(ctx, *form, false) + validateRet := ValidateRepoMetasForNewIssue(ctx, *form, false) if ctx.Written() { return } @@ -1619,37 +1621,11 @@ func ViewIssue(ctx *context.Context) { } } - retrieveRepoMetasForIssueWriter(ctx, repo, issue.IsPull) + pageMetaData := retrieveRepoIssueMetaData(ctx, repo, issue, issue.IsPull) if ctx.Written() { return } - labelsData := retrieveRepoLabels(ctx, repo, issue.ID, issue.IsPull) - if ctx.Written() { - return - } - labelsData.SetSelectedLabels(issue.Labels) - - // Check milestone and assignee. - if ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { - RetrieveRepoMilestonesAndAssignees(ctx, repo) - retrieveProjects(ctx, repo) - - if ctx.Written() { - return - } - } - - if issue.IsPull { - canChooseReviewer := false - if ctx.Doer != nil && ctx.IsSigned { - canChooseReviewer = issue_service.CanDoerChangeReviewRequests(ctx, ctx.Doer, repo, issue) - } - - RetrieveRepoReviewers(ctx, repo, issue, canChooseReviewer) - if ctx.Written() { - return - } - } + pageMetaData.LabelsData.SetSelectedLabels(issue.Labels) if ctx.IsSigned { // Update issue-user. diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index dd9671efbe8..bb814eab6e7 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -1269,7 +1269,7 @@ func CompareAndPullRequestPost(ctx *context.Context) { return } - validateRet := ValidateRepoMetas(ctx, *form, true) + validateRet := ValidateRepoMetasForNewIssue(ctx, *form, true) if ctx.Written() { return } diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 83f2dd6caac..d27bbca8948 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -451,7 +451,6 @@ type CreateIssueForm struct { Ref string `form:"ref"` MilestoneID int64 ProjectID int64 - AssigneeID int64 Content string Files []string AllowMaintainerEdit bool diff --git a/templates/repo/issue/milestone/select_menu.tmpl b/templates/repo/issue/milestone/select_menu.tmpl deleted file mode 100644 index 9b0492ce524..00000000000 --- a/templates/repo/issue/milestone/select_menu.tmpl +++ /dev/null @@ -1,38 +0,0 @@ -{{if or .OpenMilestones .ClosedMilestones}} - <div class="ui icon search input"> - <i class="icon">{{svg "octicon-search" 16}}</i> - <input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_milestones"}}"> - </div> - <div class="divider"></div> -{{end}} -<div class="no-select item">{{ctx.Locale.Tr "repo.issues.new.clear_milestone"}}</div> -{{if and (not .OpenMilestones) (not .ClosedMilestones)}} - <div class="disabled item"> - {{ctx.Locale.Tr "repo.issues.new.no_items"}} - </div> -{{else}} - {{if .OpenMilestones}} - <div class="divider"></div> - <div class="header"> - {{ctx.Locale.Tr "repo.issues.new.open_milestone"}} - </div> - {{range .OpenMilestones}} - <a class="item" data-id="{{.ID}}" data-href="{{$.RepoLink}}/issues?milestone={{.ID}}"> - {{svg "octicon-milestone" 16 "tw-mr-1"}} - {{.Name}} - </a> - {{end}} - {{end}} - {{if .ClosedMilestones}} - <div class="divider"></div> - <div class="header"> - {{ctx.Locale.Tr "repo.issues.new.closed_milestone"}} - </div> - {{range .ClosedMilestones}} - <a class="item" data-id="{{.ID}}" data-href="{{$.RepoLink}}/issues?milestone={{.ID}}"> - {{svg "octicon-milestone" 16 "tw-mr-1"}} - {{.Name}} - </a> - {{end}} - {{end}} -{{end}} diff --git a/templates/repo/issue/new_form.tmpl b/templates/repo/issue/new_form.tmpl index 65d359e9dcd..ceaaebc4d54 100644 --- a/templates/repo/issue/new_form.tmpl +++ b/templates/repo/issue/new_form.tmpl @@ -49,142 +49,22 @@ <div class="issue-content-right ui segment"> {{template "repo/issue/branch_selector_field" $}} {{if .PageIsComparePull}} - {{template "repo/issue/sidebar/reviewer_list" $.IssueSidebarReviewersData}} + {{template "repo/issue/sidebar/reviewer_list" $.IssuePageMetaData}} <div class="divider"></div> {{end}} - {{template "repo/issue/sidebar/label_list" $.IssueSidebarLabelsData}} - - <div class="divider"></div> - - <input id="milestone_id" name="milestone_id" type="hidden" value="{{.milestone_id}}"> - <div class="ui {{if not .HasIssuesOrPullsWritePermission}}disabled{{end}} floating jump select-milestone dropdown"> - <span class="text flex-text-block"> - <strong>{{ctx.Locale.Tr "repo.issues.new.milestone"}}</strong> - {{if .HasIssuesOrPullsWritePermission}} - {{svg "octicon-gear" 16 "tw-ml-1"}} - {{end}} - </span> - <div class="menu"> - {{template "repo/issue/milestone/select_menu" .}} - </div> - </div> - <div class="ui select-milestone list"> - <span class="no-select item {{if .Milestone}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_milestone"}}</span> - <div class="selected"> - {{if .Milestone}} - <a class="item muted sidebar-item-link" href="{{.RepoLink}}/issues?milestone={{.Milestone.ID}}"> - {{svg "octicon-milestone" 18 "tw-mr-2"}} - {{.Milestone.Name}} - </a> - {{end}} - </div> - </div> - + {{template "repo/issue/sidebar/label_list" $.IssuePageMetaData}} + {{template "repo/issue/sidebar/milestone_list" $.IssuePageMetaData}} {{if .IsProjectsEnabled}} - <div class="divider"></div> - - <input id="project_id" name="project_id" type="hidden" value="{{.project_id}}"> - <div class="ui {{if not .HasIssuesOrPullsWritePermission}}disabled{{end}} floating jump select-project dropdown"> - <span class="text flex-text-block"> - <strong>{{ctx.Locale.Tr "repo.issues.new.projects"}}</strong> - {{if .HasIssuesOrPullsWritePermission}} - {{svg "octicon-gear" 16 "tw-ml-1"}} - {{end}} - </span> - <div class="menu"> - {{if or .OpenProjects .ClosedProjects}} - <div class="ui icon search input"> - <i class="icon">{{svg "octicon-search" 16}}</i> - <input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_projects"}}"> - </div> - {{end}} - <div class="no-select item">{{ctx.Locale.Tr "repo.issues.new.clear_projects"}}</div> - {{if and (not .OpenProjects) (not .ClosedProjects)}} - <div class="disabled item"> - {{ctx.Locale.Tr "repo.issues.new.no_items"}} - </div> - {{else}} - {{if .OpenProjects}} - <div class="divider"></div> - <div class="header"> - {{ctx.Locale.Tr "repo.issues.new.open_projects"}} - </div> - {{range .OpenProjects}} - <a class="item muted sidebar-item-link" data-id="{{.ID}}" data-href="{{.Link ctx}}"> - {{svg .IconName 18 "tw-mr-2"}}{{.Title}} - </a> - {{end}} - {{end}} - {{if .ClosedProjects}} - <div class="divider"></div> - <div class="header"> - {{ctx.Locale.Tr "repo.issues.new.closed_projects"}} - </div> - {{range .ClosedProjects}} - <a class="item muted sidebar-item-link" data-id="{{.ID}}" data-href="{{.Link ctx}}"> - {{svg .IconName 18 "tw-mr-2"}}{{.Title}} - </a> - {{end}} - {{end}} - {{end}} - </div> - </div> - <div class="ui select-project list"> - <span class="no-select item {{if .Project}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_projects"}}</span> - <div class="selected"> - {{if .Project}} - <a class="item muted sidebar-item-link" href="{{.Project.Link ctx}}"> - {{svg .Project.IconName 18 "tw-mr-2"}}{{.Project.Title}} - </a> - {{end}} - </div> - </div> + {{template "repo/issue/sidebar/project_list" $.IssuePageMetaData}} {{end}} - <div class="divider"></div> - <input id="assignee_ids" name="assignee_ids" type="hidden" value="{{.assignee_ids}}"> - <div class="ui {{if not .HasIssuesOrPullsWritePermission}}disabled{{end}} floating jump select-assignees dropdown"> - <span class="text flex-text-block"> - <strong>{{ctx.Locale.Tr "repo.issues.new.assignees"}}</strong> - {{if .HasIssuesOrPullsWritePermission}} - {{svg "octicon-gear" 16 "tw-ml-1"}} - {{end}} - </span> - <div class="filter menu" data-id="#assignee_ids"> - <div class="ui icon search input"> - <i class="icon">{{svg "octicon-search" 16}}</i> - <input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_assignees"}}"> - </div> - <div class="no-select item">{{ctx.Locale.Tr "repo.issues.new.clear_assignees"}}</div> - {{range .Assignees}} - <a class="{{if SliceUtils.Contains $.SelectedAssigneeIDs .ID}}checked{{end}} item muted" href="#" data-id="{{.ID}}" data-id-selector="#assignee_{{.ID}}"> - <span class="octicon-check {{if not (SliceUtils.Contains $.SelectedAssigneeIDs .ID)}}tw-invisible{{end}}">{{svg "octicon-check"}}</span> - <span class="text"> - {{ctx.AvatarUtils.Avatar . 28 "tw-mr-2"}}{{template "repo/search_name" .}} - </span> - </a> - {{end}} - </div> - </div> - <div class="ui assignees list"> - <span class="no-select item {{if .HasSelectedAssignee}}tw-hidden{{end}}"> - {{ctx.Locale.Tr "repo.issues.new.no_assignees"}} - </span> - <div class="selected"> - {{range .Assignees}} - <a class="item tw-p-1 muted {{if not (SliceUtils.Contains $.SelectedAssigneeIDs .ID)}}tw-hidden{{end}}" id="assignee_{{.ID}}" href="{{$.RepoLink}}/issues?assignee={{.ID}}"> - {{ctx.AvatarUtils.Avatar . 28 "tw-mr-2 tw-align-middle"}}{{.GetDisplayName}} - </a> - {{end}} - </div> - </div> + {{template "repo/issue/sidebar/assignee_list" $.IssuePageMetaData}} + {{if and .PageIsComparePull (not (eq .HeadRepo.FullName .BaseCompareRepo.FullName)) .CanWriteToHeadRepo}} <div class="divider"></div> - <div class="inline field"> - <div class="ui checkbox"> - <label data-tooltip-content="{{ctx.Locale.Tr "repo.pulls.allow_edits_from_maintainers_desc"}}"><strong>{{ctx.Locale.Tr "repo.pulls.allow_edits_from_maintainers"}}</strong></label> - <input name="allow_maintainer_edit" type="checkbox" {{if .AllowMaintainerEdit}}checked{{end}}> - </div> + <div class="ui checkbox"> + <label data-tooltip-content="{{ctx.Locale.Tr "repo.pulls.allow_edits_from_maintainers_desc"}}"><strong>{{ctx.Locale.Tr "repo.pulls.allow_edits_from_maintainers"}}</strong></label> + <input name="allow_maintainer_edit" type="checkbox" {{if .AllowMaintainerEdit}}checked{{end}}> </div> {{end}} </div> diff --git a/templates/repo/issue/sidebar/assignee_list.tmpl b/templates/repo/issue/sidebar/assignee_list.tmpl index 260f7c5be4d..bee6123e52c 100644 --- a/templates/repo/issue/sidebar/assignee_list.tmpl +++ b/templates/repo/issue/sidebar/assignee_list.tmpl @@ -1,46 +1,35 @@ +{{$pageMeta := .}} +{{$data := .AssigneesData}} +{{$issueAssignees := NIL}}{{if $pageMeta.Issue}}{{$issueAssignees = $pageMeta.Issue.Assignees}}{{end}} <div class="divider"></div> -<input id="assignee_id" name="assignee_id" type="hidden" value="{{.assignee_id}}"> -<div class="ui {{if or (not .HasIssuesOrPullsWritePermission) .Repository.IsArchived}}disabled{{end}} floating jump select-assignees-modify dropdown"> - <a class="text muted flex-text-block"> - <strong>{{ctx.Locale.Tr "repo.issues.new.assignees"}}</strong> - {{if and .HasIssuesOrPullsWritePermission (not .Repository.IsArchived)}} - {{svg "octicon-gear" 16 "tw-ml-1"}} - {{end}} - </a> - <div class="filter menu" data-action="update" data-issue-id="{{$.Issue.ID}}" data-update-url="{{$.RepoLink}}/issues/assignee"> - <div class="ui icon search input"> - <i class="icon">{{svg "octicon-search" 16}}</i> - <input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_assignees"}}"> +<div class="issue-sidebar-combo" data-selection-mode="multiple" data-update-algo="diff" + {{if $pageMeta.Issue}}data-update-url="{{$pageMeta.RepoLink}}/issues/assignee?issue_ids={{$pageMeta.Issue.ID}}"{{end}} +> + <input class="combo-value" name="assignee_ids" type="hidden" value="{{$data.SelectedAssigneeIDs}}"> + <div class="ui dropdown {{if not $pageMeta.CanModifyIssueOrPull}}disabled{{end}}"> + <a class="text muted"> + <strong>{{ctx.Locale.Tr "repo.issues.new.assignees"}}</strong> {{if $pageMeta.CanModifyIssueOrPull}}{{svg "octicon-gear"}}{{end}} + </a> + <div class="menu"> + <div class="ui icon search input"> + <i class="icon">{{svg "octicon-search" 16}}</i> + <input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_assignees"}}"> + </div> + <div class="item clear-selection">{{ctx.Locale.Tr "repo.issues.new.clear_assignees"}}</div> + {{range $data.CandidateAssignees}} + <a class="item muted" href="#" data-value="{{.ID}}"> + <span class="item-check-mark">{{svg "octicon-check"}}</span> + {{ctx.AvatarUtils.Avatar . 20}} {{template "repo/search_name" .}} + </a> + {{end}} </div> - <div class="no-select item">{{ctx.Locale.Tr "repo.issues.new.clear_assignees"}}</div> - {{range .Assignees}} - - {{$AssigneeID := .ID}} - <a class="item{{range $.Issue.Assignees}}{{if eq .ID $AssigneeID}} checked{{end}}{{end}}" href="#" data-id="{{.ID}}" data-id-selector="#assignee_{{.ID}}"> - {{$checked := false}} - {{range $.Issue.Assignees}} - {{if eq .ID $AssigneeID}} - {{$checked = true}} - {{end}} - {{end}} - <span class="octicon-check {{if not $checked}}tw-invisible{{end}}">{{svg "octicon-check"}}</span> - <span class="text"> - {{ctx.AvatarUtils.Avatar . 20 "tw-mr-2"}}{{template "repo/search_name" .}} - </span> + </div> + <div class="ui list tw-flex tw-flex-row tw-gap-2"> + <span class="item empty-list {{if $issueAssignees}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_assignees"}}</span> + {{range $issueAssignees}} + <a class="item muted" href="{{$pageMeta.RepoLink}}/{{if $pageMeta.IsPullRequest}}pulls{{else}}issues{{end}}?assignee={{.ID}}"> + {{ctx.AvatarUtils.Avatar . 20}} {{.GetDisplayName}} </a> {{end}} </div> </div> -<div class="ui assignees list"> - <span class="no-select item {{if .Issue.Assignees}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_assignees"}}</span> - <div class="selected"> - {{range .Issue.Assignees}} - <div class="item"> - <a class="muted sidebar-item-link" href="{{$.RepoLink}}/{{if $.Issue.IsPull}}pulls{{else}}issues{{end}}?assignee={{.ID}}"> - {{ctx.AvatarUtils.Avatar . 28 "tw-mr-2"}} - {{.GetDisplayName}} - </a> - </div> - {{end}} - </div> -</div> diff --git a/templates/repo/issue/sidebar/label_list.tmpl b/templates/repo/issue/sidebar/label_list.tmpl index e9f4baa4335..ed80047661b 100644 --- a/templates/repo/issue/sidebar/label_list.tmpl +++ b/templates/repo/issue/sidebar/label_list.tmpl @@ -1,10 +1,12 @@ -{{$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}}> +{{$pageMeta := .}} +{{$data := .LabelsData}} +<div class="issue-sidebar-combo" data-selection-mode="multiple" data-update-algo="diff" + {{if $pageMeta.Issue}}data-update-url="{{$pageMeta.RepoLink}}/issues/labels?issue_ids={{$pageMeta.Issue.ID}}"{{end}} +> <input class="combo-value" name="label_ids" type="hidden" value="{{$data.SelectedLabelIDs}}"> - <div class="ui dropdown {{if not $canChange}}disabled{{end}}"> + <div class="ui dropdown {{if not $pageMeta.CanModifyIssueOrPull}}disabled{{end}}"> <a class="text muted"> - <strong>{{ctx.Locale.Tr "repo.issues.new.labels"}}</strong> {{if $canChange}}{{svg "octicon-gear"}}{{end}} + <strong>{{ctx.Locale.Tr "repo.issues.new.labels"}}</strong> {{if $pageMeta.CanModifyIssueOrPull}}{{svg "octicon-gear"}}{{end}} </a> <div class="menu"> {{if not $data.AllLabels}} @@ -16,7 +18,7 @@ </div> <a class="item clear-selection" href="#">{{ctx.Locale.Tr "repo.issues.new.clear_labels"}}</a> {{$previousExclusiveScope := "_no_scope"}} - {{range .RepoLabels}} + {{range $data.RepoLabels}} {{$exclusiveScope := .ExclusiveScope}} {{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}} <div class="divider"></div> @@ -26,7 +28,7 @@ {{end}} <div class="divider"></div> {{$previousExclusiveScope = "_no_scope"}} - {{range .OrgLabels}} + {{range $data.OrgLabels}} {{$exclusiveScope := .ExclusiveScope}} {{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}} <div class="divider"></div> @@ -42,7 +44,7 @@ <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}}"> + <a class="item" href="{{$pageMeta.RepoLink}}/{{if $pageMeta.IsPullRequest}}pulls{{else}}issues{{end}}?labels={{.ID}}"> {{- ctx.RenderUtils.RenderLabel . -}} </a> {{end}} diff --git a/templates/repo/issue/sidebar/label_list_item.tmpl b/templates/repo/issue/sidebar/label_list_item.tmpl index ad878e918be..5c6808d95b2 100644 --- a/templates/repo/issue/sidebar/label_list_item.tmpl +++ b/templates/repo/issue/sidebar/label_list_item.tmpl @@ -1,5 +1,5 @@ {{$label := .Label}} -<a class="item {{if $label.IsChecked}}checked{{else if $label.IsArchived}}tw-hidden{{end}}" href="#" +<a class="item muted {{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> diff --git a/templates/repo/issue/sidebar/milestone_list.tmpl b/templates/repo/issue/sidebar/milestone_list.tmpl index e9ca02f77a1..4f2b4cb06fb 100644 --- a/templates/repo/issue/sidebar/milestone_list.tmpl +++ b/templates/repo/issue/sidebar/milestone_list.tmpl @@ -1,22 +1,52 @@ +{{$pageMeta := .}} +{{$data := .MilestonesData}} +{{$issueMilestone := NIL}}{{if and $pageMeta.Issue $pageMeta.Issue.Milestone}}{{$issueMilestone = $pageMeta.Issue.Milestone}}{{end}} <div class="divider"></div> -<div class="ui {{if or (not .HasIssuesOrPullsWritePermission) .Repository.IsArchived}}disabled{{end}} floating jump select-milestone dropdown"> - <a class="text muted flex-text-block"> - <strong>{{ctx.Locale.Tr "repo.issues.new.milestone"}}</strong> - {{if and .HasIssuesOrPullsWritePermission (not .Repository.IsArchived)}} - {{svg "octicon-gear" 16 "tw-ml-1"}} - {{end}} - </a> - <div class="menu" data-action="update" data-issue-id="{{$.Issue.ID}}" data-update-url="{{$.RepoLink}}/issues/milestone"> - {{template "repo/issue/milestone/select_menu" .}} +<div class="issue-sidebar-combo" data-selection-mode="single" data-update-algo="all" + {{if $pageMeta.Issue}}data-update-url="{{$pageMeta.RepoLink}}/issues/milestone?issue_ids={{$pageMeta.Issue.ID}}"{{end}} +> + <input class="combo-value" name="milestone_id" type="hidden" value="{{$data.SelectedMilestoneID}}"> + <div class="ui dropdown {{if not $pageMeta.CanModifyIssueOrPull}}disabled{{end}} "> + <a class="text muted"> + <strong>{{ctx.Locale.Tr "repo.issues.new.milestone"}}</strong> {{if $pageMeta.CanModifyIssueOrPull}}{{svg "octicon-gear"}}{{end}} + </a> + <div class="menu"> + {{if and (not $data.OpenMilestones) (not $data.ClosedMilestones)}} + <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"}}</i> + <input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_milestones"}}"> + </div> + <div class="divider"></div> + <div class="item clear-selection">{{ctx.Locale.Tr "repo.issues.new.clear_milestone"}}</div> + {{if $data.OpenMilestones}} + <div class="divider"></div> + <div class="header">{{ctx.Locale.Tr "repo.issues.new.open_milestone"}}</div> + {{range $data.OpenMilestones}} + <a class="item muted" data-value="{{.ID}}" href="{{$pageMeta.RepoLink}}/issues?milestone={{.ID}}"> + {{svg "octicon-milestone" 18}} {{.Name}} + </a> + {{end}} + {{end}} + {{if $data.ClosedMilestones}} + <div class="divider"></div> + <div class="header">{{ctx.Locale.Tr "repo.issues.new.closed_milestone"}}</div> + {{range $data.ClosedMilestones}} + <a class="item muted" data-value="{{.ID}}" href="{{$pageMeta.RepoLink}}/issues?milestone={{.ID}}"> + {{svg "octicon-milestone" 18}} {{.Name}} + </a> + {{end}} + {{end}} + {{end}} + </div> </div> -</div> -<div class="ui select-milestone list"> - <span class="no-select item {{if .Issue.Milestone}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_milestone"}}</span> - <div class="selected"> - {{if .Issue.Milestone}} - <a class="item muted sidebar-item-link" href="{{.RepoLink}}/milestone/{{.Issue.Milestone.ID}}"> - {{svg "octicon-milestone" 18 "tw-mr-2"}} - {{.Issue.Milestone.Name}} + + <div class="ui list"> + <span class="item empty-list {{if $issueMilestone}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_milestone"}}</span> + {{if $issueMilestone}} + <a class="item muted" href="{{$pageMeta.RepoLink}}/milestone/{{$issueMilestone.ID}}"> + {{svg "octicon-milestone" 18}} {{$issueMilestone.Name}} </a> {{end}} </div> diff --git a/templates/repo/issue/sidebar/participant_list.tmpl b/templates/repo/issue/sidebar/participant_list.tmpl index 91c36fc01ef..11debf95c48 100644 --- a/templates/repo/issue/sidebar/participant_list.tmpl +++ b/templates/repo/issue/sidebar/participant_list.tmpl @@ -4,7 +4,7 @@ <div class="ui list tw-flex tw-flex-wrap"> {{range .Participants}} <a {{if gt .ID 0}}href="{{.HomeLink}}"{{end}} data-tooltip-content="{{.GetDisplayName}}"> - {{ctx.AvatarUtils.Avatar . 28 "tw-my-0.5 tw-mr-1"}} + {{ctx.AvatarUtils.Avatar . 20 "tw-my-0.5 tw-mr-1"}} </a> {{end}} </div> diff --git a/templates/repo/issue/sidebar/project_list.tmpl b/templates/repo/issue/sidebar/project_list.tmpl index ec79f8032f7..ab1243caddc 100644 --- a/templates/repo/issue/sidebar/project_list.tmpl +++ b/templates/repo/issue/sidebar/project_list.tmpl @@ -1,53 +1,49 @@ -{{if .IsProjectsEnabled}} - <div class="divider"></div> - - <div class="ui {{if or (not .HasIssuesOrPullsWritePermission) .Repository.IsArchived}}disabled{{end}} floating jump select-project dropdown"> - <a class="text muted flex-text-block"> - <strong>{{ctx.Locale.Tr "repo.issues.new.projects"}}</strong> - {{if and .HasIssuesOrPullsWritePermission (not .Repository.IsArchived)}} - {{svg "octicon-gear" 16 "tw-ml-1"}} - {{end}} +{{$pageMeta := .}} +{{$data := .ProjectsData}} +{{$issueProject := NIL}}{{if and $pageMeta.Issue $pageMeta.Issue.Project}}{{$issueProject = $pageMeta.Issue.Project}}{{end}} +<div class="divider"></div> +<div class="issue-sidebar-combo" data-selection-mode="single" data-update-algo="all" + {{if $pageMeta.Issue}}data-update-url="{{$pageMeta.RepoLink}}/issues/projects?issue_ids={{$pageMeta.Issue.ID}}"{{end}} +> + <input class="combo-value" name="project_id" type="hidden" value="{{$data.SelectedProjectID}}"> + <div class="ui dropdown {{if not $pageMeta.CanModifyIssueOrPull}}disabled{{end}}"> + <a class="text muted"> + <strong>{{ctx.Locale.Tr "repo.issues.new.projects"}}</strong> {{if $pageMeta.CanModifyIssueOrPull}}{{svg "octicon-gear"}}{{end}} </a> - <div class="menu" data-action="update" data-issue-id="{{$.Issue.ID}}" data-update-url="{{$.RepoLink}}/issues/projects"> - {{if or .OpenProjects .ClosedProjects}} + <div class="menu"> + {{if or $data.OpenProjects $data.ClosedProjects}} <div class="ui icon search input"> <i class="icon">{{svg "octicon-search" 16}}</i> <input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_projects"}}"> </div> {{end}} - <div class="no-select item">{{ctx.Locale.Tr "repo.issues.new.clear_projects"}}</div> - {{if .OpenProjects}} + <div class="item clear-selection">{{ctx.Locale.Tr "repo.issues.new.clear_projects"}}</div> + {{if $data.OpenProjects}} <div class="divider"></div> - <div class="header"> - {{ctx.Locale.Tr "repo.issues.new.open_projects"}} - </div> - {{range .OpenProjects}} - <a class="item muted sidebar-item-link" data-id="{{.ID}}" data-href="{{.Link ctx}}"> - {{svg .IconName 18 "tw-mr-2"}}{{.Title}} + <div class="header">{{ctx.Locale.Tr "repo.issues.new.open_projects"}}</div> + {{range $data.OpenProjects}} + <a class="item muted" data-value="{{.ID}}" href="{{.Link ctx}}"> + {{svg .IconName 18}} {{.Title}} </a> {{end}} {{end}} - {{if .ClosedProjects}} + {{if $data.ClosedProjects}} <div class="divider"></div> - <div class="header"> - {{ctx.Locale.Tr "repo.issues.new.closed_projects"}} - </div> - {{range .ClosedProjects}} - <a class="item muted sidebar-item-link" data-id="{{.ID}}" data-href="{{.Link ctx}}"> - {{svg .IconName 18 "tw-mr-2"}}{{.Title}} + <div class="header">{{ctx.Locale.Tr "repo.issues.new.closed_projects"}}</div> + {{range $data.ClosedProjects}} + <a class="item muted" data-value="{{.ID}}" href="{{.Link ctx}}"> + {{svg .IconName 18}} {{.Title}} </a> {{end}} {{end}} </div> </div> - <div class="ui select-project list"> - <span class="no-select item {{if .Issue.Project}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_projects"}}</span> - <div class="selected"> - {{if .Issue.Project}} - <a class="item muted sidebar-item-link" href="{{.Issue.Project.Link ctx}}"> - {{svg .Issue.Project.IconName 18 "tw-mr-2"}}{{.Issue.Project.Title}} - </a> - {{end}} - </div> + <div class="ui list"> + <span class="item empty-list {{if $issueProject}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_projects"}}</span> + {{if $issueProject}} + <a class="item muted" href="{{$issueProject.Link ctx}}"> + {{svg $issueProject.IconName 18}} {{$issueProject.Title}} + </a> + {{end}} </div> -{{end}} +</div> diff --git a/templates/repo/issue/sidebar/reviewer_list.tmpl b/templates/repo/issue/sidebar/reviewer_list.tmpl index cf7b97c02b1..e990fc5afc8 100644 --- a/templates/repo/issue/sidebar/reviewer_list.tmpl +++ b/templates/repo/issue/sidebar/reviewer_list.tmpl @@ -1,10 +1,14 @@ -{{$data := .}} +{{$pageMeta := .}} +{{$data := .ReviewersData}} +{{$repoOwnerName := $pageMeta.Repository.OwnerName}} {{$hasCandidates := or $data.Reviewers $data.TeamReviewers}} -<div class="issue-sidebar-combo" {{if $data.IssueID}}data-update-url="{{$data.RepoLink}}/issues/request_review?issue_ids={{$data.IssueID}}"{{end}}> +<div class="issue-sidebar-combo" data-selection-mode="multiple" data-update-algo="diff" + {{if $pageMeta.Issue}}data-update-url="{{$pageMeta.RepoLink}}/issues/request_review?issue_ids={{$pageMeta.Issue.ID}}"{{end}} +> <input type="hidden" class="combo-value" name="reviewer_ids">{{/* match CreateIssueForm */}} <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}} + <strong>{{ctx.Locale.Tr "repo.issues.review.reviewers"}}</strong> {{if $data.CanChooseReviewer}}{{svg "octicon-gear"}}{{end}} </a> <div class="menu flex-items-menu"> {{if $hasCandidates}} @@ -29,7 +33,7 @@ <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}}> <span class="item-check-mark">{{svg "octicon-check"}}</span> - {{svg "octicon-people" 20}} {{$data.RepoOwnerName}}/{{.Team.Name}} + {{svg "octicon-people" 20}} {{$repoOwnerName}}/{{.Team.Name}} </a> {{end}} {{end}} @@ -47,7 +51,7 @@ {{if .User}} <a class="muted flex-text-inline" href="{{.User.HomeLink}}">{{ctx.AvatarUtils.Avatar .User 20}} {{.User.GetDisplayName}}</a> {{else if .Team}} - {{svg "octicon-people" 20}} {{$data.RepoOwnerName}}/{{.Team.Name}} + {{svg "octicon-people" 20}} {{$repoOwnerName}}/{{.Team.Name}} {{end}} </div> <div class="flex-text-inline"> @@ -64,13 +68,13 @@ {{if .Requested}} <a href="#" class="ui muted icon link-action" data-tooltip-content="{{ctx.Locale.Tr "repo.issues.remove_request_review"}}" - data-url="{{$data.RepoLink}}/issues/request_review?action=detach&issue_ids={{$data.IssueID}}&id={{.ItemID}}"> + data-url="{{$pageMeta.RepoLink}}/issues/request_review?action=detach&issue_ids={{$pageMeta.Issue.ID}}&id={{.ItemID}}"> {{svg "octicon-trash"}} </a> {{else}} <a href="#" class="ui muted icon link-action" data-tooltip-content="{{ctx.Locale.Tr "repo.issues.re_request_review"}}" - data-url="{{$data.RepoLink}}/issues/request_review?action=attach&issue_ids={{$data.IssueID}}&id={{.ItemID}}"> + data-url="{{$pageMeta.RepoLink}}/issues/request_review?action=attach&issue_ids={{$pageMeta.Issue.ID}}&id={{.ItemID}}"> {{svg "octicon-sync"}} </a> {{end}} @@ -84,8 +88,8 @@ {{range $data.OriginalReviews}} <div class="item"> <div class="flex-text-inline tw-flex-1"> - {{$originalURLHostname := $data.Repository.GetOriginalURLHostname}} - {{$originalURL := $data.Repository.OriginalURL}} + {{$originalURLHostname := $pageMeta.Repository.GetOriginalURLHostname}} + {{$originalURL := $pageMeta.Repository.OriginalURL}} <a class="muted flex-text-inline" href="{{$originalURL}}" data-tooltip-content="{{ctx.Locale.Tr "repo.migrated_from_fake" $originalURLHostname}}"> {{svg (MigrationIcon $originalURLHostname) 20}} {{.OriginalAuthor}} </a> @@ -108,7 +112,7 @@ <div class="ui warning message"> {{ctx.Locale.Tr "repo.issues.dismiss_review_warning"}} </div> - <form class="ui form" action="{{$data.RepoLink}}/issues/dismiss_review" method="post"> + <form class="ui form" action="{{$pageMeta.RepoLink}}/issues/dismiss_review" method="post"> {{ctx.RootData.CsrfTokenHtml}} <input type="hidden" class="reviewer-id" name="review_id"> <div class="field"> diff --git a/templates/repo/issue/view_content/sidebar.tmpl b/templates/repo/issue/view_content/sidebar.tmpl index 0fae1e9e1c1..02f5d3e2df9 100644 --- a/templates/repo/issue/view_content/sidebar.tmpl +++ b/templates/repo/issue/view_content/sidebar.tmpl @@ -2,16 +2,19 @@ {{template "repo/issue/branch_selector_field" $}} {{if .Issue.IsPull}} - {{template "repo/issue/sidebar/reviewer_list" $.IssueSidebarReviewersData}} + {{template "repo/issue/sidebar/reviewer_list" $.IssuePageMetaData}} {{template "repo/issue/sidebar/wip_switch" $}} <div class="divider"></div> {{end}} - {{template "repo/issue/sidebar/label_list" $.IssueSidebarLabelsData}} + {{template "repo/issue/sidebar/label_list" $.IssuePageMetaData}} + + {{template "repo/issue/sidebar/milestone_list" $.IssuePageMetaData}} + {{if .IsProjectsEnabled}} + {{template "repo/issue/sidebar/project_list" $.IssuePageMetaData}} + {{end}} + {{template "repo/issue/sidebar/assignee_list" $.IssuePageMetaData}} - {{template "repo/issue/sidebar/milestone_list" $}} - {{template "repo/issue/sidebar/project_list" $}} - {{template "repo/issue/sidebar/assignee_list" $}} {{template "repo/issue/sidebar/participant_list" $}} {{template "repo/issue/sidebar/watch_notification" $}} {{template "repo/issue/sidebar/stopwatch_timetracker" $}} diff --git a/web_src/css/repo.css b/web_src/css/repo.css index ff8342d29aa..01ddab97e59 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -2453,12 +2453,6 @@ tbody.commit-list { margin-top: 1em; } -.sidebar-item-link { - display: inline-flex; - align-items: center; - overflow-wrap: anywhere; -} - .diff-file-header { padding: 5px 8px !important; box-shadow: 0 -1px 0 1px var(--color-body); /* prevent borders being visible behind top corners when sticky and scrolled */ diff --git a/web_src/js/features/repo-issue-sidebar-combolist.ts b/web_src/js/features/repo-issue-sidebar-combolist.ts index f408eb43ba0..24d620547f7 100644 --- a/web_src/js/features/repo-issue-sidebar-combolist.ts +++ b/web_src/js/features/repo-issue-sidebar-combolist.ts @@ -3,7 +3,7 @@ import {POST} from '../modules/fetch.ts'; import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts'; // if there are draft comments, confirm before reloading, to avoid losing comments -export function issueSidebarReloadConfirmDraftComment() { +function issueSidebarReloadConfirmDraftComment() { const commentTextareas = [ document.querySelector<HTMLTextAreaElement>('.edit-content-zone:not(.tw-hidden) textarea'), document.querySelector<HTMLTextAreaElement>('#comment-form textarea'), @@ -22,84 +22,138 @@ export function issueSidebarReloadConfirmDraftComment() { window.location.reload(); } -function collectCheckedValues(elDropdown: HTMLElement) { - return Array.from(elDropdown.querySelectorAll('.menu > .item.checked'), (el) => el.getAttribute('data-value')); -} +class IssueSidebarComboList { + updateUrl: string; + updateAlgo: string; + selectionMode: string; + elDropdown: HTMLElement; + elList: HTMLElement; + elComboValue: HTMLInputElement; + initialValues: string[]; -export function initIssueSidebarComboList(container: HTMLElement) { - 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'); - let initialValues = collectCheckedValues(elDropdown); + constructor(private container: HTMLElement) { + this.updateUrl = this.container.getAttribute('data-update-url'); + this.updateAlgo = container.getAttribute('data-update-algo'); + this.selectionMode = container.getAttribute('data-selection-mode'); + if (!['single', 'multiple'].includes(this.selectionMode)) throw new Error(`Invalid data-update-on: ${this.selectionMode}`); + if (!['diff', 'all'].includes(this.updateAlgo)) throw new Error(`Invalid data-update-algo: ${this.updateAlgo}`); + this.elDropdown = container.querySelector<HTMLElement>(':scope > .ui.dropdown'); + this.elList = container.querySelector<HTMLElement>(':scope > .ui.list'); + this.elComboValue = container.querySelector<HTMLInputElement>(':scope > .combo-value'); + } - elDropdown.addEventListener('click', (e) => { + collectCheckedValues() { + return Array.from(this.elDropdown.querySelectorAll('.menu > .item.checked'), (el) => el.getAttribute('data-value')); + } + + updateUiList(changedValues) { + const elEmptyTip = this.elList.querySelector('.item.empty-list'); + queryElemChildren(this.elList, '.item:not(.empty-list)', (el) => el.remove()); + for (const value of changedValues) { + const el = this.elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${CSS.escape(value)}"]`); + if (!el) continue; + const listItem = el.cloneNode(true) as HTMLElement; + queryElems(listItem, '.item-check-mark, .item-secondary-info', (el) => el.remove()); + this.elList.append(listItem); + } + const hasItems = Boolean(this.elList.querySelector('.item:not(.empty-list)')); + toggleElem(elEmptyTip, !hasItems); + } + + async updateToBackend(changedValues) { + if (this.updateAlgo === 'diff') { + for (const value of this.initialValues) { + if (!changedValues.includes(value)) { + await POST(this.updateUrl, {data: new URLSearchParams({action: 'detach', id: value})}); + } + } + for (const value of changedValues) { + if (!this.initialValues.includes(value)) { + await POST(this.updateUrl, {data: new URLSearchParams({action: 'attach', id: value})}); + } + } + } else { + await POST(this.updateUrl, {data: new URLSearchParams({id: changedValues.join(',')})}); + } + issueSidebarReloadConfirmDraftComment(); + } + + async doUpdate() { + const changedValues = this.collectCheckedValues(); + if (this.initialValues.join(',') === changedValues.join(',')) return; + this.updateUiList(changedValues); + if (this.updateUrl) await this.updateToBackend(changedValues); + this.initialValues = changedValues; + } + + async onChange() { + if (this.selectionMode === 'single') { + await this.doUpdate(); + fomanticQuery(this.elDropdown).dropdown('hide'); + } + } + + async onItemClick(e) { const elItem = (e.target as HTMLElement).closest('.item'); if (!elItem) return; e.preventDefault(); 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 = ''; + queryElems(this.elDropdown, '.menu > .item', (el) => el.classList.remove('checked')); + this.elComboValue.value = ''; + this.onChange(); 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)}"]`); + const elSelected = this.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')); + queryElems(this.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(','); - }); - - const updateToBackend = async (changedValues) => { - let changed = false; - for (const value of initialValues) { - if (!changedValues.includes(value)) { - await POST(updateUrl, {data: new URLSearchParams({action: 'detach', id: value})}); - changed = true; + if (this.selectionMode === 'multiple') { + elItem.classList.toggle('checked'); + } else { + queryElems(this.elDropdown, `.menu > .item.checked`, (el) => el.classList.remove('checked')); + elItem.classList.toggle('checked', true); } } - for (const value of changedValues) { - if (!initialValues.includes(value)) { - await POST(updateUrl, {data: new URLSearchParams({action: 'attach', id: value})}); - changed = true; + this.elComboValue.value = this.collectCheckedValues().join(','); + this.onChange(); + } + + async onHide() { + if (this.selectionMode === 'multiple') this.doUpdate(); + } + + init() { + // init the checked items from initial value + if (this.elComboValue.value && this.elComboValue.value !== '0' && !queryElems(this.elDropdown, `.menu > .item.checked`).length) { + const values = this.elComboValue.value.split(','); + for (const value of values) { + const elItem = this.elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${CSS.escape(value)}"]`); + elItem?.classList.add('checked'); } + this.updateUiList(values); } - if (changed) issueSidebarReloadConfirmDraftComment(); - }; + this.initialValues = this.collectCheckedValues(); - 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="${CSS.escape(value)}"]`); - const listItem = el.cloneNode(true) as HTMLElement; - 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); - }; + this.elDropdown.addEventListener('click', (e) => this.onItemClick(e)); - 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); - syncUiList(changedValues); - if (updateUrl) await updateToBackend(changedValues); - initialValues = changedValues; - }, - }); + fomanticQuery(this.elDropdown).dropdown('setting', { + action: 'nothing', // do not hide the menu if user presses Enter + fullTextSearch: 'exact', + onHide: () => this.onHide(), + }); + } +} + +export function initIssueSidebarComboList(container: HTMLElement) { + new IssueSidebarComboList(container).init(); } diff --git a/web_src/js/features/repo-issue-sidebar.md b/web_src/js/features/repo-issue-sidebar.md index 3022b52d05b..6de013f1c28 100644 --- a/web_src/js/features/repo-issue-sidebar.md +++ b/web_src/js/features/repo-issue-sidebar.md @@ -1,7 +1,7 @@ A sidebar combo (dropdown+list) is like this: ```html -<div class="issue-sidebar-combo" data-update-url="..."> +<div class="issue-sidebar-combo" data-selection-mode="..." data-update-url="..."> <input class="combo-value" name="..." type="hidden" value="..."> <div class="ui dropdown"> <div class="menu"> @@ -25,3 +25,7 @@ If there is `data-update-url`, it also calls backend to attach/detach the change 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. + +The dropdown selection could work in 2 modes: +* single: only one item could be selected, it updates immediately when the item is selected. +* multiple: multiple items could be selected, it defers the update until the dropdown is hidden. diff --git a/web_src/js/features/repo-issue-sidebar.ts b/web_src/js/features/repo-issue-sidebar.ts index 52878848e8c..45cd38d533d 100644 --- a/web_src/js/features/repo-issue-sidebar.ts +++ b/web_src/js/features/repo-issue-sidebar.ts @@ -1,10 +1,7 @@ import $ from 'jquery'; import {POST} from '../modules/fetch.ts'; -import {updateIssuesMeta} from './repo-common.ts'; -import {svg} from '../svg.ts'; -import {htmlEscape} from 'escape-goat'; import {queryElems, toggleElem} from '../utils/dom.ts'; -import {initIssueSidebarComboList, issueSidebarReloadConfirmDraftComment} from './repo-issue-sidebar-combolist.ts'; +import {initIssueSidebarComboList} from './repo-issue-sidebar-combolist.ts'; function initBranchSelector() { const elSelectBranch = document.querySelector('.ui.dropdown.select-branch'); @@ -34,212 +31,6 @@ function initBranchSelector() { }); } -// List submits -function initListSubmits(selector, outerSelector) { - const $list = $(`.ui.${outerSelector}.list`); - const $noSelect = $list.find('.no-select'); - const $listMenu = $(`.${selector} .menu`); - let hasUpdateAction = $listMenu.data('action') === 'update'; - const items = {}; - - $(`.${selector}`).dropdown({ - 'action': 'nothing', // do not hide the menu if user presses Enter - fullTextSearch: 'exact', - async onHide() { - hasUpdateAction = $listMenu.data('action') === 'update'; // Update the var - if (hasUpdateAction) { - // TODO: Add batch functionality and make this 1 network request. - const itemEntries = Object.entries(items); - for (const [elementId, item] of itemEntries) { - await updateIssuesMeta( - item['update-url'], - item['action'], - item['issue-id'], - elementId, - ); - } - if (itemEntries.length) { - issueSidebarReloadConfirmDraftComment(); - } - } - }, - }); - - $listMenu.find('.item:not(.no-select)').on('click', function (e) { - e.preventDefault(); - if (this.classList.contains('ban-change')) { - return false; - } - - hasUpdateAction = $listMenu.data('action') === 'update'; // Update the var - - const clickedItem = this; // eslint-disable-line unicorn/no-this-assignment - const scope = this.getAttribute('data-scope'); - - $(this).parent().find('.item').each(function () { - if (scope) { - // Enable only clicked item for scoped labels - if (this.getAttribute('data-scope') !== scope) { - return; - } - if (this !== clickedItem && !this.classList.contains('checked')) { - return; - } - } else if (this !== clickedItem) { - // Toggle for other labels - return; - } - - if (this.classList.contains('checked')) { - $(this).removeClass('checked'); - $(this).find('.octicon-check').addClass('tw-invisible'); - if (hasUpdateAction) { - if (!($(this).data('id') in items)) { - items[$(this).data('id')] = { - 'update-url': $listMenu.data('update-url'), - action: 'detach', - 'issue-id': $listMenu.data('issue-id'), - }; - } else { - delete items[$(this).data('id')]; - } - } - } else { - $(this).addClass('checked'); - $(this).find('.octicon-check').removeClass('tw-invisible'); - if (hasUpdateAction) { - if (!($(this).data('id') in items)) { - items[$(this).data('id')] = { - 'update-url': $listMenu.data('update-url'), - action: 'attach', - 'issue-id': $listMenu.data('issue-id'), - }; - } else { - delete items[$(this).data('id')]; - } - } - } - }); - - // TODO: Which thing should be done for choosing review requests - // to make chosen items be shown on time here? - if (selector === 'select-assignees-modify') { - return false; - } - - const listIds = []; - $(this).parent().find('.item').each(function () { - if (this.classList.contains('checked')) { - listIds.push($(this).data('id')); - $($(this).data('id-selector')).removeClass('tw-hidden'); - } else { - $($(this).data('id-selector')).addClass('tw-hidden'); - } - }); - if (!listIds.length) { - $noSelect.removeClass('tw-hidden'); - } else { - $noSelect.addClass('tw-hidden'); - } - $($(this).parent().data('id')).val(listIds.join(',')); - return false; - }); - $listMenu.find('.no-select.item').on('click', function (e) { - e.preventDefault(); - if (hasUpdateAction) { - (async () => { - await updateIssuesMeta( - $listMenu.data('update-url'), - 'clear', - $listMenu.data('issue-id'), - '', - ); - issueSidebarReloadConfirmDraftComment(); - })(); - } - - $(this).parent().find('.item').each(function () { - $(this).removeClass('checked'); - $(this).find('.octicon-check').addClass('tw-invisible'); - }); - - if (selector === 'select-assignees-modify') { - return false; - } - - $list.find('.item').each(function () { - $(this).addClass('tw-hidden'); - }); - $noSelect.removeClass('tw-hidden'); - $($(this).parent().data('id')).val(''); - }); -} - -function selectItem(select_id, input_id) { - const $menu = $(`${select_id} .menu`); - const $list = $(`.ui${select_id}.list`); - const hasUpdateAction = $menu.data('action') === 'update'; - - $menu.find('.item:not(.no-select)').on('click', function () { - $(this).parent().find('.item').each(function () { - $(this).removeClass('selected active'); - }); - - $(this).addClass('selected active'); - if (hasUpdateAction) { - (async () => { - await updateIssuesMeta( - $menu.data('update-url'), - '', - $menu.data('issue-id'), - $(this).data('id'), - ); - issueSidebarReloadConfirmDraftComment(); - })(); - } - - let icon = ''; - if (input_id === '#milestone_id') { - icon = svg('octicon-milestone', 18, 'tw-mr-2'); - } else if (input_id === '#project_id') { - icon = svg('octicon-project', 18, 'tw-mr-2'); - } else if (input_id === '#assignee_id') { - icon = `<img class="ui avatar image tw-mr-2" alt="avatar" src=${$(this).data('avatar')}>`; - } - - $list.find('.selected').html(` - <a class="item muted sidebar-item-link" href="${htmlEscape(this.getAttribute('data-href'))}"> - ${icon} - ${htmlEscape(this.textContent)} - </a> - `); - - $(`.ui${select_id}.list .no-select`).addClass('tw-hidden'); - $(input_id).val($(this).data('id')); - }); - $menu.find('.no-select.item').on('click', function () { - $(this).parent().find('.item:not(.no-select)').each(function () { - $(this).removeClass('selected active'); - }); - - if (hasUpdateAction) { - (async () => { - await updateIssuesMeta( - $menu.data('update-url'), - '', - $menu.data('issue-id'), - $(this).data('id'), - ); - issueSidebarReloadConfirmDraftComment(); - })(); - } - - $list.find('.selected').html(''); - $list.find('.no-select').removeClass('tw-hidden'); - $(input_id).val(''); - }); -} - function initRepoIssueDue() { const form = document.querySelector<HTMLFormElement>('.issue-due-form'); if (!form) return; @@ -257,14 +48,6 @@ export function initRepoIssueSidebar() { initBranchSelector(); initRepoIssueDue(); - // TODO: refactor the legacy initListSubmits&selectItem to initIssueSidebarComboList - initListSubmits('select-assignees', 'assignees'); - initListSubmits('select-assignees-modify', 'assignees'); - selectItem('.select-assignee', '#assignee_id'); - - selectItem('.select-project', '#project_id'); - 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)); }