Skip to content

Commit

Permalink
feat: adds basic pagination and improved loading state (#37)
Browse files Browse the repository at this point in the history
  • Loading branch information
jbottigliero authored Apr 17, 2024
1 parent 063a357 commit ef577e1
Show file tree
Hide file tree
Showing 8 changed files with 193 additions and 42 deletions.
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"react-dom": "^18"
},
"devDependencies": {
"@heroicons/react": "^2.1.3",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
Expand Down
48 changes: 39 additions & 9 deletions src/app/search-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -25,6 +29,8 @@ export function getFacetFieldNameByName(name: string) {
}

export type SearchState = {
limit: number;
offset: number;
facetFilters: Record<string, any>;
};

Expand All @@ -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":
Expand All @@ -65,6 +93,8 @@ function searchReducer(state: SearchState, action: SearchAction) {
}

const initialState: SearchState = {
limit: 25,
offset: 0,
facetFilters: {},
};

Expand Down
98 changes: 98 additions & 0 deletions src/components/Pagination.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Flex align="center" p={2}>
<HStack>
<Text fontSize="sm" as="label" htmlFor="limit">
Results per page:
</Text>
<Box>
<Select
id="limit"
size="sm"
value={search.limit}
onChange={(e) => {
dispatch({
type: "set_limit",
payload: parseInt(e.target.value),
});
}}
>
<option value={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
</Select>
</Box>
</HStack>
<Spacer />
<Text fontSize="xs">
{search.offset > 0 ? search.offset : 1}-{search.offset + search.limit}{" "}
of {result.total}
</Text>
<Spacer />
<ButtonGroup variant="outline" spacing="6">
<Button
size="sm"
isDisabled={search.offset === 0}
onClick={() => {
dispatch({ type: "set_offset", payload: 0 });
}}
>
<Icon as={ChevronDoubleLeftIcon} />
</Button>
<Button
size="sm"
isDisabled={search.offset === 0}
onClick={() => {
dispatch({
type: "set_offset",
payload: search.offset - search.limit,
});
}}
>
<Icon as={ChevronLeftIcon} />
</Button>

<Button
size="sm"
onClick={() => {
dispatch({
type: "set_offset",
payload: search.offset + search.limit,
});
}}
>
<Icon as={ChevronRightIcon} />
</Button>
</ButtonGroup>
</Flex>
);
};
1 change: 0 additions & 1 deletion src/components/Result.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
63 changes: 35 additions & 28 deletions src/components/Search.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,24 @@
"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";
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", []);
Expand All @@ -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<string>("");
const [result, setResult] = useState<undefined | GSearchResult>();

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 (
<>
<form>
<HStack p={4}>
<Box py={2} position="sticky" top={0} backgroundColor="white" zIndex={1}>
<VStack spacing={2} align="stretch">
<InputGroup size="md">
<InputLeftElement pointerEvents="none">
<SearchIcon color="gray.300" />
Expand All @@ -64,28 +74,25 @@ export function Search() {
onChange={debounce((e) => setQuery(e.target.value), 300)}
/>
</InputGroup>
</HStack>
<SearchFacets result={result} px={4} />
<Box>
<Box p={4}>
{isGError(result) && <Error error={result} />}
{result && result.total > 0 && (
<>
<Stat size="sm">
<StatLabel>Results</StatLabel>
<StatNumber>{result.total} datasets found</StatNumber>
</Stat>
<VStack py={2} spacing={5} align="stretch">
{result.gmeta?.map((gmeta, i) => (
<ResultListing key={i} gmeta={gmeta} />
))}
</VStack>
</>
)}
{result && result.total === 0 && <Box>No datasets found.</Box>}
</Box>
<SearchFacets result={result} />
<Pagination result={result} />
</VStack>
</Box>
<Box>
<Box p={4}>
{isGError(result) && <Error error={result} />}
{result && result.total > 0 && (
<>
<VStack py={2} spacing={5} align="stretch">
{result.gmeta?.map((gmeta, i) => (
<ResultListing key={i} gmeta={gmeta} />
))}
</VStack>
</>
)}
{result && result.total === 0 && <Box>No datasets found.</Box>}
</Box>
</form>
</Box>
</>
);
}
13 changes: 9 additions & 4 deletions src/components/SearchFacets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -41,7 +46,7 @@ const BucketSearch = (props: UseMenuItemProps & InputProps) => {
<Box role={role}>
<InputGroup>
<InputLeftElement pointerEvents="none">
<SearchIcon color="gray.300" />
<Icon as={MagnifyingGlassIcon} color="gray.300" />
</InputLeftElement>
<Input
placeholder="Search"
Expand Down Expand Up @@ -91,7 +96,7 @@ function FacetMenu({ facet }: { facet: GFacetResult }) {
<MenuButton
as={Button}
size="sm"
leftIcon={<PlusSquareIcon />}
leftIcon={<Icon as={PlusCircleIcon} />}
variant="ghost"
border="1px dashed"
borderColor="gray.400"
Expand Down Expand Up @@ -180,7 +185,7 @@ export default function SearchFacets({
size="sm"
onClick={reset}
variant="ghost"
rightIcon={<SmallCloseIcon />}
rightIcon={<Icon as={XMarkIcon} />}
>
Clear All Filters
</Button>
Expand Down
1 change: 1 addition & 0 deletions src/globus/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export type GFacetResult = {
export type GSearchResult = {
"@datatype": "GSearchResult";
"@version": string;
count: number;
offset: number;
total: number;
has_next_page: boolean;
Expand Down

0 comments on commit ef577e1

Please sign in to comment.