fix(editor): Add debounce to text parameter input (#19339)

This commit is contained in:
yehorkardash
2025-09-11 08:56:18 +00:00
committed by GitHub
parent fea0a62f8e
commit 18cccb29ea
8 changed files with 99 additions and 39 deletions

View File

@@ -37,7 +37,8 @@ export const simpleWebhookCall = (options: SimpleWebhookCallOptions) => {
.find('.parameter-input')
.find('input')
.clear()
.type(webhookPath);
.type(webhookPath)
.wait(300);
if (authentication) {
cy.getByTestId('parameter-input-authentication').click();
@@ -46,7 +47,8 @@ export const simpleWebhookCall = (options: SimpleWebhookCallOptions) => {
if (responseCode) {
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();
getVisibleSelect().contains('201').click();
}

View File

@@ -78,7 +78,8 @@ describe('n8n Form Trigger', () => {
//add optional submitted message
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')
.should('exist')
.parent()

View File

@@ -4,10 +4,10 @@
"scripts": {
"typecheck": "tsc --noEmit",
"cypress:install": "cypress install",
"test:e2e:ui": "scripts/run-e2e.js ui",
"test:e2e:dev": "scripts/run-e2e.js dev",
"test:e2e:all": "scripts/run-e2e.js all",
"test:flaky": "scripts/run-e2e.js debugFlaky",
"test:e2e:ui": "node scripts/run-e2e.js ui",
"test:e2e:dev": "node scripts/run-e2e.js dev",
"test:e2e:all": "node scripts/run-e2e.js all",
"test:flaky": "node scripts/run-e2e.js debugFlaky",
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "eslint . --quiet",

View File

@@ -74,7 +74,9 @@ export class CredentialsModal extends BasePage {
.filter(':not([readonly])')
.each(($el) => {
cy.wrap($el).type('test');
});
})
// wait for text input debounce
.wait(300);
saveCredential();
if (closeModal) {
this.getters.closeButton().click();

View File

@@ -2,7 +2,7 @@ import { computed, nextTick, ref } from 'vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
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 { defaultSettings } from '@/__tests__/defaults';
import { STORES } from '@n8n/stores';
@@ -53,9 +53,11 @@ describe('Assignment.vue', () => {
await userEvent.click(baseElement.querySelectorAll('.option')[3]);
expect(emitted('update:model-value')[0]).toEqual([
{ name: 'New name', type: 'array', value: 'New value' },
]);
await waitFor(() =>
expect(emitted('update:model-value')[0]).toEqual([
{ name: 'New name', type: 'array', value: 'New value' },
]),
);
});
it('can remove itself', async () => {

View File

@@ -221,7 +221,9 @@ describe('ParameterInput.vue', () => {
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 () => {
@@ -241,7 +243,9 @@ describe('ParameterInput.vue', () => {
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', () => {
@@ -269,17 +273,23 @@ describe('ParameterInput.vue', () => {
await userEvent.click(input);
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 }}');
expect(emitted('update')).toContainEqual([
expect.objectContaining({ value: '={{ $json.foo }}' }),
]);
await waitFor(() =>
expect(emitted('update')).toContainEqual([
expect.objectContaining({ value: '={{ $json.foo }}' }),
]),
);
await paste(input, '=flDvzj%y1nP');
expect(emitted('update')).toContainEqual([
expect.objectContaining({ value: '==flDvzj%y1nP' }),
]);
await waitFor(() =>
expect(emitted('update')).toContainEqual([
expect.objectContaining({ value: '==flDvzj%y1nP' }),
]),
);
});
test('should handle pasting an expression into a number parameter', async () => {
@@ -299,9 +309,11 @@ describe('ParameterInput.vue', () => {
await userEvent.click(input);
await paste(input, '{{ $json.foo }}');
expect(emitted('update')).toContainEqual([
expect.objectContaining({ value: '={{ $json.foo }}' }),
]);
await waitFor(() =>
expect(emitted('update')).toContainEqual([
expect.objectContaining({ value: '={{ $json.foo }}' }),
]),
);
});
});
@@ -603,4 +615,41 @@ describe('ParameterInput.vue', () => {
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' })]);
});
});
});
});

View File

@@ -1010,6 +1010,8 @@ function onUpdateTextInput(value: string) {
onTextInputChange(value);
}
const onUpdateTextInputDebounced = debounce(onUpdateTextInput, { debounceTime: 200 });
function onClickOutsideMapper() {
if (!isFocused.value) {
isMapperShown.value = false;
@@ -1150,7 +1152,8 @@ defineExpose({
});
onBeforeUnmount(() => {
valueChangedDebounced.cancel();
valueChangedDebounced.flush();
onUpdateTextInputDebounced.flush();
props.eventBus.off('optionSelected', optionSelected);
});
@@ -1289,7 +1292,7 @@ onClickOutside(mapperElRef, onClickOutsideMapper);
:node="node"
:path="path"
:event-bus="eventBus"
@update:model-value="valueChanged"
@update:model-value="valueChangedDebounced"
@modal-opener-click="openExpressionEditorModal"
@focus="setFocus"
@blur="onBlur"
@@ -1309,7 +1312,7 @@ onClickOutside(mapperElRef, onClickOutsideMapper);
:path="path"
:parameter-issues="getIssues"
:is-read-only="isReadOnly"
@update:model-value="valueChanged"
@update:model-value="valueChangedDebounced"
@modal-opener-click="openExpressionEditorModal"
@focus="setFocus"
@blur="onBlur"
@@ -1574,7 +1577,6 @@ onClickOutside(mapperElRef, onClickOutsideMapper);
:rows="editorRows"
/>
</div>
<N8nInput
v-else
ref="inputField"
@@ -1591,7 +1593,7 @@ onClickOutside(mapperElRef, onClickOutsideMapper);
:title="displayTitle"
:placeholder="getPlaceholder()"
data-test-id="parameter-input-field"
@update:model-value="(valueChanged($event) as undefined) && onUpdateTextInput($event)"
@update:model-value="onUpdateTextInputDebounced($event)"
@keydown.stop
@focus="setFocus"
@blur="onBlur"
@@ -1637,7 +1639,7 @@ onClickOutside(mapperElRef, onClickOutsideMapper);
type="text"
:disabled="isReadOnly"
:title="displayTitle"
@update:model-value="valueChanged"
@update:model-value="valueChangedDebounced"
@keydown.stop
@focus="setFocus"
@blur="onBlur"

View File

@@ -5,6 +5,7 @@ import { createTestingPinia } from '@pinia/testing';
import { ref } from 'vue';
import { createAppModals } from '@/__tests__/utils';
import { STORES } from '@n8n/stores';
import { waitFor } from '@testing-library/vue';
vi.mock('vue-router', () => {
return {
@@ -107,15 +108,16 @@ describe('ParameterOverrideSelectableList', () => {
});
await userEvent.type(getByTestId('parameter-input-field'), '2');
expect(model.value.extraPropValues.description).toBe('Test description2');
expect(emitted('update')).toHaveLength(1);
expect(emitted('update')[0]).toEqual([
{
name: 'parameters.workflowInputs.value["test"]',
value:
"={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('test', `Test description2`, 'string') }}",
},
]);
await waitFor(() => {
expect(model.value.extraPropValues.description).toBe('Test description2');
expect(emitted('update')).toContainEqual([
{
name: 'parameters.workflowInputs.value["test"]',
value:
"={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('test', `Test description2`, 'string') }}",
},
]);
});
});
it('should reset extra prop back to default when removed', async () => {