Commit e21c1a49 authored by Denys Mishunov's avatar Denys Mishunov

Merge branch 'nfriend-prevent-enter-submission' into 'master'

Prevent form submission on Enter in dropdown search fields

See merge request gitlab-org/gitlab!40011
parents bf15c795 54e47a49
...@@ -89,6 +89,14 @@ export default { ...@@ -89,6 +89,14 @@ export default {
return this.requestCount !== 0; return this.requestCount !== 0;
}, },
}, },
created() {
// This method is defined here instead of in `methods`
// because we need to access the .cancel() method
// lodash attaches to the function, which is
// made inaccessible by Vue. More info:
// https://stackoverflow.com/a/52988020/1063392
this.debouncedSearchMilestones = debounce(this.searchMilestones, 100);
},
mounted() { mounted() {
this.fetchMilestones(); this.fetchMilestones();
}, },
...@@ -108,7 +116,7 @@ export default { ...@@ -108,7 +116,7 @@ export default {
this.requestCount -= 1; this.requestCount -= 1;
}); });
}, },
searchMilestones: debounce(function searchMilestones() { searchMilestones() {
this.requestCount += 1; this.requestCount += 1;
const options = { const options = {
search: this.searchQuery, search: this.searchQuery,
...@@ -133,7 +141,14 @@ export default { ...@@ -133,7 +141,14 @@ export default {
.finally(() => { .finally(() => {
this.requestCount -= 1; this.requestCount -= 1;
}); });
}, 100), },
onSearchBoxInput() {
this.debouncedSearchMilestones();
},
onSearchBoxEnter() {
this.debouncedSearchMilestones.cancel();
this.searchMilestones();
},
toggleMilestoneSelection(clickedMilestone) { toggleMilestoneSelection(clickedMilestone) {
if (!clickedMilestone) return []; if (!clickedMilestone) return [];
...@@ -186,7 +201,8 @@ export default { ...@@ -186,7 +201,8 @@ export default {
v-model.trim="searchQuery" v-model.trim="searchQuery"
class="gl-m-3" class="gl-m-3"
:placeholder="this.$options.translations.searchMilestones" :placeholder="this.$options.translations.searchMilestones"
@input="searchMilestones" @input="onSearchBoxInput"
@keydown.enter.prevent="onSearchBoxEnter"
/> />
<gl-new-dropdown-item @click="onMilestoneClicked(null)"> <gl-new-dropdown-item @click="onMilestoneClicked(null)">
......
...@@ -87,6 +87,15 @@ export default { ...@@ -87,6 +87,15 @@ export default {
}, },
}, },
created() { created() {
// This method is defined here instead of in `methods`
// because we need to access the .cancel() method
// lodash attaches to the function, which is
// made inaccessible by Vue. More info:
// https://stackoverflow.com/a/52988020/1063392
this.debouncedSearch = debounce(function search() {
this.search(this.query);
}, SEARCH_DEBOUNCE_MS);
this.setProjectId(this.projectId); this.setProjectId(this.projectId);
this.search(this.query); this.search(this.query);
}, },
...@@ -95,9 +104,13 @@ export default { ...@@ -95,9 +104,13 @@ export default {
focusSearchBox() { focusSearchBox() {
this.$refs.searchBox.$el.querySelector('input').focus(); this.$refs.searchBox.$el.querySelector('input').focus();
}, },
onSearchBoxInput: debounce(function search() { onSearchBoxEnter() {
this.debouncedSearch.cancel();
this.search(this.query); this.search(this.query);
}, SEARCH_DEBOUNCE_MS), },
onSearchBoxInput() {
this.debouncedSearch();
},
selectRef(ref) { selectRef(ref) {
this.setSelectedRef(ref); this.setSelectedRef(ref);
this.$emit('input', this.selectedRef); this.$emit('input', this.selectedRef);
...@@ -129,6 +142,7 @@ export default { ...@@ -129,6 +142,7 @@ export default {
class="gl-m-3" class="gl-m-3"
:placeholder="i18n.searchPlaceholder" :placeholder="i18n.searchPlaceholder"
@input="onSearchBoxInput" @input="onSearchBoxInput"
@keydown.enter.prevent="onSearchBoxEnter"
/> />
<div class="gl-flex-grow-1 gl-overflow-y-auto"> <div class="gl-flex-grow-1 gl-overflow-y-auto">
......
...@@ -140,7 +140,7 @@ export default { ...@@ -140,7 +140,7 @@ export default {
class="form-control" class="form-control"
/> />
</gl-form-group> </gl-form-group>
<gl-form-group class="w-50" data-testid="milestones-field" @keydown.enter.prevent.capture> <gl-form-group class="w-50" data-testid="milestones-field">
<label>{{ __('Milestones') }}</label> <label>{{ __('Milestones') }}</label>
<div class="d-flex flex-column col-md-6 col-sm-10 pl-0"> <div class="d-flex flex-column col-md-6 col-sm-10 pl-0">
<milestone-combobox <milestone-combobox
......
---
title: Prevent form submission in search boxes on New Release and Edit Release pages
merge_request: 40011
author:
type: changed
...@@ -8,4 +8,15 @@ ...@@ -8,4 +8,15 @@
// [2]: https://gitlab.com/gitlab-org/gitlab/-/issues/213378 // [2]: https://gitlab.com/gitlab-org/gitlab/-/issues/213378
// Further reference: https://github.com/facebook/jest/issues/3465 // Further reference: https://github.com/facebook/jest/issues/3465
export default fn => fn; export default fn => {
const debouncedFn = jest.fn().mockImplementation(fn);
debouncedFn.cancel = jest.fn();
debouncedFn.flush = jest.fn().mockImplementation(() => {
const errorMessage =
"The .flush() method returned by lodash.debounce is not yet implemented/mocked by the mock in 'spec/frontend/__mocks__/lodash/debounce.js'.";
throw new Error(errorMessage);
});
return debouncedFn;
};
...@@ -2,10 +2,12 @@ import axios from 'axios'; ...@@ -2,10 +2,12 @@ import axios from 'axios';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlNewDropdown, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; import { GlNewDropdown, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
import { ENTER_KEY } from '~/lib/utils/keys';
import MilestoneCombobox from '~/milestones/project_milestone_combobox.vue'; import MilestoneCombobox from '~/milestones/project_milestone_combobox.vue';
import { milestones as projectMilestones } from './mock_data'; import { milestones as projectMilestones } from './mock_data';
const TEST_SEARCH_ENDPOINT = '/api/v4/projects/8/search'; const TEST_SEARCH_ENDPOINT = '/api/v4/projects/8/search';
const TEST_SEARCH = 'TEST_SEARCH';
const extraLinks = [ const extraLinks = [
{ text: 'Create new', url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/new' }, { text: 'Create new', url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/new' },
...@@ -21,6 +23,8 @@ describe('Milestone selector', () => { ...@@ -21,6 +23,8 @@ describe('Milestone selector', () => {
const findNoResultsMessage = () => wrapper.find({ ref: 'noResults' }); const findNoResultsMessage = () => wrapper.find({ ref: 'noResults' });
const findSearchBox = () => wrapper.find(GlSearchBoxByType);
const factory = (options = {}) => { const factory = (options = {}) => {
wrapper = shallowMount(MilestoneCombobox, { wrapper = shallowMount(MilestoneCombobox, {
...options, ...options,
...@@ -63,7 +67,7 @@ describe('Milestone selector', () => { ...@@ -63,7 +67,7 @@ describe('Milestone selector', () => {
describe('before results', () => { describe('before results', () => {
it('should show a loading icon', () => { it('should show a loading icon', () => {
const request = mock.onGet(TEST_SEARCH_ENDPOINT, { const request = mock.onGet(TEST_SEARCH_ENDPOINT, {
params: { search: 'TEST_SEARCH', scope: 'milestones' }, params: { search: TEST_SEARCH, scope: 'milestones' },
}); });
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
...@@ -85,9 +89,9 @@ describe('Milestone selector', () => { ...@@ -85,9 +89,9 @@ describe('Milestone selector', () => {
describe('with empty results', () => { describe('with empty results', () => {
beforeEach(() => { beforeEach(() => {
mock mock
.onGet(TEST_SEARCH_ENDPOINT, { params: { search: 'TEST_SEARCH', scope: 'milestones' } }) .onGet(TEST_SEARCH_ENDPOINT, { params: { search: TEST_SEARCH, scope: 'milestones' } })
.reply(200, []); .reply(200, []);
wrapper.find(GlSearchBoxByType).vm.$emit('input', 'TEST_SEARCH'); findSearchBox().vm.$emit('input', TEST_SEARCH);
return axios.waitForAll(); return axios.waitForAll();
}); });
...@@ -116,7 +120,7 @@ describe('Milestone selector', () => { ...@@ -116,7 +120,7 @@ describe('Milestone selector', () => {
web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/6', web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/6',
}, },
]); ]);
wrapper.find(GlSearchBoxByType).vm.$emit('input', 'v0.1'); findSearchBox().vm.$emit('input', 'v0.1');
return axios.waitForAll().then(() => { return axios.waitForAll().then(() => {
items = wrapper.findAll('[role="milestone option"]'); items = wrapper.findAll('[role="milestone option"]');
}); });
...@@ -147,4 +151,36 @@ describe('Milestone selector', () => { ...@@ -147,4 +151,36 @@ describe('Milestone selector', () => {
expect(findNoResultsMessage().exists()).toBe(false); expect(findNoResultsMessage().exists()).toBe(false);
}); });
}); });
describe('when Enter is pressed', () => {
beforeEach(() => {
factory({
propsData: {
projectId,
preselectedMilestones,
extraLinks,
},
data() {
return {
searchQuery: 'TEST_SEARCH',
};
},
});
mock
.onGet(TEST_SEARCH_ENDPOINT, { params: { search: 'TEST_SEARCH', scope: 'milestones' } })
.reply(200, []);
});
it('should trigger a search', async () => {
mock.resetHistory();
findSearchBox().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
await axios.waitForAll();
expect(mock.history.get.length).toBe(1);
expect(mock.history.get[0].url).toBe(TEST_SEARCH_ENDPOINT);
});
});
}); });
...@@ -5,6 +5,7 @@ import MockAdapter from 'axios-mock-adapter'; ...@@ -5,6 +5,7 @@ import MockAdapter from 'axios-mock-adapter';
import { GlLoadingIcon, GlSearchBoxByType, GlNewDropdownItem, GlIcon } from '@gitlab/ui'; import { GlLoadingIcon, GlSearchBoxByType, GlNewDropdownItem, GlIcon } from '@gitlab/ui';
import { trimText } from 'helpers/text_helper'; import { trimText } from 'helpers/text_helper';
import { sprintf } from '~/locale'; import { sprintf } from '~/locale';
import { ENTER_KEY } from '~/lib/utils/keys';
import RefSelector from '~/ref/components/ref_selector.vue'; import RefSelector from '~/ref/components/ref_selector.vue';
import { X_TOTAL_HEADER, DEFAULT_I18N } from '~/ref/constants'; import { X_TOTAL_HEADER, DEFAULT_I18N } from '~/ref/constants';
import createStore from '~/ref/stores/'; import createStore from '~/ref/stores/';
...@@ -83,6 +84,8 @@ describe('Ref selector component', () => { ...@@ -83,6 +84,8 @@ describe('Ref selector component', () => {
const findLoadingIcon = () => wrapper.find(GlLoadingIcon); const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findSearchBox = () => wrapper.find(GlSearchBoxByType);
const findBranchesSection = () => wrapper.find('[data-testid="branches-section"]'); const findBranchesSection = () => wrapper.find('[data-testid="branches-section"]');
const findBranchDropdownItems = () => findBranchesSection().findAll(GlNewDropdownItem); const findBranchDropdownItems = () => findBranchesSection().findAll(GlNewDropdownItem);
const findFirstBranchDropdownItem = () => findBranchDropdownItems().at(0); const findFirstBranchDropdownItem = () => findBranchDropdownItems().at(0);
...@@ -120,7 +123,7 @@ describe('Ref selector component', () => { ...@@ -120,7 +123,7 @@ describe('Ref selector component', () => {
// Convenience methods // Convenience methods
// //
const updateQuery = newQuery => { const updateQuery = newQuery => {
wrapper.find(GlSearchBoxByType).vm.$emit('input', newQuery); findSearchBox().vm.$emit('input', newQuery);
}; };
const selectFirstBranch = () => { const selectFirstBranch = () => {
...@@ -244,6 +247,23 @@ describe('Ref selector component', () => { ...@@ -244,6 +247,23 @@ describe('Ref selector component', () => {
}); });
}); });
describe('when the Enter is pressed', () => {
beforeEach(() => {
createComponent();
return waitForRequests({ andClearMocks: true });
});
it('requeries the endpoints when Enter is pressed', () => {
findSearchBox().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
return waitForRequests().then(() => {
expect(branchesApiCallSpy).toHaveBeenCalledTimes(1);
expect(tagsApiCallSpy).toHaveBeenCalledTimes(1);
});
});
});
describe('when no results are found', () => { describe('when no results are found', () => {
beforeEach(() => { beforeEach(() => {
branchesApiCallSpy = jest.fn().mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]); branchesApiCallSpy = jest.fn().mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]);
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment