Skip to content
Merged
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
22 changes: 19 additions & 3 deletions apps/sim/blocks/blocks/google_sheets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,9 +462,15 @@ Return ONLY the range string - no sheet name, no explanations, no quotes.`,
type: 'dropdown',
options: [
{ label: 'Contains', id: 'contains' },
{ label: 'Does Not Contain', id: 'not_contains' },
{ label: 'Exact Match', id: 'exact' },
{ label: 'Not Equal To', id: 'not_equals' },
{ label: 'Starts With', id: 'starts_with' },
{ label: 'Ends With', id: 'ends_with' },
{ label: 'Greater Than', id: 'gt' },
{ label: 'Greater Than or Equal', id: 'gte' },
{ label: 'Less Than', id: 'lt' },
{ label: 'Less Than or Equal', id: 'lte' },
],
condition: { field: 'operation', value: 'read' },
mode: 'advanced',
Expand Down Expand Up @@ -503,7 +509,7 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
{ label: 'User Entered (Parse formulas)', id: 'USER_ENTERED' },
{ label: "Raw (Don't parse formulas)", id: 'RAW' },
],
condition: { field: 'operation', value: ['write', 'batch_update'] },
condition: { field: 'operation', value: ['write', 'update', 'batch_update'] },
},
// Update-specific Fields
{
Expand Down Expand Up @@ -896,11 +902,15 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
type: 'string',
description: 'Destination spreadsheet ID for copy',
},
filterColumn: { type: 'string', description: 'Column header name to filter on' },
filterColumn: {
type: 'string',
description: 'Column header name to filter the read rows on (within the read range)',
},
filterValue: { type: 'string', description: 'Value to match against the filter column' },
filterMatchType: {
type: 'string',
description: 'Match type: contains, exact, starts_with, or ends_with',
description:
'Match type: contains, not_contains, exact, not_equals, starts_with, ends_with, gt, gte, lt, or lte',
},
},
outputs: {
Expand All @@ -920,6 +930,12 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`,
description: 'Cell values as 2D array',
condition: { field: 'operation', value: 'read' },
},
filter: {
type: 'json',
description:
'Filter summary (present only when a filter was requested): applied, column, matchType, columnFound, matchedRows, totalRows',
condition: { field: 'operation', value: 'read' },
},
// Write/Update/Append outputs
updatedRange: {
type: 'string',
Expand Down
6 changes: 3 additions & 3 deletions apps/sim/tools/google_sheets/append.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,14 @@ export const appendTool: ToolConfig<GoogleSheetsToolParams, GoogleSheetsAppendRe
const range = params.range || 'Sheet1'

const url = new URL(
`https://sheets.googleapis.com/v4/spreadsheets/${params.spreadsheetId}/values/${encodeURIComponent(range)}:append`
`https://sheets.googleapis.com/v4/spreadsheets/${params.spreadsheetId?.trim()}/values/${encodeURIComponent(range)}:append`
)

// Default to USER_ENTERED if not specified
const valueInputOption = params.valueInputOption || 'USER_ENTERED'
url.searchParams.append('valueInputOption', valueInputOption)

// Default to INSERT_ROWS if not specified
// Only send insertDataOption when the user provides it; the API defaults to OVERWRITE
if (params.insertDataOption) {
url.searchParams.append('insertDataOption', params.insertDataOption)
}
Expand Down Expand Up @@ -294,7 +294,7 @@ export const appendV2Tool: ToolConfig<GoogleSheetsV2ToolParams, GoogleSheetsV2Ap
}

const url = new URL(
`https://sheets.googleapis.com/v4/spreadsheets/${params.spreadsheetId}/values/${encodeURIComponent(sheetName)}:append`
`https://sheets.googleapis.com/v4/spreadsheets/${params.spreadsheetId?.trim()}/values/${encodeURIComponent(sheetName)}:append`
)

const valueInputOption = params.valueInputOption || 'USER_ENTERED'
Expand Down
228 changes: 228 additions & 0 deletions apps/sim/tools/google_sheets/filter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { filterSheetRows } from '@/tools/google_sheets/filter'

const VALUES: unknown[][] = [
['Name', 'Email', 'Status', 'Score'],
['Alice', 'alice@example.com', 'Active', '90'],
['Bob', 'bob@test.com', 'Closed', '40'],
['Carol', 'carol@example.com', 'Active', '7'],
]

describe('filterSheetRows', () => {
it('passes values through unchanged when no filter column is provided', () => {
const result = filterSheetRows(VALUES, {})
expect(result.applied).toBe(false)
expect(result.values).toBe(VALUES)
expect(result.totalRows).toBe(3)
})

it('passes through when filterValue is empty', () => {
const result = filterSheetRows(VALUES, { filterColumn: 'Status', filterValue: '' })
expect(result.applied).toBe(false)
expect(result.values).toBe(VALUES)
})

it('defaults to case-insensitive contains', () => {
const result = filterSheetRows(VALUES, { filterColumn: 'status', filterValue: 'active' })
expect(result.applied).toBe(true)
expect(result.columnFound).toBe(true)
expect(result.values).toEqual([VALUES[0], VALUES[1], VALUES[3]])
expect(result.matchedRows).toBe(2)
})

it('matches column names case-insensitively and trims whitespace', () => {
const result = filterSheetRows(VALUES, {
filterColumn: ' EMAIL ',
filterValue: 'example.com',
})
expect(result.columnFound).toBe(true)
expect(result.matchedRows).toBe(2)
})

it('supports exact and not_equals', () => {
expect(
filterSheetRows(VALUES, {
filterColumn: 'Status',
filterValue: 'Active',
filterMatchType: 'exact',
}).matchedRows
).toBe(2)
expect(
filterSheetRows(VALUES, {
filterColumn: 'Status',
filterValue: 'Active',
filterMatchType: 'not_equals',
}).matchedRows
).toBe(1)
})

it('supports starts_with, ends_with, and not_contains', () => {
expect(
filterSheetRows(VALUES, {
filterColumn: 'Email',
filterValue: 'bob',
filterMatchType: 'starts_with',
}).matchedRows
).toBe(1)
expect(
filterSheetRows(VALUES, {
filterColumn: 'Email',
filterValue: '.com',
filterMatchType: 'ends_with',
}).matchedRows
).toBe(3)
expect(
filterSheetRows(VALUES, {
filterColumn: 'Email',
filterValue: 'example.com',
filterMatchType: 'not_contains',
}).matchedRows
).toBe(1)
})

it('compares numerically for ordering operators (not substring)', () => {
const gt = filterSheetRows(VALUES, {
filterColumn: 'Score',
filterValue: '50',
filterMatchType: 'gt',
})
expect(gt.matchedRows).toBe(1)
expect(gt.values).toEqual([VALUES[0], VALUES[1]])

expect(
filterSheetRows(VALUES, {
filterColumn: 'Score',
filterValue: '40',
filterMatchType: 'gte',
}).matchedRows
).toBe(2)
expect(
filterSheetRows(VALUES, {
filterColumn: 'Score',
filterValue: '40',
filterMatchType: 'lt',
}).matchedRows
).toBe(1)
expect(
filterSheetRows(VALUES, {
filterColumn: 'Score',
filterValue: '40',
filterMatchType: 'lte',
}).matchedRows
).toBe(2)
})

it('orders negative numbers correctly', () => {
const temps: unknown[][] = [
['City', 'Temp'],
['A', '-5'],
['B', '0'],
['C', '-12'],
['D', '3'],
]
expect(
filterSheetRows(temps, { filterColumn: 'Temp', filterValue: '-5', filterMatchType: 'gte' })
.matchedRows
).toBe(3)
expect(
filterSheetRows(temps, { filterColumn: 'Temp', filterValue: '0', filterMatchType: 'lt' })
.matchedRows
).toBe(2)
})

it('excludes blank and non-numeric cells from numeric comparisons', () => {
const scores: unknown[][] = [
['Name', 'Score'],
['Alice', '90'],
['Bob', ''],
['Carol', 'N/A'],
['Dan', '60'],
]
const result = filterSheetRows(scores, {
filterColumn: 'Score',
filterValue: '50',
filterMatchType: 'gt',
})
expect(result.matchedRows).toBe(2)
expect(result.values).toEqual([scores[0], scores[1], scores[4]])
})

it('falls back to lexicographic ordering when values are not numeric (ISO dates)', () => {
const dated: unknown[][] = [
['Task', 'Due'],
['A', '2026-01-15'],
['B', '2026-03-01'],
['C', '2025-12-31'],
]
const result = filterSheetRows(dated, {
filterColumn: 'Due',
filterValue: '2026-01-01',
filterMatchType: 'gte',
})
expect(result.matchedRows).toBe(2)
})

it('reports columnFound=false and leaves values unchanged when the column is missing', () => {
const result = filterSheetRows(VALUES, {
filterColumn: 'Nonexistent',
filterValue: 'x',
})
expect(result.applied).toBe(false)
expect(result.columnFound).toBe(false)
expect(result.matchedRows).toBe(0)
expect(result.values).toBe(VALUES)
expect(result.totalRows).toBe(3)
})

it('handles a header-only sheet and reports the column as found when it exists', () => {
const headerOnly: unknown[][] = [['Name', 'Status']]
const result = filterSheetRows(headerOnly, { filterColumn: 'Status', filterValue: 'Active' })
expect(result.applied).toBe(false)
expect(result.columnFound).toBe(true)
expect(result.matchedRows).toBe(0)
expect(result.totalRows).toBe(0)
expect(result.values).toBe(headerOnly)
})

it('reports columnFound=false for a header-only sheet when the column is absent', () => {
const headerOnly: unknown[][] = [['Name', 'Status']]
const result = filterSheetRows(headerOnly, { filterColumn: 'Nonexistent', filterValue: 'x' })
expect(result.applied).toBe(false)
expect(result.columnFound).toBe(false)
expect(result.matchedRows).toBe(0)
expect(result.values).toBe(headerOnly)
})

it('reports columnFound=false for an empty values array', () => {
const empty: unknown[][] = []
const result = filterSheetRows(empty, { filterColumn: 'Status', filterValue: 'Active' })
expect(result.applied).toBe(false)
expect(result.columnFound).toBe(false)
expect(result.matchedRows).toBe(0)
expect(result.totalRows).toBe(0)
})

it('treats missing cells as empty strings', () => {
const sparse: unknown[][] = [['Name', 'Status'], ['Alice'], ['Bob', 'Active']]
const result = filterSheetRows(sparse, {
filterColumn: 'Status',
filterValue: 'Active',
filterMatchType: 'exact',
})
expect(result.matchedRows).toBe(1)
expect(result.values).toEqual([sparse[0], sparse[2]])
})

it('always retains the header row in filtered output', () => {
const result = filterSheetRows(VALUES, {
filterColumn: 'Status',
filterValue: 'no-match',
filterMatchType: 'exact',
})
expect(result.values).toEqual([VALUES[0]])
expect(result.matchedRows).toBe(0)
})
})
Loading
Loading