feat(editor): NDV UI overhaul experiment (#14209)

Co-authored-by: jakeranallo <jake.ranallo@gmail.com>
This commit is contained in:
Elias Meire
2025-07-04 17:14:17 +02:00
committed by GitHub
parent 5db122be0d
commit 6ef38411d8
33 changed files with 2528 additions and 195 deletions

View File

@@ -23,6 +23,8 @@ import IconLucideArrowDown from '~icons/lucide/arrow-down';
import IconLucideArrowLeft from '~icons/lucide/arrow-left';
import IconLucideArrowLeftRight from '~icons/lucide/arrow-left-right';
import IconLucideArrowRight from '~icons/lucide/arrow-right';
import IconLucideArrowRightFromLine from '~icons/lucide/arrow-right-from-line';
import IconLucideArrowRightToLine from '~icons/lucide/arrow-right-to-line';
import IconLucideArrowUp from '~icons/lucide/arrow-up';
import IconLucideAtSign from '~icons/lucide/at-sign';
import IconLucideBan from '~icons/lucide/ban';
@@ -414,6 +416,8 @@ export const updatedIconSet = {
'arrow-left': IconLucideArrowLeft,
'arrow-left-right': IconLucideArrowLeftRight,
'arrow-right': IconLucideArrowRight,
'arrow-right-from-line': IconLucideArrowRightFromLine,
'arrow-right-to-line': IconLucideArrowRightToLine,
'arrow-up': IconLucideArrowUp,
'at-sign': IconLucideAtSign,
ban: IconLucideBan,

View File

@@ -155,8 +155,11 @@ const scrollRight = () => scroll(50);
--active-tab-border-width: 2px;
display: block;
padding: 0 var(--spacing-s);
padding-bottom: calc(var(--spacing-2xs) + var(--active-tab-border-width));
font-size: var(--font-size-s);
padding-bottom: calc(
var(--spacing-bottom-tab, var(--spacing-2xs)) + var(--active-tab-border-width)
);
font-size: var(--font-size-tab, var(--font-size-s));
font-weight: var(--font-weight-tab, var(--font-weight-regular));
cursor: pointer;
white-space: nowrap;
color: var(--color-text-base);
@@ -175,7 +178,7 @@ const scrollRight = () => scroll(50);
.activeTab {
color: var(--color-primary);
padding-bottom: var(--spacing-2xs);
padding-bottom: var(--spacing-bottom-tab, var(--spacing-2xs));
border-bottom: var(--color-primary) var(--active-tab-border-width) solid;
}

View File

@@ -457,6 +457,13 @@
0.75
);
--prim-color-alt-j-alpha-095: hsla(
var(--prim-color-alt-j-h),
var(--prim-color-alt-j-s),
var(--prim-color-alt-j-l),
0.95
);
// Color Alternate K - Used for errors in dark mode
--prim-color-alt-k-h: 355;
--prim-color-alt-k-s: 100%;

View File

@@ -247,6 +247,7 @@
--color-ndv-droppable-parameter-background: var(--prim-color-primary-alpha-010);
--color-ndv-droppable-parameter-active-background: var(--prim-color-alt-a-alpha-015);
--color-ndv-back-font: var(--prim-gray-0);
--color-ndv-overlay-background: var(--prim-color-alt-j-alpha-095);
// Notice
--color-notice-warning-border: var(--prim-color-alt-b-tint-250);

View File

@@ -310,10 +310,11 @@
--execution-select-all-text: var(--color-danger);
// NDV
--color-run-data-background: var(--color-background-base);
--color-run-data-background: var(--prim-gray-70);
--color-ndv-droppable-parameter: var(--color-secondary);
--color-ndv-droppable-parameter-background: var(--prim-color-secondary-alpha-010);
--color-ndv-droppable-parameter-active-background: var(--prim-color-alt-a-alpha-015);
--color-ndv-overlay-background: var(--prim-color-alt-j-alpha-095);
--color-ndv-back-font: var(--prim-gray-0);
// Notice

View File

@@ -1102,6 +1102,7 @@
"multipleParameter.moveUp": "Move up",
"ndv.backToCanvas": "Back to canvas",
"ndv.backToCanvas.waitingForTriggerWarning": "Waiting for a Trigger node to execute. Close this view to see the Workflow Canvas.",
"ndv.close.tooltip": "Data stored, safe to close",
"ndv.execute.testNode": "Execute step",
"ndv.execute.testNode.description": "Runs the current node. Will also run previous nodes if they have not been run yet",
"ndv.execute.generateCodeAndTestNode.description": "Generates code and then runs the current node",
@@ -1133,12 +1134,18 @@
"ndv.input.noOutputData": "No data",
"ndv.input.noOutputData.executePrevious": "Execute previous nodes",
"ndv.input.noOutputData.title": "No input data yet",
"ndv.input.noOutputData.v2.title": "No input data",
"ndv.input.noOutputData.v2.description": "{link} to view input data",
"ndv.input.noOutputData.v2.action": "Execute previous nodes",
"ndv.input.noOutputData.v2.tooltip": "From the earliest node which is unexecuted, or is executed but has since been changed",
"ndv.input.noOutputData.hint": "(From the earliest node that needs it {info} )",
"ndv.input.noOutputData.hint.tooltip": "From the earliest node which is unexecuted, or is executed but has since been changed",
"ndv.input.noOutputData.schemaPreviewHint": "switch to {schema} to use the schema preview",
"ndv.input.noOutputData.or": "or",
"ndv.input.executingPrevious": "Executing previous nodes...",
"ndv.input.notConnected.title": "Wire me up",
"ndv.input.notConnected.v2.title": "No input connected",
"ndv.input.notConnected.v2.description": "This node can only receive input data if you connect it to another node. {link}",
"ndv.input.notConnected.message": "This node can only receive input data if you connect it to another node.",
"ndv.input.notConnected.learnMore": "Learn more",
"ndv.input.disabled": "The '{nodeName}' node is disabled and wont execute.",
@@ -1161,6 +1168,11 @@
"ndv.output.noOutputData.message.settings": "Settings",
"ndv.output.noOutputData.message.settingsOption": "> \"Always Output Data\".",
"ndv.output.noOutputData.title": "No output data returned",
"ndv.output.noOutputData.v2.title": "No output data",
"ndv.output.noOutputData.v2.description": "{link} to view output data",
"ndv.output.noOutputData.v2.action": "Test this step",
"ndv.output.noOutputData.trigger.title": "No trigger output",
"ndv.output.noOutputData.trigger.action": "Test this trigger",
"ndv.output.noOutputDataInBranch": "No output data in this branch",
"ndv.output.of": "{current} of {total}",
"ndv.output.pageSize": "Page Size",
@@ -1183,6 +1195,7 @@
"ndv.output.noToolUsedInfo": "None of your tools were used in this run. Try giving your tools clearer names and descriptions to help the AI",
"ndv.title.cancel": "Cancel",
"ndv.title.rename": "Rename",
"ndv.title.rename.placeholder": "Enter new name...",
"ndv.title.renameNode": "Rename node",
"ndv.pinData.pin.title": "Pin data",
"ndv.pinData.pin.description": "Node will always output current data instead of executing. Doesn't apply to production executions.",

View File

@@ -96,6 +96,7 @@ export const mockNodeTypeDescription = ({
documentationUrl: 'https://docs',
iconUrl: 'nodes/test-node/icon.svg',
webhooks: undefined,
parameterPane: undefined,
hidden,
});

View File

@@ -5,6 +5,7 @@ import {
CRON_NODE_TYPE,
INTERVAL_NODE_TYPE,
MANUAL_TRIGGER_NODE_TYPE,
NDV_UI_OVERHAUL_EXPERIMENT,
START_NODE_TYPE,
} from '@/constants';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
@@ -25,8 +26,10 @@ import { storeToRefs } from 'pinia';
import { computed, ref, watch } from 'vue';
import InputNodeSelect from './InputNodeSelect.vue';
import NodeExecuteButton from './NodeExecuteButton.vue';
import NDVEmptyState from './NDVEmptyState.vue';
import RunData from './RunData.vue';
import WireMeUp from './WireMeUp.vue';
import { usePostHog } from '@/stores/posthog.store';
import { type IRunDataDisplayMode } from '@/Interface';
type MappingMode = 'debugging' | 'mapping';
@@ -89,6 +92,7 @@ const inputModes = [
const nodeTypesStore = useNodeTypesStore();
const ndvStore = useNDVStore();
const workflowsStore = useWorkflowsStore();
const posthogStore = usePostHog();
const {
activeNode,
@@ -238,6 +242,13 @@ const waitingMessage = computed(() => {
return parentNode && waitingNodeTooltip(workflowsStore.getNodeByName(parentNode.name));
});
const isNDVV2 = computed(() =>
posthogStore.isVariantEnabled(
NDV_UI_OVERHAUL_EXPERIMENT.name,
NDV_UI_OVERHAUL_EXPERIMENT.variant,
),
);
watch(
inputMode,
(mode) => {
@@ -389,8 +400,10 @@ function activatePane() {
@display-mode-change="emit('displayModeChange', $event)"
>
<template #header>
<div :class="$style.titleSection">
<span :class="$style.title">{{ i18n.baseText('ndv.input') }}</span>
<div :class="[$style.titleSection, { [$style.titleSectionV2]: isNDVV2 }]">
<span :class="[$style.title, { [$style.titleV2]: isNDVV2 }]">{{
i18n.baseText('ndv.input')
}}</span>
<N8nRadioButtons
v-if="isActiveNodeConfig && !readOnly"
data-test-id="input-panel-mode"
@@ -429,6 +442,61 @@ function activatePane() {
v-if="(isActiveNodeConfig && rootNode) || parentNodes.length"
:class="$style.noOutputData"
>
<template v-if="isNDVV2">
<NDVEmptyState
v-if="isMappingEnabled || hasRootNodeRun"
:title="i18n.baseText('ndv.input.noOutputData.v2.title')"
>
<template #icon>
<N8nIcon icon="arrow-right-to-line" size="xlarge" />
</template>
<template #description>
<i18n-t tag="span" keypath="ndv.input.noOutputData.v2.description">
<template #link>
<NodeExecuteButton
hide-icon
transparent
type="secondary"
:node-name="(isActiveNodeConfig ? rootNode : activeNode?.name) ?? ''"
:label="i18n.baseText('ndv.input.noOutputData.v2.action')"
:tooltip="i18n.baseText('ndv.input.noOutputData.v2.tooltip')"
tooltip-placement="bottom"
telemetry-source="inputs"
data-test-id="execute-previous-node"
@execute="onNodeExecute"
/>
<br />
</template>
</i18n-t>
</template>
</NDVEmptyState>
<NDVEmptyState v-else :title="i18n.baseText('ndv.input.rootNodeHasNotRun.title')">
<template #icon>
<svg width="16px" viewBox="0 0 16 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M11 2C10.4375 2 10 1.5625 10 1C10 0.46875 10.4375 0 11 0H13C14.6562 0 16 1.34375 16 3V11C16 12.6562 14.6562 14 13 14H11C10.4375 14 10 13.5625 10 13C10 12.4688 10.4375 12 11 12H13C13.5312 12 14 11.5625 14 11V3C14 2.46875 13.5312 2 13 2H11ZM10.6875 7.71875L6.6875 11.7188C6.3125 12.125 5.65625 12.125 5.28125 11.7188C4.875 11.3438 4.875 10.6875 5.28125 10.3125L7.5625 8H1C0.4375 8 0 7.5625 0 7C0 6.46875 0.4375 6 1 6H7.5625L5.28125 3.71875C4.875 3.34375 4.875 2.6875 5.28125 2.3125C5.65625 1.90625 6.3125 1.90625 6.6875 2.3125L10.6875 6.3125C11.0938 6.6875 11.0938 7.34375 10.6875 7.71875Z"
fill="currentColor"
/>
</svg>
</template>
<template #description>
<i18n-t tag="span" keypath="ndv.input.rootNodeHasNotRun.description">
<template #link>
<a
href="#"
data-test-id="switch-to-mapping-mode-link"
@click.prevent="onInputModeChange('mapping')"
>
{{ i18n.baseText('ndv.input.rootNodeHasNotRun.description.link') }}
</a>
</template>
</i18n-t>
</template>
</NDVEmptyState>
</template>
<template v-else>
<template v-if="isMappingEnabled || hasRootNodeRun">
<N8nText tag="div" :bold="true" color="text-dark" size="large">{{
i18n.baseText('ndv.input.noOutputData.title')
@@ -465,7 +533,7 @@ function activatePane() {
type="secondary"
hide-icon
:transparent="true"
:node-name="(isActiveNodeConfig ? rootNode : currentNodeName) ?? ''"
:node-name="(isActiveNodeConfig ? rootNode : activeNode?.name) ?? ''"
:label="i18n.baseText('ndv.input.noOutputData.executePrevious')"
class="mt-m"
telemetry-source="inputs"
@@ -485,8 +553,29 @@ function activatePane() {
</template>
</i18n-t>
</N8nText>
</template>
</div>
<div v-else :class="$style.notConnected">
<NDVEmptyState v-if="isNDVV2" :title="i18n.baseText('ndv.input.notConnected.v2.title')">
<template #icon>
<WireMeUp />
</template>
<template #description>
<i18n-t tag="span" keypath="ndv.input.notConnected.v2.description">
<template #link>
<a
href="https://docs.n8n.io/workflows/connections/"
target="_blank"
@click="onConnectionHelpClick"
>
{{ i18n.baseText('ndv.input.notConnected.learnMore') }}
</a>
</template>
</i18n-t>
</template>
</NDVEmptyState>
<template v-else>
<div>
<WireMeUp />
</div>
@@ -503,6 +592,7 @@ function activatePane() {
{{ i18n.baseText('ndv.input.notConnected.learnMore') }}
</a>
</N8nText>
</template>
</div>
</template>
@@ -550,6 +640,10 @@ function activatePane() {
margin-right: var(--spacing-2xs);
}
}
.titleSectionV2 {
padding-left: var(--spacing-4xs);
}
.inputModeTab {
margin-left: auto;
}
@@ -590,4 +684,9 @@ function activatePane() {
font-size: var(--font-size-s);
font-weight: var(--font-weight-bold);
}
.titleV2 {
letter-spacing: 2px;
font-size: var(--font-size-xs);
}
</style>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
defineProps<{ title: string }>();
defineSlots<{
icon(): unknown;
description(): unknown;
}>();
</script>
<template>
<article :class="$style.empty">
<slot name="icon" />
<h1 :class="$style.title">{{ title }}</h1>
<p :class="$style.description"><slot name="description" /></p>
</article>
</template>
<style lang="css" module>
.empty {
display: flex;
flex-flow: column;
align-items: center;
justify-content: center;
gap: var(--spacing-2xs);
line-height: 2;
color: var(--color-text-base);
}
.title {
font-size: var(--font-size-m);
font-weight: var(--font-weight-bold);
color: var(--color-text-base);
margin: 0;
}
.description {
font-size: var(--font-size-s);
max-width: 180px;
margin: 0;
}
</style>

View File

@@ -5,6 +5,8 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { computed, onMounted, onBeforeUnmount } from 'vue';
import NodeIcon from '@/components/NodeIcon.vue';
import { NodeConnectionTypes, type INodeTypeDescription } from 'n8n-workflow';
import { NDV_UI_OVERHAUL_EXPERIMENT } from '@/constants';
import { usePostHog } from '@/stores/posthog.store';
interface Props {
rootNode: INodeUi;
@@ -17,7 +19,7 @@ const enum FloatingNodePosition {
const props = defineProps<Props>();
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const workflow = workflowsStore.getCurrentWorkflow();
const posthogStore = usePostHog();
const emit = defineEmits<{
switchSelectedNode: [nodeName: string];
}>();
@@ -26,6 +28,14 @@ interface NodeConfig {
node: INodeUi;
nodeType: INodeTypeDescription;
}
const isNDVV2 = computed(() =>
posthogStore.isVariantEnabled(
NDV_UI_OVERHAUL_EXPERIMENT.name,
NDV_UI_OVERHAUL_EXPERIMENT.variant,
),
);
function moveNodeDirection(direction: FloatingNodePosition) {
const matchedDirectionNode = connectedNodes.value[direction][0];
if (matchedDirectionNode) {
@@ -66,7 +76,9 @@ function getINodesFromNames(names: string[]): NodeConfig[] {
const connectedNodes = computed<
Record<FloatingNodePosition, Array<{ node: INodeUi; nodeType: INodeTypeDescription }>>
>(() => {
const workflow = workflowsStore.getCurrentWorkflow();
const rootName = props.rootNode.name;
return {
[FloatingNodePosition.top]: getINodesFromNames(
workflow.getChildNodes(rootName, 'ALL_NON_MAIN'),
@@ -104,7 +116,7 @@ defineExpose({
</script>
<template>
<aside :class="$style.floatingNodes">
<aside :class="[$style.floatingNodes, { [$style.v2]: isNDVV2 }]" data-test-id="floating-nodes">
<ul
v-for="connectionGroup in connectionGroups"
:key="connectionGroup"
@@ -116,7 +128,7 @@ defineExpose({
:key="node.name"
:placement="tooltipPositionMapper[connectionGroup]"
:teleported="false"
:offset="60"
:offset="isNDVV2 ? 16 : 60"
>
<template #content>{{ node.name }}</template>
@@ -131,7 +143,7 @@ defineExpose({
:node-type="nodeType"
:node-name="node.name"
:tooltip-position="tooltipPositionMapper[connectionGroup]"
:size="35"
:size="isNDVV2 ? 24 : 35"
circle
/>
</li>
@@ -257,4 +269,31 @@ defineExpose({
}
}
}
.connectedNode {
&::after {
display: none;
}
padding: var(--spacing-xs);
.v2 .outputMain & {
&:hover {
transform: scale(1.1);
}
}
.v2 .outputSub & {
&:hover {
transform: scale(1.1);
}
}
.v2 .inputMain & {
&:hover {
transform: scale(1.1);
}
}
.v2 .inputSub & {
&:hover {
transform: scale(1.1);
}
}
}
</style>

View File

@@ -0,0 +1,56 @@
import { userEvent } from '@testing-library/user-event';
import NDVHeader from '@/components/NDVHeader.vue';
import { renderComponent } from '../__tests__/render';
describe('NDVHeader', () => {
const defaultProps = {
nodeName: 'My Custom Name',
nodeTypeName: 'Edit Fields',
docsUrl: 'https://example.com/docs',
icon: { icon: 'code' },
readOnly: false,
};
it('renders docs label with node type name if name is customized', () => {
const { getByText } = renderComponent(NDVHeader, { props: defaultProps });
expect(getByText('Edit Fields Docs')).toBeInTheDocument();
});
it('renders nodeTypeName if docsUrl is not provided and name is custom', () => {
const { getByText, queryByText } = renderComponent(NDVHeader, {
props: {
...defaultProps,
docsUrl: undefined,
},
});
expect(getByText('Edit Fields')).toBeInTheDocument();
expect(queryByText('Docs')).not.toBeInTheDocument();
});
it('emits rename when inline text is changed', async () => {
const { getByTestId, emitted } = renderComponent(NDVHeader, {
props: defaultProps,
});
const input = getByTestId('inline-edit-input');
const preview = getByTestId('inline-edit-preview');
await userEvent.click(preview);
await userEvent.tripleClick(input);
await userEvent.keyboard('Updated Name');
await userEvent.keyboard('{enter}');
expect(emitted().rename).toHaveLength(1);
expect(emitted().rename[0]).toEqual(['Updated Name']);
});
it('emits close when close button is clicked', async () => {
const { getByRole, emitted } = renderComponent(NDVHeader, {
props: defaultProps,
});
const closeButton = getByRole('button');
await userEvent.click(closeButton);
expect(emitted().close).toBeTruthy();
});
});

View File

@@ -0,0 +1,110 @@
<script setup lang="ts">
import type { NodeIconSource } from '@/utils/nodeIcon';
import { N8nIconButton } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { computed } from 'vue';
const props = defineProps<{
nodeName: string;
nodeTypeName: string;
docsUrl?: string;
icon?: NodeIconSource;
readOnly?: boolean;
}>();
const i18n = useI18n();
const emit = defineEmits<{ close: []; rename: [name: string] }>();
const hasCustomName = computed(() => props.nodeName !== props.nodeTypeName);
const docsLabel = computed(() => {
if (!hasCustomName.value) {
return i18n.baseText('nodeSettings.docs');
}
return `${props.nodeTypeName} ${i18n.baseText('nodeSettings.docs')}`;
});
function onRename(newNodeName: string) {
emit('rename', newNodeName || props.nodeTypeName);
}
</script>
<template>
<header :class="$style.ndvHeader">
<div :class="$style.content">
<NodeIcon v-if="icon" :class="$style.icon" :size="20" :icon-source="icon" />
<div :class="$style.title">
<N8nInlineTextEdit
:model-value="nodeName"
:min-width="0"
:max-width="500"
:placeholder="i18n.baseText('ndv.title.rename.placeholder')"
:read-only="readOnly"
@update:model-value="onRename"
/>
</div>
<N8nLink v-if="docsUrl" theme="text" target="_blank" :href="docsUrl">
<span :class="$style.docsLabel">
<N8nText size="small" bold>
{{ docsLabel }}
</N8nText>
<N8nIcon icon="external-link" />
</span>
</N8nLink>
<N8nText v-else-if="hasCustomName" size="small" bold>
{{ nodeTypeName }}
</N8nText>
</div>
<N8nTooltip>
<template #content>
{{ i18n.baseText('ndv.close.tooltip') }}
</template>
<N8nIconButton icon="x" type="tertiary" @click="emit('close')" />
</N8nTooltip>
</header>
</template>
<style lang="css" module>
.ndvHeader {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-2xs);
padding: var(--spacing-2xs);
background: var(--color-background-xlight);
}
.content {
display: flex;
align-items: flex-end;
gap: var(--spacing-2xs);
margin-left: var(--spacing-2xs);
}
.title {
color: var(--color-text-dark);
font-size: var(--font-size-m);
}
.subtitle {
display: flex;
align-items: baseline;
gap: var(--spacing-2xs);
margin: 0;
}
.docsLabel {
display: flex;
gap: var(--spacing-4xs);
}
.icon {
align-self: center;
z-index: 1;
}
</style>

View File

@@ -96,7 +96,7 @@ const isInputPaneActive = ref(false);
const isOutputPaneActive = ref(false);
const isPairedItemHoveringEnabled = ref(true);
//computed
// computed
const pushRef = computed(() => ndvStore.pushRef);

View File

@@ -0,0 +1,232 @@
import { createPinia, setActivePinia } from 'pinia';
import { waitFor, waitForElementToBeRemoved, fireEvent } from '@testing-library/vue';
import { mock } from 'vitest-mock-extended';
import NodeDetailsViewV2 from '@/components/NodeDetailsViewV2.vue';
import { VIEWS } from '@/constants';
import type { IWorkflowDb } from '@/Interface';
import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { createComponentRenderer } from '@/__tests__/render';
import { setupServer } from '@/__tests__/server';
import { defaultNodeDescriptions, mockNodes } from '@/__tests__/mocks';
import { cleanupAppModals, createAppModals } from '@/__tests__/utils';
vi.mock('vue-router', () => {
return {
useRouter: () => ({}),
useRoute: () => ({ meta: {} }),
RouterLink: vi.fn(),
};
});
async function createPiniaStore(isActiveNode: boolean) {
const node = mockNodes[0];
const workflow = mock<IWorkflowDb>({
connections: {},
active: true,
nodes: [node],
});
const pinia = createPinia();
setActivePinia(pinia);
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const ndvStore = useNDVStore();
nodeTypesStore.setNodeTypes(defaultNodeDescriptions);
workflowsStore.workflow = workflow;
workflowsStore.nodeMetadata[node.name] = { pristine: true };
if (isActiveNode) {
ndvStore.activeNodeName = node.name;
}
await useSettingsStore().getSettings();
await useUsersStore().loginWithCookie();
return {
pinia,
currentWorkflow: workflowsStore.getCurrentWorkflow(),
nodeName: node.name,
};
}
describe('NodeDetailsViewV2', () => {
let server: ReturnType<typeof setupServer>;
beforeAll(() => {
HTMLDialogElement.prototype.show = vi.fn();
server = setupServer();
});
beforeEach(() => {
createAppModals();
});
afterEach(() => {
cleanupAppModals();
vi.clearAllMocks();
});
afterAll(() => {
server.shutdown();
});
it('should render correctly', async () => {
const { pinia, currentWorkflow } = await createPiniaStore(true);
const renderComponent = createComponentRenderer(NodeDetailsViewV2, {
props: {
teleported: false,
appendToBody: false,
workflowObject: currentWorkflow,
},
global: {
mocks: {
$route: {
name: VIEWS.WORKFLOW,
},
},
},
});
const { getByTestId } = renderComponent({
pinia,
});
await waitFor(() => expect(getByTestId('ndv')).toBeInTheDocument());
});
describe('keyboard listener', () => {
test('should register and unregister keydown listener based on modal open state', async () => {
const { pinia, currentWorkflow, nodeName } = await createPiniaStore(false);
const ndvStore = useNDVStore();
const renderComponent = createComponentRenderer(NodeDetailsViewV2, {
props: {
teleported: false,
appendToBody: false,
workflowObject: currentWorkflow,
},
global: {
mocks: {
$route: {
name: VIEWS.WORKFLOW,
},
},
},
});
const { getByTestId, queryByTestId } = renderComponent({
pinia,
});
const addEventListenerSpy = vi.spyOn(document, 'addEventListener');
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
ndvStore.activeNodeName = nodeName;
await waitFor(() => expect(getByTestId('ndv')).toBeInTheDocument());
expect(addEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true);
expect(removeEventListenerSpy).not.toHaveBeenCalledWith(
'keydown',
expect.any(Function),
true,
);
ndvStore.activeNodeName = null;
await waitForElementToBeRemoved(queryByTestId('ndv'));
expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true);
addEventListenerSpy.mockRestore();
removeEventListenerSpy.mockRestore();
});
test('should unregister keydown listener on unmount', async () => {
const { pinia, currentWorkflow, nodeName } = await createPiniaStore(false);
const ndvStore = useNDVStore();
const renderComponent = createComponentRenderer(NodeDetailsViewV2, {
props: {
teleported: false,
appendToBody: false,
workflowObject: currentWorkflow,
},
global: {
mocks: {
$route: {
name: VIEWS.WORKFLOW,
},
},
},
});
const { getByTestId, unmount } = renderComponent({
pinia,
});
ndvStore.activeNodeName = nodeName;
await waitFor(() => expect(getByTestId('ndv')).toBeInTheDocument());
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
expect(removeEventListenerSpy).not.toHaveBeenCalledWith(
'keydown',
expect.any(Function),
true,
);
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true);
removeEventListenerSpy.mockRestore();
});
test("should emit 'saveKeyboardShortcut' when save shortcut keybind is pressed", async () => {
const { pinia, currentWorkflow, nodeName } = await createPiniaStore(false);
const ndvStore = useNDVStore();
const renderComponent = createComponentRenderer(NodeDetailsViewV2, {
props: {
teleported: false,
appendToBody: false,
workflowObject: currentWorkflow,
},
global: {
mocks: {
$route: {
name: VIEWS.WORKFLOW,
},
},
},
});
const { getByTestId, emitted } = renderComponent({
pinia,
});
ndvStore.activeNodeName = nodeName;
await waitFor(() => expect(getByTestId('ndv')).toBeInTheDocument());
await fireEvent.keyDown(getByTestId('ndv'), {
key: 's',
ctrlKey: true,
bubbles: true,
cancelable: true,
});
expect(emitted().saveKeyboardShortcut).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,961 @@
<script setup lang="ts">
import type {
IRunDataDisplayMode,
IUpdateInformation,
MainPanelType,
NodePanelType,
TargetItem,
} from '@/Interface';
import { createEventBus } from '@n8n/utils/event-bus';
import type { IRunData, NodeConnectionType, Workflow } from 'n8n-workflow';
import { jsonParse, NodeConnectionTypes, NodeHelpers } from 'n8n-workflow';
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
import NodeSettings from '@/components/NodeSettings.vue';
import { useExternalHooks } from '@/composables/useExternalHooks';
import { useKeybindings } from '@/composables/useKeybindings';
import { useMessage } from '@/composables/useMessage';
import { useNdvLayout } from '@/composables/useNdvLayout';
import { useNodeDocsUrl } from '@/composables/useNodeDocsUrl';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { usePinnedData } from '@/composables/usePinnedData';
import { useStyles } from '@/composables/useStyles';
import { useTelemetry } from '@/composables/useTelemetry';
import { useWorkflowActivate } from '@/composables/useWorkflowActivate';
import {
APP_MODALS_ELEMENT_ID,
EnterpriseEditionFeature,
EXECUTABLE_TRIGGER_NODE_TYPES,
MODAL_CONFIRM,
START_NODE_TYPE,
STICKY_NODE_TYPE,
} from '@/constants';
import type { DataPinningDiscoveryEvent } from '@/event-bus';
import { dataPinningEventBus } from '@/event-bus';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { getNodeIconSource } from '@/utils/nodeIcon';
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
import { useI18n } from '@n8n/i18n';
import { storeToRefs } from 'pinia';
import InputPanel from './InputPanel.vue';
import OutputPanel from './OutputPanel.vue';
import PanelDragButtonV2 from './PanelDragButtonV2.vue';
import TriggerPanel from './TriggerPanel.vue';
const emit = defineEmits<{
saveKeyboardShortcut: [event: KeyboardEvent];
valueChanged: [parameterData: IUpdateInformation];
switchSelectedNode: [nodeTypeName: string];
openConnectionNodeCreator: [nodeTypeName: string, connectionType: NodeConnectionType];
renameNode: [nodeName: string];
stopExecution: [];
}>();
const props = withDefaults(
defineProps<{
workflowObject: Workflow;
readOnly?: boolean;
isProductionExecutionPreview?: boolean;
}>(),
{
isProductionExecutionPreview: false,
readOnly: false,
},
);
const ndvStore = useNDVStore();
const externalHooks = useExternalHooks();
const nodeHelpers = useNodeHelpers();
const { activeNode } = storeToRefs(ndvStore);
const pinnedData = usePinnedData(activeNode);
const workflowActivate = useWorkflowActivate();
const nodeTypesStore = useNodeTypesStore();
const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore();
const settingsStore = useSettingsStore();
const deviceSupport = useDeviceSupport();
const telemetry = useTelemetry();
const i18n = useI18n();
const message = useMessage();
const { APP_Z_INDEXES } = useStyles();
const settingsEventBus = createEventBus();
const redrawRequired = ref(false);
const runInputIndex = ref(-1);
const runOutputIndex = ref(-1);
const isLinkingEnabled = ref(true);
const selectedInput = ref<string | undefined>();
const triggerWaitingWarningEnabled = ref(false);
const isDragging = ref(false);
const mainPanelPosition = ref(0);
const pinDataDiscoveryTooltipVisible = ref(false);
const avgInputRowHeight = ref(0);
const avgOutputRowHeight = ref(0);
const isInputPaneActive = ref(false);
const isOutputPaneActive = ref(false);
const isPairedItemHoveringEnabled = ref(true);
const dialogRef = ref<HTMLDialogElement>();
const containerRef = useTemplateRef('containerRef');
const mainPanelRef = useTemplateRef('mainPanelRef');
// computed
const pushRef = computed(() => ndvStore.pushRef);
const activeNodeType = computed(() => {
if (activeNode.value) {
return nodeTypesStore.getNodeType(activeNode.value.type, activeNode.value.typeVersion);
}
return null;
});
const { docsUrl } = useNodeDocsUrl({ nodeType: activeNodeType });
const workflowRunning = computed(() => uiStore.isActionActive.workflowRunning);
const workflowRunData = computed(() => {
if (workflowExecution.value === null) {
return null;
}
const executionData = workflowExecution.value.data;
if (executionData?.resultData) {
return executionData.resultData.runData;
}
return null;
});
const parentNodes = computed(() => {
if (activeNode.value) {
return (
props.workflowObject
.getParentNodesByDepth(activeNode.value.name, 1)
.map(({ name }) => name) || []
);
} else {
return [];
}
});
const parentNode = computed(() => {
for (const parentNodeName of parentNodes.value) {
if (workflowsStore?.pinnedWorkflowData?.[parentNodeName]) {
return parentNodeName;
}
if (workflowRunData.value?.[parentNodeName]) {
return parentNodeName;
}
}
return parentNodes.value[0];
});
const inputNodeName = computed<string | undefined>(() => {
const nodeOutputs =
activeNode.value && activeNodeType.value
? NodeHelpers.getNodeOutputs(props.workflowObject, activeNode.value, activeNodeType.value)
: [];
const nonMainOutputs = nodeOutputs.filter((output) => {
if (typeof output === 'string') return output !== NodeConnectionTypes.Main;
return output.type !== NodeConnectionTypes.Main;
});
const isSubNode = nonMainOutputs.length > 0;
if (isSubNode && activeNode.value) {
// For sub-nodes, we need to get their connected output node to determine the input
// because sub-nodes use specialized outputs (e.g. NodeConnectionTypes.AiTool)
// instead of the standard Main output type
const connectedOutputNode = props.workflowObject.getChildNodes(
activeNode.value.name,
'ALL_NON_MAIN',
)?.[0];
return connectedOutputNode;
}
return selectedInput.value || parentNode.value;
});
const inputNode = computed(() => {
if (inputNodeName.value) {
return workflowsStore.getNodeByName(inputNodeName.value);
}
return null;
});
const inputSize = computed(() => ndvStore.ndvInputDataWithPinnedData.length);
const isTriggerNode = computed(
() =>
!!activeNodeType.value &&
(activeNodeType.value.group.includes('trigger') ||
activeNodeType.value.name === START_NODE_TYPE),
);
const showTriggerPanel = computed(() => {
const override = !!activeNodeType.value?.triggerPanel;
if (typeof activeNodeType.value?.triggerPanel === 'boolean') {
return override;
}
const isWebhookBasedNode = !!activeNodeType.value?.webhooks?.length;
const isPollingNode = activeNodeType.value?.polling;
return (
!props.readOnly && isTriggerNode.value && (isWebhookBasedNode || isPollingNode || override)
);
});
const isExecutableTriggerNode = computed(() => {
if (!activeNodeType.value) return false;
return EXECUTABLE_TRIGGER_NODE_TYPES.includes(activeNodeType.value.name);
});
const isActiveStickyNode = computed(
() => !!ndvStore.activeNode && ndvStore.activeNode.type === STICKY_NODE_TYPE,
);
const workflowExecution = computed(() => workflowsStore.getWorkflowExecution);
const maxOutputRun = computed(() => {
if (activeNode.value === null) {
return 0;
}
const runData = workflowRunData.value;
if (!runData?.[activeNode.value.name]) {
return 0;
}
if (runData[activeNode.value.name].length) {
return runData[activeNode.value.name].length - 1;
}
return 0;
});
const outputRun = computed(() =>
runOutputIndex.value === -1
? maxOutputRun.value
: Math.min(runOutputIndex.value, maxOutputRun.value),
);
const maxInputRun = computed(() => {
if (inputNode.value === null || activeNode.value === null) {
return 0;
}
const workflowNode = props.workflowObject.getNode(activeNode.value.name);
if (!workflowNode || !activeNodeType.value) {
return 0;
}
const outputs = NodeHelpers.getNodeOutputs(
props.workflowObject,
workflowNode,
activeNodeType.value,
);
let node = inputNode.value;
const runData: IRunData | null = workflowRunData.value;
if (outputs.some((output) => output !== NodeConnectionTypes.Main)) {
node = activeNode.value;
}
if (!node || !runData || !runData.hasOwnProperty(node.name)) {
return 0;
}
if (runData[node.name].length) {
return runData[node.name].length - 1;
}
return 0;
});
const inputRun = computed(() => {
if (isLinkingEnabled.value && maxOutputRun.value === maxInputRun.value) {
return outputRun.value;
}
if (runInputIndex.value === -1) {
return maxInputRun.value;
}
return Math.min(runInputIndex.value, maxInputRun.value);
});
const canLinkRuns = computed(
() => maxOutputRun.value > 0 && maxOutputRun.value === maxInputRun.value,
);
const linked = computed(() => isLinkingEnabled.value && canLinkRuns.value);
const outputPanelEditMode = computed(() => ndvStore.outputPanelEditMode);
const isWorkflowRunning = computed(() => uiStore.isActionActive.workflowRunning);
const isExecutionWaitingForWebhook = computed(() => workflowsStore.executionWaitingForWebhook);
const blockUi = computed(() => isWorkflowRunning.value || isExecutionWaitingForWebhook.value);
const foreignCredentials = computed(() => {
const credentials = activeNode.value?.credentials;
const usedCredentials = workflowsStore.usedCredentials;
const foreignCredentialsArray: string[] = [];
if (credentials && settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing]) {
Object.values(credentials).forEach((credential) => {
if (
credential.id &&
usedCredentials[credential.id] &&
!usedCredentials[credential.id].currentUserHasAccess
) {
foreignCredentialsArray.push(credential.id);
}
});
}
return foreignCredentialsArray;
});
const hasForeignCredential = computed(() => foreignCredentials.value.length > 0);
const inputPanelDisplayMode = computed(() => ndvStore.inputPanelDisplayMode);
const outputPanelDisplayMode = computed(() => ndvStore.outputPanelDisplayMode);
const hasInputPanel = computed(() => !isTriggerNode.value || showTriggerPanel.value);
const supportedResizeDirections = computed<Array<'left' | 'right'>>(() =>
hasInputPanel.value ? ['left', 'right'] : ['right'],
);
const currentNodePaneType = computed((): MainPanelType => {
if (!hasInputPanel.value) return 'inputless';
return activeNodeType.value?.parameterPane ?? 'regular';
});
const { containerWidth, onDrag, onResize, onResizeEnd, panelWidthPercentage, panelWidthPixels } =
useNdvLayout({ container: containerRef, hasInputPanel, paneType: currentNodePaneType });
//methods
const setIsTooltipVisible = ({ isTooltipVisible }: DataPinningDiscoveryEvent) => {
pinDataDiscoveryTooltipVisible.value = isTooltipVisible;
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 's' && deviceSupport.isCtrlKeyPressed(e)) {
onSaveWorkflow(e);
}
};
const onSaveWorkflow = (e: KeyboardEvent) => {
e.stopPropagation();
e.preventDefault();
if (props.readOnly) return;
emit('saveKeyboardShortcut', e);
};
const onInputItemHover = (e: { itemIndex: number; outputIndex: number } | null) => {
if (e === null || !inputNodeName.value || !isPairedItemHoveringEnabled.value) {
ndvStore.setHoveringItem(null);
return;
}
const item = {
nodeName: inputNodeName.value,
runIndex: inputRun.value,
outputIndex: e.outputIndex,
itemIndex: e.itemIndex,
};
ndvStore.setHoveringItem(item);
};
const onInputTableMounted = (e: { avgRowHeight: number }) => {
avgInputRowHeight.value = e.avgRowHeight;
};
const onWorkflowActivate = () => {
ndvStore.activeNodeName = null;
setTimeout(() => {
void workflowActivate.activateCurrentWorkflow('ndv');
}, 1000);
};
const onOutputItemHover = (e: { itemIndex: number; outputIndex: number } | null) => {
if (e === null || !activeNode.value || !isPairedItemHoveringEnabled.value) {
ndvStore.setHoveringItem(null);
return;
}
const item: TargetItem = {
nodeName: activeNode.value?.name,
runIndex: outputRun.value,
outputIndex: e.outputIndex,
itemIndex: e.itemIndex,
};
ndvStore.setHoveringItem(item);
};
const onDragEnd = () => {
onResizeEnd();
isDragging.value = false;
telemetry.track('User moved parameters pane', {
// example method for tracking
window_width: containerWidth.value,
start_position: mainPanelPosition.value,
// TODO:
// end_position: mainPanelDimensions.value.relativeLeft,
node_type: activeNodeType.value ? activeNodeType.value.name : '',
push_ref: pushRef.value,
workflow_id: workflowsStore.workflowId,
});
};
const onDragStart = () => {
isDragging.value = true;
};
const onLinkRunToOutput = () => {
isLinkingEnabled.value = true;
trackLinking('output');
};
const onUnlinkRun = (pane: string) => {
runInputIndex.value = runOutputIndex.value;
isLinkingEnabled.value = false;
trackLinking(pane);
};
const onNodeExecute = () => {
setTimeout(() => {
if (!activeNode.value || !workflowRunning.value) {
return;
}
triggerWaitingWarningEnabled.value = true;
}, 1000);
};
const openSettings = () => {
settingsEventBus.emit('openSettings');
};
const trackLinking = (pane: string) => {
telemetry.track('User changed ndv run linking', {
node_type: activeNodeType.value ? activeNodeType.value.name : '',
push_ref: pushRef.value,
linked: linked.value,
pane,
});
};
const onLinkRunToInput = () => {
runOutputIndex.value = runInputIndex.value;
isLinkingEnabled.value = true;
trackLinking('input');
};
const onSwitchSelectedNode = (nodeTypeName: string) => {
emit('switchSelectedNode', nodeTypeName);
};
const onOpenConnectionNodeCreator = (nodeTypeName: string, connectionType: NodeConnectionType) => {
emit('openConnectionNodeCreator', nodeTypeName, connectionType);
};
const close = async () => {
if (isDragging.value) {
return;
}
if (outputPanelEditMode.value.enabled && activeNode.value) {
const shouldPinDataBeforeClosing = await message.confirm(
'',
i18n.baseText('ndv.pinData.beforeClosing.title'),
{
confirmButtonText: i18n.baseText('ndv.pinData.beforeClosing.confirm'),
cancelButtonText: i18n.baseText('ndv.pinData.beforeClosing.cancel'),
},
);
if (shouldPinDataBeforeClosing === MODAL_CONFIRM) {
const { value } = outputPanelEditMode.value;
try {
pinnedData.setData(jsonParse(value), 'on-ndv-close-modal');
} catch (error) {
console.error(error);
}
}
ndvStore.setOutputPanelEditModeEnabled(false);
}
await externalHooks.run('dataDisplay.nodeEditingFinished');
telemetry.track('User closed node modal', {
node_type: activeNodeType.value ? activeNodeType.value?.name : '',
push_ref: pushRef.value,
workflow_id: workflowsStore.workflowId,
});
triggerWaitingWarningEnabled.value = false;
ndvStore.activeNodeName = null;
ndvStore.resetNDVPushRef();
};
useKeybindings({ Escape: close });
const trackRunChange = (run: number, pane: string) => {
telemetry.track('User changed ndv run dropdown', {
push_ref: pushRef.value,
run_index: run,
node_type: activeNodeType.value ? activeNodeType.value?.name : '',
pane,
});
};
const onRunOutputIndexChange = (run: number) => {
runOutputIndex.value = run;
trackRunChange(run, 'output');
};
const onRunInputIndexChange = (run: number) => {
runInputIndex.value = run;
if (linked.value) {
runOutputIndex.value = run;
}
trackRunChange(run, 'input');
};
const onOutputTableMounted = (e: { avgRowHeight: number }) => {
avgOutputRowHeight.value = e.avgRowHeight;
};
const onInputNodeChange = (value: string, index: number) => {
runInputIndex.value = -1;
isLinkingEnabled.value = true;
selectedInput.value = value;
telemetry.track('User changed ndv input dropdown', {
node_type: activeNode.value ? activeNode.value.type : '',
push_ref: pushRef.value,
workflow_id: workflowsStore.workflowId,
selection_value: index,
input_node_type: inputNode.value ? inputNode.value.type : '',
});
};
const onStopExecution = () => {
emit('stopExecution');
};
const activateInputPane = () => {
isInputPaneActive.value = true;
isOutputPaneActive.value = false;
};
const activateOutputPane = () => {
isInputPaneActive.value = false;
isOutputPaneActive.value = true;
};
const onSearch = (search: string) => {
isPairedItemHoveringEnabled.value = !search;
};
const registerKeyboardListener = () => {
document.addEventListener('keydown', onKeyDown, true);
};
const unregisterKeyboardListener = () => {
document.removeEventListener('keydown', onKeyDown, true);
};
const onRename = (name: string) => {
emit('renameNode', name);
};
const handleChangeDisplayMode = (pane: NodePanelType, mode: IRunDataDisplayMode) => {
ndvStore.setPanelDisplayMode({ pane, mode });
};
//watchers
watch(
activeNode,
(node, oldNode) => {
if (node && !oldNode) {
registerKeyboardListener();
dialogRef.value?.show();
} else if (!node) {
unregisterKeyboardListener();
}
if (node && node.name !== oldNode?.name && !isActiveStickyNode.value) {
runInputIndex.value = -1;
runOutputIndex.value = -1;
isLinkingEnabled.value = true;
selectedInput.value = undefined;
triggerWaitingWarningEnabled.value = false;
avgOutputRowHeight.value = 0;
avgInputRowHeight.value = 0;
setTimeout(() => ndvStore.setNDVPushRef(), 0);
if (!activeNodeType.value) {
return;
}
void externalHooks.run('dataDisplay.nodeTypeChanged', {
nodeSubtitle: nodeHelpers.getNodeSubtitle(node, activeNodeType.value, props.workflowObject),
});
setTimeout(() => {
if (activeNode.value) {
const outgoingConnections = workflowsStore.outgoingConnectionsByNodeName(
activeNode.value?.name,
);
telemetry.track('User opened node modal', {
node_id: activeNode.value?.id,
node_type: activeNodeType.value ? activeNodeType.value?.name : '',
workflow_id: workflowsStore.workflowId,
push_ref: pushRef.value,
is_editable: !hasForeignCredential.value,
parameters_pane_position: mainPanelPosition.value,
input_first_connector_runs: maxInputRun.value,
output_first_connector_runs: maxOutputRun.value,
selected_view_inputs: isTriggerNode.value ? 'trigger' : ndvStore.inputPanelDisplayMode,
selected_view_outputs: ndvStore.outputPanelDisplayMode,
input_connectors: parentNodes.value.length,
output_connectors: outgoingConnections?.main?.length,
input_displayed_run_index: inputRun.value,
output_displayed_run_index: outputRun.value,
data_pinning_tooltip_presented: pinDataDiscoveryTooltipVisible.value,
input_displayed_row_height_avg: avgInputRowHeight.value,
output_displayed_row_height_avg: avgOutputRowHeight.value,
});
}
}, 2000); // wait for RunData to mount and present pindata discovery tooltip
}
if (window.top && !isActiveStickyNode.value) {
window.top.postMessage(JSON.stringify({ command: node ? 'openNDV' : 'closeNDV' }), '*');
}
},
{ immediate: true },
);
watch(maxOutputRun, () => {
runOutputIndex.value = -1;
});
watch(maxInputRun, () => {
runInputIndex.value = -1;
});
watch(inputNodeName, (nodeName) => {
setTimeout(() => {
ndvStore.setInputNodeName(nodeName);
}, 0);
});
watch(inputRun, (run) => {
setTimeout(() => {
ndvStore.setInputRunIndex(run);
}, 0);
});
watch(mainPanelRef, (mainPanel) => {
if (!mainPanel) return;
// Based on https://github.com/unovue/reka-ui/blob/v2/packages/core/src/FocusScope/utils.ts
// Should use FocusScope here from Reka UI when we have it
function getTabbableCandidates(element: HTMLElement) {
const nodes: HTMLElement[] = [];
const walker = document.createTreeWalker(element, NodeFilter.SHOW_ELEMENT, {
acceptNode: (node: HTMLInputElement) => {
const isHiddenInput = node.tagName === 'INPUT' && node.type === 'hidden';
if (node.disabled || node.hidden || isHiddenInput) return NodeFilter.FILTER_SKIP;
// `.tabIndex` is not the same as the `tabindex` attribute. It works on the
// runtime's understanding of tabbability, so this automatically accounts
// for any kind of element that could be tabbed to.
return node.tabIndex >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
},
});
while (walker.nextNode()) nodes.push(walker.currentNode as HTMLElement);
// we do not take into account the order of nodes with positive `tabIndex` as it
// hinders accessibility to have tab order different from visual order.
return nodes;
}
const firstFocusableElement = getTabbableCandidates(mainPanel)[0];
if (firstFocusableElement) {
firstFocusableElement.focus();
}
});
onMounted(() => {
dialogRef.value?.show();
dataPinningEventBus.on('data-pinning-discovery', setIsTooltipVisible);
});
onBeforeUnmount(() => {
dataPinningEventBus.off('data-pinning-discovery', setIsTooltipVisible);
unregisterKeyboardListener();
});
</script>
<template>
<Teleport v-if="activeNode && activeNodeType" :to="`#${APP_MODALS_ELEMENT_ID}`">
<div :class="$style.backdrop" :style="{ zIndex: APP_Z_INDEXES.NDV }" @click="close"></div>
<dialog
ref="dialogRef"
open
aria-modal="true"
data-test-id="ndv"
:class="$style.dialog"
:style="{ zIndex: APP_Z_INDEXES.NDV }"
>
<NDVFloatingNodes :root-node="activeNode" @switch-selected-node="onSwitchSelectedNode" />
<div ref="containerRef" :class="$style.container">
<NDVHeader
:class="$style.header"
:node-name="activeNode.name"
:node-type-name="activeNodeType.defaults.name ?? activeNodeType.displayName"
:icon="getNodeIconSource(activeNodeType)"
:docs-url="docsUrl"
@close="close"
@rename="onRename"
/>
<main :class="$style.main">
<div
v-if="hasInputPanel"
:class="[$style.column, $style.dataColumn]"
:style="{ width: `${panelWidthPercentage.left}%` }"
>
<TriggerPanel
v-if="showTriggerPanel"
:node-name="activeNode.name"
:push-ref="pushRef"
:class="$style.input"
@execute="onNodeExecute"
@activate="onWorkflowActivate"
/>
<InputPanel
v-else-if="!isTriggerNode"
:workflow="workflowObject"
:can-link-runs="canLinkRuns"
:run-index="inputRun"
:linked-runs="linked"
:current-node-name="inputNodeName"
:push-ref="pushRef"
:read-only="readOnly || hasForeignCredential"
:is-production-execution-preview="isProductionExecutionPreview"
:is-pane-active="isInputPaneActive"
:display-mode="inputPanelDisplayMode"
:class="$style.input"
@activate-pane="activateInputPane"
@link-run="onLinkRunToInput"
@unlink-run="() => onUnlinkRun('input')"
@run-change="onRunInputIndexChange"
@open-settings="openSettings"
@change-input-node="onInputNodeChange"
@execute="onNodeExecute"
@table-mounted="onInputTableMounted"
@item-hover="onInputItemHover"
@search="onSearch"
@display-mode-change="handleChangeDisplayMode('input', $event)"
/>
</div>
<N8nResizeWrapper
:width="panelWidthPixels.main"
:min-width="260"
:supported-directions="supportedResizeDirections"
:grid-size="8"
:class="$style.column"
:style="{ width: `${panelWidthPercentage.main}%` }"
outset
@resize="onResize"
@resizestart="onDragStart"
@resizeend="onDragEnd"
>
<div ref="mainPanelRef" :class="$style.main">
<PanelDragButtonV2
v-if="hasInputPanel"
:class="$style.draggable"
:can-move-left="true"
:can-move-right="true"
@drag="onDrag"
@dragstart="onDragStart"
@dragend="onDragEnd"
/>
<NodeSettings
:event-bus="settingsEventBus"
:dragging="isDragging"
:push-ref="pushRef"
:node-type="activeNodeType"
:foreign-credentials="foreignCredentials"
:read-only="readOnly"
:block-u-i="blockUi && showTriggerPanel"
:executable="!readOnly"
:input-size="inputSize"
:class="$style.settings"
@execute="onNodeExecute"
@stop-execution="onStopExecution"
@redraw-required="redrawRequired = true"
@activate="onWorkflowActivate"
@switch-selected-node="onSwitchSelectedNode"
@open-connection-node-creator="onOpenConnectionNodeCreator"
/>
</div>
</N8nResizeWrapper>
<div
:class="[$style.column, $style.dataColumn]"
:style="{ width: `${panelWidthPercentage.right}%` }"
>
<OutputPanel
data-test-id="output-panel"
:workflow="workflowObject"
:can-link-runs="canLinkRuns"
:run-index="outputRun"
:linked-runs="linked"
:push-ref="pushRef"
:is-read-only="readOnly || hasForeignCredential"
:block-u-i="blockUi && isTriggerNode && !isExecutableTriggerNode"
:is-production-execution-preview="isProductionExecutionPreview"
:is-pane-active="isOutputPaneActive"
:display-mode="outputPanelDisplayMode"
:class="$style.output"
@activate-pane="activateOutputPane"
@link-run="onLinkRunToOutput"
@unlink-run="() => onUnlinkRun('output')"
@run-change="onRunOutputIndexChange"
@open-settings="openSettings"
@table-mounted="onOutputTableMounted"
@item-hover="onOutputItemHover"
@search="onSearch"
@execute="onNodeExecute"
@display-mode-change="handleChangeDisplayMode('output', $event)"
/>
</div>
</main>
</div>
</dialog>
</Teleport>
</template>
<style lang="scss" module>
.backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--color-ndv-overlay-background);
}
.dialog {
position: fixed;
width: calc(100vw - var(--spacing-2xl));
height: calc(100vh - var(--spacing-2xl));
top: var(--spacing-l);
left: var(--spacing-l);
border: none;
background: none;
padding: 0;
margin: 0;
display: flex;
}
.container {
display: flex;
flex-direction: column;
flex-grow: 1;
background: var(--border-color-base);
border: var(--border-base);
border-radius: var(--border-radius-large);
color: var(--color-text-base);
min-width: 0;
}
.main {
width: 0;
flex-grow: 1;
display: flex;
align-items: stretch;
}
.column {
min-width: 0;
+ .column {
border-left: var(--border-base);
}
&:first-child > div {
border-bottom-left-radius: var(--border-radius-large);
}
&:last-child {
border-bottom-right-radius: var(--border-radius-large);
}
}
.input,
.output {
min-width: 280px;
}
.dataColumn {
overflow-x: auto;
}
.header {
border-bottom: var(--border-base);
border-top-left-radius: var(--border-radius-large);
border-top-right-radius: var(--border-radius-large);
}
.main {
display: flex;
width: 100%;
height: 100%;
min-height: 0;
position: relative;
}
.settings {
overflow: hidden;
flex-grow: 1;
}
.draggable {
--draggable-height: 22px;
position: absolute;
top: calc(-1 * var(--draggable-height));
left: 50%;
transform: translateX(-50%);
height: var(--draggable-height);
}
</style>
<style lang="scss">
// Hide notice(.ndv-connection-hint-notice) warning when node has output connection
[data-has-output-connection='true'] .ndv-connection-hint-notice {
display: none;
}
</style>

View File

@@ -51,6 +51,7 @@ const props = withDefaults(
hideIcon?: boolean;
hideLabel?: boolean;
tooltip?: string;
tooltipPlacement?: 'top' | 'bottom' | 'left' | 'right';
}>(),
{
disabled: false,
@@ -387,7 +388,11 @@ async function onClick() {
</script>
<template>
<N8nTooltip placement="right" :disabled="!tooltipText" :content="tooltipText">
<N8nTooltip
:placement="tooltipPlacement ?? 'right'"
:disabled="!tooltipText"
:content="tooltipText"
>
<N8nButton
v-bind="$attrs"
:loading="isLoading"

View File

@@ -7,7 +7,7 @@ import type {
NodeParameterValue,
INodeCredentialDescription,
} from 'n8n-workflow';
import { NodeHelpers, deepCopy } from 'n8n-workflow';
import { NodeConnectionTypes, NodeHelpers, deepCopy } from 'n8n-workflow';
import type {
CurlToJSONResponse,
INodeUi,
@@ -15,12 +15,19 @@ import type {
IUpdateInformation,
} from '@/Interface';
import { COMMUNITY_NODES_INSTALLATION_DOCS_URL, CUSTOM_NODES_DOCS_URL } from '@/constants';
import {
BASE_NODE_SURVEY_URL,
COMMUNITY_NODES_INSTALLATION_DOCS_URL,
CUSTOM_NODES_DOCS_URL,
NDV_UI_OVERHAUL_EXPERIMENT,
} from '@/constants';
import ParameterInputList from '@/components/ParameterInputList.vue';
import NodeCredentials from '@/components/NodeCredentials.vue';
import NodeSettingsTabs, { type Tab } from '@/components/NodeSettingsTabs.vue';
import NodeWebhooks from '@/components/NodeWebhooks.vue';
import NDVSubConnections from '@/components/NDVSubConnections.vue';
import NodeSettingsHeader from '@/components/NodeSettingsHeader.vue';
import get from 'lodash/get';
import NodeExecuteButton from './NodeExecuteButton.vue';
@@ -39,6 +46,7 @@ import { useTelemetry } from '@/composables/useTelemetry';
import { importCurlEventBus, ndvEventBus } from '@/event-bus';
import { ProjectTypes } from '@/types/projects.types';
import FreeAiCreditsCallout from '@/components/FreeAiCreditsCallout.vue';
import { usePostHog } from '@/stores/posthog.store';
import { shouldShowParameter } from './canvas/experimental/experimentalNdv.utils';
import { useResizeObserver } from '@vueuse/core';
import { useNodeSettingsParameters } from '@/composables/useNodeSettingsParameters';
@@ -90,6 +98,7 @@ const ndvStore = useNDVStore();
const workflowsStore = useWorkflowsStore();
const credentialsStore = useCredentialsStore();
const historyStore = useHistoryStore();
const posthogStore = usePostHog();
const telemetry = useTelemetry();
const nodeHelpers = useNodeHelpers();
@@ -110,7 +119,7 @@ if (props.isEmbeddedInCanvas) {
}
const nodeValid = ref(true);
const openPanel = ref<'params' | 'settings'>('params');
const openPanel = ref<Tab>('params');
// Used to prevent nodeValues from being overwritten by defaults on reopening ndv
const nodeValuesInitialized = ref(false);
@@ -236,6 +245,20 @@ const credentialOwnerName = computed(() => {
return credentialsStore.getCredentialOwnerName(credential);
});
const isNDVV2 = computed(() =>
posthogStore.isVariantEnabled(
NDV_UI_OVERHAUL_EXPERIMENT.name,
NDV_UI_OVERHAUL_EXPERIMENT.variant,
),
);
const featureRequestUrl = computed(() => {
if (!nodeType.value) {
return '';
}
return `${BASE_NODE_SURVEY_URL}${nodeType.value.name}`;
});
const valueChanged = (parameterData: IUpdateInformation) => {
let newValue: NodeParameterValue;
@@ -695,10 +718,23 @@ const openSettings = () => {
openPanel.value = 'settings';
};
const onTabSelect = (tab: 'params' | 'settings') => {
const onTabSelect = (tab: Tab) => {
openPanel.value = tab;
};
const onFeatureRequestClick = () => {
window.open(featureRequestUrl.value, '_blank');
if (node.value) {
telemetry.track('User clicked ndv link', {
node_type: node.value.type,
workflow_id: workflowsStore.workflowId,
push_ref: props.pushRef,
pane: NodeConnectionTypes.Main,
type: 'i-wish-this-node-would',
});
}
};
watch(node, () => {
setNodeValues();
});
@@ -749,7 +785,7 @@ function handleWheelEvent(event: WheelEvent) {
}"
@keydown.stop
>
<div :class="$style.header">
<div v-if="!isNDVV2" :class="$style.header">
<div class="header-side-menu">
<NodeTitle
v-if="node"
@@ -783,6 +819,21 @@ function handleWheelEvent(event: WheelEvent) {
@update:model-value="onTabSelect"
/>
</div>
<NodeSettingsHeader
v-else-if="node"
:selected-tab="openPanel"
:node-name="node.name"
:node-type="nodeType"
:execute-button-tooltip="executeButtonTooltip"
:hide-execute="!isExecutable || blockUI || !node || !nodeValid"
:disable-execute="outputPanelEditMode.enabled && !isTriggerNode"
:hide-tabs="!nodeValid"
:push-ref="pushRef"
@execute="onNodeExecute"
@stop-execution="onStopExecution"
@value-changed="valueChanged"
@tab-changed="onTabSelect"
/>
<div v-if="node && !nodeValid" class="node-is-not-valid">
<p :class="$style.warningIcon">
<n8n-icon icon="triangle-alert" />
@@ -832,6 +883,7 @@ function handleWheelEvent(event: WheelEvent) {
'node-parameters-wrapper',
shouldShowStaticScrollbar ? 'with-static-scrollbar' : '',
noWheel && shouldShowStaticScrollbar ? 'nowheel' : '',
{ 'ndv-v2': isNDVV2 },
]"
data-test-id="node-parameters"
@wheel="noWheel ? handleWheelEvent : undefined"
@@ -925,6 +977,12 @@ function handleWheelEvent(event: WheelEvent) {
<span>({{ nodeVersionTag }})</span>
</div>
</div>
<div v-if="isNDVV2 && featureRequestUrl" :class="$style.featureRequest">
<a target="_blank" @click="onFeatureRequestClick">
<N8nIcon icon="lightbulb" />
{{ i18n.baseText('ndv.featureRequest') }}
</a>
</div>
</div>
<NDVSubConnections
v-if="node && !props.isEmbeddedInCanvas"
@@ -951,6 +1009,22 @@ function handleWheelEvent(event: WheelEvent) {
display: flex;
flex-direction: column;
}
.featureRequest {
margin-top: auto;
align-self: center;
a {
display: inline-flex;
align-items: center;
gap: var(--spacing-4xs);
margin-top: var(--spacing-xl);
font-size: var(--font-size-3xs);
font-weight: var(--font-weight-bold);
color: var(--color-text-light);
}
}
</style>
<style lang="scss" scoped>
@@ -993,9 +1067,15 @@ function handleWheelEvent(event: WheelEvent) {
}
.node-parameters-wrapper {
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 0 var(--spacing-m) var(--spacing-l) var(--spacing-m);
flex-grow: 1;
&.ndv-v2 {
padding: 0 var(--spacing-s) var(--spacing-l) var(--spacing-s);
}
}
&.embedded .node-parameters-wrapper {

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
import type { IUpdateInformation } from '@/Interface';
import type { INodeTypeDescription } from 'n8n-workflow';
import { type Tab, default as NodeSettingsTabs } from './NodeSettingsTabs.vue';
import NodeExecuteButton from './NodeExecuteButton.vue';
type Props = {
nodeName: string;
hideExecute: boolean;
hideTabs: boolean;
disableExecute: boolean;
executeButtonTooltip: string;
selectedTab: Tab;
nodeType?: INodeTypeDescription | null;
pushRef: string;
};
defineProps<Props>();
const emit = defineEmits<{
execute: [];
'stop-execution': [];
'value-changed': [update: IUpdateInformation];
'tab-changed': [tab: Tab];
}>();
</script>
<template>
<div :class="$style.header">
<NodeSettingsTabs
v-if="!hideTabs"
hide-docs
:model-value="selectedTab"
:node-type="nodeType"
:push-ref="pushRef"
:class="$style.tabs"
@update:model-value="emit('tab-changed', $event)"
/>
<NodeExecuteButton
v-if="!hideExecute"
data-test-id="node-execute-button"
:node-name="nodeName"
:disabled="disableExecute"
:tooltip="executeButtonTooltip"
:class="$style.execute"
size="small"
telemetry-source="parameters"
@execute="emit('execute')"
@stop-execution="emit('stop-execution')"
@value-changed="emit('value-changed', $event)"
/>
</div>
</template>
<style lang="scss" module>
.header {
--spacing-bottom-tab: calc(var(--spacing-xs));
--font-size-tab: var(--font-size-2xs);
--color-tabs-arrow-buttons: var(--color-background-xlight);
--font-weight-tab: var(--font-weight-bold);
display: flex;
align-items: center;
min-height: 40px;
padding-right: var(--spacing-s);
border-bottom: var(--border-base);
}
.tabs {
padding-top: calc(var(--spacing-xs) + 1px);
height: 100%;
}
</style>

View File

@@ -1,10 +1,6 @@
<script setup lang="ts">
import type { ITab } from '@/Interface';
import {
BUILTIN_NODES_DOCS_URL,
COMMUNITY_NODES_INSTALLATION_DOCS_URL,
NPM_PACKAGE_DOCS_BASE_URL,
} from '@/constants';
import { COMMUNITY_NODES_INSTALLATION_DOCS_URL } from '@/constants';
import { useNDVStore } from '@/stores/ndv.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { INodeTypeDescription } from 'n8n-workflow';
@@ -15,12 +11,15 @@ import { useExternalHooks } from '@/composables/useExternalHooks';
import { useI18n } from '@n8n/i18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { isCommunityPackageName } from '@/utils/nodeTypesUtils';
import { N8nTabs } from '@n8n/design-system';
import { useNodeDocsUrl } from '@/composables/useNodeDocsUrl';
type Tab = 'settings' | 'params';
export type Tab = 'settings' | 'params' | 'communityNode' | 'docs';
type Props = {
modelValue?: Tab;
nodeType?: INodeTypeDescription | null;
pushRef?: string;
hideDocs?: boolean;
};
const props = withDefaults(defineProps<Props>(), {
@@ -37,6 +36,7 @@ const ndvStore = useNDVStore();
const workflowsStore = useWorkflowsStore();
const i18n = useI18n();
const telemetry = useTelemetry();
const { docsUrl } = useNodeDocsUrl({ nodeType: () => props.nodeType });
const activeNode = computed(() => ndvStore.activeNode);
@@ -51,38 +51,15 @@ const isCommunityNode = computed(() => {
const packageName = computed(() => props.nodeType?.name.split('.')[0] ?? '');
const documentationUrl = computed(() => {
const nodeType = props.nodeType;
if (!nodeType) {
if (props.hideDocs) {
return '';
}
if (nodeType.documentationUrl?.startsWith('http')) {
return nodeType.documentationUrl;
}
const utmParams = new URLSearchParams({
utm_source: 'n8n_app',
utm_medium: 'node_settings_modal-credential_link',
utm_campaign: nodeType.name,
});
// Built-in node documentation available via its codex entry
const primaryDocUrl = nodeType.codex?.resources?.primaryDocumentation?.[0]?.url;
if (primaryDocUrl) {
return `${primaryDocUrl}?${utmParams.toString()}`;
}
if (isCommunityNode.value) {
return `${NPM_PACKAGE_DOCS_BASE_URL}${packageName.value}`;
}
// Fallback to the root of the node documentation
return `${BUILTIN_NODES_DOCS_URL}?${utmParams.toString()}`;
return docsUrl.value;
});
const options = computed<ITab[]>(() => {
const options: ITab[] = [
const options = computed(() => {
const options: Array<ITab<Tab>> = [
{
label: i18n.baseText('nodeSettings.parameters'),
value: 'params',

View File

@@ -22,6 +22,8 @@ import { N8nRadioButtons, N8nText } from '@n8n/design-system';
import { useSettingsStore } from '@/stores/settings.store';
import { useNodeDirtiness } from '@/composables/useNodeDirtiness';
import { CanvasNodeDirtiness } from '@/types';
import { NDV_UI_OVERHAUL_EXPERIMENT } from '@/constants';
import { usePostHog } from '@/stores/posthog.store';
import { type IRunDataDisplayMode } from '@/Interface';
// Types
@@ -67,6 +69,7 @@ const emit = defineEmits<{
itemHover: [item: { itemIndex: number; outputIndex: number } | null];
search: [string];
openSettings: [];
execute: [];
displayModeChange: [IRunDataDisplayMode];
}>();
@@ -75,6 +78,7 @@ const emit = defineEmits<{
const ndvStore = useNDVStore();
const nodeTypesStore = useNodeTypesStore();
const workflowsStore = useWorkflowsStore();
const posthogStore = usePostHog();
const telemetry = useTelemetry();
const i18n = useI18n();
const { activeNode } = storeToRefs(ndvStore);
@@ -250,6 +254,13 @@ const allToolsWereUnusedNotice = computed(() => {
}
});
const isNDVV2 = computed(() =>
posthogStore.isVariantEnabled(
NDV_UI_OVERHAUL_EXPERIMENT.name,
NDV_UI_OVERHAUL_EXPERIMENT.variant,
),
);
// Methods
const insertTestData = () => {
@@ -335,6 +346,7 @@ const activatePane = () => {
:callout-message="allToolsWereUnusedNotice"
:display-mode="displayMode"
:disable-ai-content="true"
data-test-id="ndv-output-panel"
@activate-pane="activatePane"
@run-change="onRunIndexChange"
@link-run="onLinkRun"
@@ -345,7 +357,7 @@ const activatePane = () => {
@display-mode-change="emit('displayModeChange', $event)"
>
<template #header>
<div :class="$style.titleSection">
<div :class="[$style.titleSection, { [$style.titleSectionV2]: isNDVV2 }]">
<template v-if="hasAiMetadata">
<N8nRadioButtons
v-model="outputMode"
@@ -353,7 +365,7 @@ const activatePane = () => {
:options="outputTypes"
/>
</template>
<span v-else :class="$style.title">
<span v-else :class="[$style.title, { [$style.titleV2]: isNDVV2 }]">
{{ i18n.baseText(outputPanelEditMode.enabled ? 'ndv.output.edit' : 'ndv.output') }}
</span>
<RunInfo
@@ -371,6 +383,60 @@ const activatePane = () => {
</template>
<template #node-not-run>
<template v-if="isNDVV2">
<NDVEmptyState
:title="
i18n.baseText(
isTriggerNode
? 'ndv.output.noOutputData.trigger.title'
: 'ndv.output.noOutputData.v2.title',
)
"
>
<template v-if="isTriggerNode" #icon>
<svg width="16" viewBox="0 0 14 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M10.9062 2.40625L8.5 8.03125H12C12.4062 8.03125 12.7812 8.28125 12.9375 8.65625C13.0625 9.0625 12.9688 9.5 12.6562 9.78125L4.65625 16.7812C4.28125 17.0625 3.78125 17.0938 3.40625 16.8125C3.03125 16.5625 2.875 16.0625 3.0625 15.625L5.46875 10H2C1.5625 10 1.1875 9.75 1.0625 9.375C0.90625 8.96875 1 8.53125 1.3125 8.25L9.3125 1.25C9.6875 0.96875 10.1875 0.9375 10.5625 1.21875C10.9375 1.46875 11.0938 1.96875 10.9062 2.40625Z"
fill="currentColor"
/>
</svg>
</template>
<template v-else #icon>
<N8nIcon icon="arrow-right-from-line" size="xlarge" />
</template>
<template #description>
<i18n-t
tag="span"
:keypath="
isSubNodeType
? 'ndv.output.runNodeHintSubNode'
: 'ndv.output.noOutputData.v2.description'
"
>
<template #link>
<NodeExecuteButton
hide-icon
transparent
type="secondary"
:node-name="activeNode?.name ?? ''"
:label="
i18n.baseText(
isTriggerNode
? 'ndv.output.noOutputData.trigger.action'
: 'ndv.output.noOutputData.v2.action',
)
"
telemetry-source="inputs"
@execute="emit('execute')"
/>
<br />
</template>
</i18n-t>
</template>
</NDVEmptyState>
</template>
<template v-else>
<N8nText v-if="workflowRunning && !isTriggerNode" data-test-id="ndv-output-waiting">{{
i18n.baseText('ndv.output.waitingToRun')
}}</N8nText>
@@ -390,6 +456,7 @@ const activatePane = () => {
</template>
</N8nText>
</template>
</template>
<template #node-waiting>
<N8nText :bold="true" color="text-dark" size="large">
@@ -453,14 +520,23 @@ const activatePane = () => {
}
}
.titleSectionV2 {
padding-left: var(--spacing-4xs);
}
.title {
text-transform: uppercase;
color: var(--color-text-light);
letter-spacing: 3px;
letter-spacing: 2px;
font-weight: var(--font-weight-bold);
font-size: var(--font-size-s);
}
.titleV2 {
letter-spacing: 2px;
font-size: var(--font-size-xs);
}
.noOutputData {
max-width: 180px;
@@ -482,4 +558,11 @@ const activatePane = () => {
margin-bottom: var(--spacing-m);
}
}
.link {
display: inline;
padding: 0;
font-size: var(--font-size-s);
font-weight: var(--font-weight-regular);
}
</style>

View File

@@ -0,0 +1,84 @@
<script setup lang="ts">
import Draggable from './Draggable.vue';
import type { XYPosition } from '@/Interface';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
defineProps<{
canMoveRight: boolean;
canMoveLeft: boolean;
}>();
const emit = defineEmits<{
drag: [e: XYPosition];
dragstart: [];
dragend: [];
}>();
const onDrag = (e: XYPosition) => {
emit('drag', e);
};
const onDragEnd = () => {
emit('dragend');
};
const onDragStart = () => {
emit('dragstart');
};
</script>
<template>
<Draggable
:class="$style.dragContainer"
type="panel-resize"
cursor="ew-resize"
data-test-id="panel-drag-button"
@drag="onDrag"
@dragstart="onDragStart"
@dragend="onDragEnd"
>
<template #default="{ isDragging }">
<button :class="[$style.dragButton, { [$style.dragging]: isDragging }]">
<FontAwesomeIcon v-if="canMoveLeft" :class="$style.arrow" icon="arrow-left" />
<FontAwesomeIcon :class="$style.handle" icon="bars" />
<FontAwesomeIcon v-if="canMoveRight" :class="$style.arrow" icon="arrow-right" />
</button>
</template>
</Draggable>
</template>
<style lang="scss" module>
.dragButton {
cursor: ew-resize;
border: none;
outline: none;
background: var(--color-background-xlight);
display: flex;
align-items: baseline;
gap: var(--spacing-2xs);
padding: var(--spacing-4xs) var(--spacing-2xs) var(--spacing-4xs) var(--spacing-2xs);
color: var(--color-foreground-dark);
border: var(--border-base);
border-bottom: none;
border-top-left-radius: var(--border-radius-base);
border-top-right-radius: var(--border-radius-base);
.arrow {
opacity: 0;
width: 7px;
}
.handle {
width: 11px;
transform: rotate(90deg);
}
&:hover,
&.dragging {
.arrow {
opacity: 1;
}
}
}
</style>

View File

@@ -262,7 +262,7 @@ describe('RunData', () => {
it('should render pagination with binary data on non-binary tab', async () => {
const { getByTestId } = render({
defaultRunItems: Array.from({ length: 11 }).map((_, i) => ({
defaultRunItems: Array.from({ length: 26 }).map((_, i) => ({
json: {
data: {
id: i,

View File

@@ -41,7 +41,9 @@ import {
LOCAL_STORAGE_PIN_DATA_DISCOVERY_NDV_FLAG,
MAX_DISPLAY_DATA_SIZE,
MAX_DISPLAY_DATA_SIZE_SCHEMA_VIEW,
NDV_UI_OVERHAUL_EXPERIMENT,
NODE_TYPES_EXCLUDED_FROM_OUTPUT_NAME_APPEND,
RUN_DATA_DEFAULT_PAGE_SIZE,
TEST_PIN_DATA,
} from '@/constants';
@@ -94,6 +96,7 @@ import RunDataItemCount from '@/components/RunDataItemCount.vue';
import RunDataDisplayModeSelect from '@/components/RunDataDisplayModeSelect.vue';
import RunDataPaginationBar from '@/components/RunDataPaginationBar.vue';
import { parseAiContent } from '@/utils/aiUtils';
import { usePostHog } from '@/stores/posthog.store';
const LazyRunDataTable = defineAsyncComponent(
async () => await import('@/components/RunDataTable.vue'),
@@ -229,6 +232,7 @@ const sourceControlStore = useSourceControlStore();
const rootStore = useRootStore();
const uiStore = useUIStore();
const schemaPreviewStore = useSchemaPreviewStore();
const posthogStore = usePostHog();
const toast = useToast();
const route = useRoute();
@@ -585,6 +589,13 @@ const isSchemaPreviewEnabled = computed(
!(nodeType.value?.codex?.categories ?? []).some((category) => category === CORE_NODES_CATEGORY),
);
const isNDVV2 = computed(() =>
posthogStore.isVariantEnabled(
NDV_UI_OVERHAUL_EXPERIMENT.name,
NDV_UI_OVERHAUL_EXPERIMENT.variant,
),
);
const hasPreviewSchema = asyncComputed(async () => {
if (!isSchemaPreviewEnabled.value || props.nodes.length === 0) return false;
const nodes = props.nodes
@@ -1201,6 +1212,7 @@ function init() {
outputIndex.value = determineInitialOutputIndex();
refreshDataSize();
closeBinaryDataDisplay();
let outputTypes: NodeConnectionType[] = [];
if (node.value && nodeType.value) {
const outputs = getResolvedNodeOutputs();
@@ -1212,6 +1224,10 @@ function init() {
} else if (props.displayMode === 'binary') {
emit('displayModeChange', 'schema');
}
if (isNDVV2.value) {
pageSize.value = RUN_DATA_DEFAULT_PAGE_SIZE;
}
}
function closeBinaryDataDisplay() {
@@ -1344,7 +1360,11 @@ defineExpose({ enterEditMode });
<template>
<div
:class="['run-data', $style.container, props.compact ? $style.compact : '']"
:class="[
'run-data',
$style.container,
{ [$style['ndv-v2']]: isNDVV2, [$style.compact]: compact },
]"
@mouseover="activatePane"
>
<N8nCallout
@@ -1992,7 +2012,7 @@ defineExpose({ enterEditMode });
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--spacing-s) var(--spacing-s) var(--spacing-xl) var(--spacing-s);
padding: var(--ndv-spacing) var(--ndv-spacing) var(--spacing-xl) var(--ndv-spacing);
text-align: center;
> * {
@@ -2002,6 +2022,7 @@ defineExpose({ enterEditMode });
}
.container {
--ndv-spacing: var(--spacing-s);
position: relative;
width: 100%;
height: 100%;
@@ -2020,12 +2041,12 @@ defineExpose({ enterEditMode });
.header {
display: flex;
align-items: center;
margin-bottom: var(--spacing-s);
padding: var(--spacing-s) var(--spacing-s) 0 var(--spacing-s);
margin-bottom: var(--ndv-spacing);
padding: var(--ndv-spacing) var(--ndv-spacing) 0 var(--ndv-spacing);
position: relative;
overflow-x: auto;
overflow-y: hidden;
min-height: calc(30px + var(--spacing-s));
min-height: calc(30px + var(--ndv-spacing));
scrollbar-width: thin;
container-type: inline-size;
@@ -2053,7 +2074,7 @@ defineExpose({ enterEditMode });
position: absolute;
top: 0;
left: 0;
padding: 0 var(--spacing-s) var(--spacing-3xl) var(--spacing-s);
padding: 0 var(--ndv-spacing) var(--spacing-3xl) var(--ndv-spacing);
right: 0;
overflow-y: auto;
line-height: var(--font-line-height-xloose);
@@ -2067,18 +2088,18 @@ defineExpose({ enterEditMode });
.inlineError {
line-height: var(--font-line-height-xloose);
padding-left: var(--spacing-s);
padding-right: var(--spacing-s);
padding-bottom: var(--spacing-s);
padding-left: var(--ndv-spacing);
padding-right: var(--ndv-spacing);
padding-bottom: var(--ndv-spacing);
}
.outputs {
display: flex;
flex-direction: column;
gap: var(--spacing-s);
padding-left: var(--spacing-s);
padding-right: var(--spacing-s);
padding-bottom: var(--spacing-s);
gap: var(--ndv-spacing);
padding-left: var(--ndv-spacing);
padding-right: var(--ndv-spacing);
padding-bottom: var(--ndv-spacing);
.compact & {
padding-left: var(--spacing-2xs);
@@ -2100,25 +2121,29 @@ defineExpose({ enterEditMode });
display: flex;
align-items: center;
gap: var(--spacing-2xs);
padding-left: var(--spacing-s);
padding-right: var(--spacing-s);
padding-bottom: var(--spacing-s);
padding-left: var(--ndv-spacing);
padding-right: var(--ndv-spacing);
padding-bottom: var(--ndv-spacing);
flex-flow: wrap;
}
.ndv-v2 .itemsCount {
padding-left: var(--spacing-xs);
}
.inputSelect {
padding-left: var(--spacing-s);
padding-right: var(--spacing-s);
padding-bottom: var(--spacing-s);
padding-left: var(--ndv-spacing);
padding-right: var(--ndv-spacing);
padding-bottom: var(--ndv-spacing);
}
.runSelector {
display: flex;
align-items: center;
flex-flow: wrap;
padding-left: var(--spacing-s);
padding-right: var(--spacing-s);
margin-bottom: var(--spacing-s);
padding-left: var(--ndv-spacing);
padding-right: var(--ndv-spacing);
margin-bottom: var(--ndv-spacing);
gap: var(--spacing-3xs);
:global(.el-input--suffix .el-input__inner) {
@@ -2168,11 +2193,11 @@ defineExpose({ enterEditMode });
width: 300px;
overflow: hidden;
background-color: var(--color-foreground-xlight);
margin-right: var(--spacing-s);
margin-bottom: var(--spacing-s);
margin-right: var(--ndv-spacing);
margin-bottom: var(--ndv-spacing);
border-radius: var(--border-radius-base);
border: var(--border-base);
padding: var(--spacing-s);
padding: var(--ndv-spacing);
}
.binaryHeader {
@@ -2227,7 +2252,7 @@ defineExpose({ enterEditMode });
display: flex;
justify-content: center;
margin-bottom: var(--spacing-s);
margin-bottom: var(--ndv-spacing);
}
.editMode {
@@ -2235,8 +2260,8 @@ defineExpose({ enterEditMode });
display: flex;
flex-direction: column;
justify-content: stretch;
padding-left: var(--spacing-s);
padding-right: var(--spacing-s);
padding-left: var(--ndv-spacing);
padding-right: var(--ndv-spacing);
}
.editModeBody {
@@ -2252,8 +2277,8 @@ defineExpose({ enterEditMode });
width: 100%;
justify-content: space-between;
align-items: center;
padding-top: var(--spacing-s);
padding-bottom: var(--spacing-s);
padding-top: var(--ndv-spacing);
padding-bottom: var(--ndv-spacing);
}
.editModeFooterInfotip {
@@ -2266,7 +2291,7 @@ defineExpose({ enterEditMode });
display: flex;
justify-content: flex-end;
align-items: center;
margin-left: var(--spacing-s);
margin-left: var(--ndv-spacing);
}
.stretchVertically {
@@ -2280,8 +2305,8 @@ defineExpose({ enterEditMode });
.hintCallout {
margin-bottom: var(--spacing-xs);
margin-left: var(--spacing-s);
margin-right: var(--spacing-s);
margin-left: var(--ndv-spacing);
margin-right: var(--ndv-spacing);
.compact & {
margin: 0 var(--spacing-2xs) var(--spacing-2xs) var(--spacing-2xs);
@@ -2289,7 +2314,7 @@ defineExpose({ enterEditMode });
}
.schema {
padding: 0 var(--spacing-s);
padding: 0 var(--ndv-spacing);
}
.search,
@@ -2317,6 +2342,11 @@ defineExpose({ enterEditMode });
width: 0;
}
}
.ndv-v2,
.compact {
--ndv-spacing: var(--spacing-2xs);
}
</style>
<style lang="scss" scoped>

View File

@@ -681,7 +681,7 @@ watch(focusedMappableInput, (curr) => {
position: absolute;
top: 0;
left: 0;
padding-left: var(--spacing-s);
padding-left: var(--spacing-xs);
right: 0;
overflow-y: auto;
line-height: 1.5;
@@ -855,7 +855,7 @@ watch(focusedMappableInput, (curr) => {
.tableRightMargin {
// becomes necessary with large tables
width: var(--spacing-s);
width: var(--ndv-spacing);
border-right: none !important;
border-top: none !important;
border-bottom: none !important;

View File

@@ -477,7 +477,7 @@ const onNodeExecute = () => {
position: relative;
width: 100%;
height: 100%;
background-color: var(--color-background-base);
background-color: var(--color-run-data-background);
display: flex;
flex-direction: column;

View File

@@ -532,7 +532,7 @@ const onDragEnd = (el: HTMLElement) => {
}
.scroller {
padding: 0 var(--spacing-s);
padding: 0 var(--ndv-spacing);
padding-bottom: var(--spacing-2xl);
.compact & {
@@ -548,14 +548,14 @@ const onDragEnd = (el: HTMLElement) => {
text-align: center;
height: 100%;
gap: var(--spacing-2xs);
padding: var(--spacing-s) var(--spacing-s) var(--spacing-xl) var(--spacing-s);
padding: var(--ndv-spacing) var(--ndv-spacing) var(--spacing-xl) var(--ndv-spacing);
}
.icon {
display: inline-flex;
margin-left: var(--spacing-xl);
color: var(--color-text-light);
margin-bottom: var(--spacing-s);
margin-bottom: var(--ndv-spacing);
}
.notice {

View File

@@ -1,8 +1,8 @@
import { useActiveElement, useEventListener } from '@vueuse/core';
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
import type { MaybeRef, Ref } from 'vue';
import { computed, inject, ref, unref } from 'vue';
import { PiPWindowSymbol } from '@/constants';
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
import { useActiveElement, useEventListener } from '@vueuse/core';
import type { MaybeRefOrGetter } from 'vue';
import { computed, inject, ref, toValue } from 'vue';
type KeyboardEventHandler =
| ((event: KeyboardEvent) => void)
@@ -25,16 +25,16 @@ export type KeyMap = Partial<Record<string, KeyboardEventHandler>>;
* ```
*/
export const useKeybindings = (
keymap: Ref<KeyMap>,
keymap: MaybeRefOrGetter<KeyMap>,
options?: {
disabled: MaybeRef<boolean>;
disabled: MaybeRefOrGetter<boolean>;
},
) => {
const pipWindow = inject(PiPWindowSymbol, ref<Window | undefined>());
const activeElement = useActiveElement({ window: pipWindow?.value });
const { isCtrlKeyPressed } = useDeviceSupport();
const isDisabled = computed(() => unref(options?.disabled));
const isDisabled = computed(() => toValue(options?.disabled));
const ignoreKeyPresses = computed(() => {
if (!activeElement.value) return false;
@@ -49,7 +49,7 @@ export const useKeybindings = (
const normalizedKeymap = computed(() =>
Object.fromEntries(
Object.entries(keymap.value).flatMap(([shortcut, handler]) => {
Object.entries(toValue(keymap)).flatMap(([shortcut, handler]) => {
const shortcuts = shortcut.split('|');
return shortcuts.map((s) => [normalizeShortcutString(s), handler]);
}),

View File

@@ -0,0 +1,87 @@
import { ref, type Ref } from 'vue';
import { useNdvLayout } from './useNdvLayout';
import { LOCAL_STORAGE_NDV_PANEL_WIDTH } from '../constants';
import { mock } from 'vitest-mock-extended';
vi.mock('@vueuse/core', () => {
return {
useElementSize: () => ({
width: ref(1000),
height: ref(500),
}),
};
});
describe('useNdvLayout', () => {
let containerRef: HTMLDivElement;
let container: Ref<HTMLElement | null>;
let hasInputPanel: Ref<boolean>;
let paneType: Ref<'regular' | 'inputless' | 'wide'>;
beforeEach(() => {
containerRef = document.createElement('div');
container = ref(containerRef);
hasInputPanel = ref(true);
paneType = ref('regular');
localStorage.clear();
});
it('sets default panel sizes for "regular" layout', () => {
const { panelWidthPercentage } = useNdvLayout({ container, hasInputPanel, paneType });
expect(panelWidthPercentage.value.main).toBeGreaterThan(0);
expect(
panelWidthPercentage.value.left +
panelWidthPercentage.value.main +
panelWidthPercentage.value.right,
).toBeCloseTo(100);
});
it('loads and uses stored values from localStorage', () => {
const key = `${LOCAL_STORAGE_NDV_PANEL_WIDTH}_REGULAR`;
localStorage.setItem(key, JSON.stringify({ left: 30, main: 40, right: 30 }));
const { panelWidthPercentage } = useNdvLayout({ container, hasInputPanel, paneType });
expect(panelWidthPercentage.value).toEqual({ left: 30, main: 40, right: 30 });
});
it('enforces minimum panel sizes', () => {
const key = `${LOCAL_STORAGE_NDV_PANEL_WIDTH}_REGULAR`;
localStorage.setItem(key, JSON.stringify({ left: 0, main: 5, right: 0 }));
const { panelWidthPercentage } = useNdvLayout({ container, hasInputPanel, paneType });
expect(panelWidthPercentage.value.left).toBeCloseTo(12);
expect(panelWidthPercentage.value.right).toBeCloseTo(12);
});
it('updates layout on resize (left)', () => {
const { panelWidthPercentage, onResize } = useNdvLayout({ container, hasInputPanel, paneType });
onResize(mock({ width: 500, direction: 'left' }));
expect(panelWidthPercentage.value.main).toBeGreaterThanOrEqual(50);
});
it('updates layout on resize (right)', () => {
const { panelWidthPercentage, onResize } = useNdvLayout({ container, hasInputPanel, paneType });
onResize(mock({ width: 500, direction: 'right' }));
expect(panelWidthPercentage.value.main).toBeGreaterThanOrEqual(50);
});
it('updates layout on drag', () => {
const { panelWidthPercentage, onDrag } = useNdvLayout({ container, hasInputPanel, paneType });
onDrag([300, 0]);
expect(panelWidthPercentage.value.left).toBeCloseTo(12);
expect(panelWidthPercentage.value.main).toBeCloseTo(42);
expect(panelWidthPercentage.value.right).toBeCloseTo(46);
});
it('persists layout changes on resize end', () => {
const { onResizeEnd } = useNdvLayout({ container, hasInputPanel, paneType });
const spy = vi.spyOn(localStorage.__proto__, 'setItem');
onResizeEnd();
expect(spy).toHaveBeenCalledWith(expect.stringContaining('_REGULAR'), expect.any(String));
});
});

View File

@@ -0,0 +1,200 @@
import { useElementSize } from '@vueuse/core';
import { jsonParse } from 'n8n-workflow';
import type { MaybeRefOrGetter } from 'vue';
import { computed, ref, toRef, toValue, watch } from 'vue';
import type { MainPanelType, ResizeData, XYPosition } from '../Interface';
import { LOCAL_STORAGE_NDV_PANEL_WIDTH } from '../constants';
interface UseNdvLayoutOptions {
container: MaybeRefOrGetter<HTMLElement | null>;
hasInputPanel: MaybeRefOrGetter<boolean>;
paneType: MaybeRefOrGetter<MainPanelType>;
}
type NdvPanelsSize = {
left: number;
main: number;
right: number;
};
export function useNdvLayout(options: UseNdvLayoutOptions) {
const MIN_MAIN_PANEL_WIDTH_PX = 368;
const MIN_PANEL_WIDTH_PX = 120;
const DEFAULT_INPUTLESS_MAIN_WIDTH_PX = 480;
const DEFAULT_WIDE_MAIN_WIDTH_PX = 640;
const DEFAULT_REGULAR_MAIN_WIDTH_PX = 420;
const panelWidthPercentage = ref<NdvPanelsSize>({ left: 40, main: 20, right: 40 });
const localStorageKey = computed(
() => `${LOCAL_STORAGE_NDV_PANEL_WIDTH}_${toValue(options.paneType).toUpperCase()}`,
);
const containerSize = useElementSize(options.container);
const containerWidth = computed(() => containerSize.width.value);
const percentageToPixels = (percentage: number) => {
return (percentage / 100) * containerWidth.value;
};
const pixelsToPercentage = (pixels: number) => {
return (pixels / containerWidth.value) * 100;
};
const minMainPanelWidthPercentage = computed(() => pixelsToPercentage(MIN_MAIN_PANEL_WIDTH_PX));
const panelWidthPixels = computed(() => ({
left: percentageToPixels(panelWidthPercentage.value.left),
main: percentageToPixels(panelWidthPercentage.value.main),
right: percentageToPixels(panelWidthPercentage.value.right),
}));
const minPanelWidthPercentage = computed(() => pixelsToPercentage(MIN_PANEL_WIDTH_PX));
const defaultPanelSize = computed(() => {
switch (toValue(options.paneType)) {
case 'inputless': {
const main = pixelsToPercentage(DEFAULT_INPUTLESS_MAIN_WIDTH_PX);
return { left: 0, main, right: 100 - main };
}
case 'wide': {
const main = pixelsToPercentage(DEFAULT_WIDE_MAIN_WIDTH_PX);
const panels = (100 - main) / 2;
return { left: panels, main, right: panels };
}
case 'dragless':
case 'unknown':
case 'regular':
default: {
const main = pixelsToPercentage(DEFAULT_REGULAR_MAIN_WIDTH_PX);
const panels = (100 - main) / 2;
return { left: panels, main, right: panels };
}
}
});
const safePanelWidth = ({ left, main, right }: { left: number; main: number; right: number }) => {
const hasInput = toValue(options.hasInputPanel);
const minLeft = hasInput ? minPanelWidthPercentage.value : 0;
const minRight = minPanelWidthPercentage.value;
const minMain = minMainPanelWidthPercentage.value;
const newPanelWidth = {
left: Math.max(minLeft, left),
main: Math.max(minMain, main),
right: Math.max(minRight, right),
};
const total = newPanelWidth.left + newPanelWidth.main + newPanelWidth.right;
if (total > 100) {
const overflow = total - 100;
const trimLeft = (newPanelWidth.left / (newPanelWidth.left + newPanelWidth.right)) * overflow;
const trimRight = overflow - trimLeft;
newPanelWidth.left = Math.max(minLeft, newPanelWidth.left - trimLeft);
newPanelWidth.right = Math.max(minRight, newPanelWidth.right - trimRight);
}
return newPanelWidth;
};
const persistPanelSize = () => {
localStorage.setItem(localStorageKey.value, JSON.stringify(panelWidthPercentage.value));
};
const loadPanelSize = () => {
const storedPanelSizeString = localStorage.getItem(localStorageKey.value);
const defaultSize = defaultPanelSize.value;
if (storedPanelSizeString) {
const storedPanelSize = jsonParse<NdvPanelsSize>(storedPanelSizeString, {
fallbackValue: defaultSize,
});
panelWidthPercentage.value = safePanelWidth(storedPanelSize ?? defaultSize);
} else {
panelWidthPercentage.value = safePanelWidth(defaultSize);
}
};
const onResizeEnd = () => {
persistPanelSize();
};
const onResize = (event: ResizeData) => {
const newMain = Math.max(minMainPanelWidthPercentage.value, pixelsToPercentage(event.width));
const initialLeft = panelWidthPercentage.value.left;
const initialMain = panelWidthPercentage.value.main;
const initialRight = panelWidthPercentage.value.right;
const diffMain = newMain - initialMain;
if (event.direction === 'left') {
const potentialLeft = initialLeft - diffMain;
if (potentialLeft < minPanelWidthPercentage.value) return;
const newLeft = Math.max(minPanelWidthPercentage.value, potentialLeft);
const newRight = initialRight;
panelWidthPercentage.value = safePanelWidth({
left: newLeft,
main: newMain,
right: newRight,
});
} else if (event.direction === 'right') {
const potentialRight = initialRight - diffMain;
if (potentialRight < minPanelWidthPercentage.value) return;
const newRight = Math.max(minPanelWidthPercentage.value, potentialRight);
const newLeft = initialLeft;
panelWidthPercentage.value = safePanelWidth({
left: newLeft,
main: newMain,
right: newRight,
});
}
};
const onDrag = (position: XYPosition) => {
const newLeft = Math.max(
minPanelWidthPercentage.value,
pixelsToPercentage(position[0]) - panelWidthPercentage.value.main / 2,
);
const newRight = Math.max(
minPanelWidthPercentage.value,
100 - newLeft - panelWidthPercentage.value.main,
);
if (newLeft + panelWidthPercentage.value.main + newRight > 100) {
return;
}
panelWidthPercentage.value.left = newLeft;
panelWidthPercentage.value.right = newRight;
};
watch(containerWidth, (newWidth, oldWidth) => {
if (!newWidth) return;
if (!oldWidth) {
loadPanelSize();
} else {
panelWidthPercentage.value = safePanelWidth(panelWidthPercentage.value);
}
});
watch(
toRef(options.paneType),
() => {
loadPanelSize();
},
{ immediate: true },
);
return {
containerWidth,
panelWidthPercentage,
panelWidthPixels,
onResize,
onDrag,
onResizeEnd,
};
}

View File

@@ -0,0 +1,63 @@
import { NPM_PACKAGE_DOCS_BASE_URL } from '../constants';
import { useNodeDocsUrl } from './useNodeDocsUrl';
import type { INodeTypeDescription } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
describe('useNodeDocsUrl', () => {
it('returns full documentationUrl if set', () => {
const nodeType = mock<INodeTypeDescription>({
name: 'n8n-nodes-base.set',
documentationUrl: 'https://example.com/docs',
});
const { docsUrl } = useNodeDocsUrl({ nodeType });
expect(docsUrl.value).toBe('https://example.com/docs');
});
it('returns codex primaryDocumentation url with UTM params', () => {
const nodeType = mock<INodeTypeDescription>({
name: 'n8n-nodes-base.set',
documentationUrl: '',
codex: {
resources: {
primaryDocumentation: [{ url: 'https://docs.n8n.io/nodes/MyNode' }],
},
},
});
const { docsUrl } = useNodeDocsUrl({ nodeType });
expect(docsUrl.value).toEqual(
'https://docs.n8n.io/nodes/MyNode?utm_source=n8n_app&utm_medium=node_settings_modal-credential_link&utm_campaign=n8n-nodes-base.set',
);
});
it('returns community docs url for community-nodes', () => {
const nodeType = mock<INodeTypeDescription>({
name: 'n8n-nodes-custom.custom',
documentationUrl: '',
});
const { docsUrl } = useNodeDocsUrl({ nodeType });
expect(docsUrl.value).toBe(`${NPM_PACKAGE_DOCS_BASE_URL}n8n-nodes-custom`);
});
it('returns builtin docs root with UTM if no other match', () => {
const nodeType = mock<INodeTypeDescription>({
name: 'n8n-nodes-base.set',
documentationUrl: '',
});
const { docsUrl } = useNodeDocsUrl({ nodeType });
expect(docsUrl.value).toEqual(
'https://docs.n8n.io/integrations/builtin/?utm_source=n8n_app&utm_medium=node_settings_modal-credential_link&utm_campaign=n8n-nodes-base.set',
);
});
it('returns empty string if documentationUrl is null', () => {
const nodeType = null;
const { docsUrl } = useNodeDocsUrl({ nodeType });
expect(docsUrl.value).toBe('');
});
});

View File

@@ -0,0 +1,50 @@
import type { INodeTypeDescription } from 'n8n-workflow';
import { computed, toValue, type MaybeRefOrGetter } from 'vue';
import { isCommunityPackageName } from '../utils/nodeTypesUtils';
import { BUILTIN_NODES_DOCS_URL, NPM_PACKAGE_DOCS_BASE_URL } from '../constants';
export const useNodeDocsUrl = ({
nodeType: nodeTypeRef,
}: { nodeType: MaybeRefOrGetter<INodeTypeDescription | null | undefined> }) => {
const packageName = computed(() => toValue(nodeTypeRef)?.name.split('.')[0] ?? '');
const isCommunityNode = computed(() => {
const nodeType = toValue(nodeTypeRef);
if (nodeType) {
return isCommunityPackageName(nodeType.name);
}
return false;
});
const docsUrl = computed(() => {
const nodeType = toValue(nodeTypeRef);
if (!nodeType) {
return '';
}
if (nodeType.documentationUrl?.startsWith('http')) {
return nodeType.documentationUrl;
}
const utmParams = new URLSearchParams({
utm_source: 'n8n_app',
utm_medium: 'node_settings_modal-credential_link',
utm_campaign: nodeType.name,
});
// Built-in node documentation available via its codex entry
const primaryDocUrl = nodeType.codex?.resources?.primaryDocumentation?.[0]?.url;
if (primaryDocUrl) {
return `${primaryDocUrl}?${utmParams.toString()}`;
}
if (isCommunityNode.value) {
return `${NPM_PACKAGE_DOCS_BASE_URL}${packageName.value}`;
}
// Fallback to the root of the node documentation
return `${BUILTIN_NODES_DOCS_URL}?${utmParams.toString()}`;
});
return { docsUrl };
};

View File

@@ -475,6 +475,7 @@ export const LOCAL_STORAGE_MAPPING_IS_ONBOARDED = 'N8N_MAPPING_ONBOARDED';
export const LOCAL_STORAGE_AUTOCOMPLETE_IS_ONBOARDED = 'N8N_AUTOCOMPLETE_ONBOARDED';
export const LOCAL_STORAGE_TABLE_HOVER_IS_ONBOARDED = 'N8N_TABLE_HOVER_ONBOARDED';
export const LOCAL_STORAGE_MAIN_PANEL_RELATIVE_WIDTH = 'N8N_MAIN_PANEL_RELATIVE_WIDTH';
export const LOCAL_STORAGE_NDV_DIMENSIONS = 'N8N_NDV_DIMENSIONS';
export const LOCAL_STORAGE_ACTIVE_MODAL = 'N8N_ACTIVE_MODAL';
export const LOCAL_STORAGE_THEME = 'N8N_THEME';
export const LOCAL_STORAGE_EXPERIMENT_OVERRIDES = 'N8N_EXPERIMENT_OVERRIDES';
@@ -492,6 +493,7 @@ export const LOCAL_STORAGE_EXPERIMENTAL_DOCKED_NODE_SETTINGS =
'N8N_EXPERIMENTAL_DOCKED_NODE_SETTINGS';
export const LOCAL_STORAGE_READ_WHATS_NEW_ARTICLES = 'N8N_READ_WHATS_NEW_ARTICLES';
export const LOCAL_STORAGE_DISMISSED_WHATS_NEW_CALLOUT = 'N8N_DISMISSED_WHATS_NEW_CALLOUT';
export const LOCAL_STORAGE_NDV_PANEL_WIDTH = 'N8N_NDV_PANEL_WIDTH';
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
export const COMMUNITY_PLUS_DOCS_URL =
@@ -727,6 +729,12 @@ export const KEEP_AUTH_IN_NDV_FOR_NODES = [
export const MAIN_AUTH_FIELD_NAME = 'authentication';
export const NODE_RESOURCE_FIELD_NAME = 'resource';
export const NDV_UI_OVERHAUL_EXPERIMENT = {
name: '029_ndv_ui_overhaul',
control: 'control',
variant: 'variant',
};
export const WORKFLOW_BUILDER_EXPERIMENT = {
name: '30_workflow_builder',
control: 'control',
@@ -920,3 +928,5 @@ export const APP_MODALS_ELEMENT_ID = 'app-modals';
export const AI_NODES_PACKAGE_NAME = '@n8n/n8n-nodes-langchain';
export const AI_ASSISTANT_MAX_CONTENT_LENGTH = 100; // in kilobytes
export const RUN_DATA_DEFAULT_PAGE_SIZE = 25;

View File

@@ -27,7 +27,6 @@ import type {
AddedNodesAndConnections,
IExecutionResponse,
INodeUi,
IUpdateInformation,
IWorkflowDb,
NodeCreatorOpenSource,
NodeFilterType,
@@ -67,11 +66,11 @@ import {
STICKY_NODE_TYPE,
VALID_WORKFLOW_IMPORT_URL_REGEX,
VIEWS,
NDV_UI_OVERHAUL_EXPERIMENT,
WORKFLOW_SETTINGS_MODAL_KEY,
} from '@/constants';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
import { usePostHog } from '@/stores/posthog.store';
import { useExternalHooks } from '@/composables/useExternalHooks';
import {
NodeConnectionTypes,
@@ -129,6 +128,7 @@ import type { CanvasLayoutEvent } from '@/composables/useCanvasLayout';
import { useWorkflowSaving } from '@/composables/useWorkflowSaving';
import { useBuilderStore } from '@/stores/builder.store';
import { useFoldersStore } from '@/stores/folders.store';
import { usePostHog } from '@/stores/posthog.store';
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
import { useWorkflowExtraction } from '@/composables/useWorkflowExtraction';
import { useAgentRequestStore } from '@n8n/stores/useAgentRequestStore';
@@ -149,6 +149,9 @@ const LazyNodeCreation = defineAsyncComponent(
const LazyNodeDetailsView = defineAsyncComponent(
async () => await import('@/components/NodeDetailsView.vue'),
);
const LazyNodeDetailsViewV2 = defineAsyncComponent(
async () => await import('@/components/NodeDetailsViewV2.vue'),
);
const LazySetupWorkflowCredentialsButton = defineAsyncComponent(
async () =>
@@ -191,6 +194,7 @@ const focusPanelStore = useFocusPanelStore();
const templatesStore = useTemplatesStore();
const builderStore = useBuilderStore();
const foldersStore = useFoldersStore();
const posthogStore = usePostHog();
const agentRequestStore = useAgentRequestStore();
const logsStore = useLogsStore();
@@ -278,6 +282,12 @@ const isReadOnlyRoute = computed(() => !!route?.meta?.readOnlyCanvas);
const isReadOnlyEnvironment = computed(() => {
return sourceControlStore.preferences.branchReadOnly;
});
const isNDVV2 = computed(() =>
posthogStore.isVariantEnabled(
NDV_UI_OVERHAUL_EXPERIMENT.name,
NDV_UI_OVERHAUL_EXPERIMENT.variant,
),
);
const isCanvasReadOnly = computed(() => {
return (
@@ -875,9 +885,9 @@ async function onCreateWorkflow() {
await router.push({ name: VIEWS.NEW_WORKFLOW });
}
function onRenameNode(parameterData: IUpdateInformation) {
if (parameterData.name === 'name' && parameterData.oldValue) {
void renameNode(parameterData.oldValue as string, parameterData.value as string);
function onRenameNode(name: string) {
if (ndvStore.activeNode?.name) {
void renameNode(ndvStore.activeNode.name, name);
}
}
@@ -2117,19 +2127,30 @@ onBeforeUnmount(() => {
</Suspense>
<Suspense>
<LazyNodeDetailsView
v-if="!isNDVV2"
:workflow-object="editableWorkflowObject"
:read-only="isCanvasReadOnly"
:is-production-execution-preview="isProductionExecutionPreview"
:renaming="false"
@value-changed="onRenameNode"
@value-changed="onRenameNode($event.value as string)"
@stop-execution="onStopExecution"
@switch-selected-node="onSwitchActiveNode"
@open-connection-node-creator="onOpenSelectiveNodeCreator"
@save-keyboard-shortcut="onSaveWorkflow"
/>
</Suspense>
<Suspense>
<LazyNodeDetailsViewV2
v-if="isNDVV2"
:workflow-object="editableWorkflowObject"
:read-only="isCanvasReadOnly"
:is-production-execution-preview="isProductionExecutionPreview"
@rename-node="onRenameNode"
@stop-execution="onStopExecution"
@switch-selected-node="onSwitchActiveNode"
@open-connection-node-creator="onOpenSelectiveNodeCreator"
@save-keyboard-shortcut="onSaveWorkflow"
/>
<!--
:renaming="renamingActive"
-->
</Suspense>
</WorkflowCanvas>
<FocusPanel v-if="isFocusPanelFeatureEnabled" :executable="!isCanvasReadOnly" />