Skip to content
Merged
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
93 changes: 92 additions & 1 deletion src/components/EvidenceList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<colgroup>
<col class="w-20" />
<col class="w-[30%]" />
<col v-if="showSubjects" class="w-[25%]" />
<col class="w-48" />
<col v-if="configStore.showLabels" />
</colgroup>
Expand Down Expand Up @@ -42,6 +43,9 @@
<span class="sr-only">{{ getSortLabel('name') }}</span>
</button>
</th>
<th v-if="showSubjects" class="py-2 px-2 text-left text-sm font-medium">
Subjects
</th>
<th
class="py-2 px-2 text-left text-sm font-medium"
:aria-sort="getAriaSort('lastSeenAt')"
Expand Down Expand Up @@ -90,6 +94,52 @@
{{ item.title }}
</RouterLink>
</td>
<td
v-if="showSubjects"
class="py-2 px-2 min-w-0"
data-testid="subjects-cell"
>
<div
v-if="item.subjectReferences?.length"
class="flex min-w-0 flex-wrap items-center gap-1"
>
<!-- Styled like a Chip, but truncates long titles; the tooltip shows them in full. -->
<span
v-for="subject in item.subjectReferences.slice(
0,
SUBJECT_PREVIEW_LIMIT,
)"
:key="subject.subjectUuid"
v-tooltip.top="subjectTitle(subject)"
class="inline-block min-w-0 max-w-full truncate rounded-2xl bg-ccf-100 px-3 py-2 text-sm text-ccf-900 dark:bg-slate-800 dark:text-slate-300"
data-testid="subject-chip"
>
{{ subjectTitle(subject) }}
</span>
<button
v-if="item.subjectReferences.length > SUBJECT_PREVIEW_LIMIT"
type="button"
class="inline-block shrink-0 rounded-md border border-slate-200 bg-white px-2 py-1 text-sm text-gray-600 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-400"
:aria-label="remainingSubjectsAriaLabel(item.subjectReferences)"
data-testid="subjects-more"
@click.stop="toggleSubjects($event, item.subjectReferences)"
>
+{{ item.subjectReferences.length - SUBJECT_PREVIEW_LIMIT }} more
{{
item.subjectReferences.length - SUBJECT_PREVIEW_LIMIT === 1
? 'subject'
: 'subjects'
}}
</button>
</div>
<span
v-else
class="inline-flex items-center gap-1 text-sm text-amber-700 dark:text-amber-400"
>
<BIconExclamationTriangle aria-hidden="true" />
Unattributed
</span>
</td>
<td class="py-2 px-2 whitespace-nowrap">
{{ formatDateTime(item.end) }}
</td>
Expand Down Expand Up @@ -156,6 +206,19 @@
/>
</div>
</Popover>
<Popover ref="subjectsOp" class="max-w-[40rem]">
<div
class="flex gap-2 items-center flex-wrap"
data-testid="subjects-popover"
>
<Chip
class="mx-0.5 max-w-full whitespace-normal text-sm [overflow-wrap:anywhere]"
v-for="(subject, index) in popoverSubjects"
:key="`popover-subject-${index}-${subject}`"
:label="subject"
/>
</div>
</Popover>
</template>
<script setup lang="ts">
import ResultStatusRing from '@/components/ResultStatusRing.vue';
Expand All @@ -169,8 +232,9 @@ import type {
EvidenceSortBy,
SortDirection,
} from '@/stores/evidence.ts';
import type { SubjectReference } from '@/oscal';
import Chip from '@/volt/Chip.vue';
import { BIconEye } from 'bootstrap-icons-vue';
import { BIconExclamationTriangle, BIconEye } from 'bootstrap-icons-vue';

const props = withDefaults(
defineProps<{
Expand All @@ -192,11 +256,38 @@ const emit = defineEmits<{

const popoverLabels = ref<string[]>([]);
const op = ref();
const popoverSubjects = ref<string[]>([]);
const subjectsOp = ref();

const configStore = useConfigStore();
const route = useRoute();
const router = useRouter();
const LABEL_PREVIEW_LIMIT = 5;
// The Subjects column shows the first subjects as chips, then "+N" for the rest.
const SUBJECT_PREVIEW_LIMIT = 2;

// Rows carry subjectReferences only when the API supports subjects; without it the column is
// left out rather than showing every row as unattributed.
const showSubjects = computed(() =>
props.evidence.some((item) => item.subjectReferences !== undefined),
);

function subjectTitle(subject: SubjectReference) {
return subject.title || subject.subjectUuid;
}

function remainingSubjectsAriaLabel(subjects: SubjectReference[]) {
const remaining = subjects.slice(SUBJECT_PREVIEW_LIMIT).map(subjectTitle);
return `View all subjects. +${remaining.length} more ${
remaining.length === 1 ? 'subject' : 'subjects'
}: ${remaining.join('; ')}`;
}

// Opens the popover listing all of a row's subjects in full, like the labels popover.
function toggleSubjects(event: Event, subjects: SubjectReference[]) {
popoverSubjects.value = subjects.map(subjectTitle);
subjectsOp.value?.toggle?.(event);
}

interface LabelPreview {
preview: string[];
Expand Down
146 changes: 146 additions & 0 deletions src/components/__tests__/EvidenceList.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,4 +413,150 @@ describe('EvidenceList', () => {
expect(showHiddenPreview.text()).toContain('_internal=secret');
expect(showHiddenPreview.text()).toContain('resource_id=visible-resource');
});

describe('Subjects column', () => {
const baseEvidence = {
start: '2026-04-15T00:00:00Z',
end: '2026-04-15T01:00:00Z',
status: { reason: 'test', state: 'satisfied' },
labels: [],
activities: [],
};

function mountWithSubjects(evidence: Record<string, unknown>[]) {
return mount(EvidenceList, {
props: {
evidence: evidence.map((item) => ({ ...baseEvidence, ...item })),
} as never,
global: {
directives: {
tooltip: {
mounted(el: HTMLElement, binding: { value: string }) {
el.setAttribute('data-tooltip', binding.value);
},
},
},
stubs: {
RouterLink: { props: ['to'], template: '<a><slot /></a>' },
ResultStatusRing: { template: '<span />' },
Popover: { template: '<div><slot /></div>' },
Chip: {
props: ['label'],
template: '<span>{{ label }}</span>',
},
BIconEye: { template: '<span />' },
BIconExclamationTriangle: { template: '<span />' },
},
},
});
}

it('is hidden when the API does not return subjects', () => {
const wrapper = mountWithSubjects([
{ id: 'evidence-1', uuid: 'stream-1', title: 'Older API evidence' },
]);

expect(wrapper.text()).not.toContain('Subjects');
expect(wrapper.find('[data-testid="subjects-cell"]').exists()).toBe(
false,
);
});

it('shows the first two subjects as chips, then +N for the rest', () => {
const wrapper = mountWithSubjects([
{
id: 'evidence-1',
uuid: 'stream-1',
title: 'Branch protection on default branch',
subjectReferences: [
{ subjectUuid: 's-1', type: 'component', title: 'repo: api' },
{ subjectUuid: 's-2', type: 'component', title: 'acme' },
{ subjectUuid: 's-3', type: 'component', title: 'branch: main' },
{ subjectUuid: 's-4', type: 'component' },
],
},
]);

expect(wrapper.find('thead').text()).toContain('Subjects');
const chips = wrapper.findAll('[data-testid="subject-chip"]');
expect(chips.map((chip) => chip.text())).toEqual(['repo: api', 'acme']);
for (const chip of chips) {
expect(chip.classes(), 'long titles are cut short').toContain(
'truncate',
);
expect(
chip.attributes('data-tooltip'),
'hover shows the full title',
).toBe(chip.text());
}

const more = wrapper.get('[data-testid="subjects-more"]');
expect(more.element.tagName).toBe('BUTTON');
expect(more.text()).toBe('+2 more subjects');
expect(
more.attributes('data-tooltip'),
'the button opens the subjects popover rather than showing a tooltip',
).toBeUndefined();
expect(more.attributes('aria-label')).toBe(
'View all subjects. +2 more subjects: branch: main; s-4',
);
});

it("lists all of a row's subjects in full when +N is clicked", async () => {
const wrapper = mountWithSubjects([
{
id: 'evidence-1',
uuid: 'stream-1',
title: 'Branch protection on default branch',
subjectReferences: [
{ subjectUuid: 's-1', type: 'component', title: 'repo: api' },
{ subjectUuid: 's-2', type: 'component', title: 'acme' },
{ subjectUuid: 's-3', type: 'component', title: 'branch: main' },
],
},
]);

const popover = wrapper.get('[data-testid="subjects-popover"]');
expect(popover.text()).toBe('');

await wrapper.get('[data-testid="subjects-more"]').trigger('click');

expect(wrapper.get('[data-testid="subjects-more"]').text()).toBe(
'+1 more subject',
);
expect(popover.findAll('span').map((chip) => chip.text())).toEqual([
'repo: api',
'acme',
'branch: main',
]);
expect(
pushMock,
'clicking +N does not open the evidence',
).not.toHaveBeenCalled();
});

it('marks evidence without subjects as unattributed', () => {
const wrapper = mountWithSubjects([
{
id: 'evidence-1',
uuid: 'stream-1',
title: 'All teams use closed visibility',
subjectReferences: [
{ subjectUuid: 's-1', type: 'component', title: 'acme' },
],
},
{
id: 'evidence-2',
uuid: 'stream-2',
title: 'Legacy plugin check',
subjectReferences: [],
},
]);

const cells = wrapper.findAll('[data-testid="subjects-cell"]');
expect(cells).toHaveLength(2);
expect(cells[0]!.text()).toBe('acme');
expect(cells[1]!.text()).toContain('Unattributed');
});
});
});
64 changes: 64 additions & 0 deletions src/components/evidence/SubjectFilter.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<template>
<div v-if="!unsupported" class="flex items-center gap-1">
<AutoComplete
:modelValue="modelValue"
:suggestions="suggestions"
optionLabel="title"
placeholder="Subject: any"
dropdown
forceSelection
class="w-64"
aria-label="Filter by subject"
@complete="search($event.query)"
@update:modelValue="onChange"
>
<template #item="{ item }">
<div class="flex flex-col">
<span class="font-medium text-gray-900 dark:text-slate-100">
{{ item.title }}
</span>
<span class="text-xs text-gray-500 dark:text-slate-400">
{{ describeSubject(item) }}
</span>
</div>
</template>
</AutoComplete>
<TertiaryButton
v-if="modelValue"
type="button"
class="!px-1"
aria-label="Clear subject filter"
@click="emit('update:modelValue', null)"
>
<BIconX />
</TertiaryButton>
</div>
</template>

<script setup lang="ts">
import { BIconX } from 'bootstrap-icons-vue';
import AutoComplete from '@/volt/AutoComplete.vue';
import TertiaryButton from '@/volt/TertiaryButton.vue';
import { useSubjectSearch } from '@/composables/subjects/useSubjectSearch';
import { describeSubject, type SubjectSummary } from '@/types/subjects';

defineProps<{
modelValue: SubjectSummary | null;
}>();

const emit = defineEmits<{
'update:modelValue': [subject: SubjectSummary | null];
}>();

const { suggestions, unsupported, search } = useSubjectSearch();

// While typing, the AutoComplete reports the text; only a picked subject, or an emptied
// input, changes the filter.
function onChange(value: SubjectSummary | string | null | undefined) {
if (value && typeof value === 'object') {
emit('update:modelValue', value);
} else if (!value) {
emit('update:modelValue', null);
}
}
</script>
Loading
Loading