From d924d82ee2862f398f66eb624815694893527d48 Mon Sep 17 00:00:00 2001 From: Jaakko Husso Date: Fri, 1 Aug 2025 09:32:05 +0300 Subject: [PATCH] fix(Google Sheets Node): Make it possible to set cell values empty on updates (#17224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Milorad FIlipović Co-authored-by: Nikhil Kuriakose --- .../src/components/ParameterInputList.vue | 1 + .../ResourceMapper/ResourceMapper.vue | 4 +- .../nodes/Google/Sheet/GoogleSheets.node.ts | 3 +- .../Sheet/test/v2/node/appendOrUpdate.test.ts | 148 ++++++++++++++++++ .../Google/Sheet/test/v2/node/update.test.ts | 147 +++++++++++++++++ .../actions/sheet/appendOrUpdate.operation.ts | 37 ++++- .../v2/actions/sheet/update.operation.ts | 37 ++++- .../Sheet/v2/actions/versionDescription.ts | 2 +- packages/workflow/src/interfaces.ts | 1 + 9 files changed, 375 insertions(+), 5 deletions(-) diff --git a/packages/frontend/editor-ui/src/components/ParameterInputList.vue b/packages/frontend/editor-ui/src/components/ParameterInputList.vue index 17b9965820..2d8e9f4623 100644 --- a/packages/frontend/editor-ui/src/components/ParameterInputList.vue +++ b/packages/frontend/editor-ui/src/components/ParameterInputList.vue @@ -607,6 +607,7 @@ const onCalloutDismiss = async (parameter: INodeProperties) => { :path="getPath(parameter.name)" :dependent-parameters-values="getDependentParametersValues(parameter)" :is-read-only="isReadOnly" + :allow-empty-strings="parameter.typeOptions?.resourceMapper?.allowEmptyValues" input-size="small" label-size="small" @value-changed="valueChanged" diff --git a/packages/frontend/editor-ui/src/components/ResourceMapper/ResourceMapper.vue b/packages/frontend/editor-ui/src/components/ResourceMapper/ResourceMapper.vue index 0292409fc2..0d3f1c434b 100644 --- a/packages/frontend/editor-ui/src/components/ResourceMapper/ResourceMapper.vue +++ b/packages/frontend/editor-ui/src/components/ResourceMapper/ResourceMapper.vue @@ -40,6 +40,7 @@ type Props = { teleported?: boolean; dependentParametersValues?: string | null; isReadOnly?: boolean; + allowEmptyStrings?: boolean; }; const nodeTypesStore = useNodeTypesStore(); @@ -50,6 +51,7 @@ const props = withDefaults(defineProps(), { teleported: true, dependentParametersValues: null, isReadOnly: false, + allowEmptyStrings: false, }); const { onDocumentVisible } = useDocumentVisibility(); @@ -436,8 +438,8 @@ function fieldValueChanged(updateInfo: IUpdateInformation): void { let newValue = null; if ( updateInfo.value !== undefined && - updateInfo.value !== '' && updateInfo.value !== null && + (props.allowEmptyStrings || updateInfo.value !== '') && isResourceMapperValue(updateInfo.value) ) { newValue = updateInfo.value; diff --git a/packages/nodes-base/nodes/Google/Sheet/GoogleSheets.node.ts b/packages/nodes-base/nodes/Google/Sheet/GoogleSheets.node.ts index 40425b11ea..2a791594e7 100644 --- a/packages/nodes-base/nodes/Google/Sheet/GoogleSheets.node.ts +++ b/packages/nodes-base/nodes/Google/Sheet/GoogleSheets.node.ts @@ -11,7 +11,7 @@ export class GoogleSheets extends VersionedNodeType { name: 'googleSheets', icon: 'file:googleSheets.svg', group: ['input', 'output'], - defaultVersion: 4.6, + defaultVersion: 4.7, subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', description: 'Read, update and write data to Google Sheets', }; @@ -27,6 +27,7 @@ export class GoogleSheets extends VersionedNodeType { 4.4: new GoogleSheetsV2(baseDescription), 4.5: new GoogleSheetsV2(baseDescription), 4.6: new GoogleSheetsV2(baseDescription), + 4.7: new GoogleSheetsV2(baseDescription), }; super(nodeVersions, baseDescription); diff --git a/packages/nodes-base/nodes/Google/Sheet/test/v2/node/appendOrUpdate.test.ts b/packages/nodes-base/nodes/Google/Sheet/test/v2/node/appendOrUpdate.test.ts index 552567609f..fb39624f1d 100644 --- a/packages/nodes-base/nodes/Google/Sheet/test/v2/node/appendOrUpdate.test.ts +++ b/packages/nodes-base/nodes/Google/Sheet/test/v2/node/appendOrUpdate.test.ts @@ -96,3 +96,151 @@ describe('Google Sheet - Append or Update', () => { }); }); }); + +describe('Google Sheet - Append or Update v4.6 vs v4.7 Behavior', () => { + let mockExecuteFunctions: MockProxy; + let mockGoogleSheet: MockProxy; + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('v4.6: empty string in UI gets filtered out, field not sent to backend', async () => { + mockExecuteFunctions = mock(); + mockGoogleSheet = mock(); + + mockExecuteFunctions.getNode + .mockReturnValueOnce(mock({ typeVersion: 4.6 })) + .mockReturnValueOnce(mock({ typeVersion: 4.6 })); + + mockExecuteFunctions.getInputData.mockReturnValueOnce([ + { + json: {}, + pairedItem: { item: 0, input: undefined }, + }, + ]); + + mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName: string) => { + const params: { [key: string]: any } = { + 'options.cellFormat': 'USER_ENTERED', + options: {}, + 'columns.mappingMode': 'defineBelow', + 'columns.schema': [], + 'columns.matchingColumns': ['id'], + 'columns.value': { + id: 1, + name: 'John', + // email field is NOT present here because user typed '' in UI + // and v4.6 frontend filtered it out (allowEmptyValues: false) + }, + }; + return params[parameterName]; + }); + + mockGoogleSheet.getData.mockResolvedValueOnce([ + ['id', 'name', 'email'], + ['1', 'Old Name', 'old@email.com'], + ]); + + mockGoogleSheet.getColumnValues.mockResolvedValueOnce(['1']); + mockGoogleSheet.updateRows.mockResolvedValueOnce([]); + + mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({ + updateData: [], + appendData: [ + { + id: 1, + name: 'John', + // email is not included, so it keeps old value + }, + ], + }); + + mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]); + mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]); + + await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234'); + + // v4.6: Only fields with non-empty values are sent to prepareDataForUpdateOrUpsert + expect(mockGoogleSheet.prepareDataForUpdateOrUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + inputData: [ + { + id: 1, + name: 'John', + // email is NOT in the inputData, so cell keeps old value + }, + ], + }), + ); + }); + + it('v4.7: empty string in UI is preserved and sent to backend to clear cell', async () => { + mockExecuteFunctions = mock(); + mockGoogleSheet = mock(); + + mockExecuteFunctions.getNode + .mockReturnValueOnce(mock({ typeVersion: 4.7 })) + .mockReturnValueOnce(mock({ typeVersion: 4.7 })); + + mockExecuteFunctions.getInputData.mockReturnValueOnce([ + { + json: {}, + pairedItem: { item: 0, input: undefined }, + }, + ]); + + mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName: string) => { + const params: { [key: string]: any } = { + 'options.cellFormat': 'USER_ENTERED', + options: {}, + 'columns.mappingMode': 'defineBelow', + 'columns.schema': [], + 'columns.matchingColumns': ['id'], + 'columns.value': { + id: 1, + name: 'John', + email: '', // Empty string is preserved in v4.7 (allowEmptyValues: true) + }, + }; + return params[parameterName]; + }); + + mockGoogleSheet.getData.mockResolvedValueOnce([ + ['id', 'name', 'email'], + ['1', 'Old Name', 'old@email.com'], + ]); + + mockGoogleSheet.getColumnValues.mockResolvedValueOnce(['1']); + mockGoogleSheet.updateRows.mockResolvedValueOnce([]); + + mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({ + updateData: [], + appendData: [ + { + id: 1, + name: 'John', + email: '', // Empty string will clear the cell + }, + ], + }); + + mockGoogleSheet.appendEmptyRowsOrColumns.mockResolvedValueOnce([]); + mockGoogleSheet.appendSheetData.mockResolvedValueOnce([]); + + await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1', '1234'); + + // v4.7: Empty strings are preserved and sent to prepareDataForUpdateOrUpsert + expect(mockGoogleSheet.prepareDataForUpdateOrUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + inputData: [ + { + id: 1, + name: 'John', + email: '', // Empty string is preserved and will clear the cell + }, + ], + }), + ); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Sheet/test/v2/node/update.test.ts b/packages/nodes-base/nodes/Google/Sheet/test/v2/node/update.test.ts index 77d249f609..098a48d541 100644 --- a/packages/nodes-base/nodes/Google/Sheet/test/v2/node/update.test.ts +++ b/packages/nodes-base/nodes/Google/Sheet/test/v2/node/update.test.ts @@ -319,3 +319,150 @@ describe('Google Sheet - Update 4.6', () => { ); }); }); + +describe('Google Sheet - Update v4.6 vs v4.7 Behavior', () => { + let mockExecuteFunctions: MockProxy; + let mockGoogleSheet: MockProxy; + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('v4.6: empty string in UI gets filtered out, field not sent to backend', async () => { + mockExecuteFunctions = mock(); + mockGoogleSheet = mock(); + mockExecuteFunctions.getNode.mockReturnValueOnce(mock({ typeVersion: 4.6 })); + mockGoogleSheet.batchUpdate.mockResolvedValueOnce([]); + + mockExecuteFunctions.getInputData.mockReturnValueOnce([ + { + json: {}, + pairedItem: { item: 0, input: undefined }, + }, + ]); + + mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName: string) => { + const params: { [key: string]: string | object } = { + options: {}, + 'options.cellFormat': 'USER_ENTERED', + 'columns.matchingColumns': ['id'], + 'columns.mappingMode': 'defineBelow', + 'columns.value': { + id: 1, + name: 'John', + // email field is NOT present here because user typed '' in UI + // and v4.6 frontend filtered it out (allowEmptyStrings: false) + }, + }; + return params[parameterName]; + }); + + mockGoogleSheet.getData.mockResolvedValueOnce([ + ['id', 'name', 'email'], + ['1', 'Old Name', 'old@email.com'], + ]); + + mockGoogleSheet.getColumnValues.mockResolvedValueOnce(['1']); + + mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({ + updateData: [ + { + range: 'Sheet1!B2', + values: [['John']], + }, + // No update for email column - it keeps its old value + ], + appendData: [], + }); + + await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1'); + + // v4.6: Only name field is updated, email is not included in the update + expect(mockGoogleSheet.prepareDataForUpdateOrUpsert).toHaveBeenCalledWith({ + inputData: [ + { + id: 1, + name: 'John', + // email is NOT in the inputData, so cell keeps old value + }, + ], + indexKey: 'id', + range: 'Sheet1!A:Z', + keyRowIndex: 0, + dataStartRowIndex: 1, + valueRenderMode: 'UNFORMATTED_VALUE', + columnNamesList: [['id', 'name', 'email']], + columnValuesList: ['1'], + }); + }); + + it('v4.7: empty string in UI is preserved and sent to backend to clear cell', async () => { + mockExecuteFunctions = mock(); + mockGoogleSheet = mock(); + mockExecuteFunctions.getNode.mockReturnValueOnce(mock({ typeVersion: 4.7 })); + mockGoogleSheet.batchUpdate.mockResolvedValueOnce([]); + + mockExecuteFunctions.getInputData.mockReturnValueOnce([ + { + json: {}, + pairedItem: { item: 0, input: undefined }, + }, + ]); + + mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName: string) => { + const params: { [key: string]: string | object } = { + options: {}, + 'options.cellFormat': 'USER_ENTERED', + 'columns.matchingColumns': ['id'], + 'columns.mappingMode': 'defineBelow', + 'columns.value': { + id: 1, + name: 'John', + email: '', // Empty string is preserved in v4.7 (allowEmptyStrings: true) + }, + }; + return params[parameterName]; + }); + + mockGoogleSheet.getData.mockResolvedValueOnce([ + ['id', 'name', 'email'], + ['1', 'Old Name', 'old@email.com'], + ]); + + mockGoogleSheet.getColumnValues.mockResolvedValueOnce(['1']); + + mockGoogleSheet.prepareDataForUpdateOrUpsert.mockResolvedValueOnce({ + updateData: [ + { + range: 'Sheet1!B2', + values: [['John']], + }, + { + range: 'Sheet1!C2', + values: [['']], + }, + ], + appendData: [], + }); + + await execute.call(mockExecuteFunctions, mockGoogleSheet, 'Sheet1'); + + // v4.7: Both name and email fields are updated, email is cleared with empty string + expect(mockGoogleSheet.prepareDataForUpdateOrUpsert).toHaveBeenCalledWith({ + inputData: [ + { + id: 1, + name: 'John', + email: '', // Empty string is preserved and will clear the cell + }, + ], + indexKey: 'id', + range: 'Sheet1!A:Z', + keyRowIndex: 0, + dataStartRowIndex: 1, + valueRenderMode: 'UNFORMATTED_VALUE', + columnNamesList: [['id', 'name', 'email']], + columnValuesList: ['1'], + }); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/appendOrUpdate.operation.ts b/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/appendOrUpdate.operation.ts index 4e94d54f4d..fcb9fd6d96 100644 --- a/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/appendOrUpdate.operation.ts +++ b/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/appendOrUpdate.operation.ts @@ -182,13 +182,48 @@ export const description: SheetProperties = [ }, addAllFields: true, multiKeyMatch: false, + allowEmptyValues: true, }, }, displayOptions: { show: { resource: ['sheet'], operation: ['appendOrUpdate'], - '@version': [{ _cnd: { gte: 4 } }], + '@version': [{ _cnd: { gte: 4.7 } }], + }, + hide: { + ...untilSheetSelected, + }, + }, + }, + { + displayName: 'Columns', + name: 'columns', + type: 'resourceMapper', + noDataExpression: true, + default: { + mappingMode: 'defineBelow', + value: null, + }, + required: true, + typeOptions: { + loadOptionsDependsOn: ['sheetName.value'], + resourceMapper: { + resourceMapperMethod: 'getMappingColumns', + mode: 'upsert', + fieldWords: { + singular: 'column', + plural: 'columns', + }, + addAllFields: true, + multiKeyMatch: false, + }, + }, + displayOptions: { + show: { + resource: ['sheet'], + operation: ['appendOrUpdate'], + '@version': [{ _cnd: { between: { from: 4, to: 4.6 } } }], }, hide: { ...untilSheetSelected, diff --git a/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/update.operation.ts b/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/update.operation.ts index 6e6b1c507b..c2928027d6 100644 --- a/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/update.operation.ts +++ b/packages/nodes-base/nodes/Google/Sheet/v2/actions/sheet/update.operation.ts @@ -168,13 +168,48 @@ export const description: SheetProperties = [ }, addAllFields: true, multiKeyMatch: false, + allowEmptyValues: true, }, }, displayOptions: { show: { resource: ['sheet'], operation: ['update'], - '@version': [{ _cnd: { gte: 4 } }], + '@version': [{ _cnd: { gte: 4.7 } }], + }, + hide: { + ...untilSheetSelected, + }, + }, + }, + { + displayName: 'Columns', + name: 'columns', + type: 'resourceMapper', + noDataExpression: true, + default: { + mappingMode: 'defineBelow', + value: null, + }, + required: true, + typeOptions: { + loadOptionsDependsOn: ['sheetName.value'], + resourceMapper: { + resourceMapperMethod: 'getMappingColumns', + mode: 'update', + fieldWords: { + singular: 'column', + plural: 'columns', + }, + addAllFields: true, + multiKeyMatch: false, + }, + }, + displayOptions: { + show: { + resource: ['sheet'], + operation: ['update'], + '@version': [{ _cnd: { between: { from: 4, to: 4.6 } } }], }, hide: { ...untilSheetSelected, diff --git a/packages/nodes-base/nodes/Google/Sheet/v2/actions/versionDescription.ts b/packages/nodes-base/nodes/Google/Sheet/v2/actions/versionDescription.ts index 02ae0ae13e..99d594cf12 100644 --- a/packages/nodes-base/nodes/Google/Sheet/v2/actions/versionDescription.ts +++ b/packages/nodes-base/nodes/Google/Sheet/v2/actions/versionDescription.ts @@ -28,7 +28,7 @@ export const versionDescription: INodeTypeDescription = { name: 'googleSheets', icon: 'file:googleSheets.svg', group: ['input', 'output'], - version: [3, 4, 4.1, 4.2, 4.3, 4.4, 4.5, 4.6], + version: [3, 4, 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7], subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', description: 'Read, update and write data to Google Sheets', defaults: { diff --git a/packages/workflow/src/interfaces.ts b/packages/workflow/src/interfaces.ts index a0eef7e6a7..2f9d510636 100644 --- a/packages/workflow/src/interfaces.ts +++ b/packages/workflow/src/interfaces.ts @@ -1391,6 +1391,7 @@ export interface ResourceMapperTypeOptionsBase { hint?: string; }; showTypeConversionOptions?: boolean; + allowEmptyValues?: boolean; } // Enforce at least one of resourceMapperMethod or localResourceMapperMethod