From 160dfd383d79fc44be79e5a071dc5f6c6b67469b Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Tue, 26 Mar 2024 15:23:30 +0100 Subject: [PATCH] feat(editor): Show tip when user can type dot after an expression (#8931) --- cypress/e2e/14-mapping.cy.ts | 1 + packages/editor-ui/src/Interface.ts | 1 + .../components/ExpressionParameterInput.vue | 260 +++++++++--------- .../InlineExpressionEditorInput.vue | 38 ++- .../InlineExpressionEditorOutput.vue | 39 ++- .../InlineExpressionTip.vue | 248 ++++++++++------- .../__tests__/InlineExpressionTip.test.ts | 128 +++++++++ .../src/components/NodeExecuteButton.vue | 2 +- .../src/components/ParameterInputFull.vue | 8 +- .../editor-ui/src/components/RunDataJson.vue | 2 +- .../src/components/RunDataSchema.vue | 4 +- .../editor-ui/src/components/RunDataTable.vue | 2 +- .../src/composables/useExpressionEditor.ts | 30 +- .../completions/datatype.completions.ts | 2 +- .../plugins/codemirror/completions/utils.ts | 6 + .../src/plugins/i18n/locales/en.json | 5 +- packages/editor-ui/src/stores/ndv.store.ts | 6 +- 17 files changed, 510 insertions(+), 272 deletions(-) create mode 100644 packages/editor-ui/src/components/InlineExpressionEditor/__tests__/InlineExpressionTip.test.ts diff --git a/cypress/e2e/14-mapping.cy.ts b/cypress/e2e/14-mapping.cy.ts index 9dc878402f..b797a10aca 100644 --- a/cypress/e2e/14-mapping.cy.ts +++ b/cypress/e2e/14-mapping.cy.ts @@ -255,6 +255,7 @@ describe('Data mapping', () => { ndv.actions.typeIntoParameterInput('value', 'delete me'); ndv.actions.typeIntoParameterInput('name', 'test'); + ndv.getters.parameterInput('name').find('input').blur(); ndv.actions.typeIntoParameterInput('value', 'fun'); ndv.actions.clearParameterInput('value'); // keep focus on param diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 2ed67fbaf4..0e6d812d2b 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -1247,6 +1247,7 @@ export interface NDVState { }; isMappingOnboarded: boolean; isAutocompleteOnboarded: boolean; + highlightDraggables: boolean; } export interface NotificationOptions extends Partial { diff --git a/packages/editor-ui/src/components/ExpressionParameterInput.vue b/packages/editor-ui/src/components/ExpressionParameterInput.vue index 1ab3f85302..dd08d22f7c 100644 --- a/packages/editor-ui/src/components/ExpressionParameterInput.vue +++ b/packages/editor-ui/src/components/ExpressionParameterInput.vue @@ -1,3 +1,124 @@ + + - - diff --git a/packages/editor-ui/src/components/InlineExpressionEditor/__tests__/InlineExpressionTip.test.ts b/packages/editor-ui/src/components/InlineExpressionEditor/__tests__/InlineExpressionTip.test.ts new file mode 100644 index 0000000000..f3cdc81318 --- /dev/null +++ b/packages/editor-ui/src/components/InlineExpressionEditor/__tests__/InlineExpressionTip.test.ts @@ -0,0 +1,128 @@ +import { renderComponent } from '@/__tests__/render'; +import InlineExpressionTip from '@/components/InlineExpressionEditor/InlineExpressionTip.vue'; +import { FIELDS_SECTION } from '@/plugins/codemirror/completions/constants'; +import type { useNDVStore } from '@/stores/ndv.store'; +import type { CompletionResult } from '@codemirror/autocomplete'; +import { EditorSelection, EditorState } from '@codemirror/state'; +import { createTestingPinia } from '@pinia/testing'; +import { waitFor } from '@testing-library/vue'; + +let mockNdvState: Partial>; +let mockCompletionResult: Partial; + +vi.mock('@/stores/ndv.store', () => { + return { + useNDVStore: vi.fn(() => mockNdvState), + }; +}); + +vi.mock('@/plugins/codemirror/completions/datatype.completions', () => { + return { + datatypeCompletions: vi.fn(() => mockCompletionResult), + }; +}); + +describe('InlineExpressionTip.vue', () => { + beforeEach(() => { + mockNdvState = { + hasInputData: true, + isNDVDataEmpty: vi.fn(() => true), + }; + }); + + test('should show the default tip', async () => { + const { container } = renderComponent(InlineExpressionTip, { + pinia: createTestingPinia(), + }); + expect(container).toHaveTextContent('Tip: Anything inside {{ }} is JavaScript. Learn more'); + }); + + describe('When the NDV input is not empty and a mappable input is focused', () => { + test('should show the drag-n-drop tip', async () => { + mockNdvState = { + hasInputData: true, + isNDVDataEmpty: vi.fn(() => false), + focusedMappableInput: 'Some Input', + }; + const { container } = renderComponent(InlineExpressionTip, { + pinia: createTestingPinia(), + }); + expect(container).toHaveTextContent('Tip: Drag aninput fieldfrom the left to use it here.'); + }); + }); + + describe('When the node has no input data', () => { + test('should show the execute previous nodes tip', async () => { + mockNdvState = { + hasInputData: false, + isInputParentOfActiveNode: true, + isNDVDataEmpty: vi.fn(() => false), + focusedMappableInput: 'Some Input', + }; + const { container } = renderComponent(InlineExpressionTip, { + pinia: createTestingPinia(), + }); + expect(container).toHaveTextContent('Tip: Execute previous nodes to use input data'); + }); + }); + + describe('When the expression can be autocompleted with a dot', () => { + test('should show the correct tip for objects', async () => { + mockNdvState = { + hasInputData: true, + isNDVDataEmpty: vi.fn(() => false), + focusedMappableInput: 'Some Input', + setHighlightDraggables: vi.fn(), + }; + mockCompletionResult = { options: [{ label: 'foo', section: FIELDS_SECTION }] }; + const selection = EditorSelection.cursor(8); + const expression = '{{ $json }}'; + const { rerender, container } = renderComponent(InlineExpressionTip, { + pinia: createTestingPinia(), + }); + + await rerender({ + editorState: EditorState.create({ + doc: expression, + selection: EditorSelection.create([selection]), + }), + selection, + unresolvedExpression: expression, + }); + await waitFor(() => + expect(container).toHaveTextContent( + 'Tip: Type . for data transformation options, or to access fields. Learn more', + ), + ); + }); + + test('should show the correct tip for primitives', async () => { + mockNdvState = { + hasInputData: true, + isNDVDataEmpty: vi.fn(() => false), + focusedMappableInput: 'Some Input', + setHighlightDraggables: vi.fn(), + }; + mockCompletionResult = { options: [{ label: 'foo' }] }; + const selection = EditorSelection.cursor(12); + const expression = '{{ $json.foo }}'; + const { rerender, container } = renderComponent(InlineExpressionTip, { + pinia: createTestingPinia(), + }); + + await rerender({ + editorState: EditorState.create({ + doc: expression, + selection: EditorSelection.create([selection]), + }), + selection, + unresolvedExpression: expression, + }); + await waitFor(() => + expect(container).toHaveTextContent( + 'Tip: Type . for data transformation options. Learn more', + ), + ); + }); + }); +}); diff --git a/packages/editor-ui/src/components/NodeExecuteButton.vue b/packages/editor-ui/src/components/NodeExecuteButton.vue index acf9db35db..9aa11ee7bc 100644 --- a/packages/editor-ui/src/components/NodeExecuteButton.vue +++ b/packages/editor-ui/src/components/NodeExecuteButton.vue @@ -230,7 +230,7 @@ export default defineComponent({ async onClick() { // Show chat if it's a chat node or a child of a chat node with no input data - if (this.isChatNode || (this.isChatChild && this.ndvStore.isDNVDataEmpty('input'))) { + if (this.isChatNode || (this.isChatChild && this.ndvStore.isNDVDataEmpty('input'))) { this.ndvStore.setActiveNodeName(null); nodeViewEventBus.emit('openChat'); } else if (this.isListeningForEvents) { diff --git a/packages/editor-ui/src/components/ParameterInputFull.vue b/packages/editor-ui/src/components/ParameterInputFull.vue index aa900f01b1..ceda6d05d7 100644 --- a/packages/editor-ui/src/components/ParameterInputFull.vue +++ b/packages/editor-ui/src/components/ParameterInputFull.vue @@ -55,7 +55,7 @@
- +
{ + return typeof section === 'object'; +}; diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 8f663343c4..49e0223cbd 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -688,6 +688,9 @@ "expressionModalInput.undefined": "[undefined]", "expressionModalInput.null": "null", "expressionTip.noExecutionData": "Execute previous nodes to use input data", + "expressionTip.typeDotPrimitive": "Type . for data transformation options. Learn more", + "expressionTip.typeDotObject": "Type . for data transformation options, or to access fields. Learn more", + "expressionTip.javascript": "Anything inside {'{{ }}'} is JavaScript. Learn more", "expressionModalInput.noExecutionData": "Execute previous nodes for preview", "expressionModalInput.noNodeExecutionData": "Execute node ‘{node}’ for preview", "expressionModalInput.noInputConnection": "No input connected", @@ -1219,8 +1222,6 @@ "openWorkflow.workflowNotFoundError": "Could not find workflow", "parameterInput.expressionResult": "e.g. {result}", "parameterInput.tip": "Tip", - "parameterInput.anythingInside": "Anything inside ", - "parameterInput.isJavaScript": " is JavaScript.", "parameterInput.dragTipBeforePill": "Drag an", "parameterInput.inputField": "input field", "parameterInput.dragTipAfterPill": "from the left to use it here.", diff --git a/packages/editor-ui/src/stores/ndv.store.ts b/packages/editor-ui/src/stores/ndv.store.ts index 6fb4f2a528..5ac185d4d2 100644 --- a/packages/editor-ui/src/stores/ndv.store.ts +++ b/packages/editor-ui/src/stores/ndv.store.ts @@ -56,6 +56,7 @@ export const useNDVStore = defineStore(STORES.NDV, { }, isMappingOnboarded: useStorage(LOCAL_STORAGE_MAPPING_IS_ONBOARDED).value === 'true', isAutocompleteOnboarded: useStorage(LOCAL_STORAGE_AUTOCOMPLETE_IS_ONBOARDED).value === 'true', + highlightDraggables: false, }), getters: { activeNode(): INodeUi | null { @@ -129,7 +130,7 @@ export const useNDVStore = defineStore(STORES.NDV, { ndvInputBranchIndex(): number | undefined { return this.input.branch; }, - isDNVDataEmpty() { + isNDVDataEmpty() { return (panel: 'input' | 'output'): boolean => this[panel].data.isEmpty; }, isInputParentOfActiveNode(): boolean { @@ -252,6 +253,9 @@ export const useNDVStore = defineStore(STORES.NDV, { this.isAutocompleteOnboarded = true; useStorage(LOCAL_STORAGE_AUTOCOMPLETE_IS_ONBOARDED).value = 'true'; }, + setHighlightDraggables(highlight: boolean) { + this.highlightDraggables = highlight; + }, updateNodeParameterIssues(issues: INodeIssues): void { const workflowsStore = useWorkflowsStore(); const activeNode = workflowsStore.getNodeByName(this.activeNodeName || '');