diff --git a/CHANGELOG-entity-header-styling.md b/CHANGELOG-entity-header-styling.md new file mode 100644 index 0000000000..d7f8bde1a3 --- /dev/null +++ b/CHANGELOG-entity-header-styling.md @@ -0,0 +1 @@ +- Fix Webkit-specific bugs that caused the entity header to become misaligned. \ No newline at end of file diff --git a/CHANGELOG-fix-metadata-table.md b/CHANGELOG-fix-metadata-table.md new file mode 100644 index 0000000000..fe89cfa966 --- /dev/null +++ b/CHANGELOG-fix-metadata-table.md @@ -0,0 +1,3 @@ +- Switch to using metadata table with tabs component in the Sample and Donor pages. +- In the metadata sections of Dataset, Sample, and Donor pages, add tabs for any entities in the Provenance section with metadata. +- Update the metadata table component to show unique labels for each tab and to be scrollable when many tabs are present. \ No newline at end of file diff --git a/CHANGELOG-remove-qa-workspace-option.md b/CHANGELOG-remove-qa-workspace-option.md new file mode 100644 index 0000000000..72d5b720f4 --- /dev/null +++ b/CHANGELOG-remove-qa-workspace-option.md @@ -0,0 +1 @@ +- Fix issue of Workspaces button being available for protected datasets in the Visualization section. \ No newline at end of file diff --git a/context/app/static/js/components/Header/Header/Header.tsx b/context/app/static/js/components/Header/Header/Header.tsx index fd20fa4ce0..f041e7754e 100644 --- a/context/app/static/js/components/Header/Header/Header.tsx +++ b/context/app/static/js/components/Header/Header/Header.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import Box from '@mui/material/Box'; import EntityHeader from 'js/components/detailPage/entityHeader/EntityHeader'; import HeaderAppBar from '../HeaderAppBar'; import HeaderContent from '../HeaderContent'; @@ -9,12 +10,12 @@ function Header() { const { shouldDisplayHeader, ...props } = useEntityHeaderVisibility(); return ( - <> + ({ position: 'fixed', width: '100%', zIndex: theme.zIndex.header })}> {shouldDisplayHeader && } - + ); } diff --git a/context/app/static/js/components/detailPage/MetadataSection/MetadataSection.tsx b/context/app/static/js/components/detailPage/MetadataSection/MetadataSection.tsx index 09cd0fdb72..1ec2ce4e3b 100644 --- a/context/app/static/js/components/detailPage/MetadataSection/MetadataSection.tsx +++ b/context/app/static/js/components/detailPage/MetadataSection/MetadataSection.tsx @@ -6,66 +6,14 @@ import { CollapsibleDetailPageSection } from 'js/components/detailPage/DetailPag 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 { ESEntityType, isDataset } from 'js/components/types'; -import { useProcessedDatasets } from 'js/pages/Dataset/hooks'; -import { entityIconMap } from 'js/shared-styles/icons/entityIconMap'; +import { Dataset, Donor, Sample, isDataset } from 'js/components/types'; import withShouldDisplay from 'js/helpers/withShouldDisplay'; import { sectionIconMap } from 'js/shared-styles/icons/sectionIconMap'; +import { getTableEntities } from 'js/components/detailPage/MetadataSection/utils'; 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, - metadataFieldDescriptions: Record | Record, -) { - const [prefix, stem] = field.split('.'); - if (!stem) { - return metadataFieldDescriptions?.[field]; - } - const description = metadataFieldDescriptions?.[stem]; - if (!description) { - return undefined; - } - if (prefix === 'donor') { - return `For the original donor: ${metadataFieldDescriptions?.[stem]}`; - } - if (prefix === 'sample') { - return `For the original sample: ${metadataFieldDescriptions?.[stem]}`; - } - throw new Error(`Unrecognized metadata field prefix: ${prefix}`); -} - -export function buildTableData( - tableData: Record, - metadataFieldDescriptions: Record | Record, - extraValues: Record = {}, -) { - return ( - Object.entries(tableData) - // Filter out nested objects, like nested "metadata" for Samples... - // but allow arrays. Remember, in JS: typeof [] === 'object' - .filter((entry) => typeof entry[1] !== 'object' || Array.isArray(entry[1])) - // Filter out fields from TSV that aren't really metadata: - .filter((entry) => !['contributors_path', 'antibodies_path', 'version'].includes(entry[0])) - .map((entry) => ({ - ...extraValues, - key: entry[0], - // eslint-disable-next-line @typescript-eslint/no-base-to-string - value: Array.isArray(entry[1]) ? entry[1].join(', ') : entry[1].toString(), - description: getDescription(entry[0], metadataFieldDescriptions), - })) - ); -} - -function useTableData(tableData: Record) { - const { data: fieldDescriptions } = useMetadataFieldDescriptions(); - return buildTableData(tableData, fieldDescriptions); -} +import { Columns, defaultTSVColumns } from './columns'; interface TableRow { key: string; @@ -124,76 +72,25 @@ function MetadataWrapper({ allTableRows, tsvColumns = defaultTSVColumns, childre ); } -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; + entities: (Donor | Dataset | Sample)[]; } -function Metadata({ metadata }: MetadataProps) { - const { searchHits: datasetsWithMetadata, isLoading } = useProcessedDatasets(true); +function Metadata({ entities }: MetadataProps) { const { data: fieldDescriptions } = useMetadataFieldDescriptions(); + const { + entity: { uuid }, + } = useFlaskDataContext(); - const { entity } = useFlaskDataContext(); - - if (!isDataset(entity)) { - return ; - } - - if (isLoading || !datasetsWithMetadata) { - return null; - } - - const { donor, source_samples } = entity; - - const entities = [entity, ...datasetsWithMetadata.map((d) => d._source), ...source_samples, 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, - currentEntity: e, - }), - fieldDescriptions, - { hubmap_id: e.hubmap_id, label }, - ), - }; - }); - - const allTableRows = entities.map((d) => d.tableRows).flat(); + const tableEntities = getTableEntities({ entities, uuid, fieldDescriptions }); + const allTableRows = tableEntities.map((d) => d.tableRows).flat(); return ( - + ); } diff --git a/context/app/static/js/components/detailPage/MetadataSection/MetadataTable.spec.ts b/context/app/static/js/components/detailPage/MetadataSection/MetadataTable.spec.ts deleted file mode 100644 index cad1593421..0000000000 --- a/context/app/static/js/components/detailPage/MetadataSection/MetadataTable.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { getDescription, buildTableData } from './MetadataSection'; - -test('should handle plain fields', () => { - // Wouldn't actually expect to see age_value anywhere except for donor, - // but shouldn't make a difference... and if it does, we want to know. - expect(getDescription('age_value', { age_value: 'The time elapsed since birth.' })).toEqual( - 'The time elapsed since birth.', - ); -}); - -test('should handle donor fields', () => { - expect(getDescription('donor.age_value', { age_value: 'The time elapsed since birth.' })).toEqual( - 'For the original donor: The time elapsed since birth.', - ); -}); - -test('should handle sample fields', () => { - expect(getDescription('sample.age_value', { age_value: 'The time elapsed since birth.' })).toEqual( - 'For the original sample: The time elapsed since birth.', - ); -}); - -test('should return undefined if there is not a definition', () => { - expect(getDescription('sample.no_such_stem', {})).toEqual(undefined); -}); - -test('should error if stem is known, but prefix is not', () => { - expect(() => getDescription('no_such_prefix.age_value', { age_value: 'The time elapsed since birth.' })).toThrow(); -}); - -test('should look up field descriptions', () => { - expect( - buildTableData( - { - assay_type: 'FAKE-seq', - }, - { - assay_type: 'The specific type of assay being executed.', - }, - ), - ).toEqual([ - { - description: 'The specific type of assay being executed.', - key: 'assay_type', - value: 'FAKE-seq', - }, - ]); -}); - -test('should remove nested objects, but concat nested lists', () => { - expect( - buildTableData( - { - object: { foo: 'bar' }, - list: ['foo', 'bar'], - }, - {}, - ), - ).toEqual([ - { - description: undefined, - key: 'list', - value: 'foo, bar', - }, - ]); -}); - -test('should remove keys that are not metadata', () => { - expect( - buildTableData( - { - contributors_path: '/local/path', - antibodies_path: '/local/path', - version: '42', - }, - {}, - ), - ).toEqual([]); -}); diff --git a/context/app/static/js/components/detailPage/MetadataSection/utils.spec.ts b/context/app/static/js/components/detailPage/MetadataSection/utils.spec.ts new file mode 100644 index 0000000000..ffd24d4584 --- /dev/null +++ b/context/app/static/js/components/detailPage/MetadataSection/utils.spec.ts @@ -0,0 +1,145 @@ +import { DonorIcon } from 'js/shared-styles/icons'; +import { buildTableData, sortEntities, TableEntity } from './utils'; + +/** + * ============================================================================= + * buildTableData + * ============================================================================= + */ + +test('should look up field descriptions', () => { + expect( + buildTableData( + { + assay_type: 'FAKE-seq', + }, + { + assay_type: 'The specific type of assay being executed.', + }, + ), + ).toEqual([ + { + description: 'The specific type of assay being executed.', + key: 'assay_type', + value: 'FAKE-seq', + }, + ]); +}); + +test('should remove nested objects, but concat nested lists', () => { + expect( + buildTableData( + { + object: { foo: 'bar' }, + list: ['foo', 'bar'], + }, + {}, + ), + ).toEqual([ + { + description: undefined, + key: 'list', + value: 'foo, bar', + }, + ]); +}); + +test('should remove keys that are not metadata', () => { + expect( + buildTableData( + { + contributors_path: '/local/path', + antibodies_path: '/local/path', + version: '42', + }, + {}, + ), + ).toEqual([]); +}); + +/** + * ============================================================================= + * sortEntities + * ============================================================================= + */ + +const baseEntity = { + icon: DonorIcon, + tableRows: [], +}; + +const current: TableEntity = { + ...baseEntity, + uuid: 'current', + label: 'Current', + entity_type: 'Dataset', + hubmap_id: 'current', +}; + +const donor: TableEntity = { + ...baseEntity, + uuid: 'donor', + label: 'Donor', + entity_type: 'Donor', + hubmap_id: 'donor', +}; + +const uniqueSample1: TableEntity = { + ...baseEntity, + uuid: 'sample1', + label: 'Unique Category', + entity_type: 'Sample', + hubmap_id: 'sample1', +}; + +const duplicateSample2: TableEntity = { + ...baseEntity, + uuid: 'sample2', + label: 'Duplicate Category (sample2)', + entity_type: 'Sample', + hubmap_id: 'sample2', +}; + +const duplicateSample3: TableEntity = { + ...baseEntity, + uuid: 'sample3', + label: 'Duplicate Category (sample3)', + entity_type: 'Sample', + hubmap_id: 'sample3', +}; + +test('should sort by current -> donor -> samples without IDs in their label -> samples with IDs in their label', () => { + expect( + sortEntities({ + tableEntities: [duplicateSample2, duplicateSample3, donor, current, uniqueSample1], + uuid: 'current', + }), + ).toEqual([current, donor, uniqueSample1, duplicateSample2, duplicateSample3]); +}); + +test('should sort by current -> samples without IDs in their label -> samples with IDs in their label when donor is absent', () => { + expect( + sortEntities({ + tableEntities: [duplicateSample2, current, uniqueSample1, duplicateSample3], + uuid: 'current', + }), + ).toEqual([current, uniqueSample1, duplicateSample2, duplicateSample3]); +}); + +test('should sort by donor -> samples without IDs -> samples with IDs when current is absent', () => { + expect( + sortEntities({ + tableEntities: [duplicateSample3, duplicateSample2, donor, uniqueSample1], + uuid: 'current', + }), + ).toEqual([donor, uniqueSample1, duplicateSample2, duplicateSample3]); +}); + +test('should return only the current entity if no other entities are present', () => { + expect( + sortEntities({ + tableEntities: [current], + uuid: 'current', + }), + ).toEqual([current]); +}); diff --git a/context/app/static/js/components/detailPage/MetadataSection/utils.ts b/context/app/static/js/components/detailPage/MetadataSection/utils.ts new file mode 100644 index 0000000000..639bb791f8 --- /dev/null +++ b/context/app/static/js/components/detailPage/MetadataSection/utils.ts @@ -0,0 +1,121 @@ +import { TableRows } from 'js/components/detailPage/MetadataSection/MetadataSection'; +import { Dataset, Donor, ESEntityType, Sample, isDataset, isSample } from 'js/components/types'; +import { getEntityIcon } from 'js/helpers/functions'; +import { getMetadata } from 'js/helpers/metadata'; +import { ProcessedDatasetInfo } from 'js/pages/Dataset/hooks'; +import { MUIIcon } from 'js/shared-styles/icons/entityIconMap'; + +function buildTableData( + tableData: Record, + metadataFieldDescriptions: Record | Record, + extraValues: Record = {}, +) { + return ( + Object.entries(tableData) + // Filter out nested objects, like nested "metadata" for Samples... + // but allow arrays. Remember, in JS: typeof [] === 'object' + .filter((entry) => typeof entry[1] !== 'object' || Array.isArray(entry[1])) + // Filter out fields from TSV that aren't really metadata: + .filter((entry) => !['contributors_path', 'antibodies_path', 'version'].includes(entry[0])) + .map((entry) => ({ + ...extraValues, + key: entry[0], + // eslint-disable-next-line @typescript-eslint/no-base-to-string + value: Array.isArray(entry[1]) ? entry[1].join(', ') : entry[1].toString(), + description: metadataFieldDescriptions?.[entry[0]], + })) + ); +} + +export interface TableEntity { + uuid: string; + label: string; + icon: MUIIcon; + tableRows: TableRows; + entity_type: ESEntityType; + hubmap_id: string; +} + +interface sortEntitiesProps { + tableEntities: TableEntity[]; + uuid: string; +} + +function sortEntities({ tableEntities, uuid }: sortEntitiesProps) { + return [...tableEntities].sort((a, b) => { + // Current entity at the front + if (a.uuid === uuid) return -1; + if (b.uuid === uuid) return 1; + + // Then donors + if (a.entity_type === 'Donor' && b.entity_type !== 'Donor') return -1; + if (b.entity_type === 'Donor' && a.entity_type !== 'Donor') return 1; + + // Then samples, with unique categories first + const aIsSampleWithoutHubmapId = a.entity_type === 'Sample' && !a.label.includes(a.hubmap_id); + const bIsSampleWithoutHubmapId = b.entity_type === 'Sample' && !b.label.includes(b.hubmap_id); + if (aIsSampleWithoutHubmapId && !bIsSampleWithoutHubmapId) return -1; + if (bIsSampleWithoutHubmapId && !aIsSampleWithoutHubmapId) return 1; + + return a.label.localeCompare(b.label); + }); +} + +interface getEntityLabelProps { + entity: ProcessedDatasetInfo | Donor | Sample; + sampleCategoryCounts: Record; +} + +function getEntityLabel({ entity, sampleCategoryCounts }: getEntityLabelProps) { + if (isSample(entity)) { + // If samples have the same category, add the HuBMAP ID to the label + if (sampleCategoryCounts[entity.sample_category] > 1) { + return `${entity.sample_category} (${entity.hubmap_id})`; + } + return entity.sample_category; + } + if (isDataset(entity)) { + return entity.assay_display_name; + } + return entity.entity_type; +} + +interface getTableEntitiesProps { + entities: (Donor | Dataset | Sample)[]; + uuid: string; + fieldDescriptions: Record; +} + +function getTableEntities({ entities, uuid, fieldDescriptions }: getTableEntitiesProps) { + // Keep track of whether there are multiple samples with the same category + const sampleCategoryCounts: Record = {}; + entities.forEach((e) => { + if (isSample(e)) { + sampleCategoryCounts[e.sample_category] = (sampleCategoryCounts[e.sample_category] || 0) + 1; + } + }); + + const tableEntities = entities.map((entity) => { + // Generate a label with the HuBMAP ID if there are multiple samples with the same category + const label = getEntityLabel({ entity, sampleCategoryCounts }); + return { + uuid: entity.uuid, + label: label ?? '', + icon: getEntityIcon(entity), + tableRows: buildTableData( + getMetadata({ + targetEntityType: entity.entity_type, + currentEntity: entity, + }), + fieldDescriptions, + { hubmap_id: entity.hubmap_id, label }, + ), + entity_type: entity.entity_type, + hubmap_id: entity.hubmap_id, + }; + }); + + return sortEntities({ tableEntities, uuid }); +} + +export { getTableEntities, buildTableData, sortEntities }; diff --git a/context/app/static/js/components/detailPage/entityHeader/EntityHeader/style.ts b/context/app/static/js/components/detailPage/entityHeader/EntityHeader/style.ts index 8cd749ed3d..0c8d0fc0a4 100644 --- a/context/app/static/js/components/detailPage/entityHeader/EntityHeader/style.ts +++ b/context/app/static/js/components/detailPage/entityHeader/EntityHeader/style.ts @@ -7,7 +7,7 @@ const StyledPaper = styled(Paper)(({ theme }) => ({ position: 'sticky', width: '100%', top: headerHeight, - zIndex: theme.zIndex.entityHeader, + zIndex: theme.zIndex.header, })); export { StyledPaper }; diff --git a/context/app/static/js/components/detailPage/entityHeader/EntityHeaderContent/EntityHeaderContent.tsx b/context/app/static/js/components/detailPage/entityHeader/EntityHeaderContent/EntityHeaderContent.tsx index 9d38915dc7..26911cd1b2 100644 --- a/context/app/static/js/components/detailPage/entityHeader/EntityHeaderContent/EntityHeaderContent.tsx +++ b/context/app/static/js/components/detailPage/entityHeader/EntityHeaderContent/EntityHeaderContent.tsx @@ -217,6 +217,7 @@ function EntityHeaderContent({ view, setView }: { view: SummaryViewsType; setVie ({ ...(view !== 'narrow' && { borderBottom: `1px solid ${theme.palette.primary.lowEmphasis}` }) })} diff --git a/context/app/static/js/components/detailPage/multi-assay/MultiAssayMetadataTabs/MultiAssayMetadataTabs.tsx b/context/app/static/js/components/detailPage/multi-assay/MultiAssayMetadataTabs/MultiAssayMetadataTabs.tsx index e0af51e4a7..8148676cb8 100644 --- a/context/app/static/js/components/detailPage/multi-assay/MultiAssayMetadataTabs/MultiAssayMetadataTabs.tsx +++ b/context/app/static/js/components/detailPage/multi-assay/MultiAssayMetadataTabs/MultiAssayMetadataTabs.tsx @@ -38,7 +38,7 @@ function MetadataTabs({ entities }: { entities: MultiAssayEntityWithTableRows[] return ( - + 4 ? 'scrollable' : 'fullWidth'}> {entities.map(({ label, uuid, icon }, index) => ( ))} 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 1b8a73cb60..bd8ff0c846 100644 --- a/context/app/static/js/components/detailPage/visualization/Visualization/Visualization.tsx +++ b/context/app/static/js/components/detailPage/visualization/Visualization/Visualization.tsx @@ -43,8 +43,6 @@ const visualizationStoreSelector = (state: VisualizationStore) => ({ interface VisualizationProps { vitData: object | object[]; uuid?: string; - hubmap_id?: string; - mapped_data_access_level?: string; hasNotebook: boolean; shouldDisplayHeader: boolean; shouldMountVitessce?: boolean; @@ -54,8 +52,6 @@ interface VisualizationProps { function Visualization({ vitData, uuid, - hubmap_id, - mapped_data_access_level, hasNotebook, shouldDisplayHeader, shouldMountVitessce = true, @@ -122,12 +118,7 @@ function Visualization({ leftText={shouldDisplayHeader ? Visualization : undefined} buttons={ - + {hasNotebook && } diff --git a/context/app/static/js/components/detailPage/visualization/VisualizationWorkspaceButton/VisualizationWorkspaceButton.tsx b/context/app/static/js/components/detailPage/visualization/VisualizationWorkspaceButton/VisualizationWorkspaceButton.tsx index dde0ef9f18..303f41de6f 100644 --- a/context/app/static/js/components/detailPage/visualization/VisualizationWorkspaceButton/VisualizationWorkspaceButton.tsx +++ b/context/app/static/js/components/detailPage/visualization/VisualizationWorkspaceButton/VisualizationWorkspaceButton.tsx @@ -7,30 +7,29 @@ import NewWorkspaceDialog from 'js/components/workspaces/NewWorkspaceDialog'; import { useCreateWorkspaceForm } from 'js/components/workspaces/NewWorkspaceDialog/useCreateWorkspaceForm'; import { WhiteBackgroundIconTooltipButton } from 'js/shared-styles/buttons'; import { useAppContext } from 'js/components/Contexts'; +import { useDetailContext } from 'js/components/detailPage/DetailContext'; +import { useProcessedDatasetDetails } from 'js/components/detailPage/ProcessedData/ProcessedDataset/hooks'; const tooltip = 'Launch New Workspace'; interface VisualizationWorkspaceButtonProps { uuid?: string; - hubmap_id?: string; - hasNotebook?: boolean; - mapped_data_access_level?: string; } -function VisualizationWorkspaceButton({ - uuid = '', - hubmap_id, - hasNotebook, - mapped_data_access_level, -}: VisualizationWorkspaceButtonProps) { +function VisualizationWorkspaceButton({ uuid = '' }: VisualizationWorkspaceButtonProps) { + const { mapped_data_access_level } = useDetailContext(); + const { + datasetDetails: { hubmap_id }, + } = useProcessedDatasetDetails(uuid); const { isWorkspacesUser } = useAppContext(); + const { setDialogIsOpen, removeDatasets, ...rest } = useCreateWorkspaceForm({ defaultName: hubmap_id, defaultTemplate: 'visualization', initialSelectedDatasets: [uuid], }); - if (!isWorkspacesUser ?? !uuid ?? !hubmap_id ?? !hasNotebook ?? mapped_data_access_level === 'Protected') { + if (!isWorkspacesUser || !hubmap_id || mapped_data_access_level !== 'Public') { return null; } 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 94da48470b..bd58c89b3f 100644 --- a/context/app/static/js/components/detailPage/visualization/VisualizationWrapper/VisualizationWrapper.tsx +++ b/context/app/static/js/components/detailPage/visualization/VisualizationWrapper/VisualizationWrapper.tsx @@ -10,8 +10,6 @@ const Visualization = React.lazy(() => import('../Visualization')); interface VisualizationWrapperProps { vitData: object | object[]; uuid?: string; - hubmap_id?: string; - mapped_data_access_level?: string; hasNotebook?: boolean; shouldDisplayHeader?: boolean; hasBeenMounted?: boolean; @@ -22,8 +20,6 @@ interface VisualizationWrapperProps { function VisualizationWrapper({ vitData, uuid, - hubmap_id, - mapped_data_access_level, hasNotebook = false, shouldDisplayHeader = true, hasBeenMounted, @@ -45,8 +41,6 @@ function VisualizationWrapper({ (uuids: string[], source?: string[]): [T[], boolean] { const query = useEntityQuery(uuids, source); - const { searchHits, isLoading } = useSearchHits(query); + const { searchHits, isLoading } = useSearchHits(query); return [searchHits.map((hit) => hit._source), isLoading]; } diff --git a/context/app/static/js/pages/Dataset/Dataset.tsx b/context/app/static/js/pages/Dataset/Dataset.tsx index 37a83738a2..4f7c3be0fd 100644 --- a/context/app/static/js/pages/Dataset/Dataset.tsx +++ b/context/app/static/js/pages/Dataset/Dataset.tsx @@ -17,7 +17,7 @@ import { getCombinedDatasetStatus } from 'js/components/detailPage/utils'; 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 } from 'js/components/types'; +import { Dataset, Donor, Entity, Sample, isDataset } from 'js/components/types'; import DatasetRelationships from 'js/components/detailPage/DatasetRelationships'; import ProcessedDataSection from 'js/components/detailPage/ProcessedData'; import { SelectedVersionStoreProvider } from 'js/components/detailPage/VersionSelect/SelectedVersionStore'; @@ -28,7 +28,8 @@ import { useDatasetsCollections } from 'js/hooks/useDatasetsCollections'; import useTrackID from 'js/hooks/useTrackID'; import { InternalLink } from 'js/shared-styles/Links'; import OrganIcon from 'js/shared-styles/icons/OrganIcon'; - +import { useEntitiesData } from 'js/hooks/useEntityData'; +import { hasMetadata } from 'js/helpers/metadata'; import { useProcessedDatasets, useProcessedDatasetsSections, useRedirectAlert } from './hooks'; interface SummaryDataChildrenProps { @@ -94,8 +95,14 @@ function DatasetDetail({ assayMetadata }: EntityDetailProps) { is_component, assay_modality, processing, + ancestor_ids, } = assayMetadata; + const [entities, loadingEntities] = useEntitiesData([uuid, ...ancestor_ids]); + const entitiesWithMetadata = entities.filter((e) => + hasMetadata({ targetEntityType: e.entity_type, currentEntity: e }), + ); + useRedirectAlert(); const origin_sample = origin_samples[0]; @@ -122,6 +129,10 @@ function DatasetDetail({ assayMetadata }: EntityDetailProps) { const { shouldDisplay: shouldDisplayRelationships } = useDatasetRelationships(uuid, processing); + if (loadingEntities) { + return null; + } + return ( ds._id) ?? []}> @@ -146,7 +157,7 @@ function DatasetDetail({ assayMetadata }: EntityDetailProps) { > - + diff --git a/context/app/static/js/pages/Donor/Donor.jsx b/context/app/static/js/pages/Donor/Donor.jsx index 8de24e9e90..0334ee6d80 100644 --- a/context/app/static/js/pages/Donor/Donor.jsx +++ b/context/app/static/js/pages/Donor/Donor.jsx @@ -12,21 +12,20 @@ import useTrackID from 'js/hooks/useTrackID'; import MetadataSection from 'js/components/detailPage/MetadataSection'; function DonorDetail() { + const { entity } = useFlaskDataContext(); const { - entity: { - uuid, - protocol_url, - hubmap_id, - entity_type, - mapped_metadata = {}, - created_timestamp, - last_modified_timestamp, - description, - group_name, - created_by_user_displayname, - created_by_user_email, - }, - } = useFlaskDataContext(); + uuid, + protocol_url, + hubmap_id, + entity_type, + mapped_metadata = {}, + created_timestamp, + last_modified_timestamp, + description, + group_name, + created_by_user_displayname, + created_by_user_email, + } = entity; const shouldDisplaySection = { summary: true, @@ -53,7 +52,7 @@ function DonorDetail() { description={description} group_name={group_name} /> - {shouldDisplaySection.metadata && } + {shouldDisplaySection.metadata && } {shouldDisplaySection.protocols && } diff --git a/context/app/static/js/pages/Sample/Sample.jsx b/context/app/static/js/pages/Sample/Sample.jsx index 914ba98f94..17926c1dc5 100644 --- a/context/app/static/js/pages/Sample/Sample.jsx +++ b/context/app/static/js/pages/Sample/Sample.jsx @@ -11,41 +11,43 @@ import SummaryItem from 'js/components/detailPage/summary/SummaryItem'; import DetailLayout from 'js/components/detailPage/DetailLayout'; import SampleTissue from 'js/components/detailPage/SampleTissue'; import { DetailContext } from 'js/components/detailPage/DetailContext'; +import { hasMetadata } from 'js/helpers/metadata'; import DerivedDatasetsSection from 'js/components/detailPage/derivedEntities/DerivedDatasetsSection'; -import { combineMetadata } from 'js/pages/utils/entity-utils'; import useTrackID from 'js/hooks/useTrackID'; import MetadataSection from 'js/components/detailPage/MetadataSection'; +import { useEntitiesData } from 'js/hooks/useEntityData'; function SampleDetail() { + const { entity } = useFlaskDataContext(); const { - entity: { - uuid, - donor, - protocol_url, - sample_category, - origin_samples, - hubmap_id, - entity_type, - metadata, - descendant_counts, - }, - } = useFlaskDataContext(); + uuid, + protocol_url, + sample_category, + origin_samples, + hubmap_id, + entity_type, + descendant_counts, + ancestor_ids, + } = entity; + + const [entities, loadingEntities] = useEntitiesData([uuid, ...ancestor_ids]); + const entitiesWithMetadata = entities.filter((e) => + hasMetadata({ targetEntityType: e.entity_type, currentEntity: e }), + ); // TODO: Update design to reflect samples and datasets which have multiple origin samples with different organs. const origin_sample = origin_samples[0]; const { mapped_organ } = origin_sample; - const combinedMetadata = combineMetadata(donor, undefined, metadata); - const shouldDisplaySection = { summary: true, 'derived-data': Boolean(descendant_counts?.entity_type?.Dataset > 0), tissue: true, provenance: true, protocols: Boolean(protocol_url), - metadata: Boolean(Object.keys(combinedMetadata).length), + metadata: Boolean(entitiesWithMetadata.length), attribution: true, }; @@ -53,6 +55,10 @@ function SampleDetail() { const detailContext = useMemo(() => ({ hubmap_id, uuid }), [hubmap_id, uuid]); + if (loadingEntities) { + return null; + } + return ( @@ -70,7 +76,7 @@ function SampleDetail() { {shouldDisplaySection.protocols && } - + diff --git a/context/app/static/js/pages/utils/entity-utils.spec.ts b/context/app/static/js/pages/utils/entity-utils.spec.ts deleted file mode 100644 index b51eb42418..0000000000 --- a/context/app/static/js/pages/utils/entity-utils.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Donor, Sample } from 'js/components/types'; -import { combineMetadata } from './entity-utils'; - -test('robust against undefined data', () => { - const donor = undefined; - const source_samples = undefined; - const metadata = undefined; - // @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 source_samples: Sample[] = []; - const metadata = {}; - // @ts-expect-error - This is a test case for bad data. - expect(combineMetadata(donor, source_samples, metadata)).toEqual({}); -}); - -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. - const donor = { - created_by_user_displayname: 'John Doe', - mapped_metadata: { - age_unit: ['years'], - age_value: [40], - }, - metadata: { - // This is the source of the mapped_metadata. - living_donor_data: [], - }, - } as unknown as Donor; - const source_samples = [ - { - // mapped_metadata seems to be empty. - mapped_metadata: {}, - metadata: { - cold_ischemia_time_unit: 'minutes', - cold_ischemia_time_value: '100', - }, - }, - ] as unknown as Sample[]; - const metadata = { - dag_provenance_list: [], - extra_metadata: {}, - metadata: { - analyte_class: 'polysaccharides', - assay_category: 'imaging', - assay_type: 'PAS microscopy', - }, - }; - expect(combineMetadata(donor, source_samples, metadata)).toEqual({ - analyte_class: 'polysaccharides', - assay_category: 'imaging', - assay_type: 'PAS microscopy', - 'donor.age_unit': ['years'], - 'donor.age_value': [40], - 'sample.cold_ischemia_time_unit': 'minutes', - 'sample.cold_ischemia_time_value': '100', - }); -}); diff --git a/context/app/static/js/pages/utils/entity-utils.ts b/context/app/static/js/pages/utils/entity-utils.ts deleted file mode 100644 index 1161cfa2af..0000000000 --- a/context/app/static/js/pages/utils/entity-utils.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Donor, Sample } from 'js/components/types'; - -function addPrefix(prefix: string, object: object) { - return Object.fromEntries(Object.entries(object).map(([key, value]) => [prefix + key, value])); -} - -function prefixDonorMetadata(donor: Donor | null | undefined) { - const donorMetadata = donor?.mapped_metadata ?? {}; - return addPrefix('donor.', donorMetadata); -} - -function prefixSampleMetadata(source_samples: Sample[] | null | undefined) { - const sampleMetadatas = (source_samples ?? []).filter((sample) => sample?.metadata).map((sample) => sample.metadata); - return sampleMetadatas.map((sampleMetadata) => addPrefix('sample.', sampleMetadata)); -} - -function combineMetadata( - donor: Donor, - source_samples: Sample[] = [], - metadata: Record | null | undefined = {}, -) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const combinedMetadata: Record = { - ...(metadata?.metadata ?? {}), - ...prefixDonorMetadata(donor), - }; - prefixSampleMetadata(source_samples).forEach((sampleMetadata) => { - Object.assign(combinedMetadata, sampleMetadata); - }); - - return combinedMetadata; -} -export { combineMetadata }; diff --git a/context/app/static/js/theme/theme.tsx b/context/app/static/js/theme/theme.tsx index f79df3dfc4..eee6ac1c56 100644 --- a/context/app/static/js/theme/theme.tsx +++ b/context/app/static/js/theme/theme.tsx @@ -42,7 +42,7 @@ declare module '@mui/material/styles' { export interface ZIndex { tutorial: number; dropdownOffset: number; - entityHeader: number; + header: number; dropdown: number; visualization: number; fileBrowserHeader: number; @@ -264,7 +264,7 @@ const theme = createTheme({ zIndex: { tutorial: 1101, // one higher than AppBar zIndex provided by MUI dropdownOffset: 1001, - entityHeader: 1000, + header: 1000, dropdown: 50, visualization: 3, fileBrowserHeader: 1,