From d86ad72d96f33119bec369b497d7b96f8b65822d Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Wed, 20 Mar 2024 16:55:43 +0100 Subject: [PATCH 1/9] Show tip when user can type dot after an expression --- packages/editor-ui/src/Interface.ts | 1 + .../components/ExpressionParameterInput.vue | 256 +++++++++--------- .../InlineExpressionEditorInput.vue | 30 +- .../InlineExpressionEditorOutput.vue | 37 ++- .../InlineExpressionTip.vue | 212 +++++++++------ .../__tests__/InlineExpressionTip.test.ts | 92 +++++++ .../src/components/ParameterInputFull.vue | 1 + .../editor-ui/src/components/RunDataJson.vue | 2 +- .../src/components/RunDataSchema.vue | 4 +- .../editor-ui/src/components/RunDataTable.vue | 2 +- .../src/composables/useExpressionEditor.ts | 29 +- .../src/plugins/i18n/locales/en.json | 4 +- packages/editor-ui/src/stores/ndv.store.ts | 4 + 13 files changed, 419 insertions(+), 255 deletions(-) create mode 100644 packages/editor-ui/src/components/InlineExpressionEditor/__tests__/InlineExpressionTip.test.ts diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 70848e67eaf03..296c6b2d4c097 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -1246,6 +1246,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 1ab3f85302c16..dc6738322c1b0 100644 --- a/packages/editor-ui/src/components/ExpressionParameterInput.vue +++ b/packages/editor-ui/src/components/ExpressionParameterInput.vue @@ -1,3 +1,121 @@ + + - - 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 0000000000000..f3bd926e6756a --- /dev/null +++ b/packages/editor-ui/src/components/InlineExpressionEditor/__tests__/InlineExpressionTip.test.ts @@ -0,0 +1,92 @@ +import { createTestingPinia } from '@pinia/testing'; +import { renderComponent } from '@/__tests__/render'; +import InlineExpressionTip from '@/components/InlineExpressionEditor/InlineExpressionTip.vue'; +import type { useNDVStore } from '@/stores/ndv.store'; +import { EditorSelection, EditorState } from '@codemirror/state'; +import type { CompletionResult } from '@codemirror/autocomplete'; + +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, + isDNVDataEmpty: 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, + isDNVDataEmpty: 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, + isDNVDataEmpty: 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 "add a dot" tip', async () => { + mockNdvState = { + hasInputData: true, + isDNVDataEmpty: vi.fn(() => false), + focusedMappableInput: 'Some Input', + setHighlightDraggables: vi.fn(), + }; + mockCompletionResult = { options: [{ label: 'foo' }] }; + const selection = EditorSelection.cursor(9); + const expression = '{{ $json }}'; + const { rerender, container } = renderComponent(InlineExpressionTip, { + pinia: createTestingPinia(), + }); + + await rerender({ + editorState: EditorState.create({ + doc: expression, + selection: EditorSelection.create([selection]), + }), + selection, + unresolvedExpression: expression, + }); + expect(container).toHaveTextContent('Tip: Type . to access all available fields and methods'); + }); + }); +}); diff --git a/packages/editor-ui/src/components/ParameterInputFull.vue b/packages/editor-ui/src/components/ParameterInputFull.vue index 2d573ffe0b3f1..bb33767a736e2 100644 --- a/packages/editor-ui/src/components/ParameterInputFull.vue +++ b/packages/editor-ui/src/components/ParameterInputFull.vue @@ -220,6 +220,7 @@ export default defineComponent({ (this.isInputTypeString || this.isInputTypeNumber) && !this.isValueExpression && !this.isDropDisabled && + (!this.ndvStore.hasInputData || !this.isInputDataEmpty) && !this.ndvStore.isMappingOnboarded ); }, diff --git a/packages/editor-ui/src/components/RunDataJson.vue b/packages/editor-ui/src/components/RunDataJson.vue index 0c978cb027126..6492a4cd14993 100644 --- a/packages/editor-ui/src/components/RunDataJson.vue +++ b/packages/editor-ui/src/components/RunDataJson.vue @@ -158,7 +158,7 @@ export default defineComponent({ return executionDataToJson(this.inputData); }, highlight(): boolean { - return !this.ndvStore.isMappingOnboarded && Boolean(this.ndvStore.focusedMappableInput); + return this.ndvStore.highlightDraggables; }, }, methods: { diff --git a/packages/editor-ui/src/components/RunDataSchema.vue b/packages/editor-ui/src/components/RunDataSchema.vue index 9a6413915f9b8..5496c1406ac0c 100644 --- a/packages/editor-ui/src/components/RunDataSchema.vue +++ b/packages/editor-ui/src/components/RunDataSchema.vue @@ -35,9 +35,7 @@ const schema = computed(() => getSchemaForExecutionData(props.data)); const isDataEmpty = computed(() => isEmpty(props.data)); -const highlight = computed(() => { - return !ndvStore.isMappingOnboarded && Boolean(ndvStore.focusedMappableInput); -}); +const highlight = computed(() => ndvStore.highlightDraggables); const onDragStart = (el: HTMLElement) => { if (el?.dataset?.path) { diff --git a/packages/editor-ui/src/components/RunDataTable.vue b/packages/editor-ui/src/components/RunDataTable.vue index 6cd75d6f14e49..5bd7c0e7f5cf3 100644 --- a/packages/editor-ui/src/components/RunDataTable.vue +++ b/packages/editor-ui/src/components/RunDataTable.vue @@ -260,7 +260,7 @@ export default defineComponent({ return this.ndvStore.focusedMappableInput; }, highlight(): boolean { - return !this.ndvStore.isMappingOnboarded && Boolean(this.ndvStore.focusedMappableInput); + return this.ndvStore.highlightDraggables; }, }, methods: { diff --git a/packages/editor-ui/src/composables/useExpressionEditor.ts b/packages/editor-ui/src/composables/useExpressionEditor.ts index 2608954a39f70..df610af1d3a9f 100644 --- a/packages/editor-ui/src/composables/useExpressionEditor.ts +++ b/packages/editor-ui/src/composables/useExpressionEditor.ts @@ -25,7 +25,13 @@ import { isEmptyExpression, } from '@/utils/expressions'; import { completionStatus } from '@codemirror/autocomplete'; -import { Compartment, EditorState, type Extension } from '@codemirror/state'; +import { + Compartment, + EditorState, + type SelectionRange, + type Extension, + EditorSelection, +} from '@codemirror/state'; import { EditorView, type ViewUpdate } from '@codemirror/view'; import { debounce, isEqual } from 'lodash-es'; import { useRouter } from 'vue-router'; @@ -59,6 +65,7 @@ export const useExpressionEditor = ({ const editor = ref(); const hasFocus = ref(false); const segments = ref([]); + const selection = ref(EditorSelection.cursor(0)) as Ref; const customExtensions = ref(new Compartment()); const readOnlyExtensions = ref(new Compartment()); const telemetryExtensions = ref(new Compartment()); @@ -108,7 +115,7 @@ export const useExpressionEditor = ({ // For some reason, expressions that resolve to a number 0 are breaking preview in the SQL editor // This fixes that but as as TODO we should figure out why this is happening resolved: String(resolved), - state: getResolvableState(fullError ?? error, completionStatus !== null), + state: getResolvableState(fullError ?? error, autocompleteStatus.value !== null), error: fullError, }); @@ -131,11 +138,23 @@ export const useExpressionEditor = ({ highlighter.addColor(editor.value, resolvableSegments.value); } + function updateSelection(viewUpdate: ViewUpdate) { + const currentSelection = selection.value; + const newSelection = viewUpdate.state.selection.ranges[0]; + + if (!currentSelection?.eq(newSelection)) { + selection.value = newSelection; + } + } + + const debouncedUpdateSelection = debounce(updateSelection, 200); const debouncedUpdateSegments = debounce(updateSegments, 200); - function onEditorUpdate(viewUpdate: ViewUpdate) { - if (!viewUpdate.docChanged || !editor.value) return; + function onEditorUpdate(viewUpdate: ViewUpdate) { autocompleteStatus.value = completionStatus(viewUpdate.view.state); + debouncedUpdateSelection(viewUpdate); + + if (!viewUpdate.docChanged) return; debouncedUpdateSegments(); } @@ -157,6 +176,7 @@ export const useExpressionEditor = ({ EditorView.updateListener.of(onEditorUpdate), EditorView.focusChangeEffect.of((_, newHasFocus) => { hasFocus.value = newHasFocus; + selection.value = state.selection.ranges[0]; return null; }), EditorView.contentAttributes.of({ 'data-gramm': 'false' }), // disable grammarly @@ -389,6 +409,7 @@ export const useExpressionEditor = ({ return { editor, hasFocus, + selection, segments: { all: segments, html: htmlSegments, diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 5131a7c9def3b..d9753490debbf 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -681,6 +681,8 @@ "expressionModalInput.undefined": "[undefined]", "expressionModalInput.null": "null", "expressionTip.noExecutionData": "Execute previous nodes to use input data", + "expressionTip.typeDot": "Type . to access all available fields and methods", + "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", @@ -1201,8 +1203,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 7fb3b9c4d5bf9..07228f3ee4bc7 100644 --- a/packages/editor-ui/src/stores/ndv.store.ts +++ b/packages/editor-ui/src/stores/ndv.store.ts @@ -55,6 +55,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 { @@ -251,6 +252,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 || ''); From c0ed519ba99347db86115e25b874fa2b7758aa6a Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Thu, 21 Mar 2024 12:11:53 +0100 Subject: [PATCH 2/9] Apply different tip message for objects --- .../InlineExpressionTip.vue | 92 +++++++++++-------- .../__tests__/InlineExpressionTip.test.ts | 36 +++++++- .../plugins/codemirror/completions/utils.ts | 6 ++ .../src/plugins/i18n/locales/en.json | 3 +- 4 files changed, 95 insertions(+), 42 deletions(-) diff --git a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionTip.vue b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionTip.vue index 23a3326145ab1..f4689ac2c61fa 100644 --- a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionTip.vue +++ b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionTip.vue @@ -20,8 +20,12 @@ {{ i18n.baseText('expressionTip.noExecutionData') }} -
- +
+ +
+ +
+
@@ -35,20 +39,23 @@ import { useI18n } from '@/composables/useI18n'; import { useNDVStore } from '@/stores/ndv.store'; import { computed, ref, watch } from 'vue'; import { EditorSelection, EditorState, type SelectionRange } from '@codemirror/state'; -import { CompletionContext } from '@codemirror/autocomplete'; +import { type Completion, CompletionContext } from '@codemirror/autocomplete'; import { datatypeCompletions } from '@/plugins/codemirror/completions/datatype.completions'; +import { watchDebounced } from '@vueuse/core'; +import { FIELDS_SECTION } from '@/plugins/codemirror/completions/constants'; +import { isCompletionSection } from '@/plugins/codemirror/completions/utils'; type Props = { tip?: 'drag' | 'default' | 'dot' | 'auto'; - unresolvedExpression?: string; editorState?: EditorState; + unresolvedExpression?: string; selection?: SelectionRange; }; const props = withDefaults(defineProps(), { - unresolvedExpression: '', tip: 'auto', editorState: undefined, + unresolvedExpression: '', selection: () => EditorSelection.cursor(0), }); @@ -56,13 +63,14 @@ const i18n = useI18n(); const ndvStore = useNDVStore(); const canAddDotToExpression = ref(false); - -const emptyExpression = computed(() => props.unresolvedExpression.trim().length === 0); +const resolvedExpressionHasFields = ref(false); const canDragToFocusedInput = computed( () => !ndvStore.isDNVDataEmpty('input') && ndvStore.focusedMappableInput, ); +const emptyExpression = computed(() => props.unresolvedExpression.trim().length === 0); + const tip = computed(() => { if (!ndvStore.hasInputData) { return 'executePrevious'; @@ -70,46 +78,56 @@ const tip = computed(() => { if (props.tip !== 'auto') return props.tip; - if (canAddDotToExpression.value) return 'dot'; + if (canAddDotToExpression.value) { + return resolvedExpressionHasFields.value ? 'dotObject' : 'dotPrimitive'; + } if (canDragToFocusedInput.value && emptyExpression.value) return 'drag'; return 'default'; }); +const cursor = computed(() => props.selection.anchor); + +function getCompletionsWithDot(): readonly Completion[] { + const atCursor = cursor.value; + if ( + !props.editorState || + !props.selection || + !props.selection.empty || + !props.unresolvedExpression || + props.unresolvedExpression.charAt(atCursor - 1) === '.' || + props.unresolvedExpression.charAt(atCursor - 1) === ' ' || + props.unresolvedExpression.charAt(atCursor) === '.' + ) { + return []; + } + + const cursorAfterDot = atCursor + 1; + const docWithDot = + props.editorState.sliceDoc(0, atCursor) + '.' + props.editorState.sliceDoc(atCursor); + const selectionWithDot = EditorSelection.create([EditorSelection.cursor(cursorAfterDot)]); + const stateWithDot = EditorState.create({ + doc: docWithDot, + selection: selectionWithDot, + }); + + const context = new CompletionContext(stateWithDot, cursorAfterDot, true); + const completionResult = datatypeCompletions(context); + return completionResult?.options ?? []; +} + watch(tip, (newTip) => { ndvStore.setHighlightDraggables(!ndvStore.isMappingOnboarded && newTip === 'drag'); }); -watch( - () => props.selection, - () => { - if ( - !props.editorState || - !props.selection || - !props.selection.empty || - props.unresolvedExpression.endsWith('.') - ) { - canAddDotToExpression.value = false; - return; - } - - const cursor = props.selection.anchor; - const cursorAfterDot = cursor + 1; - const docWithDot = - props.editorState.sliceDoc(0, cursor) + '.' + props.editorState.sliceDoc(cursor); - const selectionWithDot = EditorSelection.create([EditorSelection.cursor(cursorAfterDot)]); - const stateWithDot = EditorState.create({ - doc: docWithDot, - selection: selectionWithDot, - }); - - const context = new CompletionContext(stateWithDot, cursorAfterDot, true); - - const result = datatypeCompletions(context); - canAddDotToExpression.value = !!result && result.options.length > 0; - }, -); +watchDebounced([() => props.selection, () => props.unresolvedExpression], () => { + const completions = getCompletionsWithDot(); + canAddDotToExpression.value = completions.length > 0; + resolvedExpressionHasFields.value = completions.some( + ({ section }) => isCompletionSection(section) && section.name === FIELDS_SECTION.name, + ); +});