mirror of
https://github.com/n8n-io/n8n
synced 2026-04-21 15:47:20 +00:00
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:
parent
87163163e6
commit
5e111975d4
4 changed files with 408 additions and 2 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue