fix(issue): display error toast on batch action failures instead of reloading page (#38593)

Batch operations in the issues list (labels, milestones, projects,
assignees, etc.) could fail silently because `updateIssuesMeta` caught
and suppressed fetch errors internally. The caller would then proceed to
reload the page, clearing all selected issue checkboxes and giving the
impression that the operation had succeeded.

- Remove updateIssuesMeta and use our "fetch-action" framework to handle errors and page reloading
- Refactor backend code to use ctx.JSONError

---------

Signed-off-by: Sudhanshu Singh <sudhanshuwriterblc@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Shudhanshu Singh
2026-07-23 09:33:47 +00:00
committed by GitHub
co-authored by wxiaoguang
parent ee10ae168c
commit 6ac19f6226
5 changed files with 27 additions and 46 deletions
+21 -15
View File
@@ -8,6 +8,7 @@ import {registerGlobalSelectorFunc} from '../modules/observer.ts';
import {Idiomorph} from 'idiomorph';
import {parseDom} from '../utils.ts';
import {html} from '../utils/html.ts';
import type {RequestData} from '../types.ts';
const {appSubUrl, runModeIsProd} = window.config;
@@ -15,16 +16,16 @@ type FetchActionOpts = {
method: string;
url: string;
headers?: HeadersInit;
body?: FormData;
data?: RequestData;
formSubmitter?: HTMLElement | null;
// pseudo selectors/commands to update the current page with the response text when the response is text (html)
// e.g.: "$this", "$innerHTML", "$closest(tr) td .the-class", "$body #the-id"
successSync: string;
successSync?: string;
// the loading indicator element selector, it uses the same syntax as "data-fetch-sync" to find the element(s)
// empty means no loading indicator, "$this" means the element itself
loadingIndicator: string;
loadingIndicator?: string;
};
// fetchActionDoRedirect does real redirection to bypass the browser's limitations of "location"
@@ -120,10 +121,10 @@ function buildFetchActionUrl(el: HTMLElement, opt: FetchActionOpts) {
return url;
}
async function performActionRequest(el: HTMLElement, opt: FetchActionOpts) {
// Use "fetch" to send a request and handle the error cases, return the success response and let caller handle
export async function performFetchActionRequest(el: HTMLElement, opt: FetchActionOpts): Promise<Response | null> {
const attrIsLoading = 'data-fetch-is-loading';
if (el.getAttribute(attrIsLoading)) return;
if (!await confirmFetchAction(opt.formSubmitter ?? el)) return;
if (el.getAttribute(attrIsLoading)) return null;
el.setAttribute(attrIsLoading, 'true');
toggleLoadingIndicator(el, opt, true);
@@ -132,11 +133,8 @@ async function performActionRequest(el: HTMLElement, opt: FetchActionOpts) {
const url = buildFetchActionUrl(el, opt);
const headers = new Headers(opt.headers);
headers.set('X-Gitea-Fetch-Action', '1');
const resp = await request(url, {method: opt.method, body: opt.body, headers});
if (resp.ok) {
await handleFetchActionSuccess(el, opt, resp);
return;
}
const resp = await request(url, {method: opt.method, data: opt.data, headers});
if (resp.ok) return resp;
await handleFetchActionError(resp);
} catch (err) {
if (errorName(err) !== 'AbortError') {
@@ -147,6 +145,14 @@ async function performActionRequest(el: HTMLElement, opt: FetchActionOpts) {
toggleLoadingIndicator(el, opt, false);
el.removeAttribute(attrIsLoading);
}
return null;
}
// Use "fetch" to send a request and fully handle its response
export async function performFetchAction(el: HTMLElement, opt: FetchActionOpts) {
if (!await confirmFetchAction(opt.formSubmitter ?? el)) return;
const resp = await performFetchActionRequest(el, opt);
if (resp) await handleFetchActionSuccess(el, opt, resp);
}
type SubmitFormFetchActionOpts = {
@@ -181,7 +187,7 @@ function prepareFormFetchActionOpts(formEl: HTMLFormElement, opts: SubmitFormFet
return {
method: formMethodUpper,
url: reqUrl,
body: reqBody,
data: reqBody,
formSubmitter: opts.formSubmitter,
loadingIndicator: '$this', // for form submit, by default, the loading indicator is the whole form
successSync: formEl.getAttribute('data-fetch-sync') ?? '', // by default, no fetch sync for form submit
@@ -190,7 +196,7 @@ function prepareFormFetchActionOpts(formEl: HTMLFormElement, opts: SubmitFormFet
export async function submitFormFetchAction(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}) {
hideToastsAll();
await performActionRequest(formEl, prepareFormFetchActionOpts(formEl, opts));
await performFetchAction(formEl, prepareFormFetchActionOpts(formEl, opts));
}
async function confirmFetchAction(el: HTMLElement) {
@@ -221,7 +227,7 @@ async function confirmFetchAction(el: HTMLElement) {
async function performLinkFetchAction(el: HTMLElement) {
hideToastsAll();
await performActionRequest(el, {
await performFetchAction(el, {
method: el.getAttribute('data-fetch-method') || 'POST', // by default, the method is POST for link-action
url: el.getAttribute('data-url')!,
loadingIndicator: el.getAttribute('data-fetch-indicator') ?? '$this', // by default, the link-action itself is the loading indicator
@@ -237,7 +243,7 @@ export async function performFetchActionTrigger(el: HTMLElement, triggerType: Fe
const defaultLoadingIndicator = isUserInitiated ? '$this' : '';
if (isUserInitiated) hideToastsAll();
await performActionRequest(el, {
await performFetchAction(el, {
method: el.getAttribute('data-fetch-method') || 'GET', // by default, the method is GET for fetch trigger action
url: el.getAttribute('data-fetch-url')!,
loadingIndicator: el.getAttribute('data-fetch-indicator') ?? defaultLoadingIndicator,