- {{template "base/alert" .}}
500 Internal Server Error
diff --git a/types.d.ts b/types.d.ts index 234bd267fe..fd1951b715 100644 --- a/types.d.ts +++ b/types.d.ts @@ -41,6 +41,13 @@ declare module 'htmx.org/dist/htmx.esm.js' { export default value; } +declare module 'idiomorph' { + interface Idiomorph { + morph(existing: Node | string, replacement: Node | string, options?: {morphStyle: 'innerHTML' | 'outerHTML'}): void; + } + export const Idiomorph: Idiomorph; +} + declare module 'swagger-ui-dist/swagger-ui-es-bundle.js' { const value = await import('swagger-ui-dist'); export default value.SwaggerUIBundle; diff --git a/web_src/css/repo/home-file-list.css b/web_src/css/repo/home-file-list.css index 6aa9e4bca3..485c544196 100644 --- a/web_src/css/repo/home-file-list.css +++ b/web_src/css/repo/home-file-list.css @@ -92,3 +92,8 @@ white-space: nowrap; color: var(--color-text-light-1); } + +#repo-files-table .repo-file-cell.is-loading::after { + height: 40%; + border-width: 2px; +} diff --git a/web_src/js/features/admin/common.ts b/web_src/js/features/admin/common.ts index ba4cf19446..4ba74f1b08 100644 --- a/web_src/js/features/admin/common.ts +++ b/web_src/js/features/admin/common.ts @@ -285,6 +285,6 @@ function initAdminNotice() { } } await POST(this.getAttribute('data-link')!, {data}); - window.location.href = this.getAttribute('data-redirect')!; + window.location.reload(); }); } diff --git a/web_src/js/features/common-fetch-action.ts b/web_src/js/features/common-fetch-action.ts index 7d98da17f2..3e623f9484 100644 --- a/web_src/js/features/common-fetch-action.ts +++ b/web_src/js/features/common-fetch-action.ts @@ -1,73 +1,138 @@ -import {request} from '../modules/fetch.ts'; +import {GET, request} from '../modules/fetch.ts'; import {hideToastsAll, showErrorToast} from '../modules/toast.ts'; import {addDelegatedEventListener, createElementFromHTML, submitEventSubmitter} from '../utils/dom.ts'; import {confirmModal, createConfirmModal} from './comp/ConfirmModal.ts'; -import type {RequestOpts} from '../types.ts'; import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts'; +import {registerGlobalSelectorFunc} from '../modules/observer.ts'; +import {Idiomorph} from 'idiomorph'; +import {parseDom} from '../utils.ts'; +import {html} from '../utils/html.ts'; const {appSubUrl} = window.config; +type FetchActionOpts = { + method: string; + url: string; + headers?: HeadersInit; + body?: FormData; + + // 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; + + // null: no indicator + // empty string: the current element + // '.css-selector': find the element by selector + loadingIndicator: string | null; +}; + // fetchActionDoRedirect does real redirection to bypass the browser's limitations of "location" // more details are in the backend's fetch-redirect handler function fetchActionDoRedirect(redirect: string) { - const form = document.createElement('form'); - const input = document.createElement('input'); - form.method = 'post'; - form.action = `${appSubUrl}/-/fetch-redirect`; - input.type = 'hidden'; - input.name = 'redirect'; - input.value = redirect; - form.append(input); + const form = createElementFromHTML(html`
`); + form.action = `${appSubUrl}/-/fetch-redirect?redirect=${encodeURIComponent(redirect)}`; document.body.append(form); form.submit(); } -async function fetchActionDoRequest(actionElem: HTMLElement, url: string, opt: RequestOpts) { - const showErrorForResponse = (code: number, message: string) => { - showErrorToast(`Error ${code || 'request'}: ${message}`); - }; - - let respStatus = 0; - let respText = ''; - try { - hideToastsAll(); - const resp = await request(url, opt); - respStatus = resp.status; - respText = await resp.text(); - const respJson = JSON.parse(respText); - if (respStatus === 200) { - let {redirect} = respJson; - redirect = redirect || actionElem.getAttribute('data-redirect'); - ignoreAreYouSure(actionElem); // ignore the areYouSure check before reloading - if (redirect) { - fetchActionDoRedirect(redirect); +function toggleLoadingIndicator(el: HTMLElement, opt: FetchActionOpts, isLoading: boolean) { + const loadingIndicatorElems = opt.loadingIndicator === null ? [] : (opt.loadingIndicator === '' ? [el] : document.querySelectorAll(opt.loadingIndicator)); + for (const indicatorEl of loadingIndicatorElems) { + if (isLoading) { + if ('disabled' in indicatorEl) { + indicatorEl.disabled = true; } else { - window.location.reload(); + indicatorEl.classList.add('is-loading'); + if (indicatorEl.clientHeight < 50) indicatorEl.classList.add('loading-icon-2px'); } - return; - } - - if (respStatus >= 400 && respStatus < 500 && respJson?.errorMessage) { - // the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error" - // but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond. - showErrorToast(respJson.errorMessage, {useHtmlBody: respJson.renderFormat === 'html'}); } else { - showErrorForResponse(respStatus, respText); - } - } catch (e) { - if (e.name === 'SyntaxError') { - showErrorForResponse(respStatus, (respText || '').substring(0, 100)); - } else if (e.name !== 'AbortError') { - console.error('fetchActionDoRequest error', e); - showErrorForResponse(respStatus, `${e}`); + if ('disabled' in indicatorEl) { + indicatorEl.disabled = false; + } else { + indicatorEl.classList.remove('is-loading', 'loading-icon-2px'); + } } } - actionElem.classList.remove('is-loading', 'loading-icon-2px'); } -async function onFormFetchActionSubmit(formEl: HTMLFormElement, e: SubmitEvent) { - e.preventDefault(); - await submitFormFetchAction(formEl, {formSubmitter: submitEventSubmitter(e)}); +async function handleFetchActionSuccessJson(el: HTMLElement, respJson: any) { + ignoreAreYouSure(el); // ignore the areYouSure check before reloading + if (respJson?.redirect) { + fetchActionDoRedirect(respJson.redirect); + } else { + window.location.reload(); + } +} + +async function handleFetchActionSuccess(el: HTMLElement, opt: FetchActionOpts, resp: Response) { + const isRespJson = resp.headers.get('content-type')?.includes('application/json'); + const respText = await resp.text(); + const respJson = isRespJson ? JSON.parse(respText) : null; + if (isRespJson) { + await handleFetchActionSuccessJson(el, respJson); + } else if (opt.successSync) { + await handleFetchActionSuccessSync(el, opt.successSync, respText); + } else { + showErrorToast(`Unsupported fetch action response, expected JSON but got: ${respText.substring(0, 200)}`); + } +} + +async function handleFetchActionError(resp: Response) { + const isRespJson = resp.headers.get('content-type')?.includes('application/json'); + const respText = await resp.text(); + const respJson = isRespJson ? JSON.parse(await resp.text()) : null; + if (respJson?.errorMessage) { + // the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error" + // but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond. + showErrorToast(respJson.errorMessage, {useHtmlBody: respJson.renderFormat === 'html'}); + } else { + showErrorToast(`Error ${resp.status} ${resp.statusText}. Response: ${respText.substring(0, 200)}`); + } +} + +function buildFetchActionUrl(el: HTMLElement, opt: FetchActionOpts) { + let url = opt.url; + if ('name' in el && 'value' in el) { + // ref: https://htmx.org/attributes/hx-get/ + // If the element with the hx-get attribute also has a value, this will be included as a parameter + const name = (el as HTMLInputElement).name; + const val = (el as HTMLInputElement).value; + const u = new URL(url, window.location.href); + if (name && !u.searchParams.has(name)) { + u.searchParams.set(name, val); + url = u.toString(); + } + } + return url; +} + +async function performActionRequest(el: HTMLElement, opt: FetchActionOpts) { + const attrIsLoading = 'data-fetch-is-loading'; + if (el.getAttribute(attrIsLoading)) return; + if (!await confirmFetchAction(el)) return; + + el.setAttribute(attrIsLoading, 'true'); + toggleLoadingIndicator(el, opt, true); + + try { + 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; + } + await handleFetchActionError(resp); + } catch (e) { + if (e.name !== 'AbortError') { + console.error(`Fetch action request error:`, e); + showErrorToast(`Error: ${e.message ?? e}`); + } + } finally { + toggleLoadingIndicator(el, opt, false); + el.removeAttribute(attrIsLoading); + } } type SubmitFormFetchActionOpts = { @@ -75,15 +140,8 @@ type SubmitFormFetchActionOpts = { formData?: FormData; }; -export async function submitFormFetchAction(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}) { - if (formEl.classList.contains('is-loading')) return; - - formEl.classList.add('is-loading'); - if (formEl.clientHeight < 50) { - formEl.classList.add('loading-icon-2px'); - } - - const formMethod = formEl.getAttribute('method') || 'get'; +function prepareFormFetchActionOpts(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}): FetchActionOpts { + const formMethodUpper = formEl.getAttribute('method')?.toUpperCase() || 'GET'; const formActionUrl = formEl.getAttribute('action') || window.location.href; const formData = opts.formData ?? new FormData(formEl); const [submitterName, submitterValue] = [opts.formSubmitter?.getAttribute('name'), opts.formSubmitter?.getAttribute('value')]; @@ -92,11 +150,8 @@ export async function submitFormFetchAction(formEl: HTMLFormElement, opts: Submi } let reqUrl = formActionUrl; - const reqOpt = { - method: formMethod.toUpperCase(), - body: null as FormData | null, - }; - if (formMethod.toLowerCase() === 'get') { + let reqBody: FormData | undefined; + if (formMethodUpper === 'GET') { const params = new URLSearchParams(); for (const [key, value] of formData) { params.append(key, value as string); @@ -107,25 +162,23 @@ export async function submitFormFetchAction(formEl: HTMLFormElement, opts: Submi } reqUrl += `?${params.toString()}`; } else { - reqOpt.body = formData; + reqBody = formData; } - - await fetchActionDoRequest(formEl, reqUrl, reqOpt); + return { + method: formMethodUpper, + url: reqUrl, + body: reqBody, + loadingIndicator: '', // 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 + }; } -async function onLinkActionClick(el: HTMLElement, e: Event) { - // A "link-action" can post AJAX request to its "data-url" - // Then the browser is redirected to: the "redirect" in response, or "data-redirect" attribute, or current URL by reloading. - // If the "link-action" has "data-modal-confirm" attribute, a "confirm modal dialog" will be shown before taking action. - // Attribute "data-modal-confirm" can be a modal element by "#the-modal-id", or a string content for the modal dialog. - e.preventDefault(); - const url = el.getAttribute('data-url')!; - const doRequest = async () => { - if ('disabled' in el) el.disabled = true; // el could be A or BUTTON, but "A" doesn't have the "disabled" attribute - await fetchActionDoRequest(el, url, {method: el.getAttribute('data-link-action-method') || 'POST'}); - if ('disabled' in el) el.disabled = false; - }; +export async function submitFormFetchAction(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}) { + hideToastsAll(); + await performActionRequest(formEl, prepareFormFetchActionOpts(formEl, opts)); +} +async function confirmFetchAction(el: HTMLElement) { let elModal: HTMLElement | null = null; const dataModalConfirm = el.getAttribute('data-modal-confirm') || ''; if (dataModalConfirm.startsWith('#')) { @@ -147,18 +200,179 @@ async function onLinkActionClick(el: HTMLElement, e: Event) { }); } } + if (!elModal) return true; + return await confirmModal(elModal); +} - if (!elModal) { - await doRequest(); +async function performLinkFetchAction(el: HTMLElement) { + hideToastsAll(); + await performActionRequest(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') || '', // by default, the link-action itself is the loading indicator + successSync: el.getAttribute('data-fetch-sync') ?? '', // by default, no fetch sync for link-action + }); +} + +type FetchActionTriggerType = 'click' | 'change' | 'every' | 'load' | 'fetch-reload'; + +async function performFetchActionTriggerRequest(el: HTMLElement, triggerType: FetchActionTriggerType) { + const isUserInitiated = triggerType === 'click' || triggerType === 'change'; + // for user initiated action, by default, the loading indicator is the element itself, otherwise no loading indicator + const defaultLoadingIndicator = isUserInitiated ? '' : null; + + if (isUserInitiated) hideToastsAll(); + await performActionRequest(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, + successSync: el.getAttribute('data-fetch-sync') ?? '$this', // by default, the response will replace the current element + }); +} + +async function handleFetchActionSuccessSync(el: HTMLElement, successSync: string, respText: string) { + const cmds = successSync.split(' ').map((s) => s.trim()).filter(Boolean) || []; + let target = el, replaceInner = false, useMorph = false; + for (const cmd of cmds) { + if (cmd === '$this') { + target = el; + } else if (cmd === '$body') { + target = document.body; + } else if (cmd === '$innerHTML') { + replaceInner = true; + } else if (cmd === '$morph') { + useMorph = true; + } else if (cmd.startsWith('$closest(') && cmd.endsWith(')')) { + const selector = cmd.substring('$closest('.length, cmd.length - 1); + target = target.closest(selector) as HTMLElement; + } else { + target = target.querySelector(cmd) as HTMLElement; + } + } + if (useMorph) { + Idiomorph.morph(target, respText, {morphStyle: replaceInner ? 'innerHTML' : 'outerHTML'}); + } else if (replaceInner) { + target.innerHTML = respText; + } else { + target.outerHTML = respText; + } + await fetchActionReloadOutdatedElements(); +} + +async function fetchActionReloadOutdatedElements() { + const outdatedElems: HTMLElement[] = []; + for (const outdated of document.querySelectorAll('[data-fetch-trigger~="fetch-reload"]')) { + if (!outdated.id) throw new Error(`Elements with "fetch-reload" trigger must have an id to be reloaded after fetch sync: ${outdated.outerHTML.substring(0, 100)}`); + outdatedElems.push(outdated); + } + if (!outdatedElems.length) return; + + const resp = await GET(window.location.href); + if (!resp.ok) { + showErrorToast(`Failed to reload page content after fetch action: ${resp.status} ${resp.statusText}`); return; } + const newPageHtml = await resp.text(); + const newPageDom = parseDom(newPageHtml, 'text/html'); + for (const oldEl of outdatedElems) { + // eslint-disable-next-line unicorn/prefer-query-selector + const newEl = newPageDom.getElementById(oldEl.id); + if (newEl) { + oldEl.replaceWith(newEl); + } else { + oldEl.remove(); + } + } +} - if (await confirmModal(elModal)) { - await doRequest(); +function initFetchActionTriggerEvery(el: HTMLElement, trigger: string) { + const interval = trigger.substring('every '.length); + const match = /^(\d+)(ms|s)$/.exec(interval); + if (!match) throw new Error(`Invalid interval format: ${interval}`); + + const num = parseInt(match[1], 10), unit = match[2]; + const intervalMs = unit === 's' ? num * 1000 : num; + const fn = async () => { + try { + await performFetchActionTriggerRequest(el, 'every'); + } finally { + // only continue if the element is still in the document + if (document.contains(el)) { + setTimeout(fn, intervalMs); + } + } + }; + setTimeout(fn, intervalMs); +} + +function initFetchActionTrigger(el: HTMLElement) { + const trigger = el.getAttribute('data-fetch-trigger'); + + // this trigger is managed internally, only triggered after fetch sync success, not triggered by event or timer + if (trigger === 'fetch-reload') return; + + if (trigger === 'load') { + performFetchActionTriggerRequest(el, trigger); + } else if (trigger === 'change') { + el.addEventListener('change', () => performFetchActionTriggerRequest(el, trigger)); + } else if (trigger?.startsWith('every ')) { + initFetchActionTriggerEvery(el, trigger); + } else if (!trigger || trigger === 'click') { + el.addEventListener('click', (e) => { + e.preventDefault(); + performFetchActionTriggerRequest(el, 'click'); + }); + } else { + throw new Error(`Unsupported fetch trigger: ${trigger}`); } } export function initGlobalFetchAction() { - addDelegatedEventListener(document, 'submit', '.form-fetch-action', onFormFetchActionSubmit); - addDelegatedEventListener(document, 'click', '.link-action', onLinkActionClick); + // "fetch-action" is a general approach for elements to trigger fetch requests: + // show confirm dialog (if any), show loading indicators, send fetch request, and redirect or update UI after success. + // + // Attributes: + // + // * data-fetch-method: the HTTP method to use + // * default to "GET" for "data-fetch-url" actions, "POST" for "link-action" elements + // * this attribute is ignored, the method will be determined by the form's "method" attribute, and default to "GET" + // + // * data-fetch-url: the URL for the request + // + // * data-fetch-trigger: the event to trigger the fetch action, can be: + // * "click", "change" (user-initiated events) + // * "load" (triggered on page load) + // * "every 5s" (also support "ms" unit) + // * "fetch-reload" (only triggered by fetch sync success to reload outdated content) + // + // * data-fetch-indicator: the loading indicator element selector + // + // * data-fetch-sync: when the response is text (html), the pseudo selectors/commands defined in "data-fetch-sync" + // will be used to update the content in the current page. It only supports some simple syntaxes that we need. + // "$" prefix means it is our private command (for special logic) + // * "" (empty string): replace the current element with the response + // * "$innerHTML": replace innerHTML of the current element with the response, instead of replacing the whole element (outerHTML) + // * "$morph": use morph algorithm to update the target element + // * "$body #the-id .the-class": query the selector one by one from body + // * "$closest(tr) td": pseudo command can help to find the target element in a more flexible way + // + // * data-modal-confirm: a "confirm modal dialog" will be shown before taking action. + // * it can be a string for the content of the modal dialog + // * it has "-header" and "-content" variants to set the header and content of the confirm modal + // * it can refer an existing modal element by "#the-modal-id" + + addDelegatedEventListener(document, 'submit', '.form-fetch-action', async (el: HTMLFormElement, e) => { + // "fetch-action" will use the form's data to send the request + e.preventDefault(); + await submitFormFetchAction(el, {formSubmitter: submitEventSubmitter(e)}); + }); + + addDelegatedEventListener(document, 'click', '.link-action', async (el, e) => { + // `` is a shorthand for + // `` + e.preventDefault(); + await performLinkFetchAction(el); + }); + + registerGlobalSelectorFunc('[data-fetch-url]', initFetchActionTrigger); } diff --git a/web_src/js/features/comp/WebHookEditor.ts b/web_src/js/features/comp/WebHookEditor.ts index a69d438ba2..a6944ff185 100644 --- a/web_src/js/features/comp/WebHookEditor.ts +++ b/web_src/js/features/comp/WebHookEditor.ts @@ -37,11 +37,6 @@ export function initCompWebHookEditor() { document.querySelector('#test-delivery')?.addEventListener('click', async function () { this.classList.add('is-loading', 'disabled'); await POST(this.getAttribute('data-link')!); - setTimeout(() => { - const redirectUrl = this.getAttribute('data-redirect'); - if (redirectUrl) { - window.location.href = redirectUrl; - } - }, 5000); + setTimeout(() => window.location.reload(), 5000); }); } diff --git a/web_src/js/features/repo-diff-commit.ts b/web_src/js/features/repo-diff-commit.ts index ac5d7baca6..ac385e4b6d 100644 --- a/web_src/js/features/repo-diff-commit.ts +++ b/web_src/js/features/repo-diff-commit.ts @@ -4,7 +4,7 @@ import {GET} from '../modules/fetch.ts'; async function loadBranchesAndTags(area: Element, loadingButton: Element) { loadingButton.classList.add('disabled'); try { - const res = await GET(loadingButton.getAttribute('data-fetch-url')!); + const res = await GET(loadingButton.getAttribute('data-url')!); const data = await res.json(); hideElem(loadingButton); addTags(area, data.tags); diff --git a/web_src/js/features/repo-issue-pull.ts b/web_src/js/features/repo-issue-pull.ts index 58dbf1790e..89f513e971 100644 --- a/web_src/js/features/repo-issue-pull.ts +++ b/web_src/js/features/repo-issue-pull.ts @@ -11,7 +11,6 @@ function initRepoPullRequestUpdate(el: HTMLElement) { const prUpdateDropdown = prUpdateButtonContainer.querySelector(':scope > .ui.dropdown')!; prUpdateButton.addEventListener('click', async function (e) { e.preventDefault(); - const redirect = this.getAttribute('data-redirect'); this.classList.add('is-loading'); let response: Response | undefined; try { @@ -29,8 +28,6 @@ function initRepoPullRequestUpdate(el: HTMLElement) { } if (data?.redirect) { window.location.href = data.redirect; - } else if (redirect) { - window.location.href = redirect; } else { window.location.reload(); } diff --git a/web_src/js/globals.ts b/web_src/js/globals.ts index 9cd66d8322..458bb042a1 100644 --- a/web_src/js/globals.ts +++ b/web_src/js/globals.ts @@ -1,6 +1,5 @@ import jquery from 'jquery'; // eslint-disable-line no-restricted-imports import htmx from 'htmx.org'; // eslint-disable-line no-restricted-imports -import 'idiomorph/htmx'; // eslint-disable-line no-restricted-imports // Some users still use inline scripts and expect jQuery to be available globally. // To avoid breaking existing users and custom plugins, import jQuery globally without ES module. diff --git a/web_src/js/index.ts b/web_src/js/index.ts index ef4b29ba98..25d1ca03f3 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -185,5 +185,3 @@ document.body.addEventListener('htmx:responseError', (event) => { // TODO: add translations showErrorToast(`Error ${(event as HtmxEvent).detail.xhr.status} when calling ${(event as HtmxEvent).detail.requestConfig.path}`); }); - -document.dispatchEvent(new CustomEvent('gitea:index-ready')); diff --git a/web_src/js/modules/devtest.ts b/web_src/js/modules/devtest.ts index 66655c0946..7886ff9748 100644 --- a/web_src/js/modules/devtest.ts +++ b/web_src/js/modules/devtest.ts @@ -1,20 +1,57 @@ import {showInfoToast, showWarningToast, showErrorToast} from './toast.ts'; import type {Toast} from './toast.ts'; import {registerGlobalInitFunc} from './observer.ts'; +import {fomanticQuery} from './fomantic/base.ts'; +import {createElementFromHTML} from '../utils/dom.ts'; +import {html} from '../utils/html.ts'; type LevelMap = Record Toast | null>; -export function initDevtest() { - registerGlobalInitFunc('initDevtestPage', () => { - const els = document.querySelectorAll('.toast-test-button'); - if (!els.length) return; +function initDevtestPage() { + const toastButtons = document.querySelectorAll('.toast-test-button'); + if (toastButtons.length) { const levelMap: LevelMap = {info: showInfoToast, warning: showWarningToast, error: showErrorToast}; - for (const el of els) { + for (const el of toastButtons) { el.addEventListener('click', () => { const level = el.getAttribute('data-toast-level')!; const message = el.getAttribute('data-toast-message')!; levelMap[level](message); }); } - }); + } + + const modalButtons = document.querySelector('.modal-buttons'); + if (modalButtons) { + for (const el of document.querySelectorAll('.ui.modal:not([data-skip-button])')) { + const btn = createElementFromHTML(html`