diff --git a/cypress/e2e/26-resource-locator.cy.ts b/cypress/e2e/26-resource-locator.cy.ts index bc6baa4de9..9f4853aa8a 100644 --- a/cypress/e2e/26-resource-locator.cy.ts +++ b/cypress/e2e/26-resource-locator.cy.ts @@ -75,7 +75,7 @@ describe('Resource Locator', () => { ndv.actions.setInvalidExpression({ fieldName: 'fieldId' }); - ndv.getters.inputDataContainer().click(); // remove focus from input, hide expression preview + ndv.getters.inputPanel().click(); // remove focus from input, hide expression preview ndv.getters.resourceLocatorInput('rlc').click(); diff --git a/cypress/e2e/5-ndv.cy.ts b/cypress/e2e/5-ndv.cy.ts index 715052c9a8..cdac202f48 100644 --- a/cypress/e2e/5-ndv.cy.ts +++ b/cypress/e2e/5-ndv.cy.ts @@ -325,7 +325,7 @@ describe('NDV', () => { ndv.actions.setInvalidExpression({ fieldName: 'fieldId', delay: 200 }); - ndv.getters.inputDataContainer().click(); // remove focus from input, hide expression preview + ndv.getters.inputPanel().click(); // remove focus from input, hide expression preview ndv.getters.parameterInput('remoteOptions').click(); @@ -712,4 +712,31 @@ describe('NDV', () => { workflowPage.getters.successToast().should('exist'); }); }); + + it('should allow selecting item for expressions', () => { + workflowPage.actions.visit(); + + cy.createFixtureWorkflow('Test_workflow_3.json', `My test workflow`); + workflowPage.actions.openNode('Set'); + + ndv.actions.typeIntoParameterInput('value', '='); // switch to expressions + ndv.actions.typeIntoParameterInput('value', '{{', { + parseSpecialCharSequences: false, + }); + ndv.actions.typeIntoParameterInput('value', '$json.input[0].count'); + ndv.getters.inlineExpressionEditorOutput().should('have.text', '0'); + + ndv.actions.expressionSelectNextItem(); + ndv.getters.inlineExpressionEditorOutput().should('have.text', '1'); + ndv.getters.inlineExpressionEditorItemInput().should('have.value', '1'); + ndv.getters.inlineExpressionEditorItemNextButton().should('be.disabled'); + + ndv.actions.expressionSelectPrevItem(); + ndv.getters.inlineExpressionEditorOutput().should('have.text', '0'); + ndv.getters.inlineExpressionEditorItemInput().should('have.value', '0'); + ndv.getters.inlineExpressionEditorItemPrevButton().should('be.disabled'); + + ndv.actions.expressionSelectItem(1); + ndv.getters.inlineExpressionEditorOutput().should('have.text', '1'); + }); }); diff --git a/cypress/pages/ndv.ts b/cypress/pages/ndv.ts index f9cb5b1304..d9e40e4291 100644 --- a/cypress/pages/ndv.ts +++ b/cypress/pages/ndv.ts @@ -40,6 +40,12 @@ export class NDV extends BasePage { this.getters.inputTableRow(row).find('td').eq(col), inlineExpressionEditorInput: () => cy.getByTestId('inline-expression-editor-input'), inlineExpressionEditorOutput: () => cy.getByTestId('inline-expression-editor-output'), + inlineExpressionEditorItemInput: () => + cy.getByTestId('inline-expression-editor-item-input').find('input'), + inlineExpressionEditorItemPrevButton: () => + cy.getByTestId('inline-expression-editor-item-prev'), + inlineExpressionEditorItemNextButton: () => + cy.getByTestId('inline-expression-editor-item-next'), nodeParameters: () => cy.getByTestId('node-parameters'), parameterInput: (parameterName: string) => cy.getByTestId(`parameter-input-${parameterName}`), parameterInputIssues: (parameterName: string) => @@ -290,6 +296,15 @@ export class NDV extends BasePage { .click({ force: true }); this.getters.parameterInput('operation').find('input').should('have.value', operation); }, + expressionSelectItem: (index: number) => { + this.getters.inlineExpressionEditorItemInput().type(`{selectall}${index}`); + }, + expressionSelectNextItem: () => { + this.getters.inlineExpressionEditorItemNextButton().click(); + }, + expressionSelectPrevItem: () => { + this.getters.inlineExpressionEditorItemPrevButton().click(); + }, }; } diff --git a/packages/design-system/src/components/N8nInputNumber/InputNumber.stories.ts b/packages/design-system/src/components/N8nInputNumber/InputNumber.stories.ts index d1496da540..feee3265c0 100644 --- a/packages/design-system/src/components/N8nInputNumber/InputNumber.stories.ts +++ b/packages/design-system/src/components/N8nInputNumber/InputNumber.stories.ts @@ -95,3 +95,24 @@ Sizes.args = { placeholder: 'placeholder...', controls: false, }; + +const ControlsTemplate: StoryFn = (args, { argTypes }) => ({ + setup: () => ({ args }), + props: Object.keys(argTypes), + components: { + N8nInputNumber, + }, + template: + '
', + methods, + data() { + return { + val: '', + }; + }, +}); + +export const Controls = ControlsTemplate.bind({}); +Controls.args = { + placeholder: 'placeholder...', +}; diff --git a/packages/design-system/src/components/N8nInputNumber/InputNumber.vue b/packages/design-system/src/components/N8nInputNumber/InputNumber.vue index fa659ab598..19303c40c3 100644 --- a/packages/design-system/src/components/N8nInputNumber/InputNumber.vue +++ b/packages/design-system/src/components/N8nInputNumber/InputNumber.vue @@ -1,16 +1,16 @@ - diff --git a/packages/editor-ui/src/components/InlineExpressionEditor/ExpressionOutput.vue b/packages/editor-ui/src/components/InlineExpressionEditor/ExpressionOutput.vue index ab545be0ab..c037129a46 100644 --- a/packages/editor-ui/src/components/InlineExpressionEditor/ExpressionOutput.vue +++ b/packages/editor-ui/src/components/InlineExpressionEditor/ExpressionOutput.vue @@ -103,6 +103,9 @@ onMounted(() => { ], }), }); + + highlighter.addColor(editor.value as EditorView, resolvedSegments.value); + highlighter.removeColor(editor.value as EditorView, plaintextSegments.value); }); onBeforeUnmount(() => { diff --git a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue index 3c160848d1..ff0eaa6732 100644 --- a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue +++ b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue @@ -6,20 +6,20 @@ import type { Segment } from '@/types/expressions'; import ExpressionOutput from './ExpressionOutput.vue'; import InlineExpressionTip from './InlineExpressionTip.vue'; import { outputTheme } from './theme'; +import { computed, onBeforeUnmount } from 'vue'; +import { useNDVStore } from '@/stores/ndv.store'; +import { N8nTooltip } from 'n8n-design-system/components'; interface InlineExpressionEditorOutputProps { segments: Segment[]; - hoveringItemNumber: number; unresolvedExpression?: string; editorState?: EditorState; selection?: SelectionRange; visible?: boolean; - noInputData?: boolean; } withDefaults(defineProps(), { visible: false, - noInputData: false, editorState: undefined, selection: undefined, unresolvedExpression: undefined, @@ -27,19 +27,95 @@ withDefaults(defineProps(), { const i18n = useI18n(); const theme = outputTheme(); +const ndvStore = useNDVStore(); + +const hideTableHoverHint = computed(() => ndvStore.isTableHoverOnboarded); +const hoveringItem = computed(() => ndvStore.getHoveringItem); +const hoveringItemIndex = computed(() => hoveringItem.value?.itemIndex); +const isHoveringItem = computed(() => Boolean(hoveringItem.value)); +const itemsLength = computed(() => ndvStore.ndvInputDataWithPinnedData.length); +const itemIndex = computed(() => hoveringItemIndex.value ?? ndvStore.expressionOutputItemIndex); +const max = computed(() => Math.max(itemsLength.value - 1, 0)); +const isItemIndexEditable = computed(() => !isHoveringItem.value && itemsLength.value > 0); +const canSelectPrevItem = computed(() => isItemIndexEditable.value && itemIndex.value !== 0); +const canSelectNextItem = computed( + () => isItemIndexEditable.value && itemIndex.value < itemsLength.value - 1, +); + +function updateItemIndex(index: number) { + ndvStore.expressionOutputItemIndex = index; +} + +function nextItem() { + ndvStore.expressionOutputItemIndex = ndvStore.expressionOutputItemIndex + 1; +} + +function prevItem() { + ndvStore.expressionOutputItemIndex = ndvStore.expressionOutputItemIndex - 1; +} + +onBeforeUnmount(() => { + ndvStore.expressionOutputItemIndex = 0; +}); @@ -25,7 +24,6 @@ import { tabKeyMap, } from '@/plugins/codemirror/keymap'; import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang'; -import { useNDVStore } from '@/stores/ndv.store'; import { ifNotIn } from '@codemirror/autocomplete'; import { history, toggleComment } from '@codemirror/commands'; import { LanguageSupport, bracketMatching, foldGutter, indentOnInput } from '@codemirror/language'; @@ -143,11 +141,6 @@ const { skipSegments: ['Statement', 'CompositeIdentifier', 'Parens', 'Brackets'], isReadOnly: props.isReadOnly, }); -const ndvStore = useNDVStore(); - -const hoveringItemNumber = computed(() => { - return ndvStore.hoveringItemNumber; -}); watch( () => props.modelValue, diff --git a/packages/editor-ui/src/components/__tests__/ExpressionParameterInput.test.ts b/packages/editor-ui/src/components/__tests__/ExpressionParameterInput.test.ts index aaad6c9dc5..8943b1bc2b 100644 --- a/packages/editor-ui/src/components/__tests__/ExpressionParameterInput.test.ts +++ b/packages/editor-ui/src/components/__tests__/ExpressionParameterInput.test.ts @@ -2,6 +2,7 @@ import { createComponentRenderer } from '@/__tests__/render'; import ExpressionParameterInput from '@/components/ExpressionParameterInput.vue'; import { type TestingPinia, createTestingPinia } from '@pinia/testing'; import userEvent from '@testing-library/user-event'; +import { waitFor } from '@testing-library/vue'; import { setActivePinia } from 'pinia'; describe('ExpressionParameterInput', () => { @@ -57,10 +58,12 @@ describe('ExpressionParameterInput', () => { }, }); - const editor = container.querySelector('.cm-content') as HTMLDivElement; - expect(editor).toBeInTheDocument(); - expect(editor.getAttribute('contenteditable')).toEqual('false'); - expect(editor.getAttribute('aria-readonly')).toEqual('true'); + await waitFor(() => { + const editor = container.querySelector('.cm-content') as HTMLDivElement; + expect(editor).toBeInTheDocument(); + expect(editor.getAttribute('contenteditable')).toEqual('false'); + expect(editor.getAttribute('aria-readonly')).toEqual('true'); + }); }); }); }); diff --git a/packages/editor-ui/src/components/__tests__/SQLEditor.test.ts b/packages/editor-ui/src/components/__tests__/SQLEditor.test.ts index 6de5647cd4..234a7acc1b 100644 --- a/packages/editor-ui/src/components/__tests__/SQLEditor.test.ts +++ b/packages/editor-ui/src/components/__tests__/SQLEditor.test.ts @@ -6,6 +6,7 @@ import { createTestingPinia } from '@pinia/testing'; import SqlEditor from '@/components/SqlEditor/SqlEditor.vue'; import { renderComponent } from '@/__tests__/render'; import { waitFor } from '@testing-library/vue'; +import { userEvent } from '@testing-library/user-event'; import { setActivePinia } from 'pinia'; import { useRouter } from 'vue-router'; import { useWorkflowsStore } from '@/stores/workflows.store'; @@ -20,6 +21,10 @@ const DEFAULT_SETUP = { }, }; +async function focusEditor(container: Element) { + await waitFor(() => expect(container.querySelector('.cm-line')).toBeInTheDocument()); + await userEvent.click(container.querySelector('.cm-line') as Element); +} const nodes = [ { id: '1', @@ -70,7 +75,7 @@ describe('SqlEditor.vue', () => { }); it('renders basic query', async () => { - const { getByTestId } = renderComponent(SqlEditor, { + const { getByTestId, container } = renderComponent(SqlEditor, { ...DEFAULT_SETUP, props: { ...DEFAULT_SETUP.props, @@ -78,6 +83,7 @@ describe('SqlEditor.vue', () => { }, }); + await focusEditor(container); await waitFor(() => expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent('SELECT * FROM users'), ); @@ -85,7 +91,7 @@ describe('SqlEditor.vue', () => { it('renders basic query with expression', async () => { mockResolveExpression().mockReturnValueOnce('users'); - const { getByTestId } = renderComponent(SqlEditor, { + const { getByTestId, container } = renderComponent(SqlEditor, { ...DEFAULT_SETUP, props: { ...DEFAULT_SETUP.props, @@ -93,6 +99,7 @@ describe('SqlEditor.vue', () => { }, }); + await focusEditor(container); await waitFor(() => expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent('SELECT * FROM users'), ); @@ -100,13 +107,15 @@ describe('SqlEditor.vue', () => { it('renders resolved expressions with dot between resolvables', async () => { mockResolveExpression().mockReturnValueOnce('public.users'); - const { getByTestId } = renderComponent(SqlEditor, { + const { getByTestId, container } = renderComponent(SqlEditor, { ...DEFAULT_SETUP, props: { ...DEFAULT_SETUP.props, modelValue: 'SELECT * FROM {{ $json.schema }}.{{ $json.table }}', }, }); + + await focusEditor(container); await waitFor(() => expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent( 'SELECT * FROM public.users', @@ -120,7 +129,7 @@ describe('SqlEditor.vue', () => { .mockReturnValueOnce('users') .mockReturnValueOnce('id') .mockReturnValueOnce(0); - const { getByTestId } = renderComponent(SqlEditor, { + const { getByTestId, container } = renderComponent(SqlEditor, { ...DEFAULT_SETUP, props: { ...DEFAULT_SETUP.props, @@ -128,6 +137,8 @@ describe('SqlEditor.vue', () => { 'SELECT * FROM {{ $json.schema }}.{{ $json.table }} WHERE {{ $json.id }} > {{ $json.limit - 10 }}', }, }); + + await focusEditor(container); await waitFor(() => expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent( 'SELECT * FROM public.users WHERE id > 0', @@ -141,7 +152,7 @@ describe('SqlEditor.vue', () => { .mockReturnValueOnce('users') .mockReturnValueOnce(0) .mockReturnValueOnce(false); - const { getByTestId } = renderComponent(SqlEditor, { + const { getByTestId, container } = renderComponent(SqlEditor, { ...DEFAULT_SETUP, props: { ...DEFAULT_SETUP.props, @@ -149,6 +160,8 @@ describe('SqlEditor.vue', () => { 'SELECT * FROM {{ $json.schema }}.{{ $json.table }}\n WHERE id > {{ $json.limit - 10 }}\n AND active = {{ $json.active }};', }, }); + + await focusEditor(container); await waitFor(() => expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent( 'SELECT * FROM public.users WHERE id > 0 AND active = false;', diff --git a/packages/editor-ui/src/composables/useExpressionEditor.ts b/packages/editor-ui/src/composables/useExpressionEditor.ts index a4a423f512..f28b687b06 100644 --- a/packages/editor-ui/src/composables/useExpressionEditor.ts +++ b/packages/editor-ui/src/composables/useExpressionEditor.ts @@ -105,7 +105,7 @@ export const useExpressionEditor = ({ const { from, to, text, token } = segment; if (token === 'Resolvable') { - const { resolved, error, fullError } = resolve(text, hoveringItem.value); + const { resolved, error, fullError } = resolve(text, targetItem.value); acc.push({ kind: 'resolvable', from, @@ -253,7 +253,7 @@ export const useExpressionEditor = ({ return end !== undefined && expressionExtensionNames.value.has(end); } - function resolve(resolvable: string, hoverItem: TargetItem | null) { + function resolve(resolvable: string, target: TargetItem | null) { const result: { resolved: unknown; error: boolean; fullError: Error | null } = { resolved: undefined, error: false, @@ -268,7 +268,7 @@ export const useExpressionEditor = ({ let opts; if (ndvStore.isInputParentOfActiveNode) { opts = { - targetItem: hoverItem ?? undefined, + targetItem: target ?? undefined, inputNodeName: ndvStore.ndvInputNodeName, inputRunIndex: ndvStore.ndvInputRunIndex, inputBranchIndex: ndvStore.ndvInputBranchIndex, @@ -306,8 +306,21 @@ export const useExpressionEditor = ({ return result; } - const hoveringItem = computed(() => { - return ndvStore.hoveringItem; + const targetItem = computed(() => { + if (ndvStore.hoveringItem) { + return ndvStore.hoveringItem; + } + + if (ndvStore.expressionOutputItemIndex && ndvStore.ndvInputNodeName) { + return { + nodeName: ndvStore.ndvInputNodeName, + runIndex: ndvStore.ndvInputRunIndex ?? 0, + outputIndex: ndvStore.ndvInputBranchIndex ?? 0, + itemIndex: ndvStore.expressionOutputItemIndex, + }; + } + + return null; }); const resolvableSegments = computed(() => { @@ -372,14 +385,12 @@ export const useExpressionEditor = ({ }); watch( - [ - () => workflowsStore.getWorkflowExecution, - () => workflowsStore.getWorkflowRunData, - () => ndvStore.hoveringItemNumber, - ], + [() => workflowsStore.getWorkflowExecution, () => workflowsStore.getWorkflowRunData], debouncedUpdateSegments, ); + watch(targetItem, updateSegments); + watch(resolvableSegments, updateHighlighting); function setCursorPosition(pos: number | 'lastExpression' | 'end'): void { diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index 8a07115afa..aedd7743ab 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -401,6 +401,7 @@ export const LOCAL_STORAGE_PIN_DATA_DISCOVERY_NDV_FLAG = 'N8N_PIN_DATA_DISCOVERY export const LOCAL_STORAGE_PIN_DATA_DISCOVERY_CANVAS_FLAG = 'N8N_PIN_DATA_DISCOVERY_CANVAS'; export const LOCAL_STORAGE_MAPPING_IS_ONBOARDED = 'N8N_MAPPING_ONBOARDED'; export const LOCAL_STORAGE_AUTOCOMPLETE_IS_ONBOARDED = 'N8N_AUTOCOMPLETE_ONBOARDED'; +export const LOCAL_STORAGE_TABLE_HOVER_IS_ONBOARDED = 'N8N_TABLE_HOVER_ONBOARDED'; export const LOCAL_STORAGE_MAIN_PANEL_RELATIVE_WIDTH = 'N8N_MAIN_PANEL_RELATIVE_WIDTH'; export const LOCAL_STORAGE_ACTIVE_MODAL = 'N8N_ACTIVE_MODAL'; export const LOCAL_STORAGE_THEME = 'N8N_THEME'; diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index b14818fdb4..548db56c76 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -1244,7 +1244,9 @@ "parameterInput.inputField": "input field", "parameterInput.dragTipAfterPill": "from the left to use it here.", "parameterInput.learnMore": "Learn more", - "parameterInput.resultForItem": "Result for Item", + "parameterInput.result": "Result", + "parameterInput.item": "Item", + "parameterInput.hoverTableItemTip": "You can also do this by hovering over input/output items in the table view", "parameterInput.emptyString": "[empty]", "parameterInput.customApiCall": "Custom API Call", "parameterInput.error": "ERROR", diff --git a/packages/editor-ui/src/stores/ndv.store.ts b/packages/editor-ui/src/stores/ndv.store.ts index b2e7e315b3..1073cc44ff 100644 --- a/packages/editor-ui/src/stores/ndv.store.ts +++ b/packages/editor-ui/src/stores/ndv.store.ts @@ -10,6 +10,7 @@ import { useStorage } from '@/composables/useStorage'; import { LOCAL_STORAGE_AUTOCOMPLETE_IS_ONBOARDED, LOCAL_STORAGE_MAPPING_IS_ONBOARDED, + LOCAL_STORAGE_TABLE_HOVER_IS_ONBOARDED, STORES, } from '@/constants'; import type { INodeExecutionData, INodeIssues } from 'n8n-workflow'; @@ -47,6 +48,7 @@ export const useNDVStore = defineStore(STORES.NDV, { focusedInputPath: '', mappingTelemetry: {}, hoveringItem: null, + expressionOutputItemIndex: 0, draggable: { isDragging: false, type: '', @@ -55,6 +57,7 @@ export const useNDVStore = defineStore(STORES.NDV, { activeTarget: null, }, isMappingOnboarded: useStorage(LOCAL_STORAGE_MAPPING_IS_ONBOARDED).value === 'true', + isTableHoverOnboarded: useStorage(LOCAL_STORAGE_TABLE_HOVER_IS_ONBOARDED).value === 'true', isAutocompleteOnboarded: useStorage(LOCAL_STORAGE_AUTOCOMPLETE_IS_ONBOARDED).value === 'true', highlightDraggables: false, }), @@ -79,14 +82,20 @@ export const useNDVStore = defineStore(STORES.NDV, { return []; } - return executionData.data?.resultData?.runData?.[inputNodeName]?.[inputRunIndex]?.data - ?.main?.[inputBranchIndex]; + return ( + executionData.data?.resultData?.runData?.[inputNodeName]?.[inputRunIndex]?.data?.main?.[ + inputBranchIndex + ] ?? [] + ); + }, + ndvInputDataWithPinnedData(): INodeExecutionData[] { + const data = this.ndvInputData; + return this.ndvInputNodeName + ? useWorkflowsStore().pinDataByNodeName(this.ndvInputNodeName) ?? data + : data; }, hasInputData(): boolean { - const data = this.ndvInputData; - const pinData = - this.ndvInputNodeName && useWorkflowsStore().pinDataByNodeName(this.ndvInputNodeName); - return !!(data && data.length > 0) || !!(pinData && pinData.length > 0); + return this.ndvInputDataWithPinnedData.length > 0; }, getPanelDisplayMode() { return (panel: NodePanelType) => this[panel].displayMode; @@ -237,6 +246,7 @@ export const useNDVStore = defineStore(STORES.NDV, { this.mappingTelemetry = {}; }, setHoveringItem(item: null | NDVState['hoveringItem']): void { + if (item) this.setTableHoverOnboarded(); this.hoveringItem = item; }, setNDVBranchIndex(e: { pane: 'input' | 'output'; branchIndex: number }): void { @@ -249,6 +259,10 @@ export const useNDVStore = defineStore(STORES.NDV, { this.isMappingOnboarded = true; useStorage(LOCAL_STORAGE_MAPPING_IS_ONBOARDED).value = 'true'; }, + setTableHoverOnboarded() { + this.isTableHoverOnboarded = true; + useStorage(LOCAL_STORAGE_TABLE_HOVER_IS_ONBOARDED).value = 'true'; + }, setAutocompleteOnboarded() { this.isAutocompleteOnboarded = true; useStorage(LOCAL_STORAGE_AUTOCOMPLETE_IS_ONBOARDED).value = 'true';