Commit 3593b83a authored by Filipa Lacerda's avatar Filipa Lacerda

Handles action icons requests in a contained way and shows a loading icon to the user

parent bf806712
<script> <script>
import $ from 'jquery'; import $ from 'jquery';
import tooltip from '../../../vue_shared/directives/tooltip'; import axios from '~/lib/utils/axios_utils';
import Icon from '../../../vue_shared/components/icon.vue'; import { dasherize } from '~/lib/utils/text_utility';
import { dasherize } from '../../../lib/utils/text_utility'; import { __ } from '~/locale';
import eventHub from '../../event_hub'; import createFlash from '~/flash';
import tooltip from '~/vue_shared/directives/tooltip';
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import Icon from '~/vue_shared/components/icon.vue';
/** /**
* Renders either a cancel, retry or play icon pointing to the given path. * Renders either a cancel, retry or play icon button and handles the post request
*
* Used in:
* - mr widget mini pipeline graph: `mr_widget_pipeline.vue`
* - pipelines table
* - pipelines table in merge request page
* - pipelines table in commit page
* - pipelines detail page in big graph
*/ */
export default { export default {
components: { components: {
Icon, Icon,
LoadingIcon,
}, },
directives: { directives: {
...@@ -32,16 +44,10 @@ export default { ...@@ -32,16 +44,10 @@ export default {
required: true, required: true,
}, },
requestFinishedFor: {
type: String,
required: false,
default: '',
},
}, },
data() { data() {
return { return {
isDisabled: false, isLoading: false,
linkRequested: '',
}; };
}, },
...@@ -51,19 +57,28 @@ export default { ...@@ -51,19 +57,28 @@ export default {
return `${actionIconDash} js-icon-${actionIconDash}`; return `${actionIconDash} js-icon-${actionIconDash}`;
}, },
}, },
watch: {
requestFinishedFor() {
if (this.requestFinishedFor === this.linkRequested) {
this.isDisabled = false;
}
},
},
methods: { methods: {
/**
* The request should not be handled here.
* However due to this component being used in several
* different apps it avoids repetition & complexity.
*
*/
onClickAction() { onClickAction() {
$(this.$el).tooltip('hide'); $(this.$el).tooltip('hide');
eventHub.$emit('postAction', this.link);
this.linkRequested = this.link; this.isLoading = true;
this.isDisabled = true;
axios.post(`${this.link}.json`)
.then(() => {
this.isLoading = false;
this.$emit('pipelineActionRequestComplete');
})
.catch(() => {
this.isLoading = false;
createFlash(__('An error occurred while making the request.'));
});
}, },
}, },
}; };
...@@ -78,8 +93,12 @@ export default { ...@@ -78,8 +93,12 @@ export default {
btn-transparent ci-action-icon-container ci-action-icon-wrapper" btn-transparent ci-action-icon-container ci-action-icon-wrapper"
:class="cssClass" :class="cssClass"
data-container="body" data-container="body"
:disabled="isDisabled" :disabled="isLoading"
> >
<icon :name="actionIcon" /> <icon
v-if="!isLoading"
:name="actionIcon"
/>
<loading-icon v-else />
</button> </button>
</template> </template>
...@@ -42,11 +42,6 @@ export default { ...@@ -42,11 +42,6 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
requestFinishedFor: {
type: String,
required: false,
default: '',
},
}, },
computed: { computed: {
...@@ -76,11 +71,15 @@ export default { ...@@ -76,11 +71,15 @@ export default {
e.stopPropagation(); e.stopPropagation();
}); });
}, },
pipelineActionRequestComplete() {
this.$emit('pipelineActionRequestComplete');
},
}, },
}; };
</script> </script>
<template> <template>
<div class="ci-job-dropdown-container"> <div class="ci-job-dropdown-container dropdown">
<button <button
v-tooltip v-tooltip
type="button" type="button"
...@@ -110,7 +109,7 @@ export default { ...@@ -110,7 +109,7 @@ export default {
<job-component <job-component
:job="item" :job="item"
css-class-job-name="mini-pipeline-graph-dropdown-item" css-class-job-name="mini-pipeline-graph-dropdown-item"
:request-finished-for="requestFinishedFor" @pipelineActionRequestComplete="pipelineActionRequestComplete"
/> />
</li> </li>
</ul> </ul>
......
...@@ -16,11 +16,6 @@ export default { ...@@ -16,11 +16,6 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
requestFinishedFor: {
type: String,
required: false,
default: '',
},
}, },
computed: { computed: {
...@@ -51,6 +46,10 @@ export default { ...@@ -51,6 +46,10 @@ export default {
return className; return className;
}, },
refreshPipelineGraph() {
this.$emit('refreshPipelineGraph');
},
}, },
}; };
</script> </script>
...@@ -74,7 +73,7 @@ export default { ...@@ -74,7 +73,7 @@ export default {
:key="stage.name" :key="stage.name"
:stage-connector-class="stageConnectorClass(index, stage)" :stage-connector-class="stageConnectorClass(index, stage)"
:is-first-column="isFirstColumn(index)" :is-first-column="isFirstColumn(index)"
:request-finished-for="requestFinishedFor" @refreshPipelineGraph="refreshPipelineGraph"
/> />
</ul> </ul>
</div> </div>
......
...@@ -46,11 +46,6 @@ export default { ...@@ -46,11 +46,6 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
requestFinishedFor: {
type: String,
required: false,
default: '',
},
}, },
computed: { computed: {
status() { status() {
...@@ -84,6 +79,11 @@ export default { ...@@ -84,6 +79,11 @@ export default {
return this.job.status && this.job.status.action && this.job.status.action.path; return this.job.status && this.job.status.action && this.job.status.action.path;
}, },
}, },
methods: {
pipelineActionRequestComplete() {
this.$emit('pipelineActionRequestComplete');
},
},
}; };
</script> </script>
<template> <template>
...@@ -126,7 +126,7 @@ export default { ...@@ -126,7 +126,7 @@ export default {
:tooltip-text="status.action.title" :tooltip-text="status.action.title"
:link="status.action.path" :link="status.action.path"
:action-icon="status.action.icon" :action-icon="status.action.icon"
:request-finished-for="requestFinishedFor" @pipelineActionRequestComplete="pipelineActionRequestComplete"
/> />
</div> </div>
</template> </template>
...@@ -29,12 +29,6 @@ export default { ...@@ -29,12 +29,6 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
requestFinishedFor: {
type: String,
required: false,
default: '',
},
}, },
methods: { methods: {
...@@ -49,6 +43,10 @@ export default { ...@@ -49,6 +43,10 @@ export default {
buildConnnectorClass(index) { buildConnnectorClass(index) {
return index === 0 && !this.isFirstColumn ? 'left-connector' : ''; return index === 0 && !this.isFirstColumn ? 'left-connector' : '';
}, },
pipelineActionRequestComplete() {
this.$emit('refreshPipelineGraph');
},
}, },
}; };
</script> </script>
...@@ -75,12 +73,13 @@ export default { ...@@ -75,12 +73,13 @@ export default {
v-if="job.size === 1" v-if="job.size === 1"
:job="job" :job="job"
css-class-job-name="build-content" css-class-job-name="build-content"
@pipelineActionRequestComplete="pipelineActionRequestComplete"
/> />
<dropdown-job-component <dropdown-job-component
v-if="job.size > 1" v-if="job.size > 1"
:job="job" :job="job"
:request-finished-for="requestFinishedFor" @pipelineActionRequestComplete="pipelineActionRequestComplete"
/> />
</li> </li>
......
...@@ -297,6 +297,7 @@ ...@@ -297,6 +297,7 @@
v-for="(stage, index) in pipeline.details.stages" v-for="(stage, index) in pipeline.details.stages"
:key="index"> :key="index">
<pipeline-stage <pipeline-stage
type="PIPELINES_TABLE"
:stage="stage" :stage="stage"
:update-dropdown="updateGraphDropdown" :update-dropdown="updateGraphDropdown"
/> />
......
...@@ -44,6 +44,12 @@ export default { ...@@ -44,6 +44,12 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
type: {
type: String,
required: false,
default: '',
},
}, },
data() { data() {
...@@ -133,6 +139,16 @@ export default { ...@@ -133,6 +139,16 @@ export default {
isDropdownOpen() { isDropdownOpen() {
return this.$el.classList.contains('open'); return this.$el.classList.contains('open');
}, },
pipelineActionRequestComplete() {
if (this.type === 'PIPELINES_TABLE') {
// warn the table to update
eventHub.$emit('clickedDropdown');
} else {
// refresh the content
this.fetchJobs();
}
},
}, },
}; };
</script> </script>
...@@ -188,6 +204,7 @@ export default { ...@@ -188,6 +204,7 @@ export default {
<job-component <job-component
:job="job" :job="job"
css-class-job-name="mini-pipeline-graph-dropdown-item" css-class-job-name="mini-pipeline-graph-dropdown-item"
@pipelineActionRequestComplete="pipelineActionRequestComplete"
/> />
</li> </li>
</ul> </ul>
......
...@@ -25,30 +25,14 @@ export default () => { ...@@ -25,30 +25,14 @@ export default () => {
data() { data() {
return { return {
mediator, mediator,
requestFinishedFor: null,
}; };
}, },
created() {
eventHub.$on('postAction', this.postAction);
},
beforeDestroy() {
eventHub.$off('postAction', this.postAction);
},
methods: { methods: {
postAction(action) { requestRefreshPipelineGraph() {
// Click was made, reset this variable // When an action is clicked
this.requestFinishedFor = null; // (wether in the dropdown or in the main nodes, we refresh the big graph)
this.mediator.refreshPipeline()
this.mediator.service .catch(() => Flash(__('An error occurred while making the request.')));
.postAction(action)
.then(() => {
this.mediator.refreshPipeline();
this.requestFinishedFor = action;
})
.catch(() => {
this.requestFinishedFor = action;
Flash(__('An error occurred while making the request.'));
});
}, },
}, },
render(createElement) { render(createElement) {
...@@ -56,7 +40,9 @@ export default () => { ...@@ -56,7 +40,9 @@ export default () => {
props: { props: {
isLoading: this.mediator.state.isLoading, isLoading: this.mediator.state.isLoading,
pipeline: this.mediator.store.state.pipeline, pipeline: this.mediator.store.state.pipeline,
requestFinishedFor: this.requestFinishedFor, },
on: {
refreshPipelineGraph: this.requestRefreshPipelineGraph,
}, },
}); });
}, },
......
import Vue from 'vue'; import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import actionComponent from '~/pipelines/components/graph/action_component.vue'; import actionComponent from '~/pipelines/components/graph/action_component.vue';
import eventHub from '~/pipelines/event_hub';
import mountComponent from '../../helpers/vue_mount_component_helper'; import mountComponent from '../../helpers/vue_mount_component_helper';
describe('pipeline graph action component', () => { describe('pipeline graph action component', () => {
let component; let component;
let mock;
beforeEach(done => { beforeEach(done => {
const ActionComponent = Vue.extend(actionComponent); const ActionComponent = Vue.extend(actionComponent);
mock = new MockAdapter(axios);
mock.onPost('foo.json').reply(200);
component = mountComponent(ActionComponent, { component = mountComponent(ActionComponent, {
tooltipText: 'bar', tooltipText: 'bar',
link: 'foo', link: 'foo',
...@@ -18,15 +24,10 @@ describe('pipeline graph action component', () => { ...@@ -18,15 +24,10 @@ describe('pipeline graph action component', () => {
}); });
afterEach(() => { afterEach(() => {
mock.restore();
component.$destroy(); component.$destroy();
}); });
it('should emit an event with the provided link', () => {
eventHub.$on('postAction', link => {
expect(link).toEqual('foo');
});
});
it('should render the provided title as a bootstrap tooltip', () => { it('should render the provided title as a bootstrap tooltip', () => {
expect(component.$el.getAttribute('data-original-title')).toEqual('bar'); expect(component.$el.getAttribute('data-original-title')).toEqual('bar');
}); });
...@@ -34,10 +35,12 @@ describe('pipeline graph action component', () => { ...@@ -34,10 +35,12 @@ describe('pipeline graph action component', () => {
it('should update bootstrap tooltip when title changes', done => { it('should update bootstrap tooltip when title changes', done => {
component.tooltipText = 'changed'; component.tooltipText = 'changed';
setTimeout(() => { component.$nextTick()
.then(() => {
expect(component.$el.getAttribute('data-original-title')).toBe('changed'); expect(component.$el.getAttribute('data-original-title')).toBe('changed');
done(); })
}); .then(done)
.catch(done.fail);
}); });
it('should render an svg', () => { it('should render an svg', () => {
...@@ -45,44 +48,30 @@ describe('pipeline graph action component', () => { ...@@ -45,44 +48,30 @@ describe('pipeline graph action component', () => {
expect(component.$el.querySelector('svg')).toBeDefined(); expect(component.$el.querySelector('svg')).toBeDefined();
}); });
it('disables the button when clicked', done => { it('renders a loading icon while component is loading', done => {
component.$el.click(); component.isLoading = true;
component.$nextTick(() => {
expect(component.$el.getAttribute('disabled')).toEqual('disabled');
done();
});
});
it('re-enabled the button when `requestFinishedFor` matches `linkRequested`', done => {
component.$el.click();
component component.$nextTick()
.$nextTick()
.then(() => { .then(() => {
expect(component.$el.getAttribute('disabled')).toEqual('disabled'); expect(component.$el.querySelector('.fa-spin')).not.toBeNull();
component.requestFinishedFor = 'foo';
})
.then(() => {
expect(component.$el.getAttribute('disabled')).toBeNull();
}) })
.then(done) .then(done)
.catch(done.fail); .catch(done.fail);
}); });
it('does not re-enable the button when `requestFinishedFor` does not matches `linkRequested`', done => { describe('on click', () => {
component.$el.click(); it('emits `pipelineActionRequestComplete` after a successfull request', done => {
spyOn(component, '$emit');
component component.$el.click();
.$nextTick() expect(component.isLoading).toEqual(true);
.then(() => {
expect(component.$el.getAttribute('disabled')).toEqual('disabled'); component.$nextTick()
component.requestFinishedFor = 'bar'; .then(() => {
}) expect(component.$emit).toHaveBeenCalledWith('pipelineActionRequestComplete');
.then(() => { })
expect(component.$el.getAttribute('disabled')).toEqual('disabled'); .then(done)
}) .catch(done.fail);
.then(done) });
.catch(done.fail);
}); });
}); });
...@@ -102,4 +102,53 @@ describe('Pipelines stage component', () => { ...@@ -102,4 +102,53 @@ describe('Pipelines stage component', () => {
}); });
}); });
}); });
describe('pipelineActionRequestComplete', () => {
beforeEach(() => {
mock.onGet('path.json').reply(200, stageReply);
mock.onPost(`${stageReply.latest_statuses[0].status.action.path}.json`).reply(200);
});
describe('within pipeline table', () => {
it('emits `clickedDropdown` event when `pipelineActionRequestComplete` is triggered', done => {
spyOn(eventHub, '$emit');
component.type = 'PIPELINES_TABLE';
component.$el.querySelector('button').click();
setTimeout(() => {
component.$el.querySelector('.js-ci-action').click();
component.$nextTick()
.then(() => {
expect(eventHub.$emit).toHaveBeenCalledWith('clickedDropdown');
expect(eventHub.$emit).toHaveBeenCalledTimes(2);
})
.then(done)
.catch(done.fail);
}, 0);
});
});
describe('without a type', () => {
it('fetches dropdown content again', done => {
spyOn(component, 'fetchJobs').and.callThrough();
component.$el.querySelector('button').click();
expect(component.fetchJobs).toHaveBeenCalledTimes(1);
setTimeout(() => {
component.$el.querySelector('.js-ci-action').click();
component.$nextTick()
.then(() => {
expect(component.fetchJobs).toHaveBeenCalledTimes(2);
})
.then(done)
.catch(done.fail);
}, 0);
});
});
});
}); });
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