refactoring

This commit is contained in:
Lakee Sivaraya
2026-01-14 20:43:56 -08:00
parent c9373c7b3e
commit b08ce03409
27 changed files with 112 additions and 530 deletions

View File

@@ -3,8 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { deleteTable, getTableById } from '@/lib/table'
import type { TableSchemaData } from '../utils'
import { deleteTable, getTableById, type TableSchema } from '@/lib/table'
import {
checkTableAccess,
checkTableWriteAccess,
@@ -106,7 +105,7 @@ export async function GET(request: NextRequest, { params }: TableRouteParams) {
logger.info(`[${requestId}] Retrieved table ${tableId} for user ${authResult.userId}`)
const schemaData = table.schema as TableSchemaData
const schemaData = table.schema as TableSchema
return NextResponse.json({
success: true,

View File

@@ -6,8 +6,8 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { createTable, listTables, TABLE_LIMITS } from '@/lib/table'
import { normalizeColumn, type TableSchemaData } from './utils'
import { createTable, listTables, TABLE_LIMITS, type TableSchema } from '@/lib/table'
import { normalizeColumn } from './utils'
const logger = createLogger('TableAPI')
@@ -192,7 +192,7 @@ export async function POST(request: NextRequest) {
}
// Normalize schema to ensure all fields have explicit defaults
const normalizedSchema: TableSchemaData = {
const normalizedSchema: TableSchema = {
columns: params.schema.columns.map(normalizeColumn),
}
@@ -301,7 +301,7 @@ export async function GET(request: NextRequest) {
success: true,
data: {
tables: tables.map((t) => {
const schemaData = t.schema as TableSchemaData
const schemaData = t.schema as TableSchema
return {
...t,
schema: {

View File

@@ -3,12 +3,15 @@ import { userTableDefinitions } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import type { ColumnDefinition, TableSchema } from '@/lib/table'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('TableUtils')
/**
* Represents the core data structure for a user-defined table.
* Represents the core data structure for a user-defined table as stored in the database.
*
* This extends the base TableDefinition with DB-specific fields like createdBy and deletedAt.
*/
export interface TableData {
/** Unique identifier for the table */
@@ -22,7 +25,7 @@ export interface TableData {
/** Optional description of the table's purpose */
description?: string | null
/** JSON schema defining the table's column structure */
schema: TableSchemaData
schema: TableSchema
/** Maximum number of rows allowed in this table */
maxRows: number
/** Current number of rows in the table */
@@ -35,28 +38,6 @@ export interface TableData {
updatedAt: Date
}
/**
* Schema structure for table columns stored in the database.
*/
export interface TableSchemaData {
/** Array of column definitions */
columns: TableColumnData[]
}
/**
* Represents a single column definition in the table schema.
*/
export interface TableColumnData {
/** Name of the column */
name: string
/** Data type of the column */
type: 'string' | 'number' | 'boolean' | 'date' | 'json'
/** Whether this column is required */
required?: boolean
/** Whether this column must have unique values */
unique?: boolean
}
/**
* Result returned when a user has access to a table.
*/
@@ -492,7 +473,7 @@ export function serverErrorResponse(
* @param col - The column definition to normalize
* @returns A normalized column with explicit required and unique values
*/
export function normalizeColumn(col: TableColumnData): TableColumnData {
export function normalizeColumn(col: ColumnDefinition): ColumnDefinition {
return {
name: col.name,
type: col.type,

View File

@@ -2,33 +2,21 @@
import { useCallback, useMemo, useState } from 'react'
import { ArrowDownAZ, ArrowUpAZ, Plus, X } from 'lucide-react'
import { nanoid } from 'nanoid'
import { Button, Combobox, Input } from '@/components/emcn'
import type { FilterCondition } from '@/lib/table/filters/constants'
import { conditionsToFilter } from '@/lib/table/filters/builder-utils'
import type { FilterCondition, SortCondition } from '@/lib/table/filters/constants'
import { useFilterBuilder } from '@/lib/table/filters/use-builder'
/**
* Represents a sort configuration.
*/
export interface SortConfig {
/** Column to sort by */
column: string
/** Sort direction */
direction: 'asc' | 'desc'
}
/**
* Filter value structure for API queries.
*/
type FilterValue = string | number | boolean | null | FilterValue[] | { [key: string]: FilterValue }
import type { JsonValue } from '@/lib/table/types'
/**
* Query options for the table API.
*/
export interface QueryOptions {
/** Filter criteria or null for no filter, keys are column names, values are filter values */
filter: Record<string, FilterValue> | null
filter: Record<string, JsonValue> | null
/** Sort configuration or null for default sort */
sort: SortConfig | null
sort: SortCondition | null
}
/**
@@ -53,111 +41,6 @@ interface TableQueryBuilderProps {
onAddRow: () => void
}
/**
* Parses a string value into its appropriate type.
*
* @param value - String value to parse
* @returns Parsed value (boolean, null, number, or string)
*/
function parseValue(value: string): string | number | boolean | null {
if (value === 'true') return true
if (value === 'false') return false
if (value === 'null') return null
if (!Number.isNaN(Number(value)) && value !== '') return Number(value)
return value
}
/**
* Parses a comma-separated string into an array of values.
*
* @param value - Comma-separated string
* @returns Array of parsed values
*/
function parseArrayValue(value: string): FilterValue[] {
return value.split(',').map((v) => {
const trimmed = v.trim()
return parseValue(trimmed)
})
}
/**
* Converts builder filter conditions to a MongoDB-style filter object.
*
* 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)
} else {
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 }
}
return orGroups[0] || null
}
/**
* Component for building filter and sort queries for table data.
*
@@ -178,7 +61,7 @@ function conditionsToFilter(conditions: FilterCondition[]): Record<string, Filte
*/
export function TableQueryBuilder({ columns, onApply, onAddRow }: TableQueryBuilderProps) {
const [conditions, setConditions] = useState<FilterCondition[]>([])
const [sortConfig, setSortConfig] = useState<SortConfig | null>(null)
const [sortCondition, setSortCondition] = useState<SortCondition | null>(null)
const columnOptions = useMemo(
() => columns.map((col) => ({ value: col.name, label: col.name })),
@@ -200,46 +83,47 @@ export function TableQueryBuilder({ columns, onApply, onAddRow }: TableQueryBuil
})
/**
* Adds a sort configuration.
* Adds a sort condition.
*/
const handleAddSort = useCallback(() => {
setSortConfig({
setSortCondition({
id: nanoid(),
column: columns[0]?.name || '',
direction: 'asc',
})
}, [columns])
/**
* Removes the sort configuration.
* Removes the sort condition.
*/
const handleRemoveSort = useCallback(() => {
setSortConfig(null)
setSortCondition(null)
}, [])
/**
* Applies the current filter and sort configuration.
* Applies the current filter and sort conditions.
*/
const handleApply = useCallback(() => {
const filter = conditionsToFilter(conditions)
onApply({
filter,
sort: sortConfig,
sort: sortCondition,
})
}, [conditions, sortConfig, onApply])
}, [conditions, sortCondition, onApply])
/**
* Clears all filters and sort configuration.
* Clears all filters and sort conditions.
*/
const handleClear = useCallback(() => {
setConditions([])
setSortConfig(null)
setSortCondition(null)
onApply({
filter: null,
sort: null,
})
}, [onApply])
const hasChanges = conditions.length > 0 || sortConfig !== null
const hasChanges = conditions.length > 0 || sortCondition !== null
return (
<div className='flex flex-col gap-[8px]'>
@@ -259,12 +143,12 @@ export function TableQueryBuilder({ columns, onApply, onAddRow }: TableQueryBuil
))}
{/* Sort Row */}
{sortConfig && (
<SortConfigRow
sortConfig={sortConfig}
{sortCondition && (
<SortConditionRow
sortCondition={sortCondition}
columnOptions={columnOptions}
sortDirectionOptions={sortDirectionOptions}
onChange={setSortConfig}
onChange={setSortCondition}
onRemove={handleRemoveSort}
/>
)}
@@ -281,7 +165,7 @@ export function TableQueryBuilder({ columns, onApply, onAddRow }: TableQueryBuil
Add filter
</Button>
{!sortConfig && (
{!sortCondition && (
<Button variant='default' size='sm' onClick={handleAddSort}>
<ArrowUpAZ className='mr-[4px] h-[12px] w-[12px]' />
Add sort
@@ -406,31 +290,31 @@ function FilterConditionRow({
}
/**
* Props for the SortConfigRow component.
* Props for the SortConditionRow component.
*/
interface SortConfigRowProps {
/** The sort configuration */
sortConfig: SortConfig
interface SortConditionRowProps {
/** The sort condition */
sortCondition: SortCondition
/** Available column options */
columnOptions: Array<{ value: string; label: string }>
/** Available sort direction options */
sortDirectionOptions: Array<{ value: string; label: string }>
/** Callback to update the sort configuration */
onChange: (config: SortConfig | null) => void
/** Callback to update the sort condition */
onChange: (condition: SortCondition | null) => void
/** Callback to remove the sort */
onRemove: () => void
}
/**
* Sort configuration row component.
* Sort condition row component.
*/
function SortConfigRow({
sortConfig,
function SortConditionRow({
sortCondition,
columnOptions,
sortDirectionOptions,
onChange,
onRemove,
}: SortConfigRowProps) {
}: SortConditionRowProps) {
return (
<div className='flex items-center gap-[8px]'>
<Button
@@ -450,8 +334,8 @@ function SortConfigRow({
<Combobox
size='sm'
options={columnOptions}
value={sortConfig.column}
onChange={(value) => onChange({ ...sortConfig, column: value })}
value={sortCondition.column}
onChange={(value) => onChange({ ...sortCondition, column: value })}
placeholder='Column'
/>
</div>
@@ -460,13 +344,13 @@ function SortConfigRow({
<Combobox
size='sm'
options={sortDirectionOptions}
value={sortConfig.direction}
onChange={(value) => onChange({ ...sortConfig, direction: value as 'asc' | 'desc' })}
value={sortCondition.direction}
onChange={(value) => onChange({ ...sortCondition, direction: value as 'asc' | 'desc' })}
/>
</div>
<div className='flex items-center text-[12px] text-[var(--text-tertiary)]'>
{sortConfig.direction === 'asc' ? (
{sortCondition.direction === 'asc' ? (
<ArrowUpAZ className='h-[14px] w-[14px]' />
) : (
<ArrowDownAZ className='h-[14px] w-[14px]' />

View File

@@ -16,24 +16,10 @@ import {
Textarea,
} from '@/components/emcn'
import { Input } from '@/components/ui/input'
import type { ColumnDefinition, TableSchema } from '@/lib/table'
import type { ColumnDefinition, TableRow, TableSchema } from '@/lib/table'
const logger = createLogger('TableRowModal')
/**
* Represents row data from the table.
*/
export interface TableRowData {
/** Unique identifier for the row */
id: string
/** Row field values keyed by column name */
data: Record<string, unknown>
/** ISO timestamp when the row was created */
createdAt: string
/** ISO timestamp when the row was last updated */
updatedAt: string
}
/**
* Table metadata needed for row operations.
*/
@@ -64,7 +50,7 @@ export interface TableRowModalProps {
/** Table to operate on */
table: TableInfo
/** Row being edited/deleted (required for edit/delete modes) */
row?: TableRowData
row?: TableRow
/** Row IDs to delete (for delete mode batch operations) */
rowIds?: string[]
/** Callback when operation is successful */

View File

@@ -5,11 +5,12 @@
*/
import { useCallback, useState } from 'react'
import type { ContextMenuState, TableRowData } from '../types'
import type { TableRow } from '@/lib/table'
import type { ContextMenuState } from '../types'
interface UseContextMenuReturn {
contextMenu: ContextMenuState
handleRowContextMenu: (e: React.MouseEvent, row: TableRowData) => void
handleRowContextMenu: (e: React.MouseEvent, row: TableRow) => void
closeContextMenu: () => void
}
@@ -28,7 +29,7 @@ export function useContextMenu(): UseContextMenuReturn {
/**
* Opens the context menu for a row.
*/
const handleRowContextMenu = useCallback((e: React.MouseEvent, row: TableRowData) => {
const handleRowContextMenu = useCallback((e: React.MouseEvent, row: TableRow) => {
e.preventDefault()
e.stopPropagation()
setContextMenu({

View File

@@ -5,7 +5,7 @@
*/
import { useCallback, useState } from 'react'
import type { TableRowData } from '../types'
import type { TableRow } from '@/lib/table'
interface UseRowSelectionReturn {
selectedRows: Set<string>
@@ -20,7 +20,7 @@ interface UseRowSelectionReturn {
* @param rows - The current rows to select from
* @returns Selection state and handlers
*/
export function useRowSelection(rows: TableRowData[]): UseRowSelectionReturn {
export function useRowSelection(rows: TableRow[]): UseRowSelectionReturn {
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set())
/**

View File

@@ -5,9 +5,9 @@
*/
import { useQuery } from '@tanstack/react-query'
import type { TableDefinition, TableRow } from '@/lib/table'
import type { QueryOptions } from '../../components/table-query-builder'
import { ROWS_PER_PAGE } from '../constants'
import type { TableData, TableRowData } from '../types'
interface UseTableDataParams {
workspaceId: string
@@ -17,9 +17,9 @@ interface UseTableDataParams {
}
interface UseTableDataReturn {
tableData: TableData | undefined
tableData: TableDefinition | undefined
isLoadingTable: boolean
rows: TableRowData[]
rows: TableRow[]
totalCount: number
totalPages: number
isLoadingRows: boolean
@@ -43,9 +43,9 @@ export function useTableData({
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 json: { data?: { table: TableDefinition }; table?: TableDefinition } = await res.json()
const data = json.data || json
return (data as { table: TableData }).table
return (data as { table: TableDefinition }).table
},
})
@@ -74,8 +74,8 @@ export function useTableData({
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[]
data?: { rows: TableRow[]; totalCount: number }
rows?: TableRow[]
totalCount?: number
} = await res.json()
return json.data || json
@@ -83,7 +83,7 @@ export function useTableData({
enabled: !!tableData,
})
const rows = (rowsData?.rows || []) as TableRowData[]
const rows = (rowsData?.rows || []) as TableRow[]
const totalCount = rowsData?.totalCount || 0
const totalPages = Math.ceil(totalCount / ROWS_PER_PAGE)

View File

@@ -19,6 +19,7 @@ import {
TableRow,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { TableRow as TableRowType } from '@/lib/table'
import { type QueryOptions, TableActionBar, TableQueryBuilder, TableRowModal } from '../components'
import {
CellViewerModal,
@@ -31,7 +32,7 @@ import {
TablePagination,
} from './components'
import { useContextMenu, useRowSelection, useTableData } from './hooks'
import type { CellViewerData, TableRowData } from './types'
import type { CellViewerData } from './types'
/**
* Main component for viewing and managing table data.
@@ -62,7 +63,7 @@ export function TableDataViewer() {
const [currentPage, setCurrentPage] = useState(0)
const [showAddModal, setShowAddModal] = useState(false)
const [editingRow, setEditingRow] = useState<TableRowData | null>(null)
const [editingRow, setEditingRow] = useState<TableRowType | null>(null)
const [deletingRows, setDeletingRows] = useState<string[]>([])
const [showSchemaModal, setShowSchemaModal] = useState(false)

View File

@@ -4,43 +4,7 @@
* @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<string, unknown>
/** 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
}
import type { TableRow } from '@/lib/table'
/**
* Data for the cell viewer modal.
@@ -63,5 +27,5 @@ export interface ContextMenuState {
/** Screen position of the menu */
position: { x: number; y: number }
/** Row the menu was opened on */
row: TableRowData | null
row: TableRow | null
}

View File

@@ -17,29 +17,11 @@ import {
ModalHeader,
Textarea,
} from '@/components/emcn'
import type { ColumnDefinition, ColumnType } from '@/lib/table'
import { useCreateTable } from '@/hooks/queries/use-tables'
const logger = createLogger('CreateTableModal')
/**
* Supported column data types for table schemas.
*/
type ColumnType = 'string' | 'number' | 'boolean' | 'date' | 'json'
/**
* Definition for a single table column.
*/
interface ColumnDefinition {
/** Name of the column */
name: string
/** Data type of the column */
type: ColumnType
/** Whether this column is required */
required: boolean
/** Whether this column must have unique values */
unique: boolean
}
/**
* Props for the CreateTableModal component.
*/
@@ -51,9 +33,9 @@ interface CreateTableModalProps {
}
/**
* Available column type options for the combobox.
* Available column type options for the combobox UI.
*/
const COLUMN_TYPES: Array<{ value: ColumnType; label: string }> = [
const COLUMN_TYPE_OPTIONS: Array<{ value: ColumnType; label: string }> = [
{ value: 'string', label: 'String' },
{ value: 'number', label: 'Number' },
{ value: 'boolean', label: 'Boolean' },
@@ -356,7 +338,7 @@ function ColumnRow({ column, index, isRemovable, onChange, onRemove }: ColumnRow
{/* Column Type */}
<div className='w-[110px]'>
<Combobox
options={COLUMN_TYPES}
options={COLUMN_TYPE_OPTIONS}
value={column.type}
selectedValue={column.type}
onChange={(value) => onChange(index, 'type', value as ColumnType)}

View File

@@ -24,8 +24,8 @@ import {
TableRow,
Tooltip,
} from '@/components/emcn'
import type { TableDefinition } from '@/lib/table'
import { useDeleteTable } from '@/hooks/queries/use-tables'
import type { TableDefinition } from '@/tools/table/types'
const logger = createLogger('TableCard')

View File

@@ -3,10 +3,9 @@
import { useMemo } from 'react'
import { Plus } from 'lucide-react'
import { Button } from '@/components/emcn'
import { conditionsToJsonString, jsonStringToConditions } from '@/lib/table/filters/builder-utils'
import type { FilterCondition } from '@/lib/table/filters/constants'
import { useFilterBuilder } from '@/lib/table/filters/use-builder'
import { useBuilderJsonSync, useTableColumns } from '@/lib/table/hooks'
import { useTableColumns } from '@/lib/table/hooks'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { EmptyState } from './components/empty-state'
import { FilterConditionRow } from './components/filter-condition-row'
@@ -19,15 +18,10 @@ interface FilterFormatProps {
disabled?: boolean
columns?: Array<{ value: string; label: string }>
tableIdSubBlockId?: string
modeSubBlockId?: string
jsonSubBlockId?: string
}
/**
* Visual builder for filter conditions with optional JSON sync.
*
* When `modeSubBlockId` and `jsonSubBlockId` are provided, handles bidirectional
* conversion between builder conditions and JSON format.
* Visual builder for filter conditions.
*/
export function FilterFormat({
blockId,
@@ -37,16 +31,9 @@ export function FilterFormat({
disabled = false,
columns: propColumns,
tableIdSubBlockId = 'tableId',
modeSubBlockId,
jsonSubBlockId,
}: FilterFormatProps) {
const [storeValue, setStoreValue] = useSubBlockValue<FilterCondition[]>(blockId, subBlockId)
const [tableIdValue] = useSubBlockValue<string>(blockId, tableIdSubBlockId)
const [modeValue] = useSubBlockValue<string>(blockId, modeSubBlockId || '_unused_mode')
const [jsonValue, setJsonValue] = useSubBlockValue<string>(
blockId,
jsonSubBlockId || '_unused_json'
)
const dynamicColumns = useTableColumns({ tableId: tableIdValue })
const columns = useMemo(() => {
@@ -58,18 +45,6 @@ export function FilterFormat({
const conditions: FilterCondition[] = Array.isArray(value) && value.length > 0 ? value : []
const isReadOnly = isPreview || disabled
useBuilderJsonSync({
modeValue,
jsonValue,
setJsonValue,
isPreview,
conditions,
setConditions: setStoreValue,
jsonToConditions: jsonStringToConditions,
conditionsToJson: conditionsToJsonString,
enabled: Boolean(modeSubBlockId && jsonSubBlockId),
})
const { comparisonOptions, logicalOptions, addCondition, removeCondition, updateCondition } =
useFilterBuilder({
columns,

View File

@@ -4,12 +4,8 @@ import { useCallback, useMemo } from 'react'
import { Plus } from 'lucide-react'
import { nanoid } from 'nanoid'
import { Button, type ComboboxOption } from '@/components/emcn'
import {
jsonStringToSortConditions,
sortConditionsToJsonString,
} from '@/lib/table/filters/builder-utils'
import { SORT_DIRECTIONS, type SortCondition } from '@/lib/table/filters/constants'
import { useBuilderJsonSync, useTableColumns } from '@/lib/table/hooks'
import { useTableColumns } from '@/lib/table/hooks'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { EmptyState } from './components/empty-state'
import { SortConditionRow } from './components/sort-condition-row'
@@ -22,8 +18,6 @@ interface SortFormatProps {
disabled?: boolean
columns?: Array<{ value: string; label: string }>
tableIdSubBlockId?: string
modeSubBlockId?: string
jsonSubBlockId?: string
}
const createDefaultCondition = (columns: ComboboxOption[]): SortCondition => ({
@@ -33,10 +27,7 @@ const createDefaultCondition = (columns: ComboboxOption[]): SortCondition => ({
})
/**
* Visual builder for sort conditions with optional JSON sync.
*
* When `modeSubBlockId` and `jsonSubBlockId` are provided, handles bidirectional
* conversion between builder conditions and JSON format.
* Visual builder for sort conditions.
*/
export function SortFormat({
blockId,
@@ -46,16 +37,9 @@ export function SortFormat({
disabled = false,
columns: propColumns,
tableIdSubBlockId = 'tableId',
modeSubBlockId,
jsonSubBlockId,
}: SortFormatProps) {
const [storeValue, setStoreValue] = useSubBlockValue<SortCondition[]>(blockId, subBlockId)
const [tableIdValue] = useSubBlockValue<string>(blockId, tableIdSubBlockId)
const [modeValue] = useSubBlockValue<string>(blockId, modeSubBlockId || '_unused_mode')
const [jsonValue, setJsonValue] = useSubBlockValue<string>(
blockId,
jsonSubBlockId || '_unused_json'
)
const dynamicColumns = useTableColumns({ tableId: tableIdValue, includeBuiltIn: true })
const columns = useMemo(() => {
@@ -72,18 +56,6 @@ export function SortFormat({
const conditions: SortCondition[] = Array.isArray(value) && value.length > 0 ? value : []
const isReadOnly = isPreview || disabled
useBuilderJsonSync({
modeValue,
jsonValue,
setJsonValue,
isPreview,
conditions,
setConditions: setStoreValue,
jsonToConditions: jsonStringToSortConditions,
conditionsToJson: sortConditionsToJsonString,
enabled: Boolean(modeSubBlockId && jsonSubBlockId),
})
const addCondition = useCallback(() => {
if (isReadOnly) return
setStoreValue([...conditions, createDefaultCondition(columns)])

View File

@@ -19,11 +19,11 @@ interface TableProps {
subBlockId: string
columns: string[]
isPreview?: boolean
previewValue?: TableRow[] | null
previewValue?: WorkflowTableRow[] | null
disabled?: boolean
}
interface TableRow {
interface WorkflowTableRow {
id: string
cells: Record<string, string>
}
@@ -38,7 +38,7 @@ export function Table({
}: TableProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const [storeValue, setStoreValue] = useSubBlockValue<TableRow[]>(blockId, subBlockId)
const [storeValue, setStoreValue] = useSubBlockValue<WorkflowTableRow[]>(blockId, subBlockId)
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
// Use the extended hook for field-level management
@@ -73,7 +73,7 @@ export function Table({
*/
useEffect(() => {
if (!isPreview && !disabled && (!Array.isArray(storeValue) || storeValue.length === 0)) {
const initialRow: TableRow = {
const initialRow: WorkflowTableRow = {
id: crypto.randomUUID(),
cells: { ...emptyCellsTemplate },
}
@@ -110,7 +110,7 @@ export function Table({
}
})
return validatedRows as TableRow[]
return validatedRows as WorkflowTableRow[]
}, [value, emptyCellsTemplate])
// Helper to update a cell value
@@ -164,7 +164,12 @@ export function Table({
</thead>
)
const renderCell = (row: TableRow, rowIndex: number, column: string, cellIndex: number) => {
const renderCell = (
row: WorkflowTableRow,
rowIndex: number,
column: string,
cellIndex: number
) => {
// Defensive programming: ensure row.cells exists and has the expected structure
const hasValidCells = row.cells && typeof row.cells === 'object'
if (!hasValidCells) logger.warn('Table row has malformed cells data:', row)

View File

@@ -814,17 +814,7 @@ function SubBlockComponent({
/>
)
case 'filter-format': {
// Determine sync props based on subBlockId
let modeSubBlockId: string | undefined
let jsonSubBlockId: string | undefined
if (config.id === 'filterBuilder') {
modeSubBlockId = 'builderMode'
jsonSubBlockId = 'filter'
} else if (config.id === 'bulkFilterBuilder') {
modeSubBlockId = 'bulkFilterMode'
jsonSubBlockId = 'filterCriteria'
}
case 'filter-format':
return (
<FilterFormat
blockId={blockId}
@@ -832,20 +822,10 @@ function SubBlockComponent({
isPreview={isPreview}
previewValue={previewValue as FilterCondition[] | null | undefined}
disabled={isDisabled}
modeSubBlockId={modeSubBlockId}
jsonSubBlockId={jsonSubBlockId}
/>
)
}
case 'sort-format': {
// Determine sync props based on subBlockId
let modeSubBlockId: string | undefined
let jsonSubBlockId: string | undefined
if (config.id === 'sortBuilder') {
modeSubBlockId = 'builderMode'
jsonSubBlockId = 'sort'
}
case 'sort-format':
return (
<SortFormat
blockId={blockId}
@@ -853,11 +833,8 @@ function SubBlockComponent({
isPreview={isPreview}
previewValue={previewValue as SortCondition[] | null | undefined}
disabled={isDisabled}
modeSubBlockId={modeSubBlockId}
jsonSubBlockId={jsonSubBlockId}
/>
)
}
case 'channel-selector':
case 'user-selector':

View File

@@ -46,9 +46,9 @@ import { wouldCreateCycle } from '@/stores/workflows/workflow/utils'
const logger = createLogger('WorkflowBlock')
/**
* Type guard for table row structure
* Type guard for workflow table row structure (sub-block table inputs)
*/
interface TableRow {
interface WorkflowTableRow {
id: string
cells: Record<string, string>
}
@@ -67,7 +67,7 @@ interface FieldFormat {
/**
* Checks if a value is a table row array
*/
const isTableRowArray = (value: unknown): value is TableRow[] => {
const isTableRowArray = (value: unknown): value is WorkflowTableRow[] => {
if (!Array.isArray(value) || value.length === 0) return false
const firstItem = value[0]
return (

View File

@@ -3,7 +3,7 @@
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { TableDefinition } from '@/tools/table/types'
import type { TableDefinition } from '@/lib/table'
export const tableKeys = {
all: ['tables'] as const,

View File

@@ -5,10 +5,9 @@
*/
import { nanoid } from 'nanoid'
import type { JsonValue } from '../types'
import type { FilterCondition, SortCondition } from './constants'
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }
/**
* Parses a string value into its appropriate type based on the operator.
*
@@ -163,35 +162,6 @@ function formatValueForBuilder(value: JsonValue): string {
return String(value)
}
/**
* Converts builder conditions to JSON string.
*
* @param conditions - Array of filter conditions
* @returns JSON string representation
*/
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.
*
* @param jsonString - JSON string to parse
* @returns Array of filter conditions or empty array if invalid
*/
export function jsonStringToConditions(jsonString: string): FilterCondition[] {
if (!jsonString || !jsonString.trim()) return []
try {
const filter = JSON.parse(jsonString)
return filterToConditions(filter)
} catch {
return []
}
}
/**
* Converts builder sort conditions to sort object.
*
@@ -226,32 +196,3 @@ export function sortToConditions(sort: Record<string, string> | null): SortCondi
direction: direction === 'desc' ? 'desc' : 'asc',
}))
}
/**
* Converts builder sort conditions to JSON string.
*
* @param conditions - Array of sort conditions
* @returns JSON string representation
*/
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.
*
* @param jsonString - JSON string to parse
* @returns Array of sort conditions or empty array if invalid
*/
export function jsonStringToSortConditions(jsonString: string): SortCondition[] {
if (!jsonString || !jsonString.trim()) return []
try {
const sort = JSON.parse(jsonString)
return sortToConditions(sort)
} catch {
return []
}
}

View File

@@ -1,2 +1 @@
export { useBuilderJsonSync } from './use-builder-json-sync'
export { useTableColumns } from './use-table-columns'

View File

@@ -1,66 +0,0 @@
import { useEffect, useRef } from 'react'
interface UseBuilderJsonSyncOptions<T> {
modeValue: string | null
jsonValue: string | null
setJsonValue: (value: string) => void
isPreview: boolean
conditions: T[]
setConditions: (conditions: T[]) => void
jsonToConditions: (json: string) => T[]
conditionsToJson: (conditions: T[]) => string
enabled?: boolean
}
/**
* Handles bidirectional sync between builder conditions and JSON format.
*
* - JSON → Builder: When mode switches to 'builder', parses JSON into conditions
* - Builder → JSON: When conditions change in builder mode, converts to JSON
*/
export function useBuilderJsonSync<T>({
modeValue,
jsonValue,
setJsonValue,
isPreview,
conditions,
setConditions,
jsonToConditions,
conditionsToJson,
enabled = true,
}: UseBuilderJsonSyncOptions<T>) {
const prevModeRef = useRef<string | null>(null)
const isSyncingRef = useRef(false)
// Sync JSON → Builder when switching to builder mode
useEffect(() => {
if (!enabled || isPreview) return
const switchingToBuilder =
prevModeRef.current !== null && prevModeRef.current !== 'builder' && modeValue === 'builder'
if (switchingToBuilder && jsonValue?.trim()) {
isSyncingRef.current = true
const parsedConditions = jsonToConditions(jsonValue)
if (parsedConditions.length > 0) {
setConditions(parsedConditions)
}
isSyncingRef.current = false
}
prevModeRef.current = modeValue
}, [modeValue, jsonValue, setConditions, isPreview, jsonToConditions, enabled])
// Sync Builder → JSON when conditions change in builder mode
useEffect(() => {
if (!enabled || isPreview || isSyncingRef.current) return
if (modeValue !== 'builder') return
if (conditions.length > 0) {
const newJson = conditionsToJson(conditions)
if (newJson !== jsonValue) {
setJsonValue(newJson)
}
}
}, [conditions, modeValue, jsonValue, setJsonValue, isPreview, conditionsToJson, enabled])
}

View File

@@ -4,12 +4,14 @@
* Provides validation, query building, service layer, and filter utilities
* for user-defined tables.
*
* Hooks are not re-exported here to avoid pulling React into server code.
* Import hooks directly from '@/lib/table/hooks' in client components.
*
* @module lib/table
*/
export * from './constants'
export * from './filters'
export * from './hooks'
export * from './query-builder'
export * from './service'
export * from './types'

View File

@@ -10,12 +10,6 @@ import { sql } from 'drizzle-orm'
import { NAME_PATTERN } from './constants'
import type { FilterOperators, JsonValue, QueryFilter } from './types'
/**
* Field condition is an alias for FilterOperators.
* @deprecated Use FilterOperators from types.ts instead.
*/
export type FieldCondition = FilterOperators
/**
* Whitelist of allowed operators for query filtering.
* Only these operators can be used in filter conditions.
@@ -107,7 +101,7 @@ function buildContainsClause(tableName: string, field: string, value: string): S
*
* @param tableName - The name of the table to query (used for SQL table reference)
* @param field - The field name to filter on (must match NAME_PATTERN)
* @param condition - Either a simple value (for equality) or a FieldCondition
* @param condition - Either a simple value (for equality) or a FilterOperators
* object with operators like $eq, $gt, $in, etc.
* @returns Array of SQL condition fragments. Multiple conditions are returned
* when the condition object contains multiple operators.
@@ -116,7 +110,7 @@ function buildContainsClause(tableName: string, field: string, value: string): S
function buildFieldCondition(
tableName: string,
field: string,
condition: JsonValue | FieldCondition
condition: JsonValue | FilterOperators
): SQL[] {
validateFieldName(field)
@@ -249,7 +243,7 @@ export function buildFilterClause(filter: QueryFilter, tableName: string): SQL |
const fieldConditions = buildFieldCondition(
tableName,
field,
condition as JsonValue | FieldCondition
condition as JsonValue | FilterOperators
)
conditions.push(...fieldConditions)
}

View File

@@ -1,15 +1,11 @@
/**
* Core type definitions for user-defined tables.
*
* This module provides the single source of truth for all table-related types.
* Import types from here rather than defining them locally.
*
* @module lib/table/types
*/
import type { ColumnType } from './constants'
// Re-export ColumnType for convenience
export type { ColumnType }
/** Primitive values that can be stored in table columns */

View File

@@ -1,20 +1,3 @@
import type { ToolResponse } from '@/tools/types'
// Re-export shared types from lib/table for convenience
export type {
ColumnDefinition,
ColumnType,
ColumnValue,
FilterOperators,
JsonValue,
QueryFilter,
RowData,
TableDefinition,
TableRow,
TableSchema,
} from '@/lib/table/types'
// Import types for use in this file
import type {
ColumnDefinition,
QueryFilter,
@@ -23,6 +6,7 @@ import type {
TableRow,
TableSchema,
} from '@/lib/table/types'
import type { ToolResponse } from '@/tools/types'
/**
* Execution context provided by the workflow executor

View File

@@ -1,5 +1,6 @@
import type { TableRow } from '@/lib/table'
import type { ToolConfig, ToolResponse } from '@/tools/types'
import type { TableRow, TableRowInsertParams } from './types'
import type { TableRowInsertParams } from './types'
interface TableUpsertResponse extends ToolResponse {
output: {

View File

@@ -112,7 +112,8 @@ export interface ToolConfig<P = any, R = any> {
directExecution?: (params: P) => Promise<ToolResponse>
}
export interface TableRow {
/** Key-value pair row for HTTP request tables (headers, params) */
export interface KeyValueRow {
id: string
cells: {
Key: string
@@ -120,6 +121,9 @@ export interface TableRow {
}
}
/** @deprecated Use KeyValueRow instead */
export type TableRow = KeyValueRow
export interface OAuthTokenPayload {
credentialId?: string
credentialAccountUserId?: string