feat: add Welcome chat (#1365)

https://github.com/StanGirard/quivr/issues/1361


https://github.com/StanGirard/quivr/assets/63923024/cc4b1c0a-363a-49f3-8306-181151554b34

---------

Co-authored-by: Zineb El Bachiri <100568984+gozineb@users.noreply.github.com>
This commit is contained in:
Mamadou DICKO 2023-10-10 09:27:35 +02:00 committed by GitHub
parent bbf5e12f3c
commit 9293b7d782
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 216 additions and 20 deletions

View File

@ -1,3 +1,4 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
@ -19,6 +20,7 @@ vi.mock("../hooks/useChatDialogue", () => ({
chatListRef: vi.fn(),
})),
}));
const queryClient = new QueryClient();
describe("ChatDialogue", () => {
it("should render chat messages correctly", () => {
@ -37,14 +39,22 @@ describe("ChatDialogue", () => {
messages,
[]
);
const { getAllByTestId } = render(<ChatDialogue chatItems={chatItems} />);
const { getAllByTestId } = render(
<QueryClientProvider client={queryClient}>
<ChatDialogue chatItems={chatItems} />
</QueryClientProvider>
);
expect(getAllByTestId("brain-tags")).toBeDefined();
expect(getAllByTestId("prompt-tags")).toBeDefined();
expect(getAllByTestId("chat-message-text")).toBeDefined();
});
it("should render placeholder text when history is empty", () => {
const { getByTestId } = render(<ChatDialogue chatItems={[]} />);
const { getByTestId } = render(
<QueryClientProvider client={queryClient}>
<ChatDialogue chatItems={[]} />
</QueryClientProvider>
);
expect(getByTestId("empty-history-message")).toBeDefined();
});

View File

@ -1,6 +1,7 @@
import { useFeatureIsOn } from "@growthbook/growthbook-react";
import { useTranslation } from "react-i18next";
import { useOnboarding } from "@/lib/hooks/useOnboarding";
import { ChatItem } from "./components";
import { Onboarding } from "./components/Onboarding/Onboarding";
import { useChatDialogue } from "./hooks/useChatDialogue";
@ -21,9 +22,9 @@ export const ChatDialogue = ({
const { t } = useTranslation(["chat"]);
const { chatListRef } = useChatDialogue();
const shouldDisplayOnboarding = useFeatureIsOn("onboarding");
const { shouldDisplayOnboardingAInstructions } = useOnboarding();
if (shouldDisplayOnboarding) {
if (shouldDisplayOnboardingAInstructions) {
return (
<div className={chatDialogueContainerClassName} ref={chatListRef}>
<Onboarding />

View File

@ -10,9 +10,15 @@ import { useChatsListItem } from "./hooks/useChatsListItem";
interface ChatsListItemProps {
chat: ChatEntity;
editable?: boolean;
onDelete?: () => void;
}
export const ChatsListItem = ({ chat }: ChatsListItemProps): JSX.Element => {
export const ChatsListItem = ({
chat,
editable = true,
onDelete,
}: ChatsListItemProps): JSX.Element => {
const {
setChatName,
deleteChat,
@ -47,6 +53,7 @@ export const ChatsListItem = ({ chat }: ChatsListItemProps): JSX.Element => {
</div>
</Link>
<div className="opacity-0 group-hover:opacity-100 flex items-center justify-center bg-gradient-to-l from-white dark:from-black to-transparent z-10 transition-opacity">
{editable && (
<button
className="p-0 hover:text-blue-700"
type="button"
@ -54,10 +61,11 @@ export const ChatsListItem = ({ chat }: ChatsListItemProps): JSX.Element => {
>
{editingName ? <FiSave /> : <FiEdit />}
</button>
)}
<button
className="p-5 hover:text-red-700"
type="button"
onClick={() => void deleteChat()}
onClick={onDelete ?? (() => void deleteChat())}
data-testid="delete-chat-button"
>
<FiTrash2 />

View File

@ -0,0 +1,24 @@
import { useTranslation } from "react-i18next";
import { ChatEntity } from "@/app/chat/[chatId]/types";
import { useOnboarding } from "@/lib/hooks/useOnboarding";
import { ChatsListItem } from "./ChatsListItem";
export const WelcomeChat = (): JSX.Element => {
const { t } = useTranslation("chat");
const chat: ChatEntity = {
chat_name: t("welcome"),
// @ts-expect-error because we don't need to pass all the props
chat_id: "",
};
const { updateOnboarding } = useOnboarding();
return (
<ChatsListItem
onDelete={() => void updateOnboarding({ onboarding_a: false })}
editable={false}
chat={chat}
/>
);
};

View File

@ -1,15 +1,18 @@
"use client";
import { Sidebar } from "@/lib/components/Sidebar/Sidebar";
import { useOnboarding } from "@/lib/hooks/useOnboarding";
import { ChatHistory } from "./components/ChatHistory";
import { NewChatButton } from "./components/NewChatButton";
import { WelcomeChat } from "./components/WelcomeChat";
import { useChatNotificationsSync } from "./hooks/useChatNotificationsSync";
import { useChatsList } from "./hooks/useChatsList";
export const ChatsList = (): JSX.Element => {
useChatsList();
useChatNotificationsSync();
const { shouldDisplayWelcomeChat } = useOnboarding();
return (
<Sidebar showButtons={["myBrains", "user"]}>
@ -17,6 +20,11 @@ export const ChatsList = (): JSX.Element => {
<div className="pt-2">
<NewChatButton />
</div>
{shouldDisplayWelcomeChat && (
<div className="pt-2">
<WelcomeChat />
</div>
)}
<ChatHistory />
</div>
</Sidebar>

View File

@ -0,0 +1,51 @@
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { Onboarding } from "@/lib/types/Onboarding";
import { useOnboardingApi } from "../useOnboardingApi";
const axiosGetMock = vi.fn(() => ({}));
const axiosPutMock = vi.fn(() => ({}));
vi.mock("@/lib/hooks", () => ({
useAxios: () => ({
axiosInstance: {
get: axiosGetMock,
put: axiosPutMock,
},
}),
}));
describe("useOnboarding", () => {
it("should call getOnboarding with the correct parameters", async () => {
axiosGetMock.mockReturnValue({ data: {} });
const {
result: {
current: { getOnboarding },
},
} = renderHook(() => useOnboardingApi());
await getOnboarding();
expect(axiosGetMock).toHaveBeenCalledTimes(1);
expect(axiosGetMock).toHaveBeenCalledWith("/onboarding");
});
it("should call updateOnboarding with the correct parameters", async () => {
const onboarding: Partial<Onboarding> = {
onboarding_a: true,
onboarding_b1: false,
};
axiosPutMock.mockReturnValue({ data: {} });
const {
result: {
current: { updateOnboarding },
},
} = renderHook(() => useOnboardingApi());
await updateOnboarding(onboarding);
expect(axiosPutMock).toHaveBeenCalledTimes(1);
expect(axiosPutMock).toHaveBeenCalledWith("/onboarding", onboarding);
});
});

View File

@ -0,0 +1 @@
export const ONBOARDING_DATA_KEY = "onboarding";

View File

@ -0,0 +1,16 @@
import { AxiosInstance } from "axios";
import { Onboarding } from "@/lib/types/Onboarding";
export const getOnboarding = async (
axiosInstance: AxiosInstance
): Promise<Onboarding> => {
return (await axiosInstance.get<Onboarding>("/onboarding")).data;
};
export const updateOnboarding = async (
onboarding: Partial<Onboarding>,
axiosInstance: AxiosInstance
): Promise<Onboarding> => {
return (await axiosInstance.put<Onboarding>("/onboarding", onboarding)).data;
};

View File

@ -0,0 +1,19 @@
import { useAxios } from "@/lib/hooks";
import { Onboarding } from "@/lib/types/Onboarding";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useOnboardingApi = () => {
const { axiosInstance } = useAxios();
const getOnboarding = async () => {
return (await axiosInstance.get<Onboarding>("/onboarding")).data;
};
const updateOnboarding = async (onboarding: Partial<Onboarding>) => {
return (await axiosInstance.put<Onboarding>("/onboarding", onboarding))
.data;
};
return {
getOnboarding,
updateOnboarding,
};
};

View File

@ -0,0 +1,46 @@
import { useFeatureIsOn } from "@growthbook/growthbook-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { ONBOARDING_DATA_KEY } from "@/lib/api/onboarding/config";
import { useOnboardingApi } from "@/lib/api/onboarding/useOnboardingApi";
import { Onboarding } from "../types/Onboarding";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useOnboarding = () => {
const isOnboardingFeatureActivated = useFeatureIsOn("onboarding");
const { getOnboarding } = useOnboardingApi();
const params = useParams();
const { updateOnboarding } = useOnboardingApi();
const queryClient = useQueryClient();
const chatId = params?.chatId as string | undefined;
const { data: onboarding } = useQuery({
queryFn: getOnboarding,
queryKey: [ONBOARDING_DATA_KEY],
});
const updateOnboardingHandler = async (
newOnboardingStatus: Partial<Onboarding>
) => {
await updateOnboarding(newOnboardingStatus);
await queryClient.invalidateQueries({ queryKey: [ONBOARDING_DATA_KEY] });
};
const shouldDisplayWelcomeChat =
isOnboardingFeatureActivated && onboarding?.onboarding_a === true;
const shouldDisplayOnboardingAInstructions =
isOnboardingFeatureActivated &&
chatId === undefined &&
shouldDisplayWelcomeChat;
return {
onboarding,
shouldDisplayOnboardingAInstructions,
shouldDisplayWelcomeChat,
updateOnboarding: updateOnboardingHandler,
};
};

View File

@ -0,0 +1,6 @@
export type Onboarding = {
onboarding_a: boolean;
onboarding_b1: boolean;
onboarding_b2: boolean;
onboarding_b3: boolean;
};

View File

@ -44,5 +44,6 @@
"how_to_use_quivr": "How to use Quivr ?",
"what_is_quivr": "What is Quivr ?",
"what_is_brain": "What is a brain ?"
}
},
"welcome":"Welcome"
}

View File

@ -44,5 +44,6 @@
"how_to_use_quivr": "¿Cómo usar Quivr?",
"what_is_quivr": "¿Qué es Quivr?",
"what_is_brain": "¿Qué es un cerebro?"
}
},
"welcome": "Bienvenido"
}

View File

@ -44,5 +44,6 @@
"how_to_use_quivr": "Comment utiliser Quivr ?",
"what_is_quivr": "Qu'est-ce que Quivr ?",
"what_is_brain": "Qu'est-ce qu'un cerveau ?"
}
},
"welcome": "Bienvenue"
}

View File

@ -44,5 +44,6 @@
"how_to_use_quivr": "Como usar o Quivr?",
"what_is_quivr": "O que é o Quivr?",
"what_is_brain": "O que é um cérebro?"
}
},
"welcome": "Bem-vindo"
}

View File

@ -44,5 +44,6 @@
"how_to_use_quivr": "Как использовать Quivr?",
"what_is_quivr": "Что такое Quivr?",
"what_is_brain": "Что такое мозг?"
}
},
"welcome": "Добро пожаловать"
}

View File

@ -45,5 +45,6 @@
"how_to_use_quivr": "如何使用Quivr",
"what_is_quivr": "什么是Quivr",
"what_is_brain": "什么是大脑?"
}
},
"welcome": "欢迎来到"
}