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> <script>
import { GlLink } from '@gitlab/ui'; import { GlButtonGroup, GlDropdown, GlDropdownItem, GlLink, GlSearchBoxByType } from '@gitlab/ui';
import FilteredSearchDropdown from '~/vue_shared/components/filtered_search_dropdown.vue'; import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
import ReviewAppLink from '../review_app_link.vue'; import ReviewAppLink from '../review_app_link.vue';
export default { export default {
name: 'DeploymentViewButton', name: 'DeploymentViewButton',
components: { components: {
FilteredSearchDropdown, GlButtonGroup,
GlDropdown,
GlDropdownItem,
GlLink, GlLink,
GlSearchBoxByType,
ReviewAppLink, ReviewAppLink,
VisualReviewAppLink: () => VisualReviewAppLink: () =>
import('ee_component/vue_merge_request_widget/components/visual_review_app_link.vue'), import('ee_component/vue_merge_request_widget/components/visual_review_app_link.vue'),
}, },
directives: {
autofocusonshow,
},
props: { props: {
appButtonText: { appButtonText: {
type: Object, type: Object,
...@@ -37,6 +43,9 @@ export default { ...@@ -37,6 +43,9 @@ export default {
}), }),
}, },
}, },
data() {
return { searchTerm: '' };
},
computed: { computed: {
deploymentExternalUrl() { deploymentExternalUrl() {
if (this.deployment.changes && this.deployment.changes.length === 1) { if (this.deployment.changes && this.deployment.changes.length === 1) {
...@@ -47,44 +56,52 @@ export default { ...@@ -47,44 +56,52 @@ export default {
shouldRenderDropdown() { shouldRenderDropdown() {
return this.deployment.changes && this.deployment.changes.length > 1; return this.deployment.changes && this.deployment.changes.length > 1;
}, },
filteredChanges() {
return this.deployment?.changes?.filter(change => change.path.includes(this.searchTerm));
},
}, },
}; };
</script> </script>
<template> <template>
<span> <span>
<filtered-search-dropdown <gl-button-group v-if="shouldRenderDropdown" size="small">
v-if="shouldRenderDropdown" <review-app-link
class="js-mr-wigdet-deployment-dropdown inline" :display="appButtonText"
:items="deployment.changes" :link="deploymentExternalUrl"
:main-action-link="deploymentExternalUrl" size="small"
filter-key="path" css-class="deploy-link js-deploy-url inline"
> />
<template #mainAction="{ className }"> <gl-dropdown size="small" class="js-mr-wigdet-deployment-dropdown">
<review-app-link <gl-search-box-by-type
:display="appButtonText" v-model.trim="searchTerm"
:link="deploymentExternalUrl" v-autofocusonshow
:css-class="`deploy-link js-deploy-url inline ${className}`" autofocus
class="gl-m-3"
/> />
</template> <gl-dropdown-item
v-for="change in filteredChanges"
<template #result="{ result }"> :key="change.path"
<gl-link class="js-filtered-dropdown-result"
:href="result.external_url"
target="_blank"
rel="noopener noreferrer nofollow"
class="js-deploy-url-menu-item menu-item"
> >
<strong class="str-truncated-100 gl-mb-0 d-block">{{ result.path }}</strong> <gl-link
:href="change.external_url"
<p class="text-secondary str-truncated-100 gl-mb-0 d-block">{{ result.external_url }}</p> target="_blank"
</gl-link> rel="noopener noreferrer nofollow"
</template> class="js-deploy-url-menu-item menu-item"
</filtered-search-dropdown> >
<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 <review-app-link
v-else v-else
:display="appButtonText" :display="appButtonText"
:link="deploymentExternalUrl" :link="deploymentExternalUrl"
size="small"
css-class="js-deploy-url deploy-link btn btn-default btn-sm inline" css-class="js-deploy-url deploy-link btn btn-default btn-sm inline"
/> />
<visual-review-app-link <visual-review-app-link
......
<script> <script>
import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
export default { export default {
components: { components: {
GlButton,
GlIcon, GlIcon,
}, },
directives: { directives: {
...@@ -21,14 +22,20 @@ export default { ...@@ -21,14 +22,20 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
size: {
type: String,
required: false,
default: 'medium',
},
}, },
}; };
</script> </script>
<template> <template>
<a <gl-button
v-gl-tooltip v-gl-tooltip
:title="display.tooltip" :title="display.tooltip"
:href="link" :href="link"
:size="size"
target="_blank" target="_blank"
rel="noopener noreferrer nofollow" rel="noopener noreferrer nofollow"
:class="cssClass" :class="cssClass"
...@@ -36,5 +43,5 @@ export default { ...@@ -36,5 +43,5 @@ export default {
data-track-label="review_app" data-track-label="review_app"
> >
{{ display.text }} <gl-icon class="fgray" name="external-link" /> {{ display.text }} <gl-icon class="fgray" name="external-link" />
</a> </gl-button>
</template> </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`] = ` ...@@ -26,6 +26,7 @@ exports[`Environment Header has a failed pipeline matches the snapshot 1`] = `
cssclass="btn btn-default btn-sm" cssclass="btn btn-default btn-sm"
display="[object Object]" display="[object Object]"
link="http://example.com" link="http://example.com"
size="medium"
/> />
</div> </div>
`; `;
...@@ -56,6 +57,7 @@ exports[`Environment Header has errors matches the snapshot 1`] = ` ...@@ -56,6 +57,7 @@ exports[`Environment Header has errors matches the snapshot 1`] = `
cssclass="btn btn-default btn-sm" cssclass="btn btn-default btn-sm"
display="[object Object]" display="[object Object]"
link="http://example.com" link="http://example.com"
size="medium"
/> />
</div> </div>
`; `;
...@@ -83,7 +85,7 @@ exports[`Environment Header renders name and link to app matches the snapshot 1` ...@@ -83,7 +85,7 @@ exports[`Environment Header renders name and link to app matches the snapshot 1`
</div> </div>
<a <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-event="open_review_app"
data-track-label="review_app" data-track-label="review_app"
href="http://example.com" href="http://example.com"
...@@ -91,16 +93,24 @@ exports[`Environment Header renders name and link to app matches the snapshot 1` ...@@ -91,16 +93,24 @@ exports[`Environment Header renders name and link to app matches the snapshot 1`
target="_blank" target="_blank"
title="" title=""
> >
<!---->
View app
<svg <!---->
class="fgray gl-icon s16"
data-testid="external-link-icon" <span
class="gl-button-text"
> >
<use
href="#external-link" View app
/> <svg
</svg> class="fgray gl-icon s16"
data-testid="external-link-icon"
>
<use
href="#external-link"
/>
</svg>
</span>
</a> </a>
</div> </div>
`; `;
......
...@@ -10357,9 +10357,6 @@ msgstr "" ...@@ -10357,9 +10357,6 @@ msgstr ""
msgid "Expand approvers" msgid "Expand approvers"
msgstr "" msgstr ""
msgid "Expand dropdown"
msgstr ""
msgid "Expand file" msgid "Expand file"
msgstr "" msgstr ""
......
...@@ -30,7 +30,7 @@ describe('review app link', () => { ...@@ -30,7 +30,7 @@ describe('review app link', () => {
}); });
it('renders provided cssClass as class attribute', () => { 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', () => { 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