feat(editor): Improve ai nodes, handles and connections design in new canvas (no-changelog) (#10969)

This commit is contained in:
Alex Grozav 2024-09-30 14:29:30 +03:00 committed by GitHub
parent a62a9db8fa
commit fe871d89a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 135 additions and 26 deletions

View file

@ -125,6 +125,7 @@ export function createCanvasHandleProvide({
isConnected = false, isConnected = false,
isConnecting = false, isConnecting = false,
isReadOnly = false, isReadOnly = false,
isRequired = false,
}: { }: {
label?: string; label?: string;
mode?: CanvasConnectionMode; mode?: CanvasConnectionMode;
@ -134,6 +135,7 @@ export function createCanvasHandleProvide({
isConnected?: boolean; isConnected?: boolean;
isConnecting?: boolean; isConnecting?: boolean;
isReadOnly?: boolean; isReadOnly?: boolean;
isRequired?: boolean;
} = {}) { } = {}) {
return { return {
[String(CanvasNodeHandleKey)]: { [String(CanvasNodeHandleKey)]: {
@ -143,8 +145,9 @@ export function createCanvasHandleProvide({
index: ref(index), index: ref(index),
isConnected: ref(isConnected), isConnected: ref(isConnected),
isConnecting: ref(isConnecting), isConnecting: ref(isConnecting),
runData: ref(runData),
isReadOnly: ref(isReadOnly), isReadOnly: ref(isReadOnly),
isRequired: ref(isRequired),
runData: ref(runData),
} satisfies CanvasNodeHandleInjectionData, } satisfies CanvasNodeHandleInjectionData,
}; };
} }

View file

@ -5,7 +5,7 @@ import { isValidNodeConnectionType } from '@/utils/typeGuards';
import type { Connection, EdgeProps } from '@vue-flow/core'; import type { Connection, EdgeProps } from '@vue-flow/core';
import { useVueFlow, BaseEdge, EdgeLabelRenderer } from '@vue-flow/core'; import { useVueFlow, BaseEdge, EdgeLabelRenderer } from '@vue-flow/core';
import { NodeConnectionType } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow';
import { computed, useCssModule, ref } from 'vue'; import { computed, useCssModule, ref, toRef } from 'vue';
import CanvasEdgeToolbar from './CanvasEdgeToolbar.vue'; import CanvasEdgeToolbar from './CanvasEdgeToolbar.vue';
import { getCustomPath } from './utils/edgePath'; import { getCustomPath } from './utils/edgePath';
@ -21,6 +21,8 @@ export type CanvasEdgeProps = EdgeProps<CanvasConnectionData> & {
const props = defineProps<CanvasEdgeProps>(); const props = defineProps<CanvasEdgeProps>();
const data = toRef(props, 'data');
const { onEdgeMouseEnter, onEdgeMouseLeave } = useVueFlow(); const { onEdgeMouseEnter, onEdgeMouseLeave } = useVueFlow();
const isHovered = ref(false); const isHovered = ref(false);
@ -44,6 +46,8 @@ const connectionType = computed(() =>
const renderToolbar = computed(() => (props.selected || isHovered.value) && !props.readOnly); const renderToolbar = computed(() => (props.selected || isHovered.value) && !props.readOnly);
const isMainConnection = computed(() => data.value.source.type === NodeConnectionType.Main);
const status = computed(() => props.data.status); const status = computed(() => props.data.status);
const statusColor = computed(() => { const statusColor = computed(() => {
if (props.selected) { if (props.selected) {
@ -54,6 +58,8 @@ const statusColor = computed(() => {
return 'var(--color-secondary)'; return 'var(--color-secondary)';
} else if (status.value === 'running') { } else if (status.value === 'running') {
return 'var(--color-primary)'; return 'var(--color-primary)';
} else if (!isMainConnection.value) {
return 'var(--node-type-supplemental-color)';
} else { } else {
return 'var(--color-foreground-xdark)'; return 'var(--color-foreground-xdark)';
} }
@ -61,6 +67,7 @@ const statusColor = computed(() => {
const edgeStyle = computed(() => ({ const edgeStyle = computed(() => ({
...props.style, ...props.style,
...(isMainConnection.value ? {} : { strokeDasharray: '8,8' }),
strokeWidth: 2, strokeWidth: 2,
stroke: isHovered.value ? 'var(--color-primary)' : statusColor.value, stroke: isHovered.value ? 'var(--color-primary)' : statusColor.value,
})); }));

View file

@ -20,6 +20,7 @@ const props = defineProps<{
isConnecting?: boolean; isConnecting?: boolean;
isReadOnly?: boolean; isReadOnly?: boolean;
label?: string; label?: string;
required?: boolean;
type: CanvasConnectionPort['type']; type: CanvasConnectionPort['type'];
index: CanvasConnectionPort['index']; index: CanvasConnectionPort['index'];
position: CanvasElementPortWithRenderData['position']; position: CanvasElementPortWithRenderData['position'];
@ -116,6 +117,7 @@ const isReadOnly = toRef(props, 'isReadOnly');
const mode = toRef(props, 'mode'); const mode = toRef(props, 'mode');
const type = toRef(props, 'type'); const type = toRef(props, 'type');
const index = toRef(props, 'index'); const index = toRef(props, 'index');
const isRequired = toRef(props, 'required');
provide(CanvasNodeHandleKey, { provide(CanvasNodeHandleKey, {
label, label,
@ -123,6 +125,7 @@ provide(CanvasNodeHandleKey, {
type, type,
index, index,
runData, runData,
isRequired,
isConnected, isConnected,
isConnecting, isConnecting,
isReadOnly, isReadOnly,

View file

@ -1,12 +1,21 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useCanvasNodeHandle } from '@/composables/useCanvasNodeHandle'; import { useCanvasNodeHandle } from '@/composables/useCanvasNodeHandle';
import { computed, useCssModule } from 'vue';
const { label } = useCanvasNodeHandle(); const $style = useCssModule();
const { label, isRequired } = useCanvasNodeHandle();
const classes = computed(() => ({
'canvas-node-handle-main-input': true,
[$style.handle]: true,
[$style.required]: isRequired.value,
}));
const handleClasses = 'target'; const handleClasses = 'target';
</script> </script>
<template> <template>
<div :class="['canvas-node-handle-main-input', $style.handle]"> <div :class="classes">
<div :class="[$style.label]">{{ label }}</div> <div :class="[$style.label]">{{ label }}</div>
<CanvasHandleRectangle :handle-classes="handleClasses" /> <CanvasHandleRectangle :handle-classes="handleClasses" />
</div> </div>
@ -32,4 +41,9 @@ const handleClasses = 'target';
text-align: center; text-align: center;
white-space: nowrap; white-space: nowrap;
} }
.required .label::after {
content: '*';
color: var(--color-danger);
}
</style> </style>

View file

@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useCanvasNodeHandle } from '@/composables/useCanvasNodeHandle'; import { useCanvasNodeHandle } from '@/composables/useCanvasNodeHandle';
import { useCanvasNode } from '@/composables/useCanvasNode'; import { useCanvasNode } from '@/composables/useCanvasNode';
import { computed, ref } from 'vue'; import { computed, ref, useCssModule } from 'vue';
import type { CanvasNodeDefaultRender } from '@/types'; import type { CanvasNodeDefaultRender } from '@/types';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
@ -9,11 +9,20 @@ const emit = defineEmits<{
add: []; add: [];
}>(); }>();
const $style = useCssModule();
const i18n = useI18n(); const i18n = useI18n();
const { render } = useCanvasNode(); const { render } = useCanvasNode();
const { label, isConnected, isConnecting, isReadOnly, runData } = useCanvasNodeHandle(); const { label, isConnected, isConnecting, isReadOnly, isRequired, runData } = useCanvasNodeHandle();
const handleClasses = 'source'; const handleClasses = 'source';
const classes = computed(() => ({
'canvas-node-handle-main-output': true,
[$style.handle]: true,
[$style.required]: isRequired.value,
}));
const isHovered = ref(false); const isHovered = ref(false);
const renderOptions = computed(() => render.value.options as CanvasNodeDefaultRender['options']); const renderOptions = computed(() => render.value.options as CanvasNodeDefaultRender['options']);
@ -29,7 +38,7 @@ const runDataLabel = computed(() =>
const isHandlePlusVisible = computed(() => !isConnecting.value || isHovered.value); const isHandlePlusVisible = computed(() => !isConnecting.value || isHovered.value);
const plusState = computed(() => (runData.value ? 'success' : 'default')); const plusStatus = computed(() => (runData.value ? 'success' : 'default'));
const plusLineSize = computed( const plusLineSize = computed(
() => () =>
@ -53,7 +62,7 @@ function onClickAdd() {
} }
</script> </script>
<template> <template>
<div :class="['canvas-node-handle-main-output', $style.handle]"> <div :class="classes">
<div v-if="label" :class="[$style.label, $style.outputLabel]">{{ label }}</div> <div v-if="label" :class="[$style.label, $style.outputLabel]">{{ label }}</div>
<div v-else-if="runData" :class="[$style.label, $style.runDataLabel]">{{ runDataLabel }}</div> <div v-else-if="runData" :class="[$style.label, $style.runDataLabel]">{{ runDataLabel }}</div>
<CanvasHandleDot :handle-classes="handleClasses" /> <CanvasHandleDot :handle-classes="handleClasses" />
@ -64,7 +73,7 @@ function onClickAdd() {
data-test-id="canvas-handle-plus" data-test-id="canvas-handle-plus"
:line-size="plusLineSize" :line-size="plusLineSize"
:handle-classes="handleClasses" :handle-classes="handleClasses"
:state="plusState" :status="plusStatus"
@mouseenter="onMouseEnter" @mouseenter="onMouseEnter"
@mouseleave="onMouseLeave" @mouseleave="onMouseLeave"
@click:plus="onClickAdd" @click:plus="onClickAdd"
@ -91,6 +100,11 @@ function onClickAdd() {
overflow: hidden; overflow: hidden;
} }
.required .label::after {
content: '*';
color: var(--color-danger);
}
.outputLabel { .outputLabel {
top: 50%; top: 50%;
left: var(--spacing-m); left: var(--spacing-m);

View file

@ -2,24 +2,36 @@
import CanvasHandlePlus from '@/components/canvas/elements/handles/render-types/parts/CanvasHandlePlus.vue'; import CanvasHandlePlus from '@/components/canvas/elements/handles/render-types/parts/CanvasHandlePlus.vue';
import { useCanvasNodeHandle } from '@/composables/useCanvasNodeHandle'; import { useCanvasNodeHandle } from '@/composables/useCanvasNodeHandle';
import { NodeConnectionType } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow';
import { computed, ref } from 'vue'; import { computed, ref, useCssModule } from 'vue';
const emit = defineEmits<{ const emit = defineEmits<{
add: []; add: [];
}>(); }>();
const { label, isConnected, isConnecting, type } = useCanvasNodeHandle(); const $style = useCssModule();
const { label, isConnected, isConnecting, isRequired, type, runData } = useCanvasNodeHandle();
const handleClasses = 'target'; const handleClasses = 'target';
const classes = computed(() => ({
'canvas-node-handle-non-main-input': true,
[$style.handle]: true,
[$style.required]: isRequired.value,
}));
const supportsMultipleConnections = computed(() => type.value === NodeConnectionType.AiTool); const supportsMultipleConnections = computed(() => type.value === NodeConnectionType.AiTool);
const isHandlePlusAvailable = computed( const isHandlePlusAvailable = computed(
() => !isConnected.value || supportsMultipleConnections.value, () => !isConnected.value || supportsMultipleConnections.value,
); );
const isHandlePlusVisible = computed( const isHandlePlusVisible = computed(
() => !isConnecting.value || isHovered.value || supportsMultipleConnections.value, () => !isConnecting.value || isHovered.value || supportsMultipleConnections.value,
); );
const plusStatus = computed(() => (runData.value ? 'success' : 'ai'));
const isHovered = ref(false); const isHovered = ref(false);
function onMouseEnter() { function onMouseEnter() {
@ -35,7 +47,7 @@ function onClickAdd() {
} }
</script> </script>
<template> <template>
<div :class="['canvas-node-handle-non-main-input', $style.handle]"> <div :class="classes">
<div :class="[$style.label]">{{ label }}</div> <div :class="[$style.label]">{{ label }}</div>
<CanvasHandleDiamond :handle-classes="handleClasses" /> <CanvasHandleDiamond :handle-classes="handleClasses" />
<Transition name="canvas-node-handle-non-main-input"> <Transition name="canvas-node-handle-non-main-input">
@ -43,6 +55,7 @@ function onClickAdd() {
v-if="isHandlePlusAvailable" v-if="isHandlePlusAvailable"
v-show="isHandlePlusVisible" v-show="isHandlePlusVisible"
:handle-classes="handleClasses" :handle-classes="handleClasses"
:status="plusStatus"
position="bottom" position="bottom"
@mouseenter="onMouseEnter" @mouseenter="onMouseEnter"
@mouseleave="onMouseLeave" @mouseleave="onMouseLeave"
@ -66,12 +79,17 @@ function onClickAdd() {
left: 50%; left: 50%;
transform: translate(-50%, 0); transform: translate(-50%, 0);
font-size: var(--font-size-2xs); font-size: var(--font-size-2xs);
color: var(--color-foreground-xdark); color: var(--node-type-supplemental-color);
background: var(--color-canvas-label-background); background: var(--color-canvas-label-background);
z-index: 1; z-index: 1;
text-align: center; text-align: center;
white-space: nowrap; white-space: nowrap;
} }
.required .label::after {
content: '*';
color: var(--color-danger);
}
</style> </style>
<style lang="scss"> <style lang="scss">

View file

@ -1,13 +1,21 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useCanvasNodeHandle } from '@/composables/useCanvasNodeHandle'; import { useCanvasNodeHandle } from '@/composables/useCanvasNodeHandle';
import { computed, useCssModule } from 'vue';
const { label } = useCanvasNodeHandle(); const $style = useCssModule();
const { label, isRequired } = useCanvasNodeHandle();
const handleClasses = 'source'; const handleClasses = 'source';
const classes = computed(() => ({
'canvas-node-handle-non-main-output': true,
[$style.handle]: true,
[$style.required]: isRequired.value,
}));
</script> </script>
<template> <template>
<div :class="['canvas-node-handle-non-main-output', $style.handle]"> <div :class="classes">
<div :class="[$style.label]">{{ label }}</div> <div :class="$style.label">{{ label }}</div>
<CanvasHandleDiamond :handle-classes="handleClasses" /> <CanvasHandleDiamond :handle-classes="handleClasses" />
</div> </div>
</template> </template>
@ -23,15 +31,20 @@ const handleClasses = 'source';
.label { .label {
position: absolute; position: absolute;
top: -20px; top: -20px;
left: -50%; left: 50%;
transform: translate(0%, 0); transform: translate(-50%, 0);
font-size: var(--font-size-2xs); font-size: var(--font-size-2xs);
color: var(--color-foreground-xdark); color: var(--node-type-supplemental-color);
background: var(--color-canvas-label-background); background: var(--color-canvas-label-background);
z-index: 0; z-index: 0;
white-space: nowrap; white-space: nowrap;
} }
.required .label::after {
content: '*';
color: var(--color-danger);
}
:global(.vue-flow__handle:not(.connectionindicator)) .plus { :global(.vue-flow__handle:not(.connectionindicator)) .plus {
display: none; display: none;
position: absolute; position: absolute;

View file

@ -40,9 +40,9 @@ describe('CanvasHandlePlus', () => {
}); });
}); });
it('should apply correct classes based on state', () => { it('should apply correct classes based on status', () => {
const { container } = renderComponent({ const { container } = renderComponent({
props: { state: 'success' }, props: { status: 'success' },
}); });
expect(container.firstChild).toHaveClass('success'); expect(container.firstChild).toHaveClass('success');

View file

@ -7,14 +7,14 @@ const props = withDefaults(
handleClasses?: string; handleClasses?: string;
plusSize?: number; plusSize?: number;
lineSize?: number; lineSize?: number;
state?: 'success' | 'default'; status?: 'success' | 'ai' | 'default';
}>(), }>(),
{ {
position: 'right', position: 'right',
handleClasses: undefined, handleClasses: undefined,
plusSize: 24, plusSize: 24,
lineSize: 46, lineSize: 46,
state: 'default', status: 'default',
}, },
); );
@ -27,7 +27,7 @@ const style = useCssModule();
const classes = computed(() => [ const classes = computed(() => [
style.wrapper, style.wrapper,
style[props.position], style[props.position],
style[props.state], style[props.status],
props.handleClasses, props.handleClasses,
]); ]);
@ -135,8 +135,26 @@ function onClick(event: MouseEvent) {
.wrapper { .wrapper {
position: relative; position: relative;
&.success .line { &.ai {
stroke: var(--color-success); .line {
stroke: var(--node-type-supplemental-color);
}
.plus {
path {
fill: var(--node-type-supplemental-color);
}
rect {
stroke: var(--node-type-supplemental-color);
}
}
}
&.success {
.line {
stroke: var(--color-success);
}
} }
} }

View file

@ -269,6 +269,7 @@ onBeforeUnmount(() => {
:index="source.index" :index="source.index"
:position="source.position" :position="source.position"
:offset="source.offset" :offset="source.offset"
:required="source.required"
:is-connected="source.isConnected" :is-connected="source.isConnected"
:is-connecting="source.isConnecting" :is-connecting="source.isConnecting"
:is-read-only="readOnly" :is-read-only="readOnly"
@ -286,6 +287,7 @@ onBeforeUnmount(() => {
:index="target.index" :index="target.index"
:position="target.position" :position="target.position"
:offset="target.offset" :offset="target.offset"
:required="target.required"
:is-connected="target.isConnected" :is-connected="target.isConnected"
:is-connecting="target.isConnecting" :is-connecting="target.isConnecting"
:is-read-only="readOnly" :is-read-only="readOnly"

View file

@ -190,6 +190,7 @@ function openContextMenu(event: MouseEvent) {
.description { .description {
top: unset; top: unset;
position: relative; position: relative;
margin-top: 0;
margin-left: var(--spacing-s); margin-left: var(--spacing-s);
width: auto; width: auto;
min-width: unset; min-width: unset;
@ -199,6 +200,19 @@ function openContextMenu(event: MouseEvent) {
) - 2 * var(--spacing-s) ) - 2 * var(--spacing-s)
); );
} }
.label {
text-align: left;
}
&.configuration {
--canvas-node--height: 75px;
.statusIcons {
right: calc(-1 * var(--spacing-2xs));
bottom: 0;
}
}
} }
/** /**

View file

@ -15,6 +15,7 @@ export function useCanvasNodeHandle() {
const isConnected = computed(() => handle?.isConnected.value ?? false); const isConnected = computed(() => handle?.isConnected.value ?? false);
const isConnecting = computed(() => handle?.isConnecting.value ?? false); const isConnecting = computed(() => handle?.isConnecting.value ?? false);
const isReadOnly = computed(() => handle?.isReadOnly.value); const isReadOnly = computed(() => handle?.isReadOnly.value);
const isRequired = computed(() => handle?.isRequired.value);
const type = computed(() => handle?.type.value ?? NodeConnectionType.Main); const type = computed(() => handle?.type.value ?? NodeConnectionType.Main);
const mode = computed(() => handle?.mode.value ?? CanvasConnectionMode.Input); const mode = computed(() => handle?.mode.value ?? CanvasConnectionMode.Input);
const index = computed(() => handle?.index.value ?? 0); const index = computed(() => handle?.index.value ?? 0);
@ -25,6 +26,7 @@ export function useCanvasNodeHandle() {
isConnected, isConnected,
isConnecting, isConnecting,
isReadOnly, isReadOnly,
isRequired,
type, type,
mode, mode,
index, index,

View file

@ -165,6 +165,7 @@ export interface CanvasNodeHandleInjectionData {
mode: Ref<CanvasConnectionMode>; mode: Ref<CanvasConnectionMode>;
type: Ref<NodeConnectionType>; type: Ref<NodeConnectionType>;
index: Ref<number>; index: Ref<number>;
isRequired: Ref<boolean | undefined>;
isConnected: Ref<boolean | undefined>; isConnected: Ref<boolean | undefined>;
isConnecting: Ref<boolean | undefined>; isConnecting: Ref<boolean | undefined>;
isReadOnly: Ref<boolean | undefined>; isReadOnly: Ref<boolean | undefined>;