diff --git a/src/components/search/concept-modal/concept-modal.js b/src/components/search/concept-modal/concept-modal.js index 49d14d6b..4c269fc7 100644 --- a/src/components/search/concept-modal/concept-modal.js +++ b/src/components/search/concept-modal/concept-modal.js @@ -74,12 +74,15 @@ export const ConceptModalBody = ({ result }) => { const { context } = useEnvironment(); const [currentTab, _setCurrentTab] = useState('overview') const { query, setSelectedResult, fetchKnowledgeGraphs, fetchVariablesForConceptId, fetchCDEs, studySources } = useHelxSearch() - const [graphs, setGraphs] = useState([]) - const [studies, setStudies] = useState([]) - const [cdes, setCdes] = useState(null) + const [graphs, setGraphs] = useState(undefined) + const [studies, setStudies] = useState(undefined) + const [cdes, setCdes] = useState(undefined) const [cdeRelatedConcepts, setCdeRelatedConcepts] = useState(null) const [cdeRelatedStudies, setCdeRelatedStudies] = useState(null) - const [cdesLoading, setCdesLoading] = useState(true) + + const [cdesLoading, cdesError] = useMemo(() => ([ cdes === undefined, cdes === null ]), [cdes]) + const [graphsLoading, graphsError] = useMemo(() => ([ graphs === undefined, graphs === null ]), [graphs]) + const [studiesLoading, studiesError] = useMemo(() => ([ studies === undefined, studies === null ]), [studies]) /** Abort controllers */ const fetchVarsController = useRef() @@ -113,7 +116,7 @@ export const ConceptModalBody = ({ result }) => { 'studies': { title: studyTitle, icon: , - content: , + content: , tooltip:
The Studies tab displays studies referencing or researching the concept. Studies are grouped into { studySources.length } categories:  @@ -132,7 +135,7 @@ export const ConceptModalBody = ({ result }) => { 'cdes': { title: cdeTitle, icon: , - content: , + content: , tooltip:
The CDEs tab displays{ context.brand === "heal" ? " HEAL-approved" : "" } common data elements (CDEs) associated with the concept. A CDE is a standardized question used across studies and clinical @@ -147,10 +150,10 @@ export const ConceptModalBody = ({ result }) => { 'kgs': { title: 'Knowledge Graphs', icon: , - content: , + content: , tooltip:
The Knowledge Graphs tab displays relevant edges from the ROBOKOP Knowledge Graph - portion connected to the concept and containing your search terms. This section highlights +  portion connected to the concept and containing your search terms. This section highlights potential interesting knowledge graph relationships and shows terms (e.g., synonyms) that would be returned as related concepts.
@@ -214,13 +217,14 @@ export const ConceptModalBody = ({ result }) => { return studies }, {})) } catch (e) { - if (e.name !== "CanceledError") throw e + if (e.name !== "CanceledError") { + console.warning(e) + setStudies(null) + } } } const getCdes = async () => { try { - setCdesLoading(true) - fetchCdesController.current?.abort() fetchCdesController.current = new AbortController() fetchCdesTranqlController.current.forEach((controller) => controller.abort()) @@ -229,90 +233,51 @@ export const ConceptModalBody = ({ result }) => { const cdeData = await fetchCDEs(result.id, query, { signal: fetchCdesController.current.signal }) - const loadRelatedConcepts = async (cdeId) => { - const formatCdeQuery = (conceptType) => { - return `\ - SELECT publication-[mentions]->${conceptType} - FROM "/schema" - WHERE publication="${cdeId}"` - } - const tranqlUrl = context.tranql_url - const types = ['disease', 'anatomical_entity', 'phenotypic_feature', 'biological_process'] // add any others that you can think of, these are the main 4 found in heal results and supported by tranql - const kg = (await Promise.all(types.map(async (type) => { - const controller = new AbortController() - fetchCdesTranqlController.current.push(controller) - const res = await fetch( - `${tranqlUrl}tranql/query`, - { - headers: { 'Content-Type': 'text/plain' }, - method: 'POST', - body: formatCdeQuery(type), - signal: controller.signal - } - ) - const message = await res.json() - return message.message.knowledge_graph - }))).reduce((acc, kg) => ({ - nodes: { - ...acc.nodes, - ...kg.nodes - }, - edges: { - ...acc.edges, - ...kg.edges - } - }), {"nodes": {}, "edges": {}}) - const cdeOutEdges = Object.values(kg.edges).filter((edge) => edge.subject === cdeId) - return cdeOutEdges.map( - (outEdge) => { - const [nodeId, node] = Object.entries(kg.nodes).find(([nodeId, node]) => nodeId === outEdge.object) - return { - id: nodeId, - ...node - } - } - ) - } - - const loadRelatedStudies = async (cdeId) => { - const formatCdeQuery = () => { - return `\ - SELECT publication->study - FROM "/schema" - WHERE publication="${cdeId}"` + const loadRelatedConceptsAndStudies = async (cdeId) => { + const getNodeAttribute = (node, attrName) => { + return node.attributes.find((attr) => attr.name === attrName) } const tranqlUrl = context.tranql_url const controller = new AbortController() fetchCdesTranqlController.current.push(controller) + const res = await fetch( - `${tranqlUrl}tranql/query`, - { - headers: { 'Content-Type': 'text/plain' }, - method: 'POST', - body: formatCdeQuery(), - signal: controller.signal - } + `${tranqlUrl}tranql/query`, + { + headers: { 'Content-Type': 'text/plain' }, + method: 'POST', + body: `SELECT publication->named_thing FROM "redis:" WHERE publication="${cdeId}"`, + signal: controller.signal + } ) const message = await res.json() - const studies = [] - const nodes = message.message.knowledge_graph.nodes - for (const [key, value] of Object.entries(nodes)) { - const name = value.name; - const urlAttribute = value.attributes.find(attr => attr.name === 'url'); - urlAttribute && studies.push({c_id: key, - c_name: name, - c_link: urlAttribute.value}); - } - return studies + const kg = message.message.knowledge_graph + const cdeOutEdges = Object.values(kg.edges).filter((edge) => edge.subject === cdeId) + return cdeOutEdges.map(({ object }) => kg.nodes[object]) + .reduce((acc, node) => { + const [relatedConceptNodes, relatedStudyNodes] = acc + const types = getNodeAttribute(node, "category") + const url = getNodeAttribute(node, "url") + if (url && types && types.value.includes("biolink:Study")) relatedStudyNodes.push({ + c_id: node.id, + c_name: node.name, + c_link: url.value + }) + else relatedConceptNodes.push(node) + return [relatedConceptNodes, relatedStudyNodes] + }, [[], []]) } - + const relatedConcepts = {} const relatedStudies = {} if (cdeData) { const cdeIds = cdeData.elements.map((cde) => cde.id) await Promise.all(cdeIds.map(async (cdeId, i) => { try { - relatedConcepts[cdeId] = await loadRelatedConcepts(cdeId) + const [relatedConceptsRaw, relatedStudiesRaw] = await loadRelatedConceptsAndStudies(cdeId) + // Counterproductive to suggest the concept the user is actively viewing as "related" + relatedConcepts[cdeId] = relatedConceptsRaw.filter((c) => c.id !== result.id) + relatedStudies[cdeId] = relatedStudiesRaw } catch (e) { // Here, we explicitly want to halt execution and forward this error to the outer handler // if a related concept request was aborted, because we now have stale data and don't want to @@ -321,25 +286,17 @@ export const ConceptModalBody = ({ result }) => { relatedConcepts[cdeId] = null } })) - await Promise.all(cdeIds.map(async (cdeId, i) => { - try { - relatedStudies[cdeId] = await loadRelatedStudies(cdeId) - } catch (e) { - // Here, we explicitly want to halt execution and forward this error to the outer handler - // if a related concept request was aborted, because we now have stale data and don't want to - // update state with it. - if (e.name === "CanceledError" || e.name === "AbortError") throw e - } - })) } setCdes(cdeData) /** Note that relatedConcepts are *TranQL* concepts/nodes, not DUG concepts. Both have the top level fields `id` and `name`. */ setCdeRelatedConcepts(relatedConcepts) setCdeRelatedStudies(relatedStudies) - setCdesLoading(false) } catch (e) { // Check both because this function uses both Fetch API & Axios - if (e.name !== "CanceledError" && e.name !== "AbortError") throw e + if (e.name !== "CanceledError" && e.name !== "AbortError") { + console.warning(e) + setCdes(null) + } } } const getKgs = async () => { @@ -352,19 +309,22 @@ export const ConceptModalBody = ({ result }) => { }) setGraphs(kgs) } catch (e) { - if (e.name !== "CanceledError") throw e + if (e.name !== "CanceledError") { + console.warning(e) + setGraphs(null) + } } } if (!result.loading && !result.failed) { - setStudies([]) - setCdes(null) + setStudies(undefined) + setGraphs(undefined) + setCdes(undefined) setCdeRelatedConcepts(null) setCdeRelatedStudies(null) - setGraphs([]) getVars() - getCdes() getKgs() + getCdes() } }, [fetchKnowledgeGraphs, fetchVariablesForConceptId, fetchCDEs, result, query]) @@ -395,25 +355,29 @@ export const ConceptModalBody = ({ result }) => { ]} className="concept-modal-failed-result" > - - Related concepts - -
- { - result.suggestions - .slice(0, 8) - .map((suggestedResult) => ( - setSelectedResult(suggestedResult)} - > - {suggestedResult.name} - - )) - } -
+ { result.suggestions && ( + + + Related concepts + +
+ { + result.suggestions + .slice(0, 8) + .map((suggestedResult) => ( + setSelectedResult(suggestedResult)} + > + {suggestedResult.name} + + )) + } +
+
+ ) } ) return ( diff --git a/src/components/search/concept-modal/tabs/cdes/cde-item.js b/src/components/search/concept-modal/tabs/cdes/cde-item.js index 873cced3..62206596 100644 --- a/src/components/search/concept-modal/tabs/cdes/cde-item.js +++ b/src/components/search/concept-modal/tabs/cdes/cde-item.js @@ -28,11 +28,11 @@ export const CdeItem = ({ cde, cdeRelatedConcepts, cdeRelatedStudies, highlight const { analyticsEvents } = useAnalytics() const relatedConceptsSource = useMemo(() => ( - cdeRelatedConcepts[cde.id] + cdeRelatedConcepts ? cdeRelatedConcepts[cde.id] : null ), [cdeRelatedConcepts, cde]) const relatedStudySource = useMemo(() => ( - cdeRelatedStudies[cde.id] + cdeRelatedStudies ? cdeRelatedStudies[cde.id] : null ), [cdeRelatedStudies, cde]) const Highlighter = useCallback(({ ...props }) => ( diff --git a/src/components/search/concept-modal/tabs/cdes/cdes.js b/src/components/search/concept-modal/tabs/cdes/cdes.js index ada14b44..dfc57d65 100644 --- a/src/components/search/concept-modal/tabs/cdes/cdes.js +++ b/src/components/search/concept-modal/tabs/cdes/cdes.js @@ -11,12 +11,12 @@ const { Text, Title } = Typography const { CheckableTag: CheckableFacet } = Tag const { Panel } = Collapse -export const CdesTab = ({ cdes, cdeRelatedConcepts, cdeRelatedStudies, loading }) => { +export const CdesTab = ({ cdes, cdeRelatedConcepts, cdeRelatedStudies, loading, error }) => { const [search, setSearch] = useState("") const { context } = useEnvironment() - /** CDEs have loaded, but there aren't any. */ - const failed = useMemo(() => !loading && !cdes, [loading, cdes]) + /** CDEs failed to load or loaded but no results. */ + const failed = useMemo(() => error && !cdes, [error, cdes]) const docs = useMemo(() => { if (!loading && !failed) { @@ -27,7 +27,7 @@ export const CdesTab = ({ cdes, cdeRelatedConcepts, cdeRelatedStudies, loading } // Lunr supports array fields, though it expects the user to tokenize the elements themselves. // Instead, just join the concepts into a string and let lunr tokenize it instead. // See: https://stackoverflow.com/a/43562885 - concepts: Object.values(cdeRelatedConcepts[cde.id] ?? []).map((concept) => concept.name).join(" ") + concepts: cdeRelatedConcepts ? Object.values(cdeRelatedConcepts[cde.id] ?? []).map((concept) => concept.name).join(" ") : [] })) } else return [] }, [loading, failed, cdes, cdeRelatedConcepts]) diff --git a/src/components/search/concept-modal/tabs/knowledge-graphs.js b/src/components/search/concept-modal/tabs/knowledge-graphs.js index cb723f1c..c7c59b9e 100644 --- a/src/components/search/concept-modal/tabs/knowledge-graphs.js +++ b/src/components/search/concept-modal/tabs/knowledge-graphs.js @@ -6,28 +6,27 @@ import { useLunrSearch } from '../../../../hooks' const { Title } = Typography -export const KnowledgeGraphsTab = ({ graphs }) => { +export const KnowledgeGraphsTab = ({ graphs, loading, error }) => { const [search, setSearch] = useState("") const docs = useMemo(() => { - if (graphs) { - return graphs.map(({ knowledge_graph }, i) => { - const { nodes, edges } = knowledge_graph + if (loading || error) return [] + return graphs.map(({ knowledge_graph }, i) => { + const { nodes, edges } = knowledge_graph - const edge = edges[0] - const subject = nodes.find((node) => node.id === edge.subject) - const object = nodes.find((node) => node.id === edge.object) - return { - id: i, - predicate: edge.predicate_label, - subjectName: subject.name, - subjectSynonyms: subject.synonyms?.join(" "), - objectName: object.name, - objectSynonyms: object.synonyms?.join(" ") - } - }) - } else return [] - }, [graphs]) + const edge = edges[0] + const subject = nodes.find((node) => node.id === edge.subject) + const object = nodes.find((node) => node.id === edge.object) + return { + id: i, + predicate: edge.predicate_label, + subjectName: subject.name, + subjectSynonyms: subject.synonyms?.join(" "), + objectName: object.name, + objectSynonyms: object.synonyms?.join(" ") + } + }) + }, [graphs, loading, error]) const lunrConfig = useMemo(() => ({ docs, @@ -50,21 +49,22 @@ export const KnowledgeGraphsTab = ({ graphs }) => { const { index, lexicalSearch } = useLunrSearch(lunrConfig) const [graphSource, highlightTokens] = useMemo(() => { + if (loading || error) return [[], []] if (search.length < 3) return [graphs, []] const { hits, tokens } = lexicalSearch(search) const matchedGraphs = hits.map(({ ref: i }) => graphs[i]) return [ matchedGraphs, tokens ] - }, [graphs, search, lexicalSearch]) + }, [graphs, loading, error, search, lexicalSearch]) return (
Knowledge Graphs - + { !loading && !error && }
- +
) } \ No newline at end of file diff --git a/src/components/search/concept-modal/tabs/studies/studies.js b/src/components/search/concept-modal/tabs/studies/studies.js index c0b407a2..c3543078 100644 --- a/src/components/search/concept-modal/tabs/studies/studies.js +++ b/src/components/search/concept-modal/tabs/studies/studies.js @@ -1,31 +1,30 @@ import { useEffect, useMemo, useState } from 'react' -import { Collapse, List, Space, Tag, Typography } from 'antd' +import { Collapse, List, Result, Space, Spin, Tag, Typography } from 'antd' import { Study } from './study' import { DebouncedInput } from '../../../../' import { useLunrSearch } from '../../../../../hooks' -const { Title } = Typography +const { Title, Text } = Typography const { CheckableTag: CheckableFacet } = Tag -export const StudiesTab = ({ studies }) => { +export const StudiesTab = ({ studies, loading, error }) => { const [search, setSearch] = useState("") const [facets, setFacets] = useState([]) const [selectedFacets, setSelectedFacets] = useState([]) const [activeStudyKeys, setActiveStudyKeys] = useState([]) const docs = useMemo(() => { - if (studies) { - return Object.entries(studies).flatMap(([source, studies]) => ( - studies.map((study) => ({ - id: study.c_id, - type: source, - studyName: study.c_name, - variableNames: study.elements.map((variable) => variable.name).join(" "), - variableDescriptions: study.elements.map((variable) => variable.description).join(" ") - })) - )) - } else return [] - }, [studies]) + if (loading || error) return [] + return Object.entries(studies).flatMap(([source, studies]) => ( + studies.map((study) => ({ + id: study.c_id, + type: source, + studyName: study.c_name, + variableNames: study.elements.map((variable) => variable.name).join(" "), + variableDescriptions: study.elements.map((variable) => variable.description).join(" ") + })) + )) + }, [studies, loading, error]) const lunrConfig = useMemo(() => ({ docs, @@ -52,11 +51,13 @@ export const StudiesTab = ({ studies }) => { } useEffect(() => { + if (!studies) return setFacets(Object.keys(studies)) setSelectedFacets(Object.keys(studies)) }, [studies]) const [studiesSource, highlightTokens] = useMemo(() => { + if (loading || error) return [[], []] if (search.length < 3) return [studies, []] const { hits, tokens } = lexicalSearch(search) @@ -75,7 +76,7 @@ export const StudiesTab = ({ studies }) => { // )) return [ matchedStudies, tokens ] - }, [studies, search, lexicalSearch]) + }, [studies, loading, error, search, lexicalSearch]) const filteredStudiesSource = useMemo(() => ( Object.keys(studiesSource) @@ -84,7 +85,19 @@ export const StudiesTab = ({ studies }) => { .sort((s, t) => s.c_name < t.c_name ? -1 : 1) ), [studiesSource, selectedFacets]) - return ( + if (loading || error) return ( + +
+ Studies +
+
+ { loading ? : ( + + ) } +
+
+ ) + else return (
Studies diff --git a/src/components/search/concept-modal/tabs/studies/study.js b/src/components/search/concept-modal/tabs/studies/study.js index 04c9d17b..7612c075 100644 --- a/src/components/search/concept-modal/tabs/studies/study.js +++ b/src/components/search/concept-modal/tabs/studies/study.js @@ -29,7 +29,7 @@ export const Study = ({ study, highlight, collapsed, ...panelProps }) => { ({ study.c_id }) } - extra={ { study.elements.length } variable{ study.elements.length === 1 ? '' : 's' } } + extra={ { study.elements.length } variable{ study.elements.length === 1 ? '' : 's' } } {...panelProps} > diff --git a/src/components/search/context.js b/src/components/search/context.js index 5d86789a..2e990800 100644 --- a/src/components/search/context.js +++ b/src/components/search/context.js @@ -32,7 +32,7 @@ const validateResult = result => { } export const HelxSearch = ({ children }) => { - const { helxSearchUrl, basePath } = useEnvironment() + const { helxSearchUrl, basePath, context } = useEnvironment() const { analyticsEvents } = useAnalytics() const [query, setQuery] = useState('') const [isLoadingConcepts, setIsLoadingConcepts] = useState(false); @@ -60,6 +60,7 @@ export const HelxSearch = ({ children }) => { const [variableError, setVariableError] = useState({}) const variablesAbortController = useRef() + const cdeVariableAttributeControllers = useRef([]) const inputRef = useRef() const navigate = useNavigate() const [searchHistory, setSearchHistory] = useLocalStorage('search_history', []) @@ -157,7 +158,7 @@ export const HelxSearch = ({ children }) => { setSelectedResult(foundConceptResult ? foundConceptResult : { name, failed: true, - suggestions: synonymousConcepts.length > 0 ? synonymousConcepts : results + suggestions: synonymousConcepts?.length > 0 ? synonymousConcepts : null }) } catch (e) { if (e.name !== "CanceledError") throw e @@ -246,7 +247,6 @@ export const HelxSearch = ({ children }) => { // `/agg_data_types` since CDE variables are classified under the `cde` data_type field setStudySources( response.data.result - .filter((source) => source !== "cde") .sort((a, b) => a.localeCompare(b)) ) } else { @@ -373,7 +373,7 @@ export const HelxSearch = ({ children }) => { } const filteredAndTypedStudies = Object.keys(result) .reduce((studies, key) => { - if (key !== "cde") { + if (key.toLowerCase() !== "cde") { const newStudies = [...result[key].map(item => ({ type: key, ...item }))] return [...newStudies, ...studies] } @@ -400,17 +400,20 @@ export const HelxSearch = ({ children }) => { } const cdesOnly = Object.keys(result) .reduce((studies, key) => { - if (key === 'cde') { + if (key.toLowerCase() === 'cde') { const newStudies = [...result[key].map(item => ({ type: key, ...item }))] return [...newStudies, ...studies] } return [...studies] }, []) - return cdesOnly ? cdesOnly[0] : null + return cdesOnly.length > 0 ? cdesOnly[0] : null } catch (error) { /** Forward AbortError upwards. Handle other errors here. */ if (error.name === "CanceledError") throw error - else console.error(error) + else { + console.error(error) + return null + } } }, [helxSearchUrl]) @@ -440,6 +443,19 @@ export const HelxSearch = ({ children }) => { } }, [setSelectedResult, navigate, basePath, setSearchHistory]) + const loadTranQLNode = useCallback(async (nodeId, axiosOptions={}) => { + const tranqlUrl = context.tranql_url + const tranqlQuery = `SELECT named_thing FROM "redis:" WHERE named_thing='${ nodeId }'` + const response = await axios.post(`${tranqlUrl}tranql/query`, tranqlQuery, { + headers: { "Content-Type": "text/plain" }, + ...axiosOptions + }) + const kg = response.data.message.knowledge_graph + return kg.nodes[nodeId] + }, [context]) + + const isCDE = useCallback((variable) => variable.data_source.toLowerCase() === "cde", []) + useEffect(() => { return () => { // Unmount @@ -491,6 +507,10 @@ export const HelxSearch = ({ children }) => { try { variablesAbortController.current?.abort() variablesAbortController.current = new AbortController() + + cdeVariableAttributeControllers.current.forEach((controller) => controller.abort()) + cdeVariableAttributeControllers.current = [] + const params = { index: 'variables_index', query: query, @@ -509,6 +529,26 @@ export const HelxSearch = ({ children }) => { }) return [...acc, ...studies] }, []) + // Load non-indexed supplemental CDE attributes where applicable. + const cdeVariables = studies.flatMap((s) => s.elements).filter((v) => isCDE(v)) + /** + * Currently, don't load these. Not much useful info and requires 1 TranQL request per CDE returned. + await Promise.all(cdeVariables.map(async (cde) => { + try { + const controller = new AbortController() + cdeVariableAttributeControllers.current.push(controller) + const { attributes } = await loadTranQLNode(cde.id, { + signal: controller.signal + }) + cde.attributes = attributes + } catch (e) { + if (!axios.isCancel(e)) { + console.warning(e) + cde.attributes = null + } + } + })) + */ // Data structure of sortedVariables is designed to populate the histogram feature const {sortedVariables, variablesCount, studiesWithVariablesMarked, studiesCount} = collectVariablesAndUpdateStudies(studies) setVariableStudyResults(studiesWithVariablesMarked) @@ -537,7 +577,7 @@ export const HelxSearch = ({ children }) => { if (query) { fetchAllVariables() } - }, [query, helxSearchUrl]) + }, [query, loadTranQLNode, isCDE, helxSearchUrl]) return ( @@ -556,7 +596,7 @@ export const HelxSearch = ({ children }) => { conceptTypes, variableStudyResults, variableStudyResultCount, variableError, variableResults, isLoadingVariableResults, - totalVariableResults, + totalVariableResults, isCDE, studySources }}> {children} diff --git a/src/components/search/knowledge-graphs/knowledge-graphs.js b/src/components/search/knowledge-graphs/knowledge-graphs.js index cf465b55..0c1cdeed 100644 --- a/src/components/search/knowledge-graphs/knowledge-graphs.js +++ b/src/components/search/knowledge-graphs/knowledge-graphs.js @@ -1,4 +1,5 @@ import React, { Fragment, useCallback, useEffect, useState } from 'react' +import { Result, Spin } from 'antd' import _Highlighter from 'react-highlight-words' import { kgLink } from '../../../utils' import { Link } from '../../link' @@ -64,8 +65,16 @@ const NoKnowledgeGraphsMessage = () => { ) } -export const KnowledgeGraphs = ({ graphs: complete_graphs, highlight }) => { - if (complete_graphs.length) { +export const KnowledgeGraphs = ({ graphs: complete_graphs, loading, error, highlight }) => { + if (loading) return ( +
+ +
+ ) + else if (error) return ( + + ) + else if (complete_graphs.length) { const graphs = complete_graphs.map((graph) => graph.knowledge_graph); return (
@@ -88,6 +97,5 @@ export const KnowledgeGraphs = ({ graphs: complete_graphs, highlight }) => {
) } - - return + else return } \ No newline at end of file diff --git a/src/components/search/results/variable-view-layout/variable-list/variable-list-item.tsx b/src/components/search/results/variable-view-layout/variable-list/variable-list-item.tsx index 1346ea08..6bc16f16 100644 --- a/src/components/search/results/variable-view-layout/variable-list/variable-list-item.tsx +++ b/src/components/search/results/variable-view-layout/variable-list/variable-list-item.tsx @@ -1,14 +1,32 @@ import { useCallback, useMemo, useRef, useState } from 'react' -import { Button, Tooltip, Typography } from 'antd' +import { Button, Spin, Tooltip, Typography } from 'antd' import _Highlighter from 'react-highlight-words' -import { useVariableView, VariableResult } from '../variable-view-context' +import { CdeVariableResult, ISearchContext, useVariableView, VariableResult } from '../variable-view-context' import classNames from 'classnames' import { StudyInfoTooltip } from '../study-info-tooltip' +import { useHelxSearch } from '../../../context' const { Text } = Typography const VARIABLE_DESCRIPTION_CUTOFF = 500 +interface VariableCdeAttributesProps { + // null indicates error state + attributes: { name: string, type: string, value: any }[] | null +} + +const VariableCdeAttributes = ({ attributes }: VariableCdeAttributesProps) => { + const cdeCategories = useMemo(() => attributes?.find((a) => a.name === "cde_categories")?.value, [attributes]) + if (!attributes) return null + else return ( +
    +
  • + Categories: { cdeCategories!.sort((a, b) => a.localeCompare(b)).join(", ") } +
  • +
+ ) +} + interface VariableListItemProps extends React.HTMLProps { variable: VariableResult showStudySource?: boolean @@ -16,6 +34,7 @@ interface VariableListItemProps extends React.HTMLProps { } export const VariableListItem = ({ variable, showStudySource=true, showDataSource=true, style={}, ...props }: VariableListItemProps) => { + const { isCDE } = useHelxSearch() as ISearchContext const { dataSources, highlightTokens, getStudyById } = useVariableView()! const [showMore, setShowMore] = useState(false) @@ -37,6 +56,7 @@ export const VariableListItem = ({ variable, showStudySource=true, showDataSourc alignItems: "flex-start", flexWrap: "nowrap", paddingRight: 16, + gap: 4, ...style }} { ...props } @@ -93,6 +113,9 @@ export const VariableListItem = ({ variable, showStudySource=true, showDataSourc ) } + { isCDE(variable) && ( + + ) } { showStudySource && ( Source:  diff --git a/src/components/search/results/variable-view-layout/variable-view-context.tsx b/src/components/search/results/variable-view-layout/variable-view-context.tsx index 9d432bcf..e66dc56d 100644 --- a/src/components/search/results/variable-view-layout/variable-view-context.tsx +++ b/src/components/search/results/variable-view-layout/variable-view-context.tsx @@ -18,17 +18,15 @@ const GRADIENT_CONSTITUENTS = [ // ] const COLOR_GRADIENT = chroma.scale(GRADIENT_CONSTITUENTS).mode("lrgb") -// Determines the order in which data sources appear in the tag list. -const seededPalette = new Palette(chroma.rgb(255 * .75, 255 * .25, 255 * .25), {mode: 'hex'}); const FIXED_DATA_SOURCES: { [string: string]: string } = { - "HEAL Studies": "#40bf65", - "HEAL Research Programs": "#bfaf40", - "Non-HEAL Studies": "#bf4040", + "heal studies": "#40bf65", + "heal research programs": "#bfaf40", + "non-heal studies": "#bf4040", "cde": "#8a40bf", - "dbGaP": "#40aabf", - "AnVIL": "#bf4085", - "Cancer Data Commons": "#60bf40", - "Kids First": "#4540bf" + "dbgap": "#40aabf", + "anvil": "#bf4085", + "cancer data commons": "#60bf40", + "kids first": "#4540bf" } export interface DataSource { @@ -57,6 +55,10 @@ export interface VariableResult { study_name: string data_source: string // e.g. "HEAL Studies" vs "Non-HEAL Studies" } +export interface CdeVariableResult extends VariableResult { + // null indicates error state + attributes: { name: string, type: string, value: any }[] | null +} interface GetChart { (): G2Column @@ -67,9 +69,11 @@ interface ChartRef { export interface ISearchContext { query: string + studySources: string[] variableResults: VariableResult[] variableStudyResults: StudyResult[] totalVariableResults: number + isCDE: (variable: VariableResult) => variable is CdeVariableResult } export interface IVariableViewContext { @@ -114,7 +118,7 @@ interface VariableViewProviderProps { export const VariableViewContext = createContext(undefined) export const VariableViewProvider = ({ children }: VariableViewProviderProps) => { - const { variableResults, variableStudyResults } = useHelxSearch() as ISearchContext + const { variableResults, variableStudyResults, studySources } = useHelxSearch() as ISearchContext /** * Filters @@ -126,6 +130,16 @@ export const VariableViewProvider = ({ children }: VariableViewProviderProps) => const [sortOrderOption, setSortOrderOption] = useState("descending") const [collapseIntoVariables, setCollapseIntoVariables] = useState(false) + const dataSourceColors = useMemo>(() => { + const colorMap = new Map() + const seededPalette = new Palette(chroma.rgb(255 * .25, 255 * .75, 255 * .75), {mode: 'hex'}); + studySources.sort((a, b) => a.localeCompare(b)).forEach((s) => { + const color = FIXED_DATA_SOURCES[s.toLowerCase()] ?? seededPalette.getNextColor() + colorMap.set(s, color) + }) + return colorMap + }, [studySources]) + const [variableIdMap, studyIdMap] = useMemo<[Map, Map]>(() => { const variableMap = new Map() const studyMap = new Map() @@ -320,7 +334,7 @@ export const VariableViewProvider = ({ children }: VariableViewProviderProps) => } else { acc[dataSource] = { name: dataSource, - color: FIXED_DATA_SOURCES[dataSource] ?? seededPalette.getNextColor(), + color: dataSourceColors.get(dataSource)!, studies: [cur.study_id], variables: [cur.id], filteredVariables: filteredVariables.includes(cur) ? [cur.id] : [] @@ -328,25 +342,12 @@ export const VariableViewProvider = ({ children }: VariableViewProviderProps) => } return acc }, {}) - }, [variablesSource, filteredVariables]) + }, [variablesSource, filteredVariables, dataSourceColors]) const orderedDataSources = useMemo(() => { - const dataSourceKeyOrder = Object.keys(FIXED_DATA_SOURCES) - return Object.entries(dataSources) - .sort((a, b) => { - const [aName, aDataSource] = a - const [bName, bDataSource] = b - const aIndex = dataSourceKeyOrder.indexOf(aName) - const bIndex = dataSourceKeyOrder.indexOf(bName) - // Sort unrecognized data sources alphabetically. - if (aIndex === -1 && bIndex === -1) return aName.localeCompare(bName) - // Put unrecognized data sources at the end of the array. - if (aIndex === -1) return 1 - if (bIndex === -1) return -1 - return aIndex - bIndex - }) - .map(([name, dataSource]) => dataSource) - }, [dataSources]) + const order = studySources.sort((a, b) => a.localeCompare(b)) + return Object.values(dataSources).sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name)) + }, [dataSources, studySources]) const isFiltered = useMemo(() => ( collapseIntoVariables