diff --git a/web_src/css/modules/animations.css b/web_src/css/modules/animations.css
index 3f5e8bd267..dcd0e07537 100644
--- a/web_src/css/modules/animations.css
+++ b/web_src/css/modules/animations.css
@@ -4,20 +4,22 @@
}
.is-loading {
- background: transparent !important;
- color: transparent !important;
- border: transparent !important;
pointer-events: none !important;
position: relative !important;
overflow: hidden !important;
}
+.is-loading > * {
+ opacity: 0.3;
+}
+
.is-loading::after {
content: "";
position: absolute;
display: block;
- width: 4rem;
height: 4rem;
+ max-height: 50%;
+ aspect-ratio: 1 / 1;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
@@ -28,18 +30,24 @@
border-radius: 100%;
}
+.is-loading.small-loading-icon::after {
+ border-width: 2px;
+}
+
.markup pre.is-loading,
.editor-loading.is-loading,
.pdf-content.is-loading {
height: var(--height-loading);
}
+/* TODO: not needed, use "is-loading small-loading-icon" instead */
.btn-octicon.is-loading::after {
border-width: 2px;
height: 1.25rem;
width: 1.25rem;
}
+/* TODO: not needed, use "is-loading small-loading-icon" instead */
code.language-math.is-loading::after {
padding: 0;
border-width: 2px;
@@ -47,11 +55,6 @@ code.language-math.is-loading::after {
height: 1.25rem;
}
-#oauth2-login-navigator.is-loading::after {
- width: 40px;
- height: 40px;
-}
-
@keyframes fadein {
0% {
opacity: 0;
diff --git a/web_src/css/modules/tippy.css b/web_src/css/modules/tippy.css
index bd55b9d6b9..fe32597280 100644
--- a/web_src/css/modules/tippy.css
+++ b/web_src/css/modules/tippy.css
@@ -29,6 +29,12 @@
color: var(--color-text);
}
+.tippy-box[data-theme="form-fetch-error"] {
+ border-color: var(--color-error-border);
+ background-color: var(--color-error-bg);
+ color: var(--color-error-text);
+}
+
.tippy-content {
position: relative;
padding: 1rem;
diff --git a/web_src/js/features/common-global.js b/web_src/js/features/common-global.js
index b1d3fa22d8..c0e66be51c 100644
--- a/web_src/js/features/common-global.js
+++ b/web_src/js/features/common-global.js
@@ -7,6 +7,7 @@ import {handleGlobalEnterQuickSubmit} from './comp/QuickSubmit.js';
import {svg} from '../svg.js';
import {hideElem, showElem, toggleElem} from '../utils/dom.js';
import {htmlEscape} from 'escape-goat';
+import {createTippy} from '../modules/tippy.js';
const {appUrl, csrfToken, i18n} = window.config;
@@ -60,6 +61,81 @@ export function initGlobalButtonClickOnEnter() {
});
}
+async function formFetchAction(e) {
+ if (!e.target.classList.contains('form-fetch-action')) return;
+
+ e.preventDefault();
+ const formEl = e.target;
+ if (formEl.classList.contains('is-loading')) return;
+
+ formEl.classList.add('is-loading');
+ if (formEl.clientHeight < 50) {
+ formEl.classList.add('small-loading-icon');
+ }
+
+ const formMethod = formEl.getAttribute('method') || 'get';
+ const formActionUrl = formEl.getAttribute('action');
+ const formData = new FormData(formEl);
+ const [submitterName, submitterValue] = [e.submitter?.getAttribute('name'), e.submitter?.getAttribute('value')];
+ if (submitterName) {
+ formData.append(submitterName, submitterValue || '');
+ }
+
+ let reqUrl = formActionUrl;
+ const reqOpt = {method: formMethod.toUpperCase(), headers: {'X-Csrf-Token': csrfToken}};
+ if (formMethod.toLowerCase() === 'get') {
+ const params = new URLSearchParams();
+ for (const [key, value] of formData) {
+ params.append(key, value.toString());
+ }
+ const pos = reqUrl.indexOf('?');
+ if (pos !== -1) {
+ reqUrl = reqUrl.slice(0, pos);
+ }
+ reqUrl += `?${params.toString()}`;
+ } else {
+ reqOpt.body = formData;
+ }
+
+ let errorTippy;
+ const onError = (msg) => {
+ formEl.classList.remove('is-loading', 'small-loading-icon');
+ if (errorTippy) errorTippy.destroy();
+ errorTippy = createTippy(formEl, {
+ content: msg,
+ interactive: true,
+ showOnCreate: true,
+ hideOnClick: true,
+ role: 'alert',
+ theme: 'form-fetch-error',
+ trigger: 'manual',
+ arrow: false,
+ });
+ };
+
+ const doRequest = async () => {
+ try {
+ const resp = await fetch(reqUrl, reqOpt);
+ if (resp.status === 200) {
+ const {redirect} = await resp.json();
+ formEl.classList.remove('dirty'); // remove the areYouSure check before reloading
+ if (redirect) {
+ window.location.href = redirect;
+ } else {
+ window.location.reload();
+ }
+ } else {
+ onError(`server error: ${resp.status}`);
+ }
+ } catch (e) {
+ onError(e.error);
+ }
+ };
+
+ // TODO: add "confirm" support like "link-action" in the future
+ await doRequest();
+}
+
export function initGlobalCommon() {
// Semantic UI modules.
const $uiDropdowns = $('.ui.dropdown');
@@ -114,6 +190,8 @@ export function initGlobalCommon() {
if (btn.classList.contains('loading')) return e.preventDefault();
btn.classList.add('loading');
});
+
+ document.addEventListener('submit', formFetchAction);
}
export function initGlobalDropzone() {
@@ -182,7 +260,7 @@ function linkAction(e) {
const $this = $(e.target);
const redirect = $this.attr('data-redirect');
- const request = () => {
+ const doRequest = () => {
$this.prop('disabled', true);
$.post($this.attr('data-url'), {
_csrf: csrfToken
@@ -201,7 +279,7 @@ function linkAction(e) {
const modalConfirmHtml = htmlEscape($this.attr('data-modal-confirm') || '');
if (!modalConfirmHtml) {
- request();
+ doRequest();
return;
}
@@ -220,7 +298,7 @@ function linkAction(e) {
$modal.appendTo(document.body);
$modal.modal({
onApprove() {
- request();
+ doRequest();
},
onHidden() {
$modal.remove();
diff --git a/web_src/js/features/comp/QuickSubmit.js b/web_src/js/features/comp/QuickSubmit.js
index d598a59655..2587375a71 100644
--- a/web_src/js/features/comp/QuickSubmit.js
+++ b/web_src/js/features/comp/QuickSubmit.js
@@ -1,17 +1,24 @@
import $ from 'jquery';
export function handleGlobalEnterQuickSubmit(target) {
- const $target = $(target);
- const $form = $(target).closest('form');
- if ($form.length) {
+ const form = target.closest('form');
+ if (form) {
+ if (!form.checkValidity()) {
+ form.reportValidity();
+ return;
+ }
+
+ if (form.classList.contains('form-fetch-action')) {
+ form.dispatchEvent(new SubmitEvent('submit', {bubbles: true, cancelable: true}));
+ return;
+ }
+
// here use the event to trigger the submit event (instead of calling `submit()` method directly)
// otherwise the `areYouSure` handler won't be executed, then there will be an annoying "confirm to leave" dialog
- if ($form[0].checkValidity()) {
- $form.trigger('submit');
- }
+ $(form).trigger('submit');
} else {
// if no form, then the editor is for an AJAX request, dispatch an event to the target, let the target's event handler to do the AJAX request.
// the 'ce-' prefix means this is a CustomEvent
- $target.trigger('ce-quick-submit');
+ target.dispatchEvent(new CustomEvent('ce-quick-submit', {bubbles: true}));
}
}
diff --git a/web_src/js/features/repo-code.js b/web_src/js/features/repo-code.js
index 6a01a8445b..306f38829f 100644
--- a/web_src/js/features/repo-code.js
+++ b/web_src/js/features/repo-code.js
@@ -111,7 +111,7 @@ function showLineButton() {
hideOnClick: true,
content: menu,
placement: 'right-start',
- interactive: 'true',
+ interactive: true,
onShow: (tippy) => {
tippy.popper.addEventListener('click', () => {
tippy.hide();
diff --git a/web_src/js/modules/tippy.js b/web_src/js/modules/tippy.js
index b424cdfd50..3409e1c714 100644
--- a/web_src/js/modules/tippy.js
+++ b/web_src/js/modules/tippy.js
@@ -3,6 +3,11 @@ 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;
+
const instance = tippy(target, {
appendTo: document.body,
animation: false,
@@ -13,9 +18,11 @@ export function createTippy(target, opts = {}) {
maxWidth: 500, // increase over default 350px
onHide: (instance) => {
visibleInstances.delete(instance);
+ return optsOnHide?.(instance);
},
onDestroy: (instance) => {
visibleInstances.delete(instance);
+ return optsOnDestroy?.(instance);
},
onShow: (instance) => {
// hide other tooltip instances so only one tooltip shows at a time
@@ -25,18 +32,19 @@ export function createTippy(target, opts = {}) {
}
}
visibleInstances.add(instance);
+ return optOnShow?.(instance);
},
arrow: `
`,
role: 'menu', // HTML role attribute, only tooltips should use "tooltip"
- theme: opts.role || 'menu', // CSS theme, we support either "tooltip" or "menu"
+ theme: role || 'menu', // CSS theme, we support either "tooltip" or "menu"
...opts,
});
// 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 (opts.content instanceof Element) {
- opts.content.classList.remove('tippy-target');
+ if (content instanceof Element) {
+ content.classList.remove('tippy-target');
}
return instance;