From 62f0009191b8374db36d981740b40ef919202c16 Mon Sep 17 00:00:00 2001 From: Ensar Emir Erol Date: Mon, 16 Dec 2024 20:30:40 +0100 Subject: [PATCH] mapping screen --- .../GroupedSelectRenderer/index.tsx | 80 ++++++ app/src/lib/api/mapping_service/types.ts | 6 +- app/src/lib/api/ontology_api/types.ts | 12 +- .../components/MainPanel/index.tsx | 3 +- .../SidePanel/components/NodeProperties.tsx | 236 ++++++++++++++++++ .../components/SidePanel/index.tsx | 3 +- .../mapping_page/hooks/useClassOrderer.ts | 97 +++++++ app/src/pages/mapping_page/state.ts | 22 +- app/src/pages/mapping_page/styles.scss | 5 + server/models/mapping.py | 29 ++- 10 files changed, 478 insertions(+), 15 deletions(-) create mode 100644 app/src/components/GroupedSelectRenderer/index.tsx create mode 100644 app/src/pages/mapping_page/components/SidePanel/components/NodeProperties.tsx create mode 100644 app/src/pages/mapping_page/hooks/useClassOrderer.ts diff --git a/app/src/components/GroupedSelectRenderer/index.tsx b/app/src/components/GroupedSelectRenderer/index.tsx new file mode 100644 index 0000000..0ed8401 --- /dev/null +++ b/app/src/components/GroupedSelectRenderer/index.tsx @@ -0,0 +1,80 @@ +import { MenuDivider } from '@blueprintjs/core'; +import { ItemListRendererProps } from '@blueprintjs/select'; +import React from 'react'; + +type GroupedSelectRendererProps = { + listProps: ItemListRendererProps; + initialContent?: JSX.Element; + noResults?: JSX.Element; + getGroup: (item: T) => string; + createFirst?: boolean; +}; + +const GroupedSelectRenderer = ({ + listProps, + initialContent, + noResults, + getGroup, + createFirst = true, +}: GroupedSelectRendererProps) => { + const createItemView = listProps.renderCreateItem(); + const menuContent = _GroupedMenuContent( + listProps, + initialContent, + noResults, + getGroup, + ); + + if (menuContent === null && createItemView === null) { + return null; + } + + return ( +
+ {createFirst && createItemView} + {menuContent} + {!createFirst && createItemView} +
+ ); +}; + +const _GroupedMenuContent = ( + props: ItemListRendererProps, + initialContent: JSX.Element | undefined, + noResults: JSX.Element | undefined, + getGroup: (item: T) => string, +) => { + if (props.filteredItems.length === 0 && initialContent) { + return initialContent; + } + + const groupedItems = props.filteredItems.reduce< + Array<{ group: string; index: number; items: T[]; key: number }> + >((acc, item, index) => { + const group = getGroup(item); + const groupIndex = acc.findIndex(g => g.group === group); + if (groupIndex === -1) { + acc.push({ group, index, items: [item], key: index }); + } else { + acc[groupIndex].items.push(item); + } + return acc; + }, []); + + const menuContent = groupedItems.map(groupedItem => ( + + + {groupedItem.items.map((item, index) => + props.renderItem(item, groupedItem.index + index), + )} + + )); + + return props.filteredItems.length === 0 ? noResults : menuContent; +}; + +export default GroupedSelectRenderer; diff --git a/app/src/lib/api/mapping_service/types.ts b/app/src/lib/api/mapping_service/types.ts index c9c78f7..db8187e 100644 --- a/app/src/lib/api/mapping_service/types.ts +++ b/app/src/lib/api/mapping_service/types.ts @@ -1,3 +1,5 @@ +import { Property } from '../ontology_api/types'; + export type MappingNodeType = 'entity' | 'literal' | 'uri_ref'; export type MappingNode = { @@ -6,6 +8,7 @@ export type MappingNode = { label: string; uri_pattern: string; rdf_type: string[]; + properties: Property[]; }; export type MappingLiteral = { @@ -26,7 +29,8 @@ export type MappingEdge = { id: string; source: string; target: string; - predicate_uri: string; + source_handle: string; + target_handle: string; }; export type MappingGraph = { diff --git a/app/src/lib/api/ontology_api/types.ts b/app/src/lib/api/ontology_api/types.ts index e131b92..b83437d 100644 --- a/app/src/lib/api/ontology_api/types.ts +++ b/app/src/lib/api/ontology_api/types.ts @@ -23,12 +23,12 @@ interface NamedNode { /** * A named node is a node that has a URI. */ - belongsTo: string; + belongs_to: string; type: NamedNodeType; - fullUri: string; + full_uri: string; label: Literal[]; description: Literal[]; - isDeprecated?: boolean; + is_deprecated?: boolean; } interface Individual extends NamedNode { @@ -42,7 +42,7 @@ interface OntologyClass extends NamedNode { /** * A class is a named node that is a class. */ - superClasses: string[]; + super_classes: string[]; type: NamedNodeType.CLASS; } @@ -50,7 +50,7 @@ interface Property extends NamedNode { /** * A property is a named node that is a property. */ - propertyType: PropertyType; + property_type: PropertyType; range: string[]; domain: string[]; type: NamedNodeType.PROPERTY; @@ -61,7 +61,7 @@ interface Ontology { * An ontology is a collection of named nodes. */ uuid: string; - fileUuid: string; + file_uuid: string; name: string; description: string; base_uri: string; diff --git a/app/src/pages/mapping_page/components/MainPanel/index.tsx b/app/src/pages/mapping_page/components/MainPanel/index.tsx index 3765660..add49d9 100644 --- a/app/src/pages/mapping_page/components/MainPanel/index.tsx +++ b/app/src/pages/mapping_page/components/MainPanel/index.tsx @@ -47,8 +47,9 @@ const MainPanel = ({ initialGraph }: MainPanelProps) => { data: { id: `node-${nodes.length}`, label: 'New Entity', - rdf_type: [''], + rdf_type: [], uri_pattern: '', + properties: [], type: 'entity', }, width: 200, diff --git a/app/src/pages/mapping_page/components/SidePanel/components/NodeProperties.tsx b/app/src/pages/mapping_page/components/SidePanel/components/NodeProperties.tsx new file mode 100644 index 0000000..830c057 --- /dev/null +++ b/app/src/pages/mapping_page/components/SidePanel/components/NodeProperties.tsx @@ -0,0 +1,236 @@ +import { + FormGroup, + InputGroup, + MenuItem, + NonIdealState, +} from '@blueprintjs/core'; +import { ItemListRendererProps, MultiSelect } from '@blueprintjs/select'; +import { useNodes, useReactFlow } from '@xyflow/react'; +import { useEffect, useMemo, useState } from 'react'; +import GroupedSelectRenderer from '../../../../../components/GroupedSelectRenderer'; +import toast from '../../../../../consts/toast'; +import { OntologyClass } from '../../../../../lib/api/ontology_api/types'; +import useClassOrderer from '../../../hooks/useClassOrderer'; +import useMappingPage from '../../../state'; +import { EntityNodeType, XYNodeTypes } from '../../MainPanel/types'; + +const NodeProperties = () => { + const nodes = useNodes(); + + const selectedNodes = useMemo(() => { + return nodes.filter(node => node.selected); + }, [nodes]); + + const [selectedNode, setSelectedNode] = useState(null); + + useEffect(() => { + if (selectedNodes.length === 1) { + setSelectedNode(selectedNodes[0]); + } else { + setSelectedNode(null); + } + }, [selectedNodes]); + + if (!selectedNode) { + return ( + + ); + } + + if (selectedNode.data.type === 'entity') { + return ; + } +}; + +const EntityNodePropertiesForm = ({ node }: { node: EntityNodeType }) => { + const ontologies = useMappingPage(state => state.ontologies); + const reactflow = useReactFlow(); + const [label, setLabel] = useState(node.data.label); + const [uriPattern, setUriPattern] = useState(node.data.uri_pattern); + const [rdfType, setRdfType] = useState<(OntologyClass & { group: string })[]>( + [], + ); + // Update node_data when fields change + useEffect(() => { + reactflow.setNodes(nodes => + nodes.map(n => { + if (n.id === node.id) { + return { + ...n, + data: { + ...n.data, + label, + uri_pattern: uriPattern, + rdf_type: rdfType.map(c => c.full_uri), + }, + }; + } + return n; + }), + ); + }, [label, uriPattern, rdfType, node.id, reactflow]); + + useEffect(() => { + if (ontologies) { + const rdfTypes = node.data.rdf_type + .map( + uri => + ontologies.flatMap(o => o.classes).find(c => c.full_uri === uri) || + ({ + full_uri: uri, + label: [{ value: uri }], + belongs_to: '', + super_classes: [], + type: 'class', + group: 'Create', + description: [], + is_deprecated: false, + } as OntologyClass & { group: string }), + ) + .map(c => ({ + ...c, + group: c.belongs_to, + })); + setRdfType(rdfTypes); + } + // This effect should only run when the ontologies or the node id changes + // Otherwise, it will run every time the label, uriPattern or rdfType changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ontologies, node.id]); + + const possibleClasses = useClassOrderer(node); + + const createNewItemFromQuery = (query: string) => { + return { + full_uri: query, + label: [{ value: query }], + belongs_to: '', + super_classes: [], + type: 'class', + group: 'Create', + description: [], + is_deprecated: false, + } as OntologyClass & { group: string }; + }; + + const createNewItemRenderer = ( + query: string, + active: boolean, + handleClick: React.MouseEventHandler, + ) => ( + ) => { + try { + new URL(query); + } catch { + toast.show({ + message: 'Invalid URI, please enter a valid URI', + intent: 'danger', + }); + return; + } + handleClick(e); + }} + active={active} + /> + ); + + const itemListRenderer = ( + props: ItemListRendererProps, + ) => { + return ( + + listProps={props} + initialContent={} + noResults={} + getGroup={item => item.group} + /> + ); + }; + + const tagRenderer = (item: OntologyClass & { group: string }) => { + const ontology = ontologies?.find(o => o.base_uri === item.belongs_to); + if (ontology) { + return `${ontology.name}:${item.label[0].value}`; + } + return item.label[0].value; + }; + + return ( + <> + + ) => + setLabel(e.target.value) + } + /> + + + + fill + popoverProps={{ + matchTargetWidth: true, + }} + onRemove={item => setRdfType(rdfType.filter(c => c !== item))} + itemRenderer={(item, { handleClick, modifiers }) => ( + + )} + itemPredicate={(query, item) => + item.label[0].value.toLowerCase().includes(query.toLowerCase()) + } + items={possibleClasses.classes.filter( + c => !rdfType.some(r => r.full_uri === c.full_uri), + )} + onItemSelect={item => { + try { + new URL(item.full_uri); + } catch { + toast.show({ + message: 'Invalid URI, please enter a valid URI', + intent: 'danger', + }); + return; + } + if (!rdfType.some(c => c.full_uri === item.full_uri)) { + setRdfType([...rdfType, item]); + } + }} + selectedItems={rdfType} + itemListRenderer={itemListRenderer} + tagRenderer={tagRenderer} + createNewItemFromQuery={createNewItemFromQuery} + createNewItemRenderer={createNewItemRenderer} + itemsEqual={(a, b) => a.full_uri === b.full_uri} + resetOnQuery + resetOnSelect + /> + + + ) => + setUriPattern(e.target.value) + } + /> + + + ); +}; + +export default NodeProperties; diff --git a/app/src/pages/mapping_page/components/SidePanel/index.tsx b/app/src/pages/mapping_page/components/SidePanel/index.tsx index 7e35063..10b1749 100644 --- a/app/src/pages/mapping_page/components/SidePanel/index.tsx +++ b/app/src/pages/mapping_page/components/SidePanel/index.tsx @@ -1,4 +1,5 @@ import { Card } from '@blueprintjs/core'; +import NodeProperties from './components/NodeProperties'; type SidePanelProps = { selectedTab: string | undefined; @@ -8,7 +9,7 @@ const SidePanel = ({ selectedTab }: SidePanelProps) => { const panelContent = () => { switch (selectedTab) { case 'properties': - return
Properties Panel Content
; + return ; case 'ai': return
AI Panel Content
; case 'references': diff --git a/app/src/pages/mapping_page/hooks/useClassOrderer.ts b/app/src/pages/mapping_page/hooks/useClassOrderer.ts new file mode 100644 index 0000000..3a959e7 --- /dev/null +++ b/app/src/pages/mapping_page/hooks/useClassOrderer.ts @@ -0,0 +1,97 @@ +import { getConnectedEdges, useEdges } from '@xyflow/react'; +import { useEffect, useState } from 'react'; +import { OntologyClass } from '../../../lib/api/ontology_api/types'; +import { EntityNodeType, XYEdgeType } from '../components/MainPanel/types'; +import useMappingPage from '../state'; + +/** + * Custom hook to order ontology classes based on the properties of a given node. + * + * @param node - The entity node for which the classes are to be ordered. + * @returns An object containing the ordered classes. + * + * The hook performs the following steps: + * 1. Initializes state for storing classes. + * 2. Retrieves edges and ontologies from the mapping page state. + * 3. Uses a `useEffect` hook to update the classes whenever the edges, ontologies, or node change. + * 4. If there are no ontologies, it resets the classes state. + * 5. Flattens all classes and properties from the ontologies. + * 6. Retrieves outgoing properties from the node and incoming properties from connected edges. + * 7. If there are no outgoing or incoming properties, it sets the classes state to include all classes grouped by ontology. + * 8. Filters classes based on whether they apply to the outgoing and incoming properties. + * 9. Identifies the best fit classes that apply to both outgoing and incoming properties. + * 10. Groups the remaining classes by their respective ontologies. + * 11. Updates the classes state with the ordered classes. + */ +export default function useClassOrderer(node: EntityNodeType) { + const [classes, setClasses] = useState<(OntologyClass & { group: string })[]>( + [], + ); + const edges = useEdges(); + const ontologies = useMappingPage(state => state.ontologies); + + useEffect(() => { + if (!ontologies || ontologies.length === 0) { + setClasses([]); + return; + } + + const allClasses = ontologies.flatMap(ontology => ontology.classes); + const allProperties = ontologies.flatMap(ontology => ontology.properties); + + const outgoingProperties = node.data.properties; + const incomingProperties = getConnectedEdges([node], edges) + .filter(edge => edge.target === node.id) + .map(edge => allProperties.find(p => p.full_uri === edge.sourceHandle)) + .filter((p): p is (typeof allProperties)[number] => p !== undefined); + + if (outgoingProperties.length === 0 && incomingProperties.length === 0) { + const classesArr = ontologies.reduce( + (acc, ontology) => { + acc.push( + ...ontology.classes.map(c => ({ ...c, group: ontology.name })), + ); + return acc; + }, + [] as (OntologyClass & { group: string })[], + ); + setClasses(classesArr); + return; + } + + const classesArr = [] as (OntologyClass & { group: string })[]; + + const filterClasses = ( + properties: typeof allProperties, + key: 'domain' | 'range', + ) => + allClasses.filter(c => + properties.every(p => + p[key].some(d => [c.full_uri, ...c.super_classes].includes(d)), + ), + ); + + const classesApplyToOutgoing = filterClasses(outgoingProperties, 'domain'); + const classesApplyToIncoming = filterClasses(incomingProperties, 'range'); + + const bestFitClasses = classesApplyToOutgoing.filter(c => + classesApplyToIncoming.includes(c), + ); + classesArr.push(...bestFitClasses.map(c => ({ ...c, group: 'Best Fit' }))); + + allClasses.forEach(c => { + if (!bestFitClasses.includes(c)) { + const ontology = ontologies.find(o => + o.classes.some(oc => oc.full_uri === c.full_uri), + ); + if (ontology) { + classesArr.push({ ...c, group: ontology.name }); + } + } + }); + + setClasses(classesArr); + }, [edges, ontologies, node]); + + return { classes }; +} diff --git a/app/src/pages/mapping_page/state.ts b/app/src/pages/mapping_page/state.ts index 0b96a90..c4e15d8 100644 --- a/app/src/pages/mapping_page/state.ts +++ b/app/src/pages/mapping_page/state.ts @@ -2,10 +2,16 @@ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import MappingService from '../../lib/api/mapping_service'; import { MappingGraph } from '../../lib/api/mapping_service/types'; +import OntologyApi from '../../lib/api/ontology_api'; +import { Ontology } from '../../lib/api/ontology_api/types'; +import PrefixApi from '../../lib/api/prefix_api'; +import { Prefix } from '../../lib/api/prefix_api/types'; import { ZustandActions } from '../../utils/zustand'; interface MappingPageState { mapping: MappingGraph | null; + ontologies: Ontology[] | null; + prefixes: Prefix[] | null; isLoading: string | null; error: string | null; } @@ -21,6 +27,8 @@ interface MappingPageStateActions { const defaultState: MappingPageState = { mapping: null, + ontologies: null, + prefixes: null, isLoading: null, error: null, }; @@ -33,11 +41,21 @@ const functions: ZustandActions = ( async loadMapping(workspaceUuid: string, mappingUuid: string) { set({ isLoading: 'Loading mapping...' }); try { - const mapping = await MappingService.getMappingInWorkspace( + const mapping_promise = MappingService.getMappingInWorkspace( workspaceUuid, mappingUuid, ); - set({ mapping, error: null }); + const ontologies_promise = + OntologyApi.getOntologiesInWorkspace(workspaceUuid); + const prefixes_promise = PrefixApi.getPrefixesInWorkspace(workspaceUuid); + + const [mapping, ontologies, prefixes] = await Promise.all([ + mapping_promise, + ontologies_promise, + prefixes_promise, + ]); + + set({ mapping, ontologies, prefixes, error: null }); } catch (error) { if (error instanceof Error) { set({ error: error.message }); diff --git a/app/src/pages/mapping_page/styles.scss b/app/src/pages/mapping_page/styles.scss index e6a70c9..cc53270 100644 --- a/app/src/pages/mapping_page/styles.scss +++ b/app/src/pages/mapping_page/styles.scss @@ -54,3 +54,8 @@ margin-left: -10px; background: #182026; } + +ul li { + /* or ol li, or a more specific selector */ + list-style: none; +} diff --git a/server/models/mapping.py b/server/models/mapping.py index d6bef44..92fe5a6 100644 --- a/server/models/mapping.py +++ b/server/models/mapping.py @@ -1,6 +1,8 @@ from dataclasses import dataclass from enum import StrEnum +from server.models.ontology import Property + class MappingNodeType(StrEnum): ENTITY = "entity" @@ -19,6 +21,7 @@ class MappingNode: label (str): The label of the node uri_pattern (str): The URI pattern of the node rdf_type (list[str]): The RDF type/s of the node + properties (list[Property]): The properties of the node """ id: str @@ -26,6 +29,7 @@ class MappingNode: label: str uri_pattern: str rdf_type: list[str] + properties: list[Property] def to_dict(self): return { @@ -34,6 +38,9 @@ def to_dict(self): "label": self.label, "uri_pattern": self.uri_pattern, "rdf_type": self.rdf_type, + "properties": [ + prop.to_dict() for prop in self.properties + ], } @classmethod @@ -48,12 +55,18 @@ def from_dict(cls, data): raise ValueError("uri_pattern is required") if "rdf_type" not in data: data["rdf_type"] = [] + if "properties" not in data: + data["properties"] = [] return cls( id=data["id"], type=data["type"], label=data["label"], uri_pattern=data["uri_pattern"], rdf_type=data["rdf_type"], + properties=[ + Property.from_dict(prop) + for prop in data["properties"] + ], ) @@ -152,20 +165,23 @@ class MappingEdge: id (str): The ID of the edge source (str): The ID of the source node target (str): The ID of the target node - predicate_uri (str): The URI of the predicate + source_handle (str): The handle of the source node + target_handle (str): The handle of the target node """ id: str source: str target: str - predicate_uri: str + source_handle: str + target_handle: str def to_dict(self): return { "id": self.id, "source": self.source, "target": self.target, - "predicate_uri": self.predicate_uri, + "source_handle": self.source_handle, + "target_handle": self.target_handle, } @classmethod @@ -178,11 +194,16 @@ def from_dict(cls, data): raise ValueError("target is required") if "predicate_uri" not in data: raise ValueError("predicate_uri is required") + if "source_handle" not in data: + raise ValueError("source_handle is required") + if "target_handle" not in data: + raise ValueError("target_handle is required") return cls( id=data["id"], source=data["source"], target=data["target"], - predicate_uri=data["predicate_uri"], + source_handle=data["source_handle"], + target_handle=data["target_handle"], )