From a713b3ed25feb1790412fc320cf41a0967635263 Mon Sep 17 00:00:00 2001 From: Dana <152518854+dana-gill@users.noreply.github.com> Date: Tue, 26 Nov 2024 20:13:53 +0100 Subject: [PATCH 01/37] feat(editor): Make the left sidebar in Expressions editor draggable (#11838) --- .../N8nResizeWrapper/ResizeWrapper.vue | 25 +------- .../N8nResizeableSticky/ResizeableSticky.vue | 4 +- packages/design-system/src/types/index.ts | 1 + packages/design-system/src/types/resize.ts | 22 +++++++ .../AskAssistant/AskAssistantChat.vue | 4 +- .../src/components/CanvasChat/CanvasChat.vue | 4 +- .../CanvasChat/composables/useResize.ts | 2 +- .../src/components/ExpressionEditModal.vue | 64 ++++++++++++------- .../src/components/NDVDraggablePanels.vue | 15 +++-- 9 files changed, 83 insertions(+), 58 deletions(-) create mode 100644 packages/design-system/src/types/resize.ts diff --git a/packages/design-system/src/components/N8nResizeWrapper/ResizeWrapper.vue b/packages/design-system/src/components/N8nResizeWrapper/ResizeWrapper.vue index 3d32365c4b..b5a37da693 100644 --- a/packages/design-system/src/components/N8nResizeWrapper/ResizeWrapper.vue +++ b/packages/design-system/src/components/N8nResizeWrapper/ResizeWrapper.vue @@ -1,6 +1,8 @@ diff --git a/packages/editor-ui/src/components/TestDefinition/EditDefinition/DescriptionInput.vue b/packages/editor-ui/src/components/TestDefinition/EditDefinition/DescriptionInput.vue new file mode 100644 index 0000000000..9b0ec16a7e --- /dev/null +++ b/packages/editor-ui/src/components/TestDefinition/EditDefinition/DescriptionInput.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/packages/editor-ui/src/components/TestDefinition/EditDefinition/EvaluationHeader.vue b/packages/editor-ui/src/components/TestDefinition/EditDefinition/EvaluationHeader.vue new file mode 100644 index 0000000000..3f21e6a399 --- /dev/null +++ b/packages/editor-ui/src/components/TestDefinition/EditDefinition/EvaluationHeader.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/packages/editor-ui/src/components/TestDefinition/EditDefinition/EvaluationStep.vue b/packages/editor-ui/src/components/TestDefinition/EditDefinition/EvaluationStep.vue new file mode 100644 index 0000000000..f942613a35 --- /dev/null +++ b/packages/editor-ui/src/components/TestDefinition/EditDefinition/EvaluationStep.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/packages/editor-ui/src/components/TestDefinition/EditDefinition/MetricsInput.vue b/packages/editor-ui/src/components/TestDefinition/EditDefinition/MetricsInput.vue new file mode 100644 index 0000000000..e899c8be2d --- /dev/null +++ b/packages/editor-ui/src/components/TestDefinition/EditDefinition/MetricsInput.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/packages/editor-ui/src/components/TestDefinition/EditDefinition/TagsInput.vue b/packages/editor-ui/src/components/TestDefinition/EditDefinition/TagsInput.vue new file mode 100644 index 0000000000..91e4df6261 --- /dev/null +++ b/packages/editor-ui/src/components/TestDefinition/EditDefinition/TagsInput.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/packages/editor-ui/src/components/TestDefinition/EditDefinition/WorkflowSelector.vue b/packages/editor-ui/src/components/TestDefinition/EditDefinition/WorkflowSelector.vue new file mode 100644 index 0000000000..87626d3ae3 --- /dev/null +++ b/packages/editor-ui/src/components/TestDefinition/EditDefinition/WorkflowSelector.vue @@ -0,0 +1,43 @@ + + diff --git a/packages/editor-ui/src/components/TestDefinition/ListDefinition/EmptyState.vue b/packages/editor-ui/src/components/TestDefinition/ListDefinition/EmptyState.vue new file mode 100644 index 0000000000..9f507c327f --- /dev/null +++ b/packages/editor-ui/src/components/TestDefinition/ListDefinition/EmptyState.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/packages/editor-ui/src/components/TestDefinition/ListDefinition/TestItem.vue b/packages/editor-ui/src/components/TestDefinition/ListDefinition/TestItem.vue new file mode 100644 index 0000000000..350c48f663 --- /dev/null +++ b/packages/editor-ui/src/components/TestDefinition/ListDefinition/TestItem.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/packages/editor-ui/src/components/TestDefinition/ListDefinition/TestsList.vue b/packages/editor-ui/src/components/TestDefinition/ListDefinition/TestsList.vue new file mode 100644 index 0000000000..e5e169dbe0 --- /dev/null +++ b/packages/editor-ui/src/components/TestDefinition/ListDefinition/TestsList.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/packages/editor-ui/src/components/TestDefinition/composables/useTestDefinitionForm.ts b/packages/editor-ui/src/components/TestDefinition/composables/useTestDefinitionForm.ts new file mode 100644 index 0000000000..1a2ccd988a --- /dev/null +++ b/packages/editor-ui/src/components/TestDefinition/composables/useTestDefinitionForm.ts @@ -0,0 +1,200 @@ +import { ref, computed } from 'vue'; +import type { ComponentPublicInstance } from 'vue'; +import type { INodeParameterResourceLocator } from 'n8n-workflow'; +import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee'; +import type AnnotationTagsDropdownEe from '@/components/AnnotationTagsDropdown.ee.vue'; +import type { N8nInput } from 'n8n-design-system'; +import type { UpdateTestDefinitionParams } from '@/api/testDefinition.ee'; + +interface EditableField { + value: string; + isEditing: boolean; + tempValue: string; +} + +export interface IEvaluationFormState { + name: EditableField; + description: string; + tags: { + isEditing: boolean; + appliedTagIds: string[]; + }; + evaluationWorkflow: INodeParameterResourceLocator; + metrics: string[]; +} + +type FormRefs = { + nameInput: ComponentPublicInstance; + tagsInput: ComponentPublicInstance; +}; + +export function useTestDefinitionForm() { + // Stores + const evaluationsStore = useTestDefinitionStore(); + + // Form state + const state = ref({ + description: '', + name: { + value: `My Test [${new Date().toLocaleString(undefined, { month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' })}]`, + isEditing: false, + tempValue: '', + }, + tags: { + isEditing: false, + appliedTagIds: [], + }, + evaluationWorkflow: { + mode: 'list', + value: '', + __rl: true, + }, + metrics: [''], + }); + + // Loading states + const isSaving = ref(false); + const fieldsIssues = ref>([]); + + // Field refs + const fields = ref({} as FormRefs); + + // Methods + const loadTestData = async (testId: string) => { + try { + await evaluationsStore.fetchAll({ force: true }); + const testDefinition = evaluationsStore.testDefinitionsById[testId]; + + if (testDefinition) { + state.value = { + description: testDefinition.description ?? '', + name: { + value: testDefinition.name ?? '', + isEditing: false, + tempValue: '', + }, + tags: { + isEditing: false, + appliedTagIds: testDefinition.annotationTagId ? [testDefinition.annotationTagId] : [], + }, + evaluationWorkflow: { + mode: 'list', + value: testDefinition.evaluationWorkflowId ?? '', + __rl: true, + }, + metrics: [''], + }; + } + } catch (error) { + // TODO: Throw better errors + console.error('Failed to load test data', error); + } + }; + + const createTest = async (workflowId: string) => { + if (isSaving.value) return; + + isSaving.value = true; + fieldsIssues.value = []; + + try { + // Prepare parameters for creating a new test + const params = { + name: state.value.name.value, + workflowId, + description: state.value.description, + }; + + const newTest = await evaluationsStore.create(params); + return newTest; + } catch (error) { + throw error; + } finally { + isSaving.value = false; + } + }; + + const updateTest = async (testId: string) => { + if (isSaving.value) return; + + isSaving.value = true; + fieldsIssues.value = []; + + try { + // Check if the test ID is provided + if (!testId) { + throw new Error('Test ID is required for updating a test'); + } + + // Prepare parameters for updating the existing test + const params: UpdateTestDefinitionParams = { + name: state.value.name.value, + description: state.value.description, + }; + if (state.value.evaluationWorkflow.value) { + params.evaluationWorkflowId = state.value.evaluationWorkflow.value.toString(); + } + + const annotationTagId = state.value.tags.appliedTagIds[0]; + if (annotationTagId) { + params.annotationTagId = annotationTagId; + } + // Update the existing test + return await evaluationsStore.update({ ...params, id: testId }); + } catch (error) { + throw error; + } finally { + isSaving.value = false; + } + }; + + const startEditing = async (field: string) => { + if (field === 'name') { + state.value.name.tempValue = state.value.name.value; + state.value.name.isEditing = true; + } else { + state.value.tags.isEditing = true; + } + }; + + const saveChanges = (field: string) => { + if (field === 'name') { + state.value.name.value = state.value.name.tempValue; + state.value.name.isEditing = false; + } else { + state.value.tags.isEditing = false; + } + }; + + const cancelEditing = (field: string) => { + if (field === 'name') { + state.value.name.isEditing = false; + state.value.name.tempValue = ''; + } else { + state.value.tags.isEditing = false; + } + }; + + const handleKeydown = (event: KeyboardEvent, field: string) => { + if (event.key === 'Escape') { + cancelEditing(field); + } else if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + saveChanges(field); + } + }; + + return { + state, + fields, + isSaving: computed(() => isSaving.value), + fieldsIssues: computed(() => fieldsIssues.value), + loadTestData, + createTest, + updateTest, + startEditing, + saveChanges, + cancelEditing, + handleKeydown, + }; +} diff --git a/packages/editor-ui/src/components/TestDefinition/tests/MetricsInput.test.ts b/packages/editor-ui/src/components/TestDefinition/tests/MetricsInput.test.ts new file mode 100644 index 0000000000..43b2164521 --- /dev/null +++ b/packages/editor-ui/src/components/TestDefinition/tests/MetricsInput.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createComponentRenderer } from '@/__tests__/render'; +import MetricsInput from '../EditDefinition/MetricsInput.vue'; +import userEvent from '@testing-library/user-event'; + +const renderComponent = createComponentRenderer(MetricsInput); + +describe('MetricsInput', () => { + let props: { modelValue: string[] }; + + beforeEach(() => { + props = { + modelValue: ['Metric 1', 'Metric 2'], + }; + }); + + it('should render correctly with initial metrics', () => { + const { getAllByPlaceholderText } = renderComponent({ props }); + const inputs = getAllByPlaceholderText('Enter metric name'); + expect(inputs).toHaveLength(2); + expect(inputs[0]).toHaveValue('Metric 1'); + expect(inputs[1]).toHaveValue('Metric 2'); + }); + + it('should update a metric when typing in the input', async () => { + const { getAllByPlaceholderText, emitted } = renderComponent({ + props: { + modelValue: [''], + }, + }); + const inputs = getAllByPlaceholderText('Enter metric name'); + await userEvent.type(inputs[0], 'Updated Metric 1'); + + expect(emitted('update:modelValue')).toBeTruthy(); + expect(emitted('update:modelValue')).toEqual('Updated Metric 1'.split('').map((c) => [[c]])); + }); + + it('should render correctly with no initial metrics', () => { + props.modelValue = []; + const { queryAllByRole, getByText } = renderComponent({ props }); + const inputs = queryAllByRole('textbox'); + expect(inputs).toHaveLength(0); + expect(getByText('New metric')).toBeInTheDocument(); + }); + + it('should handle adding multiple metrics', async () => { + const { getByText, emitted } = renderComponent({ props }); + const addButton = getByText('New metric'); + + addButton.click(); + addButton.click(); + addButton.click(); + + expect(emitted('update:modelValue')).toHaveProperty('length', 3); + }); +}); diff --git a/packages/editor-ui/src/components/TestDefinition/tests/useTestDefinitionForm.test.ts b/packages/editor-ui/src/components/TestDefinition/tests/useTestDefinitionForm.test.ts new file mode 100644 index 0000000000..952807d7cc --- /dev/null +++ b/packages/editor-ui/src/components/TestDefinition/tests/useTestDefinitionForm.test.ts @@ -0,0 +1,176 @@ +import { setActivePinia } from 'pinia'; +import { createTestingPinia } from '@pinia/testing'; +import { useTestDefinitionForm } from '../composables/useTestDefinitionForm'; +import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee'; +import { mockedStore } from '@/__tests__/utils'; +import type { TestDefinitionRecord } from '@/api/testDefinition.ee'; + +const TEST_DEF_A: TestDefinitionRecord = { + id: '1', + name: 'Test Definition A', + description: 'Description A', + evaluationWorkflowId: '456', + workflowId: '123', + annotationTagId: '789', +}; +const TEST_DEF_B: TestDefinitionRecord = { + id: '2', + name: 'Test Definition B', + workflowId: '123', + description: 'Description B', +}; +const TEST_DEF_NEW: TestDefinitionRecord = { + id: '3', + workflowId: '123', + name: 'New Test Definition', + description: 'New Description', +}; + +beforeEach(() => { + const pinia = createTestingPinia(); + setActivePinia(pinia); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('useTestDefinitionForm', async () => { + it('should initialize with default props', async () => { + const { state } = useTestDefinitionForm(); + + expect(state.value.description).toEqual(''); + expect(state.value.name.value).toContain('My Test'); + expect(state.value.tags.appliedTagIds).toEqual([]); + expect(state.value.metrics).toEqual(['']); + expect(state.value.evaluationWorkflow.value).toEqual(''); + }); + + it('should load test data', async () => { + const { loadTestData, state } = useTestDefinitionForm(); + const fetchSpy = vi.fn(); + const evaluationsStore = mockedStore(useTestDefinitionStore); + + expect(state.value.description).toEqual(''); + expect(state.value.name.value).toContain('My Test'); + evaluationsStore.testDefinitionsById = { + [TEST_DEF_A.id]: TEST_DEF_A, + [TEST_DEF_B.id]: TEST_DEF_B, + }; + evaluationsStore.fetchAll = fetchSpy; + + await loadTestData(TEST_DEF_A.id); + expect(fetchSpy).toBeCalled(); + expect(state.value.name.value).toEqual(TEST_DEF_A.name); + expect(state.value.description).toEqual(TEST_DEF_A.description); + expect(state.value.tags.appliedTagIds).toEqual([TEST_DEF_A.annotationTagId]); + expect(state.value.evaluationWorkflow.value).toEqual(TEST_DEF_A.evaluationWorkflowId); + }); + + it('should save a new test', async () => { + const { createTest, state } = useTestDefinitionForm(); + const createSpy = vi.fn().mockResolvedValue(TEST_DEF_NEW); + const evaluationsStore = mockedStore(useTestDefinitionStore); + + evaluationsStore.create = createSpy; + + state.value.name.value = TEST_DEF_NEW.name; + state.value.description = TEST_DEF_NEW.description ?? ''; + + const newTest = await createTest('123'); + expect(createSpy).toBeCalledWith({ + name: TEST_DEF_NEW.name, + description: TEST_DEF_NEW.description, + workflowId: '123', + }); + expect(newTest).toEqual(TEST_DEF_NEW); + }); + + it('should update an existing test', async () => { + const { updateTest, state } = useTestDefinitionForm(); + const updateSpy = vi.fn().mockResolvedValue(TEST_DEF_B); + const evaluationsStore = mockedStore(useTestDefinitionStore); + + evaluationsStore.update = updateSpy; + + state.value.name.value = TEST_DEF_B.name; + state.value.description = TEST_DEF_B.description ?? ''; + + const updatedTest = await updateTest(TEST_DEF_A.id); + expect(updateSpy).toBeCalledWith({ + id: TEST_DEF_A.id, + name: TEST_DEF_B.name, + description: TEST_DEF_B.description, + }); + expect(updatedTest).toEqual(TEST_DEF_B); + }); + + it('should start editing a field', async () => { + const { state, startEditing } = useTestDefinitionForm(); + + await startEditing('name'); + expect(state.value.name.isEditing).toBe(true); + expect(state.value.name.tempValue).toBe(state.value.name.value); + + await startEditing('tags'); + expect(state.value.tags.isEditing).toBe(true); + }); + + it('should save changes to a field', async () => { + const { state, startEditing, saveChanges } = useTestDefinitionForm(); + + await startEditing('name'); + state.value.name.tempValue = 'New Name'; + saveChanges('name'); + expect(state.value.name.isEditing).toBe(false); + expect(state.value.name.value).toBe('New Name'); + + await startEditing('tags'); + state.value.tags.appliedTagIds = ['123']; + saveChanges('tags'); + expect(state.value.tags.isEditing).toBe(false); + expect(state.value.tags.appliedTagIds).toEqual(['123']); + }); + + it('should cancel editing a field', async () => { + const { state, startEditing, cancelEditing } = useTestDefinitionForm(); + + await startEditing('name'); + state.value.name.tempValue = 'New Name'; + cancelEditing('name'); + expect(state.value.name.isEditing).toBe(false); + expect(state.value.name.tempValue).toBe(''); + + await startEditing('tags'); + state.value.tags.appliedTagIds = ['123']; + cancelEditing('tags'); + expect(state.value.tags.isEditing).toBe(false); + }); + + it('should handle keydown - Escape', async () => { + const { state, startEditing, handleKeydown } = useTestDefinitionForm(); + + await startEditing('name'); + handleKeydown(new KeyboardEvent('keydown', { key: 'Escape' }), 'name'); + expect(state.value.name.isEditing).toBe(false); + + await startEditing('tags'); + handleKeydown(new KeyboardEvent('keydown', { key: 'Escape' }), 'tags'); + expect(state.value.tags.isEditing).toBe(false); + }); + + it('should handle keydown - Enter', async () => { + const { state, startEditing, handleKeydown } = useTestDefinitionForm(); + + await startEditing('name'); + state.value.name.tempValue = 'New Name'; + handleKeydown(new KeyboardEvent('keydown', { key: 'Enter' }), 'name'); + expect(state.value.name.isEditing).toBe(false); + expect(state.value.name.value).toBe('New Name'); + + await startEditing('tags'); + state.value.tags.appliedTagIds = ['123']; + handleKeydown(new KeyboardEvent('keydown', { key: 'Enter' }), 'tags'); + expect(state.value.tags.isEditing).toBe(false); + }); +}); diff --git a/packages/editor-ui/src/components/TestDefinition/types.ts b/packages/editor-ui/src/components/TestDefinition/types.ts new file mode 100644 index 0000000000..68a9d246a5 --- /dev/null +++ b/packages/editor-ui/src/components/TestDefinition/types.ts @@ -0,0 +1,13 @@ +export interface TestExecution { + lastRun: string | null; + errorRate: number | null; + metrics: Record; +} + +export interface TestListItem { + id: string; + name: string; + tagName: string; + testCases: number; + execution: TestExecution; +} diff --git a/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.vue b/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.vue index 0a4dbecba3..8422699fee 100644 --- a/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.vue +++ b/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.vue @@ -459,8 +459,8 @@ async function onAutoRefreshToggle(value: boolean) { position: relative; height: 100%; width: 100%; - max-width: 1280px; padding: var(--spacing-l) var(--spacing-2xl) 0; + max-width: var(--content-container-width); } .execList { diff --git a/packages/editor-ui/src/components/layouts/PageViewLayout.vue b/packages/editor-ui/src/components/layouts/PageViewLayout.vue index 3500700373..419a41663f 100644 --- a/packages/editor-ui/src/components/layouts/PageViewLayout.vue +++ b/packages/editor-ui/src/components/layouts/PageViewLayout.vue @@ -13,7 +13,7 @@ flex-direction: column; height: 100%; width: 100%; - max-width: 1280px; + max-width: var(--content-container-width); box-sizing: border-box; align-content: start; padding: var(--spacing-l) var(--spacing-2xl) 0; diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index 90332668f3..0ee70b9929 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -495,6 +495,9 @@ export const enum VIEWS { COMMUNITY_NODES = 'CommunityNodes', WORKFLOWS = 'WorkflowsView', WORKFLOW_EXECUTIONS = 'WorkflowExecutions', + TEST_DEFINITION = 'TestDefinition', + TEST_DEFINITION_EDIT = 'TestDefinitionEdit', + NEW_TEST_DEFINITION = 'NewTestDefinition', USAGE = 'Usage', LOG_STREAMING_SETTINGS = 'LogStreamingSettingsView', SSO_SETTINGS = 'SSoSettings', @@ -591,6 +594,7 @@ export const enum MAIN_HEADER_TABS { WORKFLOW = 'workflow', EXECUTIONS = 'executions', SETTINGS = 'settings', + TEST_DEFINITION = 'testDefinition', } export const CURL_IMPORT_NOT_SUPPORTED_PROTOCOLS = [ 'ftp', @@ -652,6 +656,7 @@ export const enum STORES { ASSISTANT = 'assistant', BECOME_TEMPLATE_CREATOR = 'becomeTemplateCreator', PROJECTS = 'projects', + TEST_DEFINITION = 'testDefinition', } export const enum SignInType { @@ -709,6 +714,8 @@ export const EXPERIMENTS_TO_TRACK = [ CREDENTIAL_DOCS_EXPERIMENT.name, ]; +export const WORKFLOW_EVALUATION_EXPERIMENT = '025_workflow_evaluation'; + export const MFA_FORM = { MFA_TOKEN: 'MFA_TOKEN', MFA_RECOVERY_CODE: 'MFA_RECOVERY_CODE', diff --git a/packages/editor-ui/src/n8n-theme.scss b/packages/editor-ui/src/n8n-theme.scss index 05064ca78a..33fa11cb5f 100644 --- a/packages/editor-ui/src/n8n-theme.scss +++ b/packages/editor-ui/src/n8n-theme.scss @@ -4,6 +4,7 @@ :root { // Using native css variable enables us to use this value in JS --header-height: 65; + --content-container-width: 1280px; } .clickable { diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index e78fd1b105..d0d959ce67 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -47,6 +47,7 @@ "generic.delete": "Delete", "generic.dontShowAgain": "Don't show again", "generic.executions": "Executions", + "generic.tests": "Tests", "generic.or": "or", "generic.clickToCopy": "Click to copy", "generic.copiedToClipboard": "Copied to clipboard", @@ -2716,5 +2717,47 @@ "communityPlusModal.button.skip": "Skip", "communityPlusModal.button.confirm": "Send me a free license key", "executeWorkflowTrigger.createNewSubworkflow": "Create a sub-workflow in {projectName}", - "executeWorkflowTrigger.createNewSubworkflow.noProject": "Create a new sub-workflow" + "executeWorkflowTrigger.createNewSubworkflow.noProject": "Create a new sub-workflow", + "testDefinition.edit.descriptionPlaceholder": "", + "testDefinition.edit.backButtonTitle": "Back to Workflow Evaluation", + "testDefinition.edit.namePlaceholder": "Enter test name", + "testDefinition.edit.metricsTitle": "Metrics", + "testDefinition.edit.metricsHelpText": "The output field of the last node in the evaluation workflow. Metrics will be averaged across all test cases.", + "testDefinition.edit.metricsFields": "Output field(s)", + "testDefinition.edit.metricsPlaceholder": "Enter metric name", + "testDefinition.edit.metricsNew": "New metric", + "testDefinition.edit.selectTag": "Select tag...", + "testDefinition.edit.tagsHelpText": "Executions with this tag will be added as test cases to this test.", + "testDefinition.edit.workflowSelectorLabel": "Workflow to make comparisons", + "testDefinition.edit.workflowSelectorDisplayName": "Workflow", + "testDefinition.edit.workflowSelectorTitle": "Workflow to make comparisons", + "testDefinition.edit.workflowSelectorHelpText": "This workflow will be called once for each test case.", + "testDefinition.edit.updateTest": "Update test", + "testDefinition.edit.saveTest": "Run test", + "testDefinition.edit.testSaved": "Test saved", + "testDefinition.edit.testSaveFailed": "Failed to save test", + "testDefinition.edit.description": "Description", + "testDefinition.edit.tagName": "Tag name", + "testDefinition.edit.step.intro": "When running a test", + "testDefinition.edit.step.executions": "Fetch 5 past executions", + "testDefinition.edit.step.nodes": "Mock nodes", + "testDefinition.edit.step.mockedNodes": "No nodes mocked | {count} node mocked | {count} nodes mocked", + "testDefinition.edit.step.reRunExecutions": "Re-run executions", + "testDefinition.edit.step.compareExecutions": "Compare each past and new execution", + "testDefinition.edit.step.metrics": "Summarise metrics", + "testDefinition.edit.step.collapse": "Collapse", + "testDefinition.edit.step.expand": "Expand", + "testDefinition.list.testDeleted": "Test deleted", + "testDefinition.list.tests": "Tests", + "testDefinition.list.createNew": "Create new test", + "testDefinition.list.actionDescription": "Replay past executions to check whether performance has changed", + "testDefinition.list.actionButton": "Create Test", + "testDefinition.list.testCases": "No test cases | {count} test case | {count} test cases", + "testDefinition.list.lastRun": "Ran {lastRun}", + "testDefinition.list.errorRate": "Error rate: {errorRate}", + "testDefinition.runTest": "Run Test", + "testDefinition.notImplemented": "This feature is not implemented yet!", + "testDefinition.viewDetails": "View Details", + "testDefinition.editTest": "Edit Test", + "testDefinition.deleteTest": "Delete Test" } diff --git a/packages/editor-ui/src/plugins/icons/index.ts b/packages/editor-ui/src/plugins/icons/index.ts index 0e9430d8ae..561ad6afbf 100644 --- a/packages/editor-ui/src/plugins/icons/index.ts +++ b/packages/editor-ui/src/plugins/icons/index.ts @@ -51,6 +51,7 @@ import { faEllipsisH, faEllipsisV, faEnvelope, + faEquals, faEye, faExclamationTriangle, faExpand, @@ -223,6 +224,7 @@ export const FontAwesomePlugin: Plugin = { addIcon(faEllipsisH); addIcon(faEllipsisV); addIcon(faEnvelope); + addIcon(faEquals); addIcon(faEye); addIcon(faExclamationTriangle); addIcon(faExpand); diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index 1580a3bff4..f3bf9f656a 100644 --- a/packages/editor-ui/src/router.ts +++ b/packages/editor-ui/src/router.ts @@ -57,6 +57,10 @@ const SettingsExternalSecrets = async () => await import('./views/SettingsExtern const WorkerView = async () => await import('./views/WorkerView.vue'); const WorkflowHistory = async () => await import('@/views/WorkflowHistory.vue'); const WorkflowOnboardingView = async () => await import('@/views/WorkflowOnboardingView.vue'); +const TestDefinitionListView = async () => + await import('./views/TestDefinition/TestDefinitionListView.vue'); +const TestDefinitionEditView = async () => + await import('./views/TestDefinition/TestDefinitionEditView.vue'); function getTemplatesRedirect(defaultRedirect: VIEWS[keyof VIEWS]): { name: string } | false { const settingsStore = useSettingsStore(); @@ -249,6 +253,55 @@ export const routes: RouteRecordRaw[] = [ }, ], }, + { + path: '/workflow/:name/evaluation', + name: VIEWS.TEST_DEFINITION, + meta: { + keepWorkflowAlive: true, + middleware: ['authenticated'], + }, + children: [ + { + path: '', + name: VIEWS.TEST_DEFINITION, + components: { + default: TestDefinitionListView, + header: MainHeader, + sidebar: MainSidebar, + }, + meta: { + keepWorkflowAlive: true, + middleware: ['authenticated'], + }, + }, + { + path: 'new', + name: VIEWS.NEW_TEST_DEFINITION, + components: { + default: TestDefinitionEditView, + header: MainHeader, + sidebar: MainSidebar, + }, + meta: { + keepWorkflowAlive: true, + middleware: ['authenticated'], + }, + }, + { + path: ':testId', + name: VIEWS.TEST_DEFINITION_EDIT, + components: { + default: TestDefinitionEditView, + header: MainHeader, + sidebar: MainSidebar, + }, + meta: { + keepWorkflowAlive: true, + middleware: ['authenticated'], + }, + }, + ], + }, { path: '/workflow/:workflowId/history/:versionId?', name: VIEWS.WORKFLOW_HISTORY, diff --git a/packages/editor-ui/src/stores/testDefinition.store.ee.test.ts b/packages/editor-ui/src/stores/testDefinition.store.ee.test.ts new file mode 100644 index 0000000000..702701a495 --- /dev/null +++ b/packages/editor-ui/src/stores/testDefinition.store.ee.test.ts @@ -0,0 +1,264 @@ +import { createPinia, setActivePinia } from 'pinia'; +import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee'; // Adjust the import path as necessary +import { useRootStore } from '@/stores/root.store'; +import { usePostHog } from '@/stores/posthog.store'; +import type { TestDefinitionRecord } from '@/api/testDefinition.ee'; + +const { createTestDefinition, deleteTestDefinition, getTestDefinitions, updateTestDefinition } = + vi.hoisted(() => ({ + getTestDefinitions: vi.fn(), + createTestDefinition: vi.fn(), + updateTestDefinition: vi.fn(), + deleteTestDefinition: vi.fn(), + })); + +vi.mock('@/api/testDefinition.ee', () => ({ + createTestDefinition, + deleteTestDefinition, + getTestDefinitions, + updateTestDefinition, +})); + +vi.mock('@/stores/root.store', () => ({ + useRootStore: vi.fn(() => ({ + restApiContext: { instanceId: 'test-instance-id' }, + })), +})); + +const TEST_DEF_A: TestDefinitionRecord = { + id: '1', + name: 'Test Definition A', + workflowId: '123', + description: 'Description A', +}; +const TEST_DEF_B: TestDefinitionRecord = { + id: '2', + name: 'Test Definition B', + workflowId: '123', + description: 'Description B', +}; +const TEST_DEF_NEW: TestDefinitionRecord = { + id: '3', + name: 'New Test Definition', + workflowId: '123', + description: 'New Description', +}; + +describe('testDefinition.store.ee', () => { + let store: ReturnType; + let rootStoreMock: ReturnType; + let posthogStoreMock: ReturnType; + + beforeEach(() => { + vi.restoreAllMocks(); + setActivePinia(createPinia()); + store = useTestDefinitionStore(); + rootStoreMock = useRootStore(); + posthogStoreMock = usePostHog(); + + getTestDefinitions.mockResolvedValue({ + count: 2, + testDefinitions: [TEST_DEF_A, TEST_DEF_B], + }); + + createTestDefinition.mockResolvedValue(TEST_DEF_NEW); + + deleteTestDefinition.mockResolvedValue({ success: true }); + }); + + test('Initialization', () => { + expect(store.testDefinitionsById).toEqual({}); + expect(store.isLoading).toBe(false); + expect(store.hasTestDefinitions).toBe(false); + }); + + test('Fetching Test Definitions', async () => { + expect(store.isLoading).toBe(false); + + const result = await store.fetchAll(); + + expect(getTestDefinitions).toHaveBeenCalledWith(rootStoreMock.restApiContext); + expect(store.testDefinitionsById).toEqual({ + '1': TEST_DEF_A, + '2': TEST_DEF_B, + }); + expect(store.isLoading).toBe(false); + expect(result).toEqual({ + count: 2, + testDefinitions: [TEST_DEF_A, TEST_DEF_B], + }); + }); + + test('Fetching Test Definitions with force flag', async () => { + expect(store.isLoading).toBe(false); + + const result = await store.fetchAll({ force: true }); + + expect(getTestDefinitions).toHaveBeenCalledWith(rootStoreMock.restApiContext); + expect(store.testDefinitionsById).toEqual({ + '1': TEST_DEF_A, + '2': TEST_DEF_B, + }); + expect(store.isLoading).toBe(false); + expect(result).toEqual({ + count: 2, + testDefinitions: [TEST_DEF_A, TEST_DEF_B], + }); + }); + + test('Fetching Test Definitions when already fetched', async () => { + store.fetchedAll = true; + + const result = await store.fetchAll(); + + expect(getTestDefinitions).not.toHaveBeenCalled(); + expect(store.testDefinitionsById).toEqual({}); + expect(result).toEqual({ + count: 0, + testDefinitions: [], + }); + }); + + test('Upserting Test Definitions - New Definition', () => { + const newDefinition = TEST_DEF_NEW; + + store.upsertTestDefinitions([newDefinition]); + + expect(store.testDefinitionsById).toEqual({ + '3': TEST_DEF_NEW, + }); + }); + + test('Upserting Test Definitions - Existing Definition', () => { + store.testDefinitionsById = { + '1': TEST_DEF_A, + }; + + const updatedDefinition = { + id: '1', + name: 'Updated Test Definition A', + description: 'Updated Description A', + workflowId: '123', + }; + + store.upsertTestDefinitions([updatedDefinition]); + + expect(store.testDefinitionsById).toEqual({ + 1: updatedDefinition, + }); + }); + + test('Deleting Test Definitions', () => { + store.testDefinitionsById = { + '1': TEST_DEF_A, + '2': TEST_DEF_B, + }; + + store.deleteTestDefinition('1'); + + expect(store.testDefinitionsById).toEqual({ + '2': TEST_DEF_B, + }); + }); + + test('Creating a Test Definition', async () => { + const params = { + name: 'New Test Definition', + workflowId: 'test-workflow-id', + evaluationWorkflowId: 'test-evaluation-workflow-id', + description: 'New Description', + }; + + const result = await store.create(params); + + expect(createTestDefinition).toHaveBeenCalledWith(rootStoreMock.restApiContext, params); + expect(store.testDefinitionsById).toEqual({ + '3': TEST_DEF_NEW, + }); + expect(result).toEqual(TEST_DEF_NEW); + }); + + test('Updating a Test Definition', async () => { + store.testDefinitionsById = { + '1': TEST_DEF_A, + '2': TEST_DEF_B, + }; + + const params = { + id: '1', + name: 'Updated Test Definition A', + description: 'Updated Description A', + workflowId: '123', + }; + updateTestDefinition.mockResolvedValue(params); + + const result = await store.update(params); + + expect(updateTestDefinition).toHaveBeenCalledWith(rootStoreMock.restApiContext, '1', { + name: 'Updated Test Definition A', + description: 'Updated Description A', + workflowId: '123', + }); + expect(store.testDefinitionsById).toEqual({ + '1': params, + '2': TEST_DEF_B, + }); + expect(result).toEqual(params); + }); + + test('Deleting a Test Definition by ID', async () => { + store.testDefinitionsById = { + '1': TEST_DEF_A, + }; + + const result = await store.deleteById('1'); + + expect(deleteTestDefinition).toHaveBeenCalledWith(rootStoreMock.restApiContext, '1'); + expect(store.testDefinitionsById).toEqual({}); + expect(result).toBe(true); + }); + + test('Computed Properties - hasTestDefinitions', () => { + store.testDefinitionsById = {}; + + expect(store.hasTestDefinitions).toBe(false); + store.testDefinitionsById = { + '1': TEST_DEF_A, + }; + + expect(store.hasTestDefinitions).toBe(true); + }); + + test('Computed Properties - isFeatureEnabled', () => { + posthogStoreMock.isFeatureEnabled = vi.fn().mockReturnValue(false); + + expect(store.isFeatureEnabled).toBe(false); + posthogStoreMock.isFeatureEnabled = vi.fn().mockReturnValue(true); + + expect(store.isFeatureEnabled).toBe(true); + }); + + test('Error Handling - create', async () => { + createTestDefinition.mockRejectedValue(new Error('Create failed')); + + await expect( + store.create({ name: 'New Test Definition', workflowId: 'test-workflow-id' }), + ).rejects.toThrow('Create failed'); + }); + + test('Error Handling - update', async () => { + updateTestDefinition.mockRejectedValue(new Error('Update failed')); + + await expect(store.update({ id: '1', name: 'Updated Test Definition A' })).rejects.toThrow( + 'Update failed', + ); + }); + + test('Error Handling - deleteById', async () => { + deleteTestDefinition.mockResolvedValue({ success: false }); + + const result = await store.deleteById('1'); + + expect(result).toBe(false); + }); +}); diff --git a/packages/editor-ui/src/stores/testDefinition.store.ee.ts b/packages/editor-ui/src/stores/testDefinition.store.ee.ts new file mode 100644 index 0000000000..6a9e3fe363 --- /dev/null +++ b/packages/editor-ui/src/stores/testDefinition.store.ee.ts @@ -0,0 +1,171 @@ +import { defineStore } from 'pinia'; +import { computed, ref } from 'vue'; +import { useRootStore } from './root.store'; +import * as testDefinitionsApi from '@/api/testDefinition.ee'; +import type { TestDefinitionRecord } from '@/api/testDefinition.ee'; +import { usePostHog } from './posthog.store'; +import { STORES, WORKFLOW_EVALUATION_EXPERIMENT } from '@/constants'; + +export const useTestDefinitionStore = defineStore( + STORES.TEST_DEFINITION, + () => { + // State + const testDefinitionsById = ref>({}); + const loading = ref(false); + const fetchedAll = ref(false); + + // Store instances + const posthogStore = usePostHog(); + const rootStore = useRootStore(); + + // Computed + const allTestDefinitions = computed(() => { + return Object.values(testDefinitionsById.value).sort((a, b) => + (a.name ?? '').localeCompare(b.name ?? ''), + ); + }); + + // Enable with `window.featureFlags.override('025_workflow_evaluation', true)` + const isFeatureEnabled = computed(() => + posthogStore.isFeatureEnabled(WORKFLOW_EVALUATION_EXPERIMENT), + ); + + const isLoading = computed(() => loading.value); + + const hasTestDefinitions = computed(() => Object.keys(testDefinitionsById.value).length > 0); + + // Methods + const setAllTestDefinitions = (definitions: TestDefinitionRecord[]) => { + testDefinitionsById.value = definitions.reduce( + (acc: Record, def: TestDefinitionRecord) => { + acc[def.id] = def; + return acc; + }, + {}, + ); + }; + + /** + * Upserts test definitions in the store. + * @param toUpsertDefinitions - An array of test definitions to upsert. + */ + const upsertTestDefinitions = (toUpsertDefinitions: TestDefinitionRecord[]) => { + toUpsertDefinitions.forEach((toUpsertDef) => { + const defId = toUpsertDef.id; + if (!defId) throw Error('ID is required for upserting'); + const currentDef = testDefinitionsById.value[defId]; + testDefinitionsById.value = { + ...testDefinitionsById.value, + [defId]: { + ...currentDef, + ...toUpsertDef, + }, + }; + }); + }; + + const deleteTestDefinition = (id: string) => { + const { [id]: deleted, ...rest } = testDefinitionsById.value; + testDefinitionsById.value = rest; + }; + + /** + * Fetches all test definitions from the API. + * @param {boolean} force - If true, fetches the definitions from the API even if they were already fetched before. + */ + const fetchAll = async (params?: { force?: boolean }) => { + const { force = false } = params ?? {}; + if (!force && fetchedAll.value) { + const testDefinitions = Object.values(testDefinitionsById.value); + return { + count: testDefinitions.length, + testDefinitions, + }; + } + + loading.value = true; + try { + const retrievedDefinitions = await testDefinitionsApi.getTestDefinitions( + rootStore.restApiContext, + ); + + setAllTestDefinitions(retrievedDefinitions.testDefinitions); + fetchedAll.value = true; + return retrievedDefinitions; + } finally { + loading.value = false; + } + }; + + /** + * Creates a new test definition using the provided parameters. + * + * @param {Object} params - An object containing the necessary parameters to create a test definition. + * @param {string} params.name - The name of the new test definition. + * @param {string} params.workflowId - The ID of the workflow associated with the test definition. + * @returns {Promise} A promise that resolves to the newly created test definition. + * @throws {Error} Throws an error if there is a problem creating the test definition. + */ + const create = async (params: { + name: string; + workflowId: string; + }) => { + const createdDefinition = await testDefinitionsApi.createTestDefinition( + rootStore.restApiContext, + params, + ); + upsertTestDefinitions([createdDefinition]); + return createdDefinition; + }; + + const update = async (params: Partial) => { + if (!params.id) throw new Error('ID is required to update a test definition'); + + const { id, ...updateParams } = params; + const updatedDefinition = await testDefinitionsApi.updateTestDefinition( + rootStore.restApiContext, + id, + updateParams, + ); + upsertTestDefinitions([updatedDefinition]); + return updatedDefinition; + }; + + /** + * Deletes a test definition by its ID. + * + * @param {number} id - The ID of the test definition to delete. + * @returns {Promise} A promise that resolves to true if the test definition was successfully deleted, false otherwise. + */ + const deleteById = async (id: string) => { + const result = await testDefinitionsApi.deleteTestDefinition(rootStore.restApiContext, id); + + if (result.success) { + deleteTestDefinition(id); + } + + return result.success; + }; + + return { + // State + fetchedAll, + testDefinitionsById, + + // Computed + allTestDefinitions, + isLoading, + hasTestDefinitions, + isFeatureEnabled, + + // Methods + fetchAll, + create, + update, + deleteById, + upsertTestDefinitions, + deleteTestDefinition, + }; + }, + {}, +); diff --git a/packages/editor-ui/src/views/ProjectSettings.vue b/packages/editor-ui/src/views/ProjectSettings.vue index 971fac96a6..70d4232f32 100644 --- a/packages/editor-ui/src/views/ProjectSettings.vue +++ b/packages/editor-ui/src/views/ProjectSettings.vue @@ -399,7 +399,7 @@ onMounted(() => { form { width: 100%; - max-width: 1280px; + max-width: var(--content-container-width); padding: 0 var(--spacing-2xl); fieldset { @@ -416,7 +416,7 @@ onMounted(() => { .header { width: 100%; - max-width: 1280px; + max-width: var(--content-container-width); padding: var(--spacing-l) var(--spacing-2xl) 0; } diff --git a/packages/editor-ui/src/views/TemplatesView.vue b/packages/editor-ui/src/views/TemplatesView.vue index d38107f51f..5f570eab92 100644 --- a/packages/editor-ui/src/views/TemplatesView.vue +++ b/packages/editor-ui/src/views/TemplatesView.vue @@ -30,7 +30,7 @@ withDefaults(defineProps(), { .template { display: flex; width: 100%; - max-width: 1280px; + max-width: var(--content-container-width); padding: var(--spacing-l) var(--spacing-l) 0; justify-content: center; @media (min-width: 1200px) { diff --git a/packages/editor-ui/src/views/TestDefinition/TestDefinitionEditView.vue b/packages/editor-ui/src/views/TestDefinition/TestDefinitionEditView.vue new file mode 100644 index 0000000000..e17c164472 --- /dev/null +++ b/packages/editor-ui/src/views/TestDefinition/TestDefinitionEditView.vue @@ -0,0 +1,270 @@ + + + + + diff --git a/packages/editor-ui/src/views/TestDefinition/TestDefinitionListView.vue b/packages/editor-ui/src/views/TestDefinition/TestDefinitionListView.vue new file mode 100644 index 0000000000..33a966b4b7 --- /dev/null +++ b/packages/editor-ui/src/views/TestDefinition/TestDefinitionListView.vue @@ -0,0 +1,153 @@ + + + + diff --git a/packages/editor-ui/src/views/TestDefinition/tests/TestDefinitionEditView.test.ts b/packages/editor-ui/src/views/TestDefinition/tests/TestDefinitionEditView.test.ts new file mode 100644 index 0000000000..9aeb70679b --- /dev/null +++ b/packages/editor-ui/src/views/TestDefinition/tests/TestDefinitionEditView.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import { createTestingPinia } from '@pinia/testing'; +import { createComponentRenderer } from '@/__tests__/render'; +import TestDefinitionEditView from '@/views/TestDefinition/TestDefinitionEditView.vue'; +import { useRoute, useRouter } from 'vue-router'; +import { useToast } from '@/composables/useToast'; +import { useTestDefinitionForm } from '@/components/TestDefinition/composables/useTestDefinitionForm'; +import { useAnnotationTagsStore } from '@/stores/tags.store'; +import { ref, nextTick } from 'vue'; + +vi.mock('vue-router'); +vi.mock('@/composables/useToast'); +vi.mock('@/components/TestDefinition/composables/useTestDefinitionForm'); +vi.mock('@/stores/tags.store'); +vi.mock('@/stores/projects.store'); + +describe('TestDefinitionEditView', () => { + const renderComponent = createComponentRenderer(TestDefinitionEditView); + + beforeEach(() => { + setActivePinia(createPinia()); + + vi.mocked(useRoute).mockReturnValue({ + params: {}, + path: '/test-path', + name: 'test-route', + } as ReturnType); + + vi.mocked(useRouter).mockReturnValue({ + push: vi.fn(), + resolve: vi.fn().mockReturnValue({ href: '/test-href' }), + } as unknown as ReturnType); + + vi.mocked(useToast).mockReturnValue({ + showMessage: vi.fn(), + showError: vi.fn(), + } as unknown as ReturnType); + vi.mocked(useTestDefinitionForm).mockReturnValue({ + state: ref({ + name: { value: '', isEditing: false, tempValue: '' }, + description: '', + tags: { appliedTagIds: [], isEditing: false }, + evaluationWorkflow: { id: '1', name: 'Test Workflow' }, + metrics: [], + }), + fieldsIssues: ref([]), + isSaving: ref(false), + loadTestData: vi.fn(), + saveTest: vi.fn(), + startEditing: vi.fn(), + saveChanges: vi.fn(), + cancelEditing: vi.fn(), + handleKeydown: vi.fn(), + } as unknown as ReturnType); + vi.mocked(useAnnotationTagsStore).mockReturnValue({ + isLoading: ref(false), + allTags: ref([]), + tagsById: ref({}), + fetchAll: vi.fn(), + } as unknown as ReturnType); + + vi.mock('@/stores/projects.store', () => ({ + useProjectsStore: vi.fn().mockReturnValue({ + isTeamProjectFeatureEnabled: false, + currentProject: null, + currentProjectId: null, + }), + })); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should load test data when testId is provided', async () => { + vi.mocked(useRoute).mockReturnValue({ + params: { testId: '1' }, + path: '/test-path', + name: 'test-route', + } as unknown as ReturnType); + const loadTestDataMock = vi.fn(); + vi.mocked(useTestDefinitionForm).mockReturnValue({ + ...vi.mocked(useTestDefinitionForm)(), + loadTestData: loadTestDataMock, + } as unknown as ReturnType); + + renderComponent({ + pinia: createTestingPinia(), + }); + + await nextTick(); + expect(loadTestDataMock).toHaveBeenCalledWith('1'); + }); + + it('should not load test data when testId is not provided', async () => { + const loadTestDataMock = vi.fn(); + vi.mocked(useTestDefinitionForm).mockReturnValue({ + ...vi.mocked(useTestDefinitionForm)(), + loadTestData: loadTestDataMock, + } as unknown as ReturnType); + + renderComponent({ + pinia: createTestingPinia(), + }); + + await nextTick(); + expect(loadTestDataMock).not.toHaveBeenCalled(); + }); + + it('should save test and show success message on successful save', async () => { + const saveTestMock = vi.fn().mockResolvedValue({}); + const routerPushMock = vi.fn(); + const routerResolveMock = vi.fn().mockReturnValue({ href: '/test-href' }); + vi.mocked(useTestDefinitionForm).mockReturnValue({ + ...vi.mocked(useTestDefinitionForm)(), + createTest: saveTestMock, + } as unknown as ReturnType); + + vi.mocked(useRouter).mockReturnValue({ + push: routerPushMock, + resolve: routerResolveMock, + } as unknown as ReturnType); + + const { getByTestId } = renderComponent({ + pinia: createTestingPinia(), + }); + await nextTick(); + const saveButton = getByTestId('run-test-button'); + saveButton.click(); + await nextTick(); + + expect(saveTestMock).toHaveBeenCalled(); + }); + + it('should show error message on failed save', async () => { + const saveTestMock = vi.fn().mockRejectedValue(new Error('Save failed')); + const showErrorMock = vi.fn(); + vi.mocked(useTestDefinitionForm).mockReturnValue({ + ...vi.mocked(useTestDefinitionForm)(), + createTest: saveTestMock, + } as unknown as ReturnType); + vi.mocked(useToast).mockReturnValue({ showError: showErrorMock } as unknown as ReturnType< + typeof useToast + >); + + const { getByTestId } = renderComponent({ + pinia: createTestingPinia(), + }); + await nextTick(); + const saveButton = getByTestId('run-test-button'); + saveButton.click(); + await nextTick(); + expect(saveTestMock).toHaveBeenCalled(); + expect(showErrorMock).toHaveBeenCalled(); + }); + + it('should display "Update Test" button when editing existing test', async () => { + vi.mocked(useRoute).mockReturnValue({ + params: { testId: '1' }, + path: '/test-path', + name: 'test-route', + } as unknown as ReturnType); + const { getByTestId } = renderComponent({ + pinia: createTestingPinia(), + }); + await nextTick(); + const updateButton = getByTestId('run-test-button'); + expect(updateButton.textContent).toContain('Update test'); + }); + + it('should display "Run Test" button when creating new test', async () => { + const { getByTestId } = renderComponent({ + pinia: createTestingPinia(), + }); + await nextTick(); + const saveButton = getByTestId('run-test-button'); + expect(saveButton).toBeTruthy(); + }); + + it('should apply "has-issues" class to inputs with issues', async () => { + vi.mocked(useTestDefinitionForm).mockReturnValue({ + ...vi.mocked(useTestDefinitionForm)(), + fieldsIssues: ref([{ field: 'name' }, { field: 'tags' }]), + } as unknown as ReturnType); + + const { container } = renderComponent({ + pinia: createTestingPinia(), + }); + await nextTick(); + expect(container.querySelector('.has-issues')).toBeTruthy(); + }); + + it('should fetch all tags on mount', async () => { + const fetchAllMock = vi.fn(); + vi.mocked(useAnnotationTagsStore).mockReturnValue({ + ...vi.mocked(useAnnotationTagsStore)(), + fetchAll: fetchAllMock, + } as unknown as ReturnType); + + renderComponent({ + pinia: createTestingPinia(), + }); + + await nextTick(); + expect(fetchAllMock).toHaveBeenCalled(); + }); +}); From caa744785a2cc5063a5fb9d269c0ea53ea432298 Mon Sep 17 00:00:00 2001 From: Alex Grozav Date: Wed, 27 Nov 2024 10:11:33 +0200 Subject: [PATCH 05/37] feat(editor): Migrate existing users to new canvas and set new canvas as default (#11896) --- cypress/support/commands.ts | 7 +- cypress/support/e2e.ts | 5 - .../components/MainHeader/WorkflowDetails.vue | 16 +- .../useNodeViewVersionSwitcher.test.ts | 156 ++++++++++++++++++ .../composables/useNodeViewVersionSwitcher.ts | 32 ++-- .../src/plugins/i18n/locales/en.json | 4 +- .../editor-ui/src/views/NodeViewSwitcher.vue | 8 +- 7 files changed, 203 insertions(+), 25 deletions(-) create mode 100644 packages/editor-ui/src/composables/useNodeViewVersionSwitcher.test.ts diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index bc5a18a34f..c414c9fea9 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -75,8 +75,13 @@ Cypress.Commands.add('signin', ({ email, password }) => { .then((response) => { Cypress.env('currentUserId', response.body.data.id); + // @TODO Remove this once the switcher is removed cy.window().then((win) => { - win.localStorage.setItem('NodeView.switcher.discovered', 'true'); // @TODO Remove this once the switcher is removed + win.localStorage.setItem('NodeView.migrated', 'true'); + win.localStorage.setItem('NodeView.switcher.discovered.beta', 'true'); + + const nodeViewVersion = Cypress.env('NODE_VIEW_VERSION'); + win.localStorage.setItem('NodeView.version', nodeViewVersion ?? '1'); }); }); }); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 4261cb4b63..0fe782499d 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -20,11 +20,6 @@ beforeEach(() => { win.localStorage.setItem('N8N_THEME', 'light'); win.localStorage.setItem('N8N_AUTOCOMPLETE_ONBOARDED', 'true'); win.localStorage.setItem('N8N_MAPPING_ONBOARDED', 'true'); - - const nodeViewVersion = Cypress.env('NODE_VIEW_VERSION'); - if (nodeViewVersion) { - win.localStorage.setItem('NodeView.version', nodeViewVersion); - } }); cy.intercept('GET', '/rest/settings', (req) => { diff --git a/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue b/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue index 089f6fee91..6c053057de 100644 --- a/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue +++ b/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue @@ -103,6 +103,7 @@ const tagsEventBus = createEventBus(); const sourceControlModalEventBus = createEventBus(); const { + isNewUser, nodeViewVersion, nodeViewSwitcherDiscovered, isNodeViewDiscoveryTooltipVisible, @@ -193,10 +194,14 @@ const workflowMenuItems = computed(() => { actions.push({ id: WORKFLOW_MENU_ACTIONS.SWITCH_NODE_VIEW_VERSION, ...(nodeViewVersion.value === '2' - ? {} + ? nodeViewSwitcherDiscovered.value || isNewUser.value + ? {} + : { + badge: locale.baseText('menuActions.badge.new'), + } : nodeViewSwitcherDiscovered.value ? { - badge: locale.baseText('menuActions.badge.alpha'), + badge: locale.baseText('menuActions.badge.beta'), badgeProps: { theme: 'tertiary', }, @@ -756,9 +761,12 @@ function showCreateWorkflowSuccessToast(id?: string) { />