@@ -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 | |||
} |
@@ -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) |
@@ -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() { |
@@ -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,45 @@ 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 | |||
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 +142,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 |
@@ -1751,6 +1751,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 |
@@ -2042,6 +2042,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) | |||
} | |||
@@ -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 { |
@@ -1206,6 +1206,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() { |
@@ -193,13 +193,25 @@ | |||
{{end}} | |||
</div> | |||
</div> | |||
<div class="ui select-project list"> | |||
<div class="ui select-project-current list"> | |||
<span class="no-select item {{if .Issue.Project}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_projects"}}</span> | |||
<div class="selected"> | |||
{{if .Issue.Project}} | |||
<a class="item muted sidebar-item-link" href="{{.Issue.Project.Link ctx}}"> | |||
<a class="item muted sidebar-item-link tw-block" href="{{.Issue.Project.Link ctx}}"> | |||
{{svg .Issue.Project.IconName 18 "tw-mr-2"}}{{.Issue.Project.Title}} | |||
</a> | |||
<div class="ui dropdown jump {{if not .CanWriteProjects}}disabled{{end}} select-issue-project-board item tw-mx-0 tw-pr-2" data-url="{{$.Issue.Link}}/move_project_board/"> | |||
<span class="text"> | |||
{{ctx.Locale.Tr "repo.issues.move_project_boad"}}: {{.Issue.ProjectIssue.ProjectBoard.Title}} | |||
</span> | |||
<div class="menu"> | |||
{{if .ProjectBoards}} | |||
{{range .ProjectBoards}} | |||
<div class="item no-select" data-project-id="{{.ProjectID}}" data-board-id="{{.ID}}">{{.Title}}</div> | |||
{{end}} | |||
{{end}} | |||
</div> | |||
</div> | |||
{{end}} | |||
</div> | |||
</div> |
@@ -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; | |||
}); | |||
} | |||
} |
@@ -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'; | |||
@@ -394,6 +395,7 @@ export function initRepository() { | |||
initRepoIssueCodeCommentCancel(); | |||
initRepoPullRequestUpdate(); | |||
initCompReactionSelector(); | |||
initIssueProjectBoardSelector(); | |||
initRepoPullRequestMergeForm(); | |||
initRepoPullRequestCommitStatus(); |