From cebeca50663c0746728fdaf27504cc122881bd2f Mon Sep 17 00:00:00 2001
From: dextmorgn
Date: Wed, 5 Nov 2025 15:36:09 +0100
Subject: [PATCH] feat(app): import modal
---
flowsint-app/src/api/api.ts | 9 +-
flowsint-app/src/api/sketch-service.ts | 23 +
.../src/components/graphs/graph-viewer.tsx | 31 +-
.../src/components/graphs/import-preview.tsx | 441 ++++++++++++++++++
.../src/components/graphs/import-sheet.tsx | 198 ++++++++
.../src/components/layout/top-navbar.tsx | 11 +-
flowsint-app/src/env.d.ts | 1 +
.../src/stores/graph-settings-store.ts | 7 +-
yarn.lock | 2 +-
9 files changed, 700 insertions(+), 23 deletions(-)
create mode 100644 flowsint-app/src/components/graphs/import-preview.tsx
create mode 100644 flowsint-app/src/components/graphs/import-sheet.tsx
diff --git a/flowsint-app/src/api/api.ts b/flowsint-app/src/api/api.ts
index 6743fd5..9538e1a 100644
--- a/flowsint-app/src/api/api.ts
+++ b/flowsint-app/src/api/api.ts
@@ -5,14 +5,15 @@ const API_URL = import.meta.env.VITE_API_URL
export async function fetchWithAuth(endpoint: string, options: RequestInit = {}): Promise {
const token = useAuthStore.getState().token
- const defaultHeaders: HeadersInit = {
- 'Content-Type': 'application/json'
+ // Check if body is FormData - if so, don't set Content-Type (browser will set it with boundary)
+ const isFormData = options.body instanceof FormData
+ const defaultHeaders: HeadersInit = {}
+ if (!isFormData) {
+ defaultHeaders['Content-Type'] = 'application/json'
}
-
if (token) {
defaultHeaders['Authorization'] = `Bearer ${token}`
}
-
const config: RequestInit = {
...options,
headers: {
diff --git a/flowsint-app/src/api/sketch-service.ts b/flowsint-app/src/api/sketch-service.ts
index 445f911..f3efd77 100644
--- a/flowsint-app/src/api/sketch-service.ts
+++ b/flowsint-app/src/api/sketch-service.ts
@@ -72,5 +72,28 @@ export const sketchService = {
method: 'PUT',
body: body
})
+ },
+ analyzeImportFile: async (sketchId: string, file: File): Promise => {
+ const formData = new FormData()
+ formData.append('file', file)
+
+ return fetchWithAuth(`/api/sketches/${sketchId}/import/analyze`, {
+ method: 'POST',
+ body: formData
+ })
+ },
+ executeImport: async (
+ sketchId: string,
+ file: File,
+ entityMappings: Array<{ row_index: number; entity_type: string; include: boolean; label?: string }>,
+ ): Promise => {
+ const formData = new FormData()
+ formData.append('file', file)
+ formData.append('entity_mappings_json', JSON.stringify(entityMappings))
+
+ return fetchWithAuth(`/api/sketches/${sketchId}/import/execute`, {
+ method: 'POST',
+ body: formData
+ })
}
}
diff --git a/flowsint-app/src/components/graphs/graph-viewer.tsx b/flowsint-app/src/components/graphs/graph-viewer.tsx
index 2f3028c..03d8c76 100644
--- a/flowsint-app/src/components/graphs/graph-viewer.tsx
+++ b/flowsint-app/src/components/graphs/graph-viewer.tsx
@@ -7,7 +7,7 @@ import React, { useCallback, useMemo, useEffect, useState, useRef } from 'react'
import ForceGraph2D from 'react-force-graph-2d'
import { Button } from '../ui/button'
import { useTheme } from '@/components/theme-provider'
-import { Info, Share2, Type } from 'lucide-react'
+import { Info, Plus, Share2, Type, Upload } from 'lucide-react'
import Lasso from './lasso'
import { GraphNode, GraphEdge } from '@/types'
import MiniMap from './minimap'
@@ -187,6 +187,7 @@ const GraphViewer: React.FC = ({
const selectedNodes = useGraphStore((s) => s.selectedNodes)
const { theme } = useTheme()
const setOpenMainDialog = useGraphStore((state) => state.setOpenMainDialog)
+ const setImportModalOpen = useGraphSettingsStore((s) => s.setImportModalOpen)
const shouldUseSimpleRendering = useMemo(
() => nodes.length > CONSTANTS.NODE_COUNT_THRESHOLD || currentZoom < 1.5,
@@ -506,6 +507,11 @@ const GraphViewer: React.FC = ({
setOpenMainDialog(true)
}, [setOpenMainDialog])
+ const handleOpenImportDialog = useCallback(() => {
+ setImportModalOpen(true)
+ }, [setImportModalOpen])
+
+
// Throttled hover handlers using RAF for better performance
const handleNodeHover = useCallback((node: any) => {
// Cancel any pending RAF
@@ -977,19 +983,18 @@ const GraphViewer: React.FC = ({
Labels: Zoom in to see node labels progressively by connection weight
-
+
+
+
or
+
+
-
+
)
}
diff --git a/flowsint-app/src/components/graphs/import-preview.tsx b/flowsint-app/src/components/graphs/import-preview.tsx
new file mode 100644
index 0000000..025af6d
--- /dev/null
+++ b/flowsint-app/src/components/graphs/import-preview.tsx
@@ -0,0 +1,441 @@
+import { useState, useMemo } from 'react'
+import { Button } from '@/components/ui/button'
+import { Label } from '@/components/ui/label'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue
+} from '@/components/ui/select'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Badge } from '@/components/ui/badge'
+import { Input } from '@/components/ui/input'
+import { CheckCircle2, XCircle, Loader2, ChevronLeft, ChevronRight } from 'lucide-react'
+import { sketchService } from '@/api/sketch-service'
+import { useActionItems } from '@/hooks/use-action-items'
+import {
+ useReactTable,
+ getCoreRowModel,
+ getPaginationRowModel,
+ flexRender,
+ createColumnHelper,
+} from '@tanstack/react-table'
+import { toast } from 'sonner'
+import { useGraphControls } from '@/stores/graph-controls-store'
+
+interface EntityPreview {
+ row_index: number
+ data: Record
+ detected_type: string
+ primary_value: string
+}
+
+interface AnalysisResult {
+ entities: EntityPreview[]
+ total_entities: number
+ type_distribution: Record
+ columns: string[]
+}
+
+interface EntityMapping {
+ row_index: number
+ entity_type: string
+ include: boolean
+ label: string
+ data: Record
+}
+
+interface ImportPreviewProps {
+ analysisResult: AnalysisResult
+ file: File
+ sketchId: string
+ onSuccess: () => void
+ onCancel: () => void
+}
+
+// Removed fixed ENTITY_TYPES; now derived from useActionItems()
+
+type TableRow = EntityPreview & {
+ mapping: EntityMapping
+}
+
+export function ImportPreview({
+ analysisResult,
+ file,
+ sketchId,
+ onSuccess,
+ onCancel
+}: ImportPreviewProps) {
+ const { actionItems, isLoading: isLoadingActionItems } = useActionItems()
+ const refetchGraph = useGraphControls((s) => s.refetchGraph)
+ const [entityMappings, setEntityMappings] = useState(() => {
+ // Initialize entity mappings from detected entities
+ return analysisResult.entities.map((entity) => ({
+ row_index: entity.row_index,
+ entity_type: entity.detected_type,
+ include: true,
+ label: entity.primary_value,
+ data: { ...entity.data }
+ }))
+ })
+
+ // Get all unique column keys across all entities
+ const allDataKeys = useMemo(() => {
+ const keysSet = new Set()
+ analysisResult.entities.forEach(entity => {
+ Object.keys(entity.data).forEach(key => keysSet.add(key))
+ })
+ return Array.from(keysSet)
+ }, [analysisResult.entities])
+
+ const [isImporting, setIsImporting] = useState(false)
+ const [importResult, setImportResult] = useState<{
+ status: string
+ nodes_created: number
+ nodes_skipped: number
+ errors: string[]
+ } | null>(null)
+
+ const handleIncludeChange = (rowIndex: number, include: boolean) => {
+ setEntityMappings((prev) =>
+ prev.map((mapping) =>
+ mapping.row_index === rowIndex ? { ...mapping, include } : mapping
+ )
+ )
+ }
+
+ const handleTypeChange = (rowIndex: number, entityType: string) => {
+ setEntityMappings((prev) =>
+ prev.map((mapping) =>
+ mapping.row_index === rowIndex
+ ? { ...mapping, entity_type: entityType }
+ : mapping
+ )
+ )
+ }
+
+ const handleLabelChange = (rowIndex: number, label: string) => {
+ setEntityMappings((prev) =>
+ prev.map((mapping) =>
+ mapping.row_index === rowIndex ? { ...mapping, label } : mapping
+ )
+ )
+ }
+
+ const handleDataChange = (rowIndex: number, key: string, value: string) => {
+ setEntityMappings((prev) =>
+ prev.map((mapping) =>
+ mapping.row_index === rowIndex
+ ? { ...mapping, data: { ...mapping.data, [key]: value } }
+ : mapping
+ )
+ )
+ }
+
+ const handleImport = async () => {
+ setIsImporting(true)
+ try {
+ const result = await sketchService.executeImport(
+ sketchId,
+ file,
+ entityMappings
+ )
+ setImportResult(result)
+
+ if (result.status === 'completed') {
+ setTimeout(() => {
+ onSuccess()
+ }, 2000)
+ }
+ } catch (error) {
+ toast.error('Failed to import entities. Please try again.')
+ } finally {
+ refetchGraph()
+ setIsImporting(false)
+ toast.success("Import successfull !")
+ }
+ }
+
+
+ // Build entity types list from action items (flatten parent/children)
+ const entityTypes = useMemo(() => {
+ if (!actionItems) return []
+ const keys: string[] = []
+ for (const item of actionItems) {
+ if (item.children && item.children.length > 0) {
+ for (const child of item.children) {
+ if (child.label) keys.push(child.label)
+ }
+ } else if (item.label) {
+ keys.push(item.label)
+ }
+ }
+ // De-duplicate while preserving order
+ return Array.from(new Set(keys))
+ }, [actionItems])
+
+ // Prepare table data
+ const tableData: TableRow[] = useMemo(() => {
+ return analysisResult.entities.map((entity) => ({
+ ...entity,
+ mapping: entityMappings.find(m => m.row_index === entity.row_index)!
+ }))
+ }, [analysisResult.entities, entityMappings])
+
+ // Define columns
+ const columns = useMemo(() => {
+ const columnHelper: any = createColumnHelper() as any
+
+ const baseColumns = [
+ columnHelper.display({
+ id: 'include',
+ header: 'Include',
+ size: 60,
+ cell: ({ row }: { row: any }) => (
+
+ handleIncludeChange(row.original.row_index, checked as boolean)
+ }
+ />
+ ),
+ }),
+ columnHelper.display({
+ id: 'entity_type',
+ header: 'Entity Type',
+ size: 160,
+ cell: ({ row }: { row: any }) => (
+
+ ),
+ }),
+ columnHelper.display({
+ id: 'label',
+ header: 'Label *',
+ size: 200,
+ cell: ({ row }: { row: any }) => (
+
+ handleLabelChange(row.original.row_index, e.target.value)
+ }
+ disabled={!row.original.mapping.include}
+ placeholder="Enter label..."
+ />
+ ),
+ }),
+ ]
+
+ // Add dynamic data columns
+ const dataColumns = allDataKeys.map((key) =>
+ columnHelper.display({
+ id: `data_${key}`,
+ header: key,
+ size: 200,
+ cell: ({ row }: { row: any }) => (
+
+ handleDataChange(row.original.row_index, key, e.target.value)
+ }
+ disabled={!row.original.mapping.include}
+ placeholder="-"
+ />
+ ),
+ })
+ )
+
+ return [...baseColumns, ...dataColumns]
+ }, [allDataKeys, entityMappings])
+
+ // Create table instance
+ const table = useReactTable({
+ data: tableData,
+ columns,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ initialState: {
+ pagination: {
+ pageSize: 20,
+ },
+ },
+ })
+
+ if (importResult) {
+ return (
+
+
+ {importResult.status === 'completed' ? (
+ <>
+
+
+
Import Successful!
+
+ {importResult.nodes_created} entities created
+
+ {importResult.nodes_skipped > 0 && (
+
+ {importResult.nodes_skipped} entities skipped
+
+ )}
+
+ >
+ ) : (
+ <>
+
+
+
Import Completed with Errors
+
+ {importResult.nodes_created} entities created
+
+
+ {importResult.errors.length} errors encountered
+
+
+ {importResult.errors.length > 0 && (
+
+
+
+ {importResult.errors.map((error, idx) => (
+
+ {error}
+
+ ))}
+
+
+ )}
+ >
+ )}
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ {table.getHeaderGroups().map((headerGroup: any) => (
+
+ {headerGroup.headers.map((header: any, index: number) => {
+ return (
+ |
+ {flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ |
+ )
+ })}
+
+ ))}
+
+
+ {table.getRowModel().rows.map((row: any) => (
+
+ {row.getVisibleCells().map((cell: any, index: number) => {
+ return (
+ |
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+ |
+ )
+ })}
+
+ ))}
+
+
+
+
+ {/* Pagination */}
+
+
+ Showing {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1} to{' '}
+ {Math.min(
+ (table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize,
+ table.getFilteredRowModel().rows.length
+ )}{' '}
+ of {table.getFilteredRowModel().rows.length} entities
+
+
+
+
+ Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
+
+
+
+
+
+ {/* Actions */}
+
+
+
+
+
+ )
+}
diff --git a/flowsint-app/src/components/graphs/import-sheet.tsx b/flowsint-app/src/components/graphs/import-sheet.tsx
new file mode 100644
index 0000000..e5d9259
--- /dev/null
+++ b/flowsint-app/src/components/graphs/import-sheet.tsx
@@ -0,0 +1,198 @@
+import { useState, useCallback } from 'react'
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle
+} from '@/components/ui/sheet'
+import { Button } from '@/components/ui/button'
+import { Upload, FileText, FileSpreadsheet } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { ImportPreview } from './import-preview'
+import { sketchService } from '@/api/sketch-service'
+import { toast } from 'sonner'
+import { useGraphSettingsStore } from '@/stores/graph-settings-store'
+import Loader from '../loader'
+
+interface ImportSheetProps {
+ sketchId: string
+}
+
+export function ImportSheet({ sketchId }: ImportSheetProps) {
+ const onOpenChange = useGraphSettingsStore((s) => s.setImportModalOpen)
+ const open = useGraphSettingsStore((s) => s.importModalOpen)
+ const [file, setFile] = useState(null)
+ const [isDragging, setIsDragging] = useState(false)
+ const [isAnalyzing, setIsAnalyzing] = useState(false)
+ const [analysisResult, setAnalysisResult] = useState(null)
+
+ const handleDragOver = useCallback((e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ setIsDragging(true)
+ }, [])
+
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ setIsDragging(false)
+ }, [])
+
+ const handleDrop = useCallback((e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ setIsDragging(false)
+
+ const droppedFile = e.dataTransfer.files[0]
+ if (droppedFile) {
+ handleFileSelect(droppedFile)
+ }
+ }, [])
+
+ const handleFileInputChange = useCallback((e: React.ChangeEvent) => {
+ const selectedFile = e.target.files?.[0]
+ if (selectedFile) {
+ handleFileSelect(selectedFile)
+ }
+ }, [])
+
+ const handleFileSelect = async (selectedFile: File) => {
+ // Validate file type
+ const validExtensions = ['.csv', '.txt', '.xlsx', '.xls']
+ const fileExtension = selectedFile.name.toLowerCase().substring(selectedFile.name.lastIndexOf('.'))
+ if (!validExtensions.includes(fileExtension)) {
+ alert('Please upload a CSV, TXT, or XLSX file')
+ return
+ }
+ setFile(selectedFile)
+ setIsAnalyzing(true)
+ try {
+ const result = await sketchService.analyzeImportFile(sketchId, selectedFile)
+ setAnalysisResult(result)
+ } catch (error) {
+ toast.error('Failed to analyze file. Please try again.')
+ setFile(null)
+ } finally {
+ setIsAnalyzing(false)
+ }
+ }
+
+ const handleReset = () => {
+ setFile(null)
+ setAnalysisResult(null)
+ setIsAnalyzing(false)
+ }
+
+ const handleClose = () => {
+ handleReset()
+ onOpenChange(false)
+ }
+
+ const getFileIcon = (fileName: string) => {
+ const extension = fileName.toLowerCase().substring(fileName.lastIndexOf('.'))
+ if (extension === '.csv' || extension === '.txt') {
+ return
+ }
+ return
+ }
+
+ return (
+
+
+
+ Import Entities
+
+ Upload a CSV, TXT, or XLSX file to import entities into your sketch
+
+
+
+
+
+ This import feature is in beta. There may be minor side effects. If you see any issue, please
report them here to help out the community.
+
+
+
+
+
+ {!file && !analysisResult && (
+
+
+
+
+
+ Drag & drop your file here
+
+
+ or click to browse
+
+
+
+
+
+ Supported formats: CSV, TXT, XLSX
+
+
+
+ )}
+
+ {isAnalyzing && (
+
+ )}
+
+ {file && !isAnalyzing && !analysisResult && (
+
+ {getFileIcon(file.name)}
+
+
{file.name}
+
+ {(file.size / 1024).toFixed(2)} KB
+
+
+
+
+ )}
+
+ {analysisResult && file && (
+
+ )}
+
+
+
+ )
+}
diff --git a/flowsint-app/src/components/layout/top-navbar.tsx b/flowsint-app/src/components/layout/top-navbar.tsx
index a39cb50..3404e38 100644
--- a/flowsint-app/src/components/layout/top-navbar.tsx
+++ b/flowsint-app/src/components/layout/top-navbar.tsx
@@ -2,11 +2,12 @@ import { Command } from '../command'
import { Link, useNavigate, useParams } from '@tanstack/react-router'
import InvestigationSelector from './investigation-selector'
import SketchSelector from './sketch-selector'
-import { memo, useCallback } from 'react'
+import { memo, useCallback, useState } from 'react'
import { Switch } from '../ui/switch'
import { Label } from '../ui/label'
import { useLayoutStore } from '@/stores/layout-store'
import { Button } from '@/components/ui/button'
+import { ImportSheet } from '../graphs/import-sheet'
import {
DropdownMenu,
DropdownMenuContent,
@@ -17,7 +18,7 @@ import {
DropdownMenuShortcut,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
-import { Ellipsis } from 'lucide-react'
+import { Ellipsis, Upload } from 'lucide-react'
import { isMac } from '@/lib/utils'
import { useGraphSettingsStore } from '@/stores/graph-settings-store'
import { useMutation, useQueryClient } from '@tanstack/react-query'
@@ -79,6 +80,7 @@ export const TopNavbar = memo(() => {
export function InvestigationMenu({ investigationId, sketchId }: { investigationId?: string, sketchId: string }) {
const setSettingsModalOpen = useGraphSettingsStore((s) => s.setSettingsModalOpen)
const setKeyboardShortcutsOpen = useGraphSettingsStore((s) => s.setKeyboardShortcutsOpen)
+ const setImportModalOpen = useGraphSettingsStore((s) => s.setImportModalOpen)
const navigate = useNavigate()
const { confirm } = useConfirm()
@@ -147,11 +149,16 @@ export function InvestigationMenu({ investigationId, sketchId }: { investigation
API
+ setImportModalOpen(true)}>
+ Import entities
+
+
Delete
⇧⌘Q
+
)
}
diff --git a/flowsint-app/src/env.d.ts b/flowsint-app/src/env.d.ts
index 11f02fe..8af8a33 100644
--- a/flowsint-app/src/env.d.ts
+++ b/flowsint-app/src/env.d.ts
@@ -1 +1,2 @@
///
+declare module '@tanstack/react-table';
diff --git a/flowsint-app/src/stores/graph-settings-store.ts b/flowsint-app/src/stores/graph-settings-store.ts
index 72eb2a1..6b2343c 100644
--- a/flowsint-app/src/stores/graph-settings-store.ts
+++ b/flowsint-app/src/stores/graph-settings-store.ts
@@ -187,6 +187,8 @@ type GraphGeneralSettingsStore = {
setSettingsModalOpen: (open: boolean) => void
keyboardShortcutsOpen: boolean
setKeyboardShortcutsOpen: (open: boolean) => void
+ importModalOpen: boolean
+ setImportModalOpen: (open: boolean) => void
// Helper methods
getSettingValue: (category: string, key: string) => any
@@ -212,7 +214,7 @@ export const useGraphSettingsStore = create()(
// UI State
settingsModalOpen: false,
keyboardShortcutsOpen: false,
-
+ importModalOpen: false,
// Core methods
updateSetting: (category, key, value) =>
set((state) => {
@@ -227,7 +229,6 @@ export const useGraphSettingsStore = create()(
}
}
}
-
// Also update forceSettings if we're updating a graph setting
let newForceSettings = state.forceSettings
if (category === 'graph') {
@@ -239,7 +240,6 @@ export const useGraphSettingsStore = create()(
}
}
}
-
return {
settings: newSettings,
forceSettings: newForceSettings,
@@ -313,6 +313,7 @@ export const useGraphSettingsStore = create()(
// UI State methods
setSettingsModalOpen: (open) => set({ settingsModalOpen: open }),
setKeyboardShortcutsOpen: (open) => set({ keyboardShortcutsOpen: open }),
+ setImportModalOpen: (open) => set({ importModalOpen: open }),
// Helper methods
getSettingValue: (category: string, key: string) => {
diff --git a/yarn.lock b/yarn.lock
index 36677d7..471f68a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1875,7 +1875,7 @@
"@tanstack/react-table@^8.21.3":
version "8.21.3"
- resolved "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz"
+ resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.21.3.tgz#2c38c747a5731c1a07174fda764b9c2b1fb5e91b"
integrity sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==
dependencies:
"@tanstack/table-core" "8.21.3"