Skip to content
Snippets Groups Projects
Commit e69732e2 authored by Filipa Lacerda's avatar Filipa Lacerda Committed by Phil Hughes
Browse files

Pipeline table mini graph dropdown remains open when table is refreshed

parent dcdced81
No related branches found
No related tags found
No related merge requests found
Loading
Loading
@@ -46,6 +46,7 @@ export default Vue.component('pipelines-table', {
isLoading: false,
hasError: false,
isMakingRequest: false,
updateGraphDropdown: false,
};
},
 
Loading
Loading
@@ -130,15 +131,21 @@ export default Vue.component('pipelines-table', {
const pipelines = response.pipelines || response;
this.store.storePipelines(pipelines);
this.isLoading = false;
this.updateGraphDropdown = true;
},
 
errorCallback() {
this.hasError = true;
this.isLoading = false;
this.updateGraphDropdown = false;
},
 
setIsMakingRequest(isMakingRequest) {
this.isMakingRequest = isMakingRequest;
if (isMakingRequest) {
this.updateGraphDropdown = false;
}
},
},
 
Loading
Loading
@@ -163,7 +170,9 @@ export default Vue.component('pipelines-table', {
v-if="shouldRenderTable">
<pipelines-table-component
:pipelines="state.pipelines"
:service="service" />
:service="service"
:update-graph-dropdown="updateGraphDropdown"
/>
</div>
</div>
`,
Loading
Loading
<script>
/**
* Renders each stage of the pipeline mini graph.
*
* Given the provided endpoint will make a request to
* fetch the dropdown data when the stage is clicked.
*
* Request is made inside this component to make it reusable between:
* 1. Pipelines main table
* 2. Pipelines table in commit and Merge request views
* 3. Merge request widget
* 4. Commit widget
*/
/* global Flash */
import StatusIconEntityMap from '../../ci_status_icons';
 
Loading
Loading
@@ -7,36 +22,55 @@ export default {
type: Object,
required: true,
},
updateDropdown: {
type: Boolean,
required: false,
default: false,
},
},
 
data() {
return {
builds: '',
spinner: '<span class="fa fa-spinner fa-spin"></span>',
isLoading: false,
dropdownContent: '',
endpoint: this.stage.dropdown_path,
};
},
 
updated() {
if (this.builds) {
if (this.dropdownContent.length > 0) {
this.stopDropdownClickPropagation();
}
},
 
methods: {
fetchBuilds(e) {
const ariaExpanded = e.currentTarget.attributes['aria-expanded'];
watch: {
updateDropdown() {
if (this.updateDropdown &&
this.isDropdownOpen() &&
!this.isLoading) {
this.fetchJobs();
}
},
},
 
if (ariaExpanded && (ariaExpanded.textContent === 'true')) return null;
methods: {
onClickStage() {
if (!this.isDropdownOpen()) {
this.isLoading = true;
this.fetchJobs();
}
},
 
return this.$http.get(this.stage.dropdown_path)
fetchJobs() {
this.$http.get(this.endpoint)
.then((response) => {
this.builds = JSON.parse(response.body).html;
this.dropdownContent = response.json().html;
this.isLoading = false;
})
.catch(() => {
// If dropdown is opened we'll close it.
if (this.$el.classList.contains('open')) {
$(this.$refs.dropdown).dropdown('toggle');
}
this.closeDropdown();
this.isLoading = false;
 
const flash = new Flash('Something went wrong on our end.');
return flash;
Loading
Loading
@@ -57,59 +91,83 @@ export default {
e.stopPropagation();
});
},
closeDropdown() {
if (this.isDropdownOpen()) {
$(this.$refs.dropdown).dropdown('toggle');
}
},
isDropdownOpen() {
return this.$el.classList.contains('open');
},
},
computed: {
buildsOrSpinner() {
return this.builds ? this.builds : this.spinner;
},
dropdownClass() {
if (this.builds) return 'js-builds-dropdown-container';
return 'js-builds-dropdown-loading builds-dropdown-loading';
},
buildStatus() {
return `Build: ${this.stage.status.label}`;
},
tooltip() {
return `has-tooltip ci-status-icon ci-status-icon-${this.stage.status.group}`;
return this.dropdownContent.length > 0 ? 'js-builds-dropdown-container' : 'js-builds-dropdown-loading';
},
triggerButtonClass() {
return `mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button ci-status-icon-${this.stage.status.group}`;
return `ci-status-icon-${this.stage.status.group}`;
},
svgHTML() {
svgIcon() {
return StatusIconEntityMap[this.stage.status.icon];
},
},
template: `
<div>
<button
@click="fetchBuilds($event)"
:class="triggerButtonClass"
:title="stage.title"
data-placement="top"
data-toggle="dropdown"
type="button"
:aria-label="stage.title"
ref="dropdown">
<span
v-html="svgHTML"
aria-hidden="true">
</span>
<i
class="fa fa-caret-down"
aria-hidden="true" />
</button>
<ul
ref="dropdown-content"
class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container">
<div
class="arrow-up"
aria-hidden="true"></div>
};
</script>
<template>
<div class="dropdown">
<button
:class="triggerButtonClass"
@click="onClickStage"
class="mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button"
:title="stage.title"
data-placement="top"
data-toggle="dropdown"
type="button"
id="stageDropdown"
aria-haspopup="true"
aria-expanded="false">
<span
v-html="svgIcon"
aria-hidden="true"
:aria-label="stage.title">
</span>
<i
class="fa fa-caret-down"
aria-hidden="true">
</i>
</button>
<ul
class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container"
aria-labelledby="stageDropdown">
<li
:class="dropdownClass"
class="js-builds-dropdown-list scrollable-menu">
<div
:class="dropdownClass"
class="js-builds-dropdown-list scrollable-menu"
v-html="buildsOrSpinner">
class="text-center"
v-if="isLoading">
<i
class="fa fa-spin fa-spinner"
aria-hidden="true"
aria-label="Loading">
</i>
</div>
</ul>
</div>
`,
};
<ul
v-else
v-html="dropdownContent">
</ul>
</li>
</ul>
</div>
</script>
Loading
Loading
@@ -49,6 +49,7 @@ export default {
isLoading: false,
hasError: false,
isMakingRequest: false,
updateGraphDropdown: false,
};
},
 
Loading
Loading
@@ -198,15 +199,21 @@ export default {
this.store.storePagination(response.headers);
 
this.isLoading = false;
this.updateGraphDropdown = true;
},
 
errorCallback() {
this.hasError = true;
this.isLoading = false;
this.updateGraphDropdown = false;
},
 
setIsMakingRequest(isMakingRequest) {
this.isMakingRequest = isMakingRequest;
if (isMakingRequest) {
this.updateGraphDropdown = false;
}
},
},
 
Loading
Loading
@@ -263,7 +270,9 @@ export default {
 
<pipelines-table-component
:pipelines="state.pipelines"
:service="service"/>
:service="service"
:update-graph-dropdown="updateGraphDropdown"
/>
</div>
 
<gl-pagination
Loading
Loading
Loading
Loading
@@ -10,13 +10,18 @@ export default {
pipelines: {
type: Array,
required: true,
default: () => ([]),
},
 
service: {
type: Object,
required: true,
},
updateGraphDropdown: {
type: Boolean,
required: false,
default: false,
},
},
 
components: {
Loading
Loading
@@ -40,7 +45,9 @@ export default {
v-bind:model="model">
<tr is="pipelines-table-row-component"
:pipeline="model"
:service="service"></tr>
:service="service"
:update-graph-dropdown="updateGraphDropdown"
/>
</template>
</tbody>
</table>
Loading
Loading
Loading
Loading
@@ -3,7 +3,7 @@ import AsyncButtonComponent from '../../pipelines/components/async_button.vue';
import PipelinesActionsComponent from '../../pipelines/components/pipelines_actions';
import PipelinesArtifactsComponent from '../../pipelines/components/pipelines_artifacts';
import PipelinesStatusComponent from '../../pipelines/components/status';
import PipelinesStageComponent from '../../pipelines/components/stage';
import PipelinesStageComponent from '../../pipelines/components/stage.vue';
import PipelinesUrlComponent from '../../pipelines/components/pipeline_url';
import PipelinesTimeagoComponent from '../../pipelines/components/time_ago';
import CommitComponent from './commit';
Loading
Loading
@@ -24,6 +24,12 @@ export default {
type: Object,
required: true,
},
updateGraphDropdown: {
type: Boolean,
required: false,
default: false,
},
},
 
components: {
Loading
Loading
@@ -213,7 +219,10 @@ export default {
<div class="stage-container dropdown js-mini-pipeline-graph"
v-if="pipeline.details.stages.length > 0"
v-for="stage in pipeline.details.stages">
<dropdown-stage :stage="stage"/>
<dropdown-stage
:stage="stage"
:update-dropdown="updateGraphDropdown"/>
</div>
</td>
 
Loading
Loading
Loading
Loading
@@ -781,16 +781,11 @@
}
 
.scrollable-menu {
padding: 0;
max-height: 245px;
overflow: auto;
}
 
// Loading icon
.builds-dropdown-loading {
margin: 0 auto;
width: 20px;
}
// Action icon on the right
a.ci-action-icon-wrapper {
color: $action-icon-color;
Loading
Loading
@@ -893,30 +888,29 @@
* Top arrow in the dropdown in the mini pipeline graph
*/
.mini-pipeline-graph-dropdown-menu {
.arrow-up {
&::before,
&::after {
content: '';
display: inline-block;
position: absolute;
width: 0;
height: 0;
border-color: transparent;
border-style: solid;
top: -6px;
left: 2px;
border-width: 0 5px 6px;
}
 
&::before {
border-width: 0 5px 5px;
border-bottom-color: $border-color;
}
&::before,
&::after {
content: '';
display: inline-block;
position: absolute;
width: 0;
height: 0;
border-color: transparent;
border-style: solid;
top: -6px;
left: 2px;
border-width: 0 5px 6px;
}
 
&::after {
margin-top: 1px;
border-bottom-color: $white-light;
}
&::before {
border-width: 0 5px 5px;
border-bottom-color: $border-color;
}
&::after {
margin-top: 1px;
border-bottom-color: $white-light;
}
}
 
Loading
Loading
Loading
Loading
@@ -11,8 +11,8 @@
= icon('caret-down')
 
%ul.dropdown-menu.mini-pipeline-graph-dropdown-menu.js-builds-dropdown-container
.arrow-up
.js-builds-dropdown-list.scrollable-menu
%li.js-builds-dropdown-list.scrollable-menu
 
.js-builds-dropdown-loading.builds-dropdown-loading.hidden
%span.fa.fa-spinner.fa-spin
%li.js-builds-dropdown-loading.hidden
.text-center
%i.fa.fa-spinner.fa-spin{ 'aria-hidden': 'true', 'aria-label': 'Loading' }
---
title: Job dropdown of pipeline mini graph updates in realtime when its opened
merge_request:
author:
Loading
Loading
@@ -3,7 +3,7 @@
Dropdown
 
%ul.dropdown-menu.mini-pipeline-graph-dropdown-menu.js-builds-dropdown-container
.js-builds-dropdown-list.scrollable-menu
%li.js-builds-dropdown-list.scrollable-menu
 
.js-builds-dropdown-loading.builds-dropdown-loading.hidden
%span.fa.fa-spinner.fa-spin
%li.js-builds-dropdown-loading.hidden
%span.fa.fa-spinner
import Vue from 'vue';
import { SUCCESS_SVG } from '~/ci_status_icons';
import Stage from '~/pipelines/components/stage';
import stage from '~/pipelines/components/stage.vue';
describe('Pipelines stage component', () => {
let StageComponent;
let component;
beforeEach(() => {
StageComponent = Vue.extend(stage);
component = new StageComponent({
propsData: {
stage: {
status: {
group: 'success',
icon: 'icon_status_success',
title: 'success',
},
dropdown_path: 'foo',
},
updateDropdown: false,
},
}).$mount();
});
 
function minify(string) {
return string.replace(/\s/g, '');
}
it('should render a dropdown with the status icon', () => {
expect(component.$el.getAttribute('class')).toEqual('dropdown');
expect(component.$el.querySelector('svg')).toBeDefined();
expect(component.$el.querySelector('button').getAttribute('data-toggle')).toEqual('dropdown');
});
 
describe('Pipelines Stage', () => {
describe('data', () => {
let stageReturnValue;
describe('with successfull request', () => {
const interceptor = (request, next) => {
next(request.respondWith(JSON.stringify({ html: 'foo' }), {
status: 200,
}));
};
 
beforeEach(() => {
stageReturnValue = Stage.data();
Vue.http.interceptors.push(interceptor);
});
 
it('should return object with .builds and .spinner', () => {
expect(stageReturnValue).toEqual({
builds: '',
spinner: '<span class="fa fa-spinner fa-spin"></span>',
});
afterEach(() => {
Vue.http.interceptors = _.without(
Vue.http.interceptors, interceptor,
);
});
});
 
describe('computed', () => {
describe('svgHTML', function () {
let stage;
let svgHTML;
it('should render the received data', (done) => {
component.$el.querySelector('button').click();
 
beforeEach(() => {
stage = { stage: { status: { icon: 'icon_status_success' } } };
svgHTML = Stage.computed.svgHTML.call(stage);
});
it("should return the correct icon for the stage's status", () => {
expect(svgHTML).toBe(SUCCESS_SVG);
});
setTimeout(() => {
expect(
component.$el.querySelector('.js-builds-dropdown-container ul').textContent.trim(),
).toEqual('foo');
done();
}, 0);
});
});
 
describe('when mounted', () => {
let StageComponent;
let renderedComponent;
let stage;
describe('when request fails', () => {
const interceptor = (request, next) => {
next(request.respondWith(JSON.stringify({}), {
status: 500,
}));
};
 
beforeEach(() => {
stage = { status: { icon: 'icon_status_success' } };
StageComponent = Vue.extend(Stage);
renderedComponent = new StageComponent({
propsData: {
stage,
},
}).$mount();
Vue.http.interceptors.push(interceptor);
});
 
it('should render the correct status svg', () => {
const minifiedComponent = minify(renderedComponent.$el.outerHTML);
const expectedSVG = minify(SUCCESS_SVG);
expect(minifiedComponent).toContain(expectedSVG);
afterEach(() => {
Vue.http.interceptors = _.without(
Vue.http.interceptors, interceptor,
);
});
});
describe('when request fails', () => {
it('closes dropdown', () => {
spyOn($, 'ajax').and.callFake(options => options.error());
const StageComponent = Vue.extend(Stage);
 
const component = new StageComponent({
propsData: { stage: { status: { icon: 'foo' } } },
}).$mount();
it('should close the dropdown', () => {
component.$el.click();
 
expect(
component.$el.classList.contains('open'),
).toEqual(false);
setTimeout(() => {
expect(component.$el.classList.contains('open')).toEqual(false);
}, 0);
});
});
});
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment