From 6e8bde8f63972b3458dff45a85279fa6647374cf Mon Sep 17 00:00:00 2001 From: "quang.nghiem" Date: Fri, 4 Oct 2024 18:16:50 +0700 Subject: [PATCH 1/4] Add a new search function to filter results based on user input --- src/App.css | 146 ++++++++++++++++++++++++++------ src/App.tsx | 32 ++----- src/components/Input/index.tsx | 107 ++++++++++++++++++++--- src/components/Input/input.scss | 16 ++-- 4 files changed, 230 insertions(+), 71 deletions(-) diff --git a/src/App.css b/src/App.css index b9d355d..56e3d65 100644 --- a/src/App.css +++ b/src/App.css @@ -1,42 +1,134 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; +* { + box-sizing: border-box } -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; +html { + font-size: 16px } -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); + +body { + justify-content: center; +} + +.search-result { + width: 100%; + position: absolute; + border-radius: 4px; + min-height: 100px; + border: solid 1px #ddd; + margin-top: 4px; + max-height: 400px; + overflow-y: auto; +} + +.lists { + display: flex; + flex-direction: column } -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); + +.item { + padding: 1em 2em +} + +.item:hover { + background-color: #eee; + cursor: pointer +} + +.no-result { + font-style: italic; + color: #333; + text-align: center } -@keyframes logo-spin { - from { - transform: rotate(0deg); +.error-message { + color: red; + font-style: italic; + padding: .5em 1em +} + +.loader-container { + height: 50px; + width: 50px; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) scale(.8) +} + +.loader { + animation: rotate 1s infinite; + height: 50px; + width: 50px; + position: absolute +} + +.loader:before, +.loader:after { + border-radius: 50%; + content: ""; + display: block; + height: 20px; + width: 20px +} + +.loader:before { + animation: ball1 1s infinite; + background-color: #ccc; + box-shadow: 30px 0 #333; + margin-bottom: 10px +} + +.loader:after { + animation: ball2 1s infinite; + background-color: #333; + box-shadow: 30px 0 #ccc +} + +@keyframes rotate { + 0% { + transform: rotate(0) scale(.8) + } + + 50% { + transform: rotate(360deg) scale(1.2) } + to { - transform: rotate(360deg); + transform: rotate(720deg) scale(.8) } } -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; +@keyframes ball1 { + 0% { + box-shadow: 30px 0 #333 } -} -.card { - padding: 2em; -} + 50% { + box-shadow: 0 0 #333; + margin-bottom: 0; + transform: translate(15px, 15px) + } -.read-the-docs { - color: #888; + to { + box-shadow: 30px 0 #333; + margin-bottom: 10px + } } + +@keyframes ball2 { + 0% { + box-shadow: 30px 0 #ccc + } + + 50% { + box-shadow: 0 0 #ccc; + margin-top: -20px; + transform: translate(15px, 15px) + } + + to { + box-shadow: 30px 0 #ccc; + margin-top: 0 + } +} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index afe48ac..0e92994 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,34 +1,14 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' import './App.css' +import Input from './components/Input' function App() { - const [count, setCount] = useState(0) + + const handleSelectItem = (item: string) => { + console.log(item) + } return ( - <> -
- - Vite logo - - - React logo - -
-

Vite + React

-
- -

- Edit src/App.tsx and save to test HMR -

-
-

- Click on the Vite and React logos to learn more -

- + ) } diff --git a/src/components/Input/index.tsx b/src/components/Input/index.tsx index 453c742..cb63ad1 100644 --- a/src/components/Input/index.tsx +++ b/src/components/Input/index.tsx @@ -1,23 +1,106 @@ -import "./input.scss"; -import { fetchData } from "../../utils/fetch-data"; -import { debounce } from "../../utils/deboucne"; -import Loader from "../Loader"; +import "./input.scss" +import { useState, useCallback, useRef } from "react" +import { fetchData } from "../../utils/fetch-data" +import { debounce } from "../../utils/deboucne" +import Loader from "../Loader" export interface InputProps { /** Placeholder of the input */ - placeholder?: string; + placeholder?: string /** On click item handler */ - onSelectItem: (item: string) => void; + onSelectItem: (item: string) => void } -const Input = ({ placeholder, onSelectItem }: InputProps) => { +const DEBOUNCE_TIME = 100 +const INITIAL_RESULTS: string[] = [] + +const Input = ({ placeholder, onSelectItem }: InputProps) => { // DO NOT remove this log console.log('input re-render') - // Your code start here - return - // Your code end here -}; +// Your code starts here + + const [searchQuery, setSearchQuery] = useState("") + const [searchResults, setSearchResults] = useState(INITIAL_RESULTS) + const [isLoading, setIsLoading] = useState(false) + const [errorMessage, setErrorMessage] = useState(null) + + // A ref to store the current request id + const latestRequestRef = useRef(0) + const isLatestRequest = (requestId: number) => requestId === latestRequestRef.current; + + const debouncedSearch = useCallback( + debounce(async (query: string) => { + // Increase request id for the new request + const requestId = ++latestRequestRef.current + + try { + if (query) { + const results = await fetchData(query) + // Only update the state if this is the latest request + if (isLatestRequest(requestId)) { + setSearchResults(results) + } + } + } catch (error) { + if (isLatestRequest(requestId)) { + if (typeof error === "string") { + setErrorMessage(error) + } else { + console.log(error) + } + } + } finally { + if (isLatestRequest(requestId)) { + setIsLoading(false) + } + } + }, DEBOUNCE_TIME), + [] + ) -export default Input; + const handleSearch = (e: React.ChangeEvent) => { + const { value } = e.target + setSearchQuery(value) + setIsLoading(true) + setSearchResults(INITIAL_RESULTS) + setErrorMessage(null) + + debouncedSearch(value) + } + + return ( +
+ + + {searchQuery && ( +
+ {isLoading && } + {errorMessage &&

{errorMessage}

} + {searchResults.length === 0 && !isLoading && !errorMessage && ( +

No results found

+ )} +
+ {searchResults.map((result, index) => ( +
onSelectItem(result)} + > + {result} +
+ ))} +
+
+ )} +
+ ) + // Your code ends here +} +export default Input diff --git a/src/components/Input/input.scss b/src/components/Input/input.scss index 1dafbe7..59e38cd 100644 --- a/src/components/Input/input.scss +++ b/src/components/Input/input.scss @@ -1,6 +1,10 @@ -* { - box-sizing: border-box; -} -html{ - font-size: 16px; -} +.input-search-container { + position: relative; + input { + border-radius: 4px; + line-height: 1.5em; + font-size: 16px; + padding: .5em 1em; + width: 100% + } +} \ No newline at end of file From 545492c3d3329cb228fe55af470deef66417fc08 Mon Sep 17 00:00:00 2001 From: "quang.nghiem" Date: Fri, 4 Oct 2024 19:26:00 +0700 Subject: [PATCH 2/4] update input style --- src/components/Input/index.tsx | 5 +++-- src/components/Input/input.scss | 19 ++++++++++++------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/components/Input/index.tsx b/src/components/Input/index.tsx index cb63ad1..9938503 100644 --- a/src/components/Input/index.tsx +++ b/src/components/Input/index.tsx @@ -70,12 +70,13 @@ const Input = ({ placeholder, onSelectItem }: InputProps) => { } return ( -
+
{searchQuery && ( @@ -98,7 +99,7 @@ const Input = ({ placeholder, onSelectItem }: InputProps) => {
)} - + ) // Your code ends here } diff --git a/src/components/Input/input.scss b/src/components/Input/input.scss index 59e38cd..0adf2cc 100644 --- a/src/components/Input/input.scss +++ b/src/components/Input/input.scss @@ -1,10 +1,15 @@ -.input-search-container { +//** variables +$input-text-color: #a3a3a3; + +.form { position: relative; - input { - border-radius: 4px; - line-height: 1.5em; - font-size: 16px; - padding: .5em 1em; - width: 100% + &__field { + width: 360px; + background: #fff; + font: inherit; + border: 1px solid $input-text-color; + box-shadow: 0 6px 10px 0 rgba(0, 0, 0, .1); + outline: 0; + padding: 22px 18px; } } \ No newline at end of file From 390141bb091bfddb92bab76821bbf44499f09fc3 Mon Sep 17 00:00:00 2001 From: "quang.nghiem" Date: Fri, 4 Oct 2024 20:05:14 +0700 Subject: [PATCH 3/4] trigger build --- src/components/Input/input.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Input/input.scss b/src/components/Input/input.scss index 0adf2cc..c591652 100644 --- a/src/components/Input/input.scss +++ b/src/components/Input/input.scss @@ -12,4 +12,4 @@ $input-text-color: #a3a3a3; outline: 0; padding: 22px 18px; } -} \ No newline at end of file +} From 93f5448e87b195cc45995ac49775b86875b90385 Mon Sep 17 00:00:00 2001 From: "quang.nghiem" Date: Mon, 7 Oct 2024 23:24:05 +0700 Subject: [PATCH 4/4] fix re-render Component --- src/components/Input/SearchResults.tsx | 39 +++++++++ src/components/Input/index.tsx | 107 +++++++++---------------- src/components/Input/inputReducer.tsx | 34 ++++++++ 3 files changed, 111 insertions(+), 69 deletions(-) create mode 100644 src/components/Input/SearchResults.tsx create mode 100644 src/components/Input/inputReducer.tsx diff --git a/src/components/Input/SearchResults.tsx b/src/components/Input/SearchResults.tsx new file mode 100644 index 0000000..42047c0 --- /dev/null +++ b/src/components/Input/SearchResults.tsx @@ -0,0 +1,39 @@ +import React from "react" +import Loader from "../Loader" + +interface SearchResultsProps { + isLoading: boolean + errorMessage: string | null + searchResults: string[] + onSelectItem: (item: string) => void +} + +const SearchResults: React.FC = ({ + isLoading, + errorMessage, + searchResults, + onSelectItem +}) => { + return ( +
+ {isLoading && } + {errorMessage &&

{errorMessage}

} + {searchResults.length === 0 && !isLoading && !errorMessage && ( +

No results found

+ )} +
+ {searchResults.map((result, index) => ( +
onSelectItem(result)} + > + {result} +
+ ))} +
+
+ ) +} + +export default SearchResults \ No newline at end of file diff --git a/src/components/Input/index.tsx b/src/components/Input/index.tsx index 9938503..949e2ef 100644 --- a/src/components/Input/index.tsx +++ b/src/components/Input/index.tsx @@ -1,103 +1,72 @@ import "./input.scss" -import { useState, useCallback, useRef } from "react" +import { useReducer, useEffect, useCallback } from "react" import { fetchData } from "../../utils/fetch-data" import { debounce } from "../../utils/deboucne" -import Loader from "../Loader" - +import SearchResults from "./SearchResults" +import { reducer, initialState } from "./inputReducer" export interface InputProps { /** Placeholder of the input */ placeholder?: string /** On click item handler */ onSelectItem: (item: string) => void } - -const DEBOUNCE_TIME = 100 -const INITIAL_RESULTS: string[] = [] - -const Input = ({ placeholder, onSelectItem }: InputProps) => { +const DEBOUNCE_TIME = 500 +const Input = ({ placeholder, onSelectItem }: InputProps) => { // DO NOT remove this log console.log('input re-render') -// Your code starts here - - const [searchQuery, setSearchQuery] = useState("") - const [searchResults, setSearchResults] = useState(INITIAL_RESULTS) - const [isLoading, setIsLoading] = useState(false) - const [errorMessage, setErrorMessage] = useState(null) - - // A ref to store the current request id - const latestRequestRef = useRef(0) - const isLatestRequest = (requestId: number) => requestId === latestRequestRef.current; + // Your code starts here + const [state, dispatch] = useReducer(reducer, initialState) + const { searchQuery, searchResults, isLoading, errorMessage } = state - const debouncedSearch = useCallback( - debounce(async (query: string) => { - // Increase request id for the new request - const requestId = ++latestRequestRef.current + useEffect(() => { + if (!searchQuery) { + return + } + const startFetching = async () => { try { - if (query) { - const results = await fetchData(query) - // Only update the state if this is the latest request - if (isLatestRequest(requestId)) { - setSearchResults(results) - } + const results = await fetchData(searchQuery) + if (!ignore) { + dispatch({ type: "SET_SEARCH_RESULTS", payload: results }) } - } catch (error) { - if (isLatestRequest(requestId)) { - if (typeof error === "string") { - setErrorMessage(error) - } else { - console.log(error) - } - } - } finally { - if (isLatestRequest(requestId)) { - setIsLoading(false) + } catch (error: any) { + if (!ignore) { + dispatch({ type: "SET_ERROR", payload: error.message || "An error occurred" }) } } + } + let ignore = false + startFetching() + + return () => { + ignore = true + } + }, [searchQuery]) + + const handleSearch = useCallback( + debounce((value: string) => { + dispatch({ type: "SET_SEARCH_QUERY", payload: value }) }, DEBOUNCE_TIME), [] ) - const handleSearch = (e: React.ChangeEvent) => { - const { value } = e.target - setSearchQuery(value) - setIsLoading(true) - setSearchResults(INITIAL_RESULTS) - setErrorMessage(null) - - debouncedSearch(value) - } - return (
handleSearch(e.target.value)} className="form__field" /> {searchQuery && ( -
- {isLoading && } - {errorMessage &&

{errorMessage}

} - {searchResults.length === 0 && !isLoading && !errorMessage && ( -

No results found

- )} -
- {searchResults.map((result, index) => ( -
onSelectItem(result)} - > - {result} -
- ))} -
-
+ )} ) diff --git a/src/components/Input/inputReducer.tsx b/src/components/Input/inputReducer.tsx new file mode 100644 index 0000000..17e3eff --- /dev/null +++ b/src/components/Input/inputReducer.tsx @@ -0,0 +1,34 @@ +type Action = + | { type: "SET_SEARCH_QUERY"; payload: string } + | { type: "SET_SEARCH_RESULTS"; payload: string[] } + | { type: "SET_LOADING"; payload: boolean } + | { type: "SET_ERROR"; payload: string | null } + +export interface State { + searchQuery: string + searchResults: string[] + isLoading: boolean + errorMessage: string | null +} + +export const initialState: State = { + searchQuery: "", + searchResults: [], + isLoading: false, + errorMessage: null, +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "SET_SEARCH_QUERY": + return { ...state, searchQuery: action.payload, searchResults: [], errorMessage: null, isLoading: true } + case "SET_SEARCH_RESULTS": + return { ...state, searchResults: action.payload, isLoading: false } + case "SET_LOADING": + return { ...state, isLoading: action.payload } + case "SET_ERROR": + return { ...state, errorMessage: action.payload, isLoading: false } + default: + return state + } +} \ No newline at end of file