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:
parent
51c2aebe1f
commit
a1c5057fe8
10 changed files with 104 additions and 47 deletions
|
@ -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 = $(`
|
doRequest();
|
||||||
<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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|
Loading…
Reference in a new issue