Skip to content

Commit

Permalink
feat: adds support for globus.search.facet to generate filter UI base…
Browse files Browse the repository at this point in the history
…d on configured facets and responses from the index (#28)
  • Loading branch information
jbottigliero authored Apr 16, 2024
1 parent ffb58a9 commit c0cce3b
Show file tree
Hide file tree
Showing 9 changed files with 422 additions and 86 deletions.
4 changes: 2 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from "react";
import { Providers } from "./providers";
import { ThemeProvider } from "./theme-provider";

export default function RootLayout({
children,
Expand All @@ -9,7 +9,7 @@ export default function RootLayout({
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
Expand Down
118 changes: 36 additions & 82 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,12 @@
"use client";
import React from "react";

import React, { useEffect, useState, FormEvent } from "react";
import { Container, Box, Heading, HStack, Image } from "@chakra-ui/react";

import { search } from "@globus/sdk";
import {
Input,
Container,
Box,
Heading,
VStack,
HStack,
Image,
Stat,
StatLabel,
StatNumber,
} from "@chakra-ui/react";
import { getAttribute } from "../../static";

import { STATIC } from "../../static";
import { useSearchParams, useRouter } from "next/navigation";
import ResultListing from "@/components/ResultListing";
import SearchProvider from "./search-provider";
import { Search } from "@/components/Search";

export type SearchEntry = {
entry_id: string | null;
Expand All @@ -35,12 +23,28 @@ export type GMetaResult = {
entries: SearchEntry[];
};

type GSearchResult = {
export type GBucket = {
"@datatype": "GBucket";
"@version": string;
value: string | Record<string, unknown>;
count: number;
};

export type GFacetResult = {
"@datatype": "GFacetResult";
"@version": string;
name: string;
value?: number;
buckets: GBucket[];
};

export type GSearchResult = {
"@datatype": "GSearchResult";
"@version": string;
offset: number;
total: number;
has_next_page: boolean;
facet_results?: GFacetResult[];
gmeta: GMetaResult[];
};

Expand All @@ -53,86 +57,36 @@ export type GError = {
error: Record<string, unknown> | Array<GError>;
};

export default function Index() {
const router = useRouter();
const searchParams = useSearchParams();
const query = searchParams.get("q");

const [results, setResults] = useState<null | GSearchResult>(null);

const updateQueryParam = (key: string, value: string) => {
const currentParams = new URLSearchParams(searchParams.toString());
currentParams.set(key, value);
router.replace(`?${currentParams.toString()}`);
};

useEffect(() => {
const fetchResults = async () => {
if (!query) {
setResults(null);
return;
}
const response = await search.query.get(
STATIC.data.attributes.globus.search.index,
{
query: {
q: query,
},
},
);
const results = await response.json();
setResults(results);
};

fetchResults();
}, [query]);

const { logo, headline } = STATIC.data.attributes.content;
const SEARCH_INDEX = getAttribute("globus.search.index");
const LOGO = getAttribute("content.logo");
const HEADLINE = getAttribute(
"content.headline",
`Search Index ${SEARCH_INDEX}`,
);

export default function Index() {
return (
<>
<Box bg="brand.800">
<HStack p={4} spacing="24px">
{logo && (
{LOGO && (
<Image
src={logo.src}
alt={logo.alt}
src={LOGO.src}
alt={LOGO.alt}
boxSize="100px"
objectFit="contain"
/>
)}
<Heading size="md" color="white">
{headline}
{HEADLINE}
</Heading>
</HStack>
</Box>
<Container maxW="container.xl">
<main>
<Box p={4}>
<Input
type="search"
placeholder="Start your search here..."
value={query || ""}
onInput={(e: FormEvent<HTMLInputElement>) => {
updateQueryParam("q", e.currentTarget.value);
}}
/>
</Box>

<Box>
{results && (
<Stat size="sm">
<StatLabel>Results</StatLabel>
<StatNumber>{results.total} datasets found</StatNumber>
</Stat>
)}
<VStack py={2} spacing={5} align="stretch">
{results &&
results.gmeta.map((gmeta, i) => (
<ResultListing key={i} gmeta={gmeta} />
))}
</VStack>
</Box>
<SearchProvider>
<Search />
</SearchProvider>
</main>
</Container>
</>
Expand Down
92 changes: 92 additions & 0 deletions src/app/search-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"use context";
import React, {
createContext,
useReducer,
useContext,
type Dispatch,
} from "react";
import { Static, getAttribute } from "../../static";
import { GFacetResult } from "./page";

const FACETS = getAttribute("globus.search.facets", []);
type Facet = NonNullable<
Static["data"]["attributes"]["globus"]["search"]["facets"]
>[0];

export function getFacetFieldNameByName(name: string) {
let match = FACETS.find((facet: Facet) => facet.name === name)?.field_name;
if (!match) {
match = FACETS.find(
(facet: Facet) => facet.field_name === name,
)?.field_name;
}
return match;
}

type SearchState = {
facetFilters: Record<string, any>;
};

type SearchAction =
| {
type: "set_facet_filter";
payload: {
facet: GFacetResult;
value: string[];
};
}
| {
type: "reset_facet_filters";
};

function searchReducer(state: SearchState, action: SearchAction) {
switch (action.type) {
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,
};
}
return {
...state,
facetFilters: { ...state.facetFilters, [fieldName]: filter },
};
}
case "reset_facet_filters":
return { ...state, facetFilters: initialState.facetFilters };
default:
return state;
}
}

const initialState: SearchState = {
facetFilters: {},
};

const SearchContext = createContext(initialState);
const SearchDispatchContext = createContext<Dispatch<SearchAction>>(() => {});

export default function SearchProvider({
children,
}: React.PropsWithChildren<{}>) {
const [search, dispatch] = useReducer(searchReducer, initialState);
return (
<SearchContext.Provider value={search}>
<SearchDispatchContext.Provider value={dispatch}>
{children}
</SearchDispatchContext.Provider>
</SearchContext.Provider>
);
}

export function useSearch() {
return useContext(SearchContext);
}

export function useSearchDispatch() {
return useContext(SearchDispatchContext);
}
2 changes: 1 addition & 1 deletion src/app/providers.tsx → src/app/theme-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ import theme from "../theme";

import { ChakraProvider } from "@chakra-ui/react";

export function Providers({ children }: { children: React.ReactNode }) {
export function ThemeProvider({ children }: { children: React.ReactNode }) {
return <ChakraProvider theme={theme}>{children}</ChakraProvider>;
}
81 changes: 81 additions & 0 deletions src/components/Search.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"use client";
import {
HStack,
InputGroup,
Input,
Button,
Box,
Stat,
StatLabel,
StatNumber,
VStack,
} from "@chakra-ui/react";
import React, { FormEvent, useState } from "react";
import SearchFacets from "./SearchFacets";
import { search as gsearch } from "@globus/sdk";
import { useSearch } from "../app/search-provider";
import { GSearchResult } from "../app/page";
import { getAttribute } from "../../static";
import ResultListing from "./ResultListing";

const SEARCH_INDEX = getAttribute("globus.search.index");
const FACETS = getAttribute("globus.search.facets", []);

export function Search() {
const search = useSearch();
const inputRef = React.useRef<HTMLInputElement>(null);
const formRef = React.useRef<HTMLFormElement>(null);
const [result, setResult] = useState<undefined | GSearchResult>();

const handleSearchSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const response = await gsearch.query.post(SEARCH_INDEX, {
payload: {
q: e.currentTarget.q.value,
facets: FACETS,
filters: Object.values(search.facetFilters),
},
});
const results = await response.json();
setResult(results);
};

return (
<>
<form ref={formRef} onSubmit={handleSearchSubmit}>
<HStack p={4}>
<InputGroup size="md">
<Input
name="q"
type="search"
placeholder="Start your search here..."
ref={inputRef}
/>
</InputGroup>
<Button colorScheme="brand" type="submit">
Search
</Button>
</HStack>
<SearchFacets result={result} px={4} />
<Box>
<Box p={4}>
{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>
</Box>
</form>
</>
);
}
Empty file added src/components/SearchBar.tsx
Empty file.
Loading

0 comments on commit c0cce3b

Please sign in to comment.