Fix issue attachment handling (#24202) (#24221)

Backport #24202

Close #24195

Fix the bug:

1. The old code doesn't handle `removedfile` event correctly
2. The old code doesn't provide attachments for type=CommentTypeReview

---------

Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
wxiaoguang 2023-04-20 16:21:10 +08:00 committed by GitHub
parent 95c2cb4b79
commit d5f2c9d74d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 131 additions and 129 deletions

View File

@ -52,84 +52,61 @@ func (err ErrCommentNotExist) Unwrap() error {
// CommentType defines whether a comment is just a simple comment, an action (like close) or a reference. // CommentType defines whether a comment is just a simple comment, an action (like close) or a reference.
type CommentType int type CommentType int
// define unknown comment type // CommentTypeUndefined is used to search for comments of any type
const ( const CommentTypeUndefined CommentType = -1
CommentTypeUnknown CommentType = -1
)
// Enumerate all the comment types
const ( const (
// 0 Plain comment, can be associated with a commit (CommitID > 0) and a line (LineNum > 0) CommentTypeComment CommentType = iota // 0 Plain comment, can be associated with a commit (CommitID > 0) and a line (LineNum > 0)
CommentTypeComment CommentType = iota
CommentTypeReopen // 1 CommentTypeReopen // 1
CommentTypeClose // 2 CommentTypeClose // 2
// 3 References. CommentTypeIssueRef // 3 References.
CommentTypeIssueRef CommentTypeCommitRef // 4 Reference from a commit (not part of a pull request)
// 4 Reference from a commit (not part of a pull request) CommentTypeCommentRef // 5 Reference from a comment
CommentTypeCommitRef CommentTypePullRef // 6 Reference from a pull request
// 5 Reference from a comment
CommentTypeCommentRef CommentTypeLabel // 7 Labels changed
// 6 Reference from a pull request CommentTypeMilestone // 8 Milestone changed
CommentTypePullRef CommentTypeAssignees // 9 Assignees changed
// 7 Labels changed CommentTypeChangeTitle // 10 Change Title
CommentTypeLabel CommentTypeDeleteBranch // 11 Delete Branch
// 8 Milestone changed
CommentTypeMilestone CommentTypeStartTracking // 12 Start a stopwatch for time tracking
// 9 Assignees changed CommentTypeStopTracking // 13 Stop a stopwatch for time tracking
CommentTypeAssignees CommentTypeAddTimeManual // 14 Add time manual for time tracking
// 10 Change Title CommentTypeCancelTracking // 15 Cancel a stopwatch for time tracking
CommentTypeChangeTitle CommentTypeAddedDeadline // 16 Added a due date
// 11 Delete Branch CommentTypeModifiedDeadline // 17 Modified the due date
CommentTypeDeleteBranch CommentTypeRemovedDeadline // 18 Removed a due date
// 12 Start a stopwatch for time tracking
CommentTypeStartTracking CommentTypeAddDependency // 19 Dependency added
// 13 Stop a stopwatch for time tracking CommentTypeRemoveDependency // 20 Dependency removed
CommentTypeStopTracking
// 14 Add time manual for time tracking CommentTypeCode // 21 Comment a line of code
CommentTypeAddTimeManual CommentTypeReview // 22 Reviews a pull request by giving general feedback
// 15 Cancel a stopwatch for time tracking
CommentTypeCancelTracking CommentTypeLock // 23 Lock an issue, giving only collaborators access
// 16 Added a due date CommentTypeUnlock // 24 Unlocks a previously locked issue
CommentTypeAddedDeadline
// 17 Modified the due date CommentTypeChangeTargetBranch // 25 Change pull request's target branch
CommentTypeModifiedDeadline
// 18 Removed a due date CommentTypeDeleteTimeManual // 26 Delete time manual for time tracking
CommentTypeRemovedDeadline
// 19 Dependency added CommentTypeReviewRequest // 27 add or remove Request from one
CommentTypeAddDependency CommentTypeMergePull // 28 merge pull request
// 20 Dependency removed CommentTypePullRequestPush // 29 push to PR head branch
CommentTypeRemoveDependency
// 21 Comment a line of code CommentTypeProject // 30 Project changed
CommentTypeCode CommentTypeProjectBoard // 31 Project board changed
// 22 Reviews a pull request by giving general feedback
CommentTypeReview CommentTypeDismissReview // 32 Dismiss Review
// 23 Lock an issue, giving only collaborators access
CommentTypeLock CommentTypeChangeIssueRef // 33 Change issue ref
// 24 Unlocks a previously locked issue
CommentTypeUnlock CommentTypePRScheduledToAutoMerge // 34 pr was scheduled to auto merge when checks succeed
// 25 Change pull request's target branch CommentTypePRUnScheduledToAutoMerge // 35 pr was un scheduled to auto merge when checks succeed
CommentTypeChangeTargetBranch
// 26 Delete time manual for time tracking
CommentTypeDeleteTimeManual
// 27 add or remove Request from one
CommentTypeReviewRequest
// 28 merge pull request
CommentTypeMergePull
// 29 push to PR head branch
CommentTypePullRequestPush
// 30 Project changed
CommentTypeProject
// 31 Project board changed
CommentTypeProjectBoard
// 32 Dismiss Review
CommentTypeDismissReview
// 33 Change issue ref
CommentTypeChangeIssueRef
// 34 pr was scheduled to auto merge when checks succeed
CommentTypePRScheduledToAutoMerge
// 35 pr was un scheduled to auto merge when checks succeed
CommentTypePRUnScheduledToAutoMerge
) )
var commentStrings = []string{ var commentStrings = []string{
@ -181,7 +158,23 @@ func AsCommentType(typeName string) CommentType {
return CommentType(index) return CommentType(index)
} }
} }
return CommentTypeUnknown return CommentTypeUndefined
}
func (t CommentType) HasContentSupport() bool {
switch t {
case CommentTypeComment, CommentTypeCode, CommentTypeReview:
return true
}
return false
}
func (t CommentType) HasAttachmentSupport() bool {
switch t {
case CommentTypeComment, CommentTypeCode, CommentTypeReview:
return true
}
return false
} }
// RoleDescriptor defines comment tag type // RoleDescriptor defines comment tag type
@ -1039,7 +1032,7 @@ func (opts *FindCommentsOptions) ToConds() builder.Cond {
if opts.Before > 0 { if opts.Before > 0 {
cond = cond.And(builder.Lte{"comment.updated_unix": opts.Before}) cond = cond.And(builder.Lte{"comment.updated_unix": opts.Before})
} }
if opts.Type != CommentTypeUnknown { if opts.Type != CommentTypeUndefined {
cond = cond.And(builder.Eq{"comment.type": opts.Type}) cond = cond.And(builder.Eq{"comment.type": opts.Type})
} }
if opts.Line != 0 { if opts.Line != 0 {

View File

@ -64,8 +64,9 @@ func TestFetchCodeComments(t *testing.T) {
} }
func TestAsCommentType(t *testing.T) { func TestAsCommentType(t *testing.T) {
assert.Equal(t, issues_model.CommentTypeUnknown, issues_model.AsCommentType("")) assert.Equal(t, issues_model.CommentType(0), issues_model.CommentTypeComment)
assert.Equal(t, issues_model.CommentTypeUnknown, issues_model.AsCommentType("nonsense")) assert.Equal(t, issues_model.CommentTypeUndefined, issues_model.AsCommentType(""))
assert.Equal(t, issues_model.CommentTypeUndefined, issues_model.AsCommentType("nonsense"))
assert.Equal(t, issues_model.CommentTypeComment, issues_model.AsCommentType("comment")) assert.Equal(t, issues_model.CommentTypeComment, issues_model.AsCommentType("comment"))
assert.Equal(t, issues_model.CommentTypePRUnScheduledToAutoMerge, issues_model.AsCommentType("pull_cancel_scheduled_merge")) assert.Equal(t, issues_model.CommentTypePRUnScheduledToAutoMerge, issues_model.AsCommentType("pull_cancel_scheduled_merge"))
} }

View File

@ -267,7 +267,7 @@ func (issue *Issue) LoadPullRequest(ctx context.Context) (err error) {
} }
func (issue *Issue) loadComments(ctx context.Context) (err error) { func (issue *Issue) loadComments(ctx context.Context) (err error) {
return issue.loadCommentsByType(ctx, CommentTypeUnknown) return issue.loadCommentsByType(ctx, CommentTypeUndefined)
} }
// LoadDiscussComments loads discuss comments // LoadDiscussComments loads discuss comments

View File

@ -173,7 +173,7 @@ func ListIssueCommentsAndTimeline(ctx *context.APIContext) {
IssueID: issue.ID, IssueID: issue.ID,
Since: since, Since: since,
Before: before, Before: before,
Type: issues_model.CommentTypeUnknown, Type: issues_model.CommentTypeUndefined,
} }
comments, err := issues_model.FindComments(ctx, opts) comments, err := issues_model.FindComments(ctx, opts)
@ -549,7 +549,7 @@ func editIssueComment(ctx *context.APIContext, form api.EditIssueCommentOption)
return return
} }
if comment.Type != issues_model.CommentTypeComment && comment.Type != issues_model.CommentTypeReview && comment.Type != issues_model.CommentTypeCode { if !comment.Type.HasContentSupport() {
ctx.Status(http.StatusNoContent) ctx.Status(http.StatusNoContent)
return return
} }

View File

@ -1563,7 +1563,7 @@ func ViewIssue(ctx *context.Context) {
return return
} }
} }
} else if comment.Type == issues_model.CommentTypeCode || comment.Type == issues_model.CommentTypeReview || comment.Type == issues_model.CommentTypeDismissReview { } else if comment.Type.HasContentSupport() {
comment.RenderedContent, err = markdown.RenderString(&markup.RenderContext{ comment.RenderedContent, err = markdown.RenderString(&markup.RenderContext{
URLPrefix: ctx.Repo.RepoLink, URLPrefix: ctx.Repo.RepoLink,
Metas: ctx.Repo.Repository.ComposeMetas(), Metas: ctx.Repo.Repository.ComposeMetas(),
@ -2800,7 +2800,7 @@ func UpdateCommentContent(ctx *context.Context) {
return return
} }
if comment.Type != issues_model.CommentTypeComment && comment.Type != issues_model.CommentTypeReview && comment.Type != issues_model.CommentTypeCode { if !comment.Type.HasContentSupport() {
ctx.Error(http.StatusNoContent) ctx.Error(http.StatusNoContent)
return return
} }
@ -2864,7 +2864,7 @@ func DeleteComment(ctx *context.Context) {
if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanWriteIssuesOrPulls(comment.Issue.IsPull)) { if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanWriteIssuesOrPulls(comment.Issue.IsPull)) {
ctx.Error(http.StatusForbidden) ctx.Error(http.StatusForbidden)
return return
} else if comment.Type != issues_model.CommentTypeComment && comment.Type != issues_model.CommentTypeCode { } else if !comment.Type.HasContentSupport() {
ctx.Error(http.StatusNoContent) ctx.Error(http.StatusNoContent)
return return
} }
@ -3010,7 +3010,7 @@ func ChangeCommentReaction(ctx *context.Context) {
return return
} }
if comment.Type != issues_model.CommentTypeComment && comment.Type != issues_model.CommentTypeCode && comment.Type != issues_model.CommentTypeReview { if !comment.Type.HasContentSupport() {
ctx.Error(http.StatusNoContent) ctx.Error(http.StatusNoContent)
return return
} }
@ -3126,8 +3126,13 @@ func GetCommentAttachments(ctx *context.Context) {
ctx.NotFoundOrServerError("GetCommentByID", issues_model.IsErrCommentNotExist, err) ctx.NotFoundOrServerError("GetCommentByID", issues_model.IsErrCommentNotExist, err)
return return
} }
if !comment.Type.HasAttachmentSupport() {
ctx.ServerError("GetCommentAttachments", fmt.Errorf("comment type %v does not support attachments", comment.Type))
return
}
attachments := make([]*api.Attachment, 0) attachments := make([]*api.Attachment, 0)
if comment.Type == issues_model.CommentTypeComment {
if err := comment.LoadAttachments(ctx); err != nil { if err := comment.LoadAttachments(ctx); err != nil {
ctx.ServerError("LoadAttachments", err) ctx.ServerError("LoadAttachments", err)
return return
@ -3135,7 +3140,6 @@ func GetCommentAttachments(ctx *context.Context) {
for i := 0; i < len(comment.Attachments); i++ { for i := 0; i < len(comment.Attachments); i++ {
attachments = append(attachments, convert.ToAttachment(comment.Attachments[i])) attachments = append(attachments, convert.ToAttachment(comment.Attachments[i]))
} }
}
ctx.JSON(http.StatusOK, attachments) ctx.JSON(http.StatusOK, attachments)
} }

View File

@ -90,8 +90,7 @@ func CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_m
// UpdateComment updates information of comment. // UpdateComment updates information of comment.
func UpdateComment(ctx context.Context, c *issues_model.Comment, doer *user_model.User, oldContent string) error { func UpdateComment(ctx context.Context, c *issues_model.Comment, doer *user_model.User, oldContent string) error {
needsContentHistory := c.Content != oldContent && needsContentHistory := c.Content != oldContent && c.Type.HasContentSupport()
(c.Type == issues_model.CommentTypeComment || c.Type == issues_model.CommentTypeReview || c.Type == issues_model.CommentTypeCode)
if needsContentHistory { if needsContentHistory {
hasContentHistory, err := issues_model.HasIssueContentHistory(ctx, c.IssueID, c.ID) hasContentHistory, err := issues_model.HasIssueContentHistory(ctx, c.IssueID, c.ID)
if err != nil { if err != nil {

View File

@ -1,11 +1,11 @@
<div class="dropzone-attachments"> <div class="dropzone-attachments">
{{if .Attachments}} {{if .Attachments}}
<div class="ui clearing divider"></div> <div class="ui divider"></div>
{{end}} {{end}}
<div class="ui middle aligned padded grid">
{{$hasThumbnails := false}} {{$hasThumbnails := false}}
{{- range .Attachments -}} {{- range .Attachments -}}
<div class="twelve wide column" style="padding: 6px;"> <div class="gt-df">
<div class="gt-f1 gt-p-3">
<a target="_blank" rel="noopener noreferrer" href="{{.DownloadURL}}" title='{{$.ctx.locale.Tr "repo.issues.attachment.open_tab" .Name}}'> <a target="_blank" rel="noopener noreferrer" href="{{.DownloadURL}}" title='{{$.ctx.locale.Tr "repo.issues.attachment.open_tab" .Name}}'>
{{if FilenameIsImage .Name}} {{if FilenameIsImage .Name}}
{{if not (containGeneric $.Content .UUID)}} {{if not (containGeneric $.Content .UUID)}}
@ -18,14 +18,14 @@
<span><strong>{{.Name}}</strong></span> <span><strong>{{.Name}}</strong></span>
</a> </a>
</div> </div>
<div class="four wide column" style="padding: 0px;"> <div class="gt-p-3 gt-df gt-ac">
<span class="ui text grey right">{{.Size | FileSize}}</span> <span class="ui text grey right">{{.Size | FileSize}}</span>
</div> </div>
{{end -}}
</div> </div>
{{end -}}
{{if $hasThumbnails}} {{if $hasThumbnails}}
<div class="ui clearing divider"></div> <div class="ui divider"></div>
<div class="ui small thumbnails"> <div class="ui small thumbnails">
{{- range .Attachments -}} {{- range .Attachments -}}
{{if FilenameIsImage .Name}} {{if FilenameIsImage .Name}}

View File

@ -333,20 +333,19 @@ async function onEditContent(event) {
let dz; let dz;
const $dropzone = $editContentZone.find('.dropzone'); const $dropzone = $editContentZone.find('.dropzone');
if ($dropzone.length === 1) { if ($dropzone.length === 1) {
$dropzone.data('saved', false); let disableRemovedfileEvent = false; // when resetting the dropzone (removeAllFiles), disable the "removedfile" event
let fileUuidDict = {}; // to record: if a comment has been saved, then the uploaded files won't be deleted from server when clicking the Remove in the dropzone
const fileUuidDict = {}; const dz = await createDropzone($dropzone[0], {
dz = await createDropzone($dropzone[0], { url: $dropzone.attr('data-upload-url'),
url: $dropzone.data('upload-url'),
headers: {'X-Csrf-Token': csrfToken}, headers: {'X-Csrf-Token': csrfToken},
maxFiles: $dropzone.data('max-file'), maxFiles: $dropzone.attr('data-max-file'),
maxFilesize: $dropzone.data('max-size'), maxFilesize: $dropzone.attr('data-max-size'),
acceptedFiles: (['*/*', ''].includes($dropzone.data('accepts'))) ? null : $dropzone.data('accepts'), acceptedFiles: (['*/*', ''].includes($dropzone.attr('data-accepts'))) ? null : $dropzone.attr('data-accepts'),
addRemoveLinks: true, addRemoveLinks: true,
dictDefaultMessage: $dropzone.data('default-message'), dictDefaultMessage: $dropzone.attr('data-default-message'),
dictInvalidFileType: $dropzone.data('invalid-input-type'), dictInvalidFileType: $dropzone.attr('data-invalid-input-type'),
dictFileTooBig: $dropzone.data('file-too-big'), dictFileTooBig: $dropzone.attr('data-file-too-big'),
dictRemoveFile: $dropzone.data('remove-file'), dictRemoveFile: $dropzone.attr('data-remove-file'),
timeout: 0, timeout: 0,
thumbnailMethod: 'contain', thumbnailMethod: 'contain',
thumbnailWidth: 480, thumbnailWidth: 480,
@ -359,9 +358,10 @@ async function onEditContent(event) {
$dropzone.find('.files').append(input); $dropzone.find('.files').append(input);
}); });
this.on('removedfile', (file) => { this.on('removedfile', (file) => {
if (disableRemovedfileEvent) return;
$(`#${file.uuid}`).remove(); $(`#${file.uuid}`).remove();
if ($dropzone.data('remove-url') && !fileUuidDict[file.uuid].submitted) { if ($dropzone.attr('data-remove-url') && !fileUuidDict[file.uuid].submitted) {
$.post($dropzone.data('remove-url'), { $.post($dropzone.attr('data-remove-url'), {
file: file.uuid, file: file.uuid,
_csrf: csrfToken, _csrf: csrfToken,
}); });
@ -373,20 +373,25 @@ async function onEditContent(event) {
}); });
}); });
this.on('reload', () => { this.on('reload', () => {
$.getJSON($editContentZone.data('attachment-url'), (data) => { $.getJSON($editContentZone.attr('data-attachment-url'), (data) => {
// do not trigger the "removedfile" event, otherwise the attachments would be deleted from server
disableRemovedfileEvent = true;
dz.removeAllFiles(true); dz.removeAllFiles(true);
$dropzone.find('.files').empty(); $dropzone.find('.files').empty();
$.each(data, function () { fileUuidDict = {};
const imgSrc = `${$dropzone.data('link-url')}/${this.uuid}`; disableRemovedfileEvent = false;
dz.emit('addedfile', this);
dz.emit('thumbnail', this, imgSrc); for (const attachment of data) {
dz.emit('complete', this); const imgSrc = `${$dropzone.attr('data-link-url')}/${attachment.uuid}`;
dz.files.push(this); dz.emit('addedfile', attachment);
fileUuidDict[this.uuid] = {submitted: true}; dz.emit('thumbnail', attachment, imgSrc);
dz.emit('complete', attachment);
dz.files.push(attachment);
fileUuidDict[attachment.uuid] = {submitted: true};
$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="${attachment.uuid}" name="files" type="hidden">`).val(attachment.uuid);
$dropzone.find('.files').append(input); $dropzone.find('.files').append(input);
}); }
}); });
}); });
}, },