Skip to content

Commit a1c5057

Browse files
authored
Batch delete issue and improve tippy opts (#25253)
1. Add "batch delete" button for selected issues, close #22273 2. Address the review in #25219 (comment)
1 parent 51c2aeb commit a1c5057

File tree

10 files changed

+104
-47
lines changed

10 files changed

+104
-47
lines changed

modules/context/base.go

+4
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,10 @@ func (b *Base) JSONRedirect(redirect string) {
140140
b.JSON(http.StatusOK, map[string]any{"redirect": redirect})
141141
}
142142

143+
func (b *Base) JSONOK() {
144+
b.JSON(http.StatusOK, map[string]any{"ok": true}) // this is only a dummy response, frontend seldom uses it
145+
}
146+
143147
func (b *Base) JSONError(msg string) {
144148
b.JSON(http.StatusBadRequest, map[string]any{"errorMessage": msg})
145149
}

options/locale/locale_en-US.ini

+2
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ show_timestamps = Show timestamps
130130
show_log_seconds = Show seconds
131131
show_full_screen = Show full screen
132132

133+
confirm_delete_selected = Confirm to delete all selected items?
134+
133135
[aria]
134136
navbar = Navigation Bar
135137
footer = Footer

routers/web/repo/issue.go

+15-3
Original file line numberDiff line numberDiff line change
@@ -2705,6 +2705,20 @@ func ListIssues(ctx *context.Context) {
27052705
ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, issues))
27062706
}
27072707

2708+
func BatchDeleteIssues(ctx *context.Context) {
2709+
issues := getActionIssues(ctx)
2710+
if ctx.Written() {
2711+
return
2712+
}
2713+
for _, issue := range issues {
2714+
if err := issue_service.DeleteIssue(ctx, ctx.Doer, ctx.Repo.GitRepo, issue); err != nil {
2715+
ctx.ServerError("DeleteIssue", err)
2716+
return
2717+
}
2718+
}
2719+
ctx.JSONOK()
2720+
}
2721+
27082722
// UpdateIssueStatus change issue's status
27092723
func UpdateIssueStatus(ctx *context.Context) {
27102724
issues := getActionIssues(ctx)
@@ -2740,9 +2754,7 @@ func UpdateIssueStatus(ctx *context.Context) {
27402754
}
27412755
}
27422756
}
2743-
ctx.JSON(http.StatusOK, map[string]interface{}{
2744-
"ok": true,
2745-
})
2757+
ctx.JSONOK()
27462758
}
27472759

27482760
// NewComment create a comment for issue

routers/web/web.go

+1
Original file line numberDiff line numberDiff line change
@@ -1024,6 +1024,7 @@ func registerRoutes(m *web.Route) {
10241024
m.Post("/request_review", reqRepoIssuesOrPullsReader, repo.UpdatePullReviewRequest)
10251025
m.Post("/dismiss_review", reqRepoAdmin, web.Bind(forms.DismissReviewForm{}), repo.DismissReview)
10261026
m.Post("/status", reqRepoIssuesOrPullsWriter, repo.UpdateIssueStatus)
1027+
m.Post("/delete", reqRepoAdmin, repo.BatchDeleteIssues)
10271028
m.Post("/resolve_conversation", reqRepoIssuesOrPullsReader, repo.UpdateResolveConversation)
10281029
m.Post("/attachments", repo.UploadIssueAttachment)
10291030
m.Post("/attachments/remove", repo.DeleteAttachment)

templates/devtest/fetch-action.tmpl

+3-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
It might be renamed to "link-fetch-action" to match the "form-fetch-action".
99
</div>
1010
<div>
11-
<button class="link-action" data-url="fetch-action-test?k=1">test</button>
11+
<button class="link-action" data-url="fetch-action-test?k=1">test action</button>
12+
<button class="link-action" data-url="fetch-action-test?k=1" data-modal-confirm="confirm?">test with confirm</button>
13+
<button class="ui red button link-action" data-url="fetch-action-test?k=1" data-modal-confirm="confirm?">test with risky confirm</button>
1214
</div>
1315
</div>
1416
<div>

templates/repo/issue/list.tmpl

+8-2
Original file line numberDiff line numberDiff line change
@@ -282,9 +282,15 @@
282282
{{if not .Repository.IsArchived}}
283283
<!-- Action Button -->
284284
{{if .IsShowClosed}}
285-
<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>
285+
<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>
286286
{{else}}
287-
<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>
287+
<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>
288+
{{end}}
289+
{{if $.IsRepoAdmin}}
290+
<button class="ui red button issue-action gt-ml-auto"
291+
data-action="delete" data-url="{{$.RepoLink}}/issues/delete"
292+
data-action-delete-confirm="{{.locale.Tr "confirm_delete_selected"}}"
293+
>{{.locale.Tr "repo.issues.delete"}}</button>
288294
{{end}}
289295
<!-- Labels -->
290296
<div class="ui {{if not .Labels}}disabled{{end}} dropdown jump item">

web_src/js/features/common-global.js

+8-24
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {svg} from '../svg.js';
88
import {hideElem, showElem, toggleElem} from '../utils/dom.js';
99
import {htmlEscape} from 'escape-goat';
1010
import {createTippy} from '../modules/tippy.js';
11+
import {confirmModal} from './comp/ConfirmModal.js';
1112

1213
const {appUrl, appSubUrl, csrfToken, i18n} = window.config;
1314

@@ -264,7 +265,7 @@ export function initGlobalDropzone() {
264265
}
265266
}
266267

267-
function linkAction(e) {
268+
async function linkAction(e) {
268269
e.preventDefault();
269270

270271
// A "link-action" can post AJAX request to its "data-url"
@@ -291,33 +292,16 @@ function linkAction(e) {
291292
});
292293
};
293294

294-
const modalConfirmHtml = htmlEscape($this.attr('data-modal-confirm') || '');
295-
if (!modalConfirmHtml) {
295+
const modalConfirmContent = htmlEscape($this.attr('data-modal-confirm') || '');
296+
if (!modalConfirmContent) {
296297
doRequest();
297298
return;
298299
}
299300

300-
const okButtonColor = $this.hasClass('red') || $this.hasClass('yellow') || $this.hasClass('orange') || $this.hasClass('negative') ? 'orange' : 'green';
301-
302-
const $modal = $(`
303-
<div class="ui g-modal-confirm modal">
304-
<div class="content">${modalConfirmHtml}</div>
305-
<div class="actions">
306-
<button class="ui basic cancel button">${svg('octicon-x')} ${i18n.modal_cancel}</button>
307-
<button class="ui ${okButtonColor} ok button">${svg('octicon-check')} ${i18n.modal_confirm}</button>
308-
</div>
309-
</div>
310-
`);
311-
312-
$modal.appendTo(document.body);
313-
$modal.modal({
314-
onApprove() {
315-
doRequest();
316-
},
317-
onHidden() {
318-
$modal.remove();
319-
},
320-
}).modal('show');
301+
const isRisky = $this.hasClass('red') || $this.hasClass('yellow') || $this.hasClass('orange') || $this.hasClass('negative');
302+
if (await confirmModal({content: modalConfirmContent, buttonColor: isRisky ? 'orange' : 'green'})) {
303+
doRequest();
304+
}
321305
}
322306

323307
export function initGlobalLinkActions() {
+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import $ from 'jquery';
2+
import {svg} from '../../svg.js';
3+
import {htmlEscape} from 'escape-goat';
4+
5+
const {i18n} = window.config;
6+
7+
export async function confirmModal(opts = {content: '', buttonColor: 'green'}) {
8+
return new Promise((resolve) => {
9+
const $modal = $(`
10+
<div class="ui g-modal-confirm modal">
11+
<div class="content">${htmlEscape(opts.content)}</div>
12+
<div class="actions">
13+
<button class="ui basic cancel button">${svg('octicon-x')} ${i18n.modal_cancel}</button>
14+
<button class="ui ${opts.buttonColor || 'green'} ok button">${svg('octicon-check')} ${i18n.modal_confirm}</button>
15+
</div>
16+
</div>
17+
`);
18+
19+
$modal.appendTo(document.body);
20+
$modal.modal({
21+
onApprove() {
22+
resolve(true);
23+
},
24+
onHidden() {
25+
$modal.remove();
26+
resolve(false);
27+
},
28+
}).modal('show');
29+
});
30+
}

web_src/js/features/repo-issue-list.js

+23-5
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {updateIssuesMeta} from './repo-issue.js';
33
import {toggleElem} from '../utils/dom.js';
44
import {htmlEscape} from 'escape-goat';
55
import {Sortable} from 'sortablejs';
6+
import {confirmModal} from './comp/ConfirmModal.js';
67

78
function initRepoIssueListCheckboxes() {
89
const $issueSelectAll = $('.issue-checkbox-all');
@@ -36,19 +37,36 @@ function initRepoIssueListCheckboxes() {
3637

3738
$('.issue-action').on('click', async function (e) {
3839
e.preventDefault();
40+
41+
const url = this.getAttribute('data-url');
3942
let action = this.getAttribute('data-action');
4043
let elementId = this.getAttribute('data-element-id');
41-
const url = this.getAttribute('data-url');
42-
const issueIDs = $('.issue-checkbox:checked').map((_, el) => {
43-
return el.getAttribute('data-issue-id');
44-
}).get().join(',');
45-
if (elementId === '0' && url.slice(-9) === '/assignee') {
44+
let issueIDs = [];
45+
for (const el of document.querySelectorAll('.issue-checkbox:checked')) {
46+
issueIDs.push(el.getAttribute('data-issue-id'));
47+
}
48+
issueIDs = issueIDs.join(',');
49+
if (!issueIDs) return;
50+
51+
// for assignee
52+
if (elementId === '0' && url.endsWith('/assignee')) {
4653
elementId = '';
4754
action = 'clear';
4855
}
56+
57+
// for toggle
4958
if (action === 'toggle' && e.altKey) {
5059
action = 'toggle-alt';
5160
}
61+
62+
// for delete
63+
if (action === 'delete') {
64+
const confirmText = e.target.getAttribute('data-action-delete-confirm');
65+
if (!await confirmModal({content: confirmText, buttonColor: 'orange'})) {
66+
return;
67+
}
68+
}
69+
5270
updateIssuesMeta(
5371
url,
5472
action,

web_src/js/modules/tippy.js

+10-12
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,9 @@ import tippy from 'tippy.js';
33
const visibleInstances = new Set();
44

55
export function createTippy(target, opts = {}) {
6-
const {role, content, onHide: optsOnHide, onDestroy: optsOnDestroy, onShow: optOnShow} = opts;
7-
delete opts.onHide;
8-
delete opts.onDestroy;
9-
delete opts.onShow;
10-
6+
// the callback functions should be destructured from opts,
7+
// because we should use our own wrapper functions to handle them, do not let the user override them
8+
const {onHide, onShow, onDestroy, ...other} = opts;
119
const instance = tippy(target, {
1210
appendTo: document.body,
1311
animation: false,
@@ -18,11 +16,11 @@ export function createTippy(target, opts = {}) {
1816
maxWidth: 500, // increase over default 350px
1917
onHide: (instance) => {
2018
visibleInstances.delete(instance);
21-
return optsOnHide?.(instance);
19+
return onHide?.(instance);
2220
},
2321
onDestroy: (instance) => {
2422
visibleInstances.delete(instance);
25-
return optsOnDestroy?.(instance);
23+
return onDestroy?.(instance);
2624
},
2725
onShow: (instance) => {
2826
// hide other tooltip instances so only one tooltip shows at a time
@@ -32,19 +30,19 @@ export function createTippy(target, opts = {}) {
3230
}
3331
}
3432
visibleInstances.add(instance);
35-
return optOnShow?.(instance);
33+
return onShow?.(instance);
3634
},
3735
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>`,
3836
role: 'menu', // HTML role attribute, only tooltips should use "tooltip"
39-
theme: role || 'menu', // CSS theme, we support either "tooltip" or "menu"
40-
...opts,
37+
theme: other.role || 'menu', // CSS theme, we support either "tooltip" or "menu"
38+
...other,
4139
});
4240

4341
// for popups where content refers to a DOM element, we use the 'tippy-target' class
4442
// to initially hide the content, now we can remove it as the content has been removed
4543
// from the DOM by tippy
46-
if (content instanceof Element) {
47-
content.classList.remove('tippy-target');
44+
if (other.content instanceof Element) {
45+
other.content.classList.remove('tippy-target');
4846
}
4947

5048
return instance;

0 commit comments

Comments
 (0)