fix(Google Sheets Node): Make it possible to set cell values empty on updates (#17224)

Co-authored-by: Milorad FIlipović <milorad@n8n.io>
Co-authored-by: Nikhil Kuriakose <nikhil.kuriakose@n8n.io>
This commit is contained in:
Jaakko Husso
2025-08-01 09:32:05 +03:00
committed by GitHub
parent 22f505de69
commit d924d82ee2
9 changed files with 375 additions and 5 deletions

View File

@@ -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);

View File

@@ -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<IExecuteFunctions>;
let mockGoogleSheet: MockProxy<GoogleSheet>;
afterEach(() => {
jest.resetAllMocks();
});
it('v4.6: empty string in UI gets filtered out, field not sent to backend', async () => {
mockExecuteFunctions = mock<IExecuteFunctions>();
mockGoogleSheet = mock<GoogleSheet>();
mockExecuteFunctions.getNode
.mockReturnValueOnce(mock<INode>({ typeVersion: 4.6 }))
.mockReturnValueOnce(mock<INode>({ 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<IExecuteFunctions>();
mockGoogleSheet = mock<GoogleSheet>();
mockExecuteFunctions.getNode
.mockReturnValueOnce(mock<INode>({ typeVersion: 4.7 }))
.mockReturnValueOnce(mock<INode>({ 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
},
],
}),
);
});
});

View File

@@ -319,3 +319,150 @@ describe('Google Sheet - Update 4.6', () => {
);
});
});
describe('Google Sheet - Update v4.6 vs v4.7 Behavior', () => {
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
let mockGoogleSheet: MockProxy<GoogleSheet>;
afterEach(() => {
jest.resetAllMocks();
});
it('v4.6: empty string in UI gets filtered out, field not sent to backend', async () => {
mockExecuteFunctions = mock<IExecuteFunctions>();
mockGoogleSheet = mock<GoogleSheet>();
mockExecuteFunctions.getNode.mockReturnValueOnce(mock<INode>({ 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<IExecuteFunctions>();
mockGoogleSheet = mock<GoogleSheet>();
mockExecuteFunctions.getNode.mockReturnValueOnce(mock<INode>({ 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'],
});
});
});

View File

@@ -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,

View File

@@ -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,

View File

@@ -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: {