OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / web / static / pgv3-user-typeahead.js

184 lines · javascript ref:
/**
 * User typeahead — search-as-you-type picker that resolves any
 * identifier (username, identity hex, userId, display name) to a
 * canonical userId before submission.
 *
 * Markup contract:
 *
 *   <div class="pgv3-user-picker">
 *     <input type="text" data-pgv3-user-pick="member-userid-hidden"
 *            placeholder="Search by name, username, or identity…">
 *     <input type="hidden" id="member-userid-hidden" name="userId">
 *     <div class="pgv3-user-picker-dropdown" hidden></div>
 *     <div class="pgv3-user-picker-status" aria-live="polite"></div>
 *   </div>
 *
 * On selection, the visible input shows "Alice (d07ad368…)" and the
 * hidden field is set to the canonical userId. Submitting the form
 * with an empty hidden field is blocked client-side. The backend
 * `add-org-member` handler is the authoritative gate — this JS is
 * UX only.
 */

(function () {
  function esc (s) {
    if (s == null) return ''
    return String(s)
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
  }

  function fmtUser (u) {
    // Display priority: name → username → primaryIdentity → userId
    const label = u.name || u.username || (u.primaryIdentity ? u.primaryIdentity.slice(0, 12) + '…' : u.userId.slice(0, 12) + '…')
    const sub = u.username && u.username !== label
      ? '@' + u.username
      : (u.primaryIdentity ? u.primaryIdentity.slice(0, 16) + '…' : u.userId.slice(0, 16) + '…')
    return { label, sub }
  }

  function attach (visibleInput) {
    const hiddenId = visibleInput.dataset.pgv3UserPick
    if (!hiddenId) return
    const hidden = document.getElementById(hiddenId)
    if (!hidden) return
    const wrap = visibleInput.closest('.pgv3-user-picker')
    if (!wrap) return
    const dropdown = wrap.querySelector('.pgv3-user-picker-dropdown')
    const status = wrap.querySelector('.pgv3-user-picker-status')
    if (!dropdown) return
    const pfx = (document.body && document.body.dataset.prefix) || ''
    let debounce = null
    let lastResults = []

    function hide () { dropdown.hidden = true }
    function show () { dropdown.hidden = false }

    function clearSelection () {
      hidden.value = ''
      if (status) status.textContent = ''
      visibleInput.classList.remove('resolved')
    }

    function select (u) {
      hidden.value = u.userId
      const { label, sub } = fmtUser(u)
      visibleInput.value = label + ' (' + sub + ')'
      visibleInput.classList.add('resolved')
      if (status) status.textContent = '✓ ' + label
      hide()
    }

    function render (q, rows) {
      lastResults = rows
      if (!rows.length) {
        dropdown.innerHTML = '<div class="pgv3-user-picker-empty">No users match</div>'
        show()
        return
      }
      dropdown.innerHTML = rows.map((u, i) => {
        const { label, sub } = fmtUser(u)
        const perms = (u.globalPermissions || []).join(',') || 'read'
        return '<div class="pgv3-user-picker-row" data-idx="' + i + '" tabindex="0">' +
          '<div class="pgv3-user-picker-row-label">' + esc(label) + '</div>' +
          '<div class="pgv3-user-picker-row-sub">' + esc(sub) + ' · ' + esc(perms) + '</div>' +
          '</div>'
      }).join('')
      show()
    }

    async function run (q) {
      if (status) status.textContent = 'searching…'
      try {
        const r = await fetch(pfx + '/api/search/users?q=' + encodeURIComponent(q) + '&limit=10')
        if (!r.ok) throw new Error('search failed: ' + r.status)
        const rows = await r.json()
        render(q, rows)
        if (status) status.textContent = rows.length + ' match' + (rows.length === 1 ? '' : 'es')
      } catch (err) {
        if (status) status.textContent = 'error: ' + err.message
        dropdown.innerHTML = '<div class="pgv3-user-picker-empty">search failed</div>'
        show()
      }
    }

    visibleInput.addEventListener('input', () => {
      clearSelection()
      const q = visibleInput.value.trim()
      clearTimeout(debounce)
      if (q.length < 2) { hide(); if (status) status.textContent = ''; return }
      debounce = setTimeout(() => run(q), 150)
    })

    visibleInput.addEventListener('focus', () => {
      if (lastResults.length && visibleInput.value.trim().length >= 2 && !hidden.value) show()
    })

    visibleInput.addEventListener('keydown', (e) => {
      if (e.key === 'Escape') { hide() }
      else if (e.key === 'ArrowDown') {
        const first = dropdown.querySelector('.pgv3-user-picker-row')
        if (first) first.focus()
        e.preventDefault()
      }
    })

    dropdown.addEventListener('click', (e) => {
      const row = e.target.closest('.pgv3-user-picker-row')
      if (!row) return
      const idx = parseInt(row.dataset.idx, 10)
      const u = lastResults[idx]
      if (u) select(u)
    })

    dropdown.addEventListener('keydown', (e) => {
      const row = e.target.closest('.pgv3-user-picker-row')
      if (!row) return
      if (e.key === 'Enter' || e.key === ' ') {
        const idx = parseInt(row.dataset.idx, 10)
        const u = lastResults[idx]
        if (u) select(u)
        e.preventDefault()
      } else if (e.key === 'ArrowDown' && row.nextElementSibling) {
        row.nextElementSibling.focus()
        e.preventDefault()
      } else if (e.key === 'ArrowUp') {
        if (row.previousElementSibling) row.previousElementSibling.focus()
        else visibleInput.focus()
        e.preventDefault()
      } else if (e.key === 'Escape') {
        hide()
        visibleInput.focus()
      }
    })

    document.addEventListener('click', (e) => {
      if (!e.target.closest('.pgv3-user-picker')) hide()
    })

    // Submit guard — block the form if no user is resolved.
    const form = visibleInput.closest('form')
    if (form) {
      form.addEventListener('submit', (e) => {
        if (!hidden.value) {
          e.preventDefault()
          if (status) status.textContent = 'pick a user from the dropdown first'
          visibleInput.focus()
        }
      })
    }
  }

  function init () {
    document.querySelectorAll('input[data-pgv3-user-pick]').forEach(attach)
  }

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