mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
refactor(editor): Move editor-ui and design-system to frontend dir (no-changelog) (#13564)
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import Assignment from './Assignment.vue';
|
||||
import { defaultSettings } from '@/__tests__/defaults';
|
||||
import { STORES } from '@/constants';
|
||||
import { merge } from 'lodash-es';
|
||||
import { cleanupAppModals, createAppModals } from '@/__tests__/utils';
|
||||
|
||||
const DEFAULT_SETUP = {
|
||||
pinia: createTestingPinia({
|
||||
initialState: { [STORES.SETTINGS]: { settings: merge({}, defaultSettings) } },
|
||||
}),
|
||||
props: {
|
||||
path: 'parameters.fields.0',
|
||||
modelValue: {
|
||||
name: '',
|
||||
type: 'string',
|
||||
value: '',
|
||||
},
|
||||
issues: [],
|
||||
},
|
||||
};
|
||||
|
||||
const renderComponent = createComponentRenderer(Assignment, DEFAULT_SETUP);
|
||||
|
||||
describe('Assignment.vue', () => {
|
||||
beforeEach(() => {
|
||||
createAppModals();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupAppModals();
|
||||
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([[]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
<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 { useResolvedExpression } from '@/composables/useResolvedExpression';
|
||||
import useEnvironmentsStore from '@/stores/environments.ee.store';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import type { AssignmentValue, INodeProperties } from 'n8n-workflow';
|
||||
import { computed, ref } from 'vue';
|
||||
import TypeSelect from './TypeSelect.vue';
|
||||
import { N8nIconButton } from '@n8n/design-system';
|
||||
|
||||
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<{
|
||||
'update:model-value': [value: AssignmentValue];
|
||||
remove: [];
|
||||
}>();
|
||||
|
||||
const ndvStore = useNDVStore();
|
||||
const environmentsStore = useEnvironmentsStore();
|
||||
|
||||
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: 'name',
|
||||
displayName: 'Name',
|
||||
default: '',
|
||||
requiresDataPath: 'single',
|
||||
placeholder: 'name',
|
||||
type: 'string',
|
||||
}));
|
||||
|
||||
const valueParameter = computed<INodeProperties>(() => {
|
||||
return {
|
||||
name: 'value',
|
||||
displayName: 'Value',
|
||||
default: '',
|
||||
placeholder: 'value',
|
||||
...assignmentTypeToNodeProperty(assignment.value.type ?? 'string'),
|
||||
};
|
||||
});
|
||||
|
||||
const value = computed(() => assignment.value.value);
|
||||
|
||||
const resolvedAdditionalExpressionData = computed(() => {
|
||||
return { $vars: environmentsStore.variablesAsObject };
|
||||
});
|
||||
|
||||
const { resolvedExpressionString, isExpression } = useResolvedExpression({
|
||||
expression: value,
|
||||
additionalData: resolvedAdditionalExpressionData,
|
||||
});
|
||||
|
||||
const hint = computed(() => resolvedExpressionString.value);
|
||||
|
||||
const highlightHint = computed(() => Boolean(hint.value && ndvStore.getHoveringItem));
|
||||
|
||||
const onAssignmentNameChange = (update: IUpdateInformation): void => {
|
||||
assignment.value.name = update.value as string;
|
||||
};
|
||||
|
||||
const onAssignmentTypeChange = (update: string): void => {
|
||||
assignment.value.type = update;
|
||||
|
||||
if (update === 'boolean' && !isExpression.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"
|
||||
>
|
||||
<N8nIconButton
|
||||
v-if="!isReadOnly"
|
||||
type="tertiary"
|
||||
text
|
||||
size="mini"
|
||||
icon="grip-vertical"
|
||||
:class="[$style.iconButton, $style.defaultTopPadding, 'drag-handle']"
|
||||
></N8nIconButton>
|
||||
<N8nIconButton
|
||||
v-if="!isReadOnly"
|
||||
type="tertiary"
|
||||
text
|
||||
size="mini"
|
||||
icon="trash"
|
||||
data-test-id="assignment-remove"
|
||||
:class="[$style.iconButton, $style.extraTopPadding]"
|
||||
@click="onRemove"
|
||||
></N8nIconButton>
|
||||
|
||||
<div :class="$style.inputs">
|
||||
<InputTriple middle-width="100px">
|
||||
<template #left>
|
||||
<ParameterInputFull
|
||||
:key="nameParameter.type"
|
||||
display-options
|
||||
hide-label
|
||||
hide-hint
|
||||
: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
|
||||
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
|
||||
data-test-id="parameter-expression-preview-value"
|
||||
:class="$style.hint"
|
||||
:highlight="highlightHint"
|
||||
: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 {
|
||||
.iconButton {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease-in;
|
||||
color: var(--icon-base-color);
|
||||
}
|
||||
.extraTopPadding {
|
||||
top: calc(20px + var(--spacing-l));
|
||||
}
|
||||
|
||||
.defaultTopPadding {
|
||||
top: var(--spacing-l);
|
||||
}
|
||||
|
||||
.status {
|
||||
align-self: flex-start;
|
||||
padding-top: 28px;
|
||||
}
|
||||
|
||||
.statusIcon {
|
||||
padding-left: var(--spacing-4xs);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,154 @@
|
||||
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 '@/composables/useWorkflowHelpers';
|
||||
import AssignmentCollection from './AssignmentCollection.vue';
|
||||
import { STORES } from '@/constants';
|
||||
import { cleanupAppModals, createAppModals, SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
|
||||
|
||||
const DEFAULT_SETUP = {
|
||||
pinia: createTestingPinia({
|
||||
initialState: {
|
||||
[STORES.SETTINGS]: SETTINGS_STORE_DEFAULT_STATE,
|
||||
},
|
||||
stubActions: false,
|
||||
}),
|
||||
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', () => {
|
||||
beforeEach(() => {
|
||||
createAppModals();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanupAppModals();
|
||||
});
|
||||
|
||||
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('does not break with saved assignments that have no ID (legacy)', async () => {
|
||||
const { findAllByTestId } = renderComponent({
|
||||
props: {
|
||||
value: {
|
||||
assignments: [
|
||||
{ name: 'key1', value: 'value1', type: 'string' },
|
||||
{ name: 'key2', value: 'value2', type: 'string' },
|
||||
{ name: 'key3', value: 'value3', type: 'string' },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let assignments = await findAllByTestId('assignment');
|
||||
|
||||
expect(assignments.length).toEqual(3);
|
||||
|
||||
// Remove 2nd assignment
|
||||
await userEvent.click(within(assignments[1]).getByTestId('assignment-remove'));
|
||||
assignments = await findAllByTestId('assignment');
|
||||
expect(assignments.length).toEqual(2);
|
||||
expect(getInput(within(assignments[0]).getByTestId('assignment-value'))).toHaveValue('value1');
|
||||
expect(getInput(within(assignments[1]).getByTestId('assignment-value'))).toHaveValue('value3');
|
||||
});
|
||||
|
||||
it('can add assignments by drag and drop (and infer type)', async () => {
|
||||
const { getByTestId, findAllByTestId } = renderComponent();
|
||||
const dropArea = getByTestId('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 });
|
||||
|
||||
const 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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,299 @@
|
||||
<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 { computed, reactive, watch } from 'vue';
|
||||
import DropArea from '../DropArea/DropArea.vue';
|
||||
import ParameterOptions from '../ParameterOptions.vue';
|
||||
import Assignment from './Assignment.vue';
|
||||
import { inputDataToAssignments, typeFromExpression } from './utils';
|
||||
import { propertyNameFromExpression } from '@/utils/mappingUtils';
|
||||
import Draggable from 'vuedraggable';
|
||||
|
||||
interface Props {
|
||||
parameter: INodeProperties;
|
||||
value: AssignmentCollectionValue;
|
||||
path: string;
|
||||
node: INode | null;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { isReadOnly: false });
|
||||
|
||||
const emit = defineEmits<{
|
||||
valueChanged: [value: { name: string; node: string; value: AssignmentCollectionValue }];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const state = reactive<{ paramValue: AssignmentCollectionValue }>({
|
||||
paramValue: {
|
||||
assignments:
|
||||
props.value.assignments?.map((assignment) => {
|
||||
if (!assignment.id) assignment.id = crypto.randomUUID();
|
||||
return assignment;
|
||||
}) ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
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(() => propertyNameFromExpression(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: crypto.randomUUID(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'string',
|
||||
});
|
||||
}
|
||||
|
||||
function dropAssignment(expression: string): void {
|
||||
state.paramValue.assignments.push({
|
||||
id: crypto.randomUUID(),
|
||||
name: propertyNameFromExpression(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: string) {
|
||||
if (action === 'clearAll') {
|
||||
state.paramValue.assignments = [];
|
||||
} else if (action === 'addAll' && inputData.value) {
|
||||
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">
|
||||
<Draggable
|
||||
v-model="state.paramValue.assignments"
|
||||
item-key="id"
|
||||
handle=".drag-handle"
|
||||
:drag-class="$style.dragging"
|
||||
:ghost-class="$style.ghost"
|
||||
>
|
||||
<template #item="{ index, element: assignment }">
|
||||
<Assignment
|
||||
:model-value="assignment"
|
||||
:index="index"
|
||||
:path="`${path}.assignments.${index}`"
|
||||
:issues="getIssues(index)"
|
||||
:class="$style.assignment"
|
||||
:is-read-only="isReadOnly"
|
||||
@update:model-value="(value) => onAssignmentUpdate(index, value)"
|
||||
@remove="() => onAssignmentRemove(index)"
|
||||
>
|
||||
</Assignment>
|
||||
</template>
|
||||
</Draggable>
|
||||
</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);
|
||||
}
|
||||
.ghost,
|
||||
.dragging {
|
||||
border-radius: var(--border-radius-base);
|
||||
padding-right: var(--spacing-xs);
|
||||
padding-bottom: var(--spacing-xs);
|
||||
}
|
||||
.ghost {
|
||||
background-color: var(--color-background-base);
|
||||
opacity: 0.5;
|
||||
}
|
||||
.dragging {
|
||||
background-color: var(--color-background-xlight);
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
@@ -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']]);
|
||||
});
|
||||
});
|
||||
@@ -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<{
|
||||
'update:model-value': [type: string];
|
||||
}>();
|
||||
|
||||
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>
|
||||
@@ -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' },
|
||||
];
|
||||
@@ -0,0 +1,57 @@
|
||||
import { isObject } from 'lodash-es';
|
||||
import type { AssignmentValue, IDataObject } from 'n8n-workflow';
|
||||
import { resolveParameter } from '@/composables/useWorkflowHelpers';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user