Commit e39fdf7f authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch '214640-integrate-gl-combobox' into 'master'

Replace CiKey field with GlFormCombobox

See merge request gitlab-org/gitlab!37091
parents 92c8dd85 bde6de46
<script>
import { uniqueId } from 'lodash';
import { GlButton, GlFormGroup, GlFormInput } from '@gitlab/ui';
export default {
name: 'CiKeyField',
components: {
GlButton,
GlFormGroup,
GlFormInput,
},
model: {
prop: 'value',
event: 'input',
},
props: {
tokenList: {
type: Array,
required: true,
},
value: {
type: String,
required: true,
},
},
data() {
return {
results: [],
arrowCounter: -1,
userDismissedResults: false,
suggestionsId: uniqueId('token-suggestions-'),
};
},
computed: {
showAutocomplete() {
return this.showSuggestions ? 'off' : 'on';
},
showSuggestions() {
return this.results.length > 0;
},
},
mounted() {
document.addEventListener('click', this.handleClickOutside);
},
destroyed() {
document.removeEventListener('click', this.handleClickOutside);
},
methods: {
closeSuggestions() {
this.results = [];
this.arrowCounter = -1;
},
handleClickOutside(event) {
if (!this.$el.contains(event.target)) {
this.closeSuggestions();
}
},
onArrowDown() {
const newCount = this.arrowCounter + 1;
if (newCount >= this.results.length) {
this.arrowCounter = 0;
return;
}
this.arrowCounter = newCount;
},
onArrowUp() {
const newCount = this.arrowCounter - 1;
if (newCount < 0) {
this.arrowCounter = this.results.length - 1;
return;
}
this.arrowCounter = newCount;
},
onEnter() {
const currentToken = this.results[this.arrowCounter] || this.value;
this.selectToken(currentToken);
},
onEsc() {
if (!this.showSuggestions) {
this.$emit('input', '');
}
this.closeSuggestions();
this.userDismissedResults = true;
},
onEntry(value) {
this.$emit('input', value);
this.userDismissedResults = false;
// short circuit so that we don't false match on empty string
if (value.length < 1) {
this.closeSuggestions();
return;
}
const filteredTokens = this.tokenList.filter(token =>
token.toLowerCase().includes(value.toLowerCase()),
);
if (filteredTokens.length) {
this.openSuggestions(filteredTokens);
} else {
this.closeSuggestions();
}
},
openSuggestions(filteredResults) {
this.results = filteredResults;
},
selectToken(value) {
this.$emit('input', value);
this.closeSuggestions();
this.$emit('key-selected');
},
},
};
</script>
<template>
<div>
<div class="dropdown position-relative" role="combobox" aria-owns="token-suggestions">
<gl-form-group :label="__('Key')" label-for="ci-variable-key">
<gl-form-input
id="ci-variable-key"
:value="value"
type="text"
role="searchbox"
class="form-control pl-2 js-env-input"
:autocomplete="showAutocomplete"
aria-autocomplete="list"
aria-controls="token-suggestions"
aria-haspopup="listbox"
:aria-expanded="showSuggestions"
data-qa-selector="ci_variable_key_field"
@input="onEntry"
@keydown.down="onArrowDown"
@keydown.up="onArrowUp"
@keydown.enter.prevent="onEnter"
@keydown.esc.stop="onEsc"
@keydown.tab="closeSuggestions"
/>
</gl-form-group>
<div
v-show="showSuggestions && !userDismissedResults"
id="ci-variable-dropdown"
class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width"
:class="{ 'd-block': showSuggestions }"
>
<div class="dropdown-content">
<ul :id="suggestionsId">
<li
v-for="(result, i) in results"
:key="i"
role="option"
:class="{ 'gl-bg-gray-50': i === arrowCounter }"
:aria-selected="i === arrowCounter"
>
<gl-button tabindex="-1" class="btn-transparent pl-2" @click="selectToken(result)">{{
result
}}</gl-button>
</li>
</ul>
</div>
</div>
</div>
</div>
</template>
......@@ -5,6 +5,7 @@ import {
GlCollapse,
GlDeprecatedButton,
GlFormCheckbox,
GlFormCombobox,
GlFormGroup,
GlFormInput,
GlFormSelect,
......@@ -26,7 +27,6 @@ import {
AWS_TIP_MESSAGE,
} from '../constants';
import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens';
import CiKeyField from './ci_key_field.vue';
import CiEnvironmentsDropdown from './ci_environments_dropdown.vue';
export default {
......@@ -36,12 +36,12 @@ export default {
awsTipMessage: AWS_TIP_MESSAGE,
components: {
CiEnvironmentsDropdown,
CiKeyField,
GlAlert,
GlButton,
GlCollapse,
GlDeprecatedButton,
GlFormCheckbox,
GlFormCombobox,
GlFormGroup,
GlFormInput,
GlFormSelect,
......@@ -205,10 +205,11 @@ export default {
@shown="setVariableProtectedByDefault"
>
<form>
<ci-key-field
<gl-form-combobox
v-if="glFeatures.ciKeyAutocomplete"
v-model="key"
:token-list="$options.tokenList"
:label-text="__('Key')"
/>
<gl-form-group v-else :label="__('Key')" label-for="ci-variable-key">
......
import { mount } from '@vue/test-utils';
import { GlButton, GlFormInput } from '@gitlab/ui';
import { AWS_ACCESS_KEY_ID, AWS_DEFAULT_REGION } from '~/ci_variable_list/constants';
import CiKeyField from '~/ci_variable_list/components/ci_key_field.vue';
import {
awsTokens,
awsTokenList,
} from '~/ci_variable_list/components/ci_variable_autocomplete_tokens';
const doTimes = (num, fn) => {
for (let i = 0; i < num; i += 1) {
fn();
}
};
describe('Ci Key field', () => {
let wrapper;
const createComponent = () => {
wrapper = mount({
data() {
return {
inputVal: '',
tokens: awsTokenList,
};
},
components: { CiKeyField },
template: `
<div>
<ci-key-field
v-model="inputVal"
:token-list="tokens"
/>
</div>
`,
});
};
const findDropdown = () => wrapper.find('#ci-variable-dropdown');
const findDropdownOptions = () => wrapper.findAll(GlButton).wrappers.map(item => item.text());
const findInput = () => wrapper.find(GlFormInput);
const findInputValue = () => findInput().element.value;
const setInput = val => findInput().setValue(val);
const clickDown = () => findInput().trigger('keydown.down');
afterEach(() => {
wrapper.destroy();
});
describe('match and filter functionality', () => {
beforeEach(() => {
createComponent();
});
it('is closed when the input is empty', () => {
expect(findInput().isVisible()).toBe(true);
expect(findInputValue()).toBe('');
expect(findDropdown().isVisible()).toBe(false);
});
it('is open when the input text matches a token', () => {
setInput('AWS');
return wrapper.vm.$nextTick().then(() => {
expect(findDropdown().isVisible()).toBe(true);
});
});
it('shows partial matches at string start', () => {
setInput('AWS');
return wrapper.vm.$nextTick().then(() => {
expect(findDropdown().isVisible()).toBe(true);
expect(findDropdownOptions()).toEqual(awsTokenList);
});
});
it('shows partial matches mid-string', () => {
setInput('D');
return wrapper.vm.$nextTick().then(() => {
expect(findDropdown().isVisible()).toBe(true);
expect(findDropdownOptions()).toEqual([
awsTokens[AWS_ACCESS_KEY_ID].name,
awsTokens[AWS_DEFAULT_REGION].name,
]);
});
});
it('is closed when the text does not match', () => {
setInput('elephant');
return wrapper.vm.$nextTick().then(() => {
expect(findDropdown().isVisible()).toBe(false);
});
});
});
describe('keyboard navigation in dropdown', () => {
beforeEach(() => {
createComponent();
});
describe('on down arrow + enter', () => {
it('selects the next item in the list and closes the dropdown', () => {
setInput('AWS');
return wrapper.vm
.$nextTick()
.then(() => {
findInput().trigger('keydown.down');
findInput().trigger('keydown.enter');
return wrapper.vm.$nextTick();
})
.then(() => {
expect(findInputValue()).toBe(awsTokenList[0]);
});
});
it('loops to the top when it reaches the bottom', () => {
setInput('AWS');
return wrapper.vm
.$nextTick()
.then(() => {
doTimes(findDropdownOptions().length + 1, clickDown);
findInput().trigger('keydown.enter');
return wrapper.vm.$nextTick();
})
.then(() => {
expect(findInputValue()).toBe(awsTokenList[0]);
});
});
});
describe('on up arrow + enter', () => {
it('selects the previous item in the list and closes the dropdown', () => {
setInput('AWS');
return wrapper.vm
.$nextTick()
.then(() => {
doTimes(3, clickDown);
findInput().trigger('keydown.up');
findInput().trigger('keydown.enter');
return wrapper.vm.$nextTick();
})
.then(() => {
expect(findInputValue()).toBe(awsTokenList[1]);
});
});
it('loops to the bottom when it reaches the top', () => {
setInput('AWS');
return wrapper.vm
.$nextTick()
.then(() => {
findInput().trigger('keydown.down');
findInput().trigger('keydown.up');
findInput().trigger('keydown.enter');
return wrapper.vm.$nextTick();
})
.then(() => {
expect(findInputValue()).toBe(awsTokenList[awsTokenList.length - 1]);
});
});
});
describe('on enter with no item highlighted', () => {
it('does not select any item and closes the dropdown', () => {
setInput('AWS');
return wrapper.vm
.$nextTick()
.then(() => {
findInput().trigger('keydown.enter');
return wrapper.vm.$nextTick();
})
.then(() => {
expect(findInputValue()).toBe('AWS');
});
});
});
describe('on click', () => {
it('selects the clicked item regardless of arrow highlight', () => {
setInput('AWS');
return wrapper.vm
.$nextTick()
.then(() => {
wrapper.find(GlButton).trigger('click');
return wrapper.vm.$nextTick();
})
.then(() => {
expect(findInputValue()).toBe(awsTokenList[0]);
});
});
});
describe('on tab', () => {
it('selects entered text, closes dropdown', () => {
setInput('AWS');
return wrapper.vm
.$nextTick()
.then(() => {
findInput().trigger('keydown.tab');
doTimes(2, clickDown);
return wrapper.vm.$nextTick();
})
.then(() => {
expect(findInputValue()).toBe('AWS');
expect(findDropdown().isVisible()).toBe(false);
});
});
});
describe('on esc', () => {
describe('when dropdown is open', () => {
it('closes dropdown and does not select anything', () => {
setInput('AWS');
return wrapper.vm
.$nextTick()
.then(() => {
findInput().trigger('keydown.esc');
return wrapper.vm.$nextTick();
})
.then(() => {
expect(findInputValue()).toBe('AWS');
expect(findDropdown().isVisible()).toBe(false);
});
});
});
describe('when dropdown is closed', () => {
it('clears the input field', () => {
setInput('elephant');
return wrapper.vm
.$nextTick()
.then(() => {
expect(findDropdown().isVisible()).toBe(false);
findInput().trigger('keydown.esc');
return wrapper.vm.$nextTick();
})
.then(() => {
expect(findInputValue()).toBe('');
});
});
});
});
});
});
import Vuex from 'vuex';
import { createLocalVue, shallowMount, mount } from '@vue/test-utils';
import { GlDeprecatedButton } from '@gitlab/ui';
import { GlDeprecatedButton, GlFormCombobox } from '@gitlab/ui';
import { AWS_ACCESS_KEY_ID } from '~/ci_variable_list/constants';
import CiVariableModal from '~/ci_variable_list/components/ci_variable_modal.vue';
import CiKeyField from '~/ci_variable_list/components/ci_key_field.vue';
import createStore from '~/ci_variable_list/store';
import mockData from '../services/mock_data';
import ModalStub from '../stubs';
......@@ -50,7 +49,7 @@ describe('Ci variable modal', () => {
});
it('does not render the autocomplete dropdown', () => {
expect(wrapper.contains(CiKeyField)).toBe(false);
expect(wrapper.contains(GlFormCombobox)).toBe(false);
});
});
......@@ -59,7 +58,7 @@ describe('Ci variable modal', () => {
createComponent(shallowMount);
});
it('renders the autocomplete dropdown', () => {
expect(wrapper.find(CiKeyField).exists()).toBe(true);
expect(wrapper.find(GlFormCombobox).exists()).toBe(true);
});
});
});
......
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