mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user