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