diff --git a/backend/modules/sync/controller/azure_sync_routes.py b/backend/modules/sync/controller/azure_sync_routes.py index e53205e4465..83bdd3ad68d 100644 --- a/backend/modules/sync/controller/azure_sync_routes.py +++ b/backend/modules/sync/controller/azure_sync_routes.py @@ -8,6 +8,9 @@ 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 @@ ] -@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) diff --git a/backend/modules/sync/controller/google_sync_routes.py b/backend/modules/sync/controller/google_sync_routes.py index 86631b7c492..c20b29548d4 100644 --- a/backend/modules/sync/controller/google_sync_routes.py +++ b/backend/modules/sync/controller/google_sync_routes.py @@ -9,6 +9,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 .successfull_connection import successfullConnectionPage +from fastapi.responses import HTMLResponse + # Set environment variable for OAuthlib os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" @@ -47,7 +50,7 @@ } -@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) diff --git a/backend/modules/sync/controller/successfull_connection.py b/backend/modules/sync/controller/successfull_connection.py new file mode 100644 index 00000000000..ffdb877e808 --- /dev/null +++ b/backend/modules/sync/controller/successfull_connection.py @@ -0,0 +1,53 @@ +successfullConnectionPage = """ + + + + + + + +
+ +
Connection successful
+ +
+ + +""" \ No newline at end of file diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index 7afaa30ce70..9ed3328524e 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -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", diff --git a/frontend/app/App.tsx b/frontend/app/App.tsx index c9e6db387b2..9a1ef92d25d 100644 --- a/frontend/app/App.tsx +++ b/frontend/app/App.tsx @@ -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 => { - - - {children} - - + + + + {children} + + + diff --git a/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/KnowledgeToFeed.module.scss b/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/KnowledgeToFeed.module.scss index 0beb0cfad59..c668c2ee223 100644 --- a/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/KnowledgeToFeed.module.scss +++ b/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/KnowledgeToFeed.module.scss @@ -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%; - overflow: scroll; - flex-direction: column; - gap: Spacings.$spacing02; - flex-grow: 1; + height: 80%; 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; } } diff --git a/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/KnowledgeToFeed.tsx b/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/KnowledgeToFeed.tsx index a874fc10ae7..63da54ecf8e 100644 --- a/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/KnowledgeToFeed.tsx +++ b/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/KnowledgeToFeed.tsx @@ -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 (
{!hideBrainSelector && ( @@ -68,39 +123,9 @@ export const KnowledgeToFeed = ({ )}
- {selectedTab === "From documents" && } - {selectedTab === "From websites" && } -
-
-
- Knowledges to upload - {knowledgeToFeed.length} -
-
- {knowledgeToFeed.map((knowledge, index) => ( -
-
- - - {knowledge.source === "crawl" - ? knowledge.url - : knowledge.file.name} - -
- removeKnowledgeToFeed(index)} - /> -
- ))} -
+ {selectedTab === "Documents" && } + {selectedTab === "Websites" && } + {selectedTab === "Connections" && }
); diff --git a/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FileLine/FileLine.module.scss b/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FileLine/FileLine.module.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FileLine/FileLine.tsx b/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FileLine/FileLine.tsx new file mode 100644 index 00000000000..069cf9df127 --- /dev/null +++ b/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FileLine/FileLine.tsx @@ -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 ( + + ); +}; diff --git a/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FolderLine/FolderLine.module.scss b/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FolderLine/FolderLine.module.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FolderLine/FolderLine.tsx b/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FolderLine/FolderLine.tsx new file mode 100644 index 00000000000..7ff77a086e1 --- /dev/null +++ b/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FolderLine/FolderLine.tsx @@ -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 ( + + ); +}; diff --git a/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FromConnections.module.scss b/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FromConnections.module.scss new file mode 100644 index 00000000000..666fd4b9f99 --- /dev/null +++ b/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FromConnections.module.scss @@ -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; + } + } + } +} diff --git a/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FromConnections.tsx b/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FromConnections.tsx new file mode 100644 index 00000000000..d05549acacf --- /dev/null +++ b/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FromConnections.tsx @@ -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([]); + const [currentFolders, setCurrentFolders] = useState([]); + 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 ( +
+ {!currentSyncId ? ( + + ) : ( +
+
+ { + void handleBackClick(); + }} + small={true} + disabled={!folderStack.length} + /> +
+
+ {currentFolders.map((folder) => ( +
{ + void handleFolderClick(currentSyncId, folder.id); + }} + > + +
+ ))} + {currentFiles.map((file) => ( +
+ +
+ ))} + {!currentFiles.length && !currentFolders.length && ( + Empty folder + )} +
+
+ )} +
+ ); +}; diff --git a/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FromConnectionsProvider/FromConnection-provider.tsx b/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FromConnectionsProvider/FromConnection-provider.tsx new file mode 100644 index 00000000000..dc0c33eef5a --- /dev/null +++ b/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FromConnectionsProvider/FromConnection-provider.tsx @@ -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 + >; + currentSyncId: number | undefined; + setCurrentSyncId: React.Dispatch>; + openedConnections: OpenedConnection[]; + setOpenedConnections: React.Dispatch< + React.SetStateAction + >; + hasToReload: boolean; + setHasToReload: React.Dispatch>; +}; + +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( + undefined + ); + const [openedConnections, setOpenedConnections] = useState< + OpenedConnection[] + >([]); + const [hasToReload, setHasToReload] = useState(false); + + return ( + + {children} + + ); +}; diff --git a/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FromConnectionsProvider/hooks/useFromConnectionContext.tsx b/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FromConnectionsProvider/hooks/useFromConnectionContext.tsx new file mode 100644 index 00000000000..034d2b01c4a --- /dev/null +++ b/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/FromConnectionsProvider/hooks/useFromConnectionContext.tsx @@ -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; +}; diff --git a/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/SyncElementLine/SyncElementLine.module.scss b/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/SyncElementLine/SyncElementLine.module.scss new file mode 100644 index 00000000000..d296ab8bff5 --- /dev/null +++ b/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/SyncElementLine/SyncElementLine.module.scss @@ -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); + } + } +} diff --git a/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/SyncElementLine/SyncElementLine.tsx b/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/SyncElementLine/SyncElementLine.tsx new file mode 100644 index 00000000000..5a0823a2b30 --- /dev/null +++ b/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromConnections/SyncElementLine/SyncElementLine.tsx @@ -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(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 ( +
{ + if (isFolder && checked) { + event.stopPropagation(); + } + }} + > +
setIsCheckboxHovered(true)} + onMouseLeave={() => setIsCheckboxHovered(false)} + style={{ pointerEvents: "auto" }} + > + +
+ + + {name} +
+ ); +}; diff --git a/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromDocuments/FromDocuments.module.scss b/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromDocuments/FromDocuments.module.scss index de462b71ef0..0d4c3ed9770 100644 --- a/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromDocuments/FromDocuments.module.scss +++ b/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromDocuments/FromDocuments.module.scss @@ -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; + + @media (max-width: ScreenSizes.$small) { + flex-direction: column; + } - .clickable { - font-weight: bold; + .clickable { + font-weight: bold; + } } } } diff --git a/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromDocuments/FromDocuments.tsx b/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromDocuments/FromDocuments.tsx index dbb8f186834..4bbe2506126 100644 --- a/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromDocuments/FromDocuments.tsx +++ b/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/components/FromDocuments/FromDocuments.tsx @@ -27,13 +27,15 @@ export const FromDocuments = (): JSX.Element => { onMouseLeave={() => setDragging(false)} onClick={open} > - -
-
- Choose files - +
+ +
+
+ Choose files + +
+ or drag it here
- or drag it here
); diff --git a/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/hooks/useFeedBrainInChat.ts b/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/hooks/useFeedBrainInChat.ts index 75f6bd4e50d..9c2359ebb28 100644 --- a/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/hooks/useFeedBrainInChat.ts +++ b/frontend/app/chat/[chatId]/components/ActionsBar/components/KnowledgeToFeed/hooks/useFeedBrainInChat.ts @@ -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"), diff --git a/frontend/app/globals.css b/frontend/app/globals.css index cec6f192d6b..4ff546cd3e5 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -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); diff --git a/frontend/lib/components/AddBrainModal/components/BrainTypeSelectionStep/BrainCatalogue/BrainCatalogue.module.scss b/frontend/app/user/components/Connections/Connections.module.scss similarity index 62% rename from frontend/lib/components/AddBrainModal/components/BrainTypeSelectionStep/BrainCatalogue/BrainCatalogue.module.scss rename to frontend/app/user/components/Connections/Connections.module.scss index 9f1def9781c..6aa1f0a3c92 100644 --- a/frontend/lib/components/AddBrainModal/components/BrainTypeSelectionStep/BrainCatalogue/BrainCatalogue.module.scss +++ b/frontend/app/user/components/Connections/Connections.module.scss @@ -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; - } } diff --git a/frontend/app/user/components/Connections/Connections.tsx b/frontend/app/user/components/Connections/Connections.tsx new file mode 100644 index 00000000000..59fa09aaf4b --- /dev/null +++ b/frontend/app/user/components/Connections/Connections.tsx @@ -0,0 +1,12 @@ +import { ConnectionCards } from "@/lib/components/ConnectionCards/ConnectionCards"; + +import styles from "./Connections.module.scss"; + +export const Connections = (): JSX.Element => { + return ( +
+ Link apps you want to search across + +
+ ); +}; diff --git a/frontend/app/user/components/Settings/Settings.module.scss b/frontend/app/user/components/Settings/Settings.module.scss index 3758a445173..c42743abd1a 100644 --- a/frontend/app/user/components/Settings/Settings.module.scss +++ b/frontend/app/user/components/Settings/Settings.module.scss @@ -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; + } } diff --git a/frontend/app/user/components/Settings/Settings.tsx b/frontend/app/user/components/Settings/Settings.tsx index db47c58bc3e..4d08fa0ea93 100644 --- a/frontend/app/user/components/Settings/Settings.tsx +++ b/frontend/app/user/components/Settings/Settings.tsx @@ -13,6 +13,9 @@ type InfoDisplayerProps = { export const Settings = ({ email }: InfoDisplayerProps): JSX.Element => { return (
+ + General settings and main information + {email} diff --git a/frontend/app/user/page.module.scss b/frontend/app/user/page.module.scss index 84203a00f7b..df757345ede 100644 --- a/frontend/app/user/page.module.scss +++ b/frontend/app/user/page.module.scss @@ -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 { diff --git a/frontend/app/user/page.tsx b/frontend/app/user/page.tsx index ee2a802d0fc..de79cc3053c 100644 --- a/frontend/app/user/page.tsx +++ b/frontend/app/user/page.tsx @@ -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 => {
+ +
- + {selectedTab === "General" && } + {selectedTab === "Connections" && }
({ - 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"); - }); -}); diff --git a/frontend/lib/api/chat/__tests__/useChatApi.http.test.ts b/frontend/lib/api/chat/__tests__/useChatApi.http.test.ts deleted file mode 100644 index b51d49aa4ba..00000000000 --- a/frontend/lib/api/chat/__tests__/useChatApi.http.test.ts +++ /dev/null @@ -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 }); - }); -}); diff --git a/frontend/lib/api/chat/__tests__/useChatApi.test.ts b/frontend/lib/api/chat/__tests__/useChatApi.test.ts deleted file mode 100644 index f91ad16db8f..00000000000 --- a/frontend/lib/api/chat/__tests__/useChatApi.test.ts +++ /dev/null @@ -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 } - ); - }); -}); diff --git a/frontend/lib/api/notification/__tests__/useNotificationApi.test.ts b/frontend/lib/api/notification/__tests__/useNotificationApi.test.ts deleted file mode 100644 index 9d99bba08a8..00000000000 --- a/frontend/lib/api/notification/__tests__/useNotificationApi.test.ts +++ /dev/null @@ -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}`); - }); -}); diff --git a/frontend/lib/api/sync/sync.ts b/frontend/lib/api/sync/sync.ts new file mode 100644 index 00000000000..beb33a4fecb --- /dev/null +++ b/frontend/lib/api/sync/sync.ts @@ -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 => { + return (await axiosInstance.get("/sync")).data; +}; + +export const getSyncFiles = async ( + axiosInstance: AxiosInstance, + userSyncId: number, + folderId?: string +): Promise => { + const url = folderId + ? `/sync/${userSyncId}/files?user_sync_id=${userSyncId}&folder_id=${folderId}` + : `/sync/${userSyncId}/files?user_sync_id=${userSyncId}`; + + return (await axiosInstance.get(url)).data; +}; + +export const syncFiles = async ( + axiosInstance: AxiosInstance, + openedConnection: OpenedConnection, + brainId: UUID +): Promise => { + return ( + await axiosInstance.post(`/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 => { + return ( + await axiosInstance.put(`/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 => { + await axiosInstance.delete(`/sync/active/${syncId}`); +}; + +export const getActiveSyncs = async ( + axiosInstance: AxiosInstance +): Promise => { + return (await axiosInstance.get(`/sync/active`)).data; +}; + +export const deleteUserSync = async ( + axiosInstance: AxiosInstance, + syncId: number +): Promise => { + return (await axiosInstance.delete(`/sync/${syncId}`)).data; +}; diff --git a/frontend/lib/api/sync/types.ts b/frontend/lib/api/sync/types.ts new file mode 100644 index 00000000000..629ef72abfd --- /dev/null +++ b/frontend/lib/api/sync/types.ts @@ -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; +} diff --git a/frontend/lib/api/sync/useSync.ts b/frontend/lib/api/sync/useSync.ts new file mode 100644 index 00000000000..a1ead5683f5 --- /dev/null +++ b/frontend/lib/api/sync/useSync.ts @@ -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 = { + 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), + }; +}; diff --git a/frontend/lib/components/AddBrainModal/AddBrainModal.module.scss b/frontend/lib/components/AddBrainModal/AddBrainModal.module.scss index 4fabc95fd1a..be328e74430 100644 --- a/frontend/lib/components/AddBrainModal/AddBrainModal.module.scss +++ b/frontend/lib/components/AddBrainModal/AddBrainModal.module.scss @@ -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; } } diff --git a/frontend/lib/components/AddBrainModal/AddBrainModal.tsx b/frontend/lib/components/AddBrainModal/AddBrainModal.tsx index 8a08a25e9a5..a9886412a64 100644 --- a/frontend/lib/components/AddBrainModal/AddBrainModal.tsx +++ b/frontend/lib/components/AddBrainModal/AddBrainModal.tsx @@ -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 => {
- - + +
diff --git a/frontend/lib/components/AddBrainModal/components/BrainMainInfosStep/BrainMainInfosStep.module.scss b/frontend/lib/components/AddBrainModal/components/BrainMainInfosStep/BrainMainInfosStep.module.scss index 6e5a18bb848..621ea7102a3 100644 --- a/frontend/lib/components/AddBrainModal/components/BrainMainInfosStep/BrainMainInfosStep.module.scss +++ b/frontend/lib/components/AddBrainModal/components/BrainMainInfosStep/BrainMainInfosStep.module.scss @@ -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; } } diff --git a/frontend/lib/components/AddBrainModal/components/BrainMainInfosStep/BrainMainInfosStep.tsx b/frontend/lib/components/AddBrainModal/components/BrainMainInfosStep/BrainMainInfosStep.tsx index d78edf49fdc..e47eb6c7041 100644 --- a/frontend/lib/components/AddBrainModal/components/BrainMainInfosStep/BrainMainInfosStep.tsx +++ b/frontend/lib/components/AddBrainModal/components/BrainMainInfosStep/BrainMainInfosStep.tsx @@ -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(); 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 => {
Define brain identity -
+
{
- previous()} - iconName="chevronLeft" - /> { + return ( +
+ {number.toString()} + + {label} + {number > 1 ? "s" : ""} + +
+ ); +}; diff --git a/frontend/lib/components/AddBrainModal/components/BrainRecapStep/BrainRecapStep.module.scss b/frontend/lib/components/AddBrainModal/components/BrainRecapStep/BrainRecapStep.module.scss new file mode 100644 index 00000000000..c2fa8825941 --- /dev/null +++ b/frontend/lib/components/AddBrainModal/components/BrainRecapStep/BrainRecapStep.module.scss @@ -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; + } +} diff --git a/frontend/lib/components/AddBrainModal/components/BrainRecapStep/BrainRecapStep.tsx b/frontend/lib/components/AddBrainModal/components/BrainRecapStep/BrainRecapStep.tsx new file mode 100644 index 00000000000..0f0a9eb6667 --- /dev/null +++ b/frontend/lib/components/AddBrainModal/components/BrainRecapStep/BrainRecapStep.tsx @@ -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 => { + if (!userIdentityData?.onboarded) { + await updateUserIdentity({ + ...userIdentityData, + username: userIdentityData?.username ?? "", + onboarded: true, + }); + } + setCreating(true); + createBrain(); + }; + + const previous = (): void => { + goToPreviousStep(); + }; + + if (currentStepIndex !== 2) { + return <>; + } + + return ( +
+
+ Brain Recap + + + Depending on the number of knowledge, the upload can take + few minutes. + + +
+
+ ( + + )} + /> +
+
+ ( + + )} + /> +
+
+ Knowledge From +
+ + knowledge.source === "crawl" + ).length + } + /> + knowledge.source === "upload" + ).length + } + /> +
+
+
+ + { + await feed(); + setOpenedConnections([]); + }} + disabled={ + knowledgeToFeed.length === 0 && !userIdentityData?.onboarded + } + isLoading={creating} + important={true} + /> +
+
+ ); +}; diff --git a/frontend/lib/components/AddBrainModal/components/BrainTypeSelectionStep/BrainCatalogue/BrainCatalogue.tsx b/frontend/lib/components/AddBrainModal/components/BrainTypeSelectionStep/BrainCatalogue/BrainCatalogue.tsx deleted file mode 100644 index ad9f13e6784..00000000000 --- a/frontend/lib/components/AddBrainModal/components/BrainTypeSelectionStep/BrainCatalogue/BrainCatalogue.tsx +++ /dev/null @@ -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 ( -
- - - A Brain is a specialized AI tool designed to interact with specific - use cases or data sources. - - - {!userIdentityData?.onboarded && ( - - - Let's start by creating a Docs & URLs brain.

Of - course, feel free to explore other types of brains during your Quivr - journey. -
-
- )} - Choose a brain type -
- {brains.map((brain) => { - return ( - { - next(); - setCurrentSelectedBrain(brain); - }} - cardKey={brain.id} - disabled={!userIdentityData?.onboarded && !brain.onboarding_brain} - /> - ); - })} -
-
- ); -}; diff --git a/frontend/lib/components/AddBrainModal/components/BrainTypeSelectionStep/BrainTypeSelectionStep.module.scss b/frontend/lib/components/AddBrainModal/components/BrainTypeSelectionStep/BrainTypeSelectionStep.module.scss deleted file mode 100644 index 0f0f20629a4..00000000000 --- a/frontend/lib/components/AddBrainModal/components/BrainTypeSelectionStep/BrainTypeSelectionStep.module.scss +++ /dev/null @@ -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; - } - } -} diff --git a/frontend/lib/components/AddBrainModal/components/BrainTypeSelectionStep/BrainTypeSelectionStep.tsx b/frontend/lib/components/AddBrainModal/components/BrainTypeSelectionStep/BrainTypeSelectionStep.tsx deleted file mode 100644 index 184149376c8..00000000000 --- a/frontend/lib/components/AddBrainModal/components/BrainTypeSelectionStep/BrainTypeSelectionStep.tsx +++ /dev/null @@ -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([]); - const { goToNextStep, currentStepIndex } = useBrainCreationSteps(); - const { getIntegrationBrains } = useBrainApi(); - const { setValue } = useFormContext(); - - useEffect(() => { - getIntegrationBrains() - .then((response) => { - setBrains(response); - }) - .catch((error) => { - console.error(error); - }); - - setValue("name", ""); - setValue("description", ""); - }, []); - - if (currentStepIndex !== 0) { - return <>; - } - - return ( -
-
- -
-
- ); -}; diff --git a/frontend/lib/components/AddBrainModal/components/CreateBrainStep/CreateBrainStep.tsx b/frontend/lib/components/AddBrainModal/components/CreateBrainStep/CreateBrainStep.tsx deleted file mode 100644 index de0e5aaf09a..00000000000 --- a/frontend/lib/components/AddBrainModal/components/CreateBrainStep/CreateBrainStep.tsx +++ /dev/null @@ -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(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 => { - if (!userIdentityData?.onboarded) { - await updateUserIdentity({ - ...userIdentityData, - username: userIdentityData?.username ?? "", - onboarded: true, - }); - } - setCreating(true); - createBrain(); - }; - - const renderSettings = () => { - return ( - <> - - {currentSelectedBrain?.information} - - {fields.map(({ name, value }) => ( - handleInputChange(name, inputValue)} - label={capitalCase(name)} - /> - ))} - - ); - }; - - const renderFeedBrain = () => { - return ( - <> - {!userIdentityData?.onboarded && ( - - - Upload documents or add URLs to add knowledges to your brain. - - - )} -
- Feed your brain - -
- - ); - }; - - const renderCreateButton = () => { - return ( - -
- Click on - - to finish your brain creation. -
-
- ); - }; - - const renderButtons = () => { - return ( -
- - {(!currentSelectedBrain?.max_files && !createBrainStepIndex) || - createBrainStepIndex ? ( - - ) : ( - setCreateBrainStepIndex(1)} - isLoading={creating} - important={true} - /> - )} -
- ); - }; - - if (currentStepIndex !== 2) { - return <>; - } - - return ( -
- {!createBrainStepIndex && renderSettings()} - {!!currentSelectedBrain?.max_files && - !!createBrainStepIndex && - renderFeedBrain()} - {!currentSelectedBrain?.max_files && - !currentSelectedBrain?.connection_settings && - renderCreateButton()} - - {renderButtons()} -
- ); -}; diff --git a/frontend/lib/components/AddBrainModal/components/CreateBrainStep/CreateBrainStep.module.scss b/frontend/lib/components/AddBrainModal/components/FeedBrainStep/FeedBrainStep.module.scss similarity index 85% rename from frontend/lib/components/AddBrainModal/components/CreateBrainStep/CreateBrainStep.module.scss rename to frontend/lib/components/AddBrainModal/components/FeedBrainStep/FeedBrainStep.module.scss index fdfc3ebe43c..db877905159 100644 --- a/frontend/lib/components/AddBrainModal/components/CreateBrainStep/CreateBrainStep.module.scss +++ b/frontend/lib/components/AddBrainModal/components/FeedBrainStep/FeedBrainStep.module.scss @@ -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 { diff --git a/frontend/lib/components/AddBrainModal/components/FeedBrainStep/FeedBrainStep.tsx b/frontend/lib/components/AddBrainModal/components/FeedBrainStep/FeedBrainStep.tsx new file mode 100644 index 00000000000..1426faf2df7 --- /dev/null +++ b/frontend/lib/components/AddBrainModal/components/FeedBrainStep/FeedBrainStep.tsx @@ -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 && ( + + + Upload documents or add URLs to add knowledges to your brain. + + + )} +
+ Feed your brain + +
+ + ); + + const renderButtons = () => { + const buttonProps = getButtonProps(); + + return ( +
+ {currentSyncId ? ( + setCurrentSyncId(undefined)} + /> + ) : ( + + )} + {currentSyncId ? ( + + ) : ( + + )} +
+ ); + }; + + if (currentStepIndex !== 1) { + return <>; + } + + return ( +
+ {renderFeedBrain()} + {renderButtons()} +
+ ); +}; diff --git a/frontend/lib/components/AddBrainModal/components/CreateBrainStep/hooks/useBrainCreationApi.ts b/frontend/lib/components/AddBrainModal/components/FeedBrainStep/hooks/useBrainCreationApi.ts similarity index 88% rename from frontend/lib/components/AddBrainModal/components/CreateBrainStep/hooks/useBrainCreationApi.ts rename to frontend/lib/components/AddBrainModal/components/FeedBrainStep/hooks/useBrainCreationApi.ts index 4968c426c8c..3e91290d665 100644 --- a/frontend/lib/components/AddBrainModal/components/CreateBrainStep/hooks/useBrainCreationApi.ts +++ b/frontend/lib/components/AddBrainModal/components/FeedBrainStep/hooks/useBrainCreationApi.ts @@ -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 => { 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([]); }; diff --git a/frontend/lib/components/AddBrainModal/components/Stepper/Stepper.module.scss b/frontend/lib/components/AddBrainModal/components/Stepper/Stepper.module.scss index 2384ab0a2b9..3526b661dad 100644 --- a/frontend/lib/components/AddBrainModal/components/Stepper/Stepper.module.scss +++ b/frontend/lib/components/AddBrainModal/components/Stepper/Stepper.module.scss @@ -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); diff --git a/frontend/lib/components/ConnectionCards/ConnectionCards.module.scss b/frontend/lib/components/ConnectionCards/ConnectionCards.module.scss new file mode 100644 index 00000000000..601dd681a42 --- /dev/null +++ b/frontend/lib/components/ConnectionCards/ConnectionCards.module.scss @@ -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; + } +} diff --git a/frontend/lib/components/ConnectionCards/ConnectionCards.tsx b/frontend/lib/components/ConnectionCards/ConnectionCards.tsx new file mode 100644 index 00000000000..5267faeb6a5 --- /dev/null +++ b/frontend/lib/components/ConnectionCards/ConnectionCards.tsx @@ -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 ( +
+ syncGoogleDrive(name)} + fromAddKnowledge={fromAddKnowledge} + /> + syncSharepoint(name)} + fromAddKnowledge={fromAddKnowledge} + /> +
+ ); +}; diff --git a/frontend/lib/components/ConnectionCards/ConnectionSection/ConnectionButton/ConnectionButton.module.scss b/frontend/lib/components/ConnectionCards/ConnectionSection/ConnectionButton/ConnectionButton.module.scss new file mode 100644 index 00000000000..d63655c3b26 --- /dev/null +++ b/frontend/lib/components/ConnectionCards/ConnectionSection/ConnectionButton/ConnectionButton.module.scss @@ -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; + } +} diff --git a/frontend/lib/components/ConnectionCards/ConnectionSection/ConnectionButton/ConnectionButton.tsx b/frontend/lib/components/ConnectionCards/ConnectionSection/ConnectionButton/ConnectionButton.tsx new file mode 100644 index 00000000000..e07d7e091d4 --- /dev/null +++ b/frontend/lib/components/ConnectionCards/ConnectionSection/ConnectionButton/ConnectionButton.tsx @@ -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 ( +
+
+ + {label} +
+
+ onClick(index)} + /> +
+
+ ); +}; diff --git a/frontend/lib/components/ConnectionCards/ConnectionSection/ConnectionLine/ConnectionLine.module.scss b/frontend/lib/components/ConnectionCards/ConnectionSection/ConnectionLine/ConnectionLine.module.scss new file mode 100644 index 00000000000..51b6c794a7b --- /dev/null +++ b/frontend/lib/components/ConnectionCards/ConnectionSection/ConnectionLine/ConnectionLine.module.scss @@ -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; + } +} diff --git a/frontend/lib/components/ConnectionCards/ConnectionSection/ConnectionLine/ConnectionLine.tsx b/frontend/lib/components/ConnectionCards/ConnectionSection/ConnectionLine/ConnectionLine.tsx new file mode 100644 index 00000000000..e271f8ee828 --- /dev/null +++ b/frontend/lib/components/ConnectionCards/ConnectionSection/ConnectionLine/ConnectionLine.tsx @@ -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 ( +
+
+ + {label} +
+
+ { + await deleteUserSync(id); + setHasToReload(true); + }} + /> +
+
+ ); +}; diff --git a/frontend/lib/components/ConnectionCards/ConnectionSection/ConnectionSection.module.scss b/frontend/lib/components/ConnectionCards/ConnectionSection/ConnectionSection.module.scss new file mode 100644 index 00000000000..7c37594835e --- /dev/null +++ b/frontend/lib/components/ConnectionCards/ConnectionSection/ConnectionSection.module.scss @@ -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); + } + } + } +} diff --git a/frontend/lib/components/ConnectionCards/ConnectionSection/ConnectionSection.tsx b/frontend/lib/components/ConnectionCards/ConnectionSection/ConnectionSection.tsx new file mode 100644 index 00000000000..66543eaff9f --- /dev/null +++ b/frontend/lib/components/ConnectionCards/ConnectionSection/ConnectionSection.tsx @@ -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) => ( +
+ +
+ )); + } else { + return ( +
+ {existingConnections.map((connection, index) => ( +
+ +
+ ))} +
+ ); + } +}; + +const renderExistingConnections = ({ + existingConnections, + folded, + setFolded, + fromAddKnowledge, + handleGetSyncFiles, + openedConnections, +}: { + existingConnections: Sync[]; + folded: boolean; + setFolded: (folded: boolean) => void; + fromAddKnowledge: boolean; + handleGetSyncFiles: ( + userSyncId: number, + currentProvider: Provider + ) => Promise; + openedConnections: OpenedConnection[]; +}) => { + if (!!existingConnections.length && !fromAddKnowledge) { + return ( +
+
+ Connected accounts + setFolded(!folded)} + /> +
+ {renderConnectionLines(existingConnections, folded)} +
+ ); + } else if (existingConnections.length > 0 && fromAddKnowledge) { + return ( +
+ {existingConnections.map((connection, index) => ( +
+ { + return ( + openedConnection.name === connection.name && + openedConnection.submitted + ); + })} + onClick={() => + void handleGetSyncFiles(connection.id, connection.provider) + } + /> +
+ ))} +
+ ); + } 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([]); + const [folded, setFolded] = useState(!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 ( + <> +
+
+
+ {label} + {label} +
+ {!fromAddKnowledge ? ( + connect()} + small={true} + /> + ) : ( + connect()} + small={true} + /> + )} +
+ {renderExistingConnections({ + existingConnections, + folded, + setFolded, + fromAddKnowledge: !!fromAddKnowledge, + handleGetSyncFiles, + openedConnections, + })} +
+ + ); +}; diff --git a/frontend/lib/components/PageHeader/PageHeader.tsx b/frontend/lib/components/PageHeader/PageHeader.tsx index 5a03c3f59a5..d95d696534e 100644 --- a/frontend/lib/components/PageHeader/PageHeader.tsx +++ b/frontend/lib/components/PageHeader/PageHeader.tsx @@ -53,11 +53,18 @@ export const PageHeader = ({ /> ))} {!isMobile && } + void (window.location.href = "/user")} + />
diff --git a/frontend/lib/components/UploadDocumentModal/UploadDocumentModal.module.scss b/frontend/lib/components/UploadDocumentModal/UploadDocumentModal.module.scss index a9f279cd586..e3f474c22c0 100644 --- a/frontend/lib/components/UploadDocumentModal/UploadDocumentModal.module.scss +++ b/frontend/lib/components/UploadDocumentModal/UploadDocumentModal.module.scss @@ -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; + } } } diff --git a/frontend/lib/components/UploadDocumentModal/UploadDocumentModal.tsx b/frontend/lib/components/UploadDocumentModal/UploadDocumentModal.tsx index 61e5ba73c9e..1236ed44256 100644 --- a/frontend/lib/components/UploadDocumentModal/UploadDocumentModal.tsx +++ b/frontend/lib/components/UploadDocumentModal/UploadDocumentModal.tsx @@ -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(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 => { >
-
- +
+ {!!currentSyncId && ( + { + setCurrentSyncId(undefined); + }} + /> + )} + {currentSyncId ? ( + + ) : ( + { + setOpenedConnections([]); + void handleFeedBrain(); + }} + disabled={disabled} + isLoading={feeding} + important={true} + /> + )}
diff --git a/frontend/lib/components/UploadDocumentModal/hooks/useFeedBrain.ts b/frontend/lib/components/UploadDocumentModal/hooks/useFeedBrain.ts index 9fa1e00dc5b..d3d442eb22f 100644 --- a/frontend/lib/components/UploadDocumentModal/hooks/useFeedBrain.ts +++ b/frontend/lib/components/UploadDocumentModal/hooks/useFeedBrain.ts @@ -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({ diff --git a/frontend/lib/components/UploadDocumentModal/hooks/useFeedBrainHandler.ts b/frontend/lib/components/UploadDocumentModal/hooks/useFeedBrainHandler.ts index b88a8ab0de3..5b4232c9c93 100644 --- a/frontend/lib/components/UploadDocumentModal/hooks/useFeedBrainHandler.ts +++ b/frontend/lib/components/UploadDocumentModal/hooks/useFeedBrainHandler.ts @@ -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, diff --git a/frontend/lib/components/ui/Checkbox/Checkbox.module.scss b/frontend/lib/components/ui/Checkbox/Checkbox.module.scss index c35d68715c4..608701f68d1 100644 --- a/frontend/lib/components/ui/Checkbox/Checkbox.module.scss +++ b/frontend/lib/components/ui/Checkbox/Checkbox.module.scss @@ -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); + } } diff --git a/frontend/lib/components/ui/Checkbox/Checkbox.tsx b/frontend/lib/components/ui/Checkbox/Checkbox.tsx index 46e351cd146..ee71cd60471 100644 --- a/frontend/lib/components/ui/Checkbox/Checkbox.tsx +++ b/frontend/lib/components/ui/Checkbox/Checkbox.tsx @@ -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(checked); @@ -19,18 +26,31 @@ export const Checkbox = ({ setCurrentChecked(checked); }, [checked]); - return ( + const checkboxElement = (
{ - setChecked(!currentChecked); - setCurrentChecked(!currentChecked); + className={`${styles.checkbox_wrapper} ${ + disabled ? styles.disabled : "" + }`} + onClick={(event) => { + event.stopPropagation(); + if (!disabled) { + setChecked(!currentChecked); + setCurrentChecked(!currentChecked); + } }} >
- {label} + > + {currentChecked && } +
+ {label && {label}}
); + + return tooltip ? ( + {checkboxElement} + ) : ( + checkboxElement + ); }; diff --git a/frontend/lib/components/ui/ConnectionIcon/ConnectionIcon.module.scss b/frontend/lib/components/ui/ConnectionIcon/ConnectionIcon.module.scss new file mode 100644 index 00000000000..e0b22aaf54e --- /dev/null +++ b/frontend/lib/components/ui/ConnectionIcon/ConnectionIcon.module.scss @@ -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); +} diff --git a/frontend/lib/components/ui/ConnectionIcon/ConnectionIcon.tsx b/frontend/lib/components/ui/ConnectionIcon/ConnectionIcon.tsx new file mode 100644 index 00000000000..f7ac5283a98 --- /dev/null +++ b/frontend/lib/components/ui/ConnectionIcon/ConnectionIcon.tsx @@ -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 ( +
+ {letter.toUpperCase()} +
+ ); +}; diff --git a/frontend/lib/components/ui/Icon/Icon.module.scss b/frontend/lib/components/ui/Icon/Icon.module.scss index 8bd075a6629..8a6df4ec023 100644 --- a/frontend/lib/components/ui/Icon/Icon.module.scss +++ b/frontend/lib/components/ui/Icon/Icon.module.scss @@ -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; diff --git a/frontend/lib/components/ui/Modal/Modal.module.scss b/frontend/lib/components/ui/Modal/Modal.module.scss index 72b089f2112..fb183043b1b 100644 --- a/frontend/lib/components/ui/Modal/Modal.module.scss +++ b/frontend/lib/components/ui/Modal/Modal.module.scss @@ -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 { diff --git a/frontend/lib/components/ui/QuivrButton/QuivrButton.module.scss b/frontend/lib/components/ui/QuivrButton/QuivrButton.module.scss index 3e93df05e61..83b0efb96b9 100644 --- a/frontend/lib/components/ui/QuivrButton/QuivrButton.module.scss +++ b/frontend/lib/components/ui/QuivrButton/QuivrButton.module.scss @@ -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; diff --git a/frontend/lib/components/ui/QuivrButton/QuivrButton.tsx b/frontend/lib/components/ui/QuivrButton/QuivrButton.tsx index 2db991cd0aa..1cde25d2514 100644 --- a/frontend/lib/components/ui/QuivrButton/QuivrButton.tsx +++ b/frontend/lib/components/ui/QuivrButton/QuivrButton.tsx @@ -17,6 +17,7 @@ export const QuivrButton = ({ disabled, hidden, important, + small, }: ButtonType): JSX.Element => { const [hovered, setHovered] = useState(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 ( + + ); } else { - return color; + return ( + + ); } }; @@ -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} >
- {!isLoading ? ( - - ) : ( - - )} + {renderIcon()} {label}
diff --git a/frontend/lib/components/ui/SwitchButton/SwitchButton.module.scss b/frontend/lib/components/ui/SwitchButton/SwitchButton.module.scss new file mode 100644 index 00000000000..110cf519e10 --- /dev/null +++ b/frontend/lib/components/ui/SwitchButton/SwitchButton.module.scss @@ -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; + } + } +} diff --git a/frontend/lib/components/ui/SwitchButton/SwitchButton.tsx b/frontend/lib/components/ui/SwitchButton/SwitchButton.tsx new file mode 100644 index 00000000000..6d799482c96 --- /dev/null +++ b/frontend/lib/components/ui/SwitchButton/SwitchButton.tsx @@ -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 ( +
+ {label} +
+
+
+
+ ); +}; + +export default SwitchButton; diff --git a/frontend/lib/components/ui/Tabs/Tabs.module.scss b/frontend/lib/components/ui/Tabs/Tabs.module.scss index 98a32474d56..d6d4a059bc9 100644 --- a/frontend/lib/components/ui/Tabs/Tabs.module.scss +++ b/frontend/lib/components/ui/Tabs/Tabs.module.scss @@ -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; + } + } } } diff --git a/frontend/lib/components/ui/Tabs/Tabs.tsx b/frontend/lib/components/ui/Tabs/Tabs.tsx index b15b089a885..9ddab1e08ef 100644 --- a/frontend/lib/components/ui/Tabs/Tabs.tsx +++ b/frontend/lib/components/ui/Tabs/Tabs.tsx @@ -38,7 +38,12 @@ export const Tabs = ({ tabList }: TabsProps): JSX.Element => { : "black" } /> - {tab.label} +
+ {tab.label} + {!!tab.badge && tab.badge > 0 && ( +
{tab.badge}
+ )} +
))} diff --git a/frontend/lib/components/ui/TextAreaInput/TextAreaInput.tsx b/frontend/lib/components/ui/TextAreaInput/TextAreaInput.tsx index 1b61ce420ad..9cbcbdc3590 100644 --- a/frontend/lib/components/ui/TextAreaInput/TextAreaInput.tsx +++ b/frontend/lib/components/ui/TextAreaInput/TextAreaInput.tsx @@ -24,7 +24,7 @@ export const TextAreaInput = ({