diff --git a/cypress/e2e/13-pinning.cy.ts b/cypress/e2e/13-pinning.cy.ts index ed77797f789e9..1643363a5f3c9 100644 --- a/cypress/e2e/13-pinning.cy.ts +++ b/cypress/e2e/13-pinning.cy.ts @@ -188,9 +188,9 @@ describe('Data pinning', () => { function setExpressionOnStringValueInSet(expression: string) { cy.get('button').contains('Test step').click(); - cy.get('.fixed-collection-parameter > :nth-child(2) > .button > span').click(); - ndv.getters.nthParam(4).contains('Expression').invoke('show').click(); + ndv.getters.assignmentCollectionAdd('assignments').click(); + ndv.getters.assignmentValue('assignments').contains('Expression').invoke('show').click(); ndv.getters .inlineExpressionEditorInput() diff --git a/cypress/e2e/14-data-transformation-expressions.cy.ts b/cypress/e2e/14-data-transformation-expressions.cy.ts index 454f1d1749fff..da08ec8817dc7 100644 --- a/cypress/e2e/14-data-transformation-expressions.cy.ts +++ b/cypress/e2e/14-data-transformation-expressions.cy.ts @@ -1,4 +1,5 @@ import { WorkflowPage, NDV } from '../pages'; +import { getVisibleSelect } from '../utils'; const wf = new WorkflowPage(); const ndv = new NDV(); @@ -104,8 +105,6 @@ describe('Data transformation expressions', () => { const addEditFields = () => { wf.actions.addNodeToCanvas('Edit Fields', true, true); - cy.get('.fixed-collection-parameter > :nth-child(2) > .button > span').click(); - ndv.getters.parameterInput('include').click(); // shorten output - cy.get('div').contains('No Input Fields').click(); - ndv.getters.nthParam(4).contains('Expression').invoke('show').click(); + ndv.getters.assignmentCollectionAdd('assignments').click(); + ndv.getters.assignmentValue('assignments').contains('Expression').invoke('show').click(); }; diff --git a/cypress/e2e/16-webhook-node.cy.ts b/cypress/e2e/16-webhook-node.cy.ts index da43a7cf4b14f..d753a143755f8 100644 --- a/cypress/e2e/16-webhook-node.cy.ts +++ b/cypress/e2e/16-webhook-node.cy.ts @@ -181,9 +181,10 @@ describe('Webhook Trigger node', async () => { workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME); workflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME); - cy.get('.fixed-collection-parameter > :nth-child(2) > .button > span').click(); - ndv.getters.nthParam(2).type('data'); - ndv.getters.nthParam(4).invoke('val', cowBase64).trigger('blur'); + ndv.getters.assignmentCollectionAdd('assignments').click(); + ndv.getters.assignmentName('assignments').type('data'); + ndv.getters.assignmentType('assignments').click(); + ndv.getters.assignmentValue('assignments').paste(cowBase64); ndv.getters.backToCanvas().click(); @@ -311,9 +312,9 @@ describe('Webhook Trigger node', async () => { const addEditFields = () => { workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME); workflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME); - cy.get('.fixed-collection-parameter > :nth-child(2) > .button > span').click(); - ndv.getters.nthParam(2).type('MyValue'); - ndv.getters.nthParam(3).click(); - cy.get('div').contains('Number').click(); - ndv.getters.nthParam(4).type('1234'); + ndv.getters.assignmentCollectionAdd('assignments').click(); + ndv.getters.assignmentName('assignments').type('MyValue'); + ndv.getters.assignmentType('assignments').click(); + getVisibleSelect().find('li').contains('Number').click(); + ndv.getters.assignmentValue('assignments').type('1234'); }; diff --git a/cypress/e2e/5-ndv.cy.ts b/cypress/e2e/5-ndv.cy.ts index 0c6d0cc8ddec1..d98bc3e1989ca 100644 --- a/cypress/e2e/5-ndv.cy.ts +++ b/cypress/e2e/5-ndv.cy.ts @@ -306,7 +306,7 @@ describe('NDV', () => { ndv.getters.parameterInput('remoteOptions').click(); - ndv.getters.parameterInputIssues('remoteOptions').realHover({ scrollBehavior: false}); + ndv.getters.parameterInputIssues('remoteOptions').realHover({ scrollBehavior: false }); // Remote options dropdown should not be visible ndv.getters.parameterInput('remoteOptions').find('.el-select').should('not.exist'); }); @@ -509,12 +509,12 @@ describe('NDV', () => { workflowPage.actions.openNode('Edit Fields (old)'); ndv.actions.openSettings(); - ndv.getters.nodeVersion().should('have.text', 'Set node version 2 (Latest version: 3.2)'); + ndv.getters.nodeVersion().should('have.text', 'Set node version 2 (Latest version: 3.3)'); ndv.actions.close(); workflowPage.actions.openNode('Edit Fields (latest)'); ndv.actions.openSettings(); - ndv.getters.nodeVersion().should('have.text', 'Edit Fields (Set) node version 3.2 (Latest)'); + ndv.getters.nodeVersion().should('have.text', 'Edit Fields (Set) node version 3.3 (Latest)'); ndv.actions.close(); workflowPage.actions.openNode('Function'); diff --git a/cypress/fixtures/Test_workflow_ndv_version.json b/cypress/fixtures/Test_workflow_ndv_version.json index 7d8f6af924810..409c8be54b208 100644 --- a/cypress/fixtures/Test_workflow_ndv_version.json +++ b/cypress/fixtures/Test_workflow_ndv_version.json @@ -37,7 +37,7 @@ "id": "93aaadac-55fe-4618-b1eb-f63e61d1446a", "name": "Edit Fields (latest)", "type": "n8n-nodes-base.set", - "typeVersion": 3.2, + "typeVersion": 3.3, "position": [ 1720, 780 diff --git a/cypress/pages/ndv.ts b/cypress/pages/ndv.ts index 449ad75eb45f7..71e756ec11d67 100644 --- a/cypress/pages/ndv.ts +++ b/cypress/pages/ndv.ts @@ -94,6 +94,20 @@ export class NDV extends BasePage { this.getters.filterComponent(paramName).getByTestId('filter-remove-condition').eq(index), filterConditionAdd: (paramName: string) => this.getters.filterComponent(paramName).getByTestId('filter-add-condition'), + assignmentCollection: (paramName: string) => + cy.getByTestId(`assignment-collection-${paramName}`), + assignmentCollectionAdd: (paramName: string) => + this.getters.assignmentCollection(paramName).getByTestId('assignment-collection-drop-area'), + assignment: (paramName: string, index = 0) => + this.getters.assignmentCollection(paramName).getByTestId('assignment').eq(index), + assignmentRemove: (paramName: string, index = 0) => + this.getters.assignment(paramName, index).getByTestId('assignment-remove'), + assignmentName: (paramName: string, index = 0) => + this.getters.assignment(paramName, index).getByTestId('assignment-name'), + assignmentValue: (paramName: string, index = 0) => + this.getters.assignment(paramName, index).getByTestId('assignment-value'), + assignmentType: (paramName: string, index = 0) => + this.getters.assignment(paramName, index).getByTestId('assignment-type-select'), searchInput: () => cy.getByTestId('ndv-search'), pagination: () => cy.getByTestId('ndv-data-pagination'), nodeVersion: () => cy.getByTestId('node-version'), @@ -235,6 +249,9 @@ export class NDV extends BasePage { removeFilterCondition: (paramName: string, index: number) => { this.getters.filterConditionRemove(paramName, index).click(); }, + removeAssignment: (paramName: string, index: number) => { + this.getters.assignmentRemove(paramName, index).click(); + }, setInvalidExpression: ({ fieldName, invalidExpression, diff --git a/packages/design-system/src/css/_primitives.scss b/packages/design-system/src/css/_primitives.scss index d45b5785303fd..77e3a4e30ca4f 100644 --- a/packages/design-system/src/css/_primitives.scss +++ b/packages/design-system/src/css/_primitives.scss @@ -46,6 +46,12 @@ var(--prim-color-primary-s), var(--prim-color-primary-l) ); + --prim-color-primary-alpha-010: hsla( + var(--prim-color-primary-h), + var(--prim-color-primary-s), + var(--prim-color-primary-l), + 0.1 + ); --prim-color-primary-tint-100: hsl( var(--prim-color-primary-h), var(--prim-color-primary-s), @@ -93,6 +99,12 @@ var(--prim-color-secondary-l), 0.25 ); + --prim-color-secondary-alpha-010: hsla( + var(--prim-color-secondary-h), + var(--prim-color-secondary-s), + var(--prim-color-secondary-l), + 0.1 + ); --prim-color-secondary-tint-100: hsl( var(--prim-color-secondary-h), var(--prim-color-secondary-s), @@ -140,6 +152,12 @@ var(--prim-color-alt-a-l), 0.25 ); + --prim-color-alt-a-alpha-015: hsl( + var(--prim-color-alt-a-h), + var(--prim-color-alt-a-s), + var(--prim-color-alt-a-l), + 0.15 + ); --prim-color-alt-a-tint-300: hsl( var(--prim-color-alt-a-h), var(--prim-color-alt-a-s), diff --git a/packages/design-system/src/css/_tokens.dark.scss b/packages/design-system/src/css/_tokens.dark.scss index 83705187de5ba..6f85ffb752237 100644 --- a/packages/design-system/src/css/_tokens.dark.scss +++ b/packages/design-system/src/css/_tokens.dark.scss @@ -131,6 +131,8 @@ // NDV --color-run-data-background: var(--prim-gray-800); --color-ndv-droppable-parameter: var(--prim-color-primary); + --color-ndv-droppable-parameter-background: var(--prim-color-primary-alpha-010); + --color-ndv-droppable-parameter-active-background: var(--prim-color-alt-a-alpha-015); --color-ndv-back-font: var(--prim-gray-0); --color-ndv-ouptut-error-font: var(--prim-color-alt-c-tint-150); @@ -174,6 +176,9 @@ // Action Dropdown --color-action-dropdown-item-active-background: var(--color-background-xlight); + // Input Triple + --color-background-input-triple: var(--prim-gray-800); + // Various --color-info-tint-1: var(--prim-gray-420); --color-info-tint-2: var(--prim-gray-740); diff --git a/packages/design-system/src/css/_tokens.scss b/packages/design-system/src/css/_tokens.scss index 113add69eef8d..3c20089511182 100644 --- a/packages/design-system/src/css/_tokens.scss +++ b/packages/design-system/src/css/_tokens.scss @@ -201,6 +201,8 @@ // NDV --color-run-data-background: var(--color-background-base); --color-ndv-droppable-parameter: var(--color-secondary); + --color-ndv-droppable-parameter-background: var(--prim-color-secondary-alpha-010); + --color-ndv-droppable-parameter-active-background: var(--prim-color-alt-a-alpha-015); --color-ndv-back-font: var(--prim-gray-0); --color-ndv-ouptut-error-font: var(--prim-color-alt-c); @@ -253,6 +255,9 @@ // Feature Request --color-feature-request-font: var(--prim-gray-0); + // Input Triple + --color-background-input-triple: var(--color-background-light); + // Various --color-avatar-accent-1: var(--prim-gray-120); --color-avatar-accent-2: var(--prim-color-alt-e-shade-100); diff --git a/packages/design-system/src/css/input-number.scss b/packages/design-system/src/css/input-number.scss index 0fef6a703ff94..e39c66c09081b 100644 --- a/packages/design-system/src/css/input-number.scss +++ b/packages/design-system/src/css/input-number.scss @@ -96,7 +96,7 @@ } @include mixins.m(small) { - line-height: #{var.$input-small-height - 2}; + line-height: #{var.$input-small-height - 4}; @include mixins.e((increase, decrease)) { width: var.$input-small-height; diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 8084fdbd08901..6fd65e1cf7a2f 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -1235,8 +1235,8 @@ export interface NDVState { isDragging: boolean; type: string; data: string; - activeTargetId: string | null; - stickyPosition: null | XYPosition; + dimensions: DOMRect | null; + activeTarget: { id: string; stickyPosition: null | XYPosition } | null; }; isMappingOnboarded: boolean; } diff --git a/packages/editor-ui/src/components/AssignmentCollection/Assignment.vue b/packages/editor-ui/src/components/AssignmentCollection/Assignment.vue new file mode 100644 index 0000000000000..1d5d166fba036 --- /dev/null +++ b/packages/editor-ui/src/components/AssignmentCollection/Assignment.vue @@ -0,0 +1,264 @@ + + + + + diff --git a/packages/editor-ui/src/components/AssignmentCollection/AssignmentCollection.vue b/packages/editor-ui/src/components/AssignmentCollection/AssignmentCollection.vue new file mode 100644 index 0000000000000..7336684f6c8a7 --- /dev/null +++ b/packages/editor-ui/src/components/AssignmentCollection/AssignmentCollection.vue @@ -0,0 +1,270 @@ + + + + + diff --git a/packages/editor-ui/src/components/AssignmentCollection/TypeSelect.vue b/packages/editor-ui/src/components/AssignmentCollection/TypeSelect.vue new file mode 100644 index 0000000000000..2a646a064fa1e --- /dev/null +++ b/packages/editor-ui/src/components/AssignmentCollection/TypeSelect.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/packages/editor-ui/src/components/AssignmentCollection/__tests__/Assignment.test.ts b/packages/editor-ui/src/components/AssignmentCollection/__tests__/Assignment.test.ts new file mode 100644 index 0000000000000..6d2f438afe855 --- /dev/null +++ b/packages/editor-ui/src/components/AssignmentCollection/__tests__/Assignment.test.ts @@ -0,0 +1,54 @@ +import { createComponentRenderer } from '@/__tests__/render'; +import { createTestingPinia } from '@pinia/testing'; +import userEvent from '@testing-library/user-event'; +import Assignment from '../Assignment.vue'; + +const DEFAULT_SETUP = { + pinia: createTestingPinia(), + props: { + path: 'parameters.fields.0', + modelValue: { + name: '', + type: 'string', + value: '', + }, + issues: [], + }, +}; + +const renderComponent = createComponentRenderer(Assignment, DEFAULT_SETUP); + +describe('Assignment.vue', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('can edit name, type and value', async () => { + const { getByTestId, baseElement, emitted } = renderComponent(); + + const nameField = getByTestId('assignment-name').querySelector('input') as HTMLInputElement; + const valueField = getByTestId('assignment-value').querySelector('input') as HTMLInputElement; + + expect(getByTestId('assignment')).toBeInTheDocument(); + expect(getByTestId('assignment-name')).toBeInTheDocument(); + expect(getByTestId('assignment-value')).toBeInTheDocument(); + expect(getByTestId('assignment-type-select')).toBeInTheDocument(); + + await userEvent.type(nameField, 'New name'); + await userEvent.type(valueField, 'New value'); + + await userEvent.click(baseElement.querySelectorAll('.option')[3]); + + expect(emitted('update:model-value')[0]).toEqual([ + { name: 'New name', type: 'array', value: 'New value' }, + ]); + }); + + it('can remove itself', async () => { + const { getByTestId, emitted } = renderComponent(); + + await userEvent.click(getByTestId('assignment-remove')); + + expect(emitted('remove')).toEqual([[]]); + }); +}); diff --git a/packages/editor-ui/src/components/AssignmentCollection/__tests__/AssignmentCollection.test.ts b/packages/editor-ui/src/components/AssignmentCollection/__tests__/AssignmentCollection.test.ts new file mode 100644 index 0000000000000..fb425a6bc0617 --- /dev/null +++ b/packages/editor-ui/src/components/AssignmentCollection/__tests__/AssignmentCollection.test.ts @@ -0,0 +1,121 @@ +import { createComponentRenderer } from '@/__tests__/render'; +import { useNDVStore } from '@/stores/ndv.store'; +import { createTestingPinia } from '@pinia/testing'; +import userEvent from '@testing-library/user-event'; +import { fireEvent, within } from '@testing-library/vue'; +import * as workflowHelpers from '@/mixins/workflowHelpers'; +import AssignmentCollection from '../AssignmentCollection.vue'; +import { createPinia, setActivePinia } from 'pinia'; + +const DEFAULT_SETUP = { + pinia: createTestingPinia(), + props: { + path: 'parameters.fields', + node: { + parameters: {}, + id: 'f63efb2d-3cc5-4500-89f9-b39aab19baf5', + name: 'Edit Fields', + type: 'n8n-nodes-base.set', + typeVersion: 3.3, + position: [1120, 380], + credentials: {}, + disabled: false, + }, + parameter: { name: 'fields', displayName: 'Fields To Set' }, + value: {}, + }, +}; + +const renderComponent = createComponentRenderer(AssignmentCollection, DEFAULT_SETUP); + +const getInput = (e: HTMLElement): HTMLInputElement => { + return e.querySelector('input') as HTMLInputElement; +}; + +const getAssignmentType = (assignment: HTMLElement): string => { + return getInput(within(assignment).getByTestId('assignment-type-select')).value; +}; + +async function dropAssignment({ + key, + value, + dropArea, +}: { + key: string; + value: unknown; + dropArea: HTMLElement; +}): Promise { + useNDVStore().draggableStartDragging({ + type: 'mapping', + data: `{{ $json.${key} }}`, + dimensions: null, + }); + + vitest.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(value as never); + + await userEvent.hover(dropArea); + await fireEvent.mouseUp(dropArea); +} + +describe('AssignmentCollection.vue', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('renders empty state properly', async () => { + const { getByTestId, queryByTestId } = renderComponent(); + expect(getByTestId('assignment-collection-fields')).toBeInTheDocument(); + expect(getByTestId('assignment-collection-fields')).toHaveClass('empty'); + expect(getByTestId('assignment-collection-drop-area')).toHaveTextContent( + 'Drag input fields here', + ); + expect(queryByTestId('assignment')).not.toBeInTheDocument(); + }); + + it('can add and remove assignments', async () => { + const { getByTestId, findAllByTestId } = renderComponent(); + + await userEvent.click(getByTestId('assignment-collection-drop-area')); + await userEvent.click(getByTestId('assignment-collection-drop-area')); + + let assignments = await findAllByTestId('assignment'); + + expect(assignments.length).toEqual(2); + + await userEvent.type(getInput(within(assignments[1]).getByTestId('assignment-name')), 'second'); + await userEvent.type( + getInput(within(assignments[1]).getByTestId('assignment-value')), + 'secondValue', + ); + await userEvent.click(within(assignments[0]).getByTestId('assignment-remove')); + + assignments = await findAllByTestId('assignment'); + expect(assignments.length).toEqual(1); + expect(getInput(within(assignments[0]).getByTestId('assignment-value'))).toHaveValue( + 'secondValue', + ); + }); + + it('can add assignments by drag and drop (and infer type)', async () => { + const pinia = createPinia(); + setActivePinia(pinia); + + const { getByTestId, findAllByTestId } = renderComponent({ pinia }); + const dropArea = getByTestId('assignment-collection-drop-area'); + + await dropAssignment({ key: 'boolKey', value: true, dropArea }); + await dropAssignment({ key: 'stringKey', value: 'stringValue', dropArea }); + await dropAssignment({ key: 'numberKey', value: 25, dropArea }); + await dropAssignment({ key: 'objectKey', value: {}, dropArea }); + await dropAssignment({ key: 'arrayKey', value: [], dropArea }); + + let assignments = await findAllByTestId('assignment'); + + expect(assignments.length).toBe(5); + expect(getAssignmentType(assignments[0])).toEqual('Boolean'); + expect(getAssignmentType(assignments[1])).toEqual('String'); + expect(getAssignmentType(assignments[2])).toEqual('Number'); + expect(getAssignmentType(assignments[3])).toEqual('Object'); + expect(getAssignmentType(assignments[4])).toEqual('Array'); + }); +}); diff --git a/packages/editor-ui/src/components/AssignmentCollection/__tests__/TypeSelect.test.ts b/packages/editor-ui/src/components/AssignmentCollection/__tests__/TypeSelect.test.ts new file mode 100644 index 0000000000000..78f4b683c6a85 --- /dev/null +++ b/packages/editor-ui/src/components/AssignmentCollection/__tests__/TypeSelect.test.ts @@ -0,0 +1,41 @@ +import { createComponentRenderer } from '@/__tests__/render'; +import { createTestingPinia } from '@pinia/testing'; +import userEvent from '@testing-library/user-event'; +import TypeSelect from '../TypeSelect.vue'; + +const DEFAULT_SETUP = { + pinia: createTestingPinia(), + props: { + modelValue: 'boolean', + }, +}; + +const renderComponent = createComponentRenderer(TypeSelect, DEFAULT_SETUP); + +describe('TypeSelect.vue', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('renders default state correctly and emit events', async () => { + const { getByTestId, baseElement, emitted } = renderComponent(); + expect(getByTestId('assignment-type-select')).toBeInTheDocument(); + + await userEvent.click( + getByTestId('assignment-type-select').querySelector('.select-trigger') as HTMLElement, + ); + + const options = baseElement.querySelectorAll('.option'); + expect(options.length).toEqual(5); + + expect(options[0]).toHaveTextContent('String'); + expect(options[1]).toHaveTextContent('Number'); + expect(options[2]).toHaveTextContent('Boolean'); + expect(options[3]).toHaveTextContent('Array'); + expect(options[4]).toHaveTextContent('Object'); + + await userEvent.click(options[2]); + + expect(emitted('update:model-value')).toEqual([['boolean']]); + }); +}); diff --git a/packages/editor-ui/src/components/AssignmentCollection/constants.ts b/packages/editor-ui/src/components/AssignmentCollection/constants.ts new file mode 100644 index 0000000000000..c448f96ce8dfa --- /dev/null +++ b/packages/editor-ui/src/components/AssignmentCollection/constants.ts @@ -0,0 +1,7 @@ +export const ASSIGNMENT_TYPES = [ + { type: 'string', icon: 'font' }, + { type: 'number', icon: 'hashtag' }, + { type: 'boolean', icon: 'check-square' }, + { type: 'array', icon: 'list' }, + { type: 'object', icon: 'cube' }, +]; diff --git a/packages/editor-ui/src/components/AssignmentCollection/utils.ts b/packages/editor-ui/src/components/AssignmentCollection/utils.ts new file mode 100644 index 0000000000000..d4845aa59839e --- /dev/null +++ b/packages/editor-ui/src/components/AssignmentCollection/utils.ts @@ -0,0 +1,61 @@ +import { isObject } from 'lodash-es'; +import type { AssignmentValue, IDataObject } from 'n8n-workflow'; +import { resolveParameter } from '@/mixins/workflowHelpers'; +import { v4 as uuid } from 'uuid'; + +export function nameFromExpression(expression: string): string { + return expression.replace(/^{{\s*|\s*}}$/g, '').replace('$json.', ''); +} + +export function inferAssignmentType(value: unknown): string { + if (typeof value === 'boolean') return 'boolean'; + if (typeof value === 'number') return 'number'; + if (typeof value === 'string') return 'string'; + if (Array.isArray(value)) return 'array'; + if (isObject(value)) return 'object'; + return 'string'; +} + +export function typeFromExpression(expression: string): string { + try { + const resolved = resolveParameter(`=${expression}`); + return inferAssignmentType(resolved); + } catch (error) { + return 'string'; + } +} + +export function inputDataToAssignments(input: IDataObject): AssignmentValue[] { + const assignments: AssignmentValue[] = []; + + function processValue(value: IDataObject, path: Array = []) { + if (Array.isArray(value)) { + value.forEach((element, index) => { + processValue(element, [...path, index]); + }); + } else if (isObject(value)) { + for (const [key, objectValue] of Object.entries(value)) { + processValue(objectValue as IDataObject, [...path, key]); + } + } else { + const stringPath = path.reduce((fullPath: string, part) => { + if (typeof part === 'number') { + return `${fullPath}[${part}]`; + } + return `${fullPath}.${part}`; + }, '$json'); + + const expression = `={{ ${stringPath} }}`; + assignments.push({ + id: uuid(), + name: stringPath.replace('$json.', ''), + value: expression, + type: inferAssignmentType(value), + }); + } + } + + processValue(input); + + return assignments; +} diff --git a/packages/editor-ui/src/components/Draggable.vue b/packages/editor-ui/src/components/Draggable.vue index 91e8e996a6aa9..8b65ed35d9abe 100644 --- a/packages/editor-ui/src/components/Draggable.vue +++ b/packages/editor-ui/src/components/Draggable.vue @@ -117,7 +117,11 @@ export default defineComponent({ const data = this.targetDataKey && this.draggingEl ? this.draggingEl.dataset.value : this.data || ''; - this.ndvStore.draggableStartDragging({ type: this.type, data: data || '' }); + this.ndvStore.draggableStartDragging({ + type: this.type, + data: data || '', + dimensions: this.draggingEl?.getBoundingClientRect() ?? null, + }); this.$emit('dragstart', this.draggingEl); document.body.style.cursor = 'grabbing'; diff --git a/packages/editor-ui/src/components/DraggableTarget.vue b/packages/editor-ui/src/components/DraggableTarget.vue index 0cc331d6b8ea1..18274cd4ad52c 100644 --- a/packages/editor-ui/src/components/DraggableTarget.vue +++ b/packages/editor-ui/src/components/DraggableTarget.vue @@ -10,6 +10,7 @@ import type { PropType } from 'vue'; import { mapStores } from 'pinia'; import { useNDVStore } from '@/stores/ndv.store'; import { v4 as uuid } from 'uuid'; +import type { XYPosition } from '@/Interface'; export default defineComponent({ props: { @@ -26,21 +27,18 @@ export default defineComponent({ type: Array as PropType, default: () => [0, 0], }, + stickyOrigin: { + type: String as PropType<'top-left' | 'center'>, + default: 'top-left', + }, }, data() { return { hovering: false, + dimensions: null as DOMRect | null, id: uuid(), }; }, - mounted() { - window.addEventListener('mousemove', this.onMouseMove); - window.addEventListener('mouseup', this.onMouseUp); - }, - beforeUnmount() { - window.removeEventListener('mousemove', this.onMouseMove); - window.removeEventListener('mouseup', this.onMouseUp); - }, computed: { ...mapStores(useNDVStore), isDragging(): boolean { @@ -49,12 +47,57 @@ export default defineComponent({ draggableType(): string { return this.ndvStore.draggableType; }, + draggableDimensions(): DOMRect | null { + return this.ndvStore.draggable.dimensions; + }, droppable(): boolean { return !this.disabled && this.isDragging && this.draggableType === this.type; }, activeDrop(): boolean { return this.droppable && this.hovering; }, + stickyPosition(): XYPosition | null { + if (this.disabled || !this.sticky || !this.hovering || !this.dimensions) { + return null; + } + + if (this.stickyOrigin === 'center') { + return [ + this.dimensions.left + + this.stickyOffset[0] + + this.dimensions.width / 2 - + (this.draggableDimensions?.width ?? 0) / 2, + this.dimensions.top + + this.stickyOffset[1] + + this.dimensions.height / 2 - + (this.draggableDimensions?.height ?? 0) / 2, + ]; + } + + return [ + this.dimensions.left + this.stickyOffset[0], + this.dimensions.top + this.stickyOffset[1], + ]; + }, + }, + watch: { + activeDrop(active) { + if (active) { + this.ndvStore.setDraggableTarget({ id: this.id, stickyPosition: this.stickyPosition }); + } else if (this.ndvStore.draggable.activeTarget?.id === this.id) { + // Only clear active target if it is this one + this.ndvStore.setDraggableTarget(null); + } + }, + }, + mounted() { + window.addEventListener('mousemove', this.onMouseMove); + window.addEventListener('mouseup', this.onMouseUp); + }, + + beforeUnmount() { + window.removeEventListener('mousemove', this.onMouseMove); + window.removeEventListener('mouseup', this.onMouseUp); }, methods: { onMouseMove(e: MouseEvent) { @@ -63,17 +106,12 @@ export default defineComponent({ if (targetRef && this.isDragging) { const dim = targetRef.getBoundingClientRect(); + this.dimensions = dim; this.hovering = e.clientX >= dim.left && e.clientX <= dim.right && e.clientY >= dim.top && e.clientY <= dim.bottom; - - if (!this.disabled && this.sticky && this.hovering) { - const [xOffset, yOffset] = this.stickyOffset; - - this.ndvStore.setDraggableStickyPos([dim.left + xOffset, dim.top + yOffset]); - } } }, onMouseUp(e: MouseEvent) { @@ -83,15 +121,5 @@ export default defineComponent({ } }, }, - watch: { - activeDrop(active) { - if (active) { - this.ndvStore.setDraggableTargetId(this.id); - } else if (this.ndvStore.draggable.activeTargetId === this.id) { - // Only clear active target if it is this one - this.ndvStore.setDraggableTargetId(null); - } - }, - }, }); diff --git a/packages/editor-ui/src/components/DropArea/DropArea.vue b/packages/editor-ui/src/components/DropArea/DropArea.vue new file mode 100644 index 0000000000000..a6129983a77da --- /dev/null +++ b/packages/editor-ui/src/components/DropArea/DropArea.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/packages/editor-ui/src/components/DropArea/__tests__/DropArea.test.ts b/packages/editor-ui/src/components/DropArea/__tests__/DropArea.test.ts new file mode 100644 index 0000000000000..d9c8371d72479 --- /dev/null +++ b/packages/editor-ui/src/components/DropArea/__tests__/DropArea.test.ts @@ -0,0 +1,40 @@ +import { createComponentRenderer } from '@/__tests__/render'; +import { useNDVStore } from '@/stores/ndv.store'; +import { createTestingPinia } from '@pinia/testing'; +import userEvent from '@testing-library/user-event'; +import { fireEvent } from '@testing-library/vue'; +import { createPinia, setActivePinia } from 'pinia'; +import DropArea from '../DropArea.vue'; + +const renderComponent = createComponentRenderer(DropArea, { + pinia: createTestingPinia(), +}); + +async function fireDrop(dropArea: HTMLElement): Promise { + useNDVStore().draggableStartDragging({ + type: 'mapping', + data: '{{ $json.something }}', + dimensions: null, + }); + + await userEvent.hover(dropArea); + await fireEvent.mouseUp(dropArea); +} + +describe('DropArea.vue', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('renders default state correctly and emits drop events', async () => { + const pinia = createPinia(); + setActivePinia(pinia); + + const { getByTestId, emitted } = renderComponent({ pinia }); + expect(getByTestId('drop-area')).toBeInTheDocument(); + + await fireDrop(getByTestId('drop-area')); + + expect(emitted('drop')).toEqual([['{{ $json.something }}']]); + }); +}); diff --git a/packages/editor-ui/src/components/ExpressionParameterInput.vue b/packages/editor-ui/src/components/ExpressionParameterInput.vue index be5a0f7ef81be..6616f0d4595c6 100644 --- a/packages/editor-ui/src/components/ExpressionParameterInput.vue +++ b/packages/editor-ui/src/components/ExpressionParameterInput.vue @@ -4,16 +4,22 @@ :class="$style['expression-parameter-input']" @keydown.tab="onBlur" > -
+
- + = +
import type { IUpdateInformation } from '@/Interface'; +import InputTriple from '@/components/InputTriple/InputTriple.vue'; import ParameterInputFull from '@/components/ParameterInputFull.vue'; import ParameterIssues from '@/components/ParameterIssues.vue'; import { useI18n } from '@/composables/useI18n'; +import { resolveParameter } from '@/mixins/workflowHelpers'; import { DateTime } from 'luxon'; import { + FilterError, executeFilterCondition, - type FilterOptionsValue, + validateFieldType, type FilterConditionValue, type FilterOperatorType, + type FilterOptionsValue, type INodeProperties, type NodeParameterValue, - type NodePropertyTypes, - FilterError, - validateFieldType, } from 'n8n-workflow'; import { computed, ref } from 'vue'; import OperatorSelect from './OperatorSelect.vue'; import { OPERATORS_BY_ID, type FilterOperatorId } from './constants'; import type { FilterOperator } from './types'; -import { resolveParameter } from '@/mixins/workflowHelpers'; type ConditionResult = | { status: 'resolve_error' } | { status: 'validation_error'; error: string } @@ -58,15 +58,24 @@ const operatorId = computed(() => { }); const operator = computed(() => OPERATORS_BY_ID[operatorId.value] as FilterOperator); -const operatorTypeToNodePropType = (operatorType: FilterOperatorType): NodePropertyTypes => { +const operatorTypeToNodeProperty = ( + operatorType: FilterOperatorType, +): Pick => { switch (operatorType) { + case 'boolean': + return { + type: 'options', + options: [ + { name: 'true', value: true }, + { name: 'false', value: false }, + ], + }; case 'array': case 'object': - case 'boolean': case 'any': - return 'string'; + return { type: 'string' }; default: - return operatorType; + return { type: operatorType }; } }; @@ -119,7 +128,7 @@ const leftParameter = computed(() => ({ operator.value.type === 'dateTime' ? now.value : i18n.baseText('filter.condition.placeholderLeft'), - type: operatorTypeToNodePropType(operator.value.type), + ...operatorTypeToNodeProperty(operator.value.type), })); const rightParameter = computed(() => ({ @@ -130,7 +139,7 @@ const rightParameter = computed(() => ({ operator.value.type === 'dateTime' ? now.value : i18n.baseText('filter.condition.placeholderRight'), - type: operatorTypeToNodePropType(operator.value.rightType ?? operator.value.type), + ...operatorTypeToNodeProperty(operator.value.type), })); const onLeftValueChange = (update: IUpdateInformation): void => { @@ -144,9 +153,11 @@ const onRightValueChange = (update: IUpdateInformation): void => { const convertToType = (value: unknown, type: FilterOperatorType): unknown => { if (type === 'any') return value; + const fallback = type === 'boolean' ? false : value; + return ( validateFieldType('filter', condition.value.leftValue, type, { parseStrings: true }).newValue ?? - value + fallback ); }; @@ -202,64 +213,53 @@ const onBlur = (): void => { :class="$style.remove" @click="onRemove" > - -