feat: Add assignment component with drag and drop to Set node (#8283)

Co-authored-by: Giulio Andreini <andreini@netseven.it>
This commit is contained in:
Elias Meire
2024-02-06 18:34:34 +01:00
committed by GitHub
parent c04f92f7fd
commit 2799de491b
53 changed files with 3296 additions and 1060 deletions

View File

@@ -0,0 +1,264 @@
<script setup lang="ts">
import type { IUpdateInformation } from '@/Interface';
import InputTriple from '@/components/InputTriple/InputTriple.vue';
import ParameterInputFull from '@/components/ParameterInputFull.vue';
import ParameterInputHint from '@/components/ParameterInputHint.vue';
import ParameterIssues from '@/components/ParameterIssues.vue';
import { resolveParameter } from '@/mixins/workflowHelpers';
import { isExpression } from '@/utils/expressions';
import { isObject } from '@jsplumb/util';
import type { AssignmentValue, INodeProperties } from 'n8n-workflow';
import { computed, ref } from 'vue';
import TypeSelect from './TypeSelect.vue';
interface Props {
path: string;
modelValue: AssignmentValue;
issues: string[];
hideType?: boolean;
isReadOnly?: boolean;
index?: number;
}
const props = defineProps<Props>();
const assignment = ref<AssignmentValue>(props.modelValue);
const emit = defineEmits<{
(event: 'update:model-value', value: AssignmentValue): void;
(event: 'remove'): void;
}>();
const assignmentTypeToNodeProperty = (
type: string,
): Partial<INodeProperties> & Pick<INodeProperties, 'type'> => {
switch (type) {
case 'boolean':
return {
type: 'options',
default: false,
options: [
{ name: 'false', value: false },
{ name: 'true', value: true },
],
};
case 'array':
case 'object':
case 'any':
return { type: 'string' };
default:
return { type } as INodeProperties;
}
};
const nameParameter = computed<INodeProperties>(() => ({
name: '',
displayName: '',
default: '',
requiresDataPath: 'single',
placeholder: 'name',
type: 'string',
}));
const valueParameter = computed<INodeProperties>(() => {
return {
name: '',
displayName: '',
default: '',
placeholder: 'value',
...assignmentTypeToNodeProperty(assignment.value.type ?? 'string'),
};
});
const hint = computed(() => {
const { value } = assignment.value;
if (typeof value !== 'string' || !value.startsWith('=')) {
return '';
}
try {
const resolvedValue = resolveParameter(value) as unknown;
if (isObject(resolvedValue)) {
return JSON.stringify(resolvedValue);
}
if (typeof resolvedValue === 'boolean' || typeof resolvedValue === 'number') {
return resolvedValue.toString();
}
return resolvedValue as string;
} catch (error) {
return '';
}
});
const valueIsExpression = computed(() => {
const { value } = assignment.value;
return typeof value === 'string' && isExpression(value);
});
const onAssignmentNameChange = (update: IUpdateInformation): void => {
assignment.value.name = update.value as string;
};
const onAssignmentTypeChange = (update: string): void => {
assignment.value.type = update;
if (update === 'boolean' && !valueIsExpression.value) {
assignment.value.value = false;
}
};
const onAssignmentValueChange = (update: IUpdateInformation): void => {
assignment.value.value = update.value as string;
};
const onRemove = (): void => {
emit('remove');
};
const onBlur = (): void => {
emit('update:model-value', assignment.value);
};
</script>
<template>
<div
:class="{
[$style.wrapper]: true,
[$style.hasIssues]: issues.length > 0,
[$style.hasHint]: !!hint,
}"
data-test-id="assignment"
>
<n8n-icon-button
v-if="!isReadOnly"
type="tertiary"
text
size="mini"
icon="trash"
data-test-id="assignment-remove"
:class="$style.remove"
@click="onRemove"
></n8n-icon-button>
<div :class="$style.inputs">
<InputTriple middle-width="100px">
<template #left>
<ParameterInputFull
:key="nameParameter.type"
display-options
hide-label
hide-hint
:rows="3"
:is-read-only="isReadOnly"
:parameter="nameParameter"
:value="assignment.name"
:path="`${path}.name`"
data-test-id="assignment-name"
@update="onAssignmentNameChange"
@blur="onBlur"
/>
</template>
<template v-if="!hideType" #middle>
<TypeSelect
:class="$style.select"
:model-value="assignment.type ?? 'string'"
:is-read-only="isReadOnly"
@update:model-value="onAssignmentTypeChange"
>
</TypeSelect>
</template>
<template #right="{ breakpoint }">
<div :class="$style.value">
<ParameterInputFull
:key="valueParameter.type"
display-options
hide-label
hide-issues
hide-hint
:rows="3"
is-assignment
:is-read-only="isReadOnly"
:options-position="breakpoint === 'default' ? 'top' : 'bottom'"
:parameter="valueParameter"
:value="assignment.value"
:path="`${path}.value`"
data-test-id="assignment-value"
@update="onAssignmentValueChange"
@blur="onBlur"
/>
<ParameterInputHint :class="$style.hint" :hint="hint" single-line />
</div>
</template>
</InputTriple>
</div>
<div :class="$style.status">
<ParameterIssues v-if="issues.length > 0" :issues="issues" />
</div>
</div>
</template>
<style lang="scss" module>
.wrapper {
position: relative;
display: flex;
align-items: flex-end;
gap: var(--spacing-4xs);
&.hasIssues {
--input-border-color: var(--color-danger);
}
&.hasHint {
padding-bottom: var(--spacing-s);
}
&:hover {
.remove {
opacity: 1;
}
}
}
.inputs {
display: flex;
flex-direction: column;
flex-grow: 1;
min-width: 0;
> div {
flex-grow: 1;
}
}
.value {
position: relative;
.hint {
position: absolute;
bottom: calc(var(--spacing-s) * -1);
left: 0;
right: 0;
}
}
.remove {
position: absolute;
left: 0;
top: var(--spacing-l);
opacity: 0;
transition: opacity 100ms ease-in;
}
.status {
align-self: flex-start;
padding-top: 28px;
}
.statusIcon {
padding-left: var(--spacing-4xs);
}
</style>

View File

@@ -0,0 +1,270 @@
<script setup lang="ts">
import { useDebounce } from '@/composables/useDebounce';
import { useI18n } from '@/composables/useI18n';
import { useNDVStore } from '@/stores/ndv.store';
import type {
AssignmentCollectionValue,
AssignmentValue,
INode,
INodeProperties,
} from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import { computed, reactive, watch } from 'vue';
import DropArea from '../DropArea/DropArea.vue';
import ParameterOptions from '../ParameterOptions.vue';
import Assignment from './Assignment.vue';
import { inputDataToAssignments, nameFromExpression, typeFromExpression } from './utils';
interface Props {
parameter: INodeProperties;
value: AssignmentCollectionValue;
path: string;
node: INode | null;
isReadOnly?: boolean;
}
const props = withDefaults(defineProps<Props>(), { isReadOnly: false });
const emit = defineEmits<{
(
event: 'valueChanged',
value: { name: string; node: string; value: AssignmentCollectionValue },
): void;
}>();
const i18n = useI18n();
const state = reactive<{ paramValue: AssignmentCollectionValue }>({
paramValue: {
assignments: props.value.assignments ?? [],
},
});
const ndvStore = useNDVStore();
const { callDebounced } = useDebounce();
const issues = computed(() => {
if (!ndvStore.activeNode) return {};
return ndvStore.activeNode?.issues?.parameters ?? {};
});
const empty = computed(() => state.paramValue.assignments.length === 0);
const activeDragField = computed(() => nameFromExpression(ndvStore.draggableData));
const inputData = computed(() => ndvStore.ndvInputData?.[0]?.json);
const actions = computed(() => {
return [
{
label: i18n.baseText('assignment.addAll'),
value: 'addAll',
disabled: !inputData.value,
},
{
label: i18n.baseText('assignment.clearAll'),
value: 'clearAll',
disabled: state.paramValue.assignments.length === 0,
},
];
});
watch(state.paramValue, (value) => {
void callDebounced(
() => {
emit('valueChanged', { name: props.path, value, node: props.node?.name as string });
},
{ debounceTime: 1000 },
);
});
function addAssignment(): void {
state.paramValue.assignments.push({ id: uuid(), name: '', value: '', type: 'string' });
}
function dropAssignment(expression: string): void {
state.paramValue.assignments.push({
id: uuid(),
name: nameFromExpression(expression),
value: `=${expression}`,
type: typeFromExpression(expression),
});
}
function onAssignmentUpdate(index: number, value: AssignmentValue): void {
state.paramValue.assignments[index] = value;
}
function onAssignmentRemove(index: number): void {
state.paramValue.assignments.splice(index, 1);
}
function getIssues(index: number): string[] {
return issues.value[`${props.parameter.name}.${index}`] ?? [];
}
function optionSelected(action: 'clearAll' | 'addAll') {
if (action === 'clearAll') {
state.paramValue.assignments = [];
} else {
const newAssignments = inputDataToAssignments(inputData.value);
state.paramValue.assignments = state.paramValue.assignments.concat(newAssignments);
}
}
</script>
<template>
<div
:class="{ [$style.assignmentCollection]: true, [$style.empty]: empty }"
:data-test-id="`assignment-collection-${parameter.name}`"
>
<n8n-input-label
:label="parameter.displayName"
:show-expression-selector="false"
size="small"
underline
color="text-dark"
>
<template #options>
<ParameterOptions
:parameter="parameter"
:value="value"
:custom-actions="actions"
:is-read-only="isReadOnly"
:show-expression-selector="false"
@update:model-value="optionSelected"
/>
</template>
</n8n-input-label>
<div :class="$style.content">
<div :class="$style.assignments">
<div v-for="(assignment, index) of state.paramValue.assignments" :key="assignment.id">
<Assignment
:model-value="assignment"
:index="index"
:path="`${path}.${index}`"
:issues="getIssues(index)"
:class="$style.assignment"
:is-read-only="isReadOnly"
@update:model-value="(value) => onAssignmentUpdate(index, value)"
@remove="() => onAssignmentRemove(index)"
>
</Assignment>
</div>
</div>
<div
v-if="!isReadOnly"
:class="$style.dropAreaWrapper"
data-test-id="assignment-collection-drop-area"
@click="addAssignment"
>
<DropArea :sticky-offset="empty ? [-4, 32] : [92, 0]" @drop="dropAssignment">
<template #default="{ active, droppable }">
<div :class="{ [$style.active]: active, [$style.droppable]: droppable }">
<div v-if="droppable" :class="$style.dropArea">
<span>{{ i18n.baseText('assignment.dropField') }}</span>
<span :class="$style.activeField">{{ activeDragField }}</span>
</div>
<div v-else :class="$style.dropArea">
<span>{{ i18n.baseText('assignment.dragFields') }}</span>
<span :class="$style.or">{{ i18n.baseText('assignment.or') }}</span>
<span :class="$style.add">{{ i18n.baseText('assignment.add') }} </span>
</div>
</div>
</template>
</DropArea>
</div>
</div>
</div>
</template>
<style lang="scss" module>
.assignmentCollection {
display: flex;
flex-direction: column;
margin: var(--spacing-xs) 0;
}
.content {
display: flex;
gap: var(--spacing-l);
flex-direction: column;
}
.assignments {
display: flex;
flex-direction: column;
gap: var(--spacing-4xs);
}
.assignment {
padding-left: var(--spacing-l);
}
.dropAreaWrapper {
cursor: pointer;
&:not(.empty .dropAreaWrapper) {
padding-left: var(--spacing-l);
}
&:hover .add {
color: var(--color-primary-shade-1);
}
}
.dropArea {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: center;
font-size: var(--font-size-xs);
color: var(--color-text-dark);
gap: 1ch;
min-height: 24px;
> span {
white-space: nowrap;
}
}
.or {
color: var(--color-text-light);
font-size: var(--font-size-2xs);
}
.add {
color: var(--color-primary);
font-weight: var(--font-weight-bold);
}
.activeField {
font-weight: var(--font-weight-bold);
color: var(--color-ndv-droppable-parameter);
}
.active {
.activeField {
color: var(--color-success);
}
}
.empty {
.dropArea {
flex-direction: column;
align-items: center;
gap: var(--spacing-3xs);
min-height: 20vh;
}
.droppable .dropArea {
flex-direction: row;
gap: 1ch;
}
.content {
gap: var(--spacing-s);
}
}
.icon {
font-size: var(--font-size-2xl);
}
</style>

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import type { BaseTextKey } from '@/plugins/i18n';
import { ASSIGNMENT_TYPES } from './constants';
import { computed } from 'vue';
interface Props {
modelValue: string;
isReadOnly?: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'update:model-value', type: string): void;
}>();
const i18n = useI18n();
const types = ASSIGNMENT_TYPES;
const icon = computed(() => types.find((type) => type.type === props.modelValue)?.icon ?? 'cube');
const onTypeChange = (type: string): void => {
emit('update:model-value', type);
};
</script>
<template>
<n8n-select
data-test-id="assignment-type-select"
size="small"
:model-value="modelValue"
:disabled="isReadOnly"
@update:model-value="onTypeChange"
>
<template #prefix>
<n8n-icon :class="$style.icon" :icon="icon" color="text-light" size="small" />
</template>
<n8n-option
v-for="option in types"
:key="option.type"
:value="option.type"
:label="i18n.baseText(`type.${option.type}` as BaseTextKey)"
:class="$style.option"
>
<n8n-icon
:icon="option.icon"
:color="modelValue === option.type ? 'primary' : 'text-light'"
size="small"
/>
<span>{{ i18n.baseText(`type.${option.type}` as BaseTextKey) }}</span>
</n8n-option>
</n8n-select>
</template>
<style lang="scss" module>
.icon {
color: var(--color-text-light);
}
.option {
display: flex;
gap: var(--spacing-2xs);
align-items: center;
font-size: var(--font-size-s);
}
</style>

View File

@@ -0,0 +1,54 @@
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import Assignment from '../Assignment.vue';
const DEFAULT_SETUP = {
pinia: createTestingPinia(),
props: {
path: 'parameters.fields.0',
modelValue: {
name: '',
type: 'string',
value: '',
},
issues: [],
},
};
const renderComponent = createComponentRenderer(Assignment, DEFAULT_SETUP);
describe('Assignment.vue', () => {
afterEach(() => {
vi.clearAllMocks();
});
it('can edit name, type and value', async () => {
const { getByTestId, baseElement, emitted } = renderComponent();
const nameField = getByTestId('assignment-name').querySelector('input') as HTMLInputElement;
const valueField = getByTestId('assignment-value').querySelector('input') as HTMLInputElement;
expect(getByTestId('assignment')).toBeInTheDocument();
expect(getByTestId('assignment-name')).toBeInTheDocument();
expect(getByTestId('assignment-value')).toBeInTheDocument();
expect(getByTestId('assignment-type-select')).toBeInTheDocument();
await userEvent.type(nameField, 'New name');
await userEvent.type(valueField, 'New value');
await userEvent.click(baseElement.querySelectorAll('.option')[3]);
expect(emitted('update:model-value')[0]).toEqual([
{ name: 'New name', type: 'array', value: 'New value' },
]);
});
it('can remove itself', async () => {
const { getByTestId, emitted } = renderComponent();
await userEvent.click(getByTestId('assignment-remove'));
expect(emitted('remove')).toEqual([[]]);
});
});

View File

@@ -0,0 +1,121 @@
import { createComponentRenderer } from '@/__tests__/render';
import { useNDVStore } from '@/stores/ndv.store';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { fireEvent, within } from '@testing-library/vue';
import * as workflowHelpers from '@/mixins/workflowHelpers';
import AssignmentCollection from '../AssignmentCollection.vue';
import { createPinia, setActivePinia } from 'pinia';
const DEFAULT_SETUP = {
pinia: createTestingPinia(),
props: {
path: 'parameters.fields',
node: {
parameters: {},
id: 'f63efb2d-3cc5-4500-89f9-b39aab19baf5',
name: 'Edit Fields',
type: 'n8n-nodes-base.set',
typeVersion: 3.3,
position: [1120, 380],
credentials: {},
disabled: false,
},
parameter: { name: 'fields', displayName: 'Fields To Set' },
value: {},
},
};
const renderComponent = createComponentRenderer(AssignmentCollection, DEFAULT_SETUP);
const getInput = (e: HTMLElement): HTMLInputElement => {
return e.querySelector('input') as HTMLInputElement;
};
const getAssignmentType = (assignment: HTMLElement): string => {
return getInput(within(assignment).getByTestId('assignment-type-select')).value;
};
async function dropAssignment({
key,
value,
dropArea,
}: {
key: string;
value: unknown;
dropArea: HTMLElement;
}): Promise<void> {
useNDVStore().draggableStartDragging({
type: 'mapping',
data: `{{ $json.${key} }}`,
dimensions: null,
});
vitest.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(value as never);
await userEvent.hover(dropArea);
await fireEvent.mouseUp(dropArea);
}
describe('AssignmentCollection.vue', () => {
afterEach(() => {
vi.clearAllMocks();
});
it('renders empty state properly', async () => {
const { getByTestId, queryByTestId } = renderComponent();
expect(getByTestId('assignment-collection-fields')).toBeInTheDocument();
expect(getByTestId('assignment-collection-fields')).toHaveClass('empty');
expect(getByTestId('assignment-collection-drop-area')).toHaveTextContent(
'Drag input fields here',
);
expect(queryByTestId('assignment')).not.toBeInTheDocument();
});
it('can add and remove assignments', async () => {
const { getByTestId, findAllByTestId } = renderComponent();
await userEvent.click(getByTestId('assignment-collection-drop-area'));
await userEvent.click(getByTestId('assignment-collection-drop-area'));
let assignments = await findAllByTestId('assignment');
expect(assignments.length).toEqual(2);
await userEvent.type(getInput(within(assignments[1]).getByTestId('assignment-name')), 'second');
await userEvent.type(
getInput(within(assignments[1]).getByTestId('assignment-value')),
'secondValue',
);
await userEvent.click(within(assignments[0]).getByTestId('assignment-remove'));
assignments = await findAllByTestId('assignment');
expect(assignments.length).toEqual(1);
expect(getInput(within(assignments[0]).getByTestId('assignment-value'))).toHaveValue(
'secondValue',
);
});
it('can add assignments by drag and drop (and infer type)', async () => {
const pinia = createPinia();
setActivePinia(pinia);
const { getByTestId, findAllByTestId } = renderComponent({ pinia });
const dropArea = getByTestId('assignment-collection-drop-area');
await dropAssignment({ key: 'boolKey', value: true, dropArea });
await dropAssignment({ key: 'stringKey', value: 'stringValue', dropArea });
await dropAssignment({ key: 'numberKey', value: 25, dropArea });
await dropAssignment({ key: 'objectKey', value: {}, dropArea });
await dropAssignment({ key: 'arrayKey', value: [], dropArea });
let assignments = await findAllByTestId('assignment');
expect(assignments.length).toBe(5);
expect(getAssignmentType(assignments[0])).toEqual('Boolean');
expect(getAssignmentType(assignments[1])).toEqual('String');
expect(getAssignmentType(assignments[2])).toEqual('Number');
expect(getAssignmentType(assignments[3])).toEqual('Object');
expect(getAssignmentType(assignments[4])).toEqual('Array');
});
});

View File

@@ -0,0 +1,41 @@
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import TypeSelect from '../TypeSelect.vue';
const DEFAULT_SETUP = {
pinia: createTestingPinia(),
props: {
modelValue: 'boolean',
},
};
const renderComponent = createComponentRenderer(TypeSelect, DEFAULT_SETUP);
describe('TypeSelect.vue', () => {
afterEach(() => {
vi.clearAllMocks();
});
it('renders default state correctly and emit events', async () => {
const { getByTestId, baseElement, emitted } = renderComponent();
expect(getByTestId('assignment-type-select')).toBeInTheDocument();
await userEvent.click(
getByTestId('assignment-type-select').querySelector('.select-trigger') as HTMLElement,
);
const options = baseElement.querySelectorAll('.option');
expect(options.length).toEqual(5);
expect(options[0]).toHaveTextContent('String');
expect(options[1]).toHaveTextContent('Number');
expect(options[2]).toHaveTextContent('Boolean');
expect(options[3]).toHaveTextContent('Array');
expect(options[4]).toHaveTextContent('Object');
await userEvent.click(options[2]);
expect(emitted('update:model-value')).toEqual([['boolean']]);
});
});

View File

@@ -0,0 +1,7 @@
export const ASSIGNMENT_TYPES = [
{ type: 'string', icon: 'font' },
{ type: 'number', icon: 'hashtag' },
{ type: 'boolean', icon: 'check-square' },
{ type: 'array', icon: 'list' },
{ type: 'object', icon: 'cube' },
];

View File

@@ -0,0 +1,61 @@
import { isObject } from 'lodash-es';
import type { AssignmentValue, IDataObject } from 'n8n-workflow';
import { resolveParameter } from '@/mixins/workflowHelpers';
import { v4 as uuid } from 'uuid';
export function nameFromExpression(expression: string): string {
return expression.replace(/^{{\s*|\s*}}$/g, '').replace('$json.', '');
}
export function inferAssignmentType(value: unknown): string {
if (typeof value === 'boolean') return 'boolean';
if (typeof value === 'number') return 'number';
if (typeof value === 'string') return 'string';
if (Array.isArray(value)) return 'array';
if (isObject(value)) return 'object';
return 'string';
}
export function typeFromExpression(expression: string): string {
try {
const resolved = resolveParameter(`=${expression}`);
return inferAssignmentType(resolved);
} catch (error) {
return 'string';
}
}
export function inputDataToAssignments(input: IDataObject): AssignmentValue[] {
const assignments: AssignmentValue[] = [];
function processValue(value: IDataObject, path: Array<string | number> = []) {
if (Array.isArray(value)) {
value.forEach((element, index) => {
processValue(element, [...path, index]);
});
} else if (isObject(value)) {
for (const [key, objectValue] of Object.entries(value)) {
processValue(objectValue as IDataObject, [...path, key]);
}
} else {
const stringPath = path.reduce((fullPath: string, part) => {
if (typeof part === 'number') {
return `${fullPath}[${part}]`;
}
return `${fullPath}.${part}`;
}, '$json');
const expression = `={{ ${stringPath} }}`;
assignments.push({
id: uuid(),
name: stringPath.replace('$json.', ''),
value: expression,
type: inferAssignmentType(value),
});
}
}
processValue(input);
return assignments;
}

View File

@@ -117,7 +117,11 @@ export default defineComponent({
const data =
this.targetDataKey && this.draggingEl ? this.draggingEl.dataset.value : this.data || '';
this.ndvStore.draggableStartDragging({ type: this.type, data: data || '' });
this.ndvStore.draggableStartDragging({
type: this.type,
data: data || '',
dimensions: this.draggingEl?.getBoundingClientRect() ?? null,
});
this.$emit('dragstart', this.draggingEl);
document.body.style.cursor = 'grabbing';

View File

@@ -10,6 +10,7 @@ import type { PropType } from 'vue';
import { mapStores } from 'pinia';
import { useNDVStore } from '@/stores/ndv.store';
import { v4 as uuid } from 'uuid';
import type { XYPosition } from '@/Interface';
export default defineComponent({
props: {
@@ -26,21 +27,18 @@ export default defineComponent({
type: Array as PropType<number[]>,
default: () => [0, 0],
},
stickyOrigin: {
type: String as PropType<'top-left' | 'center'>,
default: 'top-left',
},
},
data() {
return {
hovering: false,
dimensions: null as DOMRect | null,
id: uuid(),
};
},
mounted() {
window.addEventListener('mousemove', this.onMouseMove);
window.addEventListener('mouseup', this.onMouseUp);
},
beforeUnmount() {
window.removeEventListener('mousemove', this.onMouseMove);
window.removeEventListener('mouseup', this.onMouseUp);
},
computed: {
...mapStores(useNDVStore),
isDragging(): boolean {
@@ -49,12 +47,57 @@ export default defineComponent({
draggableType(): string {
return this.ndvStore.draggableType;
},
draggableDimensions(): DOMRect | null {
return this.ndvStore.draggable.dimensions;
},
droppable(): boolean {
return !this.disabled && this.isDragging && this.draggableType === this.type;
},
activeDrop(): boolean {
return this.droppable && this.hovering;
},
stickyPosition(): XYPosition | null {
if (this.disabled || !this.sticky || !this.hovering || !this.dimensions) {
return null;
}
if (this.stickyOrigin === 'center') {
return [
this.dimensions.left +
this.stickyOffset[0] +
this.dimensions.width / 2 -
(this.draggableDimensions?.width ?? 0) / 2,
this.dimensions.top +
this.stickyOffset[1] +
this.dimensions.height / 2 -
(this.draggableDimensions?.height ?? 0) / 2,
];
}
return [
this.dimensions.left + this.stickyOffset[0],
this.dimensions.top + this.stickyOffset[1],
];
},
},
watch: {
activeDrop(active) {
if (active) {
this.ndvStore.setDraggableTarget({ id: this.id, stickyPosition: this.stickyPosition });
} else if (this.ndvStore.draggable.activeTarget?.id === this.id) {
// Only clear active target if it is this one
this.ndvStore.setDraggableTarget(null);
}
},
},
mounted() {
window.addEventListener('mousemove', this.onMouseMove);
window.addEventListener('mouseup', this.onMouseUp);
},
beforeUnmount() {
window.removeEventListener('mousemove', this.onMouseMove);
window.removeEventListener('mouseup', this.onMouseUp);
},
methods: {
onMouseMove(e: MouseEvent) {
@@ -63,17 +106,12 @@ export default defineComponent({
if (targetRef && this.isDragging) {
const dim = targetRef.getBoundingClientRect();
this.dimensions = dim;
this.hovering =
e.clientX >= dim.left &&
e.clientX <= dim.right &&
e.clientY >= dim.top &&
e.clientY <= dim.bottom;
if (!this.disabled && this.sticky && this.hovering) {
const [xOffset, yOffset] = this.stickyOffset;
this.ndvStore.setDraggableStickyPos([dim.left + xOffset, dim.top + yOffset]);
}
}
},
onMouseUp(e: MouseEvent) {
@@ -83,15 +121,5 @@ export default defineComponent({
}
},
},
watch: {
activeDrop(active) {
if (active) {
this.ndvStore.setDraggableTargetId(this.id);
} else if (this.ndvStore.draggable.activeTargetId === this.id) {
// Only clear active target if it is this one
this.ndvStore.setDraggableTargetId(null);
}
},
},
});
</script>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import DraggableTarget from '@/components/DraggableTarget.vue';
const emit = defineEmits<{ (event: 'drop', value: string): void }>();
const onDrop = (value: string) => {
emit('drop', value);
};
</script>
<template>
<DraggableTarget type="mapping" @drop="onDrop">
<template #default="{ droppable, activeDrop }">
<div
data-test-id="drop-area"
:class="{ [$style.area]: true, [$style.active]: activeDrop, [$style.droppable]: droppable }"
>
<slot :active="activeDrop" :droppable="droppable"></slot>
</div>
</template>
</DraggableTarget>
</template>
<style lang="scss" module>
.area {
border: dashed 1px var(--color-foreground-dark);
border-radius: var(--border-radius-large);
background: var(--color-background-light);
padding: var(--spacing-s) var(--spacing-m);
display: flex;
align-items: baseline;
justify-content: center;
font-size: var(--font-size-s);
transition: border-color 0.1s ease-in;
box-shadow: inset 0 0 0px 1.5px var(--color-background-xlight);
&:not(.active):hover {
border-color: var(--color-ndv-droppable-parameter);
background: var(--color-ndv-droppable-parameter-background);
}
}
.droppable {
border-color: var(--color-ndv-droppable-parameter);
border-width: 1.5px;
background: var(--color-ndv-droppable-parameter-background);
}
.active {
border-color: var(--color-success);
background: var(--color-ndv-droppable-parameter-active-background);
}
</style>

View File

@@ -0,0 +1,40 @@
import { createComponentRenderer } from '@/__tests__/render';
import { useNDVStore } from '@/stores/ndv.store';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { fireEvent } from '@testing-library/vue';
import { createPinia, setActivePinia } from 'pinia';
import DropArea from '../DropArea.vue';
const renderComponent = createComponentRenderer(DropArea, {
pinia: createTestingPinia(),
});
async function fireDrop(dropArea: HTMLElement): Promise<void> {
useNDVStore().draggableStartDragging({
type: 'mapping',
data: '{{ $json.something }}',
dimensions: null,
});
await userEvent.hover(dropArea);
await fireEvent.mouseUp(dropArea);
}
describe('DropArea.vue', () => {
afterEach(() => {
vi.clearAllMocks();
});
it('renders default state correctly and emits drop events', async () => {
const pinia = createPinia();
setActivePinia(pinia);
const { getByTestId, emitted } = renderComponent({ pinia });
expect(getByTestId('drop-area')).toBeInTheDocument();
await fireDrop(getByTestId('drop-area'));
expect(emitted('drop')).toEqual([['{{ $json.something }}']]);
});
});

View File

@@ -4,16 +4,22 @@
:class="$style['expression-parameter-input']"
@keydown.tab="onBlur"
>
<div :class="[$style['all-sections'], { [$style['focused']]: isFocused }]">
<div
:class="[
$style['all-sections'],
{ [$style.focused]: isFocused, [$style.assignment]: isAssignment },
]"
>
<div :class="[$style['prepend-section'], 'el-input-group__prepend']">
<ExpressionFunctionIcon />
<span v-if="isAssignment">=</span>
<ExpressionFunctionIcon v-else />
</div>
<InlineExpressionEditorInput
ref="inlineInput"
:model-value="modelValue"
:is-read-only="isReadOnly"
:target-item="hoveringItem"
:is-single-line="isSingleLine"
:rows="rows"
:additional-data="additionalExpressionData"
:path="path"
@focus="onFocus"
@@ -77,7 +83,11 @@ export default defineComponent({
type: Boolean,
default: false,
},
isSingleLine: {
rows: {
type: Number,
default: 5,
},
isAssignment: {
type: Boolean,
default: false,
},
@@ -145,10 +155,9 @@ export default defineComponent({
}
},
onChange({ value, segments }: { value: string; segments: Segment[] }) {
if (this.isDragging) return;
this.segments = segments;
if (this.isDragging) return;
if (value === '=' + this.modelValue) return; // prevent report on change of target item
this.$emit('update:modelValue', value);
@@ -167,8 +176,6 @@ export default defineComponent({
.all-sections {
height: 30px;
display: flex;
flex-direction: row;
display: inline-table;
width: 100%;
}
@@ -181,6 +188,13 @@ export default defineComponent({
}
}
.assignment {
.prepend-section {
vertical-align: top;
padding-top: 4px;
}
}
.expression-editor-modal-opener {
position: absolute;
right: 0;
@@ -192,6 +206,8 @@ export default defineComponent({
var(--input-border-style, var(--border-style-base))
var(--input-border-width, var(--border-width-base));
cursor: pointer;
border-radius: 0;
border-top-left-radius: var(--border-radius-base);
&:hover {
border: var(--input-border-color, var(--border-color-base))

View File

@@ -1,25 +1,25 @@
<script setup lang="ts">
import type { IUpdateInformation } from '@/Interface';
import InputTriple from '@/components/InputTriple/InputTriple.vue';
import ParameterInputFull from '@/components/ParameterInputFull.vue';
import ParameterIssues from '@/components/ParameterIssues.vue';
import { useI18n } from '@/composables/useI18n';
import { resolveParameter } from '@/mixins/workflowHelpers';
import { DateTime } from 'luxon';
import {
FilterError,
executeFilterCondition,
type FilterOptionsValue,
validateFieldType,
type FilterConditionValue,
type FilterOperatorType,
type FilterOptionsValue,
type INodeProperties,
type NodeParameterValue,
type NodePropertyTypes,
FilterError,
validateFieldType,
} from 'n8n-workflow';
import { computed, ref } from 'vue';
import OperatorSelect from './OperatorSelect.vue';
import { OPERATORS_BY_ID, type FilterOperatorId } from './constants';
import type { FilterOperator } from './types';
import { resolveParameter } from '@/mixins/workflowHelpers';
type ConditionResult =
| { status: 'resolve_error' }
| { status: 'validation_error'; error: string }
@@ -58,15 +58,24 @@ const operatorId = computed<FilterOperatorId>(() => {
});
const operator = computed(() => OPERATORS_BY_ID[operatorId.value] as FilterOperator);
const operatorTypeToNodePropType = (operatorType: FilterOperatorType): NodePropertyTypes => {
const operatorTypeToNodeProperty = (
operatorType: FilterOperatorType,
): Pick<INodeProperties, 'type' | 'options'> => {
switch (operatorType) {
case 'boolean':
return {
type: 'options',
options: [
{ name: 'true', value: true },
{ name: 'false', value: false },
],
};
case 'array':
case 'object':
case 'boolean':
case 'any':
return 'string';
return { type: 'string' };
default:
return operatorType;
return { type: operatorType };
}
};
@@ -119,7 +128,7 @@ const leftParameter = computed<INodeProperties>(() => ({
operator.value.type === 'dateTime'
? now.value
: i18n.baseText('filter.condition.placeholderLeft'),
type: operatorTypeToNodePropType(operator.value.type),
...operatorTypeToNodeProperty(operator.value.type),
}));
const rightParameter = computed<INodeProperties>(() => ({
@@ -130,7 +139,7 @@ const rightParameter = computed<INodeProperties>(() => ({
operator.value.type === 'dateTime'
? now.value
: i18n.baseText('filter.condition.placeholderRight'),
type: operatorTypeToNodePropType(operator.value.rightType ?? operator.value.type),
...operatorTypeToNodeProperty(operator.value.type),
}));
const onLeftValueChange = (update: IUpdateInformation): void => {
@@ -144,9 +153,11 @@ const onRightValueChange = (update: IUpdateInformation): void => {
const convertToType = (value: unknown, type: FilterOperatorType): unknown => {
if (type === 'any') return value;
const fallback = type === 'boolean' ? false : value;
return (
validateFieldType('filter', condition.value.leftValue, type, { parseStrings: true }).newValue ??
value
fallback
);
};
@@ -202,64 +213,53 @@ const onBlur = (): void => {
:class="$style.remove"
@click="onRemove"
></n8n-icon-button>
<n8n-resize-observer
:class="$style.observer"
:breakpoints="[
{ bp: 'stacked', width: 340 },
{ bp: 'medium', width: 520 },
]"
>
<template #default="{ bp }">
<div
:class="{
[$style.condition]: true,
[$style.hideRightInput]: operator.singleValue,
[$style.stacked]: bp === 'stacked',
[$style.medium]: bp === 'medium',
}"
>
<ParameterInputFull
v-if="!fixedLeftValue"
:key="leftParameter.type"
display-options
hide-label
hide-hint
is-single-line
:parameter="leftParameter"
:value="condition.leftValue"
:path="`${path}.left`"
:class="[$style.input, $style.inputLeft]"
:is-read-only="readOnly"
data-test-id="filter-condition-left"
@update="onLeftValueChange"
@blur="onBlur"
/>
<OperatorSelect
:class="$style.select"
:selected="`${operator.type}:${operator.operation}`"
:read-only="readOnly"
@operatorChange="onOperatorChange"
></OperatorSelect>
<ParameterInputFull
v-if="!operator.singleValue"
:key="rightParameter.type"
display-options
hide-label
hide-hint
is-single-line
:options-position="bp === 'default' ? 'top' : 'bottom'"
:parameter="rightParameter"
:value="condition.rightValue"
:path="`${path}.right`"
:class="[$style.input, $style.inputRight]"
:is-read-only="readOnly"
data-test-id="filter-condition-right"
@update="onRightValueChange"
@blur="onBlur"
/>
</div>
<InputTriple>
<template #left>
<ParameterInputFull
v-if="!fixedLeftValue"
:key="leftParameter.type"
display-options
hide-label
hide-hint
hide-issues
:rows="3"
:is-read-only="readOnly"
:parameter="leftParameter"
:value="condition.leftValue"
:path="`${path}.left`"
:class="[$style.input, $style.inputLeft]"
data-test-id="filter-condition-left"
@update="onLeftValueChange"
@blur="onBlur"
/>
</template>
</n8n-resize-observer>
<template #middle>
<OperatorSelect
:selected="`${operator.type}:${operator.operation}`"
:read-only="readOnly"
@operatorChange="onOperatorChange"
></OperatorSelect>
</template>
<template #right="{ breakpoint }" v-if="!operator.singleValue">
<ParameterInputFull
:key="rightParameter.type"
display-options
hide-label
hide-hint
hide-issues
:rows="3"
:is-read-only="readOnly"
:options-position="breakpoint === 'default' ? 'top' : 'bottom'"
:parameter="rightParameter"
:value="condition.rightValue"
:path="`${path}.right`"
:class="[$style.input, $style.inputRight]"
data-test-id="filter-condition-right"
@update="onRightValueChange"
@blur="onBlur"
/>
</template>
</InputTriple>
<div :class="$style.status">
<ParameterIssues v-if="allIssues.length > 0" :issues="allIssues" />
@@ -305,16 +305,6 @@ const onBlur = (): void => {
}
}
.condition {
display: flex;
flex-wrap: nowrap;
align-items: flex-end;
}
.observer {
width: 100%;
}
.status {
align-self: flex-start;
padding-top: 28px;
@@ -324,39 +314,6 @@ const onBlur = (): void => {
padding-left: var(--spacing-4xs);
}
.select {
flex-shrink: 0;
flex-grow: 0;
flex-basis: 160px;
--input-border-radius: 0;
--input-border-right-color: transparent;
}
.input {
flex-shrink: 0;
flex-basis: 160px;
flex-grow: 1;
}
.inputLeft {
--input-border-top-right-radius: 0;
--input-border-bottom-right-radius: 0;
--input-border-right-color: transparent;
}
.inputRight {
--input-border-top-left-radius: 0;
--input-border-bottom-left-radius: 0;
}
.hideRightInput {
.select {
--input-border-top-right-radius: var(--border-radius-base);
--input-border-bottom-right-radius: var(--border-radius-base);
--input-border-right-color: var(--input-border-color-base);
}
}
.remove {
position: absolute;
left: 0;
@@ -364,91 +321,4 @@ const onBlur = (): void => {
opacity: 0;
transition: opacity 100ms ease-in;
}
.medium {
flex-wrap: wrap;
.select {
--input-border-top-right-radius: var(--border-radius-base);
--input-border-bottom-right-radius: 0;
--input-border-bottom-color: transparent;
--input-border-right-color: var(--input-border-color-base);
}
.inputLeft {
--input-border-top-right-radius: 0;
--input-border-bottom-left-radius: 0;
--input-border-right-color: transparent;
--input-border-bottom-color: transparent;
}
.inputRight {
flex-basis: 340px;
flex-shrink: 1;
--input-border-top-right-radius: 0;
--input-border-bottom-left-radius: var(--border-radius-base);
}
&.hideRightInput {
.select {
--input-border-bottom-color: var(--input-border-color-base);
--input-border-top-left-radius: 0;
--input-border-bottom-left-radius: 0;
--input-border-top-right-radius: var(--border-radius-base);
--input-border-bottom-right-radius: var(--border-radius-base);
}
.inputLeft {
--input-border-top-right-radius: 0;
--input-border-bottom-left-radius: var(--border-radius-base);
--input-border-bottom-right-radius: 0;
--input-border-bottom-color: var(--input-border-color-base);
}
}
}
.stacked {
display: block;
.select {
width: 100%;
--input-border-right-color: var(--input-border-color-base);
--input-border-bottom-color: transparent;
--input-border-radius: 0;
}
.inputLeft {
--input-border-right-color: var(--input-border-color-base);
--input-border-bottom-color: transparent;
--input-border-top-left-radius: var(--border-radius-base);
--input-border-top-right-radius: var(--border-radius-base);
--input-border-bottom-left-radius: 0;
--input-border-bottom-right-radius: 0;
}
.inputRight {
--input-border-top-left-radius: 0;
--input-border-top-right-radius: 0;
--input-border-bottom-left-radius: var(--border-radius-base);
--input-border-bottom-right-radius: var(--border-radius-base);
}
&.hideRightInput {
.select {
--input-border-bottom-color: var(--input-border-color-base);
--input-border-top-left-radius: 0;
--input-border-top-right-radius: 0;
--input-border-bottom-left-radius: var(--border-radius-base);
--input-border-bottom-right-radius: var(--border-radius-base);
}
.inputLeft {
--input-border-top-left-radius: var(--border-radius-base);
--input-border-top-right-radius: var(--border-radius-base);
--input-border-bottom-left-radius: 0;
--input-border-bottom-right-radius: 0;
--input-border-bottom-color: transparent;
}
}
}
</style>

View File

@@ -138,6 +138,7 @@ function getIssues(index: number): string[] {
:underline="true"
:show-options="true"
:show-expression-selector="false"
size="small"
color="text-dark"
>
</n8n-input-label>

View File

@@ -247,37 +247,37 @@ export const DEFAULT_OPERATOR_VALUE: FilterConditionValue['operator'] =
export const OPERATOR_GROUPS: FilterOperatorGroup[] = [
{
id: 'string',
name: 'filter.operatorGroup.string',
name: 'type.string',
icon: 'font',
children: OPERATORS.filter((operator) => operator.type === 'string'),
},
{
id: 'number',
name: 'filter.operatorGroup.number',
name: 'type.number',
icon: 'hashtag',
children: OPERATORS.filter((operator) => operator.type === 'number'),
},
{
id: 'dateTime',
name: 'filter.operatorGroup.date',
name: 'type.dateTime',
icon: 'calendar',
children: OPERATORS.filter((operator) => operator.type === 'dateTime'),
},
{
id: 'boolean',
name: 'filter.operatorGroup.boolean',
name: 'type.boolean',
icon: 'check-square',
children: OPERATORS.filter((operator) => operator.type === 'boolean'),
},
{
id: 'array',
name: 'filter.operatorGroup.array',
name: 'type.array',
icon: 'list',
children: OPERATORS.filter((operator) => operator.type === 'array'),
},
{
id: 'object',
name: 'filter.operatorGroup.object',
name: 'type.object',
icon: 'cube',
children: OPERATORS.filter((operator) => operator.type === 'object'),
},

View File

@@ -185,7 +185,6 @@ export default defineComponent({
multipleValues(): boolean {
return !!this.parameter.typeOptions?.multipleValues;
},
parameterOptions(): INodePropertyCollection[] {
if (this.multipleValues && isINodePropertyCollectionList(this.parameter.options)) {
return this.parameter.options;

View File

@@ -35,9 +35,9 @@ export default defineComponent({
type: Boolean,
default: false,
},
isSingleLine: {
type: Boolean,
default: false,
rows: {
type: Number,
default: 5,
},
path: {
type: String,
@@ -92,7 +92,7 @@ export default defineComponent({
mounted() {
const extensions = [
n8nLang(),
inputTheme({ isSingleLine: this.isSingleLine }),
inputTheme({ rows: this.rows }),
Prec.highest(
keymap.of([
{ key: 'Tab', run: acceptCompletion },

View File

@@ -15,11 +15,12 @@ const commonThemeProps = {
},
};
export const inputTheme = ({ isSingleLine } = { isSingleLine: false }) => {
export const inputTheme = ({ rows } = { rows: 5 }) => {
const maxHeight = Math.max(rows * 22 + 8);
const theme = EditorView.theme({
...commonThemeProps,
'&': {
maxHeight: isSingleLine ? '30px' : '112px',
maxHeight: `${maxHeight}px`,
minHeight: '30px',
width: '100%',
fontSize: 'var(--font-size-2xs)',

View File

@@ -0,0 +1,163 @@
<script setup lang="ts">
type Props = {
middleWidth?: string;
};
withDefaults(defineProps<Props>(), { middleWidth: '160px' });
</script>
<template>
<n8n-resize-observer
:class="{ [$style.observer]: true }"
:breakpoints="[
{ bp: 'stacked', width: 400 },
{ bp: 'medium', width: 680 },
]"
>
<template #default="{ bp }">
<div :class="$style.background"></div>
<div
:class="{
[$style.triple]: true,
[$style.stacked]: bp === 'stacked',
[$style.medium]: bp === 'medium',
[$style.default]: bp === 'default',
[$style.noRightSlot]: !$slots.right,
[$style.noMiddleSlot]: !$slots.middle,
}"
>
<div v-if="$slots.left" :class="$style.item">
<slot name="left" :breakpoint="bp"></slot>
</div>
<div
v-if="$slots.middle"
:class="[$style.item, $style.middle]"
:style="{ flexBasis: middleWidth }"
>
<slot name="middle" :breakpoint="bp"></slot>
</div>
<div v-if="$slots.right" :class="$style.item">
<slot name="right" :breakpoint="bp"></slot>
</div>
</div>
</template>
</n8n-resize-observer>
</template>
<style lang="scss" module>
.triple {
display: flex;
flex-wrap: nowrap;
align-items: flex-start;
}
.observer {
--parameter-input-options-height: 22px;
width: 100%;
position: relative;
}
.background {
position: absolute;
background-color: var(--color-background-input-triple);
top: var(--parameter-input-options-height);
bottom: 0;
left: 0;
right: 0;
border: 1px solid var(--border-color-base);
border-radius: var(--border-radius-base);
}
.item {
flex-shrink: 0;
flex-basis: 240px;
flex-grow: 1;
--input-border-radius: 0;
}
.default .item:not(:first-child):not(:focus-within + .item) {
margin-left: -1px;
}
.middle {
flex-grow: 0;
flex-basis: 160px;
padding-top: var(--parameter-input-options-height);
}
.item:first-of-type {
--input-border-top-left-radius: var(--border-radius-base);
--input-border-bottom-left-radius: var(--border-radius-base);
--input-border-top-right-radius: 0;
--input-border-bottom-right-radius: 0;
}
.item:last-of-type {
--input-border-top-left-radius: 0;
--input-border-bottom-left-radius: 0;
--input-border-top-right-radius: var(--border-radius-base);
--input-border-bottom-right-radius: var(--border-radius-base);
}
.medium:not(.noRightSlot) {
flex-wrap: wrap;
.middle {
--input-border-top-right-radius: var(--border-radius-base);
--input-border-bottom-right-radius: 0;
&:not(:focus-within + .item) {
margin-left: -1px;
}
}
.item:first-of-type {
--input-border-top-left-radius: var(--border-radius-base);
--input-border-top-right-radius: 0;
--input-border-bottom-left-radius: 0;
}
.item:last-of-type {
flex-basis: 400px;
--input-border-top-left-radius: 0;
--input-border-top-right-radius: 0;
--input-border-bottom-left-radius: var(--border-radius-base);
--input-border-bottom-right-radius: var(--border-radius-base);
&:not(:focus-within ~ .item) {
margin-top: -1px;
}
}
}
.stacked {
display: block;
.middle {
padding-top: 0;
}
.middle:not(.item:last-of-type) {
width: 100%;
--input-border-radius: 0;
}
.item:first-of-type {
--input-border-top-left-radius: var(--border-radius-base);
--input-border-top-right-radius: var(--border-radius-base);
--input-border-bottom-left-radius: 0;
--input-border-bottom-right-radius: 0;
}
.item:not(:first-of-type):not(:focus-within + .item) {
margin-top: -1px;
}
.item:last-of-type {
--input-border-top-left-radius: 0;
--input-border-top-right-radius: 0;
--input-border-bottom-left-radius: var(--border-radius-base);
--input-border-bottom-right-radius: var(--border-radius-base);
}
}
</style>

View File

@@ -0,0 +1,38 @@
import { createComponentRenderer } from '@/__tests__/render';
import InputTriple from '../InputTriple.vue';
const renderComponent = createComponentRenderer(InputTriple);
describe('InputTriple.vue', () => {
afterEach(() => {
vi.clearAllMocks();
});
it('renders layout correctly', async () => {
const { container } = renderComponent({
props: { middleWidth: '200px' },
slots: {
left: '<div>left</div>',
middle: '<div>middle</div>',
right: '<div>right</div>',
},
});
expect(container.querySelector('.triple')).toBeInTheDocument();
expect(container.querySelectorAll('.item')).toHaveLength(3);
expect(container.querySelector('.middle')).toHaveStyle('flex-basis: 200px');
});
it('does not render missing slots', async () => {
const { container } = renderComponent({
props: { middleWidth: '200px' },
slots: {
left: '<div>left</div>',
middle: '<div>middle</div>',
},
});
expect(container.querySelector('.triple')).toBeInTheDocument();
expect(container.querySelectorAll('.item')).toHaveLength(2);
});
});

View File

@@ -46,7 +46,8 @@
:model-value="expressionDisplayValue"
:title="displayTitle"
:is-read-only="isReadOnly"
:is-single-line="isSingleLine"
:rows="rows"
:is-assignment="isAssignment"
:path="path"
:additional-expression-data="additionalExpressionData"
:class="{ 'ph-no-capture': shouldRedactValue }"
@@ -549,7 +550,11 @@ export default defineComponent({
isReadOnly: {
type: Boolean,
},
isSingleLine: {
rows: {
type: Number,
default: 5,
},
isAssignment: {
type: Boolean,
},
parameter: {
@@ -1314,7 +1319,11 @@ export default defineComponent({
(!this.modelValue || this.modelValue === '[Object: null]')
) {
this.valueChanged('={{ 0 }}');
} else if (this.parameter.type === 'number' || this.parameter.type === 'boolean') {
} else if (
this.parameter.type === 'number' ||
this.parameter.type === 'boolean' ||
typeof this.modelValue !== 'string'
) {
this.valueChanged(`={{ ${this.modelValue} }}`);
} else {
this.valueChanged(`=${this.modelValue}`);
@@ -1345,7 +1354,6 @@ export default defineComponent({
// Strip the '=' from the beginning
newValue = this.modelValue ? this.modelValue.toString().substring(1) : null;
}
this.valueChanged(newValue);
}
} else if (command === 'refreshOptions') {
@@ -1416,6 +1424,7 @@ export default defineComponent({
.droppable {
--input-border-color: var(--color-ndv-droppable-parameter);
--input-border-right-color: var(--color-ndv-droppable-parameter);
--input-border-style: dashed;
textarea,
@@ -1427,6 +1436,7 @@ export default defineComponent({
.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;

View File

@@ -49,7 +49,8 @@
:model-value="value"
:path="path"
:is-read-only="isReadOnly"
:is-single-line="isSingleLine"
:is-assignment="isAssignment"
:rows="rows"
:droppable="droppable"
:active-drop="activeDrop"
:force-show-expression="forceShowExpression"
@@ -140,7 +141,11 @@ export default defineComponent({
type: Boolean,
default: false,
},
isSingleLine: {
rows: {
type: Number,
default: 5,
},
isAssignment: {
type: Boolean,
default: false,
},
@@ -387,6 +392,7 @@ export default defineComponent({
position: absolute;
bottom: -22px;
right: 0;
z-index: 1;
opacity: 0;
transition: opacity 100ms ease-in;

View File

@@ -117,6 +117,15 @@
:read-only="isReadOnly"
@valueChanged="valueChanged"
/>
<AssignmentCollection
v-else-if="parameter.type === 'assignmentCollection'"
:parameter="parameter"
:value="nodeHelpers.getParameterValue(nodeValues, parameter.name, path)"
:path="getPath(parameter.name)"
:node="node"
:is-read-only="isReadOnly"
@valueChanged="valueChanged"
/>
<div
v-else-if="displayNodeParameter(parameter) && credentialsParameterIndex !== index"
class="parameter-item"
@@ -170,7 +179,8 @@ import ImportParameter from '@/components/ImportParameter.vue';
import MultipleParameter from '@/components/MultipleParameter.vue';
import ParameterInputFull from '@/components/ParameterInputFull.vue';
import ResourceMapper from '@/components/ResourceMapper/ResourceMapper.vue';
import Conditions from '@/components/FilterConditions/FilterConditions.vue';
import FilterConditions from '@/components/FilterConditions/FilterConditions.vue';
import AssignmentCollection from '@/components/AssignmentCollection/AssignmentCollection.vue';
import { KEEP_AUTH_IN_NDV_FOR_NODES } from '@/constants';
import { workflowHelpers } from '@/mixins/workflowHelpers';
import { useNDVStore } from '@/stores/ndv.store';
@@ -199,7 +209,8 @@ export default defineComponent({
CollectionParameter,
ImportParameter,
ResourceMapper,
FilterConditions: Conditions,
FilterConditions,
AssignmentCollection,
},
mixins: [workflowHelpers],
props: {

View File

@@ -7,6 +7,7 @@
:model-value="modelValue"
:path="path"
:is-read-only="isReadOnly"
:is-assignment="isAssignment"
:droppable="droppable"
:active-drop="activeDrop"
:force-show-expression="forceShowExpression"
@@ -15,10 +16,10 @@
:error-highlight="errorHighlight"
:is-for-credential="isForCredential"
:event-source="eventSource"
:expression-evaluated="expressionValueComputed"
:expression-evaluated="evaluatedExpressionValue"
:additional-expression-data="resolvedAdditionalExpressionData"
:label="label"
:is-single-line="isSingleLine"
:rows="rows"
:data-test-id="`parameter-input-${parsedParameterName}`"
:event-bus="eventBus"
@focus="onFocus"
@@ -45,28 +46,29 @@
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import type { PropType } from 'vue';
import { mapStores } from 'pinia';
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
import type { INodeUi, IUpdateInformation, TargetItem } from '@/Interface';
import ParameterInput from '@/components/ParameterInput.vue';
import InputHint from '@/components/ParameterInputHint.vue';
import { workflowHelpers } from '@/mixins/workflowHelpers';
import { useEnvironmentsStore } from '@/stores/environments.ee.store';
import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
import { useNDVStore } from '@/stores/ndv.store';
import { isValueExpression, parseResourceMapperFieldName } from '@/utils/nodeTypesUtils';
import type {
IDataObject,
INodeProperties,
INodePropertyMode,
IParameterLabel,
NodeParameterValue,
NodeParameterValueType,
Result,
} from 'n8n-workflow';
import { isResourceLocatorValue } from 'n8n-workflow';
import type { INodeUi, IUpdateInformation, TargetItem } from '@/Interface';
import { workflowHelpers } from '@/mixins/workflowHelpers';
import { isValueExpression, parseResourceMapperFieldName } from '@/utils/nodeTypesUtils';
import { useNDVStore } from '@/stores/ndv.store';
import { useEnvironmentsStore } from '@/stores/environments.ee.store';
import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
import { get } from 'lodash-es';
import type { EventBus } from 'n8n-design-system/utils';
import { createEventBus } from 'n8n-design-system/utils';
@@ -85,7 +87,11 @@ export default defineComponent({
isReadOnly: {
type: Boolean,
},
isSingleLine: {
rows: {
type: Number,
default: 5,
},
isAssignment: {
type: Boolean,
},
parameter: {
@@ -183,15 +189,14 @@ export default defineComponent({
isInputParentOfActiveNode(): boolean {
return this.ndvStore.isInputParentOfActiveNode;
},
expressionValueComputed(): string | null {
evaluatedExpression(): Result<unknown, unknown> {
const value = isResourceLocatorValue(this.modelValue)
? this.modelValue.value
: this.modelValue;
if (!this.activeNode || !this.isValueExpression || typeof value !== 'string') {
return null;
return { ok: false, error: '' };
}
let computedValue: NodeParameterValue;
try {
let opts;
if (this.ndvStore.isInputParentOfActiveNode) {
@@ -204,24 +209,39 @@ export default defineComponent({
};
}
computedValue = this.resolveExpression(value, undefined, opts);
if (computedValue === null) {
return null;
}
if (typeof computedValue === 'string' && computedValue.length === 0) {
return this.$locale.baseText('parameterInput.emptyString');
}
return { ok: true, result: this.resolveExpression(value, undefined, opts) };
} catch (error) {
computedValue = `[${this.$locale.baseText('parameterInput.error')}: ${error.message}]`;
return { ok: false, error };
}
},
evaluatedExpressionValue(): unknown {
const evaluated = this.evaluatedExpression;
return evaluated.ok ? evaluated.result : null;
},
evaluatedExpressionString(): string | null {
const evaluated = this.evaluatedExpression;
if (!evaluated.ok) {
return `[${this.$locale.baseText('parameterInput.error')}: ${get(
evaluated.error,
'message',
)}]`;
}
return typeof computedValue === 'string' ? computedValue : JSON.stringify(computedValue);
if (evaluated.result === null) {
return null;
}
if (typeof evaluated.result === 'string' && evaluated.result.length === 0) {
return this.$locale.baseText('parameterInput.emptyString');
}
return typeof evaluated.result === 'string'
? evaluated.result
: JSON.stringify(evaluated.result);
},
expressionOutput(): string | null {
if (this.isValueExpression && this.expressionValueComputed) {
return this.expressionValueComputed;
if (this.isValueExpression && this.evaluatedExpressionString) {
return this.evaluatedExpressionString;
}
return null;

View File

@@ -93,7 +93,7 @@
ref="input"
:model-value="expressionDisplayValue"
:path="path"
is-single-line
:rows="1"
@update:modelValue="onInputChange"
@modalOpenerClick="$emit('modalOpenerClick')"
/>

View File

@@ -257,9 +257,7 @@ describe('FilterConditions.vue', () => {
let conditions = await findAllByTestId('filter-condition');
expect(conditions.length).toEqual(2);
const removeButton = conditions[0].querySelector('[data-test-id="filter-remove-condition"]');
await userEvent.click(removeButton as Element);
await userEvent.click(within(conditions[0]).getByTestId('filter-remove-condition'));
conditions = await findAllByTestId('filter-condition');
expect(conditions.length).toEqual(1);