2
0

(sheets) Add rows filtering to update multiple rows at the same time

This commit is contained in:
Baptiste Arnaud
2023-05-04 14:00:50 -04:00
parent 035dded654
commit 55db360200
6 changed files with 168 additions and 91 deletions

View File

@ -214,21 +214,61 @@ const ActionOptions = ({
)
case GoogleSheetsAction.UPDATE_ROW:
return (
<Stack>
<Text>Row to select</Text>
<CellWithValueStack
columns={sheet?.columns ?? []}
item={options.referenceCell ?? { id: 'reference' }}
onItemChange={handleReferenceCellChange}
/>
<Text>Cells to update</Text>
<TableList<Cell>
initialItems={options.cellsToUpsert}
onItemsChange={handleUpsertColumnsChange}
Item={UpdatingCellItem}
addLabel="Add a value"
/>
</Stack>
<Accordion allowMultiple>
{options.referenceCell && (
<AccordionItem>
<AccordionButton>
<Text w="full" textAlign="left">
Row to update
</Text>
<AccordionIcon />
</AccordionButton>
<AccordionPanel pt="4">
<CellWithValueStack
columns={sheet?.columns ?? []}
item={options.referenceCell ?? { id: 'reference' }}
onItemChange={handleReferenceCellChange}
/>
</AccordionPanel>
</AccordionItem>
)}
{!options.referenceCell && (
<AccordionItem>
<AccordionButton>
<Text w="full" textAlign="left">
Row(s) to update
</Text>
<AccordionIcon />
</AccordionButton>
<AccordionPanel pt="4">
<RowsFilterTableList
columns={sheet?.columns ?? []}
filter={options.filter}
onFilterChange={handleFilterChange}
/>
</AccordionPanel>
</AccordionItem>
)}
<AccordionItem>
<AccordionButton>
<Text w="full" textAlign="left">
Cells to update
</Text>
<AccordionIcon />
</AccordionButton>
<AccordionPanel pt="4">
<TableList<Cell>
initialItems={options.cellsToUpsert}
onItemsChange={handleUpsertColumnsChange}
Item={UpdatingCellItem}
addLabel="Add a value"
/>
</AccordionPanel>
</AccordionItem>
</Accordion>
)
case GoogleSheetsAction.GET:
return (

View File

@ -58,12 +58,16 @@ test.describe.parallel('Google sheets integration', () => {
await page.click('text=Select an operation')
await page.click('text=Update a row')
await page.click('text=Add a value')
await page.getByRole('button', { name: 'Row(s) to update' }).click()
await page.getByRole('button', { name: 'Add filter rule' }).click()
await page.click('text=Select a column')
await page.click('button >> text="Email"')
await page.getByRole('button', { name: 'Select an operator' }).click()
await page.getByRole('menuitem', { name: 'Equal to' }).click()
await page.click('[aria-label="Insert a variable"]')
await page.click('button >> text="Email" >> nth=1')
await page.getByRole('button', { name: 'Cells to update' }).click()
await page.click('text=Add a value')
await page.click('text=Select a column')
await page.click('text=Last name')
@ -82,7 +86,7 @@ test.describe.parallel('Google sheets integration', () => {
.locator('input[placeholder="Type your email..."]')
.press('Enter')
await expect(
page.getByText('Succesfully updated row in CRM > Sheet1').nth(0)
page.getByText('Succesfully updated matching rows').nth(0)
).toBeVisible()
})

View File

@ -2,17 +2,15 @@ import {
SessionState,
GoogleSheetsGetOptions,
VariableWithValue,
ComparisonOperators,
LogicalOperator,
ReplyLog,
} from '@typebot.io/schemas'
import { isNotEmpty, byId, isDefined } from '@typebot.io/lib'
import type { GoogleSpreadsheetRow } from 'google-spreadsheet'
import { isNotEmpty, byId } from '@typebot.io/lib'
import { getAuthenticatedGoogleDoc } from './helpers/getAuthenticatedGoogleDoc'
import { ExecuteIntegrationResponse } from '@/features/chat/types'
import { saveErrorLog } from '@/features/logs/saveErrorLog'
import { updateVariables } from '@/features/variables/updateVariables'
import { deepParseVariables } from '@/features/variables/deepParseVariable'
import { matchFilter } from './helpers/matchFilter'
export const getRow = async (
state: SessionState,
@ -101,56 +99,3 @@ export const getRow = async (
}
return { outgoingEdgeId, logs: log ? [log] : undefined }
}
const matchFilter = (
row: GoogleSpreadsheetRow,
filter: NonNullable<GoogleSheetsGetOptions['filter']>
) => {
return filter.logicalOperator === LogicalOperator.AND
? filter.comparisons.every(
(comparison) =>
comparison.column &&
matchComparison(
row[comparison.column],
comparison.comparisonOperator,
comparison.value
)
)
: filter.comparisons.some(
(comparison) =>
comparison.column &&
matchComparison(
row[comparison.column],
comparison.comparisonOperator,
comparison.value
)
)
}
const matchComparison = (
inputValue?: string,
comparisonOperator?: ComparisonOperators,
value?: string
) => {
if (!inputValue || !comparisonOperator || !value) return false
switch (comparisonOperator) {
case ComparisonOperators.CONTAINS: {
return inputValue.toLowerCase().includes(value.toLowerCase())
}
case ComparisonOperators.EQUAL: {
return inputValue === value
}
case ComparisonOperators.NOT_EQUAL: {
return inputValue !== value
}
case ComparisonOperators.GREATER: {
return parseFloat(inputValue) > parseFloat(value)
}
case ComparisonOperators.LESS: {
return parseFloat(inputValue) < parseFloat(value)
}
case ComparisonOperators.IS_SET: {
return isDefined(inputValue) && inputValue.length > 0
}
}
}

View File

@ -0,0 +1,72 @@
import { isDefined } from '@typebot.io/lib'
import {
GoogleSheetsGetOptions,
LogicalOperator,
ComparisonOperators,
} from '@typebot.io/schemas'
import { GoogleSpreadsheetRow } from 'google-spreadsheet'
export const matchFilter = (
row: GoogleSpreadsheetRow,
filter: NonNullable<GoogleSheetsGetOptions['filter']>
) => {
return filter.logicalOperator === LogicalOperator.AND
? filter.comparisons.every(
(comparison) =>
comparison.column &&
matchComparison(
row[comparison.column],
comparison.comparisonOperator,
comparison.value
)
)
: filter.comparisons.some(
(comparison) =>
comparison.column &&
matchComparison(
row[comparison.column],
comparison.comparisonOperator,
comparison.value
)
)
}
const matchComparison = (
inputValue?: string,
comparisonOperator?: ComparisonOperators,
value?: string
): boolean => {
if (!inputValue || !comparisonOperator || !value) return false
switch (comparisonOperator) {
case ComparisonOperators.CONTAINS: {
return inputValue.toLowerCase().includes(value.toLowerCase())
}
case ComparisonOperators.EQUAL: {
return inputValue === value
}
case ComparisonOperators.NOT_EQUAL: {
return inputValue !== value
}
case ComparisonOperators.GREATER: {
return parseFloat(inputValue) > parseFloat(value)
}
case ComparisonOperators.LESS: {
return parseFloat(inputValue) < parseFloat(value)
}
case ComparisonOperators.IS_SET: {
return isDefined(inputValue) && inputValue.length > 0
}
case ComparisonOperators.IS_EMPTY: {
return !isDefined(inputValue) || inputValue.length === 0
}
case ComparisonOperators.STARTS_WITH: {
return inputValue.toLowerCase().startsWith(value.toLowerCase())
}
case ComparisonOperators.ENDS_WITH: {
return inputValue.toLowerCase().endsWith(value.toLowerCase())
}
case ComparisonOperators.NOT_CONTAINS: {
return !inputValue.toLowerCase().includes(value.toLowerCase())
}
}
}

View File

@ -10,6 +10,7 @@ import { ExecuteIntegrationResponse } from '@/features/chat/types'
import { saveErrorLog } from '@/features/logs/saveErrorLog'
import { saveSuccessLog } from '@/features/logs/saveSuccessLog'
import { TRPCError } from '@trpc/server'
import { matchFilter } from './helpers/matchFilter'
export const updateRow = async (
{ result, typebot: { variables } }: SessionState,
@ -18,8 +19,9 @@ export const updateRow = async (
options,
}: { outgoingEdgeId?: string; options: GoogleSheetsUpdateRowOptions }
): Promise<ExecuteIntegrationResponse> => {
const { sheetId, referenceCell } = deepParseVariables(variables)(options)
if (!options.cellsToUpsert || !sheetId || !referenceCell)
const { sheetId, referenceCell, filter } =
deepParseVariables(variables)(options)
if (!options.cellsToUpsert || !sheetId || (!referenceCell && !filter))
return { outgoingEdgeId }
let log: ReplyLog | undefined
@ -34,14 +36,16 @@ export const updateRow = async (
await doc.loadInfo()
const sheet = doc.sheetsById[sheetId]
const rows = await sheet.getRows()
const updatingRowIndex = rows.findIndex(
(row) => row[referenceCell.column as string] === referenceCell.value
const filteredRows = rows.filter((row) =>
referenceCell
? row[referenceCell.column as string] === referenceCell.value
: matchFilter(row, filter as NonNullable<typeof filter>)
)
if (updatingRowIndex === -1) {
if (filteredRows.length === 0) {
log = {
status: 'error',
description: `Could not find row to update`,
details: `Looked for row with ${referenceCell.column} equals to "${referenceCell.value}"`,
description: `Could not find any row that matches the filter`,
details: JSON.stringify(filter, null, 2),
}
result &&
(await saveErrorLog({
@ -51,19 +55,22 @@ export const updateRow = async (
}))
throw new TRPCError({
code: 'NOT_FOUND',
message: `Couldn't find row with ${referenceCell.column} that equals to "${referenceCell.value}"`,
message: `Could not find any row that matches the filter`,
})
}
for (const key in parsedValues) {
rows[updatingRowIndex][key] = parsedValues[key]
}
try {
await rows[updatingRowIndex].save()
for (const filteredRow of filteredRows) {
const rowIndex = rows.findIndex((row) => row.id === filteredRow.id)
for (const key in parsedValues) {
rows[rowIndex][key] = parsedValues[key]
}
await rows[rowIndex].save()
}
log = log = {
status: 'success',
description: `Succesfully updated row in ${doc.title} > ${sheet.title}`,
description: `Succesfully updated matching rows`,
}
result &&
(await saveSuccessLog({

View File

@ -38,8 +38,9 @@ const initialGoogleSheetsOptionsSchema = googleSheetsOptionsBaseSchema.merge(
const googleSheetsGetOptionsSchema = googleSheetsOptionsBaseSchema.merge(
z.object({
action: z.enum([GoogleSheetsAction.GET]),
// TODO: remove referenceCell once migrated to filtering
referenceCell: cellSchema.optional(),
referenceCell: cellSchema
.optional()
.describe('Deprecated. Use `filter` instead.'),
filter: z
.object({
comparisons: z.array(rowsFilterComparisonSchema),
@ -61,7 +62,15 @@ const googleSheetsUpdateRowOptionsSchema = googleSheetsOptionsBaseSchema.merge(
z.object({
action: z.enum([GoogleSheetsAction.UPDATE_ROW]),
cellsToUpsert: z.array(cellSchema),
referenceCell: cellSchema.optional(),
referenceCell: cellSchema
.optional()
.describe('Deprecated. Use `filter` instead.'),
filter: z
.object({
comparisons: z.array(rowsFilterComparisonSchema),
logicalOperator: z.nativeEnum(LogicalOperator),
})
.optional(),
})
)