Skip to content

Commit 7a548a0

Browse files
alex-pagedominikwilkowskimartenbjork
authored
[polaris.shopify.com] Server side search (#7049)
**This PR** - [x] Moves `utils/search.ts` to the `api/search/v0/index.tsx` and API for searching the site **Future work** - [ ] Removes `foundations.json` - [ ] Adds search functionality for body of markdown content - [ ] Adds loading states during fetch - [ ] Add caching to the search API (unsure how this will work with new pages being added) Co-authored-by: Dominik Wilkowski <1266923+dominikwilkowski@users.noreply.github.com> Co-authored-by: Marten Bjork <marten@martenbjork.com>
1 parent a805116 commit 7a548a0

File tree

7 files changed

+248
-184
lines changed

7 files changed

+248
-184
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'polaris.shopify.com': minor
3+
---
4+
5+
Move search to the server side

polaris.shopify.com/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
"react-dom": "^17.0.2",
3030
"react-markdown": "^8.0.2",
3131
"use-dark-mode": "^2.3.1",
32-
"remark-gfm": "^3.0.1"
32+
"remark-gfm": "^3.0.1",
33+
"lodash.throttle": "^4.1.1"
3334
},
3435
"devDependencies": {
3536
"@types/gtag.js": "^0.0.10",
@@ -38,6 +39,7 @@
3839
"@types/node": "17.0.21",
3940
"@types/prismjs": "^1.26.0",
4041
"@types/react": "*",
42+
"@types/lodash.throttle": "^4.1.7",
4143
"eslint-config-next": "12.1.0",
4244
"eslint": "8.10.0",
4345
"execa": "^6.1.0",
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import type {NextApiRequest, NextApiResponse} from 'next';
2+
import Fuse from 'fuse.js';
3+
4+
import {
5+
SearchResults,
6+
GroupedSearchResults,
7+
searchResultCategories,
8+
SearchResultCategory,
9+
Status,
10+
} from '../../../../src/types';
11+
12+
import {slugify, stripMarkdownLinks} from '../../../../src/utils/various';
13+
14+
import {metadata, MetadataProperties} from '@shopify/polaris-tokens';
15+
import iconMetadata from '@shopify/polaris-icons/metadata';
16+
import components from '../../../../src/data/components.json';
17+
import foundations from '../../../../src/data/foundations.json';
18+
19+
const MAX_RESULTS: {[key in SearchResultCategory]: number} = {
20+
foundations: 8,
21+
components: 6,
22+
tokens: 5,
23+
icons: 9,
24+
};
25+
26+
const getSearchResults = (query: string) => {
27+
if (query.length === 0) return [];
28+
29+
let results: SearchResults = [];
30+
31+
// Add components
32+
components.forEach(({frontMatter: {title, status}, description}) => {
33+
const typedStatus: Status | undefined = status
34+
? {
35+
value: status.value.toLowerCase() as Status['value'],
36+
message: status.message,
37+
}
38+
: undefined;
39+
40+
results.push({
41+
id: slugify(`components ${title}`),
42+
category: 'components',
43+
score: 0,
44+
url: `/components/${slugify(title)}`,
45+
meta: {
46+
components: {
47+
title,
48+
description: stripMarkdownLinks(description),
49+
status: typedStatus,
50+
},
51+
},
52+
});
53+
});
54+
55+
const {colors, depth, font, motion, shape, spacing, zIndex} = metadata;
56+
const tokenGroups = {
57+
colors,
58+
depth,
59+
font,
60+
motion,
61+
shape,
62+
spacing,
63+
zIndex,
64+
};
65+
66+
Object.entries(tokenGroups).forEach(([groupSlug, tokenGroup]) => {
67+
Object.entries(tokenGroup).forEach(
68+
([tokenName, tokenProperties]: [string, MetadataProperties]) => {
69+
results.push({
70+
id: slugify(`tokens ${tokenName}`),
71+
category: 'tokens',
72+
score: 0,
73+
url: `/tokens/${slugify(groupSlug)}#${tokenName}`,
74+
meta: {
75+
tokens: {
76+
category: groupSlug,
77+
token: {
78+
name: tokenName,
79+
description: tokenProperties.description || '',
80+
value: tokenProperties.value,
81+
},
82+
},
83+
},
84+
});
85+
},
86+
);
87+
});
88+
89+
// Add icons
90+
Object.keys(iconMetadata).forEach((fileName) => {
91+
results.push({
92+
id: slugify(`icons ${fileName} ${iconMetadata[fileName].set}`),
93+
category: 'icons',
94+
url: `/icons?icon=${fileName}`,
95+
score: 0,
96+
meta: {
97+
icons: {
98+
icon: iconMetadata[fileName],
99+
},
100+
},
101+
});
102+
});
103+
104+
// Add foundations
105+
foundations.forEach((data) => {
106+
const {title, icon} = data.frontMatter;
107+
const {description, category} = data;
108+
const url = `/foundations/${category}/${slugify(title)}`;
109+
110+
results.push({
111+
id: slugify(`foundations ${title}`),
112+
category: 'foundations',
113+
score: 0,
114+
url,
115+
meta: {
116+
foundations: {
117+
title,
118+
icon: icon || '',
119+
description,
120+
category: category || '',
121+
},
122+
},
123+
});
124+
});
125+
126+
const fuse = new Fuse(results, {
127+
keys: [
128+
// Foundations
129+
{name: 'meta.foundations.title', weight: 100},
130+
{name: 'meta.foundations.description', weight: 50},
131+
132+
// Components
133+
{name: 'meta.components.title', weight: 100},
134+
{name: 'meta.components.description', weight: 50},
135+
136+
// Tokens
137+
{name: 'meta.tokens.token.name', weight: 200},
138+
{name: 'meta.tokens.token.value', weight: 50},
139+
140+
// Icons
141+
{name: 'meta.icons.icon.fileName', weight: 50},
142+
{name: 'meta.icons.icon.name', weight: 50},
143+
{name: 'meta.icons.icon.keywords', weight: 20},
144+
{name: 'meta.icons.icon.set', weight: 20},
145+
{name: 'meta.icons.icon.description', weight: 50},
146+
],
147+
includeScore: true,
148+
threshold: 0.5,
149+
shouldSort: true,
150+
ignoreLocation: true,
151+
});
152+
153+
const groupedResults: GroupedSearchResults = [];
154+
155+
const fuseResults = fuse.search(query);
156+
157+
const scoredResults: SearchResults = fuseResults.map((result) => ({
158+
...result.item,
159+
score: result.score || 0,
160+
}));
161+
162+
searchResultCategories.forEach((category) => {
163+
groupedResults.push({
164+
category,
165+
results: scoredResults
166+
.filter((result) => result.category === category)
167+
.map((result) => ({...result, score: result.score || 0}))
168+
.slice(0, MAX_RESULTS[category]),
169+
});
170+
});
171+
172+
groupedResults.sort(
173+
(a, b) => (a.results[0]?.score || 0) - (b.results[0]?.score || 0),
174+
);
175+
176+
return groupedResults;
177+
};
178+
179+
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
180+
const query = Array.isArray(req.query.q)
181+
? req.query.q.join(' ')
182+
: req.query.q;
183+
184+
const results = {results: getSearchResults(query)};
185+
res.status(200).json(results);
186+
};
187+
188+
export default handler;

polaris.shopify.com/src/components/GlobalSearch/GlobalSearch.tsx

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import {useState, useEffect, createContext, useContext} from 'react';
2-
import {search} from '../../utils/search';
32
import {
43
GroupedSearchResults,
54
SearchResultCategory,
65
SearchResults,
76
} from '../../types';
7+
import {useThrottle} from '../../utils/hooks';
88
import styles from './GlobalSearch.module.scss';
99
import {useRouter} from 'next/router';
1010
import IconGrid from '../IconGrid';
@@ -94,11 +94,19 @@ function GlobalSearch() {
9494
return () => document.removeEventListener('keydown', listener);
9595
}, []);
9696

97-
useEffect(() => {
97+
const throttledSearch = useThrottle(() => {
98+
fetch(`/api/search/v0?q=${encodeURIComponent(searchTerm)}`)
99+
.then((data) => data.json())
100+
.then((json) => {
101+
const {results} = json;
102+
setSearchResults(results);
103+
});
104+
98105
setCurrentResultIndex(0);
99-
setSearchResults(search(searchTerm.trim()));
100106
scrollToTop();
101-
}, [searchTerm]);
107+
}, 400);
108+
109+
useEffect(throttledSearch, [searchTerm, throttledSearch]);
102110

103111
useEffect(() => scrollIntoView(), [currentResultIndex]);
104112

@@ -227,7 +235,7 @@ function SearchResults({
227235
switch (category) {
228236
case 'foundations':
229237
return (
230-
<ResultsGroup category={category}>
238+
<ResultsGroup category={category} key={category}>
231239
<FoundationsGrid>
232240
{results.map(({id, url, meta}) => {
233241
if (!meta.foundations) return null;
@@ -254,7 +262,7 @@ function SearchResults({
254262

255263
case 'components': {
256264
return (
257-
<ResultsGroup category={category}>
265+
<ResultsGroup category={category} key={category}>
258266
<ComponentGrid>
259267
{results.map(({id, url, meta}) => {
260268
if (!meta.components) return null;
@@ -280,7 +288,7 @@ function SearchResults({
280288

281289
case 'tokens': {
282290
return (
283-
<ResultsGroup category={category}>
291+
<ResultsGroup category={category} key={category}>
284292
<TokenList
285293
showTableHeading={false}
286294
columns={{
@@ -310,7 +318,7 @@ function SearchResults({
310318

311319
case 'icons': {
312320
return (
313-
<ResultsGroup category={category}>
321+
<ResultsGroup category={category} key={category}>
314322
<IconGrid>
315323
{results.map(({id, meta}) => {
316324
if (!meta.icons) return null;

polaris.shopify.com/src/utils/hooks.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,28 @@
1-
import React, {useEffect, useState} from 'react';
1+
import React, {useEffect, useRef, useState, useCallback} from 'react';
2+
import throttle from 'lodash.throttle';
23
import {useRouter} from 'next/router';
34

45
import {ParsedUrlQueryInput} from 'querystring';
56

67
const COPY_TO_CLIPBOARD_TIMEOUT = 2000;
78

9+
export const useThrottle = (cb: Function, delay: number) => {
10+
const cbRef = useRef(cb);
11+
12+
useEffect(() => {
13+
cbRef.current = cb;
14+
});
15+
16+
return useCallback(
17+
() =>
18+
throttle((...args) => cbRef.current(...args), delay, {
19+
leading: true,
20+
trailing: true,
21+
}),
22+
[delay],
23+
);
24+
};
25+
826
export const useCopyToClipboard = (stringToCopy: string) => {
927
const [didJustCopy, setDidJustCopy] = useState(false);
1028

0 commit comments

Comments
 (0)