Skip to content

Commit 766ad33

Browse files
improvement(tables): empty-state filter/sort builders + upsert conflict-column selection
1 parent 6423ab1 commit 766ad33

7 files changed

Lines changed: 71 additions & 33 deletions

File tree

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/blocks/blocks/table.ts

Lines changed: 17 additions & 0 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,16 @@ Return ONLY the data JSON:`,
275278
},
276279
},
277280

281+
// Upsert - which unique column to match on (defaults to the first unique column)
282+
{
283+
id: 'conflictColumn',
284+
title: 'Conflict Column',
285+
type: 'short-input',
286+
placeholder: 'Unique column to match on (defaults to the first unique column)',
287+
dependsOn: ['tableId'],
288+
condition: { field: 'operation', value: 'upsert_row' },
289+
},
290+
278291
// Batch Insert - multiple rows
279292
{
280293
id: 'rows',
@@ -631,6 +644,10 @@ Return ONLY the sort JSON:`,
631644
sortBuilder: { type: 'json', description: 'Visual sort builder conditions' },
632645
sort: { type: 'json', description: 'Sort order (JSON)' },
633646
offset: { type: 'number', description: 'Query result offset' },
647+
conflictColumn: {
648+
type: 'string',
649+
description: 'Unique column to match on for upsert (defaults to the first unique column)',
650+
},
634651
},
635652

636653
outputs: {

apps/sim/lib/table/query-builder/converters.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ export function filterRulesToFilter(rules: FilterRule[]): Filter | null {
2121
let currentGroup: Filter = {}
2222

2323
for (const rule of rules) {
24+
// Skip incomplete rows (no column selected) so a blank builder row never
25+
// serializes to a `{ '': ... }` predicate.
26+
if (!rule.column) continue
27+
2428
const isOr = rule.logicalOperator === 'or'
2529
const ruleValue = toRuleValue(rule.operator, rule.value)
2630

apps/sim/lib/table/rows/service.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -492,12 +492,9 @@ export async function upsertRow(
492492
)
493493
}
494494
targetColumnKey = getColumnId(col)
495-
} else if (uniqueColumns.length === 1) {
496-
targetColumnKey = getColumnId(uniqueColumns[0])
497495
} else {
498-
throw new Error(
499-
`Table has multiple unique columns (${uniqueColumns.map((c) => c.name).join(', ')}). Specify conflictTarget to indicate which column to match on.`
500-
)
496+
// No conflict target specified — default to the first unique column.
497+
targetColumnKey = getColumnId(uniqueColumns[0])
501498
}
502499

503500
// Validate row data

apps/sim/tools/table/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export interface TableListParams {
2323
export interface TableRowInsertParams {
2424
tableId: string
2525
data: RowData
26+
/** Unique column to match on for upsert; ignored by plain insert */
27+
conflictTarget?: string
2628
_context?: WorkflowToolExecutionContext
2729
}
2830

apps/sim/tools/table/upsert_row.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ export const tableUpsertRowTool: ToolConfig<TableRowInsertParams, TableUpsertRes
2828
description: 'Row data to insert or update',
2929
visibility: 'user-or-llm',
3030
},
31+
conflictTarget: {
32+
type: 'string',
33+
required: false,
34+
description:
35+
'Unique column to match on. Required only when the table has more than one unique column.',
36+
visibility: 'user-only',
37+
},
3138
},
3239

3340
request: {
@@ -45,6 +52,7 @@ export const tableUpsertRowTool: ToolConfig<TableRowInsertParams, TableUpsertRes
4552
return {
4653
data: params.data,
4754
workspaceId,
55+
...(params.conflictTarget ? { conflictTarget: params.conflictTarget } : {}),
4856
}
4957
},
5058
},

0 commit comments

Comments
 (0)