From caba5a1de67e02d6a4b596133ead6df3e086358b Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Mon, 22 Sep 2025 12:12:19 -0400 Subject: [PATCH 1/9] feat: support preset deeplink to default database to openneuro; refs #95 --- src/pages/SearchPage.tsx | 41 ++++++++++++++++++++++---- src/pages/UpdatedDatasetDetailPage.tsx | 2 +- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index ef303a2..88d3877 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -54,6 +54,10 @@ const matchesKeyword = (item: RegistryItem, keyword: string) => { ); }; +const getPresetKey = () => { + return new URLSearchParams(window.location.search).get("preset"); +}; + const SearchPage: React.FC = () => { const dispatch = useAppDispatch(); const [hasSearched, setHasSearched] = useState(false); @@ -115,6 +119,20 @@ const SearchPage: React.FC = () => { value !== "any" ); + useEffect(() => { + // If a #query=... already exists, existing effect will handle it. + if (window.location.hash.startsWith("#query=")) return; + + const key = getPresetKey(); // "openneuro" + if (key === "openneuro") { + const initial = { database: "openneuro" }; + // set initial form/filter state + setFormData(initial); + setAppliedFilters(initial); + setHasSearched(false); // set it to true if want to auto-run search + } + }, []); + // parse query from url on page load useEffect(() => { const hash = window.location.hash; @@ -359,7 +377,7 @@ const SearchPage: React.FC = () => { const showNoResults = hasSearched && !loading && - !hasDbMatches && + // !hasDbMatches && (!hasDatasetMatches || backendEmpty); return ( { {/* Single place to show the red message */} {showNoResults && ( - - No results found based on your criteria. Please adjust - the filters and try again. - + + + Search Results + + + + No datasets or subjects found. Please adjust the + filters and try again. + + )} {hasSearched && diff --git a/src/pages/UpdatedDatasetDetailPage.tsx b/src/pages/UpdatedDatasetDetailPage.tsx index feb8b8e..c63eda7 100644 --- a/src/pages/UpdatedDatasetDetailPage.tsx +++ b/src/pages/UpdatedDatasetDetailPage.tsx @@ -60,7 +60,7 @@ const UpdatedDatasetDetailPage: React.FC = () => { const handleSelectRevision = (newRev?: string | null) => { setSearchParams((prev) => { - const p = new URLSearchParams(prev); + const p = new URLSearchParams(prev); // copy of the query url if (newRev) p.set("rev", newRev); else p.delete("rev"); return p; From 0c9ebf2169966fec6dd070b615cadd5bef9cc953 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Mon, 22 Sep 2025 13:40:32 -0400 Subject: [PATCH 2/9] feat: add collapsible dataset-level filters to search page; closes #95 --- src/pages/SearchPage.tsx | 24 +++++++- .../SearchPageFunctions/generateUiSchema.ts | 57 +++++++++++++++++-- .../SearchPageFunctions/searchformSchema.ts | 4 ++ 3 files changed, 79 insertions(+), 6 deletions(-) diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index 88d3877..e09c020 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -71,6 +71,8 @@ const SearchPage: React.FC = () => { const [formData, setFormData] = useState>({}); const [showSubjectFilters, setShowSubjectFilters] = useState(false); + const [showDatasetFilters, setShowDatasetFilters] = useState(true); // for dataset-level filters + const [results, setResults] = useState< any[] | { status: string; msg: string } >([]); @@ -130,6 +132,8 @@ const SearchPage: React.FC = () => { setFormData(initial); setAppliedFilters(initial); setHasSearched(false); // set it to true if want to auto-run search + setShowSubjectFilters(true); // expand the subject-level section + setShowDatasetFilters(false); // collapse the dataset-level section } }, []); @@ -184,8 +188,8 @@ const SearchPage: React.FC = () => { // form UI const uiSchema = useMemo( - () => generateUiSchema(formData, showSubjectFilters), - [formData, showSubjectFilters] + () => generateUiSchema(formData, showSubjectFilters, showDatasetFilters), + [formData, showSubjectFilters, showDatasetFilters] ); // Create the "Subject-level Filters" button as a custom field @@ -208,6 +212,22 @@ const SearchPage: React.FC = () => { ), + datasetFiltersToggle: () => ( + + + + ), }; // determine the results are subject-level or dataset-level diff --git a/src/utils/SearchPageFunctions/generateUiSchema.ts b/src/utils/SearchPageFunctions/generateUiSchema.ts index 467bb7a..f352a23 100644 --- a/src/utils/SearchPageFunctions/generateUiSchema.ts +++ b/src/utils/SearchPageFunctions/generateUiSchema.ts @@ -4,7 +4,8 @@ import { Colors } from "design/theme"; // Controls the visibility of subject-level filters export const generateUiSchema = ( formData: Record, - showSubjectFilters: boolean + showSubjectFilters: boolean, + showDatasetFilters: boolean ) => { const activeStyle = { "ui:options": { @@ -31,10 +32,58 @@ export const generateUiSchema = ( }, }; + // collapsible sections (subject-level & dataset-level) + // const subjectHiddenStyle = { + // "ui:options": { + // style: { display: showSubjectFilters ? "block" : "none" }, + // }, + // }; + + const datasetHiddenStyle = { + "ui:options": { + style: { display: showDatasetFilters ? "block" : "none" }, + }, + }; + return { - keyword: formData["keyword"] ? activeStyle : {}, - database: - formData["database"] && formData["database"] !== "any" ? activeStyle : {}, + "ui:order": [ + "dataset_filters_toggle", // button first + "database", + "keyword", + "subject_filters_toggle", + "modality", + "gender", + "age_min", + "age_max", + "sess_min", + "sess_max", + "task_min", + "task_max", + "run_min", + "run_max", + "task_name", + "type_name", + "session_name", + "run_name", + "limit", + "skip", + "*", // anything else not listed + ], + // keyword: formData["keyword"] ? activeStyle : {}, + dataset_filters_toggle: { "ui:field": "datasetFiltersToggle" }, + keyword: showDatasetFilters + ? formData["keyword"] + ? activeStyle + : {} + : datasetHiddenStyle, + // database: + // formData["database"] && formData["database"] !== "any" ? activeStyle : {}, + database: showDatasetFilters + ? formData["database"] && formData["database"] !== "any" + ? activeStyle + : {} + : datasetHiddenStyle, + // dataset: formData["dataset"] ? activeStyle : {}, // limit: formData["limit"] ? activeStyle : {}, // skip: formData["skip"] ? activeStyle : {}, diff --git a/src/utils/SearchPageFunctions/searchformSchema.ts b/src/utils/SearchPageFunctions/searchformSchema.ts index 4f1c4bd..b3e1a97 100644 --- a/src/utils/SearchPageFunctions/searchformSchema.ts +++ b/src/utils/SearchPageFunctions/searchformSchema.ts @@ -4,6 +4,10 @@ export const baseSchema: JSONSchema7 = { title: "", type: "object", properties: { + dataset_filters_toggle: { + type: "null", + title: "Dataset Filters", + }, keyword: { title: "Search keyword", type: "string", From b53a802fea5e8ddc086a5e4be12dc24100cbcd2b Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Tue, 23 Sep 2025 14:33:16 -0400 Subject: [PATCH 3/9] feat: highlight subject folder when navigating from search results; refs #96 --- .../DatasetDetailPage/FileTree/FileTree.tsx | 3 + .../FileTree/FileTreeRow.tsx | 60 +++++++++++-------- src/components/SearchPage/SubjectCard.tsx | 14 ++++- src/pages/UpdatedDatasetDetailPage.tsx | 13 +++- 4 files changed, 60 insertions(+), 30 deletions(-) diff --git a/src/components/DatasetDetailPage/FileTree/FileTree.tsx b/src/components/DatasetDetailPage/FileTree/FileTree.tsx index 1b5b8de..d00456b 100644 --- a/src/components/DatasetDetailPage/FileTree/FileTree.tsx +++ b/src/components/DatasetDetailPage/FileTree/FileTree.tsx @@ -14,6 +14,7 @@ type Props = { onPreview: (src: string | any, index: number, isInternal?: boolean) => void; getInternalByPath: (path: string) => { data: any; index: number } | undefined; getJsonByPath?: (path: string) => any; + highlightText?: string; }; const formatSize = (n: number) => { @@ -32,6 +33,7 @@ const FileTree: React.FC = ({ onPreview, getInternalByPath, getJsonByPath, + highlightText, }) => ( = ({ onPreview={onPreview} getInternalByPath={getInternalByPath} getJsonByPath={getJsonByPath} + highlightText={highlightText} /> // pass the handlePreview(onPreview = handlePreview) function to FileTreeRow ))} diff --git a/src/components/DatasetDetailPage/FileTree/FileTreeRow.tsx b/src/components/DatasetDetailPage/FileTree/FileTreeRow.tsx index d490104..e9ee706 100644 --- a/src/components/DatasetDetailPage/FileTree/FileTreeRow.tsx +++ b/src/components/DatasetDetailPage/FileTree/FileTreeRow.tsx @@ -14,6 +14,7 @@ import { Box, Button, Collapse, Typography } from "@mui/material"; import { Tooltip, IconButton } from "@mui/material"; import { Colors } from "design/theme"; import React, { useState } from "react"; +import { Color } from "three"; // show more / show less button for long string const LeafString: React.FC<{ value: string }> = ({ value }) => { @@ -80,11 +81,11 @@ const LeafString: React.FC<{ value: string }> = ({ value }) => { type Props = { node: TreeNode; level: number; - // src is either an external URL(string) or the internal object onPreview: (src: string | any, index: number, isInternal?: boolean) => void; getInternalByPath: (path: string) => { data: any; index: number } | undefined; getJsonByPath?: (path: string) => any; + highlightText?: string; }; // copy helper function @@ -112,6 +113,7 @@ const FileTreeRow: React.FC = ({ onPreview, getInternalByPath, getJsonByPath, + highlightText, }) => { const [open, setOpen] = useState(false); const [copied, setCopied] = useState(false); @@ -120,6 +122,34 @@ const FileTreeRow: React.FC = ({ const internal = getInternalByPath(node.path); const externalUrl = node.link?.url; + const rowRef = React.useRef(null); + // Highlight only if this row is exactly the subject folder (e.g., "sub-04") + const isSubjectFolder = + node.kind === "folder" && /^sub-[A-Za-z0-9]+$/i.test(node.name); + const isExactHit = + !!highlightText && + isSubjectFolder && + node.name.toLowerCase() === highlightText.toLowerCase(); + + React.useEffect(() => { + if (isExactHit && rowRef.current) { + rowRef.current.scrollIntoView({ behavior: "smooth", block: "center" }); + // subtle flash + // rowRef.current.animate( + // [ + // { backgroundColor: `${Colors.yellow}`, offset: 0 }, // turn yellow + // { backgroundColor: `${Colors.yellow}`, offset: 0.85 }, // stay yellow 85% of time + // { backgroundColor: "transparent", offset: 1 }, // then fade out + // ], + // { duration: 8000, easing: "ease", fill: "forwards" } + // ); + } + }, [isExactHit]); + + const rowHighlightSx = isExactHit + ? { backgroundColor: `${Colors.yellow}`, borderRadius: 4 } + : {}; + const handleCopy = async (e: React.MouseEvent) => { e.stopPropagation(); // prevent expand/ collapse from firing when click the copy button const json = getJsonByPath?.(node.path); // call getJsonByPath(node.path) @@ -136,6 +166,7 @@ const FileTreeRow: React.FC = ({ return ( <> = ({ py: 0.5, px: 1, cursor: "pointer", + ...rowHighlightSx, "&:hover": { backgroundColor: "rgba(0,0,0,0.04)" }, }} onClick={() => setOpen((o) => !o)} @@ -252,6 +284,7 @@ const FileTreeRow: React.FC = ({ onPreview={onPreview} getInternalByPath={getInternalByPath} getJsonByPath={getJsonByPath} + highlightText={highlightText} // for subject highlight /> ))} @@ -308,31 +341,6 @@ const FileTreeRow: React.FC = ({ : formatLeafValue(node.value)} ))} - - {/* {!node.link && node.value !== undefined && ( - - {node.name === "_ArrayZipData_" - ? "[compressed data]" - : formatLeafValue(node.value)} - - )} */} {/* ALWAYS show copy for files, even when no external/internal */} diff --git a/src/components/SearchPage/SubjectCard.tsx b/src/components/SearchPage/SubjectCard.tsx index 3700086..1998350 100644 --- a/src/components/SearchPage/SubjectCard.tsx +++ b/src/components/SearchPage/SubjectCard.tsx @@ -34,6 +34,15 @@ const SubjectCard: React.FC = ({ }) => { const { modalities, tasks, sessions, types } = parsedJson.value; const subjectLink = `${RoutesEnum.DATABASES}/${dbname}/${dsname}`; + // const subjectLink = `${ + // RoutesEnum.DATABASES + // }/${dbname}/${dsname}?focusSubj=${encodeURIComponent(subj)}`; + const canonicalSubj = /^sub-/i.test(subj) + ? subj + : `sub-${String(subj) + .replace(/^sub-/i, "") + .replace(/^0+/, "") + .padStart(2, "0")}`; // get the gender of subject const genderCode = parsedJson?.key?.[1]; @@ -84,8 +93,9 @@ const SubjectCard: React.FC = ({ ":hover": { textDecoration: "underline" }, }} component={Link} - to={subjectLink} - target="_blank" + // to={subjectLink} + to={`${subjectLink}?focusSubj=${encodeURIComponent(canonicalSubj)}`} + // target="_blank" > Subject: {subj}   |   Dataset: {dsname} diff --git a/src/pages/UpdatedDatasetDetailPage.tsx b/src/pages/UpdatedDatasetDetailPage.tsx index c63eda7..bf2bc5f 100644 --- a/src/pages/UpdatedDatasetDetailPage.tsx +++ b/src/pages/UpdatedDatasetDetailPage.tsx @@ -54,8 +54,16 @@ interface InternalDataLink { const UpdatedDatasetDetailPage: React.FC = () => { const { dbName, docId } = useParams<{ dbName: string; docId: string }>(); const navigate = useNavigate(); - // for revision const [searchParams, setSearchParams] = useSearchParams(); + // for subject highlight + const focusSubjRaw = searchParams.get("focusSubj") || undefined; + const focusSubj = !focusSubjRaw + ? undefined + : /^sub-/i.test(focusSubjRaw) + ? focusSubjRaw + : `sub-${focusSubjRaw.replace(/^0+/, "").padStart(2, "0")}`; + console.log("focusSubj", focusSubj); + // for revision const rev = searchParams.get("rev") || undefined; const handleSelectRevision = (newRev?: string | null) => { @@ -835,6 +843,7 @@ const UpdatedDatasetDetailPage: React.FC = () => { onPreview={handlePreview} // pass the function down to FileTree getInternalByPath={getInternalByPath} getJsonByPath={getJsonByPath} + highlightText={focusSubj} // for subject highlight /> @@ -1156,7 +1165,7 @@ const UpdatedDatasetDetailPage: React.FC = () => { }} > - {/* Date: Wed, 24 Sep 2025 11:31:05 -0400 Subject: [PATCH 4/9] feat: accept any registry dbname via url param and alert on invaild names; closes #95 --- src/pages/SearchPage.tsx | 100 +++++++++++++++------------------------ 1 file changed, 39 insertions(+), 61 deletions(-) diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index e09c020..dc36201 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -12,6 +12,7 @@ import { Drawer, Tooltip, IconButton, + Alert, } from "@mui/material"; import { useTheme } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; @@ -54,9 +55,12 @@ const matchesKeyword = (item: RegistryItem, keyword: string) => { ); }; -const getPresetKey = () => { - return new URLSearchParams(window.location.search).get("preset"); -}; +// const getDbnameKey = () => { +// return new URLSearchParams(window.location.search).get("dbname"); +// }; +const getDbnameFromURL = () => + new URLSearchParams(window.location.search).get("dbname")?.trim() || ""; +// const [invalidDbNotice, setInvalidDbNotice] = useState(null); const SearchPage: React.FC = () => { const dispatch = useAppDispatch(); @@ -72,7 +76,7 @@ const SearchPage: React.FC = () => { const [formData, setFormData] = useState>({}); const [showSubjectFilters, setShowSubjectFilters] = useState(false); const [showDatasetFilters, setShowDatasetFilters] = useState(true); // for dataset-level filters - + const [invalidDbNotice, setInvalidDbNotice] = useState(null); const [results, setResults] = useState< any[] | { status: string; msg: string } >([]); @@ -124,18 +128,31 @@ const SearchPage: React.FC = () => { useEffect(() => { // If a #query=... already exists, existing effect will handle it. if (window.location.hash.startsWith("#query=")) return; - - const key = getPresetKey(); // "openneuro" - if (key === "openneuro") { - const initial = { database: "openneuro" }; + if (!Array.isArray(registry) || registry.length === 0) return; // wait until registry is loaded + // const key = getDbnameKey(); // "openneuro" + const urlDb = getDbnameFromURL(); // e.g., "openneuro", "bfnirs", etc. + if (!urlDb) return; + // case-insensitive match against registry ids + const match = (registry as RegistryItem[]).find( + (r) => String(r.id).toLowerCase() === urlDb.toLowerCase() + ); + // if (!match) return; // unknown dbname; do nothing + + if (match) { + const initial = { database: match.id }; // set initial form/filter state setFormData(initial); setAppliedFilters(initial); setHasSearched(false); // set it to true if want to auto-run search setShowSubjectFilters(true); // expand the subject-level section setShowDatasetFilters(false); // collapse the dataset-level section + } else { + setInvalidDbNotice( + `Database “${urlDb}” isn’t available. Showing all databases instead.` + ); + return; } - }, []); + }, [registry]); // parse query from url on page load useEffect(() => { @@ -474,24 +491,6 @@ const SearchPage: React.FC = () => { )} - {/* before submit box */} - {/* - {!hasSearched && ( - - Use the filters and click submit to search for datasets or - subjects based on metadata. - - )} - */} - {/* after submit box */} { )} - {/* {!hasSearched && ( - - Use the filters and click submit to search for{" "} - - datasets - {" "} - and{" "} - - subjects - {" "} - based on metadata. - - )} */} - + {/* if the dbname in the url is invalid */} + {invalidDbNotice && ( + + setInvalidDbNotice(null)} + sx={{ border: `1px solid ${Colors.lightGray}` }} + > + {invalidDbNotice} + + + )} + {/* suggested databases */} {registryMatches.length > 0 && ( Date: Wed, 24 Sep 2025 12:05:31 -0400 Subject: [PATCH 5/9] fix(search): stop showing undefined age in subject card --- src/components/SearchPage/SubjectCard.tsx | 11 ++++------- src/pages/SearchPage.tsx | 1 + src/pages/UpdatedDatasetDetailPage.tsx | 6 +++--- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/components/SearchPage/SubjectCard.tsx b/src/components/SearchPage/SubjectCard.tsx index 1998350..5aeb6e0 100644 --- a/src/components/SearchPage/SubjectCard.tsx +++ b/src/components/SearchPage/SubjectCard.tsx @@ -8,7 +8,7 @@ import RoutesEnum from "types/routes.enum"; interface SubjectCardProps { dbname: string; dsname: string; - age: string; + agemin: string; subj: string; parsedJson: { key: string[]; @@ -26,7 +26,7 @@ interface SubjectCardProps { const SubjectCard: React.FC = ({ dbname, dsname, - age, + agemin, subj, parsedJson, index, @@ -34,9 +34,6 @@ const SubjectCard: React.FC = ({ }) => { const { modalities, tasks, sessions, types } = parsedJson.value; const subjectLink = `${RoutesEnum.DATABASES}/${dbname}/${dsname}`; - // const subjectLink = `${ - // RoutesEnum.DATABASES - // }/${dbname}/${dsname}?focusSubj=${encodeURIComponent(subj)}`; const canonicalSubj = /^sub-/i.test(subj) ? subj : `sub-${String(subj) @@ -55,8 +52,8 @@ const SubjectCard: React.FC = ({ // cover age string to readable format let ageDisplay = "N/A"; - if (age) { - const ageNum = parseInt(age, 10) / 100; + if (agemin) { + const ageNum = parseInt(agemin, 10) / 100; if (Number.isInteger(ageNum)) { ageDisplay = `${ageNum} years`; } else { diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index dc36201..b7b5e1d 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -814,6 +814,7 @@ const SearchPage: React.FC = () => { paginatedResults.length > 0 && paginatedResults.map((item, idx) => { try { + // console.log("item:", item); const parsedJson = JSON.parse(item.json); const globalIndex = (page - 1) * itemsPerPage + idx; diff --git a/src/pages/UpdatedDatasetDetailPage.tsx b/src/pages/UpdatedDatasetDetailPage.tsx index bf2bc5f..1691557 100644 --- a/src/pages/UpdatedDatasetDetailPage.tsx +++ b/src/pages/UpdatedDatasetDetailPage.tsx @@ -62,7 +62,7 @@ const UpdatedDatasetDetailPage: React.FC = () => { : /^sub-/i.test(focusSubjRaw) ? focusSubjRaw : `sub-${focusSubjRaw.replace(/^0+/, "").padStart(2, "0")}`; - console.log("focusSubj", focusSubj); + // for revision const rev = searchParams.get("rev") || undefined; @@ -210,7 +210,7 @@ const UpdatedDatasetDetailPage: React.FC = () => { obj.MeshNode?.hasOwnProperty("_ArrayZipData_") && typeof obj.MeshNode["_ArrayZipData_"] === "string" ) { - console.log("path", path); + // console.log("path", path); internalLinks.push({ name: "JMesh", data: obj, @@ -297,7 +297,7 @@ const UpdatedDatasetDetailPage: React.FC = () => { useEffect(() => { if (datasetDocument) { // Extract External Data & Assign `index` - console.log("datasetDocument", datasetDocument); + // console.log("datasetDocument", datasetDocument); const links = extractDataLinks(datasetDocument, "").map( (link, index) => ({ ...link, From d9e7dce50c43d07e897c15f17528c56f30679828 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Wed, 24 Sep 2025 14:27:55 -0400 Subject: [PATCH 6/9] fix(search): correct dataset count on database card --- src/components/SearchPage/DatabaseCard.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/components/SearchPage/DatabaseCard.tsx b/src/components/SearchPage/DatabaseCard.tsx index f1525e8..2399deb 100644 --- a/src/components/SearchPage/DatabaseCard.tsx +++ b/src/components/SearchPage/DatabaseCard.tsx @@ -8,8 +8,12 @@ import { Avatar, } from "@mui/material"; import { Colors } from "design/theme"; +import { useAppDispatch } from "hooks/useAppDispatch"; +import { useAppSelector } from "hooks/useAppSelector"; import React from "react"; +import { useEffect } from "react"; import { Link } from "react-router-dom"; +import { fetchDbInfo } from "redux/neurojson/neurojson.action"; import RoutesEnum from "types/routes.enum"; import { modalityValueToEnumLabel } from "utils/SearchPageFunctions/modalityLabels"; @@ -32,6 +36,10 @@ const DatabaseCard: React.FC = ({ keyword, onChipClick, }) => { + const dispatch = useAppDispatch(); + const { loading, error, data, limit } = useAppSelector( + (state) => state.neurojson + ); const databaseLink = `${RoutesEnum.DATABASES}/${dbId}`; // keyword hightlight functional component const highlightKeyword = (text: string, keyword?: string) => { @@ -42,6 +50,12 @@ const DatabaseCard: React.FC = ({ const regex = new RegExp(`(${keyword})`, "gi"); // for case-insensitive and global const parts = text.split(regex); + useEffect(() => { + if (dbId) { + dispatch(fetchDbInfo(dbId.toLowerCase())); + } + }, [dbId, dispatch]); + return ( <> {parts.map((part, i) => @@ -181,7 +195,8 @@ const DatabaseCard: React.FC = ({ - Datasets: {datasets ?? "N/A"} + {/* Datasets: {datasets ?? "N/A"} */} + Datasets: {limit ?? "N/A"} From 3c4c2aa3141c26e4b5f5333887785d94a33f3c6f Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Wed, 24 Sep 2025 15:23:11 -0400 Subject: [PATCH 7/9] feat: make modalities in detadatapanel clickable to run a search for selected modality; closes #97 --- .../DatasetDetailPage/MetaDataPanel.tsx | 64 ++++++++++++++++++- src/pages/SearchPage.tsx | 2 + 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/components/DatasetDetailPage/MetaDataPanel.tsx b/src/components/DatasetDetailPage/MetaDataPanel.tsx index b4b7573..388dca2 100644 --- a/src/components/DatasetDetailPage/MetaDataPanel.tsx +++ b/src/components/DatasetDetailPage/MetaDataPanel.tsx @@ -11,7 +11,9 @@ import { Tooltip, } from "@mui/material"; import { Colors } from "design/theme"; +import pako from "pako"; import React, { useMemo, useState } from "react"; +import { modalityValueToEnumLabel } from "utils/SearchPageFunctions/modalityLabels"; type Props = { dbViewInfo: any; @@ -63,6 +65,19 @@ const MetaDataPanel: React.FC = ({ // const [revIdx, setRevIdx] = useState(0); // const selected = revs[revIdx]; + // builds /search#query= + const buildSearchUrl = (query: Record) => { + const deflated = pako.deflate(JSON.stringify(query)); + const encoded = btoa(String.fromCharCode(...deflated)); + return `${window.location.origin}/search#query=${encoded}`; + }; + + const openSearchForModality = (mod: string) => { + const normalized = modalityValueToEnumLabel[mod] || mod; + const url = buildSearchUrl({ modality: normalized }); + window.open(url, "_blank", "noopener,noreferrer"); + }; + return ( = ({ Modalities - + + {(() => { + const mods = Array.isArray(dbViewInfo?.rows?.[0]?.value?.modality) + ? [...new Set(dbViewInfo.rows[0].value.modality as string[])] + : []; + + if (mods.length === 0) { + return ( + N/A + ); + } + + return ( + + {mods.map((m) => ( + openSearchForModality(m)} + variant="outlined" + sx={{ + "& .MuiChip-label": { + paddingX: "7px", + fontSize: "0.8rem", + }, + height: "24px", + color: Colors.white, + border: `1px solid ${Colors.orange}`, + fontWeight: "bold", + transition: "all 0.2s ease", + backgroundColor: `${Colors.orange} !important`, + "&:hover": { + backgroundColor: `${Colors.darkOrange} !important`, + color: "white", + borderColor: Colors.darkOrange, + paddingX: "8px", + fontSize: "1rem", + }, + }} + /> + ))} + + ); + })()} + {/* {dbViewInfo?.rows?.[0]?.value?.modality?.join(", ") ?? "N/A"} - + */} diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index b7b5e1d..8e35494 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -170,6 +170,8 @@ const SearchPage: React.FC = () => { const requestData = { ...parsed, skip: 0, limit: 50 }; setSkip(0); setHasSearched(true); + setShowSubjectFilters(true); // expand the subject-level section + setShowDatasetFilters(true); // expand the dataset-level section dispatch(fetchMetadataSearchResults(requestData)).then((res: any) => { if (res.payload) { setResults(res.payload); From 8d4c7750303258bc27bc836448da25e024a1ae98 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Thu, 25 Sep 2025 10:13:50 -0400 Subject: [PATCH 8/9] fix(search): dataset count in database card --- .../DatasetDetailPage/MetaDataPanel.tsx | 8 +++---- src/components/SearchPage/DatabaseCard.tsx | 22 +++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/components/DatasetDetailPage/MetaDataPanel.tsx b/src/components/DatasetDetailPage/MetaDataPanel.tsx index 388dca2..5c1c618 100644 --- a/src/components/DatasetDetailPage/MetaDataPanel.tsx +++ b/src/components/DatasetDetailPage/MetaDataPanel.tsx @@ -106,7 +106,7 @@ const MetaDataPanel: React.FC = ({ Modalities - {(() => { + {/* {(() => { const mods = Array.isArray(dbViewInfo?.rows?.[0]?.value?.modality) ? [...new Set(dbViewInfo.rows[0].value.modality as string[])] : []; @@ -149,10 +149,10 @@ const MetaDataPanel: React.FC = ({ ))} ); - })()} - {/* + })()} */} + {dbViewInfo?.rows?.[0]?.value?.modality?.join(", ") ?? "N/A"} - */} + diff --git a/src/components/SearchPage/DatabaseCard.tsx b/src/components/SearchPage/DatabaseCard.tsx index 2399deb..025be91 100644 --- a/src/components/SearchPage/DatabaseCard.tsx +++ b/src/components/SearchPage/DatabaseCard.tsx @@ -14,6 +14,7 @@ import React from "react"; import { useEffect } from "react"; import { Link } from "react-router-dom"; import { fetchDbInfo } from "redux/neurojson/neurojson.action"; +import { RootState } from "redux/store"; import RoutesEnum from "types/routes.enum"; import { modalityValueToEnumLabel } from "utils/SearchPageFunctions/modalityLabels"; @@ -37,9 +38,13 @@ const DatabaseCard: React.FC = ({ onChipClick, }) => { const dispatch = useAppDispatch(); - const { loading, error, data, limit } = useAppSelector( - (state) => state.neurojson - ); + const dbInfo = useAppSelector((state: RootState) => state.neurojson.dbInfo); + console.log("dbInfo", dbInfo); + useEffect(() => { + if (dbId) { + dispatch(fetchDbInfo(dbId.toLowerCase())); + } + }, [dbId, dispatch]); const databaseLink = `${RoutesEnum.DATABASES}/${dbId}`; // keyword hightlight functional component const highlightKeyword = (text: string, keyword?: string) => { @@ -50,12 +55,6 @@ const DatabaseCard: React.FC = ({ const regex = new RegExp(`(${keyword})`, "gi"); // for case-insensitive and global const parts = text.split(regex); - useEffect(() => { - if (dbId) { - dispatch(fetchDbInfo(dbId.toLowerCase())); - } - }, [dbId, dispatch]); - return ( <> {parts.map((part, i) => @@ -195,8 +194,9 @@ const DatabaseCard: React.FC = ({ - {/* Datasets: {datasets ?? "N/A"} */} - Datasets: {limit ?? "N/A"} + Datasets: {datasets ?? "N/A"} + {/* Datasets:{" "} + {dbInfo?.doc_count != null ? dbInfo.doc_count - 1 : "N/A"} */} From b94dcbb118c3c3ca0a71855db40fe1064db61e9c Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Thu, 25 Sep 2025 16:26:14 -0400 Subject: [PATCH 9/9] =?UTF-8?q?feat(dataset-detail):=20add=20=E2=80=9CCopy?= =?UTF-8?q?=20URL=E2=80=9D=20next=20to=20Preview=20for=20previewable=20dat?= =?UTF-8?q?a;=20refs=20#98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DatasetDetailPage/MetaDataPanel.tsx | 8 +- src/pages/UpdatedDatasetDetailPage.tsx | 167 ++++++++++++++---- 2 files changed, 139 insertions(+), 36 deletions(-) diff --git a/src/components/DatasetDetailPage/MetaDataPanel.tsx b/src/components/DatasetDetailPage/MetaDataPanel.tsx index 5c1c618..388dca2 100644 --- a/src/components/DatasetDetailPage/MetaDataPanel.tsx +++ b/src/components/DatasetDetailPage/MetaDataPanel.tsx @@ -106,7 +106,7 @@ const MetaDataPanel: React.FC = ({ Modalities - {/* {(() => { + {(() => { const mods = Array.isArray(dbViewInfo?.rows?.[0]?.value?.modality) ? [...new Set(dbViewInfo.rows[0].value.modality as string[])] : []; @@ -149,10 +149,10 @@ const MetaDataPanel: React.FC = ({ ))} ); - })()} */} - + })()} + {/* {dbViewInfo?.rows?.[0]?.value?.modality?.join(", ") ?? "N/A"} - + */} diff --git a/src/pages/UpdatedDatasetDetailPage.tsx b/src/pages/UpdatedDatasetDetailPage.tsx index 1691557..d1f8735 100644 --- a/src/pages/UpdatedDatasetDetailPage.tsx +++ b/src/pages/UpdatedDatasetDetailPage.tsx @@ -1,5 +1,6 @@ import PreviewModal from "../components/PreviewModal"; import CloudDownloadIcon from "@mui/icons-material/CloudDownload"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import DescriptionIcon from "@mui/icons-material/Description"; import ExpandLess from "@mui/icons-material/ExpandLess"; import ExpandMore from "@mui/icons-material/ExpandMore"; @@ -12,6 +13,7 @@ import { Alert, Button, Collapse, + Snackbar, } from "@mui/material"; import FileTree from "components/DatasetDetailPage/FileTree/FileTree"; import { @@ -93,20 +95,16 @@ const UpdatedDatasetDetailPage: React.FC = () => { const [isExternalExpanded, setIsExternalExpanded] = useState(true); const [jsonSize, setJsonSize] = useState(0); const [previewIndex, setPreviewIndex] = useState(0); + const [copiedToast, setCopiedToast] = useState<{ + open: boolean; + text: string; + }>({ + open: false, + text: "", + }); const aiSummary = datasetDocument?.[".datainfo"]?.AISummary ?? ""; - // useEffect(() => { - // if (!datasetDocument) { - // setJsonSize(0); - // return; - // } - // const bytes = new TextEncoder().encode( - // JSON.stringify(datasetDocument) - // ).length; - // setJsonSize(bytes); - // }, [datasetDocument]); - - const linkMap = useMemo(() => makeLinkMap(externalLinks), [externalLinks]); + const linkMap = useMemo(() => makeLinkMap(externalLinks), [externalLinks]); // => external Link Map const treeData = useMemo( () => buildTreeFromDoc(datasetDocument || {}, linkMap, ""), @@ -262,6 +260,32 @@ const UpdatedDatasetDetailPage: React.FC = () => { return internalLinks; }; + // Build a shareable preview URL for a JSON path in this dataset + const buildPreviewUrl = (path: string) => { + const origin = window.location.origin; + const revPart = rev ? `rev=${encodeURIComponent(rev)}&` : ""; + return `${origin}/db/${dbName}/${docId}?${revPart}preview=${encodeURIComponent( + path + )}`; + }; + + // Copy helper + const copyPreviewUrl = async (path: string) => { + const url = buildPreviewUrl(path); + try { + await navigator.clipboard.writeText(url); + setCopiedToast({ open: true, text: "Preview link copied" }); + } catch { + // fallback + const ta = document.createElement("textarea"); + ta.value = url; + document.body.appendChild(ta); + ta.select(); + document.execCommand("copy"); + document.body.removeChild(ta); + setCopiedToast({ open: true, text: "Preview link copied" }); + } + }; // useEffect(() => { // const fetchData = async () => { @@ -386,6 +410,13 @@ const UpdatedDatasetDetailPage: React.FC = () => { } }, [datasetDocument, docId]); + // const externalMap = React.useMemo(() => { + // const m = new Map(); + // for (const it of externalLinks) + // m.set(it.path, { url: it.url, index: it.index }); + // return m; + // }, [externalLinks]); + const [previewOpen, setPreviewOpen] = useState(false); const [previewDataKey, setPreviewDataKey] = useState(null); @@ -563,6 +594,34 @@ const UpdatedDatasetDetailPage: React.FC = () => { [datasetDocument] ); + useEffect(() => { + const p = searchParams.get("preview"); + if (!p || !datasetDocument) return; + + const previewPath = decodeURIComponent(p); + + // Try internal data first + const internal = internalMap.get(previewPath); + if (internal) { + handlePreview(internal.data, internal.index, true); + return; + } + + // Then try external data by JSON path + const external = linkMap.get(previewPath); + if (external) { + handlePreview(external.url, external.index, false); + } + }, [ + datasetDocument, + internalLinks, + externalLinks, + searchParams, + internalMap, + // externalMap, + linkMap, + ]); + const handleClosePreview = () => { setPreviewOpen(false); setPreviewDataKey(null); @@ -976,26 +1035,48 @@ const UpdatedDatasetDetailPage: React.FC = () => { {link.name}{" "} {link.arraySize ? `[${link.arraySize.join("x")}]` : ""} - + + + {/* */} + )) ) : ( @@ -1129,6 +1210,28 @@ const UpdatedDatasetDetailPage: React.FC = () => { Preview )} + {/* {isPreviewable && ( + + )} */} );