feat(app): import modal

This commit is contained in:
dextmorgn
2025-11-05 15:36:09 +01:00
parent 91a01a23ef
commit cebeca5066
9 changed files with 700 additions and 23 deletions

View File

@@ -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: {

View File

@@ -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
})
}
}

View File

@@ -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 >
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -1 +1,2 @@
/// <reference types="vite/client" />
declare module '@tanstack/react-table';

View File

@@ -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) => {

View File

@@ -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"