mirror of
https://github.com/reconurge/flowsint.git
synced 2026-03-11 17:34:31 -05:00
feat(app): import modal
This commit is contained in:
@@ -5,14 +5,15 @@ const API_URL = import.meta.env.VITE_API_URL
|
||||
export async function fetchWithAuth(endpoint: string, options: RequestInit = {}): Promise<any> {
|
||||
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: {
|
||||
|
||||
@@ -72,5 +72,28 @@ export const sketchService = {
|
||||
method: 'PUT',
|
||||
body: body
|
||||
})
|
||||
},
|
||||
analyzeImportFile: async (sketchId: string, file: File): Promise<any> => {
|
||||
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<any> => {
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<GraphViewerProps> = ({
|
||||
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<GraphViewerProps> = ({
|
||||
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<GraphViewerProps> = ({
|
||||
<strong>Labels:</strong> Zoom in to see node labels progressively by connection weight
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleOpenNewAddItemDialog}>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
/>
|
||||
</svg>
|
||||
Add your first item
|
||||
</Button>
|
||||
<div className='flex flex-col justify-center gap-1'>
|
||||
<Button onClick={handleOpenNewAddItemDialog}>
|
||||
<Plus />
|
||||
Add your first item
|
||||
</Button>
|
||||
<span className='opacity-60'>or</span>
|
||||
<Button variant="secondary" onClick={handleOpenImportDialog}>
|
||||
<Upload /> Import data
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
441
flowsint-app/src/components/graphs/import-preview.tsx
Normal file
441
flowsint-app/src/components/graphs/import-preview.tsx
Normal file
@@ -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<string, any>
|
||||
detected_type: string
|
||||
primary_value: string
|
||||
}
|
||||
|
||||
interface AnalysisResult {
|
||||
entities: EntityPreview[]
|
||||
total_entities: number
|
||||
type_distribution: Record<string, number>
|
||||
columns: string[]
|
||||
}
|
||||
|
||||
interface EntityMapping {
|
||||
row_index: number
|
||||
entity_type: string
|
||||
include: boolean
|
||||
label: string
|
||||
data: Record<string, any>
|
||||
}
|
||||
|
||||
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<EntityMapping[]>(() => {
|
||||
// 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<string>()
|
||||
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<string[]>(() => {
|
||||
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 }) => (
|
||||
<Checkbox
|
||||
checked={row.original.mapping.include}
|
||||
onCheckedChange={(checked) =>
|
||||
handleIncludeChange(row.original.row_index, checked as boolean)
|
||||
}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: 'entity_type',
|
||||
header: 'Entity Type',
|
||||
size: 160,
|
||||
cell: ({ row }: { row: any }) => (
|
||||
<Select
|
||||
value={row.original.mapping.entity_type}
|
||||
onValueChange={(value) =>
|
||||
handleTypeChange(row.original.row_index, value)
|
||||
}
|
||||
disabled={!row.original.mapping.include || isLoadingActionItems}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{entityTypes.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
),
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: 'label',
|
||||
header: 'Label *',
|
||||
size: 200,
|
||||
cell: ({ row }: { row: any }) => (
|
||||
<Input
|
||||
className="h-8 w-full text-xs"
|
||||
value={row.original.mapping.label}
|
||||
onChange={(e) =>
|
||||
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 }) => (
|
||||
<Input
|
||||
className="h-8 w-full text-xs"
|
||||
value={row.original.mapping.data[key] || ''}
|
||||
onChange={(e) =>
|
||||
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 (
|
||||
<div className="py-6">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{importResult.status === 'completed' ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-16 w-16 text-green-500" />
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold">Import Successful!</h3>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{importResult.nodes_created} entities created
|
||||
</p>
|
||||
{importResult.nodes_skipped > 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{importResult.nodes_skipped} entities skipped
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="h-16 w-16 text-orange-500" />
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold">Import Completed with Errors</h3>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{importResult.nodes_created} entities created
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{importResult.errors.length} errors encountered
|
||||
</p>
|
||||
</div>
|
||||
{importResult.errors.length > 0 && (
|
||||
<div className="w-full mt-4">
|
||||
<Label>Errors:</Label>
|
||||
<div className="h-32 w-full rounded-md border p-2 mt-2 overflow-auto">
|
||||
{importResult.errors.map((error, idx) => (
|
||||
<p key={idx} className="text-xs text-red-500 mb-1">
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Button onClick={onSuccess} className="mt-4">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="border rounded-lg">
|
||||
<div className="overflow-auto" style={{ maxHeight: '500px' }}>
|
||||
<table className="w-full" style={{ borderCollapse: 'separate', borderSpacing: 0 }}>
|
||||
<thead className="bg-muted top-0 z-10">
|
||||
{table.getHeaderGroups().map((headerGroup: any) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header: any, index: number) => {
|
||||
return (
|
||||
<th
|
||||
key={header.id}
|
||||
className={`px-3 py-2 text-left text-xs font-medium border-b border-r`}
|
||||
style={{
|
||||
width: `${header.getSize()}px`,
|
||||
minWidth: `${header.getSize()}px`,
|
||||
maxWidth: `${header.getSize()}px`,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row: any) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={`border-b ${!row.original.mapping.include ? 'opacity-50' : ''}`}
|
||||
>
|
||||
{row.getVisibleCells().map((cell: any, index: number) => {
|
||||
return (
|
||||
<td
|
||||
key={cell.id}
|
||||
className={`px-3 py-2 border-r`}
|
||||
style={{
|
||||
width: `${cell.column.getSize()}px`,
|
||||
minWidth: `${cell.column.getSize()}px`,
|
||||
maxWidth: `${cell.column.getSize()}px`,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
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
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm">
|
||||
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={onCancel} disabled={isImporting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleImport} disabled={isImporting}>
|
||||
{isImporting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{isImporting ? 'Importing...' : `Import ${entityMappings.filter(m => m.include).length} Entities`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
198
flowsint-app/src/components/graphs/import-sheet.tsx
Normal file
198
flowsint-app/src/components/graphs/import-sheet.tsx
Normal file
@@ -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<File | null>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false)
|
||||
const [analysisResult, setAnalysisResult] = useState<any>(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<HTMLInputElement>) => {
|
||||
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 <FileText className="h-8 w-8" />
|
||||
}
|
||||
return <FileSpreadsheet className="h-8 w-8" />
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={handleClose}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className={`w-full overflow-y-auto ${analysisResult ? 'sm:max-w-[95vw]' : 'sm:max-w-2xl'
|
||||
}`}
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Import Entities</SheetTitle>
|
||||
<SheetDescription>
|
||||
Upload a CSV, TXT, or XLSX file to import entities into your sketch
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="px-6">
|
||||
<div className="mt-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||||
This import feature is in beta. There may be minor side effects. If you see any issue, please <a className='text-primary underline font-semibold' target='_blank' href="https://github.com/reconurge/flowsint/issues">report them here</a> to help out the community.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="p-6">
|
||||
{!file && !analysisResult && (
|
||||
<div
|
||||
className={cn(
|
||||
'border-2 border-dashed rounded-lg p-12 text-center transition-colors',
|
||||
isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted-foreground/25 hover:border-muted-foreground/50'
|
||||
)}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Upload className="h-12 w-12 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-lg font-medium">
|
||||
Drag & drop your file here
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
or click to browse
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
id="file-upload"
|
||||
className="hidden"
|
||||
accept=".csv,.txt,.xlsx,.xls"
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
<Button asChild variant="outline">
|
||||
<label htmlFor="file-upload" className="cursor-pointer">
|
||||
Browse files
|
||||
</label>
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Supported formats: CSV, TXT, XLSX
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAnalyzing && (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-12">
|
||||
<Loader />
|
||||
<p className="text-sm text-muted-foreground">Analyzing file...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{file && !isAnalyzing && !analysisResult && (
|
||||
<div className="flex items-center gap-4 p-4 border rounded-lg">
|
||||
{getFileIcon(file.name)}
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{file.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(file.size / 1024).toFixed(2)} KB
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleReset}>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysisResult && file && (
|
||||
<ImportPreview
|
||||
analysisResult={analysisResult}
|
||||
file={file}
|
||||
sketchId={sketchId}
|
||||
onSuccess={handleClose}
|
||||
onCancel={handleReset}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled>API</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setImportModalOpen(true)}>
|
||||
<Upload /> Import entities
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleDelete} variant="destructive">
|
||||
Delete
|
||||
<DropdownMenuShortcut>⇧⌘Q</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
<ImportSheet sketchId={sketchId} />
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
1
flowsint-app/src/env.d.ts
vendored
1
flowsint-app/src/env.d.ts
vendored
@@ -1 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
declare module '@tanstack/react-table';
|
||||
|
||||
@@ -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<GraphGeneralSettingsStore>()(
|
||||
// UI State
|
||||
settingsModalOpen: false,
|
||||
keyboardShortcutsOpen: false,
|
||||
|
||||
importModalOpen: false,
|
||||
// Core methods
|
||||
updateSetting: (category, key, value) =>
|
||||
set((state) => {
|
||||
@@ -227,7 +229,6 @@ export const useGraphSettingsStore = create<GraphGeneralSettingsStore>()(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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<GraphGeneralSettingsStore>()(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
settings: newSettings,
|
||||
forceSettings: newForceSettings,
|
||||
@@ -313,6 +313,7 @@ export const useGraphSettingsStore = create<GraphGeneralSettingsStore>()(
|
||||
// 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) => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user