Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

TranQL query optimization, loading error/state improvements, variable view color fix #325

Open
wants to merge 6 commits into
base: develop
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 81 additions & 117 deletions src/components/search/concept-modal/concept-modal.js
Original file line number Diff line number Diff line change
@@ -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: <StudiesIcon />,
content: <StudiesTab studies={ studies } />,
content: <StudiesTab studies={ studies } loading={ studiesLoading } error={ studiesError } />,
tooltip: <div>
The Studies tab displays studies referencing or researching the concept.
Studies are grouped into { studySources.length } categories:&nbsp;
@@ -132,7 +135,7 @@ export const ConceptModalBody = ({ result }) => {
'cdes': {
title: cdeTitle,
icon: <CdesIcon />,
content: <CdesTab cdes={ cdes } cdeRelatedConcepts={ cdeRelatedConcepts } cdeRelatedStudies={cdeRelatedStudies} loading={ cdesLoading } />,
content: <CdesTab cdes={ cdes } cdeRelatedConcepts={ cdeRelatedConcepts } cdeRelatedStudies={cdeRelatedStudies} loading={ cdesLoading } error={ cdesError } />,
tooltip: <div>
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: <KnowledgeGraphsIcon />,
content: <KnowledgeGraphsTab graphs={ graphs } />,
content: <KnowledgeGraphsTab graphs={ graphs } loading={ graphsLoading } error={ graphsError } />,
tooltip: <div>
The Knowledge Graphs tab displays relevant edges from the <a href="https://robokop.renci.org/" target="_blank" rel="noopener noreferrer">ROBOKOP Knowledge Graph</a>
portion connected to the concept and containing your search terms. This section highlights
&nbsp;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.
</div>
@@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be useful to propagate the exception?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not in this scenario. The exception is "implicitly" handled by setting the state to null, indicating an error state.

}
}
}
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}"`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Be careful that this could pick up (1) things that aren't concepts, or (2) concepts that we haven't loaded. For example, when I tried running this locally, I found the related concept "Rheum", which we haven't actually loaded, so trying to select it (via "Open") resulted in a synonymousConcepts is undefined error.

I prefer the approach we were using previously, which is to limit this query to certain types (['disease', 'anatomical_entity', 'phenotypic_feature', 'biological_process'] in https://github.com/helxplatform/helx-ui/pull/325/files#diff-e11890aa96a2d27536af10ab83b21362d569d6467f13afd9f6792b8196bd65afL2400). I'm not sure if this will fix this problem entirely, but I think it would be helpful.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, good point. I considered that it could possibly lead to unindexed concepts from unrestricting the types, but I didn't find in an example of such a thing happening during my testing.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll note that querying against these specific types in tranql isn't going to fix the issue of getting results back that aren't indexed in dug. This can be done by checking the concept returned by tranql is linked to a study variable, but this also isn't particularly practical. There was a bug with the concept lookup mechanism (used here) which I've fixed now, so you shouldn't be seeing that error anymore.

Really, what needs to happen is the addition of a new Dug endpoint that let's you pull any documents out of the concept index by ID. Then we could do a subsequent lookup against the results returned by tranql and filter out any that aren't indexed. It's just that this feature isn't particularly prominent so the work hasn't been done.

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"
>
<Paragraph style={{ marginBottom: 0 }}>
<Text strong style={{ fontSize: 16 }}>Related concepts</Text>
</Paragraph>
<div>
{
result.suggestions
.slice(0, 8)
.map((suggestedResult) => (
<a
key={ suggestedResult.id }
role="button"
style={{ display: "inline", marginRight: "12px", lineHeight: "36px" }}
onClick={() => setSelectedResult(suggestedResult)}
>
{suggestedResult.name}
</a>
))
}
</div>
{ result.suggestions && (
<Fragment>
<Paragraph style={{ marginBottom: 0 }}>
<Text strong style={{ fontSize: 16 }}>Related concepts</Text>
</Paragraph>
<div>
{
result.suggestions
.slice(0, 8)
.map((suggestedResult) => (
<a
key={ suggestedResult.id }
role="button"
style={{ display: "inline", marginRight: "12px", lineHeight: "36px" }}
onClick={() => setSelectedResult(suggestedResult)}
>
{suggestedResult.name}
</a>
))
}
</div>
</Fragment>
) }
</Result>
)
return (
4 changes: 2 additions & 2 deletions src/components/search/concept-modal/tabs/cdes/cde-item.js
Original file line number Diff line number Diff line change
@@ -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 }) => (
8 changes: 4 additions & 4 deletions src/components/search/concept-modal/tabs/cdes/cdes.js
Original file line number Diff line number Diff line change
@@ -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])
Loading