OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / web / static / pgv3-forms.js

103 lines · javascript ref:
// pgv3-forms.js — site-wide submit-button lock to prevent double-submits.
//
// Closes UX blocker U5 (docs/plans/2026-04-14-ux-reaudit.md): before this,
// double-clicking a Create / Save button could send two POSTs, producing
// duplicate records (or two conflicting writes on slow networks).
//
// Behaviour
// =========
// On DOMContentLoaded:
//   - Every <form> in the document gets a single 'submit' listener.
//   - When a form submits, every <button type="submit"> and <input
//     type="submit"> inside that form is disabled and given class
//     'pgv3-submitting'. The original label is stashed on dataset.origLabel
//     and replaced with the button's data-busy-label (default "…").
//   - If the submit handler returned falsy (preventDefault'd by an
//     AJAX form, e.g. /profile), the buttons are re-enabled when the
//     form dispatches a custom 'pgv3:done' event OR after a fallback
//     timeout (10s) so a failed AJAX submit doesn't strand the form.
//   - For traditional submits (default behaviour — browser navigates),
//     the buttons stay disabled because the page is about to unload.
//
// AJAX forms that preventDefault should dispatch `pgv3:done` on the
// form element when they finish, success or failure. The profile
// form's pgv3SaveProfile() is updated in the same slice.
//
// CSS: add styling for .pgv3-submitting in your stylesheet (or rely on
// the default disabled-button styling — still better than unchanged).

(function () {
  'use strict'

  var FALLBACK_TIMEOUT_MS = 10000

  function lockForm (form) {
    var controls = form.querySelectorAll('button[type="submit"], input[type="submit"], button:not([type])')
    controls.forEach(function (btn) {
      if (btn.dataset.pgv3Locked) return
      btn.dataset.pgv3Locked = '1'
      btn.dataset.pgv3OrigLabel = btn.textContent || btn.value || ''
      var busy = btn.dataset.busyLabel || 'Working…'
      if (btn.tagName === 'BUTTON') btn.textContent = busy
      else btn.value = busy
      btn.disabled = true
      btn.classList.add('pgv3-submitting')
    })
  }

  function unlockForm (form) {
    var controls = form.querySelectorAll('button[type="submit"], input[type="submit"], button:not([type])')
    controls.forEach(function (btn) {
      if (!btn.dataset.pgv3Locked) return
      btn.disabled = false
      btn.classList.remove('pgv3-submitting')
      var orig = btn.dataset.pgv3OrigLabel
      if (orig != null) {
        if (btn.tagName === 'BUTTON') btn.textContent = orig
        else btn.value = orig
      }
      delete btn.dataset.pgv3Locked
      delete btn.dataset.pgv3OrigLabel
    })
  }

  function attachLock (form) {
    if (form.dataset.pgv3FormsBound) return
    form.dataset.pgv3FormsBound = '1'

    form.addEventListener('submit', function (ev) {
      // Lock immediately, before the event default runs. If the form
      // uses preventDefault (AJAX), we'll rely on pgv3:done or the
      // fallback timer to unlock. If it doesn't, the browser navigates
      // and the new page reinitialises everything.
      lockForm(form)

      if (ev.defaultPrevented || form.dataset.pgv3Ajax === '1') {
        // AJAX form — wait for explicit done or timeout.
        var fallback = setTimeout(function () {
          unlockForm(form)
        }, FALLBACK_TIMEOUT_MS)
        form.addEventListener('pgv3:done', function onDone () {
          clearTimeout(fallback)
          form.removeEventListener('pgv3:done', onDone)
          unlockForm(form)
        })
      }
    })
  }

  function initAll () {
    document.querySelectorAll('form').forEach(attachLock)
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', initAll)
  } else {
    initAll()
  }

  // Expose a tiny API so inline handlers / future dynamic forms can
  // participate without re-implementing the locking logic.
  window.pgv3Forms = { lock: lockForm, unlock: unlockForm, attach: attachLock }
})()