diff options
10 files changed, 427 insertions, 61 deletions
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 @@ <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" diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index a599cc002d6..8180f13a7cb 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -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> 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 @@ +<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> 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(); + }); + }); + }); + }); +}); |