Skip to content

Commit 607fcfe

Browse files
committed
feat: adds basic pagination and improved loading state
1 parent 8ba1ec2 commit 607fcfe

File tree

8 files changed

+193
-42
lines changed

8 files changed

+193
-42
lines changed

package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"react-dom": "^18"
2727
},
2828
"devDependencies": {
29+
"@heroicons/react": "^2.1.3",
2930
"@types/node": "^20",
3031
"@types/react": "^18",
3132
"@types/react-dom": "^18",

src/app/search-provider.tsx

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ const FACETS = getAttribute("globus.search.facets", []);
1313
type Facet = NonNullable<
1414
Static["data"]["attributes"]["globus"]["search"]["facets"]
1515
>[0];
16-
16+
/**
17+
* Since a `GFacet` can be expressed with or without a `name`, when
18+
* when processing a `GFacetResult` to map to a `GFilter`, we need to
19+
* figure out what the configured `field_name` is for the facet.
20+
*/
1721
export function getFacetFieldNameByName(name: string) {
1822
let match = FACETS.find((facet: Facet) => facet.name === name)?.field_name;
1923
if (!match) {
@@ -25,6 +29,8 @@ export function getFacetFieldNameByName(name: string) {
2529
}
2630

2731
export type SearchState = {
32+
limit: number;
33+
offset: number;
2834
facetFilters: Record<string, any>;
2935
};
3036

@@ -38,23 +44,45 @@ type SearchAction =
3844
}
3945
| {
4046
type: "reset_facet_filters";
47+
}
48+
| {
49+
type: "set_limit";
50+
payload: number;
51+
}
52+
| {
53+
type: "set_offset";
54+
payload: number;
4155
};
4256

4357
function searchReducer(state: SearchState, action: SearchAction) {
4458
switch (action.type) {
59+
case "set_limit":
60+
return { ...state, limit: action.payload, offset: 0 };
61+
case "set_offset":
62+
return { ...state, offset: action.payload };
4563
case "set_facet_filter": {
4664
const fieldName = getFacetFieldNameByName(action.payload.facet.name);
47-
let filter;
48-
if (action.payload.value.length !== 0) {
49-
filter = {
50-
type: "match_any",
51-
field_name: fieldName,
52-
values: action.payload.value,
53-
};
65+
const { facetFilters } = state;
66+
/**
67+
* If the incoming value is empty, remove the filter from the state.
68+
*/
69+
if (action.payload.value.length === 0 && facetFilters[fieldName]) {
70+
const { [fieldName]: _, ...rest } = facetFilters;
71+
return { ...state, facetFilters: rest };
5472
}
73+
/**
74+
* Otherwise, update the filter in the state to the provided value.
75+
*/
5576
return {
5677
...state,
57-
facetFilters: { ...state.facetFilters, [fieldName]: filter },
78+
facetFilters: {
79+
...facetFilters,
80+
[fieldName]: {
81+
type: "match_any",
82+
field_name: fieldName,
83+
values: action.payload.value,
84+
},
85+
},
5886
};
5987
}
6088
case "reset_facet_filters":
@@ -65,6 +93,8 @@ function searchReducer(state: SearchState, action: SearchAction) {
6593
}
6694

6795
const initialState: SearchState = {
96+
limit: 25,
97+
offset: 0,
6898
facetFilters: {},
6999
};
70100

src/components/Pagination.tsx

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import React from "react";
2+
import {
3+
Box,
4+
Button,
5+
ButtonGroup,
6+
Flex,
7+
HStack,
8+
Icon,
9+
Select,
10+
Spacer,
11+
Text,
12+
} from "@chakra-ui/react";
13+
import {
14+
ChevronRightIcon,
15+
ChevronLeftIcon,
16+
ChevronDoubleLeftIcon,
17+
} from "@heroicons/react/24/outline";
18+
19+
import type { GSearchResult } from "@/globus/search";
20+
import { useSearch, useSearchDispatch } from "@/app/search-provider";
21+
22+
export const Pagination = ({ result }: { result?: GSearchResult }) => {
23+
const search = useSearch();
24+
const dispatch = useSearchDispatch();
25+
26+
if (!result) {
27+
return null;
28+
}
29+
30+
return (
31+
<Flex align="center" p={2}>
32+
<HStack>
33+
<Text fontSize="sm" as="label" htmlFor="limit">
34+
Results per page:
35+
</Text>
36+
<Box>
37+
<Select
38+
id="limit"
39+
size="sm"
40+
value={search.limit}
41+
onChange={(e) => {
42+
dispatch({
43+
type: "set_limit",
44+
payload: parseInt(e.target.value),
45+
});
46+
}}
47+
>
48+
<option value={10}>10</option>
49+
<option value={25}>25</option>
50+
<option value={50}>50</option>
51+
<option value={100}>100</option>
52+
</Select>
53+
</Box>
54+
</HStack>
55+
<Spacer />
56+
<Text fontSize="xs">
57+
{search.offset > 0 ? search.offset : 1}-{search.offset + search.limit}{" "}
58+
of {result.total}
59+
</Text>
60+
<Spacer />
61+
<ButtonGroup variant="outline" spacing="6">
62+
<Button
63+
size="sm"
64+
isDisabled={search.offset === 0}
65+
onClick={() => {
66+
dispatch({ type: "set_offset", payload: 0 });
67+
}}
68+
>
69+
<Icon as={ChevronDoubleLeftIcon} />
70+
</Button>
71+
<Button
72+
size="sm"
73+
isDisabled={search.offset === 0}
74+
onClick={() => {
75+
dispatch({
76+
type: "set_offset",
77+
payload: search.offset - search.limit,
78+
});
79+
}}
80+
>
81+
<Icon as={ChevronLeftIcon} />
82+
</Button>
83+
84+
<Button
85+
size="sm"
86+
onClick={() => {
87+
dispatch({
88+
type: "set_offset",
89+
payload: search.offset + search.limit,
90+
});
91+
}}
92+
>
93+
<Icon as={ChevronRightIcon} />
94+
</Button>
95+
</ButtonGroup>
96+
</Flex>
97+
);
98+
};

src/components/Result.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import { get } from "lodash";
2020

2121
import { getAttribute, getAttributeFrom } from "../../static";
2222
import { Error } from "./Error";
23-
2423
import { isGError, type GError, type GMetaResult } from "@/globus/search";
2524

2625
type FieldDefinition =

src/components/Search.tsx

Lines changed: 35 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,24 @@
11
"use client";
22
import {
3-
HStack,
43
InputGroup,
54
Input,
65
Box,
7-
Stat,
8-
StatLabel,
9-
StatNumber,
106
VStack,
117
InputLeftElement,
8+
useToast,
129
} from "@chakra-ui/react";
1310
import { SearchIcon } from "@chakra-ui/icons";
1411
import { throttle, debounce } from "lodash";
1512
import React, { useEffect, useState } from "react";
1613
import { search as gsearch } from "@globus/sdk";
1714

1815
import { GSearchResult, isGError } from "@/globus/search";
19-
2016
import SearchFacets from "./SearchFacets";
2117
import { SearchState, useSearch } from "../app/search-provider";
2218
import { getAttribute } from "../../static";
2319
import ResultListing from "./ResultListing";
2420
import { Error } from "./Error";
21+
import { Pagination } from "./Pagination";
2522

2623
const SEARCH_INDEX = getAttribute("globus.search.index");
2724
const FACETS = getAttribute("globus.search.facets", []);
@@ -30,30 +27,43 @@ function getSearchPayload(query: string, state: SearchState) {
3027
return {
3128
q: query,
3229
facets: FACETS,
30+
offset: state.offset,
31+
limit: state.limit,
3332
filters: Object.values(state.facetFilters).filter((f) => Boolean(f)),
3433
};
3534
}
3635

3736
export function Search() {
3837
const search = useSearch();
38+
const toast = useToast({
39+
position: "bottom-right",
40+
});
41+
const toastId = "search-status";
3942
const [query, setQuery] = useState<string>("");
4043
const [result, setResult] = useState<undefined | GSearchResult>();
4144

4245
useEffect(() => {
4346
const fetchResults = throttle(async () => {
47+
const id = toast({
48+
id: toastId,
49+
title: "Fetching search results...",
50+
status: "loading",
51+
duration: null,
52+
});
4453
const response = await gsearch.query.post(SEARCH_INDEX, {
4554
payload: getSearchPayload(query, search),
4655
});
4756
const results = await response.json();
4857
setResult(results);
58+
toast.close(id);
4959
}, 1000);
5060
fetchResults();
5161
}, [query, search]);
5262

5363
return (
5464
<>
55-
<form>
56-
<HStack p={4}>
65+
<Box py={2} position="sticky" top={0} backgroundColor="white" zIndex={1}>
66+
<VStack spacing={2} align="stretch">
5767
<InputGroup size="md">
5868
<InputLeftElement pointerEvents="none">
5969
<SearchIcon color="gray.300" />
@@ -64,28 +74,25 @@ export function Search() {
6474
onChange={debounce((e) => setQuery(e.target.value), 300)}
6575
/>
6676
</InputGroup>
67-
</HStack>
68-
<SearchFacets result={result} px={4} />
69-
<Box>
70-
<Box p={4}>
71-
{isGError(result) && <Error error={result} />}
72-
{result && result.total > 0 && (
73-
<>
74-
<Stat size="sm">
75-
<StatLabel>Results</StatLabel>
76-
<StatNumber>{result.total} datasets found</StatNumber>
77-
</Stat>
78-
<VStack py={2} spacing={5} align="stretch">
79-
{result.gmeta?.map((gmeta, i) => (
80-
<ResultListing key={i} gmeta={gmeta} />
81-
))}
82-
</VStack>
83-
</>
84-
)}
85-
{result && result.total === 0 && <Box>No datasets found.</Box>}
86-
</Box>
77+
<SearchFacets result={result} />
78+
<Pagination result={result} />
79+
</VStack>
80+
</Box>
81+
<Box>
82+
<Box p={4}>
83+
{isGError(result) && <Error error={result} />}
84+
{result && result.total > 0 && (
85+
<>
86+
<VStack py={2} spacing={5} align="stretch">
87+
{result.gmeta?.map((gmeta, i) => (
88+
<ResultListing key={i} gmeta={gmeta} />
89+
))}
90+
</VStack>
91+
</>
92+
)}
93+
{result && result.total === 0 && <Box>No datasets found.</Box>}
8794
</Box>
88-
</form>
95+
</Box>
8996
</>
9097
);
9198
}

src/components/SearchFacets.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,13 @@ import {
2121
useMenuItem,
2222
InputProps,
2323
UseMenuItemProps,
24+
Icon,
2425
} from "@chakra-ui/react";
25-
import { PlusSquareIcon, SearchIcon, SmallCloseIcon } from "@chakra-ui/icons";
26+
import {
27+
PlusCircleIcon,
28+
MagnifyingGlassIcon,
29+
XMarkIcon,
30+
} from "@heroicons/react/24/outline";
2631
import { getAttribute } from "../../static";
2732

2833
import {
@@ -41,7 +46,7 @@ const BucketSearch = (props: UseMenuItemProps & InputProps) => {
4146
<Box role={role}>
4247
<InputGroup>
4348
<InputLeftElement pointerEvents="none">
44-
<SearchIcon color="gray.300" />
49+
<Icon as={MagnifyingGlassIcon} color="gray.300" />
4550
</InputLeftElement>
4651
<Input
4752
placeholder="Search"
@@ -91,7 +96,7 @@ function FacetMenu({ facet }: { facet: GFacetResult }) {
9196
<MenuButton
9297
as={Button}
9398
size="sm"
94-
leftIcon={<PlusSquareIcon />}
99+
leftIcon={<Icon as={PlusCircleIcon} />}
95100
variant="ghost"
96101
border="1px dashed"
97102
borderColor="gray.400"
@@ -180,7 +185,7 @@ export default function SearchFacets({
180185
size="sm"
181186
onClick={reset}
182187
variant="ghost"
183-
rightIcon={<SmallCloseIcon />}
188+
rightIcon={<Icon as={XMarkIcon} />}
184189
>
185190
Clear All Filters
186191
</Button>

src/globus/search.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export type GFacetResult = {
3131
export type GSearchResult = {
3232
"@datatype": "GSearchResult";
3333
"@version": string;
34+
count: number;
3435
offset: number;
3536
total: number;
3637
has_next_page: boolean;

0 commit comments

Comments
 (0)