Commit 0fbf9e82 authored by Ezekiel Kigbo's avatar Ezekiel Kigbo

Merge branch '341364-dast-view-scans-all-tab' into 'master'

Implement the All on-demand DAST scans tab

See merge request gitlab-org/gitlab!72868
parents 6cc50efd 43bf93be
......@@ -21,6 +21,7 @@ import CiIcon from './ci_icon.vue';
* - Job show view - header
* - MR widget
* - Terraform table
* - On-demand scans list
*/
export default {
......
<script>
import { __ } from '~/locale';
import { __, s__ } from '~/locale';
import onDemandScansQuery from '../../graphql/on_demand_scans.query.graphql';
import BaseTab from './base_tab.vue';
export default {
query: onDemandScansQuery,
components: {
BaseTab,
},
tableFields: [
{
label: __('Status'),
key: 'detailedStatus',
},
{
label: __('Name'),
key: 'dastProfile.name',
},
{
label: s__('OnDemandScans|Scan type'),
key: 'scanType',
},
{
label: s__('OnDemandScans|Target'),
key: 'dastProfile.dastSiteProfile.targetUrl',
},
{
label: __('Start date'),
key: 'createdAt',
},
{
label: __('Pipeline'),
key: 'id',
},
],
i18n: {
title: __('All'),
},
......@@ -13,5 +41,10 @@ export default {
</script>
<template>
<base-tab :title="$options.i18n.title" v-bind="$attrs" />
<base-tab
v-bind="$attrs"
:query="$options.query"
:title="$options.i18n.title"
:fields="$options.tableFields"
/>
</template>
<script>
import { GlTab, GlBadge } from '@gitlab/ui';
import { GlTab, GlBadge, GlLink, GlTable, GlKeysetPagination } from '@gitlab/ui';
import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { DAST_SHORT_NAME } from '~/security_configuration/components/constants';
import { __ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { scrollToElement } from '~/lib/utils/common_utils';
import EmptyState from '../empty_state.vue';
import { PIPELINES_PER_PAGE, PIPELINES_POLL_INTERVAL } from '../../constants';
const defaultCursor = {
first: PIPELINES_PER_PAGE,
last: null,
after: null,
before: null,
};
export default {
PIPELINES_PER_PAGE,
DAST_SHORT_NAME,
getIdFromGraphQLId,
components: {
GlTab,
GlBadge,
GlLink,
GlTable,
GlKeysetPagination,
CiBadgeLink,
TimeAgoTooltip,
EmptyState,
},
inject: ['projectPath'],
props: {
query: {
type: Object,
required: true,
},
title: {
type: String,
required: true,
......@@ -27,6 +54,94 @@ export default {
required: false,
default: undefined,
},
fields: {
type: Array,
required: true,
},
},
apollo: {
pipelines: {
query() {
return this.query;
},
variables() {
return {
fullPath: this.projectPath,
...this.cursor,
};
},
update(data) {
const pipelines = data?.project?.pipelines;
if (!pipelines?.nodes?.length && (this.cursor.after || this.cursor.before)) {
this.resetCursor();
this.updateRoute();
}
return pipelines;
},
pollInterval: PIPELINES_POLL_INTERVAL,
},
},
data() {
const { after, before } = this.$route.query;
const cursor = { ...defaultCursor };
if (after) {
cursor.after = after;
} else if (before) {
cursor.before = before;
cursor.first = null;
cursor.last = PIPELINES_PER_PAGE;
}
return {
cursor,
hasError: false,
};
},
computed: {
hasPipelines() {
return Boolean(this.pipelines?.nodes?.length);
},
tableFields() {
return this.fields.map(({ key, label }) => ({
key,
label,
class: ['gl-text-black-normal'],
thClass: ['gl-bg-transparent!', 'gl-white-space-nowrap'],
}));
},
},
methods: {
resetCursor() {
this.cursor = { ...defaultCursor };
},
nextPage(after) {
this.cursor = {
...defaultCursor,
after,
};
this.updateRoute({ after });
},
prevPage(before) {
this.cursor = {
first: null,
last: PIPELINES_PER_PAGE,
after: null,
before,
};
this.updateRoute({ before });
},
updateRoute(query = {}) {
scrollToElement(this.$el);
this.$router.push({
path: this.$route.path,
query,
});
},
},
i18n: {
previousPage: __('Prev'),
nextPage: __('Next'),
},
};
</script>
......@@ -37,6 +152,43 @@ export default {
{{ title }}
<gl-badge size="sm" class="gl-tab-counter-badge">{{ itemsCount }}</gl-badge>
</template>
<empty-state :title="emptyStateTitle" :text="emptyStateText" no-primary-button />
<template v-if="hasPipelines">
<gl-table
thead-class="gl-border-b-solid gl-border-gray-100 gl-border-1"
:fields="tableFields"
:items="pipelines.nodes"
stacked="md"
>
<template #cell(detailedStatus)="{ item }">
<div class="gl-my-3">
<ci-badge-link :status="item.detailedStatus" />
</div>
</template>
<template #cell(scanType)>
{{ $options.DAST_SHORT_NAME }}
</template>
<template #cell(createdAt)="{ item }">
<time-ago-tooltip v-if="item.createdAt" :time="item.createdAt" tooltip-placement="left" />
</template>
<template #cell(id)="{ item }">
<gl-link :href="item.path">#{{ $options.getIdFromGraphQLId(item.id) }}</gl-link>
</template>
</gl-table>
<div class="gl-display-flex gl-justify-content-center">
<gl-keyset-pagination
data-testid="pagination"
v-bind="pipelines.pageInfo"
:prev-text="$options.i18n.previousPage"
:next-text="$options.i18n.nextPage"
@prev="prevPage"
@next="nextPage"
/>
</div>
</template>
<empty-state v-else :title="emptyStateTitle" :text="emptyStateText" no-primary-button />
</gl-tab>
</template>
......@@ -3,3 +3,6 @@ import { helpPagePath } from '~/helpers/help_page_helper';
export const HELP_PAGE_PATH = helpPagePath('user/application_security/dast/index', {
anchor: 'on-demand-scans',
});
export const PIPELINES_PER_PAGE = 20;
export const PIPELINES_POLL_INTERVAL = 1000;
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query allPipelinesCount($fullPath: ID!, $first: Int, $last: Int, $after: String, $before: String) {
project(fullPath: $fullPath) {
pipelines(
source: "ondemand_dast_scan"
first: $first
last: $last
after: $after
before: $before
) {
pageInfo {
...PageInfo
}
nodes {
id
path
createdAt
detailedStatus {
detailsPath
text
group
icon
}
dastProfile {
name
dastSiteProfile {
targetUrl
}
}
}
}
}
}
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
const defaultClient = createDefaultClient(
{},
{
assumeImmutableResults: true,
},
);
export default new VueApollo({
defaultClient,
});
import Vue from 'vue';
import { createRouter } from './router';
import OnDemandScans from './components/on_demand_scans.vue';
import apolloProvider from './graphql/provider';
export default () => {
const el = document.querySelector('#js-on-demand-scans');
......@@ -13,6 +14,7 @@ export default () => {
return new Vue({
el,
router: createRouter(),
apolloProvider,
provide: {
projectPath,
newDastScanPath,
......
- breadcrumb_title s_('OnDemandScans|On-demand Scans')
- page_title s_('OnDemandScans|On-demand Scans')
- add_page_specific_style 'page_bundles/ci_status'
#js-on-demand-scans{ data: on_demand_scans_data(@project) }
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'On-demand DAST scans (GraphQL fixtures)' do
describe GraphQL::Query, type: :request do
include ApiHelpers
include GraphqlHelpers
include JavaScriptFixturesHelpers
let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project, :repository, :public) }
let_it_be(:dast_profile) { create(:dast_profile, project: project) }
path = 'on_demand_scans/graphql/on_demand_scans.query.graphql'
before do
project.add_developer(current_user)
end
context 'with pipelines' do
let_it_be(:pipelines) do
create_list(
:ci_pipeline,
30,
:success,
source: :ondemand_dast_scan,
sha: project.commit.id,
project: project,
user: current_user,
dast_profile: dast_profile
)
end
it "graphql/#{path}.with_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 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
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AllTab renders the base tab with the correct props 1`] = `
Array [
Object {
"key": "detailedStatus",
"label": "Status",
},
Object {
"key": "dastProfile.name",
"label": "Name",
},
Object {
"key": "scanType",
"label": "Scan type",
},
Object {
"key": "dastProfile.dastSiteProfile.targetUrl",
"label": "Target",
},
Object {
"key": "createdAt",
"label": "Start date",
},
Object {
"key": "id",
"label": "Pipeline",
},
]
`;
......@@ -23,5 +23,6 @@ describe('AllTab', () => {
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();
});
});
import { GlTab } from '@gitlab/ui';
import { GlTab, GlTable } from '@gitlab/ui';
import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import allPipelinesWithPipelinesMock from 'test_fixtures/graphql/on_demand_scans/graphql/on_demand_scans.query.graphql.with_pipelines.json';
import allPipelinesWithoutPipelinesMock from 'test_fixtures/graphql/on_demand_scans/graphql/on_demand_scans.query.graphql.without_pipelines.json';
import { stubComponent } from 'helpers/stub_component';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import BaseTab from 'ee/on_demand_scans/components/tabs/base_tab.vue';
import EmptyState from 'ee/on_demand_scans/components/empty_state.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import onDemandScansQuery from 'ee/on_demand_scans/graphql/on_demand_scans.query.graphql';
import { createRouter } from 'ee/on_demand_scans/router';
import waitForPromises from 'helpers/wait_for_promises';
import { scrollToElement } from '~/lib/utils/common_utils';
import setWindowLocation from 'helpers/set_window_location_helper';
jest.mock('~/lib/utils/common_utils');
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('BaseTab', () => {
let wrapper;
let router;
let requestHandler;
// Props
const projectPath = '/namespace/project';
// Finders
const findTitle = () => wrapper.findByTestId('tab-title');
const findTable = () => wrapper.findComponent(GlTable);
const findEmptyState = () => wrapper.findComponent(EmptyState);
const findPagination = () => wrapper.findByTestId('pagination');
// Helpers
const createMockApolloProvider = () => {
return createMockApollo([[onDemandScansQuery, requestHandler]]);
};
const createComponent = (propsData) => {
router = createRouter();
wrapper = shallowMountExtended(BaseTab, {
propsData,
localVue,
apolloProvider: createMockApolloProvider(),
router,
propsData: {
title: 'All',
query: onDemandScansQuery,
itemsCount: 0,
fields: [{ name: 'ID', key: 'id' }],
...propsData,
},
provide: {
projectPath,
},
stubs: {
GlTab: stubComponent(GlTab, {
template: `
......@@ -25,22 +65,106 @@ describe('BaseTab', () => {
</div>
`,
}),
GlTable: stubComponent(GlTable, {
props: ['items'],
}),
},
});
};
beforeEach(() => {
requestHandler = jest.fn().mockResolvedValue(allPipelinesWithPipelinesMock);
});
afterEach(() => {
wrapper.destroy();
router = null;
requestHandler = null;
});
describe('when the app loads', () => {
it('fetches the pipelines', () => {
createComponent();
expect(requestHandler).toHaveBeenCalledWith({
after: null,
before: null,
first: 20,
fullPath: projectPath,
last: null,
});
});
it('resets the route if no pipeline matches the cursor', async () => {
setWindowLocation('#?after=nothingToSeeHere');
requestHandler = jest.fn().mockResolvedValue(allPipelinesWithoutPipelinesMock);
createComponent();
expect(router.currentRoute.query.after).toBe('nothingToSeeHere');
await waitForPromises();
expect(router.currentRoute.query.after).toBeUndefined();
});
});
describe('when there are pipelines', () => {
beforeEach(() => {
createComponent({
title: 'All',
itemsCount: 12,
itemsCount: 30,
});
});
it('renders the title with the item count', () => {
expect(findTitle().text()).toMatchInterpolatedText('All 12');
expect(findTitle().text()).toMatchInterpolatedText('All 30');
});
it('passes the pipelines to GlTable', () => {
const table = findTable();
expect(table.exists()).toBe(true);
expect(table.props('items')).toEqual(
allPipelinesWithPipelinesMock.data.project.pipelines.nodes,
);
});
it('when navigating to another page, scrolls back to the top', () => {
findPagination().vm.$emit('next');
expect(scrollToElement).toHaveBeenCalledWith(wrapper.vm.$el);
});
it('when navigating to the next page, the route is updated and pipelines are fetched', async () => {
expect(Object.keys(router.currentRoute.query)).not.toContain('after');
expect(requestHandler).toHaveBeenCalledTimes(1);
findPagination().vm.$emit('next');
await wrapper.vm.$nextTick();
expect(Object.keys(router.currentRoute.query)).toContain('after');
expect(requestHandler).toHaveBeenCalledTimes(2);
});
it('when navigating back to the previous page, the route is updated and pipelines are fetched', async () => {
findPagination().vm.$emit('next');
await wrapper.vm.$nextTick();
findPagination().vm.$emit('prev');
await wrapper.vm.$nextTick();
expect(Object.keys(router.currentRoute.query)).not.toContain('after');
expect(Object.keys(router.currentRoute.query)).toContain('before');
expect(requestHandler).toHaveBeenCalledTimes(3);
});
});
describe('when there are no pipelines', () => {
beforeEach(() => {
requestHandler = jest.fn().mockResolvedValue(allPipelinesWithoutPipelinesMock);
createComponent();
});
it('renders an empty state', () => {
expect(findEmptyState().exists()).toBe(true);
});
});
});
......@@ -23916,6 +23916,9 @@ msgstr ""
msgid "OnDemandScans|Scan name"
msgstr ""
msgid "OnDemandScans|Scan type"
msgstr ""
msgid "OnDemandScans|Scanner profile"
msgstr ""
......@@ -23931,6 +23934,9 @@ msgstr ""
msgid "OnDemandScans|Start time"
msgstr ""
msgid "OnDemandScans|Target"
msgstr ""
msgid "OnDemandScans|Use existing scanner profile"
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