Merge branch '46381-dropdown-mr-widget' into 'master'

Resolve "Dropdown actions in mini pipeline graph in mr widget don't work"

Closes #46381

See merge request gitlab-org/gitlab-ce!18976
This commit is contained in:
Annabel Gray 2018-05-17 17:04:02 +00:00
commit 7f7f874184
12 changed files with 141 additions and 117 deletions

View file

@ -1,11 +1,21 @@
<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 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: {
@ -32,16 +42,10 @@ export default {
required: true, required: true,
}, },
requestFinishedFor: {
type: String,
required: false,
default: '',
},
}, },
data() { data() {
return { return {
isDisabled: false, isDisabled: false,
linkRequested: '',
}; };
}, },
@ -51,19 +55,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.isDisabled = true; this.isDisabled = true;
axios.post(`${this.link}.json`)
.then(() => {
this.isDisabled = false;
this.$emit('pipelineActionRequestComplete');
})
.catch(() => {
this.isDisabled = false;
createFlash(__('An error occurred while making the request.'));
});
}, },
}, },
}; };

View file

@ -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 {
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 {
<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>

View file

@ -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 {
return className; return className;
}, },
refreshPipelineGraph() {
this.$emit('refreshPipelineGraph');
},
}, },
}; };
</script> </script>
@ -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>

View file

@ -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 {
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 {
: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>

View file

@ -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 {
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 {
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>

View file

@ -9,6 +9,7 @@
import CommitComponent from '../../vue_shared/components/commit.vue'; import CommitComponent from '../../vue_shared/components/commit.vue';
import LoadingButton from '../../vue_shared/components/loading_button.vue'; import LoadingButton from '../../vue_shared/components/loading_button.vue';
import Icon from '../../vue_shared/components/icon.vue'; import Icon from '../../vue_shared/components/icon.vue';
import { PIPELINES_TABLE } from '../constants';
/** /**
* Pipeline table row. * Pipeline table row.
@ -46,6 +47,7 @@
required: true, required: true,
}, },
}, },
pipelinesTable: PIPELINES_TABLE,
data() { data() {
return { return {
isRetrying: false, isRetrying: false,
@ -297,6 +299,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="$options.pipelinesTable"
:stage="stage" :stage="stage"
:update-dropdown="updateGraphDropdown" :update-dropdown="updateGraphDropdown"
/> />

View file

@ -21,6 +21,7 @@ import Icon from '../../vue_shared/components/icon.vue';
import LoadingIcon from '../../vue_shared/components/loading_icon.vue'; import LoadingIcon from '../../vue_shared/components/loading_icon.vue';
import JobComponent from './graph/job_component.vue'; import JobComponent from './graph/job_component.vue';
import tooltip from '../../vue_shared/directives/tooltip'; import tooltip from '../../vue_shared/directives/tooltip';
import { PIPELINES_TABLE } from '../constants';
export default { export default {
components: { components: {
@ -44,6 +45,12 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
type: {
type: String,
required: false,
default: '',
},
}, },
data() { data() {
@ -133,6 +140,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('refreshPipelinesTable');
} else {
// close the dropdown in mr widget
$(this.$refs.dropdown).dropdown('toggle');
}
},
}, },
}; };
</script> </script>
@ -151,6 +168,7 @@ export default {
id="stageDropdown" id="stageDropdown"
aria-haspopup="true" aria-haspopup="true"
aria-expanded="false" aria-expanded="false"
ref="dropdown"
> >
<span <span
@ -188,6 +206,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>

View file

@ -1,2 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export const CANCEL_REQUEST = 'CANCEL_REQUEST'; export const CANCEL_REQUEST = 'CANCEL_REQUEST';
export const PIPELINES_TABLE = 'PIPELINES_TABLE';

View file

@ -55,11 +55,13 @@ export default {
eventHub.$on('postAction', this.postAction); eventHub.$on('postAction', this.postAction);
eventHub.$on('retryPipeline', this.postAction); eventHub.$on('retryPipeline', this.postAction);
eventHub.$on('clickedDropdown', this.updateTable); eventHub.$on('clickedDropdown', this.updateTable);
eventHub.$on('refreshPipelinesTable', this.fetchPipelines);
}, },
beforeDestroy() { beforeDestroy() {
eventHub.$off('postAction', this.postAction); eventHub.$off('postAction', this.postAction);
eventHub.$off('retryPipeline', this.postAction); eventHub.$off('retryPipeline', this.postAction);
eventHub.$off('clickedDropdown', this.updateTable); eventHub.$off('clickedDropdown', this.updateTable);
eventHub.$off('refreshPipelinesTable', this.fetchPipelines);
}, },
destroyed() { destroyed() {
this.poll.stop(); this.poll.stop();

View file

@ -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 () => {
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,
}, },
}); });
}, },

View file

@ -1,13 +1,19 @@
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', () => {
}); });
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', () => {
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,18 @@ describe('pipeline graph action component', () => {
expect(component.$el.querySelector('svg')).toBeDefined(); expect(component.$el.querySelector('svg')).toBeDefined();
}); });
it('disables the button when clicked', done => { describe('on click', () => {
it('emits `pipelineActionRequestComplete` after a successfull request', done => {
spyOn(component, '$emit');
component.$el.click(); component.$el.click();
component.$nextTick(() => { component.$nextTick()
expect(component.$el.getAttribute('disabled')).toEqual('disabled');
done();
});
});
it('re-enabled the button when `requestFinishedFor` matches `linkRequested`', done => {
component.$el.click();
component
.$nextTick()
.then(() => { .then(() => {
expect(component.$el.getAttribute('disabled')).toEqual('disabled'); expect(component.$emit).toHaveBeenCalledWith('pipelineActionRequestComplete');
component.requestFinishedFor = 'foo';
})
.then(() => {
expect(component.$el.getAttribute('disabled')).toBeNull();
})
.then(done)
.catch(done.fail);
});
it('does not re-enable the button when `requestFinishedFor` does not matches `linkRequested`', done => {
component.$el.click();
component
.$nextTick()
.then(() => {
expect(component.$el.getAttribute('disabled')).toEqual('disabled');
component.requestFinishedFor = 'bar';
})
.then(() => {
expect(component.$el.getAttribute('disabled')).toEqual('disabled');
}) })
.then(done) .then(done)
.catch(done.fail); .catch(done.fail);
}); });
}); });
});

View file

@ -102,4 +102,31 @@ 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 `refreshPipelinesTable` 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('refreshPipelinesTable');
})
.then(done)
.catch(done.fail);
}, 0);
});
});
});
}); });