feat: Add sticky notes support to the new canvas (no-changelog) (#10031)

This commit is contained in:
Alex Grozav 2024-07-15 13:00:52 +03:00 committed by GitHub
parent 9302e33d55
commit cd24c71a9e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 653 additions and 147 deletions

View file

@ -0,0 +1,69 @@
import { action } from '@storybook/addon-actions';
import type { StoryFn } from '@storybook/vue3';
import N8nResizeableSticky from './ResizeableSticky.vue';
export default {
title: 'Atoms/ResizeableSticky',
component: N8nResizeableSticky,
argTypes: {
content: {
control: {
control: 'text',
},
},
height: {
control: {
control: 'number',
},
},
minHeight: {
control: {
control: 'number',
},
},
minWidth: {
control: {
control: 'number',
},
},
readOnly: {
control: {
control: 'Boolean',
},
},
width: {
control: {
control: 'number',
},
},
},
};
const methods = {
onInput: action('update:modelValue'),
onResize: action('resize'),
onResizeEnd: action('resizeend'),
onResizeStart: action('resizestart'),
};
const Template: StoryFn = (args, { argTypes }) => ({
setup: () => ({ args }),
props: Object.keys(argTypes),
components: {
N8nResizeableSticky,
},
template:
'<n8n-resizeable-sticky v-bind="args" @resize="onResize" @resizeend="onResizeEnd" @resizeStart="onResizeStart" @input="onInput"></n8n-resizeable-sticky>',
methods,
});
export const ResizeableSticky = Template.bind({});
ResizeableSticky.args = {
height: 160,
width: 150,
modelValue:
"## I'm a note \n**Double click** to edit me. [Guide](https://docs.n8n.io/workflows/sticky-notes/)",
minHeight: 80,
minWidth: 150,
readOnly: false,
};

View file

@ -0,0 +1,61 @@
<template>
<N8nResizeWrapper
:is-resizing-enabled="!readOnly"
:height="height"
:width="width"
:min-height="minHeight"
:min-width="minWidth"
:scale="scale"
:grid-size="gridSize"
@resizeend="onResizeEnd"
@resize="onResize"
@resizestart="onResizeStart"
>
<N8nSticky v-bind="stickyBindings" />
</N8nResizeWrapper>
</template>
<script lang="ts" setup>
import { computed, ref, useAttrs } from 'vue';
import N8nResizeWrapper, { type ResizeData } from '../N8nResizeWrapper/ResizeWrapper.vue';
import N8nSticky from '../N8nSticky/Sticky.vue';
import type { StickyProps } from '../N8nSticky/types';
import { defaultStickyProps } from '../N8nSticky/constants';
type ResizeableStickyProps = StickyProps & {
scale?: number;
gridSize?: number;
};
const props = withDefaults(defineProps<ResizeableStickyProps>(), {
...defaultStickyProps,
scale: 1,
gridSize: 20,
});
const emit = defineEmits<{
resize: [values: ResizeData];
resizestart: [];
resizeend: [];
}>();
const attrs = useAttrs();
const stickyBindings = computed(() => ({ ...props, ...attrs }));
const isResizing = ref(false);
const onResize = (values: ResizeData) => {
emit('resize', values);
};
const onResizeStart = () => {
isResizing.value = true;
emit('resizestart');
};
const onResizeEnd = () => {
isResizing.value = false;
emit('resizeend');
};
</script>

View file

@ -0,0 +1,3 @@
import ResizeableSticky from './ResizeableSticky.vue';
export default ResizeableSticky;

View file

@ -61,9 +61,7 @@ export const Sticky = Template.bind({});
Sticky.args = {
height: 160,
width: 150,
content:
"## I'm a note \n**Double click** to edit me. [Guide](https://docs.n8n.io/workflows/sticky-notes/)",
defaultText:
modelValue:
"## I'm a note \n**Double click** to edit me. [Guide](https://docs.n8n.io/workflows/sticky-notes/)",
minHeight: 80,
minWidth: 150,

View file

@ -9,52 +9,40 @@
:style="styles"
@keydown.prevent
>
<N8nResizeWrapper
:is-resizing-enabled="!readOnly"
:height="height"
:width="width"
:min-height="minHeight"
:min-width="minWidth"
:scale="scale"
:grid-size="gridSize"
@resizeend="onResizeEnd"
@resize="onResize"
@resizestart="onResizeStart"
<div v-show="!editMode" :class="$style.wrapper" @dblclick.stop="onDoubleClick">
<N8nMarkdown
theme="sticky"
:content="modelValue"
:with-multi-breaks="true"
@markdown-click="onMarkdownClick"
@update-content="onUpdateModelValue"
/>
</div>
<div
v-show="editMode"
:class="{ 'full-height': !shouldShowFooter, 'sticky-textarea': true }"
@click.stop
@mousedown.stop
@mouseup.stop
@keydown.esc="onInputBlur"
@keydown.stop
>
<div v-show="!editMode" :class="$style.wrapper" @dblclick.stop="onDoubleClick">
<N8nMarkdown
theme="sticky"
:content="modelValue"
:with-multi-breaks="true"
@markdown-click="onMarkdownClick"
@update-content="onUpdateModelValue"
/>
</div>
<div
v-show="editMode"
:class="{ 'full-height': !shouldShowFooter, 'sticky-textarea': true }"
@click.stop
@mousedown.stop
@mouseup.stop
@keydown.esc="onInputBlur"
@keydown.stop
>
<N8nInput
ref="input"
:model-value="modelValue"
type="textarea"
:rows="5"
@blur="onInputBlur"
@update:model-value="onUpdateModelValue"
@wheel="onInputScroll"
/>
</div>
<div v-if="editMode && shouldShowFooter" :class="$style.footer">
<N8nText size="xsmall" align="right">
<span v-html="t('sticky.markdownHint')"></span>
</N8nText>
</div>
</N8nResizeWrapper>
<N8nInput
ref="input"
:model-value="modelValue"
:name="inputName"
type="textarea"
:rows="5"
@blur="onInputBlur"
@update:model-value="onUpdateModelValue"
@wheel="onInputScroll"
/>
</div>
<div v-if="editMode && shouldShowFooter" :class="$style.footer">
<N8nText size="xsmall" align="right">
<span v-html="t('sticky.markdownHint')"></span>
</N8nText>
</div>
</div>
</template>
@ -62,45 +50,17 @@
import { computed, ref, watch } from 'vue';
import N8nInput from '../N8nInput';
import N8nMarkdown from '../N8nMarkdown';
import N8nResizeWrapper, { type ResizeData } from '../N8nResizeWrapper/ResizeWrapper.vue';
import N8nText from '../N8nText';
import { useI18n } from '../../composables/useI18n';
import { defaultStickyProps } from './constants';
import type { StickyProps } from './types';
interface StickyProps {
modelValue?: string;
height?: number;
width?: number;
minHeight?: number;
minWidth?: number;
scale?: number;
gridSize?: number;
id?: string;
defaultText?: string;
editMode?: boolean;
readOnly?: boolean;
backgroundColor?: number | string;
}
const props = withDefaults(defineProps<StickyProps>(), {
height: 180,
width: 240,
minHeight: 80,
minWidth: 150,
scale: 1,
gridSize: 20,
id: '0',
editMode: false,
readOnly: false,
backgroundColor: 1,
});
const props = withDefaults(defineProps<StickyProps>(), defaultStickyProps);
const emit = defineEmits<{
edit: [editing: boolean];
'update:modelValue': [value: string];
'markdown-click': [link: string, e: Event];
resize: [values: ResizeData];
resizestart: [];
resizeend: [];
}>();
const { t } = useI18n();
@ -115,6 +75,8 @@ const resWidth = computed((): number => {
return props.width < props.minWidth ? props.minWidth : props.width;
});
const inputName = computed(() => (props.id ? `${props.id}-input` : undefined));
const styles = computed((): { height: string; width: string } => ({
height: `${resHeight.value}px`,
width: `${resWidth.value}px`,
@ -152,20 +114,6 @@ const onMarkdownClick = (link: string, event: Event) => {
emit('markdown-click', link, event);
};
const onResize = (values: ResizeData) => {
emit('resize', values);
};
const onResizeStart = () => {
isResizing.value = true;
emit('resizestart');
};
const onResizeEnd = () => {
isResizing.value = false;
emit('resizeend');
};
const onInputScroll = (event: WheelEvent) => {
// Pass through zoom events but hold regular scrolling
if (!event.ctrlKey && !event.metaKey) {

View file

@ -0,0 +1,10 @@
export const defaultStickyProps = {
height: 180,
width: 240,
minHeight: 80,
minWidth: 150,
id: '0',
editMode: false,
readOnly: false,
backgroundColor: 1,
};

View file

@ -0,0 +1,12 @@
export interface StickyProps {
modelValue?: string;
height?: number;
width?: number;
minHeight?: number;
minWidth?: number;
id?: string;
defaultText?: string;
editMode?: boolean;
readOnly?: boolean;
backgroundColor?: number | string;
}

View file

@ -40,6 +40,7 @@ export { default as N8nResizeWrapper } from './N8nResizeWrapper';
export { default as N8nSelect } from './N8nSelect';
export { default as N8nSpinner } from './N8nSpinner';
export { default as N8nSticky } from './N8nSticky';
export { default as N8nResizeableSticky } from './N8nResizeableSticky';
export { default as N8nTabs } from './N8nTabs';
export { default as N8nTag } from './N8nTag';
export { default as N8nTags } from './N8nTags';

View file

@ -40,6 +40,7 @@
"@vue-flow/controls": "^1.1.1",
"@vue-flow/core": "^1.33.5",
"@vue-flow/minimap": "^1.4.0",
"@vue-flow/node-resizer": "^1.4.0",
"@vueuse/components": "^10.11.0",
"@vueuse/core": "^10.11.0",
"axios": "1.6.7",

View file

@ -5,6 +5,7 @@ import { CanvasNodeRenderType } from '@/types';
export function createCanvasNodeData({
id = 'node',
name = 'Test Node',
type = 'test',
typeVersion = 1,
disabled = false,
@ -21,13 +22,14 @@ export function createCanvasNodeData({
},
}: Partial<CanvasNodeData> = {}): CanvasNodeData {
return {
id,
name,
type,
typeVersion,
execution,
issues,
pinnedData,
runData,
id,
type,
typeVersion,
disabled,
inputs,
outputs,

View file

@ -23,6 +23,7 @@ import {
MANUAL_TRIGGER_NODE_TYPE,
NO_OP_NODE_TYPE,
SET_NODE_TYPE,
STICKY_NODE_TYPE,
} from '@/constants';
import type { INodeUi } from '@/Interface';
@ -34,6 +35,7 @@ export const mockNode = ({
disabled = false,
issues = undefined,
typeVersion = 1,
parameters = {},
}: {
id?: INodeUi['id'];
name: INodeUi['name'];
@ -42,7 +44,8 @@ export const mockNode = ({
disabled?: INodeUi['disabled'];
issues?: INodeIssues;
typeVersion?: INodeUi['typeVersion'];
}) => mock<INodeUi>({ id, name, type, position, disabled, issues, typeVersion });
parameters?: INodeUi['parameters'];
}) => mock<INodeUi>({ id, name, type, position, disabled, issues, typeVersion, parameters });
export const mockNodeTypeDescription = ({
name,
@ -90,6 +93,7 @@ export const mockNodes = [
mockNode({ name: 'Rename', type: SET_NODE_TYPE }),
mockNode({ name: 'Chat Trigger', type: CHAT_TRIGGER_NODE_TYPE }),
mockNode({ name: 'Agent', type: AGENT_NODE_TYPE }),
mockNode({ name: 'Sticky', type: STICKY_NODE_TYPE }),
mockNode({ name: 'End', type: NO_OP_NODE_TYPE }),
];

View file

@ -24,7 +24,7 @@
@click.left="mouseLeftClick"
@contextmenu="onContextMenu"
>
<n8n-sticky
<N8nResizeableSticky
v-if="node"
:id="node.id"
:model-value="node.parameters.content"

View file

@ -19,6 +19,7 @@ const emit = defineEmits<{
'update:node:active': [id: string];
'update:node:enabled': [id: string];
'update:node:selected': [id?: string];
'update:node:parameters': [id: string, parameters: Record<string, unknown>];
'run:node': [id: string];
'delete:node': [id: string];
'delete:connection': [connection: Connection];
@ -57,10 +58,14 @@ const { getSelectedEdges, getSelectedNodes, viewportRef, fitView, project } = us
function onNodeDragStop(e: NodeDragEvent) {
e.nodes.forEach((node) => {
emit('update:node:position', node.id, node.position);
onUpdateNodePosition(node.id, node.position);
});
}
function onUpdateNodePosition(id: string, position: XYPosition) {
emit('update:node:position', id, position);
}
function onSelectionDragStop(e: NodeDragEvent) {
onNodeDragStop(e);
}
@ -82,6 +87,10 @@ function onDeleteNode(id: string) {
emit('delete:node', id);
}
function onUpdateNodeParameters(id: string, parameters: Record<string, unknown>) {
emit('update:node:parameters', id, parameters);
}
/**
* Connections
*/
@ -222,6 +231,8 @@ onUnmounted(() => {
@select="onSelectNode"
@toggle="onToggleNodeEnabled"
@activate="onSetNodeActive"
@update="onUpdateNodeParameters"
@move="onUpdateNodePosition"
/>
</template>

View file

@ -10,8 +10,8 @@ defineEmits<{
}>();
const props = defineProps<{
waitingForWebhook: boolean;
executing: boolean;
waitingForWebhook?: boolean;
executing?: boolean;
disabled?: boolean;
}>();

View file

@ -1,5 +1,4 @@
<script lang="ts" setup>
import { Position } from '@vue-flow/core';
import { computed, provide, toRef, watch } from 'vue';
import type { CanvasNodeData, CanvasConnectionPort, CanvasElementPortWithPosition } from '@/types';
import NodeIcon from '@/components/NodeIcon.vue';
@ -9,7 +8,8 @@ import CanvasNodeRenderer from '@/components/canvas/elements/nodes/CanvasNodeRen
import HandleRenderer from '@/components/canvas/elements/handles/HandleRenderer.vue';
import { useNodeConnections } from '@/composables/useNodeConnections';
import { CanvasNodeKey } from '@/constants';
import type { NodeProps } from '@vue-flow/core';
import { Position } from '@vue-flow/core';
import type { XYPosition, NodeProps } from '@vue-flow/core';
const emit = defineEmits<{
delete: [id: string];
@ -17,6 +17,8 @@ const emit = defineEmits<{
select: [id: string, selected: boolean];
toggle: [id: string];
activate: [id: string];
update: [id: string, parameters: Record<string, unknown>];
move: [id: string, position: XYPosition];
}>();
const props = defineProps<NodeProps<CanvasNodeData>>();
@ -103,7 +105,9 @@ provide(CanvasNodeKey, {
nodeType,
});
const nodeIconSize = computed(() => (data.value.render.options.configuration ? 30 : 40));
const nodeIconSize = computed(() =>
'configuration' in data.value.render.options && data.value.render.options.configuration ? 30 : 40,
);
function onDelete() {
emit('delete', props.id);
@ -120,6 +124,14 @@ function onDisabledToggle() {
function onActivate() {
emit('activate', props.id);
}
function onUpdate(parameters: Record<string, unknown>) {
emit('update', props.id, parameters);
}
function onMove(position: XYPosition) {
emit('move', props.id, position);
}
</script>
<template>
@ -157,7 +169,7 @@ function onActivate() {
@run="onRun"
/>
<CanvasNodeRenderer @dblclick="onActivate">
<CanvasNodeRenderer @dblclick="onActivate" @move="onMove" @update="onUpdate">
<NodeIcon
v-if="nodeType"
:node-type="nodeType"

View file

@ -1,6 +1,7 @@
<script lang="ts" setup>
import { h, inject } from 'vue';
import CanvasNodeDefault from '@/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue';
import CanvasNodeStickyNote from '@/components/canvas/elements/nodes/render-types/CanvasNodeStickyNote.vue';
import CanvasNodeAddNodes from '@/components/canvas/elements/nodes/render-types/CanvasNodeAddNodes.vue';
import { CanvasNodeKey } from '@/constants';
import { CanvasNodeRenderType } from '@/types';
@ -14,7 +15,9 @@ const slots = defineSlots<{
const Render = () => {
let Component;
switch (node?.data.value.render.type) {
// @TODO Add support for sticky notes here
case CanvasNodeRenderType.StickyNote:
Component = CanvasNodeStickyNote;
break;
case CanvasNodeRenderType.AddNodes:
Component = CanvasNodeAddNodes;
break;

View file

@ -1,7 +1,8 @@
<script setup lang="ts">
import { useCssModule } from 'vue';
import { computed, useCssModule } from 'vue';
import { useI18n } from '@/composables/useI18n';
import { useCanvasNode } from '@/composables/useCanvasNode';
import { CanvasNodeRenderType } from '@/types';
const emit = defineEmits<{
delete: [];
@ -12,7 +13,7 @@ const emit = defineEmits<{
const $style = useCssModule();
const i18n = useI18n();
const { renderOptions } = useCanvasNode();
const { render } = useCanvasNode();
// @TODO
const workflowRunning = false;
@ -20,6 +21,18 @@ const workflowRunning = false;
// @TODO
const nodeDisabledTitle = 'Test';
const isExecuteNodeVisible = computed(() => {
return (
render.value.type === CanvasNodeRenderType.Default &&
'configuration' in render.value.options &&
!render.value.options.configuration
);
});
const isDisableNodeVisible = computed(() => {
return render.value.type === CanvasNodeRenderType.Default;
});
function executeNode() {
emit('run');
}
@ -40,7 +53,7 @@ function openContextMenu(_e: MouseEvent, _type: string) {}
<div :class="$style.canvasNodeToolbar">
<div :class="$style.canvasNodeToolbarItems">
<N8nIconButton
v-if="!renderOptions.configuration"
v-if="isExecuteNodeVisible"
data-test-id="execute-node-button"
type="tertiary"
text
@ -51,6 +64,7 @@ function openContextMenu(_e: MouseEvent, _type: string) {}
@click="executeNode"
/>
<N8nIconButton
v-if="isDisableNodeVisible"
data-test-id="disable-node-button"
type="tertiary"
text

View file

@ -7,6 +7,7 @@ import CanvasNodeStatusIcons from '@/components/canvas/elements/nodes/render-typ
import { useCanvasNode } from '@/composables/useCanvasNode';
import { NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS } from '@/constants';
import { N8nTooltip } from 'n8n-design-system';
import type { CanvasNodeDefaultRender } from '@/types';
const $style = useCssModule();
const i18n = useI18n();
@ -22,7 +23,7 @@ const {
executionRunning,
hasRunData,
hasIssues,
renderOptions,
render,
} = useCanvasNode();
const { mainOutputs, nonMainInputs, requiredNonMainInputs } = useNodeConnections({
inputs,
@ -30,6 +31,8 @@ const { mainOutputs, nonMainInputs, requiredNonMainInputs } = useNodeConnections
connections,
});
const renderOptions = computed(() => render.value.options as CanvasNodeDefaultRender['options']);
const classes = computed(() => {
return {
[$style.node]: true,

View file

@ -0,0 +1,28 @@
import CanvasNodeStickyNote from '@/components/canvas/elements/nodes/render-types/CanvasNodeStickyNote.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createCanvasNodeProvide } from '@/__tests__/data';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
const renderComponent = createComponentRenderer(CanvasNodeStickyNote);
beforeEach(() => {
const pinia = createTestingPinia();
setActivePinia(pinia);
});
describe('CanvasNodeStickyNote', () => {
it('should render node correctly', () => {
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
id: 'sticky',
}),
},
},
});
expect(getByTestId('canvas-sticky-note-node')).toMatchSnapshot();
});
});

View file

@ -0,0 +1,83 @@
<script setup lang="ts">
/* eslint-disable vue/no-multiple-template-root */
import { useCanvasNode } from '@/composables/useCanvasNode';
import type { CanvasNodeStickyNoteRender } from '@/types';
import { ref, computed } from 'vue';
import { NodeResizer } from '@vue-flow/node-resizer';
import type { OnResize } from '@vue-flow/node-resizer/dist/types';
import type { XYPosition } from '@vue-flow/core';
const emit = defineEmits<{
update: [parameters: Record<string, unknown>];
move: [position: XYPosition];
dblclick: [event: MouseEvent];
}>();
const { id, render } = useCanvasNode();
const renderOptions = computed(() => render.value.options as CanvasNodeStickyNoteRender['options']);
/**
* Resizing
*/
function onResize(event: OnResize) {
emit('move', {
x: event.params.x,
y: event.params.y,
});
emit('update', {
...(event.params.width ? { width: event.params.width } : {}),
...(event.params.height ? { height: event.params.height } : {}),
});
}
/**
* Content change
*/
const isActive = ref(false);
function onInputChange(value: string) {
emit('update', {
content: value,
});
}
function onEdit(edit: boolean) {
isActive.value = edit;
}
function onDoubleClick(event: MouseEvent) {
emit('dblclick', event);
}
</script>
<template>
<NodeResizer
:min-height="80"
:min-width="150"
:height="renderOptions.height"
:width="renderOptions.width"
@resize="onResize"
/>
<N8nSticky
:id="id"
data-test-id="canvas-sticky-note-node"
:height="renderOptions.height"
:width="renderOptions.width"
:class="$style.sticky"
:model-value="renderOptions.content"
:background="renderOptions.color"
:edit-mode="isActive"
@edit="onEdit"
@dblclick="onDoubleClick"
@update:model-value="onInputChange"
/>
</template>
<style lang="scss" module>
.sticky {
position: relative;
}
</style>

View file

@ -0,0 +1,45 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasNodeStickyNote > should render node correctly 1`] = `
<div
class="n8n-sticky sticky clickable color-1 sticky"
data-test-id="canvas-sticky-note-node"
style="height: 180px; width: 240px;"
>
<div
class="wrapper"
>
<div
class="n8n-markdown"
>
<div
class="sticky"
/>
</div>
</div>
<div
class="sticky-textarea"
style="display: none;"
>
<div
class="el-textarea el-input--large n8n-input"
>
<!-- input -->
<!-- textarea -->
<textarea
autocomplete="off"
class="el-textarea__inner"
name="sticky-input"
placeholder=""
rows="5"
tabindex="0"
title=""
/>
<!--v-if-->
</div>
</div>
<!--v-if-->
</div>
`;

View file

@ -12,14 +12,14 @@ import {
mockNodes,
mockNodeTypeDescription,
} from '@/__tests__/mocks';
import { MANUAL_TRIGGER_NODE_TYPE, SET_NODE_TYPE } from '@/constants';
import { MANUAL_TRIGGER_NODE_TYPE, SET_NODE_TYPE, STICKY_NODE_TYPE } from '@/constants';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import {
createCanvasConnectionHandleString,
createCanvasConnectionId,
} from '@/utils/canvasUtilsV2';
import { CanvasConnectionMode } from '@/types';
import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types';
beforeEach(() => {
const pinia = createPinia();
@ -86,6 +86,7 @@ describe('useCanvasMapping', () => {
position: expect.anything(),
data: {
id: manualTriggerNode.id,
name: manualTriggerNode.name,
type: manualTriggerNode.type,
typeVersion: expect.anything(),
disabled: false,
@ -224,6 +225,93 @@ describe('useCanvasMapping', () => {
}),
);
});
describe('render', () => {
it('should handle render options for default node type', () => {
const manualTriggerNode = mockNode({
name: 'Manual Trigger',
type: MANUAL_TRIGGER_NODE_TYPE,
disabled: false,
});
const nodes = [manualTriggerNode];
const connections = {};
const workflowObject = createTestWorkflowObject({
nodes,
connections,
});
const { nodes: mappedNodes } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(mappedNodes.value[0]?.data?.render).toEqual({
type: CanvasNodeRenderType.Default,
options: {
configurable: false,
configuration: false,
trigger: true,
},
});
});
it('should handle render options for addNodes node type', () => {
const addNodesNode = mockNode({
name: CanvasNodeRenderType.AddNodes,
type: CanvasNodeRenderType.AddNodes,
disabled: false,
});
const nodes = [addNodesNode];
const connections = {};
const workflowObject = createTestWorkflowObject({
nodes: [],
connections,
});
const { nodes: mappedNodes } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(mappedNodes.value[0]?.data?.render).toEqual({
type: CanvasNodeRenderType.AddNodes,
options: {},
});
});
it('should handle render options for stickyNote node type', () => {
const stickyNoteNode = mockNode({
name: 'Sticky',
type: STICKY_NODE_TYPE,
disabled: false,
parameters: {
width: 200,
height: 200,
color: 3,
content: '# Hello world',
},
});
const nodes = [stickyNoteNode];
const connections = {};
const workflowObject = createTestWorkflowObject({
nodes,
connections,
});
const { nodes: mappedNodes } = useCanvasMapping({
nodes: ref(nodes),
connections: ref(connections),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(mappedNodes.value[0]?.data?.render).toEqual({
type: CanvasNodeRenderType.StickyNote,
options: stickyNoteNode.parameters,
});
});
});
});
describe('connections', () => {

View file

@ -13,7 +13,10 @@ import type {
CanvasConnectionData,
CanvasConnectionPort,
CanvasNode,
CanvasNodeAddNodesRender,
CanvasNodeData,
CanvasNodeDefaultRender,
CanvasNodeStickyNoteRender,
} from '@/types';
import { CanvasNodeRenderType } from '@/types';
import {
@ -30,7 +33,7 @@ import type {
} from 'n8n-workflow';
import { NodeHelpers } from 'n8n-workflow';
import type { INodeUi } from '@/Interface';
import { WAIT_TIME_UNLIMITED } from '@/constants';
import { STICKY_NODE_TYPE, WAIT_TIME_UNLIMITED } from '@/constants';
import { sanitizeHtml } from '@/utils/htmlUtils';
export function useCanvasMapping({
@ -46,30 +49,48 @@ export function useCanvasMapping({
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const renderTypeByNodeType = computed(
function createStickyNoteRenderType(node: INodeUi): CanvasNodeStickyNoteRender {
return {
type: CanvasNodeRenderType.StickyNote,
options: {
width: node.parameters.width as number,
height: node.parameters.height as number,
color: node.parameters.color as number,
content: node.parameters.content as string,
},
};
}
function createAddNodesRenderType(): CanvasNodeAddNodesRender {
return {
type: CanvasNodeRenderType.AddNodes,
options: {},
};
}
function createDefaultNodeRenderType(node: INodeUi): CanvasNodeDefaultRender {
return {
type: CanvasNodeRenderType.Default,
options: {
trigger: nodeTypesStore.isTriggerNode(node.type),
configuration: nodeTypesStore.isConfigNode(workflowObject.value, node, node.type),
configurable: nodeTypesStore.isConfigurableNode(workflowObject.value, node, node.type),
},
};
}
const renderTypeByNodeId = computed(
() =>
nodes.value.reduce<Record<string, CanvasNodeData['render']>>((acc, node) => {
// @TODO Add support for sticky notes here
switch (node.type) {
case `${CanvasNodeRenderType.StickyNote}`:
acc[node.id] = createStickyNoteRenderType(node);
break;
case `${CanvasNodeRenderType.AddNodes}`:
acc[node.type] = {
type: CanvasNodeRenderType.AddNodes,
options: {},
};
acc[node.id] = createAddNodesRenderType();
break;
default:
acc[node.type] = {
type: CanvasNodeRenderType.Default,
options: {
trigger: nodeTypesStore.isTriggerNode(node.type),
configuration: nodeTypesStore.isConfigNode(workflowObject.value, node, node.type),
configurable: nodeTypesStore.isConfigurableNode(
workflowObject.value,
node,
node.type,
),
},
};
acc[node.id] = createDefaultNodeRenderType(node);
}
return acc;
@ -214,6 +235,20 @@ export function useCanvasMapping({
}, {}),
);
const additionalNodePropertiesById = computed(() => {
return nodes.value.reduce<Record<string, Partial<CanvasNode>>>((acc, node) => {
if (node.type === STICKY_NODE_TYPE) {
acc[node.id] = {
style: {
zIndex: -1,
},
};
}
return acc;
}, {});
});
const mappedNodes = computed<CanvasNode[]>(() => [
...nodes.value.map<CanvasNode>((node) => {
const inputConnections = workflowObject.value.connectionsByDestinationNode[node.name] ?? {};
@ -221,6 +256,7 @@ export function useCanvasMapping({
const data: CanvasNodeData = {
id: node.id,
name: node.name,
type: node.type,
typeVersion: node.typeVersion,
disabled: !!node.disabled,
@ -247,7 +283,7 @@ export function useCanvasMapping({
count: nodeExecutionRunDataById.value[node.id]?.length ?? 0,
visible: !!nodeExecutionRunDataById.value[node.id],
},
render: renderTypeByNodeType.value[node.type] ?? { type: 'default', options: {} },
render: renderTypeByNodeId.value[node.id] ?? { type: 'default', options: {} },
};
return {
@ -256,6 +292,7 @@ export function useCanvasMapping({
type: 'canvas-node',
position: { x: node.position[0], y: node.position[1] },
data,
...additionalNodePropertiesById.value[node.id],
};
}),
]);

View file

@ -30,13 +30,14 @@ describe('useCanvasNode', () => {
expect(result.executionStatus.value).toBeUndefined();
expect(result.executionWaiting.value).toBeUndefined();
expect(result.executionRunning.value).toBe(false);
expect(result.renderOptions.value).toEqual({});
expect(result.render.value).toEqual({ type: CanvasNodeRenderType.Default, options: {} });
});
it('should return node data when node is provided', () => {
const node = {
data: ref({
id: 'node1',
name: 'Node 1',
type: 'nodeType1',
typeVersion: 1,
disabled: true,
@ -66,6 +67,7 @@ describe('useCanvasNode', () => {
const result = useCanvasNode();
expect(result.label.value).toBe('Node 1');
expect(result.name.value).toBe('Node 1');
expect(result.inputs.value).toEqual([{ type: 'main', index: 0 }]);
expect(result.outputs.value).toEqual([{ type: 'main', index: 0 }]);
expect(result.connections.value).toEqual({ input: { '0': [] }, output: {} });
@ -80,6 +82,6 @@ describe('useCanvasNode', () => {
expect(result.executionStatus.value).toBe('running');
expect(result.executionWaiting.value).toBe('waiting');
expect(result.executionRunning.value).toBe(true);
expect(result.renderOptions.value).toBe(node.data.value.render.options);
expect(result.render.value).toBe(node.data.value.render);
});
});

View file

@ -14,6 +14,7 @@ export function useCanvasNode() {
() =>
node?.data.value ?? {
id: '',
name: '',
type: '',
typeVersion: 1,
disabled: false,
@ -33,8 +34,10 @@ export function useCanvasNode() {
},
);
const id = computed(() => node?.id.value ?? '');
const label = computed(() => node?.label.value ?? '');
const name = computed(() => data.value.name);
const inputs = computed(() => data.value.inputs);
const outputs = computed(() => data.value.outputs);
const connections = computed(() => data.value.connections);
@ -56,10 +59,12 @@ export function useCanvasNode() {
const runDataCount = computed(() => data.value.runData.count);
const hasRunData = computed(() => data.value.runData.visible);
const renderOptions = computed(() => data.value.render.options);
const render = computed(() => data.value.render);
return {
node,
id,
name,
label,
inputs,
outputs,
@ -75,6 +80,6 @@ export function useCanvasNode() {
executionStatus,
executionWaiting,
executionRunning,
renderOptions,
render,
};
}

View file

@ -40,6 +40,7 @@ import type {
INodeTypeDescription,
INodeTypeNameVersion,
ITelemetryTrackProperties,
NodeParameterValueType,
} from 'n8n-workflow';
import { NodeConnectionType, NodeHelpers } from 'n8n-workflow';
import { useNDVStore } from '@/stores/ndv.store';
@ -229,6 +230,21 @@ export function useCanvasOperations({
ndvStore.activeNodeName = name;
}
function setNodeParameters(id: string, parameters: Record<string, unknown>) {
const node = workflowsStore.getNodeById(id);
if (!node) {
return;
}
workflowsStore.setNodeParameters(
{
name: node.name,
value: parameters as NodeParameterValueType,
},
true,
);
}
function setNodeSelected(id?: string) {
if (!id) {
uiStore.lastSelectedNode = '';
@ -443,7 +459,7 @@ export function useCanvasOperations({
const nodeType = nodeTypesStore.getNodeType(newNodeData.type, newNodeData.typeVersion);
const nodeParameters = NodeHelpers.getNodeParameters(
nodeType?.properties ?? [],
{},
node.parameters ?? {},
true,
false,
newNodeData,
@ -883,10 +899,16 @@ export function useCanvasOperations({
targetNode: INodeUi,
connectionType: NodeConnectionType,
): boolean {
const blocklist = [STICKY_NODE_TYPE];
if (sourceNode.id === targetNode.id) {
return false;
}
if (blocklist.includes(sourceNode.type) || blocklist.includes(targetNode.type)) {
return false;
}
const targetNodeType = nodeTypesStore.getNodeType(targetNode.type, targetNode.typeVersion);
if (targetNodeType?.inputs?.length) {
const workflowNode = editableWorkflowObject.value.getNode(targetNode.name);
@ -958,6 +980,7 @@ export function useCanvasOperations({
setNodeActive,
setNodeActiveByName,
setNodeSelected,
setNodeParameters,
toggleNodeDisabled,
renameNode,
revertRenameNode,

View file

@ -4,6 +4,7 @@ import '@vue-flow/core/dist/style.css';
import '@vue-flow/core/dist/theme-default.css';
import '@vue-flow/controls/dist/style.css';
import '@vue-flow/minimap/dist/style.css';
import '@vue-flow/node-resizer/dist/style.css';
import 'vue-json-pretty/lib/styles.css';
import '@jsplumb/browser-ui/css/jsplumbtoolkit.css';

View file

@ -0,0 +1,7 @@
.vue-flow__resize-control.line {
border-color: transparent;
}
.vue-flow__resize-control.handle {
background-color: transparent;
}

View file

@ -1 +1,2 @@
@import "codemirror";
@import "vueflow";

View file

@ -36,6 +36,7 @@ export interface CanvasElementPortWithPosition extends CanvasConnectionPort {
export const enum CanvasNodeRenderType {
Default = 'default',
StickyNote = 'n8n-nodes-base.stickyNote',
AddNodes = 'n8n-nodes-internal.addNodes',
}
@ -53,8 +54,19 @@ export type CanvasNodeAddNodesRender = {
options: Record<string, never>;
};
export type CanvasNodeStickyNoteRender = {
type: CanvasNodeRenderType.StickyNote;
options: Partial<{
width: number;
height: number;
color: number;
content: string;
}>;
};
export interface CanvasNodeData {
id: INodeUi['id'];
name: INodeUi['name'];
type: INodeUi['type'];
typeVersion: INodeUi['typeVersion'];
disabled: INodeUi['disabled'];
@ -81,7 +93,7 @@ export interface CanvasNodeData {
count: number;
visible: boolean;
};
render: CanvasNodeDefaultRender | CanvasNodeAddNodesRender;
render: CanvasNodeDefaultRender | CanvasNodeStickyNoteRender | CanvasNodeAddNodesRender;
}
export type CanvasNode = Node<CanvasNodeData>;

View file

@ -144,6 +144,7 @@ const {
setNodeActive,
setNodeSelected,
toggleNodeDisabled,
setNodeParameters,
deleteNode,
revertDeleteNode,
addNodes,
@ -310,6 +311,7 @@ async function initializeView() {
nodeHelpers.updateNodesParameterIssues();
await loadCredentials();
canvasEventBus.emit('fitView');
uiStore.nodeViewInitialized = true;
@ -553,7 +555,6 @@ function onSetNodeSelected(id?: string) {
}
function onRenameNode(parameterData: IUpdateInformation) {
// The name changed. Do not forget to change the connections as well
if (parameterData.name === 'name' && parameterData.oldValue) {
void renameNode(parameterData.oldValue as string, parameterData.value as string);
}
@ -569,6 +570,10 @@ async function onRevertRenameNode({
await revertRenameNode(currentName, newName);
}
function onUpdateNodeParameters(id: string, parameters: Record<string, unknown>) {
setNodeParameters(id, parameters);
}
/**
* Credentials
*/
@ -1157,6 +1162,7 @@ onBeforeUnmount(() => {
@update:node:active="onSetNodeActive"
@update:node:selected="onSetNodeSelected"
@update:node:enabled="onToggleNodeDisabled"
@update:node:parameters="onUpdateNodeParameters"
@run:node="onRunWorkflowToNode"
@delete:node="onDeleteNode"
@create:connection="onCreateConnection"

View file

@ -1147,6 +1147,9 @@ importers:
'@vue-flow/minimap':
specifier: ^1.4.0
version: 1.4.0(@vue-flow/core@1.33.5(vue@3.4.21(typescript@5.5.2)))(vue@3.4.21(typescript@5.5.2))
'@vue-flow/node-resizer':
specifier: ^1.4.0
version: 1.4.0(@vue-flow/core@1.33.5(vue@3.4.21(typescript@5.5.2)))(vue@3.4.21(typescript@5.5.2))
'@vueuse/components':
specifier: ^10.11.0
version: 10.11.0(vue@3.4.21(typescript@5.5.2))
@ -5696,6 +5699,12 @@ packages:
'@vue-flow/core': ^1.23.0
vue: ^3.3.0
'@vue-flow/node-resizer@1.4.0':
resolution: {integrity: sha512-S52MRcSpd6asza8Cl0bKM2sHGrbq7vBydKHDuPdoTD+cvjNX6XF4LSiPZOuzExePI6b+O6dg2EZ1378oOLGFpA==}
peerDependencies:
'@vue-flow/core': ^1.23.0
vue: ^3.3.0
'@vue/compiler-core@3.4.21':
resolution: {integrity: sha512-MjXawxZf2SbZszLPYxaFCjxfibYrzr3eYbKxwpLR9EQN+oaziSu3qKVbwBERj1IFIB8OLUewxB5m/BFzi613og==}
@ -19069,6 +19078,13 @@ snapshots:
d3-zoom: 3.0.0
vue: 3.4.21(typescript@5.5.2)
'@vue-flow/node-resizer@1.4.0(@vue-flow/core@1.33.5(vue@3.4.21(typescript@5.5.2)))(vue@3.4.21(typescript@5.5.2))':
dependencies:
'@vue-flow/core': 1.33.5(vue@3.4.21(typescript@5.5.2))
d3-drag: 3.0.0
d3-selection: 3.0.0
vue: 3.4.21(typescript@5.5.2)
'@vue/compiler-core@3.4.21':
dependencies:
'@babel/parser': 7.24.0
@ -21201,7 +21217,7 @@ snapshots:
eslint-import-resolver-node@0.3.9:
dependencies:
debug: 3.2.7(supports-color@5.5.0)
debug: 3.2.7(supports-color@8.1.1)
is-core-module: 2.13.1
resolve: 1.22.8
transitivePeerDependencies:
@ -21226,7 +21242,7 @@ snapshots:
eslint-module-utils@2.8.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.2))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0):
dependencies:
debug: 3.2.7(supports-color@5.5.0)
debug: 3.2.7(supports-color@8.1.1)
optionalDependencies:
'@typescript-eslint/parser': 7.2.0(eslint@8.57.0)(typescript@5.5.2)
eslint: 8.57.0
@ -21246,7 +21262,7 @@ snapshots:
array.prototype.findlastindex: 1.2.3
array.prototype.flat: 1.3.2
array.prototype.flatmap: 1.3.2
debug: 3.2.7(supports-color@5.5.0)
debug: 3.2.7(supports-color@8.1.1)
doctrine: 2.1.0
eslint: 8.57.0
eslint-import-resolver-node: 0.3.9
@ -21776,7 +21792,7 @@ snapshots:
follow-redirects@1.15.6(debug@3.2.7):
optionalDependencies:
debug: 3.2.7(supports-color@5.5.0)
debug: 3.2.7(supports-color@8.1.1)
follow-redirects@1.15.6(debug@4.3.4):
optionalDependencies:
@ -22117,7 +22133,7 @@ snapshots:
array-parallel: 0.1.3
array-series: 0.1.5
cross-spawn: 4.0.2
debug: 3.2.7(supports-color@5.5.0)
debug: 3.2.7(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
@ -24882,7 +24898,7 @@ snapshots:
pdf-parse@1.1.1:
dependencies:
debug: 3.2.7(supports-color@5.5.0)
debug: 3.2.7(supports-color@8.1.1)
node-ensure: 0.0.0
transitivePeerDependencies:
- supports-color
@ -25764,7 +25780,7 @@ snapshots:
rhea@1.0.24:
dependencies:
debug: 3.2.7(supports-color@5.5.0)
debug: 3.2.7(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
@ -26138,7 +26154,7 @@ snapshots:
binascii: 0.0.2
bn.js: 5.2.1
browser-request: 0.3.3
debug: 3.2.7(supports-color@5.5.0)
debug: 3.2.7(supports-color@8.1.1)
expand-tilde: 2.0.2
extend: 3.0.2
fast-xml-parser: 4.2.7