diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue index 9161f703697..6c87287a4c4 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue @@ -1,6 +1,7 @@ diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_dropdown.vue b/app/assets/javascripts/vue_shared/components/filtered_search_dropdown.vue new file mode 100644 index 00000000000..460fa6ad72e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_dropdown.vue @@ -0,0 +1,143 @@ + + diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 895db89f289..2feb7464ecb 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -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; diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index f87337b67aa..757b03d0b0e 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -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 diff --git a/changelogs/unreleased/fe-ac-review-app-changes-33418.yml b/changelogs/unreleased/fe-ac-review-app-changes-33418.yml new file mode 100644 index 00000000000..e4803683062 --- /dev/null +++ b/changelogs/unreleased/fe-ac-review-app-changes-33418.yml @@ -0,0 +1,5 @@ +--- +title: Adds filtered dropdown with changed files in review +merge_request: +author: +type: changed diff --git a/locale/gitlab.pot b/locale/gitlab.pot index d73beaf93b6..bb18d4eccd8 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -6717,6 +6717,9 @@ msgstr "" msgid "Version" msgstr "" +msgid "View app" +msgstr "" + msgid "View file @ " msgstr "" diff --git a/spec/javascripts/vue_mr_widget/components/deployment_spec.js b/spec/javascripts/vue_mr_widget/components/deployment_spec.js index 20b5532a837..ce850bc621e 100644 --- a/spec/javascripts/vue_mr_widget/components/deployment_spec.js +++ b/spec/javascripts/vue_mr_widget/components/deployment_spec.js @@ -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(); + }); + }); }); diff --git a/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js b/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js index 6b5e32fdfd5..d1a064b9f4d 100644 --- a/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js @@ -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); + }); }); }); diff --git a/spec/javascripts/vue_shared/components/filtered_search_dropdown_spec.js b/spec/javascripts/vue_shared/components/filtered_search_dropdown_spec.js new file mode 100644 index 00000000000..b71cb36ecf6 --- /dev/null +++ b/spec/javascripts/vue_shared/components/filtered_search_dropdown_spec.js @@ -0,0 +1,91 @@ +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(); + }); + }); + }); + }); +});