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

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

View File

@@ -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<Props>(), {
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;

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

View File

@@ -1391,6 +1391,7 @@ export interface ResourceMapperTypeOptionsBase {
hint?: string;
};
showTypeConversionOptions?: boolean;
allowEmptyValues?: boolean;
}
// Enforce at least one of resourceMapperMethod or localResourceMapperMethod