mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 04:04:06 -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.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 }}');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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<
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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">
|
||||
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>
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)))',
|
||||
backgroundColor: 'white',
|
||||
},
|
||||
'.cm-cursor, .cm-dropCursor': {
|
||||
borderLeftColor: 'var(--color-code-caret)',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
lineHeight: '1.68',
|
||||
},
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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(() =>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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.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": "We’ve been busy ✨",
|
||||
"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.released": "Released",
|
||||
"versionCard.securityUpdate": "Security update",
|
||||
|
|
|
@ -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 }>>;
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue