feat(frontend): new ui ux for knowledge (#2732)

# Description

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

## Checklist before requesting a review

Please delete options that are not relevant.

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented hard-to-understand areas
- [ ] I have ideally added tests that prove my fix is effective or that
my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged

## Screenshots (if appropriate):
This commit is contained in:
Antoine Dewez 2024-06-25 16:02:53 +02:00 committed by GitHub
parent cb2442d76b
commit fe41b36788
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 259 additions and 44 deletions

View File

@ -1,3 +1,4 @@
@use "styles/BoxShadow.module.scss";
@use "styles/Radius.module.scss"; @use "styles/Radius.module.scss";
@use "styles/ScreenSizes.module.scss"; @use "styles/ScreenSizes.module.scss";
@use "styles/Spacings.module.scss"; @use "styles/Spacings.module.scss";
@ -19,6 +20,7 @@
overflow: visible; overflow: visible;
overflow: hidden; overflow: hidden;
height: 100%; height: 100%;
box-shadow: BoxShadow.$small;
&:hover { &:hover {
border-color: var(--primary-0); border-color: var(--primary-0);

View File

@ -3,5 +3,5 @@
.knowledge_tab_wrapper { .knowledge_tab_wrapper {
display: flex; display: flex;
justify-content: center; justify-content: center;
padding: Spacings.$spacing05; padding-block: Spacings.$spacing05;
} }

View File

@ -5,22 +5,27 @@
@use "styles/ZIndexes.module.scss"; @use "styles/ZIndexes.module.scss";
.knowledge_item_wrapper { .knowledge_item_wrapper {
padding-inline: Spacings.$spacing05; padding-inline: Spacings.$spacing06;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
gap: Spacings.$spacing02; gap: Spacings.$spacing02;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-block: Spacings.$spacing03; border: 1px solid var(--border-0);
border: 1px solid var(--border-1);
border-radius: Radius.$normal;
padding-block: Spacings.$spacing03; padding-block: Spacings.$spacing03;
position: relative; position: relative;
overflow: visible; overflow: visible;
font-size: Typography.$small;
border-bottom: none;
&.last {
border-radius: 0 0 Radius.$normal Radius.$normal;
border-bottom: 1px solid var(--border-0);
}
&:hover { &:hover {
border-color: var(--primary-0); background-color: var(--background-1);
background-color: var(--background-special-0); cursor: pointer;
} }
.left { .left {
@ -29,10 +34,6 @@
gap: Spacings.$spacing03; gap: Spacings.$spacing03;
overflow: hidden; overflow: hidden;
.icon {
min-width: 16px;
}
.file_name { .file_name {
@include Typography.EllipsisOverflow; @include Typography.EllipsisOverflow;
} }
@ -45,4 +46,4 @@
z-index: ZIndexes.$modal; z-index: ZIndexes.$modal;
padding-bottom: Spacings.$spacing01; padding-bottom: Spacings.$spacing01;
} }
} }

View File

@ -1,11 +1,11 @@
"use client";
import axios from "axios"; import axios from "axios";
import { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { useKnowledgeApi } from "@/lib/api/knowledge/useKnowledgeApi"; import { useKnowledgeApi } from "@/lib/api/knowledge/useKnowledgeApi";
import { Checkbox } from "@/lib/components/ui/Checkbox/Checkbox";
import Icon from "@/lib/components/ui/Icon/Icon"; import Icon from "@/lib/components/ui/Icon/Icon";
import { OptionsModal } from "@/lib/components/ui/OptionsModal/OptionsModal"; import { OptionsModal } from "@/lib/components/ui/OptionsModal/OptionsModal";
import { getFileIcon } from "@/lib/helpers/getFileIcon"; import { iconList } from "@/lib/helpers/iconList";
import { useUrlBrain } from "@/lib/hooks/useBrainIdFromUrl"; import { useUrlBrain } from "@/lib/hooks/useBrainIdFromUrl";
import { isUploadedKnowledge, Knowledge } from "@/lib/types/Knowledge"; import { isUploadedKnowledge, Knowledge } from "@/lib/types/Knowledge";
import { Option } from "@/lib/types/Options"; import { Option } from "@/lib/types/Options";
@ -16,8 +16,14 @@ import styles from "./KnowledgeItem.module.scss";
const KnowledgeItem = ({ const KnowledgeItem = ({
knowledge, knowledge,
selected,
setSelected,
lastChild,
}: { }: {
knowledge: Knowledge; knowledge: Knowledge;
selected: boolean;
setSelected: (selected: boolean, event: React.MouseEvent) => void;
lastChild?: boolean;
}): JSX.Element => { }): JSX.Element => {
const [optionsOpened, setOptionsOpened] = useState<boolean>(false); const [optionsOpened, setOptionsOpened] = useState<boolean>(false);
const iconRef = useRef<HTMLDivElement | null>(null); const iconRef = useRef<HTMLDivElement | null>(null);
@ -89,13 +95,25 @@ const KnowledgeItem = ({
}, []); }, []);
return ( return (
<div className={styles.knowledge_item_wrapper}> <div
className={`${styles.knowledge_item_wrapper} ${
lastChild ? styles.last : ""
}`}
>
<div className={styles.left}> <div className={styles.left}>
<Checkbox
checked={selected}
setChecked={(checked, event) => setSelected(checked, event)}
/>
<div className={styles.icon}> <div className={styles.icon}>
{isUploadedKnowledge(knowledge) ? ( {isUploadedKnowledge(knowledge) ? (
getFileIcon(knowledge.fileName) <Icon
name={knowledge.extension.slice(1) as keyof typeof iconList}
size="small"
color="black"
/>
) : ( ) : (
<Icon name="link" size="normal" color="black" /> <Icon name="link" size="small" color="black" />
)} )}
</div> </div>
{isUploadedKnowledge(knowledge) ? ( {isUploadedKnowledge(knowledge) ? (
@ -109,11 +127,11 @@ const KnowledgeItem = ({
<div <div
ref={iconRef} ref={iconRef}
onClick={(event: React.MouseEvent<HTMLElement>) => { onClick={(event: React.MouseEvent<HTMLElement>) => {
event.nativeEvent.stopImmediatePropagation(); event.stopPropagation();
setOptionsOpened(!optionsOpened); setOptionsOpened(!optionsOpened);
}} }}
> >
<Icon name="options" size="normal" color="black" handleHover={true} /> <Icon name="options" size="small" color="black" handleHover={true} />
</div> </div>
<div ref={optionsRef} className={styles.options_modal}> <div ref={optionsRef} className={styles.options_modal}>
{optionsOpened && <OptionsModal options={options} />} {optionsOpened && <OptionsModal options={options} />}

View File

@ -16,7 +16,7 @@ export const useKnowledgeItem = () => {
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const { publish } = useToast(); const { publish } = useToast();
const { track } = useEventTracking(); const { track } = useEventTracking();
const { brainId, brain } = useUrlBrain(); const { brainId } = useUrlBrain();
const { invalidateKnowledgeDataKey } = useKnowledge({ const { invalidateKnowledgeDataKey } = useKnowledge({
brainId, brainId,
}); });
@ -38,15 +38,6 @@ export const useKnowledgeItem = () => {
}); });
invalidateKnowledgeDataKey(); invalidateKnowledgeDataKey();
publish({
variant: "success",
text: t("deleted", {
fileName: knowledge_name,
brain: brain?.name,
ns: "explore",
}),
});
} catch (error) { } catch (error) {
publish({ publish({
variant: "warning", variant: "warning",

View File

@ -6,8 +6,46 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: Spacings.$spacing05; gap: Spacings.$spacing05;
padding-bottom: Spacings.$spacing10;
border-radius: Radius.$normal;
.title { .title {
@include Typography.H3; @include Typography.H3;
} }
}
.table_header {
display: flex;
justify-content: space-between;
align-items: center;
gap: Spacings.$spacing03;
.search {
width: 250px;
}
}
.first_line {
display: flex;
justify-content: space-between;
padding-left: calc(Spacings.$spacing06);
padding-right: Spacings.$spacing04;
padding-block: Spacings.$spacing02;
font-weight: 500;
background-color: var(--background-1);
font-size: Typography.$small;
border: 1px solid var(--border-0);
border-radius: Radius.$normal Radius.$normal 0 0;
border-bottom: none;
&.empty {
border: 1px solid var(--border-0);
border-radius: Radius.$normal;
}
.left {
display: flex;
align-items: center;
gap: Spacings.$spacing06;
}
}
}

View File

@ -1,7 +1,12 @@
import React from "react"; import React, { useEffect, useState } from "react";
import { Knowledge } from "@/lib/types/Knowledge"; import { Checkbox } from "@/lib/components/ui/Checkbox/Checkbox";
import QuivrButton from "@/lib/components/ui/QuivrButton/QuivrButton";
import { TextInput } from "@/lib/components/ui/TextInput/TextInput";
import { isUploadedKnowledge, Knowledge } from "@/lib/types/Knowledge";
import { useKnowledgeItem } from "./KnowledgeItem/hooks/useKnowledgeItem";
// eslint-disable-next-line import/order
import KnowledgeItem from "./KnowledgeItem/KnowledgeItem"; import KnowledgeItem from "./KnowledgeItem/KnowledgeItem";
import styles from "./KnowledgeTable.module.scss"; import styles from "./KnowledgeTable.module.scss";
@ -11,12 +16,133 @@ interface KnowledgeTableProps {
const KnowledgeTable = React.forwardRef<HTMLDivElement, KnowledgeTableProps>( const KnowledgeTable = React.forwardRef<HTMLDivElement, KnowledgeTableProps>(
({ knowledgeList }, ref) => { ({ knowledgeList }, ref) => {
const [selectedKnowledge, setSelectedKnowledge] = useState<Knowledge[]>([]);
const [lastSelectedIndex, setLastSelectedIndex] = useState<number | null>(
null
);
const { onDeleteKnowledge } = useKnowledgeItem();
const [allChecked, setAllChecked] = useState<boolean>(false);
const [searchQuery, setSearchQuery] = useState<string>("");
const [filteredKnowledgeList, setFilteredKnowledgeList] =
useState<Knowledge[]>(knowledgeList);
useEffect(() => {
setFilteredKnowledgeList(
knowledgeList.filter((knowledge) =>
isUploadedKnowledge(knowledge)
? knowledge.fileName
.toLowerCase()
.includes(searchQuery.toLowerCase())
: knowledge.url.toLowerCase().includes(searchQuery.toLowerCase())
)
);
}, [searchQuery, knowledgeList]);
const handleSelect = (
knowledge: Knowledge,
index: number,
event: React.MouseEvent
) => {
if (event.shiftKey && lastSelectedIndex !== null) {
const start = Math.min(lastSelectedIndex, index);
const end = Math.max(lastSelectedIndex, index);
const range = filteredKnowledgeList.slice(start, end + 1);
setSelectedKnowledge((prevSelected) => {
const newSelected = [...prevSelected];
range.forEach((item) => {
if (
!newSelected.some((selectedItem) => selectedItem.id === item.id)
) {
newSelected.push(item);
}
});
return newSelected;
});
} else {
const isSelected = selectedKnowledge.some(
(item) => item.id === knowledge.id
);
setSelectedKnowledge((prevSelected) =>
isSelected
? prevSelected.filter(
(selectedItem) => selectedItem.id !== knowledge.id
)
: [...prevSelected, knowledge]
);
setLastSelectedIndex(
isSelected && lastSelectedIndex === index ? null : index
);
}
};
const handleDelete = () => {
const toDelete = selectedKnowledge.filter((knowledge) =>
filteredKnowledgeList.some((item) => item.id === knowledge.id)
);
toDelete.forEach((knowledge) => {
void onDeleteKnowledge(knowledge);
});
setSelectedKnowledge([]);
};
return ( return (
<div ref={ref} className={styles.knowledge_table_wrapper}> <div ref={ref} className={styles.knowledge_table_wrapper}>
<span className={styles.title}>Uploaded Knowledge</span> <span className={styles.title}>Uploaded Knowledge</span>
<div className={styles.table_header}>
<div className={styles.search}>
<TextInput
iconName="search"
label="Search"
inputValue={searchQuery}
setInputValue={setSearchQuery}
/>
</div>
<QuivrButton
label="Delete"
iconName="delete"
color="dangerous"
disabled={selectedKnowledge.length === 0}
onClick={handleDelete}
/>
</div>
<div> <div>
{knowledgeList.map((knowledge) => ( <div
<KnowledgeItem knowledge={knowledge} key={knowledge.id} /> className={`${styles.first_line} ${
filteredKnowledgeList.length === 0 ? styles.empty : ""
}`}
>
<div className={styles.left}>
<Checkbox
checked={allChecked}
setChecked={(checked) => {
setAllChecked(checked);
checked
? setSelectedKnowledge(filteredKnowledgeList)
: setSelectedKnowledge([]);
}}
/>
<span className={styles.name}>Name</span>
</div>
<span className={styles.actions}>Actions</span>
</div>
{filteredKnowledgeList.map((knowledge, index) => (
<div
key={knowledge.id}
onClick={(event) => handleSelect(knowledge, index, event)}
>
<KnowledgeItem
knowledge={knowledge}
selected={selectedKnowledge.some(
(item) => item.id === knowledge.id
)}
setSelected={(_selected, event) =>
handleSelect(knowledge, index, event)
}
lastChild={index === filteredKnowledgeList.length - 1}
/>
</div>
))} ))}
</div> </div>
</div> </div>

View File

@ -6,18 +6,20 @@
align-items: center; align-items: center;
gap: Spacings.$spacing03; gap: Spacings.$spacing03;
cursor: pointer; cursor: pointer;
border-radius: Radius.$normal;
.checkbox { .checkbox {
width: 16px; width: 16px;
height: 16px; height: 16px;
border: 1px solid var(--border-2); border: 1px solid var(--border-2);
border-radius: Radius.$small;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
border-radius: Radius.$normal;
&.filled { &.filled {
background-color: var(--primary-0); background-color: var(--primary-0);
border-color: var(--primary-0);
} }
} }
@ -30,4 +32,4 @@
&:hover { &:hover {
background-color: var(--background-3); background-color: var(--background-3);
} }
} }

View File

@ -7,7 +7,7 @@ import { Icon } from "../Icon/Icon";
interface CheckboxProps { interface CheckboxProps {
label?: string; label?: string;
checked: boolean; checked: boolean;
setChecked: (value: boolean) => void; setChecked: (value: boolean, event: React.MouseEvent) => void;
disabled?: boolean; disabled?: boolean;
} }
@ -31,7 +31,7 @@ export const Checkbox = ({
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
if (!disabled) { if (!disabled) {
setChecked(!currentChecked); setChecked(!currentChecked, event);
setCurrentChecked(!currentChecked); setCurrentChecked(!currentChecked);
} }
}} }}

View File

@ -43,7 +43,7 @@ const fileTypeIcons: Record<SupportedFileExtensions, IconType> = {
ipynb: BsFiletypePy, ipynb: BsFiletypePy,
py: BsFiletypePy, py: BsFiletypePy,
telegram: BsFiletypeDocx, telegram: BsFiletypeDocx,
bib: FaFile bib: FaFile,
}; };
export const getFileIcon = (fileName: string): JSX.Element => { export const getFileIcon = (fileName: string): JSX.Element => {
@ -51,5 +51,5 @@ export const getFileIcon = (fileName: string): JSX.Element => {
const Icon = fileType !== undefined ? fileTypeIcons[fileType] : FaFile; const Icon = fileType !== undefined ? fileTypeIcons[fileType] : FaFile;
return <Icon width={16} />; return <Icon width={24} height={24} />;
}; };

View File

@ -3,6 +3,18 @@ import { BiCoin } from "react-icons/bi";
import { import {
BsArrowRightShort, BsArrowRightShort,
BsChatLeftText, BsChatLeftText,
BsFiletypeCsv,
BsFiletypeDocx,
BsFiletypeHtml,
BsFiletypeMd,
BsFiletypeMp3,
BsFiletypeMp4,
BsFiletypePdf,
BsFiletypePptx,
BsFiletypePy,
BsFiletypeTxt,
BsFiletypeXls,
BsFiletypeXlsx,
BsTextParagraph, BsTextParagraph,
} from "react-icons/bs"; } from "react-icons/bs";
import { CgSoftwareDownload } from "react-icons/cg"; import { CgSoftwareDownload } from "react-icons/cg";
@ -12,6 +24,7 @@ import {
FaCheck, FaCheck,
FaCheckCircle, FaCheckCircle,
FaDiscord, FaDiscord,
FaFile,
FaFileAlt, FaFileAlt,
FaFolder, FaFolder,
FaGithub, FaGithub,
@ -19,6 +32,7 @@ import {
FaLinkedin, FaLinkedin,
FaQuestionCircle, FaQuestionCircle,
FaRegFileAlt, FaRegFileAlt,
FaRegFileAudio,
FaRegKeyboard, FaRegKeyboard,
FaRegStar, FaRegStar,
FaRegThumbsDown, FaRegThumbsDown,
@ -50,7 +64,7 @@ import {
IoShareSocial, IoShareSocial,
IoWarningOutline, IoWarningOutline,
} from "react-icons/io5"; } from "react-icons/io5";
import { LiaRobotSolid } from "react-icons/lia"; import { LiaFileVideo, LiaRobotSolid } from "react-icons/lia";
import { IconType } from "react-icons/lib"; import { IconType } from "react-icons/lib";
import { import {
LuBrain, LuBrain,
@ -91,6 +105,7 @@ export const iconList: { [name: string]: IconType } = {
addWithoutCircle: IoIosAdd, addWithoutCircle: IoIosAdd,
assistant: TbRobot, assistant: TbRobot,
back: RiDeleteBackLine, back: RiDeleteBackLine,
bib: FaFile,
brain: LuBrain, brain: LuBrain,
brainCircuit: LuBrainCircuit, brainCircuit: LuBrainCircuit,
calendar: FaCalendar, calendar: FaCalendar,
@ -107,9 +122,11 @@ export const iconList: { [name: string]: IconType } = {
custom: MdDashboardCustomize, custom: MdDashboardCustomize,
delete: MdDeleteOutline, delete: MdDeleteOutline,
discord: FaDiscord, discord: FaDiscord,
docx: BsFiletypeDocx,
download: IoCloudDownloadOutline, download: IoCloudDownloadOutline,
edit: MdOutlineModeEditOutline, edit: MdOutlineModeEditOutline,
email: MdAlternateEmail, email: MdAlternateEmail,
epub: FaFile,
eureka: GoLightBulb, eureka: GoLightBulb,
externLink: LuExternalLink, externLink: LuExternalLink,
feed: MdDynamicFeed, feed: MdDynamicFeed,
@ -126,22 +143,34 @@ export const iconList: { [name: string]: IconType } = {
help: IoIosHelpCircleOutline, help: IoIosHelpCircleOutline,
history: MdHistory, history: MdHistory,
home: IoHomeOutline, home: IoHomeOutline,
html: BsFiletypeHtml,
info: FaInfo, info: FaInfo,
ipynb: BsFiletypePy,
key: FaKey, key: FaKey,
link: MdLink, link: MdLink,
linkedin: FaLinkedin, linkedin: FaLinkedin,
loader: AiOutlineLoading3Quarters, loader: AiOutlineLoading3Quarters,
logout: IoMdLogOut, logout: IoMdLogOut,
m4a: LiaFileVideo,
markdown: BsFiletypeMd,
md: BsFiletypeMd,
moon: MdDarkMode, moon: MdDarkMode,
mp3: BsFiletypeMp3,
mp4: BsFiletypeMp4,
mpga: FaRegFileAudio,
mpeg: LiaFileVideo,
notifications: IoIosNotifications, notifications: IoIosNotifications,
office: HiBuildingOffice, office: HiBuildingOffice,
odt: BsFiletypeDocx,
options: SlOptions, options: SlOptions,
paragraph: BsTextParagraph, paragraph: BsTextParagraph,
pptx: BsFiletypePptx,
prompt: FaRegKeyboard, prompt: FaRegKeyboard,
py: BsFiletypePy,
question: FaQuestionCircle, question: FaQuestionCircle,
redirection: BsArrowRightShort,
radio: IoIosRadio, radio: IoIosRadio,
read: MdMarkEmailRead, read: MdMarkEmailRead,
redirection: BsArrowRightShort,
robot: LiaRobotSolid, robot: LiaRobotSolid,
search: LuSearch, search: LuSearch,
settings: IoMdSettings, settings: IoMdSettings,
@ -152,6 +181,7 @@ export const iconList: { [name: string]: IconType } = {
step: IoFootsteps, step: IoFootsteps,
sun: MdOutlineLightMode, sun: MdOutlineLightMode,
sync: IoMdSync, sync: IoMdSync,
telegram: BsFiletypeDocx,
thumbsDown: FaRegThumbsDown, thumbsDown: FaRegThumbsDown,
thumbsUp: FaRegThumbsUp, thumbsUp: FaRegThumbsUp,
twitter: FaTwitter, twitter: FaTwitter,
@ -162,5 +192,12 @@ export const iconList: { [name: string]: IconType } = {
uploadFile: MdUploadFile, uploadFile: MdUploadFile,
user: FaRegUserCircle, user: FaRegUserCircle,
warning: IoWarningOutline, warning: IoWarningOutline,
wav: FaRegFileAudio,
webm: LiaFileVideo,
website: TbNetwork, website: TbNetwork,
xls: BsFiletypeXls,
xlsx: BsFiletypeXlsx,
txt: BsFiletypeTxt,
csv: BsFiletypeCsv,
pdf: BsFiletypePdf,
}; };