Commit c1e1a61c authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch 'migrate-mr-deployment-widget-to-gl-dropdown' into 'master'

Migrate MR Deployment Widget to GlDropdown

See merge request gitlab-org/gitlab!42004
parents b8b856d6 b0020a4f
<script>
import { GlLink } from '@gitlab/ui';
import FilteredSearchDropdown from '~/vue_shared/components/filtered_search_dropdown.vue';
import { GlButtonGroup, GlDropdown, GlDropdownItem, GlLink, GlSearchBoxByType } from '@gitlab/ui';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
import ReviewAppLink from '../review_app_link.vue';
export default {
name: 'DeploymentViewButton',
components: {
FilteredSearchDropdown,
GlButtonGroup,
GlDropdown,
GlDropdownItem,
GlLink,
GlSearchBoxByType,
ReviewAppLink,
VisualReviewAppLink: () =>
import('ee_component/vue_merge_request_widget/components/visual_review_app_link.vue'),
},
directives: {
autofocusonshow,
},
props: {
appButtonText: {
type: Object,
......@@ -37,6 +43,9 @@ export default {
}),
},
},
data() {
return { searchTerm: '' };
},
computed: {
deploymentExternalUrl() {
if (this.deployment.changes && this.deployment.changes.length === 1) {
......@@ -47,44 +56,52 @@ export default {
shouldRenderDropdown() {
return this.deployment.changes && this.deployment.changes.length > 1;
},
filteredChanges() {
return this.deployment?.changes?.filter(change => change.path.includes(this.searchTerm));
},
},
};
</script>
<template>
<span>
<filtered-search-dropdown
v-if="shouldRenderDropdown"
class="js-mr-wigdet-deployment-dropdown inline"
:items="deployment.changes"
:main-action-link="deploymentExternalUrl"
filter-key="path"
>
<template #mainAction="{ className }">
<review-app-link
:display="appButtonText"
:link="deploymentExternalUrl"
:css-class="`deploy-link js-deploy-url inline ${className}`"
<gl-button-group v-if="shouldRenderDropdown" size="small">
<review-app-link
:display="appButtonText"
:link="deploymentExternalUrl"
size="small"
css-class="deploy-link js-deploy-url inline"
/>
<gl-dropdown size="small" class="js-mr-wigdet-deployment-dropdown">
<gl-search-box-by-type
v-model.trim="searchTerm"
v-autofocusonshow
autofocus
class="gl-m-3"
/>
</template>
<template #result="{ result }">
<gl-link
:href="result.external_url"
target="_blank"
rel="noopener noreferrer nofollow"
class="js-deploy-url-menu-item menu-item"
<gl-dropdown-item
v-for="change in filteredChanges"
:key="change.path"
class="js-filtered-dropdown-result"
>
<strong class="str-truncated-100 gl-mb-0 d-block">{{ result.path }}</strong>
<p class="text-secondary str-truncated-100 gl-mb-0 d-block">{{ result.external_url }}</p>
</gl-link>
</template>
</filtered-search-dropdown>
<gl-link
:href="change.external_url"
target="_blank"
rel="noopener noreferrer nofollow"
class="js-deploy-url-menu-item menu-item"
>
<strong class="str-truncated-100 gl-mb-0 gl-display-block">{{ change.path }}</strong>
<p class="text-secondary str-truncated-100 gl-mb-0 d-block">
{{ change.external_url }}
</p>
</gl-link>
</gl-dropdown-item>
</gl-dropdown>
</gl-button-group>
<review-app-link
v-else
:display="appButtonText"
:link="deploymentExternalUrl"
size="small"
css-class="js-deploy-url deploy-link btn btn-default btn-sm inline"
/>
<visual-review-app-link
......
<script>
import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
export default {
components: {
GlButton,
GlIcon,
},
directives: {
......@@ -21,14 +22,20 @@ export default {
type: String,
required: true,
},
size: {
type: String,
required: false,
default: 'medium',
},
},
};
</script>
<template>
<a
<gl-button
v-gl-tooltip
:title="display.tooltip"
:href="link"
:size="size"
target="_blank"
rel="noopener noreferrer nofollow"
:class="cssClass"
......@@ -36,5 +43,5 @@ export default {
data-track-label="review_app"
>
{{ display.text }} <gl-icon class="fgray" name="external-link" />
</a>
</gl-button>
</template>
<script>
import $ from 'jquery';
import { GlDeprecatedButton, GlIcon } from '@gitlab/ui';
import { __ } from '~/locale';
/**
* Renders a split dropdown with
* an input that allows to search through the given
* array of options.
*
* When there are no results and `showCreateMode` is true
* it renders a create button with the value typed.
*/
export default {
name: 'FilteredSearchDropdown',
components: {
GlIcon,
GlDeprecatedButton,
},
props: {
title: {
type: String,
required: false,
default: '',
},
buttonType: {
required: false,
validator: value =>
['primary', 'default', 'secondary', 'success', 'info', 'warning', 'danger'].indexOf(
value,
) !== -1,
default: 'default',
},
size: {
required: false,
type: String,
default: 'sm',
},
items: {
type: Array,
required: true,
},
visibleItems: {
type: Number,
required: false,
default: 5,
},
filterKey: {
type: String,
required: true,
},
showCreateMode: {
type: Boolean,
required: false,
default: false,
},
createButtonText: {
type: String,
required: false,
default: __('Create'),
},
},
data() {
return {
filter: '',
};
},
computed: {
className() {
return `btn btn-${this.buttonType} btn-${this.size}`;
},
filteredResults() {
if (this.filter !== '') {
return this.items.filter(
item =>
item[this.filterKey] &&
item[this.filterKey].toLowerCase().includes(this.filter.toLowerCase()),
);
}
return this.items.slice(0, this.visibleItems);
},
computedCreateButtonText() {
return `${this.createButtonText} ${this.filter}`;
},
shouldRenderCreateButton() {
return this.showCreateMode && this.filteredResults.length === 0 && this.filter !== '';
},
},
mounted() {
/**
* Resets the filter every time the user closes the dropdown
*/
$(this.$el)
.on('shown.bs.dropdown', () => {
this.$nextTick(() => this.$refs.searchInput.focus());
})
.on('hidden.bs.dropdown', () => {
this.filter = '';
});
},
};
</script>
<template>
<div class="dropdown">
<div class="btn-group">
<slot name="mainAction" :class-name="className">
<button type="button" :class="className">{{ title }}</button>
</slot>
<button
type="button"
:class="className"
class="dropdown-toggle dropdown-toggle-split"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
:aria-label="__('Expand dropdown')"
>
<gl-icon name="angle-down" :size="12" />
</button>
<div class="dropdown-menu dropdown-menu-right">
<div class="dropdown-input">
<input
ref="searchInput"
v-model="filter"
type="search"
:placeholder="__('Filter')"
class="js-filtered-dropdown-input dropdown-input-field"
/>
<gl-icon class="dropdown-input-search" name="search" />
</div>
<div class="dropdown-content">
<ul>
<li v-for="(result, i) in filteredResults" :key="i" class="js-filtered-dropdown-result">
<slot name="result" :result="result">{{ result[filterKey] }}</slot>
</li>
</ul>
</div>
<div v-if="shouldRenderCreateButton" class="dropdown-footer">
<slot name="footer" :filter="filter">
<gl-deprecated-button
class="js-dropdown-create-button btn-transparent"
@click="$emit('createItem', filter)"
>{{ computedCreateButtonText }}</gl-deprecated-button
>
</slot>
</div>
</div>
</div>
</div>
</template>
---
title: Migrate MR Deployment Widget to GlDropdown
merge_request: 42004
author:
type: changed
......@@ -26,6 +26,7 @@ exports[`Environment Header has a failed pipeline matches the snapshot 1`] = `
cssclass="btn btn-default btn-sm"
display="[object Object]"
link="http://example.com"
size="medium"
/>
</div>
`;
......@@ -56,6 +57,7 @@ exports[`Environment Header has errors matches the snapshot 1`] = `
cssclass="btn btn-default btn-sm"
display="[object Object]"
link="http://example.com"
size="medium"
/>
</div>
`;
......@@ -83,7 +85,7 @@ exports[`Environment Header renders name and link to app matches the snapshot 1`
</div>
<a
class="btn btn-default btn-sm"
class="btn btn-default btn-md gl-button btn btn-default btn-sm"
data-track-event="open_review_app"
data-track-label="review_app"
href="http://example.com"
......@@ -91,16 +93,24 @@ exports[`Environment Header renders name and link to app matches the snapshot 1`
target="_blank"
title=""
>
View app
<svg
class="fgray gl-icon s16"
data-testid="external-link-icon"
<!---->
<!---->
<span
class="gl-button-text"
>
<use
href="#external-link"
/>
</svg>
View app
<svg
class="fgray gl-icon s16"
data-testid="external-link-icon"
>
<use
href="#external-link"
/>
</svg>
</span>
</a>
</div>
`;
......
......@@ -10357,9 +10357,6 @@ msgstr ""
msgid "Expand approvers"
msgstr ""
msgid "Expand dropdown"
msgstr ""
msgid "Expand file"
msgstr ""
......
......@@ -30,7 +30,7 @@ describe('review app link', () => {
});
it('renders provided cssClass as class attribute', () => {
expect(el.getAttribute('class')).toEqual(props.cssClass);
expect(el.getAttribute('class')).toContain(props.cssClass);
});
it('renders View app text', () => {
......
import Vue from 'vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import component from '~/vue_shared/components/filtered_search_dropdown.vue';
describe('Filtered search dropdown', () => {
const Component = Vue.extend(component);
let vm;
afterEach(() => {
vm.$destroy();
});
describe('with an empty array of items', () => {
beforeEach(() => {
vm = mountComponent(Component, {
items: [],
filterKey: '',
});
});
it('renders empty list', () => {
expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(0);
});
it('renders filter input', () => {
expect(vm.$el.querySelector('.js-filtered-dropdown-input')).not.toBeNull();
});
});
describe('when visible numbers is less than the items length', () => {
beforeEach(() => {
vm = mountComponent(Component, {
items: [{ title: 'One' }, { title: 'Two' }, { title: 'Three' }],
visibleItems: 2,
filterKey: 'title',
});
});
it('it renders only the maximum number provided', () => {
expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(2);
});
});
describe('when visible number is bigger than the items length', () => {
beforeEach(() => {
vm = mountComponent(Component, {
items: [{ title: 'One' }, { title: 'Two' }, { title: 'Three' }],
filterKey: 'title',
});
});
it('it renders the full list of items the maximum number provided', () => {
expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(3);
});
});
describe('while filtering', () => {
beforeEach(() => {
vm = mountComponent(Component, {
items: [
{ title: 'One' },
{ title: 'Two/three' },
{ title: 'Three four' },
{ title: 'Five' },
],
filterKey: 'title',
});
});
it('updates the results to match the typed value', done => {
vm.$el.querySelector('.js-filtered-dropdown-input').value = 'three';
vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input'));
vm.$nextTick(() => {
expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(2);
done();
});
});
describe('when no value matches the typed one', () => {
it('does not render any result', done => {
vm.$el.querySelector('.js-filtered-dropdown-input').value = 'six';
vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input'));
vm.$nextTick(() => {
expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(0);
done();
});
});
});
});
describe('with create mode enabled', () => {
describe('when there are no matches', () => {
beforeEach(() => {
vm = mountComponent(Component, {
items: [
{ title: 'One' },
{ title: 'Two/three' },
{ title: 'Three four' },
{ title: 'Five' },
],
filterKey: 'title',
showCreateMode: true,
});
vm.$el.querySelector('.js-filtered-dropdown-input').value = 'eleven';
vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input'));
});
it('renders a create button', done => {
vm.$nextTick(() => {
expect(vm.$el.querySelector('.js-dropdown-create-button')).not.toBeNull();
done();
});
});
it('renders computed button text', done => {
vm.$nextTick(() => {
expect(vm.$el.querySelector('.js-dropdown-create-button').textContent.trim()).toEqual(
'Create eleven',
);
done();
});
});
describe('on click create button', () => {
it('emits createItem event with the filter', done => {
jest.spyOn(vm, '$emit').mockImplementation(() => {});
vm.$nextTick(() => {
vm.$el.querySelector('.js-dropdown-create-button').click();
expect(vm.$emit).toHaveBeenCalledWith('createItem', 'eleven');
done();
});
});
});
});
describe('when there are matches', () => {
beforeEach(() => {
vm = mountComponent(Component, {
items: [
{ title: 'One' },
{ title: 'Two/three' },
{ title: 'Three four' },
{ title: 'Five' },
],
filterKey: 'title',
showCreateMode: true,
});
vm.$el.querySelector('.js-filtered-dropdown-input').value = 'one';
vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input'));
});
it('does not render a create button', done => {
vm.$nextTick(() => {
expect(vm.$el.querySelector('.js-dropdown-create-button')).toBeNull();
done();
});
});
});
});
describe('with create mode disabled', () => {
describe('when there are no matches', () => {
beforeEach(() => {
vm = mountComponent(Component, {
items: [
{ title: 'One' },
{ title: 'Two/three' },
{ title: 'Three four' },
{ title: 'Five' },
],
filterKey: 'title',
});
vm.$el.querySelector('.js-filtered-dropdown-input').value = 'eleven';
vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input'));
});
it('does not render a create button', done => {
vm.$nextTick(() => {
expect(vm.$el.querySelector('.js-dropdown-create-button')).toBeNull();
done();
});
});
});
});
});
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