user and territory autocomplete in search bar (#2217)
* autocomplete in the search bar * update some naming conventions * create dual autocomplete --------- Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com> Co-authored-by: k00b <k00b@stacker.news>
This commit is contained in:
parent
aebba27c57
commit
524b1b97f3
@ -228,6 +228,86 @@ function useEntityAutocomplete ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useDualAutocomplete ({ meta, helpers, innerRef, setSelectionRange }) {
|
||||||
|
const userAutocomplete = useEntityAutocomplete({
|
||||||
|
prefix: '@',
|
||||||
|
meta,
|
||||||
|
helpers,
|
||||||
|
innerRef,
|
||||||
|
setSelectionRange,
|
||||||
|
SuggestComponent: UserSuggest
|
||||||
|
})
|
||||||
|
|
||||||
|
const territoryAutocomplete = useEntityAutocomplete({
|
||||||
|
prefix: '~',
|
||||||
|
meta,
|
||||||
|
helpers,
|
||||||
|
innerRef,
|
||||||
|
setSelectionRange,
|
||||||
|
SuggestComponent: TerritorySuggest
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleTextChange = useCallback((e) => {
|
||||||
|
// Try to match user mentions first, then territories
|
||||||
|
if (!userAutocomplete.handleTextChange(e)) {
|
||||||
|
territoryAutocomplete.handleTextChange(e)
|
||||||
|
}
|
||||||
|
}, [userAutocomplete, territoryAutocomplete])
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((e, userOnKeyDown, territoryOnKeyDown) => {
|
||||||
|
const metaOrCtrl = e.metaKey || e.ctrlKey
|
||||||
|
if (!metaOrCtrl) {
|
||||||
|
if (userAutocomplete.entityData) {
|
||||||
|
return userOnKeyDown(e)
|
||||||
|
} else if (territoryAutocomplete.entityData) {
|
||||||
|
return territoryOnKeyDown(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false // Didn't handle the event
|
||||||
|
}, [userAutocomplete.entityData, territoryAutocomplete.entityData])
|
||||||
|
|
||||||
|
const handleBlur = useCallback((resetUserSuggestions, resetTerritorySuggestions) => {
|
||||||
|
setTimeout(resetUserSuggestions, 500)
|
||||||
|
setTimeout(resetTerritorySuggestions, 500)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
userAutocomplete,
|
||||||
|
territoryAutocomplete,
|
||||||
|
handleTextChange,
|
||||||
|
handleKeyDown,
|
||||||
|
handleBlur
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DualAutocompleteWrapper ({
|
||||||
|
userAutocomplete,
|
||||||
|
territoryAutocomplete,
|
||||||
|
children
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<UserSuggest
|
||||||
|
query={userAutocomplete.entityData?.query}
|
||||||
|
onSelect={userAutocomplete.handleSelect}
|
||||||
|
dropdownStyle={userAutocomplete.entityData?.style}
|
||||||
|
>{({ onKeyDown: userSuggestOnKeyDown, resetSuggestions: resetUserSuggestions }) => (
|
||||||
|
<TerritorySuggest
|
||||||
|
query={territoryAutocomplete.entityData?.query}
|
||||||
|
onSelect={territoryAutocomplete.handleSelect}
|
||||||
|
dropdownStyle={territoryAutocomplete.entityData?.style}
|
||||||
|
>{({ onKeyDown: territorySuggestOnKeyDown, resetSuggestions: resetTerritorySuggestions }) =>
|
||||||
|
children({
|
||||||
|
userSuggestOnKeyDown,
|
||||||
|
territorySuggestOnKeyDown,
|
||||||
|
resetUserSuggestions,
|
||||||
|
resetTerritorySuggestions
|
||||||
|
})}
|
||||||
|
</TerritorySuggest>
|
||||||
|
)}
|
||||||
|
</UserSuggest>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKeyDown, innerRef, ...props }) {
|
export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKeyDown, innerRef, ...props }) {
|
||||||
const [tab, setTab] = useState('write')
|
const [tab, setTab] = useState('write')
|
||||||
const [, meta, helpers] = useField(props)
|
const [, meta, helpers] = useField(props)
|
||||||
@ -287,22 +367,11 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
|||||||
}
|
}
|
||||||
}, [innerRef, selectionRange.start, selectionRange.end])
|
}, [innerRef, selectionRange.start, selectionRange.end])
|
||||||
|
|
||||||
const userAutocomplete = useEntityAutocomplete({
|
const { userAutocomplete, territoryAutocomplete, handleTextChange, handleKeyDown, handleBlur } = useDualAutocomplete({
|
||||||
prefix: '@',
|
|
||||||
meta,
|
meta,
|
||||||
helpers,
|
helpers,
|
||||||
innerRef,
|
innerRef,
|
||||||
setSelectionRange,
|
setSelectionRange
|
||||||
SuggestComponent: UserSuggest
|
|
||||||
})
|
|
||||||
|
|
||||||
const territoryAutocomplete = useEntityAutocomplete({
|
|
||||||
prefix: '~',
|
|
||||||
meta,
|
|
||||||
helpers,
|
|
||||||
innerRef,
|
|
||||||
setSelectionRange,
|
|
||||||
SuggestComponent: TerritorySuggest
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const uploadFeesUpdate = useDebounceCallback(
|
const uploadFeesUpdate = useDebounceCallback(
|
||||||
@ -313,56 +382,9 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
|||||||
|
|
||||||
const onChangeInner = useCallback((formik, e) => {
|
const onChangeInner = useCallback((formik, e) => {
|
||||||
if (onChange) onChange(formik, e)
|
if (onChange) onChange(formik, e)
|
||||||
// check for mentions and territory suggestions
|
|
||||||
uploadFeesUpdate(e.target.value)
|
uploadFeesUpdate(e.target.value)
|
||||||
|
handleTextChange(e)
|
||||||
// Try to match user mentions first, then territories
|
}, [onChange, uploadFeesUpdate, handleTextChange])
|
||||||
if (!userAutocomplete.handleTextChange(e)) {
|
|
||||||
territoryAutocomplete.handleTextChange(e)
|
|
||||||
}
|
|
||||||
}, [onChange, uploadFeesUpdate, userAutocomplete, territoryAutocomplete])
|
|
||||||
|
|
||||||
const onKeyDownInner = useCallback((userSuggestOnKeyDown, territorySuggestOnKeyDown) => {
|
|
||||||
return (e) => {
|
|
||||||
const metaOrCtrl = e.metaKey || e.ctrlKey
|
|
||||||
if (metaOrCtrl) {
|
|
||||||
if (e.key === 'k') {
|
|
||||||
// some browsers use CTRL+K to focus search bar so we have to prevent that behavior
|
|
||||||
e.preventDefault()
|
|
||||||
insertMarkdownLinkFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
|
||||||
}
|
|
||||||
if (e.key === 'b') {
|
|
||||||
// some browsers use CTRL+B to open bookmarks so we have to prevent that behavior
|
|
||||||
e.preventDefault()
|
|
||||||
insertMarkdownBoldFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
|
||||||
}
|
|
||||||
if (e.key === 'i') {
|
|
||||||
// some browsers might use CTRL+I to do something else so prevent that behavior too
|
|
||||||
e.preventDefault()
|
|
||||||
insertMarkdownItalicFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
|
||||||
}
|
|
||||||
if (e.key === 'u') {
|
|
||||||
// some browsers might use CTRL+U to do something else so prevent that behavior too
|
|
||||||
e.preventDefault()
|
|
||||||
imageUploadRef.current?.click()
|
|
||||||
}
|
|
||||||
if (e.key === 'Tab' && e.altKey) {
|
|
||||||
e.preventDefault()
|
|
||||||
insertMarkdownTabFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!metaOrCtrl) {
|
|
||||||
if (userAutocomplete.entityData) {
|
|
||||||
userSuggestOnKeyDown(e)
|
|
||||||
} else if (territoryAutocomplete.entityData) {
|
|
||||||
territorySuggestOnKeyDown(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onKeyDown) onKeyDown(e)
|
|
||||||
}
|
|
||||||
}, [innerRef, helpers?.setValue, setSelectionRange, onKeyDown, userAutocomplete.entityData, territoryAutocomplete.entityData])
|
|
||||||
|
|
||||||
const onPaste = useCallback((event) => {
|
const onPaste = useCallback((event) => {
|
||||||
const items = event.clipboardData.items
|
const items = event.clipboardData.items
|
||||||
@ -406,6 +428,44 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
|||||||
setDragStyle(null)
|
setDragStyle(null)
|
||||||
}, [setDragStyle])
|
}, [setDragStyle])
|
||||||
|
|
||||||
|
const onKeyDownInner = useCallback((userSuggestOnKeyDown, territorySuggestOnKeyDown) => {
|
||||||
|
return (e) => {
|
||||||
|
const metaOrCtrl = e.metaKey || e.ctrlKey
|
||||||
|
|
||||||
|
// Handle markdown shortcuts first
|
||||||
|
if (metaOrCtrl) {
|
||||||
|
if (e.key === 'k') {
|
||||||
|
// some browsers use CTRL+K to focus search bar so we have to prevent that behavior
|
||||||
|
e.preventDefault()
|
||||||
|
insertMarkdownLinkFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
||||||
|
}
|
||||||
|
if (e.key === 'b') {
|
||||||
|
// some browsers use CTRL+B to open bookmarks so we have to prevent that behavior
|
||||||
|
e.preventDefault()
|
||||||
|
insertMarkdownBoldFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
||||||
|
}
|
||||||
|
if (e.key === 'i') {
|
||||||
|
// some browsers might use CTRL+I to do something else so prevent that behavior too
|
||||||
|
e.preventDefault()
|
||||||
|
insertMarkdownItalicFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
||||||
|
}
|
||||||
|
if (e.key === 'u') {
|
||||||
|
// some browsers might use CTRL+U to do something else so prevent that behavior too
|
||||||
|
e.preventDefault()
|
||||||
|
imageUploadRef.current?.click()
|
||||||
|
}
|
||||||
|
if (e.key === 'Tab' && e.altKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
insertMarkdownTabFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
handleKeyDown(e, userSuggestOnKeyDown, territorySuggestOnKeyDown)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onKeyDown) onKeyDown(e)
|
||||||
|
}
|
||||||
|
}, [innerRef, helpers?.setValue, setSelectionRange, onKeyDown, handleKeyDown, imageUploadRef])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormGroup label={label} className={groupClassName}>
|
<FormGroup label={label} className={groupClassName}>
|
||||||
<div className={`${styles.markdownInput} ${tab === 'write' ? styles.noTopLeftRadius : ''}`}>
|
<div className={`${styles.markdownInput} ${tab === 'write' ? styles.noTopLeftRadius : ''}`}>
|
||||||
@ -472,34 +532,25 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
|||||||
</span>
|
</span>
|
||||||
</Nav>
|
</Nav>
|
||||||
<div className={`position-relative ${tab === 'write' ? '' : 'd-none'}`}>
|
<div className={`position-relative ${tab === 'write' ? '' : 'd-none'}`}>
|
||||||
<UserSuggest
|
<DualAutocompleteWrapper
|
||||||
query={userAutocomplete.entityData?.query}
|
userAutocomplete={userAutocomplete}
|
||||||
onSelect={userAutocomplete.handleSelect}
|
territoryAutocomplete={territoryAutocomplete}
|
||||||
dropdownStyle={userAutocomplete.entityData?.style}
|
>
|
||||||
>{({ onKeyDown: userSuggestOnKeyDown, resetSuggestions: resetUserSuggestions }) => (
|
{({ userSuggestOnKeyDown, territorySuggestOnKeyDown, resetUserSuggestions, resetTerritorySuggestions }) => (
|
||||||
<TerritorySuggest
|
|
||||||
query={territoryAutocomplete.entityData?.query}
|
|
||||||
onSelect={territoryAutocomplete.handleSelect}
|
|
||||||
dropdownStyle={territoryAutocomplete.entityData?.style}
|
|
||||||
>{({ onKeyDown: territorySuggestOnKeyDown, resetSuggestions: resetTerritorySuggestions }) => (
|
|
||||||
<InputInner
|
<InputInner
|
||||||
innerRef={innerRef}
|
innerRef={innerRef}
|
||||||
{...props}
|
{...props}
|
||||||
onChange={onChangeInner}
|
onChange={onChangeInner}
|
||||||
onKeyDown={onKeyDownInner(userSuggestOnKeyDown, territorySuggestOnKeyDown)}
|
onKeyDown={onKeyDownInner(userSuggestOnKeyDown, territorySuggestOnKeyDown)}
|
||||||
onBlur={() => {
|
onBlur={() => handleBlur(resetUserSuggestions, resetTerritorySuggestions)}
|
||||||
setTimeout(resetUserSuggestions, 500)
|
|
||||||
setTimeout(resetTerritorySuggestions, 500)
|
|
||||||
}}
|
|
||||||
onDragEnter={onDragEnter}
|
onDragEnter={onDragEnter}
|
||||||
onDragLeave={onDragLeave}
|
onDragLeave={onDragLeave}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
onPaste={onPaste}
|
onPaste={onPaste}
|
||||||
className={dragStyle === 'over' ? styles.dragOver : ''}
|
className={dragStyle === 'over' ? styles.dragOver : ''}
|
||||||
/>)}
|
/>
|
||||||
</TerritorySuggest>
|
)}
|
||||||
)}
|
</DualAutocompleteWrapper>
|
||||||
</UserSuggest>
|
|
||||||
</div>
|
</div>
|
||||||
{tab !== 'write' &&
|
{tab !== 'write' &&
|
||||||
<div className='form-group'>
|
<div className='form-group'>
|
||||||
|
@ -1,22 +1,26 @@
|
|||||||
import Container from 'react-bootstrap/Container'
|
import Container from 'react-bootstrap/Container'
|
||||||
import styles from './search.module.css'
|
import styles from './search.module.css'
|
||||||
import SearchIcon from '@/svgs/search-line.svg'
|
import SearchIcon from '@/svgs/search-line.svg'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState, useCallback } from 'react'
|
||||||
import { Form, Input, Select, DatePicker, SubmitButton } from './form'
|
import {
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
DatePicker,
|
||||||
|
SubmitButton,
|
||||||
|
useDualAutocomplete,
|
||||||
|
DualAutocompleteWrapper
|
||||||
|
} from './form'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { whenToFrom } from '@/lib/time'
|
import { whenToFrom } from '@/lib/time'
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
|
import { useField } from 'formik'
|
||||||
|
|
||||||
export default function Search ({ sub }) {
|
export default function Search ({ sub }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [q, setQ] = useState(router.query.q || '')
|
const [q, setQ] = useState(router.query.q || '')
|
||||||
const inputRef = useRef(null)
|
|
||||||
const { me } = useMe()
|
const { me } = useMe()
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
inputRef.current?.focus()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const search = async values => {
|
const search = async values => {
|
||||||
let prefix = ''
|
let prefix = ''
|
||||||
if (sub) {
|
if (sub) {
|
||||||
@ -63,18 +67,13 @@ export default function Search ({ sub }) {
|
|||||||
onSubmit={values => search({ ...values })}
|
onSubmit={values => search({ ...values })}
|
||||||
>
|
>
|
||||||
<div className={`${styles.active} mb-3`}>
|
<div className={`${styles.active} mb-3`}>
|
||||||
<Input
|
<SearchInput
|
||||||
name='q'
|
name='q'
|
||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
groupClassName='me-3 mb-0 flex-grow-1'
|
groupClassName='me-3 mb-0 flex-grow-1'
|
||||||
className='flex-grow-1'
|
className='flex-grow-1'
|
||||||
clear
|
setOuterQ={setQ}
|
||||||
innerRef={inputRef}
|
|
||||||
overrideValue={q}
|
|
||||||
onChange={async (formik, e) => {
|
|
||||||
setQ(e.target.value?.trim())
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<SubmitButton variant='primary' className={styles.search}>
|
<SubmitButton variant='primary' className={styles.search}>
|
||||||
<SearchIcon width={22} height={22} />
|
<SearchIcon width={22} height={22} />
|
||||||
@ -135,3 +134,52 @@ export default function Search ({ sub }) {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SearchInput ({ name, setOuterQ, ...props }) {
|
||||||
|
const [, meta, helpers] = useField(name)
|
||||||
|
const inputRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (meta.value !== undefined) setOuterQ(meta.value.trim())
|
||||||
|
}, [meta.value, setOuterQ])
|
||||||
|
|
||||||
|
const setCaret = useCallback(({ start, end }) => {
|
||||||
|
inputRef.current?.setSelectionRange(start, end)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const { userAutocomplete, territoryAutocomplete, handleTextChange, handleKeyDown, handleBlur } = useDualAutocomplete({
|
||||||
|
meta,
|
||||||
|
helpers,
|
||||||
|
innerRef: inputRef,
|
||||||
|
setSelectionRange: setCaret
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleChangeWithOuter = useCallback((formik, e) => {
|
||||||
|
setOuterQ(e.target.value.trim())
|
||||||
|
handleTextChange(e)
|
||||||
|
}, [setOuterQ, handleTextChange])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='position-relative flex-grow-1'>
|
||||||
|
<DualAutocompleteWrapper
|
||||||
|
userAutocomplete={userAutocomplete}
|
||||||
|
territoryAutocomplete={territoryAutocomplete}
|
||||||
|
>
|
||||||
|
{({ userSuggestOnKeyDown, territorySuggestOnKeyDown, resetUserSuggestions, resetTerritorySuggestions }) => (
|
||||||
|
<Input
|
||||||
|
name={name}
|
||||||
|
innerRef={inputRef}
|
||||||
|
clear
|
||||||
|
autoComplete='off'
|
||||||
|
onChange={handleChangeWithOuter}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
handleKeyDown(e, userSuggestOnKeyDown, territorySuggestOnKeyDown)
|
||||||
|
}}
|
||||||
|
onBlur={() => handleBlur(resetUserSuggestions, resetTerritorySuggestions)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DualAutocompleteWrapper>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user