diff --git a/models/issues/issue.go b/models/issues/issue.go index 87c1c86eb1..74c5be1db3 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -140,6 +140,8 @@ type Issue struct { // For view issue page. ShowRole RoleDescriptor `xorm:"-"` + + ProjectIssue *project_model.ProjectIssue `xorm:"-"` } var ( @@ -315,6 +317,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 907a5a17b9..9f72e10cd1 100644 --- a/models/issues/issue_project.go +++ b/models/issues/issue_project.go @@ -28,6 +28,23 @@ 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 + } + + return issue.ProjectIssue.LoadProjectBoard(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/board.go b/models/project/board.go index 5f142a356c..463eb96da9 100644 --- a/models/project/board.go +++ b/models/project/board.go @@ -69,7 +69,7 @@ func (Board) TableName() string { } // NumIssues return counter of all issues assigned to the board -func (b *Board) NumIssues(ctx context.Context) int { +func (b *Board) NumIssues(ctx context.Context) (int64, error) { c, err := db.GetEngine(ctx).Table("project_issue"). Where("project_id=?", b.ProjectID). And("project_board_id=?", b.ID). @@ -77,9 +77,9 @@ func (b *Board) NumIssues(ctx context.Context) int { Cols("issue_id"). Count() if err != nil { - return 0 + return 0, err } - return int(c) + return c, nil } func init() { @@ -219,6 +219,19 @@ func GetBoard(ctx context.Context, boardID int64) (*Board, error) { return board, nil } +// GetBoard fetches the current default board of a project +func GetDefaultBoard(ctx context.Context, projectID int64) (*Board, error) { + board := new(Board) + has, err := db.GetEngine(ctx).Where("project_id = ? AND `default` = ?", projectID, true).Get(board) + if err != nil { + return nil, err + } else if !has { + return nil, ErrProjectBoardNotExist{BoardID: -1} + } + + return board, nil +} + // UpdateBoard updates a project board func UpdateBoard(ctx context.Context, board *Board) error { var fieldToUpdate []string diff --git a/models/project/issue.go b/models/project/issue.go index ebc9719de5..7c60cee04b 100644 --- a/models/project/issue.go +++ b/models/project/issue.go @@ -18,7 +18,8 @@ type ProjectIssue struct { //revive:disable-line:exported ProjectID int64 `xorm:"INDEX"` // If 0, then it has not been added to a specific board in the project - ProjectBoardID int64 `xorm:"INDEX"` + ProjectBoardID int64 `xorm:"INDEX"` + ProjectBoard *Board `xorm:"-"` // the sorting order on the board Sorting int64 `xorm:"NOT NULL DEFAULT 0"` @@ -33,6 +34,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) LoadProjectBoard(ctx context.Context) error { + if issue.ProjectBoard != nil { + return nil + } + + var err error + + if issue.ProjectBoardID == 0 { + issue.ProjectBoard, err = GetDefaultBoard(ctx, issue.ProjectID) + return err + } + + issue.ProjectBoard, err = GetBoard(ctx, issue.ProjectBoardID) + 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"). @@ -102,6 +147,27 @@ func MoveIssuesOnProjectBoard(ctx context.Context, board *Board, sortedIssueIDs }) } +func MoveIssueToBoardTail(ctx context.Context, issue *ProjectIssue, toBoard *Board) error { + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + num, err := toBoard.NumIssues(ctx) + if err != nil { + return err + } + + _, err = db.GetEngine(ctx).Exec("UPDATE `project_issue` SET project_board_id=?, sorting=? WHERE issue_id=?", + toBoard.ID, num, issue.IssueID) + if err != nil { + return err + } + + return committer.Commit() +} + func (b *Board) removeIssues(ctx context.Context) error { _, err := db.GetEngine(ctx).Exec("UPDATE `project_issue` SET project_board_id = 0 WHERE project_board_id = ? ", b.ID) return err diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index c602aba53d..c6263c3788 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1749,6 +1749,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 1364d75676..7a39830d64 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -2044,6 +2044,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["ProjectBoards"], err = issue.Project.GetBoards(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 9b765e89e8..39e4925db8 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -574,6 +574,72 @@ func SetDefaultProjectBoard(ctx *context.Context) { ctx.JSONOK() } +// MoveBoardForIssue move a issue to other board +func MoveBoardForIssue(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 + } + + board, err := project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID")) + if err != nil { + if project_model.IsErrProjectBoardNotExist(err) { + ctx.NotFound("ProjectBoardNotExist", nil) + } else { + ctx.ServerError("GetProjectBoard", err) + } + return + } + + if board.ProjectID != issue.Project.ID { + ctx.NotFound("BoardNotInProject", nil) + return + } + + err = project_model.MoveIssueToBoardTail(ctx, issue.ProjectIssue, board) + 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 8fa24a2824..e056418fe4 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1204,6 +1204,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_board/{boardID}", repo.MoveBoardForIssue) }, 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 bb0bb2cff3..d65fd12b35 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 2b2eed58bb..699fe24051 100644 --- a/web_src/js/features/repo-issue.js +++ b/web_src/js/features/repo-issue.js @@ -746,3 +746,29 @@ export function initArchivedLabelHandler() { toggleElem(label, label.classList.contains('checked')); } } + +export function initIssueProjectBoardSelector() { + 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 18d98c891d..ec478c125d 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, + initIssueProjectBoardSelector, } from './repo-issue.js'; import {initUnicodeEscapeButton} from './repo-unicode-escape.js'; import {svg} from '../svg.js'; @@ -182,7 +183,7 @@ export function initRepoCommentForm() { // 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-reviewers-modify' || selector === 'select-assignees-modify' || selector === 'select-issue-project-board') { return false; } @@ -222,7 +223,7 @@ export function initRepoCommentForm() { $(this).find('.octicon-check').addClass('tw-invisible'); }); - if (selector === 'select-reviewers-modify' || selector === 'select-assignees-modify') { + if (selector === 'select-reviewers-modify' || selector === 'select-assignees-modify' || selector === 'select-issue-project-board') { return false; } @@ -394,6 +395,7 @@ export function initRepository() { initRepoIssueCodeCommentCancel(); initRepoPullRequestUpdate(); initCompReactionSelector(); + initIssueProjectBoardSelector(); initRepoPullRequestMergeForm(); initRepoPullRequestCommitStatus();