feat(editor): Add item selector to expression output (#9281)

This commit is contained in:
Elias Meire 2024-05-09 14:45:31 +02:00 committed by GitHub
parent 1c1e4443f4
commit dc5994b185
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 313 additions and 98 deletions

View file

@ -75,7 +75,7 @@ describe('Resource Locator', () => {
ndv.actions.setInvalidExpression({ fieldName: 'fieldId' }); 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(); ndv.getters.resourceLocatorInput('rlc').click();

View file

@ -325,7 +325,7 @@ describe('NDV', () => {
ndv.actions.setInvalidExpression({ fieldName: 'fieldId', delay: 200 }); 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(); ndv.getters.parameterInput('remoteOptions').click();
@ -712,4 +712,31 @@ describe('NDV', () => {
workflowPage.getters.successToast().should('exist'); 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');
});
}); });

View file

@ -40,6 +40,12 @@ export class NDV extends BasePage {
this.getters.inputTableRow(row).find('td').eq(col), this.getters.inputTableRow(row).find('td').eq(col),
inlineExpressionEditorInput: () => cy.getByTestId('inline-expression-editor-input'), inlineExpressionEditorInput: () => cy.getByTestId('inline-expression-editor-input'),
inlineExpressionEditorOutput: () => cy.getByTestId('inline-expression-editor-output'), 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'), nodeParameters: () => cy.getByTestId('node-parameters'),
parameterInput: (parameterName: string) => cy.getByTestId(`parameter-input-${parameterName}`), parameterInput: (parameterName: string) => cy.getByTestId(`parameter-input-${parameterName}`),
parameterInputIssues: (parameterName: string) => parameterInputIssues: (parameterName: string) =>
@ -290,6 +296,15 @@ export class NDV extends BasePage {
.click({ force: true }); .click({ force: true });
this.getters.parameterInput('operation').find('input').should('have.value', operation); 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();
},
}; };
} }

View file

@ -95,3 +95,24 @@ Sizes.args = {
placeholder: 'placeholder...', placeholder: 'placeholder...',
controls: false, controls: false,
}; };
const ControlsTemplate: StoryFn = (args, { argTypes }) => ({
setup: () => ({ args }),
props: Object.keys(argTypes),
components: {
N8nInputNumber,
},
template:
'<div> <n8n-input-number style="margin-bottom:10px" v-bind="args" v-model="val" @update:modelValue="onUpdateModelValue" /> <n8n-input-number style="margin-bottom:10px" v-bind="args" size="medium" v-model="val" @update:modelValue="onUpdateModelValue" /> <n8n-input-number style="margin-bottom:10px" v-bind="args" size="small" v-model="val" @update:modelValue="onUpdateModelValue" /> <n8n-input-number style="margin-bottom:10px" v-bind="args" v-model="val" size="mini" @update:modelValue="onUpdateModelValue" /> <n8n-input-number controls-position="right" style="margin-bottom:10px" v-bind="args" v-model="val" @update:modelValue="onUpdateModelValue" /> <n8n-input-number controls-position="right" style="margin-bottom:10px" v-bind="args" size="medium" v-model="val" @update:modelValue="onUpdateModelValue" /> <n8n-input-number controls-position="right" style="margin-bottom:10px" v-bind="args" size="small" v-model="val" @update:modelValue="onUpdateModelValue" /> <n8n-input-number controls-position="right" style="margin-bottom:10px" v-bind="args" v-model="val" size="mini" @update:modelValue="onUpdateModelValue" /> </div>',
methods,
data() {
return {
val: '',
};
},
});
export const Controls = ControlsTemplate.bind({});
Controls.args = {
placeholder: 'placeholder...',
};

View file

@ -1,16 +1,16 @@
<script lang="ts"> <script setup lang="ts">
import type { InputSize } from '@/types';
import { ElInputNumber } from 'element-plus'; import { ElInputNumber } from 'element-plus';
import { defineComponent } from 'vue';
export default defineComponent({ type InputNumberProps = {
name: 'N8nInputNumber', size?: InputSize;
components: { min?: number;
ElInputNumber, max?: number;
}, step?: number;
props: { precision?: number;
...ElInputNumber.props, };
},
}); defineProps<InputNumberProps>();
</script> </script>
<template> <template>

View file

@ -88,6 +88,10 @@ const classes = computed(() => {
color: var(--color-primary); color: var(--color-primary);
} }
.secondary {
color: var(--color-secondary);
}
.text-dark { .text-dark {
color: var(--color-text-dark); color: var(--color-text-dark);
} }

View file

@ -466,6 +466,7 @@ $input-small-height: 30px !default;
$input-mini-font-size: 12px; $input-mini-font-size: 12px;
/// height||Other|4 /// height||Other|4
$input-mini-height: 26px !default; $input-mini-height: 26px !default;
$input-number-control-border-radius: 3px;
/* Cascader /* Cascader
-------------------------- */ -------------------------- */

View file

@ -32,12 +32,16 @@
} }
@include mixins.e((increase, decrease)) { @include mixins.e((increase, decrease)) {
--disabled-color: var(--color-text-light);
position: absolute; position: absolute;
z-index: 1; z-index: 1;
top: 1px; top: 1px;
bottom: 1px;
display: flex;
align-items: center;
justify-content: center;
width: var.$input-height; width: var.$input-height;
height: auto; height: auto;
text-align: center;
background: var.$background-color-base; background: var.$background-color-base;
color: var(--color-text-dark); color: var(--color-text-dark);
cursor: pointer; cursor: pointer;
@ -59,13 +63,15 @@
@include mixins.e(increase) { @include mixins.e(increase) {
right: 1px; right: 1px;
border-radius: 0 var(--border-radius-base) var(--border-radius-base) 0; border-radius: 0 var.$input-number-control-border-radius var.$input-number-control-border-radius
0;
border-left: var(--border-base); border-left: var(--border-base);
} }
@include mixins.e(decrease) { @include mixins.e(decrease) {
left: 1px; left: 1px;
border-radius: var(--border-radius-base) 0 0 var(--border-radius-base); border-radius: var.$input-number-control-border-radius 0 0
var.$input-number-control-border-radius;
border-right: var(--border-base); border-right: var(--border-base);
} }
@ -82,7 +88,7 @@
} }
@include mixins.m(medium) { @include mixins.m(medium) {
line-height: #{var.$input-medium-height - 2}; line-height: #{var.$input-medium-height - 4};
@include mixins.e((increase, decrease)) { @include mixins.e((increase, decrease)) {
width: var.$input-medium-height; width: var.$input-medium-height;
@ -114,7 +120,7 @@
} }
@include mixins.m(mini) { @include mixins.m(mini) {
line-height: #{var.$input-mini-height - 2}; line-height: #{var.$input-mini-height - 4};
@include mixins.e((increase, decrease)) { @include mixins.e((increase, decrease)) {
width: var.$input-mini-height; width: var.$input-mini-height;
@ -134,20 +140,33 @@
@include mixins.when(without-controls) { @include mixins.when(without-controls) {
.el-input__inner { .el-input__inner {
text-align: left; text-align: left;
padding-left: 12px; padding-left: var(--spacing-2xs);
padding-right: 12px; padding-right: var(--spacing-2xs);
} }
} }
@include mixins.when(controls-right) { @include mixins.when(controls-right) {
.el-input__inner { .el-input__inner {
padding-left: 15px; padding-left: var(--spacing-2xs);
padding-right: #{var.$input-height + 10}; padding-right: #{var.$input-height + 4};
}
&.el-input-number--medium .el-input__inner {
padding-right: #{var.$input-medium-height + 4};
}
&.el-input-number--small .el-input__inner {
padding-right: #{var.$input-small-height + 4};
}
&.el-input-number--mini .el-input__inner {
padding-left: var(--spacing-4xs);
padding-right: #{var.$input-mini-height + 4};
} }
@include mixins.e((increase, decrease)) { @include mixins.e((increase, decrease)) {
height: auto; height: calc((100% - 1px) / 2);
line-height: #{(var.$input-height - 2) * 0.5}; bottom: auto;
[class*='el-icon'] { [class*='el-icon'] {
transform: scale(0.8); transform: scale(0.8);
@ -155,7 +174,7 @@
} }
@include mixins.e(increase) { @include mixins.e(increase) {
border-radius: 0 var(--border-radius-base) 0 0; border-radius: 0 var.$input-number-control-border-radius 0 0;
border-bottom: var(--border-base); border-bottom: var(--border-base);
} }
@ -166,28 +185,7 @@
left: auto; left: auto;
border-right: none; border-right: none;
border-left: var(--border-base); border-left: var(--border-base);
border-radius: 0 0 var(--border-radius-base) 0; border-radius: 0 0 var.$input-number-control-border-radius 0;
}
&[class*='medium'] {
[class*='increase'],
[class*='decrease'] {
line-height: #{(var.$input-medium-height - 2) * 0.5};
}
}
&[class*='small'] {
[class*='increase'],
[class*='decrease'] {
line-height: #{(var.$input-small-height - 2) * 0.5};
}
}
&[class*='mini'] {
[class*='increase'],
[class*='decrease'] {
line-height: #{(var.$input-mini-height - 2) * 0.5};
}
} }
} }
} }

View file

@ -1253,6 +1253,7 @@ export interface NDVState {
focusedInputPath: string; focusedInputPath: string;
mappingTelemetry: { [key: string]: string | number | boolean }; mappingTelemetry: { [key: string]: string | number | boolean };
hoveringItem: null | TargetItem; hoveringItem: null | TargetItem;
expressionOutputItemIndex: number;
draggable: { draggable: {
isDragging: boolean; isDragging: boolean;
type: string; type: string;
@ -1261,6 +1262,7 @@ export interface NDVState {
activeTarget: { id: string; stickyPosition: null | XYPosition } | null; activeTarget: { id: string; stickyPosition: null | XYPosition } | null;
}; };
isMappingOnboarded: boolean; isMappingOnboarded: boolean;
isTableHoverOnboarded: boolean;
isAutocompleteOnboarded: boolean; isAutocompleteOnboarded: boolean;
highlightDraggables: boolean; highlightDraggables: boolean;
} }

View file

@ -48,9 +48,7 @@ const telemetry = useTelemetry();
const ndvStore = useNDVStore(); const ndvStore = useNDVStore();
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const hoveringItemNumber = computed(() => ndvStore.hoveringItemNumber);
const isDragging = computed(() => ndvStore.isDraggableDragging); const isDragging = computed(() => ndvStore.isDraggableDragging);
const noInputData = computed(() => ndvStore.hasInputData);
function focus() { function focus() {
if (inlineInput.value) { if (inlineInput.value) {
@ -166,9 +164,7 @@ defineExpose({ focus });
:editor-state="editorState" :editor-state="editorState"
:segments="segments" :segments="segments"
:is-read-only="isReadOnly" :is-read-only="isReadOnly"
:no-input-data="noInputData"
:visible="isFocused" :visible="isFocused"
:hovering-item-number="hoveringItemNumber"
/> />
</div> </div>
</template> </template>

View file

@ -103,6 +103,9 @@ onMounted(() => {
], ],
}), }),
}); });
highlighter.addColor(editor.value as EditorView, resolvedSegments.value);
highlighter.removeColor(editor.value as EditorView, plaintextSegments.value);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {

View file

@ -6,20 +6,20 @@ import type { Segment } from '@/types/expressions';
import ExpressionOutput from './ExpressionOutput.vue'; import ExpressionOutput from './ExpressionOutput.vue';
import InlineExpressionTip from './InlineExpressionTip.vue'; import InlineExpressionTip from './InlineExpressionTip.vue';
import { outputTheme } from './theme'; import { outputTheme } from './theme';
import { computed, onBeforeUnmount } from 'vue';
import { useNDVStore } from '@/stores/ndv.store';
import { N8nTooltip } from 'n8n-design-system/components';
interface InlineExpressionEditorOutputProps { interface InlineExpressionEditorOutputProps {
segments: Segment[]; segments: Segment[];
hoveringItemNumber: number;
unresolvedExpression?: string; unresolvedExpression?: string;
editorState?: EditorState; editorState?: EditorState;
selection?: SelectionRange; selection?: SelectionRange;
visible?: boolean; visible?: boolean;
noInputData?: boolean;
} }
withDefaults(defineProps<InlineExpressionEditorOutputProps>(), { withDefaults(defineProps<InlineExpressionEditorOutputProps>(), {
visible: false, visible: false,
noInputData: false,
editorState: undefined, editorState: undefined,
selection: undefined, selection: undefined,
unresolvedExpression: undefined, unresolvedExpression: undefined,
@ -27,19 +27,95 @@ withDefaults(defineProps<InlineExpressionEditorOutputProps>(), {
const i18n = useI18n(); const i18n = useI18n();
const theme = outputTheme(); 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;
});
</script> </script>
<template> <template>
<div :class="visible ? $style.dropdown : $style.hidden"> <div v-if="visible" :class="$style.dropdown" title="">
<n8n-text v-if="!noInputData" size="small" compact :class="$style.header"> <div :class="$style.header">
{{ i18n.baseText('parameterInput.resultForItem') }} {{ hoveringItemNumber }} <n8n-text bold size="small" compact>
{{ i18n.baseText('parameterInput.result') }}
</n8n-text> </n8n-text>
<div :class="$style.item">
<n8n-text size="small" color="text-base" compact>
{{ i18n.baseText('parameterInput.item') }}
</n8n-text>
<div :class="$style.controls">
<N8nInputNumber
data-test-id="inline-expression-editor-item-input"
size="mini"
:controls="false"
:class="[$style.input, { [$style.hovering]: isHoveringItem }]"
:min="0"
:max="max"
:model-value="itemIndex"
@update:model-value="updateItemIndex"
></N8nInputNumber>
<N8nIconButton
data-test-id="inline-expression-editor-item-prev"
icon="chevron-left"
type="tertiary"
text
size="mini"
:disabled="!canSelectPrevItem"
@click="prevItem"
></N8nIconButton>
<N8nTooltip placement="right" :disabled="hideTableHoverHint">
<template #content>
<div>{{ i18n.baseText('parameterInput.hoverTableItemTip') }}</div>
</template>
<N8nIconButton
data-test-id="inline-expression-editor-item-next"
icon="chevron-right"
type="tertiary"
text
size="mini"
:disabled="!canSelectNextItem"
@click="nextItem"
></N8nIconButton>
</N8nTooltip>
</div>
</div>
</div>
<n8n-text :class="$style.body"> <n8n-text :class="$style.body">
<ExpressionOutput <ExpressionOutput
data-test-id="inline-expression-editor-output" data-test-id="inline-expression-editor-output"
:segments="segments" :segments="segments"
:extensions="theme" :extensions="theme"
></ExpressionOutput> >
</ExpressionOutput>
</n8n-text> </n8n-text>
<div :class="$style.footer"> <div :class="$style.footer">
<InlineExpressionTip <InlineExpressionTip
@ -52,10 +128,6 @@ const theme = outputTheme();
</template> </template>
<style lang="scss" module> <style lang="scss" module>
.hidden {
display: none;
}
.dropdown { .dropdown {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -73,7 +145,6 @@ const theme = outputTheme();
background-color: var(--color-code-background); background-color: var(--color-code-background);
} }
.header,
.body { .body {
padding: var(--spacing-3xs); padding: var(--spacing-3xs);
} }
@ -83,12 +154,22 @@ const theme = outputTheme();
} }
.header { .header {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--spacing-2xs);
color: var(--color-text-dark); color: var(--color-text-dark);
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
padding-left: var(--spacing-2xs); padding: 0 var(--spacing-2xs);
padding-top: var(--spacing-2xs); padding-top: var(--spacing-2xs);
} }
.item {
display: flex;
align-items: center;
gap: var(--spacing-4xs);
}
.body { .body {
padding-top: 0; padding-top: 0;
padding-left: var(--spacing-2xs); padding-left: var(--spacing-2xs);
@ -98,5 +179,33 @@ const theme = outputTheme();
padding-top: var(--spacing-2xs); padding-top: var(--spacing-2xs);
} }
} }
.controls {
display: flex;
align-items: center;
}
.input {
--input-height: 22px;
--input-width: 26px;
--input-border-top-left-radius: var(--border-radius-base);
--input-border-bottom-left-radius: var(--border-radius-base);
--input-border-top-right-radius: var(--border-radius-base);
--input-border-bottom-right-radius: var(--border-radius-base);
max-width: var(--input-width);
line-height: calc(var(--input-height) - var(--spacing-4xs));
&.hovering {
--input-font-color: var(--color-secondary);
}
:global(.el-input__inner) {
height: var(--input-height);
min-height: var(--input-height);
line-height: var(--input-height);
text-align: center;
padding: 0 var(--spacing-4xs);
}
}
} }
</style> </style>

View file

@ -8,6 +8,7 @@ describe('InlineExpressionEditorOutput.vue', () => {
pinia: createTestingPinia(), pinia: createTestingPinia(),
props: { props: {
hoveringItemNumber: 0, hoveringItemNumber: 0,
visible: true,
segments: [ segments: [
{ {
from: 0, from: 0,
@ -56,6 +57,7 @@ describe('InlineExpressionEditorOutput.vue', () => {
pinia: createTestingPinia(), pinia: createTestingPinia(),
props: { props: {
hoveringItemNumber: 0, hoveringItemNumber: 0,
visible: true,
segments: [ segments: [
{ {
kind: 'plaintext', kind: 'plaintext',

View file

@ -7,7 +7,6 @@
:segments="segments" :segments="segments"
:is-read-only="isReadOnly" :is-read-only="isReadOnly"
:visible="hasFocus" :visible="hasFocus"
:hovering-item-number="hoveringItemNumber"
/> />
</div> </div>
</template> </template>
@ -25,7 +24,6 @@ import {
tabKeyMap, tabKeyMap,
} from '@/plugins/codemirror/keymap'; } from '@/plugins/codemirror/keymap';
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang'; import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
import { useNDVStore } from '@/stores/ndv.store';
import { ifNotIn } from '@codemirror/autocomplete'; import { ifNotIn } from '@codemirror/autocomplete';
import { history, toggleComment } from '@codemirror/commands'; import { history, toggleComment } from '@codemirror/commands';
import { LanguageSupport, bracketMatching, foldGutter, indentOnInput } from '@codemirror/language'; import { LanguageSupport, bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
@ -143,11 +141,6 @@ const {
skipSegments: ['Statement', 'CompositeIdentifier', 'Parens', 'Brackets'], skipSegments: ['Statement', 'CompositeIdentifier', 'Parens', 'Brackets'],
isReadOnly: props.isReadOnly, isReadOnly: props.isReadOnly,
}); });
const ndvStore = useNDVStore();
const hoveringItemNumber = computed(() => {
return ndvStore.hoveringItemNumber;
});
watch( watch(
() => props.modelValue, () => props.modelValue,

View file

@ -2,6 +2,7 @@ import { createComponentRenderer } from '@/__tests__/render';
import ExpressionParameterInput from '@/components/ExpressionParameterInput.vue'; import ExpressionParameterInput from '@/components/ExpressionParameterInput.vue';
import { type TestingPinia, createTestingPinia } from '@pinia/testing'; import { type TestingPinia, createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/vue';
import { setActivePinia } from 'pinia'; import { setActivePinia } from 'pinia';
describe('ExpressionParameterInput', () => { describe('ExpressionParameterInput', () => {
@ -57,6 +58,7 @@ describe('ExpressionParameterInput', () => {
}, },
}); });
await waitFor(() => {
const editor = container.querySelector('.cm-content') as HTMLDivElement; const editor = container.querySelector('.cm-content') as HTMLDivElement;
expect(editor).toBeInTheDocument(); expect(editor).toBeInTheDocument();
expect(editor.getAttribute('contenteditable')).toEqual('false'); expect(editor.getAttribute('contenteditable')).toEqual('false');
@ -64,3 +66,4 @@ describe('ExpressionParameterInput', () => {
}); });
}); });
}); });
});

View file

@ -6,6 +6,7 @@ import { createTestingPinia } from '@pinia/testing';
import SqlEditor from '@/components/SqlEditor/SqlEditor.vue'; import SqlEditor from '@/components/SqlEditor/SqlEditor.vue';
import { renderComponent } from '@/__tests__/render'; import { renderComponent } from '@/__tests__/render';
import { waitFor } from '@testing-library/vue'; import { waitFor } from '@testing-library/vue';
import { userEvent } from '@testing-library/user-event';
import { setActivePinia } from 'pinia'; import { setActivePinia } from 'pinia';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useWorkflowsStore } from '@/stores/workflows.store'; 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 = [ const nodes = [
{ {
id: '1', id: '1',
@ -70,7 +75,7 @@ describe('SqlEditor.vue', () => {
}); });
it('renders basic query', async () => { it('renders basic query', async () => {
const { getByTestId } = renderComponent(SqlEditor, { const { getByTestId, container } = renderComponent(SqlEditor, {
...DEFAULT_SETUP, ...DEFAULT_SETUP,
props: { props: {
...DEFAULT_SETUP.props, ...DEFAULT_SETUP.props,
@ -78,6 +83,7 @@ describe('SqlEditor.vue', () => {
}, },
}); });
await focusEditor(container);
await waitFor(() => await waitFor(() =>
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent('SELECT * FROM users'), expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent('SELECT * FROM users'),
); );
@ -85,7 +91,7 @@ describe('SqlEditor.vue', () => {
it('renders basic query with expression', async () => { it('renders basic query with expression', async () => {
mockResolveExpression().mockReturnValueOnce('users'); mockResolveExpression().mockReturnValueOnce('users');
const { getByTestId } = renderComponent(SqlEditor, { const { getByTestId, container } = renderComponent(SqlEditor, {
...DEFAULT_SETUP, ...DEFAULT_SETUP,
props: { props: {
...DEFAULT_SETUP.props, ...DEFAULT_SETUP.props,
@ -93,6 +99,7 @@ describe('SqlEditor.vue', () => {
}, },
}); });
await focusEditor(container);
await waitFor(() => await waitFor(() =>
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent('SELECT * FROM users'), 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 () => { it('renders resolved expressions with dot between resolvables', async () => {
mockResolveExpression().mockReturnValueOnce('public.users'); mockResolveExpression().mockReturnValueOnce('public.users');
const { getByTestId } = renderComponent(SqlEditor, { const { getByTestId, container } = renderComponent(SqlEditor, {
...DEFAULT_SETUP, ...DEFAULT_SETUP,
props: { props: {
...DEFAULT_SETUP.props, ...DEFAULT_SETUP.props,
modelValue: 'SELECT * FROM {{ $json.schema }}.{{ $json.table }}', modelValue: 'SELECT * FROM {{ $json.schema }}.{{ $json.table }}',
}, },
}); });
await focusEditor(container);
await waitFor(() => await waitFor(() =>
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent( expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent(
'SELECT * FROM public.users', 'SELECT * FROM public.users',
@ -120,7 +129,7 @@ describe('SqlEditor.vue', () => {
.mockReturnValueOnce('users') .mockReturnValueOnce('users')
.mockReturnValueOnce('id') .mockReturnValueOnce('id')
.mockReturnValueOnce(0); .mockReturnValueOnce(0);
const { getByTestId } = renderComponent(SqlEditor, { const { getByTestId, container } = renderComponent(SqlEditor, {
...DEFAULT_SETUP, ...DEFAULT_SETUP,
props: { props: {
...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 }}', 'SELECT * FROM {{ $json.schema }}.{{ $json.table }} WHERE {{ $json.id }} > {{ $json.limit - 10 }}',
}, },
}); });
await focusEditor(container);
await waitFor(() => await waitFor(() =>
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent( expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent(
'SELECT * FROM public.users WHERE id > 0', 'SELECT * FROM public.users WHERE id > 0',
@ -141,7 +152,7 @@ describe('SqlEditor.vue', () => {
.mockReturnValueOnce('users') .mockReturnValueOnce('users')
.mockReturnValueOnce(0) .mockReturnValueOnce(0)
.mockReturnValueOnce(false); .mockReturnValueOnce(false);
const { getByTestId } = renderComponent(SqlEditor, { const { getByTestId, container } = renderComponent(SqlEditor, {
...DEFAULT_SETUP, ...DEFAULT_SETUP,
props: { props: {
...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 }};', 'SELECT * FROM {{ $json.schema }}.{{ $json.table }}\n WHERE id > {{ $json.limit - 10 }}\n AND active = {{ $json.active }};',
}, },
}); });
await focusEditor(container);
await waitFor(() => await waitFor(() =>
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent( expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent(
'SELECT * FROM public.users WHERE id > 0 AND active = false;', 'SELECT * FROM public.users WHERE id > 0 AND active = false;',

View file

@ -105,7 +105,7 @@ export const useExpressionEditor = ({
const { from, to, text, token } = segment; const { from, to, text, token } = segment;
if (token === 'Resolvable') { if (token === 'Resolvable') {
const { resolved, error, fullError } = resolve(text, hoveringItem.value); const { resolved, error, fullError } = resolve(text, targetItem.value);
acc.push({ acc.push({
kind: 'resolvable', kind: 'resolvable',
from, from,
@ -253,7 +253,7 @@ export const useExpressionEditor = ({
return end !== undefined && expressionExtensionNames.value.has(end); 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 } = { const result: { resolved: unknown; error: boolean; fullError: Error | null } = {
resolved: undefined, resolved: undefined,
error: false, error: false,
@ -268,7 +268,7 @@ export const useExpressionEditor = ({
let opts; let opts;
if (ndvStore.isInputParentOfActiveNode) { if (ndvStore.isInputParentOfActiveNode) {
opts = { opts = {
targetItem: hoverItem ?? undefined, targetItem: target ?? undefined,
inputNodeName: ndvStore.ndvInputNodeName, inputNodeName: ndvStore.ndvInputNodeName,
inputRunIndex: ndvStore.ndvInputRunIndex, inputRunIndex: ndvStore.ndvInputRunIndex,
inputBranchIndex: ndvStore.ndvInputBranchIndex, inputBranchIndex: ndvStore.ndvInputBranchIndex,
@ -306,8 +306,21 @@ export const useExpressionEditor = ({
return result; return result;
} }
const hoveringItem = computed(() => { const targetItem = computed<TargetItem | null>(() => {
if (ndvStore.hoveringItem) {
return 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<Resolvable[]>(() => { const resolvableSegments = computed<Resolvable[]>(() => {
@ -372,14 +385,12 @@ export const useExpressionEditor = ({
}); });
watch( watch(
[ [() => workflowsStore.getWorkflowExecution, () => workflowsStore.getWorkflowRunData],
() => workflowsStore.getWorkflowExecution,
() => workflowsStore.getWorkflowRunData,
() => ndvStore.hoveringItemNumber,
],
debouncedUpdateSegments, debouncedUpdateSegments,
); );
watch(targetItem, updateSegments);
watch(resolvableSegments, updateHighlighting); watch(resolvableSegments, updateHighlighting);
function setCursorPosition(pos: number | 'lastExpression' | 'end'): void { function setCursorPosition(pos: number | 'lastExpression' | 'end'): void {

View file

@ -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_PIN_DATA_DISCOVERY_CANVAS_FLAG = 'N8N_PIN_DATA_DISCOVERY_CANVAS';
export const LOCAL_STORAGE_MAPPING_IS_ONBOARDED = 'N8N_MAPPING_ONBOARDED'; export const LOCAL_STORAGE_MAPPING_IS_ONBOARDED = 'N8N_MAPPING_ONBOARDED';
export const LOCAL_STORAGE_AUTOCOMPLETE_IS_ONBOARDED = 'N8N_AUTOCOMPLETE_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_MAIN_PANEL_RELATIVE_WIDTH = 'N8N_MAIN_PANEL_RELATIVE_WIDTH';
export const LOCAL_STORAGE_ACTIVE_MODAL = 'N8N_ACTIVE_MODAL'; export const LOCAL_STORAGE_ACTIVE_MODAL = 'N8N_ACTIVE_MODAL';
export const LOCAL_STORAGE_THEME = 'N8N_THEME'; export const LOCAL_STORAGE_THEME = 'N8N_THEME';

View file

@ -1244,7 +1244,9 @@
"parameterInput.inputField": "input field", "parameterInput.inputField": "input field",
"parameterInput.dragTipAfterPill": "from the left to use it here.", "parameterInput.dragTipAfterPill": "from the left to use it here.",
"parameterInput.learnMore": "Learn more", "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.emptyString": "[empty]",
"parameterInput.customApiCall": "Custom API Call", "parameterInput.customApiCall": "Custom API Call",
"parameterInput.error": "ERROR", "parameterInput.error": "ERROR",

View file

@ -10,6 +10,7 @@ import { useStorage } from '@/composables/useStorage';
import { import {
LOCAL_STORAGE_AUTOCOMPLETE_IS_ONBOARDED, LOCAL_STORAGE_AUTOCOMPLETE_IS_ONBOARDED,
LOCAL_STORAGE_MAPPING_IS_ONBOARDED, LOCAL_STORAGE_MAPPING_IS_ONBOARDED,
LOCAL_STORAGE_TABLE_HOVER_IS_ONBOARDED,
STORES, STORES,
} from '@/constants'; } from '@/constants';
import type { INodeExecutionData, INodeIssues } from 'n8n-workflow'; import type { INodeExecutionData, INodeIssues } from 'n8n-workflow';
@ -47,6 +48,7 @@ export const useNDVStore = defineStore(STORES.NDV, {
focusedInputPath: '', focusedInputPath: '',
mappingTelemetry: {}, mappingTelemetry: {},
hoveringItem: null, hoveringItem: null,
expressionOutputItemIndex: 0,
draggable: { draggable: {
isDragging: false, isDragging: false,
type: '', type: '',
@ -55,6 +57,7 @@ export const useNDVStore = defineStore(STORES.NDV, {
activeTarget: null, activeTarget: null,
}, },
isMappingOnboarded: useStorage(LOCAL_STORAGE_MAPPING_IS_ONBOARDED).value === 'true', 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', isAutocompleteOnboarded: useStorage(LOCAL_STORAGE_AUTOCOMPLETE_IS_ONBOARDED).value === 'true',
highlightDraggables: false, highlightDraggables: false,
}), }),
@ -79,14 +82,20 @@ export const useNDVStore = defineStore(STORES.NDV, {
return []; return [];
} }
return executionData.data?.resultData?.runData?.[inputNodeName]?.[inputRunIndex]?.data return (
?.main?.[inputBranchIndex]; 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 { hasInputData(): boolean {
const data = this.ndvInputData; return this.ndvInputDataWithPinnedData.length > 0;
const pinData =
this.ndvInputNodeName && useWorkflowsStore().pinDataByNodeName(this.ndvInputNodeName);
return !!(data && data.length > 0) || !!(pinData && pinData.length > 0);
}, },
getPanelDisplayMode() { getPanelDisplayMode() {
return (panel: NodePanelType) => this[panel].displayMode; return (panel: NodePanelType) => this[panel].displayMode;
@ -237,6 +246,7 @@ export const useNDVStore = defineStore(STORES.NDV, {
this.mappingTelemetry = {}; this.mappingTelemetry = {};
}, },
setHoveringItem(item: null | NDVState['hoveringItem']): void { setHoveringItem(item: null | NDVState['hoveringItem']): void {
if (item) this.setTableHoverOnboarded();
this.hoveringItem = item; this.hoveringItem = item;
}, },
setNDVBranchIndex(e: { pane: 'input' | 'output'; branchIndex: number }): void { setNDVBranchIndex(e: { pane: 'input' | 'output'; branchIndex: number }): void {
@ -249,6 +259,10 @@ export const useNDVStore = defineStore(STORES.NDV, {
this.isMappingOnboarded = true; this.isMappingOnboarded = true;
useStorage(LOCAL_STORAGE_MAPPING_IS_ONBOARDED).value = 'true'; useStorage(LOCAL_STORAGE_MAPPING_IS_ONBOARDED).value = 'true';
}, },
setTableHoverOnboarded() {
this.isTableHoverOnboarded = true;
useStorage(LOCAL_STORAGE_TABLE_HOVER_IS_ONBOARDED).value = 'true';
},
setAutocompleteOnboarded() { setAutocompleteOnboarded() {
this.isAutocompleteOnboarded = true; this.isAutocompleteOnboarded = true;
useStorage(LOCAL_STORAGE_AUTOCOMPLETE_IS_ONBOARDED).value = 'true'; useStorage(LOCAL_STORAGE_AUTOCOMPLETE_IS_ONBOARDED).value = 'true';