From 08e010cbcf5b15534762aa10d89ba30b0fca30e2 Mon Sep 17 00:00:00 2001 From: Yogesh Ojha Date: Tue, 3 Sep 2024 20:29:25 +0530 Subject: [PATCH] debounce search, optimize query --- web/static/custom/bountyhub.js | 405 ++++++++++++++------------------- 1 file changed, 166 insertions(+), 239 deletions(-) diff --git a/web/static/custom/bountyhub.js b/web/static/custom/bountyhub.js index 25c75ca3..cefa8b5b 100644 --- a/web/static/custom/bountyhub.js +++ b/web/static/custom/bountyhub.js @@ -1,242 +1,141 @@ -// this js file will be used to do everything js related to the bountyhub page +// all the js code for the bountyhub page document.addEventListener('DOMContentLoaded', function() { + let allPrograms = []; + const container = document.getElementById('program_cards'); + const importBtn = document.getElementById('importProgramsBtn'); + const clearBtn = document.getElementById('clearSelectionsLink'); + const filterSelect = document.querySelector('select[aria-label="Program type"]'); + const searchInput = document.querySelector('#search-program-box'); + const showClosedCheckbox = document.getElementById('show-closed-programs'); + const sortSelect = document.getElementById('sort-select'); + + // Debounce function for search input to avoid making too many requests + const debounce = (func, delay) => { + let timeoutId; + return (...args) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => func.apply(null, args), delay); + }; + }; + + async function fetchPrograms(isSortingRequest = false) { + if (isSortingRequest) { + showLoadingIndicator("Sorting..."); + } else { + showLoadingIndicator("Loading HackerOne Programs"); + } - function fetchPrograms(isSortingRequest = false){ let api_url = '/api/hackerone-programs/'; const sortParams = updateSortingParams(); const queryParams = new URLSearchParams(sortParams).toString(); - if (queryParams){ - api_url += '?' + queryParams + if (queryParams) { + api_url += '?' + queryParams; } - - if (isSortingRequest) { - Swal.fire({ - title: 'Sorting...', - text: 'Please wait', - allowOutsideClick: false, - allowEscapeKey: false, - showConfirmButton: false, - willOpen: () => { - Swal.showLoading(); + try { + const response = await fetch(api_url, { + method: "GET", + credentials: "same-origin", + headers: { + "X-CSRFToken": getCookie("csrftoken"), }, - customClass: { - popup: 'small-swal' - } }); - } else { - Swal.fire({ - title: "Loading HackerOne Programs", - text: "Fetching the latest data...", - icon: "info", - allowOutsideClick: false, - allowEscapeKey: false, - showConfirmButton: false, - didOpen: () => { - Swal.showLoading(); - } - }); - } - - fetch(api_url, { - method: "GET", - credentials: "same-origin", - headers: { - "X-CSRFToken": getCookie("csrftoken"), - }, - }) - .then(response => { + if (!response.ok) { throw new Error('Network response was not ok'); } - return response.json(); - }) - .then(data => { - Swal.close(); + + const data = await response.json(); + allPrograms = data; displayPrograms(data); - }) - .catch(error => { - Swal.close(); + } catch (error) { displayErrorMessage("An error occurred while fetching the hackerone programs. Please try again later. Make sure you have hackerone api key set in your API Vault."); console.error('Error:', error); - }); + } finally { + hideLoadingIndicator(); + } } function displayPrograms(programs) { - const container = document.getElementById('program_cards'); - container.innerHTML = ''; + container.innerHTML = ''; // clear up the html content if (!programs || programs.length === 0) { displayErrorMessage("No programs available at the moment."); return; } - programs.forEach(program => { - const { id, attributes } = program; - const card = document.createElement('div'); - card.className = 'col-md-6 col-lg-4 col-xl-3 mb-3 program-card-wrapper'; - card.innerHTML = ` -
+ const fragment = document.createDocumentFragment(); + const template = document.createElement('template'); -
-
- ${attributes.name} -
-
${attributes.name}  - ${attributes.bookmarked ? '' : ''} -
- @${attributes.handle} + programs.forEach(program => { + const { attributes } = program; + template.innerHTML = ` +
+
+
+ +
+ `; + const cardNode = template.content.firstElementChild.cloneNode(true); + cardNode.querySelector('.card-body').innerHTML = generateCardContent(attributes); + fragment.appendChild(cardNode); + }); -
- ${attributes.submission_state === 'open' ? 'Open for Submission' : 'Closed'} - ${attributes.state === 'public_mode' ? 'Public Program' : 'Private Program'} - ${attributes.offers_bounties ? 'Bounty $$$' : 'VDP'} - ${attributes.open_scope ? 'Open Scope' : ''} - ${isProgramNew(attributes.started_accepting_at) ? ' New' : ''} -
+ container.appendChild(fragment); + initializeFilter(); + } -
-
My Reports: ${attributes.number_of_reports_for_user}
-
My Earnings: $${attributes.bounty_earned_for_user.toFixed(2)}
-
- -
- -
-
Since ${new Date(attributes.started_accepting_at).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
-
${attributes.currency.toUpperCase()}
-
- See details + function generateCardContent(attributes) { + return ` +
+ ${attributes.name} +
+
${attributes.name}${attributes.bookmarked ? '' : ''}
+ @${attributes.handle}
- `; - - - // Initialize tooltips esp for bookmarked programs - const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]'); - tooltips.forEach((tooltip) => { - new bootstrap.Tooltip(tooltip); - }); - container.appendChild(card); - - initializeFilter(); - - }); +
+ ${attributes.submission_state === 'open' ? 'Open' : 'Closed'} + ${attributes.state === 'public_mode' ? 'Public' : 'Private'} + ${attributes.offers_bounties ? 'Bounty' : 'VDP'} + ${attributes.open_scope ? 'Open Scope' : ''} + ${isProgramNew(attributes.started_accepting_at) ? ' New' : ''} +
+
+
Reports: ${attributes.number_of_reports_for_user}
+
Earnings: $${attributes.bounty_earned_for_user.toFixed(2)}
+
+
+
+
Since ${new Date(attributes.started_accepting_at).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
+
${attributes.currency.toUpperCase()}
+
+ + `; } function isProgramNew(startedAcceptingAt) { const threeMonthsAgo = new Date(); threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); - const startedAcceptingDate = new Date(startedAcceptingAt); - return startedAcceptingDate > threeMonthsAgo; + return new Date(startedAcceptingAt) > threeMonthsAgo; } - function displayErrorMessage(message) { - const container = document.getElementById('program_cards'); - container.innerHTML = ` -
- -
- `; - } - - // below has everything to do with card selection and import button - const container = document.getElementById('program_cards'); - const importBtn = document.getElementById('importProgramsBtn'); - const clearBtn = document.getElementById('clearSelectionsLink'); - - container.addEventListener('click', function(event) { - const card = event.target.closest('.card-selectable'); - if (card) { - toggleCardSelection(event, card); - } - }); - - function toggleCardSelection(event, card) { - if (event.target.closest('#btn-see-details') || event.target.closest('#handle-link')) { - // If it's the "See details" button, don't toggle selection, maybe we need other actions in the future here - return; - } - - card.classList.toggle('card-selected'); - updateImportButton(); - } - - function updateImportButton() { - const selectedCards = container.querySelectorAll('.card-selected'); - const count = selectedCards.length; - - if (count === 0) { - importBtn.disabled = true; - importBtn.innerHTML = ' Import Programs'; - clearBtn.style.display = 'none'; - } else { - importBtn.disabled = false; - importBtn.innerHTML = ` Import ${count} Program${count !== 1 ? 's' : ''}`; - clearBtn.style.display = 'inline'; - } - } - - function clearAllSelections() { - const selectedCards = container.querySelectorAll('.card-selected'); - selectedCards.forEach(card => card.classList.remove('card-selected')); - updateImportButton(); - } - - // clear btn listener - clearBtn.addEventListener('click', function(event) { - event.preventDefault(); - clearAllSelections(); - }); - - - // init state - updateImportButton(); - - - // we begin filtering here function initializeFilter() { - const filterSelect = document.querySelector('select[aria-label="Program type"]'); - const container = document.getElementById('program_cards'); const allCards = Array.from(container.querySelectorAll('.program-card-wrapper')); - const searchInput = document.querySelector('#search-program-box'); - const showClosedCheckbox = document.getElementById('show-closed-programs'); - - // Pre-compute card data to avoid querying the DOM on each filter/search - const cardData = allCards.map(cardWrapper => { - const card = cardWrapper.querySelector('.bbp-card'); - return { - wrapper: cardWrapper, - name: card.querySelector('h5').textContent.toLowerCase(), - offersBounties: card.dataset.offersBounties === 'true', - isPrivate: card.dataset.programState === 'private_mode', - isClosed: card.querySelector('.badge').textContent.trim() !== 'Open for Submission' - }; - }); - - let lastFilter = ''; - let lastSearch = ''; - - function filterAndSearchCards() { + const cardData = allCards.map(createCardData); + + const filterAndSearchCards = debounce(() => { const selectedFilter = filterSelect.value; const searchTerm = searchInput.value.toLowerCase().trim(); const showClosed = showClosedCheckbox.checked; - - if (selectedFilter === lastFilter && searchTerm === lastSearch && showClosed === lastShowClosed) return; - lastFilter = selectedFilter; - lastSearch = searchTerm; - lastShowClosed = showClosed; - + const visibleCards = cardData.filter(({ offersBounties, isPrivate, name, isClosed }) => { let shouldShow = true; - + switch(selectedFilter) { case 'Bounty Eligible': shouldShow = offersBounties; @@ -248,71 +147,99 @@ document.addEventListener('DOMContentLoaded', function() { shouldShow = isPrivate; break; } - - shouldShow = shouldShow && (!searchTerm || name.includes(searchTerm)); + shouldShow = shouldShow && (!searchTerm || name.includes(searchTerm)); shouldShow = shouldShow && (showClosed || !isClosed); return shouldShow; }); - - // Batch DOM updates to avoid reflows and make the transition smoother + requestAnimationFrame(() => { - cardData.forEach(({ wrapper }) => { - wrapper.style.display = 'none'; - wrapper.classList.add('filtering-hide'); - }); - - visibleCards.forEach(({ wrapper }) => { - wrapper.style.display = ''; - wrapper.classList.remove('filtering-hide'); - }); + cardData.forEach(({ wrapper }) => wrapper.style.display = 'none'); + visibleCards.forEach(({ wrapper }) => wrapper.style.display = ''); }); - } - + }, 100); + filterSelect.addEventListener('change', filterAndSearchCards); searchInput.addEventListener('input', filterAndSearchCards); showClosedCheckbox.addEventListener('change', filterAndSearchCards); - filterAndSearchCards(); // init call + filterAndSearchCards(); + } + + function createCardData(cardWrapper) { + const card = cardWrapper.querySelector('.bbp-card'); + return { + wrapper: cardWrapper, + name: card.querySelector('h5').textContent.toLowerCase(), + offersBounties: card.dataset.offersBounties === 'true', + isPrivate: card.dataset.programState === 'private_mode', + isClosed: card.querySelector('.badge').textContent.trim() !== 'Open' + }; } - // in this function, we handle the sort select function updateSortingParams() { - const sortSelect = document.getElementById('sort-select'); - let sortBy, sortOrder; - - if (sortSelect.value === 'Sort by' || !sortSelect.value) { - sortBy = 'age'; - sortOrder = 'desc'; - } else { - [sortBy, sortOrder] = sortSelect.value.split('-'); - } + const [sortBy, sortOrder] = sortSelect.value.split('-'); + const apiSortBy = { + name: 'name', + reports: 'reports', + posted: 'age' + }[sortBy] || 'age'; - let apiSortBy; - - switch (sortBy) { - case 'name': - apiSortBy = 'name'; - break; - case 'reports': - apiSortBy = 'reports'; - break; - case 'posted': - apiSortBy = 'age'; - break; - default: - apiSortBy = 'age'; - } - - return { sort_by: apiSortBy, sort_order: sortOrder }; + return { sort_by: apiSortBy, sort_order: sortOrder || 'desc' }; } - - // init call to fetch programs - const sortSelect = document.getElementById('sort-select'); + // Event listeners sortSelect.addEventListener('change', () => fetchPrograms(true)); + container.addEventListener('click', handleCardClick); + clearBtn.addEventListener('click', clearAllSelections); + + function handleCardClick(event) { + const card = event.target.closest('.card-selectable'); + if (card) { + if (event.target.closest('.btn-see-details') || event.target.closest('.handle-link')) { + // Handle "See details" button or handle link click + return; + } + toggleCardSelection(card); + } + } + + function toggleCardSelection(card) { + card.classList.toggle('card-selected'); + updateImportButton(); + } + + function updateImportButton() { + const selectedCards = container.querySelectorAll('.card-selected'); + const count = selectedCards.length; + + importBtn.disabled = count === 0; + importBtn.innerHTML = count === 0 ? ' Import Programs' : ` Import ${count} Program${count !== 1 ? 's' : ''}`; + clearBtn.style.display = count === 0 ? 'none' : 'inline'; + } + + function clearAllSelections() { + container.querySelectorAll('.card-selected').forEach(card => card.classList.remove('card-selected')); + updateImportButton(); + } + + function showLoadingIndicator(message) { + Swal.fire({ + title: message, + text: "Please wait", + allowOutsideClick: false, + allowEscapeKey: false, + showConfirmButton: false, + willOpen: () => { + Swal.showLoading(); + } + }); + } + + function hideLoadingIndicator() { + Swal.close(); + } fetchPrograms(false); - }); \ No newline at end of file