259 lines
7.8 KiB
TypeScript
259 lines
7.8 KiB
TypeScript
import Button from '@/components/ui/Button'
|
|
import Dialog from '@/components/ui/Dialog'
|
|
import { GLOBAL_SEARCH } from '@/constants/permission.constant'
|
|
import withHeaderItem from '@/utils/hoc/withHeaderItem'
|
|
import useThemeClass from '@/utils/hooks/useThemeClass'
|
|
import classNames from 'classnames'
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import Highlighter from 'react-highlight-words'
|
|
import { FaChevronRight, FaSearch } from 'react-icons/fa';
|
|
import { Link } from 'react-router-dom'
|
|
import { PermissionCheck } from '../shared'
|
|
import { Badge, Checkbox, Pagination } from '../ui'
|
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
|
import { getSearch, getSystems } from '@/services/global-search.service'
|
|
|
|
type SearchData = {
|
|
title: string
|
|
url: string
|
|
icon?: string
|
|
category?: string
|
|
categoryTitle?: string
|
|
}
|
|
|
|
type SearchResult = {
|
|
title: string
|
|
badge?: string
|
|
data: SearchData[]
|
|
}
|
|
|
|
const ListItem = (props: {
|
|
icon?: string
|
|
label: string
|
|
url: string
|
|
isLast?: boolean
|
|
keyWord: string
|
|
onNavigate: () => void
|
|
}) => {
|
|
const { icon, label, url = '', isLast, keyWord, onNavigate } = props
|
|
|
|
const { textTheme } = useThemeClass()
|
|
|
|
return (
|
|
<Link to={url} onClick={onNavigate}>
|
|
<div
|
|
className={classNames(
|
|
'flex items-center justify-between rounded-lg p-3.5 cursor-pointer user-select',
|
|
'bg-gray-50 dark:bg-gray-700/60 hover:bg-gray-100 dark:hover:bg-gray-700/90',
|
|
!isLast && 'mb-3',
|
|
)}
|
|
>
|
|
<div className="flex items-center">
|
|
{/* <div
|
|
className={classNames(
|
|
'mr-4 rounded-md ring-1 ring-slate-900/5 shadow-sm text-xl group-hover:shadow h-6 w-6 flex items-center justify-center bg-white dark:bg-gray-700',
|
|
textTheme,
|
|
'dark:text-gray-100',
|
|
)}
|
|
>
|
|
{icon && navigationIcon[icon]}
|
|
</div> */}
|
|
<div className="text-gray-900 dark:text-gray-300">
|
|
<Highlighter
|
|
autoEscape
|
|
highlightClassName={classNames(
|
|
textTheme,
|
|
'underline bg-transparent font-semibold dark:text-white',
|
|
)}
|
|
searchWords={[keyWord]}
|
|
textToHighlight={label}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<FaChevronRight className="text-lg" />
|
|
</div>
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
const _Search = ({ className }: { className?: string }) => {
|
|
const [searchDialogOpen, setSearchDialogOpen] = useState(false)
|
|
const [searchResult, setSearchResult] = useState<SearchResult[]>([])
|
|
const [systems, setSystems] = useState<string[]>([])
|
|
const [noResult, setNoResult] = useState(false)
|
|
const [totalCount, setTotalCount] = useState(0)
|
|
const [currentPage, setCurrentPage] = useState(1)
|
|
const [currentSystems, setCurrentSystems] = useState<string[]>([])
|
|
|
|
const { translate } = useLocalization()
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
|
|
const handleReset = () => {
|
|
setNoResult(false)
|
|
setCurrentPage(1)
|
|
setTotalCount(0)
|
|
setSearchResult([])
|
|
}
|
|
|
|
const handleSearchOpen = async () => {
|
|
const result = await getSystems()
|
|
if (result?.data) {
|
|
setSystems(result.data)
|
|
setCurrentSystems(result.data)
|
|
}
|
|
setSearchDialogOpen(true)
|
|
}
|
|
|
|
const handleSearchClose = () => {
|
|
setSearchDialogOpen(false)
|
|
if (noResult) {
|
|
setTimeout(() => {
|
|
handleReset()
|
|
}, 300)
|
|
}
|
|
}
|
|
|
|
const handleSearch = async () => {
|
|
const q = inputRef.current?.value
|
|
if (!q || q.length < 3) return
|
|
|
|
const result = await getSearch({ query: q, system: currentSystems, page: currentPage })
|
|
|
|
if (!result.data?.totalCount || !result.data.items?.length) {
|
|
setNoResult(true)
|
|
setTotalCount(0)
|
|
setSearchResult([])
|
|
return
|
|
}
|
|
|
|
setTotalCount(result.data.totalCount)
|
|
|
|
const results = result.data.items.reduce((acc, curr) => {
|
|
const a = acc.find((item) => item.title === curr.group && item.badge === curr.system)
|
|
if (a) {
|
|
a.data.push({
|
|
title: curr.term,
|
|
url: curr.url,
|
|
})
|
|
} else {
|
|
acc.push({
|
|
title: curr.group,
|
|
badge: curr.system,
|
|
data: [
|
|
{
|
|
title: curr.term,
|
|
url: curr.url,
|
|
},
|
|
],
|
|
})
|
|
}
|
|
|
|
return acc
|
|
}, [] as SearchResult[])
|
|
|
|
setSearchResult(results)
|
|
}
|
|
|
|
useEffect(() => {
|
|
handleSearch()
|
|
}, [currentPage])
|
|
|
|
useEffect(() => {
|
|
if (searchDialogOpen) {
|
|
const timeout = setTimeout(() => inputRef.current?.focus(), 100)
|
|
return () => {
|
|
clearTimeout(timeout)
|
|
}
|
|
}
|
|
}, [searchDialogOpen])
|
|
|
|
const handleNavigate = () => {
|
|
handleSearchClose()
|
|
}
|
|
|
|
return (
|
|
<PermissionCheck permissions={[GLOBAL_SEARCH]}>
|
|
<div className={classNames(className, 'text-2xl')} onClick={handleSearchOpen}>
|
|
<FaSearch />
|
|
</div>
|
|
<Dialog
|
|
contentClassName="p-0"
|
|
isOpen={searchDialogOpen}
|
|
closable={false}
|
|
onRequestClose={handleSearchClose}
|
|
>
|
|
<div>
|
|
<div className="px-4 flex items-center border-b border-gray-200 dark:border-gray-600">
|
|
<div className="flex items-center flex-1">
|
|
<FaSearch className="text-xl" />
|
|
<input
|
|
ref={inputRef}
|
|
className="ring-0 outline-none block w-full p-4 text-base bg-transparent text-gray-900 dark:text-gray-100"
|
|
placeholder={translate('::App.Search')}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
|
/>
|
|
</div>
|
|
<Button size="xs" onClick={handleSearch}>
|
|
<FaSearch />
|
|
</Button>
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-600">
|
|
<div className="flex items-center space-x-2">
|
|
<span className="text-sm">{translate('::App.SearchIn')}</span>
|
|
<Checkbox.Group
|
|
value={currentSystems}
|
|
onChange={(value) => setCurrentSystems(value as string[])}
|
|
>
|
|
{systems.map((system) => (
|
|
<Checkbox key={system} value={system}>
|
|
{system}
|
|
</Checkbox>
|
|
))}
|
|
</Checkbox.Group>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="py-6 px-5 max-h-[550px] overflow-y-auto">
|
|
{searchResult.map((result) => (
|
|
<div key={`${result.title}-${result.badge}`} className="mb-6">
|
|
<div className="flex items-center justify-between mb-1">
|
|
<h6 className="mb-3">{result.title}</h6>
|
|
<Badge content={result.badge} />
|
|
</div>
|
|
|
|
{result.data.map((data, index) => (
|
|
<ListItem
|
|
key={data.title + index}
|
|
icon={data.icon}
|
|
label={data.title}
|
|
url={data.url}
|
|
keyWord={inputRef.current?.value || ''}
|
|
onNavigate={handleNavigate}
|
|
/>
|
|
))}
|
|
</div>
|
|
))}
|
|
{searchResult.length === 0 && noResult && (
|
|
<div className="my-10 text-center text-lg">
|
|
<span>{translate('::App.NoResults')}</span>
|
|
<div className="heading-text">'{inputRef.current?.value}'</div>
|
|
</div>
|
|
)}
|
|
<Pagination
|
|
className="float-right"
|
|
total={totalCount}
|
|
currentPage={currentPage}
|
|
pageSize={10}
|
|
onChange={setCurrentPage}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
</PermissionCheck>
|
|
)
|
|
}
|
|
|
|
const Search = withHeaderItem(_Search)
|
|
|
|
export default Search
|