From 4e77612a92020e64fc1b1c4cd2fa11ea9ad95d7a Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Tue, 9 Sep 2025 14:46:33 -0400 Subject: [PATCH 1/8] fix: show accurate metadata size on dataset cards and dataset detail page --- .../DatasetDetailPage/MetaDataPanel.tsx | 128 +++++++++++++ src/components/DatasetPageCard.tsx | 28 ++- src/pages/DatasetPage.tsx | 17 +- src/pages/UpdatedDatasetDetailPage.tsx | 179 ++++-------------- 4 files changed, 202 insertions(+), 150 deletions(-) create mode 100644 src/components/DatasetDetailPage/MetaDataPanel.tsx diff --git a/src/components/DatasetDetailPage/MetaDataPanel.tsx b/src/components/DatasetDetailPage/MetaDataPanel.tsx new file mode 100644 index 0000000..b0f3f83 --- /dev/null +++ b/src/components/DatasetDetailPage/MetaDataPanel.tsx @@ -0,0 +1,128 @@ +import { Box, Typography } from "@mui/material"; +import { Colors } from "design/theme"; +import React from "react"; + +type Props = { + dbViewInfo: any; + datasetDocument: any; +}; + +const MetaDataPanel: React.FC = ({ dbViewInfo, datasetDocument }) => { + return ( + + + + + Modalities + + + {dbViewInfo?.rows?.[0]?.value?.modality?.join(", ") ?? "N/A"} + + + + + + DOI + + + {(() => { + const doi = + datasetDocument?.["dataset_description.json"]?.DatasetDOI || + datasetDocument?.["dataset_description.json"]?.ReferenceDOI; + + if (!doi) return "N/A"; + + // Normalize into a clickable URL + let url = doi; + if (/^10\./.test(doi)) { + url = `https://doi.org/${doi}`; + } else if (/^doi:/.test(doi)) { + url = `https://doi.org/${doi.replace(/^doi:/, "")}`; + } + + return ( + + {url} + + ); + })()} + + + + + + Subjects + + + {dbViewInfo?.rows?.[0]?.value?.subj?.length ?? "N/A"} + + + + + License + + + {datasetDocument?.["dataset_description.json"]?.License ?? "N/A"} + + + + + BIDS Version + + + {datasetDocument?.["dataset_description.json"]?.BIDSVersion ?? + "N/A"} + + + + + References and Links + + + {Array.isArray( + datasetDocument?.["dataset_description.json"]?.ReferencesAndLinks + ) + ? datasetDocument["dataset_description.json"].ReferencesAndLinks + .length > 0 + ? datasetDocument[ + "dataset_description.json" + ].ReferencesAndLinks.join(", ") + : "N/A" + : datasetDocument?.["dataset_description.json"] + ?.ReferencesAndLinks ?? "N/A"} + + + + + ); +}; + +export default MetaDataPanel; diff --git a/src/components/DatasetPageCard.tsx b/src/components/DatasetPageCard.tsx index c3d9d17..3ab0bde 100644 --- a/src/components/DatasetPageCard.tsx +++ b/src/components/DatasetPageCard.tsx @@ -15,6 +15,24 @@ import { useNavigate } from "react-router-dom"; import { Row } from "redux/neurojson/types/neurojson.interface"; import RoutesEnum from "types/routes.enum"; +const formatSize = (sizeInBytes: number): string => { + if (sizeInBytes < 1024) { + return `${sizeInBytes} Bytes`; + } else if (sizeInBytes < 1024 * 1024) { + return `${(sizeInBytes / 1024).toFixed(1)} KB`; + } else if (sizeInBytes < 1024 * 1024 * 1024) { + return `${(sizeInBytes / (1024 * 1024)).toFixed(2)} MB`; + } else if (sizeInBytes < 1024 * 1024 * 1024 * 1024) { + return `${(sizeInBytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; + } else { + return `${(sizeInBytes / (1024 * 1024 * 1024 * 1024)).toFixed(2)} TB`; + } +}; + +// for showing the size +const jsonBytes = (obj: unknown) => + obj ? new TextEncoder().encode(JSON.stringify(obj)).length : 0; + interface DatasetPageCardProps { doc: Row; index: number; @@ -32,6 +50,11 @@ const DatasetPageCard: React.FC = ({ }) => { const navigate = useNavigate(); const datasetIndex = (page - 1) * pageSize + index + 1; + const sizeInBytes = React.useMemo(() => { + const len = (doc as any)?.value?.length; // bytes from backend (full doc) + if (typeof len === "number" && Number.isFinite(len)) return len; + return jsonBytes(doc.value); // fallback: summary object size + }, [doc.value]); return ( = ({ Size:{" "} - {doc.value.length + {/* {doc.value.length ? `${(doc.value.length / 1024 / 1024).toFixed(2)} MB` - : "Unknown"} + : "Unknown"} */} + {formatSize(sizeInBytes)} {doc.value.info?.DatasetDOI && ( diff --git a/src/pages/DatasetPage.tsx b/src/pages/DatasetPage.tsx index 978eb6d..21cd777 100644 --- a/src/pages/DatasetPage.tsx +++ b/src/pages/DatasetPage.tsx @@ -39,6 +39,20 @@ const DatasetPage: React.FC = () => { const totalPages = Math.ceil(limit / pageSize); const [searchQuery, setSearchQuery] = useState(""); + const formatSize = (sizeInBytes: number): string => { + if (sizeInBytes < 1024) { + return `${sizeInBytes} Bytes`; + } else if (sizeInBytes < 1024 * 1024) { + return `${(sizeInBytes / 1024).toFixed(1)} KB`; + } else if (sizeInBytes < 1024 * 1024 * 1024) { + return `${(sizeInBytes / (1024 * 1024)).toFixed(2)} MB`; + } else if (sizeInBytes < 1024 * 1024 * 1024 * 1024) { + return `${(sizeInBytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; + } else { + return `${(sizeInBytes / (1024 * 1024 * 1024 * 1024)).toFixed(2)} TB`; + } + }; + useEffect(() => { if (dbName) { dispatch(fetchDbInfo(dbName.toLowerCase())); @@ -326,11 +340,12 @@ const DatasetPage: React.FC = () => { {filteredData.map((doc: any, index: number) => { const datasetIndex = (currentPage - 1) * pageSize + index + 1; + return ( { const [previewIndex, setPreviewIndex] = useState(0); const aiSummary = datasetDocument?.[".datainfo"]?.AISummary ?? ""; + useEffect(() => { + if (!datasetDocument) { + setJsonSize(0); + return; + } + const bytes = new TextEncoder().encode( + JSON.stringify(datasetDocument) + ).length; + setJsonSize(bytes); + }, [datasetDocument]); + // 1) detect subjects at the top level, return true or false const hasTopLevelSubjects = useMemo( () => Object.keys(datasetDocument || {}).some((k) => /^sub-/i.test(k)), @@ -87,22 +99,22 @@ const UpdatedDatasetDetailPage: React.FC = () => { ); // “rest” JSON only when we actually have subjects - const rest = useMemo(() => { - if (!datasetDocument || !hasTopLevelSubjects) return {}; - const r: any = {}; - Object.keys(datasetDocument).forEach((k) => { - if (!/^sub-/i.test(k)) r[k] = (datasetDocument as any)[k]; - }); - return r; - }, [datasetDocument, hasTopLevelSubjects]); + // const rest = useMemo(() => { + // if (!datasetDocument || !hasTopLevelSubjects) return {}; + // const r: any = {}; + // Object.keys(datasetDocument).forEach((k) => { + // if (!/^sub-/i.test(k)) r[k] = (datasetDocument as any)[k]; + // }); + // return r; + // }, [datasetDocument, hasTopLevelSubjects]); // JSON panel should always render: // - if we have subjects -> JSON show "rest" (everything except sub-*) // - if we don't have subjects -> JSON show the whole document - const jsonPanelData = useMemo( - () => (hasTopLevelSubjects ? rest : datasetDocument || {}), - [hasTopLevelSubjects, rest, datasetDocument] - ); + // const jsonPanelData = useMemo( + // () => (hasTopLevelSubjects ? rest : datasetDocument || {}), + // [hasTopLevelSubjects, rest, datasetDocument] + // ); // 5) header title + counts also fall back // const treeTitle = hasTopLevelSubjects ? "Subjects" : "Files"; @@ -317,10 +329,10 @@ const UpdatedDatasetDetailPage: React.FC = () => { } }); - const blob = new Blob([JSON.stringify(datasetDocument, null, 2)], { - type: "application/json", - }); - setJsonSize(blob.size); + // const blob = new Blob([JSON.stringify(datasetDocument, null, 2)], { + // type: "application/json", + // }); + // setJsonSize(blob.size); // Construct download script dynamically let script = `curl -L --create-dirs "https://neurojson.io:7777/${dbName}/${docId}" -o "${docId}.json"\n`; @@ -856,137 +868,10 @@ const UpdatedDatasetDetailPage: React.FC = () => { flexDirection: "column", }} > - - - - - Modalities - - - {dbViewInfo?.rows?.[0]?.value?.modality?.join(", ") ?? - "N/A"} - - - - - - DOI - - - {(() => { - const doi = - datasetDocument?.["dataset_description.json"] - ?.DatasetDOI || - datasetDocument?.["dataset_description.json"] - ?.ReferenceDOI; - - if (!doi) return "N/A"; - - // Normalize into a clickable URL - let url = doi; - if (/^10\./.test(doi)) { - url = `https://doi.org/${doi}`; - } else if (/^doi:/.test(doi)) { - url = `https://doi.org/${doi.replace(/^doi:/, "")}`; - } - - return ( - - {url} - - ); - })()} - - - - - - Subjects - - - {datasetDocument?.["participants.tsv"]?.["participant_id"] - ?.length ?? "N/A"} - - - - - License - - - {datasetDocument?.["dataset_description.json"]?.License ?? - "N/A"} - - - - - BIDS Version - - - {datasetDocument?.["dataset_description.json"] - ?.BIDSVersion ?? "N/A"} - - - - - References and Links - - - {Array.isArray( - datasetDocument?.["dataset_description.json"] - ?.ReferencesAndLinks - ) - ? datasetDocument["dataset_description.json"] - .ReferencesAndLinks.length > 0 - ? datasetDocument[ - "dataset_description.json" - ].ReferencesAndLinks.join(", ") - : "N/A" - : datasetDocument?.["dataset_description.json"] - ?.ReferencesAndLinks ?? "N/A"} - - - - + From 1ce5acc2a7cbacde1e9a2f653345ff0aedb1e8b8 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Wed, 10 Sep 2025 11:57:50 -0400 Subject: [PATCH 2/8] feat: add revision select dropdown menu with link in dataset detail page --- .../DatasetDetailPage/MetaDataPanel.tsx | 120 +++++++++++++++++- src/components/DatasetPageCard.tsx | 2 +- src/pages/UpdatedDatasetDetailPage.tsx | 105 +++++---------- src/services/neurojson.service.ts | 4 +- 4 files changed, 153 insertions(+), 78 deletions(-) diff --git a/src/components/DatasetDetailPage/MetaDataPanel.tsx b/src/components/DatasetDetailPage/MetaDataPanel.tsx index b0f3f83..68a29f0 100644 --- a/src/components/DatasetDetailPage/MetaDataPanel.tsx +++ b/src/components/DatasetDetailPage/MetaDataPanel.tsx @@ -1,13 +1,44 @@ -import { Box, Typography } from "@mui/material"; +import ArrowCircleRightIcon from "@mui/icons-material/ArrowCircleRight"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; +import { + Box, + Typography, + FormControl, + InputLabel, + Select, + MenuItem, + Chip, + IconButton, + Tooltip, +} from "@mui/material"; import { Colors } from "design/theme"; -import React from "react"; +import React, { useMemo, useState } from "react"; type Props = { dbViewInfo: any; datasetDocument: any; + dbName: string | undefined; + docId: string | undefined; }; -const MetaDataPanel: React.FC = ({ dbViewInfo, datasetDocument }) => { +type RevInfo = { rev: string }; + +const MetaDataPanel: React.FC = ({ + dbViewInfo, + datasetDocument, + dbName, + docId, +}) => { + const revs: RevInfo[] = useMemo( + () => + Array.isArray(datasetDocument?.["_revs_info"]) + ? (datasetDocument!["_revs_info"] as RevInfo[]) + : [], + [datasetDocument] + ); + const [revIdx, setRevIdx] = useState(0); + const selected = revs[revIdx]; + return ( = ({ dbViewInfo, datasetDocument }) => { {dbViewInfo?.rows?.[0]?.value?.modality?.join(", ") ?? "N/A"} - DOI @@ -76,7 +106,6 @@ const MetaDataPanel: React.FC = ({ dbViewInfo, datasetDocument }) => { })()} - Subjects @@ -120,6 +149,87 @@ const MetaDataPanel: React.FC = ({ dbViewInfo, datasetDocument }) => { ?.ReferencesAndLinks ?? "N/A"} + + {revs.length > 0 && ( + + + Revisions + + + + Select revision + + + + {selected && ( + + + + Selected rev: + + + {selected.rev} + + + + + window.open( + `https://neurojson.io:7777/${dbName}/${docId}?rev=${selected.rev}`, + "_blank" + ) + } + > + + + + + )} + + )} ); diff --git a/src/components/DatasetPageCard.tsx b/src/components/DatasetPageCard.tsx index 3ab0bde..e7aff0b 100644 --- a/src/components/DatasetPageCard.tsx +++ b/src/components/DatasetPageCard.tsx @@ -51,7 +51,7 @@ const DatasetPageCard: React.FC = ({ const navigate = useNavigate(); const datasetIndex = (page - 1) * pageSize + index + 1; const sizeInBytes = React.useMemo(() => { - const len = (doc as any)?.value?.length; // bytes from backend (full doc) + const len = (doc as any)?.value?.length; // bytes from length key if (typeof len === "number" && Number.isFinite(len)) return len; return jsonBytes(doc.value); // fallback: summary object size }, [doc.value]); diff --git a/src/pages/UpdatedDatasetDetailPage.tsx b/src/pages/UpdatedDatasetDetailPage.tsx index ee80327..59d7d09 100644 --- a/src/pages/UpdatedDatasetDetailPage.tsx +++ b/src/pages/UpdatedDatasetDetailPage.tsx @@ -26,7 +26,7 @@ import { Colors } from "design/theme"; import { useAppDispatch } from "hooks/useAppDispatch"; import { useAppSelector } from "hooks/useAppSelector"; import React, { useEffect, useMemo, useState } from "react"; -import ReactJson from "react-json-view"; +// import ReactJson from "react-json-view"; import { useParams, useNavigate } from "react-router-dom"; import { fetchDocumentDetails, @@ -74,22 +74,16 @@ const UpdatedDatasetDetailPage: React.FC = () => { const [previewIndex, setPreviewIndex] = useState(0); const aiSummary = datasetDocument?.[".datainfo"]?.AISummary ?? ""; - useEffect(() => { - if (!datasetDocument) { - setJsonSize(0); - return; - } - const bytes = new TextEncoder().encode( - JSON.stringify(datasetDocument) - ).length; - setJsonSize(bytes); - }, [datasetDocument]); - - // 1) detect subjects at the top level, return true or false - const hasTopLevelSubjects = useMemo( - () => Object.keys(datasetDocument || {}).some((k) => /^sub-/i.test(k)), - [datasetDocument] - ); + // useEffect(() => { + // if (!datasetDocument) { + // setJsonSize(0); + // return; + // } + // const bytes = new TextEncoder().encode( + // JSON.stringify(datasetDocument) + // ).length; + // setJsonSize(bytes); + // }, [datasetDocument]); const linkMap = useMemo(() => makeLinkMap(externalLinks), [externalLinks]); @@ -98,26 +92,6 @@ const UpdatedDatasetDetailPage: React.FC = () => { [datasetDocument, linkMap] ); - // “rest” JSON only when we actually have subjects - // const rest = useMemo(() => { - // if (!datasetDocument || !hasTopLevelSubjects) return {}; - // const r: any = {}; - // Object.keys(datasetDocument).forEach((k) => { - // if (!/^sub-/i.test(k)) r[k] = (datasetDocument as any)[k]; - // }); - // return r; - // }, [datasetDocument, hasTopLevelSubjects]); - - // JSON panel should always render: - // - if we have subjects -> JSON show "rest" (everything except sub-*) - // - if we don't have subjects -> JSON show the whole document - // const jsonPanelData = useMemo( - // () => (hasTopLevelSubjects ? rest : datasetDocument || {}), - // [hasTopLevelSubjects, rest, datasetDocument] - // ); - - // 5) header title + counts also fall back - // const treeTitle = hasTopLevelSubjects ? "Subjects" : "Files"; const treeTitle = "Files"; const filesCount = externalLinks.length; const totalBytes = useMemo(() => { @@ -268,15 +242,24 @@ const UpdatedDatasetDetailPage: React.FC = () => { return internalLinks; }; + // useEffect(() => { + // const fetchData = async () => { + // if (dbName && docId) { + // await dispatch(fetchDocumentDetails({ dbName, docId })); + // await dispatch(fetchDbInfoByDatasetId({ dbName, docId })); + // } + // }; + + // fetchData(); + // }, [dbName, docId, dispatch]); + useEffect(() => { - const fetchData = async () => { - if (dbName && docId) { - await dispatch(fetchDocumentDetails({ dbName, docId })); - await dispatch(fetchDbInfoByDatasetId({ dbName, docId })); - } - }; + if (!dbName || !docId) return; - fetchData(); + (async () => { + await dispatch(fetchDocumentDetails({ dbName, docId })); // render tree ASAP + dispatch(fetchDbInfoByDatasetId({ dbName, docId })); // don't await + })(); }, [dbName, docId, dispatch]); useEffect(() => { @@ -289,7 +272,10 @@ const UpdatedDatasetDetailPage: React.FC = () => { index, // Assign index correctly }) ); - + const bytes = new TextEncoder().encode( + JSON.stringify(datasetDocument) + ).length; + setJsonSize(bytes); // Extract Internal Data & Assign `index` const internalData = extractInternalData(datasetDocument).map( (data, index) => ({ @@ -414,18 +400,7 @@ const UpdatedDatasetDetailPage: React.FC = () => { if (typeof window !== "undefined" && (window as any).__previewType) { return (window as any).__previewType === "2d"; } - // if (window.__previewType) { - // console.log("work~~~~~~~"); - // return window.__previewType === "2d"; - // } - // console.log("is 2d preview candidate !== 2d"); - // console.log("obj", obj); - // if (typeof obj === "string" && obj.includes("db=optics-at-martinos")) { - // return false; - // } - // if (typeof obj === "string" && obj.endsWith(".jdb")) { - // return true; - // } + if (!obj || typeof obj !== "object") { return false; } @@ -434,8 +409,6 @@ const UpdatedDatasetDetailPage: React.FC = () => { return false; } const dim = obj._ArraySize_; - // console.log("array.isarray(dim)", Array.isArray(dim)); - // console.log("dim.length", dim.length === 1 || dim.length === 2); return ( Array.isArray(dim) && @@ -512,18 +485,6 @@ const UpdatedDatasetDetailPage: React.FC = () => { typeof dataOrUrl === "string" ? extractFileName(dataOrUrl) : ""; if (isPreviewableFile(fileName)) { (window as any).previewdataurl(dataOrUrl, idx); - // const is2D = is2DPreviewCandidate(dataOrUrl); - // const panel = document.getElementById("chartpanel"); - // console.log("is2D", is2D); - // console.log("panel", panel); - - // if (is2D) { - // // console.log("📊 2D data → rendering inline with dopreview()"); - // if (panel) panel.style.display = "block"; // Show it! - // setPreviewOpen(false); // Don't open modal - // } else { - // if (panel) panel.style.display = "none"; // Hide chart panel on 3D external - // } } else { console.warn("⚠️ Unsupported file format for preview:", dataOrUrl); } @@ -871,6 +832,8 @@ const UpdatedDatasetDetailPage: React.FC = () => { diff --git a/src/services/neurojson.service.ts b/src/services/neurojson.service.ts index 02aa590..8e5b706 100644 --- a/src/services/neurojson.service.ts +++ b/src/services/neurojson.service.ts @@ -30,7 +30,9 @@ export const NeurojsonService = { }, getDocumentById: async (dbName: string, documentId: string): Promise => { try { - const response = await api.get(`${baseURL}/${dbName}/${documentId}`); + const response = await api.get( + `${baseURL}/${dbName}/${documentId}?revs_info=true` + ); return response.data; } catch (error) { console.error( From fb338686f08f1d82dc7417814ab22b5c7c2fb5a0 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Wed, 10 Sep 2025 12:15:40 -0400 Subject: [PATCH 3/8] chore: rename the dropdown label from raw revision string to Revision N --- .../DatasetDetailPage/MetaDataPanel.tsx | 43 ++++++++++++++----- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/src/components/DatasetDetailPage/MetaDataPanel.tsx b/src/components/DatasetDetailPage/MetaDataPanel.tsx index 68a29f0..e70c140 100644 --- a/src/components/DatasetDetailPage/MetaDataPanel.tsx +++ b/src/components/DatasetDetailPage/MetaDataPanel.tsx @@ -166,7 +166,27 @@ const MetaDataPanel: React.FC = ({ Revisions - + Select revision From 97968634d9022f58974fee9a8d1bd21b59ebec27 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Wed, 10 Sep 2025 12:42:52 -0400 Subject: [PATCH 4/8] style: update button color in dataset detail page for consistent UI styling --- .../DatasetDetailPage/MetaDataPanel.tsx | 13 ++++++------- src/pages/UpdatedDatasetDetailPage.tsx | 18 +++++++++++++++--- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/components/DatasetDetailPage/MetaDataPanel.tsx b/src/components/DatasetDetailPage/MetaDataPanel.tsx index e70c140..ad63381 100644 --- a/src/components/DatasetDetailPage/MetaDataPanel.tsx +++ b/src/components/DatasetDetailPage/MetaDataPanel.tsx @@ -1,5 +1,4 @@ import ArrowCircleRightIcon from "@mui/icons-material/ArrowCircleRight"; -import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import { Box, Typography, @@ -42,7 +41,7 @@ const MetaDataPanel: React.FC = ({ return ( = ({ mb: 1, "& .MuiOutlinedInput-root": { "& fieldset": { - borderColor: Colors.purple, + borderColor: Colors.green, }, "&:hover fieldset": { - borderColor: Colors.purple, + borderColor: Colors.green, }, "&.Mui-focused fieldset": { - borderColor: Colors.purple, + borderColor: Colors.green, }, }, "& .MuiInputLabel-root.Mui-focused": { - color: Colors.purple, + color: Colors.green, }, }} > @@ -242,7 +241,7 @@ const MetaDataPanel: React.FC = ({ diff --git a/src/pages/UpdatedDatasetDetailPage.tsx b/src/pages/UpdatedDatasetDetailPage.tsx index 59d7d09..c213cf5 100644 --- a/src/pages/UpdatedDatasetDetailPage.tsx +++ b/src/pages/UpdatedDatasetDetailPage.tsx @@ -937,12 +937,15 @@ const UpdatedDatasetDetailPage: React.FC = () => { variant="contained" size="small" sx={{ - backgroundColor: "#1976d2", + backgroundColor: Colors.purple, flexShrink: 0, minWidth: "70px", fontSize: "0.7rem", padding: "2px 6px", lineHeight: 1, + "&:hover": { + backgroundColor: Colors.secondaryPurple, + }, }} onClick={() => handlePreview(link.data, link.index, true) @@ -962,7 +965,7 @@ const UpdatedDatasetDetailPage: React.FC = () => { { variant="contained" size="small" sx={{ - backgroundColor: "#1976d2", + backgroundColor: Colors.purple, minWidth: "70px", fontSize: "0.7rem", padding: "2px 6px", lineHeight: 1, + "&:hover": { + backgroundColor: Colors.secondaryPurple, + }, }} onClick={() => window.open(link.url, "_blank")} > @@ -1062,10 +1068,16 @@ const UpdatedDatasetDetailPage: React.FC = () => { variant="outlined" size="small" sx={{ + color: Colors.purple, + borderColor: Colors.purple, minWidth: "65px", fontSize: "0.7rem", padding: "2px 6px", lineHeight: 1, + "&:hover": { + color: Colors.secondaryPurple, + borderColor: Colors.secondaryPurple, + }, }} onClick={() => handlePreview(link.url, link.index, false) From 2b6dc8e1eb896ebd3f23daf860dd73544e4876c8 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Thu, 11 Sep 2025 14:55:07 -0400 Subject: [PATCH 5/8] feat: add matching database card to the search page; refs #81 --- src/components/SearchPage/DatabaseCard.tsx | 166 +++++++++++++++++++++ src/pages/SearchPage.tsx | 58 ++++++- src/pages/UpdatedDatasetDetailPage.tsx | 14 +- 3 files changed, 233 insertions(+), 5 deletions(-) create mode 100644 src/components/SearchPage/DatabaseCard.tsx diff --git a/src/components/SearchPage/DatabaseCard.tsx b/src/components/SearchPage/DatabaseCard.tsx new file mode 100644 index 0000000..cc9a141 --- /dev/null +++ b/src/components/SearchPage/DatabaseCard.tsx @@ -0,0 +1,166 @@ +import { + Box, + Typography, + Chip, + Card, + CardContent, + Stack, + Avatar, +} from "@mui/material"; +import { Colors } from "design/theme"; +import React from "react"; +import { Link } from "react-router-dom"; +import RoutesEnum from "types/routes.enum"; + +type Props = { + dbName?: string; + fullName?: string; + datasets?: number; + modalities?: string[]; + logo?: string; + keyword?: string; + onChipClick: (key: string, value: string) => void; +}; + +const DatabaseCard: React.FC = ({ + dbName, + fullName, + datasets, + modalities, + logo, + keyword, + onChipClick, +}) => { + const databaseLink = `${RoutesEnum.DATABASES}/${dbName}`; + // keyword hightlight functional component + const highlightKeyword = (text: string, keyword?: string) => { + if (!keyword || !text?.toLowerCase().includes(keyword.toLowerCase())) { + return text; + } + + const regex = new RegExp(`(${keyword})`, "gi"); // for case-insensitive and global + const parts = text.split(regex); + + return ( + <> + {parts.map((part, i) => + part.toLowerCase() === keyword.toLowerCase() ? ( + + {part} + + ) : ( + {part} + ) + )} + + ); + }; + + return ( + + + + {/* Logo as Avatar */} + + {logo && ( + + )} + + {/* database card */} + + + Database:{" "} + {highlightKeyword(fullName || "Untitled Database", keyword)} + + + + + Modalities: + + + {Array.isArray(modalities) && modalities.length > 0 ? ( + modalities.map((mod, idx) => ( + onChipClick("modality", mod)} + sx={{ + "& .MuiChip-label": { + paddingX: "6px", + fontSize: "0.8rem", + }, + height: "24px", + color: Colors.darkPurple, + border: `1px solid ${Colors.darkPurple}`, + fontWeight: "bold", + transition: "all 0.2s ease", + "&:hover": { + backgroundColor: `${Colors.purple} !important`, + color: "white", + borderColor: Colors.purple, + }, + }} + /> + )) + ) : ( + + N/A + + )} + + + + + Datasets: {datasets ?? "N/A"} + + + + + + + + ); +}; + +export default DatabaseCard; diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index 005fb10..dacda61 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -14,6 +14,7 @@ import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import Form from "@rjsf/mui"; import validator from "@rjsf/validator-ajv8"; +import DatabaseCard from "components/SearchPage/DatabaseCard"; import DatasetCard from "components/SearchPage/DatasetCard"; import SubjectCard from "components/SearchPage/SubjectCard"; import { Colors } from "design/theme"; @@ -30,6 +31,25 @@ import { RootState } from "redux/store"; import { generateUiSchema } from "utils/SearchPageFunctions/generateUiSchema"; import { modalityValueToEnumLabel } from "utils/SearchPageFunctions/modalityLabels"; +type RegistryItem = { + id: string; + name?: string; + fullname?: string; + datatype?: string[]; + datasets?: number; + logo?: string; +}; + +const matchesKeyword = (item: RegistryItem, keyword: string) => { + if (!keyword) return false; + const needle = keyword.toLowerCase(); + return ( + item.name?.toLowerCase().includes(needle) || + item.fullname?.toLowerCase().includes(needle) || + item.datatype?.some((dt) => dt.toLowerCase().includes(needle)) + ); +}; + const SearchPage: React.FC = () => { const dispatch = useAppDispatch(); const [hasSearched, setHasSearched] = useState(false); @@ -54,6 +74,17 @@ const SearchPage: React.FC = () => { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("sm")); + // for database card + const keywordInput = String(formData?.keyword ?? "").trim(); + console.log("keyword", keywordInput); + + const registryMatches: RegistryItem[] = React.useMemo(() => { + if (!Array.isArray(registry) || !keywordInput) return []; + return (registry as RegistryItem[]).filter((r) => + matchesKeyword(r, keywordInput) + ); + }, [registry, keywordInput]); + // to show the applied chips on the top of results const activeFilters = Object.entries(appliedFilters).filter( ([key, value]) => @@ -298,13 +329,14 @@ const SearchPage: React.FC = () => { return ( { )} + {/* matching databases */} + {keywordInput && registryMatches.length > 0 && ( + + + Matching Databases + + {registryMatches.map((db) => ( + + ))} + + )} + {/* results */} {hasSearched && ( diff --git a/src/pages/UpdatedDatasetDetailPage.tsx b/src/pages/UpdatedDatasetDetailPage.tsx index c213cf5..0e946e7 100644 --- a/src/pages/UpdatedDatasetDetailPage.tsx +++ b/src/pages/UpdatedDatasetDetailPage.tsx @@ -272,10 +272,16 @@ const UpdatedDatasetDetailPage: React.FC = () => { index, // Assign index correctly }) ); - const bytes = new TextEncoder().encode( - JSON.stringify(datasetDocument) - ).length; - setJsonSize(bytes); + + const bytes = new Blob([JSON.stringify(datasetDocument)], { + type: "application/json", + }); + setJsonSize(bytes.size); + + // const bytes = new TextEncoder().encode( + // JSON.stringify(datasetDocument) + // ).length; + // setJsonSize(bytes); // Extract Internal Data & Assign `index` const internalData = extractInternalData(datasetDocument).map( (data, index) => ({ From 37b3d3aee4efd7267630c5c370eed74f02d5f9b7 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Fri, 12 Sep 2025 11:50:24 -0400 Subject: [PATCH 6/8] fix: show on results only when neither datasets nor databases match; #81 --- src/pages/SearchPage.tsx | 151 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 148 insertions(+), 3 deletions(-) diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index dacda61..8141cb0 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -327,6 +327,19 @@ const SearchPage: React.FC = () => { ); + // check if has database/dataset matches + const hasDbMatches = !!keywordInput && registryMatches.length > 0; + const hasDatasetMatches = Array.isArray(results) && results.length > 0; + // when backend find nothing + const backendEmpty = + !Array.isArray(results) && (results as any)?.msg === "empty output"; + + // show red message only if nothing matched at all + const showNoResults = + hasSearched && + !loading && + !hasDbMatches && + (!hasDatasetMatches || backendEmpty); return ( { color: Colors.darkPurple, }} > - Use the filters to search for datasets or subjects based on - metadata. + Use the filters and click submit to search for datasets or + subjects based on metadata. )} @@ -523,6 +536,138 @@ const SearchPage: React.FC = () => { {/* results */} {hasSearched && ( + + {loading ? ( + + + + Loading search results... + + + ) : ( + <> + {/* Only show header when there are dataset hits */} + {hasDatasetMatches && ( + + {`Showing ${results.length} ${ + isDataset ? "Datasets" : "Subjects" + }`} + + )} + + {/* pagination + cards (unchanged, but guard with hasDatasetMatches) */} + {hasDatasetMatches && ( + <> + {results.length >= 50 && ( + + + + )} + + + + + + {results.length > 0 && + paginatedResults.length > 0 && + paginatedResults.map((item, idx) => { + try { + const parsedJson = JSON.parse(item.json); + const globalIndex = (page - 1) * itemsPerPage + idx; + + const isDataset = + parsedJson?.value?.subj && + Array.isArray(parsedJson.value.subj); + + return isDataset ? ( + + ) : ( + + ); + } catch (e) { + console.error( + `Failed to parse JSON for item #${idx}`, + e + ); + return null; + } + })} + + )} + + {/* Single place to show the red message */} + {showNoResults && ( + + No results found based on your criteria. Please adjust the + filters and try again. + + )} + + {hasSearched && + !loading && + !Array.isArray(results) && + results?.msg !== "empty output" && ( + + Something went wrong. Please try again later. + + )} + + )} + + )} + + {/* {hasSearched && ( {loading ? ( @@ -640,7 +785,7 @@ const SearchPage: React.FC = () => { )} - )} + )} */} {/* mobile version filters */} From 10abb366bb830e6bcc8341c106f16f07c291af4b Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Fri, 12 Sep 2025 15:46:30 -0400 Subject: [PATCH 7/8] feat: make database card datatypes conditionally clickable based on modality allowlist; #81 --- src/components/SearchPage/DatabaseCard.tsx | 95 ++++-- src/pages/SearchPage.tsx | 315 ++++++++++-------- .../SearchPageFunctions/modalityLabels.ts | 3 + 3 files changed, 243 insertions(+), 170 deletions(-) diff --git a/src/components/SearchPage/DatabaseCard.tsx b/src/components/SearchPage/DatabaseCard.tsx index cc9a141..b2be26a 100644 --- a/src/components/SearchPage/DatabaseCard.tsx +++ b/src/components/SearchPage/DatabaseCard.tsx @@ -11,6 +11,7 @@ import { Colors } from "design/theme"; import React from "react"; import { Link } from "react-router-dom"; import RoutesEnum from "types/routes.enum"; +import { modalityValueToEnumLabel } from "utils/SearchPageFunctions/modalityLabels"; type Props = { dbName?: string; @@ -59,6 +60,47 @@ const DatabaseCard: React.FC = ({ ); }; + // for datatype rendering + const isClickableModality = (raw: string) => + !!modalityValueToEnumLabel[raw.toLowerCase()]; + + const renderDatatype = (raw: string, idx: number) => { + const key = raw.toLowerCase(); + const label = modalityValueToEnumLabel[key]; + + if (label) { + // Clickable modality → drives the "modality" filter + return ( + onChipClick("modality", key)} // pass normalized key + sx={{ + "& .MuiChip-label": { px: "6px", fontSize: "0.8rem" }, + height: 24, + color: Colors.darkPurple, + border: `1px solid ${Colors.darkPurple}`, + fontWeight: "bold", + transition: "all 0.2s ease", + "&:hover": { + backgroundColor: `${Colors.purple} !important`, + color: "white", + borderColor: Colors.purple, + }, + }} + /> + ); + } + + // Not a modality → render as plain text (or a disabled/outlined chip if you prefer) + return ( + + {raw} + + ); + }; + return ( @@ -115,35 +157,38 @@ const DatabaseCard: React.FC = ({ alignItems="center" > - Modalities: + Data Type: {Array.isArray(modalities) && modalities.length > 0 ? ( - modalities.map((mod, idx) => ( - onChipClick("modality", mod)} - sx={{ - "& .MuiChip-label": { - paddingX: "6px", - fontSize: "0.8rem", - }, - height: "24px", - color: Colors.darkPurple, - border: `1px solid ${Colors.darkPurple}`, - fontWeight: "bold", - transition: "all 0.2s ease", - "&:hover": { - backgroundColor: `${Colors.purple} !important`, - color: "white", - borderColor: Colors.purple, - }, - }} - /> - )) + modalities.map(renderDatatype) ) : ( + // ( + // modalities.map((mod, idx) => ( + // onChipClick("modality", mod)} + // sx={{ + // "& .MuiChip-label": { + // paddingX: "6px", + // fontSize: "0.8rem", + // }, + // height: "24px", + // color: Colors.darkPurple, + // border: `1px solid ${Colors.darkPurple}`, + // fontWeight: "bold", + // transition: "all 0.2s ease", + // "&:hover": { + // backgroundColor: `${Colors.purple} !important`, + // color: "white", + // borderColor: Colors.purple, + // }, + // }} + // /> + // )) + // ) N/A diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index 8141cb0..f544782 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -408,7 +408,7 @@ const SearchPage: React.FC = () => { p: 3, borderRadius: 2, boxShadow: 1, - minWidth: "35%", + minWidth: "25%", }} > {renderFilterForm()} @@ -510,162 +510,187 @@ const SearchPage: React.FC = () => { )} - {/* matching databases */} - {keywordInput && registryMatches.length > 0 && ( - - + {/* matching databases */} + {keywordInput && registryMatches.length > 0 && ( + - Matching Databases - - {registryMatches.map((db) => ( - - ))} - - )} - - {/* results */} - {hasSearched && ( - - {loading ? ( - - - - Loading search results... - - - ) : ( - <> - {/* Only show header when there are dataset hits */} - {hasDatasetMatches && ( - - {`Showing ${results.length} ${ - isDataset ? "Datasets" : "Subjects" - }`} + + Matching Databases + + {registryMatches.map((db) => ( + + ))} + + )} + + {/* results */} + {hasSearched && ( + + {loading ? ( + + + + Loading search results... - )} + + ) : ( + <> + {/* Only show header when there are dataset hits */} + {hasDatasetMatches && ( + + {`Showing ${results.length} ${ + isDataset ? "Datasets" : "Subjects" + }`} + + )} - {/* pagination + cards (unchanged, but guard with hasDatasetMatches) */} - {hasDatasetMatches && ( - <> - {results.length >= 50 && ( - - + + )} + + + - Load Extra 50 Results - + /> - )} - - - - - {results.length > 0 && - paginatedResults.length > 0 && - paginatedResults.map((item, idx) => { - try { - const parsedJson = JSON.parse(item.json); - const globalIndex = (page - 1) * itemsPerPage + idx; - - const isDataset = - parsedJson?.value?.subj && - Array.isArray(parsedJson.value.subj); - - return isDataset ? ( - - ) : ( - - ); - } catch (e) { - console.error( - `Failed to parse JSON for item #${idx}`, - e - ); - return null; - } - })} - - )} - - {/* Single place to show the red message */} - {showNoResults && ( - - No results found based on your criteria. Please adjust the - filters and try again. - - )} + {results.length > 0 && + paginatedResults.length > 0 && + paginatedResults.map((item, idx) => { + try { + const parsedJson = JSON.parse(item.json); + const globalIndex = + (page - 1) * itemsPerPage + idx; + + const isDataset = + parsedJson?.value?.subj && + Array.isArray(parsedJson.value.subj); + + return isDataset ? ( + + ) : ( + + ); + } catch (e) { + console.error( + `Failed to parse JSON for item #${idx}`, + e + ); + return null; + } + })} + + )} - {hasSearched && - !loading && - !Array.isArray(results) && - results?.msg !== "empty output" && ( + {/* Single place to show the red message */} + {showNoResults && ( - Something went wrong. Please try again later. + No results found based on your criteria. Please adjust + the filters and try again. )} - - )} - - )} + + {hasSearched && + !loading && + !Array.isArray(results) && + results?.msg !== "empty output" && ( + + Something went wrong. Please try again later. + + )} + + )} + + )} + {/* {hasSearched && ( diff --git a/src/utils/SearchPageFunctions/modalityLabels.ts b/src/utils/SearchPageFunctions/modalityLabels.ts index b4c6f9a..532c3be 100644 --- a/src/utils/SearchPageFunctions/modalityLabels.ts +++ b/src/utils/SearchPageFunctions/modalityLabels.ts @@ -1,6 +1,8 @@ export const modalityValueToEnumLabel: Record = { anat: "Structural MRI (anat)", + mri: "Structural MRI (anat)", func: "fMRI (func)", + fmri: "fMRI (func)", dwi: "DWI (dwi)", fmap: "Field maps (fmap)", perf: "Perfusion (perf)", @@ -11,5 +13,6 @@ export const modalityValueToEnumLabel: Record = { pet: "PET (pet)", micr: "microscopy (micr)", nirs: "fNIRS (nirs)", + fnirs: "fNIRS (nirs)", motion: "motion (motion)", }; From 9967d5129b1d27a8c8297a51b39af0c8e1bcfffe Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Mon, 15 Sep 2025 13:11:25 -0400 Subject: [PATCH 8/8] feat: show database card when a database is selected; closes #81 --- src/components/SearchPage/DatabaseCard.tsx | 6 ++-- src/pages/SearchPage.tsx | 40 +++++++++++++++++----- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/src/components/SearchPage/DatabaseCard.tsx b/src/components/SearchPage/DatabaseCard.tsx index b2be26a..0a76afb 100644 --- a/src/components/SearchPage/DatabaseCard.tsx +++ b/src/components/SearchPage/DatabaseCard.tsx @@ -14,7 +14,7 @@ import RoutesEnum from "types/routes.enum"; import { modalityValueToEnumLabel } from "utils/SearchPageFunctions/modalityLabels"; type Props = { - dbName?: string; + dbId?: string; fullName?: string; datasets?: number; modalities?: string[]; @@ -24,7 +24,7 @@ type Props = { }; const DatabaseCard: React.FC = ({ - dbName, + dbId, fullName, datasets, modalities, @@ -32,7 +32,7 @@ const DatabaseCard: React.FC = ({ keyword, onChipClick, }) => { - const databaseLink = `${RoutesEnum.DATABASES}/${dbName}`; + const databaseLink = `${RoutesEnum.DATABASES}/${dbId}`; // keyword hightlight functional component const highlightKeyword = (text: string, keyword?: string) => { if (!keyword || !text?.toLowerCase().includes(keyword.toLowerCase())) { diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index f544782..6ccf638 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -76,14 +76,34 @@ const SearchPage: React.FC = () => { // for database card const keywordInput = String(formData?.keyword ?? "").trim(); + const selectedDbId = String(formData?.database ?? "").trim(); console.log("keyword", keywordInput); + // const registryMatches: RegistryItem[] = React.useMemo(() => { + // if (!Array.isArray(registry) || !keywordInput) return []; + // return (registry as RegistryItem[]).filter((r) => + // matchesKeyword(r, keywordInput) + // ); + // }, [registry, keywordInput]); + const registryMatches: RegistryItem[] = React.useMemo(() => { - if (!Array.isArray(registry) || !keywordInput) return []; - return (registry as RegistryItem[]).filter((r) => - matchesKeyword(r, keywordInput) - ); - }, [registry, keywordInput]); + if (!Array.isArray(registry)) return []; + const list = registry as RegistryItem[]; + + const fromId = + selectedDbId && selectedDbId !== "any" + ? list.filter((r) => r.id === selectedDbId) + : []; + + const fromKeyword = keywordInput + ? list.filter((r) => matchesKeyword(r, keywordInput)) + : []; + + // merge the db results of selectedDB and keywordInput --> de duplicates + const map = new Map(); + [...fromId, ...fromKeyword].forEach((r) => map.set(r.id, r)); + return Array.from(map.values()); // return matched registry + }, [registry, selectedDbId, keywordInput]); // to show the applied chips on the top of results const activeFilters = Object.entries(appliedFilters).filter( @@ -328,7 +348,8 @@ const SearchPage: React.FC = () => { ); // check if has database/dataset matches - const hasDbMatches = !!keywordInput && registryMatches.length > 0; + // const hasDbMatches = !!keywordInput && registryMatches.length > 0; + const hasDbMatches = registryMatches.length > 0; const hasDatasetMatches = Array.isArray(results) && results.length > 0; // when backend find nothing const backendEmpty = @@ -522,7 +543,8 @@ const SearchPage: React.FC = () => { }} > {/* matching databases */} - {keywordInput && registryMatches.length > 0 && ( + {/* {keywordInput && registryMatches.length > 0 && ( */} + {registryMatches.length > 0 && ( { {registryMatches.map((db) => (