From ebf253b841d56c5cb1e57cb1e5e50c06d315bdee Mon Sep 17 00:00:00 2001
From: KN4CK3R <admin@oldschoolhack.me>
Date: Tue, 15 Jun 2021 03:12:33 +0200
Subject: [PATCH] Add attachments for PR reviews (#16075)

* First step for multiple dropzones per page.

* Allow attachments on review comments.

* Lint.

* Fixed accidental initialize of the review textarea.

* Initialize SimpleMDE textarea.

Co-authored-by: techknowlogick <techknowlogick@gitea.io>
---
 models/issue_comment.go                       |  2 +
 models/review.go                              | 15 ++--
 routers/api/v1/repo/pull_review.go            |  4 +-
 routers/web/repo/pull.go                      |  4 +
 routers/web/repo/pull_review.go               |  8 +-
 services/forms/repo_form.go                   |  1 +
 services/pull/review.go                       |  6 +-
 templates/repo/diff/new_review.tmpl           |  5 ++
 templates/repo/editor/upload.tmpl             |  1 -
 templates/repo/issue/comment_tab.tmpl         |  1 -
 templates/repo/issue/view_content.tmpl        |  1 -
 .../repo/issue/view_content/comments.tmpl     |  3 +
 templates/repo/release/new.tmpl               |  1 -
 templates/repo/upload.tmpl                    |  5 +-
 web_src/js/index.js                           | 77 ++++++++++++-------
 15 files changed, 87 insertions(+), 47 deletions(-)

diff --git a/models/issue_comment.go b/models/issue_comment.go
index 26bf122dc9..1b98b248b1 100644
--- a/models/issue_comment.go
+++ b/models/issue_comment.go
@@ -762,6 +762,8 @@ func updateCommentInfos(e *xorm.Session, opts *CreateCommentOptions, comment *Co
 			}
 		}
 		fallthrough
+	case CommentTypeReview:
+		fallthrough
 	case CommentTypeComment:
 		if _, err = e.Exec("UPDATE `issue` SET num_comments=num_comments+1 WHERE id=?", opts.Issue.ID); err != nil {
 			return err
diff --git a/models/review.go b/models/review.go
index 343621c0fa..316cbe4da6 100644
--- a/models/review.go
+++ b/models/review.go
@@ -347,7 +347,7 @@ func IsContentEmptyErr(err error) bool {
 }
 
 // SubmitReview creates a review out of the existing pending review or creates a new one if no pending review exist
-func SubmitReview(doer *User, issue *Issue, reviewType ReviewType, content, commitID string, stale bool) (*Review, *Comment, error) {
+func SubmitReview(doer *User, issue *Issue, reviewType ReviewType, content, commitID string, stale bool, attachmentUUIDs []string) (*Review, *Comment, error) {
 	sess := x.NewSession()
 	defer sess.Close()
 	if err := sess.Begin(); err != nil {
@@ -419,12 +419,13 @@ func SubmitReview(doer *User, issue *Issue, reviewType ReviewType, content, comm
 	}
 
 	comm, err := createComment(sess, &CreateCommentOptions{
-		Type:     CommentTypeReview,
-		Doer:     doer,
-		Content:  review.Content,
-		Issue:    issue,
-		Repo:     issue.Repo,
-		ReviewID: review.ID,
+		Type:        CommentTypeReview,
+		Doer:        doer,
+		Content:     review.Content,
+		Issue:       issue,
+		Repo:        issue.Repo,
+		ReviewID:    review.ID,
+		Attachments: attachmentUUIDs,
 	})
 	if err != nil || comm == nil {
 		return nil, nil, err
diff --git a/routers/api/v1/repo/pull_review.go b/routers/api/v1/repo/pull_review.go
index 63179aa990..35414e0a80 100644
--- a/routers/api/v1/repo/pull_review.go
+++ b/routers/api/v1/repo/pull_review.go
@@ -359,7 +359,7 @@ func CreatePullReview(ctx *context.APIContext) {
 	}
 
 	// create review and associate all pending review comments
-	review, _, err := pull_service.SubmitReview(ctx.User, ctx.Repo.GitRepo, pr.Issue, reviewType, opts.Body, opts.CommitID)
+	review, _, err := pull_service.SubmitReview(ctx.User, ctx.Repo.GitRepo, pr.Issue, reviewType, opts.Body, opts.CommitID, nil)
 	if err != nil {
 		ctx.Error(http.StatusInternalServerError, "SubmitReview", err)
 		return
@@ -447,7 +447,7 @@ func SubmitPullReview(ctx *context.APIContext) {
 	}
 
 	// create review and associate all pending review comments
-	review, _, err = pull_service.SubmitReview(ctx.User, ctx.Repo.GitRepo, pr.Issue, reviewType, opts.Body, headCommitID)
+	review, _, err = pull_service.SubmitReview(ctx.User, ctx.Repo.GitRepo, pr.Issue, reviewType, opts.Body, headCommitID, nil)
 	if err != nil {
 		ctx.Error(http.StatusInternalServerError, "SubmitReview", err)
 		return
diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go
index 28f94c8417..e5554e9664 100644
--- a/routers/web/repo/pull.go
+++ b/routers/web/repo/pull.go
@@ -694,6 +694,10 @@ func ViewPullFiles(ctx *context.Context) {
 	getBranchData(ctx, issue)
 	ctx.Data["IsIssuePoster"] = ctx.IsSigned && issue.IsPoster(ctx.User.ID)
 	ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)
+
+	ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
+	upload.AddUploadContext(ctx, "comment")
+
 	ctx.HTML(http.StatusOK, tplPullFiles)
 }
 
diff --git a/routers/web/repo/pull_review.go b/routers/web/repo/pull_review.go
index 9e505c3db3..36eee3f377 100644
--- a/routers/web/repo/pull_review.go
+++ b/routers/web/repo/pull_review.go
@@ -12,6 +12,7 @@ import (
 	"code.gitea.io/gitea/modules/base"
 	"code.gitea.io/gitea/modules/context"
 	"code.gitea.io/gitea/modules/log"
+	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/web"
 	"code.gitea.io/gitea/services/forms"
 	pull_service "code.gitea.io/gitea/services/pull"
@@ -211,7 +212,12 @@ func SubmitReview(ctx *context.Context) {
 		}
 	}
 
-	_, comm, err := pull_service.SubmitReview(ctx.User, ctx.Repo.GitRepo, issue, reviewType, form.Content, form.CommitID)
+	var attachments []string
+	if setting.Attachment.Enabled {
+		attachments = form.Files
+	}
+
+	_, comm, err := pull_service.SubmitReview(ctx.User, ctx.Repo.GitRepo, issue, reviewType, form.Content, form.CommitID, attachments)
 	if err != nil {
 		if models.IsContentEmptyErr(err) {
 			ctx.Flash.Error(ctx.Tr("repo.issues.review.content.empty"))
diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go
index a40b0be9a7..71a83a8be3 100644
--- a/services/forms/repo_form.go
+++ b/services/forms/repo_form.go
@@ -587,6 +587,7 @@ type SubmitReviewForm struct {
 	Content  string
 	Type     string `binding:"Required;In(approve,comment,reject)"`
 	CommitID string
+	Files    []string
 }
 
 // Validate validates the fields
diff --git a/services/pull/review.go b/services/pull/review.go
index 4b647722fc..b07e21fad9 100644
--- a/services/pull/review.go
+++ b/services/pull/review.go
@@ -100,7 +100,7 @@ func CreateCodeComment(doer *models.User, gitRepo *git.Repository, issue *models
 
 	if !isReview && !existsReview {
 		// Submit the review we've just created so the comment shows up in the issue view
-		if _, _, err = SubmitReview(doer, gitRepo, issue, models.ReviewTypeComment, "", latestCommitID); err != nil {
+		if _, _, err = SubmitReview(doer, gitRepo, issue, models.ReviewTypeComment, "", latestCommitID, nil); err != nil {
 			return nil, err
 		}
 	}
@@ -215,7 +215,7 @@ func createCodeComment(doer *models.User, repo *models.Repository, issue *models
 }
 
 // SubmitReview creates a review out of the existing pending review or creates a new one if no pending review exist
-func SubmitReview(doer *models.User, gitRepo *git.Repository, issue *models.Issue, reviewType models.ReviewType, content, commitID string) (*models.Review, *models.Comment, error) {
+func SubmitReview(doer *models.User, gitRepo *git.Repository, issue *models.Issue, reviewType models.ReviewType, content, commitID string, attachmentUUIDs []string) (*models.Review, *models.Comment, error) {
 	pr, err := issue.GetPullRequest()
 	if err != nil {
 		return nil, nil, err
@@ -240,7 +240,7 @@ func SubmitReview(doer *models.User, gitRepo *git.Repository, issue *models.Issu
 		}
 	}
 
-	review, comm, err := models.SubmitReview(doer, issue, reviewType, content, commitID, stale)
+	review, comm, err := models.SubmitReview(doer, issue, reviewType, content, commitID, stale, attachmentUUIDs)
 	if err != nil {
 		return nil, nil, err
 	}
diff --git a/templates/repo/diff/new_review.tmpl b/templates/repo/diff/new_review.tmpl
index 9e65d6d420..cbaabe255e 100644
--- a/templates/repo/diff/new_review.tmpl
+++ b/templates/repo/diff/new_review.tmpl
@@ -15,6 +15,11 @@
 				<div class="ui field">
 					<textarea name="content" tabindex="0" rows="2" placeholder="{{$.i18n.Tr "repo.diff.review.placeholder"}}"></textarea>
 				</div>
+				{{if .IsAttachmentEnabled}}
+					<div class="field">
+						{{template "repo/upload" .}}
+					</div>
+				{{end}}
 				<div class="ui divider"></div>
 				<button type="submit" name="type" value="approve" {{ if and $.IsSigned ($.Issue.IsPoster $.SignedUser.ID) }} disabled {{ end }} class="ui submit green tiny button btn-submit">{{$.i18n.Tr "repo.diff.review.approve"}}</button>
 				<button type="submit" name="type" value="comment" class="ui submit tiny basic button btn-submit">{{$.i18n.Tr "repo.diff.review.comment"}}</button>
diff --git a/templates/repo/editor/upload.tmpl b/templates/repo/editor/upload.tmpl
index 488465120e..fb00615abd 100644
--- a/templates/repo/editor/upload.tmpl
+++ b/templates/repo/editor/upload.tmpl
@@ -26,7 +26,6 @@
 				</div>
 			</div>
 			<div class="field">
-				<div class="files"></div>
 				{{template "repo/upload" .}}
 			</div>
 			{{template "repo/editor/commit_form" .}}
diff --git a/templates/repo/issue/comment_tab.tmpl b/templates/repo/issue/comment_tab.tmpl
index 77e82930dc..22e1d5af84 100644
--- a/templates/repo/issue/comment_tab.tmpl
+++ b/templates/repo/issue/comment_tab.tmpl
@@ -14,7 +14,6 @@
 </div>
 {{if .IsAttachmentEnabled}}
 	<div class="field">
-		<div class="files"></div>
 		{{template "repo/upload" .}}
 	</div>
 {{end}}
diff --git a/templates/repo/issue/view_content.tmpl b/templates/repo/issue/view_content.tmpl
index 00ce61921d..d2928df342 100644
--- a/templates/repo/issue/view_content.tmpl
+++ b/templates/repo/issue/view_content.tmpl
@@ -197,7 +197,6 @@
 		</div>
 		{{if .IsAttachmentEnabled}}
 			<div class="field">
-				<div class="comment-files"></div>
 				{{template "repo/upload" .}}
 			</div>
 		{{end}}
diff --git a/templates/repo/issue/view_content/comments.tmpl b/templates/repo/issue/view_content/comments.tmpl
index 53005cc820..de31430ce0 100644
--- a/templates/repo/issue/view_content/comments.tmpl
+++ b/templates/repo/issue/view_content/comments.tmpl
@@ -449,6 +449,9 @@
 								<span class="no-content">{{$.i18n.Tr "repo.issues.no_content"}}</span>
 							{{end}}
 						</div>
+						{{if .Attachments}}
+							{{template "repo/issue/view_content/attachments" Dict "ctx" $ "Attachments" .Attachments "Content" .RenderedContent}}
+						{{end}}
 					</div>
 				</div>
 			</div>
diff --git a/templates/repo/release/new.tmpl b/templates/repo/release/new.tmpl
index c4b36597c6..49759713aa 100644
--- a/templates/repo/release/new.tmpl
+++ b/templates/repo/release/new.tmpl
@@ -76,7 +76,6 @@
 				{{end}}
 				{{if .IsAttachmentEnabled}}
 					<div class="field">
-						<div class="files"></div>
 						{{template "repo/upload" .}}
 					</div>
 				{{end}}
diff --git a/templates/repo/upload.tmpl b/templates/repo/upload.tmpl
index 9215da2b19..3dd40d1b27 100644
--- a/templates/repo/upload.tmpl
+++ b/templates/repo/upload.tmpl
@@ -1,6 +1,5 @@
 <div
 	class="ui dropzone"
-	id="dropzone"
 	data-link-url="{{.UploadLinkUrl}}"
 	data-upload-url="{{.UploadUrl}}"
 	data-remove-url="{{.UploadRemoveUrl}}"
@@ -11,4 +10,6 @@
 	data-invalid-input-type="{{.i18n.Tr "dropzone.invalid_input_type"}}"
 	data-file-too-big="{{.i18n.Tr "dropzone.file_too_big"}}"
 	data-remove-file="{{.i18n.Tr "dropzone.remove_file"}}"
-></div>
+>
+	<div class="files"></div>
+</div>
diff --git a/web_src/js/index.js b/web_src/js/index.js
index 8818511e32..e42a664015 100644
--- a/web_src/js/index.js
+++ b/web_src/js/index.js
@@ -327,11 +327,11 @@ function getPastedImages(e) {
   return files;
 }
 
-async function uploadFile(file) {
+async function uploadFile(file, uploadUrl) {
   const formData = new FormData();
   formData.append('file', file, file.name);
 
-  const res = await fetch($('#dropzone').data('upload-url'), {
+  const res = await fetch(uploadUrl, {
     method: 'POST',
     headers: {'X-Csrf-Token': csrf},
     body: formData,
@@ -345,24 +345,33 @@ function reload() {
 
 function initImagePaste(target) {
   target.each(function () {
-    this.addEventListener('paste', async (e) => {
-      for (const img of getPastedImages(e)) {
-        const name = img.name.substr(0, img.name.lastIndexOf('.'));
-        insertAtCursor(this, `![${name}]()`);
-        const data = await uploadFile(img);
-        replaceAndKeepCursor(this, `![${name}]()`, `![${name}](${AppSubUrl}/attachments/${data.uuid})`);
-        const input = $(`<input id="${data.uuid}" name="files" type="hidden">`).val(data.uuid);
-        $('.files').append(input);
-      }
-    }, false);
+    const dropzone = this.querySelector('.dropzone');
+    if (!dropzone) {
+      return;
+    }
+    const uploadUrl = dropzone.dataset.uploadUrl;
+    const dropzoneFiles = dropzone.querySelector('.files');
+    for (const textarea of this.querySelectorAll('textarea')) {
+      textarea.addEventListener('paste', async (e) => {
+        for (const img of getPastedImages(e)) {
+          const name = img.name.substr(0, img.name.lastIndexOf('.'));
+          insertAtCursor(textarea, `![${name}]()`);
+          const data = await uploadFile(img, uploadUrl);
+          replaceAndKeepCursor(textarea, `![${name}]()`, `![${name}](${AppSubUrl}/attachments/${data.uuid})`);
+          const input = $(`<input id="${data.uuid}" name="files" type="hidden">`).val(data.uuid);
+          dropzoneFiles.appendChild(input[0]);
+        }
+      }, false);
+    }
   });
 }
 
-function initSimpleMDEImagePaste(simplemde, files) {
+function initSimpleMDEImagePaste(simplemde, dropzone, files) {
+  const uploadUrl = dropzone.dataset.uploadUrl;
   simplemde.codemirror.on('paste', async (_, e) => {
     for (const img of getPastedImages(e)) {
       const name = img.name.substr(0, img.name.lastIndexOf('.'));
-      const data = await uploadFile(img);
+      const data = await uploadFile(img, uploadUrl);
       const pos = simplemde.codemirror.getCursor();
       simplemde.codemirror.replaceRange(`![${name}](${AppSubUrl}/attachments/${data.uuid})`, pos);
       const input = $(`<input id="${data.uuid}" name="files" type="hidden">`).val(data.uuid);
@@ -381,7 +390,7 @@ function initCommentForm() {
   autoSimpleMDE = setCommentSimpleMDE($('.comment.form textarea:not(.review-textarea)'));
   initBranchSelector();
   initCommentPreviewTab($('.comment.form'));
-  initImagePaste($('.comment.form textarea'));
+  initImagePaste($('.comment.form'));
 
   // Listsubmit
   function initListSubmits(selector, outerSelector) {
@@ -993,8 +1002,7 @@ async function initRepository() {
 
         let dz;
         const $dropzone = $editContentZone.find('.dropzone');
-        const $files = $editContentZone.find('.comment-files');
-        if ($dropzone.length > 0) {
+        if ($dropzone.length === 1) {
           $dropzone.data('saved', false);
 
           const filenameDict = {};
@@ -1020,7 +1028,7 @@ async function initRepository() {
                   submitted: false
                 };
                 const input = $(`<input id="${data.uuid}" name="files" type="hidden">`).val(data.uuid);
-                $files.append(input);
+                $dropzone.find('.files').append(input);
               });
               this.on('removedfile', (file) => {
                 if (!(file.name in filenameDict)) {
@@ -1042,7 +1050,7 @@ async function initRepository() {
               this.on('reload', () => {
                 $.getJSON($editContentZone.data('attachment-url'), (data) => {
                   dz.removeAllFiles(true);
-                  $files.empty();
+                  $dropzone.find('.files').empty();
                   $.each(data, function () {
                     const imgSrc = `${$dropzone.data('link-url')}/${this.uuid}`;
                     dz.emit('addedfile', this);
@@ -1055,7 +1063,7 @@ async function initRepository() {
                     };
                     $dropzone.find(`img[src='${imgSrc}']`).css('max-width', '100%');
                     const input = $(`<input id="${this.uuid}" name="files" type="hidden">`).val(this.uuid);
-                    $files.append(input);
+                    $dropzone.find('.files').append(input);
                   });
                 });
               });
@@ -1075,7 +1083,9 @@ async function initRepository() {
         $simplemde = setCommentSimpleMDE($textarea);
         commentMDEditors[$editContentZone.data('write')] = $simplemde;
         initCommentPreviewTab($editContentForm);
-        initSimpleMDEImagePaste($simplemde, $files);
+        if ($dropzone.length === 1) {
+          initSimpleMDEImagePaste($simplemde, $dropzone[0], $dropzone.find('.files'));
+        }
 
         $editContentZone.find('.cancel.button').on('click', () => {
           $renderContent.show();
@@ -1087,7 +1097,7 @@ async function initRepository() {
         $editContentZone.find('.save.button').on('click', () => {
           $renderContent.show();
           $editContentZone.hide();
-          const $attachments = $files.find('[name=files]').map(function () {
+          const $attachments = $dropzone.find('.files').find('[name=files]').map(function () {
             return $(this).val();
           }).get();
           $.post($editContentZone.data('update-url'), {
@@ -1369,6 +1379,13 @@ function initPullRequestReview() {
     $simplemde.codemirror.focus();
     assingMenuAttributes(form.find('.menu'));
   });
+
+  const $reviewBox = $('.review-box');
+  if ($reviewBox.length === 1) {
+    setCommentSimpleMDE($reviewBox.find('textarea'));
+    initImagePaste($reviewBox);
+  }
+
   // The following part is only for diff views
   if ($('.repository.pull.diff').length === 0) {
     return;
@@ -1656,6 +1673,10 @@ $.fn.getCursorPosition = function () {
 };
 
 function setCommentSimpleMDE($editArea) {
+  if ($editArea.length === 0) {
+    return null;
+  }
+
   const simplemde = new SimpleMDE({
     autoDownloadFontAwesome: false,
     element: $editArea[0],
@@ -1827,7 +1848,8 @@ function initReleaseEditor() {
   const $files = $editor.parent().find('.files');
   const $simplemde = setCommentSimpleMDE($textarea);
   initCommentPreviewTab($editor);
-  initSimpleMDEImagePaste($simplemde, $files);
+  const dropzone = $editor.parent().find('.dropzone')[0];
+  initSimpleMDEImagePaste($simplemde, dropzone, $files);
 }
 
 function initOrganization() {
@@ -2610,11 +2632,10 @@ $(document).ready(async () => {
   initLinkAccountView();
 
   // Dropzone
-  const $dropzone = $('#dropzone');
-  if ($dropzone.length > 0) {
+  for (const el of document.querySelectorAll('.dropzone')) {
     const filenameDict = {};
-
-    await createDropzone('#dropzone', {
+    const $dropzone = $(el);
+    await createDropzone(el, {
       url: $dropzone.data('upload-url'),
       headers: {'X-Csrf-Token': csrf},
       maxFiles: $dropzone.data('max-file'),
@@ -2633,7 +2654,7 @@ $(document).ready(async () => {
         this.on('success', (file, data) => {
           filenameDict[file.name] = data.uuid;
           const input = $(`<input id="${data.uuid}" name="files" type="hidden">`).val(data.uuid);
-          $('.files').append(input);
+          $dropzone.find('.files').append(input);
         });
         this.on('removedfile', (file) => {
           if (file.name in filenameDict) {