diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.js b/app/assets/javascripts/commit/pipelines/pipelines_table.js index 68a1c1de1df804e0a1b38236e5cd7f843a449ae9..933aee2d7392dbd5ee541e41346c53ed8c064340 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.js +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.js @@ -91,7 +91,6 @@ export default Vue.component('pipelines-table', { }); if (!Visibility.hidden()) { - this.isLoading = true; this.poll.makeRequest(); } @@ -125,8 +124,6 @@ export default Vue.component('pipelines-table', { methods: { fetchPipelines() { - this.isLoading = true; - return this.service.getPipelines() .then(response => this.successCallback(response)) .catch(() => this.errorCallback()); @@ -172,7 +169,7 @@ export default Vue.component('pipelines-table', { v-if="shouldRenderTable"> <pipelines-table-component :pipelines="state.pipelines" - :service="service" /> + :service="service"/> </div> </div> `, diff --git a/app/assets/javascripts/pipelines/components/async_button.vue b/app/assets/javascripts/pipelines/components/async_button.vue index d1c60b570dec945c026a51d09d25f76d4bc283df..ced65b41c48309bf2564a6067d3e8f42d60c6406 100644 --- a/app/assets/javascripts/pipelines/components/async_button.vue +++ b/app/assets/javascripts/pipelines/components/async_button.vue @@ -1,82 +1,30 @@ <script> -/* eslint-disable no-new, no-alert */ -/* global Flash */ -import '~/flash'; -import eventHub from '../event_hub'; - export default { props: { - endpoint: { - type: String, - required: true, - }, - - service: { - type: Object, - required: true, - }, - title: { type: String, required: true, }, - icon: { type: String, required: true, }, - - cssClass: { - type: String, + isLoading: { + type: Boolean, required: true, }, - - confirmActionMessage: { - type: String, - required: false, - }, - }, - - data() { - return { - isLoading: false, - }; }, computed: { iconClass() { return `fa fa-${this.icon}`; }, - buttonClass() { - return `btn has-tooltip ${this.cssClass}`; + return 'btn has-tooltip'; }, }, - - methods: { - onClick() { - if (this.confirmActionMessage && confirm(this.confirmActionMessage)) { - this.makeRequest(); - } else if (!this.confirmActionMessage) { - this.makeRequest(); - } - }, - - makeRequest() { - this.isLoading = true; - - $(this.$el).tooltip('destroy'); - - this.service.postAction(this.endpoint) - .then(() => { - this.isLoading = false; - eventHub.$emit('refreshPipelines'); - }) - .catch(() => { - this.isLoading = false; - new Flash('An error occured while making the request.'); - }); - }, + updated() { + $(this.$el).tooltip('destroy'); }, }; </script> @@ -84,7 +32,6 @@ export default { <template> <button type="button" - @click="onClick" :class="buttonClass" :title="title" :aria-label="title" diff --git a/app/assets/javascripts/pipelines/pipelines.js b/app/assets/javascripts/pipelines/pipelines.js index 6eea4812f335556a83086bba5caaa9e7a6d366cc..964efc5cc622debca076979e9eb8a735d44308c9 100644 --- a/app/assets/javascripts/pipelines/pipelines.js +++ b/app/assets/javascripts/pipelines/pipelines.js @@ -146,7 +146,6 @@ export default { }); if (!Visibility.hidden()) { - this.isLoading = true; poll.makeRequest(); } @@ -189,8 +188,6 @@ export default { fetchPipelines() { if (!this.isMakingRequest) { - this.isLoading = true; - this.service.getPipelines({ scope: this.scopeParameter, page: this.pageParameter }) .then(response => this.successCallback(response)) .catch(() => this.errorCallback()); @@ -206,7 +203,6 @@ export default { this.store.storeCount(response.body.count); this.store.storePipelines(response.body.pipelines); this.store.storePagination(response.headers); - this.isLoading = false; }, diff --git a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js index 62b7131de515248c15701e2e21f6819e52bdfc43..cf5161d65a626fc5f1b0eca0df0dfee182d3fd6e 100644 --- a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js +++ b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js @@ -1,5 +1,6 @@ -/* eslint-disable no-param-reassign */ - +/* eslint-disable no-param-reassign, no-alert */ +import Flash from '../../flash'; +import eventHub from '../../pipelines/event_hub'; import AsyncButtonComponent from '../../pipelines/components/async_button.vue'; import PipelinesActionsComponent from '../../pipelines/components/pipelines_actions'; import PipelinesArtifactsComponent from '../../pipelines/components/pipelines_artifacts'; @@ -26,7 +27,12 @@ export default { required: true, }, }, - + data() { + return { + isRetrying: false, + isCancelling: false, + }; + }, components: { 'async-button-component': AsyncButtonComponent, 'pipelines-actions-component': PipelinesActionsComponent, @@ -166,8 +172,48 @@ export default { } return undefined; }, + /* These computed's allow us to track changes specifically to these values + and not update loading state everytime `pipeline` is updated */ + isRetryable() { + return !!this.pipeline.flags.retryable; + }, + isCancelable() { + return !!this.pipeline.flags.cancelable; + }, + }, + watch: { + /* When these values change, it's a signal that the retry/cancel + was successful **and** that all pipelines have been refreshed */ + isRetryable() { + this.resetRequestingState(); + }, + isCancelable() { + this.resetRequestingState(); + }, }, + methods: { + resetRequestingState() { + this.isRetrying = false; + this.isCancelling = false; + }, + cancelPipeline() { + const confirmMessage = 'Are you sure you want to cancel this pipeline?'; + if (confirmMessage && confirm(confirmMessage)) { + this.isCancelling = true; + this.makeRequest(this.pipeline.cancel_path); + } + }, + retryPipeline() { + this.isRetrying = true; + this.makeRequest(this.pipeline.retry_path); + }, + makeRequest(endpoint) { + return this.service.postAction(endpoint) + .then(() => eventHub.$emit('refreshPipelines')) + .catch(() => new Flash('An error occured while making the request.')); + }, + }, template: ` <tr class="commit"> <status-scope :pipeline="pipeline"/> @@ -206,21 +252,20 @@ export default { :artifacts="pipeline.details.artifacts" /> <async-button-component - v-if="pipeline.flags.retryable" - :service="service" - :endpoint="pipeline.retry_path" - css-class="js-pipelines-retry-button btn-default btn-retry" + v-if="isRetryable" + class="js-pipelines-retry-button btn-default btn-retry" + @click.native="retryPipeline" + :is-loading="isRetrying" title="Retry" - icon="repeat" /> + icon="repeat"/> <async-button-component - v-if="pipeline.flags.cancelable" - :service="service" - :endpoint="pipeline.cancel_path" - css-class="js-pipelines-cancel-button btn-remove" + v-if="isCancelable" + class="js-pipelines-cancel-button btn-remove" + @click.native="cancelPipeline" + :is-loading="isCancelling" title="Cancel" - icon="remove" - confirm-action-message="Are you sure you want to cancel this pipeline?" /> + icon="remove"/> </div> </td> </tr> diff --git a/spec/javascripts/pipelines/async_button_spec.js b/spec/javascripts/pipelines/async_button_spec.js index 28c9c7ab2829c800e6adfa7101da62641702e690..02a7881552ae92c3b8d8c8310a9609024e50c2be 100644 --- a/spec/javascripts/pipelines/async_button_spec.js +++ b/spec/javascripts/pipelines/async_button_spec.js @@ -3,23 +3,16 @@ import asyncButtonComp from '~/pipelines/components/async_button.vue'; describe('Pipelines Async Button', () => { let component; - let spy; let AsyncButtonComponent; beforeEach(() => { AsyncButtonComponent = Vue.extend(asyncButtonComp); - spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve()); - component = new AsyncButtonComponent({ propsData: { - endpoint: '/foo', title: 'Foo', - icon: 'fa fa-foo', - cssClass: 'bar', - service: { - postAction: spy, - }, + icon: 'foo', + isLoading: false, }, }).$mount(); }); @@ -28,6 +21,10 @@ describe('Pipelines Async Button', () => { expect(component.$el.tagName).toEqual('BUTTON'); }); + it('#iconClass computed should return the provided icon', () => { + expect(component.iconClass).toBe('fa fa-foo'); + }); + it('should render the provided icon', () => { expect(component.$el.querySelector('i').getAttribute('class')).toContain('fa fa-foo'); }); @@ -37,57 +34,16 @@ describe('Pipelines Async Button', () => { expect(component.$el.getAttribute('aria-label')).toContain('Foo'); }); - it('should render the provided cssClass', () => { - expect(component.$el.getAttribute('class')).toContain('bar'); - }); - - it('should call the service when it is clicked with the provided endpoint', () => { - component.$el.click(); - expect(spy).toHaveBeenCalledWith('/foo'); + it('should not render the spinner when not loading', () => { + expect(component.$el.querySelector('.fa-spinner')).toBeNull(); }); - it('should hide loading if request fails', () => { - spy = jasmine.createSpy('spy').and.returnValue(Promise.reject()); - - component = new AsyncButtonComponent({ - propsData: { - endpoint: '/foo', - title: 'Foo', - icon: 'fa fa-foo', - cssClass: 'bar', - dataAttributes: { - 'data-foo': 'foo', - }, - service: { - postAction: spy, - }, - }, - }).$mount(); - - component.$el.click(); - expect(component.$el.querySelector('.fa-spinner')).toBe(null); - }); - - describe('With confirm dialog', () => { - it('should call the service when confimation is positive', () => { - spyOn(window, 'confirm').and.returnValue(true); - spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve()); - - component = new AsyncButtonComponent({ - propsData: { - endpoint: '/foo', - title: 'Foo', - icon: 'fa fa-foo', - cssClass: 'bar', - service: { - postAction: spy, - }, - confirmActionMessage: 'bar', - }, - }).$mount(); + it('should render the spinner when loading state changes', (done) => { + component.isLoading = true; - component.$el.click(); - expect(spy).toHaveBeenCalledWith('/foo'); + Vue.nextTick(() => { + expect(component.$el.querySelector('.fa-spinner')).not.toBe(null); + done(); }); }); }); diff --git a/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js b/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js index 699625cdbb7e1ac3bdc1a14efff7988d136890db..7ec1e6b346901ef893a13741d91af06bb4c9218e 100644 --- a/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js +++ b/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js @@ -3,34 +3,35 @@ import tableRowComp from '~/vue_shared/components/pipelines_table_row'; import pipeline from '../../commit/pipelines/mock_data'; describe('Pipelines Table Row', () => { - let component; - beforeEach(() => { + const postActionSpy = jasmine.createSpy('spy').and.returnValue(Promise.resolve()); const PipelinesTableRowComponent = Vue.extend(tableRowComp); - component = new PipelinesTableRowComponent({ + this.component = new PipelinesTableRowComponent({ el: document.querySelector('.test-dom-element'), propsData: { pipeline, - service: {}, + service: { + postAction: postActionSpy, + }, }, }).$mount(); }); it('should render a table row', () => { - expect(component.$el).toEqual('TR'); + expect(this.component.$el).toEqual('TR'); }); describe('status column', () => { it('should render a pipeline link', () => { expect( - component.$el.querySelector('td.commit-link a').getAttribute('href'), + this.component.$el.querySelector('td.commit-link a').getAttribute('href'), ).toEqual(pipeline.path); }); it('should render status text', () => { expect( - component.$el.querySelector('td.commit-link a').textContent, + this.component.$el.querySelector('td.commit-link a').textContent, ).toContain(pipeline.details.status.text); }); }); @@ -38,24 +39,24 @@ describe('Pipelines Table Row', () => { describe('information column', () => { it('should render a pipeline link', () => { expect( - component.$el.querySelector('td:nth-child(2) a').getAttribute('href'), + this.component.$el.querySelector('td:nth-child(2) a').getAttribute('href'), ).toEqual(pipeline.path); }); it('should render pipeline ID', () => { expect( - component.$el.querySelector('td:nth-child(2) a > span').textContent, + this.component.$el.querySelector('td:nth-child(2) a > span').textContent, ).toEqual(`#${pipeline.id}`); }); describe('when a user is provided', () => { it('should render user information', () => { expect( - component.$el.querySelector('td:nth-child(2) a:nth-child(3)').getAttribute('href'), + this.component.$el.querySelector('td:nth-child(2) a:nth-child(3)').getAttribute('href'), ).toEqual(pipeline.user.web_url); expect( - component.$el.querySelector('td:nth-child(2) img').getAttribute('title'), + this.component.$el.querySelector('td:nth-child(2) img').getAttribute('title'), ).toEqual(pipeline.user.name); }); }); @@ -64,7 +65,7 @@ describe('Pipelines Table Row', () => { describe('commit column', () => { it('should render link to commit', () => { expect( - component.$el.querySelector('td:nth-child(3) .commit-id').getAttribute('href'), + this.component.$el.querySelector('td:nth-child(3) .commit-id').getAttribute('href'), ).toEqual(pipeline.commit.commit_path); }); }); @@ -72,16 +73,119 @@ describe('Pipelines Table Row', () => { describe('stages column', () => { it('should render an icon for each stage', () => { expect( - component.$el.querySelectorAll('td:nth-child(4) .js-builds-dropdown-button').length, + this.component.$el.querySelectorAll('td:nth-child(4) .js-builds-dropdown-button').length, ).toEqual(pipeline.details.stages.length); }); }); describe('actions column', () => { it('should render the provided actions', () => { - expect( - component.$el.querySelectorAll('td:nth-child(6) ul li').length, - ).toEqual(pipeline.details.manual_actions.length); + expect(this.component.$el.querySelectorAll('td:nth-child(6) ul li').length) + .toEqual(pipeline.details.manual_actions.length); + }); + + it('renders the cancel button', () => { + expect(this.component.$el.querySelectorAll('.js-pipelines-cancel-button')).not.toBeNull(); + }); + + it('renders the retry button', () => { + expect(this.component.$el.querySelectorAll('.js-pipelines-retry-button')).not.toBeNull(); + }); + }); + + describe('async button action methods', () => { + beforeEach(() => { + spyOn(window, 'confirm').and.returnValue(true); + }); + + it('#resetRequestingState resets isCancelling', (done) => { + this.component.isCancelling = true; + + this.component.resetRequestingState(); + + Vue.nextTick(() => { + expect(this.component.isCancelling).toBe(false); + done(); + }); + }); + + it('#resetRequestingState resets isRetrying', (done) => { + this.component.isRetrying = true; + + this.component.resetRequestingState(); + + Vue.nextTick(() => { + expect(this.component.isRetrying).toBe(false); + done(); + }); + }); + + it('#cancelPipeline sets isCancelling', (done) => { + spyOn(this.component, 'makeRequest'); + + this.component.cancelPipeline(); + + Vue.nextTick(() => { + expect(this.component.isCancelling).toBe(true); + done(); + }); + }); + + it('#cancelPipeline calls makeRequest', (done) => { + spyOn(this.component, 'makeRequest'); + + this.component.cancelPipeline(); + + Vue.nextTick(() => { + expect(this.component.makeRequest).toHaveBeenCalled(); + done(); + }); + }); + + it('#retryPipeline sets isRetrying', (done) => { + spyOn(this.component, 'makeRequest'); + + this.component.retryPipeline(); + + Vue.nextTick(() => { + expect(this.component.isRetrying).toBe(true); + done(); + }); + }); + + it('#retryPipeline calls makeRequest', (done) => { + spyOn(this.component, 'makeRequest'); + + this.component.retryPipeline(); + + Vue.nextTick(() => { + expect(this.component.makeRequest).toHaveBeenCalled(); + done(); + }); + }); + + it('pipeline cancelable update triggers watcher to reset isCancelling', (done) => { + this.isCancelling = true; + this.component.$props.pipeline = Object.assign({}, pipeline, { + flags: { cancelable: false }, + }); + + Vue.nextTick(() => { + expect(this.component.isCancelling).toBe(false); + done(); + }); + }); + + it('pipeline retryable update triggers watcher to reset isRetrying', (done) => { + this.isRetrying = true; + this.component.$props.pipeline = Object.assign({}, pipeline, { + flags: { retryable: false }, + }); + + Vue.nextTick(() => { + expect(this.component.isRetrying).toBe(false); + done(); + }); }); }); });