Commit 9eab1db9 authored by Phil Hughes's avatar Phil Hughes

Merge branch '31558-job-dropdown' into 'master'

Pipeline table mini graph dropdown remains open when table is refreshed

Closes #31558 and #31433

See merge request !11033
parents dcdced81 e69732e2
......@@ -46,6 +46,7 @@ export default Vue.component('pipelines-table', {
isLoading: false,
hasError: false,
isMakingRequest: false,
updateGraphDropdown: false,
};
},
......@@ -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;
}
},
},
......@@ -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>
`,
......
<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';
......@@ -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;
......@@ -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>
......@@ -49,6 +49,7 @@ export default {
isLoading: false,
hasError: false,
isMakingRequest: false,
updateGraphDropdown: false,
};
},
......@@ -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;
}
},
},
......@@ -263,7 +270,9 @@ export default {
<pipelines-table-component
:pipelines="state.pipelines"
:service="service"/>
:service="service"
:update-graph-dropdown="updateGraphDropdown"
/>
</div>
<gl-pagination
......
......@@ -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: {
......@@ -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>
......
......@@ -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';
......@@ -24,6 +24,12 @@ export default {
type: Object,
required: true,
},
updateGraphDropdown: {
type: Boolean,
required: false,
default: false,
},
},
components: {
......@@ -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>
......
......@@ -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;
......@@ -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;
}
}
......
......@@ -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:
......@@ -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);
});
});
});
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