mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-12 05:17:28 -08:00
feat(editor): Add schema view to expression modal (#9976)
This commit is contained in:
parent
9d7caacc69
commit
71b6c67179
|
@ -44,7 +44,7 @@ describe('Data mapping', () => {
|
||||||
ndv.actions.mapDataFromHeader(2, 'value');
|
ndv.actions.mapDataFromHeader(2, 'value');
|
||||||
ndv.getters
|
ndv.getters
|
||||||
.inlineExpressionEditorInput()
|
.inlineExpressionEditorInput()
|
||||||
.should('have.text', "{{ $json.timestamp }} {{ $json['Readable date'] }}");
|
.should('have.text', "{{ $json['Readable date'] }}{{ $json.timestamp }}");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps expressions from table json, and resolves value based on hover', () => {
|
it('maps expressions from table json, and resolves value based on hover', () => {
|
||||||
|
@ -145,8 +145,8 @@ describe('Data mapping', () => {
|
||||||
ndv.actions.mapToParameter('value');
|
ndv.actions.mapToParameter('value');
|
||||||
ndv.getters
|
ndv.getters
|
||||||
.inlineExpressionEditorInput()
|
.inlineExpressionEditorInput()
|
||||||
.should('have.text', '{{ $json.input[0].count }} {{ $json.input }}');
|
.should('have.text', '{{ $json.input }}{{ $json.input[0].count }}');
|
||||||
ndv.actions.validateExpressionPreview('value', '0 [object Object]');
|
ndv.actions.validateExpressionPreview('value', '[object Object]0');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps expressions from schema view', () => {
|
it('maps expressions from schema view', () => {
|
||||||
|
@ -170,8 +170,8 @@ describe('Data mapping', () => {
|
||||||
ndv.actions.mapToParameter('value');
|
ndv.actions.mapToParameter('value');
|
||||||
ndv.getters
|
ndv.getters
|
||||||
.inlineExpressionEditorInput()
|
.inlineExpressionEditorInput()
|
||||||
.should('have.text', '{{ $json.input[0].count }} {{ $json.input }}');
|
.should('have.text', '{{ $json.input }}{{ $json.input[0].count }}');
|
||||||
ndv.actions.validateExpressionPreview('value', '0 [object Object]');
|
ndv.actions.validateExpressionPreview('value', '[object Object]0');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps expressions from previous nodes', () => {
|
it('maps expressions from previous nodes', () => {
|
||||||
|
@ -200,17 +200,17 @@ describe('Data mapping', () => {
|
||||||
.inlineExpressionEditorInput()
|
.inlineExpressionEditorInput()
|
||||||
.should(
|
.should(
|
||||||
'have.text',
|
'have.text',
|
||||||
`{{ $('${SCHEDULE_TRIGGER_NODE_NAME}').item.json.input[0].count }} {{ $('${SCHEDULE_TRIGGER_NODE_NAME}').item.json.input }}`,
|
`{{ $('${SCHEDULE_TRIGGER_NODE_NAME}').item.json.input }}{{ $('${SCHEDULE_TRIGGER_NODE_NAME}').item.json.input[0].count }}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
ndv.actions.selectInputNode('Set');
|
ndv.actions.selectInputNode('Set');
|
||||||
|
|
||||||
ndv.getters.executingLoader().should('not.exist');
|
ndv.getters.executingLoader().should('not.exist');
|
||||||
ndv.getters.inputDataContainer().should('exist');
|
ndv.getters.inputDataContainer().should('exist');
|
||||||
ndv.actions.validateExpressionPreview('value', '0 [object Object]');
|
ndv.actions.validateExpressionPreview('value', '[object Object]0');
|
||||||
|
|
||||||
ndv.getters.inputTbodyCell(2, 0).realHover();
|
ndv.getters.inputTbodyCell(2, 0).realHover();
|
||||||
ndv.actions.validateExpressionPreview('value', '1 [object Object]');
|
ndv.actions.validateExpressionPreview('value', '[object Object]1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps keys to path', () => {
|
it('maps keys to path', () => {
|
||||||
|
@ -284,8 +284,8 @@ describe('Data mapping', () => {
|
||||||
ndv.actions.mapToParameter('value');
|
ndv.actions.mapToParameter('value');
|
||||||
ndv.getters
|
ndv.getters
|
||||||
.inlineExpressionEditorInput()
|
.inlineExpressionEditorInput()
|
||||||
.should('have.text', '{{ $json.input[0].count }} {{ $json.input }}');
|
.should('have.text', '{{ $json.input }}{{ $json.input[0].count }}');
|
||||||
ndv.actions.validateExpressionPreview('value', '0 [object Object]');
|
ndv.actions.validateExpressionPreview('value', '[object Object]0');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders expression preview when a previous node is selected', () => {
|
it('renders expression preview when a previous node is selected', () => {
|
||||||
|
@ -342,4 +342,27 @@ describe('Data mapping', () => {
|
||||||
.invoke('css', 'border')
|
.invoke('css', 'border')
|
||||||
.should('include', 'dashed rgb(90, 76, 194)');
|
.should('include', 'dashed rgb(90, 76, 194)');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('maps expressions to a specific location in the editor', () => {
|
||||||
|
cy.fixture('Test_workflow_3.json').then((data) => {
|
||||||
|
cy.get('body').paste(JSON.stringify(data));
|
||||||
|
});
|
||||||
|
workflowPage.actions.zoomToFit();
|
||||||
|
|
||||||
|
workflowPage.actions.openNode('Set');
|
||||||
|
ndv.actions.clearParameterInput('value');
|
||||||
|
ndv.actions.typeIntoParameterInput('value', '=');
|
||||||
|
ndv.actions.typeIntoParameterInput('value', 'hello world{enter}{enter}newline');
|
||||||
|
|
||||||
|
ndv.getters.inputDataContainer().should('exist').find('span').contains('count').realMouseDown();
|
||||||
|
|
||||||
|
ndv.actions.mapToParameter('value');
|
||||||
|
|
||||||
|
ndv.getters.inputDataContainer().find('span').contains('input').realMouseDown();
|
||||||
|
ndv.actions.mapToParameter('value', 'bottom');
|
||||||
|
|
||||||
|
ndv.getters
|
||||||
|
.inlineExpressionEditorInput()
|
||||||
|
.should('have.text', '{{ $json.input[0].count }}hello worldnewline{{ $json.input }}');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -205,9 +205,9 @@ export class NDV extends BasePage {
|
||||||
const droppable = `[data-test-id="parameter-input-${parameterName}"]`;
|
const droppable = `[data-test-id="parameter-input-${parameterName}"]`;
|
||||||
cy.draganddrop(draggable, droppable);
|
cy.draganddrop(draggable, droppable);
|
||||||
},
|
},
|
||||||
mapToParameter: (parameterName: string) => {
|
mapToParameter: (parameterName: string, position?: 'top' | 'center' | 'bottom') => {
|
||||||
const droppable = `[data-test-id="parameter-input-${parameterName}"]`;
|
const droppable = `[data-test-id="parameter-input-${parameterName}"]`;
|
||||||
cy.draganddrop('', droppable);
|
cy.draganddrop('', droppable, { position });
|
||||||
},
|
},
|
||||||
switchInputMode: (type: 'Schema' | 'Table' | 'JSON' | 'Binary') => {
|
switchInputMode: (type: 'Schema' | 'Table' | 'JSON' | 'Binary') => {
|
||||||
this.getters.inputDisplayMode().find('label').contains(type).click({ force: true });
|
this.getters.inputDisplayMode().find('label').contains(type).click({ force: true });
|
||||||
|
|
|
@ -175,7 +175,7 @@ Cypress.Commands.add('drag', (selector, pos, options) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Cypress.Commands.add('draganddrop', (draggableSelector, droppableSelector) => {
|
Cypress.Commands.add('draganddrop', (draggableSelector, droppableSelector, options) => {
|
||||||
if (draggableSelector) {
|
if (draggableSelector) {
|
||||||
cy.get(draggableSelector).should('exist');
|
cy.get(draggableSelector).should('exist');
|
||||||
}
|
}
|
||||||
|
@ -197,7 +197,7 @@ Cypress.Commands.add('draganddrop', (draggableSelector, droppableSelector) => {
|
||||||
cy.get(droppableSelector).realMouseMove(0, 0);
|
cy.get(droppableSelector).realMouseMove(0, 0);
|
||||||
cy.get(droppableSelector).realMouseMove(pageX, pageY);
|
cy.get(droppableSelector).realMouseMove(pageX, pageY);
|
||||||
cy.get(droppableSelector).realHover();
|
cy.get(droppableSelector).realHover();
|
||||||
cy.get(droppableSelector).realMouseUp();
|
cy.get(droppableSelector).realMouseUp({ position: options?.position ?? 'top' });
|
||||||
if (draggableSelector) {
|
if (draggableSelector) {
|
||||||
cy.get(draggableSelector).realMouseUp();
|
cy.get(draggableSelector).realMouseUp();
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,10 @@ interface SigninPayload {
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DragAndDropOptions {
|
||||||
|
position: 'top' | 'center' | 'bottom';
|
||||||
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace Cypress {
|
namespace Cypress {
|
||||||
interface SuiteConfigOverrides {
|
interface SuiteConfigOverrides {
|
||||||
|
@ -56,7 +60,11 @@ declare global {
|
||||||
target: [number, number],
|
target: [number, number],
|
||||||
options?: { abs?: boolean; index?: number; realMouse?: boolean; clickToFinish?: boolean },
|
options?: { abs?: boolean; index?: number; realMouse?: boolean; clickToFinish?: boolean },
|
||||||
): void;
|
): void;
|
||||||
draganddrop(draggableSelector: string, droppableSelector: string): void;
|
draganddrop(
|
||||||
|
draggableSelector: string,
|
||||||
|
droppableSelector: string,
|
||||||
|
options?: Partial<DragAndDropOptions>,
|
||||||
|
): void;
|
||||||
push(type: string, data: unknown): void;
|
push(type: string, data: unknown): void;
|
||||||
shouldNotHaveConsoleErrors(): void;
|
shouldNotHaveConsoleErrors(): void;
|
||||||
window(): Chainable<
|
window(): Chainable<
|
||||||
|
|
|
@ -101,6 +101,7 @@
|
||||||
--color-pending-resolvable-foreground: var(--color-text-base);
|
--color-pending-resolvable-foreground: var(--color-text-base);
|
||||||
--color-pending-resolvable-background: var(--prim-gray-70-alpha-01);
|
--color-pending-resolvable-background: var(--prim-gray-70-alpha-01);
|
||||||
--color-expression-editor-background: var(--prim-gray-800);
|
--color-expression-editor-background: var(--prim-gray-800);
|
||||||
|
--color-expression-editor-modal-background: var(--prim-gray-800);
|
||||||
--color-expression-syntax-example: var(--prim-gray-670);
|
--color-expression-syntax-example: var(--prim-gray-670);
|
||||||
--color-autocomplete-item-selected: var(--prim-color-secondary-tint-200);
|
--color-autocomplete-item-selected: var(--prim-color-secondary-tint-200);
|
||||||
--color-autocomplete-section-header-border: var(--prim-gray-540);
|
--color-autocomplete-section-header-border: var(--prim-gray-540);
|
||||||
|
|
|
@ -135,6 +135,7 @@
|
||||||
--color-pending-resolvable-foreground: var(--color-text-base);
|
--color-pending-resolvable-foreground: var(--color-text-base);
|
||||||
--color-pending-resolvable-background: var(--prim-gray-40);
|
--color-pending-resolvable-background: var(--prim-gray-40);
|
||||||
--color-expression-editor-background: var(--prim-gray-0);
|
--color-expression-editor-background: var(--prim-gray-0);
|
||||||
|
--color-expression-editor-modal-background: var(--color-background-base);
|
||||||
--color-expression-syntax-example: var(--prim-gray-40);
|
--color-expression-syntax-example: var(--prim-gray-40);
|
||||||
--color-autocomplete-item-selected: var(--color-secondary);
|
--color-autocomplete-item-selected: var(--color-secondary);
|
||||||
--color-autocomplete-section-header-border: var(--color-foreground-light);
|
--color-autocomplete-section-header-border: var(--color-foreground-light);
|
||||||
|
|
|
@ -207,19 +207,6 @@ export interface ITableData {
|
||||||
hasJson: { [key: string]: boolean };
|
hasJson: { [key: string]: boolean };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IVariableItemSelected {
|
|
||||||
variable: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IVariableSelectorOption {
|
|
||||||
name: string;
|
|
||||||
key?: string;
|
|
||||||
value?: string;
|
|
||||||
options?: IVariableSelectorOption[] | null;
|
|
||||||
allowParentSelect?: boolean;
|
|
||||||
dataType?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple version of n8n-workflow.Workflow
|
// Simple version of n8n-workflow.Workflow
|
||||||
export interface IWorkflowData {
|
export interface IWorkflowData {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|
|
@ -26,7 +26,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
drop: [value: string];
|
drop: [value: string, event: MouseEvent];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const hovering = ref(false);
|
const hovering = ref(false);
|
||||||
|
@ -60,10 +60,10 @@ function onMouseLeave() {
|
||||||
hovering.value = false;
|
hovering.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMouseUp() {
|
function onMouseUp(event: MouseEvent) {
|
||||||
if (activeDrop.value) {
|
if (activeDrop.value) {
|
||||||
const data = ndvStore.draggableData;
|
const data = ndvStore.draggableData;
|
||||||
emit('drop', data);
|
emit('drop', data, event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,388 +0,0 @@
|
||||||
<template>
|
|
||||||
<div v-if="dialogVisible" class="expression-edit" @keydown.stop>
|
|
||||||
<el-dialog
|
|
||||||
:model-value="dialogVisible"
|
|
||||||
class="expression-dialog classic"
|
|
||||||
width="80%"
|
|
||||||
:title="$locale.baseText('expressionEdit.editExpression')"
|
|
||||||
:before-close="closeDialog"
|
|
||||||
>
|
|
||||||
<el-row>
|
|
||||||
<el-col :span="8">
|
|
||||||
<div class="header-side-menu">
|
|
||||||
<div class="headline">
|
|
||||||
{{ $locale.baseText('expressionEdit.editExpression') }}
|
|
||||||
</div>
|
|
||||||
<div class="sub-headline">
|
|
||||||
{{ $locale.baseText('expressionEdit.variableSelector') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="variable-selector">
|
|
||||||
<VariableSelector
|
|
||||||
:path="path"
|
|
||||||
:redact-values="redactValues"
|
|
||||||
@item-selected="itemSelected"
|
|
||||||
></VariableSelector>
|
|
||||||
</div>
|
|
||||||
</el-col>
|
|
||||||
<el-col :span="16" class="right-side">
|
|
||||||
<div class="expression-editor-wrapper">
|
|
||||||
<div class="editor-description">
|
|
||||||
<div>
|
|
||||||
{{ $locale.baseText('expressionEdit.expression') }}
|
|
||||||
</div>
|
|
||||||
<div class="hint">
|
|
||||||
<span>
|
|
||||||
{{ $locale.baseText('expressionEdit.anythingInside') }}
|
|
||||||
</span>
|
|
||||||
<div class="expression-syntax-example" v-text="`{{ }}`"></div>
|
|
||||||
<span>
|
|
||||||
{{ $locale.baseText('expressionEdit.isJavaScript') }}
|
|
||||||
</span>
|
|
||||||
{{ ' ' }}
|
|
||||||
<n8n-link size="medium" :to="expressionsDocsUrl">
|
|
||||||
{{ $locale.baseText('expressionEdit.learnMore') }}
|
|
||||||
</n8n-link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="expression-editor">
|
|
||||||
<ExpressionEditorModalInput
|
|
||||||
ref="inputFieldExpression"
|
|
||||||
:model-value="modelValue"
|
|
||||||
:is-read-only="isReadOnly"
|
|
||||||
:path="path"
|
|
||||||
:class="{ 'ph-no-capture': redactValues }"
|
|
||||||
data-test-id="expression-modal-input"
|
|
||||||
@change="valueChanged"
|
|
||||||
@close="closeDialog"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="expression-result-wrapper">
|
|
||||||
<div class="editor-description">
|
|
||||||
{{ $locale.baseText('expressionEdit.resultOfItem1') }}
|
|
||||||
</div>
|
|
||||||
<div :class="{ 'ph-no-capture': redactValues }">
|
|
||||||
<ExpressionOutput
|
|
||||||
ref="expressionResult"
|
|
||||||
:segments="segments"
|
|
||||||
:extensions="theme"
|
|
||||||
data-test-id="expression-modal-output"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
</el-dialog>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { type PropType, defineComponent } from 'vue';
|
|
||||||
import { mapStores } from 'pinia';
|
|
||||||
import ExpressionEditorModalInput from '@/components/ExpressionEditorModal/ExpressionEditorModalInput.vue';
|
|
||||||
import VariableSelector from '@/components/VariableSelector.vue';
|
|
||||||
|
|
||||||
import type { IVariableItemSelected } from '@/Interface';
|
|
||||||
|
|
||||||
import { EXPRESSIONS_DOCS_URL } from '@/constants';
|
|
||||||
|
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
|
||||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
|
||||||
import { createExpressionTelemetryPayload } from '@/utils/telemetryUtils';
|
|
||||||
import { useDebounce } from '@/composables/useDebounce';
|
|
||||||
|
|
||||||
import type { Segment } from '@/types/expressions';
|
|
||||||
import ExpressionOutput from './InlineExpressionEditor/ExpressionOutput.vue';
|
|
||||||
import { outputTheme } from './ExpressionEditorModal/theme';
|
|
||||||
import type { INodeProperties } from 'n8n-workflow';
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
name: 'ExpressionEdit',
|
|
||||||
components: {
|
|
||||||
ExpressionEditorModalInput,
|
|
||||||
ExpressionOutput,
|
|
||||||
VariableSelector,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
dialogVisible: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
parameter: {
|
|
||||||
type: Object as PropType<INodeProperties>,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
path: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
modelValue: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
eventSource: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
redactValues: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
isReadOnly: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup() {
|
|
||||||
const externalHooks = useExternalHooks();
|
|
||||||
const { callDebounced } = useDebounce();
|
|
||||||
|
|
||||||
return {
|
|
||||||
callDebounced,
|
|
||||||
externalHooks,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
displayValue: '',
|
|
||||||
latestValue: '',
|
|
||||||
segments: [] as Segment[],
|
|
||||||
expressionsDocsUrl: EXPRESSIONS_DOCS_URL,
|
|
||||||
theme: outputTheme(),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapStores(useNDVStore, useWorkflowsStore),
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
dialogVisible(newValue) {
|
|
||||||
this.displayValue = this.modelValue;
|
|
||||||
this.latestValue = this.modelValue;
|
|
||||||
|
|
||||||
const resolvedExpressionValue =
|
|
||||||
(this.$refs.expressionResult as InstanceType<typeof ExpressionOutput>)?.getValue() || '';
|
|
||||||
void this.externalHooks.run('expressionEdit.dialogVisibleChanged', {
|
|
||||||
dialogVisible: newValue,
|
|
||||||
parameter: this.parameter,
|
|
||||||
value: this.modelValue,
|
|
||||||
resolvedExpressionValue,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!newValue) {
|
|
||||||
const telemetryPayload = createExpressionTelemetryPayload(
|
|
||||||
this.segments,
|
|
||||||
this.modelValue,
|
|
||||||
this.workflowsStore.workflowId,
|
|
||||||
this.ndvStore.pushRef,
|
|
||||||
this.ndvStore.activeNode?.type ?? '',
|
|
||||||
);
|
|
||||||
|
|
||||||
this.$telemetry.track('User closed Expression Editor', telemetryPayload);
|
|
||||||
void this.externalHooks.run('expressionEdit.closeDialog', telemetryPayload);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
valueChanged({ value, segments }: { value: string; segments: Segment[] }, forceUpdate = false) {
|
|
||||||
this.latestValue = value;
|
|
||||||
this.segments = segments;
|
|
||||||
|
|
||||||
if (forceUpdate) {
|
|
||||||
this.updateDisplayValue();
|
|
||||||
this.$emit('update:modelValue', this.latestValue);
|
|
||||||
} else {
|
|
||||||
void this.callDebounced(this.updateDisplayValue, {
|
|
||||||
debounceTime: 500,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
updateDisplayValue() {
|
|
||||||
this.displayValue = this.latestValue;
|
|
||||||
},
|
|
||||||
|
|
||||||
closeDialog() {
|
|
||||||
if (this.latestValue !== this.modelValue) {
|
|
||||||
// Handle the close externally as the visible parameter is an external prop
|
|
||||||
// and is so not allowed to be changed here.
|
|
||||||
this.$emit('update:modelValue', this.latestValue);
|
|
||||||
}
|
|
||||||
this.$emit('closeDialog');
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
|
|
||||||
itemSelected(eventData: IVariableItemSelected) {
|
|
||||||
(
|
|
||||||
this.$refs.inputFieldExpression as {
|
|
||||||
itemSelected: (variable: IVariableItemSelected) => void;
|
|
||||||
}
|
|
||||||
).itemSelected(eventData);
|
|
||||||
void this.externalHooks.run('expressionEdit.itemSelected', {
|
|
||||||
parameter: this.parameter,
|
|
||||||
value: this.modelValue,
|
|
||||||
selectedItem: eventData,
|
|
||||||
});
|
|
||||||
|
|
||||||
const trackProperties: {
|
|
||||||
event_version: string;
|
|
||||||
node_type_dest: string;
|
|
||||||
node_type_source?: string;
|
|
||||||
parameter_name_dest: string;
|
|
||||||
parameter_name_source?: string;
|
|
||||||
variable_type?: string;
|
|
||||||
is_immediate_input: boolean;
|
|
||||||
variable_expression: string;
|
|
||||||
node_name: string;
|
|
||||||
} = {
|
|
||||||
event_version: '2',
|
|
||||||
node_type_dest: this.ndvStore.activeNode ? this.ndvStore.activeNode.type : '',
|
|
||||||
parameter_name_dest: this.parameter.displayName,
|
|
||||||
is_immediate_input: false,
|
|
||||||
variable_expression: eventData.variable,
|
|
||||||
node_name: this.ndvStore.activeNode ? this.ndvStore.activeNode.name : '',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (eventData.variable) {
|
|
||||||
let splitVar = eventData.variable.split('.');
|
|
||||||
|
|
||||||
if (eventData.variable.startsWith('Object.keys')) {
|
|
||||||
splitVar = eventData.variable.split('(')[1].split(')')[0].split('.');
|
|
||||||
trackProperties.variable_type = 'Keys';
|
|
||||||
} else if (eventData.variable.startsWith('Object.values')) {
|
|
||||||
splitVar = eventData.variable.split('(')[1].split(')')[0].split('.');
|
|
||||||
trackProperties.variable_type = 'Values';
|
|
||||||
} else {
|
|
||||||
trackProperties.variable_type = 'Raw value';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (splitVar[0].startsWith("$('")) {
|
|
||||||
const match = /\$\('(.*?)'\)/.exec(splitVar[0]);
|
|
||||||
if (match && match.length > 1) {
|
|
||||||
const sourceNodeName = match[1];
|
|
||||||
trackProperties.node_type_source =
|
|
||||||
this.workflowsStore.getNodeByName(sourceNodeName)?.type;
|
|
||||||
const nodeConnections: Array<Array<{ node: string }>> =
|
|
||||||
this.workflowsStore.outgoingConnectionsByNodeName(sourceNodeName).main;
|
|
||||||
trackProperties.is_immediate_input =
|
|
||||||
nodeConnections &&
|
|
||||||
nodeConnections[0] &&
|
|
||||||
nodeConnections[0].some(({ node }) => node === this.ndvStore.activeNode?.name || '');
|
|
||||||
|
|
||||||
if (splitVar[1].startsWith('parameter')) {
|
|
||||||
trackProperties.parameter_name_source = splitVar[1].split('"')[1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
trackProperties.is_immediate_input = true;
|
|
||||||
|
|
||||||
if (splitVar[0].startsWith('$parameter')) {
|
|
||||||
trackProperties.parameter_name_source = splitVar[0].split('"')[1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$telemetry.track(
|
|
||||||
'User inserted item from Expression Editor variable selector',
|
|
||||||
trackProperties,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.expression-edit {
|
|
||||||
:deep(.expression-dialog) {
|
|
||||||
.el-dialog__header {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.el-dialog__title {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-dialog__body {
|
|
||||||
padding: 0;
|
|
||||||
font-size: var(--font-size-s);
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-side {
|
|
||||||
background-color: var(--color-background-light);
|
|
||||||
border-top-right-radius: 8px;
|
|
||||||
border-bottom-right-radius: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-description {
|
|
||||||
line-height: 1.5;
|
|
||||||
font-weight: bold;
|
|
||||||
padding: 0 0 0.5em 0.2em;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
.hint {
|
|
||||||
color: var(--color-text-base);
|
|
||||||
font-weight: normal;
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
@media (max-width: $breakpoint-xs) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
margin-right: var(--spacing-4xs);
|
|
||||||
}
|
|
||||||
.expression-syntax-example {
|
|
||||||
display: inline-block;
|
|
||||||
margin-top: 3px;
|
|
||||||
height: 16px;
|
|
||||||
line-height: 1;
|
|
||||||
background-color: var(--color-expression-syntax-example);
|
|
||||||
color: var(--color-text-dark);
|
|
||||||
margin-right: var(--spacing-4xs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.expression-result-wrapper,
|
|
||||||
.expression-editor-wrapper {
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expression-result-wrapper {
|
|
||||||
margin-top: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-side-menu {
|
|
||||||
padding: 1em 0 0.5em var(--spacing-s);
|
|
||||||
border-top-left-radius: 8px;
|
|
||||||
|
|
||||||
background-color: var(--color-background-base);
|
|
||||||
color: var(--color-text-dark);
|
|
||||||
border-bottom: 1px solid $color-primary;
|
|
||||||
margin-bottom: 1em;
|
|
||||||
|
|
||||||
.headline {
|
|
||||||
font-size: 1.35em;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sub-headline {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 1.1em;
|
|
||||||
text-align: center;
|
|
||||||
line-height: 1.5;
|
|
||||||
padding-top: 1.5em;
|
|
||||||
color: $color-primary;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.variable-selector {
|
|
||||||
margin: 0 var(--spacing-s);
|
|
||||||
}
|
|
||||||
</style>
|
|
336
packages/editor-ui/src/components/ExpressionEditModal.vue
Normal file
336
packages/editor-ui/src/components/ExpressionEditModal.vue
Normal file
|
@ -0,0 +1,336 @@
|
||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
width="calc(100vw - var(--spacing-3xl))"
|
||||||
|
append-to-body
|
||||||
|
:class="$style.modal"
|
||||||
|
:model-value="dialogVisible"
|
||||||
|
:before-close="closeDialog"
|
||||||
|
>
|
||||||
|
<button :class="$style.close" @click="closeDialog">
|
||||||
|
<Close height="18" width="18" />
|
||||||
|
</button>
|
||||||
|
<div :class="$style.container">
|
||||||
|
<div :class="$style.sidebar">
|
||||||
|
<N8nInput
|
||||||
|
v-model="search"
|
||||||
|
size="small"
|
||||||
|
:class="$style.search"
|
||||||
|
:placeholder="i18n.baseText('ndv.search.placeholder.input.schema')"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<N8nIcon :class="$style.ioSearchIcon" icon="search" />
|
||||||
|
</template>
|
||||||
|
</N8nInput>
|
||||||
|
|
||||||
|
<RunDataSchema
|
||||||
|
:class="$style.schema"
|
||||||
|
:search="appliedSearch"
|
||||||
|
:nodes="parentNodes"
|
||||||
|
mapping-enabled
|
||||||
|
pane-type="input"
|
||||||
|
connection-type="main"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :class="$style.io">
|
||||||
|
<div :class="$style.input">
|
||||||
|
<div :class="$style.header">
|
||||||
|
<N8nText bold size="large">
|
||||||
|
{{ i18n.baseText('expressionEdit.expression') }}
|
||||||
|
</N8nText>
|
||||||
|
<N8nText
|
||||||
|
:class="$style.tip"
|
||||||
|
size="small"
|
||||||
|
v-html="i18n.baseText('expressionTip.javascript')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DraggableTarget :class="$style.editorContainer" type="mapping" @drop="onDrop">
|
||||||
|
<template #default>
|
||||||
|
<ExpressionEditorModalInput
|
||||||
|
ref="expressionInputRef"
|
||||||
|
:model-value="modelValue"
|
||||||
|
:is-read-only="isReadOnly"
|
||||||
|
:path="path"
|
||||||
|
:class="[
|
||||||
|
$style.editor,
|
||||||
|
{
|
||||||
|
'ph-no-capture': redactValues,
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
data-test-id="expression-modal-input"
|
||||||
|
@change="valueChanged"
|
||||||
|
@close="closeDialog"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</DraggableTarget>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :class="$style.output">
|
||||||
|
<div :class="$style.header">
|
||||||
|
<N8nText bold size="large">
|
||||||
|
{{ i18n.baseText('parameterInput.result') }}
|
||||||
|
</N8nText>
|
||||||
|
<OutputItemSelect />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :class="[$style.editorContainer, { 'ph-no-capture': redactValues }]">
|
||||||
|
<ExpressionOutput
|
||||||
|
ref="expressionResultRef"
|
||||||
|
:class="$style.editor"
|
||||||
|
:segments="segments"
|
||||||
|
:extensions="theme"
|
||||||
|
data-test-id="expression-modal-output"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ExpressionEditorModalInput from '@/components/ExpressionEditorModal/ExpressionEditorModalInput.vue';
|
||||||
|
import { computed, ref, toRaw, watch } from 'vue';
|
||||||
|
import Close from 'virtual:icons/mdi/close';
|
||||||
|
|
||||||
|
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||||
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { createExpressionTelemetryPayload } from '@/utils/telemetryUtils';
|
||||||
|
|
||||||
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
import type { Segment } from '@/types/expressions';
|
||||||
|
import type { INodeProperties } from 'n8n-workflow';
|
||||||
|
import { outputTheme } from './ExpressionEditorModal/theme';
|
||||||
|
import ExpressionOutput from './InlineExpressionEditor/ExpressionOutput.vue';
|
||||||
|
import RunDataSchema from './RunDataSchema.vue';
|
||||||
|
import OutputItemSelect from './InlineExpressionEditor/OutputItemSelect.vue';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { useDebounce } from '@/composables/useDebounce';
|
||||||
|
import DraggableTarget from './DraggableTarget.vue';
|
||||||
|
import { dropInEditor } from '@/plugins/codemirror/dragAndDrop';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
parameter: INodeProperties;
|
||||||
|
path: string;
|
||||||
|
modelValue: string;
|
||||||
|
dialogVisible?: boolean;
|
||||||
|
eventSource?: string;
|
||||||
|
redactValues?: boolean;
|
||||||
|
isReadOnly?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
eventSource: '',
|
||||||
|
dialogVisible: false,
|
||||||
|
redactValues: false,
|
||||||
|
isReadOnly: false,
|
||||||
|
});
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:model-value': [value: string];
|
||||||
|
closeDialog: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const ndvStore = useNDVStore();
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
|
||||||
|
const telemetry = useTelemetry();
|
||||||
|
const i18n = useI18n();
|
||||||
|
const externalHooks = useExternalHooks();
|
||||||
|
const { debounce } = useDebounce();
|
||||||
|
|
||||||
|
const segments = ref<Segment[]>([]);
|
||||||
|
const search = ref('');
|
||||||
|
const appliedSearch = ref('');
|
||||||
|
const expressionInputRef = ref<InstanceType<typeof ExpressionEditorModalInput>>();
|
||||||
|
const expressionResultRef = ref<InstanceType<typeof ExpressionOutput>>();
|
||||||
|
const theme = outputTheme();
|
||||||
|
|
||||||
|
const activeNode = computed(() => ndvStore.activeNode);
|
||||||
|
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
|
||||||
|
const inputEditor = computed(() => expressionInputRef.value?.editor);
|
||||||
|
const parentNodes = computed(() => {
|
||||||
|
const node = activeNode.value;
|
||||||
|
if (!node) return [];
|
||||||
|
const nodes = workflow.value.getParentNodesByDepth(node.name);
|
||||||
|
|
||||||
|
return nodes.filter(({ name }) => name !== node.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.dialogVisible,
|
||||||
|
(newValue) => {
|
||||||
|
const resolvedExpressionValue = expressionResultRef.value?.getValue() ?? '';
|
||||||
|
|
||||||
|
void externalHooks.run('expressionEdit.dialogVisibleChanged', {
|
||||||
|
dialogVisible: newValue,
|
||||||
|
parameter: props.parameter,
|
||||||
|
value: props.modelValue,
|
||||||
|
resolvedExpressionValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!newValue) {
|
||||||
|
const telemetryPayload = createExpressionTelemetryPayload(
|
||||||
|
segments.value,
|
||||||
|
props.modelValue,
|
||||||
|
workflowsStore.workflowId,
|
||||||
|
ndvStore.pushRef,
|
||||||
|
ndvStore.activeNode?.type ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
|
telemetry.track('User closed Expression Editor', telemetryPayload);
|
||||||
|
void externalHooks.run('expressionEdit.closeDialog', telemetryPayload);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
search,
|
||||||
|
debounce(
|
||||||
|
(newSearch: string) => {
|
||||||
|
appliedSearch.value = newSearch;
|
||||||
|
},
|
||||||
|
{ debounceTime: 500 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
function valueChanged(update: { value: string; segments: Segment[] }) {
|
||||||
|
segments.value = update.segments;
|
||||||
|
emit('update:model-value', update.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDialog() {
|
||||||
|
emit('closeDialog');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDrop(expression: string, event: MouseEvent) {
|
||||||
|
if (!inputEditor.value) return;
|
||||||
|
|
||||||
|
await dropInEditor(toRaw(inputEditor.value), event, expression);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
.modal {
|
||||||
|
--dialog-close-top: var(--spacing-m);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: clip;
|
||||||
|
height: calc(100% - var(--spacing-4xl));
|
||||||
|
margin-bottom: 0;
|
||||||
|
|
||||||
|
:global(.el-dialog__body) {
|
||||||
|
background-color: var(--color-expression-editor-modal-background);
|
||||||
|
height: 100%;
|
||||||
|
padding: var(--spacing-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.el-dialog__header) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row nowrap;
|
||||||
|
gap: var(--spacing-s);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-s);
|
||||||
|
flex-basis: 360px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schema {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: var(--spacing-4xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 0;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
|
||||||
|
> div {
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorContainer {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.io {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 0;
|
||||||
|
gap: var(--spacing-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input,
|
||||||
|
.output {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-2xs);
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output {
|
||||||
|
[aria-readonly] {
|
||||||
|
background: var(--color-background-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
gap: var(--spacing-5xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip {
|
||||||
|
min-height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
display: flex;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--spacing-4xs);
|
||||||
|
position: absolute;
|
||||||
|
right: var(--spacing-s);
|
||||||
|
top: var(--spacing-s);
|
||||||
|
color: var(--color-button-secondary-font);
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:active {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-md) {
|
||||||
|
.io {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input,
|
||||||
|
.output {
|
||||||
|
height: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--spacing-2xs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -5,8 +5,8 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { history } from '@codemirror/commands';
|
import { history } from '@codemirror/commands';
|
||||||
import { Prec } from '@codemirror/state';
|
import { Prec } from '@codemirror/state';
|
||||||
import { EditorView, keymap } from '@codemirror/view';
|
import { dropCursor, EditorView, keymap } from '@codemirror/view';
|
||||||
import { computed, onMounted, ref, toValue, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
|
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
|
||||||
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
|
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
|
||||||
|
@ -14,7 +14,6 @@ import { forceParse } from '@/utils/forceParse';
|
||||||
import { completionStatus } from '@codemirror/autocomplete';
|
import { completionStatus } from '@codemirror/autocomplete';
|
||||||
import { inputTheme } from './theme';
|
import { inputTheme } from './theme';
|
||||||
|
|
||||||
import type { IVariableItemSelected } from '@/Interface';
|
|
||||||
import { useExpressionEditor } from '@/composables/useExpressionEditor';
|
import { useExpressionEditor } from '@/composables/useExpressionEditor';
|
||||||
import {
|
import {
|
||||||
autocompleteKeyMap,
|
autocompleteKeyMap,
|
||||||
|
@ -22,9 +21,10 @@ import {
|
||||||
historyKeyMap,
|
historyKeyMap,
|
||||||
tabKeyMap,
|
tabKeyMap,
|
||||||
} from '@/plugins/codemirror/keymap';
|
} from '@/plugins/codemirror/keymap';
|
||||||
|
import { infoBoxTooltips } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
|
||||||
import type { Segment } from '@/types/expressions';
|
import type { Segment } from '@/types/expressions';
|
||||||
import { removeExpressionPrefix } from '@/utils/expressions';
|
import { removeExpressionPrefix } from '@/utils/expressions';
|
||||||
import { infoBoxTooltips } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
|
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
modelValue: string;
|
modelValue: string;
|
||||||
|
@ -44,7 +44,7 @@ const emit = defineEmits<{
|
||||||
|
|
||||||
const root = ref<HTMLElement>();
|
const root = ref<HTMLElement>();
|
||||||
const extensions = computed(() => [
|
const extensions = computed(() => [
|
||||||
inputTheme(),
|
inputTheme(props.isReadOnly),
|
||||||
Prec.highest(
|
Prec.highest(
|
||||||
keymap.of([
|
keymap.of([
|
||||||
...tabKeyMap(),
|
...tabKeyMap(),
|
||||||
|
@ -65,6 +65,8 @@ const extensions = computed(() => [
|
||||||
),
|
),
|
||||||
n8nLang(),
|
n8nLang(),
|
||||||
n8nAutocompletion(),
|
n8nAutocompletion(),
|
||||||
|
mappingDropCursor(),
|
||||||
|
dropCursor(),
|
||||||
history(),
|
history(),
|
||||||
expressionInputHandler(),
|
expressionInputHandler(),
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
|
@ -72,14 +74,7 @@ const extensions = computed(() => [
|
||||||
infoBoxTooltips(),
|
infoBoxTooltips(),
|
||||||
]);
|
]);
|
||||||
const editorValue = ref<string>(removeExpressionPrefix(props.modelValue));
|
const editorValue = ref<string>(removeExpressionPrefix(props.modelValue));
|
||||||
const {
|
const { segments, readEditorValue, editor, hasFocus, focus } = useExpressionEditor({
|
||||||
editor: editorRef,
|
|
||||||
segments,
|
|
||||||
readEditorValue,
|
|
||||||
setCursorPosition,
|
|
||||||
hasFocus,
|
|
||||||
focus,
|
|
||||||
} = useExpressionEditor({
|
|
||||||
editorRef: root,
|
editorRef: root,
|
||||||
editorValue,
|
editorValue,
|
||||||
extensions,
|
extensions,
|
||||||
|
@ -111,45 +106,11 @@ onMounted(() => {
|
||||||
focus();
|
focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
function itemSelected({ variable }: IVariableItemSelected) {
|
defineExpose({ editor });
|
||||||
const editor = toValue(editorRef);
|
|
||||||
|
|
||||||
if (!editor || props.isReadOnly) return;
|
|
||||||
|
|
||||||
const OPEN_MARKER = '{{';
|
|
||||||
const CLOSE_MARKER = '}}';
|
|
||||||
|
|
||||||
const { selection, doc } = editor.state;
|
|
||||||
const { head } = selection.main;
|
|
||||||
|
|
||||||
const isInsideResolvable =
|
|
||||||
editor.state.sliceDoc(0, head).includes(OPEN_MARKER) &&
|
|
||||||
editor.state.sliceDoc(head, doc.length).includes(CLOSE_MARKER);
|
|
||||||
|
|
||||||
const insert = isInsideResolvable ? variable : [OPEN_MARKER, variable, CLOSE_MARKER].join(' ');
|
|
||||||
|
|
||||||
editor.dispatch({
|
|
||||||
changes: {
|
|
||||||
from: head,
|
|
||||||
insert,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
focus();
|
|
||||||
setCursorPosition(head + insert.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({ itemSelected });
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.editor div[contenteditable='false'] {
|
:global(.cm-content) {
|
||||||
background-color: var(--disabled-fill, var(--color-background-light));
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
:deep(.cm-content) {
|
|
||||||
border-radius: var(--border-radius-base);
|
border-radius: var(--border-radius-base);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { EditorView } from '@codemirror/view';
|
import { EditorView } from '@codemirror/view';
|
||||||
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
|
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
|
||||||
|
|
||||||
const commonThemeProps = {
|
const commonThemeProps = (isReadOnly = false) => ({
|
||||||
'&': {
|
'&': {
|
||||||
borderWidth: 'var(--border-width-base)',
|
borderWidth: 'var(--border-width-base)',
|
||||||
borderStyle: 'var(--input-border-style, var(--border-style-base))',
|
borderStyle: 'var(--input-border-style, var(--border-style-base))',
|
||||||
|
@ -9,31 +9,33 @@ const commonThemeProps = {
|
||||||
borderRadius: 'var(--input-border-radius, var(--border-radius-base))',
|
borderRadius: 'var(--input-border-radius, var(--border-radius-base))',
|
||||||
backgroundColor: 'var(--color-expression-editor-background)',
|
backgroundColor: 'var(--color-expression-editor-background)',
|
||||||
},
|
},
|
||||||
|
'.cm-cursor, .cm-dropCursor': {
|
||||||
|
borderLeftColor: 'var(--color-code-caret)',
|
||||||
|
},
|
||||||
'&.cm-focused': {
|
'&.cm-focused': {
|
||||||
borderColor: 'var(--color-secondary)',
|
borderColor: 'var(--color-secondary)',
|
||||||
outline: '0 !important',
|
outline: '0 !important',
|
||||||
},
|
},
|
||||||
'.cm-content': {
|
'.cm-content': {
|
||||||
fontFamily: 'var(--font-family-monospace)',
|
fontFamily: 'var(--font-family-monospace)',
|
||||||
height: '220px',
|
|
||||||
padding: 'var(--spacing-xs)',
|
padding: 'var(--spacing-xs)',
|
||||||
color: 'var(--input-font-color, var(--color-text-dark))',
|
color: 'var(--input-font-color, var(--color-text-dark))',
|
||||||
caretColor: 'var(--color-code-caret)',
|
caretColor: isReadOnly ? 'transparent' : 'var(--color-code-caret)',
|
||||||
},
|
},
|
||||||
'.cm-line': {
|
'.cm-line': {
|
||||||
padding: '0',
|
padding: '0',
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
export const inputTheme = () => {
|
export const inputTheme = (isReadOnly = false) => {
|
||||||
const theme = EditorView.theme(commonThemeProps);
|
const theme = EditorView.theme(commonThemeProps(isReadOnly));
|
||||||
|
|
||||||
return [theme, highlighter.resolvableStyle];
|
return [theme, highlighter.resolvableStyle];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const outputTheme = () => {
|
export const outputTheme = () => {
|
||||||
const theme = EditorView.theme({
|
const theme = EditorView.theme({
|
||||||
...commonThemeProps,
|
...commonThemeProps(true),
|
||||||
'.cm-valid-resolvable': {
|
'.cm-valid-resolvable': {
|
||||||
padding: '0 2px',
|
padding: '0 2px',
|
||||||
borderRadius: '2px',
|
borderRadius: '2px',
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
|
||||||
|
|
||||||
import ExpressionFunctionIcon from '@/components/ExpressionFunctionIcon.vue';
|
import ExpressionFunctionIcon from '@/components/ExpressionFunctionIcon.vue';
|
||||||
import InlineExpressionEditorInput from '@/components/InlineExpressionEditor/InlineExpressionEditorInput.vue';
|
import InlineExpressionEditorInput from '@/components/InlineExpressionEditor/InlineExpressionEditorInput.vue';
|
||||||
|
@ -9,10 +9,12 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { createExpressionTelemetryPayload } from '@/utils/telemetryUtils';
|
import { createExpressionTelemetryPayload } from '@/utils/telemetryUtils';
|
||||||
|
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
import { dropInEditor } from '@/plugins/codemirror/dragAndDrop';
|
||||||
import type { Segment } from '@/types/expressions';
|
import type { Segment } from '@/types/expressions';
|
||||||
import { createEventBus, type EventBus } from 'n8n-design-system/utils';
|
import { startCompletion } from '@codemirror/autocomplete';
|
||||||
import type { IDataObject } from 'n8n-workflow';
|
|
||||||
import type { EditorState, SelectionRange } from '@codemirror/state';
|
import type { EditorState, SelectionRange } from '@codemirror/state';
|
||||||
|
import type { IDataObject } from 'n8n-workflow';
|
||||||
|
import { createEventBus, type EventBus } from 'n8n-design-system';
|
||||||
|
|
||||||
const isFocused = ref(false);
|
const isFocused = ref(false);
|
||||||
const segments = ref<Segment[]>([]);
|
const segments = ref<Segment[]>([]);
|
||||||
|
@ -25,9 +27,9 @@ type Props = {
|
||||||
modelValue: string;
|
modelValue: string;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
additionalExpressionData?: IDataObject;
|
additionalExpressionData?: IDataObject;
|
||||||
eventBus?: EventBus;
|
|
||||||
isReadOnly?: boolean;
|
isReadOnly?: boolean;
|
||||||
isAssignment?: boolean;
|
isAssignment?: boolean;
|
||||||
|
eventBus?: EventBus;
|
||||||
};
|
};
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
@ -109,6 +111,45 @@ function onSelectionChange({
|
||||||
selection.value = newSelection;
|
selection.value = newSelection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onDrop(value: string, event: MouseEvent) {
|
||||||
|
if (!inlineInput.value) return;
|
||||||
|
const { editor, setCursorPosition } = inlineInput.value;
|
||||||
|
|
||||||
|
if (!editor) return;
|
||||||
|
|
||||||
|
const droppedSelection = await dropInEditor(toRaw(editor), event, value);
|
||||||
|
|
||||||
|
if (!ndvStore.isAutocompleteOnboarded) {
|
||||||
|
setCursorPosition((droppedSelection.ranges.at(0)?.head ?? 3) - 3);
|
||||||
|
setTimeout(() => {
|
||||||
|
startCompletion(editor);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDropOnFixedInput() {
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
if (!inlineInput.value) return;
|
||||||
|
const { editor, setCursorPosition } = inlineInput.value;
|
||||||
|
|
||||||
|
if (!editor || ndvStore.isAutocompleteOnboarded) return;
|
||||||
|
|
||||||
|
setCursorPosition('lastExpression');
|
||||||
|
setTimeout(() => {
|
||||||
|
focus();
|
||||||
|
startCompletion(editor);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
props.eventBus.on('drop', onDropOnFixedInput);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
props.eventBus.off('drop', onDropOnFixedInput);
|
||||||
|
});
|
||||||
|
|
||||||
watch(isDragging, (newIsDragging) => {
|
watch(isDragging, (newIsDragging) => {
|
||||||
if (newIsDragging) {
|
if (newIsDragging) {
|
||||||
onBlur();
|
onBlur();
|
||||||
|
@ -134,19 +175,23 @@ defineExpose({ focus });
|
||||||
<span v-if="isAssignment">=</span>
|
<span v-if="isAssignment">=</span>
|
||||||
<ExpressionFunctionIcon v-else />
|
<ExpressionFunctionIcon v-else />
|
||||||
</div>
|
</div>
|
||||||
<InlineExpressionEditorInput
|
<DraggableTarget type="mapping" :disabled="isReadOnly" @drop="onDrop">
|
||||||
ref="inlineInput"
|
<template #default="{ activeDrop, droppable }">
|
||||||
:model-value="modelValue"
|
<InlineExpressionEditorInput
|
||||||
:path="path"
|
ref="inlineInput"
|
||||||
:is-read-only="isReadOnly"
|
:model-value="modelValue"
|
||||||
:rows="rows"
|
:path="path"
|
||||||
:additional-data="additionalExpressionData"
|
:is-read-only="isReadOnly"
|
||||||
:event-bus="eventBus"
|
:rows="rows"
|
||||||
@focus="onFocus"
|
:additional-data="additionalExpressionData"
|
||||||
@blur="onBlur"
|
:class="{ [$style.activeDrop]: activeDrop, [$style.droppable]: droppable }"
|
||||||
@update:model-value="onValueChange"
|
@focus="onFocus"
|
||||||
@update:selection="onSelectionChange"
|
@blur="onBlur"
|
||||||
/>
|
@update:model-value="onValueChange"
|
||||||
|
@update:selection="onSelectionChange"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</DraggableTarget>
|
||||||
<n8n-button
|
<n8n-button
|
||||||
v-if="!isDragging"
|
v-if="!isDragging"
|
||||||
square
|
square
|
||||||
|
@ -240,4 +285,26 @@ defineExpose({ focus });
|
||||||
border-bottom-right-radius: 0;
|
border-bottom-right-radius: 0;
|
||||||
background-color: var(--color-code-background);
|
background-color: var(--color-code-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.droppable {
|
||||||
|
--input-border-color: var(--color-ndv-droppable-parameter);
|
||||||
|
--input-border-right-color: var(--color-ndv-droppable-parameter);
|
||||||
|
--input-border-style: dashed;
|
||||||
|
|
||||||
|
:global(.cm-editor) {
|
||||||
|
border-width: 1.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.activeDrop {
|
||||||
|
--input-border-color: var(--color-success);
|
||||||
|
--input-border-right-color: var(--color-success);
|
||||||
|
--input-background-color: var(--color-foreground-xlight);
|
||||||
|
--input-border-style: solid;
|
||||||
|
|
||||||
|
:global(.cm-editor) {
|
||||||
|
cursor: grabbing !important;
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { startCompletion } from '@codemirror/autocomplete';
|
|
||||||
import { history } from '@codemirror/commands';
|
import { history } from '@codemirror/commands';
|
||||||
import { type EditorState, Prec, type SelectionRange } from '@codemirror/state';
|
import { type EditorState, Prec, type SelectionRange } from '@codemirror/state';
|
||||||
import { EditorView, keymap } from '@codemirror/view';
|
import { dropCursor, EditorView, keymap } from '@codemirror/view';
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, toValue, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { useExpressionEditor } from '@/composables/useExpressionEditor';
|
import { useExpressionEditor } from '@/composables/useExpressionEditor';
|
||||||
|
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
|
||||||
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
|
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
|
||||||
import {
|
import {
|
||||||
autocompleteKeyMap,
|
autocompleteKeyMap,
|
||||||
|
@ -14,13 +14,11 @@ import {
|
||||||
tabKeyMap,
|
tabKeyMap,
|
||||||
} from '@/plugins/codemirror/keymap';
|
} from '@/plugins/codemirror/keymap';
|
||||||
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
|
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
import { infoBoxTooltips } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
|
||||||
import type { Segment } from '@/types/expressions';
|
import type { Segment } from '@/types/expressions';
|
||||||
import { removeExpressionPrefix } from '@/utils/expressions';
|
import { removeExpressionPrefix } from '@/utils/expressions';
|
||||||
import { createEventBus, type EventBus } from 'n8n-design-system/utils';
|
|
||||||
import type { IDataObject } from 'n8n-workflow';
|
import type { IDataObject } from 'n8n-workflow';
|
||||||
import { inputTheme } from './theme';
|
import { inputTheme } from './theme';
|
||||||
import { infoBoxTooltips } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
modelValue: string;
|
modelValue: string;
|
||||||
|
@ -28,14 +26,12 @@ type Props = {
|
||||||
rows?: number;
|
rows?: number;
|
||||||
isReadOnly?: boolean;
|
isReadOnly?: boolean;
|
||||||
additionalData?: IDataObject;
|
additionalData?: IDataObject;
|
||||||
eventBus?: EventBus;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
rows: 5,
|
rows: 5,
|
||||||
isReadOnly: false,
|
isReadOnly: false,
|
||||||
additionalData: () => ({}),
|
additionalData: () => ({}),
|
||||||
eventBus: () => createEventBus(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
@ -44,8 +40,6 @@ const emit = defineEmits<{
|
||||||
focus: [];
|
focus: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const ndvStore = useNDVStore();
|
|
||||||
|
|
||||||
const root = ref<HTMLElement>();
|
const root = ref<HTMLElement>();
|
||||||
const extensions = computed(() => [
|
const extensions = computed(() => [
|
||||||
Prec.highest(
|
Prec.highest(
|
||||||
|
@ -55,6 +49,8 @@ const extensions = computed(() => [
|
||||||
n8nAutocompletion(),
|
n8nAutocompletion(),
|
||||||
inputTheme({ isReadOnly: props.isReadOnly, rows: props.rows }),
|
inputTheme({ isReadOnly: props.isReadOnly, rows: props.rows }),
|
||||||
history(),
|
history(),
|
||||||
|
mappingDropCursor(),
|
||||||
|
dropCursor(),
|
||||||
expressionInputHandler(),
|
expressionInputHandler(),
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
infoBoxTooltips(),
|
infoBoxTooltips(),
|
||||||
|
@ -77,32 +73,6 @@ const {
|
||||||
additionalData: props.additionalData,
|
additionalData: props.additionalData,
|
||||||
});
|
});
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
focus: () => {
|
|
||||||
if (!hasFocus.value) {
|
|
||||||
setCursorPosition('lastExpression');
|
|
||||||
focus();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
async function onDrop() {
|
|
||||||
await nextTick();
|
|
||||||
|
|
||||||
const editor = toValue(editorRef);
|
|
||||||
if (!editor) return;
|
|
||||||
|
|
||||||
focus();
|
|
||||||
|
|
||||||
setCursorPosition('lastExpression');
|
|
||||||
|
|
||||||
if (!ndvStore.isAutocompleteOnboarded) {
|
|
||||||
setTimeout(() => {
|
|
||||||
startCompletion(editor);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(newValue) => {
|
(newValue) => {
|
||||||
|
@ -130,12 +100,15 @@ watch(hasFocus, (focused) => {
|
||||||
if (focused) emit('focus');
|
if (focused) emit('focus');
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
defineExpose({
|
||||||
props.eventBus.on('drop', onDrop);
|
editor: editorRef,
|
||||||
});
|
setCursorPosition,
|
||||||
|
focus: () => {
|
||||||
onBeforeUnmount(() => {
|
if (!hasFocus.value) {
|
||||||
props.eventBus.off('drop', onDrop);
|
setCursorPosition('lastExpression');
|
||||||
|
focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -2,13 +2,13 @@
|
||||||
import type { EditorState, SelectionRange } from '@codemirror/state';
|
import type { EditorState, SelectionRange } from '@codemirror/state';
|
||||||
|
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
import type { Segment } from '@/types/expressions';
|
import type { Segment } from '@/types/expressions';
|
||||||
|
import { onBeforeUnmount } from 'vue';
|
||||||
import ExpressionOutput from './ExpressionOutput.vue';
|
import ExpressionOutput from './ExpressionOutput.vue';
|
||||||
|
import OutputItemSelect from './OutputItemSelect.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[];
|
||||||
|
@ -29,31 +29,6 @@ const i18n = useI18n();
|
||||||
const theme = outputTheme();
|
const theme = outputTheme();
|
||||||
const ndvStore = useNDVStore();
|
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(() => {
|
onBeforeUnmount(() => {
|
||||||
ndvStore.expressionOutputItemIndex = 0;
|
ndvStore.expressionOutputItemIndex = 0;
|
||||||
});
|
});
|
||||||
|
@ -66,48 +41,7 @@ onBeforeUnmount(() => {
|
||||||
{{ i18n.baseText('parameterInput.result') }}
|
{{ i18n.baseText('parameterInput.result') }}
|
||||||
</n8n-text>
|
</n8n-text>
|
||||||
|
|
||||||
<div :class="$style.item">
|
<OutputItemSelect />
|
||||||
<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>
|
</div>
|
||||||
<n8n-text :class="$style.body">
|
<n8n-text :class="$style.body">
|
||||||
<ExpressionOutput
|
<ExpressionOutput
|
||||||
|
@ -164,12 +98,6 @@ onBeforeUnmount(() => {
|
||||||
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);
|
||||||
|
@ -179,33 +107,5 @@ onBeforeUnmount(() => {
|
||||||
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>
|
||||||
|
|
|
@ -0,0 +1,114 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
const ndvStore = useNDVStore();
|
||||||
|
|
||||||
|
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 hideTableHoverHint = computed(() => ndvStore.isTableHoverOnboarded);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-4xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
|
@ -41,6 +41,9 @@ export const inputTheme = ({ rows, isReadOnly } = { rows: 5, isReadOnly: false }
|
||||||
'var(--input-border-bottom-right-radius, var(--input-border-radius, var(--border-radius-base)))',
|
'var(--input-border-bottom-right-radius, var(--input-border-radius, var(--border-radius-base)))',
|
||||||
backgroundColor: 'white',
|
backgroundColor: 'white',
|
||||||
},
|
},
|
||||||
|
'.cm-cursor, .cm-dropCursor': {
|
||||||
|
borderLeftColor: 'var(--color-code-caret)',
|
||||||
|
},
|
||||||
'.cm-scroller': {
|
'.cm-scroller': {
|
||||||
lineHeight: '1.68',
|
lineHeight: '1.68',
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,16 +1,18 @@
|
||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" :class="parameterInputClasses" @keydown.stop>
|
<div ref="wrapper" :class="parameterInputClasses" @keydown.stop>
|
||||||
<ExpressionEdit
|
<ExpressionEditModal
|
||||||
:dialog-visible="expressionEditDialogVisible"
|
:dialog-visible="expressionEditDialogVisible"
|
||||||
:model-value="modelValueExpressionEdit"
|
:model-value="modelValueExpressionEdit"
|
||||||
:parameter="parameter"
|
:parameter="parameter"
|
||||||
|
:node="node"
|
||||||
:path="path"
|
:path="path"
|
||||||
:event-source="eventSource || 'ndv'"
|
:event-source="eventSource || 'ndv'"
|
||||||
:is-read-only="isReadOnly"
|
:is-read-only="isReadOnly"
|
||||||
:redact-values="shouldRedactValue"
|
:redact-values="shouldRedactValue"
|
||||||
@close-dialog="closeExpressionEditDialog"
|
@close-dialog="closeExpressionEditDialog"
|
||||||
@update:model-value="expressionUpdated"
|
@update:model-value="expressionUpdated"
|
||||||
></ExpressionEdit>
|
></ExpressionEditModal>
|
||||||
|
|
||||||
<div class="parameter-input ignore-key-press" :style="parameterInputWrapperStyle">
|
<div class="parameter-input ignore-key-press" :style="parameterInputWrapperStyle">
|
||||||
<ResourceLocator
|
<ResourceLocator
|
||||||
v-if="isResourceLocatorParameter"
|
v-if="isResourceLocatorParameter"
|
||||||
|
@ -497,7 +499,7 @@ import { CREDENTIAL_EMPTY_VALUE, NodeHelpers } from 'n8n-workflow';
|
||||||
|
|
||||||
import CodeNodeEditor from '@/components/CodeNodeEditor/CodeNodeEditor.vue';
|
import CodeNodeEditor from '@/components/CodeNodeEditor/CodeNodeEditor.vue';
|
||||||
import CredentialsSelect from '@/components/CredentialsSelect.vue';
|
import CredentialsSelect from '@/components/CredentialsSelect.vue';
|
||||||
import ExpressionEdit from '@/components/ExpressionEdit.vue';
|
import ExpressionEditModal from '@/components/ExpressionEditModal.vue';
|
||||||
import ExpressionParameterInput from '@/components/ExpressionParameterInput.vue';
|
import ExpressionParameterInput from '@/components/ExpressionParameterInput.vue';
|
||||||
import HtmlEditor from '@/components/HtmlEditor/HtmlEditor.vue';
|
import HtmlEditor from '@/components/HtmlEditor/HtmlEditor.vue';
|
||||||
import JsEditor from '@/components/JsEditor/JsEditor.vue';
|
import JsEditor from '@/components/JsEditor/JsEditor.vue';
|
||||||
|
|
|
@ -24,13 +24,12 @@
|
||||||
<DraggableTarget
|
<DraggableTarget
|
||||||
type="mapping"
|
type="mapping"
|
||||||
:disabled="isDropDisabled"
|
:disabled="isDropDisabled"
|
||||||
:sticky="true"
|
sticky
|
||||||
:sticky-offset="isExpression ? [26, 3] : [3, 3]"
|
:sticky-offset="[3, 3]"
|
||||||
@drop="onDrop"
|
@drop="onDrop"
|
||||||
>
|
>
|
||||||
<template #default="{ droppable, activeDrop }">
|
<template #default="{ droppable, activeDrop }">
|
||||||
<ParameterInputWrapper
|
<ParameterInputWrapper
|
||||||
ref="param"
|
|
||||||
:parameter="parameter"
|
:parameter="parameter"
|
||||||
:model-value="value"
|
:model-value="value"
|
||||||
:path="path"
|
:path="path"
|
||||||
|
@ -79,7 +78,6 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
import type { IUpdateInformation } from '@/Interface';
|
import type { IUpdateInformation } from '@/Interface';
|
||||||
|
|
||||||
import DraggableTarget from '@/components/DraggableTarget.vue';
|
import DraggableTarget from '@/components/DraggableTarget.vue';
|
||||||
|
@ -141,7 +139,11 @@ const isInputTypeString = computed(() => props.parameter.type === 'string');
|
||||||
const isInputTypeNumber = computed(() => props.parameter.type === 'number');
|
const isInputTypeNumber = computed(() => props.parameter.type === 'number');
|
||||||
const isResourceLocator = computed(() => props.parameter.type === 'resourceLocator');
|
const isResourceLocator = computed(() => props.parameter.type === 'resourceLocator');
|
||||||
const isDropDisabled = computed(
|
const isDropDisabled = computed(
|
||||||
() => props.parameter.noDataExpression || props.isReadOnly || isResourceLocator.value,
|
() =>
|
||||||
|
props.parameter.noDataExpression ||
|
||||||
|
props.isReadOnly ||
|
||||||
|
isResourceLocator.value ||
|
||||||
|
isExpression.value,
|
||||||
);
|
);
|
||||||
const isExpression = computed(() => isValueExpression(props.parameter, props.value));
|
const isExpression = computed(() => isValueExpression(props.parameter, props.value));
|
||||||
const showExpressionSelector = computed(() =>
|
const showExpressionSelector = computed(() =>
|
||||||
|
|
|
@ -435,6 +435,7 @@
|
||||||
:output-index="currentOutputIndex"
|
:output-index="currentOutputIndex"
|
||||||
:total-runs="maxRunIndex"
|
:total-runs="maxRunIndex"
|
||||||
:search="search"
|
:search="search"
|
||||||
|
:class="$style.schema"
|
||||||
@clear:search="onSearchClear"
|
@clear:search="onSearchClear"
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
@ -2008,6 +2009,10 @@ export default defineComponent({
|
||||||
margin-left: var(--spacing-s);
|
margin-left: var(--spacing-s);
|
||||||
margin-right: var(--spacing-s);
|
margin-right: var(--spacing-s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.schema {
|
||||||
|
padding: 0 var(--spacing-s);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|
|
@ -7,11 +7,12 @@ import NodeIcon from '@/components/NodeIcon.vue';
|
||||||
import Draggable from '@/components/Draggable.vue';
|
import Draggable from '@/components/Draggable.vue';
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
import { telemetry } from '@/plugins/telemetry';
|
import { telemetry } from '@/plugins/telemetry';
|
||||||
import type {
|
import {
|
||||||
ConnectionTypes,
|
NodeConnectionType,
|
||||||
IConnectedNode,
|
type ConnectionTypes,
|
||||||
IDataObject,
|
type IConnectedNode,
|
||||||
INodeTypeDescription,
|
type IDataObject,
|
||||||
|
type INodeTypeDescription,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||||
import { i18n } from '@/plugins/i18n';
|
import { i18n } from '@/plugins/i18n';
|
||||||
|
@ -27,13 +28,13 @@ type Props = {
|
||||||
nodes?: IConnectedNode[];
|
nodes?: IConnectedNode[];
|
||||||
node?: INodeUi | null;
|
node?: INodeUi | null;
|
||||||
data?: IDataObject[];
|
data?: IDataObject[];
|
||||||
mappingEnabled: boolean;
|
mappingEnabled?: boolean;
|
||||||
runIndex: number;
|
runIndex?: number;
|
||||||
outputIndex: number;
|
outputIndex?: number;
|
||||||
totalRuns: number;
|
totalRuns?: number;
|
||||||
paneType: 'input' | 'output';
|
paneType: 'input' | 'output';
|
||||||
connectionType: ConnectionTypes;
|
connectionType?: ConnectionTypes;
|
||||||
search: string;
|
search?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SchemaNode = {
|
type SchemaNode = {
|
||||||
|
@ -51,6 +52,12 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
distanceFromActive: 1,
|
distanceFromActive: 1,
|
||||||
node: null,
|
node: null,
|
||||||
data: undefined,
|
data: undefined,
|
||||||
|
runIndex: 0,
|
||||||
|
outputIndex: 0,
|
||||||
|
totalRuns: 1,
|
||||||
|
connectionType: NodeConnectionType.Main,
|
||||||
|
search: '',
|
||||||
|
mappingEnabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const draggingPath = ref<string>('');
|
const draggingPath = ref<string>('');
|
||||||
|
@ -414,9 +421,7 @@ watch(
|
||||||
--title-spacing-left: 38px;
|
--title-spacing-left: 38px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 0 0 var(--spacing-s) var(--spacing-s);
|
|
||||||
container: schema / inline-size;
|
container: schema / inline-size;
|
||||||
min-height: 100%;
|
|
||||||
|
|
||||||
&.animating {
|
&.animating {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
@ -437,7 +442,6 @@ watch(
|
||||||
|
|
||||||
.schema {
|
.schema {
|
||||||
display: grid;
|
display: grid;
|
||||||
padding-right: var(--spacing-s);
|
|
||||||
grid-template-rows: 1fr;
|
grid-template-rows: 1fr;
|
||||||
|
|
||||||
&.animated {
|
&.animated {
|
||||||
|
@ -480,7 +484,6 @@ watch(
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
padding-bottom: var(--spacing-2xs);
|
padding-bottom: var(--spacing-2xs);
|
||||||
padding-right: var(--spacing-s);
|
|
||||||
background: var(--color-run-data-background);
|
background: var(--color-run-data-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,889 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed } from 'vue';
|
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import { storeToRefs } from 'pinia';
|
|
||||||
import { PLACEHOLDER_FILLED_AT_EXECUTION_TIME, STICKY_NODE_TYPE } from '@/constants';
|
|
||||||
|
|
||||||
import type {
|
|
||||||
GenericValue,
|
|
||||||
IContextObject,
|
|
||||||
IDataObject,
|
|
||||||
INodeExecutionData,
|
|
||||||
IPinData,
|
|
||||||
IRunData,
|
|
||||||
IRunExecutionData,
|
|
||||||
IWorkflowDataProxyAdditionalKeys,
|
|
||||||
Workflow,
|
|
||||||
} from 'n8n-workflow';
|
|
||||||
import { NodeConnectionType, WorkflowDataProxy } from 'n8n-workflow';
|
|
||||||
|
|
||||||
import VariableSelectorItem from '@/components/VariableSelectorItem.vue';
|
|
||||||
import type { IVariableItemSelected, IVariableSelectorOption } from '@/Interface';
|
|
||||||
|
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
|
||||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
|
||||||
import { useI18n } from '@/composables/useI18n';
|
|
||||||
import { escapeMappingString } from '@/utils/mappingUtils';
|
|
||||||
|
|
||||||
// Node types that should not be displayed in variable selector
|
|
||||||
const SKIPPED_NODE_TYPES = [STICKY_NODE_TYPE];
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
path: string;
|
|
||||||
redactValues: boolean;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
itemSelected: [value: IVariableItemSelected];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const i18n = useI18n();
|
|
||||||
const workflowHelpers = useWorkflowHelpers({ router });
|
|
||||||
|
|
||||||
const workflowsStore = useWorkflowsStore();
|
|
||||||
const ndvStore = useNDVStore();
|
|
||||||
|
|
||||||
const { activeNode } = storeToRefs(ndvStore);
|
|
||||||
|
|
||||||
const variableFilter = ref('');
|
|
||||||
|
|
||||||
const extendAll = computed(() => !!variableFilter.value);
|
|
||||||
|
|
||||||
const currentResults = computed(() => getFilterResults(variableFilter.value.toLowerCase(), 0));
|
|
||||||
|
|
||||||
const workflow = computed(() => workflowHelpers.getCurrentWorkflow());
|
|
||||||
|
|
||||||
// Helper functions
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sorts the options alphabetically. Categories get sorted before items.
|
|
||||||
*/
|
|
||||||
function sortOptions(options: IVariableSelectorOption[] | null): IVariableSelectorOption[] | null {
|
|
||||||
if (options === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return options.sort((a: IVariableSelectorOption, b: IVariableSelectorOption) => {
|
|
||||||
const aHasOptions = a.hasOwnProperty('options');
|
|
||||||
const bHasOptions = b.hasOwnProperty('options');
|
|
||||||
|
|
||||||
if (bHasOptions && !aHasOptions) {
|
|
||||||
// When b has options but a not list it first
|
|
||||||
return 1;
|
|
||||||
} else if (!bHasOptions && aHasOptions) {
|
|
||||||
// When a has options but b not list it first
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Else simply sort alphabetically
|
|
||||||
if (a.name < b.name) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (a.name > b.name) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes all empty entries from the list
|
|
||||||
*/
|
|
||||||
function removeEmptyEntries(
|
|
||||||
inputData: IVariableSelectorOption[] | IVariableSelectorOption | null,
|
|
||||||
): IVariableSelectorOption[] | IVariableSelectorOption | null {
|
|
||||||
if (Array.isArray(inputData)) {
|
|
||||||
const newItems: IVariableSelectorOption[] = [];
|
|
||||||
let tempItem: IVariableSelectorOption;
|
|
||||||
inputData.forEach((item) => {
|
|
||||||
tempItem = removeEmptyEntries(item) as IVariableSelectorOption;
|
|
||||||
if (tempItem !== null) {
|
|
||||||
newItems.push(tempItem);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return newItems;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inputData?.options) {
|
|
||||||
const newOptions = removeEmptyEntries(inputData.options);
|
|
||||||
if (Array.isArray(newOptions) && newOptions.length) {
|
|
||||||
// Has still options left so return
|
|
||||||
inputData.options = newOptions;
|
|
||||||
return inputData;
|
|
||||||
} else if (Array.isArray(newOptions) && newOptions.length === 0) {
|
|
||||||
delete inputData.options;
|
|
||||||
return inputData;
|
|
||||||
}
|
|
||||||
// Has no options left so remove
|
|
||||||
return null;
|
|
||||||
} else {
|
|
||||||
// Is an item no category
|
|
||||||
return inputData;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalizes the path so compare paths which have use dots or brakets
|
|
||||||
*/
|
|
||||||
function getPathNormalized(path: string | undefined): string {
|
|
||||||
if (path === undefined) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
const pathArray = path.split('.');
|
|
||||||
|
|
||||||
const finalArray = [];
|
|
||||||
let item: string;
|
|
||||||
for (const pathPart of pathArray) {
|
|
||||||
const pathParts = pathPart.match(/\[.*?\]/g);
|
|
||||||
if (pathParts === null) {
|
|
||||||
// Does not have any brakets so add as it is
|
|
||||||
finalArray.push(pathPart);
|
|
||||||
} else {
|
|
||||||
// Has brakets so clean up the items and add them
|
|
||||||
if (pathPart.charAt(0) !== '[') {
|
|
||||||
// Does not start with a braket so there is a part before
|
|
||||||
// we have to add
|
|
||||||
finalArray.push(pathPart.substr(0, pathPart.indexOf('[')));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (item of pathParts) {
|
|
||||||
item = item.slice(1, -1);
|
|
||||||
if (['"', "'"].includes(item.charAt(0))) {
|
|
||||||
// Is a string
|
|
||||||
item = item.slice(1, -1);
|
|
||||||
finalArray.push(item);
|
|
||||||
} else {
|
|
||||||
// Is a number
|
|
||||||
finalArray.push(`[${item}]`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return finalArray.join('|');
|
|
||||||
}
|
|
||||||
|
|
||||||
function jsonDataToFilterOption(
|
|
||||||
inputData: IDataObject | GenericValue | IDataObject[] | GenericValue[] | null,
|
|
||||||
parentPath: string,
|
|
||||||
propertyName: string,
|
|
||||||
filterText?: string,
|
|
||||||
propertyIndex?: number,
|
|
||||||
displayName?: string,
|
|
||||||
skipKey?: string,
|
|
||||||
): IVariableSelectorOption[] {
|
|
||||||
let fullpath = `${parentPath}["${propertyName}"]`;
|
|
||||||
if (propertyIndex !== undefined) {
|
|
||||||
fullpath += `[${propertyIndex}]`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const returnData: IVariableSelectorOption[] = [];
|
|
||||||
if (inputData === null) {
|
|
||||||
returnData.push({
|
|
||||||
name: propertyName,
|
|
||||||
key: fullpath,
|
|
||||||
value: '[null]',
|
|
||||||
} as IVariableSelectorOption);
|
|
||||||
return returnData;
|
|
||||||
} else if (Array.isArray(inputData)) {
|
|
||||||
let newPropertyName = propertyName;
|
|
||||||
let newParentPath = parentPath;
|
|
||||||
if (propertyIndex !== undefined) {
|
|
||||||
newParentPath += `["${propertyName}"]`;
|
|
||||||
newPropertyName = propertyIndex.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
const arrayData: IVariableSelectorOption[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < inputData.length; i++) {
|
|
||||||
arrayData.push(
|
|
||||||
...jsonDataToFilterOption(
|
|
||||||
inputData[i],
|
|
||||||
newParentPath,
|
|
||||||
newPropertyName,
|
|
||||||
filterText,
|
|
||||||
i,
|
|
||||||
`[Item: ${i}]`,
|
|
||||||
skipKey,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
returnData.push({
|
|
||||||
name: displayName || propertyName,
|
|
||||||
options: arrayData,
|
|
||||||
key: fullpath,
|
|
||||||
allowParentSelect: true,
|
|
||||||
dataType: 'array',
|
|
||||||
} as IVariableSelectorOption);
|
|
||||||
} else if (typeof inputData === 'object') {
|
|
||||||
const tempValue: IVariableSelectorOption[] = [];
|
|
||||||
|
|
||||||
for (const key of Object.keys(inputData)) {
|
|
||||||
tempValue.push(
|
|
||||||
...jsonDataToFilterOption(
|
|
||||||
(inputData as IDataObject)[key],
|
|
||||||
fullpath,
|
|
||||||
key,
|
|
||||||
filterText,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
skipKey,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tempValue.length) {
|
|
||||||
returnData.push({
|
|
||||||
name: displayName || propertyName,
|
|
||||||
options: sortOptions(tempValue),
|
|
||||||
key: fullpath,
|
|
||||||
allowParentSelect: true,
|
|
||||||
dataType: 'object',
|
|
||||||
} as IVariableSelectorOption);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (filterText !== undefined && propertyName.toLowerCase().indexOf(filterText) === -1) {
|
|
||||||
// If filter is set apply it
|
|
||||||
return returnData;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip is currently only needed for leafs so only check here
|
|
||||||
if (getPathNormalized(skipKey) !== getPathNormalized(fullpath)) {
|
|
||||||
returnData.push({
|
|
||||||
name: propertyName,
|
|
||||||
key: fullpath,
|
|
||||||
value: inputData,
|
|
||||||
} as IVariableSelectorOption);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return returnData;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the node's output using runData
|
|
||||||
*
|
|
||||||
* @param {string} nodeName The name of the node to get the data of
|
|
||||||
* @param {IRunData} runData The data of the run to get the data of
|
|
||||||
* @param {string} filterText Filter text for parameters
|
|
||||||
* @param {number} [itemIndex=0] The index of the item
|
|
||||||
* @param {number} [runIndex=0] The index of the run
|
|
||||||
* @param {string} [inputName=NodeConnectionType.Main] The name of the input
|
|
||||||
* @param {number} [outputIndex=0] The index of the output
|
|
||||||
* @param {boolean} [useShort=false] Use short notation $json vs. $('NodeName').json
|
|
||||||
*/
|
|
||||||
function getNodeRunDataOutput(
|
|
||||||
nodeName: string,
|
|
||||||
runData: IRunData,
|
|
||||||
filterText: string,
|
|
||||||
itemIndex = 0,
|
|
||||||
runIndex = 0,
|
|
||||||
inputName = NodeConnectionType.Main,
|
|
||||||
outputIndex = 0,
|
|
||||||
useShort = false,
|
|
||||||
): IVariableSelectorOption[] | null {
|
|
||||||
if (!runData.hasOwnProperty(nodeName)) {
|
|
||||||
// No data found for node
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (runData[nodeName].length <= runIndex) {
|
|
||||||
// No data for given runIndex
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!runData[nodeName][runIndex].hasOwnProperty('data') ||
|
|
||||||
runData[nodeName][runIndex].data === null ||
|
|
||||||
runData[nodeName][runIndex].data === undefined
|
|
||||||
) {
|
|
||||||
// Data property does not exist or is not set (even though it normally has to)
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!runData[nodeName][runIndex].data.hasOwnProperty(inputName)) {
|
|
||||||
// No data found for inputName
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (runData[nodeName][runIndex].data[inputName].length <= outputIndex) {
|
|
||||||
// No data found for output Index
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The data should be identical no matter to which node it gets so always select the first one
|
|
||||||
if (
|
|
||||||
runData[nodeName][runIndex].data[inputName][outputIndex] === null ||
|
|
||||||
runData[nodeName][runIndex].data[inputName][outputIndex].length <= itemIndex
|
|
||||||
) {
|
|
||||||
// No data found for node connection found
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const outputData = runData[nodeName][runIndex].data[inputName][outputIndex][itemIndex];
|
|
||||||
|
|
||||||
return getNodeOutput(nodeName, outputData, filterText, useShort);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the node's output using pinData
|
|
||||||
*
|
|
||||||
* @param {string} nodeName The name of the node to get the data of
|
|
||||||
* @param {IPinData[string]} pinData The node's pin data
|
|
||||||
* @param {string} filterText Filter text for parameters
|
|
||||||
* @param {boolean} [useShort=false] Use short notation $json vs. $('NodeName').json
|
|
||||||
*/
|
|
||||||
function getNodePinDataOutput(
|
|
||||||
nodeName: string,
|
|
||||||
pinData: IPinData[string],
|
|
||||||
filterText: string,
|
|
||||||
useShort = false,
|
|
||||||
): IVariableSelectorOption[] | null {
|
|
||||||
const outputData = pinData.map((data) => ({ json: data }) as INodeExecutionData)[0];
|
|
||||||
|
|
||||||
return getNodeOutput(nodeName, outputData, filterText, useShort);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the node's output data
|
|
||||||
*
|
|
||||||
* @param {string} nodeName The name of the node to get the data of
|
|
||||||
* @param {INodeExecutionData} outputData The data of the run to get the data of
|
|
||||||
* @param {string} filterText Filter text for parameters
|
|
||||||
* @param {boolean} [useShort=false] Use short notation
|
|
||||||
*/
|
|
||||||
function getNodeOutput(
|
|
||||||
nodeName: string,
|
|
||||||
outputData: INodeExecutionData,
|
|
||||||
filterText: string,
|
|
||||||
useShort = false,
|
|
||||||
): IVariableSelectorOption[] | null {
|
|
||||||
const returnData: IVariableSelectorOption[] = [];
|
|
||||||
|
|
||||||
// Get json data
|
|
||||||
if (outputData.hasOwnProperty('json')) {
|
|
||||||
const jsonPropertyPrefix = useShort
|
|
||||||
? '$json'
|
|
||||||
: `$('${escapeMappingString(nodeName)}').item.json`;
|
|
||||||
|
|
||||||
const jsonDataOptions: IVariableSelectorOption[] = [];
|
|
||||||
for (const propertyName of Object.keys(outputData.json)) {
|
|
||||||
jsonDataOptions.push(
|
|
||||||
...jsonDataToFilterOption(
|
|
||||||
outputData.json[propertyName],
|
|
||||||
jsonPropertyPrefix,
|
|
||||||
propertyName,
|
|
||||||
filterText,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jsonDataOptions.length) {
|
|
||||||
returnData.push({
|
|
||||||
name: 'JSON',
|
|
||||||
options: sortOptions(jsonDataOptions),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get binary data
|
|
||||||
if (outputData.hasOwnProperty('binary')) {
|
|
||||||
const binaryPropertyPrefix = useShort
|
|
||||||
? '$binary'
|
|
||||||
: `$('${escapeMappingString(nodeName)}').item.binary`;
|
|
||||||
|
|
||||||
const binaryData = [];
|
|
||||||
let binaryPropertyData: IVariableSelectorOption[] = [];
|
|
||||||
|
|
||||||
for (const dataPropertyName of Object.keys(outputData.binary ?? {})) {
|
|
||||||
binaryPropertyData = [];
|
|
||||||
for (const propertyName in outputData.binary?.[dataPropertyName]) {
|
|
||||||
if (propertyName === 'data') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filterText && propertyName.toLowerCase().indexOf(filterText) === -1) {
|
|
||||||
// If filter is set apply it
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
binaryPropertyData.push({
|
|
||||||
name: propertyName,
|
|
||||||
key: `${binaryPropertyPrefix}.${dataPropertyName}.${propertyName}`,
|
|
||||||
value: outputData.binary?.[dataPropertyName][propertyName]?.toString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (binaryPropertyData.length) {
|
|
||||||
binaryData.push({
|
|
||||||
name: dataPropertyName,
|
|
||||||
key: `${binaryPropertyPrefix}.${dataPropertyName}`,
|
|
||||||
options: sortOptions(binaryPropertyData),
|
|
||||||
allowParentSelect: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (binaryData.length) {
|
|
||||||
returnData.push({
|
|
||||||
name: 'Binary',
|
|
||||||
key: binaryPropertyPrefix,
|
|
||||||
options: sortOptions(binaryData),
|
|
||||||
allowParentSelect: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return returnData;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNodeContext(
|
|
||||||
workflow: Workflow,
|
|
||||||
runExecutionData: IRunExecutionData | null,
|
|
||||||
parentNode: string[],
|
|
||||||
nodeName: string,
|
|
||||||
filterText: string,
|
|
||||||
): IVariableSelectorOption[] | null {
|
|
||||||
const itemIndex = 0;
|
|
||||||
const inputName = NodeConnectionType.Main;
|
|
||||||
const runIndex = 0;
|
|
||||||
const returnData: IVariableSelectorOption[] = [];
|
|
||||||
|
|
||||||
if (activeNode.value === null) {
|
|
||||||
return returnData;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodeConnection = workflow.getNodeConnectionIndexes(
|
|
||||||
activeNode.value.name,
|
|
||||||
parentNode[0],
|
|
||||||
inputName,
|
|
||||||
);
|
|
||||||
const connectionInputData = workflowHelpers.connectionInputData(
|
|
||||||
parentNode,
|
|
||||||
nodeName,
|
|
||||||
inputName,
|
|
||||||
runIndex,
|
|
||||||
nodeConnection,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (connectionInputData === null) {
|
|
||||||
return returnData;
|
|
||||||
}
|
|
||||||
|
|
||||||
const additionalKeys: IWorkflowDataProxyAdditionalKeys = {
|
|
||||||
$execution: {
|
|
||||||
id: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
|
|
||||||
mode: 'test',
|
|
||||||
resumeUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
|
|
||||||
resumeFormUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
|
|
||||||
},
|
|
||||||
|
|
||||||
// deprecated
|
|
||||||
$executionId: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
|
|
||||||
$resumeWebhookUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
|
|
||||||
};
|
|
||||||
|
|
||||||
const dataProxy = new WorkflowDataProxy(
|
|
||||||
workflow,
|
|
||||||
runExecutionData,
|
|
||||||
runIndex,
|
|
||||||
itemIndex,
|
|
||||||
nodeName,
|
|
||||||
connectionInputData,
|
|
||||||
{},
|
|
||||||
'manual',
|
|
||||||
additionalKeys,
|
|
||||||
);
|
|
||||||
const proxy = dataProxy.getDataProxy();
|
|
||||||
|
|
||||||
const nodeContext = proxy.$node[nodeName].context as IContextObject;
|
|
||||||
for (const key of Object.keys(nodeContext)) {
|
|
||||||
if (filterText !== undefined && key.toLowerCase().indexOf(filterText) === -1) {
|
|
||||||
// If filter is set apply it
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
returnData.push({
|
|
||||||
name: key,
|
|
||||||
key: `$('${escapeMappingString(nodeName)}').context['${escapeMappingString(key)}']`,
|
|
||||||
value: nodeContext[key],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return returnData;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns all the node parameters with values
|
|
||||||
*
|
|
||||||
* @param {string} nodeName The name of the node to return data of
|
|
||||||
* @param {string} path The path to the node to pretend to key
|
|
||||||
* @param {string} [skipParameter] Parameter to skip
|
|
||||||
* @param {string} [filterText] Filter text for parameters
|
|
||||||
*/
|
|
||||||
function getNodeParameters(
|
|
||||||
nodeName: string,
|
|
||||||
path: string,
|
|
||||||
skipParameter?: string,
|
|
||||||
filterText?: string,
|
|
||||||
): IVariableSelectorOption[] | null {
|
|
||||||
const node = workflow.value.getNode(nodeName);
|
|
||||||
if (node === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const returnParameters: IVariableSelectorOption[] = [];
|
|
||||||
for (const parameterName in node.parameters) {
|
|
||||||
if (parameterName === skipParameter) {
|
|
||||||
// Skip the parameter
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filterText !== undefined && parameterName.toLowerCase().indexOf(filterText) === -1) {
|
|
||||||
// If filter is set apply it
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
returnParameters.push(
|
|
||||||
...jsonDataToFilterOption(
|
|
||||||
node.parameters[parameterName],
|
|
||||||
path,
|
|
||||||
parameterName,
|
|
||||||
filterText,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
skipParameter,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return returnParameters;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFilterResults(filterText: string, itemIndex: number): IVariableSelectorOption[] {
|
|
||||||
const inputName = NodeConnectionType.Main;
|
|
||||||
|
|
||||||
if (activeNode.value === null) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const executionData = workflowsStore.getWorkflowExecution;
|
|
||||||
let parentNode = workflow.value.getParentNodes(activeNode.value.name, inputName, 1);
|
|
||||||
let runData = workflowsStore.getWorkflowRunData;
|
|
||||||
|
|
||||||
if (runData === null) {
|
|
||||||
runData = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
let returnData: IVariableSelectorOption[] | null = [];
|
|
||||||
// -----------------------------------------
|
|
||||||
// Add the parameters of the current node
|
|
||||||
// -----------------------------------------
|
|
||||||
|
|
||||||
// Add the parameters
|
|
||||||
const currentNodeData: IVariableSelectorOption[] = [];
|
|
||||||
|
|
||||||
let tempOptions: IVariableSelectorOption[];
|
|
||||||
|
|
||||||
if (executionData?.data !== undefined) {
|
|
||||||
const runExecutionData: IRunExecutionData = executionData.data;
|
|
||||||
|
|
||||||
tempOptions = getNodeContext(
|
|
||||||
workflow.value,
|
|
||||||
runExecutionData,
|
|
||||||
parentNode,
|
|
||||||
activeNode.value.name,
|
|
||||||
filterText,
|
|
||||||
) as IVariableSelectorOption[];
|
|
||||||
if (tempOptions.length) {
|
|
||||||
currentNodeData.push({
|
|
||||||
name: 'Context',
|
|
||||||
options: sortOptions(tempOptions),
|
|
||||||
} as IVariableSelectorOption);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let tempOutputData: IVariableSelectorOption[] | null | undefined;
|
|
||||||
|
|
||||||
if (parentNode.length) {
|
|
||||||
// If the node has an input node add the input data
|
|
||||||
|
|
||||||
let ndvInputNodeName = ndvStore.ndvInputNodeName;
|
|
||||||
if (!ndvInputNodeName) {
|
|
||||||
// If no input node is set use the first parent one
|
|
||||||
// this is important for config-nodes which do not have
|
|
||||||
// a main input
|
|
||||||
ndvInputNodeName = parentNode[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeInputParentNode = parentNode.find((node) => node === ndvInputNodeName);
|
|
||||||
if (!activeInputParentNode) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
// Check from which output to read the data.
|
|
||||||
// Depends on how the nodes are connected.
|
|
||||||
// (example "IF" node. If node is connected to "true" or to "false" output)
|
|
||||||
const nodeConnection = workflow.value.getNodeConnectionIndexes(
|
|
||||||
activeNode.value.name,
|
|
||||||
activeInputParentNode,
|
|
||||||
inputName,
|
|
||||||
);
|
|
||||||
const outputIndex = nodeConnection === undefined ? 0 : nodeConnection.sourceIndex;
|
|
||||||
|
|
||||||
tempOutputData = getNodeRunDataOutput(
|
|
||||||
activeInputParentNode,
|
|
||||||
runData,
|
|
||||||
filterText,
|
|
||||||
itemIndex,
|
|
||||||
0,
|
|
||||||
inputName,
|
|
||||||
outputIndex,
|
|
||||||
true,
|
|
||||||
) as IVariableSelectorOption[];
|
|
||||||
|
|
||||||
const pinDataOptions: IVariableSelectorOption[] = [
|
|
||||||
{
|
|
||||||
name: 'JSON',
|
|
||||||
options: [],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
parentNode.forEach((parentNodeName) => {
|
|
||||||
const pinData = workflowsStore.pinDataByNodeName(parentNodeName);
|
|
||||||
|
|
||||||
if (pinData) {
|
|
||||||
const output = getNodePinDataOutput(parentNodeName, pinData, filterText, true);
|
|
||||||
|
|
||||||
if (pinDataOptions[0].options) {
|
|
||||||
pinDataOptions[0].options = pinDataOptions[0].options.concat(output?.[0]?.options ?? []);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if ((pinDataOptions[0]?.options ?? []).length > 0) {
|
|
||||||
if (tempOutputData) {
|
|
||||||
const jsonTempOutputData = tempOutputData.find((tempData) => tempData.name === 'JSON');
|
|
||||||
|
|
||||||
if (jsonTempOutputData) {
|
|
||||||
if (!jsonTempOutputData.options) {
|
|
||||||
jsonTempOutputData.options = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
(pinDataOptions[0].options ?? []).forEach((pinDataOption) => {
|
|
||||||
const existingOptionIndex = jsonTempOutputData.options!.findIndex(
|
|
||||||
(option) => option.name === pinDataOption.name,
|
|
||||||
);
|
|
||||||
if (existingOptionIndex !== -1) {
|
|
||||||
jsonTempOutputData.options![existingOptionIndex] = pinDataOption;
|
|
||||||
} else {
|
|
||||||
jsonTempOutputData.options!.push(pinDataOption);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
tempOutputData.push(pinDataOptions[0]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
tempOutputData = pinDataOptions;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tempOutputData) {
|
|
||||||
if (JSON.stringify(tempOutputData).length < 102400) {
|
|
||||||
// Data is reasonable small (< 100kb) so add it
|
|
||||||
currentNodeData.push({
|
|
||||||
name: 'Input Data',
|
|
||||||
options: sortOptions(tempOutputData),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Data is to large so do not add
|
|
||||||
currentNodeData.push({
|
|
||||||
name: 'Input Data',
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
name: '[Data to large]',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialPath = '$parameter';
|
|
||||||
let skipParameter = props.path;
|
|
||||||
if (skipParameter.startsWith('parameters.')) {
|
|
||||||
skipParameter = initialPath + skipParameter.substring(10);
|
|
||||||
}
|
|
||||||
|
|
||||||
currentNodeData.push({
|
|
||||||
name: i18n.baseText('variableSelector.parameters'),
|
|
||||||
options: sortOptions(
|
|
||||||
getNodeParameters(
|
|
||||||
activeNode.value.name,
|
|
||||||
initialPath,
|
|
||||||
skipParameter,
|
|
||||||
filterText,
|
|
||||||
) as IVariableSelectorOption[],
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
returnData.push({
|
|
||||||
name: i18n.baseText('variableSelector.currentNode'),
|
|
||||||
options: sortOptions(currentNodeData),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add the input data
|
|
||||||
|
|
||||||
// -----------------------------------------
|
|
||||||
// Add all the nodes and their data
|
|
||||||
// -----------------------------------------
|
|
||||||
const allNodesData: IVariableSelectorOption[] = [];
|
|
||||||
let nodeOptions: IVariableSelectorOption[];
|
|
||||||
const upstreamNodes = workflow.value.getParentNodes(activeNode.value.name, inputName);
|
|
||||||
|
|
||||||
const workflowNodes = Object.entries(workflow.value.nodes);
|
|
||||||
|
|
||||||
// Sort the nodes according to their position relative to the current node
|
|
||||||
workflowNodes.sort((a, b) => {
|
|
||||||
return upstreamNodes.indexOf(b[0]) - upstreamNodes.indexOf(a[0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const [nodeName, node] of workflowNodes) {
|
|
||||||
// Add the parameters of all nodes
|
|
||||||
// TODO: Later have to make sure that no parameters can be referenced which have expression which use input-data (for nodes which are not parent nodes)
|
|
||||||
|
|
||||||
if (nodeName === activeNode.value.name) {
|
|
||||||
// Skip the current node as this one get added separately
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// If node type should be skipped, continue
|
|
||||||
if (SKIPPED_NODE_TYPES.includes(node.type)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
nodeOptions = [
|
|
||||||
{
|
|
||||||
name: i18n.baseText('variableSelector.parameters'),
|
|
||||||
options: sortOptions(
|
|
||||||
getNodeParameters(
|
|
||||||
nodeName,
|
|
||||||
`$('${escapeMappingString(nodeName)}').params`,
|
|
||||||
undefined,
|
|
||||||
filterText,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
} as IVariableSelectorOption,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (executionData?.data !== undefined) {
|
|
||||||
const runExecutionData: IRunExecutionData = executionData.data;
|
|
||||||
|
|
||||||
parentNode = workflow.value.getParentNodes(nodeName, inputName, 1);
|
|
||||||
tempOptions = getNodeContext(
|
|
||||||
workflow.value,
|
|
||||||
runExecutionData,
|
|
||||||
parentNode,
|
|
||||||
nodeName,
|
|
||||||
filterText,
|
|
||||||
) as IVariableSelectorOption[];
|
|
||||||
if (tempOptions.length) {
|
|
||||||
nodeOptions = [
|
|
||||||
{
|
|
||||||
name: i18n.baseText('variableSelector.context'),
|
|
||||||
options: sortOptions(tempOptions),
|
|
||||||
} as IVariableSelectorOption,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (upstreamNodes.includes(nodeName)) {
|
|
||||||
// If the node is an upstream node add also the output data which can be referenced
|
|
||||||
const pinData = workflowsStore.pinDataByNodeName(nodeName);
|
|
||||||
tempOutputData = pinData
|
|
||||||
? getNodePinDataOutput(nodeName, pinData, filterText)
|
|
||||||
: getNodeRunDataOutput(nodeName, runData, filterText, itemIndex);
|
|
||||||
|
|
||||||
if (tempOutputData) {
|
|
||||||
nodeOptions.push({
|
|
||||||
name: i18n.baseText('variableSelector.outputData'),
|
|
||||||
options: sortOptions(tempOutputData),
|
|
||||||
} as IVariableSelectorOption);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const shortNodeType = i18n.shortNodeType(node.type);
|
|
||||||
|
|
||||||
allNodesData.push({
|
|
||||||
name: i18n.headerText({
|
|
||||||
key: `headers.${shortNodeType}.displayName`,
|
|
||||||
fallback: nodeName,
|
|
||||||
}),
|
|
||||||
options: sortOptions(nodeOptions),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
returnData.push({
|
|
||||||
name: i18n.baseText('variableSelector.nodes'),
|
|
||||||
options: allNodesData,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove empty entries and return
|
|
||||||
returnData = removeEmptyEntries(returnData) as IVariableSelectorOption[] | null;
|
|
||||||
|
|
||||||
if (returnData === null) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return returnData;
|
|
||||||
}
|
|
||||||
|
|
||||||
function forwardItemSelected(eventData: IVariableItemSelected) {
|
|
||||||
emit('itemSelected', eventData);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="variable-selector-wrapper" @keydown.stop>
|
|
||||||
<div class="input-wrapper">
|
|
||||||
<n8n-input
|
|
||||||
ref="inputField"
|
|
||||||
v-model="variableFilter"
|
|
||||||
:placeholder="i18n.baseText('variableSelector.variableFilter')"
|
|
||||||
size="small"
|
|
||||||
type="text"
|
|
||||||
></n8n-input>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="result-wrapper">
|
|
||||||
<VariableSelectorItem
|
|
||||||
v-for="option in currentResults"
|
|
||||||
:key="option.key"
|
|
||||||
:item="option"
|
|
||||||
:extend-all="extendAll"
|
|
||||||
:redact-values="redactValues"
|
|
||||||
@item-selected="forwardItemSelected"
|
|
||||||
></VariableSelectorItem>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.variable-selector-wrapper {
|
|
||||||
border-radius: 0 0 4px 4px;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-wrapper {
|
|
||||||
line-height: 1em;
|
|
||||||
height: 370px;
|
|
||||||
overflow-x: hidden;
|
|
||||||
overflow-y: auto;
|
|
||||||
margin: 0.5em 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-item {
|
|
||||||
font-size: 0.7em;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,185 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="item">
|
|
||||||
<div v-if="item.options" class="options">
|
|
||||||
<div v-if="item.options.length" class="headline clickable" @click="extended = !extended">
|
|
||||||
<div v-if="extendAll !== true" class="options-toggle">
|
|
||||||
<font-awesome-icon v-if="extended" icon="angle-down" />
|
|
||||||
<font-awesome-icon v-else icon="angle-right" />
|
|
||||||
</div>
|
|
||||||
<div class="option-title" :title="item.key">
|
|
||||||
{{ item.name }}
|
|
||||||
|
|
||||||
<el-dropdown
|
|
||||||
v-if="allowParentSelect === true"
|
|
||||||
trigger="click"
|
|
||||||
@click.stop
|
|
||||||
@command="optionSelected($event, item)"
|
|
||||||
>
|
|
||||||
<span class="el-dropdown-link clickable" @click.stop>
|
|
||||||
<font-awesome-icon
|
|
||||||
icon="dot-circle"
|
|
||||||
:title="$locale.baseText('variableSelectorItem.selectItem')"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<template #dropdown>
|
|
||||||
<el-dropdown-menu>
|
|
||||||
<el-dropdown-item
|
|
||||||
v-for="operation in itemAddOperations"
|
|
||||||
:key="operation.command"
|
|
||||||
:command="operation.command"
|
|
||||||
>{{ operation.displayName }}</el-dropdown-item
|
|
||||||
>
|
|
||||||
</el-dropdown-menu>
|
|
||||||
</template>
|
|
||||||
</el-dropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="item.options && (extended === true || extendAll === true)">
|
|
||||||
<variable-selector-item
|
|
||||||
v-for="option in item.options"
|
|
||||||
:key="option.key"
|
|
||||||
:item="option"
|
|
||||||
:extend-all="extendAll"
|
|
||||||
:allow-parent-select="option.allowParentSelect"
|
|
||||||
:redact-values="redactValues"
|
|
||||||
class="sub-level"
|
|
||||||
@item-selected="forwardItemSelected"
|
|
||||||
></variable-selector-item>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="value clickable" @click="selectItem(item)">
|
|
||||||
<div class="item-title" :title="item.key">
|
|
||||||
{{ item.name }}:
|
|
||||||
<font-awesome-icon icon="dot-circle" title="Select Item" />
|
|
||||||
</div>
|
|
||||||
<div :class="{ 'ph-no-capture': redactValues, 'item-value': true }">
|
|
||||||
{{ item.value !== undefined ? item.value : $locale.baseText('variableSelectorItem.empty') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted } from 'vue';
|
|
||||||
import type { IVariableSelectorOption, IVariableItemSelected } from '@/Interface';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
allowParentSelect?: boolean;
|
|
||||||
extendAll?: boolean;
|
|
||||||
item: IVariableSelectorOption;
|
|
||||||
redactValues?: boolean;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
itemSelected: [value: IVariableItemSelected];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const extended = ref(false);
|
|
||||||
|
|
||||||
const itemAddOperations = computed(() => {
|
|
||||||
const returnOptions = [
|
|
||||||
{
|
|
||||||
command: 'raw',
|
|
||||||
displayName: 'Raw value',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
if (props.item.dataType === 'array') {
|
|
||||||
returnOptions.push(
|
|
||||||
{
|
|
||||||
command: 'arrayLength',
|
|
||||||
displayName: 'Length',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
command: 'arrayValues',
|
|
||||||
displayName: 'Values',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else if (props.item.dataType === 'object') {
|
|
||||||
returnOptions.push(
|
|
||||||
{
|
|
||||||
command: 'objectKeys',
|
|
||||||
displayName: 'Keys',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
command: 'objectValues',
|
|
||||||
displayName: 'Values',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return returnOptions;
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (extended.value) return;
|
|
||||||
|
|
||||||
const shouldAutoExtend =
|
|
||||||
['Current Node', 'Input Data', 'Binary', 'JSON'].includes(props.item.name) &&
|
|
||||||
props.item.key === undefined;
|
|
||||||
|
|
||||||
if (shouldAutoExtend) {
|
|
||||||
extended.value = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const optionSelected = (command: string, item: IVariableSelectorOption) => {
|
|
||||||
let variable = item.key ?? '';
|
|
||||||
if (command === 'arrayValues') {
|
|
||||||
variable = `${item.key}.join(', ')`;
|
|
||||||
} else if (command === 'arrayLength') {
|
|
||||||
variable = `${item.key}.length`;
|
|
||||||
} else if (command === 'objectKeys') {
|
|
||||||
variable = `Object.keys(${item.key}).join(', ')`;
|
|
||||||
} else if (command === 'objectValues') {
|
|
||||||
variable = `Object.values(${item.key}).join(', ')`;
|
|
||||||
}
|
|
||||||
emit('itemSelected', { variable });
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectItem = (item: IVariableSelectorOption) => {
|
|
||||||
emit('itemSelected', { variable: item.key ?? '' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const forwardItemSelected = (eventData: IVariableItemSelected) => {
|
|
||||||
emit('itemSelected', eventData);
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.option-title {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 0.9em;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 0.2em 1em 0.2em 0.4em;
|
|
||||||
}
|
|
||||||
.item-title {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.8em;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.headline {
|
|
||||||
position: relative;
|
|
||||||
margin: 2px;
|
|
||||||
margin-top: 10px;
|
|
||||||
color: $color-primary;
|
|
||||||
}
|
|
||||||
.options-toggle {
|
|
||||||
position: relative;
|
|
||||||
top: 1px;
|
|
||||||
margin: 0 3px 0 8px;
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
.value {
|
|
||||||
margin: 0.2em;
|
|
||||||
padding: 0.1em 0.3em;
|
|
||||||
}
|
|
||||||
.item-value {
|
|
||||||
font-size: 0.6em;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
.sub-level {
|
|
||||||
padding-left: 20px;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
|
import { EditorState } from '@codemirror/state';
|
||||||
|
import { EditorView } from '@codemirror/view';
|
||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import { fireEvent } from '@testing-library/dom';
|
||||||
|
import { setActivePinia } from 'pinia';
|
||||||
|
import { mappingDropCursor } from '../dragAndDrop';
|
||||||
|
import { n8nLang } from '../n8nLang';
|
||||||
|
|
||||||
|
describe('CodeMirror drag and drop', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const pinia = createTestingPinia({ stubActions: false });
|
||||||
|
setActivePinia(pinia);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mappingDropCursor', () => {
|
||||||
|
const createEditor = () => {
|
||||||
|
const parent = document.createElement('div');
|
||||||
|
document.body.appendChild(parent);
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: 'test {{ $json.foo }} \n\nnewline',
|
||||||
|
extensions: [mappingDropCursor(), n8nLang()],
|
||||||
|
});
|
||||||
|
const editor = new EditorView({ parent, state });
|
||||||
|
return editor;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should render a drop cursor when dragging', async () => {
|
||||||
|
useNDVStore().draggableStartDragging({
|
||||||
|
type: 'mapping',
|
||||||
|
data: '{{ $json.bar }}',
|
||||||
|
dimensions: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const editor = createEditor();
|
||||||
|
const rect = editor.contentDOM.getBoundingClientRect();
|
||||||
|
fireEvent(
|
||||||
|
editor.contentDOM,
|
||||||
|
new MouseEvent('mousemove', {
|
||||||
|
clientX: rect.left,
|
||||||
|
clientY: rect.top,
|
||||||
|
bubbles: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const cursor = editor.dom.querySelector('.cm-dropCursor');
|
||||||
|
|
||||||
|
expect(cursor).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render a drop cursor when not dragging', async () => {
|
||||||
|
const editor = createEditor();
|
||||||
|
const rect = editor.contentDOM.getBoundingClientRect();
|
||||||
|
fireEvent(
|
||||||
|
editor.contentDOM,
|
||||||
|
new MouseEvent('mousemove', {
|
||||||
|
clientX: rect.left,
|
||||||
|
clientY: rect.top,
|
||||||
|
bubbles: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const cursor = editor.dom.querySelector('.cm-dropCursor');
|
||||||
|
|
||||||
|
expect(cursor).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
153
packages/editor-ui/src/plugins/codemirror/dragAndDrop.ts
Normal file
153
packages/editor-ui/src/plugins/codemirror/dragAndDrop.ts
Normal file
|
@ -0,0 +1,153 @@
|
||||||
|
import { EditorSelection, StateEffect, StateField, type Extension } from '@codemirror/state';
|
||||||
|
import { ViewPlugin, type EditorView, type ViewUpdate } from '@codemirror/view';
|
||||||
|
import { syntaxTree } from '@codemirror/language';
|
||||||
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
|
import { unwrapExpression } from '@/utils/expressions';
|
||||||
|
|
||||||
|
const setDropCursorPos = StateEffect.define<number | null>({
|
||||||
|
map(pos, mapping) {
|
||||||
|
return pos === null ? null : mapping.mapPos(pos);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dropCursorPos = StateField.define<number | null>({
|
||||||
|
create() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
update(pos, tr) {
|
||||||
|
if (pos !== null) pos = tr.changes.mapPos(pos);
|
||||||
|
return tr.effects.reduce((p, e) => (e.is(setDropCursorPos) ? e.value : p), pos);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface MeasureRequest<T> {
|
||||||
|
read(view: EditorView): T;
|
||||||
|
write?(measure: T, view: EditorView): void;
|
||||||
|
key?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is a modification of the CodeMirror dropCursor
|
||||||
|
// This version hooks into the state of the NDV drag-n-drop
|
||||||
|
//
|
||||||
|
// We can't use CodeMirror's dropCursor because it depends on HTML drag events while our drag-and-drop uses mouse events
|
||||||
|
// We could switch to drag events later but some features of the current drag-n-drop might not be possible with drag events
|
||||||
|
const drawDropCursor = ViewPlugin.fromClass(
|
||||||
|
class {
|
||||||
|
cursor: HTMLElement | null = null;
|
||||||
|
|
||||||
|
measureReq: MeasureRequest<{ left: number; top: number; height: number } | null>;
|
||||||
|
|
||||||
|
ndvStore: ReturnType<typeof useNDVStore>;
|
||||||
|
|
||||||
|
constructor(readonly view: EditorView) {
|
||||||
|
this.measureReq = { read: this.readPos.bind(this), write: this.drawCursor.bind(this) };
|
||||||
|
this.ndvStore = useNDVStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
const cursorPos = update.state.field(dropCursorPos);
|
||||||
|
if (cursorPos === null) {
|
||||||
|
if (this.cursor !== null) {
|
||||||
|
this.cursor?.remove();
|
||||||
|
this.cursor = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!this.cursor) {
|
||||||
|
this.cursor = this.view.scrollDOM.appendChild(document.createElement('div'));
|
||||||
|
this.cursor.className = 'cm-dropCursor';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
update.startState.field(dropCursorPos) !== cursorPos ||
|
||||||
|
update.docChanged ||
|
||||||
|
update.geometryChanged
|
||||||
|
)
|
||||||
|
this.view.requestMeasure(this.measureReq);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readPos(): { left: number; top: number; height: number } | null {
|
||||||
|
const { view } = this;
|
||||||
|
const pos = view.state.field(dropCursorPos);
|
||||||
|
const rect = pos !== null && view.coordsAtPos(pos);
|
||||||
|
if (!rect) return null;
|
||||||
|
const outer = view.scrollDOM.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
left: rect.left - outer.left + view.scrollDOM.scrollLeft * view.scaleX,
|
||||||
|
top: rect.top - outer.top + view.scrollDOM.scrollTop * view.scaleY,
|
||||||
|
height: rect.bottom - rect.top,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
drawCursor(pos: { left: number; top: number; height: number } | null) {
|
||||||
|
if (this.cursor) {
|
||||||
|
const { scaleX, scaleY } = this.view;
|
||||||
|
if (pos) {
|
||||||
|
this.cursor.style.left = pos.left / scaleX + 'px';
|
||||||
|
this.cursor.style.top = pos.top / scaleY + 'px';
|
||||||
|
this.cursor.style.height = pos.height / scaleY + 'px';
|
||||||
|
} else {
|
||||||
|
this.cursor.style.left = '-100000px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this.cursor) this.cursor.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
setDropPos(pos: number | null) {
|
||||||
|
if (this.view.state.field(dropCursorPos) !== pos)
|
||||||
|
this.view.dispatch({ effects: setDropCursorPos.of(pos) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventObservers: {
|
||||||
|
mousemove(event) {
|
||||||
|
if (!this.ndvStore.isDraggableDragging || this.ndvStore.draggableType !== 'mapping') return;
|
||||||
|
const pos = this.view.posAtCoords(eventToCoord(event), false);
|
||||||
|
this.setDropPos(pos);
|
||||||
|
},
|
||||||
|
mouseleave() {
|
||||||
|
this.setDropPos(null);
|
||||||
|
},
|
||||||
|
mouseup() {
|
||||||
|
this.setDropPos(null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function eventToCoord(event: MouseEvent): { x: number; y: number } {
|
||||||
|
return { x: event.clientX, y: event.clientY };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dropInEditor(view: EditorView, event: MouseEvent, value: string) {
|
||||||
|
const dropPos = view.posAtCoords(eventToCoord(event), false);
|
||||||
|
|
||||||
|
const node = syntaxTree(view.state).resolve(dropPos);
|
||||||
|
let valueToInsert = value;
|
||||||
|
|
||||||
|
// We are already in an expression, do not insert brackets
|
||||||
|
if (node.name === 'Resolvable') {
|
||||||
|
valueToInsert = unwrapExpression(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const changes = view.state.changes({ from: dropPos, insert: valueToInsert });
|
||||||
|
const anchor = changes.mapPos(dropPos, -1);
|
||||||
|
const head = changes.mapPos(dropPos, 1);
|
||||||
|
const selection = EditorSelection.single(anchor, head);
|
||||||
|
|
||||||
|
view.dispatch({
|
||||||
|
changes,
|
||||||
|
selection,
|
||||||
|
userEvent: 'input.drop',
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => view.focus());
|
||||||
|
|
||||||
|
return selection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mappingDropCursor(): Extension {
|
||||||
|
return [dropCursorPos, drawDropCursor];
|
||||||
|
}
|
|
@ -769,7 +769,6 @@
|
||||||
"expressionEdit.editExpression": "Edit Expression",
|
"expressionEdit.editExpression": "Edit Expression",
|
||||||
"expressionEdit.expression": "Expression",
|
"expressionEdit.expression": "Expression",
|
||||||
"expressionEdit.resultOfItem1": "Result of item 1",
|
"expressionEdit.resultOfItem1": "Result of item 1",
|
||||||
"expressionEdit.variableSelector": "Variable Selector",
|
|
||||||
"expressionEditor.uncalledFunction": "[this is a function, please add ()]",
|
"expressionEditor.uncalledFunction": "[this is a function, please add ()]",
|
||||||
"expressionModalInput.empty": "[empty]",
|
"expressionModalInput.empty": "[empty]",
|
||||||
"expressionModalInput.undefined": "[undefined]",
|
"expressionModalInput.undefined": "[undefined]",
|
||||||
|
@ -2018,18 +2017,6 @@
|
||||||
"updatesPanel.version": "{numberOfVersions} version{howManySuffix}",
|
"updatesPanel.version": "{numberOfVersions} version{howManySuffix}",
|
||||||
"updatesPanel.weVeBeenBusy": "We’ve been busy ✨",
|
"updatesPanel.weVeBeenBusy": "We’ve been busy ✨",
|
||||||
"updatesPanel.youReOnVersion": "You’re on {currentVersionName}, which was released",
|
"updatesPanel.youReOnVersion": "You’re on {currentVersionName}, which was released",
|
||||||
"variableSelector.context": "Context",
|
|
||||||
"variableSelector.currentNode": "Current Node",
|
|
||||||
"variableSelector.nodes": "Nodes",
|
|
||||||
"variableSelector.outputData": "Output Data",
|
|
||||||
"variableSelector.parameters": "Parameters",
|
|
||||||
"variableSelector.variableFilter": "Variable filter...",
|
|
||||||
"variableSelectorItem.binary": "Binary",
|
|
||||||
"variableSelectorItem.currentNode": "Current Node",
|
|
||||||
"variableSelectorItem.empty": "--- EMPTY ---",
|
|
||||||
"variableSelectorItem.inputData": "Input Data",
|
|
||||||
"variableSelectorItem.json": "JSON",
|
|
||||||
"variableSelectorItem.selectItem": "Select Item",
|
|
||||||
"versionCard.breakingChanges": "Breaking changes",
|
"versionCard.breakingChanges": "Breaking changes",
|
||||||
"versionCard.released": "Released",
|
"versionCard.released": "Released",
|
||||||
"versionCard.securityUpdate": "Security update",
|
"versionCard.securityUpdate": "Security update",
|
||||||
|
|
|
@ -44,15 +44,7 @@ interface UpdatedWorkflowSettingsEventData {
|
||||||
interface NodeTypeChangedEventData {
|
interface NodeTypeChangedEventData {
|
||||||
nodeSubtitle?: string;
|
nodeSubtitle?: string;
|
||||||
}
|
}
|
||||||
interface InsertedItemFromExpEditorEventData {
|
|
||||||
parameter: {
|
|
||||||
displayName: string;
|
|
||||||
};
|
|
||||||
value: string;
|
|
||||||
selectedItem: {
|
|
||||||
variable: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
interface ExpressionEditorEventsData {
|
interface ExpressionEditorEventsData {
|
||||||
dialogVisible: boolean;
|
dialogVisible: boolean;
|
||||||
value: string;
|
value: string;
|
||||||
|
@ -180,7 +172,6 @@ export interface ExternalHooks {
|
||||||
>;
|
>;
|
||||||
};
|
};
|
||||||
expressionEdit: {
|
expressionEdit: {
|
||||||
itemSelected: Array<ExternalHooksMethod<InsertedItemFromExpEditorEventData>>;
|
|
||||||
dialogVisibleChanged: Array<ExternalHooksMethod<ExpressionEditorEventsData>>;
|
dialogVisibleChanged: Array<ExternalHooksMethod<ExpressionEditorEventsData>>;
|
||||||
closeDialog: Array<ExternalHooksMethod<ITelemetryTrackProperties>>;
|
closeDialog: Array<ExternalHooksMethod<ITelemetryTrackProperties>>;
|
||||||
mounted: Array<
|
mounted: Array<
|
||||||
|
@ -231,9 +222,6 @@ export interface ExternalHooks {
|
||||||
userInfo: {
|
userInfo: {
|
||||||
mounted: Array<ExternalHooksMethod<{ userInfoRef: HTMLElement }>>;
|
mounted: Array<ExternalHooksMethod<{ userInfoRef: HTMLElement }>>;
|
||||||
};
|
};
|
||||||
variableSelectorItem: {
|
|
||||||
mounted: Array<ExternalHooksMethod<{ variableSelectorItemRef: HTMLElement }>>;
|
|
||||||
};
|
|
||||||
mainSidebar: {
|
mainSidebar: {
|
||||||
mounted: Array<ExternalHooksMethod<{ userRef: Element }>>;
|
mounted: Array<ExternalHooksMethod<{ userRef: Element }>>;
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,38 +1,46 @@
|
||||||
import { ExpressionError } from 'n8n-workflow';
|
import { ExpressionError } from 'n8n-workflow';
|
||||||
import { stringifyExpressionResult } from '../expressions';
|
import { stringifyExpressionResult, unwrapExpression } from '../expressions';
|
||||||
|
|
||||||
describe('stringifyExpressionResult()', () => {
|
describe('Utils: Expressions', () => {
|
||||||
it('should return empty string for non-critical errors', () => {
|
describe('stringifyExpressionResult()', () => {
|
||||||
expect(
|
it('should return empty string for non-critical errors', () => {
|
||||||
stringifyExpressionResult({
|
expect(
|
||||||
ok: false,
|
stringifyExpressionResult({
|
||||||
error: new ExpressionError('error message', { type: 'no_execution_data' }),
|
ok: false,
|
||||||
}),
|
error: new ExpressionError('error message', { type: 'no_execution_data' }),
|
||||||
).toEqual('');
|
}),
|
||||||
|
).toEqual('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return an error message for critical errors', () => {
|
||||||
|
expect(
|
||||||
|
stringifyExpressionResult({
|
||||||
|
ok: false,
|
||||||
|
error: new ExpressionError('error message', { type: 'no_input_connection' }),
|
||||||
|
}),
|
||||||
|
).toEqual('[ERROR: No input connected]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty string when result is null', () => {
|
||||||
|
expect(stringifyExpressionResult({ ok: true, result: null })).toEqual('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return NaN when result is NaN', () => {
|
||||||
|
expect(stringifyExpressionResult({ ok: true, result: NaN })).toEqual('NaN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return [empty] message when result is empty string', () => {
|
||||||
|
expect(stringifyExpressionResult({ ok: true, result: '' })).toEqual('[empty]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the result when it is a string', () => {
|
||||||
|
expect(stringifyExpressionResult({ ok: true, result: 'foo' })).toEqual('foo');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return an error message for critical errors', () => {
|
describe('unwrapExpression', () => {
|
||||||
expect(
|
it('should remove the brackets around an expression', () => {
|
||||||
stringifyExpressionResult({
|
expect(unwrapExpression('{{ $json.foo }}')).toBe('$json.foo');
|
||||||
ok: false,
|
});
|
||||||
error: new ExpressionError('error message', { type: 'no_input_connection' }),
|
|
||||||
}),
|
|
||||||
).toEqual('[ERROR: No input connected]');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return empty string when result is null', () => {
|
|
||||||
expect(stringifyExpressionResult({ ok: true, result: null })).toEqual('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return NaN when result is NaN', () => {
|
|
||||||
expect(stringifyExpressionResult({ ok: true, result: NaN })).toEqual('NaN');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return [empty] message when result is empty string', () => {
|
|
||||||
expect(stringifyExpressionResult({ ok: true, result: '' })).toEqual('[empty]');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return the result when it is a string', () => {
|
|
||||||
expect(stringifyExpressionResult({ ok: true, result: 'foo' })).toEqual('foo');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -12,6 +12,10 @@ export const isEmptyExpression = (expr: string) => {
|
||||||
return /\{\{\s*\}\}/.test(expr);
|
return /\{\{\s*\}\}/.test(expr);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const unwrapExpression = (expr: string) => {
|
||||||
|
return expr.replace(/\{\{(.*)\}\}/, '$1').trim();
|
||||||
|
};
|
||||||
|
|
||||||
export const removeExpressionPrefix = (expr: string) => {
|
export const removeExpressionPrefix = (expr: string) => {
|
||||||
return expr.startsWith('=') ? expr.slice(1) : expr;
|
return expr.startsWith('=') ? expr.slice(1) : expr;
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in a new issue