Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/sim/app/api/table/[tableId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { deleteTable, renameTable, TableConflictError, type TableSchema } from '@/lib/table'
import { getWorkspaceTableLimits } from '@/lib/table/billing'
import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils'

const logger = createLogger('TableDetailAPI')
Expand Down Expand Up @@ -46,6 +47,10 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Tab

const schemaData = table.schema as TableSchema

// Source the row cap from the workspace's live plan, not the value stored on
// the table at creation time (which goes stale when the plan changes).
const { maxRowsPerTable } = await getWorkspaceTableLimits(table.workspaceId)

return NextResponse.json({
success: true,
data: {
Expand All @@ -59,7 +64,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Tab
},
metadata: table.metadata ?? null,
rowCount: table.rowCount,
maxRows: table.maxRows,
maxRows: maxRowsPerTable,
Comment thread
TheodoreSpeaks marked this conversation as resolved.
createdAt:
table.createdAt instanceof Date
? table.createdAt.toISOString()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
'use client'

import { useCallback, useMemo } from 'react'
import { generateId } from '@sim/utils/id'
import type { ComboboxOption } from '@/components/emcn'
import { Plus } from 'lucide-react'
import { Button } from '@/components/emcn'
import { useTableColumns } from '@/lib/table/hooks'
import type { FilterRule } from '@/lib/table/query-builder/constants'
import { useFilterBuilder } from '@/lib/table/query-builder/use-query-builder'
Expand All @@ -22,15 +22,6 @@ interface FilterBuilderProps {
tableIdSubBlockId?: string
}

const createDefaultRule = (columns: ComboboxOption[]): FilterRule => ({
id: generateId(),
logicalOperator: 'and',
column: columns[0]?.value || '',
operator: 'eq',
value: '',
collapsed: false,
})

/** Visual builder for table filter rules in workflow blocks. */
export function FilterBuilder({
blockId,
Expand All @@ -52,8 +43,7 @@ export function FilterBuilder({
}, [propColumns, dynamicColumns])

const value = isPreview ? previewValue : storeValue
const rules: FilterRule[] =
Array.isArray(value) && value.length > 0 ? value : [createDefaultRule(columns)]
const rules: FilterRule[] = Array.isArray(value) ? value : []
const isReadOnly = isPreview || disabled

const { comparisonOptions, logicalOptions, addRule, removeRule, updateRule } = useFilterBuilder({
Expand Down Expand Up @@ -86,15 +76,25 @@ export function FilterBuilder({
const handleRemoveRule = useCallback(
(id: string) => {
if (isReadOnly) return
if (rules.length === 1) {
setStoreValue([createDefaultRule(columns)])
} else {
removeRule(id)
}
removeRule(id)
},
[isReadOnly, rules, columns, setStoreValue, removeRule]
[isReadOnly, removeRule]
)

if (rules.length === 0) {
if (isReadOnly) return null
return (
<Button
variant='ghost'
onClick={addRule}
className='h-7 w-full justify-start gap-1.5 border border-[var(--border-1)] border-dashed text-[var(--text-muted)] text-small'
>
<Plus className='size-[14px]' />
Add filter condition
</Button>
)
}

return (
<div className='space-y-2'>
{rules.map((rule, index) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import { useCallback, useMemo } from 'react'
import { generateId } from '@sim/utils/id'
import type { ComboboxOption } from '@/components/emcn'
import { Plus } from 'lucide-react'
import { Button, type ComboboxOption } from '@/components/emcn'
import { useTableColumns } from '@/lib/table/hooks'
import { SORT_DIRECTIONS, type SortRule } from '@/lib/table/query-builder/constants'
import { useCanonicalSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-canonical-sub-block-value'
Expand Down Expand Up @@ -51,8 +52,7 @@ export function SortBuilder({
)

const value = isPreview ? previewValue : storeValue
const rules: SortRule[] =
Array.isArray(value) && value.length > 0 ? value : [createDefaultRule(columns)]
const rules: SortRule[] = Array.isArray(value) ? value : []
const isReadOnly = isPreview || disabled

const addRule = useCallback(() => {
Expand All @@ -63,13 +63,9 @@ export function SortBuilder({
const removeRule = useCallback(
(id: string) => {
if (isReadOnly) return
if (rules.length === 1) {
setStoreValue([createDefaultRule(columns)])
} else {
setStoreValue(rules.filter((r) => r.id !== id))
}
setStoreValue(rules.filter((r) => r.id !== id))
},
[isReadOnly, rules, columns, setStoreValue]
[isReadOnly, rules, setStoreValue]
)

const updateRule = useCallback(
Expand All @@ -88,6 +84,20 @@ export function SortBuilder({
[isReadOnly, rules, setStoreValue]
)

if (rules.length === 0) {
if (isReadOnly) return null
return (
<Button
variant='ghost'
onClick={addRule}
className='h-7 w-full justify-start gap-1.5 border border-[var(--border-1)] border-dashed text-[var(--text-muted)] text-small'
>
<Plus className='size-[14px]' />
Add sort
</Button>
)
}

return (
<div className='space-y-2'>
{rules.map((rule, index) => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -929,6 +929,7 @@ function SubBlockComponent({
case 'file-selector':
case 'sheet-selector':
case 'project-selector':
case 'column-selector':
return (
<SelectorInput
blockId={blockId}
Expand Down
1 change: 1 addition & 0 deletions apps/sim/blocks/blocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,7 @@ describe.concurrent('Blocks Module', () => {
'text',
'router-input',
'table-selector',
'column-selector',
'filter-builder',
'sort-builder',
'skill-input',
Expand Down
48 changes: 45 additions & 3 deletions apps/sim/blocks/blocks/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ interface TableBlockParams {
sortBuilder?: unknown
bulkFilterMode?: string
bulkFilterBuilder?: unknown
conflictColumn?: string
}

/** Normalized params after parsing, ready for tool request body */
Expand All @@ -70,6 +71,7 @@ interface ParsedParams {
sort?: unknown
limit?: number
offset?: number
conflictTarget?: string
}

/** Transforms raw block params into tool request params for each operation */
Expand All @@ -82,6 +84,7 @@ const paramTransformers: Record<string, (params: TableBlockParams) => ParsedPara
upsert_row: (params) => ({
tableId: params.tableId,
data: parseJSON(params.data, 'Row Data'),
conflictTarget: params.conflictColumn || undefined,
}),

batch_insert_rows: (params) => ({
Expand Down Expand Up @@ -275,6 +278,30 @@ Return ONLY the data JSON:`,
},
},

// Upsert - which unique column to match on (required when 2+ unique columns)
// Basic: pick a unique column. Advanced: enter the column id directly.
{
id: 'conflictColumnSelector',
title: 'Conflict Column',
type: 'column-selector',
canonicalParamId: 'conflictColumn',
mode: 'basic',
selectorKey: 'table.columns',
placeholder: 'Select a unique column',
dependsOn: ['tableSelector'],
condition: { field: 'operation', value: 'upsert_row' },
},
{
id: 'manualConflictColumn',
title: 'Conflict Column',
type: 'short-input',
canonicalParamId: 'conflictColumn',
mode: 'advanced',
placeholder: 'Enter the column id',
dependsOn: ['tableId'],
condition: { field: 'operation', value: 'upsert_row' },
},

// Batch Insert - multiple rows
{
id: 'rows',
Expand Down Expand Up @@ -631,6 +658,11 @@ Return ONLY the sort JSON:`,
sortBuilder: { type: 'json', description: 'Visual sort builder conditions' },
sort: { type: 'json', description: 'Sort order (JSON)' },
offset: { type: 'number', description: 'Query result offset' },
conflictColumn: {
type: 'string',
description:
'Unique column to match on for upsert (required if the table has multiple unique columns)',
},
},

outputs: {
Expand All @@ -655,8 +687,8 @@ Return ONLY the sort JSON:`,
},
rowCount: {
type: 'number',
description: 'Number of rows returned',
condition: { field: 'operation', value: 'query_rows' },
description: 'Rows returned (query) or total rows in the table (get schema)',
condition: { field: 'operation', value: ['query_rows', 'get_schema'] },
},
totalCount: {
type: 'number',
Expand Down Expand Up @@ -695,7 +727,17 @@ Return ONLY the sort JSON:`,
},
columns: {
type: 'array',
description: 'Column definitions',
description: 'Column definitions (each includes its stable id)',
condition: { field: 'operation', value: 'get_schema' },
},
columnCount: {
type: 'number',
description: 'Number of columns',
condition: { field: 'operation', value: 'get_schema' },
},
maxRows: {
type: 'number',
description: "Max rows per table for the workspace's plan",
condition: { field: 'operation', value: 'get_schema' },
},
message: { type: 'string', description: 'Operation status message' },
Expand Down
12 changes: 12 additions & 0 deletions apps/sim/hooks/queries/tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,18 @@ export function useTable(workspaceId: string | undefined, tableId: string | unde
})
}

/**
* Shared table-detail query options so non-component callers (e.g. selector
* providers) can `ensureQueryData` the same cache entry `useTable` populates.
*/
export function getTableDetailQueryOptions(workspaceId: string, tableId: string) {
return {
queryKey: tableKeys.detail(tableId),
queryFn: ({ signal }: { signal?: AbortSignal }) => fetchTable(workspaceId, tableId, signal),
staleTime: 30 * 1000,
}
}

export interface TableRunState {
dispatches: ActiveDispatch[]
runningCellCount: number
Expand Down
35 changes: 34 additions & 1 deletion apps/sim/hooks/selectors/providers/sim/selectors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { getColumnId } from '@/lib/table/column-keys'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { getTableDetailQueryOptions } from '@/hooks/queries/tables'
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
import { getFolderPath } from '@/hooks/queries/utils/folder-tree'
import { getWorkflowById, getWorkflows } from '@/hooks/queries/utils/workflow-cache'
Expand All @@ -12,6 +14,7 @@ import type {
SelectorQueryArgs,
} from '@/hooks/selectors/types'
import type { WorkflowFolder } from '@/stores/folders/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'

/**
Expand Down Expand Up @@ -85,4 +88,34 @@ export const simSelectors = {
}
},
},
} satisfies Record<Extract<SelectorKey, 'sim.workflows'>, SelectorDefinition>
'table.columns': {
key: 'table.columns',
staleTime: SELECTOR_STALE,
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
...selectorKeys.all,
'table.columns',
context.tableId ?? 'none',
search ?? '',
],
enabled: ({ context }) => Boolean(context.tableId),
fetchList: async ({ context }: SelectorQueryArgs): Promise<SelectorOption[]> => {
const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId
if (!workspaceId || !context.tableId) return []
const table = await getQueryClient().ensureQueryData(
getTableDetailQueryOptions(workspaceId, context.tableId)
)
return (table.schema?.columns ?? [])
.filter((col) => col.unique)
.map((col) => ({ id: getColumnId(col), label: col.name }))
},
fetchById: async ({ context, detailId }: SelectorQueryArgs): Promise<SelectorOption | null> => {
const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId
if (!detailId || !workspaceId || !context.tableId) return null
const table = await getQueryClient().ensureQueryData(
getTableDetailQueryOptions(workspaceId, context.tableId)
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
)
const col = (table.schema?.columns ?? []).find((c) => getColumnId(c) === detailId)
return col ? { id: getColumnId(col), label: col.name } : null
},
},
} satisfies Record<Extract<SelectorKey, 'sim.workflows' | 'table.columns'>, SelectorDefinition>
2 changes: 2 additions & 0 deletions apps/sim/hooks/selectors/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export type SelectorKey =
| 'monday.boards'
| 'monday.groups'
| 'sim.workflows'
| 'table.columns'

export interface SelectorOption {
id: string
Expand Down Expand Up @@ -91,6 +92,7 @@ export interface SelectorContext {
awsRegion?: string
logGroupName?: string
mcpServerId?: string
tableId?: string
}

export interface SelectorQueryArgs {
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/lib/table/query-builder/converters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export function filterRulesToFilter(rules: FilterRule[]): Filter | null {
let currentGroup: Filter = {}

for (const rule of rules) {
// Skip incomplete rows (no column selected) so a blank builder row never
// serializes to a `{ '': ... }` predicate.
if (!rule.column) continue
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

const isOr = rule.logicalOperator === 'or'
const ruleValue = toRuleValue(rule.operator, rule.value)

Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/table/rows/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,7 @@ export async function upsertRow(
targetColumnKey = getColumnId(uniqueColumns[0])
} else {
throw new Error(
`Table has multiple unique columns (${uniqueColumns.map((c) => c.name).join(', ')}). Specify conflictTarget to indicate which column to match on.`
`Table has multiple unique columns (${uniqueColumns.map((c) => c.name).join(', ')}). Specify a conflict column to indicate which one to match on.`
)
}

Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/workflows/subblocks/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const SELECTOR_CONTEXT_FIELDS = new Set<keyof SelectorContext>([
'awsSecretAccessKey',
'awsRegion',
'logGroupName',
'tableId',
])

/**
Expand Down
Loading
Loading