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>
This commit is contained in:
		
							parent
							
								
									0adcea9ba6
								
							
						
					
					
						commit
						ebf253b841
					
				
					 15 changed files with 87 additions and 47 deletions
				
			
		|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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) | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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")) | ||||
|  |  | |||
|  | @ -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
 | ||||
|  |  | |||
|  | @ -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 | ||||
| 	} | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -26,7 +26,6 @@ | |||
| 				</div> | ||||
| 			</div> | ||||
| 			<div class="field"> | ||||
| 				<div class="files"></div> | ||||
| 				{{template "repo/upload" .}} | ||||
| 			</div> | ||||
| 			{{template "repo/editor/commit_form" .}} | ||||
|  |  | |||
|  | @ -14,7 +14,6 @@ | |||
| </div> | ||||
| {{if .IsAttachmentEnabled}} | ||||
| 	<div class="field"> | ||||
| 		<div class="files"></div> | ||||
| 		{{template "repo/upload" .}} | ||||
| 	</div> | ||||
| {{end}} | ||||
|  |  | |||
|  | @ -197,7 +197,6 @@ | |||
| 		</div> | ||||
| 		{{if .IsAttachmentEnabled}} | ||||
| 			<div class="field"> | ||||
| 				<div class="comment-files"></div> | ||||
| 				{{template "repo/upload" .}} | ||||
| 			</div> | ||||
| 		{{end}} | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -76,7 +76,6 @@ | |||
| 				{{end}} | ||||
| 				{{if .IsAttachmentEnabled}} | ||||
| 					<div class="field"> | ||||
| 						<div class="files"></div> | ||||
| 						{{template "repo/upload" .}} | ||||
| 					</div> | ||||
| 				{{end}} | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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}]()`, ``); | ||||
|         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}]()`, ``); | ||||
|           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(``, 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) { | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue