Commit c4d034a2 authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch '210327-make-vulnerability-filter-reusable' into 'master'

Make vulnerability filter template extensible and reusable

See merge request gitlab-org/gitlab!45863
parents 06956103 8f96459e
<script>
import { mapGetters, mapActions } from 'vuex';
import DashboardFilter from './filters/filter.vue';
import StandardFilter from './filters/standard_filter.vue';
import GlToggleVuex from '~/vue_shared/components/gl_toggle_vuex.vue';
export default {
components: {
DashboardFilter,
StandardFilter,
GlToggleVuex,
},
computed: {
......@@ -20,7 +20,7 @@ export default {
<template>
<div class="dashboard-filters border-bottom bg-gray-light">
<div class="row mx-0 p-2">
<dashboard-filter
<standard-filter
v-for="filter in visibleFilters"
:key="filter.id"
class="col-sm-6 col-md-4 col-lg-2 p-2 js-filter"
......
<script>
import { GlDropdown, GlSearchBoxByType, GlIcon, GlTruncate, GlDropdownText } from '@gitlab/ui';
import FilterItem from './filter_item.vue';
export default {
components: {
......@@ -9,47 +8,41 @@ export default {
GlIcon,
GlTruncate,
GlDropdownText,
FilterItem,
},
props: {
filter: {
type: Object,
value: {
type: String,
required: false,
default: '',
},
name: {
type: String,
required: true,
},
},
data() {
return {
filterTerm: '',
};
},
computed: {
filterId() {
return this.filter.id;
selectedOptions: {
type: Array,
required: true,
},
selection() {
return this.filter.selection;
showSearchBox: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
firstSelectedOption() {
return this.filter.options.find(option => this.selection.has(option.id))?.name || '-';
return this.selectedOptions[0] || '-';
},
extraOptionCount() {
return this.selection.size - 1;
},
filteredOptions() {
return this.filter.options.filter(option =>
option.name.toLowerCase().includes(this.filterTerm.toLowerCase()),
);
return this.selectedOptions.length - 1;
},
qaSelector() {
return `filter_${this.filter.name.toLowerCase().replace(' ', '_')}_dropdown`;
return `filter_${this.name.toLowerCase().replace(' ', '_')}_dropdown`;
},
},
methods: {
clickFilter(option) {
this.$emit('setFilter', { filterId: this.filterId, optionId: option.id });
},
isSelected(option) {
return this.selection.has(option.id);
emitInput(value) {
this.$emit('input', value);
},
},
};
......@@ -57,11 +50,11 @@ export default {
<template>
<div class="dashboard-filter">
<strong class="js-name">{{ filter.name }}</strong>
<strong data-testid="name">{{ name }}</strong>
<gl-dropdown
class="gl-mt-2 gl-w-full"
menu-class="dropdown-extended-height"
:header-text="filter.name"
:header-text="name"
toggle-class="gl-w-full"
>
<template #button-content>
......@@ -77,22 +70,16 @@ export default {
</template>
<gl-search-box-by-type
v-if="filter.options.length >= 20"
v-model="filterTerm"
v-if="showSearchBox"
:placeholder="__('Filter...')"
@input="emitInput"
/>
<filter-item
v-for="option in filteredOptions"
:key="option.id"
:is-checked="isSelected(option)"
:text="option.name"
@click="clickFilter(option)"
/>
<gl-dropdown-text v-if="filteredOptions.length <= 0">
<span class="gl-text-gray-500">{{ __('No matching results') }}</span>
</gl-dropdown-text>
<slot>
<gl-dropdown-text>
<span class="gl-text-gray-500">{{ __('No matching results') }}</span>
</gl-dropdown-text>
</slot>
</gl-dropdown>
</div>
</template>
<script>
import FilterBody from './filter_body.vue';
import FilterItem from './filter_item.vue';
export default {
components: {
FilterBody,
FilterItem,
},
props: {
filter: {
type: Object,
required: true,
},
},
data() {
return {
searchTerm: '',
};
},
computed: {
selection() {
return this.filter.selection;
},
filteredOptions() {
return this.filter.options.filter(option =>
option.name.toLowerCase().includes(this.searchTerm.toLowerCase()),
);
},
selectedOptionsNames() {
return Array.from(this.selection).map(id => this.filter.options.find(x => x.id === id).name);
},
},
methods: {
clickFilter(option) {
this.$emit('setFilter', { filterId: this.filter.id, optionId: option.id });
},
isSelected(option) {
return this.selection.has(option.id);
},
},
};
</script>
<template>
<filter-body
v-model.trim="searchTerm"
:name="filter.name"
:selected-options="selectedOptionsNames"
:show-search-box="filter.options.length >= 20"
>
<filter-item
v-for="option in filteredOptions"
:key="option.id"
:is-checked="isSelected(option)"
:text="option.name"
@click="clickFilter(option)"
/>
</filter-body>
</template>
......@@ -2,12 +2,12 @@
import { isEqual } from 'lodash';
import { ALL, STATE } from 'ee/security_dashboard/store/modules/filters/constants';
import { setFilter } from 'ee/security_dashboard/store/modules/filters/utils';
import DashboardFilter from 'ee/security_dashboard/components/filters/filter.vue';
import StandardFilter from 'ee/security_dashboard/components/filters/standard_filter.vue';
import { initFirstClassVulnerabilityFilters, mapProjects } from 'ee/security_dashboard/helpers';
export default {
components: {
DashboardFilter,
StandardFilter,
},
props: {
projects: { type: Array, required: false, default: undefined },
......@@ -85,7 +85,7 @@ export default {
<template>
<div class="dashboard-filters border-bottom bg-gray-light">
<div class="row mx-0 p-2">
<dashboard-filter
<standard-filter
v-for="filter in filters"
:key="filter.id"
class="col-sm-6 col-md-4 col-lg-2 p-2"
......
import { GlDropdown, GlSearchBoxByType } from '@gitlab/ui';
import FilterBody from 'ee/security_dashboard/components/filters/filter_body.vue';
import { mount } from '@vue/test-utils';
describe('Filter Body component', () => {
let wrapper;
const defaultProps = {
name: 'Some Name',
selectedOptions: [],
};
const createComponent = (props, slotContent = '') => {
wrapper = mount(FilterBody, {
propsData: { ...defaultProps, ...props },
slots: { default: slotContent },
});
};
const dropdownButton = () => wrapper.find('.dropdown-toggle');
const searchBox = () => wrapper.find(GlSearchBoxByType);
afterEach(() => {
wrapper.destroy();
});
it('shows the correct label name and dropdown header name', () => {
createComponent();
expect(wrapper.find('[data-testid="name"]').text()).toBe(defaultProps.name);
expect(wrapper.find(GlDropdown).props('headerText')).toBe(defaultProps.name);
});
describe('dropdown button', () => {
it('shows the selected option name if only one option is selected', () => {
const props = { selectedOptions: ['Some Selected Option'] };
createComponent(props);
expect(dropdownButton().text()).toBe(props.selectedOptions[0]);
});
it('shows the selected option name and "+x more" if more than one option is selected', () => {
const props = { selectedOptions: ['Option 1', 'Option 2', 'Option 3'] };
createComponent(props);
expect(dropdownButton().text()).toMatch(/Option 1\s+\+2 more/);
});
});
describe('search box', () => {
it.each([true, false])('shows/hides search box when the showSearchBox prop is %s', show => {
createComponent({ showSearchBox: show });
expect(searchBox().exists()).toBe(show);
});
it('emits input event on component when search box input is changed', () => {
const text = 'abc';
createComponent({ showSearchBox: true });
searchBox().vm.$emit('input', text);
expect(wrapper.emitted('input')[0][0]).toBe(text);
});
});
describe('dropdown body', () => {
it('shows slot content', () => {
const slotContent = 'some slot content';
createComponent({}, slotContent);
expect(wrapper.text()).toContain(slotContent);
});
it('shows no matching results text if there is no slot content', () => {
createComponent();
expect(wrapper.text()).toContain('No matching results');
});
});
});
import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
import Filter from 'ee/security_dashboard/components/filters/filter.vue';
import StandardFilter from 'ee/security_dashboard/components/filters/standard_filter.vue';
import { mount } from '@vue/test-utils';
import { trimText } from 'helpers/text_helper';
......@@ -12,11 +12,11 @@ const generateOptions = length => {
return Array.from({ length }).map((_, i) => generateOption(i));
};
describe('Filter component', () => {
describe('Standard Filter component', () => {
let wrapper;
const createWrapper = propsData => {
wrapper = mount(Filter, { propsData });
wrapper = mount(StandardFilter, { propsData });
};
const findSearchBox = () => wrapper.find(GlSearchBoxByType);
......@@ -58,7 +58,7 @@ describe('Filter component', () => {
});
it('should display "Severity" as the option name', () => {
expect(wrapper.find('.js-name').text()).toContain('Severity');
expect(wrapper.find('[data-testid="name"]').text()).toEqual('Severity');
});
it('should not have a search box', () => {
......
......@@ -2,7 +2,7 @@ import VueRouter from 'vue-router';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { initFirstClassVulnerabilityFilters } from 'ee/security_dashboard/helpers';
import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue';
import Filter from 'ee/security_dashboard/components/filters/filter.vue';
import StandardFilter from 'ee/security_dashboard/components/filters/standard_filter.vue';
const router = new VueRouter();
const localVue = createLocalVue();
......@@ -17,7 +17,7 @@ describe('First class vulnerability filters component', () => {
{ id: 'gid://gitlab/Project/12', name: 'GitLab Com' },
];
const findFilters = () => wrapper.findAll(Filter);
const findFilters = () => wrapper.findAll(StandardFilter);
const findStateFilter = () => findFilters().at(0);
const findSeverityFilter = () => findFilters().at(1);
const findReportTypeFilter = () => findFilters().at(2);
......
......@@ -11,7 +11,7 @@ module QA
super
base.class_eval do
view 'ee/app/assets/javascripts/security_dashboard/components/filter.vue' do
view 'ee/app/assets/javascripts/security_dashboard/components/filters/standard_filter.vue' do
element :filter_dropdown, ':data-qa-selector="qaSelector"' # rubocop:disable QA/ElementWithPattern
element :filter_dropdown_content
end
......
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