Commit 9e5596cc authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 515ec7e2 b9e97cb9
...@@ -5,10 +5,29 @@ import GpgBadges from '~/gpg_badges'; ...@@ -5,10 +5,29 @@ import GpgBadges from '~/gpg_badges';
import initBlob from '~/pages/projects/init_blob'; import initBlob from '~/pages/projects/init_blob';
import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
import BlobContentViewer from '~/repository/components/blob_content_viewer.vue';
import '~/sourcegraph/load'; import '~/sourcegraph/load';
new BlobViewer(); // eslint-disable-line no-new const viewBlobEl = document.querySelector('#js-view-blob-app');
initBlob();
if (viewBlobEl) {
const { blobPath } = viewBlobEl.dataset;
// eslint-disable-next-line no-new
new Vue({
el: viewBlobEl,
render(createElement) {
return createElement(BlobContentViewer, {
props: {
path: blobPath,
},
});
},
});
} else {
new BlobViewer(); // eslint-disable-line no-new
initBlob();
}
const CommitPipelineStatusEl = document.querySelector('.js-commit-pipeline-status'); const CommitPipelineStatusEl = document.querySelector('.js-commit-pipeline-status');
const statusLink = document.querySelector('.commit-actions .ci-status-link'); const statusLink = document.querySelector('.commit-actions .ci-status-link');
......
...@@ -11,6 +11,11 @@ ...@@ -11,6 +11,11 @@
#blob-content-holder.blob-content-holder #blob-content-holder.blob-content-holder
- if @code_navigation_path - if @code_navigation_path
#js-code-navigation{ data: { code_navigation_path: @code_navigation_path, blob_path: blob.path, definition_path_prefix: project_blob_path(@project, @ref) } } #js-code-navigation{ data: { code_navigation_path: @code_navigation_path, blob_path: blob.path, definition_path_prefix: project_blob_path(@project, @ref) } }
%article.file-holder - if Feature.enabled?(:refactor_blob_viewer, @project, default_enabled: :yaml)
= render 'projects/blob/header', blob: blob #js-view-blob-app{ data: { blob_path: blob.path } }
= render 'projects/blob/content', blob: blob .gl-spinner-container
= loading_icon(size: 'md')
- else
%article.file-holder
= render 'projects/blob/header', blob: blob
= render 'projects/blob/content', blob: blob
---
title: Fix N+1 in REST projects and service desk
merge_request: 58747
author:
type: performance
...@@ -3,10 +3,13 @@ import { GlLoadingIcon } from '@gitlab/ui'; ...@@ -3,10 +3,13 @@ import { GlLoadingIcon } from '@gitlab/ui';
import GroupSecurityVulnerabilities from 'ee/security_dashboard/components/first_class_group_security_dashboard_vulnerabilities.vue'; import GroupSecurityVulnerabilities from 'ee/security_dashboard/components/first_class_group_security_dashboard_vulnerabilities.vue';
import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue'; import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue';
import SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue'; import SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue';
import { PROJECT_LOADING_ERROR_MESSAGE } from 'ee/security_dashboard/helpers';
import createFlash from '~/flash';
import { vulnerabilitiesSeverityCountScopes } from '../constants'; import { vulnerabilitiesSeverityCountScopes } from '../constants';
import groupProjectsQuery from '../graphql/queries/group_projects.query.graphql'; import groupProjectsQuery from '../graphql/queries/group_projects.query.graphql';
import CsvExportButton from './csv_export_button.vue'; import CsvExportButton from './csv_export_button.vue';
import DashboardNotConfigured from './empty_states/group_dashboard_not_configured.vue'; import DashboardNotConfigured from './empty_states/group_dashboard_not_configured.vue';
import SurveyRequestBanner from './survey_request_banner.vue';
import VulnerabilitiesCountList from './vulnerability_count_list.vue'; import VulnerabilitiesCountList from './vulnerability_count_list.vue';
export default { export default {
...@@ -18,6 +21,7 @@ export default { ...@@ -18,6 +21,7 @@ export default {
DashboardNotConfigured, DashboardNotConfigured,
GlLoadingIcon, GlLoadingIcon,
VulnerabilitiesCountList, VulnerabilitiesCountList,
SurveyRequestBanner,
}, },
inject: ['groupFullPath'], inject: ['groupFullPath'],
apollo: { apollo: {
...@@ -29,11 +33,8 @@ export default { ...@@ -29,11 +33,8 @@ export default {
update(data) { update(data) {
return data.group.projects.nodes; return data.group.projects.nodes;
}, },
result() {
this.projectsWereFetched = true;
},
error() { error() {
this.projectsWereFetched = false; createFlash({ message: PROJECT_LOADING_ERROR_MESSAGE });
}, },
}, },
}, },
...@@ -41,12 +42,14 @@ export default { ...@@ -41,12 +42,14 @@ export default {
return { return {
filters: {}, filters: {},
projects: [], projects: [],
projectsWereFetched: false,
}; };
}, },
computed: { computed: {
isLoadingProjects() {
return this.$apollo.queries.projects.loading;
},
hasNoProjects() { hasNoProjects() {
return this.projects.length === 0 && this.projectsWereFetched; return this.projects.length === 0;
}, },
}, },
methods: { methods: {
...@@ -59,27 +62,32 @@ export default { ...@@ -59,27 +62,32 @@ export default {
</script> </script>
<template> <template>
<div> <gl-loading-icon v-if="isLoadingProjects" size="lg" class="gl-mt-6" />
<gl-loading-icon v-if="!projectsWereFetched" size="lg" class="gl-mt-6" />
<dashboard-not-configured v-else-if="hasNoProjects" /> <div v-else-if="hasNoProjects">
<security-dashboard-layout v-else> <survey-request-banner class="gl-mt-5" />
<template #header> <dashboard-not-configured />
<header class="gl-my-6 gl-display-flex gl-align-items-center">
<h2 class="gl-flex-grow-1 gl-my-0">
{{ s__('SecurityReports|Vulnerability Report') }}
</h2>
<csv-export-button />
</header>
<vulnerabilities-count-list
:scope="$options.vulnerabilitiesSeverityCountScopes.group"
:full-path="groupFullPath"
:filters="filters"
/>
</template>
<template #sticky>
<filters :projects="projects" @filterChange="handleFilterChange" />
</template>
<group-security-vulnerabilities :filters="filters" />
</security-dashboard-layout>
</div> </div>
<security-dashboard-layout v-else>
<template #header>
<survey-request-banner class="gl-mt-5" />
<header class="gl-my-6 gl-display-flex gl-align-items-center">
<h2 class="gl-flex-grow-1 gl-my-0">
{{ s__('SecurityReports|Vulnerability Report') }}
</h2>
<csv-export-button />
</header>
<vulnerabilities-count-list
:scope="$options.vulnerabilitiesSeverityCountScopes.group"
:full-path="groupFullPath"
:filters="filters"
/>
</template>
<template #sticky>
<filters :projects="projects" @filterChange="handleFilterChange" />
</template>
<group-security-vulnerabilities :filters="filters" />
</security-dashboard-layout>
</template> </template>
<script> <script>
import { GlLoadingIcon } from '@gitlab/ui';
import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue'; import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue';
import SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue'; import SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue';
import instanceProjectsQuery from 'ee/security_dashboard/graphql/queries/instance_projects.query.graphql'; import instanceProjectsQuery from 'ee/security_dashboard/graphql/queries/instance_projects.query.graphql';
...@@ -8,6 +9,7 @@ import { PROJECT_LOADING_ERROR_MESSAGE } from '../helpers'; ...@@ -8,6 +9,7 @@ import { PROJECT_LOADING_ERROR_MESSAGE } from '../helpers';
import CsvExportButton from './csv_export_button.vue'; import CsvExportButton from './csv_export_button.vue';
import DashboardNotConfigured from './empty_states/instance_dashboard_not_configured.vue'; import DashboardNotConfigured from './empty_states/instance_dashboard_not_configured.vue';
import InstanceSecurityVulnerabilities from './first_class_instance_security_dashboard_vulnerabilities.vue'; import InstanceSecurityVulnerabilities from './first_class_instance_security_dashboard_vulnerabilities.vue';
import SurveyRequestBanner from './survey_request_banner.vue';
import VulnerabilitiesCountList from './vulnerability_count_list.vue'; import VulnerabilitiesCountList from './vulnerability_count_list.vue';
export default { export default {
...@@ -18,6 +20,8 @@ export default { ...@@ -18,6 +20,8 @@ export default {
Filters, Filters,
DashboardNotConfigured, DashboardNotConfigured,
VulnerabilitiesCountList, VulnerabilitiesCountList,
SurveyRequestBanner,
GlLoadingIcon,
}, },
apollo: { apollo: {
projects: { projects: {
...@@ -40,14 +44,8 @@ export default { ...@@ -40,14 +44,8 @@ export default {
isLoadingProjects() { isLoadingProjects() {
return this.$apollo.queries.projects.loading; return this.$apollo.queries.projects.loading;
}, },
hasProjectsData() { hasNoProjects() {
return !this.isLoadingProjects && this.projects.length > 0; return this.projects.length === 0;
},
shouldShowDashboard() {
return this.hasProjectsData;
},
shouldShowEmptyState() {
return !this.isLoadingProjects && this.projects.length === 0;
}, },
}, },
methods: { methods: {
...@@ -60,30 +58,33 @@ export default { ...@@ -60,30 +58,33 @@ export default {
</script> </script>
<template> <template>
<security-dashboard-layout> <gl-loading-icon v-if="isLoadingProjects" size="lg" class="gl-mt-6" />
<dashboard-not-configured v-if="shouldShowEmptyState" />
<div v-else-if="hasNoProjects">
<survey-request-banner class="gl-mt-5" />
<dashboard-not-configured />
</div>
<security-dashboard-layout v-else>
<template #header> <template #header>
<div v-if="shouldShowDashboard"> <survey-request-banner class="gl-mt-5" />
<header class="gl-my-6 gl-display-flex gl-align-items-center" data-testid="header">
<h2 class="gl-flex-grow-1 gl-my-0"> <header class="gl-my-6 gl-display-flex gl-align-items-center" data-testid="header">
{{ s__('SecurityReports|Vulnerability Report') }} <h2 class="gl-flex-grow-1 gl-my-0">
</h2> {{ s__('SecurityReports|Vulnerability Report') }}
<csv-export-button /> </h2>
</header> <csv-export-button />
<vulnerabilities-count-list </header>
:scope="$options.vulnerabilitiesSeverityCountScopes.instance" <vulnerabilities-count-list
:filters="filters" :scope="$options.vulnerabilitiesSeverityCountScopes.instance"
/> :filters="filters"
</div> />
</template> </template>
<template #sticky> <template #sticky>
<filters v-if="shouldShowDashboard" :projects="projects" @filterChange="handleFilterChange" /> <filters :projects="projects" @filterChange="handleFilterChange" />
</template> </template>
<instance-security-vulnerabilities
v-if="shouldShowDashboard" <instance-security-vulnerabilities :projects="projects" :filters="filters" />
:projects="projects"
:filters="filters"
/>
</security-dashboard-layout> </security-dashboard-layout>
</template> </template>
...@@ -9,6 +9,7 @@ import Filters from './first_class_vulnerability_filters.vue'; ...@@ -9,6 +9,7 @@ import Filters from './first_class_vulnerability_filters.vue';
import ProjectPipelineStatus from './project_pipeline_status.vue'; import ProjectPipelineStatus from './project_pipeline_status.vue';
import ProjectVulnerabilitiesApp from './project_vulnerabilities.vue'; import ProjectVulnerabilitiesApp from './project_vulnerabilities.vue';
import SecurityDashboardLayout from './security_dashboard_layout.vue'; import SecurityDashboardLayout from './security_dashboard_layout.vue';
import SurveyRequestBanner from './survey_request_banner.vue';
import VulnerabilitiesCountList from './vulnerability_count_list.vue'; import VulnerabilitiesCountList from './vulnerability_count_list.vue';
export default { export default {
...@@ -21,6 +22,7 @@ export default { ...@@ -21,6 +22,7 @@ export default {
VulnerabilitiesCountList, VulnerabilitiesCountList,
CsvExportButton, CsvExportButton,
Filters, Filters,
SurveyRequestBanner,
}, },
mixins: [glFeatureFlagsMixin()], mixins: [glFeatureFlagsMixin()],
inject: ['dashboardDocumentation', 'autoFixDocumentation', 'projectFullPath'], inject: ['dashboardDocumentation', 'autoFixDocumentation', 'projectFullPath'],
...@@ -58,6 +60,8 @@ export default { ...@@ -58,6 +60,8 @@ export default {
<template> <template>
<div> <div>
<survey-request-banner class="gl-mt-5" />
<template v-if="pipeline.id"> <template v-if="pipeline.id">
<auto-fix-user-callout <auto-fix-user-callout
v-if="shouldShowAutoFixUserCallout" v-if="shouldShowAutoFixUserCallout"
...@@ -67,7 +71,9 @@ export default { ...@@ -67,7 +71,9 @@ export default {
<security-dashboard-layout> <security-dashboard-layout>
<template #header> <template #header>
<div class="gl-mt-6 gl-display-flex"> <div class="gl-mt-6 gl-display-flex">
<h4 class="gl-flex-grow-1 gl-my-0">{{ __('Vulnerability Report') }}</h4> <h4 class="gl-flex-grow-1 gl-my-0">
{{ s__('SecurityReports|Vulnerability Report') }}
</h4>
<csv-export-button /> <csv-export-button />
</div> </div>
<project-pipeline-status :pipeline="pipeline" /> <project-pipeline-status :pipeline="pipeline" />
......
<script> <script>
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import SurveyRequestBanner from './survey_request_banner.vue';
export default { export default {
components: { SurveyRequestBanner },
i18n: { i18n: {
title: s__('SecurityReports|Security Dashboard'), title: s__('SecurityReports|Security Dashboard'),
}, },
...@@ -12,6 +14,8 @@ export default { ...@@ -12,6 +14,8 @@ export default {
<div> <div>
<slot name="loading"></slot> <slot name="loading"></slot>
<survey-request-banner v-if="!$slots.loading" class="gl-mt-5" />
<template v-if="$slots.default"> <template v-if="$slots.default">
<h2 data-testid="title">{{ $options.i18n.title }}</h2> <h2 data-testid="title">{{ $options.i18n.title }}</h2>
<div class="security-charts gl-display-flex gl-flex-wrap"> <div class="security-charts gl-display-flex gl-flex-wrap">
......
<script>
import { GlButton, GlBanner, GlSprintf } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import showToast from '~/vue_shared/plugins/global_toast';
import { SURVEY_BANNER_LOCAL_STORAGE_KEY, SURVEY_BANNER_CURRENT_ID } from '../constants';
const SURVEY_LINK = 'https://gitlab.fra1.qualtrics.com/jfe/form/SV_7UMsVhPbjmwCp1k';
const DAYS_TO_ASK_LATER = 7;
export default {
components: { GlButton, GlBanner, GlSprintf, LocalStorageSync },
inject: ['surveyRequestSvgPath'],
data: () => ({
surveyShowDate: null,
}),
computed: {
shouldShowSurvey() {
const { surveyShowDate } = this;
const isFeatureEnabled = Boolean(gon.features?.vulnerabilityManagementSurvey);
const date = new Date(surveyShowDate);
// Survey is not enabled or user dismissed the survey by clicking the close icon.
if (!isFeatureEnabled || surveyShowDate === SURVEY_BANNER_CURRENT_ID) {
return false;
}
// Date is invalid, we should show the survey.
else if (Number.isNaN(date.getDate())) {
return true;
}
return date <= Date.now();
},
},
methods: {
hideSurvey() {
this.surveyShowDate = SURVEY_BANNER_CURRENT_ID;
},
askLater() {
const date = new Date();
date.setDate(date.getDate() + DAYS_TO_ASK_LATER);
this.surveyShowDate = date.toISOString();
showToast(this.$options.i18n.toastMessage);
},
},
i18n: {
title: s__('SecurityReports|Vulnerability Management feature survey'),
buttonText: s__('SecurityReports|Take survey'),
askAgainLater: __('Ask again later'),
description: s__(
`SecurityReports|At GitLab, we're all about iteration and feedback. That's why we are reaching out to customers like you to help guide what we work on this year for Vulnerability Management. We have a lot of exciting ideas and ask that you assist us by taking a short survey %{boldStart}no longer than 10 minutes%{boldEnd} to evaluate a few of our potential features.`,
),
toastMessage: s__(
'SecurityReports|Your feedback is important to us! We will ask again in a week.',
),
},
storageKey: SURVEY_BANNER_LOCAL_STORAGE_KEY,
surveyLink: SURVEY_LINK,
};
</script>
<template>
<local-storage-sync v-model="surveyShowDate" :storage-key="$options.storageKey">
<gl-banner
v-if="shouldShowSurvey"
:title="$options.i18n.title"
:button-text="$options.i18n.buttonText"
:svg-path="surveyRequestSvgPath"
:button-link="$options.surveyLink"
@close="hideSurvey"
>
<p>
<gl-sprintf :message="$options.i18n.description">
<template #bold="{ content }">
<span class="gl-font-weight-bold">{{ content }}</span>
</template>
</gl-sprintf>
</p>
<template #actions>
<gl-button variant="link" class="gl-ml-5" data-testid="ask-later-button" @click="askLater">
{{ $options.i18n.askAgainLater }}
</gl-button>
</template>
</gl-banner>
</local-storage-sync>
</template>
export const COLLAPSE_SECURITY_REPORTS_SUMMARY_LOCAL_STORAGE_KEY = export const COLLAPSE_SECURITY_REPORTS_SUMMARY_LOCAL_STORAGE_KEY =
'hide_pipelines_security_reports_summary_details'; 'hide_pipelines_security_reports_summary_details';
export const SURVEY_BANNER_LOCAL_STORAGE_KEY = 'vulnerability_management_survey_request';
// NOTE: This string needs to parse to an invalid date. Do not put any characters in between the
// word 'survey' and the number, or else it will parse to a valid date.
export const SURVEY_BANNER_CURRENT_ID = 'survey1';
export const vulnerabilitiesSeverityCountScopes = { export const vulnerabilitiesSeverityCountScopes = {
instance: 'instance', instance: 'instance',
group: 'group', group: 'group',
......
---
title: Add vulnerability management survey request banner
merge_request: 56620
author:
type: added
...@@ -6,6 +6,7 @@ import FirstClassGroupDashboard from 'ee/security_dashboard/components/first_cla ...@@ -6,6 +6,7 @@ import FirstClassGroupDashboard from 'ee/security_dashboard/components/first_cla
import FirstClassGroupVulnerabilities from 'ee/security_dashboard/components/first_class_group_security_dashboard_vulnerabilities.vue'; import FirstClassGroupVulnerabilities from 'ee/security_dashboard/components/first_class_group_security_dashboard_vulnerabilities.vue';
import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue'; import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue';
import SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue'; import SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue';
import SurveyRequestBanner from 'ee/security_dashboard/components/survey_request_banner.vue';
import VulnerabilitiesCountList from 'ee/security_dashboard/components/vulnerability_count_list.vue'; import VulnerabilitiesCountList from 'ee/security_dashboard/components/vulnerability_count_list.vue';
describe('First Class Group Dashboard Component', () => { describe('First Class Group Dashboard Component', () => {
...@@ -15,15 +16,16 @@ describe('First Class Group Dashboard Component', () => { ...@@ -15,15 +16,16 @@ describe('First Class Group Dashboard Component', () => {
const emptyStateSvgPath = 'empty-state-path'; const emptyStateSvgPath = 'empty-state-path';
const groupFullPath = 'group-full-path'; const groupFullPath = 'group-full-path';
const findDashboardLayout = () => wrapper.find(SecurityDashboardLayout); const findDashboardLayout = () => wrapper.findComponent(SecurityDashboardLayout);
const findGroupVulnerabilities = () => wrapper.find(FirstClassGroupVulnerabilities); const findGroupVulnerabilities = () => wrapper.findComponent(FirstClassGroupVulnerabilities);
const findCsvExportButton = () => wrapper.find(CsvExportButton); const findCsvExportButton = () => wrapper.findComponent(CsvExportButton);
const findFilters = () => wrapper.find(Filters); const findFilters = () => wrapper.findComponent(Filters);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findEmptyState = () => wrapper.find(DashboardNotConfigured); const findEmptyState = () => wrapper.findComponent(DashboardNotConfigured);
const findVulnerabilitiesCountList = () => wrapper.find(VulnerabilitiesCountList); const findVulnerabilitiesCountList = () => wrapper.findComponent(VulnerabilitiesCountList);
const findSurveyRequestBanner = () => wrapper.findComponent(SurveyRequestBanner);
const createWrapper = ({ data } = {}) => { const createWrapper = ({ data, loading = false } = {}) => {
return shallowMount(FirstClassGroupDashboard, { return shallowMount(FirstClassGroupDashboard, {
propsData: { propsData: {
dashboardDocumentation, dashboardDocumentation,
...@@ -34,6 +36,13 @@ describe('First Class Group Dashboard Component', () => { ...@@ -34,6 +36,13 @@ describe('First Class Group Dashboard Component', () => {
stubs: { stubs: {
SecurityDashboardLayout, SecurityDashboardLayout,
}, },
mocks: {
$apollo: {
queries: {
projects: { loading },
},
},
},
}); });
}; };
...@@ -43,7 +52,7 @@ describe('First Class Group Dashboard Component', () => { ...@@ -43,7 +52,7 @@ describe('First Class Group Dashboard Component', () => {
describe('when loading', () => { describe('when loading', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createWrapper(); wrapper = createWrapper({ loading: true });
}); });
it('loading button should be visible', () => { it('loading button should be visible', () => {
...@@ -53,6 +62,10 @@ describe('First Class Group Dashboard Component', () => { ...@@ -53,6 +62,10 @@ describe('First Class Group Dashboard Component', () => {
it('should not display the dashboard not configured component', () => { it('should not display the dashboard not configured component', () => {
expect(findEmptyState().exists()).toBe(false); expect(findEmptyState().exists()).toBe(false);
}); });
it('should not show the survey request banner', () => {
expect(findSurveyRequestBanner().exists()).toBe(false);
});
}); });
describe('when has projects', () => { describe('when has projects', () => {
...@@ -105,6 +118,10 @@ describe('First Class Group Dashboard Component', () => { ...@@ -105,6 +118,10 @@ describe('First Class Group Dashboard Component', () => {
filters: wrapper.vm.filters, filters: wrapper.vm.filters,
}); });
}); });
it('should show the survey request banner', () => {
expect(findSurveyRequestBanner().exists()).toBe(true);
});
}); });
describe('when has no projects', () => { describe('when has no projects', () => {
...@@ -125,5 +142,9 @@ describe('First Class Group Dashboard Component', () => { ...@@ -125,5 +142,9 @@ describe('First Class Group Dashboard Component', () => {
it('should display the dashboard not configured component', () => { it('should display the dashboard not configured component', () => {
expect(findEmptyState().exists()).toBe(true); expect(findEmptyState().exists()).toBe(true);
}); });
it('should show the survey request banner', () => {
expect(findSurveyRequestBanner().exists()).toBe(true);
});
}); });
}); });
import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import CsvExportButton from 'ee/security_dashboard/components/csv_export_button.vue'; import CsvExportButton from 'ee/security_dashboard/components/csv_export_button.vue';
import DashboardNotConfigured from 'ee/security_dashboard/components/empty_states/instance_dashboard_not_configured.vue'; import DashboardNotConfigured from 'ee/security_dashboard/components/empty_states/instance_dashboard_not_configured.vue';
...@@ -5,6 +6,7 @@ import FirstClassInstanceDashboard from 'ee/security_dashboard/components/first_ ...@@ -5,6 +6,7 @@ import FirstClassInstanceDashboard from 'ee/security_dashboard/components/first_
import FirstClassInstanceVulnerabilities from 'ee/security_dashboard/components/first_class_instance_security_dashboard_vulnerabilities.vue'; import FirstClassInstanceVulnerabilities from 'ee/security_dashboard/components/first_class_instance_security_dashboard_vulnerabilities.vue';
import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue'; import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue';
import SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue'; import SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue';
import SurveyRequestBanner from 'ee/security_dashboard/components/survey_request_banner.vue';
import VulnerabilitiesCountList from 'ee/security_dashboard/components/vulnerability_count_list.vue'; import VulnerabilitiesCountList from 'ee/security_dashboard/components/vulnerability_count_list.vue';
describe('First Class Instance Dashboard Component', () => { describe('First Class Instance Dashboard Component', () => {
...@@ -14,12 +16,14 @@ describe('First Class Instance Dashboard Component', () => { ...@@ -14,12 +16,14 @@ describe('First Class Instance Dashboard Component', () => {
$apollo: { queries: { projects: { loading } } }, $apollo: { queries: { projects: { loading } } },
}); });
const findInstanceVulnerabilities = () => wrapper.find(FirstClassInstanceVulnerabilities); const findInstanceVulnerabilities = () =>
const findCsvExportButton = () => wrapper.find(CsvExportButton); wrapper.findComponent(FirstClassInstanceVulnerabilities);
const findEmptyState = () => wrapper.find(DashboardNotConfigured); const findCsvExportButton = () => wrapper.findComponent(CsvExportButton);
const findFilters = () => wrapper.find(Filters); const findEmptyState = () => wrapper.findComponent(DashboardNotConfigured);
const findVulnerabilitiesCountList = () => wrapper.find(VulnerabilitiesCountList); const findFilters = () => wrapper.findComponent(Filters);
const findVulnerabilitiesCountList = () => wrapper.findComponent(VulnerabilitiesCountList);
const findHeader = () => wrapper.find('[data-testid="header"]'); const findHeader = () => wrapper.find('[data-testid="header"]');
const findSurveyBanner = () => wrapper.findComponent(SurveyRequestBanner);
const createWrapper = ({ data = {}, stubs, mocks = defaultMocks() }) => { const createWrapper = ({ data = {}, stubs, mocks = defaultMocks() }) => {
return shallowMount(FirstClassInstanceDashboard, { return shallowMount(FirstClassInstanceDashboard, {
...@@ -58,6 +62,10 @@ describe('First Class Instance Dashboard Component', () => { ...@@ -58,6 +62,10 @@ describe('First Class Instance Dashboard Component', () => {
}); });
}); });
it('should show the survey banner', () => {
expect(findSurveyBanner().exists()).toBe(true);
});
it('has filters', () => { it('has filters', () => {
expect(findFilters().exists()).toBe(true); expect(findFilters().exists()).toBe(true);
}); });
...@@ -93,10 +101,12 @@ describe('First Class Instance Dashboard Component', () => { ...@@ -93,10 +101,12 @@ describe('First Class Instance Dashboard Component', () => {
}); });
}); });
it('does not render the export button, vulnerabilities count list, or header', () => { it('only shows the loading icon', () => {
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
expect(findCsvExportButton().exists()).toBe(false); expect(findCsvExportButton().exists()).toBe(false);
expect(findVulnerabilitiesCountList().exists()).toBe(false); expect(findVulnerabilitiesCountList().exists()).toBe(false);
expect(findHeader().exists()).toBe(false); expect(findHeader().exists()).toBe(false);
expect(findSurveyBanner().exists()).toBe(false);
}); });
}); });
...@@ -109,8 +119,9 @@ describe('First Class Instance Dashboard Component', () => { ...@@ -109,8 +119,9 @@ describe('First Class Instance Dashboard Component', () => {
}); });
}); });
it('only renders the empty state', () => { it('only renders the empty state and survey banner', () => {
expect(findEmptyState().exists()).toBe(true); expect(findEmptyState().exists()).toBe(true);
expect(findSurveyBanner().exists()).toBe(true);
expect(findCsvExportButton().exists()).toBe(false); expect(findCsvExportButton().exists()).toBe(false);
expect(findInstanceVulnerabilities().exists()).toBe(false); expect(findInstanceVulnerabilities().exists()).toBe(false);
expect(findFilters().exists()).toBe(false); expect(findFilters().exists()).toBe(false);
......
...@@ -10,6 +10,7 @@ import Filters from 'ee/security_dashboard/components/first_class_vulnerability_ ...@@ -10,6 +10,7 @@ import Filters from 'ee/security_dashboard/components/first_class_vulnerability_
import ProjectPipelineStatus from 'ee/security_dashboard/components/project_pipeline_status.vue'; import ProjectPipelineStatus from 'ee/security_dashboard/components/project_pipeline_status.vue';
import ProjectVulnerabilitiesApp from 'ee/security_dashboard/components/project_vulnerabilities.vue'; import ProjectVulnerabilitiesApp from 'ee/security_dashboard/components/project_vulnerabilities.vue';
import SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue'; import SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue';
import SurveyRequestBanner from 'ee/security_dashboard/components/survey_request_banner.vue';
import VulnerabilityCountList from 'ee/security_dashboard/components/vulnerability_count_list.vue'; import VulnerabilityCountList from 'ee/security_dashboard/components/vulnerability_count_list.vue';
const props = { const props = {
...@@ -38,13 +39,14 @@ const filters = { foo: 'bar' }; ...@@ -38,13 +39,14 @@ const filters = { foo: 'bar' };
describe('First class Project Security Dashboard component', () => { describe('First class Project Security Dashboard component', () => {
let wrapper; let wrapper;
const findFilters = () => wrapper.find(Filters); const findFilters = () => wrapper.findComponent(Filters);
const findProjectPipelineStatus = () => wrapper.find(ProjectPipelineStatus); const findProjectPipelineStatus = () => wrapper.findComponent(ProjectPipelineStatus);
const findVulnerabilities = () => wrapper.find(ProjectVulnerabilitiesApp); const findVulnerabilities = () => wrapper.findComponent(ProjectVulnerabilitiesApp);
const findVulnerabilityCountList = () => wrapper.find(VulnerabilityCountList); const findVulnerabilityCountList = () => wrapper.findComponent(VulnerabilityCountList);
const findUnconfiguredState = () => wrapper.find(ReportsNotConfigured); const findUnconfiguredState = () => wrapper.findComponent(ReportsNotConfigured);
const findCsvExportButton = () => wrapper.find(CsvExportButton); const findCsvExportButton = () => wrapper.findComponent(CsvExportButton);
const findAutoFixUserCallout = () => wrapper.find(AutoFixUserCallout); const findAutoFixUserCallout = () => wrapper.findComponent(AutoFixUserCallout);
const findSurveyRequestBanner = () => wrapper.findComponent(SurveyRequestBanner);
const createComponent = (options) => { const createComponent = (options) => {
wrapper = shallowMount(FirstClassProjectSecurityDashboard, { wrapper = shallowMount(FirstClassProjectSecurityDashboard, {
...@@ -113,6 +115,10 @@ describe('First class Project Security Dashboard component', () => { ...@@ -113,6 +115,10 @@ describe('First class Project Security Dashboard component', () => {
it('should display the project pipeline status', () => { it('should display the project pipeline status', () => {
expect(findProjectPipelineStatus()).toExist(); expect(findProjectPipelineStatus()).toExist();
}); });
it('should show the survey request banner', () => {
expect(findSurveyRequestBanner().exists()).toBe(true);
});
}); });
describe('auto-fix user callout', () => { describe('auto-fix user callout', () => {
...@@ -199,5 +205,9 @@ describe('First class Project Security Dashboard component', () => { ...@@ -199,5 +205,9 @@ describe('First class Project Security Dashboard component', () => {
it('displays the unconfigured state', () => { it('displays the unconfigured state', () => {
expect(findUnconfiguredState().exists()).toBe(true); expect(findUnconfiguredState().exists()).toBe(true);
}); });
it('shows the survey request banner', () => {
expect(findSurveyRequestBanner().exists()).toBe(true);
});
}); });
}); });
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import SecurityChartsLayout from 'ee/security_dashboard/components/security_charts_layout.vue'; import SecurityChartsLayout from 'ee/security_dashboard/components/security_charts_layout.vue';
import SurveyRequestBanner from 'ee/security_dashboard/components/survey_request_banner.vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper';
describe('Security Charts Layout component', () => { describe('Security Charts Layout component', () => {
...@@ -12,6 +13,7 @@ describe('Security Charts Layout component', () => { ...@@ -12,6 +13,7 @@ describe('Security Charts Layout component', () => {
const findDummyComponent = () => wrapper.findComponent(DummyComponent); const findDummyComponent = () => wrapper.findComponent(DummyComponent);
const findTitle = () => wrapper.findByTestId('title'); const findTitle = () => wrapper.findByTestId('title');
const findSurveyBanner = () => wrapper.findComponent(SurveyRequestBanner);
const createWrapper = (slots) => { const createWrapper = (slots) => {
wrapper = extendedWrapper(shallowMount(SecurityChartsLayout, { slots })); wrapper = extendedWrapper(shallowMount(SecurityChartsLayout, { slots }));
...@@ -21,18 +23,20 @@ describe('Security Charts Layout component', () => { ...@@ -21,18 +23,20 @@ describe('Security Charts Layout component', () => {
wrapper.destroy(); wrapper.destroy();
}); });
it('should render the default slot', () => { it('should render the default slot and survey banner', () => {
createWrapper({ default: DummyComponent }); createWrapper({ default: DummyComponent });
expect(findDummyComponent().exists()).toBe(true); expect(findDummyComponent().exists()).toBe(true);
expect(findTitle().exists()).toBe(true); expect(findTitle().exists()).toBe(true);
expect(findSurveyBanner().exists()).toBe(true);
}); });
it('should render the empty-state slot', () => { it('should render the empty-state slot and survey banner', () => {
createWrapper({ 'empty-state': DummyComponent }); createWrapper({ 'empty-state': DummyComponent });
expect(findDummyComponent().exists()).toBe(true); expect(findDummyComponent().exists()).toBe(true);
expect(findTitle().exists()).toBe(false); expect(findTitle().exists()).toBe(false);
expect(findSurveyBanner().exists()).toBe(true);
}); });
it('should render the loading slot', () => { it('should render the loading slot', () => {
...@@ -40,5 +44,6 @@ describe('Security Charts Layout component', () => { ...@@ -40,5 +44,6 @@ describe('Security Charts Layout component', () => {
expect(findDummyComponent().exists()).toBe(true); expect(findDummyComponent().exists()).toBe(true);
expect(findTitle().exists()).toBe(false); expect(findTitle().exists()).toBe(false);
expect(findSurveyBanner().exists()).toBe(false);
}); });
}); });
import { GlBanner, GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import SurveyRequestBanner from 'ee/security_dashboard/components/survey_request_banner.vue';
import {
SURVEY_BANNER_LOCAL_STORAGE_KEY,
SURVEY_BANNER_CURRENT_ID,
} from 'ee/security_dashboard/constants';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import toast from '~/vue_shared/plugins/global_toast';
jest.mock('~/vue_shared/plugins/global_toast');
describe('Survey Request Banner component', () => {
let wrapper;
const surveyRequestSvgPath = 'icon.svg';
const findGlBanner = () => wrapper.findComponent(GlBanner);
const findAskLaterButton = () => wrapper.findByTestId('ask-later-button');
const getOffsetDateString = (days) => {
const date = new Date();
date.setDate(date.getDate() + days);
return date.toISOString();
};
const createWrapper = () => {
wrapper = extendedWrapper(
shallowMount(SurveyRequestBanner, {
provide: { surveyRequestSvgPath },
stubs: { GlBanner, GlButton, LocalStorageSync },
}),
);
};
beforeEach(() => {
gon.features = {};
});
afterEach(() => {
wrapper.destroy();
localStorage.removeItem(SURVEY_BANNER_LOCAL_STORAGE_KEY);
});
describe('feature flag disabled', () => {
it('should not show banner regardless of localStorage value', () => {
[
getOffsetDateString(1),
getOffsetDateString(-1),
SURVEY_BANNER_CURRENT_ID,
'SOME OTHER ID',
].forEach((localStorageValue) => {
localStorage.setItem(SURVEY_BANNER_LOCAL_STORAGE_KEY, localStorageValue);
createWrapper();
expect(findGlBanner().exists()).toBe(false);
});
});
});
describe('feature flag enabled', () => {
beforeEach(() => {
gon.features.vulnerabilityManagementSurvey = true;
});
it('shows the banner with the correct components and props', () => {
createWrapper();
const { title, buttonText, description } = wrapper.vm.$options.i18n;
expect(findGlBanner().html()).toContain(description);
expect(findAskLaterButton().exists()).toBe(true);
expect(findGlBanner().props()).toMatchObject({
title,
buttonText,
svgPath: surveyRequestSvgPath,
});
});
it.each`
showOrHide | phrase | localStorageValue | isShown
${'hides'} | ${'a future date'} | ${getOffsetDateString(1)} | ${false}
${'shows'} | ${'a past date'} | ${getOffsetDateString(-1)} | ${true}
${'hides'} | ${'the current survey ID'} | ${SURVEY_BANNER_CURRENT_ID} | ${false}
${'shows'} | ${'a different survey ID'} | ${'SOME OTHER ID'} | ${true}
`(
'$showOrHide the banner if the localStorage value is $phrase',
async ({ localStorageValue, isShown }) => {
localStorage.setItem(SURVEY_BANNER_LOCAL_STORAGE_KEY, localStorageValue);
createWrapper();
await wrapper.vm.$nextTick();
expect(findGlBanner().exists()).toBe(isShown);
},
);
});
describe('closing the banner', () => {
beforeEach(() => {
gon.features.vulnerabilityManagementSurvey = true;
});
it('hides the banner and will set it to reshow later if the "Ask again later" button is clicked', async () => {
createWrapper();
expect(findGlBanner().exists()).toBe(true);
findAskLaterButton().vm.$emit('click');
await wrapper.vm.$nextTick();
const date = new Date(localStorage.getItem(SURVEY_BANNER_LOCAL_STORAGE_KEY));
expect(findGlBanner().exists()).toBe(false);
expect(toast).toHaveBeenCalledTimes(1);
expect(date > new Date()).toBe(true);
});
it('hides the banner and sets it to never show again if the close button is clicked', async () => {
createWrapper();
expect(findGlBanner().exists()).toBe(true);
findGlBanner().vm.$emit('close');
await wrapper.vm.$nextTick();
expect(findGlBanner().exists()).toBe(false);
expect(localStorage.getItem(SURVEY_BANNER_LOCAL_STORAGE_KEY)).toBe(SURVEY_BANNER_CURRENT_ID);
});
});
});
...@@ -132,6 +132,7 @@ module API ...@@ -132,6 +132,7 @@ module API
.preload(:project_setting) .preload(:project_setting)
.preload(:container_expiration_policy) .preload(:container_expiration_policy)
.preload(:auto_devops) .preload(:auto_devops)
.preload(:service_desk_setting)
.preload(project_group_links: { group: :route }, .preload(project_group_links: { group: :route },
fork_network: :root_project, fork_network: :root_project,
fork_network_member: :forked_from_project, fork_network_member: :forked_from_project,
......
...@@ -4194,6 +4194,9 @@ msgstr "" ...@@ -4194,6 +4194,9 @@ msgstr ""
msgid "Ascending" msgid "Ascending"
msgstr "" msgstr ""
msgid "Ask again later"
msgstr ""
msgid "Ask your group maintainer to set up a group runner." msgid "Ask your group maintainer to set up a group runner."
msgstr "" msgstr ""
...@@ -27473,6 +27476,9 @@ msgstr "" ...@@ -27473,6 +27476,9 @@ msgstr ""
msgid "SecurityReports|Although it's rare to have no vulnerabilities, it can happen. Check your settings to make sure you've set up your dashboard correctly." msgid "SecurityReports|Although it's rare to have no vulnerabilities, it can happen. Check your settings to make sure you've set up your dashboard correctly."
msgstr "" msgstr ""
msgid "SecurityReports|At GitLab, we're all about iteration and feedback. That's why we are reaching out to customers like you to help guide what we work on this year for Vulnerability Management. We have a lot of exciting ideas and ask that you assist us by taking a short survey %{boldStart}no longer than 10 minutes%{boldEnd} to evaluate a few of our potential features."
msgstr ""
msgid "SecurityReports|Change status" msgid "SecurityReports|Change status"
msgstr "" msgstr ""
...@@ -27632,6 +27638,9 @@ msgstr "" ...@@ -27632,6 +27638,9 @@ msgstr ""
msgid "SecurityReports|Status" msgid "SecurityReports|Status"
msgstr "" msgstr ""
msgid "SecurityReports|Take survey"
msgstr ""
msgid "SecurityReports|There was an error adding the comment." msgid "SecurityReports|There was an error adding the comment."
msgstr "" msgstr ""
...@@ -27677,6 +27686,9 @@ msgstr "" ...@@ -27677,6 +27686,9 @@ msgstr ""
msgid "SecurityReports|Upgrade to manage vulnerabilities" msgid "SecurityReports|Upgrade to manage vulnerabilities"
msgstr "" msgstr ""
msgid "SecurityReports|Vulnerability Management feature survey"
msgstr ""
msgid "SecurityReports|Vulnerability Report" msgid "SecurityReports|Vulnerability Report"
msgstr "" msgstr ""
...@@ -27692,6 +27704,9 @@ msgstr "" ...@@ -27692,6 +27704,9 @@ msgstr ""
msgid "SecurityReports|You must sign in as an authorized user to see this report" msgid "SecurityReports|You must sign in as an authorized user to see this report"
msgstr "" msgstr ""
msgid "SecurityReports|Your feedback is important to us! We will ask again in a week."
msgstr ""
msgid "See GitLab's %{password_policy_guidelines}" msgid "See GitLab's %{password_policy_guidelines}"
msgstr "" msgstr ""
......
...@@ -114,7 +114,7 @@ module QA ...@@ -114,7 +114,7 @@ module QA
end end
context 'Geo', :orchestrated, :geo do context 'Geo', :orchestrated, :geo do
it 'replicates a published pypi package to the Geo secondary site', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1120' do it 'replicates a published pypi package to the Geo secondary site', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1120', quarantine: { issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/325556', type: :investigating } do
QA::Runtime::Logger.debug('Visiting the secondary Geo site') QA::Runtime::Logger.debug('Visiting the secondary Geo site')
QA::Flow::Login.while_signed_in(address: :geo_secondary) do QA::Flow::Login.while_signed_in(address: :geo_secondary) do
......
...@@ -290,7 +290,6 @@ RSpec.describe 'Project' do ...@@ -290,7 +290,6 @@ RSpec.describe 'Project' do
let(:project) { create(:forked_project_with_submodules) } let(:project) { create(:forked_project_with_submodules) }
before do before do
stub_feature_flags(refactor_blob_viewer: false)
project.add_maintainer(user) project.add_maintainer(user)
sign_in user sign_in user
visit project_path(project) visit project_path(project)
......
...@@ -835,6 +835,29 @@ RSpec.describe API::Projects do ...@@ -835,6 +835,29 @@ RSpec.describe API::Projects do
end.not_to exceed_query_limit(control.count) end.not_to exceed_query_limit(control.count)
end end
end end
context 'when service desk is enabled', :use_clean_rails_memory_store_caching do
let_it_be(:admin) { create(:admin) }
it 'avoids N+1 queries' do
allow(Gitlab::ServiceDeskEmail).to receive(:enabled?).and_return(true)
allow(Gitlab::IncomingEmail).to receive(:enabled?).and_return(true)
get api('/projects', admin)
create(:project, :public, :service_desk_enabled, namespace: admin.namespace)
control = ActiveRecord::QueryRecorder.new do
get api('/projects', admin)
end
create_list(:project, 2, :public, :service_desk_enabled, namespace: admin.namespace)
expect do
get api('/projects', admin)
end.not_to exceed_query_limit(control.count)
end
end
end end
describe 'POST /projects' do describe 'POST /projects' do
......
...@@ -277,6 +277,11 @@ RSpec.configure do |config| ...@@ -277,6 +277,11 @@ RSpec.configure do |config|
# Vue issues page has feature parity with the current Haml page # Vue issues page has feature parity with the current Haml page
stub_feature_flags(vue_issues_list: false) stub_feature_flags(vue_issues_list: false)
# Disable `refactor_blob_viewer` as we refactor
# the blob viewer. See the follwing epic for more:
# https://gitlab.com/groups/gitlab-org/-/epics/5531
stub_feature_flags(refactor_blob_viewer: false)
allow(Gitlab::GitalyClient).to receive(:can_use_disk?).and_return(enable_rugged) allow(Gitlab::GitalyClient).to receive(:can_use_disk?).and_return(enable_rugged)
else else
unstub_all_feature_flags unstub_all_feature_flags
......
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