mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
refactor(editor): Migrate mapper popover to ruka UI (#19564)
This commit is contained in:
@@ -244,6 +244,9 @@ describe('AI Assistant::enabled', () => {
|
||||
|
||||
ndv.getters.nodeExecuteButton().click();
|
||||
|
||||
// Wait for a message from AI to be shown
|
||||
aiAssistant.getters.chatMessagesAssistant().should('have.length', 3);
|
||||
|
||||
getEditor()
|
||||
.type('{selectall}')
|
||||
.paste(
|
||||
|
||||
@@ -113,4 +113,32 @@ describe('N8nPopoverReka', () => {
|
||||
|
||||
expect(wrapper.props('maxHeight')).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('auto-focus behavior', () => {
|
||||
it('should focus an element in the content slot by default', async () => {
|
||||
const wrapper = render(N8nPopoverReka, {
|
||||
props: { open: true },
|
||||
slots: {
|
||||
trigger: '<button />',
|
||||
content: '<input />',
|
||||
},
|
||||
});
|
||||
const popover = await wrapper.findByRole('dialog');
|
||||
|
||||
expect(popover.contains(document.activeElement)).toBe(true);
|
||||
});
|
||||
|
||||
it('should suppress auto-focus when suppressAutoFocus is true', async () => {
|
||||
const wrapper = render(N8nPopoverReka, {
|
||||
props: { open: true, suppressAutoFocus: true },
|
||||
slots: {
|
||||
trigger: '<button />',
|
||||
content: '<input />',
|
||||
},
|
||||
});
|
||||
const popover = await wrapper.findByRole('dialog');
|
||||
|
||||
expect(popover.contains(document.activeElement)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from 'reka-ui';
|
||||
import {
|
||||
PopoverContent,
|
||||
type PopoverContentProps,
|
||||
PopoverPortal,
|
||||
PopoverRoot,
|
||||
type PopoverRootProps,
|
||||
PopoverTrigger,
|
||||
} from 'reka-ui';
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import N8nScrollArea from '../N8nScrollArea/N8nScrollArea.vue';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
interface Props
|
||||
extends Pick<PopoverContentProps, 'side' | 'align' | 'sideFlip' | 'sideOffset' | 'reference'>,
|
||||
Pick<PopoverRootProps, 'open'> {
|
||||
/**
|
||||
* Whether to enable scrolling in the popover content
|
||||
*/
|
||||
enableScrolling?: boolean;
|
||||
/**
|
||||
* Whether to enable slide-in animation
|
||||
*/
|
||||
enableSlideIn?: boolean;
|
||||
/**
|
||||
* Whether to suppress auto-focus behavior when the content includes focusable element
|
||||
*/
|
||||
suppressAutoFocus?: boolean;
|
||||
/**
|
||||
* Scrollbar visibility behavior
|
||||
*/
|
||||
@@ -17,14 +34,18 @@ interface Props {
|
||||
* Popover width
|
||||
*/
|
||||
width?: string;
|
||||
/**
|
||||
* z-index of popover content
|
||||
*/
|
||||
zIndex?: number | CSSProperties['zIndex'];
|
||||
/**
|
||||
* Popover max height
|
||||
*/
|
||||
maxHeight?: string;
|
||||
/**
|
||||
* The preferred alignment against the trigger. May change when collisions occur.
|
||||
* Additional class name set to PopperContent
|
||||
*/
|
||||
align?: 'start' | 'center' | 'end';
|
||||
contentClass?: string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -32,15 +53,24 @@ interface Emits {
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
open: undefined,
|
||||
maxHeight: undefined,
|
||||
width: undefined,
|
||||
enableScrolling: true,
|
||||
enableSlideIn: true,
|
||||
scrollType: 'hover',
|
||||
align: undefined,
|
||||
sideOffset: 5,
|
||||
sideFlip: undefined,
|
||||
suppressAutoFocus: false,
|
||||
zIndex: 999,
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
function handleOpenAutoFocus(e: Event) {
|
||||
if (props.suppressAutoFocus) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -49,21 +79,29 @@ const emit = defineEmits<Emits>();
|
||||
<slot name="trigger"></slot>
|
||||
</PopoverTrigger>
|
||||
<PopoverPortal>
|
||||
<PopoverContent side="bottom" :align="align" :side-offset="5" :class="$style.popoverContent">
|
||||
<PopoverContent
|
||||
role="dialog"
|
||||
:side="side"
|
||||
:side-flip="sideFlip"
|
||||
:align="align"
|
||||
:side-offset="sideOffset"
|
||||
:class="[$style.popoverContent, contentClass, { [$style.enableSlideIn]: enableSlideIn }]"
|
||||
:style="{ width, zIndex }"
|
||||
:reference="reference"
|
||||
@open-auto-focus="handleOpenAutoFocus"
|
||||
>
|
||||
<N8nScrollArea
|
||||
v-if="enableScrolling"
|
||||
:max-height="props.maxHeight"
|
||||
:max-height="maxHeight"
|
||||
:type="scrollType"
|
||||
:enable-vertical-scroll="true"
|
||||
:enable-horizontal-scroll="false"
|
||||
>
|
||||
<div :style="{ width }">
|
||||
<slot name="content" :close="() => emit('update:open', false)" />
|
||||
</div>
|
||||
</N8nScrollArea>
|
||||
<div v-else :style="{ width }">
|
||||
<slot name="content" :close="() => emit('update:open', false)" />
|
||||
</div>
|
||||
</N8nScrollArea>
|
||||
<template v-else>
|
||||
<slot name="content" :close="() => emit('update:open', false)" />
|
||||
</template>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</PopoverRoot>
|
||||
@@ -77,10 +115,12 @@ const emit = defineEmits<Emits>();
|
||||
box-shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px,
|
||||
rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
animation-duration: 400ms;
|
||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
will-change: transform, opacity;
|
||||
z-index: 999;
|
||||
|
||||
&.enableSlideIn {
|
||||
animation-duration: 400ms;
|
||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.popoverContent[data-state='open'][data-side='top'] {
|
||||
|
||||
@@ -4,13 +4,11 @@ exports[`N8nPopoverReka > should render correctly with default props 1`] = `
|
||||
"<mock-popover-root>
|
||||
<mock-popover-trigger><button></button></mock-popover-trigger>
|
||||
<mock-popover-portal>
|
||||
<mock-popover-content>
|
||||
<mock-popover-content role="dialog">
|
||||
<div dir="ltr" style="position: relative; --reka-scroll-area-corner-width: 0px; --reka-scroll-area-corner-height: 0px;" class="scrollAreaRoot">
|
||||
<div data-reka-scroll-area-viewport="" style="overflow-x: hidden; overflow-y: hidden;" class="viewport" tabindex="0">
|
||||
<div>
|
||||
<div>
|
||||
<content></content>
|
||||
</div>
|
||||
<content></content>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
INodeTypeDescription,
|
||||
INodeIssues,
|
||||
ITaskData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { FORM_TRIGGER_NODE_TYPE, NodeConnectionTypes, NodeHelpers, Workflow } from 'n8n-workflow';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
@@ -217,6 +218,16 @@ export function createTestNode(node: Partial<INode> = {}): INode {
|
||||
};
|
||||
}
|
||||
|
||||
export function createTestNodeProperties(data: Partial<INodeProperties> = {}): INodeProperties {
|
||||
return {
|
||||
displayName: 'Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
default: '',
|
||||
...data,
|
||||
};
|
||||
}
|
||||
|
||||
export function createMockEnterpriseSettings(
|
||||
overrides: Partial<FrontendSettings['enterprise']> = {},
|
||||
): FrontendSettings['enterprise'] {
|
||||
|
||||
@@ -236,7 +236,6 @@ defineExpose({ focus, select });
|
||||
:segments="segments"
|
||||
:is-read-only="isReadOnly"
|
||||
:virtual-ref="container"
|
||||
:append-to="isInExperimentalNdv ? 'body' : undefined"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,7 +4,7 @@ import InlineExpressionEditorOutput from './InlineExpressionEditorOutput.vue';
|
||||
|
||||
describe('InlineExpressionEditorOutput.vue', () => {
|
||||
test('should render duplicate segments correctly', async () => {
|
||||
const { getByTestId } = renderComponent(InlineExpressionEditorOutput, {
|
||||
const rendered = renderComponent(InlineExpressionEditorOutput, {
|
||||
pinia: createTestingPinia(),
|
||||
props: {
|
||||
visible: true,
|
||||
@@ -48,11 +48,13 @@ describe('InlineExpressionEditorOutput.vue', () => {
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(getByTestId('inline-expression-editor-output')).toHaveTextContent('[1,2]');
|
||||
const body = await rendered.findByTestId('inline-expression-editor-output');
|
||||
|
||||
expect(body).toHaveTextContent('[1,2]');
|
||||
});
|
||||
|
||||
test('should render segments with resolved expressions', () => {
|
||||
const { getByTestId } = renderComponent(InlineExpressionEditorOutput, {
|
||||
test('should render segments with resolved expressions', async () => {
|
||||
const rendered = renderComponent(InlineExpressionEditorOutput, {
|
||||
pinia: createTestingPinia(),
|
||||
props: {
|
||||
visible: true,
|
||||
@@ -93,8 +95,8 @@ describe('InlineExpressionEditorOutput.vue', () => {
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(getByTestId('inline-expression-editor-output')).toHaveTextContent(
|
||||
'before> [Object: "2024-04-18T09:03:26.651-04:00"] <after',
|
||||
);
|
||||
const body = await rendered.findByTestId('inline-expression-editor-output');
|
||||
|
||||
expect(body).toHaveTextContent('before> [Object: "2024-04-18T09:03:26.651-04:00"] <after');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,13 +4,13 @@ import type { EditorState, SelectionRange } from '@codemirror/state';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import type { Segment } from '@/types/expressions';
|
||||
import { computed, onBeforeUnmount, useTemplateRef } from 'vue';
|
||||
import { onBeforeUnmount, useTemplateRef } from 'vue';
|
||||
import ExpressionOutput from './ExpressionOutput.vue';
|
||||
import OutputItemSelect from './OutputItemSelect.vue';
|
||||
import InlineExpressionTip from './InlineExpressionTip.vue';
|
||||
import { outputTheme } from './theme';
|
||||
import { useElementSize } from '@vueuse/core';
|
||||
import { N8nPopover } from '@n8n/design-system';
|
||||
import { N8nPopoverReka, N8nText } from '@n8n/design-system';
|
||||
import { useStyles } from '@/composables/useStyles';
|
||||
|
||||
interface InlineExpressionEditorOutputProps {
|
||||
segments: Segment[];
|
||||
@@ -20,10 +20,9 @@ interface InlineExpressionEditorOutputProps {
|
||||
isReadOnly?: boolean;
|
||||
visible: boolean;
|
||||
virtualRef?: HTMLElement;
|
||||
appendTo?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<InlineExpressionEditorOutputProps>(), {
|
||||
withDefaults(defineProps<InlineExpressionEditorOutputProps>(), {
|
||||
editorState: undefined,
|
||||
selection: undefined,
|
||||
isReadOnly: false,
|
||||
@@ -34,7 +33,7 @@ const i18n = useI18n();
|
||||
const theme = outputTheme();
|
||||
const ndvStore = useNDVStore();
|
||||
const contentRef = useTemplateRef('content');
|
||||
const virtualRefSize = useElementSize(computed(() => props.virtualRef));
|
||||
const { APP_Z_INDEXES } = useStyles();
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
ndvStore.expressionOutputItemIndex = 0;
|
||||
@@ -46,75 +45,60 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nPopover
|
||||
:visible="visible"
|
||||
placement="bottom"
|
||||
:show-arrow="false"
|
||||
:offset="0"
|
||||
:persistent="false"
|
||||
:virtual-triggering="virtualRef !== undefined"
|
||||
:virtual-ref="virtualRef"
|
||||
:width="virtualRefSize.width.value"
|
||||
:popper-class="`${$style.popper} ignore-key-press-canvas`"
|
||||
:popper-options="{
|
||||
modifiers: [
|
||||
{ name: 'flip', enabled: false },
|
||||
{
|
||||
// Ensures that the popover is re-positioned when the reference element is resized
|
||||
name: 'custom modifier',
|
||||
options: {
|
||||
width: virtualRefSize.width.value,
|
||||
height: virtualRefSize.height.value,
|
||||
},
|
||||
},
|
||||
],
|
||||
}"
|
||||
:append-to="appendTo"
|
||||
<N8nPopoverReka
|
||||
:open="visible"
|
||||
side="bottom"
|
||||
:side-flip="false"
|
||||
:side-offset="0"
|
||||
align="start"
|
||||
:reference="virtualRef"
|
||||
width="var(--reka-popper-anchor-width)"
|
||||
:content-class="$style.popover"
|
||||
:enable-slide-in="false"
|
||||
:enable-scrolling="false"
|
||||
:suppress-auto-focus="true"
|
||||
:z-index="APP_Z_INDEXES.NDV + 1"
|
||||
>
|
||||
<div ref="content" :class="$style.dropdown">
|
||||
<div :class="$style.header">
|
||||
<n8n-text bold size="small" compact>
|
||||
{{ i18n.baseText('parameterInput.result') }}
|
||||
</n8n-text>
|
||||
<template #content>
|
||||
<div ref="content" :class="[$style.dropdown, 'ignore-key-press-canvas']">
|
||||
<div :class="$style.header">
|
||||
<N8nText bold size="small" compact>
|
||||
{{ i18n.baseText('parameterInput.result') }}
|
||||
</N8nText>
|
||||
|
||||
<OutputItemSelect />
|
||||
<OutputItemSelect />
|
||||
</div>
|
||||
<N8nText :class="$style.body">
|
||||
<ExpressionOutput
|
||||
data-test-id="inline-expression-editor-output"
|
||||
:segments="segments"
|
||||
:extensions="theme"
|
||||
>
|
||||
</ExpressionOutput>
|
||||
</N8nText>
|
||||
<div v-if="!isReadOnly" :class="$style.footer">
|
||||
<InlineExpressionTip
|
||||
:editor-state="editorState"
|
||||
:selection="selection"
|
||||
:unresolved-expression="unresolvedExpression"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<n8n-text :class="$style.body">
|
||||
<ExpressionOutput
|
||||
data-test-id="inline-expression-editor-output"
|
||||
:segments="segments"
|
||||
:extensions="theme"
|
||||
>
|
||||
</ExpressionOutput>
|
||||
</n8n-text>
|
||||
<div v-if="!isReadOnly" :class="$style.footer">
|
||||
<InlineExpressionTip
|
||||
:editor-state="editorState"
|
||||
:selection="selection"
|
||||
:unresolved-expression="unresolvedExpression"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</N8nPopover>
|
||||
</template>
|
||||
</N8nPopoverReka>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.popper {
|
||||
background-color: transparent !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
|
||||
/* Override styles set for el-popper */
|
||||
word-break: normal;
|
||||
text-align: unset;
|
||||
.popover {
|
||||
border-top: none;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-code-background);
|
||||
border: var(--border-base);
|
||||
border-top: none;
|
||||
width: 100%;
|
||||
box-shadow: 0 2px 6px 0 rgba(#441c17, 0.1);
|
||||
border-bottom-left-radius: 4px;
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { useNDVStore } from '@/stores/ndv.store';
|
||||
import type { CompletionResult } from '@codemirror/autocomplete';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { waitFor, within } from '@testing-library/vue';
|
||||
import { fireEvent, waitFor, within } from '@testing-library/vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
createMockEnterpriseSettings,
|
||||
createTestNode,
|
||||
createTestWorkflowObject,
|
||||
createTestNodeProperties,
|
||||
} from '@/__tests__/mocks';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { NodeConnectionTypes, type INodeParameterResourceLocator } from 'n8n-workflow';
|
||||
@@ -680,11 +681,13 @@ describe('ParameterInput.vue', () => {
|
||||
global: { provide: { [ExpressionLocalResolveContextSymbol]: ctx } },
|
||||
props: {
|
||||
path: 'name',
|
||||
parameter: { displayName: 'Name', name: 'name', type: 'string' },
|
||||
parameter: createTestNodeProperties(),
|
||||
modelValue: '',
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.focusIn(rendered.container.querySelector('.parameter-input')!);
|
||||
|
||||
expect(rendered.queryByTestId('ndv-input-panel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -693,16 +696,13 @@ describe('ParameterInput.vue', () => {
|
||||
global: { provide: { [ExpressionLocalResolveContextSymbol]: ctx } },
|
||||
props: {
|
||||
path: 'name',
|
||||
parameter: {
|
||||
displayName: 'Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
typeOptions: { editor: 'sqlEditor' },
|
||||
},
|
||||
parameter: createTestNodeProperties({ typeOptions: { editor: 'sqlEditor' } }),
|
||||
modelValue: 'SELECT 1;',
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.focusIn(rendered.container.querySelector('.parameter-input')!);
|
||||
|
||||
expect(rendered.queryByTestId('ndv-input-panel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -711,11 +711,13 @@ describe('ParameterInput.vue', () => {
|
||||
global: { provide: { [ExpressionLocalResolveContextSymbol]: ctx } },
|
||||
props: {
|
||||
path: 'name',
|
||||
parameter: { displayName: 'Name', name: 'name', type: 'string' },
|
||||
parameter: createTestNodeProperties(),
|
||||
modelValue: '={{$today}}',
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.focusIn(rendered.container.querySelector('.parameter-input')!);
|
||||
|
||||
expect(rendered.queryByTestId('ndv-input-panel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -724,11 +726,13 @@ describe('ParameterInput.vue', () => {
|
||||
global: { provide: { [ExpressionLocalResolveContextSymbol]: ctx } },
|
||||
props: {
|
||||
path: 'name',
|
||||
parameter: { displayName: 'Name', name: 'name', type: 'string', isNodeSetting: true },
|
||||
parameter: createTestNodeProperties({ isNodeSetting: true }),
|
||||
modelValue: '',
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.focusIn(rendered.container.querySelector('.parameter-input')!);
|
||||
|
||||
expect(rendered.queryByTestId('ndv-input-panel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -737,11 +741,13 @@ describe('ParameterInput.vue', () => {
|
||||
global: { provide: { [ExpressionLocalResolveContextSymbol]: ctx } },
|
||||
props: {
|
||||
path: 'name',
|
||||
parameter: { displayName: 'Name', name: 'name', type: 'dateTime' },
|
||||
parameter: createTestNodeProperties({ type: 'dateTime' }),
|
||||
modelValue: '',
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.focusIn(rendered.container.querySelector('.parameter-input')!);
|
||||
|
||||
expect(rendered.queryByTestId('ndv-input-panel')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -194,21 +194,10 @@ describe('SqlEditor.vue', () => {
|
||||
await focusEditor(container);
|
||||
await userEvent.click(getByTestId(EXPRESSION_OUTPUT_TEST_ID));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
queryByTestId(EXPRESSION_OUTPUT_TEST_ID)?.closest('[aria-hidden=false]'),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
await waitFor(() => expect(queryByTestId(EXPRESSION_OUTPUT_TEST_ID)).toBeInTheDocument());
|
||||
|
||||
// Does hide output when clicking outside the container
|
||||
await userEvent.click(baseElement);
|
||||
|
||||
// NOTE: in testing, popover persists regardless of persist option.
|
||||
// See https://github.com/element-plus/element-plus/blob/2.4.3/packages/components/tooltip/src/content.vue#L83-L90
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
queryByTestId(EXPRESSION_OUTPUT_TEST_ID)?.closest('[aria-hidden=true]'),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
await waitFor(() => expect(queryByTestId(EXPRESSION_OUTPUT_TEST_ID)).not.toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -375,379 +375,3 @@ exports[`WhatsNewModal > should not render update button when no version updates
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`WhatsNewModal > should render with update button disabled 1`] = `
|
||||
<div
|
||||
class="article"
|
||||
data-test-id="whats-new-item-1"
|
||||
>
|
||||
<h2
|
||||
class="n8n-heading size-xlarge bold"
|
||||
>
|
||||
|
||||
Convert to sub-workflow
|
||||
|
||||
</h2>
|
||||
<div
|
||||
class="n8n-markdown markdown"
|
||||
>
|
||||
<!-- Needed to support YouTube player embeds. HTML rendered here is sanitized. -->
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
class="markdown"
|
||||
>
|
||||
<p>
|
||||
Large, monolithic workflows can slow things down. They’re harder to maintain, tougher to debug, and more difficult to scale. With sub-workflows, you can take a more modular approach, breaking up big workflows into smaller, manageable parts that are easier to reuse, test, understand, and explain.
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
Until now, creating sub-workflows required copying and pasting nodes manually, setting up a new workflow from scratch, and reconnecting everything by hand.
|
||||
<strong>
|
||||
Convert to sub-workflow
|
||||
</strong>
|
||||
allows you to simplify this process into a single action, so you can spend more time building and less time restructuring.
|
||||
</p>
|
||||
|
||||
|
||||
<h3>
|
||||
How it works
|
||||
</h3>
|
||||
|
||||
|
||||
<ol>
|
||||
|
||||
|
||||
<li>
|
||||
Highlight the nodes you want to convert to a sub-workflow. These must:
|
||||
|
||||
<ul>
|
||||
|
||||
|
||||
<li>
|
||||
Be fully connected, meaning no missing steps in between them
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
Start from a single starting node
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
End with a single node
|
||||
</li>
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
Right-click to open the context menu and select
|
||||
<strong>
|
||||
Convert to sub-workflow
|
||||
</strong>
|
||||
|
||||
|
||||
<ul>
|
||||
|
||||
|
||||
<li>
|
||||
Or use the shortcut:
|
||||
<code>
|
||||
Alt + X
|
||||
</code>
|
||||
</li>
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
n8n will:
|
||||
|
||||
<ul>
|
||||
|
||||
|
||||
<li>
|
||||
Open a new tab containing the selected nodes
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
Preserve all node parameters as-is
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
Replace the selected nodes in the original workflow with a
|
||||
<strong>
|
||||
Call My Sub-workflow
|
||||
</strong>
|
||||
node
|
||||
</li>
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
|
||||
</li>
|
||||
|
||||
|
||||
</ol>
|
||||
|
||||
|
||||
<p>
|
||||
<em>
|
||||
Note:
|
||||
</em>
|
||||
You will need to manually adjust the field types in the Start and Return nodes in the new sub-workflow.
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
This makes it easier to keep workflows modular, performant, and easier to maintain.
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
Learn more about
|
||||
<a
|
||||
href="https://docs.n8n.io/flow-logic/subworkflows/"
|
||||
target="_blank"
|
||||
>
|
||||
sub-workflows
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
This release contains performance improvements and bug fixes.
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
<iframe
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen=""
|
||||
frameborder="0"
|
||||
height="315"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
src="https://www.youtube-nocookie.com/embed/ZCuL2e4zC_4"
|
||||
title="YouTube video player"
|
||||
width="100%"
|
||||
/>
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
Fusce malesuada diam eget tincidunt ultrices. Mauris quis mauris mollis, venenatis risus ut.
|
||||
</p>
|
||||
|
||||
|
||||
<h2>
|
||||
Second level title
|
||||
</h2>
|
||||
|
||||
|
||||
<h3>
|
||||
Third level title
|
||||
</h3>
|
||||
|
||||
|
||||
<p>
|
||||
This
|
||||
<strong>
|
||||
is bold
|
||||
</strong>
|
||||
, this
|
||||
<em>
|
||||
in italics
|
||||
</em>
|
||||
.
|
||||
<br />
|
||||
|
||||
|
||||
<s>
|
||||
Strikethrough is also something we support
|
||||
</s>
|
||||
.
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
Here’s a peace of code:
|
||||
</p>
|
||||
|
||||
|
||||
<pre>
|
||||
<code>
|
||||
const props = defineProps<{
|
||||
modalName: string;
|
||||
data: {
|
||||
articleId: number;
|
||||
};
|
||||
}>();
|
||||
|
||||
</code>
|
||||
</pre>
|
||||
|
||||
|
||||
<p>
|
||||
Inline
|
||||
<code>
|
||||
code also works
|
||||
</code>
|
||||
withing text.
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
This is a list:
|
||||
</p>
|
||||
|
||||
|
||||
<ul>
|
||||
|
||||
|
||||
<li>
|
||||
first
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
second
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
third
|
||||
</li>
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
|
||||
<p>
|
||||
And this list is ordered
|
||||
</p>
|
||||
|
||||
|
||||
<ol>
|
||||
|
||||
|
||||
<li>
|
||||
foo
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
bar
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
qux
|
||||
</li>
|
||||
|
||||
|
||||
</ol>
|
||||
|
||||
|
||||
<p>
|
||||
Dividers:
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
Three or more…
|
||||
</p>
|
||||
|
||||
|
||||
<hr />
|
||||
|
||||
|
||||
<p>
|
||||
Hyphens
|
||||
</p>
|
||||
|
||||
|
||||
<hr />
|
||||
|
||||
|
||||
<p>
|
||||
Asterisks
|
||||
</p>
|
||||
|
||||
|
||||
<hr />
|
||||
|
||||
|
||||
<p>
|
||||
Underscores
|
||||
</p>
|
||||
|
||||
|
||||
<hr />
|
||||
|
||||
|
||||
<details>
|
||||
|
||||
|
||||
<summary>
|
||||
Fixes (4)
|
||||
</summary>
|
||||
|
||||
|
||||
<ul>
|
||||
|
||||
|
||||
<li>
|
||||
<strong>
|
||||
Credential Storage Issue
|
||||
</strong>
|
||||
Resolved an issue where credentials would occasionally become inaccessible after server restarts
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
<strong>
|
||||
Webhook Timeout Handling
|
||||
</strong>
|
||||
Fixed timeout issues with long-running webhook requests
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
<strong>
|
||||
Node Connection Validation
|
||||
</strong>
|
||||
Improved validation for node connections to prevent invalid workflow configurations
|
||||
</li>
|
||||
|
||||
|
||||
<li>
|
||||
<strong>
|
||||
Memory Leak in Execution Engine
|
||||
</strong>
|
||||
Fixed memory leak that could occur during long-running workflow executions
|
||||
</li>
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -3,13 +3,12 @@ import InputPanel from '@/components/InputPanel.vue';
|
||||
import { CanvasKey } from '@/constants';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { ElPopover } from 'element-plus';
|
||||
import { useVueFlow } from '@vue-flow/core';
|
||||
import { useCanvasStore } from '@/stores/canvas.store';
|
||||
import { onBeforeUnmount, watch } from 'vue';
|
||||
import type { Workflow } from 'n8n-workflow';
|
||||
import { computed, inject, ref, useTemplateRef } from 'vue';
|
||||
import { useElementBounding, useElementSize } from '@vueuse/core';
|
||||
import { computed, inject, useTemplateRef } from 'vue';
|
||||
import { N8nPopoverReka } from '@n8n/design-system';
|
||||
import { useStyles } from '@/composables/useStyles';
|
||||
|
||||
const { node, inputNodeName, visible, virtualRef } = defineProps<{
|
||||
workflow: Workflow;
|
||||
@@ -21,19 +20,15 @@ const { node, inputNodeName, visible, virtualRef } = defineProps<{
|
||||
|
||||
const contentRef = useTemplateRef('content');
|
||||
const ndvStore = useNDVStore();
|
||||
const vf = useVueFlow();
|
||||
const canvas = inject(CanvasKey, undefined);
|
||||
const isVisible = computed(() => visible && !canvas?.isPaneMoving.value);
|
||||
const isOnceVisible = ref(isVisible.value);
|
||||
const canvasStore = useCanvasStore();
|
||||
const contentElRef = computed(() => contentRef.value?.$el ?? null);
|
||||
const contentSize = useElementSize(contentElRef);
|
||||
const refBounding = useElementBounding(virtualRef);
|
||||
const { APP_Z_INDEXES } = useStyles();
|
||||
|
||||
watch(
|
||||
isVisible,
|
||||
(value) => {
|
||||
isOnceVisible.value = isOnceVisible.value || value;
|
||||
canvasStore.setSuppressInteraction(value);
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -49,74 +44,41 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElPopover
|
||||
:visible="isVisible"
|
||||
placement="left-start"
|
||||
:show-arrow="false"
|
||||
:popper-class="`${$style.component} ignore-key-press-canvas`"
|
||||
:width="360"
|
||||
:offset="8"
|
||||
append-to="body"
|
||||
:popper-options="{
|
||||
modifiers: [
|
||||
{ name: 'flip', enabled: false },
|
||||
{
|
||||
// Ensures that the popover is re-positioned when the reference element is resized
|
||||
name: 'custom modifier',
|
||||
options: {
|
||||
refX: refBounding.x.value,
|
||||
refY: refBounding.y.value,
|
||||
width: contentSize.width.value,
|
||||
height: contentSize?.height.value,
|
||||
},
|
||||
},
|
||||
],
|
||||
}"
|
||||
:persistent="isOnceVisible /* works like lazy initialization */"
|
||||
virtual-triggering
|
||||
:virtual-ref="virtualRef"
|
||||
<N8nPopoverReka
|
||||
:open="isVisible"
|
||||
side="left"
|
||||
:side-flip="false"
|
||||
align="start"
|
||||
width="360px"
|
||||
:max-height="`calc(100vh - var(--spacing-s) * 2)`"
|
||||
:reference="virtualRef"
|
||||
:suppress-auto-focus="true"
|
||||
:z-index="APP_Z_INDEXES.NDV + 1"
|
||||
>
|
||||
<InputPanel
|
||||
ref="content"
|
||||
:tabindex="-1"
|
||||
:class="$style.inputPanel"
|
||||
:style="{
|
||||
maxHeight: `calc(${vf.viewportRef.value?.offsetHeight ?? 0}px - var(--spacing-s) * 2)`,
|
||||
}"
|
||||
:workflow-object="workflow"
|
||||
:run-index="0"
|
||||
compact
|
||||
push-ref=""
|
||||
display-mode="schema"
|
||||
disable-display-mode-selection
|
||||
:active-node-name="node.name"
|
||||
:current-node-name="inputNodeName"
|
||||
:is-mapping-onboarded="ndvStore.isMappingOnboarded"
|
||||
:focused-mappable-input="ndvStore.focusedMappableInput"
|
||||
node-not-run-message-variant="simple"
|
||||
/>
|
||||
</ElPopover>
|
||||
<template #content>
|
||||
<InputPanel
|
||||
ref="content"
|
||||
:tabindex="-1"
|
||||
:class="[$style.inputPanel, 'ignore-key-press-canvas']"
|
||||
:workflow-object="workflow"
|
||||
:run-index="0"
|
||||
compact
|
||||
push-ref=""
|
||||
display-mode="schema"
|
||||
disable-display-mode-selection
|
||||
:active-node-name="node.name"
|
||||
:current-node-name="inputNodeName"
|
||||
:is-mapping-onboarded="ndvStore.isMappingOnboarded"
|
||||
:focused-mappable-input="ndvStore.focusedMappableInput"
|
||||
node-not-run-message-variant="simple"
|
||||
/>
|
||||
</template>
|
||||
</N8nPopoverReka>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.component {
|
||||
background-color: transparent !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
margin-top: -2px;
|
||||
|
||||
/* Override styles set for el-popper */
|
||||
word-break: normal;
|
||||
text-align: unset;
|
||||
}
|
||||
|
||||
.inputPanel {
|
||||
border: var(--border-base);
|
||||
border-width: 1px;
|
||||
background-color: var(--color-background-light);
|
||||
border-radius: var(--border-radius-large);
|
||||
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.05);
|
||||
background-color: transparent;
|
||||
padding: var(--spacing-2xs);
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
|
||||
@@ -19,6 +19,6 @@ export class FocusPanel {
|
||||
|
||||
getMapper(): Locator {
|
||||
// find from the entire page because the mapper is rendered as portal
|
||||
return this.root.page().getByRole('tooltip').getByTestId('ndv-input-panel');
|
||||
return this.root.page().getByRole('dialog').getByTestId('ndv-input-panel');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user