mirror of
https://github.com/StanGirard/quivr.git
synced 2024-12-03 06:24:15 +03:00
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:
parent
9cd7cca7df
commit
1cf0e1a164
@ -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}
|
||||
/>
|
||||
);
|
||||
|
@ -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,
|
||||
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
@ -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]);
|
||||
};
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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" />}
|
||||
</>
|
||||
);
|
||||
|
||||
|
38
frontend/lib/components/ui/LoaderIcon/LoaderIcon.module.scss
Normal file
38
frontend/lib/components/ui/LoaderIcon/LoaderIcon.module.scss
Normal 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;
|
||||
}
|
||||
}
|
17
frontend/lib/components/ui/LoaderIcon/LoaderIcon.tsx
Normal file
17
frontend/lib/components/ui/LoaderIcon/LoaderIcon.tsx
Normal 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] ?? ""}`}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
1
frontend/lib/types/Icons.ts
Normal file
1
frontend/lib/types/Icons.ts
Normal file
@ -0,0 +1 @@
|
||||
export type IconSize = "small" | "normal" | "large" | "big";
|
@ -1,2 +1,4 @@
|
||||
$small: 12px;
|
||||
$normal: 18px;
|
||||
$large: 24px;
|
||||
$big: 30px;
|
||||
$medium: 24px;
|
Loading…
Reference in New Issue
Block a user