diff --git a/CHANGELOG-cat-831.md b/CHANGELOG-cat-831.md new file mode 100644 index 0000000000..c902d74b39 --- /dev/null +++ b/CHANGELOG-cat-831.md @@ -0,0 +1,3 @@ +- Extend provenance table logic to handle missing entities. +- Fix handling of large search requests. +- Request less data for provenance table tiles. diff --git a/CHANGELOG-processed-datasets.md b/CHANGELOG-processed-datasets.md new file mode 100644 index 0000000000..8a3b1c1562 --- /dev/null +++ b/CHANGELOG-processed-datasets.md @@ -0,0 +1 @@ +- Added processed datasets section to display all visualizations for a given raw dataset. diff --git a/context/app/routes_browse.py b/context/app/routes_browse.py index ca8048cdd6..8e72d2915e 100644 --- a/context/app/routes_browse.py +++ b/context/app/routes_browse.py @@ -1,5 +1,5 @@ import json -from urllib.parse import urlparse +from urllib.parse import urlparse, quote from flask import ( render_template, jsonify, @@ -47,22 +47,49 @@ def details(type, uuid): client = get_client() entity = client.get_entity(uuid) actual_type = entity['entity_type'].lower() + + # Redirect to primary dataset if this is + # - a support entity (e.g. an image pyramid) + # - a processed or component dataset + is_support = actual_type == 'support' + is_processed = entity.get('processing') != 'raw' and actual_type == 'dataset' + is_component = entity.get('is_component', False) is True + if (is_support or is_processed or is_component): + supported_entity = client.get_entities( + 'datasets', + query_override={ + "bool": { + "must": { + "terms": { + "descendant_ids": [uuid] + } + } + } + }, + non_metadata_fields=['hubmap_id', 'uuid'] + ) + + pipeline_anchor = entity.get('pipeline', entity.get('hubmap_id')).replace(' ', '') + anchor = quote(f'section-{pipeline_anchor}-{entity.get("status")}').lower() + + if len(supported_entity) > 0: + return redirect( + url_for('routes_browse.details', + type='dataset', + uuid=supported_entity[0]['uuid'], + _anchor=anchor, + redirected=True)) + if type != actual_type: return redirect(url_for('routes_browse.details', type=actual_type, uuid=uuid)) + redirected = request.args.get('redirected') == 'True' + flask_data = { **get_default_flask_data(), 'entity': entity, + 'redirected': redirected, } - marker = request.args.get('marker') - - if type == 'dataset': - conf_cells_uuid = client.get_vitessce_conf_cells_and_lifted_uuid(entity, marker=marker) - flask_data.update({ - 'vitessce_conf': conf_cells_uuid.vitessce_conf.conf, - 'has_notebook': conf_cells_uuid.vitessce_conf.cells is not None, - 'vis_lifted_uuid': conf_cells_uuid.vis_lifted_uuid - }) if type == 'publication': publication_ancillary_data = client.get_publication_ancillary_json(entity) @@ -93,7 +120,11 @@ def details_vitessce(type, uuid): abort(404) client = get_client() entity = client.get_entity(uuid) - vitessce_conf = client.get_vitessce_conf_cells_and_lifted_uuid(entity).vitessce_conf + parent_uuid = request.args.get('parent') or None + marker = request.args.get('marker') or None + parent = client.get_entity(parent_uuid) if parent_uuid else None + vitessce_conf = client.get_vitessce_conf_cells_and_lifted_uuid( + entity, marker=marker, parent=parent).vitessce_conf # Returns a JSON null if there is no visualization. response = jsonify(vitessce_conf.conf) response.headers.add("Access-Control-Allow-Origin", "*") diff --git a/context/app/static/js/components/Contexts.tsx b/context/app/static/js/components/Contexts.tsx index c3a55978bd..2c32016779 100644 --- a/context/app/static/js/components/Contexts.tsx +++ b/context/app/static/js/components/Contexts.tsx @@ -9,6 +9,7 @@ export interface FlaskDataContextType { [key: string]: unknown; title: string; // preview page title vis_lifted_uuid?: string; + redirected?: boolean; } export const FlaskDataContext = createContext('FlaskDataContext'); diff --git a/context/app/static/js/components/Providers.jsx b/context/app/static/js/components/Providers.jsx index 97898f28d0..d9b6a82557 100644 --- a/context/app/static/js/components/Providers.jsx +++ b/context/app/static/js/components/Providers.jsx @@ -9,6 +9,7 @@ import { FlaskDataContext, AppContext } from 'js/components/Contexts'; import GlobalStyles from 'js/components/globalStyles'; import { ProtocolAPIContext } from 'js/components/detailPage/Protocol/ProtocolAPIContext'; import { EntityStoreProvider } from 'js/stores/useEntityStore'; +import { InitialHashContextProvider } from 'js/hooks/useInitialHash'; import theme from '../theme'; import GlobalFonts from '../fonts'; import { useEntityHeaderSprings } from './detailPage/entityHeader/EntityHeader/hooks'; @@ -63,22 +64,24 @@ export default function Providers({ return ( - - - - - - - - - - {children} - - - - - - + + + + + + + + + + + {children} + + + + + + + ); } diff --git a/context/app/static/js/components/Routes/Routes.jsx b/context/app/static/js/components/Routes/Routes.jsx index c8f91a2004..1ffc354be9 100644 --- a/context/app/static/js/components/Routes/Routes.jsx +++ b/context/app/static/js/components/Routes/Routes.jsx @@ -45,8 +45,6 @@ function Routes({ flaskData }) { markdown, errorCode, list_uuid, - has_notebook, - vis_lifted_uuid, entities, organs, organs_count, @@ -86,12 +84,7 @@ function Routes({ flaskData }) { if (urlPath.startsWith('/browse/dataset/') || urlPath.startsWith('/browse/support/')) { return ( - + ); } @@ -341,6 +334,7 @@ Routes.propTypes = { redirected_from: PropTypes.string, cell_type: PropTypes.string, globusGroups: PropTypes.object, + redirected: PropTypes.bool, }), }; diff --git a/context/app/static/js/components/cell-types/CellTypesBiomarkersTable.tsx b/context/app/static/js/components/cell-types/CellTypesBiomarkersTable.tsx index f3ea3ee54b..7daf2acbd9 100644 --- a/context/app/static/js/components/cell-types/CellTypesBiomarkersTable.tsx +++ b/context/app/static/js/components/cell-types/CellTypesBiomarkersTable.tsx @@ -12,7 +12,6 @@ import TableCell from '@mui/material/TableCell'; import TableRow from '@mui/material/TableRow'; import { capitalize } from '@mui/material/utils'; -import SectionHeader from 'js/shared-styles/sections/SectionHeader'; import Description from 'js/shared-styles/sections/Description'; import { useTabs } from 'js/shared-styles/tabs'; import { Tab, Tabs, TabPanel } from 'js/shared-styles/tables/TableTabs'; @@ -20,7 +19,7 @@ import { StyledTableContainer } from 'js/shared-styles/tables'; import { InternalLink } from 'js/shared-styles/Links'; import { CellTypeBiomarkerInfo } from 'js/hooks/useUBKG'; -import DetailPageSection from '../detailPage/DetailPageSection'; +import { CollapsibleDetailPageSection } from '../detailPage/DetailPageSection'; import { useCellTypeBiomarkers } from './hooks'; const tableKeys = ['genes', 'proteins'] as const; @@ -80,8 +79,7 @@ export default function CellTypesBiomarkersTable() { const { openTabIndex, handleTabChange } = useTabs(); return ( - - Biomarkers + This is a list of identified biomarkers that are validated from the listed source. Explore other sources in @@ -118,6 +116,6 @@ export default function CellTypesBiomarkersTable() { ))} - + ); } diff --git a/context/app/static/js/components/cell-types/CellTypesEntitiesTables.tsx b/context/app/static/js/components/cell-types/CellTypesEntitiesTables.tsx index 65218a6212..432087787b 100644 --- a/context/app/static/js/components/cell-types/CellTypesEntitiesTables.tsx +++ b/context/app/static/js/components/cell-types/CellTypesEntitiesTables.tsx @@ -1,4 +1,3 @@ -import SectionHeader from 'js/shared-styles/sections/SectionHeader'; import React, { Fragment } from 'react'; import Description from 'js/shared-styles/sections/Description'; import { useTabs } from 'js/shared-styles/tabs'; @@ -13,7 +12,7 @@ import { format } from 'date-fns/format'; import { InternalLink } from 'js/shared-styles/Links'; import Paper from '@mui/material/Paper'; import Stack from '@mui/material/Stack'; -import DetailPageSection from '../detailPage/DetailPageSection'; +import { CollapsibleDetailPageSection } from '../detailPage/DetailPageSection'; import { useCellTypeDetails } from './hooks'; export default function CellTypesEntitiesTables() { @@ -21,8 +20,7 @@ export default function CellTypesEntitiesTables() { const { openTabIndex, handleTabChange } = useTabs(); return ( - - Organs + This is the list of organs and its associated data that is dependent on the data available within HuBMAP. To filter the list of data in the table below by organ, select organ(s) from the list below. Multiple organs can be @@ -71,6 +69,6 @@ export default function CellTypesEntitiesTables() { ))} - + ); } diff --git a/context/app/static/js/components/cell-types/CellTypesVisualization.tsx b/context/app/static/js/components/cell-types/CellTypesVisualization.tsx index 7a8942b5a5..2232d646e0 100644 --- a/context/app/static/js/components/cell-types/CellTypesVisualization.tsx +++ b/context/app/static/js/components/cell-types/CellTypesVisualization.tsx @@ -5,12 +5,11 @@ import Box from '@mui/material/Box'; import Skeleton from '@mui/material/Skeleton'; import { LegendItem, LegendLabel, LegendOrdinal } from '@visx/legend'; -import SectionHeader from 'js/shared-styles/sections/SectionHeader'; import Description from 'js/shared-styles/sections/Description'; import VerticalStackedBarChart from 'js/shared-styles/charts/VerticalStackedBarChart'; import { useBandScale, useLogScale, useOrdinalScale } from 'js/shared-styles/charts/hooks'; -import DetailPageSection from 'js/components/detailPage/DetailPageSection'; +import { CollapsibleDetailPageSection } from 'js/components/detailPage/DetailPageSection'; import { TooltipData } from 'js/shared-styles/charts/types'; import { CellTypeOrgan } from 'js/hooks/useCrossModalityApi'; import { useCellTypeDetails, useCellTypeName } from './hooks'; @@ -158,10 +157,9 @@ export default function CellTypesVisualization() { const { organs = [] } = useCellTypeDetails(); return ( - - Distribution Across Organs + Cell counts in this visualization are dependent on the data available within HuBMAP. - + ); } diff --git a/context/app/static/js/components/detailPage/provenance/ProvAnalysisDetails/ProvAnalysisDetails.spec.js b/context/app/static/js/components/detailPage/AnalysisDetails/AnalysisDetails.spec.tsx similarity index 76% rename from context/app/static/js/components/detailPage/provenance/ProvAnalysisDetails/ProvAnalysisDetails.spec.js rename to context/app/static/js/components/detailPage/AnalysisDetails/AnalysisDetails.spec.tsx index c8c5653f04..29f6b0dc72 100644 --- a/context/app/static/js/components/detailPage/provenance/ProvAnalysisDetails/ProvAnalysisDetails.spec.js +++ b/context/app/static/js/components/detailPage/AnalysisDetails/AnalysisDetails.spec.tsx @@ -1,7 +1,8 @@ import React from 'react'; import { render, screen } from 'test-utils/functions'; -import ProvAnalysisDetails from './ProvAnalysisDetails'; +import { DagProvenanceType } from 'js/components/types'; +import AnalysisDetails from './AnalysisDetails'; test('should display ingest and cwl lists', () => { const dagListData = [ @@ -9,7 +10,7 @@ test('should display ingest and cwl lists', () => { { origin: 'https://github.com/fake2/fake2.git', hash: 'bbbbbbb' }, { origin: 'https://github.com/fake3/fake3.git', hash: 'ccccccc', name: 'fake3.cwl' }, ]; - render(); + render(); expect(screen.getByText('Ingest Pipelines')).toBeInTheDocument(); expect(screen.getByText('CWL Pipelines')).toBeInTheDocument(); @@ -19,8 +20,8 @@ test('should display ingest and cwl lists', () => { }); test('should not display pipelines when pipelines do not exist', () => { - const dagListData = []; - render(); + const dagListData: DagProvenanceType[] = []; + render(); expect(screen.queryByText('Ingest Pipelines')).not.toBeInTheDocument(); expect(screen.queryByText('CWL Pipelines')).not.toBeInTheDocument(); diff --git a/context/app/static/js/components/detailPage/AnalysisDetails/AnalysisDetails.tsx b/context/app/static/js/components/detailPage/AnalysisDetails/AnalysisDetails.tsx new file mode 100644 index 0000000000..deca01089f --- /dev/null +++ b/context/app/static/js/components/detailPage/AnalysisDetails/AnalysisDetails.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import { DagProvenanceType } from 'js/components/types'; +import AnalysisDetailsList from './AnalysisDetailsList'; + +interface AnalysisDetails { + dagListData: DagProvenanceType[]; +} + +function AnalysisDetails({ dagListData }: AnalysisDetails) { + const ingestPipelines = dagListData.filter((pipeline) => !('name' in pipeline)); + const cwlPipelines = dagListData.filter((pipeline) => 'name' in pipeline); + + return ( +
+ {ingestPipelines.length > 0 && ( + + )} + {cwlPipelines.length > 0 && ( + + )} +
+ ); +} + +export default AnalysisDetails; diff --git a/context/app/static/js/components/detailPage/provenance/ProvAnalysisDetailsLink/ProvAnalysisDetailsLink.spec.js b/context/app/static/js/components/detailPage/AnalysisDetails/AnalysisDetailsLink.spec.tsx similarity index 96% rename from context/app/static/js/components/detailPage/provenance/ProvAnalysisDetailsLink/ProvAnalysisDetailsLink.spec.js rename to context/app/static/js/components/detailPage/AnalysisDetails/AnalysisDetailsLink.spec.tsx index 58ac1b8051..3a3b6199bd 100644 --- a/context/app/static/js/components/detailPage/provenance/ProvAnalysisDetailsLink/ProvAnalysisDetailsLink.spec.js +++ b/context/app/static/js/components/detailPage/AnalysisDetails/AnalysisDetailsLink.spec.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { render, screen } from 'test-utils/functions'; -import ProvAnalysisDetailsLink from './ProvAnalysisDetailsLink'; +import ProvAnalysisDetailsLink from './AnalysisDetailsLink'; test('should display ingest pipeline link', () => { const fakePipeline = { origin: 'https://github.com/fake/fake.git', hash: 'aabbccd' }; diff --git a/context/app/static/js/components/detailPage/provenance/ProvAnalysisDetailsLink/ProvAnalysisDetailsLink.jsx b/context/app/static/js/components/detailPage/AnalysisDetails/AnalysisDetailsLink.tsx similarity index 77% rename from context/app/static/js/components/detailPage/provenance/ProvAnalysisDetailsLink/ProvAnalysisDetailsLink.jsx rename to context/app/static/js/components/detailPage/AnalysisDetails/AnalysisDetailsLink.tsx index 0aea352de3..f3efb2077b 100644 --- a/context/app/static/js/components/detailPage/provenance/ProvAnalysisDetailsLink/ProvAnalysisDetailsLink.jsx +++ b/context/app/static/js/components/detailPage/AnalysisDetails/AnalysisDetailsLink.tsx @@ -1,9 +1,13 @@ import React from 'react'; -import PropTypes from 'prop-types'; +import { DagProvenanceType } from 'js/components/types'; import { CwlIcon, FlexOutboundLink, PrimaryTextDivider, StyledListItem } from './style'; -function ProvAnalysisDetailsLink({ data }) { +interface ProvAnalysisDetailsLinkProps { + data: DagProvenanceType; +} + +function ProvAnalysisDetailsLink({ data }: ProvAnalysisDetailsLinkProps) { const trimmedOrigin = data.origin.replace(/\.git$/, ''); const githubUrl = 'name' in data ? `${trimmedOrigin}/blob/${data.hash}/${data.name}` : `${trimmedOrigin}/tree/${data.hash}`; @@ -26,12 +30,4 @@ function ProvAnalysisDetailsLink({ data }) { ); } -ProvAnalysisDetailsLink.propTypes = { - data: PropTypes.shape({ - hash: PropTypes.string, - name: PropTypes.string, - origin: PropTypes.string, - }).isRequired, -}; - export default ProvAnalysisDetailsLink; diff --git a/context/app/static/js/components/detailPage/AnalysisDetails/AnalysisDetailsList.tsx b/context/app/static/js/components/detailPage/AnalysisDetails/AnalysisDetailsList.tsx new file mode 100644 index 0000000000..cdd1918a45 --- /dev/null +++ b/context/app/static/js/components/detailPage/AnalysisDetails/AnalysisDetailsList.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import List from '@mui/material/List'; +import Typography from '@mui/material/Typography'; + +import InfoTooltipIcon from 'js/shared-styles/icons/TooltipIcon'; +import ProvAnalysisDetailsLink from './AnalysisDetailsLink'; + +interface ProvAnalysisDetailsListProps { + pipelines: { + hash: string; + name?: string; + origin: string; + }[]; + pipelineType: string; + tooltip?: string; +} + +function ProvAnalysisDetailsList({ pipelines, pipelineType, tooltip }: ProvAnalysisDetailsListProps) { + return ( + <> + + {`${pipelineType} Pipelines`} + + + {pipelines.map((item) => ( + + ))} + + + ); +} + +export default ProvAnalysisDetailsList; diff --git a/context/app/static/js/components/detailPage/AnalysisDetails/index.ts b/context/app/static/js/components/detailPage/AnalysisDetails/index.ts new file mode 100644 index 0000000000..fba3c3cbcd --- /dev/null +++ b/context/app/static/js/components/detailPage/AnalysisDetails/index.ts @@ -0,0 +1,3 @@ +import AnalysisDetails from './AnalysisDetails'; + +export default AnalysisDetails; diff --git a/context/app/static/js/components/detailPage/AnalysisDetails/style.ts b/context/app/static/js/components/detailPage/AnalysisDetails/style.ts new file mode 100644 index 0000000000..fe6b59ffbb --- /dev/null +++ b/context/app/static/js/components/detailPage/AnalysisDetails/style.ts @@ -0,0 +1,29 @@ +import { styled } from '@mui/material/styles'; +import LaunchRoundedIcon from '@mui/icons-material/LaunchRounded'; +import Divider from '@mui/material/Divider'; + +import OutboundLink from 'js/shared-styles/Links/OutboundLink'; + +const CwlIcon = styled(LaunchRoundedIcon)(({ theme }) => ({ + marginLeft: theme.spacing(0.5), + fontSize: '1rem', + alignSelf: 'center', +})); + +const FlexOutboundLink = styled(OutboundLink)({ + display: 'flex', +}); + +const PrimaryTextDivider = styled(Divider)(({ theme }) => ({ + marginLeft: theme.spacing(0.5), + marginRight: theme.spacing(0.5), + height: '1rem', + backgroundColor: theme.palette.text.primary, + alignSelf: 'center', +})); + +const StyledListItem = styled('li')({ + display: 'flex', +}); + +export { CwlIcon, FlexOutboundLink, PrimaryTextDivider, StyledListItem }; diff --git a/context/app/static/js/components/detailPage/Attribution/Attribution.tsx b/context/app/static/js/components/detailPage/Attribution/Attribution.tsx index 390b8731d7..fa14325eae 100644 --- a/context/app/static/js/components/detailPage/Attribution/Attribution.tsx +++ b/context/app/static/js/components/detailPage/Attribution/Attribution.tsx @@ -1,46 +1,37 @@ -import React from 'react'; +import React, { PropsWithChildren } from 'react'; import Stack from '@mui/material/Stack'; import { useFlaskDataContext } from 'js/components/Contexts'; -import DetailPageSection from 'js/components/detailPage/DetailPageSection'; -import SectionHeader from 'js/shared-styles/sections/SectionHeader'; +import { CollapsibleDetailPageSection } from 'js/components/detailPage/DetailPageSection'; import SummaryPaper from 'js/shared-styles/sections/SectionPaper'; import LabelledSectionText from 'js/shared-styles/sections/LabelledSectionText'; -import { OutlinedAlert } from 'js/shared-styles/alerts/OutlinedAlert.stories'; +import { sectionIconMap } from 'js/shared-styles/icons/sectionIconMap'; import { useAttributionSections } from '../ContributorsTable/hooks'; +import { SectionDescription } from '../ProcessedData/ProcessedDataset/SectionDescription'; const tooltips = { - group: 'This is the group that provided the raw dataset.', + group: 'This is the group that submitted the raw dataset to be published.', contact: 'This is the contact for this data.', }; -function Attribution() { +const DatasetAttribution = ( + + Below is the information for the individuals who provided this dataset. For questions for this dataset, reach out to + the individuals listed as contacts, either via the email address listed in the table or via contact information + provided on their ORCID profile page. + +); + +function Attribution({ children }: PropsWithChildren) { const { - entity: { - group_name, - created_by_user_displayname, - created_by_user_email, - entity_type, - processing, - creation_action, - descendants, - }, + entity: { group_name, created_by_user_displayname, created_by_user_email, entity_type }, } = useFlaskDataContext(); - const isProcessedDataset = entity_type === 'Dataset' && processing === 'processed'; - const isVisLiftedDataset = descendants?.find((descendant) => descendant.dataset_type === 'Histology [Image Pyramid]'); - const isHiveProcessedDataset = isProcessedDataset && creation_action === 'Central Process'; - const isSupportDataset = entity_type === 'Support'; - - const showContactAndAlert = isHiveProcessedDataset || isSupportDataset; - const showRegisteredBy = !isProcessedDataset && !isVisLiftedDataset && !isHiveProcessedDataset && !isSupportDataset; + const isDataset = entity_type === 'Dataset'; - const hiveInfoAlertText = `The data provided by the ${group_name} Group was centrally processed by HuBMAP. The results of this processing are independent of analyses conducted by the data providers or third parties.`; - const iconTooltipText = showRegisteredBy - ? `Information about the group registering this ${entity_type?.toLowerCase()}.` - : undefined; + const showRegisteredBy = !isDataset; const sections = useAttributionSections( group_name, @@ -48,14 +39,12 @@ function Attribution() { created_by_user_email, tooltips, showRegisteredBy, - showContactAndAlert, ); return ( - + - Attribution - {showContactAndAlert && {hiveInfoAlertText}} + {isDataset && DatasetAttribution} {sections.map((props) => ( @@ -63,8 +52,9 @@ function Attribution() { ))} + {children} - + ); } diff --git a/context/app/static/js/components/detailPage/BulkDataTransfer/BulkDataTransferPanels.tsx b/context/app/static/js/components/detailPage/BulkDataTransfer/BulkDataTransferPanels.tsx index ca30b3f5bb..e07c4622fc 100644 --- a/context/app/static/js/components/detailPage/BulkDataTransfer/BulkDataTransferPanels.tsx +++ b/context/app/static/js/components/detailPage/BulkDataTransfer/BulkDataTransferPanels.tsx @@ -1,58 +1,50 @@ import React from 'react'; import Paper from '@mui/material/Paper'; -import { useFlaskDataContext } from 'js/components/Contexts'; - -import { useTrackEntityPageEvent } from 'js/components/detailPage/useTrackEntityPageEvent'; +import Stack from '@mui/material/Stack'; import BulkDataTransferPanel from './BulkDataTransferPanel'; import Link from './Link'; import NoAccess from './NoAccess'; import { usePanelSet } from './usePanelSet'; +import { useStudyURLsQuery } from './hooks'; +import GlobusLink from './GlobusLink'; -function isReactNode(value: unknown): value is React.ReactNode { - return typeof value === 'object' && value !== null && React.isValidElement(value); +interface BulkDataTransferPanelProps { + uuid: string; + label: string; } -function BulkDataTransferPanels() { - const { - entity: { dbgap_study_url, dbgap_sra_experiment_url }, - } = useFlaskDataContext(); - - const trackEntityPageEvent = useTrackEntityPageEvent(); - - const panelsToUse = usePanelSet(); +function BulkDataTransferPanels({ uuid, label }: BulkDataTransferPanelProps) { + const { dbgap_study_url, dbgap_sra_experiment_url, mapped_data_access_level, hubmap_id } = + useStudyURLsQuery(uuid).searchHits[0]?._source || {}; - // Assign dynamic URL's to each type of link - const linkTitleUrlMap = { - dbGaP: dbgap_study_url as string, - 'SRA Experiment': dbgap_sra_experiment_url as string, - } as const; + const panelsToUse = usePanelSet(uuid, dbgap_study_url, mapped_data_access_level); if ('error' in panelsToUse) { return ; } + const { showDbGaP, showGlobus, showSRA, panels } = panelsToUse; + + // This is a logical OR and should remain as such. + // If `showDbGaP` is false, we still want to check if `showGlobus` or `showSRA` are true. + // If we use `??` instead of `||`, the expression would only check if `showDbGaP` is false. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const hasLinks = Boolean(showDbGaP || showGlobus || showSRA); + return ( - <> - {panelsToUse.panels.length > 0 && - panelsToUse.panels.map((panel) => )} - {panelsToUse.links.length > 0 && ( + + {panels.map((panel) => ( + + ))} + {hasLinks && ( - {panelsToUse.links.map((link) => - isReactNode(link) ? ( - link - ) : ( - trackEntityPageEvent({ action: 'Bulk Data Transfer / Panel Link', label: link.key })} - /> - ), - )} + {showGlobus && } + {showDbGaP && } + {showSRA && } )} - + ); } diff --git a/context/app/static/js/components/detailPage/BulkDataTransfer/BulkDataTransferSection.tsx b/context/app/static/js/components/detailPage/BulkDataTransfer/BulkDataTransferSection.tsx index 09a293e032..11d025494a 100644 --- a/context/app/static/js/components/detailPage/BulkDataTransfer/BulkDataTransferSection.tsx +++ b/context/app/static/js/components/detailPage/BulkDataTransfer/BulkDataTransferSection.tsx @@ -1,31 +1,51 @@ -import React from 'react'; +import React, { useState } from 'react'; -import SectionHeader from 'js/shared-styles/sections/SectionHeader'; -import DetailPageSection from 'js/components/detailPage/DetailPageSection'; -import { useFlaskDataContext } from 'js/components/Contexts'; +import { CollapsibleDetailPageSection } from 'js/components/detailPage/DetailPageSection'; import { FilesContextProvider } from 'js/components/detailPage/files/FilesContext'; +import { Tabs, Tab, TabPanel } from 'js/shared-styles/tables/TableTabs'; +import { DetailSectionPaper } from 'js/shared-styles/surfaces'; +import withShouldDisplay from 'js/helpers/withShouldDisplay'; +import { sectionIconMap } from 'js/shared-styles/icons/sectionIconMap'; +import { SectionDescription } from '../ProcessedData/ProcessedDataset/SectionDescription'; import BulkDataTransferPanels from './BulkDataTransferPanels'; -import { StyledContainer } from './style'; +import { useProcessedDatasetTabs } from '../ProcessedData/ProcessedDataset/hooks'; +import { BULK_DATA_DESCRIPTION_TEXT } from './const'; function BulkDataTransfer() { - const { - entity: { entity_type }, - } = useFlaskDataContext(); + const tabs = useProcessedDatasetTabs(); + + const [openTabIndex, setOpenTabIndex] = useState(0); return ( - - - + + {BULK_DATA_DESCRIPTION_TEXT} + { + setOpenTabIndex(newValue as number); + }} > - Bulk Data Transfer - - - - - - + {tabs.map(({ label, icon: Icon }, index) => ( + : undefined} iconPosition="start" /> + ))} + + + {tabs.map((tab, index) => ( + + + + + + ))} + + ); } -export default BulkDataTransfer; +export default withShouldDisplay(BulkDataTransfer); diff --git a/context/app/static/js/components/detailPage/BulkDataTransfer/GlobusLink.tsx b/context/app/static/js/components/detailPage/BulkDataTransfer/GlobusLink.tsx index 752a6be123..83a55520b0 100644 --- a/context/app/static/js/components/detailPage/BulkDataTransfer/GlobusLink.tsx +++ b/context/app/static/js/components/detailPage/BulkDataTransfer/GlobusLink.tsx @@ -1,50 +1,19 @@ -import React, { PropsWithChildren } from 'react'; -import Divider from '@mui/material/Divider'; -import Typography from '@mui/material/Typography'; -import Stack from '@mui/material/Stack'; +import React from 'react'; import { DetailSectionPaper } from 'js/shared-styles/surfaces'; -import { useFlaskDataContext } from 'js/components/Contexts'; import { useFilesContext } from 'js/components/detailPage/files/FilesContext'; -import { SecondaryBackgroundTooltip } from 'js/shared-styles/tooltips'; import FilesConditionalLink from './FilesConditionalLink'; import { LinkContainer } from './style'; import { useFetchProtectedFile } from './hooks'; import { useTrackEntityPageEvent } from '../useTrackEntityPageEvent'; -interface WrapperComponentProps extends PropsWithChildren { - isSupport: boolean; -} - -function WrapperComponent({ isSupport, children }: WrapperComponentProps) { - if (isSupport) { - return ( - -
- {isSupport && ( - - {'Support Dataset: '} - - )} - {children} -
-
- ); - } - - return children; -} - interface GlobusLinkProps { uuid: string; - isSupport?: boolean; + hubmap_id: string; + label: string; } -function GlobusLink({ uuid, isSupport = false }: GlobusLinkProps) { - const { - entity: { hubmap_id }, - } = useFlaskDataContext(); - +function GlobusLink({ uuid, hubmap_id, label }: GlobusLinkProps) { const { status, responseUrl } = useFetchProtectedFile(uuid); const { hasAgreedToDUA, openDUA } = useFilesContext(); const trackEntityPageEvent = useTrackEntityPageEvent(); @@ -55,33 +24,17 @@ function GlobusLink({ uuid, isSupport = false }: GlobusLinkProps) { return ( - - (responseUrl ? openDUA(responseUrl) : undefined)} - variant="subtitle2" - hasIcon - fileName={`${hubmap_id} ${'Globus'}`} - onClick={() => trackEntityPageEvent({ action: 'Bulk Data Transfer / Globus Navigation' })} - /> - + (responseUrl ? openDUA(responseUrl) : undefined)} + variant="subtitle2" + hasIcon + fileName={`${label} (${hubmap_id})`} + onClick={() => trackEntityPageEvent({ action: 'Bulk Data Transfer / Globus Navigation' })} + /> ); } -function GlobusLinkContainer() { - const { - entity: { uuid }, - vis_lifted_uuid, - } = useFlaskDataContext(); - - return ( - }> - - {vis_lifted_uuid && } - - ); -} - -export default GlobusLinkContainer; +export default GlobusLink; diff --git a/context/app/static/js/components/detailPage/BulkDataTransfer/Link.tsx b/context/app/static/js/components/detailPage/BulkDataTransfer/Link.tsx index 1aa9f32dd3..5b80ecd0ec 100644 --- a/context/app/static/js/components/detailPage/BulkDataTransfer/Link.tsx +++ b/context/app/static/js/components/detailPage/BulkDataTransfer/Link.tsx @@ -8,23 +8,27 @@ import { SecondaryBackgroundTooltip } from 'js/shared-styles/tooltips'; import { InfoIcon } from 'js/shared-styles/icons'; import { LinkContainer, StyledLink } from './style'; +import { DBGAP_TEXT, SRA_EXPERIMENT_TEXT } from './const'; +import { useTrackEntityPageEvent } from '../useTrackEntityPageEvent'; + interface BulkDataTransferLinkProps { - url: string; + href: string; title?: string; description?: string; tooltip?: string; - outboundLink?: string; - onClick?: () => void; + documentationLink?: string; } -function Link({ url, title, description, tooltip, outboundLink, onClick }: BulkDataTransferLinkProps) { +function Link({ href, title, description, tooltip, documentationLink }: BulkDataTransferLinkProps) { + const trackEntityPageEvent = useTrackEntityPageEvent(); + const onClick = () => trackEntityPageEvent({ action: 'Bulk Data Transfer / Panel Link', label: title }); return ( <> {title && ( - + {title} )} @@ -35,14 +39,23 @@ function Link({ url, title, description, tooltip, outboundLink, onClick }: BulkD )} {description && {description}} - {outboundLink && ( + {documentationLink && ( <>  Here is  - additional documentation. + additional documentation. )} ); } + +export function DbGaPLink({ href }: Pick) { + return ; +} + +export function SRAExperimentLink({ href }: Pick) { + return ; +} + export default Link; diff --git a/context/app/static/js/components/detailPage/BulkDataTransfer/PublicationBulkDataTransfer.tsx b/context/app/static/js/components/detailPage/BulkDataTransfer/PublicationBulkDataTransfer.tsx new file mode 100644 index 0000000000..52c4e493f4 --- /dev/null +++ b/context/app/static/js/components/detailPage/BulkDataTransfer/PublicationBulkDataTransfer.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +import { CollapsibleDetailPageSection } from 'js/components/detailPage/DetailPageSection'; +import { FilesContextProvider } from 'js/components/detailPage/files/FilesContext'; +import withShouldDisplay from 'js/helpers/withShouldDisplay'; +import { sectionIconMap } from 'js/shared-styles/icons/sectionIconMap'; +import BulkDataTransferPanels from './BulkDataTransferPanels'; + +// Workaround for publication pages, which only have one BulkDataTransfer section and doesn't need tabs. +// For the dataset page's BulkDataTransfer section, see BulkDataTransferSection.tsx +function BulkDataTransfer(props: { uuid: string; label: string }) { + return ( + + + + + + ); +} + +export default withShouldDisplay(BulkDataTransfer); diff --git a/context/app/static/js/components/detailPage/BulkDataTransfer/const.ts b/context/app/static/js/components/detailPage/BulkDataTransfer/const.ts new file mode 100644 index 0000000000..401ef6a8e9 --- /dev/null +++ b/context/app/static/js/components/detailPage/BulkDataTransfer/const.ts @@ -0,0 +1,22 @@ +export const DBGAP_TEXT = { + title: 'Non-Consortium Members: Database of Genotypes and Phenotypes (dbGaP)', + tooltip: + 'The database of Genotypes and Phenotypes archives and distributes data and results from studies that have investigated the interaction of genotype and phenotype in humans.', + description: 'Navigate to the "Bioproject" or "Sequencing Read Archive" links to access the datasets.', +}; + +export const GLOBUS_TEXT = { + title: 'HuBMAP Consortium Members: Globus Access', + tooltip: 'Global research data management system.', +}; + +export const SRA_EXPERIMENT_TEXT = { + title: 'SRA Experiment', + tooltip: + 'SRA data, available through multiple cloud providers and NCBI servers, is the largest publicly available repository of high throughput sequencing data.', + description: 'Select the "Run" link on the page to download the dataset information.', + outboundLink: 'https://www.ncbi.nlm.nih.gov/sra/docs/', +}; + +export const BULK_DATA_DESCRIPTION_TEXT = + 'This section explains how to download data in bulk from raw and processed datasets. Processed datasets have separate download directories in Globus or dbGaP, distinct from the raw dataset.'; diff --git a/context/app/static/js/components/detailPage/BulkDataTransfer/hooks.ts b/context/app/static/js/components/detailPage/BulkDataTransfer/hooks.ts index 56feeda276..c89b68e245 100644 --- a/context/app/static/js/components/detailPage/BulkDataTransfer/hooks.ts +++ b/context/app/static/js/components/detailPage/BulkDataTransfer/hooks.ts @@ -2,6 +2,8 @@ import useSWR from 'swr'; import { getAuthHeader } from 'js/helpers/functions'; import { useAppContext } from 'js/components/Contexts'; +import { useSearchHits } from 'js/hooks/useSearchData'; +import { Dataset } from 'js/components/types'; interface FetchProtectedFileResponse { status?: number; @@ -41,3 +43,24 @@ export const useFetchProtectedFile = (uuid: string) => { return { ...result, isLoading }; }; + +type DatasetURLs = Pick< + Dataset, + 'hubmap_id' | 'dbgap_study_url' | 'dbgap_sra_experiment_url' | 'mapped_data_access_level' +>; + +export const useStudyURLsQuery = (uuid: string) => { + return useSearchHits( + { + query: { + bool: { + must: [{ term: { uuid } }], + }, + }, + _source: ['hubmap_id', 'dbgap_study_url', 'dbgap_sra_experiment_url', 'mapped_data_access_level'], + }, + { + useDefaultQuery: false, + }, + ); +}; diff --git a/context/app/static/js/components/detailPage/BulkDataTransfer/usePanelSet.tsx b/context/app/static/js/components/detailPage/BulkDataTransfer/usePanelSet.tsx index 8d9d9d0ab3..8d22ffea4b 100644 --- a/context/app/static/js/components/detailPage/BulkDataTransfer/usePanelSet.tsx +++ b/context/app/static/js/components/detailPage/BulkDataTransfer/usePanelSet.tsx @@ -2,21 +2,12 @@ import React from 'react'; import OutboundLink from 'js/shared-styles/Links/OutboundLink'; import { InternalLink } from 'js/shared-styles/Links'; -import { useAppContext, useFlaskDataContext } from 'js/components/Contexts'; +import { useAppContext } from 'js/components/Contexts'; import ContactUsLink from 'js/shared-styles/Links/ContactUsLink'; import InfoTooltipIcon from 'js/shared-styles/icons/TooltipIcon'; -import GlobusLink from './GlobusLink'; import { useFetchProtectedFile } from './hooks'; import { LoginButton } from './style'; -interface LinkPanel { - title: string; - tooltip: string; - description: string; - outboundLink: string; - key: 'dbGaP' | 'SRA Experiment'; -} - const dbGaPText = { title: 'Non-Consortium Members: Database of Genotypes and Phenotypes (dbGaP)', tooltip: @@ -34,25 +25,6 @@ const dbGaPTooltip = ( ); -const sraExpTooltip = - 'SRA data, available through multiple cloud providers and NCBI servers, is the largest publicly available repository of high throughput sequencing data.'; - -const dbGaPLink: LinkPanel = { - title: 'dbGaP Study', - tooltip: dbGaPText.tooltip, - description: 'Navigate to the "Bioproject" or "Sequencing Read Archive" links to access the datasets.', - outboundLink: '', - key: 'dbGaP', -}; - -const sraExperimentLink: LinkPanel = { - title: 'SRA Experiment', - tooltip: sraExpTooltip, - description: 'Select the "Run" link on the page to download the dataset information.', - outboundLink: 'https://www.ncbi.nlm.nih.gov/sra/docs/', - key: 'SRA Experiment', -}; - interface GlobusPanel { title: string; tooltip: string; @@ -111,7 +83,9 @@ const noGlobusAccessWhileLoggedInPanel: GlobusPanel = { interface SuccessPanelSet { panels: GlobusPanel[]; - links: LinkPanel[] | React.ReactNode[]; + showGlobus?: boolean; + showDbGaP?: boolean; + showSRA?: boolean; } interface ErrorPanelSet { @@ -127,12 +101,12 @@ type PanelSet = SuccessPanelSet | ErrorPanelSet; const PROTECTED_DATA_NOT_LOGGED_IN: SuccessPanelSet = { panels: [loginPanel, dbGaPPanel], - links: [dbGaPLink, sraExperimentLink], + showDbGaP: true, + showSRA: true, }; const PROTECTED_DATA_LOGGED_IN_NO_GLOBUS_ACCESS: SuccessPanelSet = { panels: [noGlobusAccessWhileLoggedInPanel], - links: [], }; const noDbGaPPanel: GlobusPanel = { @@ -148,7 +122,6 @@ const noDbGaPPanel: GlobusPanel = { const PROTECTED_DATA_NO_DBGAP: SuccessPanelSet = { panels: [loginPanel, noDbGaPPanel], - links: [], }; const PUBLIC_DATA: SuccessPanelSet = { @@ -166,7 +139,7 @@ const PUBLIC_DATA: SuccessPanelSet = { ), }, ], - links: [], + showGlobus: true, }; const ACCESS_TO_PROTECTED_DATA: SuccessPanelSet = { @@ -185,7 +158,7 @@ const ACCESS_TO_PROTECTED_DATA: SuccessPanelSet = { ), }, ], - links: [], + showGlobus: true, }; const NO_ACCESS_TO_PROTECTED_DATA: ErrorPanelSet = { @@ -216,12 +189,12 @@ const NON_CONSORTIUM_MEMBERS: SuccessPanelSet = { ), }, ], - links: [dbGaPLink, sraExperimentLink], + showDbGaP: true, + showSRA: true, }; const NON_CONSORTIUM_MEMBERS_NO_DBGAP: SuccessPanelSet = { panels: [noDbGaPPanel], - links: [], }; const ENTITY_API_ERROR: ErrorPanelSet = { @@ -251,19 +224,19 @@ function getGlobusPanel(status: number | undefined, panel: PanelSet, isLoading: return panel; } -export const usePanelSet: () => PanelSet = () => { +export const usePanelSet: (uuid: string, dbGapStudyURL?: string, accessType?: string) => PanelSet = ( + uuid, + dbGapStudyURL, + accessType, +) => { const { isAuthenticated, isHubmapUser } = useAppContext(); - const { - entity: { dbgap_study_url, mapped_data_access_level: accessType, uuid }, - } = useFlaskDataContext(); - - const hasDbGaPStudyURL = Boolean(dbgap_study_url); + const hasDbGaPStudyURL = Boolean(dbGapStudyURL); const { status: globusURLStatus, isLoading: globusURLIsLoading } = useFetchProtectedFile(uuid); const hasNoAccess = globusURLStatus === 403; const isNonConsortium = !isHubmapUser; - if (accessType !== 'Protected') { + if (!accessType || accessType !== 'Protected') { return getGlobusPanel(globusURLStatus, PUBLIC_DATA, globusURLIsLoading); } diff --git a/context/app/static/js/components/detailPage/CollectionDatasetsTable/CollectionDatasetsTable.tsx b/context/app/static/js/components/detailPage/CollectionDatasetsTable/CollectionDatasetsTable.tsx index 79ed0f226c..f422d58443 100644 --- a/context/app/static/js/components/detailPage/CollectionDatasetsTable/CollectionDatasetsTable.tsx +++ b/context/app/static/js/components/detailPage/CollectionDatasetsTable/CollectionDatasetsTable.tsx @@ -3,9 +3,8 @@ import PropTypes from 'prop-types'; import Paper from '@mui/material/Paper'; import Typography from '@mui/material/Typography'; -import SectionHeader from 'js/shared-styles/sections/SectionHeader'; import type { Entity } from 'js/components/types'; -import DetailPageSection from 'js/components/detailPage/DetailPageSection'; +import { CollapsibleDetailPageSection } from 'js/components/detailPage/DetailPageSection'; import RelatedEntitiesTable from 'js/components/detailPage/related-entities/RelatedEntitiesTable'; import { useCollectionsDatasets } from './hooks'; @@ -20,15 +19,14 @@ function CollectionDatasetsTable({ datasets }: CollectionDatasetsTableProps) { }); return ( - - Datasets + {datasets.length} Datasets - + ); } diff --git a/context/app/static/js/components/detailPage/CollectionsSection/CollectionsSection.tsx b/context/app/static/js/components/detailPage/CollectionsSection/CollectionsSection.tsx index 951773ae70..3df44012c3 100644 --- a/context/app/static/js/components/detailPage/CollectionsSection/CollectionsSection.tsx +++ b/context/app/static/js/components/detailPage/CollectionsSection/CollectionsSection.tsx @@ -1,31 +1,106 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; -import SectionHeader from 'js/shared-styles/sections/SectionHeader'; import PanelList from 'js/shared-styles/panels/PanelList'; import { useFlaskDataContext } from 'js/components/Contexts'; -import DetailPageSection from 'js/components/detailPage/DetailPageSection'; +import { CollapsibleDetailPageSection } from 'js/components/detailPage/DetailPageSection'; import { buildCollectionsPanelsProps } from 'js/pages/Collections/utils'; -import { CollectionHit } from 'js/pages/Collections/types'; +import { useDatasetsCollections } from 'js/hooks/useDatasetsCollections'; +import { Tabs, Tab, TabPanel } from 'js/shared-styles/tables/TableTabs'; +import { OutlinedAlert } from 'js/shared-styles/alerts/OutlinedAlert.stories'; +import withShouldDisplay from 'js/helpers/withShouldDisplay'; +import { sectionIconMap } from 'js/shared-styles/icons/sectionIconMap'; +import { useProcessedDatasetTabs } from '../ProcessedData/ProcessedDataset/hooks'; +import { SectionDescription } from '../ProcessedData/ProcessedDataset/SectionDescription'; +import CollectionsSectionProvider, { useCollectionsSectionContext } from './CollectionsSectionContext'; -interface CollectionsSectionProps { - collectionsData: CollectionHit[]; +interface CollectionTabProps { + label: string; + uuid: string; + index: number; + icon?: React.ComponentType; } -function CollectionsSection({ collectionsData }: CollectionsSectionProps) { - const panelsProps = buildCollectionsPanelsProps(collectionsData); +function CollectionTab({ label, uuid, index, icon: Icon }: CollectionTabProps) { + const collectionsData = useDatasetsCollections([uuid]); + const { + entity: { uuid: primaryDatasetId }, + } = useFlaskDataContext(); + const { processedDatasetHasCollections } = useCollectionsSectionContext(); + + const isPrimaryDataset = uuid === primaryDatasetId; + + if (!collectionsData || (collectionsData.length === 0 && uuid !== primaryDatasetId)) { + return null; + } + const isSingleTab = !processedDatasetHasCollections && isPrimaryDataset; + + return ( + : undefined} + iconPosition="start" + /> + ); +} +function CollectionPanel({ uuid, index }: { uuid: string; index: number }) { + const collectionsData = useDatasetsCollections([uuid]); + const { setProcessedDatasetHasCollections } = useCollectionsSectionContext(); const { - entity: { entity_type }, + entity: { uuid: primaryDatasetId }, } = useFlaskDataContext(); + useEffect(() => { + if (uuid !== primaryDatasetId && collectionsData?.length > 0) { + setProcessedDatasetHasCollections(true); + } + }, [collectionsData?.length, primaryDatasetId, setProcessedDatasetHasCollections, uuid]); + if (!collectionsData) { + return null; + } + const panelsProps = buildCollectionsPanelsProps(collectionsData); + if (panelsProps.length === 0) { + if (uuid === primaryDatasetId) { + return ( + + The raw dataset is not referenced in any existing collections. + + ); + } + return null; + } + return ( + + + + ); +} + +const collectionsSectionDescription = + 'Collections may contain references to either raw or processed datasets. If a processed dataset is not included in any collection, there will be no corresponding tabs in the table below.'; + +function CollectionsSection() { + const processedDatasetTabs = useProcessedDatasetTabs(); + + const [selectedTab, setSelectedTab] = useState(0); + return ( - - - Collections - - - + + + {collectionsSectionDescription} + setSelectedTab(tabIndex as number)}> + {processedDatasetTabs.map(({ label, uuid, icon }, index) => ( + + ))} + + {processedDatasetTabs.map(({ uuid }, index) => ( + + ))} + + ); } -export default CollectionsSection; +export default withShouldDisplay(CollectionsSection); diff --git a/context/app/static/js/components/detailPage/CollectionsSection/CollectionsSectionContext.tsx b/context/app/static/js/components/detailPage/CollectionsSection/CollectionsSectionContext.tsx new file mode 100644 index 0000000000..8be462a56e --- /dev/null +++ b/context/app/static/js/components/detailPage/CollectionsSection/CollectionsSectionContext.tsx @@ -0,0 +1,29 @@ +import React, { useState, PropsWithChildren, useMemo } from 'react'; + +import { createContext, useContext } from 'js/helpers/context'; + +interface CollectionsSectionContextType { + processedDatasetHasCollections: boolean; + setProcessedDatasetHasCollections: (value: boolean) => void; +} + +const CollectionsSectionContext = createContext('CollectionsSectionContext'); + +export const useCollectionsSectionContext = () => useContext(CollectionsSectionContext); + +/** + * This context helps deal with tab logic specific to the collections section. + * If no collections are found for any of the datasets, the section should not be displayed + * If the primary dataset is not found in any collections but the processed dataset is, the section should be displayed + * If the primary dataset is found in a collection, but none of the processed datasets are, the section should be displayed as a single tab + */ +export default function CollectionsSectionProvider({ children }: PropsWithChildren) { + const [processedDatasetHasCollections, setProcessedDatasetHasCollections] = useState(false); + + const value = useMemo( + () => ({ processedDatasetHasCollections, setProcessedDatasetHasCollections }), + [processedDatasetHasCollections, setProcessedDatasetHasCollections], + ); + + return {children}; +} diff --git a/context/app/static/js/components/detailPage/ContributorsTable/ContributorsTable.tsx b/context/app/static/js/components/detailPage/ContributorsTable/ContributorsTable.tsx index 0dc37fe6f0..d8ab9eb365 100644 --- a/context/app/static/js/components/detailPage/ContributorsTable/ContributorsTable.tsx +++ b/context/app/static/js/components/detailPage/ContributorsTable/ContributorsTable.tsx @@ -13,8 +13,7 @@ import Typography from '@mui/material/Typography'; import OutboundIconLink from 'js/shared-styles/Links/iconLinks/OutboundIconLink'; import { StyledTableContainer, HeaderCell } from 'js/shared-styles/tables'; import IconTooltipCell from 'js/shared-styles/tables/IconTooltipCell'; -import SectionHeader from 'js/shared-styles/sections/SectionHeader'; -import DetailPageSection from 'js/components/detailPage/DetailPageSection'; +import { CollapsibleDetailPageSection } from 'js/components/detailPage/DetailPageSection'; import EmailIconLink from 'js/shared-styles/Links/iconLinks/EmailIconLink'; import { OutlinedAlert } from 'js/shared-styles/alerts/OutlinedAlert.stories'; import { isValidEmail } from 'js/helpers/functions'; @@ -50,7 +49,7 @@ function ContactCell({ isContact, email }: ContactCellProps) { } interface ContributorsTableProps { - title: string; + title?: string; contributors: ContributorAPIResponse[]; contacts?: ContactAPIResponse[]; iconTooltipText?: string; @@ -58,7 +57,7 @@ interface ContributorsTableProps { } function ContributorsTable({ - title, + title = '', contributors = [], contacts = [], iconTooltipText, @@ -73,60 +72,76 @@ function ContributorsTable({ const normalizedContributors = useNormalizedContributors(contributors); const normalizedContacts = useNormalizedContacts(contacts); + if (contributors.length === 0) { + return null; + } + const sortedContributors = sortContributors(normalizedContributors, normalizedContacts); - return ( - - - {title} - {showInfoAlert && {contributorsInfoAlertText}} - - - - - - {columns.map((column) => ( - - {column.label} - - ))} - + {showInfoAlert && {contributorsInfoAlertText}} + + +
+ + + {columns.map((column) => ( + - ORCID - - - - - {sortedContributors.map((contributor) => { - const { affiliation, name, email, isPrincipalInvestigator, orcid } = contributor; - return ( - - {`${name}${isPrincipalInvestigator ? ' (PI)' : ''}`} - {affiliation} - - - - - {orcid && ( - - {orcid} - - )} - - - ); - })} - -
-
-
-
-
+ {column.label} + + ))} + + ORCID + + + + + {sortedContributors.map((contributor) => { + const { affiliation, name, email, isPrincipalInvestigator, orcid } = contributor; + return ( + + {`${name}${isPrincipalInvestigator ? ' (PI)' : ''}`} + {affiliation} + + + + + {orcid && ( + + {orcid} + + )} + + + ); + })} + + + + + + ); + + if (title === '') { + return contents; + } + + return ( + + {contents} + ); } diff --git a/context/app/static/js/components/detailPage/ContributorsTable/hooks.tsx b/context/app/static/js/components/detailPage/ContributorsTable/hooks.tsx index fd06bb2b1c..e87c24722d 100644 --- a/context/app/static/js/components/detailPage/ContributorsTable/hooks.tsx +++ b/context/app/static/js/components/detailPage/ContributorsTable/hooks.tsx @@ -1,7 +1,6 @@ import React, { useMemo } from 'react'; import { Stack } from '@mui/material'; -import ContactUsLink from 'js/shared-styles/Links/ContactUsLink'; import EmailIconLink from 'js/shared-styles/Links/iconLinks/EmailIconLink'; import { ContributorAPIResponse, ContactAPIResponse, normalizeContributor, normalizeContact } from './utils'; @@ -28,7 +27,6 @@ export function useAttributionSections( contact: string; }, showRegisteredBy: boolean, - showContactAndAlert: boolean, ) { return useMemo(() => { const sections: { @@ -43,13 +41,7 @@ export function useAttributionSections( }, ]; - if (showContactAndAlert) { - sections.push({ - label: 'Contact', - children: HuBMAP Help Desk , - tooltip: tooltips.contact, - }); - } else if (showRegisteredBy) { + if (showRegisteredBy) { sections.push({ label: 'Registered by', children: ( @@ -64,5 +56,5 @@ export function useAttributionSections( } return sections; - }, [group_name, created_by_user_displayname, created_by_user_email, tooltips, showRegisteredBy, showContactAndAlert]); + }, [group_name, created_by_user_displayname, created_by_user_email, tooltips, showRegisteredBy]); } diff --git a/context/app/static/js/components/detailPage/DatasetRelationships/DatasetRelationships.tsx b/context/app/static/js/components/detailPage/DatasetRelationships/DatasetRelationships.tsx index 222581e008..fbfa4f0d8f 100644 --- a/context/app/static/js/components/detailPage/DatasetRelationships/DatasetRelationships.tsx +++ b/context/app/static/js/components/detailPage/DatasetRelationships/DatasetRelationships.tsx @@ -22,7 +22,7 @@ interface DatasetRelationshipsVisualizationProps { function DatasetRelationshipsHeader() { return ( - + Dataset Relationship Diagram diff --git a/context/app/static/js/components/detailPage/DatasetRelationships/Legend.tsx b/context/app/static/js/components/detailPage/DatasetRelationships/Legend.tsx index feb4c0042a..ce29b6a19d 100644 --- a/context/app/static/js/components/detailPage/DatasetRelationships/Legend.tsx +++ b/context/app/static/js/components/detailPage/DatasetRelationships/Legend.tsx @@ -46,7 +46,7 @@ interface LegendProps { function Legend({ children, title, tooltip }: PropsWithChildren) { return ( - + {title} {tooltip && } diff --git a/context/app/static/js/components/detailPage/DatasetRelationships/hooks.ts b/context/app/static/js/components/detailPage/DatasetRelationships/hooks.ts index 0cfe1255cd..759511a4b1 100644 --- a/context/app/static/js/components/detailPage/DatasetRelationships/hooks.ts +++ b/context/app/static/js/components/detailPage/DatasetRelationships/hooks.ts @@ -40,7 +40,14 @@ async function fetchPipelineInfo({ url, datasets, groupsToken }: PipelineInfoReq if (datasets.length !== 1) { return Promise.resolve(''); } - return (await fetchSoftAssay({ url, dataset: datasets[0], groupsToken }))['pipeline-shorthand']; + const result = await fetchSoftAssay({ url, dataset: datasets[0], groupsToken }); + + // Handle image pyramids separately since their pipeline-shorthand is blank + if (result['vitessce-hints'].includes('pyramid') && result['vitessce-hints'].includes('is_image')) { + return 'Image Pyramid Generation'; + } + + return result['pipeline-shorthand']; } export function usePipelineInfo(datasets: string[]) { diff --git a/context/app/static/js/components/detailPage/DatasetRelationships/nodeTypes.tsx b/context/app/static/js/components/detailPage/DatasetRelationships/nodeTypes.tsx index 48762cbc60..d7f5c5b4d5 100644 --- a/context/app/static/js/components/detailPage/DatasetRelationships/nodeTypes.tsx +++ b/context/app/static/js/components/detailPage/DatasetRelationships/nodeTypes.tsx @@ -8,6 +8,8 @@ import Skeleton from '@mui/material/Skeleton'; import { Box } from '@mui/system'; import StatusIcon from '../StatusIcon'; import { usePipelineInfo } from './hooks'; +import { useProcessedDatasetDetails } from '../ProcessedData/ProcessedDataset/hooks'; +import { makeNodeHref } from './utils'; interface CommonNodeInfo extends Record { name: string; @@ -21,6 +23,7 @@ interface PipelineNodeInfo extends CommonNodeInfo { interface DatasetNodeInfo extends CommonNodeInfo { datasetType?: string; + uuid: string; status: string; } @@ -122,13 +125,15 @@ function PipelineNode({ type ProcessedDatasetNodeProps = Node; function ProcessedDatasetNode({ data }: NodeProps) { + const { datasetDetails, isLoading } = useProcessedDatasetDetails(data.uuid); return ( {data.datasetType} @@ -139,13 +144,15 @@ function ProcessedDatasetNode({ data }: NodeProps) { type ComponentDatasetNodeProps = Node; function ComponentDatasetNode({ data }: NodeProps) { + const { datasetDetails, isLoading } = useProcessedDatasetDetails(data.uuid); return ( {data.datasetType} diff --git a/context/app/static/js/components/detailPage/DatasetRelationships/prov.fixtures.ts b/context/app/static/js/components/detailPage/DatasetRelationships/prov.fixtures.ts index 0238dcbc18..8514c54452 100644 --- a/context/app/static/js/components/detailPage/DatasetRelationships/prov.fixtures.ts +++ b/context/app/static/js/components/detailPage/DatasetRelationships/prov.fixtures.ts @@ -473,6 +473,7 @@ export const nodes = [ name: 'HBM749.SMWP.555', status: 'Published', datasetType: 'CODEX', + uuid: '6df2f796ad72307d04dc94d688b725c5', }, }, { @@ -500,6 +501,7 @@ export const nodes = [ name: 'HBM878.HRPG.642', status: 'QA', datasetType: 'CODEX [Cytokit + SPRM]', + uuid: 'd8f851efb54f84d7ee0952e10d4c449e', }, }, { @@ -509,6 +511,7 @@ export const nodes = [ name: 'HBM398.SWKV.256', status: 'Published', datasetType: 'CODEX [Cytokit + SPRM]', + uuid: 'ff77fcae7f6d9b5b7b8741c282677eef', }, }, ]; diff --git a/context/app/static/js/components/detailPage/DatasetRelationships/utils.spec.ts b/context/app/static/js/components/detailPage/DatasetRelationships/utils.spec.ts index 37103fdeab..623f706b24 100644 --- a/context/app/static/js/components/detailPage/DatasetRelationships/utils.spec.ts +++ b/context/app/static/js/components/detailPage/DatasetRelationships/utils.spec.ts @@ -1,4 +1,4 @@ -import { convertProvDataToNodesAndEdges, generatePrefix, getCurrentEntityNodeType } from './utils'; +import { convertProvDataToNodesAndEdges, generatePrefix, getCurrentEntityNodeType, makeNodeHref } from './utils'; import { provData, nodes, edges } from './prov.fixtures'; @@ -34,3 +34,17 @@ describe('convertProvDataToNodesAndEdges', () => { expect(edges).toEqual(expectedEdges); }); }); + +describe('makeNodeHref', () => { + it('should return a hash link for the provided node ID', () => { + const testPipeline = { + pipeline: 'Test Pipeline', + hubmap_id: 'HBM123', + status: 'Published', + }; + expect(makeNodeHref(testPipeline)).toBe('#section-testpipeline-published'); + }); + it('should handle missing data by returning undefined', () => { + expect(makeNodeHref(undefined)).toBe(undefined); + }); +}); diff --git a/context/app/static/js/components/detailPage/DatasetRelationships/utils.ts b/context/app/static/js/components/detailPage/DatasetRelationships/utils.ts index eab3dfc05e..61ae45939b 100644 --- a/context/app/static/js/components/detailPage/DatasetRelationships/utils.ts +++ b/context/app/static/js/components/detailPage/DatasetRelationships/utils.ts @@ -1,6 +1,7 @@ import { Node, Edge, MarkerType } from '@xyflow/react'; import Dagre from '@dagrejs/dagre'; import theme from 'js/theme/theme'; +import { datasetSectionId } from 'js/pages/Dataset/utils'; import { NodeWithoutPosition } from './types'; import { ProvData } from '../provenance/types'; import { nodeHeight } from './nodeTypes'; @@ -69,9 +70,6 @@ function datasetTypeIsForbidden(datasetType: string) { if (datasetType === 'Publication') { return true; } - if (datasetType.includes('[Image Pyramid]')) { - return true; - } return false; } @@ -153,6 +151,7 @@ export function convertProvDataToNodesAndEdges(primaryDatasetUuid: string, provD id: currentEntityUUID, type: currentEntityType, data: { + uuid: currentEntity[generatePrefix('uuid')], name: currentEntity[generatePrefix('hubmap_id')], status: currentEntity[generatePrefix('status')], datasetType: currentEntity[generatePrefix('dataset_type')], @@ -213,3 +212,10 @@ export function convertProvDataToNodesAndEdges(primaryDatasetUuid: string, provD } return { nodes, edges }; } + +export function makeNodeHref(data?: Parameters[0]) { + if (!data) { + return undefined; + } + return `#${datasetSectionId(data, 'section')}`; +} diff --git a/context/app/static/js/components/detailPage/DetailLayout/DetailLayout.tsx b/context/app/static/js/components/detailPage/DetailLayout/DetailLayout.tsx index f13361b9ae..d14de6814d 100644 --- a/context/app/static/js/components/detailPage/DetailLayout/DetailLayout.tsx +++ b/context/app/static/js/components/detailPage/DetailLayout/DetailLayout.tsx @@ -5,7 +5,7 @@ import Stack from '@mui/material/Stack'; import useEntityStore, { savedAlertStatus, editedAlertStatus, EntityStore } from 'js/stores/useEntityStore'; import TableOfContents from 'js/shared-styles/sections/TableOfContents'; import { TableOfContentsItems } from 'js/shared-styles/sections/TableOfContents/types'; -import { leftRouteBoundaryID } from 'js/components/Routes/Route/Route'; +import { leftRouteBoundaryID, rightRouteBoundaryID } from 'js/components/Routes/Route/Route'; import { SectionOrder, getSections } from 'js/shared-styles/sections/TableOfContents/utils'; import { StyledAlert } from './style'; @@ -33,6 +33,19 @@ function TableOfContentsPortal({ items, isLoading = false }: { items: TableOfCon ); } +export function HelperPanelPortal({ children }: PropsWithChildren) { + const element = document.getElementById(rightRouteBoundaryID); + if (!element) { + return null; + } + return createPortal( + + {children} + , + element, + ); +} + function DetailAlert() { const { shouldDisplaySavedOrEditedAlert, setShouldDisplaySavedOrEditedAlert } = useEntityStore(entityStoreSelector); diff --git a/context/app/static/js/components/detailPage/DetailPageSection.tsx b/context/app/static/js/components/detailPage/DetailPageSection.tsx deleted file mode 100644 index 5f386b0147..0000000000 --- a/context/app/static/js/components/detailPage/DetailPageSection.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React, { PropsWithChildren } from 'react'; - -import { useTotalHeaderOffset } from './entityHeader/EntityHeader/hooks'; -import { OffsetSection } from './style'; - -function DetailPageSection({ children, ...rest }: PropsWithChildren>) { - const offset = useTotalHeaderOffset(); - - return ( - - {children} - - ); -} - -export default DetailPageSection; diff --git a/context/app/static/js/components/detailPage/DetailPageSection/CollapsibleDetailPageSection.tsx b/context/app/static/js/components/detailPage/DetailPageSection/CollapsibleDetailPageSection.tsx new file mode 100644 index 0000000000..f10029a76f --- /dev/null +++ b/context/app/static/js/components/detailPage/DetailPageSection/CollapsibleDetailPageSection.tsx @@ -0,0 +1,84 @@ +import React, { PropsWithChildren } from 'react'; +import AccordionDetails from '@mui/material/AccordionDetails'; +import AccordionSummary from '@mui/material/AccordionSummary'; +import ExpandMore from '@mui/icons-material/ExpandMore'; +import Typography, { TypographyProps } from '@mui/material/Typography'; +import Box from '@mui/material/Box'; +import { SvgIconComponent } from '@mui/icons-material'; +import { SecondaryBackgroundTooltip } from 'js/shared-styles/tooltips'; +import { StyledInfoIcon } from 'js/shared-styles/sections/LabelledSectionText/style'; +import { sectionIconMap, sectionImageIconMap } from 'js/shared-styles/icons/sectionIconMap'; +import ExternalImageIcon from 'js/shared-styles/icons/ExternalImageIcon'; +import DetailPageSection from './DetailPageSection'; +import { DetailPageSectionAccordion, StyledExternalImageIconContainer, StyledSvgIcon } from './style'; + +export interface CollapsibleDetailPageSectionProps extends PropsWithChildren> { + title: string; + icon?: SvgIconComponent; + action?: React.ReactNode; + variant?: TypographyProps['variant']; + component?: TypographyProps['component']; + iconTooltipText?: string; +} + +interface IconDisplayProps { + icon?: SvgIconComponent; + id: string; +} + +function IconDisplay({ icon: IconOverride, id }: IconDisplayProps) { + if (Boolean(IconOverride) || sectionIconMap[id]) { + return ; + } + const externalImageKey = sectionImageIconMap[id]; + if (externalImageKey) { + return ( + + + + ); + } + return null; +} + +export default function CollapsibleDetailPageSection({ + title, + icon, + children, + variant = 'h4', + component = 'h3', + action, + iconTooltipText, + ...rest +}: CollapsibleDetailPageSectionProps) { + return ( + + + }> + + + {title} + + {iconTooltipText && ( + + + + )} + {action && ( + { + // Prevent the accordion from expanding/collapsing when the action is clicked + e.stopPropagation(); + }} + ml="auto" + className="accordion-section-action" + > + {action} + + )} + + {children} + + + ); +} diff --git a/context/app/static/js/components/detailPage/DetailPageSection/DetailPageSection.tsx b/context/app/static/js/components/detailPage/DetailPageSection/DetailPageSection.tsx new file mode 100644 index 0000000000..7d88fdb17d --- /dev/null +++ b/context/app/static/js/components/detailPage/DetailPageSection/DetailPageSection.tsx @@ -0,0 +1,33 @@ +import React, { PropsWithChildren, useEffect, useRef } from 'react'; + +import { useInitialHashContext } from 'js/hooks/useInitialHash'; +import { useTotalHeaderOffset } from '../entityHeader/EntityHeader/hooks'; +import { OffsetSection } from '../style'; + +function DetailPageSection({ children, ...rest }: PropsWithChildren>) { + const offset = useTotalHeaderOffset(); + + const sectionRef = useRef(null); + const initialHash = useInitialHashContext(); + + useEffect(() => { + if (initialHash && initialHash?.length > 1) { + const strippedHash = initialHash.slice(1); + if (strippedHash === rest.id) { + setTimeout(() => { + sectionRef.current?.scrollIntoView({ + behavior: 'smooth', + }); + }, 1000); + } + } + }, [initialHash, rest.id]); + + return ( + + {children} + + ); +} + +export default DetailPageSection; diff --git a/context/app/static/js/components/detailPage/DetailPageSection/index.ts b/context/app/static/js/components/detailPage/DetailPageSection/index.ts new file mode 100644 index 0000000000..bf56ee6267 --- /dev/null +++ b/context/app/static/js/components/detailPage/DetailPageSection/index.ts @@ -0,0 +1,8 @@ +import DetailPageSection from './DetailPageSection'; +import CollapsibleDetailPageSection from './CollapsibleDetailPageSection'; + +import type { CollapsibleDetailPageSectionProps } from './CollapsibleDetailPageSection'; + +export { DetailPageSection, CollapsibleDetailPageSection }; +export default DetailPageSection; +export type { CollapsibleDetailPageSectionProps }; diff --git a/context/app/static/js/components/detailPage/DetailPageSection/style.ts b/context/app/static/js/components/detailPage/DetailPageSection/style.ts new file mode 100644 index 0000000000..d32e905513 --- /dev/null +++ b/context/app/static/js/components/detailPage/DetailPageSection/style.ts @@ -0,0 +1,48 @@ +import SvgIcon from '@mui/material/SvgIcon'; +import Accordion from '@mui/material/Accordion'; +import Box from '@mui/material/Box'; +import { styled } from '@mui/material/styles'; + +export const DetailPageSectionAccordion = styled(Accordion)(({ theme }) => ({ + backgroundColor: 'transparent', + '& > .MuiAccordionSummary-root': { + flexDirection: 'row-reverse', + padding: 0, + '& > .MuiAccordionSummary-content': { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + color: theme.palette.primary.main, + }, + '& > .MuiAccordionSummary-expandIconWrapper': { + marginRight: theme.spacing(1), + color: theme.palette.primary.main, + }, + }, + + '.accordion-section-action': { + opacity: 0, + transition: 'opacity 0.3s', + pointerEvents: 'none', + }, + '&.Mui-expanded': { + '.accordion-section-action': { + opacity: 1, + pointerEvents: 'auto', + }, + }, +})); + +export const StyledExternalImageIconContainer = styled(Box)({ + width: '1.5rem', + display: 'flex', + '& > *': { + width: '100%', + height: '100%', + }, +}); + +export const StyledSvgIcon = styled(SvgIcon)({ + fontSize: '1.5rem', + color: 'primary', +}) as typeof SvgIcon; diff --git a/context/app/static/js/components/detailPage/MetadataSection/MetadataSection.tsx b/context/app/static/js/components/detailPage/MetadataSection/MetadataSection.tsx index 2723cec5bc..d8e0897771 100644 --- a/context/app/static/js/components/detailPage/MetadataSection/MetadataSection.tsx +++ b/context/app/static/js/components/detailPage/MetadataSection/MetadataSection.tsx @@ -1,19 +1,23 @@ import React, { PropsWithChildren } from 'react'; import { useFlaskDataContext } from 'js/components/Contexts'; -import SectionHeader from 'js/shared-styles/sections/SectionHeader'; import { SecondaryBackgroundTooltip } from 'js/shared-styles/tooltips'; -import DetailPageSection from 'js/components/detailPage/DetailPageSection'; +import { CollapsibleDetailPageSection } from 'js/components/detailPage/DetailPageSection'; import { useTrackEntityPageEvent } from 'js/components/detailPage/useTrackEntityPageEvent'; import { tableToDelimitedString, createDownloadUrl } from 'js/helpers/functions'; import { useMetadataFieldDescriptions } from 'js/hooks/useUBKG'; import { getMetadata, hasMetadata } from 'js/helpers/metadata'; -import { isDataset } from 'js/components/types'; -import { DownloadIcon, Flex, StyledWhiteBackgroundIconButton } from '../MetadataTable/style'; -import MultiAssayMetadataTabs from '../multi-assay/MultiAssayMetadataTabs'; -import MetadataTable from '../MetadataTable'; -import { useRelatedMultiAssayMetadata } from '../multi-assay/useRelatedMultiAssayDatasets'; +import { ESEntityType, isDataset } from 'js/components/types'; +import { useProcessedDatasets } from 'js/pages/Dataset/hooks'; +import { entityIconMap } from 'js/shared-styles/icons/entityIconMap'; +import withShouldDisplay from 'js/helpers/withShouldDisplay'; +import { sectionIconMap } from 'js/shared-styles/icons/sectionIconMap'; +import { DownloadIcon, StyledWhiteBackgroundIconButton } from '../MetadataTable/style'; +import MetadataTabs from '../multi-assay/MultiAssayMetadataTabs'; import { Columns, defaultTSVColumns } from './columns'; +import { SectionDescription } from '../ProcessedData/ProcessedDataset/SectionDescription'; +import MetadataTable from '../MetadataTable'; +import { nodeIcons } from '../DatasetRelationships/nodeTypes'; export function getDescription( field: string, @@ -74,17 +78,11 @@ export type TableRows = TableRow[]; type MetadataWrapperProps = PropsWithChildren<{ allTableRows: TableRows; tsvColumns?: Columns; - buildTooltip: (entity_type: string) => string; }>; -function MetadataWrapper({ - allTableRows, - buildTooltip, - tsvColumns = defaultTSVColumns, - children, -}: MetadataWrapperProps) { +function MetadataWrapper({ allTableRows, tsvColumns = defaultTSVColumns, children }: MetadataWrapperProps) { const { - entity: { entity_type, hubmap_id }, + entity: { hubmap_id, ...entity }, } = useFlaskDataContext(); const trackEntityPageEvent = useTrackEntityPageEvent(); @@ -98,10 +96,14 @@ function MetadataWrapper({ 'text/tab-separated-values', ); + const entityIsDataset = isDataset(entity); + return ( - - - Metadata + - + } + > + + This is the list of metadata that was provided by the data provider. + {entityIsDataset && ' Metadata from the donor or sample of this dataset may also be included in this list.'} + {children} - + ); } -const buildMultiAssayTooltip = (entity_type: string) => - `Data provided for all ${entity_type.toLowerCase()}s involved in the multi-assay.`; +function SingleMetadata({ metadata }: { metadata: Record }) { + const tableRows = useTableData(metadata); + + return ( + + + + ); +} + +function getEntityIcon(entity: { entity_type: ESEntityType; is_component?: boolean; processing?: string }) { + if (isDataset(entity)) { + if (entity.is_component) { + return nodeIcons.componentDataset; + } + if (entity.processing === 'processed') { + return nodeIcons.processedDataset; + } + return nodeIcons.primaryDataset; + } + return entityIconMap[entity.entity_type]; +} + +interface MetadataProps { + metadata?: Record; +} -function MultiAssayMetadata() { - const datasetsWithMetadata = useRelatedMultiAssayMetadata(); +function Metadata({ metadata }: MetadataProps) { + const { searchHits: datasetsWithMetadata, isLoading } = useProcessedDatasets(true); const { data: fieldDescriptions } = useMetadataFieldDescriptions(); const { entity } = useFlaskDataContext(); if (!isDataset(entity)) { - throw new Error(`Expected entity to be a dataset, got ${entity.entity_type}`); + return ; + } + + if (isLoading || !datasetsWithMetadata) { + return null; } const { donor } = entity; - const entities = [donor, ...datasetsWithMetadata] + const entities = [entity, ...datasetsWithMetadata.map((d) => d._source), donor] .filter((e) => hasMetadata({ targetEntityType: e.entity_type, currentEntity: e })) .map((e) => { const label = isDataset(e) ? e.assay_display_name : e.entity_type; return { uuid: e.uuid, label, + icon: getEntityIcon(e), tableRows: buildTableData( getMetadata({ targetEntityType: e.entity_type, @@ -155,42 +191,11 @@ function MultiAssayMetadata() { return ( - + ); } -const buildMetadataTooltip = (entity_type: string) => `Data provided for the given ${entity_type?.toLowerCase()}.`; - -function Metadata({ metadata }: { metadata: Record }) { - const tableRows = useTableData(metadata); - - return ( - - - - ); -} - -type MetadataSectionProps = - | { - assay_modality: 'single'; - metadata: Record; - } - | { - assay_modality: 'multiple'; - metadata: undefined; - }; - -function MetadataSection({ metadata, assay_modality }: MetadataSectionProps) { - if (assay_modality === 'multiple') { - return ; - } - - return ; -} - -export default MetadataSection; +export default withShouldDisplay(Metadata); diff --git a/context/app/static/js/components/detailPage/ProcessedData/HelperPanel/HelperPanel.tsx b/context/app/static/js/components/detailPage/ProcessedData/HelperPanel/HelperPanel.tsx new file mode 100644 index 0000000000..9d4e181a95 --- /dev/null +++ b/context/app/static/js/components/detailPage/ProcessedData/HelperPanel/HelperPanel.tsx @@ -0,0 +1,258 @@ +import React, { PropsWithChildren } from 'react'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { formatDate } from 'date-fns/format'; +import Divider from '@mui/material/Divider'; +import { useIsDesktop } from 'js/hooks/media-queries'; +import SchemaRounded from '@mui/icons-material/SchemaRounded'; +import { WorkspacesIcon } from 'js/shared-styles/icons'; +import CloudDownloadRounded from '@mui/icons-material/CloudDownloadRounded'; +import { useAppContext } from 'js/components/Contexts'; +import { formatSectionHash } from 'js/shared-styles/sections/TableOfContents/utils'; +import { useAnimatedSidebarPosition } from 'js/shared-styles/sections/TableOfContents/hooks'; +import { animated } from '@react-spring/web'; +import { useEventCallback } from '@mui/material/utils'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import AddRounded from '@mui/icons-material/AddRounded'; +import NewWorkspaceDialog from 'js/components/workspaces/NewWorkspaceDialog'; +import { useCreateWorkspaceForm } from 'js/components/workspaces/NewWorkspaceDialog/useCreateWorkspaceForm'; +import { useOpenDialog } from 'js/components/workspaces/WorkspacesDropdownMenu/WorkspacesDropdownMenu'; +import SelectableTableProvider from 'js/shared-styles/tables/SelectableTableProvider/SelectableTableProvider'; +import AddDatasetsFromSearchDialog from 'js/components/workspaces/AddDatasetsFromSearchDialog'; +import { LineClamp } from 'js/shared-styles/text'; +import Fade from '@mui/material/Fade'; +import { HelperPanelPortal } from '../../DetailLayout/DetailLayout'; +import useProcessedDataStore from '../store'; +import StatusIcon from '../../StatusIcon'; +import { getDateLabelAndValue } from '../../utils'; +import { HelperPanelButton } from './styles'; +import { useTrackEntityPageEvent } from '../../useTrackEntityPageEvent'; + +function useCurrentDataset() { + return useProcessedDataStore((state) => state.currentDataset); +} + +function HelperPanelHeader() { + const currentDataset = useCurrentDataset(); + if (!currentDataset) { + return null; + } + return ( + + + {currentDataset?.hubmap_id} + + ); +} + +function HelperPanelStatus() { + const currentDataset = useCurrentDataset(); + if (!currentDataset) { + return null; + } + return ( + + + {currentDataset.status} + + ); +} + +interface HelperPanelBodyItemProps extends PropsWithChildren { + label: string; + noWrap?: boolean; +} + +function HelperPanelBodyItem({ label, children, noWrap }: HelperPanelBodyItemProps) { + const body = noWrap ? children : {children}; + return ( + + {label} + {body} + + ); +} + +function HelperPanelBody() { + const currentDataset = useCurrentDataset(); + if (!currentDataset) { + return null; + } + const [dateLabel, date] = getDateLabelAndValue(currentDataset); + return ( + <> + {currentDataset.title && ( + + {currentDataset.title} + + )} + {currentDataset.description && ( + + {currentDataset.description} + + )} + {currentDataset.pipeline} + {currentDataset.group_name} + {date && formatDate(date, 'yyyy-MM-dd')} + + ); +} + +function WorkspaceButton() { + const currentDataset = useCurrentDataset(); + const { isWorkspacesUser } = useAppContext(); + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + const track = useTrackEntityPageEvent(); + + const handleClick = (event: React.MouseEvent) => { + track({ + action: 'Open Workspace Menu', + label: currentDataset?.hubmap_id, + }); + setAnchorEl(event.currentTarget); + }; + const handleClose = () => { + setAnchorEl(null); + }; + + const { + control, + errors, + setDialogIsOpen: setOpenCreateWorkspace, + dialogIsOpen: createWorkspaceIsOpen, + ...rest + } = useCreateWorkspaceForm({ defaultName: currentDataset?.hubmap_id }); + + const openEditWorkspaceDialog = useOpenDialog('ADD_DATASETS_FROM_SEARCH'); + + const trackCreateWorkspace = useEventCallback(() => { + track({ + action: 'Start Creating Workspace', + label: currentDataset?.hubmap_id, + }); + setOpenCreateWorkspace(true); + handleClose(); + }); + + const trackAddToWorkspace = useEventCallback(() => { + track({ + action: 'Start Adding Dataset to Existing Workspace', + label: currentDataset?.hubmap_id, + }); + openEditWorkspaceDialog(); + handleClose(); + }); + + if (!isWorkspacesUser || currentDataset?.status !== 'Published') { + return null; + } + // The selectable table provider is used here since a lot of the workspace logic relies on the selected rows + return ( + + } + onClick={handleClick} + aria-controls={open ? 'basic-menu' : undefined} + aria-haspopup="true" + aria-expanded={open ? 'true' : undefined} + > + Workspace + + + + + + + Launch New Workspace + + + + + + Add to Workspace + + + + + + ); +} + +function HelperPanelActions() { + const currentDataset = useCurrentDataset(); + const track = useTrackEntityPageEvent(); + if (!currentDataset) { + return null; + } + return ( + <> + + } + href="#bulk-data-transfer" + onClick={() => { + track({ + action: 'Navigate to Bulk Download', + label: currentDataset?.hubmap_id, + }); + }} + > + Bulk Download + + + ); +} + +const AnimatedStack = animated(Stack); + +export default function HelperPanel() { + const currentDataset = useCurrentDataset(); + const isDesktop = useIsDesktop(); + const style = useAnimatedSidebarPosition(); + return ( + + + + + + + + + + + + ); +} diff --git a/context/app/static/js/components/detailPage/ProcessedData/HelperPanel/index.ts b/context/app/static/js/components/detailPage/ProcessedData/HelperPanel/index.ts new file mode 100644 index 0000000000..e1fb356fbc --- /dev/null +++ b/context/app/static/js/components/detailPage/ProcessedData/HelperPanel/index.ts @@ -0,0 +1,3 @@ +import HelperPanel from './HelperPanel'; + +export default HelperPanel; diff --git a/context/app/static/js/components/detailPage/ProcessedData/HelperPanel/styles.tsx b/context/app/static/js/components/detailPage/ProcessedData/HelperPanel/styles.tsx new file mode 100644 index 0000000000..75b5b8568d --- /dev/null +++ b/context/app/static/js/components/detailPage/ProcessedData/HelperPanel/styles.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +import { styled } from '@mui/material/styles'; +import Button, { ButtonProps } from '@mui/material/Button'; + +export const HelperPanelButton = styled((props: ButtonProps) => + ); +} + +export default RelatedEntitiesSectionActions; diff --git a/context/app/static/js/components/detailPage/related-entities/RelatedEntitiesSectionActions/index.ts b/context/app/static/js/components/detailPage/related-entities/RelatedEntitiesSectionActions/index.ts new file mode 100644 index 0000000000..a52b4c87ea --- /dev/null +++ b/context/app/static/js/components/detailPage/related-entities/RelatedEntitiesSectionActions/index.ts @@ -0,0 +1,3 @@ +import RelatedEntitiesSectionActions from './RelatedEntitiesSectionActions'; + +export default RelatedEntitiesSectionActions; diff --git a/context/app/static/js/components/detailPage/related-entities/RelatedEntitiesSectionHeader/RelatedEntitiesSectionHeader.tsx b/context/app/static/js/components/detailPage/related-entities/RelatedEntitiesSectionHeader/RelatedEntitiesSectionHeader.tsx deleted file mode 100644 index 2501029f7d..0000000000 --- a/context/app/static/js/components/detailPage/related-entities/RelatedEntitiesSectionHeader/RelatedEntitiesSectionHeader.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React from 'react'; -import Button from '@mui/material/Button'; - -import SectionHeader from 'js/shared-styles/sections/SectionHeader'; -import { useFlaskDataContext } from 'js/components/Contexts'; -import { SpacedSectionButtonRow } from 'js/shared-styles/sections/SectionButtonRow'; -import { useTrackEntityPageEvent } from 'js/components/detailPage/useTrackEntityPageEvent'; - -const tooltipTexts = { - Donor: 'Samples and datasets derived from this donor.', - Sample: 'Datasets derived from this sample.', - Dataset: 'These datasets include those that have additional processing, such as visualizations.', -} as const; - -interface RelatedEntitiesSectionHeaderProps { - header: string; - searchPageHref: string; -} - -function RelatedEntitiesSectionHeader({ header, searchPageHref }: RelatedEntitiesSectionHeaderProps) { - const { - entity: { entity_type, uuid }, - } = useFlaskDataContext(); - - const track = useTrackEntityPageEvent(); - - return ( - {header} - } - buttons={ - - } - /> - ); -} - -export default RelatedEntitiesSectionHeader; diff --git a/context/app/static/js/components/detailPage/related-entities/RelatedEntitiesSectionHeader/index.ts b/context/app/static/js/components/detailPage/related-entities/RelatedEntitiesSectionHeader/index.ts deleted file mode 100644 index 6540f963ae..0000000000 --- a/context/app/static/js/components/detailPage/related-entities/RelatedEntitiesSectionHeader/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import RelatedEntitiesSectionHeader from './RelatedEntitiesSectionHeader'; - -export default RelatedEntitiesSectionHeader; diff --git a/context/app/static/js/components/detailPage/related-entities/RelatedEntitiesSectionWrapper/RelatedEntitiesSectionWrapper.tsx b/context/app/static/js/components/detailPage/related-entities/RelatedEntitiesSectionWrapper/RelatedEntitiesSectionWrapper.tsx index 920d466f47..962a42f4c0 100644 --- a/context/app/static/js/components/detailPage/related-entities/RelatedEntitiesSectionWrapper/RelatedEntitiesSectionWrapper.tsx +++ b/context/app/static/js/components/detailPage/related-entities/RelatedEntitiesSectionWrapper/RelatedEntitiesSectionWrapper.tsx @@ -1,32 +1,24 @@ import React from 'react'; import CircularProgress from '@mui/material/CircularProgress'; -import DetailPageSection from 'js/components/detailPage/DetailPageSection'; +import { + CollapsibleDetailPageSection, + CollapsibleDetailPageSectionProps, +} from 'js/components/detailPage/DetailPageSection'; import { StyledCenteredLoaderWrapper, RelatedEntitiesPaper } from './style'; -interface WrapperProps { - sectionId?: string; - children: React.ReactNode; -} - -function Wrapper({ sectionId, children }: WrapperProps) { - if (sectionId) { - return {children}; - } - - return children; -} - -interface RelatedEntitiesSectionWrapperProps extends WrapperProps { +interface RelatedEntitiesSectionWrapperProps extends CollapsibleDetailPageSectionProps { isLoading: boolean; - headerComponent: React.ReactNode; + title: string; } function RelatedEntitiesSectionWrapper({ isLoading, - sectionId, + id, children, - headerComponent, + title, + action, + iconTooltipText, }: RelatedEntitiesSectionWrapperProps) { if (isLoading) { @@ -35,10 +27,9 @@ function RelatedEntitiesSectionWrapper({ } return ( - - {headerComponent} + {children} - + ); } diff --git a/context/app/static/js/components/detailPage/summary/Summary/Summary.spec.tsx b/context/app/static/js/components/detailPage/summary/Summary/Summary.spec.tsx index bd036d4ccf..bbf28071c5 100644 --- a/context/app/static/js/components/detailPage/summary/Summary/Summary.spec.tsx +++ b/context/app/static/js/components/detailPage/summary/Summary/Summary.spec.tsx @@ -9,134 +9,155 @@ const testStatusAndAccessLevel = { mapped_data_access_level: 'fakeAccessLevel', }; -test('displays correctly with required props', () => { - const flaskDataContext = { - entity: { - uuid: 'fakeUUID', - hubmap_id: 'fakeTitle', - entity_type: 'Publication', - } as Publication, - } as FlaskDataContextType; - - render( - - - , - ); - const textToTest = ['fakeTitle', 'Publication']; - textToTest.forEach((text) => expect(screen.getByText(text)).toBeInTheDocument()); -}); - -test('publication prefered to creation, if available', () => { - const flaskDataContext = { - entity: { - uuid: 'fakeUUID', - hubmap_id: 'fakeTitle', - entity_type: 'Dataset', - created_timestamp: 1596724856094, - published_timestamp: 1596724856094, - } as Dataset, - } as FlaskDataContextType; - - render( - - - , - ); - expect(screen.getByText('Publication Date')).toBeInTheDocument(); - expect(screen.getAllByText('2020-08-06')).toHaveLength(1); - expect(screen.queryByText('Undefined')).not.toBeInTheDocument(); -}); - -test('timestamps do not display when undefined', () => { - const flaskDataContext = { - entity: { - uuid: 'fakeUUID', - hubmap_id: 'fakeTitle', - entity_type: 'Dataset', - } as Dataset, - } as FlaskDataContextType; - - render( - - - , - ); - - expect(screen.getByText('Creation Date')).toBeInTheDocument(); - expect(screen.getAllByText('Undefined')).toHaveLength(1); -}); - -test('collection name displays when defined', () => { - const flaskDataContext = { - entity: { - uuid: 'fakeUUID', - hubmap_id: 'fakeTitle', - entity_type: 'Collection', - title: 'Fake Collection Name', - } as Collection, - } as FlaskDataContextType; - - render( - - - , - ); - - expect(screen.getByText('Fake Collection Name')).toBeInTheDocument(); -}); - -test('collection name does not display when undefined', () => { - const flaskDataContext = { - entity: { - uuid: 'fakeUUID', - hubmap_id: 'fakeTitle', - entity_type: 'Collection', - } as Collection, - } as FlaskDataContextType; - - render( - - - , - ); - - expect(screen.queryByText('Fake Collection Name')).not.toBeInTheDocument(); -}); - -test('description displays when defined', () => { - const flaskDataContext = { - entity: { - uuid: 'fakeUUID', - hubmap_id: 'fakeTitle', - description: 'fake description', - entity_type: 'Dataset', - } as Dataset, - } as FlaskDataContextType; - - render( - - - , - ); - - expect(screen.getByText('fake description')).toBeInTheDocument(); -}); - -test('description name does not display when undefined', () => { - const flaskDataContext = { - entity: { - uuid: 'fakeUUID', - hubmap_id: 'fakeTitle', - entity_type: 'Dataset', - } as Dataset, - } as FlaskDataContextType; - - render( - - - , - ); - - expect(screen.queryByText('fake description')).not.toBeInTheDocument(); +describe('Summary', () => { + let location: Location; + const mockLocation: Location = new URL('https://example.com') as unknown as Location; + + beforeEach(() => { + location = window.location; + mockLocation.replace = jest.fn(); + mockLocation.assign = jest.fn(); + mockLocation.reload = jest.fn(); + mockLocation.search = 'mockSearch'; + mockLocation.hash = 'mockHash'; + // @ts-expect-error - This is setting up test mocks. + delete window.location; + window.location = mockLocation; + }); + + afterEach(() => { + window.location = location; + }); + + test('displays correctly with required props', () => { + const flaskDataContext = { + entity: { + uuid: 'fakeUUID', + hubmap_id: 'fakeTitle', + entity_type: 'Publication', + } as Publication, + } as FlaskDataContextType; + + render( + + + , + ); + const textToTest = ['fakeTitle', 'Publication']; + textToTest.forEach((text) => expect(screen.getByText(text)).toBeInTheDocument()); + }); + + test('publication preferred to creation, if available', () => { + const flaskDataContext = { + entity: { + uuid: 'fakeUUID', + hubmap_id: 'fakeTitle', + entity_type: 'Dataset', + created_timestamp: 1596724856094, + published_timestamp: 1596724856094, + } as Dataset, + } as FlaskDataContextType; + + render( + + + , + ); + expect(screen.getByText('Publication Date')).toBeInTheDocument(); + expect(screen.getAllByText('2020-08-06')).toHaveLength(1); + expect(screen.queryByText('Undefined')).not.toBeInTheDocument(); + }); + + test('timestamps do not display when undefined', () => { + const flaskDataContext = { + entity: { + uuid: 'fakeUUID', + hubmap_id: 'fakeTitle', + entity_type: 'Dataset', + } as Dataset, + } as FlaskDataContextType; + + render( + + + , + ); + + expect(screen.getByText('Creation Date')).toBeInTheDocument(); + expect(screen.getAllByText('Undefined')).toHaveLength(1); + }); + + test('collection name displays when defined', () => { + const flaskDataContext = { + entity: { + uuid: 'fakeUUID', + hubmap_id: 'fakeTitle', + entity_type: 'Collection', + title: 'Fake Collection Name', + } as Collection, + } as FlaskDataContextType; + + render( + + + , + ); + + expect(screen.getByText('Fake Collection Name')).toBeInTheDocument(); + }); + + test('collection name does not display when undefined', () => { + const flaskDataContext = { + entity: { + uuid: 'fakeUUID', + hubmap_id: 'fakeTitle', + entity_type: 'Collection', + } as Collection, + } as FlaskDataContextType; + + render( + + + , + ); + + expect(screen.queryByText('Fake Collection Name')).not.toBeInTheDocument(); + }); + + test('description displays when defined', () => { + const flaskDataContext = { + entity: { + uuid: 'fakeUUID', + hubmap_id: 'fakeTitle', + description: 'fake description', + entity_type: 'Dataset', + } as Dataset, + } as FlaskDataContextType; + + render( + + + , + ); + + expect(screen.getByText('fake description')).toBeInTheDocument(); + }); + + test('description name does not display when undefined', () => { + const flaskDataContext = { + entity: { + uuid: 'fakeUUID', + hubmap_id: 'fakeTitle', + entity_type: 'Dataset', + } as Dataset, + } as FlaskDataContextType; + + render( + + + , + ); + + expect(screen.queryByText('fake description')).not.toBeInTheDocument(); + }); }); diff --git a/context/app/static/js/components/detailPage/utils.ts b/context/app/static/js/components/detailPage/utils.ts index 131f75f85f..20a19a66a2 100644 --- a/context/app/static/js/components/detailPage/utils.ts +++ b/context/app/static/js/components/detailPage/utils.ts @@ -1,4 +1,5 @@ import { Dataset, Donor, isDataset, isDonor, isSample, Sample } from 'js/components/types'; +import { ProcessedDatasetDetails } from './ProcessedData/ProcessedDataset/hooks'; export function getSectionOrder( possibleSections: string[], @@ -13,6 +14,24 @@ export function getCombinedDatasetStatus({ sub_status, status }: { sub_status?: return sub_status ?? status; } +/** + * Helper function to handle different date labels for creation and publication dates + * depending on dataset status + * @param dataset + * @returns [label: string, value: number] + */ +export function getDateLabelAndValue( + dataset: Pick, +): [string, number] { + const { published_timestamp, created_timestamp, status } = dataset; + + if (status.toLowerCase() === 'published') { + return ['Publication Date', published_timestamp]; + } + + return ['Creation Date', created_timestamp]; +} + export function getDonorMetadata(entity: Donor | Sample | Dataset) { if (isDonor(entity)) { return entity.mapped_metadata; diff --git a/context/app/static/js/components/detailPage/visualization/Visualization/Visualization.tsx b/context/app/static/js/components/detailPage/visualization/Visualization/Visualization.tsx index 6ae8311571..a317315f13 100644 --- a/context/app/static/js/components/detailPage/visualization/Visualization/Visualization.tsx +++ b/context/app/static/js/components/detailPage/visualization/Visualization/Visualization.tsx @@ -43,7 +43,7 @@ const visualizationStoreSelector = (state: VisualizationStore) => ({ interface VisualizationProps { vitData: object | object[]; - uuid: string; + uuid?: string; hubmap_id?: string; mapped_data_access_level?: string; hasNotebook: boolean; @@ -79,7 +79,7 @@ function Visualization({ // Propagate UUID to the store if there is a notebook so we can display the download button when the visualization is expanded // Reruns every time vizIsFullscreen changes to ensure the proper notebook's UUID is used useEffect(() => { - if (hasNotebook) { + if (hasNotebook && uuid) { setVizNotebookId(uuid); } }, [hasNotebook, vizIsFullscreen, setVizNotebookId, uuid]); diff --git a/context/app/static/js/components/detailPage/visualization/VisualizationWrapper/ContainerStylingContext.ts b/context/app/static/js/components/detailPage/visualization/VisualizationWrapper/ContainerStylingContext.ts index cf8f404f3a..b0885ffba9 100644 --- a/context/app/static/js/components/detailPage/visualization/VisualizationWrapper/ContainerStylingContext.ts +++ b/context/app/static/js/components/detailPage/visualization/VisualizationWrapper/ContainerStylingContext.ts @@ -2,7 +2,7 @@ import { createContext, useContext } from 'js/helpers/context'; export interface VizContainerStylingProps { isPublicationPage: boolean; - uuid: string; + uuid?: string; shouldDisplayHeader: boolean; } diff --git a/context/app/static/js/components/detailPage/visualization/VisualizationWrapper/VisualizationWrapper.tsx b/context/app/static/js/components/detailPage/visualization/VisualizationWrapper/VisualizationWrapper.tsx index b50d320d3e..94da48470b 100644 --- a/context/app/static/js/components/detailPage/visualization/VisualizationWrapper/VisualizationWrapper.tsx +++ b/context/app/static/js/components/detailPage/visualization/VisualizationWrapper/VisualizationWrapper.tsx @@ -9,7 +9,7 @@ const Visualization = React.lazy(() => import('../Visualization')); interface VisualizationWrapperProps { vitData: object | object[]; - uuid: string; + uuid?: string; hubmap_id?: string; mapped_data_access_level?: string; hasNotebook?: boolean; diff --git a/context/app/static/js/components/entity-tile/EntityTile/EntityTile.tsx b/context/app/static/js/components/entity-tile/EntityTile/EntityTile.tsx index 208afa102b..8550211f84 100644 --- a/context/app/static/js/components/entity-tile/EntityTile/EntityTile.tsx +++ b/context/app/static/js/components/entity-tile/EntityTile/EntityTile.tsx @@ -1,9 +1,17 @@ import React, { ComponentProps, ComponentType } from 'react'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import ContentCopyIcon from '@mui/icons-material/ContentCopyRounded'; import Tile from 'js/shared-styles/tiles/Tile/'; import { DatasetIcon } from 'js/shared-styles/icons'; import { entityIconMap } from 'js/shared-styles/icons/entityIconMap'; import { Entity } from 'js/components/types'; +import StatusIcon from 'js/components/detailPage/StatusIcon'; +import ContactUsLink from 'js/shared-styles/Links/ContactUsLink'; +import { useHandleCopyClick } from 'js/hooks/useCopyText'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; import EntityTileFooter from '../EntityTileFooter/index'; import EntityTileBody from '../EntityTileBody/index'; import { StyledIcon } from './style'; @@ -48,5 +56,74 @@ function EntityTile({ uuid, entity_type, id, invertColors, entityData, descendan ); } -export { tileWidth }; +function ErrorTile({ entity_type, id }: Pick) { + const entityTypeLowercase = entity_type.toLowerCase(); + const copy = useHandleCopyClick(); + return ( + ({ + bgcolor: '#fbebf3', + width: tileWidth, + padding: theme.spacing(1), + borderRadius: theme.spacing(0.5), + border: `1px solid ${theme.palette.error.main}`, + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1), + })} + variant="outlined" + > + + + {id} + copy(id)}> + + + + + Unable to load {entityTypeLowercase}. with the {entityTypeLowercase} ID for more + information. + + + ); + // return ( + // theme.spacing(1), alignSelf: 'start' }} + // /> + // } + // bodyContent={ + // <> + // Unable to load {entityTypeLowercase}. + // + // Please with the {entityTypeLowercase}'s ID for more information. + // + // + // ID:{' '} + // { + // e.preventDefault(); + // copy(id); + // }} + // href="#" + // icon={ + // + // + // + // } + // > + // {id} + // + // + // + // } + // footerContent={undefined} + // tileWidth={tileWidth} + // /> + // ); +} + +export { tileWidth, ErrorTile }; export default EntityTile; diff --git a/context/app/static/js/components/entity-tile/EntityTile/utils.ts b/context/app/static/js/components/entity-tile/EntityTile/utils.ts index b9dadf93c7..c31d1b20c7 100644 --- a/context/app/static/js/components/entity-tile/EntityTile/utils.ts +++ b/context/app/static/js/components/entity-tile/EntityTile/utils.ts @@ -9,11 +9,11 @@ function filterDescendantCountsByType(descendant_counts: DescendantCounts['entit return descendant_counts; } -function getTileDescendantCounts(source: object, type: string) { +function getTileDescendantCounts(source: object | undefined, type: string) { const defaultDescendantCounts: Record = ['Donor', 'Sample'].includes(type) ? { Sample: 0, Dataset: 0 } : { Dataset: 0 }; - if ('descendant_counts' in source === false) return defaultDescendantCounts; + if (!source || 'descendant_counts' in source === false) return defaultDescendantCounts; const counts = source.descendant_counts as DescendantCounts; return { ...defaultDescendantCounts, ...filterDescendantCountsByType(counts.entity_type, type) }; } diff --git a/context/app/static/js/components/genes/BiomarkerQuery/BiomarkerQuery.tsx b/context/app/static/js/components/genes/BiomarkerQuery/BiomarkerQuery.tsx index 4845677891..8b45d9c2df 100644 --- a/context/app/static/js/components/genes/BiomarkerQuery/BiomarkerQuery.tsx +++ b/context/app/static/js/components/genes/BiomarkerQuery/BiomarkerQuery.tsx @@ -1,9 +1,8 @@ import CellsResults from 'js/components/cells/CellsResults'; import DatasetsSelectedByExpression from 'js/components/cells/DatasetsSelectedByExpression'; -import DetailPageSection from 'js/components/detailPage/DetailPageSection'; +import { CollapsibleDetailPageSection } from 'js/components/detailPage/DetailPageSection'; import AccordionSteps from 'js/shared-styles/accordions/AccordionSteps'; import { AccordionStepsProvider } from 'js/shared-styles/accordions/AccordionSteps/store'; -import SectionHeader from 'js/shared-styles/sections/SectionHeader'; import React, { useMemo } from 'react'; import TutorialProvider from 'js/shared-styles/tutorials/TutorialProvider'; import { useGeneOntology, useGenePageContext } from '../hooks'; @@ -36,11 +35,14 @@ export default function BiomarkerQuery() { }, [geneSymbol, data?.approved_symbol]); return ( - - {biomarkerQuery.title} + - + ); } diff --git a/context/app/static/js/components/genes/CellTypes/CellTypes.tsx b/context/app/static/js/components/genes/CellTypes/CellTypes.tsx index 1c5861a83f..7fec773e92 100644 --- a/context/app/static/js/components/genes/CellTypes/CellTypes.tsx +++ b/context/app/static/js/components/genes/CellTypes/CellTypes.tsx @@ -8,8 +8,7 @@ import TableRow from '@mui/material/TableRow'; import LinearProgress from '@mui/material/LinearProgress'; import Paper from '@mui/material/Paper'; -import DetailPageSection from 'js/components/detailPage/DetailPageSection'; -import SectionHeader from 'js/shared-styles/sections/SectionHeader'; +import { CollapsibleDetailPageSection } from 'js/components/detailPage/DetailPageSection'; import LoadingTableRows from 'js/shared-styles/tables/LoadingTableRows'; import { StyledTableContainer } from 'js/shared-styles/tables'; @@ -100,9 +99,8 @@ function CellTypesTable() { export default function CellTypes() { return ( - - {cellTypes.title} + - + ); } diff --git a/context/app/static/js/components/genes/Organs/OrgansSection.tsx b/context/app/static/js/components/genes/Organs/OrgansSection.tsx index 7166cf9619..9b25bcf861 100644 --- a/context/app/static/js/components/genes/Organs/OrgansSection.tsx +++ b/context/app/static/js/components/genes/Organs/OrgansSection.tsx @@ -1,8 +1,7 @@ import React from 'react'; import Stack from '@mui/material/Stack'; -import SectionHeader from 'js/shared-styles/sections/SectionHeader'; -import DetailPageSection from 'js/components/detailPage/DetailPageSection'; +import { CollapsibleDetailPageSection } from 'js/components/detailPage/DetailPageSection'; import { organs } from '../constants'; @@ -14,14 +13,13 @@ import OrganCount from './OrganCount'; export default function GeneOrgans() { return ( - + - {organs.title} - + ); } diff --git a/context/app/static/js/components/home/ExternalLinks/ExternalLinks.tsx b/context/app/static/js/components/home/ExternalLinks/ExternalLinks.tsx index 793e2e0185..2f46cdbad3 100644 --- a/context/app/static/js/components/home/ExternalLinks/ExternalLinks.tsx +++ b/context/app/static/js/components/home/ExternalLinks/ExternalLinks.tsx @@ -4,16 +4,9 @@ import Paper from '@mui/material/Paper'; import ExternalLink from 'js/components/home/ExternalLink'; import Stack from '@mui/material/Stack'; import { SectionHeader } from 'js/pages/Home/style'; -import { - avr, - azimuth, - fusion, - googleScholar, - hra, - hubmapConsortium, - nih, - protocols, -} from 'js/shared-styles/icons/externalImageIcons'; +import { externalIconMap } from 'js/shared-styles/icons/externalImageIcons'; + +const { avr, azimuth, fusion, googleScholar, hra, hubmapConsortium, nih, protocols } = externalIconMap; interface ExternalLinkPropsAdapter { src: string; diff --git a/context/app/static/js/components/organ/Assays/Assays.jsx b/context/app/static/js/components/organ/Assays/Assays.tsx similarity index 58% rename from context/app/static/js/components/organ/Assays/Assays.jsx rename to context/app/static/js/components/organ/Assays/Assays.tsx index 72a6affc43..ab405545f9 100644 --- a/context/app/static/js/components/organ/Assays/Assays.jsx +++ b/context/app/static/js/components/organ/Assays/Assays.tsx @@ -5,44 +5,43 @@ import TableRow from '@mui/material/TableRow'; import Paper from '@mui/material/Paper'; import Button from '@mui/material/Button'; -import { SecondaryBackgroundTooltip } from 'js/shared-styles/tooltips'; import EntitiesTable from 'js/shared-styles/tables/EntitiesTable'; import { InternalLink } from 'js/shared-styles/Links'; -import SectionHeader from 'js/shared-styles/sections/SectionHeader'; -import { SpacedSectionButtonRow } from 'js/shared-styles/sections/SectionButtonRow'; +import DatasetsBarChart from 'js/components/organ/OrganDatasetsChart'; import { HeaderCell } from 'js/shared-styles/tables'; import { useDatasetTypeMap } from 'js/components/home/HuBMAPDatasetsChart/hooks'; -import { Flex, StyledInfoIcon, StyledDatasetIcon } from '../style'; +import { CollapsibleDetailPageSection } from 'js/components/detailPage/DetailPageSection'; +import { DatasetIcon } from 'js/shared-styles/icons'; import { getSearchURL } from '../utils'; -function Assays({ organTerms, bucketData }) { +interface AssaysProps { + organTerms: string[]; + bucketData: { key: string; doc_count: number }[]; + id: string; +} + +function Assays({ organTerms, bucketData, id: sectionId }: AssaysProps) { const assayTypeMap = useDatasetTypeMap(); return ( - <> - - Assays - - - - - } - buttons={ - - } - /> + } + > + View All Datasets + + } + > - + + ); } diff --git a/context/app/static/js/components/organ/Assays/index.js b/context/app/static/js/components/organ/Assays/index.ts similarity index 100% rename from context/app/static/js/components/organ/Assays/index.js rename to context/app/static/js/components/organ/Assays/index.ts diff --git a/context/app/static/js/components/organ/Azimuth/Azimuth.jsx b/context/app/static/js/components/organ/Azimuth/Azimuth.jsx deleted file mode 100644 index 5ab3422590..0000000000 --- a/context/app/static/js/components/organ/Azimuth/Azimuth.jsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; -import Stack from '@mui/material/Stack'; - -import { SecondaryBackgroundTooltip } from 'js/shared-styles/tooltips'; -import OutboundLinkButton from 'js/shared-styles/Links/OutboundLinkButton'; -import SectionHeader from 'js/shared-styles/sections/SectionHeader'; -import VisualizationWrapper from 'js/components/detailPage/visualization/VisualizationWrapper/VisualizationWrapper'; -import { SpacedSectionButtonRow } from 'js/shared-styles/sections/SectionButtonRow'; - -import { Flex, StyledInfoIcon } from '../style'; -import ReferenceBasedAnalysis from './ReferenceBasedAnalysis'; - -function Azimuth({ config }) { - return ( - <> - - Reference-Based Analysis - - - - - } - buttons={Open Azimuth App} - /> - - - - - - ); -} - -export default Azimuth; diff --git a/context/app/static/js/components/organ/Azimuth/Azimuth.tsx b/context/app/static/js/components/organ/Azimuth/Azimuth.tsx new file mode 100644 index 0000000000..9371844b1e --- /dev/null +++ b/context/app/static/js/components/organ/Azimuth/Azimuth.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import Stack from '@mui/material/Stack'; + +import OutboundLinkButton from 'js/shared-styles/Links/OutboundLinkButton'; +import VisualizationWrapper from 'js/components/detailPage/visualization/VisualizationWrapper/VisualizationWrapper'; + +import { CollapsibleDetailPageSection } from 'js/components/detailPage/DetailPageSection'; +import ReferenceBasedAnalysis from './ReferenceBasedAnalysis'; +import { AzimuthConfig } from '../types'; + +interface AzimuthProps { + config: AzimuthConfig; + id: string; +} + +function Azimuth({ config, id }: AzimuthProps) { + return ( + + Open Azimuth App + + } + > + + + + + + ); +} + +export default Azimuth; diff --git a/context/app/static/js/components/organ/Azimuth/ReferenceBasedAnalysis.tsx b/context/app/static/js/components/organ/Azimuth/ReferenceBasedAnalysis.tsx index 4536290f0b..08c9e953a2 100644 --- a/context/app/static/js/components/organ/Azimuth/ReferenceBasedAnalysis.tsx +++ b/context/app/static/js/components/organ/Azimuth/ReferenceBasedAnalysis.tsx @@ -5,7 +5,7 @@ import LabelledSectionText from 'js/shared-styles/sections/LabelledSectionText'; import Skeleton from '@mui/material/Skeleton'; import MarkdownRenderer from 'js/components/Markdown/MarkdownRenderer'; import { ExtraProps } from 'react-markdown'; -import { StyledPaper } from './style'; +import { DetailSectionPaper } from 'js/shared-styles/surfaces'; interface ReferenceBasedAnalysisProps { modalities: string; @@ -22,7 +22,7 @@ function MarkdownParagraph({ } export default function ReferenceBasedAnalysis({ modalities, nunit, dataref, wrapped }: ReferenceBasedAnalysisProps) { - const Wrapper = wrapped ? StyledPaper : React.Fragment; + const Wrapper = wrapped ? DetailSectionPaper : React.Fragment; return ( {modalities} @@ -36,7 +36,7 @@ export default function ReferenceBasedAnalysis({ modalities, nunit, dataref, wra export function ReferenceBasedAnalysisSkeleton() { return ( - + @@ -46,6 +46,6 @@ export function ReferenceBasedAnalysisSkeleton() { - + ); } diff --git a/context/app/static/js/components/organ/Azimuth/index.js b/context/app/static/js/components/organ/Azimuth/index.ts similarity index 100% rename from context/app/static/js/components/organ/Azimuth/index.js rename to context/app/static/js/components/organ/Azimuth/index.ts diff --git a/context/app/static/js/components/organ/Azimuth/style.js b/context/app/static/js/components/organ/Azimuth/style.js deleted file mode 100644 index 53c56b18fb..0000000000 --- a/context/app/static/js/components/organ/Azimuth/style.js +++ /dev/null @@ -1,8 +0,0 @@ -import styled from 'styled-components'; -import Paper from '@mui/material/Paper'; - -const StyledPaper = styled(Paper)` - padding: 20px 40px 20px 40px; -`; - -export { StyledPaper }; diff --git a/context/app/static/js/components/organ/Description/Description.jsx b/context/app/static/js/components/organ/Description/Description.jsx deleted file mode 100644 index e0ddd579bf..0000000000 --- a/context/app/static/js/components/organ/Description/Description.jsx +++ /dev/null @@ -1,28 +0,0 @@ -import React from 'react'; - -import OutboundIconLink from 'js/shared-styles/Links/iconLinks/OutboundIconLink'; - -import { StyledPaper } from './style'; - -function Description({ children, uberonIri, uberonShort, asctbId }) { - return ( - -

{children}

-

- Uberon: {uberonShort} -

- {asctbId && ( -

- Visit the{' '} - - ASCT+B Reporter - -

- )} -
- ); -} - -export default Description; diff --git a/context/app/static/js/components/organ/Description/Description.tsx b/context/app/static/js/components/organ/Description/Description.tsx new file mode 100644 index 0000000000..b1fa1942ec --- /dev/null +++ b/context/app/static/js/components/organ/Description/Description.tsx @@ -0,0 +1,42 @@ +import React, { PropsWithChildren } from 'react'; + +import OutboundIconLink from 'js/shared-styles/Links/iconLinks/OutboundIconLink'; + +import DetailPageSection from 'js/components/detailPage/DetailPageSection'; +import { DetailSectionPaper } from 'js/shared-styles/surfaces'; +import Typography from '@mui/material/Typography'; +import Stack from '@mui/material/Stack'; + +interface DescriptionProps extends PropsWithChildren { + uberonIri: string; + uberonShort: string; + asctbId?: string; + id: string; +} + +function Description({ children, uberonIri, uberonShort, asctbId, id }: DescriptionProps) { + return ( + + + + {children} + + Uberon: {uberonShort} + + {asctbId && ( + + Visit the{' '} + + ASCT+B Reporter + + + )} + + + + ); +} + +export default Description; diff --git a/context/app/static/js/components/organ/Description/index.js b/context/app/static/js/components/organ/Description/index.ts similarity index 100% rename from context/app/static/js/components/organ/Description/index.js rename to context/app/static/js/components/organ/Description/index.ts diff --git a/context/app/static/js/components/organ/Description/style.js b/context/app/static/js/components/organ/Description/style.js deleted file mode 100644 index 38b542d687..0000000000 --- a/context/app/static/js/components/organ/Description/style.js +++ /dev/null @@ -1,8 +0,0 @@ -import styled from 'styled-components'; -import Paper from '@mui/material/Paper'; - -const StyledPaper = styled(Paper)` - padding: 20px 40px; -`; - -export { StyledPaper }; diff --git a/context/app/static/js/components/organ/HumanReferenceAtlas/HumanReferenceAtlas.tsx b/context/app/static/js/components/organ/HumanReferenceAtlas/HumanReferenceAtlas.tsx index be2822169b..981d40a286 100644 --- a/context/app/static/js/components/organ/HumanReferenceAtlas/HumanReferenceAtlas.tsx +++ b/context/app/static/js/components/organ/HumanReferenceAtlas/HumanReferenceAtlas.tsx @@ -2,30 +2,25 @@ import React from 'react'; import Paper from '@mui/material/Paper'; -import { SecondaryBackgroundTooltip } from 'js/shared-styles/tooltips'; - -import SectionHeader from 'js/shared-styles/sections/SectionHeader'; - import CCFOrganInfo from 'js/components/HRA/CCFOrganInfo'; -import { Flex, StyledInfoIcon } from '../style'; +import { CollapsibleDetailPageSection } from 'js/components/detailPage/DetailPageSection'; interface HumanReferenceAtlasProps { uberonIri: string; + id: string; } -function HumanReferenceAtlas({ uberonIri }: HumanReferenceAtlasProps) { +function HumanReferenceAtlas({ uberonIri, id }: HumanReferenceAtlasProps) { return ( - <> - - Human Reference Atlas - - - - + - + ); } diff --git a/context/app/static/js/components/organ/Samples/Samples.tsx b/context/app/static/js/components/organ/Samples/Samples.tsx index 8c111b10a7..77c93947e9 100644 --- a/context/app/static/js/components/organ/Samples/Samples.tsx +++ b/context/app/static/js/components/organ/Samples/Samples.tsx @@ -1,8 +1,6 @@ import React, { useMemo } from 'react'; import Button from '@mui/material/Button'; -import SectionContainer from 'js/shared-styles/sections/SectionContainer'; -import { SpacedSectionButtonRow } from 'js/shared-styles/sections/SectionButtonRow'; import { withSelectableTableProvider, useSelectableTableStore } from 'js/shared-styles/tables/SelectableTableProvider'; import AddItemsToListDialog from 'js/components/savedLists/AddItemsToListDialog'; import EntitiesTables from 'js/shared-styles/tables/EntitiesTable/EntitiesTables'; @@ -15,16 +13,17 @@ import { parentDonorSex, parentDonorRace, } from 'js/shared-styles/tables/columns'; -import { StyledSectionHeader } from './style'; +import { CollapsibleDetailPageSection } from 'js/components/detailPage/DetailPageSection'; import { getSearchURL } from '../utils'; const columns = [hubmapID, parentDonorAge, parentDonorSex, parentDonorRace, datasetDescendants, lastModifiedTimestamp]; interface OrganSamplesProps { organTerms: string[]; + id: string; } -function Samples({ organTerms }: OrganSamplesProps) { +function Samples({ organTerms, id }: OrganSamplesProps) { const { selectedRows, deselectHeaderAndRows } = useSelectableTableStore(); const searchUrl = getSearchURL({ entityType: 'Sample', organTerms }); @@ -55,28 +54,25 @@ function Samples({ organTerms }: OrganSamplesProps) { ); return ( - - - Samples - - } - buttons={ - <> - - - - } - /> + + + + + } + > entities={[{ query, columns, entityType: 'Sample' }]} /> - + ); } + export default withSelectableTableProvider(Samples, 'organ-samples'); diff --git a/context/app/static/js/components/organ/types.ts b/context/app/static/js/components/organ/types.ts index 35d7a4616e..4eb7cf777b 100644 --- a/context/app/static/js/components/organ/types.ts +++ b/context/app/static/js/components/organ/types.ts @@ -1,6 +1,6 @@ type Markdown = string; -interface Azimuth { +export interface AzimuthConfig { annotations: { file: string; name: string; @@ -31,7 +31,7 @@ export interface OrganFile { search: string[]; uberon: string; uberon_short: string; - azimuth?: Azimuth; + azimuth?: AzimuthConfig; } export interface OrganFileWithDescendants extends OrganFile { diff --git a/context/app/static/js/components/profile/MyLists.tsx b/context/app/static/js/components/profile/MyLists.tsx index 4823f31796..d17825e729 100644 --- a/context/app/static/js/components/profile/MyLists.tsx +++ b/context/app/static/js/components/profile/MyLists.tsx @@ -5,14 +5,13 @@ import { InternalLink } from 'js/shared-styles/Links'; import useSavedEntitiesStore from 'js/stores/useSavedEntitiesStore'; import Button from '@mui/material/Button'; import Stack from '@mui/material/Stack'; -import DetailPageSection from 'js/components/detailPage/DetailPageSection'; +import { CollapsibleDetailPageSection } from 'js/components/detailPage/DetailPageSection'; export function MyLists() { const savedListCount = useSavedEntitiesStore((s) => Object.keys(s.savedLists).length); const buttonText = savedListCount === 0 ? 'Create List' : `Manage Lists (${savedListCount})`; return ( - - My Lists + @@ -24,6 +23,6 @@ export function MyLists() { - + ); } diff --git a/context/app/static/js/components/profile/MyWorkspaces.tsx b/context/app/static/js/components/profile/MyWorkspaces.tsx index a0edb9ecf7..c2a194ea29 100644 --- a/context/app/static/js/components/profile/MyWorkspaces.tsx +++ b/context/app/static/js/components/profile/MyWorkspaces.tsx @@ -1,12 +1,11 @@ import React from 'react'; -import Typography from '@mui/material/Typography'; import LoadingButton from '@mui/lab/LoadingButton'; import Stack from '@mui/material/Stack'; import ContactUsLink from 'js/shared-styles/Links/ContactUsLink'; import { Alert } from 'js/shared-styles/alerts'; import SectionPaper from 'js/shared-styles/sections/SectionPaper'; import LabelledSectionText from 'js/shared-styles/sections/LabelledSectionText'; -import DetailPageSection from 'js/components/detailPage/DetailPageSection'; +import { CollapsibleDetailPageSection } from 'js/components/detailPage/DetailPageSection'; import { useAppContext } from '../Contexts'; import { useWorkspaces } from '../workspaces/api'; @@ -44,11 +43,8 @@ function MainView() { export function MyWorkspaces() { const { isWorkspacesUser } = useAppContext(); return ( - - - My Workspaces - {isWorkspacesUser ? : } - - + + {isWorkspacesUser ? : } + ); } diff --git a/context/app/static/js/components/publications/PublicationCollections/PublicationCollections.jsx b/context/app/static/js/components/publications/PublicationCollections/PublicationCollections.jsx index 07edf6f9b0..2131f82402 100644 --- a/context/app/static/js/components/publications/PublicationCollections/PublicationCollections.jsx +++ b/context/app/static/js/components/publications/PublicationCollections/PublicationCollections.jsx @@ -5,19 +5,20 @@ import PanelList from 'js/shared-styles/panels/PanelList'; import LabelledSectionText from 'js/shared-styles/sections/LabelledSectionText'; +import { CollapsibleDetailPageSection } from 'js/components/detailPage/DetailPageSection'; import { StyledSectionPaper } from './styles'; -function PublicationCollections({ collectionsData, isCollectionPublication }) { +function PublicationCollections({ collectionsData = [], isCollectionPublication }) { const panelsProps = buildCollectionsPanelsProps(collectionsData); return ( - <> + Datasets associated with this publication are included in the Collections listed below. - + ); } diff --git a/context/app/static/js/components/publications/PublicationRelatedEntities/PublicationRelatedEntities.jsx b/context/app/static/js/components/publications/PublicationRelatedEntities/PublicationRelatedEntities.tsx similarity index 70% rename from context/app/static/js/components/publications/PublicationRelatedEntities/PublicationRelatedEntities.jsx rename to context/app/static/js/components/publications/PublicationRelatedEntities/PublicationRelatedEntities.tsx index 0ac736ad8f..9aafee3f51 100644 --- a/context/app/static/js/components/publications/PublicationRelatedEntities/PublicationRelatedEntities.jsx +++ b/context/app/static/js/components/publications/PublicationRelatedEntities/PublicationRelatedEntities.tsx @@ -2,22 +2,25 @@ import React, { useState } from 'react'; import RelatedEntitiesSectionWrapper from 'js/components/detailPage/related-entities/RelatedEntitiesSectionWrapper'; import RelatedEntitiesTabs from 'js/components/detailPage/related-entities/RelatedEntitiesTabs'; -import RelatedEntitiesSectionHeader from 'js/components/detailPage/related-entities/RelatedEntitiesSectionHeader'; +import RelatedEntitiesSectionActions from 'js/components/detailPage/related-entities/RelatedEntitiesSectionActions'; import { usePublicationsRelatedEntities } from './hooks'; -function PublicationRelatedEntities({ uuid }) { +interface PublicationRelatedEntitiesProps { + uuid: string; +} + +function PublicationRelatedEntities({ uuid }: PublicationRelatedEntitiesProps) { const [openIndex, setOpenIndex] = useState(0); const { entities, isLoading } = usePublicationsRelatedEntities(uuid); return ( } diff --git a/context/app/static/js/components/publications/PublicationRelatedEntities/hooks.js b/context/app/static/js/components/publications/PublicationRelatedEntities/hooks.ts similarity index 62% rename from context/app/static/js/components/publications/PublicationRelatedEntities/hooks.js rename to context/app/static/js/components/publications/PublicationRelatedEntities/hooks.ts index 9b97af08b6..566984d460 100644 --- a/context/app/static/js/components/publications/PublicationRelatedEntities/hooks.js +++ b/context/app/static/js/components/publications/PublicationRelatedEntities/hooks.ts @@ -9,11 +9,13 @@ import { } from 'js/components/detailPage/derivedEntities/columns'; import { getAncestorsQuery } from 'js/helpers/queries'; +import { Dataset, Donor, PartialEntity, Sample } from 'js/components/types'; +import { SearchHit } from '@elastic/elasticsearch/lib/api/types'; -function useAncestorSearchHits(descendantUUID) { +function useAncestorSearchHits(descendantUUID: string) { const query = useMemo( () => ({ - query: getAncestorsQuery(descendantUUID, 'dataset'), + query: getAncestorsQuery(descendantUUID), _source: [ 'uuid', 'hubmap_id', @@ -31,10 +33,10 @@ function useAncestorSearchHits(descendantUUID) { [descendantUUID], ); - return useSearchHits(query); + return useSearchHits(query); } -function usePublicationsRelatedEntities(uuid) { +function usePublicationsRelatedEntities(uuid: string) { const { searchHits: ancestorHits, isLoading } = useAncestorSearchHits(uuid); const ancestorsSplitByEntityType = ancestorHits.reduce( @@ -50,51 +52,55 @@ function usePublicationsRelatedEntities(uuid) { acc[entity_type].push(ancestor); return acc; }, - { Donor: [], Sample: [], Dataset: [] }, + { Donor: [], Sample: [], Dataset: [] } as Record>[]>, ); const entities = [ { - entityType: 'Donor', + entityType: 'Donor' as const, tabLabel: 'Donors', data: ancestorsSplitByEntityType.Donor, columns: [ { id: 'mapped_metadata.age_value', label: 'Age', - renderColumnCell: ({ mapped_metadata }) => mapped_metadata?.age_value, + renderColumnCell: ({ mapped_metadata }: PartialEntity) => mapped_metadata?.age_value as string, }, { id: 'mapped_metadata.body_mass_index_value', label: 'BMI', - renderColumnCell: ({ mapped_metadata }) => mapped_metadata?.body_mass_index_value, + renderColumnCell: ({ mapped_metadata }: PartialEntity) => mapped_metadata?.body_mass_index_value as string, }, { id: 'mapped_metadata.sex', label: 'Sex', - renderColumnCell: ({ mapped_metadata }) => mapped_metadata?.sex, + renderColumnCell: ({ mapped_metadata }: PartialEntity) => mapped_metadata?.sex as string, }, { id: 'mapped_metadata.race', label: 'Race', - renderColumnCell: ({ mapped_metadata }) => mapped_metadata?.race, + renderColumnCell: ({ mapped_metadata }: PartialEntity) => mapped_metadata?.race as string, }, lastModifiedTimestampCol, ], }, { - entityType: 'Sample', + entityType: 'Sample' as const, tabLabel: 'Samples', data: ancestorsSplitByEntityType.Sample, columns: [ organCol, - { id: 'sample_category', label: 'Sample Category', renderColumnCell: ({ sample_category }) => sample_category }, + { + id: 'sample_category', + label: 'Sample Category', + renderColumnCell: ({ sample_category }: PartialEntity) => sample_category as string, + }, lastModifiedTimestampCol, ], }, { - entityType: 'Dataset', + entityType: 'Dataset' as const, tabLabel: 'Datasets', data: ancestorsSplitByEntityType.Dataset, columns: [dataTypesCol, organCol, statusCol, lastModifiedTimestampCol], diff --git a/context/app/static/js/components/publications/PublicationRelatedEntities/index.js b/context/app/static/js/components/publications/PublicationRelatedEntities/index.ts similarity index 100% rename from context/app/static/js/components/publications/PublicationRelatedEntities/index.js rename to context/app/static/js/components/publications/PublicationRelatedEntities/index.ts diff --git a/context/app/static/js/components/publications/PublicationVisualizationsSection/PublicationsVisualizationSection.jsx b/context/app/static/js/components/publications/PublicationVisualizationsSection/PublicationsVisualizationSection.jsx index 1ccc522924..eae6af5564 100644 --- a/context/app/static/js/components/publications/PublicationVisualizationsSection/PublicationsVisualizationSection.jsx +++ b/context/app/static/js/components/publications/PublicationVisualizationsSection/PublicationsVisualizationSection.jsx @@ -3,10 +3,9 @@ import Typography from '@mui/material/Typography'; import ArrowDropUpRoundedIcon from '@mui/icons-material/ArrowDropUpRounded'; import React, { useCallback, useMemo, useState } from 'react'; -import DetailPageSection from 'js/components/detailPage/DetailPageSection'; +import { CollapsibleDetailPageSection } from 'js/components/detailPage/DetailPageSection'; import PublicationVignette from 'js/components/publications/PublicationVignette'; import PrimaryColorAccordionSummary from 'js/shared-styles/accordions/PrimaryColorAccordionSummary'; -import SectionHeader from 'js/shared-styles/sections/SectionHeader'; import { StyledAccordionDetails } from './style'; function PublicationsVisualizationSection({ vignette_json: { vignettes }, uuid }) { @@ -23,14 +22,13 @@ function PublicationsVisualizationSection({ vignette_json: { vignettes }, uuid } const handleChange = useCallback((i) => (event, isExpanded) => setExpandedIndex(isExpanded ? i : false), []); return ( - - Visualizations + {sortedVignettes.map((vignette, i) => { return ( setDisplayedVignettes((prev) => ({ ...prev, [i]: true })) }} + slotProps={{ transition: { onEntered: () => setDisplayedVignettes((prev) => ({ ...prev, [i]: true })) } }} onChange={handleChange(i)} data-testid="vignette" > @@ -52,7 +50,7 @@ function PublicationsVisualizationSection({ vignette_json: { vignettes }, uuid } ); })} - + ); } diff --git a/context/app/static/js/components/publications/PublicationsDataSection/PublicationsDataSection.jsx b/context/app/static/js/components/publications/PublicationsDataSection/PublicationsDataSection.jsx index 711042481c..ee3a5f81b5 100644 --- a/context/app/static/js/components/publications/PublicationsDataSection/PublicationsDataSection.jsx +++ b/context/app/static/js/components/publications/PublicationsDataSection/PublicationsDataSection.jsx @@ -5,8 +5,6 @@ import PublicationCollections from 'js/components/publications/PublicationCollec import { buildCollectionsWithDatasetQuery } from 'js/hooks/useDatasetsCollections'; import { useSearchHits } from 'js/hooks/useSearchData'; import { getIDsQuery } from 'js/helpers/queries'; -import SectionHeader from 'js/shared-styles/sections/SectionHeader'; -import DetailPageSection from 'js/components/detailPage/DetailPageSection'; function PublicationsDataSection({ datasetUUIDs, uuid, associatedCollectionUUID }) { const query = associatedCollectionUUID @@ -15,17 +13,12 @@ function PublicationsDataSection({ datasetUUIDs, uuid, associatedCollectionUUID const { searchHits: collectionsData } = useSearchHits(query); - return ( - - {associatedCollectionUUID && ( - Data - )} - {!associatedCollectionUUID && } - {collectionsData.length > 0 && ( - - )} - - ); + if (associatedCollectionUUID) { + return ( + + ); + } + return ; } export default PublicationsDataSection; diff --git a/context/app/static/js/components/searchPage/SearchNote.tsx b/context/app/static/js/components/searchPage/SearchNote.tsx index a580314e94..4ea27c2583 100644 --- a/context/app/static/js/components/searchPage/SearchNote.tsx +++ b/context/app/static/js/components/searchPage/SearchNote.tsx @@ -9,25 +9,19 @@ interface SearchNoteProps { params: URLSearchParams; } -interface EntityType { - entity_type: string; - hubmap_id: string; - mapped_data_types: string[]; -} - interface MessageProps { label: string; arg: string; } function EntityMessage({ arg: uuid, label }: MessageProps) { - const entity = useEntityData(uuid) as EntityType; - if (!entity) { + const [entity, isLoading] = useEntityData(uuid, ['hubmap_id', 'entity_type', 'mapped_data_types']); + if (!entity || isLoading) { return ; } const { entity_type, hubmap_id } = entity; const lcType = entity_type.toLowerCase(); - const dataTypes = (entity?.mapped_data_types || []).join('/'); + const dataTypes = (entity?.mapped_data_types ?? []).join('/'); return ( <> {`${label} ${dataTypes} ${lcType} `} diff --git a/context/app/static/js/components/searchPage/__tests__/SearchNote.spec.js b/context/app/static/js/components/searchPage/__tests__/SearchNote.spec.js index 480f27f307..3a0e95d4cc 100644 --- a/context/app/static/js/components/searchPage/__tests__/SearchNote.spec.js +++ b/context/app/static/js/components/searchPage/__tests__/SearchNote.spec.js @@ -1,6 +1,5 @@ import React from 'react'; -// eslint-disable-next-line -import { act, render, screen } from 'test-utils/functions'; +import { render, screen } from 'test-utils/functions'; import SearchNote from '../SearchNote'; @@ -10,7 +9,7 @@ const entity = { hubmap_id: 'FAKE_DOI', }; -jest.mock('js/hooks/useEntityData', () => () => entity); +jest.mock('js/hooks/useEntityData', () => () => [entity, false]); test('SearchNote renders for derived entities', async () => { const params = new URLSearchParams(); diff --git a/context/app/static/js/components/types.ts b/context/app/static/js/components/types.ts index 380a340b45..23a9d4aa8e 100644 --- a/context/app/static/js/components/types.ts +++ b/context/app/static/js/components/types.ts @@ -16,13 +16,18 @@ export type ESEntityType = | CollectionEntityType | PublicationEntityType; -export type DagProvenanceType = - | { - origin: string; - } - | { - name: string; - }; +export interface CWLPipelineLink { + hash: string; + name: string; + origin: string; +} + +export interface IngestPipelineLink { + hash: string; + origin: string; +} + +export type DagProvenanceType = CWLPipelineLink | IngestPipelineLink; export interface Entity { entity_type: ESEntityType; @@ -47,8 +52,10 @@ export interface Entity { created_by_user_displayname: string; created_by_user_email: string; mapped_status: string; + mapped_data_types?: string[]; mapped_data_access_level: 'Public' | 'Protected' | 'Consortium'; status: string; + mapped_metadata: Record; [key: string]: unknown; } @@ -61,6 +68,7 @@ export interface Donor extends Entity { age_unit: string; age_value: string; race: string[]; + body_mass_index_value: string; }>; } @@ -97,7 +105,10 @@ export interface Dataset extends Entity { registered_doi: string; doi_url?: string; published_timestamp: number; - mapped_external_group_name?: string; // Does this exist? + mapped_external_group_name?: string; + title: string; + dataset_type: string; + visualization: boolean; } export interface Collection extends Entity { diff --git a/context/app/static/js/components/workspaces/WorkspacesDropdownMenu/WorkspacesDropdownMenu.tsx b/context/app/static/js/components/workspaces/WorkspacesDropdownMenu/WorkspacesDropdownMenu.tsx index 85efeb987d..920454dbcb 100644 --- a/context/app/static/js/components/workspaces/WorkspacesDropdownMenu/WorkspacesDropdownMenu.tsx +++ b/context/app/static/js/components/workspaces/WorkspacesDropdownMenu/WorkspacesDropdownMenu.tsx @@ -1,4 +1,4 @@ -import React, { PropsWithChildren } from 'react'; +import React, { PropsWithChildren, useCallback } from 'react'; import MenuItem from '@mui/material/MenuItem'; import SvgIcon from '@mui/material/SvgIcon'; @@ -35,16 +35,19 @@ interface WorkspaceDropdownMenuItemProps extends PropsWithChildren { icon: typeof SvgIcon; } -function WorkspaceDropdownMenuItem({ dialogType, children, icon: Icon }: WorkspaceDropdownMenuItemProps) { +export function useOpenDialog(dialogType: DialogTypes) { const { open, setDialogType } = useEditWorkspaceStore(); + const onClick = useCallback(() => { + setDialogType(dialogType); + open(); + }, [dialogType, open, setDialogType]); + return onClick; +} +function WorkspaceDropdownMenuItem({ dialogType, children, icon: Icon }: WorkspaceDropdownMenuItemProps) { + const onClick = useOpenDialog(dialogType); return ( - { - setDialogType(dialogType); - open(); - }} - > + {children} diff --git a/context/app/static/js/helpers/swr/fetchers.ts b/context/app/static/js/helpers/swr/fetchers.ts index 830ef525b7..fe589f410f 100644 --- a/context/app/static/js/helpers/swr/fetchers.ts +++ b/context/app/static/js/helpers/swr/fetchers.ts @@ -22,8 +22,13 @@ async function f({ expectedStatusCodes = [200], errorMessages = {}, returnResponse = false, -}: SingleFetchOptionsType) { +}: SingleFetchOptionsType): Promise { return fetch(url, requestInit).then(async (response) => { + // Separate handling for 303 status code thrown by ES when documents are >10MB + if (response.status === 303) { + const s3URL = await response.text(); + return fetch(s3URL); + } if (!expectedStatusCodes.includes(response.status)) { const rawText = await response.text(); let errorBody: Record = { error: rawText }; diff --git a/context/app/static/js/helpers/withShouldDisplay.tsx b/context/app/static/js/helpers/withShouldDisplay.tsx new file mode 100644 index 0000000000..7b03fcd85f --- /dev/null +++ b/context/app/static/js/helpers/withShouldDisplay.tsx @@ -0,0 +1,10 @@ +import React, { ComponentType } from 'react'; + +export default function withShouldDisplay

(Component: ComponentType

) { + return function ComponentWithShouldDisplay({ shouldDisplay = true, ...props }: P & { shouldDisplay?: boolean }) { + if (!shouldDisplay) { + return null; + } + return ; + }; +} diff --git a/context/app/static/js/hooks/useDatasetsCollections.js b/context/app/static/js/hooks/useDatasetsCollections.ts similarity index 54% rename from context/app/static/js/hooks/useDatasetsCollections.js rename to context/app/static/js/hooks/useDatasetsCollections.ts index 7ad8a2d85e..c9773ed5aa 100644 --- a/context/app/static/js/hooks/useDatasetsCollections.js +++ b/context/app/static/js/hooks/useDatasetsCollections.ts @@ -1,7 +1,9 @@ import { useSearchHits } from 'js/hooks/useSearchData'; import { getAllCollectionsQuery } from 'js/helpers/queries'; +import { Collection } from 'js/pages/Collections/types'; +import { SearchRequest } from '@elastic/elasticsearch/lib/api/types'; -function buildCollectionsWithDatasetQuery(datasetUUIDs) { +function buildCollectionsWithDatasetQuery(datasetUUIDs: string[]): SearchRequest { return { ...getAllCollectionsQuery, query: { @@ -12,9 +14,10 @@ function buildCollectionsWithDatasetQuery(datasetUUIDs) { }; } -function useDatasetsCollections(datasetUUIDs) { +function useDatasetsCollections(datasetUUIDs: string[]) { const query = buildCollectionsWithDatasetQuery(datasetUUIDs); - const { searchHits: collections } = useSearchHits(query); + const { searchHits: collections } = useSearchHits(query); + return collections; } diff --git a/context/app/static/js/hooks/useEntityData.js b/context/app/static/js/hooks/useEntityData.js deleted file mode 100644 index b3d8ae403a..0000000000 --- a/context/app/static/js/hooks/useEntityData.js +++ /dev/null @@ -1,13 +0,0 @@ -import { useMemo } from 'react'; -import { useSearchHits } from 'js/hooks/useSearchData'; - -function useEntityData(uuid) { - const query = useMemo(() => ({ query: { ids: { values: [uuid] } } }), [uuid]); - - const { searchHits } = useSearchHits(query); - - // eslint-disable-next-line no-underscore-dangle - return searchHits[0]?._source; -} - -export default useEntityData; diff --git a/context/app/static/js/hooks/useEntityData.ts b/context/app/static/js/hooks/useEntityData.ts new file mode 100644 index 0000000000..310d5984a7 --- /dev/null +++ b/context/app/static/js/hooks/useEntityData.ts @@ -0,0 +1,13 @@ +import { useMemo } from 'react'; +import { useSearchHits } from 'js/hooks/useSearchData'; +import { Entity } from 'js/components/types'; + +export function useEntityData(uuid: string, source?: string[]): [Entity, boolean] { + const query = useMemo(() => ({ query: { ids: { values: [uuid] } }, _source: source }), [uuid, source]); + + const { searchHits, isLoading } = useSearchHits(query); + + return [searchHits[0]?._source, isLoading]; +} + +export default useEntityData; diff --git a/context/app/static/js/hooks/useInitialHash.tsx b/context/app/static/js/hooks/useInitialHash.tsx new file mode 100644 index 0000000000..9433aea645 --- /dev/null +++ b/context/app/static/js/hooks/useInitialHash.tsx @@ -0,0 +1,20 @@ +import React, { PropsWithChildren, useEffect, useState } from 'react'; +import { useContext, createContext } from 'js/helpers/context'; + +type InitialHashContextType = string | undefined; + +export const InitialHashContext = createContext('InitialHashContext'); +export function InitialHashContextProvider({ children }: PropsWithChildren) { + const [initialHash, setInitialHash] = useState(''); + useEffect(() => { + setInitialHash(window.location.hash); + }, []); + return {children}; +} + +/** + * Access the initial hash of the page when it's first loaded. + * This is used to scroll to the correct lazy-loaded section when the page is loaded. + * @returns the initial hash of the page when it's first loaded + */ +export const useInitialHashContext = () => useContext(InitialHashContext); diff --git a/context/app/static/js/pages/Dataset/Dataset.tsx b/context/app/static/js/pages/Dataset/Dataset.tsx index 86870dd605..7bf29404b5 100644 --- a/context/app/static/js/pages/Dataset/Dataset.tsx +++ b/context/app/static/js/pages/Dataset/Dataset.tsx @@ -2,24 +2,19 @@ import React from 'react'; import Box from '@mui/material/Box'; import Paper from '@mui/material/Paper'; import { InternalLink } from 'js/shared-styles/Links'; -import Files from 'js/components/detailPage/files/Files'; import DataProducts from 'js/components/detailPage/files/DataProducts'; import ProvSection from 'js/components/detailPage/provenance/ProvSection'; import Summary from 'js/components/detailPage/summary/Summary'; import Attribution from 'js/components/detailPage/Attribution'; -import Protocol from 'js/components/detailPage/Protocol'; -import VisualizationWrapper from 'js/components/detailPage/visualization/VisualizationWrapper'; import DetailLayout from 'js/components/detailPage/DetailLayout'; import SummaryItem from 'js/components/detailPage/summary/SummaryItem'; import ContributorsTable from 'js/components/detailPage/ContributorsTable'; import CollectionsSection from 'js/components/detailPage/CollectionsSection'; -import SupportAlert from 'js/components/detailPage/SupportAlert'; import { DetailPageAlert } from 'js/components/detailPage/style'; import BulkDataTransfer from 'js/components/detailPage/BulkDataTransfer'; import { DetailContextProvider } from 'js/components/detailPage/DetailContext'; import { getCombinedDatasetStatus } from 'js/components/detailPage/utils'; -import { combineMetadata } from 'js/pages/utils/entity-utils'; import { useDatasetsCollections } from 'js/hooks/useDatasetsCollections'; import useTrackID from 'js/hooks/useTrackID'; import { useTrackEntityPageEvent } from 'js/components/detailPage/useTrackEntityPageEvent'; @@ -27,9 +22,14 @@ import { useTrackEntityPageEvent } from 'js/components/detailPage/useTrackEntity import ComponentAlert from 'js/components/detailPage/multi-assay/ComponentAlert'; import MultiAssayRelationship from 'js/components/detailPage/multi-assay/MultiAssayRelationship'; import MetadataSection from 'js/components/detailPage/MetadataSection'; -import { Dataset, Entity, isDataset, isSupport, Sample, Support } from 'js/components/types'; +import { Dataset, Entity, isDataset } from 'js/components/types'; import DatasetRelationships from 'js/components/detailPage/DatasetRelationships'; -import useDatasetLabel, { useProcessedDatasetsSections } from './hooks'; +import ProcessedDataSection from 'js/components/detailPage/ProcessedData'; +import { SelectedVersionStoreProvider } from 'js/components/detailPage/VersionSelect/SelectedVersionStore'; +import SupportAlert from 'js/components/detailPage/SupportAlert'; +import OrganIcon from 'js/shared-styles/icons/OrganIcon'; +import Stack from '@mui/material/Stack'; +import { useProcessedDatasets, useProcessedDatasetsSections, useRedirectAlert } from './hooks'; interface SummaryDataChildrenProps { mapped_data_types: string[]; @@ -43,7 +43,6 @@ function SummaryDataChildren({ mapped_data_types, mapped_organ }: SummaryDataChi <> trackEntityPageEvent({ action: 'Assay Documentation Navigation', label: dataTypes })} @@ -52,29 +51,17 @@ function SummaryDataChildren({ mapped_data_types, mapped_organ }: SummaryDataChi - - {mapped_organ} + + + + {mapped_organ} + ); } -function OldVersionAlert({ uuid, isLatest }: { uuid: string; isLatest: boolean }) { - if (isLatest) { - return null; - } - return ( - - - {/* to override "display: flex" which splits this on to multiple lines. */} - You are viewing an older version of this page. Navigate to the{' '} - latest version. - - - ); -} - function ExternalDatasetAlert({ isExternal }: { isExternal: boolean }) { if (!isExternal) { return null; @@ -89,98 +76,12 @@ function ExternalDatasetAlert({ isExternal }: { isExternal: boolean }) { interface EntityDetailProps { assayMetadata: T; - vitData: object | object[]; - hasNotebook?: boolean; - visLiftedUUID: string; -} - -function makeMetadataSectionProps(metadata: Record, assay_modality: 'single' | 'multiple') { - return assay_modality === 'multiple' ? { assay_modality } : { metadata, assay_modality }; -} - -function SupportDetail({ assayMetadata }: EntityDetailProps) { - const { - metadata, - files, - donor, - source_samples, - uuid, - mapped_data_types, - origin_samples, - hubmap_id, - entity_type, - status, - mapped_data_access_level, - mapped_external_group_name, - contributors, - contacts, - is_component, - assay_modality, - } = assayMetadata; - - const isLatest = !('next_revision_uuid' in assayMetadata); - - const combinedMetadata = combineMetadata( - donor, - origin_samples[0], - source_samples as Sample[], - metadata as Record, - ); - - const shouldDisplaySection = { - summary: true, - provenance: false, - metadata: Boolean(Object.keys(combinedMetadata).length) || assay_modality === 'multiple', - files: Boolean(files?.length), - 'bulk-data-transfer': true, - contributors: Boolean(contributors && (contributors as unknown[]).length), - attribution: true, - }; - - const datasetLabel = useDatasetLabel(); - - return ( - - - - - {Boolean(is_component) && } - -

- - - - } - > - - - {shouldDisplaySection.metadata && ( - - )} - {shouldDisplaySection.files && } - {shouldDisplaySection['bulk-data-transfer'] && } - {shouldDisplaySection.contributors && ( - - )} - - - - - ); } -function DatasetDetail({ assayMetadata, vitData, hasNotebook }: EntityDetailProps) { +function DatasetDetail({ assayMetadata }: EntityDetailProps) { const { protocol_url, - metadata, files, - donor, - source_samples, uuid, mapped_data_types, origin_samples, @@ -196,86 +97,63 @@ function DatasetDetail({ assayMetadata, vitData, hasNotebook }: EntityDetailProp processing, } = assayMetadata; - const isLatest = !('next_revision_uuid' in assayMetadata); + useRedirectAlert(); const origin_sample = origin_samples[0]; const { mapped_organ } = origin_sample; const combinedStatus = getCombinedDatasetStatus({ sub_status, status }); - const combinedMetadata = combineMetadata( - donor, - origin_sample, - source_samples as Sample[], - metadata as Record, - ); - - const collectionsData = useDatasetsCollections([uuid]); - const { sections, isLoading } = useProcessedDatasetsSections(); + const { searchHits: processedDatasets } = useProcessedDatasets(); + + // Top level request for collections data to determine if there are any collections for any of the datasets + const collectionsData = useDatasetsCollections([uuid, ...processedDatasets.map((ds) => ds._id)]); const shouldDisplaySection = { summary: true, + metadata: true, 'processed-data': sections, - visualization: Boolean(vitData), + 'bulk-data-transfer': true, provenance: true, protocols: Boolean(protocol_url), - metadata: Boolean(Object.keys(combinedMetadata).length) || assay_modality === 'multiple', - files: Boolean(files?.length), - 'bulk-data-transfer': true, collections: Boolean(collectionsData.length), - contributors: Boolean(contributors && (contributors as unknown[]).length), attribution: true, }; - const datasetLabel = useDatasetLabel(); - - const metadataSectionProps = - assay_modality === 'multiple' ? { assay_modality } : { metadata: combinedMetadata, assay_modality }; - return ( - - - {Boolean(is_component) && } - - - - - - - - - } - > - - - {shouldDisplaySection.visualization && ( - ds._id) ?? []}> + + {Boolean(is_component) && } + + - )} - {shouldDisplaySection.provenance && } - {shouldDisplaySection.protocols && } - {shouldDisplaySection.metadata && } - {shouldDisplaySection.files && } - {shouldDisplaySection['bulk-data-transfer'] && } - {shouldDisplaySection.collections && } - {shouldDisplaySection.contributors && ( - - )} - - + mapped_external_group_name={mapped_external_group_name} + bottomFold={ + <> + + + + + + + } + > + + + + + + + + + + + + ); } @@ -288,11 +166,8 @@ function DetailPageWrapper({ assayMetadata, ...props }: EntityDetailProps; } - if (isSupport(assayMetadata)) { - return ; - } - console.error('Unsupported entity type'); - return null; + // Should never be reached due to server-side redirect to primary dataset, but just in case... + return ; } export default DetailPageWrapper; diff --git a/context/app/static/js/pages/Dataset/hooks.ts b/context/app/static/js/pages/Dataset/hooks.ts index 5800df97ff..a6dc8bd777 100644 --- a/context/app/static/js/pages/Dataset/hooks.ts +++ b/context/app/static/js/pages/Dataset/hooks.ts @@ -1,14 +1,19 @@ import { SearchHit } from '@elastic/elasticsearch/lib/api/types'; -import useSWR from 'swr'; +import useSWR, { useSWRConfig } from 'swr'; -import { useFlaskDataContext } from 'js/components/Contexts'; +import { useAppContext, useFlaskDataContext } from 'js/components/Contexts'; import { useSearchHits } from 'js/hooks/useSearchData'; -import { excludeComponentDatasetsClause, excludeSupportEntitiesClause, getIDsQuery } from 'js/helpers/queries'; +import { excludeComponentDatasetsClause, getIDsQuery } from 'js/helpers/queries'; import { Dataset, isDataset } from 'js/components/types'; import { getSectionFromString } from 'js/shared-styles/sections/TableOfContents/utils'; -import { multiFetcher } from 'js/helpers/swr'; +import { fetcher } from 'js/helpers/swr'; import { TableOfContentsItem } from 'js/shared-styles/sections/TableOfContents/types'; +import { getAuthHeader } from 'js/helpers/functions'; +import { useEffect } from 'react'; +import { useSnackbarActions } from 'js/shared-styles/snackbars'; +import { datasetSectionId } from './utils'; + function useDatasetLabelPrefix() { const { entity: { processing, is_component }, @@ -34,25 +39,44 @@ function useDatasetLabel() { return [prefix, 'Dataset'].join(' '); } -type ProcessedDatasetTypes = Pick< +export type ProcessedDatasetInfo = Pick< Dataset, - 'hubmap_id' | 'entity_type' | 'uuid' | 'assay_display_name' | 'files' | 'pipeline' + | 'hubmap_id' + | 'entity_type' + | 'uuid' + | 'assay_display_name' + | 'files' + | 'pipeline' + | 'status' + | 'metadata' + | 'creation_action' + | 'created_timestamp' + | 'dbgap_study_url' + | 'dbgap_sra_experiment_url' + | 'is_component' + | 'visualization' >; -type VitessceConf = object | null; - -async function fetchVitessceConfMap(uuids: string[]) { - const urls = uuids.map((id) => `/browse/dataset/${id}.vitessce.json`); - const confs = await multiFetcher({ urls }); +type VitessceConf = object | undefined; - return new Map(uuids.map((id, i) => [id, confs[i]])); +// Helper function to access the result in the cache. +function getVitessceConfKey(uuid: string, groupsToken: string) { + return `vitessce-conf-${uuid}-${groupsToken}`; } -function useVitessceConfs({ uuids, shouldFetch = true }: { uuids: string[]; shouldFetch?: boolean }) { - return useSWR(shouldFetch ? uuids : null, (u) => fetchVitessceConfMap(u), { fallbackData: new Map() }); +export function useVitessceConf(uuid: string, parentUuid?: string) { + const { groupsToken } = useAppContext(); + const base = `/browse/dataset/${uuid}.vitessce.json`; + const urlParams = new URLSearchParams(window.location.search); + if (parentUuid) { + urlParams.set('parent', parentUuid); + } + return useSWR(getVitessceConfKey(uuid, groupsToken), (_key: unknown) => + fetcher({ url: `${base}?${urlParams.toString()}`, requestInit: { headers: getAuthHeader(groupsToken) } }), + ); } -function useProcessedDatasets() { +function useProcessedDatasets(includeComponents?: boolean) { const { entity } = useFlaskDataContext(); const entityIsDataset = isDataset(entity); @@ -62,10 +86,27 @@ function useProcessedDatasets() { query: { bool: { // TODO: Futher narrow once we understand EPICs. - must: [getIDsQuery(descendant_ids), excludeSupportEntitiesClause, excludeComponentDatasetsClause], + must: includeComponents + ? [getIDsQuery(descendant_ids)] + : [getIDsQuery(descendant_ids), excludeComponentDatasetsClause], }, }, - _source: ['hubmap_id', 'entity_type', 'uuid', 'assay_display_name', 'files', 'pipeline'], + _source: [ + 'hubmap_id', + 'entity_type', + 'uuid', + 'assay_display_name', + 'files', + 'pipeline', + 'status', + 'metadata', + 'creation_action', + 'created_timestamp', + 'dbgap_study_url', + 'dbgap_sra_experiment_url', + 'is_component', + 'visualization', + ], size: 10000, }; @@ -73,47 +114,55 @@ function useProcessedDatasets() { const shouldFetch = isPrimary && entityIsDataset; - const { searchHits, isLoading } = useSearchHits(query, { + const { searchHits, isLoading } = useSearchHits(query, { shouldFetch, }); - const { data: confs, isLoading: isLoadingConfs } = useVitessceConfs({ uuids: descendant_ids, shouldFetch }); - - return { searchHits, confs, isLoading: isLoading || isLoadingConfs }; + return { searchHits, isLoading }; } function getProcessedDatasetSection({ hit, conf, }: { - hit: Required>; + hit: Required>; conf?: VitessceConf; }) { - const { pipeline, hubmap_id } = hit._source; + const { pipeline, hubmap_id, files, metadata, visualization } = hit._source; const shouldDisplaySection = { summary: true, - visualization: Boolean(conf), - files: Boolean(hit?._source?.files), + visualization: visualization || Boolean(conf && 'data' in conf && conf?.data), + files: Boolean(files), + analysis: Boolean(metadata?.dag_provenance_list), }; const sectionsToDisplay = Object.entries(shouldDisplaySection).filter(([_k, v]) => v === true); return { // TODO: Improve the lookup for descendants to exclude anything with a missing pipeline name - ...getSectionFromString(pipeline ?? hubmap_id, `${hubmap_id}-section`), - items: sectionsToDisplay.map(([s]) => ({ ...getSectionFromString(s), hash: `${s}-${hubmap_id}` })), + ...getSectionFromString(pipeline ?? hubmap_id, datasetSectionId(hit._source, 'section')), + items: sectionsToDisplay.map(([s]) => ({ + ...getSectionFromString(s, datasetSectionId(hit._source, s)), + hash: datasetSectionId(hit._source, s), + })), }; } function useProcessedDatasetsSections(): { sections: TableOfContentsItem | false; isLoading: boolean } { - const { searchHits, confs, isLoading } = useProcessedDatasets(); + const { searchHits, isLoading } = useProcessedDatasets(); + + const { cache } = useSWRConfig(); + + const { groupsToken } = useAppContext(); const sections = searchHits.length > 0 ? { ...getSectionFromString('processed-data'), - items: searchHits.map((hit) => getProcessedDatasetSection({ hit, conf: confs.get(hit._id) })), + items: searchHits.map((hit) => + getProcessedDatasetSection({ hit, conf: cache.get(getVitessceConfKey(hit._id, groupsToken)) }), + ), } : false; @@ -123,5 +172,15 @@ function useProcessedDatasetsSections(): { sections: TableOfContentsItem | false }; } +export function useRedirectAlert() { + const { redirected } = useFlaskDataContext(); + const { toastInfo } = useSnackbarActions(); + useEffect(() => { + if (redirected) { + toastInfo('You have been redirected to the unified view for this dataset.'); + } + }, [redirected, toastInfo]); +} + export { useProcessedDatasets, useProcessedDatasetsSections }; export default useDatasetLabel; diff --git a/context/app/static/js/pages/Dataset/utils.ts b/context/app/static/js/pages/Dataset/utils.ts new file mode 100644 index 0000000000..3e6d3190bb --- /dev/null +++ b/context/app/static/js/pages/Dataset/utils.ts @@ -0,0 +1,11 @@ +import { ProcessedDatasetInfo } from './hooks'; + +export function datasetSectionId( + dataset: Pick, + prefix: string, +) { + const { pipeline, hubmap_id, status } = dataset; + const formattedDatasetIdentifier = (pipeline ?? hubmap_id).replace(/\s/g, ''); + const formattedStatus = status.replace(/\s/g, ''); + return `${prefix}-${encodeURIComponent(formattedDatasetIdentifier)}-${encodeURIComponent(formattedStatus)}`.toLowerCase(); +} diff --git a/context/app/static/js/pages/Donor/Donor.jsx b/context/app/static/js/pages/Donor/Donor.jsx index c1b193047c..8de24e9e90 100644 --- a/context/app/static/js/pages/Donor/Donor.jsx +++ b/context/app/static/js/pages/Donor/Donor.jsx @@ -56,7 +56,7 @@ function DonorDetail() { {shouldDisplaySection.metadata && } - {shouldDisplaySection.protocols && } + {shouldDisplaySection.protocols && } {shouldDisplaySection[summaryId] && ( -
- - {organ.description} - -
- )} - {shouldDisplaySection[hraId] && ( -
- -
- )} - {shouldDisplaySection[referenceId] && ( -
- -
- )} - {shouldDisplaySection[assaysId] && ( -
- - -
- )} - {shouldDisplaySection[samplesId] && ( -
- -
+ + {organ.description} + )} + {shouldDisplaySection[hraId] && } + {shouldDisplaySection[referenceId] && } + {shouldDisplaySection[assaysId] && } + {shouldDisplaySection[samplesId] && } ); } diff --git a/context/app/static/js/pages/Publication/Publication.jsx b/context/app/static/js/pages/Publication/Publication.jsx index 0ac2112e27..141609ff4a 100644 --- a/context/app/static/js/pages/Publication/Publication.jsx +++ b/context/app/static/js/pages/Publication/Publication.jsx @@ -7,9 +7,9 @@ import PublicationSummary from 'js/components/publications/PublicationSummary'; import PublicationsVisualizationSection from 'js/components/publications/PublicationVisualizationsSection/'; import PublicationsDataSection from 'js/components/publications/PublicationsDataSection'; import Files from 'js/components/detailPage/files/Files'; -import BulkDataTransfer from 'js/components/detailPage/BulkDataTransfer'; import { DetailContext } from 'js/components/detailPage/DetailContext'; import useTrackID from 'js/hooks/useTrackID'; +import PublicationBulkDataTransfer from 'js/components/detailPage/BulkDataTransfer/PublicationBulkDataTransfer'; function Publication({ publication, vignette_json }) { const { @@ -60,17 +60,9 @@ function Publication({ publication, vignette_json }) { )} {shouldDisplaySection.files && } - {shouldDisplaySection['bulk-data-transfer'] && ( - - )} + {shouldDisplaySection['bulk-data-transfer'] && } - {shouldDisplaySection.provenance && ( - - )} + {shouldDisplaySection.provenance && } ); diff --git a/context/app/static/js/pages/Sample/Sample.jsx b/context/app/static/js/pages/Sample/Sample.jsx index 062dae083c..914ba98f94 100644 --- a/context/app/static/js/pages/Sample/Sample.jsx +++ b/context/app/static/js/pages/Sample/Sample.jsx @@ -37,7 +37,7 @@ function SampleDetail() { const origin_sample = origin_samples[0]; const { mapped_organ } = origin_sample; - const combinedMetadata = combineMetadata(donor, undefined, undefined, metadata); + const combinedMetadata = combineMetadata(donor, undefined, metadata); const shouldDisplaySection = { summary: true, @@ -69,8 +69,8 @@ function SampleDetail() { {shouldDisplaySection['derived-data'] && } - {shouldDisplaySection.protocols && } - {shouldDisplaySection.metadata && } + {shouldDisplaySection.protocols && } + diff --git a/context/app/static/js/pages/utils/entity-utils.spec.js b/context/app/static/js/pages/utils/entity-utils.spec.ts similarity index 69% rename from context/app/static/js/pages/utils/entity-utils.spec.js rename to context/app/static/js/pages/utils/entity-utils.spec.ts index 6395aac7ac..b51eb42418 100644 --- a/context/app/static/js/pages/utils/entity-utils.spec.js +++ b/context/app/static/js/pages/utils/entity-utils.spec.ts @@ -1,22 +1,23 @@ +import { Donor, Sample } from 'js/components/types'; import { combineMetadata } from './entity-utils'; test('robust against undefined data', () => { const donor = undefined; - const origin_sample = undefined; const source_samples = undefined; const metadata = undefined; - expect(combineMetadata(donor, origin_sample, source_samples, metadata)).toEqual({}); + // @ts-expect-error - This is a test case for bad data. + expect(combineMetadata(donor, source_samples, metadata)).toEqual({}); }); test('robust against empty objects', () => { const donor = {}; - const origin_sample = {}; - const source_samples = []; + const source_samples: Sample[] = []; const metadata = {}; - expect(combineMetadata(donor, origin_sample, source_samples, metadata)).toEqual({}); + // @ts-expect-error - This is a test case for bad data. + expect(combineMetadata(donor, source_samples, metadata)).toEqual({}); }); -test('combines appropiately structured metadata', () => { +test('combines appropriately structured metadata', () => { // This information is also available in the "ancestors" list, // but metadata is structured differently between Samples and Donors, // so it wouldn't simplify things to use that. @@ -30,12 +31,7 @@ test('combines appropiately structured metadata', () => { // This is the source of the mapped_metadata. living_donor_data: [], }, - }; - const origin_sample = { - mapped_organ: 'Kidney (Right)', - sample_category: 'Organ', - // Currently, not seeing any metadata here, but that may change. - }; + } as unknown as Donor; const source_samples = [ { // mapped_metadata seems to be empty. @@ -45,17 +41,17 @@ test('combines appropiately structured metadata', () => { cold_ischemia_time_value: '100', }, }, - ]; + ] as unknown as Sample[]; const metadata = { dag_provenance_list: [], - extra_metatadata: {}, + extra_metadata: {}, metadata: { analyte_class: 'polysaccharides', assay_category: 'imaging', assay_type: 'PAS microscopy', }, }; - expect(combineMetadata(donor, origin_sample, source_samples, metadata)).toEqual({ + expect(combineMetadata(donor, source_samples, metadata)).toEqual({ analyte_class: 'polysaccharides', assay_category: 'imaging', assay_type: 'PAS microscopy', diff --git a/context/app/static/js/pages/utils/entity-utils.ts b/context/app/static/js/pages/utils/entity-utils.ts index dba9839148..1161cfa2af 100644 --- a/context/app/static/js/pages/utils/entity-utils.ts +++ b/context/app/static/js/pages/utils/entity-utils.ts @@ -16,9 +16,8 @@ function prefixSampleMetadata(source_samples: Sample[] | null | undefined) { function combineMetadata( donor: Donor, - _origin_sample: unknown, - source_samples: Sample[], - metadata: Record | null | undefined, + source_samples: Sample[] = [], + metadata: Record | null | undefined = {}, ) { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const combinedMetadata: Record = { diff --git a/context/app/static/js/shared-styles/icons/ExternalImageIcon.tsx b/context/app/static/js/shared-styles/icons/ExternalImageIcon.tsx index c294caefc5..1e61230ee3 100644 --- a/context/app/static/js/shared-styles/icons/ExternalImageIcon.tsx +++ b/context/app/static/js/shared-styles/icons/ExternalImageIcon.tsx @@ -1,11 +1,11 @@ import React from 'react'; -import * as externalIcons from './externalImageIcons'; +import { externalIconMap } from './externalImageIcons'; interface ExternalImageIconProps { - icon: keyof typeof externalIcons; + icon: keyof typeof externalIconMap; } export default function ExternalImageIcon({ icon }: ExternalImageIconProps) { - const { src, alt } = externalIcons[icon]; + const { src, alt } = externalIconMap[icon]; return {alt}; } diff --git a/context/app/static/js/shared-styles/icons/externalImageIcons.ts b/context/app/static/js/shared-styles/icons/externalImageIcons.ts index 9349d1ad7c..12c6d35bfb 100644 --- a/context/app/static/js/shared-styles/icons/externalImageIcons.ts +++ b/context/app/static/js/shared-styles/icons/externalImageIcons.ts @@ -1,50 +1,62 @@ -interface IconProps { +export interface ExternalIconProps { src: string; alt: string; } -const nih: IconProps = { +const nih: ExternalIconProps = { src: `${CDN_URL}/v2/icons/nih_common_fund.png`, alt: 'NIH Logo', }; -const protocols: IconProps = { +const protocols: ExternalIconProps = { src: `${CDN_URL}/v2/icons/protocols.png`, alt: 'Protocols.io Logo', }; -const googleScholar: IconProps = { +const googleScholar: ExternalIconProps = { src: `${CDN_URL}/v2/icons/google_scholar.png`, alt: 'Google Scholar Logo', }; -const hra: IconProps = { +const hra: ExternalIconProps = { src: `${CDN_URL}/v2/icons/hra_icon.png`, alt: 'Human Reference Atlas Logo', }; -const azimuth: IconProps = { +const azimuth: ExternalIconProps = { src: `${CDN_URL}/v2/icons/azimuth.png`, alt: 'A miniature scatterplot visualization.', }; -const fusion: IconProps = { +const fusion: ExternalIconProps = { src: `${CDN_URL}/v2/icons/fusion.png`, alt: 'FUSION Logo', }; -const avr: IconProps = { +const avr: ExternalIconProps = { src: `${CDN_URL}/v2/icons/antibody_validation_reports.png`, alt: 'Antibody Validation Reports Logo', }; -const hubmapConsortium: IconProps = { +const hubmapConsortium: ExternalIconProps = { src: `${CDN_URL}/v2/icons/hubmap_consortium.png`, alt: 'HuBMAP Consortium Logo', }; -const dataPortal: IconProps = { +const dataPortal: ExternalIconProps = { src: `${CDN_URL}/v2/icons/dataportal_icon.png`, alt: 'Data Portal Logo', }; -export { nih, protocols, googleScholar, hra, azimuth, fusion, avr, hubmapConsortium, dataPortal }; +export const externalIconMap = { + nih, + protocols, + googleScholar, + hra, + azimuth, + fusion, + avr, + hubmapConsortium, + dataPortal, +} as const; + +export type ExternalIcons = keyof typeof externalIconMap; diff --git a/context/app/static/js/shared-styles/icons/organIconMap.ts b/context/app/static/js/shared-styles/icons/organIconMap.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/context/app/static/js/shared-styles/icons/sectionIconMap.ts b/context/app/static/js/shared-styles/icons/sectionIconMap.ts index 69121317c0..91185a96b5 100644 --- a/context/app/static/js/shared-styles/icons/sectionIconMap.ts +++ b/context/app/static/js/shared-styles/icons/sectionIconMap.ts @@ -1,26 +1,51 @@ import SvgIcon from '@mui/material/SvgIcon'; +import AddChartRounded from '@mui/icons-material/AddchartRounded'; import { AnalysisDetailsIcon, AttributionIcon, BulkDataIcon, CollectionIcon, + DatasetIcon, FileIcon, + GeneIcon, MetadataIcon, + OrganIcon, ProcessedDataIcon, ProvenanceIcon, + SampleIcon, SummaryIcon, VisualizationIcon, } from './icons'; +import { externalIconMap } from './externalImageIcons'; export const sectionIconMap: Record = { summary: SummaryIcon, metadata: MetadataIcon, 'processed-data': ProcessedDataIcon, visualization: VisualizationIcon, + visualizations: AddChartRounded, // Publications use this key files: FileIcon, analysis: AnalysisDetailsIcon, + protocols: AnalysisDetailsIcon, // Donors/Samples use this key + tissue: OrganIcon, // Samples use this key + organs: OrganIcon, // Genes use this key 'bulk-data-transfer': BulkDataIcon, provenance: ProvenanceIcon, collections: CollectionIcon, attribution: AttributionIcon, + authors: AttributionIcon, // Publications use this key + contributors: AttributionIcon, // Collections use this key + data: DatasetIcon, + 'derived-data': ProcessedDataIcon, // Donors/Samples use this key + 'biomarker-query': DatasetIcon, // Genes use this key + datasets: DatasetIcon, // Collections use this key + 'distribution-across-organs': VisualizationIcon, // Cell Types use this key + biomarkers: GeneIcon, // Cell Types use this key + 'reference-based-analysis': VisualizationIcon, + assays: DatasetIcon, + samples: SampleIcon, +} as const; + +export const sectionImageIconMap: Record = { + 'human-reference-atlas': 'hra', }; diff --git a/context/app/static/js/shared-styles/sections/TableOfContents/TableOfContents.tsx b/context/app/static/js/shared-styles/sections/TableOfContents/TableOfContents.tsx index 1ecdb97ba0..777f7aa682 100644 --- a/context/app/static/js/shared-styles/sections/TableOfContents/TableOfContents.tsx +++ b/context/app/static/js/shared-styles/sections/TableOfContents/TableOfContents.tsx @@ -11,11 +11,11 @@ import Box from '@mui/material/Box'; import { animated } from '@react-spring/web'; -import useEntityStore from 'js/stores/useEntityStore'; -import { StickyNav, TableTitle, StyledItemLink } from './style'; +import ExternalImageIcon from 'js/shared-styles/icons/ExternalImageIcon'; +import { StickyNav, TableTitle, StyledItemLink, StyledIconContainer } from './style'; import { TableOfContentsItem, TableOfContentsItems, TableOfContentsItemWithNode } from './types'; import { getItemsClient } from './utils'; -import { useThrottledOnScroll, useFindActiveIndex } from './hooks'; +import { useThrottledOnScroll, useFindActiveIndex, useAnimatedSidebarPosition } from './hooks'; const AnimatedNav = animated(StickyNav); @@ -26,7 +26,7 @@ interface LinkProps { } function ItemLink({ item, currentSection, handleClick, isNested = false }: LinkProps & { item: TableOfContentsItem }) { - const { icon: Icon } = item; + const { icon: Icon, externalIcon } = item; return ( {Icon && } + {externalIcon && ( + + + + )} {item.text} ); @@ -145,21 +150,19 @@ function TableOfContents({ items, isLoading = false }: { items: TableOfContentsI } }, []); - const { springs } = useEntityStore(); + const position = useAnimatedSidebarPosition(); if (!items || items.length === 0) { return null; } - const [springValues] = springs; - - if (springValues[1] === undefined) { + if (!position) { return null; } return ( - + Contents {isLoading ? ( <> diff --git a/context/app/static/js/shared-styles/sections/TableOfContents/hooks.ts b/context/app/static/js/shared-styles/sections/TableOfContents/hooks.ts index 4fd443ec86..d657f06b9a 100644 --- a/context/app/static/js/shared-styles/sections/TableOfContents/hooks.ts +++ b/context/app/static/js/shared-styles/sections/TableOfContents/hooks.ts @@ -1,5 +1,7 @@ import React, { MutableRefObject } from 'react'; import { throttle } from 'js/helpers/functions'; +import useEntityStore from 'js/stores/useEntityStore'; +import { SpringValues } from '@react-spring/web'; import { TableOfContentsNodesRef } from './types'; function useThrottledOnScroll(callback: (() => void) | null, delay: number) { @@ -52,4 +54,16 @@ function useFindActiveIndex({ }, [currentSection, setCurrentSection, clickedRef, itemsWithNodeRef]); } -export { useThrottledOnScroll, useFindActiveIndex }; +function useAnimatedSidebarPosition() { + const { springs } = useEntityStore(); + + const [springValues] = springs; + + if (springValues[1] === undefined) { + return null; + } + + return springValues[1] as SpringValues; +} + +export { useThrottledOnScroll, useFindActiveIndex, useAnimatedSidebarPosition }; diff --git a/context/app/static/js/shared-styles/sections/TableOfContents/style.ts b/context/app/static/js/shared-styles/sections/TableOfContents/style.ts index 007fed8d45..d4aafb99e7 100644 --- a/context/app/static/js/shared-styles/sections/TableOfContents/style.ts +++ b/context/app/static/js/shared-styles/sections/TableOfContents/style.ts @@ -1,6 +1,7 @@ import { styled } from '@mui/material/styles'; import Typography from '@mui/material/Typography'; import Link from '@mui/material/Link'; +import Box from '@mui/material/Box'; const StickyNav = styled('nav')({ position: 'sticky', @@ -29,4 +30,13 @@ const StyledItemLink = styled(Link)<{ $isCurrentSection: boolean; $isNested: boo }), ); -export { StickyNav, TableTitle, StyledItemLink }; +const StyledIconContainer = styled(Box)({ + width: '1rem', + display: 'flex', + '& > *': { + width: '100%', + height: '100%', + }, +}); + +export { StickyNav, TableTitle, StyledItemLink, StyledIconContainer }; diff --git a/context/app/static/js/shared-styles/sections/TableOfContents/types.ts b/context/app/static/js/shared-styles/sections/TableOfContents/types.ts index f3a4f06cfe..af0458675a 100644 --- a/context/app/static/js/shared-styles/sections/TableOfContents/types.ts +++ b/context/app/static/js/shared-styles/sections/TableOfContents/types.ts @@ -1,10 +1,12 @@ import SvgIcon from '@mui/material/SvgIcon'; +import { ExternalIcons } from 'js/shared-styles/icons/externalImageIcons'; import { MutableRefObject } from 'react'; export interface TableOfContentsItem { text: string; hash: string; icon?: typeof SvgIcon; + externalIcon?: ExternalIcons; items?: TableOfContentsItem[]; } diff --git a/context/app/static/js/shared-styles/sections/TableOfContents/utils.ts b/context/app/static/js/shared-styles/sections/TableOfContents/utils.ts index 29f670aca8..90dca234c5 100644 --- a/context/app/static/js/shared-styles/sections/TableOfContents/utils.ts +++ b/context/app/static/js/shared-styles/sections/TableOfContents/utils.ts @@ -1,9 +1,19 @@ import { capitalizeAndReplaceDashes } from 'js/helpers/functions'; -import { sectionIconMap } from 'js/shared-styles/icons/sectionIconMap'; +import { sectionIconMap, sectionImageIconMap } from 'js/shared-styles/icons/sectionIconMap'; import { TableOfContentsItem, TableOfContentsItemWithNode, TableOfContentsItems } from './types'; -function getSectionFromString(s: string, hash: string = s): TableOfContentsItem { - return { text: capitalizeAndReplaceDashes(s), hash: encodeURIComponent(hash), icon: sectionIconMap?.[s] }; +function formatSectionHash(hash: string) { + const hashWithoutDots = hash.replace(/\s/g, '').toLowerCase(); + return encodeURIComponent(hashWithoutDots); +} + +function getSectionFromString(s: string, hash: string = formatSectionHash(s)): TableOfContentsItem { + return { + text: capitalizeAndReplaceDashes(s), + hash, + icon: sectionIconMap?.[s], + externalIcon: sectionImageIconMap?.[s], + }; } export type SectionOrder = Record; @@ -25,8 +35,9 @@ function getItemsClient(items: TableOfContentsItems): TableOfContentsItems props.theme.spacing(2)}; -`; - -export { DetailSectionPaper }; diff --git a/context/app/static/js/shared-styles/surfaces/index.ts b/context/app/static/js/shared-styles/surfaces/index.ts new file mode 100644 index 0000000000..21c2bc4069 --- /dev/null +++ b/context/app/static/js/shared-styles/surfaces/index.ts @@ -0,0 +1,8 @@ +import { styled } from '@mui/material/styles'; +import Paper from '@mui/material/Paper'; + +const DetailSectionPaper = styled(Paper)(({ theme }) => ({ + padding: theme.spacing(2), +})) as typeof Paper; + +export { DetailSectionPaper }; diff --git a/context/app/static/js/shared-styles/tables/TableTabs/TableTab.tsx b/context/app/static/js/shared-styles/tables/TableTabs/TableTab.tsx index e7fd6888ba..9b6bc850ae 100644 --- a/context/app/static/js/shared-styles/tables/TableTabs/TableTab.tsx +++ b/context/app/static/js/shared-styles/tables/TableTabs/TableTab.tsx @@ -8,6 +8,8 @@ interface TableTabProps extends TabProps { const SingleTableTab = styled((props: TabProps) => )({ cursor: 'default', + maxWidth: 'unset', + flexGrow: 1, }); function TableTab({ isSingleTab = false, ...props }: TableTabProps, ref: React.Ref) { diff --git a/context/app/static/js/stores/useEntityStore.ts b/context/app/static/js/stores/useEntityStore.ts index 4b9cef55fc..ae52388b02 100644 --- a/context/app/static/js/stores/useEntityStore.ts +++ b/context/app/static/js/stores/useEntityStore.ts @@ -41,7 +41,7 @@ export const createEntityStore = ({ springs }: { springs: ReturnType set({ view: val }), springs, assayMetadata: {}, diff --git a/context/jest.config.js b/context/jest.config.js index 38f6561afa..0ddb1cb590 100644 --- a/context/jest.config.js +++ b/context/jest.config.js @@ -1,5 +1,8 @@ /** @type {import('jest').Config} */ const config = { + globals: { + CDN_URL: 'https://cdn_url.example.com/', + }, restoreMocks: true, testPathIgnorePatterns: ['jest.config.js', '/cypress/'], setupFilesAfterEnv: ['/test-utils/setupTests.js'],