This commit is contained in:
Lakee Sivaraya
2026-01-14 13:40:40 -08:00
parent dfa018f2d4
commit 22f89cf67d
8 changed files with 104 additions and 129 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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