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