Commit 4c48e6be authored by Angelo Gulina's avatar Angelo Gulina Committed by Simon Knox

Make environments dropdown fetch results as soon as it's focused

- reduces number of calls per input event
- uses GitLab UI component on old dropdown
parent 29ac2713
<script>
import { isEmpty } from 'lodash';
import { GlLoadingIcon, GlDeprecatedButton, GlIcon } from '@gitlab/ui';
import { debounce } from 'lodash';
import { GlDeprecatedButton, GlSearchBoxByType } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import { deprecatedCreateFlash as createFlash } from '~/flash';
......@@ -9,8 +9,8 @@ import { deprecatedCreateFlash as createFlash } from '~/flash';
* Creates a searchable input for environments.
*
* When given a value, it will render it as selected value
* Otherwise it will render a placeholder for the search
* input.
* Otherwise it will render a placeholder for the search input.
* It will fetch the available environments on focus.
*
* When the user types, it will trigger an event to allow
* for API queries outside of the component.
......@@ -29,8 +29,7 @@ export default {
name: 'EnvironmentsSearchableInput',
components: {
GlDeprecatedButton,
GlLoadingIcon,
GlIcon,
GlSearchBoxByType,
},
props: {
endpoint: {
......@@ -60,7 +59,7 @@ export default {
},
data() {
return {
filter: this.value || '',
environmentSearch: this.value,
results: [],
showSuggestions: false,
isLoading: false,
......@@ -72,49 +71,28 @@ export default {
* @returns {String}
*/
composedCreateButtonLabel() {
return `${this.createButtonLabel} ${this.filter}`;
return `${this.createButtonLabel} ${this.environmentSearch}`;
},
/**
* Create button is available when
* - loading is false, filter is set and no results are available
* @returns Boolean
*/
shouldRenderCreateButton() {
return !isEmpty(this.filter) && !this.isLoading && !this.results.length;
},
},
watch: {
value(newVal) {
this.filter = newVal;
return !this.isLoading && !this.results.length;
},
},
methods: {
/**
* On each input event, it updates the filter value and fetches the
* list of environments based on the value typed.
*
* Since we need to update the input value both with the value provided by the parent
* and the value typed by the user, we can't use v-model.
*/
fetchEnvironments(evt) {
this.filter = evt.target.value;
fetchEnvironments: debounce(function debouncedFetchEnvironments() {
this.isLoading = true;
this.openSuggestions();
return axios
.get(this.endpoint, { params: { query: this.filter } })
axios
.get(this.endpoint, { params: { query: this.environmentSearch } })
.then(({ data }) => {
this.results = data;
this.results = data || [];
this.isLoading = false;
})
.catch(() => {
this.isLoading = false;
this.closeSuggestions();
createFlash(__('Something went wrong on our end. Please try again.'));
});
},
}, 250),
/**
* Opens the list of suggestions
*/
......@@ -126,7 +104,7 @@ export default {
*/
closeSuggestions() {
this.showSuggestions = false;
this.results = [];
this.environmentSearch = '';
},
/**
* On click, it will:
......@@ -135,7 +113,6 @@ export default {
* 3. emit an event
*/
clearInput() {
this.filter = '';
this.closeSuggestions();
this.$emit('clearInput');
},
......@@ -150,19 +127,16 @@ export default {
*/
selectEnvironment(selected) {
this.$emit('selectEnvironment', selected);
this.filter = '';
this.results = [];
this.closeSuggestions();
},
/**
* When the user clicks the create button
* it emits an event with the filter value
* Clears the input and closes the list of suggestions.
*/
createClicked() {
this.$emit('createClicked', this.filter);
this.filter = '';
this.$emit('createClicked', this.environmentSearch);
this.closeSuggestions();
},
},
......@@ -171,44 +145,31 @@ export default {
<template>
<div>
<div class="dropdown position-relative">
<gl-icon name="search" class="search-icon-input" />
<input
type="text"
class="form-control pl-4 js-env-input"
<gl-search-box-by-type
v-model.trim="environmentSearch"
class="js-env-search"
:aria-label="placeholder"
:value="filter"
:placeholder="placeholder"
:disabled="disabled"
@input="fetchEnvironments"
:is-loading="isLoading"
@focus="fetchEnvironments"
@keyup="fetchEnvironments"
/>
<gl-deprecated-button
v-if="!disabled"
class="js-clear-search-input btn-transparent clear-search-input position-right-0"
@click="clearInput"
>
<gl-icon name="clear" :aria-label="__('Clear input')" />
</gl-deprecated-button>
<div
v-if="showSuggestions"
class="dropdown-menu d-block dropdown-menu-selectable dropdown-menu-full-width"
>
<div class="dropdown-content">
<gl-loading-icon v-if="isLoading" />
<ul v-else-if="results.length">
<ul v-if="results.length">
<li v-for="(result, i) in results" :key="i">
<gl-deprecated-button class="btn-transparent" @click="selectEnvironment(result)">{{
result
}}</gl-deprecated-button>
</li>
</ul>
<div v-else-if="!results.length" class="text-secondary p-2">
<div v-else-if="!results.length" class="text-secondary gl-p-3">
{{ __('No matching results') }}
</div>
<div v-if="shouldRenderCreateButton" class="dropdown-footer">
<gl-deprecated-button
class="js-create-button btn-blank dropdown-item"
......
......@@ -410,7 +410,7 @@ export default {
class="col-12"
:value="scope.environmentScope"
:endpoint="environmentsEndpoint"
:disabled="!canUpdateScope(scope)"
:disabled="!canUpdateScope(scope) || scope.environmentScope !== ''"
@selectEnvironment="env => (scope.environmentScope = env)"
@createClicked="env => (scope.environmentScope = env)"
@clearInput="env => (scope.environmentScope = '')"
......
<script>
import { debounce } from 'lodash';
import {
GlNewDropdown,
GlNewDropdownDivider,
......@@ -30,7 +31,6 @@ export default {
return {
environmentSearch: '',
results: [],
filter: '',
isLoading: false,
};
},
......@@ -40,23 +40,21 @@ export default {
},
computed: {
createEnvironmentLabel() {
return sprintf(__('Create %{environment}'), { environment: this.filter });
return sprintf(__('Create %{environment}'), { environment: this.environmentSearch });
},
},
methods: {
addEnvironment(newEnvironment) {
this.$emit('add', newEnvironment);
this.environmentSearch = '';
this.filter = '';
this.results = [];
},
fetchEnvironments() {
this.filter = this.environmentSearch;
fetchEnvironments: debounce(function debouncedFetchEnvironments() {
this.isLoading = true;
axios
.get(this.endpoint, { params: { query: this.filter } })
.get(this.endpoint, { params: { query: this.environmentSearch } })
.then(({ data }) => {
this.results = data;
this.results = data || [];
})
.catch(() => {
createFlash(__('Something went wrong on our end. Please try again.'));
......@@ -64,12 +62,15 @@ export default {
.finally(() => {
this.isLoading = false;
});
}, 250),
setFocus() {
this.$refs.searchBox.focusInput();
},
},
};
</script>
<template>
<gl-new-dropdown class="js-new-environments-dropdown">
<gl-new-dropdown class="js-new-environments-dropdown" @shown="setFocus">
<template #button-content>
<span class="d-md-none mr-1">
{{ $options.translations.addEnvironmentsLabel }}
......@@ -77,9 +78,11 @@ export default {
<gl-icon class="d-none d-md-inline-flex" name="plus" />
</template>
<gl-search-box-by-type
ref="searchBox"
v-model.trim="environmentSearch"
class="gl-m-3"
@input="fetchEnvironments"
@focus="fetchEnvironments"
@keyup="fetchEnvironments"
/>
<gl-loading-icon v-if="isLoading" />
<gl-new-dropdown-item
......@@ -90,12 +93,12 @@ export default {
>
{{ environment }}
</gl-new-dropdown-item>
<template v-else-if="filter.length">
<span ref="noResults" class="text-secondary p-2">
<template v-else-if="environmentSearch.length">
<span ref="noResults" class="text-secondary gl-p-3">
{{ $options.translations.noMatchingResults }}
</span>
<gl-new-dropdown-divider />
<gl-new-dropdown-item @click="addEnvironment(filter)">
<gl-new-dropdown-item @click="addEnvironment(environmentSearch)">
{{ createEnvironmentLabel }}
</gl-new-dropdown-item>
</template>
......
---
title: Make environments dropdown fetch results as soon as it's focused
merge_request: 40624
author:
type: changed
......@@ -130,7 +130,7 @@ RSpec.describe 'User creates feature flag', :js do
within_scope_row(2) do
within_environment_spec do
find('.js-env-input').set("review/*")
find('.js-env-search > input').set("review/*")
find('.js-create-button').click
end
end
......@@ -164,7 +164,7 @@ RSpec.describe 'User creates feature flag', :js do
within_scope_row(2) do
within_environment_spec do
find('.js-env-input').set('prod')
find('.js-env-search > input').set('prod')
click_button 'production'
end
end
......
......@@ -137,7 +137,7 @@ RSpec.describe 'User updates feature flag', :js do
before do
within_scope_row(3) do
within_environment_spec do
find('.js-env-input').set('production')
find('.js-env-search > input').set('production')
find('.js-create-button').click
end
end
......
import MockAdapter from 'axios-mock-adapter';
import { shallowMount } from '@vue/test-utils';
import { GlLoadingIcon, GlDeprecatedButton } from '@gitlab/ui';
import { GlLoadingIcon, GlDeprecatedButton, GlSearchBoxByType } from '@gitlab/ui';
import EnvironmentsDropdown from 'ee/feature_flags/components/environments_dropdown.vue';
import { TEST_HOST } from 'spec/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
import httpStatusCodes from '~/lib/utils/http_status';
describe('Feature flags > Environments dropdown ', () => {
let wrapper;
let mock;
const results = ['production', 'staging'];
const factory = props => {
wrapper = shallowMount(EnvironmentsDropdown, {
propsData: {
......@@ -18,6 +20,9 @@ describe('Feature flags > Environments dropdown ', () => {
});
};
const findEnvironmentSearchInput = () => wrapper.find(GlSearchBoxByType);
const findDropdownMenu = () => wrapper.find('.dropdown-menu');
afterEach(() => {
wrapper.destroy();
mock.restore();
......@@ -30,98 +35,111 @@ describe('Feature flags > Environments dropdown ', () => {
describe('without value', () => {
it('renders the placeholder', () => {
factory();
expect(wrapper.find('input').attributes('placeholder')).toEqual('Search an environment spec');
expect(findEnvironmentSearchInput().vm.$attrs.placeholder).toBe('Search an environment spec');
});
});
describe('with value', () => {
it('sets filter to equal the value', () => {
factory({ value: 'production' });
expect(findEnvironmentSearchInput().props('value')).toBe('production');
});
});
expect(wrapper.vm.filter).toEqual('production');
describe('on focus', () => {
it('sets results with the received data', async () => {
mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(httpStatusCodes.OK, results);
factory();
findEnvironmentSearchInput().vm.$emit('focus');
await waitForPromises();
await wrapper.vm.$nextTick();
expect(wrapper.find('.dropdown-content > ul').exists()).toBe(true);
expect(wrapper.findAll('.dropdown-content > ul > li').exists()).toBe(true);
});
});
describe('on keyup', () => {
it('sets results with the received data', async () => {
mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(httpStatusCodes.OK, results);
factory();
findEnvironmentSearchInput().vm.$emit('keyup');
await waitForPromises();
await wrapper.vm.$nextTick();
expect(wrapper.find('.dropdown-content > ul').exists()).toBe(true);
expect(wrapper.findAll('.dropdown-content > ul > li').exists()).toBe(true);
});
});
describe('on input change', () => {
const results = ['production', 'staging'];
describe('on success', () => {
beforeEach(() => {
mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(200, results);
beforeEach(async () => {
mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(httpStatusCodes.OK, results);
factory();
wrapper.find('input').setValue('production');
findEnvironmentSearchInput().vm.$emit('focus');
findEnvironmentSearchInput().vm.$emit('input', 'production');
await waitForPromises();
await wrapper.vm.$nextTick();
});
it('sets filter value', () => {
expect(wrapper.vm.filter).toEqual('production');
expect(findEnvironmentSearchInput().props('value')).toBe('production');
});
describe('with received data', () => {
beforeEach(done => setImmediate(() => done()));
it('sets is loading to false', () => {
expect(wrapper.vm.isLoading).toEqual(false);
expect(wrapper.find(GlLoadingIcon).exists()).toEqual(false);
});
it('sets results with the received data', () => {
expect(wrapper.vm.results).toEqual(results);
expect(wrapper.vm.isLoading).toBe(false);
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
});
it('sets showSuggestions to true', () => {
expect(wrapper.vm.showSuggestions).toEqual(true);
it('shows the suggestions', () => {
expect(findDropdownMenu().exists()).toBe(true);
});
it('emits event when a suggestion is clicked', () => {
it('emits event when a suggestion is clicked', async () => {
const button = wrapper
.findAll(GlDeprecatedButton)
.filter(b => b.text() === 'production')
.at(0);
button.vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('selectEnvironment')).toEqual([['production']]);
});
await wrapper.vm.$nextTick();
expect(wrapper.emitted('selectEnvironment')).toEqual([['production']]);
});
});
describe('on click clear button', () => {
beforeEach(() => {
beforeEach(async () => {
wrapper.find(GlDeprecatedButton).vm.$emit('click');
await wrapper.vm.$nextTick();
});
it('resets filter value', () => {
expect(wrapper.vm.filter).toEqual('');
expect(findEnvironmentSearchInput().props('value')).toBe('');
});
it('closes list of suggestions', () => {
expect(wrapper.vm.showSuggestions).toEqual(false);
expect(wrapper.vm.showSuggestions).toBe(false);
});
});
});
});
describe('on click create button', () => {
beforeEach(done => {
mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(200, []);
beforeEach(async () => {
mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(httpStatusCodes.OK, []);
factory();
wrapper.find('input').setValue('production');
setImmediate(() => done());
findEnvironmentSearchInput().vm.$emit('focus');
findEnvironmentSearchInput().vm.$emit('input', 'production');
await waitForPromises();
await wrapper.vm.$nextTick();
});
it('emits create event', () => {
it('emits create event', async () => {
wrapper
.findAll(GlDeprecatedButton)
.at(1)
.at(0)
.vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('createClicked')).toEqual([['production']]);
});
await wrapper.vm.$nextTick();
expect(wrapper.emitted('createClicked')).toEqual([['production']]);
});
});
});
......@@ -3,6 +3,7 @@ import MockAdapter from 'axios-mock-adapter';
import { GlLoadingIcon, GlSearchBoxByType, GlNewDropdownItem } from '@gitlab/ui';
import NewEnvironmentsDropdown from 'ee/feature_flags/components/new_environments_dropdown.vue';
import axios from '~/lib/utils/axios_utils';
import httpStatusCodes from '~/lib/utils/http_status';
const TEST_HOST = '/test';
const TEST_SEARCH = 'production';
......@@ -29,14 +30,15 @@ describe('New Environments Dropdown', () => {
axiosMock.onGet(TEST_HOST).reply(() => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
wrapper.find(GlSearchBoxByType).vm.$emit('input', TEST_SEARCH);
wrapper.find(GlSearchBoxByType).vm.$emit('focus');
return axios.waitForAll();
});
it('should not show any dropdown items', () => {
axiosMock.onGet(TEST_HOST).reply(() => {
expect(wrapper.findAll(GlNewDropdownItem)).toHaveLength(0);
});
wrapper.find(GlSearchBoxByType).vm.$emit('input', TEST_SEARCH);
wrapper.find(GlSearchBoxByType).vm.$emit('focus');
return axios.waitForAll();
});
});
......@@ -45,6 +47,7 @@ describe('New Environments Dropdown', () => {
let item;
beforeEach(() => {
axiosMock.onGet(TEST_HOST).reply(200, []);
wrapper.find(GlSearchBoxByType).vm.$emit('focus');
wrapper.find(GlSearchBoxByType).vm.$emit('input', TEST_SEARCH);
return axios
.waitForAll()
......@@ -71,23 +74,28 @@ describe('New Environments Dropdown', () => {
describe('with results', () => {
let items;
beforeEach(() => {
axiosMock.onGet(TEST_HOST).reply(200, ['prod', 'production']);
axiosMock.onGet(TEST_HOST).reply(httpStatusCodes.OK, ['prod', 'production']);
wrapper.find(GlSearchBoxByType).vm.$emit('focus');
wrapper.find(GlSearchBoxByType).vm.$emit('input', 'prod');
return axios.waitForAll().then(() => {
items = wrapper.findAll(GlNewDropdownItem);
});
});
it('should display one item per result', () => {
expect(items).toHaveLength(2);
});
it('should emit an add if an item is clicked', () => {
items.at(0).vm.$emit('click');
expect(wrapper.emitted('add')).toEqual([['prod']]);
});
it('should not display a create label', () => {
items = items.filter(i => i.text().startsWith('Create'));
expect(items).toHaveLength(0);
});
it('should not display a message about no results', () => {
expect(wrapper.find({ ref: 'noResults' }).exists()).toBe(false);
});
......
......@@ -5048,9 +5048,6 @@ msgstr ""
msgid "Clear due date"
msgstr ""
msgid "Clear input"
msgstr ""
msgid "Clear recent searches"
msgstr ""
......
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