feat(editor): Add schema view to expression modal (#9976)

This commit is contained in:
Elias Meire 2024-08-12 16:47:14 +02:00 committed by GitHub
parent 9d7caacc69
commit 71b6c67179
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 927 additions and 1793 deletions

View file

@ -44,7 +44,7 @@ describe('Data mapping', () => {
ndv.actions.mapDataFromHeader(2, 'value');
ndv.getters
.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', () => {
@ -145,8 +145,8 @@ describe('Data mapping', () => {
ndv.actions.mapToParameter('value');
ndv.getters
.inlineExpressionEditorInput()
.should('have.text', '{{ $json.input[0].count }} {{ $json.input }}');
ndv.actions.validateExpressionPreview('value', '0 [object Object]');
.should('have.text', '{{ $json.input }}{{ $json.input[0].count }}');
ndv.actions.validateExpressionPreview('value', '[object Object]0');
});
it('maps expressions from schema view', () => {
@ -170,8 +170,8 @@ describe('Data mapping', () => {
ndv.actions.mapToParameter('value');
ndv.getters
.inlineExpressionEditorInput()
.should('have.text', '{{ $json.input[0].count }} {{ $json.input }}');
ndv.actions.validateExpressionPreview('value', '0 [object Object]');
.should('have.text', '{{ $json.input }}{{ $json.input[0].count }}');
ndv.actions.validateExpressionPreview('value', '[object Object]0');
});
it('maps expressions from previous nodes', () => {
@ -200,17 +200,17 @@ describe('Data mapping', () => {
.inlineExpressionEditorInput()
.should(
'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.getters.executingLoader().should('not.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.actions.validateExpressionPreview('value', '1 [object Object]');
ndv.actions.validateExpressionPreview('value', '[object Object]1');
});
it('maps keys to path', () => {
@ -284,8 +284,8 @@ describe('Data mapping', () => {
ndv.actions.mapToParameter('value');
ndv.getters
.inlineExpressionEditorInput()
.should('have.text', '{{ $json.input[0].count }} {{ $json.input }}');
ndv.actions.validateExpressionPreview('value', '0 [object Object]');
.should('have.text', '{{ $json.input }}{{ $json.input[0].count }}');
ndv.actions.validateExpressionPreview('value', '[object Object]0');
});
it('renders expression preview when a previous node is selected', () => {
@ -342,4 +342,27 @@ describe('Data mapping', () => {
.invoke('css', 'border')
.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 }}');
});
});

View file

@ -205,9 +205,9 @@ export class NDV extends BasePage {
const droppable = `[data-test-id="parameter-input-${parameterName}"]`;
cy.draganddrop(draggable, droppable);
},
mapToParameter: (parameterName: string) => {
mapToParameter: (parameterName: string, position?: 'top' | 'center' | 'bottom') => {
const droppable = `[data-test-id="parameter-input-${parameterName}"]`;
cy.draganddrop('', droppable);
cy.draganddrop('', droppable, { position });
},
switchInputMode: (type: 'Schema' | 'Table' | 'JSON' | 'Binary') => {
this.getters.inputDisplayMode().find('label').contains(type).click({ force: true });

View file

@ -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) {
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(pageX, pageY);
cy.get(droppableSelector).realHover();
cy.get(droppableSelector).realMouseUp();
cy.get(droppableSelector).realMouseUp({ position: options?.position ?? 'top' });
if (draggableSelector) {
cy.get(draggableSelector).realMouseUp();
}

View file

@ -12,6 +12,10 @@ interface SigninPayload {
password: string;
}
interface DragAndDropOptions {
position: 'top' | 'center' | 'bottom';
}
declare global {
namespace Cypress {
interface SuiteConfigOverrides {
@ -56,7 +60,11 @@ declare global {
target: [number, number],
options?: { abs?: boolean; index?: number; realMouse?: boolean; clickToFinish?: boolean },
): void;
draganddrop(draggableSelector: string, droppableSelector: string): void;
draganddrop(
draggableSelector: string,
droppableSelector: string,
options?: Partial<DragAndDropOptions>,
): void;
push(type: string, data: unknown): void;
shouldNotHaveConsoleErrors(): void;
window(): Chainable<

View file

@ -101,6 +101,7 @@
--color-pending-resolvable-foreground: var(--color-text-base);
--color-pending-resolvable-background: var(--prim-gray-70-alpha-01);
--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-autocomplete-item-selected: var(--prim-color-secondary-tint-200);
--color-autocomplete-section-header-border: var(--prim-gray-540);

View file

@ -135,6 +135,7 @@
--color-pending-resolvable-foreground: var(--color-text-base);
--color-pending-resolvable-background: var(--prim-gray-40);
--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-autocomplete-item-selected: var(--color-secondary);
--color-autocomplete-section-header-border: var(--color-foreground-light);

View file

@ -207,19 +207,6 @@ export interface ITableData {
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
export interface IWorkflowData {
id?: string;

View file

@ -26,7 +26,7 @@ const props = withDefaults(defineProps<Props>(), {
});
const emit = defineEmits<{
drop: [value: string];
drop: [value: string, event: MouseEvent];
}>();
const hovering = ref(false);
@ -60,10 +60,10 @@ function onMouseLeave() {
hovering.value = false;
}
function onMouseUp() {
function onMouseUp(event: MouseEvent) {
if (activeDrop.value) {
const data = ndvStore.draggableData;
emit('drop', data);
emit('drop', data, event);
}
}

View file

@ -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>

View 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>

View file

@ -5,8 +5,8 @@
<script setup lang="ts">
import { history } from '@codemirror/commands';
import { Prec } from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view';
import { computed, onMounted, ref, toValue, watch } from 'vue';
import { dropCursor, EditorView, keymap } from '@codemirror/view';
import { computed, onMounted, ref, watch } from 'vue';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
@ -14,7 +14,6 @@ import { forceParse } from '@/utils/forceParse';
import { completionStatus } from '@codemirror/autocomplete';
import { inputTheme } from './theme';
import type { IVariableItemSelected } from '@/Interface';
import { useExpressionEditor } from '@/composables/useExpressionEditor';
import {
autocompleteKeyMap,
@ -22,9 +21,10 @@ import {
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { infoBoxTooltips } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
import type { Segment } from '@/types/expressions';
import { removeExpressionPrefix } from '@/utils/expressions';
import { infoBoxTooltips } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
type Props = {
modelValue: string;
@ -44,7 +44,7 @@ const emit = defineEmits<{
const root = ref<HTMLElement>();
const extensions = computed(() => [
inputTheme(),
inputTheme(props.isReadOnly),
Prec.highest(
keymap.of([
...tabKeyMap(),
@ -65,6 +65,8 @@ const extensions = computed(() => [
),
n8nLang(),
n8nAutocompletion(),
mappingDropCursor(),
dropCursor(),
history(),
expressionInputHandler(),
EditorView.lineWrapping,
@ -72,14 +74,7 @@ const extensions = computed(() => [
infoBoxTooltips(),
]);
const editorValue = ref<string>(removeExpressionPrefix(props.modelValue));
const {
editor: editorRef,
segments,
readEditorValue,
setCursorPosition,
hasFocus,
focus,
} = useExpressionEditor({
const { segments, readEditorValue, editor, hasFocus, focus } = useExpressionEditor({
editorRef: root,
editorValue,
extensions,
@ -111,45 +106,11 @@ onMounted(() => {
focus();
});
function itemSelected({ variable }: IVariableItemSelected) {
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 });
defineExpose({ editor });
</script>
<style lang="scss" module>
.editor div[contenteditable='false'] {
background-color: var(--disabled-fill, var(--color-background-light));
cursor: not-allowed;
}
</style>
<style lang="scss" scoped>
:deep(.cm-content) {
:global(.cm-content) {
border-radius: var(--border-radius-base);
}
</style>

View file

@ -1,7 +1,7 @@
import { EditorView } from '@codemirror/view';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
const commonThemeProps = {
const commonThemeProps = (isReadOnly = false) => ({
'&': {
borderWidth: 'var(--border-width-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))',
backgroundColor: 'var(--color-expression-editor-background)',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--color-code-caret)',
},
'&.cm-focused': {
borderColor: 'var(--color-secondary)',
outline: '0 !important',
},
'.cm-content': {
fontFamily: 'var(--font-family-monospace)',
height: '220px',
padding: 'var(--spacing-xs)',
color: 'var(--input-font-color, var(--color-text-dark))',
caretColor: 'var(--color-code-caret)',
caretColor: isReadOnly ? 'transparent' : 'var(--color-code-caret)',
},
'.cm-line': {
padding: '0',
},
};
});
export const inputTheme = () => {
const theme = EditorView.theme(commonThemeProps);
export const inputTheme = (isReadOnly = false) => {
const theme = EditorView.theme(commonThemeProps(isReadOnly));
return [theme, highlighter.resolvableStyle];
};
export const outputTheme = () => {
const theme = EditorView.theme({
...commonThemeProps,
...commonThemeProps(true),
'.cm-valid-resolvable': {
padding: '0 2px',
borderRadius: '2px',

View file

@ -1,5 +1,5 @@
<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 InlineExpressionEditorInput from '@/components/InlineExpressionEditor/InlineExpressionEditorInput.vue';
@ -9,10 +9,12 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
import { createExpressionTelemetryPayload } from '@/utils/telemetryUtils';
import { useTelemetry } from '@/composables/useTelemetry';
import { dropInEditor } from '@/plugins/codemirror/dragAndDrop';
import type { Segment } from '@/types/expressions';
import { createEventBus, type EventBus } from 'n8n-design-system/utils';
import type { IDataObject } from 'n8n-workflow';
import { startCompletion } from '@codemirror/autocomplete';
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 segments = ref<Segment[]>([]);
@ -25,9 +27,9 @@ type Props = {
modelValue: string;
rows?: number;
additionalExpressionData?: IDataObject;
eventBus?: EventBus;
isReadOnly?: boolean;
isAssignment?: boolean;
eventBus?: EventBus;
};
const props = withDefaults(defineProps<Props>(), {
@ -109,6 +111,45 @@ function onSelectionChange({
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) => {
if (newIsDragging) {
onBlur();
@ -134,19 +175,23 @@ defineExpose({ focus });
<span v-if="isAssignment">=</span>
<ExpressionFunctionIcon v-else />
</div>
<InlineExpressionEditorInput
ref="inlineInput"
:model-value="modelValue"
:path="path"
:is-read-only="isReadOnly"
:rows="rows"
:additional-data="additionalExpressionData"
:event-bus="eventBus"
@focus="onFocus"
@blur="onBlur"
@update:model-value="onValueChange"
@update:selection="onSelectionChange"
/>
<DraggableTarget type="mapping" :disabled="isReadOnly" @drop="onDrop">
<template #default="{ activeDrop, droppable }">
<InlineExpressionEditorInput
ref="inlineInput"
:model-value="modelValue"
:path="path"
:is-read-only="isReadOnly"
:rows="rows"
:additional-data="additionalExpressionData"
:class="{ [$style.activeDrop]: activeDrop, [$style.droppable]: droppable }"
@focus="onFocus"
@blur="onBlur"
@update:model-value="onValueChange"
@update:selection="onSelectionChange"
/>
</template>
</DraggableTarget>
<n8n-button
v-if="!isDragging"
square
@ -240,4 +285,26 @@ defineExpose({ focus });
border-bottom-right-radius: 0;
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>

View file

@ -1,11 +1,11 @@
<script setup lang="ts">
import { startCompletion } from '@codemirror/autocomplete';
import { history } from '@codemirror/commands';
import { type EditorState, Prec, type SelectionRange } from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, toValue, watch } from 'vue';
import { dropCursor, EditorView, keymap } from '@codemirror/view';
import { computed, ref, watch } from 'vue';
import { useExpressionEditor } from '@/composables/useExpressionEditor';
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import {
autocompleteKeyMap,
@ -14,13 +14,11 @@ import {
tabKeyMap,
} from '@/plugins/codemirror/keymap';
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 { removeExpressionPrefix } from '@/utils/expressions';
import { createEventBus, type EventBus } from 'n8n-design-system/utils';
import type { IDataObject } from 'n8n-workflow';
import { inputTheme } from './theme';
import { infoBoxTooltips } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
type Props = {
modelValue: string;
@ -28,14 +26,12 @@ type Props = {
rows?: number;
isReadOnly?: boolean;
additionalData?: IDataObject;
eventBus?: EventBus;
};
const props = withDefaults(defineProps<Props>(), {
rows: 5,
isReadOnly: false,
additionalData: () => ({}),
eventBus: () => createEventBus(),
});
const emit = defineEmits<{
@ -44,8 +40,6 @@ const emit = defineEmits<{
focus: [];
}>();
const ndvStore = useNDVStore();
const root = ref<HTMLElement>();
const extensions = computed(() => [
Prec.highest(
@ -55,6 +49,8 @@ const extensions = computed(() => [
n8nAutocompletion(),
inputTheme({ isReadOnly: props.isReadOnly, rows: props.rows }),
history(),
mappingDropCursor(),
dropCursor(),
expressionInputHandler(),
EditorView.lineWrapping,
infoBoxTooltips(),
@ -77,32 +73,6 @@ const {
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(
() => props.modelValue,
(newValue) => {
@ -130,12 +100,15 @@ watch(hasFocus, (focused) => {
if (focused) emit('focus');
});
onMounted(() => {
props.eventBus.on('drop', onDrop);
});
onBeforeUnmount(() => {
props.eventBus.off('drop', onDrop);
defineExpose({
editor: editorRef,
setCursorPosition,
focus: () => {
if (!hasFocus.value) {
setCursorPosition('lastExpression');
focus();
}
},
});
</script>

View file

@ -2,13 +2,13 @@
import type { EditorState, SelectionRange } from '@codemirror/state';
import { useI18n } from '@/composables/useI18n';
import { useNDVStore } from '@/stores/ndv.store';
import type { Segment } from '@/types/expressions';
import { onBeforeUnmount } from 'vue';
import ExpressionOutput from './ExpressionOutput.vue';
import OutputItemSelect from './OutputItemSelect.vue';
import InlineExpressionTip from './InlineExpressionTip.vue';
import { outputTheme } from './theme';
import { computed, onBeforeUnmount } from 'vue';
import { useNDVStore } from '@/stores/ndv.store';
import { N8nTooltip } from 'n8n-design-system/components';
interface InlineExpressionEditorOutputProps {
segments: Segment[];
@ -29,31 +29,6 @@ const i18n = useI18n();
const theme = outputTheme();
const ndvStore = useNDVStore();
const hideTableHoverHint = computed(() => ndvStore.isTableHoverOnboarded);
const hoveringItem = computed(() => ndvStore.getHoveringItem);
const hoveringItemIndex = computed(() => hoveringItem.value?.itemIndex);
const isHoveringItem = computed(() => Boolean(hoveringItem.value));
const itemsLength = computed(() => ndvStore.ndvInputDataWithPinnedData.length);
const itemIndex = computed(() => hoveringItemIndex.value ?? ndvStore.expressionOutputItemIndex);
const max = computed(() => Math.max(itemsLength.value - 1, 0));
const isItemIndexEditable = computed(() => !isHoveringItem.value && itemsLength.value > 0);
const canSelectPrevItem = computed(() => isItemIndexEditable.value && itemIndex.value !== 0);
const canSelectNextItem = computed(
() => isItemIndexEditable.value && itemIndex.value < itemsLength.value - 1,
);
function updateItemIndex(index: number) {
ndvStore.expressionOutputItemIndex = index;
}
function nextItem() {
ndvStore.expressionOutputItemIndex = ndvStore.expressionOutputItemIndex + 1;
}
function prevItem() {
ndvStore.expressionOutputItemIndex = ndvStore.expressionOutputItemIndex - 1;
}
onBeforeUnmount(() => {
ndvStore.expressionOutputItemIndex = 0;
});
@ -66,48 +41,7 @@ onBeforeUnmount(() => {
{{ i18n.baseText('parameterInput.result') }}
</n8n-text>
<div :class="$style.item">
<n8n-text size="small" color="text-base" compact>
{{ i18n.baseText('parameterInput.item') }}
</n8n-text>
<div :class="$style.controls">
<N8nInputNumber
data-test-id="inline-expression-editor-item-input"
size="mini"
:controls="false"
:class="[$style.input, { [$style.hovering]: isHoveringItem }]"
:min="0"
:max="max"
:model-value="itemIndex"
@update:model-value="updateItemIndex"
></N8nInputNumber>
<N8nIconButton
data-test-id="inline-expression-editor-item-prev"
icon="chevron-left"
type="tertiary"
text
size="mini"
:disabled="!canSelectPrevItem"
@click="prevItem"
></N8nIconButton>
<N8nTooltip placement="right" :disabled="hideTableHoverHint">
<template #content>
<div>{{ i18n.baseText('parameterInput.hoverTableItemTip') }}</div>
</template>
<N8nIconButton
data-test-id="inline-expression-editor-item-next"
icon="chevron-right"
type="tertiary"
text
size="mini"
:disabled="!canSelectNextItem"
@click="nextItem"
></N8nIconButton>
</N8nTooltip>
</div>
</div>
<OutputItemSelect />
</div>
<n8n-text :class="$style.body">
<ExpressionOutput
@ -164,12 +98,6 @@ onBeforeUnmount(() => {
padding-top: var(--spacing-2xs);
}
.item {
display: flex;
align-items: center;
gap: var(--spacing-4xs);
}
.body {
padding-top: 0;
padding-left: var(--spacing-2xs);
@ -179,33 +107,5 @@ onBeforeUnmount(() => {
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>

View file

@ -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>

View file

@ -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)))',
backgroundColor: 'white',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--color-code-caret)',
},
'.cm-scroller': {
lineHeight: '1.68',
},

View file

@ -1,16 +1,18 @@
<template>
<div ref="wrapper" :class="parameterInputClasses" @keydown.stop>
<ExpressionEdit
<ExpressionEditModal
:dialog-visible="expressionEditDialogVisible"
:model-value="modelValueExpressionEdit"
:parameter="parameter"
:node="node"
:path="path"
:event-source="eventSource || 'ndv'"
:is-read-only="isReadOnly"
:redact-values="shouldRedactValue"
@close-dialog="closeExpressionEditDialog"
@update:model-value="expressionUpdated"
></ExpressionEdit>
></ExpressionEditModal>
<div class="parameter-input ignore-key-press" :style="parameterInputWrapperStyle">
<ResourceLocator
v-if="isResourceLocatorParameter"
@ -497,7 +499,7 @@ import { CREDENTIAL_EMPTY_VALUE, NodeHelpers } from 'n8n-workflow';
import CodeNodeEditor from '@/components/CodeNodeEditor/CodeNodeEditor.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 HtmlEditor from '@/components/HtmlEditor/HtmlEditor.vue';
import JsEditor from '@/components/JsEditor/JsEditor.vue';

View file

@ -24,13 +24,12 @@
<DraggableTarget
type="mapping"
:disabled="isDropDisabled"
:sticky="true"
:sticky-offset="isExpression ? [26, 3] : [3, 3]"
sticky
:sticky-offset="[3, 3]"
@drop="onDrop"
>
<template #default="{ droppable, activeDrop }">
<ParameterInputWrapper
ref="param"
:parameter="parameter"
:model-value="value"
:path="path"
@ -79,7 +78,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { IUpdateInformation } from '@/Interface';
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 isResourceLocator = computed(() => props.parameter.type === 'resourceLocator');
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 showExpressionSelector = computed(() =>

View file

@ -435,6 +435,7 @@
:output-index="currentOutputIndex"
:total-runs="maxRunIndex"
:search="search"
:class="$style.schema"
@clear:search="onSearchClear"
/>
</Suspense>
@ -2008,6 +2009,10 @@ export default defineComponent({
margin-left: var(--spacing-s);
margin-right: var(--spacing-s);
}
.schema {
padding: 0 var(--spacing-s);
}
</style>
<style lang="scss" scoped>

View file

@ -7,11 +7,12 @@ import NodeIcon from '@/components/NodeIcon.vue';
import Draggable from '@/components/Draggable.vue';
import { useNDVStore } from '@/stores/ndv.store';
import { telemetry } from '@/plugins/telemetry';
import type {
ConnectionTypes,
IConnectedNode,
IDataObject,
INodeTypeDescription,
import {
NodeConnectionType,
type ConnectionTypes,
type IConnectedNode,
type IDataObject,
type INodeTypeDescription,
} from 'n8n-workflow';
import { useExternalHooks } from '@/composables/useExternalHooks';
import { i18n } from '@/plugins/i18n';
@ -27,13 +28,13 @@ type Props = {
nodes?: IConnectedNode[];
node?: INodeUi | null;
data?: IDataObject[];
mappingEnabled: boolean;
runIndex: number;
outputIndex: number;
totalRuns: number;
mappingEnabled?: boolean;
runIndex?: number;
outputIndex?: number;
totalRuns?: number;
paneType: 'input' | 'output';
connectionType: ConnectionTypes;
search: string;
connectionType?: ConnectionTypes;
search?: string;
};
type SchemaNode = {
@ -51,6 +52,12 @@ const props = withDefaults(defineProps<Props>(), {
distanceFromActive: 1,
node: null,
data: undefined,
runIndex: 0,
outputIndex: 0,
totalRuns: 1,
connectionType: NodeConnectionType.Main,
search: '',
mappingEnabled: false,
});
const draggingPath = ref<string>('');
@ -414,9 +421,7 @@ watch(
--title-spacing-left: 38px;
display: flex;
flex-direction: column;
padding: 0 0 var(--spacing-s) var(--spacing-s);
container: schema / inline-size;
min-height: 100%;
&.animating {
overflow: hidden;
@ -437,7 +442,6 @@ watch(
.schema {
display: grid;
padding-right: var(--spacing-s);
grid-template-rows: 1fr;
&.animated {
@ -480,7 +484,6 @@ watch(
top: 0;
z-index: 1;
padding-bottom: var(--spacing-2xs);
padding-right: var(--spacing-s);
background: var(--color-run-data-background);
}

View file

@ -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>

View file

@ -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>

View file

@ -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();
});
});
});

View 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];
}

View file

@ -769,7 +769,6 @@
"expressionEdit.editExpression": "Edit Expression",
"expressionEdit.expression": "Expression",
"expressionEdit.resultOfItem1": "Result of item 1",
"expressionEdit.variableSelector": "Variable Selector",
"expressionEditor.uncalledFunction": "[this is a function, please add ()]",
"expressionModalInput.empty": "[empty]",
"expressionModalInput.undefined": "[undefined]",
@ -2018,18 +2017,6 @@
"updatesPanel.version": "{numberOfVersions} version{howManySuffix}",
"updatesPanel.weVeBeenBusy": "Weve been busy ✨",
"updatesPanel.youReOnVersion": "Youre 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.released": "Released",
"versionCard.securityUpdate": "Security update",

View file

@ -44,15 +44,7 @@ interface UpdatedWorkflowSettingsEventData {
interface NodeTypeChangedEventData {
nodeSubtitle?: string;
}
interface InsertedItemFromExpEditorEventData {
parameter: {
displayName: string;
};
value: string;
selectedItem: {
variable: string;
};
}
interface ExpressionEditorEventsData {
dialogVisible: boolean;
value: string;
@ -180,7 +172,6 @@ export interface ExternalHooks {
>;
};
expressionEdit: {
itemSelected: Array<ExternalHooksMethod<InsertedItemFromExpEditorEventData>>;
dialogVisibleChanged: Array<ExternalHooksMethod<ExpressionEditorEventsData>>;
closeDialog: Array<ExternalHooksMethod<ITelemetryTrackProperties>>;
mounted: Array<
@ -231,9 +222,6 @@ export interface ExternalHooks {
userInfo: {
mounted: Array<ExternalHooksMethod<{ userInfoRef: HTMLElement }>>;
};
variableSelectorItem: {
mounted: Array<ExternalHooksMethod<{ variableSelectorItemRef: HTMLElement }>>;
};
mainSidebar: {
mounted: Array<ExternalHooksMethod<{ userRef: Element }>>;
};

View file

@ -1,38 +1,46 @@
import { ExpressionError } from 'n8n-workflow';
import { stringifyExpressionResult } from '../expressions';
import { stringifyExpressionResult, unwrapExpression } from '../expressions';
describe('stringifyExpressionResult()', () => {
it('should return empty string for non-critical errors', () => {
expect(
stringifyExpressionResult({
ok: false,
error: new ExpressionError('error message', { type: 'no_execution_data' }),
}),
).toEqual('');
describe('Utils: Expressions', () => {
describe('stringifyExpressionResult()', () => {
it('should return empty string for non-critical errors', () => {
expect(
stringifyExpressionResult({
ok: false,
error: new ExpressionError('error message', { type: 'no_execution_data' }),
}),
).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', () => {
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');
describe('unwrapExpression', () => {
it('should remove the brackets around an expression', () => {
expect(unwrapExpression('{{ $json.foo }}')).toBe('$json.foo');
});
});
});

View file

@ -12,6 +12,10 @@ export const isEmptyExpression = (expr: string) => {
return /\{\{\s*\}\}/.test(expr);
};
export const unwrapExpression = (expr: string) => {
return expr.replace(/\{\{(.*)\}\}/, '$1').trim();
};
export const removeExpressionPrefix = (expr: string) => {
return expr.startsWith('=') ? expr.slice(1) : expr;
};