feat(frontend): better UI for General Settings (#2773)

This commit is contained in:
Antoine Dewez 2024-06-27 20:09:36 +02:00 committed by GitHub
parent bbbf0d1217
commit 41f120bc07
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 198 additions and 328 deletions

View File

@ -1,108 +0,0 @@
/* eslint-disable max-lines */
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useApiKeyConfig } from "../useApiKeyConfig";
const createApiKeyMock = vi.fn(() => "dummyApiKey");
const trackMock = vi.fn((props: unknown) => ({ props }));
const mockUseSupabase = vi.fn(() => ({
session: {
user: {},
},
}));
const useAuthApiMock = vi.fn(() => ({
createApiKey: () => createApiKeyMock(),
}));
const useEventTrackingMock = vi.fn(() => ({
track: (props: unknown) => trackMock(props),
}));
vi.mock("@/lib/api/auth/useAuthApi", () => ({
useAuthApi: () => useAuthApiMock(),
}));
vi.mock("@/services/analytics/june/useEventTracking", () => ({
useEventTracking: () => useEventTrackingMock(),
}));
vi.mock("@/lib/context/SupabaseProvider", () => ({
useSupabase: () => mockUseSupabase(),
}));
vi.mock("@/lib/hooks", async () => {
const actual = await vi.importActual<typeof import("@/lib/hooks")>(
"@/lib/hooks"
);
return {
...actual,
useAxios: () => ({
axiosInstance: {
put: vi.fn(() => ({})),
get: vi.fn(() => ({})),
},
}),
};
});
vi.mock("@tanstack/react-query", async () => {
const actual = await vi.importActual<typeof import("@tanstack/react-query")>(
"@tanstack/react-query"
);
return {
...actual,
useQuery: () => ({
data: {},
}),
useQueryClient: () => ({
invalidateQueries: vi.fn(),
}),
};
});
describe("useApiKeyConfig", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("should set the apiKey when handleCreateClick is called", async () => {
const { result } = renderHook(() => useApiKeyConfig());
await act(async () => {
await result.current.handleCreateClick();
});
expect(createApiKeyMock).toHaveBeenCalledTimes(1);
expect(trackMock).toHaveBeenCalledWith("CREATE_API_KEY");
expect(result.current.apiKey).toBe("dummyApiKey");
});
it("should call copyToClipboard when handleCopyClick is called with a non-empty apiKey", () => {
vi.mock("react", async () => {
const actual = await vi.importActual<typeof import("react")>("react");
return {
...actual,
useState: () => ["dummyApiKey", vi.fn()],
};
});
//@ts-ignore - clipboard is not actually readonly
global.navigator.clipboard = {
writeText: vi.fn(),
};
const { result } = renderHook(() => useApiKeyConfig());
act(() => result.current.handleCopyClick());
expect(trackMock).toHaveBeenCalledTimes(1);
expect(trackMock).toHaveBeenCalledWith("COPY_API_KEY");
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(global.navigator.clipboard.writeText).toHaveBeenCalledWith(
"dummyApiKey"
);
});
});

View File

@ -1,29 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import { CountrySelector } from "@/lib/components/ui/CountrySelector/CountrySelector";
import { useLanguageHook } from "./hooks/useLanguageHook";
const LanguageSelect = (): JSX.Element => {
const { t } = useTranslation(["translation"]);
const { currentLanguage, change } = useLanguageHook();
if (!currentLanguage) {
return <></>;
}
return (
<CountrySelector
iconName="flag"
label={t("languageSelect")}
currentValue={currentLanguage}
setCurrentValue={change}
/>
);
};
LanguageSelect.displayName = "LanguageSelect";
export default LanguageSelect;

View File

@ -1,87 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useEventTracking } from "@/services/analytics/june/useEventTracking";
export type Language = {
label: string;
flag: string;
shortName: string;
};
export const languages: Language[] = [
{
label: "English",
flag: "🇬🇧",
shortName: "en",
},
{
label: "Español",
flag: "🇪🇸",
shortName: "es",
},
{
label: "Français",
flag: "🇫🇷",
shortName: "fr",
},
{
label: "Português",
flag: "🇵🇹",
shortName: "pt",
},
{
label: "Русский",
flag: "🇷🇺",
shortName: "ru",
},
{
label: "简体中文",
flag: "🇨🇳",
shortName: "zh",
},
];
export const useLanguageHook = (): {
change: (newLanguage: Language) => void;
allLanguages: Language[];
currentLanguage: Language | undefined;
} => {
const { i18n } = useTranslation();
const [allLanguages, setAllLanguages] = useState<Language[]>([]);
const [currentLanguage, setCurrentLanguage] = useState<Language>();
const { track } = useEventTracking();
useEffect(() => {
setAllLanguages(languages);
const savedLanguage = localStorage.getItem("selectedLanguage") ?? "English";
let choosedLanguage = languages.find(
(lang) => lang.label === savedLanguage
);
if (!choosedLanguage) {
choosedLanguage = languages.find((lang) => lang.label === "English");
}
if (currentLanguage) {
setCurrentLanguage(choosedLanguage);
localStorage.setItem("selectedLanguage", currentLanguage.label);
void i18n.changeLanguage(currentLanguage.shortName);
} else {
console.error(
"No valid language found, please check the languages configuration."
);
}
}, [i18n]);
const change = (newLanguage: Language) => {
void track("CHANGE_LANGUAGE");
setCurrentLanguage(newLanguage);
localStorage.setItem("selectedLanguage", newLanguage.label);
void i18n.changeLanguage(newLanguage.shortName);
};
return {
change,
allLanguages,
currentLanguage,
};
};

View File

@ -1,30 +1,25 @@
/* eslint-disable max-lines */
"use client";
import { useTranslation } from "react-i18next";
import Button from "@/lib/components/ui/Button";
import { CopyButton } from "@/lib/components/ui/CopyButton";
import { FieldHeader } from "@/lib/components/ui/FieldHeader/FieldHeader";
import QuivrButton from "@/lib/components/ui/QuivrButton/QuivrButton";
import styles from "./ApiKeyConfig.module.scss";
import { useApiKeyConfig } from "./hooks/useApiKeyConfig";
export const ApiKeyConfig = (): JSX.Element => {
const { apiKey, handleCopyClick, handleCreateClick } = useApiKeyConfig();
const { t } = useTranslation(["config"]);
return (
<div>
<FieldHeader iconName="key" label={`Quivr ${t("apiKey")}`} />
{apiKey === "" ? (
<Button
data-testid="create-new-key"
variant="secondary"
onClick={() => void handleCreateClick()}
>
Create New Key
</Button>
<QuivrButton
iconName="key"
color="primary"
label="Create new key"
onClick={void handleCreateClick()}
small={true}
/>
) : (
<div className={styles.response_wrapper}>
<span>{apiKey}</span>

View File

@ -0,0 +1,30 @@
@use "styles/Spacings.module.scss";
@use "styles/Typography.module.scss";
.info_wrapper {
display: flex;
gap: Spacings.$spacing03;
border-bottom: 1px solid var(--border-2);
padding-block: Spacings.$spacing04;
position: relative;
font-size: Typography.$small;
&.without_border {
border-bottom: none;
}
.title_wrapper {
display: flex;
margin-top: 0;
gap: Spacings.$spacing03;
align-items: center;
align-self: flex-start;
.title {
min-width: 140px;
max-width: 140px;
color: var(--text-4);
font-size: Typography.$small;
}
}
}

View File

@ -0,0 +1,27 @@
import Icon from "@/lib/components/ui/Icon/Icon";
import styles from "./InfoSection.module.scss";
interface InfoSectionProps {
iconName: string;
title: string;
children: React.ReactNode;
last?: boolean;
}
export const InfoSection = ({
iconName,
title,
children,
last,
}: InfoSectionProps): JSX.Element => (
<div
className={`${styles.info_wrapper} ${last ? styles.without_border : ""}`}
>
<div className={styles.title_wrapper}>
<Icon name={iconName} color="grey" size="small" />
<span className={styles.title}>{title}</span>
</div>
{children}
</div>
);

View File

@ -8,8 +8,52 @@
gap: Spacings.$spacing07;
width: auto;
padding: Spacings.$spacing06;
max-width: 700px;
.title {
@include Typography.H2;
}
.infos_wrapper {
display: flex;
flex-direction: column;
.bold {
font-weight: 550;
}
.credits {
color: var(--gold);
font-weight: 550;
}
.remaining_credits {
display: flex;
gap: Spacings.$spacing03;
align-items: center;
}
.text_and_button {
font-size: Typography.$small;
display: flex;
flex-direction: column;
gap: Spacings.$spacing04;
.text {
.link {
font-weight: 550;
color: var(--primary-0);
&:hover {
color: var(--primary-1);
}
}
}
.button {
display: flex;
align-self: flex-end;
}
}
}
}

View File

@ -1,27 +1,73 @@
import { InfoDisplayer } from "@/lib/components/ui/InfoDisplayer/InfoDisplayer";
import Icon from "@/lib/components/ui/Icon/Icon";
import { ApiKeyConfig } from "./ApiKeyConfig";
import { InfoSection } from "./InfoSection/InfoSection";
import styles from "./Settings.module.scss";
import { ApiKeyConfig } from "../ApiKeyConfig";
import LanguageSelect from "../LanguageSelect/LanguageSelect";
import { StripePricingOrManageButton } from "../StripePricingOrManageButton";
type InfoDisplayerProps = {
email: string;
username: string;
remainingCredits: number;
};
export const Settings = ({ email }: InfoDisplayerProps): JSX.Element => {
export const Settings = ({
email,
username,
remainingCredits,
}: InfoDisplayerProps): JSX.Element => {
return (
<div className={styles.settings_wrapper}>
<span className={styles.title}>
General settings and main information
</span>
<InfoDisplayer label="Email" iconName="email">
<span>{email}</span>
</InfoDisplayer>
<LanguageSelect />
<ApiKeyConfig />
<StripePricingOrManageButton />
<div className={styles.infos_wrapper}>
<InfoSection iconName="email" title="Email">
<span className={styles.bold}>{email}</span>
</InfoSection>
<InfoSection iconName="user" title="Username">
<span className={styles.bold}>{username}</span>
</InfoSection>
<InfoSection iconName="coin" title="Remaining credits">
<div className={styles.remaining_credits}>
<span className={styles.credits}>{remainingCredits}</span>
<Icon name="coin" color="gold" size="normal" />
</div>
</InfoSection>
<InfoSection iconName="key" title="Quivr API Key">
<div className={styles.text_and_button}>
<span className={styles.text}>
The Quivr API key is a unique identifier that allows you to access
and interact with{" "}
<a
href="https://api.quivr.app/docs"
target="_blank"
rel="noopener noreferrer"
className={styles.link}
>
Quivr&apos;s API.
</a>
</span>
<div className={styles.button}>
<ApiKeyConfig />
</div>
</div>
</InfoSection>
<InfoSection iconName="star" title="My plan" last={true}>
<div className={styles.text_and_button}>
<span className={styles.text}>
Customize your subscription to best suit your needs. By upgrading
to a premium plan, you gain access to a host of additional
benefits, including significantly more chat credits and the
ability to create more Brains.
</span>
<div className={styles.button}>
<StripePricingOrManageButton small={true} />
</div>
</div>
</InfoSection>
</div>
</div>
);
};

View File

@ -4,7 +4,13 @@ import { useUserData } from "@/lib/hooks/useUserData";
const MANAGE_PLAN_URL = process.env.NEXT_PUBLIC_STRIPE_MANAGE_PLAN_URL;
export const StripePricingOrManageButton = (): JSX.Element => {
type StripePricingModalButtonProps = {
small?: boolean;
};
export const StripePricingOrManageButton = ({
small = false,
}: StripePricingModalButtonProps): JSX.Element => {
const { userData } = useUserData();
const is_premium = userData?.is_premium ?? false;
@ -15,6 +21,7 @@ export const StripePricingOrManageButton = (): JSX.Element => {
label="Manage my plan"
color="gold"
iconName="star"
small={small}
></QuivrButton>
</a>
);
@ -28,6 +35,7 @@ export const StripePricingOrManageButton = (): JSX.Element => {
label="Upgrade my plan"
color="gold"
iconName="star"
small={small}
></QuivrButton>
</div>
}

View File

@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useUserApi } from "@/lib/api/user/useUserApi";
@ -9,6 +9,7 @@ import { Modal } from "@/lib/components/ui/Modal/Modal";
import QuivrButton from "@/lib/components/ui/QuivrButton/QuivrButton";
import { Tabs } from "@/lib/components/ui/Tabs/Tabs";
import { useSupabase } from "@/lib/context/SupabaseProvider";
import { useUserSettingsContext } from "@/lib/context/UserSettingsProvider/hooks/useUserSettingsContext";
import { useUserData } from "@/lib/hooks/useUserData";
import { redirectToLogin } from "@/lib/router/redirectToLogin";
import { ButtonType } from "@/lib/types/QuivrButton";
@ -22,8 +23,8 @@ import { useLogoutModal } from "../../lib/hooks/useLogoutModal";
const UserPage = (): JSX.Element => {
const { session } = useSupabase();
const { userData } = useUserData();
const { deleteUserData } = useUserApi();
const { userData, userIdentityData } = useUserData();
const { deleteUserData, getUserCredits } = useUserApi();
const { t } = useTranslation(["translation", "logout"]);
const [deleteAccountModalOpened, setDeleteAccountModalOpened] =
useState(false);
@ -34,6 +35,14 @@ const UserPage = (): JSX.Element => {
setIsLogoutModalOpened,
} = useLogoutModal();
const [selectedTab, setSelectedTab] = useState("Connections");
const { remainingCredits, setRemainingCredits } = useUserSettingsContext();
useEffect(() => {
void (async () => {
const res = await getUserCredits();
setRemainingCredits(res);
})();
}, []);
const buttons: ButtonType[] = [
{
@ -82,7 +91,13 @@ const UserPage = (): JSX.Element => {
<Tabs tabList={studioTabs} />
<div className={styles.user_page_menu}></div>
<div className={styles.content_wrapper}>
{selectedTab === "General" && <Settings email={userData.email} />}
{selectedTab === "General" && (
<Settings
email={userData.email}
username={userIdentityData?.username ?? ""}
remainingCredits={remainingCredits ?? 0}
/>
)}
{selectedTab === "Connections" && <Connections />}
</div>
</div>

View File

@ -1,17 +0,0 @@
@use "styles/Radius.module.scss";
.selection {
border-radius: Radius.$normal;
box-shadow: none;
cursor: pointer;
background-color: var(--background-0);
&:hover {
background-color: var(--background-3);
}
&:focus {
box-shadow: none;
border-color: var(--primary-0);
}
}

View File

@ -1,54 +0,0 @@
import {
Language,
useLanguageHook,
} from "@/app/user/components/LanguageSelect/hooks/useLanguageHook";
import styles from "./CountrySelector.module.scss";
import { FieldHeader } from "../FieldHeader/FieldHeader";
type CountrySelectorProps = {
iconName: string;
label: string;
currentValue: { label: string; flag: string };
setCurrentValue: (newCountry: Language) => void;
};
export const CountrySelector = ({
iconName,
label,
currentValue,
setCurrentValue,
}: CountrySelectorProps): JSX.Element => {
const { allLanguages } = useLanguageHook();
return (
<div className={styles.country_selector_container}>
<FieldHeader iconName={iconName} label={label} />
<select
className={styles.selection}
data-testid="language-select"
name="language"
id="language"
value={currentValue.label}
onChange={(e) =>
setCurrentValue(
allLanguages.find(
(language) => language.label === e.target.value
) ?? allLanguages[2]
)
}
>
{allLanguages.map((lang) => (
<option
data-testid={`option-${lang}`}
value={lang.label}
key={lang.shortName}
>
{lang.flag} {lang.label}
</option>
))}
</select>
</div>
);
};

View File

@ -82,4 +82,4 @@
}
}
}
}
}

View File

@ -28,7 +28,6 @@ import {
FaFileAlt,
FaFolder,
FaGithub,
FaKey,
FaLinkedin,
FaQuestionCircle,
FaRegFileAlt,
@ -79,7 +78,6 @@ import {
LuSearch,
} from "react-icons/lu";
import {
MdAlternateEmail,
MdDarkMode,
MdDashboardCustomize,
MdDeleteOutline,
@ -89,7 +87,9 @@ import {
MdMarkEmailRead,
MdMarkEmailUnread,
MdOutlineLightMode,
MdOutlineMail,
MdOutlineModeEditOutline,
MdOutlineVpnKey,
MdUnfoldLess,
MdUnfoldMore,
MdUploadFile,
@ -125,7 +125,7 @@ export const iconList: { [name: string]: IconType } = {
docx: BsFiletypeDocx,
download: IoCloudDownloadOutline,
edit: MdOutlineModeEditOutline,
email: MdAlternateEmail,
email: MdOutlineMail,
epub: FaFile,
eureka: GoLightBulb,
externLink: LuExternalLink,
@ -146,7 +146,7 @@ export const iconList: { [name: string]: IconType } = {
html: BsFiletypeHtml,
info: FaInfo,
ipynb: BsFiletypePy,
key: FaKey,
key: MdOutlineVpnKey,
link: MdLink,
linkedin: FaLinkedin,
loader: AiOutlineLoading3Quarters,