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';
initDevOpsScoreEmptyState();
initDevOpsScore();
@import 'mixins_and_variables_and_functions';
$space-between-cards: 8px;
.devops-empty svg {
margin: 64px auto 32px;
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
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)
if score < 33.33
'low'
{
label: s_('DevopsReport|Low'),
variant: 'muted'
}
elsif score < 66.66
'average'
{
label: s_('DevopsReport|Moderate'),
variant: 'neutral'
}
else
'high'
{
label: s_('DevopsReport|High'),
variant: 'success'
}
end
end
def format_score(score)
precision = score < 1 ? 2 : 1
number_with_precision(score, precision: precision)
def average_score_level(score)
if score < 33.33
{
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
.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 @@
- elsif @metric.blank?
= render 'no_data'
- else
.devops
.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')
#js-devops-score{ data: { devops_score_metrics: devops_score_metrics(@metric).to_json } }
.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
end
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)
create(:dev_ops_report_metric)
visit admin_dev_ops_report_path
expect(page).to have_content(
'Issues created per active user 1.2 You 9.3 Lead 13.3%'
)
expect(page).to have_selector('[data-testid="devops-score-app"]')
end
end
end
......
......@@ -27,16 +27,6 @@ RSpec.describe 'admin/dev_ops_report/show.html.haml' do
expect(rendered).not_to have_selector('#devops-adoption')
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
context 'when show_adoption? returns true' do
......
......@@ -11334,18 +11334,33 @@ msgstr ""
msgid "DevopsReport|Adoption"
msgstr ""
msgid "DevopsReport|DevOps"
msgstr ""
msgid "DevopsReport|DevOps Score"
msgstr ""
msgid "DevopsReport|DevOps score metrics are based on usage over the last 30 days. Last updated: %{timestamp}."
msgstr ""
msgid "DevopsReport|High"
msgstr ""
msgid "DevopsReport|Leader usage"
msgstr ""
msgid "DevopsReport|Low"
msgstr ""
msgid "DevopsReport|Moderate"
msgstr ""
msgid "DevopsReport|Score"
msgstr ""
msgid "DevopsReport|Your score"
msgstr ""
msgid "DevopsReport|Your usage"
msgstr ""
msgid "Didn't receive a confirmation email?"
msgstr ""
......@@ -18947,9 +18962,6 @@ msgstr ""
msgid "Launch a ready-to-code development environment for your project."
msgstr ""
msgid "Lead"
msgstr ""
msgid "Lead Time"
msgstr ""
......
......@@ -53,15 +53,13 @@ RSpec.describe 'DevOps Report page', :js do
end
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)
create(:dev_ops_report_metric)
visit admin_dev_ops_report_path
expect(page).to have_content(
'Issues created per active user 1.2 You 9.3 Lead 13.3%'
)
expect(page).to have_selector('[data-testid="devops-score-app"]')
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