From 96560d114c81317d018cabcc65c3ace573103669 Mon Sep 17 00:00:00 2001 From: John Ballesteros Date: Thu, 26 Sep 2024 10:51:27 +0800 Subject: [PATCH] refactor: add search components --- src/app/search/page.tsx | 1 + src/search/components/search-category.tsx | 54 +++++++++++++++ src/search/components/search-input.tsx | 67 +++++++++++++++++++ src/search/components/search-result.tsx | 36 ++++++++++ .../components/search-results-skeleton.tsx | 29 ++++++++ src/search/components/search-results.tsx | 26 +++++++ src/search/core/atoms.ts | 3 + src/search/core/query-keys.ts | 6 ++ src/search/core/schemas.ts | 16 +++++ src/search/data/search.ts | 6 ++ src/search/hooks/use-search-input.ts | 19 ++++++ src/search/hooks/use-search-results.ts | 24 +++++++ src/search/pages/search-page.tsx | 11 +++ src/search/testutils/fake-search-results.ts | 49 ++++++++++++++ src/shared/components/icons/close-icon.tsx | 18 +++++ .../components/icons/seach-input-icon.tsx | 20 ------ 16 files changed, 365 insertions(+), 20 deletions(-) create mode 100644 src/app/search/page.tsx create mode 100644 src/search/components/search-category.tsx create mode 100644 src/search/components/search-input.tsx create mode 100644 src/search/components/search-result.tsx create mode 100644 src/search/components/search-results-skeleton.tsx create mode 100644 src/search/components/search-results.tsx create mode 100644 src/search/core/atoms.ts create mode 100644 src/search/core/query-keys.ts create mode 100644 src/search/core/schemas.ts create mode 100644 src/search/data/search.ts create mode 100644 src/search/hooks/use-search-input.ts create mode 100644 src/search/hooks/use-search-results.ts create mode 100644 src/search/pages/search-page.tsx create mode 100644 src/search/testutils/fake-search-results.ts create mode 100644 src/shared/components/icons/close-icon.tsx delete mode 100644 src/shared/components/icons/seach-input-icon.tsx diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx new file mode 100644 index 0000000..d842ceb --- /dev/null +++ b/src/app/search/page.tsx @@ -0,0 +1 @@ +export { SearchPage as default } from '@/search/pages/search-page'; diff --git a/src/search/components/search-category.tsx b/src/search/components/search-category.tsx new file mode 100644 index 0000000..f51c08f --- /dev/null +++ b/src/search/components/search-category.tsx @@ -0,0 +1,54 @@ +'use client'; + +import Link from 'next/link'; + +import { Button } from '@nextui-org/react'; + +import { ExternalIcon } from '@/shared/components/icons/external-icon'; + +import { SearchResultDto } from '@/search/core/schemas'; + +const escapeRegExp = (str: string): string => + str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const highlightText = (text: string, query: string): React.ReactNode => { + if (!query) return text; + + const escapedQuery = escapeRegExp(query); + const regex = new RegExp(`(${escapedQuery})`, 'gi'); + const parts = text.split(regex); + + return ( +
+ {parts.map((part, index) => + regex.test(part) ? ( + + {part} + + ) : ( + {part} + ), + )} +
+ ); +}; + +type Category = SearchResultDto['categories'][number]; + +interface Props extends Category { + query: string; +} + +export const SearchCategory = ({ label, url, query }: Props) => { + return ( + + ); +}; diff --git a/src/search/components/search-input.tsx b/src/search/components/search-input.tsx new file mode 100644 index 0000000..5cc448e --- /dev/null +++ b/src/search/components/search-input.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { Button, Input } from '@nextui-org/react'; + +import { CloseIcon } from '@/shared/components/icons/close-icon'; +import { SearchIcon } from '@/shared/components/icons/sidebar-search-icon'; + +import { useSearchInput } from '@/search/hooks/use-search-input'; + +export const SearchInput = () => { + const { value, onChange, onClear } = useSearchInput(); + + return ( + + + + } + endContent={ + value ? ( +
+ Cancel + +
+ ) : null + } + value={value} + onChange={onChange} + /> + ); +}; + +// 'use client'; + +// import { Button } from '@nextui-org/react'; + +// import { CloseIcon } from '@/shared/components/icons/close-icon'; +// import { SearchIcon } from '@/shared/components/icons/sidebar-search-icon'; + +// import { useSearchInput } from '@/search/hooks/use-search-input'; + +// export const SearchInput = () => { +// const { value, onChange } = useSearchInput(); + +// return ( +//
+// +// +//
+// Cancel +// +//
+//
+// ); +// }; diff --git a/src/search/components/search-result.tsx b/src/search/components/search-result.tsx new file mode 100644 index 0000000..86051ea --- /dev/null +++ b/src/search/components/search-result.tsx @@ -0,0 +1,36 @@ +import { SearchResultDto } from '@/search/core/schemas'; +import { SearchCategory } from '@/search/components/search-category'; + +export const SearchResultLayout = ({ + label, + categories, +}: { + label: React.ReactNode; + categories: React.ReactNode; +}) => { + return ( +
+ {label} +
{categories}
+
+ ); +}; + +interface Props extends SearchResultDto { + query: string; +} + +export const SearchResult = ({ query, title, categories }: Props) => { + return ( + {title}} + categories={ + <> + {categories.map(({ label, url }) => ( + + ))} + + } + /> + ); +}; diff --git a/src/search/components/search-results-skeleton.tsx b/src/search/components/search-results-skeleton.tsx new file mode 100644 index 0000000..0c7b2ac --- /dev/null +++ b/src/search/components/search-results-skeleton.tsx @@ -0,0 +1,29 @@ +import { Fragment } from 'react'; + +import { Skeleton } from '@nextui-org/react'; + +import { Divider } from '@/shared/components/divider'; + +import { SearchResultLayout } from '@/search/components/search-result'; + +export const SearchResultsSkeleton = () => { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + + } + categories={ + <> + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + } + /> + + ))} +
+ ); +}; diff --git a/src/search/components/search-results.tsx b/src/search/components/search-results.tsx new file mode 100644 index 0000000..39c6e04 --- /dev/null +++ b/src/search/components/search-results.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { Fragment } from 'react'; + +import { Divider } from '@/shared/components/divider'; + +import { useSearchResults } from '@/search/hooks/use-search-results'; +import { SearchResult } from '@/search/components/search-result'; +import { SearchResultsSkeleton } from '@/search/components/search-results-skeleton'; + +export const SearchResults = () => { + const { query, data } = useSearchResults(); + + if (!data) return ; + + return ( +
+ {data.map(({ title: label, categories }) => ( + + + + + ))} +
+ ); +}; diff --git a/src/search/core/atoms.ts b/src/search/core/atoms.ts new file mode 100644 index 0000000..55e616a --- /dev/null +++ b/src/search/core/atoms.ts @@ -0,0 +1,3 @@ +import { atom } from 'jotai'; + +export const searchQueryAtom = atom(''); diff --git a/src/search/core/query-keys.ts b/src/search/core/query-keys.ts new file mode 100644 index 0000000..fcaad9b --- /dev/null +++ b/src/search/core/query-keys.ts @@ -0,0 +1,6 @@ +export const searchQueryKeys = { + all: ['search'] as const, + search: (query: string) => [...searchQueryKeys.all, 'search', query] as const, +}; + +export type SearchQueryKeys = typeof searchQueryKeys; diff --git a/src/search/core/schemas.ts b/src/search/core/schemas.ts new file mode 100644 index 0000000..20d3b66 --- /dev/null +++ b/src/search/core/schemas.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +export const searchResultsDtoSchema = z.array( + z.object({ + title: z.string(), + categories: z.array( + z.object({ + label: z.string(), + url: z.string(), + }), + ), + }), +); + +export type SearchResultsDto = z.infer; +export type SearchResultDto = SearchResultsDto[number]; diff --git a/src/search/data/search.ts b/src/search/data/search.ts new file mode 100644 index 0000000..8b14737 --- /dev/null +++ b/src/search/data/search.ts @@ -0,0 +1,6 @@ +import { fakeSearchResults } from '@/search/testutils/fake-search-results'; + +export const search = async (_query: string) => { + await new Promise((r) => setTimeout(r, 200)); + return fakeSearchResults(); +}; diff --git a/src/search/hooks/use-search-input.ts b/src/search/hooks/use-search-input.ts new file mode 100644 index 0000000..18959d6 --- /dev/null +++ b/src/search/hooks/use-search-input.ts @@ -0,0 +1,19 @@ +import { useAtom } from 'jotai'; + +import { searchQueryAtom } from '@/search/core/atoms'; + +export const useSearchInput = () => { + const [value, setValue] = useAtom(searchQueryAtom); + + const onChange: React.ChangeEventHandler = (e) => { + setValue(e.target.value); + }; + + const onClear = () => setValue(''); + + return { + value, + onChange, + onClear, + }; +}; diff --git a/src/search/hooks/use-search-results.ts b/src/search/hooks/use-search-results.ts new file mode 100644 index 0000000..f5d643a --- /dev/null +++ b/src/search/hooks/use-search-results.ts @@ -0,0 +1,24 @@ +import { useQuery } from '@tanstack/react-query'; +import { useAtomValue } from 'jotai'; + +import { QUERY_STALETIME } from '@/shared/core/constants'; + +import { searchQueryKeys } from '@/search/core/query-keys'; +import { searchQueryAtom } from '@/search/core/atoms'; +import { search } from '@/search/data/search'; + +export const useSearchResults = () => { + const query = useAtomValue(searchQueryAtom); + + const fetchResult = useQuery({ + // eslint-disable-next-line @tanstack/query/exhaustive-deps + queryKey: searchQueryKeys.search(query.toLowerCase()), + queryFn: () => search(query), + staleTime: QUERY_STALETIME.DEFAULT, + }); + + return { + query, + ...fetchResult, + }; +}; diff --git a/src/search/pages/search-page.tsx b/src/search/pages/search-page.tsx new file mode 100644 index 0000000..78fefcd --- /dev/null +++ b/src/search/pages/search-page.tsx @@ -0,0 +1,11 @@ +import { SearchInput } from '@/search/components/search-input'; +import { SearchResults } from '@/search/components/search-results'; + +export const SearchPage = () => { + return ( +
+ + +
+ ); +}; diff --git a/src/search/testutils/fake-search-results.ts b/src/search/testutils/fake-search-results.ts new file mode 100644 index 0000000..80c1695 --- /dev/null +++ b/src/search/testutils/fake-search-results.ts @@ -0,0 +1,49 @@ +import { SearchResultsDto } from '@/search/core/schemas'; + +export const fakeSearchResults = (): SearchResultsDto => { + return [ + { + title: 'Jobs', + categories: [ + { label: 'Creative Designer', url: '#' }, + { label: 'Staff Product Designer', url: '#' }, + { label: 'Design Customer Support', url: '#' }, + { label: 'Cyber Design', url: '#' }, + { label: 'Design Science', url: '#' }, + { label: 'Design Engineering', url: '#' }, + ], + }, + { + title: 'Organizations', + categories: [ + { label: 'Business Design', url: '#' }, + { label: 'Golang Design', url: '#' }, + { label: 'Smart Design Contracts', url: '#' }, + ], + }, + { + title: 'Projects', + categories: [ + { label: 'Analysis Design', url: '#' }, + { label: 'Project 1 Design', url: '#' }, + { label: 'Project 2 Design', url: '#' }, + ], + }, + { + title: 'Categories', + categories: [ + { label: 'Design Category 1', url: '#' }, + { label: 'Design Category 2', url: '#' }, + { label: 'Design Category 3', url: '#' }, + ], + }, + { + title: 'Skills', + categories: [ + { label: 'Design Skill 1', url: '#' }, + { label: 'Design Skill 2', url: '#' }, + { label: 'Design Skill 3', url: '#' }, + ], + }, + ]; +}; diff --git a/src/shared/components/icons/close-icon.tsx b/src/shared/components/icons/close-icon.tsx new file mode 100644 index 0000000..50c5fd3 --- /dev/null +++ b/src/shared/components/icons/close-icon.tsx @@ -0,0 +1,18 @@ +import { memo } from 'react'; + +export const CloseIcon = memo(() => ( + + + +)); + +CloseIcon.displayName = 'CloseIcon'; diff --git a/src/shared/components/icons/seach-input-icon.tsx b/src/shared/components/icons/seach-input-icon.tsx deleted file mode 100644 index 0d1cdc9..0000000 --- a/src/shared/components/icons/seach-input-icon.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { memo } from 'react'; - -export const SearchInputIcon = memo(() => ( - - - -)); - -SearchInputIcon.displayName = 'SearchInputIcon';