Commit a1348a5d authored by Filipa Lacerda's avatar Filipa Lacerda Committed by Phil Hughes

Uniform CI status components in vue

parent aa6f4432
/* global Flash */
import { borderlessStatusIconEntityMap } from '../../vue_shared/ci_status_icons';
export default {
data() {
return {
builds: '',
spinner: '<span class="fa fa-spinner fa-spin"></span>',
};
},
props: {
stage: {
type: Object,
required: true,
},
},
updated() {
if (this.builds) {
this.stopDropdownClickPropagation();
}
},
methods: {
fetchBuilds(e) {
const ariaExpanded = e.currentTarget.attributes['aria-expanded'];
if (ariaExpanded && (ariaExpanded.textContent === 'true')) return null;
return this.$http.get(this.stage.dropdown_path)
.then((response) => {
this.builds = JSON.parse(response.body).html;
}, () => {
const flash = new Flash('Something went wrong on our end.');
return flash;
});
},
/**
* When the user right clicks or cmd/ctrl + click in the job name
* the dropdown should not be closed and the link should open in another tab,
* so we stop propagation of the click event inside the dropdown.
*
* Since this component is rendered multiple times per page we need to guarantee we only
* target the click event of this component.
*/
stopDropdownClickPropagation() {
$(this.$el.querySelectorAll('.js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item')).on('click', (e) => {
e.stopPropagation();
});
},
},
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}`;
},
triggerButtonClass() {
return `mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button ci-status-icon-${this.stage.status.group}`;
},
svgHTML() {
return borderlessStatusIconEntityMap[this.stage.status.icon];
},
},
watch: {
'stage.title': function stageTitle() {
$(this.$refs.button).tooltip('destroy').tooltip();
},
},
template: `
<div>
<button
@click="fetchBuilds($event)"
:class="triggerButtonClass"
:title="stage.title"
data-placement="top"
data-toggle="dropdown"
type="button"
ref="button"
:aria-label="stage.title">
<span v-html="svgHTML" aria-hidden="true"></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">
<div class="arrow-up" aria-hidden="true"></div>
<div
:class="dropdownClass"
class="js-builds-dropdown-list scrollable-menu"
v-html="buildsOrSpinner">
</div>
</ul>
</div>
`,
};
import canceledSvg from 'icons/_icon_status_canceled.svg';
import createdSvg from 'icons/_icon_status_created.svg';
import failedSvg from 'icons/_icon_status_failed.svg';
import manualSvg from 'icons/_icon_status_manual.svg';
import pendingSvg from 'icons/_icon_status_pending.svg';
import runningSvg from 'icons/_icon_status_running.svg';
import skippedSvg from 'icons/_icon_status_skipped.svg';
import successSvg from 'icons/_icon_status_success.svg';
import warningSvg from 'icons/_icon_status_warning.svg';
export default {
props: {
pipeline: {
type: Object,
required: true,
},
},
data() {
const svgsDictionary = {
icon_status_canceled: canceledSvg,
icon_status_created: createdSvg,
icon_status_failed: failedSvg,
icon_status_manual: manualSvg,
icon_status_pending: pendingSvg,
icon_status_running: runningSvg,
icon_status_skipped: skippedSvg,
icon_status_success: successSvg,
icon_status_warning: warningSvg,
};
return {
svg: svgsDictionary[this.pipeline.details.status.icon],
};
},
computed: {
cssClasses() {
return `ci-status ci-${this.pipeline.details.status.group}`;
},
detailsPath() {
const { status } = this.pipeline.details;
return status.has_details ? status.details_path : false;
},
content() {
return `${this.svg} ${this.pipeline.details.status.text}`;
},
},
template: `
<td class="commit-link">
<a
:class="cssClasses"
:href="detailsPath"
v-html="content">
</a>
</td>
`,
};
/* global Flash */
import '~/lib/utils/datetime_utility';
import { statusClassToSvgMap } from '../../vue_shared/pipeline_svg_icons';
import { statusIconEntityMap } from '../../vue_shared/ci_status_icons';
import MemoryUsage from './mr_widget_memory_usage';
import MRWidgetService from '../services/mr_widget_service';
......@@ -16,7 +16,7 @@ export default {
},
computed: {
svg() {
return statusClassToSvgMap.icon_status_success;
return statusIconEntityMap.icon_status_success;
},
},
methods: {
......
import PipelineStage from '../../pipelines/components/stage';
import pipelineStatusIcon from '../../vue_shared/components/pipeline_status_icon';
import { statusClassToSvgMap } from '../../vue_shared/pipeline_svg_icons';
import PipelineStage from '../../pipelines/components/stage.vue';
import ciIcon from '../../vue_shared/components/ci_icon.vue';
import { statusIconEntityMap } from '../../vue_shared/ci_status_icons';
export default {
name: 'MRWidgetPipeline',
......@@ -9,7 +9,7 @@ export default {
},
components: {
'pipeline-stage': PipelineStage,
'pipeline-status-icon': pipelineStatusIcon,
ciIcon,
},
computed: {
hasCIError() {
......@@ -18,11 +18,14 @@ export default {
return hasCI && !ciStatus;
},
svg() {
return statusClassToSvgMap.icon_status_failed;
return statusIconEntityMap.icon_status_failed;
},
stageText() {
return this.mr.pipeline.details.stages.length > 1 ? 'stages' : 'stage';
},
status() {
return this.mr.pipeline.details.status || {};
},
},
template: `
<div class="mr-widget-heading">
......@@ -38,7 +41,13 @@ export default {
<span>Could not connect to the CI server. Please check your settings and try again.</span>
</template>
<template v-else>
<pipeline-status-icon :pipelineStatus="mr.pipelineDetailedStatus" />
<div>
<a
class="icon-link"
:href="this.status.details_path">
<ci-icon :status="status" />
</a>
</div>
<span>
Pipeline
<a
......
......@@ -41,15 +41,3 @@ export const statusIconEntityMap = {
icon_status_success: SUCCESS_SVG,
icon_status_warning: WARNING_SVG,
};
export const statusCssClasses = {
icon_status_canceled: 'canceled',
icon_status_created: 'created',
icon_status_failed: 'failed',
icon_status_manual: 'manual',
icon_status_pending: 'pending',
icon_status_running: 'running',
icon_status_skipped: 'skipped',
icon_status_success: 'success',
icon_status_warning: 'warning',
};
<script>
import ciIcon from './ci_icon.vue';
/**
* Renders CI Badge link with CI icon and status text based on
* API response shared between all places where it is used.
*
* Receives status object containing:
* status: {
* details_path: "/gitlab-org/gitlab-ce/pipelines/8150156" // url
* group:"running" // used for CSS class
* icon: "icon_status_running" // used to render the icon
* label:"running" // used for potential tooltip
* text:"running" // text rendered
* }
*
* Used in:
* - Pipelines table - first column
* - Jobs table - first column
* - Pipeline show view - header
* - Job show view - header
* - MR widget
*/
export default {
props: {
status: {
type: Object,
required: true,
},
},
components: {
ciIcon,
},
computed: {
cssClass() {
const className = this.status.group;
return className ? `ci-status ci-${this.status.group}` : 'ci-status';
},
},
};
</script>
<template>
<a
:href="status.details_path"
:class="cssClass">
<ci-icon :status="status" />
{{status.text}}
</a>
</template>
<script>
import { statusIconEntityMap, statusCssClasses } from '../../vue_shared/ci_status_icons';
import { statusIconEntityMap } from '../ci_status_icons';
/**
* Renders CI icon based on API response shared between all places where it is used.
*
* Receives status object containing:
* status: {
* details_path: "/gitlab-org/gitlab-ce/pipelines/8150156" // url
* group:"running" // used for CSS class
* icon: "icon_status_running" // used to render the icon
* label:"running" // used for potential tooltip
* text:"running" // text rendered
* }
*
* Used in:
* - Pipelines table Badge
* - Pipelines table mini graph
* - Pipeline graph
* - Pipeline show view badge
* - Jobs table
* - Jobs show view header
* - Jobs show view sidebar
*/
export default {
props: {
status: {
......@@ -15,7 +36,7 @@
},
cssClass() {
const status = statusCssClasses[this.status.icon];
const status = this.status.group;
return `ci-status-icon ci-status-icon-${status} js-ci-status-icon-${status}`;
},
},
......
import { statusClassToSvgMap } from '../pipeline_svg_icons';
export default {
name: 'PipelineStatusIcon',
props: {
pipelineStatus: { type: Object, required: true, default: () => ({}) },
},
computed: {
svg() {
return statusClassToSvgMap[this.pipelineStatus.icon];
},
statusClass() {
return `ci-status-icon ci-status-icon-${this.pipelineStatus.group}`;
},
},
template: `
<div :class="statusClass">
<a class="icon-link" :href="pipelineStatus.details_path">
<span v-html="svg" aria-hidden="true"></span>
</a>
</div>
`,
};
......@@ -2,7 +2,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 ciBadge from './ci_badge_link.vue';
import PipelinesStageComponent from '../../pipelines/components/stage.vue';
import PipelinesUrlComponent from '../../pipelines/components/pipeline_url';
import PipelinesTimeagoComponent from '../../pipelines/components/time_ago';
......@@ -39,7 +39,7 @@ export default {
'commit-component': CommitComponent,
'dropdown-stage': PipelinesStageComponent,
'pipeline-url': PipelinesUrlComponent,
'status-scope': PipelinesStatusComponent,
ciBadge,
'time-ago': PipelinesTimeagoComponent,
},
......@@ -196,11 +196,20 @@ export default {
return '';
},
pipelineStatus() {
if (this.pipeline.details && this.pipeline.details.status) {
return this.pipeline.details.status;
}
return {};
},
},
template: `
<tr class="commit">
<status-scope :pipeline="pipeline"/>
<td class="commit-link">
<ci-badge :status="pipelineStatus"/>
</td>
<pipeline-url :pipeline="pipeline"></pipeline-url>
......
import canceledSvg from 'icons/_icon_status_canceled.svg';
import createdSvg from 'icons/_icon_status_created.svg';
import failedSvg from 'icons/_icon_status_failed.svg';
import manualSvg from 'icons/_icon_status_manual.svg';
import pendingSvg from 'icons/_icon_status_pending.svg';
import runningSvg from 'icons/_icon_status_running.svg';
import skippedSvg from 'icons/_icon_status_skipped.svg';
import successSvg from 'icons/_icon_status_success.svg';
import warningSvg from 'icons/_icon_status_warning.svg';
import canceledBorderlessSvg from 'icons/_icon_status_canceled_borderless.svg';
import createdBorderlessSvg from 'icons/_icon_status_created_borderless.svg';
import failedBorderlessSvg from 'icons/_icon_status_failed_borderless.svg';
import manualBorderlessSvg from 'icons/_icon_status_manual_borderless.svg';
import pendingBorderlessSvg from 'icons/_icon_status_pending_borderless.svg';
import runningBorderlessSvg from 'icons/_icon_status_running_borderless.svg';
import skippedBorderlessSvg from 'icons/_icon_status_skipped_borderless.svg';
import successBorderlessSvg from 'icons/_icon_status_success_borderless.svg';
import warningBorderlessSvg from 'icons/_icon_status_warning_borderless.svg';
export const statusClassToSvgMap = {
icon_status_canceled: canceledSvg,
icon_status_created: createdSvg,
icon_status_failed: failedSvg,
icon_status_manual: manualSvg,
icon_status_pending: pendingSvg,
icon_status_running: runningSvg,
icon_status_skipped: skippedSvg,
icon_status_success: successSvg,
icon_status_warning: warningSvg,
};
export const statusClassToBorderlessSvgMap = {
icon_status_canceled: canceledBorderlessSvg,
icon_status_created: createdBorderlessSvg,
icon_status_failed: failedBorderlessSvg,
icon_status_manual: manualBorderlessSvg,
icon_status_pending: pendingBorderlessSvg,
icon_status_running: runningBorderlessSvg,
icon_status_skipped: skippedBorderlessSvg,
icon_status_success: successBorderlessSvg,
icon_status_warning: warningBorderlessSvg,
};
......@@ -90,11 +90,6 @@
align-items: center;
padding: $gl-padding-top $gl-padding 0;
i,
svg {
margin-right: 8px;
}
svg {
position: relative;
top: 1px;
......@@ -109,9 +104,10 @@
flex-wrap: wrap;
}
.ci-status-icon > .icon-link svg {
.icon-link > .ci-status-icon > svg {
width: 22px;
height: 22px;
margin-right: 8px;
}
}
......
---
title: Refactor all CI vue badges to use the same vue component
merge_request:
author:
import Vue from 'vue';
import deploymentComponent from '~/vue_merge_request_widget/components/mr_widget_deployment';
import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service';
import { statusClassToSvgMap } from '~/vue_shared/pipeline_svg_icons';
import { statusIconEntityMap } from '~/vue_shared/ci_status_icons';
const deploymentMockData = [
{
......@@ -46,7 +46,7 @@ describe('MRWidgetDeployment', () => {
describe('svg', () => {
it('should have the proper SVG icon', () => {
const vm = createComponent(deploymentMockData);
expect(vm.svg).toEqual(statusClassToSvgMap.icon_status_success);
expect(vm.svg).toEqual(statusIconEntityMap.icon_status_success);
});
});
});
......
import Vue from 'vue';
import { statusClassToSvgMap } from '~/vue_shared/pipeline_svg_icons';
import { statusIconEntityMap } from '~/vue_shared/ci_status_icons';
import pipelineComponent from '~/vue_merge_request_widget/components/mr_widget_pipeline';
import mockData from '../mock_data';
......@@ -24,7 +24,7 @@ describe('MRWidgetPipeline', () => {
describe('components', () => {
it('should have components added', () => {
expect(pipelineComponent.components['pipeline-stage']).toBeDefined();
expect(pipelineComponent.components['pipeline-status-icon']).toBeDefined();
expect(pipelineComponent.components.ciIcon).toBeDefined();
});
});
......@@ -33,7 +33,7 @@ describe('MRWidgetPipeline', () => {
it('should have the proper SVG icon', () => {
const vm = createComponent({ pipeline: mockData.pipeline });
expect(vm.svg).toEqual(statusClassToSvgMap.icon_status_failed);
expect(vm.svg).toEqual(statusIconEntityMap.icon_status_failed);
});
});
......
import Vue from 'vue';
import ciBadge from '~/vue_shared/components/ci_badge_link.vue';
describe('CI Badge Link Component', () => {
let CIBadge;
const statuses = {
canceled: {
text: 'canceled',
label: 'canceled',
group: 'canceled',
icon: 'icon_status_canceled',
details_path: 'status/canceled',
},
created: {
text: 'created',
label: 'created',
group: 'created',
icon: 'icon_status_created',
details_path: 'status/created',
},
failed: {
text: 'failed',
label: 'failed',
group: 'failed',
icon: 'icon_status_failed',
details_path: 'status/failed',
},
manual: {
text: 'manual',
label: 'manual action',
group: 'manual',
icon: 'icon_status_manual',
details_path: 'status/manual',
},
pending: {
text: 'pending',
label: 'pending',
group: 'pending',
icon: 'icon_status_pending',
details_path: 'status/pending',
},
running: {
text: 'running',
label: 'running',
group: 'running',
icon: 'icon_status_running',
details_path: 'status/running',
},
skipped: {
text: 'skipped',
label: 'skipped',
group: 'skipped',
icon: 'icon_status_skipped',
details_path: 'status/skipped',
},
success_warining: {
text: 'passed',
label: 'passed',
group: 'success_with_warnings',
icon: 'icon_status_warning',
details_path: 'status/warning',
},
success: {
text: 'passed',
label: 'passed',
group: 'passed',
icon: 'icon_status_success',
details_path: 'status/passed',
},
};
it('should render each status badge', () => {
CIBadge = Vue.extend(ciBadge);
Object.keys(statuses).map((status) => {
const vm = new CIBadge({
propsData: {
status: statuses[status],
},
}).$mount();
expect(vm.$el.getAttribute('href')).toEqual(statuses[status].details_path);
expect(vm.$el.textContent.trim()).toEqual(statuses[status].text);
expect(vm.$el.getAttribute('class')).toEqual(`ci-status ci-${statuses[status].group}`);
expect(vm.$el.querySelector('svg')).toBeDefined();
return vm;
});
});
});
......@@ -25,6 +25,7 @@ describe('CI Icon component', () => {
propsData: {
status: {
icon: 'icon_status_success',
group: 'success',
},
},
}).$mount();
......@@ -37,6 +38,7 @@ describe('CI Icon component', () => {
propsData: {
status: {
icon: 'icon_status_failed',
group: 'failed',
},
},
}).$mount();
......@@ -49,6 +51,7 @@ describe('CI Icon component', () => {
propsData: {
status: {
icon: 'icon_status_warning',
group: 'warning',
},
},
}).$mount();
......@@ -61,6 +64,7 @@ describe('CI Icon component', () => {
propsData: {
status: {
icon: 'icon_status_pending',
group: 'pending',
},
},
}).$mount();
......@@ -73,6 +77,7 @@ describe('CI Icon component', () => {
propsData: {
status: {
icon: 'icon_status_running',
group: 'running',
},
},
}).$mount();
......@@ -85,6 +90,7 @@ describe('CI Icon component', () => {
propsData: {
status: {
icon: 'icon_status_created',
group: 'created',
},
},
}).$mount();
......@@ -97,6 +103,7 @@ describe('CI Icon component', () => {
propsData: {
status: {
icon: 'icon_status_skipped',
group: 'skipped',
},
},
}).$mount();
......@@ -109,6 +116,7 @@ describe('CI Icon component', () => {
propsData: {
status: {
icon: 'icon_status_canceled',
group: 'canceled',
},
},
}).$mount();
......@@ -121,6 +129,7 @@ describe('CI Icon component', () => {
propsData: {
status: {
icon: 'icon_status_manual',
group: 'manual',
},
},
}).$mount();
......
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