Skip to content

Commit

Permalink
Merge pull request #523 from RedHatProductSecurity/feature/OSIDB-3743…
Browse files Browse the repository at this point in the history
…-cwe-selector-ui

✨ Provide CWE Selector
  • Loading branch information
C-Valen authored Feb 6, 2025
2 parents 655b5ba + d51bea1 commit 424ea9a
Show file tree
Hide file tree
Showing 11 changed files with 366 additions and 45 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* Support PURLs in affected components (`OSIDB-3412`)
* Increase step value for affects and trackers per page (`OSIDB-3508`)
* Support showing all trackers/affects in single page (`OSIDB-3506`)
* Add suggestions on CWE Field (`OSIDB-3743`)


### Fixed
Expand Down
12 changes: 12 additions & 0 deletions src/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,15 @@ table {
pointer-events: none;
opacity: 0.5;
}

.flex-1 {
flex: 1;
}

.flex-2 {
flex: 2;
}

.flex-3 {
flex: 3;
}
191 changes: 191 additions & 0 deletions src/components/CweSelector/CweSelector.vue
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>
4 changes: 2 additions & 2 deletions src/components/FlawForm/FlawForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import FlawHistory from '@/components/FlawHistory/FlawHistory.vue';
import FlawContributors from '@/components/FlawContributors/FlawContributors.vue';
import CvssExplainForm from '@/components/CvssExplainForm/CvssExplainForm.vue';
import FlawAffects from '@/components/FlawAffects/FlawAffects.vue';
import CweSelector from '@/components/CweSelector/CweSelector.vue';
import { useFlawModel } from '@/composables/useFlawModel';
Expand Down Expand Up @@ -244,10 +245,9 @@ const createdDate = computed(() => {
:allCvss="flaw.cvss_scores"
:nistCvss="nvdCvss3String"
/>
<LabelEditable
<CweSelector
v-model="flaw.cwe_id"
label="CWE ID"
type="text"
:error="errors.cwe_id"
/>
<LabelSelect
Expand Down
47 changes: 47 additions & 0 deletions src/components/__tests__/CweSelector.spec.ts
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([]);
});
});
5 changes: 3 additions & 2 deletions src/components/__tests__/FlawForm.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import FlawForm from '@/components/FlawForm/FlawForm.vue';
import CvssCalculator from '@/components/CvssCalculator/CvssCalculator.vue';
import FlawFormOwner from '@/components/FlawFormOwner/FlawFormOwner.vue';
import IssueFieldEmbargo from '@/components/IssueFieldEmbargo/IssueFieldEmbargo.vue';
import CweSelector from '@/components/CweSelector/CweSelector.vue';

import { blankFlaw } from '@/composables/useFlaw';

Expand Down Expand Up @@ -106,7 +107,7 @@ describe('flawForm', () => {
expect(nvdCvssField?.exists()).toBe(true);

const cweIdField = subject
.findAllComponents(LabelEditable)
.findAllComponents(CweSelector)
.find(component => component.props().label === 'CWE ID');
expect(cweIdField?.exists()).toBe(true);

Expand Down Expand Up @@ -187,7 +188,7 @@ describe('flawForm', () => {
expect(nvdCvssField?.exists()).toBe(true);

const cweIdField = subject
.findAllComponents(LabelEditable)
.findAllComponents(CweSelector)
.find(component => component.props().label === 'CWE ID');
expect(cweIdField?.exists()).toBe(true);

Expand Down
23 changes: 23 additions & 0 deletions src/components/__tests__/__snapshots__/CweSelector.spec.ts.snap
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>"
`;
30 changes: 17 additions & 13 deletions src/components/__tests__/__snapshots__/FlawForm.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -196,22 +196,26 @@ exports[`flawForm > mounts and renders 1`] = `
</div>
</div>
<!--v-if-->
</div><label data-v-76509415="" data-v-76e7a15d="" class="osim-input ps-3 mb-2 input-group">
<div data-v-76509415="" class="row"><span data-v-76509415="" class="form-label col-3">CWE ID</span>
<!--v-if-->
<!-- for invalid-tooltip positioning -->
<div data-v-76509415="" class="position-relative col-9 osim-editable-field osim-text">
<!--<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">CWE-1</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>
<div data-v-0e2ae48e="" data-v-478878c2="" data-v-76e7a15d="" 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--> CWE ID <!--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">CWE-1</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>
</transition-stub>
<div class="input-group" style="display: none;"><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-->
<!--v-if-->
</div>
</div>
</div>
</label><label data-v-001a00e0="" data-v-76e7a15d="" class="osim-input mb-2 ps-3">
</div><label data-v-001a00e0="" data-v-76e7a15d="" class="osim-input mb-2 ps-3">
<div data-v-001a00e0="" class="row"><span data-v-001a00e0="" class="form-label col-3">CVE Source</span>
<div data-v-001a00e0="" class="col-9"><select data-v-001a00e0="" class="form-select" value="GIT">
<!--v-if-->
Expand Down
Loading

0 comments on commit 424ea9a

Please sign in to comment.