feat(editor): Add option to create sub workflow from workflows list in Execute Workflow node (#11706)

This commit is contained in:
Ricardo Espinoza 2024-11-19 10:09:06 -05:00 committed by GitHub
parent b38ce14ec9
commit c265d44841
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 242 additions and 21 deletions

View file

@ -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');
});
});

View file

@ -239,6 +239,10 @@ export interface IWorkflowDataUpdate {
meta?: WorkflowMetadata;
}
export interface IWorkflowDataCreate extends IWorkflowDataUpdate {
projectId?: string;
}
export interface IWorkflowToShare extends IWorkflowDataUpdate {
meta: WorkflowMetadata;
}

View file

@ -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>

View file

@ -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">

View file

@ -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,

View file

@ -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: {},
};

View file

@ -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"
}

View file

@ -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;
}

View file

@ -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') {