feat(frontend): handle mentions in search bar (#2049)

# Description

- Handle mentions in search bar
- Add a Loader Icon component and use it in Search Bar
- Custom Placeholder possible on Editor Component
- Remove unused useEditorStateUpdater
- Fix Bug when Enter was typed

## Checklist before requesting a review

Please delete options that are not relevant.

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented hard-to-understand areas
- [ ] I have ideally added tests that prove my fix is effective or that
my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged

## Screenshots (if appropriate):
This commit is contained in:
Antoine Dewez 2024-01-21 18:13:20 -08:00 committed by GitHub
parent 9cd7cca7df
commit 1cf0e1a164
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 144 additions and 168 deletions

View File

@ -4,39 +4,34 @@ import "./styles.css";
import { useChatStateUpdater } from "./hooks/useChatStateUpdater";
import { useCreateEditorState } from "./hooks/useCreateEditorState";
import { useEditor } from "./hooks/useEditor";
import { useEditorStateUpdater } from "./hooks/useEditorStateUpdater";
type EditorProps = {
onSubmit: () => void;
setMessage: (text: string) => void;
message: string;
placeholder?: string;
};
export const Editor = ({
setMessage,
message,
onSubmit,
placeholder,
}: EditorProps): JSX.Element => {
const { editor } = useCreateEditorState();
const { editor } = useCreateEditorState(placeholder);
useChatStateUpdater({
editor,
setMessage,
});
useEditorStateUpdater({
editor,
message,
});
const { submitOnEnter } = useEditor({
onSubmit,
});
return (
<EditorContent
className="w-full"
onKeyDown={submitOnEnter}
className="w-full caret-accent"
onKeyDown={(event) => void submitOnEnter(event)}
editor={editor}
/>
);

View File

@ -1,20 +1,28 @@
import Document from "@tiptap/extension-document";
import HardBreak from "@tiptap/extension-hard-break";
import Paragraph from "@tiptap/extension-paragraph";
import Placeholder from "@tiptap/extension-placeholder";
import Text from "@tiptap/extension-text";
import { useEditor } from "@tiptap/react";
import { Document } from "@tiptap/extension-document";
import { HardBreak } from "@tiptap/extension-hard-break";
import { Paragraph } from "@tiptap/extension-paragraph";
import { Placeholder } from "@tiptap/extension-placeholder";
import { Text } from "@tiptap/extension-text";
import { Extension, useEditor } from "@tiptap/react";
import { useTranslation } from "react-i18next";
import { useBrainMention } from "./useBrainMention";
import { usePromptMention } from "./usePromptSuggestionsConfig";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useCreateEditorState = () => {
export const useCreateEditorState = (placeholder?: string) => {
const { t } = useTranslation(["chat"]);
const { BrainMention, items } = useBrainMention();
const { PromptMention } = usePromptMention();
const PreventEnter = Extension.create({
addKeyboardShortcuts: () => {
return {
Enter: () => true,
};
},
});
const editor = useEditor(
{
autofocus: true,
@ -22,9 +30,10 @@ export const useCreateEditorState = () => {
editor?.commands.focus("end");
},
extensions: [
PreventEnter,
Placeholder.configure({
showOnlyWhenEditable: true,
placeholder: t("actions_bar_placeholder"),
placeholder: placeholder ?? t("actions_bar_placeholder"),
}),
Document,
Text,

View File

@ -7,8 +7,7 @@ type UseEditorProps = {
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useEditor = ({ onSubmit }: UseEditorProps) => {
const submitOnEnter = (ev: KeyboardEvent<HTMLDivElement>) => {
if (ev.key === "Enter" && !ev.shiftKey) {
ev.preventDefault();
if (ev.key === "Enter" && !ev.shiftKey && !ev.metaKey) {
onSubmit();
}
};

View File

@ -1,74 +0,0 @@
import { Editor } from "@tiptap/core";
import { useCallback, useEffect } from "react";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { getChatInputAttributesFromEditorState } from "../utils/getChatInputAttributesFromEditorState";
type UseEditorStateUpdaterProps = {
editor: Editor | null;
message: string;
};
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useEditorStateUpdater = ({
message,
editor,
}: UseEditorStateUpdaterProps) => {
const { currentBrain, currentPrompt } = useBrainContext();
const setCurrentBrainAndPrompt = useCallback(() => {
const { promptId, brainId } = getChatInputAttributesFromEditorState(editor);
if (currentBrain !== undefined && currentBrain.id !== brainId) {
editor
?.chain()
.focus()
.insertContent({
type: "mention@",
attrs: {
id: currentBrain.id,
label: currentBrain.name,
},
})
.insertContent({
type: "text",
text: " ",
})
.run();
}
if (
currentPrompt !== undefined &&
currentPrompt.id !== promptId &&
promptId === ""
) {
editor
?.chain()
.focus()
.insertContent({
type: "mention#",
attrs: {
id: currentPrompt.id,
label: currentPrompt.title,
},
})
.insertContent({
type: "text",
text: " ",
})
.run();
}
}, [currentBrain, currentPrompt, editor]);
useEffect(() => {
setCurrentBrainAndPrompt();
}, [setCurrentBrainAndPrompt]);
useEffect(() => {
const { text } = getChatInputAttributesFromEditorState(editor);
if (text !== message) {
editor?.commands.setContent(message);
}
setCurrentBrainAndPrompt();
}, [editor, message, setCurrentBrainAndPrompt]);
};

View File

@ -1,13 +1,13 @@
@use '@/styles/Colors.module.scss';
@use '@/styles/IconSizes.module.scss';
@use '@/styles/Spacings.module.scss';
@use "@/styles/Colors.module.scss";
@use "@/styles/IconSizes.module.scss";
@use "@/styles/Spacings.module.scss";
.menu_icon {
width: IconSizes.$medium;
height: IconSizes.$medium;
cursor: pointer;
width: IconSizes.$large;
height: IconSizes.$large;
cursor: pointer;
&:hover {
color: Colors.$accent;
}
}
&:hover {
color: Colors.$accent;
}
}

View File

@ -7,9 +7,9 @@ import {
} from "@radix-ui/react-tooltip";
import { cva, type VariantProps } from "class-variance-authority";
import { ButtonHTMLAttributes, Ref, RefAttributes, forwardRef } from "react";
import { FaSpinner } from "react-icons/fa";
import { cn } from "@/lib/utils";
import { AiOutlineLoading3Quarters } from "react-icons/ai";
const ButtonVariants = cva(
"px-8 py-3 text-sm disabled:opacity-80 text-center font-medium rounded-md focus:ring ring-primary/10 outline-none flex items-center justify-center gap-2 transition-opacity focus:ring-0",
@ -65,7 +65,8 @@ const Button = forwardRef(
const buttonChildren = (
<>
{children} {isLoading && <FaSpinner className="animate-spin" />}
{children}{" "}
{isLoading && <AiOutlineLoading3Quarters className="animate-spin" />}
</>
);

View File

@ -0,0 +1,38 @@
@use "@/styles/Colors.module.scss";
@use "@/styles/IconSizes.module.scss";
.loader_icon {
animation: spin 1s linear infinite;
width: IconSizes.$big;
height: IconSizes.$big;
color: Colors.$accent;
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
&.small {
width: IconSizes.$small;
height: IconSizes.$small;
}
&.normal {
width: IconSizes.$normal;
height: IconSizes.$normal;
}
&.large {
width: IconSizes.$large;
height: IconSizes.$large;
}
&.big {
width: IconSizes.$big;
height: IconSizes.$big;
}
}

View File

@ -0,0 +1,17 @@
import { AiOutlineLoading3Quarters } from "react-icons/ai";
import { IconSize } from "@/lib/types/Icons";
import styles from "./LoaderIcon.module.scss";
interface LoaderIconProps {
size: IconSize;
}
export const LoaderIcon = (props: LoaderIconProps): JSX.Element => {
return (
<AiOutlineLoading3Quarters
className={`${styles.loader_icon ?? ""} ${styles[props.size] ?? ""}`}
/>
);
};

View File

@ -1,41 +1,32 @@
@use '@/styles/Colors.module.scss';
@use '@/styles/IconSizes.module.scss';
@use '@/styles/Spacings.module.scss';
@use "@/styles/Colors.module.scss";
@use "@/styles/IconSizes.module.scss";
@use "@/styles/Spacings.module.scss";
.search_bar_wrapper {
display: flex;
justify-content: space-between;
align-items: center;
gap: Spacings.$spacing03;
background-color: Colors.$white;
padding: Spacings.$spacing05;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
display: flex;
justify-content: space-between;
align-items: center;
gap: Spacings.$spacing03;
background-color: Colors.$white;
padding: Spacings.$spacing05;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
&:hover {
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
&:hover {
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1),
0 8px 10px -6px rgb(0 0 0 / 0.1);
}
.search_icon {
width: IconSizes.$big;
height: IconSizes.$big;
color: Colors.$accent;
cursor: pointer;
&.disabled {
color: Colors.$black;
pointer-events: none;
opacity: 0.2;
}
.search_input {
border: none;
flex: 1;
caret-color: Colors.$accent;
&:focus {
box-shadow: none;
}
}
.search_icon {
width: IconSizes.$big;
height: IconSizes.$big;
color: Colors.$accent;
cursor: pointer;
&.disabled {
color: Colors.$black;
pointer-events: none;
opacity: 0.2;
}
}
}
}
}

View File

@ -1,14 +1,18 @@
import { ChangeEvent, useEffect } from "react";
import { useEffect, useState } from "react";
import { LuSearch } from "react-icons/lu";
import { Editor } from "@/app/chat/[chatId]/components/ActionsBar/components/ChatInput/components/ChatEditor/components/Editor/Editor";
import { useChatInput } from "@/app/chat/[chatId]/components/ActionsBar/components/ChatInput/hooks/useChatInput";
import { useChat } from "@/app/chat/[chatId]/hooks/useChat";
import { useChatContext } from "@/lib/context";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { LoaderIcon } from "../LoaderIcon/LoaderIcon";
// eslint-disable-next-line import/order
import styles from "./SearchBar.module.scss";
export const SearchBar = (): JSX.Element => {
const [searching, setSearching] = useState(false);
const { message, setMessage } = useChatInput();
const { setMessages } = useChatContext();
const { addQuestion } = useChat();
@ -18,24 +22,15 @@ export const SearchBar = (): JSX.Element => {
setCurrentBrainId(null);
});
const handleChange = (event: ChangeEvent<HTMLInputElement>): void => {
setMessage(event.target.value);
};
const handleEnter = async (
event: React.KeyboardEvent<HTMLInputElement>
): Promise<void> => {
if (event.key === "Enter") {
await submit();
}
};
const submit = async (): Promise<void> => {
setSearching(true);
setMessages([]);
try {
await addQuestion(message);
} catch (error) {
console.error(error);
} finally {
setSearching(false);
}
};
@ -43,18 +38,20 @@ export const SearchBar = (): JSX.Element => {
return (
<div className={styles.search_bar_wrapper}>
<input
className={styles.search_input}
type="text"
<Editor
message={message}
setMessage={setMessage}
onSubmit={() => void submit()}
placeholder="Search"
value={message}
onChange={handleChange}
onKeyDown={(event) => void handleEnter(event)}
/>
<LuSearch
className={`${styles.search_icon} ${!message ? styles.disabled : ""}`}
onClick={() => void submit()}
/>
></Editor>
{searching ? (
<LoaderIcon size="big" />
) : (
<LuSearch
className={`${styles.search_icon} ${!message ? styles.disabled : ""}`}
onClick={() => void submit()}
/>
)}
</div>
);
};

View File

@ -0,0 +1 @@
export type IconSize = "small" | "normal" | "large" | "big";

View File

@ -1,2 +1,4 @@
$small: 12px;
$normal: 18px;
$large: 24px;
$big: 30px;
$medium: 24px;