diff options
-rw-r--r-- | modules/context/base.go | 4 | ||||
-rw-r--r-- | options/locale/locale_en-US.ini | 2 | ||||
-rw-r--r-- | routers/web/repo/issue.go | 18 | ||||
-rw-r--r-- | routers/web/web.go | 1 | ||||
-rw-r--r-- | templates/devtest/fetch-action.tmpl | 4 | ||||
-rw-r--r-- | templates/repo/issue/list.tmpl | 10 | ||||
-rw-r--r-- | web_src/js/features/common-global.js | 32 | ||||
-rw-r--r-- | web_src/js/features/comp/ConfirmModal.js | 30 | ||||
-rw-r--r-- | web_src/js/features/repo-issue-list.js | 28 | ||||
-rw-r--r-- | web_src/js/modules/tippy.js | 22 |
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; |