Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 118 additions & 46 deletions src/components/CRA/SRPReportDetails.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue';

import SRPStatusBadge from '@/components/CRA/SRPStatusBadge.vue';

import type { SRPReport, SRPReportMilestone } from '@/types/cra';
Expand All @@ -16,7 +18,21 @@ const emit = defineEmits<{
'refresh': [];
}>();

function formatTimeRemaining(milestone: any): string {
const expandedMilestones = ref<Set<string>>(new Set());

function toggleExpanded(uuid: string) {
if (expandedMilestones.value.has(uuid)) {
expandedMilestones.value.delete(uuid);
} else {
expandedMilestones.value.add(uuid);
}
}

function isExpanded(uuid: string): boolean {
return expandedMilestones.value.has(uuid);
}

function formatTimeRemaining(milestone: SRPReportMilestone): string {
if (milestone.is_overdue) return 'Overdue';
if (milestone.days_remaining === null) return '-';
const hours = milestone.hours_remaining ? ` ${milestone.hours_remaining % 24}h` : '';
Expand Down Expand Up @@ -75,6 +91,7 @@ async function handleQuickAction(milestone: SRPReportMilestone, action: 'block'
<table class="table table-striped table-hover table-sm mb-0">
<thead class="table-dark">
<tr>
<th style="width: 30px"></th>
<th>Type</th>
<th>Status</th>
<th>Due Date</th>
Expand All @@ -83,66 +100,121 @@ async function handleQuickAction(milestone: SRPReportMilestone, action: 'block'
</tr>
</thead>
<tbody>
<tr
v-for="milestone in report.milestones"
:key="milestone.uuid"
:class="{ 'table-danger': isMilestoneActionable(milestone) }"
>
<td class="ps-4">{{ milestone.milestone_type }}</td>
<td>
<SRPStatusBadge :status="milestone.status" />
</td>
<td>{{ milestone.due_at ? formatDate(new Date(milestone.due_at), false) : 'N/A' }}</td>
<td :class="{ 'text-danger': isMilestoneActionable(milestone) }">
{{ formatTimeRemaining(milestone) }}
</td>
<td>
<div class="btn-group btn-group-sm me-1" role="group">
<button
v-if="milestone.status !== 'submitted'"
type="button"
class="btn btn-success"
title="Mark Submitted"
@click="handleQuickAction(milestone, 'submit')"
>
<i class="bi bi-check-circle"></i>
</button>
<template v-for="milestone in report.milestones" :key="milestone.uuid">
<tr
class="milestone-row"
:class="{ 'table-danger': isMilestoneActionable(milestone) }"
>
<td>
<button
v-if="milestone.status !== 'deferred'"
type="button"
class="btn btn-warning"
title="Defer"
@click="handleQuickAction(milestone, 'defer')"
class="expand-btn btn btn-sm p-0 border-0 bg-transparent"
:aria-label="isExpanded(milestone.uuid) ? 'Collapse details' : 'Expand details'"
:aria-expanded="isExpanded(milestone.uuid)"
@click="toggleExpanded(milestone.uuid)"
>
<i class="bi bi-clock"></i>
<i
class="bi"
:class="isExpanded(milestone.uuid) ? 'bi-chevron-down' : 'bi-chevron-right'"
></i>
</button>
</td>
<td>{{ milestone.milestone_type }}</td>
<td>
<SRPStatusBadge :status="milestone.status" />
</td>
<td>{{ milestone.due_at ? formatDate(new Date(milestone.due_at), false) : 'N/A' }}</td>
<td :class="{ 'text-danger': isMilestoneActionable(milestone) }">
{{ formatTimeRemaining(milestone) }}
</td>
<td>
<div class="btn-group btn-group-sm me-1" role="group">
<button
v-if="milestone.status !== 'submitted'"
type="button"
class="btn btn-success"
title="Mark Submitted"
@click="handleQuickAction(milestone, 'submit')"
@keydown.enter.prevent="handleQuickAction(milestone, 'submit')"
>
<i class="bi bi-check-circle"></i>
</button>
<button
v-if="milestone.status !== 'deferred'"
type="button"
class="btn btn-warning"
title="Defer"
@click="handleQuickAction(milestone, 'defer')"
@keydown.enter.prevent="handleQuickAction(milestone, 'defer')"
>
<i class="bi bi-clock"></i>
</button>
<button
v-if="milestone.status !== 'blocked'"
type="button"
class="btn btn-danger"
title="Block"
@click="handleQuickAction(milestone, 'block')"
@keydown.enter.prevent="handleQuickAction(milestone, 'block')"
>
<i class="bi bi-slash-circle"></i>
</button>
</div>
<button
v-if="milestone.status !== 'blocked'"
type="button"
class="btn btn-danger"
title="Block"
@click="handleQuickAction(milestone, 'block')"
class="btn btn-sm btn-dark"
title="Edit"
@click="emit('edit-milestone', milestone)"
>
<i class="bi bi-slash-circle"></i>
<i class="bi bi-pencil"></i>
</button>
</div>
<button
type="button"
class="btn btn-sm btn-dark"
title="Edit"
@click="emit('edit-milestone', milestone)"
>
<i class="bi bi-pencil"></i>
</button>
</td>
</tr>
</td>
</tr>
<tr v-if="isExpanded(milestone.uuid)" class="milestone-detail">
<td colspan="6" class="p-0">
<div class="px-4 py-3 bg-white border-top">
<div v-if="milestone.request_source" class="mb-2">
<span class="fw-semibold text-muted small">Request Source</span>
<p class="mb-0">{{ milestone.request_source }}</p>
</div>
<div v-if="milestone.request_received_at" class="mb-2">
<span class="fw-semibold text-muted small">Request Received</span>
<p class="mb-0">{{ formatDate(new Date(milestone.request_received_at), false) }}</p>
</div>
<div v-if="milestone.request_text" class="mb-2">
<span class="fw-semibold text-muted small">Request Text</span>
<p class="mb-0 white-space-pre-wrap">{{ milestone.request_text }}</p>
</div>
<div v-if="milestone.manual_completion_notes">
<span class="fw-semibold text-muted small">Notes</span>
<p class="mb-0 white-space-pre-wrap">{{ milestone.manual_completion_notes }}</p>
</div>
<p
v-if="!milestone.request_source && !milestone.request_received_at
&& !milestone.request_text && !milestone.manual_completion_notes"
class="mb-0 text-muted fst-italic"
>
No additional details.
</p>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>

<style scoped>
.milestone-detail td {
border-top: none;
}

.white-space-pre-wrap {
white-space: pre-wrap;
}

.btn:hover {
opacity: 0.85;
}
Expand Down
75 changes: 73 additions & 2 deletions src/components/CRA/__tests__/SRPReportDetails.spec.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,35 @@
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mount } from '@vue/test-utils';

import { mockSRPReport } from '@/components/CRA/__tests__/fixtures';

import type { SRPReport } from '@/types/cra';

import SRPReportDetails from '../SRPReportDetails.vue';

const mockReportWithNotes: SRPReport = {
...mockSRPReport,
milestones: [{
...mockSRPReport.milestones[0],
manual_completion_notes: 'Coordinator note about submission',
request_source: 'ENISA Portal',
request_text: 'Please provide additional CVE details',
}],
};

vi.mock('@/services/SRPService', () => ({
updateSRPMilestone: vi.fn(() => Promise.resolve({})),
}));

describe('sRPReportDetails', () => {
let updateSRPMilestone: ReturnType<typeof vi.fn>;

beforeEach(async () => {
({ updateSRPMilestone } = await import('@/services/SRPService') as any);
vi.mocked(updateSRPMilestone).mockReset();
vi.mocked(updateSRPMilestone).mockResolvedValue({});
});

it('renders milestone table', () => {
const wrapper = mount(SRPReportDetails, {
props: {
Expand Down Expand Up @@ -44,8 +64,59 @@ describe('sRPReportDetails', () => {
expect(wrapper.emitted('edit-milestone')).toBeTruthy();
});

it('hides milestone detail row by default', () => {
const wrapper = mount(SRPReportDetails, { props: { report: mockReportWithNotes } });
expect(wrapper.find('.milestone-detail').exists()).toBe(false);
});

it('shows milestone detail with notes when expand button is clicked', async () => {
const wrapper = mount(SRPReportDetails, { props: { report: mockReportWithNotes } });
await wrapper.find('.expand-btn').trigger('click');
expect(wrapper.find('.milestone-detail').exists()).toBe(true);
expect(wrapper.text()).toContain('Coordinator note about submission');
expect(wrapper.text()).toContain('ENISA Portal');
expect(wrapper.text()).toContain('Please provide additional CVE details');
});

it('collapses milestone detail when expand button is clicked again', async () => {
const wrapper = mount(SRPReportDetails, { props: { report: mockReportWithNotes } });
await wrapper.find('.expand-btn').trigger('click');
expect(wrapper.find('.milestone-detail').exists()).toBe(true);
await wrapper.find('.expand-btn').trigger('click');
expect(wrapper.find('.milestone-detail').exists()).toBe(false);
});

it('activates submit button via keyboard', async () => {
const wrapper = mount(SRPReportDetails, { props: { report: mockSRPReport } });
const submitButton = wrapper.findAll('button').find(btn => btn.html().includes('bi-check-circle'));
await submitButton?.trigger('keydown', { key: 'Enter' });
expect(updateSRPMilestone).toHaveBeenCalled();
vi.mocked(updateSRPMilestone).mockClear();
await submitButton?.trigger('click');
expect(updateSRPMilestone).toHaveBeenCalled();
});

it('activates defer button via keyboard', async () => {
const wrapper = mount(SRPReportDetails, { props: { report: mockSRPReport } });
const deferButton = wrapper.findAll('button').find(btn => btn.html().includes('bi-clock'));
await deferButton?.trigger('keydown', { key: 'Enter' });
expect(updateSRPMilestone).toHaveBeenCalled();
vi.mocked(updateSRPMilestone).mockClear();
await deferButton?.trigger('click');
expect(updateSRPMilestone).toHaveBeenCalled();
});

it('activates block button via keyboard', async () => {
const wrapper = mount(SRPReportDetails, { props: { report: mockSRPReport } });
const blockButton = wrapper.findAll('button').find(btn => btn.html().includes('bi-slash-circle'));
await blockButton?.trigger('keydown', { key: 'Enter' });
expect(updateSRPMilestone).toHaveBeenCalled();
vi.mocked(updateSRPMilestone).mockClear();
await blockButton?.trigger('click');
expect(updateSRPMilestone).toHaveBeenCalled();
});

it('handles quick action errors', async () => {
const { updateSRPMilestone } = await import('@/services/SRPService');
vi.mocked(updateSRPMilestone).mockRejectedValue(new Error('Network error'));
vi.spyOn(console, 'error').mockImplementation(() => {});

Expand Down
15 changes: 15 additions & 0 deletions src/composables/__tests__/useFlawCommentsModel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,21 @@ describe('useFlawCommentsModel', () => {
expect(isSaving.value).toBe(false);
});

it('should append the saved comment to flaw.value.comments', async () => {
flaw.value.comments = [];
const savedComment = {
uuid: 'c1', text: 'new note', is_private: false, creator: 'creator', created_dt: '2026-01-01T00:00:00Z',
};
vi.mocked(postFlawComment).mockResolvedValue(savedComment as any);
const { addFlawComment } = useFlawCommentsModel(flaw, isSaving, afterSaveSuccess);

addFlawComment('new note', 'creator', 'Public');
await flushPromises();

expect(flaw.value.comments).toHaveLength(1);
expect(flaw.value.comments[0]).toMatchObject({ text: 'new note', is_private: false });
});

it('should add an internal comment', async () => {
vi.mocked(postJiraComment).mockResolvedValue({});
const { addFlawComment } = useFlawCommentsModel(flaw, isSaving, afterSaveSuccess);
Expand Down
5 changes: 4 additions & 1 deletion src/composables/useFlawCommentsModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,10 @@ export function useFlawCommentsModel(flaw: Ref<ZodFlawType>, isSaving: Ref<boole
function addOsidbComment(comment: string, creator: string, isPrivate: boolean) {
isSaving.value = true;
postFlawComment(flaw.value.uuid, comment, creator, isPrivate, flaw.value.embargoed)
.then(afterSaveSuccess)
.then((newComment) => {
flaw.value.comments.push(newComment);
afterSaveSuccess();
})
.finally(() => isSaving.value = false);
}

Expand Down
Loading