compete search

This commit is contained in:
amirhouieh 2023-05-09 17:46:56 +02:00 committed by Jinho Jang
parent 2aa98625cf
commit 3e5b8c6ec8
16 changed files with 458 additions and 114 deletions

View File

@ -11,7 +11,15 @@ import { ESearchScope, ESearchStatus } from '@/types/ui.types'
import React, { useCallback, useEffect, useState } from 'react'
import FilterTags from '@/components/FilterTags/FilterTags'
import styled from '@emotion/styled'
import { useSearchContext } from '@/context/SearchContext'
import { useRouter } from 'next/router'
import { ParsedUrlQuery } from 'querystring'
import {
addQueryToQuery,
addTopicsToQuery,
createMinimizedSearchText,
extractQueryFromQuery,
extractTopicsFromQuery,
} from '@/utils/search.utils'
export type SearchbarProps = {
searchScope?: ESearchScope
@ -21,25 +29,42 @@ export type SearchbarProps = {
export default function Searchbar(props: SearchbarProps) {
const { searchScope = ESearchScope.GLOBAL } = props
const [active, setActive] = useState(false)
const { exec } = useSearchContext()
const router = useRouter()
// const [loading, setLoading] = useState(false)
// const [error, setError] = useState(false)
const [query, setQuery] = useState<string>('')
const [filterTags, setFilterTags] = useState<string[]>([])
const [query, setQuery] = useState<string>(
extractQueryFromQuery(router.query),
)
const [filterTags, setFilterTags] = useState<string[]>(
extractTopicsFromQuery(router.query),
)
const isValidSearchInput = () =>
(query && query.length > 0) || filterTags.length > 0
const isValidSearchInput = (_filterTags: string[] = []) =>
(query && query.length > 0) || _filterTags.length > 0
const performSearch = async () => {
if (!isValidSearchInput()) return
exec(query, filterTags)
const performSearch = async (
q: string = query,
_filterTags: string[] = filterTags,
) => {
await router.push(
{
pathname: '/search',
query: {
...addQueryToQuery(q),
...addTopicsToQuery(_filterTags),
},
},
undefined,
{ shallow: true },
)
}
useEffect(() => {
setQuery(extractQueryFromQuery(router.query))
setFilterTags(extractTopicsFromQuery(router.query))
}, [router.query.query, router.query.topics])
const performClear = useCallback(() => {
// TODO: clear input.value seems to be not working. When set to undefined, the input value is still there.
setQuery('')
setFilterTags([])
performSearch('', [])
}, [setQuery, setFilterTags])
const handleTagClick = (tag: string) => {
@ -49,7 +74,7 @@ export default function Searchbar(props: SearchbarProps) {
} else {
newSelectedTags.push(tag)
}
setFilterTags(newSelectedTags)
performSearch(query, newSelectedTags)
}
const handleEnter = (e: React.KeyboardEvent<HTMLInputElement>) => {
@ -58,18 +83,6 @@ export default function Searchbar(props: SearchbarProps) {
}
}
const constructCollapseText = () => {
let txt = ''
if (query !== undefined && query.length > 0) {
txt += `<span>${query}</span>`
}
if (filterTags.length > 0) {
if (txt.length > 0) txt += '<b> . </b>'
txt += `${filterTags.map((t) => `<small>[${t}]</small>`).join(' ')}`
}
return txt
}
const isCollapsed = isValidSearchInput() && !active
const placeholder =
@ -111,7 +124,9 @@ export default function Searchbar(props: SearchbarProps) {
<Collapsed
className={isCollapsed ? 'enabled' : ''}
onClick={() => setActive(true)}
dangerouslySetInnerHTML={{ __html: constructCollapseText() }}
dangerouslySetInnerHTML={{
__html: createMinimizedSearchText(query, filterTags),
}}
/>
</SearchbarContainer>
)
@ -146,9 +161,11 @@ const Collapsed = styled.div`
&.enabled {
top: 0;
}
> * {
margin-right: 4px;
}
> *:not(:first-child) {
margin-left: 4px;
}

View File

@ -1,7 +0,0 @@
import { useArticlesSearch } from '@/hooks/useSearch'
type SearchContainerProps = {}
export const SearchContainer = (props: SearchContainerProps) => {
return <div>Search result container...</div>
}

View File

@ -1,47 +0,0 @@
import { useArticlesSearch } from '@/hooks/useSearch'
import { UnbodyGoogleDoc } from '@/lib/unbody/unbody.types'
import { SearchResultItem } from '@/types/data.types'
import { ESearchStatus } from '@/types/ui.types'
import { createContext, useContext, useState } from 'react'
type SearchContextType = {
status: ESearchStatus
exec: (
q?: string,
tags?: string[],
) => Promise<SearchResultItem<UnbodyGoogleDoc>[] | null>
}
const SearchContext = createContext<SearchContextType>({
status: ESearchStatus.NOT_ACTIVE,
exec: () => {},
} as SearchContextType)
export const SearchContextProvider = ({
children,
}: {
children: React.ReactNode
}) => {
const [status, setStatus] = useState<ESearchStatus>(ESearchStatus.NOT_ACTIVE)
const { data, loading, search } = useArticlesSearch()
const exec = async (q: string = '', tags: string[] = []) => {
setStatus(ESearchStatus.SEARCHING)
await search(q, tags)
setStatus(ESearchStatus.IDLE)
return data
}
return (
<SearchContext.Provider
value={{
status,
exec,
}}
>
{children}
</SearchContext.Provider>
)
}
export const useSearchContext = () => useContext(SearchContext)

View File

@ -1,21 +1,62 @@
import { UnbodyGoogleDoc } from '@/lib/unbody/unbody.types'
import {
UnbodyGoogleDoc,
UnbodyImageBlock,
UnbodyTextBlock,
} from '@/lib/unbody/unbody.types'
import searchApi from '@/services/search.service'
import { SearchResultItem } from '@/types/data.types'
import {
SearchHook,
SearchHookDataPayload,
SearchResultItem,
SearchResults,
} from '@/types/data.types'
import { useState } from 'react'
export const useArticlesSearch = () => {
const [data, setData] = useState<SearchResultItem<UnbodyGoogleDoc>[] | null>(
null,
)
export const useSearchGeneric = <T>(
initialData: SearchResultItem<T>[],
): SearchHook<T> => {
const [data, setData] = useState<SearchResultItem<T>[]>(initialData)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const search = async (query: string, tags: string[]) => {
if (loading) return null
setLoading(true)
const result = await searchApi.searchArticles(query, tags)
setData(result)
setData(result.data)
setLoading(false)
return result.data
}
return { data, loading, error, search }
const reset = (_initialData: SearchResultItem<T>[]) => {
setData(_initialData)
setLoading(false)
setError(null)
}
return { data, loading, error, search, reset }
}
export const useSearch = (
initialData: SearchHookDataPayload,
): SearchResults => {
const articles = useSearchGeneric<UnbodyGoogleDoc>(
initialData?.articles ?? null,
)
const blocks = useSearchGeneric<UnbodyImageBlock | UnbodyTextBlock>(
initialData?.blocks ?? null,
)
const search = async (query: string, tags: string[]) => {
const [articlesResult, blocksResult] = await Promise.all([
articles.loading ? () => {} : articles.search(query, tags),
blocks.loading ? () => {} : blocks.search(query, tags),
])
}
const reset = (initialData: SearchHookDataPayload) => {
articles.reset(initialData?.articles)
blocks.reset(initialData?.blocks)
}
return { search, reset, blocks, articles }
}

View File

@ -70,8 +70,17 @@ export namespace UnbodyGraphQl {
}
}
export enum UnbodyDocumentTypeNames {
GoogleDoc = 'GoogleDoc',
GoogleCalendarEvent = 'GoogleCalendarEvent',
TextBlock = 'TextBlock',
ImageBlock = 'ImageBlock',
AudioFile = 'AudioFile',
}
export interface BaseObject {
_additional: Additional.AdditionalProps
__typename: UnbodyDocumentTypeNames
}
export interface BaseObjectWithRef<T> extends BaseObject {
@ -80,6 +89,7 @@ export namespace UnbodyGraphQl {
export interface Beacon {
beacon: string
__typename: 'Beacon'
}
export namespace Fragments {
@ -89,7 +99,8 @@ export namespace UnbodyGraphQl {
}
export interface ImageBlock
extends BaseObjectWithRef<GoogleDoc | GoogleCalendarEvent | Beacon> {
extends BaseObjectWithRef<GoogleDoc | GoogleCalendarEvent> {
__typename: UnbodyDocumentTypeNames.ImageBlock
alt: string
createdAt: string
ext: string
@ -107,7 +118,8 @@ export namespace UnbodyGraphQl {
width: number
}
export interface TextBlock extends BaseObjectWithRef<GoogleDoc | Beacon> {
export interface TextBlock extends BaseObjectWithRef<GoogleDoc> {
__typename: UnbodyDocumentTypeNames.TextBlock
footnotes: string | Array<Fragments.FootnoteItem>
html: string
order: number
@ -118,7 +130,7 @@ export namespace UnbodyGraphQl {
}
export interface AudioFile
extends BaseObjectWithRef<GoogleDoc | GoogleCalendarEvent | Beacon> {
extends BaseObjectWithRef<GoogleDoc | GoogleCalendarEvent> {
duration: number
ext: string
mimeType: string
@ -128,9 +140,11 @@ export namespace UnbodyGraphQl {
size: number
sourceId: string
url: string
__typename: UnbodyDocumentTypeNames.AudioFile
}
export interface GoogleDoc extends BaseObject {
__typename: UnbodyDocumentTypeNames.GoogleDoc
blocks: Array<ImageBlock | TextBlock>
createdAt: string
html: string
@ -151,6 +165,7 @@ export namespace UnbodyGraphQl {
}
export interface GoogleCalendarEvent extends BaseObject {
__typename: UnbodyDocumentTypeNames.GoogleCalendarEvent
createdAt: string
creatorDisplayName: string
creatorEmail: string

View File

@ -30,3 +30,8 @@ export type UnbodyGraphQlResponseTextBlock = UnbodyGraphQlResponse<{
export type UnbodyGraphQlResponseImageBlock = UnbodyGraphQlResponse<{
ImageBlock: UnbodyImageBlock[]
}>
export type UnbodyGraphQlResponseBlocks = UnbodyGraphQlResponse<{
ImageBlock: UnbodyImageBlock[]
TextBlock: UnbodyTextBlock[]
}>

View File

@ -7,7 +7,6 @@ import { uiConfigs } from '@/configs/ui.configs'
import { DefaultLayout } from '@/layouts/DefaultLayout'
import { ReactNode } from 'react'
import { NextComponentType, NextPageContext } from 'next'
import { SearchContextProvider } from '@/context/SearchContext'
type NextLayoutComponentType<P = {}> = NextComponentType<
NextPageContext,
@ -51,9 +50,7 @@ export default function App({ Component, pageProps }: AppLayoutProps) {
}
`}
/>
<SearchContextProvider>
{getLayout(<Component {...pageProps} />)}
</SearchContextProvider>
{getLayout(<Component {...pageProps} />)}
</ThemeProvider>
)
}

View File

@ -1,7 +1,5 @@
import { PostDataProps } from '@/components/Post/Post'
import PostsDemo from '@/components/Post/PostsDemo'
import { SearchContainer } from '@/containers/SearchContainer'
import { useSearchContext } from '@/context/SearchContext'
import { UnbodyGoogleDoc, UnbodyImageBlock } from '@/lib/unbody/unbody.types'
import api from '@/services/unbody.service'
import { ESearchStatus } from '@/types/ui.types'
@ -13,15 +11,9 @@ type Props = {
}
export default function Home({ posts }: Props) {
const { status } = useSearchContext()
return (
<>
{status === ESearchStatus.NOT_ACTIVE ? (
<PostsDemo posts={posts} featuredPost={posts[0]} />
) : (
<SearchContainer />
)}
<PostsDemo posts={posts} featuredPost={posts[0]} />
</>
)
}

View File

@ -1,3 +1,169 @@
export default function SearchPage() {
return <div>Search</div>
import { useSearch, useSearchGeneric } from '@/hooks/useSearch'
import {
UnbodyGoogleDoc,
UnbodyImageBlock,
UnbodyTextBlock,
} from '@/lib/unbody/unbody.types'
import { UnbodyGraphQl } from '@/lib/unbody/unbody-content.types'
import Image from 'next/image'
import unbodyApi from '@/services/unbody.service'
import {
SearchHook,
SearchHookDataPayload,
SearchResultItem,
SearchResults,
} from '@/types/data.types'
import {
createMinimizedSearchText,
extractQueryFromQuery,
extractTopicsFromQuery,
} from '@/utils/search.utils'
import { useRouter } from 'next/router'
import { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
interface SearchPageProps {
articles: SearchResultItem<UnbodyGoogleDoc>[]
blocks: SearchResultItem<UnbodyTextBlock | UnbodyImageBlock>[]
}
export default function SearchPage({
articles: initialArticles = [],
blocks: initialBlocks = [],
}: SearchPageProps) {
const router = useRouter()
const hasUpdated = useRef(false)
const {
query: { query = '', topics = [] },
} = router
const articles = useSearchGeneric<UnbodyGoogleDoc>(initialArticles)
const blocks = useSearchGeneric<UnbodyTextBlock | UnbodyImageBlock>(
initialBlocks,
)
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
return () => {
setMounted(false)
}
}, [])
useEffect(() => {
if (mounted) {
if (query.length > 0 || topics.length > 0) {
const serchArgs = [
extractQueryFromQuery(router.query),
extractTopicsFromQuery(router.query),
]
articles.search(...(serchArgs as [string, string[]]))
blocks.search(...(serchArgs as [string, string[]]))
} else {
articles.reset(initialArticles)
blocks.reset(initialBlocks)
}
} else {
hasUpdated.current = true
}
}, [mounted, router.query])
return (
<div>
<section>
<strong>Related articles</strong>
<hr />
<div>
{articles.loading && <div>...</div>}
{!articles.error && articles.data && articles.data.length > 0 ? (
articles.data.map((article: SearchResultItem<UnbodyGoogleDoc>) => (
<div key={article.doc.remoteId}>
<h2>{article.doc.title}</h2>
<p>{article.doc.summary}</p>
</div>
))
) : (
<div>Nothing found</div>
)}
</div>
</section>
<br />
<section>
<strong>Related content blocks</strong>
<hr />
<div>
{blocks.loading && <div>...</div>}
{!blocks.error && blocks.data && blocks.data.length > 0 ? (
blocks.data.map(
(block: SearchResultItem<UnbodyImageBlock | UnbodyTextBlock>) => {
if (!block.doc.document || !block.doc.document[0]) return null
let refArticle = null
if (UnbodyGraphQl.UnbodyDocumentTypeNames.GoogleDoc) {
refArticle = block.doc.document[0]
}
switch (block.doc.__typename) {
case UnbodyGraphQl.UnbodyDocumentTypeNames.TextBlock:
return (
<div key={block.doc.remoteId}>
{refArticle && (
<h3>
<Link href={`/articles/${refArticle.remoteId}`}>
{refArticle.title}
</Link>
</h3>
)}
{
<p
dangerouslySetInnerHTML={{ __html: block.doc.html }}
/>
}
</div>
)
case UnbodyGraphQl.UnbodyDocumentTypeNames.ImageBlock: {
return (
<div key={block.doc.remoteId}>
{refArticle && (
<h3>
<Link href={`/articles/${refArticle.remoteId}`}>
{refArticle.title}
</Link>
</h3>
)}
<Image
title={block.doc.alt}
src={block.doc.url}
width={block.doc.width}
height={block.doc.height}
alt={block.doc.alt}
/>
</div>
)
}
}
},
)
) : (
<div>Nothing found</div>
)}
</div>
</section>
</div>
)
}
export async function getStaticProps() {
const { data: articles = [] } = await unbodyApi.searchArticles()
const { data: blocks = [] } = await unbodyApi.serachBlocks()
return {
props: {
articles,
blocks,
},
}
}

View File

@ -7,3 +7,13 @@ export const GetGoogleDocQuery = (args: UnbodyGetFilters) => (q: string) => {
if (Object.keys(args).length === 0) return GetQuery(`GoogleDoc{ ${q} }`)
return GetQuery(`GoogleDoc(${parseFilterArgs(args)}){ ${q} }`)
}
export const GetTextBlockQuery = (args: UnbodyGetFilters) => (q: string) => {
if (Object.keys(args).length === 0) return `TextBlock{ ${q} }`
return `TextBlock(${parseFilterArgs(args)}){ ${q} }`
}
export const GetImageBlockQuery = (args: UnbodyGetFilters) => (q: string) => {
if (Object.keys(args).length === 0) return `ImageBlock{ ${q} }`
return `ImageBlock(${parseFilterArgs(args)}){ ${q} }`
}

View File

@ -0,0 +1,41 @@
import {
GetGoogleDocQuery,
GetImageBlockQuery,
GetQuery,
GetTextBlockQuery,
} from '.'
import { UnbodyGetFilters } from '../lib/unbody/unbody.types'
const defaultArgs: UnbodyGetFilters = {}
export const getSearchBlocksQuery = (args: UnbodyGetFilters = defaultArgs) =>
GetQuery(
[
GetTextBlockQuery(args)(`
__typename
text
tagName
document{
...on GoogleDoc{
title
remoteId
__typename
}
}
`),
GetImageBlockQuery(args)(`
__typename
url
width
height
alt
document{
...on GoogleDoc{
title
remoteId
__typename
}
}
`),
].join(' '),
)

View File

@ -1,9 +1,13 @@
class SearchService {
constructor() {}
searchArticles = (query: string, tags: string[]) => {
return fetch(`/api/search?q=${query}&tags=${tags.join(',')}`).then((res) =>
res.json(),
)
return fetch(`/api/search?q=${query}&tags=${tags.join(',')}`)
.then((res) => res.json())
.catch((e) => {
console.error(e)
return { data: null, errors: JSON.stringify(e) }
})
}
}

View File

@ -3,6 +3,9 @@ import {
UnbodyGoogleDoc,
UnbodyGraphQlResponseGoogleDoc,
UnbodyGetFilters,
UnbodyImageBlock,
UnbodyTextBlock,
UnbodyGraphQlResponseBlocks,
} from '@/lib/unbody/unbody.types'
import { UnbodyGraphQl } from '@/lib/unbody/unbody-content.types'
@ -17,6 +20,7 @@ import {
GlobalSearchResponse,
SearchResultItem,
} from '@/types/data.types'
import { getSearchBlocksQuery } from '@/queries/searchBlocks'
const { UNBODY_API_KEY, UNBODY_LPE_PROJECT_ID } = process.env
@ -25,6 +29,17 @@ type HomepagePost = Pick<
'title' | 'summary' | 'tags' | 'modifiedAt' | 'subtitle' | 'blocks'
>
type UnbodyDocTypes = UnbodyGoogleDoc | UnbodyImageBlock | UnbodyTextBlock
const mapSearchResultItem = <T extends UnbodyDocTypes>(
q: string,
tags: string[],
item: T,
): SearchResultItem<T> => ({
doc: item,
score: q.length > 0 || tags.length > 0 ? item._additional.certainty : 0,
})
class UnbodyService extends UnbodyClient {
constructor() {
super(UNBODY_API_KEY as string, UNBODY_LPE_PROJECT_ID as string)
@ -90,6 +105,39 @@ class UnbodyService extends UnbodyClient {
.catch((e) => this.handleResponse(null, e))
}
serachBlocks = async (
q: string = '',
tags: string[] = [],
): Promise<
ApiResponse<SearchResultItem<UnbodyImageBlock | UnbodyTextBlock>[]>
> => {
const query = getSearchBlocksQuery({
...(q.trim().length > 0
? {
nearText: {
concepts: [q, ...tags],
certainty: 0.75,
},
}
: {}),
})
console.log(query)
return this.request<UnbodyGraphQlResponseBlocks>(query)
.then(({ data }) => {
if (!data || !(data.Get.ImageBlock || data.Get.TextBlock))
return this.handleResponse([], 'No data')
const blocks = [...data.Get.ImageBlock, ...data.Get.TextBlock]
return this.handleResponse(
blocks
.map((block) => mapSearchResultItem(q, tags, block))
.sort((a, b) => b.score - a.score),
)
})
.catch((e) => this.handleResponse([], e))
}
searchArticles = (
q: string = '',
tags: string[] = [],
@ -115,11 +163,10 @@ class UnbodyService extends UnbodyClient {
{}),
})
console.log(q, tags, q.length, tags.length)
return this.request<UnbodyGraphQlResponseGoogleDoc>(query)
.then(({ data }) => {
if (!data) return this.handleResponse([], 'No data')
if (!data || !data.Get.GoogleDoc)
return this.handleResponse([], 'No data')
return this.handleResponse(
data.Get.GoogleDoc.map((item) => ({
doc: item,

View File

@ -23,3 +23,27 @@ export type GlobalSearchResponse = {
posts: ArticlePostData[]
blocks: Array<SearchResultItem<UnbodyTextBlock | UnbodyImageBlock>>
}
export type SearchHookDataPayload = {
articles: SearchResultItem<UnbodyGoogleDoc>[]
blocks: SearchResultItem<UnbodyImageBlock | UnbodyTextBlock>[]
}
export type SearchResults = {
articles: SearchHook<UnbodyGoogleDoc>
blocks: SearchHook<UnbodyImageBlock | UnbodyTextBlock>
search: (query: string, tags: string[]) => Promise<void>
reset: (initialData: SearchHookDataPayload) => void
}
export type SearchResultsItemTypes =
| SearchResultItem<UnbodyGoogleDoc>
| SearchResultItem<UnbodyImageBlock | UnbodyTextBlock>
export type SearchHook<T> = {
data: SearchResultItem<T>[]
loading: boolean
error: string | null
search: (query: string, tags: string[]) => Promise<SearchResultItem<T>[]>
reset: (initialData: SearchResultItem<T>[]) => void
}

View File

@ -1,3 +1,5 @@
import { SearchResultItem } from './data.types'
export enum ESearchScope {
GLOBAL = 'global',
ARTICLE = 'article',

37
src/utils/search.utils.ts Normal file
View File

@ -0,0 +1,37 @@
import { SearchResultItem } from '@/types/data.types'
import { ParsedUrlQuery } from 'querystring'
export const extractTopicsFromQuery = (query: ParsedUrlQuery): string[] => {
return query.topics ? (query.topics as string).split(',') : []
}
export const addTopicsToQuery = (topics: string[]) => {
return {
...(topics.length > 0 && { topics: topics.join(',') }),
}
}
export const extractQueryFromQuery = (queryObj: ParsedUrlQuery): string => {
return (queryObj.query as string) || ''
}
export const addQueryToQuery = (query: string) => {
return {
...(query && query.length > 0 && { query }),
}
}
export const createMinimizedSearchText = (
query: string,
filterTags: string[],
) => {
let txt = ''
if (query !== undefined && query.length > 0) {
txt += `<span>${query}</span>`
}
if (filterTags.length > 0) {
if (txt.length > 0) txt += '<b> . </b>'
txt += `${filterTags.map((t) => `<small>[${t}]</small>`).join(' ')}`
}
return txt
}