diff --git a/packages/nodes-base/nodes/Baserow/OperationDescription.ts b/packages/nodes-base/nodes/Baserow/OperationDescription.ts index a934138375b..562b7eaa355 100644 --- a/packages/nodes-base/nodes/Baserow/OperationDescription.ts +++ b/packages/nodes-base/nodes/Baserow/OperationDescription.ts @@ -716,7 +716,7 @@ export const operationFields: INodeProperties[] = [ default: 'UTC', displayOptions: { show: { - '../operator': [ + operator: [ ...MULTI_STEP_DATE_OPERATORS, ...DEPRECATED_TIMEZONE_NUMBER_OPERATORS, ...DEPRECATED_TIMEZONE_ONLY_OPERATORS, diff --git a/packages/nodes-base/nodes/Baserow/__tests__/OperationDescription.test.ts b/packages/nodes-base/nodes/Baserow/__tests__/OperationDescription.test.ts new file mode 100644 index 00000000000..cbe6297e627 --- /dev/null +++ b/packages/nodes-base/nodes/Baserow/__tests__/OperationDescription.test.ts @@ -0,0 +1,62 @@ +import { getNodeParameters, type INodeParameters } from 'n8n-workflow'; + +import { Baserow } from '../Baserow.node'; +import { MULTI_STEP_DATE_OPERATORS } from '../GenericFunctions'; + +// Regression test for CAT-3999 / NODE-5693 (GH #35788, #35783): +// the `timezone` filter child gated on a sibling via an unsupported `'../'` +// prefix, so loading a workflow with a populated Filters collection threw +// "Could not resolve parameter dependencies. Max iterations reached!". + +const properties = new Baserow().description.properties; + +/** Stored parameters for a Get Many with a single populated filter. */ +const withFilter = (filter: INodeParameters): INodeParameters => ({ + resource: 'row', + operation: 'getAll', + tableId: '1110755', + additionalOptions: { filters: { fields: [{ field: '9882393', ...filter }] } }, +}); + +const firstFilter = (values: INodeParameters, returnDefaults = true) => { + const resolved = getNodeParameters(properties, values, returnDefaults, false, null, null); + + return (resolved?.additionalOptions as { filters: { fields: INodeParameters[] } }).filters + .fields[0]; +}; + +describe('Baserow filter description', () => { + it('resolves a workflow with a populated filters collection', () => { + const values = withFilter({ + operator: 'equal', + value: "={{ $('Webhook Trigger').item.json.body.author_id || 'fallback' }}", + }); + + expect(() => firstFilter(values)).not.toThrow(); + }); + + it.each([...MULTI_STEP_DATE_OPERATORS])('shows Timezone for operator %s', (operator) => { + expect(firstFilter(withFilter({ operator, value: '2026-06-17' }))).toEqual({ + field: '9882393', + operator, + timezone: 'UTC', + value: '2026-06-17', + }); + }); + + it('hides Timezone for a non-date operator', () => { + expect(firstFilter(withFilter({ operator: 'equal', value: 'abc' }))).not.toHaveProperty( + 'timezone', + ); + }); + + it('preserves a stored non-default Timezone', () => { + const stored = withFilter({ + operator: 'date_is', + timezone: 'Europe/Berlin', + value: '2026-06-17', + }); + + expect(firstFilter(stored, false)).toHaveProperty('timezone', 'Europe/Berlin'); + }); +}); diff --git a/packages/workflow/src/node-helpers.ts b/packages/workflow/src/node-helpers.ts index 200064e7d27..3c44711b18e 100644 --- a/packages/workflow/src/node-helpers.ts +++ b/packages/workflow/src/node-helpers.ts @@ -626,8 +626,10 @@ function getParameterResolveOrder( // Parameter has dependencies for (const dependency of parameterDependencies[property.name]) { if (!resolvedParameters.includes(dependency)) { - if (dependency.charAt(0) === '/') { - // Assume that root level dependencies are resolved + if (!Object.hasOwn(parameterDependencies, dependency)) { + // Not a parameter of this level (e.g. a `/root.path`), so it cannot be + // resolved here. `displayParameter` looks the value up later and hides + // the parameter if it is missing. continue; } // Dependencies for that parameter are still missing so diff --git a/packages/workflow/test/node-helpers.parameter-dependencies.test.ts b/packages/workflow/test/node-helpers.parameter-dependencies.test.ts new file mode 100644 index 00000000000..7c9d2857dbf --- /dev/null +++ b/packages/workflow/test/node-helpers.parameter-dependencies.test.ts @@ -0,0 +1,143 @@ +import type { INodeParameters, INodeProperties } from '../src/interfaces'; +import { getNodeParameters } from '../src/node-helpers'; + +// Regression test for CAT-3999 / NODE-5693 (GH #35788, #35783): +// A `displayOptions` key that names no parameter at its own level used to make +// getNodeParameters throw "Could not resolve parameter dependencies. Max +// iterations reached!", aborting workflow load/activation/publish and leaving a +// blank canvas. Such a dependency can never be satisfied at that level, so the +// resolver now treats it as external and lets `displayParameter` decide +// visibility instead. + +/** A `fixedCollection` whose child gates on `dep`, mirroring the Baserow filters shape. */ +const withChildDependency = (dep: string): INodeProperties[] => [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + default: 'getAll', + options: [{ name: 'Get Many', value: 'getAll' }], + }, + { + displayName: 'Additional Options', + name: 'additionalOptions', + type: 'collection', + placeholder: 'Add option', + default: {}, + options: [ + { + displayName: 'Filters', + name: 'filters', + type: 'fixedCollection', + typeOptions: { multipleValues: true }, + default: {}, + options: [ + { + name: 'fields', + displayName: 'Field', + values: [ + { + displayName: 'Field Name or ID', + name: 'field', + type: 'string', + default: '', + }, + { + displayName: 'Filter', + name: 'operator', + type: 'options', + default: 'equal', + options: [ + { name: 'Is', value: 'equal' }, + { name: 'Is Date', value: 'date_is' }, + ], + }, + { + displayName: 'Timezone', + name: 'timezone', + type: 'string', + default: 'UTC', + displayOptions: { show: { [dep]: ['date_is'] } }, + }, + ], + }, + ], + }, + ], + }, +]; + +const populated: INodeParameters = { + operation: 'getAll', + additionalOptions: { + filters: { fields: [{ field: '9882393', operator: 'date_is' }] }, + }, +}; + +const resolve = (props: INodeProperties[], values: INodeParameters) => + getNodeParameters(props, values, true, false, null, null); + +describe('getNodeParameters dependency resolution', () => { + // Each of these names nothing at the `timezone` parameter's own level, so it + // is unsatisfiable there: `timezone` is hidden rather than throwing. + test.each([ + ['a relative-path reference', '../operator'], + ['a dot-notation reference', 'filters.fields.operator'], + ['a name only present at an enclosing level', 'operation'], + ['a name that exists nowhere', 'nonExistentParameter'], + ])('hides the parameter instead of throwing for %s', (_label, dep) => { + expect(resolve(withChildDependency(dep), populated)).toEqual(populated); + }); + + test('resolves a sibling reference and applies the default when displayed', () => { + expect(resolve(withChildDependency('operator'), populated)).toEqual({ + operation: 'getAll', + additionalOptions: { + filters: { fields: [{ field: '9882393', operator: 'date_is', timezone: 'UTC' }] }, + }, + }); + }); + + test('hides a sibling-gated parameter when the sibling does not match', () => { + const values: INodeParameters = { + operation: 'getAll', + additionalOptions: { filters: { fields: [{ field: '9882393', operator: 'equal' }] } }, + }; + + expect(resolve(withChildDependency('operator'), values)).toEqual(values); + }); + + test('resolves a root-level reference from inside a fixedCollection', () => { + expect(resolve(withChildDependency('/operation'), populated)).toEqual(populated); + }); + + test('leaves an empty fixedCollection untouched', () => { + expect(resolve(withChildDependency('../operator'), { additionalOptions: {} })).toEqual({ + operation: 'getAll', + additionalOptions: {}, + }); + }); + + test('still resolves parameters that depend on each other in a cycle', () => { + // Mutually dependent siblings are tolerated rather than reported as + // unresolvable; this guards that behaviour against resolver changes. + const cyclic: INodeProperties[] = [ + { + displayName: 'X', + name: 'x', + type: 'string', + default: '', + displayOptions: { show: { y: ['1'] } }, + }, + { + displayName: 'Y', + name: 'y', + type: 'string', + default: '', + displayOptions: { show: { x: ['1'] } }, + }, + ]; + + expect(resolve(cyclic, { x: '1', y: '1' })).toEqual({ x: '1', y: '1' }); + }); +});