mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1391,6 +1391,7 @@ export interface ResourceMapperTypeOptionsBase {
|
||||
hint?: string;
|
||||
};
|
||||
showTypeConversionOptions?: boolean;
|
||||
allowEmptyValues?: boolean;
|
||||
}
|
||||
|
||||
// Enforce at least one of resourceMapperMethod or localResourceMapperMethod
|
||||
|
||||
Reference in New Issue
Block a user