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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,8 +6,46 @@
display: flex;
flex-direction: column;
gap: Spacings.$spacing05;
padding-bottom: Spacings.$spacing10;
border-radius: Radius.$normal;
.title {
@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 styles from "./KnowledgeTable.module.scss";
@ -11,12 +16,133 @@ interface KnowledgeTableProps {
const KnowledgeTable = React.forwardRef<HTMLDivElement, KnowledgeTableProps>(
({ 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 (
<div ref={ref} className={styles.knowledge_table_wrapper}>
<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>
{knowledgeList.map((knowledge) => (
<KnowledgeItem knowledge={knowledge} key={knowledge.id} />
<div
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>

View File

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

View File

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

View File

@ -43,7 +43,7 @@ const fileTypeIcons: Record<SupportedFileExtensions, IconType> = {
ipynb: BsFiletypePy,
py: BsFiletypePy,
telegram: BsFiletypeDocx,
bib: FaFile
bib: FaFile,
};
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;
return <Icon width={16} />;
return <Icon width={24} height={24} />;
};

View File

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