Refactor roadmap search and suggestions

feat/ai-autocomplete
Kamran Ahmed 1 year ago
parent 4855d28144
commit 603ff23acf
  1. 145
      src/components/GenerateRoadmap/AITermSuggestionInput.tsx
  2. 58
      src/components/GenerateRoadmap/GenerateRoadmap.tsx
  3. 51
      src/components/GenerateRoadmap/RoadmapSearch.tsx

@ -1,16 +1,15 @@
import { import {
type InputHTMLAttributes,
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
type InputHTMLAttributes,
} from 'react'; } from 'react';
import { cn } from '../../lib/classname'; import { cn } from '../../lib/classname';
import { useOutsideClick } from '../../hooks/use-outside-click'; import { useOutsideClick } from '../../hooks/use-outside-click';
import { useDebounceValue } from '../../hooks/use-debounce'; import { useDebounceValue } from '../../hooks/use-debounce';
import { httpGet } from '../../lib/http'; import { httpGet } from '../../lib/http';
import { useToast } from '../../hooks/use-toast'; import { useToast } from '../../hooks/use-toast';
import { Loader2 } from 'lucide-react';
import { Spinner } from '../ReactIcons/Spinner.tsx'; import { Spinner } from '../ReactIcons/Spinner.tsx';
import type { PageType } from '../CommandMenu/CommandMenu.tsx'; import type { PageType } from '../CommandMenu/CommandMenu.tsx';
@ -24,7 +23,7 @@ type GetTopAIRoadmapTermResponse = {
type AITermSuggestionInputProps = { type AITermSuggestionInputProps = {
value: string; value: string;
onValueChange: (value: string) => void; onValueChange: (value: string) => void;
onSelect?: (roadmapId: string) => void; onSelect?: (roadmapId: string, roadmapTitle: string) => void;
inputClassName?: string; inputClassName?: string;
wrapperClassName?: string; wrapperClassName?: string;
placeholder?: string; placeholder?: string;
@ -62,7 +61,7 @@ export function AITermSuggestionInput(props: AITermSuggestionInputProps) {
useState<GetTopAIRoadmapTermResponse>([]); useState<GetTopAIRoadmapTermResponse>([]);
const [searchedText, setSearchedText] = useState(defaultValue); const [searchedText, setSearchedText] = useState(defaultValue);
const [activeCounter, setActiveCounter] = useState(0); const [activeCounter, setActiveCounter] = useState(0);
const debouncedSearchValue = useDebounceValue(searchedText, 500); const debouncedSearchValue = useDebounceValue(searchedText, 300);
const loadTopAIRoadmapTerm = async () => { const loadTopAIRoadmapTerm = async () => {
const trimmedValue = debouncedSearchValue.trim(); const trimmedValue = debouncedSearchValue.trim();
@ -133,18 +132,23 @@ export function AITermSuggestionInput(props: AITermSuggestionInputProps) {
setIsActive(true); setIsActive(true);
setIsLoading(true); setIsLoading(true);
loadTopAIRoadmapTerm().then((results) => { loadTopAIRoadmapTerm()
const normalizedSearchText = debouncedSearchValue.trim().toLowerCase(); .then((results) => {
const matchingOfficialRoadmaps = officialRoadmaps.filter((roadmap) => { const normalizedSearchText = debouncedSearchValue.trim().toLowerCase();
return roadmap.title.toLowerCase().indexOf(normalizedSearchText) !== -1; const matchingOfficialRoadmaps = officialRoadmaps.filter((roadmap) => {
}); return (
roadmap.title.toLowerCase().indexOf(normalizedSearchText) !== -1
);
});
setSearchResults( setSearchResults(
[...matchingOfficialRoadmaps, ...results]?.slice(0, 5) || [], [...matchingOfficialRoadmaps, ...results]?.slice(0, 5) || [],
); );
setActiveCounter(0); setActiveCounter(0);
setIsLoading(false); })
}); .finally(() => {
setIsLoading(false);
});
}, [debouncedSearchValue]); }, [debouncedSearchValue]);
useEffect(() => { useEffect(() => {
@ -158,6 +162,8 @@ export function AITermSuggestionInput(props: AITermSuggestionInputProps) {
setIsActive(false); setIsActive(false);
}); });
const isFinishedTyping = debouncedSearchValue === searchedText;
return ( return (
<div className={cn('relative', wrapperClassName)}> <div className={cn('relative', wrapperClassName)}>
<input <input
@ -196,19 +202,21 @@ export function AITermSuggestionInput(props: AITermSuggestionInputProps) {
setSearchedText(''); setSearchedText('');
setIsActive(false); setIsActive(false);
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
if (searchResults.length > 0) { if (!searchResults.length || !isFinishedTyping) {
e.preventDefault(); return;
const activeData = searchResults[activeCounter]; }
if (activeData) {
if (activeData.isOfficial) {
window.location.href = `/${activeData._id}`;
return;
}
onValueChange(activeData.term); e.preventDefault();
onSelect?.(activeData._id); const activeData = searchResults[activeCounter];
setIsActive(false); if (activeData) {
if (activeData.isOfficial) {
window.open(`/${activeData._id}`, '_blank')?.focus();
return;
} }
onValueChange(activeData.term);
onSelect?.(activeData._id, activeData.title);
setIsActive(false);
} }
} }
}} }}
@ -223,51 +231,54 @@ export function AITermSuggestionInput(props: AITermSuggestionInputProps) {
</div> </div>
)} )}
{isActive && searchResults.length > 0 && searchedText.length > 0 && ( {isActive &&
<div isFinishedTyping &&
className="absolute top-full z-50 mt-1 w-full rounded-md border bg-white px-2 py-2 shadow" searchResults.length > 0 &&
ref={dropdownRef} searchedText.length > 0 && (
> <div
<div className="flex flex-col"> className="absolute top-full z-50 mt-1 w-full rounded-md border bg-white p-1 shadow"
{searchResults.map((result, counter) => { ref={dropdownRef}
return ( >
<button <div className="flex flex-col">
key={result?._id} {searchResults.map((result, counter) => {
type="button" return (
className={cn( <button
'flex w-full items-center rounded p-2 text-sm', key={result?._id}
counter === activeCounter ? 'bg-gray-100' : '', type="button"
)}
onMouseOver={() => setActiveCounter(counter)}
onClick={() => {
if (result.isOfficial) {
window.location.href = `/${result._id}`;
return;
}
onValueChange(result?.term);
onSelect?.(result._id);
setSearchedText('');
setIsActive(false);
}}
>
<span
className={cn( className={cn(
'mr-2 rounded-full p-1 px-1.5 text-xs leading-none', 'flex w-full items-center rounded p-2 text-sm',
result.isOfficial counter === activeCounter ? 'bg-gray-100' : '',
? 'bg-green-500 text-green-50'
: 'bg-blue-400 text-blue-50',
)} )}
onMouseOver={() => setActiveCounter(counter)}
onClick={() => {
if (result.isOfficial) {
window.location.href = `/${result._id}`;
return;
}
onValueChange(result?.term);
onSelect?.(result._id, result.title);
setSearchedText('');
setIsActive(false);
}}
> >
{result.isOfficial ? 'Official' : 'Generated'} <span
</span> className={cn(
{result?.title || result?.term} 'mr-2 rounded-full p-1 px-1.5 text-xs leading-none',
</button> result.isOfficial
); ? 'bg-green-500 text-green-50'
})} : 'bg-blue-400 text-blue-50',
)}
>
{result.isOfficial ? 'Official' : 'AI Generated'}
</span>
{result?.title || result?.term}
</button>
);
})}
</div>
</div> </div>
</div> )}
)}
</div> </div>
); );
} }

@ -37,6 +37,7 @@ import { AIRoadmapAlert } from './AIRoadmapAlert.tsx';
import { OpenAISettings } from './OpenAISettings.tsx'; import { OpenAISettings } from './OpenAISettings.tsx';
import { IS_KEY_ONLY_ROADMAP_GENERATION } from '../../lib/ai.ts'; import { IS_KEY_ONLY_ROADMAP_GENERATION } from '../../lib/ai.ts';
import { AITermSuggestionInput } from './AITermSuggestionInput.tsx'; import { AITermSuggestionInput } from './AITermSuggestionInput.tsx';
import { useParams } from '../../hooks/use-params.ts';
export type GetAIRoadmapLimitResponse = { export type GetAIRoadmapLimitResponse = {
used: number; used: number;
@ -91,6 +92,7 @@ export function GenerateRoadmap() {
const [hasSubmitted, setHasSubmitted] = useState<boolean>(false); const [hasSubmitted, setHasSubmitted] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isLoadingResults, setIsLoadingResults] = useState(false);
const [roadmapTerm, setRoadmapTerm] = useState(''); const [roadmapTerm, setRoadmapTerm] = useState('');
const [generatedRoadmapContent, setGeneratedRoadmapContent] = useState(''); const [generatedRoadmapContent, setGeneratedRoadmapContent] = useState('');
const [currentRoadmap, setCurrentRoadmap] = const [currentRoadmap, setCurrentRoadmap] =
@ -194,7 +196,7 @@ export function GenerateRoadmap() {
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
if (!roadmapTerm) { if (!roadmapTerm || isLoadingResults) {
return; return;
} }
@ -481,7 +483,7 @@ export function GenerateRoadmap() {
> >
{roadmapLimitUsed} of {roadmapLimit} {roadmapLimitUsed} of {roadmapLimit}
</span>{' '} </span>{' '}
roadmaps generated. roadmaps generated today.
</span> </span>
{!openAPIKey && ( {!openAPIKey && (
<button <button
@ -523,8 +525,8 @@ export function GenerateRoadmap() {
onValueChange={(value) => setRoadmapTerm(value)} onValueChange={(value) => setRoadmapTerm(value)}
placeholder="e.g. Try searching for Ansible or DevOps" placeholder="e.g. Try searching for Ansible or DevOps"
wrapperClassName="grow" wrapperClassName="grow"
onSelect={(id) => { onSelect={(id, title) => {
setUrlParams({ id }); loadTermRoadmap(title).finally(() => null);
}} }}
/> />
<button <button
@ -540,37 +542,47 @@ export function GenerateRoadmap() {
} }
}} }}
disabled={ disabled={
isAuthenticatedUser && isLoadingResults ||
(!roadmapLimit || (isAuthenticatedUser &&
!roadmapTerm || (!roadmapLimit ||
roadmapLimitUsed >= roadmapLimit || !roadmapTerm ||
roadmapTerm === currentRoadmap?.term || roadmapLimitUsed >= roadmapLimit ||
(isKeyOnly && !openAPIKey)) roadmapTerm === currentRoadmap?.term ||
(isKeyOnly && !openAPIKey)))
} }
> >
{!isAuthenticatedUser && ( {isLoadingResults && (
<> <>
<Wand size={20} /> <span>Please wait..</span>
Generate
</> </>
)} )}
{!isLoadingResults && (
{isAuthenticatedUser && (
<> <>
{roadmapLimit > 0 && canGenerateMore && ( {!isAuthenticatedUser && (
<> <>
<Wand size={20} /> <Wand size={20} />
Generate Generate
</> </>
)} )}
{roadmapLimit === 0 && <span>Please wait..</span>} {isAuthenticatedUser && (
<>
{roadmapLimit > 0 && !canGenerateMore && ( {roadmapLimit > 0 && canGenerateMore && (
<span className="flex items-center"> <>
<Ban size={15} className="mr-2" /> <Wand size={20} />
Limit reached Generate
</span> </>
)}
{roadmapLimit === 0 && <span>Please wait..</span>}
{roadmapLimit > 0 && !canGenerateMore && (
<span className="flex items-center">
<Ban size={15} className="mr-2" />
Limit reached
</span>
)}
</>
)} )}
</> </>
)} )}

@ -1,12 +1,11 @@
import { ArrowUpRight, Ban, Cog, Telescope, Wand } from 'lucide-react'; import { ArrowUpRight, Ban, Cog, Telescope, Wand } from 'lucide-react';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
import { useEffect, useState } from 'react';
import { getOpenAIKey, isLoggedIn } from '../../lib/jwt'; import { getOpenAIKey, isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup'; import { showLoginPopup } from '../../lib/popup';
import { cn } from '../../lib/classname.ts'; import { cn } from '../../lib/classname.ts';
import { useEffect, useState } from 'react';
import { OpenAISettings } from './OpenAISettings.tsx'; import { OpenAISettings } from './OpenAISettings.tsx';
import { AITermSuggestionInput } from './AITermSuggestionInput.tsx'; import { AITermSuggestionInput } from './AITermSuggestionInput.tsx';
import { setUrlParams } from '../../lib/browser.ts';
type RoadmapSearchProps = { type RoadmapSearchProps = {
roadmapTerm: string; roadmapTerm: string;
@ -35,11 +34,12 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
const [isConfiguring, setIsConfiguring] = useState(false); const [isConfiguring, setIsConfiguring] = useState(false);
const [openAPIKey, setOpenAPIKey] = useState(''); const [openAPIKey, setOpenAPIKey] = useState('');
const [isAuthenticatedUser, setIsAuthenticatedUser] = useState(false); const [isAuthenticatedUser, setIsAuthenticatedUser] = useState(false);
const [isLoadingResults, setIsLoadingResults] = useState(false);
useEffect(() => { useEffect(() => {
setOpenAPIKey(getOpenAIKey() || ''); setOpenAPIKey(getOpenAIKey() || '');
setIsAuthenticatedUser(isLoggedIn()); setIsAuthenticatedUser(isLoggedIn());
}, [getOpenAIKey(), isLoggedIn()]); }, []);
const randomTerms = ['OAuth', 'APIs', 'UX Design', 'gRPC']; const randomTerms = ['OAuth', 'APIs', 'UX Design', 'gRPC'];
@ -84,8 +84,8 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
onValueChange={(value) => setRoadmapTerm(value)} onValueChange={(value) => setRoadmapTerm(value)}
placeholder="Enter a topic to generate a roadmap for" placeholder="Enter a topic to generate a roadmap for"
wrapperClassName="w-full" wrapperClassName="w-full"
onSelect={(roadmapId) => { onSelect={(roadmapId, roadmapTitle) => {
setUrlParams({ id: roadmapId }); onLoadTerm(roadmapTitle);
}} }}
/> />
<button <button
@ -100,33 +100,44 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
} }
}} }}
disabled={ disabled={
isAuthenticatedUser && isLoadingResults ||
(!limit || (isAuthenticatedUser &&
!roadmapTerm || (!limit ||
limitUsed >= limit || !roadmapTerm ||
(isKeyOnly && !openAPIKey)) limitUsed >= limit ||
(isKeyOnly && !openAPIKey)))
} }
> >
{!isAuthenticatedUser && ( {isLoadingResults && (
<> <>
<Wand size={20} /> <span>Please wait..</span>
Generate
</> </>
)} )}
{isAuthenticatedUser && (
{!isLoadingResults && (
<> <>
{(!limit || canGenerateMore) && ( {!isAuthenticatedUser && (
<> <>
<Wand size={20} /> <Wand size={20} />
Generate Generate
</> </>
)} )}
{isAuthenticatedUser && (
<>
{(!limit || canGenerateMore) && (
<>
<Wand size={20} />
Generate
</>
)}
{limit > 0 && !canGenerateMore && ( {limit > 0 && !canGenerateMore && (
<span className="flex items-center text-base"> <span className="flex items-center text-base">
<Ban size={15} className="mr-2" /> <Ban size={15} className="mr-2" />
Limit reached Limit reached
</span> </span>
)}
</>
)} )}
</> </>
)} )}

Loading…
Cancel
Save