feat: Territory autocomplete (#2124)
* feat: Territory autocomplete Closes #992. * refactor: refactor UserSuggest and TerritorySuggest components * style: lint * refactor: unify user and territory autocomplete logic * simplify a bit and fix unrelated onSelect re-query * fix skipping empty string on forward draft population --------- Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com> Co-authored-by: k00b <k00b@stacker.news>
This commit is contained in:
parent
d4e3853f27
commit
0edf68cab9
@ -35,6 +35,27 @@ export async function getSub (parent, { name }, { models, me }) {
|
||||
export default {
|
||||
Query: {
|
||||
sub: getSub,
|
||||
subSuggestions: async (parent, { q, limit = 5 }, { models }) => {
|
||||
let subs = []
|
||||
if (q) {
|
||||
subs = await models.$queryRaw`
|
||||
SELECT name
|
||||
FROM "Sub"
|
||||
WHERE status = 'ACTIVE'
|
||||
AND SIMILARITY(name, ${q}) > 0.1
|
||||
ORDER BY SIMILARITY(name, ${q}) DESC
|
||||
LIMIT ${limit}`
|
||||
} else {
|
||||
subs = await models.$queryRaw`
|
||||
SELECT name
|
||||
FROM "Sub"
|
||||
WHERE status = 'ACTIVE'
|
||||
ORDER BY name ASC
|
||||
LIMIT ${limit}`
|
||||
}
|
||||
|
||||
return subs
|
||||
},
|
||||
subs: async (parent, args, { models, me }) => {
|
||||
if (me) {
|
||||
const currentUser = await models.user.findUnique({ where: { id: me.id } })
|
||||
|
@ -7,6 +7,7 @@ export default gql`
|
||||
subs: [Sub!]!
|
||||
topSubs(cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Subs
|
||||
userSubs(name: String!, cursor: String, when: String, from: String, to: String, by: String, limit: Limit): Subs
|
||||
subSuggestions(q: String!, limit: Limit): [Sub!]!
|
||||
}
|
||||
|
||||
type Subs {
|
||||
|
@ -198,7 +198,7 @@ export default function AdvPostForm ({ children, item, sub, storageKeyPrefix })
|
||||
for (let i = 0; i < MAX_FORWARDS; i++) {
|
||||
['nym', 'pct'].forEach(key => {
|
||||
const value = window.localStorage.getItem(`${storageKeyPrefix}-forward[${i}].${key}`)
|
||||
if (value) {
|
||||
if (value !== undefined && value !== null) {
|
||||
formik?.setFieldValue(`forward[${i}].${key}`, value)
|
||||
}
|
||||
})
|
||||
|
@ -16,6 +16,7 @@ import AddIcon from '@/svgs/add-fill.svg'
|
||||
import CloseIcon from '@/svgs/close-line.svg'
|
||||
import { gql, useLazyQuery } from '@apollo/client'
|
||||
import { USER_SUGGESTIONS } from '@/fragments/users'
|
||||
import { SUB_SUGGESTIONS } from '@/fragments/subs'
|
||||
import TextareaAutosize from 'react-textarea-autosize'
|
||||
import { useToast } from './toast'
|
||||
import { numWithUnits } from '@/lib/format'
|
||||
@ -139,6 +140,94 @@ function setNativeValue (textarea, value) {
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true, value }))
|
||||
}
|
||||
|
||||
function useEntityAutocomplete ({
|
||||
prefix,
|
||||
meta,
|
||||
helpers,
|
||||
innerRef,
|
||||
setSelectionRange,
|
||||
SuggestComponent
|
||||
}) {
|
||||
const [entityData, setEntityData] = useState()
|
||||
|
||||
const handleSelect = useCallback((name) => {
|
||||
if (entityData?.start === undefined || entityData?.end === undefined) return
|
||||
const { start, end } = entityData
|
||||
setEntityData(undefined)
|
||||
const first = `${meta?.value.substring(0, start)}${prefix}${name}`
|
||||
const second = meta?.value.substring(end)
|
||||
const updatedValue = `${first}${second}`
|
||||
helpers.setValue(updatedValue)
|
||||
setSelectionRange({ start: first.length, end: first.length })
|
||||
innerRef.current.focus()
|
||||
}, [entityData, meta?.value, helpers, prefix, setSelectionRange, innerRef])
|
||||
|
||||
const handleTextChange = useCallback((e) => {
|
||||
const { value, selectionStart } = e.target
|
||||
if (!value || selectionStart === undefined) {
|
||||
setEntityData(undefined)
|
||||
return false
|
||||
}
|
||||
|
||||
let priorSpace = -1
|
||||
for (let i = selectionStart - 1; i >= 0; i--) {
|
||||
if (/[^\w@~]/.test(value[i])) {
|
||||
priorSpace = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let nextSpace = value.length
|
||||
for (let i = selectionStart; i <= value.length; i++) {
|
||||
if (/[^\w]/.test(value[i])) {
|
||||
nextSpace = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const currentSegment = value.substring(priorSpace + 1, nextSpace)
|
||||
const regexPattern = new RegExp(`^\\${prefix}\\w*$`)
|
||||
|
||||
if (regexPattern.test(currentSegment)) {
|
||||
const { top, left } = textAreaCaret(e.target, e.target.selectionStart)
|
||||
setEntityData({
|
||||
query: currentSegment,
|
||||
start: priorSpace + 1,
|
||||
end: nextSpace,
|
||||
style: {
|
||||
position: 'absolute',
|
||||
top: `${top + Number(window.getComputedStyle(e.target).lineHeight.replace('px', ''))}px`,
|
||||
left: `${left}px`
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
setEntityData(undefined)
|
||||
return false
|
||||
}, [prefix])
|
||||
|
||||
// Return a function that takes a render prop instead of directly returning the component
|
||||
return {
|
||||
entityData,
|
||||
handleSelect,
|
||||
handleTextChange,
|
||||
renderSuggest: (renderProps) => {
|
||||
if (!entityData) return null
|
||||
|
||||
return (
|
||||
<SuggestComponent
|
||||
query={entityData?.query}
|
||||
onSelect={handleSelect}
|
||||
dropdownStyle={entityData?.style}
|
||||
>
|
||||
{renderProps}
|
||||
</SuggestComponent>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKeyDown, innerRef, ...props }) {
|
||||
const [tab, setTab] = useState('write')
|
||||
const [, meta, helpers] = useField(props)
|
||||
@ -198,18 +287,23 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
||||
}
|
||||
}, [innerRef, selectionRange.start, selectionRange.end])
|
||||
|
||||
const [mention, setMention] = useState()
|
||||
const insertMention = useCallback((name) => {
|
||||
if (mention?.start === undefined || mention?.end === undefined) return
|
||||
const { start, end } = mention
|
||||
setMention(undefined)
|
||||
const first = `${meta?.value.substring(0, start)}@${name}`
|
||||
const second = meta?.value.substring(end)
|
||||
const updatedValue = `${first}${second}`
|
||||
helpers.setValue(updatedValue)
|
||||
setSelectionRange({ start: first.length, end: first.length })
|
||||
innerRef.current.focus()
|
||||
}, [mention, meta?.value, helpers?.setValue])
|
||||
const userAutocomplete = useEntityAutocomplete({
|
||||
prefix: '@',
|
||||
meta,
|
||||
helpers,
|
||||
innerRef,
|
||||
setSelectionRange,
|
||||
SuggestComponent: UserSuggest
|
||||
})
|
||||
|
||||
const territoryAutocomplete = useEntityAutocomplete({
|
||||
prefix: '~',
|
||||
meta,
|
||||
helpers,
|
||||
innerRef,
|
||||
setSelectionRange,
|
||||
SuggestComponent: TerritorySuggest
|
||||
})
|
||||
|
||||
const uploadFeesUpdate = useDebounceCallback(
|
||||
(text) => {
|
||||
@ -219,50 +313,16 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
||||
|
||||
const onChangeInner = useCallback((formik, e) => {
|
||||
if (onChange) onChange(formik, e)
|
||||
// check for mention editing
|
||||
const { value, selectionStart } = e.target
|
||||
uploadFeesUpdate(value)
|
||||
// check for mentions and territory suggestions
|
||||
uploadFeesUpdate(e.target.value)
|
||||
|
||||
if (!value || selectionStart === undefined) {
|
||||
setMention(undefined)
|
||||
return
|
||||
// Try to match user mentions first, then territories
|
||||
if (!userAutocomplete.handleTextChange(e)) {
|
||||
territoryAutocomplete.handleTextChange(e)
|
||||
}
|
||||
}, [onChange, uploadFeesUpdate, userAutocomplete, territoryAutocomplete])
|
||||
|
||||
let priorSpace = -1
|
||||
for (let i = selectionStart - 1; i >= 0; i--) {
|
||||
if (/[^\w@]/.test(value[i])) {
|
||||
priorSpace = i
|
||||
break
|
||||
}
|
||||
}
|
||||
let nextSpace = value.length
|
||||
for (let i = selectionStart; i <= value.length; i++) {
|
||||
if (/[^\w]/.test(value[i])) {
|
||||
nextSpace = i
|
||||
break
|
||||
}
|
||||
}
|
||||
const currentSegment = value.substring(priorSpace + 1, nextSpace)
|
||||
|
||||
// set the query to the current character segment and note where it appears
|
||||
if (/^@\w*$/.test(currentSegment)) {
|
||||
const { top, left } = textAreaCaret(e.target, e.target.selectionStart)
|
||||
setMention({
|
||||
query: currentSegment,
|
||||
start: priorSpace + 1,
|
||||
end: nextSpace,
|
||||
style: {
|
||||
position: 'absolute',
|
||||
top: `${top + Number(window.getComputedStyle(e.target).lineHeight.replace('px', ''))}px`,
|
||||
left: `${left}px`
|
||||
}
|
||||
})
|
||||
} else {
|
||||
setMention(undefined)
|
||||
}
|
||||
}, [onChange, setMention, uploadFeesUpdate])
|
||||
|
||||
const onKeyDownInner = useCallback((userSuggestOnKeyDown) => {
|
||||
const onKeyDownInner = useCallback((userSuggestOnKeyDown, territorySuggestOnKeyDown) => {
|
||||
return (e) => {
|
||||
const metaOrCtrl = e.metaKey || e.ctrlKey
|
||||
if (metaOrCtrl) {
|
||||
@ -293,12 +353,16 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
||||
}
|
||||
|
||||
if (!metaOrCtrl) {
|
||||
userSuggestOnKeyDown(e)
|
||||
if (userAutocomplete.entityData) {
|
||||
userSuggestOnKeyDown(e)
|
||||
} else if (territoryAutocomplete.entityData) {
|
||||
territorySuggestOnKeyDown(e)
|
||||
}
|
||||
}
|
||||
|
||||
if (onKeyDown) onKeyDown(e)
|
||||
}
|
||||
}, [innerRef, helpers?.setValue, setSelectionRange, onKeyDown])
|
||||
}, [innerRef, helpers?.setValue, setSelectionRange, onKeyDown, userAutocomplete.entityData, territoryAutocomplete.entityData])
|
||||
|
||||
const onPaste = useCallback((event) => {
|
||||
const items = event.clipboardData.items
|
||||
@ -409,22 +473,32 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
||||
</Nav>
|
||||
<div className={`position-relative ${tab === 'write' ? '' : 'd-none'}`}>
|
||||
<UserSuggest
|
||||
query={mention?.query}
|
||||
onSelect={insertMention}
|
||||
dropdownStyle={mention?.style}
|
||||
>{({ onKeyDown: userSuggestOnKeyDown, resetSuggestions }) => (
|
||||
<InputInner
|
||||
innerRef={innerRef}
|
||||
{...props}
|
||||
onChange={onChangeInner}
|
||||
onKeyDown={onKeyDownInner(userSuggestOnKeyDown)}
|
||||
onBlur={() => setTimeout(resetSuggestions, 500)}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onPaste={onPaste}
|
||||
className={dragStyle === 'over' ? styles.dragOver : ''}
|
||||
/>)}
|
||||
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 }) => (
|
||||
<InputInner
|
||||
innerRef={innerRef}
|
||||
{...props}
|
||||
onChange={onChangeInner}
|
||||
onKeyDown={onKeyDownInner(userSuggestOnKeyDown, territorySuggestOnKeyDown)}
|
||||
onBlur={() => {
|
||||
setTimeout(resetUserSuggestions, 500)
|
||||
setTimeout(resetTerritorySuggestions, 500)
|
||||
}}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onPaste={onPaste}
|
||||
className={dragStyle === 'over' ? styles.dragOver : ''}
|
||||
/>)}
|
||||
</TerritorySuggest>
|
||||
)}
|
||||
</UserSuggest>
|
||||
</div>
|
||||
{tab !== 'write' &&
|
||||
@ -617,34 +691,34 @@ function InputInner ({
|
||||
}
|
||||
|
||||
const INITIAL_SUGGESTIONS = { array: [], index: 0 }
|
||||
export function UserSuggest ({
|
||||
query, onSelect, dropdownStyle, children,
|
||||
transformUser = user => user, selectWithTab = true, filterUsers = () => true
|
||||
|
||||
export function BaseSuggest ({
|
||||
query, onSelect, dropdownStyle,
|
||||
transformItem = item => item, selectWithTab = true, filterItems = () => true,
|
||||
getSuggestionsQuery, queryName, itemsField,
|
||||
children
|
||||
}) {
|
||||
const [getSuggestions] = useLazyQuery(USER_SUGGESTIONS, {
|
||||
const [getSuggestions] = useLazyQuery(getSuggestionsQuery, {
|
||||
onCompleted: data => {
|
||||
query !== undefined && setSuggestions({
|
||||
array: data.userSuggestions
|
||||
.filter((...args) => filterUsers(query, ...args))
|
||||
.map(transformUser),
|
||||
array: data[itemsField]
|
||||
.filter((...args) => filterItems(query, ...args))
|
||||
.map(transformItem),
|
||||
index: 0
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const [suggestions, setSuggestions] = useState(INITIAL_SUGGESTIONS)
|
||||
const resetSuggestions = useCallback(() => setSuggestions(INITIAL_SUGGESTIONS), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (query !== undefined) {
|
||||
// remove both the leading @ and any @domain after nym
|
||||
const q = query?.replace(/^[@ ]+|[ ]+$/g, '').replace(/@[^\s]*$/, '')
|
||||
// remove the leading character and any trailing spaces
|
||||
const q = query?.replace(/^[@ ~]+|[ ]+$/g, '').replace(/@[^\s]*$/, '').replace(/~[^\s]*$/, '')
|
||||
getSuggestions({ variables: { q, limit: 5 } })
|
||||
} else {
|
||||
resetSuggestions()
|
||||
}
|
||||
}, [query, resetSuggestions, getSuggestions])
|
||||
|
||||
const onKeyDown = useCallback(e => {
|
||||
switch (e.code) {
|
||||
case 'ArrowUp':
|
||||
@ -689,7 +763,6 @@ export function UserSuggest ({
|
||||
break
|
||||
}
|
||||
}, [onSelect, resetSuggestions, suggestions])
|
||||
|
||||
return (
|
||||
<>
|
||||
{children?.({ onKeyDown, resetSuggestions })}
|
||||
@ -712,17 +785,17 @@ export function UserSuggest ({
|
||||
)
|
||||
}
|
||||
|
||||
export function InputUserSuggest ({
|
||||
label, groupClassName, transformUser, filterUsers,
|
||||
selectWithTab, onChange, transformQuery, ...props
|
||||
function BaseInputSuggest ({
|
||||
label, groupClassName, transformItem, filterItems,
|
||||
selectWithTab, onChange, transformQuery, SuggestComponent, prefixRegex, ...props
|
||||
}) {
|
||||
const [ovalue, setOValue] = useState()
|
||||
const [query, setQuery] = useState()
|
||||
return (
|
||||
<FormGroup label={label} className={groupClassName}>
|
||||
<UserSuggest
|
||||
transformUser={transformUser}
|
||||
filterUsers={filterUsers}
|
||||
<SuggestComponent
|
||||
transformItem={transformItem}
|
||||
filterItems={filterItems}
|
||||
selectWithTab={selectWithTab}
|
||||
onSelect={(v) => {
|
||||
// HACK ... ovalue does not trigger onChange
|
||||
@ -737,19 +810,85 @@ export function InputUserSuggest ({
|
||||
autoComplete='off'
|
||||
onChange={(formik, e) => {
|
||||
onChange && onChange(formik, e)
|
||||
if (e.target.value === ovalue) {
|
||||
// we don't need to set the ovalue or query if the value is the same
|
||||
return
|
||||
}
|
||||
setOValue(e.target.value)
|
||||
setQuery(e.target.value.replace(/^[@ ]+|[ ]+$/g, ''))
|
||||
setQuery(e.target.value.replace(prefixRegex, ''))
|
||||
}}
|
||||
overrideValue={ovalue}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={() => setTimeout(resetSuggestions, 500)}
|
||||
/>
|
||||
)}
|
||||
</UserSuggest>
|
||||
</SuggestComponent>
|
||||
</FormGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export function InputUserSuggest ({
|
||||
transformUser, filterUsers, ...props
|
||||
}) {
|
||||
return (
|
||||
<BaseInputSuggest
|
||||
transformItem={transformUser}
|
||||
filterItems={filterUsers}
|
||||
SuggestComponent={UserSuggest}
|
||||
prefixRegex={/^[@ ]+|[ ]+$/g}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function InputTerritorySuggest ({
|
||||
transformSub, filterSubs, ...props
|
||||
}) {
|
||||
return (
|
||||
<BaseInputSuggest
|
||||
transformItem={transformSub}
|
||||
filterItems={filterSubs}
|
||||
SuggestComponent={TerritorySuggest}
|
||||
prefixRegex={/^[~ ]+|[ ]+$/g}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function UserSuggest ({
|
||||
transformUser = user => user, filterUsers = () => true,
|
||||
children, ...props
|
||||
}) {
|
||||
return (
|
||||
<BaseSuggest
|
||||
transformItem={transformUser}
|
||||
filterItems={filterUsers}
|
||||
getSuggestionsQuery={USER_SUGGESTIONS}
|
||||
itemsField='userSuggestions'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</BaseSuggest>
|
||||
)
|
||||
}
|
||||
|
||||
function TerritorySuggest ({
|
||||
transformSub = sub => sub, filterSubs = () => true,
|
||||
children, ...props
|
||||
}) {
|
||||
return (
|
||||
<BaseSuggest
|
||||
transformItem={transformSub}
|
||||
filterItems={filterSubs}
|
||||
getSuggestionsQuery={SUB_SUGGESTIONS}
|
||||
itemsField='subSuggestions'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</BaseSuggest>
|
||||
)
|
||||
}
|
||||
|
||||
export function Input ({ label, groupClassName, under, ...props }) {
|
||||
return (
|
||||
<FormGroup label={label} className={groupClassName}>
|
||||
|
@ -122,6 +122,14 @@ export const SUB_SEARCH = gql`
|
||||
}
|
||||
`
|
||||
|
||||
export const SUB_SUGGESTIONS = gql`
|
||||
query subSuggestions($q: String!, $limit: Limit) {
|
||||
subSuggestions(q: $q, limit: $limit) {
|
||||
name
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const TOP_SUBS = gql`
|
||||
${SUB_FULL_FIELDS}
|
||||
query TopSubs($cursor: String, $when: String, $from: String, $to: String, $by: String, ) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user