feat: show pre-existing results on AI search input (#5349)

* feat: ai term suggestion input

* fix: add suggestion for roadmap

* Update spinner

* fix: hydration errors

* Refactor roadmap search and suggestions

* Remove limit from frontend

* Update roadmap title

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
pull/5380/head
Arik Chakma 8 months ago committed by GitHub
parent 696e4f1890
commit 6e6489bc4c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 284
      src/components/GenerateRoadmap/AITermSuggestionInput.tsx
  2. 52
      src/components/GenerateRoadmap/GenerateRoadmap.tsx
  3. 55
      src/components/GenerateRoadmap/RoadmapSearch.tsx
  4. 17
      src/hooks/use-debounce.ts

@ -0,0 +1,284 @@
import {
type InputHTMLAttributes,
useEffect,
useMemo,
useRef,
useState,
} 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';
import { Spinner } from '../ReactIcons/Spinner.tsx';
import type { PageType } from '../CommandMenu/CommandMenu.tsx';
type GetTopAIRoadmapTermResponse = {
_id: string;
term: string;
title: string;
isOfficial: boolean;
}[];
type AITermSuggestionInputProps = {
value: string;
onValueChange: (value: string) => void;
onSelect?: (roadmapId: string, roadmapTitle: string) => void;
inputClassName?: string;
wrapperClassName?: string;
placeholder?: string;
} & Omit<
InputHTMLAttributes<HTMLInputElement>,
'onSelect' | 'onChange' | 'className'
>;
export function AITermSuggestionInput(props: AITermSuggestionInputProps) {
const {
value: defaultValue,
onValueChange,
onSelect,
inputClassName,
wrapperClassName,
placeholder,
...inputProps
} = props;
const termCache = useMemo(
() => new Map<string, GetTopAIRoadmapTermResponse>(),
[],
);
const [officialRoadmaps, setOfficialRoadmaps] =
useState<GetTopAIRoadmapTermResponse>([]);
const toast = useToast();
const searchInputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const isFirstRender = useRef(true);
const [isActive, setIsActive] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [searchResults, setSearchResults] =
useState<GetTopAIRoadmapTermResponse>([]);
const [searchedText, setSearchedText] = useState(defaultValue);
const [activeCounter, setActiveCounter] = useState(0);
const debouncedSearchValue = useDebounceValue(searchedText, 300);
const loadTopAIRoadmapTerm = async () => {
const trimmedValue = debouncedSearchValue.trim();
if (trimmedValue.length === 0) {
return [];
}
if (termCache.has(trimmedValue)) {
const cachedData = termCache.get(trimmedValue);
return cachedData || [];
}
const { response, error } = await httpGet<GetTopAIRoadmapTermResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-top-ai-roadmap-term`,
{
term: trimmedValue,
},
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
setSearchResults([]);
return [];
}
termCache.set(trimmedValue, response);
return response;
};
const loadOfficialRoadmaps = async () => {
if (officialRoadmaps.length > 0) {
return officialRoadmaps;
}
const { error, response } = await httpGet<PageType[]>(`/pages.json`);
if (error) {
toast.error(error.message || 'Something went wrong');
return;
}
if (!response) {
return [];
}
const allRoadmaps = response
.filter((page) => page.group === 'Roadmaps')
.sort((a, b) => {
if (a.title === 'Android') return 1;
return a.title.localeCompare(b.title);
})
.map((page) => ({
_id: page.id,
term: page.title,
title: page.title,
isOfficial: true,
}));
setOfficialRoadmaps(allRoadmaps);
return allRoadmaps;
};
useEffect(() => {
if (debouncedSearchValue.length === 0 || isFirstRender.current) {
setSearchResults([]);
return;
}
setIsActive(true);
setIsLoading(true);
loadTopAIRoadmapTerm()
.then((results) => {
const normalizedSearchText = debouncedSearchValue.trim().toLowerCase();
const matchingOfficialRoadmaps = officialRoadmaps.filter((roadmap) => {
return (
roadmap.title.toLowerCase().indexOf(normalizedSearchText) !== -1
);
});
setSearchResults(
[...matchingOfficialRoadmaps, ...results]?.slice(0, 5) || [],
);
setActiveCounter(0);
})
.finally(() => {
setIsLoading(false);
});
}, [debouncedSearchValue]);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
loadOfficialRoadmaps().finally(() => {});
}
}, []);
useOutsideClick(dropdownRef, () => {
setIsActive(false);
});
const isFinishedTyping = debouncedSearchValue === searchedText;
return (
<div className={cn('relative', wrapperClassName)}>
<input
{...inputProps}
ref={searchInputRef}
type="text"
value={defaultValue}
className={cn(
'w-full rounded-md border border-gray-400 px-3 py-2.5 pr-8 transition-colors focus:border-black focus:outline-none',
inputClassName,
)}
placeholder={placeholder}
autoComplete="off"
onChange={(e) => {
const value = (e.target as HTMLInputElement).value;
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') {
if (!searchResults.length || !isFinishedTyping) {
return;
}
e.preventDefault();
const activeData = searchResults[activeCounter];
if (activeData) {
if (activeData.isOfficial) {
window.open(`/${activeData._id}`, '_blank')?.focus();
return;
}
onValueChange(activeData.term);
onSelect?.(activeData._id, activeData.title);
setIsActive(false);
}
}
}}
/>
{isLoading && (
<div className="absolute right-3 top-0 flex h-full items-center">
<Spinner
isDualRing={false}
className="h-5 w-5 animate-spin stroke-[2.5]"
/>
</div>
)}
{isActive &&
isFinishedTyping &&
searchResults.length > 0 &&
searchedText.length > 0 && (
<div
className="absolute top-full z-50 mt-1 w-full rounded-md border bg-white p-1 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={() => {
if (result.isOfficial) {
window.location.href = `/${result._id}`;
return;
}
onValueChange(result?.term);
onSelect?.(result._id, result.title);
setSearchedText('');
setIsActive(false);
}}
>
<span
className={cn(
'mr-2 rounded-full p-1 px-1.5 text-xs leading-none',
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>
);
}

@ -36,6 +36,8 @@ import { RoadmapTopicDetail } from './RoadmapTopicDetail.tsx';
import { AIRoadmapAlert } from './AIRoadmapAlert.tsx'; 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 { useParams } from '../../hooks/use-params.ts';
export type GetAIRoadmapLimitResponse = { export type GetAIRoadmapLimitResponse = {
used: number; used: number;
@ -90,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] =
@ -120,12 +123,6 @@ export function GenerateRoadmap() {
setIsLoading(true); setIsLoading(true);
setHasSubmitted(true); setHasSubmitted(true);
if (roadmapLimitUsed >= roadmapLimit) {
toast.error('You have reached your limit of generating roadmaps');
setIsLoading(false);
return;
}
deleteUrlParam('id'); deleteUrlParam('id');
setCurrentRoadmap(null); setCurrentRoadmap(null);
@ -171,10 +168,13 @@ export function GenerateRoadmap() {
const roadmapId = result.match(ROADMAP_ID_REGEX)?.[1] || ''; const roadmapId = result.match(ROADMAP_ID_REGEX)?.[1] || '';
setUrlParams({ id: roadmapId }); setUrlParams({ id: roadmapId });
result = result.replace(ROADMAP_ID_REGEX, ''); result = result.replace(ROADMAP_ID_REGEX, '');
const roadmapTitle =
result.trim().split('\n')[0]?.replace('#', '')?.trim() || term;
setRoadmapTerm(roadmapTitle);
setCurrentRoadmap({ setCurrentRoadmap({
id: roadmapId, id: roadmapId,
term: roadmapTerm, term: roadmapTerm,
title: term, title: roadmapTitle,
data: result, data: result,
}); });
} }
@ -193,11 +193,11 @@ 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;
} }
if (roadmapTerm === currentRoadmap?.topic) { if (roadmapTerm === currentRoadmap?.term) {
return; return;
} }
@ -293,7 +293,8 @@ export function GenerateRoadmap() {
pageProgressMessage.set('Loading Roadmap'); pageProgressMessage.set('Loading Roadmap');
const { response, error } = await httpGet<{ const { response, error } = await httpGet<{
topic: string; term: string;
title: string;
data: string; data: string;
}>(`${import.meta.env.PUBLIC_API_URL}/v1-get-ai-roadmap/${roadmapId}`); }>(`${import.meta.env.PUBLIC_API_URL}/v1-get-ai-roadmap/${roadmapId}`);
@ -479,7 +480,7 @@ export function GenerateRoadmap() {
> >
{roadmapLimitUsed} of {roadmapLimit} {roadmapLimitUsed} of {roadmapLimit}
</span>{' '} </span>{' '}
roadmaps generated. roadmaps generated today.
</span> </span>
{!openAPIKey && ( {!openAPIKey && (
<button <button
@ -516,15 +517,14 @@ export function GenerateRoadmap() {
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="my-3 flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-center" className="my-3 flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-center"
> >
<input <AITermSuggestionInput
type="text"
autoFocus
placeholder="e.g. Try searching for Ansible or DevOps"
className="flex-grow rounded-md border border-gray-400 px-3 py-2 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="e.g. Try searching for Ansible or DevOps"
} wrapperClassName="grow"
onSelect={(id, title) => {
loadTermRoadmap(title).finally(() => null);
}}
/> />
<button <button
type={'submit'} type={'submit'}
@ -539,14 +539,22 @@ export function GenerateRoadmap() {
} }
}} }}
disabled={ disabled={
isAuthenticatedUser && isLoadingResults ||
(isAuthenticatedUser &&
(!roadmapLimit || (!roadmapLimit ||
!roadmapTerm || !roadmapTerm ||
roadmapLimitUsed >= roadmapLimit || roadmapLimitUsed >= roadmapLimit ||
roadmapTerm === currentRoadmap?.term || roadmapTerm === currentRoadmap?.term ||
(isKeyOnly && !openAPIKey)) (isKeyOnly && !openAPIKey)))
} }
> >
{isLoadingResults && (
<>
<span>Please wait..</span>
</>
)}
{!isLoadingResults && (
<>
{!isAuthenticatedUser && ( {!isAuthenticatedUser && (
<> <>
<Wand size={20} /> <Wand size={20} />
@ -573,6 +581,8 @@ export function GenerateRoadmap() {
)} )}
</> </>
)} )}
</>
)}
</button> </button>
</form> </form>
<div className="flex w-full items-center justify-between gap-2"> <div className="flex w-full items-center justify-between gap-2">

@ -1,17 +1,11 @@
import { import { ArrowUpRight, Ban, Cog, Telescope, Wand } from 'lucide-react';
ArrowUpRight,
Ban,
CircleFadingPlus,
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 { useState } from 'react';
import { OpenAISettings } from './OpenAISettings.tsx'; import { OpenAISettings } from './OpenAISettings.tsx';
import { AITermSuggestionInput } from './AITermSuggestionInput.tsx';
type RoadmapSearchProps = { type RoadmapSearchProps = {
roadmapTerm: string; roadmapTerm: string;
@ -38,8 +32,14 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
const canGenerateMore = limitUsed < limit; const canGenerateMore = limitUsed < limit;
const [isConfiguring, setIsConfiguring] = useState(false); const [isConfiguring, setIsConfiguring] = useState(false);
const openAPIKey = getOpenAIKey(); const [openAPIKey, setOpenAPIKey] = useState('');
const isAuthenticatedUser = isLoggedIn(); const [isAuthenticatedUser, setIsAuthenticatedUser] = useState(false);
const [isLoadingResults, setIsLoadingResults] = useState(false);
useEffect(() => {
setOpenAPIKey(getOpenAIKey() || '');
setIsAuthenticatedUser(isLoggedIn());
}, []);
const randomTerms = ['OAuth', 'APIs', 'UX Design', 'gRPC']; const randomTerms = ['OAuth', 'APIs', 'UX Design', 'gRPC'];
@ -78,15 +78,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, roadmapTitle) => {
onLoadTerm(roadmapTitle);
}}
/> />
<button <button
className={cn( className={cn(
@ -100,13 +100,22 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
} }
}} }}
disabled={ disabled={
isAuthenticatedUser && isLoadingResults ||
(isAuthenticatedUser &&
(!limit || (!limit ||
!roadmapTerm || !roadmapTerm ||
limitUsed >= limit || limitUsed >= limit ||
(isKeyOnly && !openAPIKey)) (isKeyOnly && !openAPIKey)))
} }
> >
{isLoadingResults && (
<>
<span>Please wait..</span>
</>
)}
{!isLoadingResults && (
<>
{!isAuthenticatedUser && ( {!isAuthenticatedUser && (
<> <>
<Wand size={20} /> <Wand size={20} />
@ -130,6 +139,8 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
)} )}
</> </>
)} )}
</>
)}
</button> </button>
</form> </form>
<div className="flex flex-row flex-wrap items-center justify-center gap-2"> <div className="flex flex-row flex-wrap items-center justify-center gap-2">
@ -242,7 +253,7 @@ export function RoadmapSearch(props: RoadmapSearchProps) {
> >
{limitUsed} of {limit} {limitUsed} of {limit}
</span>{' '} </span>{' '}
roadmaps. roadmaps today.
</p> </p>
{isAuthenticatedUser && ( {isAuthenticatedUser && (
<p className="flex items-center text-sm"> <p className="flex items-center text-sm">

@ -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