diff --git a/.github/scripts/trim-fe-packageJson.js b/.github/scripts/trim-fe-packageJson.js index b331f37e5f..50f456d2a9 100644 --- a/.github/scripts/trim-fe-packageJson.js +++ b/.github/scripts/trim-fe-packageJson.js @@ -14,5 +14,5 @@ const trimPackageJson = (packageName) => { }; trimPackageJson('frontend/@n8n/chat'); -trimPackageJson('design-system'); -trimPackageJson('editor-ui'); +trimPackageJson('frontend/@n8n/design-system'); +trimPackageJson('frontend/editor-ui'); diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 5054e76c79..c38f038105 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -51,14 +51,20 @@ jobs: - name: Dry-run publishing run: pnpm publish -r --no-git-checks --dry-run - - name: Publish to NPM + - name: Pre publishing changes run: | echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc node .github/scripts/trim-fe-packageJson.js node .github/scripts/ensure-provenance-fields.mjs + cp README.md packages/cli/README.md sed -i "s/default: 'dev'/default: 'stable'/g" packages/cli/dist/config/schema.js - pnpm publish -r --publish-branch ${{github.event.pull_request.base.ref}} --access public --tag rc --no-git-checks - npm dist-tag rm n8n rc + + - name: Publish to NPM + run: pnpm publish -r --publish-branch ${{github.event.pull_request.base.ref}} --access public --tag rc --no-git-checks + + - name: Cleanup rc tag + run: npm dist-tag rm n8n rc + continue-on-error: true - id: set-release run: echo "release=${{ env.RELEASE }}" >> $GITHUB_OUTPUT @@ -68,7 +74,7 @@ jobs: needs: [publish-to-npm] runs-on: ubuntu-latest if: github.event.pull_request.merged == true - timeout-minutes: 10 + timeout-minutes: 15 steps: - name: Checkout @@ -156,7 +162,7 @@ jobs: with: projects: ${{ secrets.SENTRY_FRONTEND_PROJECT }} version: ${{ needs.publish-to-npm.outputs.release }} - sourcemaps: packages/editor-ui/dist + sourcemaps: packages/frontend/editor-ui/dist - name: Create a backend release uses: getsentry/action-release@v1.7.0 diff --git a/.github/workflows/release-push-to-channel.yml b/.github/workflows/release-push-to-channel.yml index 74219e51c4..2f4a9170a7 100644 --- a/.github/workflows/release-push-to-channel.yml +++ b/.github/workflows/release-push-to-channel.yml @@ -11,10 +11,10 @@ on: description: 'Release channel' required: true type: choice - default: 'next' + default: 'beta' options: - - next - - latest + - beta + - stable jobs: release-to-npm: @@ -25,9 +25,18 @@ jobs: - uses: actions/setup-node@v4.2.0 with: node-version: 20.x - - run: | - echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc - npm dist-tag add n8n@${{ github.event.inputs.version }} ${{ github.event.inputs.release-channel }} + + - run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc + + - if: github.event.inputs.release-channel == 'beta' + run: | + npm dist-tag add n8n@${{ github.event.inputs.version }} next + npm dist-tag add n8n@${{ github.event.inputs.version }} beta + + - if: github.event.inputs.release-channel == 'stable' + run: | + npm dist-tag add n8n@${{ github.event.inputs.version }} latest + npm dist-tag add n8n@${{ github.event.inputs.version }} stable release-to-docker-hub: name: Release to DockerHub @@ -39,7 +48,15 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - - run: docker buildx imagetools create -t ${{ secrets.DOCKER_USERNAME }}/n8n:${{ github.event.inputs.release-channel }} ${{ secrets.DOCKER_USERNAME }}/n8n:${{ github.event.inputs.version }} + - if: github.event.inputs.release-channel == 'stable' + run: | + docker buildx imagetools create -t ${{ secrets.DOCKER_USERNAME }}/n8n:stable ${{ secrets.DOCKER_USERNAME }}/n8n:${{ github.event.inputs.version }} + docker buildx imagetools create -t ${{ secrets.DOCKER_USERNAME }}/n8n:latest ${{ secrets.DOCKER_USERNAME }}/n8n:${{ github.event.inputs.version }} + + - if: github.event.inputs.release-channel == 'beta' + run: | + docker buildx imagetools create -t ${{ secrets.DOCKER_USERNAME }}/n8n:beta ${{ secrets.DOCKER_USERNAME }}/n8n:${{ github.event.inputs.version }} + docker buildx imagetools create -t ${{ secrets.DOCKER_USERNAME }}/n8n:next ${{ secrets.DOCKER_USERNAME }}/n8n:${{ github.event.inputs.version }} release-to-github-container-registry: name: Release to GitHub Container Registry @@ -52,7 +69,15 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - run: docker buildx imagetools create -t ghcr.io/${{ github.repository_owner }}/n8n:${{ github.event.inputs.release-channel }} ghcr.io/${{ github.repository_owner }}/n8n:${{ github.event.inputs.version }} + - if: github.event.inputs.release-channel == 'stable' + run: | + docker buildx imagetools create -t ghcr.io/${{ github.repository_owner }}/n8n:stable ghcr.io/${{ github.repository_owner }}/n8n:${{ github.event.inputs.version }} + docker buildx imagetools create -t ghcr.io/${{ github.repository_owner }}/n8n:latest ghcr.io/${{ github.repository_owner }}/n8n:${{ github.event.inputs.version }} + + - if: github.event.inputs.release-channel == 'beta' + run: | + docker buildx imagetools create -t ghcr.io/${{ github.repository_owner }}/n8n:beta ghcr.io/${{ github.repository_owner }}/n8n:${{ github.event.inputs.version }} + docker buildx imagetools create -t ghcr.io/${{ github.repository_owner }}/n8n:next ghcr.io/${{ github.repository_owner }}/n8n:${{ github.event.inputs.version }} update-docs: name: Update latest and next in the docs diff --git a/.prettierignore b/.prettierignore index 192edfec2d..d34c856f88 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,7 +2,7 @@ coverage dist package.json pnpm-lock.yaml -packages/editor-ui/index.html +packages/frontend/editor-ui/index.html packages/nodes-base/nodes/**/test packages/cli/templates/form-trigger.handlebars packages/cli/templates/form-trigger-completion.handlebars diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5e495fda9c..ef062a12a0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,8 +49,8 @@ The most important directories: execution, active webhooks and workflows. **Contact n8n before starting on any changes here** -- [/packages/design-system](/packages/design-system) - Vue frontend components -- [/packages/editor-ui](/packages/editor-ui) - Vue frontend workflow editor +- [/packages/frontend/@n8n/design-system](/packages/design-system) - Vue frontend components +- [/packages/frontend/editor-ui](/packages/editor-ui) - Vue frontend workflow editor - [/packages/node-dev](/packages/node-dev) - CLI to create new n8n-nodes - [/packages/nodes-base](/packages/nodes-base) - Base n8n nodes - [/packages/workflow](/packages/workflow) - Workflow code with interfaces which diff --git a/codecov.yml b/codecov.yml index 3afef4c8a2..1d61749328 100644 --- a/codecov.yml +++ b/codecov.yml @@ -43,8 +43,6 @@ component_management: name: Frontend paths: - packages/@n8n/codemirror-lang/** - - packages/design-system/** - - packages/editor-ui/** - packages/frontend/** - component_id: nodes_packages name: Nodes diff --git a/cypress/.eslintrc.js b/cypress/.eslintrc.js index 36fc0af7e2..4d42f23154 100644 --- a/cypress/.eslintrc.js +++ b/cypress/.eslintrc.js @@ -1,10 +1,10 @@ -const sharedOptions = require('@n8n_io/eslint-config/shared'); +const sharedOptions = require('@n8n/eslint-config/shared'); /** * @type {import('@types/eslint').ESLint.ConfigData} */ module.exports = { - extends: ['@n8n_io/eslint-config/base', 'plugin:cypress/recommended'], + extends: ['@n8n/eslint-config/base', 'plugin:cypress/recommended'], ...sharedOptions(__dirname), diff --git a/cypress/composables/ndv.ts b/cypress/composables/ndv.ts index c70e0b20e8..36ae10669b 100644 --- a/cypress/composables/ndv.ts +++ b/cypress/composables/ndv.ts @@ -206,6 +206,10 @@ export function clickWorkflowCardContent(workflowName: string) { getWorkflowCardContent(workflowName).click(); } +export function clickAssignmentCollectionAdd() { + cy.getByTestId('assignment-collection-drop-area').click(); +} + export function assertNodeOutputHintExists() { getNodeOutputHint().should('exist'); } diff --git a/cypress/composables/workflow.ts b/cypress/composables/workflow.ts index 7d783c1d3c..ff9788349b 100644 --- a/cypress/composables/workflow.ts +++ b/cypress/composables/workflow.ts @@ -356,5 +356,5 @@ export function openContextMenu( } export function clickContextMenuAction(action: string) { - getContextMenuAction(action).click(); + getContextMenuAction(action).click({ force: true }); } diff --git a/cypress/constants.ts b/cypress/constants.ts index cbbf838530..8186b23db0 100644 --- a/cypress/constants.ts +++ b/cypress/constants.ts @@ -52,7 +52,7 @@ export const PIPEDRIVE_NODE_NAME = 'Pipedrive'; export const HTTP_REQUEST_NODE_NAME = 'HTTP Request'; export const AGENT_NODE_NAME = 'AI Agent'; export const BASIC_LLM_CHAIN_NODE_NAME = 'Basic LLM Chain'; -export const AI_MEMORY_WINDOW_BUFFER_MEMORY_NODE_NAME = 'Window Buffer Memory'; +export const AI_MEMORY_WINDOW_BUFFER_MEMORY_NODE_NAME = 'Simple Memory'; export const AI_TOOL_CALCULATOR_NODE_NAME = 'Calculator'; export const AI_TOOL_CODE_NODE_NAME = 'Code Tool'; export const AI_TOOL_WIKIPEDIA_NODE_NAME = 'Wikipedia'; diff --git a/cypress/cypress.config.js b/cypress/cypress.config.js index 63913af7f8..e7b953d6ca 100644 --- a/cypress/cypress.config.js +++ b/cypress/cypress.config.js @@ -14,6 +14,8 @@ module.exports = defineConfig({ experimentalMemoryManagement: true, e2e: { baseUrl: BASE_URL, + viewportWidth: 1536, + viewportHeight: 960, video: true, screenshotOnRunFailure: true, experimentalInteractiveRunEvents: true, diff --git a/cypress/e2e/1-workflows.cy.ts b/cypress/e2e/1-workflows.cy.ts index 82d1b9b90b..a972a21403 100644 --- a/cypress/e2e/1-workflows.cy.ts +++ b/cypress/e2e/1-workflows.cy.ts @@ -1,9 +1,11 @@ +import { WorkflowSharingModal } from '../pages'; import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; import { WorkflowsPage as WorkflowsPageClass } from '../pages/workflows'; import { getUniqueWorkflowName } from '../utils/workflowUtils'; const WorkflowsPage = new WorkflowsPageClass(); const WorkflowPage = new WorkflowPageClass(); +const workflowSharingModal = new WorkflowSharingModal(); const multipleWorkflowsCount = 5; @@ -138,4 +140,10 @@ describe('Workflows', () => { cy.url().should('include', 'sort=lastCreated'); cy.url().should('include', 'pageSize=25'); }); + + it('should be able to share workflows from workflows list', () => { + WorkflowsPage.getters.workflowCardActions('Empty State Card Workflow').click(); + WorkflowsPage.getters.workflowActionItem('share').click(); + workflowSharingModal.getters.modal().should('be.visible'); + }); }); diff --git a/cypress/e2e/10-undo-redo.cy.ts b/cypress/e2e/10-undo-redo.cy.ts index 2931897f03..bad159b1cb 100644 --- a/cypress/e2e/10-undo-redo.cy.ts +++ b/cypress/e2e/10-undo-redo.cy.ts @@ -22,7 +22,11 @@ describe('Undo/Redo', () => { it('should undo/redo deleting node using context menu', () => { WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - WorkflowPage.actions.deleteNodeFromContextMenu(CODE_NODE_NAME); + WorkflowPage.actions.zoomToFit(); + WorkflowPage.actions.deleteNodeFromContextMenu(CODE_NODE_NAME, { + method: 'right-click', + anchor: 'topLeft', + }); WorkflowPage.getters.canvasNodes().should('have.have.length', 1); WorkflowPage.getters.nodeConnections().should('have.length', 0); WorkflowPage.actions.hitUndo(); diff --git a/cypress/e2e/12-canvas.cy.ts b/cypress/e2e/12-canvas.cy.ts index be423344fb..3035a61ca2 100644 --- a/cypress/e2e/12-canvas.cy.ts +++ b/cypress/e2e/12-canvas.cy.ts @@ -17,6 +17,7 @@ import { openContextMenu, } from '../composables/workflow'; import { NDV, WorkflowExecutionsTab } from '../pages'; +import { clearNotifications, successToast } from '../pages/notifications'; import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; const WorkflowPage = new WorkflowPageClass(); @@ -235,7 +236,11 @@ describe('Canvas Node Manipulation and Navigation', () => { it('should delete node using context menu', () => { WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - WorkflowPage.actions.deleteNodeFromContextMenu(CODE_NODE_NAME); + WorkflowPage.actions.zoomToFit(); + WorkflowPage.actions.deleteNodeFromContextMenu(CODE_NODE_NAME, { + method: 'right-click', + anchor: 'topLeft', + }); WorkflowPage.getters.canvasNodes().should('have.length', 1); WorkflowPage.getters.nodeConnections().should('have.length', 0); }); @@ -379,6 +384,7 @@ describe('Canvas Node Manipulation and Navigation', () => { WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); + WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); // At this point last added node should be off-screen WorkflowPage.getters.canvasNodes().last().should('not.be.visible'); WorkflowPage.getters.zoomToFitButton().click(); @@ -485,6 +491,9 @@ describe('Canvas Node Manipulation and Navigation', () => { WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); WorkflowPage.actions.executeWorkflow(); + successToast().should('contain.text', 'Workflow executed successfully'); + clearNotifications(); + ExecutionsTab.actions.switchToExecutionsTab(); ExecutionsTab.getters.successfulExecutionListItems().should('have.length', 1); diff --git a/cypress/e2e/20-workflow-executions.cy.ts b/cypress/e2e/20-workflow-executions.cy.ts index 5788af171c..618334e2b6 100644 --- a/cypress/e2e/20-workflow-executions.cy.ts +++ b/cypress/e2e/20-workflow-executions.cy.ts @@ -27,8 +27,8 @@ describe('Workflow Executions', () => { executionsTab.getters.executionsList().scrollTo(0, 500).wait(0); - executionsTab.getters.executionListItems().should('have.length', 11); - executionsTab.getters.successfulExecutionListItems().should('have.length', 9); + executionsTab.getters.executionListItems().should('have.length', 30); + executionsTab.getters.successfulExecutionListItems().should('have.length', 28); executionsTab.getters.failedExecutionListItems().should('have.length', 2); executionsTab.getters .executionListItems() @@ -185,8 +185,9 @@ describe('Workflow Executions', () => { .invoke('attr', 'title') .should('eq', newWorkflowName); }); - - it('should load items and auto scroll after filter change', () => { + // This should be a component test. Abstracting this away into to ensure our lists work. + // eslint-disable-next-line n8n-local-rules/no-skipped-tests + it.skip('should load items and auto scroll after filter change', () => { createMockExecutions(); createMockExecutions(); cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions'); @@ -289,15 +290,20 @@ describe('Workflow Executions', () => { }); const createMockExecutions = () => { - executionsTab.actions.createManualExecutions(5); + executionsTab.actions.createManualExecutions(15); + // This wait is added to allow time for the notifications to expire + cy.wait(2000); // Make some failed executions by enabling Code node with syntax error executionsTab.actions.toggleNodeEnabled('Error'); workflowPage.getters.disabledNodes().should('have.length', 0); executionsTab.actions.createManualExecutions(2); + // This wait is added to allow time for the notifications to expire + cy.wait(2000); + // Then add some more successful ones executionsTab.actions.toggleNodeEnabled('Error'); workflowPage.getters.disabledNodes().should('have.length', 1); - executionsTab.actions.createManualExecutions(4); + executionsTab.actions.createManualExecutions(15); }; const checkMainHeaderELements = () => { diff --git a/cypress/e2e/39-projects.cy.ts b/cypress/e2e/39-projects.cy.ts index 09b9c2bb65..d8c618539f 100644 --- a/cypress/e2e/39-projects.cy.ts +++ b/cypress/e2e/39-projects.cy.ts @@ -54,11 +54,7 @@ describe('Projects', { disableAutoLogin: true }, () => { cy.changeQuota('maxTeamProjects', -1); }); - /** - * @TODO: New Canvas - Fix this test - */ - // eslint-disable-next-line n8n-local-rules/no-skipped-tests - it.skip('should filter credentials by project ID when creating new workflow or hard reloading an opened workflow', () => { + it('should filter credentials by project ID when creating new workflow or hard reloading an opened workflow', () => { cy.signinAsOwner(); cy.visit(workflowsPage.url); @@ -453,6 +449,8 @@ describe('Projects', { disableAutoLogin: true }, () => { }); it('should allow to change inaccessible credential when the workflow was moved to a team project', () => { + cy.intercept('GET', /\/rest\/(workflows|credentials).*/).as('getResources'); + cy.signinAsOwner(); cy.visit(workflowsPage.url); @@ -474,15 +472,14 @@ describe('Projects', { disableAutoLogin: true }, () => { // Create a project and add a user to it projects.createProject('Project 1'); projects.addProjectMember(INSTANCE_MEMBERS[0].email); + + clearNotifications(); projects.getProjectSettingsSaveButton().click(); // Move the workflow from Home to Project 1 projects.getHomeButton().click(); - workflowsPage.getters - .workflowCards() - .should('have.length', 1) - .filter(':contains("Personal")') - .should('exist'); + workflowsPage.getters.workflowCards().should('have.length', 1); + workflowsPage.getters.workflowCards().filter(':contains("Personal")').should('exist'); workflowsPage.getters.workflowCardActions('My workflow').click(); workflowsPage.getters.workflowMoveButton().click(); @@ -492,13 +489,13 @@ describe('Projects', { disableAutoLogin: true }, () => { .contains('button', 'Move workflow') .should('be.disabled'); projects.getProjectMoveSelect().click(); - getVisibleSelect() - .find('li') - .should('have.length', 4) - .filter(':contains("Project 1")') - .click(); + getVisibleSelect().find('li').should('have.length', 4); + getVisibleSelect().find('li').filter(':contains("Project 1")').click(); projects.getResourceMoveModal().contains('button', 'Move workflow').click(); + clearNotifications(); + cy.wait('@getResources'); + workflowsPage.getters .workflowCards() .should('have.length', 1) diff --git a/cypress/e2e/40-manual-partial-execution.cy.ts b/cypress/e2e/40-manual-partial-execution.cy.ts index 28a1539252..29c8f13e38 100644 --- a/cypress/e2e/40-manual-partial-execution.cy.ts +++ b/cypress/e2e/40-manual-partial-execution.cy.ts @@ -1,3 +1,16 @@ +import { + clickAssignmentCollectionAdd, + clickGetBackToCanvas, + getNodeRunInfoStale, + getOutputTbodyCell, +} from '../composables/ndv'; +import { + clickExecuteWorkflowButton, + getNodeByName, + getZoomToFitButton, + navigateToNewWorkflowPage, + openNode, +} from '../composables/workflow'; import { NDV, WorkflowPage } from '../pages'; const canvas = new WorkflowPage(); @@ -26,4 +39,53 @@ describe('Manual partial execution', () => { ndv.getters.nodeRunTooltipIndicator().should('not.exist'); ndv.getters.outputRunSelector().should('not.exist'); }); + + describe('partial execution v2', () => { + beforeEach(() => { + cy.window().then((win) => { + win.localStorage.setItem('PartialExecution.version', '2'); + }); + navigateToNewWorkflowPage(); + }); + + it('should execute from the first dirty node up to the current node', () => { + cy.createFixtureWorkflow('Test_workflow_partial_execution_v2.json'); + + getZoomToFitButton().click(); + + // First, execute the whole workflow + clickExecuteWorkflowButton(); + + getNodeByName('A').findChildByTestId('canvas-node-status-success').should('be.visible'); + getNodeByName('B').findChildByTestId('canvas-node-status-success').should('be.visible'); + getNodeByName('C').findChildByTestId('canvas-node-status-success').should('be.visible'); + openNode('A'); + getOutputTbodyCell(1, 0).invoke('text').as('before', { type: 'static' }); + clickGetBackToCanvas(); + + // Change parameter of the node in the middle + openNode('B'); + clickAssignmentCollectionAdd(); + getNodeRunInfoStale().should('be.visible'); + clickGetBackToCanvas(); + + getNodeByName('A').findChildByTestId('canvas-node-status-success').should('be.visible'); + getNodeByName('B').findChildByTestId('canvas-node-status-warning').should('be.visible'); + getNodeByName('C').findChildByTestId('canvas-node-status-success').should('be.visible'); + + // Partial execution + getNodeByName('C').findChildByTestId('execute-node-button').click(); + + getNodeByName('A').findChildByTestId('canvas-node-status-success').should('be.visible'); + getNodeByName('B').findChildByTestId('canvas-node-status-success').should('be.visible'); + getNodeByName('C').findChildByTestId('canvas-node-status-success').should('be.visible'); + openNode('A'); + getOutputTbodyCell(1, 0).invoke('text').as('after', { type: 'static' }); + + // Assert that 'A' ran only once by comparing its output + cy.get('@before').then((before) => + cy.get('@after').then((after) => expect(before).to.equal(after)), + ); + }); + }); }); diff --git a/cypress/fixtures/Test-workflow-with-long-parameters.json b/cypress/fixtures/Test-workflow-with-long-parameters.json index d4d052f6f0..3c64a184b5 100644 --- a/cypress/fixtures/Test-workflow-with-long-parameters.json +++ b/cypress/fixtures/Test-workflow-with-long-parameters.json @@ -25,6 +25,18 @@ "value": "test", "type": "string" }, + { + "id": "85095836-4e94-442f-9270-e1a89008c125", + "name": "test", + "value": "test", + "type": "string" + }, + { + "id": "85095836-4e94-442f-9270-e1a89008c121", + "name": "test", + "value": "test", + "type": "string" + }, { "id": "b6163f8a-bca6-4364-8b38-182df37c55cd", "name": "=should be visible!", @@ -50,6 +62,10 @@ "blocksUi": "blocks", "text": "=should be visible", "otherOptions": { + "includeLinkToWorkflow": true, + "link_names": false, + "mrkdwn": true, + "unfurl_links": false, "sendAsUser": "=not visible" } }, @@ -67,6 +83,7 @@ "parameters": { "rule": { "interval": [ + {}, {}, { "field": "=should be visible" diff --git a/cypress/fixtures/Test_workflow_partial_execution_v2.json b/cypress/fixtures/Test_workflow_partial_execution_v2.json new file mode 100644 index 0000000000..c3c8ecc7ae --- /dev/null +++ b/cypress/fixtures/Test_workflow_partial_execution_v2.json @@ -0,0 +1,74 @@ +{ + "nodes": [ + { + "parameters": { + "rule": { + "interval": [{}] + } + }, + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [0, 0], + "id": "dcc1c5e1-c6c1-45f8-80d5-65c88d66d56e", + "name": "A" + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "3d8f0810-84f0-41ce-a81b-0e7f04fd88cb", + "name": "", + "value": "", + "type": "string" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [220, 0], + "id": "097ffa30-d37b-4de6-bd5c-ccd945f31df1", + "name": "B" + }, + { + "parameters": { + "options": {} + }, + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [440, 0], + "id": "dc44e635-916f-4f76-a745-1add5762f730", + "name": "C" + } + ], + "connections": { + "A": { + "main": [ + [ + { + "node": "B", + "type": "main", + "index": 0 + } + ] + ] + }, + "B": { + "main": [ + [ + { + "node": "C", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": {}, + "meta": { + "instanceId": "b0d9447cff9c96796e4ac4f00fcd899b03cfac3ab3d4f748ae686d34881eae0c" + } +} diff --git a/cypress/pages/notifications.ts b/cypress/pages/notifications.ts index 46db264633..2c3648355a 100644 --- a/cypress/pages/notifications.ts +++ b/cypress/pages/notifications.ts @@ -16,9 +16,11 @@ export const clearNotifications = () => { const notificationSelector = '.el-notification:has(.el-notification--success)'; cy.get('body').then(($body) => { if ($body.find(notificationSelector).length) { - cy.get(notificationSelector) - .find('.el-notification__closeBtn') - .click({ multiple: true, force: true }); + cy.get(notificationSelector).each(($el) => { + if ($el.find('.el-notification__closeBtn').length) { + cy.wrap($el).find('.el-notification__closeBtn').click({ force: true }); + } + }); } }); }; diff --git a/cypress/pages/workflow.ts b/cypress/pages/workflow.ts index 24fda156f6..d34109f1b2 100644 --- a/cypress/pages/workflow.ts +++ b/cypress/pages/workflow.ts @@ -306,8 +306,8 @@ export class WorkflowPage extends BasePage { this.actions.openContextMenu(nodeTypeName); clickContextMenuAction('duplicate'); }, - deleteNodeFromContextMenu: (nodeTypeName: string) => { - this.actions.openContextMenu(nodeTypeName); + deleteNodeFromContextMenu: (nodeTypeName: string, options?: OpenContextMenuOptions) => { + this.actions.openContextMenu(nodeTypeName, options); clickContextMenuAction('delete'); }, executeNode: (nodeTypeName: string, options?: OpenContextMenuOptions) => { diff --git a/cypress/pages/workflows.ts b/cypress/pages/workflows.ts index 87507c80a1..f93e3e0103 100644 --- a/cypress/pages/workflows.ts +++ b/cypress/pages/workflows.ts @@ -35,6 +35,7 @@ export class WorkflowsPage extends BasePage { this.getters.workflowActivator(workflowName).findChildByTestId('workflow-activator-status'), workflowCardActions: (workflowName: string) => this.getters.workflowCard(workflowName).findChildByTestId('workflow-card-actions'), + workflowActionItem: (action: string) => cy.getByTestId(`action-${action}`).filter(':visible'), workflowDeleteButton: () => cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Delete'), workflowMoveButton: () => diff --git a/docker/images/n8n/Dockerfile b/docker/images/n8n/Dockerfile index 8edff951d4..e81732dda2 100644 --- a/docker/images/n8n/Dockerfile +++ b/docker/images/n8n/Dockerfile @@ -20,7 +20,7 @@ RUN set -eux; \ npm install -g --omit=dev n8n@${N8N_VERSION} --ignore-scripts && \ npm rebuild --prefix=/usr/local/lib/node_modules/n8n sqlite3 && \ rm -rf /usr/local/lib/node_modules/n8n/node_modules/@n8n/chat && \ - rm -rf /usr/local/lib/node_modules/n8n/node_modules/n8n-design-system && \ + rm -rf /usr/local/lib/node_modules/n8n/node_modules/@n8n/design-system && \ rm -rf /usr/local/lib/node_modules/n8n/node_modules/n8n-editor-ui/node_modules && \ find /usr/local/lib/node_modules/n8n -type f -name "*.ts" -o -name "*.js.map" -o -name "*.vue" | xargs rm -f && \ rm -rf /root/.npm diff --git a/jest.config.js b/jest.config.js index 3caac38ef9..d1d8f95e12 100644 --- a/jest.config.js +++ b/jest.config.js @@ -12,6 +12,8 @@ const tsJestOptions = { const { baseUrl, paths } = require('get-tsconfig').getTsconfig().config?.compilerOptions; +const isCoverageEnabled = process.env.COVERAGE_ENABLED === 'true'; + /** @type {import('jest').Config} */ const config = { verbose: true, @@ -32,8 +34,8 @@ const config = { return acc; }, {}), setupFilesAfterEnv: ['jest-expect-message'], - collectCoverage: process.env.COVERAGE_ENABLED === 'true', - coverageReporters: ['text-summary'], + collectCoverage: isCoverageEnabled, + coverageReporters: ['text-summary', 'lcov', 'html-spa'], collectCoverageFrom: ['src/**/*.ts'], }; diff --git a/package.json b/package.json index 200ada1b88..cf9e4cb9ea 100644 --- a/package.json +++ b/package.json @@ -15,10 +15,10 @@ "build:frontend": "turbo run build:frontend", "build:nodes": "turbo run build:nodes", "typecheck": "turbo typecheck", - "dev": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat --filter=!@n8n/task-runner", - "dev:be": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat --filter=!@n8n/task-runner --filter=!n8n-editor-ui", + "dev": "turbo run dev --parallel --env-mode=loose --filter=!@n8n/design-system --filter=!@n8n/chat --filter=!@n8n/task-runner", + "dev:be": "turbo run dev --parallel --env-mode=loose --filter=!@n8n/design-system --filter=!@n8n/chat --filter=!@n8n/task-runner --filter=!n8n-editor-ui", "dev:ai": "turbo run dev --parallel --env-mode=loose --filter=@n8n/nodes-langchain --filter=n8n --filter=n8n-core", - "dev:fe": "run-p start \"dev:fe:editor --filter=n8n-design-system\"", + "dev:fe": "run-p start \"dev:fe:editor --filter=@n8n/design-system\"", "dev:fe:editor": "turbo run dev --parallel --env-mode=loose --filter=n8n-editor-ui", "dev:e2e": "cd cypress && pnpm run test:e2e:dev", "dev:e2e:v1": "cd cypress && pnpm run test:e2e:dev:v1", @@ -47,7 +47,7 @@ }, "devDependencies": { "@biomejs/biome": "^1.9.0", - "@n8n_io/eslint-config": "workspace:*", + "@n8n/eslint-config": "workspace:*", "@types/jest": "^29.5.3", "@types/node": "*", "@types/supertest": "^6.0.2", @@ -96,7 +96,8 @@ "@types/express-serve-static-core@4.17.43": "patches/@types__express-serve-static-core@4.17.43.patch", "@types/ws@8.5.4": "patches/@types__ws@8.5.4.patch", "@types/uuencode@0.0.3": "patches/@types__uuencode@0.0.3.patch", - "vue-tsc@2.1.10": "patches/vue-tsc@2.1.10.patch" + "vue-tsc@2.1.10": "patches/vue-tsc@2.1.10.patch", + "eslint-plugin-n8n-local-rules": "patches/eslint-plugin-n8n-local-rules.patch" } } } diff --git a/packages/@n8n/api-types/.eslintrc.js b/packages/@n8n/api-types/.eslintrc.js index 1c42fddcdc..8c5b78c5da 100644 --- a/packages/@n8n/api-types/.eslintrc.js +++ b/packages/@n8n/api-types/.eslintrc.js @@ -1,7 +1,7 @@ -const sharedOptions = require('@n8n_io/eslint-config/shared'); +const sharedOptions = require('@n8n/eslint-config/shared'); /** @type {import('@types/eslint').ESLint.ConfigData} */ module.exports = { - extends: ['@n8n_io/eslint-config/base'], + extends: ['@n8n/eslint-config/base'], ...sharedOptions(__dirname), }; diff --git a/packages/@n8n/api-types/src/dto/folders/__tests__/update-folder.request.dto.test.ts b/packages/@n8n/api-types/src/dto/folders/__tests__/update-folder.request.dto.test.ts new file mode 100644 index 0000000000..28067c1a25 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/folders/__tests__/update-folder.request.dto.test.ts @@ -0,0 +1,63 @@ +import { UpdateFolderDto } from '../update-folder.dto'; + +describe('UpdateFolderDto', () => { + describe('Valid requests', () => { + test.each([ + { + name: 'name', + request: { + name: 'test', + }, + }, + { + name: 'tagIds', + request: { + tagIds: ['1', '2'], + }, + }, + { + name: 'empty tagIds', + request: { + tagIds: [], + }, + }, + ])('should validate $name', ({ request }) => { + const result = UpdateFolderDto.safeParse(request); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'empty name', + request: { + name: '', + }, + expectedErrorPath: ['name'], + }, + { + name: 'non string tagIds', + request: { + tagIds: [0], + }, + expectedErrorPath: ['tagIds'], + }, + { + name: 'non array tagIds', + request: { + tagIds: 0, + }, + expectedErrorPath: ['tagIds'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = UpdateFolderDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path[0]).toEqual(expectedErrorPath[0]); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/folders/create-folder.dto.ts b/packages/@n8n/api-types/src/dto/folders/create-folder.dto.ts index d0c59eaf54..a673284f51 100644 --- a/packages/@n8n/api-types/src/dto/folders/create-folder.dto.ts +++ b/packages/@n8n/api-types/src/dto/folders/create-folder.dto.ts @@ -1,7 +1,8 @@ -import { z } from 'zod'; import { Z } from 'zod-class'; +import { folderNameSchema, folderId } from '../../schemas/folder.schema'; + export class CreateFolderDto extends Z.class({ - name: z.string().trim().min(1).max(128), - parentFolderId: z.string().optional(), + name: folderNameSchema, + parentFolderId: folderId.optional(), }) {} diff --git a/packages/@n8n/api-types/src/dto/folders/delete-folder.dto.ts b/packages/@n8n/api-types/src/dto/folders/delete-folder.dto.ts new file mode 100644 index 0000000000..c03659ad18 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/folders/delete-folder.dto.ts @@ -0,0 +1,7 @@ +import { Z } from 'zod-class'; + +import { folderId } from '../../schemas/folder.schema'; + +export class DeleteFolderDto extends Z.class({ + transferToFolderId: folderId.optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/folders/update-folder.dto.ts b/packages/@n8n/api-types/src/dto/folders/update-folder.dto.ts new file mode 100644 index 0000000000..f002f6aa00 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/folders/update-folder.dto.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +import { folderNameSchema } from '../../schemas/folder.schema'; +export class UpdateFolderDto extends Z.class({ + name: folderNameSchema.optional(), + tagIds: z.array(z.string().max(24)).optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index dd3fd20fac..f2dd481ee2 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -56,3 +56,5 @@ export { UpdateApiKeyRequestDto } from './api-keys/update-api-key-request.dto'; export { CreateApiKeyRequestDto } from './api-keys/create-api-key-request.dto'; export { CreateFolderDto } from './folders/create-folder.dto'; +export { UpdateFolderDto } from './folders/update-folder.dto'; +export { DeleteFolderDto } from './folders/delete-folder.dto'; diff --git a/packages/@n8n/api-types/src/schemas/folder.schema.ts b/packages/@n8n/api-types/src/schemas/folder.schema.ts new file mode 100644 index 0000000000..4544a1b58c --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/folder.schema.ts @@ -0,0 +1,4 @@ +import { z } from 'zod'; + +export const folderNameSchema = z.string().trim().min(1).max(128); +export const folderId = z.string().max(36); diff --git a/packages/@n8n/benchmark/.eslintrc.js b/packages/@n8n/benchmark/.eslintrc.js index 4d740c55b3..83698e4feb 100644 --- a/packages/@n8n/benchmark/.eslintrc.js +++ b/packages/@n8n/benchmark/.eslintrc.js @@ -1,10 +1,10 @@ -const sharedOptions = require('@n8n_io/eslint-config/shared'); +const sharedOptions = require('@n8n/eslint-config/shared'); /** * @type {import('@types/eslint').ESLint.ConfigData} */ module.exports = { - extends: ['@n8n_io/eslint-config/node'], + extends: ['@n8n/eslint-config/node'], ...sharedOptions(__dirname), diff --git a/packages/@n8n/benchmark/Dockerfile b/packages/@n8n/benchmark/Dockerfile index 8275bbe415..8c759cbab6 100644 --- a/packages/@n8n/benchmark/Dockerfile +++ b/packages/@n8n/benchmark/Dockerfile @@ -37,9 +37,9 @@ ENV DOCKER_BUILD=true RUN pnpm install --frozen-lockfile # TS config files -COPY --chown=node:node ./tsconfig.json /app/tsconfig.json -COPY --chown=node:node ./tsconfig.build.json /app/tsconfig.build.json -COPY --chown=node:node ./tsconfig.backend.json /app/tsconfig.backend.json +COPY --chown=node:node ./packages/@n8n/typescript-config/tsconfig.common.json /app/packages/@n8n/typescript-config/tsconfig.common.json +COPY --chown=node:node ./packages/@n8n/typescript-config/tsconfig.build.json /app/packages/@n8n/typescript-config/tsconfig.build.json +COPY --chown=node:node ./packages/@n8n/typescript-config/tsconfig.backend.json /app/packages/@n8n/typescript-config/tsconfig.backend.json COPY --chown=node:node ./packages/@n8n/benchmark/tsconfig.json /app/packages/@n8n/benchmark/tsconfig.json COPY --chown=node:node ./packages/@n8n/benchmark/tsconfig.build.json /app/packages/@n8n/benchmark/tsconfig.build.json diff --git a/packages/@n8n/client-oauth2/.eslintrc.js b/packages/@n8n/client-oauth2/.eslintrc.js index be8ebd21d1..c6e71ea4d0 100644 --- a/packages/@n8n/client-oauth2/.eslintrc.js +++ b/packages/@n8n/client-oauth2/.eslintrc.js @@ -1,10 +1,10 @@ -const sharedOptions = require('@n8n_io/eslint-config/shared'); +const sharedOptions = require('@n8n/eslint-config/shared'); /** * @type {import('@types/eslint').ESLint.ConfigData} */ module.exports = { - extends: ['@n8n_io/eslint-config/base'], + extends: ['@n8n/eslint-config/base'], ...sharedOptions(__dirname), diff --git a/packages/@n8n/codemirror-lang/.eslintrc.cjs b/packages/@n8n/codemirror-lang/.eslintrc.cjs index d07f6ff6fa..8d407e73db 100644 --- a/packages/@n8n/codemirror-lang/.eslintrc.cjs +++ b/packages/@n8n/codemirror-lang/.eslintrc.cjs @@ -1,10 +1,10 @@ -const sharedOptions = require('@n8n_io/eslint-config/shared'); +const sharedOptions = require('@n8n/eslint-config/shared'); /** * @type {import('@types/eslint').ESLint.ConfigData} */ module.exports = { - extends: ['@n8n_io/eslint-config/base'], + extends: ['@n8n/eslint-config/base'], ...sharedOptions(__dirname), diff --git a/packages/@n8n/config/.eslintrc.js b/packages/@n8n/config/.eslintrc.js index e5a8f3f0f9..0c43755bd0 100644 --- a/packages/@n8n/config/.eslintrc.js +++ b/packages/@n8n/config/.eslintrc.js @@ -1,10 +1,10 @@ -const sharedOptions = require('@n8n_io/eslint-config/shared'); +const sharedOptions = require('@n8n/eslint-config/shared'); /** * @type {import('@types/eslint').ESLint.ConfigData} */ module.exports = { - extends: ['@n8n_io/eslint-config/node'], + extends: ['@n8n/eslint-config/node'], ...sharedOptions(__dirname), diff --git a/packages/@n8n/config/src/configs/endpoints.config.ts b/packages/@n8n/config/src/configs/endpoints.config.ts index 4ec58ccf0d..994343c650 100644 --- a/packages/@n8n/config/src/configs/endpoints.config.ts +++ b/packages/@n8n/config/src/configs/endpoints.config.ts @@ -57,6 +57,10 @@ class PrometheusMetricsConfig { /** How often (in seconds) to update queue metrics. */ @Env('N8N_METRICS_QUEUE_METRICS_INTERVAL') queueMetricsInterval: number = 20; + + /** How often (in seconds) to update active workflow metric */ + @Env('N8N_METRICS_ACTIVE_WORKFLOW_METRIC_INTERVAL') + activeWorkflowCountInterval: number = 60; } @Config diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index 735cb4bfcf..626c5f4d62 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -173,6 +173,7 @@ describe('GlobalConfig', () => { includeApiStatusCodeLabel: false, includeQueueMetrics: false, queueMetricsInterval: 20, + activeWorkflowCountInterval: 60, }, additionalNonUIRoutes: '', disableProductionWebhooksOnMainProcess: false, diff --git a/packages/@n8n/di/.eslintrc.js b/packages/@n8n/di/.eslintrc.js index 1c42fddcdc..8c5b78c5da 100644 --- a/packages/@n8n/di/.eslintrc.js +++ b/packages/@n8n/di/.eslintrc.js @@ -1,7 +1,7 @@ -const sharedOptions = require('@n8n_io/eslint-config/shared'); +const sharedOptions = require('@n8n/eslint-config/shared'); /** @type {import('@types/eslint').ESLint.ConfigData} */ module.exports = { - extends: ['@n8n_io/eslint-config/base'], + extends: ['@n8n/eslint-config/base'], ...sharedOptions(__dirname), }; diff --git a/packages/@n8n_io/eslint-config/base.js b/packages/@n8n/eslint-config/base.js similarity index 100% rename from packages/@n8n_io/eslint-config/base.js rename to packages/@n8n/eslint-config/base.js diff --git a/packages/@n8n_io/eslint-config/frontend.js b/packages/@n8n/eslint-config/frontend.js similarity index 100% rename from packages/@n8n_io/eslint-config/frontend.js rename to packages/@n8n/eslint-config/frontend.js diff --git a/packages/@n8n_io/eslint-config/jest.config.js b/packages/@n8n/eslint-config/jest.config.js similarity index 100% rename from packages/@n8n_io/eslint-config/jest.config.js rename to packages/@n8n/eslint-config/jest.config.js diff --git a/packages/@n8n_io/eslint-config/local-rules.js b/packages/@n8n/eslint-config/local-rules.js similarity index 99% rename from packages/@n8n_io/eslint-config/local-rules.js rename to packages/@n8n/eslint-config/local-rules.js index d32d72f89c..7d169a7192 100644 --- a/packages/@n8n_io/eslint-config/local-rules.js +++ b/packages/@n8n/eslint-config/local-rules.js @@ -320,7 +320,7 @@ module.exports = { const LOCALE_NAMESPACE = '$locale'; const LOCALE_FILEPATH = cwd.endsWith('editor-ui') ? path.join(cwd, locale) - : path.join(cwd, 'packages/editor-ui', locale); + : path.join(cwd, 'packages/frontend/editor-ui', locale); let LOCALE_MAP; diff --git a/packages/@n8n_io/eslint-config/local-rules.test.js b/packages/@n8n/eslint-config/local-rules.test.js similarity index 100% rename from packages/@n8n_io/eslint-config/local-rules.test.js rename to packages/@n8n/eslint-config/local-rules.test.js diff --git a/packages/@n8n_io/eslint-config/node.js b/packages/@n8n/eslint-config/node.js similarity index 100% rename from packages/@n8n_io/eslint-config/node.js rename to packages/@n8n/eslint-config/node.js diff --git a/packages/@n8n_io/eslint-config/package.json b/packages/@n8n/eslint-config/package.json similarity index 78% rename from packages/@n8n_io/eslint-config/package.json rename to packages/@n8n/eslint-config/package.json index aac4ea2e32..84b2e24633 100644 --- a/packages/@n8n_io/eslint-config/package.json +++ b/packages/@n8n/eslint-config/package.json @@ -1,7 +1,14 @@ { - "name": "@n8n_io/eslint-config", + "name": "@n8n/eslint-config", "private": true, "version": "0.0.1", + "exports": { + "./base": "./base.js", + "./frontend": "./frontend.js", + "./local-rules": "./local-rules.js", + "./node": "./node.js", + "./shared": "./shared.js" + }, "devDependencies": { "@types/eslint": "^8.56.5", "@typescript-eslint/eslint-plugin": "^7.2.0", diff --git a/packages/@n8n_io/eslint-config/shared.js b/packages/@n8n/eslint-config/shared.js similarity index 100% rename from packages/@n8n_io/eslint-config/shared.js rename to packages/@n8n/eslint-config/shared.js diff --git a/packages/@n8n/imap/.eslintrc.js b/packages/@n8n/imap/.eslintrc.js index c3fe283453..1cc57e8288 100644 --- a/packages/@n8n/imap/.eslintrc.js +++ b/packages/@n8n/imap/.eslintrc.js @@ -1,10 +1,10 @@ -const sharedOptions = require('@n8n_io/eslint-config/shared'); +const sharedOptions = require('@n8n/eslint-config/shared'); /** * @type {import('@types/eslint').ESLint.ConfigData} */ module.exports = { - extends: ['@n8n_io/eslint-config/base'], + extends: ['@n8n/eslint-config/base'], ...sharedOptions(__dirname), diff --git a/packages/@n8n/json-schema-to-zod/.eslintrc.js b/packages/@n8n/json-schema-to-zod/.eslintrc.js index 03caaf4930..039086a8bc 100644 --- a/packages/@n8n/json-schema-to-zod/.eslintrc.js +++ b/packages/@n8n/json-schema-to-zod/.eslintrc.js @@ -1,10 +1,10 @@ -const sharedOptions = require('@n8n_io/eslint-config/shared'); +const sharedOptions = require('@n8n/eslint-config/shared'); /** * @type {import('@types/eslint').ESLint.ConfigData} */ module.exports = { - extends: ['@n8n_io/eslint-config/node'], + extends: ['@n8n/eslint-config/node'], ...sharedOptions(__dirname), diff --git a/packages/@n8n/nodes-langchain/.eslintrc.js b/packages/@n8n/nodes-langchain/.eslintrc.js index 510b970755..b08c88ce40 100644 --- a/packages/@n8n/nodes-langchain/.eslintrc.js +++ b/packages/@n8n/nodes-langchain/.eslintrc.js @@ -1,10 +1,10 @@ -const sharedOptions = require('@n8n_io/eslint-config/shared'); +const sharedOptions = require('@n8n/eslint-config/shared'); /** * @type {import('@types/eslint').ESLint.ConfigData} */ module.exports = { - extends: ['@n8n_io/eslint-config/node'], + extends: ['@n8n/eslint-config/node'], ...sharedOptions(__dirname), diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts index c5a46a1192..45c9d10e8c 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts @@ -27,7 +27,13 @@ import { toolsAgentExecute } from './agents/ToolsAgent/execute'; // Function used in the inputs expression to figure out which inputs to // display based on the agent type function getInputs( - agent: 'toolsAgent' | 'conversationalAgent' | 'openAiFunctionsAgent' | 'reActAgent' | 'sqlAgent', + agent: + | 'toolsAgent' + | 'conversationalAgent' + | 'openAiFunctionsAgent' + | 'planAndExecuteAgent' + | 'reActAgent' + | 'sqlAgent', hasOutputParser?: boolean, ): Array { interface SpecialInput { @@ -256,7 +262,7 @@ export class Agent implements INodeType { icon: 'fa:robot', iconColor: 'black', group: ['transform'], - version: [1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7], + version: [1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8], description: 'Generates an action plan and executes it. Can use external tools.', subtitle: "={{ { toolsAgent: 'Tools Agent', conversationalAgent: 'Conversational Agent', openAiFunctionsAgent: 'OpenAI Functions Agent', reActAgent: 'ReAct Agent', sqlAgent: 'SQL Agent', planAndExecuteAgent: 'Plan and Execute Agent' }[$parameter.agent] }}", @@ -322,6 +328,24 @@ export class Agent implements INodeType { }, }, }, + { + displayName: + "This node is using Agent that has been deprecated. Please switch to using 'Tools Agent' instead.", + name: 'deprecated', + type: 'notice', + default: '', + displayOptions: { + show: { + agent: [ + 'conversationalAgent', + 'openAiFunctionsAgent', + 'planAndExecuteAgent', + 'reActAgent', + 'sqlAgent', + ], + }, + }, + }, // Make Conversational Agent the default agent for versions 1.5 and below { ...agentTypeProperty, @@ -331,10 +355,17 @@ export class Agent implements INodeType { displayOptions: { show: { '@version': [{ _cnd: { lte: 1.5 } }] } }, default: 'conversationalAgent', }, - // Make Tools Agent the default agent for versions 1.6 and above + // Make Tools Agent the default agent for versions 1.6 and 1.7 { ...agentTypeProperty, - displayOptions: { show: { '@version': [{ _cnd: { gte: 1.6 } }] } }, + displayOptions: { show: { '@version': [{ _cnd: { between: { from: 1.6, to: 1.7 } } }] } }, + default: 'toolsAgent', + }, + // Make Tools Agent the only agent option for versions 1.8 and above + { + ...agentTypeProperty, + type: 'hidden', + displayOptions: { show: { '@version': [{ _cnd: { gte: 1.8 } }] } }, default: 'toolsAgent', }, { diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/description.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/description.ts index 890d83dc4a..20e74b07c2 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/description.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/description.ts @@ -96,6 +96,13 @@ export const reActAgentAgentProperties: INodeProperties[] = [ rows: 6, }, }, + { + displayName: 'Max Iterations', + name: 'maxIterations', + type: 'number', + default: 10, + description: 'The maximum number of iterations the agent will run before stopping', + }, { displayName: 'Return Intermediate Steps', name: 'returnIntermediateSteps', diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/execute.ts index 4db35634d6..32676af5ee 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ReActAgent/execute.ts @@ -38,6 +38,7 @@ export async function reActAgentAgentExecute( prefix?: string; suffix?: string; suffixChat?: string; + maxIterations?: number; humanMessageTemplate?: string; returnIntermediateSteps?: boolean; }; @@ -60,6 +61,7 @@ export async function reActAgentAgentExecute( agent, tools, returnIntermediateSteps: options?.returnIntermediateSteps === true, + maxIterations: options.maxIterations ?? 10, }); const returnData: INodeExecutionData[] = []; diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts index f81ab9526c..543b6da49a 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts @@ -392,13 +392,14 @@ export async function toolsAgentExecute(this: IExecuteFunctions): PromiseLearn more.', + displayOptions: { + show: { + '@version': [{ _cnd: { gte: 1.3 } }], }, }, }, @@ -150,7 +195,7 @@ export class LmChatAnthropic implements INodeType { { displayName: 'Maximum Number of Tokens', name: 'maxTokensToSample', - default: 4096, + default: DEFAULT_MAX_TOKENS, description: 'The maximum number of tokens to generate in the completion', type: 'number', }, @@ -162,6 +207,11 @@ export class LmChatAnthropic implements INodeType { description: 'Controls randomness: Lowering results in less random completions. As the temperature approaches zero, the model will become deterministic and repetitive.', type: 'number', + displayOptions: { + hide: { + thinking: [true], + }, + }, }, { displayName: 'Top K', @@ -171,6 +221,11 @@ export class LmChatAnthropic implements INodeType { description: 'Used to remove "long tail" low probability responses. Defaults to -1, which disables it.', type: 'number', + displayOptions: { + hide: { + thinking: [true], + }, + }, }, { displayName: 'Top P', @@ -180,6 +235,30 @@ export class LmChatAnthropic implements INodeType { description: 'Controls diversity via nucleus sampling: 0.5 means half of all likelihood-weighted options are considered. We generally recommend altering this or temperature but not both.', type: 'number', + displayOptions: { + hide: { + thinking: [true], + }, + }, + }, + { + displayName: 'Enable Thinking', + name: 'thinking', + type: 'boolean', + default: false, + description: 'Whether to enable thinking mode for the model', + }, + { + displayName: 'Thinking Budget (Tokens)', + name: 'thinkingBudget', + type: 'number', + default: MIN_THINKING_BUDGET, + description: 'The maximum number of tokens to use for thinking', + displayOptions: { + show: { + thinking: [true], + }, + }, }, ], }, @@ -189,13 +268,21 @@ export class LmChatAnthropic implements INodeType { async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { const credentials = await this.getCredentials('anthropicApi'); - const modelName = this.getNodeParameter('model', itemIndex) as string; + const version = this.getNode().typeVersion; + const modelName = + version >= 1.3 + ? (this.getNodeParameter('model.value', itemIndex) as string) + : (this.getNodeParameter('model', itemIndex) as string); + const options = this.getNodeParameter('options', itemIndex, {}) as { maxTokensToSample?: number; temperature: number; - topK: number; - topP: number; + topK?: number; + topP?: number; + thinking?: boolean; + thinkingBudget?: number; }; + let invocationKwargs = {}; const tokensUsageParser = (llmOutput: LLMResult['llmOutput']) => { const usage = (llmOutput?.usage as { input_tokens: number; output_tokens: number }) ?? { @@ -208,6 +295,27 @@ export class LmChatAnthropic implements INodeType { totalTokens: usage.input_tokens + usage.output_tokens, }; }; + + if (options.thinking) { + invocationKwargs = { + thinking: { + type: 'enabled', + // If thinking is enabled, we need to set a budget. + // We fallback to 1024 as that is the minimum + budget_tokens: options.thinkingBudget ?? MIN_THINKING_BUDGET, + }, + // The default Langchain max_tokens is -1 (no limit) but Anthropic requires a number + // higher than budget_tokens + max_tokens: options.maxTokensToSample ?? DEFAULT_MAX_TOKENS, + // These need to be unset when thinking is enabled. + // Because the invocationKwargs will override the model options + // we can pass options to the model and then override them here + top_k: undefined, + top_p: undefined, + temperature: undefined, + }; + } + const model = new ChatAnthropic({ anthropicApiKey: credentials.apiKey as string, modelName, @@ -217,6 +325,7 @@ export class LmChatAnthropic implements INodeType { topP: options.topP, callbacks: [new N8nLlmTracing(this, { tokensUsageParser })], onFailedAttempt: makeN8nLlmFailedAttemptHandler(this), + invocationKwargs, }); return { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/methods/__tests__/searchModels.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/methods/__tests__/searchModels.test.ts new file mode 100644 index 0000000000..e1cc8e75a3 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/methods/__tests__/searchModels.test.ts @@ -0,0 +1,105 @@ +import type { ILoadOptionsFunctions } from 'n8n-workflow'; + +import { searchModels, type AnthropicModel } from '../searchModels'; + +describe('searchModels', () => { + let mockContext: jest.Mocked; + + const mockModels: AnthropicModel[] = [ + { + id: 'claude-3-opus-20240229', + display_name: 'Claude 3 Opus', + type: 'model', + created_at: '2024-02-29T00:00:00Z', + }, + { + id: 'claude-3-sonnet-20240229', + display_name: 'Claude 3 Sonnet', + type: 'model', + created_at: '2024-02-29T00:00:00Z', + }, + { + id: 'claude-3-haiku-20240307', + display_name: 'Claude 3 Haiku', + type: 'model', + created_at: '2024-03-07T00:00:00Z', + }, + { + id: 'claude-2.1', + display_name: 'Claude 2.1', + type: 'model', + created_at: '2023-11-21T00:00:00Z', + }, + { + id: 'claude-2.0', + display_name: 'Claude 2.0', + type: 'model', + created_at: '2023-07-11T00:00:00Z', + }, + ]; + + beforeEach(() => { + mockContext = { + helpers: { + httpRequestWithAuthentication: jest.fn().mockResolvedValue({ + data: mockModels, + }), + }, + } as unknown as jest.Mocked; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should fetch models from Anthropic API', async () => { + const result = await searchModels.call(mockContext); + + expect(mockContext.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith('anthropicApi', { + url: 'https://api.anthropic.com/v1/models', + headers: { + 'anthropic-version': '2023-06-01', + }, + }); + expect(result.results).toHaveLength(5); + }); + + it('should sort models by created_at date, most recent first', async () => { + const result = await searchModels.call(mockContext); + const sortedResults = result.results; + + expect(sortedResults[0].value).toBe('claude-3-haiku-20240307'); + expect(sortedResults[1].value).toBe('claude-3-opus-20240229'); + expect(sortedResults[2].value).toBe('claude-3-sonnet-20240229'); + expect(sortedResults[3].value).toBe('claude-2.1'); + expect(sortedResults[4].value).toBe('claude-2.0'); + }); + + it('should filter models based on search term', async () => { + const result = await searchModels.call(mockContext, 'claude-3'); + + expect(result.results).toHaveLength(3); + expect(result.results).toEqual([ + { name: 'Claude 3 Haiku', value: 'claude-3-haiku-20240307' }, + { name: 'Claude 3 Opus', value: 'claude-3-opus-20240229' }, + { name: 'Claude 3 Sonnet', value: 'claude-3-sonnet-20240229' }, + ]); + }); + + it('should handle case-insensitive search', async () => { + const result = await searchModels.call(mockContext, 'CLAUDE-3'); + + expect(result.results).toHaveLength(3); + expect(result.results).toEqual([ + { name: 'Claude 3 Haiku', value: 'claude-3-haiku-20240307' }, + { name: 'Claude 3 Opus', value: 'claude-3-opus-20240229' }, + { name: 'Claude 3 Sonnet', value: 'claude-3-sonnet-20240229' }, + ]); + }); + + it('should handle when no models match the filter', async () => { + const result = await searchModels.call(mockContext, 'nonexistent-model'); + + expect(result.results).toHaveLength(0); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/methods/searchModels.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/methods/searchModels.ts new file mode 100644 index 0000000000..8b6c7f469f --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/methods/searchModels.ts @@ -0,0 +1,60 @@ +import type { + ILoadOptionsFunctions, + INodeListSearchItems, + INodeListSearchResult, +} from 'n8n-workflow'; + +export interface AnthropicModel { + id: string; + display_name: string; + type: string; + created_at: string; +} + +export async function searchModels( + this: ILoadOptionsFunctions, + filter?: string, +): Promise { + const response = (await this.helpers.httpRequestWithAuthentication.call(this, 'anthropicApi', { + url: 'https://api.anthropic.com/v1/models', + headers: { + 'anthropic-version': '2023-06-01', + }, + })) as { data: AnthropicModel[] }; + + const models = response.data || []; + let results: INodeListSearchItems[] = []; + + if (filter) { + for (const model of models) { + if (model.id.toLowerCase().includes(filter.toLowerCase())) { + results.push({ + name: model.display_name, + value: model.id, + }); + } + } + } else { + results = models.map((model) => ({ + name: model.display_name, + value: model.id, + })); + } + + // Sort models with more recent ones first (claude-3 before claude-2) + results = results.sort((a, b) => { + const modelA = models.find((m) => m.id === a.value); + const modelB = models.find((m) => m.id === b.value); + + if (!modelA || !modelB) return 0; + + // Sort by created_at date, most recent first + const dateA = new Date(modelA.created_at); + const dateB = new Date(modelB.created_at); + return dateB.getTime() - dateA.getTime(); + }); + + return { + results, + }; +} diff --git a/packages/@n8n/nodes-langchain/nodes/memory/MemoryBufferWindow/MemoryBufferWindow.node.ts b/packages/@n8n/nodes-langchain/nodes/memory/MemoryBufferWindow/MemoryBufferWindow.node.ts index ab02339816..91282ce144 100644 --- a/packages/@n8n/nodes-langchain/nodes/memory/MemoryBufferWindow/MemoryBufferWindow.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/memory/MemoryBufferWindow/MemoryBufferWindow.node.ts @@ -75,7 +75,7 @@ class MemoryChatBufferSingleton { export class MemoryBufferWindow implements INodeType { description: INodeTypeDescription = { - displayName: 'Window Buffer Memory (easiest)', + displayName: 'Simple Memory', name: 'memoryBufferWindow', icon: 'fa:database', iconColor: 'black', @@ -83,7 +83,7 @@ export class MemoryBufferWindow implements INodeType { version: [1, 1.1, 1.2, 1.3], description: 'Stores in n8n memory, so no credentials required', defaults: { - name: 'Window Buffer Memory', + name: 'Simple Memory', }, codex: { categories: ['AI'], diff --git a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserAutofixing/test/OutputParserAutofixing.node.test.ts b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserAutofixing/test/OutputParserAutofixing.node.test.ts index 45f054a34b..8c8e723c37 100644 --- a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserAutofixing/test/OutputParserAutofixing.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserAutofixing/test/OutputParserAutofixing.node.test.ts @@ -5,7 +5,7 @@ import { OutputParserException } from '@langchain/core/output_parsers'; import type { MockProxy } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended'; import { normalizeItems } from 'n8n-core'; -import type { IExecuteFunctions, IWorkflowDataProxyData } from 'n8n-workflow'; +import type { ISupplyDataFunctions, IWorkflowDataProxyData } from 'n8n-workflow'; import { ApplicationError, NodeConnectionType, NodeOperationError } from 'n8n-workflow'; import type { @@ -18,13 +18,13 @@ import { NAIVE_FIX_PROMPT } from '../prompt'; describe('OutputParserAutofixing', () => { let outputParser: OutputParserAutofixing; - let thisArg: MockProxy; + let thisArg: MockProxy; let mockModel: MockProxy; let mockStructuredOutputParser: MockProxy; beforeEach(() => { outputParser = new OutputParserAutofixing(); - thisArg = mock({ + thisArg = mock({ helpers: { normalizeItems }, }); mockModel = mock(); diff --git a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserItemList/test/OutputParserItemList.node.test.ts b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserItemList/test/OutputParserItemList.node.test.ts index ae31e88353..fe2fcbbf47 100644 --- a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserItemList/test/OutputParserItemList.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserItemList/test/OutputParserItemList.node.test.ts @@ -2,7 +2,7 @@ import { mock } from 'jest-mock-extended'; import { normalizeItems } from 'n8n-core'; import { ApplicationError, - type IExecuteFunctions, + type ISupplyDataFunctions, type IWorkflowDataProxyData, } from 'n8n-workflow'; @@ -12,7 +12,7 @@ import { OutputParserItemList } from '../OutputParserItemList.node'; describe('OutputParserItemList', () => { let outputParser: OutputParserItemList; - const thisArg = mock({ + const thisArg = mock({ helpers: { normalizeItems }, }); const workflowDataProxy = mock({ $input: mock() }); diff --git a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/test/OutputParserStructured.node.test.ts b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/test/OutputParserStructured.node.test.ts index e07b012ec6..67e5d63cdc 100644 --- a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/test/OutputParserStructured.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserStructured/test/OutputParserStructured.node.test.ts @@ -2,8 +2,8 @@ import { mock } from 'jest-mock-extended'; import { normalizeItems } from 'n8n-core'; import { jsonParse, - type IExecuteFunctions, type INode, + type ISupplyDataFunctions, type IWorkflowDataProxyData, } from 'n8n-workflow'; @@ -13,7 +13,7 @@ import { OutputParserStructured } from '../OutputParserStructured.node'; describe('OutputParserStructured', () => { let outputParser: OutputParserStructured; - const thisArg = mock({ + const thisArg = mock({ helpers: { normalizeItems }, }); const workflowDataProxy = mock({ $input: mock() }); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/test/ToolHttpRequest.node.test.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/test/ToolHttpRequest.node.test.ts index 346234c8d3..7a25435dc7 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/test/ToolHttpRequest.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/test/ToolHttpRequest.node.test.ts @@ -1,5 +1,5 @@ import { mock } from 'jest-mock-extended'; -import type { IExecuteFunctions, INode } from 'n8n-workflow'; +import type { INode, ISupplyDataFunctions } from 'n8n-workflow'; import { jsonParse } from 'n8n-workflow'; import type { N8nTool } from '@utils/N8nTool'; @@ -8,8 +8,8 @@ import { ToolHttpRequest } from '../ToolHttpRequest.node'; describe('ToolHttpRequest', () => { const httpTool = new ToolHttpRequest(); - const helpers = mock(); - const executeFunctions = mock({ helpers }); + const helpers = mock(); + const executeFunctions = mock({ helpers }); beforeEach(() => { jest.resetAllMocks(); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts index de7abf6a8b..109a090259 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts @@ -10,6 +10,7 @@ export class ToolWorkflow extends VersionedNodeType { displayName: 'Call n8n Sub-Workflow Tool', name: 'toolWorkflow', icon: 'fa:network-wired', + iconColor: 'black', group: ['transform'], description: 'Uses another n8n workflow as a tool. Allows packaging any n8n node(s) as a tool.', diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/versionDescription.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/versionDescription.ts index da7a0e9815..f46ef05c0d 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/versionDescription.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v1/versionDescription.ts @@ -13,8 +13,6 @@ import { getConnectionHintNoticeField } from '../../../../utils/sharedFields'; export const versionDescription: INodeTypeDescription = { displayName: 'Call n8n Workflow Tool', name: 'toolWorkflow', - icon: 'fa:network-wired', - iconColor: 'black', group: ['transform'], version: [1, 1.1, 1.2, 1.3], description: 'Uses another n8n workflow as a tool. Allows packaging any n8n node(s) as a tool.', diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.test.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.test.ts index a5aa4e41bc..688000b1ec 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.test.ts @@ -11,12 +11,8 @@ import type { import { WorkflowToolService } from './utils/WorkflowToolService'; -type ISupplyDataFunctionsWithRunIndex = ISupplyDataFunctions & { runIndex: number }; - // Mock ISupplyDataFunctions interface -function createMockContext( - overrides?: Partial, -): ISupplyDataFunctionsWithRunIndex { +function createMockContext(overrides?: Partial): ISupplyDataFunctions { return { runIndex: 0, getNodeParameter: jest.fn(), @@ -33,6 +29,7 @@ function createMockContext( getTimezone: jest.fn(), getWorkflow: jest.fn(), getWorkflowStaticData: jest.fn(), + cloneWith: jest.fn(), logger: { debug: jest.fn(), error: jest.fn(), @@ -40,11 +37,11 @@ function createMockContext( warn: jest.fn(), }, ...overrides, - } as ISupplyDataFunctionsWithRunIndex; + } as ISupplyDataFunctions; } describe('WorkflowTool::WorkflowToolService', () => { - let context: ISupplyDataFunctionsWithRunIndex; + let context: ISupplyDataFunctions; let service: WorkflowToolService; beforeEach(() => { @@ -92,13 +89,25 @@ describe('WorkflowTool::WorkflowToolService', () => { $execution: { id: 'exec-id' }, $workflow: { id: 'workflow-id' }, } as unknown as IWorkflowDataProxyData); + jest.spyOn(context, 'cloneWith').mockReturnValue(context); const tool = await service.createTool(toolParams); const result = await tool.func('test query'); expect(result).toBe(JSON.stringify(TEST_RESPONSE, null, 2)); expect(context.addOutputData).toHaveBeenCalled(); - expect(context.runIndex).toBe(1); + + // Here we validate that the runIndex is correctly updated + expect(context.cloneWith).toHaveBeenCalledWith({ + runIndex: 0, + inputData: [[{ json: { query: 'test query' } }]], + }); + + await tool.func('another query'); + expect(context.cloneWith).toHaveBeenCalledWith({ + runIndex: 1, + inputData: [[{ json: { query: 'another query' } }]], + }); }); it('should handle errors during tool execution', async () => { @@ -113,6 +122,7 @@ describe('WorkflowTool::WorkflowToolService', () => { .mockRejectedValueOnce(new Error('Workflow execution failed')); jest.spyOn(context, 'addInputData').mockReturnValue({ index: 0 }); jest.spyOn(context, 'getNodeParameter').mockReturnValue('database'); + jest.spyOn(context, 'cloneWith').mockReturnValue(context); const tool = await service.createTool(toolParams); const result = await tool.func('test query'); @@ -166,7 +176,12 @@ describe('WorkflowTool::WorkflowToolService', () => { jest.spyOn(context, 'executeWorkflow').mockResolvedValueOnce(mockResponse); - const result = await service['executeSubWorkflow'](workflowInfo, items, workflowProxyMock); + const result = await service['executeSubWorkflow']( + context, + workflowInfo, + items, + workflowProxyMock, + ); expect(result.response).toBe(TEST_RESPONSE); expect(result.subExecutionId).toBe('test-execution'); @@ -175,7 +190,7 @@ describe('WorkflowTool::WorkflowToolService', () => { it('should throw error when workflow execution fails', async () => { jest.spyOn(context, 'executeWorkflow').mockRejectedValueOnce(new Error('Execution failed')); - await expect(service['executeSubWorkflow']({}, [], {} as never)).rejects.toThrow( + await expect(service['executeSubWorkflow'](context, {}, [], {} as never)).rejects.toThrow( NodeOperationError, ); }); @@ -188,7 +203,7 @@ describe('WorkflowTool::WorkflowToolService', () => { jest.spyOn(context, 'executeWorkflow').mockResolvedValueOnce(mockResponse); - await expect(service['executeSubWorkflow']({}, [], {} as never)).rejects.toThrow(); + await expect(service['executeSubWorkflow'](context, {}, [], {} as never)).rejects.toThrow(); }); }); @@ -202,7 +217,12 @@ describe('WorkflowTool::WorkflowToolService', () => { jest.spyOn(context, 'getNodeParameter').mockReturnValueOnce({ value: 'workflow-id' }); - const result = await service['getSubWorkflowInfo'](source, itemIndex, workflowProxyMock); + const result = await service['getSubWorkflowInfo']( + context, + source, + itemIndex, + workflowProxyMock, + ); expect(result.workflowInfo).toHaveProperty('id', 'workflow-id'); expect(result.subWorkflowId).toBe('workflow-id'); @@ -218,7 +238,12 @@ describe('WorkflowTool::WorkflowToolService', () => { jest.spyOn(context, 'getNodeParameter').mockReturnValueOnce(JSON.stringify(mockWorkflow)); - const result = await service['getSubWorkflowInfo'](source, itemIndex, workflowProxyMock); + const result = await service['getSubWorkflowInfo']( + context, + source, + itemIndex, + workflowProxyMock, + ); expect(result.workflowInfo.code).toEqual(mockWorkflow); expect(result.subWorkflowId).toBe('proxy-id'); @@ -234,7 +259,7 @@ describe('WorkflowTool::WorkflowToolService', () => { jest.spyOn(context, 'getNodeParameter').mockReturnValueOnce('invalid json'); await expect( - service['getSubWorkflowInfo'](source, itemIndex, workflowProxyMock), + service['getSubWorkflowInfo'](context, source, itemIndex, workflowProxyMock), ).rejects.toThrow(NodeOperationError); }); }); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts index f3089239fe..8fc366084e 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts @@ -43,8 +43,8 @@ export class WorkflowToolService { // Sub-workflow execution id, will be set after the sub-workflow is executed private subExecutionId: string | undefined; - constructor(private context: ISupplyDataFunctions) { - const subWorkflowInputs = this.context.getNode().parameters + constructor(private baseContext: ISupplyDataFunctions) { + const subWorkflowInputs = this.baseContext.getNode().parameters .workflowInputs as ResourceMapperValue; this.useSchema = (subWorkflowInputs?.schema ?? []).length > 0; } @@ -59,18 +59,23 @@ export class WorkflowToolService { description: string; itemIndex: number; }): Promise { + let runIndex = 0; // Handler for the tool execution, will be called when the tool is executed // This function will execute the sub-workflow and return the response const toolHandler = async ( query: string | IDataObject, runManager?: CallbackManagerForToolRun, ): Promise => { - const { index } = this.context.addInputData(NodeConnectionType.AiTool, [ - [{ json: { query } }], - ]); - + const localRunIndex = runIndex++; + // We need to clone the context here to handle runIndex correctly + // Otherwise the runIndex will be shared between different executions + // Causing incorrect data to be passed to the sub-workflow and via $fromAI + const context = this.baseContext.cloneWith({ + runIndex: localRunIndex, + inputData: [[{ json: { query } }]], + }); try { - const response = await this.runFunction(query, itemIndex, runManager); + const response = await this.runFunction(context, query, itemIndex, runManager); const processedResponse = this.handleToolResponse(response); // Once the sub-workflow is executed, add the output data to the context @@ -87,7 +92,12 @@ export class WorkflowToolService { const json = jsonParse(processedResponse, { fallbackValue: { response: processedResponse }, }); - void this.context.addOutputData(NodeConnectionType.AiTool, index, [[{ json }]], metadata); + void context.addOutputData( + NodeConnectionType.AiTool, + localRunIndex, + [[{ json }]], + metadata, + ); return processedResponse; } catch (error) { @@ -95,11 +105,13 @@ export class WorkflowToolService { const errorResponse = `There was an error: "${executionError.message}"`; const metadata = parseErrorMetadata(error); - void this.context.addOutputData(NodeConnectionType.AiTool, index, executionError, metadata); + void context.addOutputData( + NodeConnectionType.AiTool, + localRunIndex, + executionError, + metadata, + ); return errorResponse; - } finally { - // @ts-expect-error this accesses a private member on the actual implementation to fix https://linear.app/n8n/issue/ADO-3186/bug-workflowtool-v2-always-uses-first-row-of-input-data - this.context.runIndex++; } }; @@ -119,7 +131,7 @@ export class WorkflowToolService { } if (typeof response !== 'string') { - throw new NodeOperationError(this.context.getNode(), 'Wrong output type returned', { + throw new NodeOperationError(this.baseContext.getNode(), 'Wrong output type returned', { description: `The response property should be a string, but it is an ${typeof response}`, }); } @@ -131,6 +143,7 @@ export class WorkflowToolService { * Executes specified sub-workflow with provided inputs */ private async executeSubWorkflow( + context: ISupplyDataFunctions, workflowInfo: IExecuteWorkflowInfo, items: INodeExecutionData[], workflowProxy: IWorkflowDataProxyData, @@ -138,27 +151,22 @@ export class WorkflowToolService { ): Promise<{ response: string; subExecutionId: string }> { let receivedData: ExecuteWorkflowData; try { - receivedData = await this.context.executeWorkflow( - workflowInfo, - items, - runManager?.getChild(), - { - parentExecution: { - executionId: workflowProxy.$execution.id, - workflowId: workflowProxy.$workflow.id, - }, + receivedData = await context.executeWorkflow(workflowInfo, items, runManager?.getChild(), { + parentExecution: { + executionId: workflowProxy.$execution.id, + workflowId: workflowProxy.$workflow.id, }, - ); + }); // Set sub-workflow execution id so it can be used in other places this.subExecutionId = receivedData.executionId; } catch (error) { - throw new NodeOperationError(this.context.getNode(), error as Error); + throw new NodeOperationError(context.getNode(), error as Error); } const response: string | undefined = get(receivedData, 'data[0][0].json') as string | undefined; if (response === undefined) { throw new NodeOperationError( - this.context.getNode(), + context.getNode(), 'There was an error: "The workflow did not return a response"', ); } @@ -171,20 +179,27 @@ export class WorkflowToolService { * This function will be called as part of the tool execution (from the toolHandler) */ private async runFunction( + context: ISupplyDataFunctions, query: string | IDataObject, itemIndex: number, runManager?: CallbackManagerForToolRun, ): Promise { - const source = this.context.getNodeParameter('source', itemIndex) as string; - const workflowProxy = this.context.getWorkflowDataProxy(0); + const source = context.getNodeParameter('source', itemIndex) as string; + const workflowProxy = context.getWorkflowDataProxy(0); - const { workflowInfo } = await this.getSubWorkflowInfo(source, itemIndex, workflowProxy); - const rawData = this.prepareRawData(query, itemIndex); - const items = await this.prepareWorkflowItems(query, itemIndex, rawData); + const { workflowInfo } = await this.getSubWorkflowInfo( + context, + source, + itemIndex, + workflowProxy, + ); + const rawData = this.prepareRawData(context, query, itemIndex); + const items = await this.prepareWorkflowItems(context, query, itemIndex, rawData); this.subWorkflowId = workflowInfo.id; const { response } = await this.executeSubWorkflow( + context, workflowInfo, items, workflowProxy, @@ -197,6 +212,7 @@ export class WorkflowToolService { * Gets the sub-workflow info based on the source (database or parameter) */ private async getSubWorkflowInfo( + context: ISupplyDataFunctions, source: string, itemIndex: number, workflowProxy: IWorkflowDataProxyData, @@ -208,7 +224,7 @@ export class WorkflowToolService { let subWorkflowId: string; if (source === 'database') { - const { value } = this.context.getNodeParameter( + const { value } = context.getNodeParameter( 'workflowId', itemIndex, {}, @@ -216,14 +232,14 @@ export class WorkflowToolService { workflowInfo.id = value as string; subWorkflowId = workflowInfo.id; } else if (source === 'parameter') { - const workflowJson = this.context.getNodeParameter('workflowJson', itemIndex) as string; + const workflowJson = context.getNodeParameter('workflowJson', itemIndex) as string; try { workflowInfo.code = JSON.parse(workflowJson) as IWorkflowBase; // subworkflow is same as parent workflow subWorkflowId = workflowProxy.$workflow.id; } catch (error) { throw new NodeOperationError( - this.context.getNode(), + context.getNode(), `The provided workflow is not valid JSON: "${(error as Error).message}"`, { itemIndex }, ); @@ -233,9 +249,13 @@ export class WorkflowToolService { return { workflowInfo, subWorkflowId: subWorkflowId! }; } - private prepareRawData(query: string | IDataObject, itemIndex: number): IDataObject { + private prepareRawData( + context: ISupplyDataFunctions, + query: string | IDataObject, + itemIndex: number, + ): IDataObject { const rawData: IDataObject = { query }; - const workflowFieldsJson = this.context.getNodeParameter('fields.values', itemIndex, [], { + const workflowFieldsJson = context.getNodeParameter('fields.values', itemIndex, [], { rawExpressions: true, }) as SetField[]; @@ -253,6 +273,7 @@ export class WorkflowToolService { * Prepares the sub-workflow items for execution */ private async prepareWorkflowItems( + context: ISupplyDataFunctions, query: string | IDataObject, itemIndex: number, rawData: IDataObject, @@ -261,17 +282,17 @@ export class WorkflowToolService { let jsonData = typeof query === 'object' ? query : { query }; if (this.useSchema) { - const currentWorkflowInputs = getCurrentWorkflowInputData.call(this.context); + const currentWorkflowInputs = getCurrentWorkflowInputData.call(context); jsonData = currentWorkflowInputs[itemIndex].json; } const newItem = await manual.execute.call( - this.context, + context, { json: jsonData }, itemIndex, options, rawData, - this.context.getNode(), + context.getNode(), ); return [newItem] as INodeExecutionData[]; @@ -299,7 +320,7 @@ export class WorkflowToolService { private async extractFromAIParameters(): Promise { const collectedArguments: FromAIArgument[] = []; - traverseNodeParameters(this.context.getNode().parameters, collectedArguments); + traverseNodeParameters(this.baseContext.getNode().parameters, collectedArguments); const uniqueArgsMap = new Map(); for (const arg of collectedArguments) { diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/versionDescription.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/versionDescription.ts index 6d4275b449..46fb3d1677 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/versionDescription.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/versionDescription.ts @@ -7,7 +7,6 @@ import { getConnectionHintNoticeField } from '../../../../utils/sharedFields'; export const versionDescription: INodeTypeDescription = { displayName: 'Call n8n Workflow Tool', name: 'toolWorkflow', - icon: 'fa:network-wired', group: ['transform'], description: 'Uses another n8n workflow as a tool. Allows packaging any n8n node(s) as a tool.', defaults: { diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts index 27fb1bcd35..fab05be8ec 100644 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/ChatTrigger.node.ts @@ -12,6 +12,7 @@ import type { INodeProperties, } from 'n8n-workflow'; +import { cssVariables } from './constants'; import { validateAuth } from './GenericFunctions'; import { createPage } from './templates'; import type { LoadPreviousSessionChatOption } from './types'; @@ -378,6 +379,29 @@ export class ChatTrigger extends Node { placeholder: 'e.g. Welcome', description: 'Shown at the top of the chat', }, + { + displayName: 'Custom Chat Styling', + name: 'customCss', + type: 'string', + typeOptions: { + rows: 10, + editor: 'cssEditor', + }, + displayOptions: { + show: { + '/mode': ['hostedChat'], + }, + }, + default: ` +${cssVariables} + +/* You can override any class styles, too. Right-click inspect in Chat UI to find class to override. */ +.chat-message { + max-width: 50%; +} +`.trim(), + description: 'Override default styling of the public chat interface with CSS', + }, ], }, ], @@ -466,6 +490,7 @@ export class ChatTrigger extends Node { title?: string; allowFileUploads?: boolean; allowedFilesMimeTypes?: string; + customCss?: string; }; const req = ctx.getRequestObject(); @@ -517,6 +542,7 @@ export class ChatTrigger extends Node { authentication, allowFileUploads: options.allowFileUploads, allowedFilesMimeTypes: options.allowedFilesMimeTypes, + customCss: options.customCss, }); res.status(200).send(page).end(); diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/constants.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/constants.ts new file mode 100644 index 0000000000..379629a896 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/constants.ts @@ -0,0 +1,122 @@ +// CSS Variables are defined in `@n8n/chat/src/css/_tokens.scss` +export const cssVariables = ` +:root { + /* Colors */ + --chat--color-primary: #e74266; + --chat--color-primary-shade-50: #db4061; + --chat--color-primary-shade-100: #cf3c5c; + --chat--color-secondary: #20b69e; + --chat--color-secondary-shade-50: #1ca08a; + --chat--color-white: #ffffff; + --chat--color-light: #f2f4f8; + --chat--color-light-shade-50: #e6e9f1; + --chat--color-light-shade-100: #c2c5cc; + --chat--color-medium: #d2d4d9; + --chat--color-dark: #101330; + --chat--color-disabled: #777980; + --chat--color-typing: #404040; + + /* Base Layout */ + --chat--spacing: 1rem; + --chat--border-radius: 0.25rem; + --chat--transition-duration: 0.15s; + --chat--font-family: ( + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + Oxygen-Sans, + Ubuntu, + Cantarell, + 'Helvetica Neue', + sans-serif + ); + + /* Window Dimensions */ + --chat--window--width: 400px; + --chat--window--height: 600px; + --chat--window--bottom: var(--chat--spacing); + --chat--window--right: var(--chat--spacing); + --chat--window--z-index: 9999; + --chat--window--border: 1px solid var(--chat--color-light-shade-50); + --chat--window--border-radius: var(--chat--border-radius); + --chat--window--margin-bottom: var(--chat--spacing); + + /* Header Styles */ + --chat--header-height: auto; + --chat--header--padding: var(--chat--spacing); + --chat--header--background: var(--chat--color-dark); + --chat--header--color: var(--chat--color-light); + --chat--header--border-top: none; + --chat--header--border-bottom: none; + --chat--header--border-left: none; + --chat--header--border-right: none; + --chat--heading--font-size: 2em; + --chat--subtitle--font-size: inherit; + --chat--subtitle--line-height: 1.8; + + /* Message Styles */ + --chat--message--font-size: 1rem; + --chat--message--padding: var(--chat--spacing); + --chat--message--border-radius: var(--chat--border-radius); + --chat--message-line-height: 1.5; + --chat--message--margin-bottom: calc(var(--chat--spacing) * 1); + --chat--message--bot--background: var(--chat--color-white); + --chat--message--bot--color: var(--chat--color-dark); + --chat--message--bot--border: none; + --chat--message--user--background: var(--chat--color-secondary); + --chat--message--user--color: var(--chat--color-white); + --chat--message--user--border: none; + --chat--message--pre--background: rgba(0, 0, 0, 0.05); + --chat--messages-list--padding: var(--chat--spacing); + + /* Toggle Button */ + --chat--toggle--size: 64px; + --chat--toggle--width: var(--chat--toggle--size); + --chat--toggle--height: var(--chat--toggle--size); + --chat--toggle--border-radius: 50%; + --chat--toggle--background: var(--chat--color-primary); + --chat--toggle--hover--background: var(--chat--color-primary-shade-50); + --chat--toggle--active--background: var(--chat--color-primary-shade-100); + --chat--toggle--color: var(--chat--color-white); + + /* Input Area */ + --chat--textarea--height: 50px; + --chat--textarea--max-height: 30rem; + --chat--input--font-size: inherit; + --chat--input--border: 0; + --chat--input--border-radius: 0; + --chat--input--padding: 0.8rem; + --chat--input--background: var(--chat--color-white); + --chat--input--text-color: initial; + --chat--input--line-height: 1.5; + --chat--input--placeholder--font-size: var(--chat--input--font-size); + --chat--input--border-active: 0; + --chat--input--left--panel--width: 2rem; + + /* Button Styles */ + --chat--button--color: var(--chat--color-light); + --chat--button--background: var(--chat--color-primary); + --chat--button--padding: calc(var(--chat--spacing) * 1 / 2) var(--chat--spacing); + --chat--button--border-radius: var(--chat--border-radius); + --chat--button--hover--color: var(--chat--color-light); + --chat--button--hover--background: var(--chat--color-primary-shade-50); + --chat--close--button--color-hover: var(--chat--color-primary); + + /* Send and File Buttons */ + --chat--input--send--button--background: var(--chat--color-white); + --chat--input--send--button--color: var(--chat--color-light); + --chat--input--send--button--background-hover: var(--chat--color-primary-shade-50); + --chat--input--send--button--color-hover: var(--chat--color-secondary-shade-50); + --chat--input--file--button--background: var(--chat--color-white); + --chat--input--file--button--color: var(--chat--color-secondary); + --chat--input--file--button--background-hover: var(--chat--input--file--button--background); + --chat--input--file--button--color-hover: var(--chat--color-secondary-shade-50); + --chat--files-spacing: 0.25rem; + + /* Body and Footer */ + --chat--body--background: var(--chat--color-light); + --chat--footer--background: var(--chat--color-light); + --chat--footer--color: var(--chat--color-dark); +} +`; diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/templates.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/templates.ts index 0435df59e8..d224a89361 100644 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/templates.ts +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ChatTrigger/templates.ts @@ -1,5 +1,6 @@ -import type { AuthenticationChatOption, LoadPreviousSessionChatOption } from './types'; +import sanitizeHtml from 'sanitize-html'; +import type { AuthenticationChatOption, LoadPreviousSessionChatOption } from './types'; export function createPage({ instanceId, webhookUrl, @@ -10,6 +11,7 @@ export function createPage({ authentication, allowFileUploads, allowedFilesMimeTypes, + customCss, }: { instanceId: string; webhookUrl?: string; @@ -23,6 +25,7 @@ export function createPage({ authentication: AuthenticationChatOption; allowFileUploads?: boolean; allowedFilesMimeTypes?: string; + customCss?: string; }) { const validAuthenticationOptions: AuthenticationChatOption[] = [ 'none', @@ -41,6 +44,11 @@ export function createPage({ const sanitizedShowWelcomeScreen = !!showWelcomeScreen; const sanitizedAllowFileUploads = !!allowFileUploads; const sanitizedAllowedFilesMimeTypes = allowedFilesMimeTypes?.toString() ?? ''; + const sanitizedCustomCss = sanitizeHtml(``, { + allowedTags: ['style'], + allowedAttributes: false, + }); + const sanitizedLoadPreviousSession = validLoadPreviousSessionOptions.includes( loadPreviousSession as LoadPreviousSessionChatOption, ) @@ -63,6 +71,7 @@ export function createPage({ height: 100%; } + ${sanitizedCustomCss} diff --git a/packages/design-system/src/components/N8nInfoTip/__snapshots__/InfoTip.test.ts.snap b/packages/frontend/@n8n/design-system/src/components/N8nInfoTip/__snapshots__/InfoTip.test.ts.snap similarity index 100% rename from packages/design-system/src/components/N8nInfoTip/__snapshots__/InfoTip.test.ts.snap rename to packages/frontend/@n8n/design-system/src/components/N8nInfoTip/__snapshots__/InfoTip.test.ts.snap diff --git a/packages/design-system/src/components/N8nInfoTip/index.ts b/packages/frontend/@n8n/design-system/src/components/N8nInfoTip/index.ts similarity index 100% rename from packages/design-system/src/components/N8nInfoTip/index.ts rename to packages/frontend/@n8n/design-system/src/components/N8nInfoTip/index.ts diff --git a/packages/design-system/src/components/N8nInput/Input.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nInput/Input.stories.ts similarity index 100% rename from packages/design-system/src/components/N8nInput/Input.stories.ts rename to packages/frontend/@n8n/design-system/src/components/N8nInput/Input.stories.ts diff --git a/packages/design-system/src/components/N8nInput/Input.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nInput/Input.test.ts similarity index 100% rename from packages/design-system/src/components/N8nInput/Input.test.ts rename to packages/frontend/@n8n/design-system/src/components/N8nInput/Input.test.ts diff --git a/packages/design-system/src/components/N8nInput/Input.vue b/packages/frontend/@n8n/design-system/src/components/N8nInput/Input.vue similarity index 95% rename from packages/design-system/src/components/N8nInput/Input.vue rename to packages/frontend/@n8n/design-system/src/components/N8nInput/Input.vue index 7df719728a..b622a349cd 100644 --- a/packages/design-system/src/components/N8nInput/Input.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nInput/Input.vue @@ -2,8 +2,8 @@ import { ElInput } from 'element-plus'; import { computed, ref } from 'vue'; -import type { ElementPlusSizePropType, InputAutocompletePropType } from 'n8n-design-system/types'; -import type { InputSize, InputType } from 'n8n-design-system/types/input'; +import type { ElementPlusSizePropType, InputAutocompletePropType } from '@n8n/design-system/types'; +import type { InputSize, InputType } from '@n8n/design-system/types/input'; import { uid } from '../../utils'; diff --git a/packages/design-system/src/components/N8nInput/__snapshots__/Input.test.ts.snap b/packages/frontend/@n8n/design-system/src/components/N8nInput/__snapshots__/Input.test.ts.snap similarity index 100% rename from packages/design-system/src/components/N8nInput/__snapshots__/Input.test.ts.snap rename to packages/frontend/@n8n/design-system/src/components/N8nInput/__snapshots__/Input.test.ts.snap diff --git a/packages/design-system/src/components/N8nInput/index.ts b/packages/frontend/@n8n/design-system/src/components/N8nInput/index.ts similarity index 100% rename from packages/design-system/src/components/N8nInput/index.ts rename to packages/frontend/@n8n/design-system/src/components/N8nInput/index.ts diff --git a/packages/design-system/src/components/N8nInputLabel/InputLabel.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nInputLabel/InputLabel.stories.ts similarity index 100% rename from packages/design-system/src/components/N8nInputLabel/InputLabel.stories.ts rename to packages/frontend/@n8n/design-system/src/components/N8nInputLabel/InputLabel.stories.ts diff --git a/packages/design-system/src/components/N8nInputLabel/InputLabel.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nInputLabel/InputLabel.test.ts similarity index 100% rename from packages/design-system/src/components/N8nInputLabel/InputLabel.test.ts rename to packages/frontend/@n8n/design-system/src/components/N8nInputLabel/InputLabel.test.ts diff --git a/packages/design-system/src/components/N8nInputLabel/InputLabel.vue b/packages/frontend/@n8n/design-system/src/components/N8nInputLabel/InputLabel.vue similarity index 98% rename from packages/design-system/src/components/N8nInputLabel/InputLabel.vue rename to packages/frontend/@n8n/design-system/src/components/N8nInputLabel/InputLabel.vue index 2224528c1e..b08090bbed 100644 --- a/packages/design-system/src/components/N8nInputLabel/InputLabel.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nInputLabel/InputLabel.vue @@ -1,5 +1,5 @@ + + + + diff --git a/packages/editor-ui/src/components/DebugPaywallModal.vue b/packages/frontend/editor-ui/src/components/DebugPaywallModal.vue similarity index 100% rename from packages/editor-ui/src/components/DebugPaywallModal.vue rename to packages/frontend/editor-ui/src/components/DebugPaywallModal.vue diff --git a/packages/editor-ui/src/components/DeleteUserModal.test.ts b/packages/frontend/editor-ui/src/components/DeleteUserModal.test.ts similarity index 100% rename from packages/editor-ui/src/components/DeleteUserModal.test.ts rename to packages/frontend/editor-ui/src/components/DeleteUserModal.test.ts diff --git a/packages/editor-ui/src/components/DeleteUserModal.vue b/packages/frontend/editor-ui/src/components/DeleteUserModal.vue similarity index 98% rename from packages/editor-ui/src/components/DeleteUserModal.vue rename to packages/frontend/editor-ui/src/components/DeleteUserModal.vue index db9bb671c4..68bc63a496 100644 --- a/packages/editor-ui/src/components/DeleteUserModal.vue +++ b/packages/frontend/editor-ui/src/components/DeleteUserModal.vue @@ -5,7 +5,7 @@ import Modal from '@/components/Modal.vue'; import ProjectSharing from '@/components/Projects/ProjectSharing.vue'; import { useUsersStore } from '@/stores/users.store'; import { useProjectsStore } from '@/stores/projects.store'; -import { createEventBus } from 'n8n-design-system/utils'; +import { createEventBus } from '@n8n/utils/event-bus'; import type { ProjectSharingData } from '@/types/projects.types'; import { useI18n } from '@/composables/useI18n'; diff --git a/packages/editor-ui/src/components/Draggable.vue b/packages/frontend/editor-ui/src/components/Draggable.vue similarity index 100% rename from packages/editor-ui/src/components/Draggable.vue rename to packages/frontend/editor-ui/src/components/Draggable.vue diff --git a/packages/editor-ui/src/components/DraggableTarget.vue b/packages/frontend/editor-ui/src/components/DraggableTarget.vue similarity index 100% rename from packages/editor-ui/src/components/DraggableTarget.vue rename to packages/frontend/editor-ui/src/components/DraggableTarget.vue diff --git a/packages/editor-ui/src/components/DropArea/DropArea.test.ts b/packages/frontend/editor-ui/src/components/DropArea/DropArea.test.ts similarity index 100% rename from packages/editor-ui/src/components/DropArea/DropArea.test.ts rename to packages/frontend/editor-ui/src/components/DropArea/DropArea.test.ts diff --git a/packages/editor-ui/src/components/DropArea/DropArea.vue b/packages/frontend/editor-ui/src/components/DropArea/DropArea.vue similarity index 100% rename from packages/editor-ui/src/components/DropArea/DropArea.vue rename to packages/frontend/editor-ui/src/components/DropArea/DropArea.vue diff --git a/packages/editor-ui/src/components/DuplicateWorkflowDialog.vue b/packages/frontend/editor-ui/src/components/DuplicateWorkflowDialog.vue similarity index 96% rename from packages/editor-ui/src/components/DuplicateWorkflowDialog.vue rename to packages/frontend/editor-ui/src/components/DuplicateWorkflowDialog.vue index 62753180a2..0f624311f0 100644 --- a/packages/editor-ui/src/components/DuplicateWorkflowDialog.vue +++ b/packages/frontend/editor-ui/src/components/DuplicateWorkflowDialog.vue @@ -7,7 +7,7 @@ import Modal from '@/components/Modal.vue'; import { useSettingsStore } from '@/stores/settings.store'; import { useWorkflowsStore } from '@/stores/workflows.store'; import type { IWorkflowDataUpdate } from '@/Interface'; -import { createEventBus, type EventBus } from 'n8n-design-system/utils'; +import { createEventBus, type EventBus } from '@n8n/utils/event-bus'; import { useCredentialsStore } from '@/stores/credentials.store'; import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; import { useRouter } from 'vue-router'; @@ -72,6 +72,8 @@ const save = async (): Promise => { return; } + const parentFolderId = router.currentRoute.value.params.folderId as string | undefined; + const currentWorkflowId = props.data.id; isSaving.value = true; @@ -102,6 +104,7 @@ const save = async (): Promise => { resetWebhookUrls: true, openInNewWindow: true, resetNodeIds: true, + parentFolderId, }); if (saved) { diff --git a/packages/editor-ui/src/components/EnterpriseEdition.ee.vue b/packages/frontend/editor-ui/src/components/EnterpriseEdition.ee.vue similarity index 100% rename from packages/editor-ui/src/components/EnterpriseEdition.ee.vue rename to packages/frontend/editor-ui/src/components/EnterpriseEdition.ee.vue diff --git a/packages/editor-ui/src/components/Error/NodeErrorView.test.ts b/packages/frontend/editor-ui/src/components/Error/NodeErrorView.test.ts similarity index 100% rename from packages/editor-ui/src/components/Error/NodeErrorView.test.ts rename to packages/frontend/editor-ui/src/components/Error/NodeErrorView.test.ts diff --git a/packages/editor-ui/src/components/Error/NodeErrorView.vue b/packages/frontend/editor-ui/src/components/Error/NodeErrorView.vue similarity index 99% rename from packages/editor-ui/src/components/Error/NodeErrorView.vue rename to packages/frontend/editor-ui/src/components/Error/NodeErrorView.vue index f1b8a1f7c6..b21cc52a22 100644 --- a/packages/editor-ui/src/components/Error/NodeErrorView.vue +++ b/packages/frontend/editor-ui/src/components/Error/NodeErrorView.vue @@ -20,7 +20,7 @@ import { MAX_DISPLAY_DATA_SIZE, NEW_ASSISTANT_SESSION_MODAL } from '@/constants' import type { BaseTextKey } from '@/plugins/i18n'; import { useAssistantStore } from '@/stores/assistant.store'; import type { ChatRequest } from '@/types/assistant.types'; -import InlineAskAssistantButton from 'n8n-design-system/components/InlineAskAssistantButton/InlineAskAssistantButton.vue'; +import InlineAskAssistantButton from '@n8n/design-system/components/InlineAskAssistantButton/InlineAskAssistantButton.vue'; import { useUIStore } from '@/stores/ui.store'; import { isCommunityPackageName } from '@/utils/nodeTypesUtils'; import { useAIAssistantHelpers } from '@/composables/useAIAssistantHelpers'; diff --git a/packages/editor-ui/src/components/ExpandableInput/ExpandableInputBase.vue b/packages/frontend/editor-ui/src/components/ExpandableInput/ExpandableInputBase.vue similarity index 100% rename from packages/editor-ui/src/components/ExpandableInput/ExpandableInputBase.vue rename to packages/frontend/editor-ui/src/components/ExpandableInput/ExpandableInputBase.vue diff --git a/packages/editor-ui/src/components/ExpandableInput/ExpandableInputEdit.vue b/packages/frontend/editor-ui/src/components/ExpandableInput/ExpandableInputEdit.vue similarity index 96% rename from packages/editor-ui/src/components/ExpandableInput/ExpandableInputEdit.vue rename to packages/frontend/editor-ui/src/components/ExpandableInput/ExpandableInputEdit.vue index 88f45b7374..c334e7091d 100644 --- a/packages/editor-ui/src/components/ExpandableInput/ExpandableInputEdit.vue +++ b/packages/frontend/editor-ui/src/components/ExpandableInput/ExpandableInputEdit.vue @@ -1,5 +1,5 @@ + + + diff --git a/packages/editor-ui/src/components/Folders/FolderCard.test.ts b/packages/frontend/editor-ui/src/components/Folders/FolderCard.test.ts similarity index 93% rename from packages/editor-ui/src/components/Folders/FolderCard.test.ts rename to packages/frontend/editor-ui/src/components/Folders/FolderCard.test.ts index b8aa155d2c..bf19b9a2fc 100644 --- a/packages/editor-ui/src/components/Folders/FolderCard.test.ts +++ b/packages/frontend/editor-ui/src/components/Folders/FolderCard.test.ts @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'; import FolderCard from './FolderCard.vue'; import { createPinia, setActivePinia } from 'pinia'; import type { FolderResource } from '../layouts/ResourcesListLayout.vue'; -import type { UserAction } from '@/Interface'; +import type { FolderPathItem, UserAction } from '@/Interface'; vi.mock('vue-router', () => { const push = vi.fn(); @@ -61,6 +61,11 @@ const PARENT_FOLDER: FolderResource = { }, } as const satisfies FolderResource; +const DEFAULT_BREADCRUMBS: { visibleItems: FolderPathItem[]; hiddenItems: FolderPathItem[] } = { + visibleItems: [{ id: '1', label: 'Parent 2' }], + hiddenItems: [{ id: '2', label: 'Parent 1', parentFolder: '1' }], +}; + const renderComponent = createComponentRenderer(FolderCard, { props: { data: DEFAULT_FOLDER, @@ -68,6 +73,7 @@ const renderComponent = createComponentRenderer(FolderCard, { { label: 'Open', value: 'open', disabled: false }, { label: 'Delete', value: 'delete', disabled: false }, ] as const satisfies UserAction[], + breadcrumbs: DEFAULT_BREADCRUMBS, }, global: { stubs: { @@ -145,6 +151,10 @@ describe('FolderCard', () => { }, parentFolder: PARENT_FOLDER, }, + breadcrumbs: { + visibleItems: [{ id: PARENT_FOLDER.id, label: PARENT_FOLDER.name, parentFolder: '1' }], + hiddenItems: [], + }, }, }); expect(getByTestId('folder-card-icon')).toBeInTheDocument(); diff --git a/packages/editor-ui/src/components/Folders/FolderCard.vue b/packages/frontend/editor-ui/src/components/Folders/FolderCard.vue similarity index 72% rename from packages/editor-ui/src/components/Folders/FolderCard.vue rename to packages/frontend/editor-ui/src/components/Folders/FolderCard.vue index a10210889b..16f3ed3e5c 100644 --- a/packages/editor-ui/src/components/Folders/FolderCard.vue +++ b/packages/frontend/editor-ui/src/components/Folders/FolderCard.vue @@ -6,12 +6,16 @@ import { type ProjectIcon, ProjectTypes } from '@/types/projects.types'; import { useI18n } from '@/composables/useI18n'; import { useRoute, useRouter } from 'vue-router'; import { VIEWS } from '@/constants'; -import type { PathItem } from 'n8n-design-system/components/N8nBreadcrumbs/Breadcrumbs.vue'; -import type { UserAction } from '@/Interface'; +import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue'; +import type { FolderPathItem, UserAction } from '@/Interface'; type Props = { data: FolderResource; actions: UserAction[]; + breadcrumbs: { + visibleItems: FolderPathItem[]; + hiddenItems: FolderPathItem[]; + }; }; const props = withDefaults(defineProps(), { @@ -27,18 +31,6 @@ const emit = defineEmits<{ folderOpened: [{ folder: FolderResource }]; }>(); -const breadCrumbsItems = computed(() => { - if (props.data.parentFolder) { - return [ - { - id: props.data.parentFolder.id, - label: props.data.parentFolder.name, - }, - ]; - } - return []; -}); - const projectIcon = computed(() => { const defaultIcon: ProjectIcon = { type: 'icon', value: 'layer-group' }; if (props.data.homeProject?.type === ProjectTypes.Personal) { @@ -109,7 +101,7 @@ const onBreadcrumbsItemClick = async (item: PathItem) => { {{ data.workflowCount }} {{ i18n.baseText('generic.workflows') }} @@ -117,7 +109,7 @@ const onBreadcrumbsItemClick = async (item: PathItem) => { {{ i18n.baseText('workerList.item.lastUpdated') }} @@ -126,7 +118,7 @@ const onBreadcrumbsItemClick = async (item: PathItem) => { {{ i18n.baseText('workflows.item.created') }} @@ -136,26 +128,29 @@ const onBreadcrumbsItemClick = async (item: PathItem) => {