feat(editor): Implement workflowSelector parameter type (#10482)
Some checks are pending
Test Master / install-and-build (push) Waiting to run
Test Master / Unit tests (18.x) (push) Blocked by required conditions
Test Master / Unit tests (20.x) (push) Blocked by required conditions
Test Master / Unit tests (22.4) (push) Blocked by required conditions
Test Master / Lint (push) Blocked by required conditions
Test Master / Notify Slack on failure (push) Blocked by required conditions

This commit is contained in:
oleg 2024-08-22 16:59:12 +02:00 committed by GitHub
parent a73b9a38d6
commit 84e54beac7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 954 additions and 140 deletions

View file

@ -61,6 +61,7 @@ export const AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME = 'OpenAI Chat Model'
export const AI_MEMORY_POSTGRES_NODE_NAME = 'Postgres Chat Memory'; export const AI_MEMORY_POSTGRES_NODE_NAME = 'Postgres Chat Memory';
export const AI_OUTPUT_PARSER_AUTO_FIXING_NODE_NAME = 'Auto-fixing Output Parser'; export const AI_OUTPUT_PARSER_AUTO_FIXING_NODE_NAME = 'Auto-fixing Output Parser';
export const WEBHOOK_NODE_NAME = 'Webhook'; export const WEBHOOK_NODE_NAME = 'Webhook';
export const EXECUTE_WORKFLOW_NODE_NAME = 'Execute Workflow';
export const META_KEY = Cypress.platform === 'darwin' ? 'meta' : 'ctrl'; export const META_KEY = Cypress.platform === 'darwin' ? 'meta' : 'ctrl';

View file

@ -0,0 +1,82 @@
import { EXECUTE_WORKFLOW_NODE_NAME } from '../constants';
import { WorkflowPage as WorkflowPageClass, NDV } from '../pages';
import { getVisiblePopper } from '../utils';
const workflowPage = new WorkflowPageClass();
const ndv = new NDV();
describe('Workflow Selector Parameter', () => {
beforeEach(() => {
cy.resetDatabase();
cy.signinAsOwner();
['Get_Weather', 'Search_DB'].forEach((workflowName) => {
workflowPage.actions.visit();
cy.createFixtureWorkflow(`Test_Subworkflow_${workflowName}.json`, workflowName);
workflowPage.actions.saveWorkflowOnButtonClick();
});
workflowPage.actions.visit();
workflowPage.actions.addInitialNodeToCanvas(EXECUTE_WORKFLOW_NODE_NAME, {
keepNdvOpen: true,
action: 'Call Another Workflow',
});
});
it('should render sub-workflows list', () => {
ndv.getters.resourceLocator('workflowId').should('be.visible');
ndv.getters.resourceLocatorInput('workflowId').click();
getVisiblePopper()
.should('have.length', 1)
.findChildByTestId('rlc-item')
.should('have.length', 2);
});
it('should show required parameter warning', () => {
ndv.getters.resourceLocator('workflowId').should('be.visible');
ndv.getters.resourceLocatorInput('workflowId').click();
ndv.getters.parameterInputIssues('workflowId').should('exist');
});
it('should filter sub-workflows list', () => {
ndv.getters.resourceLocator('workflowId').should('be.visible');
ndv.getters.resourceLocatorInput('workflowId').click();
ndv.getters.resourceLocatorSearch('workflowId').type('Weather');
getVisiblePopper()
.should('have.length', 1)
.findChildByTestId('rlc-item')
.should('have.length', 1)
.click();
ndv.getters
.resourceLocatorInput('workflowId')
.find('input')
.should('have.value', 'Get_Weather');
});
it('should render sub-workflow links correctly', () => {
ndv.getters.resourceLocator('workflowId').should('be.visible');
ndv.getters.resourceLocatorInput('workflowId').click();
getVisiblePopper().findChildByTestId('rlc-item').first().click();
ndv.getters.resourceLocatorInput('workflowId').find('a').should('exist');
cy.getByTestId('radio-button-expression').eq(1).click();
ndv.getters.resourceLocatorInput('workflowId').find('a').should('not.exist');
});
it('should switch to ID mode on expression', () => {
ndv.getters.resourceLocator('workflowId').should('be.visible');
ndv.getters.resourceLocatorInput('workflowId').click();
getVisiblePopper().findChildByTestId('rlc-item').first().click();
ndv.getters
.resourceLocatorModeSelector('workflowId')
.find('input')
.should('have.value', 'From list');
cy.getByTestId('radio-button-expression').eq(1).click();
ndv.getters
.resourceLocatorModeSelector('workflowId')
.find('input')
.should('have.value', 'By ID');
});
});

View file

@ -0,0 +1,53 @@
{
"name": "Get Weather",
"nodes": [
{
"parameters": {},
"id": "82eed1ba-179b-4f8f-8a85-b45f0d4e5857",
"name": "Execute Workflow Trigger",
"type": "n8n-nodes-base.executeWorkflowTrigger",
"typeVersion": 1,
"position": [
560,
340
]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "6ad8dc55-20f3-45af-a724-c7ecac90d338",
"name": "response",
"value": "Weather is sunny",
"type": "string"
}
]
},
"options": {}
},
"id": "8f3e00f6-fc92-4aba-817b-93d206158bda",
"name": "Edit Fields",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
780,
340
]
}
],
"pinData": {},
"connections": {
"Execute Workflow Trigger": {
"main": [
[
{
"node": "Edit Fields",
"type": "main",
"index": 0
}
]
]
}
}
}

View file

@ -0,0 +1,64 @@
{
"name": "Search DB",
"nodes": [
{
"parameters": {},
"id": "64465f9b-63de-43f9-8d90-b5b2eb7a2dc7",
"name": "Execute Workflow Trigger",
"type": "n8n-nodes-base.executeWorkflowTrigger",
"typeVersion": 1,
"position": [
640,
380
]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "6ad8dc55-20f3-45af-a724-c7ecac90d338",
"name": "response",
"value": "10 results found",
"type": "string"
}
]
},
"options": {}
},
"id": "b580fd2b-00c8-4a52-8acb-024f204c0947",
"name": "Edit Fields",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
860,
380
]
}
],
"pinData": {},
"connections": {
"Execute Workflow Trigger": {
"main": [
[
{
"node": "Edit Fields",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "6026f7a4-f5dc-4c27-9f83-3a02fc6e33ae",
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "27cc9b56542ad45b38725555722c50a1c3fee1670bbb67980558314ee08517c4"
},
"id": "BFFhCdBZmNSkx4qf",
"tags": []
}

View file

@ -9,6 +9,7 @@ import type {
INodeType, INodeType,
INodeTypeDescription, INodeTypeDescription,
SupplyData, SupplyData,
INodeParameterResourceLocator,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { BaseRetriever, type BaseRetrieverInput } from '@langchain/core/retrievers'; import { BaseRetriever, type BaseRetrieverInput } from '@langchain/core/retrievers';
@ -41,7 +42,7 @@ export class RetrieverWorkflow implements INodeType {
name: 'retrieverWorkflow', name: 'retrieverWorkflow',
icon: 'fa:box-open', icon: 'fa:box-open',
group: ['transform'], group: ['transform'],
version: 1, version: [1, 1.1],
description: 'Use an n8n Workflow as Retriever', description: 'Use an n8n Workflow as Retriever',
defaults: { defaults: {
name: 'Workflow Retriever', name: 'Workflow Retriever',
@ -105,12 +106,26 @@ export class RetrieverWorkflow implements INodeType {
displayOptions: { displayOptions: {
show: { show: {
source: ['database'], source: ['database'],
'@version': [{ _cnd: { eq: 1 } }],
}, },
}, },
default: '', default: '',
required: true, required: true,
description: 'The workflow to execute', description: 'The workflow to execute',
}, },
{
displayName: 'Workflow',
name: 'workflowId',
type: 'workflowSelector',
displayOptions: {
show: {
source: ['database'],
'@version': [{ _cnd: { gte: 1.1 } }],
},
},
default: '',
required: true,
},
// ---------------------------------- // ----------------------------------
// source:parameter // source:parameter
@ -301,11 +316,21 @@ export class RetrieverWorkflow implements INodeType {
const workflowInfo: IExecuteWorkflowInfo = {}; const workflowInfo: IExecuteWorkflowInfo = {};
if (source === 'database') { if (source === 'database') {
// Read workflow from database const nodeVersion = this.executeFunctions.getNode().typeVersion;
workflowInfo.id = this.executeFunctions.getNodeParameter( if (nodeVersion === 1) {
'workflowId', workflowInfo.id = this.executeFunctions.getNodeParameter(
itemIndex, 'workflowId',
) as string; itemIndex,
) as string;
} else {
const { value } = this.executeFunctions.getNodeParameter(
'workflowId',
itemIndex,
{},
) as INodeParameterResourceLocator;
workflowInfo.id = value as string;
}
baseMetadata.workflowId = workflowInfo.id; baseMetadata.workflowId = workflowInfo.id;
} else if (source === 'parameter') { } else if (source === 'parameter') {
// Read workflow from parameter // Read workflow from parameter

View file

@ -8,6 +8,7 @@ import type {
SupplyData, SupplyData,
ExecutionError, ExecutionError,
IDataObject, IDataObject,
INodeParameterResourceLocator,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { NodeConnectionType, NodeOperationError, jsonParse } from 'n8n-workflow'; import { NodeConnectionType, NodeOperationError, jsonParse } from 'n8n-workflow';
import type { SetField, SetNodeOptions } from 'n8n-nodes-base/dist/nodes/Set/v2/helpers/interfaces'; import type { SetField, SetNodeOptions } from 'n8n-nodes-base/dist/nodes/Set/v2/helpers/interfaces';
@ -32,7 +33,7 @@ export class ToolWorkflow implements INodeType {
name: 'toolWorkflow', name: 'toolWorkflow',
icon: 'fa:network-wired', icon: 'fa:network-wired',
group: ['transform'], group: ['transform'],
version: [1, 1.1], version: [1, 1.1, 1.2],
description: 'Uses another n8n workflow as a tool. Allows packaging any n8n node(s) as a tool.', description: 'Uses another n8n workflow as a tool. Allows packaging any n8n node(s) as a tool.',
defaults: { defaults: {
name: 'Call n8n Workflow Tool', name: 'Call n8n Workflow Tool',
@ -142,6 +143,7 @@ export class ToolWorkflow implements INodeType {
displayOptions: { displayOptions: {
show: { show: {
source: ['database'], source: ['database'],
'@version': [{ _cnd: { lte: 1.1 } }],
}, },
}, },
default: '', default: '',
@ -150,6 +152,20 @@ export class ToolWorkflow implements INodeType {
hint: 'Can be found in the URL of the workflow', hint: 'Can be found in the URL of the workflow',
}, },
{
displayName: 'Workflow',
name: 'workflowId',
type: 'workflowSelector',
displayOptions: {
show: {
source: ['database'],
'@version': [{ _cnd: { gte: 1.2 } }],
},
},
default: '',
required: true,
},
// ---------------------------------- // ----------------------------------
// source:parameter // source:parameter
// ---------------------------------- // ----------------------------------
@ -368,7 +384,17 @@ export class ToolWorkflow implements INodeType {
const workflowInfo: IExecuteWorkflowInfo = {}; const workflowInfo: IExecuteWorkflowInfo = {};
if (source === 'database') { if (source === 'database') {
// Read workflow from database // Read workflow from database
workflowInfo.id = this.getNodeParameter('workflowId', itemIndex) as string; const nodeVersion = this.getNode().typeVersion;
if (nodeVersion <= 1.1) {
workflowInfo.id = this.getNodeParameter('workflowId', itemIndex) as string;
} else {
const { value } = this.getNodeParameter(
'workflowId',
itemIndex,
{},
) as INodeParameterResourceLocator;
workflowInfo.id = value as string;
}
} else if (source === 'parameter') { } else if (source === 'parameter') {
// Read workflow from parameter // Read workflow from parameter
const workflowJson = this.getNodeParameter('workflowJson', itemIndex) as string; const workflowJson = this.getNodeParameter('workflowJson', itemIndex) as string;

View file

@ -15,7 +15,7 @@
<div class="parameter-input ignore-key-press" :style="parameterInputWrapperStyle"> <div class="parameter-input ignore-key-press" :style="parameterInputWrapperStyle">
<ResourceLocator <ResourceLocator
v-if="isResourceLocatorParameter" v-if="parameter.type === 'resourceLocator'"
ref="resourceLocator" ref="resourceLocator"
:parameter="parameter" :parameter="parameter"
:model-value="modelValueResourceLocator" :model-value="modelValueResourceLocator"
@ -36,6 +36,25 @@
@blur="onBlur" @blur="onBlur"
@drop="onResourceLocatorDrop" @drop="onResourceLocatorDrop"
/> />
<WorkflowSelectorParameterInput
v-else-if="parameter.type === 'workflowSelector'"
ref="resourceLocator"
:parameter="parameter"
:model-value="modelValueResourceLocator"
:dependent-parameters-values="dependentParametersValues"
:display-title="displayTitle"
:expression-display-value="expressionDisplayValue"
:expression-computed-value="expressionEvaluated"
:is-value-expression="isModelValueExpression"
:expression-edit-dialog-visible="expressionEditDialogVisible"
:path="path"
:parameter-issues="getIssues"
@update:model-value="valueChanged"
@modal-opener-click="openExpressionEditorModal"
@focus="setFocus"
@blur="onBlur"
@drop="onResourceLocatorDrop"
/>
<ExpressionParameterInput <ExpressionParameterInput
v-else-if="isModelValueExpression || forceShowExpression" v-else-if="isModelValueExpression || forceShowExpression"
ref="inputField" ref="inputField"
@ -939,7 +958,7 @@ const shortPath = computed<string>(() => {
}); });
const isResourceLocatorParameter = computed<boolean>(() => { const isResourceLocatorParameter = computed<boolean>(() => {
return props.parameter.type === 'resourceLocator'; return props.parameter.type === 'resourceLocator' || props.parameter.type === 'workflowSelector';
}); });
const isSecretParameter = computed<boolean>(() => { const isSecretParameter = computed<boolean>(() => {

View file

@ -137,7 +137,9 @@ const node = computed(() => ndvStore.activeNode);
const hint = computed(() => i18n.nodeText().hint(props.parameter, props.path)); const hint = computed(() => i18n.nodeText().hint(props.parameter, props.path));
const isInputTypeString = computed(() => props.parameter.type === 'string'); const isInputTypeString = computed(() => props.parameter.type === 'string');
const isInputTypeNumber = computed(() => props.parameter.type === 'number'); const isInputTypeNumber = computed(() => props.parameter.type === 'number');
const isResourceLocator = computed(() => props.parameter.type === 'resourceLocator'); const isResourceLocator = computed(
() => props.parameter.type === 'resourceLocator' || props.parameter.type === 'workflowSelector',
);
const isDropDisabled = computed( const isDropDisabled = computed(
() => () =>
props.parameter.noDataExpression || props.parameter.noDataExpression ||

View file

@ -828,125 +828,5 @@ export default defineComponent({
</script> </script>
<style lang="scss" module> <style lang="scss" module>
$--mode-selector-width: 92px; @import './resourceLocator.scss';
.modeSelector {
--input-background-color: initial;
--input-font-color: initial;
--input-border-color: initial;
flex-basis: $--mode-selector-width;
input {
border-radius: var(--border-radius-base) 0 0 var(--border-radius-base);
border-right: none;
overflow: hidden;
&:focus {
border-right: var(--border-base);
}
&:disabled {
cursor: not-allowed !important;
}
}
}
.resourceLocator {
display: flex;
flex-wrap: wrap;
position: relative;
--input-issues-width: 28px;
.inputContainer {
display: flex;
align-items: center;
width: 100%;
--input-border-top-left-radius: 0;
--input-border-bottom-left-radius: 0;
> div {
width: 100%;
}
}
.background {
position: absolute;
background-color: var(--color-background-input-triple);
top: 0;
bottom: 0;
left: 0;
right: var(--input-issues-width);
border: 1px solid var(--border-color-base);
border-radius: var(--border-radius-base);
}
&.multipleModes {
.inputContainer {
display: flex;
align-items: center;
flex-basis: calc(100% - $--mode-selector-width);
flex-grow: 1;
input {
border-radius: 0 var(--border-radius-base) var(--border-radius-base) 0;
}
}
}
}
.droppable {
--input-border-color: var(--color-secondary-tint-1);
--input-border-style: dashed;
}
.activeDrop {
--input-border-color: var(--color-success);
--input-background-color: var(--color-success-tint-2);
--input-border-style: solid;
textarea,
input {
cursor: grabbing !important;
}
}
.selectInput input {
padding-right: 30px !important;
overflow: hidden;
text-overflow: ellipsis;
}
.selectIcon {
cursor: pointer;
font-size: 14px;
transition: transform 0.3s;
transform: rotateZ(0);
&.isReverse {
transform: rotateZ(180deg);
}
}
.listModeInputContainer * {
cursor: pointer;
}
.error {
max-width: 170px;
word-break: normal;
text-align: center;
}
.openResourceLink {
width: 25px !important;
padding-left: var(--spacing-2xs);
padding-top: var(--spacing-4xs);
align-self: flex-start;
}
.parameter-issues {
width: 25px !important;
}
</style> </style>

View file

@ -0,0 +1,121 @@
$--mode-selector-width: 92px;
.modeSelector {
--input-background-color: initial;
--input-font-color: initial;
--input-border-color: initial;
flex-basis: $--mode-selector-width;
input {
border-radius: var(--border-radius-base) 0 0 var(--border-radius-base);
border-right: none;
overflow: hidden;
&:focus {
border-right: var(--border-base);
}
&:disabled {
cursor: not-allowed !important;
}
}
}
.resourceLocator {
display: flex;
flex-wrap: wrap;
position: relative;
--input-issues-width: 28px;
.inputContainer {
display: flex;
align-items: center;
width: 100%;
--input-border-top-left-radius: 0;
--input-border-bottom-left-radius: 0;
> div {
width: 100%;
}
}
.background {
position: absolute;
background-color: var(--color-background-input-triple);
top: 0;
bottom: 0;
left: 0;
right: var(--input-issues-width);
border: 1px solid var(--border-color-base);
border-radius: var(--border-radius-base);
}
&.multipleModes {
.inputContainer {
display: flex;
align-items: center;
flex-basis: calc(100% - $--mode-selector-width);
flex-grow: 1;
input {
border-radius: 0 var(--border-radius-base) var(--border-radius-base) 0;
}
}
}
}
.droppable {
--input-border-color: var(--color-secondary-tint-1);
--input-border-style: dashed;
}
.activeDrop {
--input-border-color: var(--color-success);
--input-background-color: var(--color-success-tint-2);
--input-border-style: solid;
textarea,
input {
cursor: grabbing !important;
}
}
.selectInput input {
padding-right: 30px !important;
overflow: hidden;
text-overflow: ellipsis;
}
.selectIcon {
cursor: pointer;
font-size: 14px;
transition: transform 0.3s;
transform: rotateZ(0);
&.isReverse {
transform: rotateZ(180deg);
}
}
.listModeInputContainer * {
cursor: pointer;
}
.error {
max-width: 170px;
word-break: normal;
text-align: center;
}
.openResourceLink {
width: 25px !important;
padding-left: var(--spacing-2xs);
padding-top: var(--spacing-4xs);
align-self: flex-start;
}
.parameter-issues {
width: 25px !important;
}

View file

@ -0,0 +1,315 @@
<script setup lang="ts">
import type { ComponentInstance } from 'vue';
import { computed, ref, onMounted, onUnmounted, watch } from 'vue';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { EventBus } from 'n8n-design-system/utils';
import { createEventBus } from 'n8n-design-system/utils';
import type {
INodeParameterResourceLocator,
INodeProperties,
NodeParameterValue,
ResourceLocatorModes,
} from 'n8n-workflow';
import { useI18n } from '@/composables/useI18n';
import ResourceLocatorDropdown from '@/components/ResourceLocator/ResourceLocatorDropdown.vue';
import ParameterIssues from '@/components/ParameterIssues.vue';
import { onClickOutside } from '@vueuse/core';
import { useRouter } from 'vue-router';
import { useWorkflowResourceLocatorDropdown } from './useWorkflowResourceLocatorDropdown';
import { useWorkflowResourceLocatorModes } from './useWorkflowResourceLocatorModes';
import { useWorkflowResourcesLocator } from './useWorkflowResourcesLocator';
interface Props {
modelValue: INodeParameterResourceLocator;
eventBus?: EventBus;
inputSize: 'small' | 'mini' | 'medium' | 'large' | 'xlarge';
isValueExpression?: boolean;
isReadOnly?: boolean;
path: string;
expressionDisplayValue?: string;
forceShowExpression?: boolean;
parameterIssues?: string[];
parameter: INodeProperties;
}
const props = withDefaults(defineProps<Props>(), {
eventBus: () => createEventBus(),
inputSize: 'small',
isValueExpression: false,
isReadOnly: false,
forceShowExpression: false,
expressionDisplayValue: '',
parameterIssues: () => [],
});
const emit = defineEmits<{
'update:modelValue': [value: INodeParameterResourceLocator];
drop: [data: string];
modalOpenerClick: [];
focus: [];
blur: [];
}>();
const router = useRouter();
const workflowsStore = useWorkflowsStore();
const i18n = useI18n();
const container = ref<HTMLDivElement>();
const dropdown = ref<ComponentInstance<typeof ResourceLocatorDropdown>>();
const width = ref(0);
const inputRef = ref<HTMLInputElement | undefined>();
const { isListMode, getUpdatedModePayload, selectedMode, supportedModes, getModeLabel } =
useWorkflowResourceLocatorModes(
computed(() => props.modelValue),
router,
);
const { hideDropdown, isDropdownVisible, showDropdown } = useWorkflowResourceLocatorDropdown(
isListMode,
inputRef,
);
const {
hasMoreWorkflowsToLoad,
isLoadingResources,
filteredResources,
onSearchFilter,
searchFilter,
getWorkflowName,
populateNextWorkflowsPage,
setWorkflowsResources,
getWorkflowUrl,
} = useWorkflowResourcesLocator(router);
const valueToDisplay = computed<NodeParameterValue>(() => {
if (typeof props.modelValue !== 'object') {
return props.modelValue;
}
if (isListMode.value) {
return props.modelValue ? props.modelValue.cachedResultName ?? props.modelValue.value : '';
}
return props.modelValue ? props.modelValue.value : '';
});
const placeholder = computed(() => {
if (isListMode.value) {
return i18n.baseText('resourceLocator.mode.list.placeholder');
}
return i18n.baseText('resourceLocator.id.placeholder');
});
function setWidth() {
const containerRef = container.value as HTMLElement | undefined;
if (containerRef) {
width.value = containerRef?.offsetWidth;
}
}
function onInputChange(value: string): void {
const params: INodeParameterResourceLocator = { __rl: true, value, mode: selectedMode.value };
if (isListMode.value) {
const resource = workflowsStore.getWorkflowById(value);
if (resource?.name) {
params.cachedResultName = getWorkflowName(value);
}
}
emit('update:modelValue', params);
}
function onListItemSelected(value: string) {
onInputChange(value);
hideDropdown();
}
function onInputFocus(): void {
setWidth();
showDropdown();
emit('focus');
}
function onInputBlur(): void {
emit('blur');
}
async function onDrop(data: string) {
emit('drop', data);
}
function onModeSwitched(mode: ResourceLocatorModes) {
emit('update:modelValue', getUpdatedModePayload(mode));
}
function onKeyDown(e: KeyboardEvent) {
if (isDropdownVisible.value) {
props.eventBus.emit('keyDown', e);
}
}
function openWorkflow() {
window.open(getWorkflowUrl(props.modelValue.value?.toString() ?? ''), '_blank');
}
onMounted(() => {
window.addEventListener('resize', setWidth);
setWidth();
void setWorkflowsResources();
});
onUnmounted(() => {
window.removeEventListener('resize', setWidth);
});
watch(
() => props.isValueExpression,
(isValueExpression) => {
// Expressions are always in ID mode
if (isValueExpression) {
onModeSwitched('id');
}
},
);
onClickOutside(dropdown, () => {
isDropdownVisible.value = false;
});
</script>
<template>
<div
ref="container"
:class="$style.container"
:data-test-id="`resource-locator-${parameter.name}`"
>
<ResourceLocatorDropdown
ref="dropdown"
:show="isDropdownVisible"
:filterable="true"
:filter-required="false"
:resources="filteredResources"
:loading="isLoadingResources"
:filter="searchFilter"
:has-more="hasMoreWorkflowsToLoad"
:error-view="false"
:width="width"
:event-bus="eventBus"
@update:model-value="onListItemSelected"
@filter="onSearchFilter"
@load-more="populateNextWorkflowsPage"
>
<template #error>
<div :class="$style.error" data-test-id="rlc-error-container">
<n8n-text color="text-dark" align="center" tag="div">
{{ i18n.baseText('resourceLocator.mode.list.error.title') }}
</n8n-text>
</div>
</template>
<div
:class="{
[$style.resourceLocator]: true,
[$style.multipleModes]: true,
}"
>
<div :class="$style.background"></div>
<div :class="$style.modeSelector">
<n8n-select
:model-value="selectedMode"
:size="inputSize"
:disabled="isReadOnly"
:placeholder="i18n.baseText('resourceLocator.modeSelector.placeholder')"
data-test-id="rlc-mode-selector"
@update:model-value="onModeSwitched"
>
<n8n-option
v-for="mode in supportedModes"
:key="mode.name"
:value="mode.name"
:label="getModeLabel(mode)"
:disabled="isValueExpression && mode.name === 'list'"
:title="
isValueExpression && mode.name === 'list'
? i18n.baseText('resourceLocator.mode.list.disabled.title')
: ''
"
>
{{ getModeLabel(mode) }}
</n8n-option>
</n8n-select>
</div>
<div :class="$style.inputContainer" data-test-id="rlc-input-container">
<DraggableTarget
type="mapping"
:sticky="true"
:sticky-offset="isValueExpression ? [26, 3] : [3, 3]"
@drop="onDrop"
>
<template #default="{ droppable, activeDrop }">
<div
:class="{
[$style.listModeInputContainer]: isListMode,
[$style.droppable]: droppable,
[$style.activeDrop]: activeDrop,
}"
@keydown.stop="onKeyDown"
>
<ExpressionParameterInput
v-if="isValueExpression || forceShowExpression"
ref="input"
:model-value="expressionDisplayValue"
:path="path"
:rows="3"
@update:model-value="onInputChange"
@modal-opener-click="emit('modalOpenerClick')"
/>
<n8n-input
v-else
ref="input"
:class="{ [$style.selectInput]: isListMode }"
:size="inputSize"
:model-value="valueToDisplay"
:disabled="isReadOnly"
:readonly="isListMode"
:placeholder="placeholder"
type="text"
data-test-id="rlc-input"
@update:model-value="onInputChange"
@click="showDropdown"
@focus="onInputFocus"
@blur="onInputBlur"
>
<template v-if="isListMode" #suffix>
<i
:class="{
['el-input__icon']: true,
['el-icon-arrow-down']: true,
[$style.selectIcon]: true,
[$style.isReverse]: isDropdownVisible,
}"
/>
</template>
</n8n-input>
</div>
</template>
</DraggableTarget>
<ParameterIssues
v-if="parameterIssues && parameterIssues.length"
:issues="parameterIssues"
:class="$style['parameter-issues']"
/>
<div v-if="!isValueExpression && modelValue.value" :class="$style.openResourceLink">
<n8n-link theme="text" @click.stop="openWorkflow()">
<font-awesome-icon icon="external-link-alt" :title="'Open resource link'" />
</n8n-link>
</div>
</div>
</div>
</ResourceLocatorDropdown>
</div>
</template>
<style lang="scss" module>
@import '@/components/ResourceLocator/resourceLocator.scss';
</style>

View file

@ -0,0 +1,34 @@
import type { Ref } from 'vue';
import { nextTick, ref } from 'vue';
export function useWorkflowResourceLocatorDropdown(
isListMode: Ref<boolean>,
inputRef: Ref<HTMLInputElement | undefined>,
) {
const isDropdownVisible = ref(false);
const resourceDropdownHiding = ref(false);
function showDropdown() {
if (!isListMode.value || resourceDropdownHiding.value) {
return;
}
isDropdownVisible.value = true;
}
function hideDropdown() {
isDropdownVisible.value = false;
resourceDropdownHiding.value = true;
void nextTick(() => {
inputRef.value?.blur?.();
resourceDropdownHiding.value = false;
});
}
return {
isDropdownVisible,
showDropdown,
hideDropdown,
};
}

View file

@ -0,0 +1,67 @@
import type { Ref } from 'vue';
import { computed } from 'vue';
import { useI18n } from '@/composables/useI18n';
import type {
INodeParameterResourceLocator,
INodePropertyMode,
ResourceLocatorModes,
} from 'n8n-workflow';
import type { Router } from 'vue-router';
import { useWorkflowResourcesLocator } from './useWorkflowResourcesLocator';
export function useWorkflowResourceLocatorModes(
modelValue: Ref<INodeParameterResourceLocator>,
router: Router,
) {
const i18n = useI18n();
const { getWorkflowName } = useWorkflowResourcesLocator(router);
const supportedModes = computed<INodePropertyMode[]>(() => [
{
name: 'list',
type: 'list',
displayName: i18n.baseText('resourceLocator.mode.list'),
},
{
type: 'string',
name: 'id',
displayName: i18n.baseText('resourceLocator.mode.id'),
},
]);
const selectedMode = computed(() => modelValue.value?.mode || 'list');
const isListMode = computed(() => selectedMode.value === 'list');
function getUpdatedModePayload(value: ResourceLocatorModes): INodeParameterResourceLocator {
if (typeof modelValue !== 'object') {
return { __rl: true, value: modelValue, mode: value };
}
if (value === 'id' && selectedMode.value === 'list' && modelValue.value.value) {
return { __rl: true, mode: value, value: modelValue.value.value };
}
return {
__rl: true,
mode: value,
value: modelValue.value.value,
cachedResultName: getWorkflowName(modelValue.value.value?.toString() ?? ''),
};
}
function getModeLabel(mode: INodePropertyMode): string | null {
if (mode.name === 'id' || mode.name === 'list') {
return i18n.baseText(`resourceLocator.mode.${mode.name}`);
}
return mode.displayName;
}
return {
supportedModes,
selectedMode,
isListMode,
getUpdatedModePayload,
getModeLabel,
};
}

View file

@ -0,0 +1,93 @@
import { ref, computed } from 'vue';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { sortBy } from 'lodash-es';
import type { Router } from 'vue-router';
import { VIEWS } from '@/constants';
import type { IWorkflowDb } from '@/Interface';
export function useWorkflowResourcesLocator(router: Router) {
const workflowsStore = useWorkflowsStore();
const workflowsResources = ref<Array<{ name: string; value: string; url: string }>>([]);
const isLoadingResources = ref(true);
const searchFilter = ref('');
const PAGE_SIZE = 40;
const sortedWorkflows = computed(() =>
sortBy(workflowsStore.allWorkflows, (workflow) =>
new Date(workflow.updatedAt).valueOf(),
).reverse(),
);
const hasMoreWorkflowsToLoad = computed(
() => workflowsStore.allWorkflows.length > workflowsResources.value.length,
);
const filteredResources = computed(() => {
if (!searchFilter.value) return workflowsResources.value;
return workflowsStore.allWorkflows
.filter((resource) => resource.name.toLowerCase().includes(searchFilter.value.toLowerCase()))
.map(workflowDbToResourceMapper);
});
async function populateNextWorkflowsPage() {
if (workflowsStore.allWorkflows.length <= 1) {
await workflowsStore.fetchAllWorkflows();
}
const nextPage = sortedWorkflows.value.slice(
workflowsResources.value.length,
workflowsResources.value.length + PAGE_SIZE,
);
workflowsResources.value.push(...nextPage.map(workflowDbToResourceMapper));
}
async function setWorkflowsResources() {
isLoadingResources.value = true;
await populateNextWorkflowsPage();
isLoadingResources.value = false;
}
function workflowDbToResourceMapper(workflow: IWorkflowDb) {
return {
name: getWorkflowName(workflow.id),
value: workflow.id,
url: getWorkflowUrl(workflow.id),
};
}
function getWorkflowUrl(workflowId: string) {
const { href } = router.resolve({ name: VIEWS.WORKFLOW, params: { name: workflowId } });
return href;
}
function getWorkflowName(id: string): string {
const workflow = workflowsStore.getWorkflowById(id);
if (workflow) {
// Add the project name if it's not a personal project
if (workflow.homeProject && workflow.homeProject.type !== 'personal') {
return `${workflow.homeProject.name}${workflow.name}`;
}
return workflow.name;
}
return id;
}
function onSearchFilter(filter: string) {
searchFilter.value = filter;
}
return {
workflowsResources,
isLoadingResources,
hasMoreWorkflowsToLoad,
filteredResources,
searchFilter,
getWorkflowUrl,
onSearchFilter,
getWorkflowName,
populateNextWorkflowsPage,
setWorkflowsResources,
};
}

View file

@ -16,7 +16,7 @@ export class ExecuteWorkflow implements INodeType {
icon: 'fa:sign-in-alt', icon: 'fa:sign-in-alt',
iconColor: 'orange-red', iconColor: 'orange-red',
group: ['transform'], group: ['transform'],
version: 1, version: [1, 1.1],
subtitle: '={{"Workflow: " + $parameter["workflowId"]}}', subtitle: '={{"Workflow: " + $parameter["workflowId"]}}',
description: 'Execute another workflow', description: 'Execute another workflow',
defaults: { defaults: {
@ -79,6 +79,7 @@ export class ExecuteWorkflow implements INodeType {
displayOptions: { displayOptions: {
show: { show: {
source: ['database'], source: ['database'],
'@version': [1],
}, },
}, },
default: '', default: '',
@ -87,7 +88,20 @@ export class ExecuteWorkflow implements INodeType {
description: description:
"Note on using an expression here: if this node is set to run once with all items, they will all be sent to the <em>same</em> workflow. That workflow's ID will be calculated by evaluating the expression for the <strong>first input item</strong>.", "Note on using an expression here: if this node is set to run once with all items, they will all be sent to the <em>same</em> workflow. That workflow's ID will be calculated by evaluating the expression for the <strong>first input item</strong>.",
}, },
{
displayName: 'Workflow',
name: 'workflowId',
type: 'workflowSelector',
displayOptions: {
show: {
source: ['database'],
'@version': [{ _cnd: { gte: 1.1 } }],
},
},
default: '',
required: true,
hint: "Note on using an expression here: if this node is set to run once with all items, they will all be sent to the <em>same</em> workflow. That workflow's ID will be calculated by evaluating the expression for the <strong>first input item</strong>.",
},
// ---------------------------------- // ----------------------------------
// source:localFile // source:localFile
// ---------------------------------- // ----------------------------------

View file

@ -1,13 +1,27 @@
import { readFile as fsReadFile } from 'fs/promises'; import { readFile as fsReadFile } from 'fs/promises';
import { NodeOperationError, jsonParse } from 'n8n-workflow'; import { NodeOperationError, jsonParse } from 'n8n-workflow';
import type { IExecuteFunctions, IExecuteWorkflowInfo, IRequestOptions } from 'n8n-workflow'; import type {
IExecuteFunctions,
IExecuteWorkflowInfo,
INodeParameterResourceLocator,
IRequestOptions,
} from 'n8n-workflow';
export async function getWorkflowInfo(this: IExecuteFunctions, source: string, itemIndex = 0) { export async function getWorkflowInfo(this: IExecuteFunctions, source: string, itemIndex = 0) {
const workflowInfo: IExecuteWorkflowInfo = {}; const workflowInfo: IExecuteWorkflowInfo = {};
const nodeVersion = this.getNode().typeVersion;
if (source === 'database') { if (source === 'database') {
// Read workflow from database // Read workflow from database
workflowInfo.id = this.getNodeParameter('workflowId', itemIndex) as string; if (nodeVersion === 1) {
workflowInfo.id = this.getNodeParameter('workflowId', itemIndex) as string;
} else {
const { value } = this.getNodeParameter(
'workflowId',
itemIndex,
{},
) as INodeParameterResourceLocator;
workflowInfo.id = value as string;
}
} else if (source === 'localFile') { } else if (source === 'localFile') {
// Read workflow from filesystem // Read workflow from filesystem
const workflowPath = this.getNodeParameter('workflowPath', itemIndex) as string; const workflowPath = this.getNodeParameter('workflowPath', itemIndex) as string;

View file

@ -1204,7 +1204,8 @@ export type NodePropertyTypes =
| 'resourceMapper' | 'resourceMapper'
| 'filter' | 'filter'
| 'assignmentCollection' | 'assignmentCollection'
| 'credentials'; | 'credentials'
| 'workflowSelector';
export type CodeAutocompleteTypes = 'function' | 'functionItem'; export type CodeAutocompleteTypes = 'function' | 'functionItem';

View file

@ -1580,7 +1580,7 @@ export function addToIssuesIfMissing(
(nodeProperties.type === 'multiOptions' && Array.isArray(value) && value.length === 0) || (nodeProperties.type === 'multiOptions' && Array.isArray(value) && value.length === 0) ||
(nodeProperties.type === 'dateTime' && value === undefined) || (nodeProperties.type === 'dateTime' && value === undefined) ||
(nodeProperties.type === 'options' && (value === '' || value === undefined)) || (nodeProperties.type === 'options' && (value === '' || value === undefined)) ||
(nodeProperties.type === 'resourceLocator' && ((nodeProperties.type === 'resourceLocator' || nodeProperties.type === 'workflowSelector') &&
!isValidResourceLocatorParameterValue(value as INodeParameterResourceLocator)) !isValidResourceLocatorParameterValue(value as INodeParameterResourceLocator))
) { ) {
// Parameter is required but empty // Parameter is required but empty
@ -1654,7 +1654,10 @@ export function getParameterIssues(
} }
} }
if (nodeProperties.type === 'resourceLocator' && isDisplayed) { if (
(nodeProperties.type === 'resourceLocator' || nodeProperties.type === 'workflowSelector') &&
isDisplayed
) {
const value = getParameterValueByPath(nodeValues, nodeProperties.name, path); const value = getParameterValueByPath(nodeValues, nodeProperties.name, path);
if (isINodeParameterResourceLocator(value)) { if (isINodeParameterResourceLocator(value)) {
const mode = nodeProperties.modes?.find((option) => option.name === value.mode); const mode = nodeProperties.modes?.find((option) => option.name === value.mode);