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

Add saved scans actions

parent 3d2e8da5
<script>
import { GlButton, GlLink, GlSprintf, GlTabs } from '@gitlab/ui';
import { GlButton, GlLink, GlSprintf, GlScrollableTabs } from '@gitlab/ui';
import { s__ } from '~/locale';
import ConfigurationPageLayout from 'ee/security_configuration/components/configuration_page_layout.vue';
import {
......@@ -27,7 +27,7 @@ export default {
GlButton,
GlLink,
GlSprintf,
GlTabs,
GlScrollableTabs,
ConfigurationPageLayout,
AllTab,
RunningTab,
......@@ -159,7 +159,7 @@ export default {
</template>
</gl-sprintf>
</template>
<gl-tabs v-model="activeTab">
<gl-scrollable-tabs v-model="activeTab" data-testid="on-demand-scans-tabs">
<component
:is="tab.component"
v-for="(tab, key, index) in tabs"
......@@ -167,7 +167,7 @@ export default {
:items-count="tab.itemsCount"
:is-active="activeTab === index"
/>
</gl-tabs>
</gl-scrollable-tabs>
</configuration-page-layout>
<empty-state v-else />
</template>
<script>
import * as Sentry from '@sentry/browser';
import {
GlTab,
GlBadge,
......@@ -15,7 +14,7 @@ import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { DAST_SHORT_NAME } from '~/security_configuration/components/constants';
import { __, s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { scrollToElement } from '~/lib/utils/common_utils';
import handlesErrors from '../../mixins/handles_errors';
import Actions from '../actions.vue';
import EmptyState from '../empty_state.vue';
import { PIPELINES_PER_PAGE, PIPELINES_POLL_INTERVAL, ACTION_COLUMN } from '../../constants';
......@@ -45,6 +44,7 @@ export default {
Actions,
EmptyState,
},
mixins: [handlesErrors],
inject: ['projectPath'],
props: {
isActive: {
......@@ -127,7 +127,6 @@ export default {
return {
cursor,
hasError: false,
actionErrorMessage: '',
};
},
computed: {
......@@ -189,19 +188,6 @@ export default {
});
this.resetActionError();
},
handleActionError(message, exception = null) {
this.actionErrorMessage = message;
this.scrollToTop();
if (exception !== null) {
Sentry.captureException(exception);
}
},
resetActionError() {
this.actionErrorMessage = '';
},
scrollToTop() {
scrollToElement(this.$el);
},
},
i18n: {
previousPage: __('Prev'),
......@@ -216,8 +202,10 @@ export default {
<template>
<gl-tab v-bind="$attrs">
<template #title>
{{ title }}
<gl-badge size="sm" class="gl-tab-counter-badge">{{ itemsCount }}</gl-badge>
<span class="gl-white-space-nowrap">
{{ title }}
<gl-badge size="sm" class="gl-tab-counter-badge">{{ itemsCount }}</gl-badge>
</span>
</template>
<template v-if="$apollo.queries.pipelines.loading || hasPipelines">
<gl-table
......@@ -243,10 +231,10 @@ export default {
</gl-skeleton-loader>
</template>
<template v-if="actionErrorMessage" #top-row>
<template v-if="hasActionError || $scopedSlots.error" #top-row>
<td :colspan="tableFields.length">
<gl-alert class="gl-my-4" variant="danger" :dismissible="false">
{{ actionErrorMessage }}
<slot name="error">{{ actionErrorMessage }}</slot>
</gl-alert>
</td>
</template>
......@@ -308,6 +296,8 @@ export default {
@next="nextPage"
/>
</div>
<slot></slot>
</template>
<gl-alert
v-else-if="hasError"
......
<script>
import { GlIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
import {
GlButton,
GlDropdown,
GlDropdownItem,
GlIcon,
GlModal,
GlTooltipDirective,
} from '@gitlab/ui';
import { __, s__ } from '~/locale';
import { redirectTo } from '~/lib/utils/url_utility';
import ScanTypeBadge from 'ee/security_configuration/dast_profiles/components/dast_scan_type_badge.vue';
import dastProfileRunMutation from 'ee/security_configuration/dast_profiles/graphql/dast_profile_run.mutation.graphql';
import dastProfileDelete from 'ee/security_configuration/dast_profiles/graphql/dast_profile_delete.mutation.graphql';
import handlesErrors from '../../mixins/handles_errors';
import { removeProfile } from '../../graphql/cache_utils';
import dastProfilesQuery from '../../graphql/dast_profiles.query.graphql';
import { SAVED_TAB_TABLE_FIELDS, LEARN_MORE_TEXT } from '../../constants';
import BaseTab from './base_tab.vue';
......@@ -9,15 +21,124 @@ import BaseTab from './base_tab.vue';
export default {
query: dastProfilesQuery,
components: {
GlButton,
GlDropdown,
GlDropdownItem,
GlIcon,
GlModal,
BaseTab,
ScanTypeBadge,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [handlesErrors],
inject: ['projectPath'],
tableFields: SAVED_TAB_TABLE_FIELDS,
deleteScanModalId: `delete-scan-modal`,
i18n: {
title: s__('OnDemandScans|Scan library'),
emptyStateTitle: s__('OnDemandScans|There are no saved scans.'),
emptyStateText: LEARN_MORE_TEXT,
actions: __('Actions'),
moreActions: __('More actions'),
runScan: s__('OnDemandScans|Run scan'),
runScanError: s__('OnDemandScans|Could not run the scan. Please try again.'),
editProfile: s__('OnDemandScans|Edit profile'),
editButtonLabel: __('Edit'),
deleteModalTitle: s__('OnDemandScans|Are you sure you want to delete this scan?'),
deleteButtonLabel: __('Delete'),
deleteProfile: s__('OnDemandScans|Delete profile'),
deletionError: s__(
'OnDemandScans|Could not delete saved scan. Please refresh the page, or try again later.',
),
},
data() {
return {
runningScanId: null,
deletingScanId: null,
};
},
methods: {
async runScan({ id }) {
this.resetActionError();
this.runningScanId = id;
try {
const {
data: {
dastProfileRun: { pipelineUrl, errors },
},
} = await this.$apollo.mutate({
mutation: dastProfileRunMutation,
variables: {
input: {
id,
},
},
});
if (errors.length) {
this.handleActionError(errors[0]);
this.runningScanId = null;
} else {
redirectTo(pipelineUrl);
}
} catch (exception) {
this.handleActionError(this.$options.i18n.runScanError, exception);
this.runningScanId = null;
}
},
prepareProfileDeletion(profileId) {
this.deletingScanId = profileId;
this.$refs[this.$options.deleteScanModalId].show();
},
async deleteProfile() {
this.resetActionError();
try {
await this.$apollo.mutate({
mutation: dastProfileDelete,
variables: {
input: {
id: this.deletingScanId,
},
},
update: (store, { data = {} }) => {
const errors = data.dastProfileDelete?.errors ?? [];
if (errors.length) {
this.handleActionError(errors[0]);
} else {
removeProfile({
profileId: this.deletingScanId,
store,
queryBody: {
query: dastProfilesQuery,
variables: {
fullPath: this.projectPath,
},
},
});
}
},
optimisticResponse: {
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
// eslint-disable-next-line @gitlab/require-i18n-strings
__typename: 'Mutation',
dastProfileDelete: {
__typename: 'DastProfileDeletePayload',
errors: [],
},
},
});
} catch (exception) {
this.handleActionError(this.$options.i18n.deletionError, exception);
}
},
cancelDeletion() {
this.deletingScanId = null;
},
},
};
</script>
......@@ -32,10 +153,95 @@ export default {
:empty-state-text="$options.i18n.emptyStateText"
v-bind="$attrs"
>
<template v-if="hasActionError" #error>
{{ actionErrorMessage }}
</template>
<template #after-name="item"><gl-icon name="branch" /> {{ item.branch.name }}</template>
<template #cell(scanType)="{ value }">
<scan-type-badge :scan-type="value" />
</template>
<template #cell(actions)="{ item }">
<div class="gl-text-right">
<gl-button
size="small"
data-testid="dast-scan-run-button"
:loading="runningScanId === item.id"
:disabled="Boolean(runningScanId)"
@click="runScan(item)"
>
{{ $options.i18n.runScan }}
</gl-button>
<!-- More actions for desktop -->
<gl-dropdown
v-gl-tooltip
:text="$options.i18n.moreActions"
:title="$options.i18n.moreActions"
category="tertiary"
size="small"
icon="ellipsis_v"
toggle-class="gl-border-0! gl-shadow-none! gl-pl-2! gl-pr-2!"
class="gl-display-none gl-md-display-inline-flex!"
no-caret
right
text-sr-only
>
<gl-dropdown-item
:href="item.editPath"
:aria-label="$options.i18n.editProfile"
data-testid="edit-scan-button-desktop"
>
{{ $options.i18n.editButtonLabel }}
</gl-dropdown-item>
<gl-dropdown-item
:aria-label="$options.i18n.deleteProfile"
boundary="viewport"
variant="danger"
data-testid="delete-scan-button-desktop"
@click="prepareProfileDeletion(item.id)"
>
{{ $options.i18n.deleteButtonLabel }}
</gl-dropdown-item>
</gl-dropdown>
<!-- More actions for mobile -->
<gl-button
:href="item.editPath"
:aria-label="$options.i18n.editProfile"
category="tertiary"
class="gl-md-display-none"
size="small"
data-testid="edit-scan-button-mobile"
>
{{ $options.i18n.editButtonLabel }}
</gl-button>
<gl-button
category="tertiary"
icon="remove"
variant="danger"
size="small"
class="gl-md-display-none"
data-testid="delete-scan-button-mobile"
:aria-label="$options.i18n.deleteProfile"
@click="prepareProfileDeletion(item.id)"
/>
</div>
</template>
<gl-modal
:ref="$options.deleteScanModalId"
:modal-id="$options.deleteScanModalId"
:title="$options.i18n.deleteModalTitle"
:ok-title="$options.i18n.deleteButtonLabel"
ok-variant="danger"
body-class="gl-display-none"
lazy
@ok="deleteProfile"
@cancel="cancelDeletion"
/>
</base-tab>
</template>
import { produce } from 'immer';
export const removeProfile = ({ profileId, store, queryBody }) => {
const sourceData = store.readQuery(queryBody);
const data = produce(sourceData, (draftState) => {
draftState.project.pipelines.nodes = draftState.project.pipelines.nodes.filter(({ id }) => {
return id !== profileId;
});
});
store.writeQuery({ ...queryBody, data });
};
import * as Sentry from '@sentry/browser';
import { scrollToElement } from '~/lib/utils/common_utils';
export default {
data() {
return {
actionErrorMessage: '',
};
},
computed: {
hasActionError() {
return Boolean(this.actionErrorMessage.length);
},
},
methods: {
handleActionError(message, exception = null) {
this.actionErrorMessage = message;
this.scrollToTop();
if (exception !== null) {
Sentry.captureException(exception);
}
},
resetActionError() {
this.actionErrorMessage = '';
},
scrollToTop() {
scrollToElement(this.$el);
},
},
};
......@@ -40,7 +40,7 @@ describe('OnDemandScans', () => {
// Finders
const findNewScanLink = () => wrapper.findByTestId('new-scan-link');
const findHelpPageLink = () => wrapper.findByTestId('help-page-link');
const findTabs = () => wrapper.findComponent(GlTabs);
const findTabs = () => wrapper.findByTestId('on-demand-scans-tabs');
const findAllTab = () => wrapper.findComponent(AllTab);
const findRunningTab = () => wrapper.findComponent(RunningTab);
const findFinishedTab = () => wrapper.findComponent(FinishedTab);
......@@ -68,7 +68,7 @@ describe('OnDemandScans', () => {
stubs: {
ConfigurationPageLayout,
GlSprintf,
GlTabs,
GlScrollableTabs: GlTabs,
},
},
{
......
......@@ -305,21 +305,18 @@ describe('BaseTab', () => {
});
});
it('renders the after-name slot', async () => {
it.each(['default', 'after-name', 'error'])('renders the %s slot', async (slot) => {
createFullComponent({
propsData: {
itemsCount: 30,
},
stubs: {
GlTable: false,
},
scopedSlots: {
'after-name': '<div data-testid="after-name-content" />',
[slot]: `<div data-testid="${slot}-slot-content" />`,
},
});
await waitForPromises();
expect(wrapper.findByTestId('after-name-content').exists()).toBe(true);
expect(wrapper.findByTestId(`${slot}-slot-content`).exists()).toBe(true);
});
describe("when a scan's DAST profile got deleted", () => {
......
......@@ -7,19 +7,35 @@ import SavedTab from 'ee/on_demand_scans/components/tabs/saved.vue';
import BaseTab from 'ee/on_demand_scans/components/tabs/base_tab.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import dastProfilesQuery from 'ee/on_demand_scans/graphql/dast_profiles.query.graphql';
import dastProfileRunMutation from 'ee/security_configuration/dast_profiles/graphql/dast_profile_run.mutation.graphql';
import dastProfileDeleteMutation from 'ee/security_configuration/dast_profiles/graphql/dast_profile_delete.mutation.graphql';
import { createRouter } from 'ee/on_demand_scans/router';
import { SAVED_TAB_TABLE_FIELDS, LEARN_MORE_TEXT } from 'ee/on_demand_scans/constants';
import { s__ } from '~/locale';
import ScanTypeBadge from 'ee/security_configuration/dast_profiles/components/dast_scan_type_badge.vue';
jest.mock('~/lib/utils/common_utils');
import flushPromises from 'helpers/flush_promises';
import { redirectTo } from '~/lib/utils/url_utility';
Vue.use(VueApollo);
// Mocks
jest.mock('~/lib/utils/common_utils');
jest.mock('~/lib/utils/url_utility');
const [firstProfile] = dastProfilesMock.data.project.pipelines.nodes;
const GlTableMock = {
firstProfile,
template: `
<div>
<slot name="cell(actions)" :item="$options.firstProfile" />
<slot name="error" />
</div>`,
};
const errorAsDataMessage = 'Error-as-data message';
describe('Saved tab', () => {
let wrapper;
let router;
let requestHandler;
let requestHandlers;
// Props
const projectPath = '/namespace/project';
......@@ -29,11 +45,32 @@ describe('Saved tab', () => {
const findBaseTab = () => wrapper.findComponent(BaseTab);
const findFirstRow = () => wrapper.find('tbody > tr');
const findCellAt = (index) => findFirstRow().findAll('td').at(index);
const findRunScanButton = () => wrapper.findByTestId('dast-scan-run-button');
const findDeleteModal = () => wrapper.findComponent({ ref: 'delete-scan-modal' });
// Helpers
const createMockApolloProvider = () => {
return createMockApollo([[dastProfilesQuery, requestHandler]]);
return createMockApollo([
[dastProfilesQuery, requestHandlers.dastProfilesQuery],
[dastProfileRunMutation, requestHandlers.dastProfileRunMutation],
[dastProfileDeleteMutation, requestHandlers.dastProfileDeleteMutation],
]);
};
const makeDastProfileRunResponse = (errors = []) => ({
data: {
dastProfileRun: {
pipelineUrl: '/pipelines/1',
errors,
},
},
});
const makeDastProfileDeleteResponse = (errors = []) => ({
data: {
dastProfileDelete: {
errors,
},
},
});
const createComponentFactory = (mountFn = shallowMountExtended) => (options = {}) => {
router = createRouter();
......@@ -63,13 +100,17 @@ describe('Saved tab', () => {
const createFullComponent = createComponentFactory(mountExtended);
beforeEach(() => {
requestHandler = jest.fn().mockResolvedValue(dastProfilesMock);
requestHandlers = {
dastProfilesQuery: jest.fn().mockResolvedValue(dastProfilesMock),
dastProfileRunMutation: jest.fn().mockResolvedValue(makeDastProfileRunResponse()),
dastProfileDeleteMutation: jest.fn().mockResolvedValue(makeDastProfileDeleteResponse()),
};
});
afterEach(() => {
wrapper.destroy();
router = null;
requestHandler = null;
requestHandlers = null;
});
it('renders the base tab with the correct props', () => {
......@@ -88,7 +129,7 @@ describe('Saved tab', () => {
it('fetches the profiles', () => {
createComponent();
expect(requestHandler).toHaveBeenCalledWith({
expect(requestHandlers.dastProfilesQuery).toHaveBeenCalledWith({
after: null,
before: null,
first: 20,
......@@ -98,8 +139,6 @@ describe('Saved tab', () => {
});
describe('custom table cells', () => {
const [firstProfile] = dastProfilesMock.data.project.pipelines.nodes;
beforeEach(() => {
createFullComponent();
});
......@@ -117,4 +156,177 @@ describe('Saved tab', () => {
expect(firstScanTypeBadge.props('scanType')).toBe(firstProfile.dastScannerProfile.scanType);
});
});
describe('edit button', () => {
beforeEach(() => {
createComponent({
stubs: {
GlTable: GlTableMock,
},
});
});
it.each(['desktop', 'mobile'])('renders the %s edit button', (layout) => {
const editButton = wrapper.findByTestId(`edit-scan-button-${layout}`);
expect(editButton.exists()).toBe(true);
expect(editButton.attributes('href')).toBe(firstProfile.editPath);
});
});
describe('run scan button', () => {
describe('success', () => {
beforeEach(async () => {
createComponent({
stubs: {
GlTable: GlTableMock,
},
});
await flushPromises();
});
it('renders the button', () => {
expect(findRunScanButton().exists()).toBe(true);
});
it('clicking on the button triggers the run scan mutation with the profile ID', () => {
findRunScanButton().vm.$emit('click');
expect(requestHandlers.dastProfileRunMutation).toHaveBeenCalledWith({
input: { id: firstProfile.id },
});
});
it('put the button in the loading and disabled state', async () => {
const runScanButton = findRunScanButton();
runScanButton.vm.$emit('click');
await wrapper.vm.$nextTick();
expect(runScanButton.props('loading')).toBe(true);
expect(runScanButton.props('disabled')).toBe(true);
});
it("redirects to the pipeline's page once the mutation resolves", async () => {
findRunScanButton().vm.$emit('click');
await flushPromises();
expect(redirectTo).toHaveBeenCalledWith('/pipelines/1');
});
});
const topLevelErrorMessage = s__('OnDemandScans|Could not run the scan. Please try again.');
describe.each`
errorType | errorMessage | requestHander
${'error-as-data'} | ${errorAsDataMessage} | ${jest.fn().mockResolvedValue(makeDastProfileRunResponse([errorAsDataMessage]))}
${'top-level error'} | ${topLevelErrorMessage} | ${jest.fn().mockRejectedValue()}
`('when deletion fails with $errorType', ({ errorMessage, requestHander }) => {
beforeEach(async () => {
requestHandlers.dastProfileRunMutation = requestHander;
createComponent({
stubs: {
GlTable: GlTableMock,
},
});
await flushPromises();
findRunScanButton().vm.$emit('click');
});
it('shows the error message', () => {
expect(wrapper.text()).toContain(errorMessage);
});
it('hides the error message when retrying the deletion', async () => {
findRunScanButton().vm.$emit('click');
await wrapper.vm.$nextTick();
expect(wrapper.text()).not.toContain(errorMessage);
});
it("resets the button's state", async () => {
const runScanButton = findRunScanButton();
expect(runScanButton.props('loading')).toBe(false);
expect(runScanButton.props('disabled')).toBe(false);
});
});
});
describe('delete button', () => {
describe.each(['desktop', 'mobile'])('%s layout', (layout) => {
let deleteButton;
beforeEach(() => {
createComponent({
stubs: {
GlTable: GlTableMock,
GlModal: {
template: '<div />',
methods: {
show: () => {},
},
},
},
});
deleteButton = wrapper.findByTestId(`delete-scan-button-${layout}`);
});
afterEach(() => {
deleteButton = null;
});
it('renders the button', () => {
expect(deleteButton.exists()).toBe(true);
});
it('clicking on the button opens the delete modal', () => {
jest.spyOn(wrapper.vm.$refs['delete-scan-modal'], 'show');
deleteButton.vm.$emit('click');
expect(wrapper.vm.$refs['delete-scan-modal'].show).toHaveBeenCalled();
});
it('confirming the deletion in the modal triggers the delete mutation with the profile ID', () => {
deleteButton.vm.$emit('click');
findDeleteModal().vm.$emit('ok');
expect(requestHandlers.dastProfileDeleteMutation).toHaveBeenCalledWith({
input: { id: firstProfile.id },
});
});
});
const topLevelErrorMessage = s__(
'OnDemandScans|Could not delete saved scan. Please refresh the page, or try again later.',
);
describe.each`
errorType | errorMessage | requestHander
${'error-as-data'} | ${errorAsDataMessage} | ${jest.fn().mockResolvedValue(makeDastProfileDeleteResponse([errorAsDataMessage]))}
${'top-level error'} | ${topLevelErrorMessage} | ${jest.fn().mockRejectedValue()}
`('when deletion fails with $errorType', ({ errorMessage, requestHander }) => {
beforeEach(async () => {
requestHandlers.dastProfileDeleteMutation = requestHander;
createComponent({
stubs: {
GlTable: GlTableMock,
},
});
await flushPromises();
findDeleteModal().vm.$emit('ok');
});
it('shows the error message', () => {
expect(wrapper.text()).toContain(errorMessage);
});
it('hides the error message when retrying the deletion', async () => {
findDeleteModal().vm.$emit('ok');
await wrapper.vm.$nextTick();
expect(wrapper.text()).not.toContain(errorMessage);
});
});
});
});
import dastProfilesMock from 'test_fixtures/graphql/on_demand_scans/graphql/dast_profiles.query.graphql.json';
import { removeProfile } from 'ee/on_demand_scans/graphql/cache_utils';
const [firstProfile, ...otherProfiles] = dastProfilesMock.data.project.pipelines.nodes;
describe('EE - On-demand Scans GraphQL CacheUtils', () => {
describe('removeProfile', () => {
it('removes the profile with the given id from the cache', () => {
const mockQueryBody = { query: 'foo', variables: { foo: 'bar' } };
const mockStore = {
readQuery: () => dastProfilesMock.data,
writeQuery: jest.fn(),
};
removeProfile({
store: mockStore,
queryBody: mockQueryBody,
profileId: firstProfile.id,
});
expect(mockStore.writeQuery).toHaveBeenCalledWith({
...mockQueryBody,
data: {
project: {
id: dastProfilesMock.data.project.id,
pipelines: {
nodes: otherProfiles,
pageInfo: expect.any(Object),
},
},
},
});
});
});
});
......@@ -24516,6 +24516,12 @@ msgstr ""
msgid "OnDemandScans|%{learnMoreLinkStart}Learn more about on-demand scans%{learnMoreLinkEnd}."
msgstr ""
msgid "OnDemandScans|Are you sure you want to delete this scan?"
msgstr ""
msgid "OnDemandScans|Could not delete saved scan. Please refresh the page, or try again later."
msgstr ""
msgid "OnDemandScans|Could not fetch on-demand scans. Please refresh the page, or try again later."
msgstr ""
......@@ -24534,12 +24540,18 @@ msgstr ""
msgid "OnDemandScans|Create new site profile"
msgstr ""
msgid "OnDemandScans|Delete profile"
msgstr ""
msgid "OnDemandScans|Description (optional)"
msgstr ""
msgid "OnDemandScans|Edit on-demand DAST scan"
msgstr ""
msgid "OnDemandScans|Edit profile"
msgstr ""
msgid "OnDemandScans|For example: Tests the login page for SQL injections"
msgstr ""
......@@ -24582,6 +24594,9 @@ msgstr ""
msgid "OnDemandScans|Repeats"
msgstr ""
msgid "OnDemandScans|Run scan"
msgstr ""
msgid "OnDemandScans|Save and run scan"
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