Files
n8n-enterprise-unlocked/packages/frontend/editor-ui/src/components/ParameterInput.test.ts

542 lines
15 KiB
TypeScript

import { createComponentRenderer } from '@/__tests__/render';
import ParameterInput from '@/components/ParameterInput.vue';
import type { useNDVStore } from '@/stores/ndv.store';
import type { CompletionResult } from '@codemirror/autocomplete';
import { createTestingPinia } from '@pinia/testing';
import { faker } from '@faker-js/faker';
import { waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import type { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useSettingsStore } from '@/stores/settings.store';
import { cleanupAppModals, createAppModals, mockedStore } from '@/__tests__/utils';
import { createEventBus } from '@n8n/utils/event-bus';
import { createMockEnterpriseSettings } from '@/__tests__/mocks';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { INodeParameterResourceLocator } from 'n8n-workflow';
let mockNdvState: Partial<ReturnType<typeof useNDVStore>>;
let mockNodeTypesState: Partial<ReturnType<typeof useNodeTypesStore>>;
let mockCompletionResult: Partial<CompletionResult>;
beforeEach(() => {
mockNdvState = {
hasInputData: true,
activeNode: {
id: faker.string.uuid(),
name: faker.word.words(3),
parameters: {},
position: [faker.number.int(), faker.number.int()],
type: 'test',
typeVersion: 1,
},
isInputPanelEmpty: false,
isOutputPanelEmpty: false,
};
mockNodeTypesState = {
allNodeTypes: [],
};
mockCompletionResult = {};
createAppModals();
});
vi.mock('@/stores/ndv.store', () => {
return {
useNDVStore: vi.fn(() => mockNdvState),
};
});
vi.mock('@/stores/nodeTypes.store', () => {
return {
useNodeTypesStore: vi.fn(() => mockNodeTypesState),
};
});
vi.mock('@/plugins/codemirror/completions/datatype.completions', () => {
return {
datatypeCompletions: vi.fn(() => mockCompletionResult),
};
});
vi.mock('vue-router', () => {
const push = vi.fn();
return {
useRouter: () => ({
push,
resolve: vi.fn().mockReturnValue({
href: '/projects/1/folders/1',
}),
}),
useRoute: () => ({}),
RouterLink: vi.fn(),
};
});
const renderComponent = createComponentRenderer(ParameterInput, {
pinia: createTestingPinia(),
});
const settingsStore = mockedStore(useSettingsStore);
const workflowsStore = mockedStore(useWorkflowsStore);
describe('ParameterInput.vue', () => {
beforeEach(() => {
mockNdvState = {
hasInputData: true,
activeNode: {
id: faker.string.uuid(),
name: faker.word.words(3),
parameters: {},
position: [faker.number.int(), faker.number.int()],
type: 'test',
typeVersion: 1,
},
isInputPanelEmpty: false,
isOutputPanelEmpty: false,
};
mockNodeTypesState = {
allNodeTypes: [],
getNodeType: vi.fn().mockReturnValue(null),
};
createAppModals();
settingsStore.settings.enterprise = createMockEnterpriseSettings();
});
afterEach(() => {
cleanupAppModals();
vi.clearAllMocks();
});
test('should render an options parameter (select)', async () => {
const { container, baseElement, emitted } = renderComponent({
props: {
path: 'operation',
parameter: {
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: { resource: ['sheet'] } },
options: [
{
name: 'Append or Update Row',
value: 'appendOrUpdate',
description: 'Append a new row or update an existing one (upsert)',
action: 'Append or update row in sheet',
},
{
name: 'Append Row',
value: 'append',
description: 'Create a new row in a sheet',
action: 'Append row in sheet',
},
],
default: 'appendOrUpdate',
},
modelValue: 'appendOrUpdate',
},
});
const select = container.querySelector('input') as HTMLInputElement;
const selectTrigger = container.querySelector('.select-trigger') as HTMLElement;
expect(select).toBeInTheDocument();
expect(selectTrigger).toBeInTheDocument();
await waitFor(() => expect(select).toHaveValue('Append or Update Row'));
await userEvent.click(selectTrigger);
const options = baseElement.querySelectorAll('.list-option');
expect(options.length).toEqual(2);
expect(options[0].querySelector('.option-headline')).toHaveTextContent('Append or Update Row');
expect(options[0].querySelector('.option-description')).toHaveTextContent(
'Append a new row or update an existing one (upsert)',
);
expect(options[1].querySelector('.option-headline')).toHaveTextContent('Append Row');
expect(options[1].querySelector('.option-description')).toHaveTextContent(
'Create a new row in a sheet',
);
await userEvent.click(options[1]);
expect(emitted('update')).toContainEqual([expect.objectContaining({ value: 'append' })]);
});
test('should render an options parameter even if it has invalid fields (like displayName)', async () => {
// Test case based on the Schedule node
// type=options parameters shouldn't have a displayName field, but some do
const { container, baseElement, emitted } = renderComponent({
props: {
path: 'operation',
parameter: {
displayName: 'Trigger at Hour',
name: 'triggerAtHour',
type: 'options',
default: 0,
options: [
{
name: 'Midnight',
displayName: 'Midnight',
value: 0,
},
{
name: '1am',
displayName: '1am',
value: 1,
},
],
description: 'The hour of the day to trigger',
},
modelValue: 0,
},
});
const select = container.querySelector('input') as HTMLInputElement;
const selectTrigger = container.querySelector('.select-trigger') as HTMLElement;
await waitFor(() => expect(select).toHaveValue('Midnight'));
await userEvent.click(selectTrigger);
const options = baseElement.querySelectorAll('.list-option');
expect(options.length).toEqual(2);
expect(options[0].querySelector('.option-headline')).toHaveTextContent('Midnight');
expect(options[1].querySelector('.option-headline')).toHaveTextContent('1am');
await userEvent.click(options[1]);
expect(emitted('update')).toContainEqual([expect.objectContaining({ value: 1 })]);
});
test('should render a string parameter', async () => {
const { container, emitted } = renderComponent({
props: {
path: 'tag',
parameter: {
displayName: 'Tag',
name: 'tag',
type: 'string',
},
modelValue: '',
},
});
const input = container.querySelector('input') as HTMLInputElement;
expect(input).toBeInTheDocument();
await userEvent.type(input, 'foo');
expect(emitted('update')).toContainEqual([expect.objectContaining({ value: 'foo' })]);
});
describe('paste events', () => {
async function paste(input: HTMLInputElement, text: string) {
const expression = new DataTransfer();
expression.setData('text', text);
await userEvent.clear(input);
await userEvent.paste(expression);
}
test('should handle pasting into a string parameter', async () => {
const { container, emitted } = renderComponent({
props: {
path: 'tag',
parameter: {
displayName: 'Tag',
name: 'tag',
type: 'string',
},
modelValue: '',
},
});
const input = container.querySelector('input') as HTMLInputElement;
expect(input).toBeInTheDocument();
await userEvent.click(input);
await paste(input, 'foo');
expect(emitted('update')).toContainEqual([expect.objectContaining({ value: 'foo' })]);
await paste(input, '={{ $json.foo }}');
expect(emitted('update')).toContainEqual([
expect.objectContaining({ value: '={{ $json.foo }}' }),
]);
await paste(input, '=flDvzj%y1nP');
expect(emitted('update')).toContainEqual([
expect.objectContaining({ value: '==flDvzj%y1nP' }),
]);
});
test('should handle pasting an expression into a number parameter', async () => {
const { container, emitted } = renderComponent({
props: {
path: 'percentage',
parameter: {
displayName: 'Percentage',
name: 'percentage',
type: 'number',
},
modelValue: 1,
},
});
const input = container.querySelector('input') as HTMLInputElement;
expect(input).toBeInTheDocument();
await userEvent.click(input);
await paste(input, '{{ $json.foo }}');
expect(emitted('update')).toContainEqual([
expect.objectContaining({ value: '={{ $json.foo }}' }),
]);
});
});
test('should not reset the value of a multi-select with loadOptionsMethod on load', async () => {
mockNodeTypesState.getNodeParameterOptions = vi.fn(async () => [
{ name: 'ID', value: 'id' },
{ name: 'Title', value: 'title' },
{ name: 'Description', value: 'description' },
]);
const { emitted, container } = renderComponent({
props: {
path: 'columns',
parameter: {
displayName: 'Columns',
name: 'columns',
type: 'multiOptions',
typeOptions: { loadOptionsMethod: 'getColumnsMultiOptions' },
},
modelValue: ['id', 'title'],
},
});
const input = container.querySelector('input') as HTMLInputElement;
expect(input).toBeInTheDocument();
// Nothing should be emitted
expect(emitted('update')).toBeUndefined();
});
test('should show message when can not load options without credentials', async () => {
mockNodeTypesState.getNodeParameterOptions = vi.fn(async () => {
throw new Error('Node does not have any credentials set');
});
// @ts-expect-error Readonly property
mockNodeTypesState.getNodeType = vi.fn().mockReturnValue({
displayName: 'Test',
credentials: [
{
name: 'openAiApi',
required: true,
},
],
});
const { emitted, container, getByTestId } = renderComponent({
props: {
path: 'columns',
parameter: {
displayName: 'Columns',
name: 'columns',
type: 'options',
typeOptions: { loadOptionsMethod: 'getColumnsMultiOptions' },
},
modelValue: 'id',
},
});
await waitFor(() => expect(getByTestId('parameter-input-field')).toBeInTheDocument());
const input = container.querySelector('input') as HTMLInputElement;
expect(input).toBeInTheDocument();
expect(mockNodeTypesState.getNodeParameterOptions).toHaveBeenCalled();
expect(input.value.toLowerCase()).not.toContain('error');
expect(input).toHaveValue('Set up credential to see options');
expect(emitted('update')).toBeUndefined();
});
test('should render workflow selector without issues when selected workflow is not archived', async () => {
const workflowId = faker.string.uuid();
const modelValue = {
mode: 'id',
value: workflowId,
};
workflowsStore.allWorkflows = [
{
id: workflowId,
name: 'Test',
active: false,
isArchived: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
nodes: [],
connections: {},
versionId: faker.string.uuid(),
},
];
const { emitted, container, getByTestId, queryByTestId } = renderComponent({
props: {
path: 'columns',
parameter: {
displayName: 'Workflow',
name: 'workflowId',
type: 'workflowSelector',
default: '',
},
modelValue,
},
});
await waitFor(() => expect(getByTestId('resource-locator-workflowId')).toBeInTheDocument());
expect(container.querySelector('.has-issues')).not.toBeInTheDocument();
const inputs = container.querySelectorAll('input');
const mode = inputs[0];
expect(mode).toBeInTheDocument();
expect(mode).toHaveValue('By ID');
const value = inputs[1];
expect(value).toBeInTheDocument();
expect(value).toHaveValue(workflowId);
expect(queryByTestId('parameter-issues')).not.toBeInTheDocument();
expect(emitted('update')).toBeUndefined();
});
test('should show error when workflow selector has archived workflow selected', async () => {
const workflowId = faker.string.uuid();
const modelValue: INodeParameterResourceLocator = {
__rl: true,
mode: 'id',
value: workflowId,
};
workflowsStore.allWorkflows = [
{
id: workflowId,
name: 'Test',
active: false,
isArchived: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
nodes: [],
connections: {},
versionId: faker.string.uuid(),
},
];
const { emitted, container, getByTestId } = renderComponent({
props: {
path: 'columns',
parameter: {
displayName: 'Workflow',
name: 'workflowId',
type: 'workflowSelector',
default: '',
},
modelValue,
},
});
await waitFor(() => expect(getByTestId('resource-locator-workflowId')).toBeInTheDocument());
expect(container.querySelector('.has-issues')).toBeInTheDocument();
const inputs = container.querySelectorAll('input');
const mode = inputs[0];
expect(mode).toBeInTheDocument();
expect(mode).toHaveValue('By ID');
const value = inputs[1];
expect(value).toBeInTheDocument();
expect(value).toHaveValue(workflowId);
expect(getByTestId('parameter-issues')).toBeInTheDocument();
expect(emitted('update')).toBeUndefined();
});
test('should reset bool on eventBus:removeExpression', async () => {
const eventBus = createEventBus();
const { emitted } = renderComponent({
props: {
path: 'aSwitch',
parameter: {
displayName: 'A Switch',
name: 'aSwitch',
type: 'boolean',
default: true,
},
modelValue: '={{ }}', // note that this makes a syntax error
eventBus,
},
});
eventBus.emit('optionSelected', 'removeExpression');
expect(emitted('update')).toContainEqual([expect.objectContaining({ value: true })]);
});
test('should reset bool with undefined evaluation on eventBus:removeExpression', async () => {
const eventBus = createEventBus();
const { emitted } = renderComponent({
props: {
path: 'aSwitch',
parameter: {
displayName: 'A Switch',
name: 'aSwitch',
type: 'boolean',
default: true,
},
modelValue: undefined,
eventBus,
},
});
eventBus.emit('optionSelected', 'removeExpression');
expect(emitted('update')).toContainEqual([expect.objectContaining({ value: true })]);
});
test('should reset number on eventBus:removeExpression', async () => {
const eventBus = createEventBus();
const { emitted } = renderComponent({
props: {
path: 'aNum',
parameter: {
displayName: 'A Num',
name: 'aNum',
type: 'number',
default: 6,
},
modelValue: '={{ }}', // note that this makes a syntax error
eventBus,
},
});
eventBus.emit('optionSelected', 'removeExpression');
expect(emitted('update')).toContainEqual([expect.objectContaining({ value: 6 })]);
});
test('should reset string on eventBus:removeExpression', async () => {
const eventBus = createEventBus();
const { emitted } = renderComponent({
props: {
path: 'aStr',
parameter: {
displayName: 'A Str',
name: 'aStr',
type: 'string',
default: 'some default',
},
modelValue: '={{ }}', // note that this makes a syntax error
eventBus,
},
});
eventBus.emit('optionSelected', 'removeExpression');
expect(emitted('update')).toContainEqual([expect.objectContaining({ value: '{{ }}' })]);
});
});