Skip to content

user and territory autocomplete in search bar #2217

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jun 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 131 additions & 80 deletions components/form.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
const [tab, setTab] = useState('write')
const [, meta, helpers] = useField(props)
Expand Down Expand Up @@ -287,22 +367,11 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
}
}, [innerRef, selectionRange.start, selectionRange.end])

const userAutocomplete = useEntityAutocomplete({
prefix: '@',
meta,
helpers,
innerRef,
setSelectionRange,
SuggestComponent: UserSuggest
})

const territoryAutocomplete = useEntityAutocomplete({
prefix: '~',
const { userAutocomplete, territoryAutocomplete, handleTextChange, handleKeyDown, handleBlur } = useDualAutocomplete({
meta,
helpers,
innerRef,
setSelectionRange,
SuggestComponent: TerritorySuggest
setSelectionRange
})

const uploadFeesUpdate = useDebounceCallback(
Expand All @@ -313,56 +382,9 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe

const onChangeInner = useCallback((formik, e) => {
if (onChange) onChange(formik, e)
// check for mentions and territory suggestions
uploadFeesUpdate(e.target.value)

// Try to match user mentions first, then territories
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])
handleTextChange(e)
}, [onChange, uploadFeesUpdate, handleTextChange])

const onPaste = useCallback((event) => {
const items = event.clipboardData.items
Expand Down Expand Up @@ -406,6 +428,44 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
setDragStyle(null)
}, [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 (
<FormGroup label={label} className={groupClassName}>
<div className={`${styles.markdownInput} ${tab === 'write' ? styles.noTopLeftRadius : ''}`}>
Expand Down Expand Up @@ -472,34 +532,25 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
</span>
</Nav>
<div className={`position-relative ${tab === 'write' ? '' : 'd-none'}`}>
<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 }) => (
<DualAutocompleteWrapper
userAutocomplete={userAutocomplete}
territoryAutocomplete={territoryAutocomplete}
>
{({ userSuggestOnKeyDown, territorySuggestOnKeyDown, resetUserSuggestions, resetTerritorySuggestions }) => (
<InputInner
innerRef={innerRef}
{...props}
onChange={onChangeInner}
onKeyDown={onKeyDownInner(userSuggestOnKeyDown, territorySuggestOnKeyDown)}
onBlur={() => {
setTimeout(resetUserSuggestions, 500)
setTimeout(resetTerritorySuggestions, 500)
}}
onBlur={() => handleBlur(resetUserSuggestions, resetTerritorySuggestions)}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDrop={onDrop}
onPaste={onPaste}
className={dragStyle === 'over' ? styles.dragOver : ''}
/>)}
</TerritorySuggest>
)}
</UserSuggest>
/>
)}
</DualAutocompleteWrapper>
</div>
{tab !== 'write' &&
<div className='form-group'>
Expand Down
76 changes: 62 additions & 14 deletions components/search.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
import Container from 'react-bootstrap/Container'
import styles from './search.module.css'
import SearchIcon from '@/svgs/search-line.svg'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Form, Input, Select, DatePicker, SubmitButton } from './form'
import { useEffect, useMemo, useRef, useState, useCallback } from 'react'
import {
Form,
Input,
Select,
DatePicker,
SubmitButton,
useDualAutocomplete,
DualAutocompleteWrapper
} from './form'
import { useRouter } from 'next/router'
import { whenToFrom } from '@/lib/time'
import { useMe } from './me'
import { useField } from 'formik'

export default function Search ({ sub }) {
const router = useRouter()
const [q, setQ] = useState(router.query.q || '')
const inputRef = useRef(null)
const { me } = useMe()

useEffect(() => {
inputRef.current?.focus()
}, [])

const search = async values => {
let prefix = ''
if (sub) {
Expand Down Expand Up @@ -63,18 +67,13 @@ export default function Search ({ sub }) {
onSubmit={values => search({ ...values })}
>
<div className={`${styles.active} mb-3`}>
<Input
<SearchInput
name='q'
required
autoFocus
groupClassName='me-3 mb-0 flex-grow-1'
className='flex-grow-1'
clear
innerRef={inputRef}
overrideValue={q}
onChange={async (formik, e) => {
setQ(e.target.value?.trim())
}}
setOuterQ={setQ}
/>
<SubmitButton variant='primary' className={styles.search}>
<SearchIcon width={22} height={22} />
Expand Down Expand Up @@ -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>
)
}