Commit a0ad4dfe authored by Brandon Labuschagne's avatar Brandon Labuschagne Committed by Sean McGivern

Redesign the DevOps Score report

Changelog: changed
parent 44ba6521
<script>
import { GlBadge, GlTable } from '@gitlab/ui';
import { GlSingleStat } from '@gitlab/ui/dist/charts';
import { sprintf, s__ } from '~/locale';
const defaultHeaderAttrs = {
thClass: 'gl-bg-white!',
thAttr: { 'data-testid': 'header' },
};
export default {
components: {
GlBadge,
GlTable,
GlSingleStat,
},
inject: {
devopsScoreMetrics: {
default: null,
},
},
computed: {
titleHelperText() {
return sprintf(
s__(
'DevopsReport|DevOps score metrics are based on usage over the last 30 days. Last updated: %{timestamp}.',
),
{ timestamp: this.devopsScoreMetrics.createdAt },
);
},
},
tableHeaderFields: [
{
key: 'title',
label: '',
...defaultHeaderAttrs,
},
{
key: 'usage',
label: s__('DevopsReport|Your usage'),
...defaultHeaderAttrs,
},
{
key: 'leadInstance',
label: s__('DevopsReport|Leader usage'),
...defaultHeaderAttrs,
},
{
key: 'score',
label: s__('DevopsReport|Score'),
...defaultHeaderAttrs,
},
],
};
</script>
<template>
<div data-testid="devops-score-app">
<div class="gl-text-gray-400 gl-my-4" data-testid="devops-score-note-text">
{{ titleHelperText }}
</div>
<gl-single-stat
unit="%"
size="sm"
:title="s__('DevopsReport|Your score')"
:should-animate="true"
:value="devopsScoreMetrics.averageScore.value"
:meta-icon="devopsScoreMetrics.averageScore.scoreLevel.icon"
:meta-text="devopsScoreMetrics.averageScore.scoreLevel.label"
:variant="devopsScoreMetrics.averageScore.scoreLevel.variant"
/>
<gl-table
:fields="$options.tableHeaderFields"
:items="devopsScoreMetrics.cards"
thead-class="gl-border-t-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-100"
stacked="sm"
>
<template #cell(usage)="{ item }">
<div data-testid="usageCol">
<span>{{ item.usage }}</span>
<gl-badge :variant="item.scoreLevel.variant" size="sm" class="gl-ml-1">{{
item.scoreLevel.label
}}</gl-badge>
</div>
</template>
</gl-table>
</div>
</template>
import Vue from 'vue';
import DevopsScore from './components/devops_score.vue';
export default () => {
const el = document.getElementById('js-devops-score');
if (!el) return false;
const { devopsScoreMetrics } = el.dataset;
return new Vue({
el,
provide: {
devopsScoreMetrics: JSON.parse(devopsScoreMetrics),
},
render(h) {
return h(DevopsScore);
},
});
};
import initDevOpsScore from '~/analytics/devops_report/devops_score';
import initDevOpsScoreEmptyState from '~/analytics/devops_report/devops_score_empty_state'; import initDevOpsScoreEmptyState from '~/analytics/devops_report/devops_score_empty_state';
initDevOpsScoreEmptyState(); initDevOpsScoreEmptyState();
initDevOpsScore();
@import 'mixins_and_variables_and_functions'; @import 'mixins_and_variables_and_functions';
$space-between-cards: 8px;
.devops-empty svg { .devops-empty svg {
margin: 64px auto 32px; margin: 64px auto 32px;
max-width: 420px; max-width: 420px;
} }
.devops-header {
margin-top: $gl-padding;
margin-bottom: $gl-padding;
padding: 0 4px;
display: flex;
align-items: center;
.devops-header-title {
font-size: 48px;
line-height: 1;
margin: 0;
}
.devops-header-subtitle {
font-size: 22px;
line-height: 1;
color: var(--gl-text-color-secondary, $gl-text-color-secondary);
margin-left: 8px;
font-weight: $gl-font-weight-normal;
.devops-header-icon {
vertical-align: px-to-rem(-$gl-spacing-scale-1);
}
a {
font-size: 18px;
color: var(--gl-text-color-secondary, $gl-text-color-secondary);
&:hover {
color: var(--blue-500, $blue-500);
}
}
}
}
.devops-cards {
display: flex;
justify-content: center;
flex-wrap: wrap;
}
.devops-card-wrapper {
display: flex;
flex-direction: column;
align-items: stretch;
text-align: center;
width: 50%;
border-color: var(--border-color, $border-color);
margin: 0 0 32px;
padding: $space-between-cards / 2;
position: relative;
@include media-breakpoint-up(xs) {
width: percentage(1 / 4);
}
@include media-breakpoint-up(sm) {
width: percentage(1 / 5);
}
@include media-breakpoint-up(md) {
width: percentage(1 / 6);
}
@include media-breakpoint-up(lg) {
width: percentage(1 / 10);
}
}
.devops-card {
border: solid 1px var(--border-color, $border-color);
border-radius: 3px;
border-top-width: 3px;
display: flex;
flex-direction: column;
flex-grow: 1;
}
.devops-card-low {
border-top-color: var(--red-400, $red-400);
.board-card-score-big {
background-color: var(--red-50, $red-50);
}
}
.devops-card-average {
border-top-color: var(--orange-200, $orange-200);
.board-card-score-big {
background-color: var(--orange-50, $orange-50);
}
}
.devops-card-high {
border-top-color: var(--green-400, $green-400);
.board-card-score-big {
background-color: var(--green-50, $green-50);
}
}
.devops-card-title {
margin: $gl-padding auto auto;
max-width: 100px;
h3 {
font-size: 14px;
margin: 0 0 2px;
}
.light-text {
font-size: 13px;
line-height: 1.25;
color: var(--gl-text-color-secondary, $gl-text-color-secondary);
}
}
.board-card-scores {
display: flex;
justify-content: space-around;
align-items: center;
margin: $gl-padding $gl-btn-padding;
line-height: 1;
}
.board-card-score {
color: var(--gl-text-color-secondary, $gl-text-color-secondary);
.board-card-score-name {
font-size: 13px;
margin-top: 4px;
}
}
.board-card-score-value {
font-size: 16px;
color: var(--gl-text-color, $gl-text-color);
font-weight: $gl-font-weight-normal;
}
.board-card-score-big {
border-top: 2px solid var(--border-color, $border-color);
border-bottom: 1px solid var(--border-color, $border-color);
font-size: 22px;
padding: 10px 0;
font-weight: $gl-font-weight-normal;
}
.board-card-buttons {
display: flex;
> * {
font-size: 16px;
color: var(--gl-text-color-secondary, $gl-text-color-secondary);
padding: 10px;
flex-grow: 1;
&:hover {
background-color: var(--border-color, $border-color);
color: var(--border-color, $border-color);
}
+ * {
border-left: solid 1px var(--border-color, $border-color);
}
}
}
.devops-steps {
margin-top: $gl-padding;
height: 1px;
min-width: 100%;
justify-content: space-around;
position: relative;
background: var(--border-color, $border-color);
}
.devops-step {
$step-positions: 5% 10% 30% 42% 48% 55% 60% 70% 75% 90%;
@each $pos in $step-positions {
$i: index($step-positions, $pos);
&:nth-child(#{$i}) {
left: $pos;
}
}
position: absolute;
transform-origin: 75% 50%;
padding: 8px;
height: 50px;
width: 50px;
border-radius: 3px;
display: flex;
flex-direction: column;
align-items: center;
border: solid 1px var(--border-color, $border-color);
background: var(--white, $white);
transform: translate(-50%, -50%);
color: var(--gl-text-color-secondary, $gl-text-color-secondary);
fill: var(--gl-text-color-secondary, $gl-text-color-secondary);
box-shadow: 0 2px 4px var(--dropdown-shadow-color, $dropdown-shadow-color);
&:hover {
padding: 8px 10px;
fill: currentColor;
z-index: 100;
height: auto;
width: auto;
.devops-step-title {
max-height: 2em;
opacity: 1;
transition: opacity 0.2s;
}
svg {
transform: scale(1.5);
margin: $gl-btn-padding;
}
}
svg {
transition: transform 0.1s;
width: 30px;
height: 30px;
min-height: 30px;
min-width: 30px;
}
}
.devops-step-title {
max-height: 0;
opacity: 0;
text-transform: uppercase;
margin: $gl-vert-padding 0 0;
text-align: center;
font-size: 12px;
}
.devops-high-score {
color: var(--green-400, $green-400);
}
.devops-average-score {
color: var(--orange-500, $orange-500);
}
.devops-low-score {
color: var(--red-400, $red-400);
}
# frozen_string_literal: true # frozen_string_literal: true
module DevOpsReportHelper module DevOpsReportHelper
def devops_score_metrics(metric)
{
averageScore: average_score_data(metric),
cards: devops_score_card_data(metric),
createdAt: metric.created_at.strftime('%Y-%m-%d %H:%M')
}
end
private
def format_score(score)
precision = score < 1 ? 2 : 1
number_with_precision(score, precision: precision)
end
def score_level(score) def score_level(score)
if score < 33.33 if score < 33.33
'low' {
label: s_('DevopsReport|Low'),
variant: 'muted'
}
elsif score < 66.66 elsif score < 66.66
'average' {
label: s_('DevopsReport|Moderate'),
variant: 'neutral'
}
else else
'high' {
label: s_('DevopsReport|High'),
variant: 'success'
}
end end
end end
def format_score(score) def average_score_level(score)
precision = score < 1 ? 2 : 1 if score < 33.33
number_with_precision(score, precision: precision) {
label: s_('DevopsReport|Low'),
variant: 'danger',
icon: 'status-failed'
}
elsif score < 66.66
{
label: s_('DevopsReport|Moderate'),
variant: 'warning',
icon: 'status-alert'
}
else
{
label: s_('DevopsReport|High'),
variant: 'success',
icon: 'status_success_solid'
}
end
end
def average_score_data(metric)
{
value: format_score(metric.average_percentage_score),
scoreLevel: average_score_level(metric.average_percentage_score)
}
end
def devops_score_card_data(metric)
metric.cards.map do |card|
{
title: "#{card.title} #{card.description}",
usage: format_score(card.instance_score),
leadInstance: format_score(card.leader_score),
score: format_score(card.percentage_score),
scoreLevel: score_level(card.percentage_score)
}
end
end end
end end
.devops-card-wrapper
.devops-card{ class: "devops-card-#{score_level(card.percentage_score)}" }
.devops-card-title
%h3
= card.title
.light-text
= card.description
.board-card-scores
.board-card-score
.board-card-score-value
= format_score(card.instance_score)
.board-card-score-name= _('You')
.board-card-score
.board-card-score-value
= format_score(card.leader_score)
.board-card-score-name= _('Lead')
.board-card-score-big
= number_to_percentage(card.percentage_score, precision: 1)
.board-card-buttons
- if card.blog
%a.btn-svg{ href: card.blog }
= sprite_icon('information-o')
- if card.docs
%a.btn-svg{ href: card.docs }
= sprite_icon('question-o')
...@@ -8,25 +8,5 @@ ...@@ -8,25 +8,5 @@
- elsif @metric.blank? - elsif @metric.blank?
= render 'no_data' = render 'no_data'
- else - else
.devops #js-devops-score{ data: { devops_score_metrics: devops_score_metrics(@metric).to_json } }
.gl-my-3.gl-text-gray-400{ data: { testid: 'devops-score-note-text' } }
= s_('DevopsReport|DevOps score metrics are based on usage over the last 30 days. Last updated: %{timestamp}.').html_safe % { timestamp: @metric.created_at.strftime('%Y-%m-%d %H:%M') }
.devops-header
%h2.devops-header-title{ class: "devops-#{score_level(@metric.average_percentage_score)}-score" }
= number_to_percentage(@metric.average_percentage_score, precision: 1)
.devops-header-subtitle
= s_('DevopsReport|DevOps')
%br
= s_('DevopsReport|Score')
= link_to sprite_icon('question-o', css_class: 'devops-header-icon'), help_page_path('user/admin_area/analytics/dev_ops_report')
.devops-cards.board-card-container
- @metric.cards.each do |card|
= render 'card', card: card
.devops-steps.d-none.d-lg-block
- @metric.idea_to_production_steps.each_with_index do |step, index|
.devops-step{ class: "devops-#{score_level(step.percentage_score)}-score" }
= custom_icon("i2p_step_#{index + 1}")
%h4.devops-step-title
= step.title
---
title: Redesign the DevOps Score report
merge_request: 59856
author:
type: changed
...@@ -127,15 +127,13 @@ RSpec.describe 'DevOps Report page', :js do ...@@ -127,15 +127,13 @@ RSpec.describe 'DevOps Report page', :js do
end end
context 'when there is data to display' do context 'when there is data to display' do
it 'shows numbers for each metric' do it 'shows the DevOps Score app' do
stub_application_setting(usage_ping_enabled: true) stub_application_setting(usage_ping_enabled: true)
create(:dev_ops_report_metric) create(:dev_ops_report_metric)
visit admin_dev_ops_report_path visit admin_dev_ops_report_path
expect(page).to have_content( expect(page).to have_selector('[data-testid="devops-score-app"]')
'Issues created per active user 1.2 You 9.3 Lead 13.3%'
)
end end
end end
end end
......
...@@ -27,16 +27,6 @@ RSpec.describe 'admin/dev_ops_report/show.html.haml' do ...@@ -27,16 +27,6 @@ RSpec.describe 'admin/dev_ops_report/show.html.haml' do
expect(rendered).not_to have_selector('#devops-adoption') expect(rendered).not_to have_selector('#devops-adoption')
end end
it 'shows the timestamp of the latest record' do
assign(:metric, create(:dev_ops_report_metric, created_at: Time.utc(2012, 5, 1, 14, 30)).present)
render
page = Capybara.string(rendered)
note_node = page.find("div[data-testid='devops-score-note-text']")
expect(note_node.text).to include('Last updated: 2012-05-01 14:30.')
end
end end
context 'when show_adoption? returns true' do context 'when show_adoption? returns true' do
......
...@@ -11334,18 +11334,33 @@ msgstr "" ...@@ -11334,18 +11334,33 @@ msgstr ""
msgid "DevopsReport|Adoption" msgid "DevopsReport|Adoption"
msgstr "" msgstr ""
msgid "DevopsReport|DevOps"
msgstr ""
msgid "DevopsReport|DevOps Score" msgid "DevopsReport|DevOps Score"
msgstr "" msgstr ""
msgid "DevopsReport|DevOps score metrics are based on usage over the last 30 days. Last updated: %{timestamp}." msgid "DevopsReport|DevOps score metrics are based on usage over the last 30 days. Last updated: %{timestamp}."
msgstr "" msgstr ""
msgid "DevopsReport|High"
msgstr ""
msgid "DevopsReport|Leader usage"
msgstr ""
msgid "DevopsReport|Low"
msgstr ""
msgid "DevopsReport|Moderate"
msgstr ""
msgid "DevopsReport|Score" msgid "DevopsReport|Score"
msgstr "" msgstr ""
msgid "DevopsReport|Your score"
msgstr ""
msgid "DevopsReport|Your usage"
msgstr ""
msgid "Didn't receive a confirmation email?" msgid "Didn't receive a confirmation email?"
msgstr "" msgstr ""
...@@ -18947,9 +18962,6 @@ msgstr "" ...@@ -18947,9 +18962,6 @@ msgstr ""
msgid "Launch a ready-to-code development environment for your project." msgid "Launch a ready-to-code development environment for your project."
msgstr "" msgstr ""
msgid "Lead"
msgstr ""
msgid "Lead Time" msgid "Lead Time"
msgstr "" msgstr ""
......
...@@ -53,15 +53,13 @@ RSpec.describe 'DevOps Report page', :js do ...@@ -53,15 +53,13 @@ RSpec.describe 'DevOps Report page', :js do
end end
context 'when there is data to display' do context 'when there is data to display' do
it 'shows numbers for each metric' do it 'shows the DevOps Score app' do
stub_application_setting(usage_ping_enabled: true) stub_application_setting(usage_ping_enabled: true)
create(:dev_ops_report_metric) create(:dev_ops_report_metric)
visit admin_dev_ops_report_path visit admin_dev_ops_report_path
expect(page).to have_content( expect(page).to have_selector('[data-testid="devops-score-app"]')
'Issues created per active user 1.2 You 9.3 Lead 13.3%'
)
end end
end end
end end
......
import { GlTable, GlBadge } from '@gitlab/ui';
import { GlSingleStat } from '@gitlab/ui/dist/charts';
import { mount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import DevopsScore from '~/analytics/devops_report/components/devops_score.vue';
import { createdAt, cards, averageScore, devopsScoreTableHeaders } from '../mock_data';
describe('DevopsScore', () => {
let wrapper;
const createComponent = () => {
wrapper = extendedWrapper(
mount(DevopsScore, {
provide: {
devopsScoreMetrics: {
createdAt,
cards,
averageScore,
},
},
}),
);
};
const findTable = () => wrapper.find(GlTable);
const findCol = (testId) => findTable().find(`[data-testid="${testId}"]`);
const findUsageCol = () => findCol('usageCol');
beforeEach(() => {
createComponent();
});
it('displays the title note', () => {
expect(wrapper.findByTestId('devops-score-note-text').text()).toBe(
'DevOps score metrics are based on usage over the last 30 days. Last updated: 2020-06-29 08:16.',
);
});
it('displays the single stat section', () => {
const component = wrapper.find(GlSingleStat);
expect(component.exists()).toBe(true);
expect(component.props('value')).toBe(averageScore.value);
});
describe('devops score table', () => {
it('displays the table', () => {
expect(findTable().exists()).toBe(true);
});
describe('table headings', () => {
let headers;
beforeEach(() => {
headers = findTable().findAll("[data-testid='header']");
});
it('displays the correct number of headings', () => {
expect(headers).toHaveLength(devopsScoreTableHeaders.length);
});
describe.each(devopsScoreTableHeaders)('header fields', ({ label, index }) => {
let headerWrapper;
beforeEach(() => {
headerWrapper = headers.at(index);
});
it(`displays the correct table heading text for "${label}"`, () => {
expect(headerWrapper.text()).toContain(label);
});
});
});
describe('table columns', () => {
describe('Your usage', () => {
it('displays the corrrect value', () => {
expect(findUsageCol().text()).toContain('3.2');
});
it('displays the corrrect badge', () => {
const badge = findUsageCol().find(GlBadge);
expect(badge.exists()).toBe(true);
expect(badge.props('variant')).toBe('muted');
expect(badge.text()).toBe('Low');
});
});
});
});
});
export const averageScore = {
value: '10',
scoreLevel: {
label: 'High',
icon: 'check-circle',
variant: 'success',
},
};
export const cards = [
{
title: 'Issues created per active user',
usage: '3.2',
leadInstance: '10.2',
score: '0',
scoreLevel: {
label: 'Low',
variant: 'muted',
},
},
];
export const createdAt = '2020-06-29 08:16';
export const devopsScoreTableHeaders = [
{
index: 0,
label: '',
},
{
index: 1,
label: 'Your usage',
},
{
index: 2,
label: 'Leader usage',
},
{
index: 3,
label: 'Score',
},
];
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe DevOpsReportHelper do
subject { DevOpsReport::MetricPresenter.new(metric) }
let(:metric) { build(:dev_ops_report_metric, created_at: DateTime.new(2021, 4, 3, 2, 1, 0) ) }
describe '#devops_score_metrics' do
let(:devops_score_metrics) { helper.devops_score_metrics(subject) }
it { expect(devops_score_metrics[:averageScore]).to eq({ scoreLevel: { icon: "status-alert", label: "Moderate", variant: "warning" }, value: "55.9" } ) }
it { expect(devops_score_metrics[:cards].first).to eq({ leadInstance: "9.3", score: "13.3", scoreLevel: { label: "Low", variant: "muted" }, title: "Issues created per active user", usage: "1.2" } ) }
it { expect(devops_score_metrics[:cards].second).to eq({ leadInstance: "30.3", score: "92.7", scoreLevel: { label: "High", variant: "success" }, title: "Comments created per active user", usage: "28.1" } ) }
it { expect(devops_score_metrics[:cards].fourth).to eq({ leadInstance: "5.2", score: "62.4", scoreLevel: { label: "Moderate", variant: "neutral" }, title: "Boards created per active user", usage: "3.3" } ) }
it { expect(devops_score_metrics[:createdAt]).to eq("2021-04-03 02:01") }
describe 'with low average score' do
let(:low_metric) { double(average_percentage_score: 2, cards: subject.cards, created_at: subject.created_at) }
let(:devops_score_metrics) { helper.devops_score_metrics(low_metric) }
it { expect(devops_score_metrics[:averageScore]).to eq({ scoreLevel: { icon: "status-failed", label: "Low", variant: "danger" }, value: "2.0" } ) }
end
describe 'with high average score' do
let(:high_metric) { double(average_percentage_score: 82, cards: subject.cards, created_at: subject.created_at) }
let(:devops_score_metrics) { helper.devops_score_metrics(high_metric) }
it { expect(devops_score_metrics[:averageScore]).to eq({ scoreLevel: { icon: "status_success_solid", label: "High", variant: "success" }, value: "82.0" } ) }
end
end
end
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