Commit 0d2a5230 authored by Alexander Turinske's avatar Alexander Turinske

Implement alert status change

- add dropdown
- update styling
- manage errors
- create tests
parent df79193d
<script>
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { s__ } from '~/locale';
// TODO once backend is settled, update by either abstracting this out to app/assets/javascripts/graphql_shared or create new, modified query in #287757
import updateAlertStatusMutation from '~/alert_management/graphql/mutations/update_alert_status.mutation.graphql';
export default {
i18n: {
updateError: s__(
'ThreatMonitoring|There was an error while updating the status of the alert. Please try again.',
),
},
statuses: {
TRIGGERED: s__('ThreatMonitoring|Unreviewed'),
ACKNOWLEDGED: s__('ThreatMonitoring|In review'),
RESOLVED: s__('ThreatMonitoring|Resolved'),
IGNORED: s__('ThreatMonitoring|Dismissed'),
},
components: {
GlDropdown,
GlDropdownItem,
},
props: {
projectPath: {
type: String,
required: true,
},
alert: {
type: Object,
required: true,
},
},
data() {
return {
isUpdating: false,
status: this.alert.status,
};
},
methods: {
handleError() {
this.$emit('alert-error', this.$options.i18n.updateError);
},
updateAlertStatus(status) {
this.isUpdating = true;
this.status = status;
this.$apollo
.mutate({
mutation: updateAlertStatusMutation,
variables: {
iid: this.alert.iid,
status: status.toUpperCase(),
projectPath: this.projectPath,
},
})
.then(resp => {
const errors = resp.data?.updateAlertStatus?.errors || [];
if (errors[0]) {
this.handleError();
}
this.$emit('alert-update');
})
.catch(() => {
this.status = this.alert.status;
this.handleError();
})
.finally(() => {
this.isUpdating = false;
});
},
},
};
</script>
<template>
<div class="dropdown dropdown-menu-selectable">
<gl-dropdown
:loading="isUpdating"
right
:text="$options.statuses[status]"
class="gl-w-full"
toggle-class="dropdown-menu-toggle"
>
<div class="dropdown-content dropdown-body">
<gl-dropdown-item
v-for="(label, field) in $options.statuses"
:key="field"
:active="field === status"
:active-class="'is-active'"
@click="updateAlertStatus(field)"
>
{{ label }}
</gl-dropdown-item>
</div>
</gl-dropdown>
</div>
</template>
......@@ -15,6 +15,7 @@ import { convertToSnakeCase } from '~/lib/utils/text_utility';
// TODO once backend is settled, update by either abstracting this out to app/assets/javascripts/graphql_shared or create new, modified query in #287757
import getAlerts from '~/alert_management/graphql/queries/get_alerts.query.graphql';
import { FIELDS, MESSAGES, PAGE_SIZE, STATUSES } from './constants';
import AlertStatus from './alert_status.vue';
export default {
PAGE_SIZE,
......@@ -24,6 +25,7 @@ export default {
STATUSES,
},
components: {
AlertStatus,
GlAlert,
GlIntersectionObserver,
GlLink,
......@@ -60,6 +62,7 @@ export default {
return {
alerts: [],
errored: false,
errorMsg: '',
isErrorAlertDismissed: false,
pageInfo: {},
sort: 'STARTED_AT_DESC',
......@@ -85,6 +88,7 @@ export default {
methods: {
errorAlertDismissed() {
this.errored = false;
this.errorMsg = '';
this.isErrorAlertDismissed = true;
},
fetchNextPage() {
......@@ -110,6 +114,13 @@ export default {
this.sort = `${sortingColumn}_${sortingDirection}`;
},
handleAlertError(msg) {
this.errored = true;
this.errorMsg = msg;
},
handleStatusUpdate() {
this.$apollo.queries.alerts.refetch();
},
},
};
</script>
......@@ -131,7 +142,7 @@ export default {
data-testid="threat-alerts-error"
@dismiss="errorAlertDismissed"
>
{{ $options.i18n.MESSAGES.ERROR }}
{{ errorMsg || $options.i18n.MESSAGES.ERROR }}
</gl-alert>
<gl-table
......@@ -169,9 +180,12 @@ export default {
</template>
<template #cell(status)="{ item }">
<div data-testid="threat-alerts-status">
{{ $options.i18n.STATUSES[item.status] }}
</div>
<alert-status
:alert="item"
:project-path="projectPath"
@alert-error="handleAlertError"
@alert-update="handleStatusUpdate"
/>
</template>
<template #table-busy>
......
import { shallowMount } from '@vue/test-utils';
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises';
import AlertStatus from 'ee/threat_monitoring/components/alerts/alert_status.vue';
import updateAlertStatusMutation from '~/alert_management/graphql/mutations/update_alert_status.mutation.graphql';
import { mockAlerts } from '../../mock_data';
const mockAlert = mockAlerts[0];
describe('AlertStatus', () => {
let wrapper;
const apolloMock = {
mutate: jest.fn(),
};
const findStatusDropdown = () => wrapper.find(GlDropdown);
const findFirstStatusOption = () => findStatusDropdown().find(GlDropdownItem);
const selectFirstStatusOption = () => {
findFirstStatusOption().vm.$emit('click');
return waitForPromises();
};
function createWrapper() {
wrapper = shallowMount(AlertStatus, {
propsData: {
alert: { ...mockAlert },
projectPath: 'gitlab-org/gitlab',
},
mocks: {
$apollo: apolloMock,
},
});
}
beforeEach(() => {
createWrapper();
});
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
describe('a successful request', () => {
const { iid } = mockAlert;
const mockUpdatedMutationResult = {
data: {
updateAlertStatus: {
errors: [],
alert: {
iid,
status: 'RESOLVED',
},
},
},
};
beforeEach(() => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult);
});
it('calls `$apollo.mutate` with `updateAlertStatus` mutation and variables containing `iid`, `status`, & `projectPath`', () => {
findFirstStatusOption().vm.$emit('click');
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: updateAlertStatusMutation,
variables: {
iid,
status: 'TRIGGERED',
projectPath: 'gitlab-org/gitlab',
},
});
});
it('emits to the list to refetch alerts on a successful alert status change', async () => {
expect(wrapper.emitted('alert-update')).toBeUndefined();
await selectFirstStatusOption();
expect(wrapper.emitted('alert-update').length).toBe(1);
});
});
describe('a failed request', () => {
beforeEach(() => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error()));
});
it('emits an error', async () => {
await selectFirstStatusOption();
expect(wrapper.emitted('alert-error')[0]).toEqual([
'There was an error while updating the status of the alert. Please try again.',
]);
});
it('emits an error when triggered a second time', async () => {
await selectFirstStatusOption();
await wrapper.vm.$nextTick();
await selectFirstStatusOption();
// Should emit two errors [0,1]
expect(wrapper.emitted('alert-error').length > 1).toBe(true);
});
it('reverts the status of an alert on failure', async () => {
const status = 'Unreviewed';
expect(findStatusDropdown().props('text')).toBe(status);
await selectFirstStatusOption();
await wrapper.vm.$nextTick();
expect(findStatusDropdown().props('text')).toBe(status);
});
});
});
import { GlIntersectionObserver, GlSkeletonLoading } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import AlertsList from 'ee/threat_monitoring/components/alerts/alerts_list.vue';
import AlertStatus from 'ee/threat_monitoring/components/alerts/alert_status.vue';
import { mockAlerts } from '../../mock_data';
const alerts = [
{
iid: '01',
title: 'Issue 01',
status: 'TRIGGERED',
startedAt: '2020-11-19T18:36:23Z',
},
{
iid: '02',
title: 'Issue 02',
status: 'ACKNOWLEDGED',
startedAt: '2020-11-16T21:59:28Z',
},
{
iid: '03',
title: 'Issue 03',
status: 'RESOLVED',
startedAt: '2020-11-13T20:03:04Z',
},
{
iid: '04',
title: 'Issue 04',
status: 'IGNORED',
startedAt: '2020-10-29T13:37:55Z',
},
];
const alerts = mockAlerts;
const pageInfo = {
endCursor: 'eyJpZCI6IjIwIiwic3RhcnRlZF9hdCI6IjIwMjAtMTItMDMgMjM6MTI6NDkuODM3Mjc1MDAwIFVUQyJ9',
......@@ -38,11 +15,13 @@ const pageInfo = {
describe('AlertsList component', () => {
let wrapper;
const refetchSpy = jest.fn();
const apolloMock = {
queries: {
alerts: {
fetchMore: jest.fn().mockResolvedValue(),
loading: false,
refetch: refetchSpy,
},
},
};
......@@ -53,13 +32,13 @@ describe('AlertsList component', () => {
const findStartedAtColumnHeader = () =>
wrapper.find("[data-testid='threat-alerts-started-at-header']");
const findIdColumn = () => wrapper.find("[data-testid='threat-alerts-id']");
const findStatusColumn = () => wrapper.find("[data-testid='threat-alerts-status']");
const findStatusColumn = () => wrapper.find(AlertStatus);
const findStatusColumnHeader = () => wrapper.find("[data-testid='threat-alerts-status-header']");
const findEmptyState = () => wrapper.find("[data-testid='threat-alerts-empty-state']");
const findGlIntersectionObserver = () => wrapper.find(GlIntersectionObserver);
const findGlSkeletonLoading = () => wrapper.find(GlSkeletonLoading);
const createWrapper = ({ $apollo = apolloMock, data = {} } = {}) => {
const createWrapper = ({ $apollo = apolloMock, data = {}, stubs = {} } = {}) => {
wrapper = mount(AlertsList, {
mocks: {
$apollo,
......@@ -69,9 +48,11 @@ describe('AlertsList component', () => {
projectPath: '#',
},
stubs: {
AlertStatus: true,
GlAlert: true,
GlLoadingIcon: true,
GlIntersectionObserver: true,
...stubs,
},
data() {
return data;
......@@ -206,4 +187,29 @@ describe('AlertsList component', () => {
expect(wrapper.vm.$apollo.queries.alerts.fetchMore).toHaveBeenCalledTimes(1);
});
});
describe('changing alert status', () => {
beforeEach(() => {
createWrapper();
wrapper.setData({
alerts,
});
});
it('does refetch the alerts when an alert status has changed', async () => {
expect(refetchSpy).toHaveBeenCalledTimes(0);
findStatusColumn().vm.$emit('alert-update');
await wrapper.vm.$nextTick();
expect(refetchSpy).toHaveBeenCalledTimes(1);
});
it('does show an error if changing an alert status fails', async () => {
const error = 'Error.';
expect(findErrorAlert().exists()).toBe(false);
findStatusColumn().vm.$emit('alert-error', error);
await wrapper.vm.$nextTick();
expect(findErrorAlert().exists()).toBe(true);
expect(findErrorAlert().text()).toBe(error);
});
});
});
......@@ -81,3 +81,30 @@ export const formattedMockNetworkPolicyStatisticsResponse = {
},
opsTotal: { drops: 84, total: 2703 },
};
export const mockAlerts = [
{
iid: '01',
title: 'Issue 01',
status: 'TRIGGERED',
startedAt: '2020-11-19T18:36:23Z',
},
{
iid: '02',
title: 'Issue 02',
status: 'ACKNOWLEDGED',
startedAt: '2020-11-16T21:59:28Z',
},
{
iid: '03',
title: 'Issue 03',
status: 'RESOLVED',
startedAt: '2020-11-13T20:03:04Z',
},
{
iid: '04',
title: 'Issue 04',
status: 'IGNORED',
startedAt: '2020-10-29T13:37:55Z',
},
];
......@@ -28560,6 +28560,9 @@ msgstr ""
msgid "ThreatMonitoring|There was an error displaying the alerts. Confirm your endpoint's configuration details to ensure alerts appear."
msgstr ""
msgid "ThreatMonitoring|There was an error while updating the status of the alert. Please try again."
msgstr ""
msgid "ThreatMonitoring|Threat Monitoring"
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