From 21773764d37c37a6464a3885d3fa548a5feb4fd8 Mon Sep 17 00:00:00 2001 From: Charlie Kolb Date: Wed, 5 Feb 2025 08:42:50 +0100 Subject: [PATCH 01/10] feat(editor): Easy $fromAI Button for AI Tools (#12587) --- cypress/composables/ndv.ts | 2 +- cypress/e2e/16-form-trigger-node.cy.ts | 2 + cypress/e2e/48-subworkflow-inputs.cy.ts | 12 +- .../load-nodes-and-credentials.test.ts | 26 +-- .../events/relays/telemetry.event-relay.ts | 1 + .../cli/src/load-nodes-and-credentials.ts | 10 - .../__snapshots__/Checkbox.test.ts.snap | 98 +++++---- .../__snapshots__/FormBox.test.ts.snap | 159 +++++++------- .../components/N8nInputLabel/InputLabel.vue | 131 ++++++------ .../__snapshots__/InputLabel.test.ts.snap | 98 +++++---- .../N8nSelectableList/SelectableList.test.ts | 16 +- .../N8nSelectableList/SelectableList.vue | 17 +- .../__snapshots__/SelectableList.test.ts.snap | 6 +- packages/editor-ui/src/__tests__/mocks.ts | 2 +- .../editor-ui/src/components/AiStarsIcon.vue | 37 ++++ .../components/ExpressionParameterInput.vue | 9 +- .../InlineExpressionEditorInput.vue | 5 + .../src/components/ParameterInput.vue | 112 +++++++++- .../src/components/ParameterInputFull.test.ts | 158 ++++++++++++++ .../src/components/ParameterInputFull.vue | 195 +++++++++++++++--- .../FromAiOverrideButton.vue | 43 ++++ .../FromAiOverrideField.vue | 81 ++++++++ .../ParameterOverrideSelectableList.vue | 75 +++++++ .../src/components/ParameterInputWrapper.vue | 18 +- .../ResourceLocator/ResourceLocator.vue | 125 ++++++++++- .../ResourceLocator/resourceLocator.scss | 39 +++- .../MultipleParameter.test.ts.snap | 26 ++- .../src/plugins/i18n/locales/en.json | 4 + .../src/utils/fromAIOverrideUtils.test.ts | 167 +++++++++++++++ .../src/utils/fromAIOverrideUtils.ts | 190 +++++++++++++++++ packages/workflow/src/Constants.ts | 2 + packages/workflow/src/TelemetryHelpers.ts | 58 ++++++ .../workflow/test/FromAIParseUtils.test.ts | 3 + .../workflow/test/TelemetryHelpers.test.ts | 110 +++++++++- 34 files changed, 1710 insertions(+), 327 deletions(-) create mode 100644 packages/editor-ui/src/components/AiStarsIcon.vue create mode 100644 packages/editor-ui/src/components/ParameterInputFull.test.ts create mode 100644 packages/editor-ui/src/components/ParameterInputOverrides/FromAiOverrideButton.vue create mode 100644 packages/editor-ui/src/components/ParameterInputOverrides/FromAiOverrideField.vue create mode 100644 packages/editor-ui/src/components/ParameterInputOverrides/ParameterOverrideSelectableList.vue create mode 100644 packages/editor-ui/src/utils/fromAIOverrideUtils.test.ts create mode 100644 packages/editor-ui/src/utils/fromAIOverrideUtils.ts diff --git a/cypress/composables/ndv.ts b/cypress/composables/ndv.ts index 9ec6f3ba19c9b..3ad823d4d5089 100644 --- a/cypress/composables/ndv.ts +++ b/cypress/composables/ndv.ts @@ -225,7 +225,7 @@ export function populateMapperFields(fields: ReadonlyArray<[string, string]>) { getParameterInputByName(name).type(value); // Click on a parent to dismiss the pop up which hides the field below. - getParameterInputByName(name).parent().parent().parent().click('topLeft'); + getParameterInputByName(name).parent().parent().parent().parent().click('topLeft'); } } diff --git a/cypress/e2e/16-form-trigger-node.cy.ts b/cypress/e2e/16-form-trigger-node.cy.ts index ed901107ea60e..033753bc5c91e 100644 --- a/cypress/e2e/16-form-trigger-node.cy.ts +++ b/cypress/e2e/16-form-trigger-node.cy.ts @@ -62,12 +62,14 @@ describe('n8n Form Trigger', () => { getVisibleSelect().contains('Dropdown').click(); cy.contains('button', 'Add Field Option').click(); cy.contains('label', 'Field Options') + .parent() .parent() .nextAll() .find('[data-test-id="parameter-input-field"]') .eq(0) .type('Option 1'); cy.contains('label', 'Field Options') + .parent() .parent() .nextAll() .find('[data-test-id="parameter-input-field"]') diff --git a/cypress/e2e/48-subworkflow-inputs.cy.ts b/cypress/e2e/48-subworkflow-inputs.cy.ts index d529ec2c259cc..716d253b8ca69 100644 --- a/cypress/e2e/48-subworkflow-inputs.cy.ts +++ b/cypress/e2e/48-subworkflow-inputs.cy.ts @@ -90,8 +90,8 @@ describe('Sub-workflow creation and typed usage', () => { clickExecuteNode(); const expected = [ - ['-1', 'A String', '0:11:true2:3', 'aKey:-1', '[empty object]', 'false'], - ['-1', 'Another String', '[empty array]', 'aDifferentKey:-1', '[empty array]', 'false'], + ['-1', 'A String', '0:11:true2:3', 'aKey:-1', '[empty object]', 'true'], + ['-1', 'Another String', '[empty array]', 'aDifferentKey:-1', '[empty array]', 'true'], ]; assertOutputTableContent(expected); @@ -110,8 +110,8 @@ describe('Sub-workflow creation and typed usage', () => { clickExecuteNode(); const expected2 = [ - ['-1', '5', '0:11:true2:3', 'aKey:-1', '[empty object]', 'false'], - ['-1', '5', '[empty array]', 'aDifferentKey:-1', '[empty array]', 'false'], + ['-1', '5', '0:11:true2:3', 'aKey:-1', '[empty object]', 'true'], + ['-1', '5', '[empty array]', 'aDifferentKey:-1', '[empty array]', 'true'], ]; assertOutputTableContent(expected2); @@ -167,8 +167,8 @@ describe('Sub-workflow creation and typed usage', () => { ); assertOutputTableContent([ - ['[null]', '[null]', '[null]', '[null]', '[null]', 'false'], - ['[null]', '[null]', '[null]', '[null]', '[null]', 'false'], + ['[null]', '[null]', '[null]', '[null]', '[null]', 'true'], + ['[null]', '[null]', '[null]', '[null]', '[null]', 'true'], ]); clickExecuteNode(); diff --git a/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts b/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts index c5d88b1b25a51..ddc55ae25ed3a 100644 --- a/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts +++ b/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts @@ -107,7 +107,7 @@ describe('LoadNodesAndCredentials', () => { }; fullNodeWrapper.description.properties = [existingProp]; const result = instance.convertNodeToAiTool(fullNodeWrapper); - expect(result.description.properties).toHaveLength(3); // Existing prop + toolDescription + notice + expect(result.description.properties).toHaveLength(2); // Existing prop + toolDescription expect(result.description.properties).toContainEqual(existingProp); }); @@ -121,9 +121,9 @@ describe('LoadNodesAndCredentials', () => { }; fullNodeWrapper.description.properties = [resourceProp]; const result = instance.convertNodeToAiTool(fullNodeWrapper); - expect(result.description.properties[1].name).toBe('descriptionType'); - expect(result.description.properties[2].name).toBe('toolDescription'); - expect(result.description.properties[3]).toEqual(resourceProp); + expect(result.description.properties[0].name).toBe('descriptionType'); + expect(result.description.properties[1].name).toBe('toolDescription'); + expect(result.description.properties[2]).toEqual(resourceProp); }); it('should handle nodes with operation property', () => { @@ -136,9 +136,9 @@ describe('LoadNodesAndCredentials', () => { }; fullNodeWrapper.description.properties = [operationProp]; const result = instance.convertNodeToAiTool(fullNodeWrapper); - expect(result.description.properties[1].name).toBe('descriptionType'); - expect(result.description.properties[2].name).toBe('toolDescription'); - expect(result.description.properties[3]).toEqual(operationProp); + expect(result.description.properties[0].name).toBe('descriptionType'); + expect(result.description.properties[1].name).toBe('toolDescription'); + expect(result.description.properties[2]).toEqual(operationProp); }); it('should handle nodes with both resource and operation properties', () => { @@ -158,17 +158,17 @@ describe('LoadNodesAndCredentials', () => { }; fullNodeWrapper.description.properties = [resourceProp, operationProp]; const result = instance.convertNodeToAiTool(fullNodeWrapper); - expect(result.description.properties[1].name).toBe('descriptionType'); - expect(result.description.properties[2].name).toBe('toolDescription'); - expect(result.description.properties[3]).toEqual(resourceProp); - expect(result.description.properties[4]).toEqual(operationProp); + expect(result.description.properties[0].name).toBe('descriptionType'); + expect(result.description.properties[1].name).toBe('toolDescription'); + expect(result.description.properties[2]).toEqual(resourceProp); + expect(result.description.properties[3]).toEqual(operationProp); }); it('should handle nodes with empty properties', () => { fullNodeWrapper.description.properties = []; const result = instance.convertNodeToAiTool(fullNodeWrapper); - expect(result.description.properties).toHaveLength(2); - expect(result.description.properties[1].name).toBe('toolDescription'); + expect(result.description.properties).toHaveLength(1); + expect(result.description.properties[0].name).toBe('toolDescription'); }); it('should handle nodes with existing codex property', () => { diff --git a/packages/cli/src/events/relays/telemetry.event-relay.ts b/packages/cli/src/events/relays/telemetry.event-relay.ts index 8d0f050dee8f8..e33d736c1adb4 100644 --- a/packages/cli/src/events/relays/telemetry.event-relay.ts +++ b/packages/cli/src/events/relays/telemetry.event-relay.ts @@ -701,6 +701,7 @@ export class TelemetryEventRelay extends EventRelay { sharing_role: userRole, credential_type: null, is_managed: false, + ...TelemetryHelpers.resolveAIMetrics(workflow.nodes, this.nodeTypes), }; if (!manualExecEventProperties.node_graph_string) { diff --git a/packages/cli/src/load-nodes-and-credentials.ts b/packages/cli/src/load-nodes-and-credentials.ts index 4d6493d7ee7bc..dadf44c7ab69f 100644 --- a/packages/cli/src/load-nodes-and-credentials.ts +++ b/packages/cli/src/load-nodes-and-credentials.ts @@ -484,14 +484,6 @@ export class LoadNodesAndCredentials { placeholder: `e.g. ${item.description.description}`, }; - const noticeProp: INodeProperties = { - displayName: - "Use the expression {{ $fromAI('placeholder_name') }} for any data to be filled by the model", - name: 'notice', - type: 'notice', - default: '', - }; - item.description.properties.unshift(descProp); // If node has resource or operation we can determine pre-populate tool description based on it @@ -505,8 +497,6 @@ export class LoadNodesAndCredentials { }, }; } - - item.description.properties.unshift(noticeProp); } } diff --git a/packages/design-system/src/components/N8nCheckbox/__snapshots__/Checkbox.test.ts.snap b/packages/design-system/src/components/N8nCheckbox/__snapshots__/Checkbox.test.ts.snap index fa1e46c8896ad..323ba7ae4f018 100644 --- a/packages/design-system/src/components/N8nCheckbox/__snapshots__/Checkbox.test.ts.snap +++ b/packages/design-system/src/components/N8nCheckbox/__snapshots__/Checkbox.test.ts.snap @@ -31,34 +31,39 @@ exports[`components > N8nCheckbox > should render with both child and label 1`] class="container" data-test-id="input-label" > - +
+ + + +
+ + + @@ -131,34 +136,39 @@ exports[`components > N8nCheckbox > should render with label 1`] = ` class="container" data-test-id="input-label" > - +
+ + + +
+ + + diff --git a/packages/design-system/src/components/N8nFormBox/__snapshots__/FormBox.test.ts.snap b/packages/design-system/src/components/N8nFormBox/__snapshots__/FormBox.test.ts.snap index 7123cc6c5a231..949105aef3b93 100644 --- a/packages/design-system/src/components/N8nFormBox/__snapshots__/FormBox.test.ts.snap +++ b/packages/design-system/src/components/N8nFormBox/__snapshots__/FormBox.test.ts.snap @@ -33,41 +33,46 @@ exports[`FormBox > should render the component 1`] = ` class="container" data-test-id="name" > - +
+ + + +
+ + +
should render the component 1`] = ` class="container" data-test-id="email" > -
-
- - - -
- +
+ + + +
+ + +
should render the component 1`] = ` class="container" data-test-id="password" > -
-
- - - -
- +
+ + + +
+ + +
v-bind="$attrs" data-test-id="input-label" > -
-
- - - -
- +
+ + + +
+ + + diff --git a/packages/design-system/src/components/N8nSelectableList/SelectableList.test.ts b/packages/design-system/src/components/N8nSelectableList/SelectableList.test.ts index e7ba9d420615b..21f8040699f66 100644 --- a/packages/design-system/src/components/N8nSelectableList/SelectableList.test.ts +++ b/packages/design-system/src/components/N8nSelectableList/SelectableList.test.ts @@ -72,7 +72,7 @@ describe('N8nSelectableList', () => { expect(wrapper.html()).toMatchSnapshot(); }); - it('renders disabled collection and clicks do not modify', async () => { + it('renders disabled collection without selectables', async () => { const wrapper = render(N8nSelectableList, { props: { modelValue: { @@ -87,20 +87,10 @@ describe('N8nSelectableList', () => { }, }); - expect(wrapper.getByTestId('selectable-list-selectable-propA')).toBeInTheDocument(); - expect(wrapper.getByTestId('selectable-list-slot-propB')).toBeInTheDocument(); - expect(wrapper.queryByTestId('selectable-list-selectable-propB')).not.toBeInTheDocument(); - expect(wrapper.getByTestId('selectable-list-selectable-propC')).toBeInTheDocument(); - - await fireEvent.click(wrapper.getByTestId('selectable-list-selectable-propA')); - - expect(wrapper.getByTestId('selectable-list-selectable-propA')).toBeInTheDocument(); - expect(wrapper.queryByTestId('selectable-list-slot-propA')).not.toBeInTheDocument(); - - await fireEvent.click(wrapper.getByTestId('selectable-list-remove-slot-propB')); - + expect(wrapper.queryByTestId('selectable-list-selectable-propA')).not.toBeInTheDocument(); expect(wrapper.getByTestId('selectable-list-slot-propB')).toBeInTheDocument(); expect(wrapper.queryByTestId('selectable-list-selectable-propB')).not.toBeInTheDocument(); + expect(wrapper.queryByTestId('selectable-list-selectable-propC')).not.toBeInTheDocument(); expect(wrapper.html()).toMatchSnapshot(); }); diff --git a/packages/design-system/src/components/N8nSelectableList/SelectableList.vue b/packages/design-system/src/components/N8nSelectableList/SelectableList.vue index e5ba985edf62b..de0133d2d0bba 100644 --- a/packages/design-system/src/components/N8nSelectableList/SelectableList.vue +++ b/packages/design-system/src/components/N8nSelectableList/SelectableList.vue @@ -59,7 +59,7 @@ function itemComparator(a: Item, b: Item) {