feat(frontend): onboarding form (#2342)

# Description

TODO BEFORE RELEASE
- [ ] Onboarded true for all existing users

## 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):

---------

Co-authored-by: Stan Girard <girard.stanislas@gmail.com>
This commit is contained in:
Antoine Dewez 2024-03-21 12:10:54 -07:00 committed by GitHub
parent 1381a0729e
commit 4aeb00fd47
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 416 additions and 49 deletions

View File

@ -8,3 +8,6 @@ class UserUpdatableProperties(BaseModel):
username: Optional[str] = None
company: Optional[str] = None
onboarded: Optional[bool] = None
company_size: Optional[str] = None
usage_purpose: Optional[str] = None

View File

@ -10,3 +10,5 @@ class UserIdentity(BaseModel):
username: Optional[str] = None
company: Optional[str] = None
onboarded: Optional[bool] = None
company_size: Optional[str] = None
usage_purpose: Optional[str] = None

View File

@ -18,6 +18,7 @@ import {
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { ChatsProvider } from "@/lib/context/ChatsProvider";
import { MenuProvider } from "@/lib/context/MenuProvider/Menu-provider";
import { OnboardingProvider } from "@/lib/context/OnboardingProvider/Onboarding-provider";
import { SearchModalProvider } from "@/lib/context/SearchModalProvider/search-modal-provider";
import { useSupabase } from "@/lib/context/SupabaseProvider";
import { UserSettingsProvider } from "@/lib/context/UserSettingsProvider/User-settings.provider";
@ -96,11 +97,13 @@ const AppWithQueryClient = ({ children }: PropsWithChildren): JSX.Element => {
<KnowledgeToFeedProvider>
<BrainCreationProvider>
<MenuProvider>
<ChatsProvider>
<ChatProvider>
<App>{children}</App>
</ChatProvider>
</ChatsProvider>
<OnboardingProvider>
<ChatsProvider>
<ChatProvider>
<App>{children}</App>
</ChatProvider>
</ChatsProvider>
</OnboardingProvider>
</MenuProvider>
</BrainCreationProvider>
</KnowledgeToFeedProvider>

View File

@ -5,6 +5,7 @@ import { useEffect } from "react";
import { QuivrLogo } from "@/lib/assets/QuivrLogo";
import { AddBrainModal } from "@/lib/components/AddBrainModal";
import { useBrainCreationContext } from "@/lib/components/AddBrainModal/brainCreation-provider";
import { OnboardingModal } from "@/lib/components/OnboardingModal/OnboardingModal";
import PageHeader from "@/lib/components/PageHeader/PageHeader";
import { UploadDocumentModal } from "@/lib/components/UploadDocumentModal/UploadDocumentModal";
import { SearchBar } from "@/lib/components/ui/SearchBar/SearchBar";
@ -66,6 +67,7 @@ const Search = (): JSX.Element => {
</div>
<UploadDocumentModal />
<AddBrainModal />
<OnboardingModal />
</div>
);
};

View File

@ -43,7 +43,7 @@ export const ModelSelection = (props: ModelSelectionProps): JSX.Element => {
void handleSubmit(false);
}}
selectedOption={{ value: model, label: model }}
placeholder="hey"
placeholder="Select a model"
iconName="robot"
/>
</fieldset>

View File

@ -63,7 +63,10 @@ export const useApiKeyConfig = () => {
try {
setChangeOpenAiApiKeyRequestPending(true);
await updateUserIdentity({});
await updateUserIdentity({
username: userIdentity?.username ?? "",
onboarded: userIdentity?.onboarded ?? false,
});
void queryClient.invalidateQueries({
queryKey: [USER_IDENTITY_DATA_KEY],
});
@ -82,7 +85,10 @@ export const useApiKeyConfig = () => {
const removeOpenAiApiKey = async () => {
try {
setChangeOpenAiApiKeyRequestPending(true);
await updateUserIdentity({});
await updateUserIdentity({
username: userIdentity?.username ?? "",
onboarded: userIdentity?.onboarded ?? false,
});
publish({
variant: "success",

View File

@ -3,12 +3,37 @@ import { UUID } from "crypto";
import { UserStats } from "@/lib/types/User";
export enum CompanySize {
One = "1-10",
Two = "10-25",
Three = "25-50",
Four = "50-100",
Five = "100-500",
Six = "500-1000",
Seven = "1000-5000",
Eight = "+5000",
}
export enum UsagePurpose {
Business = "Business",
NGO = "NGO",
Personal = "Personal",
Student = "Student",
Teacher = "Teacher",
}
export type UserIdentityUpdatableProperties = {
empty?: string | null;
username: string;
company?: string;
onboarded: boolean;
company_size?: CompanySize;
usage_purpose?: UsagePurpose;
};
export type UserIdentity = {
user_id: UUID;
onboarded: boolean;
username: string;
};
export const updateUserIdentity = async (

View File

@ -1,6 +1,7 @@
import { Controller, useFormContext } from "react-hook-form";
import { CreateBrainProps } from "@/lib/components/AddBrainModal/types/types";
import { FieldHeader } from "@/lib/components/ui/FieldHeader/FieldHeader";
import QuivrButton from "@/lib/components/ui/QuivrButton/QuivrButton";
import { TextAreaInput } from "@/lib/components/ui/TextAreaInput/TextAreaInput";
import { TextInput } from "@/lib/components/ui/TextInput/TextInput";
@ -35,26 +36,36 @@ export const BrainMainInfosStep = (): JSX.Element => {
<div className={styles.brain_main_infos_wrapper}>
<div className={styles.inputs_wrapper}>
<span className={styles.title}>Define brain identity</span>
<Controller
name="name"
render={({ field }) => (
<TextInput
label="Name"
inputValue={field.value as string}
setInputValue={field.onChange}
/>
)}
/>
<Controller
name="description"
render={({ field }) => (
<TextAreaInput
label="Description"
inputValue={field.value as string}
setInputValue={field.onChange}
/>
)}
/>
<div>
<FieldHeader iconName="brain" label="Name" mandatory={true} />
<Controller
name="name"
render={({ field }) => (
<TextInput
label="Enter your brain name"
inputValue={field.value as string}
setInputValue={field.onChange}
/>
)}
/>
</div>
<div>
<FieldHeader
iconName="paragraph"
label="Description"
mandatory={true}
/>
<Controller
name="description"
render={({ field }) => (
<TextAreaInput
label="Enter your brain description"
inputValue={field.value as string}
setInputValue={field.onChange}
/>
)}
/>
</div>
</div>
<div className={styles.buttons_wrapper}>
<QuivrButton

View File

@ -1,16 +1,25 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
import { MenuButton } from "@/lib/components/Menu/components/MenuButton/MenuButton";
import { useUserData } from "@/lib/hooks/useUserData";
export const ProfileButton = (): JSX.Element => {
const pathname = usePathname() ?? "";
const isSelected = pathname.includes("/user");
const { userIdentityData } = useUserData();
let username = userIdentityData?.username ?? "Profile";
useEffect(() => {
username = userIdentityData?.username ?? "Profile";
}, [userIdentityData]);
return (
<Link href="/user">
<MenuButton
label="Profile"
label={username}
iconName="user"
type="open"
isSelected={isSelected}

View File

@ -0,0 +1,21 @@
@use "@/styles/Spacings.module.scss";
.modal_content_wrapper {
height: 100%;
padding: Spacings.$spacing05;
display: flex;
flex-direction: column;
justify-content: space-between;
.form_wrapper {
display: flex;
flex-direction: column;
gap: Spacings.$spacing05;
}
.button_wrapper {
width: 100%;
display: flex;
justify-content: flex-end;
}
}

View File

@ -0,0 +1,154 @@
import { Controller, FormProvider, useForm } from "react-hook-form";
import { useUserApi } from "@/lib/api/user/useUserApi";
import { CompanySize, UsagePurpose } from "@/lib/api/user/user";
import { Modal } from "@/lib/components/ui/Modal/Modal";
import { useOnboardingContext } from "@/lib/context/OnboardingProvider/hooks/useOnboardingContext";
import styles from "./OnboardingModal.module.scss";
import { OnboardingProps } from "../OnboardingModal/types/types";
import { FieldHeader } from "../ui/FieldHeader/FieldHeader";
import { QuivrButton } from "../ui/QuivrButton/QuivrButton";
import { SingleSelector } from "../ui/SingleSelector/SingleSelector";
import { TextInput } from "../ui/TextInput/TextInput";
export const OnboardingModal = (): JSX.Element => {
const { isOnboardingModalOpened, setIsOnboardingModalOpened } =
useOnboardingContext();
const methods = useForm<OnboardingProps>({
defaultValues: {
username: "",
companyName: "",
companySize: undefined,
usagePurpose: "",
},
});
const { watch } = methods;
const username = watch("username");
const { updateUserIdentity } = useUserApi();
const companySizeOptions = Object.entries(CompanySize).map(([, value]) => ({
label: value,
value: value,
}));
const usagePurposeOptions = Object.entries(UsagePurpose).map(
([key, value]) => ({
label: value,
value: key,
})
);
const submitForm = async () => {
await updateUserIdentity({
username: methods.getValues("username"),
company: methods.getValues("companyName"),
onboarded: true,
company_size: methods.getValues("companySize"),
usage_purpose: methods.getValues("usagePurpose") as
| UsagePurpose
| undefined,
});
window.location.reload();
};
return (
<FormProvider {...methods}>
<Modal
title="Welcome to Quivr!"
desc="Let us know a bit more about you to get started."
isOpen={isOnboardingModalOpened}
setOpen={setIsOnboardingModalOpened}
CloseTrigger={<div />}
unclosable={true}
>
<div className={styles.modal_content_wrapper}>
<div className={styles.form_wrapper}>
<div>
<FieldHeader iconName="user" label="Username" mandatory={true} />
<Controller
name="username"
render={({ field }) => (
<TextInput
label="Choose a username"
inputValue={field.value as string}
setInputValue={field.onChange}
/>
)}
/>
</div>
<div>
<FieldHeader iconName="office" label="Company" />
<Controller
name="companyName"
render={({ field }) => (
<TextInput
label="Your company name"
inputValue={field.value as string}
setInputValue={field.onChange}
/>
)}
/>
</div>
<div>
<FieldHeader iconName="goal" label="Usage Purpose" />
<Controller
name="usagePurpose"
render={({ field }) => (
<SingleSelector
iconName="goal"
options={usagePurposeOptions}
placeholder="In what context will you be using Quivr"
selectedOption={
field.value
? {
label: field.value as string,
value: field.value as string,
}
: undefined
}
onChange={field.onChange}
/>
)}
/>
</div>
<div>
<FieldHeader iconName="hashtag" label="Size of your company" />
<Controller
name="companySize"
render={({ field }) => (
<SingleSelector
iconName="hashtag"
options={companySizeOptions}
placeholder="Number of employees in your company"
selectedOption={
field.value
? {
label: field.value as string,
value: field.value as string,
}
: undefined
}
onChange={field.onChange}
/>
)}
/>
</div>
</div>
<div className={styles.button_wrapper}>
<QuivrButton
iconName="chevronRight"
label="Submit"
color="primary"
onClick={() => submitForm()}
disabled={!username}
/>
</div>
</div>
</Modal>
</FormProvider>
);
};

View File

@ -0,0 +1,8 @@
import { CompanySize } from "@/lib/api/user/user";
export type OnboardingProps = {
username: string;
companyName: string;
companySize: CompanySize;
usagePurpose: string;
};

View File

@ -1,3 +1,4 @@
@use "@/styles/Colors.module.scss";
@use "@/styles/Spacings.module.scss";
.field_header_wrapper {
@ -6,4 +7,8 @@
font-weight: 500;
align-items: center;
padding-bottom: Spacings.$spacing02;
.mandatory {
color: Colors.$dangerous;
}
}

View File

@ -8,17 +8,21 @@ type FieldHeaderProps = {
iconName: string;
label: string;
help?: string;
mandatory?: boolean;
};
export const FieldHeader = ({
iconName,
label,
help,
mandatory,
}: FieldHeaderProps): JSX.Element => {
return (
<div className={styles.field_header_wrapper}>
<Icon name={iconName} color="black" size="small" />
<label>{label}</label>
{mandatory && <span className={styles.mandatory}>*</span>}
<span className={styles.mandatory}>{help && "*"}</span>
{help && (
<Tooltip tooltip={help}>
<div>

View File

@ -24,6 +24,8 @@
box-shadow: BoxShadow.$medium;
max-width: 90vw;
overflow: scroll;
width: 35vw;
height: 80vh;
&.big_modal {
width: 40vw;
@ -36,9 +38,7 @@
}
@media (max-width: ScreenSizes.$small) {
&.big_modal {
width: 90vw;
}
width: 90vw;
}
.close_button_wrapper {

View File

@ -20,6 +20,7 @@ type CommonModalProps = {
isOpen?: undefined;
setOpen?: undefined;
bigModal?: boolean;
unclosable?: boolean;
unforceWhite?: boolean;
};
@ -30,6 +31,31 @@ type ModalProps =
setOpen: (isOpen: boolean) => void;
});
const handleInteractOutside = (unclosable: boolean, event: Event) => {
if (unclosable) {
event.preventDefault();
}
};
const handleModalContentAnimation = (
isOpen: boolean,
bigModal: boolean,
unforceWhite: boolean
) => {
const initialAnimation = { opacity: 0, y: "-40%" };
const animateAnimation = { opacity: 1, y: "0%" };
const exitAnimation = { opacity: 0, y: "40%" };
return {
initial: initialAnimation,
animate: animateAnimation,
exit: exitAnimation,
className: `${styles.modal_content_wrapper} ${
bigModal ? styles.big_modal : ""
} ${unforceWhite ? styles.white : ""}`,
};
};
export const Modal = ({
title,
desc,
@ -39,6 +65,7 @@ export const Modal = ({
isOpen: customIsOpen,
setOpen: customSetOpen,
bigModal,
unclosable,
unforceWhite,
}: ModalProps): JSX.Element => {
const [isOpen, setOpen] = useState(false);
@ -62,14 +89,19 @@ export const Modal = ({
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<Dialog.Content asChild forceMount>
<Dialog.Content
asChild
forceMount
onInteractOutside={(event) =>
handleInteractOutside(!!unclosable, event)
}
>
<motion.div
className={`${styles.modal_content_wrapper} ${
bigModal ? styles.big_modal : ""
} ${unforceWhite ? styles.white : ""}`}
initial={{ opacity: 0, y: "-40%" }}
animate={{ opacity: 1, y: "0%" }}
exit={{ opacity: 0, y: "40%" }}
{...handleModalContentAnimation(
customIsOpen ?? isOpen,
!!bigModal,
!!unforceWhite
)}
>
<Dialog.Title
className="m-0 text-2xl font-bold"
@ -93,14 +125,16 @@ export const Modal = ({
</Button>
)}
</Dialog.Close>
<Dialog.Close asChild>
<button
className={styles.close_button_wrapper}
aria-label="Close"
>
<MdClose />
</button>
</Dialog.Close>
{!unclosable && (
<Dialog.Close asChild>
<button
className={styles.close_button_wrapper}
aria-label="Close"
>
<MdClose />
</button>
</Dialog.Close>
)}
</motion.div>
</Dialog.Content>
</motion.div>

View File

@ -0,0 +1,36 @@
import { createContext, useEffect, useState } from "react";
import { useUserData } from "@/lib/hooks/useUserData";
export type OnboardingContextType = {
isOnboardingModalOpened: boolean;
setIsOnboardingModalOpened: React.Dispatch<React.SetStateAction<boolean>>;
};
export const OnboardingContext = createContext<
OnboardingContextType | undefined
>(undefined);
export const OnboardingProvider = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => {
const [isOnboardingModalOpened, setIsOnboardingModalOpened] = useState(false);
const { userIdentityData } = useUserData();
useEffect(() => {
setIsOnboardingModalOpened(!!userIdentityData?.onboarded);
}, []);
return (
<OnboardingContext.Provider
value={{
isOnboardingModalOpened,
setIsOnboardingModalOpened,
}}
>
{children}
</OnboardingContext.Provider>
);
};

View File

@ -0,0 +1,17 @@
import { useContext } from "react";
import {
OnboardingContext,
OnboardingContextType,
} from "../Onboarding-provider";
export const useOnboardingContext = (): OnboardingContextType => {
const context = useContext(OnboardingContext);
if (context === undefined) {
throw new Error(
"useOnboardingContext must be used within a OnboardingProvider"
);
}
return context;
};

View File

@ -27,9 +27,11 @@ import {
} from "react-icons/fa";
import { FaInfo } from "react-icons/fa6";
import { FiUpload } from "react-icons/fi";
import { HiBuildingOffice } from "react-icons/hi2";
import {
IoIosAdd,
IoIosHelpCircleOutline,
IoIosRadio,
IoMdClose,
IoMdLogOut,
} from "react-icons/io";
@ -50,6 +52,7 @@ import {
LuChevronLeft,
LuChevronRight,
LuCopy,
LuGoal,
LuPlusCircle,
LuSearch,
} from "react-icons/lu";
@ -63,6 +66,7 @@ import {
MdOutlineModeEditOutline,
MdUploadFile,
} from "react-icons/md";
import { PiOfficeChairFill } from "react-icons/pi";
import { RiHashtag } from "react-icons/ri";
import { SlOptions } from "react-icons/sl";
import { TbNetwork } from "react-icons/tb";
@ -73,6 +77,7 @@ export const iconList: { [name: string]: IconType } = {
addWithoutCircle: IoIosAdd,
brain: LuBrain,
brainCircuit: LuBrainCircuit,
chair: PiOfficeChairFill,
chat: BsChatLeftText,
check: FaCheck,
checkCircle: FaCheckCircle,
@ -93,6 +98,7 @@ export const iconList: { [name: string]: IconType } = {
flag: CiFlag1,
followUp: IoArrowUpCircleOutline,
github: FaGithub,
goal: LuGoal,
graph: VscGraph,
hashtag: RiHashtag,
help: IoIosHelpCircleOutline,
@ -105,10 +111,12 @@ export const iconList: { [name: string]: IconType } = {
loader: AiOutlineLoading3Quarters,
logout: IoMdLogOut,
moon: FaMoon,
office: HiBuildingOffice,
options: SlOptions,
paragraph: BsTextParagraph,
prompt: FaRegKeyboard,
redirection: BsArrowRightShort,
radio: IoIosRadio,
robot: LiaRobotSolid,
search: LuSearch,
settings: IoSettingsSharp,

View File

@ -1,18 +1,25 @@
import { useQuery } from "@tanstack/react-query";
import { USER_DATA_KEY } from "../api/user/config";
import { USER_DATA_KEY, USER_IDENTITY_DATA_KEY } from "../api/user/config";
import { useUserApi } from "../api/user/useUserApi";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useUserData = () => {
const { getUser } = useUserApi();
const { getUserIdentity } = useUserApi();
const { data: userData } = useQuery({
queryKey: [USER_DATA_KEY],
queryFn: getUser,
});
const { data: userIdentityData } = useQuery({
queryKey: [USER_IDENTITY_DATA_KEY],
queryFn: getUserIdentity,
});
return {
userData,
userIdentityData,
};
};

View File

@ -0,0 +1,7 @@
create type "public"."user_identity_company_size" as enum ('1-10', '10-25', '25-50', '50-100', '100-250', '250-500', '500-1000', '1000-5000', '+5000');
alter table "public"."user_identity" add column "company_size" user_identity_company_size;
alter table "public"."user_identity" add column "role_in_company" text;

View File

@ -0,0 +1,5 @@
alter table "public"."user_identity" drop column "role_in_company";
alter table "public"."user_identity" add column "usage_purpose" text;