From bf64cd743f8dfe9feb14fa79c7fe1987f3f3bbc7 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 7 Oct 2024 15:59:50 -0400 Subject: [PATCH] [ALS-7492] Fix loading more values on the add filter page (#242) --- .../components/OptionsSelectionList.svelte | 62 ++++--- src/lib/components/explorer/AddFilter.svelte | 22 +-- .../explorer/genome-filter/gene/Genes.svelte | 54 +++--- .../component/optional-selection-list/test.ts | 154 +++++++++++++++++- tests/mock-data.ts | 84 +++++++++- 5 files changed, 317 insertions(+), 59 deletions(-) diff --git a/src/lib/components/OptionsSelectionList.svelte b/src/lib/components/OptionsSelectionList.svelte index f30d2bee..21989b09 100644 --- a/src/lib/components/OptionsSelectionList.svelte +++ b/src/lib/components/OptionsSelectionList.svelte @@ -5,38 +5,64 @@ let searchInput: string = ''; export let unselectedOptions: string[] = []; export let selectedOptions: string[] = []; + export let selectedOptionEndLocation = 20; export let currentlyLoading: boolean = false; - export let showClearAll: boolean = true; export let showSelectAll: boolean = true; + export let allOptionsLoaded: boolean = false; - export let allOptions: string[] = []; + export let allOptions: string[] | undefined = undefined; + let currentlyLoadingSelected: boolean = false; let unselectedOptionsContainer: HTMLElement; let selectedOptionsContainer: HTMLElement; - let selectedOptionEndLocation = 20; + let allSelectedOptionsLoaded: boolean = false; const dispatch = createEventDispatcher<{ scroll: { search: string } }>(); - function shouldLoadMore(element: HTMLElement) { + $: infiniteScroll = allOptions === undefined; + + $: totalAvailableOptions = infiniteScroll + ? Infinity + : (allOptions?.length || 0) - selectedOptions.length; + + $: allUnselectedOptionsLoaded = infiniteScroll + ? allOptionsLoaded + : unselectedOptions.length >= totalAvailableOptions; + + $: allSelectedOptionsLoaded = infiniteScroll + ? allSelectedOptionsLoaded + : displayedSelectedOptions.length >= selectedOptions.length; + + $: displayedSelectedOptions = selectedOptions.slice(0, selectedOptionEndLocation); + + function shouldLoadMore(element: HTMLElement, allLoaded: boolean) { const scrollTop = element.scrollTop; const containerHeight = element.clientHeight; const contentHeight = element.scrollHeight; const scrollBuffer = 30; - const hasLoadedAll = !unselectedOptions || unselectedOptions.length === 0; - return ( - hasLoadedAll || - (!hasLoadedAll && contentHeight - (scrollTop + containerHeight) <= scrollBuffer) - ); + return !allLoaded && contentHeight - (scrollTop + containerHeight) <= scrollBuffer; } function handleScroll() { if (!unselectedOptionsContainer) return; - if (!currentlyLoading && shouldLoadMore(unselectedOptionsContainer)) { + if ( + !currentlyLoading && + shouldLoadMore(unselectedOptionsContainer, allUnselectedOptionsLoaded) + ) { dispatch('scroll', { search: searchInput }); } } + function loadMoreSelectedOptions() { + if (!selectedOptionsContainer) return; + currentlyLoadingSelected = true; + if (shouldLoadMore(selectedOptionsContainer, allSelectedOptionsLoaded)) { + selectedOptionEndLocation = selectedOptionEndLocation + 20; + } + currentlyLoadingSelected = false; + } + function onSearch() { dispatch('scroll', { search: searchInput }); unselectedOptionsContainer.scrollTop = 0; @@ -59,7 +85,7 @@ } function selectAllOptions() { - if (allOptions.length !== 0) { + if (allOptions && allOptions?.length !== 0) { selectedOptions = allOptions; unselectedOptions = []; selectedOptionEndLocation = 20; @@ -70,18 +96,9 @@ } } - function loadMoreSelectedOptions() { - if (!selectedOptionsContainer) return; - if (shouldLoadMore(selectedOptionsContainer)) { - selectedOptionEndLocation = selectedOptionEndLocation + 20; - } - } - function getID(option: string) { return option.replaceAll(' ', '-').toLowerCase(); } - - $: displayedSelectedOptions = selectedOptions.slice(0, selectedOptionEndLocation);
@@ -169,6 +186,11 @@ {option} {/each} + {#if currentlyLoadingSelected} +
+ +
+ {/if}
diff --git a/src/lib/components/explorer/AddFilter.svelte b/src/lib/components/explorer/AddFilter.svelte index 223c99aa..949dbb42 100644 --- a/src/lib/components/explorer/AddFilter.svelte +++ b/src/lib/components/explorer/AddFilter.svelte @@ -25,7 +25,7 @@ let pageSize = 20; let unselectedOptions: string[] = []; let selectedOptions: string[] = []; - let startLocation = 0; + let startLocation = pageSize; let lastSearchTerm = ''; let loading = false; let display: string; @@ -110,30 +110,30 @@ loading = true; try { - let nextOptions = (data?.values || []).filter((option) => !selectedOptions.includes(option)); - let endLocation = Math.min(startLocation + pageSize, totalOptions); + let allOptions = data?.values || []; if (search !== lastSearchTerm || !lastSearchTerm.includes(search)) { // new search startLocation = 0; - endLocation = startLocation + pageSize; unselectedOptions = []; lastSearchTerm = search; } - if (search) { - nextOptions = nextOptions.filter((value) => - value.toLowerCase().includes(search.toLowerCase()), - ); - } + let filteredOptions = allOptions.filter( + (option) => + !selectedOptions.includes(option) && + (!search || option.toLowerCase().includes(search.toLowerCase())), + ); + + const endLocation = Math.min(startLocation + pageSize, filteredOptions.length); + const nextOptions = filteredOptions.slice(startLocation, endLocation); - nextOptions = nextOptions.slice(startLocation, endLocation); unselectedOptions = [...unselectedOptions, ...nextOptions]; startLocation = endLocation; } catch (error) { console.error(error); toastStore.trigger({ - message: 'An error occured while loading more options. Please try again later.', + message: 'An error occurred while loading more options. Please try again later.', background: 'variant-filled-error', }); } diff --git a/src/lib/components/explorer/genome-filter/gene/Genes.svelte b/src/lib/components/explorer/genome-filter/gene/Genes.svelte index 1d6c9416..cd969434 100644 --- a/src/lib/components/explorer/genome-filter/gene/Genes.svelte +++ b/src/lib/components/explorer/genome-filter/gene/Genes.svelte @@ -17,20 +17,19 @@ : allGenes.filter((gene) => !$selectedGenes.includes(gene)); let lastFilter = ''; - let pageSize = 50; + let pageSize = 20; let currentPage = 0; let totalPages = 1; let loading = false; + let allOptionsLoaded = false; // given a search term, return new values to be added to displayed options async function getGeneValues(search: string = '') { const newSearch = lastFilter !== search; - - if (!newSearch && currentPage >= totalPages) return; - + if (!newSearch && (currentPage >= totalPages || allOptionsLoaded)) return; loading = true; - await api - .get( + try { + const response = await api.get( `picsure/search/${resources.hpds}/values/?` + new URLSearchParams({ genomicConceptPath: 'Gene_with_variant', @@ -39,27 +38,29 @@ size: pageSize.toString(), }), { 'content-type': 'application/json' }, - ) - .then((response) => { - if (response?.error) { - return Promise.reject(response.error); - } - return response; - }) - .then((response) => { - allGenes = newSearch ? response.results : [...allGenes, ...response.results]; - totalPages = Math.ceil(response.total / pageSize); - currentPage = response.page; - lastFilter = search; - }) - .catch((error) => { - console.error(error); - toastStore.trigger({ - message: 'An error occured while loading genes list.', - background: 'variant-filled-error', - }); + ); + + if (response?.error) { + throw response.error; + } + + const newGenes = response.results; + allGenes = newSearch ? newGenes : [...allGenes, ...newGenes]; + totalPages = Math.ceil(response.total / pageSize); + currentPage = response.page; + lastFilter = search; + + // Check if we've loaded all options + allOptionsLoaded = newGenes.length < pageSize; + } catch (error) { + console.error(error); + toastStore.trigger({ + message: 'An error occurred while loading genes list.', + background: 'variant-filled-error', }); - loading = false; + } finally { + loading = false; + } } onMount(async () => { @@ -74,6 +75,7 @@ bind:unselectedOptions={unselectedGenes} bind:selectedOptions={$selectedGenes} bind:currentlyLoading={loading} + {allOptionsLoaded} on:scroll={(event) => getGeneValues(event.detail.search)} /> diff --git a/tests/lib/component/optional-selection-list/test.ts b/tests/lib/component/optional-selection-list/test.ts index 65e54917..24bf45dd 100644 --- a/tests/lib/component/optional-selection-list/test.ts +++ b/tests/lib/component/optional-selection-list/test.ts @@ -1,13 +1,17 @@ import { expect, type Route } from '@playwright/test'; -import { test } from '../../../custom-context'; +import { test, mockApiSuccess } from '../../../custom-context'; import { conceptsDetailPath, detailResponseCat, detailResponseCat2, searchResults as mockData, searchResultPath, + geneValues, + geneValuesPage2, } from '../../../mock-data'; +const HPDS = process.env.VITE_RESOURCE_HPDS; + test.describe('OptionaSelectionList', () => { // TODO: Some feartures will be hidden in the future. Cannot use nth. // TODO: Test infinite scroll @@ -202,6 +206,154 @@ test.describe('OptionaSelectionList', () => { await expect(selectedOptionContainer).toBeEmpty(); await expect(optionContainer.locator(option)).toBeVisible(); }); + test('Loads next values when scrolling', async ({ page }) => { + // Given + const mockDataWithManyOptions = { + ...detailResponseCat2, + values: Array.from({ length: 100 }, (_, i) => `Option ${i + 1}`), + }; + await page.route(searchResultPath, async (route: Route) => route.fulfill({ json: mockData })); + await page.route(`${conceptsDetailPath}${detailResponseCat2.dataset}`, async (route: Route) => + route.fulfill({ json: mockDataWithManyOptions }), + ); + await page.route('*/**/picsure/query/sync', async (route: Route) => + route.fulfill({ body: '9999' }), + ); + await page.goto('/explorer?search=somedata'); + + // When + await clickNthFilterIcon(page); + const component = page.getByTestId('optional-selection-list'); + const optionContainer = component.locator('#options-container'); + + // Then + await expect(optionContainer).toBeVisible(); + + // Check initial load + let visibleOptions = await optionContainer.getByRole('listitem').all(); + expect(visibleOptions.length).toBeLessThan(41); + + // Scroll to bottom + await optionContainer.evaluate((node) => node.scrollTo(0, node.scrollHeight)); + + // Wait for more options to load + await page.waitForTimeout(1000); // Adjust timeout as needed + + // Check if more options have loaded + visibleOptions = await optionContainer.getByRole('listitem').all(); + expect(visibleOptions.length).toBeGreaterThan(20); // Assuming initial page size is 20 + expect(visibleOptions.length).toBeLessThan(100); // Ensure not all options are loaded at once + + // Verify last visible option + const lastVisibleOption = visibleOptions[visibleOptions.length - 1]; + await expect(lastVisibleOption).toBeVisible(); + await expect(lastVisibleOption).toHaveText(/Option \d+/); + }); + test('Loads next values when scrolling when infinite scroll is enabled', async ({ page }) => { + // Given + const mockDataWithManyOptions = { + ...detailResponseCat2, + values: Array.from({ length: 100 }, (_, i) => `Option ${i + 1}`), + }; + await page.route(searchResultPath, async (route: Route) => route.fulfill({ json: mockData })); + await page.route(`${conceptsDetailPath}${detailResponseCat2.dataset}`, async (route: Route) => + route.fulfill({ json: mockDataWithManyOptions }), + ); + await page.route('*/**/picsure/query/sync', async (route: Route) => + route.fulfill({ body: '9999' }), + ); + await mockApiSuccess( + page, + `*/**/picsure/search/${HPDS}/values/?genomicConceptPath=Gene_with_variant&query=&page=1&size=20`, + { + ...geneValues, + }, + ); + await page.goto('/explorer'); + + // When + await page.getByTestId('genomic-filter-btn').click(); + await page.getByTestId('gene-variant-option').click(); + const component = page.getByTestId('optional-selection-list'); + const optionContainer = component.locator('#options-container'); + + // Then + await expect(optionContainer).toBeVisible(); + + // Check initial load + let visibleOptions = await optionContainer.getByRole('listitem').all(); + expect(visibleOptions.length).toBeLessThan(21); + + await mockApiSuccess( + page, + `*/**/picsure/search/${HPDS}/values/?genomicConceptPath=Gene_with_variant&query=&page=2&size=20`, + { + ...geneValuesPage2, + }, + ); + + // Scroll to bottom + await optionContainer.evaluate((node) => node.scrollTo(0, node.scrollHeight + 30)); + + // Wait for more options to load + await page.waitForTimeout(1000); // Adjust timeout as needed + + // Check if more options have loaded + visibleOptions = await optionContainer.getByRole('listitem').all(); + expect(visibleOptions.length).toBeGreaterThan(20); // Assuming initial page size is 20 + expect(visibleOptions.length).toBeLessThan(100); // Ensure not all options are loaded at once + + // Verify last visible option + const lastVisibleOption = visibleOptions[visibleOptions.length - 1]; + await expect(lastVisibleOption).toBeVisible(); + }); + test('Loads next selected values when scrolling', async ({ page }) => { + // Given + const mockDataWithManyOptions = { + ...detailResponseCat2, + values: Array.from({ length: 100 }, (_, i) => `Option ${i + 1}`), + }; + await page.route(searchResultPath, async (route: Route) => route.fulfill({ json: mockData })); + await page.route(`${conceptsDetailPath}${detailResponseCat2.dataset}`, async (route: Route) => + route.fulfill({ json: mockDataWithManyOptions }), + ); + await page.route('*/**/picsure/query/sync', async (route: Route) => + route.fulfill({ body: '9999' }), + ); + await page.goto('/explorer?search=somedata'); + + // When + await clickNthFilterIcon(page); + const component = page.getByTestId('optional-selection-list'); + + // Select all options + await component.locator('#select-all').click(); + + const selectedOptionsContainer = component.locator('#selected-options-container'); + + // Then + await expect(selectedOptionsContainer).toBeVisible(); + + // Check initial load of selected options + let visibleSelectedOptions = await selectedOptionsContainer.getByRole('listitem').all(); + expect(visibleSelectedOptions.length).toBe(20); // Assuming initial page size is 20 + + // Scroll to bottom of selected options + await selectedOptionsContainer.evaluate((node) => node.scrollTo(0, node.scrollHeight)); + + // Wait for more options to load + await page.waitForTimeout(1000); // Adjust timeout as needed + + // Check if more selected options have loaded + visibleSelectedOptions = await selectedOptionsContainer.getByRole('listitem').all(); + expect(visibleSelectedOptions.length).toBeGreaterThan(20); + expect(visibleSelectedOptions.length).toBeLessThan(50); // Ensure not all options are loaded at once + + // Verify last visible selected option + const lastVisibleSelectedOption = visibleSelectedOptions[visibleSelectedOptions.length - 1]; + await expect(lastVisibleSelectedOption).toBeVisible(); + await expect(lastVisibleSelectedOption).toHaveText(/Option \d+/); + }); }); /* eslint-disable @typescript-eslint/no-explicit-any */ diff --git a/tests/mock-data.ts b/tests/mock-data.ts index 683e1e01..edb8465f 100644 --- a/tests/mock-data.ts +++ b/tests/mock-data.ts @@ -899,9 +899,91 @@ export const geneValues = { 'A2ML1-AS1', 'A2MP1', 'A3GALT2', + 'A4GALT', + 'A4GNT', + 'A549', + 'A630', + 'A631', + 'A632', + 'A633', + 'A634', + 'A635', + 'A636', ], page: 1, - total: 20, + total: 60, +}; + +export const geneValuesPage2 = { + results: [ + 'A637', + 'A638', + 'A639', + 'A640', + 'A641', + 'A642', + 'CHD2', + 'CHD7', + 'CHD8', + 'CHD9', + 'CHD10', + 'CHD11', + 'CHD12', + 'CHD13', + 'BRCA1', + 'BRCA2', + 'BRCA3', + 'BRCA4', + 'BRCA5', + 'BRCA6', + 'BRCA7', + 'BRCA8', + 'BRCA9', + 'BRCA10', + 'BRCA11', + 'BRCA12', + 'BRCA13', + 'BRCA14', + 'BRCA15', + 'BRCA16', + 'BRCA17', + ], + page: 2, + total: 60, +}; + +export const geneValuesPage3 = { + results: [ + 'CHD18', + 'CHD19', + 'CHD20', + 'CHD21', + 'CHD22', + 'CHD23', + 'CHD24', + 'CHD25', + 'CHD26', + 'CHD27', + 'CHD28', + 'CHD29', + 'CHD30', + 'CHD31', + 'CHD32', + 'CHD33', + 'CHD34', + 'CHD35', + 'D10S1248', + 'D10S1249', + 'D10S1250', + 'D10S1251', + 'D10S1252', + 'D10S1253', + 'D10S1254', + 'D10S1255', + 'D10S1256', + ], + page: 3, + total: 60, }; const tsvHeader =