From 13da9cb396731f4029cdbfccdc1697c7a58329e8 Mon Sep 17 00:00:00 2001 From: Sean Hatfield <seanhatfield5@gmail.com> Date: Fri, 7 Jun 2024 13:49:13 -0700 Subject: [PATCH] [FEAT] Implement search for document picker (#1532) * implement search for document picker * patch name * Refactor file search method and implementation --------- Co-authored-by: timothycarambat <rambat1010@gmail.com> --- frontend/package.json | 3 +- .../Directory/NewFolderModal/index.jsx | 90 +++++++++++++++ .../Documents/Directory/index.jsx | 109 ++++++++---------- .../Documents/Directory/utils.js | 49 ++++++++ frontend/src/index.css | 4 + frontend/yarn.lock | 5 + 6 files changed, 200 insertions(+), 60 deletions(-) create mode 100644 frontend/src/components/Modals/ManageWorkspace/Documents/Directory/NewFolderModal/index.jsx create mode 100644 frontend/src/components/Modals/ManageWorkspace/Documents/Directory/utils.js diff --git a/frontend/package.json b/frontend/package.json index 8aa4dcfa5..84c27166a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "file-saver": "^2.0.5", "he": "^1.2.0", "highlight.js": "^11.9.0", + "js-levenshtein": "^1.1.6", "lodash.debounce": "^4.0.8", "markdown-it": "^13.0.1", "pluralize": "^8.0.0", @@ -63,4 +64,4 @@ "tailwindcss": "^3.3.1", "vite": "^4.3.0" } -} \ No newline at end of file +} diff --git a/frontend/src/components/Modals/ManageWorkspace/Documents/Directory/NewFolderModal/index.jsx b/frontend/src/components/Modals/ManageWorkspace/Documents/Directory/NewFolderModal/index.jsx new file mode 100644 index 000000000..47ad85b05 --- /dev/null +++ b/frontend/src/components/Modals/ManageWorkspace/Documents/Directory/NewFolderModal/index.jsx @@ -0,0 +1,90 @@ +import React, { useState } from "react"; +import { X } from "@phosphor-icons/react"; +import Document from "@/models/document"; + +export default function NewFolderModal({ closeModal, files, setFiles }) { + const [error, setError] = useState(null); + const [folderName, setFolderName] = useState(""); + + const handleCreate = async (e) => { + e.preventDefault(); + setError(null); + if (folderName.trim() !== "") { + const newFolder = { + name: folderName, + type: "folder", + items: [], + }; + const { success } = await Document.createFolder(folderName); + if (success) { + setFiles({ + ...files, + items: [...files.items, newFolder], + }); + closeModal(); + } else { + setError("Failed to create folder"); + } + } + }; + + return ( + <div className="relative w-full max-w-xl max-h-full"> + <div className="relative bg-main-gradient rounded-lg shadow"> + <div className="flex items-start justify-between p-4 border-b rounded-t border-gray-500/50"> + <h3 className="text-xl font-semibold text-white"> + Create New Folder + </h3> + <button + onClick={closeModal} + type="button" + className="transition-all duration-300 text-gray-400 bg-transparent hover:border-white/60 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border" + data-modal-hide="staticModal" + > + <X className="text-gray-300 text-lg" /> + </button> + </div> + <form onSubmit={handleCreate}> + <div className="p-6 space-y-6 flex h-full w-full"> + <div className="w-full flex flex-col gap-y-4"> + <div> + <label + htmlFor="folderName" + className="block mb-2 text-sm font-medium text-white" + > + Folder Name + </label> + <input + name="folderName" + type="text" + className="bg-zinc-900 placeholder:text-white/20 border-gray-500 text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" + placeholder="Enter folder name" + required={true} + autoComplete="off" + value={folderName} + onChange={(e) => setFolderName(e.target.value)} + /> + </div> + {error && <p className="text-red-400 text-sm">Error: {error}</p>} + </div> + </div> + <div className="flex w-full justify-between items-center p-6 space-x-2 border-t rounded-b border-gray-500/50"> + <button + onClick={closeModal} + type="button" + className="px-4 py-2 rounded-lg text-white hover:bg-stone-900 transition-all duration-300" + > + Cancel + </button> + <button + type="submit" + className="transition-all duration-300 border border-slate-200 px-4 py-2 rounded-lg text-white text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800 focus:ring-gray-800" + > + Create Folder + </button> + </div> + </form> + </div> + </div> + ); +} diff --git a/frontend/src/components/Modals/ManageWorkspace/Documents/Directory/index.jsx b/frontend/src/components/Modals/ManageWorkspace/Documents/Directory/index.jsx index d479a6cce..c7794a3f9 100644 --- a/frontend/src/components/Modals/ManageWorkspace/Documents/Directory/index.jsx +++ b/frontend/src/components/Modals/ManageWorkspace/Documents/Directory/index.jsx @@ -3,11 +3,16 @@ import PreLoader from "@/components/Preloader"; import { memo, useEffect, useState } from "react"; import FolderRow from "./FolderRow"; import System from "@/models/system"; -import { Plus, Trash } from "@phosphor-icons/react"; +import { MagnifyingGlass, Plus, Trash } from "@phosphor-icons/react"; import Document from "@/models/document"; import showToast from "@/utils/toast"; import FolderSelectionPopup from "./FolderSelectionPopup"; import MoveToFolderIcon from "./MoveToFolderIcon"; +import { useModal } from "@/hooks/useModal"; +import ModalWrapper from "@/components/ModalWrapper"; +import NewFolderModal from "./NewFolderModal"; +import debounce from "lodash.debounce"; +import { filterFileSearchResults } from "./utils"; function Directory({ files, @@ -24,9 +29,13 @@ function Directory({ loadingMessage, }) { const [amountSelected, setAmountSelected] = useState(0); - const [newFolderName, setNewFolderName] = useState(""); - const [showNewFolderInput, setShowNewFolderInput] = useState(false); const [showFolderSelection, setShowFolderSelection] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + const { + isOpen: isFolderModalOpen, + openModal: openFolderModal, + closeModal: closeFolderModal, + } = useModal(); useEffect(() => { setAmountSelected(Object.keys(selectedItems).length); @@ -121,32 +130,6 @@ function Directory({ return !!selectedItems[id]; }; - const createNewFolder = () => { - setShowNewFolderInput(true); - }; - - const confirmNewFolder = async () => { - if (newFolderName.trim() !== "") { - const newFolder = { - name: newFolderName, - type: "folder", - items: [], - }; - - // If folder failed to create - silently fail. - const { success } = await Document.createFolder(newFolderName); - if (success) { - setFiles({ - ...files, - items: [...files.items, newFolder], - }); - } - - setNewFolderName(""); - setShowNewFolderInput(false); - } - }; - const moveToFolder = async (folder) => { const toMove = []; for (const itemId of Object.keys(selectedItems)) { @@ -183,40 +166,39 @@ function Directory({ setLoading(false); }; + const handleSearch = debounce((e) => { + const searchValue = e.target.value; + setSearchTerm(searchValue); + }, 500); + + const filteredFiles = filterFileSearchResults(files, searchTerm); return ( <div className="px-8 pb-8"> <div className="flex flex-col gap-y-6"> <div className="flex items-center justify-between w-[560px] px-5 relative"> <h3 className="text-white text-base font-bold">My Documents</h3> - {showNewFolderInput ? ( - <div className="flex items-center gap-x-2 z-50"> - <input - type="text" - placeholder="Folder name" - value={newFolderName} - onChange={(e) => setNewFolderName(e.target.value)} - className="bg-zinc-900 text-white placeholder-white/20 text-sm rounded-md p-2.5 w-[150px] h-[32px]" - /> - <div className="flex gap-x-2"> - <button - onClick={confirmNewFolder} - className="text-sky-400 rounded-md text-sm font-bold hover:text-sky-500" - > - Create - </button> - </div> + <div className="relative"> + <input + type="search" + placeholder="Search for document" + onChange={handleSearch} + className="search-input bg-zinc-900 text-white placeholder-white/40 text-sm rounded-lg pl-9 pr-2.5 py-2 w-[250px] h-[32px]" + /> + <MagnifyingGlass + size={14} + className="absolute left-3 top-1/2 transform -translate-y-1/2 text-white" + weight="bold" + /> + </div> + <button + className="flex items-center gap-x-2 cursor-pointer px-[14px] py-[7px] -mr-[14px] rounded-lg hover:bg-[#222628]/60" + onClick={openFolderModal} + > + <Plus size={18} weight="bold" color="#D3D4D4" /> + <div className="text-[#D3D4D4] text-xs font-bold leading-[18px]"> + New Folder </div> - ) : ( - <button - className="flex items-center gap-x-2 cursor-pointer px-[14px] py-[7px] -mr-[14px] rounded-lg hover:bg-[#222628]/60" - onClick={createNewFolder} - > - <Plus size={18} weight="bold" color="#D3D4D4" /> - <div className="text-[#D3D4D4] text-xs font-bold leading-[18px]"> - New Folder - </div> - </button> - )} + </button> </div> <div className="relative w-[560px] h-[310px] bg-zinc-900 rounded-2xl overflow-hidden"> @@ -234,8 +216,8 @@ function Directory({ {loadingMessage} </p> </div> - ) : files.items ? ( - files.items.map( + ) : filteredFiles.length > 0 ? ( + filteredFiles.map( (item, index) => item.type === "folder" && ( <FolderRow @@ -302,6 +284,7 @@ function Directory({ </div> )} </div> + <UploadFile workspace={workspace} fetchKeys={fetchKeys} @@ -309,6 +292,14 @@ function Directory({ setLoadingMessage={setLoadingMessage} /> </div> + + <ModalWrapper isOpen={isFolderModalOpen}> + <NewFolderModal + closeModal={closeFolderModal} + files={files} + setFiles={setFiles} + /> + </ModalWrapper> </div> ); } diff --git a/frontend/src/components/Modals/ManageWorkspace/Documents/Directory/utils.js b/frontend/src/components/Modals/ManageWorkspace/Documents/Directory/utils.js new file mode 100644 index 000000000..1bea2615a --- /dev/null +++ b/frontend/src/components/Modals/ManageWorkspace/Documents/Directory/utils.js @@ -0,0 +1,49 @@ +import strDistance from "js-levenshtein"; + +const LEVENSHTEIN_MIN = 8; + +// Regular expression pattern to match the v4 UUID and the ending .json +const uuidPattern = + /-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/; +const jsonPattern = /\.json$/; + +// Function to strip UUID v4 and JSON from file names as that will impact search results. +const stripUuidAndJsonFromString = (input = "") => { + return input + ?.replace(uuidPattern, "") // remove v4 uuid + ?.replace(jsonPattern, "") // remove trailing .json + ?.replace("-", " "); // turn slugged names into spaces +}; + +export function filterFileSearchResults(files = [], searchTerm = "") { + if (!searchTerm) return files?.items || []; + + const searchResult = []; + for (const folder of files?.items) { + // If folder is a good match then add all its children + if (strDistance(folder.name, searchTerm) <= LEVENSHTEIN_MIN) { + searchResult.push(folder); + continue; + } + + // Otherwise check children for good results + const fileSearchResults = []; + for (const file of folder?.items) { + if ( + strDistance(stripUuidAndJsonFromString(file.name), searchTerm) <= + LEVENSHTEIN_MIN + ) { + fileSearchResults.push(file); + } + } + + if (fileSearchResults.length > 0) { + searchResult.push({ + ...folder, + items: fileSearchResults, + }); + } + } + + return searchResult; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 35159b3f1..f3fa95eae 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -742,3 +742,7 @@ does not extend the close button beyond the viewport. */ opacity: 0; } } + +.search-input::-webkit-search-cancel-button { + filter: grayscale(100%) invert(1) brightness(100) opacity(0.5); +} diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 93bdc0884..d5bdc0d6f 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2260,6 +2260,11 @@ jiti@^1.19.1: resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.0.tgz#7c97f8fe045724e136a397f7340475244156105d" integrity sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q== +js-levenshtein@^1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/js-levenshtein/-/js-levenshtein-1.1.6.tgz#c6cee58eb3550372df8deb85fad5ce66ce01d59d" + integrity sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g== + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" -- GitLab