Commit 6656925d authored by Paul Gascou-Vaillancourt's avatar Paul Gascou-Vaillancourt Committed by Natalia Tepluhina

Add "Running" and "Finished" on-demand scans tabs

parent 4c1a1d61
...@@ -9,5 +9,9 @@ module Routing ...@@ -9,5 +9,9 @@ module Routing
def graphql_etag_pipeline_sha_path(sha) def graphql_etag_pipeline_sha_path(sha)
[api_graphql_path, "pipelines/sha/#{sha}"].join(':') [api_graphql_path, "pipelines/sha/#{sha}"].join(':')
end end
def graphql_etag_project_on_demand_scan_counts_path(project)
[api_graphql_path, "on_demand_scan/counts/#{project.full_path}"].join(':')
end
end end
end end
...@@ -60,6 +60,10 @@ module Ci ...@@ -60,6 +60,10 @@ module Ci
url_helpers.graphql_etag_pipeline_sha_path(sha) url_helpers.graphql_etag_pipeline_sha_path(sha)
end end
def graphql_project_on_demand_scan_counts_path(project)
url_helpers.graphql_etag_project_on_demand_scan_counts_path(project)
end
# Updates ETag caches of a pipeline. # Updates ETag caches of a pipeline.
# #
# This logic resides in a separate method so that EE can more easily extend # This logic resides in a separate method so that EE can more easily extend
...@@ -82,6 +86,8 @@ module Ci ...@@ -82,6 +86,8 @@ module Ci
store.touch(graphql_pipeline_path(relative_pipeline)) store.touch(graphql_pipeline_path(relative_pipeline))
store.touch(graphql_pipeline_sha_path(relative_pipeline.sha)) store.touch(graphql_pipeline_sha_path(relative_pipeline.sha))
end end
store.touch(graphql_project_on_demand_scan_counts_path(project))
end end
def url_helpers def url_helpers
......
...@@ -2,8 +2,21 @@ ...@@ -2,8 +2,21 @@
import { GlButton, GlLink, GlSprintf, GlTabs } from '@gitlab/ui'; import { GlButton, GlLink, GlSprintf, GlTabs } from '@gitlab/ui';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import ConfigurationPageLayout from 'ee/security_configuration/components/configuration_page_layout.vue'; import ConfigurationPageLayout from 'ee/security_configuration/components/configuration_page_layout.vue';
import { HELP_PAGE_PATH } from '../constants'; import {
getQueryHeaders,
toggleQueryPollingByVisibility,
} from '~/pipelines/components/graph/utils';
import onDemandScanCounts from '../graphql/on_demand_scan_counts.query.graphql';
import {
HELP_PAGE_PATH,
PIPELINE_TABS_KEYS,
PIPELINES_COUNT_POLL_INTERVAL,
PIPELINES_SCOPE_RUNNING,
PIPELINES_SCOPE_FINISHED,
} from '../constants';
import AllTab from './tabs/all.vue'; import AllTab from './tabs/all.vue';
import RunningTab from './tabs/running.vue';
import FinishedTab from './tabs/finished.vue';
import EmptyState from './empty_state.vue'; import EmptyState from './empty_state.vue';
export default { export default {
...@@ -15,14 +28,39 @@ export default { ...@@ -15,14 +28,39 @@ export default {
GlTabs, GlTabs,
ConfigurationPageLayout, ConfigurationPageLayout,
AllTab, AllTab,
RunningTab,
FinishedTab,
EmptyState, EmptyState,
}, },
inject: ['newDastScanPath'], inject: ['newDastScanPath', 'projectPath', 'projectOnDemandScanCountsEtag'],
apollo: {
liveOnDemandScanCounts: {
query: onDemandScanCounts,
variables() {
return {
fullPath: this.projectPath,
runningScope: PIPELINES_SCOPE_RUNNING,
finishedScope: PIPELINES_SCOPE_FINISHED,
};
},
context() {
return getQueryHeaders(this.projectOnDemandScanCountsEtag);
},
update(data) {
return Object.fromEntries(
PIPELINE_TABS_KEYS.map((key) => {
const { count } = data[key].pipelines;
return [key, count];
}),
);
},
pollInterval: PIPELINES_COUNT_POLL_INTERVAL,
},
},
props: { props: {
pipelinesCount: { initialOnDemandScanCounts: {
type: Number, type: Object,
required: false, required: true,
default: 0,
}, },
}, },
data() { data() {
...@@ -31,14 +69,25 @@ export default { ...@@ -31,14 +69,25 @@ export default {
}; };
}, },
computed: { computed: {
onDemandScanCounts() {
return this.liveOnDemandScanCounts ?? this.initialOnDemandScanCounts;
},
hasData() { hasData() {
return this.pipelinesCount > 0; return this.onDemandScanCounts.all > 0;
}, },
tabs() { tabs() {
return { return {
all: { all: {
component: AllTab, component: AllTab,
itemsCount: this.pipelinesCount, itemsCount: this.onDemandScanCounts.all,
},
running: {
component: RunningTab,
itemsCount: this.onDemandScanCounts.running,
},
finished: {
component: FinishedTab,
itemsCount: this.onDemandScanCounts.finished,
}, },
}; };
}, },
...@@ -61,6 +110,12 @@ export default { ...@@ -61,6 +110,12 @@ export default {
this.activeTabIndex = tabIndex; this.activeTabIndex = tabIndex;
} }
}, },
mounted() {
toggleQueryPollingByVisibility(
this.$apollo.queries.liveOnDemandScanCounts,
PIPELINES_COUNT_POLL_INTERVAL,
);
},
i18n: { i18n: {
title: s__('OnDemandScans|On-demand scans'), title: s__('OnDemandScans|On-demand scans'),
newScanButtonLabel: s__('OnDemandScans|New DAST scan'), newScanButtonLabel: s__('OnDemandScans|New DAST scan'),
...@@ -93,9 +148,10 @@ export default { ...@@ -93,9 +148,10 @@ export default {
<gl-tabs v-model="activeTab"> <gl-tabs v-model="activeTab">
<component <component
:is="tab.component" :is="tab.component"
v-for="(tab, key) in tabs" v-for="(tab, key, index) in tabs"
:key="key" :key="key"
:items-count="tab.itemsCount" :items-count="tab.itemsCount"
:is-active="activeTab === index"
/> />
</gl-tabs> </gl-tabs>
</configuration-page-layout> </configuration-page-layout>
......
<script> <script>
import { __, s__ } from '~/locale'; import { __ } from '~/locale';
import onDemandScansQuery from '../../graphql/on_demand_scans.query.graphql'; import onDemandScansQuery from '../../graphql/on_demand_scans.query.graphql';
import { BASE_TABS_TABLE_FIELDS } from '../../constants';
import BaseTab from './base_tab.vue'; import BaseTab from './base_tab.vue';
export default { export default {
...@@ -8,36 +9,7 @@ export default { ...@@ -8,36 +9,7 @@ export default {
components: { components: {
BaseTab, BaseTab,
}, },
tableFields: [ tableFields: BASE_TABS_TABLE_FIELDS,
{
label: __('Status'),
key: 'detailedStatus',
columnClass: 'gl-w-15',
},
{
label: __('Name'),
key: 'dastProfile.name',
},
{
label: s__('OnDemandScans|Scan type'),
key: 'scanType',
columnClass: 'gl-w-13',
},
{
label: s__('OnDemandScans|Target'),
key: 'dastProfile.dastSiteProfile.targetUrl',
},
{
label: __('Start date'),
key: 'createdAt',
columnClass: 'gl-w-15',
},
{
label: __('Pipeline'),
key: 'id',
columnClass: 'gl-w-13',
},
],
i18n: { i18n: {
title: __('All'), title: __('All'),
}, },
......
...@@ -44,10 +44,19 @@ export default { ...@@ -44,10 +44,19 @@ export default {
}, },
inject: ['projectPath'], inject: ['projectPath'],
props: { props: {
isActive: {
type: Boolean,
required: true,
},
query: { query: {
type: Object, type: Object,
required: true, required: true,
}, },
queryVariables: {
type: Object,
required: false,
default: () => ({}),
},
title: { title: {
type: String, type: String,
required: true, required: true,
...@@ -79,6 +88,7 @@ export default { ...@@ -79,6 +88,7 @@ export default {
variables() { variables() {
return { return {
fullPath: this.projectPath, fullPath: this.projectPath,
...this.queryVariables,
...this.cursor, ...this.cursor,
}; };
}, },
...@@ -93,6 +103,9 @@ export default { ...@@ -93,6 +103,9 @@ export default {
error() { error() {
this.hasError = true; this.hasError = true;
}, },
skip() {
return !this.isActive;
},
pollInterval: PIPELINES_POLL_INTERVAL, pollInterval: PIPELINES_POLL_INTERVAL,
}, },
}, },
...@@ -132,6 +145,11 @@ export default { ...@@ -132,6 +145,11 @@ export default {
}, },
}, },
watch: { watch: {
isActive(isActive) {
if (isActive) {
this.resetCursor();
}
},
hasPipelines(hasPipelines) { hasPipelines(hasPipelines) {
if (this.hasError && hasPipelines) { if (this.hasError && hasPipelines) {
this.hasError = false; this.hasError = false;
......
<script>
import { __, s__ } from '~/locale';
import onDemandScansQuery from '../../graphql/on_demand_scans.query.graphql';
import { BASE_TABS_TABLE_FIELDS, PIPELINES_SCOPE_FINISHED, LEARN_MORE_TEXT } from '../../constants';
import BaseTab from './base_tab.vue';
export default {
query: onDemandScansQuery,
queryVariables: {
scope: PIPELINES_SCOPE_FINISHED,
},
components: {
BaseTab,
},
tableFields: BASE_TABS_TABLE_FIELDS,
i18n: {
title: __('Finished'),
emptyStateTitle: s__('OnDemandScans|There are no finished scans.'),
emptyStateText: LEARN_MORE_TEXT,
},
};
</script>
<template>
<base-tab
:query="$options.query"
:query-variables="$options.queryVariables"
:title="$options.i18n.title"
:fields="$options.tableFields"
:empty-state-title="$options.i18n.emptyStateTitle"
:empty-state-text="$options.i18n.emptyStateText"
v-bind="$attrs"
/>
</template>
<script>
import { __, s__ } from '~/locale';
import onDemandScansQuery from '../../graphql/on_demand_scans.query.graphql';
import { BASE_TABS_TABLE_FIELDS, PIPELINES_SCOPE_RUNNING, LEARN_MORE_TEXT } from '../../constants';
import BaseTab from './base_tab.vue';
export default {
query: onDemandScansQuery,
queryVariables: {
scope: PIPELINES_SCOPE_RUNNING,
},
components: {
BaseTab,
},
tableFields: BASE_TABS_TABLE_FIELDS,
i18n: {
title: __('Running'),
emptyStateTitle: s__('OnDemandScans|There are no running scans.'),
emptyStateText: LEARN_MORE_TEXT,
},
};
</script>
<template>
<base-tab
:query="$options.query"
:query-variables="$options.queryVariables"
:title="$options.i18n.title"
:fields="$options.tableFields"
:empty-state-title="$options.i18n.emptyStateTitle"
:empty-state-text="$options.i18n.emptyStateText"
v-bind="$attrs"
/>
</template>
import { __, s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper'; import { helpPagePath } from '~/helpers/help_page_helper';
export const HELP_PAGE_PATH = helpPagePath('user/application_security/dast/index', { export const HELP_PAGE_PATH = helpPagePath('user/application_security/dast/index', {
anchor: 'on-demand-scans', anchor: 'on-demand-scans',
}); });
export const LEARN_MORE_TEXT = s__(
'OnDemandScans|%{learnMoreLinkStart}Learn more about on-demand scans%{learnMoreLinkEnd}.',
);
export const PIPELINE_TABS_KEYS = ['all', 'running', 'finished'];
export const PIPELINES_PER_PAGE = 20; export const PIPELINES_PER_PAGE = 20;
export const PIPELINES_POLL_INTERVAL = 1000; export const PIPELINES_POLL_INTERVAL = 1000;
export const PIPELINES_COUNT_POLL_INTERVAL = 1000;
export const PIPELINES_SCOPE_RUNNING = 'RUNNING';
export const PIPELINES_SCOPE_FINISHED = 'FINISHED';
export const BASE_TABS_TABLE_FIELDS = [
{
label: __('Status'),
key: 'detailedStatus',
columnClass: 'gl-w-15',
},
{
label: __('Name'),
key: 'dastProfile.name',
},
{
label: s__('OnDemandScans|Scan type'),
key: 'scanType',
columnClass: 'gl-w-13',
},
{
label: s__('OnDemandScans|Target'),
key: 'dastProfile.dastSiteProfile.targetUrl',
},
{
label: __('Start date'),
key: 'createdAt',
columnClass: 'gl-w-15',
},
{
label: __('Pipeline'),
key: 'id',
columnClass: 'gl-w-13',
},
];
query onDemandScanCounts(
$fullPath: ID!
$runningScope: PipelineScopeEnum
$finishedScope: PipelineScopeEnum
) {
all: project(fullPath: $fullPath) {
pipelines(source: "ondemand_dast_scan") {
count
}
}
running: project(fullPath: $fullPath) {
pipelines(source: "ondemand_dast_scan", scope: $runningScope) {
count
}
}
finished: project(fullPath: $fullPath) {
pipelines(source: "ondemand_dast_scan", scope: $finishedScope) {
count
}
}
}
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" #import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query allPipelinesCount($fullPath: ID!, $first: Int, $last: Int, $after: String, $before: String) { query onDemandScans(
$fullPath: ID!
$scope: PipelineScopeEnum
$first: Int
$last: Int
$after: String
$before: String
) {
project(fullPath: $fullPath) { project(fullPath: $fullPath) {
pipelines( pipelines(
source: "ondemand_dast_scan" source: "ondemand_dast_scan"
scope: $scope
first: $first first: $first
last: $last last: $last
after: $after after: $after
......
...@@ -4,7 +4,12 @@ import createDefaultClient from '~/lib/graphql'; ...@@ -4,7 +4,12 @@ import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo); Vue.use(VueApollo);
const defaultClient = createDefaultClient(); const defaultClient = createDefaultClient(
{},
{
useGet: true,
},
);
export default new VueApollo({ export default new VueApollo({
defaultClient, defaultClient,
......
...@@ -8,8 +8,13 @@ export default () => { ...@@ -8,8 +8,13 @@ export default () => {
if (!el) { if (!el) {
return null; return null;
} }
const {
const { pipelinesCount, projectPath, newDastScanPath, emptyStateSvgPath } = el.dataset; projectPath,
newDastScanPath,
emptyStateSvgPath,
projectOnDemandScanCountsEtag,
} = el.dataset;
const initialOnDemandScanCounts = JSON.parse(el.dataset.onDemandScanCounts);
return new Vue({ return new Vue({
el, el,
...@@ -19,11 +24,12 @@ export default () => { ...@@ -19,11 +24,12 @@ export default () => {
projectPath, projectPath,
newDastScanPath, newDastScanPath,
emptyStateSvgPath, emptyStateSvgPath,
projectOnDemandScanCountsEtag,
}, },
render(h) { render(h) {
return h(OnDemandScans, { return h(OnDemandScans, {
props: { props: {
pipelinesCount: Number(pipelinesCount), initialOnDemandScanCounts,
}, },
}); });
}, },
......
...@@ -3,8 +3,16 @@ ...@@ -3,8 +3,16 @@
module Projects::OnDemandScansHelper module Projects::OnDemandScansHelper
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def on_demand_scans_data(project) def on_demand_scans_data(project)
on_demand_scans = project.all_pipelines.where(source: Enums::Ci::Pipeline.sources[:ondemand_dast_scan])
running_scan_count, finished_scan_count = count_running_and_finished_scans(on_demand_scans)
common_data(project).merge({ common_data(project).merge({
'pipelines-count' => project.all_pipelines.where(source: Enums::Ci::Pipeline.sources[:ondemand_dast_scan]).count, 'project-on-demand-scan-counts-etag' => graphql_etag_project_on_demand_scan_counts_path(project),
'on-demand-scan-counts' => {
all: on_demand_scans.length,
running: running_scan_count,
finished: finished_scan_count
}.to_json,
'new-dast-scan-path' => new_project_on_demand_scan_path(project), 'new-dast-scan-path' => new_project_on_demand_scan_path(project),
'empty-state-svg-path' => image_path('illustrations/empty-state/ondemand-scan-empty.svg') 'empty-state-svg-path' => image_path('illustrations/empty-state/ondemand-scan-empty.svg')
}) })
...@@ -30,4 +38,19 @@ module Projects::OnDemandScansHelper ...@@ -30,4 +38,19 @@ module Projects::OnDemandScansHelper
'project-path' => project.path_with_namespace 'project-path' => project.path_with_namespace
} }
end end
def count_running_and_finished_scans(on_demand_scans)
running_scan_count = 0
finished_scan_count = 0
on_demand_scans.each do |pipeline|
if %w[success failed canceled].include?(pipeline.status)
finished_scan_count += 1
elsif pipeline.status == "running"
running_scan_count += 1
end
end
[running_scan_count, finished_scan_count]
end
end end
...@@ -12,51 +12,81 @@ RSpec.describe 'On-demand DAST scans (GraphQL fixtures)' do ...@@ -12,51 +12,81 @@ RSpec.describe 'On-demand DAST scans (GraphQL fixtures)' do
let_it_be(:project) { create(:project, :repository, :public) } let_it_be(:project) { create(:project, :repository, :public) }
let_it_be(:dast_profile) { create(:dast_profile, project: project) } let_it_be(:dast_profile) { create(:dast_profile, project: project) }
path = 'on_demand_scans/graphql/on_demand_scans.query.graphql'
before do before do
stub_licensed_features(security_on_demand_scans: true) stub_licensed_features(security_on_demand_scans: true)
project.add_developer(current_user) project.add_developer(current_user)
end end
context 'with pipelines' do context 'project on demand scans count' do
path = 'on_demand_scans/graphql/on_demand_scan_counts.query.graphql'
let_it_be(:pipelines) do let_it_be(:pipelines) do
create_list( [
:ci_pipeline, create(:ci_pipeline, :success, source: :ondemand_dast_scan, sha: project.commit.id, project: project, user: current_user),
30, create(:ci_pipeline, :failed, source: :ondemand_dast_scan, sha: project.commit.id, project: project, user: current_user),
:success, create(:ci_pipeline, :running, source: :ondemand_dast_scan, sha: project.commit.id, project: project, user: current_user),
source: :ondemand_dast_scan, create(:ci_pipeline, :running, source: :ondemand_dast_scan, sha: project.commit.id, project: project, user: current_user)
sha: project.commit.id, ]
project: project,
user: current_user,
dast_profile: dast_profile
)
end end
it "graphql/#{path}.with_pipelines.json" do it "graphql/#{path}.json" do
query = get_graphql_query_as_string(path, ee: true) query = get_graphql_query_as_string(path, ee: true)
post_graphql(query, current_user: current_user, variables: { post_graphql(query, current_user: current_user, variables: {
fullPath: project.full_path, fullPath: project.full_path,
first: 20 runningScope: 'RUNNING',
finishedScope: 'FINISHED'
}) })
expect_graphql_errors_to_be_empty expect_graphql_errors_to_be_empty
expect(graphql_data_at(:project, :pipelines, :nodes)).to have_attributes(size: 20) expect(graphql_data_at(:all, :pipelines, :count)).to be(4)
expect(graphql_data_at(:running, :pipelines, :count)).to be(2)
expect(graphql_data_at(:finished, :pipelines, :count)).to be(2)
end end
end end
context 'without pipelines' do context 'pipelines list' do
it "graphql/#{path}.without_pipelines.json" do path = 'on_demand_scans/graphql/on_demand_scans.query.graphql'
query = get_graphql_query_as_string(path, ee: true)
post_graphql(query, current_user: current_user, variables: { context 'with pipelines' do
fullPath: project.full_path, let_it_be(:pipelines) do
first: 20 create_list(
}) :ci_pipeline,
30,
:success,
source: :ondemand_dast_scan,
sha: project.commit.id,
project: project,
user: current_user,
dast_profile: dast_profile
)
end
expect_graphql_errors_to_be_empty it "graphql/#{path}.with_pipelines.json" do
expect(graphql_data_at(:project, :pipelines, :nodes)).to be_empty query = get_graphql_query_as_string(path, ee: true)
post_graphql(query, current_user: current_user, variables: {
fullPath: project.full_path,
first: 20
})
expect_graphql_errors_to_be_empty
expect(graphql_data_at(:project, :pipelines, :nodes)).to have_attributes(size: 20)
end
end
context 'without pipelines' do
it "graphql/#{path}.without_pipelines.json" do
query = get_graphql_query_as_string(path, ee: true)
post_graphql(query, current_user: current_user, variables: {
fullPath: project.full_path,
first: 20
})
expect_graphql_errors_to_be_empty
expect(graphql_data_at(:project, :pipelines, :nodes)).to be_empty
end
end end
end end
end end
......
import { GlSprintf, GlTabs } from '@gitlab/ui'; import { GlSprintf, GlTabs } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { merge } from 'lodash'; import { merge } from 'lodash';
import onDemandScansCountsMock from 'test_fixtures/graphql/on_demand_scans/graphql/on_demand_scan_counts.query.graphql.json';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import OnDemandScans from 'ee/on_demand_scans/components/on_demand_scans.vue'; import OnDemandScans from 'ee/on_demand_scans/components/on_demand_scans.vue';
import { PIPELINE_TABS_KEYS } from 'ee/on_demand_scans/constants';
import ConfigurationPageLayout from 'ee/security_configuration/components/configuration_page_layout.vue'; import ConfigurationPageLayout from 'ee/security_configuration/components/configuration_page_layout.vue';
import { createRouter } from 'ee/on_demand_scans/router'; import { createRouter } from 'ee/on_demand_scans/router';
import AllTab from 'ee/on_demand_scans/components/tabs/all.vue'; import AllTab from 'ee/on_demand_scans/components/tabs/all.vue';
import EmptyState from 'ee/on_demand_scans/components/empty_state.vue'; import EmptyState from 'ee/on_demand_scans/components/empty_state.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import onDemandScansCounts from 'ee/on_demand_scans/graphql/on_demand_scan_counts.query.graphql';
import flushPromises from 'helpers/flush_promises';
Vue.use(VueApollo);
describe('OnDemandScans', () => { describe('OnDemandScans', () => {
let wrapper; let wrapper;
let router; let router;
let requestHandler;
// Props // Props
const newDastScanPath = '/on_demand_scans/new'; const newDastScanPath = '/on_demand_scans/new';
const projectPath = '/namespace/project';
const projectOnDemandScanCountsEtag = `/api/graphql:on_demand_scan/counts/${projectPath}`;
const nonEmptyInitialPipelineCounts = {
all: 12,
running: 3,
finished: 9,
};
const emptyInitialPipelineCounts = Object.fromEntries(PIPELINE_TABS_KEYS.map((key) => [key, 0]));
// Finders // Finders
const findNewScanLink = () => wrapper.findByTestId('new-scan-link'); const findNewScanLink = () => wrapper.findByTestId('new-scan-link');
...@@ -21,14 +39,22 @@ describe('OnDemandScans', () => { ...@@ -21,14 +39,22 @@ describe('OnDemandScans', () => {
const findAllTab = () => wrapper.findComponent(AllTab); const findAllTab = () => wrapper.findComponent(AllTab);
const findEmptyState = () => wrapper.findComponent(EmptyState); const findEmptyState = () => wrapper.findComponent(EmptyState);
// Helpers
const createMockApolloProvider = () => {
return createMockApollo([[onDemandScansCounts, requestHandler]]);
};
const createComponent = (options = {}) => { const createComponent = (options = {}) => {
wrapper = shallowMountExtended( wrapper = shallowMountExtended(
OnDemandScans, OnDemandScans,
merge( merge(
{ {
apolloProvider: createMockApolloProvider(),
router, router,
provide: { provide: {
newDastScanPath, newDastScanPath,
projectPath,
projectOnDemandScanCountsEtag,
}, },
stubs: { stubs: {
ConfigurationPageLayout, ConfigurationPageLayout,
...@@ -36,12 +62,18 @@ describe('OnDemandScans', () => { ...@@ -36,12 +62,18 @@ describe('OnDemandScans', () => {
GlTabs, GlTabs,
}, },
}, },
{
propsData: {
initialOnDemandScanCounts: nonEmptyInitialPipelineCounts,
},
},
options, options,
), ),
); );
}; };
beforeEach(() => { beforeEach(() => {
requestHandler = jest.fn().mockResolvedValue(onDemandScansCountsMock);
router = createRouter(); router = createRouter();
}); });
...@@ -50,18 +82,35 @@ describe('OnDemandScans', () => { ...@@ -50,18 +82,35 @@ describe('OnDemandScans', () => {
}); });
it('renders an empty state when there is no data', () => { it('renders an empty state when there is no data', () => {
createComponent(); createComponent({
propsData: {
initialOnDemandScanCounts: emptyInitialPipelineCounts,
},
});
expect(findEmptyState().exists()).toBe(true); expect(findEmptyState().exists()).toBe(true);
}); });
it('updates on-demand scans counts and shows the tabs once there is some data', async () => {
createComponent({
propsData: {
initialOnDemandScanCounts: emptyInitialPipelineCounts,
},
});
expect(findTabs().exists()).toBe(false);
expect(findEmptyState().exists()).toBe(true);
expect(requestHandler).toHaveBeenCalled();
await flushPromises();
expect(findTabs().exists()).toBe(true);
expect(findEmptyState().exists()).toBe(false);
});
describe('when there is data', () => { describe('when there is data', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ createComponent();
propsData: {
pipelinesCount: 12,
},
});
}); });
it('renders a link to the docs', () => { it('renders a link to the docs', () => {
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AllTab renders the base tab with the correct props 1`] = `
Array [
Object {
"columnClass": "gl-w-15",
"key": "detailedStatus",
"label": "Status",
},
Object {
"key": "dastProfile.name",
"label": "Name",
},
Object {
"columnClass": "gl-w-13",
"key": "scanType",
"label": "Scan type",
},
Object {
"key": "dastProfile.dastSiteProfile.targetUrl",
"label": "Target",
},
Object {
"columnClass": "gl-w-15",
"key": "createdAt",
"label": "Start date",
},
Object {
"columnClass": "gl-w-13",
"key": "id",
"label": "Pipeline",
},
]
`;
import { shallowMount } from '@vue/test-utils';
import AllTab from 'ee/on_demand_scans/components/tabs/all.vue';
import BaseTab from 'ee/on_demand_scans/components/tabs/base_tab.vue';
describe('AllTab', () => {
let wrapper;
// Finders
const findBaseTab = () => wrapper.findComponent(BaseTab);
const createComponent = (propsData) => {
wrapper = shallowMount(AllTab, {
propsData,
});
};
beforeEach(() => {
createComponent({
itemsCount: 12,
});
});
it('renders the base tab with the correct props', () => {
expect(findBaseTab().props('title')).toBe('All');
expect(findBaseTab().props('itemsCount')).toBe(12);
expect(findBaseTab().props('fields')).toMatchSnapshot();
});
});
...@@ -15,6 +15,7 @@ import waitForPromises from 'helpers/wait_for_promises'; ...@@ -15,6 +15,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import { scrollToElement } from '~/lib/utils/common_utils'; import { scrollToElement } from '~/lib/utils/common_utils';
import setWindowLocation from 'helpers/set_window_location_helper'; import setWindowLocation from 'helpers/set_window_location_helper';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { BASE_TABS_TABLE_FIELDS, PIPELINES_POLL_INTERVAL } from 'ee/on_demand_scans/constants';
jest.mock('~/lib/utils/common_utils'); jest.mock('~/lib/utils/common_utils');
...@@ -41,11 +42,20 @@ describe('BaseTab', () => { ...@@ -41,11 +42,20 @@ describe('BaseTab', () => {
return createMockApollo([[onDemandScansQuery, requestHandler]]); return createMockApollo([[onDemandScansQuery, requestHandler]]);
}; };
const navigateToPage = (direction) => { const navigateToPage = (direction, cursor = '') => {
findPagination().vm.$emit(direction); findPagination().vm.$emit(direction, cursor);
return wrapper.vm.$nextTick(); return wrapper.vm.$nextTick();
}; };
const setActiveState = (isActive) => {
wrapper.setProps({ isActive });
return wrapper.vm.$nextTick();
};
const advanceToNextFetch = () => {
jest.advanceTimersByTime(PIPELINES_POLL_INTERVAL);
};
const createComponentFactory = (mountFn = shallowMountExtended) => (options = {}) => { const createComponentFactory = (mountFn = shallowMountExtended) => (options = {}) => {
router = createRouter(); router = createRouter();
wrapper = mountFn( wrapper = mountFn(
...@@ -56,35 +66,11 @@ describe('BaseTab', () => { ...@@ -56,35 +66,11 @@ describe('BaseTab', () => {
apolloProvider: createMockApolloProvider(), apolloProvider: createMockApolloProvider(),
router, router,
propsData: { propsData: {
isActive: true,
title: 'All', title: 'All',
query: onDemandScansQuery, query: onDemandScansQuery,
itemsCount: 0, itemsCount: 0,
fields: [ fields: BASE_TABS_TABLE_FIELDS,
{
label: 'Status',
key: 'detailedStatus',
},
{
label: 'Name',
key: 'dastProfile.name',
},
{
label: 'OnDemandScans|Scan type',
key: 'scanType',
},
{
label: 'OnDemandScans|Target',
key: 'dastProfile.dastSiteProfile.targetUrl',
},
{
label: 'Start date',
key: 'createdAt',
},
{
label: 'Pipeline',
key: 'id',
},
],
}, },
provide: { provide: {
projectPath, projectPath,
...@@ -136,6 +122,27 @@ describe('BaseTab', () => { ...@@ -136,6 +122,27 @@ describe('BaseTab', () => {
}); });
}); });
it('polls for pipelines as long as the tab is active', async () => {
createComponent();
expect(requestHandler).toHaveBeenCalledTimes(1);
await wrapper.vm.$nextTick();
advanceToNextFetch();
expect(requestHandler).toHaveBeenCalledTimes(2);
await setActiveState(false);
advanceToNextFetch();
expect(requestHandler).toHaveBeenCalledTimes(2);
await setActiveState(true);
advanceToNextFetch();
expect(requestHandler).toHaveBeenCalledTimes(3);
});
it('puts the table in the busy state until the request resolves', async () => { it('puts the table in the busy state until the request resolves', async () => {
createComponent(); createComponent();
...@@ -206,6 +213,29 @@ describe('BaseTab', () => { ...@@ -206,6 +213,29 @@ describe('BaseTab', () => {
expect(Object.keys(router.currentRoute.query)).toContain('before'); expect(Object.keys(router.currentRoute.query)).toContain('before');
expect(requestHandler).toHaveBeenCalledTimes(3); expect(requestHandler).toHaveBeenCalledTimes(3);
}); });
it('when navigating to the next page, leaving the tab and coming back to it, the cursor is reset', async () => {
const { endCursor } = allPipelinesWithPipelinesMock.data.project.pipelines.pageInfo;
await navigateToPage('next', endCursor);
expect(requestHandler).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
after: endCursor,
}),
);
await setActiveState(false);
await setActiveState(true);
advanceToNextFetch();
expect(requestHandler).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
after: null,
}),
);
});
}); });
describe('rendered cells', () => { describe('rendered cells', () => {
......
import { shallowMount } from '@vue/test-utils';
import AllTab from 'ee/on_demand_scans/components/tabs/all.vue';
import RunningTab from 'ee/on_demand_scans/components/tabs/running.vue';
import FinishedTab from 'ee/on_demand_scans/components/tabs/finished.vue';
import BaseTab from 'ee/on_demand_scans/components/tabs/base_tab.vue';
import onDemandScansQuery from 'ee/on_demand_scans/graphql/on_demand_scans.query.graphql';
import { BASE_TABS_TABLE_FIELDS, LEARN_MORE_TEXT } from 'ee/on_demand_scans/constants';
describe.each`
tab | component | queryVariables | emptyTitle | emptyText
${'All'} | ${AllTab} | ${{}} | ${undefined} | ${undefined}
${'Running'} | ${RunningTab} | ${{ scope: 'RUNNING' }} | ${'There are no running scans.'} | ${LEARN_MORE_TEXT}
${'Finished'} | ${FinishedTab} | ${{ scope: 'FINISHED' }} | ${'There are no finished scans.'} | ${LEARN_MORE_TEXT}
`('$tab tab', ({ tab, component, queryVariables, emptyTitle, emptyText }) => {
let wrapper;
// Finders
const findBaseTab = () => wrapper.findComponent(BaseTab);
const createComponent = (propsData) => {
wrapper = shallowMount(component, {
propsData,
});
};
beforeEach(() => {
createComponent({
isActive: true,
itemsCount: 12,
});
});
it('renders the base tab with the correct props', () => {
expect(findBaseTab().props('title')).toBe(tab);
expect(findBaseTab().props('itemsCount')).toBe(12);
expect(findBaseTab().props('query')).toBe(onDemandScansQuery);
expect(findBaseTab().props('queryVariables')).toEqual(queryVariables);
expect(findBaseTab().props('emptyStateTitle')).toBe(emptyTitle);
expect(findBaseTab().props('emptyStateText')).toBe(emptyText);
expect(findBaseTab().props('fields')).toBe(BASE_TABS_TABLE_FIELDS);
});
});
...@@ -4,14 +4,18 @@ require 'spec_helper' ...@@ -4,14 +4,18 @@ require 'spec_helper'
RSpec.describe Projects::OnDemandScansHelper do RSpec.describe Projects::OnDemandScansHelper do
let_it_be(:project) { create(:project) } let_it_be(:project) { create(:project) }
let_it_be(:path_with_namespace) { "foo/bar" }
let_it_be(:graphql_etag_project_on_demand_scan_counts_path) {"/api/graphql:#{path_with_namespace}/on_demand_scans/counts" }
before do before do
allow(project).to receive(:path_with_namespace).and_return("foo/bar") allow(project).to receive(:path_with_namespace).and_return(path_with_namespace)
end end
describe '#on_demand_scans_data' do describe '#on_demand_scans_data' do
before do before do
create_list(:ci_pipeline, 12, project: project, ref: 'master', source: :ondemand_dast_scan) create_list(:ci_pipeline, 8, :success, project: project, ref: 'master', source: :ondemand_dast_scan)
create_list(:ci_pipeline, 4, :running, project: project, ref: 'master', source: :ondemand_dast_scan)
allow(helper).to receive(:graphql_etag_project_on_demand_scan_counts_path).and_return(graphql_etag_project_on_demand_scan_counts_path)
end end
it 'returns proper data' do it 'returns proper data' do
...@@ -19,7 +23,12 @@ RSpec.describe Projects::OnDemandScansHelper do ...@@ -19,7 +23,12 @@ RSpec.describe Projects::OnDemandScansHelper do
'project-path' => "foo/bar", 'project-path' => "foo/bar",
'new-dast-scan-path' => "/#{project.full_path}/-/on_demand_scans/new", 'new-dast-scan-path' => "/#{project.full_path}/-/on_demand_scans/new",
'empty-state-svg-path' => match_asset_path('/assets/illustrations/empty-state/ondemand-scan-empty.svg'), 'empty-state-svg-path' => match_asset_path('/assets/illustrations/empty-state/ondemand-scan-empty.svg'),
'pipelines-count' => 12 'project-on-demand-scan-counts-etag' => graphql_etag_project_on_demand_scan_counts_path,
'on-demand-scan-counts' => {
all: 12,
running: 4,
finished: 8
}.to_json
) )
end end
end end
......
...@@ -17,6 +17,11 @@ module Gitlab ...@@ -17,6 +17,11 @@ module Gitlab
%r(\Apipelines/sha/\w{7,40}\z), %r(\Apipelines/sha/\w{7,40}\z),
'ci_editor', 'ci_editor',
'pipeline_authoring' 'pipeline_authoring'
],
[
%r(\Aon_demand_scan/counts/),
'on_demand_scans',
'dynamic_application_security_testing'
] ]
].map(&method(:build_route)).freeze ].map(&method(:build_route)).freeze
......
...@@ -24122,6 +24122,9 @@ msgstr "" ...@@ -24122,6 +24122,9 @@ msgstr ""
msgid "OnCallSchedules|Your schedule has been successfully created. To add individual users to this schedule, use the Add a rotation button. To enable notifications for this schedule, you must also create an %{linkStart}escalation policy%{linkEnd}." msgid "OnCallSchedules|Your schedule has been successfully created. To add individual users to this schedule, use the Add a rotation button. To enable notifications for this schedule, you must also create an %{linkStart}escalation policy%{linkEnd}."
msgstr "" msgstr ""
msgid "OnDemandScans|%{learnMoreLinkStart}Learn more about on-demand scans%{learnMoreLinkEnd}."
msgstr ""
msgid "OnDemandScans|Could not fetch on-demand scans. Please refresh the page, or try again later." msgid "OnDemandScans|Could not fetch on-demand scans. Please refresh the page, or try again later."
msgstr "" msgstr ""
...@@ -24215,6 +24218,12 @@ msgstr "" ...@@ -24215,6 +24218,12 @@ msgstr ""
msgid "OnDemandScans|Target" msgid "OnDemandScans|Target"
msgstr "" msgstr ""
msgid "OnDemandScans|There are no finished scans."
msgstr ""
msgid "OnDemandScans|There are no running scans."
msgstr ""
msgid "OnDemandScans|Use existing scanner profile" msgid "OnDemandScans|Use existing scanner profile"
msgstr "" msgstr ""
......
...@@ -16,6 +16,7 @@ RSpec.describe Ci::ExpirePipelineCacheService do ...@@ -16,6 +16,7 @@ RSpec.describe Ci::ExpirePipelineCacheService do
pipeline_path = "/#{project.full_path}/-/pipelines/#{pipeline.id}.json" pipeline_path = "/#{project.full_path}/-/pipelines/#{pipeline.id}.json"
graphql_pipeline_path = "/api/graphql:pipelines/id/#{pipeline.id}" graphql_pipeline_path = "/api/graphql:pipelines/id/#{pipeline.id}"
graphql_pipeline_sha_path = "/api/graphql:pipelines/sha/#{pipeline.sha}" graphql_pipeline_sha_path = "/api/graphql:pipelines/sha/#{pipeline.sha}"
graphql_project_on_demand_scan_counts_path = "/api/graphql:on_demand_scan/counts/#{project.full_path}"
expect_next_instance_of(Gitlab::EtagCaching::Store) do |store| expect_next_instance_of(Gitlab::EtagCaching::Store) do |store|
expect(store).to receive(:touch).with(pipelines_path) expect(store).to receive(:touch).with(pipelines_path)
...@@ -23,6 +24,7 @@ RSpec.describe Ci::ExpirePipelineCacheService do ...@@ -23,6 +24,7 @@ RSpec.describe Ci::ExpirePipelineCacheService do
expect(store).to receive(:touch).with(pipeline_path) expect(store).to receive(:touch).with(pipeline_path)
expect(store).to receive(:touch).with(graphql_pipeline_path) expect(store).to receive(:touch).with(graphql_pipeline_path)
expect(store).to receive(:touch).with(graphql_pipeline_sha_path) expect(store).to receive(:touch).with(graphql_pipeline_sha_path)
expect(store).to receive(:touch).with(graphql_project_on_demand_scan_counts_path)
end end
subject.execute(pipeline) subject.execute(pipeline)
......
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