Commit f4dadfcd authored by Phil Hughes's avatar Phil Hughes

Merge branch '9439-searchable-environments' into 'master'

Creates environment search dropdown

Closes #9439

See merge request gitlab-org/gitlab-ee!9417
parents 3a085e85 4e1a2bd5
......@@ -22,6 +22,10 @@ export default {
type: String,
required: true,
},
environmentsEndpoint: {
type: String,
required: true,
},
},
computed: {
...mapState(['error', 'name', 'description', 'scopes', 'isLoading', 'hasError']),
......@@ -55,6 +59,7 @@ export default {
:scopes="scopes"
:cancel-path="path"
:submit-text="__('Save changes')"
:environments-endpoint="environmentsEndpoint"
@handleSubmit="data => updateFeatureFlag(data)"
/>
</template>
......
<script>
import _ from 'underscore';
import { GlLoadingIcon, GlButton } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import 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.
*
* When the user types, it will trigger an event to allow
* for API queries outside of the component.
*
* When results are returned, it renders a selectable
* list with the suggestions
*
* When no results are returned, it will render a
* button with a `Create` label. When clicked, it will
* emit an event to allow for the creation of a new
* record.
*
*/
export default {
name: 'EnvironmentsSearchableInput',
components: {
GlButton,
GlLoadingIcon,
Icon,
},
props: {
endpoint: {
type: String,
required: true,
},
value: {
type: String,
required: false,
default: '',
},
placeholder: {
type: String,
required: false,
default: __('Search an environment spec'),
},
createButtonLabel: {
type: String,
required: false,
default: __('Create'),
},
},
data() {
return {
filter: this.value || '',
results: [],
showSuggestions: false,
isLoading: false,
};
},
computed: {
/**
* Creates a label with the value of the filter
* @returns {String}
*/
composedCreateButtonLabel() {
return `${this.createButtonLabel} ${this.filter}`;
},
/**
* 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;
},
},
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;
this.isLoading = true;
this.openSuggestions();
return axios
.get(this.endpoint, { params: { query: this.filter } })
.then(({ data }) => {
this.results = data;
this.isLoading = false;
})
.catch(() => {
this.isLoading = false;
this.closeSuggestions();
createFlash(__('Something went wrong on our end. Please try again.'));
});
},
/**
* Opens the list of suggestions
*/
openSuggestions() {
this.showSuggestions = true;
},
/**
* Closes the list of suggestions and cleans the results
*/
closeSuggestions() {
this.showSuggestions = false;
this.results = [];
},
/**
* On click, it will:
* 1. clear the input value
* 2. close the list of suggestions
* 3. emit an event
*/
clearInput() {
this.filter = '';
this.closeSuggestions();
this.$emit('clearInput');
},
/**
* When the user selects a value from the list of suggestions
*
* It emits an event with the selected value
* Clears the filter
* and closes the list of suggestions
*
* @param {String} selected
*/
selectEnvironment(selected) {
this.$emit('selectEnvironment', selected);
this.filter = '';
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.closeSuggestions();
},
},
};
</script>
<template>
<div>
<div class="dropdown position-relative">
<icon name="search" class="seach-icon-input" />
<input
type="text"
class="js-env-input form-control pl-4"
:aria-label="placeholder"
:value="filter"
:placeholder="placeholder"
@input="fetchEnvironments"
/>
<gl-button
class="js-clear-search-input btn-transparent clear-search-input position-right-0"
@click="clearInput"
>
<icon name="clear" :aria-label="__('Clear input')" />
</gl-button>
<div
v-show="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">
<li v-for="(result, i) in results" :key="i">
<gl-button class="btn-transparent" @click="selectEnvironment(result)">{{
result
}}</gl-button>
</li>
</ul>
<div v-else-if="!results.length" class="text-secondary p-2">
{{ __('No matching results') }}
</div>
<div v-if="shouldRenderCreateButton" class="dropdown-footer">
<gl-button class="js-create-button btn-blank dropdown-item" @click="createClicked">{{
composedCreateButtonLabel
}}</gl-button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import _ from 'underscore';
import { GlButton } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import ToggleButton from '~/vue_shared/components/toggle_button.vue';
import Icon from '~/vue_shared/components/icon.vue';
import EnvironmentsDropdown from './environments_dropdown.vue';
export default {
components: {
GlButton,
ToggleButton,
Icon,
EnvironmentsDropdown,
},
props: {
name: {
......@@ -35,6 +36,10 @@ export default {
type: String,
required: true,
},
environmentsEndpoint: {
type: String,
required: true,
},
},
data() {
return {
......@@ -65,13 +70,6 @@ export default {
return this.formScopes.filter(scope => !scope._destroy);
},
},
watch: {
newScope(newVal) {
if (!_.isEmpty(newVal)) {
this.addNewScope();
}
},
},
methods: {
isAllEnvironment(name) {
return name === this.$options.all;
......@@ -80,19 +78,24 @@ export default {
* When the user updates the status of
* an existing scope we toggle the status for
* the `formScopes`
*
* @param {Object} scope
* @param {Number} index
* @param {Boolean} status
*/
onUpdateScopeStatus(scope, index, status) {
this.formScopes.splice(index, 1, Object.assign({}, scope, { active: status }));
},
addNewScope() {
const uniqueId = _.uniqueId('scope_');
this.formScopes.push({ environment_scope: this.newScope, active: false, uniqueId });
this.$nextTick(() => {
this.$refs[uniqueId][0].focus();
this.newScope = '';
});
/**
* When the user selects or creates a new scope in the environemnts dropdoown
* we update the selected value.
*
* @param {String} name
* @param {Object} scope
* @param {Number} index
*/
updateScope(name, scope, index) {
this.formScopes.splice(index, 1, Object.assign({}, scope, { environment_scope: name }));
},
/**
* When the user clicks the toggle button in the new row,
......@@ -114,6 +117,9 @@ export default {
* If the scope has an ID, we need to add the `_destroy` flag
* otherwise we can just remove it.
* Backend needs the destroy flag only in the PUT request.
*
* @param {Number} index
* @param {Object} scope
*/
removeScope(index, scope) {
if (scope.id) {
......@@ -122,6 +128,20 @@ export default {
this.formScopes.splice(index, 1);
}
},
/**
* When the user selects a value or creates a new value in the environments
* dropdown in the creation row, we push a new entry with the selected value.
*
* @param {String}
*/
createNewEnvironment(name) {
this.formScopes.push({ environment_scope: name, active: false });
this.newScope = '';
},
/**
* When the user clicks the submit button
* it triggers an event with the form data
*/
handleSubmit() {
this.$emit('handleSubmit', {
name: this.formName,
......@@ -161,7 +181,7 @@ export default {
<h4>{{ s__('FeatureFlags|Target environments') }}</h4>
<div v-html="$options.helpText"></div>
<div class="js-scopes-table table-holder prepend-top-default">
<div class="js-scopes-table prepend-top-default">
<div class="gl-responsive-table-row table-row-header" role="row">
<div class="table-section section-60" role="columnheader">
{{ s__('FeatureFlags|Environment Spec') }}
......@@ -182,15 +202,18 @@ export default {
{{ s__('FeatureFlags|Environment Spec') }}
</div>
<div class="table-mobile-content js-feature-flag-status">
<p v-if="isAllEnvironment(scope.environment_scope)" class="js-scope-all">
<p v-if="isAllEnvironment(scope.environment_scope)" class="js-scope-all pl-3">
{{ $options.allEnvironments }}
</p>
<input
<environments-dropdown
v-else
:ref="scope.uniqueId"
v-model="scope.environment_scope"
type="text"
class="form-control col-md-6 prepend-left-4"
class="col-md-6"
:value="scope.environment_scope"
:endpoint="environmentsEndpoint"
@selectEnvironment="env => updateScope(env, scope, index)"
@createClicked="env => updateScope(env, scope, index)"
@clearInput="updateScope('', scope, index)"
/>
</div>
</div>
......@@ -229,10 +252,12 @@ export default {
{{ s__('FeatureFlags|Environment Spec') }}
</div>
<div class="table-mobile-content js-feature-flag-status">
<input
v-model="newScope"
type="text"
class="js-new-scope-name form-control col-md-6 prepend-left-4"
<environments-dropdown
class="js-new-scope-name col-md-6"
:endpoint="environmentsEndpoint"
:value="newScope"
@selectEnvironment="env => createNewEnvironment(env)"
@createClicked="env => createNewEnvironment(env)"
/>
</div>
</div>
......
......@@ -19,6 +19,10 @@ export default {
type: String,
required: true,
},
environmentsEndpoint: {
type: String,
required: true,
},
},
computed: {
...mapState(['error']),
......@@ -52,6 +56,7 @@ export default {
:cancel-path="path"
:submit-text="s__('FeatureFlags|Create feature flag')"
:scopes="scopes"
:environments-endpoint="environmentsEndpoint"
@handleSubmit="data => createFeatureFlag(data)"
/>
</div>
......
......@@ -14,6 +14,7 @@ export default () => {
props: {
endpoint: el.dataset.endpoint,
path: el.dataset.featureFlagsPath,
environmentsEndpoint: el.dataset.environmentsEndpoint,
},
});
},
......
......@@ -14,6 +14,7 @@ export default () => {
props: {
endpoint: el.dataset.endpoint,
path: el.dataset.featureFlagsPath,
environmentsEndpoint: el.dataset.environmentsEndpoint,
},
});
},
......
......@@ -3,11 +3,6 @@ export const parseFeatureFlagsParams = params => ({
operations_feature_flag: {
name: params.name,
description: params.description,
// removes uniqueId key used in creation form
scopes_attributes: params.scopes.map(scope => {
const scopeCopy = Object.assign({}, scope);
delete scopeCopy.uniqueId;
return scopeCopy;
}),
scopes_attributes: params.scopes,
},
});
......@@ -9,3 +9,19 @@ $label-blue: #428bca;
color: $white-light;
background-color: $gray-500;
}
.seach-icon-input,
.clear-search-input {
position: absolute;
z-index: 10;
fill: $gl-gray-400;
}
.seach-icon-input {
left: 4px;
top: 10px;
}
.clear-search-input {
top: 1px;
}
......@@ -3,7 +3,7 @@
- page_title s_('FeatureFlags|Edit Feature Flag')
- if Feature.enabled?(:feature_flags_environment_scope, @project)
#js-edit-feature-flag{ data: { endpoint: project_feature_flag_path(@project, @feature_flag), feature_flags_path: project_feature_flags_path(@project) } }
#js-edit-feature-flag{ data: { endpoint: project_feature_flag_path(@project, @feature_flag), feature_flags_path: project_feature_flags_path(@project), environments_endpoint: search_project_environments_path(@project, format: :json)} }
- else
%h3.page-title
= s_('FeatureFlags|Edit %{feature_flag_name}') % { feature_flag_name: @feature_flag.name }
......
......@@ -4,7 +4,7 @@
- page_title s_('FeatureFlags|New Feature Flag')
- if Feature.enabled?(:feature_flags_environment_scope, @project)
#js-new-feature-flag{ data: { endpoint: project_feature_flags_path(@project, format: :json), feature_flags_path: project_feature_flags_path(@project) } }
#js-new-feature-flag{ data: { endpoint: project_feature_flags_path(@project, format: :json), feature_flags_path: project_feature_flags_path(@project), environments_endpoint: search_project_environments_path(@project, format: :json) } }
- else
%h3.page-title
= s_('FeatureFlags|New Feature Flag')
......
......@@ -65,7 +65,10 @@ describe 'User creates feature flag', :js do
set_feature_flag_info('mr_train', '')
within_scope_row(2) do
within_environment_spec { find('.js-new-scope-name').set("review/*") }
within_environment_spec do
find('.js-env-input').set("review/*")
find('.js-create-button').click
end
end
within_scope_row(2) do
......@@ -90,6 +93,38 @@ describe 'User creates feature flag', :js do
end
end
context 'when searches an environment name for scope creation' do
let!(:environment) { create(:environment, name: 'production', project: project) }
before do
visit(new_project_feature_flag_path(project))
set_feature_flag_info('mr_train', '')
within_scope_row(2) do
within_environment_spec do
find('.js-env-input').set('prod')
click_button 'production'
end
end
click_button 'Create feature flag'
end
it 'shows the created feature flag' do
within_feature_flag_row(1) do
expect(page.find('.feature-flag-name')).to have_content('mr_train')
expect(page).to have_css('.js-feature-flag-status .badge-success')
within_feature_flag_scopes do
expect(page.find('.badge:nth-child(1)')).to have_content('*')
expect(page.find('.badge:nth-child(1)')['class']).to include('badge-active')
expect(page.find('.badge:nth-child(2)')).to have_content('production')
expect(page.find('.badge:nth-child(2)')['class']).to include('badge-inactive')
end
end
end
end
private
def set_feature_flag_info(name, description)
......
......@@ -63,7 +63,10 @@ describe 'User updates feature flag', :js do
context 'when user adds a new scope' do
before do
within_scope_row(3) do
within_environment_spec { find('.js-new-scope-name').set('production') }
within_environment_spec do
find('.js-env-input').set('production')
find('.js-create-button').click
end
end
click_button 'Save changes'
......
......@@ -26,6 +26,7 @@ describe('Edit feature flag form', () => {
propsData: {
endpoint: `${TEST_HOST}/feature_flags.json'`,
path: '/feature_flags',
environmentsEndpoint: 'environments.json',
},
store,
sync: false,
......
import MockAdapter from 'axios-mock-adapter';
import { createLocalVue, mount } from '@vue/test-utils';
import { GlLoadingIcon } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import EnvironmentsDropdown from 'ee/feature_flags/components/environments_dropdown.vue';
import { TEST_HOST } from 'spec/test_constants';
const localVue = createLocalVue();
describe('Feature Flags > Environments dropdown ', () => {
let wrapper;
let mock;
const factory = props => {
wrapper = mount(localVue.extend(EnvironmentsDropdown), {
localVue,
propsData: {
endpoint: `${TEST_HOST}/environments.json'`,
...props,
},
sync: false,
});
};
afterEach(() => {
wrapper.destroy();
mock.restore();
});
beforeEach(() => {
mock = new MockAdapter(axios);
});
describe('without value', () => {
it('renders the placeholder', () => {
factory();
expect(wrapper.find('input').attributes('placeholder')).toEqual('Search an environment spec');
});
});
describe('with value', () => {
it('sets filter to equal the value', () => {
factory({ value: 'production' });
expect(wrapper.vm.filter).toEqual('production');
});
});
describe('on input change', () => {
const results = ['production', 'staging'];
describe('on success', () => {
beforeEach(() => {
mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(200, results);
factory();
wrapper.find('input').setValue('production');
});
it('sets filter value', () => {
expect(wrapper.vm.filter).toEqual('production');
});
describe('with received data', () => {
it('sets is loading to false', done => {
setTimeout(() => {
expect(wrapper.vm.isLoading).toEqual(false);
expect(wrapper.find(GlLoadingIcon).exists()).toEqual(false);
done();
});
});
it('sets results with the received data', done => {
setTimeout(() => {
expect(wrapper.vm.results).toEqual(results);
done();
});
});
it('sets showSuggestions to true', done => {
setTimeout(() => {
expect(wrapper.vm.showSuggestions).toEqual(true);
done();
});
});
it('emits even when a suggestion is clicked', done => {
setTimeout(() => {
spyOn(wrapper.vm, '$emit');
wrapper.find('ul button').trigger('click');
expect(wrapper.vm.$emit).toHaveBeenCalledWith('selectEnvironment', 'production');
done();
});
});
});
});
});
describe('on click clear button', () => {
beforeEach(() => {
wrapper.find('.js-clear-search-input').trigger('click');
});
it('resets filter value', () => {
expect(wrapper.vm.filter).toEqual('');
});
it('closes list of suggestions', () => {
expect(wrapper.vm.showSuggestions).toEqual(false);
});
});
describe('on click create button', () => {
beforeEach(() => {
mock.onGet(`${TEST_HOST}/environments.json'`).replyOnce(200, []);
factory();
wrapper.find('input').setValue('production');
});
it('emits create event', done => {
setTimeout(() => {
spyOn(wrapper.vm, '$emit');
wrapper.find('.js-create-button').trigger('click');
expect(wrapper.vm.$emit).toHaveBeenCalledWith('createClicked', 'production');
done();
});
});
});
});
import { createLocalVue, mount } from '@vue/test-utils';
import Form from 'ee/feature_flags/components/form.vue';
import ToggleButton from '~/vue_shared/components/toggle_button.vue';
import EnvironmentsDropdown from 'ee/feature_flags/components/environments_dropdown.vue';
describe('feature flag form', () => {
let wrapper;
const requiredProps = {
cancelPath: 'feature_flags',
submitText: 'Create',
environmentsEndpoint: '/environments.json',
};
const factory = (props = {}) => {
......@@ -58,20 +60,6 @@ describe('feature flag form', () => {
});
describe('status toggle', () => {
describe('with filled text input', () => {
it('should add a new scope with the text value and the status and reset the form', () => {
wrapper.find('.js-new-scope-name').setValue('production');
wrapper.find(ToggleButton).vm.$emit('change', true);
expect(wrapper.vm.formScopes).toEqual([
{ active: true, environment_scope: 'production' },
]);
expect(wrapper.vm.newScope).toEqual('');
});
});
describe('without filled text input', () => {
it('should add a new scope with the text value empty and the status', () => {
wrapper.find(ToggleButton).vm.$emit('change', true);
......@@ -203,7 +191,11 @@ describe('feature flag form', () => {
it('should emit handleSubmit with the updated data', () => {
wrapper.find('#feature-flag-name').setValue('feature_flag_2');
wrapper.find('.js-new-scope-name').setValue('review');
wrapper
.find('.js-new-scope-name')
.find(EnvironmentsDropdown)
.vm.$emit('selectEnvironment', 'review');
wrapper
.find('.js-add-new-scope')
.find(ToggleButton)
......@@ -222,6 +214,10 @@ describe('feature flag form', () => {
},
{
environment_scope: 'review',
active: false,
},
{
environment_scope: '',
active: true,
},
],
......
......@@ -23,6 +23,7 @@ describe('New feature flag form', () => {
propsData: {
endpoint: 'feature_flags.json',
path: '/feature_flags',
environmentsEndpoint: 'environments.json',
},
store,
sync: false,
......
......@@ -1883,6 +1883,9 @@ msgstr ""
msgid "Clear"
msgstr ""
msgid "Clear input"
msgstr ""
msgid "Clear search"
msgstr ""
......@@ -6333,6 +6336,9 @@ msgstr ""
msgid "No license. All rights reserved"
msgstr ""
msgid "No matching results"
msgstr ""
msgid "No merge requests for the selected time period."
msgstr ""
......@@ -8151,6 +8157,9 @@ msgstr ""
msgid "Search"
msgstr ""
msgid "Search an environment spec"
msgstr ""
msgid "Search branches"
msgstr ""
......@@ -8639,6 +8648,9 @@ msgstr ""
msgid "Something went wrong on our end. Please try again!"
msgstr ""
msgid "Something went wrong on our end. Please try again."
msgstr ""
msgid "Something went wrong trying to change the confidentiality of this issue"
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