feat: update header and improve ux (#1164)

* feat: add Knowledge tab

* feat: use tanstack query for knowledges fetching

* feat: update header

* feat: remove upload page

* feat: make chat page the default redirection page

* feat(homePage): redirect user to chat page when already authenticated
This commit is contained in:
Mamadou DICKO 2023-09-13 16:43:25 +02:00 committed by GitHub
parent 70ffa5d6be
commit 2b4c3ecbbc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 51 additions and 540 deletions

View File

@ -31,7 +31,7 @@ describe("Login component", () => {
it("redirects to /upload if user is already signed in and is not coming from another page", () => {
render(<Login />);
expect(mockRedirect).toHaveBeenCalledTimes(1);
expect(mockRedirect).toHaveBeenCalledWith("/upload");
expect(mockRedirect).toHaveBeenCalledWith("/chat");
});
it('redirects to "/previous-page" if user is already signed in and previous page is set', () => {

View File

@ -1,8 +1,8 @@
import { redirect } from "next/navigation";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useSupabase } from "@/lib/context/SupabaseProvider";
import { redirectToPreviousPageOrChatPage } from "@/lib/helpers/redirectToPreviousPageOrChatPage";
import { useToast } from "@/lib/hooks";
import { useEventTracking } from "@/services/analytics/useEventTracking";
@ -25,28 +25,28 @@ export const useLogin = () => {
password: password,
});
if (error) {
console.log(error.message)
console.log(error.message);
if (error.message.includes("Failed")) {
publish({
variant: "danger",
text: t("Failedtofetch",{ ns: 'login' })
text: t("Failedtofetch", { ns: "login" }),
});
} else if (error.message.includes("Invalid")) {
publish({
variant: "danger",
text: t("Invalidlogincredentials",{ ns: 'login' })
text: t("Invalidlogincredentials", { ns: "login" }),
});
} else {
publish({
variant: "danger",
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-call
text: error.message
text: error.message,
});
}
} else {
publish({
variant: "success",
text: t("loginSuccess",{ ns: 'login' })
text: t("loginSuccess", { ns: "login" }),
});
}
setIsPending(false);
@ -55,14 +55,7 @@ export const useLogin = () => {
useEffect(() => {
if (session?.user !== undefined) {
void track("SIGNED_IN");
const previousPage = sessionStorage.getItem("previous-page");
if (previousPage === null) {
redirect("/upload");
} else {
sessionStorage.removeItem("previous-page");
redirect(previousPage);
}
redirectToPreviousPageOrChatPage();
}
}, [session?.user]);

View File

@ -1,10 +1,24 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { getProcessEnvManager } from "@/lib/helpers/getProcessEnvManager";
import HomePage from "../page";
const mockUseSupabase = vi.fn(() => ({
session: {
user: {},
},
}));
vi.mock("@/lib/context/SupabaseProvider", () => ({
useSupabase: () => mockUseSupabase(),
}));
vi.mock("next/navigation", () => ({
redirect: (url: string) => url,
}));
describe("HomePage", () => {
it("should render HomePage component properly", () => {
const { overwriteEnvValuesWith, resetEnvValues } = getProcessEnvManager();

View File

@ -1,12 +1,20 @@
import { redirect } from "next/navigation";
"use client";
import { useEffect } from "react";
import { useSupabase } from "@/lib/context/SupabaseProvider";
import { redirectToPreviousPageOrChatPage } from "@/lib/helpers/redirectToPreviousPageOrChatPage";
import Features from "./Features";
import Hero from "./Hero";
const HomePage = (): JSX.Element => {
if (process.env.NEXT_PUBLIC_ENV === "local") {
redirect("/upload");
}
const { session } = useSupabase();
useEffect(() => {
if (session?.user !== undefined) {
redirectToPreviousPageOrChatPage();
}
}, [session?.user]);
return (
<main data-testid="home-page">

View File

@ -3,8 +3,8 @@ import { MentionData } from "@draft-js-plugins/mention";
import { EditorState } from "draft-js";
import { useEffect, useMemo, useState } from "react";
import { requiredRolesForUpload } from "@/app/upload/config";
import { mapMinimalBrainToMentionData } from "@/lib/components/MentionInput";
import { requiredRolesForUpload } from "@/lib/config/upload";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { MentionInputMentionsType } from "../../types";

View File

@ -1,6 +1,6 @@
import { useEffect } from "react";
import { requiredRolesForUpload } from "@/app/upload/config";
import { requiredRolesForUpload } from "@/lib/config/upload";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types

View File

@ -124,7 +124,7 @@ export const useInvitation = () => {
}
} finally {
setIsProcessingRequest(false);
void router.push("/upload");
void router.push("/chat");
}
};

View File

@ -1,92 +0,0 @@
"use client";
import { UUID } from "crypto";
import { useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { isValidUrl } from "@/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/Crawler/helpers/isValidUrl";
import { useCrawlApi } from "@/lib/api/crawl/useCrawlApi";
import { useSupabase } from "@/lib/context/SupabaseProvider";
import { useToast } from "@/lib/hooks";
import { redirectToLogin } from "@/lib/router/redirectToLogin";
import { useEventTracking } from "@/services/analytics/useEventTracking";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useCrawler = () => {
const [isCrawling, setCrawling] = useState(false);
const urlInputRef = useRef<HTMLInputElement | null>(null);
const { session } = useSupabase();
const { publish } = useToast();
const { track } = useEventTracking();
const { crawlWebsiteUrl } = useCrawlApi();
if (session === null) {
redirectToLogin();
}
const { t } = useTranslation(["translation", "upload"]);
const crawlWebsite = useCallback(
async (brainId: UUID | undefined) => {
// Validate URL
const url = urlInputRef.current ? urlInputRef.current.value : null;
if (url === null || !isValidUrl(url)) {
console.log("Invalid URL");
void track("URL_INVALID");
publish({
variant: "danger",
text: t("invalidUrl", { ns: "upload" }),
});
return;
}
// Configure parameters
const config = {
url: url,
js: false,
depth: 1,
max_pages: 100,
max_time: 60,
};
setCrawling(true);
console.log("Tracking URL_CRAWLED");
void track("URL_CRAWLED");
try {
console.log("Crawling website...", brainId);
if (brainId !== undefined) {
const response = await crawlWebsiteUrl({
brainId,
config,
});
publish({
variant: response.data.type,
text: response.data.message,
});
}
} catch (error: unknown) {
publish({
variant: "danger",
text: t("crawlFailed", {
message: JSON.stringify(error),
ns: "upload",
}),
});
} finally {
setCrawling(false);
}
},
[crawlWebsiteUrl, publish, t, track]
);
return {
isCrawling,
urlInputRef,
crawlWebsite,
};
};

View File

@ -1,46 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import Button from "@/lib/components/ui/Button";
import Card from "@/lib/components/ui/Card";
import Field from "@/lib/components/ui/Field";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { useCrawler } from "./hooks/useCrawler";
export const Crawler = (): JSX.Element => {
const { urlInputRef, isCrawling, crawlWebsite } = useCrawler();
const { currentBrain } = useBrainContext();
const { t } = useTranslation(["translation", "upload"]);
return (
<div className="w-full">
<div className="flex justify-center gap-5 px-6">
<div className="max-w-xl w-full">
<div className="flex-col justify-center gap-5">
<Card className="h-32 flex gap-5 justify-center items-center px-5">
<div className="text-center max-w-sm w-full flex flex-col gap-5 items-center">
<Field
name="crawlurl"
ref={urlInputRef}
type="text"
placeholder={t("webSite", { ns: "upload" })}
className="w-full"
/>
</div>
<div className="flex flex-col items-center justify-center gap-5">
<Button
isLoading={isCrawling}
onClick={() => void crawlWebsite(currentBrain?.id)}
>
{t("crawlButton")}
</Button>
</div>
</Card>
</div>
</div>
</div>
</div>
);
};

View File

@ -1,48 +0,0 @@
/* eslint-disable */
import { motion } from "framer-motion";
import { Dispatch, forwardRef, RefObject, SetStateAction } from "react";
import { MdClose } from "react-icons/md";
interface FileComponentProps {
file: File;
setFiles: Dispatch<SetStateAction<File[]>>;
}
const FileComponent = forwardRef(
({ file, setFiles }: FileComponentProps, forwardedRef) => {
return (
<motion.div
initial={{ x: "-10%", opacity: 0 }}
animate={{ x: "0%", opacity: 1 }}
exit={{ x: "10%", opacity: 0 }}
layout
ref={forwardedRef as RefObject<HTMLDivElement>}
className="relative flex flex-row gap-1 py-2 dark:bg-black border-b last:border-none border-black/10 dark:border-white/25 leading-none px-6 overflow-hidden"
>
<div className="flex flex-1">
<div className="flex flex-col">
<span className="overflow-ellipsis overflow-hidden whitespace-nowrap">
{file.name}
</span>
<span className="text-xs opacity-50 overflow-hidden text-ellipsis">
{(file.size / 1000).toFixed(3)} kb
</span>
</div>
</div>
<button
role="remove file"
className="text-xl text-red-500 text-ellipsis absolute top-0 h-full right-0 flex items-center justify-center bg-white dark:bg-black p-3 shadow-md aspect-square"
onClick={() =>
setFiles((files) => files.filter((f) => f.name !== file.name))
}
>
<MdClose />
</button>
</motion.div>
);
}
);
FileComponent.displayName = "FileComponent";
export default FileComponent;

View File

@ -1,164 +0,0 @@
/* eslint-disable max-lines */
import axios from "axios";
import { UUID } from "crypto";
import { useCallback, useState } from "react";
import { FileRejection, useDropzone } from "react-dropzone";
import { useTranslation } from "react-i18next";
import { useUploadApi } from "@/lib/api/upload/useUploadApi";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { useSupabase } from "@/lib/context/SupabaseProvider";
import { useToast } from "@/lib/hooks";
import { redirectToLogin } from "@/lib/router/redirectToLogin";
import { useEventTracking } from "@/services/analytics/useEventTracking";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useFileUploader = () => {
const { track } = useEventTracking();
const [isPending, setIsPending] = useState(false);
const { publish } = useToast();
const [files, setFiles] = useState<File[]>([]);
const { session } = useSupabase();
const { uploadFile } = useUploadApi();
const { currentBrain } = useBrainContext();
if (session === null) {
redirectToLogin();
}
const { t } = useTranslation(["upload"]);
const upload = useCallback(
async (file: File, brainId: UUID) => {
const formData = new FormData();
formData.append("uploadFile", file);
try {
void track("FILE_UPLOADED");
const response = await uploadFile({ brainId, formData });
publish({
variant: response.data.type,
text:
response.data.type === "success"
? t("success", { ns: "upload" })
: t("error", { message: response.data.message, ns: "upload" }),
});
} catch (e: unknown) {
if (axios.isAxiosError(e) && e.response?.status === 403) {
publish({
variant: "danger",
text: `${JSON.stringify(
(
e.response as {
data: { detail: string };
}
).data.detail
)}`,
});
} else {
publish({
variant: "danger",
text: t("error", { message: e, ns: "upload" }),
});
}
}
},
[publish, t, track, uploadFile]
);
const onDrop = (acceptedFiles: File[], fileRejections: FileRejection[]) => {
if (fileRejections.length > 0) {
const firstRejection = fileRejections[0];
if (firstRejection.errors[0].code === "file-invalid-type") {
publish({ variant: "danger", text: t("invalidFileType") });
} else {
publish({
variant: "danger",
text: t("maxSizeError", { ns: "upload" }),
});
}
return;
}
for (let i = 0; i < acceptedFiles.length; i++) {
const file = acceptedFiles[i];
const isAlreadyInFiles =
files.filter((f) => f.name === file.name && f.size === file.size)
.length > 0;
if (isAlreadyInFiles) {
publish({
variant: "warning",
text: t("alreadyAdded", { fileName: file.name, ns: "upload" }),
});
acceptedFiles.splice(i, 1);
}
}
// eslint-disable-next-line @typescript-eslint/no-shadow
setFiles((files) => [...files, ...acceptedFiles]);
};
const uploadAllFiles = async () => {
if (files.length === 0) {
publish({
text: t("addFiles", { ns: "upload" }),
variant: "warning",
});
return;
}
setIsPending(true);
if (currentBrain?.id !== undefined) {
await Promise.all(files.map((file) => upload(file, currentBrain.id)));
setFiles([]);
} else {
publish({
text: t("selectBrain", { ns: "upload" }),
variant: "warning",
});
}
setIsPending(false);
};
const { getInputProps, getRootProps, isDragActive, open } = useDropzone({
onDrop,
noClick: true,
maxSize: 100000000, // 1 MB
accept: {
"text/plain": [".txt"],
"text/csv": [".csv"],
"text/markdown": [".md", ".markdown"],
"audio/x-m4a": [".m4a"],
"audio/mpeg": [".mp3", ".mpga", ".mpeg"],
"audio/webm": [".webm"],
"video/mp4": [".mp4"],
"audio/wav": [".wav"],
"application/pdf": [".pdf"],
"text/html": [".html"],
"application/vnd.openxmlformats-officedocument.presentationml.presentation":
[".pptx"],
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
[".docx"],
"application/vnd.oasis.opendocument.text": [".odt"],
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [
".xlsx",
".xls",
],
"application/epub+zip": [".epub"],
"application/x-ipynb+json": [".ipynb"],
"text/x-python": [".py"],
},
});
return {
isPending,
getInputProps,
getRootProps,
isDragActive,
open,
uploadAllFiles,
files,
setFiles,
};
};

View File

@ -1,74 +0,0 @@
"use client";
import { AnimatePresence } from "framer-motion";
import { useTranslation } from "react-i18next";
import Button from "@/lib/components/ui/Button";
import Card from "@/lib/components/ui/Card";
import FileComponent from "./components/FileComponent";
import { useFileUploader } from "./hooks/useFileUploader";
export const FileUploader = (): JSX.Element => {
const {
getInputProps,
getRootProps,
isDragActive,
isPending,
open,
uploadAllFiles,
files,
setFiles,
} = useFileUploader();
const { t } = useTranslation(["translation", "upload"]);
return (
<section
{...getRootProps()}
className="w-full outline-none flex flex-col gap-10 items-center justify-center px-6 py-3"
>
<div className="flex flex-col sm:flex-row max-w-3xl w-full items-center gap-5">
<div className="flex-1 w-full">
<Card className="h-52 flex justify-center items-center">
<input {...getInputProps()} />
<div className="text-center p-6 max-w-sm w-full flex flex-col gap-5 items-center">
{isDragActive ? (
<p className="text-blue-600">{t("drop", { ns: "upload" })}</p>
) : (
<button
onClick={open}
className="opacity-50 h-full cursor-pointer hover:opacity-100 hover:underline transition-opacity"
>
{t("dragAndDrop", { ns: "upload" })}
</button>
)}
</div>
</Card>
</div>
{files.length > 0 && (
<div className="flex-1 w-full">
<Card className="h-52 py-3 overflow-y-auto">
{files.length > 0 ? (
<AnimatePresence mode="popLayout">
{files.map((file) => (
<FileComponent
key={`${file.name} ${file.size}`}
file={file}
setFiles={setFiles}
/>
))}
</AnimatePresence>
) : null}
</Card>
</div>
)}
</div>
<div className="flex flex-col items-center justify-center">
<Button isLoading={isPending} onClick={() => void uploadAllFiles()}>
{isPending ? t("uploadingButton") : t("uploadButton")}
</Button>
</div>
</section>
);
};

View File

@ -1,77 +0,0 @@
"use client";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import Button from "@/lib/components/ui/Button";
import { Divider } from "@/lib/components/ui/Divider";
import PageHeading from "@/lib/components/ui/PageHeading";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { useSupabase } from "@/lib/context/SupabaseProvider";
import { redirectToLogin } from "@/lib/router/redirectToLogin";
import { Crawler } from "./Crawler";
import { FileUploader } from "./FileUploader";
import { requiredRolesForUpload } from "./config";
const UploadPage = (): JSX.Element => {
const { currentBrain } = useBrainContext();
const { session } = useSupabase();
const { t } = useTranslation(["translation", "upload"]);
if (session === null) {
redirectToLogin();
}
if (currentBrain === undefined) {
return (
<div className="flex justify-center items-center mt-5">
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative max-w-md">
<strong className="font-bold mr-1">
{t("ohNo", { ns: "upload" })}
</strong>
<span className="block sm:inline">
{t("selectBrainFirst", { ns: "upload" })}
</span>
</div>
</div>
);
}
const hasUploadRights = requiredRolesForUpload.includes(currentBrain.role);
if (!hasUploadRights) {
return (
<div className="flex justify-center items-center mt-5">
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative max-w-md">
<strong className="font-bold mr-1">
{t("ohNo", { ns: "upload" })}
</strong>
<span className="block sm:inline">
{t("missingNecessaryRole", { ns: "upload" })}
</span>
</div>
</div>
);
}
return (
<main className="pt-10">
<PageHeading
title={t("title", { ns: "upload" })}
subtitle={t("subtitle", { ns: "upload" })}
/>
<FileUploader />
<Divider text={t("or")} className="m-5" />
<Crawler />
<div className="flex flex-col items-center justify-center gap-5 mt-5">
<Link href={"/chat"}>
<Button variant={"secondary"} className="py-3">
{t("chatButton")}
</Button>
</Link>
</div>
</main>
);
};
export default UploadPage;

View File

@ -3,7 +3,7 @@ import Link from "next/link";
export const Logo = (): JSX.Element => {
return (
<Link href={"/"} className="flex items-center gap-4">
<Link href={"/chat"} className="flex items-center gap-4">
<Image
className="rounded-full"
src={"/logo.png"}

View File

@ -1,7 +1,6 @@
"use client";
import Link from "next/link";
import { Dispatch, HTMLAttributes, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import { MdPerson } from "react-icons/md";
import { useSupabase } from "@/lib/context/SupabaseProvider";
@ -24,7 +23,6 @@ export const NavItems = ({
}: NavItemsProps): JSX.Element => {
const { session } = useSupabase();
const isUserLoggedIn = session?.user !== undefined;
const { t } = useTranslation();
return (
<ul
@ -34,19 +32,7 @@ export const NavItems = ({
)}
{...props}
>
{isUserLoggedIn ? (
<>
<NavLink setOpen={setOpen} to="/upload">
{t("Upload")}
</NavLink>
<NavLink setOpen={setOpen} to="/chat">
{t("Chat")}
</NavLink>
<NavLink setOpen={setOpen} to="/explore">
{t("Explore")}
</NavLink>
</>
) : (
{!isUserLoggedIn && (
<>
<NavLink setOpen={setOpen} to="https://github.com/StanGirard/quivr">
Github

View File

@ -0,0 +1,11 @@
import { redirect } from "next/navigation";
export const redirectToPreviousPageOrChatPage = (): void => {
const previousPage = sessionStorage.getItem("previous-page");
if (previousPage === null) {
redirect("/chat");
} else {
sessionStorage.removeItem("previous-page");
redirect(previousPage);
}
};