feat(editor): Use resource locator at Simple Vector Store memory key, allow cross workflow use (#15421)

Remove workflow isolation from in-memory Simple Vector Store, making it possible to use vector stores created on other workflows. Display all current in-memory vector stores with a resource locator at Memory Key picker.

Note that these vector stores are still intended for non-production development use. Any users of an instance can access data in all in-memory vector stores as they aren't bound to workflows.
This commit is contained in:
Jaakko Husso
2025-05-22 23:34:59 +03:00
committed by GitHub
parent a86bc43f50
commit e5c2aea6fe
16 changed files with 392 additions and 36 deletions

View File

@@ -49,6 +49,27 @@ export const TEST_PARAMETER_SINGLE_MODE: INodeProperties = {
],
};
export const TEST_PARAMETER_ADD_RESOURCE: INodeProperties = {
...TEST_PARAMETER_MULTI_MODE,
name: 'testParameterAddResource',
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'testSearch',
searchable: true,
allowNewResource: {
label: 'resourceLocator.mode.list.addNewResource.vectorStoreInMemory',
method: 'testAddResource',
defaultName: 'test',
},
},
},
],
};
export const TEST_NODE_MULTI_MODE: INode = {
type: 'n8n-nodes-base.airtable',
typeVersion: 2.1,

View File

@@ -9,6 +9,7 @@ import {
TEST_MODEL_VALUE,
TEST_NODE_MULTI_MODE,
TEST_NODE_SINGLE_MODE,
TEST_PARAMETER_ADD_RESOURCE,
TEST_PARAMETER_MULTI_MODE,
TEST_PARAMETER_SINGLE_MODE,
} from './ResourceLocator.test.constants';
@@ -133,6 +134,69 @@ describe('ResourceLocator', () => {
});
});
it('renders add resource button', async () => {
nodeTypesStore.getResourceLocatorResults.mockResolvedValue({
results: [],
paginationToken: null,
});
const { getByTestId } = renderComponent({
props: {
modelValue: TEST_MODEL_VALUE,
parameter: TEST_PARAMETER_ADD_RESOURCE,
path: `parameters.${TEST_PARAMETER_ADD_RESOURCE.name}`,
node: TEST_NODE_SINGLE_MODE,
displayTitle: 'Test Resource Locator',
expressionComputedValue: '',
},
});
expect(getByTestId(`resource-locator-${TEST_PARAMETER_ADD_RESOURCE.name}`)).toBeInTheDocument();
// Click on the input to open it
await userEvent.click(getByTestId('rlc-input'));
// Expect the button to create a new resource to be rendered
expect(getByTestId('rlc-item-add-resource')).toBeInTheDocument();
});
it('creates new resource passing search filter as name', async () => {
nodeTypesStore.getResourceLocatorResults.mockResolvedValue({
results: [],
paginationToken: null,
});
nodeTypesStore.getNodeParameterActionResult.mockResolvedValue('new-resource');
const { getByTestId } = renderComponent({
props: {
modelValue: TEST_MODEL_VALUE,
parameter: TEST_PARAMETER_ADD_RESOURCE,
path: `parameters.${TEST_PARAMETER_ADD_RESOURCE.name}`,
node: TEST_NODE_SINGLE_MODE,
displayTitle: 'Test Resource Locator',
expressionComputedValue: '',
},
});
// Click on the input to open it
await userEvent.click(getByTestId('rlc-input'));
// Type in the input to name the resource
await userEvent.type(getByTestId('rlc-search'), 'Test Resource');
// Click on the add resource button
await userEvent.click(getByTestId('rlc-item-add-resource'));
expect(nodeTypesStore.getNodeParameterActionResult).toHaveBeenCalledWith({
nodeTypeAndVersion: {
name: TEST_NODE_SINGLE_MODE.type,
version: TEST_NODE_SINGLE_MODE.typeVersion,
},
path: `parameters.${TEST_PARAMETER_ADD_RESOURCE.name}`,
currentNodeParameters: expect.any(Object),
credentials: TEST_NODE_SINGLE_MODE.credentials,
handler: 'testAddResource',
payload: {
name: 'Test Resource',
},
});
});
// Testing error message deduplication
describe('ResourceLocator credentials error handling', () => {
it.each([

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { ResourceLocatorRequestDto } from '@n8n/api-types';
import type { ResourceLocatorRequestDto, ActionResultRequestDto } from '@n8n/api-types';
import type { IResourceLocatorResultExpanded, IUpdateInformation } from '@/Interface';
import DraggableTarget from '@/components/DraggableTarget.vue';
import ExpressionParameterInput from '@/components/ExpressionParameterInput.vue';
@@ -55,6 +55,7 @@ import {
} from '../../utils/fromAIOverrideUtils';
import { N8nNotice } from '@n8n/design-system';
import { completeExpressionSyntax } from '@/utils/expressions';
import type { BaseTextKey } from '@/plugins/i18n';
/**
* Regular expression to check if the error message contains credential-related phrases.
@@ -346,6 +347,70 @@ const showOverrideButton = computed(
() => canBeContentOverride.value && !isContentOverride.value && !props.isReadOnly,
);
const allowNewResources = computed(() => {
if (!props.node) {
return undefined;
}
const addNewResourceOptions = getPropertyArgument(currentMode.value, 'allowNewResource');
if (!addNewResourceOptions || typeof addNewResourceOptions !== 'object') {
return undefined;
}
return {
label: i18n.baseText(addNewResourceOptions.label as BaseTextKey, {
interpolate: {
resourceName: !!searchFilter.value ? searchFilter.value : addNewResourceOptions.defaultName,
},
}),
method: addNewResourceOptions.method,
};
});
const handleAddResourceClick = async () => {
if (!props.node || !allowNewResources.value) {
return;
}
const { method: addNewResourceMethodName } = allowNewResources.value;
const resolvedNodeParameters = workflowHelpers.resolveRequiredParameters(
props.parameter,
currentRequestParams.value.parameters,
);
if (!resolvedNodeParameters || !addNewResourceMethodName) {
return;
}
const requestParams: ActionResultRequestDto = {
nodeTypeAndVersion: {
name: props.node.type,
version: props.node.typeVersion,
},
path: props.path,
currentNodeParameters: resolvedNodeParameters,
credentials: props.node.credentials,
handler: addNewResourceMethodName,
payload: {
name: searchFilter.value,
},
};
const newResource = (await nodeTypesStore.getNodeParameterActionResult(
requestParams,
)) as NodeParameterValue;
refreshList();
await loadResources();
searchFilter.value = '';
onListItemSelected(newResource);
};
const onAddResourceClicked = computed(() =>
allowNewResources.value ? handleAddResourceClick : undefined,
);
watch(currentQueryError, (curr, prev) => {
if (resourceDropdownVisible.value && curr && !prev) {
if (inputRef.value) {
@@ -453,7 +518,7 @@ function openResource(url: string) {
function getPropertyArgument(
parameter: INodePropertyMode,
argumentName: keyof INodePropertyModeTypeOptions,
): string | number | boolean | undefined {
): string | number | boolean | INodePropertyModeTypeOptions['allowNewResource'] | undefined {
return parameter.typeOptions?.[argumentName];
}
@@ -838,9 +903,11 @@ function removeOverride() {
:error-view="currentQueryError"
:width="width"
:event-bus="eventBus"
:allow-new-resources="allowNewResources"
@update:model-value="onListItemSelected"
@filter="onSearchFilter"
@load-more="loadResourcesDebounced"
@add-resource-click="onAddResourceClicked"
>
<template #error>
<div :class="$style.errorContainer" data-test-id="rlc-error-container">

View File

@@ -128,7 +128,8 @@ function openUrl(event: MouseEvent, url: string) {
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
if (hoverIndex.value < sortedResources.value.length - 1) {
// hoverIndex 0 is reserved for the "add new resource" item
if (hoverIndex.value < sortedResources.value.length) {
hoverIndex.value++;
if (resultsContainerRef.value && itemsRef.value.length === 1) {
@@ -155,7 +156,12 @@ function onKeyDown(e: KeyboardEvent) {
}
}
} else if (e.key === 'Enter') {
const selected = sortedResources.value[hoverIndex.value]?.value;
if (hoverIndex.value === 0 && props.allowNewResources.label) {
emit('addResourceClick');
return;
}
const selected = sortedResources.value[hoverIndex.value - 1]?.value;
// Selected resource can be empty when loading or empty results
if (selected) {
@@ -225,7 +231,11 @@ defineExpose({ isWithinDropdown });
ref="searchRef"
:model-value="filter"
:clearable="true"
:placeholder="i18n.baseText('resourceLocator.search.placeholder')"
:placeholder="
allowNewResources.label
? i18n.baseText('resourceLocator.placeholder.searchOrCreate')
: i18n.baseText('resourceLocator.placeholder.search')
"
data-test-id="rlc-search"
@update:model-value="onFilterInput"
>
@@ -253,7 +263,7 @@ defineExpose({ isWithinDropdown });
v-if="allowNewResources.label"
key="addResourceKey"
ref="itemsRef"
data-test-id="rlc-item"
data-test-id="rlc-item-add-resource"
:class="{
[$style.resourceItem]: true,
[$style.hovering]: hoverIndex === 0,