mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 01:56:46 +00:00
fix(editor): Add debounce to text parameter input (#19339)
This commit is contained in:
@@ -37,7 +37,8 @@ export const simpleWebhookCall = (options: SimpleWebhookCallOptions) => {
|
|||||||
.find('.parameter-input')
|
.find('.parameter-input')
|
||||||
.find('input')
|
.find('input')
|
||||||
.clear()
|
.clear()
|
||||||
.type(webhookPath);
|
.type(webhookPath)
|
||||||
|
.wait(300);
|
||||||
|
|
||||||
if (authentication) {
|
if (authentication) {
|
||||||
cy.getByTestId('parameter-input-authentication').click();
|
cy.getByTestId('parameter-input-authentication').click();
|
||||||
@@ -46,7 +47,8 @@ export const simpleWebhookCall = (options: SimpleWebhookCallOptions) => {
|
|||||||
|
|
||||||
if (responseCode) {
|
if (responseCode) {
|
||||||
cy.get('.param-options').click();
|
cy.get('.param-options').click();
|
||||||
getVisibleSelect().contains('Response Code').click();
|
// wait for selector debounce
|
||||||
|
getVisibleSelect().contains('Response Code').click().wait(300);
|
||||||
cy.get('.parameter-item-wrapper > .parameter-input-list-wrapper').children().click();
|
cy.get('.parameter-item-wrapper > .parameter-input-list-wrapper').children().click();
|
||||||
getVisibleSelect().contains('201').click();
|
getVisibleSelect().contains('201').click();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,8 @@ describe('n8n Form Trigger', () => {
|
|||||||
|
|
||||||
//add optional submitted message
|
//add optional submitted message
|
||||||
cy.get('.param-options').click();
|
cy.get('.param-options').click();
|
||||||
getVisibleSelect().find('span').contains('Form Response').click();
|
// wait for selector debounce
|
||||||
|
getVisibleSelect().find('span').contains('Form Response').click().wait(300);
|
||||||
cy.contains('span', 'Text to Show')
|
cy.contains('span', 'Text to Show')
|
||||||
.should('exist')
|
.should('exist')
|
||||||
.parent()
|
.parent()
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"cypress:install": "cypress install",
|
"cypress:install": "cypress install",
|
||||||
"test:e2e:ui": "scripts/run-e2e.js ui",
|
"test:e2e:ui": "node scripts/run-e2e.js ui",
|
||||||
"test:e2e:dev": "scripts/run-e2e.js dev",
|
"test:e2e:dev": "node scripts/run-e2e.js dev",
|
||||||
"test:e2e:all": "scripts/run-e2e.js all",
|
"test:e2e:all": "node scripts/run-e2e.js all",
|
||||||
"test:flaky": "scripts/run-e2e.js debugFlaky",
|
"test:flaky": "node scripts/run-e2e.js debugFlaky",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"format:check": "biome ci .",
|
"format:check": "biome ci .",
|
||||||
"lint": "eslint . --quiet",
|
"lint": "eslint . --quiet",
|
||||||
|
|||||||
@@ -74,7 +74,9 @@ export class CredentialsModal extends BasePage {
|
|||||||
.filter(':not([readonly])')
|
.filter(':not([readonly])')
|
||||||
.each(($el) => {
|
.each(($el) => {
|
||||||
cy.wrap($el).type('test');
|
cy.wrap($el).type('test');
|
||||||
});
|
})
|
||||||
|
// wait for text input debounce
|
||||||
|
.wait(300);
|
||||||
saveCredential();
|
saveCredential();
|
||||||
if (closeModal) {
|
if (closeModal) {
|
||||||
this.getters.closeButton().click();
|
this.getters.closeButton().click();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { computed, nextTick, ref } from 'vue';
|
|||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { fireEvent } from '@testing-library/vue';
|
import { fireEvent, waitFor } from '@testing-library/vue';
|
||||||
import Assignment from './Assignment.vue';
|
import Assignment from './Assignment.vue';
|
||||||
import { defaultSettings } from '@/__tests__/defaults';
|
import { defaultSettings } from '@/__tests__/defaults';
|
||||||
import { STORES } from '@n8n/stores';
|
import { STORES } from '@n8n/stores';
|
||||||
@@ -53,9 +53,11 @@ describe('Assignment.vue', () => {
|
|||||||
|
|
||||||
await userEvent.click(baseElement.querySelectorAll('.option')[3]);
|
await userEvent.click(baseElement.querySelectorAll('.option')[3]);
|
||||||
|
|
||||||
expect(emitted('update:model-value')[0]).toEqual([
|
await waitFor(() =>
|
||||||
{ name: 'New name', type: 'array', value: 'New value' },
|
expect(emitted('update:model-value')[0]).toEqual([
|
||||||
]);
|
{ name: 'New name', type: 'array', value: 'New value' },
|
||||||
|
]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can remove itself', async () => {
|
it('can remove itself', async () => {
|
||||||
|
|||||||
@@ -221,7 +221,9 @@ describe('ParameterInput.vue', () => {
|
|||||||
|
|
||||||
await userEvent.click(options[1]);
|
await userEvent.click(options[1]);
|
||||||
|
|
||||||
expect(emitted('update')).toContainEqual([expect.objectContaining({ value: 1 })]);
|
await waitFor(() =>
|
||||||
|
expect(emitted('update')).toContainEqual([expect.objectContaining({ value: 1 })]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should render a string parameter', async () => {
|
test('should render a string parameter', async () => {
|
||||||
@@ -241,7 +243,9 @@ describe('ParameterInput.vue', () => {
|
|||||||
|
|
||||||
await userEvent.type(input, 'foo');
|
await userEvent.type(input, 'foo');
|
||||||
|
|
||||||
expect(emitted('update')).toContainEqual([expect.objectContaining({ value: 'foo' })]);
|
await waitFor(() =>
|
||||||
|
expect(emitted('update')).toContainEqual([expect.objectContaining({ value: 'foo' })]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('paste events', () => {
|
describe('paste events', () => {
|
||||||
@@ -269,17 +273,23 @@ describe('ParameterInput.vue', () => {
|
|||||||
await userEvent.click(input);
|
await userEvent.click(input);
|
||||||
|
|
||||||
await paste(input, 'foo');
|
await paste(input, 'foo');
|
||||||
expect(emitted('update')).toContainEqual([expect.objectContaining({ value: 'foo' })]);
|
await waitFor(() =>
|
||||||
|
expect(emitted('update')).toContainEqual([expect.objectContaining({ value: 'foo' })]),
|
||||||
|
);
|
||||||
|
|
||||||
await paste(input, '={{ $json.foo }}');
|
await paste(input, '={{ $json.foo }}');
|
||||||
expect(emitted('update')).toContainEqual([
|
await waitFor(() =>
|
||||||
expect.objectContaining({ value: '={{ $json.foo }}' }),
|
expect(emitted('update')).toContainEqual([
|
||||||
]);
|
expect.objectContaining({ value: '={{ $json.foo }}' }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
await paste(input, '=flDvzj%y1nP');
|
await paste(input, '=flDvzj%y1nP');
|
||||||
expect(emitted('update')).toContainEqual([
|
await waitFor(() =>
|
||||||
expect.objectContaining({ value: '==flDvzj%y1nP' }),
|
expect(emitted('update')).toContainEqual([
|
||||||
]);
|
expect.objectContaining({ value: '==flDvzj%y1nP' }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should handle pasting an expression into a number parameter', async () => {
|
test('should handle pasting an expression into a number parameter', async () => {
|
||||||
@@ -299,9 +309,11 @@ describe('ParameterInput.vue', () => {
|
|||||||
await userEvent.click(input);
|
await userEvent.click(input);
|
||||||
|
|
||||||
await paste(input, '{{ $json.foo }}');
|
await paste(input, '{{ $json.foo }}');
|
||||||
expect(emitted('update')).toContainEqual([
|
await waitFor(() =>
|
||||||
expect.objectContaining({ value: '={{ $json.foo }}' }),
|
expect(emitted('update')).toContainEqual([
|
||||||
]);
|
expect.objectContaining({ value: '={{ $json.foo }}' }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -603,4 +615,41 @@ describe('ParameterInput.vue', () => {
|
|||||||
await waitFor(() => expect(expressionEditorInput).not.toHaveFocus());
|
await waitFor(() => expect(expressionEditorInput).not.toHaveFocus());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('debounced input', () => {
|
||||||
|
test('should debounce text input and emit update event only once', async () => {
|
||||||
|
const { container, emitted } = renderComponent({
|
||||||
|
props: {
|
||||||
|
path: 'textField',
|
||||||
|
parameter: {
|
||||||
|
displayName: 'Text Field',
|
||||||
|
name: 'textField',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
modelValue: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = container.querySelector('input') as HTMLInputElement;
|
||||||
|
expect(input).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(input);
|
||||||
|
|
||||||
|
await userEvent.type(input, 'h');
|
||||||
|
await userEvent.type(input, 'e');
|
||||||
|
await userEvent.type(input, 'l');
|
||||||
|
await userEvent.type(input, 'l');
|
||||||
|
await userEvent.type(input, 'o');
|
||||||
|
// by now the update event should not have been emitted because of debouncing
|
||||||
|
expect(emitted('update')).not.toContainEqual([expect.objectContaining({ value: 'hello' })]);
|
||||||
|
|
||||||
|
// Now the update event should have been emitted
|
||||||
|
await waitFor(() => {
|
||||||
|
const updateEvents = emitted('update');
|
||||||
|
expect(updateEvents).toBeDefined();
|
||||||
|
expect(updateEvents.length).toBeLessThan(5);
|
||||||
|
expect(updateEvents).toContainEqual([expect.objectContaining({ value: 'hello' })]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1010,6 +1010,8 @@ function onUpdateTextInput(value: string) {
|
|||||||
onTextInputChange(value);
|
onTextInputChange(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onUpdateTextInputDebounced = debounce(onUpdateTextInput, { debounceTime: 200 });
|
||||||
|
|
||||||
function onClickOutsideMapper() {
|
function onClickOutsideMapper() {
|
||||||
if (!isFocused.value) {
|
if (!isFocused.value) {
|
||||||
isMapperShown.value = false;
|
isMapperShown.value = false;
|
||||||
@@ -1150,7 +1152,8 @@ defineExpose({
|
|||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
valueChangedDebounced.cancel();
|
valueChangedDebounced.flush();
|
||||||
|
onUpdateTextInputDebounced.flush();
|
||||||
props.eventBus.off('optionSelected', optionSelected);
|
props.eventBus.off('optionSelected', optionSelected);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1289,7 +1292,7 @@ onClickOutside(mapperElRef, onClickOutsideMapper);
|
|||||||
:node="node"
|
:node="node"
|
||||||
:path="path"
|
:path="path"
|
||||||
:event-bus="eventBus"
|
:event-bus="eventBus"
|
||||||
@update:model-value="valueChanged"
|
@update:model-value="valueChangedDebounced"
|
||||||
@modal-opener-click="openExpressionEditorModal"
|
@modal-opener-click="openExpressionEditorModal"
|
||||||
@focus="setFocus"
|
@focus="setFocus"
|
||||||
@blur="onBlur"
|
@blur="onBlur"
|
||||||
@@ -1309,7 +1312,7 @@ onClickOutside(mapperElRef, onClickOutsideMapper);
|
|||||||
:path="path"
|
:path="path"
|
||||||
:parameter-issues="getIssues"
|
:parameter-issues="getIssues"
|
||||||
:is-read-only="isReadOnly"
|
:is-read-only="isReadOnly"
|
||||||
@update:model-value="valueChanged"
|
@update:model-value="valueChangedDebounced"
|
||||||
@modal-opener-click="openExpressionEditorModal"
|
@modal-opener-click="openExpressionEditorModal"
|
||||||
@focus="setFocus"
|
@focus="setFocus"
|
||||||
@blur="onBlur"
|
@blur="onBlur"
|
||||||
@@ -1574,7 +1577,6 @@ onClickOutside(mapperElRef, onClickOutsideMapper);
|
|||||||
:rows="editorRows"
|
:rows="editorRows"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<N8nInput
|
<N8nInput
|
||||||
v-else
|
v-else
|
||||||
ref="inputField"
|
ref="inputField"
|
||||||
@@ -1591,7 +1593,7 @@ onClickOutside(mapperElRef, onClickOutsideMapper);
|
|||||||
:title="displayTitle"
|
:title="displayTitle"
|
||||||
:placeholder="getPlaceholder()"
|
:placeholder="getPlaceholder()"
|
||||||
data-test-id="parameter-input-field"
|
data-test-id="parameter-input-field"
|
||||||
@update:model-value="(valueChanged($event) as undefined) && onUpdateTextInput($event)"
|
@update:model-value="onUpdateTextInputDebounced($event)"
|
||||||
@keydown.stop
|
@keydown.stop
|
||||||
@focus="setFocus"
|
@focus="setFocus"
|
||||||
@blur="onBlur"
|
@blur="onBlur"
|
||||||
@@ -1637,7 +1639,7 @@ onClickOutside(mapperElRef, onClickOutsideMapper);
|
|||||||
type="text"
|
type="text"
|
||||||
:disabled="isReadOnly"
|
:disabled="isReadOnly"
|
||||||
:title="displayTitle"
|
:title="displayTitle"
|
||||||
@update:model-value="valueChanged"
|
@update:model-value="valueChangedDebounced"
|
||||||
@keydown.stop
|
@keydown.stop
|
||||||
@focus="setFocus"
|
@focus="setFocus"
|
||||||
@blur="onBlur"
|
@blur="onBlur"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { createTestingPinia } from '@pinia/testing';
|
|||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { createAppModals } from '@/__tests__/utils';
|
import { createAppModals } from '@/__tests__/utils';
|
||||||
import { STORES } from '@n8n/stores';
|
import { STORES } from '@n8n/stores';
|
||||||
|
import { waitFor } from '@testing-library/vue';
|
||||||
|
|
||||||
vi.mock('vue-router', () => {
|
vi.mock('vue-router', () => {
|
||||||
return {
|
return {
|
||||||
@@ -107,15 +108,16 @@ describe('ParameterOverrideSelectableList', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await userEvent.type(getByTestId('parameter-input-field'), '2');
|
await userEvent.type(getByTestId('parameter-input-field'), '2');
|
||||||
expect(model.value.extraPropValues.description).toBe('Test description2');
|
await waitFor(() => {
|
||||||
expect(emitted('update')).toHaveLength(1);
|
expect(model.value.extraPropValues.description).toBe('Test description2');
|
||||||
expect(emitted('update')[0]).toEqual([
|
expect(emitted('update')).toContainEqual([
|
||||||
{
|
{
|
||||||
name: 'parameters.workflowInputs.value["test"]',
|
name: 'parameters.workflowInputs.value["test"]',
|
||||||
value:
|
value:
|
||||||
"={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('test', `Test description2`, 'string') }}",
|
"={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('test', `Test description2`, 'string') }}",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reset extra prop back to default when removed', async () => {
|
it('should reset extra prop back to default when removed', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user