Commit 000f0666 authored by Alexander Turinske's avatar Alexander Turinske

Create threat alert drawer

- create new component
- allow issue creation from drawer
- show details
- add tests

Changelog: added
parent f963a7b0
<script>
import { GlAlert, GlButton, GlDrawer, GlLink, GlLoadingIcon } from '@gitlab/ui';
import { capitalizeFirstCharacter, splitCamelCase } from '~/lib/utils/text_utility';
import { joinPaths, visitUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import createIssueMutation from '~/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql';
import getAlertDetailsQuery from '~/vue_shared/alert_details/graphql/queries/alert_details.query.graphql';
import { HIDDEN_VALUES } from './constants';
export default {
HEADER_HEIGHT: process.env.NODE_ENV === 'development' ? '75px' : '40px',
i18n: {
CREATE_ISSUE: __('Create incident'),
ERROR: __('There was an error.'),
},
components: {
GlAlert,
GlButton,
GlDrawer,
GlLink,
GlLoadingIcon,
},
inject: ['projectPath'],
apollo: {
alertDetails: {
query: getAlertDetailsQuery,
variables() {
return {
fullPath: this.projectPath,
alertId: this.selectedAlert.iid,
};
},
update(data) {
return data?.project?.alertManagementAlerts?.nodes?.[0] ?? null;
},
error() {
this.errored = true;
},
},
},
props: {
isAlertDrawerOpen: {
type: Boolean,
required: false,
default: false,
},
projectId: {
type: String,
required: false,
default: '',
},
selectedAlert: {
type: Object,
required: true,
},
},
data() {
return {
alertDetails: {},
errored: false,
creatingIssue: false,
};
},
computed: {
alertIssuePath() {
const issueIid = this.selectedAlert.issue?.iid;
return issueIid ? this.getIssuePath(issueIid) : '';
},
curatedAlertDetails() {
return Object.entries({ ...this.alertDetails, ...this.alertDetails?.details }).reduce(
(acc, [key, value]) => {
return HIDDEN_VALUES.includes(key) || !value ? acc : [...acc, [key, value]];
},
[],
);
},
hasIssue() {
return Boolean(this.selectedAlert.issue);
},
issueText() {
return `#${this.selectedAlert.issue.iid}`;
},
isLoadingDetails() {
return this.$apollo.queries.alertDetails.loading;
},
},
methods: {
async createIssue() {
this.creatingIssue = true;
try {
const response = await this.$apollo.mutate({
mutation: createIssueMutation,
variables: {
iid: this.selectedAlert.iid,
projectPath: this.projectPath,
},
});
const { errors, issue } = response.data.createAlertIssue;
if (errors?.length) {
throw new Error();
}
visitUrl(this.getIssuePath(issue.iid));
} catch {
this.handleAlertError();
this.creatingIssue = false;
}
},
getIssuePath(issueIid) {
return joinPaths(gon.relative_url_root || '/', this.projectPath, '-', 'issues', issueIid);
},
handleAlertError() {
this.errored = true;
},
humanizeText(text) {
return capitalizeFirstCharacter(splitCamelCase(text));
},
},
};
</script>
<template>
<gl-drawer
:z-index="252"
:open="isAlertDrawerOpen"
:header-height="$options.HEADER_HEIGHT"
@close="$emit('deselect-alert')"
>
<template #header>
<div>
<h5 class="gl-mb-5">{{ selectedAlert.title }}</h5>
<div>
<gl-link v-if="hasIssue" :href="alertIssuePath" data-testid="issue-link">
{{ issueText }}
</gl-link>
<gl-button
v-else
category="primary"
variant="confirm"
:disabled="errored"
:loading="creatingIssue"
data-testid="create-issue-button"
@click="createIssue"
>
{{ $options.i18n.CREATE_ISSUE }}
</gl-button>
</div>
</div>
</template>
<gl-alert v-if="errored" variant="danger" :dismissable="false" contained>
{{ $options.i18n.ERROR }}
</gl-alert>
<gl-loading-icon v-if="isLoadingDetails" size="lg" color="dark" class="gl-mt-5" />
<div v-else data-testid="details-list">
<div v-for="[key, value] in curatedAlertDetails" :key="key" class="gl-mb-3">
<div>{{ humanizeText(key) }}</div>
<b>{{ value }}</b>
</div>
</div>
</gl-drawer>
</template>
......@@ -17,6 +17,7 @@ import getAlertsQuery from '~/graphql_shared/queries/get_alerts.query.graphql';
import { convertToSnakeCase } from '~/lib/utils/text_utility';
import { joinPaths } from '~/lib/utils/url_utility';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import AlertDrawer from './alert_drawer.vue';
import AlertFilters from './alert_filters.vue';
import AlertStatus from './alert_status.vue';
import {
......@@ -39,6 +40,7 @@ export default {
CLOSED,
},
components: {
AlertDrawer,
AlertStatus,
AlertFilters,
GlAlert,
......@@ -84,8 +86,10 @@ export default {
errored: false,
errorMsg: '',
filters: DEFAULT_FILTERS,
isAlertDrawerOpen: false,
isErrorAlertDismissed: false,
pageInfo: {},
selectedAlert: {},
sort: 'STARTED_AT_DESC',
sortBy: 'startedAt',
sortDesc: true,
......@@ -146,6 +150,10 @@ export default {
),
};
},
handleAlertDeselect() {
this.isAlertDrawerOpen = false;
this.selectedAlert = {};
},
handleAlertError(msg) {
this.errored = true;
this.errorMsg = msg;
......@@ -162,6 +170,10 @@ export default {
alertDetailsUrl({ iid }) {
return joinPaths(window.location.pathname, 'alerts', iid);
},
openAlertDrawer(data) {
this.isAlertDrawerOpen = true;
this.selectedAlert = data;
},
},
};
</script>
......@@ -201,6 +213,7 @@ export default {
sort-icon-left
responsive
show-empty
@row-clicked="openAlertDrawer"
@sort-changed="fetchSortedData"
>
<template #cell(startedAt)="{ item }">
......@@ -304,5 +317,10 @@ export default {
<gl-loading-icon v-if="isLoadingAlerts" size="md" />
<span v-else>&nbsp;</span>
</gl-intersection-observer>
<alert-drawer
:is-alert-drawer-open="isAlertDrawerOpen"
:selected-alert="selectedAlert"
@deselect-alert="handleAlertDeselect"
/>
</div>
</template>
......@@ -72,3 +72,15 @@ export const DEBOUNCE = 250;
export const ALL = { key: 'ALL', value: __('All') };
export const CLOSED = __('closed');
export const HIDDEN_VALUES = [
'__typename',
'assignees',
'details',
'iid',
'issue',
'notes',
'severity',
'status',
'todos',
];
---
title: Create threat alert drawer
merge_request: 61183
author:
type: added
import { GlAlert, GlDrawer, GlLoadingIcon } from '@gitlab/ui';
import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import AlertDrawer from 'ee/threat_monitoring/components/alerts/alert_drawer.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { visitUrl } from '~/lib/utils/url_utility';
import getAlertDetailsQuery from '~/vue_shared/alert_details/graphql/queries/alert_details.query.graphql';
import { erroredGetAlertDetailsQuerySpy, getAlertDetailsQuerySpy } from '../../mocks/mock_apollo';
import { mockAlertDetails } from '../../mocks/mock_data';
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn(),
joinPaths: jest.requireActual('~/lib/utils/url_utility').joinPaths,
}));
let localVue;
describe('Alert Drawer', () => {
let wrapper;
const DEFAULT_PROJECT_PATH = '#';
const mutateSpy = jest
.fn()
.mockResolvedValue({ data: { createAlertIssue: { errors: [], issue: { iid: '03' } } } });
let querySpy;
const createMockApolloProvider = (query) => {
localVue.use(VueApollo);
return createMockApollo([[getAlertDetailsQuery, query]]);
};
const shallowApolloMock = ({ loading = false, mutate = mutateSpy }) => ({
mutate,
queries: { alertDetails: { loading } },
});
const findAlert = () => wrapper.findComponent(GlAlert);
const findCreateIssueButton = () => wrapper.findByTestId('create-issue-button');
const findDrawer = () => wrapper.findComponent(GlDrawer);
const findIssueLink = () => wrapper.findByTestId('issue-link');
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findDetails = () => wrapper.findByTestId('details-list');
const createWrapper = ({
$apollo,
apolloSpy = getAlertDetailsQuerySpy,
mount = shallowMountExtended,
props = {},
} = {}) => {
let apolloOptions;
if ($apollo) {
apolloOptions = {
mocks: {
$apollo,
},
};
} else {
localVue = createLocalVue();
querySpy = apolloSpy;
const mockApollo = createMockApolloProvider(querySpy);
apolloOptions = {
localVue,
apolloProvider: mockApollo,
};
}
wrapper = mount(AlertDrawer, {
propsData: {
isAlertDrawerOpen: true,
projectId: '1',
selectedAlert: mockAlertDetails,
...props,
},
provide: {
projectPath: DEFAULT_PROJECT_PATH,
},
stubs: { GlDrawer },
...apolloOptions,
});
};
describe('default', () => {
it.each`
component | status | findComponent | state | mount
${'alert'} | ${'does not display'} | ${findAlert} | ${false} | ${undefined}
${'"Create Issue" button'} | ${'does not display'} | ${findCreateIssueButton} | ${false} | ${undefined}
${'details list'} | ${'does display'} | ${findDetails} | ${true} | ${undefined}
${'drawer'} | ${'does display'} | ${findDrawer} | ${true} | ${undefined}
${'issue link'} | ${'does display'} | ${findIssueLink} | ${true} | ${undefined}
${'loading icon'} | ${'does not display'} | ${findLoadingIcon} | ${false} | ${mountExtended}
`('$status the $component', ({ findComponent, state, mount }) => {
createWrapper({ $apollo: shallowApolloMock({}), mount });
expect(findComponent().exists()).toBe(state);
});
});
it('displays the issue link if an alert already has an issue associated with it', () => {
createWrapper();
expect(findIssueLink().exists()).toBe(true);
expect(findIssueLink().attributes('href')).toBe('/#/-/issues/02');
});
it('displays the loading icon when retrieving the alert details', () => {
createWrapper({ $apollo: shallowApolloMock({ loading: true }) });
expect(findLoadingIcon().exists()).toBe(true);
expect(findDetails().exists()).toBe(false);
});
it('displays the alert when there was an error retrieving alert details', async () => {
createWrapper({ apolloSpy: erroredGetAlertDetailsQuerySpy });
await wrapper.vm.$nextTick();
expect(findAlert().exists()).toBe(true);
});
describe('creating an issue', () => {
it('navigates to the created issue when the "Create Issue" button is clicked', async () => {
createWrapper({
$apollo: shallowApolloMock({}),
props: { selectedAlert: {} },
});
expect(findCreateIssueButton().exists()).toBe(true);
findCreateIssueButton().vm.$emit('click');
await waitForPromises;
expect(mutateSpy).toHaveBeenCalledTimes(1);
await wrapper.vm.$nextTick();
expect(visitUrl).toHaveBeenCalledWith('/#/-/issues/03');
});
it('displays the alert when there was an error creating an issue', async () => {
const erroredMutateSpy = jest
.fn()
.mockResolvedValue({ data: { createAlertIssue: { errors: ['test'] } } });
createWrapper({
$apollo: shallowApolloMock({ mutate: erroredMutateSpy }),
props: { selectedAlert: {} },
});
expect(findCreateIssueButton().exists()).toBe(true);
findCreateIssueButton().vm.$emit('click');
await waitForPromises;
expect(erroredMutateSpy).toHaveBeenCalledTimes(1);
await wrapper.vm.$nextTick();
expect(visitUrl).not.toHaveBeenCalled();
await wrapper.vm.$nextTick();
expect(findAlert().exists()).toBe(true);
});
});
});
......@@ -8,7 +8,11 @@ import { DEFAULT_FILTERS } from 'ee/threat_monitoring/components/alerts/constant
import createMockApollo from 'helpers/mock_apollo_helper';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import getAlertsQuery from '~/graphql_shared/queries/get_alerts.query.graphql';
import { defaultQuerySpy, emptyQuerySpy, loadingQuerySpy } from '../../mocks/mock_apollo';
import {
getAlertsQuerySpy,
emptyGetAlertsQuerySpy,
loadingQuerySpy,
} from '../../mocks/mock_apollo';
import { mockAlerts, mockPageInfo } from '../../mocks/mock_data';
let localVue;
......@@ -54,7 +58,7 @@ describe('AlertsList component', () => {
const findGlIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
const findGlSkeletonLoading = () => wrapper.findComponent(GlSkeletonLoading);
const createWrapper = ({ $apollo, apolloSpy = defaultQuerySpy, data, stubs = {} } = {}) => {
const createWrapper = ({ $apollo, apolloSpy = getAlertsQuerySpy, data, stubs = {} } = {}) => {
let apolloOptions;
if ($apollo) {
apolloOptions = {
......@@ -80,6 +84,7 @@ describe('AlertsList component', () => {
projectPath: DEFAULT_PROJECT_PATH,
},
stubs: {
AlertDrawer: true,
AlertStatus: true,
AlertFilters: true,
GlAlert: true,
......@@ -235,7 +240,7 @@ describe('AlertsList component', () => {
describe('empty state', () => {
beforeEach(() => {
createWrapper({ apolloSpy: emptyQuerySpy });
createWrapper({ apolloSpy: emptyGetAlertsQuerySpy });
});
it('does show the empty state', () => {
......
import { mockAlerts, mockPageInfo } from './mock_data';
import { mockAlertDetails, mockAlerts, mockPageInfo } from './mock_data';
export const defaultQuerySpy = jest.fn().mockResolvedValue({
export const getAlertsQuerySpy = jest.fn().mockResolvedValue({
data: { project: { alertManagementAlerts: { nodes: mockAlerts, pageInfo: mockPageInfo } } },
});
export const emptyQuerySpy = jest.fn().mockResolvedValue({
export const emptyGetAlertsQuerySpy = jest.fn().mockResolvedValue({
data: {
project: {
alertManagementAlerts: {
......@@ -16,3 +16,11 @@ export const emptyQuerySpy = jest.fn().mockResolvedValue({
});
export const loadingQuerySpy = jest.fn().mockReturnValue(new Promise(() => {}));
export const getAlertDetailsQuerySpy = jest.fn().mockResolvedValue({
data: { project: { alertManagementAlerts: { nodes: [mockAlertDetails] } } },
});
export const erroredGetAlertDetailsQuerySpy = jest.fn().mockResolvedValue({
errors: [{ message: 'Variable $fullPath of type ID! was provided invalid value' }],
});
......@@ -153,3 +153,5 @@ export const mockPageInfo = {
hasPreviousPage: false,
startCursor: 'eyJpZCI6IjM5Iiwic3RhcnRlZF9hdCI6IjIwMjAtMTItMDQgMTg6MDE6MDcuNzY1ODgyMDAwIFVUQyJ9',
};
export const mockAlertDetails = { iid: '01', issue: { iid: '02' }, title: 'dropingress' };
......@@ -9340,6 +9340,9 @@ msgstr ""
msgid "Create group label"
msgstr ""
msgid "Create incident"
msgstr ""
msgid "Create issue"
msgstr ""
......@@ -32813,6 +32816,9 @@ msgstr ""
msgid "There was an error with the reCAPTCHA. Please solve the reCAPTCHA again."
msgstr ""
msgid "There was an error."
msgstr ""
msgid "These dates affect how your epics appear in the roadmap. Set a fixed date or one inherited from the milestones assigned to issues in this epic."
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