Cycle analytics second iteration

- Vue app has been completely rewritten
- New components
- Basic CSS
This commit is contained in:
Alfredo Sumaran 2016-10-20 18:24:36 -05:00
parent 6f824b156f
commit 10282283b0
22 changed files with 752 additions and 154 deletions

View File

@ -23,7 +23,8 @@
"spyOn": false,
"spyOnEvent": false,
"Turbolinks": false,
"window": false
"window": false,
"Vue": false,
"Flash": false
}
}

View File

@ -0,0 +1,24 @@
((global) => {
global.cycleAnalytics = global.cycleAnalytics || {};
/*
`build` prop should have
- Build name/title
- Build ID
- Build URL
- Build branch
- Build branch URL
- Build short SHA
- Build commit URL
- Build date
- Total time
*/
global.cycleAnalytics.ItemBuildComponent = Vue.extend({
template: '#item-build-component',
props: {
build: Object,
}
});
}(window.gl || (window.gl = {})));

View File

@ -0,0 +1,22 @@
((global) => {
global.cycleAnalytics = global.cycleAnalytics || {};
/*
`commit` prop should have
- Commit title
- Commit URL
- Commit Short SHA
- Commit author
- Commit author profile URL
- Commit author avatar URL
- Total time
*/
global.cycleAnalytics.ItemCommitComponent = Vue.extend({
template: '#item-commit-component',
props: {
commit: Object,
}
});
}(window.gl || (window.gl = {})));

View File

@ -0,0 +1,23 @@
((global) => {
global.cycleAnalytics = global.cycleAnalytics || {};
/*
`issue` prop should have
- Issue title
- Issue URL
- Issue ID
- Issue date created
- Issue author
- Issue author profile URL
- Issue author avatar URL
- Total time
*/
global.cycleAnalytics.ItemIssueComponent = Vue.extend({
template: '#item-issue-component',
props: {
issue: Object,
}
});
})(window.gl || (window.gl = {}));

View File

@ -0,0 +1,23 @@
((global) => {
global.cycleAnalytics = global.cycleAnalytics || {};
/*
`mergeRequest` prop should have
- MR title
- MR URL
- MR ID
- MR date opened
- MR author
- MR author profile URL
- MR author avatar URL
- Total time
*/
global.cycleAnalytics.ItemMergeRequestComponent = Vue.extend({
template: '#item-merge-request-component',
props: {
mergeRequest: Object,
}
});
}(window.gl || (window.gl = {})));

View File

@ -0,0 +1,26 @@
((global) => {
global.cycleAnalytics = global.cycleAnalytics || {};
global.cycleAnalytics.StageButton = Vue.extend({
props: {
stage: Object,
onStageClick: Function
},
computed: {
classObject() {
return {
'active': this.stage.active
}
}
},
methods: {
onClick(stage) {
this.onStageClick(stage);
}
}
});
})(window.gl || (window.gl = {}));

View File

@ -0,0 +1,15 @@
((global) => {
global.cycleAnalytics = global.cycleAnalytics || {};
global.cycleAnalytics.StageCodeComponent = Vue.extend({
template: '#stage-code-component',
components: {
'item-merge-request-component': gl.cycleAnalytics.ItemMergeRequestComponent,
},
props: {
items: Array,
}
});
})(window.gl || (window.gl = {}));

View File

@ -0,0 +1,15 @@
((global) => {
global.cycleAnalytics = global.cycleAnalytics || {};
global.cycleAnalytics.StageIssueComponent = Vue.extend({
template: '#stage-issue-component',
components: {
'item-issue-component': gl.cycleAnalytics.ItemIssueComponent,
},
props: {
items: Array,
}
});
})(window.gl || (window.gl = {}));

View File

@ -0,0 +1,15 @@
((global) => {
global.cycleAnalytics = global.cycleAnalytics || {};
global.cycleAnalytics.StagePlanComponent = Vue.extend({
template: '#stage-plan-component',
components: {
'item-commit-component': gl.cycleAnalytics.ItemCommitComponent,
},
props: {
items: Array,
}
});
})(window.gl || (window.gl = {}));

View File

@ -0,0 +1,15 @@
((global) => {
global.cycleAnalytics = global.cycleAnalytics || {};
global.cycleAnalytics.StageProductionComponent = Vue.extend({
template: '#stage-production-component',
components: {
'item-issue-component': gl.cycleAnalytics.ItemIssueComponent,
},
props: {
items: Array,
}
});
})(window.gl || (window.gl = {}));

View File

@ -0,0 +1,15 @@
((global) => {
global.cycleAnalytics = global.cycleAnalytics || {};
global.cycleAnalytics.StageReviewComponent = Vue.extend({
template: '#stage-review-component',
components: {
'item-merge-request-component': gl.cycleAnalytics.ItemMergeRequestComponent,
},
props: {
items: Array,
}
});
})(window.gl || (window.gl = {}));

View File

@ -0,0 +1,15 @@
((global) => {
global.cycleAnalytics = global.cycleAnalytics || {};
global.cycleAnalytics.StageStagingComponent = Vue.extend({
template: '#stage-staging-component',
components: {
'item-build-component': gl.cycleAnalytics.ItemBuildComponent,
},
props: {
items: Array,
}
});
})(window.gl || (window.gl = {}));

View File

@ -0,0 +1,15 @@
((global) => {
global.cycleAnalytics = global.cycleAnalytics || {};
global.cycleAnalytics.StageTestComponent = Vue.extend({
template: '#stage-test-component',
components: {
'item-build-component': gl.cycleAnalytics.ItemBuildComponent,
},
props: {
items: Array,
}
});
})(window.gl || (window.gl = {}));

View File

@ -1,98 +0,0 @@
/* eslint-disable */
//= require vue
((global) => {
const COOKIE_NAME = 'cycle_analytics_help_dismissed';
const store = gl.cycleAnalyticsStore = {
isLoading: true,
hasError: false,
isHelpDismissed: Cookies.get(COOKIE_NAME),
analytics: {}
};
gl.CycleAnalytics = class CycleAnalytics {
constructor() {
const that = this;
this.vue = new Vue({
el: '#cycle-analytics',
name: 'CycleAnalytics',
created: this.fetchData(),
data: store,
methods: {
dismissLanding() {
that.dismissLanding();
}
}
});
}
fetchData(options) {
store.isLoading = true;
options = options || { startDate: 30 };
$.ajax({
url: $('#cycle-analytics').data('request-path'),
method: 'GET',
dataType: 'json',
contentType: 'application/json',
data: {
cycle_analytics: {
start_date: options.startDate
}
}
}).done((data) => {
this.decorateData(data);
this.initDropdown();
})
.error((data) => {
this.handleError(data);
})
.always(() => {
store.isLoading = false;
})
}
decorateData(data) {
data.summary = data.summary || [];
data.stats = data.stats || [];
data.summary.forEach((item) => {
item.value = item.value || '-';
});
data.stats.forEach((item) => {
item.value = item.value || '- - -';
});
store.analytics = data;
}
handleError(data) {
store.hasError = true;
new Flash('There was an error while fetching cycle analytics data.', 'alert');
}
dismissLanding() {
store.isHelpDismissed = true;
Cookies.set(COOKIE_NAME, true);
}
initDropdown() {
const $dropdown = $('.js-ca-dropdown');
const $label = $dropdown.find('.dropdown-label');
$dropdown.find('li a').off('click').on('click', (e) => {
e.preventDefault();
const $target = $(e.currentTarget);
const value = $target.data('value');
$label.text($target.text().trim());
this.fetchData({ startDate: value });
})
}
}
})(window.gl || (window.gl = {}));

View File

@ -2,24 +2,48 @@
//= require_tree .
$(() => {
const EMPTY_DIALOG_COOKIE = 'ca_empty_dialog_dismissed';
const OVERVIEW_DIALOG_COOKIE = 'ca_overview_dialog_dismissed';
const cycleAnalyticsEl = document.querySelector('#cycle-analytics');
const cycleAnalyticsStore = gl.cycleAnalytics.CycleAnalyticsStore;
const cycleAnalyticsService = new gl.cycleAnalytics.CycleAnalyticsService({
requestPath: cycleAnalyticsEl.dataset.requestPath
})
requestPath: cycleAnalyticsEl.dataset.requestPath,
});
gl.cycleAnalyticsApp = new Vue({
el: '#cycle-analytics',
name: 'CycleAnalytics',
data: cycleAnalyticsStore.state,
data: {
state: cycleAnalyticsStore.state,
isLoading: false,
isLoadingStage: false,
isEmptyStage: false,
startDate: 30,
isEmptyDialogDismissed: Cookies.get(EMPTY_DIALOG_COOKIE),
isOverviewDialogDismissed: Cookies.get(OVERVIEW_DIALOG_COOKIE),
},
computed: {
currentStage() {
return cycleAnalyticsStore.currentActiveStage();
},
},
components: {
'stage-button': gl.cycleAnalytics.StageButton,
'stage-issue-component': gl.cycleAnalytics.StageIssueComponent,
'stage-plan-component': gl.cycleAnalytics.StagePlanComponent,
'stage-code-component': gl.cycleAnalytics.StageCodeComponent,
'stage-test-component': gl.cycleAnalytics.StageTestComponent,
'stage-review-component': gl.cycleAnalytics.StageReviewComponent,
'stage-staging-component': gl.cycleAnalytics.StageStagingComponent,
'stage-production-component': gl.cycleAnalytics.StageProductionComponent,
},
created() {
this.fetchCycleAnalyticsData();
},
methods: {
handleError(data) {
handleError() {
cycleAnalyticsStore.setErrorState(true);
new Flash('There was an error while fetching cycle analytics data.');
return new Flash('There was an error while fetching cycle analytics data.');
},
initDropdown() {
const $dropdown = $('.js-ca-dropdown');
@ -28,30 +52,66 @@ $(() => {
$dropdown.find('li a').off('click').on('click', (e) => {
e.preventDefault();
const $target = $(e.currentTarget);
const value = $target.data('value');
this.startDate = $target.data('value');
$label.text($target.text().trim());
this.fetchCycleAnalyticsData({ startDate: value });
this.fetchCycleAnalyticsData({ startDate: this.startDate });
});
},
fetchCycleAnalyticsData(options) {
options = options || { startDate: 30 };
const fetchOptions = options || { startDate: this.startDate };
cycleAnalyticsStore.setLoadingState(true);
this.isLoading = true;
cycleAnalyticsService
.fetchCycleAnalyticsData(options)
.then((response) => {
.fetchCycleAnalyticsData(fetchOptions)
.done((response) => {
cycleAnalyticsStore.setCycleAnalyticsData(response);
this.selectDefaultStage();
this.initDropdown();
})
.fail(() => {
this.handleError(data);
.error(() => {
this.handleError();
})
.always(() => {
cycleAnalyticsStore.setLoadingState(false);
this.isLoading = false;
});
}
}
},
selectDefaultStage() {
this.selectStage(this.state.stages.first());
},
selectStage(stage) {
if (this.isLoadingStage) return;
if (this.currentStage === stage) return;
this.isLoadingStage = true;
cycleAnalyticsStore.setStageItems([]);
cycleAnalyticsStore.setActiveStage(stage);
cycleAnalyticsService
.fetchStageData({
stage,
startDate: this.startDate,
})
.done((response) => {
this.isEmptyStage = !response.items.length;
cycleAnalyticsStore.setStageItems(response.items);
})
.error(() => {
this.isEmptyStage = true;
})
.always(() => {
this.isLoadingStage = false;
});
},
dismissEmptyDialog() {
this.isEmptyDialogDismissed = true;
Cookies.set(EMPTY_DIALOG_COOKIE, '1');
},
dismissOverviewDialog() {
this.isOverviewDialogDismissed = true;
Cookies.set(OVERVIEW_DIALOG_COOKIE, '1');
},
},
});
});

View File

@ -21,6 +21,19 @@
}
});
}
fetchStageData(options) {
let {
stage,
startDate,
} = options;
return $.get(`http://localhost:8000/${stage.name.toLowerCase()}.json`, {
cycle_analytics: {
start_date: options.startDate
}
});
}
};
global.cycleAnalytics.CycleAnalyticsService = CycleAnalyticsService;

View File

@ -3,11 +3,54 @@
global.cycleAnalytics.CycleAnalyticsStore = {
state: {
isLoading: true,
hasError: false,
summary: '',
stats: '',
analytics: ''
analytics: '',
items: [],
stages:[
{
name:'Issue',
active: false,
component: 'stage-issue-component',
legendTitle: 'Related Issues',
},
{
name:'Plan',
active: false,
component: 'stage-plan-component',
legendTitle: 'Related Commits',
},
{
name:'Code',
active: false,
component: 'stage-code-component',
legendTitle: 'Related Merge Requests',
},
{
name:'Test',
active: false,
component: 'stage-test-component',
legendTitle: 'Relative Builds Trigger by Commits',
},
{
name:'Review',
active: false,
component: 'stage-review-component',
legendTitle: 'Relative Merged Requests',
},
{
name:'Staging',
active: false,
component: 'stage-staging-component',
legendTitle: 'Relative Deployed Builds',
},
{
name:'Production',
active: false,
component: 'stage-production-component',
legendTitle: 'Related Issues',
}
],
},
setCycleAnalyticsData(data) {
this.state = Object.assign(this.state, this.decorateData(data));
@ -35,7 +78,22 @@
},
setErrorState(state) {
this.state.hasError = state;
}
},
deactivateAllStages() {
this.state.stages.forEach(stage => {
stage.active = false;
});
},
setActiveStage(stage) {
this.deactivateAllStages();
stage.active = true;
},
setStageItems(items) {
this.state.items = items;
},
currentActiveStage() {
return this.state.stages.find(stage => stage.active);
},
};
})(window.gl || (window.gl = {}));

View File

@ -160,6 +160,7 @@ $settings-icon-size: 18px;
$provider-btn-group-border: #e5e5e5;
$provider-btn-not-active-color: #4688f1;
$link-underline-blue: #4a8bee;
$active-item-blue: #4a8bee;
$layout-link-gray: #7e7c7c;
$todo-alert-blue: #428bca;
$btn-side-margin: 10px;
@ -283,6 +284,9 @@ $calendar-unselectable-bg: $gray-light;
*/
$cycle-analytics-box-padding: 30px;
$cycle-analytics-box-text-color: #8c8c8c;
$cycle-analytics-big-font: 19px;
$cycle-analytics-dark-text: $gl-title-color;
$cycle-analytics-light-gray: #bfbfbf;
/*
* Personal Access Tokens

View File

@ -1,10 +1,56 @@
#cycle-analytics {
margin: 24px auto 0;
max-width: 800px;
position: relative;
.panel {
.col-headers {
ul {
margin: 0;
padding: 0;
@include clearfix;
}
li {
display: inline-block;
float: left;
line-height: 50px;
width: 20%;
}
.fa {
color: $cycle-analytics-light-gray;
}
.stage-header {
width: 16%;
padding-left: $gl-padding;
}
.median-header {
width: 12%;
}
.delta-header {
width: 12%;
}
.event-header {
width: 45%;
padding-left: $gl-padding;
}
.total-time-header {
width: 15%;
text-align: right;
padding-right: $gl-padding;
}
.stage-name {
font-weight: 600;
}
}
.panel {
.content-block {
padding: 24px 0;
border-bottom: none;
@ -35,23 +81,16 @@
}
&:last-child {
text-align: right;
@media (max-width: $screen-sm-min) {
text-align: center;
}
}
}
.dropdown {
top: 13px;
}
}
.bordered-box {
border: 1px solid $border-color;
border-radius: $border-radius-default;
}
.content-list {
@ -141,4 +180,152 @@
margin-top: 36px;
}
.stage-panel-body {
display: flex;
flex-wrap: wrap;
}
.stage-nav,
.stage-entries {
display: flex;
vertical-align: top;
font-size: $gl-font-size;
}
.stage-nav {
width: 40%;
margin-bottom: 0;
ul {
padding: 0;
margin: 0;
width: 100%;
}
li {
list-style-type: none;
@include clearfix;
}
.stage-nav-item {
display: block;
line-height: 65px;
border-top: solid 1px transparent;
border-bottom: solid 1px transparent;
border-right: solid 1px $border-color;
background-color: $gray-light;
&.active {
background-color: transparent;
border-right-color: transparent;
border-top-color: $border-color;
border-bottom-color: $border-color;
box-shadow: inset 2px 0px 0px 0px $active-item-blue;
.stage-name {
font-weight: 600;
}
}
&:first-child {
border-top: none;
}
&:last-child {
border-bottom: none;
}
> div {
float: left;
&.stage-name {
width: 40%;
}
&.stage-median {
width: 30%;
}
&.stage-delta {
width: 30%;
.stage-direction {
float: right;
padding-right: $gl-padding;
}
}
}
.stage-name {
padding-left: 16px;
}
}
}
.stage-panel {
.panel-heading {
padding: 0;
background-color: transparent;
}
.events-description {
line-height: 65px;
padding-left: $gl-padding;
}
}
.stage-events {
width: 60%;
overflow: scroll;
height: 467px;
}
.stage-event-list {
margin: 0;
padding: 0;
}
.stage-event-item {
list-style-type: none;
padding: 0 0 $gl-padding;
margin: 0 $gl-padding $gl-padding $gl-padding;
border-bottom: solid 1px $gray-darker;
@include clearfix;
&:last-child {
border-bottom: none;
margin-bottom: 0;
}
.item-details, .item-time {
float: left;
}
.item-details {
width: 75%;
}
.item-title {
margin: 0 0 2px 0;
a {
color: $gl-dark-link-color;
max-width: 100%;
display: block;
@include text-overflow();
}
}
.item-time {
width: 25%;
text-align: right;
font-size: $cycle-analytics-big-font;
color: $cycle-analytics-dark-text;
abbr {
font-size: $gl-font-size;
color: $gl-text-color;
}
}
}
}

View File

@ -2,14 +2,17 @@
- page_title "Cycle Analytics"
- content_for :page_specific_javascripts do
= page_specific_javascript_tag('cycle_analytics/cycle_analytics_bundle.js')
= page_specific_javascript_tag("cycle_analytics/cycle_analytics_bundle.js")
= render "projects/pipelines/head"
#cycle-analytics{class: container_class, "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) }}
#cycle-analytics{ class: container_class, "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) } }
.empty-dialog-message{ "v-if" => "!isEmptyDialogDismissed" }
%p There is nothing happened
= icon("times", class: "dismiss-icon", "@click" => "dismissEmptyDialog()")
.bordered-box.landing.content-block{"v-if" => "!isHelpDismissed"}
= icon('times', class: 'dismiss-icon', "@click" => "dismissLanding()")
.bordered-box.landing.content-block{"v-if" => "!isOverviewDialogDismissed"}
= icon("times", class: "dismiss-icon", "@click" => "dismissOverviewDialog()")
.row
.col-sm-3.col-xs-12.svg-container
= custom_icon('icon_cycle_analytics_splash')
@ -20,21 +23,17 @@
Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.
= link_to "Read more", help_page_path('user/project/cycle_analytics'), target: '_blank', class: 'btn'
= icon("spinner spin", "v-show" => "isLoading")
.wrapper{"v-show" => "!isLoading && !hasError"}
.panel.panel-default
.panel-heading
Pipeline Health
.content-block
.container-fluid
.row
.col-sm-3.col-xs-12.column{"v-for" => "item in analytics.summary"}
.col-sm-3.col-xs-12.column{"v-for" => "item in state.analytics.summary"}
%h3.header {{item.value}}
%p.text {{item.title}}
.col-sm-3.col-xs-12.column
.dropdown.inline.js-ca-dropdown
%button.dropdown-menu-toggle{"data-toggle" => "dropdown", :type => "button"}
@ -42,22 +41,167 @@
%i.fa.fa-chevron-down
%ul.dropdown-menu.dropdown-menu-align-right
%li
%a{'href' => "#", 'data-value' => '30'}
%a{ "href" => "#", "data-value" => "30" }
Last 30 days
%li
%a{'href' => "#", 'data-value' => '90'}
%a{ "href" => "#", "data-value" => "90" }
Last 90 days
.panel.panel-default.stage-panel
.panel-heading
%nav.col-headers
%ul
%li.stage-header
%span.stage-name
Stage
%i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The phase of the development lifecycle.", "aria-hidden" => "true" }
%li.median-header
%span.stage-name
Median
%i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.", "aria-hidden" => "true" }
%li.delta-header
%span.stage-name
= render "shared/icons/delta.svg"
%i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The difference between the previous and last measure, expressed as positive or negative values. E.g., if the previous value was 5 and the new value is 7, the delta is +2.", "aria-hidden" => "true" }
%li.event-header
%span.stage-name
{{ currentStage ? currentStage.legendTitle : 'Related Issues' }}
%i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The collection of events added to the data gathered for that stage.", "aria-hidden" => "true" }
%li.total-time-header
%span.stage-name
Total Time
%i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: "The time taken by each data entry gathered by that stage.", "aria-hidden" => "true" }
.stage-panel-body
%nav.stage-nav
%ul
%stage-button{ "inline-template" => true,
"v-for" => "stage in state.stages",
":stage" => "stage",
":on-stage-click" => "selectStage" }
%li.stage-nav-item{ ":class" => "classObject", "@click" => "onClick(stage)" }
.stage-name
{{stage.name}}
.stage-median
20 hrs 21 mins
.stage-delta
+ 20 days
%span.stage-direction
= render "shared/icons/down_arrow.svg"
.section.stage-events
%template{ "v-if" => "isLoadingStage" }
= icon("spinner spin", "v-show" => "isLoadingStage")
%template{ "v-if" => "isEmptyStage" }
%p No results
%template{ "v-if" => "state.items.length && !isLoadingStage && !isEmptyStage" }
%component{ ":is" => "currentStage.component", ":items" => "state.items" }
.bordered-box
%ul.content-list
%li{"v-for" => "item in analytics.stats"}
.container-fluid
.row
.col-xs-8.title-col
%p.title
{{item.title}}
%p.text
{{item.description}}
.col-xs-4.value-col
%span
{{item.value}}
%script{ type: 'text/x-template', id: 'stage-issue-component' }
%div
.events-description
Time before an issue get scheluded
%ul.stage-event-list
%li.stage-event-item{ "v-for" => "issue in items" }
%item-issue-component{ ":issue" => "issue" }
%script{ type: 'text/x-template', id: 'stage-plan-component' }
%div
.events-description
Time before an issue starts implementation
%ul.event-list
%li.event-item{ "v-for" => "commit in items" }
%item-commit-component{ ":commit" => "commit" }
%script{ type: 'text/x-template', id: 'stage-code-component' }
%div
.events-description
Time spent coding
%ul
%li{ "v-for" => "mergeRequest in items" }
%item-merge-request-component{ ":merge-request" => "mergeRequest" }
%script{ type: 'text/x-template', id: 'stage-test-component' }
%div
.events-description
The time taken to build and test the application
%ul
%li{ "v-for" => "build in items" }
%item-build-component{ ":build" => "build" }
%script{ type: 'text/x-template', id: 'stage-review-component' }
%div
.events-description
The time taken to review the code
%ul
%li{ "v-for" => "mergeRequest in items" }
%item-merge-request-component{ ":merge-request" => "mergeRequest" }
%script{ type: 'text/x-template', id: 'stage-staging-component' }
%div
.events-description
The time taken in staging
%ul
%li{ "v-for" => "build in items" }
%item-build-component{ ":build" => "build" }
%script{ type: 'text/x-template', id: 'stage-production-component' }
%div
.events-description
The total time taken from idea to production
%ul
%li{ "v-for" => "issue in items" }
%item-issue-component{ ":issue" => "issue" }
%script{ type: 'text/x-template', id: 'item-issue-component' }
.item-details
%img.avatar{:src => "https://secure.gravatar.com/avatar/3731e7dd4f2b4fa8ae184c0a7519dd58?s=64&d=identicon"}/
%h5.item-title
%a{ :href => "issue.url" }
{{ issue.title }}
%a{ :href => "issue.url" }
= '#{{issue.id}}'
%span
Opened
%a{:href => "issue.url"}
{{ issue.datetime }}
%span
by
%a{:href => "issue.profile"}
{{ issue.author }}
.item-time
%span.hours{ "v-if" => "issue.totalTime.hours"}
{{ issue.totalTime.hours }}
%abbr{:title => "Hours"} hr
%span.minutes{ "v-if" => "issue.totalTime.minutes" }
{{ issue.totalTime.minutes }}
%abbr{:title => "Minutes"} mins
%script{ type: 'text/x-template', id: 'item-commit-component' }
%div
%p
%h5
%a{:href => "commit.url"}
{{ commit.title }}
%span
First
%a{:href => "#"}
{{ commit.hash }}
pushed by
%a{:href => "commit.profile"}
{{ commit.author }}
%script{ type: 'text/x-template', id: 'item-merge-request-component' }
%div
%p
%h5
merge request -
%a{:href => "mergeRequest.url"}
{{ mergeRequest.title }}
%script{ type: 'text/x-template', id: 'item-build-component' }
%div
%p
%h5
build -
%a{:href => "build.url"}
{{ build.title }}

View File

@ -0,0 +1,3 @@
<svg width="14px" height="10px" viewBox="322 21 14 10" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M330.078605,22.8166945 L335.259532,29.6235062 C335.615145,30.0907182 335.412062,30.4694683 334.822641,30.4694683 L331.657805,30.4694683 L324.04678,30.4694683 C323.449879,30.4694683 323.260751,30.0822112 323.609889,29.6235062 L328.790816,22.8166945 C329.146429,22.3494825 329.729467,22.3579895 330.078605,22.8166945 Z" id="delta" stroke="#5C5C5C" stroke-width="1" fill="none"></path>
</svg>

After

Width:  |  Height:  |  Size: 549 B

View File

@ -0,0 +1,3 @@
<svg width="9px" height="12px" viewBox="4 3 9 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M10,8.01971215 L10,13.022682 C10,13.5733266 9.55613518,14.0197122 9,14.0197122 C8.44771525,14.0197122 8,13.5666758 8,13.022682 L8,8.01971215 L5.99703014,8.01971215 C5.4463856,8.01971215 5.2749362,7.6760419 5.625,7.23846215 L8.375,3.80096215 C8.72017797,3.36948969 9.2749362,3.3633824 9.625,3.80096215 L12.375,7.23846215 C12.720178,7.66993461 12.5469637,8.01971215 12.0029699,8.01971215 L10,8.01971215 Z" id="Combined-Shape" stroke="none" fill="#31AF64" fill-rule="evenodd" transform="translate(8.998117, 8.747388) scale(1, -1) translate(-8.998117, -8.747388) "></path>
</svg>

After

Width:  |  Height:  |  Size: 729 B