Commit ccb1ff62 authored by Daniel Tian's avatar Daniel Tian Committed by Jose Ivan Vargas

Show loading spinner for security dashboard charts

parent c28c03e0
<script>
import { GlTooltipDirective, GlTable } from '@gitlab/ui';
import { GlTooltipDirective, GlTable, GlLoadingIcon } from '@gitlab/ui';
import { GlSparklineChart } from '@gitlab/ui/dist/charts';
import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_badge.vue';
import { firstAndLastY } from '~/lib/utils/chart_utils';
......@@ -14,12 +14,17 @@ import { SEVERITY_LEVELS, DAYS } from '../store/constants';
import ChartButtons from './vulnerability_chart_buttons.vue';
const ISO_DATE = 'isoDate';
const TH_CLASS = 'gl-bg-white!';
const TD_CLASS = 'gl-border-none!';
const TH_CLASS_TEXT_RIGHT = `${TH_CLASS} gl-text-right`;
const TD_CLASS_TEXT_RIGHT = `${TD_CLASS} gl-text-right`;
export default {
components: {
ChartButtons,
GlSparklineChart,
GlTable,
GlLoadingIcon,
SeverityBadge,
},
directives: {
......@@ -38,14 +43,29 @@ export default {
},
days: Object.values(DAYS),
fields: [
{ key: 'severityLevel', label: s__('VulnerabilityChart|Severity'), tdClass: 'border-0' },
{ key: 'chartData', label: '', tdClass: 'border-0 w-100' },
{ key: 'changeInPercent', label: '%', thClass: 'text-right', tdClass: 'border-0 text-right' },
{
key: 'severityLevel',
label: s__('VulnerabilityChart|Severity'),
thClass: TH_CLASS,
tdClass: TD_CLASS,
},
{
key: 'chartData',
label: '',
thClass: TH_CLASS,
tdClass: `${TD_CLASS} gl-w-full`,
},
{
key: 'changeInPercent',
label: '%',
thClass: TH_CLASS_TEXT_RIGHT,
tdClass: TD_CLASS_TEXT_RIGHT,
},
{
key: 'currentVulnerabilitiesCount',
label: '#',
thClass: 'text-right',
tdClass: 'border-0 text-right',
thClass: TH_CLASS_TEXT_RIGHT,
tdClass: TD_CLASS_TEXT_RIGHT,
},
],
severityLevels: [
......@@ -114,6 +134,9 @@ export default {
[formattedEndDate, 0],
];
},
isLoadingHistory() {
return this.$apollo.queries.vulnerabilitiesHistory.loading;
},
},
watch: {
startDate() {
......@@ -162,29 +185,35 @@ export default {
</script>
<template>
<section class="border rounded p-0">
<div class="p-3">
<section class="gl-border-solid gl-rounded-base gl-border-1 gl-border-gray-100">
<div class="gl-p-5">
<header id="vulnerability-chart-header">
<h4 class="my-0">
<h4 class="gl-my-0">
{{ __('Vulnerabilities over time') }}
</h4>
<p ref="timeInfo" class="text-secondary mt-0 js-vulnerabilities-chart-time-info">
<p
v-if="!isLoadingHistory"
data-testid="timeInfo"
class="gl-text-gray-500 js-vulnerabilities-chart-time-info"
>
{{ dateInfo }}
</p>
</header>
<chart-buttons
v-if="!isLoadingHistory"
:days="$options.days"
:active-day="vulnerabilitiesHistoryDayRange"
@click="setVulnerabilitiesHistoryDayRange"
/>
</div>
<gl-loading-icon v-if="isLoadingHistory" size="lg" class="gl-my-12" />
<gl-table
v-else
:fields="$options.fields"
:items="charts"
:borderless="true"
thead-class="thead-white"
class="js-vulnerabilities-chart-severity-level-breakdown mb-2"
borderless
class="js-vulnerabilities-chart-severity-level-breakdown gl-mb-3"
>
<template #head(changeInPercent)="{ label }">
<span v-gl-tooltip :title="__('Difference between start date and now')">{{ label }}</span>
......@@ -198,14 +227,14 @@ export default {
<severity-badge :ref="`severityBadge${value}`" :severity="value" />
</template>
<template #cell(chartData)="{ item }">
<div class="position-relative h-32-px">
<div class="gl-relative gl-p-5">
<gl-sparkline-chart
:ref="`sparklineChart${item.severityLevel}`"
:height="32"
:data="item.chartData"
:tooltip-label="__('Vulnerabilities')"
:show-last-y-value="false"
class="position-absolute w-100 position-top-0 position-left-0"
class="gl-absolute gl-w-full gl-top-0 gl-left-0"
/>
</div>
</template>
......
<script>
import { GlLink, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { GlLink, GlTooltipDirective, GlIcon, GlLoadingIcon } from '@gitlab/ui';
import {
severityGroupTypes,
severityLevels,
......@@ -28,7 +28,7 @@ export default {
},
},
accordionItemsContentMaxHeight: '445px',
components: { Accordion, AccordionItem, GlLink, GlIcon },
components: { Accordion, AccordionItem, GlLink, GlIcon, GlLoadingIcon },
directives: {
'gl-tooltip': GlTooltipDirective,
},
......@@ -69,6 +69,9 @@ export default {
},
},
computed: {
isLoadingGrades() {
return this.$apollo.queries.vulnerabilityGrades.loading;
},
severityGroups() {
return SEVERITY_GROUPS.map((group) => ({
...group,
......@@ -137,7 +140,7 @@ export default {
<section
class="gl-border-solid gl-border-1 gl-border-gray-100 gl-rounded-base gl-display-flex gl-flex-direction-column"
>
<header class="gl-border-b-solid gl-border-b-1 gl-border-b-gray-100 gl-p-5">
<header class="gl-p-5">
<h4 class="gl-my-0">
{{ __('Project security status') }}
<gl-link
......@@ -148,11 +151,16 @@ export default {
><gl-icon name="question"
/></gl-link>
</h4>
<p class="gl-text-gray-500 gl-m-0">
<p v-if="!isLoadingGrades" class="gl-text-gray-500 gl-m-0">
{{ __('Projects are graded based on the highest severity vulnerability present') }}
</p>
</header>
<accordion class="security-dashboard-accordion gl-px-5 gl-display-flex gl-flex-fill-1">
<gl-loading-icon v-if="isLoadingGrades" size="lg" class="gl-my-12" />
<accordion
v-else
class="security-dashboard-accordion gl-px-5 gl-display-flex gl-flex-fill-1 gl-border-t-1 gl-border-t-solid gl-border-t-gray-100"
>
<template #default="{ accordionId }">
<accordion-item
v-for="severityGroup in severityGroups"
......
---
title: Show loading spinner for security dashboard
merge_request: 56493
author:
type: fixed
import { GlLoadingIcon, GlTable } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import VulnerabilityChart from 'ee/security_dashboard/components/first_class_vulnerability_chart';
import ChartButtons from 'ee/security_dashboard/components/vulnerability_chart_buttons.vue';
import stubChildren from 'helpers/stub_children';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
describe('First class vulnerability chart component', () => {
let wrapper;
......@@ -16,29 +17,33 @@ describe('First class vulnerability chart component', () => {
},
};
const findTimeInfo = () => wrapper.find({ ref: 'timeInfo' });
const findChartButtons = () => wrapper.find(ChartButtons);
const findActiveChartButton = () => findChartButtons().find('.selected');
const findTimeInfo = () => wrapper.findByTestId('timeInfo');
const findChartButtons = () => wrapper.findComponent(ChartButtons);
const findSelectedChartButton = () => findChartButtons().find('.selected');
const find90DaysChartButton = () => findChartButtons().find('[data-days="90"]');
const mockApollo = (options) => {
return {
queries: {
vulnerabilitiesHistory: { ...options },
},
};
};
const createComponent = ({ $apollo, propsData, stubs, data, provide } = {}) => {
const instance = shallowMount(VulnerabilityChart, {
$apollo,
return extendedWrapper(
shallowMount(VulnerabilityChart, {
propsData: { query: {}, ...propsData },
provide: { groupFullPath: undefined, ...provide },
stubs: {
...stubChildren(VulnerabilityChart),
...stubs,
},
mocks: { $apollo: mockApollo($apollo) },
stubs,
data,
});
instance.vm.$apollo = { queries: { vulnerabilitiesHistory: { refetch: jest.fn() } } };
return instance;
}),
);
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('header', () => {
......@@ -63,7 +68,10 @@ describe('First class vulnerability chart component', () => {
describe('date range selectors', () => {
beforeEach(() => {
wrapper = createComponent({ stubs: { ChartButtons } });
wrapper = createComponent({
stubs: { ChartButtons },
$apollo: { refetch: jest.fn() },
});
});
it('should contain the chart buttons', () => {
......@@ -73,10 +81,10 @@ describe('First class vulnerability chart component', () => {
});
it('should change the actively selected chart button and refetch the new data', () => {
expect(findActiveChartButton().text()).toContain('30 Days');
expect(findSelectedChartButton().text()).toContain('30 Days');
find90DaysChartButton().vm.$emit('click');
return wrapper.vm.$nextTick(() => {
expect(findActiveChartButton().text()).toContain('90 Days');
expect(findSelectedChartButton().text()).toContain('90 Days');
expect(wrapper.vm.$apollo.queries.vulnerabilitiesHistory.refetch).toHaveBeenCalledTimes(1);
});
});
......@@ -86,9 +94,6 @@ describe('First class vulnerability chart component', () => {
beforeEach(() => {
wrapper = createComponent({
provide: { groupFullPath: 'gitlab-org' },
$apollo: {
queries: { vulnerabilitiesHistory: { group: responseData } },
},
});
});
......@@ -102,11 +107,7 @@ describe('First class vulnerability chart component', () => {
describe('when loading the history chart for instance level dashboard', () => {
beforeEach(() => {
wrapper = createComponent({
$apollo: {
queries: { vulnerabilitiesHistory: responseData },
},
});
wrapper = createComponent();
});
it('should process the data returned from GraphQL properly', () => {
......@@ -116,4 +117,20 @@ describe('First class vulnerability chart component', () => {
});
});
});
describe('when query is loading', () => {
beforeEach(() => {
wrapper = createComponent({
$apollo: { loading: true },
});
});
it('only shows the header and loading icon', () => {
expect(wrapper.find('h4').exists()).toBe(true);
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
expect(findTimeInfo().exists()).toBe(false);
expect(findChartButtons().exists()).toBe(false);
expect(wrapper.findComponent(GlTable).exists()).toBe(false);
});
});
});
import { GlLink } from '@gitlab/ui';
import { GlLink, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import VulnerabilitySeverity from 'ee/security_dashboard/components/first_class_vulnerability_severities.vue';
......@@ -29,25 +29,19 @@ describe('Vulnerability Severity component', () => {
.findAll('[data-testid="vulnerability-severity-groups"]')
.wrappers.map((item) => trimText(item.text()));
const createApolloProvider = (...queries) => {
return createMockApollo([...queries]);
};
const createComponent = ({ propsData, data, apolloProvider, provide }) => {
const createComponent = ({ provide, query, mockData } = {}) => {
return shallowMount(VulnerabilitySeverity, {
localVue,
apolloProvider,
apolloProvider: createMockApollo([[query, jest.fn().mockResolvedValue(mockData)]]),
propsData: {
query: {},
query,
helpPagePath,
...propsData,
},
provide: { groupFullPath: undefined, ...provide },
stubs: {
Accordion,
AccordionItem,
},
data,
});
};
......@@ -59,20 +53,14 @@ describe('Vulnerability Severity component', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when loading the project severity component for group level dashboard', () => {
beforeEach(() => {
const apolloProvider = createApolloProvider([
groupVulnerabilityGradesQuery,
jest.fn().mockResolvedValue(mockGroupVulnerabilityGrades()),
]);
wrapper = createComponent({
propsData: { query: groupVulnerabilityGradesQuery },
provide: { groupFullPath: 'gitlab-org' },
apolloProvider,
query: groupVulnerabilityGradesQuery,
mockData: mockGroupVulnerabilityGrades(),
});
return wrapper.vm.$nextTick();
......@@ -91,14 +79,9 @@ describe('Vulnerability Severity component', () => {
describe('when loading the project severity component for instance level dashboard', () => {
beforeEach(() => {
const apolloProvider = createApolloProvider([
instanceVulnerabilityGradesQuery,
jest.fn().mockResolvedValue(mockInstanceVulnerabilityGrades()),
]);
wrapper = createComponent({
propsData: { query: instanceVulnerabilityGradesQuery },
apolloProvider,
query: instanceVulnerabilityGradesQuery,
mockData: mockInstanceVulnerabilityGrades(),
});
return wrapper.vm.$nextTick();
......@@ -117,7 +100,10 @@ describe('Vulnerability Severity component', () => {
describe('for all cases', () => {
beforeEach(() => {
wrapper = createComponent({});
wrapper = createComponent({
query: instanceVulnerabilityGradesQuery,
mockData: mockInstanceVulnerabilityGrades(),
});
});
it('has the link to the help page', () => {
......@@ -151,14 +137,9 @@ describe('Vulnerability Severity component', () => {
beforeEach(async () => {
// Here instance or group does not matter. We just need some data to test
// common functionality.
const apolloProvider = createApolloProvider([
instanceVulnerabilityGradesQuery,
jest.fn().mockResolvedValue(mockInstanceVulnerabilityGrades()),
]);
wrapper = createComponent({
propsData: { query: instanceVulnerabilityGradesQuery },
apolloProvider,
query: instanceVulnerabilityGradesQuery,
mockData: mockInstanceVulnerabilityGrades(),
});
await wrapper.vm.$nextTick();
......@@ -194,4 +175,18 @@ describe('Vulnerability Severity component', () => {
});
},
);
describe('when query is loading', () => {
it('only shows the header and loading icon', () => {
wrapper = createComponent({
query: instanceVulnerabilityGradesQuery,
mockData: mockInstanceVulnerabilityGrades(),
});
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
expect(findHeader().exists()).toBe(true);
expect(findDescription().exists()).toBe(false);
expect(wrapper.findComponent(Accordion).exists()).toBe(false);
});
});
});
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