Commit 5b6007f9 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'fe-ac-review-app-changes-33418' into 'master'

Frontend: Review app changes

Closes #33418

See merge request gitlab-org/gitlab-ce!22363
parents 1438e322 25170fbe
<script>
import Icon from '~/vue_shared/components/icon.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import FilteredSearchDropdown from '~/vue_shared/components/filtered_search_dropdown.vue';
import timeagoMixin from '../../vue_shared/mixins/timeago';
import tooltip from '../../vue_shared/directives/tooltip';
import LoadingButton from '../../vue_shared/components/loading_button.vue';
......@@ -18,6 +19,7 @@ export default {
StatusIcon,
Icon,
TooltipOnTruncate,
FilteredSearchDropdown,
},
directives: {
tooltip,
......@@ -30,8 +32,10 @@ export default {
},
},
data() {
const features = window.gon.features || {};
return {
isStopping: false,
enableCiEnvironmentsStatusChanges: features.ciEnvironmentsStatusChanges,
};
},
computed: {
......@@ -118,18 +122,65 @@ export default {
/>
</div>
<div>
<a
v-if="hasExternalUrls"
:href="deployment.external_url"
target="_blank"
rel="noopener noreferrer nofollow"
class="deploy-link js-deploy-url btn btn-default btn-sm inline"
>
<span>
View app
<icon name="external-link" />
</span>
</a>
<template v-if="hasExternalUrls">
<filtered-search-dropdown
v-if="enableCiEnvironmentsStatusChanges"
class="js-mr-wigdet-deployment-dropdown inline"
:items="deployment.changes"
:main-action-link="deployment.external_url"
filter-key="path"
>
<template
slot="mainAction"
slot-scope="slotProps"
>
<a
:href="deployment.external_url"
target="_blank"
rel="noopener noreferrer nofollow"
class="deploy-link js-deploy-url inline"
:class="slotProps.className"
>
<span>
{{ __('View app') }}
<icon name="external-link" />
</span>
</a>
</template>
<template
slot="result"
slot-scope="slotProps"
>
<a
:href="slotProps.result.external_url"
target="_blank"
rel="noopener noreferrer nofollow"
class="menu-item"
>
<strong class="str-truncated-100 append-bottom-0 d-block">
{{ slotProps.result.path }}
</strong>
<p class="text-secondary str-truncated-100 append-bottom-0 d-block">
{{ slotProps.result.external_url }}
</p>
</a>
</template>
</filtered-search-dropdown>
<a
v-else
:href="deployment.external_url"
target="_blank"
rel="noopener noreferrer nofollow"
class="js-deploy-url js-deploy-url-feature-flag deploy-link btn btn-default btn-sm inline"
>
<span>
{{ __('View app') }}
<icon name="external-link" />
</span>
</a>
</template>
<loading-button
v-if="deployment.stop_url"
:loading="isStopping"
......
......@@ -112,7 +112,8 @@ export default {
eventHub.$on('mr.discussion.updated', this.checkStatus);
},
mounted() {
this.handleMounted();
this.setFaviconHelper();
this.initDeploymentsPolling();
},
beforeDestroy() {
eventHub.$off('mr.discussion.updated', this.checkStatus);
......@@ -250,10 +251,6 @@ export default {
this.stopPolling();
});
},
handleMounted() {
this.setFaviconHelper();
this.initDeploymentsPolling();
},
},
};
</script>
......
<script>
import $ from 'jquery';
import Icon from '~/vue_shared/components/icon.vue';
/**
* Renders a split dropdown with
* an input that allows to search through the given
* array of options.
*/
export default {
name: 'FilteredSearchDropdown',
components: {
Icon,
},
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,
},
},
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);
}
},
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"
>
<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"
/>
<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>
</div>
</div>
</template>
......@@ -47,7 +47,6 @@
}
}
.mr-widget-heading {
position: relative;
border: 1px solid $border-color;
......@@ -454,7 +453,7 @@
.mr-list {
.merge-request {
padding: 10px 0 10px 15px;
padding: 10px 0 10px 15px;
position: relative;
display: -webkit-flex;
display: flex;
......@@ -468,7 +467,6 @@
margin-bottom: 2px;
.ci-status-link {
svg {
height: 16px;
width: 16px;
......@@ -698,7 +696,6 @@
.table-holder {
.ci-table {
th {
background-color: $white-light;
color: $gl-text-color-secondary;
......@@ -775,7 +772,7 @@
&.affix {
left: 0;
transition: right .15s;
transition: right 0.15s;
@include media-breakpoint-down(xs) {
right: 0;
......@@ -884,7 +881,7 @@
}
> *:not(:last-child) {
margin-right: .3em;
margin-right: 0.3em;
}
svg {
......@@ -907,6 +904,10 @@
.btn svg {
fill: $theme-gray-700;
}
.dropdown-menu {
width: 400px;
}
}
// Hack alert: we've rewritten `btn` class in a way that
......@@ -917,7 +918,7 @@
&[disabled] {
cursor: not-allowed;
box-shadow: none;
opacity: .65;
opacity: 0.65;
&:hover {
color: $gl-gray-500;
......
......@@ -14,6 +14,9 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
before_action :set_issuables_index, only: [:index]
before_action :authenticate_user!, only: [:assign_related_issues]
before_action :check_user_can_push_to_source_branch!, only: [:rebase]
before_action do
push_frontend_feature_flag(:ci_environments_status_changes)
end
def index
@merge_requests = @issuables
......
---
title: Adds filtered dropdown with changed files in review
merge_request:
author:
type: changed
......@@ -6717,6 +6717,9 @@ msgstr ""
msgid "Version"
msgstr ""
msgid "View app"
msgstr ""
msgid "View file @ "
msgstr ""
......
......@@ -14,6 +14,20 @@ const deploymentMockData = {
external_url_formatted: 'diplo.',
deployed_at: '2017-03-22T22:44:42.258Z',
deployed_at_formatted: 'Mar 22, 2017 10:44pm',
changes: [
{
path: 'index.html',
external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/index.html',
},
{
path: 'imgs/gallery.html',
external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/imgs/gallery.html',
},
{
path: 'about/',
external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/about/',
},
],
};
const createComponent = () => {
const Component = Vue.extend(deploymentComponent);
......@@ -176,4 +190,42 @@ describe('Deployment component', () => {
expect(el.querySelector('.js-mr-memory-usage')).not.toBeNull();
});
});
describe('with `features.ciEnvironmentsStatusChanges` enabled', () => {
beforeEach(() => {
window.gon = window.gon || {};
window.gon.features = window.gon.features || {};
window.gon.features.ciEnvironmentsStatusChanges = true;
vm = createComponent(deploymentMockData);
});
afterEach(() => {
window.gon.features = {};
});
it('renders dropdown with changes', () => {
expect(vm.$el.querySelector('.js-mr-wigdet-deployment-dropdown')).not.toBeNull();
expect(vm.$el.querySelector('.js-deploy-url-feature-flag')).toBeNull();
});
});
describe('with `features.ciEnvironmentsStatusChanges` disabled', () => {
beforeEach(() => {
window.gon = window.gon || {};
window.gon.features = window.gon.features || {};
window.gon.features.ciEnvironmentsStatusChanges = false;
vm = createComponent(deploymentMockData);
});
afterEach(() => {
delete window.gon.features.ciEnvironmentsStatusChanges;
});
it('renders the old link to the review app', () => {
expect(vm.$el.querySelector('.js-mr-wigdet-deployment-dropdown')).toBeNull();
expect(vm.$el.querySelector('.js-deploy-url-feature-flag')).not.toBeNull();
});
});
});
......@@ -7,11 +7,12 @@ import mountComponent from 'spec/helpers/vue_mount_component_helper';
import mockData from './mock_data';
import { faviconDataUrl, overlayDataUrl, faviconWithOverlayDataUrl } from '../lib/utils/mock_data';
const returnPromise = data => new Promise((resolve) => {
resolve({
data,
const returnPromise = data =>
new Promise(resolve => {
resolve({
data,
});
});
});
describe('mrWidgetOptions', () => {
let vm;
......@@ -135,7 +136,7 @@ describe('mrWidgetOptions', () => {
describe('methods', () => {
describe('checkStatus', () => {
it('should tell service to check status', (done) => {
it('should tell service to check status', done => {
spyOn(vm.service, 'checkStatus').and.returnValue(returnPromise(mockData));
spyOn(vm.mr, 'setData');
spyOn(vm, 'handleNotification');
......@@ -185,7 +186,7 @@ describe('mrWidgetOptions', () => {
});
describe('fetchDeployments', () => {
it('should fetch deployments', (done) => {
it('should fetch deployments', done => {
spyOn(vm.service, 'fetchDeployments').and.returnValue(returnPromise([{ id: 1 }]));
vm.fetchDeployments();
......@@ -200,7 +201,7 @@ describe('mrWidgetOptions', () => {
});
describe('fetchActionsContent', () => {
it('should fetch content of Cherry Pick and Revert modals', (done) => {
it('should fetch content of Cherry Pick and Revert modals', done => {
spyOn(vm.service, 'fetchMergeActionsContent').and.returnValue(returnPromise('hello world'));
vm.fetchActionsContent();
......@@ -251,7 +252,7 @@ describe('mrWidgetOptions', () => {
};
const allArgs = eventHub.$on.calls.allArgs();
allArgs.forEach((params) => {
allArgs.forEach(params => {
const eventName = params[0];
const callback = params[1];
......@@ -270,18 +271,6 @@ describe('mrWidgetOptions', () => {
});
});
describe('handleMounted', () => {
it('should call required methods to do the initial kick-off', () => {
spyOn(vm, 'initDeploymentsPolling');
spyOn(vm, 'setFaviconHelper');
vm.handleMounted();
expect(vm.setFaviconHelper).toHaveBeenCalled();
expect(vm.initDeploymentsPolling).toHaveBeenCalled();
});
});
describe('setFavicon', () => {
let faviconElement;
......@@ -298,13 +287,14 @@ describe('mrWidgetOptions', () => {
document.body.removeChild(document.getElementById('favicon'));
});
it('should call setFavicon method', (done) => {
it('should call setFavicon method', done => {
vm.mr.ciStatusFaviconPath = overlayDataUrl;
vm.setFaviconHelper().then(() => {
expect(faviconElement.getAttribute('href')).toEqual(faviconWithOverlayDataUrl);
done();
})
.catch(done.fail);
vm.setFaviconHelper()
.then(() => {
expect(faviconElement.getAttribute('href')).toEqual(faviconWithOverlayDataUrl);
done();
})
.catch(done.fail);
});
it('should not call setFavicon when there is no ciStatusFaviconPath', () => {
......@@ -379,7 +369,7 @@ describe('mrWidgetOptions', () => {
});
describe('rendering relatedLinks', () => {
beforeEach((done) => {
beforeEach(done => {
vm.mr.relatedLinks = {
assignToMe: null,
closing: `
......@@ -396,7 +386,7 @@ describe('mrWidgetOptions', () => {
expect(vm.$el.querySelector('.close-related-link')).toBeDefined();
});
it('does not render if state is nothingToMerge', (done) => {
it('does not render if state is nothingToMerge', done => {
vm.mr.state = stateKey.nothingToMerge;
Vue.nextTick(() => {
expect(vm.$el.querySelector('.close-related-link')).toBeNull();
......@@ -406,7 +396,7 @@ describe('mrWidgetOptions', () => {
});
describe('rendering source branch removal status', () => {
it('renders when user cannot remove branch and branch should be removed', (done) => {
it('renders when user cannot remove branch and branch should be removed', done => {
vm.mr.canRemoveSourceBranch = false;
vm.mr.shouldRemoveSourceBranch = true;
vm.mr.state = 'readyToMerge';
......@@ -423,7 +413,7 @@ describe('mrWidgetOptions', () => {
});
});
it('does not render in merged state', (done) => {
it('does not render in merged state', done => {
vm.mr.canRemoveSourceBranch = false;
vm.mr.shouldRemoveSourceBranch = true;
vm.mr.state = 'merged';
......@@ -438,6 +428,20 @@ describe('mrWidgetOptions', () => {
});
describe('rendering deployments', () => {
const changes = [
{
path: 'index.html',
external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/index.html',
},
{
path: 'imgs/gallery.html',
external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/imgs/gallery.html',
},
{
path: 'about/',
external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/about/',
},
];
const deploymentMockData = {
id: 15,
name: 'review/diplo',
......@@ -449,15 +453,23 @@ describe('mrWidgetOptions', () => {
external_url_formatted: 'diplo.',
deployed_at: '2017-03-22T22:44:42.258Z',
deployed_at_formatted: 'Mar 22, 2017 10:44pm',
changes,
};
beforeEach((done) => {
vm.mr.deployments.push({
...deploymentMockData,
}, {
...deploymentMockData,
id: deploymentMockData.id + 1,
});
beforeEach(done => {
window.gon = window.gon || {};
window.gon.features = window.gon.features || {};
window.gon.features.ciEnvironmentsStatusChanges = true;
vm.mr.deployments.push(
{
...deploymentMockData,
},
{
...deploymentMockData,
id: deploymentMockData.id + 1,
},
);
vm.$nextTick(done);
});
......@@ -465,5 +477,13 @@ describe('mrWidgetOptions', () => {
it('renders multiple deployments', () => {
expect(vm.$el.querySelectorAll('.deploy-heading').length).toBe(2);
});
it('renders dropdpown with multiple file changes', () => {
expect(
vm.$el
.querySelector('.js-mr-wigdet-deployment-dropdown')
.querySelectorAll('.js-filtered-dropdown-result').length,
).toEqual(changes.length);
});
});
});
import Vue from 'vue';
import component from '~/vue_shared/components/filtered_search_dropdown.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
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 lenght', () => {
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();
});
});
});
});
});
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