feat: allow user to control left panel from Chat input (#1880)

Issue: https://github.com/StanGirard/quivr/issues/1866

- Use context to control sidebar open status
- Control sidebar through chat bar
- Persist sidebar status after page change

Demo:



https://github.com/StanGirard/quivr/assets/63923024/b9750198-e68d-47a7-b266-627a01586512
This commit is contained in:
Mamadou DICKO 2023-12-13 20:56:07 +01:00 committed by GitHub
parent 5f114c26d6
commit 3ed0ea15bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 125 additions and 37 deletions

View File

@ -5,6 +5,7 @@ import { PropsWithChildren, useEffect } from "react";
import { BrainProvider } from "@/lib/context"; import { BrainProvider } from "@/lib/context";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext"; import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { SideBarProvider } from "@/lib/context/SidebarProvider/sidebar-provider";
import { useSupabase } from "@/lib/context/SupabaseProvider"; import { useSupabase } from "@/lib/context/SupabaseProvider";
import { UpdateMetadata } from "@/lib/helpers/updateMetadata"; import { UpdateMetadata } from "@/lib/helpers/updateMetadata";
import { usePageTracking } from "@/services/analytics/june/usePageTracking"; import { usePageTracking } from "@/services/analytics/june/usePageTracking";
@ -40,7 +41,9 @@ const AppWithQueryClient = ({ children }: PropsWithChildren): JSX.Element => {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<BrainProvider> <BrainProvider>
<App>{children}</App> <SideBarProvider>
<App>{children}</App>
</SideBarProvider>
</BrainProvider> </BrainProvider>
</QueryClientProvider> </QueryClientProvider>
); );

View File

@ -11,6 +11,7 @@ import {
ChatProviderMock, ChatProviderMock,
} from "@/lib/context/ChatProvider/mocks/ChatProviderMock"; } from "@/lib/context/ChatProvider/mocks/ChatProviderMock";
import { KnowledgeToFeedProvider } from "@/lib/context/KnowledgeToFeedProvider"; import { KnowledgeToFeedProvider } from "@/lib/context/KnowledgeToFeedProvider";
import { SideBarProvider } from "@/lib/context/SidebarProvider/sidebar-provider";
import { import {
SupabaseContextMock, SupabaseContextMock,
SupabaseProviderMock, SupabaseProviderMock,
@ -87,7 +88,9 @@ describe("Chat page", () => {
<ChatProviderMock> <ChatProviderMock>
<SupabaseProviderMock> <SupabaseProviderMock>
<BrainProviderMock> <BrainProviderMock>
<SelectedChatPage />, <SideBarProvider>
<SelectedChatPage />,
</SideBarProvider>
</BrainProviderMock> </BrainProviderMock>
</SupabaseProviderMock> </SupabaseProviderMock>
</ChatProviderMock> </ChatProviderMock>

View File

@ -0,0 +1,25 @@
import { useTranslation } from "react-i18next";
import { LuPanelLeftClose, LuPanelRightClose } from "react-icons/lu";
import Button from "@/lib/components/ui/Button";
import { useSideBarContext } from "@/lib/context/SidebarProvider/hooks/useSideBarContext";
export const MenuControlButton = (): JSX.Element => {
const { isOpened, setIsOpened } = useSideBarContext();
const Icon = isOpened ? LuPanelLeftClose : LuPanelRightClose;
const { t } = useTranslation("chat");
return (
<Button
variant="tertiary"
className="px-2 py-0"
type="button"
onClick={() => setIsOpened(!isOpened)}
>
<div className="flex flex-col items-center justify-center gap-1">
<Icon className="text-2xl md:text-3xl self-center text-accent" />
<span className="text-xs">{t("menu")}</span>
</div>
</Button>
);
};

View File

@ -9,6 +9,7 @@ import { getBrainIconFromBrainType } from "@/lib/helpers/getBrainIconFromBrainTy
import { OnboardingQuestions } from "./components"; import { OnboardingQuestions } from "./components";
import { ActionsModal } from "./components/ActionsModal/ActionsModal"; import { ActionsModal } from "./components/ActionsModal/ActionsModal";
import { ChatEditor } from "./components/ChatEditor/ChatEditor"; import { ChatEditor } from "./components/ChatEditor/ChatEditor";
import { MenuControlButton } from "./components/MenuControlButton";
import { useChatInput } from "./hooks/useChatInput"; import { useChatInput } from "./hooks/useChatInput";
type ChatInputProps = { type ChatInputProps = {
@ -37,6 +38,7 @@ export const ChatInput = ({
}} }}
className="sticky bottom-0 bg-white dark:bg-black w-full flex items-center gap-2 z-20 p-2" className="sticky bottom-0 bg-white dark:bg-black w-full flex items-center gap-2 z-20 p-2"
> >
<MenuControlButton />
{!shouldDisplayFeedOrSecretsCard && ( {!shouldDisplayFeedOrSecretsCard && (
<Button <Button
className="p-0" className="p-0"

View File

@ -12,6 +12,7 @@ import {
ChatProviderMock, ChatProviderMock,
} from "@/lib/context/ChatProvider/mocks/ChatProviderMock"; } from "@/lib/context/ChatProvider/mocks/ChatProviderMock";
import { KnowledgeToFeedProvider } from "@/lib/context/KnowledgeToFeedProvider"; import { KnowledgeToFeedProvider } from "@/lib/context/KnowledgeToFeedProvider";
import { SideBarProvider } from "@/lib/context/SidebarProvider/sidebar-provider";
import { SupabaseContextMock } from "@/lib/context/SupabaseProvider/mocks/SupabaseProviderMock"; import { SupabaseContextMock } from "@/lib/context/SupabaseProvider/mocks/SupabaseProviderMock";
vi.mock("@/lib/context/SupabaseProvider/supabase-provider", () => ({ vi.mock("@/lib/context/SupabaseProvider/supabase-provider", () => ({
@ -91,7 +92,9 @@ describe("ChatsList", () => {
<KnowledgeToFeedProvider> <KnowledgeToFeedProvider>
<ChatProviderMock> <ChatProviderMock>
<BrainProviderMock> <BrainProviderMock>
<ChatsList /> <SideBarProvider>
<ChatsList />
</SideBarProvider>
</BrainProviderMock> </BrainProviderMock>
</ChatProviderMock> </ChatProviderMock>
</KnowledgeToFeedProvider> </KnowledgeToFeedProvider>
@ -110,7 +113,9 @@ describe("ChatsList", () => {
<KnowledgeToFeedProvider> <KnowledgeToFeedProvider>
<ChatProviderMock> <ChatProviderMock>
<BrainProviderMock> <BrainProviderMock>
<ChatsList /> <SideBarProvider>
<ChatsList />
</SideBarProvider>
</BrainProviderMock> </BrainProviderMock>
</ChatProviderMock> </ChatProviderMock>
</KnowledgeToFeedProvider> </KnowledgeToFeedProvider>
@ -133,7 +138,9 @@ describe("ChatsList", () => {
<KnowledgeToFeedProvider> <KnowledgeToFeedProvider>
<ChatProviderMock> <ChatProviderMock>
<BrainProviderMock> <BrainProviderMock>
<ChatsList /> <SideBarProvider>
<ChatsList />
</SideBarProvider>
</BrainProviderMock> </BrainProviderMock>
</ChatProviderMock> </ChatProviderMock>
</KnowledgeToFeedProvider> </KnowledgeToFeedProvider>

View File

@ -1,10 +1,8 @@
import { motion, MotionConfig } from "framer-motion"; import { motion, MotionConfig } from "framer-motion";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { LuPanelLeftOpen } from "react-icons/lu"; import { LuPanelLeftOpen } from "react-icons/lu";
import { SidebarHeader } from "@/lib/components/Sidebar/components/SidebarHeader"; import { SidebarHeader } from "@/lib/components/Sidebar/components/SidebarHeader";
import { useDevice } from "@/lib/hooks/useDevice"; import { useSideBarContext } from "@/lib/context/SidebarProvider/hooks/useSideBarContext";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
@ -21,13 +19,7 @@ export const Sidebar = ({
children, children,
showButtons, showButtons,
}: SidebarProps): JSX.Element => { }: SidebarProps): JSX.Element => {
const { isMobile } = useDevice(); const { isOpened, setIsOpened } = useSideBarContext();
const pathname = usePathname();
const [open, setOpen] = useState(!isMobile);
useEffect(() => {
setOpen(!isMobile);
}, [isMobile, pathname]);
return ( return (
<MotionConfig transition={{ mass: 1, damping: 10, duration: 0.2 }}> <MotionConfig transition={{ mass: 1, damping: 10, duration: 0.2 }}>
@ -36,40 +28,40 @@ export const Sidebar = ({
dragConstraints={{ right: 0, left: 0 }} dragConstraints={{ right: 0, left: 0 }}
dragElastic={0.15} dragElastic={0.15}
onDragEnd={(event, info) => { onDragEnd={(event, info) => {
if (info.offset.x > 100 && !open) { if (info.offset.x > 100 && !isOpened) {
setOpen(true); setIsOpened(true);
} else if (info.offset.x < -100 && open) { } else if (info.offset.x < -100 && isOpened) {
setOpen(false); setIsOpened(false);
} }
}} }}
className="flex flex-col fixed sm:sticky top-0 left-0 h-full overflow-visible z-30 border-r border-black/10 dark:border-white/25 bg-white dark:bg-black" className="flex flex-col fixed sm:sticky top-0 left-0 h-full overflow-visible z-30 border-r border-black/10 dark:border-white/25 bg-white dark:bg-black"
> >
{!open && ( {!isOpened && (
<button <button
title="Open Sidebar" title="Open Sidebar"
type="button" type="button"
className="absolute p-3 text-2xl bg-red top-5 -right-20 hover:text-primary dark:hover:text-gray-200 transition-colors" className="absolute p-3 text-2xl bg-red top-5 -right-20 hover:text-primary dark:hover:text-gray-200 transition-colors"
data-testid="open-sidebar-button" data-testid="open-sidebar-button"
onClick={() => setOpen(true)} onClick={() => setIsOpened(true)}
> >
<LuPanelLeftOpen /> <LuPanelLeftOpen />
</button> </button>
)} )}
<motion.div <motion.div
initial={{ initial={{
width: open ? "18rem" : "0px", width: isOpened ? "18rem" : "0px",
}} }}
animate={{ animate={{
width: open ? "18rem" : "0px", width: isOpened ? "18rem" : "0px",
opacity: open ? 1 : 0.5, opacity: isOpened ? 1 : 0.5,
boxShadow: open boxShadow: isOpened
? "10px 10px 16px rgba(0, 0, 0, 0)" ? "10px 10px 16px rgba(0, 0, 0, 0)"
: "10px 10px 16px rgba(0, 0, 0, 0.5)", : "10px 10px 16px rgba(0, 0, 0, 0.5)",
}} }}
className={cn("overflow-hidden flex flex-col flex-1 max-w-xs")} className={cn("overflow-hidden flex flex-col flex-1 max-w-xs")}
data-testid="sidebar" data-testid="sidebar"
> >
<SidebarHeader setOpen={setOpen} /> <SidebarHeader />
<div className="overflow-auto flex flex-col flex-1">{children}</div> <div className="overflow-auto flex flex-col flex-1">{children}</div>
{showButtons && <SidebarFooter showButtons={showButtons} />} {showButtons && <SidebarFooter showButtons={showButtons} />}
</motion.div> </motion.div>

View File

@ -9,6 +9,7 @@ import {
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { Sidebar } from "@/lib/components/Sidebar/Sidebar"; import { Sidebar } from "@/lib/components/Sidebar/Sidebar";
import { SideBarProvider } from "@/lib/context/SidebarProvider/sidebar-provider";
import { useDevice } from "@/lib/hooks/useDevice"; import { useDevice } from "@/lib/hooks/useDevice";
vi.mock("@/lib/hooks/useDevice"); vi.mock("@/lib/hooks/useDevice");
@ -16,9 +17,11 @@ vi.mock("@/lib/hooks/useDevice");
const renderSidebar = async () => { const renderSidebar = async () => {
await act(() => await act(() =>
render( render(
<Sidebar> <SideBarProvider>
<div data-testid="sidebar-test-content">📦</div> <Sidebar>
</Sidebar> <div data-testid="sidebar-test-content">📦</div>
</Sidebar>
</SideBarProvider>
) )
); );
}; };

View File

@ -1,13 +1,11 @@
import { Dispatch, SetStateAction } from "react"; import { LuPanelLeftClose } from "react-icons/lu";
import { LuPanelLeft } from "react-icons/lu";
import { Logo } from "@/lib/components/Logo/Logo"; import { Logo } from "@/lib/components/Logo/Logo";
import { useSideBarContext } from "@/lib/context/SidebarProvider/hooks/useSideBarContext";
type SidebarProps = { export const SidebarHeader = (): JSX.Element => {
setOpen: Dispatch<SetStateAction<boolean>>; const { setIsOpened } = useSideBarContext();
};
export const SidebarHeader = ({ setOpen }: SidebarProps): JSX.Element => {
return ( return (
<div className="p-2 border-b relative"> <div className="p-2 border-b relative">
<div className="max-w-screen-xl flex justify-between items-center pt-3 pl-3"> <div className="max-w-screen-xl flex justify-between items-center pt-3 pl-3">
@ -17,9 +15,9 @@ export const SidebarHeader = ({ setOpen }: SidebarProps): JSX.Element => {
className="p-3 text-2xl bg:white dark:bg-black text-black dark:text-white hover:text-primary dark:hover:text-gray-200 transition-colors" className="p-3 text-2xl bg:white dark:bg-black text-black dark:text-white hover:text-primary dark:hover:text-gray-200 transition-colors"
type="button" type="button"
data-testid="close-sidebar-button" data-testid="close-sidebar-button"
onClick={() => setOpen(false)} onClick={() => setIsOpened(false)}
> >
<LuPanelLeft /> <LuPanelLeftClose />
</button> </button>
</div> </div>
</div> </div>

View File

@ -0,0 +1,13 @@
import { useContext } from "react";
import { SideBarContext } from "../sidebar-provider";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useSideBarContext = () => {
const context = useContext(SideBarContext);
if (context === undefined) {
throw new Error("useSideBarContext must be used within a SideBarProvider");
}
return context;
};

View File

@ -0,0 +1,36 @@
import { createContext, useEffect, useState } from "react";
import { useDevice } from "@/lib/hooks/useDevice";
type SideBarContextType = {
isOpened: boolean;
setIsOpened: React.Dispatch<React.SetStateAction<boolean>>;
};
export const SideBarContext = createContext<SideBarContextType | undefined>(
undefined
);
export const SideBarProvider = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => {
const { isMobile } = useDevice();
const [isOpened, setIsOpened] = useState(!isMobile);
useEffect(() => {
setIsOpened(!isMobile);
}, [isMobile]);
return (
<SideBarContext.Provider
value={{
isOpened,
setIsOpened,
}}
>
{children}
</SideBarContext.Provider>
);
};

View File

@ -21,6 +21,7 @@
"last30Days": "Previous 30 days", "last30Days": "Previous 30 days",
"last7Days": "Previous 7 days", "last7Days": "Previous 7 days",
"limit_reached": "You have reached the limit of requests, please try again later", "limit_reached": "You have reached the limit of requests, please try again later",
"menu": "Menu",
"missing_brain": "Please select a brain to chat with", "missing_brain": "Please select a brain to chat with",
"new_discussion": "New discussion", "new_discussion": "New discussion",
"new_prompt": "Create new prompt", "new_prompt": "Create new prompt",

View File

@ -21,6 +21,7 @@
"last30Days": "Últimos 30 días", "last30Days": "Últimos 30 días",
"last7Days": "Últimos 7 días", "last7Days": "Últimos 7 días",
"limit_reached": "Has alcanzado el límite de peticiones, intente de nuevo más tarde", "limit_reached": "Has alcanzado el límite de peticiones, intente de nuevo más tarde",
"menu": "Menú",
"missing_brain": "No hay cerebro seleccionado", "missing_brain": "No hay cerebro seleccionado",
"new_discussion": "Nueva discusión", "new_discussion": "Nueva discusión",
"new_prompt": "Crear nueva instrucción", "new_prompt": "Crear nueva instrucción",

View File

@ -21,6 +21,7 @@
"last30Days": "30 derniers jours", "last30Days": "30 derniers jours",
"last7Days": "7 derniers jours", "last7Days": "7 derniers jours",
"limit_reached": "Vous avez atteint la limite de requêtes, veuillez réessayer plus tard", "limit_reached": "Vous avez atteint la limite de requêtes, veuillez réessayer plus tard",
"menu": "Menu",
"missing_brain": "Veuillez selectionner un cerveau pour discuter", "missing_brain": "Veuillez selectionner un cerveau pour discuter",
"new_discussion": "Nouvelle discussion", "new_discussion": "Nouvelle discussion",
"new_prompt": "Créer un nouveau prompt", "new_prompt": "Créer un nouveau prompt",

View File

@ -21,6 +21,7 @@
"last30Days": "Últimos 30 dias", "last30Days": "Últimos 30 dias",
"last7Days": "Últimos 7 dias", "last7Days": "Últimos 7 dias",
"limit_reached": "Você atingiu o limite de solicitações, por favor, tente novamente mais tarde", "limit_reached": "Você atingiu o limite de solicitações, por favor, tente novamente mais tarde",
"menu": "Menu",
"missing_brain": "Cérebro não encontrado", "missing_brain": "Cérebro não encontrado",
"new_discussion": "Nova discussão", "new_discussion": "Nova discussão",
"new_prompt": "Criar novo prompt", "new_prompt": "Criar novo prompt",

View File

@ -21,6 +21,7 @@
"last30Days": "Последние 30 дней", "last30Days": "Последние 30 дней",
"last7Days": "Последние 7 дней", "last7Days": "Последние 7 дней",
"limit_reached": "Вы достигли лимита запросов, пожалуйста, попробуйте позже", "limit_reached": "Вы достигли лимита запросов, пожалуйста, попробуйте позже",
"menu": "Меню",
"missing_brain": "Мозг не найден", "missing_brain": "Мозг не найден",
"new_discussion": "Новое обсуждение", "new_discussion": "Новое обсуждение",
"new_prompt": "Создать новый запрос", "new_prompt": "Создать новый запрос",

View File

@ -22,6 +22,7 @@
"last30Days": "过去30天", "last30Days": "过去30天",
"last7Days": "过去7天", "last7Days": "过去7天",
"limit_reached": "您已达到请求限制,请稍后再试", "limit_reached": "您已达到请求限制,请稍后再试",
"menu": "菜单",
"missing_brain": "请选择一个大脑进行聊天", "missing_brain": "请选择一个大脑进行聊天",
"new_discussion": "新讨论", "new_discussion": "新讨论",
"new_prompt": "新提示", "new_prompt": "新提示",