test: e2e tests voor infinite scroll — batch loading + search reset
2 nieuwe Playwright tests: - Sentinels triggeren meer items na scrollIntoView - Zoekopdracht reset paginering terug naar ≤ SEARCH_PAGE_SIZE docker/index.html synced met worker-implementatie
This commit is contained in:
parent
544d71e1ac
commit
35278053a7
2 changed files with 199 additions and 46 deletions
|
|
@ -252,6 +252,19 @@ body {
|
|||
list-style: none;
|
||||
}
|
||||
|
||||
.search-sentinel {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.food-item {
|
||||
background: var(--white);
|
||||
border-radius: var(--radius-sm);
|
||||
|
|
@ -2048,6 +2061,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
|||
<div class="results-section">
|
||||
<div class="results-count" id="resultsCount"></div>
|
||||
<ul class="food-list" id="foodList"></ul>
|
||||
<div id="searchSentinel" class="search-sentinel"></div>
|
||||
<div class="loading hidden" id="loadingState">
|
||||
<div class="loading-spinner"></div>
|
||||
<div>Voedingsmiddelen laden...</div>
|
||||
|
|
@ -2355,6 +2369,9 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
|||
let searchIndex = null; // Map<item, expanded search tokens string>
|
||||
let dataLoaded = false;
|
||||
let maaltijden = []; // [{ id, naam, items: [{item, portie}, ...], khTotaal }]
|
||||
const SEARCH_PAGE_SIZE = 50;
|
||||
let searchVisibleCount = SEARCH_PAGE_SIZE;
|
||||
let searchObserver = null;
|
||||
|
||||
// ===== Utility Functions =====
|
||||
function formatDate(d) {
|
||||
|
|
@ -2671,16 +2688,17 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
|||
// ===== Rendering =====
|
||||
function renderZoeken() {
|
||||
if (!dataLoaded) return;
|
||||
if (activeTab !== 'zoeken') return;
|
||||
|
||||
const items = getFilteredItems();
|
||||
const listEl = document.getElementById('foodList');
|
||||
const countEl = document.getElementById('resultsCount');
|
||||
const emptyEl = document.getElementById('emptyState');
|
||||
const container = document.getElementById('tabZoeken');
|
||||
const errorEl = document.getElementById('errorState');
|
||||
const sentinelEl = document.getElementById('searchSentinel');
|
||||
|
||||
// Make sure zoeken tab is visible if active
|
||||
if (activeTab !== 'zoeken') return;
|
||||
// Reset pagination on new search/filter
|
||||
searchVisibleCount = SEARCH_PAGE_SIZE;
|
||||
disconnectSearchObserver();
|
||||
|
||||
// Show results count
|
||||
countEl.textContent = items.length === 0
|
||||
|
|
@ -2691,12 +2709,33 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
|||
if (items.length === 0) {
|
||||
listEl.innerHTML = '';
|
||||
emptyEl.classList.remove('hidden');
|
||||
} else {
|
||||
sentinelEl.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
emptyEl.classList.add('hidden');
|
||||
listEl.innerHTML = items.map((item, idx) => {
|
||||
|
||||
// Render first batch
|
||||
const batch = items.slice(0, SEARCH_PAGE_SIZE);
|
||||
listEl.innerHTML = renderFoodItemsHtml(batch, 0);
|
||||
attachFoodItemHandlers(listEl);
|
||||
|
||||
// Setup infinite scroll if more items exist
|
||||
if (items.length > SEARCH_PAGE_SIZE) {
|
||||
sentinelEl.classList.remove('hidden');
|
||||
setupSearchObserver(items);
|
||||
} else {
|
||||
sentinelEl.classList.add('hidden');
|
||||
disconnectSearchObserver();
|
||||
}
|
||||
}
|
||||
|
||||
function renderFoodItemsHtml(items, startIndex) {
|
||||
return items.map((item, idx) => {
|
||||
const globalIdx = startIndex + idx;
|
||||
const kh = parseKh(item.kh);
|
||||
return `
|
||||
<li class="food-item" data-index="${voedingsmiddelen.indexOf(item)}" style="--i:${idx}" role="button" tabindex="0">
|
||||
<li class="food-item" data-index="${voedingsmiddelen.indexOf(item)}" style="--i:${globalIdx}" role="button" tabindex="0">
|
||||
<div class="food-item-left">
|
||||
<div class="food-item-name">${escapeHtml(displayNaam(item.naam))}</div>
|
||||
<div class="food-item-cat">${escapeHtml(item.cat)}</div>
|
||||
|
|
@ -2709,9 +2748,13 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
|||
</li>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Add click handlers
|
||||
function attachFoodItemHandlers(listEl) {
|
||||
listEl.querySelectorAll('.food-item').forEach(el => {
|
||||
// Avoid double handlers on re-render edge cases
|
||||
if (el._clickAttached) return;
|
||||
el._clickAttached = true;
|
||||
el.addEventListener('click', () => {
|
||||
const idx = parseInt(el.dataset.index);
|
||||
if (idx >= 0 && idx < voedingsmiddelen.length) {
|
||||
|
|
@ -2720,6 +2763,56 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
|||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setupSearchObserver(allItems) {
|
||||
disconnectSearchObserver();
|
||||
const sentinel = document.getElementById('searchSentinel');
|
||||
if (!sentinel) return;
|
||||
|
||||
searchObserver = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
appendSearchBatch(allItems);
|
||||
}
|
||||
}, { rootMargin: '200px' });
|
||||
|
||||
searchObserver.observe(sentinel);
|
||||
}
|
||||
|
||||
function appendSearchBatch(allItems) {
|
||||
const listEl = document.getElementById('foodList');
|
||||
const sentinelEl = document.getElementById('searchSentinel');
|
||||
if (!listEl || !sentinelEl) return;
|
||||
|
||||
const remaining = allItems.length - searchVisibleCount;
|
||||
if (remaining <= 0) {
|
||||
disconnectSearchObserver();
|
||||
sentinelEl.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const batchSize = Math.min(SEARCH_PAGE_SIZE, remaining);
|
||||
const batch = allItems.slice(searchVisibleCount, searchVisibleCount + batchSize);
|
||||
listEl.insertAdjacentHTML('beforeend', renderFoodItemsHtml(batch, searchVisibleCount));
|
||||
attachFoodItemHandlers(listEl);
|
||||
searchVisibleCount += batchSize;
|
||||
|
||||
// All items loaded — hide sentinel and disconnect
|
||||
if (searchVisibleCount >= allItems.length) {
|
||||
disconnectSearchObserver();
|
||||
sentinelEl.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function disconnectSearchObserver() {
|
||||
if (searchObserver) {
|
||||
searchObserver.disconnect();
|
||||
searchObserver = null;
|
||||
}
|
||||
}
|
||||
|
||||
function resetSearchPagination() {
|
||||
searchVisibleCount = SEARCH_PAGE_SIZE;
|
||||
disconnectSearchObserver();
|
||||
}
|
||||
|
||||
function renderProgressBar(totaal) {
|
||||
|
|
@ -3151,21 +3244,21 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
|||
// Store the target moment so next food tap adds to it
|
||||
window._targetMoment = momentId;
|
||||
|
||||
// Override food item click behavior temporarily
|
||||
// Use event delegation so dynamically loaded items also work
|
||||
const listEl = document.getElementById('foodList');
|
||||
listEl.querySelectorAll('.food-item').forEach(el => {
|
||||
const origClick = el.onclick;
|
||||
el.addEventListener('click', function handler(e) {
|
||||
const idx = parseInt(el.dataset.index);
|
||||
const handler = (e) => {
|
||||
const itemEl = e.target.closest('.food-item');
|
||||
if (!itemEl) return;
|
||||
const idx = parseInt(itemEl.dataset.index);
|
||||
if (idx >= 0 && idx < voedingsmiddelen.length && window._targetMoment) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
showAddToMeal(voedingsmiddelen[idx], window._targetMoment);
|
||||
window._targetMoment = null;
|
||||
el.removeEventListener('click', handler);
|
||||
listEl.removeEventListener('click', handler);
|
||||
}
|
||||
}, { once: true });
|
||||
});
|
||||
};
|
||||
listEl.addEventListener('click', handler);
|
||||
}
|
||||
|
||||
function addToMeal(item, portie, momentId) {
|
||||
|
|
@ -3546,10 +3639,13 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
|||
setTimeout(() => mainContent.classList.remove('page-transition'), 350);
|
||||
|
||||
if (tab === 'zoeken') {
|
||||
resetSearchPagination();
|
||||
renderZoeken();
|
||||
} else if (tab === 'dagboek') {
|
||||
disconnectSearchObserver();
|
||||
renderDagboek();
|
||||
} else if (tab === 'maaltijden') {
|
||||
disconnectSearchObserver();
|
||||
renderMaaltijden();
|
||||
}
|
||||
}
|
||||
|
|
@ -4599,6 +4695,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
|||
searchInput.addEventListener('input', () => {
|
||||
searchQuery = searchInput.value;
|
||||
searchClear.classList.toggle('visible', searchQuery.length > 0);
|
||||
resetSearchPagination();
|
||||
renderZoeken();
|
||||
});
|
||||
|
||||
|
|
@ -4606,6 +4703,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
|||
searchInput.value = '';
|
||||
searchQuery = '';
|
||||
searchClear.classList.remove('visible');
|
||||
resetSearchPagination();
|
||||
renderZoeken();
|
||||
searchInput.focus();
|
||||
});
|
||||
|
|
@ -4619,6 +4717,7 @@ button:active, .btn:active, .meal-card-main:active, .dagboek-item:active, .food-
|
|||
btn.classList.add('active');
|
||||
|
||||
selectedCategory = btn.dataset.cat || null;
|
||||
resetSearchPagination();
|
||||
renderZoeken();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -437,4 +437,58 @@ test.describe('Karby Eetdagboek', () => {
|
|||
await expect(page.locator('#portieEditInput')).not.toBeAttached({ timeout: 3000 });
|
||||
});
|
||||
|
||||
test('infinite scroll: only first batch rendered, sentinel triggers more', async ({ page }) => {
|
||||
await page.goto(BASE);
|
||||
await page.waitForTimeout(1500); // wait for data load
|
||||
|
||||
// Clear any search query to see all items
|
||||
const clearBtn = page.locator('#searchClear');
|
||||
if (await clearBtn.isVisible().catch(() => false)) {
|
||||
await clearBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Count initially rendered food items — should be ≤ SEARCH_PAGE_SIZE (50)
|
||||
const initialCount = await page.locator('.food-item').count();
|
||||
expect(initialCount).toBeLessThanOrEqual(50);
|
||||
expect(initialCount).toBeGreaterThan(0);
|
||||
|
||||
// Sentinel should exist (there are more than 50 items)
|
||||
await expect(page.locator('#searchSentinel')).toBeAttached({ timeout: 3000 });
|
||||
|
||||
// Scroll sentinel into view to trigger next batch
|
||||
await page.locator('#searchSentinel').scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// Now more items should be rendered
|
||||
const afterScrollCount = await page.locator('.food-item').count();
|
||||
expect(afterScrollCount).toBeGreaterThan(initialCount);
|
||||
expect(afterScrollCount).toBeLessThanOrEqual(100); // 2nd page = 100 max
|
||||
});
|
||||
|
||||
test('infinite scroll: search query resets pagination', async ({ page }) => {
|
||||
await page.goto(BASE);
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Scroll down a bit to load more
|
||||
const sentinel = page.locator('#searchSentinel');
|
||||
if (await sentinel.isVisible().catch(() => false)) {
|
||||
await sentinel.scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(800);
|
||||
}
|
||||
|
||||
const beforeSearch = await page.locator('.food-item').count();
|
||||
|
||||
// Now type a search query — should reset to ≤ 50 items
|
||||
await page.locator('.search-input').fill('aardappel');
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
const afterSearch = await page.locator('.food-item').count();
|
||||
expect(afterSearch).toBeLessThanOrEqual(50);
|
||||
// Should be fewer than before (filtered), unless we hadn't scrolled far
|
||||
if (beforeSearch > 50) {
|
||||
expect(afterSearch).toBeLessThan(beforeSearch);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue