From 1ec62d2d47a081cf00c0994d1043177537e72daa Mon Sep 17 00:00:00 2001 From: Austen Money Date: Tue, 15 Oct 2024 09:28:19 -0400 Subject: [PATCH] Austenem/CAT-934 Update metadata sections (#3568) * update combineMetadata param * add changelog * append hubmap IDs to duplicate sample categories * use metadata from provenance table * fix tab widths * update labels * separate entity sorting * add tests, move to utils file * clean up * update changelog * remove extra utils functions * lift helper function --- CHANGELOG-fix-metadata-table.md | 3 + .../MetadataSection/MetadataSection.tsx | 125 ++------------- .../MetadataSection/MetadataTable.spec.ts | 79 ---------- .../detailPage/MetadataSection/utils.spec.ts | 145 ++++++++++++++++++ .../detailPage/MetadataSection/utils.ts | 121 +++++++++++++++ .../MultiAssayMetadataTabs.tsx | 2 +- context/app/static/js/helpers/functions.ts | 16 ++ context/app/static/js/hooks/useEntityData.ts | 4 +- .../app/static/js/pages/Dataset/Dataset.tsx | 17 +- context/app/static/js/pages/Donor/Donor.jsx | 29 ++-- context/app/static/js/pages/Sample/Sample.jsx | 40 +++-- .../js/pages/utils/entity-utils.spec.ts | 63 -------- .../app/static/js/pages/utils/entity-utils.ts | 33 ---- 13 files changed, 350 insertions(+), 327 deletions(-) create mode 100644 CHANGELOG-fix-metadata-table.md delete mode 100644 context/app/static/js/components/detailPage/MetadataSection/MetadataTable.spec.ts create mode 100644 context/app/static/js/components/detailPage/MetadataSection/utils.spec.ts create mode 100644 context/app/static/js/components/detailPage/MetadataSection/utils.ts delete mode 100644 context/app/static/js/pages/utils/entity-utils.spec.ts delete mode 100644 context/app/static/js/pages/utils/entity-utils.ts 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/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/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/helpers/functions.ts b/context/app/static/js/helpers/functions.ts index 79fdf59bc9..8a5e5a2d36 100644 --- a/context/app/static/js/helpers/functions.ts +++ b/context/app/static/js/helpers/functions.ts @@ -1,6 +1,9 @@ import { SearchRequest } from '@elastic/elasticsearch/lib/api/types'; +import { nodeIcons } from 'js/components/detailPage/DatasetRelationships/nodeTypes'; +import { ESEntityType, isDataset } from 'js/components/types'; import { MAX_NUMBER_OF_WORKSPACE_DATASETS } from 'js/components/workspaces/api'; import { MergedWorkspace } from 'js/components/workspaces/types'; +import { entityIconMap } from 'js/shared-styles/icons/entityIconMap'; export function isEmptyArrayOrObject(val: object | unknown[]) { if (val.constructor.name === 'Object') { @@ -222,3 +225,16 @@ export function isValidEmail(email: string) { const cleanedValue: string = email?.replace(/^\s+|\s+$/g, ''); return emailRegex.test(cleanedValue); } + +export 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]; +} diff --git a/context/app/static/js/hooks/useEntityData.ts b/context/app/static/js/hooks/useEntityData.ts index a08b686409..d80436a254 100644 --- a/context/app/static/js/hooks/useEntityData.ts +++ b/context/app/static/js/hooks/useEntityData.ts @@ -20,10 +20,10 @@ export function useEntityData(uuid: string, source?: string[]): [Entity, boolean return [searchHits[0]?._source, isLoading]; } -export function useEntitiesData(uuids: string[], source?: string[]): [Entity[], boolean] { +export function useEntitiesData(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 };