diff --git a/app/package-lock.json b/app/package-lock.json index fafd748..9fb57d3 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -19,6 +19,7 @@ "react-icons": "^5.4.0", "react-resizable-panels": "^2.1.7", "react-router-dom": "7.0.2", + "uuid": "^11.0.3", "zustand": "5.0.2" }, "devDependencies": { @@ -4337,6 +4338,19 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/uuid": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz", + "integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/varint": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", diff --git a/app/package.json b/app/package.json index d6fc441..0547816 100644 --- a/app/package.json +++ b/app/package.json @@ -21,6 +21,7 @@ "react-icons": "^5.4.0", "react-resizable-panels": "^2.1.7", "react-router-dom": "7.0.2", + "uuid": "^11.0.3", "zustand": "5.0.2" }, "devDependencies": { @@ -37,4 +38,4 @@ "typescript-eslint": "8.18.0", "vite": "6.0.3" } -} \ No newline at end of file +} diff --git a/app/src/consts/toast.ts b/app/src/consts/toast.ts index af79630..fde418e 100644 --- a/app/src/consts/toast.ts +++ b/app/src/consts/toast.ts @@ -1,7 +1,14 @@ import { OverlayToaster } from '@blueprintjs/core'; +import { createRoot } from 'react-dom/client'; -const toast = OverlayToaster.create({ - position: 'top', -}); +const toast = await OverlayToaster.createAsync( + { + position: 'top', + }, + { + domRenderer: (toaster, containerElement) => + createRoot(containerElement).render(toaster), + }, +); export default toast; diff --git a/app/src/lib/api/mapping_service/index.ts b/app/src/lib/api/mapping_service/index.ts index 35c234a..8ae4ed0 100644 --- a/app/src/lib/api/mapping_service/index.ts +++ b/app/src/lib/api/mapping_service/index.ts @@ -27,6 +27,27 @@ class MappingService { ); } + public static async getMappingInWorkspace( + workspaceUuid: string, + mappingUuid: string, + ): Promise { + const result = await this.getApiClient().callApi( + `/workspaces/${workspaceUuid}/mapping/${mappingUuid}`, + { + method: 'GET', + parser: data => data as MappingGraph, + }, + ); + + if (result.type === 'success') { + return result.data; + } + + throw new Error( + `Failed to get mapping: ${result.message} (status: ${result.status})`, + ); + } + public static async createMappingInWorkspace( workspaceUuid: string, name: string, diff --git a/app/src/lib/api/mapping_service/types.ts b/app/src/lib/api/mapping_service/types.ts index 3493f8e..c9c78f7 100644 --- a/app/src/lib/api/mapping_service/types.ts +++ b/app/src/lib/api/mapping_service/types.ts @@ -1,43 +1,39 @@ -export enum MappingNodeType { - ENTITY = 'entity', - LITERAL = 'literal', - URIRef = 'uri_ref', -} +export type MappingNodeType = 'entity' | 'literal' | 'uri_ref'; -export interface MappingNode { +export type MappingNode = { id: string; - type: MappingNodeType.ENTITY; + type: 'entity'; label: string; uri_pattern: string; rdf_type: string[]; -} +}; -export interface MappingLiteral { +export type MappingLiteral = { id: string; - type: MappingNodeType.LITERAL; + type: 'literal'; label: string; value: string; literal_type: string; -} +}; -export interface MappingURIRef { +export type MappingURIRef = { id: string; - type: MappingNodeType.URIRef; + type: 'uri_ref'; uri_pattern: string; -} +}; -export interface MappingEdge { +export type MappingEdge = { id: string; source: string; target: string; predicate_uri: string; -} +}; -export interface MappingGraph { +export type MappingGraph = { uuid: string; name: string; description: string; source_id: string; nodes: (MappingNode | MappingLiteral | MappingURIRef)[]; edges: MappingEdge[]; -} +}; diff --git a/app/src/main.tsx b/app/src/main.tsx index f2d295b..0e474b8 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -2,9 +2,16 @@ import { createRoot } from 'react-dom/client'; import './index.scss'; +import React from 'react'; import App from './app'; import ApiService from './lib/services/api_service'; ApiService.registerWithNamespace('default', 'http://localhost:8000/api/'); -createRoot(document.getElementById('root')!).render(); +const root = createRoot(document.getElementById('root')!); + +root.render( + + + , +); diff --git a/app/src/pages/mapping_page/components/MainPanel/components/BaseNode.tsx b/app/src/pages/mapping_page/components/MainPanel/components/BaseNode.tsx new file mode 100644 index 0000000..e69de29 diff --git a/app/src/pages/mapping_page/components/MainPanel/components/EntityNode.tsx b/app/src/pages/mapping_page/components/MainPanel/components/EntityNode.tsx new file mode 100644 index 0000000..76a675e --- /dev/null +++ b/app/src/pages/mapping_page/components/MainPanel/components/EntityNode.tsx @@ -0,0 +1,35 @@ +import { Card, H3 } from '@blueprintjs/core'; +import { Handle, NodeProps, NodeResizer, Position } from '@xyflow/react'; +import { EntityNodeType } from '../types'; + +const EntityNode: React.FC> = node => { + return ( + <> + + + + +

{node.data.label}

+
+ + + + ); +}; + +export default EntityNode; diff --git a/app/src/pages/mapping_page/components/MainPanel/index.tsx b/app/src/pages/mapping_page/components/MainPanel/index.tsx index f7ab8c4..3765660 100644 --- a/app/src/pages/mapping_page/components/MainPanel/index.tsx +++ b/app/src/pages/mapping_page/components/MainPanel/index.tsx @@ -1,10 +1,102 @@ -const MainPanel = () => { +import { Menu, MenuItem, showContextMenu } from '@blueprintjs/core'; + +import { + addEdge, + Background, + Connection, + Controls, + NodeTypes, + ReactFlow, + useEdgesState, + useNodesState, + useReactFlow, +} from '@xyflow/react'; + +import '@xyflow/react/dist/style.css'; +import { useCallback } from 'react'; +import { MappingGraph } from '../../../../lib/api/mapping_service/types'; +import EntityNode from './components/EntityNode'; +import { XYEdgeType, XYNodeTypes } from './types'; + +type MainPanelProps = { + initialGraph: MappingGraph | null; +}; +const nodeTypes: NodeTypes = { + entity: EntityNode, +}; + +const MainPanel = ({ initialGraph }: MainPanelProps) => { + const reactflow = useReactFlow(); + + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + + const onConnect = useCallback( + (params: Connection) => { + setEdges(edges => addEdge(params, edges)); + }, + [setEdges], + ); + + const handleAddEntityNode = useCallback( + (e: React.MouseEvent) => { + setNodes(nodes => [ + ...nodes, + { + id: `node-${nodes.length}`, + data: { + id: `node-${nodes.length}`, + label: 'New Entity', + rdf_type: [''], + uri_pattern: '', + type: 'entity', + }, + width: 200, + height: 100, + position: reactflow.screenToFlowPosition({ + x: e.clientX, + y: e.clientY, + }), + type: 'entity', + }, + ]); + }, + [setNodes, reactflow], + ); + + const openMenu = (e: React.MouseEvent) => { + e.preventDefault(); + showContextMenu({ + content: ( + + + + + + ), + targetOffset: { left: e.clientX, top: e.clientY }, + isDarkTheme: true, + }); + }; + return ( + // disable default right click menu
-

Main Panel

-

This area expands fully when the side panel is collapsed.

+ + + +
); }; -export default MainPanel; \ No newline at end of file +export default MainPanel; diff --git a/app/src/pages/mapping_page/components/MainPanel/types.ts b/app/src/pages/mapping_page/components/MainPanel/types.ts new file mode 100644 index 0000000..941b2ae --- /dev/null +++ b/app/src/pages/mapping_page/components/MainPanel/types.ts @@ -0,0 +1,13 @@ +import { Edge, Node } from '@xyflow/react'; +import { + MappingEdge, + MappingNode, +} from '../../../../lib/api/mapping_service/types'; + +export type EntityNodeType = Node; +export type LiteralNodeType = Node; +export type URIRefNodeType = Node; + +export type XYNodeTypes = EntityNodeType | LiteralNodeType | URIRefNodeType; + +export type XYEdgeType = Edge; diff --git a/app/src/pages/mapping_page/components/Navbar/index.tsx b/app/src/pages/mapping_page/components/Navbar/index.tsx index 0c2c58a..a532547 100644 --- a/app/src/pages/mapping_page/components/Navbar/index.tsx +++ b/app/src/pages/mapping_page/components/Navbar/index.tsx @@ -3,10 +3,12 @@ import { useNavigate } from 'react-router-dom'; type NavbarProps = { uuid: string | undefined; - mapping_uuid: string | undefined; + name: string | undefined; + isLoading: string | null; + onSave: () => void; }; -const Navbar = ({ uuid, mapping_uuid }: NavbarProps) => { +const Navbar = ({ uuid, name, isLoading, onSave }: NavbarProps) => { const navigation = useNavigate(); return ( @@ -20,13 +22,17 @@ const Navbar = ({ uuid, mapping_uuid }: NavbarProps) => { }} />
- Workspace: {mapping_uuid} + Mapping: {name} - + + {isLoading ? <>{isLoading} : null} + - + diff --git a/app/src/pages/mapping_page/components/SidePanel/index.tsx b/app/src/pages/mapping_page/components/SidePanel/index.tsx index 00a6d21..7e35063 100644 --- a/app/src/pages/mapping_page/components/SidePanel/index.tsx +++ b/app/src/pages/mapping_page/components/SidePanel/index.tsx @@ -13,6 +13,8 @@ const SidePanel = ({ selectedTab }: SidePanelProps) => { return
AI Panel Content
; case 'references': return
Source References Panel Content
; + case 'search': + return
Search Panel Content
; case 'settings': return
Settings Panel Content
; default: diff --git a/app/src/pages/mapping_page/components/VerticalTabs/index.tsx b/app/src/pages/mapping_page/components/VerticalTabs/index.tsx index ca0d72e..810a9e7 100644 --- a/app/src/pages/mapping_page/components/VerticalTabs/index.tsx +++ b/app/src/pages/mapping_page/components/VerticalTabs/index.tsx @@ -2,9 +2,10 @@ import { Button } from '@blueprintjs/core'; import { SetStateAction } from 'react'; import { VscBook, + VscGear, VscPlug, + VscSearch, VscSymbolProperty, - VscGear, } from 'react-icons/vsc'; type VerticalTabsProps = { @@ -16,11 +17,16 @@ type VerticalTabsProps = { const tabs = [ { id: 'properties', label: 'Node Properties', icon: }, { id: 'ai', label: 'AI', icon: }, + { id: 'search', label: 'Search', icon: }, { id: 'references', label: 'Source References', icon: }, { id: 'settings', label: 'Settings', icon: }, ]; -const VerticalTabs = ({ selectedTab, isCollapsed, handleTabClick }: VerticalTabsProps) => { +const VerticalTabs = ({ + selectedTab, + isCollapsed, + handleTabClick, +}: VerticalTabsProps) => { return (
{tabs.map(tab => ( @@ -38,4 +44,4 @@ const VerticalTabs = ({ selectedTab, isCollapsed, handleTabClick }: VerticalTabs ); }; -export default VerticalTabs; \ No newline at end of file +export default VerticalTabs; diff --git a/app/src/pages/mapping_page/index.tsx b/app/src/pages/mapping_page/index.tsx index a533195..f6d6c3f 100644 --- a/app/src/pages/mapping_page/index.tsx +++ b/app/src/pages/mapping_page/index.tsx @@ -1,3 +1,4 @@ +import { ReactFlowProvider } from '@xyflow/react'; import { SetStateAction, useEffect, useRef, useState } from 'react'; import { ImperativePanelHandle, @@ -6,10 +7,12 @@ import { PanelResizeHandle, } from 'react-resizable-panels'; import { useParams } from 'react-router-dom'; +import useErrorToast from '../../hooks/useErrorToast'; import MainPanel from './components/MainPanel'; import Navbar from './components/Navbar'; import SidePanel from './components/SidePanel'; import VerticalTabs from './components/VerticalTabs'; +import useMappingPage from './state'; import './styles.scss'; type MappingPageURLProps = { @@ -21,6 +24,11 @@ const MappingPage = () => { const props = useParams(); const [selectedTab, setSelectedTab] = useState(undefined); const [isCollapsed, setIsCollapsed] = useState(false); + const mapping = useMappingPage(state => state.mapping); + const isLoading = useMappingPage(state => state.isLoading); + const error = useMappingPage(state => state.error); + const loadMapping = useMappingPage(state => state.loadMapping); + const saveMapping = useMappingPage(state => state.saveMapping); const sidePanelHandle = useRef(null); @@ -34,6 +42,14 @@ const MappingPage = () => { } }, [isCollapsed]); + useEffect(() => { + if (props.uuid && props.mapping_uuid) { + loadMapping(props.uuid, props.mapping_uuid); + } + }, [props.uuid, props.mapping_uuid, loadMapping]); + + useErrorToast(error); + const handleTabClick = (tabId: SetStateAction) => { if (selectedTab === tabId && !isCollapsed) { setIsCollapsed(true); @@ -43,36 +59,49 @@ const MappingPage = () => { } }; + const handleSave = () => { + if (mapping && props.uuid && props.mapping_uuid) { + saveMapping(props.uuid, props.mapping_uuid, mapping); + } + }; + return ( -
- -
- - - setIsCollapsed(true)} - defaultSize={20} - minSize={10} - maxSize={50} - > - - - {!isCollapsed && ( - - )} - - - - + +
+ +
+ + + setIsCollapsed(true)} + defaultSize={20} + minSize={10} + maxSize={50} + > + + + {!isCollapsed && ( + + )} + + + + +
-
+ ); }; diff --git a/app/src/pages/mapping_page/state.ts b/app/src/pages/mapping_page/state.ts index e69de29..0b96a90 100644 --- a/app/src/pages/mapping_page/state.ts +++ b/app/src/pages/mapping_page/state.ts @@ -0,0 +1,77 @@ +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 { ZustandActions } from '../../utils/zustand'; + +interface MappingPageState { + mapping: MappingGraph | null; + isLoading: string | null; + error: string | null; +} + +interface MappingPageStateActions { + loadMapping: (workspaceUuid: string, mappingUuid: string) => Promise; + saveMapping: ( + workspaceUuid: string, + mappingUuid: string, + mapping: MappingGraph, + ) => Promise; +} + +const defaultState: MappingPageState = { + mapping: null, + isLoading: null, + error: null, +}; + +const functions: ZustandActions = ( + set, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + get, +) => ({ + async loadMapping(workspaceUuid: string, mappingUuid: string) { + set({ isLoading: 'Loading mapping...' }); + try { + const mapping = await MappingService.getMappingInWorkspace( + workspaceUuid, + mappingUuid, + ); + set({ mapping, error: null }); + } catch (error) { + if (error instanceof Error) { + set({ error: error.message }); + } + } finally { + set({ isLoading: null }); + } + }, + async saveMapping( + workspaceUuid: string, + mappingUuid: string, + mapping: MappingGraph, + ) { + set({ isLoading: 'Saving mapping...' }); + try { + await MappingService.updateMapping(workspaceUuid, mappingUuid, mapping); + set({ error: null }); + } catch (error) { + if (error instanceof Error) { + set({ error: error.message }); + } + } finally { + set({ isLoading: null }); + } + }, +}); + +export const useMappingPage = create< + MappingPageState & MappingPageStateActions +>()( + devtools((set, get) => ({ + ...defaultState, + ...functions(set, get), + })), +); + +export default useMappingPage; diff --git a/app/src/pages/mapping_page/styles.scss b/app/src/pages/mapping_page/styles.scss index 841fd74..e6a70c9 100644 --- a/app/src/pages/mapping_page/styles.scss +++ b/app/src/pages/mapping_page/styles.scss @@ -42,7 +42,8 @@ /* Main panel */ .main-panel { flex: 1; - padding: 20px; + width: 100%; + height: 100%; overflow: auto; } diff --git a/app/src/pages/workspace_page/components/MappingCard/index.tsx b/app/src/pages/workspace_page/components/MappingCard/index.tsx index ea80458..532cbfd 100644 --- a/app/src/pages/workspace_page/components/MappingCard/index.tsx +++ b/app/src/pages/workspace_page/components/MappingCard/index.tsx @@ -23,12 +23,12 @@ const MappingCardItem = ({ } actions={ <> - + } /> diff --git a/server/facades/workspace/mapping/get_mappings_in_workspace_facade.py b/server/facades/workspace/mapping/get_mappings_in_workspace_facade.py index cb8bde5..b2524b5 100644 --- a/server/facades/workspace/mapping/get_mappings_in_workspace_facade.py +++ b/server/facades/workspace/mapping/get_mappings_in_workspace_facade.py @@ -43,6 +43,7 @@ def __init__( def execute( self, workspace_id: str, + mapping_id: str | None = None, ) -> FacadeResponse: self.logger.info( f"Retrieving mappings in workspace {workspace_id}" @@ -56,6 +57,25 @@ def execute( workspace = self.workspace_service.get_workspace( workspace_metadata.location, ) + + if mapping_id and mapping_id in workspace.mappings: + self.logger.info( + f"Retrieving mapping {mapping_id}" + ) + mapping = self.mapping_service.get_mapping( + mapping_id + ) + return FacadeResponse( + status=200, + message=f"Retrieved mapping {mapping_id}", + data=mapping, + ) + elif mapping_id: + return FacadeResponse( + status=404, + message=f"Mapping {mapping_id} not found in workspace {workspace_id}", + ) + self.logger.info("Retrieving mappings") mappings = [ diff --git a/server/routers/workspaces/workspaces.py b/server/routers/workspaces/workspaces.py index 30f37f5..59d1dbc 100644 --- a/server/routers/workspaces/workspaces.py +++ b/server/routers/workspaces/workspaces.py @@ -381,6 +381,31 @@ async def get_mappings( ) +@router.get("/{workspace_id}/mapping/{mapping_id}") +async def get_mapping( + workspace_id: str, + mapping_id: str, + get_mappings_in_workspace_facade: GetMappingsInWorkspaceDep, +) -> MappingGraph: + facade_response = ( + get_mappings_in_workspace_facade.execute( + workspace_id=workspace_id, + mapping_id=mapping_id, + ) + ) + + if ( + facade_response.status // 100 == 2 + and facade_response.data + ): + return facade_response.data + + raise HTTPException( + status_code=facade_response.status, + detail=facade_response.to_dict(), + ) + + @router.post("/{workspace_id}/mapping", status_code=201) async def create_mapping( workspace_id: str,