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> <script>
import { mapGetters, mapActions } from 'vuex'; 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'; import GlToggleVuex from '~/vue_shared/components/gl_toggle_vuex.vue';
export default { export default {
components: { components: {
DashboardFilter, StandardFilter,
GlToggleVuex, GlToggleVuex,
}, },
computed: { computed: {
...@@ -20,7 +20,7 @@ export default { ...@@ -20,7 +20,7 @@ export default {
<template> <template>
<div class="dashboard-filters border-bottom bg-gray-light"> <div class="dashboard-filters border-bottom bg-gray-light">
<div class="row mx-0 p-2"> <div class="row mx-0 p-2">
<dashboard-filter <standard-filter
v-for="filter in visibleFilters" v-for="filter in visibleFilters"
:key="filter.id" :key="filter.id"
class="col-sm-6 col-md-4 col-lg-2 p-2 js-filter" class="col-sm-6 col-md-4 col-lg-2 p-2 js-filter"
......
<script> <script>
import { GlDropdown, GlSearchBoxByType, GlIcon, GlTruncate, GlDropdownText } from '@gitlab/ui'; import { GlDropdown, GlSearchBoxByType, GlIcon, GlTruncate, GlDropdownText } from '@gitlab/ui';
import FilterItem from './filter_item.vue';
export default { export default {
components: { components: {
...@@ -9,47 +8,41 @@ export default { ...@@ -9,47 +8,41 @@ export default {
GlIcon, GlIcon,
GlTruncate, GlTruncate,
GlDropdownText, GlDropdownText,
FilterItem,
}, },
props: { props: {
filter: { value: {
type: Object, type: String,
required: false,
default: '',
},
name: {
type: String,
required: true, required: true,
}, },
}, selectedOptions: {
data() { type: Array,
return { required: true,
filterTerm: '',
};
},
computed: {
filterId() {
return this.filter.id;
}, },
selection() { showSearchBox: {
return this.filter.selection; type: Boolean,
required: false,
default: false,
}, },
},
computed: {
firstSelectedOption() { firstSelectedOption() {
return this.filter.options.find(option => this.selection.has(option.id))?.name || '-'; return this.selectedOptions[0] || '-';
}, },
extraOptionCount() { extraOptionCount() {
return this.selection.size - 1; return this.selectedOptions.length - 1;
},
filteredOptions() {
return this.filter.options.filter(option =>
option.name.toLowerCase().includes(this.filterTerm.toLowerCase()),
);
}, },
qaSelector() { qaSelector() {
return `filter_${this.filter.name.toLowerCase().replace(' ', '_')}_dropdown`; return `filter_${this.name.toLowerCase().replace(' ', '_')}_dropdown`;
}, },
}, },
methods: { methods: {
clickFilter(option) { emitInput(value) {
this.$emit('setFilter', { filterId: this.filterId, optionId: option.id }); this.$emit('input', value);
},
isSelected(option) {
return this.selection.has(option.id);
}, },
}, },
}; };
...@@ -57,11 +50,11 @@ export default { ...@@ -57,11 +50,11 @@ export default {
<template> <template>
<div class="dashboard-filter"> <div class="dashboard-filter">
<strong class="js-name">{{ filter.name }}</strong> <strong data-testid="name">{{ name }}</strong>
<gl-dropdown <gl-dropdown
class="gl-mt-2 gl-w-full" class="gl-mt-2 gl-w-full"
menu-class="dropdown-extended-height" menu-class="dropdown-extended-height"
:header-text="filter.name" :header-text="name"
toggle-class="gl-w-full" toggle-class="gl-w-full"
> >
<template #button-content> <template #button-content>
...@@ -77,22 +70,16 @@ export default { ...@@ -77,22 +70,16 @@ export default {
</template> </template>
<gl-search-box-by-type <gl-search-box-by-type
v-if="filter.options.length >= 20" v-if="showSearchBox"
v-model="filterTerm"
:placeholder="__('Filter...')" :placeholder="__('Filter...')"
@input="emitInput"
/> />
<filter-item <slot>
v-for="option in filteredOptions" <gl-dropdown-text>
:key="option.id" <span class="gl-text-gray-500">{{ __('No matching results') }}</span>
:is-checked="isSelected(option)" </gl-dropdown-text>
:text="option.name" </slot>
@click="clickFilter(option)"
/>
<gl-dropdown-text v-if="filteredOptions.length <= 0">
<span class="gl-text-gray-500">{{ __('No matching results') }}</span>
</gl-dropdown-text>
</gl-dropdown> </gl-dropdown>
</div> </div>
</template> </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 @@ ...@@ -2,12 +2,12 @@
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
import { ALL, STATE } from 'ee/security_dashboard/store/modules/filters/constants'; import { ALL, STATE } from 'ee/security_dashboard/store/modules/filters/constants';
import { setFilter } from 'ee/security_dashboard/store/modules/filters/utils'; 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'; import { initFirstClassVulnerabilityFilters, mapProjects } from 'ee/security_dashboard/helpers';
export default { export default {
components: { components: {
DashboardFilter, StandardFilter,
}, },
props: { props: {
projects: { type: Array, required: false, default: undefined }, projects: { type: Array, required: false, default: undefined },
...@@ -85,7 +85,7 @@ export default { ...@@ -85,7 +85,7 @@ export default {
<template> <template>
<div class="dashboard-filters border-bottom bg-gray-light"> <div class="dashboard-filters border-bottom bg-gray-light">
<div class="row mx-0 p-2"> <div class="row mx-0 p-2">
<dashboard-filter <standard-filter
v-for="filter in filters" v-for="filter in filters"
:key="filter.id" :key="filter.id"
class="col-sm-6 col-md-4 col-lg-2 p-2" 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 { 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 { mount } from '@vue/test-utils';
import { trimText } from 'helpers/text_helper'; import { trimText } from 'helpers/text_helper';
...@@ -12,11 +12,11 @@ const generateOptions = length => { ...@@ -12,11 +12,11 @@ const generateOptions = length => {
return Array.from({ length }).map((_, i) => generateOption(i)); return Array.from({ length }).map((_, i) => generateOption(i));
}; };
describe('Filter component', () => { describe('Standard Filter component', () => {
let wrapper; let wrapper;
const createWrapper = propsData => { const createWrapper = propsData => {
wrapper = mount(Filter, { propsData }); wrapper = mount(StandardFilter, { propsData });
}; };
const findSearchBox = () => wrapper.find(GlSearchBoxByType); const findSearchBox = () => wrapper.find(GlSearchBoxByType);
...@@ -58,7 +58,7 @@ describe('Filter component', () => { ...@@ -58,7 +58,7 @@ describe('Filter component', () => {
}); });
it('should display "Severity" as the option name', () => { 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', () => { it('should not have a search box', () => {
......
...@@ -2,7 +2,7 @@ import VueRouter from 'vue-router'; ...@@ -2,7 +2,7 @@ import VueRouter from 'vue-router';
import { createLocalVue, shallowMount } from '@vue/test-utils'; import { createLocalVue, shallowMount } from '@vue/test-utils';
import { initFirstClassVulnerabilityFilters } from 'ee/security_dashboard/helpers'; import { initFirstClassVulnerabilityFilters } from 'ee/security_dashboard/helpers';
import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue'; 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 router = new VueRouter();
const localVue = createLocalVue(); const localVue = createLocalVue();
...@@ -17,7 +17,7 @@ describe('First class vulnerability filters component', () => { ...@@ -17,7 +17,7 @@ describe('First class vulnerability filters component', () => {
{ id: 'gid://gitlab/Project/12', name: 'GitLab Com' }, { id: 'gid://gitlab/Project/12', name: 'GitLab Com' },
]; ];
const findFilters = () => wrapper.findAll(Filter); const findFilters = () => wrapper.findAll(StandardFilter);
const findStateFilter = () => findFilters().at(0); const findStateFilter = () => findFilters().at(0);
const findSeverityFilter = () => findFilters().at(1); const findSeverityFilter = () => findFilters().at(1);
const findReportTypeFilter = () => findFilters().at(2); const findReportTypeFilter = () => findFilters().at(2);
......
...@@ -11,7 +11,7 @@ module QA ...@@ -11,7 +11,7 @@ module QA
super super
base.class_eval do 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, ':data-qa-selector="qaSelector"' # rubocop:disable QA/ElementWithPattern
element :filter_dropdown_content element :filter_dropdown_content
end 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