Skip to content

Commit 63fdc47

Browse files
improvement(block): table empty-state filter/sort builders + upsert conflict-column selection (#5123)
* ci(migrations): fail dev schema push with an actionable error on rename/drop prompt `drizzle-kit push --force` only suppresses the data-loss confirm, not the rename-vs-drop disambiguation prompt. That prompt fires whenever a diff both adds and drops tables/columns at once (e.g. migration 0231 created sim_trigger_state while dropping the workspace_notification_* tables), and in CI it crashes with a bare "Interactive prompts require a TTY" stack trace. Catch that specific failure in the dev push step and emit a GitHub error annotation explaining the cause and the fix (drop the stale objects on the dev DB to match schema.ts — the same DROPs the versioned migration already applied to staging/prod), instead of leaving an opaque trace. Exit status is preserved either way. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * improvement(tables): empty-state filter/sort builders + upsert conflict-column selection * improvement(tables): throw on ambiguous upsert instead of guessing the conflict column * Revert "ci(migrations): fail dev schema push with an actionable error on rename/drop prompt" This reverts commit 2626482. * improvement(tables): unique-column picker for upsert + richer get-schema (counts, ids, live plan row limit) * fix(tables): honor OR boundary when skipping incomplete filter rows * fix(tables): source workspaceId for column selector from route context --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 58312a1 commit 63fdc47

17 files changed

Lines changed: 184 additions & 38 deletions

File tree

apps/sim/app/api/table/[tableId]/route.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
88
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
99
import { captureServerEvent } from '@/lib/posthog/server'
1010
import { deleteTable, renameTable, TableConflictError, type TableSchema } from '@/lib/table'
11+
import { getWorkspaceTableLimits } from '@/lib/table/billing'
1112
import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils'
1213

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

4748
const schemaData = table.schema as TableSchema
4849

50+
// Source the row cap from the workspace's live plan, not the value stored on
51+
// the table at creation time (which goes stale when the plan changes).
52+
const { maxRowsPerTable } = await getWorkspaceTableLimits(table.workspaceId)
53+
4954
return NextResponse.json({
5055
success: true,
5156
data: {
@@ -59,7 +64,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Tab
5964
},
6065
metadata: table.metadata ?? null,
6166
rowCount: table.rowCount,
62-
maxRows: table.maxRows,
67+
maxRows: maxRowsPerTable,
6368
createdAt:
6469
table.createdAt instanceof Date
6570
? table.createdAt.toISOString()

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/filter-builder/filter-builder.tsx

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
'use client'
22

33
import { useCallback, useMemo } from 'react'
4-
import { generateId } from '@sim/utils/id'
5-
import type { ComboboxOption } from '@/components/emcn'
4+
import { Plus } from 'lucide-react'
5+
import { Button } from '@/components/emcn'
66
import { useTableColumns } from '@/lib/table/hooks'
77
import type { FilterRule } from '@/lib/table/query-builder/constants'
88
import { useFilterBuilder } from '@/lib/table/query-builder/use-query-builder'
@@ -22,15 +22,6 @@ interface FilterBuilderProps {
2222
tableIdSubBlockId?: string
2323
}
2424

25-
const createDefaultRule = (columns: ComboboxOption[]): FilterRule => ({
26-
id: generateId(),
27-
logicalOperator: 'and',
28-
column: columns[0]?.value || '',
29-
operator: 'eq',
30-
value: '',
31-
collapsed: false,
32-
})
33-
3425
/** Visual builder for table filter rules in workflow blocks. */
3526
export function FilterBuilder({
3627
blockId,
@@ -52,8 +43,7 @@ export function FilterBuilder({
5243
}, [propColumns, dynamicColumns])
5344

5445
const value = isPreview ? previewValue : storeValue
55-
const rules: FilterRule[] =
56-
Array.isArray(value) && value.length > 0 ? value : [createDefaultRule(columns)]
46+
const rules: FilterRule[] = Array.isArray(value) ? value : []
5747
const isReadOnly = isPreview || disabled
5848

5949
const { comparisonOptions, logicalOptions, addRule, removeRule, updateRule } = useFilterBuilder({
@@ -86,15 +76,25 @@ export function FilterBuilder({
8676
const handleRemoveRule = useCallback(
8777
(id: string) => {
8878
if (isReadOnly) return
89-
if (rules.length === 1) {
90-
setStoreValue([createDefaultRule(columns)])
91-
} else {
92-
removeRule(id)
93-
}
79+
removeRule(id)
9480
},
95-
[isReadOnly, rules, columns, setStoreValue, removeRule]
81+
[isReadOnly, removeRule]
9682
)
9783

84+
if (rules.length === 0) {
85+
if (isReadOnly) return null
86+
return (
87+
<Button
88+
variant='ghost'
89+
onClick={addRule}
90+
className='h-7 w-full justify-start gap-1.5 border border-[var(--border-1)] border-dashed text-[var(--text-muted)] text-small'
91+
>
92+
<Plus className='size-[14px]' />
93+
Add filter condition
94+
</Button>
95+
)
96+
}
97+
9898
return (
9999
<div className='space-y-2'>
100100
{rules.map((rule, index) => {

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sort-builder/sort-builder.tsx

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

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

5354
const value = isPreview ? previewValue : storeValue
54-
const rules: SortRule[] =
55-
Array.isArray(value) && value.length > 0 ? value : [createDefaultRule(columns)]
55+
const rules: SortRule[] = Array.isArray(value) ? value : []
5656
const isReadOnly = isPreview || disabled
5757

5858
const addRule = useCallback(() => {
@@ -63,13 +63,9 @@ export function SortBuilder({
6363
const removeRule = useCallback(
6464
(id: string) => {
6565
if (isReadOnly) return
66-
if (rules.length === 1) {
67-
setStoreValue([createDefaultRule(columns)])
68-
} else {
69-
setStoreValue(rules.filter((r) => r.id !== id))
70-
}
66+
setStoreValue(rules.filter((r) => r.id !== id))
7167
},
72-
[isReadOnly, rules, columns, setStoreValue]
68+
[isReadOnly, rules, setStoreValue]
7369
)
7470

7571
const updateRule = useCallback(
@@ -88,6 +84,20 @@ export function SortBuilder({
8884
[isReadOnly, rules, setStoreValue]
8985
)
9086

87+
if (rules.length === 0) {
88+
if (isReadOnly) return null
89+
return (
90+
<Button
91+
variant='ghost'
92+
onClick={addRule}
93+
className='h-7 w-full justify-start gap-1.5 border border-[var(--border-1)] border-dashed text-[var(--text-muted)] text-small'
94+
>
95+
<Plus className='size-[14px]' />
96+
Add sort
97+
</Button>
98+
)
99+
}
100+
91101
return (
92102
<div className='space-y-2'>
93103
{rules.map((rule, index) => (

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export function useSelectorSetup(
3131
const params = useParams()
3232
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
3333
const workflowId = (params?.workflowId as string) || activeWorkflowId || ''
34+
const workspaceId = (params?.workspaceId as string) || ''
3435

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

@@ -63,6 +64,7 @@ export function useSelectorSetup(
6364
const selectorContext = useMemo<SelectorContext>(() => {
6465
const context: SelectorContext = {
6566
workflowId,
67+
workspaceId: workspaceId || undefined,
6668
mimeType: subBlock.mimeType,
6769
}
6870

@@ -87,6 +89,7 @@ export function useSelectorSetup(
8789
resolvedDependencyValues,
8890
canonicalIndex,
8991
workflowId,
92+
workspaceId,
9093
subBlock.mimeType,
9194
impersonateUserEmail,
9295
])

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -929,6 +929,7 @@ function SubBlockComponent({
929929
case 'file-selector':
930930
case 'sheet-selector':
931931
case 'project-selector':
932+
case 'column-selector':
932933
return (
933934
<SelectorInput
934935
blockId={blockId}

apps/sim/blocks/blocks.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,7 @@ describe.concurrent('Blocks Module', () => {
580580
'text',
581581
'router-input',
582582
'table-selector',
583+
'column-selector',
583584
'filter-builder',
584585
'sort-builder',
585586
'skill-input',

apps/sim/blocks/blocks/table.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ interface TableBlockParams {
5858
sortBuilder?: unknown
5959
bulkFilterMode?: string
6060
bulkFilterBuilder?: unknown
61+
conflictColumn?: string
6162
}
6263

6364
/** Normalized params after parsing, ready for tool request body */
@@ -70,6 +71,7 @@ interface ParsedParams {
7071
sort?: unknown
7172
limit?: number
7273
offset?: number
74+
conflictTarget?: string
7375
}
7476

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

8790
batch_insert_rows: (params) => ({
@@ -275,6 +278,30 @@ Return ONLY the data JSON:`,
275278
},
276279
},
277280

281+
// Upsert - which unique column to match on (required when 2+ unique columns)
282+
// Basic: pick a unique column. Advanced: enter the column id directly.
283+
{
284+
id: 'conflictColumnSelector',
285+
title: 'Conflict Column',
286+
type: 'column-selector',
287+
canonicalParamId: 'conflictColumn',
288+
mode: 'basic',
289+
selectorKey: 'table.columns',
290+
placeholder: 'Select a unique column',
291+
dependsOn: ['tableSelector'],
292+
condition: { field: 'operation', value: 'upsert_row' },
293+
},
294+
{
295+
id: 'manualConflictColumn',
296+
title: 'Conflict Column',
297+
type: 'short-input',
298+
canonicalParamId: 'conflictColumn',
299+
mode: 'advanced',
300+
placeholder: 'Enter the column id',
301+
dependsOn: ['tableId'],
302+
condition: { field: 'operation', value: 'upsert_row' },
303+
},
304+
278305
// Batch Insert - multiple rows
279306
{
280307
id: 'rows',
@@ -631,6 +658,11 @@ Return ONLY the sort JSON:`,
631658
sortBuilder: { type: 'json', description: 'Visual sort builder conditions' },
632659
sort: { type: 'json', description: 'Sort order (JSON)' },
633660
offset: { type: 'number', description: 'Query result offset' },
661+
conflictColumn: {
662+
type: 'string',
663+
description:
664+
'Unique column to match on for upsert (required if the table has multiple unique columns)',
665+
},
634666
},
635667

636668
outputs: {
@@ -655,8 +687,8 @@ Return ONLY the sort JSON:`,
655687
},
656688
rowCount: {
657689
type: 'number',
658-
description: 'Number of rows returned',
659-
condition: { field: 'operation', value: 'query_rows' },
690+
description: 'Rows returned (query) or total rows in the table (get schema)',
691+
condition: { field: 'operation', value: ['query_rows', 'get_schema'] },
660692
},
661693
totalCount: {
662694
type: 'number',
@@ -695,7 +727,17 @@ Return ONLY the sort JSON:`,
695727
},
696728
columns: {
697729
type: 'array',
698-
description: 'Column definitions',
730+
description: 'Column definitions (each includes its stable id)',
731+
condition: { field: 'operation', value: 'get_schema' },
732+
},
733+
columnCount: {
734+
type: 'number',
735+
description: 'Number of columns',
736+
condition: { field: 'operation', value: 'get_schema' },
737+
},
738+
maxRows: {
739+
type: 'number',
740+
description: "Max rows per table for the workspace's plan",
699741
condition: { field: 'operation', value: 'get_schema' },
700742
},
701743
message: { type: 'string', description: 'Operation status message' },

apps/sim/hooks/queries/tables.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,18 @@ export function useTable(workspaceId: string | undefined, tableId: string | unde
267267
})
268268
}
269269

270+
/**
271+
* Shared table-detail query options so non-component callers (e.g. selector
272+
* providers) can `ensureQueryData` the same cache entry `useTable` populates.
273+
*/
274+
export function getTableDetailQueryOptions(workspaceId: string, tableId: string) {
275+
return {
276+
queryKey: tableKeys.detail(tableId),
277+
queryFn: ({ signal }: { signal?: AbortSignal }) => fetchTable(workspaceId, tableId, signal),
278+
staleTime: 30 * 1000,
279+
}
280+
}
281+
270282
export interface TableRunState {
271283
dispatches: ActiveDispatch[]
272284
runningCellCount: number

apps/sim/hooks/selectors/providers/sim/selectors.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { getColumnId } from '@/lib/table/column-keys'
12
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
3+
import { getTableDetailQueryOptions } from '@/hooks/queries/tables'
24
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
35
import { getFolderPath } from '@/hooks/queries/utils/folder-tree'
46
import { getWorkflowById, getWorkflows } from '@/hooks/queries/utils/workflow-cache'
@@ -85,4 +87,33 @@ export const simSelectors = {
8587
}
8688
},
8789
},
88-
} satisfies Record<Extract<SelectorKey, 'sim.workflows'>, SelectorDefinition>
90+
'table.columns': {
91+
key: 'table.columns',
92+
staleTime: SELECTOR_STALE,
93+
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
94+
...selectorKeys.all,
95+
'table.columns',
96+
context.workspaceId ?? 'none',
97+
context.tableId ?? 'none',
98+
search ?? '',
99+
],
100+
enabled: ({ context }) => Boolean(context.workspaceId && context.tableId),
101+
fetchList: async ({ context }: SelectorQueryArgs): Promise<SelectorOption[]> => {
102+
if (!context.workspaceId || !context.tableId) return []
103+
const table = await getQueryClient().ensureQueryData(
104+
getTableDetailQueryOptions(context.workspaceId, context.tableId)
105+
)
106+
return (table.schema?.columns ?? [])
107+
.filter((col) => col.unique)
108+
.map((col) => ({ id: getColumnId(col), label: col.name }))
109+
},
110+
fetchById: async ({ context, detailId }: SelectorQueryArgs): Promise<SelectorOption | null> => {
111+
if (!detailId || !context.workspaceId || !context.tableId) return null
112+
const table = await getQueryClient().ensureQueryData(
113+
getTableDetailQueryOptions(context.workspaceId, context.tableId)
114+
)
115+
const col = (table.schema?.columns ?? []).find((c) => getColumnId(c) === detailId)
116+
return col ? { id: getColumnId(col), label: col.name } : null
117+
},
118+
},
119+
} satisfies Record<Extract<SelectorKey, 'sim.workflows' | 'table.columns'>, SelectorDefinition>

apps/sim/hooks/selectors/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export type SelectorKey =
5656
| 'monday.boards'
5757
| 'monday.groups'
5858
| 'sim.workflows'
59+
| 'table.columns'
5960

6061
export interface SelectorOption {
6162
id: string
@@ -91,6 +92,7 @@ export interface SelectorContext {
9192
awsRegion?: string
9293
logGroupName?: string
9394
mcpServerId?: string
95+
tableId?: string
9496
}
9597

9698
export interface SelectorQueryArgs {

0 commit comments

Comments
 (0)