diff --git a/package-lock.json b/package-lock.json index f874fae..90dbb64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "react-dom": "^18" }, "devDependencies": { + "@heroicons/react": "^2.1.3", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", @@ -1664,6 +1665,15 @@ } } }, + "node_modules/@heroicons/react": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.1.3.tgz", + "integrity": "sha512-fEcPfo4oN345SoqdlCDdSa4ivjaKbk0jTd+oubcgNxnNgAfzysfwWfQUr+51wigiWHQQRiZNd1Ao0M5Y3M2EGg==", + "dev": true, + "peerDependencies": { + "react": ">= 16" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", diff --git a/package.json b/package.json index bae740d..9cd3489 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "react-dom": "^18" }, "devDependencies": { + "@heroicons/react": "^2.1.3", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", diff --git a/src/app/search-provider.tsx b/src/app/search-provider.tsx index 8a1f2da..903f7a1 100644 --- a/src/app/search-provider.tsx +++ b/src/app/search-provider.tsx @@ -13,7 +13,11 @@ const FACETS = getAttribute("globus.search.facets", []); type Facet = NonNullable< Static["data"]["attributes"]["globus"]["search"]["facets"] >[0]; - +/** + * Since a `GFacet` can be expressed with or without a `name`, when + * when processing a `GFacetResult` to map to a `GFilter`, we need to + * figure out what the configured `field_name` is for the facet. + */ export function getFacetFieldNameByName(name: string) { let match = FACETS.find((facet: Facet) => facet.name === name)?.field_name; if (!match) { @@ -25,6 +29,8 @@ export function getFacetFieldNameByName(name: string) { } export type SearchState = { + limit: number; + offset: number; facetFilters: Record; }; @@ -38,23 +44,45 @@ type SearchAction = } | { type: "reset_facet_filters"; + } + | { + type: "set_limit"; + payload: number; + } + | { + type: "set_offset"; + payload: number; }; function searchReducer(state: SearchState, action: SearchAction) { switch (action.type) { + case "set_limit": + return { ...state, limit: action.payload, offset: 0 }; + case "set_offset": + return { ...state, offset: action.payload }; case "set_facet_filter": { const fieldName = getFacetFieldNameByName(action.payload.facet.name); - let filter; - if (action.payload.value.length !== 0) { - filter = { - type: "match_any", - field_name: fieldName, - values: action.payload.value, - }; + const { facetFilters } = state; + /** + * If the incoming value is empty, remove the filter from the state. + */ + if (action.payload.value.length === 0 && facetFilters[fieldName]) { + const { [fieldName]: _, ...rest } = facetFilters; + return { ...state, facetFilters: rest }; } + /** + * Otherwise, update the filter in the state to the provided value. + */ return { ...state, - facetFilters: { ...state.facetFilters, [fieldName]: filter }, + facetFilters: { + ...facetFilters, + [fieldName]: { + type: "match_any", + field_name: fieldName, + values: action.payload.value, + }, + }, }; } case "reset_facet_filters": @@ -65,6 +93,8 @@ function searchReducer(state: SearchState, action: SearchAction) { } const initialState: SearchState = { + limit: 25, + offset: 0, facetFilters: {}, }; diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx new file mode 100644 index 0000000..ddf02b5 --- /dev/null +++ b/src/components/Pagination.tsx @@ -0,0 +1,98 @@ +import React from "react"; +import { + Box, + Button, + ButtonGroup, + Flex, + HStack, + Icon, + Select, + Spacer, + Text, +} from "@chakra-ui/react"; +import { + ChevronRightIcon, + ChevronLeftIcon, + ChevronDoubleLeftIcon, +} from "@heroicons/react/24/outline"; + +import type { GSearchResult } from "@/globus/search"; +import { useSearch, useSearchDispatch } from "@/app/search-provider"; + +export const Pagination = ({ result }: { result?: GSearchResult }) => { + const search = useSearch(); + const dispatch = useSearchDispatch(); + + if (!result) { + return null; + } + + return ( + + + + Results per page: + + + + + + + + {search.offset > 0 ? search.offset : 1}-{search.offset + search.limit}{" "} + of {result.total} + + + + + + + + + + ); +}; diff --git a/src/components/Result.tsx b/src/components/Result.tsx index 329c4a8..1dd3a3b 100644 --- a/src/components/Result.tsx +++ b/src/components/Result.tsx @@ -20,7 +20,6 @@ import { get } from "lodash"; import { getAttribute, getAttributeFrom } from "../../static"; import { Error } from "./Error"; - import { isGError, type GError, type GMetaResult } from "@/globus/search"; type FieldDefinition = diff --git a/src/components/Search.tsx b/src/components/Search.tsx index 60e27a5..2884878 100644 --- a/src/components/Search.tsx +++ b/src/components/Search.tsx @@ -1,14 +1,11 @@ "use client"; import { - HStack, InputGroup, Input, Box, - Stat, - StatLabel, - StatNumber, VStack, InputLeftElement, + useToast, } from "@chakra-ui/react"; import { SearchIcon } from "@chakra-ui/icons"; import { throttle, debounce } from "lodash"; @@ -16,12 +13,12 @@ import React, { useEffect, useState } from "react"; import { search as gsearch } from "@globus/sdk"; import { GSearchResult, isGError } from "@/globus/search"; - import SearchFacets from "./SearchFacets"; import { SearchState, useSearch } from "../app/search-provider"; import { getAttribute } from "../../static"; import ResultListing from "./ResultListing"; import { Error } from "./Error"; +import { Pagination } from "./Pagination"; const SEARCH_INDEX = getAttribute("globus.search.index"); const FACETS = getAttribute("globus.search.facets", []); @@ -30,30 +27,43 @@ function getSearchPayload(query: string, state: SearchState) { return { q: query, facets: FACETS, + offset: state.offset, + limit: state.limit, filters: Object.values(state.facetFilters).filter((f) => Boolean(f)), }; } export function Search() { const search = useSearch(); + const toast = useToast({ + position: "bottom-right", + }); + const toastId = "search-status"; const [query, setQuery] = useState(""); const [result, setResult] = useState(); useEffect(() => { const fetchResults = throttle(async () => { + const id = toast({ + id: toastId, + title: "Fetching search results...", + status: "loading", + duration: null, + }); const response = await gsearch.query.post(SEARCH_INDEX, { payload: getSearchPayload(query, search), }); const results = await response.json(); setResult(results); + toast.close(id); }, 1000); fetchResults(); }, [query, search]); return ( <> -
- + + @@ -64,28 +74,25 @@ export function Search() { onChange={debounce((e) => setQuery(e.target.value), 300)} /> - - - - - {isGError(result) && } - {result && result.total > 0 && ( - <> - - Results - {result.total} datasets found - - - {result.gmeta?.map((gmeta, i) => ( - - ))} - - - )} - {result && result.total === 0 && No datasets found.} - + + + + + + + {isGError(result) && } + {result && result.total > 0 && ( + <> + + {result.gmeta?.map((gmeta, i) => ( + + ))} + + + )} + {result && result.total === 0 && No datasets found.} - + ); } diff --git a/src/components/SearchFacets.tsx b/src/components/SearchFacets.tsx index 3e9b980..aee62ac 100644 --- a/src/components/SearchFacets.tsx +++ b/src/components/SearchFacets.tsx @@ -21,8 +21,13 @@ import { useMenuItem, InputProps, UseMenuItemProps, + Icon, } from "@chakra-ui/react"; -import { PlusSquareIcon, SearchIcon, SmallCloseIcon } from "@chakra-ui/icons"; +import { + PlusCircleIcon, + MagnifyingGlassIcon, + XMarkIcon, +} from "@heroicons/react/24/outline"; import { getAttribute } from "../../static"; import { @@ -41,7 +46,7 @@ const BucketSearch = (props: UseMenuItemProps & InputProps) => { - + } + leftIcon={} variant="ghost" border="1px dashed" borderColor="gray.400" @@ -180,7 +185,7 @@ export default function SearchFacets({ size="sm" onClick={reset} variant="ghost" - rightIcon={} + rightIcon={} > Clear All Filters diff --git a/src/globus/search.ts b/src/globus/search.ts index c0eed2a..1d70cba 100644 --- a/src/globus/search.ts +++ b/src/globus/search.ts @@ -31,6 +31,7 @@ export type GFacetResult = { export type GSearchResult = { "@datatype": "GSearchResult"; "@version": string; + count: number; offset: number; total: number; has_next_page: boolean;