aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--modules/context/base.go4
-rw-r--r--options/locale/locale_en-US.ini2
-rw-r--r--routers/web/repo/issue.go18
-rw-r--r--routers/web/web.go1
-rw-r--r--templates/devtest/fetch-action.tmpl4
-rw-r--r--templates/repo/issue/list.tmpl10
-rw-r--r--web_src/js/features/common-global.js32
-rw-r--r--web_src/js/features/comp/ConfirmModal.js30
-rw-r--r--web_src/js/features/repo-issue-list.js28
-rw-r--r--web_src/js/modules/tippy.js22
10 files changed, 104 insertions, 47 deletions
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;