Skip to content

Commit 56448b5

Browse files
authored
feat(modules): add modules/search page (#196)
Uses [js-search](https://www.npmjs.com/package/js-search) to build a client-side search index and filter results. Introduces a custom higher-order component to fetch location information from [@reach/router](https://www.npmjs.com/package/@reach/router) and parse it with [query-string](https://www.npmjs.com/package/query-string). The wrapper makes `location`, `navigate`, and the parsed `search` available to wrapped components. In addition to the index search by search `term` the module search allows to filter by `tags` as well. ``` /modules/search/?tag=Library&tag=Assets ``` ``` /modules/search/?term=rail ``` Based on - https://www.gatsbyjs.com/docs/adding-search-with-js-search - https://medium.com/@chrisfitkin/how-to-get-query-string-parameter-values-in-gatsby-f714161104f - https://www.dolthub.com/blog/2021-11-29-gatsby-search-and-pagination/ * add UI search for modules * add react-select for tag filtering via UI * allow to search modules by term and by tags (instant search) * add "load more" functionality * link to new "Browse All" and "Search" pages for modules * show no results when search is empty, add helpful text if no results are shown.
1 parent 377ec42 commit 56448b5

8 files changed

Lines changed: 505 additions & 19 deletions

File tree

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
],
1111
"author": "The Terasology Foundation",
1212
"dependencies": {
13+
"@reach/router": "^1.3.4",
1314
"bootstrap": "^4.3.1",
1415
"canvas": "^2.8.0",
1516
"gatsby": "^5.0.0",
@@ -31,6 +32,7 @@
3132
"gatsby-transformer-remark": "^6.3.2",
3233
"gatsby-transformer-sharp": "^5.3.1",
3334
"html-react-parser": "^1.2.7",
35+
"js-search": "^2.0.0",
3436
"lodash.kebabcase": "^4.1.1",
3537
"moment": "^2.29.2",
3638
"moment-timezone": "^0.5.33",
@@ -40,6 +42,7 @@
4042
"react-github-corner": "^2.5.0",
4143
"react-icons": "^3.7.0",
4244
"react-multi-carousel": "^2.6.3",
45+
"react-select": "^5.7.0",
4346
"react-share": "^4.4.0",
4447
"react-twitter-widgets": "^1.7.1",
4548
"reactstrap": "^8.0.0",

src/components/Header/Header.jsx

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -65,17 +65,35 @@ function Header() {
6565
</Link>
6666
</NavLink>
6767
</NavItem>
68-
<NavItem>
69-
<NavLink>
70-
<Link
71-
to="/modules"
72-
className="text-color"
73-
activeClassName="active"
74-
>
75-
Modules
76-
</Link>
77-
</NavLink>
78-
</NavItem>
68+
<UncontrolledDropdown nav inNavbar>
69+
<DropdownToggle nav caret className="text-color">
70+
<span className="text-color">Modules</span>
71+
</DropdownToggle>
72+
<DropdownMenu>
73+
<DropdownItem>
74+
<NavLink>
75+
<Link
76+
to="/modules/a"
77+
className="text-color"
78+
activeClassName="active"
79+
>
80+
Browse all
81+
</Link>
82+
</NavLink>
83+
</DropdownItem>
84+
<DropdownItem>
85+
<NavLink>
86+
<Link
87+
to="/modules/search"
88+
className="text-color"
89+
activeClassName="active"
90+
>
91+
Search
92+
</Link>
93+
</NavLink>
94+
</DropdownItem>
95+
</DropdownMenu>
96+
</UncontrolledDropdown>
7997
<UncontrolledDropdown nav inNavbar>
8098
<DropdownToggle nav caret className="text-color">
8199
<span className="text-color">Contribute</span>

src/components/SEO/SEO.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from "react";
22
import urljoin from "url-join";
3-
import { useSiteMetadata } from "../../hooks/use-site-metadata";
3+
import { useSiteMetadata } from "../../hooks/useSiteMetadata";
44

55
function SEO({ title, description, pathname, children }) {
66
const {
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import React from "react";
2+
import { Location } from "@reach/router";
3+
4+
// Based on https://medium.com/@chrisfitkin/how-to-get-query-string-parameter-values-in-gatsby-f714161104f
5+
6+
/* Explicitly use property spreading for higher-order component (a component wrapping another component). */
7+
/* eslint-disable react/jsx-props-no-spreading */
8+
function withLocation(ComponentToWrap) {
9+
return function wrapComponent(props) {
10+
return (
11+
<Location>
12+
{({ location, navigate }) => (
13+
<ComponentToWrap
14+
{...props}
15+
location={location}
16+
navigate={navigate}
17+
searchParams={new URLSearchParams(location.search ?? "")}
18+
/>
19+
)}
20+
</Location>
21+
);
22+
};
23+
}
24+
25+
export default withLocation;
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import * as JsSearch from "js-search";
2+
3+
// Based on https://www.dolthub.com/blog/2021-11-29-gatsby-search-and-pagination/
4+
5+
function useJsSearch(modules) {
6+
// Search configuration
7+
const dataToSearch = new JsSearch.Search("name");
8+
// TODO: understand how JsSearch works and which parameters we want to use here
9+
dataToSearch.indexStrategy = new JsSearch.AllSubstringsIndexStrategy();
10+
dataToSearch.sanitizer = new JsSearch.LowerCaseSanitizer();
11+
12+
// Fields to search
13+
dataToSearch.addIndex("name");
14+
dataToSearch.addIndex(["moduleTxt", "description"]);
15+
dataToSearch.addIndex(["moduleTxt", "tags"]);
16+
17+
dataToSearch.addDocuments(modules);
18+
19+
function search(query) {
20+
return dataToSearch.search(query);
21+
}
22+
23+
return {
24+
search,
25+
};
26+
}
27+
28+
export default useJsSearch;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { graphql, useStaticQuery } from "gatsby";
22

3+
// TODO: make this a higher-order component to allow wrapping components
4+
// export default withLocation(withSiteMetadata(Component))
35
export const useSiteMetadata = () => {
46
const data = useStaticQuery(graphql`
57
query {

src/pages/modules/search.jsx

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import React, { useState, useEffect, useCallback } from "react";
2+
import { graphql, navigate } from "gatsby";
3+
import { Button, Col, Form, FormGroup, Input, Row } from "reactstrap";
4+
import Select from "react-select";
5+
import SEO from "../../components/SEO/SEO";
6+
import Layout from "../../layout";
7+
import ModuleListing from "../../components/modules/ModuleListing";
8+
import withLocation from "../../components/common/withLocation";
9+
import useJsSearch from "../../components/modules/useJsSearch";
10+
11+
// TODO: factor this out into it's own component in components/modules.
12+
function SearchForm({ searchParams, allTags, setSearchParams }) {
13+
const options = allTags.sort().map((t) => ({ value: t, label: t }));
14+
15+
return (
16+
<Form role="search" method="GET">
17+
<Row form classNames="justify-content-center" id="search-form">
18+
<Col md="4">
19+
<FormGroup>
20+
<Select
21+
form
22+
isMulti
23+
name="tags"
24+
placeholder="Tags..."
25+
className="search-input"
26+
classNamePrefix="react-select"
27+
options={options}
28+
onChange={(e) => {
29+
searchParams.delete("tag");
30+
e.forEach((t) => searchParams.append("tag", t.value));
31+
setSearchParams(searchParams);
32+
}}
33+
defaultValue={searchParams
34+
.getAll("tag")
35+
.filter((x) => x)
36+
.map((t) => ({ value: t, label: t }))}
37+
/>
38+
</FormGroup>
39+
</Col>
40+
<Col md="8">
41+
<FormGroup>
42+
<Input
43+
type="search"
44+
className="search-input"
45+
name="keywords"
46+
bsSize="lg"
47+
aria-controls="search-results-count"
48+
onChange={(e) => {
49+
if (e.target.value) {
50+
searchParams.set("term", e.target.value);
51+
} else {
52+
searchParams.delete("term");
53+
}
54+
setSearchParams(searchParams);
55+
}}
56+
placeholder="Search..."
57+
value={searchParams.get("term") || ""}
58+
/>
59+
</FormGroup>
60+
</Col>
61+
</Row>
62+
</Form>
63+
);
64+
}
65+
66+
export function Head({ data: { site } }) {
67+
return <SEO title={`Modules | ${site.metadata.title}`} />;
68+
}
69+
70+
function Search({ data, searchParams: initialSearchParams }) {
71+
const MODULES_PER_PAGE = 9;
72+
73+
const allModules = data.modules.nodes;
74+
const allTags = allModules.reduce((acc, m) => {
75+
m.moduleTxt.tags.forEach((t) => acc.add(t));
76+
return acc;
77+
}, new Set());
78+
79+
// build search index for all modules
80+
const { search } = useJsSearch(allModules);
81+
82+
const [searchParams, setSearchParams] = useState(initialSearchParams);
83+
const [searchDirty, setSearchDirty] = useState(true);
84+
const [filteredModules, setFilteredModules] = useState(allModules);
85+
const [noResults, setNoResults] = useState(
86+
"Filter by tags or type a search term to show matching modules."
87+
);
88+
89+
const updateSearchParams = useCallback(
90+
(params) => {
91+
setSearchParams(params);
92+
setSearchDirty(true);
93+
},
94+
[setSearchParams, setSearchDirty]
95+
);
96+
97+
useEffect(() => {
98+
if (searchDirty) {
99+
const tags = searchParams.getAll("tag").filter((x) => x);
100+
const term = searchParams.get("term") || "";
101+
const hasSearchParams = term || tags.length > 0;
102+
let result = [];
103+
if (hasSearchParams) {
104+
// filter based on searched term
105+
const searchResults = term ? search(term) : allModules;
106+
// filter based on tags
107+
result = searchResults.filter((m) =>
108+
tags.every((t) => m.moduleTxt.tags.includes(t))
109+
);
110+
}
111+
setFilteredModules(result);
112+
setSearchDirty(false);
113+
navigate(`?${searchParams.toString()}`);
114+
}
115+
}, [searchDirty, searchParams, allModules, search]);
116+
117+
useEffect(() => {
118+
if (!searchParams.get("term") && !searchParams.get("tag")) {
119+
setNoResults(
120+
"Filter by tags or type a search term to show matching modules."
121+
);
122+
} else if (filteredModules.length) {
123+
setNoResults("");
124+
} else {
125+
setNoResults("No modules found matching the search criteria.");
126+
}
127+
}, [searchParams, filteredModules]);
128+
129+
// "Load More" functionality based on https://www.erichowey.dev/writing/load-more-button-and-infinite-scroll-in-gatsby/
130+
const [modules, setModules] = useState(
131+
filteredModules.slice(0, MODULES_PER_PAGE)
132+
);
133+
const [loadMore, setLoadMore] = useState(false);
134+
const [hasMore, setHasMore] = useState(allModules.length > MODULES_PER_PAGE);
135+
136+
// reset number of shown modules when the result changes
137+
useEffect(() => {
138+
setModules(filteredModules.slice(0, MODULES_PER_PAGE));
139+
}, [filteredModules]);
140+
141+
// show more results if there are more available and the user requested to load more
142+
useEffect(() => {
143+
if (loadMore && hasMore) {
144+
const nextModules = filteredModules.slice(
145+
0,
146+
modules.length + MODULES_PER_PAGE
147+
);
148+
setModules(nextModules);
149+
}
150+
setLoadMore(false);
151+
}, [loadMore, hasMore, filteredModules, modules.length]);
152+
153+
// compute whether there are more results to show whenever the list of results changes
154+
useEffect(() => {
155+
setHasMore(modules.length < filteredModules.length);
156+
}, [modules, filteredModules]);
157+
158+
return (
159+
<Layout title="Modules">
160+
<div className="module-container">
161+
<Row>
162+
<Col md="12">
163+
<SearchForm
164+
searchParams={searchParams}
165+
allTags={[...allTags]}
166+
setSearchParams={updateSearchParams}
167+
/>
168+
</Col>
169+
</Row>
170+
<Row className="justify-content-center">
171+
<ModuleListing defaultCover={data.defaultCover} modules={modules} />
172+
</Row>
173+
{noResults ? (
174+
<Row className="justify-content-center">
175+
<p>{noResults}</p>
176+
</Row>
177+
) : null}
178+
{hasMore ? (
179+
<Row className="justify-content-center">
180+
<Button onClick={() => setLoadMore(true)}>Load More</Button>
181+
</Row>
182+
) : null}
183+
</div>
184+
</Layout>
185+
);
186+
}
187+
188+
export const query = graphql`
189+
query AllModules {
190+
modules: allTerasologyModule(sort: { name: ASC }) {
191+
nodes {
192+
name
193+
description
194+
url
195+
moduleTxt {
196+
description
197+
tags
198+
}
199+
}
200+
}
201+
defaultCover: file(relativePath: { eq: "logos/defaultCardcover.jpg" }) {
202+
childImageSharp {
203+
gatsbyImageData
204+
}
205+
}
206+
site {
207+
metadata: siteMetadata {
208+
title
209+
}
210+
}
211+
}
212+
`;
213+
214+
export default withLocation(Search);

0 commit comments

Comments
 (0)