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 ( + + ) + })} + + ))} + + + {table.getRowModel().rows.map((row: any) => ( + + {row.getVisibleCells().map((cell: any, index: number) => { + return ( + + ) + })} + + ))} + +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} +
+ {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 && ( +
+ +

Analyzing file...

+
+ )} + + {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"