@cn-npm/search-autocomplete 0.2.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.babelrc +3 -0
- package/.storybook/main.js +26 -0
- package/.storybook/preview.js +24 -0
- package/README.md +69 -0
- package/dist/App.d.ts +4 -0
- package/dist/bundle.js +406 -0
- package/dist/components/ErrorBoundary/ErrorBoundary.d.ts +10 -0
- package/dist/components/Loader/Loader.d.ts +6 -0
- package/dist/components/SearchAutocomplete/SearchAutocomplete.d.ts +7 -0
- package/dist/components/SearchAutocompleteSection/SearchAutocompleteSection.d.ts +9 -0
- package/dist/components/SearchAutocompleteTag/SearchAutocompleteTag.d.ts +10 -0
- package/dist/components/index.d.ts +3 -0
- package/dist/index.d.ts +1 -0
- package/dist/reportWebVitals.d.ts +3 -0
- package/dist/search-result.d.ts +42 -0
- package/dist/setupTests.d.ts +1 -0
- package/generate-react-cli.json +15 -0
- package/package.json +110 -0
- package/postcss.config.js +6 -0
- package/public/favicon.ico +0 -0
- package/public/index.html +43 -0
- package/public/logo192.png +0 -0
- package/public/logo512.png +0 -0
- package/public/manifest.json +25 -0
- package/public/robots.txt +3 -0
- package/rollup.config.js +35 -0
- package/src/App.scss +0 -0
- package/src/App.tsx +13 -0
- package/src/assets/img/circle-close.svg +12 -0
- package/src/assets/img/search.svg +18 -0
- package/src/components/ErrorBoundary/ErrorBoundary.module.scss +1 -0
- package/src/components/ErrorBoundary/ErrorBoundary.tsx +33 -0
- package/src/components/Loader/Loader.module.scss +21 -0
- package/src/components/Loader/Loader.stories.tsx +12 -0
- package/src/components/Loader/Loader.test.tsx +12 -0
- package/src/components/Loader/Loader.tsx +18 -0
- package/src/components/SearchAutocomplete/SearchAutocomplete.module.scss +3 -0
- package/src/components/SearchAutocomplete/SearchAutocomplete.stories.tsx +41 -0
- package/src/components/SearchAutocomplete/SearchAutocomplete.test.tsx +86 -0
- package/src/components/SearchAutocomplete/SearchAutocomplete.tsx +310 -0
- package/src/components/SearchAutocompleteSection/SearchAutocompleteSection.module.scss +64 -0
- package/src/components/SearchAutocompleteSection/SearchAutocompleteSection.test.tsx +54 -0
- package/src/components/SearchAutocompleteSection/SearchAutocompleteSection.tsx +74 -0
- package/src/components/SearchAutocompleteTag/SearchAutocompleteTag.module.scss +6 -0
- package/src/components/SearchAutocompleteTag/SearchAutocompleteTag.test.tsx +52 -0
- package/src/components/SearchAutocompleteTag/SearchAutocompleteTag.tsx +67 -0
- package/src/components/index.tsx +4 -0
- package/src/index.css +320 -0
- package/src/index.tsx +19 -0
- package/src/react-app-env.d.ts +1 -0
- package/src/reportWebVitals.ts +15 -0
- package/src/search-result.ts +49 -0
- package/src/setupTests.ts +5 -0
- package/src/stories/Introduction.stories.mdx +211 -0
- package/src/stories/assets/code-brackets.svg +1 -0
- package/src/stories/assets/colors.svg +1 -0
- package/src/stories/assets/comments.svg +1 -0
- package/src/stories/assets/direction.svg +1 -0
- package/src/stories/assets/flow.svg +1 -0
- package/src/stories/assets/plugin.svg +1 -0
- package/src/stories/assets/repo.svg +1 -0
- package/src/stories/assets/stackalt.svg +1 -0
- package/tailwind.config.js +93 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import React, { FC, useState } from "react";
|
|
2
|
+
import styles from "./SearchAutocomplete.module.scss";
|
|
3
|
+
import { DebounceInput } from "react-debounce-input";
|
|
4
|
+
import SearchAutocompleteSection from "../SearchAutocompleteSection/SearchAutocompleteSection";
|
|
5
|
+
import classNames from "classnames";
|
|
6
|
+
import CSSTransition from "react-transition-group/CSSTransition";
|
|
7
|
+
import axios from "axios";
|
|
8
|
+
import { SearchResponse } from "../../search-result";
|
|
9
|
+
import Loader from "../Loader/Loader";
|
|
10
|
+
import { ReactComponent as SearchIcon } from "../../assets/img/search.svg";
|
|
11
|
+
import { ReactComponent as CircleCloseIcon } from "../../assets/img/circle-close.svg";
|
|
12
|
+
import ErrorBoundary from "../ErrorBoundary/ErrorBoundary";
|
|
13
|
+
|
|
14
|
+
interface SearchAutocompleteProps {
|
|
15
|
+
label?: string;
|
|
16
|
+
open?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const SearchAutocomplete: FC<SearchAutocompleteProps> = (props) => {
|
|
20
|
+
// Set state variables
|
|
21
|
+
const [open, setOpen] = useState(props.open || false);
|
|
22
|
+
const [label, setLabel] = useState(
|
|
23
|
+
props.label || "Search by Charity or Cause"
|
|
24
|
+
);
|
|
25
|
+
const [loading, setLoading] = useState<boolean>(false);
|
|
26
|
+
const [searchTerm, setSearchTerm] = useState("");
|
|
27
|
+
const [searchData, setSearchData] = useState<SearchResponse>({
|
|
28
|
+
data: {
|
|
29
|
+
searchAutocomplete: [],
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
const [hasError, setHasError] = useState<boolean>(false);
|
|
33
|
+
const [selectedTag, setSelectedTag] = useState<number>(0);
|
|
34
|
+
|
|
35
|
+
// On selected tag change
|
|
36
|
+
React.useEffect(() => {
|
|
37
|
+
const tags = document.querySelectorAll(
|
|
38
|
+
".autocomplete-tag, .autocomplete-custom-search"
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
tags.forEach((x, i) => {
|
|
42
|
+
x.classList.remove(
|
|
43
|
+
"autocomplete-selected",
|
|
44
|
+
"!bg-grey-100",
|
|
45
|
+
"!ring-blue-600"
|
|
46
|
+
);
|
|
47
|
+
if (i + 1 === selectedTag) {
|
|
48
|
+
x.classList.add(
|
|
49
|
+
"autocomplete-selected",
|
|
50
|
+
tags[i].classList.contains("autocomplete-tag")
|
|
51
|
+
? "!bg-grey-100"
|
|
52
|
+
: "none",
|
|
53
|
+
"!ring-blue-600"
|
|
54
|
+
);
|
|
55
|
+
x.scrollIntoView({
|
|
56
|
+
behavior: "smooth",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}, [selectedTag]);
|
|
61
|
+
|
|
62
|
+
// On open props change
|
|
63
|
+
React.useEffect(() => {
|
|
64
|
+
setOpen(props.open || false);
|
|
65
|
+
}, [props.open]);
|
|
66
|
+
|
|
67
|
+
// On open change
|
|
68
|
+
React.useEffect(() => {
|
|
69
|
+
if (!open) {
|
|
70
|
+
setSelectedTag(0);
|
|
71
|
+
}
|
|
72
|
+
}, [open]);
|
|
73
|
+
|
|
74
|
+
// On label change
|
|
75
|
+
React.useEffect(() => {
|
|
76
|
+
setLabel(props.label || "Search by Charity or Cause");
|
|
77
|
+
}, [props.label]);
|
|
78
|
+
|
|
79
|
+
// On search term change
|
|
80
|
+
React.useEffect(() => {
|
|
81
|
+
setLoading(true);
|
|
82
|
+
|
|
83
|
+
setSelectedTag(0);
|
|
84
|
+
|
|
85
|
+
// Trigger custom event for analytics
|
|
86
|
+
if (searchTerm) {
|
|
87
|
+
// Create custom event
|
|
88
|
+
var event = new CustomEvent("autocomplete-search", {
|
|
89
|
+
detail: {
|
|
90
|
+
name: searchTerm,
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Trigger custom event
|
|
95
|
+
document.dispatchEvent(event);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const controller = new AbortController();
|
|
99
|
+
|
|
100
|
+
// Get results from API
|
|
101
|
+
axios({
|
|
102
|
+
signal: controller.signal,
|
|
103
|
+
url: `https://graph.charitynavigator.org/graphql`,
|
|
104
|
+
params: {
|
|
105
|
+
user_key: "67a233b3ae2f5690bce775c1760925f2",
|
|
106
|
+
},
|
|
107
|
+
method: "post",
|
|
108
|
+
data: {
|
|
109
|
+
query: `
|
|
110
|
+
query SearchQuery {
|
|
111
|
+
searchAutocomplete(term: "${searchTerm}") {
|
|
112
|
+
title
|
|
113
|
+
results {
|
|
114
|
+
ein
|
|
115
|
+
title
|
|
116
|
+
url
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
`,
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
.then((res) => {
|
|
124
|
+
const data: SearchResponse = res.data;
|
|
125
|
+
setSearchData(data);
|
|
126
|
+
setHasError(false);
|
|
127
|
+
setLoading(false);
|
|
128
|
+
})
|
|
129
|
+
.catch((e: any) => {
|
|
130
|
+
setSearchData({} as SearchResponse);
|
|
131
|
+
setHasError(true);
|
|
132
|
+
setLoading(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return () => {
|
|
136
|
+
controller.abort();
|
|
137
|
+
};
|
|
138
|
+
}, [searchTerm]);
|
|
139
|
+
|
|
140
|
+
// Clear search
|
|
141
|
+
const clearSearch = (e: any) => {
|
|
142
|
+
e.preventDefault();
|
|
143
|
+
setSearchTerm("");
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// Handle key events
|
|
147
|
+
const keyDownHandler = (e: any) => {
|
|
148
|
+
const tags = document.querySelectorAll(
|
|
149
|
+
".autocomplete-tag, .autocomplete-custom-search"
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
switch (e.key) {
|
|
153
|
+
case "ArrowUp": {
|
|
154
|
+
setSelectedTag(Math.max(0, selectedTag - 1));
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
case "ArrowDown": {
|
|
158
|
+
setOpen(true);
|
|
159
|
+
setSelectedTag(Math.min(tags.length, selectedTag + 1));
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
case "ArrowLeft": {
|
|
163
|
+
if (selectedTag > 0) {
|
|
164
|
+
setSelectedTag(Math.max(0, selectedTag - 1));
|
|
165
|
+
}
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
case "ArrowRight": {
|
|
169
|
+
setOpen(true);
|
|
170
|
+
if (selectedTag > 0) {
|
|
171
|
+
setSelectedTag(Math.min(tags.length, selectedTag + 1));
|
|
172
|
+
}
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
case "Tab": {
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
case "Enter": {
|
|
179
|
+
if (selectedTag > 0) {
|
|
180
|
+
e.preventDefault();
|
|
181
|
+
(tags[selectedTag - 1] as any).click();
|
|
182
|
+
}
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
case "Space": {
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
case "Escape": {
|
|
189
|
+
setOpen(false);
|
|
190
|
+
setSelectedTag(0);
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
case "Home": {
|
|
194
|
+
setSelectedTag(1);
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
case "End": {
|
|
198
|
+
setSelectedTag(tags.length);
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<div
|
|
206
|
+
className={classNames(styles.SearchAutocomplete, "autocomplete")}
|
|
207
|
+
data-testid="SearchAutocomplete"
|
|
208
|
+
>
|
|
209
|
+
<form
|
|
210
|
+
action={`/search?q=${searchTerm}`}
|
|
211
|
+
className="flex font-sofia-pro"
|
|
212
|
+
autoComplete="off"
|
|
213
|
+
>
|
|
214
|
+
<label htmlFor="search" className="hidden">
|
|
215
|
+
{label}
|
|
216
|
+
</label>
|
|
217
|
+
<div className="relative flex flex-grow">
|
|
218
|
+
<DebounceInput
|
|
219
|
+
type="text"
|
|
220
|
+
name="q"
|
|
221
|
+
id="search"
|
|
222
|
+
placeholder={label}
|
|
223
|
+
className="flex-auto pr-0 items-stretch flex-grow text-night-sky-800 ring-0 py-0 shadow-none focus:ring-0 bg-white transition-colors border-opacity-100 hover:bg-grey-100 hover:border-grey-500 hover:border-opacity-50 focus:bg-grey-100 focus:border-blue-400 inline-block sm:text-sm border-grey-500 rounded-md rounded-tr-none rounded-br-none placeholder:text-gray-600"
|
|
224
|
+
debounceTimeout={300}
|
|
225
|
+
minLength={2}
|
|
226
|
+
value={searchTerm}
|
|
227
|
+
onChange={(e) => setSearchTerm(e.target.value)}
|
|
228
|
+
onClick={() => setOpen(true)}
|
|
229
|
+
onFocus={() => setOpen(true)}
|
|
230
|
+
onBlur={() => setOpen(false)}
|
|
231
|
+
onKeyDown={(e) => keyDownHandler(e)}
|
|
232
|
+
/>
|
|
233
|
+
{searchTerm !== "" && (
|
|
234
|
+
<button
|
|
235
|
+
type="button"
|
|
236
|
+
className="absolute right-3 top-[50%] -translate-y-[50%] text-night-sky-800 hover:text-blue-500 transition-colors !bg-[transparent] cursor-pointer"
|
|
237
|
+
onClick={clearSearch}
|
|
238
|
+
>
|
|
239
|
+
<CircleCloseIcon className="fill-night-sky-800 hover:fill-blue-500 h-4 w-4" />
|
|
240
|
+
</button>
|
|
241
|
+
)}
|
|
242
|
+
</div>
|
|
243
|
+
<button
|
|
244
|
+
type="submit"
|
|
245
|
+
className="inline-flex transition-colors items-center justify-center px-4 h-[38px] font-medium rounded-md shadow-sm border border-blue-500 text-white bg-blue-500 hover:bg-blue-600 hover:border-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:text-sm rounded-tl-none rounded-bl-none"
|
|
246
|
+
>
|
|
247
|
+
<SearchIcon className="fill-white h-4 w-4" />
|
|
248
|
+
</button>
|
|
249
|
+
</form>
|
|
250
|
+
<div className="relative">
|
|
251
|
+
<CSSTransition
|
|
252
|
+
mountOnEnter
|
|
253
|
+
unmountOnExit
|
|
254
|
+
in={open}
|
|
255
|
+
timeout={300}
|
|
256
|
+
classNames="fade-slide"
|
|
257
|
+
data-testid="searchResults"
|
|
258
|
+
>
|
|
259
|
+
<div
|
|
260
|
+
className={classNames(
|
|
261
|
+
"search-results-container absolute top-0 left-1/2 -translate-x-1/2 mt-2 bg-white py-5 rounded-[1.25rem] z-50 shadow-lg border border-[#a6abbd] border-opacity-60 w-full tablet:w-[70vw] min-h-[8rem] overflow-hidden"
|
|
262
|
+
)}
|
|
263
|
+
>
|
|
264
|
+
<ErrorBoundary>
|
|
265
|
+
{loading && (
|
|
266
|
+
<Loader
|
|
267
|
+
data-testid="Loader"
|
|
268
|
+
className="absolute top-5 right-5"
|
|
269
|
+
/>
|
|
270
|
+
)}
|
|
271
|
+
<div
|
|
272
|
+
className="px-6 h-full mb-12 max-h-[70vh] overflow-y-auto"
|
|
273
|
+
data-testid="Sections"
|
|
274
|
+
>
|
|
275
|
+
{hasError && (
|
|
276
|
+
<p>
|
|
277
|
+
Sorry, there was a problem. Please try your search again.
|
|
278
|
+
</p>
|
|
279
|
+
)}
|
|
280
|
+
{searchData.data &&
|
|
281
|
+
searchData.data.searchAutocomplete &&
|
|
282
|
+
searchData.data.searchAutocomplete!.map((section) => (
|
|
283
|
+
<SearchAutocompleteSection
|
|
284
|
+
key={section.title}
|
|
285
|
+
searchTerm={searchTerm}
|
|
286
|
+
title={section.title}
|
|
287
|
+
results={section.results}
|
|
288
|
+
/>
|
|
289
|
+
))}
|
|
290
|
+
</div>
|
|
291
|
+
</ErrorBoundary>
|
|
292
|
+
<div className="border-t border-[#a6abbd] border-opacity-60 flex items-center absolute bottom-0 left-0 w-full bg-white h-16">
|
|
293
|
+
<a
|
|
294
|
+
href={`/search?q=${searchTerm}`}
|
|
295
|
+
className="px-3 py-1 ml-6 flex items-center space-x-3 ring ring-1 ring-offset-2 ring-transparent transition-colors text-blue-500 hover:text-blue-600 rounded-lg autocomplete-custom-search"
|
|
296
|
+
>
|
|
297
|
+
<SearchIcon className="fill-blue-500 hover:fill-blue-600 h-4 w-4" />
|
|
298
|
+
<span className="font-semibold text-[1.125rem] ">
|
|
299
|
+
{searchTerm === "" ? "Custom Search" : "View All Results"}
|
|
300
|
+
</span>
|
|
301
|
+
</a>
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
</CSSTransition>
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
);
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
export default SearchAutocomplete;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
.SearchAutocompleteSection {
|
|
2
|
+
@apply font-sofia-pro mb-6;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
// Hide results over 5 on mobile
|
|
6
|
+
:global(.autocomplete-section a:nth-child(6)) {
|
|
7
|
+
@apply hidden tablet:inline-block;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
:global(.autocomplete-section a:nth-child(7)) {
|
|
11
|
+
@apply hidden tablet:inline-block;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
:global(.autocomplete-section a:nth-child(8)) {
|
|
15
|
+
@apply hidden tablet:inline-block;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
:global(.autocomplete-section a:nth-child(9)) {
|
|
19
|
+
@apply hidden tablet:inline-block;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
:global(.autocomplete-section a:nth-child(10)) {
|
|
23
|
+
@apply hidden tablet:inline-block;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
:global(.autocomplete-section a:nth-child(11)) {
|
|
27
|
+
@apply hidden tablet:inline-block;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
:global(.autocomplete-section a:nth-child(12)) {
|
|
31
|
+
@apply hidden tablet:inline-block;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
:global(.autocomplete-section a:nth-child(13)) {
|
|
35
|
+
@apply hidden tablet:inline-block;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
:global(.autocomplete-section a:nth-child(14)) {
|
|
39
|
+
@apply hidden tablet:inline-block;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
:global(.autocomplete-section a:nth-child(15)) {
|
|
43
|
+
@apply hidden tablet:inline-block;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
:global(.autocomplete-section a:nth-child(16)) {
|
|
47
|
+
@apply hidden tablet:inline-block;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
:global(.autocomplete-section a:nth-child(17)) {
|
|
51
|
+
@apply hidden tablet:inline-block;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
:global(.autocomplete-section a:nth-child(18)) {
|
|
55
|
+
@apply hidden tablet:inline-block;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
:global(.autocomplete-section a:nth-child(19)) {
|
|
59
|
+
@apply hidden tablet:inline-block;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
:global(.autocomplete-section a:nth-child(20)) {
|
|
63
|
+
@apply hidden tablet:inline-block;
|
|
64
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import "@testing-library/jest-dom/extend-expect";
|
|
4
|
+
import SearchAutocompleteSection, {
|
|
5
|
+
SearchAutocompleteSectionProps,
|
|
6
|
+
} from "./SearchAutocompleteSection";
|
|
7
|
+
|
|
8
|
+
// Search autocomplete section test block
|
|
9
|
+
describe("<SearchAutocompleteSection />", () => {
|
|
10
|
+
// Set component props
|
|
11
|
+
const props: SearchAutocompleteSectionProps = {
|
|
12
|
+
searchTerm: "Test",
|
|
13
|
+
title: "test",
|
|
14
|
+
results: [
|
|
15
|
+
{
|
|
16
|
+
title: "Tag 1",
|
|
17
|
+
url: "http://yahoo.com",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
title: "Tag 2",
|
|
21
|
+
url: "http://google.com",
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Component should mount
|
|
27
|
+
test("it should mount", async () => {
|
|
28
|
+
await render(<SearchAutocompleteSection {...props} />);
|
|
29
|
+
const searchAutocompleteSection = screen.getByTestId(
|
|
30
|
+
"searchAutocompleteSection"
|
|
31
|
+
);
|
|
32
|
+
expect(searchAutocompleteSection).toBeInTheDocument();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Component should have correct title
|
|
36
|
+
test("it should have correct title", async () => {
|
|
37
|
+
await render(<SearchAutocompleteSection {...props} />);
|
|
38
|
+
const searchResultTitle = screen.getByTestId("searchResultTitle");
|
|
39
|
+
expect(searchResultTitle.textContent).toBe("test");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Component should have a tag
|
|
43
|
+
test("it should have a tag", async () => {
|
|
44
|
+
await render(<SearchAutocompleteSection {...props} />);
|
|
45
|
+
const searchTag = screen.getByTestId("searchTag");
|
|
46
|
+
expect(searchTag).toBeInTheDocument();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Component should have correct tags
|
|
50
|
+
test("it should have correct tags", async () => {
|
|
51
|
+
await render(<SearchAutocompleteSection {...props} />);
|
|
52
|
+
expect(screen.getAllByRole("link").length).toBe(2);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import classNames from "classnames";
|
|
2
|
+
import React, { FC } from "react";
|
|
3
|
+
import { Result } from "../../search-result";
|
|
4
|
+
import SearchAutocompleteTag from "../SearchAutocompleteTag/SearchAutocompleteTag";
|
|
5
|
+
import styles from "./SearchAutocompleteSection.module.scss";
|
|
6
|
+
|
|
7
|
+
export interface SearchAutocompleteSectionProps {
|
|
8
|
+
searchTerm: string;
|
|
9
|
+
title?: string;
|
|
10
|
+
results?: Result[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Custom limits based on section
|
|
14
|
+
const resultsLimit = (title: string) => {
|
|
15
|
+
switch (title.toLowerCase()) {
|
|
16
|
+
case "where to give now": {
|
|
17
|
+
return 3;
|
|
18
|
+
}
|
|
19
|
+
case "organizations": {
|
|
20
|
+
return 10;
|
|
21
|
+
}
|
|
22
|
+
case "causes": {
|
|
23
|
+
return 5;
|
|
24
|
+
}
|
|
25
|
+
default: {
|
|
26
|
+
return 20;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const SearchAutocompleteSection: FC<SearchAutocompleteSectionProps> = (
|
|
32
|
+
props
|
|
33
|
+
) => (
|
|
34
|
+
<div
|
|
35
|
+
className={classNames(
|
|
36
|
+
styles.SearchAutocompleteSection,
|
|
37
|
+
"autocomplete-section"
|
|
38
|
+
)}
|
|
39
|
+
data-testid="searchAutocompleteSection"
|
|
40
|
+
>
|
|
41
|
+
<h2
|
|
42
|
+
className="text-night-sky-800 mb-2 text-base"
|
|
43
|
+
data-testid="searchResultTitle"
|
|
44
|
+
>
|
|
45
|
+
{props.title}
|
|
46
|
+
</h2>
|
|
47
|
+
<div className="flow-root">
|
|
48
|
+
<div
|
|
49
|
+
className="-m-1 flex flex-wrap autocomplete-section"
|
|
50
|
+
data-testid="searchTag"
|
|
51
|
+
>
|
|
52
|
+
{(props.results == null || props.results.length === 0) && (
|
|
53
|
+
<p className="pl-3">No results found</p>
|
|
54
|
+
)}
|
|
55
|
+
{props.results &&
|
|
56
|
+
props.results!.length > 0 &&
|
|
57
|
+
props.results
|
|
58
|
+
?.slice(0, resultsLimit(props.title || ""))
|
|
59
|
+
.map((results) => (
|
|
60
|
+
<SearchAutocompleteTag
|
|
61
|
+
key={results.url}
|
|
62
|
+
section={props.title || ""}
|
|
63
|
+
searchTerm={props.searchTerm}
|
|
64
|
+
ein={results.ein}
|
|
65
|
+
name={results.title}
|
|
66
|
+
url={results.url}
|
|
67
|
+
/>
|
|
68
|
+
))}
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
export default SearchAutocompleteSection;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
.SearchAutocompleteTag {
|
|
2
|
+
@apply inline-block font-sofia-pro font-normal text-base leading-6 tracking-normal text-left transition-colors text-night-sky-800 bg-grey-50 hover:bg-grey-100 rounded-lg pt-2 pb-2 pl-3 pr-3 m-1 ring ring-1 ring-offset-2 ring-transparent;
|
|
3
|
+
}
|
|
4
|
+
:global(.highlighted-text) {
|
|
5
|
+
@apply font-semibold;
|
|
6
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
import "@testing-library/jest-dom/extend-expect";
|
|
4
|
+
import SearchAutocompleteTag, {
|
|
5
|
+
SearchAutocompleteTagProps,
|
|
6
|
+
} from "./SearchAutocompleteTag";
|
|
7
|
+
|
|
8
|
+
// Search autocomplete tag test block
|
|
9
|
+
describe("<SearchAutocompleteTag />", () => {
|
|
10
|
+
// Set component props
|
|
11
|
+
const props: SearchAutocompleteTagProps = {
|
|
12
|
+
section: "Test",
|
|
13
|
+
searchTerm: "Tes",
|
|
14
|
+
name: "Test",
|
|
15
|
+
url: "http://google.com",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Component should mount
|
|
19
|
+
test("it should mount", async () => {
|
|
20
|
+
await render(<SearchAutocompleteTag {...props} />);
|
|
21
|
+
const searchAutocompleteTag = screen.getByTestId("searchAutocompleteTag");
|
|
22
|
+
expect(searchAutocompleteTag).toBeInTheDocument();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Component should have correct name
|
|
26
|
+
test("it should have correct name", async () => {
|
|
27
|
+
await render(<SearchAutocompleteTag {...props} />);
|
|
28
|
+
const searchAutocompleteTag = screen.getByTestId("searchAutocompleteTag");
|
|
29
|
+
expect(searchAutocompleteTag.textContent).toBe("Test");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Component should have correct url
|
|
33
|
+
test("it should have correct url", async () => {
|
|
34
|
+
await render(<SearchAutocompleteTag {...props} />);
|
|
35
|
+
const searchAutocompleteTag = screen.getByTestId("searchAutocompleteTag");
|
|
36
|
+
expect(searchAutocompleteTag.getAttribute("href")).toBe(
|
|
37
|
+
"http://google.com"
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Component should have correct highlighted text
|
|
42
|
+
test("it should have correct highlighted text", async () => {
|
|
43
|
+
await render(<SearchAutocompleteTag {...props} />);
|
|
44
|
+
const highlightedText = screen.getByText("Tes");
|
|
45
|
+
expect(highlightedText).toHaveClass("highlighted-text");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Component should trigger tracking event
|
|
49
|
+
test("it should trigger tracking event", async () => {
|
|
50
|
+
// TODO: Write test for tracking event
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import React, { FC } from "react";
|
|
2
|
+
import styles from "./SearchAutocompleteTag.module.scss";
|
|
3
|
+
import Highlighter from "react-highlight-words";
|
|
4
|
+
import classNames from "classnames";
|
|
5
|
+
|
|
6
|
+
export interface SearchAutocompleteTagProps {
|
|
7
|
+
section: string;
|
|
8
|
+
searchTerm: string;
|
|
9
|
+
ein?: string;
|
|
10
|
+
name: string;
|
|
11
|
+
url?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const Highlight = ({ children, highlightIndex }: any) => (
|
|
15
|
+
<span className="highlighted-text">{children}</span>
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
const SearchAutocompleteTag: FC<SearchAutocompleteTagProps> = (props) => {
|
|
19
|
+
// Custom event trigger
|
|
20
|
+
const triggerCustomEvent = (e: any) => {
|
|
21
|
+
e.preventDefault();
|
|
22
|
+
|
|
23
|
+
// Create custom event
|
|
24
|
+
var event = new CustomEvent("autocomplete-term-clicked", {
|
|
25
|
+
detail: {
|
|
26
|
+
name: props.name,
|
|
27
|
+
searchTerm: props.searchTerm,
|
|
28
|
+
section: props.section,
|
|
29
|
+
url: props.url,
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Trigger custom event
|
|
34
|
+
document.dispatchEvent(event);
|
|
35
|
+
|
|
36
|
+
if (props.url) {
|
|
37
|
+
window.location.href = props.url;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<a
|
|
43
|
+
key={props.ein}
|
|
44
|
+
href={props.url}
|
|
45
|
+
className={classNames(
|
|
46
|
+
styles.SearchAutocompleteTag,
|
|
47
|
+
"autocomplete-tag"
|
|
48
|
+
// ? 'bg-grey-100' : ''
|
|
49
|
+
)}
|
|
50
|
+
data-section={props.section}
|
|
51
|
+
data-testid="searchAutocompleteTag"
|
|
52
|
+
onClick={(e) => triggerCustomEvent(e)}
|
|
53
|
+
>
|
|
54
|
+
{props.searchTerm === "" ? (
|
|
55
|
+
<span className="highlighted-text">{props.name}</span>
|
|
56
|
+
) : (
|
|
57
|
+
<Highlighter
|
|
58
|
+
highlightTag={Highlight}
|
|
59
|
+
searchWords={[props.searchTerm]}
|
|
60
|
+
textToHighlight={props.name}
|
|
61
|
+
/>
|
|
62
|
+
)}
|
|
63
|
+
</a>
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export default SearchAutocompleteTag;
|