Refactor sidebar assignee&milestone&project selectors (#32465)

Follow #32460

Now the code could be much clearer than before and easier to maintain. A
lot of legacy code is removed.

Manually tested.

This PR is large enough, that fine tunes could be deferred to the future if
there is no bug found or design problem.

Screenshots:

<details>

![image](https://github.com/user-attachments/assets/35f4ab7b-1bc0-4bad-a73c-a4569328303c)

</details>
pull/32234/head^2
wxiaoguang 2 weeks ago committed by GitHub
parent 58c634b854
commit a928739456
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 3
      modules/base/tool.go
  2. 1
      modules/base/tool_test.go
  3. 4
      modules/container/set.go
  4. 2
      modules/container/set_test.go
  5. 1
      modules/templates/helper.go
  6. 12
      routers/web/repo/compare.go
  7. 474
      routers/web/repo/issue.go
  8. 2
      routers/web/repo/pull.go
  9. 1
      services/forms/repo_form.go
  10. 38
      templates/repo/issue/milestone/select_menu.tmpl
  11. 138
      templates/repo/issue/new_form.tmpl
  12. 69
      templates/repo/issue/sidebar/assignee_list.tmpl
  13. 18
      templates/repo/issue/sidebar/label_list.tmpl
  14. 2
      templates/repo/issue/sidebar/label_list_item.tmpl
  15. 64
      templates/repo/issue/sidebar/milestone_list.tmpl
  16. 2
      templates/repo/issue/sidebar/participant_list.tmpl
  17. 68
      templates/repo/issue/sidebar/project_list.tmpl
  18. 24
      templates/repo/issue/sidebar/reviewer_list.tmpl
  19. 13
      templates/repo/issue/view_content/sidebar.tmpl
  20. 6
      web_src/css/repo.css
  21. 166
      web_src/js/features/repo-issue-sidebar-combolist.ts
  22. 6
      web_src/js/features/repo-issue-sidebar.md
  23. 219
      web_src/js/features/repo-issue-sidebar.ts

@ -147,6 +147,9 @@ func StringsToInt64s(strs []string) ([]int64, error) {
} }
ints := make([]int64, 0, len(strs)) ints := make([]int64, 0, len(strs))
for _, s := range strs { for _, s := range strs {
if s == "" {
continue
}
n, err := strconv.ParseInt(s, 10, 64) n, err := strconv.ParseInt(s, 10, 64)
if err != nil { if err != nil {
return nil, err return nil, err

@ -152,6 +152,7 @@ func TestStringsToInt64s(t *testing.T) {
} }
testSuccess(nil, nil) testSuccess(nil, nil)
testSuccess([]string{}, []int64{}) testSuccess([]string{}, []int64{})
testSuccess([]string{""}, []int64{})
testSuccess([]string{"-1234"}, []int64{-1234}) testSuccess([]string{"-1234"}, []int64{-1234})
testSuccess([]string{"1", "4", "16", "64", "256"}, []int64{1, 4, 16, 64, 256}) testSuccess([]string{"1", "4", "16", "64", "256"}, []int64{1, 4, 16, 64, 256})

@ -31,8 +31,8 @@ func (s Set[T]) AddMultiple(values ...T) {
} }
} }
// Contains determines whether a set contains the specified elements. // Contains determines whether a set contains all these elements.
// Returns true if the set contains the specified element; otherwise, false. // Returns true if the set contains all these elements; otherwise, false.
func (s Set[T]) Contains(values ...T) bool { func (s Set[T]) Contains(values ...T) bool {
ret := true ret := true
for _, value := range values { for _, value := range values {

@ -18,7 +18,9 @@ func TestSet(t *testing.T) {
assert.True(t, s.Contains("key1")) assert.True(t, s.Contains("key1"))
assert.True(t, s.Contains("key2")) assert.True(t, s.Contains("key2"))
assert.True(t, s.Contains("key1", "key2"))
assert.False(t, s.Contains("key3")) assert.False(t, s.Contains("key3"))
assert.False(t, s.Contains("key1", "key3"))
assert.True(t, s.Remove("key2")) assert.True(t, s.Remove("key2"))
assert.False(t, s.Contains("key2")) assert.False(t, s.Contains("key2"))

@ -31,6 +31,7 @@ func NewFuncMap() template.FuncMap {
"ctx": func() any { return nil }, // template context function "ctx": func() any { return nil }, // template context function
"DumpVar": dumpVar, "DumpVar": dumpVar,
"NIL": func() any { return nil },
// ----------------------------------------------------------------- // -----------------------------------------------------------------
// html/template related functions // html/template related functions

@ -788,19 +788,11 @@ func CompareDiff(ctx *context.Context) {
if !nothingToCompare { if !nothingToCompare {
// Setup information for new form. // Setup information for new form.
retrieveRepoMetasForIssueWriter(ctx, ctx.Repo.Repository, true) pageMetaData := retrieveRepoIssueMetaData(ctx, ctx.Repo.Repository, nil, true)
if ctx.Written() { if ctx.Written() {
return return
} }
labelsData := retrieveRepoLabels(ctx, ctx.Repo.Repository, 0, true) _, templateErrs := setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates, pageMetaData)
if ctx.Written() {
return
}
RetrieveRepoReviewers(ctx, ctx.Repo.Repository, nil, true)
if ctx.Written() {
return
}
_, templateErrs := setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates, labelsData)
if len(templateErrs) > 0 { if len(templateErrs) > 0 {
ctx.Flash.Warning(renderErrorOfTemplates(ctx, templateErrs), true) ctx.Flash.Warning(renderErrorOfTemplates(ctx, templateErrs), true)
} }

@ -431,7 +431,7 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption opt
return 0 return 0
} }
retrieveProjects(ctx, repo) retrieveProjectsForIssueList(ctx, repo)
if ctx.Written() { if ctx.Written() {
return return
} }
@ -556,37 +556,147 @@ func renderMilestones(ctx *context.Context) {
ctx.Data["ClosedMilestones"] = closedMilestones ctx.Data["ClosedMilestones"] = closedMilestones
} }
// RetrieveRepoMilestonesAndAssignees find all the milestones and assignees of a repository type issueSidebarMilestoneData struct {
func RetrieveRepoMilestonesAndAssignees(ctx *context.Context, repo *repo_model.Repository) { 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 var err error
ctx.Data["OpenMilestones"], err = db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ if d.Issue != nil {
RepoID: repo.ID, 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), IsClosed: optional.Some(false),
}) })
if err != nil { if err != nil {
ctx.ServerError("GetMilestones", err) ctx.ServerError("GetMilestones", err)
return return
} }
ctx.Data["ClosedMilestones"], err = db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ d.MilestonesData.ClosedMilestones, err = db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{
RepoID: repo.ID, RepoID: d.Repository.ID,
IsClosed: optional.Some(true), IsClosed: optional.Some(true),
}) })
if err != nil { if err != nil {
ctx.ServerError("GetMilestones", err) ctx.ServerError("GetMilestones", err)
return 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 { if err != nil {
ctx.ServerError("GetRepoAssignees", err) ctx.ServerError("GetRepoAssignees", err)
return 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) 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 // Distinguish whether the owner of the repository
// is an individual or an organization // is an individual or an organization
repoOwnerType := project_model.TypeIndividual repoOwnerType := project_model.TypeIndividual
@ -609,7 +719,7 @@ func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) {
}) })
if err != nil { if err != nil {
ctx.ServerError("GetProjects", err) ctx.ServerError("GetProjects", err)
return return nil, nil
} }
closedProjects, err = db.Find[project_model.Project](ctx, project_model.SearchOptions{ closedProjects, err = db.Find[project_model.Project](ctx, project_model.SearchOptions{
ListOptions: db.ListOptionsAll, ListOptions: db.ListOptionsAll,
@ -619,7 +729,7 @@ func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) {
}) })
if err != nil { if err != nil {
ctx.ServerError("GetProjects", err) ctx.ServerError("GetProjects", err)
return return nil, nil
} }
} }
@ -632,7 +742,7 @@ func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) {
}) })
if err != nil { if err != nil {
ctx.ServerError("GetProjects", err) ctx.ServerError("GetProjects", err)
return return nil, nil
} }
openProjects = append(openProjects, openProjects2...) openProjects = append(openProjects, openProjects2...)
closedProjects2, err := db.Find[project_model.Project](ctx, project_model.SearchOptions{ 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 { if err != nil {
ctx.ServerError("GetProjects", err) ctx.ServerError("GetProjects", err)
return return nil, nil
} }
closedProjects = append(closedProjects, closedProjects2...) closedProjects = append(closedProjects, closedProjects2...)
} }
return openProjects, closedProjects
ctx.Data["OpenProjects"] = openProjects
ctx.Data["ClosedProjects"] = closedProjects
} }
// repoReviewerSelection items to bee shown // repoReviewerSelection items to bee shown
@ -665,10 +773,6 @@ type repoReviewerSelection struct {
} }
type issueSidebarReviewersData struct { type issueSidebarReviewersData struct {
Repository *repo_model.Repository
RepoOwnerName string
RepoLink string
IssueID int64
CanChooseReviewer bool CanChooseReviewer bool
OriginalReviews issues_model.ReviewList OriginalReviews issues_model.ReviewList
TeamReviewers []*repoReviewerSelection 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. // 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) { func (d *IssuePageMetaData) retrieveReviewersData(ctx *context.Context) {
data := &issueSidebarReviewersData{} data := d.ReviewersData
data.RepoLink = ctx.Repo.RepoLink repo := d.Repository
data.Repository = repo if ctx.Doer != nil && ctx.IsSigned {
data.RepoOwnerName = repo.OwnerName if d.Issue == nil {
data.CanChooseReviewer = canChooseReviewer data.CanChooseReviewer = true
} else {
data.CanChooseReviewer = issue_service.CanDoerChangeReviewRequests(ctx, ctx.Doer, repo, d.Issue)
}
}
var posterID int64 var posterID int64
var isClosed bool var isClosed bool
var reviews issues_model.ReviewList var reviews issues_model.ReviewList
if issue == nil { if d.Issue == nil {
posterID = ctx.Doer.ID posterID = ctx.Doer.ID
} else { } else {
posterID = issue.PosterID posterID = d.Issue.PosterID
if issue.OriginalAuthorID > 0 { if d.Issue.OriginalAuthorID > 0 {
posterID = 0 // for migrated PRs, no poster ID posterID = 0 // for migrated PRs, no poster ID
} }
data.IssueID = issue.ID isClosed = d.Issue.IsClosed || d.Issue.PullRequest.HasMerged
isClosed = issue.IsClosed || issue.PullRequest.HasMerged
originalAuthorReviews, err := issues_model.GetReviewersFromOriginalAuthorsByIssueID(ctx, issue.ID) originalAuthorReviews, err := issues_model.GetReviewersFromOriginalAuthorsByIssueID(ctx, d.Issue.ID)
if err != nil { if err != nil {
ctx.ServerError("GetReviewersFromOriginalAuthorsByIssueID", err) ctx.ServerError("GetReviewersFromOriginalAuthorsByIssueID", err)
return return
} }
data.OriginalReviews = originalAuthorReviews data.OriginalReviews = originalAuthorReviews
reviews, err = issues_model.GetReviewsByIssueID(ctx, issue.ID) reviews, err = issues_model.GetReviewsByIssueID(ctx, d.Issue.ID)
if err != nil { if err != nil {
ctx.ServerError("GetReviewersByIssueID", err) ctx.ServerError("GetReviewersByIssueID", err)
return return
} }
if len(reviews) == 0 && !canChooseReviewer { if len(reviews) == 0 && !data.CanChooseReviewer {
return return
} }
} }
@ -724,7 +831,7 @@ func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, is
reviewers []*user_model.User reviewers []*user_model.User
) )
if canChooseReviewer { if data.CanChooseReviewer {
var err error var err error
reviewers, err = repo_model.GetReviewers(ctx, repo, ctx.Doer.ID, posterID) reviewers, err = repo_model.GetReviewers(ctx, repo, ctx.Doer.ID, posterID)
if err != nil { if err != nil {
@ -760,7 +867,7 @@ func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, is
tmp.ItemID = -review.ReviewerTeamID tmp.ItemID = -review.ReviewerTeamID
} }
if canChooseReviewer { if data.CanChooseReviewer {
// Users who can choose reviewers can also remove review requests // Users who can choose reviewers can also remove review requests
tmp.CanChange = true tmp.CanChange = true
} else if ctx.Doer != nil && ctx.Doer.ID == review.ReviewerID && review.Type == issues_model.ReviewTypeRequest { } 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) pullReviews = append(pullReviews, tmp)
if canChooseReviewer { if data.CanChooseReviewer {
if tmp.IsTeam { if tmp.IsTeam {
teamReviewersResult = append(teamReviewersResult, tmp) teamReviewersResult = append(teamReviewersResult, tmp)
} else { } else {
@ -811,7 +918,7 @@ func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, is
data.CurrentPullReviewers = currentPullReviewers data.CurrentPullReviewers = currentPullReviewers
} }
if canChooseReviewer && reviewersResult != nil { if data.CanChooseReviewer && reviewersResult != nil {
preadded := len(reviewersResult) preadded := len(reviewersResult)
for _, reviewer := range reviewers { for _, reviewer := range reviewers {
found := false found := false
@ -839,7 +946,7 @@ func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, is
data.Reviewers = reviewersResult data.Reviewers = reviewersResult
} }
if canChooseReviewer && teamReviewersResult != nil { if data.CanChooseReviewer && teamReviewersResult != nil {
preadded := len(teamReviewersResult) preadded := len(teamReviewersResult)
for _, team := range teamReviewers { for _, team := range teamReviewers {
found := false found := false
@ -866,15 +973,9 @@ func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, is
data.TeamReviewers = teamReviewersResult data.TeamReviewers = teamReviewersResult
} }
ctx.Data["IssueSidebarReviewersData"] = data
} }
type issueSidebarLabelsData struct { type issueSidebarLabelsData struct {
Repository *repo_model.Repository
RepoLink string
IssueID int64
IsPullRequest bool
AllLabels []*issues_model.Label AllLabels []*issues_model.Label
RepoLabels []*issues_model.Label RepoLabels []*issues_model.Label
OrgLabels []*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 { func (d *IssuePageMetaData) retrieveLabelsData(ctx *context.Context) {
labelsData := &issueSidebarLabelsData{ repo := d.Repository
Repository: repo, labelsData := d.LabelsData
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
} }
labelsData.RepoLabels = 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
} }
labelsData.OrgLabels = orgLabels labelsData.OrgLabels = orgLabels
} }
labelsData.AllLabels = append(labelsData.AllLabels, labelsData.RepoLabels...) labelsData.AllLabels = append(labelsData.AllLabels, labelsData.RepoLabels...)
labelsData.AllLabels = append(labelsData.AllLabels, labelsData.OrgLabels...) 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. // 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) commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch)
if err != nil { if err != nil {
return false, nil return false, nil
@ -1013,24 +1084,20 @@ func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles
ctx.Data["TemplateFile"] = template.FileName 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)) 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 { for _, userID := range userIDs {
selectedAssigneeIDs = append(selectedAssigneeIDs, userID)
selectedAssigneeIDStrings = append(selectedAssigneeIDStrings, strconv.FormatInt(userID, 10)) 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> 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["HasSelectedAssignee"] = len(selectedAssigneeIDs) > 0
ctx.Data["assignee_ids"] = strings.Join(selectedAssigneeIDStrings, ",")
ctx.Data["SelectedAssigneeIDs"] = selectedAssigneeIDs
ctx.Data["Reference"] = template.Ref ctx.Data["Reference"] = template.Ref
ctx.Data["RefEndName"] = git.RefName(template.Ref).ShortName() ctx.Data["RefEndName"] = git.RefName(template.Ref).ShortName()
return true, templateErrs return true, templateErrs
@ -1057,42 +1124,19 @@ func NewIssue(ctx *context.Context) {
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
upload.AddUploadContext(ctx, "comment") upload.AddUploadContext(ctx, "comment")
milestoneID := ctx.FormInt64("milestone") pageMetaData := retrieveRepoIssueMetaData(ctx, ctx.Repo.Repository, nil, false)
if milestoneID > 0 { if ctx.Written() {
milestone, err := issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, milestoneID) return
if err != nil {
log.Error("GetMilestoneByID: %d: %v", milestoneID, err)
} else {
ctx.Data["milestone_id"] = milestoneID
ctx.Data["Milestone"] = milestone
}
} }
projectID := ctx.FormInt64("project") pageMetaData.MilestonesData.SelectedMilestoneID = ctx.FormInt64("milestone")
if projectID > 0 && isProjectsEnabled { pageMetaData.ProjectsData.SelectedProjectID = ctx.FormInt64("project")
project, err := project_model.GetProjectByID(ctx, projectID) if pageMetaData.ProjectsData.SelectedProjectID > 0 {
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
}
if len(ctx.Req.URL.Query().Get("project")) > 0 { if len(ctx.Req.URL.Query().Get("project")) > 0 {
ctx.Data["redirect_after_creation"] = "project" 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) tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID)
if err != nil { if err != nil {
ctx.ServerError("GetTagNamesByRepoID", err) ctx.ServerError("GetTagNamesByRepoID", err)
@ -1101,7 +1145,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, labelsData) templateLoaded, errs := setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates, pageMetaData)
for k, v := range errs { for k, v := range errs {
ret.TemplateErrors[k] = v 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) ctx.Redirect(fmt.Sprintf("%s/issues", ctx.Repo.Repository.Link()), http.StatusSeeOther)
} }
// ValidateRepoMetas check and returns repository's meta information func toSet[ItemType any, KeyType comparable](slice []ItemType, keyFunc func(ItemType) KeyType) container.Set[KeyType] {
func ValidateRepoMetas(ctx *context.Context, form forms.CreateIssueForm, isPull bool) (ret struct { 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 LabelIDs, AssigneeIDs []int64
MilestoneID, ProjectID int64 MilestoneID, ProjectID int64
@ -1205,126 +1257,76 @@ func ValidateRepoMetas(ctx *context.Context, form forms.CreateIssueForm, isPull
TeamReviewers []*organization.Team TeamReviewers []*organization.Team
}, },
) { ) {
var ( pageMetaData := retrieveRepoIssueMetaData(ctx, ctx.Repo.Repository, nil, isPull)
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)
if ctx.Written() { if ctx.Written() {
return ret return ret
} }
var labelIDs []int64 inputLabelIDs, _ := base.StringsToInt64s(strings.Split(form.LabelIDs, ","))
// Check labels. candidateLabels := toSet(pageMetaData.LabelsData.AllLabels, func(label *issues_model.Label) int64 { return label.ID })
if len(form.LabelIDs) > 0 { if len(inputLabelIDs) > 0 && !candidateLabels.Contains(inputLabelIDs...) {
labelIDs, err = base.StringsToInt64s(strings.Split(form.LabelIDs, ",")) ctx.NotFound("", nil)
if err != nil { return ret
return ret
}
labelsData.SetSelectedLabelIDs(labelIDs)
}
// 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
} }
pageMetaData.LabelsData.SetSelectedLabelIDs(inputLabelIDs)
if form.ProjectID > 0 { allMilestones := append(slices.Clone(pageMetaData.MilestonesData.OpenMilestones), pageMetaData.MilestonesData.ClosedMilestones...)
p, err := project_model.GetProjectByID(ctx, form.ProjectID) candidateMilestones := toSet(allMilestones, func(milestone *issues_model.Milestone) int64 { return milestone.ID })
if err != nil { if form.MilestoneID > 0 && !candidateMilestones.Contains(form.MilestoneID) {
ctx.ServerError("GetProjectByID", err) ctx.NotFound("", nil)
return ret 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
} }
pageMetaData.MilestonesData.SelectedMilestoneID = form.MilestoneID
// Check assignees allProjects := append(slices.Clone(pageMetaData.ProjectsData.OpenProjects), pageMetaData.ProjectsData.ClosedProjects...)
var assigneeIDs []int64 candidateProjects := toSet(allProjects, func(project *project_model.Project) int64 { return project.ID })
if len(form.AssigneeIDs) > 0 { if form.ProjectID > 0 && !candidateProjects.Contains(form.ProjectID) {
assigneeIDs, err = base.StringsToInt64s(strings.Split(form.AssigneeIDs, ",")) ctx.NotFound("", nil)
if err != nil { return ret
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
}
}
} }
pageMetaData.ProjectsData.SelectedProjectID = form.ProjectID
// Keep the old assignee id thingy for compatibility reasons candidateAssignees := toSet(pageMetaData.AssigneesData.CandidateAssignees, func(user *user_model.User) int64 { return user.ID })
if form.AssigneeID > 0 { inputAssigneeIDs, _ := base.StringsToInt64s(strings.Split(form.AssigneeIDs, ","))
assigneeIDs = append(assigneeIDs, form.AssigneeID) if len(inputAssigneeIDs) > 0 && !candidateAssignees.Contains(inputAssigneeIDs...) {
ctx.NotFound("", nil)
return ret
} }
pageMetaData.AssigneesData.SelectedAssigneeIDs = form.AssigneeIDs
// Check reviewers // Check if the passed reviewers (user/team) actually exist
var reviewers []*user_model.User var reviewers []*user_model.User
var teamReviewers []*organization.Team var teamReviewers []*organization.Team
if isPull && len(form.ReviewerIDs) > 0 { reviewerIDs, _ := base.StringsToInt64s(strings.Split(form.ReviewerIDs, ","))
reviewerIDs, err := base.StringsToInt64s(strings.Split(form.ReviewerIDs, ",")) if isPull && len(reviewerIDs) > 0 {
if err != nil { userReviewersMap := map[int64]*user_model.User{}
return ret 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 { for _, rID := range reviewerIDs {
// negative reviewIDs represent team requests if rID < 0 { // negative reviewIDs represent team requests
if rID < 0 { team, ok := teamReviewersMap[-rID]
teamReviewer, err := organization.GetTeamByID(ctx, -rID) if !ok {
if err != nil { ctx.NotFound("", nil)
ctx.ServerError("GetTeamByID", err)
return ret return ret
} }
teamReviewers = append(teamReviewers, teamReviewer) teamReviewers = append(teamReviewers, team)
continue } else {
} user, ok := userReviewersMap[rID]
if !ok {
reviewer, err := user_model.GetUserByID(ctx, rID) ctx.NotFound("", nil)
if err != nil { return ret
ctx.ServerError("GetUserByID", err) }
return ret reviewers = append(reviewers, user)
} }
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 ret.Reviewers, ret.TeamReviewers = reviewers, teamReviewers
return ret return ret
} }
@ -1344,7 +1346,7 @@ func NewIssuePost(ctx *context.Context) {
attachments []string attachments []string
) )
validateRet := ValidateRepoMetas(ctx, *form, false) validateRet := ValidateRepoMetasForNewIssue(ctx, *form, false)
if ctx.Written() { if ctx.Written() {
return 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() { if ctx.Written() {
return return
} }
labelsData := retrieveRepoLabels(ctx, repo, issue.ID, issue.IsPull) pageMetaData.LabelsData.SetSelectedLabels(issue.Labels)
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
}
}
if ctx.IsSigned { if ctx.IsSigned {
// Update issue-user. // Update issue-user.

@ -1269,7 +1269,7 @@ func CompareAndPullRequestPost(ctx *context.Context) {
return return
} }
validateRet := ValidateRepoMetas(ctx, *form, true) validateRet := ValidateRepoMetasForNewIssue(ctx, *form, true)
if ctx.Written() { if ctx.Written() {
return return
} }

@ -451,7 +451,6 @@ type CreateIssueForm struct {
Ref string `form:"ref"` Ref string `form:"ref"`
MilestoneID int64 MilestoneID int64
ProjectID int64 ProjectID int64
AssigneeID int64
Content string Content string
Files []string Files []string
AllowMaintainerEdit bool AllowMaintainerEdit bool

@ -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}}

@ -49,142 +49,22 @@
<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" $.IssueSidebarReviewersData}} {{template "repo/issue/sidebar/reviewer_list" $.IssuePageMetaData}}
<div class="divider"></div> <div class="divider"></div>
{{end}} {{end}}
{{template "repo/issue/sidebar/label_list" $.IssueSidebarLabelsData}} {{template "repo/issue/sidebar/label_list" $.IssuePageMetaData}}
{{template "repo/issue/sidebar/milestone_list" $.IssuePageMetaData}}
<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>
{{if .IsProjectsEnabled}} {{if .IsProjectsEnabled}}
<div class="divider"></div> {{template "repo/issue/sidebar/project_list" $.IssuePageMetaData}}
<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>
{{end}} {{end}}
<div class="divider"></div> {{template "repo/issue/sidebar/assignee_list" $.IssuePageMetaData}}
<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>
{{if and .PageIsComparePull (not (eq .HeadRepo.FullName .BaseCompareRepo.FullName)) .CanWriteToHeadRepo}} {{if and .PageIsComparePull (not (eq .HeadRepo.FullName .BaseCompareRepo.FullName)) .CanWriteToHeadRepo}}
<div class="divider"></div> <div class="divider"></div>
<div class="inline field"> <div class="ui checkbox">
<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>
<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}}>
<input name="allow_maintainer_edit" type="checkbox" {{if .AllowMaintainerEdit}}checked{{end}}>
</div>
</div> </div>
{{end}} {{end}}
</div> </div>

@ -1,46 +1,35 @@
{{$pageMeta := .}}
{{$data := .AssigneesData}}
{{$issueAssignees := NIL}}{{if $pageMeta.Issue}}{{$issueAssignees = $pageMeta.Issue.Assignees}}{{end}}
<div class="divider"></div> <div class="divider"></div>
<input id="assignee_id" name="assignee_id" type="hidden" value="{{.assignee_id}}"> <div class="issue-sidebar-combo" data-selection-mode="multiple" data-update-algo="diff"
<div class="ui {{if or (not .HasIssuesOrPullsWritePermission) .Repository.IsArchived}}disabled{{end}} floating jump select-assignees-modify dropdown"> {{if $pageMeta.Issue}}data-update-url="{{$pageMeta.RepoLink}}/issues/assignee?issue_ids={{$pageMeta.Issue.ID}}"{{end}}
<a class="text muted flex-text-block"> >
<strong>{{ctx.Locale.Tr "repo.issues.new.assignees"}}</strong> <input class="combo-value" name="assignee_ids" type="hidden" value="{{$data.SelectedAssigneeIDs}}">
{{if and .HasIssuesOrPullsWritePermission (not .Repository.IsArchived)}} <div class="ui dropdown {{if not $pageMeta.CanModifyIssueOrPull}}disabled{{end}}">
{{svg "octicon-gear" 16 "tw-ml-1"}} <a class="text muted">
{{end}} <strong>{{ctx.Locale.Tr "repo.issues.new.assignees"}}</strong> {{if $pageMeta.CanModifyIssueOrPull}}{{svg "octicon-gear"}}{{end}}
</a> </a>
<div class="filter menu" data-action="update" data-issue-id="{{$.Issue.ID}}" data-update-url="{{$.RepoLink}}/issues/assignee"> <div class="menu">
<div class="ui icon search input"> <div class="ui icon search input">
<i class="icon">{{svg "octicon-search" 16}}</i> <i class="icon">{{svg "octicon-search" 16}}</i>
<input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_assignees"}}"> <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>
<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>
</a>
{{end}}
</div> </div>
</div> <div class="ui list tw-flex tw-flex-row tw-gap-2">
<div class="ui assignees list"> <span class="item empty-list {{if $issueAssignees}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_assignees"}}</span>
<span class="no-select item {{if .Issue.Assignees}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_assignees"}}</span> {{range $issueAssignees}}
<div class="selected"> <a class="item muted" href="{{$pageMeta.RepoLink}}/{{if $pageMeta.IsPullRequest}}pulls{{else}}issues{{end}}?assignee={{.ID}}">
{{range .Issue.Assignees}} {{ctx.AvatarUtils.Avatar . 20}} {{.GetDisplayName}}
<div class="item"> </a>
<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}} {{end}}
</div> </div>
</div> </div>

@ -1,10 +1,12 @@
{{$data := .}} {{$pageMeta := .}}
{{$canChange := and ctx.RootData.HasIssuesOrPullsWritePermission (not $data.Repository.IsArchived)}} {{$data := .LabelsData}}
<div class="issue-sidebar-combo" {{if $data.IssueID}}data-update-url="{{$data.RepoLink}}/issues/labels?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/labels?issue_ids={{$pageMeta.Issue.ID}}"{{end}}
>
<input class="combo-value" name="label_ids" type="hidden" value="{{$data.SelectedLabelIDs}}"> <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"> <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> </a>
<div class="menu"> <div class="menu">
{{if not $data.AllLabels}} {{if not $data.AllLabels}}
@ -16,7 +18,7 @@
</div> </div>
<a class="item clear-selection" href="#">{{ctx.Locale.Tr "repo.issues.new.clear_labels"}}</a> <a class="item clear-selection" href="#">{{ctx.Locale.Tr "repo.issues.new.clear_labels"}}</a>
{{$previousExclusiveScope := "_no_scope"}} {{$previousExclusiveScope := "_no_scope"}}
{{range .RepoLabels}} {{range $data.RepoLabels}}
{{$exclusiveScope := .ExclusiveScope}} {{$exclusiveScope := .ExclusiveScope}}
{{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}} {{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}}
<div class="divider"></div> <div class="divider"></div>
@ -26,7 +28,7 @@
{{end}} {{end}}
<div class="divider"></div> <div class="divider"></div>
{{$previousExclusiveScope = "_no_scope"}} {{$previousExclusiveScope = "_no_scope"}}
{{range .OrgLabels}} {{range $data.OrgLabels}}
{{$exclusiveScope := .ExclusiveScope}} {{$exclusiveScope := .ExclusiveScope}}
{{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}} {{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}}
<div class="divider"></div> <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> <span class="item empty-list {{if $data.SelectedLabelIDs}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_label"}}</span>
{{range $data.AllLabels}} {{range $data.AllLabels}}
{{if .IsChecked}} {{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 . -}} {{- ctx.RenderUtils.RenderLabel . -}}
</a> </a>
{{end}} {{end}}

@ -1,5 +1,5 @@
{{$label := .Label}} {{$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}} 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> <span class="item-check-mark">{{svg (Iif $label.ExclusiveScope "octicon-dot-fill" "octicon-check")}}</span>

@ -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="divider"></div>
<div class="ui {{if or (not .HasIssuesOrPullsWritePermission) .Repository.IsArchived}}disabled{{end}} floating jump select-milestone dropdown"> <div class="issue-sidebar-combo" data-selection-mode="single" data-update-algo="all"
<a class="text muted flex-text-block"> {{if $pageMeta.Issue}}data-update-url="{{$pageMeta.RepoLink}}/issues/milestone?issue_ids={{$pageMeta.Issue.ID}}"{{end}}
<strong>{{ctx.Locale.Tr "repo.issues.new.milestone"}}</strong> >
{{if and .HasIssuesOrPullsWritePermission (not .Repository.IsArchived)}} <input class="combo-value" name="milestone_id" type="hidden" value="{{$data.SelectedMilestoneID}}">
{{svg "octicon-gear" 16 "tw-ml-1"}} <div class="ui dropdown {{if not $pageMeta.CanModifyIssueOrPull}}disabled{{end}} ">
{{end}} <a class="text muted">
</a> <strong>{{ctx.Locale.Tr "repo.issues.new.milestone"}}</strong> {{if $pageMeta.CanModifyIssueOrPull}}{{svg "octicon-gear"}}{{end}}
<div class="menu" data-action="update" data-issue-id="{{$.Issue.ID}}" data-update-url="{{$.RepoLink}}/issues/milestone"> </a>
{{template "repo/issue/milestone/select_menu" .}} <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>
<div class="ui select-milestone list"> <div class="ui list">
<span class="no-select item {{if .Issue.Milestone}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_milestone"}}</span> <span class="item empty-list {{if $issueMilestone}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_milestone"}}</span>
<div class="selected"> {{if $issueMilestone}}
{{if .Issue.Milestone}} <a class="item muted" href="{{$pageMeta.RepoLink}}/milestone/{{$issueMilestone.ID}}">
<a class="item muted sidebar-item-link" href="{{.RepoLink}}/milestone/{{.Issue.Milestone.ID}}"> {{svg "octicon-milestone" 18}} {{$issueMilestone.Name}}
{{svg "octicon-milestone" 18 "tw-mr-2"}}
{{.Issue.Milestone.Name}}
</a> </a>
{{end}} {{end}}
</div> </div>

@ -4,7 +4,7 @@
<div class="ui list tw-flex tw-flex-wrap"> <div class="ui list tw-flex tw-flex-wrap">
{{range .Participants}} {{range .Participants}}
<a {{if gt .ID 0}}href="{{.HomeLink}}"{{end}} data-tooltip-content="{{.GetDisplayName}}"> <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> </a>
{{end}} {{end}}
</div> </div>

@ -1,53 +1,49 @@
{{if .IsProjectsEnabled}} {{$pageMeta := .}}
<div class="divider"></div> {{$data := .ProjectsData}}
{{$issueProject := NIL}}{{if and $pageMeta.Issue $pageMeta.Issue.Project}}{{$issueProject = $pageMeta.Issue.Project}}{{end}}
<div class="ui {{if or (not .HasIssuesOrPullsWritePermission) .Repository.IsArchived}}disabled{{end}} floating jump select-project dropdown"> <div class="divider"></div>
<a class="text muted flex-text-block"> <div class="issue-sidebar-combo" data-selection-mode="single" data-update-algo="all"
<strong>{{ctx.Locale.Tr "repo.issues.new.projects"}}</strong> {{if $pageMeta.Issue}}data-update-url="{{$pageMeta.RepoLink}}/issues/projects?issue_ids={{$pageMeta.Issue.ID}}"{{end}}
{{if and .HasIssuesOrPullsWritePermission (not .Repository.IsArchived)}} >
{{svg "octicon-gear" 16 "tw-ml-1"}} <input class="combo-value" name="project_id" type="hidden" value="{{$data.SelectedProjectID}}">
{{end}} <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> </a>
<div class="menu" data-action="update" data-issue-id="{{$.Issue.ID}}" data-update-url="{{$.RepoLink}}/issues/projects"> <div class="menu">
{{if or .OpenProjects .ClosedProjects}} {{if or $data.OpenProjects $data.ClosedProjects}}
<div class="ui icon search input"> <div class="ui icon search input">
<i class="icon">{{svg "octicon-search" 16}}</i> <i class="icon">{{svg "octicon-search" 16}}</i>
<input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_projects"}}"> <input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_projects"}}">
</div> </div>
{{end}} {{end}}
<div class="no-select item">{{ctx.Locale.Tr "repo.issues.new.clear_projects"}}</div> <div class="item clear-selection">{{ctx.Locale.Tr "repo.issues.new.clear_projects"}}</div>
{{if .OpenProjects}} {{if $data.OpenProjects}}
<div class="divider"></div> <div class="divider"></div>
<div class="header"> <div class="header">{{ctx.Locale.Tr "repo.issues.new.open_projects"}}</div>
{{ctx.Locale.Tr "repo.issues.new.open_projects"}} {{range $data.OpenProjects}}
</div> <a class="item muted" data-value="{{.ID}}" href="{{.Link ctx}}">
{{range .OpenProjects}} {{svg .IconName 18}} {{.Title}}
<a class="item muted sidebar-item-link" data-id="{{.ID}}" data-href="{{.Link ctx}}">
{{svg .IconName 18 "tw-mr-2"}}{{.Title}}
</a> </a>
{{end}} {{end}}
{{end}} {{end}}
{{if .ClosedProjects}} {{if $data.ClosedProjects}}
<div class="divider"></div> <div class="divider"></div>
<div class="header"> <div class="header">{{ctx.Locale.Tr "repo.issues.new.closed_projects"}}</div>
{{ctx.Locale.Tr "repo.issues.new.closed_projects"}} {{range $data.ClosedProjects}}
</div> <a class="item muted" data-value="{{.ID}}" href="{{.Link ctx}}">
{{range .ClosedProjects}} {{svg .IconName 18}} {{.Title}}
<a class="item muted sidebar-item-link" data-id="{{.ID}}" data-href="{{.Link ctx}}">
{{svg .IconName 18 "tw-mr-2"}}{{.Title}}
</a> </a>
{{end}} {{end}}
{{end}} {{end}}
</div> </div>
</div> </div>
<div class="ui select-project list"> <div class="ui list">
<span class="no-select item {{if .Issue.Project}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_projects"}}</span> <span class="item empty-list {{if $issueProject}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_projects"}}</span>
<div class="selected"> {{if $issueProject}}
{{if .Issue.Project}} <a class="item muted" href="{{$issueProject.Link ctx}}">
<a class="item muted sidebar-item-link" href="{{.Issue.Project.Link ctx}}"> {{svg $issueProject.IconName 18}} {{$issueProject.Title}}
{{svg .Issue.Project.IconName 18 "tw-mr-2"}}{{.Issue.Project.Title}} </a>
</a> {{end}}
{{end}}
</div>
</div> </div>
{{end}} </div>

@ -1,10 +1,14 @@
{{$data := .}} {{$pageMeta := .}}
{{$data := .ReviewersData}}
{{$repoOwnerName := $pageMeta.Repository.OwnerName}}
{{$hasCandidates := or $data.Reviewers $data.TeamReviewers}} {{$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 */}} <input type="hidden" class="combo-value" name="reviewer_ids">{{/* match CreateIssueForm */}}
<div class="ui dropdown {{if or (not $hasCandidates) (not $data.CanChooseReviewer)}}disabled{{end}}"> <div class="ui dropdown {{if or (not $hasCandidates) (not $data.CanChooseReviewer)}}disabled{{end}}">
<a class="text muted"> <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> </a>
<div class="menu flex-items-menu"> <div class="menu flex-items-menu">
{{if $hasCandidates}} {{if $hasCandidates}}
@ -29,7 +33,7 @@
<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}}>
<span class="item-check-mark">{{svg "octicon-check"}}</span> <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> </a>
{{end}} {{end}}
{{end}} {{end}}
@ -47,7 +51,7 @@
{{if .User}} {{if .User}}
<a class="muted flex-text-inline" href="{{.User.HomeLink}}">{{ctx.AvatarUtils.Avatar .User 20}} {{.User.GetDisplayName}}</a> <a class="muted flex-text-inline" href="{{.User.HomeLink}}">{{ctx.AvatarUtils.Avatar .User 20}} {{.User.GetDisplayName}}</a>
{{else if .Team}} {{else if .Team}}
{{svg "octicon-people" 20}} {{$data.RepoOwnerName}}/{{.Team.Name}} {{svg "octicon-people" 20}} {{$repoOwnerName}}/{{.Team.Name}}
{{end}} {{end}}
</div> </div>
<div class="flex-text-inline"> <div class="flex-text-inline">
@ -64,13 +68,13 @@
{{if .Requested}} {{if .Requested}}
<a href="#" class="ui muted icon link-action" <a href="#" class="ui muted icon link-action"
data-tooltip-content="{{ctx.Locale.Tr "repo.issues.remove_request_review"}}" 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"}} {{svg "octicon-trash"}}
</a> </a>
{{else}} {{else}}
<a href="#" class="ui muted icon link-action" <a href="#" class="ui muted icon link-action"
data-tooltip-content="{{ctx.Locale.Tr "repo.issues.re_request_review"}}" 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"}} {{svg "octicon-sync"}}
</a> </a>
{{end}} {{end}}
@ -84,8 +88,8 @@
{{range $data.OriginalReviews}} {{range $data.OriginalReviews}}
<div class="item"> <div class="item">
<div class="flex-text-inline tw-flex-1"> <div class="flex-text-inline tw-flex-1">
{{$originalURLHostname := $data.Repository.GetOriginalURLHostname}} {{$originalURLHostname := $pageMeta.Repository.GetOriginalURLHostname}}
{{$originalURL := $data.Repository.OriginalURL}} {{$originalURL := $pageMeta.Repository.OriginalURL}}
<a class="muted flex-text-inline" href="{{$originalURL}}" data-tooltip-content="{{ctx.Locale.Tr "repo.migrated_from_fake" $originalURLHostname}}"> <a class="muted flex-text-inline" href="{{$originalURL}}" data-tooltip-content="{{ctx.Locale.Tr "repo.migrated_from_fake" $originalURLHostname}}">
{{svg (MigrationIcon $originalURLHostname) 20}} {{.OriginalAuthor}} {{svg (MigrationIcon $originalURLHostname) 20}} {{.OriginalAuthor}}
</a> </a>
@ -108,7 +112,7 @@
<div class="ui warning message"> <div class="ui warning message">
{{ctx.Locale.Tr "repo.issues.dismiss_review_warning"}} {{ctx.Locale.Tr "repo.issues.dismiss_review_warning"}}
</div> </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}} {{ctx.RootData.CsrfTokenHtml}}
<input type="hidden" class="reviewer-id" name="review_id"> <input type="hidden" class="reviewer-id" name="review_id">
<div class="field"> <div class="field">

@ -2,16 +2,19 @@
{{template "repo/issue/branch_selector_field" $}} {{template "repo/issue/branch_selector_field" $}}
{{if .Issue.IsPull}} {{if .Issue.IsPull}}
{{template "repo/issue/sidebar/reviewer_list" $.IssueSidebarReviewersData}} {{template "repo/issue/sidebar/reviewer_list" $.IssuePageMetaData}}
{{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/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/participant_list" $}}
{{template "repo/issue/sidebar/watch_notification" $}} {{template "repo/issue/sidebar/watch_notification" $}}
{{template "repo/issue/sidebar/stopwatch_timetracker" $}} {{template "repo/issue/sidebar/stopwatch_timetracker" $}}

@ -2453,12 +2453,6 @@ tbody.commit-list {
margin-top: 1em; margin-top: 1em;
} }
.sidebar-item-link {
display: inline-flex;
align-items: center;
overflow-wrap: anywhere;
}
.diff-file-header { .diff-file-header {
padding: 5px 8px !important; padding: 5px 8px !important;
box-shadow: 0 -1px 0 1px var(--color-body); /* prevent borders being visible behind top corners when sticky and scrolled */ box-shadow: 0 -1px 0 1px var(--color-body); /* prevent borders being visible behind top corners when sticky and scrolled */

@ -3,7 +3,7 @@ import {POST} from '../modules/fetch.ts';
import {queryElemChildren, queryElems, 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() { function issueSidebarReloadConfirmDraftComment() {
const commentTextareas = [ const commentTextareas = [
document.querySelector<HTMLTextAreaElement>('.edit-content-zone:not(.tw-hidden) textarea'), document.querySelector<HTMLTextAreaElement>('.edit-content-zone:not(.tw-hidden) textarea'),
document.querySelector<HTMLTextAreaElement>('#comment-form textarea'), document.querySelector<HTMLTextAreaElement>('#comment-form textarea'),
@ -22,84 +22,138 @@ export function issueSidebarReloadConfirmDraftComment() {
window.location.reload(); window.location.reload();
} }
function collectCheckedValues(elDropdown: HTMLElement) { class IssueSidebarComboList {
return Array.from(elDropdown.querySelectorAll('.menu > .item.checked'), (el) => el.getAttribute('data-value')); updateUrl: string;
} updateAlgo: string;
selectionMode: string;
elDropdown: HTMLElement;
elList: HTMLElement;
elComboValue: HTMLInputElement;
initialValues: string[];
export function initIssueSidebarComboList(container: HTMLElement) { constructor(private container: HTMLElement) {
const updateUrl = container.getAttribute('data-update-url'); this.updateUrl = this.container.getAttribute('data-update-url');
const elDropdown = container.querySelector<HTMLElement>(':scope > .ui.dropdown'); this.updateAlgo = container.getAttribute('data-update-algo');
const elList = container.querySelector<HTMLElement>(':scope > .ui.list'); this.selectionMode = container.getAttribute('data-selection-mode');
const elComboValue = container.querySelector<HTMLInputElement>(':scope > .combo-value'); if (!['single', 'multiple'].includes(this.selectionMode)) throw new Error(`Invalid data-update-on: ${this.selectionMode}`);
let initialValues = collectCheckedValues(elDropdown); 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');
}
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');
}
}
elDropdown.addEventListener('click', (e) => { async onItemClick(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.hasAttribute('data-can-change') && elItem.getAttribute('data-can-change') !== 'true') return; if (elItem.hasAttribute('data-can-change') && elItem.getAttribute('data-can-change') !== 'true') return;
if (elItem.matches('.clear-selection')) { if (elItem.matches('.clear-selection')) {
queryElems(elDropdown, '.menu > .item', (el) => el.classList.remove('checked')); queryElems(this.elDropdown, '.menu > .item', (el) => el.classList.remove('checked'));
elComboValue.value = ''; this.elComboValue.value = '';
this.onChange();
return; return;
} }
const scope = elItem.getAttribute('data-scope'); const scope = elItem.getAttribute('data-scope');
if (scope) { if (scope) {
// scoped items could only be checked one at a time // 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) { if (elSelected === elItem) {
elItem.classList.toggle('checked'); elItem.classList.toggle('checked');
} else { } 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); elItem.classList.toggle('checked', true);
} }
} else { } else {
elItem.classList.toggle('checked'); if (this.selectionMode === 'multiple') {
} elItem.classList.toggle('checked');
elComboValue.value = collectCheckedValues(elDropdown).join(','); } else {
}); queryElems(this.elDropdown, `.menu > .item.checked`, (el) => el.classList.remove('checked'));
elItem.classList.toggle('checked', true);
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;
} }
} }
for (const value of changedValues) { this.elComboValue.value = this.collectCheckedValues().join(',');
if (!initialValues.includes(value)) { this.onChange();
await POST(updateUrl, {data: new URLSearchParams({action: 'attach', id: value})}); }
changed = true;
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) => { this.elDropdown.addEventListener('click', (e) => this.onItemClick(e));
const elEmptyTip = elList.querySelector('.item.empty-list');
queryElemChildren(elList, '.item:not(.empty-list)', (el) => el.remove()); fomanticQuery(this.elDropdown).dropdown('setting', {
for (const value of changedValues) { action: 'nothing', // do not hide the menu if user presses Enter
const el = elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${CSS.escape(value)}"]`); fullTextSearch: 'exact',
const listItem = el.cloneNode(true) as HTMLElement; onHide: () => this.onHide(),
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); export function initIssueSidebarComboList(container: HTMLElement) {
}; new IssueSidebarComboList(container).init();
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;
},
});
} }

@ -1,7 +1,7 @@
A sidebar combo (dropdown+list) is like this: A sidebar combo (dropdown+list) is like this:
```html ```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="..."> <input class="combo-value" name="..." type="hidden" value="...">
<div class="ui dropdown"> <div class="ui dropdown">
<div class="menu"> <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. 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 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.

@ -1,10 +1,7 @@
import $ from 'jquery'; import $ from 'jquery';
import {POST} from '../modules/fetch.ts'; 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 {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() { function initBranchSelector() {
const elSelectBranch = document.querySelector('.ui.dropdown.select-branch'); 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() { function initRepoIssueDue() {
const form = document.querySelector<HTMLFormElement>('.issue-due-form'); const form = document.querySelector<HTMLFormElement>('.issue-due-form');
if (!form) return; if (!form) return;
@ -257,14 +48,6 @@ export function initRepoIssueSidebar() {
initBranchSelector(); initBranchSelector();
initRepoIssueDue(); 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 // 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)); queryElems<HTMLElement>(document, '.issue-sidebar-combo', (el) => initIssueSidebarComboList(el));
} }

Loading…
Cancel
Save