diff --git a/packages/@n8n/workflow-sdk/src/generate-types/generate-types.ts b/packages/@n8n/workflow-sdk/src/generate-types/generate-types.ts index 167ecf55c5f..3117903faef 100644 --- a/packages/@n8n/workflow-sdk/src/generate-types/generate-types.ts +++ b/packages/@n8n/workflow-sdk/src/generate-types/generate-types.ts @@ -269,6 +269,10 @@ export interface NodeProperty { show?: Record; hide?: Record; }; + disabledOptions?: { + show?: Record; + hide?: Record; + }; typeOptions?: Record; 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 = {}; + const mergedHide: Record = {}; + 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 = {}; + 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, }); } diff --git a/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.test.ts b/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.test.ts index 5505e8171ef..e97865a8990 100644 --- a/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.test.ts +++ b/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.test.ts @@ -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', diff --git a/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.ts b/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.ts index 2888f819122..7399f57e30e 100644 --- a/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.ts +++ b/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.ts @@ -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 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