Commit 8bc0dd05 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '36739-vulnerability-state-dropdown' into 'master'

Add security dashboard vulnerability state dropdown

See merge request gitlab-org/gitlab!22823
parents c30b6b5a 382e8e2f
import App from 'ee/vulnerability_management/components/app.vue';
import Vue from 'vue';
window.addEventListener('DOMContentLoaded', () => {
const el = document.getElementById('vulnerability-show-header');
const { state, id } = el.dataset;
return new Vue({
el,
render: h => h(App, { props: { state, id: Number(id) } }),
});
});
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import VulnerabilityStateDropdown from './vulnerability_state_dropdown.vue';
export default {
components: { GlLoadingIcon, VulnerabilityStateDropdown },
props: {
state: { type: String, required: true },
id: { type: Number, required: true },
},
data: () => ({
isLoading: false,
}),
methods: {
onVulnerabilityStateChange(newState) {
this.isLoading = true;
axios
.post(`/api/v4/vulnerabilities/${this.id}/${newState}`)
// Reload the page for now since the rest of the page is still a static haml file.
.then(() => window.location.reload(true))
.catch(() => {
createFlash(
s__(
'VulnerabilityManagement|Something went wrong, could not update vulnerability state.',
),
);
})
.finally(() => {
this.isLoading = false;
});
},
},
};
</script>
<template>
<div class="vulnerability-show-header">
<gl-loading-icon v-if="isLoading" />
<vulnerability-state-dropdown v-else :state="state" @change="onVulnerabilityStateChange" />
</div>
</template>
<script>
import { GlDropdown, GlIcon, GlButton } from '@gitlab/ui';
import { VULNERABILITY_STATES } from '../constants';
export default {
components: { GlDropdown, GlIcon, GlButton },
props: {
// Initial vulnerability state from the parent. This is used to disable the Change Status button
// if the selected value is the initial value, and also used to reset the dropdown back to the
// initial value if the user closed the dropdown without saving it.
state: { type: String, required: true },
},
data() {
return {
states: Object.values(VULNERABILITY_STATES),
// Vulnerability state that's picked in the dropdown. Defaults to the passed-in state.
selected: VULNERABILITY_STATES[this.state],
};
},
computed: {
// Alias for this.state, since using 'state' can get confusing within this component.
initialState() {
return VULNERABILITY_STATES[this.state];
},
},
methods: {
changeSelectedState(newState) {
this.selected = newState;
},
closeDropdown() {
this.$refs.dropdown.hide();
},
// Reset the selected dropdown item to what was passed in by the parent.
resetDropdown() {
this.selected = this.initialState;
},
saveState(selectedState) {
this.$emit('change', selectedState.action);
this.closeDropdown();
},
},
};
</script>
<template>
<gl-dropdown
ref="dropdown"
menu-class="p-0"
toggle-class="text-capitalize"
:text="state"
:right="true"
@hide="resetDropdown"
>
<li
v-for="stateItem in states"
:key="stateItem.action"
class="py-3 px-2 dropdown-item cursor-pointer border-bottom"
:class="[stateItem.action, { selected: selected === stateItem }]"
@click="changeSelectedState(stateItem)"
>
<div class="d-flex align-items-center">
<gl-icon
v-if="selected === stateItem"
class="selected-icon position-absolute"
name="status_success_borderless"
:size="24"
/>
<div class="pl-4 font-weight-bold">{{ stateItem.displayName }}</div>
</div>
<div class="pl-4">{{ stateItem.description }}</div>
</li>
<div class="text-right p-3">
<gl-button ref="cancel-button" class="mr-2" @click="closeDropdown">{{
__('Cancel')
}}</gl-button>
<gl-button
ref="save-button"
variant="success"
:disabled="selected === initialState"
@click="saveState(selected)"
>{{ s__('VulnerabilityManagement|Change status') }}
</gl-button>
</div>
</gl-dropdown>
</template>
import { s__ } from '~/locale';
// eslint-disable-next-line import/prefer-default-export
export const VULNERABILITY_STATES = {
dismissed: {
action: 'dismiss',
displayName: s__('VulnerabilityManagement|Dismiss'),
description: s__('VulnerabilityManagement|Will not fix or a false-positive'),
},
confirmed: {
action: 'confirm',
displayName: s__('VulnerabilityManagement|Confirm'),
description: s__('VulnerabilityManagement|A true-positive and will fix'),
},
resolved: {
action: 'resolve',
displayName: s__('VulnerabilityManagement|Resolved'),
description: s__('VulnerabilityManagement|Verified as fixed or mitigated'),
},
};
......@@ -4,7 +4,7 @@
- page_title @vulnerability.title
- page_description @vulnerability.description
.detail-page-header
.detail-page-header.align-items-center
.detail-page-header-body
.issuable-status-box.status-box.status-box-open.closed
%span= @vulnerability.state
......@@ -16,6 +16,9 @@
- else
%span#js-vulnerability-created
= time_ago_with_tooltip(@vulnerability.created_at)
%label.mb-0.mr-2= _('Status')
#vulnerability-show-header{ data: { state: @vulnerability.state,
id: @vulnerability.id } }
.issue-details.issuable-details
.detail-page-description.content-block
......
---
title: Adds vulnerability management state dropdown
merge_request: 22823
author:
type: added
import { shallowMount } from '@vue/test-utils';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import MockAdapter from 'axios-mock-adapter';
import App from 'ee/vulnerability_management/components/app.vue';
import waitForPromises from 'helpers/wait_for_promises';
import VulnerabilityStateDropdown from 'ee/vulnerability_management/components/vulnerability_state_dropdown.vue';
const mockAxios = new MockAdapter(axios);
jest.mock('~/flash');
describe('Vulnerability management app', () => {
let wrapper;
beforeEach(() => {
wrapper = shallowMount(App, {
propsData: {
id: 1,
state: 'doesnt matter',
},
});
});
afterEach(() => {
wrapper.destroy();
mockAxios.reset();
createFlash.mockReset();
});
it('the vulnerability state dropdown is rendered', () => {
expect(wrapper.find(VulnerabilityStateDropdown).exists()).toBe(true);
});
it('when the vulnerability state dropdown emits a change event, a POST API call is made', () => {
const dropdown = wrapper.find(VulnerabilityStateDropdown);
mockAxios.onPost().reply(201);
dropdown.vm.$emit('change');
return waitForPromises().then(() => {
expect(mockAxios.history.post).toHaveLength(1); // Check that a POST request was made.
});
});
it('when the vulnerability state changes but the API call fails, an error message is displayed', () => {
const dropdown = wrapper.find(VulnerabilityStateDropdown);
mockAxios.onPost().reply(400);
dropdown.vm.$emit('change');
return waitForPromises().then(() => {
expect(mockAxios.history.post).toHaveLength(1);
expect(createFlash).toHaveBeenCalledTimes(1);
});
});
});
import { shallowMount } from '@vue/test-utils';
import VulnerabilityStateDropdown from 'ee/vulnerability_management/components/vulnerability_state_dropdown.vue';
import { VULNERABILITY_STATES } from 'ee/vulnerability_management/constants';
const vulnerabilityStateEntries = Object.entries(VULNERABILITY_STATES);
describe('Vulnerability state dropdown component', () => {
let wrapper;
const createWrapper = (state = vulnerabilityStateEntries[0][0]) => {
// Create a dropdown that by default has the first vulnerability state selected.
wrapper = shallowMount(VulnerabilityStateDropdown, {
propsData: { state },
});
// Mock out this function, it's called by some methods in the component.
wrapper.vm.$refs.dropdown.hide = jest.fn();
};
// A selected item has a selected icon as its child. We don't use .classes('.selected') here
// because it only works with .find(), whereas item.contains() works with .find() and .findAll().
const isSelected = items => items.contains('.selected-icon');
const isDisabled = item => item.attributes('disabled') === 'true';
const firstUnselectedItem = () => wrapper.find('.dropdown-item:not(.selected)');
const selectedItem = () => wrapper.find('.dropdown-item.selected');
const saveButton = () => wrapper.find({ ref: 'save-button' });
const cancelButton = () => wrapper.find({ ref: 'cancel-button' });
const innerDropdown = () => wrapper.find({ ref: 'dropdown' });
afterEach(() => wrapper.destroy());
test.each(vulnerabilityStateEntries)(
'dropdown is created with the passed-in state already selected',
(stateString, stateObject) => {
createWrapper(stateString);
const dropdownItem = wrapper.find(`.dropdown-item.${stateObject.action}`);
// Check that the dropdown item is selected.
expect(isSelected(dropdownItem)).toBe(true);
},
);
it('if an unknown state is passed in, nothing will be selected by default', () => {
createWrapper('some unknown state');
const dropdownItems = wrapper.findAll('.dropdown-item');
expect(isSelected(dropdownItems)).toBe(false);
});
test.each(vulnerabilityStateEntries)(
`when the %s dropdown item is clicked, it's the only one that's selected`,
(stateString, stateObject) => {
// Start off with an unknown state so we can click through each item and see it change.
createWrapper('some unknown state');
const dropdownItem = wrapper.find(`.dropdown-item.${stateObject.action}`);
dropdownItem.trigger('click');
return wrapper.vm.$nextTick().then(() => {
// Check that the clicked item is selected.
expect(isSelected(dropdownItem)).toBe(true);
// Check that the other items aren't selected.
const otherItems = wrapper.find(`.dropdown-item:not(.${stateObject.action})`);
expect(isSelected(otherItems)).toBe(false);
});
},
);
it('the save button should be enabled/disabled based on if the selected item has changed or not', () => {
createWrapper();
const originalItem = selectedItem();
expect(isDisabled(saveButton())).toBe(true); // Check that the save button starts off as disabled.
firstUnselectedItem().trigger('click'); // Click on an unselected item.
return wrapper.vm
.$nextTick()
.then(() => {
expect(isDisabled(saveButton())).toBe(false); // Check that the save button has been enabled.
originalItem.trigger('click'); // Re-select the original item.
return wrapper.vm.$nextTick();
})
.then(() => {
expect(isDisabled(saveButton())).toBe(true); // Check that the save button has once again been disabled.
});
});
it('clicking on the save button will close the dropdown and fire a change event', () => {
createWrapper();
expect(isDisabled(saveButton())).toBe(true); // Check that the save button starts off disabled.
firstUnselectedItem().trigger('click'); // Click on an unselected item.
return wrapper.vm.$nextTick().then(() => {
saveButton().vm.$emit('click'); // Click on the save button.
const changeEvent = wrapper.emitted('change');
expect(wrapper.vm.$refs.dropdown.hide).toHaveBeenCalledTimes(1); // Check that the dropdown hide function was called.
expect(changeEvent).toHaveLength(1); // Check that a change event was emitted.
expect(changeEvent[0][0]).toBeTruthy(); // Check that the change event has been emitted with something as its first parameter.
});
});
it('clicking on the cancel button will close the dropdown without emitting any events', () => {
createWrapper();
expect(isDisabled(saveButton())).toBe(true); // Check that the save button starts out disabled.
firstUnselectedItem().trigger('click'); // Click on an unselected item.
return wrapper.vm.$nextTick().then(() => {
expect(isDisabled(saveButton())).toBe(false); // Check that the save button is enabled.
cancelButton().vm.$emit('click'); // Click on the cancel button.
expect(Object.keys(wrapper.emitted())).toHaveLength(0); // Check that no events have been emitted.
expect(wrapper.vm.$refs.dropdown.hide).toHaveBeenCalledTimes(1); // Check that hide was called on the inner dropdown.
});
});
it('when the dropdown is closed, the selected item resets back to the initial item', () => {
createWrapper();
const initialSelectedItem = selectedItem();
firstUnselectedItem().trigger('click'); // Click on an unselected item.
return wrapper.vm
.$nextTick()
.then(() => {
expect(selectedItem().element).not.toBe(initialSelectedItem.element); // Check that the selected item actually changed.
innerDropdown().vm.$emit('hide'); // Emit the dropdown hide event.
return wrapper.vm.$nextTick();
})
.then(() => {
expect(selectedItem().element).toBe(initialSelectedItem.element); // Check that the selected item has been reset back to the initial item.
});
});
});
......@@ -21561,6 +21561,30 @@ msgstr ""
msgid "VulnerabilityChart|Severity"
msgstr ""
msgid "VulnerabilityManagement|A true-positive and will fix"
msgstr ""
msgid "VulnerabilityManagement|Change status"
msgstr ""
msgid "VulnerabilityManagement|Confirm"
msgstr ""
msgid "VulnerabilityManagement|Dismiss"
msgstr ""
msgid "VulnerabilityManagement|Resolved"
msgstr ""
msgid "VulnerabilityManagement|Something went wrong, could not update vulnerability state."
msgstr ""
msgid "VulnerabilityManagement|Verified as fixed or mitigated"
msgstr ""
msgid "VulnerabilityManagement|Will not fix or a false-positive"
msgstr ""
msgid "Vulnerability|Class"
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