From 7963b2fb7d00be0e9489f439925481ba49f7d58e Mon Sep 17 00:00:00 2001 From: David Glymph Date: Tue, 9 Jul 2024 08:30:38 -0400 Subject: [PATCH 1/7] add tree model structure to useBiolinkModel hook --- src/stores/useBiolinkModel.js | 138 ++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/src/stores/useBiolinkModel.js b/src/stores/useBiolinkModel.js index 35896246..e51b3fb4 100644 --- a/src/stores/useBiolinkModel.js +++ b/src/stores/useBiolinkModel.js @@ -5,6 +5,28 @@ import getNodeCategoryColorMap from '~/utils/colors'; const baseClass = 'biolink:NamedThing'; +const newClassNode = (name) => ({ + name, + uuid: crypto.randomUUID(), + parent: null, + children: [], + mixinParents: [], + mixinChildren: [], + abstract: false, + mixin: false, +}); + +const newSlotNode = (name) => ({ + name, + uuid: crypto.randomUUID(), + parent: null, + children: [], + mixinParents: [], + mixinChildren: [], + abstract: false, + mixin: false, +}); + export default function useBiolinkModel() { const [biolinkModel, setBiolinkModel] = useState(null); const [concepts, setConcepts] = useState([]); @@ -13,6 +35,8 @@ export default function useBiolinkModel() { const [ancestorsMap, setAncestorsMap] = useState([]); const colorMap = useCallback(getNodeCategoryColorMap(hierarchies), [hierarchies]); + const [model, setModel] = useState(null); + function checkIfDescendantOfRelatedTo([name, slot]) { let currentName = name; let current = slot; @@ -174,11 +198,125 @@ export default function useBiolinkModel() { setConcepts(allConcepts); setPredicates(allPredicates); setAncestorsMap(allAncestors); + + const slotRootItems = []; + const slotLookup = new Map(); + for (const [name, slot] of Object.entries(biolinkModel.slots)) { + if (!slotLookup.has(name)) { + slotLookup.set(name, newSlotNode(name)); + } + + const thisNode = slotLookup.get(name); + + const parentName = slot.is_a ?? null; + if (!parentName) { + slotRootItems.push(thisNode); + } else { + if (!slotLookup.has(parentName)) { + slotLookup.set(parentName, newSlotNode(parentName)); + } + + const parentNode = slotLookup.get(parentName); + parentNode.children.push(thisNode); + thisNode.parent = parentNode; + } + + thisNode.abstract = slot.abstract ?? false; + thisNode.mixin = slot.mixin ?? false; + + // this node has mixins parents + const mixinNames = slot.mixins ?? null; + if (mixinNames) { + for (const mixinName of mixinNames) { + if (!slotLookup.has(mixinName)) { + slotLookup.set(mixinName, newSlotNode(mixinName)); + } + + const mixinNode = slotLookup.get(mixinName); + mixinNode.mixinChildren.push(thisNode); + thisNode.mixinParents.push(mixinNode); + } + } + } + + const rootItems = []; + const lookup = new Map(); + for (const [name, cls] of Object.entries(biolinkModel.classes)) { + if (!lookup.has(name)) { + lookup.set(name, newClassNode(name)); + } + + const thisNode = lookup.get(name); + + const parentName = cls.is_a ?? null; + if (!parentName) { + rootItems.push(thisNode); + } else { + if (!lookup.has(parentName)) { + lookup.set(parentName, newClassNode(parentName)); + } + + const parentNode = lookup.get(parentName); + parentNode.children.push(thisNode); + thisNode.parent = parentNode; + } + + thisNode.abstract = cls.abstract ?? false; + thisNode.mixin = cls.mixin ?? false; + + if (cls.slot_usage) { + thisNode.slotUsage = cls.slot_usage; + + thisNode.slotUsage.subject = cls.slot_usage.subject?.range + ? lookup.get(cls.slot_usage.subject?.range) + : undefined; + + thisNode.slotUsage.object = cls.slot_usage.object?.range + ? lookup.get(cls.slot_usage.object?.range) + : undefined; + + thisNode.slotUsage.predicate = cls.slot_usage.predicate + ?.subproperty_of + ? slotLookup.get(cls.slot_usage.predicate?.subproperty_of) + : undefined; + } + + // this node has mixins parents + const mixinNames = cls.mixins ?? null; + if (mixinNames) { + for (const mixinName of mixinNames) { + if (!lookup.has(mixinName)) { + lookup.set(mixinName, newClassNode(mixinName)); + } + + const mixinNode = lookup.get(mixinName); + mixinNode.mixinChildren.push(thisNode); + thisNode.mixinParents.push(mixinNode); + } + } + } + + const m = { + classes: { + treeRootNodes: rootItems, + lookup, + }, + slots: { + treeRootNodes: slotRootItems, + lookup: slotLookup, + }, + associations: lookup.get("association"), + qualifiers: slotLookup.get("qualifier"), + enums: biolinkModel.enums, + }; + console.log(m); + setModel(m); } }, [biolinkModel]); return { setBiolinkModel, + model, concepts, hierarchies, predicates, From 89da9422c973837b44122fda085ae076afe951c3 Mon Sep 17 00:00:00 2001 From: David Glymph Date: Tue, 23 Jul 2024 10:26:42 -0400 Subject: [PATCH 2/7] qualified predicates + TRAPI handling --- .../textEditorRow/QualifiersSelector.jsx | 113 +++++++ .../textEditorRow/TextEditorRow.jsx | 277 ++++++++++++++---- .../textEditorRow/textEditorRow.css | 16 + src/pages/queryBuilder/useQueryBuilder.js | 10 + src/stores/useBiolinkModel.js | 6 +- 5 files changed, 360 insertions(+), 62 deletions(-) create mode 100644 src/pages/queryBuilder/textEditor/textEditorRow/QualifiersSelector.jsx diff --git a/src/pages/queryBuilder/textEditor/textEditorRow/QualifiersSelector.jsx b/src/pages/queryBuilder/textEditor/textEditorRow/QualifiersSelector.jsx new file mode 100644 index 00000000..4a3af6fd --- /dev/null +++ b/src/pages/queryBuilder/textEditor/textEditorRow/QualifiersSelector.jsx @@ -0,0 +1,113 @@ +/* eslint-disable no-restricted-syntax */ +import React, { useContext } from 'react'; +import { TextField } from '@material-ui/core'; +import { Autocomplete } from '@material-ui/lab'; +import QueryBuilderContext from '~/context/queryBuilder'; + +const flattenTree = (root, includeMixins) => { + const items = [root]; + if (root.children) { + for (const child of root.children) { + items.push(...flattenTree(child, includeMixins)); + } + } + if (root.mixinChildren && includeMixins === true) { + for (const mixinChild of root.mixinChildren) { + items.push(...flattenTree(mixinChild, includeMixins)); + } + } + return items; +}; + +const getQualifierOptions = ({ range, subpropertyOf }) => { + const options = []; + + if (range) { + if (range.permissible_values) { + options.push(...Object.keys(range.permissible_values)); + } else { + options.push(...flattenTree(range).map(({ name }) => name)); + } + } + + if (subpropertyOf) { + options.push(...flattenTree(subpropertyOf).map(({ name }) => name)); + } + + return options; +}; + +// const getBestAssociationOption = (associationOptions) => { +// let best = null; +// for (const opt of associationOptions) { +// if (opt.qualifiers.length > (best.length || 0)) best = opt; +// } +// return best; +// }; + +export default function QualifiersSelector({ id, associations }) { + const queryBuilder = useContext(QueryBuilderContext); + + const associationOptions = associations.map(({ association, qualifiers }) => ({ + name: association.name, + uuid: association.uuid, + qualifiers: qualifiers.map((q) => ({ + name: q.qualifier.name, + options: getQualifierOptions(q), + })), + })); + + const [value, setValue] = React.useState(associationOptions[0] || null); + const [qualifiers, setQualifiers] = React.useState({}); + React.useEffect(() => { + queryBuilder.dispatch({ type: 'editQualifiers', payload: { id, qualifiers } }); + }, [qualifiers]); + + if (associationOptions.length === 0) return null; + if (associationOptions.length === 1 && associationOptions[0].name === 'association') return null; + + return ( +
+ Qualifiers +
+ { + setValue(newValue); + }} + size="small" + options={associationOptions} + getOptionLabel={(option) => option.name} + getOptionSelected={(opt, val) => opt.uuid === val.uuid} + style={{ width: 300 }} + renderInput={(params) => } + /> + +
+ + { + value.qualifiers.map(({ name, options }) => ( + { + if (newValue === null) { + setQualifiers((prev) => { + const next = { ...prev }; + delete next[name]; + return next; + }); + } else { setQualifiers((prev) => ({ ...prev, [name]: newValue || null })); } + }} + options={options} + style={{ width: 300 }} + renderInput={(params) => } + size="small" + /> + )) + } +
+ +
+ ); +} diff --git a/src/pages/queryBuilder/textEditor/textEditorRow/TextEditorRow.jsx b/src/pages/queryBuilder/textEditor/textEditorRow/TextEditorRow.jsx index 580524e3..5243c04d 100644 --- a/src/pages/queryBuilder/textEditor/textEditorRow/TextEditorRow.jsx +++ b/src/pages/queryBuilder/textEditor/textEditorRow/TextEditorRow.jsx @@ -3,18 +3,172 @@ import IconButton from '@material-ui/core/IconButton'; import AddBoxOutlinedIcon from '@material-ui/icons/AddBoxOutlined'; import IndeterminateCheckBoxOutlinedIcon from '@material-ui/icons/IndeterminateCheckBoxOutlined'; +import BiolinkContext from '~/context/biolink'; import QueryBuilderContext from '~/context/queryBuilder'; import NodeSelector from './NodeSelector'; import PredicateSelector from './PredicateSelector'; +import QualifiersSelector from './QualifiersSelector'; import './textEditorRow.css'; +function getValidAssociations(s, p, o, model) { + const validAssociations = []; + + const subject = model.classes.lookup.get(s); + const predicate = model.slots.lookup.get(p); + const object = model.classes.lookup.get(o); + + const isInRange = ( + n, + range + ) => { + const traverse = (nodes, search) => { + for (const n of nodes) { + if (n === search) return true; + if (n.parent) { + if (traverse([n.parent], search)) return true; + } + if (n.mixinParents) { + if (traverse(n.mixinParents, search)) return true; + } + } + return false; + }; + return traverse([n], range); + }; + + /** + * Get the inherited subject/predicate/object ranges for an association + */ + const getInheritedSPORanges = ( + association + ) => { + const namedThing = model.classes.lookup.get("named thing"); + const relatedTo = model.slots.lookup.get("related to"); + + const traverse = ( + nodes, + part + ) => { + for (const node of nodes) { + if (node.slotUsage?.[part]) return node.slotUsage[part]; + if (node.parent) { + const discoveredType = traverse([node.parent], part); + if (discoveredType !== null) return discoveredType; + } + if (node.mixinParents) { + const discoveredType = traverse(node.mixinParents, part); + if (discoveredType !== null) return discoveredType; + } + } + + return null; + }; + + const subject = traverse([association], "subject") ?? namedThing; + const predicate = traverse([association], "predicate") ?? relatedTo; + const object = traverse([association], "object") ?? namedThing; + + return { subject, predicate, object }; + }; + + // DFS over associations + const traverse = (nodes, level = 0) => { + for (const association of nodes) { + if (association.slotUsage && !association.abstract) { + const inherited = getInheritedSPORanges(association); + + const validSubject = isInRange(subject, inherited.subject); + const validObject = isInRange(object, inherited.object); + const validPredicate = isInRange(predicate, inherited.predicate); + + const qualifiers = Object.entries(association.slotUsage) + .map(([qualifierName, properties]) => { + if (properties === null) return null; + const qualifier = model.slots.lookup.get(qualifierName); + if (!qualifier || !isInRange(qualifier, model.qualifiers)) + return null; + + let range = undefined; + if (properties.range) { + const potentialEnum = + model.enums[properties.range]; + const potentialClassNode = + model.classes.lookup.get(properties.range); + + if (potentialEnum) range = potentialEnum; + if (potentialClassNode) range = potentialClassNode; + } + + let subpropertyOf = undefined; + if ( + properties.subproperty_of && + model.slots.lookup.has(properties.subproperty_of) + ) { + subpropertyOf = model.slots.lookup.get( + properties.subproperty_of + ); + } + + return { + qualifier, + range, + subpropertyOf, + }; + }) + .filter((q) => q !== null); + + if (validSubject && validObject && validPredicate) { + validAssociations.push({ + association, + inheritedRanges: inherited, + level, + qualifiers, + }); + } + } + traverse(association.children, level + 1); + } + }; + traverse([model.associations]); + + validAssociations.sort((a, b) => b.level - a.level); + + return validAssociations; +} + export default function TextEditorRow({ row, index }) { const queryBuilder = useContext(QueryBuilderContext); + const { model } = useContext(BiolinkContext); + if (!model) return "Loading..."; const { query_graph } = queryBuilder; const edge = query_graph.edges[row.edgeId]; const { edgeId, subjectIsReference, objectIsReference } = row; + const subject = (query_graph.nodes[edge.subject].categories?.[0] ?? 'biolink:NamedThing') + .replace('biolink:', '') + .match(/[A-Z][a-z]+/g) + .join(' ') + .toLowerCase(); + const predicate = (edge.predicates?.[0] ?? 'biolink:related_to') + .replace('biolink:', '') + .replace(/_/g, ' '); + const object = (query_graph.nodes[edge.object].categories?.[0] ?? 'biolink:NamedThing') + .replace('biolink:', '') + .match(/[A-Z][a-z]+/g) + .join(' ') + .toLowerCase(); + + const validAssociations = getValidAssociations(subject, predicate, object, model); + + // console.log( + // `\ + // S: ${subjectCategory}\n\ + // P: ${predicate}\n\ + // O: ${objectCategory}\ + // ` + // ) + function deleteEdge() { queryBuilder.dispatch({ type: 'deleteEdge', payload: { id: edgeId } }); } @@ -33,67 +187,74 @@ export default function TextEditorRow({ row, index }) { return (
- - - -

- {index === 0 && 'Find'} - {index === 1 && 'where'} - {index > 1 && 'and where'} -

- setReference('subject', nodeId)} - update={subjectIsReference ? ( - () => setReference('subject', null) - ) : ( - editNode - )} - isReference={subjectIsReference} - options={{ - includeCuries: !subjectIsReference, - includeCategories: !subjectIsReference, - includeExistingNodes: index !== 0, - existingNodes: Object.keys(query_graph.nodes).filter( - (key) => key !== edge.object, - ).map((key) => ({ ...query_graph.nodes[key], key })), - }} - /> - + + + +

+ {index === 0 && 'Find'} + {index === 1 && 'where'} + {index > 1 && 'and where'} +

+ setReference('subject', nodeId)} + update={subjectIsReference ? ( + () => setReference('subject', null) + ) : ( + editNode + )} + isReference={subjectIsReference} + options={{ + includeCuries: !subjectIsReference, + includeCategories: !subjectIsReference, + includeExistingNodes: index !== 0, + existingNodes: Object.keys(query_graph.nodes).filter( + (key) => key !== edge.object, + ).map((key) => ({ ...query_graph.nodes[key], key })), + }} + /> + + setReference('object', nodeId)} + update={objectIsReference ? ( + () => setReference('object', null) + ) : ( + editNode + )} + isReference={objectIsReference} + options={{ + includeCuries: !objectIsReference, + includeCategories: !objectIsReference, + includeExistingNodes: index !== 0, + existingNodes: Object.keys(query_graph.nodes).filter( + (key) => key !== edge.subject, + ).map((key) => ({ ...query_graph.nodes[key], key })), + }} + /> + + + +
+ + - setReference('object', nodeId)} - update={objectIsReference ? ( - () => setReference('object', null) - ) : ( - editNode - )} - isReference={objectIsReference} - options={{ - includeCuries: !objectIsReference, - includeCategories: !objectIsReference, - includeExistingNodes: index !== 0, - existingNodes: Object.keys(query_graph.nodes).filter( - (key) => key !== edge.subject, - ).map((key) => ({ ...query_graph.nodes[key], key })), - }} - /> - - - ); } diff --git a/src/pages/queryBuilder/textEditor/textEditorRow/textEditorRow.css b/src/pages/queryBuilder/textEditor/textEditorRow/textEditorRow.css index b8928316..e2291c49 100644 --- a/src/pages/queryBuilder/textEditor/textEditorRow/textEditorRow.css +++ b/src/pages/queryBuilder/textEditor/textEditorRow/textEditorRow.css @@ -9,4 +9,20 @@ display: flex; flex-direction: column; align-items: flex-start; +} +.editor-row-wrapper { + display: flex; + flex-direction: column; +} + +summary { + display: list-item; + cursor: pointer; +} + +.qualifiers-dropdown { + padding: 1rem 0rem; + display: flex; + flex-direction: column; + gap: 1rem; } \ No newline at end of file diff --git a/src/pages/queryBuilder/useQueryBuilder.js b/src/pages/queryBuilder/useQueryBuilder.js index 2e9e224e..193be0b0 100644 --- a/src/pages/queryBuilder/useQueryBuilder.js +++ b/src/pages/queryBuilder/useQueryBuilder.js @@ -2,6 +2,7 @@ import { useEffect, useContext, useReducer, useMemo, } from 'react'; +import _ from 'lodash'; import AlertContext from '~/context/alert'; import queryBuilderUtils from '~/utils/queryBuilder'; import queryGraphUtils from '~/utils/queryGraph'; @@ -67,6 +68,15 @@ function reducer(state, action) { state.message.message.query_graph.edges[id].predicates = predicates; break; } + case 'editQualifiers': { + const { id, qualifiers } = action.payload; + const qualifier_set = Object.entries(qualifiers).map(([name, value]) => ({ + qualifier_type_id: `biolink:${_.snakeCase(name)}`, + qualifier_value: name === 'qualified predicate' ? `biolink:${_.snakeCase(value)}` : _.snakeCase(value), + })); + state.message.message.query_graph.edges[id].qualifier_constraints = [{ qualifier_set }]; + break; + } case 'deleteEdge': { const { id } = action.payload; delete state.message.message.query_graph.edges[id]; diff --git a/src/stores/useBiolinkModel.js b/src/stores/useBiolinkModel.js index e51b3fb4..704f0002 100644 --- a/src/stores/useBiolinkModel.js +++ b/src/stores/useBiolinkModel.js @@ -296,7 +296,7 @@ export default function useBiolinkModel() { } } - const m = { + setModel({ classes: { treeRootNodes: rootItems, lookup, @@ -308,9 +308,7 @@ export default function useBiolinkModel() { associations: lookup.get("association"), qualifiers: slotLookup.get("qualifier"), enums: biolinkModel.enums, - }; - console.log(m); - setModel(m); + }); } }, [biolinkModel]); From 4a25f97b69806ddd4e0e3e8f7280ac9b51e06f8d Mon Sep 17 00:00:00 2001 From: David Glymph Date: Tue, 23 Jul 2024 16:18:58 -0400 Subject: [PATCH 3/7] allow broader selections in the node picker to still select associations --- .../textEditorRow/QualifiersSelector.jsx | 19 ++++++++------ .../textEditorRow/TextEditorRow.jsx | 26 ++++++++++++++++--- src/pages/queryBuilder/useQueryBuilder.js | 12 +++++---- 3 files changed, 41 insertions(+), 16 deletions(-) diff --git a/src/pages/queryBuilder/textEditor/textEditorRow/QualifiersSelector.jsx b/src/pages/queryBuilder/textEditor/textEditorRow/QualifiersSelector.jsx index 4a3af6fd..13330041 100644 --- a/src/pages/queryBuilder/textEditor/textEditorRow/QualifiersSelector.jsx +++ b/src/pages/queryBuilder/textEditor/textEditorRow/QualifiersSelector.jsx @@ -48,14 +48,16 @@ const getQualifierOptions = ({ range, subpropertyOf }) => { export default function QualifiersSelector({ id, associations }) { const queryBuilder = useContext(QueryBuilderContext); - const associationOptions = associations.map(({ association, qualifiers }) => ({ - name: association.name, - uuid: association.uuid, - qualifiers: qualifiers.map((q) => ({ - name: q.qualifier.name, - options: getQualifierOptions(q), - })), - })); + const associationOptions = associations + .filter((a) => a.qualifiers.length > 0) + .map(({ association, qualifiers }) => ({ + name: association.name, + uuid: association.uuid, + qualifiers: qualifiers.map((q) => ({ + name: q.qualifier.name, + options: getQualifierOptions(q), + })), + })); const [value, setValue] = React.useState(associationOptions[0] || null); const [qualifiers, setQualifiers] = React.useState({}); @@ -75,6 +77,7 @@ export default function QualifiersSelector({ id, associations }) { onChange={(_, newValue) => { setValue(newValue); }} + disableClearable size="small" options={associationOptions} getOptionLabel={(option) => option.name} diff --git a/src/pages/queryBuilder/textEditor/textEditorRow/TextEditorRow.jsx b/src/pages/queryBuilder/textEditor/textEditorRow/TextEditorRow.jsx index 5243c04d..b65a4a38 100644 --- a/src/pages/queryBuilder/textEditor/textEditorRow/TextEditorRow.jsx +++ b/src/pages/queryBuilder/textEditor/textEditorRow/TextEditorRow.jsx @@ -37,6 +37,26 @@ function getValidAssociations(s, p, o, model) { return traverse([n], range); }; + // Returns true if `n` is an ancestor of `domain` + const isInDomain = ( + n, + domain + ) => { + const traverse = (nodes, search) => { + for (const n of nodes) { + if (n === search) return true; + if (n.parent) { + if (traverse([n.parent], search)) return true; + } + if (n.mixinParents) { + if (traverse(n.mixinParents, search)) return true; + } + } + return false; + }; + return traverse([domain], n); + }; + /** * Get the inherited subject/predicate/object ranges for an association */ @@ -78,9 +98,9 @@ function getValidAssociations(s, p, o, model) { if (association.slotUsage && !association.abstract) { const inherited = getInheritedSPORanges(association); - const validSubject = isInRange(subject, inherited.subject); - const validObject = isInRange(object, inherited.object); - const validPredicate = isInRange(predicate, inherited.predicate); + const validSubject = isInRange(subject, inherited.subject) || isInDomain(subject, inherited.subject); + const validObject = isInRange(object, inherited.object) || isInDomain(object, inherited.object); + const validPredicate = isInRange(predicate, inherited.predicate) || isInDomain(predicate, inherited.predicate); const qualifiers = Object.entries(association.slotUsage) .map(([qualifierName, properties]) => { diff --git a/src/pages/queryBuilder/useQueryBuilder.js b/src/pages/queryBuilder/useQueryBuilder.js index 193be0b0..40da4efd 100644 --- a/src/pages/queryBuilder/useQueryBuilder.js +++ b/src/pages/queryBuilder/useQueryBuilder.js @@ -70,11 +70,13 @@ function reducer(state, action) { } case 'editQualifiers': { const { id, qualifiers } = action.payload; - const qualifier_set = Object.entries(qualifiers).map(([name, value]) => ({ - qualifier_type_id: `biolink:${_.snakeCase(name)}`, - qualifier_value: name === 'qualified predicate' ? `biolink:${_.snakeCase(value)}` : _.snakeCase(value), - })); - state.message.message.query_graph.edges[id].qualifier_constraints = [{ qualifier_set }]; + if (qualifiers.length !== 0) { + const qualifier_set = Object.entries(qualifiers).map(([name, value]) => ({ + qualifier_type_id: `biolink:${_.snakeCase(name)}`, + qualifier_value: name === 'qualified predicate' ? `biolink:${_.snakeCase(value)}` : _.snakeCase(value), + })); + state.message.message.query_graph.edges[id].qualifier_constraints = [{ qualifier_set }]; + } break; } case 'deleteEdge': { From 86d3033ccb11f690cc0075c0549088c896731cb0 Mon Sep 17 00:00:00 2001 From: David Glymph Date: Tue, 24 Sep 2024 10:36:06 -0400 Subject: [PATCH 4/7] fix eslint errors --- .../textEditorRow/TextEditorRow.jsx | 68 +++++++++---------- .../textEditorRow/textEditorRow.css | 6 ++ 2 files changed, 40 insertions(+), 34 deletions(-) diff --git a/src/pages/queryBuilder/textEditor/textEditorRow/TextEditorRow.jsx b/src/pages/queryBuilder/textEditor/textEditorRow/TextEditorRow.jsx index b65a4a38..83dd545c 100644 --- a/src/pages/queryBuilder/textEditor/textEditorRow/TextEditorRow.jsx +++ b/src/pages/queryBuilder/textEditor/textEditorRow/TextEditorRow.jsx @@ -1,3 +1,4 @@ +/* eslint-disable no-restricted-syntax */ import React, { useContext } from 'react'; import IconButton from '@material-ui/core/IconButton'; import AddBoxOutlinedIcon from '@material-ui/icons/AddBoxOutlined'; @@ -20,16 +21,16 @@ function getValidAssociations(s, p, o, model) { const isInRange = ( n, - range + range, ) => { const traverse = (nodes, search) => { - for (const n of nodes) { - if (n === search) return true; - if (n.parent) { - if (traverse([n.parent], search)) return true; + for (const node of nodes) { + if (node === search) return true; + if (node.parent) { + if (traverse([node.parent], search)) return true; } - if (n.mixinParents) { - if (traverse(n.mixinParents, search)) return true; + if (node.mixinParents) { + if (traverse(node.mixinParents, search)) return true; } } return false; @@ -40,16 +41,16 @@ function getValidAssociations(s, p, o, model) { // Returns true if `n` is an ancestor of `domain` const isInDomain = ( n, - domain + domain, ) => { const traverse = (nodes, search) => { - for (const n of nodes) { - if (n === search) return true; - if (n.parent) { - if (traverse([n.parent], search)) return true; + for (const node of nodes) { + if (node === search) return true; + if (node.parent) { + if (traverse([node.parent], search)) return true; } - if (n.mixinParents) { - if (traverse(n.mixinParents, search)) return true; + if (node.mixinParents) { + if (traverse(node.mixinParents, search)) return true; } } return false; @@ -61,17 +62,17 @@ function getValidAssociations(s, p, o, model) { * Get the inherited subject/predicate/object ranges for an association */ const getInheritedSPORanges = ( - association + association, ) => { - const namedThing = model.classes.lookup.get("named thing"); - const relatedTo = model.slots.lookup.get("related to"); + const namedThing = model.classes.lookup.get('named thing'); + const relatedTo = model.slots.lookup.get('related to'); const traverse = ( nodes, - part + part, ) => { for (const node of nodes) { - if (node.slotUsage?.[part]) return node.slotUsage[part]; + if (node.slotUsage && node.slotUsage[part]) return node.slotUsage[part]; if (node.parent) { const discoveredType = traverse([node.parent], part); if (discoveredType !== null) return discoveredType; @@ -85,11 +86,11 @@ function getValidAssociations(s, p, o, model) { return null; }; - const subject = traverse([association], "subject") ?? namedThing; - const predicate = traverse([association], "predicate") ?? relatedTo; - const object = traverse([association], "object") ?? namedThing; + const sub = traverse([association], 'subject') || namedThing; + const pred = traverse([association], 'predicate') || relatedTo; + const obj = traverse([association], 'object') || namedThing; - return { subject, predicate, object }; + return { subject: sub, predicate: pred, object: obj }; }; // DFS over associations @@ -106,10 +107,9 @@ function getValidAssociations(s, p, o, model) { .map(([qualifierName, properties]) => { if (properties === null) return null; const qualifier = model.slots.lookup.get(qualifierName); - if (!qualifier || !isInRange(qualifier, model.qualifiers)) - return null; + if (!qualifier || !isInRange(qualifier, model.qualifiers)) return null; - let range = undefined; + let range; if (properties.range) { const potentialEnum = model.enums[properties.range]; @@ -120,13 +120,13 @@ function getValidAssociations(s, p, o, model) { if (potentialClassNode) range = potentialClassNode; } - let subpropertyOf = undefined; + let subpropertyOf; if ( properties.subproperty_of && model.slots.lookup.has(properties.subproperty_of) ) { subpropertyOf = model.slots.lookup.get( - properties.subproperty_of + properties.subproperty_of, ); } @@ -160,20 +160,20 @@ function getValidAssociations(s, p, o, model) { export default function TextEditorRow({ row, index }) { const queryBuilder = useContext(QueryBuilderContext); const { model } = useContext(BiolinkContext); - if (!model) return "Loading..."; + if (!model) return 'Loading...'; const { query_graph } = queryBuilder; const edge = query_graph.edges[row.edgeId]; const { edgeId, subjectIsReference, objectIsReference } = row; - const subject = (query_graph.nodes[edge.subject].categories?.[0] ?? 'biolink:NamedThing') + const subject = ((query_graph.nodes[edge.subject].categories && query_graph.nodes[edge.subject].categories[0]) || 'biolink:NamedThing') .replace('biolink:', '') .match(/[A-Z][a-z]+/g) .join(' ') .toLowerCase(); - const predicate = (edge.predicates?.[0] ?? 'biolink:related_to') + const predicate = ((query_graph.nodes[edge.subject].categories && edge.predicates[0]) || 'biolink:related_to') .replace('biolink:', '') .replace(/_/g, ' '); - const object = (query_graph.nodes[edge.object].categories?.[0] ?? 'biolink:NamedThing') + const object = ((query_graph.nodes[edge.object].categories && query_graph.nodes[edge.object].categories[0]) || 'biolink:NamedThing') .replace('biolink:', '') .match(/[A-Z][a-z]+/g) .join(' ') @@ -207,7 +207,7 @@ export default function TextEditorRow({ row, index }) { return (
- + Date: Tue, 24 Sep 2024 12:25:24 -0400 Subject: [PATCH 5/7] restyle --- src/pages/queryBuilder/queryBuilder.css | 130 +++++++++--------- .../textEditorRow/QualifiersSelector.jsx | 87 ++++++++---- .../textEditorRow/TextEditorRow.jsx | 40 +++++- .../textEditorRow/textEditorRow.css | 46 ++++++- 4 files changed, 199 insertions(+), 104 deletions(-) diff --git a/src/pages/queryBuilder/queryBuilder.css b/src/pages/queryBuilder/queryBuilder.css index 644156c3..5af754e4 100644 --- a/src/pages/queryBuilder/queryBuilder.css +++ b/src/pages/queryBuilder/queryBuilder.css @@ -1,66 +1,66 @@ -#queryBuilderContainer > button { - margin-left: 10px; -} -#queryEditorContainer { - display: flex; -} -#queryTextEditor { - width: 60%; - padding: 20px; -} -#queryTextEditor > * { - padding: 12px; -} -#queryGraphEditor { - width: 40%; - padding: 20px; -} -.textEditorRow { - display: flex; - align-items: center; - justify-content: space-between; -} -.textEditorRow > p { - margin: 0; - font-size: 16px; -} -.textEditorIconButton > span > svg { - font-size: 40px; - color: #b2b0b0; -} -.textEditorIconButton:disabled > span > svg { - color: #e0e0e0; -} - -#jsonEditorTitle { - display: flex; - justify-content: space-between; - align-items: center; - padding: 5px; -} -#uploadIconLabel { - margin-bottom: 0; -} - -#queryBuilderButtons { - width: 100%; - padding: 0 20px; - display: flex; - gap: 16px; -} - -@media (max-width: 1450px) { - #queryEditorContainer { - flex-direction: column; - align-items: center; - } - #queryGraphEditor { - width: 100%; - } - #graphContainer { - margin: auto; - } - #queryTextEditor { - width: 100%; - } +#queryBuilderContainer > button { + margin-left: 10px; +} +#queryEditorContainer { + display: flex; +} +#queryTextEditor { + width: 60%; + padding: 20px; + display: flex; + flex-direction: column; + gap: 30px; +} +#queryGraphEditor { + width: 40%; + padding: 20px; +} +.textEditorRow { + display: flex; + align-items: center; + justify-content: space-between; +} +.textEditorRow > p { + margin: 0; + font-size: 16px; +} +.textEditorIconButton > span > svg { + font-size: 40px; + color: #b2b0b0; +} +.textEditorIconButton:disabled > span > svg { + color: #e0e0e0; +} + +#jsonEditorTitle { + display: flex; + justify-content: space-between; + align-items: center; + padding: 5px; +} +#uploadIconLabel { + margin-bottom: 0; +} + +#queryBuilderButtons { + width: 100%; + padding: 0 20px; + display: flex; + gap: 16px; +} + +@media (max-width: 1450px) { + #queryEditorContainer { + flex-direction: column; + align-items: center; + } + #queryGraphEditor { + width: 100%; + } + #graphContainer { + margin: auto; + } + #queryTextEditor { + width: 100%; + } } \ No newline at end of file diff --git a/src/pages/queryBuilder/textEditor/textEditorRow/QualifiersSelector.jsx b/src/pages/queryBuilder/textEditor/textEditorRow/QualifiersSelector.jsx index 13330041..262c7d88 100644 --- a/src/pages/queryBuilder/textEditor/textEditorRow/QualifiersSelector.jsx +++ b/src/pages/queryBuilder/textEditor/textEditorRow/QualifiersSelector.jsx @@ -68,10 +68,18 @@ export default function QualifiersSelector({ id, associations }) { if (associationOptions.length === 0) return null; if (associationOptions.length === 1 && associationOptions[0].name === 'association') return null; + const subjectQualfiers = value.qualifiers.filter(({ name }) => name.includes('subject')); + const predicateQualifiers = value.qualifiers.filter(({ name }) => name.includes('predicate')); + const objectQualifiers = value.qualifiers.filter(({ name }) => name.includes('object')); + const otherQualifiers = value.qualifiers.filter((q) => ( + !subjectQualfiers.includes(q) && + !predicateQualifiers.includes(q) && + !objectQualifiers.includes(q) + )); + return ( -
- Qualifiers -
+
+
{ @@ -86,31 +94,56 @@ export default function QualifiersSelector({ id, associations }) { renderInput={(params) => } /> -
- - { - value.qualifiers.map(({ name, options }) => ( - { - if (newValue === null) { - setQualifiers((prev) => { - const next = { ...prev }; - delete next[name]; - return next; - }); - } else { setQualifiers((prev) => ({ ...prev, [name]: newValue || null })); } - }} - options={options} - style={{ width: 300 }} - renderInput={(params) => } - size="small" - /> - )) - } + {otherQualifiers.length > 0 &&
} + +
-
+ + + +
+ ); +} + +function QualifiersList({ value, qualifiers, setQualifiers }) { + if (value.length === 0) return null; + return ( +
+ {value.map(({ name, options }) => ( + { + if (newValue === null) { + setQualifiers((prev) => { + const next = { ...prev }; + delete next[name]; + return next; + }); + } else { setQualifiers((prev) => ({ ...prev, [name]: newValue || null })); } + }} + options={options} + renderInput={(params) => } + size="small" + /> + ))} +
); } diff --git a/src/pages/queryBuilder/textEditor/textEditorRow/TextEditorRow.jsx b/src/pages/queryBuilder/textEditor/textEditorRow/TextEditorRow.jsx index 83dd545c..f5fe8786 100644 --- a/src/pages/queryBuilder/textEditor/textEditorRow/TextEditorRow.jsx +++ b/src/pages/queryBuilder/textEditor/textEditorRow/TextEditorRow.jsx @@ -1,9 +1,10 @@ /* eslint-disable no-restricted-syntax */ -import React, { useContext } from 'react'; +import React, { useContext, useState } from 'react'; import IconButton from '@material-ui/core/IconButton'; import AddBoxOutlinedIcon from '@material-ui/icons/AddBoxOutlined'; import IndeterminateCheckBoxOutlinedIcon from '@material-ui/icons/IndeterminateCheckBoxOutlined'; +import { Collapse } from '@material-ui/core'; import BiolinkContext from '~/context/biolink'; import QueryBuilderContext from '~/context/queryBuilder'; import NodeSelector from './NodeSelector'; @@ -160,9 +161,14 @@ function getValidAssociations(s, p, o, model) { export default function TextEditorRow({ row, index }) { const queryBuilder = useContext(QueryBuilderContext); const { model } = useContext(BiolinkContext); + const [isOpen, setIsOpen] = useState(false); if (!model) return 'Loading...'; const { query_graph } = queryBuilder; const edge = query_graph.edges[row.edgeId]; + const hasQualifiers = Array.isArray(edge.qualifier_constraints) && + edge.qualifier_constraints.length > 0 && + Array.isArray(edge.qualifier_constraints[0].qualifier_set) && + edge.qualifier_constraints[0].qualifier_set.length > 0; const { edgeId, subjectIsReference, objectIsReference } = row; const subject = ((query_graph.nodes[edge.subject].categories && query_graph.nodes[edge.subject].categories[0]) || 'biolink:NamedThing') @@ -271,10 +277,34 @@ export default function TextEditorRow({ row, index }) { - + +
+ +
+
+ + ); } diff --git a/src/pages/queryBuilder/textEditor/textEditorRow/textEditorRow.css b/src/pages/queryBuilder/textEditor/textEditorRow/textEditorRow.css index 8957ab63..04901f23 100644 --- a/src/pages/queryBuilder/textEditor/textEditorRow/textEditorRow.css +++ b/src/pages/queryBuilder/textEditor/textEditorRow/textEditorRow.css @@ -10,25 +10,57 @@ flex-direction: column; align-items: flex-start; } -.editor-row-wrapper { - display: flex; - flex-direction: column; -} summary { display: list-item; cursor: pointer; } +.qualifiers-wrapper { + padding: 2.5rem; + border-top: 1px solid #e0e0e0; +} + .qualifiers-dropdown { - padding: 1rem 0rem; + display: flex; + flex-direction: row; + gap: 1rem; +} + +.qualifiers-list { + flex: 1; display: flex; flex-direction: column; gap: 1rem; } -.textEditorRow { +.editor-row-wrapper { + display: flex; + flex-direction: column; background-color: #f9f9f9; - border: 1px solid #f0f0f0; + box-shadow: 0px 0px 5px #00000031; + border: 1px solid #e0e0e0; border-radius: 16px; + overflow: hidden; +} + +.textEditorRow { + padding: 12px; +} + +.dropdown-toggle { + border: none; + background-color: #efefef; + padding: 4px; + border-radius: 0px 0px 16px 16px; +} +.dropdown-toggle:hover { + background-color: #e6e6e6; +} +.dropdown-toggle:active { + background-color: #e0e0e0; +} +.dropdown-toggle:focus-visible { + outline: 2px solid blue; + outline-offset: -2px; } \ No newline at end of file From 9a7424c29f937a2d95a7ec35e03af5e673030be7 Mon Sep 17 00:00:00 2001 From: David Glymph Date: Tue, 24 Sep 2024 12:36:41 -0400 Subject: [PATCH 6/7] add files to eslint ignore, using modern js syntax --- .eslintrc.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.eslintrc.js b/.eslintrc.js index fca24b0c..98f86de7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -24,6 +24,10 @@ module.exports = { overrides: [{ files: ['*.jsx', '*.js'], }], + ignorePatterns: [ + 'useBiolinkModel.js', + 'App.jsx', + ], rules: { indent: ['error', 2, { SwitchCase: 1 }], 'max-len': 'off', From e5397bdd3d1629dd6085c56f1c43c6a9bb553ec7 Mon Sep 17 00:00:00 2001 From: David Glymph Date: Tue, 24 Sep 2024 12:38:00 -0400 Subject: [PATCH 7/7] revert font size --- .../queryBuilder/textEditor/textEditorRow/TextEditorRow.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/queryBuilder/textEditor/textEditorRow/TextEditorRow.jsx b/src/pages/queryBuilder/textEditor/textEditorRow/TextEditorRow.jsx index f5fe8786..bfc52c23 100644 --- a/src/pages/queryBuilder/textEditor/textEditorRow/TextEditorRow.jsx +++ b/src/pages/queryBuilder/textEditor/textEditorRow/TextEditorRow.jsx @@ -290,7 +290,7 @@ export default function TextEditorRow({ row, index }) { type="button" className="dropdown-toggle" onClick={() => { setIsOpen((p) => !p); }} - style={{ fontSize: '0.9em', color: '#333' }} + style={{ color: '#333' }} > {isOpen ? '▲' : '▼'}