Skip to content

Commit

Permalink
mapping screen
Browse files Browse the repository at this point in the history
  • Loading branch information
ensaremirerol committed Dec 16, 2024
1 parent 123b85c commit 62f0009
Show file tree
Hide file tree
Showing 10 changed files with 478 additions and 15 deletions.
80 changes: 80 additions & 0 deletions app/src/components/GroupedSelectRenderer/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { MenuDivider } from '@blueprintjs/core';
import { ItemListRendererProps } from '@blueprintjs/select';
import React from 'react';

type GroupedSelectRendererProps<T> = {
listProps: ItemListRendererProps<T>;
initialContent?: JSX.Element;
noResults?: JSX.Element;
getGroup: (item: T) => string;
createFirst?: boolean;
};

const GroupedSelectRenderer = <T,>({
listProps,
initialContent,
noResults,
getGroup,
createFirst = true,
}: GroupedSelectRendererProps<T>) => {
const createItemView = listProps.renderCreateItem();
const menuContent = _GroupedMenuContent(
listProps,
initialContent,
noResults,
getGroup,
);

if (menuContent === null && createItemView === null) {
return null;
}

return (
<div
style={{
listStyleType: 'none',
}}
>
{createFirst && createItemView}
{menuContent}
{!createFirst && createItemView}
</div>
);
};

const _GroupedMenuContent = <T,>(
props: ItemListRendererProps<T>,
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 => (
<React.Fragment key={groupedItem.key}>
<MenuDivider title={groupedItem.group} />
{groupedItem.items.map((item, index) =>
props.renderItem(item, groupedItem.index + index),
)}
</React.Fragment>
));

return props.filteredItems.length === 0 ? noResults : menuContent;
};

export default GroupedSelectRenderer;
6 changes: 5 additions & 1 deletion app/src/lib/api/mapping_service/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Property } from '../ontology_api/types';

export type MappingNodeType = 'entity' | 'literal' | 'uri_ref';

export type MappingNode = {
Expand All @@ -6,6 +8,7 @@ export type MappingNode = {
label: string;
uri_pattern: string;
rdf_type: string[];
properties: Property[];
};

export type MappingLiteral = {
Expand All @@ -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 = {
Expand Down
12 changes: 6 additions & 6 deletions app/src/lib/api/ontology_api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -42,15 +42,15 @@ interface OntologyClass extends NamedNode {
/**
* A class is a named node that is a class.
*/
superClasses: string[];
super_classes: string[];
type: NamedNodeType.CLASS;
}

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;
Expand All @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion app/src/pages/mapping_page/components/MainPanel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<XYNodeTypes>();

const selectedNodes = useMemo(() => {
return nodes.filter(node => node.selected);
}, [nodes]);

const [selectedNode, setSelectedNode] = useState<XYNodeTypes | null>(null);

useEffect(() => {
if (selectedNodes.length === 1) {
setSelectedNode(selectedNodes[0]);
} else {
setSelectedNode(null);
}
}, [selectedNodes]);

if (!selectedNode) {
return (
<NonIdealState
icon='graph'
title='Select a node'
description='Select a node to view and edit its properties.'
/>
);
}

if (selectedNode.data.type === 'entity') {
return <EntityNodePropertiesForm node={selectedNode as EntityNodeType} />;
}
};

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<HTMLElement>,
) => (
<MenuItem
key={query}
text={`Create "${query}"`}
onClick={(e: React.MouseEvent<HTMLElement>) => {
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<OntologyClass & { group: string }>,
) => {
return (
<GroupedSelectRenderer<OntologyClass & { group: string }>
listProps={props}
initialContent={<MenuItem disabled text='No classes' />}
noResults={<MenuItem disabled text='No results' />}
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 (
<>
<FormGroup label='Label' labelFor='label'>
<InputGroup
id='label'
value={label}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setLabel(e.target.value)
}
/>
</FormGroup>
<FormGroup label='RDF Types' labelFor='rdfType'>
<MultiSelect<OntologyClass & { group: string }>
fill
popoverProps={{
matchTargetWidth: true,
}}
onRemove={item => setRdfType(rdfType.filter(c => c !== item))}
itemRenderer={(item, { handleClick, modifiers }) => (
<MenuItem
role='menuitem'
key={item.full_uri}
label='Class'
text={item.label[0].value ?? item.full_uri}
onClick={handleClick}
active={modifiers.active}
/>
)}
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
/>
</FormGroup>
<FormGroup label='URI Pattern' labelFor='uriPattern'>
<InputGroup
id='uriPattern'
value={uriPattern}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setUriPattern(e.target.value)
}
/>
</FormGroup>
</>
);
};

export default NodeProperties;
Loading

0 comments on commit 62f0009

Please sign in to comment.