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> <script>
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.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 timeagoMixin from '../../vue_shared/mixins/timeago';
import tooltip from '../../vue_shared/directives/tooltip'; import tooltip from '../../vue_shared/directives/tooltip';
import LoadingButton from '../../vue_shared/components/loading_button.vue'; import LoadingButton from '../../vue_shared/components/loading_button.vue';
...@@ -18,6 +19,7 @@ export default { ...@@ -18,6 +19,7 @@ export default {
StatusIcon, StatusIcon,
Icon, Icon,
TooltipOnTruncate, TooltipOnTruncate,
FilteredSearchDropdown,
}, },
directives: { directives: {
tooltip, tooltip,
...@@ -30,8 +32,10 @@ export default { ...@@ -30,8 +32,10 @@ export default {
}, },
}, },
data() { data() {
const features = window.gon.features || {};
return { return {
isStopping: false, isStopping: false,
enableCiEnvironmentsStatusChanges: features.ciEnvironmentsStatusChanges,
}; };
}, },
computed: { computed: {
...@@ -118,18 +122,65 @@ export default { ...@@ -118,18 +122,65 @@ export default {
/> />
</div> </div>
<div> <div>
<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 <a
v-if="hasExternalUrls" :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" :href="deployment.external_url"
target="_blank" target="_blank"
rel="noopener noreferrer nofollow" rel="noopener noreferrer nofollow"
class="deploy-link js-deploy-url btn btn-default btn-sm inline" class="js-deploy-url js-deploy-url-feature-flag deploy-link btn btn-default btn-sm inline"
> >
<span> <span>
View app {{ __('View app') }}
<icon name="external-link" /> <icon name="external-link" />
</span> </span>
</a> </a>
</template>
<loading-button <loading-button
v-if="deployment.stop_url" v-if="deployment.stop_url"
:loading="isStopping" :loading="isStopping"
......
...@@ -112,7 +112,8 @@ export default { ...@@ -112,7 +112,8 @@ export default {
eventHub.$on('mr.discussion.updated', this.checkStatus); eventHub.$on('mr.discussion.updated', this.checkStatus);
}, },
mounted() { mounted() {
this.handleMounted(); this.setFaviconHelper();
this.initDeploymentsPolling();
}, },
beforeDestroy() { beforeDestroy() {
eventHub.$off('mr.discussion.updated', this.checkStatus); eventHub.$off('mr.discussion.updated', this.checkStatus);
...@@ -250,10 +251,6 @@ export default { ...@@ -250,10 +251,6 @@ export default {
this.stopPolling(); this.stopPolling();
}); });
}, },
handleMounted() {
this.setFaviconHelper();
this.initDeploymentsPolling();
},
}, },
}; };
</script> </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 @@ ...@@ -47,7 +47,6 @@
} }
} }
.mr-widget-heading { .mr-widget-heading {
position: relative; position: relative;
border: 1px solid $border-color; border: 1px solid $border-color;
...@@ -468,7 +467,6 @@ ...@@ -468,7 +467,6 @@
margin-bottom: 2px; margin-bottom: 2px;
.ci-status-link { .ci-status-link {
svg { svg {
height: 16px; height: 16px;
width: 16px; width: 16px;
...@@ -698,7 +696,6 @@ ...@@ -698,7 +696,6 @@
.table-holder { .table-holder {
.ci-table { .ci-table {
th { th {
background-color: $white-light; background-color: $white-light;
color: $gl-text-color-secondary; color: $gl-text-color-secondary;
...@@ -775,7 +772,7 @@ ...@@ -775,7 +772,7 @@
&.affix { &.affix {
left: 0; left: 0;
transition: right .15s; transition: right 0.15s;
@include media-breakpoint-down(xs) { @include media-breakpoint-down(xs) {
right: 0; right: 0;
...@@ -884,7 +881,7 @@ ...@@ -884,7 +881,7 @@
} }
> *:not(:last-child) { > *:not(:last-child) {
margin-right: .3em; margin-right: 0.3em;
} }
svg { svg {
...@@ -907,6 +904,10 @@ ...@@ -907,6 +904,10 @@
.btn svg { .btn svg {
fill: $theme-gray-700; fill: $theme-gray-700;
} }
.dropdown-menu {
width: 400px;
}
} }
// Hack alert: we've rewritten `btn` class in a way that // Hack alert: we've rewritten `btn` class in a way that
...@@ -917,7 +918,7 @@ ...@@ -917,7 +918,7 @@
&[disabled] { &[disabled] {
cursor: not-allowed; cursor: not-allowed;
box-shadow: none; box-shadow: none;
opacity: .65; opacity: 0.65;
&:hover { &:hover {
color: $gl-gray-500; color: $gl-gray-500;
......
...@@ -14,6 +14,9 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo ...@@ -14,6 +14,9 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
before_action :set_issuables_index, only: [:index] before_action :set_issuables_index, only: [:index]
before_action :authenticate_user!, only: [:assign_related_issues] before_action :authenticate_user!, only: [:assign_related_issues]
before_action :check_user_can_push_to_source_branch!, only: [:rebase] 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 def index
@merge_requests = @issuables @merge_requests = @issuables
......
---
title: Adds filtered dropdown with changed files in review
merge_request:
author:
type: changed
...@@ -6717,6 +6717,9 @@ msgstr "" ...@@ -6717,6 +6717,9 @@ msgstr ""
msgid "Version" msgid "Version"
msgstr "" msgstr ""
msgid "View app"
msgstr ""
msgid "View file @ " msgid "View file @ "
msgstr "" msgstr ""
......
...@@ -14,6 +14,20 @@ const deploymentMockData = { ...@@ -14,6 +14,20 @@ const deploymentMockData = {
external_url_formatted: 'diplo.', external_url_formatted: 'diplo.',
deployed_at: '2017-03-22T22:44:42.258Z', deployed_at: '2017-03-22T22:44:42.258Z',
deployed_at_formatted: 'Mar 22, 2017 10:44pm', 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 createComponent = () => {
const Component = Vue.extend(deploymentComponent); const Component = Vue.extend(deploymentComponent);
...@@ -176,4 +190,42 @@ describe('Deployment component', () => { ...@@ -176,4 +190,42 @@ describe('Deployment component', () => {
expect(el.querySelector('.js-mr-memory-usage')).not.toBeNull(); 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'; ...@@ -7,11 +7,12 @@ import mountComponent from 'spec/helpers/vue_mount_component_helper';
import mockData from './mock_data'; import mockData from './mock_data';
import { faviconDataUrl, overlayDataUrl, faviconWithOverlayDataUrl } from '../lib/utils/mock_data'; import { faviconDataUrl, overlayDataUrl, faviconWithOverlayDataUrl } from '../lib/utils/mock_data';
const returnPromise = data => new Promise((resolve) => { const returnPromise = data =>
new Promise(resolve => {
resolve({ resolve({
data, data,
}); });
}); });
describe('mrWidgetOptions', () => { describe('mrWidgetOptions', () => {
let vm; let vm;
...@@ -135,7 +136,7 @@ describe('mrWidgetOptions', () => { ...@@ -135,7 +136,7 @@ describe('mrWidgetOptions', () => {
describe('methods', () => { describe('methods', () => {
describe('checkStatus', () => { 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.service, 'checkStatus').and.returnValue(returnPromise(mockData));
spyOn(vm.mr, 'setData'); spyOn(vm.mr, 'setData');
spyOn(vm, 'handleNotification'); spyOn(vm, 'handleNotification');
...@@ -185,7 +186,7 @@ describe('mrWidgetOptions', () => { ...@@ -185,7 +186,7 @@ describe('mrWidgetOptions', () => {
}); });
describe('fetchDeployments', () => { describe('fetchDeployments', () => {
it('should fetch deployments', (done) => { it('should fetch deployments', done => {
spyOn(vm.service, 'fetchDeployments').and.returnValue(returnPromise([{ id: 1 }])); spyOn(vm.service, 'fetchDeployments').and.returnValue(returnPromise([{ id: 1 }]));
vm.fetchDeployments(); vm.fetchDeployments();
...@@ -200,7 +201,7 @@ describe('mrWidgetOptions', () => { ...@@ -200,7 +201,7 @@ describe('mrWidgetOptions', () => {
}); });
describe('fetchActionsContent', () => { 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')); spyOn(vm.service, 'fetchMergeActionsContent').and.returnValue(returnPromise('hello world'));
vm.fetchActionsContent(); vm.fetchActionsContent();
...@@ -251,7 +252,7 @@ describe('mrWidgetOptions', () => { ...@@ -251,7 +252,7 @@ describe('mrWidgetOptions', () => {
}; };
const allArgs = eventHub.$on.calls.allArgs(); const allArgs = eventHub.$on.calls.allArgs();
allArgs.forEach((params) => { allArgs.forEach(params => {
const eventName = params[0]; const eventName = params[0];
const callback = params[1]; const callback = params[1];
...@@ -270,18 +271,6 @@ describe('mrWidgetOptions', () => { ...@@ -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', () => { describe('setFavicon', () => {
let faviconElement; let faviconElement;
...@@ -298,9 +287,10 @@ describe('mrWidgetOptions', () => { ...@@ -298,9 +287,10 @@ describe('mrWidgetOptions', () => {
document.body.removeChild(document.getElementById('favicon')); document.body.removeChild(document.getElementById('favicon'));
}); });
it('should call setFavicon method', (done) => { it('should call setFavicon method', done => {
vm.mr.ciStatusFaviconPath = overlayDataUrl; vm.mr.ciStatusFaviconPath = overlayDataUrl;
vm.setFaviconHelper().then(() => { vm.setFaviconHelper()
.then(() => {
expect(faviconElement.getAttribute('href')).toEqual(faviconWithOverlayDataUrl); expect(faviconElement.getAttribute('href')).toEqual(faviconWithOverlayDataUrl);
done(); done();
}) })
...@@ -379,7 +369,7 @@ describe('mrWidgetOptions', () => { ...@@ -379,7 +369,7 @@ describe('mrWidgetOptions', () => {
}); });
describe('rendering relatedLinks', () => { describe('rendering relatedLinks', () => {
beforeEach((done) => { beforeEach(done => {
vm.mr.relatedLinks = { vm.mr.relatedLinks = {
assignToMe: null, assignToMe: null,
closing: ` closing: `
...@@ -396,7 +386,7 @@ describe('mrWidgetOptions', () => { ...@@ -396,7 +386,7 @@ describe('mrWidgetOptions', () => {
expect(vm.$el.querySelector('.close-related-link')).toBeDefined(); 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; vm.mr.state = stateKey.nothingToMerge;
Vue.nextTick(() => { Vue.nextTick(() => {
expect(vm.$el.querySelector('.close-related-link')).toBeNull(); expect(vm.$el.querySelector('.close-related-link')).toBeNull();
...@@ -406,7 +396,7 @@ describe('mrWidgetOptions', () => { ...@@ -406,7 +396,7 @@ describe('mrWidgetOptions', () => {
}); });
describe('rendering source branch removal status', () => { 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.canRemoveSourceBranch = false;
vm.mr.shouldRemoveSourceBranch = true; vm.mr.shouldRemoveSourceBranch = true;
vm.mr.state = 'readyToMerge'; vm.mr.state = 'readyToMerge';
...@@ -423,7 +413,7 @@ describe('mrWidgetOptions', () => { ...@@ -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.canRemoveSourceBranch = false;
vm.mr.shouldRemoveSourceBranch = true; vm.mr.shouldRemoveSourceBranch = true;
vm.mr.state = 'merged'; vm.mr.state = 'merged';
...@@ -438,6 +428,20 @@ describe('mrWidgetOptions', () => { ...@@ -438,6 +428,20 @@ describe('mrWidgetOptions', () => {
}); });
describe('rendering deployments', () => { 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 = { const deploymentMockData = {
id: 15, id: 15,
name: 'review/diplo', name: 'review/diplo',
...@@ -449,15 +453,23 @@ describe('mrWidgetOptions', () => { ...@@ -449,15 +453,23 @@ describe('mrWidgetOptions', () => {
external_url_formatted: 'diplo.', external_url_formatted: 'diplo.',
deployed_at: '2017-03-22T22:44:42.258Z', deployed_at: '2017-03-22T22:44:42.258Z',
deployed_at_formatted: 'Mar 22, 2017 10:44pm', deployed_at_formatted: 'Mar 22, 2017 10:44pm',
changes,
}; };
beforeEach((done) => { beforeEach(done => {
vm.mr.deployments.push({ window.gon = window.gon || {};
window.gon.features = window.gon.features || {};
window.gon.features.ciEnvironmentsStatusChanges = true;
vm.mr.deployments.push(
{
...deploymentMockData, ...deploymentMockData,
}, { },
{
...deploymentMockData, ...deploymentMockData,
id: deploymentMockData.id + 1, id: deploymentMockData.id + 1,
}); },
);
vm.$nextTick(done); vm.$nextTick(done);
}); });
...@@ -465,5 +477,13 @@ describe('mrWidgetOptions', () => { ...@@ -465,5 +477,13 @@ describe('mrWidgetOptions', () => {
it('renders multiple deployments', () => { it('renders multiple deployments', () => {
expect(vm.$el.querySelectorAll('.deploy-heading').length).toBe(2); 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