fix(editor): Reset remote values on credentials change (#26282)

Co-authored-by: Elias Meire <elias@meire.dev>
Co-authored-by: Nikhil Kuriakose <nikhilkuria@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Milorad FIlipović 2026-04-21 10:21:06 +02:00 committed by GitHub
parent 87163163e6
commit 5e111975d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 408 additions and 2 deletions

View file

@ -24,7 +24,7 @@ import { NodeConnectionTypes, type INodeParameterResourceLocator } from 'n8n-wor
import type { IWorkflowDb, WorkflowListResource } from '@/Interface';
import { mock } from 'vitest-mock-extended';
import { ExpressionLocalResolveContextSymbol } from '@/app/constants';
import { nextTick } from 'vue';
import { nextTick, reactive } from 'vue';
function getNdvStateMock(): Partial<ReturnType<typeof useNDVStore>> {
return {
@ -909,6 +909,208 @@ describe('ParameterInput.vue', () => {
});
});
describe('credential change resets options value', () => {
test('should reset options value when credentials change', async () => {
mockNodeTypesState.getNodeParameterOptions = vi.fn(async () => [
{ name: 'GPT-4', value: 'gpt-4' },
{ name: 'GPT-3.5', value: 'gpt-3.5-turbo' },
]);
const activeNode = reactive({
id: faker.string.uuid(),
name: 'Test Node',
parameters: { model: 'gpt-3.5-turbo' },
position: [0, 0] as [number, number],
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
typeVersion: 1,
credentials: {
openAiApi: { id: '1', name: 'OpenAI Account 1' },
},
});
mockNdvState = {
...getNdvStateMock(),
activeNode,
};
const { emitted } = renderComponent({
props: {
path: 'model',
parameter: createTestNodeProperties({
displayName: 'Model',
name: 'model',
type: 'options',
default: 'gpt-4',
typeOptions: { loadOptionsMethod: 'getModels' },
}),
modelValue: 'gpt-3.5-turbo',
},
});
await waitFor(() => {
expect(mockNodeTypesState.getNodeParameterOptions).toHaveBeenCalled();
});
// Change credentials — the previously selected model should be reset to the default
activeNode.credentials = {
openAiApi: { id: '2', name: 'OpenAI Account 2' },
};
await nextTick();
await waitFor(() => {
expect(emitted('update')).toContainEqual([
expect.objectContaining({ name: 'model', value: 'gpt-4' }),
]);
});
});
test('should not reset non-options parameter when credentials change', async () => {
const activeNode = reactive({
id: faker.string.uuid(),
name: 'Test Node',
parameters: { temperature: 0.9 },
position: [0, 0] as [number, number],
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
typeVersion: 1,
credentials: {
openAiApi: { id: '1', name: 'OpenAI Account 1' },
},
});
mockNodeTypesState.getNodeParameterOptions = vi.fn(async () => []);
mockNdvState = {
...getNdvStateMock(),
activeNode,
};
const { emitted } = renderComponent({
props: {
path: 'temperature',
parameter: createTestNodeProperties({
displayName: 'Temperature',
name: 'temperature',
type: 'number',
default: 0.7,
typeOptions: { loadOptionsMethod: 'getTemperature' },
}),
modelValue: 0.9,
},
});
await waitFor(() => {
expect(mockNodeTypesState.getNodeParameterOptions).toHaveBeenCalled();
});
activeNode.credentials = {
openAiApi: { id: '2', name: 'OpenAI Account 2' },
};
await nextTick();
await waitFor(() => {
expect(mockNodeTypesState.getNodeParameterOptions).toHaveBeenCalledTimes(2);
});
const updates = emitted('update') ?? [];
expect(updates).not.toContainEqual([expect.objectContaining({ name: 'temperature' })]);
});
test('should not reset options value on initial load', async () => {
mockNodeTypesState.getNodeParameterOptions = vi.fn(async () => [
{ name: 'GPT-4', value: 'gpt-4' },
]);
mockNdvState = {
...getNdvStateMock(),
activeNode: reactive({
id: faker.string.uuid(),
name: 'Test Node',
parameters: { model: 'gpt-4' },
position: [0, 0] as [number, number],
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
typeVersion: 1,
credentials: {
openAiApi: { id: '1', name: 'OpenAI Account 1' },
},
}),
};
const { emitted } = renderComponent({
props: {
path: 'model',
parameter: createTestNodeProperties({
displayName: 'Model',
name: 'model',
type: 'options',
default: 'gpt-4',
typeOptions: { loadOptionsMethod: 'getModels' },
}),
modelValue: 'gpt-4',
},
});
await waitFor(() => {
expect(mockNodeTypesState.getNodeParameterOptions).toHaveBeenCalled();
});
expect(emitted('update')).toBeUndefined();
});
test('should not reset options value on first credential assignment', async () => {
mockNodeTypesState.getNodeParameterOptions = vi.fn(async () => [
{ name: 'GPT-4', value: 'gpt-4' },
{ name: 'GPT-3.5', value: 'gpt-3.5-turbo' },
]);
const activeNode = reactive({
id: faker.string.uuid(),
name: 'Test Node',
parameters: { model: 'gpt-3.5-turbo' },
position: [0, 0] as [number, number],
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
typeVersion: 1,
credentials: {} as Record<string, { id: string; name: string }>,
});
mockNdvState = {
...getNdvStateMock(),
activeNode,
};
const { emitted } = renderComponent({
props: {
path: 'model',
parameter: createTestNodeProperties({
displayName: 'Model',
name: 'model',
type: 'options',
default: 'gpt-4',
typeOptions: { loadOptionsMethod: 'getModels' },
}),
modelValue: 'gpt-3.5-turbo',
},
});
await waitFor(() => {
expect(mockNodeTypesState.getNodeParameterOptions).toHaveBeenCalled();
});
// Assign credentials for the first time (from empty to having credentials)
activeNode.credentials = {
openAiApi: { id: '1', name: 'OpenAI Account 1' },
};
await nextTick();
await waitFor(() => {
expect(mockNodeTypesState.getNodeParameterOptions).toHaveBeenCalledTimes(2);
});
// Should NOT reset the value since this is first credential assignment, not a change
const updates = emitted('update') ?? [];
expect(updates).not.toContainEqual([expect.objectContaining({ name: 'model' })]);
});
});
describe('multi-line string handling', () => {
test('should render multi-line string value as textarea', async () => {
const multiLineValue = 'line1\nline2\nline3';

View file

@ -1329,9 +1329,18 @@ onBeforeUnmount(() => {
watch(
() => node.value?.credentials,
() => {
(_newCredentials, oldCredentials) => {
if (hasRemoteMethod.value && node.value) {
void loadRemoteParameterOptions();
// Reset options value when credentials change (not on initial load or first assignment)
const hadCredentials = oldCredentials !== undefined && Object.keys(oldCredentials).length > 0;
if (hadCredentials && props.parameter.type === 'options') {
emit('update', {
node: node.value.name,
name: props.path,
value: props.parameter.default ?? '',
});
}
}
},
{ immediate: true },

View file

@ -742,6 +742,179 @@ describe('ResourceLocator', () => {
});
});
describe('credential change resets value', () => {
it('should reset value when node credentials change', async () => {
const modelValue: typeof TEST_MODEL_VALUE = {
...TEST_MODEL_VALUE,
value: 'selected-model',
cachedResultName: 'GPT-4',
cachedResultUrl: 'https://test.com/gpt-4',
};
const node = { ...TEST_NODE_MULTI_MODE };
const { emitted, rerender } = renderComponent({
props: {
modelValue,
parameter: TEST_PARAMETER_MULTI_MODE,
path: `parameters.${TEST_PARAMETER_MULTI_MODE.name}`,
node,
displayTitle: 'Test Resource Locator',
expressionComputedValue: '',
isValueExpression: false,
},
});
// Change credentials on the node
await rerender({
node: {
...node,
credentials: {
testAuth: {
id: '5678',
name: 'Different Account',
},
},
},
});
expect(emitted('update:modelValue')).toEqual([
[
{
...modelValue,
cachedResultName: '',
cachedResultUrl: '',
value: '',
},
],
]);
});
it('should not reset value when credentials have not changed', async () => {
const modelValue: typeof TEST_MODEL_VALUE = {
...TEST_MODEL_VALUE,
value: 'selected-model',
};
const node = { ...TEST_NODE_MULTI_MODE };
const { emitted, rerender } = renderComponent({
props: {
modelValue,
parameter: TEST_PARAMETER_MULTI_MODE,
path: `parameters.${TEST_PARAMETER_MULTI_MODE.name}`,
node,
displayTitle: 'Test Resource Locator',
expressionComputedValue: '',
isValueExpression: false,
},
});
// Re-render with same credentials but different parameter
await rerender({
node: {
...node,
parameters: { ...node.parameters, operation: 'list' },
},
});
expect(emitted('update:modelValue')).toBeUndefined();
});
it('should not reset value on initial mount', async () => {
const { emitted } = renderComponent({
props: {
modelValue: TEST_MODEL_VALUE,
parameter: TEST_PARAMETER_MULTI_MODE,
path: `parameters.${TEST_PARAMETER_MULTI_MODE.name}`,
node: TEST_NODE_MULTI_MODE,
displayTitle: 'Test Resource Locator',
expressionComputedValue: '',
isValueExpression: false,
},
});
expect(emitted('update:modelValue')).toBeUndefined();
});
it('should not reset when value is already empty', async () => {
const modelValue: typeof TEST_MODEL_VALUE = {
...TEST_MODEL_VALUE,
value: '',
};
const node = { ...TEST_NODE_MULTI_MODE };
const { emitted, rerender } = renderComponent({
props: {
modelValue,
parameter: TEST_PARAMETER_MULTI_MODE,
path: `parameters.${TEST_PARAMETER_MULTI_MODE.name}`,
node,
displayTitle: 'Test Resource Locator',
expressionComputedValue: '',
isValueExpression: false,
},
});
await rerender({
node: {
...node,
credentials: {
testAuth: {
id: '5678',
name: 'Different Account',
},
},
},
});
expect(emitted('update:modelValue')).toBeUndefined();
});
it('should not reset value on first credential assignment', async () => {
const modelValue: typeof TEST_MODEL_VALUE = {
...TEST_MODEL_VALUE,
value: 'selected-model',
cachedResultName: 'GPT-4',
cachedResultUrl: 'https://test.com/gpt-4',
};
// Start with no credentials
const node = {
...TEST_NODE_MULTI_MODE,
credentials: undefined,
};
const { emitted, rerender } = renderComponent({
props: {
modelValue,
parameter: TEST_PARAMETER_MULTI_MODE,
path: `parameters.${TEST_PARAMETER_MULTI_MODE.name}`,
node,
displayTitle: 'Test Resource Locator',
expressionComputedValue: '',
isValueExpression: false,
},
});
// Assign credentials for the first time
await rerender({
node: {
...node,
credentials: {
testAuth: {
id: '1234',
name: 'Test Account',
},
},
},
});
expect(emitted('update:modelValue')).toBeUndefined();
});
});
describe('ExpressionLocalResolveContext injection', () => {
const mockResolveExpression = vi.fn().mockImplementation((val) => val);
const mockResolveRequiredParameters = vi.fn().mockImplementation((_, params) => params);

View file

@ -541,6 +541,28 @@ watch(
},
);
watch(
() => stringify(props.node?.credentials ?? {}),
(currentValue, oldValue) => {
const emptyCredentials = stringify({});
const isUpdated =
oldValue !== undefined && oldValue !== emptyCredentials && currentValue !== oldValue;
if (
isUpdated &&
props.modelValue &&
isResourceLocatorValue(props.modelValue) &&
props.modelValue.value !== ''
) {
emit('update:modelValue', {
...props.modelValue,
cachedResultName: '',
cachedResultUrl: '',
value: '',
});
}
},
);
onMounted(() => {
props.eventBus.on('refreshList', refreshList);
window.addEventListener('resize', setWidth);