From 6e6489bc4c4adbd3df09fa6903249defd36e0cc8 Mon Sep 17 00:00:00 2001
From: Arik Chakma
Date: Wed, 20 Mar 2024 02:42:29 +0600
Subject: [PATCH] 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
---
.../GenerateRoadmap/AITermSuggestionInput.tsx | 284 ++++++++++++++++++
.../GenerateRoadmap/GenerateRoadmap.tsx | 86 +++---
.../GenerateRoadmap/RoadmapSearch.tsx | 81 ++---
src/hooks/use-debounce.ts | 17 ++
4 files changed, 395 insertions(+), 73 deletions(-)
create mode 100644 src/components/GenerateRoadmap/AITermSuggestionInput.tsx
create mode 100644 src/hooks/use-debounce.ts
diff --git a/src/components/GenerateRoadmap/AITermSuggestionInput.tsx b/src/components/GenerateRoadmap/AITermSuggestionInput.tsx
new file mode 100644
index 000000000..e54efd0a9
--- /dev/null
+++ b/src/components/GenerateRoadmap/AITermSuggestionInput.tsx
@@ -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,
+ 'onSelect' | 'onChange' | 'className'
+>;
+
+export function AITermSuggestionInput(props: AITermSuggestionInputProps) {
+ const {
+ value: defaultValue,
+ onValueChange,
+ onSelect,
+ inputClassName,
+ wrapperClassName,
+ placeholder,
+ ...inputProps
+ } = props;
+
+ const termCache = useMemo(
+ () => new Map(),
+ [],
+ );
+ const [officialRoadmaps, setOfficialRoadmaps] =
+ useState([]);
+
+ const toast = useToast();
+ const searchInputRef = useRef(null);
+ const dropdownRef = useRef(null);
+ const isFirstRender = useRef(true);
+
+ const [isActive, setIsActive] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+ const [searchResults, setSearchResults] =
+ useState([]);
+ 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(
+ `${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(`/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 (
+
+
{
+ 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 && (
+
+
+
+ )}
+
+ {isActive &&
+ isFinishedTyping &&
+ searchResults.length > 0 &&
+ searchedText.length > 0 && (
+
+
+ {searchResults.map((result, counter) => {
+ return (
+
+ );
+ })}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/GenerateRoadmap/GenerateRoadmap.tsx b/src/components/GenerateRoadmap/GenerateRoadmap.tsx
index fae9ea50f..875d36905 100644
--- a/src/components/GenerateRoadmap/GenerateRoadmap.tsx
+++ b/src/components/GenerateRoadmap/GenerateRoadmap.tsx
@@ -36,6 +36,8 @@ import { RoadmapTopicDetail } from './RoadmapTopicDetail.tsx';
import { AIRoadmapAlert } from './AIRoadmapAlert.tsx';
import { OpenAISettings } from './OpenAISettings.tsx';
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 = {
used: number;
@@ -90,6 +92,7 @@ export function GenerateRoadmap() {
const [hasSubmitted, setHasSubmitted] = useState(false);
const [isLoading, setIsLoading] = useState(false);
+ const [isLoadingResults, setIsLoadingResults] = useState(false);
const [roadmapTerm, setRoadmapTerm] = useState('');
const [generatedRoadmapContent, setGeneratedRoadmapContent] = useState('');
const [currentRoadmap, setCurrentRoadmap] =
@@ -120,12 +123,6 @@ export function GenerateRoadmap() {
setIsLoading(true);
setHasSubmitted(true);
- if (roadmapLimitUsed >= roadmapLimit) {
- toast.error('You have reached your limit of generating roadmaps');
- setIsLoading(false);
- return;
- }
-
deleteUrlParam('id');
setCurrentRoadmap(null);
@@ -171,10 +168,13 @@ export function GenerateRoadmap() {
const roadmapId = result.match(ROADMAP_ID_REGEX)?.[1] || '';
setUrlParams({ id: roadmapId });
result = result.replace(ROADMAP_ID_REGEX, '');
+ const roadmapTitle =
+ result.trim().split('\n')[0]?.replace('#', '')?.trim() || term;
+ setRoadmapTerm(roadmapTitle);
setCurrentRoadmap({
id: roadmapId,
term: roadmapTerm,
- title: term,
+ title: roadmapTitle,
data: result,
});
}
@@ -193,11 +193,11 @@ export function GenerateRoadmap() {
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
- if (!roadmapTerm) {
+ if (!roadmapTerm || isLoadingResults) {
return;
}
- if (roadmapTerm === currentRoadmap?.topic) {
+ if (roadmapTerm === currentRoadmap?.term) {
return;
}
@@ -293,7 +293,8 @@ export function GenerateRoadmap() {
pageProgressMessage.set('Loading Roadmap');
const { response, error } = await httpGet<{
- topic: string;
+ term: string;
+ title: string;
data: string;
}>(`${import.meta.env.PUBLIC_API_URL}/v1-get-ai-roadmap/${roadmapId}`);
@@ -479,7 +480,7 @@ export function GenerateRoadmap() {
>
{roadmapLimitUsed} of {roadmapLimit}
{' '}
- roadmaps generated.
+ roadmaps generated today.
{!openAPIKey && (
{isAuthenticatedUser && (
diff --git a/src/hooks/use-debounce.ts b/src/hooks/use-debounce.ts
new file mode 100644
index 000000000..a4cc0f2d2
--- /dev/null
+++ b/src/hooks/use-debounce.ts
@@ -0,0 +1,17 @@
+import { useEffect, useState } from 'react';
+
+export function useDebounceValue(value: T, delay: number): T {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [value, delay]);
+
+ return debouncedValue;
+}