mirror of
https://github.com/go-gitea/gitea
synced 2024-11-21 23:11:05 +01:00
Merge branch 'main' into xormigrate
This commit is contained in:
commit
0ebc2257e5
43 changed files with 774 additions and 506 deletions
|
@ -86,7 +86,9 @@ type CreatePullRequestOption struct {
|
|||
Milestone int64 `json:"milestone"`
|
||||
Labels []int64 `json:"labels"`
|
||||
// swagger:strfmt date-time
|
||||
Deadline *time.Time `json:"due_date"`
|
||||
Deadline *time.Time `json:"due_date"`
|
||||
Reviewers []string `json:"reviewers"`
|
||||
TeamReviewers []string `json:"team_reviewers"`
|
||||
}
|
||||
|
||||
// EditPullRequestOption options when modify pull request
|
||||
|
|
|
@ -1462,7 +1462,7 @@ issues.new.closed_milestone = Closed Milestones
|
|||
issues.new.assignees = Assignees
|
||||
issues.new.clear_assignees = Clear assignees
|
||||
issues.new.no_assignees = No Assignees
|
||||
issues.new.no_reviewers = No reviewers
|
||||
issues.new.no_reviewers = No Reviewers
|
||||
issues.new.blocked_user = Cannot create issue because you are blocked by the repository owner.
|
||||
issues.edit.already_changed = Unable to save changes to the issue. It appears the content has already been changed by another user. Please refresh the page and try editing again to avoid overwriting their changes
|
||||
issues.edit.blocked_user = Cannot edit content because you are blocked by the poster or repository owner.
|
||||
|
|
|
@ -554,7 +554,19 @@ func CreatePullRequest(ctx *context.APIContext) {
|
|||
}
|
||||
}
|
||||
|
||||
if err := pull_service.NewPullRequest(ctx, repo, prIssue, labelIDs, []string{}, pr, assigneeIDs); err != nil {
|
||||
prOpts := &pull_service.NewPullRequestOptions{
|
||||
Repo: repo,
|
||||
Issue: prIssue,
|
||||
LabelIDs: labelIDs,
|
||||
PullRequest: pr,
|
||||
AssigneeIDs: assigneeIDs,
|
||||
}
|
||||
prOpts.Reviewers, prOpts.TeamReviewers = parseReviewersByNames(ctx, form.Reviewers, form.TeamReviewers)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
if err := pull_service.NewPullRequest(ctx, prOpts); err != nil {
|
||||
if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
|
||||
ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err)
|
||||
} else if errors.Is(err, user_model.ErrBlockedUser) {
|
||||
|
|
|
@ -656,6 +656,47 @@ func DeleteReviewRequests(ctx *context.APIContext) {
|
|||
apiReviewRequest(ctx, *opts, false)
|
||||
}
|
||||
|
||||
func parseReviewersByNames(ctx *context.APIContext, reviewerNames, teamReviewerNames []string) (reviewers []*user_model.User, teamReviewers []*organization.Team) {
|
||||
var err error
|
||||
for _, r := range reviewerNames {
|
||||
var reviewer *user_model.User
|
||||
if strings.Contains(r, "@") {
|
||||
reviewer, err = user_model.GetUserByEmail(ctx, r)
|
||||
} else {
|
||||
reviewer, err = user_model.GetUserByName(ctx, r)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
ctx.NotFound("UserNotExist", fmt.Sprintf("User '%s' not exist", r))
|
||||
return nil, nil
|
||||
}
|
||||
ctx.Error(http.StatusInternalServerError, "GetUser", err)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
reviewers = append(reviewers, reviewer)
|
||||
}
|
||||
|
||||
if ctx.Repo.Repository.Owner.IsOrganization() && len(teamReviewerNames) > 0 {
|
||||
for _, t := range teamReviewerNames {
|
||||
var teamReviewer *organization.Team
|
||||
teamReviewer, err = organization.GetTeam(ctx, ctx.Repo.Owner.ID, t)
|
||||
if err != nil {
|
||||
if organization.IsErrTeamNotExist(err) {
|
||||
ctx.NotFound("TeamNotExist", fmt.Sprintf("Team '%s' not exist", t))
|
||||
return nil, nil
|
||||
}
|
||||
ctx.Error(http.StatusInternalServerError, "ReviewRequest", err)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
teamReviewers = append(teamReviewers, teamReviewer)
|
||||
}
|
||||
}
|
||||
return reviewers, teamReviewers
|
||||
}
|
||||
|
||||
func apiReviewRequest(ctx *context.APIContext, opts api.PullReviewRequestOptions, isAdd bool) {
|
||||
pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64(":index"))
|
||||
if err != nil {
|
||||
|
@ -672,42 +713,15 @@ func apiReviewRequest(ctx *context.APIContext, opts api.PullReviewRequestOptions
|
|||
return
|
||||
}
|
||||
|
||||
reviewers := make([]*user_model.User, 0, len(opts.Reviewers))
|
||||
|
||||
permDoer, err := access_model.GetUserRepoPermission(ctx, pr.Issue.Repo, ctx.Doer)
|
||||
if err != nil {
|
||||
ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, r := range opts.Reviewers {
|
||||
var reviewer *user_model.User
|
||||
if strings.Contains(r, "@") {
|
||||
reviewer, err = user_model.GetUserByEmail(ctx, r)
|
||||
} else {
|
||||
reviewer, err = user_model.GetUserByName(ctx, r)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
ctx.NotFound("UserNotExist", fmt.Sprintf("User '%s' not exist", r))
|
||||
return
|
||||
}
|
||||
ctx.Error(http.StatusInternalServerError, "GetUser", err)
|
||||
return
|
||||
}
|
||||
|
||||
err = issue_service.IsValidReviewRequest(ctx, reviewer, ctx.Doer, isAdd, pr.Issue, &permDoer)
|
||||
if err != nil {
|
||||
if issues_model.IsErrNotValidReviewRequest(err) {
|
||||
ctx.Error(http.StatusUnprocessableEntity, "NotValidReviewRequest", err)
|
||||
return
|
||||
}
|
||||
ctx.Error(http.StatusInternalServerError, "IsValidReviewRequest", err)
|
||||
return
|
||||
}
|
||||
|
||||
reviewers = append(reviewers, reviewer)
|
||||
reviewers, teamReviewers := parseReviewersByNames(ctx, opts.Reviewers, opts.TeamReviewers)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
var reviews []*issues_model.Review
|
||||
|
@ -716,12 +730,16 @@ func apiReviewRequest(ctx *context.APIContext, opts api.PullReviewRequestOptions
|
|||
}
|
||||
|
||||
for _, reviewer := range reviewers {
|
||||
comment, err := issue_service.ReviewRequest(ctx, pr.Issue, ctx.Doer, reviewer, isAdd)
|
||||
comment, err := issue_service.ReviewRequest(ctx, pr.Issue, ctx.Doer, &permDoer, reviewer, isAdd)
|
||||
if err != nil {
|
||||
if issues_model.IsErrReviewRequestOnClosedPR(err) {
|
||||
ctx.Error(http.StatusForbidden, "", err)
|
||||
return
|
||||
}
|
||||
if issues_model.IsErrNotValidReviewRequest(err) {
|
||||
ctx.Error(http.StatusUnprocessableEntity, "", err)
|
||||
return
|
||||
}
|
||||
ctx.Error(http.StatusInternalServerError, "ReviewRequest", err)
|
||||
return
|
||||
}
|
||||
|
@ -736,35 +754,17 @@ func apiReviewRequest(ctx *context.APIContext, opts api.PullReviewRequestOptions
|
|||
}
|
||||
|
||||
if ctx.Repo.Repository.Owner.IsOrganization() && len(opts.TeamReviewers) > 0 {
|
||||
teamReviewers := make([]*organization.Team, 0, len(opts.TeamReviewers))
|
||||
for _, t := range opts.TeamReviewers {
|
||||
var teamReviewer *organization.Team
|
||||
teamReviewer, err = organization.GetTeam(ctx, ctx.Repo.Owner.ID, t)
|
||||
if err != nil {
|
||||
if organization.IsErrTeamNotExist(err) {
|
||||
ctx.NotFound("TeamNotExist", fmt.Sprintf("Team '%s' not exist", t))
|
||||
return
|
||||
}
|
||||
ctx.Error(http.StatusInternalServerError, "ReviewRequest", err)
|
||||
return
|
||||
}
|
||||
|
||||
err = issue_service.IsValidTeamReviewRequest(ctx, teamReviewer, ctx.Doer, isAdd, pr.Issue)
|
||||
if err != nil {
|
||||
if issues_model.IsErrNotValidReviewRequest(err) {
|
||||
ctx.Error(http.StatusUnprocessableEntity, "NotValidReviewRequest", err)
|
||||
return
|
||||
}
|
||||
ctx.Error(http.StatusInternalServerError, "IsValidTeamReviewRequest", err)
|
||||
return
|
||||
}
|
||||
|
||||
teamReviewers = append(teamReviewers, teamReviewer)
|
||||
}
|
||||
|
||||
for _, teamReviewer := range teamReviewers {
|
||||
comment, err := issue_service.TeamReviewRequest(ctx, pr.Issue, ctx.Doer, teamReviewer, isAdd)
|
||||
if err != nil {
|
||||
if issues_model.IsErrReviewRequestOnClosedPR(err) {
|
||||
ctx.Error(http.StatusForbidden, "", err)
|
||||
return
|
||||
}
|
||||
if issues_model.IsErrNotValidReviewRequest(err) {
|
||||
ctx.Error(http.StatusUnprocessableEntity, "", err)
|
||||
return
|
||||
}
|
||||
ctx.ServerError("TeamReviewRequest", err)
|
||||
return
|
||||
}
|
||||
|
|
|
@ -788,10 +788,22 @@ func CompareDiff(ctx *context.Context) {
|
|||
|
||||
if !nothingToCompare {
|
||||
// Setup information for new form.
|
||||
RetrieveRepoMetas(ctx, ctx.Repo.Repository, true)
|
||||
retrieveRepoMetasForIssueWriter(ctx, ctx.Repo.Repository, true)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
labelsData := retrieveRepoLabels(ctx, ctx.Repo.Repository, 0, true)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
RetrieveRepoReviewers(ctx, ctx.Repo.Repository, nil, true)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
_, templateErrs := setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates, labelsData)
|
||||
if len(templateErrs) > 0 {
|
||||
ctx.Flash.Warning(renderErrorOfTemplates(ctx, templateErrs), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
beforeCommitID := ctx.Data["BeforeCommitID"].(string)
|
||||
|
@ -804,11 +816,6 @@ func CompareDiff(ctx *context.Context) {
|
|||
ctx.Data["Title"] = "Comparing " + base.ShortSha(beforeCommitID) + separator + base.ShortSha(afterCommitID)
|
||||
|
||||
ctx.Data["IsDiffCompare"] = true
|
||||
_, templateErrs := setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates)
|
||||
|
||||
if len(templateErrs) > 0 {
|
||||
ctx.Flash.Warning(renderErrorOfTemplates(ctx, templateErrs), true)
|
||||
}
|
||||
|
||||
if content, ok := ctx.Data["content"].(string); ok && content != "" {
|
||||
// If a template content is set, prepend the "content". In this case that's only
|
||||
|
|
|
@ -654,34 +654,66 @@ func retrieveProjects(ctx *context.Context, repo *repo_model.Repository) {
|
|||
|
||||
// repoReviewerSelection items to bee shown
|
||||
type repoReviewerSelection struct {
|
||||
IsTeam bool
|
||||
Team *organization.Team
|
||||
User *user_model.User
|
||||
Review *issues_model.Review
|
||||
CanChange bool
|
||||
Checked bool
|
||||
ItemID int64
|
||||
IsTeam bool
|
||||
Team *organization.Team
|
||||
User *user_model.User
|
||||
Review *issues_model.Review
|
||||
CanBeDismissed bool
|
||||
CanChange bool
|
||||
Requested bool
|
||||
ItemID int64
|
||||
}
|
||||
|
||||
// RetrieveRepoReviewers find all reviewers of a repository
|
||||
type issueSidebarReviewersData struct {
|
||||
Repository *repo_model.Repository
|
||||
RepoOwnerName string
|
||||
RepoLink string
|
||||
IssueID int64
|
||||
CanChooseReviewer bool
|
||||
OriginalReviews issues_model.ReviewList
|
||||
TeamReviewers []*repoReviewerSelection
|
||||
Reviewers []*repoReviewerSelection
|
||||
CurrentPullReviewers []*repoReviewerSelection
|
||||
}
|
||||
|
||||
// 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) {
|
||||
ctx.Data["CanChooseReviewer"] = canChooseReviewer
|
||||
data := &issueSidebarReviewersData{}
|
||||
data.RepoLink = ctx.Repo.RepoLink
|
||||
data.Repository = repo
|
||||
data.RepoOwnerName = repo.OwnerName
|
||||
data.CanChooseReviewer = canChooseReviewer
|
||||
|
||||
originalAuthorReviews, err := issues_model.GetReviewersFromOriginalAuthorsByIssueID(ctx, issue.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetReviewersFromOriginalAuthorsByIssueID", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["OriginalReviews"] = originalAuthorReviews
|
||||
var posterID int64
|
||||
var isClosed bool
|
||||
var reviews issues_model.ReviewList
|
||||
|
||||
reviews, err := issues_model.GetReviewsByIssueID(ctx, issue.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetReviewersByIssueID", err)
|
||||
return
|
||||
}
|
||||
if issue == nil {
|
||||
posterID = ctx.Doer.ID
|
||||
} else {
|
||||
posterID = issue.PosterID
|
||||
if issue.OriginalAuthorID > 0 {
|
||||
posterID = 0 // for migrated PRs, no poster ID
|
||||
}
|
||||
|
||||
if len(reviews) == 0 && !canChooseReviewer {
|
||||
return
|
||||
data.IssueID = issue.ID
|
||||
isClosed = issue.IsClosed || issue.PullRequest.HasMerged
|
||||
|
||||
originalAuthorReviews, err := issues_model.GetReviewersFromOriginalAuthorsByIssueID(ctx, issue.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetReviewersFromOriginalAuthorsByIssueID", err)
|
||||
return
|
||||
}
|
||||
data.OriginalReviews = originalAuthorReviews
|
||||
|
||||
reviews, err = issues_model.GetReviewsByIssueID(ctx, issue.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetReviewersByIssueID", err)
|
||||
return
|
||||
}
|
||||
if len(reviews) == 0 && !canChooseReviewer {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
|
@ -693,11 +725,7 @@ func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, is
|
|||
)
|
||||
|
||||
if canChooseReviewer {
|
||||
posterID := issue.PosterID
|
||||
if issue.OriginalAuthorID > 0 {
|
||||
posterID = 0
|
||||
}
|
||||
|
||||
var err error
|
||||
reviewers, err = repo_model.GetReviewers(ctx, repo, ctx.Doer.ID, posterID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetReviewers", err)
|
||||
|
@ -723,9 +751,9 @@ func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, is
|
|||
|
||||
for _, review := range reviews {
|
||||
tmp := &repoReviewerSelection{
|
||||
Checked: review.Type == issues_model.ReviewTypeRequest,
|
||||
Review: review,
|
||||
ItemID: review.ReviewerID,
|
||||
Requested: review.Type == issues_model.ReviewTypeRequest,
|
||||
Review: review,
|
||||
ItemID: review.ReviewerID,
|
||||
}
|
||||
if review.ReviewerTeamID > 0 {
|
||||
tmp.IsTeam = true
|
||||
|
@ -756,7 +784,7 @@ func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, is
|
|||
currentPullReviewers := make([]*repoReviewerSelection, 0, len(pullReviews))
|
||||
for _, item := range pullReviews {
|
||||
if item.Review.ReviewerID > 0 {
|
||||
if err = item.Review.LoadReviewer(ctx); err != nil {
|
||||
if err := item.Review.LoadReviewer(ctx); err != nil {
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
continue
|
||||
}
|
||||
|
@ -765,7 +793,7 @@ func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, is
|
|||
}
|
||||
item.User = item.Review.Reviewer
|
||||
} else if item.Review.ReviewerTeamID > 0 {
|
||||
if err = item.Review.LoadReviewerTeam(ctx); err != nil {
|
||||
if err := item.Review.LoadReviewerTeam(ctx); err != nil {
|
||||
if organization.IsErrTeamNotExist(err) {
|
||||
continue
|
||||
}
|
||||
|
@ -776,10 +804,11 @@ func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, is
|
|||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
item.CanBeDismissed = ctx.Repo.Permission.IsAdmin() && !isClosed &&
|
||||
(item.Review.Type == issues_model.ReviewTypeApprove || item.Review.Type == issues_model.ReviewTypeReject)
|
||||
currentPullReviewers = append(currentPullReviewers, item)
|
||||
}
|
||||
ctx.Data["PullReviewers"] = currentPullReviewers
|
||||
data.CurrentPullReviewers = currentPullReviewers
|
||||
}
|
||||
|
||||
if canChooseReviewer && reviewersResult != nil {
|
||||
|
@ -807,7 +836,7 @@ func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, is
|
|||
})
|
||||
}
|
||||
|
||||
ctx.Data["Reviewers"] = reviewersResult
|
||||
data.Reviewers = reviewersResult
|
||||
}
|
||||
|
||||
if canChooseReviewer && teamReviewersResult != nil {
|
||||
|
@ -835,55 +864,118 @@ func RetrieveRepoReviewers(ctx *context.Context, repo *repo_model.Repository, is
|
|||
})
|
||||
}
|
||||
|
||||
ctx.Data["TeamReviewers"] = teamReviewersResult
|
||||
data.TeamReviewers = teamReviewersResult
|
||||
}
|
||||
|
||||
ctx.Data["IssueSidebarReviewersData"] = data
|
||||
}
|
||||
|
||||
// RetrieveRepoMetas find all the meta information of a repository
|
||||
func RetrieveRepoMetas(ctx *context.Context, repo *repo_model.Repository, isPull bool) []*issues_model.Label {
|
||||
if !ctx.Repo.CanWriteIssuesOrPulls(isPull) {
|
||||
return nil
|
||||
type issueSidebarLabelsData struct {
|
||||
Repository *repo_model.Repository
|
||||
RepoLink string
|
||||
IssueID int64
|
||||
IsPullRequest bool
|
||||
AllLabels []*issues_model.Label
|
||||
RepoLabels []*issues_model.Label
|
||||
OrgLabels []*issues_model.Label
|
||||
SelectedLabelIDs string
|
||||
}
|
||||
|
||||
func makeSelectedStringIDs[KeyType, ItemType comparable](
|
||||
allLabels []*issues_model.Label, candidateKey func(candidate *issues_model.Label) KeyType,
|
||||
selectedItems []ItemType, selectedKey func(selected ItemType) KeyType,
|
||||
) string {
|
||||
selectedIDSet := make(container.Set[string])
|
||||
allLabelMap := map[KeyType]*issues_model.Label{}
|
||||
for _, label := range allLabels {
|
||||
allLabelMap[candidateKey(label)] = label
|
||||
}
|
||||
for _, item := range selectedItems {
|
||||
if label, ok := allLabelMap[selectedKey(item)]; ok {
|
||||
label.IsChecked = true
|
||||
selectedIDSet.Add(strconv.FormatInt(label.ID, 10))
|
||||
}
|
||||
}
|
||||
ids := selectedIDSet.Values()
|
||||
sort.Strings(ids)
|
||||
return strings.Join(ids, ",")
|
||||
}
|
||||
|
||||
func (d *issueSidebarLabelsData) SetSelectedLabels(labels []*issues_model.Label) {
|
||||
d.SelectedLabelIDs = makeSelectedStringIDs(
|
||||
d.AllLabels, func(label *issues_model.Label) int64 { return label.ID },
|
||||
labels, func(label *issues_model.Label) int64 { return label.ID },
|
||||
)
|
||||
}
|
||||
|
||||
func (d *issueSidebarLabelsData) SetSelectedLabelNames(labelNames []string) {
|
||||
d.SelectedLabelIDs = makeSelectedStringIDs(
|
||||
d.AllLabels, func(label *issues_model.Label) string { return strings.ToLower(label.Name) },
|
||||
labelNames, strings.ToLower,
|
||||
)
|
||||
}
|
||||
|
||||
func (d *issueSidebarLabelsData) SetSelectedLabelIDs(labelIDs []int64) {
|
||||
d.SelectedLabelIDs = makeSelectedStringIDs(
|
||||
d.AllLabels, func(label *issues_model.Label) int64 { return label.ID },
|
||||
labelIDs, func(labelID int64) int64 { return labelID },
|
||||
)
|
||||
}
|
||||
|
||||
func retrieveRepoLabels(ctx *context.Context, repo *repo_model.Repository, issueID int64, isPull bool) *issueSidebarLabelsData {
|
||||
labelsData := &issueSidebarLabelsData{
|
||||
Repository: repo,
|
||||
RepoLink: ctx.Repo.RepoLink,
|
||||
IssueID: issueID,
|
||||
IsPullRequest: isPull,
|
||||
}
|
||||
ctx.Data["IssueSidebarLabelsData"] = labelsData
|
||||
|
||||
labels, err := issues_model.GetLabelsByRepoID(ctx, repo.ID, "", db.ListOptions{})
|
||||
if err != nil {
|
||||
ctx.ServerError("GetLabelsByRepoID", err)
|
||||
return nil
|
||||
}
|
||||
ctx.Data["Labels"] = labels
|
||||
labelsData.RepoLabels = labels
|
||||
|
||||
if repo.Owner.IsOrganization() {
|
||||
orgLabels, err := issues_model.GetLabelsByOrgID(ctx, repo.Owner.ID, ctx.FormString("sort"), db.ListOptions{})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
labelsData.OrgLabels = orgLabels
|
||||
}
|
||||
labelsData.AllLabels = append(labelsData.AllLabels, labelsData.RepoLabels...)
|
||||
labelsData.AllLabels = append(labelsData.AllLabels, labelsData.OrgLabels...)
|
||||
return labelsData
|
||||
}
|
||||
|
||||
ctx.Data["OrgLabels"] = orgLabels
|
||||
labels = append(labels, orgLabels...)
|
||||
// retrieveRepoMetasForIssueWriter finds some the meta information of a repository for an issue/pr writer
|
||||
func retrieveRepoMetasForIssueWriter(ctx *context.Context, repo *repo_model.Repository, isPull bool) {
|
||||
if !ctx.Repo.CanWriteIssuesOrPulls(isPull) {
|
||||
return
|
||||
}
|
||||
|
||||
RetrieveRepoMilestonesAndAssignees(ctx, repo)
|
||||
if ctx.Written() {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
retrieveProjects(ctx, repo)
|
||||
if ctx.Written() {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
PrepareBranchList(ctx)
|
||||
if ctx.Written() {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
// Contains true if the user can create issue dependencies
|
||||
ctx.Data["CanCreateIssueDependencies"] = ctx.Repo.CanCreateIssueDependencies(ctx, ctx.Doer, isPull)
|
||||
|
||||
return labels
|
||||
}
|
||||
|
||||
// Tries to load and set an issue template. The first return value indicates if a template was loaded.
|
||||
func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles []string) (bool, map[string]error) {
|
||||
func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles []string, labelsData *issueSidebarLabelsData) (bool, map[string]error) {
|
||||
commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
|
@ -920,26 +1012,9 @@ func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles
|
|||
ctx.Data["Fields"] = template.Fields
|
||||
ctx.Data["TemplateFile"] = template.FileName
|
||||
}
|
||||
labelIDs := make([]string, 0, len(template.Labels))
|
||||
if repoLabels, err := issues_model.GetLabelsByRepoID(ctx, ctx.Repo.Repository.ID, "", db.ListOptions{}); err == nil {
|
||||
ctx.Data["Labels"] = repoLabels
|
||||
if ctx.Repo.Owner.IsOrganization() {
|
||||
if orgLabels, err := issues_model.GetLabelsByOrgID(ctx, ctx.Repo.Owner.ID, ctx.FormString("sort"), db.ListOptions{}); err == nil {
|
||||
ctx.Data["OrgLabels"] = orgLabels
|
||||
repoLabels = append(repoLabels, orgLabels...)
|
||||
}
|
||||
}
|
||||
|
||||
for _, metaLabel := range template.Labels {
|
||||
for _, repoLabel := range repoLabels {
|
||||
if strings.EqualFold(repoLabel.Name, metaLabel) {
|
||||
repoLabel.IsChecked = true
|
||||
labelIDs = append(labelIDs, strconv.FormatInt(repoLabel.ID, 10))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
labelsData.SetSelectedLabelNames(template.Labels)
|
||||
|
||||
selectedAssigneeIDs := make([]int64, 0, len(template.Assignees))
|
||||
selectedAssigneeIDStrings := make([]string, 0, len(template.Assignees))
|
||||
if userIDs, err := user_model.GetUserIDsByNames(ctx, template.Assignees, false); err == nil {
|
||||
|
@ -952,8 +1027,7 @@ func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles
|
|||
if template.Ref != "" && !strings.HasPrefix(template.Ref, "refs/") { // Assume that the ref intended is always a branch - for tags users should use refs/tags/<ref>
|
||||
template.Ref = git.BranchPrefix + template.Ref
|
||||
}
|
||||
ctx.Data["HasSelectedLabel"] = len(labelIDs) > 0
|
||||
ctx.Data["label_ids"] = strings.Join(labelIDs, ",")
|
||||
|
||||
ctx.Data["HasSelectedAssignee"] = len(selectedAssigneeIDs) > 0
|
||||
ctx.Data["assignee_ids"] = strings.Join(selectedAssigneeIDStrings, ",")
|
||||
ctx.Data["SelectedAssigneeIDs"] = selectedAssigneeIDs
|
||||
|
@ -1011,8 +1085,14 @@ func NewIssue(ctx *context.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
RetrieveRepoMetas(ctx, ctx.Repo.Repository, false)
|
||||
|
||||
retrieveRepoMetasForIssueWriter(ctx, ctx.Repo.Repository, false)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
labelsData := retrieveRepoLabels(ctx, ctx.Repo.Repository, 0, false)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetTagNamesByRepoID", err)
|
||||
|
@ -1021,7 +1101,7 @@ func NewIssue(ctx *context.Context) {
|
|||
ctx.Data["Tags"] = tags
|
||||
|
||||
ret := issue_service.ParseTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
|
||||
templateLoaded, errs := setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates)
|
||||
templateLoaded, errs := setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates, labelsData)
|
||||
for k, v := range errs {
|
||||
ret.TemplateErrors[k] = v
|
||||
}
|
||||
|
@ -1117,51 +1197,49 @@ func DeleteIssue(ctx *context.Context) {
|
|||
}
|
||||
|
||||
// ValidateRepoMetas check and returns repository's meta information
|
||||
func ValidateRepoMetas(ctx *context.Context, form forms.CreateIssueForm, isPull bool) ([]int64, []int64, int64, int64) {
|
||||
func ValidateRepoMetas(ctx *context.Context, form forms.CreateIssueForm, isPull bool) (ret struct {
|
||||
LabelIDs, AssigneeIDs []int64
|
||||
MilestoneID, ProjectID int64
|
||||
|
||||
Reviewers []*user_model.User
|
||||
TeamReviewers []*organization.Team
|
||||
},
|
||||
) {
|
||||
var (
|
||||
repo = ctx.Repo.Repository
|
||||
err error
|
||||
)
|
||||
|
||||
labels := RetrieveRepoMetas(ctx, ctx.Repo.Repository, isPull)
|
||||
retrieveRepoMetasForIssueWriter(ctx, ctx.Repo.Repository, isPull)
|
||||
if ctx.Written() {
|
||||
return nil, nil, 0, 0
|
||||
return ret
|
||||
}
|
||||
labelsData := retrieveRepoLabels(ctx, ctx.Repo.Repository, 0, isPull)
|
||||
if ctx.Written() {
|
||||
return ret
|
||||
}
|
||||
|
||||
var labelIDs []int64
|
||||
hasSelected := false
|
||||
// Check labels.
|
||||
if len(form.LabelIDs) > 0 {
|
||||
labelIDs, err = base.StringsToInt64s(strings.Split(form.LabelIDs, ","))
|
||||
if err != nil {
|
||||
return nil, nil, 0, 0
|
||||
}
|
||||
labelIDMark := make(container.Set[int64])
|
||||
labelIDMark.AddMultiple(labelIDs...)
|
||||
|
||||
for i := range labels {
|
||||
if labelIDMark.Contains(labels[i].ID) {
|
||||
labels[i].IsChecked = true
|
||||
hasSelected = true
|
||||
}
|
||||
return ret
|
||||
}
|
||||
labelsData.SetSelectedLabelIDs(labelIDs)
|
||||
}
|
||||
|
||||
ctx.Data["Labels"] = labels
|
||||
ctx.Data["HasSelectedLabel"] = hasSelected
|
||||
ctx.Data["label_ids"] = form.LabelIDs
|
||||
|
||||
// Check milestone.
|
||||
milestoneID := form.MilestoneID
|
||||
if milestoneID > 0 {
|
||||
milestone, err := issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, milestoneID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetMilestoneByID", err)
|
||||
return nil, nil, 0, 0
|
||||
return ret
|
||||
}
|
||||
if milestone.RepoID != repo.ID {
|
||||
ctx.ServerError("GetMilestoneByID", err)
|
||||
return nil, nil, 0, 0
|
||||
return ret
|
||||
}
|
||||
ctx.Data["Milestone"] = milestone
|
||||
ctx.Data["milestone_id"] = milestoneID
|
||||
|
@ -1171,11 +1249,11 @@ func ValidateRepoMetas(ctx *context.Context, form forms.CreateIssueForm, isPull
|
|||
p, err := project_model.GetProjectByID(ctx, form.ProjectID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetProjectByID", err)
|
||||
return nil, nil, 0, 0
|
||||
return ret
|
||||
}
|
||||
if p.RepoID != ctx.Repo.Repository.ID && p.OwnerID != ctx.Repo.Repository.OwnerID {
|
||||
ctx.NotFound("", nil)
|
||||
return nil, nil, 0, 0
|
||||
return ret
|
||||
}
|
||||
|
||||
ctx.Data["Project"] = p
|
||||
|
@ -1187,7 +1265,7 @@ func ValidateRepoMetas(ctx *context.Context, form forms.CreateIssueForm, isPull
|
|||
if len(form.AssigneeIDs) > 0 {
|
||||
assigneeIDs, err = base.StringsToInt64s(strings.Split(form.AssigneeIDs, ","))
|
||||
if err != nil {
|
||||
return nil, nil, 0, 0
|
||||
return ret
|
||||
}
|
||||
|
||||
// Check if the passed assignees actually exists and is assignable
|
||||
|
@ -1195,18 +1273,18 @@ func ValidateRepoMetas(ctx *context.Context, form forms.CreateIssueForm, isPull
|
|||
assignee, err := user_model.GetUserByID(ctx, aID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetUserByID", err)
|
||||
return nil, nil, 0, 0
|
||||
return ret
|
||||
}
|
||||
|
||||
valid, err := access_model.CanBeAssigned(ctx, assignee, repo, isPull)
|
||||
if err != nil {
|
||||
ctx.ServerError("CanBeAssigned", err)
|
||||
return nil, nil, 0, 0
|
||||
return ret
|
||||
}
|
||||
|
||||
if !valid {
|
||||
ctx.ServerError("canBeAssigned", repo_model.ErrUserDoesNotHaveAccessToRepo{UserID: aID, RepoName: repo.Name})
|
||||
return nil, nil, 0, 0
|
||||
return ret
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1216,7 +1294,39 @@ func ValidateRepoMetas(ctx *context.Context, form forms.CreateIssueForm, isPull
|
|||
assigneeIDs = append(assigneeIDs, form.AssigneeID)
|
||||
}
|
||||
|
||||
return labelIDs, assigneeIDs, milestoneID, form.ProjectID
|
||||
// Check reviewers
|
||||
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
|
||||
}
|
||||
// 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)
|
||||
return ret
|
||||
}
|
||||
teamReviewers = append(teamReviewers, teamReviewer)
|
||||
continue
|
||||
}
|
||||
|
||||
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.Reviewers, ret.TeamReviewers = reviewers, teamReviewers
|
||||
return ret
|
||||
}
|
||||
|
||||
// NewIssuePost response for creating new issue
|
||||
|
@ -1234,11 +1344,13 @@ func NewIssuePost(ctx *context.Context) {
|
|||
attachments []string
|
||||
)
|
||||
|
||||
labelIDs, assigneeIDs, milestoneID, projectID := ValidateRepoMetas(ctx, *form, false)
|
||||
validateRet := ValidateRepoMetas(ctx, *form, false)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
labelIDs, assigneeIDs, milestoneID, projectID := validateRet.LabelIDs, validateRet.AssigneeIDs, validateRet.MilestoneID, validateRet.ProjectID
|
||||
|
||||
if projectID > 0 {
|
||||
if !ctx.Repo.CanRead(unit.TypeProjects) {
|
||||
// User must also be able to see the project.
|
||||
|
@ -1507,38 +1619,15 @@ func ViewIssue(ctx *context.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
// Metas.
|
||||
// Check labels.
|
||||
labelIDMark := make(container.Set[int64])
|
||||
for _, label := range issue.Labels {
|
||||
labelIDMark.Add(label.ID)
|
||||
}
|
||||
labels, err := issues_model.GetLabelsByRepoID(ctx, repo.ID, "", db.ListOptions{})
|
||||
if err != nil {
|
||||
ctx.ServerError("GetLabelsByRepoID", err)
|
||||
retrieveRepoMetasForIssueWriter(ctx, repo, issue.IsPull)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
ctx.Data["Labels"] = labels
|
||||
|
||||
if repo.Owner.IsOrganization() {
|
||||
orgLabels, err := issues_model.GetLabelsByOrgID(ctx, repo.Owner.ID, ctx.FormString("sort"), db.ListOptions{})
|
||||
if err != nil {
|
||||
ctx.ServerError("GetLabelsByOrgID", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["OrgLabels"] = orgLabels
|
||||
|
||||
labels = append(labels, orgLabels...)
|
||||
labelsData := retrieveRepoLabels(ctx, repo, issue.ID, issue.IsPull)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
hasSelected := false
|
||||
for i := range labels {
|
||||
if labelIDMark.Contains(labels[i].ID) {
|
||||
labels[i].IsChecked = true
|
||||
hasSelected = true
|
||||
}
|
||||
}
|
||||
ctx.Data["HasSelectedLabel"] = hasSelected
|
||||
labelsData.SetSelectedLabels(issue.Labels)
|
||||
|
||||
// Check milestone and assignee.
|
||||
if ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) {
|
||||
|
@ -2479,7 +2568,7 @@ func UpdatePullReviewRequest(ctx *context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
err = issue_service.IsValidTeamReviewRequest(ctx, team, ctx.Doer, action == "attach", issue)
|
||||
_, err = issue_service.TeamReviewRequest(ctx, issue, ctx.Doer, team, action == "attach")
|
||||
if err != nil {
|
||||
if issues_model.IsErrNotValidReviewRequest(err) {
|
||||
log.Warn(
|
||||
|
@ -2490,12 +2579,6 @@ func UpdatePullReviewRequest(ctx *context.Context) {
|
|||
ctx.Status(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
ctx.ServerError("IsValidTeamReviewRequest", err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = issue_service.TeamReviewRequest(ctx, issue, ctx.Doer, team, action == "attach")
|
||||
if err != nil {
|
||||
ctx.ServerError("TeamReviewRequest", err)
|
||||
return
|
||||
}
|
||||
|
@ -2517,7 +2600,7 @@ func UpdatePullReviewRequest(ctx *context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
err = issue_service.IsValidReviewRequest(ctx, reviewer, ctx.Doer, action == "attach", issue, nil)
|
||||
_, err = issue_service.ReviewRequest(ctx, issue, ctx.Doer, &ctx.Repo.Permission, reviewer, action == "attach")
|
||||
if err != nil {
|
||||
if issues_model.IsErrNotValidReviewRequest(err) {
|
||||
log.Warn(
|
||||
|
@ -2528,12 +2611,6 @@ func UpdatePullReviewRequest(ctx *context.Context) {
|
|||
ctx.Status(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
ctx.ServerError("isValidReviewRequest", err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = issue_service.ReviewRequest(ctx, issue, ctx.Doer, reviewer, action == "attach")
|
||||
if err != nil {
|
||||
if issues_model.IsErrReviewRequestOnClosedPR(err) {
|
||||
ctx.Status(http.StatusForbidden)
|
||||
return
|
||||
|
|
|
@ -53,11 +53,11 @@ func InitializeLabels(ctx *context.Context) {
|
|||
ctx.Redirect(ctx.Repo.RepoLink + "/labels")
|
||||
}
|
||||
|
||||
// RetrieveLabels find all the labels of a repository and organization
|
||||
func RetrieveLabels(ctx *context.Context) {
|
||||
// RetrieveLabelsForList find all the labels of a repository and organization, it is only used by "/labels" page to list all labels
|
||||
func RetrieveLabelsForList(ctx *context.Context) {
|
||||
labels, err := issues_model.GetLabelsByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormString("sort"), db.ListOptions{})
|
||||
if err != nil {
|
||||
ctx.ServerError("RetrieveLabels.GetLabels", err)
|
||||
ctx.ServerError("RetrieveLabelsForList.GetLabels", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -62,7 +62,7 @@ func TestRetrieveLabels(t *testing.T) {
|
|||
contexttest.LoadUser(t, ctx, 2)
|
||||
contexttest.LoadRepo(t, ctx, testCase.RepoID)
|
||||
ctx.Req.Form.Set("sort", testCase.Sort)
|
||||
RetrieveLabels(ctx)
|
||||
RetrieveLabelsForList(ctx)
|
||||
assert.False(t, ctx.Written())
|
||||
labels, ok := ctx.Data["Labels"].([]*issues_model.Label)
|
||||
assert.True(t, ok)
|
||||
|
|
|
@ -1269,11 +1269,13 @@ func CompareAndPullRequestPost(ctx *context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
labelIDs, assigneeIDs, milestoneID, projectID := ValidateRepoMetas(ctx, *form, true)
|
||||
validateRet := ValidateRepoMetas(ctx, *form, true)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
labelIDs, assigneeIDs, milestoneID, projectID := validateRet.LabelIDs, validateRet.AssigneeIDs, validateRet.MilestoneID, validateRet.ProjectID
|
||||
|
||||
if setting.Attachment.Enabled {
|
||||
attachments = form.Files
|
||||
}
|
||||
|
@ -1318,8 +1320,17 @@ func CompareAndPullRequestPost(ctx *context.Context) {
|
|||
}
|
||||
// FIXME: check error in the case two people send pull request at almost same time, give nice error prompt
|
||||
// instead of 500.
|
||||
|
||||
if err := pull_service.NewPullRequest(ctx, repo, pullIssue, labelIDs, attachments, pullRequest, assigneeIDs); err != nil {
|
||||
prOpts := &pull_service.NewPullRequestOptions{
|
||||
Repo: repo,
|
||||
Issue: pullIssue,
|
||||
LabelIDs: labelIDs,
|
||||
AttachmentUUIDs: attachments,
|
||||
PullRequest: pullRequest,
|
||||
AssigneeIDs: assigneeIDs,
|
||||
Reviewers: validateRet.Reviewers,
|
||||
TeamReviewers: validateRet.TeamReviewers,
|
||||
}
|
||||
if err := pull_service.NewPullRequest(ctx, prOpts); err != nil {
|
||||
switch {
|
||||
case repo_model.IsErrUserDoesNotHaveAccessToRepo(err):
|
||||
ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error())
|
||||
|
|
|
@ -1163,7 +1163,7 @@ func registerRoutes(m *web.Router) {
|
|||
m.Get("/issues/posters", repo.IssuePosters) // it can't use {type:issues|pulls} because it would conflict with other routes like "/pulls/{index}"
|
||||
m.Get("/pulls/posters", repo.PullPosters)
|
||||
m.Get("/comments/{id}/attachments", repo.GetCommentAttachments)
|
||||
m.Get("/labels", repo.RetrieveLabels, repo.Labels)
|
||||
m.Get("/labels", repo.RetrieveLabelsForList, repo.Labels)
|
||||
m.Get("/milestones", repo.Milestones)
|
||||
m.Get("/milestone/{id}", context.RepoRef(), repo.MilestoneIssuesAndPulls)
|
||||
m.Group("/{type:issues|pulls}", func() {
|
||||
|
|
|
@ -137,8 +137,12 @@ func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git.
|
|||
Type: issues_model.PullRequestGitea,
|
||||
Flow: issues_model.PullRequestFlowAGit,
|
||||
}
|
||||
|
||||
if err := pull_service.NewPullRequest(ctx, repo, prIssue, []int64{}, []string{}, pr, []int64{}); err != nil {
|
||||
prOpts := &pull_service.NewPullRequestOptions{
|
||||
Repo: repo,
|
||||
Issue: prIssue,
|
||||
PullRequest: pr,
|
||||
}
|
||||
if err := pull_service.NewPullRequest(ctx, prOpts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
|
|
@ -447,6 +447,7 @@ type CreateIssueForm struct {
|
|||
Title string `binding:"Required;MaxSize(255)"`
|
||||
LabelIDs string `form:"label_ids"`
|
||||
AssigneeIDs string `form:"assignee_ids"`
|
||||
ReviewerIDs string `form:"reviewer_ids"`
|
||||
Ref string `form:"ref"`
|
||||
MilestoneID int64
|
||||
ProjectID int64
|
||||
|
|
|
@ -61,7 +61,12 @@ func ToggleAssigneeWithNotify(ctx context.Context, issue *issues_model.Issue, do
|
|||
}
|
||||
|
||||
// ReviewRequest add or remove a review request from a user for this PR, and make comment for it.
|
||||
func ReviewRequest(ctx context.Context, issue *issues_model.Issue, doer, reviewer *user_model.User, isAdd bool) (comment *issues_model.Comment, err error) {
|
||||
func ReviewRequest(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, permDoer *access_model.Permission, reviewer *user_model.User, isAdd bool) (comment *issues_model.Comment, err error) {
|
||||
err = isValidReviewRequest(ctx, reviewer, doer, isAdd, issue, permDoer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if isAdd {
|
||||
comment, err = issues_model.AddReviewRequest(ctx, issue, reviewer, doer)
|
||||
} else {
|
||||
|
@ -79,8 +84,8 @@ func ReviewRequest(ctx context.Context, issue *issues_model.Issue, doer, reviewe
|
|||
return comment, err
|
||||
}
|
||||
|
||||
// IsValidReviewRequest Check permission for ReviewRequest
|
||||
func IsValidReviewRequest(ctx context.Context, reviewer, doer *user_model.User, isAdd bool, issue *issues_model.Issue, permDoer *access_model.Permission) error {
|
||||
// isValidReviewRequest Check permission for ReviewRequest
|
||||
func isValidReviewRequest(ctx context.Context, reviewer, doer *user_model.User, isAdd bool, issue *issues_model.Issue, permDoer *access_model.Permission) error {
|
||||
if reviewer.IsOrganization() {
|
||||
return issues_model.ErrNotValidReviewRequest{
|
||||
Reason: "Organization can't be added as reviewer",
|
||||
|
@ -109,7 +114,7 @@ func IsValidReviewRequest(ctx context.Context, reviewer, doer *user_model.User,
|
|||
}
|
||||
}
|
||||
|
||||
lastreview, err := issues_model.GetReviewByIssueIDAndUserID(ctx, issue.ID, reviewer.ID)
|
||||
lastReview, err := issues_model.GetReviewByIssueIDAndUserID(ctx, issue.ID, reviewer.ID)
|
||||
if err != nil && !issues_model.IsErrReviewNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
@ -137,7 +142,7 @@ func IsValidReviewRequest(ctx context.Context, reviewer, doer *user_model.User,
|
|||
return nil
|
||||
}
|
||||
|
||||
if doer.ID == issue.PosterID && issue.OriginalAuthorID == 0 && lastreview != nil && lastreview.Type != issues_model.ReviewTypeRequest {
|
||||
if doer.ID == issue.PosterID && issue.OriginalAuthorID == 0 && lastReview != nil && lastReview.Type != issues_model.ReviewTypeRequest {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -152,7 +157,7 @@ func IsValidReviewRequest(ctx context.Context, reviewer, doer *user_model.User,
|
|||
return nil
|
||||
}
|
||||
|
||||
if lastreview != nil && lastreview.Type == issues_model.ReviewTypeRequest && lastreview.ReviewerID == doer.ID {
|
||||
if lastReview != nil && lastReview.Type == issues_model.ReviewTypeRequest && lastReview.ReviewerID == doer.ID {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -163,8 +168,8 @@ func IsValidReviewRequest(ctx context.Context, reviewer, doer *user_model.User,
|
|||
}
|
||||
}
|
||||
|
||||
// IsValidTeamReviewRequest Check permission for ReviewRequest Team
|
||||
func IsValidTeamReviewRequest(ctx context.Context, reviewer *organization.Team, doer *user_model.User, isAdd bool, issue *issues_model.Issue) error {
|
||||
// isValidTeamReviewRequest Check permission for ReviewRequest Team
|
||||
func isValidTeamReviewRequest(ctx context.Context, reviewer *organization.Team, doer *user_model.User, isAdd bool, issue *issues_model.Issue) error {
|
||||
if doer.IsOrganization() {
|
||||
return issues_model.ErrNotValidReviewRequest{
|
||||
Reason: "Organization can't be doer to add reviewer",
|
||||
|
@ -212,6 +217,10 @@ func IsValidTeamReviewRequest(ctx context.Context, reviewer *organization.Team,
|
|||
|
||||
// TeamReviewRequest add or remove a review request from a team for this PR, and make comment for it.
|
||||
func TeamReviewRequest(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, reviewer *organization.Team, isAdd bool) (comment *issues_model.Comment, err error) {
|
||||
err = isValidTeamReviewRequest(ctx, reviewer, doer, isAdd, issue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isAdd {
|
||||
comment, err = issues_model.AddTeamReviewRequest(ctx, issue, reviewer, doer)
|
||||
} else {
|
||||
|
@ -268,6 +277,9 @@ func teamReviewRequestNotify(ctx context.Context, issue *issues_model.Issue, doe
|
|||
|
||||
// CanDoerChangeReviewRequests returns if the doer can add/remove review requests of a PR
|
||||
func CanDoerChangeReviewRequests(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue) bool {
|
||||
if repo.IsArchived {
|
||||
return false
|
||||
}
|
||||
// The poster of the PR can change the reviewers
|
||||
if doer.ID == issue.PosterID {
|
||||
return true
|
||||
|
|
|
@ -382,7 +382,7 @@ func (s *dummySender) Send(from string, to []string, msg io.WriterTo) error {
|
|||
if _, err := msg.WriteTo(&buf); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Info("Mail From: %s To: %v Body: %s", from, to, buf.String())
|
||||
log.Debug("Mail From: %s To: %v Body: %s", from, to, buf.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@ import (
|
|||
"code.gitea.io/gitea/models/db"
|
||||
git_model "code.gitea.io/gitea/models/git"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
"code.gitea.io/gitea/models/organization"
|
||||
access_model "code.gitea.io/gitea/models/perm/access"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unit"
|
||||
|
@ -41,8 +42,20 @@ func getPullWorkingLockKey(prID int64) string {
|
|||
return fmt.Sprintf("pull_working_%d", prID)
|
||||
}
|
||||
|
||||
type NewPullRequestOptions struct {
|
||||
Repo *repo_model.Repository
|
||||
Issue *issues_model.Issue
|
||||
LabelIDs []int64
|
||||
AttachmentUUIDs []string
|
||||
PullRequest *issues_model.PullRequest
|
||||
AssigneeIDs []int64
|
||||
Reviewers []*user_model.User
|
||||
TeamReviewers []*organization.Team
|
||||
}
|
||||
|
||||
// NewPullRequest creates new pull request with labels for repository.
|
||||
func NewPullRequest(ctx context.Context, repo *repo_model.Repository, issue *issues_model.Issue, labelIDs []int64, uuids []string, pr *issues_model.PullRequest, assigneeIDs []int64) error {
|
||||
func NewPullRequest(ctx context.Context, opts *NewPullRequestOptions) error {
|
||||
repo, issue, labelIDs, uuids, pr, assigneeIDs := opts.Repo, opts.Issue, opts.LabelIDs, opts.AttachmentUUIDs, opts.PullRequest, opts.AssigneeIDs
|
||||
if err := issue.LoadPoster(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -197,7 +210,17 @@ func NewPullRequest(ctx context.Context, repo *repo_model.Repository, issue *iss
|
|||
}
|
||||
notify_service.IssueChangeAssignee(ctx, issue.Poster, issue, assignee, false, assigneeCommentMap[assigneeID])
|
||||
}
|
||||
|
||||
permDoer, err := access_model.GetUserRepoPermission(ctx, repo, issue.Poster)
|
||||
for _, reviewer := range opts.Reviewers {
|
||||
if _, err = issue_service.ReviewRequest(ctx, pr.Issue, issue.Poster, &permDoer, reviewer, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, teamReviewer := range opts.TeamReviewers {
|
||||
if _, err = issue_service.TeamReviewRequest(ctx, pr.Issue, issue.Poster, teamReviewer, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
@ -44,4 +44,5 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
{{end}}
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
<a
|
||||
class="item {{if not .label.IsChecked}}tw-hidden{{end}}"
|
||||
id="label_{{.label.ID}}"
|
||||
href="{{.root.RepoLink}}/{{if or .root.IsPull .root.Issue.IsPull}}pulls{{else}}issues{{end}}?labels={{.label.ID}}"{{/* FIXME: use .root.Issue.Link or create .root.Link */}}
|
||||
>
|
||||
{{- ctx.RenderUtils.RenderLabel .label -}}
|
||||
</a>
|
|
@ -1,46 +0,0 @@
|
|||
<div class="ui {{if or (not .HasIssuesOrPullsWritePermission) .Repository.IsArchived}}disabled{{end}} floating jump select-label dropdown">
|
||||
<span class="text muted flex-text-block">
|
||||
<strong>{{ctx.Locale.Tr "repo.issues.new.labels"}}</strong>
|
||||
{{if and .HasIssuesOrPullsWritePermission (not .Repository.IsArchived)}}
|
||||
{{svg "octicon-gear" 16 "tw-ml-1"}}
|
||||
{{end}}
|
||||
</span>
|
||||
<div class="filter menu" {{if .Issue}}data-action="update" data-issue-id="{{$.Issue.ID}}" data-update-url="{{$.RepoLink}}/issues/labels"{{else}}data-id="#label_ids"{{end}}>
|
||||
{{if or .Labels .OrgLabels}}
|
||||
<div class="ui icon search input">
|
||||
<i class="icon">{{svg "octicon-search" 16}}</i>
|
||||
<input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_labels"}}">
|
||||
</div>
|
||||
{{end}}
|
||||
<a class="no-select item" href="#">{{ctx.Locale.Tr "repo.issues.new.clear_labels"}}</a>
|
||||
{{if or .Labels .OrgLabels}}
|
||||
{{$previousExclusiveScope := "_no_scope"}}
|
||||
{{range .Labels}}
|
||||
{{$exclusiveScope := .ExclusiveScope}}
|
||||
{{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}}
|
||||
<div class="divider"></div>
|
||||
{{end}}
|
||||
{{$previousExclusiveScope = $exclusiveScope}}
|
||||
<a class="{{if .IsChecked}}checked{{end}} item" href="#" data-id="{{.ID}}" {{if .IsArchived}}data-is-archived{{end}} data-id-selector="#label_{{.ID}}" data-scope="{{$exclusiveScope}}"><span class="octicon-check {{if not .IsChecked}}tw-invisible{{end}}">{{svg (Iif $exclusiveScope "octicon-dot-fill" "octicon-check")}}</span> {{ctx.RenderUtils.RenderLabel .}}
|
||||
{{if .Description}}<br><small class="desc">{{.Description | ctx.RenderUtils.RenderEmoji}}</small>{{end}}
|
||||
<p class="archived-label-hint">{{template "repo/issue/labels/label_archived" .}}</p>
|
||||
</a>
|
||||
{{end}}
|
||||
<div class="divider"></div>
|
||||
{{$previousExclusiveScope = "_no_scope"}}
|
||||
{{range .OrgLabels}}
|
||||
{{$exclusiveScope := .ExclusiveScope}}
|
||||
{{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}}
|
||||
<div class="divider"></div>
|
||||
{{end}}
|
||||
{{$previousExclusiveScope = $exclusiveScope}}
|
||||
<a class="{{if .IsChecked}}checked{{end}} item" href="#" data-id="{{.ID}}" {{if .IsArchived}}data-is-archived{{end}} data-id-selector="#label_{{.ID}}" data-scope="{{$exclusiveScope}}"><span class="octicon-check {{if not .IsChecked}}tw-invisible{{end}}">{{svg (Iif $exclusiveScope "octicon-dot-fill" "octicon-check")}}</span> {{ctx.RenderUtils.RenderLabel .}}
|
||||
{{if .Description}}<br><small class="desc">{{.Description | ctx.RenderUtils.RenderEmoji}}</small>{{end}}
|
||||
<p class="archived-label-hint">{{template "repo/issue/labels/label_archived" .}}</p>
|
||||
</a>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="disabled item">{{ctx.Locale.Tr "repo.issues.new.no_items"}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
|
@ -1,11 +0,0 @@
|
|||
<div class="ui labels list">
|
||||
<span class="labels-list">
|
||||
<span class="no-select {{if .root.HasSelectedLabel}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_label"}}</span>
|
||||
{{range .root.Labels}}
|
||||
{{template "repo/issue/labels/label" dict "root" $.root "label" .}}
|
||||
{{end}}
|
||||
{{range .root.OrgLabels}}
|
||||
{{template "repo/issue/labels/label" dict "root" $.root "label" .}}
|
||||
{{end}}
|
||||
</span>
|
||||
</div>
|
|
@ -18,15 +18,15 @@
|
|||
<input type="hidden" name="template-file" value="{{.TemplateFile}}">
|
||||
{{range .Fields}}
|
||||
{{if eq .Type "input"}}
|
||||
{{template "repo/issue/fields/input" "item" .}}
|
||||
{{template "repo/issue/fields/input" dict "item" .}}
|
||||
{{else if eq .Type "markdown"}}
|
||||
{{template "repo/issue/fields/markdown" "item" .}}
|
||||
{{template "repo/issue/fields/markdown" dict "item" .}}
|
||||
{{else if eq .Type "textarea"}}
|
||||
{{template "repo/issue/fields/textarea" "item" . "root" $}}
|
||||
{{template "repo/issue/fields/textarea" dict "item" . "root" $}}
|
||||
{{else if eq .Type "dropdown"}}
|
||||
{{template "repo/issue/fields/dropdown" "item" .}}
|
||||
{{template "repo/issue/fields/dropdown" dict "item" .}}
|
||||
{{else if eq .Type "checkboxes"}}
|
||||
{{template "repo/issue/fields/checkboxes" "item" .}}
|
||||
{{template "repo/issue/fields/checkboxes" dict "item" .}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{else}}
|
||||
|
@ -47,11 +47,13 @@
|
|||
</div>
|
||||
|
||||
<div class="issue-content-right ui segment">
|
||||
{{template "repo/issue/branch_selector_field" .}}
|
||||
{{template "repo/issue/branch_selector_field" $}}
|
||||
{{if .PageIsComparePull}}
|
||||
{{template "repo/issue/sidebar/reviewer_list" $.IssueSidebarReviewersData}}
|
||||
<div class="divider"></div>
|
||||
{{end}}
|
||||
|
||||
<input id="label_ids" name="label_ids" type="hidden" value="{{.label_ids}}">
|
||||
{{template "repo/issue/labels/labels_selector_field" .}}
|
||||
{{template "repo/issue/labels/labels_sidebar" dict "root" $}}
|
||||
{{template "repo/issue/sidebar/label_list" $.IssueSidebarLabelsData}}
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
|
|
|
@ -1,15 +1,13 @@
|
|||
{{if and .Issue.IsPull .IsIssuePoster (not .Issue.IsClosed) .Issue.PullRequest.HeadRepo}}
|
||||
{{if and (not (eq .Issue.PullRequest.HeadRepo.FullName .Issue.PullRequest.BaseRepo.FullName)) .CanWriteToHeadRepo}}
|
||||
<div class="divider"></div>
|
||||
<div class="inline field">
|
||||
<div class="ui checkbox loading-icon-2px" id="allow-edits-from-maintainers"
|
||||
data-url="{{.Issue.Link}}"
|
||||
data-tooltip-content="{{ctx.Locale.Tr "repo.pulls.allow_edits_from_maintainers_desc"}}"
|
||||
data-prompt-error="{{ctx.Locale.Tr "repo.pulls.allow_edits_from_maintainers_err"}}"
|
||||
>
|
||||
<label><strong>{{ctx.Locale.Tr "repo.pulls.allow_edits_from_maintainers"}}</strong></label>
|
||||
<input type="checkbox" {{if .Issue.PullRequest.AllowMaintainerEdit}}checked{{end}}>
|
||||
</div>
|
||||
<div class="ui checkbox loading-icon-2px" id="allow-edits-from-maintainers"
|
||||
data-url="{{.Issue.Link}}"
|
||||
data-tooltip-content="{{ctx.Locale.Tr "repo.pulls.allow_edits_from_maintainers_desc"}}"
|
||||
data-prompt-error="{{ctx.Locale.Tr "repo.pulls.allow_edits_from_maintainers_err"}}"
|
||||
>
|
||||
<label><strong>{{ctx.Locale.Tr "repo.pulls.allow_edits_from_maintainers"}}</strong></label>
|
||||
<input type="checkbox" {{if .Issue.PullRequest.AllowMaintainerEdit}}checked{{end}}>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
|
|
@ -18,11 +18,11 @@
|
|||
<span class="text" data-tooltip-content="{{if .Issue.IsPull}}{{ctx.Locale.Tr "repo.issues.dependency.pr_close_blocks"}}{{else}}{{ctx.Locale.Tr "repo.issues.dependency.issue_close_blocks"}}{{end}}">
|
||||
<strong>{{ctx.Locale.Tr "repo.issues.dependency.blocks_short"}}</strong>
|
||||
</span>
|
||||
<div class="ui relaxed divided list">
|
||||
<div class="ui divided list">
|
||||
{{range .BlockingDependencies}}
|
||||
<div class="item dependency{{if .Issue.IsClosed}} is-closed{{end}} tw-flex tw-items-center tw-justify-between">
|
||||
<div class="item-left tw-flex tw-justify-center tw-flex-col tw-flex-1 gt-ellipsis">
|
||||
<a class="title muted" href="{{.Issue.Link}}" data-tooltip-content="#{{.Issue.Index}} {{.Issue.Title | ctx.RenderUtils.RenderEmoji}}">
|
||||
<a class="muted gt-ellipsis" href="{{.Issue.Link}}" data-tooltip-content="#{{.Issue.Index}} {{.Issue.Title | ctx.RenderUtils.RenderEmoji}}">
|
||||
#{{.Issue.Index}} {{.Issue.Title | ctx.RenderUtils.RenderEmoji}}
|
||||
</a>
|
||||
<div class="text small gt-ellipsis" data-tooltip-content="{{.Repository.OwnerName}}/{{.Repository.Name}}">
|
||||
|
@ -50,11 +50,11 @@
|
|||
<span class="text" data-tooltip-content="{{if .Issue.IsPull}}{{ctx.Locale.Tr "repo.issues.dependency.pr_closing_blockedby"}}{{else}}{{ctx.Locale.Tr "repo.issues.dependency.issue_closing_blockedby"}}{{end}}">
|
||||
<strong>{{ctx.Locale.Tr "repo.issues.dependency.blocked_by_short"}}</strong>
|
||||
</span>
|
||||
<div class="ui relaxed divided list">
|
||||
<div class="ui divided list">
|
||||
{{range .BlockedByDependencies}}
|
||||
<div class="item dependency{{if .Issue.IsClosed}} is-closed{{end}} tw-flex tw-items-center tw-justify-between">
|
||||
<div class="item-left tw-flex tw-justify-center tw-flex-col tw-flex-1 gt-ellipsis">
|
||||
<a class="title muted" href="{{.Issue.Link}}" data-tooltip-content="#{{.Issue.Index}} {{.Issue.Title | ctx.RenderUtils.RenderEmoji}}">
|
||||
<a class="muted gt-ellipsis" href="{{.Issue.Link}}" data-tooltip-content="#{{.Issue.Index}} {{.Issue.Title | ctx.RenderUtils.RenderEmoji}}">
|
||||
#{{.Issue.Index}} {{.Issue.Title | ctx.RenderUtils.RenderEmoji}}
|
||||
</a>
|
||||
<div class="text small gt-ellipsis" data-tooltip-content="{{.Repository.OwnerName}}/{{.Repository.Name}}">
|
||||
|
@ -76,7 +76,7 @@
|
|||
<div class="item-left tw-flex tw-justify-center tw-flex-col tw-flex-1 gt-ellipsis">
|
||||
<div class="gt-ellipsis">
|
||||
<span data-tooltip-content="{{ctx.Locale.Tr "repo.issues.dependency.no_permission.can_remove"}}">{{svg "octicon-lock" 16}}</span>
|
||||
<span class="title" data-tooltip-content="#{{.Issue.Index}} {{.Issue.Title | ctx.RenderUtils.RenderEmoji}}">
|
||||
<span class="gt-ellipsis" data-tooltip-content="#{{.Issue.Index}} {{.Issue.Title | ctx.RenderUtils.RenderEmoji}}">
|
||||
#{{.Issue.Index}} {{.Issue.Title | ctx.RenderUtils.RenderEmoji}}
|
||||
</span>
|
||||
</div>
|
||||
|
|
51
templates/repo/issue/sidebar/label_list.tmpl
Normal file
51
templates/repo/issue/sidebar/label_list.tmpl
Normal file
|
@ -0,0 +1,51 @@
|
|||
{{$data := .}}
|
||||
{{$canChange := and ctx.RootData.HasIssuesOrPullsWritePermission (not $data.Repository.IsArchived)}}
|
||||
<div class="issue-sidebar-combo" {{if $data.IssueID}}data-update-url="{{$data.RepoLink}}/issues/labels?issue_ids={{$data.IssueID}}"{{end}}>
|
||||
<input class="combo-value" name="label_ids" type="hidden" value="{{$data.SelectedLabelIDs}}">
|
||||
<div class="ui dropdown {{if not $canChange}}disabled{{end}}">
|
||||
<a class="text muted">
|
||||
<strong>{{ctx.Locale.Tr "repo.issues.new.labels"}}</strong> {{if $canChange}}{{svg "octicon-gear"}}{{end}}
|
||||
</a>
|
||||
<div class="menu">
|
||||
{{if not $data.AllLabels}}
|
||||
<div class="item disabled">{{ctx.Locale.Tr "repo.issues.new.no_items"}}</div>
|
||||
{{else}}
|
||||
<div class="ui icon search input">
|
||||
<i class="icon">{{svg "octicon-search" 16}}</i>
|
||||
<input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_labels"}}">
|
||||
</div>
|
||||
<a class="item clear-selection" href="#">{{ctx.Locale.Tr "repo.issues.new.clear_labels"}}</a>
|
||||
{{$previousExclusiveScope := "_no_scope"}}
|
||||
{{range .RepoLabels}}
|
||||
{{$exclusiveScope := .ExclusiveScope}}
|
||||
{{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}}
|
||||
<div class="divider"></div>
|
||||
{{end}}
|
||||
{{$previousExclusiveScope = $exclusiveScope}}
|
||||
{{template "repo/issue/sidebar/label_list_item" dict "Label" .}}
|
||||
{{end}}
|
||||
<div class="divider"></div>
|
||||
{{$previousExclusiveScope = "_no_scope"}}
|
||||
{{range .OrgLabels}}
|
||||
{{$exclusiveScope := .ExclusiveScope}}
|
||||
{{if and (ne $previousExclusiveScope "_no_scope") (ne $previousExclusiveScope $exclusiveScope)}}
|
||||
<div class="divider"></div>
|
||||
{{end}}
|
||||
{{$previousExclusiveScope = $exclusiveScope}}
|
||||
{{template "repo/issue/sidebar/label_list_item" dict "Label" .}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui list labels-list tw-my-2 tw-flex tw-gap-2">
|
||||
<span class="item empty-list {{if $data.SelectedLabelIDs}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_label"}}</span>
|
||||
{{range $data.AllLabels}}
|
||||
{{if .IsChecked}}
|
||||
<a class="item" href="{{$data.RepoLink}}/{{if $data.IsPullRequest}}pulls{{else}}issues{{end}}?labels={{.ID}}">
|
||||
{{- ctx.RenderUtils.RenderLabel . -}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
11
templates/repo/issue/sidebar/label_list_item.tmpl
Normal file
11
templates/repo/issue/sidebar/label_list_item.tmpl
Normal file
|
@ -0,0 +1,11 @@
|
|||
{{$label := .Label}}
|
||||
<a class="item {{if $label.IsChecked}}checked{{else if $label.IsArchived}}tw-hidden{{end}}" href="#"
|
||||
data-scope="{{$label.ExclusiveScope}}" data-value="{{$label.ID}}" {{if $label.IsArchived}}data-is-archived{{end}}
|
||||
>
|
||||
<span class="item-check-mark">{{svg (Iif $label.ExclusiveScope "octicon-dot-fill" "octicon-check")}}</span>
|
||||
{{ctx.RenderUtils.RenderLabel $label}}
|
||||
<div class="item-secondary-info">
|
||||
{{if $label.Description}}<div class="tw-pl-[20px]"><small>{{$label.Description | ctx.RenderUtils.RenderEmoji}}</small></div>{{end}}
|
||||
<div class="archived-label-hint">{{template "repo/issue/labels/label_archived" $label}}</div>
|
||||
</div>
|
||||
</a>
|
|
@ -1,95 +1,79 @@
|
|||
<input id="reviewer_id" name="reviewer_id" type="hidden" value="{{.reviewer_id}}">
|
||||
<div class="ui {{if or (and (not .Reviewers) (not .TeamReviewers)) (not .CanChooseReviewer) .Repository.IsArchived}}disabled{{end}} floating jump select-reviewers-modify dropdown">
|
||||
<a class="text tw-flex tw-items-center muted">
|
||||
<strong>{{ctx.Locale.Tr "repo.issues.review.reviewers"}}</strong>
|
||||
{{if and .CanChooseReviewer (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/request_review">
|
||||
{{if .Reviewers}}
|
||||
<div class="ui icon search input">
|
||||
<i class="icon">{{svg "octicon-search" 16}}</i>
|
||||
<input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_reviewers"}}">
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Reviewers}}
|
||||
{{range .Reviewers}}
|
||||
{{$data := .}}
|
||||
{{$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}}>
|
||||
<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}}
|
||||
</a>
|
||||
<div class="menu flex-items-menu">
|
||||
{{if $hasCandidates}}
|
||||
<div class="ui icon search input">
|
||||
<i class="icon">{{svg "octicon-search"}}</i>
|
||||
<input type="text" placeholder="{{ctx.Locale.Tr "repo.issues.filter_reviewers"}}">
|
||||
</div>
|
||||
{{end}}
|
||||
{{range $data.Reviewers}}
|
||||
{{if .User}}
|
||||
<a class="{{if not .CanChange}}ui{{end}} item {{if .Checked}}checked{{end}} {{if not .CanChange}}ban-change{{end}}" href="#" data-id="{{.ItemID}}" data-id-selector="#review_request_{{.ItemID}}" {{if not .CanChange}} data-tooltip-content="{{ctx.Locale.Tr "repo.issues.remove_request_review_block"}}"{{end}}>
|
||||
<span class="octicon-check {{if not .Checked}}tw-invisible{{end}}">{{svg "octicon-check"}}</span>
|
||||
<span class="text">
|
||||
{{ctx.AvatarUtils.Avatar .User 28 "tw-mr-2"}}{{template "repo/search_name" .User}}
|
||||
</span>
|
||||
<a class="item muted {{if .Requested}}checked{{end}}" href="{{.User.HomeLink}}" data-value="{{.ItemID}}" data-can-change="{{.CanChange}}"
|
||||
{{if not .CanChange}}data-tooltip-content="{{ctx.Locale.Tr "repo.issues.remove_request_review_block"}}"{{end}}>
|
||||
<span class="item-check-mark">{{svg "octicon-check"}}</span>
|
||||
{{ctx.AvatarUtils.Avatar .User 20}} {{template "repo/search_name" .User}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{if .TeamReviewers}}
|
||||
{{if .Reviewers}}
|
||||
<div class="divider"></div>
|
||||
{{end}}
|
||||
{{range .TeamReviewers}}
|
||||
{{if .Team}}
|
||||
<a class="{{if not .CanChange}}ui{{end}} item {{if .Checked}}checked{{end}} {{if not .CanChange}}ban-change{{end}}" href="#" data-id="{{.ItemID}}" data-id-selector="#review_request_team_{{.Team.ID}}" {{if not .CanChange}} data-tooltip-content="{{ctx.Locale.Tr "repo.issues.remove_request_review_block"}}"{{end}}>
|
||||
<span class="octicon-check {{if not .Checked}}tw-invisible{{end}}">{{svg "octicon-check" 16}}</span>
|
||||
<span class="text">
|
||||
{{svg "octicon-people" 16 "tw-ml-4 tw-mr-1"}}{{$.Issue.Repo.OwnerName}}/{{.Team.Name}}
|
||||
</span>
|
||||
</a>
|
||||
{{if $data.TeamReviewers}}
|
||||
{{if $data.Reviewers}}<div class="divider"></div>{{end}}
|
||||
{{range $data.TeamReviewers}}
|
||||
{{if .Team}}
|
||||
<a class="item muted {{if .Requested}}checked{{end}}" href="#" data-value="{{.ItemID}}" data-can-change="{{.CanChange}}"
|
||||
{{if not .CanChange}} data-tooltip-content="{{ctx.Locale.Tr "repo.issues.remove_request_review_block"}}"{{end}}>
|
||||
<span class="item-check-mark">{{svg "octicon-check"}}</span>
|
||||
{{svg "octicon-people" 20}} {{$data.RepoOwnerName}}/{{.Team.Name}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui assignees list">
|
||||
<span class="no-select item {{if or .OriginalReviews .PullReviewers}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_reviewers"}}</span>
|
||||
<div class="selected">
|
||||
{{range .PullReviewers}}
|
||||
<div class="item tw-flex tw-items-center tw-py-2">
|
||||
<div class="tw-flex tw-items-center tw-flex-1">
|
||||
<div class="ui relaxed list flex-items-block tw-my-4">
|
||||
<span class="item empty-list {{if or $data.OriginalReviews $data.CurrentPullReviewers}}tw-hidden{{end}}">
|
||||
{{ctx.Locale.Tr "repo.issues.new.no_reviewers"}}
|
||||
</span>
|
||||
{{range $data.CurrentPullReviewers}}
|
||||
<div class="item">
|
||||
<div class="flex-text-inline tw-flex-1">
|
||||
{{if .User}}
|
||||
<a class="muted sidebar-item-link" href="{{.User.HomeLink}}">{{ctx.AvatarUtils.Avatar .User 20 "tw-mr-2"}}{{.User.GetDisplayName}}</a>
|
||||
<a class="muted flex-text-inline" href="{{.User.HomeLink}}">{{ctx.AvatarUtils.Avatar .User 20}} {{.User.GetDisplayName}}</a>
|
||||
{{else if .Team}}
|
||||
<span class="text">{{svg "octicon-people" 20 "tw-mr-2"}}{{$.Issue.Repo.OwnerName}}/{{.Team.Name}}</span>
|
||||
{{svg "octicon-people" 20}} {{$data.RepoOwnerName}}/{{.Team.Name}}
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
{{if (and $.Permission.IsAdmin (or (eq .Review.Type 1) (eq .Review.Type 3)) (not $.Issue.IsClosed) (not $.Issue.PullRequest.HasMerged))}}
|
||||
<a href="#" class="ui muted icon tw-flex tw-items-center show-modal" data-tooltip-content="{{ctx.Locale.Tr "repo.issues.dismiss_review"}}" data-modal="#dismiss-review-modal-{{.Review.ID}}">
|
||||
<div class="flex-text-inline">
|
||||
{{if .CanBeDismissed}}
|
||||
<a href="#" class="ui muted icon show-modal" data-tooltip-content="{{ctx.Locale.Tr "repo.issues.dismiss_review"}}"
|
||||
data-modal="#issue-sidebar-dismiss-review-modal" data-modal-reviewer-id="{{.Review.ID}}">
|
||||
{{svg "octicon-x" 20}}
|
||||
</a>
|
||||
<div class="ui small modal" id="dismiss-review-modal-{{.Review.ID}}">
|
||||
<div class="header">
|
||||
{{ctx.Locale.Tr "repo.issues.dismiss_review"}}
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="ui warning message">
|
||||
{{ctx.Locale.Tr "repo.issues.dismiss_review_warning"}}
|
||||
</div>
|
||||
<form class="ui form dismiss-review-form" id="dismiss-review-{{.Review.ID}}" action="{{$.RepoLink}}/issues/dismiss_review" method="post">
|
||||
{{$.CsrfTokenHtml}}
|
||||
<input type="hidden" name="review_id" value="{{.Review.ID}}">
|
||||
<div class="field">
|
||||
<label for="message">{{ctx.Locale.Tr "action.review_dismissed_reason"}}</label>
|
||||
<input id="message" name="message">
|
||||
</div>
|
||||
<div class="text right actions">
|
||||
<button class="ui cancel button">{{ctx.Locale.Tr "settings.cancel"}}</button>
|
||||
<button class="ui red button" type="submit">{{ctx.Locale.Tr "ok"}}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Review.Stale}}
|
||||
<span data-tooltip-content="{{ctx.Locale.Tr "repo.issues.is_stale"}}">
|
||||
{{svg "octicon-hourglass" 16}}
|
||||
</span>
|
||||
<span data-tooltip-content="{{ctx.Locale.Tr "repo.issues.is_stale"}}">{{svg "octicon-hourglass" 16}}</span>
|
||||
{{end}}
|
||||
{{if and .CanChange (or .Checked (and (not $.Issue.IsClosed) (not $.Issue.PullRequest.HasMerged)))}}
|
||||
<a href="#" class="ui muted icon re-request-review{{if .Checked}} checked{{end}}" data-tooltip-content="{{if .Checked}}{{ctx.Locale.Tr "repo.issues.remove_request_review"}}{{else}}{{ctx.Locale.Tr "repo.issues.re_request_review"}}{{end}}" data-issue-id="{{$.Issue.ID}}" data-id="{{.ItemID}}" data-update-url="{{$.RepoLink}}/issues/request_review">{{svg (Iif .Checked "octicon-trash" "octicon-sync")}}</a>
|
||||
{{if and .CanChange $data.CanChooseReviewer}}
|
||||
{{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}}">
|
||||
{{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}}">
|
||||
{{svg "octicon-sync"}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<span {{if .Review.TooltipContent}}data-tooltip-content="{{ctx.Locale.Tr .Review.TooltipContent}}"{{end}}>
|
||||
{{svg (printf "octicon-%s" .Review.Type.Icon) 16 (printf "text %s" (.Review.HTMLTypeColorName))}}
|
||||
|
@ -97,15 +81,16 @@
|
|||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{range .OriginalReviews}}
|
||||
<div class="item tw-flex tw-items-center tw-py-2">
|
||||
<div class="tw-flex tw-items-center tw-flex-1">
|
||||
<a class="muted" href="{{$.Repository.OriginalURL}}" data-tooltip-content="{{ctx.Locale.Tr "repo.migrated_from_fake" $.Repository.GetOriginalURLHostname}}">
|
||||
{{svg (MigrationIcon $.Repository.GetOriginalURLHostname) 20 "tw-mr-2"}}
|
||||
{{.OriginalAuthor}}
|
||||
{{range $data.OriginalReviews}}
|
||||
<div class="item">
|
||||
<div class="flex-text-inline tw-flex-1">
|
||||
{{$originalURLHostname := $data.Repository.GetOriginalURLHostname}}
|
||||
{{$originalURL := $data.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>
|
||||
</div>
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
<div class="flex-text-inline">
|
||||
<span {{if .TooltipContent}}data-tooltip-content="{{ctx.Locale.Tr .TooltipContent}}"{{end}}>
|
||||
{{svg (printf "octicon-%s" .Type.Icon) 16 (printf "text %s" (.HTMLTypeColorName))}}
|
||||
</span>
|
||||
|
@ -113,4 +98,29 @@
|
|||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if $data.CurrentPullReviewers}}
|
||||
<div class="ui small modal" id="issue-sidebar-dismiss-review-modal">
|
||||
<div class="header">
|
||||
{{ctx.Locale.Tr "repo.issues.dismiss_review"}}
|
||||
</div>
|
||||
<div class="content">
|
||||
<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">
|
||||
{{ctx.RootData.CsrfTokenHtml}}
|
||||
<input type="hidden" class="reviewer-id" name="review_id">
|
||||
<div class="field">
|
||||
<label for="issue-sidebar-dismiss-review-message">{{ctx.Locale.Tr "action.review_dismissed_reason"}}</label>
|
||||
<input id="issue-sidebar-dismiss-review-message" name="message">
|
||||
</div>
|
||||
<div class="text right actions">
|
||||
<button class="ui cancel button">{{ctx.Locale.Tr "settings.cancel"}}</button>
|
||||
<button class="ui red button" type="submit">{{ctx.Locale.Tr "ok"}}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{{if and (or .HasIssuesOrPullsWritePermission .IsIssuePoster) (not .HasMerged) (not .Issue.IsClosed) (not .IsPullWorkInProgress)}}
|
||||
<div class="toggle-wip" data-title="{{.Issue.Title}}" data-wip-prefix="{{index .PullRequestWorkInProgressPrefixes 0}}" data-update-url="{{.Issue.Link}}/title">
|
||||
<div class="toggle-wip tw-mt-2" data-title="{{.Issue.Title}}" data-wip-prefix="{{index .PullRequestWorkInProgressPrefixes 0}}" data-update-url="{{.Issue.Link}}/title">
|
||||
<a class="muted">
|
||||
{{ctx.Locale.Tr "repo.pulls.still_in_progress"}} {{ctx.Locale.Tr "repo.pulls.add_prefix" (index .PullRequestWorkInProgressPrefixes 0)}}
|
||||
</a>
|
||||
|
|
|
@ -2,13 +2,12 @@
|
|||
{{template "repo/issue/branch_selector_field" $}}
|
||||
|
||||
{{if .Issue.IsPull}}
|
||||
{{template "repo/issue/sidebar/reviewer_list" $}}
|
||||
{{template "repo/issue/sidebar/reviewer_list" $.IssueSidebarReviewersData}}
|
||||
{{template "repo/issue/sidebar/wip_switch" $}}
|
||||
<div class="divider"></div>
|
||||
{{end}}
|
||||
|
||||
{{template "repo/issue/labels/labels_selector_field" $}}
|
||||
{{template "repo/issue/labels/labels_sidebar" dict "root" $}}
|
||||
{{template "repo/issue/sidebar/label_list" $.IssueSidebarLabelsData}}
|
||||
|
||||
{{template "repo/issue/sidebar/milestone_list" $}}
|
||||
{{template "repo/issue/sidebar/project_list" $}}
|
||||
|
|
14
templates/swagger/v1_json.tmpl
generated
14
templates/swagger/v1_json.tmpl
generated
|
@ -20095,6 +20095,20 @@
|
|||
"format": "int64",
|
||||
"x-go-name": "Milestone"
|
||||
},
|
||||
"reviewers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"x-go-name": "Reviewers"
|
||||
},
|
||||
"team_reviewers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"x-go-name": "TeamReviewers"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"x-go-name": "Title"
|
||||
|
|
|
@ -145,7 +145,8 @@ func TestPullRequestTargetEvent(t *testing.T) {
|
|||
BaseRepo: baseRepo,
|
||||
Type: issues_model.PullRequestGitea,
|
||||
}
|
||||
err = pull_service.NewPullRequest(git.DefaultContext, baseRepo, pullIssue, nil, nil, pullRequest, nil)
|
||||
prOpts := &pull_service.NewPullRequestOptions{Repo: baseRepo, Issue: pullIssue, PullRequest: pullRequest}
|
||||
err = pull_service.NewPullRequest(git.DefaultContext, prOpts)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// load and compare ActionRun
|
||||
|
@ -199,7 +200,8 @@ func TestPullRequestTargetEvent(t *testing.T) {
|
|||
BaseRepo: baseRepo,
|
||||
Type: issues_model.PullRequestGitea,
|
||||
}
|
||||
err = pull_service.NewPullRequest(git.DefaultContext, baseRepo, pullIssue, nil, nil, pullRequest, nil)
|
||||
prOpts = &pull_service.NewPullRequestOptions{Repo: baseRepo, Issue: pullIssue, PullRequest: pullRequest}
|
||||
err = pull_service.NewPullRequest(git.DefaultContext, prOpts)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// the new pull request cannot trigger actions, so there is still only 1 record
|
||||
|
|
|
@ -11,6 +11,7 @@ import (
|
|||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
access_model "code.gitea.io/gitea/models/perm/access"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
|
@ -422,7 +423,9 @@ func TestAPIPullReviewStayDismissed(t *testing.T) {
|
|||
pullIssue.ID, user8.ID, 1, 1, 2, false)
|
||||
|
||||
// user8 dismiss review
|
||||
_, err = issue_service.ReviewRequest(db.DefaultContext, pullIssue, user8, user8, false)
|
||||
permUser8, err := access_model.GetUserRepoPermission(db.DefaultContext, pullIssue.Repo, user8)
|
||||
assert.NoError(t, err)
|
||||
_, err = issue_service.ReviewRequest(db.DefaultContext, pullIssue, user8, &permUser8, user8, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
reviewsCountCheck(t,
|
||||
|
|
|
@ -520,7 +520,8 @@ func TestConflictChecking(t *testing.T) {
|
|||
BaseRepo: baseRepo,
|
||||
Type: issues_model.PullRequestGitea,
|
||||
}
|
||||
err = pull.NewPullRequest(git.DefaultContext, baseRepo, pullIssue, nil, nil, pullRequest, nil)
|
||||
prOpts := &pull.NewPullRequestOptions{Repo: baseRepo, Issue: pullIssue, PullRequest: pullRequest}
|
||||
err = pull.NewPullRequest(git.DefaultContext, prOpts)
|
||||
assert.NoError(t, err)
|
||||
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{Title: "PR with conflict!"})
|
||||
|
|
|
@ -173,7 +173,8 @@ func createOutdatedPR(t *testing.T, actor, forkOrg *user_model.User) *issues_mod
|
|||
BaseRepo: baseRepo,
|
||||
Type: issues_model.PullRequestGitea,
|
||||
}
|
||||
err = pull_service.NewPullRequest(git.DefaultContext, baseRepo, pullIssue, nil, nil, pullRequest, nil)
|
||||
prOpts := &pull_service.NewPullRequestOptions{Repo: baseRepo, Issue: pullIssue, PullRequest: pullRequest}
|
||||
err = pull_service.NewPullRequest(git.DefaultContext, prOpts)
|
||||
assert.NoError(t, err)
|
||||
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{Title: "Test Pull -to-update-"})
|
||||
|
|
|
@ -1381,6 +1381,7 @@ table th[data-sortt-desc] .svg {
|
|||
align-items: stretch;
|
||||
}
|
||||
|
||||
.ui.list.flex-items-block > .item,
|
||||
.flex-items-block > .item,
|
||||
.flex-text-block {
|
||||
display: flex;
|
||||
|
|
|
@ -50,9 +50,20 @@
|
|||
width: 300px;
|
||||
}
|
||||
|
||||
.issue-sidebar-combo .ui.dropdown .item:not(.checked) .item-check-mark {
|
||||
visibility: hidden;
|
||||
}
|
||||
/* ideally, we should move these styles to ".ui.dropdown .menu.flex-items-menu > .item ...", could be done later */
|
||||
.issue-sidebar-combo .ui.dropdown .menu > .item > img,
|
||||
.issue-sidebar-combo .ui.dropdown .menu > .item > svg {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.issue-content-right .dropdown > .menu {
|
||||
max-width: 270px;
|
||||
min-width: 0;
|
||||
max-height: 500px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
|
@ -62,23 +73,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.repository .issue-content-right .ui.list .dependency {
|
||||
padding: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.repository .issue-content-right .ui.list .title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.repository .issue-content-right #deadlineForm input {
|
||||
width: 12.8rem;
|
||||
border-radius: var(--border-radius) 0 0 var(--border-radius);
|
||||
border-right: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.repository .issue-content-right .filter.menu {
|
||||
max-height: 500px;
|
||||
overflow-x: auto;
|
||||
|
@ -118,10 +112,6 @@
|
|||
left: 0;
|
||||
}
|
||||
|
||||
.repository .select-label .desc {
|
||||
padding-left: 23px;
|
||||
}
|
||||
|
||||
/* For the secondary pointing menu, respect its own border-bottom */
|
||||
/* style reference: https://semantic-ui.com/collections/menu.html#pointing */
|
||||
.repository .ui.tabs.container .ui.menu:not(.secondary.pointing) {
|
||||
|
|
|
@ -47,6 +47,7 @@
|
|||
}
|
||||
|
||||
.archived-label-hint {
|
||||
float: right;
|
||||
margin: -12px;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 5px;
|
||||
}
|
||||
|
|
|
@ -32,13 +32,13 @@ export function initGlobalDropdown() {
|
|||
const $uiDropdowns = fomanticQuery('.ui.dropdown');
|
||||
|
||||
// do not init "custom" dropdowns, "custom" dropdowns are managed by their own code.
|
||||
$uiDropdowns.filter(':not(.custom)').dropdown();
|
||||
$uiDropdowns.filter(':not(.custom)').dropdown({hideDividers: 'empty'});
|
||||
|
||||
// The "jump" means this dropdown is mainly used for "menu" purpose,
|
||||
// clicking an item will jump to somewhere else or trigger an action/function.
|
||||
// When a dropdown is used for non-refresh actions with tippy,
|
||||
// it must have this "jump" class to hide the tippy when dropdown is closed.
|
||||
$uiDropdowns.filter('.jump').dropdown({
|
||||
$uiDropdowns.filter('.jump').dropdown('setting', {
|
||||
action: 'hide',
|
||||
onShow() {
|
||||
// hide associated tooltip while dropdown is open
|
||||
|
|
105
web_src/js/features/repo-issue-sidebar-combolist.ts
Normal file
105
web_src/js/features/repo-issue-sidebar-combolist.ts
Normal file
|
@ -0,0 +1,105 @@
|
|||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
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() {
|
||||
const commentTextareas = [
|
||||
document.querySelector<HTMLTextAreaElement>('.edit-content-zone:not(.tw-hidden) textarea'),
|
||||
document.querySelector<HTMLTextAreaElement>('#comment-form textarea'),
|
||||
];
|
||||
for (const textarea of commentTextareas) {
|
||||
// Most users won't feel too sad if they lose a comment with 10 chars, they can re-type these in seconds.
|
||||
// But if they have typed more (like 50) chars and the comment is lost, they will be very unhappy.
|
||||
if (textarea && textarea.value.trim().length > 10) {
|
||||
textarea.parentElement.scrollIntoView();
|
||||
if (!window.confirm('Page will be reloaded, but there are draft comments. Continuing to reload will discard the comments. Continue?')) {
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function collectCheckedValues(elDropdown: HTMLElement) {
|
||||
return Array.from(elDropdown.querySelectorAll('.menu > .item.checked'), (el) => el.getAttribute('data-value'));
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
elDropdown.addEventListener('click', (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 = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const scope = elItem.getAttribute('data-scope');
|
||||
if (scope) {
|
||||
// scoped items could only be checked one at a time
|
||||
const elSelected = elDropdown.querySelector<HTMLElement>(`.menu > .item.checked[data-scope="${CSS.escape(scope)}"]`);
|
||||
if (elSelected === elItem) {
|
||||
elItem.classList.toggle('checked');
|
||||
} else {
|
||||
queryElems(elDropdown, `.menu > .item[data-scope="${CSS.escape(scope)}"]`, (el) => el.classList.remove('checked'));
|
||||
elItem.classList.toggle('checked', true);
|
||||
}
|
||||
} else {
|
||||
elItem.classList.toggle('checked');
|
||||
}
|
||||
elComboValue.value = collectCheckedValues(elDropdown).join(',');
|
||||
});
|
||||
|
||||
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) {
|
||||
if (!initialValues.includes(value)) {
|
||||
await POST(updateUrl, {data: new URLSearchParams({action: 'attach', id: value})});
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) issueSidebarReloadConfirmDraftComment();
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
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;
|
||||
},
|
||||
});
|
||||
}
|
27
web_src/js/features/repo-issue-sidebar.md
Normal file
27
web_src/js/features/repo-issue-sidebar.md
Normal file
|
@ -0,0 +1,27 @@
|
|||
A sidebar combo (dropdown+list) is like this:
|
||||
|
||||
```html
|
||||
<div class="issue-sidebar-combo" data-update-url="...">
|
||||
<input class="combo-value" name="..." type="hidden" value="...">
|
||||
<div class="ui dropdown">
|
||||
<div class="menu">
|
||||
<div class="item clear-selection">clear</div>
|
||||
<div class="item" data-value="..." data-scope="...">
|
||||
<span class="item-check-mark">...</span>
|
||||
...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui list">
|
||||
<span class="item empty-list">no item</span>
|
||||
<span class="item">...</span>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
When the selected items change, the `combo-value` input will be updated.
|
||||
If there is `data-update-url`, it also calls backend to attach/detach the changed items.
|
||||
|
||||
Also, the changed items will be syncronized to the `ui list` items.
|
||||
|
||||
The items with the same data-scope only allow one selected at a time.
|
|
@ -3,27 +3,8 @@ import {POST} from '../modules/fetch.ts';
|
|||
import {updateIssuesMeta} from './repo-common.ts';
|
||||
import {svg} from '../svg.ts';
|
||||
import {htmlEscape} from 'escape-goat';
|
||||
import {toggleElem} from '../utils/dom.ts';
|
||||
|
||||
// if there are draft comments, confirm before reloading, to avoid losing comments
|
||||
function reloadConfirmDraftComment() {
|
||||
const commentTextareas = [
|
||||
document.querySelector('.edit-content-zone:not(.tw-hidden) textarea'),
|
||||
document.querySelector('#comment-form textarea'),
|
||||
];
|
||||
for (const textarea of commentTextareas) {
|
||||
// Most users won't feel too sad if they lose a comment with 10 chars, they can re-type these in seconds.
|
||||
// But if they have typed more (like 50) chars and the comment is lost, they will be very unhappy.
|
||||
if (textarea && textarea.value.trim().length > 10) {
|
||||
textarea.parentElement.scrollIntoView();
|
||||
if (!window.confirm('Page will be reloaded, but there are draft comments. Continuing to reload will discard the comments. Continue?')) {
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
window.location.reload();
|
||||
}
|
||||
import {queryElems, toggleElem} from '../utils/dom.ts';
|
||||
import {initIssueSidebarComboList, issueSidebarReloadConfirmDraftComment} from './repo-issue-sidebar-combolist.ts';
|
||||
|
||||
function initBranchSelector() {
|
||||
const elSelectBranch = document.querySelector('.ui.dropdown.select-branch');
|
||||
|
@ -47,7 +28,7 @@ function initBranchSelector() {
|
|||
} else {
|
||||
// for new issue, only update UI&form, do not send request/reload
|
||||
const selectedHiddenSelector = this.getAttribute('data-id-selector');
|
||||
document.querySelector(selectedHiddenSelector).value = selectedValue;
|
||||
document.querySelector<HTMLInputElement>(selectedHiddenSelector).value = selectedValue;
|
||||
elSelectBranch.querySelector('.text-branch-name').textContent = selectedText;
|
||||
}
|
||||
});
|
||||
|
@ -72,13 +53,13 @@ function initListSubmits(selector, outerSelector) {
|
|||
for (const [elementId, item] of itemEntries) {
|
||||
await updateIssuesMeta(
|
||||
item['update-url'],
|
||||
item.action,
|
||||
item['action'],
|
||||
item['issue-id'],
|
||||
elementId,
|
||||
);
|
||||
}
|
||||
if (itemEntries.length) {
|
||||
reloadConfirmDraftComment();
|
||||
issueSidebarReloadConfirmDraftComment();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -99,14 +80,14 @@ function initListSubmits(selector, outerSelector) {
|
|||
if (scope) {
|
||||
// Enable only clicked item for scoped labels
|
||||
if (this.getAttribute('data-scope') !== scope) {
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
if (this !== clickedItem && !this.classList.contains('checked')) {
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
} else if (this !== clickedItem) {
|
||||
// Toggle for other labels
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.classList.contains('checked')) {
|
||||
|
@ -142,7 +123,7 @@ function initListSubmits(selector, outerSelector) {
|
|||
|
||||
// TODO: Which thing should be done for choosing review requests
|
||||
// to make chosen items be shown on time here?
|
||||
if (selector === 'select-reviewers-modify' || selector === 'select-assignees-modify') {
|
||||
if (selector === 'select-assignees-modify') {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -173,7 +154,7 @@ function initListSubmits(selector, outerSelector) {
|
|||
$listMenu.data('issue-id'),
|
||||
'',
|
||||
);
|
||||
reloadConfirmDraftComment();
|
||||
issueSidebarReloadConfirmDraftComment();
|
||||
})();
|
||||
}
|
||||
|
||||
|
@ -182,7 +163,7 @@ function initListSubmits(selector, outerSelector) {
|
|||
$(this).find('.octicon-check').addClass('tw-invisible');
|
||||
});
|
||||
|
||||
if (selector === 'select-reviewers-modify' || selector === 'select-assignees-modify') {
|
||||
if (selector === 'select-assignees-modify') {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -213,7 +194,7 @@ function selectItem(select_id, input_id) {
|
|||
$menu.data('issue-id'),
|
||||
$(this).data('id'),
|
||||
);
|
||||
reloadConfirmDraftComment();
|
||||
issueSidebarReloadConfirmDraftComment();
|
||||
})();
|
||||
}
|
||||
|
||||
|
@ -249,7 +230,7 @@ function selectItem(select_id, input_id) {
|
|||
$menu.data('issue-id'),
|
||||
$(this).data('id'),
|
||||
);
|
||||
reloadConfirmDraftComment();
|
||||
issueSidebarReloadConfirmDraftComment();
|
||||
})();
|
||||
}
|
||||
|
||||
|
@ -276,14 +257,14 @@ export function initRepoIssueSidebar() {
|
|||
initBranchSelector();
|
||||
initRepoIssueDue();
|
||||
|
||||
// Init labels and assignees
|
||||
initListSubmits('select-label', 'labels');
|
||||
// TODO: refactor the legacy initListSubmits&selectItem to initIssueSidebarComboList
|
||||
initListSubmits('select-assignees', 'assignees');
|
||||
initListSubmits('select-assignees-modify', 'assignees');
|
||||
initListSubmits('select-reviewers-modify', 'assignees');
|
||||
selectItem('.select-assignee', '#assignee_id');
|
||||
|
||||
// Milestone, Assignee, Project
|
||||
selectItem('.select-project', '#project_id');
|
||||
selectItem('.select-milestone', '#milestone_id');
|
||||
selectItem('.select-assignee', '#assignee_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));
|
||||
}
|
||||
|
|
|
@ -8,7 +8,6 @@ import {parseIssuePageInfo, toAbsoluteUrl} from '../utils.ts';
|
|||
import {GET, POST} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {initRepoIssueSidebar} from './repo-issue-sidebar.ts';
|
||||
import {updateIssuesMeta} from './repo-common.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
|
||||
|
@ -99,6 +98,7 @@ export function initRepoIssueSidebarList() {
|
|||
});
|
||||
});
|
||||
|
||||
// FIXME: it is wrong place to init ".ui.dropdown.label-filter"
|
||||
$('.menu .ui.dropdown.label-filter').on('keydown', (e) => {
|
||||
if (e.altKey && e.key === 'Enter') {
|
||||
const selectedItem = document.querySelector('.menu .ui.dropdown.label-filter .menu .item.selected');
|
||||
|
@ -107,7 +107,6 @@ export function initRepoIssueSidebarList() {
|
|||
}
|
||||
}
|
||||
});
|
||||
$('.ui.dropdown.label-filter, .ui.dropdown.select-label').dropdown('setting', {'hideDividers': 'empty'}).dropdown('refreshItems');
|
||||
}
|
||||
|
||||
export function initRepoIssueCommentDelete() {
|
||||
|
@ -326,17 +325,6 @@ export function initRepoIssueWipTitle() {
|
|||
export function initRepoIssueComments() {
|
||||
if (!$('.repository.view.issue .timeline').length) return;
|
||||
|
||||
$('.re-request-review').on('click', async function (e) {
|
||||
e.preventDefault();
|
||||
const url = this.getAttribute('data-update-url');
|
||||
const issueId = this.getAttribute('data-issue-id');
|
||||
const id = this.getAttribute('data-id');
|
||||
const isChecked = this.classList.contains('checked');
|
||||
|
||||
await updateIssuesMeta(url, isChecked ? 'detach' : 'attach', issueId, id);
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
const urlTarget = document.querySelector(':target');
|
||||
if (!urlTarget) return;
|
||||
|
@ -664,19 +652,6 @@ function initIssueTemplateCommentEditors($commentForm) {
|
|||
}
|
||||
}
|
||||
|
||||
// This function used to show and hide archived label on issue/pr
|
||||
// page in the sidebar where we select the labels
|
||||
// If we have any archived label tagged to issue and pr. We will show that
|
||||
// archived label with checked classed otherwise we will hide it
|
||||
// with the help of this function.
|
||||
// This function runs globally.
|
||||
export function initArchivedLabelHandler() {
|
||||
if (!document.querySelector('.archived-label-hint')) return;
|
||||
for (const label of document.querySelectorAll('[data-is-archived]')) {
|
||||
toggleElem(label, label.classList.contains('checked'));
|
||||
}
|
||||
}
|
||||
|
||||
export function initRepoCommentFormAndSidebar() {
|
||||
const $commentForm = $('.comment.form');
|
||||
if (!$commentForm.length) return;
|
||||
|
|
|
@ -30,7 +30,7 @@ import {
|
|||
initRepoIssueWipTitle,
|
||||
initRepoPullRequestMergeInstruction,
|
||||
initRepoPullRequestAllowMaintainerEdit,
|
||||
initRepoPullRequestReview, initRepoIssueSidebarList, initArchivedLabelHandler,
|
||||
initRepoPullRequestReview, initRepoIssueSidebarList,
|
||||
} from './features/repo-issue.ts';
|
||||
import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts';
|
||||
import {initRepoTopicBar} from './features/repo-home.ts';
|
||||
|
@ -182,7 +182,6 @@ onDomReady(() => {
|
|||
initRepoIssueContentHistory,
|
||||
initRepoIssueList,
|
||||
initRepoIssueSidebarList,
|
||||
initArchivedLabelHandler,
|
||||
initRepoIssueReferenceRepositorySearch,
|
||||
initRepoIssueTimeTracking,
|
||||
initRepoIssueWipTitle,
|
||||
|
|
|
@ -57,10 +57,21 @@ export async function renderMermaid() {
|
|||
btn.setAttribute('data-clipboard-text', source);
|
||||
mermaidBlock.append(btn);
|
||||
|
||||
const updateIframeHeight = () => {
|
||||
iframe.style.height = `${iframe.contentWindow.document.body.clientHeight}px`;
|
||||
};
|
||||
|
||||
// update height when element's visibility state changes, for example when the diagram is inside
|
||||
// a <details> + <summary> block and the <details> block becomes visible upon user interaction, it
|
||||
// would initially set a incorrect height and the correct height is set during this callback.
|
||||
(new IntersectionObserver(() => {
|
||||
updateIframeHeight();
|
||||
}, {root: document.documentElement})).observe(iframe);
|
||||
|
||||
iframe.addEventListener('load', () => {
|
||||
pre.replaceWith(mermaidBlock);
|
||||
mermaidBlock.classList.remove('tw-hidden');
|
||||
iframe.style.height = `${iframe.contentWindow.document.body.clientHeight}px`;
|
||||
updateIframeHeight();
|
||||
setTimeout(() => { // avoid flash of iframe background
|
||||
mermaidBlock.classList.remove('is-loading');
|
||||
iframe.classList.remove('tw-invisible');
|
||||
|
|
|
@ -3,7 +3,7 @@ import type {Promisable} from 'type-fest';
|
|||
import type $ from 'jquery';
|
||||
|
||||
type ElementArg = Element | string | NodeListOf<Element> | Array<Element> | ReturnType<typeof $>;
|
||||
type ElementsCallback = (el: Element) => Promisable<any>;
|
||||
type ElementsCallback<T extends Element> = (el: T) => Promisable<any>;
|
||||
type ElementsCallbackWithArgs = (el: Element, ...args: any[]) => Promisable<any>;
|
||||
type ArrayLikeIterable<T> = ArrayLike<T> & Iterable<T>; // for NodeListOf and Array
|
||||
|
||||
|
@ -58,7 +58,7 @@ export function isElemHidden(el: ElementArg) {
|
|||
return res[0];
|
||||
}
|
||||
|
||||
function applyElemsCallback<T extends Element>(elems: ArrayLikeIterable<T>, fn?: ElementsCallback): ArrayLikeIterable<T> {
|
||||
function applyElemsCallback<T extends Element>(elems: ArrayLikeIterable<T>, fn?: ElementsCallback<T>): ArrayLikeIterable<T> {
|
||||
if (fn) {
|
||||
for (const el of elems) {
|
||||
fn(el);
|
||||
|
@ -67,7 +67,7 @@ function applyElemsCallback<T extends Element>(elems: ArrayLikeIterable<T>, fn?:
|
|||
return elems;
|
||||
}
|
||||
|
||||
export function queryElemSiblings<T extends Element>(el: Element, selector = '*', fn?: ElementsCallback): ArrayLikeIterable<T> {
|
||||
export function queryElemSiblings<T extends Element>(el: Element, selector = '*', fn?: ElementsCallback<T>): ArrayLikeIterable<T> {
|
||||
const elems = Array.from(el.parentNode.children) as T[];
|
||||
return applyElemsCallback<T>(elems.filter((child: Element) => {
|
||||
return child !== el && child.matches(selector);
|
||||
|
@ -75,13 +75,13 @@ export function queryElemSiblings<T extends Element>(el: Element, selector = '*'
|
|||
}
|
||||
|
||||
// it works like jQuery.children: only the direct children are selected
|
||||
export function queryElemChildren<T extends Element>(parent: Element | ParentNode, selector = '*', fn?: ElementsCallback): ArrayLikeIterable<T> {
|
||||
export function queryElemChildren<T extends Element>(parent: Element | ParentNode, selector = '*', fn?: ElementsCallback<T>): ArrayLikeIterable<T> {
|
||||
return applyElemsCallback<T>(parent.querySelectorAll(`:scope > ${selector}`), fn);
|
||||
}
|
||||
|
||||
// it works like parent.querySelectorAll: all descendants are selected
|
||||
// in the future, all "queryElems(document, ...)" should be refactored to use a more specific parent
|
||||
export function queryElems<T extends Element>(parent: Element | ParentNode, selector: string, fn?: ElementsCallback): ArrayLikeIterable<T> {
|
||||
export function queryElems<T extends Element>(parent: Element | ParentNode, selector: string, fn?: ElementsCallback<T>): ArrayLikeIterable<T> {
|
||||
return applyElemsCallback<T>(parent.querySelectorAll(selector), fn);
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue