mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
fix(ai-builder): Support 'disabledOptions' in node schemas (#28894)
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user