diff --git a/models/issues/issue.go b/models/issues/issue.go index 40462ed09d..c5b6420b4d 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -147,6 +147,8 @@ type Issue struct { // For view issue page. ShowRole RoleDescriptor `xorm:"-"` + + ProjectIssue *project_model.ProjectIssue `xorm:"-"` } var ( @@ -336,6 +338,10 @@ func (issue *Issue) LoadAttributes(ctx context.Context) (err error) { return err } + if err = issue.LoadProjectIssue(ctx); err != nil { + return err + } + if err = issue.LoadAssignees(ctx); err != nil { return err } diff --git a/models/issues/issue_project.go b/models/issues/issue_project.go index 835ea1db52..b0910459a3 100644 --- a/models/issues/issue_project.go +++ b/models/issues/issue_project.go @@ -28,6 +28,25 @@ func (issue *Issue) LoadProject(ctx context.Context) (err error) { return err } +func (issue *Issue) LoadProjectIssue(ctx context.Context) (err error) { + if issue.Project == nil { + return nil + } + + if issue.ProjectIssue != nil { + return nil + } + + issue.ProjectIssue, err = project_model.GetProjectIssueByIssueID(ctx, issue.ID) + if err != nil { + return err + } + + issue.ProjectIssue.Project = issue.Project + + return issue.ProjectIssue.LoadProjectColumn(ctx) +} + func (issue *Issue) projectID(ctx context.Context) int64 { var ip project_model.ProjectIssue has, err := db.GetEngine(ctx).Where("issue_id=?", issue.ID).Get(&ip) diff --git a/models/project/issue.go b/models/project/issue.go index 3361b533b9..9d08202704 100644 --- a/models/project/issue.go +++ b/models/project/issue.go @@ -14,12 +14,14 @@ import ( // ProjectIssue saves relation from issue to a project type ProjectIssue struct { //revive:disable-line:exported - ID int64 `xorm:"pk autoincr"` - IssueID int64 `xorm:"INDEX"` - ProjectID int64 `xorm:"INDEX"` + ID int64 `xorm:"pk autoincr"` + IssueID int64 `xorm:"INDEX"` + ProjectID int64 `xorm:"INDEX"` + Project *Project `xorm:"-"` // ProjectColumnID should not be zero since 1.22. If it's zero, the issue will not be displayed on UI and it might result in errors. - ProjectColumnID int64 `xorm:"'project_board_id' INDEX"` + ProjectColumnID int64 `xorm:"'project_board_id' INDEX"` + ProjectColumn *Column `xorm:"-"` // the sorting order on the column Sorting int64 `xorm:"NOT NULL DEFAULT 0"` @@ -34,6 +36,50 @@ func deleteProjectIssuesByProjectID(ctx context.Context, projectID int64) error return err } +type ErrProjectIssueNotExist struct { + IssueID int64 +} + +func (e ErrProjectIssueNotExist) Error() string { + return fmt.Sprintf("can't find project issue [issue_id: %d]", e.IssueID) +} + +func IsErrProjectIssueNotExist(e error) bool { + _, ok := e.(ErrProjectIssueNotExist) + return ok +} + +func GetProjectIssueByIssueID(ctx context.Context, issueID int64) (*ProjectIssue, error) { + issue := &ProjectIssue{} + + has, err := db.GetEngine(ctx).Where("issue_id = ?", issueID).Get(issue) + if err != nil { + return nil, err + } + + if !has { + return nil, ErrProjectIssueNotExist{IssueID: issueID} + } + + return issue, nil +} + +func (issue *ProjectIssue) LoadProjectColumn(ctx context.Context) error { + if issue.ProjectColumn != nil { + return nil + } + + var err error + + if issue.ProjectColumnID == 0 { + issue.ProjectColumn, err = issue.Project.GetDefaultColumn(ctx) + return err + } + + issue.ProjectColumn, err = GetColumn(ctx, issue.ProjectColumnID) + return err +} + // NumIssues return counter of all issues assigned to a project func (p *Project) NumIssues(ctx context.Context) int { c, err := db.GetEngine(ctx).Table("project_issue"). @@ -100,6 +146,41 @@ func MoveIssuesOnProjectColumn(ctx context.Context, column *Column, sortedIssueI }) } +func MoveIssueToColumnTail(ctx context.Context, issue *ProjectIssue, toColumn *Column) error { + nextSorting, err := toColumn.getNextSorting(ctx) + if err != nil { + return err + } + + return db.WithTx(ctx, func(ctx context.Context) error { + _, err = db.GetEngine(ctx).Exec("UPDATE `project_issue` SET project_board_id=?, sorting=? WHERE issue_id=?", + toColumn.ID, nextSorting, issue.IssueID) + + return err + }) +} + +func (c *Column) getNextSorting(ctx context.Context) (int64, error) { + res := struct { + MaxSorting int64 + IssueCount int64 + }{} + + if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as issue_count"). + Table("project_issue"). + Where("project_id=?", c.ProjectID). + And("project_board_id=?", c.ID). + Get(&res); err != nil { + return 0, err + } + + if res.IssueCount > 0 { + return res.MaxSorting + 1, nil + } + + return 0, nil +} + func (c *Column) moveIssuesToAnotherColumn(ctx context.Context, newColumn *Column) error { if c.ProjectID != newColumn.ProjectID { return fmt.Errorf("columns have to be in the same project") @@ -109,15 +190,8 @@ func (c *Column) moveIssuesToAnotherColumn(ctx context.Context, newColumn *Colum return nil } - res := struct { - MaxSorting int64 - IssueCount int64 - }{} - if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as issue_count"). - Table("project_issue"). - Where("project_id=?", newColumn.ProjectID). - And("project_board_id=?", newColumn.ID). - Get(&res); err != nil { + nextSorting, err := newColumn.getNextSorting(ctx) + if err != nil { return err } @@ -129,7 +203,6 @@ func (c *Column) moveIssuesToAnotherColumn(ctx context.Context, newColumn *Colum return nil } - nextSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0) return db.WithTx(ctx, func(ctx context.Context) error { for i, issue := range issues { issue.ProjectColumnID = newColumn.ID diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index fbada5472c..9e37426996 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1753,6 +1753,7 @@ issues.content_history.delete_from_history = Delete from history issues.content_history.delete_from_history_confirm = Delete from history? issues.content_history.options = Options issues.reference_link = Reference: %s +issues.move_project_boad = Status compare.compare_base = base compare.compare_head = compare diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index e7ad02c0c2..71f876c85d 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -2046,6 +2046,17 @@ func ViewIssue(ctx *context.Context) { return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee) } + canWriteProjects := ctx.Repo.Permission.CanWrite(unit.TypeProjects) + ctx.Data["CanWriteProjects"] = canWriteProjects + + if canWriteProjects && issue.Project != nil { + ctx.Data["ProjectColumns"], err = issue.Project.GetColumns(ctx) + if err != nil { + ctx.ServerError("Project.GetBoards", err) + return + } + } + ctx.HTML(http.StatusOK, tplIssueView) } diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index 2e32f478aa..a9f07e7dc4 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -579,6 +579,72 @@ func SetDefaultProjectColumn(ctx *context.Context) { ctx.JSONOK() } +// MoveColumnForIssue move a issue to other board +func MoveColumnForIssue(ctx *context.Context) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return + } + + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return + } + + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound("GetIssueByIndex", err) + } else { + ctx.ServerError("GetIssueByIndex", err) + } + return + } + + if err := issue.LoadProject(ctx); err != nil { + ctx.ServerError("LoadProject", err) + return + } + if issue.Project == nil { + ctx.NotFound("Project not found", nil) + return + } + + if err = issue.LoadProjectIssue(ctx); err != nil { + ctx.ServerError("LoadProjectIssue", err) + return + } + + column, err := project_model.GetColumn(ctx, ctx.ParamsInt64(":columnID")) + if err != nil { + if project_model.IsErrProjectColumnNotExist(err) { + ctx.NotFound("ErrProjectColumnNotExist", nil) + } else { + ctx.ServerError("GetColumn", err) + } + return + } + + if column.ProjectID != issue.Project.ID { + ctx.NotFound("ColumnNotInProject", nil) + return + } + + err = project_model.MoveIssueToColumnTail(ctx, issue.ProjectIssue, column) + if err != nil { + ctx.NotFound("MoveIssueToBoardTail", nil) + return + } + + issue.Repo = ctx.Repo.Repository + + ctx.JSONRedirect(issue.HTMLURL()) +} + // MoveIssues moves or keeps issues in a column and sorts them inside that column func MoveIssues(ctx *context.Context) { if ctx.Doer == nil { diff --git a/routers/web/web.go b/routers/web/web.go index 5fb1ce0e80..acfc3087b7 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1212,6 +1212,7 @@ func registerRoutes(m *web.Route) { m.Post("/lock", reqRepoIssuesOrPullsWriter, web.Bind(forms.IssueLockForm{}), repo.LockIssue) m.Post("/unlock", reqRepoIssuesOrPullsWriter, repo.UnlockIssue) m.Post("/delete", reqRepoAdmin, repo.DeleteIssue) + m.Post("/move_project_column/{columnID}", repo.MoveColumnForIssue) }, context.RepoMustNotBeArchived()) m.Group("/{index}", func() { diff --git a/templates/repo/issue/view_content/sidebar.tmpl b/templates/repo/issue/view_content/sidebar.tmpl index ce34c5e939..7ed3b95fad 100644 --- a/templates/repo/issue/view_content/sidebar.tmpl +++ b/templates/repo/issue/view_content/sidebar.tmpl @@ -193,13 +193,25 @@ {{end}} -
+
{{ctx.Locale.Tr "repo.issues.new.no_projects"}}
{{if .Issue.Project}} - + {{svg .Issue.Project.IconName 18 "tw-mr-2"}}{{.Issue.Project.Title}} + {{end}}
diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js index d53c3346f3..40f4311b7b 100644 --- a/web_src/js/features/repo-issue.js +++ b/web_src/js/features/repo-issue.js @@ -737,3 +737,29 @@ export function initArchivedLabelHandler() { toggleElem(label, label.classList.contains('checked')); } } + +export function initIssueProjectColumnSelector() { + const root = document.querySelector('.select-issue-project-board'); + if (!root) return; + + const link = root.getAttribute('data-url'); + + for (const board of document.querySelectorAll('.select-issue-project-board .item')) { + board.addEventListener('click', async (e) => { + e.preventDefault(); + e.stopImmediatePropagation(); + + try { + const response = await POST(`${link}${board.getAttribute('data-board-id')}`); + if (response.ok) { + const data = await response.json(); + window.location.href = data.redirect; + } + } catch (error) { + console.error(error); + } + + return false; + }); + } +} diff --git a/web_src/js/features/repo-legacy.js b/web_src/js/features/repo-legacy.js index de4f611b5d..459c9dc17e 100644 --- a/web_src/js/features/repo-legacy.js +++ b/web_src/js/features/repo-legacy.js @@ -4,6 +4,7 @@ import { initRepoIssueComments, initRepoIssueDependencyDelete, initRepoIssueReferenceIssue, initRepoIssueTitleEdit, initRepoIssueWipToggle, initRepoPullRequestUpdate, updateIssuesMeta, initIssueTemplateCommentEditors, initSingleCommentEditor, + initIssueProjectColumnSelector, } from './repo-issue.js'; import {initUnicodeEscapeButton} from './repo-unicode-escape.js'; import {svg} from '../svg.js'; @@ -393,6 +394,7 @@ export function initRepository() { initRepoIssueCodeCommentCancel(); initRepoPullRequestUpdate(); initCompReactionSelector(); + initIssueProjectColumnSelector(); initRepoPullRequestMergeForm(); initRepoPullRequestCommitStatus();