-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #523 from RedHatProductSecurity/feature/OSIDB-3743…
…-cwe-selector-ui ✨ Provide CWE Selector
- Loading branch information
Showing
11 changed files
with
366 additions
and
45 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -76,3 +76,15 @@ table { | |
pointer-events: none; | ||
opacity: 0.5; | ||
} | ||
|
||
.flex-1 { | ||
flex: 1; | ||
} | ||
|
||
.flex-2 { | ||
flex: 2; | ||
} | ||
|
||
.flex-3 { | ||
flex: 3; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,191 @@ | ||
<script setup lang="ts"> | ||
import { ref, onMounted, nextTick } from 'vue'; | ||
import EditableTextWithSuggestions from '@/widgets/EditableTextWithSuggestions/EditableTextWithSuggestions.vue'; | ||
import LabelDiv from '@/widgets/LabelDiv/LabelDiv.vue'; | ||
import type { CWEMemberType } from '@/types/mitreCwe'; | ||
import { DATA_KEY } from '@/services/CweService'; | ||
withDefaults(defineProps<{ | ||
error?: string; | ||
label?: string; | ||
}>(), { | ||
label: '', | ||
error: undefined, | ||
}); | ||
const modelValue = defineModel<null | string>('modelValue', { default: null }); | ||
const queryRef = ref(''); | ||
const cweData = ref<CWEMemberType[]>([]); | ||
const suggestions = ref<CWEMemberType[]>([]); | ||
const selectedIndex = ref(-1); | ||
const loadCweData = () => { | ||
const data = localStorage.getItem(DATA_KEY); | ||
if (data) { | ||
try { | ||
cweData.value = JSON.parse(data); | ||
} catch (e) { | ||
console.error('CWESelector:loadCweData() Failed to parse CWE data:', e); | ||
} | ||
} | ||
}; | ||
const filterSuggestions = (query: string) => { | ||
queryRef.value = query; | ||
const queryParts = query.toLowerCase().split(/(->|\(|\)|\|)/); | ||
const lastQueryPart = queryParts[queryParts.length - 1]; | ||
suggestions.value = cweData.value.filter( | ||
(cwe: CWEMemberType) => | ||
cwe.id.toLowerCase().includes(lastQueryPart) | ||
|| `CWE-${cwe.id}`.toLowerCase().includes(lastQueryPart) | ||
|| cwe.name.toLowerCase().includes(lastQueryPart) | ||
|| cwe.status.toLowerCase().includes(lastQueryPart) | ||
|| cwe.usage.toLowerCase().includes(lastQueryPart), | ||
); | ||
if (suggestions.value.length === 0) { | ||
suggestions.value = cweData.value; | ||
} | ||
}; | ||
const handleSuggestionClick = (fn: (args?: any) => void, suggestion: string) => { | ||
const queryParts = queryRef.value.split(/(->|\(|\)|\|)/); | ||
const lastIndex = queryParts.length - 1; | ||
queryParts[lastIndex] = suggestion; | ||
modelValue.value = queryParts.join(''); | ||
queryRef.value = modelValue.value; | ||
suggestions.value = []; | ||
// Ensure the modelValue is updated before calling the function | ||
nextTick(fn); | ||
}; | ||
const handleKeyDown = (event: KeyboardEvent) => { | ||
if (suggestions.value.length === 0) return; | ||
switch (event.key) { | ||
case 'ArrowDown': | ||
selectedIndex.value = (selectedIndex.value + 1) % suggestions.value.length; | ||
break; | ||
case 'ArrowUp': | ||
selectedIndex.value = (selectedIndex.value - 1 + suggestions.value.length) % suggestions.value.length; | ||
break; | ||
case 'Enter': | ||
if (selectedIndex.value >= 0 && selectedIndex.value < suggestions.value.length) { | ||
handleSuggestionClick(() => {}, `CWE-${suggestions.value[selectedIndex.value].id}`); | ||
} | ||
break; | ||
} | ||
}; | ||
onMounted(loadCweData); | ||
const usageClassMap: { [key: string]: string } = { | ||
'prohibited': 'text-bg-primary', | ||
'allowed': 'text-bg-success', | ||
'allowed-with-review': 'text-bg-danger', | ||
'discouraged': 'text-bg-warning', | ||
}; | ||
const getUsageClass = (usage: string) => { | ||
return usageClassMap[usage.toLowerCase()] ?? 'text-bg-secondary'; | ||
}; | ||
</script> | ||
|
||
<template> | ||
<LabelDiv :label class="mb-2"> | ||
<EditableTextWithSuggestions | ||
v-model="modelValue" | ||
:error | ||
class="col-12" | ||
@update:query="filterSuggestions" | ||
@keydown="handleKeyDown($event)" | ||
> | ||
<template v-if="suggestions.length > 0" #suggestions="{ abort }"> | ||
<div class="dropdown-header"> | ||
<span class="flex-1">ID</span> | ||
<span class="flex-3">Name</span> | ||
<span class="flex-1">Usage</span> | ||
<span class="flex-1"></span> | ||
</div> | ||
<div | ||
v-for="(cwe, index) in suggestions" | ||
:key="index" | ||
class="item gap-1 d-flex justify-content-between" | ||
:class="{'selected': index === selectedIndex }" | ||
@click.prevent.stop="handleSuggestionClick(abort, `CWE-${cwe.id}`)" | ||
@mouseenter="selectedIndex = index" | ||
> | ||
<span class="flex-1">{{ `CWE-${cwe.id} ` }}</span> | ||
<span class="flex-3">{{ `${cwe.name}. ` }}</span> | ||
<span class="badge flex-1" :class="getUsageClass(cwe.usage)">{{ `${cwe.usage}` }}</span> | ||
<div class="flex-1"> | ||
<i v-show="cwe.summary" class="icon bi-info-circle" :title="cwe.summary" /> | ||
<a | ||
:href="`https://cwe.mitre.org/data/definitions/${cwe.id}.html`" | ||
class="icon" | ||
target="_blank" | ||
@click.stop | ||
><i class="bi-box-arrow-up-right" title="View on Mitre" /></a> | ||
</div> | ||
</div> | ||
</template> | ||
</EditableTextWithSuggestions> | ||
</LabelDiv> | ||
</template> | ||
|
||
<style scoped lang="scss"> | ||
.item.selected { | ||
background-color: #dee2e6; | ||
} | ||
.dropdown-header { | ||
display: flex; | ||
justify-content: space-between; | ||
position: sticky; | ||
top: -0.5rem !important; | ||
z-index: 1; | ||
padding: 0.5rem 1rem; | ||
background-color: #f8f9fa; | ||
font-weight: bold; | ||
&:hover { | ||
background-color: #f8f9fa; | ||
cursor: default; | ||
} | ||
} | ||
.badge { | ||
word-break: break-all; | ||
white-space: normal; | ||
} | ||
.badge-red { | ||
background-color: #dc3545; | ||
color: white; | ||
} | ||
.badge-green { | ||
background-color: #28a745; | ||
color: white; | ||
} | ||
.badge-orange { | ||
background-color: #fd7e14; | ||
color: black; | ||
} | ||
.badge-yellow { | ||
background-color: #ffc107; | ||
color: black; | ||
} | ||
.icon { | ||
color: gray; | ||
font-size: 1.25rem; | ||
float: right; | ||
margin-left: 0.5rem; | ||
} | ||
</style> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
import { describe, it, beforeEach } from 'vitest'; | ||
import { flushPromises, type VueWrapper } from '@vue/test-utils'; | ||
|
||
import CweSelector from '@/components/CweSelector/CweSelector.vue'; | ||
|
||
import { mountWithConfig } from '@/__tests__/helpers'; | ||
import { DATA_KEY } from '@/services/CweService'; | ||
|
||
describe('cweSelector.vue', () => { | ||
let wrapper: VueWrapper<any>; | ||
|
||
beforeEach(() => { | ||
wrapper = mountWithConfig(CweSelector); | ||
vi.useFakeTimers(); | ||
}); | ||
|
||
it('renders correctly', () => { | ||
expect(wrapper.exists()).toBe(true); | ||
expect(wrapper.html()).toMatchSnapshot(); | ||
}); | ||
|
||
it('loads CWE data on component mount', async () => { | ||
const data = JSON.stringify([{ id: '123', name: 'Test CWE', status: 'Draft', summary: '', usage: '' }]); | ||
localStorage.setItem(DATA_KEY, data); | ||
wrapper = mountWithConfig(CweSelector); | ||
expect(wrapper.vm.cweData).toEqual([{ id: '123', name: 'Test CWE', status: 'Draft', summary: '', usage: '' }]); | ||
}); | ||
|
||
it('filters suggestions correctly and updates model on suggestion click', async () => { | ||
wrapper.vm.cweData = [ | ||
{ id: '123', name: 'Test CWE', status: 'Draft', summary: '', usage: '' }, | ||
{ id: '456', name: 'Another CWE', status: 'Draft', summary: '', usage: '' }, | ||
]; | ||
const input = wrapper.find('input'); | ||
await input.setValue('123'); | ||
|
||
vi.runAllTimers(); | ||
await flushPromises(); | ||
expect(wrapper.text()).toContain('CWE-123'); | ||
|
||
const suggestionRow = wrapper.findAll('.dropdown-menu .item'); | ||
await suggestionRow[0].trigger('click'); | ||
expect(wrapper.vm.modelValue).toBe('CWE-123'); | ||
expect(wrapper.vm.queryRef).toBe('CWE-123'); | ||
expect(wrapper.vm.suggestions).toEqual([]); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
23 changes: 23 additions & 0 deletions
23
src/components/__tests__/__snapshots__/CweSelector.spec.ts.snap
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html | ||
|
||
exports[`cweSelector.vue > renders correctly 1`] = ` | ||
"<div data-v-0e2ae48e="" data-v-478878c2="" class="osim-input ps-3 mb-2 mb-2"> | ||
<div data-v-0e2ae48e="" class="row"><span data-v-0e2ae48e="" class="form-label col-3 position-relative"><!--v-if--> <!--attrs: {{ $attrs }}--></span> | ||
<div data-v-0e2ae48e="" class="col-9"> | ||
<!-- for invalid-tooltip positioning --> | ||
<div data-v-478878c2="" class="position-relative col-9 osim-editable-field osim-text col-12"> | ||
<!--<Transition name="flash-bg" :duration="2000">--> | ||
<transition-stub name="flash-bg" appear="false" persisted="true" css="true"> | ||
<div class="osim-editable-text" tabindex="0"><span class="osim-editable-text-value form-control"></span> | ||
<!--if a button is inside a label, clicking the label clicks the button?--><button type="button" class="osim-editable-text-pen input-group-text" tabindex="-1"><i class="bi bi-pencil"></i></button> | ||
</div> | ||
</transition-stub> | ||
<div class="input-group position-relative" tabindex="-1" style="display: none;"> | ||
<!--v-if--><input class="form-control" type="text"><button type="button" class="input-group-text" tabindex="-1"><i class="bi bi-check"></i></button><button type="button" class="input-group-text" tabindex="-1"><i class="bi bi-x"></i></button> | ||
</div> | ||
<!--v-if--> | ||
</div> | ||
</div> | ||
</div> | ||
</div>" | ||
`; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.