From 9abb333507453e4671cd886db9f674b1957d7d5c Mon Sep 17 00:00:00 2001 From: Benjamin Schroth <68321970+schrothbn@users.noreply.github.com> Date: Wed, 4 Jun 2025 14:14:48 +0200 Subject: [PATCH] feat(editor): Improve UI for highlighted data, tags and rating in executions (#15926) Co-authored-by: Giulio Andreini --- .../src/components/N8nTag/Tag.vue | 17 +- .../@n8n/design-system/src/css/_tokens.scss | 11 + .../frontend/@n8n/i18n/src/locales/en.json | 1 + .../executions/workflow/VoteButtons.vue | 16 +- .../WorkflowExecutionAnnotationPanel.ee.vue | 346 ++++++------------ ...WorkflowExecutionAnnotationTags.ee.test.ts | 226 ++++++++++++ .../WorkflowExecutionAnnotationTags.ee.vue | 207 +++++++++++ .../WorkflowExecutionsPreview.test.ts | 197 +++++++++- .../workflow/WorkflowExecutionsPreview.vue | 216 +++++++---- 9 files changed, 928 insertions(+), 309 deletions(-) create mode 100644 packages/frontend/editor-ui/src/components/executions/workflow/WorkflowExecutionAnnotationTags.ee.test.ts create mode 100644 packages/frontend/editor-ui/src/components/executions/workflow/WorkflowExecutionAnnotationTags.ee.vue diff --git a/packages/frontend/@n8n/design-system/src/components/N8nTag/Tag.vue b/packages/frontend/@n8n/design-system/src/components/N8nTag/Tag.vue index da5093a636..4ed78db816 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nTag/Tag.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nTag/Tag.vue @@ -18,13 +18,18 @@ withDefaults(defineProps(), { diff --git a/packages/frontend/editor-ui/src/components/executions/workflow/WorkflowExecutionAnnotationTags.ee.test.ts b/packages/frontend/editor-ui/src/components/executions/workflow/WorkflowExecutionAnnotationTags.ee.test.ts new file mode 100644 index 0000000000..bd27bc518e --- /dev/null +++ b/packages/frontend/editor-ui/src/components/executions/workflow/WorkflowExecutionAnnotationTags.ee.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, vi } from 'vitest'; + +import userEvent from '@testing-library/user-event'; +import { faker } from '@faker-js/faker'; +import type { ExecutionSummary, AnnotationVote } from 'n8n-workflow'; +import WorkflowExecutionAnnotationTags from '@/components/executions/workflow/WorkflowExecutionAnnotationTags.ee.vue'; +import { EnterpriseEditionFeature } from '@/constants'; +import { createComponentRenderer } from '@/__tests__/render'; +import { createTestingPinia } from '@pinia/testing'; +import { STORES } from '@n8n/stores'; +import { nextTick } from 'vue'; + +const showError = vi.fn(); +vi.mock('@/composables/useToast', () => ({ + useToast: () => ({ showError }), +})); + +const mockTrack = vi.fn(); +vi.mock('@/composables/useTelemetry', () => ({ + useTelemetry: () => ({ + track: mockTrack, + }), +})); + +const executionDataFactory = ( + tags: Array<{ id: string; name: string }> = [], +): ExecutionSummary => ({ + id: faker.string.uuid(), + finished: faker.datatype.boolean(), + mode: faker.helpers.arrayElement(['manual', 'trigger']), + createdAt: faker.date.past(), + startedAt: faker.date.past(), + stoppedAt: faker.date.past(), + workflowId: faker.number.int().toString(), + workflowName: faker.string.sample(), + status: faker.helpers.arrayElement(['error', 'success']), + nodeExecutionStatus: {}, + retryOf: null, + retrySuccessId: null, + annotation: { tags, vote: 'up' }, +}); + +const renderComponent = createComponentRenderer(WorkflowExecutionAnnotationTags); + +describe('WorkflowExecutionAnnotationTags.ee.vue', () => { + const executionData: ExecutionSummary = executionDataFactory(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('displays existing tags', async () => { + const executionWithTags = { + ...executionData, + annotation: { + tags: [ + { id: 'tag1', name: 'Test Tag 1' }, + { id: 'tag2', name: 'Test Tag 2' }, + ], + vote: 'up' as AnnotationVote, + }, + }; + + const pinia = createTestingPinia({ + initialState: { + [STORES.SETTINGS]: { + settings: { + enterprise: { + [EnterpriseEditionFeature.AdvancedExecutionFilters]: true, + }, + }, + }, + }, + }); + + const { getByTestId, queryByTestId } = renderComponent({ + pinia, + props: { execution: executionWithTags }, + }); + + await nextTick(); + + expect(getByTestId('annotation-tags-container')).toBeInTheDocument(); + expect(getByTestId('execution-annotation-tags')).toBeInTheDocument(); + expect(queryByTestId('workflow-tags-dropdown')).not.toBeInTheDocument(); + expect(getByTestId('execution-annotation-tags')).toHaveTextContent('Test Tag 1'); + expect(getByTestId('execution-annotation-tags')).toHaveTextContent('Test Tag 2'); + }); + + it('shows add tag button when no tags exist', async () => { + const executionWithoutTags = { + ...executionData, + annotation: { + tags: [], + vote: 'up' as AnnotationVote, + }, + }; + + const pinia = createTestingPinia({ + initialState: { + [STORES.SETTINGS]: { + settings: { + enterprise: { + [EnterpriseEditionFeature.AdvancedExecutionFilters]: true, + }, + }, + }, + }, + }); + + const { getByTestId, queryByTestId } = renderComponent({ + pinia, + props: { execution: executionWithoutTags }, + }); + + await nextTick(); + + expect(getByTestId('new-tag-link')).toBeInTheDocument(); + expect(queryByTestId('execution-annotation-tags')).not.toBeInTheDocument(); + }); + + it('shows existing tags with add button when tags exist', async () => { + const executionWithTags = { + ...executionData, + annotation: { + tags: [{ id: 'tag1', name: 'Test Tag 1' }], + vote: 'up' as AnnotationVote, + }, + }; + + const pinia = createTestingPinia({ + initialState: { + [STORES.SETTINGS]: { + settings: { + enterprise: { + [EnterpriseEditionFeature.AdvancedExecutionFilters]: true, + }, + }, + }, + }, + }); + + const { getByTestId } = renderComponent({ + pinia, + props: { execution: executionWithTags }, + }); + + await nextTick(); + + expect(getByTestId('execution-annotation-tags')).toBeInTheDocument(); + expect(getByTestId('new-tag-link')).toBeInTheDocument(); + }); + + it('enables tag editing when add button is clicked', async () => { + const executionWithoutTags = { + ...executionData, + annotation: { tags: [], vote: 'up' as AnnotationVote }, + }; + + const pinia = createTestingPinia({ + initialState: { + [STORES.SETTINGS]: { + settings: { + enterprise: { + [EnterpriseEditionFeature.AdvancedExecutionFilters]: true, + }, + }, + }, + }, + }); + + const { getByTestId, queryByTestId } = renderComponent({ + pinia, + props: { execution: executionWithoutTags }, + }); + + await nextTick(); + + const addTagButton = getByTestId('new-tag-link'); + expect(addTagButton).toBeInTheDocument(); + + expect(queryByTestId('workflow-tags-dropdown')).not.toBeInTheDocument(); + + await userEvent.click(addTagButton); + + expect(getByTestId('workflow-tags-dropdown')).toBeInTheDocument(); + }); + + it('enables tag editing when existing tags are clicked', async () => { + const executionWithTags = { + ...executionData, + annotation: { + tags: [{ id: 'tag1', name: 'Test Tag 1' }], + vote: 'up' as AnnotationVote, + }, + }; + + const pinia = createTestingPinia({ + initialState: { + [STORES.SETTINGS]: { + settings: { + enterprise: { + [EnterpriseEditionFeature.AdvancedExecutionFilters]: true, + }, + }, + }, + }, + }); + + const { getByTestId, queryByTestId } = renderComponent({ + pinia, + props: { execution: executionWithTags }, + }); + + await nextTick(); + + const tagsContainer = getByTestId('execution-annotation-tags'); + expect(tagsContainer).toBeInTheDocument(); + + expect(queryByTestId('workflow-tags-dropdown')).not.toBeInTheDocument(); + + await userEvent.click(tagsContainer); + + expect(getByTestId('workflow-tags-dropdown')).toBeInTheDocument(); + }); +}); diff --git a/packages/frontend/editor-ui/src/components/executions/workflow/WorkflowExecutionAnnotationTags.ee.vue b/packages/frontend/editor-ui/src/components/executions/workflow/WorkflowExecutionAnnotationTags.ee.vue new file mode 100644 index 0000000000..c3e4fb6a7d --- /dev/null +++ b/packages/frontend/editor-ui/src/components/executions/workflow/WorkflowExecutionAnnotationTags.ee.vue @@ -0,0 +1,207 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/components/executions/workflow/WorkflowExecutionsPreview.test.ts b/packages/frontend/editor-ui/src/components/executions/workflow/WorkflowExecutionsPreview.test.ts index a37753ef1d..819ace1a48 100644 --- a/packages/frontend/editor-ui/src/components/executions/workflow/WorkflowExecutionsPreview.test.ts +++ b/packages/frontend/editor-ui/src/components/executions/workflow/WorkflowExecutionsPreview.test.ts @@ -2,7 +2,7 @@ import { describe, expect } from 'vitest'; import userEvent from '@testing-library/user-event'; import { faker } from '@faker-js/faker'; import { createRouter, createWebHistory, RouterLink } from 'vue-router'; -import { randomInt, type ExecutionSummary } from 'n8n-workflow'; +import { randomInt, type ExecutionSummary, type AnnotationVote } from 'n8n-workflow'; import { useSettingsStore } from '@/stores/settings.store'; import WorkflowExecutionsPreview from '@/components/executions/workflow/WorkflowExecutionsPreview.vue'; import { EnterpriseEditionFeature, VIEWS } from '@/constants'; @@ -12,6 +12,8 @@ import { createComponentRenderer } from '@/__tests__/render'; import { createTestingPinia } from '@pinia/testing'; import { mockedStore } from '@/__tests__/utils'; import type { FrontendSettings } from '@n8n/api-types'; +import { STORES } from '@n8n/stores'; +import { nextTick } from 'vue'; const showMessage = vi.fn(); const showError = vi.fn(); @@ -82,7 +84,20 @@ describe('WorkflowExecutionsPreview.vue', () => { const executionData: ExecutionSummary = executionDataFactory(); beforeEach(() => { - createTestingPinia(); + createTestingPinia({ + initialState: { + [STORES.SETTINGS]: { + settings: { + enterprise: { + [EnterpriseEditionFeature.AdvancedExecutionFilters]: true, + }, + }, + }, + [STORES.EXECUTIONS]: { + activeExecution: executionData, + }, + }, + }); }); test.each([ @@ -121,4 +136,182 @@ describe('WorkflowExecutionsPreview.vue', () => { expect(getByTestId('stop-execution')).toBeDisabled(); }); + + it('should display vote buttons when annotation is enabled', async () => { + // Set up the test with annotation enabled + const pinia = createTestingPinia({ + initialState: { + [STORES.SETTINGS]: { + settings: { + enterprise: { + [EnterpriseEditionFeature.AdvancedExecutionFilters]: true, + }, + }, + }, + [STORES.EXECUTIONS]: { + activeExecution: executionData, + }, + }, + }); + + const { getByTestId } = renderComponent({ + props: { execution: executionData }, + pinia, + }); + + await nextTick(); + + // Should show vote buttons container + const voteButtons = getByTestId('execution-preview-vote-buttons'); + expect(voteButtons).toBeInTheDocument(); + + // Should contain two button elements (thumbs up and thumbs down) + const buttons = voteButtons.querySelectorAll('button'); + expect(buttons).toHaveLength(2); + }); + + it('should show active vote state', async () => { + const executionWithUpVote = { + ...executionData, + annotation: { + tags: [], + vote: 'up' as AnnotationVote, + }, + }; + + // Set up the test with an up vote + const pinia = createTestingPinia({ + initialState: { + [STORES.SETTINGS]: { + settings: { + enterprise: { + [EnterpriseEditionFeature.AdvancedExecutionFilters]: true, + }, + }, + }, + [STORES.EXECUTIONS]: { + activeExecution: executionWithUpVote, + }, + }, + }); + + const { getByTestId } = renderComponent({ + props: { execution: executionWithUpVote }, + pinia, + }); + + await nextTick(); + + const voteButtons = getByTestId('execution-preview-vote-buttons'); + expect(voteButtons).toBeInTheDocument(); + + // Should have two buttons for voting + const buttons = voteButtons.querySelectorAll('button'); + expect(buttons).toHaveLength(2); + }); + + it('should display highlighted data dropdown when custom data exists', async () => { + const executionWithCustomData = { + ...executionData, + customData: { key1: 'value1', key2: 'value2' }, + }; + + // Set up the test with custom data + const pinia = createTestingPinia({ + initialState: { + [STORES.SETTINGS]: { + settings: { + enterprise: { + [EnterpriseEditionFeature.AdvancedExecutionFilters]: true, + }, + }, + }, + [STORES.EXECUTIONS]: { + activeExecution: executionWithCustomData, + }, + }, + }); + + const { getByTestId } = renderComponent({ + props: { execution: executionWithCustomData }, + pinia, + }); + + await nextTick(); + + const ellipsisButton = getByTestId('execution-preview-ellipsis-button'); + expect(ellipsisButton).toBeInTheDocument(); + + // Should show badge with custom data count + const badge = ellipsisButton.querySelector('.badge'); + expect(badge).toBeInTheDocument(); + expect(badge?.textContent).toBe('2'); + }); + + it('should not show badge when no custom data exists', async () => { + // Set up the test without custom data + const pinia = createTestingPinia({ + initialState: { + [STORES.SETTINGS]: { + settings: { + enterprise: { + [EnterpriseEditionFeature.AdvancedExecutionFilters]: true, + }, + }, + }, + [STORES.EXECUTIONS]: { + activeExecution: executionData, + }, + }, + }); + + const { getByTestId } = renderComponent({ + props: { execution: executionData }, + pinia, + }); + + await nextTick(); + + const ellipsisButton = getByTestId('execution-preview-ellipsis-button'); + expect(ellipsisButton).toBeInTheDocument(); + + // Should not show badge when no custom data + const badge = ellipsisButton.querySelector('.badge'); + expect(badge).not.toBeInTheDocument(); + }); + + it('should not show vote buttons when annotation is disabled', async () => { + const settingsStore = mockedStore(useSettingsStore); + settingsStore.settings.enterprise = { + ...settingsStore.settings.enterprise, + [EnterpriseEditionFeature.AdvancedExecutionFilters]: false, + } as FrontendSettings['enterprise']; + + const { queryByTestId } = renderComponent({ + props: { execution: executionData }, + }); + + await nextTick(); + + // Should not show vote buttons when annotation is disabled + expect(queryByTestId('execution-preview-vote-buttons')).not.toBeInTheDocument(); + }); + + it('should not show annotation features when annotation is disabled', async () => { + const settingsStore = mockedStore(useSettingsStore); + settingsStore.settings.enterprise = { + ...settingsStore.settings.enterprise, + [EnterpriseEditionFeature.AdvancedExecutionFilters]: false, + } as FrontendSettings['enterprise']; + + const { queryByTestId } = renderComponent({ + props: { execution: executionData }, + }); + + await nextTick(); + + // Should not show annotation-related elements + expect(queryByTestId('annotation-tags-container')).not.toBeInTheDocument(); + expect(queryByTestId('execution-preview-ellipsis-button')).not.toBeInTheDocument(); + }); }); diff --git a/packages/frontend/editor-ui/src/components/executions/workflow/WorkflowExecutionsPreview.vue b/packages/frontend/editor-ui/src/components/executions/workflow/WorkflowExecutionsPreview.vue index 3f811db2e8..3df582f8c4 100644 --- a/packages/frontend/editor-ui/src/components/executions/workflow/WorkflowExecutionsPreview.vue +++ b/packages/frontend/editor-ui/src/components/executions/workflow/WorkflowExecutionsPreview.vue @@ -1,20 +1,22 @@ + + +