From 06782872c42af7450e72527e9e9cd83eacd467b9 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Fri, 21 Jun 2024 15:40:33 +0800 Subject: [PATCH] Split common-global.js into separate files (#31438) To improve maintainability --- web_src/js/features/admin/common.js | 2 +- web_src/js/features/common-button.js | 159 +++++++ web_src/js/features/common-fetch-action.js | 128 ++++++ web_src/js/features/common-form.js | 28 ++ web_src/js/features/common-global.js | 467 --------------------- web_src/js/features/common-page.js | 91 ++++ web_src/js/features/dropzone.js | 73 ++++ web_src/js/features/repo-issue.js | 2 +- web_src/js/features/user-auth.js | 2 +- web_src/js/index.js | 29 +- 10 files changed, 497 insertions(+), 484 deletions(-) create mode 100644 web_src/js/features/common-button.js create mode 100644 web_src/js/features/common-fetch-action.js create mode 100644 web_src/js/features/common-form.js delete mode 100644 web_src/js/features/common-global.js create mode 100644 web_src/js/features/common-page.js diff --git a/web_src/js/features/admin/common.js b/web_src/js/features/admin/common.js index 429d6a808c..0f26ce6bc1 100644 --- a/web_src/js/features/admin/common.js +++ b/web_src/js/features/admin/common.js @@ -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'; diff --git a/web_src/js/features/common-button.js b/web_src/js/features/common-button.js new file mode 100644 index 0000000000..2db39871f0 --- /dev/null +++ b/web_src/js/features/common-button.js @@ -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="..."` => `...` + 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'); + }); +} diff --git a/web_src/js/features/common-fetch-action.js b/web_src/js/features/common-fetch-action.js new file mode 100644 index 0000000000..ddf1ec2e79 --- /dev/null +++ b/web_src/js/features/common-fetch-action.js @@ -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); +} diff --git a/web_src/js/features/common-form.js b/web_src/js/features/common-form.js new file mode 100644 index 0000000000..02c5405649 --- /dev/null +++ b/web_src/js/features/common-form.js @@ -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(); + } + } + }); +} diff --git a/web_src/js/features/common-global.js b/web_src/js/features/common-global.js deleted file mode 100644 index 34992c1f5b..0000000000 --- a/web_src/js/features/common-global.js +++ /dev/null @@ -1,467 +0,0 @@ -import $ from 'jquery'; -import '../vendor/jquery.are-you-sure.js'; -import {clippie} from 'clippie'; -import {createDropzone} from './dropzone.js'; -import {showGlobalErrorMessage} from '../bootstrap.js'; -import {handleGlobalEnterQuickSubmit} from './comp/QuickSubmit.js'; -import {svg} from '../svg.js'; -import {hideElem, showElem, toggleElem, initSubmitEventPolyfill, submitEventSubmitter} from '../utils/dom.js'; -import {htmlEscape} from 'escape-goat'; -import {showTemporaryTooltip} from '../modules/tippy.js'; -import {confirmModal} from './comp/ConfirmModal.js'; -import {showErrorToast} from '../modules/toast.js'; -import {request, POST, GET} from '../modules/fetch.js'; - -const {appUrl, appSubUrl, csrfToken, i18n} = window.config; - -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 initHeadNavbarContentToggle() { - const navbar = document.querySelector('#navbar'); - const btn = document.querySelector('#navbar-expand-toggle'); - if (!navbar || !btn) return; - - btn.addEventListener('click', () => { - const isExpanded = btn.classList.contains('active'); - navbar.classList.toggle('navbar-menu-open', !isExpanded); - btn.classList.toggle('active', !isExpanded); - }); -} - -export function initFootLanguageMenu() { - async function linkLanguageAction() { - const $this = $(this); - await GET($this.data('url')); - window.location.reload(); - } - - $('.language-menu a[lang]').on('click', linkLanguageAction); -} - -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(); - } - } - }); -} - -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(); - } - }); -} - -// 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); -} - -export function initGlobalCommon() { - // Semantic UI modules. - const $uiDropdowns = $('.ui.dropdown'); - - // do not init "custom" dropdowns, "custom" dropdowns are managed by their own code. - $uiDropdowns.filter(':not(.custom)').dropdown(); - - // The "jump" means this dropdown is mainly used for "menu" purpose, - // clicking an item will jump to somewhere else or trigger an action/function. - // When a dropdown is used for non-refresh actions with tippy, - // it must have this "jump" class to hide the tippy when dropdown is closed. - $uiDropdowns.filter('.jump').dropdown({ - action: 'hide', - onShow() { - // hide associated tooltip while dropdown is open - this._tippy?.hide(); - this._tippy?.disable(); - }, - onHide() { - this._tippy?.enable(); - - // hide all tippy elements of items after a while. eg: use Enter to click "Copy Link" in the Issue Context Menu - setTimeout(() => { - const $dropdown = $(this); - if ($dropdown.dropdown('is hidden')) { - $(this).find('.menu > .item').each((_, item) => { - item._tippy?.hide(); - }); - } - }, 2000); - }, - }); - - // Special popup-directions, prevent Fomantic from guessing the popup direction. - // With default "direction: auto", if the viewport height is small, Fomantic would show the popup upward, - // if the dropdown is at the beginning of the page, then the top part would be clipped by the window view. - // eg: Issue List "Sort" dropdown - // But we can not set "direction: downward" for all dropdowns, because there is a bug in dropdown menu positioning when calculating the "left" position, - // which would make some dropdown popups slightly shift out of the right viewport edge in some cases. - // eg: the "Create New Repo" menu on the navbar. - $uiDropdowns.filter('.upward').dropdown('setting', 'direction', 'upward'); - $uiDropdowns.filter('.downward').dropdown('setting', 'direction', 'downward'); - - $('.tabular.menu .item').tab(); - - initSubmitEventPolyfill(); - document.addEventListener('submit', formFetchAction); - document.addEventListener('click', linkAction); -} - -export function initGlobalDropzone() { - for (const el of document.querySelectorAll('.dropzone')) { - initDropzone(el); - } -} - -export function initDropzone(el) { - const $dropzone = $(el); - const _promise = createDropzone(el, { - url: $dropzone.data('upload-url'), - headers: {'X-Csrf-Token': csrfToken}, - maxFiles: $dropzone.data('max-file'), - maxFilesize: $dropzone.data('max-size'), - acceptedFiles: (['*/*', ''].includes($dropzone.data('accepts'))) ? null : $dropzone.data('accepts'), - addRemoveLinks: true, - dictDefaultMessage: $dropzone.data('default-message'), - dictInvalidFileType: $dropzone.data('invalid-input-type'), - dictFileTooBig: $dropzone.data('file-too-big'), - dictRemoveFile: $dropzone.data('remove-file'), - timeout: 0, - thumbnailMethod: 'contain', - thumbnailWidth: 480, - thumbnailHeight: 480, - init() { - this.on('success', (file, data) => { - file.uuid = data.uuid; - const $input = $(``).val(data.uuid); - $dropzone.find('.files').append($input); - // Create a "Copy Link" element, to conveniently copy the image - // or file link as Markdown to the clipboard - const copyLinkElement = document.createElement('div'); - copyLinkElement.className = 'tw-text-center'; - // The a element has a hardcoded cursor: pointer because the default is overridden by .dropzone - copyLinkElement.innerHTML = `${svg('octicon-copy', 14, 'copy link')} Copy link`; - copyLinkElement.addEventListener('click', async (e) => { - e.preventDefault(); - let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`; - if (file.type.startsWith('image/')) { - fileMarkdown = `!${fileMarkdown}`; - } else if (file.type.startsWith('video/')) { - fileMarkdown = ``; - } - const success = await clippie(fileMarkdown); - showTemporaryTooltip(e.target, success ? i18n.copy_success : i18n.copy_error); - }); - file.previewTemplate.append(copyLinkElement); - }); - this.on('removedfile', (file) => { - $(`#${file.uuid}`).remove(); - if ($dropzone.data('remove-url')) { - POST($dropzone.data('remove-url'), { - data: new URLSearchParams({file: file.uuid}), - }); - } - }); - this.on('error', function (file, message) { - showErrorToast(message); - this.removeFile(file); - }); - }, - }); -} - -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 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="..."` => `...` - 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'); - }); - } -} - -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'); - }); -} - -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'); - }); - - initGlobalShowModal(); -} - -/** - * Too many users set their ROOT_URL to wrong value, and it causes a lot of problems: - * * Cross-origin API request without correct cookie - * * Incorrect href in - * * ... - * So we check whether current URL starts with AppUrl(ROOT_URL). - * If they don't match, show a warning to users. - */ -export function checkAppUrl() { - const curUrl = window.location.href; - // some users visit "https://domain/gitea" while appUrl is "https://domain/gitea/", there should be no warning - if (curUrl.startsWith(appUrl) || `${curUrl}/` === appUrl) { - return; - } - showGlobalErrorMessage(`Your ROOT_URL in app.ini is "${appUrl}", it's unlikely matching the site you are visiting. -Mismatched ROOT_URL config causes wrong URL links for web UI/mail content/webhook notification/OAuth2 sign-in.`, 'warning'); -} diff --git a/web_src/js/features/common-page.js b/web_src/js/features/common-page.js new file mode 100644 index 0000000000..2df16a5584 --- /dev/null +++ b/web_src/js/features/common-page.js @@ -0,0 +1,91 @@ +import $ from 'jquery'; +import {GET} from '../modules/fetch.js'; +import {showGlobalErrorMessage} from '../bootstrap.js'; + +const {appUrl} = window.config; + +export function initHeadNavbarContentToggle() { + const navbar = document.querySelector('#navbar'); + const btn = document.querySelector('#navbar-expand-toggle'); + if (!navbar || !btn) return; + + btn.addEventListener('click', () => { + const isExpanded = btn.classList.contains('active'); + navbar.classList.toggle('navbar-menu-open', !isExpanded); + btn.classList.toggle('active', !isExpanded); + }); +} + +export function initFootLanguageMenu() { + async function linkLanguageAction() { + const $this = $(this); + await GET($this.data('url')); + window.location.reload(); + } + + $('.language-menu a[lang]').on('click', linkLanguageAction); +} + +export function initGlobalComponents() { + // Semantic UI modules. + const $uiDropdowns = $('.ui.dropdown'); + + // do not init "custom" dropdowns, "custom" dropdowns are managed by their own code. + $uiDropdowns.filter(':not(.custom)').dropdown(); + + // The "jump" means this dropdown is mainly used for "menu" purpose, + // clicking an item will jump to somewhere else or trigger an action/function. + // When a dropdown is used for non-refresh actions with tippy, + // it must have this "jump" class to hide the tippy when dropdown is closed. + $uiDropdowns.filter('.jump').dropdown({ + action: 'hide', + onShow() { + // hide associated tooltip while dropdown is open + this._tippy?.hide(); + this._tippy?.disable(); + }, + onHide() { + this._tippy?.enable(); + + // hide all tippy elements of items after a while. eg: use Enter to click "Copy Link" in the Issue Context Menu + setTimeout(() => { + const $dropdown = $(this); + if ($dropdown.dropdown('is hidden')) { + $(this).find('.menu > .item').each((_, item) => { + item._tippy?.hide(); + }); + } + }, 2000); + }, + }); + + // Special popup-directions, prevent Fomantic from guessing the popup direction. + // With default "direction: auto", if the viewport height is small, Fomantic would show the popup upward, + // if the dropdown is at the beginning of the page, then the top part would be clipped by the window view. + // eg: Issue List "Sort" dropdown + // But we can not set "direction: downward" for all dropdowns, because there is a bug in dropdown menu positioning when calculating the "left" position, + // which would make some dropdown popups slightly shift out of the right viewport edge in some cases. + // eg: the "Create New Repo" menu on the navbar. + $uiDropdowns.filter('.upward').dropdown('setting', 'direction', 'upward'); + $uiDropdowns.filter('.downward').dropdown('setting', 'direction', 'downward'); + + $('.ui.menu.tabular .item').tab({autoTabActivation: false}); +} + +/** + * Too many users set their ROOT_URL to wrong value, and it causes a lot of problems: + * * Cross-origin API request without correct cookie + * * Incorrect href in + * * ... + * So we check whether current URL starts with AppUrl(ROOT_URL). + * If they don't match, show a warning to users. + */ +export function checkAppUrl() { + const curUrl = window.location.href; + // some users visit "https://domain/gitea" while appUrl is "https://domain/gitea/", there should be no warning + if (curUrl.startsWith(appUrl) || `${curUrl}/` === appUrl) { + return; + } + showGlobalErrorMessage(`Your ROOT_URL in app.ini is "${appUrl}", it's unlikely matching the site you are visiting. +Mismatched ROOT_URL config causes wrong URL links for web UI/mail content/webhook notification/OAuth2 sign-in.`, 'warning'); +} diff --git a/web_src/js/features/dropzone.js b/web_src/js/features/dropzone.js index e7b8a9dde9..b3acaf5e6f 100644 --- a/web_src/js/features/dropzone.js +++ b/web_src/js/features/dropzone.js @@ -1,3 +1,13 @@ +import $ from 'jquery'; +import {svg} from '../svg.js'; +import {htmlEscape} from 'escape-goat'; +import {clippie} from 'clippie'; +import {showTemporaryTooltip} from '../modules/tippy.js'; +import {POST} from '../modules/fetch.js'; +import {showErrorToast} from '../modules/toast.js'; + +const {csrfToken, i18n} = window.config; + export async function createDropzone(el, opts) { const [{Dropzone}] = await Promise.all([ import(/* webpackChunkName: "dropzone" */'dropzone'), @@ -5,3 +15,66 @@ export async function createDropzone(el, opts) { ]); return new Dropzone(el, opts); } + +export function initGlobalDropzone() { + for (const el of document.querySelectorAll('.dropzone')) { + initDropzone(el); + } +} + +export function initDropzone(el) { + const $dropzone = $(el); + const _promise = createDropzone(el, { + url: $dropzone.data('upload-url'), + headers: {'X-Csrf-Token': csrfToken}, + maxFiles: $dropzone.data('max-file'), + maxFilesize: $dropzone.data('max-size'), + acceptedFiles: (['*/*', ''].includes($dropzone.data('accepts'))) ? null : $dropzone.data('accepts'), + addRemoveLinks: true, + dictDefaultMessage: $dropzone.data('default-message'), + dictInvalidFileType: $dropzone.data('invalid-input-type'), + dictFileTooBig: $dropzone.data('file-too-big'), + dictRemoveFile: $dropzone.data('remove-file'), + timeout: 0, + thumbnailMethod: 'contain', + thumbnailWidth: 480, + thumbnailHeight: 480, + init() { + this.on('success', (file, data) => { + file.uuid = data.uuid; + const $input = $(``).val(data.uuid); + $dropzone.find('.files').append($input); + // Create a "Copy Link" element, to conveniently copy the image + // or file link as Markdown to the clipboard + const copyLinkElement = document.createElement('div'); + copyLinkElement.className = 'tw-text-center'; + // The a element has a hardcoded cursor: pointer because the default is overridden by .dropzone + copyLinkElement.innerHTML = `${svg('octicon-copy', 14, 'copy link')} Copy link`; + copyLinkElement.addEventListener('click', async (e) => { + e.preventDefault(); + let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`; + if (file.type.startsWith('image/')) { + fileMarkdown = `!${fileMarkdown}`; + } else if (file.type.startsWith('video/')) { + fileMarkdown = ``; + } + const success = await clippie(fileMarkdown); + showTemporaryTooltip(e.target, success ? i18n.copy_success : i18n.copy_error); + }); + file.previewTemplate.append(copyLinkElement); + }); + this.on('removedfile', (file) => { + $(`#${file.uuid}`).remove(); + if ($dropzone.data('remove-url')) { + POST($dropzone.data('remove-url'), { + data: new URLSearchParams({file: file.uuid}), + }); + } + }); + this.on('error', function (file, message) { + showErrorToast(message); + this.removeFile(file); + }); + }, + }); +} diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js index d53c3346f3..fb5b3b191d 100644 --- a/web_src/js/features/repo-issue.js +++ b/web_src/js/features/repo-issue.js @@ -5,7 +5,7 @@ import {hideElem, showElem, toggleElem} from '../utils/dom.js'; import {setFileFolding} from './file-fold.js'; import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js'; import {toAbsoluteUrl} from '../utils.js'; -import {initDropzone} from './common-global.js'; +import {initDropzone} from './dropzone.js'; import {POST, GET} from '../modules/fetch.js'; import {showErrorToast} from '../modules/toast.js'; diff --git a/web_src/js/features/user-auth.js b/web_src/js/features/user-auth.js index 1ea131e75f..355843ccf2 100644 --- a/web_src/js/features/user-auth.js +++ b/web_src/js/features/user-auth.js @@ -1,4 +1,4 @@ -import {checkAppUrl} from './common-global.js'; +import {checkAppUrl} from './common-page.js'; export function initUserAuthOauth2() { const outer = document.querySelector('#oauth2-login-navigator'); diff --git a/web_src/js/index.js b/web_src/js/index.js index 99352727dd..aa7e0d2caa 100644 --- a/web_src/js/index.js +++ b/web_src/js/index.js @@ -36,17 +36,6 @@ import { initRepoPullRequestReview, initRepoIssueSidebarList, initArchivedLabelHandler, } from './features/repo-issue.js'; import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.js'; -import { - initFootLanguageMenu, - initGlobalButtonClickOnEnter, - initGlobalButtons, - initGlobalCommon, - initGlobalDropzone, - initGlobalEnterQuickSubmit, - initGlobalFormDirtyLeaveConfirm, - initGlobalDeleteButton, - initHeadNavbarContentToggle, -} from './features/common-global.js'; import {initRepoTopicBar} from './features/repo-home.js'; import {initAdminEmails} from './features/admin/emails.js'; import {initAdminCommon} from './features/admin/common.js'; @@ -78,7 +67,7 @@ import {initCaptcha} from './features/captcha.js'; import {initRepositoryActionView} from './components/RepoActionView.vue'; import {initGlobalTooltips} from './modules/tippy.js'; import {initGiteaFomantic} from './modules/fomantic.js'; -import {onDomReady} from './utils/dom.js'; +import {initSubmitEventPolyfill, onDomReady} from './utils/dom.js'; import {initRepoIssueList} from './features/repo-issue-list.js'; import {initCommonIssueListQuickGoto} from './features/common-issue-list.js'; import {initRepoContributors} from './features/contributors.js'; @@ -89,14 +78,26 @@ import {initDirAuto} from './modules/dirauto.js'; import {initRepositorySearch} from './features/repo-search.js'; import {initColorPickers} from './features/colorpicker.js'; import {initAdminSelfCheck} from './features/admin/selfcheck.js'; +import {initGlobalFetchAction} from './features/common-fetch-action.js'; +import {initFootLanguageMenu, initGlobalComponents, initHeadNavbarContentToggle} from './features/common-page.js'; +import { + initGlobalButtonClickOnEnter, + initGlobalButtons, + initGlobalDeleteButton, + initGlobalShowModal, +} from './features/common-button.js'; +import {initGlobalDropzone} from './features/dropzone.js'; +import {initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.js'; // Init Gitea's Fomantic settings initGiteaFomantic(); initDirAuto(); +initSubmitEventPolyfill(); onDomReady(() => { - initGlobalCommon(); - + initGlobalComponents(); + initGlobalShowModal(); + initGlobalFetchAction(); initGlobalTooltips(); initGlobalButtonClickOnEnter(); initGlobalButtons();