Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 @@ -31,6 +31,7 @@ export function useSelectorSetup(
const params = useParams()
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const workflowId = (params?.workflowId as string) || activeWorkflowId || ''
const workspaceId = (params?.workspaceId as string) || ''

const { data: envVariables = {} } = usePersonalEnvironment()

Expand Down Expand Up @@ -63,6 +64,7 @@ export function useSelectorSetup(
const selectorContext = useMemo<SelectorContext>(() => {
const context: SelectorContext = {
workflowId,
workspaceId: workspaceId || undefined,
mimeType: subBlock.mimeType,
}

Expand All @@ -87,6 +89,7 @@ export function useSelectorSetup(
resolvedDependencyValues,
canonicalIndex,
workflowId,
workspaceId,
subBlock.mimeType,
impersonateUserEmail,
])
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 @@ -267,6 +267,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
33 changes: 32 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 Down Expand Up @@ -85,4 +87,33 @@ 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.workspaceId ?? 'none',
context.tableId ?? 'none',
search ?? '',
],
enabled: ({ context }) => Boolean(context.workspaceId && context.tableId),
fetchList: async ({ context }: SelectorQueryArgs): Promise<SelectorOption[]> => {
if (!context.workspaceId || !context.tableId) return []
const table = await getQueryClient().ensureQueryData(
getTableDetailQueryOptions(context.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> => {
if (!detailId || !context.workspaceId || !context.tableId) return null
const table = await getQueryClient().ensureQueryData(
getTableDetailQueryOptions(context.workspaceId, context.tableId)
)
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
10 changes: 8 additions & 2 deletions apps/sim/lib/table/query-builder/converters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,20 @@ export function filterRulesToFilter(rules: FilterRule[]): Filter | null {
let currentGroup: Filter = {}

for (const rule of rules) {
// Honor the OR boundary before skipping incomplete rows, so an incomplete
// `or` row between two valid conditions still starts a new group.
const isOr = rule.logicalOperator === 'or'
const ruleValue = toRuleValue(rule.operator, rule.value)

if (isOr && Object.keys(currentGroup).length > 0) {
orGroups.push({ ...currentGroup })
currentGroup = {}
}

// Skip incomplete rows (no column selected) so a blank builder row never
// serializes to a `{ '': ... }` predicate. The OR boundary above is still
// applied; the row just contributes no condition.
if (!rule.column) continue

const ruleValue = toRuleValue(rule.operator, rule.value)
const existing = currentGroup[rule.column]
currentGroup[rule.column] =
existing === undefined
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 @@ -516,7 +516,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
Loading
Loading