mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-22 13:28:04 -05:00
comments
This commit is contained in:
@@ -69,7 +69,7 @@ type FilterValue = string | number | boolean | null | FilterValue[] | { [key: st
|
||||
* Query options for the table API.
|
||||
*/
|
||||
export interface QueryOptions {
|
||||
/** Filter criteria or null for no filter */
|
||||
/** Filter criteria or null for no filter, keys are column names, values are filter values */
|
||||
filter: Record<string, FilterValue> | null
|
||||
/** Sort configuration or null for default sort */
|
||||
sort: SortConfig | null
|
||||
@@ -82,7 +82,7 @@ interface Column {
|
||||
/** Column name */
|
||||
name: string
|
||||
/** Column data type */
|
||||
type: string
|
||||
type: 'string' | 'number' | 'boolean' | 'json' | 'date'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,21 +134,44 @@ function parseArrayValue(value: string): FilterValue[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts filter conditions to MongoDB-style filter object.
|
||||
* Converts builder filter conditions to a MongoDB-style filter object.
|
||||
*
|
||||
* @param conditions - Array of filter conditions
|
||||
* @returns Filter object for API or null if no conditions
|
||||
* Iterates through an array of filter conditions, combining them into a filter expression object
|
||||
* that is compatible with MongoDB's query format. Supports both "AND" and "OR" logical groupings:
|
||||
*
|
||||
* - "AND" conditions are grouped together in objects.
|
||||
* - "OR" conditions start new groups; groups are merged under a single `$or` array.
|
||||
*
|
||||
* @param conditions - The list of filter conditions specified by the user.
|
||||
* @returns A filter object to send to the API, or null if there are no conditions.
|
||||
*
|
||||
* @example
|
||||
* [
|
||||
* { logicalOperator: 'and', column: 'age', operator: 'gt', value: '18' },
|
||||
* { logicalOperator: 'or', column: 'role', operator: 'eq', value: 'admin' }
|
||||
* ]
|
||||
* // =>
|
||||
* {
|
||||
* $or: [
|
||||
* { age: { $gt: 18 } },
|
||||
* { role: 'admin' }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
function conditionsToFilter(conditions: FilterCondition[]): Record<string, FilterValue> | null {
|
||||
// Return null if there are no filter conditions.
|
||||
if (conditions.length === 0) return null
|
||||
|
||||
// Groups for $or logic; each group is an AND-combined object.
|
||||
const orGroups: Record<string, FilterValue>[] = []
|
||||
// Current group of AND'ed conditions.
|
||||
let currentAndGroup: Record<string, FilterValue> = {}
|
||||
|
||||
conditions.forEach((condition, index) => {
|
||||
const { column, operator, value } = condition
|
||||
const operatorKey = `$${operator}`
|
||||
|
||||
// Parse value as per operator: 'in' receives an array, others get a primitive value.
|
||||
let parsedValue: FilterValue = value
|
||||
if (operator === 'in') {
|
||||
parsedValue = parseArrayValue(value)
|
||||
@@ -156,23 +179,31 @@ function conditionsToFilter(conditions: FilterCondition[]): Record<string, Filte
|
||||
parsedValue = parseValue(value)
|
||||
}
|
||||
|
||||
// For 'eq', value is direct (shorthand), otherwise use a key for the operator.
|
||||
const conditionObj: FilterValue =
|
||||
operator === 'eq' ? parsedValue : { [operatorKey]: parsedValue }
|
||||
|
||||
// Group logic:
|
||||
// - First condition or 'and': add to the current AND group.
|
||||
// - 'or': finalize current AND group and start a new one.
|
||||
if (index === 0 || condition.logicalOperator === 'and') {
|
||||
currentAndGroup[column] = conditionObj
|
||||
} else if (condition.logicalOperator === 'or') {
|
||||
if (Object.keys(currentAndGroup).length > 0) {
|
||||
// Finalize and push the previous AND group to $or groups.
|
||||
orGroups.push({ ...currentAndGroup })
|
||||
}
|
||||
// Start a new AND group for subsequent conditions.
|
||||
currentAndGroup = { [column]: conditionObj }
|
||||
}
|
||||
})
|
||||
|
||||
// Push the last AND group, if any, to the orGroups list.
|
||||
if (Object.keys(currentAndGroup).length > 0) {
|
||||
orGroups.push(currentAndGroup)
|
||||
}
|
||||
|
||||
// If multiple groups exist, return as a $or query; otherwise, return the single group.
|
||||
if (orGroups.length > 1) {
|
||||
return { $or: orGroups }
|
||||
}
|
||||
|
||||
@@ -89,7 +89,6 @@ export function CellRenderer({ value, column, onCellClick }: CellRendererProps)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle long strings
|
||||
const strValue = String(value)
|
||||
if (strValue.length > STRING_TRUNCATE_LENGTH) {
|
||||
return (
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* 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 (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columnCount + 2} className='h-[160px] text-center'>
|
||||
<div className='flex flex-col items-center gap-[12px]'>
|
||||
<span className='text-[13px] text-[var(--text-tertiary)]'>
|
||||
{hasFilter ? 'No rows match your filter' : 'No data'}
|
||||
</span>
|
||||
{!hasFilter && (
|
||||
<Button variant='default' size='sm' onClick={onAddRow}>
|
||||
<Plus className='mr-[4px] h-[12px] w-[12px]' />
|
||||
Add first row
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
@@ -6,9 +6,8 @@
|
||||
|
||||
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-body-states'
|
||||
export * from './table-header-bar'
|
||||
|
||||
@@ -45,13 +45,7 @@ export function RowContextMenu({ contextMenu, onClose, onEdit, onDelete }: RowCo
|
||||
height: '1px',
|
||||
}}
|
||||
/>
|
||||
<PopoverContent
|
||||
align='start'
|
||||
side='bottom'
|
||||
sideOffset={4}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<PopoverContent align='start' side='bottom' sideOffset={4}>
|
||||
<PopoverItem onClick={onEdit}>
|
||||
<Edit className='mr-[8px] h-[12px] w-[12px]' />
|
||||
Edit row
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* Loading skeleton for table rows.
|
||||
* Table body placeholder states (loading and empty).
|
||||
*
|
||||
* @module tables/[tableId]/table-data-viewer/components/loading-rows
|
||||
* @module tables/[tableId]/table-data-viewer/components/table-body-states
|
||||
*/
|
||||
|
||||
import { TableCell, TableRow } from '@/components/emcn'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Button, TableCell, TableRow } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import type { ColumnDefinition } from '@/lib/table'
|
||||
|
||||
@@ -14,9 +15,6 @@ interface LoadingRowsProps {
|
||||
|
||||
/**
|
||||
* Renders skeleton rows while table data is loading.
|
||||
*
|
||||
* @param props - Component props
|
||||
* @returns Loading skeleton rows
|
||||
*/
|
||||
export function LoadingRows({ columns }: LoadingRowsProps) {
|
||||
return (
|
||||
@@ -48,14 +46,37 @@ export function LoadingRows({ columns }: LoadingRowsProps) {
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
<TableCell>
|
||||
<div className='flex gap-[4px]'>
|
||||
<Skeleton className='h-[24px] w-[24px]' />
|
||||
<Skeleton className='h-[24px] w-[24px]' />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface EmptyRowsProps {
|
||||
columnCount: number
|
||||
hasFilter: boolean
|
||||
onAddRow: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an empty state when no rows are present.
|
||||
*/
|
||||
export function EmptyRows({ columnCount, hasFilter, onAddRow }: EmptyRowsProps) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columnCount + 1} className='h-[160px] text-center'>
|
||||
<div className='flex flex-col items-center gap-[12px]'>
|
||||
<span className='text-[13px] text-[var(--text-tertiary)]'>
|
||||
{hasFilter ? 'No rows match your filter' : 'No data'}
|
||||
</span>
|
||||
{!hasFilter && (
|
||||
<Button variant='default' size='sm' onClick={onAddRow}>
|
||||
<Plus className='mr-[4px] h-[12px] w-[12px]' />
|
||||
Add first row
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
@@ -38,7 +38,6 @@ export function useTableData({
|
||||
queryOptions,
|
||||
currentPage,
|
||||
}: UseTableDataParams): UseTableDataReturn {
|
||||
// Fetch table metadata
|
||||
const { data: tableData, isLoading: isLoadingTable } = useQuery({
|
||||
queryKey: ['table', tableId],
|
||||
queryFn: async () => {
|
||||
@@ -50,7 +49,6 @@ export function useTableData({
|
||||
},
|
||||
})
|
||||
|
||||
// Fetch table rows with filter and sort
|
||||
const {
|
||||
data: rowsData,
|
||||
isLoading: isLoadingRows,
|
||||
@@ -69,7 +67,6 @@ export function useTableData({
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -7,11 +7,9 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Edit, Trash2 } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -19,7 +17,6 @@ import {
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
@@ -65,24 +62,20 @@ export function TableDataViewer() {
|
||||
const workspaceId = params.workspaceId as string
|
||||
const tableId = params.tableId as string
|
||||
|
||||
// Query state
|
||||
const [queryOptions, setQueryOptions] = useState<QueryOptions>({
|
||||
filter: null,
|
||||
sort: null,
|
||||
})
|
||||
const [currentPage, setCurrentPage] = useState(0)
|
||||
|
||||
// Modal state
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [editingRow, setEditingRow] = useState<TableRowData | null>(null)
|
||||
const [deletingRows, setDeletingRows] = useState<string[]>([])
|
||||
const [showSchemaModal, setShowSchemaModal] = useState(false)
|
||||
|
||||
// Cell viewer state
|
||||
const [cellViewer, setCellViewer] = useState<CellViewerData | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
// Fetch table data
|
||||
const { tableData, isLoadingTable, rows, totalCount, totalPages, isLoadingRows, refetchRows } =
|
||||
useTableData({
|
||||
workspaceId,
|
||||
@@ -91,13 +84,35 @@ export function TableDataViewer() {
|
||||
currentPage,
|
||||
})
|
||||
|
||||
// Row selection
|
||||
const { selectedRows, handleSelectAll, handleSelectRow, clearSelection } = useRowSelection(rows)
|
||||
|
||||
// Context menu
|
||||
const { contextMenu, handleRowContextMenu, closeContextMenu } = useContextMenu()
|
||||
|
||||
const columns = tableData?.schema?.columns || []
|
||||
const selectedCount = selectedRows.size
|
||||
const hasSelection = selectedCount > 0
|
||||
const isAllSelected = rows.length > 0 && selectedCount === rows.length
|
||||
|
||||
/**
|
||||
* Navigates back to the tables list.
|
||||
*/
|
||||
const handleNavigateBack = useCallback(() => {
|
||||
router.push(`/workspace/${workspaceId}/tables`)
|
||||
}, [router, workspaceId])
|
||||
|
||||
/**
|
||||
* Opens the schema viewer modal.
|
||||
*/
|
||||
const handleShowSchema = useCallback(() => {
|
||||
setShowSchemaModal(true)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Opens the add row modal.
|
||||
*/
|
||||
const handleAddRow = useCallback(() => {
|
||||
setShowAddModal(true)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Applies new query options and resets pagination.
|
||||
@@ -181,50 +196,40 @@ export function TableDataViewer() {
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col'>
|
||||
{/* Header */}
|
||||
<TableHeaderBar
|
||||
tableName={tableData.name}
|
||||
totalCount={totalCount}
|
||||
isLoading={isLoadingRows}
|
||||
onNavigateBack={() => router.push(`/workspace/${workspaceId}/tables`)}
|
||||
onShowSchema={() => setShowSchemaModal(true)}
|
||||
onNavigateBack={handleNavigateBack}
|
||||
onShowSchema={handleShowSchema}
|
||||
onRefresh={refetchRows}
|
||||
/>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<div className='flex shrink-0 flex-col gap-[8px] border-[var(--border)] border-b px-[16px] py-[10px]'>
|
||||
<FilterBuilder
|
||||
columns={columns}
|
||||
onApply={handleApplyQueryOptions}
|
||||
onAddRow={() => setShowAddModal(true)}
|
||||
onAddRow={handleAddRow}
|
||||
/>
|
||||
{selectedRows.size > 0 && (
|
||||
<span className='text-[11px] text-[var(--text-tertiary)]'>
|
||||
{selectedRows.size} selected
|
||||
</span>
|
||||
{hasSelection && (
|
||||
<span className='text-[11px] text-[var(--text-tertiary)]'>{selectedCount} selected</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Bar */}
|
||||
{selectedRows.size > 0 && (
|
||||
{hasSelection && (
|
||||
<TableActionBar
|
||||
selectedCount={selectedRows.size}
|
||||
selectedCount={selectedCount}
|
||||
onDelete={handleDeleteSelected}
|
||||
onClearSelection={clearSelection}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className='flex-1 overflow-auto'>
|
||||
<Table>
|
||||
<TableHeader className='sticky top-0 z-10 bg-[var(--surface-3)]'>
|
||||
<TableRow>
|
||||
<TableHead className='w-[40px]'>
|
||||
<Checkbox
|
||||
size='sm'
|
||||
checked={selectedRows.size === rows.length && rows.length > 0}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
<Checkbox size='sm' checked={isAllSelected} onCheckedChange={handleSelectAll} />
|
||||
</TableHead>
|
||||
{columns.map((column) => (
|
||||
<TableHead key={column.name}>
|
||||
@@ -239,7 +244,6 @@ export function TableDataViewer() {
|
||||
</div>
|
||||
</TableHead>
|
||||
))}
|
||||
<TableHead className='w-[80px]'>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -249,7 +253,7 @@ export function TableDataViewer() {
|
||||
<EmptyRows
|
||||
columnCount={columns.length}
|
||||
hasFilter={!!queryOptions.filter}
|
||||
onAddRow={() => setShowAddModal(true)}
|
||||
onAddRow={handleAddRow}
|
||||
/>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
@@ -279,31 +283,6 @@ export function TableDataViewer() {
|
||||
</div>
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-[2px] opacity-0 transition-opacity group-hover:opacity-100'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button variant='ghost' size='sm' onClick={() => setEditingRow(row)}>
|
||||
<Edit className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Edit</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => setDeletingRows([row.id])}
|
||||
className='text-[var(--text-tertiary)] hover:text-[var(--text-error)]'
|
||||
>
|
||||
<Trash2 className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Delete</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
@@ -311,7 +290,6 @@ export function TableDataViewer() {
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
@@ -320,7 +298,6 @@ export function TableDataViewer() {
|
||||
onNextPage={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
/>
|
||||
|
||||
{/* Modals */}
|
||||
<AddRowModal
|
||||
isOpen={showAddModal}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
@@ -358,14 +335,12 @@ export function TableDataViewer() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Schema Viewer Modal */}
|
||||
<SchemaViewerModal
|
||||
isOpen={showSchemaModal}
|
||||
onClose={() => setShowSchemaModal(false)}
|
||||
columns={columns}
|
||||
/>
|
||||
|
||||
{/* Cell Viewer Modal */}
|
||||
<CellViewerModal
|
||||
cellViewer={cellViewer}
|
||||
onClose={() => setCellViewer(null)}
|
||||
@@ -373,7 +348,6 @@ export function TableDataViewer() {
|
||||
copied={copied}
|
||||
/>
|
||||
|
||||
{/* Row Context Menu */}
|
||||
<RowContextMenu
|
||||
contextMenu={contextMenu}
|
||||
onClose={closeContextMenu}
|
||||
|
||||
Reference in New Issue
Block a user