fix(core): Handle out-of-scope display option dependencies (#35845)

This commit is contained in:
Tomi Turtiainen
2026-08-07 16:28:04 +03:00
committed by GitHub
parent ae1252a7ad
commit 9241a72b9a
4 changed files with 210 additions and 3 deletions
@@ -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,
@@ -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');
});
});
+4 -2
View File
@@ -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
@@ -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' });
});
});