From 32ce65c1af11b8e6ba4f3da51b48296221bae8c3 Mon Sep 17 00:00:00 2001 From: Alex Grozav Date: Tue, 3 Sep 2024 15:11:44 +0300 Subject: [PATCH 01/18] feat(editor): Overhaul node insert position computation in new canvas (no-changelog) (#10637) --- packages/editor-ui/src/Interface.ts | 1 + packages/editor-ui/src/__tests__/mocks.ts | 6 +- .../src/components/Node/NodeCreation.vue | 10 +- .../Node/NodeCreator/NodeCreator.vue | 4 +- .../src/components/canvas/Canvas.vue | 23 +- .../nodes/render-types/CanvasNodeDefault.vue | 4 +- .../useCanvasOperations.spec.ts.snap | 8 +- .../__tests__/useCanvasOperations.spec.ts | 877 +++++++++++------- .../src/composables/useCanvasOperations.ts | 281 +++--- .../editor-ui/src/stores/credentials.store.ts | 1 + .../editor-ui/src/stores/nodeCreator.store.ts | 11 +- packages/editor-ui/src/stores/ui.store.ts | 3 + packages/editor-ui/src/utils/canvasUtilsV2.ts | 3 +- packages/editor-ui/src/utils/nodeViewUtils.ts | 3 + packages/editor-ui/src/views/NodeView.v2.vue | 29 +- 15 files changed, 793 insertions(+), 471 deletions(-) diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index e49b0491b133f..f7ad20f9eaf9c 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -1805,6 +1805,7 @@ export type ToggleNodeCreatorOptions = { createNodeActive: boolean; source?: NodeCreatorOpenSource; nodeCreatorView?: NodeFilterType; + hasAddedNodes?: boolean; }; export type AppliedThemeOption = 'light' | 'dark'; diff --git a/packages/editor-ui/src/__tests__/mocks.ts b/packages/editor-ui/src/__tests__/mocks.ts index cb15a9bf68b6f..993d5870a540a 100644 --- a/packages/editor-ui/src/__tests__/mocks.ts +++ b/packages/editor-ui/src/__tests__/mocks.ts @@ -49,18 +49,18 @@ export const mockNode = ({ }) => mock({ id, name, type, position, disabled, issues, typeVersion, parameters }); export const mockNodeTypeDescription = ({ - name, + name = SET_NODE_TYPE, version = 1, credentials = [], inputs = [NodeConnectionType.Main], outputs = [NodeConnectionType.Main], }: { - name: INodeTypeDescription['name']; + name?: INodeTypeDescription['name']; version?: INodeTypeDescription['version']; credentials?: INodeTypeDescription['credentials']; inputs?: INodeTypeDescription['inputs']; outputs?: INodeTypeDescription['outputs']; -}) => +} = {}) => mock({ name, displayName: name, diff --git a/packages/editor-ui/src/components/Node/NodeCreation.vue b/packages/editor-ui/src/components/Node/NodeCreation.vue index dd6f876d30ee8..38c89d135a2f6 100644 --- a/packages/editor-ui/src/components/Node/NodeCreation.vue +++ b/packages/editor-ui/src/components/Node/NodeCreation.vue @@ -23,7 +23,7 @@ const LazyNodeCreator = defineAsyncComponent( ); const props = withDefaults(defineProps(), { - createNodeActive: false, + createNodeActive: false, // Determines if the node creator is open }); const emit = defineEmits<{ @@ -88,13 +88,15 @@ function addStickyNote() { emit('addNodes', getAddedNodesAndConnections([{ type: STICKY_NODE_TYPE, position }])); } -function closeNodeCreator() { - emit('toggleNodeCreator', { createNodeActive: false }); +function closeNodeCreator(hasAddedNodes = false) { + if (props.createNodeActive) { + emit('toggleNodeCreator', { createNodeActive: false, hasAddedNodes }); + } } function nodeTypeSelected(nodeTypes: string[]) { emit('addNodes', getAddedNodesAndConnections(nodeTypes.map((type) => ({ type })))); - closeNodeCreator(); + closeNodeCreator(true); } diff --git a/packages/editor-ui/src/components/Node/NodeCreator/NodeCreator.vue b/packages/editor-ui/src/components/Node/NodeCreator/NodeCreator.vue index b446ade75edcf..d93e83ebfa86d 100644 --- a/packages/editor-ui/src/components/Node/NodeCreator/NodeCreator.vue +++ b/packages/editor-ui/src/components/Node/NodeCreator/NodeCreator.vue @@ -101,8 +101,8 @@ watch( ); // Close node creator when the last view stacks is closed -watch(viewStacksLength, (viewStacksLength) => { - if (viewStacksLength === 0) { +watch(viewStacksLength, (value) => { + if (value === 0) { emit('closeNodeCreator'); setShowScrim(false); } diff --git a/packages/editor-ui/src/components/canvas/Canvas.vue b/packages/editor-ui/src/components/canvas/Canvas.vue index b456ec0df15d4..841028fa05ef0 100644 --- a/packages/editor-ui/src/components/canvas/Canvas.vue +++ b/packages/editor-ui/src/components/canvas/Canvas.vue @@ -57,7 +57,11 @@ const emit = defineEmits<{ 'create:connection:start': [handle: ConnectStartEvent]; 'create:connection': [connection: Connection]; 'create:connection:end': [connection: Connection, event?: MouseEvent]; - 'create:connection:cancelled': [handle: ConnectStartEvent, event?: MouseEvent]; + 'create:connection:cancelled': [ + handle: ConnectStartEvent, + position: XYPosition, + event?: MouseEvent, + ]; 'click:connection:add': [connection: Connection]; 'click:pane': [position: XYPosition]; 'run:workflow': []; @@ -227,7 +231,7 @@ function onConnectEnd(event?: MouseEvent) { if (connectedHandle.value) { emit('create:connection:end', connectedHandle.value, event); } else if (connectingHandle.value) { - emit('create:connection:cancelled', connectingHandle.value, event); + emit('create:connection:cancelled', connectingHandle.value, getProjectedPosition(event), event); } connectedHandle.value = undefined; @@ -291,14 +295,19 @@ function emitWithLastSelectedNode(emitFn: (id: string) => void) { const defaultZoom = 1; const zoom = ref(defaultZoom); -function onClickPane(event: MouseEvent) { +function getProjectedPosition(event?: MouseEvent) { const bounds = viewportRef.value?.getBoundingClientRect() ?? { left: 0, top: 0 }; - const position = project({ - x: event.offsetX - bounds.left, - y: event.offsetY - bounds.top, + const offsetX = event?.clientX ?? 0; + const offsetY = event?.clientY ?? 0; + + return project({ + x: offsetX - bounds.left, + y: offsetY - bounds.top, }); +} - emit('click:pane', position); +function onClickPane(event: MouseEvent) { + emit('click:pane', getProjectedPosition(event)); } async function onFitView() { diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue index 2f50454baa5c0..2bbb2421d11a2 100644 --- a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue +++ b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue @@ -145,8 +145,8 @@ function openContextMenu(event: MouseEvent) { */ &.configuration { - --canvas-node--width: 76px; - --canvas-node--height: 76px; + --canvas-node--width: 80px; + --canvas-node--height: 80px; background: var(--canvas-node--background, var(--node-type-supplemental-background)); border: var(--canvas-node-border-width) solid diff --git a/packages/editor-ui/src/composables/__tests__/__snapshots__/useCanvasOperations.spec.ts.snap b/packages/editor-ui/src/composables/__tests__/__snapshots__/useCanvasOperations.spec.ts.snap index 875db0009307d..079a5d2f51506 100644 --- a/packages/editor-ui/src/composables/__tests__/__snapshots__/useCanvasOperations.spec.ts.snap +++ b/packages/editor-ui/src/composables/__tests__/__snapshots__/useCanvasOperations.spec.ts.snap @@ -9,7 +9,7 @@ exports[`useCanvasOperations > copyNodes > should copy nodes 1`] = ` "parameters": {}, "id": "1", "name": "Node 1", - "type": "type", + "type": "n8n-nodes-base.set", "position": [ 40, 40 @@ -20,7 +20,7 @@ exports[`useCanvasOperations > copyNodes > should copy nodes 1`] = ` "parameters": {}, "id": "2", "name": "Node 2", - "type": "type", + "type": "n8n-nodes-base.set", "position": [ 40, 40 @@ -44,7 +44,7 @@ exports[`useCanvasOperations > cutNodes > should copy and delete nodes 1`] = ` "parameters": {}, "id": "1", "name": "Node 1", - "type": "type", + "type": "n8n-nodes-base.set", "position": [ 40, 40 @@ -55,7 +55,7 @@ exports[`useCanvasOperations > cutNodes > should copy and delete nodes 1`] = ` "parameters": {}, "id": "2", "name": "Node 2", - "type": "type", + "type": "n8n-nodes-base.set", "position": [ 40, 40 diff --git a/packages/editor-ui/src/composables/__tests__/useCanvasOperations.spec.ts b/packages/editor-ui/src/composables/__tests__/useCanvasOperations.spec.ts index 7e6c9a0921ea7..0a6b75d1efdc4 100644 --- a/packages/editor-ui/src/composables/__tests__/useCanvasOperations.spec.ts +++ b/packages/editor-ui/src/composables/__tests__/useCanvasOperations.spec.ts @@ -1,7 +1,6 @@ -import { createPinia, setActivePinia } from 'pinia'; -import type { Connection } from '@vue-flow/core'; +import { setActivePinia } from 'pinia'; import type { IConnection, Workflow } from 'n8n-workflow'; -import { NodeConnectionType } from 'n8n-workflow'; +import { NodeConnectionType, NodeHelpers } from 'n8n-workflow'; import { useCanvasOperations } from '@/composables/useCanvasOperations'; import type { CanvasNode } from '@/types'; import type { ICredentialsResponse, INodeUi, IWorkflowDb } from '@/Interface'; @@ -20,10 +19,12 @@ import { useRouter } from 'vue-router'; import { mock } from 'vitest-mock-extended'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useCredentialsStore } from '@/stores/credentials.store'; -import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; -import { telemetry } from '@/plugins/telemetry'; -import { useClipboard } from '@/composables/useClipboard'; import { waitFor } from '@testing-library/vue'; +import { createTestingPinia } from '@pinia/testing'; +import { mockedStore } from '@/__tests__/utils'; +import { SET_NODE_TYPE, STORES } from '@/constants'; +import type { Connection } from '@vue-flow/core'; +import { useClipboard } from '@/composables/useClipboard'; vi.mock('vue-router', async (importOriginal) => { const actual = await importOriginal<{}>(); @@ -33,78 +34,92 @@ vi.mock('vue-router', async (importOriginal) => { }; }); +vi.mock('n8n-workflow', async (importOriginal) => { + const actual = await importOriginal<{}>(); + return { + ...actual, + TelemetryHelpers: { + generateNodesGraph: vi.fn().mockReturnValue({ + nodeGraph: { + nodes: [], + }, + }), + }, + }; +}); + vi.mock('@/composables/useClipboard', async () => { const copySpy = vi.fn(); return { useClipboard: vi.fn(() => ({ copy: copySpy })) }; }); -describe('useCanvasOperations', () => { - let workflowsStore: ReturnType; - let uiStore: ReturnType; - let ndvStore: ReturnType; - let historyStore: ReturnType; - let nodeTypesStore: ReturnType; - let credentialsStore: ReturnType; - let canvasOperations: ReturnType; - let workflowHelpers: ReturnType; +vi.mock('@/composables/useTelemetry', () => ({ + useTelemetry: () => ({ track: vi.fn() }), +})); +describe('useCanvasOperations', () => { const router = useRouter(); + const workflowId = 'test'; + const initialState = { + [STORES.NODE_TYPES]: {}, + [STORES.NDV]: {}, + [STORES.WORKFLOWS]: { + workflowId, + workflow: mock({ + id: workflowId, + nodes: [], + connections: {}, + tags: [], + usedCredentials: [], + }), + }, + [STORES.SETTINGS]: { + settings: { + enterprise: {}, + }, + }, + }; + beforeEach(async () => { - const pinia = createPinia(); + const pinia = createTestingPinia({ initialState }); setActivePinia(pinia); - - workflowsStore = useWorkflowsStore(); - uiStore = useUIStore(); - ndvStore = useNDVStore(); - historyStore = useHistoryStore(); - nodeTypesStore = useNodeTypesStore(); - credentialsStore = useCredentialsStore(); - workflowHelpers = useWorkflowHelpers({ router }); - - const workflowId = 'test'; - const workflow = mock({ - id: workflowId, - nodes: [], - connections: {}, - tags: [], - usedCredentials: [], - }); - - workflowsStore.resetWorkflow(); - workflowsStore.resetState(); - workflowHelpers.initState(workflow); - - canvasOperations = useCanvasOperations({ router }); vi.clearAllMocks(); }); describe('requireNodeTypeDescription', () => { it('should return node type description when type and version match', () => { + const nodeTypesStore = useNodeTypesStore(); const type = 'testType'; const version = 1; const expectedDescription = mockNodeTypeDescription({ name: type, version }); - nodeTypesStore.setNodeTypes([expectedDescription]); - const result = canvasOperations.requireNodeTypeDescription(type, version); + nodeTypesStore.nodeTypes = { [type]: { [version]: expectedDescription } }; + + const { requireNodeTypeDescription } = useCanvasOperations({ router }); + const result = requireNodeTypeDescription(type, version); expect(result).toBe(expectedDescription); }); it('should throw an error when node type does not exist', () => { const type = 'nonexistentType'; + const { requireNodeTypeDescription } = useCanvasOperations({ router }); expect(() => { - canvasOperations.requireNodeTypeDescription(type); + requireNodeTypeDescription(type); }).toThrow(); }); it('should return node type description when only type is provided and it exists', () => { + const nodeTypesStore = useNodeTypesStore(); const type = 'testTypeWithoutVersion'; const expectedDescription = mockNodeTypeDescription({ name: type }); - nodeTypesStore.setNodeTypes([expectedDescription]); - const result = canvasOperations.requireNodeTypeDescription(type); + nodeTypesStore.nodeTypes = { [type]: { 2: expectedDescription } }; + + const { requireNodeTypeDescription } = useCanvasOperations({ router }); + const result = requireNodeTypeDescription(type); expect(result).toBe(expectedDescription); }); @@ -112,7 +127,8 @@ describe('useCanvasOperations', () => { describe('addNode', () => { it('should create node with default version when version is undefined', () => { - const result = canvasOperations.addNode( + const { addNode } = useCanvasOperations({ router }); + const result = addNode( { name: 'example', type: 'type', @@ -125,7 +141,8 @@ describe('useCanvasOperations', () => { }); it('should create node with default position when position is not provided', () => { - const result = canvasOperations.addNode( + const { addNode } = useCanvasOperations({ router }); + const result = addNode( { type: 'type', typeVersion: 1, @@ -137,7 +154,8 @@ describe('useCanvasOperations', () => { }); it('should create node with provided position when position is provided', () => { - const result = canvasOperations.addNode( + const { addNode } = useCanvasOperations({ router }); + const result = addNode( { type: 'type', typeVersion: 1, @@ -150,6 +168,7 @@ describe('useCanvasOperations', () => { }); it('should create node with default credentials when only one credential is available', () => { + const credentialsStore = useCredentialsStore(); const credential = mock({ id: '1', name: 'cred', type: 'cred' }); const nodeTypeName = 'type'; const nodeTypeDescription = mockNodeTypeDescription({ @@ -157,14 +176,17 @@ describe('useCanvasOperations', () => { credentials: [{ name: credential.name }], }); - credentialsStore.addCredentials([credential]); + credentialsStore.state.credentials = { + [credential.id]: credential, + }; // @ts-expect-error Known pinia issue when spying on store getters vi.spyOn(credentialsStore, 'getUsableCredentialByType', 'get').mockReturnValue(() => [ credential, ]); - const result = canvasOperations.addNode( + const { addNode } = useCanvasOperations({ router }); + const result = addNode( { type: nodeTypeName, typeVersion: 1, @@ -176,6 +198,7 @@ describe('useCanvasOperations', () => { }); it('should not assign credentials when multiple credentials are available', () => { + const credentialsStore = useCredentialsStore(); const credentialA = mock({ id: '1', name: 'credA', type: 'cred' }); const credentialB = mock({ id: '1', name: 'credB', type: 'cred' }); const nodeTypeName = 'type'; @@ -190,7 +213,8 @@ describe('useCanvasOperations', () => { credentialB, ]); - const result = canvasOperations.addNode( + const { addNode } = useCanvasOperations({ router }); + const result = addNode( { type: 'type', typeVersion: 1, @@ -201,9 +225,11 @@ describe('useCanvasOperations', () => { }); it('should open NDV when specified', async () => { + const ndvStore = useNDVStore(); const nodeTypeDescription = mockNodeTypeDescription({ name: 'type' }); - canvasOperations.addNode( + const { addNode } = useCanvasOperations({ router }); + addNode( { type: 'type', typeVersion: 1, @@ -213,12 +239,123 @@ describe('useCanvasOperations', () => { { openNDV: true }, ); - await waitFor(() => expect(ndvStore.activeNodeName).toBe('Test Name')); + await waitFor(() => expect(ndvStore.setActiveNodeName).toHaveBeenCalledWith('Test Name')); + }); + }); + + describe('resolveNodePosition', () => { + it('should return the node position if it is already set', () => { + const node = createTestNode({ position: [100, 100] }); + const nodeTypeDescription = mockNodeTypeDescription(); + + const { resolveNodePosition } = useCanvasOperations({ router }); + const position = resolveNodePosition(node, nodeTypeDescription); + + expect(position).toEqual([100, 100]); + }); + + it('should place the node at the last cancelled connection position', () => { + const uiStore = mockedStore(useUIStore); + const node = createTestNode({ id: '0' }); + const nodeTypeDescription = mockNodeTypeDescription(); + + vi.spyOn(uiStore, 'lastInteractedWithNode', 'get').mockReturnValue(node); + + uiStore.lastInteractedWithNodeHandle = 'inputs/main/0'; + uiStore.lastCancelledConnectionPosition = [200, 200]; + + const { resolveNodePosition } = useCanvasOperations({ router }); + const position = resolveNodePosition({ ...node, position: undefined }, nodeTypeDescription); + + expect(position).toEqual([200, 160]); + expect(uiStore.lastCancelledConnectionPosition).toBeNull(); + }); + + it('should place the node to the right of the last interacted with node', () => { + const uiStore = mockedStore(useUIStore); + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = mockedStore(useNodeTypesStore); + + const node = createTestNode({ id: '0' }); + const nodeTypeDescription = mockNodeTypeDescription(); + + uiStore.lastInteractedWithNode = createTestNode({ + position: [100, 100], + type: 'test', + typeVersion: 1, + }); + nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription); + workflowsStore.getCurrentWorkflow.mockReturnValue( + createTestWorkflowObject(workflowsStore.workflow), + ); + + const { resolveNodePosition } = useCanvasOperations({ router }); + const position = resolveNodePosition({ ...node, position: undefined }, nodeTypeDescription); + + expect(position).toEqual([320, 100]); + }); + + it('should place the node below the last interacted with node if it has non-main outputs', () => { + const uiStore = mockedStore(useUIStore); + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = mockedStore(useNodeTypesStore); + + const node = createTestNode({ id: '0' }); + const nodeTypeDescription = mockNodeTypeDescription(); + + uiStore.lastInteractedWithNode = createTestNode({ + position: [100, 100], + type: 'test', + typeVersion: 1, + }); + nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription); + workflowsStore.getCurrentWorkflow.mockReturnValue( + createTestWorkflowObject(workflowsStore.workflow), + ); + + vi.spyOn(NodeHelpers, 'getNodeOutputs').mockReturnValue([ + { type: NodeConnectionType.AiTool }, + ]); + vi.spyOn(NodeHelpers, 'getConnectionTypes').mockReturnValue([NodeConnectionType.AiTool]); + + const { resolveNodePosition } = useCanvasOperations({ router }); + const position = resolveNodePosition({ ...node, position: undefined }, nodeTypeDescription); + + expect(position).toEqual([460, 100]); + }); + + it('should place the node at the last clicked position if no other position is set', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + + const node = createTestNode({ id: '0' }); + const nodeTypeDescription = mockNodeTypeDescription(); + + workflowsStore.workflowTriggerNodes = [ + createTestNode({ id: 'trigger', position: [100, 100] }), + ]; + + const { resolveNodePosition, lastClickPosition } = useCanvasOperations({ router }); + lastClickPosition.value = [300, 300]; + + const position = resolveNodePosition({ ...node, position: undefined }, nodeTypeDescription); + + expect(position).toEqual([300, 300]); + }); + + it('should place the trigger node at the root if it is the first trigger node', () => { + const node = createTestNode({ id: '0' }); + const nodeTypeDescription = mockNodeTypeDescription(); + + const { resolveNodePosition } = useCanvasOperations({ router }); + const position = resolveNodePosition({ ...node, position: undefined }, nodeTypeDescription); + + expect(position).toEqual([0, 0]); }); }); describe('updateNodesPosition', () => { it('records history for multiple node position updates when tracking is enabled', () => { + const historyStore = useHistoryStore(); const events = [ { id: 'node1', position: { x: 100, y: 100 } }, { id: 'node2', position: { x: 200, y: 200 } }, @@ -226,19 +363,21 @@ describe('useCanvasOperations', () => { const startRecordingUndoSpy = vi.spyOn(historyStore, 'startRecordingUndo'); const stopRecordingUndoSpy = vi.spyOn(historyStore, 'stopRecordingUndo'); - canvasOperations.updateNodesPosition(events, { trackHistory: true, trackBulk: true }); + const { updateNodesPosition } = useCanvasOperations({ router }); + updateNodesPosition(events, { trackHistory: true, trackBulk: true }); expect(startRecordingUndoSpy).toHaveBeenCalled(); expect(stopRecordingUndoSpy).toHaveBeenCalled(); }); it('updates positions for multiple nodes', () => { + const workflowsStore = mockedStore(useWorkflowsStore); const events = [ { id: 'node1', position: { x: 100, y: 100 } }, { id: 'node2', position: { x: 200, y: 200 } }, ]; const setNodePositionByIdSpy = vi.spyOn(workflowsStore, 'setNodePositionById'); - vi.spyOn(workflowsStore, 'getNodeById') + workflowsStore.getNodeById .mockReturnValueOnce( createTestNode({ id: events[0].id, @@ -252,7 +391,8 @@ describe('useCanvasOperations', () => { }), ); - canvasOperations.updateNodesPosition(events); + const { updateNodesPosition } = useCanvasOperations({ router }); + updateNodesPosition(events); expect(setNodePositionByIdSpy).toHaveBeenCalledTimes(2); expect(setNodePositionByIdSpy).toHaveBeenCalledWith('node1', [100, 100]); @@ -260,11 +400,13 @@ describe('useCanvasOperations', () => { }); it('does not record history when trackHistory is false', () => { + const historyStore = useHistoryStore(); const events = [{ id: 'node1', position: { x: 100, y: 100 } }]; const startRecordingUndoSpy = vi.spyOn(historyStore, 'startRecordingUndo'); const stopRecordingUndoSpy = vi.spyOn(historyStore, 'stopRecordingUndo'); - canvasOperations.updateNodesPosition(events, { trackHistory: false, trackBulk: false }); + const { updateNodesPosition } = useCanvasOperations({ router }); + updateNodesPosition(events, { trackHistory: false, trackBulk: false }); expect(startRecordingUndoSpy).not.toHaveBeenCalled(); expect(stopRecordingUndoSpy).not.toHaveBeenCalled(); @@ -273,9 +415,7 @@ describe('useCanvasOperations', () => { describe('updateNodePosition', () => { it('should update node position', () => { - const setNodePositionByIdSpy = vi - .spyOn(workflowsStore, 'setNodePositionById') - .mockImplementation(() => {}); + const workflowsStore = mockedStore(useWorkflowsStore); const id = 'node1'; const position: CanvasNode['position'] = { x: 10, y: 20 }; const node = createTestNode({ @@ -285,40 +425,49 @@ describe('useCanvasOperations', () => { name: 'Node 1', }); - vi.spyOn(workflowsStore, 'getNodeById').mockReturnValueOnce(node); + workflowsStore.getNodeById.mockReturnValueOnce(node); - canvasOperations.updateNodePosition(id, position); + const { updateNodePosition } = useCanvasOperations({ router }); + updateNodePosition(id, position); - expect(setNodePositionByIdSpy).toHaveBeenCalledWith(id, [position.x, position.y]); + expect(workflowsStore.setNodePositionById).toHaveBeenCalledWith(id, [position.x, position.y]); }); }); describe('setNodeSelected', () => { it('should set last selected node when node id is provided and node exists', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const uiStore = useUIStore(); const nodeId = 'node1'; const nodeName = 'Node 1'; workflowsStore.getNodeById = vi.fn().mockReturnValue({ name: nodeName }); uiStore.lastSelectedNode = ''; - canvasOperations.setNodeSelected(nodeId); + const { setNodeSelected } = useCanvasOperations({ router }); + setNodeSelected(nodeId); expect(uiStore.lastSelectedNode).toBe(nodeName); }); it('should not change last selected node when node id is provided but node does not exist', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const uiStore = useUIStore(); const nodeId = 'node1'; workflowsStore.getNodeById = vi.fn().mockReturnValue(undefined); uiStore.lastSelectedNode = 'Existing Node'; - canvasOperations.setNodeSelected(nodeId); + const { setNodeSelected } = useCanvasOperations({ router }); + setNodeSelected(nodeId); expect(uiStore.lastSelectedNode).toBe('Existing Node'); }); it('should clear last selected node when node id is not provided', () => { + const uiStore = useUIStore(); uiStore.lastSelectedNode = 'Existing Node'; - canvasOperations.setNodeSelected(); + const { setNodeSelected } = useCanvasOperations({ router }); + setNodeSelected(); expect(uiStore.lastSelectedNode).toBe(''); }); @@ -326,54 +475,74 @@ describe('useCanvasOperations', () => { describe('addNodes', () => { it('should add nodes at specified positions', async () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = useNodeTypesStore(); const nodeTypeName = 'type'; const nodes = [ mockNode({ name: 'Node 1', type: nodeTypeName, position: [30, 40] }), mockNode({ name: 'Node 2', type: nodeTypeName, position: [100, 240] }), ]; - nodeTypesStore.setNodeTypes([ - mockNodeTypeDescription({ - name: nodeTypeName, - }), - ]); + workflowsStore.getCurrentWorkflow.mockReturnValue( + createTestWorkflowObject(workflowsStore.workflow), + ); - await canvasOperations.addNodes(nodes, {}); + nodeTypesStore.nodeTypes = { + [nodeTypeName]: { 1: mockNodeTypeDescription({ name: nodeTypeName }) }, + }; + + const { addNodes } = useCanvasOperations({ router }); + await addNodes(nodes, {}); - expect(workflowsStore.workflow.nodes).toHaveLength(2); - expect(workflowsStore.workflow.nodes[0]).toHaveProperty('name', nodes[0].name); - expect(workflowsStore.workflow.nodes[0]).toHaveProperty('parameters', {}); - expect(workflowsStore.workflow.nodes[0]).toHaveProperty('type', nodeTypeName); - expect(workflowsStore.workflow.nodes[0]).toHaveProperty('typeVersion', 1); - expect(workflowsStore.workflow.nodes[0]).toHaveProperty('position'); + expect(workflowsStore.addNode).toHaveBeenCalledTimes(2); + expect(workflowsStore.addNode.mock.calls[0][0]).toMatchObject({ + name: nodes[0].name, + type: nodeTypeName, + typeVersion: 1, + position: [40, 40], + parameters: {}, + }); + expect(workflowsStore.addNode.mock.calls[1][0]).toMatchObject({ + name: nodes[1].name, + type: nodeTypeName, + typeVersion: 1, + position: [100, 240], + parameters: {}, + }); }); it('should add nodes at current position when position is not specified', async () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = mockedStore(useNodeTypesStore); const nodeTypeName = 'type'; const nodes = [ mockNode({ name: 'Node 1', type: nodeTypeName, position: [120, 120] }), mockNode({ name: 'Node 2', type: nodeTypeName, position: [180, 320] }), ]; - const workflowStoreAddNodeSpy = vi.spyOn(workflowsStore, 'addNode'); - nodeTypesStore.setNodeTypes([ - mockNodeTypeDescription({ - name: nodeTypeName, - }), - ]); + workflowsStore.getCurrentWorkflow.mockReturnValue( + createTestWorkflowObject(workflowsStore.workflow), + ); - await canvasOperations.addNodes(nodes, { position: [50, 60] }); + nodeTypesStore.nodeTypes = { + [nodeTypeName]: { 1: mockNodeTypeDescription({ name: nodeTypeName }) }, + }; - expect(workflowStoreAddNodeSpy).toHaveBeenCalledTimes(2); - expect(workflowStoreAddNodeSpy.mock.calls[0][0].position).toEqual( + const { addNodes } = useCanvasOperations({ router }); + await addNodes(nodes, { position: [50, 60] }); + + expect(workflowsStore.addNode).toHaveBeenCalledTimes(2); + expect(workflowsStore.addNode.mock.calls[0][0].position).toEqual( expect.arrayContaining(nodes[0].position), ); - expect(workflowStoreAddNodeSpy.mock.calls[1][0].position).toEqual( + expect(workflowsStore.addNode.mock.calls[1][0].position).toEqual( expect.arrayContaining(nodes[1].position), ); }); it('should adjust the position of nodes with multiple inputs', async () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = useNodeTypesStore(); const nodeTypeName = 'type'; const nodes = [ mockNode({ id: 'a', name: 'Node A', type: nodeTypeName, position: [40, 40] }), @@ -382,18 +551,12 @@ describe('useCanvasOperations', () => { ]; const setNodePositionByIdSpy = vi.spyOn(workflowsStore, 'setNodePositionById'); - vi.spyOn(workflowsStore, 'getNodeByName') - .mockReturnValueOnce(nodes[1]) - .mockReturnValueOnce(nodes[2]); - vi.spyOn(workflowsStore, 'getNodeById') - .mockReturnValueOnce(nodes[1]) - .mockReturnValueOnce(nodes[2]); - - nodeTypesStore.setNodeTypes([ - mockNodeTypeDescription({ - name: nodeTypeName, - }), - ]); + workflowsStore.getNodeByName.mockReturnValueOnce(nodes[1]).mockReturnValueOnce(nodes[2]); + workflowsStore.getNodeById.mockReturnValueOnce(nodes[1]).mockReturnValueOnce(nodes[2]); + + nodeTypesStore.nodeTypes = { + [nodeTypeName]: { 1: mockNodeTypeDescription({ name: nodeTypeName }) }, + }; vi.spyOn(workflowsStore, 'getCurrentWorkflow').mockImplementation(() => mock({ @@ -406,7 +569,8 @@ describe('useCanvasOperations', () => { }), ); - await canvasOperations.addNodes(nodes, {}); + const { addNodes } = useCanvasOperations({ router }); + await addNodes(nodes, {}); expect(setNodePositionByIdSpy).toHaveBeenCalledTimes(2); expect(setNodePositionByIdSpy).toHaveBeenCalledWith(nodes[1].id, expect.any(Object)); @@ -416,12 +580,14 @@ describe('useCanvasOperations', () => { describe('revertAddNode', () => { it('deletes node if it exists', async () => { + const workflowsStore = mockedStore(useWorkflowsStore); const node = createTestNode(); - vi.spyOn(workflowsStore, 'getNodeByName').mockReturnValueOnce(node); - vi.spyOn(workflowsStore, 'getNodeById').mockReturnValueOnce(node); + workflowsStore.getNodeByName.mockReturnValueOnce(node); + workflowsStore.getNodeById.mockReturnValueOnce(node); const removeNodeByIdSpy = vi.spyOn(workflowsStore, 'removeNodeById'); - await canvasOperations.revertAddNode(node.name); + const { revertAddNode } = useCanvasOperations({ router }); + await revertAddNode(node.name); expect(removeNodeByIdSpy).toHaveBeenCalledWith(node.id); }); @@ -429,18 +595,12 @@ describe('useCanvasOperations', () => { describe('deleteNode', () => { it('should delete node and track history', () => { - const removeNodeByIdSpy = vi - .spyOn(workflowsStore, 'removeNodeById') - .mockImplementation(() => {}); - const removeNodeConnectionsByIdSpy = vi - .spyOn(workflowsStore, 'removeNodeConnectionsById') - .mockImplementation(() => {}); - const removeNodeExecutionDataByIdSpy = vi - .spyOn(workflowsStore, 'removeNodeExecutionDataById') - .mockImplementation(() => {}); - const pushCommandToUndoSpy = vi - .spyOn(historyStore, 'pushCommandToUndo') - .mockImplementation(() => {}); + const workflowsStore = mockedStore(useWorkflowsStore); + const historyStore = mockedStore(useHistoryStore); + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); + workflowsStore.incomingConnectionsByNodeName.mockReturnValue({}); const id = 'node1'; const node: INodeUi = createTestNode({ @@ -450,29 +610,24 @@ describe('useCanvasOperations', () => { name: 'Node 1', }); - vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(node); + workflowsStore.getNodeById.mockReturnValue(node); - canvasOperations.deleteNode(id, { trackHistory: true }); + const { deleteNode } = useCanvasOperations({ router }); + deleteNode(id, { trackHistory: true }); - expect(removeNodeByIdSpy).toHaveBeenCalledWith(id); - expect(removeNodeConnectionsByIdSpy).toHaveBeenCalledWith(id); - expect(removeNodeExecutionDataByIdSpy).toHaveBeenCalledWith(id); - expect(pushCommandToUndoSpy).toHaveBeenCalledWith(new RemoveNodeCommand(node)); + expect(workflowsStore.removeNodeById).toHaveBeenCalledWith(id); + expect(workflowsStore.removeNodeConnectionsById).toHaveBeenCalledWith(id); + expect(workflowsStore.removeNodeExecutionDataById).toHaveBeenCalledWith(id); + expect(historyStore.pushCommandToUndo).toHaveBeenCalledWith(new RemoveNodeCommand(node)); }); it('should delete node without tracking history', () => { - const removeNodeByIdSpy = vi - .spyOn(workflowsStore, 'removeNodeById') - .mockImplementation(() => {}); - const removeNodeConnectionsByIdSpy = vi - .spyOn(workflowsStore, 'removeNodeConnectionsById') - .mockImplementation(() => {}); - const removeNodeExecutionDataByIdSpy = vi - .spyOn(workflowsStore, 'removeNodeExecutionDataById') - .mockImplementation(() => {}); - const pushCommandToUndoSpy = vi - .spyOn(historyStore, 'pushCommandToUndo') - .mockImplementation(() => {}); + const workflowsStore = mockedStore(useWorkflowsStore); + const historyStore = mockedStore(useHistoryStore); + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); + workflowsStore.incomingConnectionsByNodeName.mockReturnValue({}); const id = 'node1'; const node = createTestNode({ @@ -483,84 +638,91 @@ describe('useCanvasOperations', () => { parameters: {}, }); - vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(node); + workflowsStore.getNodeById.mockReturnValue(node); - canvasOperations.deleteNode(id, { trackHistory: false }); + const { deleteNode } = useCanvasOperations({ router }); + deleteNode(id, { trackHistory: false }); - expect(removeNodeByIdSpy).toHaveBeenCalledWith(id); - expect(removeNodeConnectionsByIdSpy).toHaveBeenCalledWith(id); - expect(removeNodeExecutionDataByIdSpy).toHaveBeenCalledWith(id); - expect(pushCommandToUndoSpy).not.toHaveBeenCalled(); + expect(workflowsStore.removeNodeById).toHaveBeenCalledWith(id); + expect(workflowsStore.removeNodeConnectionsById).toHaveBeenCalledWith(id); + expect(workflowsStore.removeNodeExecutionDataById).toHaveBeenCalledWith(id); + expect(historyStore.pushCommandToUndo).not.toHaveBeenCalled(); }); it('should connect adjacent nodes when deleting a node surrounded by other nodes', () => { - nodeTypesStore.setNodeTypes([mockNodeTypeDescription({ name: 'node' })]); + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = mockedStore(useNodeTypesStore); + + nodeTypesStore.nodeTypes = { + [SET_NODE_TYPE]: { 1: mockNodeTypeDescription({ name: SET_NODE_TYPE }) }, + }; + const nodes = [ createTestNode({ id: 'input', - type: 'node', + type: SET_NODE_TYPE, position: [10, 20], name: 'Input Node', }), createTestNode({ id: 'middle', - type: 'node', + type: SET_NODE_TYPE, position: [10, 20], name: 'Middle Node', }), createTestNode({ id: 'output', - type: 'node', + type: SET_NODE_TYPE, position: [10, 20], name: 'Output Node', }), ]; - workflowsStore.setNodes(nodes); - workflowsStore.setConnections({ - 'Input Node': { + + workflowsStore.workflow.nodes = nodes; + workflowsStore.workflow.connections = { + [nodes[0].name]: { main: [ [ { - node: 'Middle Node', + node: nodes[1].name, type: NodeConnectionType.Main, index: 0, }, ], ], }, - 'Middle Node': { + [nodes[1].name]: { main: [ [ { - node: 'Output Node', + node: nodes[2].name, type: NodeConnectionType.Main, index: 0, }, ], ], }, - }); + }; - canvasOperations.deleteNode('middle'); - expect(workflowsStore.allConnections).toEqual({ - 'Input Node': { - main: [ - [ - { - node: 'Output Node', - type: NodeConnectionType.Main, - index: 0, - }, - ], - ], - }, - }); + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); + workflowsStore.incomingConnectionsByNodeName.mockReturnValue({}); + + workflowsStore.getNodeById.mockReturnValue(nodes[1]); + + const { deleteNode } = useCanvasOperations({ router }); + deleteNode(nodes[1].id); + + expect(workflowsStore.removeNodeById).toHaveBeenCalledWith(nodes[1].id); + expect(workflowsStore.removeNodeConnectionsById).toHaveBeenCalledWith(nodes[1].id); + expect(workflowsStore.removeNodeExecutionDataById).toHaveBeenCalledWith(nodes[1].id); + expect(workflowsStore.removeNodeById).toHaveBeenCalledWith(nodes[1].id); }); }); describe('revertDeleteNode', () => { it('should revert delete node', () => { - const addNodeSpy = vi.spyOn(workflowsStore, 'addNode').mockImplementation(() => {}); + const workflowsStore = mockedStore(useWorkflowsStore); const node = createTestNode({ id: 'node1', @@ -570,37 +732,42 @@ describe('useCanvasOperations', () => { parameters: {}, }); - canvasOperations.revertDeleteNode(node); + const { revertDeleteNode } = useCanvasOperations({ router }); + revertDeleteNode(node); - expect(addNodeSpy).toHaveBeenCalledWith(node); + expect(workflowsStore.addNode).toHaveBeenCalledWith(node); }); }); describe('renameNode', () => { it('should rename node', async () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const ndvStore = mockedStore(useNDVStore); const oldName = 'Old Node'; const newName = 'New Node'; const workflowObject = createTestWorkflowObject(); workflowObject.renameNode = vi.fn(); - - vi.spyOn(workflowsStore, 'getCurrentWorkflow').mockReturnValue(workflowObject); - + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); workflowsStore.getNodeByName = vi.fn().mockReturnValue({ name: oldName }); ndvStore.activeNodeName = oldName; - await canvasOperations.renameNode(oldName, newName); + const { renameNode } = useCanvasOperations({ router }); + await renameNode(oldName, newName); expect(workflowObject.renameNode).toHaveBeenCalledWith(oldName, newName); expect(ndvStore.activeNodeName).toBe(newName); }); it('should not rename node when new name is same as old name', async () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const ndvStore = mockedStore(useNDVStore); const oldName = 'Old Node'; workflowsStore.getNodeByName = vi.fn().mockReturnValue({ name: oldName }); ndvStore.activeNodeName = oldName; - await canvasOperations.renameNode(oldName, oldName); + const { renameNode } = useCanvasOperations({ router }); + await renameNode(oldName, oldName); expect(ndvStore.activeNodeName).toBe(oldName); }); @@ -608,22 +775,32 @@ describe('useCanvasOperations', () => { describe('revertRenameNode', () => { it('should revert node renaming', async () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const ndvStore = mockedStore(useNDVStore); const oldName = 'Old Node'; const currentName = 'New Node'; + + const workflowObject = createTestWorkflowObject(); + workflowObject.renameNode = vi.fn(); + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); workflowsStore.getNodeByName = vi.fn().mockReturnValue({ name: currentName }); ndvStore.activeNodeName = currentName; - await canvasOperations.revertRenameNode(currentName, oldName); + const { revertRenameNode } = useCanvasOperations({ router }); + await revertRenameNode(currentName, oldName); expect(ndvStore.activeNodeName).toBe(oldName); }); it('should not revert node renaming when old name is same as new name', async () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const ndvStore = mockedStore(useNDVStore); const oldName = 'Old Node'; workflowsStore.getNodeByName = vi.fn().mockReturnValue({ name: oldName }); ndvStore.activeNodeName = oldName; - await canvasOperations.revertRenameNode(oldName, oldName); + const { revertRenameNode } = useCanvasOperations({ router }); + await revertRenameNode(oldName, oldName); expect(ndvStore.activeNodeName).toBe(oldName); }); @@ -631,22 +808,28 @@ describe('useCanvasOperations', () => { describe('setNodeActive', () => { it('should set active node name when node exists', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const ndvStore = mockedStore(useNDVStore); const nodeId = 'node1'; const nodeName = 'Node 1'; workflowsStore.getNodeById = vi.fn().mockReturnValue({ name: nodeName }); ndvStore.activeNodeName = ''; - canvasOperations.setNodeActive(nodeId); + const { setNodeActive } = useCanvasOperations({ router }); + setNodeActive(nodeId); expect(ndvStore.activeNodeName).toBe(nodeName); }); it('should not change active node name when node does not exist', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const ndvStore = mockedStore(useNDVStore); const nodeId = 'node1'; workflowsStore.getNodeById = vi.fn().mockReturnValue(undefined); ndvStore.activeNodeName = 'Existing Node'; - canvasOperations.setNodeActive(nodeId); + const { setNodeActive } = useCanvasOperations({ router }); + setNodeActive(nodeId); expect(ndvStore.activeNodeName).toBe('Existing Node'); }); @@ -654,10 +837,12 @@ describe('useCanvasOperations', () => { describe('setNodeActiveByName', () => { it('should set active node name', () => { + const ndvStore = useNDVStore(); const nodeName = 'Node 1'; ndvStore.activeNodeName = ''; - canvasOperations.setNodeActiveByName(nodeName); + const { setNodeActiveByName } = useCanvasOperations({ router }); + setNodeActiveByName(nodeName); expect(ndvStore.activeNodeName).toBe(nodeName); }); @@ -665,19 +850,20 @@ describe('useCanvasOperations', () => { describe('toggleNodesDisabled', () => { it('disables nodes based on provided ids', async () => { + const workflowsStore = mockedStore(useWorkflowsStore); const nodes = [ createTestNode({ id: '1', name: 'A' }), createTestNode({ id: '2', name: 'B' }), ]; - vi.spyOn(workflowsStore, 'getNodesByIds').mockReturnValue(nodes); - const updateNodePropertiesSpy = vi.spyOn(workflowsStore, 'updateNodeProperties'); + workflowsStore.getNodesByIds.mockReturnValue(nodes); - canvasOperations.toggleNodesDisabled([nodes[0].id, nodes[1].id], { + const { toggleNodesDisabled } = useCanvasOperations({ router }); + toggleNodesDisabled([nodes[0].id, nodes[1].id], { trackHistory: true, trackBulk: true, }); - expect(updateNodePropertiesSpy).toHaveBeenCalledWith({ + expect(workflowsStore.updateNodeProperties).toHaveBeenCalledWith({ name: nodes[0].name, properties: { disabled: true, @@ -688,12 +874,14 @@ describe('useCanvasOperations', () => { describe('revertToggleNodeDisabled', () => { it('re-enables a previously disabled node', () => { + const workflowsStore = mockedStore(useWorkflowsStore); const nodeName = 'testNode'; const node = createTestNode({ name: nodeName }); - vi.spyOn(workflowsStore, 'getNodeByName').mockReturnValue(node); + workflowsStore.getNodeByName.mockReturnValue(node); const updateNodePropertiesSpy = vi.spyOn(workflowsStore, 'updateNodeProperties'); - canvasOperations.revertToggleNodeDisabled(nodeName); + const { revertToggleNodeDisabled } = useCanvasOperations({ router }); + revertToggleNodeDisabled(nodeName); expect(updateNodePropertiesSpy).toHaveBeenCalledWith({ name: nodeName, @@ -706,25 +894,19 @@ describe('useCanvasOperations', () => { describe('addConnections', () => { it('should create connections between nodes', async () => { - const nodeTypeName = 'type'; + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = mockedStore(useNodeTypesStore); + const nodeTypeName = SET_NODE_TYPE; + const nodeType = mockNodeTypeDescription({ + name: nodeTypeName, + inputs: [NodeConnectionType.Main], + outputs: [NodeConnectionType.Main], + }); const nodes = [ mockNode({ id: 'a', name: 'Node A', type: nodeTypeName, position: [40, 40] }), mockNode({ id: 'b', name: 'Node B', type: nodeTypeName, position: [40, 40] }), mockNode({ id: 'c', name: 'Node C', type: nodeTypeName, position: [40, 40] }), ]; - - nodeTypesStore.setNodeTypes([ - mockNodeTypeDescription({ - name: nodeTypeName, - }), - ]); - - await canvasOperations.addNodes(nodes, {}); - - vi.spyOn(workflowsStore, 'getNodeById') - .mockReturnValueOnce(nodes[0]) - .mockReturnValueOnce(nodes[1]); - const connections = [ { source: nodes[0].id, @@ -744,11 +926,20 @@ describe('useCanvasOperations', () => { }, ]; - const addConnectionSpy = vi.spyOn(workflowsStore, 'addConnection'); + workflowsStore.workflow.nodes = nodes; + nodeTypesStore.nodeTypes = { + [nodeTypeName]: { 1: nodeType }, + }; - canvasOperations.addConnections(connections); + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); + workflowsStore.getNodeById.mockReturnValueOnce(nodes[0]).mockReturnValueOnce(nodes[1]); + nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeType); - expect(addConnectionSpy).toHaveBeenCalledWith({ + const { addConnections } = useCanvasOperations({ router }); + addConnections(connections); + + expect(workflowsStore.addConnection).toHaveBeenCalledWith({ connection: [ { index: 0, @@ -767,54 +958,58 @@ describe('useCanvasOperations', () => { describe('createConnection', () => { it('should not create a connection if source node does not exist', () => { - const addConnectionSpy = vi - .spyOn(workflowsStore, 'addConnection') - .mockImplementation(() => {}); + const workflowsStore = mockedStore(useWorkflowsStore); + const uiStore = mockedStore(useUIStore); const connection: Connection = { source: 'nonexistent', target: 'targetNode' }; - vi.spyOn(workflowsStore, 'getNodeById').mockReturnValueOnce(undefined); + workflowsStore.getNodeById.mockReturnValueOnce(undefined); - canvasOperations.createConnection(connection); + const { createConnection } = useCanvasOperations({ router }); + createConnection(connection); - expect(addConnectionSpy).not.toHaveBeenCalled(); + expect(workflowsStore.addConnection).not.toHaveBeenCalled(); expect(uiStore.stateIsDirty).toBe(false); }); it('should not create a connection if target node does not exist', () => { - const addConnectionSpy = vi - .spyOn(workflowsStore, 'addConnection') - .mockImplementation(() => {}); + const workflowsStore = mockedStore(useWorkflowsStore); + const uiStore = mockedStore(useUIStore); const connection: Connection = { source: 'sourceNode', target: 'nonexistent' }; - vi.spyOn(workflowsStore, 'getNodeById') + workflowsStore.getNodeById .mockReturnValueOnce(createTestNode()) .mockReturnValueOnce(undefined); - canvasOperations.createConnection(connection); + const { createConnection } = useCanvasOperations({ router }); + createConnection(connection); - expect(addConnectionSpy).not.toHaveBeenCalled(); + expect(workflowsStore.addConnection).not.toHaveBeenCalled(); expect(uiStore.stateIsDirty).toBe(false); }); it('should create a connection if source and target nodes exist and connection is allowed', () => { - const addConnectionSpy = vi - .spyOn(workflowsStore, 'addConnection') - .mockImplementation(() => {}); + const workflowsStore = mockedStore(useWorkflowsStore); + const uiStore = mockedStore(useUIStore); + const nodeTypesStore = mockedStore(useNodeTypesStore); + + const nodeTypeDescription = mockNodeTypeDescription({ + name: SET_NODE_TYPE, + inputs: [NodeConnectionType.Main], + outputs: [NodeConnectionType.Main], + }); const nodeA = createTestNode({ id: 'a', - type: 'node', + type: nodeTypeDescription.name, name: 'Node A', }); const nodeB = createTestNode({ id: 'b', - type: 'node', + type: nodeTypeDescription.name, name: 'Node B', }); - vi.spyOn(workflowsStore, 'getNodeById').mockReturnValueOnce(nodeA).mockReturnValueOnce(nodeB); - const connection: Connection = { source: nodeA.id, sourceHandle: `outputs/${NodeConnectionType.Main}/0`, @@ -822,18 +1017,25 @@ describe('useCanvasOperations', () => { targetHandle: `inputs/${NodeConnectionType.Main}/0`, }; - const nodeTypeDescription = mockNodeTypeDescription({ - name: 'node', - inputs: [NodeConnectionType.Main], - }); + nodeTypesStore.nodeTypes = { + node: { 1: nodeTypeDescription }, + }; + + workflowsStore.workflow.nodes = [nodeA, nodeB]; + workflowsStore.getNodeById.mockReturnValueOnce(nodeA).mockReturnValueOnce(nodeB); + nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription); + + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); - nodeTypesStore.setNodeTypes([nodeTypeDescription]); - canvasOperations.editableWorkflowObject.value.nodes[nodeA.name] = nodeA; - canvasOperations.editableWorkflowObject.value.nodes[nodeB.name] = nodeB; + const { createConnection, editableWorkflowObject } = useCanvasOperations({ router }); - canvasOperations.createConnection(connection); + editableWorkflowObject.value.nodes[nodeA.name] = nodeA; + editableWorkflowObject.value.nodes[nodeB.name] = nodeB; - expect(addConnectionSpy).toHaveBeenCalledWith({ + createConnection(connection); + + expect(workflowsStore.addConnection).toHaveBeenCalledWith({ connection: [ { index: 0, node: nodeA.name, type: NodeConnectionType.Main }, { index: 0, node: nodeB.name, type: NodeConnectionType.Main }, @@ -845,29 +1047,32 @@ describe('useCanvasOperations', () => { describe('revertCreateConnection', () => { it('deletes connection if both source and target nodes exist', () => { + const workflowsStore = mockedStore(useWorkflowsStore); const connection: [IConnection, IConnection] = [ { node: 'sourceNode', type: NodeConnectionType.Main, index: 0 }, { node: 'targetNode', type: NodeConnectionType.Main, index: 0 }, ]; const testNode = createTestNode(); - const removeConnectionSpy = vi.spyOn(workflowsStore, 'removeConnection'); - vi.spyOn(workflowsStore, 'getNodeByName').mockReturnValue(testNode); - vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(testNode); + workflowsStore.getNodeByName.mockReturnValue(testNode); + workflowsStore.getNodeById.mockReturnValue(testNode); - canvasOperations.revertCreateConnection(connection); + const { revertCreateConnection } = useCanvasOperations({ router }); + revertCreateConnection(connection); - expect(removeConnectionSpy).toHaveBeenCalled(); + expect(workflowsStore.removeConnection).toHaveBeenCalled(); }); }); describe('isConnectionAllowed', () => { it('should return false if source and target nodes are the same', () => { const node = mockNode({ id: '1', type: 'testType', name: 'Test Node' }); - expect(canvasOperations.isConnectionAllowed(node, node, NodeConnectionType.Main)).toBe(false); + const { isConnectionAllowed } = useCanvasOperations({ router }); + expect(isConnectionAllowed(node, node, NodeConnectionType.Main)).toBe(false); }); it('should return false if target node type does not have inputs', () => { + const nodeTypesStore = mockedStore(useNodeTypesStore); const sourceNode = mockNode({ id: '1', type: 'sourceType', @@ -882,14 +1087,16 @@ describe('useCanvasOperations', () => { name: 'targetType', inputs: [], }); - nodeTypesStore.setNodeTypes([nodeTypeDescription]); + nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription); - expect( - canvasOperations.isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main), - ).toBe(false); + const { isConnectionAllowed } = useCanvasOperations({ router }); + expect(isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main)).toBe(false); }); it('should return false if target node does not exist in the workflow', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = mockedStore(useNodeTypesStore); + const sourceNode = mockNode({ id: '1', type: 'sourceType', @@ -904,14 +1111,18 @@ describe('useCanvasOperations', () => { name: 'targetType', inputs: [NodeConnectionType.Main], }); - nodeTypesStore.setNodeTypes([nodeTypeDescription]); - expect( - canvasOperations.isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main), - ).toBe(false); + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); + nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription); + + const { isConnectionAllowed } = useCanvasOperations({ router }); + expect(isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main)).toBe(false); }); it('should return false if input type does not match connection type', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = mockedStore(useNodeTypesStore); const sourceNode = mockNode({ id: '1', type: 'sourceType', @@ -929,16 +1140,21 @@ describe('useCanvasOperations', () => { inputs: [NodeConnectionType.AiTool], }); - nodeTypesStore.setNodeTypes([nodeTypeDescription]); - canvasOperations.editableWorkflowObject.value.nodes[sourceNode.name] = sourceNode; - canvasOperations.editableWorkflowObject.value.nodes[targetNode.name] = targetNode; + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); + + const { isConnectionAllowed, editableWorkflowObject } = useCanvasOperations({ router }); + + editableWorkflowObject.value.nodes[sourceNode.name] = sourceNode; + editableWorkflowObject.value.nodes[targetNode.name] = targetNode; + nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription); - expect( - canvasOperations.isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main), - ).toBe(false); + expect(isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main)).toBe(false); }); it('should return false if source node type is not allowed by target node input filter', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = mockedStore(useNodeTypesStore); const sourceNode = mockNode({ id: '1', type: 'sourceType', @@ -965,16 +1181,22 @@ describe('useCanvasOperations', () => { ], }); - nodeTypesStore.setNodeTypes([nodeTypeDescription]); - canvasOperations.editableWorkflowObject.value.nodes[sourceNode.name] = sourceNode; - canvasOperations.editableWorkflowObject.value.nodes[targetNode.name] = targetNode; + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); + + const { isConnectionAllowed, editableWorkflowObject } = useCanvasOperations({ router }); + + editableWorkflowObject.value.nodes[sourceNode.name] = sourceNode; + editableWorkflowObject.value.nodes[targetNode.name] = targetNode; + nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription); - expect( - canvasOperations.isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main), - ).toBe(false); + expect(isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main)).toBe(false); }); it('should return true if all conditions including filter are met', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = mockedStore(useNodeTypesStore); + const sourceNode = mockNode({ id: '1', type: 'sourceType', @@ -1001,16 +1223,22 @@ describe('useCanvasOperations', () => { ], }); - nodeTypesStore.setNodeTypes([nodeTypeDescription]); - canvasOperations.editableWorkflowObject.value.nodes[sourceNode.name] = sourceNode; - canvasOperations.editableWorkflowObject.value.nodes[targetNode.name] = targetNode; + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); + + const { isConnectionAllowed, editableWorkflowObject } = useCanvasOperations({ router }); + + editableWorkflowObject.value.nodes[sourceNode.name] = sourceNode; + editableWorkflowObject.value.nodes[targetNode.name] = targetNode; + nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription); - expect( - canvasOperations.isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main), - ).toBe(true); + expect(isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main)).toBe(true); }); it('should return true if all conditions are met and no filter is set', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = mockedStore(useNodeTypesStore); + const sourceNode = mockNode({ id: '1', type: 'sourceType', @@ -1034,51 +1262,50 @@ describe('useCanvasOperations', () => { ], }); - nodeTypesStore.setNodeTypes([nodeTypeDescription]); - canvasOperations.editableWorkflowObject.value.nodes[sourceNode.name] = sourceNode; - canvasOperations.editableWorkflowObject.value.nodes[targetNode.name] = targetNode; + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); + + const { isConnectionAllowed, editableWorkflowObject } = useCanvasOperations({ router }); - expect( - canvasOperations.isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main), - ).toBe(true); + editableWorkflowObject.value.nodes[sourceNode.name] = sourceNode; + editableWorkflowObject.value.nodes[targetNode.name] = targetNode; + nodeTypesStore.getNodeType = vi.fn().mockReturnValue(nodeTypeDescription); + + expect(isConnectionAllowed(sourceNode, targetNode, NodeConnectionType.Main)).toBe(true); }); }); describe('deleteConnection', () => { it('should not delete a connection if source node does not exist', () => { - const removeConnectionSpy = vi - .spyOn(workflowsStore, 'removeConnection') - .mockImplementation(() => {}); + const workflowsStore = mockedStore(useWorkflowsStore); const connection: Connection = { source: 'nonexistent', target: 'targetNode' }; - vi.spyOn(workflowsStore, 'getNodeById') + workflowsStore.getNodeById .mockReturnValueOnce(undefined) .mockReturnValueOnce(createTestNode()); - canvasOperations.deleteConnection(connection); + const { deleteConnection } = useCanvasOperations({ router }); + deleteConnection(connection); - expect(removeConnectionSpy).not.toHaveBeenCalled(); + expect(workflowsStore.removeConnection).not.toHaveBeenCalled(); }); it('should not delete a connection if target node does not exist', () => { - const removeConnectionSpy = vi - .spyOn(workflowsStore, 'removeConnection') - .mockImplementation(() => {}); + const workflowsStore = mockedStore(useWorkflowsStore); const connection: Connection = { source: 'sourceNode', target: 'nonexistent' }; - vi.spyOn(workflowsStore, 'getNodeById') + workflowsStore.getNodeById .mockReturnValueOnce(createTestNode()) .mockReturnValueOnce(undefined); - canvasOperations.deleteConnection(connection); + const { deleteConnection } = useCanvasOperations({ router }); + deleteConnection(connection); - expect(removeConnectionSpy).not.toHaveBeenCalled(); + expect(workflowsStore.removeConnection).not.toHaveBeenCalled(); }); it('should delete a connection if source and target nodes exist', () => { - const removeConnectionSpy = vi - .spyOn(workflowsStore, 'removeConnection') - .mockImplementation(() => {}); + const workflowsStore = mockedStore(useWorkflowsStore); const nodeA = createTestNode({ id: 'a', @@ -1099,11 +1326,12 @@ describe('useCanvasOperations', () => { targetHandle: `inputs/${NodeConnectionType.Main}/0`, }; - vi.spyOn(workflowsStore, 'getNodeById').mockReturnValueOnce(nodeA).mockReturnValueOnce(nodeB); + workflowsStore.getNodeById.mockReturnValueOnce(nodeA).mockReturnValueOnce(nodeB); - canvasOperations.deleteConnection(connection); + const { deleteConnection } = useCanvasOperations({ router }); + deleteConnection(connection); - expect(removeConnectionSpy).toHaveBeenCalledWith({ + expect(workflowsStore.removeConnection).toHaveBeenCalledWith({ connection: [ { index: 0, node: nodeA.name, type: NodeConnectionType.Main }, { index: 0, node: nodeB.name, type: NodeConnectionType.Main }, @@ -1114,81 +1342,98 @@ describe('useCanvasOperations', () => { describe('revertDeleteConnection', () => { it('should revert delete connection', () => { - const addConnectionSpy = vi - .spyOn(workflowsStore, 'addConnection') - .mockImplementation(() => {}); + const workflowsStore = mockedStore(useWorkflowsStore); const connection: [IConnection, IConnection] = [ { node: 'sourceNode', type: NodeConnectionType.Main, index: 1 }, { node: 'targetNode', type: NodeConnectionType.Main, index: 2 }, ]; - canvasOperations.revertDeleteConnection(connection); + const { revertDeleteConnection } = useCanvasOperations({ router }); + revertDeleteConnection(connection); - expect(addConnectionSpy).toHaveBeenCalledWith({ connection }); + expect(workflowsStore.addConnection).toHaveBeenCalledWith({ connection }); }); }); describe('duplicateNodes', () => { it('should duplicate nodes', async () => { - nodeTypesStore.setNodeTypes([mockNodeTypeDescription({ name: 'type' })]); - const telemetrySpy = vi.spyOn(telemetry, 'track'); + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = useNodeTypesStore(); + const nodeTypeDescription = mockNodeTypeDescription({ name: SET_NODE_TYPE }); + + nodeTypesStore.nodeTypes = { + [SET_NODE_TYPE]: { 1: nodeTypeDescription }, + }; const nodes = buildImportNodes(); - workflowsStore.setNodes(nodes); + workflowsStore.workflow.nodes = nodes; + workflowsStore.getNodesByIds.mockReturnValue(nodes); + workflowsStore.outgoingConnectionsByNodeName.mockReturnValue({}); + const workflowObject = createTestWorkflowObject(workflowsStore.workflow); + workflowsStore.getCurrentWorkflow.mockReturnValue(workflowObject); + workflowsStore.getWorkflow.mockReturnValue(workflowObject); + + const canvasOperations = useCanvasOperations({ router }); const duplicatedNodeIds = await canvasOperations.duplicateNodes(['1', '2']); + expect(duplicatedNodeIds.length).toBe(2); expect(duplicatedNodeIds).not.toContain('1'); expect(duplicatedNodeIds).not.toContain('2'); - expect(workflowsStore.workflow.nodes.length).toEqual(4); - expect(telemetrySpy).toHaveBeenCalledWith( - 'User duplicated nodes', - expect.objectContaining({ node_graph_string: expect.any(String), workflow_id: 'test' }), - ); }); }); describe('copyNodes', () => { it('should copy nodes', async () => { - nodeTypesStore.setNodeTypes([mockNodeTypeDescription({ name: 'type' })]); - const telemetrySpy = vi.spyOn(telemetry, 'track'); + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = useNodeTypesStore(); + const nodeTypeDescription = mockNodeTypeDescription({ name: SET_NODE_TYPE }); + + nodeTypesStore.nodeTypes = { + [SET_NODE_TYPE]: { 1: nodeTypeDescription }, + }; + const nodes = buildImportNodes(); - workflowsStore.setNodes(nodes); + workflowsStore.workflow.nodes = nodes; + workflowsStore.getNodesByIds.mockReturnValue(nodes); + workflowsStore.outgoingConnectionsByNodeName.mockReturnValue({}); + + const { copyNodes } = useCanvasOperations({ router }); + await copyNodes(['1', '2']); - await canvasOperations.copyNodes(['1', '2']); expect(useClipboard().copy).toHaveBeenCalledTimes(1); expect(vi.mocked(useClipboard().copy).mock.calls).toMatchSnapshot(); - expect(telemetrySpy).toHaveBeenCalledWith( - 'User copied nodes', - expect.objectContaining({ node_types: ['type', 'type'], workflow_id: 'test' }), - ); }); }); describe('cutNodes', () => { it('should copy and delete nodes', async () => { - nodeTypesStore.setNodeTypes([mockNodeTypeDescription({ name: 'type' })]); - const telemetrySpy = vi.spyOn(telemetry, 'track'); + const workflowsStore = mockedStore(useWorkflowsStore); + const nodeTypesStore = useNodeTypesStore(); + const nodeTypeDescription = mockNodeTypeDescription({ name: SET_NODE_TYPE }); + + nodeTypesStore.nodeTypes = { + [SET_NODE_TYPE]: { 1: nodeTypeDescription }, + }; + const nodes = buildImportNodes(); - workflowsStore.setNodes(nodes); + workflowsStore.workflow.nodes = nodes; + workflowsStore.getNodesByIds.mockReturnValue(nodes); + workflowsStore.outgoingConnectionsByNodeName.mockReturnValue({}); - await canvasOperations.cutNodes(['1', '2']); + const { cutNodes } = useCanvasOperations({ router }); + await cutNodes(['1', '2']); expect(useClipboard().copy).toHaveBeenCalledTimes(1); expect(vi.mocked(useClipboard().copy).mock.calls).toMatchSnapshot(); - expect(telemetrySpy).toHaveBeenCalledWith( - 'User copied nodes', - expect.objectContaining({ node_types: ['type', 'type'], workflow_id: 'test' }), - ); - expect(workflowsStore.getNodes().length).toBe(0); }); }); }); function buildImportNodes() { return [ - mockNode({ id: '1', name: 'Node 1', type: 'type' }), - mockNode({ id: '2', name: 'Node 2', type: 'type' }), + mockNode({ id: '1', name: 'Node 1', type: SET_NODE_TYPE }), + mockNode({ id: '2', name: 'Node 2', type: SET_NODE_TYPE }), ].map((node) => { // Setting position in mockNode will wrap it in a Proxy // This causes deepCopy to remove position -> set position after instead diff --git a/packages/editor-ui/src/composables/useCanvasOperations.ts b/packages/editor-ui/src/composables/useCanvasOperations.ts index caaed00196b91..c5ddbd4b9098d 100644 --- a/packages/editor-ui/src/composables/useCanvasOperations.ts +++ b/packages/editor-ui/src/composables/useCanvasOperations.ts @@ -17,7 +17,7 @@ import { useDataSchema } from '@/composables/useDataSchema'; import { useExternalHooks } from '@/composables/useExternalHooks'; import { useI18n } from '@/composables/useI18n'; import { useNodeHelpers } from '@/composables/useNodeHelpers'; -import { usePinnedData, type PinDataSource } from '@/composables/usePinnedData'; +import { type PinDataSource, usePinnedData } from '@/composables/usePinnedData'; import { useTelemetry } from '@/composables/useTelemetry'; import { useToast } from '@/composables/useToast'; import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; @@ -65,6 +65,13 @@ import { parseCanvasConnectionHandleString, } from '@/utils/canvasUtilsV2'; import * as NodeViewUtils from '@/utils/nodeViewUtils'; +import { + CONFIGURABLE_NODE_SIZE, + CONFIGURATION_NODE_SIZE, + DEFAULT_NODE_SIZE, + GRID_SIZE, + PUSH_NODES_OFFSET, +} from '@/utils/nodeViewUtils'; import { isValidNodeConnectionType } from '@/utils/typeGuards'; import type { Connection } from '@vue-flow/core'; import type { @@ -358,6 +365,7 @@ export function useCanvasOperations({ router }: { router: ReturnType & { position?: INodeUi['position'] }, + nodeTypeDescription: INodeTypeDescription, + ) { + let position: XYPosition | undefined = node.position; + let pushOffsets: XYPosition = [40, 40]; + // Available when + // - clicking the plus button of a node handle + // - dragging an edge / connection of a node handle + // - selecting a node, adding a node via the node creator const lastInteractedWithNode = uiStore.lastInteractedWithNode; + // Available when clicking the plus button of a node edge / connection const lastInteractedWithNodeConnection = uiStore.lastInteractedWithNodeConnection; + // Available when dragging an edge / connection from a node + const lastInteractedWithNodeHandle = uiStore.lastInteractedWithNodeHandle; + + const { type: connectionType, index: connectionIndex } = parseCanvasConnectionHandleString( + lastInteractedWithNodeHandle ?? lastInteractedWithNodeConnection?.sourceHandle ?? '', + ); + + const nodeSize = + connectionType === NodeConnectionType.Main ? DEFAULT_NODE_SIZE : CONFIGURATION_NODE_SIZE; + if (lastInteractedWithNode) { - const lastSelectedNodeTypeDescription = nodeTypesStore.getNodeType( + const lastInteractedWithNodeTypeDescription = nodeTypesStore.getNodeType( lastInteractedWithNode.type, lastInteractedWithNode.typeVersion, ); - const lastInteractedWithNodeObject = editableWorkflowObject.value.getNode( - lastInteractedWithNode.name, - ); - if (lastInteractedWithNodeConnection) { - shiftDownstreamNodesPosition(lastInteractedWithNode.name, NodeViewUtils.PUSH_NODES_OFFSET, { - trackHistory: true, - }); - } - - // This position is set in `onMouseUp` when pulling connections - const newNodeInsertPosition = canvasStore.newNodeInsertPosition; + const newNodeInsertPosition = uiStore.lastCancelledConnectionPosition; if (newNodeInsertPosition) { - canvasStore.newNodeInsertPosition = null; - return NodeViewUtils.getNewNodePosition(workflowsStore.allNodes, [ - newNodeInsertPosition[0] + NodeViewUtils.GRID_SIZE, - newNodeInsertPosition[1] - NodeViewUtils.NODE_SIZE / 2, - ]); - } else { + // When pulling / cancelling a connection. + // The new node should be placed at the same position as the mouse up event, + // designated by the `newNodeInsertPosition` value. + + const xOffset = connectionType === NodeConnectionType.Main ? 0 : -nodeSize[0] / 2; + const yOffset = connectionType === NodeConnectionType.Main ? -nodeSize[1] / 2 : 0; + + position = [newNodeInsertPosition[0] + xOffset, newNodeInsertPosition[1] + yOffset]; + + uiStore.lastCancelledConnectionPosition = null; + } else if (lastInteractedWithNodeTypeDescription) { + // When + // - clicking the plus button of a node handle + // - clicking the plus button of a node edge / connection + // - selecting a node, adding a node via the node creator + let yOffset = 0; + if (lastInteractedWithNodeConnection) { + // When clicking the plus button of a node edge / connection + // Compute the y offset for the new node based on the number of main outputs of the source node + // and shift the downstream nodes accordingly - // Compute the y offset for the new node based on the number of main outputs of the source node - if (uiStore.lastInteractedWithNodeConnection) { - const sourceNodeType = nodeTypesStore.getNodeType( - lastInteractedWithNode.type, - lastInteractedWithNode.typeVersion, - ); + shiftDownstreamNodesPosition(lastInteractedWithNode.name, PUSH_NODES_OFFSET, { + trackHistory: true, + }); - if (sourceNodeType) { - const offsets = [ - [-100, 100], - [-140, 0, 140], - [-240, -100, 100, 240], - ]; - - const sourceNodeOutputs = NodeHelpers.getNodeOutputs( - editableWorkflowObject.value, - lastInteractedWithNode, - sourceNodeType, - ); - const sourceNodeOutputTypes = NodeHelpers.getConnectionTypes(sourceNodeOutputs); - const sourceNodeOutputMainOutputs = sourceNodeOutputTypes.filter( - (output) => output === NodeConnectionType.Main, - ); + const yOffsetValuesByOutputCount = [ + [-nodeSize[1], nodeSize[1]], + [-nodeSize[1] - 2 * GRID_SIZE, 0, nodeSize[1] - 2 * GRID_SIZE], + [ + -2 * nodeSize[1] - 2 * GRID_SIZE, + -nodeSize[1], + nodeSize[1], + 2 * nodeSize[1] - 2 * GRID_SIZE, + ], + ]; + + const lastInteractedWithNodeOutputs = NodeHelpers.getNodeOutputs( + editableWorkflowObject.value, + lastInteractedWithNode, + lastInteractedWithNodeTypeDescription, + ); + const lastInteractedWithNodeOutputTypes = NodeHelpers.getConnectionTypes( + lastInteractedWithNodeOutputs, + ); + const lastInteractedWithNodeMainOutputs = lastInteractedWithNodeOutputTypes.filter( + (output) => output === NodeConnectionType.Main, + ); - if (sourceNodeOutputMainOutputs.length > 1) { - const { index: sourceOutputIndex } = parseCanvasConnectionHandleString( - uiStore.lastInteractedWithNodeConnection.sourceHandle, - ); - const offset = offsets[sourceNodeOutputMainOutputs.length - 2]; - yOffset = offset[sourceOutputIndex]; - } + if (lastInteractedWithNodeMainOutputs.length > 1) { + const yOffsetValues = + yOffsetValuesByOutputCount[lastInteractedWithNodeMainOutputs.length - 2]; + yOffset = yOffsetValues[connectionIndex]; } } @@ -913,80 +940,96 @@ export function useCanvasOperations({ router }: { router: ReturnType 0 && - outputTypes.every((outputName) => outputName !== NodeConnectionType.Main) - ) { - const lastSelectedInputs = NodeHelpers.getNodeInputs( - editableWorkflowObject.value, - lastInteractedWithNodeObject, - lastSelectedNodeTypeDescription, - ); - const lastSelectedInputTypes = NodeHelpers.getConnectionTypes(lastSelectedInputs); - - const scopedConnectionIndex = (lastSelectedInputTypes || []) - .filter((input) => input !== NodeConnectionType.Main) - .findIndex((inputType) => outputs[0] === inputType); - - return NodeViewUtils.getNewNodePosition( - workflowsStore.allNodes, - [ - lastInteractedWithNode.position[0] + - (NodeViewUtils.NODE_SIZE / - (Math.max(lastSelectedNodeTypeDescription?.inputs?.length ?? 1), 1)) * - scopedConnectionIndex, - lastInteractedWithNode.position[1] + NodeViewUtils.PUSH_NODES_OFFSET, - ], - [100, 0], - ); - } else { - // Has only main outputs or no outputs at all - const inputs = NodeHelpers.getNodeInputs( - editableWorkflowObject.value, - lastInteractedWithNode, - lastSelectedNodeTypeDescription, - ); - const inputsTypes = NodeHelpers.getConnectionTypes(inputs); + pushOffsets = [100, 0]; - let pushOffset = NodeViewUtils.PUSH_NODES_OFFSET; - if (!!inputsTypes.find((input) => input !== NodeConnectionType.Main)) { - // If the node has scoped inputs, push it down a bit more - pushOffset += 150; - } + if ( + outputTypes.length > 0 && + outputTypes.every((outputName) => outputName !== NodeConnectionType.Main) && + lastInteractedWithNodeObject + ) { + // When the added node has only non-main outputs (configuration nodes) + // We want to place the new node directly below the last interacted with node. - // If a node is active then add the new node directly after the current one - return NodeViewUtils.getNewNodePosition( - workflowsStore.allNodes, - [ - lastInteractedWithNode.position[0] + pushOffset, - lastInteractedWithNode.position[1] + yOffset, - ], - [100, 0], - ); + const lastInteractedWithNodeInputs = NodeHelpers.getNodeInputs( + editableWorkflowObject.value, + lastInteractedWithNodeObject, + lastInteractedWithNodeTypeDescription, + ); + const lastInteractedWithNodeInputTypes = NodeHelpers.getConnectionTypes( + lastInteractedWithNodeInputs, + ); + const lastInteractedWithNodeScopedInputTypes = ( + lastInteractedWithNodeInputTypes || [] + ).filter((input) => input !== NodeConnectionType.Main); + const scopedConnectionIndex = lastInteractedWithNodeScopedInputTypes.findIndex( + (inputType) => outputs[0] === inputType, + ); + + const lastInteractedWithNodeWidthDivisions = Math.max( + lastInteractedWithNodeScopedInputTypes.length + 1, + 1, + ); + + position = [ + lastInteractedWithNode.position[0] + + (CONFIGURABLE_NODE_SIZE[0] / lastInteractedWithNodeWidthDivisions) * + (scopedConnectionIndex + 1) - + nodeSize[0] / 2, + lastInteractedWithNode.position[1] + PUSH_NODES_OFFSET, + ]; + } else { + // When the node has only main outputs, mixed outputs, or no outputs at all + // We want to place the new node directly to the right of the last interacted with node. + + const lastInteractedWithNodeInputs = NodeHelpers.getNodeInputs( + editableWorkflowObject.value, + lastInteractedWithNode, + lastInteractedWithNodeTypeDescription, + ); + const lastInteractedWithNodeInputTypes = NodeHelpers.getConnectionTypes( + lastInteractedWithNodeInputs, + ); + + let pushOffset = PUSH_NODES_OFFSET; + if ( + !!lastInteractedWithNodeInputTypes.find((input) => input !== NodeConnectionType.Main) + ) { + // If the node has scoped inputs, push it down a bit more + pushOffset += 140; } + + // If a node is active then add the new node directly after the current one + position = [ + lastInteractedWithNode.position[0] + pushOffset, + lastInteractedWithNode.position[1] + yOffset, + ]; } } } - // If added node is a trigger and it's the first one added to the canvas - // we place it at canvasAddButtonPosition to replace the canvas add button - const position = ( - nodeTypesStore.isTriggerNode(node.type) && triggerNodes.value.length === 0 - ? [0, 0] - : // If no node is active find a free spot - lastClickPosition.value - ) as XYPosition; + if (!position) { + if (nodeTypesStore.isTriggerNode(node.type) && triggerNodes.value.length === 0) { + // When added node is a trigger, and it's the first one added to the canvas + // we place it at root to replace the canvas add button + + position = [0, 0]; + } else { + // When no position is set, we place the node at the last clicked position + + position = lastClickPosition.value; + } + } - return NodeViewUtils.getNewNodePosition(workflowsStore.allNodes, position); + return NodeViewUtils.getNewNodePosition(workflowsStore.allNodes, position, pushOffsets); } function resolveNodeName(node: INodeUi) { @@ -1219,7 +1262,7 @@ export function useCanvasOperations({ router }: { router: ReturnType { // #endregion return { + state, getCredentialOwnerName, getCredentialsByType, getCredentialById, diff --git a/packages/editor-ui/src/stores/nodeCreator.store.ts b/packages/editor-ui/src/stores/nodeCreator.store.ts index 6668b78237e61..db6ce26eaab81 100644 --- a/packages/editor-ui/src/stores/nodeCreator.store.ts +++ b/packages/editor-ui/src/stores/nodeCreator.store.ts @@ -90,7 +90,7 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => { setTimeout(() => { if (creatorView) { - openNodeCreator({ + setNodeCreatorState({ createNodeActive: true, nodeCreatorView: creatorView, }); @@ -110,7 +110,7 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => { }); } - function openNodeCreator({ + function setNodeCreatorState({ source, createNodeActive, nodeCreatorView, @@ -200,7 +200,6 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => { uiStore.lastSelectedNode = sourceNode.name; uiStore.lastSelectedNodeEndpointUuid = connection.sourceHandle ?? null; uiStore.lastSelectedNodeOutputIndex = index; - // canvasStore.newNodeInsertPosition = null; if (isVueFlowConnection(connection)) { uiStore.lastInteractedWithNodeConnection = connection; @@ -208,7 +207,7 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => { uiStore.lastInteractedWithNodeHandle = connection.sourceHandle ?? null; uiStore.lastInteractedWithNodeId = sourceNode.id; - openNodeCreator({ + setNodeCreatorState({ source: eventSource, createNodeActive: true, nodeCreatorView, @@ -231,7 +230,7 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => { ndvStore.activeNodeName = null; setSelectedView(TRIGGER_NODE_CREATOR_VIEW); setShowScrim(true); - openNodeCreator({ + setNodeCreatorState({ source, createNodeActive: true, nodeCreatorView: TRIGGER_NODE_CREATOR_VIEW, @@ -276,7 +275,7 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => { setOpenSource, setActions, setMergeNodes, - openNodeCreator, + setNodeCreatorState, openSelectiveNodeCreator, openNodeCreatorForConnectingNode, openNodeCreatorForTriggerNodes, diff --git a/packages/editor-ui/src/stores/ui.store.ts b/packages/editor-ui/src/stores/ui.store.ts index 18cb3fbe37fce..e854ac656c63d 100644 --- a/packages/editor-ui/src/stores/ui.store.ts +++ b/packages/editor-ui/src/stores/ui.store.ts @@ -200,6 +200,7 @@ export const useUIStore = defineStore(STORES.UI, () => { const lastInteractedWithNodeConnection = ref(null); const lastInteractedWithNodeHandle = ref(null); const lastInteractedWithNodeId = ref(null); + const lastCancelledConnectionPosition = ref(null); const settingsStore = useSettingsStore(); const workflowsStore = useWorkflowsStore(); @@ -624,6 +625,7 @@ export const useUIStore = defineStore(STORES.UI, () => { lastInteractedWithNodeConnection.value = null; lastInteractedWithNodeHandle.value = null; lastInteractedWithNodeId.value = null; + lastCancelledConnectionPosition.value = null; } return { @@ -652,6 +654,7 @@ export const useUIStore = defineStore(STORES.UI, () => { lastInteractedWithNodeHandle, lastInteractedWithNodeId, lastInteractedWithNode, + lastCancelledConnectionPosition, nodeViewOffsetPosition, nodeViewMoveInProgress, nodeViewInitialized, diff --git a/packages/editor-ui/src/utils/canvasUtilsV2.ts b/packages/editor-ui/src/utils/canvasUtilsV2.ts index 948c24f1e0b26..e73890fe9bfc9 100644 --- a/packages/editor-ui/src/utils/canvasUtilsV2.ts +++ b/packages/editor-ui/src/utils/canvasUtilsV2.ts @@ -103,9 +103,8 @@ export function mapLegacyConnectionToCanvasConnection( export function parseCanvasConnectionHandleString(handle: string | null | undefined) { const [mode, type, index] = (handle ?? '').split('/'); - const resolvedType = isValidNodeConnectionType(type) ? type : NodeConnectionType.Main; const resolvedMode = isValidCanvasConnectionMode(mode) ? mode : CanvasConnectionMode.Output; - + const resolvedType = isValidNodeConnectionType(type) ? type : NodeConnectionType.Main; let resolvedIndex = parseInt(index, 10); if (isNaN(resolvedIndex)) { resolvedIndex = 0; diff --git a/packages/editor-ui/src/utils/nodeViewUtils.ts b/packages/editor-ui/src/utils/nodeViewUtils.ts index a0e87c96def80..d28d8f1c972d0 100644 --- a/packages/editor-ui/src/utils/nodeViewUtils.ts +++ b/packages/editor-ui/src/utils/nodeViewUtils.ts @@ -38,6 +38,9 @@ const MIN_X_TO_SHOW_OUTPUT_LABEL = 90; const MIN_Y_TO_SHOW_OUTPUT_LABEL = 100; export const NODE_SIZE = 100; +export const DEFAULT_NODE_SIZE = [100, 100]; +export const CONFIGURATION_NODE_SIZE = [80, 80]; +export const CONFIGURABLE_NODE_SIZE = [256, 100]; export const PLACEHOLDER_TRIGGER_NODE_SIZE = 100; export const DEFAULT_START_POSITION_X = 180; export const DEFAULT_START_POSITION_Y = 240; diff --git a/packages/editor-ui/src/views/NodeView.v2.vue b/packages/editor-ui/src/views/NodeView.v2.vue index 04cd656e8d298..5ce133bca92cd 100644 --- a/packages/editor-ui/src/views/NodeView.v2.vue +++ b/packages/editor-ui/src/views/NodeView.v2.vue @@ -34,7 +34,11 @@ import type { ToggleNodeCreatorOptions, XYPosition, } from '@/Interface'; -import type { Connection, ViewportTransform } from '@vue-flow/core'; +import type { + Connection, + ViewportTransform, + XYPosition as VueFlowXYPosition, +} from '@vue-flow/core'; import type { CanvasConnectionCreateData, CanvasEventBusEvents, @@ -738,12 +742,20 @@ function onRevertCreateConnection({ connection }: { connection: [IConnection, IC revertCreateConnection(connection); } -function onCreateConnectionCancelled(event: ConnectStartEvent, mouseEvent?: MouseEvent) { +function onCreateConnectionCancelled( + event: ConnectStartEvent, + position: VueFlowXYPosition, + mouseEvent?: MouseEvent, +) { const preventDefault = (mouseEvent?.target as HTMLElement).classList?.contains('clickable'); if (preventDefault) { return; } + uiStore.lastInteractedWithNodeId = event.nodeId; + uiStore.lastInteractedWithNodeHandle = event.handleId; + uiStore.lastCancelledConnectionPosition = [position.x, position.y]; + setTimeout(() => { nodeCreatorStore.openNodeCreatorForConnectingNode({ connection: { @@ -874,11 +886,15 @@ async function onOpenNodeCreatorForTriggerNodes(source: NodeCreatorOpenSource) { } function onOpenNodeCreatorFromCanvas(source: NodeCreatorOpenSource) { - onOpenNodeCreator({ createNodeActive: true, source }); + onToggleNodeCreator({ createNodeActive: true, source }); } -function onOpenNodeCreator(options: ToggleNodeCreatorOptions) { - nodeCreatorStore.openNodeCreator(options); +function onToggleNodeCreator(options: ToggleNodeCreatorOptions) { + nodeCreatorStore.setNodeCreatorState(options); + + if (!options.createNodeActive && !options.hasAddedNodes) { + uiStore.resetLastInteractedWith(); + } } function onCreateSticky() { @@ -1378,7 +1394,6 @@ function selectNodes(ids: string[]) { function onClickPane(position: CanvasNode['position']) { lastClickPosition.value = [position.x, position.y]; - canvasStore.newNodeInsertPosition = [position.x, position.y]; uiStore.isCreateNodeActive = false; } @@ -1563,7 +1578,7 @@ onDeactivated(() => { v-if="!isCanvasReadOnly" :create-node-active="uiStore.isCreateNodeActive" :node-view-scale="viewportTransform.zoom" - @toggle-node-creator="onOpenNodeCreator" + @toggle-node-creator="onToggleNodeCreator" @add-nodes="onAddNodesAndConnections" /> From c97a96d314d4cd72b9a971993af5d5f1e32ccf48 Mon Sep 17 00:00:00 2001 From: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Date: Tue, 3 Sep 2024 15:35:13 +0300 Subject: [PATCH 02/18] build: Add `reset` script (#10627) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ --- package.json | 4 +- pnpm-lock.yaml | 217 ++++++++++++------------------------------ scripts/ensure-zx.mjs | 17 ++++ scripts/reset.mjs | 30 ++++++ 4 files changed, 110 insertions(+), 158 deletions(-) create mode 100644 scripts/ensure-zx.mjs create mode 100644 scripts/reset.mjs diff --git a/package.json b/package.json index b4c470cbea65c..415697e250c58 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "dev": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat", "dev:ai": "turbo run dev --parallel --env-mode=loose --filter=@n8n/nodes-langchain --filter=n8n --filter=n8n-core", "clean": "turbo run clean --parallel", + "reset": "node scripts/ensure-zx.mjs && zx scripts/reset.mjs", "format": "turbo run format && node scripts/format.mjs", "lint": "turbo run lint", "lintfix": "turbo run lintfix", @@ -55,7 +56,8 @@ "tsc-alias": "^1.8.7", "tsc-watch": "^6.0.4", "turbo": "2.0.6", - "typescript": "*" + "typescript": "*", + "zx": "^8.1.4" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b769d8a6a29df..7e993b188c05a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -174,6 +174,9 @@ importers: typescript: specifier: ^5.5.2 version: 5.5.2 + zx: + specifier: ^8.1.4 + version: 8.1.4 cypress: dependencies: @@ -559,7 +562,7 @@ importers: version: 8.1.4(@types/react@18.0.27)(encoding@0.1.13)(prettier@3.2.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@storybook/addon-interactions': specifier: ^8.1.4 - version: 8.1.4(@jest/globals@29.6.2)(@types/jest@29.5.3)(jest@29.6.2(@types/node@18.16.16)(ts-node@10.9.2(@types/node@18.16.16)(typescript@5.5.2)))(vitest@1.6.0(@types/node@18.16.16)(jsdom@23.0.1)(sass@1.64.1)(terser@5.16.1)) + version: 8.1.4(@jest/globals@29.6.2)(@types/jest@29.5.3)(jest@29.6.2(@types/node@18.16.16)(ts-node@10.9.2(@types/node@18.16.16)(typescript@5.5.2)))(vitest@1.6.0(@types/node@18.16.16)(sass@1.64.1)(terser@5.16.1)) '@storybook/addon-links': specifier: ^8.1.4 version: 8.1.4(react@18.2.0) @@ -571,7 +574,7 @@ importers: version: 8.1.4(@types/react@18.0.27)(encoding@0.1.13)(prettier@3.2.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@storybook/test': specifier: ^8.1.4 - version: 8.1.4(@jest/globals@29.6.2)(@types/jest@29.5.3)(jest@29.6.2(@types/node@18.16.16)(ts-node@10.9.2(@types/node@18.16.16)(typescript@5.5.2)))(vitest@1.6.0(@types/node@18.16.16)(jsdom@23.0.1)(sass@1.64.1)(terser@5.16.1)) + version: 8.1.4(@jest/globals@29.6.2)(@types/jest@29.5.3)(jest@29.6.2(@types/node@18.16.16)(ts-node@10.9.2(@types/node@18.16.16)(typescript@5.5.2)))(vitest@1.6.0(@types/node@18.16.16)(sass@1.64.1)(terser@5.16.1)) '@storybook/vue3': specifier: ^8.1.4 version: 8.1.4(encoding@0.1.13)(prettier@3.2.5)(vue@3.4.21(typescript@5.5.2)) @@ -1820,7 +1823,7 @@ importers: devDependencies: '@langchain/core': specifier: ^0.2.18 - version: 0.2.18(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13)) + version: 0.2.18(langchain@0.2.11(axios@1.7.4)(openai@4.53.0))(openai@4.53.0) '@types/deep-equal': specifier: ^1.0.1 version: 1.0.1 @@ -13130,8 +13133,8 @@ packages: vue-component-type-helpers@2.0.19: resolution: {integrity: sha512-cN3f1aTxxKo4lzNeQAkVopswuImUrb5Iurll9Gaw5cqpnbTAxtEMM1mgi6ou4X79OCyqYv1U1mzBHJkzmiK82w==} - vue-component-type-helpers@2.1.2: - resolution: {integrity: sha512-URuxnrOhO9lUG4LOAapGWBaa/WOLDzzyAbL+uKZqT7RS+PFy0cdXI2mUSh7GaMts6vtHaeVbGk7trd0FPJi65Q==} + vue-component-type-helpers@2.1.4: + resolution: {integrity: sha512-aVqB3KxwpM76cYRkpnezl1J62E/1omzHQfx1yuz7zcbxmzmP/PeSgI20NEmkdeGnjZPVzm0V9fB4ZyRu5BBj4A==} vue-demi@0.14.5: resolution: {integrity: sha512-o9NUVpl/YlsGJ7t+xuqJKx8EBGf1quRhCiT6D/J0pfwmk9zUwYkC7yrF4SZCe6fETvSM3UNL2edcbYrSyc4QHA==} @@ -16045,8 +16048,8 @@ snapshots: url-join: 4.0.1 zod: 3.23.8 optionalDependencies: - '@langchain/core': 0.2.18(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13)) - langchain: 0.2.11(@aws-sdk/client-s3@3.478.0)(@aws-sdk/credential-provider-node@3.535.0)(@azure/storage-blob@12.18.0(encoding@0.1.13))(@langchain/anthropic@0.2.9)(@langchain/cohere@0.0.10)(@langchain/community@0.2.20)(@langchain/google-genai@0.0.23)(@langchain/google-vertexai@0.0.21)(@langchain/groq@0.0.15)(@langchain/mistralai@0.0.27)(@langchain/ollama@0.0.2)(@pinecone-database/pinecone@3.0.0)(@supabase/supabase-js@2.43.4)(@xata.io/client@0.28.4(typescript@5.5.2))(axios@1.7.4)(cheerio@1.0.0-rc.12)(d3-dsv@2.0.0)(encoding@0.1.13)(epub2@3.0.2(ts-toolbelt@9.6.0))(fast-xml-parser@4.4.1)(handlebars@4.7.8)(html-to-text@9.0.5)(ignore@5.2.4)(ioredis@5.3.2)(jsdom@23.0.1)(mammoth@1.7.2)(mongodb@6.3.0(gcp-metadata@5.3.0(encoding@0.1.13))(socks@2.7.1))(openai@4.53.0(encoding@0.1.13))(pdf-parse@1.1.1)(redis@4.6.14)(ws@8.17.1) + '@langchain/core': 0.2.18(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13)) + langchain: 0.2.11(m7otbkpxyspoz5trt2pa3dcs6u) transitivePeerDependencies: - encoding @@ -16469,7 +16472,7 @@ snapshots: '@langchain/anthropic@0.2.9(encoding@0.1.13)(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13))': dependencies: '@anthropic-ai/sdk': 0.22.0(encoding@0.1.13) - '@langchain/core': 0.2.18(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13)) + '@langchain/core': 0.2.18(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13)) fast-xml-parser: 4.3.5 zod: 3.23.8 zod-to-json-schema: 3.23.0(zod@3.23.8) @@ -16481,7 +16484,7 @@ snapshots: '@langchain/cohere@0.0.10(encoding@0.1.13)(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13))': dependencies: - '@langchain/core': 0.2.18(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13)) + '@langchain/core': 0.2.18(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13)) cohere-ai: 7.10.1(encoding@0.1.13) transitivePeerDependencies: - encoding @@ -16559,86 +16562,13 @@ snapshots: - pyodide - supports-color - '@langchain/community@0.2.20(yatsnfdsa55wls5pvl4axjr4ti)': - dependencies: - '@langchain/core': 0.2.18(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13)) - '@langchain/openai': 0.2.5(encoding@0.1.13)(langchain@0.2.11(@aws-sdk/client-s3@3.478.0)(@aws-sdk/credential-provider-node@3.535.0)(@azure/storage-blob@12.18.0(encoding@0.1.13))(@langchain/anthropic@0.2.9)(@langchain/cohere@0.0.10)(@langchain/community@0.2.20)(@langchain/google-genai@0.0.23)(@langchain/google-vertexai@0.0.21)(@langchain/groq@0.0.15)(@langchain/mistralai@0.0.27)(@langchain/ollama@0.0.2)(@pinecone-database/pinecone@3.0.0)(@supabase/supabase-js@2.43.4)(@xata.io/client@0.28.4(typescript@5.5.2))(axios@1.7.4)(cheerio@1.0.0-rc.12)(d3-dsv@2.0.0)(encoding@0.1.13)(epub2@3.0.2(ts-toolbelt@9.6.0))(fast-xml-parser@4.4.1)(handlebars@4.7.8)(html-to-text@9.0.5)(ignore@5.2.4)(ioredis@5.3.2)(jsdom@23.0.1)(mammoth@1.7.2)(mongodb@6.3.0(gcp-metadata@5.3.0(encoding@0.1.13))(socks@2.7.1))(openai@4.53.0(encoding@0.1.13))(pdf-parse@1.1.1)(redis@4.6.14)(ws@8.17.1)) - binary-extensions: 2.2.0 - expr-eval: 2.0.2 - flat: 5.0.2 - js-yaml: 4.1.0 - langchain: 0.2.11(@aws-sdk/client-s3@3.478.0)(@aws-sdk/credential-provider-node@3.535.0)(@azure/storage-blob@12.18.0(encoding@0.1.13))(@langchain/anthropic@0.2.9)(@langchain/cohere@0.0.10)(@langchain/community@0.2.20)(@langchain/google-genai@0.0.23)(@langchain/google-vertexai@0.0.21)(@langchain/groq@0.0.15)(@langchain/mistralai@0.0.27)(@langchain/ollama@0.0.2)(@pinecone-database/pinecone@3.0.0)(@supabase/supabase-js@2.43.4)(@xata.io/client@0.28.4(typescript@5.5.2))(axios@1.7.4)(cheerio@1.0.0-rc.12)(d3-dsv@2.0.0)(encoding@0.1.13)(epub2@3.0.2(ts-toolbelt@9.6.0))(fast-xml-parser@4.4.1)(handlebars@4.7.8)(html-to-text@9.0.5)(ignore@5.2.4)(ioredis@5.3.2)(jsdom@23.0.1)(mammoth@1.7.2)(mongodb@6.3.0(gcp-metadata@5.3.0(encoding@0.1.13))(socks@2.7.1))(openai@4.53.0(encoding@0.1.13))(pdf-parse@1.1.1)(redis@4.6.14)(ws@8.17.1) - langsmith: 0.1.34(@langchain/core@0.2.18(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13)))(langchain@0.2.11(@aws-sdk/client-s3@3.478.0)(@aws-sdk/credential-provider-node@3.535.0)(@azure/storage-blob@12.18.0(encoding@0.1.13))(@langchain/anthropic@0.2.9)(@langchain/cohere@0.0.10)(@langchain/community@0.2.20)(@langchain/google-genai@0.0.23)(@langchain/google-vertexai@0.0.21)(@langchain/groq@0.0.15)(@langchain/mistralai@0.0.27)(@langchain/ollama@0.0.2)(@pinecone-database/pinecone@3.0.0)(@supabase/supabase-js@2.43.4)(@xata.io/client@0.28.4(typescript@5.5.2))(axios@1.7.4)(cheerio@1.0.0-rc.12)(d3-dsv@2.0.0)(encoding@0.1.13)(epub2@3.0.2(ts-toolbelt@9.6.0))(fast-xml-parser@4.4.1)(handlebars@4.7.8)(html-to-text@9.0.5)(ignore@5.2.4)(ioredis@5.3.2)(jsdom@23.0.1)(mammoth@1.7.2)(mongodb@6.3.0(gcp-metadata@5.3.0(encoding@0.1.13))(socks@2.7.1))(openai@4.53.0(encoding@0.1.13))(pdf-parse@1.1.1)(redis@4.6.14)(ws@8.17.1))(openai@4.53.0(encoding@0.1.13)) - uuid: 10.0.0 - zod: 3.23.8 - zod-to-json-schema: 3.23.0(zod@3.23.8) - optionalDependencies: - '@aws-sdk/client-bedrock-runtime': 3.535.0 - '@aws-sdk/client-s3': 3.478.0 - '@aws-sdk/credential-provider-node': 3.535.0 - '@azure/storage-blob': 12.18.0(encoding@0.1.13) - '@getzep/zep-cloud': 1.0.11(@langchain/core@0.2.18)(encoding@0.1.13)(langchain@0.2.11) - '@getzep/zep-js': 0.9.0 - '@google-ai/generativelanguage': 2.5.0(encoding@0.1.13) - '@google-cloud/storage': 7.12.1(encoding@0.1.13) - '@huggingface/inference': 2.7.0 - '@mozilla/readability': 0.5.0 - '@pinecone-database/pinecone': 3.0.0 - '@qdrant/js-client-rest': 1.9.0(typescript@5.5.2) - '@smithy/eventstream-codec': 2.2.0 - '@smithy/protocol-http': 3.3.0 - '@smithy/signature-v4': 2.2.1 - '@smithy/util-utf8': 2.3.0 - '@supabase/postgrest-js': 1.15.2 - '@supabase/supabase-js': 2.43.4 - '@xata.io/client': 0.28.4(typescript@5.5.2) - cheerio: 1.0.0-rc.12 - cohere-ai: 7.10.1(encoding@0.1.13) - crypto-js: 4.2.0 - d3-dsv: 2.0.0 - epub2: 3.0.2(ts-toolbelt@9.6.0) - google-auth-library: 9.10.0(encoding@0.1.13) - html-to-text: 9.0.5 - ignore: 5.2.4 - ioredis: 5.3.2 - jsdom: 23.0.1 - jsonwebtoken: 9.0.2 - lodash: 4.17.21 - mammoth: 1.7.2 - mongodb: 6.3.0(gcp-metadata@5.3.0(encoding@0.1.13))(socks@2.7.1) - mysql2: 3.11.0 - pdf-parse: 1.1.1 - pg: 8.12.0 - redis: 4.6.14 - ws: 8.17.1 - transitivePeerDependencies: - - '@gomomento/sdk-web' - - '@langchain/anthropic' - - '@langchain/aws' - - '@langchain/cohere' - - '@langchain/google-genai' - - '@langchain/google-vertexai' - - '@langchain/google-vertexai-web' - - '@langchain/groq' - - '@langchain/mistralai' - - '@langchain/ollama' - - axios - - encoding - - fast-xml-parser - - handlebars - - openai - - peggy - - pyodide - - supports-color - optional: true - - '@langchain/core@0.2.18(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13))': + '@langchain/core@0.2.18(langchain@0.2.11(axios@1.7.4)(openai@4.53.0))(openai@4.53.0)': dependencies: ansi-styles: 5.2.0 camelcase: 6.3.0 decamelize: 1.2.0 js-tiktoken: 1.0.12 - langsmith: 0.1.39(@langchain/core@0.2.18(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13)))(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13)) + langsmith: 0.1.39(@langchain/core@0.2.18(langchain@0.2.11(axios@1.7.4)(openai@4.53.0))(openai@4.53.0))(langchain@0.2.11(axios@1.7.4)(openai@4.53.0))(openai@4.53.0) ml-distance: 4.0.1 mustache: 4.2.0 p-queue: 6.6.2 @@ -16650,13 +16580,13 @@ snapshots: - langchain - openai - '@langchain/core@0.2.18(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13))': + '@langchain/core@0.2.18(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13))': dependencies: ansi-styles: 5.2.0 camelcase: 6.3.0 decamelize: 1.2.0 js-tiktoken: 1.0.12 - langsmith: 0.1.39(@langchain/core@0.2.18(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13)))(langchain@0.2.11(@aws-sdk/client-s3@3.478.0)(@aws-sdk/credential-provider-node@3.535.0)(@azure/storage-blob@12.18.0(encoding@0.1.13))(@langchain/anthropic@0.2.9)(@langchain/cohere@0.0.10)(@langchain/community@0.2.20)(@langchain/google-genai@0.0.23)(@langchain/google-vertexai@0.0.21)(@langchain/groq@0.0.15)(@langchain/mistralai@0.0.27)(@langchain/ollama@0.0.2)(@pinecone-database/pinecone@3.0.0)(@supabase/supabase-js@2.43.4)(@xata.io/client@0.28.4(typescript@5.5.2))(axios@1.7.4)(cheerio@1.0.0-rc.12)(d3-dsv@2.0.0)(encoding@0.1.13)(epub2@3.0.2(ts-toolbelt@9.6.0))(fast-xml-parser@4.4.1)(handlebars@4.7.8)(html-to-text@9.0.5)(ignore@5.2.4)(ioredis@5.3.2)(jsdom@23.0.1)(mammoth@1.7.2)(mongodb@6.3.0(gcp-metadata@5.3.0(encoding@0.1.13))(socks@2.7.1))(openai@4.53.0(encoding@0.1.13))(pdf-parse@1.1.1)(redis@4.6.14)(ws@8.17.1))(openai@4.53.0(encoding@0.1.13)) + langsmith: 0.1.39(@langchain/core@0.2.18(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13)))(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13)) ml-distance: 4.0.1 mustache: 4.2.0 p-queue: 6.6.2 @@ -16668,9 +16598,9 @@ snapshots: - langchain - openai - '@langchain/google-common@0.0.22(langchain@0.2.11(@aws-sdk/client-s3@3.478.0)(@aws-sdk/credential-provider-node@3.535.0)(@azure/storage-blob@12.18.0(encoding@0.1.13))(@langchain/anthropic@0.2.9)(@langchain/cohere@0.0.10)(@langchain/community@0.2.20)(@langchain/google-genai@0.0.23)(@langchain/google-vertexai@0.0.21)(@langchain/groq@0.0.15)(@langchain/mistralai@0.0.27)(@langchain/ollama@0.0.2)(@pinecone-database/pinecone@3.0.0)(@supabase/supabase-js@2.43.4)(@xata.io/client@0.28.4(typescript@5.5.2))(axios@1.7.4)(cheerio@1.0.0-rc.12)(d3-dsv@2.0.0)(encoding@0.1.13)(epub2@3.0.2(ts-toolbelt@9.6.0))(fast-xml-parser@4.4.1)(handlebars@4.7.8)(html-to-text@9.0.5)(ignore@5.2.4)(ioredis@5.3.2)(jsdom@23.0.1)(mammoth@1.7.2)(mongodb@6.3.0(gcp-metadata@5.3.0(encoding@0.1.13))(socks@2.7.1))(openai@4.53.0(encoding@0.1.13))(pdf-parse@1.1.1)(redis@4.6.14)(ws@8.17.1))(openai@4.53.0(encoding@0.1.13))(zod@3.23.8)': + '@langchain/google-common@0.0.22(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13))(zod@3.23.8)': dependencies: - '@langchain/core': 0.2.18(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13)) + '@langchain/core': 0.2.18(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13)) uuid: 10.0.0 zod-to-json-schema: 3.23.0(zod@3.23.8) transitivePeerDependencies: @@ -16678,10 +16608,10 @@ snapshots: - openai - zod - '@langchain/google-gauth@0.0.21(encoding@0.1.13)(langchain@0.2.11(@aws-sdk/client-s3@3.478.0)(@aws-sdk/credential-provider-node@3.535.0)(@azure/storage-blob@12.18.0(encoding@0.1.13))(@langchain/anthropic@0.2.9)(@langchain/cohere@0.0.10)(@langchain/community@0.2.20)(@langchain/google-genai@0.0.23)(@langchain/google-vertexai@0.0.21)(@langchain/groq@0.0.15)(@langchain/mistralai@0.0.27)(@langchain/ollama@0.0.2)(@pinecone-database/pinecone@3.0.0)(@supabase/supabase-js@2.43.4)(@xata.io/client@0.28.4(typescript@5.5.2))(axios@1.7.4)(cheerio@1.0.0-rc.12)(d3-dsv@2.0.0)(encoding@0.1.13)(epub2@3.0.2(ts-toolbelt@9.6.0))(fast-xml-parser@4.4.1)(handlebars@4.7.8)(html-to-text@9.0.5)(ignore@5.2.4)(ioredis@5.3.2)(jsdom@23.0.1)(mammoth@1.7.2)(mongodb@6.3.0(gcp-metadata@5.3.0(encoding@0.1.13))(socks@2.7.1))(openai@4.53.0(encoding@0.1.13))(pdf-parse@1.1.1)(redis@4.6.14)(ws@8.17.1))(openai@4.53.0(encoding@0.1.13))(zod@3.23.8)': + '@langchain/google-gauth@0.0.21(encoding@0.1.13)(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13))(zod@3.23.8)': dependencies: - '@langchain/core': 0.2.18(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13)) - '@langchain/google-common': 0.0.22(langchain@0.2.11(@aws-sdk/client-s3@3.478.0)(@aws-sdk/credential-provider-node@3.535.0)(@azure/storage-blob@12.18.0(encoding@0.1.13))(@langchain/anthropic@0.2.9)(@langchain/cohere@0.0.10)(@langchain/community@0.2.20)(@langchain/google-genai@0.0.23)(@langchain/google-vertexai@0.0.21)(@langchain/groq@0.0.15)(@langchain/mistralai@0.0.27)(@langchain/ollama@0.0.2)(@pinecone-database/pinecone@3.0.0)(@supabase/supabase-js@2.43.4)(@xata.io/client@0.28.4(typescript@5.5.2))(axios@1.7.4)(cheerio@1.0.0-rc.12)(d3-dsv@2.0.0)(encoding@0.1.13)(epub2@3.0.2(ts-toolbelt@9.6.0))(fast-xml-parser@4.4.1)(handlebars@4.7.8)(html-to-text@9.0.5)(ignore@5.2.4)(ioredis@5.3.2)(jsdom@23.0.1)(mammoth@1.7.2)(mongodb@6.3.0(gcp-metadata@5.3.0(encoding@0.1.13))(socks@2.7.1))(openai@4.53.0(encoding@0.1.13))(pdf-parse@1.1.1)(redis@4.6.14)(ws@8.17.1))(openai@4.53.0(encoding@0.1.13))(zod@3.23.8) + '@langchain/core': 0.2.18(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13)) + '@langchain/google-common': 0.0.22(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13))(zod@3.23.8) google-auth-library: 8.9.0(encoding@0.1.13) transitivePeerDependencies: - encoding @@ -16693,7 +16623,7 @@ snapshots: '@langchain/google-genai@0.0.23(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13))(zod@3.23.8)': dependencies: '@google/generative-ai': 0.7.1 - '@langchain/core': 0.2.18(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13)) + '@langchain/core': 0.2.18(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13)) zod-to-json-schema: 3.23.0(zod@3.23.8) transitivePeerDependencies: - langchain @@ -16702,8 +16632,8 @@ snapshots: '@langchain/google-vertexai@0.0.21(encoding@0.1.13)(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13))(zod@3.23.8)': dependencies: - '@langchain/core': 0.2.18(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13)) - '@langchain/google-gauth': 0.0.21(encoding@0.1.13)(langchain@0.2.11(@aws-sdk/client-s3@3.478.0)(@aws-sdk/credential-provider-node@3.535.0)(@azure/storage-blob@12.18.0(encoding@0.1.13))(@langchain/anthropic@0.2.9)(@langchain/cohere@0.0.10)(@langchain/community@0.2.20)(@langchain/google-genai@0.0.23)(@langchain/google-vertexai@0.0.21)(@langchain/groq@0.0.15)(@langchain/mistralai@0.0.27)(@langchain/ollama@0.0.2)(@pinecone-database/pinecone@3.0.0)(@supabase/supabase-js@2.43.4)(@xata.io/client@0.28.4(typescript@5.5.2))(axios@1.7.4)(cheerio@1.0.0-rc.12)(d3-dsv@2.0.0)(encoding@0.1.13)(epub2@3.0.2(ts-toolbelt@9.6.0))(fast-xml-parser@4.4.1)(handlebars@4.7.8)(html-to-text@9.0.5)(ignore@5.2.4)(ioredis@5.3.2)(jsdom@23.0.1)(mammoth@1.7.2)(mongodb@6.3.0(gcp-metadata@5.3.0(encoding@0.1.13))(socks@2.7.1))(openai@4.53.0(encoding@0.1.13))(pdf-parse@1.1.1)(redis@4.6.14)(ws@8.17.1))(openai@4.53.0(encoding@0.1.13))(zod@3.23.8) + '@langchain/core': 0.2.18(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13)) + '@langchain/google-gauth': 0.0.21(encoding@0.1.13)(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13))(zod@3.23.8) transitivePeerDependencies: - encoding - langchain @@ -16713,8 +16643,8 @@ snapshots: '@langchain/groq@0.0.15(encoding@0.1.13)(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13))': dependencies: - '@langchain/core': 0.2.18(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13)) - '@langchain/openai': 0.2.5(encoding@0.1.13)(langchain@0.2.11(@aws-sdk/client-s3@3.478.0)(@aws-sdk/credential-provider-node@3.535.0)(@azure/storage-blob@12.18.0(encoding@0.1.13))(@langchain/anthropic@0.2.9)(@langchain/cohere@0.0.10)(@langchain/community@0.2.20)(@langchain/google-genai@0.0.23)(@langchain/google-vertexai@0.0.21)(@langchain/groq@0.0.15)(@langchain/mistralai@0.0.27)(@langchain/ollama@0.0.2)(@pinecone-database/pinecone@3.0.0)(@supabase/supabase-js@2.43.4)(@xata.io/client@0.28.4(typescript@5.5.2))(axios@1.7.4)(cheerio@1.0.0-rc.12)(d3-dsv@2.0.0)(encoding@0.1.13)(epub2@3.0.2(ts-toolbelt@9.6.0))(fast-xml-parser@4.4.1)(handlebars@4.7.8)(html-to-text@9.0.5)(ignore@5.2.4)(ioredis@5.3.2)(jsdom@23.0.1)(mammoth@1.7.2)(mongodb@6.3.0(gcp-metadata@5.3.0(encoding@0.1.13))(socks@2.7.1))(openai@4.53.0(encoding@0.1.13))(pdf-parse@1.1.1)(redis@4.6.14)(ws@8.17.1)) + '@langchain/core': 0.2.18(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13)) + '@langchain/openai': 0.2.5(encoding@0.1.13)(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u)) groq-sdk: 0.3.2(encoding@0.1.13) zod: 3.23.8 zod-to-json-schema: 3.23.0(zod@3.23.8) @@ -16726,7 +16656,7 @@ snapshots: '@langchain/mistralai@0.0.27(encoding@0.1.13)(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13))': dependencies: - '@langchain/core': 0.2.18(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13)) + '@langchain/core': 0.2.18(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13)) '@mistralai/mistralai': 0.4.0(encoding@0.1.13) uuid: 10.0.0 zod: 3.23.8 @@ -16738,16 +16668,16 @@ snapshots: '@langchain/ollama@0.0.2(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13))': dependencies: - '@langchain/core': 0.2.18(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13)) + '@langchain/core': 0.2.18(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13)) ollama: 0.5.6 uuid: 10.0.0 transitivePeerDependencies: - langchain - openai - '@langchain/openai@0.2.5(encoding@0.1.13)(langchain@0.2.11(@aws-sdk/client-s3@3.478.0)(@aws-sdk/credential-provider-node@3.535.0)(@azure/storage-blob@12.18.0(encoding@0.1.13))(@langchain/anthropic@0.2.9)(@langchain/cohere@0.0.10)(@langchain/community@0.2.20)(@langchain/google-genai@0.0.23)(@langchain/google-vertexai@0.0.21)(@langchain/groq@0.0.15)(@langchain/mistralai@0.0.27)(@langchain/ollama@0.0.2)(@pinecone-database/pinecone@3.0.0)(@supabase/supabase-js@2.43.4)(@xata.io/client@0.28.4(typescript@5.5.2))(axios@1.7.4)(cheerio@1.0.0-rc.12)(d3-dsv@2.0.0)(encoding@0.1.13)(epub2@3.0.2(ts-toolbelt@9.6.0))(fast-xml-parser@4.4.1)(handlebars@4.7.8)(html-to-text@9.0.5)(ignore@5.2.4)(ioredis@5.3.2)(jsdom@23.0.1)(mammoth@1.7.2)(mongodb@6.3.0(gcp-metadata@5.3.0(encoding@0.1.13))(socks@2.7.1))(openai@4.53.0(encoding@0.1.13))(pdf-parse@1.1.1)(redis@4.6.14)(ws@8.17.1))': + '@langchain/openai@0.2.5(encoding@0.1.13)(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))': dependencies: - '@langchain/core': 0.2.18(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13)) + '@langchain/core': 0.2.18(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13)) js-tiktoken: 1.0.12 openai: 4.53.0(encoding@0.1.13) zod: 3.23.8 @@ -16757,9 +16687,9 @@ snapshots: - langchain - supports-color - '@langchain/openai@0.2.5(encoding@0.1.13)(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))': + '@langchain/openai@0.2.5(langchain@0.2.11(axios@1.7.4)(openai@4.53.0))': dependencies: - '@langchain/core': 0.2.18(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13)) + '@langchain/core': 0.2.18(langchain@0.2.11(axios@1.7.4)(openai@4.53.0))(openai@4.53.0) js-tiktoken: 1.0.12 openai: 4.53.0(encoding@0.1.13) zod: 3.23.8 @@ -16768,6 +16698,7 @@ snapshots: - encoding - langchain - supports-color + optional: true '@langchain/pinecone@0.0.8(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13))': dependencies: @@ -16797,9 +16728,9 @@ snapshots: - langchain - openai - '@langchain/textsplitters@0.0.3(langchain@0.2.11(@aws-sdk/client-s3@3.478.0)(@aws-sdk/credential-provider-node@3.535.0)(@azure/storage-blob@12.18.0(encoding@0.1.13))(@langchain/anthropic@0.2.9)(@langchain/cohere@0.0.10)(@langchain/community@0.2.20)(@langchain/google-genai@0.0.23)(@langchain/google-vertexai@0.0.21)(@langchain/groq@0.0.15)(@langchain/mistralai@0.0.27)(@langchain/ollama@0.0.2)(@pinecone-database/pinecone@3.0.0)(@supabase/supabase-js@2.43.4)(@xata.io/client@0.28.4(typescript@5.5.2))(axios@1.7.4)(cheerio@1.0.0-rc.12)(d3-dsv@2.0.0)(encoding@0.1.13)(epub2@3.0.2(ts-toolbelt@9.6.0))(fast-xml-parser@4.4.1)(handlebars@4.7.8)(html-to-text@9.0.5)(ignore@5.2.4)(ioredis@5.3.2)(jsdom@23.0.1)(mammoth@1.7.2)(mongodb@6.3.0(gcp-metadata@5.3.0(encoding@0.1.13))(socks@2.7.1))(openai@4.53.0(encoding@0.1.13))(pdf-parse@1.1.1)(redis@4.6.14)(ws@8.17.1))(openai@4.53.0(encoding@0.1.13))': + '@langchain/textsplitters@0.0.3(langchain@0.2.11(axios@1.7.4)(openai@4.53.0))(openai@4.53.0)': dependencies: - '@langchain/core': 0.2.18(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13)) + '@langchain/core': 0.2.18(langchain@0.2.11(axios@1.7.4)(openai@4.53.0))(openai@4.53.0) js-tiktoken: 1.0.12 transitivePeerDependencies: - langchain @@ -18112,11 +18043,11 @@ snapshots: dependencies: '@storybook/global': 5.0.0 - '@storybook/addon-interactions@8.1.4(@jest/globals@29.6.2)(@types/jest@29.5.3)(jest@29.6.2(@types/node@18.16.16)(ts-node@10.9.2(@types/node@18.16.16)(typescript@5.5.2)))(vitest@1.6.0(@types/node@18.16.16)(jsdom@23.0.1)(sass@1.64.1)(terser@5.16.1))': + '@storybook/addon-interactions@8.1.4(@jest/globals@29.6.2)(@types/jest@29.5.3)(jest@29.6.2(@types/node@18.16.16)(ts-node@10.9.2(@types/node@18.16.16)(typescript@5.5.2)))(vitest@1.6.0(@types/node@18.16.16)(sass@1.64.1)(terser@5.16.1))': dependencies: '@storybook/global': 5.0.0 '@storybook/instrumenter': 8.1.4 - '@storybook/test': 8.1.4(@jest/globals@29.6.2)(@types/jest@29.5.3)(jest@29.6.2(@types/node@18.16.16)(ts-node@10.9.2(@types/node@18.16.16)(typescript@5.5.2)))(vitest@1.6.0(@types/node@18.16.16)(jsdom@23.0.1)(sass@1.64.1)(terser@5.16.1)) + '@storybook/test': 8.1.4(@jest/globals@29.6.2)(@types/jest@29.5.3)(jest@29.6.2(@types/node@18.16.16)(ts-node@10.9.2(@types/node@18.16.16)(typescript@5.5.2)))(vitest@1.6.0(@types/node@18.16.16)(sass@1.64.1)(terser@5.16.1)) '@storybook/types': 8.1.4 polished: 4.2.2 ts-dedent: 2.2.0 @@ -18564,14 +18495,14 @@ snapshots: - prettier - supports-color - '@storybook/test@8.1.4(@jest/globals@29.6.2)(@types/jest@29.5.3)(jest@29.6.2(@types/node@18.16.16)(ts-node@10.9.2(@types/node@18.16.16)(typescript@5.5.2)))(vitest@1.6.0(@types/node@18.16.16)(jsdom@23.0.1)(sass@1.64.1)(terser@5.16.1))': + '@storybook/test@8.1.4(@jest/globals@29.6.2)(@types/jest@29.5.3)(jest@29.6.2(@types/node@18.16.16)(ts-node@10.9.2(@types/node@18.16.16)(typescript@5.5.2)))(vitest@1.6.0(@types/node@18.16.16)(sass@1.64.1)(terser@5.16.1))': dependencies: '@storybook/client-logger': 8.1.4 '@storybook/core-events': 8.1.4 '@storybook/instrumenter': 8.1.4 '@storybook/preview-api': 8.1.4 '@testing-library/dom': 9.3.4 - '@testing-library/jest-dom': 6.4.2(@jest/globals@29.6.2)(@types/jest@29.5.3)(jest@29.6.2(@types/node@18.16.16)(ts-node@10.9.2(@types/node@18.16.16)(typescript@5.5.2)))(vitest@1.6.0(@types/node@18.16.16)(jsdom@23.0.1)(sass@1.64.1)(terser@5.16.1)) + '@testing-library/jest-dom': 6.4.2(@jest/globals@29.6.2)(@types/jest@29.5.3)(jest@29.6.2(@types/node@18.16.16)(ts-node@10.9.2(@types/node@18.16.16)(typescript@5.5.2)))(vitest@1.6.0(@types/node@18.16.16)(sass@1.64.1)(terser@5.16.1)) '@testing-library/user-event': 14.5.2(@testing-library/dom@9.3.4) '@vitest/expect': 1.3.1 '@vitest/spy': 1.3.1 @@ -18634,7 +18565,7 @@ snapshots: ts-dedent: 2.2.0 type-fest: 2.19.0 vue: 3.4.21(typescript@5.5.2) - vue-component-type-helpers: 2.1.2 + vue-component-type-helpers: 2.1.4 transitivePeerDependencies: - encoding - prettier @@ -18734,7 +18665,7 @@ snapshots: jest: 29.6.2(@types/node@18.16.16)(ts-node@10.9.2(@types/node@18.16.16)(typescript@5.5.2)) vitest: 1.6.0(@types/node@18.16.16)(jsdom@23.0.1)(sass@1.64.1)(terser@5.16.1) - '@testing-library/jest-dom@6.4.2(@jest/globals@29.6.2)(@types/jest@29.5.3)(jest@29.6.2(@types/node@18.16.16)(ts-node@10.9.2(@types/node@18.16.16)(typescript@5.5.2)))(vitest@1.6.0(@types/node@18.16.16)(jsdom@23.0.1)(sass@1.64.1)(terser@5.16.1))': + '@testing-library/jest-dom@6.4.2(@jest/globals@29.6.2)(@types/jest@29.5.3)(jest@29.6.2(@types/node@18.16.16)(ts-node@10.9.2(@types/node@18.16.16)(typescript@5.5.2)))(vitest@1.6.0(@types/node@18.16.16)(sass@1.64.1)(terser@5.16.1))': dependencies: '@adobe/css-tools': 4.3.2 '@babel/runtime': 7.23.6 @@ -23951,17 +23882,17 @@ snapshots: kuler@2.0.0: {} - langchain@0.2.11(@aws-sdk/client-s3@3.478.0)(@aws-sdk/credential-provider-node@3.535.0)(@azure/storage-blob@12.18.0(encoding@0.1.13))(@langchain/anthropic@0.2.9)(@langchain/cohere@0.0.10)(@langchain/community@0.2.20)(@langchain/google-genai@0.0.23)(@langchain/google-vertexai@0.0.21)(@langchain/groq@0.0.15)(@langchain/mistralai@0.0.27)(@langchain/ollama@0.0.2)(@pinecone-database/pinecone@3.0.0)(@supabase/supabase-js@2.43.4)(@xata.io/client@0.28.4(typescript@5.5.2))(axios@1.7.4)(cheerio@1.0.0-rc.12)(d3-dsv@2.0.0)(encoding@0.1.13)(epub2@3.0.2(ts-toolbelt@9.6.0))(fast-xml-parser@4.4.1)(handlebars@4.7.8)(html-to-text@9.0.5)(ignore@5.2.4)(ioredis@5.3.2)(jsdom@23.0.1)(mammoth@1.7.2)(mongodb@6.3.0(gcp-metadata@5.3.0(encoding@0.1.13))(socks@2.7.1))(openai@4.53.0(encoding@0.1.13))(pdf-parse@1.1.1)(redis@4.6.14)(ws@8.17.1): + langchain@0.2.11(axios@1.7.4)(openai@4.53.0): dependencies: - '@langchain/core': 0.2.18(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13)) - '@langchain/openai': 0.2.5(encoding@0.1.13)(langchain@0.2.11(@aws-sdk/client-s3@3.478.0)(@aws-sdk/credential-provider-node@3.535.0)(@azure/storage-blob@12.18.0(encoding@0.1.13))(@langchain/anthropic@0.2.9)(@langchain/cohere@0.0.10)(@langchain/community@0.2.20)(@langchain/google-genai@0.0.23)(@langchain/google-vertexai@0.0.21)(@langchain/groq@0.0.15)(@langchain/mistralai@0.0.27)(@langchain/ollama@0.0.2)(@pinecone-database/pinecone@3.0.0)(@supabase/supabase-js@2.43.4)(@xata.io/client@0.28.4(typescript@5.5.2))(axios@1.7.4)(cheerio@1.0.0-rc.12)(d3-dsv@2.0.0)(encoding@0.1.13)(epub2@3.0.2(ts-toolbelt@9.6.0))(fast-xml-parser@4.4.1)(handlebars@4.7.8)(html-to-text@9.0.5)(ignore@5.2.4)(ioredis@5.3.2)(jsdom@23.0.1)(mammoth@1.7.2)(mongodb@6.3.0(gcp-metadata@5.3.0(encoding@0.1.13))(socks@2.7.1))(openai@4.53.0(encoding@0.1.13))(pdf-parse@1.1.1)(redis@4.6.14)(ws@8.17.1)) - '@langchain/textsplitters': 0.0.3(langchain@0.2.11(@aws-sdk/client-s3@3.478.0)(@aws-sdk/credential-provider-node@3.535.0)(@azure/storage-blob@12.18.0(encoding@0.1.13))(@langchain/anthropic@0.2.9)(@langchain/cohere@0.0.10)(@langchain/community@0.2.20)(@langchain/google-genai@0.0.23)(@langchain/google-vertexai@0.0.21)(@langchain/groq@0.0.15)(@langchain/mistralai@0.0.27)(@langchain/ollama@0.0.2)(@pinecone-database/pinecone@3.0.0)(@supabase/supabase-js@2.43.4)(@xata.io/client@0.28.4(typescript@5.5.2))(axios@1.7.4)(cheerio@1.0.0-rc.12)(d3-dsv@2.0.0)(encoding@0.1.13)(epub2@3.0.2(ts-toolbelt@9.6.0))(fast-xml-parser@4.4.1)(handlebars@4.7.8)(html-to-text@9.0.5)(ignore@5.2.4)(ioredis@5.3.2)(jsdom@23.0.1)(mammoth@1.7.2)(mongodb@6.3.0(gcp-metadata@5.3.0(encoding@0.1.13))(socks@2.7.1))(openai@4.53.0(encoding@0.1.13))(pdf-parse@1.1.1)(redis@4.6.14)(ws@8.17.1))(openai@4.53.0(encoding@0.1.13)) + '@langchain/core': 0.2.18(langchain@0.2.11(axios@1.7.4)(openai@4.53.0))(openai@4.53.0) + '@langchain/openai': 0.2.5(langchain@0.2.11(axios@1.7.4)(openai@4.53.0)) + '@langchain/textsplitters': 0.0.3(langchain@0.2.11(axios@1.7.4)(openai@4.53.0))(openai@4.53.0) binary-extensions: 2.2.0 js-tiktoken: 1.0.12 js-yaml: 4.1.0 jsonpointer: 5.0.1 langchainhub: 0.0.8 - langsmith: 0.1.34(@langchain/core@0.2.18(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13)))(langchain@0.2.11(@aws-sdk/client-s3@3.478.0)(@aws-sdk/credential-provider-node@3.535.0)(@azure/storage-blob@12.18.0(encoding@0.1.13))(@langchain/anthropic@0.2.9)(@langchain/cohere@0.0.10)(@langchain/community@0.2.20)(@langchain/google-genai@0.0.23)(@langchain/google-vertexai@0.0.21)(@langchain/groq@0.0.15)(@langchain/mistralai@0.0.27)(@langchain/ollama@0.0.2)(@pinecone-database/pinecone@3.0.0)(@supabase/supabase-js@2.43.4)(@xata.io/client@0.28.4(typescript@5.5.2))(axios@1.7.4)(cheerio@1.0.0-rc.12)(d3-dsv@2.0.0)(encoding@0.1.13)(epub2@3.0.2(ts-toolbelt@9.6.0))(fast-xml-parser@4.4.1)(handlebars@4.7.8)(html-to-text@9.0.5)(ignore@5.2.4)(ioredis@5.3.2)(jsdom@23.0.1)(mammoth@1.7.2)(mongodb@6.3.0(gcp-metadata@5.3.0(encoding@0.1.13))(socks@2.7.1))(openai@4.53.0(encoding@0.1.13))(pdf-parse@1.1.1)(redis@4.6.14)(ws@8.17.1))(openai@4.53.0(encoding@0.1.13)) + langsmith: 0.1.34(@langchain/core@0.2.18(langchain@0.2.11(axios@1.7.4)(openai@4.53.0))(openai@4.53.0))(langchain@0.2.11(axios@1.7.4)(openai@4.53.0))(openai@4.53.0) ml-distance: 4.0.1 openapi-types: 12.1.3 p-retry: 4.6.2 @@ -23970,35 +23901,7 @@ snapshots: zod: 3.23.8 zod-to-json-schema: 3.23.0(zod@3.23.8) optionalDependencies: - '@aws-sdk/client-s3': 3.478.0 - '@aws-sdk/credential-provider-node': 3.535.0 - '@azure/storage-blob': 12.18.0(encoding@0.1.13) - '@langchain/anthropic': 0.2.9(encoding@0.1.13)(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13)) - '@langchain/cohere': 0.0.10(encoding@0.1.13)(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13)) - '@langchain/community': 0.2.20(yatsnfdsa55wls5pvl4axjr4ti) - '@langchain/google-genai': 0.0.23(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13))(zod@3.23.8) - '@langchain/google-vertexai': 0.0.21(encoding@0.1.13)(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13))(zod@3.23.8) - '@langchain/groq': 0.0.15(encoding@0.1.13)(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13)) - '@langchain/mistralai': 0.0.27(encoding@0.1.13)(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13)) - '@langchain/ollama': 0.0.2(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13)) - '@pinecone-database/pinecone': 3.0.0 - '@supabase/supabase-js': 2.43.4 - '@xata.io/client': 0.28.4(typescript@5.5.2) axios: 1.7.4(debug@4.3.6) - cheerio: 1.0.0-rc.12 - d3-dsv: 2.0.0 - epub2: 3.0.2(ts-toolbelt@9.6.0) - fast-xml-parser: 4.4.1 - handlebars: 4.7.8 - html-to-text: 9.0.5 - ignore: 5.2.4 - ioredis: 5.3.2 - jsdom: 23.0.1 - mammoth: 1.7.2 - mongodb: 6.3.0(gcp-metadata@5.3.0(encoding@0.1.13))(socks@2.7.1) - pdf-parse: 1.1.1 - redis: 4.6.14 - ws: 8.17.1 transitivePeerDependencies: - encoding - openai @@ -24059,7 +23962,7 @@ snapshots: langchainhub@0.0.8: {} - langsmith@0.1.34(@langchain/core@0.2.18(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13)))(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13)): + langsmith@0.1.34(@langchain/core@0.2.18(langchain@0.2.11(axios@1.7.4)(openai@4.53.0))(openai@4.53.0))(langchain@0.2.11(axios@1.7.4)(openai@4.53.0))(openai@4.53.0): dependencies: '@types/uuid': 9.0.7 commander: 10.0.1 @@ -24068,11 +23971,12 @@ snapshots: p-retry: 4.6.2 uuid: 9.0.1 optionalDependencies: - '@langchain/core': 0.2.18(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13)) - langchain: 0.2.11(m7otbkpxyspoz5trt2pa3dcs6u) + '@langchain/core': 0.2.18(langchain@0.2.11(axios@1.7.4)(openai@4.53.0))(openai@4.53.0) + langchain: 0.2.11(axios@1.7.4)(openai@4.53.0) openai: 4.53.0(encoding@0.1.13) + optional: true - langsmith@0.1.34(@langchain/core@0.2.18(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13)))(langchain@0.2.11(@aws-sdk/client-s3@3.478.0)(@aws-sdk/credential-provider-node@3.535.0)(@azure/storage-blob@12.18.0(encoding@0.1.13))(@langchain/anthropic@0.2.9)(@langchain/cohere@0.0.10)(@langchain/community@0.2.20)(@langchain/google-genai@0.0.23)(@langchain/google-vertexai@0.0.21)(@langchain/groq@0.0.15)(@langchain/mistralai@0.0.27)(@langchain/ollama@0.0.2)(@pinecone-database/pinecone@3.0.0)(@supabase/supabase-js@2.43.4)(@xata.io/client@0.28.4(typescript@5.5.2))(axios@1.7.4)(cheerio@1.0.0-rc.12)(d3-dsv@2.0.0)(encoding@0.1.13)(epub2@3.0.2(ts-toolbelt@9.6.0))(fast-xml-parser@4.4.1)(handlebars@4.7.8)(html-to-text@9.0.5)(ignore@5.2.4)(ioredis@5.3.2)(jsdom@23.0.1)(mammoth@1.7.2)(mongodb@6.3.0(gcp-metadata@5.3.0(encoding@0.1.13))(socks@2.7.1))(openai@4.53.0(encoding@0.1.13))(pdf-parse@1.1.1)(redis@4.6.14)(ws@8.17.1))(openai@4.53.0(encoding@0.1.13)): + langsmith@0.1.34(@langchain/core@0.2.18(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13)))(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13)): dependencies: '@types/uuid': 9.0.7 commander: 10.0.1 @@ -24081,12 +23985,11 @@ snapshots: p-retry: 4.6.2 uuid: 9.0.1 optionalDependencies: - '@langchain/core': 0.2.18(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13)) - langchain: 0.2.11(@aws-sdk/client-s3@3.478.0)(@aws-sdk/credential-provider-node@3.535.0)(@azure/storage-blob@12.18.0(encoding@0.1.13))(@langchain/anthropic@0.2.9)(@langchain/cohere@0.0.10)(@langchain/community@0.2.20)(@langchain/google-genai@0.0.23)(@langchain/google-vertexai@0.0.21)(@langchain/groq@0.0.15)(@langchain/mistralai@0.0.27)(@langchain/ollama@0.0.2)(@pinecone-database/pinecone@3.0.0)(@supabase/supabase-js@2.43.4)(@xata.io/client@0.28.4(typescript@5.5.2))(axios@1.7.4)(cheerio@1.0.0-rc.12)(d3-dsv@2.0.0)(encoding@0.1.13)(epub2@3.0.2(ts-toolbelt@9.6.0))(fast-xml-parser@4.4.1)(handlebars@4.7.8)(html-to-text@9.0.5)(ignore@5.2.4)(ioredis@5.3.2)(jsdom@23.0.1)(mammoth@1.7.2)(mongodb@6.3.0(gcp-metadata@5.3.0(encoding@0.1.13))(socks@2.7.1))(openai@4.53.0(encoding@0.1.13))(pdf-parse@1.1.1)(redis@4.6.14)(ws@8.17.1) + '@langchain/core': 0.2.18(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13)) + langchain: 0.2.11(m7otbkpxyspoz5trt2pa3dcs6u) openai: 4.53.0(encoding@0.1.13) - optional: true - langsmith@0.1.39(@langchain/core@0.2.18(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13)))(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13)): + langsmith@0.1.39(@langchain/core@0.2.18(langchain@0.2.11(axios@1.7.4)(openai@4.53.0))(openai@4.53.0))(langchain@0.2.11(axios@1.7.4)(openai@4.53.0))(openai@4.53.0): dependencies: '@types/uuid': 9.0.7 commander: 10.0.1 @@ -24094,11 +23997,11 @@ snapshots: p-retry: 4.6.2 uuid: 9.0.1 optionalDependencies: - '@langchain/core': 0.2.18(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13)) - langchain: 0.2.11(m7otbkpxyspoz5trt2pa3dcs6u) + '@langchain/core': 0.2.18(langchain@0.2.11(axios@1.7.4)(openai@4.53.0))(openai@4.53.0) + langchain: 0.2.11(axios@1.7.4)(openai@4.53.0) openai: 4.53.0(encoding@0.1.13) - langsmith@0.1.39(@langchain/core@0.2.18(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13)))(langchain@0.2.11(@aws-sdk/client-s3@3.478.0)(@aws-sdk/credential-provider-node@3.535.0)(@azure/storage-blob@12.18.0(encoding@0.1.13))(@langchain/anthropic@0.2.9)(@langchain/cohere@0.0.10)(@langchain/community@0.2.20)(@langchain/google-genai@0.0.23)(@langchain/google-vertexai@0.0.21)(@langchain/groq@0.0.15)(@langchain/mistralai@0.0.27)(@langchain/ollama@0.0.2)(@pinecone-database/pinecone@3.0.0)(@supabase/supabase-js@2.43.4)(@xata.io/client@0.28.4(typescript@5.5.2))(axios@1.7.4)(cheerio@1.0.0-rc.12)(d3-dsv@2.0.0)(encoding@0.1.13)(epub2@3.0.2(ts-toolbelt@9.6.0))(fast-xml-parser@4.4.1)(handlebars@4.7.8)(html-to-text@9.0.5)(ignore@5.2.4)(ioredis@5.3.2)(jsdom@23.0.1)(mammoth@1.7.2)(mongodb@6.3.0(gcp-metadata@5.3.0(encoding@0.1.13))(socks@2.7.1))(openai@4.53.0(encoding@0.1.13))(pdf-parse@1.1.1)(redis@4.6.14)(ws@8.17.1))(openai@4.53.0(encoding@0.1.13)): + langsmith@0.1.39(@langchain/core@0.2.18(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13)))(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13)): dependencies: '@types/uuid': 9.0.7 commander: 10.0.1 @@ -24106,8 +24009,8 @@ snapshots: p-retry: 4.6.2 uuid: 9.0.1 optionalDependencies: - '@langchain/core': 0.2.18(langchain@0.2.11)(openai@4.53.0(encoding@0.1.13)) - langchain: 0.2.11(@aws-sdk/client-s3@3.478.0)(@aws-sdk/credential-provider-node@3.535.0)(@azure/storage-blob@12.18.0(encoding@0.1.13))(@langchain/anthropic@0.2.9)(@langchain/cohere@0.0.10)(@langchain/community@0.2.20)(@langchain/google-genai@0.0.23)(@langchain/google-vertexai@0.0.21)(@langchain/groq@0.0.15)(@langchain/mistralai@0.0.27)(@langchain/ollama@0.0.2)(@pinecone-database/pinecone@3.0.0)(@supabase/supabase-js@2.43.4)(@xata.io/client@0.28.4(typescript@5.5.2))(axios@1.7.4)(cheerio@1.0.0-rc.12)(d3-dsv@2.0.0)(encoding@0.1.13)(epub2@3.0.2(ts-toolbelt@9.6.0))(fast-xml-parser@4.4.1)(handlebars@4.7.8)(html-to-text@9.0.5)(ignore@5.2.4)(ioredis@5.3.2)(jsdom@23.0.1)(mammoth@1.7.2)(mongodb@6.3.0(gcp-metadata@5.3.0(encoding@0.1.13))(socks@2.7.1))(openai@4.53.0(encoding@0.1.13))(pdf-parse@1.1.1)(redis@4.6.14)(ws@8.17.1) + '@langchain/core': 0.2.18(langchain@0.2.11(m7otbkpxyspoz5trt2pa3dcs6u))(openai@4.53.0(encoding@0.1.13)) + langchain: 0.2.11(m7otbkpxyspoz5trt2pa3dcs6u) openai: 4.53.0(encoding@0.1.13) lazy-ass@1.6.0: {} @@ -28146,7 +28049,7 @@ snapshots: vue-component-type-helpers@2.0.19: {} - vue-component-type-helpers@2.1.2: {} + vue-component-type-helpers@2.1.4: {} vue-demi@0.14.5(vue@3.4.21(typescript@5.5.2)): dependencies: diff --git a/scripts/ensure-zx.mjs b/scripts/ensure-zx.mjs new file mode 100644 index 0000000000000..61f3265bfb10d --- /dev/null +++ b/scripts/ensure-zx.mjs @@ -0,0 +1,17 @@ +import { accessSync, constants } from 'node:fs'; +import { execSync } from 'node:child_process'; + +const ZX_PATH = 'node_modules/.bin/zx'; + +if (!zxExists()) { + execSync('pnpm --frozen-lockfile --filter n8n-monorepo install', { stdio: 'inherit' }); +} + +function zxExists() { + try { + accessSync(ZX_PATH, constants.F_OK); + return true; + } catch { + return false; + } +} diff --git a/scripts/reset.mjs b/scripts/reset.mjs new file mode 100644 index 0000000000000..48aecf761ba15 --- /dev/null +++ b/scripts/reset.mjs @@ -0,0 +1,30 @@ +// Resets the repository by deleting all untracked files except for few exceptions. +import { $, echo, fs } from 'zx'; + +$.verbose = true; +process.env.FORCE_COLOR = '1'; + +const excludePatterns = ['/.vscode/', '/.idea/', '.env']; +const excludeFlags = excludePatterns.map((exclude) => ['-e', exclude]).flat(); + +echo( + `This will delete all untracked files except for those matching the following patterns: ${excludePatterns.map((x) => `"${x}"`).join(', ')}.`, +); + +const answer = await question('❓ Do you want to continue? (y/n) '); + +if (!['y', 'Y', ''].includes(answer)) { + echo('Aborting...'); + process.exit(0); +} + +echo('🧹 Cleaning untracked files...'); +await $({ verbose: false })`git clean -fxd ${excludeFlags}`; +// In case node_modules is not removed by git clean +fs.removeSync('node_modules'); + +echo('⏬ Running pnpm install...'); +await $`pnpm install`; + +echo('🏗️ Running pnpm build...'); +await $`pnpm build`; From d5d7b24f55091b3da0f42de4b044f7908674ba7a Mon Sep 17 00:00:00 2001 From: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Date: Tue, 3 Sep 2024 16:57:49 +0300 Subject: [PATCH 03/18] feat(benchmark): Add benchmark scenario for binary files (#10648) --- .../scenarios/binaryData/binaryData.json | 67 +++++++++++++++++++ .../binaryData/binaryData.manifest.json | 7 ++ .../scenarios/binaryData/binaryData.script.js | 22 ++++++ .../benchmark/src/testExecution/k6Executor.ts | 3 +- 4 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 packages/@n8n/benchmark/scenarios/binaryData/binaryData.json create mode 100644 packages/@n8n/benchmark/scenarios/binaryData/binaryData.manifest.json create mode 100644 packages/@n8n/benchmark/scenarios/binaryData/binaryData.script.js diff --git a/packages/@n8n/benchmark/scenarios/binaryData/binaryData.json b/packages/@n8n/benchmark/scenarios/binaryData/binaryData.json new file mode 100644 index 0000000000000..a74b1c2d38f23 --- /dev/null +++ b/packages/@n8n/benchmark/scenarios/binaryData/binaryData.json @@ -0,0 +1,67 @@ +{ + "createdAt": "2024-09-03T11:51:56.540Z", + "updatedAt": "2024-09-03T12:22:21.000Z", + "name": "Binary Data", + "active": true, + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "binary-files-benchmark", + "responseMode": "responseNode", + "options": {} + }, + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [0, 0], + "id": "bfe19f12-3655-440f-be5c-8d71665c6353", + "name": "Webhook", + "webhookId": "109d7b13-93ad-42b0-a9ce-ca49e1817b35" + }, + { + "parameters": { "respondWith": "binary", "options": {} }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [740, 0], + "id": "cd957c9b-6b7a-4423-aac3-6df4d8bb571e", + "name": "Respond to Webhook" + }, + { + "parameters": { + "operation": "write", + "fileName": "=file-{{ Date.now() }}-{{ Math.random() }}.js", + "dataPropertyName": "file", + "options": {} + }, + "type": "n8n-nodes-base.readWriteFile", + "typeVersion": 1, + "position": [260, 0], + "id": "f2ce4709-7697-4bc6-8eca-6c222485297a", + "name": "Write File to Disk" + }, + { + "parameters": { "fileSelector": "={{ $json.fileName }}", "options": {} }, + "type": "n8n-nodes-base.readWriteFile", + "typeVersion": 1, + "position": [500, 0], + "id": "198e8a6c-81a3-4b34-b099-501961a02006", + "name": "Read File from Disk" + } + ], + "connections": { + "Webhook": { "main": [[{ "node": "Write File to Disk", "type": "main", "index": 0 }]] }, + "Write File to Disk": { + "main": [[{ "node": "Read File from Disk", "type": "main", "index": 0 }]] + }, + "Read File from Disk": { + "main": [[{ "node": "Respond to Webhook", "type": "main", "index": 0 }]] + } + }, + "settings": { "executionOrder": "v1" }, + "staticData": null, + "meta": null, + "pinData": {}, + "versionId": "8dd197c0-d1ea-43c3-9f88-9d11e7b081a0", + "triggerCount": 1, + "tags": [] +} diff --git a/packages/@n8n/benchmark/scenarios/binaryData/binaryData.manifest.json b/packages/@n8n/benchmark/scenarios/binaryData/binaryData.manifest.json new file mode 100644 index 0000000000000..13f2a8a127725 --- /dev/null +++ b/packages/@n8n/benchmark/scenarios/binaryData/binaryData.manifest.json @@ -0,0 +1,7 @@ +{ + "$schema": "../scenario.schema.json", + "name": "BinaryData", + "description": "Send a binary file to a webhook, write it to FS, read it from FS and receive it back", + "scenarioData": { "workflowFiles": ["binaryData.json"] }, + "scriptPath": "binaryData.script.js" +} diff --git a/packages/@n8n/benchmark/scenarios/binaryData/binaryData.script.js b/packages/@n8n/benchmark/scenarios/binaryData/binaryData.script.js new file mode 100644 index 0000000000000..95867245bfc5d --- /dev/null +++ b/packages/@n8n/benchmark/scenarios/binaryData/binaryData.script.js @@ -0,0 +1,22 @@ +import http from 'k6/http'; +import { check } from 'k6'; + +const apiBaseUrl = __ENV.API_BASE_URL; + +const file = open(__ENV.SCRIPT_FILE_PATH, 'b'); +const filename = String(__ENV.SCRIPT_FILE_PATH).split('/').pop(); + +export default function () { + const data = { + filename, + file: http.file(file, filename, 'application/javascript'), + }; + + const res = http.post(`${apiBaseUrl}/webhook/binary-files-benchmark`, data); + + check(res, { + 'is status 200': (r) => r.status === 200, + 'has correct content type': (r) => + r.headers['Content-Type'] === 'application/javascript; charset=utf-8', + }); +} diff --git a/packages/@n8n/benchmark/src/testExecution/k6Executor.ts b/packages/@n8n/benchmark/src/testExecution/k6Executor.ts index a491f84a32a19..da272ce69f97a 100644 --- a/packages/@n8n/benchmark/src/testExecution/k6Executor.ts +++ b/packages/@n8n/benchmark/src/testExecution/k6Executor.ts @@ -60,6 +60,7 @@ export function handleSummary(data) { env: { API_BASE_URL: this.opts.n8nApiBaseUrl, K6_CLOUD_TOKEN: this.opts.k6ApiToken, + SCRIPT_FILE_PATH: augmentedTestScriptPath, }, })`${k6ExecutablePath} run ${flattedFlags} ${augmentedTestScriptPath}`; @@ -82,7 +83,7 @@ export function handleSummary(data) { const augmentedTestScript = `${testScript}\n\n${summaryScript}`; - const tempFilePath = tmpfile(`${scenarioRunName}.ts`, augmentedTestScript); + const tempFilePath = tmpfile(`${scenarioRunName}.js`, augmentedTestScript); return tempFilePath; } From 35e6a87cbae8bf255a45d43ab72b7f8083f0acfa Mon Sep 17 00:00:00 2001 From: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Date: Tue, 3 Sep 2024 17:09:37 +0300 Subject: [PATCH 04/18] feat(benchmark): Add scenario for expressions with Set node (#10647) --- .../setNodeExpressions.json | 91 +++++++++++++++++++ .../setNodeExpressions.manifest.json | 7 ++ .../setNodeExpressions.script.js | 11 +++ 3 files changed, 109 insertions(+) create mode 100644 packages/@n8n/benchmark/scenarios/setNodeExpressions/setNodeExpressions.json create mode 100644 packages/@n8n/benchmark/scenarios/setNodeExpressions/setNodeExpressions.manifest.json create mode 100644 packages/@n8n/benchmark/scenarios/setNodeExpressions/setNodeExpressions.script.js diff --git a/packages/@n8n/benchmark/scenarios/setNodeExpressions/setNodeExpressions.json b/packages/@n8n/benchmark/scenarios/setNodeExpressions/setNodeExpressions.json new file mode 100644 index 0000000000000..fabb15a5a71e4 --- /dev/null +++ b/packages/@n8n/benchmark/scenarios/setNodeExpressions/setNodeExpressions.json @@ -0,0 +1,91 @@ +{ + "createdAt": "2024-09-03T11:30:26.333Z", + "updatedAt": "2024-09-03T11:42:52.000Z", + "name": "Set Node Expressions", + "active": false, + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "set-expressions-benchmark", + "responseMode": "responseNode", + "options": {} + }, + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [40, 0], + "id": "5babc228-2b89-48cb-8337-28416e867874", + "name": "Webhook", + "webhookId": "f6f1750d-b734-496f-afe8-26e8e393ca87" + }, + { + "parameters": { "respondWith": "allIncomingItems", "options": {} }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [640, 0], + "id": "4146a3fb-403c-4cfc-9d38-8af4d16a8440", + "name": "Respond to Webhook" + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "48c46098-f411-41f7-8f0a-1da372340a4e", + "name": "oneToOneCopy", + "value": "={{ $json.headers.host }}", + "type": "string" + }, + { + "id": "5d90808b-1c1a-4065-ac51-6d61bd03e564", + "name": "={{ $json.headers['user-agent'].slice(0, 4) }}", + "value": "Set key with expression", + "type": "string" + }, + { + "id": "8a74ac24-1f43-43ba-969d-87bfd2f401ce", + "name": "Multiple variables", + "value": "={{ $json.executionMode + ' ' + $json.webhookUrl }}", + "type": "string" + }, + { + "id": "93eba201-79d9-4305-a246-f9c8ec50ebab", + "name": "Static value", + "value": 42, + "type": "number" + }, + { + "id": "0470a712-c795-44ab-9dcc-05a3f67698bb", + "name": "Object", + "value": "={{ $json.headers }}", + "type": "object" + }, + { + "id": "eb671167-da14-4b55-8eea-31ab7bedae10", + "name": "Array", + "value": "={{ Object.values($json.headers) }}", + "type": "array" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [360, 0], + "id": "0cb5e82d-f61e-4d91-8fa9-365e382a4d75", + "name": "Edit Fields" + } + ], + "connections": { + "Webhook": { "main": [[{ "node": "Edit Fields", "type": "main", "index": 0 }]] }, + "Edit Fields": { "main": [[{ "node": "Respond to Webhook", "type": "main", "index": 0 }]] } + }, + "settings": { "executionOrder": "v1" }, + "staticData": null, + "meta": null, + "pinData": {}, + "versionId": "04fd543e-3923-4092-8c2b-2b4262ccbb38", + "triggerCount": 0, + "tags": [] +} diff --git a/packages/@n8n/benchmark/scenarios/setNodeExpressions/setNodeExpressions.manifest.json b/packages/@n8n/benchmark/scenarios/setNodeExpressions/setNodeExpressions.manifest.json new file mode 100644 index 0000000000000..850120aec1115 --- /dev/null +++ b/packages/@n8n/benchmark/scenarios/setNodeExpressions/setNodeExpressions.manifest.json @@ -0,0 +1,7 @@ +{ + "$schema": "../scenario.schema.json", + "name": "SetNodeExpressions", + "description": "Expressions in a Set node", + "scenarioData": { "workflowFiles": ["setNodeExpressions.json"] }, + "scriptPath": "setNodeExpressions.script.js" +} diff --git a/packages/@n8n/benchmark/scenarios/setNodeExpressions/setNodeExpressions.script.js b/packages/@n8n/benchmark/scenarios/setNodeExpressions/setNodeExpressions.script.js new file mode 100644 index 0000000000000..4bea17eb9fd4c --- /dev/null +++ b/packages/@n8n/benchmark/scenarios/setNodeExpressions/setNodeExpressions.script.js @@ -0,0 +1,11 @@ +import http from 'k6/http'; +import { check } from 'k6'; + +const apiBaseUrl = __ENV.API_BASE_URL; + +export default function () { + const res = http.post(`${apiBaseUrl}/webhook/set-expressions-benchmark`, {}); + check(res, { + 'is status 200': (r) => r.status === 200, + }); +} From 2ea2bfe762c02047e522f28dd97f197735b3fb46 Mon Sep 17 00:00:00 2001 From: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Date: Tue, 3 Sep 2024 17:52:12 +0300 Subject: [PATCH 05/18] feat: Reintroduce collaboration feature (#10602) --- .../__tests__/collaboration.state.test.ts | 96 +++++++++ .../collaboration/collaboration.message.ts | 35 ++++ .../collaboration/collaboration.service.ts | 120 +++++++++++ .../src/collaboration/collaboration.state.ts | 110 ++++++++++ .../src/collaboration/collaboration.types.ts | 7 + packages/cli/src/interfaces.ts | 21 +- .../src/push/__tests__/websocket.push.test.ts | 65 +++++- packages/cli/src/push/abstract.push.ts | 38 +++- packages/cli/src/push/index.ts | 17 +- packages/cli/src/push/sse.push.ts | 5 +- packages/cli/src/push/types.ts | 7 + packages/cli/src/push/websocket.push.ts | 32 ++- packages/cli/src/server.ts | 14 +- .../collaboration.service.test.ts | 188 ++++++++++++++++++ packages/editor-ui/src/Interface.ts | 11 + .../MainHeader/CollaborationPane.vue | 82 ++++++++ .../components/MainHeader/WorkflowDetails.vue | 2 + .../__tests__/CollaborationPane.test.ts | 102 ++++++++++ .../src/composables/useBeforeUnload.ts | 24 ++- packages/editor-ui/src/constants.ts | 1 + .../src/stores/collaboration.store.ts | 86 ++++++++ packages/editor-ui/src/views/NodeView.v2.vue | 6 + 22 files changed, 1046 insertions(+), 23 deletions(-) create mode 100644 packages/cli/src/collaboration/__tests__/collaboration.state.test.ts create mode 100644 packages/cli/src/collaboration/collaboration.message.ts create mode 100644 packages/cli/src/collaboration/collaboration.service.ts create mode 100644 packages/cli/src/collaboration/collaboration.state.ts create mode 100644 packages/cli/src/collaboration/collaboration.types.ts create mode 100644 packages/cli/test/integration/collaboration/collaboration.service.test.ts create mode 100644 packages/editor-ui/src/components/MainHeader/CollaborationPane.vue create mode 100644 packages/editor-ui/src/components/__tests__/CollaborationPane.test.ts create mode 100644 packages/editor-ui/src/stores/collaboration.store.ts diff --git a/packages/cli/src/collaboration/__tests__/collaboration.state.test.ts b/packages/cli/src/collaboration/__tests__/collaboration.state.test.ts new file mode 100644 index 0000000000000..4435645d7a114 --- /dev/null +++ b/packages/cli/src/collaboration/__tests__/collaboration.state.test.ts @@ -0,0 +1,96 @@ +import { CollaborationState } from '../collaboration.state'; +import type { CacheService } from '@/services/cache/cache.service'; +import { mock } from 'jest-mock-extended'; + +const origDate = global.Date; + +const mockDateFactory = (currentDate: string) => { + return class CustomDate extends origDate { + constructor() { + super(currentDate); + } + } as DateConstructor; +}; + +describe('CollaborationState', () => { + let collaborationState: CollaborationState; + let mockCacheService: jest.Mocked; + + beforeEach(() => { + mockCacheService = mock(); + collaborationState = new CollaborationState(mockCacheService); + }); + + afterEach(() => { + global.Date = origDate; + }); + + const workflowId = 'workflow'; + + describe('addActiveWorkflowUser', () => { + it('should add workflow user with correct cache key and value', async () => { + // Arrange + global.Date = mockDateFactory('2023-01-01T00:00:00.000Z'); + + // Act + await collaborationState.addActiveWorkflowUser(workflowId, 'userId'); + + // Assert + expect(mockCacheService.setHash).toHaveBeenCalledWith('collaboration:workflow', { + userId: '2023-01-01T00:00:00.000Z', + }); + }); + }); + + describe('removeActiveWorkflowUser', () => { + it('should remove workflow user with correct cache key', async () => { + // Act + await collaborationState.removeActiveWorkflowUser(workflowId, 'userId'); + + // Assert + expect(mockCacheService.deleteFromHash).toHaveBeenCalledWith( + 'collaboration:workflow', + 'userId', + ); + }); + }); + + describe('getActiveWorkflowUsers', () => { + it('should get workflows with correct cache key', async () => { + // Act + const users = await collaborationState.getActiveWorkflowUsers(workflowId); + + // Assert + expect(mockCacheService.getHash).toHaveBeenCalledWith('collaboration:workflow'); + expect(users).toBeEmptyArray(); + }); + + it('should get workflow users that are not expired', async () => { + // Arrange + const nowMinus16Minutes = new Date(); + nowMinus16Minutes.setMinutes(nowMinus16Minutes.getMinutes() - 16); + const now = new Date().toISOString(); + + mockCacheService.getHash.mockResolvedValueOnce({ + expiredUserId: nowMinus16Minutes.toISOString(), + notExpiredUserId: now, + }); + + // Act + const users = await collaborationState.getActiveWorkflowUsers(workflowId); + + // Assert + expect(users).toEqual([ + { + lastSeen: now, + userId: 'notExpiredUserId', + }, + ]); + // removes expired users from the cache + expect(mockCacheService.deleteFromHash).toHaveBeenCalledWith( + 'collaboration:workflow', + 'expiredUserId', + ); + }); + }); +}); diff --git a/packages/cli/src/collaboration/collaboration.message.ts b/packages/cli/src/collaboration/collaboration.message.ts new file mode 100644 index 0000000000000..e61a9fe9acb34 --- /dev/null +++ b/packages/cli/src/collaboration/collaboration.message.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; + +export type CollaborationMessage = WorkflowOpenedMessage | WorkflowClosedMessage; + +export const workflowOpenedMessageSchema = z + .object({ + type: z.literal('workflowOpened'), + workflowId: z.string().min(1), + }) + .strict(); + +export const workflowClosedMessageSchema = z + .object({ + type: z.literal('workflowClosed'), + workflowId: z.string().min(1), + }) + .strict(); + +export const workflowMessageSchema = z.discriminatedUnion('type', [ + workflowOpenedMessageSchema, + workflowClosedMessageSchema, +]); + +export type WorkflowOpenedMessage = z.infer; + +export type WorkflowClosedMessage = z.infer; + +export type WorkflowMessage = z.infer; + +/** + * Parses the given message and ensure it's of type WorkflowMessage + */ +export const parseWorkflowMessage = async (msg: unknown) => { + return await workflowMessageSchema.parseAsync(msg); +}; diff --git a/packages/cli/src/collaboration/collaboration.service.ts b/packages/cli/src/collaboration/collaboration.service.ts new file mode 100644 index 0000000000000..775d3791fc500 --- /dev/null +++ b/packages/cli/src/collaboration/collaboration.service.ts @@ -0,0 +1,120 @@ +import type { Workflow } from 'n8n-workflow'; +import { Service } from 'typedi'; +import { Push } from '../push'; +import type { WorkflowClosedMessage, WorkflowOpenedMessage } from './collaboration.message'; +import { parseWorkflowMessage } from './collaboration.message'; +import type { IActiveWorkflowUsersChanged } from '../interfaces'; +import type { OnPushMessage } from '@/push/types'; +import { UserRepository } from '@/databases/repositories/user.repository'; +import type { User } from '@/databases/entities/user'; +import { CollaborationState } from '@/collaboration/collaboration.state'; +import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; +import { UserService } from '@/services/user.service'; +import { ApplicationError, ErrorReporterProxy } from 'n8n-workflow'; + +/** + * Service for managing collaboration feature between users. E.g. keeping + * track of active users for a workflow. + */ +@Service() +export class CollaborationService { + constructor( + private readonly push: Push, + private readonly state: CollaborationState, + private readonly userRepository: UserRepository, + private readonly userService: UserService, + private readonly sharedWorkflowRepository: SharedWorkflowRepository, + ) {} + + init() { + this.push.on('message', async (event: OnPushMessage) => { + try { + await this.handleUserMessage(event.userId, event.msg); + } catch (error) { + ErrorReporterProxy.error( + new ApplicationError('Error handling CollaborationService push message', { + extra: { + msg: event.msg, + userId: event.userId, + }, + cause: error, + }), + ); + } + }); + } + + async handleUserMessage(userId: User['id'], msg: unknown) { + const workflowMessage = await parseWorkflowMessage(msg); + + if (workflowMessage.type === 'workflowOpened') { + await this.handleWorkflowOpened(userId, workflowMessage); + } else if (workflowMessage.type === 'workflowClosed') { + await this.handleWorkflowClosed(userId, workflowMessage); + } + } + + private async handleWorkflowOpened(userId: User['id'], msg: WorkflowOpenedMessage) { + const { workflowId } = msg; + + if (!(await this.hasUserAccessToWorkflow(userId, workflowId))) { + return; + } + + await this.state.addActiveWorkflowUser(workflowId, userId); + + await this.sendWorkflowUsersChangedMessage(workflowId); + } + + private async handleWorkflowClosed(userId: User['id'], msg: WorkflowClosedMessage) { + const { workflowId } = msg; + + if (!(await this.hasUserAccessToWorkflow(userId, workflowId))) { + return; + } + + await this.state.removeActiveWorkflowUser(workflowId, userId); + + await this.sendWorkflowUsersChangedMessage(workflowId); + } + + private async sendWorkflowUsersChangedMessage(workflowId: Workflow['id']) { + // We have already validated that all active workflow users + // have proper access to the workflow, so we don't need to validate it again + const activeWorkflowUsers = await this.state.getActiveWorkflowUsers(workflowId); + const workflowUserIds = activeWorkflowUsers.map((user) => user.userId); + + if (workflowUserIds.length === 0) { + return; + } + const users = await this.userRepository.getByIds(this.userRepository.manager, workflowUserIds); + + const msgData: IActiveWorkflowUsersChanged = { + workflowId, + activeUsers: await Promise.all( + users.map(async (user) => ({ + user: await this.userService.toPublic(user), + lastSeen: activeWorkflowUsers.find((activeUser) => activeUser.userId === user.id)! + .lastSeen, + })), + ), + }; + + this.push.sendToUsers('activeWorkflowUsersChanged', msgData, workflowUserIds); + } + + private async hasUserAccessToWorkflow(userId: User['id'], workflowId: Workflow['id']) { + const user = await this.userRepository.findOneBy({ + id: userId, + }); + if (!user) { + return false; + } + + const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [ + 'workflow:read', + ]); + + return !!workflow; + } +} diff --git a/packages/cli/src/collaboration/collaboration.state.ts b/packages/cli/src/collaboration/collaboration.state.ts new file mode 100644 index 0000000000000..d110bf20dda3b --- /dev/null +++ b/packages/cli/src/collaboration/collaboration.state.ts @@ -0,0 +1,110 @@ +import type { ActiveWorkflowUser } from '@/collaboration/collaboration.types'; +import { Time } from '@/constants'; +import type { Iso8601DateTimeString } from '@/interfaces'; +import { CacheService } from '@/services/cache/cache.service'; +import type { User } from '@/databases/entities/user'; +import { type Workflow } from 'n8n-workflow'; +import { Service } from 'typedi'; + +type WorkflowCacheHash = Record; + +/** + * State management for the collaboration service. Workflow active + * users are stored in a hash in the following format: + * { + * [workflowId] -> { + * [userId] -> lastSeenAsIso8601String + * } + * } + */ +@Service() +export class CollaborationState { + /** + * After how many minutes of inactivity a user should be removed + * as being an active user of a workflow. + */ + public readonly inactivityCleanUpTime = 15 * Time.minutes.toMilliseconds; + + constructor(private readonly cache: CacheService) {} + + /** + * Mark user active for given workflow + */ + async addActiveWorkflowUser(workflowId: Workflow['id'], userId: User['id']) { + const cacheKey = this.formWorkflowCacheKey(workflowId); + const cacheEntry: WorkflowCacheHash = { + [userId]: new Date().toISOString(), + }; + + await this.cache.setHash(cacheKey, cacheEntry); + } + + /** + * Remove user from workflow's active users + */ + async removeActiveWorkflowUser(workflowId: Workflow['id'], userId: User['id']) { + const cacheKey = this.formWorkflowCacheKey(workflowId); + + await this.cache.deleteFromHash(cacheKey, userId); + } + + async getActiveWorkflowUsers(workflowId: Workflow['id']): Promise { + const cacheKey = this.formWorkflowCacheKey(workflowId); + + const cacheValue = await this.cache.getHash(cacheKey); + if (!cacheValue) { + return []; + } + + const workflowActiveUsers = this.cacheHashToWorkflowActiveUsers(cacheValue); + const [expired, stillActive] = this.splitToExpiredAndStillActive(workflowActiveUsers); + + if (expired.length > 0) { + void this.removeExpiredUsersForWorkflow(workflowId, expired); + } + + return stillActive; + } + + private formWorkflowCacheKey(workflowId: Workflow['id']) { + return `collaboration:${workflowId}`; + } + + private splitToExpiredAndStillActive(workflowUsers: ActiveWorkflowUser[]) { + const expired: ActiveWorkflowUser[] = []; + const stillActive: ActiveWorkflowUser[] = []; + + for (const user of workflowUsers) { + if (this.hasUserExpired(user.lastSeen)) { + expired.push(user); + } else { + stillActive.push(user); + } + } + + return [expired, stillActive]; + } + + private async removeExpiredUsersForWorkflow( + workflowId: Workflow['id'], + expiredUsers: ActiveWorkflowUser[], + ) { + const cacheKey = this.formWorkflowCacheKey(workflowId); + await Promise.all( + expiredUsers.map(async (user) => await this.cache.deleteFromHash(cacheKey, user.userId)), + ); + } + + private cacheHashToWorkflowActiveUsers(workflowCacheEntry: WorkflowCacheHash) { + return Object.entries(workflowCacheEntry).map(([userId, lastSeen]) => ({ + userId, + lastSeen, + })); + } + + private hasUserExpired(lastSeenString: Iso8601DateTimeString) { + const expiryTime = new Date(lastSeenString).getTime() + this.inactivityCleanUpTime; + + return Date.now() > expiryTime; + } +} diff --git a/packages/cli/src/collaboration/collaboration.types.ts b/packages/cli/src/collaboration/collaboration.types.ts new file mode 100644 index 0000000000000..d2a0591395c8e --- /dev/null +++ b/packages/cli/src/collaboration/collaboration.types.ts @@ -0,0 +1,7 @@ +import type { Iso8601DateTimeString } from '@/interfaces'; +import type { User } from '@/databases/entities/user'; + +export type ActiveWorkflowUser = { + userId: User['id']; + lastSeen: Iso8601DateTimeString; +}; diff --git a/packages/cli/src/interfaces.ts b/packages/cli/src/interfaces.ts index 78a597e5b3977..8b44008261b87 100644 --- a/packages/cli/src/interfaces.ts +++ b/packages/cli/src/interfaces.ts @@ -290,7 +290,13 @@ export type IPushData = | PushDataWorkerStatusMessage | PushDataWorkflowActivated | PushDataWorkflowDeactivated - | PushDataWorkflowFailedToActivate; + | PushDataWorkflowFailedToActivate + | PushDataActiveWorkflowUsersChanged; + +type PushDataActiveWorkflowUsersChanged = { + data: IActiveWorkflowUsersChanged; + type: 'activeWorkflowUsersChanged'; +}; type PushDataWorkflowFailedToActivate = { data: IWorkflowFailedToActivate; @@ -362,6 +368,19 @@ export type PushDataNodeDescriptionUpdated = { type: 'nodeDescriptionUpdated'; }; +/** DateTime in the Iso8601 format, e.g. 2024-10-31T00:00:00.123Z */ +export type Iso8601DateTimeString = string; + +export interface IActiveWorkflowUser { + user: PublicUser; + lastSeen: Iso8601DateTimeString; +} + +export interface IActiveWorkflowUsersChanged { + workflowId: Workflow['id']; + activeUsers: IActiveWorkflowUser[]; +} + export interface IActiveWorkflowAdded { workflowId: Workflow['id']; } diff --git a/packages/cli/src/push/__tests__/websocket.push.test.ts b/packages/cli/src/push/__tests__/websocket.push.test.ts index b1202b3a13b25..158264827c114 100644 --- a/packages/cli/src/push/__tests__/websocket.push.test.ts +++ b/packages/cli/src/push/__tests__/websocket.push.test.ts @@ -7,6 +7,7 @@ import { Logger } from '@/logger'; import type { PushDataExecutionRecovered } from '@/interfaces'; import { mockInstance } from '@test/mocking'; +import type { User } from '@/databases/entities/user'; jest.useFakeTimers(); @@ -27,6 +28,7 @@ const createMockWebSocket = () => new MockWebSocket() as unknown as jest.Mocked< describe('WebSocketPush', () => { const pushRef1 = 'test-session1'; const pushRef2 = 'test-session2'; + const userId: User['id'] = 'test-user'; mockInstance(Logger); const webSocketPush = Container.get(WebSocketPush); @@ -35,27 +37,31 @@ describe('WebSocketPush', () => { beforeEach(() => { jest.resetAllMocks(); + mockWebSocket1.removeAllListeners(); + mockWebSocket2.removeAllListeners(); }); it('can add a connection', () => { - webSocketPush.add(pushRef1, mockWebSocket1); + webSocketPush.add(pushRef1, userId, mockWebSocket1); expect(mockWebSocket1.listenerCount('close')).toBe(1); expect(mockWebSocket1.listenerCount('pong')).toBe(1); + expect(mockWebSocket1.listenerCount('message')).toBe(1); }); it('closes a connection', () => { - webSocketPush.add(pushRef1, mockWebSocket1); + webSocketPush.add(pushRef1, userId, mockWebSocket1); mockWebSocket1.emit('close'); + expect(mockWebSocket1.listenerCount('message')).toBe(0); expect(mockWebSocket1.listenerCount('close')).toBe(0); expect(mockWebSocket1.listenerCount('pong')).toBe(0); }); it('sends data to one connection', () => { - webSocketPush.add(pushRef1, mockWebSocket1); - webSocketPush.add(pushRef2, mockWebSocket2); + webSocketPush.add(pushRef1, userId, mockWebSocket1); + webSocketPush.add(pushRef2, userId, mockWebSocket2); const data: PushDataExecutionRecovered = { type: 'executionRecovered', data: { @@ -80,8 +86,8 @@ describe('WebSocketPush', () => { }); it('sends data to all connections', () => { - webSocketPush.add(pushRef1, mockWebSocket1); - webSocketPush.add(pushRef2, mockWebSocket2); + webSocketPush.add(pushRef1, userId, mockWebSocket1); + webSocketPush.add(pushRef2, userId, mockWebSocket2); const data: PushDataExecutionRecovered = { type: 'executionRecovered', data: { @@ -105,12 +111,55 @@ describe('WebSocketPush', () => { }); it('pings all connections', () => { - webSocketPush.add(pushRef1, mockWebSocket1); - webSocketPush.add(pushRef2, mockWebSocket2); + webSocketPush.add(pushRef1, userId, mockWebSocket1); + webSocketPush.add(pushRef2, userId, mockWebSocket2); jest.runOnlyPendingTimers(); expect(mockWebSocket1.ping).toHaveBeenCalled(); expect(mockWebSocket2.ping).toHaveBeenCalled(); }); + + it('sends data to all users connections', () => { + webSocketPush.add(pushRef1, userId, mockWebSocket1); + webSocketPush.add(pushRef2, userId, mockWebSocket2); + const data: PushDataExecutionRecovered = { + type: 'executionRecovered', + data: { + executionId: 'test-execution-id', + }, + }; + + webSocketPush.sendToUsers('executionRecovered', data, [userId]); + + const expectedMsg = JSON.stringify({ + type: 'executionRecovered', + data: { + type: 'executionRecovered', + data: { + executionId: 'test-execution-id', + }, + }, + }); + expect(mockWebSocket1.send).toHaveBeenCalledWith(expectedMsg); + expect(mockWebSocket2.send).toHaveBeenCalledWith(expectedMsg); + }); + + it('emits message event when connection receives data', () => { + const mockOnMessageReceived = jest.fn(); + webSocketPush.on('message', mockOnMessageReceived); + webSocketPush.add(pushRef1, userId, mockWebSocket1); + webSocketPush.add(pushRef2, userId, mockWebSocket2); + + const data = { test: 'data' }; + const buffer = Buffer.from(JSON.stringify(data)); + + mockWebSocket1.emit('message', buffer); + + expect(mockOnMessageReceived).toHaveBeenCalledWith({ + msg: data, + pushRef: pushRef1, + userId, + }); + }); }); diff --git a/packages/cli/src/push/abstract.push.ts b/packages/cli/src/push/abstract.push.ts index 88595403308ba..20b43283de477 100644 --- a/packages/cli/src/push/abstract.push.ts +++ b/packages/cli/src/push/abstract.push.ts @@ -1,6 +1,13 @@ import { assert, jsonStringify } from 'n8n-workflow'; import type { IPushDataType } from '@/interfaces'; import type { Logger } from '@/logger'; +import type { User } from '@/databases/entities/user'; +import { TypedEmitter } from '@/typed-emitter'; +import type { OnPushMessage } from '@/push/types'; + +export interface AbstractPushEvents { + message: OnPushMessage; +} /** * Abstract class for two-way push communication. @@ -8,16 +15,20 @@ import type { Logger } from '@/logger'; * * @emits message when a message is received from a client */ -export abstract class AbstractPush { +export abstract class AbstractPush extends TypedEmitter { protected connections: Record = {}; + protected userIdByPushRef: Record = {}; + protected abstract close(connection: T): void; protected abstract sendToOneConnection(connection: T, data: string): void; - constructor(protected readonly logger: Logger) {} + constructor(protected readonly logger: Logger) { + super(); + } - protected add(pushRef: string, connection: T) { - const { connections } = this; + protected add(pushRef: string, userId: User['id'], connection: T) { + const { connections, userIdByPushRef } = this; this.logger.debug('Add editor-UI session', { pushRef }); const existingConnection = connections[pushRef]; @@ -28,6 +39,15 @@ export abstract class AbstractPush { } connections[pushRef] = connection; + userIdByPushRef[pushRef] = userId; + } + + protected onMessageReceived(pushRef: string, msg: unknown) { + this.logger.debug('Received message from editor-UI', { pushRef, msg }); + + const userId = this.userIdByPushRef[pushRef]; + + this.emit('message', { pushRef, userId, msg }); } protected remove(pushRef?: string) { @@ -36,6 +56,7 @@ export abstract class AbstractPush { this.logger.debug('Removed editor-UI session', { pushRef }); delete this.connections[pushRef]; + delete this.userIdByPushRef[pushRef]; } private sendTo(type: IPushDataType, data: unknown, pushRefs: string[]) { @@ -66,6 +87,15 @@ export abstract class AbstractPush { this.sendTo(type, data, [pushRef]); } + sendToUsers(type: IPushDataType, data: unknown, userIds: Array) { + const { connections } = this; + const userPushRefs = Object.keys(connections).filter((pushRef) => + userIds.includes(this.userIdByPushRef[pushRef]), + ); + + this.sendTo(type, data, userPushRefs); + } + closeAllConnections() { for (const pushRef in this.connections) { // Signal the connection that we want to close it. diff --git a/packages/cli/src/push/index.ts b/packages/cli/src/push/index.ts index defe07019610e..b01e085cec289 100644 --- a/packages/cli/src/push/index.ts +++ b/packages/cli/src/push/index.ts @@ -15,11 +15,13 @@ import { OrchestrationService } from '@/services/orchestration.service'; import { SSEPush } from './sse.push'; import { WebSocketPush } from './websocket.push'; -import type { PushResponse, SSEPushRequest, WebSocketPushRequest } from './types'; +import type { OnPushMessage, PushResponse, SSEPushRequest, WebSocketPushRequest } from './types'; import { TypedEmitter } from '@/typed-emitter'; +import type { User } from '@/databases/entities/user'; type PushEvents = { editorUiConnected: string; + message: OnPushMessage; }; const useWebSockets = config.getEnv('push.backend') === 'websocket'; @@ -33,16 +35,21 @@ const useWebSockets = config.getEnv('push.backend') === 'websocket'; */ @Service() export class Push extends TypedEmitter { + public isBidirectional = useWebSockets; + private backend = useWebSockets ? Container.get(WebSocketPush) : Container.get(SSEPush); constructor(private readonly orchestrationService: OrchestrationService) { super(); + + if (useWebSockets) this.backend.on('message', (msg) => this.emit('message', msg)); } handleRequest(req: SSEPushRequest | WebSocketPushRequest, res: PushResponse) { const { ws, query: { pushRef }, + user, } = req; if (!pushRef) { @@ -55,9 +62,9 @@ export class Push extends TypedEmitter { } if (req.ws) { - (this.backend as WebSocketPush).add(pushRef, req.ws); + (this.backend as WebSocketPush).add(pushRef, user.id, req.ws); } else if (!useWebSockets) { - (this.backend as SSEPush).add(pushRef, { req, res }); + (this.backend as SSEPush).add(pushRef, user.id, { req, res }); } else { res.status(401).send('Unauthorized'); return; @@ -90,6 +97,10 @@ export class Push extends TypedEmitter { return this.backend; } + sendToUsers(type: IPushDataType, data: unknown, userIds: Array) { + this.backend.sendToUsers(type, data, userIds); + } + @OnShutdown() onShutdown() { this.backend.closeAllConnections(); diff --git a/packages/cli/src/push/sse.push.ts b/packages/cli/src/push/sse.push.ts index 38779ed730f9e..e78134eac367b 100644 --- a/packages/cli/src/push/sse.push.ts +++ b/packages/cli/src/push/sse.push.ts @@ -5,6 +5,7 @@ import { Logger } from '@/logger'; import { AbstractPush } from './abstract.push'; import type { PushRequest, PushResponse } from './types'; +import type { User } from '@/databases/entities/user'; type Connection = { req: PushRequest; res: PushResponse }; @@ -22,8 +23,8 @@ export class SSEPush extends AbstractPush { }); } - add(pushRef: string, connection: Connection) { - super.add(pushRef, connection); + add(pushRef: string, userId: User['id'], connection: Connection) { + super.add(pushRef, userId, connection); this.channel.addClient(connection.req, connection.res); } diff --git a/packages/cli/src/push/types.ts b/packages/cli/src/push/types.ts index a12e582213be9..0a9d6f5b6cad9 100644 --- a/packages/cli/src/push/types.ts +++ b/packages/cli/src/push/types.ts @@ -2,6 +2,7 @@ import type { Response } from 'express'; import type { WebSocket } from 'ws'; import type { AuthenticatedRequest } from '@/requests'; +import type { User } from '@/databases/entities/user'; // TODO: move all push related types here @@ -11,3 +12,9 @@ export type SSEPushRequest = PushRequest & { ws: undefined }; export type WebSocketPushRequest = PushRequest & { ws: WebSocket }; export type PushResponse = Response & { req: PushRequest }; + +export interface OnPushMessage { + pushRef: string; + userId: User['id']; + msg: unknown; +} diff --git a/packages/cli/src/push/websocket.push.ts b/packages/cli/src/push/websocket.push.ts index 733eebdc60532..79ef00fffb1b1 100644 --- a/packages/cli/src/push/websocket.push.ts +++ b/packages/cli/src/push/websocket.push.ts @@ -2,6 +2,8 @@ import type WebSocket from 'ws'; import { Service } from 'typedi'; import { Logger } from '@/logger'; import { AbstractPush } from './abstract.push'; +import type { User } from '@/databases/entities/user'; +import { ApplicationError, ErrorReporterProxy } from 'n8n-workflow'; function heartbeat(this: WebSocket) { this.isAlive = true; @@ -16,17 +18,43 @@ export class WebSocketPush extends AbstractPush { setInterval(() => this.pingAll(), 60 * 1000); } - add(pushRef: string, connection: WebSocket) { + add(pushRef: string, userId: User['id'], connection: WebSocket) { connection.isAlive = true; connection.on('pong', heartbeat); - super.add(pushRef, connection); + super.add(pushRef, userId, connection); + + const onMessage = (data: WebSocket.RawData) => { + try { + const buffer = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data); + + this.onMessageReceived(pushRef, JSON.parse(buffer.toString('utf8'))); + } catch (error) { + ErrorReporterProxy.error( + new ApplicationError('Error parsing push message', { + extra: { + userId, + data, + }, + cause: error, + }), + ); + this.logger.error("Couldn't parse message from editor-UI", { + error: error as unknown, + pushRef, + data, + }); + } + }; // Makes sure to remove the session if the connection is closed connection.once('close', () => { connection.off('pong', heartbeat); + connection.off('message', onMessage); this.remove(pushRef); }); + + connection.on('message', onMessage); } protected close(connection: WebSocket): void { diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index 9176402a8898c..82e12b0386c4f 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -27,7 +27,7 @@ import type { ICredentialsOverwrite } from '@/interfaces'; import { CredentialsOverwrites } from '@/credentials-overwrites'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import * as ResponseHelper from '@/response-helper'; -import { setupPushServer, setupPushHandler } from '@/push'; +import { setupPushServer, setupPushHandler, Push } from '@/push'; import { isLdapEnabled } from '@/ldap/helpers.ee'; import { AbstractServer } from '@/abstract-server'; import { PostHogClient } from '@/posthog'; @@ -212,6 +212,18 @@ export class Server extends AbstractServer { const { restEndpoint, app } = this; setupPushHandler(restEndpoint, app); + const push = Container.get(Push); + if (push.isBidirectional) { + const { CollaborationService } = await import('@/collaboration/collaboration.service'); + + const collaborationService = Container.get(CollaborationService); + collaborationService.init(); + } else { + this.logger.warn( + 'Collaboration features are disabled because push is configured unidirectional. Use N8N_PUSH_BACKEND=websocket environment variable to enable them.', + ); + } + if (config.getEnv('executions.mode') === 'queue') { const { ScalingService } = await import('@/scaling/scaling.service'); await Container.get(ScalingService).setupQueue(); diff --git a/packages/cli/test/integration/collaboration/collaboration.service.test.ts b/packages/cli/test/integration/collaboration/collaboration.service.test.ts new file mode 100644 index 0000000000000..81b00dc866755 --- /dev/null +++ b/packages/cli/test/integration/collaboration/collaboration.service.test.ts @@ -0,0 +1,188 @@ +import { CollaborationService } from '@/collaboration/collaboration.service'; +import { Push } from '@/push'; +import { CacheService } from '@/services/cache/cache.service'; +import { mock } from 'jest-mock-extended'; +import * as testDb from '../shared/test-db'; +import Container from 'typedi'; +import type { User } from '@/databases/entities/user'; +import { createMember, createOwner } from '@test-integration/db/users'; +import type { + WorkflowClosedMessage, + WorkflowOpenedMessage, +} from '@/collaboration/collaboration.message'; +import { createWorkflow, shareWorkflowWithUsers } from '@test-integration/db/workflows'; +import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; +import { mockInstance } from '@test/mocking'; +import { UserService } from '@/services/user.service'; + +describe('CollaborationService', () => { + mockInstance(Push, new Push(mock())); + let pushService: Push; + let collaborationService: CollaborationService; + let owner: User; + let memberWithoutAccess: User; + let memberWithAccess: User; + let workflow: WorkflowEntity; + let userService: UserService; + let cacheService: CacheService; + + beforeAll(async () => { + await testDb.init(); + + pushService = Container.get(Push); + collaborationService = Container.get(CollaborationService); + userService = Container.get(UserService); + cacheService = Container.get(CacheService); + + await cacheService.init(); + + [owner, memberWithAccess, memberWithoutAccess] = await Promise.all([ + createOwner(), + createMember(), + createMember(), + ]); + workflow = await createWorkflow({}, owner); + await shareWorkflowWithUsers(workflow, [memberWithAccess]); + }); + + afterEach(async () => { + jest.resetAllMocks(); + await cacheService.reset(); + }); + + const sendWorkflowOpenedMessage = async (workflowId: string, userId: string) => { + const openMessage: WorkflowOpenedMessage = { + type: 'workflowOpened', + workflowId, + }; + + return await collaborationService.handleUserMessage(userId, openMessage); + }; + + const sendWorkflowClosedMessage = async (workflowId: string, userId: string) => { + const openMessage: WorkflowClosedMessage = { + type: 'workflowClosed', + workflowId, + }; + + return await collaborationService.handleUserMessage(userId, openMessage); + }; + + describe('workflow opened message', () => { + it('should emit activeWorkflowUsersChanged after workflowOpened', async () => { + // Arrange + const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers'); + + // Act + await sendWorkflowOpenedMessage(workflow.id, owner.id); + await sendWorkflowOpenedMessage(workflow.id, memberWithAccess.id); + + // Assert + expect(sendToUsersSpy).toHaveBeenNthCalledWith( + 1, + 'activeWorkflowUsersChanged', + { + activeUsers: [ + { + lastSeen: expect.any(String), + user: { + ...(await userService.toPublic(owner)), + isPending: false, + }, + }, + ], + workflowId: workflow.id, + }, + [owner.id], + ); + expect(sendToUsersSpy).toHaveBeenNthCalledWith( + 2, + 'activeWorkflowUsersChanged', + { + activeUsers: expect.arrayContaining([ + expect.objectContaining({ + lastSeen: expect.any(String), + user: expect.objectContaining({ + id: owner.id, + }), + }), + expect.objectContaining({ + lastSeen: expect.any(String), + user: expect.objectContaining({ + id: memberWithAccess.id, + }), + }), + ]), + workflowId: workflow.id, + }, + [owner.id, memberWithAccess.id], + ); + }); + + it("should not emit activeWorkflowUsersChanged if user don't have access to the workflow", async () => { + const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers'); + + // Act + await sendWorkflowOpenedMessage(workflow.id, memberWithoutAccess.id); + + // Assert + expect(sendToUsersSpy).not.toHaveBeenCalled(); + }); + }); + + describe('workflow closed message', () => { + it('should not emit activeWorkflowUsersChanged after workflowClosed when there are no active users', async () => { + // Arrange + const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers'); + await sendWorkflowOpenedMessage(workflow.id, owner.id); + sendToUsersSpy.mockClear(); + + // Act + await sendWorkflowClosedMessage(workflow.id, owner.id); + + // Assert + expect(sendToUsersSpy).not.toHaveBeenCalled(); + }); + + it('should emit activeWorkflowUsersChanged after workflowClosed when there are active users', async () => { + // Arrange + const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers'); + await sendWorkflowOpenedMessage(workflow.id, owner.id); + await sendWorkflowOpenedMessage(workflow.id, memberWithAccess.id); + sendToUsersSpy.mockClear(); + + // Act + await sendWorkflowClosedMessage(workflow.id, owner.id); + + // Assert + expect(sendToUsersSpy).toHaveBeenCalledWith( + 'activeWorkflowUsersChanged', + { + activeUsers: expect.arrayContaining([ + expect.objectContaining({ + lastSeen: expect.any(String), + user: expect.objectContaining({ + id: memberWithAccess.id, + }), + }), + ]), + workflowId: workflow.id, + }, + [memberWithAccess.id], + ); + }); + + it("should not emit activeWorkflowUsersChanged if user don't have access to the workflow", async () => { + // Arrange + const sendToUsersSpy = jest.spyOn(pushService, 'sendToUsers'); + await sendWorkflowOpenedMessage(workflow.id, owner.id); + sendToUsersSpy.mockClear(); + + // Act + await sendWorkflowClosedMessage(workflow.id, memberWithoutAccess.id); + + // Assert + expect(sendToUsersSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index f7ad20f9eaf9c..0f6f7a927e4f5 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -422,6 +422,16 @@ export interface IExecutionDeleteFilter { ids?: string[]; } +export type PushDataUsersForWorkflow = { + workflowId: string; + activeUsers: Array<{ user: IUser; lastSeen: string }>; +}; + +type PushDataWorkflowUsersChanged = { + data: PushDataUsersForWorkflow; + type: 'activeWorkflowUsersChanged'; +}; + export type IPushData = | PushDataExecutionFinished | PushDataExecutionStarted @@ -436,6 +446,7 @@ export type IPushData = | PushDataWorkerStatusMessage | PushDataActiveWorkflowAdded | PushDataActiveWorkflowRemoved + | PushDataWorkflowUsersChanged | PushDataWorkflowFailedToActivate; export type PushDataActiveWorkflowAdded = { diff --git a/packages/editor-ui/src/components/MainHeader/CollaborationPane.vue b/packages/editor-ui/src/components/MainHeader/CollaborationPane.vue new file mode 100644 index 0000000000000..daabc0a1780bc --- /dev/null +++ b/packages/editor-ui/src/components/MainHeader/CollaborationPane.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue b/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue index 28cca7736f58b..de687771d8cf4 100644 --- a/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue +++ b/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue @@ -22,6 +22,7 @@ import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue'; import InlineTextEdit from '@/components/InlineTextEdit.vue'; import BreakpointsObserver from '@/components/BreakpointsObserver.vue'; import WorkflowHistoryButton from '@/components/MainHeader/WorkflowHistoryButton.vue'; +import CollaborationPane from '@/components/MainHeader/CollaborationPane.vue'; import { useRootStore } from '@/stores/root.store'; import { useSettingsStore } from '@/stores/settings.store'; @@ -675,6 +676,7 @@ function showCreateWorkflowSuccessToast(id?: string) {
+ { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should show only current workflow users', async () => { + const { getByTestId, queryByTestId } = renderComponent(); + await waitAllPromises(); + + expect(getByTestId('collaboration-pane')).toBeInTheDocument(); + expect(getByTestId('user-stack-avatars')).toBeInTheDocument(); + expect(getByTestId(`user-stack-avatar-${OWNER_USER.id}`)).toBeInTheDocument(); + expect(getByTestId(`user-stack-avatar-${MEMBER_USER.id}`)).toBeInTheDocument(); + expect(queryByTestId(`user-stack-avatar-${MEMBER_USER_2.id}`)).toBeNull(); + }); + + it('should always render owner first in the list', async () => { + const { getByTestId } = renderComponent(); + await waitAllPromises(); + const firstAvatar = getByTestId('user-stack-avatars').querySelector('.n8n-avatar'); + // Owner is second in the store but should be rendered first + expect(firstAvatar).toHaveAttribute('data-test-id', `user-stack-avatar-${OWNER_USER.id}`); + }); +}); diff --git a/packages/editor-ui/src/composables/useBeforeUnload.ts b/packages/editor-ui/src/composables/useBeforeUnload.ts index 5469c43ee84ef..20095a217afa4 100644 --- a/packages/editor-ui/src/composables/useBeforeUnload.ts +++ b/packages/editor-ui/src/composables/useBeforeUnload.ts @@ -1,9 +1,11 @@ import { useCanvasStore } from '@/stores/canvas.store'; import { useUIStore } from '@/stores/ui.store'; import { useI18n } from '@/composables/useI18n'; -import { computed } from 'vue'; -import { VIEWS } from '@/constants'; +import { computed, ref } from 'vue'; +import { TIME, VIEWS } from '@/constants'; import type { useRoute } from 'vue-router'; +import { useCollaborationStore } from '@/stores/collaboration.store'; +import { useWorkflowsStore } from '@/stores/workflows.store'; /** * Composable to handle the beforeunload event in canvas views. @@ -15,19 +17,31 @@ import type { useRoute } from 'vue-router'; export function useBeforeUnload({ route }: { route: ReturnType }) { const uiStore = useUIStore(); const canvasStore = useCanvasStore(); + const collaborationStore = useCollaborationStore(); + const workflowsStore = useWorkflowsStore(); const i18n = useI18n(); + const unloadTimeout = ref(null); const isDemoRoute = computed(() => route.name === VIEWS.DEMO); function onBeforeUnload(e: BeforeUnloadEvent) { if (isDemoRoute.value || window.preventNodeViewBeforeUnload) { return; } else if (uiStore.stateIsDirty) { + // A bit hacky solution to detecting users leaving the page after prompt: + // 1. Notify that workflow is closed straight away + collaborationStore.notifyWorkflowClosed(workflowsStore.workflowId); + // 2. If user decided to stay on the page we notify that the workflow is opened again + unloadTimeout.value = setTimeout(() => { + collaborationStore.notifyWorkflowOpened(workflowsStore.workflowId); + }, 5 * TIME.SECOND); + e.returnValue = true; //Gecko + IE return true; //Gecko + Webkit, Safari, Chrome etc. } else { canvasStore.startLoading(i18n.baseText('nodeView.redirecting')); + collaborationStore.notifyWorkflowClosed(workflowsStore.workflowId); return; } } @@ -37,6 +51,12 @@ export function useBeforeUnload({ route }: { route: ReturnType } function removeBeforeUnloadEventBindings() { + collaborationStore.notifyWorkflowClosed(workflowsStore.workflowId); + + if (unloadTimeout.value) { + clearTimeout(unloadTimeout.value); + } + window.removeEventListener('beforeunload', onBeforeUnload); } diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index f44046139ebab..9cdf0b87b91f9 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -639,6 +639,7 @@ export const enum STORES { CLOUD_PLAN = 'cloudPlan', RBAC = 'rbac', PUSH = 'push', + COLLABORATION = 'collaboration', ASSISTANT = 'assistant', BECOME_TEMPLATE_CREATOR = 'becomeTemplateCreator', PROJECTS = 'projects', diff --git a/packages/editor-ui/src/stores/collaboration.store.ts b/packages/editor-ui/src/stores/collaboration.store.ts new file mode 100644 index 0000000000000..e9f4afe168496 --- /dev/null +++ b/packages/editor-ui/src/stores/collaboration.store.ts @@ -0,0 +1,86 @@ +import { defineStore } from 'pinia'; +import { computed, ref } from 'vue'; +import { useWorkflowsStore } from '@/stores/workflows.store'; +import { usePushConnectionStore } from '@/stores/pushConnection.store'; +import { STORES } from '@/constants'; +import type { IUser } from '@/Interface'; +import { useUsersStore } from '@/stores/users.store'; + +type ActiveUsersForWorkflows = { + [workflowId: string]: Array<{ user: IUser; lastSeen: string }>; +}; + +/** + * Store for tracking active users for workflows. I.e. to show + * who is collaboratively viewing/editing the workflow at the same time. + */ +export const useCollaborationStore = defineStore(STORES.COLLABORATION, () => { + const pushStore = usePushConnectionStore(); + const workflowStore = useWorkflowsStore(); + const usersStore = useUsersStore(); + + const usersForWorkflows = ref({}); + const pushStoreEventListenerRemovalFn = ref<(() => void) | null>(null); + + const getUsersForCurrentWorkflow = computed(() => { + return usersForWorkflows.value[workflowStore.workflowId] ?? []; + }); + + function initialize() { + if (pushStoreEventListenerRemovalFn.value) { + return; + } + + pushStoreEventListenerRemovalFn.value = pushStore.addEventListener((event) => { + if (event.type === 'activeWorkflowUsersChanged') { + const workflowId = event.data.workflowId; + usersForWorkflows.value[workflowId] = event.data.activeUsers; + } + }); + } + + function terminate() { + if (typeof pushStoreEventListenerRemovalFn.value === 'function') { + pushStoreEventListenerRemovalFn.value(); + pushStoreEventListenerRemovalFn.value = null; + } + } + + function workflowUsersUpdated(data: ActiveUsersForWorkflows) { + usersForWorkflows.value = data; + } + + function functionRemoveCurrentUserFromActiveUsers(workflowId: string) { + const workflowUsers = usersForWorkflows.value[workflowId]; + if (!workflowUsers) { + return; + } + + usersForWorkflows.value[workflowId] = workflowUsers.filter( + (activeUser) => activeUser.user.id !== usersStore.currentUserId, + ); + } + + function notifyWorkflowOpened(workflowId: string) { + pushStore.send({ + type: 'workflowOpened', + workflowId, + }); + } + + function notifyWorkflowClosed(workflowId: string) { + pushStore.send({ type: 'workflowClosed', workflowId }); + + functionRemoveCurrentUserFromActiveUsers(workflowId); + } + + return { + usersForWorkflows, + initialize, + terminate, + notifyWorkflowOpened, + notifyWorkflowClosed, + workflowUsersUpdated, + getUsersForCurrentWorkflow, + }; +}); diff --git a/packages/editor-ui/src/views/NodeView.v2.vue b/packages/editor-ui/src/views/NodeView.v2.vue index 5ce133bca92cd..4f5f16caa58fe 100644 --- a/packages/editor-ui/src/views/NodeView.v2.vue +++ b/packages/editor-ui/src/views/NodeView.v2.vue @@ -101,6 +101,7 @@ import { createEventBus } from 'n8n-design-system'; import type { PinDataSource } from '@/composables/usePinnedData'; import { useClipboard } from '@/composables/useClipboard'; import { useBeforeUnload } from '@/composables/useBeforeUnload'; +import { useCollaborationStore } from '@/stores/collaboration.store'; import { getResourcePermissions } from '@/permissions'; import NodeViewUnfinishedWorkflowMessage from '@/components/NodeViewUnfinishedWorkflowMessage.vue'; @@ -134,6 +135,7 @@ const credentialsStore = useCredentialsStore(); const environmentsStore = useEnvironmentsStore(); const externalSecretsStore = useExternalSecretsStore(); const rootStore = useRootStore(); +const collaborationStore = useCollaborationStore(); const executionsStore = useExecutionsStore(); const canvasStore = useCanvasStore(); const npsSurveyStore = useNpsSurveyStore(); @@ -338,6 +340,8 @@ async function initializeWorkspaceForExistingWorkflow(id: string) { } await projectsStore.setProjectNavActiveIdByWorkflowHomeProject(workflow.value.homeProject); + + collaborationStore.notifyWorkflowOpened(id); } catch (error) { toast.showError(error, i18n.baseText('openWorkflow.workflowNotFoundError')); @@ -1456,6 +1460,7 @@ watch( onBeforeMount(() => { if (!isDemoRoute.value) { pushConnectionStore.pushConnect(); + collaborationStore.initialize(); } }); @@ -1509,6 +1514,7 @@ onBeforeUnmount(() => { onDeactivated(() => { removeBeforeUnloadEventBindings(); + collaborationStore.terminate(); }); From 36177b0943cf72bae3b0075453498dd1e41684d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 3 Sep 2024 17:58:26 +0200 Subject: [PATCH 06/18] fix(core): Declutter webhook insertion errors (#10650) --- packages/cli/src/webhooks/__tests__/webhook.service.test.ts | 4 ++-- packages/cli/src/webhooks/webhook.service.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/webhooks/__tests__/webhook.service.test.ts b/packages/cli/src/webhooks/__tests__/webhook.service.test.ts index 13c921c65d286..6a970aa5832a0 100644 --- a/packages/cli/src/webhooks/__tests__/webhook.service.test.ts +++ b/packages/cli/src/webhooks/__tests__/webhook.service.test.ts @@ -179,12 +179,12 @@ describe('WebhookService', () => { }); describe('createWebhook()', () => { - test('should create the webhook', async () => { + test('should store webhook in DB', async () => { const mockWebhook = createWebhook('GET', 'user/:id'); await webhookService.storeWebhook(mockWebhook); - expect(webhookRepository.insert).toHaveBeenCalledWith(mockWebhook); + expect(webhookRepository.upsert).toHaveBeenCalledWith(mockWebhook, ['method', 'webhookPath']); }); }); }); diff --git a/packages/cli/src/webhooks/webhook.service.ts b/packages/cli/src/webhooks/webhook.service.ts index c72edc18d1351..3d7e75871db64 100644 --- a/packages/cli/src/webhooks/webhook.service.ts +++ b/packages/cli/src/webhooks/webhook.service.ts @@ -93,7 +93,7 @@ export class WebhookService { async storeWebhook(webhook: WebhookEntity) { void this.cacheService.set(webhook.cacheKey, webhook); - return await this.webhookRepository.insert(webhook); + await this.webhookRepository.upsert(webhook, ['method', 'webhookPath']); } createWebhook(data: Partial) { From f114035a6b4470c5a8e9ec2f470a9a667e0dd7f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20G=C3=B3mez=20Morales?= Date: Wed, 4 Sep 2024 08:04:35 +0200 Subject: [PATCH 07/18] refactor(editor): Remove Trial logic in personalization modal and port to script setup (#10649) Co-authored-by: Csaba Tuncsik --- .../components/PersonalizationModal.test.ts | 157 +++ .../src/components/PersonalizationModal.vue | 1075 ++++++++--------- .../__tests__/PersonalizationModal.spec.ts | 144 --- 3 files changed, 635 insertions(+), 741 deletions(-) create mode 100644 packages/editor-ui/src/components/PersonalizationModal.test.ts delete mode 100644 packages/editor-ui/src/components/__tests__/PersonalizationModal.spec.ts diff --git a/packages/editor-ui/src/components/PersonalizationModal.test.ts b/packages/editor-ui/src/components/PersonalizationModal.test.ts new file mode 100644 index 0000000000000..160beefccd617 --- /dev/null +++ b/packages/editor-ui/src/components/PersonalizationModal.test.ts @@ -0,0 +1,157 @@ +import userEvent from '@testing-library/user-event'; +import { createComponentRenderer } from '@/__tests__/render'; +import { getDropdownItems, mockedStore } from '@/__tests__/utils'; +import { createUser } from '@/__tests__/data/users'; +import { useSettingsStore } from '@/stores/settings.store'; +import PersonalizationModal from '@/components/PersonalizationModal.vue'; +import { useUsersStore } from '@/stores/users.store'; +import { createTestingPinia } from '@pinia/testing'; +import { + COMPANY_TYPE_KEY, + EMAIL_KEY, + COMPANY_INDUSTRY_EXTENDED_KEY, + OTHER_COMPANY_INDUSTRY_EXTENDED_KEY, + MARKETING_AUTOMATION_GOAL_KEY, + OTHER_MARKETING_AUTOMATION_GOAL_KEY, + ROLE_KEY, + ROLE_OTHER_KEY, + DEVOPS_AUTOMATION_GOAL_OTHER_KEY, + DEVOPS_AUTOMATION_GOAL_KEY, +} from '@/constants'; + +const renderModal = createComponentRenderer(PersonalizationModal, { + global: { + stubs: { + Modal: { + template: ` +
+ + + + +
+ `, + }, + }, + }, +}); + +describe('PersonalizationModal', () => { + it('mounts', () => { + const { getByTitle } = renderModal({ pinia: createTestingPinia() }); + expect(getByTitle('Customize n8n to you')).toBeInTheDocument(); + }); + + it('shows user input when needed for desktop deployment', () => { + const pinia = createTestingPinia(); + const usersStore = mockedStore(useUsersStore); + usersStore.currentUser = createUser({ firstName: undefined }); + + const settingsStore = mockedStore(useSettingsStore); + settingsStore.isDesktopDeployment = true; + + const { getByTestId } = renderModal({ pinia }); + expect(getByTestId(EMAIL_KEY)).toBeInTheDocument(); + }); + + describe('Company field', () => { + it('allows completion of company related fields', async () => { + const { getByTestId } = renderModal({ pinia: createTestingPinia() }); + + const companyTypeSelect = getByTestId(COMPANY_TYPE_KEY); + + const otherTypeOfCompanyOption = [...(await getDropdownItems(companyTypeSelect))].find( + (node) => node.textContent === 'Other', + ) as Element; + + await userEvent.click(otherTypeOfCompanyOption); + + const industrySelect = getByTestId(COMPANY_INDUSTRY_EXTENDED_KEY); + expect(industrySelect).toBeInTheDocument(); + + const otherIndustryOption = [...(await getDropdownItems(industrySelect))].find( + (node) => node.textContent === 'Other (please specify)', + ) as Element; + + await userEvent.click(otherIndustryOption); + + expect(getByTestId(OTHER_COMPANY_INDUSTRY_EXTENDED_KEY)).toBeInTheDocument(); + }); + + it('shows only company and source select when not used for work', async () => { + const { getByTestId, baseElement } = renderModal({ pinia: createTestingPinia() }); + + const companyTypeSelect = getByTestId(COMPANY_TYPE_KEY); + + const nonWorkOption = [...(await getDropdownItems(companyTypeSelect))].find( + (node) => node.textContent === "I'm not using n8n for work", + ) as Element; + + await userEvent.click(nonWorkOption); + + expect(baseElement.querySelectorAll('input').length).toBe(2); + }); + }); + + it('allows completion of role related fields', async () => { + const { getByTestId, queryByTestId } = renderModal({ pinia: createTestingPinia() }); + + const roleSelect = getByTestId(ROLE_KEY); + const roleItems = [...(await getDropdownItems(roleSelect))]; + + const devOps = roleItems.find((node) => node.textContent === 'Devops') as Element; + const engineering = roleItems.find((node) => node.textContent === 'Engineering') as Element; + const it = roleItems.find((node) => node.textContent === 'IT') as Element; + const other = roleItems.find( + (node) => node.textContent === 'Other (please specify)', + ) as Element; + + await userEvent.click(devOps); + const automationGoalSelect = getByTestId(DEVOPS_AUTOMATION_GOAL_KEY); + expect(automationGoalSelect).toBeInTheDocument(); + + await userEvent.click(engineering); + expect(automationGoalSelect).toBeInTheDocument(); + + await userEvent.click(it); + expect(automationGoalSelect).toBeInTheDocument(); + + const otherGoalsItem = [...(await getDropdownItems(automationGoalSelect))].find( + (node) => node.textContent === 'Other', + ) as Element; + + await userEvent.click(otherGoalsItem); + expect(getByTestId(DEVOPS_AUTOMATION_GOAL_OTHER_KEY)).toBeInTheDocument(); + + await userEvent.click(other); + expect(queryByTestId(DEVOPS_AUTOMATION_GOAL_KEY)).not.toBeInTheDocument(); + expect(getByTestId(ROLE_OTHER_KEY)).toBeInTheDocument(); + }); + + it('allows completion of marketing and sales related fields', async () => { + const { getByTestId } = renderModal({ pinia: createTestingPinia() }); + + const companyTypeSelect = getByTestId(COMPANY_TYPE_KEY); + + const anyWorkOption = [...(await getDropdownItems(companyTypeSelect))].find( + (node) => node.textContent !== "I'm not using n8n for work", + ) as Element; + + await userEvent.click(anyWorkOption); + + const roleSelect = getByTestId(ROLE_KEY); + const salesAndMarketingOption = [...(await getDropdownItems(roleSelect))].find( + (node) => node.textContent === 'Sales and Marketing', + ) as Element; + + await userEvent.click(salesAndMarketingOption); + + const salesAndMarketingSelect = getByTestId(MARKETING_AUTOMATION_GOAL_KEY); + const otherItem = [...(await getDropdownItems(salesAndMarketingSelect))].find( + (node) => node.textContent === 'Other', + ) as Element; + + await userEvent.click(otherItem); + expect(getByTestId(OTHER_MARKETING_AUTOMATION_GOAL_KEY)).toBeInTheDocument(); + }); +}); diff --git a/packages/editor-ui/src/components/PersonalizationModal.vue b/packages/editor-ui/src/components/PersonalizationModal.vue index 13e360c3c1981..1eab5e4b20816 100644 --- a/packages/editor-ui/src/components/PersonalizationModal.vue +++ b/packages/editor-ui/src/components/PersonalizationModal.vue @@ -1,6 +1,5 @@ -