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:
KN4CK3R 2021-06-15 03:12:33 +02:00 committed by GitHub
parent 0adcea9ba6
commit ebf253b841
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 87 additions and 47 deletions

View file

@ -762,6 +762,8 @@ func updateCommentInfos(e *xorm.Session, opts *CreateCommentOptions, comment *Co
} }
} }
fallthrough fallthrough
case CommentTypeReview:
fallthrough
case CommentTypeComment: case CommentTypeComment:
if _, err = e.Exec("UPDATE `issue` SET num_comments=num_comments+1 WHERE id=?", opts.Issue.ID); err != nil { if _, err = e.Exec("UPDATE `issue` SET num_comments=num_comments+1 WHERE id=?", opts.Issue.ID); err != nil {
return err return err

View file

@ -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 // 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() sess := x.NewSession()
defer sess.Close() defer sess.Close()
if err := sess.Begin(); err != nil { if err := sess.Begin(); err != nil {
@ -425,6 +425,7 @@ func SubmitReview(doer *User, issue *Issue, reviewType ReviewType, content, comm
Issue: issue, Issue: issue,
Repo: issue.Repo, Repo: issue.Repo,
ReviewID: review.ID, ReviewID: review.ID,
Attachments: attachmentUUIDs,
}) })
if err != nil || comm == nil { if err != nil || comm == nil {
return nil, nil, err return nil, nil, err

View file

@ -359,7 +359,7 @@ func CreatePullReview(ctx *context.APIContext) {
} }
// create review and associate all pending review comments // 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 { if err != nil {
ctx.Error(http.StatusInternalServerError, "SubmitReview", err) ctx.Error(http.StatusInternalServerError, "SubmitReview", err)
return return
@ -447,7 +447,7 @@ func SubmitPullReview(ctx *context.APIContext) {
} }
// create review and associate all pending review comments // 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 { if err != nil {
ctx.Error(http.StatusInternalServerError, "SubmitReview", err) ctx.Error(http.StatusInternalServerError, "SubmitReview", err)
return return

View file

@ -694,6 +694,10 @@ func ViewPullFiles(ctx *context.Context) {
getBranchData(ctx, issue) getBranchData(ctx, issue)
ctx.Data["IsIssuePoster"] = ctx.IsSigned && issue.IsPoster(ctx.User.ID) ctx.Data["IsIssuePoster"] = ctx.IsSigned && issue.IsPoster(ctx.User.ID)
ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
upload.AddUploadContext(ctx, "comment")
ctx.HTML(http.StatusOK, tplPullFiles) ctx.HTML(http.StatusOK, tplPullFiles)
} }

View file

@ -12,6 +12,7 @@ import (
"code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/forms"
pull_service "code.gitea.io/gitea/services/pull" 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 err != nil {
if models.IsContentEmptyErr(err) { if models.IsContentEmptyErr(err) {
ctx.Flash.Error(ctx.Tr("repo.issues.review.content.empty")) ctx.Flash.Error(ctx.Tr("repo.issues.review.content.empty"))

View file

@ -587,6 +587,7 @@ type SubmitReviewForm struct {
Content string Content string
Type string `binding:"Required;In(approve,comment,reject)"` Type string `binding:"Required;In(approve,comment,reject)"`
CommitID string CommitID string
Files []string
} }
// Validate validates the fields // Validate validates the fields

View file

@ -100,7 +100,7 @@ func CreateCodeComment(doer *models.User, gitRepo *git.Repository, issue *models
if !isReview && !existsReview { if !isReview && !existsReview {
// Submit the review we've just created so the comment shows up in the issue view // 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 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 // 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() pr, err := issue.GetPullRequest()
if err != nil { if err != nil {
return nil, nil, err 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 { if err != nil {
return nil, nil, err return nil, nil, err
} }

View file

@ -15,6 +15,11 @@
<div class="ui field"> <div class="ui field">
<textarea name="content" tabindex="0" rows="2" placeholder="{{$.i18n.Tr "repo.diff.review.placeholder"}}"></textarea> <textarea name="content" tabindex="0" rows="2" placeholder="{{$.i18n.Tr "repo.diff.review.placeholder"}}"></textarea>
</div> </div>
{{if .IsAttachmentEnabled}}
<div class="field">
{{template "repo/upload" .}}
</div>
{{end}}
<div class="ui divider"></div> <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="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> <button type="submit" name="type" value="comment" class="ui submit tiny basic button btn-submit">{{$.i18n.Tr "repo.diff.review.comment"}}</button>

View file

@ -26,7 +26,6 @@
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<div class="files"></div>
{{template "repo/upload" .}} {{template "repo/upload" .}}
</div> </div>
{{template "repo/editor/commit_form" .}} {{template "repo/editor/commit_form" .}}

View file

@ -14,7 +14,6 @@
</div> </div>
{{if .IsAttachmentEnabled}} {{if .IsAttachmentEnabled}}
<div class="field"> <div class="field">
<div class="files"></div>
{{template "repo/upload" .}} {{template "repo/upload" .}}
</div> </div>
{{end}} {{end}}

View file

@ -197,7 +197,6 @@
</div> </div>
{{if .IsAttachmentEnabled}} {{if .IsAttachmentEnabled}}
<div class="field"> <div class="field">
<div class="comment-files"></div>
{{template "repo/upload" .}} {{template "repo/upload" .}}
</div> </div>
{{end}} {{end}}

View file

@ -449,6 +449,9 @@
<span class="no-content">{{$.i18n.Tr "repo.issues.no_content"}}</span> <span class="no-content">{{$.i18n.Tr "repo.issues.no_content"}}</span>
{{end}} {{end}}
</div> </div>
{{if .Attachments}}
{{template "repo/issue/view_content/attachments" Dict "ctx" $ "Attachments" .Attachments "Content" .RenderedContent}}
{{end}}
</div> </div>
</div> </div>
</div> </div>

View file

@ -76,7 +76,6 @@
{{end}} {{end}}
{{if .IsAttachmentEnabled}} {{if .IsAttachmentEnabled}}
<div class="field"> <div class="field">
<div class="files"></div>
{{template "repo/upload" .}} {{template "repo/upload" .}}
</div> </div>
{{end}} {{end}}

View file

@ -1,6 +1,5 @@
<div <div
class="ui dropzone" class="ui dropzone"
id="dropzone"
data-link-url="{{.UploadLinkUrl}}" data-link-url="{{.UploadLinkUrl}}"
data-upload-url="{{.UploadUrl}}" data-upload-url="{{.UploadUrl}}"
data-remove-url="{{.UploadRemoveUrl}}" data-remove-url="{{.UploadRemoveUrl}}"
@ -11,4 +10,6 @@
data-invalid-input-type="{{.i18n.Tr "dropzone.invalid_input_type"}}" data-invalid-input-type="{{.i18n.Tr "dropzone.invalid_input_type"}}"
data-file-too-big="{{.i18n.Tr "dropzone.file_too_big"}}" data-file-too-big="{{.i18n.Tr "dropzone.file_too_big"}}"
data-remove-file="{{.i18n.Tr "dropzone.remove_file"}}" data-remove-file="{{.i18n.Tr "dropzone.remove_file"}}"
></div> >
<div class="files"></div>
</div>

View file

@ -327,11 +327,11 @@ function getPastedImages(e) {
return files; return files;
} }
async function uploadFile(file) { async function uploadFile(file, uploadUrl) {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file, file.name); formData.append('file', file, file.name);
const res = await fetch($('#dropzone').data('upload-url'), { const res = await fetch(uploadUrl, {
method: 'POST', method: 'POST',
headers: {'X-Csrf-Token': csrf}, headers: {'X-Csrf-Token': csrf},
body: formData, body: formData,
@ -345,24 +345,33 @@ function reload() {
function initImagePaste(target) { function initImagePaste(target) {
target.each(function () { target.each(function () {
this.addEventListener('paste', async (e) => { 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)) { for (const img of getPastedImages(e)) {
const name = img.name.substr(0, img.name.lastIndexOf('.')); const name = img.name.substr(0, img.name.lastIndexOf('.'));
insertAtCursor(this, `![${name}]()`); insertAtCursor(textarea, `![${name}]()`);
const data = await uploadFile(img); const data = await uploadFile(img, uploadUrl);
replaceAndKeepCursor(this, `![${name}]()`, `![${name}](${AppSubUrl}/attachments/${data.uuid})`); replaceAndKeepCursor(textarea, `![${name}]()`, `![${name}](${AppSubUrl}/attachments/${data.uuid})`);
const input = $(`<input id="${data.uuid}" name="files" type="hidden">`).val(data.uuid); const input = $(`<input id="${data.uuid}" name="files" type="hidden">`).val(data.uuid);
$('.files').append(input); dropzoneFiles.appendChild(input[0]);
} }
}, false); }, false);
}
}); });
} }
function initSimpleMDEImagePaste(simplemde, files) { function initSimpleMDEImagePaste(simplemde, dropzone, files) {
const uploadUrl = dropzone.dataset.uploadUrl;
simplemde.codemirror.on('paste', async (_, e) => { simplemde.codemirror.on('paste', async (_, e) => {
for (const img of getPastedImages(e)) { for (const img of getPastedImages(e)) {
const name = img.name.substr(0, img.name.lastIndexOf('.')); 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(); const pos = simplemde.codemirror.getCursor();
simplemde.codemirror.replaceRange(`![${name}](${AppSubUrl}/attachments/${data.uuid})`, pos); simplemde.codemirror.replaceRange(`![${name}](${AppSubUrl}/attachments/${data.uuid})`, pos);
const input = $(`<input id="${data.uuid}" name="files" type="hidden">`).val(data.uuid); 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)')); autoSimpleMDE = setCommentSimpleMDE($('.comment.form textarea:not(.review-textarea)'));
initBranchSelector(); initBranchSelector();
initCommentPreviewTab($('.comment.form')); initCommentPreviewTab($('.comment.form'));
initImagePaste($('.comment.form textarea')); initImagePaste($('.comment.form'));
// Listsubmit // Listsubmit
function initListSubmits(selector, outerSelector) { function initListSubmits(selector, outerSelector) {
@ -993,8 +1002,7 @@ async function initRepository() {
let dz; let dz;
const $dropzone = $editContentZone.find('.dropzone'); const $dropzone = $editContentZone.find('.dropzone');
const $files = $editContentZone.find('.comment-files'); if ($dropzone.length === 1) {
if ($dropzone.length > 0) {
$dropzone.data('saved', false); $dropzone.data('saved', false);
const filenameDict = {}; const filenameDict = {};
@ -1020,7 +1028,7 @@ async function initRepository() {
submitted: false submitted: false
}; };
const input = $(`<input id="${data.uuid}" name="files" type="hidden">`).val(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) => { this.on('removedfile', (file) => {
if (!(file.name in filenameDict)) { if (!(file.name in filenameDict)) {
@ -1042,7 +1050,7 @@ async function initRepository() {
this.on('reload', () => { this.on('reload', () => {
$.getJSON($editContentZone.data('attachment-url'), (data) => { $.getJSON($editContentZone.data('attachment-url'), (data) => {
dz.removeAllFiles(true); dz.removeAllFiles(true);
$files.empty(); $dropzone.find('.files').empty();
$.each(data, function () { $.each(data, function () {
const imgSrc = `${$dropzone.data('link-url')}/${this.uuid}`; const imgSrc = `${$dropzone.data('link-url')}/${this.uuid}`;
dz.emit('addedfile', this); dz.emit('addedfile', this);
@ -1055,7 +1063,7 @@ async function initRepository() {
}; };
$dropzone.find(`img[src='${imgSrc}']`).css('max-width', '100%'); $dropzone.find(`img[src='${imgSrc}']`).css('max-width', '100%');
const input = $(`<input id="${this.uuid}" name="files" type="hidden">`).val(this.uuid); 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); $simplemde = setCommentSimpleMDE($textarea);
commentMDEditors[$editContentZone.data('write')] = $simplemde; commentMDEditors[$editContentZone.data('write')] = $simplemde;
initCommentPreviewTab($editContentForm); initCommentPreviewTab($editContentForm);
initSimpleMDEImagePaste($simplemde, $files); if ($dropzone.length === 1) {
initSimpleMDEImagePaste($simplemde, $dropzone[0], $dropzone.find('.files'));
}
$editContentZone.find('.cancel.button').on('click', () => { $editContentZone.find('.cancel.button').on('click', () => {
$renderContent.show(); $renderContent.show();
@ -1087,7 +1097,7 @@ async function initRepository() {
$editContentZone.find('.save.button').on('click', () => { $editContentZone.find('.save.button').on('click', () => {
$renderContent.show(); $renderContent.show();
$editContentZone.hide(); $editContentZone.hide();
const $attachments = $files.find('[name=files]').map(function () { const $attachments = $dropzone.find('.files').find('[name=files]').map(function () {
return $(this).val(); return $(this).val();
}).get(); }).get();
$.post($editContentZone.data('update-url'), { $.post($editContentZone.data('update-url'), {
@ -1369,6 +1379,13 @@ function initPullRequestReview() {
$simplemde.codemirror.focus(); $simplemde.codemirror.focus();
assingMenuAttributes(form.find('.menu')); 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 // The following part is only for diff views
if ($('.repository.pull.diff').length === 0) { if ($('.repository.pull.diff').length === 0) {
return; return;
@ -1656,6 +1673,10 @@ $.fn.getCursorPosition = function () {
}; };
function setCommentSimpleMDE($editArea) { function setCommentSimpleMDE($editArea) {
if ($editArea.length === 0) {
return null;
}
const simplemde = new SimpleMDE({ const simplemde = new SimpleMDE({
autoDownloadFontAwesome: false, autoDownloadFontAwesome: false,
element: $editArea[0], element: $editArea[0],
@ -1827,7 +1848,8 @@ function initReleaseEditor() {
const $files = $editor.parent().find('.files'); const $files = $editor.parent().find('.files');
const $simplemde = setCommentSimpleMDE($textarea); const $simplemde = setCommentSimpleMDE($textarea);
initCommentPreviewTab($editor); initCommentPreviewTab($editor);
initSimpleMDEImagePaste($simplemde, $files); const dropzone = $editor.parent().find('.dropzone')[0];
initSimpleMDEImagePaste($simplemde, dropzone, $files);
} }
function initOrganization() { function initOrganization() {
@ -2610,11 +2632,10 @@ $(document).ready(async () => {
initLinkAccountView(); initLinkAccountView();
// Dropzone // Dropzone
const $dropzone = $('#dropzone'); for (const el of document.querySelectorAll('.dropzone')) {
if ($dropzone.length > 0) {
const filenameDict = {}; const filenameDict = {};
const $dropzone = $(el);
await createDropzone('#dropzone', { await createDropzone(el, {
url: $dropzone.data('upload-url'), url: $dropzone.data('upload-url'),
headers: {'X-Csrf-Token': csrf}, headers: {'X-Csrf-Token': csrf},
maxFiles: $dropzone.data('max-file'), maxFiles: $dropzone.data('max-file'),
@ -2633,7 +2654,7 @@ $(document).ready(async () => {
this.on('success', (file, data) => { this.on('success', (file, data) => {
filenameDict[file.name] = data.uuid; filenameDict[file.name] = data.uuid;
const input = $(`<input id="${data.uuid}" name="files" type="hidden">`).val(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) => { this.on('removedfile', (file) => {
if (file.name in filenameDict) { if (file.name in filenameDict) {