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}
+
+
+
+
+
{/* Owner Filter */}
-
+
Owner
- {_.uniq(entityData.map((e) => e.owner))
- .filter((owner) => owner)
- .map((owner) => (
- {
- const isChecked = details.checked as boolean;
- if (isChecked) {
- setFilterState({
- ...filterState,
- owners: [...filterState.owners, owner],
- });
- } else {
- setFilterState({
- ...filterState,
- owners: filterState.owners.filter((o) => o !== owner),
- });
- }
- }}
- >
-
-
-
-
-
-
- ))}
+ {entityData.length > 0 &&
+ _.uniq(entityData.map((e) => e.owner))
+ .filter((owner) => owner)
+ .map((owner) => (
+ {
+ const isChecked = details.checked as boolean;
+ if (isChecked) {
+ setFilterState({
+ ...filterState,
+ owners: [...filterState.owners, owner],
+ });
+ } else {
+ setFilterState({
+ ...filterState,
+ owners: filterState.owners.filter((o) => o !== owner),
+ });
+ }
+ }}
+ >
+
+
+
+
+
+
+ ))}
+
+ {entityData.length === 0 && (
+
+ No Entity Owners
+
+ )}
+
+
{/* Has Attachments Filter */}
-
+
Attachments
{
+
+
{/* Attribute Count Range Filter */}
-
+
Attribute Count
diff --git a/client/src/pages/view/Entity.tsx b/client/src/pages/view/Entity.tsx
index 774984a6..4a5da799 100644
--- a/client/src/pages/view/Entity.tsx
+++ b/client/src/pages/view/Entity.tsx
@@ -26,6 +26,7 @@ import {
Collapsible,
Textarea,
Breadcrumb,
+ SkeletonText,
} from "@chakra-ui/react";
import ActorTag from "@components/ActorTag";
import { Content } from "@components/Container";
@@ -80,6 +81,7 @@ import { useParams, useNavigate, useBlocker } from "react-router-dom";
// Contexts and hooks
import { useBreakpoint } from "@hooks/useBreakpoint";
+import { useWorkspace } from "@hooks/useWorkspace";
// Authentication
import { auth } from "@lib/auth";
@@ -103,6 +105,10 @@ const Entity = () => {
const { onClose: onBlockerClose } = useDisclosure();
const cancelBlockerRef = useRef(null);
+ // Workspace information
+ const { workspace } = useWorkspace();
+ const [workspaceName, setWorkspaceName] = useState("");
+
// Graph dialog
const [graphOpen, setGraphOpen] = useState(false);
@@ -175,7 +181,7 @@ const Entity = () => {
// Query to retrieve Entity data and associated data for editing
const GET_ENTITY = gql`
- query GetEntityData($_id: String) {
+ query GetEntityData($_id: String, $workspace: String) {
entity(_id: $_id) {
_id
name
@@ -267,15 +273,21 @@ const Entity = () => {
data
}
}
+ workspace(_id: $workspace) {
+ _id
+ name
+ }
}
`;
const { loading, error, data, refetch } = useQuery<{
entity: EntityModel;
projects: IGenericItem[];
templates: AttributeModel[];
+ workspace: IGenericItem;
}>(GET_ENTITY, {
variables: {
_id: id,
+ workspace: workspace,
},
fetchPolicy: "no-cache",
});
@@ -365,10 +377,16 @@ const Entity = () => {
// Set the cloned Entity name
setClonedEntityName(`${data.entity.name} (cloned)`);
}
+
// Unpack Template data
if (data?.templates) {
setTemplates(data.templates);
}
+
+ // Store Workspace information
+ if (data?.workspace) {
+ setWorkspaceName(data.workspace.name);
+ }
}, [data, editing]);
useEffect(() => {
@@ -1220,13 +1238,19 @@ const Entity = () => {
navigate("/")}
_hover={{
cursor: "pointer",
textDecoration: "underline",
}}
>
- Dashboard
+
+ {loading ? (
+
+ ) : (
+ workspaceName
+ )}
{
const { id } = useParams();
+ // Workspace information
+ const { workspace } = useWorkspace();
+ const [workspaceName, setWorkspaceName] = useState("");
+
// Navigation and routing
const navigate = useNavigate();
const blocker = useBlocker(
@@ -202,7 +210,7 @@ const Project = () => {
// Execute GraphQL query both on page load and navigation
const GET_PROJECT_WITH_ENTITIES = gql`
- query GetProjectWithEntities($_id: String) {
+ query GetProjectWithEntities($_id: String, $workspace: String) {
project(_id: $_id) {
_id
name
@@ -231,14 +239,20 @@ const Project = () => {
}
total
}
+ workspace(_id: $workspace) {
+ _id
+ name
+ }
}
`;
const { loading, error, data } = useQuery<{
project: ProjectModel;
entities: IGenericItem[];
+ workspace: IGenericItem;
}>(GET_PROJECT_WITH_ENTITIES, {
variables: {
_id: id,
+ workspace: workspace,
},
fetchPolicy: "no-cache",
});
@@ -290,6 +304,10 @@ const Project = () => {
setProjectHistory(data.project.history || []);
}
+
+ if (data?.workspace) {
+ setWorkspaceName(data.workspace.name);
+ }
}, [data, editing]);
// Display error messages from GraphQL usage
@@ -688,13 +706,19 @@ const Project = () => {
navigate("/")}
_hover={{
cursor: "pointer",
textDecoration: "underline",
}}
>
- Dashboard
+
+ {loading ? (
+
+ ) : (
+ workspaceName
+ )}
{
const navigate = useNavigate();
+ const { workspace } = useWorkspace();
+ const [workspaceName, setWorkspaceName] = useState("");
+
// Effect to adjust column visibility
const { breakpoint } = useBreakpoint();
const [visibleColumns, setVisibleColumns] = useState({
@@ -87,7 +97,13 @@ const Projects = () => {
// Execute GraphQL query both on page load and navigation
const { loading, error, data } = useQuery<{
projects: ProjectModel[];
- }>(GET_PROJECTS, { fetchPolicy: "network-only" });
+ workspace: IGenericItem;
+ }>(GET_PROJECTS, {
+ variables: {
+ workspace: workspace,
+ },
+ fetchPolicy: "network-only",
+ });
const [projects, setProjects] = useState([]);
const [filteredProjects, setFilteredProjects] = useState([]);
@@ -120,6 +136,9 @@ const Projects = () => {
setProjects(data.projects);
setFilteredProjects(data.projects);
}
+ if (data?.workspace) {
+ setWorkspaceName(data.workspace.name);
+ }
}, [data]);
// Apply filters to project data
@@ -269,10 +288,17 @@ const Projects = () => {
-
-
- Projects
-
+
+
+
+ Projects
+
+
+
+ {workspaceName}
+
+
+
+
+
{/* Owner Filter */}
-
+
Owner
- {_.uniq(projects.map((p) => p.owner))
- .filter((owner) => owner)
- .map((owner) => (
- {
- const isChecked = details.checked as boolean;
- if (isChecked) {
- setFilterState({
- ...filterState,
- owners: [...filterState.owners, owner],
- });
- } else {
- setFilterState({
- ...filterState,
- owners: filterState.owners.filter((o) => o !== owner),
- });
- }
- }}
- >
-
-
-
-
-
-
- ))}
+ {projects.length > 0 &&
+ _.uniq(projects.map((p) => p.owner))
+ .filter((owner) => owner)
+ .map((owner) => (
+ {
+ const isChecked = details.checked as boolean;
+ if (isChecked) {
+ setFilterState({
+ ...filterState,
+ owners: [...filterState.owners, owner],
+ });
+ } else {
+ setFilterState({
+ ...filterState,
+ owners: filterState.owners.filter((o) => o !== owner),
+ });
+ }
+ }}
+ >
+
+
+
+
+
+
+ ))}
+
+ {projects.length === 0 && (
+
+ No Project Owners
+
+ )}
+
+
{/* Entity Count Range Filter */}
diff --git a/client/src/pages/view/Template.tsx b/client/src/pages/view/Template.tsx
index 6556bcf3..9ce92148 100644
--- a/client/src/pages/view/Template.tsx
+++ b/client/src/pages/view/Template.tsx
@@ -17,6 +17,7 @@ import {
Menu,
Portal,
Select,
+ SkeletonText,
Tag,
Text,
Textarea,
@@ -38,7 +39,7 @@ import ExportDialog from "@components/ExportDialog";
import SaveDialog from "@components/SaveDialog";
// Existing and custom types
-import { AttributeHistory, AttributeModel, AttributeUsage, IValue, ResponseData } from "@types";
+import { AttributeHistory, AttributeModel, AttributeUsage, IGenericItem, IValue, ResponseData } from "@types";
// Utility functions and libraries
import { removeTypename } from "@lib/util";
@@ -52,6 +53,9 @@ import { useNavigate, useParams } from "react-router-dom";
import { gql } from "@apollo/client";
import { useMutation, useQuery } from "@apollo/client/react";
+// Hooks
+import { useWorkspace } from "@hooks/useWorkspace";
+
// Variables
import { GLOBAL_STYLES } from "@variables";
@@ -59,6 +63,10 @@ const Template = () => {
const { id } = useParams();
const navigate = useNavigate();
+ // Workspace information
+ const { workspace } = useWorkspace();
+ const [workspaceName, setWorkspaceName] = useState("");
+
const [editing, setEditing] = useState(false);
const [template, setTemplate] = useState({} as AttributeModel);
@@ -139,7 +147,7 @@ const Template = () => {
// GraphQL operations
const GET_TEMPLATE = gql`
- query GetTemplate($_id: String) {
+ query GetTemplate($_id: String, $workspace: String) {
template(_id: $_id) {
_id
name
@@ -171,13 +179,19 @@ const Template = () => {
}
}
}
+ workspace(_id: $workspace) {
+ _id
+ name
+ }
}
`;
const { loading, error, data } = useQuery<{
template: AttributeModel;
+ workspace: IGenericItem;
}>(GET_TEMPLATE, {
variables: {
_id: id,
+ workspace: workspace,
},
fetchPolicy: "no-cache",
});
@@ -246,6 +260,10 @@ const Template = () => {
setTemplateHistory(data.template.history || []);
}
+ if (data?.workspace) {
+ setWorkspaceName(data.workspace.name);
+ }
+
if (usageData?.templateUsage) {
setTemplateUsage(usageData.templateUsage);
}
@@ -561,13 +579,19 @@ const Template = () => {
navigate("/")}
_hover={{
cursor: "pointer",
textDecoration: "underline",
}}
>
- Dashboard
+
+ {loading ? (
+
+ ) : (
+ workspaceName
+ )}
{
const navigate = useNavigate();
+ const { workspace } = useWorkspace();
+ const [workspaceName, setWorkspaceName] = useState("");
+
// Page state
const [templates, setTemplates] = useState([] as AttributeModel[]);
const [filteredTemplates, setFilteredTemplates] = useState([] as AttributeModel[]);
@@ -76,7 +82,7 @@ const Templates = () => {
// GraphQL operations
const GET_TEMPLATES = gql`
- query GetTemplates {
+ query GetTemplates($workspace: String) {
templates {
_id
name
@@ -91,11 +97,21 @@ const Templates = () => {
data
}
}
+ workspace(_id: $workspace) {
+ _id
+ name
+ }
}
`;
const { loading, error, data } = useQuery<{
templates: AttributeModel[];
- }>(GET_TEMPLATES, { fetchPolicy: "network-only" });
+ workspace: IGenericItem;
+ }>(GET_TEMPLATES, {
+ variables: {
+ workspace: workspace,
+ },
+ fetchPolicy: "network-only",
+ });
// Manage data once retrieved
useEffect(() => {
@@ -104,6 +120,11 @@ const Templates = () => {
setTemplates(data.templates);
setFilteredTemplates(data.templates);
}
+
+ if (data?.workspace) {
+ // Store the Workspace name
+ setWorkspaceName(data.workspace.name);
+ }
}, [loading]);
// Apply filters to template data
@@ -274,8 +295,17 @@ const Templates = () => {
-
- Templates
+
+
+
+ Templates
+
+
+
+ {workspaceName}
+
+
+
+
+
{/* Owner Filter */}
-
+
Owner
- {_.uniq(templates.map((t) => t.owner))
- .filter((owner) => owner)
- .map((owner) => (
- {
- const isChecked = details.checked as boolean;
- if (isChecked) {
- setFilterState({
- ...filterState,
- owners: [...filterState.owners, owner],
- });
- } else {
- setFilterState({
- ...filterState,
- owners: filterState.owners.filter((o) => o !== owner),
- });
- }
- }}
- >
-
-
-
-
-
-
- ))}
+ {templates.length > 0 &&
+ _.uniq(templates.map((t) => t.owner))
+ .filter((owner) => owner)
+ .map((owner) => (
+ {
+ const isChecked = details.checked as boolean;
+ if (isChecked) {
+ setFilterState({
+ ...filterState,
+ owners: [...filterState.owners, owner],
+ });
+ } else {
+ setFilterState({
+ ...filterState,
+ owners: filterState.owners.filter((o) => o !== owner),
+ });
+ }
+ }}
+ >
+
+
+
+
+
+
+ ))}
+
+ {templates.length === 0 && (
+
+ No Template Owners
+
+ )}
+
+
{/* Value Count Range Filter */}
-
+
Value Count
diff --git a/client/src/styles/styles.scss b/client/src/styles/styles.scss
index 315b0633..d8997c90 100644
--- a/client/src/styles/styles.scss
+++ b/client/src/styles/styles.scss
@@ -8,44 +8,6 @@ a:hover {
transition: 0.5s;
}
-/* Custom scrollbar styles for DataTable */
-.data-table-scroll-container {
- /* For Webkit browsers (Chrome, Safari, Edge) */
- &::-webkit-scrollbar {
- width: 3px;
- height: 3px;
- }
-
- &::-webkit-scrollbar-track {
- background: transparent;
- border-radius: 2px;
- }
-
- &::-webkit-scrollbar-thumb {
- background: rgba(0, 0, 0, 0.2);
- border-radius: 2px;
- transition: background 0.2s ease;
- }
-
- &::-webkit-scrollbar-thumb:hover {
- background: rgba(0, 0, 0, 0.3);
- }
-
- /* For Firefox */
- scrollbar-width: thin;
- scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
-
- /* Minimize spacing, remove any default margins/padding on scrollbar */
- &::-webkit-scrollbar-corner {
- background: transparent;
- }
-
- /* Reduce gap by making scrollbar track flush with content */
- &::-webkit-scrollbar:horizontal {
- margin-top: 0;
- }
-}
-
/* Custom scrollbar styles for Error component */
.error-scroll-container {
/* For Webkit browsers (Chrome, Safari, Edge) */
diff --git a/client/src/variables.ts b/client/src/variables.ts
index b801766b..cd967b18 100644
--- a/client/src/variables.ts
+++ b/client/src/variables.ts
@@ -14,6 +14,15 @@ export const STATIC_URL =
// Key for the local storage data
export const STORAGE_KEY = "metadatify_storage";
+// Accepted MIME types for attachments and imports
+export const ACCEPTED_ATTACHMENTS = ["image/jpeg", "image/png", "application/pdf", "application/vnd.dna"];
+export const ACCEPTED_IMPORTS_ENTITIES = [
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ "text/csv",
+ "application/json",
+];
+export const ACCEPTED_IMPORTS_TEMPLATES = ["application/json"];
+
// Default styling
export const GLOBAL_STYLES = {
font: {
diff --git a/client/test/integration/actions/entity.test.ts b/client/test/integration/actions/entity.test.ts
index d034fe3d..03ab9a7e 100644
--- a/client/test/integration/actions/entity.test.ts
+++ b/client/test/integration/actions/entity.test.ts
@@ -75,7 +75,7 @@ test.describe("Entity", () => {
test("should appear in the Entities list after creation", async ({ page }) => {
await navigateToSection(page, "Entities");
- const table = page.locator(".data-table-scroll-container");
+ const table = page.getByTestId("data-table-scroll-container");
await table.waitFor({ state: "visible", timeout: 5000 });
await expect(table.locator(`text=1-Entity-List`)).toBeVisible({ timeout: 10000 });
});
diff --git a/client/test/integration/actions/project.test.ts b/client/test/integration/actions/project.test.ts
index e26e23c5..99474cd0 100644
--- a/client/test/integration/actions/project.test.ts
+++ b/client/test/integration/actions/project.test.ts
@@ -86,7 +86,7 @@ test.describe("Project", () => {
await addEntityToProject(page, "1-Entity-Project");
// Check for the View button rather than the entity name since the table column truncates long names
- const table = page.locator(".data-table-scroll-container");
+ const table = page.getByTestId("data-table-scroll-container");
await expect(table.locator('button[aria-label="View Entity"]')).toBeVisible({ timeout: 10000 });
await page.reload();
@@ -106,11 +106,11 @@ test.describe("Project", () => {
await page.click("#editProjectButton");
await addEntityToProject(page, "2-Project-Entity");
- const table = page.locator(".data-table-scroll-container");
+ const table = page.getByTestId("data-table-scroll-container");
await table.locator('button[aria-label="View Entity"]').first().click();
await page.click("#editEntityButton");
- const projectsTable = page.locator(".data-table-scroll-container").filter({ hasText: "2-Entity-Project" });
+ const projectsTable = page.getByTestId("data-table-scroll-container").filter({ hasText: "2-Entity-Project" });
await projectsTable.locator('button[aria-label="Remove Project"]').first().click();
await saveAndWait(page);
@@ -138,7 +138,7 @@ test.describe("Project", () => {
test("should appear in the Projects list after creation", async ({ page }) => {
await navigateToSection(page, "Projects");
- const table = page.locator(".data-table-scroll-container");
+ const table = page.getByTestId("data-table-scroll-container");
await table.waitFor({ state: "visible", timeout: 5000 });
await expect(table.locator("text=1-Project-List")).toBeVisible({ timeout: 10000 });
});
diff --git a/client/test/integration/helpers.ts b/client/test/integration/helpers.ts
index 0c8a7d41..8b491cc5 100644
--- a/client/test/integration/helpers.ts
+++ b/client/test/integration/helpers.ts
@@ -22,9 +22,6 @@ import { IAttribute, IEntity, IProject, IWorkspace, ResponseData } from "../../.
// Utility functions
import dayjs from "dayjs";
-// Variables
-const TABLE_CONTAINER = ".data-table-scroll-container";
-
/**
* Create an Entity for use in testing
* @param {string} name The name of the Entity to create
@@ -250,7 +247,7 @@ export const openItemFromTable = async (
itemName: string,
viewButtonLabel: "View Entity" | "View Project" | "View Template",
): Promise => {
- const table = page.locator(TABLE_CONTAINER);
+ const table = page.getByTestId("data-table-scroll-container");
await table.waitFor({ state: "visible", timeout: 5000 });
// Wait for table to be populated - check that at least one view button exists
diff --git a/client/test/integration/workflows/import.test.ts b/client/test/integration/workflows/import.test.ts
index 1a54f3ac..295702da 100644
--- a/client/test/integration/workflows/import.test.ts
+++ b/client/test/integration/workflows/import.test.ts
@@ -32,13 +32,14 @@ test.describe("Import", () => {
test("should import a CSV file successfully", async ({ page }) => {
await clickButtonWhenEnabled(page, "#navImportButtonDesktop");
+ // Select the import type first, which enables the `FileUpload` dropzone
+ await page.click('[data-testid="import-type-select-trigger-entities"]');
+
// Upload CSV file
const csvPath = path.resolve(process.cwd(), "test/integration/export_entities.csv");
const fileInput = page.locator('input[type="file"]').first();
await fileInput.setInputFiles(csvPath);
- await page.click('[data-testid="import-type-select-trigger-entities"]');
-
await clickButtonWhenEnabled(page, "#importContinueButton");
// Wait for details page to load before selecting columns
@@ -54,7 +55,11 @@ test.describe("Import", () => {
// Verify import success
await page.click("#navProjectsButtonDesktop");
- await page.locator(".data-table-scroll-container").locator('button[aria-label="View Project"]').first().click();
+ await page
+ .getByTestId("data-table-scroll-container")
+ .locator('button[aria-label="View Project"]')
+ .first()
+ .click();
await expect(page.locator("text=Mini Box 1 (CSV)").first()).toBeVisible();
});
@@ -62,13 +67,14 @@ test.describe("Import", () => {
test("should import a JSON file successfully", async ({ page }) => {
await clickButtonWhenEnabled(page, "#navImportButtonDesktop");
+ // Select the import type first, which enables the `FileUpload` dropzone
+ await page.click('[data-testid="import-type-select-trigger-entities"]');
+
// Upload JSON file
const jsonPath = path.resolve(process.cwd(), "test/integration/export_entities.json");
const fileInput = page.locator('input[type="file"]').first();
await fileInput.setInputFiles(jsonPath);
- await page.click('[data-testid="import-type-select-trigger-entities"]');
-
// Wait for continue button to be enabled
await clickButtonWhenEnabled(page, "#importContinueButton");
await page.locator("input[placeholder='JSON: \"name\"']").first().waitFor({ state: "visible", timeout: 15000 });
diff --git a/client/test/integration/workflows/template.test.ts b/client/test/integration/workflows/template.test.ts
index 0fcb2fdf..54b539c2 100644
--- a/client/test/integration/workflows/template.test.ts
+++ b/client/test/integration/workflows/template.test.ts
@@ -33,7 +33,7 @@ test.describe("Template", () => {
test("should create a Template and appear in the list", async ({ page }) => {
await navigateToSection(page, "Templates");
- const table = page.locator(".data-table-scroll-container");
+ const table = page.getByTestId("data-table-scroll-container");
await table.waitFor({ state: "visible", timeout: 5000 });
await expect(table.locator(`text=1-Template-Create`)).toBeVisible({ timeout: 10000 });
});
diff --git a/client/test/setup.ts b/client/test/setup.ts
index 2d77eef6..30780b39 100644
--- a/client/test/setup.ts
+++ b/client/test/setup.ts
@@ -26,12 +26,14 @@ Object.defineProperty(window, "matchMedia", {
});
// IntersectionObserver mock
-const IntersectionObserverMock = vi.fn(() => ({
- disconnect: vi.fn(),
- observe: vi.fn(),
- takeRecords: vi.fn(),
- unobserve: vi.fn(),
-}));
+const IntersectionObserverMock = vi.fn(function () {
+ return {
+ disconnect: vi.fn(),
+ observe: vi.fn(),
+ takeRecords: vi.fn(),
+ unobserve: vi.fn(),
+ };
+});
vi.stubGlobal("IntersectionObserver", IntersectionObserverMock);
window["IntersectionObserver"] = IntersectionObserverMock;
diff --git a/types/index.d.ts b/types/index.d.ts
index 220a00f4..83ad3b0d 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -512,6 +512,7 @@ export type IconNames =
| "close"
| "counter"
| "info"
+ | "file"
| "search"
| "search_query"
| "bell"