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