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 =