diff --git a/client/src/components/DataTable/index.tsx b/client/src/components/DataTable/index.tsx index c732cf14..8260ba13 100644 --- a/client/src/components/DataTable/index.tsx +++ b/client/src/components/DataTable/index.tsx @@ -16,6 +16,7 @@ import { Fieldset, Field, Input, + ScrollArea, } from "@chakra-ui/react"; import { flexRender, @@ -846,170 +847,170 @@ const DataTable = (props: DataTableProps) => { flexDirection={"column"} css={{ WebkitOverflowScrolling: "touch" }} > - - - {headerGroups.length > 0 && ( - + + + - {headerGroups[0].headers - .filter((header) => header.column.getIsVisible()) - .map((header, headerIndex, visibleHeaders) => { - const isSelectColumn = header.id === "select"; - const isLastColumn = headerIndex === visibleHeaders.length - 1; - const columnWidth = getColumnWidth(header.id, isLastColumn); - const columnMinWidth = getColumnMinWidth(header.id); - const align = getColumnAlign(header.id); - const headerMeta = header.column.columnDef.meta as ColumnMeta | undefined; + {headerGroups.length > 0 && ( + + {headerGroups[0].headers + .filter((header) => header.column.getIsVisible()) + .map((header, headerIndex, visibleHeaders) => { + const isSelectColumn = header.id === "select"; + const isLastColumn = headerIndex === visibleHeaders.length - 1; + const columnWidth = getColumnWidth(header.id, isLastColumn); + const columnMinWidth = getColumnMinWidth(header.id); + const align = getColumnAlign(header.id); + const headerMeta = header.column.columnDef.meta as ColumnMeta | undefined; + return ( + + {isSelectColumn ? ( + flexRender(header.column.columnDef.header, header.getContext()) + ) : ( + + + {flexRender(header.column.columnDef.header, header.getContext())} + + + {canSortColumn(header.id) && ( + + )} + {canSortColumn(header.id) && props.showColumnFilters && ( + columnId={header.id} data={props.data} table={table} /> + )} + + + )} + + ); + })} + + )} + + + {rows.map((row, rowIndex) => { + const isSelected = row.getIsSelected(); + const visibleCells = row.getVisibleCells(); return ( - {isSelectColumn ? ( - flexRender(header.column.columnDef.header, header.getContext()) - ) : ( - - - {flexRender(header.column.columnDef.header, header.getContext())} - - - {canSortColumn(header.id) && ( - - )} - {canSortColumn(header.id) && props.showColumnFilters && ( - columnId={header.id} data={props.data} table={table} /> - )} - - - )} + {visibleCells.map((cell, cellIndex) => { + const isLastCell = cellIndex === visibleCells.length - 1; + const columnWidth = getColumnWidth(cell.column.id, isLastCell); + const columnMinWidth = getColumnMinWidth(cell.column.id); + const align = getColumnAlign(cell.column.id); + const cellMeta = cell.column.columnDef.meta as ColumnMeta | undefined; + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} ); })} - - )} - - - {rows.map((row, rowIndex) => { - const isSelected = row.getIsSelected(); - const visibleCells = row.getVisibleCells(); - return ( - - {visibleCells.map((cell, cellIndex) => { - const isLastCell = cellIndex === visibleCells.length - 1; - const columnWidth = getColumnWidth(cell.column.id, isLastCell); - const columnMinWidth = getColumnMinWidth(cell.column.id); - const align = getColumnAlign(cell.column.id); - const cellMeta = cell.column.columnDef.meta as ColumnMeta | undefined; - return ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ); - })} - - ); - })} - - - + + + + + + + + {/* */} diff --git a/client/src/components/Icon/index.tsx b/client/src/components/Icon/index.tsx index 3852e41d..956366f2 100644 --- a/client/src/components/Icon/index.tsx +++ b/client/src/components/Icon/index.tsx @@ -40,6 +40,7 @@ import { BsEye, BsEyeSlash, BsFileCodeFill, + BsFileEarmarkRichtextFill, BsFileTextFill, BsFillBookFill, BsFillBookmarkFill, @@ -110,6 +111,7 @@ export const SYSTEM_ICONS: Record = { counter: BsCalculator, close: BsXLg, info: BsInfoCircleFill, + file: BsFileEarmarkRichtextFill, search: BsSearch, search_query: BsBraces, bell: BsBellFill, diff --git a/client/src/components/ImportDialog/index.tsx b/client/src/components/ImportDialog/index.tsx index 43d4a8de..dda8f0eb 100644 --- a/client/src/components/ImportDialog/index.tsx +++ b/client/src/components/ImportDialog/index.tsx @@ -1,5 +1,5 @@ // React -import React, { ChangeEvent, useEffect, useState } from "react"; +import React, { useEffect, useState } from "react"; // Existing and custom components import { @@ -17,6 +17,8 @@ import { createListCollection, Steps, CloseButton, + FileUpload, + useFileUpload, } from "@chakra-ui/react"; import { createColumnHelper } from "@tanstack/react-table"; import ActorTag from "@components/ActorTag"; @@ -28,6 +30,7 @@ import DataTable from "@components/DataTable"; import Icon from "@components/Icon"; import Tooltip from "@components/Tooltip"; import { toaster } from "@components/Toast"; +import FileUploadList from "@components/UploadList"; // Custom and existing types import { @@ -51,7 +54,7 @@ import { gql } from "@apollo/client"; import { useLazyQuery, useMutation } from "@apollo/client/react"; // Utility functions and libraries -import { removeTypename, isValidValues, getValueTypeIconProps } from "@lib/util"; +import { removeTypename, isValidValues, getValueTypeIconProps, getFileExtension } from "@lib/util"; import _ from "lodash"; import dayjs from "dayjs"; @@ -62,7 +65,7 @@ import { auth } from "@lib/auth"; import { usePostHog } from "posthog-js/react"; // Variables -import { GLOBAL_STYLES } from "@variables"; +import { ACCEPTED_IMPORTS_ENTITIES, ACCEPTED_IMPORTS_TEMPLATES, GLOBAL_STYLES } from "@variables"; // Hooks import { useFeatures } from "@hooks/useFeatures"; @@ -198,11 +201,6 @@ const ImportDialog = (props: ImportDialogProps) => { const posthog = usePostHog(); const { features } = useFeatures(); - // File states - const [file, setFile] = useState({} as File); - const [fileType, setFileType] = useState(CSV_MIME_TYPE); - const [fileName, setFileName] = useState(""); - // Operation and button states const [importLoading, setImportLoading] = useState(false); const [continueDisabled, setContinueDisabled] = useState(true); @@ -214,6 +212,32 @@ const ImportDialog = (props: ImportDialogProps) => { const [importTypeSelected, setImportTypeSelected] = useState(false); const [isTypeSelectDisabled, setIsTypeSelectDisabled] = useState(false); + // File states, kept in sync with `fileUpload` below so the rest of the dialog can react to it + const [fileType, setFileType] = useState(""); + const [fileName, setFileName] = useState(""); + + const fileUpload = useFileUpload({ + maxFiles: 1, + maxFileSize: 10 * 1024 * 1024, + accept: importType === "entities" ? ACCEPTED_IMPORTS_ENTITIES : ACCEPTED_IMPORTS_TEMPLATES, + // No file contents type selected yet, so the dropzone shouldn't accept anything + disabled: _.isUndefined(importType), + onFileChange: (details) => { + const file = details.acceptedFiles[0] as File | undefined; + setFileName(file?.name ?? ""); + setFileType(file?.type ?? ""); + }, + }); + + /** Swaps between the Entity and Template upload contexts, discarding any file picked under the old type. */ + const selectImportType = (type: "entities" | "template") => { + if (isTypeSelectDisabled) return; + + fileUpload.clearFiles(); + setImportType(type); + setImportTypeSelected(true); + }; + // State management to generate and present different pages const [entityInterfacePage, setEntityInterfacePage] = useState( "upload" as "upload" | "details" | "mapping" | "review", @@ -435,10 +459,11 @@ const ImportDialog = (props: ImportDialogProps) => { }), ]; - // Effect to manipulate 'Continue' button state for 'upload' page + // Effect to manipulate 'Continue' button state for 'upload' page, also re-disabling it + // if the file is removed after being accepted useEffect(() => { - if (_.isEqual(entityInterfacePage, "upload") && fileName !== "" && importTypeSelected) { - setContinueDisabled(false); + if (_.isEqual(entityInterfacePage, "upload")) { + setContinueDisabled(!(fileName !== "" && importTypeSelected)); } }, [fileName, importTypeSelected]); @@ -536,7 +561,7 @@ const ImportDialog = (props: ImportDialogProps) => { if (fileType === JSON_MIME_TYPE) { // Handle JSON data separately setImportLoading(true); - const data = await parseJSONFile(file); + const data = await parseJSONFile(fileUpload.acceptedFiles[0]); setImportLoading(false); // Validate the JSON data @@ -546,7 +571,7 @@ const ImportDialog = (props: ImportDialogProps) => { setImportLoading(true); const response = await prepareEntityCSV({ variables: { - file: file, + file: fileUpload.acceptedFiles[0], }, }); setImportLoading(false); @@ -634,7 +659,7 @@ const ImportDialog = (props: ImportDialogProps) => { setImportLoading(true); const response = await reviewEntityJSON({ variables: { - file: file, + file: fileUpload.acceptedFiles[0], }, }); setImportLoading(false); @@ -673,7 +698,7 @@ const ImportDialog = (props: ImportDialogProps) => { const reviewResponse = await reviewEntityCSV({ variables: { columnMapping: removeTypename(columnMapping), - file: file, + file: fileUpload.acceptedFiles[0], }, }); setImportLoading(false); @@ -730,7 +755,7 @@ const ImportDialog = (props: ImportDialogProps) => { setImportLoading(true); const response = await reviewTemplateJSON({ variables: { - file: file, + file: fileUpload.acceptedFiles[0], }, }); setImportLoading(false); @@ -755,7 +780,7 @@ const ImportDialog = (props: ImportDialogProps) => { setImportLoading(true); const response = await importEntityJSON({ variables: { - file: file, + file: fileUpload.acceptedFiles[0], project: projectField, attributes: removeTypename(attributesField), }, @@ -789,7 +814,7 @@ const ImportDialog = (props: ImportDialogProps) => { variables: { columnMapping: removeTypename(columnMapping), options: removeTypename(options), - file: file, + file: fileUpload.acceptedFiles[0], }, }); setImportLoading(false); @@ -813,7 +838,7 @@ const ImportDialog = (props: ImportDialogProps) => { setImportLoading(true); await importTemplateJSON({ variables: { - file: file, + file: fileUpload.acceptedFiles[0], }, }); setImportLoading(false); @@ -1148,7 +1173,7 @@ const ImportDialog = (props: ImportDialogProps) => { setImportLoading(false); setIsTypeSelectDisabled(false); - setFile({} as File); + fileUpload.clearFiles(); setFileType(""); setFileName(""); @@ -1312,12 +1337,7 @@ const ImportDialog = (props: ImportDialogProps) => { flex={"1"} variant={importType === type ? "solid" : "outline"} colorPalette={importType === type ? "blue" : "gray"} - onClick={() => { - if (!isTypeSelectDisabled) { - setImportType(type); - setImportTypeSelected(true); - } - }} + onClick={() => selectImportType(type)} disabled={isTypeSelectDisabled} data-testid={`import-type-select-trigger-${type}`} > @@ -1390,119 +1410,86 @@ const ImportDialog = (props: ImportDialogProps) => { - - {/* Condition 1: File type not specified */} - {_.isUndefined(importType) && ( - - - - - - - Select File Contents - - - )} - - {/* Condition 2: File type specified, no file uploaded */} - {_.isEqual(file, {}) && !_.isUndefined(importType) && ( - - - - - Click to upload {_.capitalize(importType)} file - - - or drag and drop - - - {(importType === "entities" ? ["JSON", "CSV", "XLSX"] : ["JSON"]).map((fmt) => ( - - {fmt} - - ))} + + + + + {/* Condition 1: File type not specified */} + {_.isUndefined(importType) && ( + + + + + + + Select File Contents + - - - )} - - {/* Condition 3: File type specified, file uploaded */} - {!_.isEqual(file, {}) && !_.isUndefined(importType) && ( - - - - {file.name} - - - )} - + )} - ) => { - if (event.target.files && event.target.files.length > 0) { - // Only accept defined file types - if ( - _.includes([CSV_MIME_TYPE, XLSX_MIME_TYPE, JSON_MIME_TYPE], event.target.files[0].type) - ) { - // Capture event - posthog.capture("client.import.upload_file", { - importType: importType, - fileName: event.target.files[0].name, - }); - - setFileName(event.target.files[0].name); - setFileType(event.target.files[0].type); - setFile(event.target.files[0]); - } else { - toaster.create({ - title: "Warning", - type: "warning", - description: "Please upload a JSON, CSV, or XLSX file", - duration: 2000, - closable: true, - }); - } - } - }} - /> + {/* Condition 2: File type specified, no file uploaded */} + {fileUpload.acceptedFiles.length === 0 && !_.isUndefined(importType) && ( + + + + + Click to upload {_.capitalize(importType)} file + + + or drag and drop + + + + {(importType === "entities" + ? ACCEPTED_IMPORTS_ENTITIES + : ACCEPTED_IMPORTS_TEMPLATES + ).map((format) => { + return ( + + {getFileExtension(format)} + + ); + })} + + + + + )} + + {/* Condition 3: File type specified, file uploaded */} + {fileUpload.acceptedFiles.length > 0 && !_.isUndefined(importType) && ( + + + + {fileUpload.acceptedFiles.length > 0 && fileUpload.acceptedFiles[0].name} + + + )} + + + + diff --git a/client/src/components/Linky/index.tsx b/client/src/components/Linky/index.tsx index 831a84be..04f67535 100644 --- a/client/src/components/Linky/index.tsx +++ b/client/src/components/Linky/index.tsx @@ -152,7 +152,7 @@ const Linky = (props: LinkyProps) => { if (response.error || _.isUndefined(response.data)) { setShowDeleted(true); data.name = "Invalid Template"; - setTooltipLabel(`The Template (ID: ${props.id}) is either inaccessible or does not exist.`); + setTooltipLabel(`Template (ID: ${props.id}) is either inaccessible or does not exist.`); } else { data.name = response.data.template.name; setTooltipLabel(data.name); @@ -165,7 +165,7 @@ const Linky = (props: LinkyProps) => { if (response.error || _.isUndefined(response.data)) { setShowDeleted(true); data.name = "Invalid Entity"; - setTooltipLabel(`The Entity (ID: ${props.id}) is either inaccessible or does not exist.`); + setTooltipLabel(`Entity (ID: ${props.id}) is either inaccessible or does not exist.`); } else { data.name = response.data.entity.name; setTooltipLabel(data.name); @@ -178,7 +178,7 @@ const Linky = (props: LinkyProps) => { if (response.error || _.isUndefined(response.data)) { setShowDeleted(true); data.name = "Invalid Project"; - setTooltipLabel(`The Project (ID: ${props.id}) is either inaccessible or does not exist.`); + setTooltipLabel(`Project (ID: ${props.id}) is either inaccessible or does not exist.`); } else { data.name = response.data.project.name; setTooltipLabel(data.name); @@ -190,7 +190,7 @@ const Linky = (props: LinkyProps) => { } catch (error) { // If query fails completely, use fallback setShowDeleted(true); - const tooltipLabel = `The ${_.capitalize(props.type.slice(0, -1))} (ID: ${props.id}) is either inaccessible or does not exist.`; + const tooltipLabel = `${_.capitalize(props.type.slice(0, -1))} (ID: ${props.id}) is either inaccessible or does not exist.`; setTooltipLabel(tooltipLabel); } diff --git a/client/src/components/Navigation/index.tsx b/client/src/components/Navigation/index.tsx index 6d3d28b3..3d4b4941 100644 --- a/client/src/components/Navigation/index.tsx +++ b/client/src/components/Navigation/index.tsx @@ -274,7 +274,7 @@ const Navigation = () => { {/* Version number */} - v{import.meta.env.VERSION} + v{import.meta.env.VITE_VERSION} @@ -398,7 +398,7 @@ const Navigation = () => { {/* Version number */} - v{import.meta.env.VERSION} + v{import.meta.env.VITE_VERSION} diff --git a/client/src/components/UploadDialog/index.tsx b/client/src/components/UploadDialog/index.tsx index 9a6e5799..e424e0d6 100644 --- a/client/src/components/UploadDialog/index.tsx +++ b/client/src/components/UploadDialog/index.tsx @@ -1,13 +1,15 @@ // React -import React, { ChangeEvent, useEffect, useRef, useState } from "react"; +import React, { useState } from "react"; // Existing and custom components -import { Button, Dialog, CloseButton, Fieldset, Flex, Input, Tag, Text } from "@chakra-ui/react"; +import { Button, Dialog, CloseButton, Fieldset, Flex, Text, FileUpload, Tag, useFileUpload } from "@chakra-ui/react"; import Error from "@components/Error"; import Icon from "@components/Icon"; import { toaster } from "@components/Toast"; +import FileUploadList from "@components/UploadList"; // Utility functions and libraries +import { getFileExtension } from "@lib/util"; import _ from "lodash"; import { gql } from "@apollo/client"; import { useMutation } from "@apollo/client/react"; @@ -16,7 +18,7 @@ import { useMutation } from "@apollo/client/react"; import { ResponseData } from "@types"; // Variables -import { GLOBAL_STYLES } from "@variables"; +import { ACCEPTED_ATTACHMENTS, GLOBAL_STYLES } from "@variables"; const UploadDialog = (props: { open: boolean; @@ -26,12 +28,14 @@ const UploadDialog = (props: { setUploads: React.Dispatch>; onUploadSuccess?: () => void; }) => { - const [file, setFile] = useState({} as File); - const [displayName, setDisplayName] = useState(""); - const [displayType, setDisplayType] = useState(""); + const fileUpload = useFileUpload({ + maxFiles: 1, + maxFileSize: 10 * 1024 * 1024, + accept: ACCEPTED_ATTACHMENTS, + }); + const [isLoaded, setIsLoaded] = useState(true); const [isError, setIsError] = useState(false); - const fileInputRef = useRef(null); const UPLOAD_ATTACHMENT = gql` mutation UploadAttachment($target: String!, $file: Upload!) { @@ -46,25 +50,14 @@ const UploadDialog = (props: { uploadAttachment: ResponseData; }>(UPLOAD_ATTACHMENT); - // Update display name and type when file changes - useEffect(() => { - if (!_.isEqual(file, {})) { - setDisplayName(file.name); - setDisplayType(file.type); - } else { - setDisplayName(""); - setDisplayType(""); - } - }, [file]); - const performUpload = async () => { - if (_.isEqual(file, {})) return; + if (_.isUndefined(fileUpload.acceptedFiles)) return; try { const response = await uploadAttachment({ variables: { target: props.target, - file: file, + file: fileUpload.acceptedFiles[0], }, }); @@ -85,7 +78,6 @@ const UploadDialog = (props: { props.setUploads([...props.uploads, response.data?.uploadAttachment.data]); // Reset file upload state - setFile({} as File); props.setOpen(false); // Update state @@ -116,40 +108,6 @@ const UploadDialog = (props: { } }; - const handleDropZoneClick = () => { - fileInputRef.current?.click(); - }; - - const handleDragOver = (event: React.DragEvent) => { - event.preventDefault(); - event.stopPropagation(); - }; - - const handleDrop = (event: React.DragEvent) => { - event.preventDefault(); - event.stopPropagation(); - - if (event.dataTransfer.files && event.dataTransfer.files.length > 0) { - const droppedFile = event.dataTransfer.files[0]; - - // Only accept image or PDF files - if ( - _.includes(["image/jpeg", "image/png", "application/pdf"], droppedFile.type) || - _.endsWith(droppedFile.name, ".dna") - ) { - setFile(droppedFile); - } else { - toaster.create({ - title: "Warning", - type: "warning", - description: "Please upload an image (JPEG, PNG), PDF file, or sequence file (DNA)", - duration: 4000, - closable: true, - }); - } - } - }; - return ( <> {isLoaded && isError ? ( @@ -159,14 +117,9 @@ const UploadDialog = (props: { open={props.open} onOpenChange={(details) => { props.setOpen(details.open); - if (!details.open) { - setFile({} as File); - setDisplayName(""); - setDisplayType(""); - } }} placement={"center"} - size={"xl"} + size={"lg"} scrollBehavior={"inside"} closeOnEscape closeOnInteractOutside @@ -192,90 +145,37 @@ const UploadDialog = (props: { - - {_.isEqual(file, {}) ? ( - - + + + + + - - Click to upload file + + Click to upload attachment or drag and drop - {["PDF", "JPEG", "PNG", "DNA"].map((fmt) => ( - - {fmt} - - ))} + {ACCEPTED_ATTACHMENTS.map((format) => { + return ( + + {getFileExtension(format)} + + ); + })} - - ) : ( - - - - - {displayName} - - - {displayType} - - - - )} - - ) => { - if (event.target.files && event.target.files.length > 0) { - // Only accept image or PDF files - if ( - _.includes( - ["image/jpeg", "image/png", "application/pdf"], - event.target.files[0].type, - ) || - _.endsWith(event.target.files[0].name, ".dna") - ) { - setFile(event.target.files[0]); - } else { - toaster.create({ - title: "Warning", - type: "warning", - description: "Please upload an image (JPEG, PNG), PDF file, or sequence file (DNA)", - duration: 4000, - closable: true, - }); - } - } - }} - /> + + + + @@ -290,7 +190,6 @@ const UploadDialog = (props: { colorPalette={"red"} variant={"solid"} onClick={() => { - setFile({} as File); props.setOpen(false); }} > @@ -302,7 +201,7 @@ const UploadDialog = (props: { rounded={"md"} colorPalette={"green"} variant={"solid"} - disabled={_.isEqual(file, {}) || loading} + disabled={fileUpload.acceptedFiles.length === 0 || loading} onClick={() => performUpload()} loading={loading} > diff --git a/client/src/components/UploadList/index.tsx b/client/src/components/UploadList/index.tsx new file mode 100644 index 00000000..00620180 --- /dev/null +++ b/client/src/components/UploadList/index.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { FileUpload, Flex, FormatByte, IconButton, Spacer, Tag, Text, useFileUploadContext } from "@chakra-ui/react"; + +// Custom components +import Icon from "@components/Icon"; + +// Utility functions +import { getFileExtension } from "@lib/util"; + +/** + * Custom `FileUploadList` component to better render the collection of uploaded files + */ +const FileUploadList = () => { + const fileUpload = useFileUploadContext(); + const files = fileUpload.acceptedFiles; + + if (files.length === 0) { + return null; + } + + return ( + + {files.map((file: File) => ( + + + {/* File information */} + + + + {file.name} + + + + + {getFileExtension(file.type)} + + + + + + + + + + {/* `IconButton` to remove uploaded file */} + + + + + + + ))} + + ); +}; + +export default FileUploadList; diff --git a/client/src/components/WorkspaceSwitcher/index.tsx b/client/src/components/WorkspaceSwitcher/index.tsx index c26f1ff0..f84dbcad 100644 --- a/client/src/components/WorkspaceSwitcher/index.tsx +++ b/client/src/components/WorkspaceSwitcher/index.tsx @@ -197,7 +197,7 @@ const WorkspaceSwitcher = (props: { id?: string }) => { variant={"surface"} onClick={() => setOpen(!open)} > - + {_.truncate(label, { length: 18 })} @@ -211,20 +211,35 @@ const WorkspaceSwitcher = (props: { id?: string }) => { + + + + + Available Workspaces + + + {/* Create a list of all Workspaces the user has access to */} {workspaces.length > 0 ? ( workspaces.map((accessible) => { return ( handleWorkspaceClick(accessible)} + onClick={() => { + if (workspace !== accessible._id) { + handleWorkspaceClick(accessible); + } + }} key={"w_" + accessible._id} + cursor={workspace !== accessible._id ? "pointer" : undefined} > - - - {_.truncate(accessible.name, { length: 24 })} - + + {_.truncate(accessible.name, { length: 24 })} @@ -256,13 +271,18 @@ const WorkspaceSwitcher = (props: { id?: string }) => { {/* Option to create a new Workspace */} - handleUpdateClick()} disabled={workspaces.length === 0}> + handleUpdateClick()} + disabled={workspaces.length === 0} + cursor={"pointer"} + > Edit workspace - handleCreateClick()}> + handleCreateClick()} cursor={"pointer"}> Create workspace @@ -273,7 +293,7 @@ const WorkspaceSwitcher = (props: { id?: string }) => { - handleProfileClick()}> + handleProfileClick()} cursor={"pointer"}> Account settings @@ -285,6 +305,7 @@ const WorkspaceSwitcher = (props: { id?: string }) => { value={"admin"} fontSize={"xs"} onClick={() => navigate("/admin")} + cursor={"pointer"} > @@ -292,7 +313,7 @@ const WorkspaceSwitcher = (props: { id?: string }) => { )} - handleLogoutClick()}> + handleLogoutClick()} cursor={"pointer"}> Log out diff --git a/client/src/lib/util.ts b/client/src/lib/util.ts index 83099012..51b3556c 100644 --- a/client/src/lib/util.ts +++ b/client/src/lib/util.ts @@ -16,6 +16,9 @@ import { // Utility functions import dayjs from "dayjs"; +// Variables +import { ACCEPTED_ATTACHMENTS, ACCEPTED_IMPORTS_ENTITIES, ACCEPTED_IMPORTS_TEMPLATES } from "@variables"; + export const isValidValues = (values: IValue[], allowEmptyValues = false) => { if (values.length === 0) { return false; @@ -96,6 +99,35 @@ export const isValidEmail = (email: string): boolean => { return emailRegex.test(email); }; +/** + * Get the 3- or 4-letter file extension for display based on a file's MIME type + * @param {string} mimeType File MIME type + */ +export const getFileExtension = (mimeType: string): string => { + if (ACCEPTED_ATTACHMENTS.includes(mimeType)) { + // Handle attachment files + if (mimeType.endsWith(".dna")) { + // Handle unique MIME type for DNA files + return "DNA"; + } else { + return _.upperCase(mimeType.split("/")[1]); + } + } else if (ACCEPTED_IMPORTS_ENTITIES.includes(mimeType)) { + // Handle imported Entities files + if (mimeType.endsWith(".sheet")) { + // Handle unique MIME type for Excel files + return "XLSX"; + } else { + return _.upperCase(mimeType.split("/")[1]); + } + } else if (ACCEPTED_IMPORTS_TEMPLATES.includes(mimeType)) { + // Handle imported Templates files + return _.upperCase(mimeType.split("/")[1]); + } else { + return "UNKNOWN"; + } +}; + /** * Generate a collection of `ISelectOption` objects from a collection of objects, enabling the * Chakra UI `Select` component to be populated with options correctly. diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx index 6ba3c62c..38a3f1c6 100644 --- a/client/src/pages/Dashboard.tsx +++ b/client/src/pages/Dashboard.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from "react"; // Existing and custom components -import { Button, Flex, Heading, Text, Tag, Badge, EmptyState } from "@chakra-ui/react"; +import { Button, Flex, Heading, Text, Tag, Badge, EmptyState, SkeletonText } from "@chakra-ui/react"; import { createColumnHelper, ColumnFiltersState } from "@tanstack/react-table"; import { Content } from "@components/Container"; import DataTable from "@components/DataTable"; @@ -16,7 +16,15 @@ import SearchBox from "@components/SearchBox"; import ActivityFeed from "@components/ActivityFeed"; // Existing and custom types -import { ProjectModel, EntityModel, EntityMetrics, ProjectMetrics, TemplateMetrics, WorkspaceMetrics } from "@types"; +import { + ProjectModel, + EntityModel, + EntityMetrics, + ProjectMetrics, + TemplateMetrics, + WorkspaceMetrics, + IGenericItem, +} from "@types"; // Utility functions and libraries import dayjs from "dayjs"; @@ -84,6 +92,10 @@ const GET_DASHBOARD = gql` } total } + workspace(_id: $workspace) { + _id + name + } entityMetrics { all addedDay @@ -130,6 +142,9 @@ const Dashboard = () => { [] as { _id: string; name: string; description: string; created: string }[], ); + // Display state + const [workspaceName, setWorkspaceName] = useState(); + // Metrics const [entityMetrics, setEntityMetrics] = useState({} as EntityMetrics); const [projectMetrics, setProjectMetrics] = useState({} as ProjectMetrics); @@ -162,6 +177,7 @@ const Dashboard = () => { projects: ProjectModel[]; projectMetrics: ProjectMetrics; entities: { entities: EntityModel[]; total: number }; + workspace: IGenericItem; entityMetrics: EntityMetrics; templateMetrics: TemplateMetrics; workspaceMetrics: WorkspaceMetrics; @@ -185,6 +201,11 @@ const Dashboard = () => { setProjectData(data.projects); } + // Display state + if (data?.workspace) { + setWorkspaceName(data.workspace.name); + } + // Metrics if (data?.entityMetrics) { setEntityMetrics(data.entityMetrics); @@ -475,11 +496,19 @@ const Dashboard = () => { )} {/* Header */} - - - - Dashboard + + + + + Dashboard + + + + {workspaceName} + + + diff --git a/client/src/pages/Search.tsx b/client/src/pages/Search.tsx index 7ffaa09f..12b4d37b 100644 --- a/client/src/pages/Search.tsx +++ b/client/src/pages/Search.tsx @@ -15,6 +15,7 @@ import { Checkbox, Collapsible, InputGroup, + SkeletonText, } from "@chakra-ui/react"; import ActorTag from "@components/ActorTag"; import { Content } from "@components/Container"; @@ -27,9 +28,10 @@ import { toaster } from "@components/Toast"; // Custom hooks import { useBreakpoint } from "@hooks/useBreakpoint"; import { useFeatures } from "@hooks/useFeatures"; +import { useWorkspace } from "@hooks/useWorkspace"; // Existing and custom types -import { EntityModel, DataTableAction, SearchQuery } from "@types"; +import { EntityModel, DataTableAction, SearchQuery, IGenericItem } from "@types"; // Utility functions and libraries import _ from "lodash"; @@ -40,7 +42,7 @@ import { useNavigate } from "react-router-dom"; // GraphQL imports import { gql } from "@apollo/client"; -import { useLazyQuery } from "@apollo/client/react"; +import { useLazyQuery, useQuery } from "@apollo/client/react"; // Utility libraries and functions import { buildMongoQuery, ignoreAbort } from "@lib/util"; @@ -59,6 +61,9 @@ const Search = () => { const posthog = usePostHog(); const { features } = useFeatures(); + const { workspace } = useWorkspace(); + const [workspaceName, setWorkspaceName] = useState(""); + // Search status const [hasSearched, setHasSearched] = useState(false); const [isSearching, setIsSearching] = useState(false); @@ -97,6 +102,27 @@ const Search = () => { // Active filter count for text search filters const [activeFilterCount, setActiveFilterCount] = useState(0); + // Query to get the Workspace name on load + const SEARCH_PAGE_LOAD = gql` + query SearchPageLoad($workspace: String) { + workspace(_id: $workspace) { + _id + name + } + } + `; + const { data, loading } = useQuery<{ workspace: IGenericItem }>(SEARCH_PAGE_LOAD, { + variables: { + workspace: workspace, + }, + }); + + useEffect(() => { + if (data?.workspace) { + setWorkspaceName(data.workspace.name); + } + }, []); + // Query to search by text value const SEARCH_TEXT = gql` query Search( @@ -569,11 +595,16 @@ const Search = () => { return ( - - + + - Search + Search + + + {workspaceName} + + diff --git a/client/src/pages/account/Login.tsx b/client/src/pages/account/Login.tsx index bb5e7aea..1d6519bb 100644 --- a/client/src/pages/account/Login.tsx +++ b/client/src/pages/account/Login.tsx @@ -279,7 +279,7 @@ const Login = () => { - v{import.meta.env.VERSION} + v{import.meta.env.VITE_VERSION} diff --git a/client/src/pages/view/Activity.tsx b/client/src/pages/view/Activity.tsx index 64df27c0..2f8b1d90 100644 --- a/client/src/pages/view/Activity.tsx +++ b/client/src/pages/view/Activity.tsx @@ -14,6 +14,8 @@ import { Input, Field, Collapsible, + SkeletonText, + Separator, } from "@chakra-ui/react"; import ActorTag from "@components/ActorTag"; import { Content } from "@components/Container"; @@ -24,10 +26,11 @@ import ActivityGraph from "@components/ActivityGraph"; import { createColumnHelper, ColumnFiltersState } from "@tanstack/react-table"; // Existing and custom types -import { ActivityModel } from "@types"; +import { ActivityModel, IGenericItem } from "@types"; // Context and hooks import { useBreakpoint } from "@hooks/useBreakpoint"; +import { useWorkspace } from "@hooks/useWorkspace"; // Utility functions and libraries import { gql } from "@apollo/client"; @@ -43,6 +46,9 @@ dayjs.extend(isSameOrBefore); import { GLOBAL_STYLES } from "@variables"; const Activity = () => { + const { workspace } = useWorkspace(); + const [workspaceName, setWorkspaceName] = useState(""); + const [activityData, setActivityData] = useState([] as ActivityModel[]); const [initialLoaded, setInitialLoaded] = useState(false); @@ -93,7 +99,7 @@ const Activity = () => { // Query to retrieve Activity const GET_ACTIVITY = gql` - query GetActivity($limit: Int) { + query GetActivity($limit: Int, $workspace: String) { activity(limit: $limit) { _id timestamp @@ -107,13 +113,19 @@ const Activity = () => { type } } + workspace(_id: $workspace) { + _id + name + } } `; const { loading, error, data } = useQuery<{ activity: ActivityModel[]; + workspace: IGenericItem; }>(GET_ACTIVITY, { variables: { limit: 10000, // High limit to get all activity + workspace: workspace, }, fetchPolicy: "network-only", pollInterval: 5000, // Poll every 5 seconds to refresh activity @@ -126,6 +138,9 @@ const Activity = () => { setFilteredActivityData(data.activity); setInitialLoaded(true); } + if (data?.workspace) { + setWorkspaceName(data.workspace.name); + } }, [data]); // Apply filters to activity data @@ -279,11 +294,16 @@ const Activity = () => { return ( - - + + - Workspace Activity + Activity + + + {workspaceName} + + @@ -386,6 +406,8 @@ const Activity = () => { + + {/* Checkbox Filters Group */} diff --git a/client/src/pages/view/Entities.tsx b/client/src/pages/view/Entities.tsx index 7a494eeb..3cc89d11 100644 --- a/client/src/pages/view/Entities.tsx +++ b/client/src/pages/view/Entities.tsx @@ -15,6 +15,8 @@ import { Input, Checkbox, Collapsible, + SkeletonText, + Separator, } from "@chakra-ui/react"; import ActorTag from "@components/ActorTag"; import { Content } from "@components/Container"; @@ -25,13 +27,14 @@ import DataTable from "@components/DataTable"; import { createColumnHelper, ColumnFiltersState } from "@tanstack/react-table"; // Existing and custom types -import { DataTableAction, EntityModel } from "@types"; +import { DataTableAction, EntityModel, IGenericItem } from "@types"; // Routing and navigation import { useNavigate } from "react-router-dom"; // Context and hooks import { useBreakpoint } from "@hooks/useBreakpoint"; +import { useWorkspace } from "@hooks/useWorkspace"; // GraphQL imports import { gql } from "@apollo/client"; @@ -51,6 +54,9 @@ import { GLOBAL_STYLES } from "@variables"; const Entities = () => { const navigate = useNavigate(); + const { workspace } = useWorkspace(); + const [workspaceName, setWorkspaceName] = useState(""); + const [entityData, setEntityData] = useState([] as EntityModel[]); const { breakpoint } = useBreakpoint(); @@ -101,7 +107,13 @@ const Entities = () => { // Query to retrieve Entities const GET_ENTITIES = gql` - query GetEntities($page: Int, $pageSize: Int, $filter: EntityFilterInput, $sort: EntitySortInput) { + query GetEntities( + $page: Int + $pageSize: Int + $filter: EntityFilterInput + $sort: EntitySortInput + $workspace: String + ) { entities(page: $page, pageSize: $pageSize, filter: $filter, sort: $sort) { entities { _id @@ -125,6 +137,10 @@ const Entities = () => { } total } + workspace(_id: $workspace) { + _id + name + } } `; // Build filter object (only include non-empty values) @@ -151,9 +167,11 @@ const Entities = () => { const { loading, error, data } = useQuery<{ entities: { entities: EntityModel[]; total: number }; + workspace: IGenericItem; }>(GET_ENTITIES, { fetchPolicy: "network-only", variables: { + workspace, page, pageSize, filter: filterVariables, @@ -167,6 +185,10 @@ const Entities = () => { // Set the paginated Entity data (already filtered and sorted on server) setEntityData(data.entities.entities); } + if (data?.workspace) { + // Store the Workspace name + setWorkspaceName(data.workspace.name); + } }, [data]); // Calculate active filter count @@ -322,8 +344,17 @@ const Entities = () => { - - Entities + + + + Entities + + + + {workspaceName} + + +