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('.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();
} }

View File

@@ -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()

View File

@@ -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",

View File

@@ -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();

View File

@@ -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 () => {

View File

@@ -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' })]);
});
});
});
}); });

View File

@@ -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"

View File

@@ -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 () => {