diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/add-row-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/add-row-modal.tsx index 10dd44c52..5f137f0ba 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/add-row-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/add-row-modal.tsx @@ -61,7 +61,8 @@ export function AddRowModal({ isOpen, onClose, table, onSuccess }: AddRowModalPr const cleanData: Record = {} columns.forEach((col) => { const value = rowData[col.name] - if (col.required || (value !== '' && value !== null && value !== undefined)) { + const isRequired = !col.optional + if (isRequired || (value !== '' && value !== null && value !== undefined)) { if (col.type === 'number') { cleanData[col.name] = value === '' ? null : Number(value) } else if (col.type === 'json') { @@ -131,7 +132,7 @@ export function AddRowModal({ isOpen, onClose, table, onSuccess }: AddRowModalPr
))} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/edit-row-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/edit-row-modal.tsx index 7755ebd30..f66bdc916 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/edit-row-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/edit-row-modal.tsx @@ -142,7 +142,7 @@ export function EditRowModal({ isOpen, onClose, table, row, onSuccess }: EditRow
))} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/error.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/error.tsx new file mode 100644 index 000000000..53bf2162b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/error.tsx @@ -0,0 +1,71 @@ +'use client' + +import { useEffect } from 'react' +import { createLogger } from '@sim/logger' +import { AlertTriangle, ArrowLeft, RefreshCw } from 'lucide-react' +import { useParams, useRouter } from 'next/navigation' +import { Button } from '@/components/emcn' + +const logger = createLogger('TableViewerError') + +interface TableViewerErrorProps { + error: Error & { digest?: string } + reset: () => void +} + +export default function TableViewerError({ error, reset }: TableViewerErrorProps) { + const router = useRouter() + const params = useParams() + const workspaceId = params.workspaceId as string + + useEffect(() => { + logger.error('Table viewer error:', { error: error.message, digest: error.digest }) + }, [error]) + + return ( +
+ {/* Header */} +
+ +
+ + {/* Error Content */} +
+
+
+ +
+
+

+ Failed to load table +

+

+ Something went wrong while loading this table. The table may have been deleted or you + may not have permission to view it. +

+
+
+ + +
+
+
+
+ ) +} 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 index bac829f14..ee96c4fb8 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table-data-viewer.tsx @@ -1,9 +1,9 @@ 'use client' -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { useQuery } from '@tanstack/react-query' -import { Columns, Copy, Edit, Plus, RefreshCw, Trash2, X } from 'lucide-react' +import { Copy, Edit, Info, Plus, RefreshCw, Trash2, X } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' import { Badge, @@ -12,6 +12,11 @@ import { Modal, ModalBody, ModalContent, + Popover, + PopoverAnchor, + PopoverContent, + PopoverDivider, + PopoverItem, Table, TableBody, TableCell, @@ -54,11 +59,17 @@ interface TableData { interface CellViewerData { columnName: string value: any - type: 'json' | 'text' + type: 'json' | 'text' | 'date' } const STRING_TRUNCATE_LENGTH = 50 +interface ContextMenuState { + isOpen: boolean + position: { x: number; y: number } + row: TableRowData | null +} + export function TableDataViewer() { const params = useParams() const router = useRouter() @@ -79,6 +90,14 @@ export function TableDataViewer() { 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], @@ -159,12 +178,46 @@ export function TableDataViewer() { setDeletingRows(Array.from(selectedRows)) }, [selectedRows]) + // Context menu handlers + const handleRowContextMenu = useCallback((e: React.MouseEvent, row: TableRowData) => { + e.preventDefault() + e.stopPropagation() + setContextMenu({ + isOpen: true, + position: { x: e.clientX, y: e.clientY }, + row, + }) + }, []) + + const closeContextMenu = useCallback(() => { + setContextMenu((prev) => ({ ...prev, isOpen: false })) + }, []) + + const handleContextMenuEdit = useCallback(() => { + if (contextMenu.row) { + setEditingRow(contextMenu.row) + } + closeContextMenu() + }, [contextMenu.row, closeContextMenu]) + + const handleContextMenuDelete = useCallback(() => { + if (contextMenu.row) { + setDeletingRows([contextMenu.row.id]) + } + closeContextMenu() + }, [contextMenu.row, closeContextMenu]) + const handleCopyCellValue = useCallback(async () => { if (cellViewer) { - const text = - cellViewer.type === 'json' - ? JSON.stringify(cellViewer.value, null, 2) - : String(cellViewer.value) + let text: string + if (cellViewer.type === 'json') { + text = JSON.stringify(cellViewer.value, null, 2) + } else if (cellViewer.type === 'date') { + // Copy ISO format for dates (parseable) + text = String(cellViewer.value) + } else { + text = String(cellViewer.value) + } await navigator.clipboard.writeText(text) setCopied(true) setTimeout(() => setCopied(false), 2000) @@ -193,7 +246,7 @@ export function TableDataViewer() { } const handleCellClick = useCallback( - (e: React.MouseEvent, columnName: string, value: any, type: 'json' | 'text') => { + (e: React.MouseEvent, columnName: string, value: any, type: 'json' | 'text' | 'date') => { e.preventDefault() e.stopPropagation() setCellViewer({ columnName, value, type }) @@ -213,7 +266,7 @@ export function TableDataViewer() { return ( + ) + } catch { + return {String(value)} + } } // Handle long strings @@ -296,7 +376,7 @@ export function TableDataViewer() { View Schema @@ -355,7 +435,7 @@ export function TableDataViewer() { {column.type} - {column.required && ( + {!column.optional && ( * )} @@ -427,6 +507,7 @@ export function TableDataViewer() { 'group hover:bg-[var(--surface-4)]', selectedRows.has(row.id) && 'bg-[var(--surface-5)]' )} + onContextMenu={(e) => handleRowContextMenu(e, row)} >
- + Table Schema @@ -594,9 +675,9 @@ export function TableDataViewer() {
- {column.required && ( - - required + {column.optional && ( + + optional )} {column.unique && ( @@ -604,7 +685,7 @@ export function TableDataViewer() { unique )} - {!column.required && !column.unique && ( + {!column.optional && !column.unique && ( )}
@@ -626,8 +707,21 @@ export function TableDataViewer() { {cellViewer?.columnName} - - {cellViewer?.type === 'json' ? 'JSON' : 'Text'} + + {cellViewer?.type === 'json' + ? 'JSON' + : cellViewer?.type === 'date' + ? 'Date' + : 'Text'}
@@ -649,6 +743,36 @@ export function TableDataViewer() {
                 {cellViewer ? JSON.stringify(cellViewer.value, null, 2) : ''}
               
+ ) : cellViewer?.type === 'date' ? ( +
+
+
+ Formatted +
+
+ {cellViewer + ? new Date(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 +
+
+ {cellViewer ? String(cellViewer.value) : ''} +
+
+
) : (
{cellViewer ? String(cellViewer.value) : ''} @@ -657,6 +781,43 @@ export function TableDataViewer() { + + {/* Row Context Menu */} + !open && closeContextMenu()} + 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/components/create-table-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/create-table-modal.tsx index 50ee297c9..866866a94 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/create-table-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/create-table-modal.tsx @@ -24,7 +24,7 @@ const logger = createLogger('CreateTableModal') interface ColumnDefinition { name: string type: 'string' | 'number' | 'boolean' | 'date' | 'json' - required: boolean + optional: boolean unique: boolean } @@ -48,14 +48,14 @@ export function CreateTableModal({ isOpen, onClose }: CreateTableModalProps) { const [tableName, setTableName] = useState('') const [description, setDescription] = useState('') const [columns, setColumns] = useState([ - { name: '', type: 'string', required: false, unique: false }, + { name: '', type: 'string', optional: false, unique: false }, ]) const [error, setError] = useState(null) const createTable = useCreateTable(workspaceId) const handleAddColumn = () => { - setColumns([...columns, { name: '', type: 'string', required: false, unique: false }]) + setColumns([...columns, { name: '', type: 'string', optional: false, unique: false }]) } const handleRemoveColumn = (index: number) => { @@ -110,7 +110,7 @@ export function CreateTableModal({ isOpen, onClose }: CreateTableModalProps) { // Reset form setTableName('') setDescription('') - setColumns([{ name: '', type: 'string', required: false, unique: false }]) + setColumns([{ name: '', type: 'string', optional: false, unique: false }]) setError(null) onClose() } catch (err) { @@ -123,7 +123,7 @@ export function CreateTableModal({ isOpen, onClose }: CreateTableModalProps) { // Reset form on close setTableName('') setDescription('') - setColumns([{ name: '', type: 'string', required: false, unique: false }]) + setColumns([{ name: '', type: 'string', optional: false, unique: false }]) setError(null) onClose() } @@ -202,7 +202,7 @@ export function CreateTableModal({ isOpen, onClose }: CreateTableModalProps) {
Column Name
Type
-
Required
+
Optional
Unique
@@ -239,12 +239,12 @@ export function CreateTableModal({ isOpen, onClose }: CreateTableModalProps) { />
- {/* Required Checkbox */} + {/* Optional Checkbox */}
- handleColumnChange(index, 'required', checked === true) + handleColumnChange(index, 'optional', checked === true) } />
@@ -277,8 +277,9 @@ export function CreateTableModal({ isOpen, onClose }: CreateTableModalProps) {

- Mark columns as unique to prevent duplicate - values (e.g., id, email) + Columns are required by default. Check{' '} + optional for nullable fields, or{' '} + unique to prevent duplicates.

diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/table-card.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/table-card.tsx index 96ff676c5..b7630e2e4 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/table-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/table-card.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { createLogger } from '@sim/logger' -import { Columns, Database, MoreVertical, Trash2 } from 'lucide-react' +import { Columns, Info, Rows3, Trash2 } from 'lucide-react' import { useRouter } from 'next/navigation' import { Badge, @@ -22,6 +22,7 @@ import { TableHead, TableHeader, TableRow, + Tooltip, } from '@/components/emcn' import { useDeleteTable } from '@/hooks/queries/use-tables' import type { TableDefinition } from '@/tools/table/types' @@ -33,6 +34,37 @@ interface TableCardProps { workspaceId: string } +/** + * Formats a date string to relative time (e.g., "2h ago", "3d ago") + */ +function formatRelativeTime(dateString: string): string { + const date = new Date(dateString) + const now = new Date() + const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000) + + if (diffInSeconds < 60) return 'just now' + if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago` + if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago` + if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)}d ago` + if (diffInSeconds < 2592000) return `${Math.floor(diffInSeconds / 604800)}w ago` + if (diffInSeconds < 31536000) return `${Math.floor(diffInSeconds / 2592000)}mo ago` + return `${Math.floor(diffInSeconds / 31536000)}y ago` +} + +/** + * Formats a date string to absolute format for tooltip display + */ +function formatAbsoluteDate(dateString: string): string { + const date = new Date(dateString) + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} + export function TableCard({ table, workspaceId }: TableCardProps) { const router = useRouter() const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) @@ -55,75 +87,92 @@ export function TableCard({ table, workspaceId }: TableCardProps) { return ( <>
router.push(`/workspace/${workspaceId}/tables/${table.id}`)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + router.push(`/workspace/${workspaceId}/tables/${table.id}`) + } + }} > -
-
-
-
- -
-
- -
-

- {table.name} -

- - {table.description && ( -

- {table.description} -

- )} - -
- {columnCount} columns - {table.rowCount} rows -
- -
- Updated {new Date(table.updatedAt).toLocaleDateString()} -
-
+
+
+

+ {table.name} +

+ + + + + + { + e.stopPropagation() + setIsMenuOpen(false) + setIsSchemaModalOpen(true) + }} + > + + View Schema + + { + e.stopPropagation() + setIsMenuOpen(false) + setIsDeleteDialogOpen(true) + }} + className='text-[var(--text-error)] hover:text-[var(--text-error)]' + > + + Delete + + +
- - - - - - { - e.stopPropagation() - setIsMenuOpen(false) - setIsSchemaModalOpen(true) - }} - > - - View Schema - - { - e.stopPropagation() - setIsMenuOpen(false) - setIsDeleteDialogOpen(true) - }} - className='text-[var(--text-error)] hover:text-[var(--text-error)]' - > - - Delete - - - +
+
+
+ + + {columnCount} {columnCount === 1 ? 'col' : 'cols'} + + + + {table.rowCount} {table.rowCount === 1 ? 'row' : 'rows'} + +
+ + + + {formatRelativeTime(table.updatedAt)} + + + {formatAbsoluteDate(table.updatedAt)} + +
+ +
+ +

+ {table.description || 'No description'} +

+
@@ -162,7 +211,7 @@ export function TableCard({ table, workspaceId }: TableCardProps) {
- + {table.name} {columnCount} columns @@ -207,9 +256,9 @@ export function TableCard({ table, workspaceId }: TableCardProps) {
- {column.required && ( - - required + {column.optional && ( + + optional )} {column.unique && ( @@ -217,7 +266,7 @@ export function TableCard({ table, workspaceId }: TableCardProps) { unique )} - {!column.required && !column.unique && ( + {!column.optional && !column.unique && ( )}
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/error.tsx b/apps/sim/app/workspace/[workspaceId]/tables/error.tsx new file mode 100644 index 000000000..db6295e42 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/error.tsx @@ -0,0 +1,41 @@ +'use client' + +import { useEffect } from 'react' +import { createLogger } from '@sim/logger' +import { AlertTriangle, RefreshCw } from 'lucide-react' +import { Button } from '@/components/emcn' + +const logger = createLogger('TablesError') + +interface TablesErrorProps { + error: Error & { digest?: string } + reset: () => void +} + +export default function TablesError({ error, reset }: TablesErrorProps) { + useEffect(() => { + logger.error('Tables error:', { error: error.message, digest: error.digest }) + }, [error]) + + return ( +
+
+
+ +
+
+

+ Failed to load tables +

+

+ Something went wrong while loading the tables. Please try again. +

+
+ +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index 1c2b5756d..ff670ee8c 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -87,23 +87,34 @@ export function Tables() { {/* Content */}
{isLoading ? ( - // Loading skeleton + // Loading skeleton matching the new card style Array.from({ length: 8 }).map((_, i) => (
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)) ) : error ? ( -
+

Error loading tables @@ -114,7 +125,7 @@ export function Tables() {

) : filteredTables.length === 0 ? ( -
+

{searchQuery ? 'No tables found' : 'No tables yet'} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index 8edd3f380..3f2f1d4b6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -1,6 +1,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Badge } from '@/components/emcn' import { Combobox, type ComboboxOption } from '@/components/emcn/components' +import type { FilterCondition, SortCondition } from '@/lib/table/filter-builder-utils' +import { + conditionsToJsonString, + jsonStringToConditions, + jsonStringToSortConditions, + sortConditionsToJsonString, +} from '@/lib/table/filter-builder-utils' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import type { SubBlockConfig } from '@/blocks/types' import { getDependsOnFields } from '@/blocks/utils' @@ -113,13 +120,52 @@ export function Dropdown({ const [builderData, setBuilderData] = useSubBlockValue(blockId, 'builderData') const [data, setData] = useSubBlockValue(blockId, 'data') + // Filter builder state for filterMode conversion + const [filterBuilder, setFilterBuilder] = useSubBlockValue( + blockId, + 'filterBuilder' + ) + const [filter, setFilter] = useSubBlockValue(blockId, 'filter') + + // Sort builder state for sortMode conversion + const [sortBuilder, setSortBuilder] = useSubBlockValue(blockId, 'sortBuilder') + const [sort, setSort] = useSubBlockValue(blockId, 'sort') + + // Bulk filter builder state for bulkFilterMode conversion + const [bulkFilterBuilder, setBulkFilterBuilder] = useSubBlockValue( + blockId, + 'bulkFilterBuilder' + ) + const [filterCriteria, setFilterCriteria] = useSubBlockValue(blockId, 'filterCriteria') + const builderDataRef = useRef(builderData) const dataRef = useRef(data) + const filterBuilderRef = useRef(filterBuilder) + const filterRef = useRef(filter) + const sortBuilderRef = useRef(sortBuilder) + const sortRef = useRef(sort) + const bulkFilterBuilderRef = useRef(bulkFilterBuilder) + const filterCriteriaRef = useRef(filterCriteria) useEffect(() => { builderDataRef.current = builderData dataRef.current = data - }, [builderData, data]) + filterBuilderRef.current = filterBuilder + filterRef.current = filter + sortBuilderRef.current = sortBuilder + sortRef.current = sort + bulkFilterBuilderRef.current = bulkFilterBuilder + filterCriteriaRef.current = filterCriteria + }, [ + builderData, + data, + filterBuilder, + filter, + sortBuilder, + sort, + bulkFilterBuilder, + filterCriteria, + ]) const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue @@ -305,6 +351,123 @@ export function Dropdown({ previousModeRef.current = currentMode }, [storeValue, subBlockId, isPreview, disabled, setData, setBuilderData, multiSelect]) + /** + * Handle filterMode conversion between builder and json formats + */ + const previousFilterModeRef = useRef(null) + useEffect(() => { + if (multiSelect || subBlockId !== 'filterMode' || isPreview || disabled) return + + const currentMode = storeValue as string + const previousMode = previousFilterModeRef.current + + if (previousMode !== null && previousMode !== currentMode) { + if (currentMode === 'json' && previousMode === 'builder') { + // Convert builder conditions to JSON string + const currentFilterBuilder = filterBuilderRef.current + if ( + currentFilterBuilder && + Array.isArray(currentFilterBuilder) && + currentFilterBuilder.length > 0 + ) { + const jsonString = conditionsToJsonString(currentFilterBuilder) + setFilter(jsonString) + } + } else if (currentMode === 'builder' && previousMode === 'json') { + // Convert JSON string to builder conditions + const currentFilter = filterRef.current + if (currentFilter && typeof currentFilter === 'string' && currentFilter.trim().length > 0) { + const conditions = jsonStringToConditions(currentFilter) + setFilterBuilder(conditions) + } + } + } + + previousFilterModeRef.current = currentMode + }, [storeValue, subBlockId, isPreview, disabled, setFilter, setFilterBuilder, multiSelect]) + + /** + * Handle sortMode conversion between builder and json formats + */ + const previousSortModeRef = useRef(null) + useEffect(() => { + if (multiSelect || subBlockId !== 'sortMode' || isPreview || disabled) return + + const currentMode = storeValue as string + const previousMode = previousSortModeRef.current + + if (previousMode !== null && previousMode !== currentMode) { + if (currentMode === 'json' && previousMode === 'builder') { + // Convert sort builder conditions to JSON string + const currentSortBuilder = sortBuilderRef.current + if ( + currentSortBuilder && + Array.isArray(currentSortBuilder) && + currentSortBuilder.length > 0 + ) { + const jsonString = sortConditionsToJsonString(currentSortBuilder) + setSort(jsonString) + } + } else if (currentMode === 'builder' && previousMode === 'json') { + // Convert JSON string to sort builder conditions + const currentSort = sortRef.current + if (currentSort && typeof currentSort === 'string' && currentSort.trim().length > 0) { + const conditions = jsonStringToSortConditions(currentSort) + setSortBuilder(conditions) + } + } + } + + previousSortModeRef.current = currentMode + }, [storeValue, subBlockId, isPreview, disabled, setSort, setSortBuilder, multiSelect]) + + /** + * Handle bulkFilterMode conversion between builder and json formats + */ + const previousBulkFilterModeRef = useRef(null) + useEffect(() => { + if (multiSelect || subBlockId !== 'bulkFilterMode' || isPreview || disabled) return + + const currentMode = storeValue as string + const previousMode = previousBulkFilterModeRef.current + + if (previousMode !== null && previousMode !== currentMode) { + if (currentMode === 'json' && previousMode === 'builder') { + // Convert bulk filter builder conditions to JSON string + const currentBulkFilterBuilder = bulkFilterBuilderRef.current + if ( + currentBulkFilterBuilder && + Array.isArray(currentBulkFilterBuilder) && + currentBulkFilterBuilder.length > 0 + ) { + const jsonString = conditionsToJsonString(currentBulkFilterBuilder) + setFilterCriteria(jsonString) + } + } else if (currentMode === 'builder' && previousMode === 'json') { + // Convert JSON string to bulk filter builder conditions + const currentFilterCriteria = filterCriteriaRef.current + if ( + currentFilterCriteria && + typeof currentFilterCriteria === 'string' && + currentFilterCriteria.trim().length > 0 + ) { + const conditions = jsonStringToConditions(currentFilterCriteria) + setBulkFilterBuilder(conditions) + } + } + } + + previousBulkFilterModeRef.current = currentMode + }, [ + storeValue, + subBlockId, + isPreview, + disabled, + setFilterCriteria, + setBulkFilterBuilder, + multiSelect, + ]) + /** * Handles selection change for both single and multi-select modes */ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-format/filter-format.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-format/filter-format.tsx new file mode 100644 index 000000000..34f899f50 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-format/filter-format.tsx @@ -0,0 +1,214 @@ +'use client' + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Plus, X } from 'lucide-react' +import { Button, Combobox, type ComboboxOption, Input } from '@/components/emcn' +import { + COMPARISON_OPERATORS, + type FilterCondition, + generateFilterId, + LOGICAL_OPERATORS, +} from '@/lib/table/filter-builder-utils' +import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' + +interface FilterFormatProps { + blockId: string + subBlockId: string + isPreview?: boolean + previewValue?: FilterCondition[] | null + disabled?: boolean + columns?: Array<{ value: string; label: string }> + tableIdSubBlockId?: string +} + +/** + * Creates a new filter condition with default values + */ +const createDefaultCondition = (columns: ComboboxOption[]): FilterCondition => ({ + id: generateFilterId(), + logicalOperator: 'and', + column: columns[0]?.value || '', + operator: 'eq', + value: '', +}) + +export function FilterFormat({ + blockId, + subBlockId, + isPreview = false, + previewValue, + disabled = false, + columns: propColumns, + tableIdSubBlockId = 'tableId', +}: FilterFormatProps) { + const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) + const [tableIdValue] = useSubBlockValue(blockId, tableIdSubBlockId) + const [dynamicColumns, setDynamicColumns] = useState([]) + const fetchedTableIdRef = useRef(null) + + // Fetch columns when tableId changes + useEffect(() => { + const fetchColumns = async () => { + if (!tableIdValue || tableIdValue === fetchedTableIdRef.current) return + + try { + const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store') + const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId + if (!workspaceId) return + + const response = await fetch(`/api/table/${tableIdValue}?workspaceId=${workspaceId}`) + if (!response.ok) return + + const data = await response.json() + const cols = data.table?.schema?.columns || [] + setDynamicColumns( + cols.map((col: { name: string }) => ({ value: col.name, label: col.name })) + ) + fetchedTableIdRef.current = tableIdValue + } catch { + // Ignore errors + } + } + + fetchColumns() + }, [tableIdValue]) + + const columns = useMemo(() => { + if (propColumns && propColumns.length > 0) return propColumns + return dynamicColumns + }, [propColumns, dynamicColumns]) + + const comparisonOptions = useMemo( + () => COMPARISON_OPERATORS.map((op) => ({ value: op.value, label: op.label })), + [] + ) + + const logicalOptions = useMemo( + () => LOGICAL_OPERATORS.map((op) => ({ value: op.value, label: op.label })), + [] + ) + + const value = isPreview ? previewValue : storeValue + const conditions: FilterCondition[] = Array.isArray(value) && value.length > 0 ? value : [] + const isReadOnly = isPreview || disabled + + const addCondition = useCallback(() => { + if (isReadOnly) return + setStoreValue([...conditions, createDefaultCondition(columns)]) + }, [isReadOnly, conditions, columns, setStoreValue]) + + const removeCondition = useCallback( + (id: string) => { + if (isReadOnly) return + setStoreValue(conditions.filter((c) => c.id !== id)) + }, + [isReadOnly, conditions, setStoreValue] + ) + + const updateCondition = useCallback( + (id: string, field: keyof FilterCondition, newValue: string) => { + if (isReadOnly) return + setStoreValue(conditions.map((c) => (c.id === id ? { ...c, [field]: newValue } : c))) + }, + [isReadOnly, conditions, setStoreValue] + ) + + return ( +

+ {conditions.length === 0 ? ( +
+ +
+ ) : ( + <> + {conditions.map((condition, index) => ( +
+ {/* Remove Button */} + + + {/* Logical Operator */} +
+ {index === 0 ? ( + + ) : ( + + updateCondition(condition.id, 'logicalOperator', v as 'and' | 'or') + } + disabled={isReadOnly} + /> + )} +
+ + {/* Column Selector */} +
+ updateCondition(condition.id, 'column', v)} + placeholder='Column' + disabled={isReadOnly} + /> +
+ + {/* Comparison Operator */} +
+ updateCondition(condition.id, 'operator', v)} + disabled={isReadOnly} + /> +
+ + {/* Value Input */} + updateCondition(condition.id, 'value', e.target.value)} + placeholder='Value' + disabled={isReadOnly} + /> +
+ ))} + + {/* Add Button */} + + + )} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts index 4eaab626d..43d9915c5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts @@ -9,6 +9,7 @@ export { Dropdown } from './dropdown/dropdown' export { EvalInput } from './eval-input/eval-input' export { FileSelectorInput } from './file-selector/file-selector-input' export { FileUpload } from './file-upload/file-upload' +export { FilterFormat } from './filter-format/filter-format' export { FolderSelectorInput } from './folder-selector/components/folder-selector-input' export { GroupedCheckboxList } from './grouped-checkbox-list/grouped-checkbox-list' export { InputMapping } from './input-mapping/input-mapping' @@ -25,6 +26,7 @@ export { ScheduleInfo } from './schedule-info/schedule-info' export { ShortInput } from './short-input/short-input' export { SlackSelectorInput } from './slack-selector/slack-selector-input' export { SliderInput } from './slider-input/slider-input' +export { SortFormat } from './sort-format/sort-format' export { InputFormat } from './starter/input-format' export { SubBlockInputController } from './sub-block-input-controller' export { Switch } from './switch/switch' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-format/sort-format.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-format/sort-format.tsx new file mode 100644 index 000000000..e43f99c99 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-format/sort-format.tsx @@ -0,0 +1,194 @@ +'use client' + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Plus, X } from 'lucide-react' +import { Button, Combobox, type ComboboxOption } from '@/components/emcn' +import { + generateSortId, + SORT_DIRECTIONS, + type SortCondition, +} from '@/lib/table/filter-builder-utils' +import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' + +interface SortFormatProps { + blockId: string + subBlockId: string + isPreview?: boolean + previewValue?: SortCondition[] | null + disabled?: boolean + columns?: Array<{ value: string; label: string }> + tableIdSubBlockId?: string +} + +/** + * Creates a new sort condition with default values + */ +const createDefaultCondition = (columns: ComboboxOption[]): SortCondition => ({ + id: generateSortId(), + column: columns[0]?.value || '', + direction: 'asc', +}) + +export function SortFormat({ + blockId, + subBlockId, + isPreview = false, + previewValue, + disabled = false, + columns: propColumns, + tableIdSubBlockId = 'tableId', +}: SortFormatProps) { + const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) + const [tableIdValue] = useSubBlockValue(blockId, tableIdSubBlockId) + const [dynamicColumns, setDynamicColumns] = useState([]) + const fetchedTableIdRef = useRef(null) + + // Fetch columns when tableId changes + useEffect(() => { + const fetchColumns = async () => { + if (!tableIdValue || tableIdValue === fetchedTableIdRef.current) return + + try { + const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store') + const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId + if (!workspaceId) return + + const response = await fetch(`/api/table/${tableIdValue}?workspaceId=${workspaceId}`) + if (!response.ok) return + + const data = await response.json() + const cols = data.table?.schema?.columns || [] + // Add built-in columns for sorting + const builtInCols = [ + { value: 'createdAt', label: 'createdAt' }, + { value: 'updatedAt', label: 'updatedAt' }, + ] + const schemaCols = cols.map((col: { name: string }) => ({ + value: col.name, + label: col.name, + })) + setDynamicColumns([...schemaCols, ...builtInCols]) + fetchedTableIdRef.current = tableIdValue + } catch { + // Ignore errors + } + } + + fetchColumns() + }, [tableIdValue]) + + const columns = useMemo(() => { + if (propColumns && propColumns.length > 0) return propColumns + return dynamicColumns + }, [propColumns, dynamicColumns]) + + const directionOptions = useMemo( + () => SORT_DIRECTIONS.map((dir) => ({ value: dir.value, label: dir.label })), + [] + ) + + const value = isPreview ? previewValue : storeValue + const conditions: SortCondition[] = Array.isArray(value) && value.length > 0 ? value : [] + const isReadOnly = isPreview || disabled + + const addCondition = useCallback(() => { + if (isReadOnly) return + setStoreValue([...conditions, createDefaultCondition(columns)]) + }, [isReadOnly, conditions, columns, setStoreValue]) + + const removeCondition = useCallback( + (id: string) => { + if (isReadOnly) return + setStoreValue(conditions.filter((c) => c.id !== id)) + }, + [isReadOnly, conditions, setStoreValue] + ) + + const updateCondition = useCallback( + (id: string, field: keyof SortCondition, newValue: string) => { + if (isReadOnly) return + setStoreValue(conditions.map((c) => (c.id === id ? { ...c, [field]: newValue } : c))) + }, + [isReadOnly, conditions, setStoreValue] + ) + + return ( +
+ {conditions.length === 0 ? ( +
+ +
+ ) : ( + <> + {conditions.map((condition, index) => ( +
+ {/* Remove Button */} + + + {/* Order indicator */} +
+ +
+ + {/* Column Selector */} +
+ updateCondition(condition.id, 'column', v)} + placeholder='Column' + disabled={isReadOnly} + /> +
+ + {/* Direction Selector */} +
+ updateCondition(condition.id, 'direction', v as 'asc' | 'desc')} + disabled={isReadOnly} + /> +
+
+ ))} + + {/* Add Button */} + + + )} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index 5b58335a5..e869d1627 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -3,6 +3,7 @@ import { AlertTriangle, Wand2 } from 'lucide-react' import { Label, Tooltip } from '@/components/emcn/components' import { Button } from '@/components/ui/button' import { cn } from '@/lib/core/utils/cn' +import type { FilterCondition, SortCondition } from '@/lib/table/filter-builder-utils' import type { FieldDiffStatus } from '@/lib/workflows/diff/types' import { CheckboxList, @@ -16,6 +17,7 @@ import { EvalInput, FileSelectorInput, FileUpload, + FilterFormat, FolderSelectorInput, GroupedCheckboxList, InputFormat, @@ -33,6 +35,7 @@ import { ShortInput, SlackSelectorInput, SliderInput, + SortFormat, Switch, Table, Text, @@ -797,6 +800,28 @@ function SubBlockComponent({ /> ) + case 'filter-format': + return ( + + ) + + case 'sort-format': + return ( + + ) + case 'channel-selector': case 'user-selector': return ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 552f850bf..cfaa834b4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -184,6 +184,52 @@ const isDocumentTagArray = (value: unknown): value is DocumentTagItem[] => { ) } +/** + * Type guard for filter condition array (used in table block filter builder) + */ +interface FilterConditionItem { + id: string + logicalOperator: 'and' | 'or' + column: string + operator: string + value: string +} + +const isFilterConditionArray = (value: unknown): value is FilterConditionItem[] => { + if (!Array.isArray(value) || value.length === 0) return false + const firstItem = value[0] + return ( + typeof firstItem === 'object' && + firstItem !== null && + 'column' in firstItem && + 'operator' in firstItem && + 'logicalOperator' in firstItem && + typeof firstItem.column === 'string' + ) +} + +/** + * Type guard for sort condition array (used in table block sort builder) + */ +interface SortConditionItem { + id: string + column: string + direction: 'asc' | 'desc' +} + +const isSortConditionArray = (value: unknown): value is SortConditionItem[] => { + if (!Array.isArray(value) || value.length === 0) return false + const firstItem = value[0] + return ( + typeof firstItem === 'object' && + firstItem !== null && + 'column' in firstItem && + 'direction' in firstItem && + typeof firstItem.column === 'string' && + (firstItem.direction === 'asc' || firstItem.direction === 'desc') + ) +} + /** * Attempts to parse a JSON string, returns the parsed value or the original value if parsing fails */ @@ -248,6 +294,45 @@ export const getDisplayValue = (value: unknown): string => { return `${validTags[0].tagName}, ${validTags[1].tagName} +${validTags.length - 2}` } + if (isFilterConditionArray(parsedValue)) { + const validConditions = parsedValue.filter( + (c) => typeof c.column === 'string' && c.column.trim() !== '' + ) + if (validConditions.length === 0) return '-' + const formatCondition = (c: FilterConditionItem) => { + const opLabels: Record = { + eq: '=', + ne: '≠', + gt: '>', + gte: '≥', + lt: '<', + lte: '≤', + contains: '~', + in: 'in', + } + const op = opLabels[c.operator] || c.operator + return `${c.column} ${op} ${c.value || '?'}` + } + if (validConditions.length === 1) return formatCondition(validConditions[0]) + if (validConditions.length === 2) { + return `${formatCondition(validConditions[0])}, ${formatCondition(validConditions[1])}` + } + return `${formatCondition(validConditions[0])}, ${formatCondition(validConditions[1])} +${validConditions.length - 2}` + } + + if (isSortConditionArray(parsedValue)) { + const validConditions = parsedValue.filter( + (c) => typeof c.column === 'string' && c.column.trim() !== '' + ) + if (validConditions.length === 0) return '-' + const formatSort = (c: SortConditionItem) => `${c.column} ${c.direction === 'desc' ? '↓' : '↑'}` + if (validConditions.length === 1) return formatSort(validConditions[0]) + if (validConditions.length === 2) { + return `${formatSort(validConditions[0])}, ${formatSort(validConditions[1])}` + } + return `${formatSort(validConditions[0])}, ${formatSort(validConditions[1])} +${validConditions.length - 2}` + } + if (isTableRowArray(parsedValue)) { const nonEmptyRows = parsedValue.filter((row) => { const cellValues = Object.values(row.cells) diff --git a/apps/sim/blocks/blocks/table.ts b/apps/sim/blocks/blocks/table.ts index 99c9856a8..c84a19817 100644 --- a/apps/sim/blocks/blocks/table.ts +++ b/apps/sim/blocks/blocks/table.ts @@ -1,4 +1,5 @@ import { TableIcon } from '@/components/icons' +import { conditionsToFilter, sortConditionsToSort } from '@/lib/table/filter-builder-utils' import type { BlockConfig } from '@/blocks/types' import type { TableQueryResponse } from '@/tools/table/types' @@ -171,7 +172,39 @@ Return ONLY the rows array:`, }, }, - // Filter for update/delete/query operations + // Filter mode selector for bulk operations + { + id: 'bulkFilterMode', + title: 'Filter Mode', + type: 'dropdown', + options: [ + { label: 'Builder', id: 'builder' }, + { label: 'Editor', id: 'json' }, + ], + value: () => 'builder', + condition: { + field: 'operation', + value: ['updateRowsByFilter', 'deleteRowsByFilter'], + }, + }, + + // Filter builder for bulk operations (visual) + { + id: 'bulkFilterBuilder', + title: 'Filter Conditions', + type: 'filter-format', + required: { + field: 'operation', + value: ['updateRowsByFilter', 'deleteRowsByFilter'], + }, + condition: { + field: 'operation', + value: ['updateRowsByFilter', 'deleteRowsByFilter'], + and: { field: 'bulkFilterMode', value: 'builder' }, + }, + }, + + // Filter for update/delete operations (JSON editor) { id: 'filterCriteria', title: 'Filter Criteria', @@ -180,8 +213,13 @@ Return ONLY the rows array:`, condition: { field: 'operation', value: ['updateRowsByFilter', 'deleteRowsByFilter'], + and: { field: 'bulkFilterMode', value: 'json' }, + }, + required: { + field: 'operation', + value: ['updateRowsByFilter', 'deleteRowsByFilter'], + and: { field: 'bulkFilterMode', value: 'json' }, }, - required: true, wandConfig: { enabled: true, maintainHistory: true, @@ -234,13 +272,42 @@ Return ONLY the filter JSON:`, }, }, - // Query filters + // Filter mode selector for queryRows + { + id: 'filterMode', + title: 'Filter Mode', + type: 'dropdown', + options: [ + { label: 'Builder', id: 'builder' }, + { label: 'Editor', id: 'json' }, + ], + value: () => 'builder', + condition: { field: 'operation', value: 'queryRows' }, + }, + + // Filter builder (visual) + { + id: 'filterBuilder', + title: 'Filter Conditions', + type: 'filter-format', + condition: { + field: 'operation', + value: 'queryRows', + and: { field: 'filterMode', value: 'builder' }, + }, + }, + + // Query filters (JSON editor) { id: 'filter', title: 'Filter', type: 'code', placeholder: '{"column_name": {"$eq": "value"}}', - condition: { field: 'operation', value: 'queryRows' }, + condition: { + field: 'operation', + value: 'queryRows', + and: { field: 'filterMode', value: 'json' }, + }, wandConfig: { enabled: true, maintainHistory: true, @@ -288,12 +355,42 @@ Return ONLY the filter JSON:`, generationType: 'json-object', }, }, + // Sort mode selector for queryRows + { + id: 'sortMode', + title: 'Sort Mode', + type: 'dropdown', + options: [ + { label: 'Builder', id: 'builder' }, + { label: 'Editor', id: 'json' }, + ], + value: () => 'builder', + condition: { field: 'operation', value: 'queryRows' }, + }, + + // Sort builder (visual) + { + id: 'sortBuilder', + title: 'Sort Order', + type: 'sort-format', + condition: { + field: 'operation', + value: 'queryRows', + and: { field: 'sortMode', value: 'builder' }, + }, + }, + + // Sort (JSON editor) { id: 'sort', title: 'Sort', type: 'code', placeholder: '{"column_name": "desc"}', - condition: { field: 'operation', value: 'queryRows' }, + condition: { + field: 'operation', + value: 'queryRows', + and: { field: 'sortMode', value: 'json' }, + }, wandConfig: { enabled: true, maintainHistory: true, @@ -438,7 +535,12 @@ Return ONLY the sort JSON:`, // Update Rows by Filter if (operation === 'updateRowsByFilter') { - const filter = parseJSON(rest.filterCriteria, 'Filter Criteria') + let filter: any + if (rest.bulkFilterMode === 'builder' && rest.bulkFilterBuilder) { + filter = conditionsToFilter(rest.bulkFilterBuilder as any) || undefined + } else if (rest.filterCriteria) { + filter = parseJSON(rest.filterCriteria, 'Filter Criteria') + } const data = parseJSON(rest.rowData, 'Row Data') return { tableId: rest.tableId, @@ -458,7 +560,12 @@ Return ONLY the sort JSON:`, // Delete Rows by Filter if (operation === 'deleteRowsByFilter') { - const filter = parseJSON(rest.filterCriteria, 'Filter Criteria') + let filter: any + if (rest.bulkFilterMode === 'builder' && rest.bulkFilterBuilder) { + filter = conditionsToFilter(rest.bulkFilterBuilder as any) || undefined + } else if (rest.filterCriteria) { + filter = parseJSON(rest.filterCriteria, 'Filter Criteria') + } return { tableId: rest.tableId, filter, @@ -476,8 +583,21 @@ Return ONLY the sort JSON:`, // Query Rows if (operation === 'queryRows') { - const filter = rest.filter ? parseJSON(rest.filter, 'Filter') : undefined - const sort = rest.sort ? parseJSON(rest.sort, 'Sort') : undefined + let filter: any + if (rest.filterMode === 'builder' && rest.filterBuilder) { + // Convert builder conditions to filter object + filter = conditionsToFilter(rest.filterBuilder as any) || undefined + } else if (rest.filter) { + filter = parseJSON(rest.filter, 'Filter') + } + + let sort: any + if (rest.sortMode === 'builder' && rest.sortBuilder) { + // Convert sort builder conditions to sort object + sort = sortConditionsToSort(rest.sortBuilder as any) || undefined + } else if (rest.sort) { + sort = parseJSON(rest.sort, 'Sort') + } return { tableId: rest.tableId, @@ -499,10 +619,22 @@ Return ONLY the sort JSON:`, rowData: { type: 'json', description: 'Row data for insert/update' }, batchRows: { type: 'array', description: 'Array of row data for batch insert' }, rowId: { type: 'string', description: 'Row identifier for ID-based operations' }, - filterCriteria: { type: 'json', description: 'Filter criteria for bulk operations' }, + bulkFilterMode: { + type: 'string', + description: 'Filter input mode for bulk operations (builder or json)', + }, + bulkFilterBuilder: { + type: 'json', + description: 'Visual filter builder conditions for bulk operations', + }, + filterCriteria: { type: 'json', description: 'Filter criteria for bulk operations (JSON)' }, bulkLimit: { type: 'number', description: 'Safety limit for bulk operations' }, - filter: { type: 'json', description: 'Query filter conditions' }, - sort: { type: 'json', description: 'Sort order' }, + filterMode: { type: 'string', description: 'Filter input mode (builder or json)' }, + filterBuilder: { type: 'json', description: 'Visual filter builder conditions' }, + filter: { type: 'json', description: 'Query filter conditions (JSON)' }, + sortMode: { type: 'string', description: 'Sort input mode (builder or json)' }, + sortBuilder: { type: 'json', description: 'Visual sort builder conditions' }, + sort: { type: 'json', description: 'Sort order (JSON)' }, limit: { type: 'number', description: 'Query result limit' }, offset: { type: 'number', description: 'Query result offset' }, }, diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index a9cac75e1..98ff5df9e 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -71,6 +71,8 @@ export type SubBlockType = | 'mcp-dynamic-args' // MCP dynamic arguments based on tool schema | 'input-format' // Input structure format | 'response-format' // Response structure format + | 'filter-format' // Filter conditions builder + | 'sort-format' // Sort conditions builder | 'trigger-save' // Trigger save button with validation | 'file-upload' // File uploader | 'input-mapping' // Map parent variables to child workflow input schema diff --git a/apps/sim/components/ui/dialog.tsx b/apps/sim/components/ui/dialog.tsx index 6c3921eb3..d6af83cc3 100644 --- a/apps/sim/components/ui/dialog.tsx +++ b/apps/sim/components/ui/dialog.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import * as DialogPrimitive from '@radix-ui/react-dialog' +import * as VisuallyHidden from '@radix-ui/react-visually-hidden' import { X } from 'lucide-react' import { cn } from '@/lib/core/utils/cn' @@ -73,6 +74,9 @@ const DialogContent = React.forwardRef< }} {...props} > + + Dialog + {children} {!hideCloseButton && ( { + const trimmed = v.trim() + if (trimmed === 'true') return true + if (trimmed === 'false') return false + if (trimmed === 'null') return null + if (!isNaN(Number(trimmed)) && trimmed !== '') return Number(trimmed) + return trimmed + }) + } + + return value +} + +/** + * Converts builder filter conditions to MongoDB-style filter object + */ +export function conditionsToFilter(conditions: FilterCondition[]): Record | null { + if (conditions.length === 0) return null + + const orGroups: Record[] = [] + let currentAndGroup: Record = {} + + conditions.forEach((condition, index) => { + const { column, operator, value } = condition + const operatorKey = `$${operator}` + const parsedValue = parseValue(value, operator) + const conditionObj = operator === 'eq' ? parsedValue : { [operatorKey]: parsedValue } + + if (index === 0 || condition.logicalOperator === 'and') { + currentAndGroup[column] = conditionObj + } else if (condition.logicalOperator === 'or') { + if (Object.keys(currentAndGroup).length > 0) { + orGroups.push({ ...currentAndGroup }) + } + currentAndGroup = { [column]: conditionObj } + } + }) + + if (Object.keys(currentAndGroup).length > 0) { + orGroups.push(currentAndGroup) + } + + if (orGroups.length > 1) { + return { $or: orGroups } + } + + return orGroups[0] || null +} + +/** + * Converts MongoDB-style filter object to builder conditions + */ +export function filterToConditions(filter: Record | null): FilterCondition[] { + if (!filter) return [] + + const conditions: FilterCondition[] = [] + + // Handle $or at the top level + if (filter.$or && Array.isArray(filter.$or)) { + filter.$or.forEach((orGroup, groupIndex) => { + const groupConditions = parseFilterGroup(orGroup) + groupConditions.forEach((cond, condIndex) => { + conditions.push({ + ...cond, + logicalOperator: + groupIndex === 0 && condIndex === 0 + ? 'and' + : groupIndex > 0 && condIndex === 0 + ? 'or' + : 'and', + }) + }) + }) + return conditions + } + + // Handle simple filter (all AND conditions) + return parseFilterGroup(filter) +} + +/** + * Parses a single filter group (AND conditions) + */ +function parseFilterGroup(group: Record): FilterCondition[] { + const conditions: FilterCondition[] = [] + + for (const [column, value] of Object.entries(group)) { + if (column === '$or' || column === '$and') continue + + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + // Operator-based condition + for (const [op, opValue] of Object.entries(value)) { + if (op.startsWith('$')) { + conditions.push({ + id: generateFilterId(), + logicalOperator: 'and', + column, + operator: op.substring(1), + value: formatValueForBuilder(opValue), + }) + } + } + } else { + // Direct equality + conditions.push({ + id: generateFilterId(), + logicalOperator: 'and', + column, + operator: 'eq', + value: formatValueForBuilder(value), + }) + } + } + + return conditions +} + +/** + * Formats a value for display in the builder UI + */ +function formatValueForBuilder(value: any): string { + if (value === null) return 'null' + if (typeof value === 'boolean') return String(value) + if (Array.isArray(value)) return value.map(formatValueForBuilder).join(', ') + return String(value) +} + +/** + * Converts builder conditions to JSON string + */ +export function conditionsToJsonString(conditions: FilterCondition[]): string { + const filter = conditionsToFilter(conditions) + if (!filter) return '' + return JSON.stringify(filter, null, 2) +} + +/** + * Converts JSON string to builder conditions + */ +export function jsonStringToConditions(jsonString: string): FilterCondition[] { + if (!jsonString || !jsonString.trim()) return [] + + try { + const filter = JSON.parse(jsonString) + return filterToConditions(filter) + } catch { + return [] + } +} + +/** + * Sort direction options + */ +export const SORT_DIRECTIONS = [ + { value: 'asc', label: 'ascending' }, + { value: 'desc', label: 'descending' }, +] as const + +/** + * Represents a single sort condition in builder format + */ +export interface SortCondition { + id: string + column: string + direction: 'asc' | 'desc' +} + +/** + * Generates a unique ID for sort conditions + */ +export function generateSortId(): string { + return Math.random().toString(36).substring(2, 9) +} + +/** + * Converts builder sort conditions to sort object + */ +export function sortConditionsToSort(conditions: SortCondition[]): Record | null { + if (conditions.length === 0) return null + + const sort: Record = {} + for (const condition of conditions) { + if (condition.column) { + sort[condition.column] = condition.direction + } + } + + return Object.keys(sort).length > 0 ? sort : null +} + +/** + * Converts sort object to builder conditions + */ +export function sortToConditions(sort: Record | null): SortCondition[] { + if (!sort) return [] + + return Object.entries(sort).map(([column, direction]) => ({ + id: generateSortId(), + column, + direction: direction === 'desc' ? 'desc' : 'asc', + })) +} + +/** + * Converts builder sort conditions to JSON string + */ +export function sortConditionsToJsonString(conditions: SortCondition[]): string { + const sort = sortConditionsToSort(conditions) + if (!sort) return '' + return JSON.stringify(sort, null, 2) +} + +/** + * Converts JSON string to sort builder conditions + */ +export function jsonStringToSortConditions(jsonString: string): SortCondition[] { + if (!jsonString || !jsonString.trim()) return [] + + try { + const sort = JSON.parse(jsonString) + return sortToConditions(sort) + } catch { + return [] + } +} diff --git a/apps/sim/lib/table/validation.ts b/apps/sim/lib/table/validation.ts index bef7f41f4..1d0f862e0 100644 --- a/apps/sim/lib/table/validation.ts +++ b/apps/sim/lib/table/validation.ts @@ -4,7 +4,7 @@ import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from './constants' export interface ColumnDefinition { name: string type: ColumnType - required?: boolean + optional?: boolean unique?: boolean } @@ -150,8 +150,8 @@ export function validateRowAgainstSchema( for (const column of schema.columns) { const value = data[column.name] - // Check required fields - if (column.required && (value === undefined || value === null)) { + // Check required fields (columns are required by default unless marked optional) + if (!column.optional && (value === undefined || value === null)) { errors.push(`Missing required field: ${column.name}`) continue } diff --git a/apps/sim/tools/table/types.ts b/apps/sim/tools/table/types.ts index e37fc34be..3e5bd9f48 100644 --- a/apps/sim/tools/table/types.ts +++ b/apps/sim/tools/table/types.ts @@ -22,7 +22,8 @@ export type ColumnType = 'string' | 'number' | 'boolean' | 'date' | 'json' export interface ColumnDefinition { name: string type: ColumnType - required?: boolean + optional?: boolean + unique?: boolean } export interface TableSchema {