code.gitea.io/gitea@v1.22.3/web_src/js/features/common-global.js (about)

     1  import $ from 'jquery';
     2  import '../vendor/jquery.are-you-sure.js';
     3  import {clippie} from 'clippie';
     4  import {createDropzone} from './dropzone.js';
     5  import {showGlobalErrorMessage} from '../bootstrap.js';
     6  import {handleGlobalEnterQuickSubmit} from './comp/QuickSubmit.js';
     7  import {svg} from '../svg.js';
     8  import {hideElem, showElem, toggleElem, initSubmitEventPolyfill, submitEventSubmitter} from '../utils/dom.js';
     9  import {htmlEscape} from 'escape-goat';
    10  import {showTemporaryTooltip} from '../modules/tippy.js';
    11  import {confirmModal} from './comp/ConfirmModal.js';
    12  import {showErrorToast} from '../modules/toast.js';
    13  import {request, POST, GET} from '../modules/fetch.js';
    14  import '../htmx.js';
    15  
    16  const {appUrl, appSubUrl, csrfToken, i18n} = window.config;
    17  
    18  export function initGlobalFormDirtyLeaveConfirm() {
    19    // Warn users that try to leave a page after entering data into a form.
    20    // Except on sign-in pages, and for forms marked as 'ignore-dirty'.
    21    if (!$('.user.signin').length) {
    22      $('form:not(.ignore-dirty)').areYouSure();
    23    }
    24  }
    25  
    26  export function initHeadNavbarContentToggle() {
    27    const navbar = document.getElementById('navbar');
    28    const btn = document.getElementById('navbar-expand-toggle');
    29    if (!navbar || !btn) return;
    30  
    31    btn.addEventListener('click', () => {
    32      const isExpanded = btn.classList.contains('active');
    33      navbar.classList.toggle('navbar-menu-open', !isExpanded);
    34      btn.classList.toggle('active', !isExpanded);
    35    });
    36  }
    37  
    38  export function initFootLanguageMenu() {
    39    async function linkLanguageAction() {
    40      const $this = $(this);
    41      await GET($this.data('url'));
    42      window.location.reload();
    43    }
    44  
    45    $('.language-menu a[lang]').on('click', linkLanguageAction);
    46  }
    47  
    48  export function initGlobalEnterQuickSubmit() {
    49    document.addEventListener('keydown', (e) => {
    50      if (e.key !== 'Enter') return;
    51      const hasCtrlOrMeta = ((e.ctrlKey || e.metaKey) && !e.altKey);
    52      if (hasCtrlOrMeta && e.target.matches('textarea')) {
    53        if (handleGlobalEnterQuickSubmit(e.target)) {
    54          e.preventDefault();
    55        }
    56      } else if (e.target.matches('input') && !e.target.closest('form')) {
    57        // input in a normal form could handle Enter key by default, so we only handle the input outside a form
    58        // eslint-disable-next-line unicorn/no-lonely-if
    59        if (handleGlobalEnterQuickSubmit(e.target)) {
    60          e.preventDefault();
    61        }
    62      }
    63    });
    64  }
    65  
    66  export function initGlobalButtonClickOnEnter() {
    67    $(document).on('keypress', 'div.ui.button,span.ui.button', (e) => {
    68      if (e.code === ' ' || e.code === 'Enter') {
    69        $(e.target).trigger('click');
    70        e.preventDefault();
    71      }
    72    });
    73  }
    74  
    75  // fetchActionDoRedirect does real redirection to bypass the browser's limitations of "location"
    76  // more details are in the backend's fetch-redirect handler
    77  function fetchActionDoRedirect(redirect) {
    78    const form = document.createElement('form');
    79    const input = document.createElement('input');
    80    form.method = 'post';
    81    form.action = `${appSubUrl}/-/fetch-redirect`;
    82    input.type = 'hidden';
    83    input.name = 'redirect';
    84    input.value = redirect;
    85    form.append(input);
    86    document.body.append(form);
    87    form.submit();
    88  }
    89  
    90  async function fetchActionDoRequest(actionElem, url, opt) {
    91    try {
    92      const resp = await request(url, opt);
    93      if (resp.status === 200) {
    94        let {redirect} = await resp.json();
    95        redirect = redirect || actionElem.getAttribute('data-redirect');
    96        actionElem.classList.remove('dirty'); // remove the areYouSure check before reloading
    97        if (redirect) {
    98          fetchActionDoRedirect(redirect);
    99        } else {
   100          window.location.reload();
   101        }
   102        return;
   103      } else if (resp.status >= 400 && resp.status < 500) {
   104        const data = await resp.json();
   105        // the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error"
   106        // but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond.
   107        if (data.errorMessage) {
   108          showErrorToast(data.errorMessage, {useHtmlBody: data.renderFormat === 'html'});
   109        } else {
   110          showErrorToast(`server error: ${resp.status}`);
   111        }
   112      } else {
   113        showErrorToast(`server error: ${resp.status}`);
   114      }
   115    } catch (e) {
   116      if (e.name !== 'AbortError') {
   117        console.error('error when doRequest', e);
   118        showErrorToast(`${i18n.network_error} ${e}`);
   119      }
   120    }
   121    actionElem.classList.remove('is-loading', 'loading-icon-2px');
   122  }
   123  
   124  async function formFetchAction(e) {
   125    if (!e.target.classList.contains('form-fetch-action')) return;
   126  
   127    e.preventDefault();
   128    const formEl = e.target;
   129    if (formEl.classList.contains('is-loading')) return;
   130  
   131    formEl.classList.add('is-loading');
   132    if (formEl.clientHeight < 50) {
   133      formEl.classList.add('loading-icon-2px');
   134    }
   135  
   136    const formMethod = formEl.getAttribute('method') || 'get';
   137    const formActionUrl = formEl.getAttribute('action');
   138    const formData = new FormData(formEl);
   139    const formSubmitter = submitEventSubmitter(e);
   140    const [submitterName, submitterValue] = [formSubmitter?.getAttribute('name'), formSubmitter?.getAttribute('value')];
   141    if (submitterName) {
   142      formData.append(submitterName, submitterValue || '');
   143    }
   144  
   145    let reqUrl = formActionUrl;
   146    const reqOpt = {method: formMethod.toUpperCase()};
   147    if (formMethod.toLowerCase() === 'get') {
   148      const params = new URLSearchParams();
   149      for (const [key, value] of formData) {
   150        params.append(key, value.toString());
   151      }
   152      const pos = reqUrl.indexOf('?');
   153      if (pos !== -1) {
   154        reqUrl = reqUrl.slice(0, pos);
   155      }
   156      reqUrl += `?${params.toString()}`;
   157    } else {
   158      reqOpt.body = formData;
   159    }
   160  
   161    await fetchActionDoRequest(formEl, reqUrl, reqOpt);
   162  }
   163  
   164  export function initGlobalCommon() {
   165    // Semantic UI modules.
   166    const $uiDropdowns = $('.ui.dropdown');
   167  
   168    // do not init "custom" dropdowns, "custom" dropdowns are managed by their own code.
   169    $uiDropdowns.filter(':not(.custom)').dropdown();
   170  
   171    // The "jump" means this dropdown is mainly used for "menu" purpose,
   172    // clicking an item will jump to somewhere else or trigger an action/function.
   173    // When a dropdown is used for non-refresh actions with tippy,
   174    // it must have this "jump" class to hide the tippy when dropdown is closed.
   175    $uiDropdowns.filter('.jump').dropdown({
   176      action: 'hide',
   177      onShow() {
   178        // hide associated tooltip while dropdown is open
   179        this._tippy?.hide();
   180        this._tippy?.disable();
   181      },
   182      onHide() {
   183        this._tippy?.enable();
   184  
   185        // hide all tippy elements of items after a while. eg: use Enter to click "Copy Link" in the Issue Context Menu
   186        setTimeout(() => {
   187          const $dropdown = $(this);
   188          if ($dropdown.dropdown('is hidden')) {
   189            $(this).find('.menu > .item').each((_, item) => {
   190              item._tippy?.hide();
   191            });
   192          }
   193        }, 2000);
   194      },
   195    });
   196  
   197    // Special popup-directions, prevent Fomantic from guessing the popup direction.
   198    // With default "direction: auto", if the viewport height is small, Fomantic would show the popup upward,
   199    //   if the dropdown is at the beginning of the page, then the top part would be clipped by the window view.
   200    //   eg: Issue List "Sort" dropdown
   201    // But we can not set "direction: downward" for all dropdowns, because there is a bug in dropdown menu positioning when calculating the "left" position,
   202    //   which would make some dropdown popups slightly shift out of the right viewport edge in some cases.
   203    //   eg: the "Create New Repo" menu on the navbar.
   204    $uiDropdowns.filter('.upward').dropdown('setting', 'direction', 'upward');
   205    $uiDropdowns.filter('.downward').dropdown('setting', 'direction', 'downward');
   206  
   207    $('.tabular.menu .item').tab();
   208  
   209    initSubmitEventPolyfill();
   210    document.addEventListener('submit', formFetchAction);
   211    document.addEventListener('click', linkAction);
   212  }
   213  
   214  export function initGlobalDropzone() {
   215    for (const el of document.querySelectorAll('.dropzone')) {
   216      initDropzone(el);
   217    }
   218  }
   219  
   220  export function initDropzone(el) {
   221    const $dropzone = $(el);
   222    const _promise = createDropzone(el, {
   223      url: $dropzone.data('upload-url'),
   224      headers: {'X-Csrf-Token': csrfToken},
   225      maxFiles: $dropzone.data('max-file'),
   226      maxFilesize: $dropzone.data('max-size'),
   227      acceptedFiles: (['*/*', ''].includes($dropzone.data('accepts'))) ? null : $dropzone.data('accepts'),
   228      addRemoveLinks: true,
   229      dictDefaultMessage: $dropzone.data('default-message'),
   230      dictInvalidFileType: $dropzone.data('invalid-input-type'),
   231      dictFileTooBig: $dropzone.data('file-too-big'),
   232      dictRemoveFile: $dropzone.data('remove-file'),
   233      timeout: 0,
   234      thumbnailMethod: 'contain',
   235      thumbnailWidth: 480,
   236      thumbnailHeight: 480,
   237      init() {
   238        this.on('success', (file, data) => {
   239          file.uuid = data.uuid;
   240          const $input = $(`<input id="${data.uuid}" name="files" type="hidden">`).val(data.uuid);
   241          $dropzone.find('.files').append($input);
   242          // Create a "Copy Link" element, to conveniently copy the image
   243          // or file link as Markdown to the clipboard
   244          const copyLinkElement = document.createElement('div');
   245          copyLinkElement.className = 'tw-text-center';
   246          // The a element has a hardcoded cursor: pointer because the default is overridden by .dropzone
   247          copyLinkElement.innerHTML = `<a href="#" style="cursor: pointer;">${svg('octicon-copy', 14, 'copy link')} Copy link</a>`;
   248          copyLinkElement.addEventListener('click', async (e) => {
   249            e.preventDefault();
   250            let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`;
   251            if (file.type.startsWith('image/')) {
   252              fileMarkdown = `!${fileMarkdown}`;
   253            } else if (file.type.startsWith('video/')) {
   254              fileMarkdown = `<video src="/attachments/${file.uuid}" title="${htmlEscape(file.name)}" controls></video>`;
   255            }
   256            const success = await clippie(fileMarkdown);
   257            showTemporaryTooltip(e.target, success ? i18n.copy_success : i18n.copy_error);
   258          });
   259          file.previewTemplate.append(copyLinkElement);
   260        });
   261        this.on('removedfile', (file) => {
   262          $(`#${file.uuid}`).remove();
   263          if ($dropzone.data('remove-url')) {
   264            POST($dropzone.data('remove-url'), {
   265              data: new URLSearchParams({file: file.uuid}),
   266            });
   267          }
   268        });
   269        this.on('error', function (file, message) {
   270          showErrorToast(message);
   271          this.removeFile(file);
   272        });
   273      },
   274    });
   275  }
   276  
   277  async function linkAction(e) {
   278    // A "link-action" can post AJAX request to its "data-url"
   279    // Then the browser is redirected to: the "redirect" in response, or "data-redirect" attribute, or current URL by reloading.
   280    // If the "link-action" has "data-modal-confirm" attribute, a confirm modal dialog will be shown before taking action.
   281    const el = e.target.closest('.link-action');
   282    if (!el) return;
   283  
   284    e.preventDefault();
   285    const url = el.getAttribute('data-url');
   286    const doRequest = async () => {
   287      el.disabled = true;
   288      await fetchActionDoRequest(el, url, {method: 'POST'});
   289      el.disabled = false;
   290    };
   291  
   292    const modalConfirmContent = htmlEscape(el.getAttribute('data-modal-confirm') || '');
   293    if (!modalConfirmContent) {
   294      await doRequest();
   295      return;
   296    }
   297  
   298    const isRisky = el.classList.contains('red') || el.classList.contains('negative');
   299    if (await confirmModal(modalConfirmContent, {confirmButtonColor: isRisky ? 'red' : 'primary'})) {
   300      await doRequest();
   301    }
   302  }
   303  
   304  export function initGlobalLinkActions() {
   305    function showDeletePopup(e) {
   306      e.preventDefault();
   307      const $this = $(this);
   308      const dataArray = $this.data();
   309      let filter = '';
   310      if (this.getAttribute('data-modal-id')) {
   311        filter += `#${this.getAttribute('data-modal-id')}`;
   312      }
   313  
   314      const $dialog = $(`.delete.modal${filter}`);
   315      $dialog.find('.name').text($this.data('name'));
   316      for (const [key, value] of Object.entries(dataArray)) {
   317        if (key && key.startsWith('data')) {
   318          $dialog.find(`.${key}`).text(value);
   319        }
   320      }
   321  
   322      $dialog.modal({
   323        closable: false,
   324        onApprove: async () => {
   325          if ($this.data('type') === 'form') {
   326            $($this.data('form')).trigger('submit');
   327            return;
   328          }
   329          const postData = new FormData();
   330          for (const [key, value] of Object.entries(dataArray)) {
   331            if (key && key.startsWith('data')) {
   332              postData.append(key.slice(4), value);
   333            }
   334            if (key === 'id') {
   335              postData.append('id', value);
   336            }
   337          }
   338  
   339          const response = await POST($this.data('url'), {data: postData});
   340          if (response.ok) {
   341            const data = await response.json();
   342            window.location.href = data.redirect;
   343          }
   344        },
   345      }).modal('show');
   346    }
   347  
   348    // Helpers.
   349    $('.delete-button').on('click', showDeletePopup);
   350  }
   351  
   352  function initGlobalShowModal() {
   353    // A ".show-modal" button will show a modal dialog defined by its "data-modal" attribute.
   354    // Each "data-modal-{target}" attribute will be filled to target element's value or text-content.
   355    // * First, try to query '#target'
   356    // * Then, try to query '.target'
   357    // * Then, try to query 'target' as HTML tag
   358    // If there is a ".{attr}" part like "data-modal-form.action", then the form's "action" attribute will be set.
   359    $('.show-modal').on('click', function (e) {
   360      e.preventDefault();
   361      const modalSelector = this.getAttribute('data-modal');
   362      const $modal = $(modalSelector);
   363      if (!$modal.length) {
   364        throw new Error('no modal for this action');
   365      }
   366      const modalAttrPrefix = 'data-modal-';
   367      for (const attrib of this.attributes) {
   368        if (!attrib.name.startsWith(modalAttrPrefix)) {
   369          continue;
   370        }
   371  
   372        const attrTargetCombo = attrib.name.substring(modalAttrPrefix.length);
   373        const [attrTargetName, attrTargetAttr] = attrTargetCombo.split('.');
   374        // try to find target by: "#target" -> ".target" -> "target tag"
   375        let $attrTarget = $modal.find(`#${attrTargetName}`);
   376        if (!$attrTarget.length) $attrTarget = $modal.find(`.${attrTargetName}`);
   377        if (!$attrTarget.length) $attrTarget = $modal.find(`${attrTargetName}`);
   378        if (!$attrTarget.length) continue; // TODO: show errors in dev mode to remind developers that there is a bug
   379  
   380        if (attrTargetAttr) {
   381          $attrTarget[0][attrTargetAttr] = attrib.value;
   382        } else if ($attrTarget[0].matches('input, textarea')) {
   383          $attrTarget.val(attrib.value); // FIXME: add more supports like checkbox
   384        } else {
   385          $attrTarget.text(attrib.value); // FIXME: it should be more strict here, only handle div/span/p
   386        }
   387      }
   388  
   389      $modal.modal('setting', {
   390        onApprove: () => {
   391          // "form-fetch-action" can handle network errors gracefully,
   392          // so keep the modal dialog to make users can re-submit the form if anything wrong happens.
   393          if ($modal.find('.form-fetch-action').length) return false;
   394        },
   395      }).modal('show');
   396    });
   397  }
   398  
   399  export function initGlobalButtons() {
   400    // There are many "cancel button" elements in modal dialogs, Fomantic UI expects they are button-like elements but never submit a form.
   401    // However, Gitea misuses the modal dialog and put the cancel buttons inside forms, so we must prevent the form submission.
   402    // There are a few cancel buttons in non-modal forms, and there are some dynamically created forms (eg: the "Edit Issue Content")
   403    $(document).on('click', 'form button.ui.cancel.button', (e) => {
   404      e.preventDefault();
   405    });
   406  
   407    $('.show-panel').on('click', function (e) {
   408      // a '.show-panel' element can show a panel, by `data-panel="selector"`
   409      // if it has "toggle" class, it toggles the panel
   410      e.preventDefault();
   411      const sel = this.getAttribute('data-panel');
   412      if (this.classList.contains('toggle')) {
   413        toggleElem(sel);
   414      } else {
   415        showElem(sel);
   416      }
   417    });
   418  
   419    $('.hide-panel').on('click', function (e) {
   420      // a `.hide-panel` element can hide a panel, by `data-panel="selector"` or `data-panel-closest="selector"`
   421      e.preventDefault();
   422      let sel = this.getAttribute('data-panel');
   423      if (sel) {
   424        hideElem($(sel));
   425        return;
   426      }
   427      sel = this.getAttribute('data-panel-closest');
   428      if (sel) {
   429        hideElem($(this).closest(sel));
   430        return;
   431      }
   432      // should never happen, otherwise there is a bug in code
   433      showErrorToast('Nothing to hide');
   434    });
   435  
   436    initGlobalShowModal();
   437  }
   438  
   439  /**
   440   * Too many users set their ROOT_URL to wrong value, and it causes a lot of problems:
   441   *   * Cross-origin API request without correct cookie
   442   *   * Incorrect href in <a>
   443   *   * ...
   444   * So we check whether current URL starts with AppUrl(ROOT_URL).
   445   * If they don't match, show a warning to users.
   446   */
   447  export function checkAppUrl() {
   448    const curUrl = window.location.href;
   449    // some users visit "https://domain/gitea" while appUrl is "https://domain/gitea/", there should be no warning
   450    if (curUrl.startsWith(appUrl) || `${curUrl}/` === appUrl) {
   451      return;
   452    }
   453    showGlobalErrorMessage(`Your ROOT_URL in app.ini is "${appUrl}", it's unlikely matching the site you are visiting.
   454  Mismatched ROOT_URL config causes wrong URL links for web UI/mail content/webhook notification/OAuth2 sign-in.`, 'warning');
   455  }