mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-27 00:23:41 +09:00 
			
		
		
		
	Batch delete issue and improve tippy opts (#25253)
1. Add "batch delete" button for selected issues, close #22273 2. Address the review in https://github.com/go-gitea/gitea/pull/25219#discussion_r1229266083
This commit is contained in:
		| @@ -140,6 +140,10 @@ func (b *Base) JSONRedirect(redirect string) { | |||||||
| 	b.JSON(http.StatusOK, map[string]any{"redirect": redirect}) | 	b.JSON(http.StatusOK, map[string]any{"redirect": redirect}) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func (b *Base) JSONOK() { | ||||||
|  | 	b.JSON(http.StatusOK, map[string]any{"ok": true}) // this is only a dummy response, frontend seldom uses it | ||||||
|  | } | ||||||
|  |  | ||||||
| func (b *Base) JSONError(msg string) { | func (b *Base) JSONError(msg string) { | ||||||
| 	b.JSON(http.StatusBadRequest, map[string]any{"errorMessage": msg}) | 	b.JSON(http.StatusBadRequest, map[string]any{"errorMessage": msg}) | ||||||
| } | } | ||||||
|   | |||||||
| @@ -130,6 +130,8 @@ show_timestamps = Show timestamps | |||||||
| show_log_seconds = Show seconds | show_log_seconds = Show seconds | ||||||
| show_full_screen = Show full screen | show_full_screen = Show full screen | ||||||
|  |  | ||||||
|  | confirm_delete_selected = Confirm to delete all selected items? | ||||||
|  |  | ||||||
| [aria] | [aria] | ||||||
| navbar = Navigation Bar | navbar = Navigation Bar | ||||||
| footer = Footer | footer = Footer | ||||||
|   | |||||||
| @@ -2705,6 +2705,20 @@ func ListIssues(ctx *context.Context) { | |||||||
| 	ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, issues)) | 	ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, issues)) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | func BatchDeleteIssues(ctx *context.Context) { | ||||||
|  | 	issues := getActionIssues(ctx) | ||||||
|  | 	if ctx.Written() { | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	for _, issue := range issues { | ||||||
|  | 		if err := issue_service.DeleteIssue(ctx, ctx.Doer, ctx.Repo.GitRepo, issue); err != nil { | ||||||
|  | 			ctx.ServerError("DeleteIssue", err) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	ctx.JSONOK() | ||||||
|  | } | ||||||
|  |  | ||||||
| // UpdateIssueStatus change issue's status | // UpdateIssueStatus change issue's status | ||||||
| func UpdateIssueStatus(ctx *context.Context) { | func UpdateIssueStatus(ctx *context.Context) { | ||||||
| 	issues := getActionIssues(ctx) | 	issues := getActionIssues(ctx) | ||||||
| @@ -2740,9 +2754,7 @@ func UpdateIssueStatus(ctx *context.Context) { | |||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 	ctx.JSON(http.StatusOK, map[string]interface{}{ | 	ctx.JSONOK() | ||||||
| 		"ok": true, |  | ||||||
| 	}) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // NewComment create a comment for issue | // NewComment create a comment for issue | ||||||
|   | |||||||
| @@ -1024,6 +1024,7 @@ func registerRoutes(m *web.Route) { | |||||||
| 			m.Post("/request_review", reqRepoIssuesOrPullsReader, repo.UpdatePullReviewRequest) | 			m.Post("/request_review", reqRepoIssuesOrPullsReader, repo.UpdatePullReviewRequest) | ||||||
| 			m.Post("/dismiss_review", reqRepoAdmin, web.Bind(forms.DismissReviewForm{}), repo.DismissReview) | 			m.Post("/dismiss_review", reqRepoAdmin, web.Bind(forms.DismissReviewForm{}), repo.DismissReview) | ||||||
| 			m.Post("/status", reqRepoIssuesOrPullsWriter, repo.UpdateIssueStatus) | 			m.Post("/status", reqRepoIssuesOrPullsWriter, repo.UpdateIssueStatus) | ||||||
|  | 			m.Post("/delete", reqRepoAdmin, repo.BatchDeleteIssues) | ||||||
| 			m.Post("/resolve_conversation", reqRepoIssuesOrPullsReader, repo.UpdateResolveConversation) | 			m.Post("/resolve_conversation", reqRepoIssuesOrPullsReader, repo.UpdateResolveConversation) | ||||||
| 			m.Post("/attachments", repo.UploadIssueAttachment) | 			m.Post("/attachments", repo.UploadIssueAttachment) | ||||||
| 			m.Post("/attachments/remove", repo.DeleteAttachment) | 			m.Post("/attachments/remove", repo.DeleteAttachment) | ||||||
|   | |||||||
| @@ -8,7 +8,9 @@ | |||||||
| 			It might be renamed to "link-fetch-action" to match the "form-fetch-action". | 			It might be renamed to "link-fetch-action" to match the "form-fetch-action". | ||||||
| 		</div> | 		</div> | ||||||
| 		<div> | 		<div> | ||||||
| 			<button class="link-action" data-url="fetch-action-test?k=1">test</button> | 			<button class="link-action" data-url="fetch-action-test?k=1">test action</button> | ||||||
|  | 			<button class="link-action" data-url="fetch-action-test?k=1" data-modal-confirm="confirm?">test with confirm</button> | ||||||
|  | 			<button class="ui red button link-action" data-url="fetch-action-test?k=1" data-modal-confirm="confirm?">test with risky confirm</button> | ||||||
| 		</div> | 		</div> | ||||||
| 	</div> | 	</div> | ||||||
| 	<div> | 	<div> | ||||||
|   | |||||||
| @@ -282,9 +282,15 @@ | |||||||
| 					{{if not .Repository.IsArchived}} | 					{{if not .Repository.IsArchived}} | ||||||
| 					<!-- Action Button --> | 					<!-- Action Button --> | ||||||
| 					{{if .IsShowClosed}} | 					{{if .IsShowClosed}} | ||||||
| 						<button class="ui green active basic button issue-action gt-ml-auto" data-action="open" data-url="{{$.RepoLink}}/issues/status">{{.locale.Tr "repo.issues.action_open"}}</button> | 						<button class="ui green basic button issue-action gt-ml-auto" data-action="open" data-url="{{$.RepoLink}}/issues/status">{{.locale.Tr "repo.issues.action_open"}}</button> | ||||||
| 					{{else}} | 					{{else}} | ||||||
| 						<button class="ui red active basic button issue-action gt-ml-auto" data-action="close" data-url="{{$.RepoLink}}/issues/status">{{.locale.Tr "repo.issues.action_close"}}</button> | 						<button class="ui red basic button issue-action gt-ml-auto" data-action="close" data-url="{{$.RepoLink}}/issues/status">{{.locale.Tr "repo.issues.action_close"}}</button> | ||||||
|  | 					{{end}} | ||||||
|  | 					{{if $.IsRepoAdmin}} | ||||||
|  | 						<button class="ui red button issue-action gt-ml-auto" | ||||||
|  | 							data-action="delete" data-url="{{$.RepoLink}}/issues/delete" | ||||||
|  | 							data-action-delete-confirm="{{.locale.Tr "confirm_delete_selected"}}" | ||||||
|  | 						>{{.locale.Tr "repo.issues.delete"}}</button> | ||||||
| 					{{end}} | 					{{end}} | ||||||
| 					<!-- Labels --> | 					<!-- Labels --> | ||||||
| 					<div class="ui {{if not .Labels}}disabled{{end}} dropdown jump item"> | 					<div class="ui {{if not .Labels}}disabled{{end}} dropdown jump item"> | ||||||
|   | |||||||
| @@ -8,6 +8,7 @@ import {svg} from '../svg.js'; | |||||||
| import {hideElem, showElem, toggleElem} from '../utils/dom.js'; | import {hideElem, showElem, toggleElem} from '../utils/dom.js'; | ||||||
| import {htmlEscape} from 'escape-goat'; | import {htmlEscape} from 'escape-goat'; | ||||||
| import {createTippy} from '../modules/tippy.js'; | import {createTippy} from '../modules/tippy.js'; | ||||||
|  | import {confirmModal} from './comp/ConfirmModal.js'; | ||||||
|  |  | ||||||
| const {appUrl, appSubUrl, csrfToken, i18n} = window.config; | const {appUrl, appSubUrl, csrfToken, i18n} = window.config; | ||||||
|  |  | ||||||
| @@ -264,7 +265,7 @@ export function initGlobalDropzone() { | |||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| function linkAction(e) { | async function linkAction(e) { | ||||||
|   e.preventDefault(); |   e.preventDefault(); | ||||||
|  |  | ||||||
|   // A "link-action" can post AJAX request to its "data-url" |   // A "link-action" can post AJAX request to its "data-url" | ||||||
| @@ -291,33 +292,16 @@ function linkAction(e) { | |||||||
|     }); |     }); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   const modalConfirmHtml = htmlEscape($this.attr('data-modal-confirm') || ''); |   const modalConfirmContent = htmlEscape($this.attr('data-modal-confirm') || ''); | ||||||
|   if (!modalConfirmHtml) { |   if (!modalConfirmContent) { | ||||||
|     doRequest(); |     doRequest(); | ||||||
|     return; |     return; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   const okButtonColor = $this.hasClass('red') || $this.hasClass('yellow') || $this.hasClass('orange') || $this.hasClass('negative') ? 'orange' : 'green'; |   const isRisky = $this.hasClass('red') || $this.hasClass('yellow') || $this.hasClass('orange') || $this.hasClass('negative'); | ||||||
|  |   if (await confirmModal({content: modalConfirmContent, buttonColor: isRisky ? 'orange' : 'green'})) { | ||||||
|   const $modal = $(` |  | ||||||
| <div class="ui g-modal-confirm modal"> |  | ||||||
|   <div class="content">${modalConfirmHtml}</div> |  | ||||||
|   <div class="actions"> |  | ||||||
|     <button class="ui basic cancel button">${svg('octicon-x')} ${i18n.modal_cancel}</button> |  | ||||||
|     <button class="ui ${okButtonColor} ok button">${svg('octicon-check')} ${i18n.modal_confirm}</button> |  | ||||||
|   </div> |  | ||||||
| </div> |  | ||||||
| `); |  | ||||||
|  |  | ||||||
|   $modal.appendTo(document.body); |  | ||||||
|   $modal.modal({ |  | ||||||
|     onApprove() { |  | ||||||
|     doRequest(); |     doRequest(); | ||||||
|     }, |   } | ||||||
|     onHidden() { |  | ||||||
|       $modal.remove(); |  | ||||||
|     }, |  | ||||||
|   }).modal('show'); |  | ||||||
| } | } | ||||||
|  |  | ||||||
| export function initGlobalLinkActions() { | export function initGlobalLinkActions() { | ||||||
|   | |||||||
							
								
								
									
										30
									
								
								web_src/js/features/comp/ConfirmModal.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								web_src/js/features/comp/ConfirmModal.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | |||||||
|  | import $ from 'jquery'; | ||||||
|  | import {svg} from '../../svg.js'; | ||||||
|  | import {htmlEscape} from 'escape-goat'; | ||||||
|  |  | ||||||
|  | const {i18n} = window.config; | ||||||
|  |  | ||||||
|  | export async function confirmModal(opts = {content: '', buttonColor: 'green'}) { | ||||||
|  |   return new Promise((resolve) => { | ||||||
|  |     const $modal = $(` | ||||||
|  | <div class="ui g-modal-confirm modal"> | ||||||
|  |   <div class="content">${htmlEscape(opts.content)}</div> | ||||||
|  |   <div class="actions"> | ||||||
|  |     <button class="ui basic cancel button">${svg('octicon-x')} ${i18n.modal_cancel}</button> | ||||||
|  |     <button class="ui ${opts.buttonColor || 'green'} ok button">${svg('octicon-check')} ${i18n.modal_confirm}</button> | ||||||
|  |   </div> | ||||||
|  | </div> | ||||||
|  | `); | ||||||
|  |  | ||||||
|  |     $modal.appendTo(document.body); | ||||||
|  |     $modal.modal({ | ||||||
|  |       onApprove() { | ||||||
|  |         resolve(true); | ||||||
|  |       }, | ||||||
|  |       onHidden() { | ||||||
|  |         $modal.remove(); | ||||||
|  |         resolve(false); | ||||||
|  |       }, | ||||||
|  |     }).modal('show'); | ||||||
|  |   }); | ||||||
|  | } | ||||||
| @@ -3,6 +3,7 @@ import {updateIssuesMeta} from './repo-issue.js'; | |||||||
| import {toggleElem} from '../utils/dom.js'; | import {toggleElem} from '../utils/dom.js'; | ||||||
| import {htmlEscape} from 'escape-goat'; | import {htmlEscape} from 'escape-goat'; | ||||||
| import {Sortable} from 'sortablejs'; | import {Sortable} from 'sortablejs'; | ||||||
|  | import {confirmModal} from './comp/ConfirmModal.js'; | ||||||
|  |  | ||||||
| function initRepoIssueListCheckboxes() { | function initRepoIssueListCheckboxes() { | ||||||
|   const $issueSelectAll = $('.issue-checkbox-all'); |   const $issueSelectAll = $('.issue-checkbox-all'); | ||||||
| @@ -36,19 +37,36 @@ function initRepoIssueListCheckboxes() { | |||||||
|  |  | ||||||
|   $('.issue-action').on('click', async function (e) { |   $('.issue-action').on('click', async function (e) { | ||||||
|     e.preventDefault(); |     e.preventDefault(); | ||||||
|  |  | ||||||
|  |     const url = this.getAttribute('data-url'); | ||||||
|     let action = this.getAttribute('data-action'); |     let action = this.getAttribute('data-action'); | ||||||
|     let elementId = this.getAttribute('data-element-id'); |     let elementId = this.getAttribute('data-element-id'); | ||||||
|     const url = this.getAttribute('data-url'); |     let issueIDs = []; | ||||||
|     const issueIDs = $('.issue-checkbox:checked').map((_, el) => { |     for (const el of document.querySelectorAll('.issue-checkbox:checked')) { | ||||||
|       return el.getAttribute('data-issue-id'); |       issueIDs.push(el.getAttribute('data-issue-id')); | ||||||
|     }).get().join(','); |     } | ||||||
|     if (elementId === '0' && url.slice(-9) === '/assignee') { |     issueIDs = issueIDs.join(','); | ||||||
|  |     if (!issueIDs) return; | ||||||
|  |  | ||||||
|  |     // for assignee | ||||||
|  |     if (elementId === '0' && url.endsWith('/assignee')) { | ||||||
|       elementId = ''; |       elementId = ''; | ||||||
|       action = 'clear'; |       action = 'clear'; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     // for toggle | ||||||
|     if (action === 'toggle' && e.altKey) { |     if (action === 'toggle' && e.altKey) { | ||||||
|       action = 'toggle-alt'; |       action = 'toggle-alt'; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     // for delete | ||||||
|  |     if (action === 'delete') { | ||||||
|  |       const confirmText = e.target.getAttribute('data-action-delete-confirm'); | ||||||
|  |       if (!await confirmModal({content: confirmText, buttonColor: 'orange'})) { | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     updateIssuesMeta( |     updateIssuesMeta( | ||||||
|       url, |       url, | ||||||
|       action, |       action, | ||||||
|   | |||||||
| @@ -3,11 +3,9 @@ import tippy from 'tippy.js'; | |||||||
| const visibleInstances = new Set(); | const visibleInstances = new Set(); | ||||||
|  |  | ||||||
| export function createTippy(target, opts = {}) { | export function createTippy(target, opts = {}) { | ||||||
|   const {role, content, onHide: optsOnHide, onDestroy: optsOnDestroy, onShow: optOnShow} = opts; |   // the callback functions should be destructured from opts, | ||||||
|   delete opts.onHide; |   // because we should use our own wrapper functions to handle them, do not let the user override them | ||||||
|   delete opts.onDestroy; |   const {onHide, onShow, onDestroy, ...other} = opts; | ||||||
|   delete opts.onShow; |  | ||||||
|  |  | ||||||
|   const instance = tippy(target, { |   const instance = tippy(target, { | ||||||
|     appendTo: document.body, |     appendTo: document.body, | ||||||
|     animation: false, |     animation: false, | ||||||
| @@ -18,11 +16,11 @@ export function createTippy(target, opts = {}) { | |||||||
|     maxWidth: 500, // increase over default 350px |     maxWidth: 500, // increase over default 350px | ||||||
|     onHide: (instance) => { |     onHide: (instance) => { | ||||||
|       visibleInstances.delete(instance); |       visibleInstances.delete(instance); | ||||||
|       return optsOnHide?.(instance); |       return onHide?.(instance); | ||||||
|     }, |     }, | ||||||
|     onDestroy: (instance) => { |     onDestroy: (instance) => { | ||||||
|       visibleInstances.delete(instance); |       visibleInstances.delete(instance); | ||||||
|       return optsOnDestroy?.(instance); |       return onDestroy?.(instance); | ||||||
|     }, |     }, | ||||||
|     onShow: (instance) => { |     onShow: (instance) => { | ||||||
|       // hide other tooltip instances so only one tooltip shows at a time |       // hide other tooltip instances so only one tooltip shows at a time | ||||||
| @@ -32,19 +30,19 @@ export function createTippy(target, opts = {}) { | |||||||
|         } |         } | ||||||
|       } |       } | ||||||
|       visibleInstances.add(instance); |       visibleInstances.add(instance); | ||||||
|       return optOnShow?.(instance); |       return onShow?.(instance); | ||||||
|     }, |     }, | ||||||
|     arrow: `<svg width="16" height="7"><path d="m0 7 8-7 8 7Z" class="tippy-svg-arrow-outer"/><path d="m0 8 8-7 8 7Z" class="tippy-svg-arrow-inner"/></svg>`, |     arrow: `<svg width="16" height="7"><path d="m0 7 8-7 8 7Z" class="tippy-svg-arrow-outer"/><path d="m0 8 8-7 8 7Z" class="tippy-svg-arrow-inner"/></svg>`, | ||||||
|     role: 'menu', // HTML role attribute, only tooltips should use "tooltip" |     role: 'menu', // HTML role attribute, only tooltips should use "tooltip" | ||||||
|     theme: role || 'menu', // CSS theme, we support either "tooltip" or "menu" |     theme: other.role || 'menu', // CSS theme, we support either "tooltip" or "menu" | ||||||
|     ...opts, |     ...other, | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   // for popups where content refers to a DOM element, we use the 'tippy-target' class |   // for popups where content refers to a DOM element, we use the 'tippy-target' class | ||||||
|   // to initially hide the content, now we can remove it as the content has been removed |   // to initially hide the content, now we can remove it as the content has been removed | ||||||
|   // from the DOM by tippy |   // from the DOM by tippy | ||||||
|   if (content instanceof Element) { |   if (other.content instanceof Element) { | ||||||
|     content.classList.remove('tippy-target'); |     other.content.classList.remove('tippy-target'); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   return instance; |   return instance; | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user