-
Notifications
You must be signed in to change notification settings - Fork 6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
✨ Provide CWE Selector #523
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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; | ||
} |
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; | ||
superbuggy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
modelValue.value = queryParts.join(''); | ||
queryRef.value = modelValue.value; | ||
suggestions.value = []; | ||
// Ensure the modelValue is updated before calling the function | ||
nextTick(fn); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this to defer/delay the click event until the query has been parsed into a suggestion? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not exactly. That is to ensure the suggestions and query have been updated in the DOM before the optional callback function ( There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That makes sense, would you mind adding a comment to that effect (waiting for 'abort'/'commit' events)? |
||
}; | ||
|
||
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> |
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All the tests make use of the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed, I have been trying to pay closer attention to the tests I write testing the UI and not the business logic of a component. That said, composables make sense to test for business logic though. |
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([]); | ||
}); | ||
}); |
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>" | ||
`; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We're sort of split between usage of
null
andundefined
in the codebase, but maybeundefined
is better as a props default since thenull
values should come from theerrors
inuseFlawModel
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I like what you have here better than the default
null
s we have elsewhere