Skip to content

Add project column choice option on issue sidebar #30617

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
6 changes: 6 additions & 0 deletions models/issues/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ type Issue struct {

// For view issue page.
ShowRole RoleDescriptor `xorm:"-"`

ProjectIssue *project_model.ProjectIssue `xorm:"-"`
}

var (
Expand Down Expand Up @@ -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
}
Expand Down
19 changes: 19 additions & 0 deletions models/issues/issue_project.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
99 changes: 86 additions & 13 deletions models/project/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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").
Expand Down Expand Up @@ -100,24 +146,52 @@ func MoveIssuesOnProjectColumn(ctx context.Context, column *Column, sortedIssueI
})
}

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")
func MoveIssueToColumnTail(ctx context.Context, issue *ProjectIssue, toColumn *Column) error {
nextSorting, err := toColumn.getNextSorting(ctx)
if err != nil {
return err
}

if c.ID == newColumn.ID {
return nil
}
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=?", newColumn.ProjectID).
And("project_board_id=?", newColumn.ID).
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")
}

if c.ID == newColumn.ID {
return nil
}

nextSorting, err := newColumn.getNextSorting(ctx)
if err != nil {
return err
}

Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -1752,6 +1752,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
Expand Down
11 changes: 11 additions & 0 deletions routers/web/repo/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
66 changes: 66 additions & 0 deletions routers/web/repo/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions routers/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
16 changes: 14 additions & 2 deletions templates/repo/issue/view_content/sidebar.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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_column/">
<span class="text">
{{ctx.Locale.Tr "repo.issues.move_project_boad"}}: {{.Issue.ProjectIssue.ProjectColumn.Title}}
</span>
<div class="menu">
{{if .ProjectColumns}}
{{range .ProjectColumns}}
<div class="item no-select" data-project-id="{{.ProjectID}}" data-board-id="{{.ID}}">{{.Title}}</div>
{{end}}
{{end}}
</div>
</div>
{{end}}
</div>
</div>
Expand Down
26 changes: 26 additions & 0 deletions web_src/js/features/repo-issue.js
Original file line number Diff line number Diff line change
Expand Up @@ -735,3 +735,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;
});
}
}
2 changes: 2 additions & 0 deletions web_src/js/features/repo-legacy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -393,6 +394,7 @@ export function initRepository() {
initRepoIssueCodeCommentCancel();
initRepoPullRequestUpdate();
initCompReactionSelector();
initIssueProjectColumnSelector();

initRepoPullRequestMergeForm();
initRepoPullRequestCommitStatus();
Expand Down