Skip to content
代码片段 群组 项目
未验证 提交 a8181e9f 编辑于 作者: Savas Vedova's avatar Savas Vedova 提交者: GitLab
浏览文件

Implement UI for the Status Token

- Add logic to select/deselect items
- Implement placeholder logic
- Set default selected values
上级 e3401ad0
No related branches found
No related tags found
无相关合并请求
<script>
import {
GlIcon,
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlDropdownSectionHeader,
} from '@gitlab/ui';
import { VULNERABILITY_STATE_OBJECTS } from 'ee/vulnerabilities/constants';
import { getSelectedOptionsText } from '~/lib/utils/listbox_helpers';
import { s__, n__ } from '~/locale';
import { GROUPS } from '../../filters/status_filter.vue';
import { ALL_ID as ALL_STATUS_VALUE } from '../../filters/constants';
const { detected, confirmed } = VULNERABILITY_STATE_OBJECTS;
const ALL_DISMISSED_VALUE = GROUPS[1].options[0].value;
const DISMISSAL_REASON_VALUES = GROUPS[1].options.slice(1).map(({ value }) => value);
const DEFAULT_VALUES = [detected.searchParamValue, confirmed.searchParamValue];
export default {
DEFAULT_VALUES,
GROUPS,
components: {
GlIcon,
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlDropdownSectionHeader,
},
props: {
config: {
type: Object,
required: true,
},
// contains the token, with the selected operand (e.g.: '=') and the data (comma separated, e.g.: 'MIT, GNU')
value: {
type: Object,
required: true,
},
active: {
type: Boolean,
required: true,
},
},
data() {
return {
searchTerm: '',
selectedStatuses: [...DEFAULT_VALUES],
};
},
computed: {
tokenValue() {
return {
...this.value,
// when the token is active (dropdown is open), we set the value to null to prevent an UX issue
// in which only the last selected item is being displayed.
// more information: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2381
data: this.active ? null : this.selectedStatuses,
};
},
toggleText() {
// "All dismissal reasons" option is selected
if (this.selectedStatuses.length === 1 && this.selectedStatuses[0] === ALL_DISMISSED_VALUE) {
return s__('SecurityReports|Dismissed (all reasons)');
}
// Dismissal reason(s) is selected
if (this.selectedStatuses.every((value) => DISMISSAL_REASON_VALUES.includes(value))) {
return n__(`Dismissed (%d reason)`, `Dismissed (%d reasons)`, this.selectedStatuses.length);
}
return getSelectedOptionsText({
options: [...GROUPS[0].options, ...GROUPS[1].options],
selected: this.selectedStatuses,
});
},
},
methods: {
setSearchTerm(token) {
// the data can be either a string or an array, in which case we don't want to perform the search
if (typeof token.data === 'string') {
this.searchTerm = token.data.toLowerCase();
}
},
toggleSelectedStatus(selectedValue) {
const allStatusSelected = selectedValue === ALL_STATUS_VALUE;
const allDismissedSelected = selectedValue === ALL_DISMISSED_VALUE;
// Unselect
if (this.selectedStatuses.includes(selectedValue)) {
this.selectedStatuses = this.selectedStatuses.filter((s) => s !== selectedValue);
if (this.selectedStatuses.length) {
return;
}
}
// If there is no option selected or the All Statuses option is selected, simply set
// the array to ALL_STATUS_VALUE
if (!this.selectedStatuses.length || allStatusSelected) {
this.selectedStatuses = [ALL_STATUS_VALUE];
return;
}
// Remove other dismissal values when All Dismissed option is selected
if (allDismissedSelected) {
this.selectedStatuses = this.selectedStatuses.filter(
(s) => !DISMISSAL_REASON_VALUES.includes(s),
);
}
// When a dismissal reason is selected, unselect All Dismissed option
else if (DISMISSAL_REASON_VALUES.includes(selectedValue)) {
this.selectedStatuses = this.selectedStatuses.filter((s) => s !== ALL_DISMISSED_VALUE);
}
// Otherwise select the item. Make sure to unselect ALL_STATUS_VALUE anyways because
this.selectedStatuses = this.selectedStatuses.filter((s) => s !== ALL_STATUS_VALUE);
this.selectedStatuses.push(selectedValue);
},
isStatusSelected(name) {
return Boolean(this.selectedStatuses.find((s) => name === s));
},
},
groups: {
statusOptions: GROUPS[0].options,
dismissalReasonOptions: GROUPS[1].options,
},
i18n: {
statusLabel: s__('SecurityReports|Status'),
dismissedAsLabel: GROUPS[1].text,
},
};
</script>
<template>
<gl-filtered-search-token
:config="config"
v-bind="{ ...$props, ...$attrs }"
:multi-select-values="selectedStatuses"
:value="tokenValue"
v-on="$listeners"
@select="toggleSelectedStatus"
@input="setSearchTerm"
>
<template #view>
{{ toggleText }}
</template>
<template #suggestions>
<gl-dropdown-section-header>{{ $options.i18n.statusLabel }}</gl-dropdown-section-header>
<gl-dropdown-divider />
<gl-filtered-search-suggestion
v-for="status in $options.groups.statusOptions"
:key="status.value"
:value="status.value"
>
<div class="gl-display-flex gl-align-items-center">
<gl-icon
name="check"
class="gl-mr-3 gl-flex-shrink-0 gl-text-gray-700"
:class="{ 'gl-visibility-hidden': !isStatusSelected(status.value) }"
:data-testid="`status-icon-${status.value}`"
/>
{{ status.text }}
</div>
</gl-filtered-search-suggestion>
<gl-dropdown-divider />
<gl-dropdown-section-header>{{ $options.i18n.dismissedAsLabel }}</gl-dropdown-section-header>
<gl-filtered-search-suggestion
v-for="status in $options.groups.dismissalReasonOptions"
:key="status.value"
:value="status.value"
>
<div class="gl-display-flex gl-align-items-center">
<gl-icon
name="check"
class="gl-mr-3 gl-flex-shrink-0 gl-text-gray-700"
:class="{ 'gl-visibility-hidden': !isStatusSelected(status.value) }"
:data-testid="`status-icon-${status.value}`"
/>
{{ status.text }}
</div>
</gl-filtered-search-suggestion>
</template>
</gl-filtered-search-token>
</template>
<script> <script>
import { GlFilteredSearch } from '@gitlab/ui'; import { GlFilteredSearch } from '@gitlab/ui';
import { OPERATORS_IS, OPERATOR_IS } from '~/vue_shared/components/filtered_search_bar/constants';
import StatusToken from './tokens/status_token.vue';
export default { export default {
components: { components: {
...@@ -7,13 +9,29 @@ export default { ...@@ -7,13 +9,29 @@ export default {
}, },
data() { data() {
return { return {
value: [], value: [
currentFilterParams: null, {
type: 'status',
value: {
data: StatusToken.DEFAULT_VALUES,
operator: OPERATOR_IS,
},
},
],
}; };
}, },
computed: { computed: {
tokens() { tokens() {
return []; return [
{
type: 'status',
title: StatusToken.i18n.statusLabel,
multiSelect: true,
unique: true,
token: StatusToken,
operators: OPERATORS_IS,
},
];
}, },
}, },
}; };
...@@ -23,6 +41,7 @@ export default { ...@@ -23,6 +41,7 @@ export default {
<gl-filtered-search <gl-filtered-search
:placeholder="s__('Vulnerability|Search or filter vulnerabilities...')" :placeholder="s__('Vulnerability|Search or filter vulnerabilities...')"
:available-tokens="tokens" :available-tokens="tokens"
:value="value"
terms-as-tokens terms-as-tokens
/> />
</template> </template>
import { GlFilteredSearchToken } from '@gitlab/ui';
import { nextTick } from 'vue';
import StatusToken from 'ee/security_dashboard/components/shared/filtered_search/tokens/status_token.vue';
import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants';
import { stubComponent } from 'helpers/stub_component';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
describe('Status Token component', () => {
let wrapper;
const mockConfig = {
multiSelect: true,
unique: true,
operators: OPERATORS_IS,
};
const createWrapper = ({
value = { data: StatusToken.DEFAULT_VALUES, operator: '=' },
active = false,
stubs,
mountFn = shallowMountExtended,
} = {}) => {
wrapper = mountFn(StatusToken, {
propsData: {
config: mockConfig,
value,
active,
},
provide: {
portalName: 'fake target',
alignSuggestions: jest.fn(),
termsAsTokens: () => false,
},
stubs,
});
};
const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken);
const findCheckedIcon = (value) => wrapper.findByTestId(`status-icon-${value}`);
const clickDropdownItem = async (...ids) => {
await Promise.all(
ids.map((id) => {
findFilteredSearchToken().vm.$emit('select', id);
return nextTick();
}),
);
await nextTick();
};
const allOptionsExcept = (value) => {
return StatusToken.GROUPS.flatMap((i) => i.options)
.map((i) => i.value)
.filter((i) => i !== value);
};
describe('default view', () => {
const findSlotView = () => wrapper.findByTestId('slot-view');
const findSlotSuggestions = () => wrapper.findByTestId('slot-suggestions');
beforeEach(() => {
createWrapper({
stubs: {
GlFilteredSearchToken: stubComponent(GlFilteredSearchToken, {
template: `
<div>
<div data-testid="slot-view">
<slot name="view"></slot>
</div>
<div data-testid="slot-suggestions">
<slot name="suggestions"></slot>
</div>
</div>`,
}),
},
});
});
it('shows the label', () => {
expect(findSlotView().text()).toBe('Needs triage +1 more');
});
it('shows the dropdown with correct options', () => {
expect(
findSlotSuggestions()
.text()
.split('\n')
.map((s) => s.trim())
.filter((i) => i),
).toEqual([
'Status', // subheader
'All statuses',
'Needs triage',
'Confirmed',
'Resolved',
'Dismissed as...', // subheader
'All dismissal reasons',
'Acceptable risk',
'False positive',
'Mitigating control',
'Used in tests',
'Not applicable',
]);
});
});
describe('item selection', () => {
beforeEach(async () => {
createWrapper({});
await clickDropdownItem('ALL');
});
it('toggles the item selection when clicked on', async () => {
const isOptionChecked = (v) => !findCheckedIcon(v).classes('gl-visibility-hidden');
await clickDropdownItem('CONFIRMED', 'RESOLVED');
expect(isOptionChecked('ALL')).toBe(false);
expect(isOptionChecked('CONFIRMED')).toBe(true);
expect(isOptionChecked('RESOLVED')).toBe(true);
// Add a dismissal reason
await clickDropdownItem('ACCEPTABLE_RISK');
expect(isOptionChecked('CONFIRMED')).toBe(true);
expect(isOptionChecked('RESOLVED')).toBe(true);
expect(isOptionChecked('ACCEPTABLE_RISK')).toBe(true);
expect(isOptionChecked('DISMISSED')).toBe(false);
// Select all
await clickDropdownItem('ALL');
allOptionsExcept('ALL').forEach((value) => {
expect(isOptionChecked(value)).toBe(false);
});
// Select All Dismissed Values
await clickDropdownItem('DISMISSED');
allOptionsExcept('DISMISSED').forEach((value) => {
expect(isOptionChecked(value)).toBe(false);
});
// Selecting another dismissed should unselect All Dismissed values
await clickDropdownItem('USED_IN_TESTS');
expect(isOptionChecked('USED_IN_TESTS')).toBe(true);
expect(isOptionChecked('DISMISSED')).toBe(false);
});
});
describe('toggle text', () => {
const findSlotView = () => wrapper.findAllByTestId('filtered-search-token-segment').at(2);
beforeEach(async () => {
createWrapper({ value: {}, mountFn: mountExtended });
// Let's set initial state as ALL. It's easier to manipulate because
// selecting a new value should unselect this value automatically and
// we can start from an empty state.
await clickDropdownItem('ALL');
});
it('shows "Dismissed (all reasons)" when only "All dismissal reasons" option is selected', async () => {
await clickDropdownItem('DISMISSED');
expect(findSlotView().text()).toBe('Dismissed (all reasons)');
});
it('shows "Dismissed (2 reasons)" when only 2 dismissal reasons are selected', async () => {
await clickDropdownItem('FALSE_POSITIVE', 'ACCEPTABLE_RISK');
expect(findSlotView().text()).toBe('Dismissed (2 reasons)');
});
it('shows "Confirmed +1 more" when confirmed and a dismissal reason are selected', async () => {
await clickDropdownItem('CONFIRMED', 'FALSE_POSITIVE');
expect(findSlotView().text()).toBe('Confirmed +1 more');
});
it('shows "Confirmed +1 more" when confirmed and all dismissal reasons are selected', async () => {
await clickDropdownItem('CONFIRMED', 'DISMISSED');
expect(findSlotView().text()).toBe('Confirmed +1 more');
});
});
});
import { GlFilteredSearch } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import FilteredSearch from 'ee/security_dashboard/components/shared/filtered_search/vulnerability_report_filtered_search.vue';
import StatusToken from 'ee/security_dashboard/components/shared/filtered_search/tokens/status_token.vue';
import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants';
describe('Vulnerability Report Filtered Search component', () => {
let wrapper;
const findFilteredSearchComponent = () => wrapper.findComponent(GlFilteredSearch);
const createWrapper = () => {
wrapper = mountExtended(FilteredSearch);
};
beforeEach(() => {
createWrapper();
});
it('should mount the component with the correct config', () => {
const filteredSearch = findFilteredSearchComponent();
expect(filteredSearch.props('placeholder')).toEqual('Search or filter vulnerabilities...');
expect(filteredSearch.props('value')).toEqual([
{
type: 'status',
value: {
data: StatusToken.DEFAULT_VALUES,
operator: '=',
},
},
]);
expect(filteredSearch.props('availableTokens')).toEqual([
{
type: 'status',
title: 'Status',
multiSelect: true,
unique: true,
token: StatusToken,
operators: OPERATORS_IS,
},
]);
});
});
0% 加载中 .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册