From ac1a1dfbc208782c032527a2ed57975c80d7274b Mon Sep 17 00:00:00 2001 From: Suguru Inoue Date: Fri, 6 Jun 2025 13:05:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(editor):=20Make=20=E2=80=98Execute=20workf?= =?UTF-8?q?low=E2=80=99=20a=20split=20button=20(#15933)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cypress/e2e/19-execution.cy.ts | 2 +- .../@n8n/utils/src/string/truncate.test.ts | 36 +++- packages/@n8n/utils/src/string/truncate.ts | 42 ++++ .../N8nActionDropdown/ActionDropdown.vue | 10 +- .../src/components/N8nButton/Button.scss | 6 +- .../frontend/@n8n/i18n/src/locales/en.json | 1 + .../CanvasChat/future/LogsPanel.vue | 14 +- .../src/components/ParameterInput.test.ts | 22 +- .../elements/buttons/CanvasChatButton.vue | 8 +- .../buttons/CanvasRunWorkflowButton.test.ts | 94 ++++++++- .../buttons/CanvasRunWorkflowButton.vue | 188 ++++++++++++++++-- .../CanvasRunWorkflowButton.test.ts.snap | 8 +- .../render-types/parts/CanvasNodeTrigger.vue | 10 +- .../src/composables/useRunWorkflow.ts | 4 +- .../src/stores/workflows.store.test.ts | 36 +++- .../editor-ui/src/stores/workflows.store.ts | 71 ++++++- .../src/utils/executionUtils.test.ts | 62 +++++- .../editor-ui/src/utils/executionUtils.ts | 48 ++++- .../tests/EvaluationsRootView.test.ts | 13 +- .../frontend/editor-ui/src/views/NodeView.vue | 14 +- 20 files changed, 619 insertions(+), 70 deletions(-) diff --git a/cypress/e2e/19-execution.cy.ts b/cypress/e2e/19-execution.cy.ts index 2108c6eb73..c8836051ed 100644 --- a/cypress/e2e/19-execution.cy.ts +++ b/cypress/e2e/19-execution.cy.ts @@ -483,7 +483,7 @@ describe('Execution', () => { cy.wait('@workflowRun').then((interception) => { expect(interception.request.body).to.have.property('runData').that.is.an('object'); - const expectedKeys = ['Start Manually', 'Edit Fields', 'Process The Data']; + const expectedKeys = ['Start on Schedule', 'Edit Fields', 'Process The Data']; const { runData } = interception.request.body; expect(Object.keys(runData)).to.have.lengthOf(expectedKeys.length); diff --git a/packages/@n8n/utils/src/string/truncate.test.ts b/packages/@n8n/utils/src/string/truncate.test.ts index 4684fb2bb8..7166af4d7c 100644 --- a/packages/@n8n/utils/src/string/truncate.test.ts +++ b/packages/@n8n/utils/src/string/truncate.test.ts @@ -1,4 +1,4 @@ -import { truncate } from './truncate'; +import { truncateBeforeLast, truncate } from './truncate'; describe('truncate', () => { it('should truncate text to 30 chars by default', () => { @@ -13,3 +13,37 @@ describe('truncate', () => { ); }); }); + +describe(truncateBeforeLast, () => { + it('should return unmodified text if the length does not exceed max length', () => { + expect(truncateBeforeLast('I love nodemation', 25)).toBe('I love nodemation'); + expect(truncateBeforeLast('I ❤️ nodemation', 25)).toBe('I ❤️ nodemation'); + expect(truncateBeforeLast('Nodemation is cool', 25)).toBe('Nodemation is cool'); + expect(truncateBeforeLast('Internationalization', 25)).toBe('Internationalization'); + expect(truncateBeforeLast('I love 👨‍👩‍👧‍👦', 8)).toBe('I love 👨‍👩‍👧‍👦'); + }); + + it('should remove chars just before the last word, as long as the last word is under 15 chars', () => { + expect(truncateBeforeLast('I love nodemation', 15)).toBe('I lo…nodemation'); + expect(truncateBeforeLast('I love "nodemation"', 15)).toBe('I …"nodemation"'); + expect(truncateBeforeLast('I ❤️ nodemation', 13)).toBe('I …nodemation'); + expect(truncateBeforeLast('Nodemation is cool', 15)).toBe('Nodemation…cool'); + expect(truncateBeforeLast('"Nodemation" is cool', 15)).toBe('"Nodematio…cool'); + expect(truncateBeforeLast('Is it fun to automate boring stuff?', 15)).toBe('Is it fu…stuff?'); + expect(truncateBeforeLast('Is internationalization fun?', 15)).toBe('Is interna…fun?'); + expect(truncateBeforeLast('I love 👨‍👩‍👧‍👦', 7)).toBe('I lov…👨‍👩‍👧‍👦'); + }); + + it('should preserve last 5 characters if the last word is longer than 15 characters', () => { + expect(truncateBeforeLast('I love internationalization', 25)).toBe('I love internationa…ation'); + expect(truncateBeforeLast('I love "internationalization"', 25)).toBe( + 'I love "internation…tion"', + ); + expect(truncateBeforeLast('I "love" internationalization', 25)).toBe( + 'I "love" internatio…ation', + ); + expect(truncateBeforeLast('I ❤️ internationalization', 9)).toBe('I ❤️…ation'); + expect(truncateBeforeLast('I ❤️ internationalization', 8)).toBe('I …ation'); + expect(truncateBeforeLast('Internationalization', 15)).toBe('Internati…ation'); + }); +}); diff --git a/packages/@n8n/utils/src/string/truncate.ts b/packages/@n8n/utils/src/string/truncate.ts index 1c2b2aecfd..cc1d202463 100644 --- a/packages/@n8n/utils/src/string/truncate.ts +++ b/packages/@n8n/utils/src/string/truncate.ts @@ -1,2 +1,44 @@ export const truncate = (text: string, length = 30): string => text.length > length ? text.slice(0, length) + '...' : text; + +/** + * Replace part of given text with ellipsis following the rules below: + * + * - Remove chars just before the last word, as long as the last word is under 15 chars + * - Otherwise preserve the last 5 chars of the name and remove chars before that + */ +export function truncateBeforeLast(text: string, maxLength: number): string { + const chars: string[] = []; + + const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); + + for (const { segment } of segmenter.segment(text)) { + chars.push(segment); + } + + if (chars.length <= maxLength) { + return text; + } + + const lastWhitespaceIndex = chars.findLastIndex((ch) => ch.match(/^\s+$/)); + const lastWordIndex = lastWhitespaceIndex + 1; + const lastWord = chars.slice(lastWordIndex); + const ellipsis = '…'; + const ellipsisLength = ellipsis.length; + + if (lastWord.length < 15) { + const charsToRemove = chars.length - maxLength + ellipsisLength; + const indexBeforeLastWord = lastWordIndex; + const keepLength = indexBeforeLastWord - charsToRemove; + + if (keepLength > 0) { + return ( + chars.slice(0, keepLength).join('') + ellipsis + chars.slice(indexBeforeLastWord).join('') + ); + } + } + + return ( + chars.slice(0, maxLength - 5 - ellipsisLength).join('') + ellipsis + chars.slice(-5).join('') + ); +} diff --git a/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/ActionDropdown.vue b/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/ActionDropdown.vue index 524ab9f6f7..80c7944219 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/ActionDropdown.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nActionDropdown/ActionDropdown.vue @@ -56,6 +56,12 @@ const emit = defineEmits<{ select: [action: string]; visibleChange: [open: boolean]; }>(); + +defineSlots<{ + activator: {}; + menuItem: (props: ActionDropdownItem) => void; +}>(); + const elementDropdown = ref>(); const popperClass = computed( @@ -115,7 +121,9 @@ defineExpose({ open, close }); - {{ item.label }} + + {{ item.label }} + diff --git a/packages/frontend/@n8n/design-system/src/components/N8nButton/Button.scss b/packages/frontend/@n8n/design-system/src/components/N8nButton/Button.scss index 9a476ca9d5..e3e3dd0765 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nButton/Button.scss +++ b/packages/frontend/@n8n/design-system/src/components/N8nButton/Button.scss @@ -22,7 +22,11 @@ box-sizing: border-box; outline: none; margin: 0; - transition: 0.3s; + transition: + all 0.3s, + padding 0s, + width 0s, + height 0s; @include utils-user-select(none); diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 03e45d0a95..4fdc00f42f 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -1479,6 +1479,7 @@ "nodeView.runButtonText.executeWorkflow": "Execute workflow", "nodeView.runButtonText.executingWorkflow": "Executing workflow", "nodeView.runButtonText.waitingForTriggerEvent": "Waiting for trigger event", + "nodeView.runButtonText.from": "from {nodeName}", "nodeView.showError.workflowError": "Workflow execution had an error", "nodeView.showError.getWorkflowDataFromUrl.title": "Problem loading workflow", "nodeView.showError.importWorkflowData.title": "Problem importing workflow", diff --git a/packages/frontend/editor-ui/src/components/CanvasChat/future/LogsPanel.vue b/packages/frontend/editor-ui/src/components/CanvasChat/future/LogsPanel.vue index be8b04861e..52e4b08fc8 100644 --- a/packages/frontend/editor-ui/src/components/CanvasChat/future/LogsPanel.vue +++ b/packages/frontend/editor-ui/src/components/CanvasChat/future/LogsPanel.vue @@ -116,13 +116,13 @@ async function handleOpenNdv(treeNode: LogEntry) { ref="container" :class="$style.container" tabindex="-1" - @keydown.esc.stop="select(undefined)" - @keydown.j.stop="selectNext" - @keydown.down.stop.prevent="selectNext" - @keydown.k.stop="selectPrev" - @keydown.up.stop.prevent="selectPrev" - @keydown.space.stop="selected && toggleExpanded(selected)" - @keydown.enter.stop="selected && handleOpenNdv(selected)" + @keydown.esc.exact.stop="select(undefined)" + @keydown.j.exact.stop="selectNext" + @keydown.down.exact.stop.prevent="selectNext" + @keydown.k.exact.stop="selectPrev" + @keydown.up.exact.stop.prevent="selectPrev" + @keydown.space.exact.stop="selected && toggleExpanded(selected)" + @keydown.enter.exact.stop="selected && handleOpenNdv(selected)" > >; -let mockNodeTypesState: Partial>; -let mockCompletionResult: Partial; - -beforeEach(() => { - mockNdvState = { +function getNdvStateMock(): Partial> { + return { hasInputData: true, activeNode: { id: faker.string.uuid(), @@ -32,9 +28,21 @@ beforeEach(() => { isInputPanelEmpty: false, isOutputPanelEmpty: false, }; - mockNodeTypesState = { +} + +function getNodeTypesStateMock(): Partial> { + return { allNodeTypes: [], }; +} + +let mockNdvState = getNdvStateMock(); +let mockNodeTypesState = getNodeTypesStateMock(); +let mockCompletionResult: Partial = {}; + +beforeEach(() => { + mockNdvState = getNdvStateMock(); + mockNodeTypesState = getNodeTypesStateMock(); mockCompletionResult = {}; createAppModals(); }); diff --git a/packages/frontend/editor-ui/src/components/canvas/elements/buttons/CanvasChatButton.vue b/packages/frontend/editor-ui/src/components/canvas/elements/buttons/CanvasChatButton.vue index 92a1ff8128..9d4a942665 100644 --- a/packages/frontend/editor-ui/src/components/canvas/elements/buttons/CanvasChatButton.vue +++ b/packages/frontend/editor-ui/src/components/canvas/elements/buttons/CanvasChatButton.vue @@ -1,8 +1,8 @@