From 52378bbaf46ab7257f17804687c54205bf36dde1 Mon Sep 17 00:00:00 2001 From: Patrick Aljord Date: Wed, 29 Jul 2020 01:30:17 +0200 Subject: [PATCH 1/4] make it possible to order boards and issues in projects --- models/issue.go | 6 +- models/project.go | 25 +++++ models/project_board.go | 53 +++++++---- models/project_issue.go | 10 +- modules/auth/repo_form.go | 20 ++++ routers/repo/projects.go | 33 ++++++- routers/routes/routes.go | 2 + templates/repo/projects/view.tmpl | 31 +++--- web_src/js/features/projects.js | 151 +++++++++++++++++++++++------- 9 files changed, 259 insertions(+), 72 deletions(-) diff --git a/models/issue.go b/models/issue.go index ee75623f53025..407702f865bc2 100644 --- a/models/issue.go +++ b/models/issue.go @@ -1180,14 +1180,14 @@ func (opts *IssuesOptions) setupSession(sess *xorm.Session) { if opts.ProjectID > 0 { sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id"). - And("project_issue.project_id=?", opts.ProjectID) + And("project_issue.project_id=?", opts.ProjectID).OrderBy("priority") } if opts.ProjectBoardID != 0 { if opts.ProjectBoardID > 0 { - sess.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.Eq{"project_board_id": opts.ProjectBoardID})) + sess.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.Eq{"project_board_id": opts.ProjectBoardID}).OrderBy("priority")) } else { - sess.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.Eq{"project_board_id": 0})) + sess.In("issue.id", builder.Select("issue_id").From("project_issue").Where(builder.Eq{"project_board_id": 0}).OrderBy("priority")) } } diff --git a/models/project.go b/models/project.go index e032da351dde4..c135184655c5e 100644 --- a/models/project.go +++ b/models/project.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" @@ -305,3 +306,27 @@ func deleteProjectByID(e Engine, id int64) error { return updateRepositoryProjectCount(e, p.RepoID) } + +// Update given boards priority for a project +func UpdateBoards(boards []ProjectBoard) error { + for _, board := range boards { + if _, err := x.ID(board.ID).Cols("priority").Update(&board); err != nil { + log.Info("failed updating board priorities %s", err) + return err + } + + } + return nil +} + +// Update given issue priority and column +func UpdateBoardIssues(issues []ProjectIssue) error { + for _, issue := range issues { + if _, err := x.ID(issue.ID).Cols("priority", "project_board_id").Update(&issue); err != nil { + log.Info("failed updating cards priorities %s", err) + return err + } + + } + return nil +} diff --git a/models/project_board.go b/models/project_board.go index 260fc8304b22e..5385bacdae8c0 100644 --- a/models/project_board.go +++ b/models/project_board.go @@ -5,6 +5,7 @@ package models import ( + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" @@ -32,9 +33,10 @@ const ( // ProjectBoard is used to represent boards on a project type ProjectBoard struct { - ID int64 `xorm:"pk autoincr"` - Title string - Default bool `xorm:"NOT NULL DEFAULT false"` // issues not assigned to a specific board will be assigned to this board + ID int64 `xorm:"pk autoincr"` + Title string + Default bool `xorm:"NOT NULL DEFAULT false"` // issues not assigned to a specific board will be assigned to this board + Priority int ProjectID int64 `xorm:"INDEX NOT NULL"` CreatorID int64 `xorm:"NOT NULL"` @@ -42,7 +44,8 @@ type ProjectBoard struct { CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` - Issues []*Issue `xorm:"-"` + Issues []*Issue `xorm:"-"` + ProjectIssues []*ProjectIssue `xorm:"-"` } // IsProjectBoardTypeValid checks if the project board type is valid @@ -174,7 +177,7 @@ func GetProjectBoards(projectID int64) ([]*ProjectBoard, error) { var boards = make([]*ProjectBoard, 0, 5) - sess := x.Where("project_id=?", projectID) + sess := x.Where("project_id=?", projectID).OrderBy("priority") return boards, sess.Find(&boards) } @@ -188,33 +191,43 @@ func GetUncategorizedBoard(projectID int64) (*ProjectBoard, error) { } // LoadIssues load issues assigned to this board -func (b *ProjectBoard) LoadIssues() (IssueList, error) { +func (b *ProjectBoard) LoadProjectIssues() ([]*ProjectIssue, error) { var boardID int64 if !b.Default { boardID = b.ID } else { // Issues without ProjectBoardID - boardID = -1 + boardID = 0 } - issues, err := Issues(&IssuesOptions{ - ProjectBoardID: boardID, - ProjectID: b.ProjectID, - }) - b.Issues = issues - return issues, err + var projectIssuesDB []*ProjectIssue + var projectIssues []*ProjectIssue + err := x.Table("project_issue").Where("project_board_id = ?", boardID). + OrderBy("priority").Find(&projectIssuesDB) + for _, projectIssue := range projectIssuesDB { + if issue, err := getIssueByID(x, projectIssue.IssueID); err != nil { + log.Info("failed getting projectIssue's issue %v\n", err) + } else { + issue.LoadLabels() + issue.LoadMilestone() + issue.loadAssignees(x) + projectIssue.Issue = issue + projectIssues = append(projectIssues, projectIssue) + } + } + + b.ProjectIssues = projectIssues + return projectIssues, err } // LoadIssues load issues assigned to the boards -func (bs ProjectBoardList) LoadIssues() (IssueList, error) { - issues := make(IssueList, 0, len(bs)*10) +func (bs ProjectBoardList) LoadIssues() error { for i := range bs { - il, err := bs[i].LoadIssues() + il, err := bs[i].LoadProjectIssues() if err != nil { - return nil, err + return err } - bs[i].Issues = il - issues = append(issues, il...) + bs[i].ProjectIssues = il } - return issues, nil + return nil } diff --git a/models/project_issue.go b/models/project_issue.go index c41bfe5158679..4c88e111f8b39 100644 --- a/models/project_issue.go +++ b/models/project_issue.go @@ -12,9 +12,13 @@ import ( // ProjectIssue saves relation from issue to a project type ProjectIssue struct { - 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"` + IssueTitle string `xorm:"-"` + IssueIsPull bool `xorm:"-"` + Priority int + Issue *Issue `xorm:"-"` // If 0, then it has not been added to a specific board in the project ProjectBoardID int64 `xorm:"INDEX"` diff --git a/modules/auth/repo_form.go b/modules/auth/repo_form.go index 039b0cb583a09..806cf1b2e42b7 100644 --- a/modules/auth/repo_form.go +++ b/modules/auth/repo_form.go @@ -759,3 +759,23 @@ type DeadlineForm struct { func (f *DeadlineForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors { return validate(errs, ctx.Data, f, ctx.Locale) } + +// UpdateBoardPriorityForm form for updating cards on drag and drop +type UpdateBoardPriorityForm struct { + Boards []models.ProjectBoard `form:"boards" json:"boards"` +} + +// Validate validates the fields +func (f *UpdateBoardPriorityForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors { + return validate(errs, ctx.Data, f, ctx.Locale) +} + +// UpdateIssuePriorityBoardForm form for updating issue on drag and drop +type UpdateIssuePriorityBoardForm struct { + Issues []models.ProjectIssue `form:"issues" json:"issues"` +} + +// Validate validates the fields +func (f *UpdateIssuePriorityBoardForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors { + return validate(errs, ctx.Data, f, ctx.Locale) +} diff --git a/routers/repo/projects.go b/routers/repo/projects.go index 07327df9eb06d..5297a0cf1ebb0 100644 --- a/routers/repo/projects.go +++ b/routers/repo/projects.go @@ -286,7 +286,7 @@ func ViewProject(ctx *context.Context) { allBoards := models.ProjectBoardList{uncategorizedBoard} allBoards = append(allBoards, boards...) - if ctx.Data["Issues"], err = allBoards.LoadIssues(); err != nil { + if err = allBoards.LoadIssues(); err != nil { ctx.ServerError("LoadIssuesOfBoards", err) return } @@ -598,3 +598,34 @@ func CreateProjectPost(ctx *context.Context, form auth.UserCreateProjectForm) { ctx.Flash.Success(ctx.Tr("repo.projects.create_success", form.Title)) ctx.Redirect(setting.AppSubURL + "/") } + +// UpdateBoardsPriorityPost takes a slice of boards and updates their priority order on drag and drop +func UpdateBoardsPriorityPost(ctx *context.Context, form auth.UpdateBoardPriorityForm) { + if ctx.Written() { + return + } + + if ctx.HasError() { + return + } + + boards := form + models.UpdateBoards(form.Boards) + ctx.JSON(200, boards) +} + +// UpdateBoardIssuePriority takes a slice of ProjectIssue and updates their priority order on drag and drop +func UpdateBoardIssuePriority(ctx *context.Context, form auth.UpdateIssuePriorityBoardForm) { + if ctx.Written() { + return + } + + if ctx.HasError() { + return + } + + issues := form + models.UpdateBoardIssues(form.Issues) + + ctx.JSON(200, issues) +} diff --git a/routers/routes/routes.go b/routers/routes/routes.go index 285510dbe355f..f47e79d895110 100644 --- a/routers/routes/routes.go +++ b/routers/routes/routes.go @@ -956,9 +956,11 @@ func RegisterRoutes(m *macaron.Macaron) { m.Get("/edit", repo.EditProject) m.Post("/edit", bindIgnErr(auth.CreateProjectForm{}), repo.EditProjectPost) + m.Put("/updateIssuesPriorities", bindIgnErr(auth.UpdateIssuePriorityBoardForm{}), repo.UpdateBoardIssuePriority) m.Post("/^:action(open|close)$", repo.ChangeProjectStatus) m.Group("/:boardID", func() { + m.Put("updatePriorities", bindIgnErr(auth.UpdateBoardPriorityForm{}), repo.UpdateBoardsPriorityPost) m.Put("", bindIgnErr(auth.EditProjectBoardTitleForm{}), repo.EditProjectBoardTitle) m.Delete("", repo.DeleteProjectBoard) diff --git a/templates/repo/projects/view.tmpl b/templates/repo/projects/view.tmpl index ee82f24010241..20ba86e0b65c8 100644 --- a/templates/repo/projects/view.tmpl +++ b/templates/repo/projects/view.tmpl @@ -69,10 +69,11 @@
-
+
{{ range $board := .Boards }} -
+
{{.Title}}
{{if and $.CanWriteProjects (not $.Repository.IsArchived) $.PageIsProjects (ne .ID 0)}} @@ -135,32 +136,36 @@
-
+
- {{ range .Issues }} + {{ range .ProjectIssues }} -
+
- - {{if .IsPull}}{{svg "octicon-git-merge"}} - {{else if .IsClosed}}{{svg "octicon-issue-closed"}} + + {{if .Issue.IsPull}}{{svg "octicon-git-merge"}} + {{else if .Issue.IsClosed}}{{svg "octicon-issue-closed"}} {{else}}{{svg "octicon-issue-opened"}} {{end}} - #{{.Index}} {{.Title}} + #{{.IssueID}} {{.Issue.Title}}
- {{ range .Labels }} + {{ range .Issue.Labels }} {{.Name}} {{ end }}
diff --git a/web_src/js/features/projects.js b/web_src/js/features/projects.js index 13318c9f89e56..3b7464dc3e696 100644 --- a/web_src/js/features/projects.js +++ b/web_src/js/features/projects.js @@ -1,50 +1,137 @@ -const {csrf} = window.config; +const { csrf } = window.config; export default async function initProject() { if (!window.config || !window.config.PageIsProjects) { return; } - const {Sortable} = await import(/* webpackChunkName: "sortable" */'sortablejs'); + const { Sortable } = await import( + /* webpackChunkName: "sortable" */ 'sortablejs' + ); const boardColumns = document.getElementsByClassName('board-column'); + const colContainer = document.getElementById('board-container'); + let projectURL = ''; + if (colContainer && colContainer.dataset) { + projectURL = colContainer.dataset.url; + } + $('.draggable-cards').each(function(i, eli) { + new Sortable(eli, { + group: 'shared', + filter: '.ignore-elements', + animation: 150, + // Element dragging ended + onEnd: function(/**Event*/ evt) { + var itemEl = evt.item; // dragged HTMLElement + let cardsFrom = []; + let cardsTo = []; + $(evt.from).each((i, v) => { + let column = $($(v)[0]).data(); + $(v) + .children() + .each((j, y) => { + let card = $(y).data(); + if ( + card && + card.id && + evt.oldDraggableIndex !== evt.newDraggableIndex + ) + cardsFrom.push({ + id: card.id, + priority: j, + ProjectBoardID: column.columnId, + }); + }); + }); - for (const column of boardColumns) { - new Sortable( - column.getElementsByClassName('board')[0], - { - group: 'shared', - animation: 150, - onAdd: (e) => { - $.ajax(`${e.to.dataset.url}/${e.item.dataset.issue}`, { - headers: { - 'X-Csrf-Token': csrf, - 'X-Remote': true, - }, - contentType: 'application/json', - type: 'POST', - error: () => { - e.from.insertBefore(e.item, e.from.children[e.oldIndex]); - }, + $(evt.to).each((i, v) => { + let column = $($(v)[0]).data(); + $(v) + .children() + .each((j, y) => { + let card = $(y).data(); + if (card && card.id) { + cardsTo.push({ + id: card.id, + priority: j, + ProjectBoardID: column.columnId, + }); + } + }); + }); + fetch(`${projectURL}/updateIssuesPriorities`, { + method: 'PUT', + headers: { + 'X-Csrf-Token': csrf, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + issues: cardsTo.concat(cardsFrom), + }), + }) + .then(function(res) { + return res.json(); + }) + .then(function(data) { + console.log(JSON.stringify(data)); }); - }, - } - ); - } + }, + }); + }); - $('.edit-project-board').each(function () { - const projectTitleLabel = $(this).closest('.board-column-header').find('.board-label'); + if (colContainer) { + new Sortable(colContainer, { + group: 'cols', + animation: 150, + filter: '.ignore-elements', + // Element dragging ended + onEnd: function(/**Event*/ evt) { + var itemEl = evt.item; // dragged HTMLElement + let columns = []; + $(evt.to).each((i, v) => { + $(v) + .children() + .each((j, y) => { + let column = $(y).data(); + if (column && column.columnId) { + columns.push({ + id: column.columnId, + priority: j, + }); + } + }); + }); + fetch(`${projectURL}/updatePriorities`, { + method: 'PUT', + headers: { 'X-Csrf-Token': csrf, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + boards: columns, + }), + }) + .then(function(res) { + return res.json(); + }) + .then(function(data) { + console.log(JSON.stringify(data)); + }); + }, + }); + } + $('.edit-project-board').each(function() { + const projectTitleLabel = $(this) + .closest('.board-column-header') + .find('.board-label'); const projectTitleInput = $(this).find( - '.content > .form > .field > .project-board-title' + '.content > .form > .field > .project-board-title', ); $(this) .find('.content > .form > .actions > .red') - .on('click', function (e) { + .on('click', function(e) { e.preventDefault(); $.ajax({ url: $(this).data('url'), - data: JSON.stringify({title: projectTitleInput.val()}), + data: JSON.stringify({ title: projectTitleInput.val() }), headers: { 'X-Csrf-Token': csrf, 'X-Remote': true, @@ -59,8 +146,8 @@ export default async function initProject() { }); }); - $('.delete-project-board').each(function () { - $(this).click(function (e) { + $('.delete-project-board').each(function() { + $(this).click(function(e) { e.preventDefault(); $.ajax({ @@ -77,14 +164,14 @@ export default async function initProject() { }); }); - $('#new_board_submit').click(function (e) { + $('#new_board_submit').click(function(e) { e.preventDefault(); const boardTitle = $('#new_board'); $.ajax({ url: $(this).data('url'), - data: JSON.stringify({title: boardTitle.val()}), + data: JSON.stringify({ title: boardTitle.val() }), headers: { 'X-Csrf-Token': csrf, 'X-Remote': true, From 68702e8d36933bb7ade9e4803d716f1508203794 Mon Sep 17 00:00:00 2001 From: Patrick Aljord Date: Thu, 30 Jul 2020 02:29:49 +0200 Subject: [PATCH 2/4] show issue details in projects/view --- models/project_board.go | 3 +- routers/repo/issue.go | 10 +- routers/routes/routes.go | 1 + .../repo/issue/view_content/sidebar.tmpl | 7 +- templates/repo/projects/view.tmpl | 4 +- web_src/js/index.js | 127 ++++++++++++++++-- web_src/less/_repository.less | 22 +++ 7 files changed, 156 insertions(+), 18 deletions(-) diff --git a/models/project_board.go b/models/project_board.go index 5385bacdae8c0..80be9f89d5f68 100644 --- a/models/project_board.go +++ b/models/project_board.go @@ -202,7 +202,8 @@ func (b *ProjectBoard) LoadProjectIssues() ([]*ProjectIssue, error) { } var projectIssuesDB []*ProjectIssue var projectIssues []*ProjectIssue - err := x.Table("project_issue").Where("project_board_id = ?", boardID). + err := x.Table("project_issue").Where("project_board_id = ? and project_id =?", + boardID, b.ProjectID). OrderBy("priority").Find(&projectIssuesDB) for _, projectIssue := range projectIssuesDB { if issue, err := getIssueByID(x, projectIssue.IssueID); err != nil { diff --git a/routers/repo/issue.go b/routers/repo/issue.go index 009af784e775a..1932cee92239d 100644 --- a/routers/repo/issue.go +++ b/routers/repo/issue.go @@ -48,6 +48,7 @@ const ( issueTemplateKey = "IssueTemplate" issueTemplateTitleKey = "IssueTemplateTitle" + tplSidebar = "repo/issue/view_content/sidebar" ) var ( @@ -1486,7 +1487,14 @@ func ViewIssue(ctx *context.Context) { ctx.Data["IsRepoAdmin"] = ctx.IsSigned && (ctx.Repo.IsAdmin() || ctx.User.IsAdmin) ctx.Data["LockReasons"] = setting.Repository.Issue.LockReasons ctx.Data["RefEndName"] = git.RefEndName(issue.Ref) - ctx.HTML(200, tplIssueView) + + if ctx.Params(":sidebar") == "true" { + ctx.Data["Sidebar"] = true + ctx.HTML(200, tplSidebar) + } else { + ctx.Data["Sidebar"] = false + ctx.HTML(200, tplIssueView) + } } // GetActionIssue will return the issue which is used in the context. diff --git a/routers/routes/routes.go b/routers/routes/routes.go index f47e79d895110..98dd34341e672 100644 --- a/routers/routes/routes.go +++ b/routers/routes/routes.go @@ -940,6 +940,7 @@ func RegisterRoutes(m *macaron.Macaron) { m.Group("", func() { m.Get("/^:type(issues|pulls)$", repo.Issues) m.Get("/^:type(issues|pulls)$/:index", repo.ViewIssue) + m.Get("/^:type(issues|pulls)$/:index/sidebar/:sidebar", repo.ViewIssue) m.Get("/labels/", reqRepoIssuesOrPullsReader, repo.RetrieveLabels, repo.Labels) m.Get("/milestones", reqRepoIssuesOrPullsReader, repo.Milestones) }, context.RepoRef()) diff --git a/templates/repo/issue/view_content/sidebar.tmpl b/templates/repo/issue/view_content/sidebar.tmpl index dad4f7e125bfb..046e0abf24c79 100644 --- a/templates/repo/issue/view_content/sidebar.tmpl +++ b/templates/repo/issue/view_content/sidebar.tmpl @@ -1,5 +1,8 @@
+ {{if eq .Sidebar true}} +

#{{.Issue.ID}} {{.Issue.Title}}

+ {{end}} {{template "repo/issue/branch_selector_field" .}} {{if .Issue.IsPull }} @@ -554,7 +557,7 @@
+ method="post" id="lock-form"> {{.CsrfTokenHtml}} {{ if not .Issue.IsLocked }} @@ -563,7 +566,7 @@
-