From cfa01d6b013fb9f9ce44021b80f5987192186808 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim-Niclas=20Oelschl=C3=A4ger?= Date: Sat, 3 Feb 2024 17:45:32 +0100 Subject: [PATCH 01/46] feat(board-notes): add, edit and delete of BoardNotes --- models/project/board.go | 20 ++++ models/project/note.go | 159 +++++++++++++++++++++++++++ models/project/project.go | 19 ++++ routers/web/repo/projects.go | 120 ++++++++++++++++++++ routers/web/web.go | 11 ++ services/forms/repo_form.go | 6 + templates/projects/view.tmpl | 46 +++++++- templates/repo/note.tmpl | 71 ++++++++++++ web_src/css/features/projects.css | 4 + web_src/css/index.css | 1 + web_src/css/repo/note-card.css | 21 ++++ web_src/js/features/repo-projects.js | 100 +++++++++++++++-- 12 files changed, 566 insertions(+), 12 deletions(-) create mode 100644 models/project/note.go create mode 100644 templates/repo/note.tmpl create mode 100644 web_src/css/repo/note-card.css diff --git a/models/project/board.go b/models/project/board.go index 3e2d8e0472c5..d49d59e8ffd5 100644 --- a/models/project/board.go +++ b/models/project/board.go @@ -82,6 +82,26 @@ func (b *Board) NumIssues(ctx context.Context) int { return int(c) } +// NumNotes return counter of all notes assigned to the board +func (b *Board) NumNotes(ctx context.Context) int { + c, err := db.GetEngine(ctx).Table("board_note"). + And("board_id=?", b.ID). + GroupBy("id"). + Cols("id"). + Count() + if err != nil { + return 0 + } + return int(c) +} + +// NumIssuesAndNotes return counter of all issues and notes assigned to the board +func (b *Board) NumIssuesAndNotes(ctx context.Context) int { + numIssues := b.NumIssues(ctx) + numNotes := b.NumNotes(ctx) + return numIssues + numNotes +} + func init() { db.RegisterModel(new(Board)) } diff --git a/models/project/note.go b/models/project/note.go new file mode 100644 index 000000000000..855efddf62bf --- /dev/null +++ b/models/project/note.go @@ -0,0 +1,159 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package project + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/builder" +) + +// BoardNote is used to represent a note on a boards +type BoardNote struct { + ID int64 `xorm:"pk autoincr"` + Title string `xorm:"TEXT NOT NULL"` + Content string `xorm:"LONGTEXT"` + + BoardID int64 `xorm:"INDEX NOT NULL"` + CreatorID int64 `xorm:"NOT NULL"` + Creator *user_model.User `xorm:"-"` + + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` +} + +type BoardNoteList = []*BoardNote + +// NotesOptions represents options of an note. +type NotesOptions struct { //nolint + db.Paginator + BoardID int64 +} + +func init() { + db.RegisterModel(new(BoardNote)) +} + +// GetBoardNoteById load notes assigned to the boards +func GetBoardNoteById(ctx context.Context, noteID int64) (*BoardNote, error) { + note := new(BoardNote) + + has, err := db.GetEngine(ctx).ID(noteID).Get(note) + if err != nil { + return nil, err + } else if !has { + return nil, ErrProjectBoardNoteNotExist{BoardNoteID: noteID} + } + + return note, nil +} + +// LoadNotesFromBoardList load notes assigned to the boards +func (p *Project) LoadNotesFromBoardList(ctx context.Context, bs BoardList) (map[int64]BoardNoteList, error) { + notesMap := make(map[int64]BoardNoteList, len(bs)) + for i := range bs { + il, err := LoadNotesFromBoard(ctx, bs[i]) + if err != nil { + return nil, err + } + notesMap[bs[i].ID] = il + } + return notesMap, nil +} + +// LoadNotesFromBoard load notes assigned to this board +func LoadNotesFromBoard(ctx context.Context, board *Board) (BoardNoteList, error) { + noteList := make(BoardNoteList, 0, 5) + + if board.ID != 0 { + notes, err := BoardNotes(ctx, &NotesOptions{ + BoardID: board.ID, + }) + if err != nil { + return nil, err + } + noteList = notes + } + + return noteList, nil +} + +// BoardNotes returns a list of notes by given conditions. +func BoardNotes(ctx context.Context, opts *NotesOptions) (BoardNoteList, error) { + sess := db.GetEngine(ctx) + + if opts.BoardID != 0 { + if opts.BoardID > 0 { + sess.Where(builder.Eq{"board_id": opts.BoardID}) + } else { + sess.Where(builder.Eq{"board_id": 0}) + } + } + + notes := BoardNoteList{} + if err := sess.Find(¬es); err != nil { + return nil, fmt.Errorf("unable to query Notes: %w", err) + } + + for _, note := range notes { + creator := new(user_model.User) + has, err := db.GetEngine(ctx).ID(note.CreatorID).Get(creator) + if err != nil { + return nil, err + } + if !has { + return nil, user_model.ErrUserNotExist{UID: note.CreatorID} + } + note.Creator = creator + } + + return notes, nil +} + +// NewBoardNote adds a new note to a given board +func NewBoardNote(ctx context.Context, note *BoardNote) error { + _, err := db.GetEngine(ctx).Insert(note) + return err +} + +// GetLastEventTimestamp returns the last user visible event timestamp, either the creation of this issue or the close. +func (note *BoardNote) GetLastEventTimestamp() timeutil.TimeStamp { + return max(note.CreatedUnix, note.UpdatedUnix) +} + +// GetLastEventLabel returns the localization label for the current note. +func (note *BoardNote) GetLastEventLabel() string { + if note.UpdatedUnix > note.CreatedUnix { + return "repo.projects.note.edited_by" + } + return "repo.projects.note.created_by" +} + +// UpdateBoardNote changes a BoardNote +func UpdateBoardNote(ctx context.Context, note *BoardNote) error { + var fieldToUpdate []string + + if note.Title != "" { + fieldToUpdate = append(fieldToUpdate, "title") + } + + fieldToUpdate = append(fieldToUpdate, "content") + + _, err := db.GetEngine(ctx).ID(note.ID).Cols(fieldToUpdate...).Update(note) + + return err +} + +// DeleteBoardNote removes the BoardNote from the project board. +func DeleteBoardNote(ctx context.Context, boardNote *BoardNote) error { + if _, err := db.GetEngine(ctx).Delete(boardNote); err != nil { + return err + } + return nil +} diff --git a/models/project/project.go b/models/project/project.go index d2fca6cdc8a8..eca49f85a0d1 100644 --- a/models/project/project.go +++ b/models/project/project.go @@ -85,6 +85,25 @@ func (err ErrProjectBoardNotExist) Unwrap() error { return util.ErrNotExist } +// ErrProjectBoardNoteNotExist represents a "ProjectBoardNotExist" kind of error. +type ErrProjectBoardNoteNotExist struct { + BoardNoteID int64 +} + +// IsErrProjectBoardNoteNotExist checks if an error is a ErrProjectBoardNoteNotExist +func IsErrProjectBoardNoteNotExist(err error) bool { + _, ok := err.(ErrProjectBoardNoteNotExist) + return ok +} + +func (err ErrProjectBoardNoteNotExist) Error() string { + return fmt.Sprintf("project board-note does not exist [id: %d]", err.BoardNoteID) +} + +func (err ErrProjectBoardNoteNotExist) Unwrap() error { + return util.ErrNotExist +} + // Project represents a project board type Project struct { ID int64 `xorm:"pk autoincr"` diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index 4908bb796d9d..c2c9ccc410b1 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -324,6 +324,12 @@ func ViewProject(ctx *context.Context) { return } + notesMap, err := project.LoadNotesFromBoardList(ctx, boards) + if err != nil { + ctx.ServerError("LoadNotesOfBoards", err) + return + } + if project.CardType != project_model.CardTypeTextOnly { issuesAttachmentMap := make(map[int64][]*attachment_model.Attachment) for _, issuesList := range issuesMap { @@ -376,6 +382,7 @@ func ViewProject(ctx *context.Context) { ctx.Data["Project"] = project ctx.Data["IssuesMap"] = issuesMap ctx.Data["Columns"] = boards // TODO: rename boards to columns in backend + ctx.Data["NotesMap"] = notesMap ctx.HTML(http.StatusOK, tplProjectsView) } @@ -698,3 +705,116 @@ func MoveIssues(ctx *context.Context) { ctx.JSONOK() } + +func checkProjectBoardNoteChangePermissions(ctx *context.Context) (*project_model.Project, *project_model.BoardNote) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return nil, nil + } + + 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 nil, nil + } + + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetProjectByID", err) + } + return nil, nil + } + + note, err := project_model.GetBoardNoteById(ctx, ctx.ParamsInt64(":noteID")) + if err != nil { + if project_model.IsErrProjectBoardNoteNotExist(err) { + ctx.NotFound("", nil) + } else { + ctx.ServerError("GetBoardNoteById", err) + } + return nil, nil + } + boardID := ctx.ParamsInt64(":boardID") + if note.BoardID != boardID { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectBoardNote[%d] is not in Board[%d] as expected", note.ID, boardID), + }) + return nil, nil + } + + if project.RepoID != ctx.Repo.Repository.ID { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectBoard[%d] is not in Repository[%d] as expected", note.ID, ctx.Repo.Repository.ID), + }) + return nil, nil + } + return project, note +} + +func AddNoteToBoard(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.BoardNoteForm) + 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 + } + + if err := project_model.NewBoardNote(ctx, &project_model.BoardNote{ + Title: form.Title, + Content: form.Content, + + BoardID: ctx.ParamsInt64(":boardID"), + CreatorID: ctx.Doer.ID, + }); err != nil { + ctx.ServerError("NewProjectBoard", err) + return + } + + ctx.JSONOK() +} + +func EditBoardNote(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.BoardNoteForm) + _, note := checkProjectBoardNoteChangePermissions(ctx) + if ctx.Written() { + return + } + + if form.Title != "" { + note.Title = form.Title + } + + if form.Content != "" { + note.Content = form.Content + } + + if err := project_model.UpdateBoardNote(ctx, note); err != nil { + ctx.ServerError("UpdateProjectBoardNote", err) + return + } + + ctx.JSONOK() +} + +func DeleteBoardNote(ctx *context.Context) { + _, boardNote := checkProjectBoardNoteChangePermissions(ctx) + + if err := project_model.DeleteBoardNote(ctx, boardNote); err != nil { + ctx.ServerError("DeleteBoardNote", err) + return + } + + ctx.JSONOK() +} + +func MoveBoardNote(ctx *context.Context) { + + ctx.JSONOK() +} diff --git a/routers/web/web.go b/routers/web/web.go index 92cf5132b45b..c2cf620c175f 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1341,6 +1341,17 @@ func registerRoutes(m *web.Route) { m.Post("/unsetdefault", repo.UnSetDefaultProjectBoard) m.Post("/move", repo.MoveIssues) + + m.Group("/note", func() { + m.Post("", web.Bind(forms.BoardNoteForm{}), repo.AddNoteToBoard) + + m.Group("/{noteID}", func() { + m.Put("", web.Bind(forms.BoardNoteForm{}), repo.EditBoardNote) + m.Delete("", repo.DeleteBoardNote) + + m.Post("/move", repo.MoveBoardNote) + }) + }) }) }) }, reqRepoProjectsWriter, context.RepoMustNotBeArchived()) diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 845eccf817d3..c412ada44037 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -533,6 +533,12 @@ type EditProjectBoardForm struct { Color string `binding:"MaxSize(7)"` } +// BoardNoteForm is a form for editing/creating a note to a board +type BoardNoteForm struct { + Title string `binding:"Required;MaxSize(255)"` + Content string +} + // _____ .__.__ __ // / \ |__| | ____ _______/ |_ ____ ____ ____ // / \ / \| | | _/ __ \ / ___/\ __\/ _ \ / \_/ __ \ diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl index b3ad03c35498..f34d2461dc07 100644 --- a/templates/projects/view.tmpl +++ b/templates/projects/view.tmpl @@ -70,7 +70,7 @@
- {{.NumIssues ctx}} + {{.NumIssuesAndNotes ctx}}
{{.Title}}
@@ -80,6 +80,12 @@ {{svg "octicon-kebab-horizontal"}}