From a1c5057fe81c25dfd1777e9625eb5480c45897ea Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Mon, 19 Jun 2023 15:46:50 +0800
Subject: [PATCH] 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
---
 modules/context/base.go                  |  4 +++
 options/locale/locale_en-US.ini          |  2 ++
 routers/web/repo/issue.go                | 18 ++++++++++---
 routers/web/web.go                       |  1 +
 templates/devtest/fetch-action.tmpl      |  4 ++-
 templates/repo/issue/list.tmpl           | 10 ++++++--
 web_src/js/features/common-global.js     | 32 ++++++------------------
 web_src/js/features/comp/ConfirmModal.js | 30 ++++++++++++++++++++++
 web_src/js/features/repo-issue-list.js   | 28 +++++++++++++++++----
 web_src/js/modules/tippy.js              | 22 ++++++++--------
 10 files changed, 104 insertions(+), 47 deletions(-)
 create mode 100644 web_src/js/features/comp/ConfirmModal.js

diff --git a/modules/context/base.go b/modules/context/base.go
index 45f33feb08..839f3e10df 100644
--- a/modules/context/base.go
+++ b/modules/context/base.go
@@ -140,6 +140,10 @@ func (b *Base) JSONRedirect(redirect string) {
 	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) {
 	b.JSON(http.StatusBadRequest, map[string]any{"errorMessage": msg})
 }
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 25456d0493..6cab7c0cbb 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -130,6 +130,8 @@ show_timestamps = Show timestamps
 show_log_seconds = Show seconds
 show_full_screen = Show full screen
 
+confirm_delete_selected = Confirm to delete all selected items?
+
 [aria]
 navbar = Navigation Bar
 footer = Footer
diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go
index 9f087edc72..49ba753a7d 100644
--- a/routers/web/repo/issue.go
+++ b/routers/web/repo/issue.go
@@ -2705,6 +2705,20 @@ func ListIssues(ctx *context.Context) {
 	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
 func UpdateIssueStatus(ctx *context.Context) {
 	issues := getActionIssues(ctx)
@@ -2740,9 +2754,7 @@ func UpdateIssueStatus(ctx *context.Context) {
 			}
 		}
 	}
-	ctx.JSON(http.StatusOK, map[string]interface{}{
-		"ok": true,
-	})
+	ctx.JSONOK()
 }
 
 // NewComment create a comment for issue
diff --git a/routers/web/web.go b/routers/web/web.go
index fae935a507..8ac01f1742 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -1024,6 +1024,7 @@ func registerRoutes(m *web.Route) {
 			m.Post("/request_review", reqRepoIssuesOrPullsReader, repo.UpdatePullReviewRequest)
 			m.Post("/dismiss_review", reqRepoAdmin, web.Bind(forms.DismissReviewForm{}), repo.DismissReview)
 			m.Post("/status", reqRepoIssuesOrPullsWriter, repo.UpdateIssueStatus)
+			m.Post("/delete", reqRepoAdmin, repo.BatchDeleteIssues)
 			m.Post("/resolve_conversation", reqRepoIssuesOrPullsReader, repo.UpdateResolveConversation)
 			m.Post("/attachments", repo.UploadIssueAttachment)
 			m.Post("/attachments/remove", repo.DeleteAttachment)
diff --git a/templates/devtest/fetch-action.tmpl b/templates/devtest/fetch-action.tmpl
index 2fb7289ebe..70844a8751 100644
--- a/templates/devtest/fetch-action.tmpl
+++ b/templates/devtest/fetch-action.tmpl
@@ -8,7 +8,9 @@
 			It might be renamed to "link-fetch-action" to match the "form-fetch-action".
 		</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>
diff --git a/templates/repo/issue/list.tmpl b/templates/repo/issue/list.tmpl
index 5c9a5937a1..12eb31acdc 100644
--- a/templates/repo/issue/list.tmpl
+++ b/templates/repo/issue/list.tmpl
@@ -282,9 +282,15 @@
 					{{if not .Repository.IsArchived}}
 					<!-- Action Button -->
 					{{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}}
-						<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}}
 					<!-- Labels -->
 					<div class="ui {{if not .Labels}}disabled{{end}} dropdown jump item">
diff --git a/web_src/js/features/common-global.js b/web_src/js/features/common-global.js
index b6e1790a90..5e418fa48b 100644
--- a/web_src/js/features/common-global.js
+++ b/web_src/js/features/common-global.js
@@ -8,6 +8,7 @@ import {svg} from '../svg.js';
 import {hideElem, showElem, toggleElem} from '../utils/dom.js';
 import {htmlEscape} from 'escape-goat';
 import {createTippy} from '../modules/tippy.js';
+import {confirmModal} from './comp/ConfirmModal.js';
 
 const {appUrl, appSubUrl, csrfToken, i18n} = window.config;
 
@@ -264,7 +265,7 @@ export function initGlobalDropzone() {
   }
 }
 
-function linkAction(e) {
+async function linkAction(e) {
   e.preventDefault();
 
   // 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') || '');
-  if (!modalConfirmHtml) {
+  const modalConfirmContent = htmlEscape($this.attr('data-modal-confirm') || '');
+  if (!modalConfirmContent) {
     doRequest();
     return;
   }
 
-  const okButtonColor = $this.hasClass('red') || $this.hasClass('yellow') || $this.hasClass('orange') || $this.hasClass('negative') ? '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();
-    },
-    onHidden() {
-      $modal.remove();
-    },
-  }).modal('show');
+  const isRisky = $this.hasClass('red') || $this.hasClass('yellow') || $this.hasClass('orange') || $this.hasClass('negative');
+  if (await confirmModal({content: modalConfirmContent, buttonColor: isRisky ? 'orange' : 'green'})) {
+    doRequest();
+  }
 }
 
 export function initGlobalLinkActions() {
diff --git a/web_src/js/features/comp/ConfirmModal.js b/web_src/js/features/comp/ConfirmModal.js
new file mode 100644
index 0000000000..1edcfd9522
--- /dev/null
+++ b/web_src/js/features/comp/ConfirmModal.js
@@ -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');
+  });
+}
diff --git a/web_src/js/features/repo-issue-list.js b/web_src/js/features/repo-issue-list.js
index cc50ec5f88..4d61de0ce5 100644
--- a/web_src/js/features/repo-issue-list.js
+++ b/web_src/js/features/repo-issue-list.js
@@ -3,6 +3,7 @@ import {updateIssuesMeta} from './repo-issue.js';
 import {toggleElem} from '../utils/dom.js';
 import {htmlEscape} from 'escape-goat';
 import {Sortable} from 'sortablejs';
+import {confirmModal} from './comp/ConfirmModal.js';
 
 function initRepoIssueListCheckboxes() {
   const $issueSelectAll = $('.issue-checkbox-all');
@@ -36,19 +37,36 @@ function initRepoIssueListCheckboxes() {
 
   $('.issue-action').on('click', async function (e) {
     e.preventDefault();
+
+    const url = this.getAttribute('data-url');
     let action = this.getAttribute('data-action');
     let elementId = this.getAttribute('data-element-id');
-    const url = this.getAttribute('data-url');
-    const issueIDs = $('.issue-checkbox:checked').map((_, el) => {
-      return el.getAttribute('data-issue-id');
-    }).get().join(',');
-    if (elementId === '0' && url.slice(-9) === '/assignee') {
+    let issueIDs = [];
+    for (const el of document.querySelectorAll('.issue-checkbox:checked')) {
+      issueIDs.push(el.getAttribute('data-issue-id'));
+    }
+    issueIDs = issueIDs.join(',');
+    if (!issueIDs) return;
+
+    // for assignee
+    if (elementId === '0' && url.endsWith('/assignee')) {
       elementId = '';
       action = 'clear';
     }
+
+    // for toggle
     if (action === 'toggle' && e.altKey) {
       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(
       url,
       action,
diff --git a/web_src/js/modules/tippy.js b/web_src/js/modules/tippy.js
index 3409e1c714..372f7bc8f3 100644
--- a/web_src/js/modules/tippy.js
+++ b/web_src/js/modules/tippy.js
@@ -3,11 +3,9 @@ import tippy from 'tippy.js';
 const visibleInstances = new Set();
 
 export function createTippy(target, opts = {}) {
-  const {role, content, onHide: optsOnHide, onDestroy: optsOnDestroy, onShow: optOnShow} = opts;
-  delete opts.onHide;
-  delete opts.onDestroy;
-  delete opts.onShow;
-
+  // the callback functions should be destructured from opts,
+  // because we should use our own wrapper functions to handle them, do not let the user override them
+  const {onHide, onShow, onDestroy, ...other} = opts;
   const instance = tippy(target, {
     appendTo: document.body,
     animation: false,
@@ -18,11 +16,11 @@ export function createTippy(target, opts = {}) {
     maxWidth: 500, // increase over default 350px
     onHide: (instance) => {
       visibleInstances.delete(instance);
-      return optsOnHide?.(instance);
+      return onHide?.(instance);
     },
     onDestroy: (instance) => {
       visibleInstances.delete(instance);
-      return optsOnDestroy?.(instance);
+      return onDestroy?.(instance);
     },
     onShow: (instance) => {
       // hide other tooltip instances so only one tooltip shows at a time
@@ -32,19 +30,19 @@ export function createTippy(target, opts = {}) {
         }
       }
       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>`,
     role: 'menu', // HTML role attribute, only tooltips should use "tooltip"
-    theme: role || 'menu', // CSS theme, we support either "tooltip" or "menu"
-    ...opts,
+    theme: other.role || 'menu', // CSS theme, we support either "tooltip" or "menu"
+    ...other,
   });
 
   // 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
   // from the DOM by tippy
-  if (content instanceof Element) {
-    content.classList.remove('tippy-target');
+  if (other.content instanceof Element) {
+    other.content.classList.remove('tippy-target');
   }
 
   return instance;