feat(frontend): sharepoint and gdrive integration (#2643)

# Description

Please include a summary of the changes and the related issue. Please
also include relevant motivation and context.

## 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):
This commit is contained in:
Antoine Dewez 2024-06-09 18:43:18 +02:00 committed by GitHub
parent 47c6e24bf1
commit 3d3e6b7306
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
86 changed files with 2369 additions and 775 deletions

View File

@ -8,6 +8,9 @@ from modules.sync.dto.inputs import SyncsUserInput, SyncUserUpdateInput
from modules.sync.service.sync_service import SyncService, SyncUserService
from modules.user.entity.user_identity import UserIdentity
from msal import PublicClientApplication
from .successfull_connection import successfullConnectionPage
from fastapi.responses import HTMLResponse
# Initialize logger
logger = get_logger(__name__)
@ -31,7 +34,7 @@ SCOPE = [
]
@azure_sync_router.get(
@azure_sync_router.post(
"/sync/azure/authorize",
dependencies=[Depends(AuthBearer())],
tags=["Sync"],
@ -125,4 +128,4 @@ def oauth2callback_azure(request: Request):
sync_user_service.update_sync_user(current_user, state_dict, sync_user_input)
logger.info(f"Azure sync created successfully for user: {current_user}")
return {"message": "Azure sync created successfully"}
return HTMLResponse(successfullConnectionPage)

View File

@ -9,6 +9,9 @@ from middlewares.auth import AuthBearer, get_current_user
from modules.sync.dto.inputs import SyncsUserInput, SyncUserUpdateInput
from modules.sync.service.sync_service import SyncService, SyncUserService
from modules.user.entity.user_identity import UserIdentity
from .successfull_connection import successfullConnectionPage
from fastapi.responses import HTMLResponse
# Set environment variable for OAuthlib
os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"
@ -47,7 +50,7 @@ CLIENT_SECRETS_FILE_CONTENT = {
}
@google_sync_router.get(
@google_sync_router.post(
"/sync/google/authorize",
dependencies=[Depends(AuthBearer())],
tags=["Sync"],
@ -138,4 +141,4 @@ def oauth2callback_google(request: Request):
)
sync_user_service.update_sync_user(current_user, state_dict, sync_user_input)
logger.info(f"Google Drive sync created successfully for user: {current_user}")
return {"message": "Google Drive sync created successfully"}
return HTMLResponse(successfullConnectionPage)

View File

@ -0,0 +1,53 @@
successfullConnectionPage = """
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css">
<style>
body {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background-color: #f8f9fa;
font-family: Arial, sans-serif;
}
.container {
text-align: center;
}
.message {
font-size: 2em;
margin-bottom: 20px;
}
.icon {
font-size: 2em;
color: white;
background-color: green;
border-radius: 50%;
padding: 20px;
margin-bottom: 20px;
}
.close-button {
padding: 10px 20px;
font-size: 1em;
color: #fff;
background-color: #6142d4;
border: none;
border-radius: 5px;
cursor: pointer;
}
.close-button:hover {
background-color: #0056b3;
}
</style>
</head>
<body>
<div class="container">
<i class="fas fa-check icon"></i>
<div class="message">Connection successful</div>
<button class="close-button" onclick="window.close();">Close Tab</button>
</div>
</body>
</html>
"""

View File

@ -16,7 +16,7 @@ module.exports = {
complexity: ["error", 10],
"max-lines": ["error", 300],
"max-depth": ["error", 3],
"max-params": ["error", 4],
"max-params": ["error", 5],
eqeqeq: ["error", "smart"],
"import/no-extraneous-dependencies": [
"error",

View File

@ -24,7 +24,9 @@ import { UserSettingsProvider } from "@/lib/context/UserSettingsProvider/User-se
import { IntercomProvider } from "@/lib/helpers/intercom/IntercomProvider";
import { UpdateMetadata } from "@/lib/helpers/updateMetadata";
import { usePageTracking } from "@/services/analytics/june/usePageTracking";
import "../lib/config/LocaleConfig/i18n";
import { FromConnectionsProvider } from "./chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FromConnectionsProvider/FromConnection-provider";
if (
process.env.NEXT_PUBLIC_POSTHOG_KEY != null &&
@ -90,11 +92,13 @@ const AppWithQueryClient = ({ children }: PropsWithChildren): JSX.Element => {
<BrainCreationProvider>
<MenuProvider>
<OnboardingProvider>
<ChatsProvider>
<ChatProvider>
<App>{children}</App>
</ChatProvider>
</ChatsProvider>
<FromConnectionsProvider>
<ChatsProvider>
<ChatProvider>
<App>{children}</App>
</ChatProvider>
</ChatsProvider>
</FromConnectionsProvider>
</OnboardingProvider>
</MenuProvider>
</BrainCreationProvider>

View File

@ -9,6 +9,7 @@
width: 100%;
gap: Spacings.$spacing05;
overflow: hidden;
height: 100%;
.single_selector_wrapper {
width: 30%;
@ -21,44 +22,8 @@
.tabs_content_wrapper {
width: 100%;
height: 200px;
}
.uploaded_knowledges_title {
color: var(--text-2);
display: flex;
justify-content: space-between;
}
.uploaded_knowledges {
padding: Spacings.$spacing03;
display: flex;
width: 100%;
height: 80%;
overflow: scroll;
flex-direction: column;
gap: Spacings.$spacing02;
flex-grow: 1;
overflow: scroll;
.uploaded_knowledge {
display: flex;
gap: Spacings.$spacing02;
align-items: center;
justify-content: space-between;
width: 100%;
overflow: hidden;
font-size: Typography.$small;
.left {
display: flex;
align-items: center;
gap: Spacings.$spacing02;
overflow: hidden;
.label {
@include Typography.EllipsisOverflow;
}
}
}
padding: Spacings.$spacing01;
}
}

View File

@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Icon } from "@/lib/components/ui/Icon/Icon";
import { useSync } from "@/lib/api/sync/useSync";
import { SingleSelector } from "@/lib/components/ui/SingleSelector/SingleSelector";
import { Tabs } from "@/lib/components/ui/Tabs/Tabs";
import { requiredRolesForUpload } from "@/lib/config/upload";
@ -9,6 +9,8 @@ import { useKnowledgeToFeedContext } from "@/lib/context/KnowledgeToFeedProvider
import { Tab } from "@/lib/types/Tab";
import styles from "./KnowledgeToFeed.module.scss";
import { FromConnections } from "./components/FromConnections/FromConnections";
import { useFromConnectionsContext } from "./components/FromConnections/FromConnectionsProvider/hooks/useFromConnectionContext";
import { FromDocuments } from "./components/FromDocuments/FromDocuments";
import { FromWebsites } from "./components/FromWebsites/FromWebsites";
import { formatMinimalBrainsToSelectComponentInput } from "./utils/formatMinimalBrainsToSelectComponentInput";
@ -18,10 +20,13 @@ export const KnowledgeToFeed = ({
}: {
hideBrainSelector?: boolean;
}): JSX.Element => {
const { allBrains, setCurrentBrainId, currentBrain } = useBrainContext();
const [selectedTab, setSelectedTab] = useState("From documents");
const { knowledgeToFeed, removeKnowledgeToFeed } =
useKnowledgeToFeedContext();
const { allBrains, setCurrentBrainId, currentBrainId, currentBrain } =
useBrainContext();
const [selectedTab, setSelectedTab] = useState("Documents");
const { knowledgeToFeed } = useKnowledgeToFeedContext();
const { openedConnections, setOpenedConnections, setCurrentSyncId } =
useFromConnectionsContext();
const { getActiveSyncsForBrain } = useSync();
const brainsWithUploadRights = formatMinimalBrainsToSelectComponentInput(
useMemo(
@ -36,19 +41,69 @@ export const KnowledgeToFeed = ({
const knowledgesTabs: Tab[] = [
{
label: "From documents",
isSelected: selectedTab === "From documents",
onClick: () => setSelectedTab("From documents"),
label: "Documents",
isSelected: selectedTab === "Documents",
onClick: () => setSelectedTab("Documents"),
iconName: "file",
badge: knowledgeToFeed.filter(
(knowledge) => knowledge.source === "upload"
).length,
},
{
label: "From websites",
isSelected: selectedTab === "From websites",
onClick: () => setSelectedTab("From websites"),
label: "Websites",
isSelected: selectedTab === "Websites",
onClick: () => setSelectedTab("Websites"),
iconName: "website",
badge: knowledgeToFeed.filter((knowledge) => knowledge.source === "crawl")
.length,
},
{
label: "Connections",
isSelected: selectedTab === "Connections",
onClick: () => setSelectedTab("Connections"),
iconName: "sync",
badge: openedConnections.filter((connection) => connection.submitted)
.length,
},
];
useEffect(() => {
if (currentBrain) {
void (async () => {
try {
const res = await getActiveSyncsForBrain(currentBrain.id);
setCurrentSyncId(undefined);
setOpenedConnections(
res.map((sync) => ({
user_sync_id: sync.syncs_user_id,
id: sync.id,
provider: sync.syncs_user.provider,
submitted: true,
selectedFiles: {
files: [
...(sync.settings.folders?.map((folder) => ({
id: folder,
name: undefined,
is_folder: true,
})) ?? []),
...(sync.settings.files?.map((file) => ({
id: file,
name: undefined,
is_folder: false,
})) ?? []),
],
},
name: sync.name,
last_synced: sync.last_synced,
}))
);
} catch (error) {
console.error(error);
}
})();
}
}, [currentBrainId]);
return (
<div className={styles.knowledge_to_feed_wrapper}>
{!hideBrainSelector && (
@ -68,39 +123,9 @@ export const KnowledgeToFeed = ({
)}
<Tabs tabList={knowledgesTabs} />
<div className={styles.tabs_content_wrapper}>
{selectedTab === "From documents" && <FromDocuments />}
{selectedTab === "From websites" && <FromWebsites />}
</div>
<div>
<div className={styles.uploaded_knowledges_title}>
<span>Knowledges to upload</span>
<span>{knowledgeToFeed.length}</span>
</div>
<div className={styles.uploaded_knowledges}>
{knowledgeToFeed.map((knowledge, index) => (
<div className={styles.uploaded_knowledge} key={index}>
<div className={styles.left}>
<Icon
name={knowledge.source === "crawl" ? "website" : "file"}
size="small"
color="black"
/>
<span className={styles.label}>
{knowledge.source === "crawl"
? knowledge.url
: knowledge.file.name}
</span>
</div>
<Icon
name="delete"
size="normal"
color="dangerous"
handleHover={true}
onClick={() => removeKnowledgeToFeed(index)}
/>
</div>
))}
</div>
{selectedTab === "Documents" && <FromDocuments />}
{selectedTab === "Websites" && <FromWebsites />}
{selectedTab === "Connections" && <FromConnections />}
</div>
</div>
);

View File

@ -0,0 +1,22 @@
import { SyncElementLine } from "../SyncElementLine/SyncElementLine";
interface FileLineProps {
name: string;
selectable: boolean;
id: string;
}
export const FileLine = ({
name,
selectable,
id,
}: FileLineProps): JSX.Element => {
return (
<SyncElementLine
name={name}
selectable={selectable}
id={id}
isFolder={false}
/>
);
};

View File

@ -0,0 +1,22 @@
import { SyncElementLine } from "../SyncElementLine/SyncElementLine";
interface FolderLineProps {
name: string;
selectable: boolean;
id: string;
}
export const FolderLine = ({
name,
selectable,
id,
}: FolderLineProps): JSX.Element => {
return (
<SyncElementLine
name={name}
selectable={selectable}
id={id}
isFolder={true}
/>
);
};

View File

@ -0,0 +1,36 @@
@use "@/styles/Spacings.module.scss";
@use "@/styles/Typography.module.scss";
.from_connection_container {
overflow: auto;
height: 100%;
padding: Spacings.$spacing01;
.from_connection_wrapper {
display: flex;
flex-direction: column;
gap: Spacings.$spacing06;
overflow: hidden;
max-height: 100%;
.header_buttons {
display: flex;
justify-content: space-between;
}
.connection_content {
overflow: auto;
flex-grow: 1;
&.disable {
opacity: 0.5;
pointer-events: none;
}
.empty_folder {
font-style: italic;
font-size: Typography.$small;
}
}
}
}

View File

@ -0,0 +1,118 @@
import { useEffect, useState } from "react";
import { SyncElement } from "@/lib/api/sync/types";
import { useSync } from "@/lib/api/sync/useSync";
import { ConnectionCards } from "@/lib/components/ConnectionCards/ConnectionCards";
import TextButton from "@/lib/components/ui/TextButton/TextButton";
import { useUserData } from "@/lib/hooks/useUserData";
import { FileLine } from "./FileLine/FileLine";
import { FolderLine } from "./FolderLine/FolderLine";
import styles from "./FromConnections.module.scss";
import { useFromConnectionsContext } from "./FromConnectionsProvider/hooks/useFromConnectionContext";
export const FromConnections = (): JSX.Element => {
const [folderStack, setFolderStack] = useState<(string | null)[]>([]);
const { currentSyncElements, setCurrentSyncElements, currentSyncId } =
useFromConnectionsContext();
const [currentFiles, setCurrentFiles] = useState<SyncElement[]>([]);
const [currentFolders, setCurrentFolders] = useState<SyncElement[]>([]);
const { getSyncFiles } = useSync();
const { userData } = useUserData();
const isPremium = userData?.is_premium;
useEffect(() => {
setCurrentFiles(
currentSyncElements?.files.filter((file) => !file.is_folder) ?? []
);
setCurrentFolders(
currentSyncElements?.files.filter((file) => file.is_folder) ?? []
);
}, [currentSyncElements]);
const handleGetSyncFiles = async (
userSyncId: number,
folderId: string | null
) => {
try {
let res;
if (folderId !== null) {
res = await getSyncFiles(userSyncId, folderId);
} else {
res = await getSyncFiles(userSyncId);
}
setCurrentSyncElements(res);
} catch (error) {
console.error("Failed to get sync files:", error);
}
};
const handleBackClick = async () => {
if (folderStack.length > 0 && currentSyncId) {
const newFolderStack = [...folderStack];
newFolderStack.pop();
setFolderStack(newFolderStack);
const parentFolderId = newFolderStack[newFolderStack.length - 1];
await handleGetSyncFiles(currentSyncId, parentFolderId);
} else {
setCurrentSyncElements({ files: [] });
}
};
const handleFolderClick = async (userSyncId: number, folderId: string) => {
setFolderStack([...folderStack, folderId]);
await handleGetSyncFiles(userSyncId, folderId);
};
return (
<div className={styles.from_connection_container}>
{!currentSyncId ? (
<ConnectionCards fromAddKnowledge={true} />
) : (
<div className={styles.from_connection_wrapper}>
<div className={styles.header_buttons}>
<TextButton
label="Back"
iconName="chevronLeft"
color="black"
onClick={() => {
void handleBackClick();
}}
small={true}
disabled={!folderStack.length}
/>
</div>
<div className={styles.connection_content}>
{currentFolders.map((folder) => (
<div
key={folder.id}
onClick={() => {
void handleFolderClick(currentSyncId, folder.id);
}}
>
<FolderLine
name={folder.name ?? ""}
selectable={!!isPremium}
id={folder.id}
/>
</div>
))}
{currentFiles.map((file) => (
<div key={file.id}>
<FileLine
name={file.name ?? ""}
selectable={true}
id={file.id}
/>
</div>
))}
{!currentFiles.length && !currentFolders.length && (
<span className={styles.empty_folder}>Empty folder</span>
)}
</div>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,57 @@
import { createContext, useState } from "react";
import { OpenedConnection, SyncElements } from "@/lib/api/sync/types";
export type FromConnectionsContextType = {
currentSyncElements: SyncElements | undefined;
setCurrentSyncElements: React.Dispatch<
React.SetStateAction<SyncElements | undefined>
>;
currentSyncId: number | undefined;
setCurrentSyncId: React.Dispatch<React.SetStateAction<number | undefined>>;
openedConnections: OpenedConnection[];
setOpenedConnections: React.Dispatch<
React.SetStateAction<OpenedConnection[]>
>;
hasToReload: boolean;
setHasToReload: React.Dispatch<React.SetStateAction<boolean>>;
};
export const FromConnectionsContext = createContext<
FromConnectionsContextType | undefined
>(undefined);
export const FromConnectionsProvider = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => {
const [currentSyncElements, setCurrentSyncElements] = useState<
SyncElements | undefined
>(undefined);
const [currentSyncId, setCurrentSyncId] = useState<number | undefined>(
undefined
);
const [openedConnections, setOpenedConnections] = useState<
OpenedConnection[]
>([]);
const [hasToReload, setHasToReload] = useState<boolean>(false);
return (
<FromConnectionsContext.Provider
value={{
currentSyncElements,
setCurrentSyncElements,
currentSyncId,
setCurrentSyncId,
openedConnections,
setOpenedConnections,
hasToReload,
setHasToReload,
}}
>
{children}
</FromConnectionsContext.Provider>
);
};

View File

@ -0,0 +1,17 @@
import { useContext } from "react";
import {
FromConnectionsContext,
FromConnectionsContextType,
} from "../FromConnection-provider";
export const useFromConnectionsContext = (): FromConnectionsContextType => {
const context = useContext(FromConnectionsContext);
if (context === undefined) {
throw new Error(
"useFromConnectionsContext must be used within a FromConnectionsProvider"
);
}
return context;
};

View File

@ -0,0 +1,28 @@
@use "@/styles/Spacings.module.scss";
@use "@/styles/Typography.module.scss";
.sync_element_line_wrapper {
display: flex;
gap: Spacings.$spacing03;
padding: Spacings.$spacing03;
border-top: 1px solid var(--border-1);
align-items: center;
cursor: pointer;
font-weight: 500;
.element_name {
font-size: Typography.$small;
}
&:hover {
background-color: var(--background-3);
}
&.no_hover {
cursor: default;
&:hover {
background-color: var(--background-0);
}
}
}

View File

@ -0,0 +1,100 @@
import { useState } from "react";
import { Checkbox } from "@/lib/components/ui/Checkbox/Checkbox";
import Icon from "@/lib/components/ui/Icon/Icon";
import styles from "./SyncElementLine.module.scss";
import { useFromConnectionsContext } from "../FromConnectionsProvider/hooks/useFromConnectionContext";
interface SyncElementLineProps {
name: string;
selectable: boolean;
id: string;
isFolder: boolean;
}
export const SyncElementLine = ({
name,
selectable,
id,
isFolder,
}: SyncElementLineProps): JSX.Element => {
const [isCheckboxHovered, setIsCheckboxHovered] = useState(false);
const { currentSyncId, openedConnections, setOpenedConnections } =
useFromConnectionsContext();
const initialChecked = (): boolean => {
const currentConnection = openedConnections.find(
(connection) => connection.user_sync_id === currentSyncId
);
return currentConnection
? currentConnection.selectedFiles.files.some((file) => file.id === id)
: false;
};
const [checked, setChecked] = useState<boolean>(initialChecked);
const handleSetChecked = () => {
setOpenedConnections((prevState) => {
return prevState.map((connection) => {
if (connection.user_sync_id === currentSyncId) {
const isFileSelected = connection.selectedFiles.files.some(
(file) => file.id === id
);
const updatedFiles = isFileSelected
? connection.selectedFiles.files.filter((file) => file.id !== id)
: [
...connection.selectedFiles.files,
{ id, name, is_folder: isFolder },
];
return {
...connection,
selectedFiles: {
files: updatedFiles,
},
};
}
return connection;
});
});
setChecked((prevChecked) => !prevChecked);
};
return (
<div
className={`${styles.sync_element_line_wrapper} ${
isCheckboxHovered || !isFolder || checked ? styles.no_hover : ""
}`}
onClick={(event) => {
if (isFolder && checked) {
event.stopPropagation();
}
}}
>
<div
className={styles.checkbox_wrapper}
onMouseEnter={() => setIsCheckboxHovered(true)}
onMouseLeave={() => setIsCheckboxHovered(false)}
style={{ pointerEvents: "auto" }}
>
<Checkbox
checked={checked}
setChecked={handleSetChecked}
disabled={!selectable}
tooltip={
!selectable
? "Only premium members can sync folders. This feature automatically adds new files from your folders to your brain, keeping it up-to-date without manual effort."
: ""
}
/>
</div>
<Icon name={isFolder ? "folder" : "file"} color="black" size="normal" />
<span className={styles.element_name}>{name}</span>
</div>
);
};

View File

@ -4,33 +4,39 @@
.from_document_wrapper {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
column-gap: Spacings.$spacing05;
justify-content: center;
align-items: center;
border: 1px dashed var(--border-0);
border-radius: Radius.$big;
box-sizing: border-box;
cursor: pointer;
height: 100%;
overflow: scroll;
&.dragging {
border: 3px dashed var(--accent);
background-color: var(--background-3);
}
.input {
.box_content {
padding: Spacings.$spacing05;
display: flex;
gap: Spacings.$spacing02;
flex-direction: column;
column-gap: Spacings.$spacing05;
justify-content: center;
align-items: center;
height: 100%;
@media (max-width: ScreenSizes.$small) {
flex-direction: column;
}
.input {
display: flex;
gap: Spacings.$spacing02;
padding: Spacings.$spacing05;
.clickable {
font-weight: bold;
@media (max-width: ScreenSizes.$small) {
flex-direction: column;
}
.clickable {
font-weight: bold;
}
}
}
}

View File

@ -27,13 +27,15 @@ export const FromDocuments = (): JSX.Element => {
onMouseLeave={() => setDragging(false)}
onClick={open}
>
<Icon name="upload" size="big" color={dragging ? "accent" : "black"} />
<div className={styles.input}>
<div className={styles.clickable}>
<span>Choose files</span>
<input {...getInputProps()} />
<div className={styles.box_content}>
<Icon name="upload" size="big" color={dragging ? "accent" : "black"} />
<div className={styles.input}>
<div className={styles.clickable}>
<span>Choose files</span>
<input {...getInputProps()} />
</div>
<span>or drag it here</span>
</div>
<span>or drag it here</span>
</div>
</div>
);

View File

@ -17,6 +17,7 @@ import { useToast } from "@/lib/hooks";
import { useOnboarding } from "@/lib/hooks/useOnboarding";
import { FeedItemCrawlType, FeedItemUploadType } from "../../../types";
import { useFromConnectionsContext } from "../components/FromConnections/FromConnectionsProvider/hooks/useFromConnectionContext";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useFeedBrainInChat = ({
@ -42,6 +43,7 @@ export const useFeedBrainInChat = ({
const fetchedNotifications = await getChatNotifications(currentChatId);
setNotifications(fetchedNotifications);
};
const { openedConnections } = useFromConnectionsContext();
const { crawlWebsiteHandler, uploadFileHandler } = useKnowledgeToFeedInput();
const files: File[] = (
knowledgeToFeed.filter((c) => c.source === "upload") as FeedItemUploadType[]
@ -58,7 +60,7 @@ export const useFeedBrainInChat = ({
return;
}
if (knowledgeToFeed.length === 0) {
if (knowledgeToFeed.length === 0 && !openedConnections.length) {
publish({
variant: "danger",
text: t("addFiles"),

View File

@ -85,8 +85,8 @@ div:focus {
body.dark_mode {
/* Backgrounds */
--background-0: var(--black-0);
--background-1: var(--black-1);
--background-0: var(--black-1);
--background-1: var(--black-0);
--background-2: var(--black-2);
--background-3: var(--black-3);
--background-4: var(--black-4);

View File

@ -1,20 +1,15 @@
@use "@/styles/Spacings.module.scss";
@use "@/styles/Typography.module.scss";
.cards_wrapper {
width: 100%;
height: 100%;
.connections_wrapper {
display: flex;
flex-direction: column;
gap: Spacings.$spacing05;
padding: Spacings.$spacing06;
flex-direction: column;
width: 100%;
.title {
@include Typography.H2;
}
.brains_grid {
display: flex;
gap: Spacings.$spacing03;
flex-wrap: wrap;
}
}

View File

@ -0,0 +1,12 @@
import { ConnectionCards } from "@/lib/components/ConnectionCards/ConnectionCards";
import styles from "./Connections.module.scss";
export const Connections = (): JSX.Element => {
return (
<div className={styles.connections_wrapper}>
<span className={styles.title}>Link apps you want to search across</span>
<ConnectionCards />
</div>
);
};

View File

@ -1,9 +1,15 @@
@use "@/styles/Radius.module.scss";
@use "@/styles/Spacings.module.scss";
@use "@/styles/Typography.module.scss";
.settings_wrapper {
display: flex;
flex-direction: column;
gap: Spacings.$spacing07;
width: auto;
padding: Spacings.$spacing06;
.title {
@include Typography.H2;
}
}

View File

@ -13,6 +13,9 @@ type InfoDisplayerProps = {
export const Settings = ({ email }: 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>

View File

@ -1,3 +1,5 @@
@use "@/styles/Radius.module.scss";
@use "@/styles/ScreenSizes.module.scss";
@use "@/styles/Spacings.module.scss";
.user_page_container {
@ -5,7 +7,18 @@
padding-block: Spacings.$spacing07;
display: flex;
flex-direction: column;
gap: Spacings.$spacing05;
width: 100%;
height: 100%;
@media (max-width: ScreenSizes.$small) {
display: flex;
flex-direction: column;
}
.content_wrapper {
display: flex;
gap: Spacings.$spacing05;
}
}
.modal_wrapper {

View File

@ -7,11 +7,14 @@ import { useUserApi } from "@/lib/api/user/useUserApi";
import PageHeader from "@/lib/components/PageHeader/PageHeader";
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 { useUserData } from "@/lib/hooks/useUserData";
import { redirectToLogin } from "@/lib/router/redirectToLogin";
import { ButtonType } from "@/lib/types/QuivrButton";
import { Tab } from "@/lib/types/Tab";
import { Connections } from "./components/Connections/Connections";
import { Settings } from "./components/Settings/Settings";
import styles from "./page.module.scss";
@ -30,6 +33,7 @@ const UserPage = (): JSX.Element => {
isLogoutModalOpened,
setIsLogoutModalOpened,
} = useLogoutModal();
const [selectedTab, setSelectedTab] = useState("Connections");
const buttons: ButtonType[] = [
{
@ -50,6 +54,21 @@ const UserPage = (): JSX.Element => {
},
];
const studioTabs: Tab[] = [
{
label: "Connections",
isSelected: selectedTab === "Connections",
onClick: () => setSelectedTab("Connections"),
iconName: "sync",
},
{
label: "General",
isSelected: selectedTab === "General",
onClick: () => setSelectedTab("General"),
iconName: "user",
},
];
if (!session || !userData) {
redirectToLogin();
}
@ -60,8 +79,11 @@ const UserPage = (): JSX.Element => {
<PageHeader iconName="user" label="Profile" buttons={buttons} />
</div>
<div className={styles.user_page_container}>
<Tabs tabList={studioTabs} />
<div className={styles.user_page_menu}></div>
<div className={styles.content_wrapper}>
<Settings email={userData.email} />
{selectedTab === "General" && <Settings email={userData.email} />}
{selectedTab === "Connections" && <Connections />}
</div>
</div>
<Modal

View File

@ -1,31 +0,0 @@
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { useAuthApi } from "../useAuthApi";
const axiosPostMock = vi.fn(() => ({
data: {
api_key: "",
},
}));
vi.mock("@/lib/hooks", () => ({
useAxios: () => ({
axiosInstance: {
post: axiosPostMock,
},
}),
}));
describe("useAuthApi", () => {
it("should call createApiKey with the correct parameters", async () => {
const {
result: {
current: { createApiKey },
},
} = renderHook(() => useAuthApi());
await createApiKey();
expect(axiosPostMock).toHaveBeenCalledTimes(1);
expect(axiosPostMock).toHaveBeenCalledWith("/api-key");
});
});

View File

@ -1,28 +0,0 @@
import { renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { getNock } from "../../tests/getNock";
import { useChatApi } from "../useChatApi";
getNock().options("/chat").reply(200);
describe("useChatApi", () => {
it("should make http request while creating chat", async () => {
const chatName = "Test Chat";
const scope = getNock().post("/chat").reply(200, { chat_name: chatName });
const {
result: {
current: { createChat },
},
} = renderHook(() => useChatApi());
const createdChat = await createChat(chatName);
//Check that the endpoint was called
expect(scope.isDone()).toBe(true);
expect(createdChat).toMatchObject({ chat_name: chatName });
});
});

View File

@ -1,154 +0,0 @@
/* eslint-disable max-lines */
import { renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ChatQuestion } from "@/app/chat/[chatId]/types";
import { useChatApi } from "../useChatApi";
const axiosPostMock = vi.fn(() => ({}));
const axiosGetMock = vi.fn(() => ({}));
const axiosPutMock = vi.fn(() => ({}));
const axiosDeleteMock = vi.fn(() => ({}));
vi.mock("@/lib/hooks", () => ({
useAxios: () => ({
axiosInstance: {
post: axiosPostMock,
get: axiosGetMock,
put: axiosPutMock,
delete: axiosDeleteMock,
},
}),
}));
describe("useChatApi", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("should call createChat with the correct parameters", async () => {
const chatName = "Test Chat";
axiosPostMock.mockReturnValue({ data: {} });
const {
result: {
current: { createChat },
},
} = renderHook(() => useChatApi());
await createChat(chatName);
expect(axiosPostMock).toHaveBeenCalledTimes(1);
expect(axiosPostMock).toHaveBeenCalledWith("/chat", {
name: chatName,
});
});
it("should call getChats with the correct parameters", async () => {
axiosGetMock.mockReturnValue({ data: {} });
const {
result: {
current: { getChats },
},
} = renderHook(() => useChatApi());
await getChats();
expect(axiosGetMock).toHaveBeenCalledTimes(1);
expect(axiosGetMock).toHaveBeenCalledWith("/chat");
});
it("should call deleteChat with the correct parameters", async () => {
const chatId = "test-chat-id";
axiosDeleteMock.mockReturnValue({});
const {
result: {
current: { deleteChat },
},
} = renderHook(() => useChatApi());
await deleteChat(chatId);
expect(axiosDeleteMock).toHaveBeenCalledTimes(1);
expect(axiosDeleteMock).toHaveBeenCalledWith(`/chat/${chatId}`);
});
it("should call addQuestion with the correct parameters", async () => {
const chatId = "test-chat-id";
const chatQuestion: ChatQuestion = {
question: "test-question",
max_tokens: 10,
model: "test-model",
temperature: 0.5,
};
const brainId = "test-brain-id";
const {
result: {
current: { addQuestion },
},
} = renderHook(() => useChatApi());
await addQuestion({ chatId, chatQuestion, brainId });
expect(axiosPostMock).toHaveBeenCalledTimes(1);
expect(axiosPostMock).toHaveBeenCalledWith(
`/chat/${chatId}/question?brain_id=${brainId}`,
chatQuestion
);
});
it("should call getHistory with the correct parameters", async () => {
const chatId = "test-chat-id";
axiosGetMock.mockReturnValue({ data: {} });
const {
result: {
current: { getChatItems: getHistory },
},
} = renderHook(() => useChatApi());
await getHistory(chatId);
expect(axiosGetMock).toHaveBeenCalledTimes(1);
expect(axiosGetMock).toHaveBeenCalledWith(`/chat/${chatId}/history`);
});
it("should call updateChat with the correct parameters", async () => {
const chatId = "test-chat-id";
const chatName = "test-chat-name";
axiosPutMock.mockReturnValue({ data: {} });
const {
result: {
current: { updateChat },
},
} = renderHook(() => useChatApi());
await updateChat(chatId, { chat_name: chatName });
expect(axiosPutMock).toHaveBeenCalledTimes(1);
expect(axiosPutMock).toHaveBeenCalledWith(`/chat/${chatId}/metadata`, {
chat_name: chatName,
});
});
it("should call addQuestionAndAnswer with the correct parameters", async () => {
const chatId = "test-chat-id";
const question = "test-question";
const answer = "test-answer";
axiosPostMock.mockReturnValue({ data: {} });
const {
result: {
current: { addQuestionAndAnswer },
},
} = renderHook(() => useChatApi());
await addQuestionAndAnswer(chatId, { question, answer });
expect(axiosPostMock).toHaveBeenCalledTimes(1);
expect(axiosPostMock).toHaveBeenCalledWith(
`/chat/${chatId}/question/answer`,
{ question, answer }
);
});
});

View File

@ -1,28 +0,0 @@
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { useNotificationApi } from "../useNotificationApi";
const axiosGetMock = vi.fn(() => ({}));
vi.mock("@/lib/hooks", () => ({
useAxios: () => ({
axiosInstance: {
get: axiosGetMock,
},
}),
}));
describe("useNotificationApi", () => {
it("should call getChatNotifications with the correct parameters", async () => {
const chatId = "test-chat-id";
const {
result: {
current: { getChatNotifications },
},
} = renderHook(() => useNotificationApi());
await getChatNotifications(chatId);
expect(axiosGetMock).toHaveBeenCalledTimes(1);
expect(axiosGetMock).toHaveBeenCalledWith(`/notifications/${chatId}`);
});
});

View File

@ -0,0 +1,110 @@
import { AxiosInstance } from "axios";
import { UUID } from "crypto";
import {
ActiveSync,
OpenedConnection,
Sync,
SyncElement,
SyncElements,
} from "./types";
const createFilesSettings = (files: SyncElement[]) =>
files.filter((file) => !file.is_folder).map((file) => file.id);
const createFoldersSettings = (files: SyncElement[]) =>
files.filter((file) => file.is_folder).map((file) => file.id);
export const syncGoogleDrive = async (
name: string,
axiosInstance: AxiosInstance
): Promise<{ authorization_url: string }> => {
return (
await axiosInstance.post<{ authorization_url: string }>(
`/sync/google/authorize?name=${name}`
)
).data;
};
export const syncSharepoint = async (
name: string,
axiosInstance: AxiosInstance
): Promise<{ authorization_url: string }> => {
return (
await axiosInstance.post<{ authorization_url: string }>(
`/sync/azure/authorize?name=${name}`
)
).data;
};
export const getUserSyncs = async (
axiosInstance: AxiosInstance
): Promise<Sync[]> => {
return (await axiosInstance.get<Sync[]>("/sync")).data;
};
export const getSyncFiles = async (
axiosInstance: AxiosInstance,
userSyncId: number,
folderId?: string
): Promise<SyncElements> => {
const url = folderId
? `/sync/${userSyncId}/files?user_sync_id=${userSyncId}&folder_id=${folderId}`
: `/sync/${userSyncId}/files?user_sync_id=${userSyncId}`;
return (await axiosInstance.get<SyncElements>(url)).data;
};
export const syncFiles = async (
axiosInstance: AxiosInstance,
openedConnection: OpenedConnection,
brainId: UUID
): Promise<void> => {
return (
await axiosInstance.post<void>(`/sync/active`, {
name: openedConnection.name,
syncs_user_id: openedConnection.user_sync_id,
settings: {
files: createFilesSettings(openedConnection.selectedFiles.files),
folders: createFoldersSettings(openedConnection.selectedFiles.files),
},
brain_id: brainId,
})
).data;
};
export const updateActiveSync = async (
axiosInstance: AxiosInstance,
openedConnection: OpenedConnection
): Promise<void> => {
return (
await axiosInstance.put<void>(`/sync/active/${openedConnection.id}`, {
name: openedConnection.name,
settings: {
files: createFilesSettings(openedConnection.selectedFiles.files),
folders: createFoldersSettings(openedConnection.selectedFiles.files),
},
last_synced: openedConnection.last_synced,
})
).data;
};
export const deleteActiveSync = async (
axiosInstance: AxiosInstance,
syncId: number
): Promise<void> => {
await axiosInstance.delete<void>(`/sync/active/${syncId}`);
};
export const getActiveSyncs = async (
axiosInstance: AxiosInstance
): Promise<ActiveSync[]> => {
return (await axiosInstance.get<ActiveSync[]>(`/sync/active`)).data;
};
export const deleteUserSync = async (
axiosInstance: AxiosInstance,
syncId: number
): Promise<void> => {
return (await axiosInstance.delete<void>(`/sync/${syncId}`)).data;
};

View File

@ -0,0 +1,53 @@
export type Provider = "Google" | "Azure";
export interface SyncElement {
name?: string;
id: string;
is_folder: boolean;
}
export interface SyncElements {
files: SyncElement[];
}
interface Credentials {
token: string;
}
export interface Sync {
name: string;
provider: Provider;
id: number;
credentials: Credentials;
email: string;
}
export interface SyncSettings {
folders?: string[];
files?: string[];
}
export interface ActiveSync {
id: number;
name: string;
syncs_user_id: number;
user_id: string;
settings: SyncSettings;
last_synced: string;
sync_interval_minutes: number;
brain_id: string;
syncs_user: {
provider: Provider;
};
}
export interface OpenedConnection {
user_sync_id: number;
id: number | undefined;
provider: Provider;
submitted: boolean;
selectedFiles: SyncElements;
name: string;
last_synced: string;
cleaned?: boolean;
}

View File

@ -0,0 +1,54 @@
import { UUID } from "crypto";
import { useAxios } from "@/lib/hooks";
import {
deleteActiveSync,
deleteUserSync,
getActiveSyncs,
getSyncFiles,
getUserSyncs,
syncFiles,
syncGoogleDrive,
syncSharepoint,
updateActiveSync,
} from "./sync";
import { OpenedConnection, Provider } from "./types";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useSync = () => {
const { axiosInstance } = useAxios();
const iconUrls: Record<Provider, string> = {
Google:
"https://quivr-cms.s3.eu-west-3.amazonaws.com/gdrive_8316d080fd.png",
Azure:
"https://quivr-cms.s3.eu-west-3.amazonaws.com/sharepoint_8c41cfdb09.png",
};
const getActiveSyncsForBrain = async (brainId: string) => {
const activeSyncs = await getActiveSyncs(axiosInstance);
return activeSyncs.filter((sync) => sync.brain_id === brainId);
};
return {
syncGoogleDrive: async (name: string) =>
syncGoogleDrive(name, axiosInstance),
syncSharepoint: async (name: string) => syncSharepoint(name, axiosInstance),
getUserSyncs: async () => getUserSyncs(axiosInstance),
getSyncFiles: async (userSyncId: number, folderId?: string) =>
getSyncFiles(axiosInstance, userSyncId, folderId),
iconUrls,
syncFiles: async (openedConnection: OpenedConnection, brainId: UUID) =>
syncFiles(axiosInstance, openedConnection, brainId),
getActiveSyncs: async () => getActiveSyncs(axiosInstance),
getActiveSyncsForBrain,
deleteUserSync: async (syncId: number) =>
deleteUserSync(axiosInstance, syncId),
deleteActiveSync: async (syncId: number) =>
deleteActiveSync(axiosInstance, syncId),
updateActiveSync: async (openedConnection: OpenedConnection) =>
updateActiveSync(axiosInstance, openedConnection),
};
};

View File

@ -5,9 +5,10 @@
padding-block: Spacings.$spacing05;
flex-direction: column;
width: 100%;
height: 100%;
max-height: 90%;
min-height: 90%;
overflow: hidden;
gap: Spacings.$spacing08;
gap: Spacings.$spacing05;
.stepper_container {
width: 100%;
@ -16,6 +17,6 @@
.content_wrapper {
flex-grow: 1;
overflow: scroll;
overflow: auto;
}
}

View File

@ -2,15 +2,17 @@ import { useEffect } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useFromConnectionsContext } from "@/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FromConnectionsProvider/hooks/useFromConnectionContext";
import { Modal } from "@/lib/components/ui/Modal/Modal";
import { addBrainDefaultValues } from "@/lib/config/defaultBrainConfig";
import { useKnowledgeToFeedContext } from "@/lib/context/KnowledgeToFeedProvider/hooks/useKnowledgeToFeedContext";
import { useUserData } from "@/lib/hooks/useUserData";
import styles from "./AddBrainModal.module.scss";
import { useBrainCreationContext } from "./brainCreation-provider";
import { BrainMainInfosStep } from "./components/BrainMainInfosStep/BrainMainInfosStep";
import { BrainTypeSelectionStep } from "./components/BrainTypeSelectionStep/BrainTypeSelectionStep";
import { CreateBrainStep } from "./components/CreateBrainStep/CreateBrainStep";
import { BrainRecapStep } from "./components/BrainRecapStep/BrainRecapStep";
import { FeedBrainStep } from "./components/FeedBrainStep/FeedBrainStep";
import { Stepper } from "./components/Stepper/Stepper";
import { useBrainCreationSteps } from "./hooks/useBrainCreationSteps";
import { CreateBrainProps } from "./types/types";
@ -19,12 +21,14 @@ export const AddBrainModal = (): JSX.Element => {
const { t } = useTranslation(["translation", "brain", "config"]);
const { userIdentityData } = useUserData();
const { currentStep, steps } = useBrainCreationSteps();
const {
isBrainCreationModalOpened,
setIsBrainCreationModalOpened,
setCurrentSelectedBrain,
} = useBrainCreationContext();
const { setCurrentSyncId, setOpenedConnections } =
useFromConnectionsContext();
const { removeAllKnowledgeToFeed } = useKnowledgeToFeedContext();
const defaultValues: CreateBrainProps = {
...addBrainDefaultValues,
@ -38,6 +42,10 @@ export const AddBrainModal = (): JSX.Element => {
useEffect(() => {
setCurrentSelectedBrain(undefined);
setCurrentSyncId(undefined);
setOpenedConnections([]);
methods.reset(defaultValues);
removeAllKnowledgeToFeed();
}, [isBrainCreationModalOpened]);
return (
@ -56,9 +64,9 @@ export const AddBrainModal = (): JSX.Element => {
<Stepper currentStep={currentStep} steps={steps} />
</div>
<div className={styles.content_wrapper}>
<BrainTypeSelectionStep />
<BrainMainInfosStep />
<CreateBrainStep />
<FeedBrainStep />
<BrainRecapStep />
</div>
</div>
</Modal>

View File

@ -1,3 +1,4 @@
@use "@/styles/ScreenSizes.module.scss";
@use "@/styles/Spacings.module.scss";
@use "@/styles/Typography.module.scss";
@ -7,6 +8,7 @@
justify-content: space-between;
height: 100%;
padding-inline: Spacings.$spacing08;
gap: Spacings.$spacing03;
.title {
@include Typography.H2;
@ -16,10 +18,21 @@
display: flex;
flex-direction: column;
gap: Spacings.$spacing05;
overflow: scroll;
.name_field {
width: 300px;
}
@media (max-width: ScreenSizes.$small) {
.name_field {
width: 100%;
}
}
}
.buttons_wrapper {
display: flex;
justify-content: space-between;
justify-content: flex-end;
}
}

View File

@ -11,8 +11,7 @@ import styles from "./BrainMainInfosStep.module.scss";
import { useBrainCreationSteps } from "../../hooks/useBrainCreationSteps";
export const BrainMainInfosStep = (): JSX.Element => {
const { currentStepIndex, goToNextStep, goToPreviousStep } =
useBrainCreationSteps();
const { currentStepIndex, goToNextStep } = useBrainCreationSteps();
const { watch } = useFormContext<CreateBrainProps>();
const name = watch("name");
@ -24,11 +23,7 @@ export const BrainMainInfosStep = (): JSX.Element => {
goToNextStep();
};
const previous = (): void => {
goToPreviousStep();
};
if (currentStepIndex !== 1) {
if (currentStepIndex !== 0) {
return <></>;
}
@ -36,7 +31,7 @@ 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>
<div>
<div className={styles.name_field}>
<FieldHeader iconName="brain" label="Name" mandatory={true} />
<Controller
name="name"
@ -68,12 +63,6 @@ export const BrainMainInfosStep = (): JSX.Element => {
</div>
</div>
<div className={styles.buttons_wrapper}>
<QuivrButton
color="primary"
label="Previous Step"
onClick={() => previous()}
iconName="chevronLeft"
/>
<QuivrButton
color="primary"
label="Next Step"

View File

@ -0,0 +1,24 @@
@use "@/styles/BoxShadow.module.scss";
@use "@/styles/Radius.module.scss";
@use "@/styles/Spacings.module.scss";
@use "@/styles/Typography.module.scss";
.brain_recap_card_wrapper {
display: flex;
padding: Spacings.$spacing05;
justify-content: center;
box-shadow: BoxShadow.$small;
border-radius: Radius.$normal;
display: flex;
align-items: center;
gap: Spacings.$spacing03;
.number_label {
@include Typography.Big;
color: var(--primary-0);
}
.type {
@include Typography.H1;
}
}

View File

@ -0,0 +1,21 @@
import styles from "./BrainRecapCard.module.scss";
interface BrainRecapCardProps {
label: string;
number: number;
}
export const BrainRecapCard = ({
label,
number,
}: BrainRecapCardProps): JSX.Element => {
return (
<div className={styles.brain_recap_card_wrapper}>
<span className={styles.number_label}>{number.toString()}</span>
<span className={styles.type}>
{label}
{number > 1 ? "s" : ""}
</span>
</div>
);
};

View File

@ -0,0 +1,76 @@
@use "@/styles/ScreenSizes.module.scss";
@use "@/styles/Spacings.module.scss";
@use "@/styles/Typography.module.scss";
.brain_recap_wrapper {
display: flex;
justify-content: space-between;
flex-direction: column;
height: 100%;
padding-inline: Spacings.$spacing08;
gap: Spacings.$spacing03;
overflow: hidden;
.content_wrapper {
display: flex;
flex-direction: column;
gap: Spacings.$spacing05;
overflow: scroll;
.title {
@include Typography.H2;
}
.subtitle {
@include Typography.H3;
}
.warning_message {
font-size: Typography.$small;
}
.brain_info_wrapper {
display: flex;
flex-direction: column;
gap: Spacings.$spacing05;
.name_field {
width: 300px;
}
@media screen and (max-width: ScreenSizes.$small) {
.name_field {
min-width: 100%;
max-width: 100%;
}
}
}
.cards_wrapper {
display: flex;
flex-wrap: wrap;
padding: Spacings.$spacing01;
justify-content: space-between;
gap: Spacings.$spacing05;
> * {
min-width: 120px;
max-width: 200px;
flex: 1;
}
@media screen and (max-width: ScreenSizes.$small) {
> * {
min-width: 100%;
max-width: 100%;
flex: 1;
}
}
}
}
.buttons_wrapper {
display: flex;
justify-content: space-between;
}
}

View File

@ -0,0 +1,135 @@
import { Controller } from "react-hook-form";
import { useFromConnectionsContext } from "@/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FromConnectionsProvider/hooks/useFromConnectionContext";
import { useUserApi } from "@/lib/api/user/useUserApi";
import { MessageInfoBox } from "@/lib/components/ui/MessageInfoBox/MessageInfoBox";
import QuivrButton from "@/lib/components/ui/QuivrButton/QuivrButton";
import { TextAreaInput } from "@/lib/components/ui/TextAreaInput/TextAreaInput";
import { TextInput } from "@/lib/components/ui/TextInput/TextInput";
import { useKnowledgeToFeedContext } from "@/lib/context/KnowledgeToFeedProvider/hooks/useKnowledgeToFeedContext";
import { useUserData } from "@/lib/hooks/useUserData";
import { BrainRecapCard } from "./BrainRecapCard/BrainRecapCard";
import styles from "./BrainRecapStep.module.scss";
import { useBrainCreationContext } from "../../brainCreation-provider";
import { useBrainCreationSteps } from "../../hooks/useBrainCreationSteps";
import { useBrainCreationApi } from "../FeedBrainStep/hooks/useBrainCreationApi";
export const BrainRecapStep = (): JSX.Element => {
const { currentStepIndex, goToPreviousStep } = useBrainCreationSteps();
const { creating, setCreating } = useBrainCreationContext();
const { knowledgeToFeed } = useKnowledgeToFeedContext();
const { createBrain } = useBrainCreationApi();
const { updateUserIdentity } = useUserApi();
const { userIdentityData } = useUserData();
const { openedConnections, setOpenedConnections } =
useFromConnectionsContext();
const feed = async (): Promise<void> => {
if (!userIdentityData?.onboarded) {
await updateUserIdentity({
...userIdentityData,
username: userIdentityData?.username ?? "",
onboarded: true,
});
}
setCreating(true);
createBrain();
};
const previous = (): void => {
goToPreviousStep();
};
if (currentStepIndex !== 2) {
return <></>;
}
return (
<div className={styles.brain_recap_wrapper}>
<div className={styles.content_wrapper}>
<span className={styles.title}>Brain Recap</span>
<MessageInfoBox type="warning">
<span className={styles.warning_message}>
Depending on the number of knowledge, the upload can take
<strong> few minutes</strong>.
</span>
</MessageInfoBox>
<div className={styles.brain_info_wrapper}>
<div className={styles.name_field}>
<Controller
name="name"
render={({ field }) => (
<TextInput
label="Enter your brain name"
inputValue={field.value as string}
setInputValue={field.onChange}
disabled={true}
/>
)}
/>
</div>
<div>
<Controller
name="description"
render={({ field }) => (
<TextAreaInput
label="Enter your brain description"
inputValue={field.value as string}
setInputValue={field.onChange}
disabled={true}
/>
)}
/>
</div>
</div>
<span className={styles.subtitle}>Knowledge From</span>
<div className={styles.cards_wrapper}>
<BrainRecapCard
label="Connection"
number={openedConnections.length}
/>
<BrainRecapCard
label="URL"
number={
knowledgeToFeed.filter(
(knowledge) => knowledge.source === "crawl"
).length
}
/>
<BrainRecapCard
label="Document"
number={
knowledgeToFeed.filter(
(knowledge) => knowledge.source === "upload"
).length
}
/>
</div>
</div>
<div className={styles.buttons_wrapper}>
<QuivrButton
label="Previous step"
color="primary"
iconName="chevronLeft"
onClick={previous}
/>
<QuivrButton
label="Create"
color="primary"
iconName="add"
onClick={async () => {
await feed();
setOpenedConnections([]);
}}
disabled={
knowledgeToFeed.length === 0 && !userIdentityData?.onboarded
}
isLoading={creating}
important={true}
/>
</div>
</div>
);
};

View File

@ -1,61 +0,0 @@
import { IntegrationBrains } from "@/lib/api/brain/types";
import { BrainCard } from "@/lib/components/ui/BrainCard/BrainCard";
import { MessageInfoBox } from "@/lib/components/ui/MessageInfoBox/MessageInfoBox";
import { useUserData } from "@/lib/hooks/useUserData";
import styles from "./BrainCatalogue.module.scss";
import { useBrainCreationContext } from "../../../brainCreation-provider";
export const BrainCatalogue = ({
brains,
next,
}: {
brains: IntegrationBrains[];
next: () => void;
}): JSX.Element => {
const { setCurrentSelectedBrain, currentSelectedBrain } =
useBrainCreationContext();
const { userIdentityData } = useUserData();
return (
<div className={styles.cards_wrapper}>
<MessageInfoBox type="info">
<span>
A Brain is a specialized AI tool designed to interact with specific
use cases or data sources.
</span>
</MessageInfoBox>
{!userIdentityData?.onboarded && (
<MessageInfoBox type="tutorial">
<span>
Let&apos;s start by creating a Docs &amp; URLs brain.<br></br>Of
course, feel free to explore other types of brains during your Quivr
journey.
</span>
</MessageInfoBox>
)}
<span className={styles.title}>Choose a brain type</span>
<div className={styles.brains_grid}>
{brains.map((brain) => {
return (
<BrainCard
key={brain.id}
tooltip={brain.description}
brainName={brain.integration_display_name}
tags={brain.tags}
selected={currentSelectedBrain?.id === brain.id}
imageUrl={brain.integration_logo_url}
callback={() => {
next();
setCurrentSelectedBrain(brain);
}}
cardKey={brain.id}
disabled={!userIdentityData?.onboarded && !brain.onboarding_brain}
/>
);
})}
</div>
</div>
);
};

View File

@ -1,42 +0,0 @@
@use "@/styles/ScreenSizes.module.scss";
@use "@/styles/Spacings.module.scss";
@use "@/styles/Typography.module.scss";
.brain_types_wrapper {
display: flex;
justify-content: space-between;
flex-direction: column;
width: 100%;
padding-inline: Spacings.$spacing08;
height: 100%;
gap: Spacings.$spacing05;
overflow-y: hidden;
overflow-x: visible;
@media (max-width: ScreenSizes.$small) {
padding-inline: 0;
}
.main_wrapper {
display: flex;
flex-direction: column;
gap: Spacings.$spacing05;
padding-top: Spacings.$spacing03;
overflow-y: scroll;
overflow-x: visible;
.title {
@include Typography.H2;
}
}
.buttons_wrapper {
align-self: flex-end;
&.two_buttons {
display: flex;
justify-content: space-between;
align-self: normal;
}
}
}

View File

@ -1,43 +0,0 @@
import { useEffect, useState } from "react";
import { useFormContext } from "react-hook-form";
import { IntegrationBrains } from "@/lib/api/brain/types";
import { useBrainApi } from "@/lib/api/brain/useBrainApi";
import { BrainCatalogue } from "./BrainCatalogue/BrainCatalogue";
import styles from "./BrainTypeSelectionStep.module.scss";
import { useBrainCreationSteps } from "../../hooks/useBrainCreationSteps";
import { CreateBrainProps } from "../../types/types";
export const BrainTypeSelectionStep = (): JSX.Element => {
const [brains, setBrains] = useState<IntegrationBrains[]>([]);
const { goToNextStep, currentStepIndex } = useBrainCreationSteps();
const { getIntegrationBrains } = useBrainApi();
const { setValue } = useFormContext<CreateBrainProps>();
useEffect(() => {
getIntegrationBrains()
.then((response) => {
setBrains(response);
})
.catch((error) => {
console.error(error);
});
setValue("name", "");
setValue("description", "");
}, []);
if (currentStepIndex !== 0) {
return <></>;
}
return (
<div className={styles.brain_types_wrapper}>
<div className={styles.main_wrapper}>
<BrainCatalogue brains={brains} next={goToNextStep} />
</div>
</div>
);
};

View File

@ -1,170 +0,0 @@
import { capitalCase } from "change-case";
import { useEffect, useState } from "react";
import { KnowledgeToFeed } from "@/app/chat/[chatId]/components/ActionsBar/components";
import { useUserApi } from "@/lib/api/user/useUserApi";
import { MessageInfoBox } from "@/lib/components/ui/MessageInfoBox/MessageInfoBox";
import QuivrButton from "@/lib/components/ui/QuivrButton/QuivrButton";
import { TextInput } from "@/lib/components/ui/TextInput/TextInput";
import { useKnowledgeToFeedContext } from "@/lib/context/KnowledgeToFeedProvider/hooks/useKnowledgeToFeedContext";
import { useUserData } from "@/lib/hooks/useUserData";
import styles from "./CreateBrainStep.module.scss";
import { useBrainCreationApi } from "./hooks/useBrainCreationApi";
import { useBrainCreationContext } from "../../brainCreation-provider";
import { useBrainCreationSteps } from "../../hooks/useBrainCreationSteps";
export const CreateBrainStep = (): JSX.Element => {
const { currentStepIndex, goToPreviousStep } = useBrainCreationSteps();
const { createBrain, fields, setFields } = useBrainCreationApi();
const { creating, setCreating, currentSelectedBrain } =
useBrainCreationContext();
const [createBrainStepIndex, setCreateBrainStepIndex] = useState<number>(0);
const { knowledgeToFeed } = useKnowledgeToFeedContext();
const { userIdentityData } = useUserData();
const { updateUserIdentity } = useUserApi();
useEffect(() => {
if (currentSelectedBrain?.connection_settings) {
const newFields = Object.entries(
currentSelectedBrain.connection_settings
).map(([key, type]) => {
return { name: key, type, value: "" };
});
setFields(newFields);
}
setCreateBrainStepIndex(Number(!currentSelectedBrain?.connection_settings));
}, [currentSelectedBrain?.connection_settings]);
const handleInputChange = (name: string, value: string) => {
setFields(
fields.map((field) => (field.name === name ? { ...field, value } : field))
);
};
const previous = (): void => {
goToPreviousStep();
};
const feed = async (): Promise<void> => {
if (!userIdentityData?.onboarded) {
await updateUserIdentity({
...userIdentityData,
username: userIdentityData?.username ?? "",
onboarded: true,
});
}
setCreating(true);
createBrain();
};
const renderSettings = () => {
return (
<>
<MessageInfoBox type="warning">
{currentSelectedBrain?.information}
</MessageInfoBox>
{fields.map(({ name, value }) => (
<TextInput
key={name}
inputValue={value}
setInputValue={(inputValue) => handleInputChange(name, inputValue)}
label={capitalCase(name)}
/>
))}
</>
);
};
const renderFeedBrain = () => {
return (
<>
{!userIdentityData?.onboarded && (
<MessageInfoBox type="tutorial">
<span>
Upload documents or add URLs to add knowledges to your brain.
</span>
</MessageInfoBox>
)}
<div>
<span className={styles.title}>Feed your brain</span>
<KnowledgeToFeed hideBrainSelector={true} />
</div>
</>
);
};
const renderCreateButton = () => {
return (
<MessageInfoBox type="info">
<div className={styles.message_content}>
Click on
<QuivrButton
label="Create"
color="primary"
iconName="add"
onClick={feed}
isLoading={creating}
/>
to finish your brain creation.
</div>
</MessageInfoBox>
);
};
const renderButtons = () => {
return (
<div className={styles.buttons_wrapper}>
<QuivrButton
label="Previous step"
color="primary"
iconName="chevronLeft"
onClick={previous}
/>
{(!currentSelectedBrain?.max_files && !createBrainStepIndex) ||
createBrainStepIndex ? (
<QuivrButton
label="Create"
color="primary"
iconName="add"
onClick={feed}
disabled={
knowledgeToFeed.length === 0 && !userIdentityData?.onboarded
}
isLoading={creating}
important={true}
/>
) : (
<QuivrButton
label="Feed your brain"
color="primary"
iconName="add"
onClick={() => setCreateBrainStepIndex(1)}
isLoading={creating}
important={true}
/>
)}
</div>
);
};
if (currentStepIndex !== 2) {
return <></>;
}
return (
<div className={styles.brain_knowledge_wrapper}>
{!createBrainStepIndex && renderSettings()}
{!!currentSelectedBrain?.max_files &&
!!createBrainStepIndex &&
renderFeedBrain()}
{!currentSelectedBrain?.max_files &&
!currentSelectedBrain?.connection_settings &&
renderCreateButton()}
{renderButtons()}
</div>
);
};

View File

@ -8,14 +8,15 @@
padding-inline: Spacings.$spacing08;
height: 100%;
.title {
@include Typography.H2;
}
.settings_wrapper {
.feed_brain {
display: flex;
flex-direction: column;
gap: Spacings.$spacing05;
overflow: scroll;
height: 100%;
.title {
@include Typography.H2;
}
}
.message_info_box_wrapper {

View File

@ -0,0 +1,113 @@
import { useEffect, useState } from "react";
import { KnowledgeToFeed } from "@/app/chat/[chatId]/components/ActionsBar/components";
import { useFromConnectionsContext } from "@/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FromConnectionsProvider/hooks/useFromConnectionContext";
import { OpenedConnection } from "@/lib/api/sync/types";
import { MessageInfoBox } from "@/lib/components/ui/MessageInfoBox/MessageInfoBox";
import QuivrButton from "@/lib/components/ui/QuivrButton/QuivrButton";
import { createHandleGetButtonProps } from "@/lib/helpers/handleConnectionButtons";
import { useUserData } from "@/lib/hooks/useUserData";
import styles from "./FeedBrainStep.module.scss";
import { useBrainCreationSteps } from "../../hooks/useBrainCreationSteps";
export const FeedBrainStep = (): JSX.Element => {
const { currentStepIndex, goToPreviousStep, goToNextStep } =
useBrainCreationSteps();
const { userIdentityData } = useUserData();
const {
currentSyncId,
setCurrentSyncId,
openedConnections,
setOpenedConnections,
} = useFromConnectionsContext();
const [currentConnection, setCurrentConnection] = useState<
OpenedConnection | undefined
>(undefined);
useEffect(() => {
setCurrentConnection(
openedConnections.find(
(connection) => connection.user_sync_id === currentSyncId
)
);
}, [currentSyncId]);
const getButtonProps = createHandleGetButtonProps(
currentConnection,
openedConnections,
setOpenedConnections,
currentSyncId,
setCurrentSyncId
);
const renderFeedBrain = () => (
<>
{!userIdentityData?.onboarded && (
<MessageInfoBox type="tutorial">
<span>
Upload documents or add URLs to add knowledges to your brain.
</span>
</MessageInfoBox>
)}
<div className={styles.feed_brain}>
<span className={styles.title}>Feed your brain</span>
<KnowledgeToFeed hideBrainSelector={true} />
</div>
</>
);
const renderButtons = () => {
const buttonProps = getButtonProps();
return (
<div className={styles.buttons_wrapper}>
{currentSyncId ? (
<QuivrButton
label="Back to connections"
color="primary"
iconName="chevronLeft"
onClick={() => setCurrentSyncId(undefined)}
/>
) : (
<QuivrButton
label="Previous step"
color="primary"
iconName="chevronLeft"
onClick={goToPreviousStep}
/>
)}
{currentSyncId ? (
<QuivrButton
label={buttonProps.label}
color={buttonProps.type}
iconName={buttonProps.type === "dangerous" ? "delete" : "add"}
onClick={buttonProps.callback}
important={true}
disabled={buttonProps.disabled}
/>
) : (
<QuivrButton
label={"Next step"}
color="primary"
iconName="chevronRight"
onClick={goToNextStep}
important={true}
/>
)}
</div>
);
};
if (currentStepIndex !== 1) {
return <></>;
}
return (
<div className={styles.brain_knowledge_wrapper}>
{renderFeedBrain()}
{renderButtons()}
</div>
);
};

View File

@ -5,8 +5,10 @@ import { useState } from "react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useFromConnectionsContext } from "@/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FromConnectionsProvider/hooks/useFromConnectionContext";
import { PUBLIC_BRAINS_KEY } from "@/lib/api/brain/config";
import { IntegrationSettings } from "@/lib/api/brain/types";
import { useSync } from "@/lib/api/sync/useSync";
import { CreateBrainProps } from "@/lib/components/AddBrainModal/types/types";
import { useKnowledgeToFeedInput } from "@/lib/components/KnowledgeToFeedInput/hooks/useKnowledgeToFeedInput.ts";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
@ -31,14 +33,22 @@ export const useBrainCreationApi = () => {
const [fields, setFields] = useState<
{ name: string; type: string; value: string }[]
>([]);
const { syncFiles } = useSync();
const { openedConnections } = useFromConnectionsContext();
const handleFeedBrain = async (brainId: UUID): Promise<void> => {
const uploadPromises = files.map((file) =>
uploadFileHandler(file, brainId)
);
const crawlPromises = urls.map((url) => crawlWebsiteHandler(url, brainId));
await Promise.all([...uploadPromises, ...crawlPromises]);
await Promise.all(
openedConnections.map(async (openedConnection) => {
await syncFiles(openedConnection, brainId);
})
);
setKnowledgeToFeed([]);
};

View File

@ -14,8 +14,8 @@
position: relative;
.circle {
width: 2.5rem;
height: 2.5rem;
width: 1.75rem;
height: 1.75rem;
background-color: var(--primary-0);
border-radius: Radius.$circle;
display: flex;
@ -80,7 +80,7 @@
display: flex;
flex-direction: column;
font-size: Typography.$tiny;
width: 2.5rem;
width: 1.75rem;
.step_index {
white-space: nowrap;
@ -95,7 +95,7 @@
border-radius: Radius.$big;
background-color: var(--primary-1);
margin: 0 8px;
margin-top: Spacings.$spacing05;
margin-top: Spacings.$spacing04;
&.done {
background-color: var(--success);

View File

@ -0,0 +1,13 @@
@use "@/styles/ScreenSizes.module.scss";
@use "@/styles/Spacings.module.scss";
.connection_cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: Spacings.$spacing05;
width: 100%;
&.spaced {
justify-content: space-between;
}
}

View File

@ -0,0 +1,35 @@
import { useSync } from "@/lib/api/sync/useSync";
import styles from "./ConnectionCards.module.scss";
import { ConnectionSection } from "./ConnectionSection/ConnectionSection";
interface ConnectionCardsProps {
fromAddKnowledge?: boolean;
}
export const ConnectionCards = ({
fromAddKnowledge,
}: ConnectionCardsProps): JSX.Element => {
const { syncGoogleDrive, syncSharepoint } = useSync();
return (
<div
className={`${styles.connection_cards} ${
fromAddKnowledge ? styles.spaced : ""
}`}
>
<ConnectionSection
label="Google Drive"
provider="Google"
callback={(name) => syncGoogleDrive(name)}
fromAddKnowledge={fromAddKnowledge}
/>
<ConnectionSection
label="Sharepoint"
provider="Azure"
callback={(name) => syncSharepoint(name)}
fromAddKnowledge={fromAddKnowledge}
/>
</div>
);
};

View File

@ -0,0 +1,26 @@
@use "@/styles/Spacings.module.scss";
@use "@/styles/Typography.module.scss";
.connection_button_wrapper {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: Spacings.$spacing03;
.left {
display: flex;
gap: Spacings.$spacing02;
overflow: hidden;
.label {
@include Typography.EllipsisOverflow;
font-size: Typography.$small;
}
}
.buttons_wrapper {
display: flex;
gap: Spacings.$spacing02;
}
}

View File

@ -0,0 +1,36 @@
import { ConnectionIcon } from "@/lib/components/ui/ConnectionIcon/ConnectionIcon";
import QuivrButton from "@/lib/components/ui/QuivrButton/QuivrButton";
import styles from "./ConnectionButton.module.scss";
interface ConnectionButtonProps {
label: string;
index: number;
onClick: (id: number) => void;
submitted?: boolean;
}
export const ConnectionButton = ({
label,
index,
onClick,
submitted,
}: ConnectionButtonProps): JSX.Element => {
return (
<div className={styles.connection_button_wrapper}>
<div className={styles.left}>
<ConnectionIcon letter={label[0]} index={index} />
<span className={styles.label}>{label}</span>
</div>
<div className={styles.buttons_wrapper}>
<QuivrButton
label={submitted ? "Update" : "Use"}
small={true}
iconName="chevronRight"
color="primary"
onClick={() => onClick(index)}
/>
</div>
</div>
);
};

View File

@ -0,0 +1,25 @@
@use "@/styles/Spacings.module.scss";
@use "@/styles/Typography.module.scss";
.connection_line_wrapper {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
.left {
display: flex;
gap: Spacings.$spacing02;
overflow: hidden;
.label {
@include Typography.EllipsisOverflow;
font-size: Typography.$small;
}
}
.icons {
display: flex;
gap: Spacings.$spacing02;
}
}

View File

@ -0,0 +1,42 @@
import { useFromConnectionsContext } from "@/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FromConnectionsProvider/hooks/useFromConnectionContext";
import { useSync } from "@/lib/api/sync/useSync";
import { ConnectionIcon } from "@/lib/components/ui/ConnectionIcon/ConnectionIcon";
import Icon from "@/lib/components/ui/Icon/Icon";
import styles from "./ConnectionLine.module.scss";
interface ConnectionLineProps {
label: string;
index: number;
id: number;
}
export const ConnectionLine = ({
label,
index,
id,
}: ConnectionLineProps): JSX.Element => {
const { deleteUserSync } = useSync();
const { setHasToReload } = useFromConnectionsContext();
return (
<div className={styles.connection_line_wrapper}>
<div className={styles.left}>
<ConnectionIcon letter={label[0]} index={index} />
<span className={styles.label}>{label}</span>
</div>
<div className={styles.icons}>
<Icon
name="delete"
size="normal"
color="dangerous"
handleHover={true}
onClick={async () => {
await deleteUserSync(id);
setHasToReload(true);
}}
/>
</div>
</div>
);
};

View File

@ -0,0 +1,84 @@
@use "@/styles/BoxShadow.module.scss";
@use "@/styles/Radius.module.scss";
@use "@/styles/ScreenSizes.module.scss";
@use "@/styles/Spacings.module.scss";
@use "@/styles/Transitions.module.scss";
@use "@/styles/Typography.module.scss";
.connection_section_wrapper {
padding: Spacings.$spacing05;
border-radius: Radius.$normal;
overflow: hidden;
border-bottom: 1px solid transparent;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
border-radius: Radius.$normal;
box-shadow: BoxShadow.$medium;
height: min-content;
width: 100%;
@media (max-width: ScreenSizes.$small) {
width: 100%;
}
.connection_section_header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding-block: Spacings.$spacing03;
@include Typography.H3;
.left {
display: flex;
gap: Spacings.$spacing03;
align-items: center;
}
.iconRotate {
transition: transform 0.3s Transitions.$easeOutBack;
}
.iconRotateDown {
transform: rotate(0deg);
}
.iconRotateRight {
transform: rotate(-90deg);
.label {
@include Typography.H3;
}
}
}
.existing_connections {
display: flex;
flex-direction: column;
gap: Spacings.$spacing03;
width: 100%;
padding-top: Spacings.$spacing05;
.existing_connections_header {
display: flex;
justify-content: space-between;
align-items: center;
.label {
font-weight: 500;
font-size: Typography.$tiny;
}
}
.folded {
display: flex;
padding-left: Spacings.$spacing03;
.negative_margin {
margin-left: -(Spacings.$spacing02);
}
}
}
}

View File

@ -0,0 +1,255 @@
import Image from "next/image";
import { useEffect, useState } from "react";
import { useFromConnectionsContext } from "@/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FromConnectionsProvider/hooks/useFromConnectionContext";
import { OpenedConnection, Provider, Sync } from "@/lib/api/sync/types";
import { useSync } from "@/lib/api/sync/useSync";
import QuivrButton from "@/lib/components/ui/QuivrButton/QuivrButton";
import { ConnectionButton } from "./ConnectionButton/ConnectionButton";
import { ConnectionLine } from "./ConnectionLine/ConnectionLine";
import styles from "./ConnectionSection.module.scss";
import { ConnectionIcon } from "../../ui/ConnectionIcon/ConnectionIcon";
import { Icon } from "../../ui/Icon/Icon";
import { TextButton } from "../../ui/TextButton/TextButton";
interface ConnectionSectionProps {
label: string;
provider: Provider;
callback: (name: string) => Promise<{ authorization_url: string }>;
fromAddKnowledge?: boolean;
}
const renderConnectionLines = (
existingConnections: Sync[],
folded: boolean
) => {
if (!folded) {
return existingConnections.map((connection, index) => (
<div key={index}>
<ConnectionLine
label={connection.email}
index={index}
id={connection.id}
/>
</div>
));
} else {
return (
<div className={styles.folded}>
{existingConnections.map((connection, index) => (
<div className={styles.negative_margin} key={index}>
<ConnectionIcon letter={connection.email[0]} index={index} />
</div>
))}
</div>
);
}
};
const renderExistingConnections = ({
existingConnections,
folded,
setFolded,
fromAddKnowledge,
handleGetSyncFiles,
openedConnections,
}: {
existingConnections: Sync[];
folded: boolean;
setFolded: (folded: boolean) => void;
fromAddKnowledge: boolean;
handleGetSyncFiles: (
userSyncId: number,
currentProvider: Provider
) => Promise<void>;
openedConnections: OpenedConnection[];
}) => {
if (!!existingConnections.length && !fromAddKnowledge) {
return (
<div className={styles.existing_connections}>
<div className={styles.existing_connections_header}>
<span className={styles.label}>Connected accounts</span>
<Icon
name="settings"
size="normal"
color="black"
handleHover={true}
onClick={() => setFolded(!folded)}
/>
</div>
{renderConnectionLines(existingConnections, folded)}
</div>
);
} else if (existingConnections.length > 0 && fromAddKnowledge) {
return (
<div className={styles.existing_connections}>
{existingConnections.map((connection, index) => (
<div key={index}>
<ConnectionButton
label={connection.email}
index={index}
submitted={openedConnections.some((openedConnection) => {
return (
openedConnection.name === connection.name &&
openedConnection.submitted
);
})}
onClick={() =>
void handleGetSyncFiles(connection.id, connection.provider)
}
/>
</div>
))}
</div>
);
} else {
return null;
}
};
export const ConnectionSection = ({
label,
provider,
fromAddKnowledge,
callback,
}: ConnectionSectionProps): JSX.Element => {
const { iconUrls, getUserSyncs, getSyncFiles } = useSync();
const {
setCurrentSyncElements,
setCurrentSyncId,
setOpenedConnections,
openedConnections,
hasToReload,
setHasToReload,
} = useFromConnectionsContext();
const [existingConnections, setExistingConnections] = useState<Sync[]>([]);
const [folded, setFolded] = useState<boolean>(!fromAddKnowledge);
const fetchUserSyncs = async () => {
try {
const res: Sync[] = await getUserSyncs();
setExistingConnections(
res.filter(
(sync) =>
Object.keys(sync.credentials).length !== 0 &&
sync.provider === provider
)
);
} catch (error) {
console.error(error);
}
};
useEffect(() => {
void fetchUserSyncs();
}, []);
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === "visible" && !document.hidden) {
void fetchUserSyncs();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, []);
useEffect(() => {
if (hasToReload) {
void fetchUserSyncs();
setHasToReload(false);
}
}, [hasToReload]);
const handleOpenedConnections = (userSyncId: number) => {
const existingConnection = openedConnections.find(
(connection) => connection.user_sync_id === userSyncId
);
if (!existingConnection) {
const newConnection: OpenedConnection = {
name:
existingConnections.find((connection) => connection.id === userSyncId)
?.name ?? "",
user_sync_id: userSyncId,
id: undefined,
provider: provider,
submitted: false,
selectedFiles: { files: [] },
last_synced: "",
};
setOpenedConnections([...openedConnections, newConnection]);
}
};
const handleGetSyncFiles = async (userSyncId: number) => {
try {
const res = await getSyncFiles(userSyncId);
setCurrentSyncElements(res);
setCurrentSyncId(userSyncId);
handleOpenedConnections(userSyncId);
} catch (error) {
console.error("Failed to get sync files:", error);
}
};
const connect = async () => {
const res = await callback(
Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15)
);
if (res.authorization_url) {
window.open(res.authorization_url, "_blank");
}
};
return (
<>
<div className={styles.connection_section_wrapper}>
<div className={styles.connection_section_header}>
<div className={styles.left}>
<Image
src={iconUrls[provider]}
alt={label}
width={24}
height={24}
/>
<span className={styles.label}>{label}</span>
</div>
{!fromAddKnowledge ? (
<QuivrButton
iconName={existingConnections.length ? "add" : "sync"}
label={existingConnections.length ? "Add more" : "Connect"}
color="primary"
onClick={() => connect()}
small={true}
/>
) : (
<TextButton
iconName={existingConnections.length ? "add" : "sync"}
label={existingConnections.length ? "Add more" : "Connect"}
color="black"
onClick={() => connect()}
small={true}
/>
)}
</div>
{renderExistingConnections({
existingConnections,
folded,
setFolded,
fromAddKnowledge: !!fromAddKnowledge,
handleGetSyncFiles,
openedConnections,
})}
</div>
</>
);
};

View File

@ -53,11 +53,18 @@ export const PageHeader = ({
/>
))}
{!isMobile && <Notifications />}
<Icon
name="settings"
color="black"
handleHover={true}
size="normal"
onClick={() => void (window.location.href = "/user")}
/>
<Icon
name={lightModeIconName}
color="black"
handleHover={true}
size="small"
size="normal"
onClick={toggleTheme}
/>
</div>

View File

@ -10,8 +10,12 @@
width: 100%;
flex: 1;
.button {
.buttons {
display: flex;
justify-content: flex-end;
justify-content: space-between;
&.standalone {
justify-content: flex-end;
}
}
}

View File

@ -1,9 +1,12 @@
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { KnowledgeToFeed } from "@/app/chat/[chatId]/components/ActionsBar/components";
import { useFromConnectionsContext } from "@/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FromConnectionsProvider/hooks/useFromConnectionContext";
import { OpenedConnection } from "@/lib/api/sync/types";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { useKnowledgeToFeedContext } from "@/lib/context/KnowledgeToFeedProvider/hooks/useKnowledgeToFeedContext";
import { createHandleGetButtonProps } from "@/lib/helpers/handleConnectionButtons";
import styles from "./UploadDocumentModal.module.scss";
import { useAddKnowledge } from "./hooks/useAddKnowledge";
@ -17,10 +20,29 @@ export const UploadDocumentModal = (): JSX.Element => {
const { currentBrain } = useBrainContext();
const { feedBrain } = useAddKnowledge();
const [feeding, setFeeding] = useState<boolean>(false);
const {
currentSyncId,
setCurrentSyncId,
openedConnections,
setOpenedConnections,
} = useFromConnectionsContext();
const [currentConnection, setCurrentConnection] = useState<
OpenedConnection | undefined
>(undefined);
useKnowledgeToFeedContext();
const { t } = useTranslation(["knowledge"]);
const disabled = useMemo(() => {
return (
(knowledgeToFeed.length === 0 &&
openedConnections.filter((connection) => {
return connection.submitted || !!connection.last_synced;
}).length === 0) ||
!currentBrain
);
}, [knowledgeToFeed, openedConnections, currentBrain, currentSyncId]);
const handleFeedBrain = async () => {
setFeeding(true);
await feedBrain();
@ -28,6 +50,23 @@ export const UploadDocumentModal = (): JSX.Element => {
setShouldDisplayFeedCard(false);
};
const getButtonProps = createHandleGetButtonProps(
currentConnection,
openedConnections,
setOpenedConnections,
currentSyncId,
setCurrentSyncId
);
const buttonProps = getButtonProps();
useEffect(() => {
setCurrentConnection(
openedConnections.find(
(connection) => connection.user_sync_id === currentSyncId
)
);
}, [currentSyncId]);
if (!shouldDisplayFeedCard) {
return <></>;
}
@ -43,16 +82,44 @@ export const UploadDocumentModal = (): JSX.Element => {
>
<div className={styles.knowledge_modal}>
<KnowledgeToFeed />
<div className={styles.button}>
<QuivrButton
label="Feed Brain"
color="primary"
iconName="add"
onClick={handleFeedBrain}
disabled={knowledgeToFeed.length === 0 || !currentBrain}
isLoading={feeding}
important={true}
/>
<div
className={`${styles.buttons} ${
!currentSyncId ? styles.standalone : ""
}`}
>
{!!currentSyncId && (
<QuivrButton
label="Back to connections"
color="primary"
iconName="chevronLeft"
onClick={() => {
setCurrentSyncId(undefined);
}}
/>
)}
{currentSyncId ? (
<QuivrButton
label={buttonProps.label}
color={buttonProps.type}
iconName={buttonProps.type === "dangerous" ? "delete" : "add"}
onClick={buttonProps.callback}
important={true}
disabled={buttonProps.disabled}
/>
) : (
<QuivrButton
label="Feed Brain"
color="primary"
iconName="add"
onClick={() => {
setOpenedConnections([]);
void handleFeedBrain();
}}
disabled={disabled}
isLoading={feeding}
important={true}
/>
)}
</div>
</div>
</Modal>

View File

@ -1,6 +1,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useFromConnectionsContext } from "@/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FromConnectionsProvider/hooks/useFromConnectionContext";
import { useChatApi } from "@/lib/api/chat/useChatApi";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { useKnowledgeToFeedContext } from "@/lib/context/KnowledgeToFeedProvider/hooks/useKnowledgeToFeedContext";
@ -25,6 +26,7 @@ export const useFeedBrain = ({
useKnowledgeToFeedContext();
const [hasPendingRequests, setHasPendingRequests] = useState(false);
const { handleFeedBrain } = useFeedBrainHandler();
const { openedConnections } = useFromConnectionsContext();
const { createChat, deleteChat } = useChatApi();
@ -39,7 +41,7 @@ export const useFeedBrain = ({
return;
}
if (knowledgeToFeed.length === 0) {
if (knowledgeToFeed.length === 0 && !openedConnections.length) {
publish({
variant: "danger",
text: t("addFiles"),
@ -55,12 +57,11 @@ export const useFeedBrain = ({
dispatchHasPendingRequests?.();
closeFeedInput?.();
setHasPendingRequests(true);
setShouldDisplayFeedCard(false);
await handleFeedBrain({
brainId,
chatId: currentChatId,
});
setShouldDisplayFeedCard(false);
setKnowledgeToFeed([]);
} catch (e) {
publish({

View File

@ -1,5 +1,7 @@
import { UUID } from "crypto";
import { useFromConnectionsContext } from "@/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FromConnectionsProvider/hooks/useFromConnectionContext";
import { useSync } from "@/lib/api/sync/useSync";
import { useKnowledgeToFeedInput } from "@/lib/components/KnowledgeToFeedInput/hooks/useKnowledgeToFeedInput.ts";
import { useKnowledgeToFeedFilesAndUrls } from "@/lib/hooks/useKnowledgeToFeed";
import { useOnboarding } from "@/lib/hooks/useOnboarding";
@ -13,6 +15,13 @@ export const useFeedBrainHandler = () => {
const { files, urls } = useKnowledgeToFeedFilesAndUrls();
const { crawlWebsiteHandler, uploadFileHandler } = useKnowledgeToFeedInput();
const { updateOnboarding, onboarding } = useOnboarding();
const {
syncFiles,
getActiveSyncsForBrain,
deleteActiveSync,
updateActiveSync,
} = useSync();
const { openedConnections } = useFromConnectionsContext();
const updateOnboardingA = async () => {
if (onboarding.onboarding_a) {
@ -33,6 +42,26 @@ export const useFeedBrainHandler = () => {
crawlWebsiteHandler(url, brainId, chatId)
);
const existingConnections = await getActiveSyncsForBrain(brainId);
await Promise.all(
openedConnections.map(async (openedConnection) => {
const existingConnectionIds = existingConnections.map(
(connection) => connection.id
);
if (
!openedConnection.id ||
!existingConnectionIds.includes(openedConnection.id)
) {
await syncFiles(openedConnection, brainId);
} else if (!openedConnection.selectedFiles.files.length) {
await deleteActiveSync(openedConnection.id);
} else {
await updateActiveSync(openedConnection);
}
})
);
await Promise.all([
...uploadPromises,
...crawlPromises,

View File

@ -12,9 +12,22 @@
height: 16px;
border: 1px solid var(--border-2);
border-radius: Radius.$small;
display: flex;
justify-content: center;
align-items: center;
&.filled {
background-color: var(--primary-0);
}
}
&.disabled {
background-color: var(--background-3);
opacity: 0.4;
cursor: default;
}
&:hover {
background-color: var(--background-3);
}
}

View File

@ -2,16 +2,23 @@ import { useEffect, useState } from "react";
import styles from "./Checkbox.module.scss";
import { Icon } from "../Icon/Icon";
import Tooltip from "../Tooltip/Tooltip";
interface CheckboxProps {
label: string;
label?: string;
checked: boolean;
setChecked: (value: boolean) => void;
disabled?: boolean;
tooltip?: string;
}
export const Checkbox = ({
label,
checked,
setChecked,
disabled,
tooltip,
}: CheckboxProps): JSX.Element => {
const [currentChecked, setCurrentChecked] = useState<boolean>(checked);
@ -19,18 +26,31 @@ export const Checkbox = ({
setCurrentChecked(checked);
}, [checked]);
return (
const checkboxElement = (
<div
className={styles.checkbox_wrapper}
onClick={() => {
setChecked(!currentChecked);
setCurrentChecked(!currentChecked);
className={`${styles.checkbox_wrapper} ${
disabled ? styles.disabled : ""
}`}
onClick={(event) => {
event.stopPropagation();
if (!disabled) {
setChecked(!currentChecked);
setCurrentChecked(!currentChecked);
}
}}
>
<div
className={`${styles.checkbox} ${currentChecked ? styles.filled : ""}`}
></div>
<span>{label}</span>
>
{currentChecked && <Icon name="check" size="tiny" color="white" />}
</div>
{label && <span>{label}</span>}
</div>
);
return tooltip ? (
<Tooltip tooltip={tooltip}>{checkboxElement}</Tooltip>
) : (
checkboxElement
);
};

View File

@ -0,0 +1,18 @@
@use "@/styles/Radius.module.scss";
@use "@/styles/Spacings.module.scss";
@use "@/styles/Typography.module.scss";
.connection_icon {
border-radius: Radius.$circle;
padding: Spacings.$spacing01;
color: var(--white-0);
min-width: 24px;
min-height: 24px;
max-height: 24px;
max-width: 24px;
font-size: Typography.$tiny;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid var(--background-0);
}

View File

@ -0,0 +1,22 @@
import styles from "./ConnectionIcon.module.scss";
interface ConnectionIconProps {
letter: string;
index: number;
}
export const ConnectionIcon = ({
letter,
index,
}: ConnectionIconProps): JSX.Element => {
const colors = ["#FBBC04", "#F28B82", "#8AB4F8", "#81C995", "#C58AF9"];
return (
<div
className={styles.connection_icon}
style={{ backgroundColor: colors[index % 5] }}
>
{letter.toUpperCase()}
</div>
);
};

View File

@ -1,5 +1,12 @@
@use "@/styles/IconSizes.module.scss";
.tiny {
min-width: IconSizes.$tiny;
min-height: IconSizes.$tiny;
max-width: IconSizes.$tiny;
max-height: IconSizes.$tiny;
}
.small {
min-width: IconSizes.$small;
min-height: IconSizes.$small;

View File

@ -23,7 +23,6 @@
cursor: auto;
box-shadow: BoxShadow.$medium;
max-width: 90vw;
overflow: scroll;
width: 35vw;
height: 80vh;
@ -32,8 +31,8 @@
}
&.big {
width: 40vw;
height: 90vh;
width: 50vw;
height: 95vh;
}
&.white {

View File

@ -14,11 +14,18 @@
display: flex;
width: fit-content;
background-color: var(--background-0);
height: fit-content;
&.hidden {
display: none;
}
&.small {
padding-inline: Spacings.$spacing03;
padding-block: Spacings.$spacing01;
font-size: Typography.$tiny;
}
&.disabled {
border-color: var(--border-2);
pointer-events: none;

View File

@ -17,6 +17,7 @@ export const QuivrButton = ({
disabled,
hidden,
important,
small,
}: ButtonType): JSX.Element => {
const [hovered, setHovered] = useState<boolean>(false);
const { isDarkMode } = useUserSettingsContext();
@ -36,12 +37,33 @@ export const QuivrButton = ({
};
const getIconColor = () => {
let iconColor = color;
if (hovered || (important && !disabled)) {
return "white";
iconColor = "white";
} else if (disabled) {
return "grey";
iconColor = "grey";
}
return iconColor;
};
const renderIcon = () => {
if (!isLoading) {
return (
<Icon
name={iconName}
size={small ? "small" : "normal"}
color={getIconColor()}
handleHover={false}
/>
);
} else {
return color;
return (
<LoaderIcon
color={hovered || important ? "white" : disabled ? "grey" : color}
size={small ? "small" : "normal"}
/>
);
}
};
@ -54,25 +76,14 @@ export const QuivrButton = ({
${hidden ? styles.hidden : ""}
${important ? styles.important : ""}
${disabled ? styles.disabled : ""}
${small ? styles.small : ""}
`}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className={styles.icon_label}>
{!isLoading ? (
<Icon
name={iconName}
size="normal"
color={getIconColor()}
handleHover={false}
/>
) : (
<LoaderIcon
color={hovered || important ? "white" : disabled ? "grey" : color}
size="small"
/>
)}
{renderIcon()}
<span className={styles.label}>{label}</span>
</div>
</div>

View File

@ -0,0 +1,38 @@
@use "@/styles/Radius.module.scss";
@use "@/styles/Spacings.module.scss";
@use "@/styles/Typography.module.scss";
.switch_wrapper {
display: flex;
gap: Spacings.$spacing03;
align-items: center;
font-size: Typography.$small;
font-weight: 500;
.slider {
border-radius: Radius.$big;
height: 18px;
width: 42px;
background-color: var(--primary-2);
cursor: pointer;
&.checked {
background-color: var(--primary-1);
.slider_bubble {
margin-left: 26px;
background-color: var(--primary-0);
}
}
.slider_bubble {
margin-left: Spacings.$spacing01;
margin-top: Spacings.$spacing01;
height: 14px;
width: 14px;
border-radius: Radius.$circle;
background-color: var(--primary-1);
transition: margin-left 0.2s ease-in-out;
}
}
}

View File

@ -0,0 +1,31 @@
import styles from "./SwitchButton.module.scss";
interface SwitchButtonProps {
label: string;
checked: boolean;
setChecked: (checked: boolean) => void;
}
export const SwitchButton = ({
label,
checked,
setChecked,
}: SwitchButtonProps): JSX.Element => {
const handleToggle = () => {
setChecked(!checked);
};
return (
<div className={styles.switch_wrapper}>
<span>{label}</span>
<div
className={`${styles.slider} ${checked ? styles.checked : ""}`}
onClick={handleToggle}
>
<div className={styles.slider_bubble}></div>
</div>
</div>
);
};
export default SwitchButton;

View File

@ -1,6 +1,7 @@
@use "@/styles/Radius.module.scss";
@use "@/styles/ScreenSizes.module.scss";
@use "@/styles/Spacings.module.scss";
@use "@/styles/Typography.module.scss";
.tabs_container {
display: flex;
@ -37,5 +38,23 @@
display: none;
}
}
.label_wrapper {
display: flex;
position: relative;
gap: Spacings.$spacing02;
.label_badge {
border-radius: Radius.$circle;
width: 16px;
height: 16px;
color: var(--white-0);
display: flex;
justify-content: center;
align-items: center;
background-color: var(--primary-0);
font-size: Typography.$very-tiny;
}
}
}
}

View File

@ -38,7 +38,12 @@ export const Tabs = ({ tabList }: TabsProps): JSX.Element => {
: "black"
}
/>
<span className={styles.label}>{tab.label}</span>
<div className={styles.label_wrapper}>
<span className={styles.label}>{tab.label}</span>
{!!tab.badge && tab.badge > 0 && (
<div className={styles.label_badge}>{tab.badge}</div>
)}
</div>
</div>
))}
</div>

View File

@ -24,7 +24,7 @@ export const TextAreaInput = ({
<textarea
className={styles.text_area_input}
value={inputValue}
rows={5}
rows={4}
onChange={(e) => setInputValue(e.target.value)}
placeholder={label}
onKeyDown={(e) => {

View File

@ -1,4 +1,5 @@
@use "@/styles/Spacings.module.scss";
@use "@/styles/Typography.module.scss";
.text_button_wrapper {
display: flex;
@ -12,6 +13,11 @@
opacity: 0.5;
}
&.small {
gap: Spacings.$spacing02;
font-size: Typography.$tiny;
}
.black {
color: var(--text-3);

View File

@ -1,3 +1,5 @@
import { useState } from "react";
import { iconList } from "@/lib/helpers/iconList";
import { Color } from "@/lib/types/Colors";
@ -9,20 +11,29 @@ interface TextButtonProps {
iconName?: keyof typeof iconList;
label: string;
color: Color;
onClick?: () => void;
onClick?: () => void | Promise<void>;
disabled?: boolean;
small?: boolean;
}
export const TextButton = (props: TextButtonProps): JSX.Element => {
const [hovered, setHovered] = useState<boolean>(false);
return (
<div
className={`${styles.text_button_wrapper} ${
props.disabled ? styles.disabled : ""
}`}
} ${props.small ? styles.small : ""}`}
onClick={props.onClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{!!props.iconName && (
<Icon name={props.iconName} size="normal" color={props.color} />
<Icon
name={props.iconName}
size={props.small ? "small" : "normal"}
color={hovered ? "primary" : props.color}
/>
)}
<span className={styles[props.color]}>{props.label}</span>
</div>

View File

@ -24,9 +24,18 @@ export const useKnowledgeToFeedContext = () => {
});
};
const removeAllKnowledgeToFeed = () => {
context?.setKnowledgeToFeed([]);
};
if (context === undefined) {
throw new Error("useKnowledge must be used inside KnowledgeToFeedProvider");
}
return { ...context, addKnowledgeToFeed, removeKnowledgeToFeed };
return {
...context,
addKnowledgeToFeed,
removeKnowledgeToFeed,
removeAllKnowledgeToFeed,
};
};

View File

@ -0,0 +1,182 @@
import { SetStateAction } from "react";
import { OpenedConnection } from "../api/sync/types";
const isRemoveAll = (
matchingOpenedConnection: OpenedConnection,
currentConnection: OpenedConnection | undefined
): boolean => {
return !!(
currentConnection?.submitted &&
matchingOpenedConnection.selectedFiles.files.length === 0 &&
!currentConnection.cleaned
);
};
const arraysAreEqual = (arr1: string[], arr2: string[]): boolean => {
if (arr1.length !== arr2.length) {
return false;
}
for (let i = 0; i < arr1.length; i++) {
if (arr1[i] !== arr2[i]) {
return false;
}
}
return true;
};
export const handleGetButtonProps = (
currentConnection: OpenedConnection | undefined,
openedConnections: OpenedConnection[],
setOpenedConnections: React.Dispatch<
React.SetStateAction<OpenedConnection[]>
>,
currentSyncId: number | undefined,
setCurrentSyncId: React.Dispatch<React.SetStateAction<number | undefined>>
): {
label: string;
type: "dangerous" | "primary";
disabled: boolean;
callback: () => void;
} => {
const matchingOpenedConnection =
currentConnection &&
openedConnections.find(
(conn) => conn.user_sync_id === currentConnection.user_sync_id
);
if (matchingOpenedConnection) {
if (isRemoveAll(matchingOpenedConnection, currentConnection)) {
return {
label: "Remove All",
type: "dangerous",
disabled: false,
callback: () =>
removeConnection(
setOpenedConnections,
currentSyncId,
setCurrentSyncId
),
};
} else if (currentConnection.submitted) {
const matchingSelectedFileIds =
matchingOpenedConnection.selectedFiles.files
.map((file) => file.id)
.sort();
const currentSelectedFileIds = currentConnection.selectedFiles.files
.map((file) => file.id)
.sort();
const isDisabled = arraysAreEqual(
matchingSelectedFileIds,
currentSelectedFileIds
);
return {
label: "Update added files",
type: "primary",
disabled:
!matchingOpenedConnection.selectedFiles.files.length || isDisabled,
callback: () =>
addConnection(setOpenedConnections, currentSyncId, setCurrentSyncId),
};
}
}
return {
label: "Add specific files",
type: "primary",
disabled: !matchingOpenedConnection?.selectedFiles.files.length,
callback: () =>
addConnection(setOpenedConnections, currentSyncId, setCurrentSyncId),
};
};
const addConnection = (
setOpenedConnections: React.Dispatch<
React.SetStateAction<OpenedConnection[]>
>,
currentSyncId: number | undefined,
setCurrentSyncId: React.Dispatch<React.SetStateAction<number | undefined>>
): void => {
setOpenedConnections((prevConnections) => {
const connectionIndex = prevConnections.findIndex(
(connection) => connection.user_sync_id === currentSyncId
);
if (connectionIndex !== -1) {
const newConnections = [...prevConnections];
newConnections[connectionIndex] = {
...newConnections[connectionIndex],
submitted: true,
cleaned: false,
};
return newConnections;
}
return prevConnections;
});
setCurrentSyncId(undefined);
};
const removeConnection = (
setOpenedConnections: React.Dispatch<
React.SetStateAction<OpenedConnection[]>
>,
currentSyncId: number | undefined,
setCurrentSyncId: React.Dispatch<React.SetStateAction<number | undefined>>
): void => {
setOpenedConnections((prevConnections) =>
prevConnections
.filter((connection) => {
return (
connection.user_sync_id === currentSyncId || !!connection.last_synced
);
})
.map((connection) => {
if (
connection.user_sync_id === currentSyncId &&
!!connection.last_synced
) {
return { ...connection, cleaned: true };
} else {
return connection;
}
})
);
setCurrentSyncId(undefined);
};
export const createHandleGetButtonProps =
(
currentConnection: OpenedConnection | undefined,
openedConnections: OpenedConnection[],
setOpenedConnections: {
(value: SetStateAction<OpenedConnection[]>): void;
(value: SetStateAction<OpenedConnection[]>): void;
},
currentSyncId: number | undefined,
setCurrentSyncId: {
(value: SetStateAction<number | undefined>): void;
(value: SetStateAction<number | undefined>): void;
}
) =>
(): {
label: string;
type: "primary" | "dangerous";
disabled: boolean;
callback: () => void;
} =>
handleGetButtonProps(
currentConnection,
openedConnections,
setOpenedConnections,
currentSyncId,
setCurrentSyncId
);

View File

@ -13,10 +13,10 @@ import {
FaCheckCircle,
FaDiscord,
FaFileAlt,
FaFolder,
FaGithub,
FaKey,
FaLinkedin,
FaMoon,
FaQuestionCircle,
FaRegFileAlt,
FaRegKeyboard,
@ -24,7 +24,6 @@ import {
FaRegThumbsDown,
FaRegThumbsUp,
FaRegUserCircle,
FaSun,
FaTwitter,
FaUnlock,
} from "react-icons/fa";
@ -38,13 +37,14 @@ import {
IoIosRadio,
IoMdClose,
IoMdLogOut,
IoMdSettings,
IoMdSync,
} from "react-icons/io";
import {
IoArrowUpCircleOutline,
IoCloudDownloadOutline,
IoFootsteps,
IoHomeOutline,
IoSettingsSharp,
IoShareSocial,
IoWarningOutline,
} from "react-icons/io5";
@ -63,6 +63,7 @@ import {
} from "react-icons/lu";
import {
MdAlternateEmail,
MdDarkMode,
MdDashboardCustomize,
MdDeleteOutline,
MdDynamicFeed,
@ -70,6 +71,7 @@ import {
MdLink,
MdMarkEmailRead,
MdMarkEmailUnread,
MdOutlineLightMode,
MdOutlineModeEditOutline,
MdUnfoldLess,
MdUnfoldMore,
@ -110,6 +112,7 @@ export const iconList: { [name: string]: IconType } = {
fileSelected: FaFileAlt,
flag: CiFlag1,
fold: MdUnfoldLess,
folder: FaFolder,
followUp: IoArrowUpCircleOutline,
github: FaGithub,
goal: LuGoal,
@ -124,7 +127,7 @@ export const iconList: { [name: string]: IconType } = {
linkedin: FaLinkedin,
loader: AiOutlineLoading3Quarters,
logout: IoMdLogOut,
moon: FaMoon,
moon: MdDarkMode,
notifications: IoIosNotifications,
office: HiBuildingOffice,
options: SlOptions,
@ -136,12 +139,13 @@ export const iconList: { [name: string]: IconType } = {
read: MdMarkEmailRead,
robot: LiaRobotSolid,
search: LuSearch,
settings: IoSettingsSharp,
settings: IoMdSettings,
share: IoShareSocial,
software: CgSoftwareDownload,
star: FaRegStar,
step: IoFootsteps,
sun: FaSun,
sun: MdOutlineLightMode,
sync: IoMdSync,
thumbsDown: FaRegThumbsDown,
thumbsUp: FaRegThumbsUp,
twitter: FaTwitter,

View File

@ -1 +1 @@
export type IconSize = "small" | "normal" | "large" | "big";
export type IconSize = "tiny" | "small" | "normal" | "large" | "big";

View File

@ -11,4 +11,5 @@ export interface ButtonType {
disabled?: boolean;
hidden?: boolean;
important?: boolean;
small?: boolean;
}

View File

@ -6,4 +6,5 @@ export interface Tab {
disabled?: boolean;
iconName: keyof typeof iconList;
onClick: () => void;
badge?: number;
}

View File

@ -1,3 +1,4 @@
$tiny: 10px;
$small: 14px;
$normal: 18px;
$large: 24px;

View File

@ -10,12 +10,12 @@
@mixin H2 {
font-weight: 600;
font-size: 18px;
font-size: $large;
}
@mixin H3 {
font-weight: 500;
font-size: 16px;
font-size: $medium;
}
@mixin EllipsisOverflow {