Commit 2ad75500 authored by Phil Hughes's avatar Phil Hughes

Merge branch '230412-implement-dashboard-not-configured-state' into 'master'

Implement empty state on security dashboard

Closes #230412

See merge request gitlab-org/gitlab!40413
parents 4d59594d 90f2fbc7
......@@ -4,9 +4,9 @@ import VulnerabilitySeverities from 'ee/security_dashboard/components/first_clas
import VulnerabilityChart from 'ee/security_dashboard/components/first_class_vulnerability_chart.vue';
import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue';
import projectsQuery from 'ee/security_dashboard/graphql/get_instance_security_dashboard_projects.query.graphql';
import createFlash from '~/flash';
import { createProjectLoadingError } from '../helpers';
import InstanceSecurityVulnerabilities from './first_class_instance_security_dashboard_vulnerabilities.vue';
import { __ } from '~/locale';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import CsvExportButton from './csv_export_button.vue';
import vulnerabilityHistoryQuery from '../graphql/instance_vulnerability_history.query.graphql';
import vulnerabilityGradesQuery from '../graphql/instance_vulnerability_grades.query.graphql';
......@@ -35,7 +35,7 @@ export default {
return data.instanceSecurityDashboard.projects.nodes;
},
error() {
createFlash(__('Something went wrong, unable to get projects'));
createFlash({ message: createProjectLoadingError() });
},
},
},
......
......@@ -2,6 +2,7 @@
import { GlAlert } from '@gitlab/ui';
import SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue';
import projectsQuery from 'ee/security_dashboard/graphql/get_instance_security_dashboard_projects.query.graphql';
import { createProjectLoadingError } from '../helpers';
import ProjectManager from './first_class_project_manager/project_manager.vue';
export default {
......@@ -27,13 +28,18 @@ export default {
hasError: false,
};
},
computed: {
errorMessage() {
return createProjectLoadingError();
},
},
};
</script>
<template>
<security-dashboard-layout>
<gl-alert v-if="hasError" variant="danger">
{{ __('Something went wrong, unable to get projects') }}
{{ errorMessage }}
</gl-alert>
<div v-else class="gl-display-flex gl-justify-content-center">
<project-manager :projects="projects" />
......
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import createFlash from '~/flash';
import { createProjectLoadingError } from '../helpers';
import DashboardNotConfigured from './empty_states/group_dashboard_not_configured.vue';
import SecurityChartsLayout from './security_charts_layout.vue';
import VulnerabilityChart from './first_class_vulnerability_chart.vue';
import VulnerabilitySeverities from './first_class_vulnerability_severities.vue';
import vulnerabilityHistoryQuery from '../graphql/group_vulnerability_history.query.graphql';
import vulnerabilityGradesQuery from '../graphql/group_vulnerability_grades.query.graphql';
import SecurityChartsLayout from './security_charts_layout.vue';
import vulnerableProjectsQuery from '../graphql/vulnerable_projects.query.graphql';
export default {
components: {
GlLoadingIcon,
DashboardNotConfigured,
SecurityChartsLayout,
VulnerabilitySeverities,
VulnerabilityChart,
......@@ -17,18 +24,55 @@ export default {
required: true,
},
},
apollo: {
projects: {
query: vulnerableProjectsQuery,
variables() {
return { fullPath: this.groupFullPath };
},
update(data) {
return data?.group?.projects?.nodes ?? [];
},
error() {
createFlash({ message: createProjectLoadingError() });
},
},
},
data() {
return {
projects: [],
vulnerabilityHistoryQuery,
vulnerabilityGradesQuery,
};
},
computed: {
isLoadingProjects() {
return this.$apollo.queries.projects.loading;
},
shouldShowCharts() {
return Boolean(!this.isLoadingProjects && this.projects.length);
},
shouldShowEmptyState() {
return !this.isLoadingProjects && !this.projects.length;
},
},
};
</script>
<template>
<security-charts-layout>
<vulnerability-chart :query="vulnerabilityHistoryQuery" :group-full-path="groupFullPath" />
<vulnerability-severities :query="vulnerabilityGradesQuery" :group-full-path="groupFullPath" />
<template v-if="shouldShowEmptyState" #empty-state>
<dashboard-not-configured />
</template>
<template v-else-if="shouldShowCharts" #default>
<vulnerability-chart :query="vulnerabilityHistoryQuery" :group-full-path="groupFullPath" />
<vulnerability-severities
:query="vulnerabilityGradesQuery"
:group-full-path="groupFullPath"
/>
</template>
<template v-else #loading>
<gl-loading-icon size="lg" class="gl-mt-6" />
</template>
</security-charts-layout>
</template>
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import createFlash from '~/flash';
import { createProjectLoadingError } from '../helpers';
import DashboardNotConfigured from './empty_states/instance_dashboard_not_configured.vue';
import SecurityChartsLayout from './security_charts_layout.vue';
import VulnerabilityChart from './first_class_vulnerability_chart.vue';
import VulnerabilitySeverities from './first_class_vulnerability_severities.vue';
import projectsQuery from '../graphql/get_instance_security_dashboard_projects.query.graphql';
import vulnerabilityHistoryQuery from '../graphql/instance_vulnerability_history.query.graphql';
import vulnerabilityGradesQuery from '../graphql/instance_vulnerability_grades.query.graphql';
export default {
components: {
GlLoadingIcon,
DashboardNotConfigured,
SecurityChartsLayout,
VulnerabilitySeverities,
VulnerabilityChart,
},
apollo: {
projects: {
query: projectsQuery,
update(data) {
return data?.instanceSecurityDashboard?.projects?.nodes ?? [];
},
error() {
createFlash({ message: createProjectLoadingError() });
},
},
},
data() {
return {
projects: [],
vulnerabilityHistoryQuery,
vulnerabilityGradesQuery,
};
},
computed: {
isLoadingProjects() {
return this.$apollo.queries.projects.loading;
},
shouldShowCharts() {
return Boolean(!this.isLoadingProjects && this.projects.length);
},
shouldShowEmptyState() {
return !this.isLoadingProjects && !this.projects.length;
},
},
};
</script>
<template>
<security-charts-layout>
<vulnerability-chart :query="vulnerabilityHistoryQuery" />
<vulnerability-severities :query="vulnerabilityGradesQuery" />
<template v-if="shouldShowEmptyState" #empty-state>
<dashboard-not-configured />
</template>
<template v-else-if="shouldShowCharts" #default>
<vulnerability-chart :query="vulnerabilityHistoryQuery" />
<vulnerability-severities :query="vulnerabilityGradesQuery" />
</template>
<template v-else #loading>
<gl-loading-icon size="lg" class="gl-mt-6" />
</template>
</security-charts-layout>
</template>
......@@ -8,11 +8,13 @@ export default {
};
</script>
<template functional>
<div>
<template>
<div data-testid="security-charts-layout">
<h2>{{ $options.i18n.title }}</h2>
<slot name="loading"></slot>
<div class="security-charts gl-display-flex gl-flex-wrap">
<slot></slot>
</div>
<slot name="empty-state"></slot>
</div>
</template>
export const COLLAPSE_SECURITY_REPORTS_SUMMARY_LOCAL_STORAGE_KEY =
'hide_pipelines_security_reports_summary_details';
export default () => ({});
......@@ -3,7 +3,7 @@ import { ALL, BASE_FILTERS } from 'ee/security_dashboard/store/modules/filters/c
import { REPORT_TYPES, SEVERITY_LEVELS } from 'ee/security_dashboard/store/constants';
import { VULNERABILITY_STATES } from 'ee/vulnerabilities/constants';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import { s__, __ } from '~/locale';
const parseOptions = obj =>
Object.entries(obj).map(([id, name]) => ({ id: id.toUpperCase(), name }));
......@@ -107,4 +107,6 @@ export const preparePageInfo = pageInfo => {
return { ...pageInfo, hasNextPage: Boolean(pageInfo?.endCursor) };
};
export const createProjectLoadingError = () => __('An error occurred while retrieving projects.');
export default () => ({});
......@@ -28,6 +28,10 @@ export default (el, dashboardType) => {
}
const props = {};
const provide = {
dashboardDocumentation: el.dataset.dashboardDocumentation,
emptyStateSvgPath: el.dataset.emptyStateSvgPath,
};
let component;
......@@ -36,6 +40,7 @@ export default (el, dashboardType) => {
props.groupFullPath = el.dataset.groupFullPath;
} else if (dashboardType === DASHBOARD_TYPES.INSTANCE) {
component = InstanceSecurityCharts;
provide.instanceDashboardSettingsPath = el.dataset.instanceDashboardSettingsPath;
}
const router = createRouter();
......@@ -46,6 +51,7 @@ export default (el, dashboardType) => {
store,
router,
apolloProvider,
provide: () => provide,
render(createElement) {
return createElement(component, { props });
},
......
---
title: Implement empty state on security dashboard
merge_request: 40413
author:
type: changed
......@@ -51,7 +51,7 @@ describe('First Class Instance Dashboard Component', () => {
});
it('renders the alert component', () => {
expect(findAlert().text()).toBe('Something went wrong, unable to get projects');
expect(findAlert().text()).toBe('An error occurred while retrieving projects.');
});
});
});
import { shallowMount } from '@vue/test-utils';
import { TEST_HOST } from 'jest/helpers/test_constants';
import { GlLoadingIcon } from '@gitlab/ui';
import DashboardNotConfigured from 'ee/security_dashboard/components/empty_states/group_dashboard_not_configured.vue';
import GroupSecurityCharts from 'ee/security_dashboard/components/group_security_charts.vue';
import SecurityChartsLayout from 'ee/security_dashboard/components/security_charts_layout.vue';
import VulnerabilityChart from 'ee/security_dashboard/components/first_class_vulnerability_chart.vue';
import VulnerabilitySeverities from 'ee/security_dashboard/components/first_class_vulnerability_severities.vue';
import vulnerabilityHistoryQuery from 'ee/security_dashboard/graphql/group_vulnerability_history.query.graphql';
import vulnerabilityGradesQuery from 'ee/security_dashboard/graphql/group_vulnerability_grades.query.graphql';
jest.mock('ee/security_dashboard/graphql/group_vulnerability_history.query.graphql', () => ({}));
jest.mock('ee/security_dashboard/graphql/group_vulnerability_grades.query.graphql', () => ({
mockGrades: true,
}));
jest.mock('ee/security_dashboard/graphql/group_vulnerability_history.query.graphql', () => ({
mockHistory: true,
}));
describe('Group Security Charts component', () => {
let wrapper;
......@@ -13,32 +22,87 @@ describe('Group Security Charts component', () => {
const groupFullPath = `${TEST_HOST}/group/5`;
const findSecurityChartsLayoutComponent = () => wrapper.find(SecurityChartsLayout);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findVulnerabilityChart = () => wrapper.find(VulnerabilityChart);
const findVulnerabilitySeverities = () => wrapper.find(VulnerabilitySeverities);
const findDashboardNotConfigured = () => wrapper.find(DashboardNotConfigured);
const createWrapper = () => {
const createWrapper = ({ loading = false } = {}) => {
wrapper = shallowMount(GroupSecurityCharts, {
mocks: {
$apollo: {
queries: {
projects: {
loading,
},
},
},
},
propsData: { groupFullPath },
stubs: {
SecurityChartsLayout,
},
});
};
beforeEach(() => {
createWrapper();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('renders the default page', () => {
it('renders the loading page', () => {
createWrapper({ loading: true });
const securityChartsLayout = findSecurityChartsLayoutComponent();
const dashboardNotConfigured = findDashboardNotConfigured();
const loadingIcon = findLoadingIcon();
const vulnerabilityChart = findVulnerabilityChart();
const vulnerabilitySeverities = findVulnerabilitySeverities();
expect(securityChartsLayout.exists()).toBe(true);
expect(vulnerabilityChart.props()).toEqual({ query: {}, groupFullPath });
expect(dashboardNotConfigured.exists()).toBe(false);
expect(loadingIcon.exists()).toBe(true);
expect(vulnerabilityChart.exists()).toBe(false);
expect(vulnerabilitySeverities.exists()).toBe(false);
});
it('renders the empty state', () => {
createWrapper();
const securityChartsLayout = findSecurityChartsLayoutComponent();
const dashboardNotConfigured = findDashboardNotConfigured();
const loadingIcon = findLoadingIcon();
const vulnerabilityChart = findVulnerabilityChart();
const vulnerabilitySeverities = findVulnerabilitySeverities();
expect(securityChartsLayout.exists()).toBe(true);
expect(dashboardNotConfigured.exists()).toBe(true);
expect(loadingIcon.exists()).toBe(false);
expect(vulnerabilityChart.exists()).toBe(false);
expect(vulnerabilitySeverities.exists()).toBe(false);
});
it('renders the default page', async () => {
createWrapper();
wrapper.setData({ projects: [{ name: 'project1' }] });
await wrapper.vm.$nextTick();
const securityChartsLayout = findSecurityChartsLayoutComponent();
const dashboardNotConfigured = findDashboardNotConfigured();
const loadingIcon = findLoadingIcon();
const vulnerabilityChart = findVulnerabilityChart();
const vulnerabilitySeverities = findVulnerabilitySeverities();
expect(securityChartsLayout.exists()).toBe(true);
expect(dashboardNotConfigured.exists()).toBe(false);
expect(loadingIcon.exists()).toBe(false);
expect(vulnerabilityChart.exists()).toBe(true);
expect(vulnerabilityChart.props()).toEqual({ query: vulnerabilityHistoryQuery, groupFullPath });
expect(vulnerabilitySeverities.exists()).toBe(true);
expect(vulnerabilitySeverities.props().groupFullPath).toEqual(groupFullPath);
expect(vulnerabilitySeverities.props()).toEqual({
query: vulnerabilityGradesQuery,
groupFullPath,
helpPagePath: '',
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlLoadingIcon } from '@gitlab/ui';
import InstanceSecurityCharts from 'ee/security_dashboard/components/instance_security_charts.vue';
import DashboardNotConfigured from 'ee/security_dashboard/components/empty_states/instance_dashboard_not_configured.vue';
import VulnerabilityChart from 'ee/security_dashboard/components/first_class_vulnerability_chart.vue';
import VulnerabilitySeverities from 'ee/security_dashboard/components/first_class_vulnerability_severities.vue';
import SecurityChartsLayout from 'ee/security_dashboard/components/security_charts_layout.vue';
......@@ -17,28 +19,79 @@ describe('Instance Security Charts component', () => {
let wrapper;
const findSecurityChartsLayoutComponent = () => wrapper.find(SecurityChartsLayout);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findVulnerabilityChart = () => wrapper.find(VulnerabilityChart);
const findVulnerabilitySeverities = () => wrapper.find(VulnerabilitySeverities);
const findDashboardNotConfigured = () => wrapper.find(DashboardNotConfigured);
const createWrapper = () => {
wrapper = shallowMount(InstanceSecurityCharts, {});
const createWrapper = ({ loading = false } = {}) => {
wrapper = shallowMount(InstanceSecurityCharts, {
mocks: {
$apollo: {
queries: {
projects: {
loading,
},
},
},
},
stubs: {
SecurityChartsLayout,
},
});
};
beforeEach(() => {
createWrapper();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('renders the default page', () => {
it('renders the loading page', () => {
createWrapper({ loading: true });
const securityChartsLayout = findSecurityChartsLayoutComponent();
const dashboardNotConfigured = findDashboardNotConfigured();
const loadingIcon = findLoadingIcon();
const vulnerabilityChart = findVulnerabilityChart();
const vulnerabilitySeverities = findVulnerabilitySeverities();
expect(securityChartsLayout.exists()).toBe(true);
expect(dashboardNotConfigured.exists()).toBe(false);
expect(loadingIcon.exists()).toBe(true);
expect(vulnerabilityChart.exists()).toBe(false);
expect(vulnerabilitySeverities.exists()).toBe(false);
});
it('renders the empty state', () => {
createWrapper();
const securityChartsLayout = findSecurityChartsLayoutComponent();
const dashboardNotConfigured = findDashboardNotConfigured();
const loadingIcon = findLoadingIcon();
const vulnerabilityChart = findVulnerabilityChart();
const vulnerabilitySeverities = findVulnerabilitySeverities();
expect(securityChartsLayout.exists()).toBe(true);
expect(dashboardNotConfigured.exists()).toBe(true);
expect(loadingIcon.exists()).toBe(false);
expect(vulnerabilityChart.exists()).toBe(false);
expect(vulnerabilitySeverities.exists()).toBe(false);
});
it('renders the default page', async () => {
createWrapper();
wrapper.setData({ projects: [{ name: 'project1' }] });
await wrapper.vm.$nextTick();
const securityChartsLayout = findSecurityChartsLayoutComponent();
const dashboardNotConfigured = findDashboardNotConfigured();
const loadingIcon = findLoadingIcon();
const vulnerabilityChart = findVulnerabilityChart();
const vulnerabilitySeverities = findVulnerabilitySeverities();
expect(securityChartsLayout.exists()).toBe(true);
expect(dashboardNotConfigured.exists()).toBe(false);
expect(loadingIcon.exists()).toBe(false);
expect(vulnerabilityChart.props()).toEqual({ query: vulnerabilityHistoryQuery });
expect(vulnerabilitySeverities.exists()).toBe(true);
expect(vulnerabilitySeverities.props()).toEqual({
......
......@@ -4,19 +4,23 @@ import SecurityChartsLayout from 'ee/security_dashboard/components/security_char
describe('Security Charts Layout component', () => {
let wrapper;
const DummyComponent = {
name: 'dummy-component',
template: '<p>dummy component</p>',
const DummyComponent1 = {
name: 'dummy-component-1',
template: '<p>dummy component 1</p>',
};
const DummyComponent2 = {
name: 'dummy-component-2',
template: '<p>dummy component 2</p>',
};
const findSlot = () => wrapper.find('.security-charts');
const findSlot = () => wrapper.find(`[data-testid="security-charts-layout"]`);
const createWrapper = slots => {
wrapper = shallowMount(SecurityChartsLayout, { slots });
};
beforeEach(() => {
createWrapper({ default: DummyComponent });
createWrapper({ default: DummyComponent1, 'empty-state': DummyComponent2 });
});
afterEach(() => {
......@@ -26,6 +30,11 @@ describe('Security Charts Layout component', () => {
it('should render the default slot', () => {
const slot = findSlot();
expect(slot.find(DummyComponent).exists()).toBe(true);
expect(slot.find(DummyComponent1).exists()).toBe(true);
});
it('should render the empty-state slot', () => {
const slot = findSlot();
expect(slot.find(DummyComponent2).exists()).toBe(true);
});
});
......@@ -2834,6 +2834,9 @@ msgstr ""
msgid "An error occurred while retrieving diff files"
msgstr ""
msgid "An error occurred while retrieving projects."
msgstr ""
msgid "An error occurred while saving LDAP override status. Please try again."
msgstr ""
......
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