diff --git a/src/api/endpoints/index.ts b/src/api/endpoints/index.ts index 9c66e7c6..6c07e0b8 100644 --- a/src/api/endpoints/index.ts +++ b/src/api/endpoints/index.ts @@ -156,35 +156,64 @@ const fetchData = async (url, method = "GET", data: object | null = null) => { } }; -export const elasticSearch = async (query) => { +export const elasticSearch = async ( + query: string, + size?: number, + from: number = 0 +) => { const url = API_CONFIG.BASE_SCICRUNCH_URL + API_CONFIG.SCICRUNCH_KEY; + + let total = size; + + if (!size) { + try { + const initialResponse = await fetchData(url, "POST", { + size: 1, + from: 0, + query: buildQuery(query), + }); + + total = initialResponse?.hits?.total ?? 0; + } catch (error) { + console.error("Failed to fetch total count from Elasticsearch:", error); + return { results: [], total: 0 }; + } + } + try { - const result = await fetchData(url, "POST", { - "size": 20, - "from": 0, - "query": { - "bool": { - "must": [ - { - "query_string": { - "fields": [ - "*" - ], - "query": query, - "type": "cross_fields", - "default_operator": "and", - "lenient": "true" - } - } - ] - } - } + const fullResponse = await fetchData(url, "POST", { + size: total, + from, + query: buildQuery(query), }); - return elasticSearhParser(result?.hits?.hits) + + return { + results: elasticSearhParser(fullResponse?.hits?.hits), + total, + }; } catch (error) { - console.error("ElasticSearch Query Failed:", error); + console.error("Error when performing elastic search", error); + return { results: [], total: 0 }; } -} +}; + +const buildQuery = (query: string) => ({ + "bool": { + "must": [ + { + "query_string": { + "fields": [ + "*" + ], + "query": query, + "type": "cross_fields", + "default_operator": "and", + "lenient": "true" + } + } + ] + } +}); export const searchAll = async (term, filters = {}) => { const { searchAll } = useMockApi(); diff --git a/src/components/Header/Search.jsx b/src/components/Header/Search.jsx index 87dd9ba5..24efec80 100644 --- a/src/components/Header/Search.jsx +++ b/src/components/Header/Search.jsx @@ -157,10 +157,10 @@ const Search = () => { // eslint-disable-next-line react-hooks/exhaustive-deps const fetchTerms = useCallback(debounce(async (searchTerm) => { - const data = await elasticSearch(searchTerm); - const dataTerms = data?.results?.filter(result => result.type === SEARCH_TYPES.TERM); - const dataOrganizations = data?.results?.filter(result => result.type === SEARCH_TYPES.ORGANIZATION); - const dataOntologies = data?.results?.filter(result => result.type === SEARCH_TYPES.ONTOLOGY); + const data = await elasticSearch(searchTerm, 20, 0); + const dataTerms = data?.results.results?.filter(result => result.type === SEARCH_TYPES.TERM); + const dataOrganizations = data?.results.results?.filter(result => result.type === SEARCH_TYPES.ORGANIZATION); + const dataOntologies = data?.results.results?.filter(result => result.type === SEARCH_TYPES.ONTOLOGY); setTerms(dataTerms); setOrganizations(dataOrganizations); setOntologies(dataOntologies); diff --git a/src/components/SearchResults/SearchResultsBox.jsx b/src/components/SearchResults/SearchResultsBox.jsx index 7017a149..660c9a6c 100644 --- a/src/components/SearchResults/SearchResultsBox.jsx +++ b/src/components/SearchResults/SearchResultsBox.jsx @@ -1,12 +1,13 @@ -import React from 'react'; +import { useState, useEffect, useMemo } from 'react'; import ListView from './ListView'; import PropTypes from 'prop-types'; import { TableChartIcon, ListIcon } from '../../Icons'; import OntologySearch from '../SingleTermView/OntologySearch'; -import CustomSingleSelect from "../common/CustomSingleSelect"; +import CustomSingleSelect from '../common/CustomSingleSelect'; import { Box, Typography, Grid, ButtonGroup, Button, Stack, Divider } from '@mui/material'; - +import CustomPagination from '../common/CustomPagination'; import { vars } from '../../theme/variables'; + const { gray50, gray200, gray300, gray600 } = vars; const CustomViewButton = ({ view, listView, onClick, icon }) => ( @@ -29,25 +30,103 @@ const CustomViewButton = ({ view, listView, onClick, icon }) => ( ); -const SearchResultsBox = ({ searchResults, searchTerm, loading }) => { - const [numberOfVisiblePages, setNumberOfVisiblePages] = React.useState(20); - const [listView, setListView] = React.useState('list'); +const getPaginationSettings = (totalItems) => { + const largeDatasetOptions = [20, 50, 100, 200]; + const smallDatasetOptions = [10, 20, 50, 100]; + + const options = totalItems >= 200 + ? largeDatasetOptions.filter(opt => opt <= totalItems) + : smallDatasetOptions.filter(opt => opt <= totalItems); + + if (options.length === 0) { + return { + options: [totalItems], + defaultSize: totalItems + }; + } + + let defaultSize; + if (totalItems >= 1000) defaultSize = 100; + else if (totalItems >= 500) defaultSize = 100; + else if (totalItems >= 200) defaultSize = 50; + else if (totalItems >= 100) defaultSize = 50; + else if (totalItems >= 50) defaultSize = 20; + else defaultSize = 10; + + if (!options.includes(defaultSize)) { + defaultSize = options[Math.floor(options.length / 2)]; + } + + return { options, defaultSize }; +}; + +const SearchResultsBox = ({ + allResults, + pageResults, + searchTerm, + loading, + totalItems, + fetchPage, + hasActiveFilters +}) => { + const { options, defaultSize } = getPaginationSettings(totalItems); + const [listView, setListView] = useState('list'); + const [page, setPage] = useState(1); + const [itemsPerPage, setItemsPerPage] = useState(defaultSize); + + useEffect(() => { + if (!hasActiveFilters) { + const from = (page - 1) * itemsPerPage; + const remainingItems = totalItems - from; + const size = Math.min(itemsPerPage, remainingItems); - const handleNumberOfPagesChange = (v) => { - setNumberOfVisiblePages(v); + if (size > 0) { + fetchPage(from, size); + } + } + }, [page, itemsPerPage, totalItems, fetchPage, hasActiveFilters]); + + useEffect(() => { + const { defaultSize: newDefault } = getPaginationSettings(totalItems); + setItemsPerPage(newDefault); + setPage(1); + }, [totalItems]); + + const paginatedResults = useMemo(() => { + if (!hasActiveFilters) return pageResults; + + const startIndex = (page - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + return pageResults.slice(startIndex, endIndex); + }, [pageResults, page, itemsPerPage, hasActiveFilters]); + + const handlePageChange = (_, newPage) => { + setPage(newPage); + }; + + const handleItemsPerPageChange = (value) => { + const newItemsPerPage = Number(value); + setItemsPerPage(newItemsPerPage); + setPage(1) }; return ( - {searchResults?.length} results for {searchTerm} search + + {allResults.length} results for {searchTerm} search + Show on page: - + { + {listView === 'list' ? ( - + ) : (

table

)} + +
); }; @@ -89,9 +176,13 @@ CustomViewButton.propTypes = { }; SearchResultsBox.propTypes = { - searchResults: PropTypes.object, + allResults: PropTypes.object, + pageResults: PropTypes.object, searchTerm: PropTypes.string, - loading: PropTypes.bool + loading: PropTypes.bool, + totalItems: PropTypes.number, + fetchPage: PropTypes.func, + hasActiveFilters: PropTypes.bool }; export default SearchResultsBox; diff --git a/src/components/SearchResults/index.jsx b/src/components/SearchResults/index.jsx index dcb86057..604b762e 100644 --- a/src/components/SearchResults/index.jsx +++ b/src/components/SearchResults/index.jsx @@ -1,58 +1,89 @@ -import { debounce } from 'lodash'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import PropTypes from 'prop-types'; +import { debounce } from 'lodash'; import { useQuery } from '../../helpers'; import SearchResultsBox from './SearchResultsBox'; -import { useEffect, useState, useCallback } from 'react'; import FiltersSidebar from '../Sidebar/FiltersSidebar'; -import { searchAll, elasticSearch } from '../../api/endpoints'; - +import { elasticSearch } from '../../api/endpoints'; const SearchResults = () => { const [loading, setLoading] = useState(true); const [filters, setFilters] = useState([]); const [checkedLabels, setCheckedLabels] = useState({}); - const [searchResults, setSearchResults] = useState([]); + const [allResults, setAllResults] = useState([]); + const [pageResults, setPageResults] = useState([]); + const [totalItems, setTotalItems] = useState(0); const query = useQuery(); - const searchTerm = query.get('searchTerm'); + const hasActiveFilters = useMemo(() => { + return Object.values(checkedLabels).some(category => + category && Object.values(category).some(Boolean) + ); + }, [checkedLabels]); + + useEffect(() => { + const loadAllResults = async () => { + setLoading(true); + try { + const data = await elasticSearch(searchTerm); + setFilters(data.results.filters || []); + setAllResults(data?.results.results || []); + setTotalItems(data?.total || 0); + setPageResults([]); + } catch (error) { + console.error('Search error:', error); + } finally { + setLoading(false); + } + }; + loadAllResults(); + }, [searchTerm]); + + const loadPageData = useCallback(async (from, size) => { + setLoading(true); + try { + const data = await elasticSearch(searchTerm, size, from); + const results = data?.results.results || []; + + const filtered = hasActiveFilters ? filterResults(results, checkedLabels) : results; + + setPageResults(filtered); + } catch (error) { + console.error('Pagination error:', error); + } finally { + setLoading(false); + } + }, [searchTerm, checkedLabels, hasActiveFilters]); + + const fetchPage = useMemo(() => debounce(loadPageData, 500), [loadPageData]); + const handleCheckboxChange = (category, label) => { - setCheckedLabels((prev) => ({ + setCheckedLabels(prev => ({ ...prev, [category]: { ...prev[category], [label]: !prev[category]?.[label] } })); - }; - - // eslint-disable-next-line react-hooks/exhaustive-deps - const fetchTerms = useCallback(debounce(async (searchTerm) => { - const data = await elasticSearch(searchTerm); - setFilters(data.filters) - setSearchResults(data?.results || []); - setLoading(false) - }, 500), [searchAll]); - - useEffect(() => { - fetchTerms(searchTerm); - }, [searchTerm, fetchTerms]); + setPageResults([]); + }; const filterResults = (results, checkedLabels) => { return results.filter(item => { for (let category in checkedLabels) { if (!checkedLabels[category]) continue; // Skip empty categories - + const selectedLabels = Object.entries(checkedLabels[category]) .filter(([, isChecked]) => isChecked) .map(([label]) => label); - + if (selectedLabels.length === 0) continue; - + const categoryLower = category.toLowerCase(); const itemValue = item[categoryLower] || item[categoryLower === 'type' ? 'Type' : categoryLower]; - + if (!selectedLabels.includes(itemValue)) { return false; } @@ -61,12 +92,27 @@ const SearchResults = () => { }); }; - const filteredResults = filterResults(searchResults || [], checkedLabels); + const displayedResults = hasActiveFilters + ? filterResults(allResults, checkedLabels) + : pageResults; return ( <> - - + + ); }; diff --git a/src/components/SingleTermView/OntologySearch.jsx b/src/components/SingleTermView/OntologySearch.jsx index 93b55b1d..8a576566 100644 --- a/src/components/SingleTermView/OntologySearch.jsx +++ b/src/components/SingleTermView/OntologySearch.jsx @@ -26,7 +26,7 @@ const OPTIONS = [ const styles = { autocomplete: (fullWidth, selectedValue, openList) => ({ - width: fullWidth ? '100%' : '21.75rem', + width: fullWidth ? '100%' : 'auto', '& .MuiOutlinedInput-root': { minWidth: fullWidth ? '100%' : (selectedValue ? '21.75rem' : '11.75rem'), width: fullWidth ? '100%' : 'fit-content', diff --git a/src/components/term_activity/TermActivity.jsx b/src/components/term_activity/TermActivity.jsx index 1158c057..0deb3e75 100644 --- a/src/components/term_activity/TermActivity.jsx +++ b/src/components/term_activity/TermActivity.jsx @@ -108,7 +108,7 @@ const TermActivity = () => { try { setLoading(true); const data = await elasticSearch(""); - setRows(data?.results); + setRows(data?.results.results); } catch (err) { setError(err); } finally {