diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer.tsx deleted file mode 100644 index cf72a3c14..000000000 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer.tsx +++ /dev/null @@ -1,961 +0,0 @@ -'use client' - -import { useCallback, useRef, useState } from 'react' -import { createLogger } from '@sim/logger' -import { useQuery } from '@tanstack/react-query' -import { Copy, Edit, Info, Plus, RefreshCw, Trash2, X } from 'lucide-react' -import { useParams, useRouter } from 'next/navigation' -import { - Badge, - Button, - Checkbox, - Modal, - ModalBody, - ModalContent, - Popover, - PopoverAnchor, - PopoverContent, - PopoverDivider, - PopoverItem, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, - Tooltip, -} from '@/components/emcn' -import { Skeleton } from '@/components/ui/skeleton' -import { cn } from '@/lib/core/utils/cn' -import type { ColumnDefinition, TableSchema } from '@/lib/table' -import { AddRowModal } from './components/add-row-modal' -import { DeleteRowModal } from './components/delete-row-modal' -import { EditRowModal } from './components/edit-row-modal' -import { FilterBuilder, type QueryOptions } from './components/filter-builder' -import { TableActionBar } from './components/table-action-bar' - -const logger = createLogger('TableDataViewer') - -/** Number of rows to fetch per page */ -const ROWS_PER_PAGE = 100 - -/** Maximum length for string display before truncation */ -const STRING_TRUNCATE_LENGTH = 50 - -/** - * Represents row data stored in a table. - */ -interface TableRowData { - /** Unique identifier for the row */ - id: string - /** Row field values keyed by column name */ - data: Record - /** ISO timestamp when the row was created */ - createdAt: string - /** ISO timestamp when the row was last updated */ - updatedAt: string -} - -/** - * Represents table metadata. - */ -interface TableData { - /** Unique identifier for the table */ - id: string - /** Table name */ - name: string - /** Optional description */ - description?: string - /** Schema defining columns */ - schema: TableSchema - /** Current number of rows */ - rowCount: number - /** Maximum allowed rows */ - maxRows: number - /** ISO timestamp when created */ - createdAt: string - /** ISO timestamp when last updated */ - updatedAt: string -} - -/** - * Data for the cell viewer modal. - */ -interface CellViewerData { - /** Name of the column being viewed */ - columnName: string - /** Value being displayed */ - value: unknown - /** Display type for formatting */ - type: 'json' | 'text' | 'date' -} - -/** - * State for the right-click context menu. - */ -interface ContextMenuState { - /** Whether the menu is visible */ - isOpen: boolean - /** Screen position of the menu */ - position: { x: number; y: number } - /** Row the menu was opened on */ - row: TableRowData | null -} - -/** - * Gets the badge variant for a column type. - * - * @param type - The column type - * @returns Badge variant name - */ -function getTypeBadgeVariant( - type: string -): 'green' | 'blue' | 'purple' | 'orange' | 'teal' | 'gray' { - switch (type) { - case 'string': - return 'green' - case 'number': - return 'blue' - case 'boolean': - return 'purple' - case 'json': - return 'orange' - case 'date': - return 'teal' - default: - return 'gray' - } -} - -/** - * Main component for viewing and managing table data. - * - * @remarks - * Provides functionality for: - * - Viewing rows with pagination - * - Filtering and sorting - * - Adding, editing, and deleting rows - * - Viewing cell details for long/complex values - * - * @example - * ```tsx - * - * ``` - */ -export function TableDataViewer() { - const params = useParams() - const router = useRouter() - - const workspaceId = params.workspaceId as string - const tableId = params.tableId as string - - const [selectedRows, setSelectedRows] = useState>(new Set()) - const [queryOptions, setQueryOptions] = useState({ - filter: null, - sort: null, - }) - const [currentPage, setCurrentPage] = useState(0) - const [showAddModal, setShowAddModal] = useState(false) - const [editingRow, setEditingRow] = useState(null) - const [deletingRows, setDeletingRows] = useState([]) - const [cellViewer, setCellViewer] = useState(null) - const [showSchemaModal, setShowSchemaModal] = useState(false) - const [copied, setCopied] = useState(false) - - // Context menu state - const [contextMenu, setContextMenu] = useState({ - isOpen: false, - position: { x: 0, y: 0 }, - row: null, - }) - const contextMenuRef = useRef(null) - - // Fetch table metadata - const { data: tableData, isLoading: isLoadingTable } = useQuery({ - queryKey: ['table', tableId], - queryFn: async () => { - const res = await fetch(`/api/table/${tableId}?workspaceId=${workspaceId}`) - if (!res.ok) throw new Error('Failed to fetch table') - const json: { data?: { table: TableData }; table?: TableData } = await res.json() - const data = json.data || json - return (data as { table: TableData }).table - }, - }) - - // Fetch table rows with filter and sort - const { - data: rowsData, - isLoading: isLoadingRows, - refetch: refetchRows, - } = useQuery({ - queryKey: ['table-rows', tableId, queryOptions, currentPage], - queryFn: async () => { - const searchParams = new URLSearchParams({ - workspaceId, - limit: String(ROWS_PER_PAGE), - offset: String(currentPage * ROWS_PER_PAGE), - }) - - if (queryOptions.filter) { - searchParams.set('filter', JSON.stringify(queryOptions.filter)) - } - - if (queryOptions.sort) { - // Convert from {column, direction} to {column: direction} format expected by API - const sortParam = { [queryOptions.sort.column]: queryOptions.sort.direction } - searchParams.set('sort', JSON.stringify(sortParam)) - } - - const res = await fetch(`/api/table/${tableId}/rows?${searchParams}`) - if (!res.ok) throw new Error('Failed to fetch rows') - const json: { - data?: { rows: TableRowData[]; totalCount: number } - rows?: TableRowData[] - totalCount?: number - } = await res.json() - return json.data || json - }, - enabled: !!tableData, - }) - - const columns = tableData?.schema?.columns || [] - const rows = (rowsData?.rows || []) as TableRowData[] - const totalCount = rowsData?.totalCount || 0 - const totalPages = Math.ceil(totalCount / ROWS_PER_PAGE) - - /** - * Applies new query options and resets pagination. - */ - const handleApplyQueryOptions = useCallback((options: QueryOptions) => { - setQueryOptions(options) - setCurrentPage(0) - }, []) - - /** - * Toggles selection of all visible rows. - */ - const handleSelectAll = useCallback(() => { - if (selectedRows.size === rows.length) { - setSelectedRows(new Set()) - } else { - setSelectedRows(new Set(rows.map((r) => r.id))) - } - }, [rows, selectedRows.size]) - - /** - * Toggles selection of a single row. - */ - const handleSelectRow = useCallback((rowId: string) => { - setSelectedRows((prev) => { - const newSet = new Set(prev) - if (newSet.has(rowId)) { - newSet.delete(rowId) - } else { - newSet.add(rowId) - } - return newSet - }) - }, []) - - /** - * Refreshes the rows data. - */ - const handleRefresh = useCallback(() => { - refetchRows() - }, [refetchRows]) - - /** - * Opens the delete modal for selected rows. - */ - const handleDeleteSelected = useCallback(() => { - setDeletingRows(Array.from(selectedRows)) - }, [selectedRows]) - - /** - * Opens the context menu for a row. - */ - const handleRowContextMenu = useCallback((e: React.MouseEvent, row: TableRowData) => { - e.preventDefault() - e.stopPropagation() - setContextMenu({ - isOpen: true, - position: { x: e.clientX, y: e.clientY }, - row, - }) - }, []) - - /** - * Closes the context menu. - */ - const closeContextMenu = useCallback(() => { - setContextMenu((prev) => ({ ...prev, isOpen: false })) - }, []) - - /** - * Handles edit action from context menu. - */ - const handleContextMenuEdit = useCallback(() => { - if (contextMenu.row) { - setEditingRow(contextMenu.row) - } - closeContextMenu() - }, [contextMenu.row, closeContextMenu]) - - /** - * Handles delete action from context menu. - */ - const handleContextMenuDelete = useCallback(() => { - if (contextMenu.row) { - setDeletingRows([contextMenu.row.id]) - } - closeContextMenu() - }, [contextMenu.row, closeContextMenu]) - - /** - * Copies the current cell value to clipboard. - */ - const handleCopyCellValue = useCallback(async () => { - if (cellViewer) { - let text: string - if (cellViewer.type === 'json') { - text = JSON.stringify(cellViewer.value, null, 2) - } else if (cellViewer.type === 'date') { - text = String(cellViewer.value) - } else { - text = String(cellViewer.value) - } - await navigator.clipboard.writeText(text) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } - }, [cellViewer]) - - /** - * Opens the cell viewer modal. - */ - const handleCellClick = useCallback( - (e: React.MouseEvent, columnName: string, value: unknown, type: 'json' | 'text' | 'date') => { - e.preventDefault() - e.stopPropagation() - setCellViewer({ columnName, value, type }) - }, - [] - ) - - /** - * Renders a cell value with appropriate formatting. - */ - const renderCellValue = (value: unknown, column: ColumnDefinition) => { - const isNull = value === null || value === undefined - - if (isNull) { - return - } - - if (column.type === 'json') { - const jsonStr = JSON.stringify(value) - return ( - - ) - } - - if (column.type === 'boolean') { - const boolValue = Boolean(value) - return ( - - {boolValue ? 'true' : 'false'} - - ) - } - - if (column.type === 'number') { - return ( - {String(value)} - ) - } - - if (column.type === 'date') { - try { - const date = new Date(String(value)) - const formatted = date.toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }) - return ( - - ) - } catch { - return {String(value)} - } - } - - // Handle long strings - const strValue = String(value) - if (strValue.length > STRING_TRUNCATE_LENGTH) { - return ( - - ) - } - - return {strValue} - } - - if (isLoadingTable) { - return ( -
- Loading table... -
- ) - } - - if (!tableData) { - return ( -
- Table not found -
- ) - } - - return ( -
- {/* Header */} -
-
- - / - - {tableData.name} - - {isLoadingRows ? ( - - ) : ( - - {totalCount} {totalCount === 1 ? 'row' : 'rows'} - - )} -
- -
- - - - - View Schema - - - - - - - Refresh - -
-
- - {/* Filter Bar */} -
- setShowAddModal(true)} - /> - {selectedRows.size > 0 && ( - - {selectedRows.size} selected - - )} -
- - {/* Action Bar */} - {selectedRows.size > 0 && ( - setSelectedRows(new Set())} - /> - )} - - {/* Table */} -
- - - - - 0} - onCheckedChange={handleSelectAll} - /> - - {columns.map((column) => ( - -
- {column.name} - - {column.type} - - {column.required && ( - * - )} -
-
- ))} - Actions -
-
- - {isLoadingRows ? ( - - ) : rows.length === 0 ? ( - setShowAddModal(true)} - /> - ) : ( - rows.map((row) => ( - handleRowContextMenu(e, row)} - > - - handleSelectRow(row.id)} - /> - - {columns.map((column) => ( - -
- {renderCellValue(row.data[column.name], column)} -
-
- ))} - -
- - - - - Edit - - - - - - Delete - -
-
-
- )) - )} -
-
-
- - {/* Pagination */} - {totalPages > 1 && ( -
- - Page {currentPage + 1} of {totalPages} ({totalCount} rows) - -
- - -
-
- )} - - {/* Modals */} - setShowAddModal(false)} - table={tableData} - onSuccess={() => { - refetchRows() - setShowAddModal(false) - }} - /> - - {editingRow && ( - setEditingRow(null)} - table={tableData} - row={editingRow} - onSuccess={() => { - refetchRows() - setEditingRow(null) - }} - /> - )} - - {deletingRows.length > 0 && ( - setDeletingRows([])} - tableId={tableId} - rowIds={deletingRows} - onSuccess={() => { - refetchRows() - setDeletingRows([]) - setSelectedRows(new Set()) - }} - /> - )} - - {/* Schema Viewer Modal */} - setShowSchemaModal(false)} - columns={columns} - /> - - {/* Cell Viewer Modal */} - setCellViewer(null)} - onCopy={handleCopyCellValue} - copied={copied} - /> - - {/* Row Context Menu */} - !open && closeContextMenu()} - variant='secondary' - size='sm' - colorScheme='inverted' - > - - e.preventDefault()} - onInteractOutside={(e) => e.preventDefault()} - > - - - Edit row - - - - - Delete row - - - -
- ) -} - -/** - * Loading skeleton for table rows. - */ -function LoadingRows({ columns }: { columns: ColumnDefinition[] }) { - return ( - <> - {Array.from({ length: 25 }).map((_, rowIndex) => ( - - - - - {columns.map((col, colIndex) => { - const baseWidth = - col.type === 'json' - ? 200 - : col.type === 'string' - ? 160 - : col.type === 'number' - ? 80 - : col.type === 'boolean' - ? 50 - : col.type === 'date' - ? 100 - : 120 - const variation = ((rowIndex + colIndex) % 3) * 20 - const width = baseWidth + variation - - return ( - - - - ) - })} - -
- - -
-
-
- ))} - - ) -} - -/** - * Empty state for table rows. - */ -function EmptyRows({ - columnCount, - hasFilter, - onAddRow, -}: { - columnCount: number - hasFilter: boolean - onAddRow: () => void -}) { - return ( - - -
- - {hasFilter ? 'No rows match your filter' : 'No data'} - - {!hasFilter && ( - - )} -
-
-
- ) -} - -/** - * Modal for viewing table schema. - */ -function SchemaViewerModal({ - isOpen, - onClose, - columns, -}: { - isOpen: boolean - onClose: () => void - columns: ColumnDefinition[] -}) { - return ( - - -
-
- - Table Schema - - {columns.length} columns - -
- -
- -
- - - - Column - Type - Constraints - - - - {columns.map((column) => ( - - - {column.name} - - - - {column.type} - - - -
- {column.required && ( - - required - - )} - {column.unique && ( - - unique - - )} - {!column.required && !column.unique && ( - - )} -
-
-
- ))} -
-
-
-
-
-
- ) -} - -/** - * Modal for viewing cell details. - */ -function CellViewerModal({ - cellViewer, - onClose, - onCopy, - copied, -}: { - cellViewer: CellViewerData | null - onClose: () => void - onCopy: () => void - copied: boolean -}) { - if (!cellViewer) return null - - return ( - !open && onClose()}> - -
-
- - {cellViewer.columnName} - - - {cellViewer.type === 'json' ? 'JSON' : cellViewer.type === 'date' ? 'Date' : 'Text'} - -
-
- - -
-
- - {cellViewer.type === 'json' ? ( -
-              {JSON.stringify(cellViewer.value, null, 2)}
-            
- ) : cellViewer.type === 'date' ? ( -
-
-
- Formatted -
-
- {new Date(String(cellViewer.value)).toLocaleDateString('en-US', { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - timeZoneName: 'short', - })} -
-
-
-
- ISO Format -
-
- {String(cellViewer.value)} -
-
-
- ) : ( -
- {String(cellViewer.value)} -
- )} -
-
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/components/cell-renderer.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/components/cell-renderer.tsx new file mode 100644 index 000000000..8d7ec8f9d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/components/cell-renderer.tsx @@ -0,0 +1,112 @@ +/** + * Cell value renderer for different column types. + * + * @module tables/[tableId]/table-data-viewer/components/cell-renderer + */ + +import type { ColumnDefinition } from '@/lib/table' +import { STRING_TRUNCATE_LENGTH } from '../constants' +import type { CellViewerData } from '../types' + +interface CellRendererProps { + value: unknown + column: ColumnDefinition + onCellClick: (columnName: string, value: unknown, type: CellViewerData['type']) => void +} + +/** + * Renders a cell value with appropriate formatting based on column type. + * + * @param props - Component props + * @returns Formatted cell content + */ +export function CellRenderer({ value, column, onCellClick }: CellRendererProps) { + const isNull = value === null || value === undefined + + if (isNull) { + return + } + + if (column.type === 'json') { + const jsonStr = JSON.stringify(value) + return ( + + ) + } + + if (column.type === 'boolean') { + const boolValue = Boolean(value) + return ( + + {boolValue ? 'true' : 'false'} + + ) + } + + if (column.type === 'number') { + return ( + {String(value)} + ) + } + + if (column.type === 'date') { + try { + const date = new Date(String(value)) + const formatted = date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + return ( + + ) + } catch { + return {String(value)} + } + } + + // Handle long strings + const strValue = String(value) + if (strValue.length > STRING_TRUNCATE_LENGTH) { + return ( + + ) + } + + return {strValue} +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/components/cell-viewer-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/components/cell-viewer-modal.tsx new file mode 100644 index 000000000..660af9fca --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/components/cell-viewer-modal.tsx @@ -0,0 +1,96 @@ +/** + * Modal for viewing cell details. + * + * @module tables/[tableId]/table-data-viewer/components/cell-viewer-modal + */ + +import { Copy, X } from 'lucide-react' +import { Badge, Button, Modal, ModalBody, ModalContent } from '@/components/emcn' +import type { CellViewerData } from '../types' + +interface CellViewerModalProps { + cellViewer: CellViewerData | null + onClose: () => void + onCopy: () => void + copied: boolean +} + +/** + * Displays cell value details in a modal. + * + * @param props - Component props + * @returns Cell viewer modal or null if no cell is selected + */ +export function CellViewerModal({ cellViewer, onClose, onCopy, copied }: CellViewerModalProps) { + if (!cellViewer) return null + + return ( + !open && onClose()}> + +
+
+ + {cellViewer.columnName} + + + {cellViewer.type === 'json' ? 'JSON' : cellViewer.type === 'date' ? 'Date' : 'Text'} + +
+
+ + +
+
+ + {cellViewer.type === 'json' ? ( +
+              {JSON.stringify(cellViewer.value, null, 2)}
+            
+ ) : cellViewer.type === 'date' ? ( +
+
+
+ Formatted +
+
+ {new Date(String(cellViewer.value)).toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + timeZoneName: 'short', + })} +
+
+
+
+ ISO Format +
+
+ {String(cellViewer.value)} +
+
+
+ ) : ( +
+ {String(cellViewer.value)} +
+ )} +
+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/components/empty-rows.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/components/empty-rows.tsx new file mode 100644 index 000000000..ad53d2a82 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/components/empty-rows.tsx @@ -0,0 +1,40 @@ +/** + * Empty state component for table rows. + * + * @module tables/[tableId]/table-data-viewer/components/empty-rows + */ + +import { Plus } from 'lucide-react' +import { Button, TableCell, TableRow } from '@/components/emcn' + +interface EmptyRowsProps { + columnCount: number + hasFilter: boolean + onAddRow: () => void +} + +/** + * Renders an empty state when no rows are present. + * + * @param props - Component props + * @returns Empty state row + */ +export function EmptyRows({ columnCount, hasFilter, onAddRow }: EmptyRowsProps) { + return ( + + +
+ + {hasFilter ? 'No rows match your filter' : 'No data'} + + {!hasFilter && ( + + )} +
+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/components/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/components/index.ts new file mode 100644 index 000000000..76e7c8ac6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/components/index.ts @@ -0,0 +1,14 @@ +/** + * Table data viewer sub-components. + * + * @module tables/[tableId]/table-data-viewer/components + */ + +export * from './cell-renderer' +export * from './cell-viewer-modal' +export * from './empty-rows' +export * from './loading-rows' +export * from './pagination' +export * from './row-context-menu' +export * from './schema-viewer-modal' +export * from './table-header-bar' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/components/loading-rows.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/components/loading-rows.tsx new file mode 100644 index 000000000..a6446fc7c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/components/loading-rows.tsx @@ -0,0 +1,61 @@ +/** + * Loading skeleton for table rows. + * + * @module tables/[tableId]/table-data-viewer/components/loading-rows + */ + +import { TableCell, TableRow } from '@/components/emcn' +import { Skeleton } from '@/components/ui/skeleton' +import type { ColumnDefinition } from '@/lib/table' + +interface LoadingRowsProps { + columns: ColumnDefinition[] +} + +/** + * Renders skeleton rows while table data is loading. + * + * @param props - Component props + * @returns Loading skeleton rows + */ +export function LoadingRows({ columns }: LoadingRowsProps) { + return ( + <> + {Array.from({ length: 25 }).map((_, rowIndex) => ( + + + + + {columns.map((col, colIndex) => { + const baseWidth = + col.type === 'json' + ? 200 + : col.type === 'string' + ? 160 + : col.type === 'number' + ? 80 + : col.type === 'boolean' + ? 50 + : col.type === 'date' + ? 100 + : 120 + const variation = ((rowIndex + colIndex) % 3) * 20 + const width = baseWidth + variation + + return ( + + + + ) + })} + +
+ + +
+
+
+ ))} + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/components/pagination.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/components/pagination.tsx new file mode 100644 index 000000000..78347b50f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/components/pagination.tsx @@ -0,0 +1,52 @@ +/** + * Pagination controls for the table. + * + * @module tables/[tableId]/table-data-viewer/components/pagination + */ + +import { Button } from '@/components/emcn' + +interface PaginationProps { + currentPage: number + totalPages: number + totalCount: number + onPreviousPage: () => void + onNextPage: () => void +} + +/** + * Renders pagination controls for navigating table pages. + * + * @param props - Component props + * @returns Pagination controls or null if only one page + */ +export function Pagination({ + currentPage, + totalPages, + totalCount, + onPreviousPage, + onNextPage, +}: PaginationProps) { + if (totalPages <= 1) return null + + return ( +
+ + Page {currentPage + 1} of {totalPages} ({totalCount} rows) + +
+ + +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/components/row-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/components/row-context-menu.tsx new file mode 100644 index 000000000..e860da611 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/components/row-context-menu.tsx @@ -0,0 +1,67 @@ +/** + * Context menu for row actions. + * + * @module tables/[tableId]/table-data-viewer/components/row-context-menu + */ + +import { Edit, Trash2 } from 'lucide-react' +import { + Popover, + PopoverAnchor, + PopoverContent, + PopoverDivider, + PopoverItem, +} from '@/components/emcn' +import type { ContextMenuState } from '../types' + +interface RowContextMenuProps { + contextMenu: ContextMenuState + onClose: () => void + onEdit: () => void + onDelete: () => void +} + +/** + * Renders a context menu for row actions. + * + * @param props - Component props + * @returns Row context menu popover + */ +export function RowContextMenu({ contextMenu, onClose, onEdit, onDelete }: RowContextMenuProps) { + return ( + !open && onClose()} + variant='secondary' + size='sm' + colorScheme='inverted' + > + + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + > + + + Edit row + + + + + Delete row + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/components/schema-viewer-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/components/schema-viewer-modal.tsx new file mode 100644 index 000000000..cfb562154 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/components/schema-viewer-modal.tsx @@ -0,0 +1,99 @@ +/** + * Modal for viewing table schema. + * + * @module tables/[tableId]/table-data-viewer/components/schema-viewer-modal + */ + +import { Info, X } from 'lucide-react' +import { + Badge, + Button, + Modal, + ModalBody, + ModalContent, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/emcn' +import type { ColumnDefinition } from '@/lib/table' +import { getTypeBadgeVariant } from '../utils' + +interface SchemaViewerModalProps { + isOpen: boolean + onClose: () => void + columns: ColumnDefinition[] +} + +/** + * Displays the table schema in a modal. + * + * @param props - Component props + * @returns Schema viewer modal + */ +export function SchemaViewerModal({ isOpen, onClose, columns }: SchemaViewerModalProps) { + return ( + + +
+
+ + Table Schema + + {columns.length} columns + +
+ +
+ +
+ + + + Column + Type + Constraints + + + + {columns.map((column) => ( + + + {column.name} + + + + {column.type} + + + +
+ {column.required && ( + + required + + )} + {column.unique && ( + + unique + + )} + {!column.required && !column.unique && ( + + )} +
+
+
+ ))} +
+
+
+
+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/components/table-header-bar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/components/table-header-bar.tsx new file mode 100644 index 000000000..741f6d431 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/components/table-header-bar.tsx @@ -0,0 +1,75 @@ +/** + * Header bar for the table data viewer. + * + * @module tables/[tableId]/table-data-viewer/components/table-header-bar + */ + +import { Info, RefreshCw } from 'lucide-react' +import { Badge, Button, Tooltip } from '@/components/emcn' +import { Skeleton } from '@/components/ui/skeleton' + +interface TableHeaderBarProps { + tableName: string + totalCount: number + isLoading: boolean + onNavigateBack: () => void + onShowSchema: () => void + onRefresh: () => void +} + +/** + * Renders the header bar with navigation, title, and actions. + * + * @param props - Component props + * @returns Table header bar + */ +export function TableHeaderBar({ + tableName, + totalCount, + isLoading, + onNavigateBack, + onShowSchema, + onRefresh, +}: TableHeaderBarProps) { + return ( +
+
+ + / + {tableName} + {isLoading ? ( + + ) : ( + + {totalCount} {totalCount === 1 ? 'row' : 'rows'} + + )} +
+ +
+ + + + + View Schema + + + + + + + Refresh + +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/constants.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/constants.ts new file mode 100644 index 000000000..e1e812141 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/constants.ts @@ -0,0 +1,11 @@ +/** + * Constants for the table data viewer. + * + * @module tables/[tableId]/table-data-viewer/constants + */ + +/** Number of rows to fetch per page */ +export const ROWS_PER_PAGE = 100 + +/** Maximum length for string display before truncation */ +export const STRING_TRUNCATE_LENGTH = 50 diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/hooks/index.ts new file mode 100644 index 000000000..21102153d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/hooks/index.ts @@ -0,0 +1,9 @@ +/** + * Custom hooks for the table data viewer. + * + * @module tables/[tableId]/table-data-viewer/hooks + */ + +export * from './use-context-menu' +export * from './use-row-selection' +export * from './use-table-data' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/hooks/use-context-menu.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/hooks/use-context-menu.ts new file mode 100644 index 000000000..5223ff306 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/hooks/use-context-menu.ts @@ -0,0 +1,53 @@ +/** + * Hook for managing context menu state. + * + * @module tables/[tableId]/table-data-viewer/hooks/use-context-menu + */ + +import { useCallback, useState } from 'react' +import type { ContextMenuState, TableRowData } from '../types' + +interface UseContextMenuReturn { + contextMenu: ContextMenuState + handleRowContextMenu: (e: React.MouseEvent, row: TableRowData) => void + closeContextMenu: () => void +} + +/** + * Manages context menu state for row interactions. + * + * @returns Context menu state and handlers + */ +export function useContextMenu(): UseContextMenuReturn { + const [contextMenu, setContextMenu] = useState({ + isOpen: false, + position: { x: 0, y: 0 }, + row: null, + }) + + /** + * Opens the context menu for a row. + */ + const handleRowContextMenu = useCallback((e: React.MouseEvent, row: TableRowData) => { + e.preventDefault() + e.stopPropagation() + setContextMenu({ + isOpen: true, + position: { x: e.clientX, y: e.clientY }, + row, + }) + }, []) + + /** + * Closes the context menu. + */ + const closeContextMenu = useCallback(() => { + setContextMenu((prev) => ({ ...prev, isOpen: false })) + }, []) + + return { + contextMenu, + handleRowContextMenu, + closeContextMenu, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/hooks/use-row-selection.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/hooks/use-row-selection.ts new file mode 100644 index 000000000..dc0c07668 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/hooks/use-row-selection.ts @@ -0,0 +1,65 @@ +/** + * Hook for managing row selection state. + * + * @module tables/[tableId]/table-data-viewer/hooks/use-row-selection + */ + +import { useCallback, useState } from 'react' +import type { TableRowData } from '../types' + +interface UseRowSelectionReturn { + selectedRows: Set + handleSelectAll: () => void + handleSelectRow: (rowId: string) => void + clearSelection: () => void +} + +/** + * Manages row selection state and provides selection handlers. + * + * @param rows - The current rows to select from + * @returns Selection state and handlers + */ +export function useRowSelection(rows: TableRowData[]): UseRowSelectionReturn { + const [selectedRows, setSelectedRows] = useState>(new Set()) + + /** + * Toggles selection of all visible rows. + */ + const handleSelectAll = useCallback(() => { + if (selectedRows.size === rows.length) { + setSelectedRows(new Set()) + } else { + setSelectedRows(new Set(rows.map((r) => r.id))) + } + }, [rows, selectedRows.size]) + + /** + * Toggles selection of a single row. + */ + const handleSelectRow = useCallback((rowId: string) => { + setSelectedRows((prev) => { + const newSet = new Set(prev) + if (newSet.has(rowId)) { + newSet.delete(rowId) + } else { + newSet.add(rowId) + } + return newSet + }) + }, []) + + /** + * Clears all selections. + */ + const clearSelection = useCallback(() => { + setSelectedRows(new Set()) + }, []) + + return { + selectedRows, + handleSelectAll, + handleSelectRow, + clearSelection, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/hooks/use-table-data.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/hooks/use-table-data.ts new file mode 100644 index 000000000..25797cc6a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/hooks/use-table-data.ts @@ -0,0 +1,102 @@ +/** + * Hook for fetching table data and rows. + * + * @module tables/[tableId]/table-data-viewer/hooks/use-table-data + */ + +import { useQuery } from '@tanstack/react-query' +import type { QueryOptions } from '../../components/filter-builder' +import { ROWS_PER_PAGE } from '../constants' +import type { TableData, TableRowData } from '../types' + +interface UseTableDataParams { + workspaceId: string + tableId: string + queryOptions: QueryOptions + currentPage: number +} + +interface UseTableDataReturn { + tableData: TableData | undefined + isLoadingTable: boolean + rows: TableRowData[] + totalCount: number + totalPages: number + isLoadingRows: boolean + refetchRows: () => void +} + +/** + * Fetches table metadata and rows with filtering/sorting support. + * + * @param params - The parameters for fetching table data + * @returns Table data, rows, and loading states + */ +export function useTableData({ + workspaceId, + tableId, + queryOptions, + currentPage, +}: UseTableDataParams): UseTableDataReturn { + // Fetch table metadata + const { data: tableData, isLoading: isLoadingTable } = useQuery({ + queryKey: ['table', tableId], + queryFn: async () => { + const res = await fetch(`/api/table/${tableId}?workspaceId=${workspaceId}`) + if (!res.ok) throw new Error('Failed to fetch table') + const json: { data?: { table: TableData }; table?: TableData } = await res.json() + const data = json.data || json + return (data as { table: TableData }).table + }, + }) + + // Fetch table rows with filter and sort + const { + data: rowsData, + isLoading: isLoadingRows, + refetch: refetchRows, + } = useQuery({ + queryKey: ['table-rows', tableId, queryOptions, currentPage], + queryFn: async () => { + const searchParams = new URLSearchParams({ + workspaceId, + limit: String(ROWS_PER_PAGE), + offset: String(currentPage * ROWS_PER_PAGE), + }) + + if (queryOptions.filter) { + searchParams.set('filter', JSON.stringify(queryOptions.filter)) + } + + if (queryOptions.sort) { + // Convert from {column, direction} to {column: direction} format expected by API + const sortParam = { [queryOptions.sort.column]: queryOptions.sort.direction } + searchParams.set('sort', JSON.stringify(sortParam)) + } + + const res = await fetch(`/api/table/${tableId}/rows?${searchParams}`) + if (!res.ok) throw new Error('Failed to fetch rows') + const json: { + data?: { rows: TableRowData[]; totalCount: number } + rows?: TableRowData[] + totalCount?: number + } = await res.json() + return json.data || json + }, + enabled: !!tableData, + }) + + const rows = (rowsData?.rows || []) as TableRowData[] + const totalCount = rowsData?.totalCount || 0 + const totalPages = Math.ceil(totalCount / ROWS_PER_PAGE) + + return { + tableData, + isLoadingTable, + rows, + totalCount, + totalPages, + isLoadingRows, + refetchRows, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/index.ts new file mode 100644 index 000000000..7efb3309d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/index.ts @@ -0,0 +1,8 @@ +/** + * Table data viewer module. + * + * @module tables/[tableId]/table-data-viewer + */ + +export * from './table-data-viewer' +export * from './types' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/table-data-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/table-data-viewer.tsx new file mode 100644 index 000000000..8cb00ebe5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/table-data-viewer.tsx @@ -0,0 +1,385 @@ +'use client' + +/** + * Main table data viewer component. + * + * @module tables/[tableId]/table-data-viewer/table-data-viewer + */ + +import { useCallback, useState } from 'react' +import { Edit, Trash2 } from 'lucide-react' +import { useParams, useRouter } from 'next/navigation' +import { + Badge, + Button, + Checkbox, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Tooltip, +} from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' +import { + AddRowModal, + DeleteRowModal, + EditRowModal, + FilterBuilder, + type QueryOptions, + TableActionBar, +} from '../components' +import { + CellRenderer, + CellViewerModal, + EmptyRows, + LoadingRows, + Pagination, + RowContextMenu, + SchemaViewerModal, + TableHeaderBar, +} from './components' +import { useContextMenu, useRowSelection, useTableData } from './hooks' +import type { CellViewerData, TableRowData } from './types' + +/** + * Main component for viewing and managing table data. + * + * @remarks + * Provides functionality for: + * - Viewing rows with pagination + * - Filtering and sorting + * - Adding, editing, and deleting rows + * - Viewing cell details for long/complex values + * + * @example + * ```tsx + * + * ``` + */ +export function TableDataViewer() { + const params = useParams() + const router = useRouter() + + const workspaceId = params.workspaceId as string + const tableId = params.tableId as string + + // Query state + const [queryOptions, setQueryOptions] = useState({ + filter: null, + sort: null, + }) + const [currentPage, setCurrentPage] = useState(0) + + // Modal state + const [showAddModal, setShowAddModal] = useState(false) + const [editingRow, setEditingRow] = useState(null) + const [deletingRows, setDeletingRows] = useState([]) + const [showSchemaModal, setShowSchemaModal] = useState(false) + + // Cell viewer state + const [cellViewer, setCellViewer] = useState(null) + const [copied, setCopied] = useState(false) + + // Fetch table data + const { tableData, isLoadingTable, rows, totalCount, totalPages, isLoadingRows, refetchRows } = + useTableData({ + workspaceId, + tableId, + queryOptions, + currentPage, + }) + + // Row selection + const { selectedRows, handleSelectAll, handleSelectRow, clearSelection } = useRowSelection(rows) + + // Context menu + const { contextMenu, handleRowContextMenu, closeContextMenu } = useContextMenu() + + const columns = tableData?.schema?.columns || [] + + /** + * Applies new query options and resets pagination. + */ + const handleApplyQueryOptions = useCallback((options: QueryOptions) => { + setQueryOptions(options) + setCurrentPage(0) + }, []) + + /** + * Opens the delete modal for selected rows. + */ + const handleDeleteSelected = useCallback(() => { + setDeletingRows(Array.from(selectedRows)) + }, [selectedRows]) + + /** + * Handles edit action from context menu. + */ + const handleContextMenuEdit = useCallback(() => { + if (contextMenu.row) { + setEditingRow(contextMenu.row) + } + closeContextMenu() + }, [contextMenu.row, closeContextMenu]) + + /** + * Handles delete action from context menu. + */ + const handleContextMenuDelete = useCallback(() => { + if (contextMenu.row) { + setDeletingRows([contextMenu.row.id]) + } + closeContextMenu() + }, [contextMenu.row, closeContextMenu]) + + /** + * Copies the current cell value to clipboard. + */ + const handleCopyCellValue = useCallback(async () => { + if (cellViewer) { + let text: string + if (cellViewer.type === 'json') { + text = JSON.stringify(cellViewer.value, null, 2) + } else if (cellViewer.type === 'date') { + text = String(cellViewer.value) + } else { + text = String(cellViewer.value) + } + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + }, [cellViewer]) + + /** + * Opens the cell viewer modal. + */ + const handleCellClick = useCallback( + (columnName: string, value: unknown, type: CellViewerData['type']) => { + setCellViewer({ columnName, value, type }) + }, + [] + ) + + if (isLoadingTable) { + return ( +
+ Loading table... +
+ ) + } + + if (!tableData) { + return ( +
+ Table not found +
+ ) + } + + return ( +
+ {/* Header */} + router.push(`/workspace/${workspaceId}/tables`)} + onShowSchema={() => setShowSchemaModal(true)} + onRefresh={refetchRows} + /> + + {/* Filter Bar */} +
+ setShowAddModal(true)} + /> + {selectedRows.size > 0 && ( + + {selectedRows.size} selected + + )} +
+ + {/* Action Bar */} + {selectedRows.size > 0 && ( + + )} + + {/* Table */} +
+ + + + + 0} + onCheckedChange={handleSelectAll} + /> + + {columns.map((column) => ( + +
+ {column.name} + + {column.type} + + {column.required && ( + * + )} +
+
+ ))} + Actions +
+
+ + {isLoadingRows ? ( + + ) : rows.length === 0 ? ( + setShowAddModal(true)} + /> + ) : ( + rows.map((row) => ( + handleRowContextMenu(e, row)} + > + + handleSelectRow(row.id)} + /> + + {columns.map((column) => ( + +
+ +
+
+ ))} + +
+ + + + + Edit + + + + + + Delete + +
+
+
+ )) + )} +
+
+
+ + {/* Pagination */} + setCurrentPage((p) => Math.max(0, p - 1))} + onNextPage={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))} + /> + + {/* Modals */} + setShowAddModal(false)} + table={tableData} + onSuccess={() => { + refetchRows() + setShowAddModal(false) + }} + /> + + {editingRow && ( + setEditingRow(null)} + table={tableData} + row={editingRow} + onSuccess={() => { + refetchRows() + setEditingRow(null) + }} + /> + )} + + {deletingRows.length > 0 && ( + setDeletingRows([])} + tableId={tableId} + rowIds={deletingRows} + onSuccess={() => { + refetchRows() + setDeletingRows([]) + clearSelection() + }} + /> + )} + + {/* Schema Viewer Modal */} + setShowSchemaModal(false)} + columns={columns} + /> + + {/* Cell Viewer Modal */} + setCellViewer(null)} + onCopy={handleCopyCellValue} + copied={copied} + /> + + {/* Row Context Menu */} + +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/types.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/types.ts new file mode 100644 index 000000000..d0d28274e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/types.ts @@ -0,0 +1,67 @@ +/** + * Type definitions for the table data viewer. + * + * @module tables/[tableId]/table-data-viewer/types + */ + +import type { TableSchema } from '@/lib/table' + +/** + * Represents row data stored in a table. + */ +export interface TableRowData { + /** Unique identifier for the row */ + id: string + /** Row field values keyed by column name */ + data: Record + /** ISO timestamp when the row was created */ + createdAt: string + /** ISO timestamp when the row was last updated */ + updatedAt: string +} + +/** + * Represents table metadata. + */ +export interface TableData { + /** Unique identifier for the table */ + id: string + /** Table name */ + name: string + /** Optional description */ + description?: string + /** Schema defining columns */ + schema: TableSchema + /** Current number of rows */ + rowCount: number + /** Maximum allowed rows */ + maxRows: number + /** ISO timestamp when created */ + createdAt: string + /** ISO timestamp when last updated */ + updatedAt: string +} + +/** + * Data for the cell viewer modal. + */ +export interface CellViewerData { + /** Name of the column being viewed */ + columnName: string + /** Value being displayed */ + value: unknown + /** Display type for formatting */ + type: 'json' | 'text' | 'date' +} + +/** + * State for the right-click context menu. + */ +export interface ContextMenuState { + /** Whether the menu is visible */ + isOpen: boolean + /** Screen position of the menu */ + position: { x: number; y: number } + /** Row the menu was opened on */ + row: TableRowData | null +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/utils.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/utils.ts new file mode 100644 index 000000000..c55dd2af4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer/utils.ts @@ -0,0 +1,30 @@ +/** + * Utility functions for the table data viewer. + * + * @module tables/[tableId]/table-data-viewer/utils + */ + +/** + * Gets the badge variant for a column type. + * + * @param type - The column type + * @returns Badge variant name + */ +export function getTypeBadgeVariant( + type: string +): 'green' | 'blue' | 'purple' | 'orange' | 'teal' | 'gray' { + switch (type) { + case 'string': + return 'green' + case 'number': + return 'blue' + case 'boolean': + return 'purple' + case 'json': + return 'orange' + case 'date': + return 'teal' + default: + return 'gray' + } +}