Skip to content

Commit

Permalink
Split common-global.js into separate files (#31438)
Browse files Browse the repository at this point in the history
To improve maintainability
  • Loading branch information
wxiaoguang committed Jun 21, 2024
1 parent ed5ded3 commit 0678287
Show file tree
Hide file tree
Showing 10 changed files with 497 additions and 484 deletions.
2 changes: 1 addition & 1 deletion web_src/js/features/admin/common.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import $ from 'jquery';
import {checkAppUrl} from '../common-global.js';
import {checkAppUrl} from '../common-page.js';
import {hideElem, showElem, toggleElem} from '../../utils/dom.js';
import {POST} from '../../modules/fetch.js';

Expand Down
159 changes: 159 additions & 0 deletions web_src/js/features/common-button.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import $ from 'jquery';
import {POST} from '../modules/fetch.js';
import {hideElem, showElem, toggleElem} from '../utils/dom.js';
import {showErrorToast} from '../modules/toast.js';

export function initGlobalButtonClickOnEnter() {
$(document).on('keypress', 'div.ui.button,span.ui.button', (e) => {
if (e.code === ' ' || e.code === 'Enter') {
$(e.target).trigger('click');
e.preventDefault();
}
});
}

export function initGlobalDeleteButton() {
// ".delete-button" shows a confirmation modal defined by `data-modal-id` attribute.
// Some model/form elements will be filled by `data-id` / `data-name` / `data-data-xxx` attributes.
// If there is a form defined by `data-form`, then the form will be submitted as-is (without any modification).
// If there is no form, then the data will be posted to `data-url`.
// TODO: it's not encouraged to use this method. `show-modal` does far better than this.
for (const btn of document.querySelectorAll('.delete-button')) {
btn.addEventListener('click', (e) => {
e.preventDefault();

// eslint-disable-next-line github/no-dataset -- code depends on the camel-casing
const dataObj = btn.dataset;

const modalId = btn.getAttribute('data-modal-id');
const modal = document.querySelector(`.delete.modal${modalId ? `#${modalId}` : ''}`);

// set the modal "display name" by `data-name`
const modalNameEl = modal.querySelector('.name');
if (modalNameEl) modalNameEl.textContent = btn.getAttribute('data-name');

// fill the modal elements with data-xxx attributes: `data-data-organization-name="..."` => `<span class="dataOrganizationName">...</span>`
for (const [key, value] of Object.entries(dataObj)) {
if (key.startsWith('data')) {
const textEl = modal.querySelector(`.${key}`);
if (textEl) textEl.textContent = value;
}
}

$(modal).modal({
closable: false,
onApprove: async () => {
// if `data-type="form"` exists, then submit the form by the selector provided by `data-form="..."`
if (btn.getAttribute('data-type') === 'form') {
const formSelector = btn.getAttribute('data-form');
const form = document.querySelector(formSelector);
if (!form) throw new Error(`no form named ${formSelector} found`);
form.submit();
}

// prepare an AJAX form by data attributes
const postData = new FormData();
for (const [key, value] of Object.entries(dataObj)) {
if (key.startsWith('data')) { // for data-data-xxx (HTML) -> dataXxx (form)
postData.append(key.slice(4), value);
}
if (key === 'id') { // for data-id="..."
postData.append('id', value);
}
}

const response = await POST(btn.getAttribute('data-url'), {data: postData});
if (response.ok) {
const data = await response.json();
window.location.href = data.redirect;
}
},
}).modal('show');
});
}
}

export function initGlobalButtons() {
// There are many "cancel button" elements in modal dialogs, Fomantic UI expects they are button-like elements but never submit a form.
// However, Gitea misuses the modal dialog and put the cancel buttons inside forms, so we must prevent the form submission.
// There are a few cancel buttons in non-modal forms, and there are some dynamically created forms (eg: the "Edit Issue Content")
$(document).on('click', 'form button.ui.cancel.button', (e) => {
e.preventDefault();
});

$('.show-panel').on('click', function (e) {
// a '.show-panel' element can show a panel, by `data-panel="selector"`
// if it has "toggle" class, it toggles the panel
e.preventDefault();
const sel = this.getAttribute('data-panel');
if (this.classList.contains('toggle')) {
toggleElem(sel);
} else {
showElem(sel);
}
});

$('.hide-panel').on('click', function (e) {
// a `.hide-panel` element can hide a panel, by `data-panel="selector"` or `data-panel-closest="selector"`
e.preventDefault();
let sel = this.getAttribute('data-panel');
if (sel) {
hideElem($(sel));
return;
}
sel = this.getAttribute('data-panel-closest');
if (sel) {
hideElem($(this).closest(sel));
return;
}
// should never happen, otherwise there is a bug in code
showErrorToast('Nothing to hide');
});
}

export function initGlobalShowModal() {
// A ".show-modal" button will show a modal dialog defined by its "data-modal" attribute.
// Each "data-modal-{target}" attribute will be filled to target element's value or text-content.
// * First, try to query '#target'
// * Then, try to query '.target'
// * Then, try to query 'target' as HTML tag
// If there is a ".{attr}" part like "data-modal-form.action", then the form's "action" attribute will be set.
$('.show-modal').on('click', function (e) {
e.preventDefault();
const modalSelector = this.getAttribute('data-modal');
const $modal = $(modalSelector);
if (!$modal.length) {
throw new Error('no modal for this action');
}
const modalAttrPrefix = 'data-modal-';
for (const attrib of this.attributes) {
if (!attrib.name.startsWith(modalAttrPrefix)) {
continue;
}

const attrTargetCombo = attrib.name.substring(modalAttrPrefix.length);
const [attrTargetName, attrTargetAttr] = attrTargetCombo.split('.');
// try to find target by: "#target" -> ".target" -> "target tag"
let $attrTarget = $modal.find(`#${attrTargetName}`);
if (!$attrTarget.length) $attrTarget = $modal.find(`.${attrTargetName}`);
if (!$attrTarget.length) $attrTarget = $modal.find(`${attrTargetName}`);
if (!$attrTarget.length) continue; // TODO: show errors in dev mode to remind developers that there is a bug

if (attrTargetAttr) {
$attrTarget[0][attrTargetAttr] = attrib.value;
} else if ($attrTarget[0].matches('input, textarea')) {
$attrTarget.val(attrib.value); // FIXME: add more supports like checkbox
} else {
$attrTarget[0].textContent = attrib.value; // FIXME: it should be more strict here, only handle div/span/p
}
}

$modal.modal('setting', {
onApprove: () => {
// "form-fetch-action" can handle network errors gracefully,
// so keep the modal dialog to make users can re-submit the form if anything wrong happens.
if ($modal.find('.form-fetch-action').length) return false;
},
}).modal('show');
});
}
128 changes: 128 additions & 0 deletions web_src/js/features/common-fetch-action.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import {request} from '../modules/fetch.js';
import {showErrorToast} from '../modules/toast.js';
import {submitEventSubmitter} from '../utils/dom.js';
import {htmlEscape} from 'escape-goat';
import {confirmModal} from './comp/ConfirmModal.js';

const {appSubUrl, i18n} = window.config;

// 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) {
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);
document.body.append(form);
form.submit();
}

async function fetchActionDoRequest(actionElem, url, opt) {
try {
const resp = await request(url, opt);
if (resp.status === 200) {
let {redirect} = await resp.json();
redirect = redirect || actionElem.getAttribute('data-redirect');
actionElem.classList.remove('dirty'); // remove the areYouSure check before reloading
if (redirect) {
fetchActionDoRedirect(redirect);
} else {
window.location.reload();
}
return;
} else if (resp.status >= 400 && resp.status < 500) {
const data = await resp.json();
// 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.
if (data.errorMessage) {
showErrorToast(data.errorMessage, {useHtmlBody: data.renderFormat === 'html'});
} else {
showErrorToast(`server error: ${resp.status}`);
}
} else {
showErrorToast(`server error: ${resp.status}`);
}
} catch (e) {
if (e.name !== 'AbortError') {
console.error('error when doRequest', e);
showErrorToast(`${i18n.network_error} ${e}`);
}
}
actionElem.classList.remove('is-loading', 'loading-icon-2px');
}

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('loading-icon-2px');
}

const formMethod = formEl.getAttribute('method') || 'get';
const formActionUrl = formEl.getAttribute('action');
const formData = new FormData(formEl);
const formSubmitter = submitEventSubmitter(e);
const [submitterName, submitterValue] = [formSubmitter?.getAttribute('name'), formSubmitter?.getAttribute('value')];
if (submitterName) {
formData.append(submitterName, submitterValue || '');
}

let reqUrl = formActionUrl;
const reqOpt = {method: formMethod.toUpperCase()};
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;
}

await fetchActionDoRequest(formEl, reqUrl, reqOpt);
}

async function linkAction(e) {
// 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.
const el = e.target.closest('.link-action');
if (!el) return;

e.preventDefault();
const url = el.getAttribute('data-url');
const doRequest = async () => {
el.disabled = true;
await fetchActionDoRequest(el, url, {method: 'POST'});
el.disabled = false;
};

const modalConfirmContent = htmlEscape(el.getAttribute('data-modal-confirm') || '');
if (!modalConfirmContent) {
await doRequest();
return;
}

const isRisky = el.classList.contains('red') || el.classList.contains('negative');
if (await confirmModal(modalConfirmContent, {confirmButtonColor: isRisky ? 'red' : 'primary'})) {
await doRequest();
}
}

export function initGlobalFetchAction() {
document.addEventListener('submit', formFetchAction);
document.addEventListener('click', linkAction);
}
28 changes: 28 additions & 0 deletions web_src/js/features/common-form.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import $ from 'jquery';
import {handleGlobalEnterQuickSubmit} from './comp/QuickSubmit.js';

export function initGlobalFormDirtyLeaveConfirm() {
// Warn users that try to leave a page after entering data into a form.
// Except on sign-in pages, and for forms marked as 'ignore-dirty'.
if (!$('.user.signin').length) {
$('form:not(.ignore-dirty)').areYouSure();
}
}

export function initGlobalEnterQuickSubmit() {
document.addEventListener('keydown', (e) => {
if (e.key !== 'Enter') return;
const hasCtrlOrMeta = ((e.ctrlKey || e.metaKey) && !e.altKey);
if (hasCtrlOrMeta && e.target.matches('textarea')) {
if (handleGlobalEnterQuickSubmit(e.target)) {
e.preventDefault();
}
} else if (e.target.matches('input') && !e.target.closest('form')) {
// input in a normal form could handle Enter key by default, so we only handle the input outside a form
// eslint-disable-next-line unicorn/no-lonely-if
if (handleGlobalEnterQuickSubmit(e.target)) {
e.preventDefault();
}
}
});
}
Loading

0 comments on commit 0678287

Please sign in to comment.