diff --git a/context/app/markdown/CHANGELOG.md b/context/app/markdown/CHANGELOG.md index 4f31b3b57f..9fb42e0a71 100644 --- a/context/app/markdown/CHANGELOG.md +++ b/context/app/markdown/CHANGELOG.md @@ -1,3 +1,36 @@ +# Changelog + +## v1.12.2 - 2024-10-17 + +- Add access levels to processed dataset helper panels. +- Use relevant access levels when determining whether or not to show workspace buttons on dataset detail pages. +- Add title to changelog page. +- Ensure visualizations for image pyramids use parent UUID to fetch notebooks. +- Restore missing columns to derived entities section. +- Remove Provenance section from donor page in favor of using derived data tables. +- Fix Webkit-specific bugs that caused the entity header to become misaligned. +- Resolve issues of header cutting off the top of detail pages and header shifting off-screen when scrolling. +- 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. +- Fix issue of Workspaces button being available for protected datasets in the Visualization section. + + + +## v1.12.1 - 2024-10-10 + +- Prevent collections without a DOI from being counted on the homepage. +- Avoid page scrolling from using arrow keys on vitessce visualization. +- Prevent malformed provenance graph data from crashing the entire page. +- Improve readability of pipeline list in processed dataset sections. +- Append HuBMAP ID to errored datasets' URL anchors to avoid anchor duplication on dataset pages with multiple failed processing attempts. +- Improve entity header state handling of page resizes. +- Fix push script to correctly check latest minor version date. +- Sort publications by their date of publication. +- Update Unified View redirect toast to be more informative. +- Update group for HIVE-processed datasets to be 'HIVE'. + + ## v1.12.0 - 2024-10-07 - Fix bug on dataset detail page for workspace dialogs opened in succession for different processed datasets. diff --git a/context/app/routes_browse.py b/context/app/routes_browse.py index e07006397e..eab35b8d87 100644 --- a/context/app/routes_browse.py +++ b/context/app/routes_browse.py @@ -78,7 +78,9 @@ def details(type, uuid): type='dataset', uuid=supported_entity[0]['uuid'], _anchor=anchor, - redirected=True)) + redirected=True, + redirectedFromId=entity.get('hubmap_id'), + redirectedFromPipeline=entity.get('pipeline'))) if type != actual_type: return redirect(url_for('routes_browse.details', type=actual_type, uuid=uuid)) @@ -89,6 +91,8 @@ def details(type, uuid): **get_default_flask_data(), 'entity': entity, 'redirected': redirected, + 'redirectedFromId': request.args.get('redirectedFromId'), + 'redirectedFromPipeline': request.args.get('redirectedFromPipeline') } if type == 'publication': diff --git a/context/app/static/js/components/Contexts.tsx b/context/app/static/js/components/Contexts.tsx index 2c32016779..4bc4e484d7 100644 --- a/context/app/static/js/components/Contexts.tsx +++ b/context/app/static/js/components/Contexts.tsx @@ -10,6 +10,8 @@ export interface FlaskDataContextType { title: string; // preview page title vis_lifted_uuid?: string; redirected?: boolean; + redirectedFromId?: string | null; + redirectedFromPipeline?: string | null; } export const FlaskDataContext = createContext('FlaskDataContext'); diff --git a/context/app/static/js/components/detailPage/DetailPageSection/DetailPageSection.tsx b/context/app/static/js/components/detailPage/DetailPageSection/DetailPageSection.tsx index 7d88fdb17d..ca6d25f7ac 100644 --- a/context/app/static/js/components/detailPage/DetailPageSection/DetailPageSection.tsx +++ b/context/app/static/js/components/detailPage/DetailPageSection/DetailPageSection.tsx @@ -15,12 +15,19 @@ function DetailPageSection({ children, ...rest }: PropsWithChildren { - sectionRef.current?.scrollIntoView({ + // Manually scroll to section and account for header offset + const sectionTop = sectionRef.current?.getBoundingClientRect().top ?? 0; + const scrollPosition = window.scrollY + sectionTop - offset; + + window.scrollTo({ + top: Math.max(scrollPosition, 0), behavior: 'smooth', }); }, 1000); } } + // We do not want to re-scroll down to the section if the header view changes (aka offset changes) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialHash, rest.id]); return ( 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/MetadataTable/MetadataTable.tsx b/context/app/static/js/components/detailPage/MetadataTable/MetadataTable.tsx index 2291ff3b56..5bb3992ccb 100644 --- a/context/app/static/js/components/detailPage/MetadataTable/MetadataTable.tsx +++ b/context/app/static/js/components/detailPage/MetadataTable/MetadataTable.tsx @@ -5,7 +5,6 @@ import TableBody from '@mui/material/TableBody'; import TableCell from '@mui/material/TableCell'; import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; -import Stack from '@mui/material/Stack'; import { StyledTableContainer, HeaderCell } from 'js/shared-styles/tables'; import IconTooltipCell from 'js/shared-styles/tables/IconTooltipCell'; @@ -35,10 +34,11 @@ function MetadataTable({ tableRows = [] as MetadataTableRow[], columns = default {row.key} - - {row.value} - {row.key.endsWith('age_value') && } - + {row.key.endsWith('age_value') ? ( + {row.value} + ) : ( + row.value + )} ))} diff --git a/context/app/static/js/components/detailPage/ProcessedData/HelperPanel/HelperPanel.tsx b/context/app/static/js/components/detailPage/ProcessedData/HelperPanel/HelperPanel.tsx index 7f832f1383..6c1d3a3e9a 100644 --- a/context/app/static/js/components/detailPage/ProcessedData/HelperPanel/HelperPanel.tsx +++ b/context/app/static/js/components/detailPage/ProcessedData/HelperPanel/HelperPanel.tsx @@ -15,17 +15,12 @@ import { SecondaryBackgroundTooltip } from 'js/shared-styles/tooltips'; import { formatDate } from 'date-fns/format'; import { HelperPanelPortal } from '../../DetailLayout/DetailLayout'; -import useProcessedDataStore from '../store'; import StatusIcon from '../../StatusIcon'; -import { getDateLabelAndValue } from '../../utils'; +import { getDateLabelAndValue, useCurrentDataset } from '../../utils'; import { HelperPanelButton } from './styles'; import { useTrackEntityPageEvent } from '../../useTrackEntityPageEvent'; import ProcessedDataWorkspaceMenu from '../ProcessedDataWorkspaceMenu'; -function useCurrentDataset() { - return useProcessedDataStore((state) => state.currentDataset); -} - function HelperPanelHeader() { const currentDataset = useCurrentDataset(); if (!currentDataset) { @@ -47,7 +42,7 @@ function HelperPanelStatus() { return ( - {currentDataset.status} + {`${currentDataset.status} (${currentDataset.mapped_data_access_level})`} ); } @@ -99,7 +94,7 @@ function HelperPanelActions() { return null; } - const { hubmap_id, uuid, status } = currentDataset; + const { hubmap_id, uuid, status, mapped_data_access_level } = currentDataset; return ( <> @@ -109,7 +104,7 @@ function HelperPanelActions() { }>Workspace } - datasetDetails={{ hubmap_id, uuid, status }} + datasetDetails={{ hubmap_id, uuid, status, mapped_data_access_level }} dialogType="ADD_DATASETS_FROM_HELPER_PANEL" /> diff --git a/context/app/static/js/components/detailPage/ProcessedData/ProcessedData.tsx b/context/app/static/js/components/detailPage/ProcessedData/ProcessedData.tsx index 7f66fd1ef9..c4851cc524 100644 --- a/context/app/static/js/components/detailPage/ProcessedData/ProcessedData.tsx +++ b/context/app/static/js/components/detailPage/ProcessedData/ProcessedData.tsx @@ -6,15 +6,16 @@ import { sectionIconMap } from 'js/shared-styles/icons/sectionIconMap'; import ProcessedDataset from './ProcessedDataset'; import { SectionDescription } from './ProcessedDataset/SectionDescription'; import HelperPanel from './HelperPanel'; -import { useSortedSearchHits } from './hooks'; +import { usePipelineCountsInfo, useSortedSearchHits } from './hooks'; import CollapsibleDetailPageSection from '../DetailPageSection/CollapsibleDetailPageSection'; function ProcessedDataSection() { const processedDatasets = useProcessedDatasets(); const sortedSearchHits = useSortedSearchHits(processedDatasets.searchHits); - const pipelines = processedDatasets?.searchHits.map((dataset) => dataset._source.pipeline); - const pipelinesText = `Pipelines (${pipelines.length})`; + const { pipelinesText, pipelineCountsText } = usePipelineCountsInfo( + processedDatasets.searchHits.map((dataset) => dataset._source), + ); return ( {pipelines.join(', ')}} + addendum={{pipelineCountsText}} > This section lists analyses generated based on this dataset. These analyses are represented as processed datasets and are either generated by HuBMAP using uniform processing pipelines or by an external processing diff --git a/context/app/static/js/components/detailPage/ProcessedData/ProcessedDataWorkspaceMenu.tsx b/context/app/static/js/components/detailPage/ProcessedData/ProcessedDataWorkspaceMenu.tsx index d58b73bfd5..e8a7a8b6f5 100644 --- a/context/app/static/js/components/detailPage/ProcessedData/ProcessedDataWorkspaceMenu.tsx +++ b/context/app/static/js/components/detailPage/ProcessedData/ProcessedDataWorkspaceMenu.tsx @@ -8,7 +8,7 @@ import AddRounded from '@mui/icons-material/AddRounded'; import { WorkspacesIcon } from 'js/shared-styles/icons'; import { useOpenDialog } from 'js/components/workspaces/WorkspacesDropdownMenu/WorkspacesDropdownMenu'; import { useCreateWorkspaceForm } from 'js/components/workspaces/NewWorkspaceDialog/useCreateWorkspaceForm'; -import { useAppContext, useFlaskDataContext } from 'js/components/Contexts'; +import { useAppContext } from 'js/components/Contexts'; import { useTrackEntityPageEvent } from 'js/components/detailPage/useTrackEntityPageEvent'; import NewWorkspaceDialog from 'js/components/workspaces/NewWorkspaceDialog/NewWorkspaceDialog'; import { DialogType } from 'js/stores/useWorkspaceModalStore'; @@ -16,19 +16,15 @@ import AddDatasetsFromDetailDialog from 'js/components/workspaces/AddDatasetsFro interface ProcessedDataWorkspaceMenuProps { button: React.ReactNode; - datasetDetails: { hubmap_id: string; uuid: string; status: string }; + datasetDetails: { hubmap_id: string; uuid: string; status: string; mapped_data_access_level: string }; dialogType: DialogType; } function ProcessedDataWorkspaceMenu({ button, - datasetDetails: { hubmap_id, uuid, status }, + datasetDetails: { hubmap_id, uuid, status, mapped_data_access_level }, dialogType, }: ProcessedDataWorkspaceMenuProps) { - const { - entity: { mapped_data_access_level }, - } = useFlaskDataContext(); - const { isWorkspacesUser } = useAppContext(); const [anchorEl, setAnchorEl] = React.useState(null); const open = Boolean(anchorEl); @@ -86,9 +82,7 @@ function ProcessedDataWorkspaceMenu({ 'aria-expanded': open ? 'true' : undefined, }); - const showWorkspaceButton = mapped_data_access_level && hubmap_id && isWorkspacesUser && status === 'Published'; - - if (!showWorkspaceButton) { + if (!isWorkspacesUser || !hubmap_id || mapped_data_access_level !== 'Public' || status !== 'Published') { return null; } diff --git a/context/app/static/js/components/detailPage/ProcessedData/ProcessedDataset/ProcessedDataset.tsx b/context/app/static/js/components/detailPage/ProcessedData/ProcessedDataset/ProcessedDataset.tsx index 757fdce06d..e324ed13b4 100644 --- a/context/app/static/js/components/detailPage/ProcessedData/ProcessedDataset/ProcessedDataset.tsx +++ b/context/app/static/js/components/detailPage/ProcessedData/ProcessedDataset/ProcessedDataset.tsx @@ -26,6 +26,7 @@ import { getDateLabelAndValue } from 'js/components/detailPage/utils'; import { useSelectedVersionStore } from 'js/components/detailPage/VersionSelect/SelectedVersionStore'; import { useVersions } from 'js/components/detailPage/VersionSelect/hooks'; import { useTrackEntityPageEvent } from 'js/components/detailPage/useTrackEntityPageEvent'; +import InfoTextTooltip from 'js/shared-styles/tooltips/InfoTextTooltip'; import { DatasetTitle } from './DatasetTitle'; import { ProcessedDatasetAccordion } from './ProcessedDatasetAccordion'; @@ -70,13 +71,23 @@ function Contact() { function SummaryAccordion() { const { dataset } = useProcessedDatasetContext(); + const { group_name, mapped_consortium, creation_action } = dataset; const [dateLabel, dateValue] = getDateLabelAndValue(dataset); + + const isHiveProcessed = creation_action === 'Central Process'; + return ( }> - {dataset.group_name} - {dataset.mapped_consortium} + + {isHiveProcessed ? ( + HIVE + ) : ( + group_name + )} + + {mapped_consortium} {dateValue ? formatDate(new Date(dateValue), 'yyyy-MM-dd') : 'N/A'} diff --git a/context/app/static/js/components/detailPage/ProcessedData/ProcessedDataset/hooks.ts b/context/app/static/js/components/detailPage/ProcessedData/ProcessedDataset/hooks.ts index 611742108e..b080723003 100644 --- a/context/app/static/js/components/detailPage/ProcessedData/ProcessedDataset/hooks.ts +++ b/context/app/static/js/components/detailPage/ProcessedData/ProcessedDataset/hooks.ts @@ -22,6 +22,7 @@ export type ProcessedDatasetDetails = ProcessedDatasetInfo & | 'protocol_url' // TODO: This is present for non-dataset entities, but not for datasets. | 'dataset_type' | 'mapped_consortium' + | 'mapped_data_access_level' >; export function useProcessedDatasetDetails(uuid: string) { @@ -54,6 +55,7 @@ export function useProcessedDatasetDetails(uuid: string) { 'mapped_consortium', 'contributors', 'contacts', + 'mapped_data_access_level', ], size: 10000, }; diff --git a/context/app/static/js/components/detailPage/ProcessedData/hooks.spec.ts b/context/app/static/js/components/detailPage/ProcessedData/hooks.spec.ts index 73044052ed..d934986388 100644 --- a/context/app/static/js/components/detailPage/ProcessedData/hooks.spec.ts +++ b/context/app/static/js/components/detailPage/ProcessedData/hooks.spec.ts @@ -1,6 +1,6 @@ import { CreationAction } from 'js/components/types'; import { renderHook } from 'test-utils/functions'; -import { useSortedSearchHits, createdByCentralProcess, datasetIsPublished } from './hooks'; +import { useSortedSearchHits, createdByCentralProcess, datasetIsPublished, usePipelineCountsInfo } from './hooks'; const testDatasets: { _id: string; @@ -93,3 +93,9 @@ it('checks if dataset is published', () => { expect(datasetIsPublished(testDatasets[1]._source)).toBe(true); expect(datasetIsPublished(testDatasets[2]._source)).toBe(true); }); + +it('counts pipelines and their occurrences', () => { + const { pipelinesText, pipelineCountsText } = usePipelineCountsInfo(testDatasets.map((dataset) => dataset._source)); + expect(pipelinesText).toBe('Pipelines (2)'); + expect(pipelineCountsText).toBe('Cytokit + SPRM (2) and Segmentation Mask'); +}); diff --git a/context/app/static/js/components/detailPage/ProcessedData/hooks.ts b/context/app/static/js/components/detailPage/ProcessedData/hooks.ts index d22bc6d739..17a20ac091 100644 --- a/context/app/static/js/components/detailPage/ProcessedData/hooks.ts +++ b/context/app/static/js/components/detailPage/ProcessedData/hooks.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react'; import { ProcessedDatasetInfo, useProcessedDatasets } from 'js/pages/Dataset/hooks'; +import { generateCommaList } from 'js/helpers/functions'; export function createdByCentralProcess(dataset: Pick) { return dataset.creation_action === 'Central Process'; @@ -37,3 +38,28 @@ export function useSortedSearchHits(datasets: ReturnType[]) { + const pipelines = datasets.map((dataset) => dataset.pipeline); + const pipelineCounts = pipelines.reduce( + (acc, pipeline) => { + acc[pipeline] = (acc[pipeline] || 0) + 1; + return acc; + }, + {} as Record, + ); + const pipelinesText = `Pipelines (${Object.keys(pipelineCounts).length})`; + const pipelineCountsText = generateCommaList( + Object.entries(pipelineCounts).map(([pipeline, count]) => (count > 1 ? `${pipeline} (${count})` : pipeline)), + ); + + return { + pipelinesText, + pipelineCountsText, + }; +} diff --git a/context/app/static/js/components/detailPage/Protocol/Protocol.tsx b/context/app/static/js/components/detailPage/Protocol/Protocol.tsx index a368265372..1c16285692 100644 --- a/context/app/static/js/components/detailPage/Protocol/Protocol.tsx +++ b/context/app/static/js/components/detailPage/Protocol/Protocol.tsx @@ -7,6 +7,7 @@ import ContactUsLink from 'js/shared-styles/Links/ContactUsLink'; import Divider from '@mui/material/Divider'; import { CollapsibleDetailPageSection } from 'js/components/detailPage/DetailPageSection'; import { sectionIconMap } from 'js/shared-styles/icons/sectionIconMap'; +import withShouldDisplay from 'js/helpers/withShouldDisplay'; import { StyledPaper } from './style'; import SectionItem from '../SectionItem'; import { useTrackEntityPageEvent } from '../useTrackEntityPageEvent'; @@ -96,4 +97,4 @@ function Protocol({ protocol_url, showHeader }: ProtocolProps) { return contents; } -export default React.memo(Protocol); +export default React.memo(withShouldDisplay(Protocol)); diff --git a/context/app/static/js/components/detailPage/derivedEntities/columns.ts b/context/app/static/js/components/detailPage/derivedEntities/columns.ts index 14af94cf1a..c00b2c4964 100644 --- a/context/app/static/js/components/detailPage/derivedEntities/columns.ts +++ b/context/app/static/js/components/detailPage/derivedEntities/columns.ts @@ -22,7 +22,8 @@ const lastModifiedTimestampCol: Column = { const organCol: Column = { id: 'origin_samples_unique_mapped_organs', label: 'Organ', - renderColumnCell: (entity) => (isDataset(entity) ? entity.origin_samples_unique_mapped_organs.join(', ') : ''), + renderColumnCell: (entity) => + isDataset(entity) || isSample(entity) ? entity.origin_samples_unique_mapped_organs.join(', ') : '', }; const dataTypesCol: Column = { @@ -34,7 +35,7 @@ const dataTypesCol: Column = { const statusCol: Column = { id: 'mapped_status', label: 'Status', - renderColumnCell: ({ mapped_status }) => mapped_status ?? '', + renderColumnCell: ({ mapped_status, status }) => mapped_status ?? status ?? '', }; const derivedSamplesColumns: Column[] = [ diff --git a/context/app/static/js/components/detailPage/entityHeader/EntityHeader/EntityHeader.tsx b/context/app/static/js/components/detailPage/entityHeader/EntityHeader/EntityHeader.tsx index 28089230fd..1bbeabd0d7 100644 --- a/context/app/static/js/components/detailPage/entityHeader/EntityHeader/EntityHeader.tsx +++ b/context/app/static/js/components/detailPage/entityHeader/EntityHeader/EntityHeader.tsx @@ -56,6 +56,21 @@ function Header() { } }, [vizIsFullscreen, handleViewChange]); + // Switch to narrow view if screen size changes from large desktop to smaller + // Restore previous view when screen size changes back to large desktop + const previousView = useRef(view); + const wasLargeDesktop = useRef(isLargeDesktop); + useEffect(() => { + if (!isLargeDesktop && wasLargeDesktop.current) { + previousView.current = view; + handleViewChange('narrow'); + // Else if is required to prevent infinite loop/maintain functionality + } else if (isLargeDesktop && !wasLargeDesktop.current) { + handleViewChange(previousView.current); + } + wasLargeDesktop.current = isLargeDesktop; + }, [isLargeDesktop, handleViewChange, view]); + const [springValues] = springs; if (springValues[0] === undefined) { 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/EntityHeaderActionButtons/EntityHeaderActionButtons.tsx b/context/app/static/js/components/detailPage/entityHeader/EntityHeaderActionButtons/EntityHeaderActionButtons.tsx index 9bd3b7963f..fca715088f 100644 --- a/context/app/static/js/components/detailPage/entityHeader/EntityHeaderActionButtons/EntityHeaderActionButtons.tsx +++ b/context/app/static/js/components/detailPage/entityHeader/EntityHeaderActionButtons/EntityHeaderActionButtons.tsx @@ -210,7 +210,7 @@ function EntityHeaderActionButtons({ disabled={disabled} /> } - datasetDetails={{ hubmap_id, uuid, status }} + datasetDetails={{ hubmap_id, uuid, status, mapped_data_access_level }} dialogType="ADD_DATASETS_FROM_HEADER" /> 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 23bb92a5ca..2fe28527e6 100644 --- a/context/app/static/js/components/detailPage/entityHeader/EntityHeaderContent/EntityHeaderContent.tsx +++ b/context/app/static/js/components/detailPage/entityHeader/EntityHeaderContent/EntityHeaderContent.tsx @@ -73,10 +73,9 @@ function DonorItems({ data: { entity } }: EntityHeaderItemsProps) { {race && {race}} {age_unit && age_value && ( - + {age_value} {age_unit} - - + )} @@ -218,6 +217,7 @@ function EntityHeaderContent({ view, setView }: { view: SummaryViewsType; setVie ({ ...(view !== 'narrow' && { borderBottom: `1px solid ${theme.palette.primary.lowEmphasis}` }) })} @@ -237,7 +237,7 @@ function EntityHeaderContent({ view, setView }: { view: SummaryViewsType; setVie {vizIsFullscreen ? ( <> - {vizNotebookId && } + {vizNotebookId && } 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/provenance/ProvGraph/ProvGraphErrorBoundary.tsx b/context/app/static/js/components/detailPage/provenance/ProvGraph/ProvGraphErrorBoundary.tsx new file mode 100644 index 0000000000..3739826c0a --- /dev/null +++ b/context/app/static/js/components/detailPage/provenance/ProvGraph/ProvGraphErrorBoundary.tsx @@ -0,0 +1,24 @@ +import { FaroErrorBoundary } from '@grafana/faro-react'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import DetailsAccordion from 'js/shared-styles/accordions/DetailsAccordion'; +import React from 'react'; + +interface ErrorBoundaryProps { + children: React.ReactNode; +} + +function ErrorFallback(error: Error) { + return ( + + An error occurred while attempting to display the provenance graph. + + {error?.message} + + + ); +} + +export default function ProvGraphErrorBoundary({ children }: ErrorBoundaryProps) { + return {children}; +} diff --git a/context/app/static/js/components/detailPage/provenance/ProvSection/ProvSection.tsx b/context/app/static/js/components/detailPage/provenance/ProvSection/ProvSection.tsx index b012b49124..b4c3589ab2 100644 --- a/context/app/static/js/components/detailPage/provenance/ProvSection/ProvSection.tsx +++ b/context/app/static/js/components/detailPage/provenance/ProvSection/ProvSection.tsx @@ -83,6 +83,7 @@ function ProvSection() { + ); diff --git a/context/app/static/js/components/detailPage/provenance/ProvTabs/ProvTabs.tsx b/context/app/static/js/components/detailPage/provenance/ProvTabs/ProvTabs.tsx index 7e630f6189..6fa6550b41 100644 --- a/context/app/static/js/components/detailPage/provenance/ProvTabs/ProvTabs.tsx +++ b/context/app/static/js/components/detailPage/provenance/ProvTabs/ProvTabs.tsx @@ -9,6 +9,7 @@ import ProvTable from '../ProvTable'; import { hasDataTypes } from './utils'; import { filterTabsToDisplay } from './filterTabsToDisplay'; import { ProvData } from '../types'; +import ProvGraphErrorBoundary from '../ProvGraph/ProvGraphErrorBoundary'; const availableTabDetails = { multi: { label: 'Multi-Assay', 'data-testid': 'multi-prov-tab' }, @@ -67,7 +68,9 @@ function ProvTabs({ provData }: ProvTabsProps) { )} {filteredTabs?.graph && ( - + + + )} diff --git a/context/app/static/js/components/detailPage/utils.ts b/context/app/static/js/components/detailPage/utils.ts index 1e2e853836..62d15034fd 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 useProcessedDataStore from 'js/components/detailPage/ProcessedData/store'; import { ProcessedDatasetDetails } from './ProcessedData/ProcessedDataset/hooks'; export function getSectionOrder( @@ -48,3 +49,7 @@ export function getOriginSampleAndMappedOrgan(entity: Sample | Dataset) { const origin_sample = entity.origin_samples[0]; return { origin_sample, mapped_organ: origin_sample.mapped_organ }; } + +export function useCurrentDataset() { + return useProcessedDataStore((state) => state.currentDataset); +} 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..bbc2cdbc55 100644 --- a/context/app/static/js/components/detailPage/visualization/Visualization/Visualization.tsx +++ b/context/app/static/js/components/detailPage/visualization/Visualization/Visualization.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { Vitessce } from 'vitessce'; import Paper from '@mui/material/Paper'; @@ -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, @@ -102,6 +98,19 @@ function Visualization({ const isMultiDataset = Array.isArray(vitessceConfig); + // Find parent UUID for the visualization if present + const parentUuid: string | undefined = useMemo(() => { + if (Array.isArray(vitData)) { + const vitDataArray = vitData as object[]; + const found = vitDataArray.find((data) => 'parentUuid' in data) as { parentUuid: string } | undefined; + return found?.parentUuid; + } + if ('parentUuid' in vitData) { + return (vitData as { parentUuid: string }).parentUuid; + } + return undefined; + }, [vitData]); + if (!vitessceConfig) { return null; } @@ -122,13 +131,8 @@ function Visualization({ leftText={shouldDisplayHeader ? Visualization : undefined} buttons={ - - + {hasNotebook && } + diff --git a/context/app/static/js/components/detailPage/visualization/VisualizationDownloadButton/VisualizationDownloadButton.tsx b/context/app/static/js/components/detailPage/visualization/VisualizationDownloadButton/VisualizationDownloadButton.tsx index 71ac9c67ea..09cbbe037d 100644 --- a/context/app/static/js/components/detailPage/visualization/VisualizationDownloadButton/VisualizationDownloadButton.tsx +++ b/context/app/static/js/components/detailPage/visualization/VisualizationDownloadButton/VisualizationDownloadButton.tsx @@ -13,23 +13,24 @@ const tooltip = 'Download Jupyter Notebook'; interface VisualizationDownloadButtonProps { uuid?: string; hasNotebook?: boolean; + parentUuid?: string; } -function VisualizationDownloadButton({ uuid, hasNotebook }: VisualizationDownloadButtonProps) { +function VisualizationDownloadButton({ uuid, hasNotebook, parentUuid }: VisualizationDownloadButtonProps) { const trackEntityPageEvent = useTrackEntityPageEvent(); const { toastError } = useSnackbarActions(); const downloadNotebook = useCallback(() => { trackEntityPageEvent({ action: `Vitessce / ${tooltip}` }); postAndDownloadFile({ - url: `/notebooks/entities/dataset/${uuid}.ws.ipynb`, + url: `/notebooks/entities/dataset/${parentUuid ?? uuid}.ws.ipynb`, body: {}, }) .then() .catch(() => { toastError('Failed to download Jupyter Notebook'); }); - }, [uuid, toastError, trackEntityPageEvent]); + }, [parentUuid, uuid, toastError, trackEntityPageEvent]); if (!uuid || !hasNotebook) { return null; diff --git a/context/app/static/js/components/detailPage/visualization/VisualizationTracker/hooks.ts b/context/app/static/js/components/detailPage/visualization/VisualizationTracker/hooks.ts index 3685a128a2..cdf7547dab 100644 --- a/context/app/static/js/components/detailPage/visualization/VisualizationTracker/hooks.ts +++ b/context/app/static/js/components/detailPage/visualization/VisualizationTracker/hooks.ts @@ -32,6 +32,8 @@ export function useVitessceEventMetadata() { return formatEventCategoryAndLabel('Unknown', location); } +const arrowKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']; + export function useVisualizationTracker() { const { category, label } = useVitessceEventMetadata(); // Track when the visualization is first mounted @@ -66,6 +68,11 @@ export function useVisualizationTracker() { const trackKeyDown: React.KeyboardEventHandler = useEventCallback((e) => { const target = getNearestIdentifier(e.target as HTMLElement); const key = e.key === ' ' ? 'Space' : e.key; + // Prevent default scrolling behavior of arrow keys + if (arrowKeys.includes(key)) { + trackVitessceAction(`${key} ${target}`); + e.preventDefault(); + } if (!target || modifierKeys.includes(key)) return; if (typingTimeout.current) { clearTimeout(typingTimeout.current); 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..7985004d01 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,21 @@ 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 { useCurrentDataset } from 'js/components/detailPage/utils'; 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() { + const currentDataset = useCurrentDataset(); const { isWorkspacesUser } = useAppContext(); + const { setDialogIsOpen, removeDatasets, ...rest } = useCreateWorkspaceForm({ - defaultName: hubmap_id, + defaultName: currentDataset?.hubmap_id, defaultTemplate: 'visualization', - initialSelectedDatasets: [uuid], + initialSelectedDatasets: currentDataset ? [currentDataset.uuid] : [], }); - if (!isWorkspacesUser ?? !uuid ?? !hubmap_id ?? !hasNotebook ?? mapped_data_access_level === 'Protected') { + if (!isWorkspacesUser || !currentDataset?.hubmap_id || currentDataset?.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..56a44b2a92 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, @@ -38,6 +34,7 @@ function VisualizationWrapper({ }), [isPublicationPage, shouldDisplayHeader, uuid], ); + return ( @@ -45,8 +42,6 @@ function VisualizationWrapper({ {entityData.mapped_metadata?.sex} - + {entityData.mapped_metadata?.age_value} {entityData.mapped_metadata?.age_unit} - - + {(entityData.mapped_metadata?.race ?? []).join(', ')} diff --git a/context/app/static/js/components/home/EntityCounts/hooks.ts b/context/app/static/js/components/home/EntityCounts/hooks.ts index c1d0310a74..32ffd2ec33 100644 --- a/context/app/static/js/components/home/EntityCounts/hooks.ts +++ b/context/app/static/js/components/home/EntityCounts/hooks.ts @@ -3,7 +3,20 @@ import useSearchData from 'js/hooks/useSearchData'; const entityCountsQuery: SearchRequest = { size: 0, - aggs: { entity_type: { terms: { field: 'entity_type.keyword' } } }, + query: { + bool: { + // Only include collections with a DOI in count + should: [ + { bool: { must_not: { term: { 'entity_type.keyword': 'Collection' } } } }, + { bool: { must: [{ exists: { field: 'doi_url' } }, { exists: { field: 'registered_doi' } }] } }, + ], + }, + }, + aggs: { + entity_type: { + terms: { field: 'entity_type.keyword' }, + }, + }, }; interface EntityCounts { diff --git a/context/app/static/js/components/publications/PublicationsPanelList/hooks.js b/context/app/static/js/components/publications/PublicationsPanelList/hooks.js deleted file mode 100644 index 5279ee4cac..0000000000 --- a/context/app/static/js/components/publications/PublicationsPanelList/hooks.js +++ /dev/null @@ -1,43 +0,0 @@ -import useSearchData from 'js/hooks/useSearchData'; -import { fetcher } from 'js/helpers/swr'; -import { buildPublicationPanelProps } from './utils'; - -const getPublicationsByStatusQuery = (publicationStatus) => ({ - query: { - bool: { - must: [ - { - term: { - 'entity_type.keyword': 'Publication', - }, - }, - { - term: { - publication_status: publicationStatus, - }, - }, - ], - }, - }, - size: 10000, - _source: ['uuid', 'title', 'contributors', 'publication_status', 'publication_venue', 'publication_date'], -}); - -async function fetchPublicationsPanelData(args) { - const results = await fetcher(args); - return results?.hits?.hits.map((publicationHit) => buildPublicationPanelProps(publicationHit)); -} - -function usePublicationsPanelList(publicationStatus) { - const query = getPublicationsByStatusQuery(publicationStatus); - - const { searchData: publicationPanelsProps, isLoading } = useSearchData(query, { - useDefaultQuery: true, - fetcher: fetchPublicationsPanelData, - fallbackData: [], - }); - - return { publicationPanelsProps, isLoading }; -} - -export { usePublicationsPanelList }; diff --git a/context/app/static/js/components/publications/PublicationsPanelList/hooks.ts b/context/app/static/js/components/publications/PublicationsPanelList/hooks.ts new file mode 100644 index 0000000000..177aec6e10 --- /dev/null +++ b/context/app/static/js/components/publications/PublicationsPanelList/hooks.ts @@ -0,0 +1,55 @@ +import useSearchData from 'js/hooks/useSearchData'; +import { fetcher } from 'js/helpers/swr'; +import { SearchRequest, SearchResponse } from '@elastic/elasticsearch/lib/api/types'; +import { buildPublicationPanelProps, PublicationHit } from './utils'; + +const getPublicationsByStatusQuery: (publicationStatus: string) => SearchRequest = (publicationStatus) => ({ + query: { + bool: { + must: [ + { + term: { + 'entity_type.keyword': 'Publication', + }, + }, + { + term: { + publication_status: publicationStatus, + }, + }, + ], + }, + }, + sort: [ + { + 'publication_date.keyword': { + order: 'desc', + }, + }, + ], + size: 10000, + _source: ['uuid', 'title', 'contributors', 'publication_status', 'publication_venue', 'publication_date'], +}); + +async function fetchPublicationsPanelData(args: Parameters[0]) { + const results = await fetcher>(args); + const unwrappedResults = results?.hits?.hits.map((hit) => hit._source!); + if (!unwrappedResults) { + return []; + } + return unwrappedResults.map((hit) => buildPublicationPanelProps({ _source: hit })); +} + +function usePublicationsPanelList(publicationStatus: string) { + const query = getPublicationsByStatusQuery(publicationStatus); + + const { searchData: publicationPanelsProps, isLoading } = useSearchData(query, { + useDefaultQuery: true, + fetcher: fetchPublicationsPanelData, + fallbackData: [], + }); + + return { publicationPanelsProps, isLoading }; +} + +export { usePublicationsPanelList }; diff --git a/context/app/static/js/components/publications/PublicationsPanelList/index.js b/context/app/static/js/components/publications/PublicationsPanelList/index.ts similarity index 100% rename from context/app/static/js/components/publications/PublicationsPanelList/index.js rename to context/app/static/js/components/publications/PublicationsPanelList/index.ts diff --git a/context/app/static/js/components/publications/PublicationsPanelList/utils.ts b/context/app/static/js/components/publications/PublicationsPanelList/utils.ts index 2076bf558c..06187dcb83 100644 --- a/context/app/static/js/components/publications/PublicationsPanelList/utils.ts +++ b/context/app/static/js/components/publications/PublicationsPanelList/utils.ts @@ -23,7 +23,7 @@ function buildSecondaryText(contributors: Contributor[], publication_venue: stri .join(' | '); } -interface PublicationHit { +export interface PublicationHit { _source: { uuid: string; title: string; diff --git a/context/app/static/js/components/searchPage/ResultsTable/utils.jsx b/context/app/static/js/components/searchPage/ResultsTable/utils.jsx index b7ba8105db..d89b6ca3b6 100644 --- a/context/app/static/js/components/searchPage/ResultsTable/utils.jsx +++ b/context/app/static/js/components/searchPage/ResultsTable/utils.jsx @@ -1,5 +1,4 @@ import React from 'react'; -import Stack from '@mui/material/Stack'; import { get } from 'js/helpers/nodash'; import DonorAgeTooltip from 'js/shared-styles/tooltips/DonorAgeTooltip'; @@ -53,10 +52,7 @@ function getByPath(hitSource, field) { if (Array.isArray(fieldValue)) { if (field?.id === 'mapped_metadata.age_value') { return ( - - {fieldValue.join(' / ')} - {fieldValue.length > 0 && } - + fieldValue.length > 0 && {fieldValue.join(' / ')} ); } return fieldValue.join(' / '); diff --git a/context/app/static/js/components/types.ts b/context/app/static/js/components/types.ts index 36020d5e5a..ad7a767b0c 100644 --- a/context/app/static/js/components/types.ts +++ b/context/app/static/js/components/types.ts @@ -73,6 +73,8 @@ export interface Donor extends Entity { race: string[]; body_mass_index_value: string; }>; + group_name: string; + protocol_url: string; } export interface Sample extends Entity { 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/useDerivedEntitySearchHits.js b/context/app/static/js/hooks/useDerivedEntitySearchHits.ts similarity index 77% rename from context/app/static/js/hooks/useDerivedEntitySearchHits.js rename to context/app/static/js/hooks/useDerivedEntitySearchHits.ts index 4f5705220b..09babbc9dc 100644 --- a/context/app/static/js/hooks/useDerivedEntitySearchHits.js +++ b/context/app/static/js/hooks/useDerivedEntitySearchHits.ts @@ -1,8 +1,9 @@ import { useMemo } from 'react'; import { useSearchHits } from 'js/hooks/useSearchData'; +import { Dataset, Sample } from 'js/components/types'; -function getTypeQuery(ancestorUUID, type) { +function getTypeQuery(ancestorUUID: string, type: string) { return { bool: { filter: [ @@ -21,7 +22,7 @@ function getTypeQuery(ancestorUUID, type) { }; } -function useDerivedDatasetSearchHits(ancestorUUID) { +function useDerivedDatasetSearchHits(ancestorUUID: string) { const query = useMemo( () => ({ query: getTypeQuery(ancestorUUID, 'dataset'), @@ -39,10 +40,10 @@ function useDerivedDatasetSearchHits(ancestorUUID) { [ancestorUUID], ); - return useSearchHits(query); + return useSearchHits(query); } -function useDerivedSampleSearchHits(ancestorUUID) { +function useDerivedSampleSearchHits(ancestorUUID: string) { const query = useMemo( () => ({ query: getTypeQuery(ancestorUUID, 'sample'), @@ -59,7 +60,7 @@ function useDerivedSampleSearchHits(ancestorUUID) { }), [ancestorUUID], ); - return useSearchHits(query); + return useSearchHits(query); } export { useDerivedDatasetSearchHits, useDerivedSampleSearchHits }; 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/Dataset/hooks.ts b/context/app/static/js/pages/Dataset/hooks.ts index 9fc0ed1a7f..0b2f937158 100644 --- a/context/app/static/js/pages/Dataset/hooks.ts +++ b/context/app/static/js/pages/Dataset/hooks.ts @@ -73,9 +73,13 @@ export function useVitessceConf(uuid: string, parentUuid?: string) { if (parentUuid) { urlParams.set('parent', parentUuid); } - return useSWR(getVitessceConfKey(uuid, groupsToken), (_key: unknown) => + const swr = useSWR(getVitessceConfKey(uuid, groupsToken), (_key: unknown) => fetcher({ url: `${base}?${urlParams.toString()}`, requestInit: { headers: getAuthHeader(groupsToken) } }), ); + if (parentUuid) { + return { ...swr, data: { ...swr.data, parentUuid } }; + } + return swr; } function useProcessedDatasets(includeComponents?: boolean) { @@ -177,13 +181,20 @@ function useProcessedDatasetsSections(): { sections: TableOfContentsItem | false } export function useRedirectAlert() { - const { redirected } = useFlaskDataContext(); + const { redirected, redirectedFromId, redirectedFromPipeline } = useFlaskDataContext(); const { toastInfo } = useSnackbarActions(); + useEffect(() => { if (redirected) { - toastInfo('You have been redirected to the unified view for this dataset.'); + if (redirectedFromId && redirectedFromPipeline) { + toastInfo( + `You have been redirected to the unified view for ${redirectedFromPipeline} dataset ${redirectedFromId}.`, + ); + } else { + toastInfo('You have been redirected to the unified view for this dataset.'); + } } - }, [redirected, toastInfo]); + }, [redirected, toastInfo, redirectedFromId, redirectedFromPipeline]); } export { useProcessedDatasets, useProcessedDatasetsSections }; diff --git a/context/app/static/js/pages/Dataset/utils.spec.ts b/context/app/static/js/pages/Dataset/utils.spec.ts new file mode 100644 index 0000000000..b38d180dde --- /dev/null +++ b/context/app/static/js/pages/Dataset/utils.spec.ts @@ -0,0 +1,42 @@ +import { datasetSectionId } from './utils'; + +const prefix = 'prefix'; + +describe('datasetSectionId', () => { + it('should format the dataset section id correctly', () => { + const dataset = { + pipeline: 'pipeline', + hubmap_id: 'hubmap_id', + status: 'status', + }; + expect(datasetSectionId(dataset, prefix)).toBe('prefix-pipeline-status'); + }); + it('Should include the hubmap id if the pipeline is missing', () => { + const dataset = { + hubmap_id: 'hubmap_id', + status: 'status', + }; + expect(datasetSectionId(dataset, prefix)).toBe('prefix-hubmap_id-status'); + }); + it('should include the pipeline and hubmap_id if the pipeline is errored', () => { + const dataset = { + pipeline: 'pipeline', + hubmap_id: 'hubmap_id', + status: 'error', + }; + expect(datasetSectionId(dataset, prefix)).toBe('prefix-pipeline-error-hubmap_id'); + }); + it('should generate unique ids for different errored datasets', () => { + const dataset1 = { + pipeline: 'pipeline', + hubmap_id: 'hubmap_id', + status: 'error', + }; + const dataset2 = { + pipeline: 'pipeline', + hubmap_id: 'hubmap_id_2', + status: 'error', + }; + expect(datasetSectionId(dataset1, prefix)).not.toBe(datasetSectionId(dataset2, prefix)); + }); +}); diff --git a/context/app/static/js/pages/Dataset/utils.ts b/context/app/static/js/pages/Dataset/utils.ts index 1e08c8388c..712623e583 100644 --- a/context/app/static/js/pages/Dataset/utils.ts +++ b/context/app/static/js/pages/Dataset/utils.ts @@ -1,11 +1,16 @@ import { ProcessedDatasetInfo } from './hooks'; +const potentialDuplicateStates = ['error']; + export function datasetSectionId( - dataset: Pick, + dataset: Pick & { pipeline?: string }, prefix = '', ) { 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(); + const deduplicatedFormattedStatus = potentialDuplicateStates.includes(formattedStatus.toLowerCase()) + ? `${formattedStatus}-${hubmap_id}` + : formattedStatus; + return `${prefix}-${encodeURIComponent(formattedDatasetIdentifier)}-${encodeURIComponent(deduplicatedFormattedStatus)}`.toLowerCase(); } diff --git a/context/app/static/js/pages/Donor/Donor.jsx b/context/app/static/js/pages/Donor/Donor.jsx deleted file mode 100644 index 8de24e9e90..0000000000 --- a/context/app/static/js/pages/Donor/Donor.jsx +++ /dev/null @@ -1,70 +0,0 @@ -import React, { useMemo } from 'react'; - -import { useFlaskDataContext } from 'js/components/Contexts'; -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 DetailLayout from 'js/components/detailPage/DetailLayout'; -import { DetailContext } from 'js/components/detailPage/DetailContext'; -import DerivedEntitiesSection from 'js/components/detailPage/derivedEntities/DerivedEntitiesSection'; -import useTrackID from 'js/hooks/useTrackID'; -import MetadataSection from 'js/components/detailPage/MetadataSection'; - -function DonorDetail() { - 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(); - - const shouldDisplaySection = { - summary: true, - metadata: Boolean(Object.keys(mapped_metadata).length), - 'derived-data': true, - provenance: true, - protocols: Boolean(protocol_url), - attribution: true, - }; - - useTrackID({ entity_type, hubmap_id }); - - const detailContext = useMemo(() => ({ hubmap_id, uuid }), [hubmap_id, uuid]); - - return ( - - - - {shouldDisplaySection.metadata && } - - - {shouldDisplaySection.protocols && } - - - - ); -} - -export default DonorDetail; diff --git a/context/app/static/js/pages/Donor/Donor.tsx b/context/app/static/js/pages/Donor/Donor.tsx new file mode 100644 index 0000000000..abd830a10b --- /dev/null +++ b/context/app/static/js/pages/Donor/Donor.tsx @@ -0,0 +1,51 @@ +import React, { useMemo } from 'react'; + +import { useFlaskDataContext } from 'js/components/Contexts'; +import Summary from 'js/components/detailPage/summary/Summary'; +import Attribution from 'js/components/detailPage/Attribution'; +import Protocol from 'js/components/detailPage/Protocol'; +import DetailLayout from 'js/components/detailPage/DetailLayout'; +import { DetailContext } from 'js/components/detailPage/DetailContext'; +import DerivedEntitiesSection from 'js/components/detailPage/derivedEntities/DerivedEntitiesSection'; +import useTrackID from 'js/hooks/useTrackID'; +import MetadataSection from 'js/components/detailPage/MetadataSection'; +import { isDonor } from 'js/components/types'; + +function DonorDetail() { + const { entity } = useFlaskDataContext(); + + if (!isDonor(entity)) { + throw new Error('Entity is not a donor'); + } + + const { uuid, protocol_url, hubmap_id, entity_type, mapped_metadata = {}, mapped_data_access_level } = entity; + + const shouldDisplaySection = { + summary: true, + metadata: Boolean(Object.keys(mapped_metadata).length), + 'derived-data': true, + protocols: Boolean(protocol_url), + attribution: true, + }; + + useTrackID({ entity_type, hubmap_id }); + + const detailContext = useMemo( + () => ({ hubmap_id, uuid, mapped_data_access_level }), + [hubmap_id, uuid, mapped_data_access_level], + ); + + return ( + + + + + + + + + + ); +} + +export default DonorDetail; diff --git a/context/app/static/js/pages/Donor/index.js b/context/app/static/js/pages/Donor/index.ts similarity index 100% rename from context/app/static/js/pages/Donor/index.js rename to context/app/static/js/pages/Donor/index.ts 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/shared-styles/tooltips/DonorAgeTooltip/DonorAgeTooltip.tsx b/context/app/static/js/shared-styles/tooltips/DonorAgeTooltip/DonorAgeTooltip.tsx index 5b386abcb6..8f2b86d231 100644 --- a/context/app/static/js/shared-styles/tooltips/DonorAgeTooltip/DonorAgeTooltip.tsx +++ b/context/app/static/js/shared-styles/tooltips/DonorAgeTooltip/DonorAgeTooltip.tsx @@ -1,25 +1,17 @@ -import React from 'react'; - -import { SecondaryBackgroundTooltip } from 'js/shared-styles/tooltips'; -import { StyledStack, StyledInfoIcon } from 'js/shared-styles/tooltips/DonorAgeTooltip/style'; +import React, { PropsWithChildren } from 'react'; +import InfoTextTooltip from 'js/shared-styles/tooltips/InfoTextTooltip'; const DONOR_AGE_TEXT = 'For donors older than 89, the metadata will indicate an age of 90.'; -interface DonorAgeTooltipProps { +interface DonorAgeTooltipProps extends PropsWithChildren { donorAge?: string; } -function DonorAgeTooltip({ donorAge }: DonorAgeTooltipProps) { +function DonorAgeTooltip({ donorAge, children }: DonorAgeTooltipProps) { if (!donorAge || Number(donorAge) <= 89) { - return null; + return children; } - return ( - - - - - - ); + return {children}; } export default DonorAgeTooltip; diff --git a/context/app/static/js/shared-styles/tooltips/InfoTextTooltip/InfoTextTooltip.tsx b/context/app/static/js/shared-styles/tooltips/InfoTextTooltip/InfoTextTooltip.tsx new file mode 100644 index 0000000000..ba161daf28 --- /dev/null +++ b/context/app/static/js/shared-styles/tooltips/InfoTextTooltip/InfoTextTooltip.tsx @@ -0,0 +1,22 @@ +import React, { PropsWithChildren } from 'react'; + +import { SecondaryBackgroundTooltip } from 'js/shared-styles/tooltips'; +import { StyledInfoIcon, StyledOuterStack, StyledInnerStack } from 'js/shared-styles/tooltips/InfoTextTooltip/style'; + +interface InfoTextTooltipProps extends PropsWithChildren { + tooltipTitle: string; +} + +function InfoTextTooltip({ tooltipTitle, children }: InfoTextTooltipProps) { + return ( + + {children} + + + + + + + ); +} +export default InfoTextTooltip; diff --git a/context/app/static/js/shared-styles/tooltips/InfoTextTooltip/index.ts b/context/app/static/js/shared-styles/tooltips/InfoTextTooltip/index.ts new file mode 100644 index 0000000000..19616205d0 --- /dev/null +++ b/context/app/static/js/shared-styles/tooltips/InfoTextTooltip/index.ts @@ -0,0 +1,3 @@ +import InfoTextTooltip from './InfoTextTooltip'; + +export default InfoTextTooltip; diff --git a/context/app/static/js/shared-styles/tooltips/DonorAgeTooltip/style.ts b/context/app/static/js/shared-styles/tooltips/InfoTextTooltip/style.ts similarity index 60% rename from context/app/static/js/shared-styles/tooltips/DonorAgeTooltip/style.ts rename to context/app/static/js/shared-styles/tooltips/InfoTextTooltip/style.ts index ce7bf6c6bc..fe84b716ee 100644 --- a/context/app/static/js/shared-styles/tooltips/DonorAgeTooltip/style.ts +++ b/context/app/static/js/shared-styles/tooltips/InfoTextTooltip/style.ts @@ -2,7 +2,12 @@ import { Stack } from '@mui/material'; import { styled } from '@mui/material/styles'; import { InfoIcon } from 'js/shared-styles/icons'; -const StyledStack = styled(Stack)(({ theme }) => ({ +const StyledOuterStack = styled(Stack)({ + alignItems: 'center', + flexDirection: 'row', +}); + +const StyledInnerStack = styled(Stack)(({ theme }) => ({ marginLeft: theme.spacing(0.5), justifyContent: 'center', })); @@ -12,4 +17,4 @@ const StyledInfoIcon = styled(InfoIcon)(({ theme }) => ({ fontSize: '0.75rem', })); -export { StyledStack, StyledInfoIcon }; +export { StyledOuterStack, StyledInnerStack, StyledInfoIcon }; 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, diff --git a/context/package-lock.json b/context/package-lock.json index b7ade8a0ab..713e65c2c7 100644 --- a/context/package-lock.json +++ b/context/package-lock.json @@ -1,12 +1,12 @@ { "name": "portal-ui", - "version": "1.12.0", + "version": "1.12.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "portal-ui", - "version": "1.12.0", + "version": "1.12.2", "license": "MIT", "dependencies": { "@dagrejs/dagre": "^1.1.3", diff --git a/context/package.json b/context/package.json index 4c7f9c966b..78947f33c5 100644 --- a/context/package.json +++ b/context/package.json @@ -1,6 +1,6 @@ { "name": "portal-ui", - "version": "1.12.0", + "version": "1.12.2", "dependencies": { "@dagrejs/dagre": "^1.1.3", "@datapunt/matomo-tracker-js": "^0.5.1", diff --git a/etc/build/push.sh b/etc/build/push.sh index eda2f6d25b..27e56bc8fd 100755 --- a/etc/build/push.sh +++ b/etc/build/push.sh @@ -16,7 +16,7 @@ if [[ -z "$MAJOR" ]]; then TAGS=`git for-each-ref --sort=creatordate --format '%(refname) %(creatordate)' refs/tags | tac` while read -r -d $'\n' TAG DATE; do - if [[ $TAG =~ v0\.([0-9]+)\.0$ ]]; then + if [[ $TAG =~ ([0-9]+)\.([0-9]+)\.0$ ]]; then echo "Last minor tag: $TAG" # Strip timezone info (last 6 characters) DATE=${DATE%??????} @@ -44,6 +44,7 @@ else VERSION=`cd context && npm version major` fi + echo "Version: $VERSION" ./grab-dependencies.sh @@ -54,13 +55,16 @@ git commit -m "Version bump to $VERSION" if ls CHANGELOG-*.md; then ( + echo '# Changelog' + echo echo '##' $VERSION - `date +"%F"` echo # "-l" chomps and adds newline. perl -lpe '' CHANGELOG-*.md echo echo - cat CHANGELOG.md + # Skip the first line of the existing changelog (the header). + tail -n +2 context/app/markdown/CHANGELOG.md ) > CHANGELOG.md.new mv CHANGELOG.md.new context/app/markdown/CHANGELOG.md git rm CHANGELOG-*.md