feat: ai term suggestion input

feat/ai-autocomplete
Arik Chakma 7 months ago
parent f838b5dac7
commit 6c65e023a5
  1. 181
      src/components/GenerateRoadmap/AITermSuggestionInput.tsx
  2. 18
      src/components/GenerateRoadmap/RoadmapSearch.tsx
  3. 17
      src/hooks/use-debounce.ts

@ -0,0 +1,181 @@
import {
useEffect,
useMemo,
useRef,
useState,
type InputHTMLAttributes,
} from 'react';
import { cn } from '../../lib/classname';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { useDebounceValue } from '../../hooks/use-debounce';
import { httpGet } from '../../lib/http';
import { useToast } from '../../hooks/use-toast';
type GetTopAIRoadmapTermResponse = {
_id: string;
term: string;
title: string;
}[];
type AITermSuggestionInputProps = {
value: string;
onValueChange: (value: string) => void;
onSelect?: (roadmapId: string) => void;
inputClassName?: string;
wrapperClassName?: string;
placeholder?: string;
} & Omit<InputHTMLAttributes<HTMLInputElement>, 'onSelect' | 'onChange'>;
export function AITermSuggestionInput(props: AITermSuggestionInputProps) {
const {
value: defaultValue,
onValueChange,
onSelect,
inputClassName,
wrapperClassName,
placeholder,
...inputProps
} = props;
const termCache = useMemo(
() => new Map<string, GetTopAIRoadmapTermResponse>(),
[],
);
const toast = useToast();
const searchInputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [isActive, setIsActive] = useState(false);
const [searchResults, setSearchResults] =
useState<GetTopAIRoadmapTermResponse>([]);
const [searchedText, setSearchedText] = useState(defaultValue);
const [activeCounter, setActiveCounter] = useState(0);
const debouncedSearchValue = useDebounceValue(searchedText, 500);
const loadTopAIRoadmapTerm = async () => {
if (debouncedSearchValue.length === 0) {
return [];
}
if (termCache.has(debouncedSearchValue)) {
const cachedData = termCache.get(debouncedSearchValue);
return cachedData || [];
}
const { response, error } = await httpGet<GetTopAIRoadmapTermResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-top-ai-roadmap-term`,
{
term: debouncedSearchValue,
},
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
setSearchResults([]);
return [];
}
termCache.set(debouncedSearchValue, response);
return response;
};
useEffect(() => {
if (debouncedSearchValue.length === 0) {
setSearchResults([]);
return;
}
setIsActive(true);
loadTopAIRoadmapTerm().then((results) => {
setSearchResults(results?.slice(0, 5) || []);
setActiveCounter(0);
});
}, [debouncedSearchValue]);
useOutsideClick(dropdownRef, () => {
setIsActive(false);
});
return (
<div className={cn('relative', wrapperClassName)}>
<input
{...inputProps}
ref={searchInputRef}
type="text"
value={searchedText}
className={cn(
'w-full rounded-md border border-gray-400 px-3 py-2.5 transition-colors focus:border-black focus:outline-none',
inputClassName,
)}
placeholder={placeholder}
autoComplete="off"
onInput={(e) => {
const value = (e.target as HTMLInputElement).value.trim();
setSearchedText(value);
onValueChange(value);
}}
onFocus={() => {
setIsActive(true);
}}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') {
const canGoNext = activeCounter < searchResults.length - 1;
setActiveCounter(canGoNext ? activeCounter + 1 : 0);
} else if (e.key === 'ArrowUp') {
const canGoPrev = activeCounter > 0;
setActiveCounter(
canGoPrev ? activeCounter - 1 : searchResults.length - 1,
);
} else if (e.key === 'Tab') {
if (isActive) {
e.preventDefault();
}
} else if (e.key === 'Escape') {
setSearchedText('');
setIsActive(false);
} else if (e.key === 'Enter') {
e.preventDefault();
const activeData = searchResults[activeCounter];
if (activeData) {
onValueChange(activeData.term);
onSelect?.(activeData._id);
setIsActive(false);
}
}
}}
/>
{isActive && searchResults.length > 0 && (
<div
className="absolute top-full z-50 mt-1 w-full rounded-md border bg-white px-2 py-2 shadow"
ref={dropdownRef}
>
<div className="flex flex-col">
{searchResults.map((result, counter) => {
return (
<button
key={result?._id}
type="button"
className={cn(
'flex w-full items-center rounded p-2 text-sm',
counter === activeCounter ? 'bg-gray-100' : '',
)}
onMouseOver={() => setActiveCounter(counter)}
onClick={() => {
onValueChange(result?.term);
onSelect?.(result._id);
setSearchedText('');
setIsActive(false);
}}
>
{result?.title || result?.term}
</button>
);
})}
</div>
</div>
)}
</div>
);
}

@ -12,6 +12,8 @@ import { showLoginPopup } from '../../lib/popup';
import { cn } from '../../lib/classname.ts'; import { cn } from '../../lib/classname.ts';
import { useState } from 'react'; import { useState } from 'react';
import { OpenAISettings } from './OpenAISettings.tsx'; import { OpenAISettings } from './OpenAISettings.tsx';
import { AITermSuggestionInput } from './AITermSuggestionInput.tsx';
import { setUrlParams } from '../../lib/browser.ts';
type RoadmapSearchProps = { type RoadmapSearchProps = {
roadmapTerm: string; roadmapTerm: string;
@ -78,15 +80,15 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
}} }}
className="flex w-full flex-col gap-2 sm:flex-row" className="flex w-full flex-col gap-2 sm:flex-row"
> >
<input <AITermSuggestionInput
autoFocus autoFocus={true}
type="text"
placeholder="Enter a topic to generate a roadmap for"
className="w-full rounded-md border border-gray-400 px-3 py-2.5 transition-colors focus:border-black focus:outline-none"
value={roadmapTerm} value={roadmapTerm}
onInput={(e) => onValueChange={(value) => setRoadmapTerm(value)}
setRoadmapTerm((e.target as HTMLInputElement).value) placeholder="Enter a topic to generate a roadmap for"
} wrapperClassName="w-full"
onSelect={(roadmapId) => {
setUrlParams({ id: roadmapId });
}}
/> />
<button <button
className={cn( className={cn(

@ -0,0 +1,17 @@
import { useEffect, useState } from 'react';
export function useDebounceValue<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
Loading…
Cancel
Save