mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-30 06:59:41 -08:00
feat(editor): Add option to create sub workflow from workflows list in Execute Workflow
node (#11706)
This commit is contained in:
parent
b38ce14ec9
commit
c265d44841
|
@ -27,7 +27,7 @@ describe('Workflow Selector Parameter', () => {
|
|||
getVisiblePopper()
|
||||
.should('have.length', 1)
|
||||
.findChildByTestId('rlc-item')
|
||||
.should('have.length', 2);
|
||||
.should('have.length', 3);
|
||||
});
|
||||
|
||||
it('should show required parameter warning', () => {
|
||||
|
@ -44,7 +44,8 @@ describe('Workflow Selector Parameter', () => {
|
|||
getVisiblePopper()
|
||||
.should('have.length', 1)
|
||||
.findChildByTestId('rlc-item')
|
||||
.should('have.length', 1)
|
||||
.should('have.length', 2)
|
||||
.eq(1)
|
||||
.click();
|
||||
|
||||
ndv.getters
|
||||
|
@ -57,7 +58,7 @@ describe('Workflow Selector Parameter', () => {
|
|||
ndv.getters.resourceLocator('workflowId').should('be.visible');
|
||||
ndv.getters.resourceLocatorInput('workflowId').click();
|
||||
|
||||
getVisiblePopper().findChildByTestId('rlc-item').first().click();
|
||||
getVisiblePopper().findChildByTestId('rlc-item').eq(1).click();
|
||||
|
||||
ndv.getters.resourceLocatorInput('workflowId').find('a').should('exist');
|
||||
cy.getByTestId('radio-button-expression').eq(1).click();
|
||||
|
@ -68,7 +69,7 @@ describe('Workflow Selector Parameter', () => {
|
|||
ndv.getters.resourceLocator('workflowId').should('be.visible');
|
||||
ndv.getters.resourceLocatorInput('workflowId').click();
|
||||
|
||||
getVisiblePopper().findChildByTestId('rlc-item').first().click();
|
||||
getVisiblePopper().findChildByTestId('rlc-item').eq(1).click();
|
||||
ndv.getters
|
||||
.resourceLocatorModeSelector('workflowId')
|
||||
.find('input')
|
||||
|
@ -79,4 +80,24 @@ describe('Workflow Selector Parameter', () => {
|
|||
.find('input')
|
||||
.should('have.value', 'By ID');
|
||||
});
|
||||
|
||||
it('should render add resource option and redirect to the correct route when clicked', () => {
|
||||
cy.window().then((win) => {
|
||||
cy.stub(win, 'open').as('windowOpen');
|
||||
});
|
||||
|
||||
ndv.getters.resourceLocator('workflowId').should('be.visible');
|
||||
ndv.getters.resourceLocatorInput('workflowId').click();
|
||||
|
||||
getVisiblePopper().findChildByTestId('rlc-item').eq(0).should('exist');
|
||||
getVisiblePopper()
|
||||
.findChildByTestId('rlc-item')
|
||||
.eq(0)
|
||||
.find('span')
|
||||
.should('have.text', 'Create a new sub-workflow');
|
||||
|
||||
getVisiblePopper().findChildByTestId('rlc-item').eq(0).click();
|
||||
|
||||
cy.get('@windowOpen').should('be.calledWith', '/workflows/onboarding/0?sampleSubWorkflows=0');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -239,6 +239,10 @@ export interface IWorkflowDataUpdate {
|
|||
meta?: WorkflowMetadata;
|
||||
}
|
||||
|
||||
export interface IWorkflowDataCreate extends IWorkflowDataUpdate {
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export interface IWorkflowToShare extends IWorkflowDataUpdate {
|
||||
meta: WorkflowMetadata;
|
||||
}
|
||||
|
|
|
@ -22,6 +22,9 @@ type Props = {
|
|||
filterRequired?: boolean;
|
||||
width?: number;
|
||||
eventBus?: EventBus;
|
||||
allowNewResources?: {
|
||||
label?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
|
@ -35,6 +38,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
errorView: false,
|
||||
filterRequired: false,
|
||||
width: undefined,
|
||||
allowNewResources: () => ({}),
|
||||
eventBus: () => createEventBus(),
|
||||
});
|
||||
|
||||
|
@ -42,6 +46,7 @@ const emit = defineEmits<{
|
|||
'update:modelValue': [value: NodeParameterValue];
|
||||
loadMore: [];
|
||||
filter: [filter: string];
|
||||
addResourceClick: [];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
@ -225,7 +230,7 @@ function onResultsEnd() {
|
|||
{{ i18n.baseText('resourceLocator.mode.list.searchRequired') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!errorView && sortedResources.length === 0 && !loading"
|
||||
v-else-if="!errorView && !allowNewResources.label && sortedResources.length === 0 && !loading"
|
||||
:class="$style.messageContainer"
|
||||
>
|
||||
{{ i18n.baseText('resourceLocator.mode.list.noResults') }}
|
||||
|
@ -236,6 +241,24 @@ function onResultsEnd() {
|
|||
:class="$style.container"
|
||||
@scroll="onResultsEnd"
|
||||
>
|
||||
<div
|
||||
v-if="allowNewResources.label"
|
||||
key="addResourceKey"
|
||||
ref="itemsRef"
|
||||
data-test-id="rlc-item"
|
||||
:class="{
|
||||
[$style.resourceItem]: true,
|
||||
[$style.hovering]: hoverIndex === 0,
|
||||
}"
|
||||
@mouseenter="() => onItemHover(0)"
|
||||
@mouseleave="() => onItemHoverLeave()"
|
||||
@click="() => emit('addResourceClick')"
|
||||
>
|
||||
<div :class="$style.resourceNameContainer">
|
||||
<span :class="$style.addResourceText">{{ allowNewResources.label }}</span>
|
||||
<font-awesome-icon :class="$style.addResourceIcon" :icon="['fa', 'plus']" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="(result, i) in sortedResources"
|
||||
:key="result.value.toString()"
|
||||
|
@ -243,11 +266,11 @@ function onResultsEnd() {
|
|||
:class="{
|
||||
[$style.resourceItem]: true,
|
||||
[$style.selected]: result.value === modelValue,
|
||||
[$style.hovering]: hoverIndex === i,
|
||||
[$style.hovering]: hoverIndex === i + 1,
|
||||
}"
|
||||
data-test-id="rlc-item"
|
||||
@click="() => onItemClick(result.value)"
|
||||
@mouseenter="() => onItemHover(i)"
|
||||
@mouseenter="() => onItemHover(i + 1)"
|
||||
@mouseleave="() => onItemHoverLeave()"
|
||||
>
|
||||
<div :class="$style.resourceNameContainer">
|
||||
|
@ -255,7 +278,7 @@ function onResultsEnd() {
|
|||
</div>
|
||||
<div :class="$style.urlLink">
|
||||
<font-awesome-icon
|
||||
v-if="showHoverUrl && result.url && hoverIndex === i"
|
||||
v-if="showHoverUrl && result.url && hoverIndex === i + 1"
|
||||
icon="external-link-alt"
|
||||
:title="result.linkAlt || i18n.baseText('resourceLocator.mode.list.openUrl')"
|
||||
@click="openUrl($event, result.url)"
|
||||
|
@ -384,4 +407,14 @@ function onResultsEnd() {
|
|||
.searchIcon {
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
.addResourceText {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.addResourceIcon {
|
||||
color: var(--color-text-light);
|
||||
|
||||
margin-left: var(--spacing-2xs);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -18,6 +18,9 @@ import { useRouter } from 'vue-router';
|
|||
import { useWorkflowResourceLocatorDropdown } from './useWorkflowResourceLocatorDropdown';
|
||||
import { useWorkflowResourceLocatorModes } from './useWorkflowResourceLocatorModes';
|
||||
import { useWorkflowResourcesLocator } from './useWorkflowResourcesLocator';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { NEW_SAMPLE_WORKFLOW_CREATED_CHANNEL, SAMPLE_SUBWORKFLOW_WORKFLOW_ID } from '@/constants';
|
||||
|
||||
interface Props {
|
||||
modelValue: INodeParameterResourceLocator;
|
||||
|
@ -50,11 +53,14 @@ const emit = defineEmits<{
|
|||
blur: [];
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const projectStore = useProjectsStore();
|
||||
|
||||
const router = useRouter();
|
||||
const i18n = useI18n();
|
||||
const container = ref<HTMLDivElement>();
|
||||
const dropdown = ref<ComponentInstance<typeof ResourceLocatorDropdown>>();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const width = ref(0);
|
||||
const inputRef = ref<HTMLInputElement | undefined>();
|
||||
|
@ -73,14 +79,35 @@ const {
|
|||
hasMoreWorkflowsToLoad,
|
||||
isLoadingResources,
|
||||
filteredResources,
|
||||
onSearchFilter,
|
||||
searchFilter,
|
||||
onSearchFilter,
|
||||
getWorkflowName,
|
||||
populateNextWorkflowsPage,
|
||||
setWorkflowsResources,
|
||||
reloadWorkflows,
|
||||
getWorkflowUrl,
|
||||
} = useWorkflowResourcesLocator(router);
|
||||
|
||||
const currentProjectName = computed(() => {
|
||||
if (!projectStore.isTeamProjectFeatureEnabled) return '';
|
||||
|
||||
if (!projectStore?.currentProject || projectStore.currentProject?.type === 'personal') {
|
||||
return `'${i18n.baseText('projects.menu.personal')}'`;
|
||||
}
|
||||
|
||||
return `'${projectStore.currentProject?.name}'`;
|
||||
});
|
||||
|
||||
const getCreateResourceLabel = computed(() => {
|
||||
if (!currentProjectName.value) {
|
||||
return i18n.baseText('executeWorkflowTrigger.createNewSubworkflow.noProject');
|
||||
}
|
||||
|
||||
return i18n.baseText('executeWorkflowTrigger.createNewSubworkflow', {
|
||||
interpolate: { projectName: currentProjectName.value },
|
||||
});
|
||||
});
|
||||
|
||||
const valueToDisplay = computed<NodeParameterValue>(() => {
|
||||
if (typeof props.modelValue !== 'object') {
|
||||
return props.modelValue;
|
||||
|
@ -122,13 +149,13 @@ function onInputChange(value: NodeParameterValue): void {
|
|||
}
|
||||
|
||||
function onListItemSelected(value: NodeParameterValue) {
|
||||
telemetry.track('User chose sub-workflow', {}, { withPostHog: true });
|
||||
onInputChange(value);
|
||||
hideDropdown();
|
||||
}
|
||||
|
||||
function onInputFocus(): void {
|
||||
setWidth();
|
||||
showDropdown();
|
||||
emit('focus');
|
||||
}
|
||||
|
||||
|
@ -176,8 +203,39 @@ watch(
|
|||
onClickOutside(dropdown, () => {
|
||||
isDropdownVisible.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
const onAddResourceClicked = () => {
|
||||
const subWorkflowNameRegex = /My\s+Sub-Workflow\s+\d+/;
|
||||
|
||||
const urlSearchParams = new URLSearchParams();
|
||||
|
||||
if (projectStore.currentProjectId) {
|
||||
urlSearchParams.set('projectId', projectStore.currentProjectId);
|
||||
}
|
||||
|
||||
const sampleSubWorkflows = workflowsStore.allWorkflows.filter(
|
||||
(w) => w.name && subWorkflowNameRegex.test(w.name),
|
||||
);
|
||||
|
||||
urlSearchParams.set('sampleSubWorkflows', sampleSubWorkflows.length.toString());
|
||||
|
||||
telemetry.track('User clicked create new sub-workflow button', {}, { withPostHog: true });
|
||||
|
||||
const sampleSubworkflowChannel = new BroadcastChannel(NEW_SAMPLE_WORKFLOW_CREATED_CHANNEL);
|
||||
|
||||
sampleSubworkflowChannel.onmessage = async (event: MessageEvent<{ workflowId: string }>) => {
|
||||
const workflowId = event.data.workflowId;
|
||||
await reloadWorkflows();
|
||||
onInputChange(workflowId);
|
||||
hideDropdown();
|
||||
};
|
||||
|
||||
window.open(
|
||||
`/workflows/onboarding/${SAMPLE_SUBWORKFLOW_WORKFLOW_ID}?${urlSearchParams.toString()}`,
|
||||
'_blank',
|
||||
);
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
|
@ -194,11 +252,15 @@ onClickOutside(dropdown, () => {
|
|||
:filter="searchFilter"
|
||||
:has-more="hasMoreWorkflowsToLoad"
|
||||
:error-view="false"
|
||||
:allow-new-resources="{
|
||||
label: getCreateResourceLabel,
|
||||
}"
|
||||
:width="width"
|
||||
:event-bus="eventBus"
|
||||
@update:model-value="onListItemSelected"
|
||||
@filter="onSearchFilter"
|
||||
@load-more="populateNextWorkflowsPage"
|
||||
@add-resource-click="onAddResourceClicked"
|
||||
>
|
||||
<template #error>
|
||||
<div :class="$style.error" data-test-id="rlc-error-container">
|
||||
|
|
|
@ -24,17 +24,13 @@ export function useWorkflowResourcesLocator(router: Router) {
|
|||
);
|
||||
|
||||
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();
|
||||
}
|
||||
await workflowsStore.fetchAllWorkflows();
|
||||
const nextPage = sortedWorkflows.value.slice(
|
||||
workflowsResources.value.length,
|
||||
workflowsResources.value.length + PAGE_SIZE,
|
||||
|
@ -49,6 +45,12 @@ export function useWorkflowResourcesLocator(router: Router) {
|
|||
isLoadingResources.value = false;
|
||||
}
|
||||
|
||||
async function reloadWorkflows() {
|
||||
isLoadingResources.value = true;
|
||||
await workflowsStore.fetchAllWorkflows();
|
||||
isLoadingResources.value = false;
|
||||
}
|
||||
|
||||
function workflowDbToResourceMapper(workflow: IWorkflowDb) {
|
||||
return {
|
||||
name: getWorkflowName(workflow.id),
|
||||
|
@ -84,6 +86,7 @@ export function useWorkflowResourcesLocator(router: Router) {
|
|||
hasMoreWorkflowsToLoad,
|
||||
filteredResources,
|
||||
searchFilter,
|
||||
reloadWorkflows,
|
||||
getWorkflowUrl,
|
||||
onSearchFilter,
|
||||
getWorkflowName,
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import type {
|
||||
EnterpriseEditionFeatureKey,
|
||||
EnterpriseEditionFeatureValue,
|
||||
INodeUi,
|
||||
IWorkflowDataCreate,
|
||||
NodeCreatorOpenSource,
|
||||
} from './Interface';
|
||||
import { NodeConnectionType } from 'n8n-workflow';
|
||||
|
@ -872,3 +874,44 @@ export const CanvasNodeHandleKey =
|
|||
export const BROWSER_ID_STORAGE_KEY = 'n8n-browserId';
|
||||
|
||||
export const APP_MODALS_ELEMENT_ID = 'app-modals';
|
||||
|
||||
export const SAMPLE_SUBWORKFLOW_WORKFLOW_ID = '0';
|
||||
|
||||
export const NEW_SAMPLE_WORKFLOW_CREATED_CHANNEL = 'new-sample-sub-workflow-created';
|
||||
|
||||
export const SAMPLE_SUBWORKFLOW_WORKFLOW: IWorkflowDataCreate = {
|
||||
name: 'My Sub-Workflow',
|
||||
nodes: [
|
||||
{
|
||||
parameters: {},
|
||||
id: 'c055762a-8fe7-4141-a639-df2372f30060',
|
||||
name: 'Execute Workflow Trigger',
|
||||
type: 'n8n-nodes-base.executeWorkflowTrigger',
|
||||
position: [260, 340],
|
||||
},
|
||||
{
|
||||
parameters: {},
|
||||
id: 'b5942df6-0160-4ef7-965d-57583acdc8aa',
|
||||
name: 'Replace me with your logic',
|
||||
type: 'n8n-nodes-base.noOp',
|
||||
position: [520, 340],
|
||||
},
|
||||
] as INodeUi[],
|
||||
connections: {
|
||||
'Execute Workflow Trigger': {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
node: 'Replace me with your logic',
|
||||
type: NodeConnectionType.Main,
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
executionOrder: 'v1',
|
||||
},
|
||||
pinData: {},
|
||||
};
|
||||
|
|
|
@ -2708,5 +2708,7 @@
|
|||
"communityPlusModal.features.third.description": "Search and organize past workflow executions for easier review",
|
||||
"communityPlusModal.input.email.label": "Enter email to receive your license key",
|
||||
"communityPlusModal.button.skip": "Skip",
|
||||
"communityPlusModal.button.confirm": "Send me a free license key"
|
||||
"communityPlusModal.button.confirm": "Send me a free license key",
|
||||
"executeWorkflowTrigger.createNewSubworkflow": "Create a sub-workflow in {projectName}",
|
||||
"executeWorkflowTrigger.createNewSubworkflow.noProject": "Create a new sub-workflow"
|
||||
}
|
||||
|
|
|
@ -28,6 +28,7 @@ import type {
|
|||
WorkflowMetadata,
|
||||
IExecutionFlattedResponse,
|
||||
IWorkflowTemplateNode,
|
||||
IWorkflowDataCreate,
|
||||
} from '@/Interface';
|
||||
import { defineStore } from 'pinia';
|
||||
import type {
|
||||
|
@ -1390,13 +1391,18 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||
return response && unflattenExecutionData(response);
|
||||
}
|
||||
|
||||
async function createNewWorkflow(sendData: IWorkflowDataUpdate): Promise<IWorkflowDb> {
|
||||
/**
|
||||
* Creates a new workflow with the provided data.
|
||||
* Ensures that the new workflow is not active upon creation.
|
||||
* If the project ID is not provided in the data, it assigns the current project ID from the project store.
|
||||
*/
|
||||
async function createNewWorkflow(sendData: IWorkflowDataCreate): Promise<IWorkflowDb> {
|
||||
// make sure that the new ones are not active
|
||||
sendData.active = false;
|
||||
|
||||
const projectStore = useProjectsStore();
|
||||
|
||||
if (projectStore.currentProjectId) {
|
||||
if (!sendData.projectId && projectStore.currentProjectId) {
|
||||
(sendData as unknown as IDataObject).projectId = projectStore.currentProjectId;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,11 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import { useLoadingService } from '@/composables/useLoadingService';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { VIEWS } from '@/constants';
|
||||
import {
|
||||
NEW_SAMPLE_WORKFLOW_CREATED_CHANNEL,
|
||||
SAMPLE_SUBWORKFLOW_WORKFLOW,
|
||||
SAMPLE_SUBWORKFLOW_WORKFLOW_ID,
|
||||
VIEWS,
|
||||
} from '@/constants';
|
||||
import { useTemplatesStore } from '@/stores/templates.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import type { IWorkflowDataCreate } from '@/Interface';
|
||||
|
||||
const loadingService = useLoadingService();
|
||||
const templateStore = useTemplatesStore();
|
||||
|
@ -15,6 +21,11 @@ const route = useRoute();
|
|||
const i18n = useI18n();
|
||||
|
||||
const openWorkflowTemplate = async (templateId: string) => {
|
||||
if (templateId === SAMPLE_SUBWORKFLOW_WORKFLOW_ID) {
|
||||
await openSampleSubworkflow();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
loadingService.startLoading();
|
||||
const template = await templateStore.getFixedWorkflowTemplate(templateId);
|
||||
|
@ -52,6 +63,42 @@ const openWorkflowTemplate = async (templateId: string) => {
|
|||
}
|
||||
};
|
||||
|
||||
const openSampleSubworkflow = async () => {
|
||||
try {
|
||||
loadingService.startLoading();
|
||||
|
||||
const projectId = route.query?.projectId;
|
||||
|
||||
const sampleSubWorkflows = Number(route.query?.sampleSubWorkflows ?? 0);
|
||||
|
||||
const workflowName = `${SAMPLE_SUBWORKFLOW_WORKFLOW.name} ${sampleSubWorkflows + 1}`;
|
||||
|
||||
const workflow: IWorkflowDataCreate = {
|
||||
...SAMPLE_SUBWORKFLOW_WORKFLOW,
|
||||
name: workflowName,
|
||||
};
|
||||
|
||||
if (projectId) {
|
||||
workflow.projectId = projectId as string;
|
||||
}
|
||||
|
||||
const newWorkflow = await workflowsStore.createNewWorkflow(workflow);
|
||||
|
||||
const sampleSubworkflowChannel = new BroadcastChannel(NEW_SAMPLE_WORKFLOW_CREATED_CHANNEL);
|
||||
|
||||
sampleSubworkflowChannel.postMessage({ workflowId: newWorkflow.id });
|
||||
|
||||
await router.replace({
|
||||
name: VIEWS.WORKFLOW,
|
||||
params: { name: newWorkflow.id },
|
||||
});
|
||||
loadingService.stopLoading();
|
||||
} catch (e) {
|
||||
await router.replace({ name: VIEWS.NEW_WORKFLOW });
|
||||
loadingService.stopLoading();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
const templateId = route.params.id;
|
||||
if (!templateId || typeof templateId !== 'string') {
|
||||
|
|
Loading…
Reference in a new issue