fix(ai-builder): Support 'disabledOptions' in node schemas (#28894)

This commit is contained in:
Jaakko Husso
2026-04-24 15:54:12 +00:00
committed by GitHub
parent 5dda3b2142
commit bc5e490f68
3 changed files with 349 additions and 19 deletions
@@ -269,6 +269,10 @@ export interface NodeProperty {
show?: Record<string, unknown[]>;
hide?: Record<string, unknown[]>;
};
disabledOptions?: {
show?: Record<string, unknown[]>;
hide?: Record<string, unknown[]>;
};
typeOptions?: Record<string, unknown>;
noDataExpression?: boolean;
modes?: Array<{
@@ -1078,6 +1082,75 @@ function generateFixedCollectionType(
return `{\n${INDENT.repeat(2)}${groups.join(`;\n${INDENT.repeat(2)}`)};\n${INDENT}}`;
}
/**
* Translate a property's disabledOptions into effective displayOptions that
* exclude the disabled states from the generated type. Returns
* { displayOptions, fullyDisabled } where fullyDisabled is true when every
* visible state is read-only.
*
* - For keys present in both displayOptions.show and disabledOptions.show, the
* disabled values are subtracted from the show list. If any key is left with
* no settable values, the property is fully disabled.
* - For keys in disabledOptions.show that aren't covered by displayOptions.show,
* they are merged into displayOptions.hide — "disabled" is equivalent to
* "unsettable" for schema purposes, so the field is excluded from those
* states.
*/
export function narrowDisplayOptionsByDisabled(prop: NodeProperty): {
displayOptions: NodeProperty['displayOptions'];
fullyDisabled: boolean;
} {
const { displayOptions, disabledOptions } = prop;
if (!disabledOptions?.show) {
return { displayOptions, fullyDisabled: false };
}
const currentShow = displayOptions?.show;
const narrowedShow: Record<string, unknown[]> = {};
const mergedHide: Record<string, unknown[]> = {};
for (const [key, values] of Object.entries(displayOptions?.hide ?? {})) {
mergedHide[key] = [...values];
}
if (currentShow) {
for (const [key, values] of Object.entries(currentShow)) {
const disabledValues = disabledOptions.show[key];
if (!disabledValues) {
narrowedShow[key] = values;
continue;
}
const remaining = values.filter(
(v) => !disabledValues.some((d) => JSON.stringify(d) === JSON.stringify(v)),
);
if (remaining.length === 0) {
return { displayOptions: undefined, fullyDisabled: true };
}
narrowedShow[key] = remaining;
}
}
for (const [key, values] of Object.entries(disabledOptions.show)) {
if (currentShow && key in currentShow) continue;
const existing = mergedHide[key] ?? [];
const seen = new Set(existing.map((v) => JSON.stringify(v)));
for (const v of values) {
if (!seen.has(JSON.stringify(v))) {
existing.push(v);
}
}
mergedHide[key] = existing;
}
const result: NonNullable<NodeProperty['displayOptions']> = {};
if (Object.keys(narrowedShow).length > 0) result.show = narrowedShow;
if (Object.keys(mergedHide).length > 0) result.hide = mergedHide;
return {
displayOptions: Object.keys(result).length > 0 ? result : undefined,
fullyDisabled: false,
};
}
/**
* Merge properties with the same name for collection/fixedCollection types.
* When multiple properties have the same name (e.g., multiple 'options' collections
@@ -1095,18 +1168,29 @@ function mergeCollectionProperties(properties: NodeProperty[]): NodeProperty[] {
continue;
}
if (seenProps.has(prop.name)) {
const existingProp = seenProps.get(prop.name)!;
// Narrow displayOptions so the emitted type reflects the actually-settable
// states: drop the property entirely if every visible state is read-only
// (e.g. the expression-prefilled sessionKey variant of the memory nodes,
// which lives only to render a disabled UI field in 'fromInput' mode).
const { displayOptions: narrowedDisplayOptions, fullyDisabled } =
narrowDisplayOptionsByDisabled(prop);
if (fullyDisabled) {
continue;
}
const normalizedProp: NodeProperty = { ...prop, displayOptions: narrowedDisplayOptions };
if (seenProps.has(normalizedProp.name)) {
const existingProp = seenProps.get(normalizedProp.name)!;
// For collection/fixedCollection types, merge nested options
if (
(prop.type === 'collection' || prop.type === 'fixedCollection') &&
prop.options &&
(normalizedProp.type === 'collection' || normalizedProp.type === 'fixedCollection') &&
normalizedProp.options &&
existingProp.options
) {
// Merge options, avoiding duplicates by name
const existingOptionNames = new Set(existingProp.options.map((o) => o.name));
for (const opt of prop.options) {
for (const opt of normalizedProp.options) {
if (!existingOptionNames.has(opt.name)) {
existingProp.options.push(opt);
}
@@ -1117,9 +1201,9 @@ function mergeCollectionProperties(properties: NodeProperty[]): NodeProperty[] {
}
// Create a shallow copy to avoid mutating the original when merging
seenProps.set(prop.name, {
...prop,
options: prop.options ? [...prop.options] : undefined,
seenProps.set(normalizedProp.name, {
...normalizedProp,
options: normalizedProp.options ? [...normalizedProp.options] : undefined,
});
}
@@ -1,4 +1,8 @@
import type { NodeProperty, NodeTypeDescription } from './generate-types';
import {
narrowDisplayOptionsByDisabled,
type NodeProperty,
type NodeTypeDescription,
} from './generate-types';
import {
generateConditionalSchemaLine,
generateSingleVersionSchemaFile,
@@ -8,6 +12,7 @@ import {
isPropertyOptional,
mapPropertyToZodSchema,
mergeDisplayOptions,
mergePropertiesByName,
extractDefaultsForDisplayOptions,
} from './generate-zod-schemas';
@@ -1348,6 +1353,229 @@ describe('mapPropertyToZodSchema with noDataExpression', () => {
});
});
describe('narrowDisplayOptionsByDisabled', () => {
it('returns inputs unchanged when the property has no disabledOptions', () => {
const prop: NodeProperty = {
name: 'sessionKey',
displayName: 'Session Key',
type: 'string',
default: '',
displayOptions: { show: { sessionIdType: ['customKey'] } },
};
const result = narrowDisplayOptionsByDisabled(prop);
expect(result.fullyDisabled).toBe(false);
expect(result.displayOptions).toEqual({ show: { sessionIdType: ['customKey'] } });
});
it('flags the property as fully disabled when all visible states are disabled', () => {
const prop: NodeProperty = {
name: 'sessionKey',
displayName: 'Session Key',
type: 'string',
default: '={{ $json.sessionId }}',
displayOptions: { show: { sessionIdType: ['fromInput'] } },
disabledOptions: { show: { sessionIdType: ['fromInput'] } },
};
const result = narrowDisplayOptionsByDisabled(prop);
expect(result.fullyDisabled).toBe(true);
expect(result.displayOptions).toBeUndefined();
});
it('removes only the disabled values and keeps the remaining visible states', () => {
const prop: NodeProperty = {
name: 'text',
displayName: 'Text',
type: 'string',
default: '',
displayOptions: { show: { promptType: ['auto', 'define', 'guardrails'] } },
disabledOptions: { show: { promptType: ['auto', 'guardrails'] } },
};
const result = narrowDisplayOptionsByDisabled(prop);
expect(result.fullyDisabled).toBe(false);
expect(result.displayOptions).toEqual({ show: { promptType: ['define'] } });
});
it('converts disabledOptions on keys absent from displayOptions.show into hide constraints', () => {
const prop: NodeProperty = {
name: 'field',
displayName: 'Field',
type: 'string',
default: '',
displayOptions: { show: { mode: ['a'] } },
disabledOptions: { show: { otherField: ['x'] } },
};
const result = narrowDisplayOptionsByDisabled(prop);
expect(result.fullyDisabled).toBe(false);
expect(result.displayOptions).toEqual({
show: { mode: ['a'] },
hide: { otherField: ['x'] },
});
});
it('converts disabledOptions into hide when the property has no displayOptions.show', () => {
const prop: NodeProperty = {
name: 'inputType',
displayName: 'Input Type',
type: 'options',
default: 'binary',
disabledOptions: { show: { 'options.batch': [true] } },
};
const result = narrowDisplayOptionsByDisabled(prop);
expect(result.fullyDisabled).toBe(false);
expect(result.displayOptions).toEqual({ hide: { 'options.batch': [true] } });
});
it('merges disabledOptions into an existing displayOptions.hide for the same key', () => {
const prop: NodeProperty = {
name: 'field',
displayName: 'Field',
type: 'string',
default: '',
displayOptions: { hide: { mode: ['x'] } },
disabledOptions: { show: { mode: ['y'] } },
};
const result = narrowDisplayOptionsByDisabled(prop);
expect(result.fullyDisabled).toBe(false);
expect(result.displayOptions).toEqual({ hide: { mode: ['x', 'y'] } });
});
it('does not mutate the original displayOptions.hide when merging', () => {
const originalHide = { mode: ['x'] };
const prop: NodeProperty = {
name: 'field',
displayName: 'Field',
type: 'string',
default: '',
displayOptions: { hide: originalHide },
disabledOptions: { show: { mode: ['y'] } },
};
narrowDisplayOptionsByDisabled(prop);
expect(originalHide).toEqual({ mode: ['x'] });
});
});
describe('mergePropertiesByName with disabledOptions', () => {
it('drops the fully-disabled sessionKey variant so only the editable one survives', () => {
const expressionSessionKey: NodeProperty = {
name: 'sessionKey',
displayName: 'Session Key From Previous Node',
type: 'string',
default: '={{ $json.sessionId }}',
displayOptions: { show: { sessionIdType: ['fromInput'] } },
disabledOptions: { show: { sessionIdType: ['fromInput'] } },
};
const editableSessionKey: NodeProperty = {
name: 'sessionKey',
displayName: 'Key',
type: 'string',
default: '',
displayOptions: { show: { sessionIdType: ['customKey'] } },
};
const merged = mergePropertiesByName([expressionSessionKey, editableSessionKey]);
const prop = merged.get('sessionKey');
expect(prop).toBeDefined();
expect(prop?.displayOptions).toEqual({ show: { sessionIdType: ['customKey'] } });
});
it('produces the same narrowed result regardless of property ordering', () => {
const expressionSessionKey: NodeProperty = {
name: 'sessionKey',
displayName: 'Session Key From Previous Node',
type: 'string',
default: '={{ $json.sessionId }}',
displayOptions: { show: { sessionIdType: ['fromInput'] } },
disabledOptions: { show: { sessionIdType: ['fromInput'] } },
};
const editableSessionKey: NodeProperty = {
name: 'sessionKey',
displayName: 'Key',
type: 'string',
default: '',
displayOptions: { show: { sessionIdType: ['customKey'] } },
};
const mergedA = mergePropertiesByName([expressionSessionKey, editableSessionKey]);
const mergedB = mergePropertiesByName([editableSessionKey, expressionSessionKey]);
expect(mergedA.get('sessionKey')?.displayOptions).toEqual({
show: { sessionIdType: ['customKey'] },
});
expect(mergedB.get('sessionKey')?.displayOptions).toEqual({
show: { sessionIdType: ['customKey'] },
});
});
it('drops a property entirely when every duplicate is fully disabled', () => {
const first: NodeProperty = {
name: 'lockedField',
displayName: 'Locked Field',
type: 'string',
default: 'A',
displayOptions: { show: { mode: ['a'] } },
disabledOptions: { show: { mode: ['a'] } },
};
const second: NodeProperty = {
name: 'lockedField',
displayName: 'Locked Field',
type: 'string',
default: 'B',
displayOptions: { show: { mode: ['b'] } },
disabledOptions: { show: { mode: ['b'] } },
};
const merged = mergePropertiesByName([first, second]);
expect(merged.has('lockedField')).toBe(false);
});
it('keeps the merged schema emitting only settable states when generating code', () => {
const expressionSessionKey: NodeProperty = {
name: 'sessionKey',
displayName: 'Session Key From Previous Node',
type: 'string',
default: '={{ $json.sessionId }}',
displayOptions: { show: { sessionIdType: ['fromInput'] } },
disabledOptions: { show: { sessionIdType: ['fromInput'] } },
};
const editableSessionKey: NodeProperty = {
name: 'sessionKey',
displayName: 'Key',
type: 'string',
default: '',
displayOptions: { show: { sessionIdType: ['customKey'] } },
};
const merged = mergePropertiesByName([expressionSessionKey, editableSessionKey]);
const line = generateConditionalSchemaLine(merged.get('sessionKey')!, [
{
name: 'sessionIdType',
displayName: 'Session ID',
type: 'options',
default: 'fromInput',
},
]);
expect(line).toContain('displayOptions: {"show":{"sessionIdType":["customKey"]}}');
expect(line).not.toMatch(/"show":\{[^}]*"fromInput"/);
});
});
describe('mapPropertyToZodSchema for fixedCollection with field-count constraints', () => {
const buildFilters = (typeOptions: NodeProperty['typeOptions']): NodeProperty => ({
name: 'filters',
@@ -24,6 +24,7 @@ import {
filterPropertiesForVersion,
buildDiscriminatorTree,
extractAIInputTypesFromBuilderHint,
narrowDisplayOptionsByDisabled,
} from './generate-types';
// =============================================================================
@@ -879,6 +880,12 @@ export function mergeDisplayOptions(
* When multiple properties have the same name (e.g., multiple 'options' collections
* with different displayOptions), their displayOptions and nested options are merged.
*
* Properties whose displayOptions are fully covered by disabledOptions (the field
* is rendered read-only in all its visible states, e.g. the expression-prefilled
* variant of a sessionKey) contribute no settable states and are skipped, so the
* generated schema only accepts values in states where the field is actually
* user-editable.
*
* @param properties - Array of node properties, possibly with duplicates
* @returns Map of property name to merged property
*/
@@ -891,24 +898,35 @@ export function mergePropertiesByName(properties: NodeProperty[]): Map<string, N
continue;
}
const existing = propsByName.get(prop.name);
const { displayOptions: narrowedDisplayOptions, fullyDisabled } =
narrowDisplayOptionsByDisabled(prop);
if (fullyDisabled) {
continue;
}
const normalizedProp: NodeProperty = { ...prop, displayOptions: narrowedDisplayOptions };
const existing = propsByName.get(normalizedProp.name);
if (existing) {
// Merge displayOptions from duplicate property
if (prop.displayOptions && existing.displayOptions) {
existing.displayOptions = mergeDisplayOptions(existing.displayOptions, prop.displayOptions);
} else if (prop.displayOptions && !existing.displayOptions) {
if (normalizedProp.displayOptions && existing.displayOptions) {
existing.displayOptions = mergeDisplayOptions(
existing.displayOptions,
normalizedProp.displayOptions,
);
} else if (normalizedProp.displayOptions && !existing.displayOptions) {
// If only the new one has displayOptions, the existing one has no condition
// which means it's always visible - keep existing as-is (no condition)
}
// For collection/fixedCollection types, merge nested options
if (
(prop.type === 'collection' || prop.type === 'fixedCollection') &&
prop.options &&
(normalizedProp.type === 'collection' || normalizedProp.type === 'fixedCollection') &&
normalizedProp.options &&
existing.options
) {
const existingOptionNames = new Set(existing.options.map((o) => o.name));
for (const opt of prop.options) {
for (const opt of normalizedProp.options) {
if (!existingOptionNames.has(opt.name)) {
existing.options.push(opt);
}
@@ -917,9 +935,9 @@ export function mergePropertiesByName(properties: NodeProperty[]): Map<string, N
// Keep the first property's other attributes (type, required, etc.)
} else {
// Create a shallow copy to avoid mutating the original when merging
propsByName.set(prop.name, {
...prop,
options: prop.options ? [...prop.options] : undefined,
propsByName.set(normalizedProp.name, {
...normalizedProp,
options: normalizedProp.options ? [...normalizedProp.options] : undefined,
});
}
}